rustkernel_risk/
stress.rs

1//! Stress testing kernels.
2//!
3//! This module provides stress testing analytics:
4//! - Scenario-based stress testing
5//! - Historical stress scenarios
6//! - Reverse stress testing
7
8use 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// ============================================================================
19// Stress Testing Kernel
20// ============================================================================
21
22/// Stress testing kernel.
23///
24/// Applies stress scenarios to portfolios and calculates P&L impacts.
25#[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    /// Create a new stress testing kernel.
38    #[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    /// Run a single stress scenario.
49    ///
50    /// # Arguments
51    /// * `portfolio` - Portfolio to stress
52    /// * `scenario` - Stress scenario to apply
53    /// * `sensitivities` - Optional sensitivities for non-linear effects
54    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        // Calculate impact by asset
73        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            // Apply shocks based on sensitivities
85            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        // Calculate factor-level impacts
101        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    /// Run multiple stress scenarios.
135    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    /// Calculate impact of a factor shock on an asset.
147    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                // Linear: delta * S * dS/S
156                // Quadratic: + 0.5 * gamma * S^2 * (dS/S)^2
157                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                // Duration-based: rho * value * dR
163                sens.rho * value * shock
164            }
165            "volatility" | "vol" | "vega" => {
166                // Vega: vega * dVol
167                sens.vega * shock
168            }
169            "fx" | "currency" => {
170                // FX sensitivity similar to equity
171                sens.delta * value * shock
172            }
173            "credit_spread" | "credit" => {
174                // Credit spread sensitivity (negative impact for spread widening)
175                -sens.delta * value * shock
176            }
177            "commodity" => sens.delta * value * shock,
178            _ => {
179                // Default: linear sensitivity
180                sens.delta * value * shock
181            }
182        }
183    }
184
185    /// Generate standard stress scenarios.
186    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),  // +200bps
210            StressScenario::rate_shock(-100.0), // -100bps
211            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    /// Find worst-case scenario from a set.
236    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    /// Calculate expected stress loss (probability-weighted).
250    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        // With 30% equity crash and delta=1, should lose ~30% of portfolio
369        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        // With gamma, the result should differ due to convexity
389        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); // +200bps
400
401        let result = StressTesting::compute(&portfolio, &scenario, Some(&sensitivities));
402
403        // With negative rho (bond-like), rate increase should cause losses
404        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        // Larger shocks should cause larger losses
424        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        // Expected loss = P(mild) * Loss(mild) + P(severe) * Loss(severe)
480        // = 0.10 * (-10% * 175k) + 0.01 * (-40% * 175k)
481        // = 0.10 * -17500 + 0.01 * -70000
482        // = -1750 + -700 = -2450
483        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        // Each asset impact should be proportional to value
503        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; // 80% remaining
566        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}