A loss limit with a confidence level
Value at Risk answers: how much could I lose on a bad day, but not the worst? A 1-day 95% VaR of 2% says that on 95 days out of 100 the loss should not exceed 2%. The historical method needs no model. Take the past returns, sort them, and read off the loss at the 5th percentile: the VaR is minus that quantile. Regulators and risk limits use it everywhere because it is one number a manager can compare with a limit.
import numpy as np
rng = np.random.default_rng(3)
returns = rng.normal(0.0004, 0.012, size=500) # two years of daily returns
var_95 = -np.quantile(returns, 0.05) # minus the 5th percentile
var_99 = -np.quantile(returns, 0.01)
print(round(float(var_95), 5), round(float(var_99), 5)) # 0.01928 0.027 the 99% loss limit is largerBacktesting a VaR: exceptions
A VaR is a prediction, so it can be checked. An exception is a day on which the loss exceeded the VaR, return < -VaR. With a 95% VaR you expect about 5% of days to be exceptions: far more means the model understates risk, far fewer that it is too cautious. Regulators count exceptions over 250 days for exactly this reason. Note the strict inequality: a loss equal to the VaR is not an exception.
import numpy as np
rng = np.random.default_rng(3)
returns = rng.normal(0.0004, 0.012, size=500)
var_95 = -np.quantile(returns, 0.05)
exceptions = int(np.sum(returns < -var_95))
print(exceptions, "exceptions in", len(returns), "days; expected about", int(0.05 * len(returns))) # 25 25Longer horizons, and as it happened
A 10-day VaR is often required. With independent daily returns, volatility grows with the square root of time, so the square-root-of-time rule scales: VaR_T = VaR_1 * sqrt(T). It is an approximation, weaker for fat tails and autocorrelated returns. Honest backtests also respect time: the VaR used on day t may only use the returns before day t, the same discipline as avoiding look-ahead. A rolling VaR does that with a window of the last n days.
# The square-root-of-time rule for several horizons
import math
one_day = 0.0186
for days in (1, 5, 10, 20):
print(days, round(one_day * math.sqrt(days), 4)) # 1: 0.0186, 5: 0.0416, 10: 0.0588, 20: 0.0832import numpy as np
def historical_var(returns, confidence):
return float(-np.quantile(returns, 1 - confidence))
def rolling_var(returns, window, confidence):
out = np.full(len(returns), np.nan)
for t in range(window, len(returns)):
out[t] = historical_var(returns[t - window:t], confidence) # the days BEFORE day t
return out
rng = np.random.default_rng(3)
returns = rng.normal(0.0004, 0.012, size=500)
rolling = rolling_var(returns, 250, 0.99)
print(np.isnan(rolling[:250]).all(), round(float(rolling[250]), 5), round(float(rolling[-1]), 5))
exceptions = int(np.sum(returns[250:] < -rolling[250:]))
print(exceptions, "exceptions in", len(returns) - 250, "days (about 2 expected at 99%)")
print(round(historical_var(returns, 0.99) * np.sqrt(10), 5)) # a 10-day figure by the square-root rule- Historical VaR is minus the
(1 - confidence)quantile of past returns (np.quantile): a positive loss fraction needing no model. - Backtest by counting exceptions (
return < -VaR): about1 - confidenceof the days; more means the VaR is too low. - Scale to a horizon with
var * sqrt(days); a rolling window uses only earlier days; VaR gives a threshold, not the size of losses beyond it.
Next up: parametric VaR, computing the same threshold from a normal distribution instead of the raw history.
Lab · what's graded
- Read the loss from history with
historical_var25 pts - Count the breaches with
var_exceptions25 pts - Stretch the horizon with
scale_var25 pts - Estimate it as it happened with
rolling_var25 pts