GJR-GARCH from scratch — the leverage effect, five methods¶
The asymmetric corner of the Phase-2 trio (from-scratch · PyMC · R)¶
The symmetric GARCH (garch_python.ipynb) treats good and bad news alike. The GJR-GARCH (Glosten–Jagannathan–Runkle) adds a leverage term $\gamma$ that lets negative returns raise volatility more:
$$\sigma_t^2=\omega+\big(\alpha+\gamma\,\mathbf 1[r_{t-1}<0]\big)r_{t-1}^2+\beta\sigma_{t-1}^2.$$
Good news moves vol by $\alpha$, bad news by $\alpha+\gamma$; $\gamma>0$ is the leverage effect. The same five estimators from garch_scratch.py run on the extended parameter vector $(\omega,\alpha,\gamma,\beta)$ (+ $\nu$ for Student-t) — since Gauss–Hermite returns each model's marginal likelihood, we rank GARCH vs GJR × Normal vs t by Bayes factor.
The full model¶
Data. $r_t,\ t=1,\dots,T$ — daily S&P 500 log-returns (in %), demeaned so $\sum_t r_t = 0$. Let $\mathcal F_{t-1}$ denote the information up to $t-1$.
Observation (return) equation. $$r_t = \sigma_t\,\varepsilon_t,\qquad \varepsilon_t \overset{iid}{\sim} D(0,1),$$ with $\sigma_t^2 = \mathrm{Var}(r_t\mid\mathcal F_{t-1})$ the conditional variance and $D$ a zero-mean, unit-variance innovation.
Conditional variance — GJR-GARCH(1,1). $$\boxed{\ \sigma_t^2 \;=\; \omega \;+\; \big(\alpha + \gamma\,\mathbf 1[r_{t-1}<0]\big)\,r_{t-1}^2 \;+\; \beta\,\sigma_{t-1}^2\ }$$ initialized at $\sigma_1^2 = \widehat{\mathrm{Var}}(r)$ (the sample variance). A good-news shock ($r_{t-1}\ge0$) feeds volatility through $\alpha$; a bad-news shock ($r_{t-1}<0$) through $\alpha+\gamma$. Setting $\gamma=0$ recovers the symmetric GARCH(1,1).
Innovation distribution (fit both):
- Normal: $\varepsilon_t\sim N(0,1)$, i.e. $r_t\mid\mathcal F_{t-1}\sim N(0,\sigma_t^2)$.
- Student-t: $\varepsilon_t\sim t_\nu$ standardized to unit variance — the conditional density of $r_t$ is Student-$t_\nu$ with scale $s_t=\sigma_t\sqrt{(\nu-2)/\nu}$, so that $\mathrm{Var}(r_t\mid\mathcal F_{t-1})=\sigma_t^2$ exactly, with tail index $\nu>2$.
Parameters. $\theta=(\omega,\alpha,\gamma,\beta)$ for the Gaussian GJR, and $\theta=(\omega,\alpha,\gamma,\beta,\nu)$ for the Student-t GJR:
| symbol | role |
|---|---|
| $\omega>0$ | baseline variance level |
| $\alpha\ge0$ | ARCH / good-news impact |
| $\gamma$ | leverage — extra impact of bad news ($\gamma>0$ = leverage effect) |
| $\beta\ge0$ | GARCH / volatility persistence |
| $\nu>2$ | Student-$t$ tail (fatter as $\nu\downarrow$) |
Admissible region (the support of the prior): $$\omega>0,\quad \alpha\ge0,\quad \beta\ge0,\quad \alpha+\gamma\ge0,\quad \underbrace{\alpha+\tfrac12\gamma+\beta<1}_{\text{covariance stationarity}},\quad \nu>2 .$$ The $\tfrac12\gamma$ appears because a symmetric shock is negative half the time, so the average news-impact coefficient is $\alpha+\tfrac12\gamma$.
Likelihood. Running the recursion gives $\sigma_1^2,\dots,\sigma_T^2$; with $\ell_t$ the conditional log-density of $r_t$ (Normal or standardized-$t$), $$\log L(\theta)=\sum_{t=1}^{T}\ell_t\!\big(r_t;\ \sigma_t^2(\theta),\ \nu\big).$$
Prior. Flat (uniform) on the admissible region above, so the log-posterior equals the log-likelihood there (up to a constant). Every estimator below — the MLE and the four Bayesian methods (Gauss–Hermite quadrature, importance sampling, Metropolis–Hastings, griddy Gibbs) — targets this same posterior; the Gauss–Hermite marginal likelihood $\int L(\theta)\,d\theta$ over the region gives the Bayes factors used for model comparison.
import numpy as np, pandas as pd, matplotlib.pyplot as plt, scipy.stats as st
import garch_scratch as G
d = pd.read_csv("sp500ret.csv", parse_dates=["date"]); r = d["ret"].values - d["ret"].values.mean()
r2, s0 = G.prepare(r); print("%d daily returns" % len(r))
# MLE + log marginal likelihood for the 2x2 family (Gauss-Hermite gives logML for the Bayes factors)
fits = {}
for tag, gj, stu, K in [("GARCH-N", False, False, 12), ("GARCH-t", False, True, 9),
("GJR-N", True, False, 8), ("GJR-t", True, True, 7)]:
mle, se, cov = G.garch_mle(r, gjr=gj, student=stu)
logml = G.gh_quadrature(r, mle, cov, K=K, gjr=gj)["logml"]
fits[tag] = dict(mle=mle, se=se, cov=cov, gjr=gj, student=stu, logml=logml, names=G.names(gj, stu))
print("%-8s logML %.1f %s" % (tag, logml, " ".join("%s %.4f" % (n, v) for n, v in zip(G.names(gj, stu), mle))))
print("\nlog Bayes factors vs GARCH-N: " +
" ".join("%s %+.1f" % (t, fits[t]["logml"] - fits["GARCH-N"]["logml"]) for t in fits))
5523 daily returns GARCH-N logML -7558.7 omega 0.0135 alpha 0.0881 beta 0.9046
GARCH-t logML -7359.5 omega 0.0060 alpha 0.0611 beta 0.9358 nu 6.2174 GJR-N logML -7482.3 omega 0.0186 alpha 0.0078 beta 0.9096 gamma 0.1332
GJR-t logML -7315.6 omega 0.0119 alpha 0.0083 beta 0.9244 gamma 0.1147 nu 6.7725 log Bayes factors vs GARCH-N: GARCH-N +0.0 GARCH-t +199.2 GJR-N +76.4 GJR-t +243.1
# five methods on GJR-N -> the leverage parameter gamma, recovered identically
f = fits["GJR-N"]; mle, se, cov = f["mle"], f["se"], f["cov"]
gh = G.gh_quadrature(r, mle, cov, K=8, gjr=True)
imp = G.importance_sampling(r, mle, cov, N=25000, gjr=True)
mh = G.metropolis_hastings(r, mle, cov, N=12000, burn=3000, scale=0.55, gjr=True)
gg = G.griddy_gibbs(r, mle, se, N=2500, burn=600, G=40, gjr=True)
gi = f["names"].index("gamma")
print("gamma (leverage) by method:")
for lab, m in [("MLE", mle), ("Gauss-Hermite", gh["mean"]), ("importance", imp["mean"]),
("Metropolis-H", mh["mean"]), ("griddy Gibbs", gg["mean"])]:
print(" %-14s %.4f" % (lab, m[gi]))
print("news-impact ratio (alpha+gamma)/alpha = %.1f x (bad news vs good news)" % ((mle[1]+mle[gi]) / mle[1]))
gamma (leverage) by method: MLE 0.1332 Gauss-Hermite 0.1349 importance 0.1350 Metropolis-H 0.1361 griddy Gibbs 0.1334 news-impact ratio (alpha+gamma)/alpha = 18.1 x (bad news vs good news)
fig, ax = plt.subplots(1, 3, figsize=(15, 4.4))
# (A) gamma posterior: five methods overlap
a = ax[0]
a.hist(mh["theta"][:, gi], bins=55, density=True, color="0.82", label="Metropolis-H")
a.hist(gg["theta"][:, gi], bins=55, density=True, histtype="step", color="seagreen", lw=1.6, label="griddy Gibbs")
a.hist(imp["theta"][:, gi], bins=55, weights=imp["w"], density=True, histtype="step", color="goldenrod", lw=1.6, label="importance")
xs = np.linspace(*a.get_xlim(), 300); a.plot(xs, st.norm.pdf(xs, gh["mean"][gi], np.sqrt(gh["cov"][gi, gi])), color="steelblue", lw=2.2, label="Gauss-Hermite")
a.axvline(mle[gi], color="firebrick", ls="--", lw=1.6, label="MLE"); a.axvline(0, color="k", lw=1)
a.set_xlabel(r"$\gamma$ (leverage)"); a.set_yticks([]); a.set_title(r"Leverage $\gamma=%.3f\gg0$ — five methods agree" % mle[gi]); a.legend(fontsize=7.5)
# (B) news-impact curve: symmetric GARCH-t vs asymmetric GJR-t
mT = fits["GARCH-t"]["mle"]; mGt = fits["GJR-t"]["mle"]; gj = fits["GJR-t"]["names"].index("gamma")
x = np.linspace(-6, 6, 400)
nicG = mT[0] + mT[1] * x**2 + mT[2] * (mT[0] / (1 - mT[1] - mT[2]))
sbar = mGt[0] / (1 - mGt[1] - mGt[gj]/2 - mGt[2])
nicJ = mGt[0] + (mGt[1] + mGt[gj] * (x < 0)) * x**2 + mGt[2] * sbar
ax[1].plot(x, nicG, color="steelblue", lw=2, label="GARCH-t (symmetric)")
ax[1].plot(x, nicJ, color="firebrick", lw=2, label="GJR-t (asymmetric)")
ax[1].axvline(0, color="0.7", lw=.7); ax[1].set_xlabel("return shock $r_{t-1}$ (%)"); ax[1].set_ylabel("next-day variance")
ax[1].set_title("News-impact curve: GJR steeper for bad news"); ax[1].legend(fontsize=8)
# (C) model comparison by log marginal likelihood
tags = list(fits); ml = np.array([fits[t]["logml"] for t in tags]); bi = int(np.argmax(ml))
ax[2].barh(range(len(tags)), ml, color=["firebrick" if i == bi else "steelblue" for i in range(len(tags))])
ax[2].set_yticks(range(len(tags))); ax[2].set_yticklabels(tags); ax[2].invert_yaxis()
ax[2].set_xlim(ml.min() - 30, ml.max() + 15)
for i, v in enumerate(ml): ax[2].text(v, i, " %.0f" % v, va="center", fontsize=8)
ax[2].set_xlabel("log marginal likelihood"); ax[2].set_title("GJR-t wins (leverage + fat tails)")
plt.tight_layout(); plt.savefig("garch_asym_python.png", dpi=120, bbox_inches="tight"); plt.show()
# cross-check: the arch package GJR-GARCH reproduces the from-scratch leverage estimate
from arch import arch_model
aG = arch_model(r, mean="Zero", vol="GARCH", p=1, o=1, q=1, dist="t").fit(disp="off") # o=1 -> GJR
mGt = fits["GJR-t"]["mle"]; gj = fits["GJR-t"]["names"].index("gamma")
print("arch GJR-t : omega %.4f alpha %.4f gamma %.4f beta %.4f nu %.1f"
% (aG.params["omega"], aG.params["alpha[1]"], aG.params["gamma[1]"], aG.params["beta[1]"], aG.params["nu"]))
print("scratch : omega %.4f alpha %.4f gamma %.4f beta %.4f nu %.1f"
% (mGt[0], mGt[1], mGt[gj], mGt[2], mGt[-1]))
print("-> the package and the from-scratch GJR agree; garch_asym_pymc (PyMC) and garch_asym_R (rugarch) close the loop.")
arch GJR-t : omega 0.0120 alpha 0.0084 gamma 0.1148 beta 0.9243 nu 6.8 scratch : omega 0.0119 alpha 0.0083 gamma 0.1147 beta 0.9244 nu 6.8 -> the package and the from-scratch GJR agree; garch_asym_pymc (PyMC) and garch_asym_R (rugarch) close the loop.
Results¶
- Leverage is large and unambiguous: $\gamma\approx0.13$ (GJR-N), entirely above 0 and recovered identically by all five estimators. With $\alpha\approx0.008$, a negative shock moves next-day volatility by $\alpha+\gamma\approx0.14$ versus $\approx0.008$ for a positive shock — bad news raises volatility roughly 18× more than good news of the same size (the notebook computes the exact ratio at 18.1). The news-impact curve shows the kink directly.
- Model comparison (Bayes factors, from the Gauss–Hermite marginal likelihoods): GARCH-N $-7559$ → GARCH-t $-7360$ (fat tails, $+199$) → GJR-N $-7482$ (leverage, $+77$) → GJR-t $-7316$ (both; best overall). Leverage and fat tails each earn their keep, and the asymmetric fat-tailed model wins.
- Cross-check: the
archpackage's GJR fit matches the from-scratch $(\omega,\alpha,\gamma,\beta,\nu)$ to the decimal.
This is the from-scratch corner of the Phase-2 (asymmetric) trio; garch_asym_pymc.ipynb fits the same GJR via PyMC, and garch_asym_R.ipynb via rugarch (gjrGARCH / eGARCH).