Lesson 4.2.3 · 75 min

VaR, Expected Shortfall and VaR backtesting

VaR sums up in one figure the loss that should almost never be exceeded, and Expected Shortfall says how much is lost when it is. This lesson computes both with six methods on a book whose true VaR is known every day, puts them through the Kupiec, Christoffersen and Acerbi-Székely tests and the Basel Committee traffic light, and measures how wrong each one is and when.

Fig. — One-day 99% VaR of the 10 million USD book, January 2010 to March 2011: the true one (known GARCH-t) against the 250-session historical, and the losses that exceed the historical
Level 4 lessons
  1. 4.1 · Portfolio theory and optimisation

    1. 4.1.1 Markowitz: efficient frontier and real constraints
    2. 4.1.2 Covariance estimation: shrinkage, factor models and denoising
    3. 4.1.3 Black-Litterman: from market equilibrium to views
    4. 4.1.4 Risk parity, minimum variance, HRP and HERC
    5. 4.1.5 Optimization with CVaR, drawdown, turnover and costs
    6. 4.1.6 Factor models: CAPM, Fama-French, PCA and attribution
    7. 4.1.7 Rebalancing and strategy portfolios
    8. 4.1.8 Portfolio tools: Riskfolio-Lib, PyPortfolioOpt, skfolio and bt
  2. 4.2 · Risk management and position sizing

    1. 4.2.1 Risk per trade, stops and risk of ruin
    2. 4.2.2 Kelly criterion and volatility targeting: how much to risk
    3. 4.2.3 VaR, Expected Shortfall and VaR backtesting
    4. 4.2.4 Stress testing and correlation, liquidity, gap and counterparty risk
    5. 4.2.5 Drawdown control and kill switch
    6. 4.2.6 Derivatives risk: portfolio Greeks, margin and liquidations
  3. 4.3 · Microstructure and execution

    1. 4.3.1 Implementation shortfall, VWAP, TWAP and Almgren-Chriss
    2. 4.3.2 Order flow imbalance, microstructure signals and latency
  4. 4.4 · Brokers and APIs

    1. 4.4.1 Choosing a broker from Spain or the EU: regulation, assets, costs and API
    2. 4.4.2 Interactive Brokers: TWS and IB Gateway, ib_async, contracts, data and orders
    3. 4.4.3 Alpaca, Tradier, MetaTrader 5 with Python and OANDA v20
    4. 4.4.4 Crypto exchange APIs: ccxt, Binance, Bybit, Kraken and Coinbase
    5. 4.4.5 Secure credentials: environment variables, secret managers and least privilege
  5. 4.5 · Live trading system

    1. 4.5.1 Live system architecture: from data to reconciliation
    2. 4.5.2 Order management: states, partial fills, cancellations and reconnections
    3. 4.5.3 Paper trading, shadow trading and slippage monitoring
    4. 4.5.4 Task scheduling and alerts: cron, APScheduler, Prefect, Telegram, Slack and email
    5. 4.5.5 Fault tolerance: idempotency, persistent state and reconciliation at startup
    6. 4.5.6 NautilusTrader or LEAN live: from backtest to production with the same code

What VaR measures and what it does not

The VaR (value at risk) of a book at a horizon hh and a confidence level 1−α1-\alpha is the loss that is only exceeded with probability α\alpha. With LL the loss (positive when money is lost) over that horizon:

\mathrm{ES}_\alpha = \mathrm{E}\left[L \mid L \ge \mathrm{VaR}_\alpha\right].$$ VaR is a quantile: it says where the worst 1% begins, not how much is lost there. **Expected Shortfall** (ES, also CVaR or expected tail loss) is the mean of those losses. Two books with the same VaR can have very different tails: one that sells far out-of-the-money options has a small VaR and an enormous ES (lesson 3.6.1). In addition, VaR is not subadditive (the VaR of a portfolio can exceed the sum of the VaRs of its parts) and ES is: it is a coherent measure in the sense of Artzner, Delbaen, Eber and Heath (1999)[^1]. Regulation uses both. In the Basel framework for market risk, internal model capital is computed with a 97.5% ES (MAR33.3) and **backtesting**, the daily check of the model against the P&L, with a one-day 99% VaR (MAR32.5). A proprietary trading system does not need regulatory capital, but it does need both questions: how much can I lose on a bad day, and has my risk model been able to tell me. ```mermaid flowchart LR P[P&L up to yesterday] --> M[Model] M --> V[Today's VaR and ES] V --> C{Today's loss} C --> E[Exception: yes or no] E --> T[Tests] ``` The order matters: today's VaR is computed at yesterday's close and compared with today's loss. A VaR that uses today's loss to compute today's VaR passes any test and is useless. ## A book with a known true VaR `riesgo4_pyg.csv` is the daily P&L of a book with a constant notional of 10 million USD, 2006-2025. It is synthetic and has a known conditional distribution: a GJR-GARCH(1,1) with standardized Student's t innovations with 5 degrees of freedom, $a = 0.05$, $\gamma = 0.10$, $b = 0.88$, and a long-run variance that changes with the regime of the level's universe (calm, stress, inflation). `riesgo4_pyg_verdad.csv` gives the true volatility of each day, known at the previous close, and the true VaR and ES. The first cell checks where they come from: :::cell{title="The book and its true VaR"} ```python import numpy as np import pandas as pd from scipy import stats N = 10_000_000 # constant notional (USD) pyg = pd.read_csv("riesgo4_pyg.csv", index_col="fecha", parse_dates=True) verdad = pd.read_csv("riesgo4_pyg_verdad.csv", index_col="fecha", parse_dates=True) r, perdida = pyg["rendimiento"], -pyg["pyg"] # loss > 0 # quantile and ES of the standardized Student's t (variance 1) nu, mu = 5, 0.0003 esc = np.sqrt((nu - 2) / nu) q01 = stats.t.ppf(0.01, nu) * esc q025 = stats.t.ppf(0.025, nu) * esc t025 = stats.t.ppf(0.025, nu) es_t = (nu + t025**2) / (nu - 1) * stats.t.pdf(t025, nu) / 0.025 print(f"q 1%: {q01:.4f} ES 2.5%: {es_t * esc:.4f}") var99 = -(mu + verdad["sigma"] * q01) * N es975 = (-mu + verdad["sigma"] * es_t * esc) * N print("Maximum difference with the file (USD):", round(max((var99 - verdad["var_99"]).abs().max(), (es975 - verdad["es_975"]).abs().max()), 2)) verdad.loc["2020-03-16":"2020-03-18"].round( {"sigma": 5, "var_95": 0, "var_975": 0, "var_99": 0, "es_975": 0}) ``` ```output q 1%: -2.6065 ES 2.5%: 2.7278 Maximum difference with the file (USD): 1.37 sigma var_95 var_975 var_99 es_975 fecha 2020-03-16 0.01155 177307.0 227016.0 298095.0 312112.0 2020-03-17 0.01205 185123.0 236988.0 311147.0 325772.0 2020-03-18 0.01174 180195.0 230700.0 302917.0 317159.0 ``` ::: The 1% quantile of a 5-degree t standardized to variance 1 is −2.6065, 12% further from zero than the normal one (−2.3263), and its 2.5% ES, 2.7278. With $\mathrm{VaR}_{99} = -(\mu + \sigma_t q_{0.01})\cdot N$ the figures in the file are reproduced up to rounding differences of 1.37 USD. In the week of March 16, 2020, the true daily volatility is 1.2% and the 99% VaR, about 300,000 USD. Having the truth allows something that is impossible with real data: to measure the error of each method day by day, not just to count exceptions. ## Six VaRs with what was known each day The usual methods, all computed with data up to the previous close (`shift(1)`): - **Historical**: the 1% quantile of the last 250 returns. It assumes no distribution, but it gives yesterday's session the same weight as the one eleven months ago. - **Normal**: $-\hat\sigma_{250}\, z_{0.01}$ with the 250-session standard deviation. Assuming normality underestimates the tails. - **EWMA** (RiskMetrics): $\hat\sigma^2_t = \lambda\hat\sigma^2_{t-1} + (1-\lambda)r^2_{t-1}$ with $\lambda = 0.94$, the value from the RiskMetrics technical document for daily data, and a normal quantile. - **FHS** (filtered historical simulation, Barone-Adesi et al.): returns are divided by the EWMA volatility of the day before, the quantile of those residuals over 250 sessions is taken and rescaled with today's volatility. It combines the responsiveness of EWMA with the empirical shape of the tails. - **GJR-GARCH-t** with `arch` (lesson 2.1.4): re-estimated each January with the whole past and used with fixed parameters during the year. It is the true model, but with estimated parameters. - **True**: the one in the file, the reference. :::cell{title="Five 99% VaRs with what was known each day"} ```python import warnings from arch import arch_model V = 250 # one-year window z01 = stats.norm.ppf(0.01) lam = 0.94 # RiskMetrics, daily sig_ewma = np.sqrt((r**2).ewm(alpha=1 - lam, adjust=False).mean()) res_ewma = r / sig_ewma.shift(1) # filtered residuals var = pd.DataFrame({ "verdadero": verdad["var_99"], "historico": -r.rolling(V).quantile(0.01).shift(1) * N, "normal": -(r.rolling(V).std() * z01).shift(1) * N, "ewma": -(sig_ewma * z01).shift(1) * N, "fhs": -(sig_ewma.shift(1) * res_ewma.rolling(V).quantile(0.01).shift(1)) * N}) def garch_t(r, inicio=V): """GJR-GARCH(1,1)-t re-estimated each January with the whole past.""" y = 100 * r # arch prefers percentages out, params = [], {} for año in sorted(set(r.index.year)): n = int((r.index.year < año).sum()) if n < inicio: continue am = arch_model(y, mean="Constant", p=1, o=1, q=1, dist="t") with warnings.catch_warnings(): warnings.simplefilter("ignore", RuntimeWarning) fit = am.fit(last_obs=n, disp="off") prev = fit.forecast(start=n - 1, reindex=False).variance dias = r.index[r.index.year == año] s = np.sqrt(prev["h.1"]).shift(1).reindex(dias) / 100 out.append(pd.DataFrame({"sigma": s, "nu": fit.params["nu"], "mu": fit.params["mu"] / 100})) params[año] = fit.params return pd.concat(out), params gt, params = garch_t(r) q_gt = stats.t.ppf(0.01, gt["nu"]) * np.sqrt((gt["nu"] - 2) / gt["nu"]) var["garch_t"] = -(gt["mu"] + gt["sigma"] * q_gt) * N print("2020 fit:", params[2020].round(3).to_dict()) prueba = gt.index # from 2007 to 2025 var = var.loc[prueba] L = perdida.loc[prueba] excesos = L.values[:, None] > var.values pd.DataFrame({"excesos": excesos.sum(axis=0), "pct": 100 * excesos.mean(axis=0), "var_medio": var.mean(), "error_medio_abs": (var.sub(var["verdadero"], axis=0) ).abs().mean(), "sesgo": var.sub(var["verdadero"], axis=0).mean()}, index=var.columns).round(2) ``` ```output 2020 fit: {'mu': 0.036, 'omega': 0.009, 'alpha[1]': 0.061, 'gamma[1]': 0.097, 'beta[1]': 0.887, 'nu': 4.71} excesos pct var_medio error_medio_abs sesgo verdadero 49 1.03 218634.57 0.00 0.00 historico 94 1.97 242182.61 105831.93 23548.03 normal 112 2.34 215419.30 94591.87 -3215.27 ewma 78 1.63 196732.87 36019.11 -21901.70 fhs 69 1.44 226313.08 45680.86 7678.51 garch_t 55 1.15 217830.13 16162.03 -804.45 ``` ::: ::figure{vega="n4-m2-var-excesos"} With 4,780 test sessions (2007-2025), 48 exceptions of a correct 99% VaR are expected. The true one has 49. The estimated GARCH-t, 55 (1.15%), and it stays on average **16,200 USD** away from the true one, with almost zero bias: its 2020 parameters ($a = 0.061$, $\gamma = 0.097$, $b = 0.887$, $\nu = 4.7$) are close to the true ones. EWMA reacts just as fast (mean error of 36,000 USD) but with a normal quantile: 78 exceptions and a bias of −21,900 USD. FHS corrects the tail (69 exceptions). And the two fixed-window methods fail by a wide margin: the normal one has 112 exceptions and the historical one 94, with a mean error of 105,800 USD versus the true one **even though on average it is 23,500 USD above it**. Being above on average is not enough if you are high when it is not needed and low when it is. The header figure shows it in the 2010 stress episode. The true VaR goes from about 150,000 to 1.34 million USD between May and September. The historical one stays at 145,000-160,000 during May and June, rises in steps as the losses enter its window and piles up 15 exceptions in that stretch. Then it stays at 664,000 USD all winter, when the true risk has already gone back to half or less: a year of excess capital tied up behind each crisis. ## Monte Carlo: VaR over several days For one day, the VaR of a GARCH has a formula. For several days it does not: the volatility of the day after tomorrow depends on tomorrow's shock, and the sum of ten GARCH-t returns does not have a known distribution. The way out is to simulate many paths with the model and take the quantile of the sum. The usual alternative, the **square-root-of-10 rule** ($\mathrm{VaR}_{10} \approx \sqrt{10}\,\mathrm{VaR}_1$), assumes independent returns with the same volatility over the ten days. MAR33.4 starts from a 10-day ES to scale to longer liquidity horizons, so the question is not academic. The cell simulates 50,000 10-day paths from three dates, with the true model (with the long-run variance of that day's regime) and with the GARCH-t estimated that year: :::cell{title="Monte Carlo: the 10-day VaR of a GARCH"} ```python def mc_suma(mu, omega, a, gam, b, nu, sig1, h=10, n=50_000, semilla=3): """Simulates h days of a GJR-GARCH-t starting from tomorrow's sigma.""" rng = np.random.default_rng(semilla) s2 = np.full(n, sig1**2) total = np.zeros(n) for _ in range(h): z = rng.standard_t(nu, n) * np.sqrt((nu - 2) / nu) eps = np.sqrt(s2) * z total += mu + eps s2 = omega + (a + gam * (eps < 0)) * eps**2 + b * s2 return total fac = pd.read_csv("universo4_regimenes.csv", parse_dates=["desde", "hasta"]) v_reg = {"calma": 0.008, "estres": 0.020, "inflacion": 0.012} filas = {} for dia in ["2017-06-01", "2019-11-01", "2008-10-15"]: t0 = pd.Timestamp(dia) reg = fac[(fac.desde <= t0) & (fac.hasta >= t0)].regimen.item() om_v = (1 - 0.05 - 0.88 - 0.1 / 2) * v_reg[reg] ** 2 sig_v = verdad.at[t0, "sigma"] # sigma of t0, known beforehand sig_g = gt.at[t0, "sigma"] p = params[t0.year] # that year's fit (in %) tot_v = mc_suma(mu, om_v, 0.05, 0.1, 0.88, 5, sig_v) tot_g = mc_suma(p["mu"] / 100, p["omega"] / 1e4, p["alpha[1]"], p["gamma[1]"], p["beta[1]"], p["nu"], sig_g) real = -r.loc[t0:].iloc[:10].sum() * N filas[dia] = {"regimen": reg, "raiz10_verdad": np.sqrt(10) * verdad.at[t0, "var_99"], "mc_verdad": -np.quantile(tot_v, 0.01) * N, "mc_garch": -np.quantile(tot_g, 0.01) * N, "perdida_real": real} tabla = pd.DataFrame(filas).T tabla.iloc[:, 1:] = tabla.iloc[:, 1:].astype(float).round(-3) tabla ``` ```output regimen raiz10_verdad mc_verdad mc_garch perdida_real 2017-06-01 calma 966000.0 949000.0 1066000.0 -545000.0 2019-11-01 calma 441000.0 452000.0 482000.0 -326000.0 2008-10-15 estres 858000.0 945000.0 597000.0 -358000.0 ``` ::: - **In stress** (October 15, 2008), the true Monte Carlo gives 945,000 USD and the square root of 10, 858,000: with $\gamma > 0$ a negative shock raises the next day's volatility more than a positive one, and losses chain together. The square root of 10 falls short precisely when it matters most. - **In calm** the two are similar (966,000 versus 949,000 in June 2017; 441,000 versus 452,000 in November 2019). - **The estimated GARCH** with a single regime is wrong in both directions: in the calm its volatility reverts toward a long-run mean that mixes crises and calm, and it overestimates (1,066,000 and 482,000); in October 2008, with parameters estimated only with 2006-2007, it does not know the level of stress and gives 597,000, 37% less than the truth. On all three dates the book ended up making money over the following ten days (the `perdida_real` column is negative): a VaR is a statement about a distribution, and a single date neither confirms nor refutes it. That is why models are judged with many dates. ## Kupiec, Christoffersen and the clustering of exceptions Each day you record $I_t = 1$ if the loss exceeds the VaR. With a correct VaR, the $I_t$ are independent and equal 1 with probability $p = 1\%$. Two likelihood ratio tests, both $\chi^2$: $$LR_{uc} = -2\ln\frac{(1-p)^{n-x}p^{x}}{(1-\hat p)^{n-x}\hat p^{x}} \sim \chi^2_1, \qquad LR_{cc} = LR_{uc} + LR_{ind} \sim \chi^2_2.$$ The **Kupiec** test (unconditional coverage, $LR_{uc}$) compares the observed frequency $\hat p = x/n$ with $p$. The **Christoffersen** test (independence, $LR_{ind}$) fits a Markov chain to the $I_t$ and compares the probability of an exception after a day without one ($\pi_{01}$) with the probability after a day with one ($\pi_{11}$). Together they give conditional coverage: :::cell{title="Kupiec and Christoffersen"} ```python from scipy.stats import chi2 def kupiec(I, p=0.01): """Unconditional coverage: is the frequency of exceptions p?""" n, x = len(I), int(I.sum()) ph = x / n lr = -2 * ((n - x) * np.log(1 - p) + x * np.log(p) - (n - x) * np.log(1 - ph) - x * np.log(ph)) return lr, chi2.sf(lr, 1) def christoffersen(I): """Independence: does one exception make the next one more likely?""" a, b = I[:-1], I[1:] n00 = ((a == 0) & (b == 0)).sum() n01 = ((a == 0) & (b == 1)).sum() n10 = ((a == 1) & (b == 0)).sum() n11 = ((a == 1) & (b == 1)).sum() p01, p11 = n01 / (n00 + n01), n11 / (n10 + n11) p1 = (n01 + n11) / (n00 + n01 + n10 + n11) ll0 = (n00 + n10) * np.log(1 - p1) + (n01 + n11) * np.log(p1) ll1 = (n00 * np.log(1 - p01) + n01 * np.log(p01) + n10 * np.log(1 - p11) + (n11 * np.log(p11) if n11 else 0)) lr = -2 * (ll0 - ll1) return lr, chi2.sf(lr, 1), p11 filas = {} for j, m in enumerate(var.columns): I = excesos[:, j].astype(int) lr_uc, p_uc = kupiec(I) lr_ind, p_ind, p11 = christoffersen(I) filas[m] = {"excesos": I.sum(), "p_kupiec": p_uc, "p_indep": p_ind, "p_exceso_tras_exceso": p11, "p_condicional": chi2.sf(lr_uc + lr_ind, 2)} pd.DataFrame(filas).T.round(3) ``` ```output excesos p_kupiec p_indep p_exceso_tras_exceso p_condicional verdadero 49.0 0.862 0.314 0.000 0.593 historico 94.0 0.000 0.000 0.096 0.000 normal 112.0 0.000 0.000 0.089 0.000 ewma 78.0 0.000 0.798 0.013 0.000 fhs 69.0 0.004 0.997 0.014 0.015 garch_t 55.0 0.307 0.164 0.036 0.225 ``` ::: The true model passes all three (p of 0.862, 0.314 and 0.593) and never has two exceptions in a row. The estimated GARCH-t also passes (0.307, 0.164, 0.225). The historical and normal models fail both: they have too many exceptions and, after an exception, the probability of another one the next day is 9.6% and 8.9%, nine times what it should be. EWMA and FHS fail only Kupiec: they react well (independence 0.798 and 0.997) but with the wrong tail. ## The Basel traffic light The banking supervisor does not use p-values but a **traffic light** on the number of exceptions of the 99% VaR over the last 250 business days (MAR32.9, table 1). Each zone carries a backtesting capital multiplier: | Zone | Exceptions in 250 days | Multiplier | Probability with a correct model | |---|---|---|---| | Green | 0 to 4 | 1.50 | 89.2% | | Amber | 5, 6, 7, 8, 9 | 1.70, 1.76, 1.83, 1.88, 1.92 | 10.8% | | Red | 10 or more | 2.00 | 0.03% | In red, the supervisor increases the multiplier automatically or may withdraw the model (MAR32.15). The last column comes from a 250-day binomial with $p = 1\%$, in the cell: :::cell{title="The Basel traffic light, year by year"} ```python conteo = pd.DataFrame(excesos, index=prueba, columns=var.columns).rolling(250).sum() conteo = conteo.dropna() zona = pd.cut(conteo.stack(), [-1, 4, 9, 250], labels=["green", "amber", "red"]) reparto = (zona.groupby(level=1, observed=False) .value_counts(normalize=True).unstack() .reindex(var.columns) * 100) print(reparto.round(1)) # with a correct model: binomial(250, 1%) b = stats.binom(250, 0.01) print(f"\nCorrect model: green {b.cdf(4):.1%}, " f"amber {b.cdf(9) - b.cdf(4):.1%}, red {b.sf(9):.2%}") def multiplicador(n): # MAR32, table 1 return {5: 1.70, 6: 1.76, 7: 1.83, 8: 1.88, 9: 1.92}.get(n, 1.50 if n < 5 else 2.00) peor = conteo["historico"].idxmax() print(f"\nWorst window of the historical VaR: 250 days to {peor.date()}") print(pd.DataFrame({"excesos": conteo.loc[peor].astype(int), "multiplicador": conteo.loc[peor].astype(int) .map(multiplicador)}).to_string()) ``` ```output green amber red verdadero 88.0 12.0 0.0 historico 54.2 36.3 9.5 normal 44.8 30.2 25.0 ewma 58.8 41.2 0.0 fhs 74.8 25.2 0.0 garch_t 88.2 11.8 0.0 Correct model: green 89.2%, amber 10.8%, red 0.03% Worst window of the historical VaR: 250 days to 2010-09-29 excesos multiplicador verdadero 4 1.50 historico 15 2.00 normal 16 2.00 ewma 6 1.76 fhs 5 1.70 garch_t 4 1.50 ``` ::: ::figure{vega="n4-m2-var-semaforo"} The true model spends 88.0% of the days in green and 12.0% in amber, which is what is expected of a correct model. The estimated GARCH-t, the same (88.2% and 11.8%). The historical model spends 9.5% of the time in red and the normal one, 25.0%. EWMA never reaches red but spends 41.2% of the time in amber. In the historical model's worst window, the 250 days to September 29, 2010, the historical model has 15 exceptions and the normal one 16, both with a multiplier of 2.00, while the true model and the GARCH-t have 4 (green). <aside class="callout" data-kind="aviso"> One year of data gives little information. With a correct model, 10.8% of years in amber are expected, and a model with twice the exceptions it should have (2% instead of 1%) often stays in green (exercise 2). That is why the traffic light is a supervisory rule, not a strong statistical test: for a proprietary system it is worth looking at the full series, the clustering of exceptions and the ES. </aside> ## Expected Shortfall and its backtest For years it was repeated that ES could not be backtested because it is not “elicitable”. Acerbi and Székely (2014) showed that it can, with statistics that only require storing the VaR, the ES and the P&L each day. The second of them, with $X_t$ the P&L (negative when money is lost) and $I_t = 1[X_t + \mathrm{VaR}_t < 0]$ at the level $\alpha = 2.5\%$:

