packages feed

hanalyze-0.2.0.0: src/Hanalyze/Model/GARCH.hs

{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE BangPatterns #-}
-- |
-- Module      : Hanalyze.Model.GARCH
-- Description : GARCH(1,1) 条件付き分散モデル (Generalized AutoRegressive Conditional Heteroskedasticity)
-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
-- License     : BSD-3-Clause
--
-- GARCH(1,1) — Generalized AutoRegressive Conditional Heteroskedasticity.
--
-- Bollerslev (1986). Models time-varying conditional variance for a
-- (de-meaned) return series:
--
-- @
--   y_t       = μ + ε_t,   ε_t = σ_t · z_t,   z_t ~ N(0, 1)
--   σ²_t      = ω + α · ε²_{t-1} + β · σ²_{t-1}
-- @
--
-- Constraints: @ω > 0, α ≥ 0, β ≥ 0, α + β < 1@ (stationarity).
--
-- Estimation by quasi-MLE under Gaussian innovations, optimized with
-- L-BFGS using numeric gradients. The constraints are enforced via a
-- reparametrization (softplus for ω, a stick-breaking sigmoid pair for
-- α and β capped at @0.999@).
--
-- @
-- import Hanalyze.Model.GARCH
--
-- let fit = fitGARCH ys                    -- GARCH(1,1) on the series
--     vh  = forecastGARCH fit 10           -- 10-step ahead σ² forecast
-- @
--
-- == Implemented
--
--   * 'fitGARCH' (GARCH(1,1) Gaussian QMLE)
--   * 'forecastGARCH' (h-step-ahead conditional variance)
module Hanalyze.Model.GARCH
  ( GARCHFit (..)
  , fitGARCH
  , forecastGARCH
  ) where

import qualified Numeric.LinearAlgebra      as LA
import qualified Hanalyze.Optim.LBFGS       as LBFGS
import qualified Hanalyze.Optim.Common      as OC
import           System.IO.Unsafe           (unsafePerformIO)

-- ---------------------------------------------------------------------------
-- Types
-- ---------------------------------------------------------------------------

-- | Fitted GARCH(1,1) model.
data GARCHFit = GARCHFit
  { gOmega      :: !Double            -- ^ Unconditional variance offset @ω@.
  , gAlpha      :: !Double            -- ^ ARCH coefficient @α@.
  , gBeta       :: !Double            -- ^ GARCH coefficient @β@.
  , gMu         :: !Double            -- ^ Mean of @y_t@.
  , gSigma2     :: !(LA.Vector Double) -- ^ In-sample conditional variance @σ²_t@.
  , gResiduals  :: !(LA.Vector Double) -- ^ In-sample residuals @ε_t = y_t - μ@.
  , gLogLik     :: !Double            -- ^ Maximized Gaussian log-likelihood.
  } deriving (Show)

-- ---------------------------------------------------------------------------
-- Reparametrization helpers
-- ---------------------------------------------------------------------------

softplus :: Double -> Double
softplus x
  | x >  50 = x
  | x < -50 = exp x
  | otherwise = log1p (exp x)
  where log1p z = log (1 + z)

sigmoid :: Double -> Double
sigmoid x
  | x >  500 = 1
  | x < -500 = 0
  | otherwise = 1 / (1 + exp (-x))

-- | Map unconstrained @(θω, θα, θβ)@ to @(ω, α, β)@.
unpackParams :: Double -> Double -> Double -> (Double, Double, Double)
unpackParams t0 t1 t2 =
  let w   = softplus t0
      s   = sigmoid t1                -- total persistence ∈ (0, 1)
      sab = s * 0.999                 -- α + β strictly < 1
      r   = sigmoid t2                -- α-share of total ∈ (0, 1)
      a   = sab * r
      b   = sab * (1 - r)
  in (w, a, b)

-- Inverse of 'unpackParams' for warm-starting from a feasible point.
packParams :: Double -> Double -> Double -> (Double, Double, Double)
packParams w a b =
  let !sab = a + b
      !t0  = invSoftplus w
      !t1  = invSigmoid (sab / 0.999)
      !r   = if sab > 0 then a / sab else 0.5
      !t2  = invSigmoid r
  in (t0, t1, t2)
  where
    invSoftplus y
      | y > 50    = y
      | otherwise = log (exp y - 1)
    invSigmoid p =
      let pc = min 0.99999 (max 1e-5 p)
      in log (pc / (1 - pc))

-- ---------------------------------------------------------------------------
-- Recursion
-- ---------------------------------------------------------------------------

-- | Run the GARCH(1,1) σ² recursion. @σ²_0@ is initialized to the sample
-- variance of @ε@ (a standard QMLE starting choice; alternatives such as
-- the unconditional variance @ω/(1-α-β)@ are equivalent in the limit).
recurseSigma2
  :: Double            -- ^ ω.
  -> Double            -- ^ α.
  -> Double            -- ^ β.
  -> LA.Vector Double  -- ^ ε.
  -> LA.Vector Double  -- ^ σ² of same length as ε.
recurseSigma2 !w !a !b eps =
  let n      = LA.size eps
      var0   = LA.dot eps eps / fromIntegral n
      sig0   = max 1e-12 var0
      step !s2Prev !ePrev = w + a * ePrev * ePrev + b * s2Prev
      go !i !s2Prev acc
        | i >= n   = reverse acc
        | otherwise =
            let !s2 = if i == 0
                       then sig0
                       else step s2Prev (LA.atIndex eps (i - 1))
            in go (i + 1) s2 (s2 : acc)
  in LA.fromList (go 0 0 [])

-- | Negative Gaussian log-likelihood (to be minimized).
negLL
  :: LA.Vector Double  -- ^ ε.
  -> Double            -- ^ ω.
  -> Double            -- ^ α.
  -> Double            -- ^ β.
  -> Double
negLL eps w a b =
  let s2 = recurseSigma2 w a b eps
      n  = LA.size eps
      ll = sum [ let s = max 1e-12 (LA.atIndex s2 i)
                     e = LA.atIndex eps i
                 in log (2 * pi * s) + e * e / s
               | i <- [0 .. n - 1] ]
  in 0.5 * ll

-- ---------------------------------------------------------------------------
-- Fitting
-- ---------------------------------------------------------------------------

-- | Fit a GARCH(1,1) model to @y@ by Gaussian QMLE. The mean @μ@ is
-- estimated as the sample mean; ω/α/β are jointly optimized by L-BFGS
-- with numeric gradients in an unconstrained reparametrization.
--
-- Starting values: @α = 0.05@, @β = 0.90@, @ω = (1 - α - β) · Var(ε)@
-- (so that the unconditional variance matches the sample variance).
fitGARCH :: LA.Vector Double -> GARCHFit
fitGARCH y =
  let n     = LA.size y
      mu    = LA.sumElements y / fromIntegral n
      eps   = y - LA.scalar mu
      var0  = LA.dot eps eps / fromIntegral n
      a0    = 0.05
      b0    = 0.90
      w0    = max 1e-8 ((1 - a0 - b0) * var0)
      (t00, t10, t20) = packParams w0 a0 b0
      objL [t0, t1, t2] =
        let (w, a, b) = unpackParams t0 t1 t2
        in negLL eps w a b
      objL _ = error "fitGARCH: expected 3 parameters"
      cfg   = LBFGS.defaultLBFGSConfig
      res   = unsafePerformIO (LBFGS.runLBFGSNumeric cfg objL [t00, t10, t20])
      [t0, t1, t2] = OC.orBest res
      (w, a, b) = unpackParams t0 t1 t2
      s2    = recurseSigma2 w a b eps
  in GARCHFit
       { gOmega     = w
       , gAlpha     = a
       , gBeta      = b
       , gMu        = mu
       , gSigma2    = s2
       , gResiduals = eps
       , gLogLik    = negate (OC.orValue res)
       }

-- ---------------------------------------------------------------------------
-- Forecasting
-- ---------------------------------------------------------------------------

-- | @h@-step-ahead conditional variance forecast. The recursion is
--
-- @
--   σ²_{T+1} = ω + α · ε²_T + β · σ²_T
--   σ²_{T+k} = ω + (α + β) · σ²_{T+k-1}    (k ≥ 2)
-- @
--
-- so that the forecast converges to the unconditional variance
-- @ω / (1 - α - β)@.
forecastGARCH :: GARCHFit -> Int -> LA.Vector Double
forecastGARCH fit h
  | h <= 0    = LA.fromList []
  | otherwise =
      let w   = gOmega fit
          a   = gAlpha fit
          b   = gBeta fit
          s2  = gSigma2 fit
          eps = gResiduals fit
          n   = LA.size s2
          sT  = LA.atIndex s2 (n - 1)
          eT  = LA.atIndex eps (n - 1)
          s1  = w + a * eT * eT + b * sT
          go !k !prev
            | k > h     = []
            | k == 1    = s1 : go 2 s1
            | otherwise =
                let !nxt = w + (a + b) * prev
                in nxt : go (k + 1) nxt
      in LA.fromList (go 1 0)