Skip to content

Repository files navigation

VaR and Expected Shortfall: Coverage Backtesting

CI Python

This is risk-model validation, not a strategy backtest. Three one-day-ahead VaR/ES forecasting methods run over a fixed multi-asset portfolio, and the deliverable is whether each model's stated coverage holds on realised data: Kupiec unconditional coverage, Christoffersen independence and conditional coverage, and the Basel traffic light. Every forecast for day t is computed from returns up to t−1 only, enforced by construction and verified by a corruption test (tests/test_backtest.py::test_no_lookahead_all_methods).

Part of a three-repo series. Pairs-trading proposes the strategy, the overfitting-audit framework convicts or clears it, and this repo (var-backtesting) risk-measures what survives. This repo is the risk stage: the plan was to measure the residual exposure of whatever survived the audit. Nothing did, so the validation machinery is exercised on a fixed benchmark portfolio instead.

Portfolio: SPY, TLT, GLD, IWM, EFA, equal 20% weights, daily log returns, 2010-01-05 to 2026-08-27 (4,187 days; 3,687 forecast days after the 500-day warm-up).

Headline results

method level breaches expected Kupiec uc Christoffersen ind Christoffersen cc verdict
historical 99% 43 36.9 pass (p=0.32) REJECT (p=1e-4) REJECT (p=3e-4) FAIL
historical 95% 183 184.4 pass (p=0.92) REJECT (p=8e-7) REJECT (p=5e-6) FAIL
ewma 99% 69 36.9 REJECT (p=2e-6) REJECT (p=3e-4) REJECT (p=2e-8) FAIL
ewma 95% 190 184.4 pass (p=0.67) pass (p=0.30) pass (p=0.54) PASS
garch-filtered 99% 41 36.9 pass (p=0.50) REJECT (p=0.011) REJECT (p=0.030) FAIL
garch-filtered 95% 182 184.4 pass (p=0.86) pass (p=0.18) pass (p=0.41) PASS

n = 3,687 forecast days per row; LR tests at 5% significance. Full detail: reports/coverage_table.{csv,md}, reports/coverage_summary.json.

The breach chart

99% VaR vs realised loss

Realised daily returns against the −VaR/−ES lines; breaches in crimson. The ES line sits beyond VaR by construction (ES ≥ VaR always, unit-tested).

Crisis window: COVID-2020

COVID-2020 zoom

99% VaR, 2020-02-01 to 2020-06-30 (104 trading days, 1.0 expected breaches):

method breaches in window max consecutive
historical 10 2
ewma 8 2
garch-filtered 5 2

Longest full-sample 99% breach streaks land on real events: historical, 3 days 2025-04-03 to 07 (tariff selloff); EWMA, 3 days 2015-08-20 to 24 (CNY devaluation); GARCH-filtered, 2 days 2020-02-24 to 25 (COVID onset). The clustering evidence that drives the Christoffersen rejections is these episodes, not diffuse noise.

Interpreting the numbers

At 99%, the level that matters, no method passes everything, and each failure is informative:

  • Gaussian EWMA-parametric VaR fails everything: 69 breaches against 36.9 expected. A normal tail is too thin at 99%; the classic under-capitalisation failure mode of parametric VaR, exactly what Kupiec is built to catch.
  • Historical simulation gets the frequency right (43 vs 36.9) but clusters breaches (Christoffersen ind p = 1e-4): the 500-day window reacts to volatility only after it has already arrived.
  • The GARCH(1,1)-filtered bootstrap is the only method passing Kupiec at 99% (41 vs 36.9) and has the best-behaved breach process, but still fails independence marginally (LR_ind = 6.54, p = 0.011): conditional σ_t cannot fully adapt within multi-day correlation blowouts. Reported as-is; a marginal reject at the best method is more informative than a manufactured pass.

At 95% all three methods pass every LR test: the thinner the tail claimed, the easier the claim is to meet. The Basel traffic light (99% convention) ends green for all three methods on the trailing 250 days.

Method

  • Historical simulation (500-day rolling window): VaR_p = −empirical p-quantile, ES_p = −mean(window ≤ VaR).
  • Parametric EWMA (RiskMetrics, λ = 0.94, μ = 0): σ²_t = λσ²_{t−1} + (1−λ)r²_{t−1}; VaR_p = −Φ⁻¹(p)·σ_t, ES_p = σ_t·φ(Φ⁻¹(p))/p.
  • GARCH(1,1)-filtered bootstrap (FHS): fit ConstantMean+GARCH(1,1) by MLE (arch), refit every 21 days on an expanding window; one-day σ_t from the recursion; r* = μ̂ + σ_t·ê* with ê* resampled from standardised residuals (10,000 paths, seeded), VaR/ES read off the simulated distribution.

Coverage tests: Kupiec POF LR_uc ~ χ²(1); Christoffersen LR_ind ~ χ²(1) on the first-order Markov transition of the breach indicator, LR_cc = LR_uc + LR_ind ~ χ²(2); Basel traffic light on trailing 250-day breach counts (green 0-4, yellow 5-9 with multiplier step-ups 3.40 to 3.85, red 10+, multiplier 4.00).

Reproduce

uv sync                                   # pinned deps, Python 3.12
uv run pytest                             # 28 synthetic unit tests, no network
uv run python experiments/01_run_all.py   # full coverage table + charts (~1 min)
uv run python experiments/02_crisis_window.py
uv run pytest -m integration              # optional: needs data/cache or network

First experiment run fetches prices from yfinance (Stooq fallback) into data/cache/; afterwards everything is offline and deterministic (seeded bootstrap).

Assumptions and limitations

  • Fixed equal weights on log returns imply daily rebalancing; no transaction costs (this is a forecast-validation exercise, not a tradable strategy).
  • EWMA assumes μ = 0 (RiskMetrics convention); the GARCH filter uses normal innovations, and non-normality re-enters only through the residual bootstrap.
  • The Basel 250-day zones are applied to a 5-asset ETF book, not a bank trading book; reported at 99% only (the convention the thresholds assume).
  • What a pass does and does not certify: one-day-ahead coverage frequency and first-order independence of the breach process. It does not certify tail shape beyond the ES estimate, parameter stability across regimes, or behaviour under structural breaks; the marginal Christoffersen reject of the (best) GARCH-filtered model is a live reminder of exactly that.

License

MIT; see LICENSE.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages