Skip to main content

fin_primitives/risk/
var_engine.rs

1//! Value-at-Risk (VaR) calculation engine.
2//!
3//! Supports Historical, Parametric, and Monte Carlo methods with CVaR (Expected Shortfall).
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 using Cornish-Fisher z-scores.
11    Parametric,
12    /// Monte Carlo simulation using LCG + Box-Muller transform.
13    MonteCarlo {
14        /// Number of simulated paths.
15        simulations: usize,
16        /// Random seed for reproducibility.
17        seed: u64,
18    },
19}
20
21/// Result of a VaR calculation.
22#[derive(Debug, Clone)]
23pub struct VarResult {
24    /// Confidence level (e.g. 0.95 for 95%).
25    pub confidence_level: f64,
26    /// VaR expressed as an absolute dollar loss.
27    pub var_abs: f64,
28    /// VaR expressed as a fraction of portfolio value.
29    pub var_pct: f64,
30    /// Conditional VaR (Expected Shortfall) in absolute terms.
31    pub cvar_abs: f64,
32    /// Conditional VaR as a fraction of portfolio value.
33    pub cvar_pct: f64,
34    /// Method used for this calculation.
35    pub method: VarMethod,
36}
37
38/// Portfolio return series with position weights.
39#[derive(Debug, Clone)]
40pub struct PortfolioReturns {
41    /// Per-asset return series (each inner Vec is one asset's returns).
42    pub returns: Vec<f64>,
43    /// Weight for each asset (must match length of `returns`).
44    pub weights: Vec<f64>,
45    /// Total portfolio market value in dollars.
46    pub portfolio_value: f64,
47}
48
49impl PortfolioReturns {
50    /// Compute the weighted portfolio return for each observation.
51    ///
52    /// Returns a single `Vec<f64>` where each element is the dot product
53    /// of the weight vector and the corresponding per-asset return.
54    /// When `returns` and `weights` have the same length (flat single-asset
55    /// case), this is equivalent to `weight[i] * return[i]`.
56    pub fn portfolio_returns(&self) -> Vec<f64> {
57        self.returns
58            .iter()
59            .zip(self.weights.iter())
60            .map(|(r, w)| r * w)
61            .collect()
62    }
63
64    /// Arithmetic mean of the (scalar) return series.
65    pub fn mean(&self) -> f64 {
66        let pr = self.portfolio_returns();
67        if pr.is_empty() {
68            return 0.0;
69        }
70        pr.iter().sum::<f64>() / pr.len() as f64
71    }
72
73    /// Population standard deviation of the (scalar) return series.
74    pub fn std_dev(&self) -> f64 {
75        let pr = self.portfolio_returns();
76        if pr.len() < 2 {
77            return 0.0;
78        }
79        let mean = pr.iter().sum::<f64>() / pr.len() as f64;
80        let variance = pr.iter().map(|r| (r - mean).powi(2)).sum::<f64>() / pr.len() as f64;
81        variance.sqrt()
82    }
83}
84
85/// Stateless VaR calculation engine.
86pub struct VarEngine;
87
88impl VarEngine {
89    /// Historical VaR: sort returns, pick the loss at the (1-confidence) percentile.
90    pub fn historical_var(returns: &[f64], confidence: f64, portfolio_value: f64) -> VarResult {
91        if returns.is_empty() {
92            return VarResult {
93                confidence_level: confidence,
94                var_abs: 0.0,
95                var_pct: 0.0,
96                cvar_abs: 0.0,
97                cvar_pct: 0.0,
98                method: VarMethod::Historical,
99            };
100        }
101
102        let mut sorted = returns.to_vec();
103        sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
104
105        let n = sorted.len();
106        let var_idx = ((1.0 - confidence) * n as f64).floor() as usize;
107        let var_idx = var_idx.min(n - 1);
108
109        let var_pct = -sorted[var_idx].min(0.0);
110        let var_abs = var_pct * portfolio_value;
111
112        let cvar_pct = Self::cvar_from_var(&sorted, var_idx);
113        let cvar_abs = cvar_pct * portfolio_value;
114
115        VarResult {
116            confidence_level: confidence,
117            var_abs,
118            var_pct,
119            cvar_abs,
120            cvar_pct,
121            method: VarMethod::Historical,
122        }
123    }
124
125    /// Parametric VaR using Cornish-Fisher z-scores scaled by horizon.
126    ///
127    /// z values: 2.326 for 99%, 1.645 for 95%, 1.282 for 90%, linear interpolation otherwise.
128    pub fn parametric_var(
129        mean: f64,
130        std_dev: f64,
131        confidence: f64,
132        portfolio_value: f64,
133        horizon_days: u32,
134    ) -> VarResult {
135        let z = Self::cornish_fisher_z(confidence);
136        let horizon_scale = (horizon_days as f64).sqrt();
137        let var_pct = (z * std_dev - mean) * horizon_scale;
138        let var_pct = var_pct.max(0.0);
139        let var_abs = var_pct * portfolio_value;
140
141        // CVaR for normal: phi(z) / (1 - confidence) * sigma * sqrt(horizon)
142        let phi_z = Self::standard_normal_pdf(z);
143        let cvar_pct = if (1.0 - confidence).abs() < 1e-12 {
144            var_pct
145        } else {
146            (phi_z / (1.0 - confidence) * std_dev - mean) * horizon_scale
147        };
148        let cvar_pct = cvar_pct.max(var_pct);
149        let cvar_abs = cvar_pct * portfolio_value;
150
151        VarResult {
152            confidence_level: confidence,
153            var_abs,
154            var_pct,
155            cvar_abs,
156            cvar_pct,
157            method: VarMethod::Parametric,
158        }
159    }
160
161    /// Monte Carlo VaR using LCG random number generator and Box-Muller transform.
162    pub fn monte_carlo_var(
163        mean: f64,
164        std_dev: f64,
165        confidence: f64,
166        portfolio_value: f64,
167        simulations: usize,
168        seed: u64,
169    ) -> VarResult {
170        let mut sim_returns: Vec<f64> = Vec::with_capacity(simulations);
171        let mut state = seed;
172
173        let mut i = 0;
174        while i < simulations {
175            // LCG: constants from Numerical Recipes
176            state = state
177                .wrapping_mul(1_664_525)
178                .wrapping_add(1_013_904_223);
179            let u1 = (state as f64 + 0.5) / u64::MAX as f64;
180            state = state
181                .wrapping_mul(1_664_525)
182                .wrapping_add(1_013_904_223);
183            let u2 = (state as f64 + 0.5) / u64::MAX as f64;
184
185            // Box-Muller transform
186            let u1 = u1.clamp(1e-15, 1.0 - 1e-15);
187            let u2 = u2.clamp(1e-15, 1.0 - 1e-15);
188            let z0 = (-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos();
189            let z1 = (-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).sin();
190
191            sim_returns.push(mean + std_dev * z0);
192            i += 1;
193            if i < simulations {
194                sim_returns.push(mean + std_dev * z1);
195                i += 1;
196            }
197        }
198
199        sim_returns.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
200
201        let n = sim_returns.len();
202        let var_idx = ((1.0 - confidence) * n as f64).floor() as usize;
203        let var_idx = var_idx.min(n - 1);
204
205        let var_pct = -sim_returns[var_idx].min(0.0);
206        let var_abs = var_pct * portfolio_value;
207
208        let cvar_pct = Self::cvar_from_var(&sim_returns, var_idx);
209        let cvar_abs = cvar_pct * portfolio_value;
210
211        VarResult {
212            confidence_level: confidence,
213            var_abs,
214            var_pct,
215            cvar_abs,
216            cvar_pct,
217            method: VarMethod::MonteCarlo { simulations, seed },
218        }
219    }
220
221    /// Compute CVaR as the mean loss of the tail beyond the VaR index.
222    ///
223    /// `sorted_returns` must be sorted ascending. Returns the average negated
224    /// return below index `var_idx` (exclusive), which is the expected shortfall.
225    pub fn cvar_from_var(sorted_returns: &[f64], var_idx: usize) -> f64 {
226        if var_idx == 0 {
227            return -sorted_returns[0].min(0.0);
228        }
229        let tail = &sorted_returns[..var_idx];
230        if tail.is_empty() {
231            return 0.0;
232        }
233        let mean_tail: f64 = tail.iter().sum::<f64>() / tail.len() as f64;
234        -mean_tail.min(0.0)
235    }
236
237    /// Rolling window VaR: slide a window of size `window` over `returns`.
238    pub fn rolling_var(
239        returns: &[f64],
240        window: usize,
241        confidence: f64,
242        portfolio_value: f64,
243    ) -> Vec<VarResult> {
244        if window == 0 || returns.len() < window {
245            return Vec::new();
246        }
247        returns
248            .windows(window)
249            .map(|w| Self::historical_var(w, confidence, portfolio_value))
250            .collect()
251    }
252
253    // ── Private helpers ────────────────────────────────────────────────────────
254
255    /// Cornish-Fisher z-score approximation for common confidence levels.
256    fn cornish_fisher_z(confidence: f64) -> f64 {
257        if confidence >= 0.99 {
258            2.326
259        } else if confidence >= 0.95 {
260            1.645
261        } else if confidence >= 0.90 {
262            1.282
263        } else {
264            // Linear interpolation for other levels
265            let alpha = 1.0 - confidence;
266            // Rough approximation: z ≈ -ln(alpha * sqrt(2*pi)) for small alpha
267            (-2.0 * (alpha * (2.0 * std::f64::consts::PI).sqrt()).ln()).sqrt()
268        }
269    }
270
271    /// Standard normal PDF at x: (1/sqrt(2π)) * exp(-x²/2).
272    fn standard_normal_pdf(x: f64) -> f64 {
273        (-0.5 * x * x).exp() / (2.0 * std::f64::consts::PI).sqrt()
274    }
275}
276
277#[cfg(test)]
278mod tests {
279    use super::*;
280
281    #[test]
282    fn historical_var_basic() {
283        let returns: Vec<f64> = (-50i32..=50).map(|x| x as f64 / 1000.0).collect();
284        let result = VarEngine::historical_var(&returns, 0.95, 100_000.0);
285        assert!(result.var_abs > 0.0);
286        assert!(result.cvar_abs >= result.var_abs);
287        assert_eq!(result.confidence_level, 0.95);
288    }
289
290    #[test]
291    fn parametric_var_99() {
292        let result = VarEngine::parametric_var(0.0, 0.01, 0.99, 1_000_000.0, 1);
293        // VaR at 99% with 1% daily vol on $1M should be ~$23,260
294        assert!((result.var_abs - 23_260.0).abs() < 1000.0, "var_abs={}", result.var_abs);
295    }
296
297    #[test]
298    fn monte_carlo_var_reasonable() {
299        let result = VarEngine::monte_carlo_var(0.0, 0.01, 0.95, 1_000_000.0, 10_000, 42);
300        assert!(result.var_abs > 10_000.0);
301        assert!(result.var_abs < 30_000.0);
302    }
303
304    #[test]
305    fn rolling_var_length() {
306        let returns: Vec<f64> = (0..100).map(|i| i as f64 * 0.001 - 0.05).collect();
307        let rolling = VarEngine::rolling_var(&returns, 20, 0.95, 100_000.0);
308        assert_eq!(rolling.len(), 81);
309    }
310
311    #[test]
312    fn portfolio_returns_weighted() {
313        let pr = PortfolioReturns {
314            returns: vec![0.01, 0.02, -0.01],
315            weights: vec![0.5, 0.3, 0.2],
316            portfolio_value: 100_000.0,
317        };
318        let r = pr.portfolio_returns();
319        assert!((r[0] - 0.005).abs() < 1e-10);
320        assert!((r[1] - 0.006).abs() < 1e-10);
321        assert!((r[2] - (-0.002)).abs() < 1e-10);
322    }
323}