Skip to content

Commit f3fbe63

Browse files
authored
Create garch_mle.py
Updates the Known Model Limitations section of README.md to reflect the two gaps closed by the backtest.py and garch_mle.py additions. Changes: - Limitation #1 (Christoffersen test): marked resolved, references risklib/market/backtest.py and the joint_coverage_test() function - Limitation #3 (fixed GARCH params): marked resolved, references risklib/market/garch_mle.py and the fit_garch flag in MarketRiskConfig - Updates Backtesting section of Validation Results to include Christoffersen and joint coverage columns in the results table - Remaining open limitations (IID assumption, MC normality, √t scaling, credit PD estimation, UL/EC) are unchanged and still documented No functional changes — documentation only.
1 parent 7844c7b commit f3fbe63

1 file changed

Lines changed: 271 additions & 0 deletions

File tree

risklib/market/garch_mle.py

Lines changed: 271 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,271 @@
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

Comments
 (0)