←Back to the bookChapter 137 · Take it further
▶ Open in Colab⬇ Download notebookView on GitHub
Chapter 137 · Take It Further
Financial Volatility & Risk · Take It Further
Five ways to sharpen the risk model: why fat tails change VaR, a formal Kupiec backtest, Expected Shortfall beyond VaR, the leverage effect via GJR-GARCH, and scaling VaR to a multi-day horizon.
Statistics, Data Science and AI: A Visual Handbook · John Fisher · 2026

Financial Volatility & Risk: Take It Further¶

Five extensions of the Chapter 137 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.

In [1]:
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/"
In [2]:
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
RECAP · REBUILD
The returns and the GARCH fit
Everything from Chapter 137 in a few cells.
In [3]:
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)
sig_next = np.sqrt(g.forecast(horizon=1).variance.iloc[-1, 0])   # tomorrow's volatility forecast
print("rebuilt GARCH-t fit; tomorrow's VaR99 = %.2f%%" % var_pct(sig_next))
rebuilt GARCH-t fit; tomorrow's VaR99 = 2.52%
EXTENSION 1 · FAT TAILS MATTER
Normal vs Student-t VaR
A thin-tailed model under-reserves.
In [4]:
for dist in ['normal','t']:
    vp = var_pct(cv, dist=dist); viol = (r.values < -vp.values).mean()*100
    print('%-7s VaR99: tomorrow %.2f%%   backtest violations %.2f%% (target 1.00%%)' % (dist, var_pct(sig_next, 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: tomorrow 2.25%   backtest violations 1.75% (target 1.00%)
t       VaR99: tomorrow 2.52%   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.

EXTENSION 2 · THE KUPIEC TEST
Is the violation rate acceptable?
A formal test, not just eyeballing.
In [5]:
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.

EXTENSION 3 · EXPECTED SHORTFALL
How bad is a bad day?
VaR is the doorway; ES is what lies beyond.
In [6]:
var_next = var_pct(sig_next)
tq = stats.t.ppf(0.01, nu)
es_std = stats.t.pdf(tq, nu) / 0.01 * (nu + tq**2) / (nu - 1) * np.sqrt((nu-2)/nu)   # ES of the unit-variance t at 1%
es_next = -(mu - sig_next * es_std)
breach = r.values < -var_pct(cv).values                  # the backtest's breach days
print("tomorrow's 1-day 99%% VaR = %.2f%%  (the loss you exceed 1%% of the time)" % var_next)
print("tomorrow's Expected Shortfall = %.2f%%  (the model's AVERAGE loss on the days you exceed it)" % es_next)
print('on a $1,000,000 position: VaR $%.0f, but a breach loses about $%.0f on average' % (var_next/100*1e6, es_next/100*1e6))
print('check on history: the %d backtest breach days lost %.2f%% on average, against an average VaR of %.2f%% on those days' % (breach.sum(), -r.values[breach].mean(), var_pct(cv).values[breach].mean()))
tomorrow's 1-day 99% VaR = 2.52%  (the loss you exceed 1% of the time)
tomorrow's Expected Shortfall = 3.34%  (the model's AVERAGE loss on the days you exceed it)
on a $1,000,000 position: VaR $25239, but a breach loses about $33423 on average
check on history: the 15 backtest breach days lost 3.48% on average, against an average VaR of 2.79% on those days

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 by construction it is larger than VaR. From the Student-t tail, tomorrow's ES is about 3.3% (33,400 dollars) against a VaR of about 2.5%. History tells a similar story, since the 15 breach days in the backtest lost 3.48% on average when their VaR averaged 2.79%. ES is now the preferred regulatory measure because it looks into the tail rather than just at its edge.

EXTENSION 4 · TEST FOR THE LEVERAGE EFFECT
Does bad news hurt more?
Fit an asymmetric model and let the data answer.
In [7]:
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 evidence of a leverage effect 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 evidence of a leverage effect 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 before assuming. On this synthetic series the asymmetry term is small and slightly negative (gamma about -0.04, p = 0.12) and the AIC barely moves (3822 against 3821), so the data show no leverage effect and 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.

EXTENSION 5 · MULTI-DAY VaR
Risk over a 10-day horizon
GARCH's volatility path against the square-root rule.
In [8]:
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 = -(h*mu + sig_next*np.sqrt(h)*q)          # same mean, tomorrow's volatility held flat for 10 days
print('10-day volatility: GARCH path %.2f%%  vs  flat %.2f%% x sqrt(10) = %.2f%%' % (np.sqrt(cum_var), sig_next, sig_next*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('GARCH is %s: volatility is forecast to %s toward its long-run level' % ('higher' if var_10_garch > var_10_sqrt else 'lower', 'rise' if var_10_garch > var_10_sqrt else 'fall'))
10-day volatility: GARCH path 3.22%  vs  flat 0.99% x sqrt(10) = 3.14%
10-day 99% VaR, GARCH term-structure : 7.84%
10-day 99% VaR, sqrt-time rule        : 7.60%
GARCH is higher: volatility is forecast to rise toward its long-run level

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. Here tomorrow's forecast (0.99%) sits below the long-run 1.24%, so the GARCH path drifts up and its 10-day VaR of 7.84% comes out a little above the square-root rule's 7.60%. Both treat the 10-day return as Student-t with the daily tail, which is an approximation.

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: test for it with GJR/EGARCH. This series shows none, and real equity data usually does.
  • Multi-day VaR: GARCH's term structure follows volatility back toward its long-run level, which the square-root-of-time rule ignores.

Statistics, Data Science and AI: A Visual Handbook · © 2026 John Fisher
←Back to the bookChapter 137 · Take it further