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

299 lines
9.8 KiB
Rust

//! 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<StressTestResult> {
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::<f64>() / asset.returns.len() as f64;
let variance = asset
.returns
.iter()
.map(|r| (r - mean_return).powi(2))
.sum::<f64>()
/ 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<Vec<StressTestResult>> {
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<Vec<StressTestResult>> {
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);
}
}