#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct RiskFactor {
pub name: String,
pub current_value: f64,
pub shock_bps: f64,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct StressScenario {
pub name: String,
pub description: String,
pub risk_factors: Vec<RiskFactor>,
pub correlation_shock: f64,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct PortfolioPosition {
pub asset_id: String,
pub market_value: f64,
pub duration: f64,
pub beta: f64,
pub delta: f64,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct StressResult {
pub scenario_name: String,
pub portfolio_pnl: f64,
pub worst_position: String,
pub worst_loss: f64,
pub factor_contributions: Vec<(String, f64)>,
}
#[derive(Debug, Clone)]
pub struct StressTester {
pub positions: Vec<PortfolioPosition>,
pub scenarios: Vec<StressScenario>,
}
impl StressTester {
pub fn new(positions: Vec<PortfolioPosition>) -> Self {
Self { positions, scenarios: Vec::new() }
}
pub fn add_scenario(&mut self, scenario: StressScenario) {
self.scenarios.push(scenario);
}
pub fn position_pnl(position: &PortfolioPosition, factor: &RiskFactor) -> f64 {
let name = factor.name.to_lowercase();
if name.contains("rate") {
-position.duration * position.market_value * factor.shock_bps / 10_000.0
} else if name.contains("equity") {
position.beta * position.market_value * factor.shock_bps / 10_000.0
} else if name.contains("vol") {
position.delta * position.market_value * factor.shock_bps / 10_000.0 * 0.01
} else {
position.market_value * factor.shock_bps / 10_000.0
}
}
pub fn run_scenario(&self, scenario: &StressScenario) -> StressResult {
let mut portfolio_pnl = 0.0_f64;
let mut factor_contributions: Vec<(String, f64)> = Vec::new();
let mut worst_position = String::new();
let mut worst_loss = 0.0_f64;
for factor in &scenario.risk_factors {
let mut factor_pnl = 0.0;
for pos in &self.positions {
let pnl = Self::position_pnl(pos, factor);
factor_pnl += pnl;
if pnl < worst_loss {
worst_loss = pnl;
worst_position = pos.asset_id.clone();
}
}
factor_contributions.push((factor.name.clone(), factor_pnl));
portfolio_pnl += factor_pnl;
}
StressResult {
scenario_name: scenario.name.clone(),
portfolio_pnl,
worst_position,
worst_loss,
factor_contributions,
}
}
pub fn run_all(&self) -> Vec<StressResult> {
self.scenarios.iter().map(|s| self.run_scenario(s)).collect()
}
pub fn worst_scenario(&self) -> Option<StressResult> {
let results = self.run_all();
results.into_iter().min_by(|a, b| {
a.portfolio_pnl.partial_cmp(&b.portfolio_pnl).unwrap_or(std::cmp::Ordering::Equal)
})
}
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct CorrelationStressMatrix {
pub n: usize,
pub base_correlations: Vec<f64>,
pub shock_multiplier: f64,
}
pub fn stressed_correlation(mat: &CorrelationStressMatrix, i: usize, j: usize) -> f64 {
if i == j {
return 1.0;
}
let base = mat.base_correlations[i * mat.n + j];
(base * mat.shock_multiplier).clamp(-1.0, 1.0)
}
pub fn portfolio_var_stressed(
mat: &CorrelationStressMatrix,
vols: &[f64],
weights: &[f64],
) -> f64 {
let n = mat.n;
assert_eq!(vols.len(), n, "vols length must equal matrix dimension");
assert_eq!(weights.len(), n, "weights length must equal matrix dimension");
let mut variance = 0.0_f64;
for i in 0..n {
for j in 0..n {
let rho = stressed_correlation(mat, i, j);
variance += weights[i] * weights[j] * vols[i] * vols[j] * rho;
}
}
variance.max(0.0).sqrt()
}
#[cfg(test)]
mod tests {
use super::*;
fn rate_position() -> PortfolioPosition {
PortfolioPosition {
asset_id: "bond_10y".to_string(),
market_value: 1_000_000.0,
duration: 8.0,
beta: 0.0,
delta: 0.0,
}
}
fn equity_position() -> PortfolioPosition {
PortfolioPosition {
asset_id: "spy".to_string(),
market_value: 500_000.0,
duration: 0.0,
beta: 1.2,
delta: 0.0,
}
}
#[test]
fn rate_shock_reduces_duration_portfolio() {
let pos = rate_position();
let factor = RiskFactor {
name: "rate_10y".to_string(),
current_value: 0.04,
shock_bps: 100.0, };
let pnl = StressTester::position_pnl(&pos, &factor);
assert!((pnl - (-80_000.0)).abs() < 1e-6, "pnl={pnl}");
}
#[test]
fn equity_shock_by_beta() {
let pos = equity_position();
let factor = RiskFactor {
name: "equity_sp500".to_string(),
current_value: 4500.0,
shock_bps: -500.0, };
let pnl = StressTester::position_pnl(&pos, &factor);
assert!((pnl - (-30_000.0)).abs() < 1e-6, "pnl={pnl}");
}
#[test]
fn run_scenario_aggregates_factors() {
let tester = StressTester::new(vec![rate_position(), equity_position()]);
let scenario = StressScenario {
name: "test".to_string(),
description: "".to_string(),
risk_factors: vec![
RiskFactor { name: "rate_10y".to_string(), current_value: 0.04, shock_bps: 50.0 },
RiskFactor {
name: "equity_sp500".to_string(),
current_value: 4500.0,
shock_bps: -200.0,
},
],
correlation_shock: 1.0,
};
let result = tester.run_scenario(&scenario);
assert!(result.portfolio_pnl < 0.0, "combined shock should be negative");
assert!(!result.worst_position.is_empty());
}
#[test]
fn correlation_matrix_diagonal_is_one() {
let mat = CorrelationStressMatrix {
n: 3,
base_correlations: vec![
1.0, 0.5, 0.3, 0.5, 1.0, 0.4, 0.3, 0.4, 1.0,
],
shock_multiplier: 2.0,
};
for i in 0..3 {
assert_eq!(stressed_correlation(&mat, i, i), 1.0);
}
}
#[test]
fn stressed_correlation_off_diagonal_clamped() {
let mat = CorrelationStressMatrix {
n: 2,
base_correlations: vec![1.0, 0.8, 0.8, 1.0],
shock_multiplier: 2.0,
};
let c = stressed_correlation(&mat, 0, 1);
assert!((c - 1.0).abs() < 1e-10);
}
#[test]
fn portfolio_var_increases_with_higher_correlation() {
let n = 2;
let vols = vec![0.2, 0.3];
let weights = vec![0.5, 0.5];
let mat_low = CorrelationStressMatrix {
n,
base_correlations: vec![1.0, 0.1, 0.1, 1.0],
shock_multiplier: 1.0,
};
let var_low = portfolio_var_stressed(&mat_low, &vols, &weights);
let mat_high = CorrelationStressMatrix {
n,
base_correlations: vec![1.0, 0.9, 0.9, 1.0],
shock_multiplier: 1.0,
};
let var_high = portfolio_var_stressed(&mat_high, &vols, &weights);
assert!(var_high > var_low, "higher corr => higher VaR: {var_low} vs {var_high}");
}
#[test]
fn worst_scenario_returns_most_negative() {
let mut tester = StressTester::new(vec![rate_position()]);
tester.add_scenario(StressScenario {
name: "mild".to_string(),
description: "".to_string(),
risk_factors: vec![RiskFactor {
name: "rate_10y".to_string(),
current_value: 0.04,
shock_bps: 10.0,
}],
correlation_shock: 1.0,
});
tester.add_scenario(StressScenario {
name: "severe".to_string(),
description: "".to_string(),
risk_factors: vec![RiskFactor {
name: "rate_10y".to_string(),
current_value: 0.04,
shock_bps: 300.0,
}],
correlation_shock: 1.5,
});
let worst = tester.worst_scenario().expect("should have result");
assert_eq!(worst.scenario_name, "severe");
}
}