//! Stress testing for portfolios. //! //! Applies historical and hypothetical scenarios to estimate P&L impact. use riskflow_shared::{Portfolio, StressTestResult}; /// Stress tester. #[derive(Debug)] pub struct StressTester { /// Predefined scenarios. scenarios: Vec, } /// A stress test scenario. #[derive(Debug, Clone)] pub struct StressScenario { /// Scenario name. pub name: String, /// Description. pub description: String, /// Market shock (%). pub market_shock: f64, /// Sector-specific shocks. pub sector_shocks: Vec<(String, f64)>, } impl Default for StressTester { fn default() -> Self { Self::new() } } impl StressTester { /// Create a new stress tester with predefined scenarios. #[must_use] pub fn new() -> Self { Self { scenarios: default_scenarios(), } } /// Run all stress tests. #[must_use] pub fn run_all(&self, portfolio: &Portfolio) -> Vec { self.scenarios .iter() .map(|scenario| self.run_scenario(portfolio, scenario)) .collect() } /// Run a single stress test scenario. fn run_scenario(&self, portfolio: &Portfolio, scenario: &StressScenario) -> StressTestResult { let total_value = portfolio .positions .iter() .map(|p| p.market_value) .sum::(); let mut position_impacts = Vec::with_capacity(portfolio.positions.len()); let mut total_pnl: f64 = 0.0; for position in &portfolio.positions { // Calculate position-level impact let beta_impact = position.beta * scenario.market_shock; // Add sector-specific shock let sector_shock = scenario .sector_shocks .iter() .find(|(s, _)| s == &position.sector) .map_or(0.0, |(_, shock)| *shock); let position_pnl = position.market_value * (beta_impact + sector_shock) / 100.0; total_pnl += position_pnl; let position_return = beta_impact + sector_shock; position_impacts.push((position.symbol.clone(), position_return)); } let pnl_impact = if total_value > 0.0 { total_pnl / total_value * 100.0 } else { 0.0 }; StressTestResult { scenario: scenario.name.clone(), description: scenario.description.clone(), pnl_impact, position_impacts, } } /// Add a custom scenario. pub fn add_scenario(&mut self, scenario: StressScenario) { self.scenarios.push(scenario); } /// Get all scenarios. #[must_use] pub fn scenarios(&self) -> &[StressScenario] { &self.scenarios } } /// Default stress test scenarios. fn default_scenarios() -> Vec { vec![ StressScenario { name: "2008 Financial Crisis".to_string(), description: "Sharp market decline with financial sector stress".to_string(), market_shock: -40.0, sector_shocks: vec![ ("Financials".to_string(), -25.0), ("Technology".to_string(), 5.0), ("Consumer Staples".to_string(), 10.0), ], }, StressScenario { name: "COVID-19 Crash".to_string(), description: "Rapid 30% market decline".to_string(), market_shock: -30.0, sector_shocks: vec![ ("Technology".to_string(), 10.0), ("Healthcare".to_string(), 5.0), ("Energy".to_string(), -20.0), ], }, StressScenario { name: "Tech Correction".to_string(), description: "Technology sector bubble burst".to_string(), market_shock: -15.0, sector_shocks: vec![ ("Technology".to_string(), -30.0), ("Communications".to_string(), -15.0), ("Financials".to_string(), 5.0), ], }, StressScenario { name: "Rising Rates".to_string(), description: "Sharp increase in interest rates".to_string(), market_shock: -10.0, sector_shocks: vec![ ("Financials".to_string(), 10.0), ("Technology".to_string(), -15.0), ("Consumer Staples".to_string(), -5.0), ], }, StressScenario { name: "Inflation Spike".to_string(), description: "Unexpected inflation acceleration".to_string(), market_shock: -8.0, sector_shocks: vec![ ("Energy".to_string(), 15.0), ("Technology".to_string(), -10.0), ("Consumer Staples".to_string(), -5.0), ], }, StressScenario { name: "Flash Crash".to_string(), description: "Sudden market liquidity crisis".to_string(), market_shock: -10.0, sector_shocks: vec![], }, ] } #[cfg(test)] mod tests { use super::*; use riskflow_shared::get_sample_portfolio; #[test] fn test_stress_tester_creation() { let tester = StressTester::new(); assert!(!tester.scenarios.is_empty()); } #[test] fn test_run_all() { let tester = StressTester::new(); let portfolio = get_sample_portfolio(); let results = tester.run_all(&portfolio); assert!(!results.is_empty()); // All results should have P&L impact for result in &results { assert!(!result.scenario.is_empty()); // P&L should be negative for crisis scenarios (most are) } } #[test] fn test_crisis_scenario_negative_pnl() { let tester = StressTester::new(); let portfolio = get_sample_portfolio(); let results = tester.run_all(&portfolio); let crisis = results.iter().find(|r| r.scenario.contains("2008")); assert!(crisis.is_some()); assert!(crisis.unwrap().pnl_impact < 0.0); // Should be negative } #[test] fn test_add_custom_scenario() { let mut tester = StressTester::new(); let initial_count = tester.scenarios().len(); tester.add_scenario(StressScenario { name: "Custom Test".to_string(), description: "Test scenario".to_string(), market_shock: -5.0, sector_shocks: vec![], }); assert_eq!(tester.scenarios().len(), initial_count + 1); } #[test] fn test_position_impacts() { let tester = StressTester::new(); let portfolio = get_sample_portfolio(); let results = tester.run_all(&portfolio); let result = &results[0]; // Should have impact for each position assert_eq!(result.position_impacts.len(), portfolio.positions.len()); } }