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