//! Stress testing for portfolio risk analysis //! //! Simulates extreme market scenarios and their impact on portfolios use crate::error::{Result, RiskAnalyzerError}; use risk_analyzer_shared::{RiskAsset, StressScenario, StressTestResult}; /// Apply a stress scenario to a portfolio /// /// # Arguments /// * `assets` - Portfolio assets with weights and historical returns /// * `scenario` - Stress scenario to apply /// * `var_threshold` - `VaR` threshold to check for breach pub fn apply_stress_scenario( assets: &[RiskAsset], scenario: &StressScenario, var_threshold: f64, ) -> Result { if assets.is_empty() { return Err(RiskAnalyzerError::InsufficientData( "No assets provided".to_string(), )); } let weight_sum: f64 = assets.iter().map(|a| a.weight).sum(); if (weight_sum - 1.0).abs() > 0.01 { return Err(RiskAnalyzerError::InvalidWeights(format!( "Weights sum to {weight_sum}, expected 1.0" ))); } let mut asset_losses = Vec::with_capacity(assets.len()); for asset in assets { if asset.returns.is_empty() { return Err(RiskAnalyzerError::InsufficientData(format!( "Asset {} has no returns", asset.ticker ))); } let mean_return = asset.returns.iter().sum::() / asset.returns.len() as f64; let variance = asset .returns .iter() .map(|r| (r - mean_return).powi(2)) .sum::() / asset.returns.len() as f64; let volatility = variance.sqrt(); let stressed_volatility = volatility * scenario.volatility_spike; let shocked_return = scenario.market_shock + mean_return; let asset_loss = -shocked_return * stressed_volatility.max(1.0); asset_losses.push((asset.ticker.clone(), asset.weight * asset_loss)); } let total_loss: f64 = asset_losses.iter().map(|(_, loss)| loss).sum(); let (worst_ticker, worst_asset_loss) = asset_losses .iter() .max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)) .map(|(ticker, loss)| (ticker.clone(), *loss)) .ok_or_else(|| { RiskAnalyzerError::CalculationError("Failed to find worst asset".to_string()) })?; let (best_ticker, best_asset_return) = asset_losses .iter() .min_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)) .map(|(ticker, loss)| (ticker.clone(), -loss)) .ok_or_else(|| { RiskAnalyzerError::CalculationError("Failed to find best asset".to_string()) })?; let var_breach = total_loss > var_threshold; Ok(StressTestResult { scenario: scenario.name.clone(), portfolio_loss: total_loss, portfolio_loss_pct: total_loss, var_breach, worst_asset: worst_ticker, worst_asset_loss, best_asset: best_ticker, best_asset_return, }) } /// Run multiple stress tests on a portfolio pub fn run_stress_tests( assets: &[RiskAsset], scenarios: &[StressScenario], var_threshold: f64, ) -> Result> { if assets.is_empty() { return Err(RiskAnalyzerError::InsufficientData( "No assets provided".to_string(), )); } if scenarios.is_empty() { return Err(RiskAnalyzerError::InvalidConfig( "No scenarios provided".to_string(), )); } let mut results = Vec::with_capacity(scenarios.len()); for scenario in scenarios { let result = apply_stress_scenario(assets, scenario, var_threshold)?; results.push(result); } Ok(results) } /// Run predefined stress test scenarios pub fn run_predefined_stress_tests( assets: &[RiskAsset], var_threshold: f64, ) -> Result> { let scenarios = StressScenario::all_predefined(); run_stress_tests(assets, &scenarios, var_threshold) } #[cfg(test)] mod tests { use super::*; fn create_test_asset(ticker: &str, weight: f64, mean_return: f64) -> RiskAsset { let returns = vec![ mean_return, mean_return * 0.8, mean_return * 1.2, mean_return * 0.9, mean_return * 1.1, ]; RiskAsset { ticker: ticker.to_string(), name: format!("{ticker} Test"), weight, returns, } } #[test] fn test_apply_stress_scenario_empty_assets() { let scenario = StressScenario::financial_crisis_2008(); let result = apply_stress_scenario(&[], &scenario, 0.05); assert!(result.is_err()); } #[test] fn test_apply_stress_scenario_invalid_weights() { let assets = vec![ create_test_asset("AAPL", 0.5, 0.001), create_test_asset("GOOGL", 0.3, 0.0015), ]; let scenario = StressScenario::financial_crisis_2008(); let result = apply_stress_scenario(&assets, &scenario, 0.05); assert!(result.is_err()); } #[test] fn test_apply_stress_scenario_no_returns() { let asset = RiskAsset { ticker: "TEST".to_string(), name: "Test Asset".to_string(), weight: 1.0, returns: vec![], }; let scenario = StressScenario::financial_crisis_2008(); let result = apply_stress_scenario(&[asset], &scenario, 0.05); assert!(result.is_err()); } #[test] fn test_apply_stress_scenario_financial_crisis() { let assets = vec![ create_test_asset("AAPL", 0.5, 0.001), create_test_asset("GOOGL", 0.5, 0.0015), ]; let scenario = StressScenario::financial_crisis_2008(); let result = apply_stress_scenario(&assets, &scenario, 0.05).unwrap(); assert_eq!(result.scenario, "2008 Financial Crisis"); assert!(result.portfolio_loss > 0.0); assert!(!result.worst_asset.is_empty()); assert!(!result.best_asset.is_empty()); } #[test] fn test_apply_stress_scenario_covid() { let assets = vec![ create_test_asset("SPY", 0.6, 0.0008), create_test_asset("TLT", 0.4, 0.0002), ]; let scenario = StressScenario::covid_march_2020(); let result = apply_stress_scenario(&assets, &scenario, 0.10).unwrap(); assert_eq!(result.scenario, "COVID-19 March 2020"); assert!(result.portfolio_loss >= 0.0); } #[test] fn test_apply_stress_scenario_var_breach() { let assets = vec![create_test_asset("AAPL", 1.0, 0.002)]; let scenario = StressScenario::financial_crisis_2008(); let small_threshold = 0.01; let result = apply_stress_scenario(&assets, &scenario, small_threshold).unwrap(); assert!(result.portfolio_loss > 0.0); } #[test] fn test_run_stress_tests_empty_assets() { let scenarios = StressScenario::all_predefined(); let result = run_stress_tests(&[], &scenarios, 0.05); assert!(result.is_err()); } #[test] fn test_run_stress_tests_empty_scenarios() { let assets = vec![create_test_asset("AAPL", 1.0, 0.001)]; let result = run_stress_tests(&assets, &[], 0.05); assert!(result.is_err()); } #[test] fn test_run_stress_tests_multiple_scenarios() { let assets = vec![ create_test_asset("AAPL", 0.5, 0.001), create_test_asset("MSFT", 0.5, 0.0012), ]; let scenarios = vec![ StressScenario::financial_crisis_2008(), StressScenario::covid_march_2020(), ]; let results = run_stress_tests(&assets, &scenarios, 0.10).unwrap(); assert_eq!(results.len(), 2); assert_eq!(results[0].scenario, "2008 Financial Crisis"); assert_eq!(results[1].scenario, "COVID-19 March 2020"); } #[test] fn test_run_predefined_stress_tests() { let assets = vec![ create_test_asset("AAPL", 0.4, 0.001), create_test_asset("GOOGL", 0.3, 0.0015), create_test_asset("MSFT", 0.3, 0.0012), ]; let results = run_predefined_stress_tests(&assets, 0.10).unwrap(); assert_eq!(results.len(), 4); assert!(results.iter().any(|r| r.scenario.contains("2008"))); assert!(results.iter().any(|r| r.scenario.contains("COVID"))); assert!(results.iter().any(|r| r.scenario.contains("Dot-com"))); assert!(results.iter().any(|r| r.scenario.contains("Black Monday"))); } #[test] fn test_stress_test_result_completeness() { let assets = vec![create_test_asset("SPY", 1.0, 0.0008)]; let scenario = StressScenario::dotcom_crash_2000(); let result = apply_stress_scenario(&assets, &scenario, 0.15).unwrap(); assert!(!result.scenario.is_empty()); assert!(result.portfolio_loss.is_finite()); assert!(result.portfolio_loss_pct.is_finite()); assert!(!result.worst_asset.is_empty()); assert!(result.worst_asset_loss.is_finite()); assert!(!result.best_asset.is_empty()); assert!(result.best_asset_return.is_finite()); } #[test] fn test_stress_test_worst_vs_best_asset() { let assets = vec![ create_test_asset("TECH", 0.5, 0.002), create_test_asset("BOND", 0.5, 0.0003), ]; let scenario = StressScenario { name: "Tech Crash".to_string(), description: "Technology sector crash".to_string(), market_shock: -0.30, volatility_spike: 2.5, correlation_change: 0.2, }; let result = apply_stress_scenario(&assets, &scenario, 0.10).unwrap(); assert!(!result.worst_asset.is_empty()); assert!(!result.best_asset.is_empty()); assert!(result.worst_asset != result.best_asset || assets.len() == 1); } }