#!/usr/bin/env python3
# coding: utf-8
r""" Maximum-likelihood driver for GARCH-family volatility models.
Exposes :func:`fit_volatility` — a :mod:`scipy.optimize`-based maximum-likelihood
fit of a GARCH / GJR-GARCH / EGARCH(1, 1) model on a return series — and its
result container :class:`VolatilityResult`, which carries the fitted parameters,
standard errors, information criteria, the in-sample conditional volatility and
standardized residuals, plus closed-form / Monte-Carlo variance forecasting and
model simulation.
The single authoritative conditional-variance recursions and log-likelihood live
in :mod:`fynance.models.econometric_models` (the Numba kernels ``_gjr_garch`` /
``_egarch`` and :func:`~fynance.models.econometric_models.loglik_garch`). This
module only wires the optimiser, the standard errors and the forecasting /
simulation on top; it does **not** re-derive the recursions or the parameter
layout.
Parameter layout
----------------
The flat parameter vector handed to :func:`~fynance.models.econometric_models.\
loglik_garch` is (variance parameters first, ``nu`` appended last for ``'t'``):
- ``model='garch'`` : ``(omega, alpha, beta)``
- ``model='gjr'`` : ``(omega, alpha, gamma, beta)``
- ``model='egarch'``: ``(omega, alpha, gamma, beta)``
with ``nu`` (``> 2``) appended when ``dist='t'``.
"""
from __future__ import annotations
# Built-in packages
import math
from dataclasses import dataclass
# Third-party packages
import numpy as np
from numpy.typing import ArrayLike, NDArray
from scipy.optimize import minimize
# Local packages
from fynance.models.econometric_models import (
_egarch,
_gjr_garch,
_mean_abs_standardized_t,
loglik_garch,
)
__all__ = ['VolatilityResult', 'fit_volatility']
# E|z| for a standard-normal innovation (EGARCH asymmetry-centering constant).
_SQRT_2_OVER_PI = math.sqrt(2.0 / math.pi)
# Stationarity is enforced strictly inside the open region via this slack.
_EPS_STAT = 1e-6
# =========================================================================== #
# PARAMETER PLUMBING #
# =========================================================================== #
def _param_names(model: str, dist: str) -> list[str]:
""" Ordered parameter names matching the flat kernel vector. """
if model == 'garch':
names = ['omega', 'alpha', 'beta']
elif model in ('gjr', 'egarch'):
names = ['omega', 'alpha', 'gamma', 'beta']
else:
raise ValueError(f"Unknown model: {model!r}")
if dist == 't':
names = names + ['nu']
elif dist != 'normal':
raise ValueError(f"Unknown dist: {dist!r}")
return names
def _unpack(params: dict[str, float], model: str) -> tuple[float, float, float,
float]:
""" Return ``(omega, alpha, gamma, beta)`` from a parameter mapping. """
omega = float(params['omega'])
alpha = float(params['alpha'])
beta = float(params['beta'])
gamma = float(params.get('gamma', 0.0))
return omega, alpha, gamma, beta
def _filter_vol(
y: NDArray[np.float64], model: str, dist: str, params: dict[str, float],
) -> NDArray[np.float64]:
""" Forward-filter the conditional volatility with the right kernel.
Runs the authoritative Numba recursion (``_gjr_garch`` for garch / gjr,
``_egarch`` for egarch) over the mean-zero series ``y`` and returns the
conditional standard deviation :math:`\\sigma_t`.
Parameters
----------
y : numpy.ndarray
Mean-zero innovation (return) series.
model : {'garch', 'gjr', 'egarch'}
Conditional-variance specification.
dist : {'normal', 't'}
Innovation density (only used by egarch, to center ``|z|``).
params : dict of str to float
Fitted parameters (``omega, alpha, [gamma,] beta, [nu]``).
Returns
-------
numpy.ndarray
Conditional standard deviation :math:`\\sigma_t`, same length as ``y``.
"""
omega, alpha, gamma, beta = _unpack(params, model)
if model == 'garch':
return np.asarray(_gjr_garch(y, omega, alpha, 0.0, beta))
if model == 'gjr':
return np.asarray(_gjr_garch(y, omega, alpha, gamma, beta))
if model == 'egarch':
if dist == 't':
mean_abs_z = _mean_abs_standardized_t(float(params['nu']))
else:
mean_abs_z = _SQRT_2_OVER_PI
return np.asarray(_egarch(y, omega, alpha, gamma, beta, mean_abs_z))
raise ValueError(f"Unknown model: {model!r}")
def _draw(rng: np.random.Generator, dist: str, nu: float,
size: int) -> NDArray[np.float64]:
""" Draw ``size`` unit-variance innovations from ``dist``. """
if dist == 'normal':
return rng.standard_normal(size)
if dist == 't':
# standard_t has variance nu / (nu - 2); rescale to unit variance.
return rng.standard_t(nu, size) * math.sqrt((nu - 2.0) / nu)
raise ValueError(f"Unknown dist: {dist!r}")
# =========================================================================== #
# OPTIMISER SETUP #
# =========================================================================== #
def _neg_loglik(params: NDArray[np.float64], y: NDArray[np.float64],
model: str, dist: str) -> float:
""" Negative log-likelihood (cost to minimise); +1e10 on inadmissible.
The egarch log-variance recursion can underflow to a zero conditional
volatility for extreme parameters, which the Numba kernel surfaces as a
``ZeroDivisionError`` (its ``error_model`` mirrors Python); such points are
treated as inadmissible (the optimiser is repelled with the same penalty).
"""
try:
ll = loglik_garch(params, y, model, dist)
except (ZeroDivisionError, FloatingPointError):
return 1e10
if not np.isfinite(ll):
return 1e10
return -ll
def _x0_heuristic(y: NDArray[np.float64], model: str, dist: str,
x0: ArrayLike | None) -> NDArray[np.float64]:
r""" Initial parameter vector via variance targeting.
With ``alpha = 0.05``, ``beta = 0.90``, ``gamma = 0.05`` and (for ``'t'``)
``nu = 8``, variance targeting sets ``omega`` so the model's unconditional
variance matches the sample variance: for garch / gjr this is
``omega = var * (1 - alpha - beta)``. For egarch ``omega`` is the
*log-variance* intercept, so the same targeting is applied in log space,
``omega = ln(var) * (1 - beta)`` (its unconditional log-variance is
``omega / (1 - beta)``). An explicit ``x0`` overrides the heuristic.
"""
if x0 is not None:
return np.asarray(x0, dtype=np.float64).reshape(-1)
var = float(np.var(y))
if var <= 0.0:
var = 1e-8
alpha, beta, gamma, nu = 0.05, 0.90, 0.05, 8.0
if model == 'garch':
core = [var * (1.0 - alpha - beta), alpha, beta]
elif model == 'gjr':
core = [var * (1.0 - alpha - beta), alpha, gamma, beta]
elif model == 'egarch':
core = [math.log(var) * (1.0 - beta), alpha, gamma, beta]
else:
raise ValueError(f"Unknown model: {model!r}")
if dist == 't':
core = core + [nu]
return np.asarray(core, dtype=np.float64)
def _bounds_and_constraints(
model: str, dist: str,
) -> tuple[list[tuple[float | None, float | None]], list[dict]]:
""" Box bounds and stationarity constraints for the SLSQP optimiser. """
bounds: list[tuple[float | None, float | None]]
if model == 'garch':
bounds = [(1e-12, None), (0.0, 1.0), (0.0, 1.0)]
# alpha + beta < 1.
cons = [{'type': 'ineq',
'fun': lambda p: 1.0 - _EPS_STAT - p[1] - p[2]}]
elif model == 'gjr':
bounds = [(1e-12, None), (0.0, 1.0), (-1.0, 1.0), (0.0, 1.0)]
# alpha + gamma >= 0 (variance non-negativity) and
# alpha + beta + gamma / 2 < 1 (stationarity, symmetric innovations).
cons = [
{'type': 'ineq', 'fun': lambda p: p[1] + p[2]},
{'type': 'ineq',
'fun': lambda p: 1.0 - _EPS_STAT - p[1] - p[3] - 0.5 * p[2]},
]
elif model == 'egarch':
# Log-variance form: only |beta| < 1 is required (box bound below).
bounds = [(None, None), (None, None), (None, None), (-0.999, 0.999)]
cons = []
else:
raise ValueError(f"Unknown model: {model!r}")
if dist == 't':
bounds = bounds + [(2.05, None)]
return bounds, cons
def _numerical_hessian(x: NDArray[np.float64], y: NDArray[np.float64],
model: str, dist: str) -> NDArray[np.float64]:
""" Central finite-difference Hessian of the negative log-likelihood. """
n = x.size
step = 1e-4 * np.maximum(np.abs(x), 1e-3)
f0 = _neg_loglik(x, y, model, dist)
hess = np.empty((n, n))
for i in range(n):
ei = np.zeros(n)
ei[i] = step[i]
for j in range(i, n):
if i == j:
fp = _neg_loglik(x + ei, y, model, dist)
fm = _neg_loglik(x - ei, y, model, dist)
hess[i, i] = (fp - 2.0 * f0 + fm) / (step[i] ** 2)
else:
ej = np.zeros(n)
ej[j] = step[j]
fpp = _neg_loglik(x + ei + ej, y, model, dist)
fpm = _neg_loglik(x + ei - ej, y, model, dist)
fmp = _neg_loglik(x - ei + ej, y, model, dist)
fmm = _neg_loglik(x - ei - ej, y, model, dist)
val = (fpp - fpm - fmp + fmm) / (4.0 * step[i] * step[j])
hess[i, j] = val
hess[j, i] = val
return hess
def _std_errors(x: NDArray[np.float64], y: NDArray[np.float64],
model: str, dist: str) -> dict[str, float]:
""" Standard errors from the inverse observed-information (Hessian).
Inverts the numerical Hessian of the negative log-likelihood at the
optimum. A non-positive-definite Hessian (or a singular inversion) yields
``NaN`` standard errors rather than raising.
"""
names = _param_names(model, dist)
se = np.full(x.size, np.nan)
hess = _numerical_hessian(x, y, model, dist)
if np.all(np.isfinite(hess)):
try:
# A max of the log-likelihood has a positive-definite Hessian of
# the *negative* log-likelihood; cholesky guards that.
np.linalg.cholesky(hess)
diag = np.diag(np.linalg.inv(hess))
se = np.where(diag > 0.0, np.sqrt(diag), np.nan)
except np.linalg.LinAlgError:
pass
return dict(zip(names, se))
# =========================================================================== #
# RESULT #
# =========================================================================== #
[docs]
@dataclass
class VolatilityResult:
r""" Fitted GARCH-family volatility model (see :func:`fit_volatility`).
Attributes
----------
params : dict of str to float
Fitted parameters, keyed by name (``omega, alpha, [gamma,] beta,
[nu]``).
std_errors : dict of str to float
Standard error of each parameter (``NaN`` when the observed-information
Hessian is not positive definite).
loglik : float
Maximised log-likelihood.
aic, bic : float
Akaike / Bayesian information criteria, ``2k - 2ll`` and
``k ln(n) - 2ll`` with ``k`` parameters and ``n`` observations.
conditional_vol : numpy.ndarray
In-sample conditional standard deviation :math:`\sigma_t`, shape
``(n_obs,)``.
std_residuals : numpy.ndarray
Standardized residuals :math:`y_t / \sigma_t`, shape ``(n_obs,)``.
model : {'garch', 'gjr', 'egarch'}
Conditional-variance specification.
dist : {'normal', 't'}
Innovation density.
n_obs : int
Number of observations the model was fit on.
"""
params: dict[str, float]
std_errors: dict[str, float]
loglik: float
aic: float
bic: float
conditional_vol: NDArray[np.float64]
std_residuals: NDArray[np.float64]
model: str
dist: str
n_obs: int
[docs]
def forecast(self, h: int = 1, n_sims: int = 10_000,
seed: int = 0) -> NDArray[np.float64]:
r""" Multi-step conditional-variance forecast.
Returns :math:`\mathbb E[\sigma_{T+k}^2 \mid \mathcal F_{T-1}]` for
``k = 1, \dots, h`` (``T = n_obs``). The one-step value is exactly the
filter's next-step variance.
For **garch** / **gjr** the forecast is the closed-form recursion
toward the unconditional variance: after the exact one-step step, the
leverage indicator is replaced by its expectation
:math:`\mathbb E[\mathbf 1[\varepsilon < 0]] = 1/2` (symmetric
innovations), giving persistence :math:`\alpha + \beta + \gamma / 2`.
For **egarch** (log-variance form) no closed form exists, so the
forecast is a seeded Monte-Carlo average over ``n_sims`` simulated
innovation paths.
Parameters
----------
h : int, optional
Forecast horizon (number of steps). Default 1.
n_sims : int, optional
Number of Monte-Carlo paths (egarch only). Default 10000.
seed : int, optional
Seed for the Monte-Carlo draws (egarch only). Default 0.
Returns
-------
numpy.ndarray
Variance forecasts, shape ``(h,)``.
"""
if h < 1:
raise ValueError(f"h must be >= 1, got {h}")
omega, alpha, gamma, beta = _unpack(self.params, self.model)
sig_last = float(self.conditional_vol[-1])
eps_last = float(self.std_residuals[-1]) * sig_last
out = np.empty(h, dtype=np.float64)
if self.model in ('garch', 'gjr'):
ind = 1.0 if eps_last < 0.0 else 0.0
out[0] = (omega + (alpha + gamma * ind) * eps_last ** 2
+ beta * sig_last ** 2)
persist = alpha + beta + 0.5 * gamma
for k in range(1, h):
out[k] = omega + persist * out[k - 1]
return out
# egarch: Monte-Carlo forward simulation of the log-variance.
nu = float(self.params.get('nu', 0.0))
if self.dist == 't':
mean_abs_z = _mean_abs_standardized_t(nu)
else:
mean_abs_z = _SQRT_2_OVER_PI
z_last = float(self.std_residuals[-1])
log_var_next = (omega + beta * math.log(sig_last ** 2)
+ alpha * (abs(z_last) - mean_abs_z) + gamma * z_last)
out[0] = math.exp(log_var_next)
if h > 1:
rng = np.random.default_rng(seed)
log_var = np.full(n_sims, log_var_next)
for k in range(1, h):
z = _draw(rng, self.dist, nu, n_sims)
log_var = (omega + beta * log_var
+ alpha * (np.abs(z) - mean_abs_z) + gamma * z)
out[k] = float(np.mean(np.exp(log_var)))
return out
[docs]
def simulate(self, T: int, seed: int = 0) -> NDArray[np.float64]:
r""" Simulate a return path from the fitted parameters and innovation.
Runs the fitted model's conditional-variance recursion forward,
drawing unit-variance innovations from the fitted ``dist`` (Gaussian,
or standardized Student-t with ``nu`` degrees of freedom), and returns
the mean-zero simulated returns :math:`y_t = \sigma_t z_t`.
Parameters
----------
T : int
Length of the simulated path.
seed : int, optional
Seed for the innovation draws. Default 0.
Returns
-------
numpy.ndarray
Simulated returns, shape ``(T,)``.
"""
if T < 1:
raise ValueError(f"T must be >= 1, got {T}")
omega, alpha, gamma, beta = _unpack(self.params, self.model)
nu = float(self.params.get('nu', 0.0))
rng = np.random.default_rng(seed)
z = _draw(rng, self.dist, nu, T)
y = np.empty(T, dtype=np.float64)
if self.model in ('garch', 'gjr'):
var = omega # sigma_0^2 = omega, matching the kernel convention.
y[0] = math.sqrt(var) * z[0]
for t in range(1, T):
ind = 1.0 if y[t - 1] < 0.0 else 0.0
var = (omega + (alpha + gamma * ind) * y[t - 1] ** 2
+ beta * var)
y[t] = math.sqrt(var) * z[t]
return y
# egarch.
if self.dist == 't':
mean_abs_z = _mean_abs_standardized_t(nu)
else:
mean_abs_z = _SQRT_2_OVER_PI
log_var = omega # ln sigma_0^2 = omega, matching the kernel.
y[0] = math.exp(0.5 * log_var) * z[0]
for t in range(1, T):
log_var = (omega + beta * log_var
+ alpha * (abs(z[t - 1]) - mean_abs_z) + gamma * z[t - 1])
y[t] = math.exp(0.5 * log_var) * z[t]
return y
# =========================================================================== #
# DRIVER #
# =========================================================================== #
[docs]
def fit_volatility(
y: ArrayLike,
model: str = 'garch',
dist: str = 'normal',
x0: ArrayLike | None = None,
) -> VolatilityResult:
r""" Maximum-likelihood fit of a GARCH-family volatility model.
Demeans ``y`` (the sample mean is removed first) and maximises the
GARCH-family log-likelihood
(:func:`~fynance.models.econometric_models.loglik_garch`) with
:func:`scipy.optimize.minimize` (``method='SLSQP'``), under box bounds and
stationarity constraints. The starting point is the variance-targeting
heuristic (see Notes); an explicit ``x0`` overrides it. Standard errors come
from the inverse numerical Hessian of the negative log-likelihood.
Parameters
----------
y : array-like
One-dimensional return series (demeaned internally).
model : {'garch', 'gjr', 'egarch'}, optional
Conditional-variance specification. ``'garch'`` is vanilla
GARCH(1, 1); ``'gjr'`` adds a leverage term; ``'egarch'`` models the
log-variance. Default is ``'garch'``.
dist : {'normal', 't'}, optional
Innovation density: Gaussian or standardized Student-t (``nu > 2``).
Default is ``'normal'``.
x0 : array-like, optional
Explicit starting parameter vector (in the layout described in the
module docstring). Overrides the variance-targeting heuristic.
Returns
-------
VolatilityResult
Fitted parameters, standard errors, information criteria, in-sample
conditional volatility, standardized residuals, and forecasting /
simulation methods.
Notes
-----
The starting point uses variance targeting with ``alpha = 0.05``,
``beta = 0.90``, ``gamma = 0.05`` and (for ``'t'``) ``nu = 8``, and sets
``omega`` so the model's unconditional variance matches the sample
variance: ``omega = var * (1 - alpha - beta)`` for garch / gjr and, in log
space, ``omega = ln(var) * (1 - beta)`` for egarch.
Information criteria are ``AIC = 2k - 2ll`` and ``BIC = k ln(n) - 2ll``
(``k`` parameters, ``n`` observations, ``ll`` the maximised
log-likelihood).
Examples
--------
>>> import numpy as np
>>> from fynance.estimator import fit_volatility
>>> rng = np.random.default_rng(0)
>>> y = rng.standard_normal(300) * 0.02
>>> res = fit_volatility(y, model='garch', dist='normal')
>>> sorted(res.params)
['alpha', 'beta', 'omega']
>>> res.conditional_vol.shape
(300,)
>>> bool(np.isfinite(res.aic) and np.isfinite(res.bic))
True
>>> bool(np.all(res.forecast(5) > 0.0))
True
"""
model = model.lower()
dist = dist.lower()
names = _param_names(model, dist) # validates model / dist.
arr = np.asarray(y, dtype=np.float64).reshape(-1)
arr = arr - np.mean(arr)
n = arr.size
x_start = _x0_heuristic(arr, model, dist, x0)
bounds, cons = _bounds_and_constraints(model, dist)
res = minimize(
_neg_loglik,
x_start,
args=(arr, model, dist),
method='SLSQP',
bounds=bounds,
constraints=cons,
options={'maxiter': 500, 'ftol': 1e-9},
)
x = np.asarray(res.x, dtype=np.float64)
params = dict(zip(names, (float(v) for v in x)))
ll = float(loglik_garch(x, arr, model, dist))
k = x.size
aic = 2.0 * k - 2.0 * ll
bic = k * math.log(n) - 2.0 * ll
cond_vol = _filter_vol(arr, model, dist, params)
std_resid = arr / cond_vol
return VolatilityResult(
params=params,
std_errors=_std_errors(x, arr, model, dist),
loglik=ll,
aic=aic,
bic=bic,
conditional_vol=cond_vol,
std_residuals=std_resid,
model=model,
dist=dist,
n_obs=n,
)