Financial Volatility & Risk: Take It Further¶
Five extensions of the Chapter 132 risk model, each sharpening the risk number: why the fat-tailed distribution matters for VaR, a formal Kupiec backtest, Expected Shortfall (the loss beyond VaR), the leverage effect (bad news raises volatility more), and scaling VaR to a multi-day horizon. We start by rebuilding the fit.
import numpy as np, pandas as pd, matplotlib.pyplot as plt
import seaborn as sns # seaborn = high-level statistical plots (heatmaps, pairplots, count/bar plots)
from matplotlib.colors import ListedColormap
EM="#0284c7"; DEEP="#075985"; LIGHT="#bae6fd"; INK="#1a2138"; GRID="#e6e9f2"; RED="#ef4444"; AMBER="#d97706"; GREEN="#059669"; BLUE="#2563eb"; PUR="#9333ea"; GREY="#94a3b8"; SLATE="#475569"; ORG="#0284c7"; CYAN="#0891b2"
plt.rcParams.update({"figure.facecolor":"white","axes.facecolor":"white","figure.dpi":110,"font.size":11,
"axes.edgecolor":GRID,"axes.grid":True,"grid.color":GRID,"axes.axisbelow":True,"axes.spines.top":False,
"axes.spines.right":False,"axes.titlesize":12,"axes.titleweight":"bold","legend.frameon":False})
sns.set_style("whitegrid")
BASE="https://raw.githubusercontent.com/johnfisher-ai/Statistics-Data-Science-AI-Visual-Book/main/data/"
from scipy import stats
import warnings; warnings.filterwarnings('ignore')
try:
from arch import arch_model
except ImportError:
import subprocess, sys; subprocess.run([sys.executable,'-m','pip','install','-q','arch']); from arch import arch_model
try: raw = pd.read_excel('../../data/case-study-financial-volatility-and-risk--stock_returns.xlsx', sheet_name='Data')
except FileNotFoundError: raw = pd.read_excel(BASE + 'case-study-financial-volatility-and-risk--stock_returns.xlsx', sheet_name='Data')
raw['date']=pd.to_datetime(raw['date']); r = raw.set_index('date')['stock_return']
g = arch_model(r, mean='Constant', vol='GARCH', p=1, q=1, dist='t').fit(disp='off')
mu, nu = g.params['mu'], g.params['nu']; cv = g.conditional_volatility
def var_pct(sigma, level=0.99, dist='t'):
q = stats.t.ppf(1-level, nu)*np.sqrt((nu-2)/nu) if dist=='t' else stats.norm.ppf(1-level)
return -(mu + sigma*q)
print('rebuilt GARCH-t fit; today VaR99 = %.2f%%' % var_pct(cv.iloc[-1]))
rebuilt GARCH-t fit; today VaR99 = 2.58%
for dist in ['normal','t']:
vp = var_pct(cv, dist=dist); viol = (r.values < -vp.values).mean()*100
print('%-7s VaR99: today %.2f%% backtest violations %.2f%% (target 1.00%%)' % (dist, var_pct(cv.iloc[-1], dist=dist), viol))
print('the NORMAL assumption sets VaR too low, so it is breached MORE than 1%% -> it under-reserves for crashes')
normal VaR99: today 2.30% backtest violations 1.75% (target 1.00%) t VaR99: today 2.58% backtest violations 1.19% (target 1.00%) the NORMAL assumption sets VaR too low, so it is breached MORE than 1%% -> it under-reserves for crashes
Financial returns have fat tails, so a normal VaR sits too close in and is breached more than the promised 1% of the time, it systematically under-reserves for extreme days. The Student-t VaR pushes the threshold out to match the real tail, and its violation rate lands near 1%. For risk, the distribution's tail is the whole point.
vp = var_pct(cv); x = int((r.values < -vp.values).sum()); T = len(r); p0 = 0.01
phat = x/T
LR = -2*(np.log((1-p0)**(T-x) * p0**x) - np.log((1-phat)**(T-x) * phat**x))
pval = 1 - stats.chi2.cdf(LR, 1)
print('violations %d/%d = %.2f%% (target %.0f%%)' % (x, T, 100*phat, 100*p0))
print('Kupiec LR = %.2f, p = %.2f -> %s' % (LR, pval, 'PASS: violation rate is consistent with 1%' if pval>0.05 else 'FAIL: recalibrate'))
violations 15/1260 = 1.19% (target 1%) Kupiec LR = 0.44, p = 0.51 -> PASS: violation rate is consistent with 1%
The Kupiec proportion-of-failures test asks whether the observed violation rate is statistically consistent with the target (1%). A large p-value means the difference is within sampling noise, the VaR passes. This is the formal version of the backtest, and regulators expect it.
vp_today = var_pct(cv.iloc[-1]); tail = r.values[r.values < -vp_today]
es_pct = -tail.mean() if len(tail) else np.nan
print('1-day 99%% VaR = %.2f%% (the loss you exceed 1%% of the time)' % vp_today)
print('Expected Shortfall = %.2f%% (the AVERAGE loss on the days you exceed it)' % es_pct)
print('on a $1,000,000 position: VaR $%.0f, but a breach loses about $%.0f on average' % (vp_today/100*1e6, es_pct/100*1e6))
1-day 99% VaR = 2.58% (the loss you exceed 1% of the time) Expected Shortfall = 3.38% (the AVERAGE loss on the days you exceed it) on a $1,000,000 position: VaR $25766, but a breach loses about $33847 on average
Value-at-Risk tells you the threshold, but not how bad the breaches are. Expected Shortfall (ES / CVaR), the average loss on the days VaR is exceeded, answers that, and it is always worse than VaR. ES is now the preferred regulatory measure precisely because it looks into the tail rather than just at its edge.
gjr = arch_model(r, mean='Constant', vol='GARCH', p=1, o=1, q=1, dist='t').fit(disp='off')
gamma, pval = gjr.params['gamma[1]'], gjr.pvalues['gamma[1]']
verdict = 'significant leverage effect' if (pval<0.05 and gamma>0) else 'no significant asymmetry in THIS series'
print('GJR-GARCH asymmetry gamma[1] = %.3f (p = %.3f) -> %s' % (gamma, pval, verdict))
print('AIC: symmetric GARCH %.0f vs GJR %.0f (lower is better)' % (g.aic, gjr.aic))
print('reminder: gamma > 0 would mean a NEGATIVE shock adds MORE to tomorrow volatility than a positive one')
GJR-GARCH asymmetry gamma[1] = -0.041 (p = 0.117) -> no significant asymmetry in THIS series AIC: symmetric GARCH 3822 vs GJR 3821 (lower is better) reminder: gamma > 0 would mean a NEGATIVE shock adds MORE to tomorrow volatility than a positive one
In real equity markets, a drop tends to spike volatility more than an equal-sized gain, the leverage effect, captured by adding an asymmetry term (GJR-GARCH or EGARCH). The lesson here is to test, not assume: on this synthetic series the term is small and not significant (our returns happen to be symmetric), so a plain GARCH is fine. On real stock data you would usually find it positive and significant, and ignoring it would understate downside risk, exactly the risk VaR is meant to measure.
h=10; fc = g.forecast(horizon=h); cum_var = fc.variance.iloc[-1].values.sum() # variance of the 10-day return
q = stats.t.ppf(0.01, nu)*np.sqrt((nu-2)/nu)
var_10_garch = -(h*mu + np.sqrt(cum_var)*q)
var_10_sqrt = var_pct(cv.iloc[-1])*np.sqrt(h)
print('10-day 99%% VaR, GARCH term-structure : %.2f%%' % var_10_garch)
print('10-day 99%% VaR, sqrt-time rule : %.2f%%' % var_10_sqrt)
print('the sqrt-rule assumes constant volatility; GARCH accounts for mean-reversion, so they differ')
10-day 99% VaR, GARCH term-structure : 7.84% 10-day 99% VaR, sqrt-time rule : 8.15% the sqrt-rule assumes constant volatility; GARCH accounts for mean-reversion, so they differ
Risk limits often span more than one day. The crude square-root-of-time rule multiplies the 1-day VaR by the square root of the horizon, but it assumes volatility stays flat. GARCH instead forecasts the volatility path and sums the variances, correctly accounting for mean reversion, so its multi-day VaR reflects where risk is actually heading rather than freezing today's level. The two differ, and the gap widens the more today's volatility sits away from its long-run average.
Take-it-further summary, in plain terms¶
- Fat tails matter: a normal VaR under-reserves; use Student-t so violations land near the target.
- Backtest formally with the Kupiec test, not just by eye.
- Expected Shortfall reports the average loss beyond VaR, the size of a bad day.
- Leverage effect: bad news raises volatility more, capture it with GJR/EGARCH.
- Multi-day VaR: GARCH's term structure beats the naive square-root-of-time rule.