Skip to main content

fin_primitives/risk/
var.rs

1//! Value at Risk (VaR) and Conditional VaR (CVaR) models.
2//!
3//! Supports Historical, Parametric (normal), Monte Carlo (GBM), and Cornish-Fisher methods.
4
5/// Method used to compute VaR.
6#[derive(Debug, Clone, PartialEq)]
7pub enum VarMethod {
8    /// Historical simulation from empirical return distribution.
9    Historical,
10    /// Parametric normal distribution (mean + z-score * sigma).
11    Parametric,
12    /// Monte Carlo simulation via Geometric Brownian Motion.
13    MonteCarlo {
14        /// Number of simulated paths.
15        n_simulations: usize,
16        /// Random seed for reproducibility.
17        seed: u64,
18    },
19    /// Cornish-Fisher expansion adjusted for skewness and excess kurtosis.
20    CornishFisher,
21}
22
23/// Value at Risk result.
24#[derive(Debug, Clone)]
25pub struct VaRResult {
26    /// Confidence level (e.g. 0.95 for 95%).
27    pub confidence: f64,
28    /// Risk horizon in trading days.
29    pub horizon_days: u32,
30    /// VaR expressed in USD (absolute loss at the confidence level).
31    pub var_usd: f64,
32    /// VaR expressed as a fraction of position value.
33    pub var_pct: f64,
34    /// Method used for calculation.
35    pub method: VarMethod,
36}
37
38/// Conditional VaR (Expected Shortfall) result.
39#[derive(Debug, Clone)]
40pub struct CVaRResult {
41    /// Confidence level.
42    pub confidence: f64,
43    /// Expected loss beyond the VaR threshold (USD).
44    pub cvar_usd: f64,
45    /// The underlying VaR result at the same confidence.
46    pub var_result: VaRResult,
47}
48
49/// Stateless VaR calculator.
50pub struct VaRCalculator;
51
52impl VaRCalculator {
53    // -----------------------------------------------------------------------
54    // Internal helpers
55    // -----------------------------------------------------------------------
56
57    /// Probit approximation (inverse normal CDF) for the given probability `p`.
58    /// Uses the Beasley-Springer-Moro approximation.
59    fn probit(p: f64) -> f64 {
60        // Rational approximation coefficients (Abramowitz & Stegun 26.2.17)
61        const A: [f64; 4] = [2.515517, 0.802853, 0.010328, 0.0];
62        const B: [f64; 3] = [1.432788, 0.189269, 0.001308];
63
64        let p = p.clamp(1e-10, 1.0 - 1e-10);
65        let sign = if p < 0.5 { -1.0_f64 } else { 1.0_f64 };
66        let t = if p < 0.5 {
67            (-2.0 * p.ln()).sqrt()
68        } else {
69            (-2.0 * (1.0 - p).ln()).sqrt()
70        };
71        let numerator = A[0] + A[1] * t + A[2] * t * t + A[3] * t * t * t;
72        let denominator = 1.0 + B[0] * t + B[1] * t * t + B[2] * t * t * t;
73        sign * (t - numerator / denominator)
74    }
75
76    /// Computes sample mean of a slice.
77    fn mean(data: &[f64]) -> f64 {
78        if data.is_empty() {
79            return 0.0;
80        }
81        data.iter().sum::<f64>() / data.len() as f64
82    }
83
84    /// Computes sample standard deviation.
85    fn std_dev(data: &[f64]) -> f64 {
86        if data.len() < 2 {
87            return 0.0;
88        }
89        let m = Self::mean(data);
90        let var = data.iter().map(|x| (x - m).powi(2)).sum::<f64>() / (data.len() - 1) as f64;
91        var.sqrt()
92    }
93
94    /// Computes sample skewness (Fisher's definition).
95    fn skewness(data: &[f64]) -> f64 {
96        let n = data.len() as f64;
97        if n < 3.0 {
98            return 0.0;
99        }
100        let m = Self::mean(data);
101        let s = Self::std_dev(data);
102        if s == 0.0 {
103            return 0.0;
104        }
105        let sum3 = data.iter().map(|x| ((x - m) / s).powi(3)).sum::<f64>();
106        (n / ((n - 1.0) * (n - 2.0))) * sum3
107    }
108
109    /// Computes sample excess kurtosis.
110    fn excess_kurtosis(data: &[f64]) -> f64 {
111        let n = data.len() as f64;
112        if n < 4.0 {
113            return 0.0;
114        }
115        let m = Self::mean(data);
116        let s = Self::std_dev(data);
117        if s == 0.0 {
118            return 0.0;
119        }
120        let sum4 = data.iter().map(|x| ((x - m) / s).powi(4)).sum::<f64>();
121        let kurt = (n * (n + 1.0) / ((n - 1.0) * (n - 2.0) * (n - 3.0))) * sum4
122            - 3.0 * (n - 1.0).powi(2) / ((n - 2.0) * (n - 3.0));
123        kurt
124    }
125
126    /// Simple LCG pseudo-random number generator yielding values in (0, 1).
127    fn lcg_random(seed: &mut u64) -> f64 {
128        *seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
129        let bits = 0x3FF0000000000000_u64 | (*seed >> 12);
130        f64::from_bits(bits) - 1.0
131    }
132
133    /// Box-Muller transform: generates a standard normal sample.
134    fn standard_normal(seed: &mut u64) -> f64 {
135        let u1 = Self::lcg_random(seed).max(1e-10);
136        let u2 = Self::lcg_random(seed);
137        (-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos()
138    }
139
140    // -----------------------------------------------------------------------
141    // Public methods
142    // -----------------------------------------------------------------------
143
144    /// Historical VaR: sort empirical returns, pick the quantile at `1 - confidence`.
145    pub fn historical_var(
146        returns: &[f64],
147        position_value: f64,
148        confidence: f64,
149        horizon_days: u32,
150    ) -> VaRResult {
151        if returns.is_empty() {
152            return VaRResult {
153                confidence,
154                horizon_days,
155                var_usd: 0.0,
156                var_pct: 0.0,
157                method: VarMethod::Historical,
158            };
159        }
160        let mut sorted = returns.to_vec();
161        sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
162
163        let alpha = 1.0 - confidence;
164        let idx = ((alpha * sorted.len() as f64).floor() as usize).min(sorted.len() - 1);
165        let daily_var_pct = -sorted[idx]; // losses are negative returns
166
167        // Scale to horizon using sqrt-of-time rule.
168        let var_pct = daily_var_pct * (horizon_days as f64).sqrt();
169        let var_usd = var_pct * position_value;
170
171        VaRResult {
172            confidence,
173            horizon_days,
174            var_usd: var_usd.max(0.0),
175            var_pct: var_pct.max(0.0),
176            method: VarMethod::Historical,
177        }
178    }
179
180    /// Parametric VaR: assumes normally distributed returns.
181    ///
182    /// Uses the probit function to find the z-score at the given confidence level.
183    pub fn parametric_var(
184        mu: f64,
185        sigma: f64,
186        position_value: f64,
187        confidence: f64,
188        horizon_days: u32,
189    ) -> VaRResult {
190        let z = -Self::probit(1.0 - confidence); // e.g. ~1.645 for 95%
191        // Daily VaR as a fraction: -(mu - z * sigma) per day
192        let daily_var_pct = -(mu - z * sigma);
193        let var_pct = (daily_var_pct * (horizon_days as f64).sqrt()).max(0.0);
194        let var_usd = var_pct * position_value;
195
196        VaRResult {
197            confidence,
198            horizon_days,
199            var_usd,
200            var_pct,
201            method: VarMethod::Parametric,
202        }
203    }
204
205    /// Monte Carlo VaR via Geometric Brownian Motion simulation.
206    ///
207    /// Simulates `n` paths over `horizon_days` days, collects terminal returns,
208    /// and takes the empirical quantile.
209    pub fn monte_carlo_var(
210        mu: f64,
211        sigma: f64,
212        position_value: f64,
213        confidence: f64,
214        horizon_days: u32,
215        n: usize,
216        seed: u64,
217    ) -> VaRResult {
218        let mut rng_seed = seed;
219        let dt = 1.0; // daily steps
220        let mut terminal_returns: Vec<f64> = Vec::with_capacity(n);
221
222        for _ in 0..n {
223            let mut log_return = 0.0_f64;
224            for _ in 0..horizon_days {
225                let z = Self::standard_normal(&mut rng_seed);
226                log_return += (mu - 0.5 * sigma * sigma) * dt + sigma * dt.sqrt() * z;
227            }
228            // Convert log return to simple return
229            terminal_returns.push(log_return.exp() - 1.0);
230        }
231
232        terminal_returns.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
233        let alpha = 1.0 - confidence;
234        let idx = ((alpha * n as f64).floor() as usize).min(n.saturating_sub(1));
235        let var_pct = (-terminal_returns[idx]).max(0.0);
236        let var_usd = var_pct * position_value;
237
238        VaRResult {
239            confidence,
240            horizon_days,
241            var_usd,
242            var_pct,
243            method: VarMethod::MonteCarlo {
244                n_simulations: n,
245                seed,
246            },
247        }
248    }
249
250    /// Conditional VaR (Expected Shortfall): expected loss beyond the VaR threshold.
251    pub fn conditional_var(
252        returns: &[f64],
253        position_value: f64,
254        confidence: f64,
255        horizon_days: u32,
256    ) -> CVaRResult {
257        let var_result =
258            Self::historical_var(returns, position_value, confidence, horizon_days);
259
260        if returns.is_empty() {
261            return CVaRResult {
262                confidence,
263                cvar_usd: 0.0,
264                var_result,
265            };
266        }
267
268        let mut sorted = returns.to_vec();
269        sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
270
271        let alpha = 1.0 - confidence;
272        let cutoff_idx = ((alpha * sorted.len() as f64).floor() as usize).min(sorted.len() - 1);
273
274        // Expected shortfall: mean of returns worse than the VaR quantile.
275        let tail: Vec<f64> = sorted[..=cutoff_idx].to_vec();
276        let cvar_pct = if tail.is_empty() {
277            0.0
278        } else {
279            -Self::mean(&tail) * (horizon_days as f64).sqrt()
280        };
281        let cvar_usd = (cvar_pct * position_value).max(0.0);
282
283        CVaRResult {
284            confidence,
285            cvar_usd,
286            var_result,
287        }
288    }
289
290    /// Portfolio VaR using a correlation matrix.
291    ///
292    /// `individual_vars` is the vector of individual VaR values (in the same units).
293    /// `correlation_matrix` is an N×N correlation matrix (row-major).
294    ///
295    /// Uses the formula: `portfolio_var = sqrt(w^T * C * w)` where `w = individual_vars`.
296    pub fn portfolio_var(individual_vars: &[f64], correlation_matrix: &[Vec<f64>]) -> f64 {
297        let n = individual_vars.len();
298        if n == 0 {
299            return 0.0;
300        }
301
302        let mut variance = 0.0_f64;
303        for i in 0..n {
304            for j in 0..n {
305                let corr = if i < correlation_matrix.len() && j < correlation_matrix[i].len() {
306                    correlation_matrix[i][j]
307                } else {
308                    if i == j { 1.0 } else { 0.0 }
309                };
310                variance += individual_vars[i] * individual_vars[j] * corr;
311            }
312        }
313        variance.max(0.0).sqrt()
314    }
315
316    /// Cornish-Fisher VaR: adjusts the normal z-score for skewness and excess kurtosis.
317    ///
318    /// Modified z-score for the lower tail, `q = -z` with `z > 0` (e.g. 1.645 at 95%):
319    /// `q_cf = q + (q²-1)*S/6 + (q³-3q)*K/24 - (2q³-5q)*S²/36`, and VaR uses `z_cf = -q_cf`
320    /// = `z - (z²-1)*S/6 + (z³-3z)*K/24 - (2z³-5z)*S²/36`.
321    pub fn cornish_fisher_var(
322        returns: &[f64],
323        position_value: f64,
324        confidence: f64,
325        horizon_days: u32,
326    ) -> VaRResult {
327        if returns.is_empty() {
328            return VaRResult {
329                confidence,
330                horizon_days,
331                var_usd: 0.0,
332                var_pct: 0.0,
333                method: VarMethod::CornishFisher,
334            };
335        }
336
337        let mu = Self::mean(returns);
338        let sigma = Self::std_dev(returns);
339        let skew = Self::skewness(returns);
340        let kurt = Self::excess_kurtosis(returns);
341
342        let z = -Self::probit(1.0 - confidence); // ~1.645 for 95%
343
344        // Cornish-Fisher expansion. The textbook form is written for the
345        // lower-tail quantile q = -z; flipping the sign to work with the positive
346        // z negates the odd-order (skewness) term only, so negative skew raises
347        // VaR as it should.
348        let z_cf = z
349            - (z.powi(2) - 1.0) * skew / 6.0
350            + (z.powi(3) - 3.0 * z) * kurt / 24.0
351            - (2.0 * z.powi(3) - 5.0 * z) * skew.powi(2) / 36.0;
352
353        let daily_var_pct = -(mu - z_cf * sigma);
354        let var_pct = (daily_var_pct * (horizon_days as f64).sqrt()).max(0.0);
355        let var_usd = var_pct * position_value;
356
357        VaRResult {
358            confidence,
359            horizon_days,
360            var_usd,
361            var_pct,
362            method: VarMethod::CornishFisher,
363        }
364    }
365}
366
367#[cfg(test)]
368mod tests {
369    use super::*;
370
371    fn sample_returns() -> Vec<f64> {
372        vec![
373            0.01, -0.02, 0.015, -0.03, 0.005, -0.01, 0.02, -0.025, 0.008, -0.015,
374            0.012, -0.018, 0.003, -0.04, 0.022, -0.011, 0.007, -0.009, 0.014, -0.035,
375        ]
376    }
377
378    #[test]
379    fn test_historical_var_basic() {
380        let returns = sample_returns();
381        let result = VaRCalculator::historical_var(&returns, 1_000_000.0, 0.95, 1);
382        assert!(result.var_usd > 0.0, "VaR should be positive");
383        assert!(result.var_pct > 0.0, "VaR pct should be positive");
384        assert!((result.confidence - 0.95).abs() < 1e-9);
385        assert_eq!(result.horizon_days, 1);
386    }
387
388    #[test]
389    fn test_historical_var_empty() {
390        let result = VaRCalculator::historical_var(&[], 1_000_000.0, 0.95, 1);
391        assert_eq!(result.var_usd, 0.0);
392    }
393
394    #[test]
395    fn test_historical_var_horizon_scaling() {
396        let returns = sample_returns();
397        let var_1 = VaRCalculator::historical_var(&returns, 1_000_000.0, 0.95, 1);
398        let var_10 = VaRCalculator::historical_var(&returns, 1_000_000.0, 0.95, 10);
399        let ratio = var_10.var_usd / var_1.var_usd;
400        assert!((ratio - 10.0_f64.sqrt()).abs() < 1e-6, "sqrt-of-time scaling, ratio={ratio}");
401    }
402
403    #[test]
404    fn test_parametric_var_95() {
405        // For 95% confidence, z ≈ 1.645; VaR ≈ z * sigma for mu=0.
406        let result = VaRCalculator::parametric_var(0.0, 0.01, 1_000_000.0, 0.95, 1);
407        // Expected: ~1.645% of 1M = ~$16,450
408        assert!(result.var_usd > 10_000.0 && result.var_usd < 25_000.0,
409            "var_usd={}", result.var_usd);
410    }
411
412    #[test]
413    fn test_parametric_var_99() {
414        let result99 = VaRCalculator::parametric_var(0.0, 0.01, 1_000_000.0, 0.99, 1);
415        let result95 = VaRCalculator::parametric_var(0.0, 0.01, 1_000_000.0, 0.95, 1);
416        assert!(result99.var_usd > result95.var_usd, "99% VaR should exceed 95% VaR");
417    }
418
419    #[test]
420    fn test_monte_carlo_var_reasonable() {
421        let result = VaRCalculator::monte_carlo_var(
422            0.0005, 0.015, 1_000_000.0, 0.95, 10, 10_000, 42,
423        );
424        assert!(result.var_usd > 0.0);
425        assert!(result.var_pct < 0.5, "VaR fraction should be < 50%");
426        assert!(matches!(result.method, VarMethod::MonteCarlo { n_simulations: 10_000, seed: 42 }));
427    }
428
429    #[test]
430    fn test_conditional_var_exceeds_var() {
431        let returns = sample_returns();
432        let cvar = VaRCalculator::conditional_var(&returns, 1_000_000.0, 0.95, 1);
433        assert!(cvar.cvar_usd >= cvar.var_result.var_usd,
434            "CVaR should be >= VaR: cvar={}, var={}", cvar.cvar_usd, cvar.var_result.var_usd);
435    }
436
437    #[test]
438    fn test_conditional_var_empty() {
439        let result = VaRCalculator::conditional_var(&[], 1_000_000.0, 0.95, 1);
440        assert_eq!(result.cvar_usd, 0.0);
441    }
442
443    #[test]
444    fn test_portfolio_var_uncorrelated() {
445        // Two uncorrelated assets each with VaR = $1000, portfolio VaR = sqrt(2) * 1000
446        let vars = vec![1000.0, 1000.0];
447        let corr = vec![
448            vec![1.0, 0.0],
449            vec![0.0, 1.0],
450        ];
451        let pvar = VaRCalculator::portfolio_var(&vars, &corr);
452        assert!((pvar - 2.0_f64.sqrt() * 1000.0).abs() < 1e-6, "pvar={pvar}");
453    }
454
455    #[test]
456    fn test_portfolio_var_perfectly_correlated() {
457        // Two perfectly correlated assets: portfolio VaR = sum of individual VaRs
458        let vars = vec![1000.0, 1000.0];
459        let corr = vec![
460            vec![1.0, 1.0],
461            vec![1.0, 1.0],
462        ];
463        let pvar = VaRCalculator::portfolio_var(&vars, &corr);
464        assert!((pvar - 2000.0).abs() < 1e-6, "pvar={pvar}");
465    }
466
467    #[test]
468    fn test_portfolio_var_empty() {
469        let pvar = VaRCalculator::portfolio_var(&[], &[]);
470        assert_eq!(pvar, 0.0);
471    }
472
473    #[test]
474    fn test_cornish_fisher_var() {
475        let returns = sample_returns();
476        let result = VaRCalculator::cornish_fisher_var(&returns, 1_000_000.0, 0.95, 1);
477        assert!(result.var_usd > 0.0);
478        assert!(matches!(result.method, VarMethod::CornishFisher));
479    }
480
481    #[test]
482    fn test_cornish_fisher_vs_parametric_with_tail_risk() {
483        // Negatively skewed returns → Cornish-Fisher should give higher VaR
484        let mut returns: Vec<f64> = (0..100).map(|i| 0.001 * (i as f64 - 50.0) / 50.0).collect();
485        // Add fat left tail
486        returns.extend_from_slice(&[-0.08, -0.09, -0.10, -0.07, -0.085]);
487        let cf_result = VaRCalculator::cornish_fisher_var(&returns, 1_000_000.0, 0.95, 1);
488        assert!(cf_result.var_usd > 0.0);
489        let mu = VaRCalculator::mean(&returns);
490        let sigma = VaRCalculator::std_dev(&returns);
491        let p_result = VaRCalculator::parametric_var(mu, sigma, 1_000_000.0, 0.95, 1);
492        assert!(
493            cf_result.var_usd > p_result.var_usd,
494            "negative skew + fat tails must raise CF VaR above normal: cf={} normal={}",
495            cf_result.var_usd,
496            p_result.var_usd
497        );
498    }
499
500    #[test]
501    fn test_probit_symmetry() {
502        // probit(0.95) should be approximately 1.645
503        let z = -VaRCalculator::probit(0.05);
504        assert!((z - 1.645).abs() < 0.01, "z={z}");
505    }
506}