Value-at-Risk is the number desks set daily loss limits with and regulators size capital against — and it has three standard recipes that disagree exactly when it matters. We compute all three on fifteen years of real DAX data, backtest every one of them out of sample, and then recompute the historical quantile with Polars and DuckDB to show what the modern data stack changes (answer: the scaling, never the number).
- 01Download DAX (^GDAXI) closes, 2010–2024 (yfinance)
- 02Historical simulation: empirical 1% and 5% quantiles
- 03Parametric: normal, then Student-t with fitted df
- 04Monte Carlo: 200,000 seeded draws from the fitted t
- 05Backtest all four with a rolling 250-day window + Kupiec POF
- 06Re-do the quantile in Polars expressions and DuckDB SQL
1.The number, and the data
VaR at level α is the loss you do not expect to exceed with probability α over one day — the (1−α)-quantile of returns, negated. CVaR (expected shortfall) is the average loss given a breach, and we report it alongside every method because it is the number you actually want for limits. Our laboratory is the DAX from 2010-01-04 to 2024-12-30: 3,805 daily returns spanning the euro crisis, COVID and the 2022 energy shock. The worst day in the sample is 2020-03-12 at -12.24% — keep that number in mind while the normal distribution tells you it is impossible.
2.Historical simulation
No model, no parameters: sort fifteen years of returns and read the empirical quantile. On the full sample the 99% VaR is 3.42% and the 99% CVaR — the average of the worst 38 days — is 4.60%. At 95% the pair is 1.94% / 2.93%.
import numpy as np
def hist_var_cvar(x, alpha=0.99):
q = np.quantile(x, 1 - alpha) # 1% quantile of returns
return -q, -x[x <= q].mean() # VaR, CVaR
hist_var_cvar(ret.values) # (0.034169, 0.045981)The honesty of the method is also its weakness: it weights a sleepy 2017 day the same as March 2020, and it cannot produce a loss larger than anything already in the window.
3.Parametric: normal, then Student-t
The variance–covariance shortcut assumes a distribution and reads VaR off its formula. With a normal (, daily) the 99% VaR is 2.82% — roughly 60bp below the empirical quantile. Refit the same idea with a Student-t and MLE hands you df = 3.22: violently non-Gaussian tails. The t's 99% VaR of 3.40% lands almost exactly on the historical number, and its CVaR (5.13%) is fatter still. (One desk convention worth knowing: at the one-day horizon many shops zero out μ entirely — at 0.039% a day it is noise against σ, and estimating it adds error without information. We keep it for completeness.)
from scipy import stats
mu, sd = ret.mean(), ret.std(ddof=1)
z = stats.norm.ppf(0.01)
var_normal = -(mu + sd * z) # 2.82%
df, loc, scale = stats.t.fit(ret.values) # df = 3.22
var_t = -(loc + scale * stats.t.ppf(0.01, df)) # 3.40%Where the 60bp comes from is clearest in the left tail itself. Below, the three densities over the loss region from −7.5% to −1.5%: the normal (amber) runs out of probability almost immediately, while the fitted t tracks the kernel estimate of the real data the whole way down.
4.Monte Carlo
Simulate 200,000 one-day scenarios from the fitted t (seed 42) and read the empirical tail of the simulation. On a single linear asset this is a correctness check more than a method — it must reproduce the analytic t to Monte-Carlo error, and it does: 3.39% vs the analytic 3.40%. The machinery earns its keep the moment the book contains options, path dependence, or anything else with no closed-form quantile.
rng = np.random.default_rng(42)
draws = loc + scale * rng.standard_t(df, 200_000)
var_mc = -np.quantile(draws, 0.01) # 3.39%
cvar_mc = -draws[draws <= np.quantile(draws, 0.01)].mean() # 5.08%5.The backtest decides
A VaR is a falsifiable forecast: at 99%, tomorrow's loss should exceed it on about 1% of days. We re-estimate each method on a rolling 250-day window (t and Monte Carlo refit every 20 days, desk-style), forecast one day ahead — 3,555 out-of-sample forecasts per method — and test the breach count with Kupiec's proportion-of-failures likelihood ratio.
The tell-tale shape: the VaR line jumps after each crisis enters the window and relaxes as it leaves. That lag is why breaches cluster — the first breach of the backtest lands on 2011-03-15, deep in the euro crisis, and the worst runs come in March 2020 before the window has learned what COVID is.
| Method | 99% VaR | 99% CVaR | Breaches (exp. 35.6) | Kupiec LR | p-value |
|---|---|---|---|---|---|
| Historical | 3.42% | 4.60% | 61 | 15.16 | 9.9e-5 |
| Parametric normal | 2.82% | 3.23% | 81 | 43.10 | 5.2e-11 |
| Parametric t (df 3.22) | 3.40% | 5.13% | 59 | 13.04 | 0.0003 |
| Monte Carlo (t) | 3.39% | 5.08% | 59 | 13.04 | 0.0003 |
Read it honestly: every unconditional method breaches too often — 81 times for the normal against 35.6 expected, and even the t-based models manage 59. The t roughly halves the normal's excess, but Kupiec rejects all four at the 1% level. And Kupiec is the lenient examiner: the POF statistic only counts breaches — it is blind to their timing. One look at the chart shows them arriving in volatility clusters, which is exactly the pattern Christoffersen's (1998) independence test is built to punish and the pattern behind Basel's traffic-light backtest zones. The diagnosis is structural: a rolling unconditional window is late to every regime change by construction. That is not a reason to despair; it is the empirical case for conditional risk models — a GARCH filter rescales the tail to today's volatility, and filtered historical simulation is the desk standard for precisely this failure mode.
6.The same quantile in Polars and DuckDB
Historical VaR is, computationally, a quantile over a column — exactly the shape of problem the modern data-engineering stack eats. Polars evaluates a lazy expression pipeline over the CSV; DuckDB runs SQL straight against the file. Neither needs the data in pandas, and both stream — the identical two lines still work when "one index, fifteen years" becomes "every book in the firm, tick by tick".
import polars as pl
q = (pl.scan_csv("dax_returns.csv")
.select(pl.col("ret").quantile(0.01, interpolation="linear"))
.collect()
.item()) # -0.034169015644486-- duckdb.sql(...) from Python, or the duckdb CLI directly
SELECT quantile_cont(ret, 0.01) AS q01
FROM read_csv('dax_returns.csv'); -- -0.034169015644486| Engine | 1% quantile of returns | |diff| vs pandas |
|---|---|---|
| pandas / NumPy | -0.034169015644486 | — |
| Polars 1.43 (lazy) | -0.034169015644486 | 0.0e+0 |
| DuckDB 1.5 | -0.034169015644486 | 0.0e+0 |
All three agree to the last printed digit (max absolute difference 0.0e+0, well inside the 1e−12 tolerance the pipeline asserts). On 3,805 rows the timings are dominated by engine start-up — NumPy answers in a fraction of a millisecond, Polars in a few, DuckDB in tens — so at this size the engines buy you nothing but ergonomics. The crossover comes when the file stops fitting in memory: the pandas version dies, the Polars and DuckDB versions do not change by a character.
References
- 1.Jorion, P. Value at Risk: The New Benchmark for Managing Financial Risk. McGraw-Hill.
- 2.Kupiec, P. (1995). Techniques for Verifying the Accuracy of Risk Measurement Models. Journal of Derivatives, 3(2).
- 3.Christoffersen, P. (1998). Evaluating Interval Forecasts. International Economic Review, 39(4), 841–862.
- 4.McNeil, A., Frey, R. & Embrechts, P. Quantitative Risk Management: Concepts, Techniques and Tools. Princeton University Press.
- 5.Companion notebook:
var-three-ways.ipynb— reproduces every number from raw data (seed 42), including the Polars and DuckDB cells.
