299 lines
9.8 KiB
Rust
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);
|
|
}
|
|
}
|