Files
rustytorch/demos/rtx-riskflow-demo/src/stress_test.rs
T
2026-03-04 00:08:42 +00:00

233 lines
6.8 KiB
Rust

//! 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<StressScenario>,
}
/// 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<StressTestResult> {
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::<f64>();
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<StressScenario> {
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());
}
}