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.

Fig. — News impact curve: annual volatility forecast for tomorrow by today's return, with today's variance at its long-run level. GARCH-t, GJR-t and EGARCH-t estimated on the synthetic equal-weighted index, 2006-2025
Level 2 lessons
  1. 2.1 · Statistics and econometrics for trading

    1. 2.1.1 Hypothesis tests, p-values, bootstrap and regression with robust standard errors
    2. 2.1.2 Stationarity: ADF, KPSS, differencing and fractional differencing
    3. 2.1.3 ARMA and ARIMA models: autocorrelation (ACF) and partial autocorrelation (PACF)
    4. 2.1.4 Volatility models: ARCH, GARCH, EGARCH and GJR with the arch library
    5. 2.1.5 Cointegration: Engle-Granger, Johansen and the half-life of mean reversion
    6. 2.1.6 Kalman filter for dynamic hedge ratios
    7. 2.1.7 Regime detection: hidden Markov models and structural breaks
    8. 2.1.8 The multiple testing problem: Bonferroni, FDR and the “factor zoo”
  2. 2.2 · Anatomy of a trading system

    1. 2.2.1 Edge hypotheses: risk, behavior, structure and information
    2. 2.2.2 Universe, signal, filter, entry, exit, sizing and risk management
    3. 2.2.3 Frequencies: intraday, swing and position trading, and the capacity of a strategy
    4. 2.2.4 Research journal and experiment tracking against data snooping
  3. 2.3 · Strategy families

    1. 2.3.1 Trend following and momentum
    2. 2.3.2 Mean reversion: RSI and Bollinger, intraday reversal and gaps
    3. 2.3.3 Pairs trading and statistical arbitrage: distance, cointegration and Kalman
    4. 2.3.4 Carry in currencies, futures and bonds; seasonality and calendar effects
    5. 2.3.5 Factors (value, quality, low volatility, size) and event strategies
    6. 2.3.6 Introduction to market making: inventory, spread and adverse selection (Avellaneda-Stoikov)
    7. 2.3.7 Crypto-specific strategies: momentum, funding arbitrage and basis
  4. 2.4 · Backtesting: engines and paradigms

    1. 2.4.1 Vectorized backtest from scratch: matrices, lag, costs and limits
    2. 2.4.2 Event-driven backtest from scratch: loop, portfolio, orders and fills
    3. 2.4.3 vectorbt: massive parameter sweeps and portfolio simulation
    4. 2.4.4 backtesting.py and backtrader: two teaching engines and their status
    5. 2.4.5 NautilusTrader: architecture, data, strategies and the same code in backtest and live
    6. 2.4.6 LEAN/QuantConnect and Zipline Reloaded; bt for asset allocation
    7. 2.4.7 Cost modeling: commissions, bid-ask spread, impact, financing and securities borrowing
    8. 2.4.8 Fill modeling: limit orders, queue, partial fills, gaps and stops
  5. 2.5 · Biases and validation

    1. 2.5.1 Backtest biases: look-ahead, survivorship, selection and narrative
    2. 2.5.2 In-sample and out-of-sample: anchored and rolling walk-forward
    3. 2.5.3 Combinatorial purged cross-validation for strategies
    4. 2.5.4 Deflated Sharpe Ratio and Probability of Backtest Overfitting (PBO)
    5. 2.5.5 Monte Carlo: trade resampling, block bootstrap and signal permutations
    6. 2.5.6 Parameter sensitivity: plateaus versus peaks, robustness across assets and periods
    7. 2.5.7 Synthetic data for stress testing: GBM, GARCH and bootstrapping
    8. 2.5.8 “Honest backtest” checklist before moving to paper trading
  6. 2.6 · Performance metrics

    1. 2.6.1 CAGR, Sharpe, Sortino, Calmar, Omega, drawdown and Ulcer Index
    2. 2.6.2 Win rate, payoff, expectancy, alpha, beta, information ratio, turnover and capacity
    3. 2.6.3 Reports with QuantStats or pyfolio-reloaded and custom tearsheets

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 εt=rt−μ\varepsilon_t = r_t - \mu the return minus its mean and σt2\sigma_t^2 its conditional variance (the one forecast for tt with the information up to t−1t-1):

