|
| 1 | +""" |
| 2 | +risklib/market/garch_mle.py |
| 3 | +============================ |
| 4 | +GARCH(1,1) Maximum Likelihood Estimation. |
| 5 | +
|
| 6 | +Provides MLE-based parameter estimation as an upgrade over the fixed-parameter |
| 7 | +GARCH filter in market.py. This module closes the documented model limitation: |
| 8 | +
|
| 9 | + "The GARCH(1,1) filter uses fixed parameters (α=0.05, β=0.94) rather than |
| 10 | + MLE-estimated parameters. This simplification may misspecify the volatility |
| 11 | + process for individual assets, particularly during regime shifts." |
| 12 | +
|
| 13 | +Model |
| 14 | +----- |
| 15 | + sigma^2_t = omega + alpha * r^2_{t-1} + beta * sigma^2_{t-1} |
| 16 | +
|
| 17 | + Stationarity condition: alpha + beta < 1 (enforced via parameter transform) |
| 18 | + Gaussian log-likelihood maximised via scipy.optimize (L-BFGS-B) |
| 19 | +
|
| 20 | +Design |
| 21 | +------ |
| 22 | + - garch11_filter_mle() is a drop-in replacement for garch11_filter() in market.py |
| 23 | + with an additional `fit=True` flag. When fit=True, parameters are estimated |
| 24 | + by MLE from the data; when fit=False, behaviour is identical to the original. |
| 25 | + - No new runtime dependencies (scipy is already in requirements) |
| 26 | + - MarketRiskConfig gains an optional `fit_garch` flag; default=False preserves |
| 27 | + existing behaviour so no existing code breaks |
| 28 | +
|
| 29 | +References |
| 30 | +---------- |
| 31 | + Bollerslev, T. (1986). Generalized Autoregressive Conditional Heteroskedasticity. |
| 32 | + Journal of Econometrics, 31(3), 307–327. |
| 33 | + Engle, R.F. (1982). Autoregressive Conditional Heteroscedasticity with Estimates |
| 34 | + of the Variance of United Kingdom Inflation. Econometrica, 50(4), 987–1007. |
| 35 | +""" |
| 36 | + |
| 37 | +from __future__ import annotations |
| 38 | + |
| 39 | +from math import log, sqrt |
| 40 | +from typing import Dict, Optional, Tuple |
| 41 | + |
| 42 | +import numpy as np |
| 43 | +import pandas as pd |
| 44 | +from scipy.optimize import minimize |
| 45 | + |
| 46 | + |
| 47 | +# --------------------------------------------------------------------------- |
| 48 | +# Internal: variance path and negative log-likelihood |
| 49 | +# --------------------------------------------------------------------------- |
| 50 | + |
| 51 | +def _variance_path( |
| 52 | + r: np.ndarray, |
| 53 | + omega: float, |
| 54 | + alpha_g: float, |
| 55 | + beta_g: float, |
| 56 | +) -> np.ndarray: |
| 57 | + """ |
| 58 | + Compute conditional variance series sigma^2_t for GARCH(1,1). |
| 59 | + Initialised at the sample variance of r. |
| 60 | + """ |
| 61 | + n = len(r) |
| 62 | + sig2 = np.empty(n, dtype=float) |
| 63 | + sig2[0] = max(1e-18, float(np.var(r, ddof=1))) |
| 64 | + r2 = r ** 2 |
| 65 | + for t in range(1, n): |
| 66 | + sig2[t] = max(1e-18, omega + alpha_g * r2[t - 1] + beta_g * sig2[t - 1]) |
| 67 | + return sig2 |
| 68 | + |
| 69 | + |
| 70 | +def _neg_loglik(params: np.ndarray, r: np.ndarray) -> float: |
| 71 | + """ |
| 72 | + Negative Gaussian log-likelihood for GARCH(1,1). |
| 73 | + Parameters are in unconstrained space; transformations enforce constraints: |
| 74 | + omega = exp(p0) > 0 |
| 75 | + alpha_g = sigmoid(p1) ∈ (0,1) |
| 76 | + beta_g = (1 - alpha_g - eps) * sigmoid(p2) ensures alpha + beta < 1 |
| 77 | + """ |
| 78 | + p0, p1, p2 = params |
| 79 | + omega = float(np.exp(p0)) |
| 80 | + alpha_g = float(1.0 / (1.0 + np.exp(-p1))) |
| 81 | + beta_g = float((1.0 - alpha_g - 1e-6) / (1.0 + np.exp(-p2))) |
| 82 | + |
| 83 | + sig2 = _variance_path(r, omega, alpha_g, beta_g) |
| 84 | + |
| 85 | + # Gaussian NLL: 0.5 * sum[log(sig2_t) + r_t^2 / sig2_t] |
| 86 | + nll = 0.5 * float(np.sum(np.log(sig2) + r ** 2 / sig2)) |
| 87 | + return nll if np.isfinite(nll) else 1e18 |
| 88 | + |
| 89 | + |
| 90 | +# --------------------------------------------------------------------------- |
| 91 | +# Public: MLE estimation |
| 92 | +# --------------------------------------------------------------------------- |
| 93 | + |
| 94 | +def fit_garch11_mle( |
| 95 | + r: pd.Series, |
| 96 | + n_restarts: int = 3, |
| 97 | + max_iter: int = 2000, |
| 98 | +) -> Dict: |
| 99 | + """ |
| 100 | + Fit GARCH(1,1) by Maximum Likelihood Estimation. |
| 101 | +
|
| 102 | + Uses L-BFGS-B with multiple random restarts to avoid local minima. |
| 103 | + Parameters are estimated in unconstrained space and transformed to |
| 104 | + enforce stationarity (alpha + beta < 1). |
| 105 | +
|
| 106 | + Parameters |
| 107 | + ---------- |
| 108 | + r : pd.Series of asset or portfolio returns |
| 109 | + n_restarts : number of random restarts (best result kept) |
| 110 | + max_iter : maximum optimizer iterations per restart |
| 111 | +
|
| 112 | + Returns |
| 113 | + ------- |
| 114 | + dict with keys: |
| 115 | + omega, alpha_g, beta_g — MLE parameter estimates |
| 116 | + persistence — alpha + beta (< 1 for stationarity) |
| 117 | + long_run_vol — annualised long-run volatility = sqrt(omega / (1-alpha-beta)) * sqrt(252) |
| 118 | + log_likelihood — maximised log-likelihood |
| 119 | + aic — Akaike information criterion (3 free parameters) |
| 120 | + bic — Bayesian information criterion |
| 121 | + converged — bool, True if best restart converged |
| 122 | + n_obs — number of observations used |
| 123 | + source — "MLE" |
| 124 | + """ |
| 125 | + r_arr = r.dropna().values.astype(float) |
| 126 | + n = len(r_arr) |
| 127 | + if n < 50: |
| 128 | + raise ValueError( |
| 129 | + f"Need ≥50 observations for GARCH MLE; got {n}. " |
| 130 | + "Use fixed parameters (fit=False) for short series." |
| 131 | + ) |
| 132 | + |
| 133 | + best_nll = np.inf |
| 134 | + best_res = None |
| 135 | + rng = np.random.default_rng(42) |
| 136 | + var_est = float(np.var(r_arr, ddof=1)) |
| 137 | + |
| 138 | + # Deterministic starting points + random restarts |
| 139 | + start_configs = [ |
| 140 | + [log(var_est * 0.05), 0.0, 0.0], |
| 141 | + [log(var_est * 0.10), 0.5, 1.5], |
| 142 | + [log(var_est * 0.02), -1.0, 2.5], |
| 143 | + ] |
| 144 | + for _ in range(max(0, n_restarts - 3)): |
| 145 | + start_configs.append(rng.normal(0.0, 1.0, 3).tolist()) |
| 146 | + |
| 147 | + for x0 in start_configs[:n_restarts]: |
| 148 | + try: |
| 149 | + res = minimize( |
| 150 | + _neg_loglik, |
| 151 | + x0=x0, |
| 152 | + args=(r_arr,), |
| 153 | + method="L-BFGS-B", |
| 154 | + options={"maxiter": max_iter, "ftol": 1e-12, "gtol": 1e-8}, |
| 155 | + ) |
| 156 | + if np.isfinite(res.fun) and res.fun < best_nll: |
| 157 | + best_nll = res.fun |
| 158 | + best_res = res |
| 159 | + except Exception: |
| 160 | + continue |
| 161 | + |
| 162 | + if best_res is None: |
| 163 | + raise RuntimeError("GARCH MLE: optimisation failed on all restarts.") |
| 164 | + |
| 165 | + # Recover constrained estimates |
| 166 | + p0, p1, p2 = best_res.x |
| 167 | + omega = float(np.exp(p0)) |
| 168 | + alpha_g = float(1.0 / (1.0 + np.exp(-p1))) |
| 169 | + beta_g = float((1.0 - alpha_g - 1e-6) / (1.0 + np.exp(-p2))) |
| 170 | + persist = alpha_g + beta_g |
| 171 | + |
| 172 | + log_lik = float(-best_nll) |
| 173 | + k = 3 |
| 174 | + aic = float(2 * k - 2 * log_lik) |
| 175 | + bic = float(k * log(n) - 2 * log_lik) |
| 176 | + lrv_denom = max(1.0 - persist, 1e-12) |
| 177 | + long_run_vol = float(sqrt(omega / lrv_denom) * sqrt(252)) |
| 178 | + |
| 179 | + return { |
| 180 | + "omega": omega, |
| 181 | + "alpha_g": alpha_g, |
| 182 | + "beta_g": beta_g, |
| 183 | + "persistence": persist, |
| 184 | + "long_run_vol": long_run_vol, |
| 185 | + "log_likelihood": log_lik, |
| 186 | + "aic": aic, |
| 187 | + "bic": bic, |
| 188 | + "converged": bool(best_res.success), |
| 189 | + "n_obs": n, |
| 190 | + "source": "MLE", |
| 191 | + } |
| 192 | + |
| 193 | + |
| 194 | +# --------------------------------------------------------------------------- |
| 195 | +# Public: volatility filter (drop-in replacement for market.garch11_filter) |
| 196 | +# --------------------------------------------------------------------------- |
| 197 | + |
| 198 | +def garch11_filter_mle( |
| 199 | + r: pd.Series, |
| 200 | + fit: bool = True, |
| 201 | + alpha_g: float = 0.05, |
| 202 | + beta_g: float = 0.94, |
| 203 | + n_restarts: int = 3, |
| 204 | +) -> Tuple[pd.Series, Dict]: |
| 205 | + """ |
| 206 | + GARCH(1,1) conditional volatility filter with optional MLE estimation. |
| 207 | +
|
| 208 | + Drop-in replacement for garch11_filter() in market.py, adding the `fit` |
| 209 | + flag. When fit=True, omega/alpha/beta are estimated from the data. |
| 210 | + When fit=False, the function is behaviourally identical to the original. |
| 211 | +
|
| 212 | + Parameters |
| 213 | + ---------- |
| 214 | + r : pd.Series of returns |
| 215 | + fit : if True, estimate parameters via MLE (recommended) |
| 216 | + if False, use the provided alpha_g / beta_g (legacy behaviour) |
| 217 | + alpha_g : ARCH parameter — used only when fit=False (default 0.05) |
| 218 | + beta_g : GARCH parameter — used only when fit=False (default 0.94) |
| 219 | + n_restarts : MLE random restarts (used only when fit=True) |
| 220 | +
|
| 221 | + Returns |
| 222 | + ------- |
| 223 | + (sigma, fit_info) |
| 224 | + sigma : pd.Series of conditional standard deviations (same index as r) |
| 225 | + fit_info : dict with parameter estimates and model diagnostics |
| 226 | + Always contains: omega, alpha_g, beta_g, persistence, source |
| 227 | + MLE additionally: log_likelihood, aic, bic, converged, n_obs |
| 228 | + """ |
| 229 | + r_clean = r.dropna() |
| 230 | + |
| 231 | + if fit: |
| 232 | + fit_info = fit_garch11_mle(r_clean, n_restarts=n_restarts) |
| 233 | + omega_ = fit_info["omega"] |
| 234 | + alpha_g_ = fit_info["alpha_g"] |
| 235 | + beta_g_ = fit_info["beta_g"] |
| 236 | + else: |
| 237 | + long_run_var = max(float(r_clean.var(ddof=1)), 1e-18) |
| 238 | + omega_ = max(1e-18, (1.0 - alpha_g - beta_g) * long_run_var) |
| 239 | + alpha_g_ = alpha_g |
| 240 | + beta_g_ = beta_g |
| 241 | + fit_info = { |
| 242 | + "omega": omega_, |
| 243 | + "alpha_g": alpha_g_, |
| 244 | + "beta_g": beta_g_, |
| 245 | + "persistence": alpha_g + beta_g, |
| 246 | + "long_run_vol": float(sqrt(long_run_var) * sqrt(252)), |
| 247 | + "source": "fixed", |
| 248 | + } |
| 249 | + |
| 250 | + sig2 = _variance_path(r_clean.values, omega_, alpha_g_, beta_g_) |
| 251 | + sigma = pd.Series(np.sqrt(sig2), index=r_clean.index, name="sigma_garch") |
| 252 | + return sigma, fit_info |
| 253 | + |
| 254 | + |
| 255 | +# --------------------------------------------------------------------------- |
| 256 | +# Convenience: one-step-ahead sigma forecast |
| 257 | +# --------------------------------------------------------------------------- |
| 258 | + |
| 259 | +def garch11_forecast_next( |
| 260 | + r_last: float, |
| 261 | + sigma_last: float, |
| 262 | + omega: float, |
| 263 | + alpha_g: float, |
| 264 | + beta_g: float, |
| 265 | +) -> float: |
| 266 | + """ |
| 267 | + One-step-ahead conditional standard deviation forecast. |
| 268 | + sigma^2_{t+1} = omega + alpha * r_t^2 + beta * sigma_t^2 |
| 269 | + """ |
| 270 | + sig2_next = omega + alpha_g * (r_last ** 2) + beta_g * (sigma_last ** 2) |
| 271 | + return float(sqrt(max(sig2_next, 1e-18))) |
0 commit comments