use std::collections::HashMap;
#[derive(Debug, Clone)]
pub struct StressScenario {
pub name: String,
pub asset_shocks: HashMap<String, f64>,
pub correlation_shock: Option<f64>,
}
impl StressScenario {
pub fn new(name: impl Into<String>) -> Self {
Self {
name: name.into(),
asset_shocks: HashMap::new(),
correlation_shock: None,
}
}
#[must_use]
pub fn with_shock(mut self, asset: impl Into<String>, shock: f64) -> Self {
self.asset_shocks.insert(asset.into(), shock);
self
}
#[must_use]
pub fn with_correlation_shock(mut self, shock: f64) -> Self {
self.correlation_shock = Some(shock);
self
}
pub fn covid_crash() -> Self {
let mut shocks = HashMap::new();
shocks.insert("equities".to_owned(), -0.30);
shocks.insert("bonds".to_owned(), 0.05);
shocks.insert("vol".to_owned(), 1.50);
Self {
name: "COVID Crash".to_owned(),
asset_shocks: shocks,
correlation_shock: Some(0.40),
}
}
pub fn rate_spike() -> Self {
let mut shocks = HashMap::new();
shocks.insert("bonds".to_owned(), -0.15);
shocks.insert("equities".to_owned(), -0.10);
Self {
name: "Rate Spike".to_owned(),
asset_shocks: shocks,
correlation_shock: Some(0.20),
}
}
pub fn dollar_rally() -> Self {
let mut shocks = HashMap::new();
shocks.insert("em_equities".to_owned(), -0.20);
shocks.insert("commodities".to_owned(), -0.10);
Self {
name: "Dollar Rally".to_owned(),
asset_shocks: shocks,
correlation_shock: Some(-0.10),
}
}
pub fn tech_crash() -> Self {
let mut shocks = HashMap::new();
shocks.insert("tech".to_owned(), -0.40);
shocks.insert("defensive".to_owned(), 0.05);
Self {
name: "Tech Crash".to_owned(),
asset_shocks: shocks,
correlation_shock: Some(0.15),
}
}
}
#[derive(Debug, Clone, Default)]
pub struct StressPortfolio {
pub positions: HashMap<String, f64>,
}
impl StressPortfolio {
pub fn new() -> Self {
Self::default()
}
pub fn add_position(&mut self, symbol: impl Into<String>, market_value: f64) {
self.positions.insert(symbol.into(), market_value);
}
pub fn total_value(&self) -> f64 {
self.positions.values().sum()
}
}
#[derive(Debug, Clone)]
pub struct StressResult {
pub scenario_name: String,
pub pnl: f64,
pub pnl_pct: f64,
pub worst_position: Option<(String, f64)>,
pub best_position: Option<(String, f64)>,
pub positions_detail: HashMap<String, f64>,
}
pub fn apply_scenario(portfolio: &StressPortfolio, scenario: &StressScenario) -> StressResult {
let total_value = portfolio.total_value();
let mut detail: HashMap<String, f64> = HashMap::with_capacity(portfolio.positions.len());
let mut total_pnl = 0.0_f64;
for (symbol, &market_value) in &portfolio.positions {
let shock = scenario
.asset_shocks
.iter()
.filter(|(key, _)| symbol.contains(key.as_str()))
.map(|(_, &s)| s)
.reduce(|acc, s| if s.abs() > acc.abs() { s } else { acc })
.unwrap_or(0.0);
let position_pnl = market_value * shock;
detail.insert(symbol.clone(), position_pnl);
total_pnl += position_pnl;
}
let pnl_pct = if total_value.abs() > f64::EPSILON {
total_pnl / total_value
} else {
0.0
};
let worst_position = detail
.iter()
.min_by(|a, b| a.1.partial_cmp(b.1).unwrap_or(std::cmp::Ordering::Equal))
.map(|(k, v)| (k.clone(), *v));
let best_position = detail
.iter()
.max_by(|a, b| a.1.partial_cmp(b.1).unwrap_or(std::cmp::Ordering::Equal))
.map(|(k, v)| (k.clone(), *v));
StressResult {
scenario_name: scenario.name.clone(),
pnl: total_pnl,
pnl_pct,
worst_position,
best_position,
positions_detail: detail,
}
}
pub struct StressTest {
results: Vec<StressResult>,
}
impl StressTest {
pub fn run_all(portfolio: &StressPortfolio, scenarios: &[StressScenario]) -> Self {
let results = scenarios
.iter()
.map(|s| apply_scenario(portfolio, s))
.collect();
Self { results }
}
pub fn results(&self) -> &[StressResult] {
&self.results
}
pub fn worst_case(&self) -> Option<&StressResult> {
self.results
.iter()
.min_by(|a, b| a.pnl.partial_cmp(&b.pnl).unwrap_or(std::cmp::Ordering::Equal))
}
pub fn best_case(&self) -> Option<&StressResult> {
self.results
.iter()
.max_by(|a, b| a.pnl.partial_cmp(&b.pnl).unwrap_or(std::cmp::Ordering::Equal))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn equity_bond_portfolio() -> StressPortfolio {
let mut p = StressPortfolio::new();
p.add_position("equities", 100_000.0);
p.add_position("bonds", 50_000.0);
p
}
#[test]
fn covid_crash_equity_pnl() {
let portfolio = equity_bond_portfolio();
let scenario = StressScenario::covid_crash();
let result = apply_scenario(&portfolio, &scenario);
let expected = -27_500.0;
assert!(
(result.pnl - expected).abs() < 1e-6,
"COVID crash pnl {:.2} != expected {:.2}",
result.pnl,
expected
);
}
#[test]
fn covid_crash_pnl_pct() {
let portfolio = equity_bond_portfolio();
let scenario = StressScenario::covid_crash();
let result = apply_scenario(&portfolio, &scenario);
let total_value = 150_000.0_f64;
let expected_pct = -27_500.0 / total_value;
assert!(
(result.pnl_pct - expected_pct).abs() < 1e-10,
"pnl_pct {:.6} != {:.6}",
result.pnl_pct,
expected_pct
);
}
#[test]
fn covid_crash_worst_position_is_equities() {
let portfolio = equity_bond_portfolio();
let scenario = StressScenario::covid_crash();
let result = apply_scenario(&portfolio, &scenario);
let (sym, pnl) = result.worst_position.expect("should have worst position");
assert_eq!(sym, "equities");
assert!((pnl - (-30_000.0)).abs() < 1e-6, "worst pnl={pnl:.2}");
}
#[test]
fn stress_test_worst_case_is_covid() {
let portfolio = equity_bond_portfolio();
let scenarios = vec![
StressScenario::covid_crash(),
StressScenario::rate_spike(),
StressScenario::dollar_rally(),
];
let st = StressTest::run_all(&portfolio, &scenarios);
let worst = st.worst_case().expect("should have a worst case");
assert_eq!(worst.scenario_name, "COVID Crash");
}
#[test]
fn empty_portfolio_zero_pnl() {
let portfolio = StressPortfolio::new();
let result = apply_scenario(&portfolio, &StressScenario::covid_crash());
assert!((result.pnl).abs() < f64::EPSILON);
assert!((result.pnl_pct).abs() < f64::EPSILON);
assert!(result.worst_position.is_none());
assert!(result.best_position.is_none());
}
#[test]
fn tech_crash_scenario_smoke() {
let mut p = StressPortfolio::new();
p.add_position("tech", 200_000.0);
p.add_position("defensive", 100_000.0);
let result = apply_scenario(&p, &StressScenario::tech_crash());
assert!((result.pnl - (-75_000.0)).abs() < 1e-6, "pnl={}", result.pnl);
}
#[test]
fn rate_spike_scenario_smoke() {
let portfolio = equity_bond_portfolio();
let result = apply_scenario(&portfolio, &StressScenario::rate_spike());
assert!((result.pnl - (-17_500.0)).abs() < 1e-6, "pnl={}", result.pnl);
}
#[test]
fn no_scenarios_worst_case_returns_none() {
let portfolio = equity_bond_portfolio();
let st = StressTest::run_all(&portfolio, &[]);
assert!(st.worst_case().is_none());
}
}