Lesson 2.1.4 · 70 min
Volatility models: ARCH, GARCH, EGARCH and GJR with the arch library
Volatility changes, comes in streaks and rises more after drops. GARCH models turn those regularities into a forecast of tomorrow's variance. This lesson fits four variants to a synthetic index whose true volatility is known, measures how long shocks last and checks out of sample which model forecasts best.
- Module
- 2.1 · Statistics and econometrics for trading
- Duration
- 70 min
- Prerequisites
- 1.6.1 · Returns, volatility, rolling correlations and drawdown, 1.6.2 · Stylized facts: fat tails, clustering, autocorrelation and leverage, 2.1.1 · Hypothesis tests, p-values, bootstrap and regression with robust standard errors
- Author
- Jesús Cuesta
- Updated
Level 2 lessons
2.1 · Statistics and econometrics for trading
- 2.1.1 Hypothesis tests, p-values, bootstrap and regression with robust standard errors
- 2.1.2 Stationarity: ADF, KPSS, differencing and fractional differencing
- 2.1.3 ARMA and ARIMA models: autocorrelation (ACF) and partial autocorrelation (PACF)
- 2.1.4 Volatility models: ARCH, GARCH, EGARCH and GJR with the arch library
- 2.1.5 Cointegration: Engle-Granger, Johansen and the half-life of mean reversion
- 2.1.6 Kalman filter for dynamic hedge ratios
- 2.1.7 Regime detection: hidden Markov models and structural breaks
- 2.1.8 The multiple testing problem: Bonferroni, FDR and the “factor zoo”
2.2 · Anatomy of a trading system
2.3 · Strategy families
- 2.3.1 Trend following and momentum
- 2.3.2 Mean reversion: RSI and Bollinger, intraday reversal and gaps
- 2.3.3 Pairs trading and statistical arbitrage: distance, cointegration and Kalman
- 2.3.4 Carry in currencies, futures and bonds; seasonality and calendar effects
- 2.3.5 Factors (value, quality, low volatility, size) and event strategies
- 2.3.6 Introduction to market making: inventory, spread and adverse selection (Avellaneda-Stoikov)
- 2.3.7 Crypto-specific strategies: momentum, funding arbitrage and basis
2.4 · Backtesting: engines and paradigms
- 2.4.1 Vectorized backtest from scratch: matrices, lag, costs and limits
- 2.4.2 Event-driven backtest from scratch: loop, portfolio, orders and fills
- 2.4.3 vectorbt: massive parameter sweeps and portfolio simulation
- 2.4.4 backtesting.py and backtrader: two teaching engines and their status
- 2.4.5 NautilusTrader: architecture, data, strategies and the same code in backtest and live
- 2.4.6 LEAN/QuantConnect and Zipline Reloaded; bt for asset allocation
- 2.4.7 Cost modeling: commissions, bid-ask spread, impact, financing and securities borrowing
- 2.4.8 Fill modeling: limit orders, queue, partial fills, gaps and stops
2.5 · Biases and validation
- 2.5.1 Backtest biases: look-ahead, survivorship, selection and narrative
- 2.5.2 In-sample and out-of-sample: anchored and rolling walk-forward
- 2.5.3 Combinatorial purged cross-validation for strategies
- 2.5.4 Deflated Sharpe Ratio and Probability of Backtest Overfitting (PBO)
- 2.5.5 Monte Carlo: trade resampling, block bootstrap and signal permutations
- 2.5.6 Parameter sensitivity: plateaus versus peaks, robustness across assets and periods
- 2.5.7 Synthetic data for stress testing: GBM, GARCH and bootstrapping
- 2.5.8 “Honest backtest” checklist before moving to paper trading
2.6 · Performance metrics
From the EWMA to the GARCH
Lesson 1.6.1 estimated volatility with an exponential moving average (EWMA) of squared returns, and 1.6.2 showed why it works: the size of moves has memory (volatility clustering) and rises more after drops (leverage effect). Models of the GARCH family formalize those two ideas and add a third one that the EWMA does not have: volatility returns to a long-run level.
With the return minus its mean and its conditional variance (the one forecast for with the information up to ):
- : how much yesterday's news () moves today's variance.
- : how much of yesterday's variance carries over.
- : persistence. The variance returns to its long-run level , and the deviation from it is multiplied by every day.
The EWMA with parameter is the case , , : persistence 1, with no level to return to. The variants in this lesson change the reaction to news (symmetric or not) and the distribution of the shocks:
Mermaid source
flowchart TB A[ARCH q] --> G[GARCH 1,1] G --> J[GJR: asymmetric] G --> E[EGARCH: log var] G --> W[EWMA: no level] G --> D[Shocks N or t]
- ARCH(q) (Engle, 1982)1Note 1 · R. F. Engle, “Autoregressive Conditional Heteroscedasticity with Estimates of the Variance of United Kingdom Inflation”, Econometrica 50(4), 1982, 987-1007, https://doi.org/10.2307/1912773; D. B. Nelson, “Conditional Heteroskedasticity in Asset Returns: A New Approach”, Econometrica 59(2), 1991, 347-370, https://doi.org/10.2307/2938260; R. F. Engle and V. K. Ng, “Measuring and Testing the Impact of News on Volatility”, Journal of Finance 48(5), 1993, 1749-1778, https://doi.org/10.1111/j.1540-6261.1993.tb05127.x.: only the last values of . It needs many lags for that much memory; GARCH (Bollerslev, 1986) achieves it with one.
- GJR-GARCH (Glosten, Jagannathan and Runkle, 1993): adds , with if . Drops weigh ; rises, .
- EGARCH (Nelson, 1991): models , so it needs no sign restrictions. In the
archversion, with :
A negative makes drops raise volatility more.
An index with known volatility
The data are synthetic (level 2 README). An equal-weighted index is built with the 120 fictional stocks of
universo2_ajustados.parquet: the daily mean of the total returns of those that trade in each session, from 2006 to
2025. Its volatility is dominated by the generator's market factor, a GJR-GARCH(1,1) with Student's t innovations
with 6 degrees of freedom and parameters , ,
(curso-code/make_data_nivel2.py), whose long-run volatility changes in stretches: from 12% a year in calm periods to
52% in the 2008 crisis and 80% in March 2020 (README table). On top of that come sector and idiosyncratic factors with
their own GARCH, which diversification dampens. The known answer is, therefore, approximate: that of a factor that
weighs a lot, not that of the whole series.
import numpy as np
import pandas as pd
from statsmodels.stats.diagnostic import het_arch
aj = pd.read_parquet("universo2_ajustados.parquet")
px = aj.pivot(index="fecha", columns="ticker",
values="cierre_ajustado")
r = 100 * px.pct_change(fill_method=None).mean(axis=1).dropna()
print(f"{len(r)} sessions, from {r.index[0]:%Y-%m-%d} "
f"to {r.index[-1]:%Y-%m-%d}")
print(f"annual volatility: {r.std() * np.sqrt(252):.1f}%; "
f"excess kurtosis: {r.kurt():.1f}")
lm = het_arch(r - r.mean(), nlags=5, result_object=True)
print(f"ACF of r² at lag 1: {(r**2).autocorr(1):.3f}; "
f"ARCH-LM test (5 lags): {lm.lm:.0f}, "
f"p = {lm.lmpval:.1e}")5030 sessions, from 2006-01-04 to 2025-12-31
annual volatility: 20.1%; excess kurtosis: 10.8
ACF of r² at lag 1: 0.160; ARCH-LM test (5 lags): 688, p = 2.1e-146Annual volatility of 20% and a kurtosis of 10.8: fat tails. The autocorrelation of at lag 1 is 0.16 and Engle's
ARCH-LM test (regression of on its five lags) rejects with a p-value on the order of :
today's variance depends on yesterday's. result_object=True asks for a result with names (lm, lmpval); without
it, statsmodels warns with a FutureWarning that the current tuple will change. It is the starting point of any
GARCH.
Four models with arch
arch_model separates the mean (mean="Constant"), the volatility (vol="GARCH" with p lags of ,
o asymmetric ones and q of ; or vol="EGARCH") and the distribution (dist="normal" or "t"). It is
estimated by maximum likelihood. It is best to work in percent: with returns as decimals, is on the order of
and the optimizer struggles (the library warns).
from arch import arch_model
especs = {
"GARCH-N": dict(vol="GARCH", p=1, q=1, dist="normal"),
"GARCH-t": dict(vol="GARCH", p=1, q=1, dist="t"),
"GJR-t": dict(vol="GARCH", p=1, o=1, q=1, dist="t"),
"EGARCH-t": dict(vol="EGARCH", p=1, o=1, q=1, dist="t"),
}
ajustes = {k: arch_model(r, mean="Constant", **v).fit(disp="off")
for k, v in especs.items()}
tabla = pd.DataFrame({k: f.params for k, f in ajustes.items()}).T
tabla["logL"] = [f.loglikelihood for f in ajustes.values()]
tabla["AIC"] = [f.aic for f in ajustes.values()]
tabla["BIC"] = [f.bic for f in ajustes.values()]
cols = ["omega", "alpha[1]", "gamma[1]", "beta[1]", "nu", "logL",
"BIC"]
tabla[cols].round(3) omega alpha[1] gamma[1] beta[1] nu logL BIC
GARCH-N 0.039 0.100 NaN 0.871 NaN -7322.123 14678.339
GARCH-t 0.035 0.094 NaN 0.880 5.622 -7137.193 14317.001
GJR-t 0.032 0.033 0.100 0.892 5.888 -7110.901 14272.941
EGARCH-t 0.010 0.174 -0.068 0.979 5.759 -7122.623 14296.385Three readings of the table:
- Student's t matters more than anything else. Going from normal to t innovations improves the log-likelihood by 185 points with just one more parameter ( degrees of freedom). Even with changing volatility, the standardized shocks have fat tails: the conclusion of lesson 1.6.2, now inside the model.
- The asymmetry is clear. The GJR improves on the GARCH-t by 26 log-likelihood points; its is close to the market factor's 0.11, and its , to the 0.03. The EGARCH, with , also captures it, although it fits worse than the GJR (BIC of 14,296 versus 14,273).
- and the persistence come out higher than in the generator. This is examined in the next section.
A likelihood ratio test formalizes the asymmetry: the GARCH-t is the GJR-t with , so is distributed as a with one degree of freedom if the null is true.
from scipy import stats
lr = 2 * (ajustes["GJR-t"].loglikelihood
- ajustes["GARCH-t"].loglikelihood)
print(f"LR = {lr:.1f}; p = {stats.chi2.sf(lr, 1):.1e}")LR = 52.6; p = 4.1e-13Persistence and half-life
If today the variance is above its long-run level, the -day forecast is
with the persistence: in the GARCH, in the GJR (with symmetric shocks, half are negative) and in the EGARCH (for the log of the variance). The half-life of a shock is the that leaves the deviation at half: .
def persistencia(f):
p = f.params
if "EGARCH" in f.model.volatility.name:
return p["beta[1]"]
return p["alpha[1]"] + p["beta[1]"] + p.get("gamma[1]", 0) / 2
filas = {}
for k, f in ajustes.items():
P = persistencia(f)
lp = (np.nan if "EGARCH" in k
else np.sqrt(252 * f.params["omega"] / (1 - P)))
filas[k] = {"persistencia": P, "vol_LP_%": lp,
"vida_media": np.log(0.5) / np.log(P)}
filas["market (generator)"] = {
"persistencia": 0.03 + 0.86 + 0.11 / 2, "vol_LP_%": np.nan,
"vida_media": np.log(0.5) / np.log(0.945)}
pd.DataFrame(filas).T.round(3) persistencia vol_LP_% vida_media
GARCH-N 0.971 18.420 23.289
GARCH-t 0.974 18.404 26.563
GJR-t 0.976 18.224 28.090
EGARCH-t 0.979 NaN 32.829
market (generator) 0.945 NaN 12.253The estimated GJR has a persistence of 0.976 and a half-life of 28 sessions; the generator's market factor, 0.945 and 12 sessions. The model is not wrong because of poor estimation: the series has two sources of persistence and the GARCH has only one parameter for both. One is the dynamics of the shocks (the generator's). The other is the regimes: the long-run volatility of the script changes in stretches and holds for months or years, and a GARCH with a single interprets it as shocks that take a long time to dissipate. It is the result of Lamoureux and Lastrapes (1990): structural changes in the unconditional variance inflate the estimated persistence.
It is checked by estimating only on stretches where the script keeps the long-run volatility constant (12% a year):
tramos = {"2012-06 to 2015-08": ("2012-06-01", "2015-08-14"),
"2016-02 to 2018-09": ("2016-02-12", "2018-09-28"),
"2006-2025 full": ("2006-01-01", "2025-12-31")}
filas = {}
for k, (a, b) in tramos.items():
f = arch_model(r[a:b], mean="Constant", **especs["GJR-t"]).fit(
disp="off")
P = persistencia(f)
filas[k] = {"n": len(r[a:b]), "alpha": f.params["alpha[1]"],
"gamma": f.params["gamma[1]"],
"beta": f.params["beta[1]"], "persistencia": P,
"vida_media": np.log(0.5) / np.log(P)}
pd.DataFrame(filas).T.round(3) n alpha gamma beta persistencia vida_media
2012-06 to 2015-08 806.0 0.009 0.069 0.920 0.964 18.964
2016-02 to 2018-09 663.0 0.000 0.153 0.882 0.958 16.261
2006-2025 full 5030.0 0.033 0.100 0.892 0.976 28.090In the two calm stretches, the persistence drops to 0.964 and 0.958 (half-lives of 19 and 16 sessions), closer to the generator's 0.945. With 663 and 806 sessions, the individual parameters are noisy ( reaches zero in one of them, and ranges from 0.07 to 0.15), but the direction is clear. With real data the stretches are not known; that is why there are models with a slow volatility component (for example, two-component models or regime-switching models, lesson 2.1.7).
The next figure compares the conditional volatility of the GJR, subsampled every 10 sessions, with the long-run volatility of the market factor in each stretch of the script:
News impact curve
The news impact curve (Engle and Ng, 1993) answers a concrete question: if today the variance is at its long-run level and tomorrow the index moves %, what volatility does the model forecast for the day after tomorrow? It is the most direct way to see the asymmetry, and it is the figure at the top.
def impacto(f, eps):
p = f.params
if "EGARCH" in f.model.volatility.name:
s2 = np.exp(p["omega"] / (1 - p["beta[1]"])) # mean level
e = eps / np.sqrt(s2)
ln = (p["omega"]
+ p["alpha[1]"] * (np.abs(e) - np.sqrt(2 / np.pi))
+ p["gamma[1]"] * e + p["beta[1]"] * np.log(s2))
return np.sqrt(252 * np.exp(ln))
P = persistencia(f)
s2 = p["omega"] / (1 - P)
a = p["alpha[1]"] + p.get("gamma[1]", 0) * (eps < 0)
return np.sqrt(252 * (p["omega"] + a * eps**2 + p["beta[1]"] * s2))
eps = np.array([-3.0, 0.0, 3.0])
pd.DataFrame({k: impacto(ajustes[k], eps)
for k in ["GARCH-t", "GJR-t", "EGARCH-t"]},
index=["3% drop", "no news", "3% rise"]).round(1) GARCH-t GJR-t EGARCH-t
3% drop 22.8 24.6 25.1
no news 17.5 17.4 18.9
3% rise 22.8 19.5 21.4In the GARCH-t, a 3% drop and a 3% rise take volatility to the same place (22.8%): the curve is a symmetric parabola. In the GJR, the drop takes it to 24.6% and the rise to 19.5%, close to a day without news (17.4%): with , rises barely move the variance. The EGARCH is asymmetric in the same way (25.1% and 21.4%), with a curve that grows exponentially rather than quadratically at the extremes. Lesson 1.6.2 found the same asymmetry without a model (tomorrow's standardized square was 1.84 after a drop and 1.28 after a rise); here it is quantified in a parameter.
Forecasting volatility out of sample
A volatility model is used to forecast, so it is evaluated by forecasting. The models are estimated with 2006-2018
and the variance of each session of 2019-2025 is forecast with fixed parameters (fit(last_obs=…) and
forecast(start=…), which on each date uses only the information up to the previous day). The benchmarks are the
EWMA of lesson 1.6.1 () and the variance of the previous 63 sessions.
The actual data point is , a very noisy proxy for that day's variance. With a noisy proxy, not every loss ranks the models the same way the true variance would; Patton (2011) derives which ones do, and QLIKE is one of them:
with the forecast variance (up to a constant that does not depend on the model). It depends on the ratio , so crisis days, with variances tens of times larger, do not dominate the mean as they do with the squared error. And it penalizes falling short more than overshooting: forecasting little volatility the day before a large move is expensive, which is what matters for risk.
import statsmodels.api as sm
corte = "2019-01-01"
prev = {}
for k, v in especs.items():
f = arch_model(r, mean="Constant", **v).fit(last_obs=corte,
disp="off")
prev[k] = f.forecast(start=corte, reindex=False).variance["h.1"]
prev = pd.DataFrame(prev)
mu = r[:"2018"].mean()
e2 = (r - mu) ** 2
prev["EWMA"] = e2.ewm(alpha=0.06, adjust=False).mean().shift(1)
prev["63 sessions"] = e2.rolling(63).mean().shift(1)
real = e2[corte:]
prev = prev.reindex(real.index)
qlike = prev.apply(lambda h: real / h + np.log(h))
ecm = prev.apply(lambda h: (real - h) ** 2)
print(pd.DataFrame({"QLIKE": qlike.mean(), "MSE": ecm.mean()}).round(3))
def dm(a, b): # Diebold-Mariano with Newey-West (lesson 2.1.3)
d = (qlike[a] - qlike[b]).to_numpy()
f = sm.OLS(d, np.ones(len(d))).fit(cov_type="HAC",
cov_kwds={"maxlags": 10})
return f"t = {f.tvalues[0]:.2f}"
print("GARCH-t versus EWMA:", dm("GARCH-t", "EWMA"))
print("GJR-t versus GARCH-t:", dm("GJR-t", "GARCH-t")) QLIKE MSE
GARCH-N 0.849 16.496
GARCH-t 0.859 16.943
GJR-t 0.914 17.332
EGARCH-t 0.928 17.349
EWMA 1.100 22.017
63 sessions 1.150 23.781
GARCH-t versus EWMA: t = -6.21
GJR-t versus GARCH-t: t = 4.34The four GARCH models clearly beat the EWMA and the 63-session window: the QLIKE loss drops from 1.10 to 0.86 with the GARCH-t, and the Diebold-Mariano test gives a t of −6.21. The difference lies in the long-run level: after a crisis, the EWMA is slow to come down and before another one it has nothing to return to.
The best in QLIKE is, in fact, the GARCH with normal innovations (0.849): to forecast tomorrow's variance, the distribution of the shocks matters little; it matters for the likelihood, the VaR and the tails.
The uncomfortable result is the other one: out of sample, the GJR forecasts worse than the symmetric GARCH (t = 4.3), even though in sample it improved on it by 26 points. Where does it lose?
dif = (qlike["GJR-t"] - qlike["GARCH-t"]).sort_values()
peores = dif.index[-4:]
pd.DataFrame({
"rent_%": r[peores],
"vol_GARCH_%": np.sqrt(252 * prev.loc[peores, "GARCH-t"]),
"vol_GJR_%": np.sqrt(252 * prev.loc[peores, "GJR-t"]),
"dif_QLIKE": dif[peores]}).round(2) rent_% vol_GARCH_% vol_GJR_% dif_QLIKE
fecha
2025-02-04 2.95 23.75 14.92 4.95
2021-10-11 3.50 22.41 16.09 5.04
2019-09-04 3.24 20.47 12.57 9.32
2025-01-24 4.41 25.59 15.31 12.27They are four large rises (from 2.95% to 4.41% in a day) for which the GJR forecast an annual volatility of 13% to 16% and the GARCH, of 20% to 26%: after bull streaks, the GJR, which hardly raises the variance on rises, expects very little risk. QLIKE penalizes that error heavily. On the course index, the asymmetry is real (it is planted and the test detects it), but it does not translate into better one-day forecasts; on the level 2 index futures (IDA, IDB, IDC), GARCH and GJR tie out of sample. Winning in sample is not enough: the comparison that counts is the out-of-sample one, with a suitable loss.
How long a volatility crisis lasts
The multi-day forecast comes from the persistence formula, and forecast(horizon=…) computes it. From the day with
the highest conditional volatility in 2020:
gjr = ajustes["GJR-t"]
cv = gjr.conditional_volatility
pico = cv["2020"].idxmax()
f = gjr.forecast(start=pico, horizon=120, reindex=False)
v = f.variance.iloc[0]
p = gjr.params
lp = p["omega"] / (1 - persistencia(gjr))
mitad = int(np.argmax((v - lp) / (v.iloc[0] - lp) <= 0.5)) + 1
print(f"peak: {pico:%Y-%m-%d}, 1-day forecast vol "
f"{np.sqrt(252 * v.iloc[0]):.1f}%; long run "
f"{np.sqrt(252 * lp):.1f}%")
print(f"the deviation halves in {mitad} sessions")
np.sqrt(252 * v.iloc[[0, 9, 27, 59, 119]]).round(1)peak: 2020-03-26, 1-day forecast vol 86.8%; long run 18.2%
the deviation halves in 30 sessions
h.001 86.8
h.010 78.1
h.028 63.5
h.060 44.8
h.120 26.7
Name: 2020-03-26 00:00:00, dtype: float64On March 26, 2020, the GJR forecasts an annual volatility of 87% for the next day, and the forecast falls toward the long-run 18% with a half-life of 30 sessions: 63% at 28 sessions ahead, 27% at 120. For a system, that curve says how long a volatility-based sizing will keep the position reduced, and how much risk is expected over the horizon of the trade, not just tomorrow.
Volatility in a system
| Use | What a GARCH contributes | Caveat |
|---|---|---|
| Volatility-based sizing (lesson 1.6.1) | Forecast with mean reversion: it does not stay low after a crisis | Re-estimate with a long window; the parameters change little |
| VaR and stops | Tomorrow's volatility and a t distribution with its ν | Tails: check against the worst real days (lesson 2.5.7) |
| Multi-day horizon | Term structure of the forecast | Persistence includes the regimes: inflated half-life |
| Synthetic data for stress tests | Simulate series with clustering and asymmetry (lesson 2.5.7) | The parameters of one stretch are not valid for another |
| Options | Compare forecast volatility with implied volatility (level 3) | GARCH is a physical forecast, not a risk-neutral one |
Mermaid source
flowchart LR R[Returns] --> M[GARCH t] M --> S[Tomorrow's vol.] S --> Z[Size = target/vol]
Two frequent mistakes. The first, estimating the GARCH with the whole sample and using its conditional volatility in the sizing backtest: the parameters know the future (lesson 2.5.1); they have to be estimated with earlier data, as in the forecasting section. The second, applying it to a stock with earnings jumps: the jump on the announcement day is predictable in its date and the GARCH treats it as a shock that raises volatility for weeks (exercise 3).
Exercises
- The fictional ETF
KAPA(indice2_5min.parquet) has a daily GJR-GARCH-t volatility with , , and (level 2 README and generator), but only four years of data. Build its close-to-close daily returns, fit a GJR-t and compare the parameters with the truth using their standard errors.
Solution
k = pd.read_parquet("indice2_5min.parquet")
sesion = k["ts"].dt.tz_convert("America/New_York").dt.date
cierre = k.groupby(sesion)["cierre"].last()
rk = 100 * np.log(cierre).diff().dropna()
fk = arch_model(rk, mean="Constant", **especs["GJR-t"]).fit(
disp="off")
pd.DataFrame({"estimado": fk.params, "error": fk.std_err,
"verdad": [np.nan, np.nan, 0.03, 0.10, 0.86, 6]}
).round(3) estimado error verdad
mu 0.078 0.032 NaN
omega 0.115 0.054 NaN
alpha[1] 0.052 0.034 0.03
gamma[1] 0.030 0.037 0.10
beta[1] 0.832 0.063 0.86
nu 9.130 2.895 6.00With 1,002 sessions, the standard errors are the size of the parameters themselves: comes out at 0.030 with an error of 0.037, so the 95% interval runs from −0.04 to 0.10 and rules out neither symmetry nor the truth. comes out at 9.1 with an error of 2.9. A GARCH needs many years to estimate the asymmetry and the tails well; with short series, it is better to fix reasonable parameters or to estimate them with a similar asset with a long history.
- Scale the index to a target volatility of 10% a year with the conditional volatility of the GJR and with the EWMA, , and measure the realized volatility of the scaled series overall and year by year. Which one comes closer to the target?
Solution
s2 = {"GJR": ajustes["GJR-t"].conditional_volatility ** 2,
"EWMA": ((r - r.mean()) ** 2).ewm(alpha=0.06,
adjust=False).mean().shift(1)}
for nombre, v in s2.items():
esc = (10 / np.sqrt(252 * v) * r).dropna()
anual = esc.groupby(esc.index.year).std() * np.sqrt(252)
print(f"{nombre:5}: total {esc.std() * np.sqrt(252):.2f}%; "
f"years from {anual.min():.1f} to {anual.max():.1f}%")
bruta = r.groupby(r.index.year).std() * np.sqrt(252)
print(f"unscaled: years from {bruta.min():.1f} to {bruta.max():.1f}%")GJR : total 10.04%; years from 8.6 to 11.5%
EWMA : total 10.69%; years from 9.8 to 12.8%
unscaled: years from 11.0 to 41.0%Both stabilize the risk a lot: unscaled, the annual volatility of the index ranges from 11% to 41%; scaled, from 8.6% to 11.5% with the GJR and from 9.8% to 12.8% with the EWMA. The GJR hits the level better (10.04% versus 10.69%), because the EWMA falls short at the onset of crises. Note: here the GJR is estimated with the whole sample, so its advantage is somewhat inflated; in a real backtest it has to be estimated with the past only.
- In
universo2each stock has a jump on the day of the reaction to its earnings (level 2 README). What does a GARCH estimated on the returns of a stock do on the day after that jump, and how would you correct it?
Solution
The jump enters as an enormous and the GARCH, which does not know it was an earnings day, raises the
forecast variance for the following days and lets it come down with its half-life: it overestimates the risk for
weeks and a volatility-based sizing reduces the position for no reason. In addition, the jumps inflate and
the tails of the t. The usual corrections are three: add to the variance equation a variable equal to 1 on the
earnings day (arch_model accepts regressors x in the mean, not in the variance, so that extension has to be coded),
exclude those days from the estimation and treat their risk separately (lesson 2.3.5), or estimate the GARCH on the
index or the sector, without idiosyncratic jumps, and add the event risk only when it is due.
Footnotes
-
R. F. Engle, “Autoregressive Conditional Heteroscedasticity with Estimates of the Variance of United Kingdom Inflation”, Econometrica 50(4), 1982, 987-1007, https://doi.org/10.2307/1912773; D. B. Nelson, “Conditional Heteroskedasticity in Asset Returns: A New Approach”, Econometrica 59(2), 1991, 347-370, https://doi.org/10.2307/2938260; R. F. Engle and V. K. Ng, “Measuring and Testing the Impact of News on Volatility”, Journal of Finance 48(5), 1993, 1749-1778, https://doi.org/10.1111/j.1540-6261.1993.tb05127.x. ↩
Each answer is folded under its question
Self-assessment
A GARCH(1,1) has α = 0.10 and β = 0.88. What is the half-life of a volatility shock?
- AAbout 2 sessions
- BAbout 98 sessions
- CAbout 34 sessions: the deviation of the variance from its long-run level is multiplied by α + β = 0.98 every day, and ln 0.5 / ln 0.98 ≈ 34
- DInfinite
Show answer
Answer C. About 34 sessions: the deviation of the variance from its long-run level is multiplied by α + β = 0.98 every day, and ln 0.5 / ln 0.98 ≈ 34
The h-day forecast of the variance is the long-run one plus (α + β) to the power of h times today's deviation from it. The half-life is the h that leaves the deviation at half. With persistence 0.98, 34 sessions; with the true persistence of the market factor in the data (0.945), 12.
The GJR estimated on the equal-weighted index has a persistence of 0.976, but the generator's market factor has 0.945. What explains the difference?
- AA bug in the arch library
- BThat the generator's long-run volatility changes in stretches (calm, crisis): a GARCH with a single long-run level interprets those regime changes as very persistent shocks
- CThat the index has more stocks
- DThat Student's t has fat tails
Show answer
Answer B. That the generator's long-run volatility changes in stretches (calm, crisis): a GARCH with a single long-run level interprets those regime changes as very persistent shocks
It is a known result (Lamoureux and Lastrapes, 1990). In a calm stretch with constant long-run volatility, the estimated persistence drops to 0.96. A half-life of 28 sessions mixes the dynamics of the shocks with that of the regimes.
In the GJR news impact curve, a 3% drop takes tomorrow's annual volatility to 24.6% and a 3% rise, to 19.5%. Which parameter produces the difference?
- Aβ
- Bω
- CThe degrees of freedom of the t
- Dγ: the term that only acts when the shock is negative
Show answer
Answer D. γ: the term that only acts when the shock is negative
In the GJR, the effect of ε² on tomorrow's variance is α if ε > 0 and α + γ if ε < 0. With α = 0.033 and γ = 0.100, a drop weighs four times as much as a rise of the same size: it is the leverage effect that lesson 1.6.2 measured without a model.
Why is the QLIKE loss used to compare variance forecasts instead of the squared error?
- ABecause the actual data point (r²) is a very noisy proxy for the variance: QLIKE ranks the models the same as if the true variance were known and, since it depends on the ratio r²/h, it does not let a few crisis days dominate the mean
- BBecause it is easier to compute
- CBecause it always favors the GARCH
- DBecause it does not need the return
Show answer
Answer A. Because the actual data point (r²) is a very noisy proxy for the variance: QLIKE ranks the models the same as if the true variance were known and, since it depends on the ratio r²/h, it does not let a few crisis days dominate the mean
Patton (2011) derives which losses rank models the same as with the true variance even though the proxy is noisy; QLIKE is one of them. Since it depends on r²/h, a day with a variance a hundred times larger does not weigh a hundred times more, and it penalizes falling short more than overshooting.
In sample, the GJR improves the GARCH log-likelihood by 26 points; out of sample (2019-2025), its QLIKE is worse. Which conclusion is correct?
- AThat the GJR is badly implemented
- BThat the leverage effect does not exist in these data
- CThat the EWMA should be used
- DThat the best in-sample fit does not guarantee a better forecast: on this index, the GJR forecasts very little volatility after bull streaks and pays for it on days with large unexpected moves. The honest comparison is out of sample
Show answer
Answer D. That the best in-sample fit does not guarantee a better forecast: on this index, the GJR forecasts very little volatility after bull streaks and pays for it on days with large unexpected moves. The honest comparison is out of sample
The asymmetry is real (it is planted and the likelihood ratio test detects it), but forecasting is a different task. On the course's index futures, GARCH and GJR tie out of sample; both clearly beat the EWMA and the 63-session window.
Key points
- GARCH(1,1): today's variance = ω + α · (yesterday's shock)² + β · (yesterday's variance). α measures the reaction to news; β, the memory; α + β, the persistence.
- The EWMA of lesson 1.6.1 is a GARCH without ω and without a long-run level: it does not return to any mean. The GARCH does, to ω/(1 − α − β).
- Student's t improves the log-likelihood by 185 points versus the normal: fat tails do not disappear when standardizing.
- GJR and EGARCH capture the leverage effect; on the course index, γ = 0.100 versus the generator's 0.11.
- The estimated persistence (0.976, half-life of 28 sessions) is inflated by the regime changes in long-run volatility; in a calm stretch it drops to 0.96.
- Out of sample, the GARCH models reduce the QLIKE loss versus the EWMA and the rolling window; asymmetry does not always help forecasting.
Resources
Documentation
Books
- Bollerslev, T. (1986): Generalized autoregressive conditional heteroskedasticity. Journal of Econometrics 31(3), 307-327
- Glosten, L. R., Jagannathan, R. and Runkle, D. E. (1993): On the Relation between the Expected Value and the Volatility of the Nominal Excess Return on Stocks. Journal of Finance 48(5), 1779-1801
- Lamoureux, C. G. and Lastrapes, W. D. (1990): Persistence in Variance, Structural Change, and the GARCH Model. Journal of Business & Economic Statistics 8(2), 225-234
- Patton, A. J. (2011): Volatility forecast comparison using imperfect volatility proxies. Journal of Econometrics 160(1), 246-256