"""VaR backtests: Kupiec's unconditional coverage, Christoffersen's
independence and conditional coverage, and the Basel traffic light.
A VaR model at level :math:`\\alpha` should be exceeded on a fraction
:math:`p = 1 - \\alpha` of days, and the exceptions should arrive
independently rather than in clusters. Each likelihood-ratio statistic
is asymptotically chi-squared under the null of a correct model.
References: P. H. Kupiec, "Techniques for Verifying the Accuracy of Risk
Measurement Models," *J. Derivatives* 3 (1995) 73-84; P. F.
Christoffersen, "Evaluating Interval Forecasts," *Int. Econ. Rev.* 39
(1998) 841-862; Basel Committee on Banking Supervision, *Supervisory
Framework for the Use of "Backtesting" in Conjunction with the Internal
Models Approach to Market Risk Capital Requirements* (1996).
"""
from __future__ import annotations
import numpy as np
from numpy.typing import ArrayLike
from scipy import stats
from scipy.special import xlogy
from financialkit.risk.core.base import BacktestResult, TrafficLightResult
from financialkit.risk.utils.tail import check_alpha
__all__ = ["kupiec_test", "christoffersen_independence", "christoffersen_test", "basel_traffic_light"]
# Basel (1996), Table 2: plus factors for 5-9 exceptions in 250 days.
_PLUS_FACTOR = {5: 0.40, 6: 0.50, 7: 0.65, 8: 0.75, 9: 0.85}
def _hits(hits: ArrayLike) -> np.ndarray:
h = np.asarray(hits).ravel()
if h.size == 0:
raise ValueError("hits is empty")
return h.astype(bool)
def _bernoulli_loglik(x: float, n: float, p: float) -> float:
return float(xlogy(n - x, 1.0 - p) + xlogy(x, p))
[docs]
def kupiec_test(hits: ArrayLike, alpha: float = 0.99) -> BacktestResult:
"""Kupiec's proportion-of-failures test of unconditional coverage.
.. math::
LR_{uc} = -2 \\ln \\frac{(1 - p)^{n - x} p^x}{(1 - \\hat p)^{n - x} \\hat p^x},
\\qquad \\hat p = x / n, \\quad LR_{uc} \\sim \\chi^2_1.
Parameters
----------
hits : array_like of bool, shape (n,)
Exception indicator per day, e.g. from
:func:`~financialkit.risk.utils.tail.var_exceptions`.
alpha : float
VaR confidence level.
Returns
-------
BacktestResult
Examples
--------
Twelve exceptions of a 99% VaR in 250 days are far too many.
>>> res = kupiec_test([True] * 12 + [False] * 238)
>>> round(res.statistic, 2), res.reject()
(19.02, True)
"""
h = _hits(hits)
p = 1.0 - check_alpha(alpha)
n, x = h.size, int(h.sum())
lr = -2.0 * (_bernoulli_loglik(x, n, p) - _bernoulli_loglik(x, n, x / n))
return BacktestResult(statistic=lr, p_value=float(stats.chi2.sf(lr, 1)), df=1, n_exceptions=x, n_obs=n, expected=n * p)
[docs]
def christoffersen_independence(hits: ArrayLike, alpha: float = 0.99) -> BacktestResult:
"""Christoffersen's test that exceptions do not cluster.
Fits a two-state Markov chain to the hit sequence, with
:math:`\\pi_{01} = P(\\text{hit} \\mid \\text{no hit yesterday})` and
:math:`\\pi_{11} = P(\\text{hit} \\mid \\text{hit yesterday})`, and
tests :math:`\\pi_{01} = \\pi_{11}` against a single rate
(:math:`\\chi^2_1`).
Parameters
----------
hits : array_like of bool, shape (n,)
alpha : float
VaR confidence level (only used to report the expected count).
Returns
-------
BacktestResult
"""
h = _hits(hits)
prev, cur = h[:-1], h[1:]
n00 = float(np.sum(~prev & ~cur))
n01 = float(np.sum(~prev & cur))
n10 = float(np.sum(prev & ~cur))
n11 = float(np.sum(prev & cur))
pi01 = n01 / (n00 + n01) if n00 + n01 else 0.0
pi11 = n11 / (n10 + n11) if n10 + n11 else 0.0
pi = (n01 + n11) / (n00 + n01 + n10 + n11)
l_markov = _bernoulli_loglik(n01, n00 + n01, pi01) + _bernoulli_loglik(n11, n10 + n11, pi11)
lr = -2.0 * (_bernoulli_loglik(n01 + n11, n00 + n01 + n10 + n11, pi) - l_markov)
lr = max(lr, 0.0)
p = 1.0 - check_alpha(alpha)
return BacktestResult(statistic=lr, p_value=float(stats.chi2.sf(lr, 1)), df=1, n_exceptions=int(h.sum()), n_obs=h.size, expected=h.size * p)
[docs]
def christoffersen_test(hits: ArrayLike, alpha: float = 0.99) -> BacktestResult:
"""Christoffersen's conditional-coverage test, :math:`LR_{cc} = LR_{uc} + LR_{ind} \\sim \\chi^2_2`.
Parameters
----------
hits : array_like of bool, shape (n,)
alpha : float
VaR confidence level.
Returns
-------
BacktestResult
"""
uc = kupiec_test(hits, alpha)
ind = christoffersen_independence(hits, alpha)
lr = uc.statistic + ind.statistic
return BacktestResult(statistic=lr, p_value=float(stats.chi2.sf(lr, 2)), df=2, n_exceptions=uc.n_exceptions, n_obs=uc.n_obs, expected=uc.expected)
[docs]
def basel_traffic_light(n_exceptions: int, n_obs: int = 250, alpha: float = 0.99) -> TrafficLightResult:
"""The Basel Committee's (1996) green, yellow and red zones.
The zone is set by the binomial probability that an accurate model
produces at most ``n_exceptions`` exceptions: green below 95%, red
from 99.99%, yellow in between. For the regulatory setting (250 days,
99% VaR) that is 0-4 green, 5-9 yellow and 10 or more red, and the
capital multiplier is 3 plus the Committee's plus factor.
Parameters
----------
n_exceptions : int
n_obs : int
Days in the backtest.
alpha : float
VaR confidence level.
Returns
-------
TrafficLightResult
Examples
--------
>>> res = basel_traffic_light(6)
>>> res.zone, res.multiplier
('yellow', 3.5)
"""
p = 1.0 - check_alpha(alpha)
cum = float(stats.binom.cdf(n_exceptions, n_obs, p))
zone = "green" if cum < 0.95 else ("red" if cum >= 0.9999 else "yellow")
if n_obs == 250 and np.isclose(alpha, 0.99):
multiplier = 3.0 + (0.0 if zone == "green" else 1.0 if zone == "red" else _PLUS_FACTOR[n_exceptions])
else:
multiplier = float("nan")
return TrafficLightResult(zone=zone, n_exceptions=int(n_exceptions), cumulative_probability=cum, multiplier=multiplier)