Skip to main content

rustyqlib/risk/
backtest.rs

1//! VaR backtesting: the Kupiec proportion-of-failures (POF) test.
2
3/// Result of a Kupiec POF likelihood-ratio test.
4#[derive(Debug, Clone, Copy)]
5pub struct KupiecTest {
6    /// Observed VaR exceptions.
7    pub exceptions: usize,
8    pub observations: usize,
9    /// Exceptions implied by the VaR level.
10    pub expected: f64,
11    /// Likelihood-ratio statistic (chi-squared with 1 dof under H0).
12    pub lr_statistic: f64,
13    /// True when the model is rejected at the 95% test level
14    /// (LR > 3.841).
15    pub rejected: bool,
16}
17
18/// Kupiec (1995) unconditional-coverage test of a VaR model:
19/// `exceptions` days out of `observations` breached a
20/// `confidence`-level VaR. Under a correct model the breach frequency
21/// is `1 - confidence`; the LR statistic is asymptotically
22/// chi-squared(1).
23pub fn kupiec_pof(exceptions: usize, observations: usize, confidence: f64) -> KupiecTest {
24    assert!(observations > 0 && exceptions <= observations);
25    assert!(confidence > 0.5 && confidence < 1.0);
26    let p = 1.0 - confidence;
27    let n = observations as f64;
28    let x = exceptions as f64;
29    let expected = p * n;
30    // log-likelihoods, with the empty-cell conventions 0 ln 0 = 0
31    let ll = |prob: f64| -> f64 {
32        let mut v = 0.0;
33        if x > 0.0 {
34            v += x * prob.ln();
35        }
36        if x < n {
37            v += (n - x) * (1.0 - prob).ln();
38        }
39        v
40    };
41    let observed_rate = (x / n).clamp(1e-12, 1.0 - 1e-12);
42    let lr = -2.0 * (ll(p) - ll(observed_rate));
43    KupiecTest {
44        exceptions,
45        observations,
46        expected,
47        lr_statistic: lr,
48        rejected: lr > 3.841,
49    }
50}
51
52#[cfg(test)]
53mod tests {
54    use super::*;
55
56    #[test]
57    fn correct_coverage_passes_and_bad_coverage_fails() {
58        // 99% VaR over 1000 days: ~10 exceptions expected
59        let ok = kupiec_pof(10, 1000, 0.99);
60        assert!(!ok.rejected, "{ok:?}");
61        assert!(ok.lr_statistic < 0.1);
62        // a model that breaches 30 times is rejected
63        let bad = kupiec_pof(30, 1000, 0.99);
64        assert!(bad.rejected, "{bad:?}");
65        // suspiciously few exceptions is also evidence against the model
66        let conservative = kupiec_pof(0, 1000, 0.99);
67        assert!(conservative.rejected, "{conservative:?}");
68    }
69}