diff --git a/requirements.txt b/requirements.txt index f88df538..fe9291d3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,4 +2,5 @@ pyyaml>=6.0 numpy>=1.21.0 pandas>=1.3.0 +scipy>=1.7.0 pyotp>=2.6.0 diff --git a/skills/real-time-var-backtesting-kupiec-test/scripts/kupiec_var_backtester.py b/skills/real-time-var-backtesting-kupiec-test/scripts/kupiec_var_backtester.py index e60e371b..565521c7 100644 --- a/skills/real-time-var-backtesting-kupiec-test/scripts/kupiec_var_backtester.py +++ b/skills/real-time-var-backtesting-kupiec-test/scripts/kupiec_var_backtester.py @@ -1,25 +1,62 @@ +""" +kupiec_var_backtester: Kupiec POF (Proportion of Failures) Likelihood Ratio test +for VaR model backtesting. +""" from dataclasses import dataclass -import scipy.stats as st +import math +from typing import Optional + +try: + import scipy.stats as st +except ImportError: + st = None + @dataclass class KupiecResult: p_value: float is_rejected: bool exceptions: int + stat: float = 0.0 + class KupiecVaRBacktester: - def __init__(self, confidence_level: float = 0.99): + def __init__(self, confidence_level: float = 0.99, alpha: float = 0.05): self.confidence_level = confidence_level + self.alpha = alpha def run_test(self, total_observations: int, exceptions: int) -> KupiecResult: - if total_observations == 0: - return KupiecResult(1.0, False, 0) - p = 1 - self.confidence_level - # binomial test - try: - res = st.binomtest(exceptions, total_observations, p, alternative='two-sided') - p_val = res.pvalue - except AttributeError: - p_val = st.binom_test(exceptions, total_observations, p, alternative='two-sided') - is_rejected = p_val < 0.05 - return KupiecResult(float(p_val), is_rejected, exceptions) + if total_observations <= 0: + return KupiecResult(1.0, False, 0, 0.0) + + p = 1.0 - self.confidence_level + + if st is not None: + try: + res = st.binomtest(exceptions, total_observations, p, alternative='two-sided') + p_val = float(res.pvalue) + except AttributeError: + p_val = float(st.binom_test(exceptions, total_observations, p, alternative='two-sided')) + is_rejected = p_val < self.alpha + return KupiecResult(p_val, is_rejected, exceptions) + + # Pure Python Kupiec POF Likelihood Ratio test fallback + x = exceptions + N = total_observations + if x == 0: + lr_stat = -2.0 * (N * math.log(1.0 - p)) + elif x == N: + lr_stat = -2.0 * (N * math.log(p)) + else: + pi_hat = x / N + num = ((1.0 - p) ** (N - x)) * (p ** x) + den = ((1.0 - pi_hat) ** (N - x)) * (pi_hat ** x) + if den > 0 and num > 0: + lr_stat = -2.0 * math.log(num / den) + else: + lr_stat = 0.0 + + # Critical value for Chi-Square(1) at alpha=0.05 is 3.841459 + is_rejected = lr_stat > 3.841459 + p_val = math.exp(-lr_stat / 2.0) # Approx p-value under Chi2(1) + return KupiecResult(p_val, is_rejected, exceptions, stat=lr_stat)