The volatility-forecasting tournament¶

Phase 3: do realized measures actually help? HAR & Realized GARCH vs GARCH & SV¶

The arc has built two families of volatility model. One uses only daily returns to infer volatility — GARCH and stochastic volatility (SV). The other uses intraday realized measures — HAR (a regression on past $RV$) and Realized GARCH (a latent-variance model driven by $RV$). This notebook runs them against each other on the task that matters: forecasting next day's realized variance, out of sample.

Each model is estimated once on 2000–2008 and rolled forward over 2008–2013 (including the crisis), producing 1-step-ahead variance forecasts scored by QLIKE (Patton's robust loss). The question: is the extra information in intraday data worth it? Data: spx_rv_ret.csv; engines from har/realgarch.py/garch_scratch.py/sv_scratch.py+svgarch_forecast.py.

In [1]:
import numpy as np, pandas as pd, matplotlib.pyplot as plt
import realgarch as RG, garch_scratch as G, sv_scratch as S, svgarch_forecast as F

df = pd.read_csv("spx_rv_ret.csv", parse_dates=["date"])
r = df["ret"].values; RV = df["rv"].values * 1e4; dates = df["date"].values; l = np.log(RV)
idx = np.arange(22, len(RV)); ntr = int(0.6 * len(idx)); split_i = idx[ntr]
test = idx[ntr:]; rv_te = RV[test]

# HAR (log-RV): regress log RV on daily/weekly/monthly averages of past log RV
d = l[idx-1]; w = np.array([l[t-5:t].mean() for t in idx]); m = np.array([l[t-22:t].mean() for t in idx])
X = np.column_stack([np.ones(len(idx)), d, w, m]); y = l[idx]
b = np.linalg.solve(X[:ntr].T@X[:ntr], X[:ntr].T@y[:ntr]); s2 = np.var(y[:ntr]-X[:ntr]@b)
f_har = np.exp(X[ntr:]@b + s2/2)

# Realized GARCH: forecast RV via the measurement equation
th, logh0 = RG.mle(r[:split_i], RV[:split_i]); o,be,ga,xi,phi,t1,t2,su = RG._unpack(th)
f_rg = np.exp(xi + phi*RG.filter_logh(th, r, l, logh0) + su**2/2)[test]

# standard GARCH-t (returns only)
thg,_,_ = G.garch_mle(r[:split_i], student=True); r2,_ = G.prepare(r); neg=(r<0).astype(float)
f_g = G.garch_var(thg, r2, float(np.var(r[:split_i])), neg, False)[test]

# stochastic volatility (returns only, particle-filter variance forecast)
Dsv = S.sv_gibbs(r[:split_i], niter=4000, burn=1000, seed=3)
mu,ph,sg = Dsv["mu"].mean(), Dsv["phi"].mean(), Dsv["sigma"].mean()
f_sv = F.sv_particle_filter(r, mu, ph, sg, N=3000, seed=1, alphas=())[2]["var"][test]

def qlike(a,f): f=np.maximum(f,1e-8); return a/f - np.log(a/f) - 1
M = {"HAR": f_har, "Realized GARCH": f_rg, "GARCH-t": f_g, "SV": f_sv}
print("Out-of-sample RV forecast, mean QLIKE (%d test days, lower = better):" % len(test))
for k,f in M.items():
    print("  %-15s %.4f  [%s]" % (k, qlike(rv_te,f).mean(),
          "uses realized info" if k in ("HAR","Realized GARCH") else "returns only"))
Out-of-sample RV forecast, mean QLIKE (1375 test days, lower = better):
  HAR             0.2232  [uses realized info]
  Realized GARCH  0.2221  [uses realized info]
  GARCH-t         0.2962  [returns only]
  SV              0.2911  [returns only]
In [2]:
dte = pd.to_datetime(dates[test]); cols = {"HAR":"firebrick","Realized GARCH":"darkorange","GARCH-t":"seagreen","SV":"steelblue"}
fig, ax = plt.subplots(1, 2, figsize=(13.5, 4.6))
for k in M: ax[0].plot(dte, np.cumsum(qlike(rv_te, M[k])), color=cols[k], lw=1.6, label=k)
ax[0].set_ylabel("cumulative QLIKE loss"); ax[0].legend(fontsize=8)
ax[0].set_title("RV-forecast tournament (lower = better)\nrealized-measure models (red/orange) beat returns-only (green/blue)")
wz = (dte>=pd.Timestamp("2008-06-01"))&(dte<=pd.Timestamp("2009-06-01"))
ax[1].plot(dte[wz], np.sqrt(252*rv_te[wz]), color="0.7", lw=1, label="actual RVol")
for k in ["HAR","Realized GARCH","GARCH-t"]:
    ax[1].plot(dte[wz], np.sqrt(252*M[k][wz]), color=cols[k], lw=1.3, label=k)
ax[1].set_ylabel("annualized vol (%)"); ax[1].legend(fontsize=8)
ax[1].set_title("2008 crisis: realized models adapt faster\n(GARCH lags on the way down)")
for a in ax:
    for lab in a.get_xticklabels(): lab.set_rotation(20)
plt.tight_layout(); plt.show()
No description has been provided for this image

Results — realized information wins¶

model QLIKE information
Realized GARCH 0.222 realized
HAR 0.223 realized
SV 0.291 returns only
GARCH-t 0.296 returns only
  • A clean two-camp split. The models that use realized measures (HAR, Realized GARCH) forecast RV about 25% better (QLIKE ~0.22 vs ~0.29) than the ones using only returns (GARCH-t, SV). The cumulative-loss curves separate into two bundles and stay separated. Within each camp the models nearly tie — Realized GARCH edges HAR; SV edges GARCH-t (as in sv_vs_garch).
  • Why: adaptation speed. In the 2008 crisis (right) the realized models rise and fall with actual volatility, while GARCH lags on the way down — stuck near 60% into 2009 when realized vol had already returned to ~30%. Feeding a model yesterday's precisely measured variance lets it react a day faster than one inferring variance from a squared return.
  • The verdict of the realized-volatility arc. It matters little whether the realized information enters as a regression (HAR) or a latent-variance model (Realized GARCH) — what matters is using it. Intraday data is the single biggest improvement to volatility forecasting, worth more than the model class.

This closes Phase 3 and the realized-volatility arc: measure RV (Phase 1) → model it (HAR, Phase 2) → bridge it back to GARCH (Realized GARCH) → and prove out-of-sample that realized measures beat returns-only models.

References¶

  • Patton, A. (2011). Volatility forecast comparison using imperfect volatility proxies. J. Econometrics — the QLIKE loss.
  • Hansen, Huang & Shek (2012); Corsi (2009); Andersen, Bollerslev, Diebold & Labys (2003).