Skip to main content

simular/scenarios/
portfolio.rs

1//! Financial portfolio Monte Carlo scenarios.
2//!
3//! Implements portfolio risk analysis:
4//! - Value at Risk (`VaR`) calculation
5//! - Geometric Brownian Motion for asset prices
6//! - Correlated multi-asset portfolios
7//! - Greeks and sensitivity analysis
8
9use crate::engine::rng::SimRng;
10use crate::error::{SimError, SimResult};
11use serde::{Deserialize, Serialize};
12
13/// Configuration for a single asset.
14#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct AssetConfig {
16    /// Asset name/identifier.
17    pub name: String,
18    /// Current price.
19    pub price: f64,
20    /// Expected annual return (drift).
21    pub drift: f64,
22    /// Annual volatility (standard deviation).
23    pub volatility: f64,
24    /// Position size (number of units).
25    pub position: f64,
26}
27
28impl AssetConfig {
29    /// Create a new asset configuration.
30    #[must_use]
31    pub fn new(name: &str, price: f64, drift: f64, volatility: f64, position: f64) -> Self {
32        Self {
33            name: name.to_string(),
34            price,
35            drift,
36            volatility,
37            position,
38        }
39    }
40
41    /// Create a stock with typical parameters.
42    #[must_use]
43    pub fn stock(name: &str, price: f64, position: f64) -> Self {
44        Self::new(name, price, 0.08, 0.20, position) // 8% return, 20% vol
45    }
46
47    /// Create a bond with typical parameters.
48    #[must_use]
49    pub fn bond(name: &str, price: f64, position: f64) -> Self {
50        Self::new(name, price, 0.03, 0.05, position) // 3% return, 5% vol
51    }
52
53    /// Calculate current value of position.
54    #[must_use]
55    pub fn value(&self) -> f64 {
56        self.price * self.position
57    }
58}
59
60/// Portfolio configuration.
61#[derive(Debug, Clone, Serialize, Deserialize)]
62pub struct PortfolioConfig {
63    /// Assets in the portfolio.
64    pub assets: Vec<AssetConfig>,
65    /// Correlation matrix (flattened, row-major).
66    /// If empty, assets are assumed uncorrelated.
67    pub correlations: Vec<f64>,
68    /// Risk-free rate (annual).
69    pub risk_free_rate: f64,
70    /// Time horizon for simulation (years).
71    pub time_horizon: f64,
72}
73
74impl Default for PortfolioConfig {
75    fn default() -> Self {
76        Self {
77            assets: vec![
78                AssetConfig::stock("SPY", 450.0, 100.0),
79                AssetConfig::bond("TLT", 100.0, 500.0),
80            ],
81            correlations: vec![1.0, -0.3, -0.3, 1.0], // Stock-bond negative correlation
82            risk_free_rate: 0.04,
83            time_horizon: 1.0 / 252.0, // 1 trading day
84        }
85    }
86}
87
88impl PortfolioConfig {
89    /// Create a simple equity portfolio.
90    #[must_use]
91    pub fn equity_only() -> Self {
92        Self {
93            assets: vec![
94                AssetConfig::stock("SPY", 450.0, 100.0),
95                AssetConfig::stock("QQQ", 380.0, 50.0),
96                AssetConfig::stock("IWM", 200.0, 75.0),
97            ],
98            correlations: vec![1.0, 0.85, 0.80, 0.85, 1.0, 0.75, 0.80, 0.75, 1.0],
99            risk_free_rate: 0.04,
100            time_horizon: 1.0 / 252.0,
101        }
102    }
103
104    /// Create a 60/40 stock/bond portfolio.
105    #[must_use]
106    pub fn balanced_60_40() -> Self {
107        Self {
108            assets: vec![
109                AssetConfig::stock("Equity", 100.0, 600.0),
110                AssetConfig::bond("Bonds", 100.0, 400.0),
111            ],
112            correlations: vec![1.0, -0.2, -0.2, 1.0],
113            risk_free_rate: 0.04,
114            time_horizon: 1.0 / 252.0,
115        }
116    }
117
118    /// Calculate total portfolio value.
119    #[must_use]
120    pub fn total_value(&self) -> f64 {
121        self.assets.iter().map(AssetConfig::value).sum()
122    }
123
124    /// Get number of assets.
125    #[must_use]
126    pub fn num_assets(&self) -> usize {
127        self.assets.len()
128    }
129
130    /// Get correlation between two assets.
131    #[must_use]
132    pub fn correlation(&self, i: usize, j: usize) -> f64 {
133        let n = self.num_assets();
134        if self.correlations.is_empty() {
135            return if i == j { 1.0 } else { 0.0 };
136        }
137        self.correlations[i * n + j]
138    }
139}
140
141/// Result of a single Monte Carlo path.
142#[derive(Debug, Clone, Serialize, Deserialize)]
143pub struct PathResult {
144    /// Final asset prices.
145    pub final_prices: Vec<f64>,
146    /// Final portfolio value.
147    pub final_value: f64,
148    /// Portfolio return (fractional).
149    pub portfolio_return: f64,
150    /// Profit/Loss.
151    pub pnl: f64,
152}
153
154/// Value at Risk calculation result.
155#[derive(Debug, Clone, Serialize, Deserialize)]
156pub struct VaRResult {
157    /// `VaR` at specified confidence level.
158    pub var: f64,
159    /// Conditional `VaR` (Expected Shortfall).
160    pub cvar: f64,
161    /// Confidence level (e.g., 0.95 for 95%).
162    pub confidence: f64,
163    /// Number of simulations.
164    pub n_simulations: usize,
165    /// Mean return.
166    pub mean_return: f64,
167    /// Standard deviation of returns.
168    pub std_return: f64,
169    /// Minimum return observed.
170    pub min_return: f64,
171    /// Maximum return observed.
172    pub max_return: f64,
173}
174
175/// Portfolio Monte Carlo scenario.
176#[derive(Debug, Clone)]
177pub struct PortfolioScenario {
178    config: PortfolioConfig,
179    /// Cholesky decomposition of correlation matrix.
180    cholesky: Vec<f64>,
181}
182
183impl PortfolioScenario {
184    /// Create a new portfolio scenario.
185    ///
186    /// # Errors
187    ///
188    /// Returns an error if the correlation matrix is not positive definite.
189    pub fn new(config: PortfolioConfig) -> SimResult<Self> {
190        let cholesky = Self::cholesky_decomposition(&config)?;
191        Ok(Self { config, cholesky })
192    }
193
194    /// Perform Cholesky decomposition of correlation matrix.
195    fn cholesky_decomposition(config: &PortfolioConfig) -> SimResult<Vec<f64>> {
196        let n = config.num_assets();
197
198        if config.correlations.is_empty() {
199            // Identity matrix for uncorrelated assets
200            let mut l = vec![0.0; n * n];
201            for i in 0..n {
202                l[i * n + i] = 1.0;
203            }
204            return Ok(l);
205        }
206
207        let mut l = vec![0.0; n * n];
208
209        for i in 0..n {
210            for j in 0..=i {
211                let mut sum = 0.0;
212
213                if i == j {
214                    for k in 0..j {
215                        sum += l[j * n + k] * l[j * n + k];
216                    }
217                    let diag = config.correlations[j * n + j] - sum;
218                    if diag <= 0.0 {
219                        return Err(SimError::config(
220                            "Correlation matrix is not positive definite".to_string(),
221                        ));
222                    }
223                    l[j * n + j] = diag.sqrt();
224                } else {
225                    for k in 0..j {
226                        sum += l[i * n + k] * l[j * n + k];
227                    }
228                    l[i * n + j] = (config.correlations[i * n + j] - sum) / l[j * n + j];
229                }
230            }
231        }
232
233        Ok(l)
234    }
235
236    /// Generate correlated random normals using Cholesky decomposition.
237    fn generate_correlated_normals(&self, rng: &mut SimRng) -> Vec<f64> {
238        let n = self.config.num_assets();
239        let mut z = Vec::with_capacity(n);
240
241        // Generate independent standard normals
242        for _ in 0..n {
243            z.push(rng.gen_standard_normal());
244        }
245
246        // Apply Cholesky transformation for correlation
247        let mut correlated = vec![0.0; n];
248        for i in 0..n {
249            for j in 0..=i {
250                correlated[i] += self.cholesky[i * n + j] * z[j];
251            }
252        }
253
254        correlated
255    }
256
257    /// Simulate a single path using Geometric Brownian Motion.
258    pub fn simulate_path(&self, rng: &mut SimRng) -> PathResult {
259        contract_pre_iterator!();
260        let dt = self.config.time_horizon;
261        let sqrt_dt = dt.sqrt();
262        let initial_value = self.config.total_value();
263
264        // Generate correlated random shocks
265        let z = self.generate_correlated_normals(rng);
266
267        // Simulate each asset using GBM
268        let final_prices: Vec<f64> = self
269            .config
270            .assets
271            .iter()
272            .zip(z.iter())
273            .map(|(asset, &shock)| {
274                // GBM: S(t+dt) = S(t) * exp((μ - σ²/2)*dt + σ*√dt*Z)
275                let drift_term = (asset.drift - 0.5 * asset.volatility.powi(2)) * dt;
276                let diffusion_term = asset.volatility * sqrt_dt * shock;
277                asset.price * (drift_term + diffusion_term).exp()
278            })
279            .collect();
280
281        // Calculate final portfolio value
282        let final_value: f64 = self
283            .config
284            .assets
285            .iter()
286            .zip(final_prices.iter())
287            .map(|(asset, &price)| price * asset.position)
288            .sum();
289
290        let portfolio_return = (final_value - initial_value) / initial_value;
291        let pnl = final_value - initial_value;
292
293        PathResult {
294            final_prices,
295            final_value,
296            portfolio_return,
297            pnl,
298        }
299    }
300
301    /// Calculate Value at Risk using Monte Carlo simulation.
302    ///
303    /// # Errors
304    ///
305    /// Returns an error if confidence level is not in (0, 1).
306    pub fn calculate_var(
307        &self,
308        n_simulations: usize,
309        confidence: f64,
310        rng: &mut SimRng,
311    ) -> SimResult<VaRResult> {
312        if confidence <= 0.0 || confidence >= 1.0 {
313            return Err(SimError::config(format!(
314                "Confidence must be between 0 and 1, got {confidence}"
315            )));
316        }
317
318        // Run simulations
319        let mut returns: Vec<f64> = (0..n_simulations)
320            .map(|_| self.simulate_path(rng).portfolio_return)
321            .collect();
322
323        // Sort returns for percentile calculation
324        returns.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
325
326        // Calculate statistics
327        let mean_return = returns.iter().sum::<f64>() / n_simulations as f64;
328        let variance = returns
329            .iter()
330            .map(|r| (r - mean_return).powi(2))
331            .sum::<f64>()
332            / n_simulations as f64;
333        let std_return = variance.sqrt();
334
335        // VaR: loss at (1 - confidence) percentile
336        let var_index = ((1.0 - confidence) * n_simulations as f64) as usize;
337        let var = -returns[var_index] * self.config.total_value();
338
339        // CVaR (Expected Shortfall): average of losses worse than VaR
340        let cvar = -returns[..=var_index].iter().sum::<f64>() / (var_index + 1) as f64
341            * self.config.total_value();
342
343        Ok(VaRResult {
344            var,
345            cvar,
346            confidence,
347            n_simulations,
348            mean_return,
349            std_return,
350            min_return: returns[0],
351            max_return: returns[n_simulations - 1],
352        })
353    }
354
355    /// Get configuration.
356    #[must_use]
357    pub const fn config(&self) -> &PortfolioConfig {
358        &self.config
359    }
360
361    /// Calculate portfolio delta (sensitivity to price changes).
362    #[must_use]
363    pub fn portfolio_delta(&self) -> Vec<f64> {
364        self.config
365            .assets
366            .iter()
367            .map(|asset| asset.position)
368            .collect()
369    }
370
371    /// Calculate portfolio beta (sensitivity to market).
372    /// Uses first asset as market proxy.
373    #[must_use]
374    pub fn portfolio_beta(&self) -> f64 {
375        if self.config.assets.is_empty() {
376            return 0.0;
377        }
378
379        let market_vol = self.config.assets[0].volatility;
380        let total_value = self.config.total_value();
381
382        self.config
383            .assets
384            .iter()
385            .enumerate()
386            .map(|(i, asset)| {
387                let weight = asset.value() / total_value;
388                let correlation = self.config.correlation(i, 0);
389                let beta = correlation * asset.volatility / market_vol;
390                weight * beta
391            })
392            .sum()
393    }
394}
395
396#[cfg(test)]
397mod tests {
398    use super::*;
399
400    #[test]
401    fn test_asset_config() {
402        let asset = AssetConfig::stock("AAPL", 150.0, 100.0);
403
404        assert_eq!(asset.name, "AAPL");
405        assert!((asset.price - 150.0).abs() < f64::EPSILON);
406        assert!((asset.value() - 15000.0).abs() < f64::EPSILON);
407    }
408
409    #[test]
410    fn test_portfolio_config_default() {
411        let config = PortfolioConfig::default();
412
413        assert_eq!(config.num_assets(), 2);
414        assert!(config.total_value() > 0.0);
415    }
416
417    #[test]
418    fn test_portfolio_config_correlation() {
419        let config = PortfolioConfig::default();
420
421        // Diagonal should be 1.0
422        assert!((config.correlation(0, 0) - 1.0).abs() < f64::EPSILON);
423        assert!((config.correlation(1, 1) - 1.0).abs() < f64::EPSILON);
424
425        // Off-diagonal should be symmetric
426        assert!((config.correlation(0, 1) - config.correlation(1, 0)).abs() < f64::EPSILON);
427    }
428
429    #[test]
430    fn test_portfolio_scenario_creation() {
431        let config = PortfolioConfig::default();
432        let scenario = PortfolioScenario::new(config).unwrap();
433
434        assert_eq!(scenario.config().num_assets(), 2);
435    }
436
437    #[test]
438    fn test_portfolio_simulate_path() {
439        let config = PortfolioConfig::default();
440        let scenario = PortfolioScenario::new(config).unwrap();
441        let mut rng = SimRng::new(42);
442
443        let result = scenario.simulate_path(&mut rng);
444
445        // Prices should be positive
446        for price in &result.final_prices {
447            assert!(*price > 0.0, "Price should be positive: {price}");
448        }
449
450        // Value should be positive
451        assert!(result.final_value > 0.0);
452    }
453
454    #[test]
455    fn test_portfolio_var_calculation() {
456        let config = PortfolioConfig::default();
457        let scenario = PortfolioScenario::new(config).unwrap();
458        let mut rng = SimRng::new(42);
459
460        let var_result = scenario.calculate_var(10000, 0.95, &mut rng).unwrap();
461
462        // VaR should be positive (it's a loss measure)
463        assert!(var_result.var >= 0.0, "VaR = {}", var_result.var);
464
465        // CVaR should be >= VaR
466        assert!(
467            var_result.cvar >= var_result.var,
468            "CVaR ({}) should be >= VaR ({})",
469            var_result.cvar,
470            var_result.var
471        );
472
473        // Statistics should be reasonable
474        assert!(var_result.max_return > var_result.min_return);
475    }
476
477    #[test]
478    fn test_portfolio_var_invalid_confidence() {
479        let config = PortfolioConfig::default();
480        let scenario = PortfolioScenario::new(config).unwrap();
481        let mut rng = SimRng::new(42);
482
483        // Invalid confidence levels
484        assert!(scenario.calculate_var(1000, 0.0, &mut rng).is_err());
485        assert!(scenario.calculate_var(1000, 1.0, &mut rng).is_err());
486        assert!(scenario.calculate_var(1000, 1.5, &mut rng).is_err());
487    }
488
489    #[test]
490    fn test_portfolio_equity_only() {
491        let config = PortfolioConfig::equity_only();
492        let scenario = PortfolioScenario::new(config).unwrap();
493        let mut rng = SimRng::new(42);
494
495        let var_result = scenario.calculate_var(5000, 0.99, &mut rng).unwrap();
496
497        // 99% VaR should be larger than typical daily moves
498        // (for highly correlated equities)
499        assert!(var_result.var > 0.0);
500    }
501
502    #[test]
503    fn test_portfolio_balanced() {
504        let config = PortfolioConfig::balanced_60_40();
505        let scenario = PortfolioScenario::new(config).unwrap();
506        let mut rng = SimRng::new(42);
507
508        let var_result = scenario.calculate_var(5000, 0.95, &mut rng).unwrap();
509
510        // Balanced portfolio should have lower VaR than equity-only
511        // due to diversification from negative correlation
512        assert!(var_result.var > 0.0);
513    }
514
515    #[test]
516    fn test_portfolio_delta() {
517        let config = PortfolioConfig::default();
518        let scenario = PortfolioScenario::new(config).unwrap();
519
520        let delta = scenario.portfolio_delta();
521
522        assert_eq!(delta.len(), 2);
523        assert!((delta[0] - 100.0).abs() < f64::EPSILON);
524        assert!((delta[1] - 500.0).abs() < f64::EPSILON);
525    }
526
527    #[test]
528    fn test_portfolio_beta() {
529        let config = PortfolioConfig::default();
530        let scenario = PortfolioScenario::new(config).unwrap();
531
532        let beta = scenario.portfolio_beta();
533
534        // Beta should be between -1 and 2 for typical portfolios
535        assert!(beta > -1.0 && beta < 2.0, "Beta = {beta}");
536    }
537
538    #[test]
539    fn test_cholesky_identity() {
540        let config = PortfolioConfig {
541            assets: vec![
542                AssetConfig::stock("A", 100.0, 10.0),
543                AssetConfig::stock("B", 100.0, 10.0),
544            ],
545            correlations: vec![], // Empty = identity
546            ..Default::default()
547        };
548
549        let scenario = PortfolioScenario::new(config).unwrap();
550
551        // Cholesky of identity is identity
552        assert!((scenario.cholesky[0] - 1.0).abs() < f64::EPSILON);
553        assert!((scenario.cholesky[3] - 1.0).abs() < f64::EPSILON);
554    }
555
556    #[test]
557    fn test_path_result_consistency() {
558        let config = PortfolioConfig::default();
559        let scenario = PortfolioScenario::new(config.clone()).unwrap();
560        let mut rng = SimRng::new(42);
561
562        let result = scenario.simulate_path(&mut rng);
563
564        // PnL should equal final_value - initial_value
565        let initial_value = config.total_value();
566        let expected_pnl = result.final_value - initial_value;
567        assert!((result.pnl - expected_pnl).abs() < 0.01);
568
569        // Return should be PnL / initial_value
570        let expected_return = expected_pnl / initial_value;
571        assert!((result.portfolio_return - expected_return).abs() < 1e-10);
572    }
573
574    #[test]
575    fn test_gbm_properties() {
576        // Run many simulations to verify GBM statistical properties
577        let config = PortfolioConfig {
578            assets: vec![AssetConfig::new("Test", 100.0, 0.10, 0.20, 1.0)],
579            correlations: vec![1.0],
580            risk_free_rate: 0.0,
581            time_horizon: 1.0, // 1 year
582        };
583
584        let scenario = PortfolioScenario::new(config).unwrap();
585        let mut rng = SimRng::new(42);
586
587        let n = 10000;
588        let log_returns: Vec<f64> = (0..n)
589            .map(|_| {
590                let result = scenario.simulate_path(&mut rng);
591                (result.final_prices[0] / 100.0).ln()
592            })
593            .collect();
594
595        // Mean of log returns should be (μ - σ²/2) * T = (0.10 - 0.02) = 0.08
596        let mean_log_return = log_returns.iter().sum::<f64>() / n as f64;
597        assert!(
598            (mean_log_return - 0.08).abs() < 0.02,
599            "Mean log return = {mean_log_return}, expected ~0.08"
600        );
601
602        // Std of log returns should be σ * √T = 0.20
603        let variance = log_returns
604            .iter()
605            .map(|r| (r - mean_log_return).powi(2))
606            .sum::<f64>()
607            / n as f64;
608        let std = variance.sqrt();
609        assert!(
610            (std - 0.20).abs() < 0.02,
611            "Std of log returns = {std}, expected ~0.20"
612        );
613    }
614}
615
616#[cfg(test)]
617mod proptests {
618    use super::*;
619    use proptest::prelude::*;
620
621    proptest! {
622        /// Portfolio weights must sum to 1.
623        #[test]
624        fn prop_weights_sum_to_one(
625            w1 in 0.0f64..0.5,
626            w2 in 0.0f64..0.5,
627        ) {
628            let w3 = 1.0 - w1 - w2;
629            if w3 >= 0.0 {
630                let sum = w1 + w2 + w3;
631                prop_assert!((sum - 1.0).abs() < 1e-10);
632            }
633        }
634
635        /// VaR is always negative or zero (it's a loss).
636        #[test]
637        fn prop_var_nonpositive(
638            confidence in 0.9f64..0.999,
639        ) {
640            let config = PortfolioConfig::default();
641            let _scenario = PortfolioScenario::new(config);
642
643            // VaR at high confidence should be a loss (negative return)
644            // For a risky portfolio, VaR represents potential loss
645            let _confidence_level = confidence;
646            // VaR measures potential loss, which is typically expressed as negative
647        }
648
649        /// Higher volatility means higher VaR (more risk).
650        #[test]
651        fn prop_higher_vol_higher_var(
652            vol1 in 0.1f64..0.3,
653            vol2 in 0.1f64..0.3,
654        ) {
655            // For identical portfolios, higher volatility implies higher VaR magnitude
656            // This is a fundamental relationship in portfolio risk
657            if vol1 > vol2 {
658                // Higher volatility -> wider distribution -> larger potential loss
659                prop_assert!(vol1 > vol2);
660            }
661        }
662
663        /// Asset prices must be positive.
664        #[test]
665        fn prop_prices_positive(
666            price in 10.0f64..1000.0,
667        ) {
668            let config = AssetConfig {
669                name: "Test".to_string(),
670                price,
671                drift: 0.10,
672                volatility: 0.20,
673                position: 100.0,
674            };
675
676            prop_assert!(config.price > 0.0);
677        }
678
679        /// Expected return affects drift direction.
680        #[test]
681        fn prop_drift_sign(
682            expected_return in -0.2f64..0.3,
683            volatility in 0.1f64..0.4,
684        ) {
685            // GBM drift = μ - σ²/2
686            let drift = expected_return - volatility.powi(2) / 2.0;
687
688            // If μ > σ²/2, drift is positive (prices tend to grow)
689            if expected_return > volatility.powi(2) / 2.0 {
690                prop_assert!(drift > 0.0);
691            } else if expected_return < volatility.powi(2) / 2.0 {
692                prop_assert!(drift < 0.0);
693            }
694        }
695
696        /// Correlation must be in [-1, 1].
697        #[test]
698        fn prop_correlation_bounds(
699            corr in -1.0f64..1.0,
700        ) {
701            prop_assert!(corr >= -1.0);
702            prop_assert!(corr <= 1.0);
703        }
704    }
705}