Skip to main content

rustyqlib/risk/
measures.rs

1//! Value-at-Risk and Expected Shortfall in the three standard flavors:
2//! historical (empirical), parametric normal (with a Cornish-Fisher
3//! higher-moment correction), and delta-normal for multi-asset books.
4//!
5//! Conventions: `confidence` is the one-sided level (0.99 = 99%), and
6//! both VaR and ES are reported as **positive loss amounts** in the P&L
7//! currency. ES is always >= VaR at the same level (asserted in tests).
8
9use crate::core::utils::{norm_pdf, inv_norm_cdf};
10
11/// Linear-interpolation (type-7) empirical quantile of a sample.
12fn quantile(sorted: &[f64], p: f64) -> f64 {
13    let n = sorted.len();
14    assert!(n > 0);
15    let h = (n as f64 - 1.0) * p.clamp(0.0, 1.0);
16    let lo = h.floor() as usize;
17    let hi = (lo + 1).min(n - 1);
18    sorted[lo] + (h - lo as f64) * (sorted[hi] - sorted[lo])
19}
20
21/// Historical (empirical) VaR from a P&L sample.
22pub fn historical_var(pnl: &[f64], confidence: f64) -> f64 {
23    assert!(!pnl.is_empty() && confidence > 0.5 && confidence < 1.0);
24    let mut losses: Vec<f64> = pnl.iter().map(|x| -x).collect();
25    losses.sort_by(f64::total_cmp);
26    quantile(&losses, confidence).max(0.0)
27}
28
29/// Historical Expected Shortfall: the average loss at or beyond the VaR
30/// quantile.
31pub fn historical_expected_shortfall(pnl: &[f64], confidence: f64) -> f64 {
32    assert!(!pnl.is_empty() && confidence > 0.5 && confidence < 1.0);
33    let mut losses: Vec<f64> = pnl.iter().map(|x| -x).collect();
34    losses.sort_by(f64::total_cmp);
35    let var = quantile(&losses, confidence);
36    let tail: Vec<f64> = losses.iter().copied().filter(|&l| l >= var).collect();
37    if tail.is_empty() {
38        return var.max(0.0);
39    }
40    (tail.iter().sum::<f64>() / tail.len() as f64).max(0.0)
41}
42
43/// Parametric VaR under normal P&L with the given `mean` and `std`.
44/// `VaR = -mean + std * z_alpha`.
45pub fn parametric_var(mean: f64, std: f64, confidence: f64) -> f64 {
46    assert!(std >= 0.0 && confidence > 0.5 && confidence < 1.0);
47    (-mean + std * inv_norm_cdf(confidence)).max(0.0)
48}
49
50/// Parametric Expected Shortfall under normal P&L:
51/// `ES = -mean + std * phi(z_alpha) / (1 - alpha)`.
52pub fn parametric_expected_shortfall(mean: f64, std: f64, confidence: f64) -> f64 {
53    assert!(std >= 0.0 && confidence > 0.5 && confidence < 1.0);
54    let z = inv_norm_cdf(confidence);
55    (-mean + std * norm_pdf(z) / (1.0 - confidence)).max(0.0)
56}
57
58/// Cornish-Fisher VaR: the normal quantile adjusted for the sample's
59/// skewness and excess kurtosis — a standard desk correction for fat,
60/// asymmetric P&L. With zero skew and excess kurtosis it reduces to
61/// [`parametric_var`].
62pub fn cornish_fisher_var(
63    mean: f64,
64    std: f64,
65    skewness: f64,
66    excess_kurtosis: f64,
67    confidence: f64,
68) -> f64 {
69    // VaR lives in the LOWER tail of P&L: expand the (1 - confidence)
70    // quantile. The odd (linear-in-z) terms flip sign with the tail,
71    // the skew term does not - negative P&L skew lengthens the loss
72    // tail and raises VaR.
73    let zl = -inv_norm_cdf(confidence);
74    let z2 = zl * zl;
75    let q = zl
76        + (z2 - 1.0) * skewness / 6.0
77        + zl * (z2 - 3.0) * excess_kurtosis / 24.0
78        - zl * (2.0 * z2 - 5.0) * skewness * skewness / 36.0;
79    (-(mean + std * q)).max(0.0)
80}
81
82/// Delta-normal (variance-covariance) VaR of a linear book with the
83/// **Euler decomposition** into per-position components.
84#[derive(Debug, Clone)]
85pub struct DeltaNormalVar {
86    /// Total portfolio VaR.
87    pub var: f64,
88    /// Portfolio P&L standard deviation.
89    pub std: f64,
90    /// Component VaR per position (sums exactly to `var`).
91    pub component_var: Vec<f64>,
92    /// Marginal VaR per position (`d var / d exposure_i`).
93    pub marginal_var: Vec<f64>,
94}
95
96/// Delta-normal VaR: `exposures[i]` is the currency P&L per unit return
97/// of asset `i` (delta x spot), `covariance` the per-horizon return
98/// covariance matrix.
99pub fn delta_normal_var(
100    exposures: &[f64],
101    covariance: &[Vec<f64>],
102    confidence: f64,
103) -> DeltaNormalVar {
104    let n = exposures.len();
105    assert!(n > 0 && covariance.len() == n);
106    assert!(covariance.iter().all(|row| row.len() == n));
107    // sigma_p^2 = w' C w  and the gradient C w
108    let cw: Vec<f64> = (0..n)
109        .map(|i| (0..n).map(|j| covariance[i][j] * exposures[j]).sum())
110        .collect();
111    let variance: f64 = exposures.iter().zip(&cw).map(|(w, c)| w * c).sum();
112    assert!(variance >= -1e-12, "covariance matrix is not PSD on this exposure");
113    let std = variance.max(0.0).sqrt();
114    let z = inv_norm_cdf(confidence);
115    let var = std * z;
116    // Euler: component_i = w_i (Cw)_i / sigma_p * z; sums to VaR exactly
117    let (component_var, marginal_var) = if std > 0.0 {
118        (
119            exposures.iter().zip(&cw).map(|(w, c)| w * c / std * z).collect(),
120            cw.iter().map(|c| c / std * z).collect(),
121        )
122    } else {
123        (vec![0.0; n], vec![0.0; n])
124    };
125    DeltaNormalVar { var, std, component_var, marginal_var }
126}
127
128#[cfg(test)]
129mod tests {
130    use super::*;
131    use crate::core::montecarlo::path_rng;
132    use rand::Rng;
133
134    #[test]
135    fn historical_measures_on_a_hand_checked_sample() {
136        // losses: 10, 8, 6, 4, 2 and five gains
137        let pnl = [-10.0, -8.0, -6.0, -4.0, -2.0, 1.0, 2.0, 3.0, 4.0, 5.0];
138        let var90 = historical_var(&pnl, 0.9);
139        // type-7 quantile at p=0.9 on n=10 sorted losses: index 8.1
140        assert!((var90 - 8.2).abs() < 1e-12, "{var90}");
141        let es90 = historical_expected_shortfall(&pnl, 0.9);
142        assert!(es90 >= var90 && es90 <= 10.0, "{es90}");
143        // monotone in confidence
144        assert!(historical_var(&pnl, 0.95) >= var90);
145    }
146
147    #[test]
148    fn historical_converges_to_parametric_on_normal_pnl() {
149        let n = 60_000;
150        let (mu, sd) = (0.5, 10.0);
151        let mut rng = path_rng(7, 0);
152        let pnl: Vec<f64> =
153            (0..n).map(|_| mu + sd * rng.sample::<f64, _>(rand_distr::StandardNormal)).collect();
154        for conf in [0.95, 0.99] {
155            let hist = historical_var(&pnl, conf);
156            let para = parametric_var(mu, sd, conf);
157            assert!((hist - para).abs() < 0.4, "VaR {conf}: {hist} vs {para}");
158            let hist_es = historical_expected_shortfall(&pnl, conf);
159            let para_es = parametric_expected_shortfall(mu, sd, conf);
160            assert!((hist_es - para_es).abs() < 0.5, "ES {conf}: {hist_es} vs {para_es}");
161            assert!(hist_es > hist && para_es > para, "ES dominates VaR");
162        }
163    }
164
165    #[test]
166    fn parametric_es_matches_numerical_tail_integration() {
167        // ES = E[loss | loss >= VaR] under the normal, by brute quadrature
168        let (mu, sd, conf) = (0.0, 1.0, 0.975);
169        let es = parametric_expected_shortfall(mu, sd, conf);
170        let z = inv_norm_cdf(conf);
171        let steps = 400_000;
172        let (a, b) = (z, 10.0);
173        let h = (b - a) / steps as f64;
174        let mut num = 0.0;
175        for i in 0..=steps {
176            let x = a + i as f64 * h;
177            let w = if i == 0 || i == steps { 0.5 } else { 1.0 };
178            num += w * x * norm_pdf(x) * h;
179        }
180        let tail_mean = num / (1.0 - conf);
181        assert!((es - tail_mean).abs() < 1e-4, "{es} vs {tail_mean}");
182    }
183
184    #[test]
185    fn cornish_fisher_reduces_to_normal_and_penalizes_left_skew() {
186        let base = parametric_var(0.0, 5.0, 0.99);
187        assert!((cornish_fisher_var(0.0, 5.0, 0.0, 0.0, 0.99) - base).abs() < 1e-12);
188        // negative skew (long left tail of P&L = big losses) raises VaR;
189        // note the skew of LOSSES enters with the P&L sign convention
190        let skewed = cornish_fisher_var(0.0, 5.0, -0.8, 0.0, 0.99);
191        assert!(skewed > base, "{skewed} vs {base}");
192        let fat = cornish_fisher_var(0.0, 5.0, 0.0, 3.0, 0.99);
193        assert!(fat > base, "excess kurtosis must raise tail risk");
194    }
195
196    #[test]
197    fn delta_normal_var_and_euler_decomposition() {
198        // two assets, hand-computable: sigma1=2%, sigma2=3%, rho=0.5,
199        // exposures 1m and -0.5m
200        let cov = vec![
201            vec![0.02 * 0.02, 0.5 * 0.02 * 0.03],
202            vec![0.5 * 0.02 * 0.03, 0.03 * 0.03],
203        ];
204        let w = [1_000_000.0, -200_000.0];
205        let out = delta_normal_var(&w, &cov, 0.99);
206        let variance = w[0] * w[0] * cov[0][0]
207            + 2.0 * w[0] * w[1] * cov[0][1]
208            + w[1] * w[1] * cov[1][1];
209        assert!((out.std - variance.sqrt()).abs() < 1e-6);
210        assert!((out.var - variance.sqrt() * inv_norm_cdf(0.99)).abs() < 1e-6);
211        // Euler: components sum exactly to the total
212        let sum: f64 = out.component_var.iter().sum();
213        assert!((sum - out.var).abs() < 1e-6, "{sum} vs {}", out.var);
214        // Euler components are marginal-scaling contributions, not
215        // leave-one-out: at this size the short position's hedge effect
216        // dominates its own variance and its component is negative
217        assert!(out.component_var[1] < 0.0, "{:?}", out.component_var);
218        // scaled up, the short's own variance takes over and the
219        // component turns positive - both signs are legitimate
220        let big_short = delta_normal_var(&[1_000_000.0, -500_000.0], &cov, 0.99);
221        assert!(big_short.component_var[1] > 0.0);
222        let sum2: f64 = big_short.component_var.iter().sum();
223        assert!((sum2 - big_short.var).abs() < 1e-6);
224    }
225}