Z_2 = \sum_{t=1}^{T} \frac{X_t, I_t}{T,\alpha,\mathrm{ES}_t} + 1.

With a correct ES, $\mathrm{E}[Z_2] = 0$; if the model underestimates the tail, $Z_2 < 0$. The data README gives the same statistic with losses as positive numbers, with the sign flipped. For each method, the ES of its own model: the mean of the tail of 250 observations (historical), the normal formula $\sigma\,\varphi(z_\alpha)/\alpha$, the standardized t formula for the GARCH-t and the mean of the tail of the filtered residuals (FHS): :::cell{title="97.5% ES for each method"} ```python a = 0.025 za = stats.norm.ppf(a) es_norm = stats.norm.pdf(za) / a # ES of the standard normal def es_historico(x): cola = np.sort(x)[: int(np.ceil(a * len(x)))] return -cola.mean() tg = stats.t.ppf(a, gt["nu"]) es_tg = ((gt["nu"] + tg**2) / (gt["nu"] - 1) * stats.t.pdf(tg, gt["nu"]) / a * np.sqrt((gt["nu"] - 2) / gt["nu"])) sig_n = r.rolling(V).std().shift(1) cola_fhs = res_ewma.rolling(V).apply(es_historico, raw=True).shift(1) modelos = { "verdadero": (verdad["var_975"], verdad["es_975"]), "historico": (-r.rolling(V).quantile(a).shift(1) * N, r.rolling(V).apply(es_historico, raw=True) .shift(1) * N), "normal": (-sig_n * za * N, sig_n * es_norm * N), "ewma": (-sig_ewma.shift(1) * za * N, sig_ewma.shift(1) * es_norm * N), "fhs": (-sig_ewma.shift(1) * res_ewma.rolling(V).quantile(a) .shift(1) * N, sig_ewma.shift(1) * cola_fhs * N), "garch_t": ((-(gt["mu"] + gt["sigma"] * tg * np.sqrt((gt["nu"] - 2) / gt["nu"]))) * N, (-gt["mu"] + gt["sigma"] * es_tg) * N)} X = pyg["pyg"].loc[prueba] # P&L (negative = loss) def z2(X, var_a, es_a): """Acerbi-Székely (2014): ≈ 0 if the ES is correct; < 0 if not.""" I = (X + var_a < 0) return (X * I / (len(X) * a * es_a)).sum() + 1 filas = {} for m, (v, e) in modelos.items(): v, e = v.loc[prueba], e.loc[prueba] filas[m] = {"es_medio": e.mean(), "excesos_975_pct": 100 * (X + v < 0).mean(), "Z2": z2(X, v, e)} tabla_es = pd.DataFrame(filas).T tabla_es.round(3) ``` ```output es_medio excesos_975_pct Z2 verdadero 228952.309 2.490 0.004 historico 245963.360 3.703 -0.691 normal 216480.024 3.619 -0.835 ewma 197701.580 3.075 -0.401 fhs 238706.681 2.908 -0.229 garch_t 228438.812 2.573 -0.067 ``` ::: Acerbi and Székely give critical values for $Z_2$ that are almost constant across distributions, around −0.7 at 5% for one year of data. With the true model of the data, the exact critical value can be computed, also for the whole sample: :::cell{title="How bad is a “bad” Z2? Simulation with the true model"} ```python rng = np.random.default_rng(5) sig = verdad["sigma"].loc[prueba].values v_v = verdad["var_975"].loc[prueba].values e_v = verdad["es_975"].loc[prueba].values sim_total, sim_anual = [], [] for _ in range(1000): z = rng.standard_t(nu, len(sig)) * esc Xs = (mu + sig * z) * N I = Xs + v_v < 0 sim_total.append((Xs * I / (len(Xs) * a * e_v)).sum() + 1) k = 250 # a one-year window sim_anual.append((Xs[:k] * I[:k] / (k * a * e_v[:k])).sum() + 1) print(f"5% critical Z2 with {len(sig)} days: " f"{np.quantile(sim_total, 0.05):.3f}") print(f"5% critical Z2 with 250 days: " f"{np.quantile(sim_anual, 0.05):.3f}") ``` ```output 5% critical Z2 with 4780 days: -0.160 5% critical Z2 with 250 days: -0.691 ``` ::: With 250 days the critical value is −0.691, the one in the paper. With the 4,780 test days it is −0.160: with more data, smaller deviations are detected. The true model gives 0.004 and the GARCH-t −0.067: they pass. FHS (−0.229), EWMA (−0.401), historical (−0.691) and normal (−0.835) underestimate the tail and are rejected. The normal model has the worst tail: with a mean ES of 216,500 USD, 5% below the true one, it has 3.62% exceptions of its 97.5% VaR instead of 2.5%, and when it fails, it fails by more. The mean historical ES (246,000 USD) is above the true one and is still rejected, for the same reason as its VaR: it is high when it is not needed. ## What the backtest does not see - **Changing positions.** This book has a constant notional. In a real one, today's P&L comes from positions that yesterday's VaR did not know about if there was intraday trading: that is why MAR32 compares with the actual P&L and with the hypothetical one (yesterday's closing positions revalued), and counts the larger number of exceptions of the two. - **Scenarios that are not in the sample.** A 250-session historical VaR only knows the last year; a GARCH estimated in the calm does not know stress (the October 2008 Monte Carlo). Losses that have never happened are covered by the stress tests of lesson 4.2.4. - **Aggregation.** The VaR of a portfolio depends on correlations, which change in crises (4.2.4). A model that gets the VaR of each strategy right separately can get the VaR of the sum wrong. - **Using VaR as a limit.** If a system reduces positions when its VaR rises, the VaR affects the P&L that judges it. That is desirable, but it has to be taken into account when reading the backtest of a system that uses it. [^1]: Artzner, P., Delbaen, F., Eber, J.-M. and Heath, D. (1999): Coherent Measures of Risk. Mathematical Finance 9(3), 203-228, <https://doi.org/10.1111/1467-9965.00068>. Barone-Adesi, G., Giannopoulos, K. and Vosper, L. (1999): VaR without correlations for portfolios of derivative securities. Journal of Futures Markets 19(5), 583-602, <https://doi.org/10.1002/(SICI)1096-9934(199908)19:5%3C583::AID-FUT5%3E3.0.CO;2-S>. ## Exercises 1. **RiskMetrics for investors.** The RiskMetrics technical document proposed $\lambda = 0.94$ for daily trading data and $\lambda = 0.97$ for investment horizons. Compute the 99% EWMA VaR with 0.97 and with a standardized 5-degree t quantile instead of the normal one, and compare exceptions, Kupiec p and independence p with the normal EWMA with 0.94. ::::solucion :::cell{title="EWMA with λ = 0.97 and a t quantile"} ```python sig97 = np.sqrt((r**2).ewm(alpha=0.03, adjust=False).mean()) variantes = {"0.94 normal": -(sig_ewma * z01).shift(1) * N, "0.97 normal": -(sig97 * z01).shift(1) * N, "0.94 t5": -(sig_ewma * q01).shift(1) * N, "0.97 t5": -(sig97 * q01).shift(1) * N} filas = {} for k, v in variantes.items(): I = (L > v.loc[prueba]).astype(int).values filas[k] = {"excesos": I.sum(), "p_kupiec": kupiec(I)[1], "p_indep": christoffersen(I)[1]} pd.DataFrame(filas).T.round(3) ``` ```output excesos p_kupiec p_indep 0.94 normal 78.0 0.000 0.798 0.97 normal 77.0 0.000 0.524 0.94 t5 54.0 0.377 0.267 0.97 t5 48.0 0.977 0.324 ``` ::: Changing λ changes almost nothing (78 to 77 exceptions): the problem of the normal EWMA is not speed, it is the tail. With the 5-degree t quantile, the 0.94 EWMA drops to 54 exceptions (Kupiec p 0.377) and the 0.97 one to 48, exactly the expected number (p 0.977), and both still pass the independence test. An EWMA with fat tails gets close to the GARCH-t without estimating anything; here it helps that the chosen quantile is the true one of the innovations, something that would have to be estimated with real data. :::: 2. **The power of the traffic light.** Compute, with a 250-day binomial, the probability that a model whose exceptions truly occur with a 2% and a 3% probability ends up in green, amber and red. What does this say about a year in green? ::::solucion :::cell{title="Probability of each zone by the actual frequency of exceptions"} ```python zonas = {} for p_real in (0.01, 0.02, 0.03): bb = stats.binom(250, p_real) zonas[p_real] = {"green": bb.cdf(4), "amber": bb.cdf(9) - bb.cdf(4), "red": bb.sf(9)} pd.DataFrame(zonas).T.round(3) ``` ```output green amber red 0.01 0.892 0.108 0.000 0.02 0.439 0.531 0.030 0.03 0.128 0.651 0.221 ``` ::: A correct model ends up in green 89.2% of the years. One whose exceptions occur twice as often (2%) still ends up in green 43.9% of the years and only reaches red 3.0% of the time; with three times as many (3%), in green 12.8%. A year in green is compatible with a model that underestimates risk by half: the test has little power with 250 observations. A year in amber does not condemn a correct model either (10.8%). That is why results are accumulated over several years and looked at together with the clustering of exceptions and the ES. :::: 3. **A VaR for a proprietary system.** You have a system with three strategies and want a one-day 99% VaR to set a daily loss limit. Which method among those in the lesson would you choose, with what data would you estimate it and what would you check every month? :::solucion A model that reacts to recent volatility with fat tails: a GARCH-t or an FHS on the aggregate P&L of the system (not on each strategy separately, so that correlations come in on their own), re-estimated with the whole past every few months and computed at the close with data up to that close. The P&L must be that of the system with its positions of each day; if the strategies are new, that of their backtest, knowing that the backtest does not have the costs or the errors of live trading (lesson 4.5.3). Every month: number of exceptions and their clustering (Kupiec and Christoffersen with the whole history, not just the month), the 250-day traffic light, the ES Z2 and a list of each exception with its explanation, as MAR32.12 requires. And, separately, the stress tests of lesson 4.2.4 for what VaR does not see. :::