σt2=ω+α εt−12+β σt−12\sigma_t^2 = \omega + \alpha\,\varepsilon_{t-1}^2 + \beta\,\sigma_{t-1}^2
  • α\alpha: how much yesterday's news (εt−12\varepsilon_{t-1}^2) moves today's variance.
  • β\beta: how much of yesterday's variance carries over.
  • α+β<1\alpha + \beta < 1: persistence. The variance returns to its long-run level σLP2=ω/(1−α−β)\sigma^2_{LP} = \omega/(1-\alpha-\beta), and the deviation from it is multiplied by α+β\alpha + \beta every day.

The EWMA with parameter λ\lambda is the case ω=0\omega = 0, α=1−λ\alpha = 1-\lambda, β=λ\beta = \lambda: 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:

ARCH qGARCH 1,1GJR: asymmetricEGARCH: log varEWMA: no levelShocks N or t
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 qq values of ε2\varepsilon^2. It needs many lags for that much memory; GARCH (Bollerslev, 1986) achieves it with one.
  • GJR-GARCH (Glosten, Jagannathan and Runkle, 1993): adds γ It−1 εt−12\gamma\,I_{t-1}\,\varepsilon_{t-1}^2, with It−1=1I_{t-1} = 1 if εt−1<0\varepsilon_{t-1} < 0. Drops weigh α+γ\alpha + \gamma; rises, α\alpha.
  • EGARCH (Nelson, 1991): models ln⁡σt2\ln\sigma_t^2, so it needs no sign restrictions. In the arch version, with et=εt/σte_t = \varepsilon_t/\sigma_t:
ln⁡σt2=ω+α(∣et−1∣−2/π)+γ et−1+βln⁡σt−12\ln\sigma_t^2 = \omega + \alpha\left(|e_{t-1}| - \sqrt{2/\pi}\right) + \gamma\, e_{t-1} + \beta\ln\sigma_{t-1}^2

A negative γ\gamma 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 α=0.03\alpha = 0.03, γ=0.11\gamma = 0.11, β=0.86\beta = 0.86 (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.

IN [01]python
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}")
OUT [01]The equal-weighted index and its ARCH effects
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-146

Annual volatility of 20% and a kurtosis of 10.8: fat tails. The autocorrelation of r2r^2 at lag 1 is 0.16 and Engle's ARCH-LM test (regression of εt2\varepsilon_t^2 on its five lags) rejects with a p-value on the order of 10−14610^{-146}: 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 ε2\varepsilon^2, o asymmetric ones and q of σ2\sigma^2; 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, ω\omega is on the order of 10−610^{-6} and the optimizer struggles (the library warns).

IN [02]python
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)
OUT [02]Normal GARCH, t GARCH, t GJR and t EGARCH
          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.385

Three 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 (ν=5.6\nu = 5.6 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 γ=0.100\gamma = 0.100 is close to the market factor's 0.11, and its α=0.033\alpha = 0.033, to the 0.03. The EGARCH, with γ=−0.068\gamma = -0.068, also captures it, although it fits worse than the GJR (BIC of 14,296 versus 14,273).
  • β\beta 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 γ=0\gamma = 0, so 2(ln⁡LGJR−ln⁡LGARCH)2(\ln L_{GJR} - \ln L_{GARCH}) is distributed as a χ2\chi^2 with one degree of freedom if the null is true.

IN [03]python
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}")
OUT [03]Likelihood ratio: γ = 0?
LR = 52.6; p = 4.1e-13

Persistence and half-life

If today the variance is above its long-run level, the hh-day forecast is

Et[σt+h2]=σLP2+P h(σt2−σLP2)\mathrm{E}_t\left[\sigma_{t+h}^2\right] = \sigma^2_{LP} + P^{\,h}\left(\sigma_t^2 - \sigma^2_{LP}\right)

