VaR and Expected Shortfall with an auditable risk table
A risk number nobody can reproduce is a liability. This recipe computes historical and parametric VaR / Expected Shortfall for a fixed equity book on real daily data, backtests the VaR with Kupiec's POF test, and then does the part most notebooks skip: it writes the daily risk metrics into a versioned h5i-db table, one commit per production day, so that "what VaR did we report on date X, and from what inputs?" is a query, not an email thread.
h5i-db features doing the work: SQL window functions for the return
pipeline, append-only commits as the audit trail (versions() shows every
EOD run with its note and wall-clock commit time), and compact() to keep
the table healthy after many small daily appends.
import numpy as np
import pandas as pd
import pyarrow as pa
import h5i_db
import cookbook_utils as cu
db = h5i_db.Database(cu.fresh_db("prod_var"), create=True)
1. Load real prices into a versioned table#
Eight-plus years of daily data for 30 liquid US names (cached Yahoo data).
We store adj_close, a total-return-ish series, which is what P&L risk
should be computed on. One bulk append = one atomic commit.
One strictness to know about: with sort_key=["ts", "symbol"], append
requires the input sorted by the full key. Daily panels have 30 symbols
sharing each timestamp, so a ts-only sort is not enough; sort by
(ts, symbol) before appending.
real = cu.fetch_daily(cu.SP500_EXAMPLES, start="2018-01-01", end="2026-07-01")
schema = pa.schema(
[
pa.field("ts", pa.timestamp("us", tz="UTC"), nullable=False),
pa.field("symbol", pa.string()),
pa.field("adj_close", pa.float64()),
]
)
db.create_table("prices", schema, time_column="ts", sort_key=["ts", "symbol"])
db.append(
"prices",
real.select(["ts", "symbol", "adj_close"]).sort_by([("ts", "ascending"), ("symbol", "ascending")]),
note="vendor backfill 2018-2026",
)
db.sql("SELECT count(*) AS rows, count(DISTINCT symbol) AS names FROM prices").to_pandas()
| rows | names | |
|---|---|---|
| 0 | 64020 | 30 |
2. Portfolio P&L series in one SQL statement#
The book is a fixed 12-name portfolio (weights sum to 1, $10M notional).
Daily simple returns come from a lag() window per symbol; the portfolio
return is a weighted sum via a join against an inline VALUES weights
relation. Everything streams on time-sorted storage, with no pandas
reshaping until we actually need rolling quantiles.
NOTIONAL = 10_000_000.0
WEIGHTS_SQL = """
(VALUES ('AAPL', 0.10), ('MSFT', 0.10), ('NVDA', 0.08), ('AMZN', 0.08),
('GOOGL', 0.08), ('JPM', 0.10), ('XOM', 0.08), ('UNH', 0.08),
('PG', 0.08), ('KO', 0.06), ('CAT', 0.08), ('GS', 0.08)
) AS w(symbol, weight)
"""
port = db.sql(
f"""
WITH rets AS (
SELECT ts, symbol,
adj_close / lag(adj_close) OVER (PARTITION BY symbol ORDER BY ts) - 1 AS ret
FROM prices
)
SELECT r.ts, sum(r.ret * w.weight) AS port_ret, count(*) AS names
FROM rets r
JOIN {WEIGHTS_SQL} ON r.symbol = w.symbol
WHERE r.ret IS NOT NULL
GROUP BY r.ts
ORDER BY r.ts
"""
).to_pandas()
# Keep only days where all 12 names have a return (guards against partial days).
port = port[port["names"] == 12].set_index("ts")
port["pnl"] = NOTIONAL * port["port_ret"]
print(f"{len(port):,} trading days, "
f"ann. vol {port['port_ret'].std() * np.sqrt(252):.1%}, "
f"worst day {port['pnl'].min():,.0f} USD")
2,133 trading days, ann. vol 20.9%, worst day -1,154,191 USD
3. Rolling historical VaR / ES, plus parametric flavors#
All measures use a trailing 252-day window shifted by one day: the VaR reported for day t only uses information through t−1. That shift is the difference between a risk model and a hindsight model, and it is exactly the kind of detail an auditor will ask about.
- Historical: empirical quantile of the window; ES = mean of the tail beyond the quantile.
- Parametric normal: rolling mean/vol + normal quantile.
- Parametric Student-t: same, with a t quantile (df fitted once on the full standardized sample) rescaled to unit variance, a cheap fat-tail fix.
from scipy import stats
r = port["port_ret"]
W = 252
def hist_var(x, q):
return -np.quantile(x, q)
def hist_es(x, q):
cut = np.quantile(x, q)
return -x[x <= cut].mean()
roll = r.rolling(W)
port["var95"] = roll.apply(hist_var, args=(0.05,), raw=True).shift(1)
port["var99"] = roll.apply(hist_var, args=(0.01,), raw=True).shift(1)
port["es95"] = roll.apply(hist_es, args=(0.05,), raw=True).shift(1)
port["es99"] = roll.apply(hist_es, args=(0.01,), raw=True).shift(1)
mu, sd = roll.mean().shift(1), roll.std().shift(1)
port["var95_norm"] = -(mu + sd * stats.norm.ppf(0.05))
# Fit t df on the full standardized history, rescale to unit variance.
z = ((r - r.mean()) / r.std()).to_numpy()
nu = stats.t.fit(z, floc=0)[0]
t_scale = np.sqrt((nu - 2) / nu)
port["var95_t"] = -(mu + sd * stats.t.ppf(0.05, nu) * t_scale)
port[["var95", "es95", "var95_norm", "var95_t"]].dropna().tail(3).mul(NOTIONAL).round(0)
| var95 | es95 | var95_norm | var95_t | |
|---|---|---|---|---|
| ts | ||||
| 2026-06-26 20:00:00+00:00 | 123777.0 | 166664.0 | 114636.0 | 93226.0 |
| 2026-06-29 20:00:00+00:00 | 123777.0 | 166664.0 | 114700.0 | 93303.0 |
| 2026-06-30 20:00:00+00:00 | 123777.0 | 166664.0 | 114576.0 | 93233.0 |
4. Kupiec POF backtest: is 95% really 95%?#
Count days where the realized loss exceeded the previous day's VaR and run Kupiec's proportion-of-failures likelihood-ratio test (asymptotically chi-squared with 1 dof). A well-calibrated 95% VaR should be breached on about 5% of days; rejecting the null means the model's coverage is wrong.
def kupiec_pof(returns: pd.Series, var: pd.Series, p: float) -> dict:
mask = var.notna()
ret, v = returns[mask], var[mask]
n = len(ret)
x = int((ret < -v).sum())
pi = x / n
ll_null = (n - x) * np.log(1 - p) + x * np.log(p)
ll_alt = ((n - x) * np.log(1 - pi) + x * np.log(pi)) if 0 < x < n else 0.0
lr = -2 * (ll_null - ll_alt)
return {"n_days": n, "breaches": x, "expected": round(n * p, 1),
"breach_rate": round(pi, 4), "LR_pof": round(lr, 2),
"p_value": round(float(stats.chi2.sf(lr, 1)), 4)}
backtest = pd.DataFrame(
{
"hist 95%": kupiec_pof(r, port["var95"], 0.05),
"hist 99%": kupiec_pof(r, port["var99"], 0.01),
"normal 95%": kupiec_pof(r, port["var95_norm"], 0.05),
"t 95%": kupiec_pof(r, port["var95_t"], 0.05),
}
).T
backtest
| n_days | breaches | expected | breach_rate | LR_pof | p_value | |
|---|---|---|---|---|---|---|
| hist 95% | 1881.0 | 98.0 | 94.1 | 0.0521 | 0.17 | 0.678 |
| hist 99% | 1881.0 | 33.0 | 18.8 | 0.0175 | 8.83 | 0.003 |
| normal 95% | 1881.0 | 98.0 | 94.1 | 0.0521 | 0.17 | 0.678 |
| t 95% | 1881.0 | 136.0 | 94.1 | 0.0723 | 17.41 | 0.000 |
Reading the table honestly: the historical and normal models hold coverage at 95%, but the 99% historical VaR under-covers (breaches cluster in vol-regime shifts, which a trailing empirical quantile is slow to catch; Kupiec tests coverage only, not independence, and Christoffersen's test would flag the clustering). And note the trap in the "fat-tail fix": a Student-t rescaled to unit variance has less extreme quantiles than the normal at the 5% level, and the fat tails only cross over around 1%. A t-model naively swapped in at 95% is anti-conservative, and the test catches it.
5. The audit trail: a versioned risk_metrics table#
Production pattern: backfill history in one commit, then one commit per
EOD run. Each daily append is atomic and carries a note; versions()
then answers "which risk numbers were published on which day, and when
exactly", with wall-clock commit timestamps and without any extra logging
infrastructure. We simulate the last 60 production days as individual
commits.
metrics = port.dropna(subset=["var95", "var99", "es95", "es99"])[
["pnl", "var95", "es95", "var99", "es99"]
].copy()
for c in ["var95", "es95", "var99", "es99"]:
metrics[c] *= NOTIONAL
mschema = pa.schema(
[
pa.field("ts", pa.timestamp("us", tz="UTC"), nullable=False),
pa.field("pnl", pa.float64()),
pa.field("var95", pa.float64()),
pa.field("es95", pa.float64()),
pa.field("var99", pa.float64()),
pa.field("es99", pa.float64()),
]
)
db.create_table("risk_metrics", mschema, time_column="ts")
hist_part = metrics.iloc[:-60].reset_index()
db.append("risk_metrics", pa.Table.from_pandas(hist_part, schema=mschema, preserve_index=False),
note="backfill through model go-live")
for day, row in metrics.iloc[-60:].iterrows():
one = pa.Table.from_pandas(
pd.DataFrame([row]).rename_axis("ts").reset_index(), schema=mschema, preserve_index=False
)
db.append("risk_metrics", one, note=f"eod risk {day.date()}")
[
{k: v.get(k) for k in ("sequence", "op", "rows", "note")}
for v in db.versions("risk_metrics")[-4:]
]
[{'sequence': 58, 'op': 'append', 'rows': 1878, 'note': 'eod risk 2026-06-25'},
{'sequence': 59, 'op': 'append', 'rows': 1879, 'note': 'eod risk 2026-06-26'},
{'sequence': 60, 'op': 'append', 'rows': 1880, 'note': 'eod risk 2026-06-29'},
{'sequence': 61, 'op': 'append', 'rows': 1881, 'note': 'eod risk 2026-06-30'}]Sixty tiny commits also means sixty tiny Parquet segments. compact()
merges them into efficient segments, as its own audited commit, so even
maintenance shows up in the history.
before = db.versions("risk_metrics")[-1]
compacted = db.compact("risk_metrics", note="post-EOD-loop compaction")
print(f"segments: {before['segments']} -> {compacted['segments_total']}, "
f"op={compacted['op']}, sequence={compacted['sequence']}")
segments: 61 -> 1, op=compact, sequence=62
The risk table is now queryable like any other: here, every breach day of the last two years straight from SQL:
db.sql(
"""
SELECT ts, round(pnl) AS pnl, round(-var95) AS var95_floor, round(-var99) AS var99_floor
FROM risk_metrics
WHERE pnl < -var95 AND ts >= '2024-07-01T00:00:00Z'
ORDER BY ts DESC
LIMIT 8
"""
).to_pandas()
| ts | pnl | var95_floor | var99_floor | |
|---|---|---|---|---|
| 0 | 2026-06-17 20:00:00+00:00 | -120366.0 | -115987.0 | -184759.0 |
| 1 | 2026-06-10 20:00:00+00:00 | -148196.0 | -104481.0 | -184759.0 |
| 2 | 2026-06-05 20:00:00+00:00 | -138137.0 | -102827.0 | -184759.0 |
| 3 | 2026-03-27 20:00:00+00:00 | -157587.0 | -147858.0 | -274463.0 |
| 4 | 2026-03-26 20:00:00+00:00 | -147359.0 | -147281.0 | -274463.0 |
| 5 | 2026-03-12 20:00:00+00:00 | -151007.0 | -138104.0 | -274463.0 |
| 6 | 2026-02-12 20:00:00+00:00 | -168461.0 | -158913.0 | -281868.0 |
| 7 | 2026-02-05 20:00:00+00:00 | -171680.0 | -147281.0 | -281868.0 |
6. P&L against the VaR band, breaches marked#
import matplotlib.pyplot as plt
plot_df = metrics.loc["2024-07-01":]
breach95 = plot_df[plot_df["pnl"] < -plot_df["var95"]]
breach99 = plot_df[plot_df["pnl"] < -plot_df["var99"]]
fig, ax = plt.subplots(figsize=(11, 4.5))
ax.plot(plot_df.index, plot_df["pnl"], lw=0.6, color="0.4", label="daily P&L")
ax.plot(plot_df.index, -plot_df["var95"], lw=1.2, color="tab:orange", label="-VaR 95%")
ax.plot(plot_df.index, -plot_df["var99"], lw=1.2, color="tab:red", label="-VaR 99%")
ax.scatter(breach95.index, breach95["pnl"], s=28, color="tab:orange", zorder=3,
label=f"95% breach ({len(breach95)})")
ax.scatter(breach99.index, breach99["pnl"], s=36, color="tab:red", marker="x", zorder=4,
label=f"99% breach ({len(breach99)})")
ax.set_title("Daily P&L vs rolling 252d historical VaR ($10M book)")
ax.set_xlabel("date")
ax.set_ylabel("USD")
ax.legend(loc="lower left", ncols=3, fontsize=8)
fig.tight_layout()
Takeaways#
- The whole return pipeline (per-symbol
lag()returns, weighted portfolio aggregation) is one SQL statement on time-sorted storage; pandas only enters for rolling quantiles. - Shift your VaR window by one day. Kupiec's POF test then tells you honestly whether coverage holds; the normal model's fat-tail failure at 99% shows up in the numbers, not just in folklore.
risk_metricsis an audit trail for free: one append per EOD run with a note,versions()gives the exact publication time of every risk number ever reported. No extra logging system.- Many small daily commits are fine; run
compact()periodically, and it is itself a versioned, noted commit.
db.close()