Each answer is folded under its question

Self-assessment

  1. A one-day 99% VaR of 200,000 USD means that...

    • AThe maximum possible loss tomorrow is 200,000 USD
    • BWith a correct model, tomorrow's loss exceeds 200,000 USD with a probability of 1%; VaR does not say how much is lost that day
    • C200,000 USD is lost one day out of every hundred
    • DThe mean loss of the worst days is 200,000 USD
    Show answer

    Answer B. With a correct model, tomorrow's loss exceeds 200,000 USD with a probability of 1%; VaR does not say how much is lost that day

    VaR is a quantile: it marks the boundary of the worst 1%, not its size. What lies beyond is measured by Expected Shortfall, the mean loss conditional on exceeding the VaR.

  2. The 250-session historical VaR has 1.97% exceptions in the data and its mean error versus the true one is 105,800 USD, even though on average it is above the true one. Why does it fail?

    • ABecause it uses the normal distribution
    • BBecause the window is too long to have enough data
    • CBecause it reacts late: it does not rise until the crisis losses enter its window and does not fall until they leave, a year later; exceptions cluster at the start of each crisis
    • DBecause the book has a positive mean
    Show answer

    Answer C. Because it reacts late: it does not rise until the crisis losses enter its window and does not fall until they leave, a year later; exceptions cluster at the start of each crisis

    A quantile of 250 equally weighted observations does not distinguish yesterday's volatility from that of eleven months ago. That is why the historical VaR fails Christoffersen's independence test (p 0.000): one exception makes the next one more likely.

  3. A bank has 7 exceptions of its 99% VaR in the last 250 days. According to table 1 of MAR32, what happens?

    • ANothing: it is in the green zone
    • BIt is in the red zone and loses its internal model
    • CThe supervisor forces it to switch to a historical VaR
    • DIt is in the amber zone and its backtesting multiplier goes from 1.50 to 1.83
    Show answer

    Answer D. It is in the amber zone and its backtesting multiplier goes from 1.50 to 1.83

    Green from 0 to 4 exceptions (1.50), amber from 5 to 9 (1.70, 1.76, 1.83, 1.88 and 1.92) and red with 10 or more (2.00). With a correct model the amber zone comes up 10.8% of the time: it does not prove that the model is wrong, but it makes it more expensive.

  4. What does the Christoffersen test check that the Kupiec test does not?

    • AThat exceptions do not cluster: the probability of an exception must not depend on whether there was one yesterday
    • BThat the number of exceptions is the expected one
    • CThat the mean loss of the exceptions is correct
    • DThat VaR is larger than ES
    Show answer

    Answer A. That exceptions do not cluster: the probability of an exception must not depend on whether there was one yesterday

    Kupiec only looks at frequency. A VaR can hit 1% on average and fail in streaks in every crisis. Christoffersen compares the probability of an exception after a day without one with the probability after a day with one.

  5. With 250 days, the 5% critical value of the Acerbi and Székely Z2 comes out at −0.69 in the simulation; with 4,780 days, −0.16. What does it imply for FHS, which gives Z2 = −0.229 on the full sample?

    • AThat it passes: it is above −0.69
    • BThat with the full sample its ES is rejected at 5% (−0.229 < −0.16), although in a one-year window that value would not stand out
    • CThat the FHS ES is too high
    • DThat Z2 does not work for FHS
    Show answer

    Answer B. That with the full sample its ES is rejected at 5% (−0.229 < −0.16), although in a one-year window that value would not stand out

    The critical value depends on the number of days: with more data, smaller deviations are significant. FHS underestimates the tail overall and, with 19 years, this is detected; with one, it is not.

Key points

  • VaR is a quantile of the loss and ES the mean of what lies beyond it. Basel uses the 99% VaR for backtesting (MAR32) and the 97.5% ES for capital (MAR33).
  • With the same book, the 250-session historical and normal VaRs have twice as many exceptions as they should, and the error of the historical one versus the true one is 105,800 USD on average: they react late and stay high for a year.
  • A GJR-GARCH-t estimated each January with the past stays 16,200 USD away from the true VaR on average, passes Kupiec and Christoffersen and spends 88% of the time in green, like the true model.
  • The square-root-of-10 rule fails when volatility reverts to the mean: the Monte Carlo of the true model gives 945,000 USD at 10 days in October 2008 versus 858,000 with the square root of 10.
  • The tests have little power with one year of data: a correct model falls into amber 10.8% of the time and one with twice the exceptions often stays in green. You have to look at long series, clustering and ES, not just the count.
  • The Acerbi-Székely Z2 tests ES with what is stored each day (VaR, ES and P&L): it rejects the historical, normal, EWMA and FHS models, and not the GARCH-t or the true one.

Resources

Data