with PP the persistence: α+β\alpha + \beta in the GARCH, α+β+γ/2\alpha + \beta + \gamma/2 in the GJR (with symmetric shocks, half are negative) and β\beta in the EGARCH (for the log of the variance). The half-life of a shock is the hh that leaves the deviation at half: ln⁡0.5/ln⁡P\ln 0.5 / \ln P.

IN [04]python
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)
OUT [04]Persistence, long-run volatility and half-life
                    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.253

The 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 σLP2\sigma^2_{LP} 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):

IN [05]python
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)
OUT [05]Persistence in calm stretches
                         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.090

In 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 (α\alpha reaches zero in one of them, and γ\gamma 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:

Fig. — Annual conditional volatility of the GJR-t on the synthetic equal-weighted index (every 10 sessions) against the market factor's long-run volatility in each segment of the generator's script, 2006-2025

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 xx%, 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.

IN [06]python
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)
OUT [06]Impact of a 3% drop and a 3% rise
         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.4

In 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 α=0.033\alpha = 0.033, 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 (λ=0.94\lambda = 0.94) and the variance of the previous 63 sessions.

The actual data point is εt2\varepsilon_t^2, 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:

L(εt2,ht)=εt2ht+ln⁡htL(\varepsilon_t^2, h_t) = \frac{\varepsilon_t^2}{h_t} + \ln h_t

with hth_t the forecast variance (up to a constant that does not depend on the model). It depends on the ratio εt2/ht\varepsilon_t^2/h_t, 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.

IN [07]python
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"))
OUT [07]Out-of-sample QLIKE and squared error, 2019-2025
             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.34
Fig. — Mean out-of-sample QLIKE loss (2019-2025) of six forecasts of the synthetic equal-weighted index's daily variance; lower is better

The 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?

IN [08]python
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)
OUT [08]The days on which the GJR loses most against the GARCH
            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.27

They 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:

IN [09]python
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)
OUT [09]Term structure of the forecast from the 2020 peak
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: float64
Fig. — Annual volatility forecast by the GJR-t from the 26 March 2020 peak for each of the next 120 days, against its long-run level (synthetic equal-weighted index)

On 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

UseWhat a GARCH contributesCaveat
Volatility-based sizing (lesson 1.6.1)Forecast with mean reversion: it does not stay low after a crisisRe-estimate with a long window; the parameters change little
VaR and stopsTomorrow's volatility and a t distribution with its νTails: check against the worst real days (lesson 2.5.7)
Multi-day horizonTerm structure of the forecastPersistence includes the regimes: inflated half-life
Synthetic data for stress testsSimulate series with clustering and asymmetry (lesson 2.5.7)The parameters of one stretch are not valid for another
OptionsCompare forecast volatility with implied volatility (level 3)GARCH is a physical forecast, not a risk-neutral one
ReturnsGARCH tTomorrow's vol.Size = target/vol
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

  1. The fictional ETF KAPA (indice2_5min.parquet) has a daily GJR-GARCH-t volatility with α=0.03\alpha = 0.03, γ=0.10\gamma = 0.10, β=0.86\beta = 0.86 and ν=6\nu = 6 (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
IN [10]python
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)
OUT [10]GJR-t with four years of data
          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.00

With 1,002 sessions, the standard errors are the size of the parameters themselves: γ\gamma 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. ν\nu 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.

  1. Scale the index to a target volatility of 10% a year with the conditional volatility of the GJR and with the EWMA, wt=10/σ^tw_t = 10 / \hat\sigma_t, and measure the realized volatility of the scaled series overall and year by year. Which one comes closer to the target?
Solution
IN [11]python
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}%")
OUT [11]Target volatility with GJR and with EWMA
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.

  1. In universo2 each 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 ε2\varepsilon^2 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 α\alpha 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

  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. ↩

Each answer is folded under its question

Self-assessment

  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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