1use crate::messages::{
9 StressTestingBatchInput, StressTestingBatchOutput, StressTestingInput, StressTestingOutput,
10};
11use crate::types::{Portfolio, Sensitivity, StressScenario, StressTestResult};
12use async_trait::async_trait;
13use rustkernel_core::error::Result;
14use rustkernel_core::traits::BatchKernel;
15use rustkernel_core::{domain::Domain, kernel::KernelMetadata, traits::GpuKernel};
16use std::time::Instant;
17
18#[derive(Debug, Clone)]
26pub struct StressTesting {
27 metadata: KernelMetadata,
28}
29
30impl Default for StressTesting {
31 fn default() -> Self {
32 Self::new()
33 }
34}
35
36impl StressTesting {
37 #[must_use]
39 pub fn new() -> Self {
40 Self {
41 metadata: KernelMetadata::batch("risk/stress-testing", Domain::RiskAnalytics)
42 .with_description("Scenario-based stress testing")
43 .with_throughput(5_000)
44 .with_latency_us(2000.0),
45 }
46 }
47
48 pub fn compute(
55 portfolio: &Portfolio,
56 scenario: &StressScenario,
57 sensitivities: Option<&[Sensitivity]>,
58 ) -> StressTestResult {
59 if portfolio.n_assets() == 0 {
60 return StressTestResult {
61 scenario_name: scenario.name.clone(),
62 pnl_impact: 0.0,
63 pnl_impact_pct: 0.0,
64 asset_impacts: Vec::new(),
65 factor_impacts: Vec::new(),
66 post_stress_value: 0.0,
67 };
68 }
69
70 let total_value = portfolio.total_value();
71
72 let mut asset_impacts = Vec::with_capacity(portfolio.n_assets());
74 let mut total_pnl = 0.0;
75
76 for (i, (&asset_id, &value)) in portfolio
77 .asset_ids
78 .iter()
79 .zip(portfolio.values.iter())
80 .enumerate()
81 {
82 let mut asset_pnl = 0.0;
83
84 let sens = sensitivities
86 .and_then(|s| s.get(i))
87 .cloned()
88 .unwrap_or_default();
89
90 for (factor_name, shock) in &scenario.shocks {
91 let factor_impact =
92 Self::calculate_factor_impact(factor_name, *shock, value, &sens);
93 asset_pnl += factor_impact;
94 }
95
96 asset_impacts.push((asset_id, asset_pnl));
97 total_pnl += asset_pnl;
98 }
99
100 let factor_impacts: Vec<(String, f64)> = scenario
102 .shocks
103 .iter()
104 .map(|(factor_name, shock)| {
105 let mut factor_total = 0.0;
106 for (i, &value) in portfolio.values.iter().enumerate() {
107 let sens = sensitivities
108 .and_then(|s| s.get(i))
109 .cloned()
110 .unwrap_or_default();
111 factor_total +=
112 Self::calculate_factor_impact(factor_name, *shock, value, &sens);
113 }
114 (factor_name.clone(), factor_total)
115 })
116 .collect();
117
118 let pnl_impact_pct = if total_value.abs() > 1e-10 {
119 total_pnl / total_value * 100.0
120 } else {
121 0.0
122 };
123
124 StressTestResult {
125 scenario_name: scenario.name.clone(),
126 pnl_impact: total_pnl,
127 pnl_impact_pct,
128 asset_impacts,
129 factor_impacts,
130 post_stress_value: total_value + total_pnl,
131 }
132 }
133
134 pub fn compute_batch(
136 portfolio: &Portfolio,
137 scenarios: &[StressScenario],
138 sensitivities: Option<&[Sensitivity]>,
139 ) -> Vec<StressTestResult> {
140 scenarios
141 .iter()
142 .map(|s| Self::compute(portfolio, s, sensitivities))
143 .collect()
144 }
145
146 fn calculate_factor_impact(
148 factor_name: &str,
149 shock: f64,
150 value: f64,
151 sens: &Sensitivity,
152 ) -> f64 {
153 match factor_name.to_lowercase().as_str() {
154 "equity" | "stock" | "index" => {
155 let linear = sens.delta * value * shock;
158 let quadratic = 0.5 * sens.gamma * value * shock.powi(2);
159 linear + quadratic
160 }
161 "interest_rate" | "rate" | "ir" => {
162 sens.rho * value * shock
164 }
165 "volatility" | "vol" | "vega" => {
166 sens.vega * shock
168 }
169 "fx" | "currency" => {
170 sens.delta * value * shock
172 }
173 "credit_spread" | "credit" => {
174 -sens.delta * value * shock
176 }
177 "commodity" => sens.delta * value * shock,
178 _ => {
179 sens.delta * value * shock
181 }
182 }
183 }
184
185 pub fn standard_scenarios() -> Vec<StressScenario> {
187 vec![
188 StressScenario::new(
189 "2008 Financial Crisis",
190 "Equity -40%, Credit +300bps, Vol +100%",
191 vec![
192 ("equity".to_string(), -0.40),
193 ("credit_spread".to_string(), 0.03),
194 ("volatility".to_string(), 1.0),
195 ],
196 0.01,
197 ),
198 StressScenario::new(
199 "COVID-19 Crash",
200 "Equity -30%, Rate -150bps, Vol +200%",
201 vec![
202 ("equity".to_string(), -0.30),
203 ("interest_rate".to_string(), -0.015),
204 ("volatility".to_string(), 2.0),
205 ],
206 0.02,
207 ),
208 StressScenario::equity_crash(-0.20),
209 StressScenario::rate_shock(200.0), StressScenario::rate_shock(-100.0), StressScenario::credit_spread_widening(100.0),
212 StressScenario::new(
213 "Stagflation",
214 "Equity -15%, Rates +300bps, Commodity +30%",
215 vec![
216 ("equity".to_string(), -0.15),
217 ("interest_rate".to_string(), 0.03),
218 ("commodity".to_string(), 0.30),
219 ],
220 0.03,
221 ),
222 StressScenario::new(
223 "Flight to Quality",
224 "Equity -25%, Rate -200bps, Credit +150bps",
225 vec![
226 ("equity".to_string(), -0.25),
227 ("interest_rate".to_string(), -0.02),
228 ("credit_spread".to_string(), 0.015),
229 ],
230 0.02,
231 ),
232 ]
233 }
234
235 pub fn worst_case(
237 portfolio: &Portfolio,
238 scenarios: &[StressScenario],
239 sensitivities: Option<&[Sensitivity]>,
240 ) -> Option<StressTestResult> {
241 let results = Self::compute_batch(portfolio, scenarios, sensitivities);
242 results.into_iter().min_by(|a, b| {
243 a.pnl_impact
244 .partial_cmp(&b.pnl_impact)
245 .unwrap_or(std::cmp::Ordering::Equal)
246 })
247 }
248
249 pub fn expected_stress_loss(
251 portfolio: &Portfolio,
252 scenarios: &[StressScenario],
253 sensitivities: Option<&[Sensitivity]>,
254 ) -> f64 {
255 let results = Self::compute_batch(portfolio, scenarios, sensitivities);
256
257 results
258 .iter()
259 .zip(scenarios.iter())
260 .map(|(result, scenario)| result.pnl_impact.min(0.0) * scenario.probability)
261 .sum()
262 }
263}
264
265impl GpuKernel for StressTesting {
266 fn metadata(&self) -> &KernelMetadata {
267 &self.metadata
268 }
269}
270
271#[async_trait]
272impl BatchKernel<StressTestingInput, StressTestingOutput> for StressTesting {
273 async fn execute(&self, input: StressTestingInput) -> Result<StressTestingOutput> {
274 let start = Instant::now();
275 let result = Self::compute(
276 &input.portfolio,
277 &input.scenario,
278 input.sensitivities.as_deref(),
279 );
280 Ok(StressTestingOutput {
281 result,
282 compute_time_us: start.elapsed().as_micros() as u64,
283 })
284 }
285}
286
287#[async_trait]
288impl BatchKernel<StressTestingBatchInput, StressTestingBatchOutput> for StressTesting {
289 async fn execute(&self, input: StressTestingBatchInput) -> Result<StressTestingBatchOutput> {
290 let start = Instant::now();
291 let results = Self::compute_batch(
292 &input.portfolio,
293 &input.scenarios,
294 input.sensitivities.as_deref(),
295 );
296 let worst_case = Self::worst_case(
297 &input.portfolio,
298 &input.scenarios,
299 input.sensitivities.as_deref(),
300 );
301 Ok(StressTestingBatchOutput {
302 results,
303 worst_case,
304 compute_time_us: start.elapsed().as_micros() as u64,
305 })
306 }
307}
308
309#[cfg(test)]
310mod tests {
311 use super::*;
312
313 fn create_equity_portfolio() -> Portfolio {
314 Portfolio::new(
315 vec![1, 2, 3],
316 vec![100_000.0, 50_000.0, 25_000.0],
317 vec![0.08, 0.10, 0.06],
318 vec![0.20, 0.25, 0.15],
319 vec![1.0, 0.6, 0.4, 0.6, 1.0, 0.5, 0.4, 0.5, 1.0],
320 )
321 }
322
323 fn create_sensitivities() -> Vec<Sensitivity> {
324 vec![
325 Sensitivity {
326 asset_id: 1,
327 delta: 1.0,
328 gamma: 0.05,
329 vega: 1000.0,
330 theta: -50.0,
331 rho: -200.0,
332 },
333 Sensitivity {
334 asset_id: 2,
335 delta: 1.2,
336 gamma: 0.08,
337 vega: 800.0,
338 theta: -40.0,
339 rho: -150.0,
340 },
341 Sensitivity {
342 asset_id: 3,
343 delta: 0.8,
344 gamma: 0.03,
345 vega: 500.0,
346 theta: -25.0,
347 rho: -100.0,
348 },
349 ]
350 }
351
352 #[test]
353 fn test_stress_testing_metadata() {
354 let kernel = StressTesting::new();
355 assert_eq!(kernel.metadata().id, "risk/stress-testing");
356 assert_eq!(kernel.metadata().domain, Domain::RiskAnalytics);
357 }
358
359 #[test]
360 fn test_equity_crash_scenario() {
361 let portfolio = create_equity_portfolio();
362 let scenario = StressScenario::equity_crash(-0.30);
363
364 let result = StressTesting::compute(&portfolio, &scenario, None);
365
366 assert_eq!(result.scenario_name, "Equity Crash");
367
368 let expected_loss = -0.30 * portfolio.total_value();
370 let tolerance = 0.01 * portfolio.total_value();
371 assert!(
372 (result.pnl_impact - expected_loss).abs() < tolerance,
373 "Expected ~{}%, got {}%",
374 -30.0,
375 result.pnl_impact_pct
376 );
377 }
378
379 #[test]
380 fn test_stress_with_sensitivities() {
381 let portfolio = create_equity_portfolio();
382 let sensitivities = create_sensitivities();
383 let scenario = StressScenario::equity_crash(-0.20);
384
385 let result_no_sens = StressTesting::compute(&portfolio, &scenario, None);
386 let result_with_sens = StressTesting::compute(&portfolio, &scenario, Some(&sensitivities));
387
388 assert!(
390 result_no_sens.pnl_impact != result_with_sens.pnl_impact,
391 "Gamma should affect result"
392 );
393 }
394
395 #[test]
396 fn test_rate_shock_scenario() {
397 let portfolio = create_equity_portfolio();
398 let sensitivities = create_sensitivities();
399 let scenario = StressScenario::rate_shock(200.0); let result = StressTesting::compute(&portfolio, &scenario, Some(&sensitivities));
402
403 assert!(
405 result.pnl_impact < 0.0,
406 "Rate increase should cause loss with negative rho"
407 );
408 }
409
410 #[test]
411 fn test_batch_stress() {
412 let portfolio = create_equity_portfolio();
413 let scenarios = vec![
414 StressScenario::equity_crash(-0.10),
415 StressScenario::equity_crash(-0.20),
416 StressScenario::equity_crash(-0.30),
417 ];
418
419 let results = StressTesting::compute_batch(&portfolio, &scenarios, None);
420
421 assert_eq!(results.len(), 3);
422
423 assert!(results[0].pnl_impact > results[1].pnl_impact);
425 assert!(results[1].pnl_impact > results[2].pnl_impact);
426 }
427
428 #[test]
429 fn test_standard_scenarios() {
430 let scenarios = StressTesting::standard_scenarios();
431
432 assert!(!scenarios.is_empty());
433 assert!(
434 scenarios
435 .iter()
436 .any(|s| s.name.contains("2008") || s.name.contains("Financial"))
437 );
438 assert!(scenarios.iter().any(|s| s.name.contains("COVID")));
439 }
440
441 #[test]
442 fn test_worst_case() {
443 let portfolio = create_equity_portfolio();
444 let scenarios = vec![
445 StressScenario::equity_crash(-0.10),
446 StressScenario::equity_crash(-0.40),
447 StressScenario::equity_crash(-0.20),
448 ];
449
450 let worst = StressTesting::worst_case(&portfolio, &scenarios, None);
451
452 assert!(worst.is_some());
453 assert!(
454 worst.as_ref().unwrap().pnl_impact_pct < -35.0,
455 "Worst case should be -40% scenario"
456 );
457 }
458
459 #[test]
460 fn test_expected_stress_loss() {
461 let portfolio = create_equity_portfolio();
462 let scenarios = vec![
463 StressScenario::new(
464 "Mild",
465 "Mild downturn",
466 vec![("equity".to_string(), -0.10)],
467 0.10,
468 ),
469 StressScenario::new(
470 "Severe",
471 "Severe crash",
472 vec![("equity".to_string(), -0.40)],
473 0.01,
474 ),
475 ];
476
477 let expected_loss = StressTesting::expected_stress_loss(&portfolio, &scenarios, None);
478
479 let manual_expected = 0.10 * (-0.10 * 175_000.0) + 0.01 * (-0.40 * 175_000.0);
484
485 assert!(
486 (expected_loss - manual_expected).abs() < 100.0,
487 "Expected stress loss calculation: got {}, expected {}",
488 expected_loss,
489 manual_expected
490 );
491 }
492
493 #[test]
494 fn test_asset_impacts() {
495 let portfolio = create_equity_portfolio();
496 let scenario = StressScenario::equity_crash(-0.25);
497
498 let result = StressTesting::compute(&portfolio, &scenario, None);
499
500 assert_eq!(result.asset_impacts.len(), 3);
501
502 for (i, (asset_id, impact)) in result.asset_impacts.iter().enumerate() {
504 assert_eq!(*asset_id, portfolio.asset_ids[i]);
505 let expected = -0.25 * portfolio.values[i];
506 assert!(
507 (impact - expected).abs() < 1.0,
508 "Asset {} impact: {} vs expected {}",
509 asset_id,
510 impact,
511 expected
512 );
513 }
514 }
515
516 #[test]
517 fn test_factor_impacts() {
518 let portfolio = create_equity_portfolio();
519 let scenario = StressScenario::new(
520 "Multi-factor",
521 "Multiple shocks",
522 vec![
523 ("equity".to_string(), -0.20),
524 ("volatility".to_string(), 0.50),
525 ],
526 0.05,
527 );
528 let sensitivities = create_sensitivities();
529
530 let result = StressTesting::compute(&portfolio, &scenario, Some(&sensitivities));
531
532 assert_eq!(result.factor_impacts.len(), 2);
533 assert!(
534 result
535 .factor_impacts
536 .iter()
537 .any(|(name, _)| name == "equity")
538 );
539 assert!(
540 result
541 .factor_impacts
542 .iter()
543 .any(|(name, _)| name == "volatility")
544 );
545 }
546
547 #[test]
548 fn test_empty_portfolio() {
549 let empty = Portfolio::new(Vec::new(), Vec::new(), Vec::new(), Vec::new(), Vec::new());
550 let scenario = StressScenario::equity_crash(-0.30);
551
552 let result = StressTesting::compute(&empty, &scenario, None);
553
554 assert_eq!(result.pnl_impact, 0.0);
555 assert_eq!(result.post_stress_value, 0.0);
556 }
557
558 #[test]
559 fn test_post_stress_value() {
560 let portfolio = create_equity_portfolio();
561 let scenario = StressScenario::equity_crash(-0.20);
562
563 let result = StressTesting::compute(&portfolio, &scenario, None);
564
565 let expected_post_stress = portfolio.total_value() * 0.80; assert!(
567 (result.post_stress_value - expected_post_stress).abs() < 100.0,
568 "Post-stress value: {} vs expected {}",
569 result.post_stress_value,
570 expected_post_stress
571 );
572 }
573}