Skip to main content

rustyqlib/risk/
stress.rs

1//! Stress MtM: scenario revaluation of an options book driven by a
2//! **TOML shock configuration**.
3//!
4//! A config declares named scenarios, each a list of shocks on risk
5//! factors (`spot`, `vol`, `rate`, `time`) with `relative` or
6//! `absolute` sizing and an optional `underlying` filter:
7//!
8//! ```toml
9//! [[scenarios]]
10//! name = "equity_crash"
11//!
12//! [[scenarios.shocks]]
13//! factor = "spot"
14//! mode = "relative"
15//! size = -0.20            # spot down 20%
16//!
17//! [[scenarios.shocks]]
18//! factor = "vol"
19//! mode = "absolute"
20//! size = 0.10             # implied vol up 10 points
21//! underlying = "ACME"     # only this name (omit or "*" for all)
22//!
23//! [[scenarios.shocks]]
24//! factor = "rate"
25//! mode = "absolute"
26//! size = 0.005
27//! tenors = [1.0, 2.0]     # key-rate: bump only this part of the curve
28//! # shifts = [0.005, 0.003]  # optional per-tenor sizes (default: size)
29//!
30//! [arbitrage]             # optional guard on bumped curves
31//! policy = "warn"         # allow | warn (default) | reject
32//! forward_floor = 0.0
33//! ```
34//!
35//! The book's market is snapshotted once into a typed
36//! [`Market`](crate::core::market::Market) store, each scenario bumps it
37//! ([`Market::bumped`](crate::core::market::Market::bumped) — every risk
38//! factor object performs its own bump, shocks apply in order, relative
39//! shocks scale the current level), and every position reprices fully on
40//! its own engine under the bumped snapshot
41//! ([`EquityOption::npv_in`](crate::equity::vanilla_option::EquityOption)).
42//! Results come back **per trade** and **aggregated per scenario**, with
43//! the aggregation identity `portfolio = sum(trades)` exact by
44//! construction.
45//!
46//! The `time` factor is an absolute horizon in days (theta-inclusive
47//! stresses); dividend/carry shocks are not yet supported by the
48//! repricer and are rejected at parse time by omission from the enum.
49
50use serde::Deserialize;
51
52use crate::core::market::{Discount, Market};
53use crate::equity::portfolio::EquityPortfolio;
54use crate::equity::utils::PayoffType;
55use crate::equity::vanilla_option::EquityOption;
56use crate::core::errors::RustyQLibError;
57
58// the shock vocabulary is the market layer's; re-exported here so stress
59// configs keep their import paths
60pub use crate::core::market::{BumpMode, RiskFactor, Shock};
61
62/// A named collection of shocks applied together.
63#[derive(Debug, Clone, Deserialize)]
64pub struct StressScenario {
65    pub name: String,
66    pub shocks: Vec<Shock>,
67}
68
69/// What to do when a bumped scenario curve implies a forward rate below
70/// the configured floor (see
71/// [`min_forward`](crate::core::curves::YieldCurve::min_forward)).
72#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)]
73#[serde(rename_all = "lowercase")]
74pub enum ArbitragePolicy {
75    /// Accept the curve silently.
76    Allow,
77    /// Log a warning and revalue anyway — the default: stress scenarios
78    /// are deliberately extreme, and the P&L number is usually still wanted.
79    #[default]
80    Warn,
81    /// Fail the run, naming the scenario, curve and offending segment.
82    Reject,
83}
84
85/// The no-arbitrage guard applied to every bumped curve of every scenario.
86///
87/// ```toml
88/// [arbitrage]
89/// policy = "reject"        # allow | warn (default) | reject
90/// forward_floor = 0.0      # smallest admissible continuous forward
91/// ```
92#[derive(Debug, Clone, Copy, Deserialize)]
93#[serde(default)]
94pub struct ArbitrageCheck {
95    pub policy: ArbitragePolicy,
96    /// Smallest admissible continuously compounded forward. `0.0` is the
97    /// classic no-arbitrage bound; set it negative to tolerate negative
98    /// forwards (the library allows negative rates).
99    pub forward_floor: f64,
100}
101
102impl Default for ArbitrageCheck {
103    fn default() -> Self {
104        ArbitrageCheck { policy: ArbitragePolicy::default(), forward_floor: 0.0 }
105    }
106}
107
108/// The whole stress configuration (one or more scenarios).
109#[derive(Debug, Clone, Deserialize)]
110pub struct StressConfig {
111    pub scenarios: Vec<StressScenario>,
112    /// No-arbitrage guard on bumped curves; defaults to warn at floor 0.
113    #[serde(default)]
114    pub arbitrage: ArbitrageCheck,
115}
116
117impl StressConfig {
118    /// Parse from TOML text. Requires the `stress-config` feature.
119    #[cfg(feature = "stress-config")]
120    pub fn from_toml_str(text: &str) -> Result<StressConfig, RustyQLibError> {
121        let config: StressConfig =
122            toml::from_str(text).map_err(|e| RustyQLibError::ParseError(format!("invalid stress config: {e}")))?;
123        config.validate()?;
124        Ok(config)
125    }
126
127    /// Load and parse a TOML file. Requires the `stress-config` feature.
128    #[cfg(feature = "stress-config")]
129    pub fn from_toml_file(path: &str) -> Result<StressConfig, RustyQLibError> {
130        let text = std::fs::read_to_string(path)
131            .map_err(|e| RustyQLibError::ParseError(format!("cannot read stress config '{path}': {e}")))?;
132        Self::from_toml_str(&text)
133    }
134
135    #[cfg(feature = "stress-config")]
136    fn validate(&self) -> Result<(), RustyQLibError> {
137        if self.scenarios.is_empty() {
138            return Err(RustyQLibError::ParseError("stress config has no scenarios".to_string()));
139        }
140        for scenario in &self.scenarios {
141            if scenario.shocks.is_empty() {
142                return Err(RustyQLibError::ParseError(format!("scenario '{}' has no shocks", scenario.name)));
143            }
144            for shock in &scenario.shocks {
145                if shock.factor == RiskFactor::Time && shock.mode == BumpMode::Relative {
146                    return Err(RustyQLibError::ParseError(format!(
147                        "scenario '{}': time shocks must be absolute (days)",
148                        scenario.name
149                    )));
150                }
151                let scenario_error = |reason: &str| {
152                    RustyQLibError::ParseError(format!("scenario '{}': {reason}", scenario.name))
153                };
154                if let Some(tenors) = &shock.tenors {
155                    if shock.factor != RiskFactor::Rate {
156                        return Err(scenario_error("tenors are only supported on rate shocks"));
157                    }
158                    if shock.mode == BumpMode::Relative {
159                        return Err(scenario_error("key-rate rate shocks must be absolute"));
160                    }
161                    if tenors.is_empty() {
162                        return Err(scenario_error("tenors must not be empty"));
163                    }
164                    if tenors.windows(2).any(|w| w[1] <= w[0]) || tenors.iter().any(|&t| t <= 0.0) {
165                        return Err(scenario_error("tenors must be positive and strictly increasing"));
166                    }
167                    if let Some(shifts) = &shock.shifts {
168                        if shifts.len() != tenors.len() {
169                            return Err(scenario_error("shifts must match tenors in length"));
170                        }
171                    }
172                } else if shock.shifts.is_some() {
173                    return Err(scenario_error("shifts require tenors"));
174                }
175            }
176        }
177        Ok(())
178    }
179}
180
181/// One trade's stress result.
182#[derive(Debug, Clone)]
183pub struct TradeStress {
184    /// Human-readable trade tag: symbol, payoff kind, strike, quantity.
185    pub label: String,
186    pub quantity: f64,
187    pub base_mtm: f64,
188    pub stressed_mtm: f64,
189    /// `stressed - base`.
190    pub stress_pnl: f64,
191}
192
193/// One scenario's book-level result with the per-trade breakdown.
194#[derive(Debug, Clone)]
195pub struct ScenarioResult {
196    pub scenario: String,
197    pub trades: Vec<TradeStress>,
198    pub base_mtm: f64,
199    pub stressed_mtm: f64,
200    pub stress_pnl: f64,
201}
202
203fn trade_label(option: &EquityOption, quantity: f64) -> String {
204    format!(
205        "{} {:?} K={} x {}",
206        option.base.symbol,
207        option.payoff.payoff_kind(),
208        option.base.strike_price,
209        quantity
210    )
211}
212
213/// Enforce the config's [`ArbitrageCheck`] on every discount curve of a
214/// bumped scenario market.
215fn check_arbitrage(
216    market: &Market,
217    check: &ArbitrageCheck,
218    scenario: &str,
219) -> Result<(), RustyQLibError> {
220    if check.policy == ArbitragePolicy::Allow {
221        return Ok(());
222    }
223    let keys: Vec<Discount> = market.keys::<Discount>().cloned().collect();
224    for key in keys {
225        let worst = market.get(&key)?.min_forward();
226        if worst.forward < check.forward_floor {
227            let detail = format!(
228                "scenario '{scenario}': curve {key:?} implies forward {:.6} on [{:.4}, {:.4}], below floor {}",
229                worst.forward, worst.t1, worst.t2, check.forward_floor
230            );
231            match check.policy {
232                ArbitragePolicy::Warn => log::warn!("{detail}"),
233                ArbitragePolicy::Reject => {
234                    return Err(RustyQLibError::invalid_input("stress scenario", detail));
235                }
236                ArbitragePolicy::Allow => unreachable!("handled above"),
237            }
238        }
239    }
240    Ok(())
241}
242
243/// Run every scenario in `config` over the book, through the pricing
244/// context: the book's market is snapshotted once, each scenario bumps it
245/// ([`Market::bumped`]), the bumped curves pass the config's no-arbitrage
246/// guard ([`ArbitrageCheck`]), and every position revalues fully on its
247/// own engine under the bumped snapshot. Reported per trade and
248/// aggregated; `portfolio = sum(trades)` is exact by construction.
249/// Errors on a malformed shock, a rejected arbitrage check, or a position
250/// the captured market cannot price.
251pub fn stress_mtm(
252    book: &EquityPortfolio,
253    config: &StressConfig,
254) -> Result<Vec<ScenarioResult>, RustyQLibError> {
255    let base_market = book.snapshot_market();
256    // base MtM is scenario-independent: price the book once, reuse the
257    // per-position values across every scenario
258    let base_values = book.position_values_in(&base_market)?;
259    let base_total: f64 = base_values.iter().sum();
260    let mut results = Vec::with_capacity(config.scenarios.len());
261    for scenario in &config.scenarios {
262        let stressed_market = base_market.bumped(&scenario.shocks)?;
263        check_arbitrage(&stressed_market, &config.arbitrage, &scenario.name)?;
264        let mut trades = Vec::with_capacity(book.positions.len());
265        let mut stressed_total = 0.0;
266        for (position, &base) in book.positions.iter().zip(&base_values) {
267            let stressed = position.quantity * position.option.npv_in(&stressed_market)?;
268            stressed_total += stressed;
269            trades.push(TradeStress {
270                label: trade_label(&position.option, position.quantity),
271                quantity: position.quantity,
272                base_mtm: base,
273                stressed_mtm: stressed,
274                stress_pnl: stressed - base,
275            });
276        }
277        results.push(ScenarioResult {
278            scenario: scenario.name.clone(),
279            trades,
280            base_mtm: base_total,
281            stressed_mtm: stressed_total,
282            stress_pnl: stressed_total - base_total,
283        });
284    }
285    Ok(results)
286}
287
288// silence the unused-import lint path for PayoffType (used in labels)
289const _: fn(&EquityOption) -> PayoffType = |o| o.payoff.payoff_kind();
290
291#[cfg(test)]
292mod tests {
293    use super::*;
294    use crate::core::trade::PutOrCall;
295    use crate::equity::builder::EquityOptionBuilder;
296    use crate::equity::utils::Engine;
297    use chrono::NaiveDate;
298
299    #[cfg(feature = "stress-config")]
300    const CONFIG: &str = r#"
301        [[scenarios]]
302        name = "equity_crash"
303        [[scenarios.shocks]]
304        factor = "spot"
305        mode = "relative"
306        size = -0.20
307        [[scenarios.shocks]]
308        factor = "vol"
309        mode = "absolute"
310        size = 0.10
311
312        [[scenarios]]
313        name = "rates_up_acme_only"
314        [[scenarios.shocks]]
315        factor = "rate"
316        mode = "absolute"
317        size = 0.01
318        underlying = "ACME"
319
320        [[scenarios]]
321        name = "one_week_decay"
322        [[scenarios.shocks]]
323        factor = "time"
324        mode = "absolute"
325        size = 7.0
326    "#;
327
328    fn option(symbol: &str, pc: PutOrCall, strike: f64) -> EquityOption {
329        EquityOptionBuilder::new()
330            .symbol(symbol)
331            .spot(100.0)
332            .strike(strike)
333            .flat_vol(0.25)
334            .flat_rate(0.03)
335            .valuation_date(NaiveDate::from_ymd_opt(2026, 1, 1).unwrap())
336            .maturity_date(NaiveDate::from_ymd_opt(2027, 1, 1).unwrap())
337            .vanilla(pc)
338            .engine(Engine::BlackScholes)
339            .build().expect("option must build")
340    }
341
342    #[cfg(feature = "stress-config")]
343    fn book() -> EquityPortfolio {
344        let mut b = EquityPortfolio::new();
345        b.add(option("ACME", PutOrCall::Call, 100.0), 100.0);
346        b.add(option("ACME", PutOrCall::Put, 90.0), 50.0);
347        b
348    }
349
350    #[test]
351    #[cfg(feature = "stress-config")]
352    fn toml_config_parses_scenarios_shocks_and_filters() {
353        let config = StressConfig::from_toml_str(CONFIG).unwrap();
354        assert_eq!(config.scenarios.len(), 3);
355        let crash = &config.scenarios[0];
356        assert_eq!(crash.shocks.len(), 2);
357        assert_eq!(crash.shocks[0].factor, RiskFactor::Spot);
358        assert_eq!(crash.shocks[0].mode, BumpMode::Relative);
359        assert_eq!(crash.shocks[1].factor, RiskFactor::Vol);
360        assert_eq!(config.scenarios[1].shocks[0].underlying.as_deref(), Some("ACME"));
361        // rejects an empty config and relative time shocks
362        assert!(StressConfig::from_toml_str("scenarios = []").is_err());
363        let bad = r#"
364            [[scenarios]]
365            name = "bad"
366            [[scenarios.shocks]]
367            factor = "time"
368            mode = "relative"
369            size = 0.1
370        "#;
371        assert!(StressConfig::from_toml_str(bad).is_err());
372        // unknown factors fail loudly at parse time
373        let unknown = r#"
374            [[scenarios]]
375            name = "x"
376            [[scenarios.shocks]]
377            factor = "dividend"
378            mode = "absolute"
379            size = 0.01
380        "#;
381        assert!(StressConfig::from_toml_str(unknown).is_err());
382    }
383
384    #[test]
385    fn shocks_bump_the_market_levels_in_order_and_honour_filters() {
386        use crate::core::market::{Spot, Vol};
387        let opt = option("ACME", PutOrCall::Call, 100.0);
388        let market = opt.snapshot_market();
389        let shocks = vec![
390            Shock { factor: RiskFactor::Spot, mode: BumpMode::Relative, size: -0.2, underlying: None, tenors: None, shifts: None },
391            Shock { factor: RiskFactor::Spot, mode: BumpMode::Absolute, size: -1.0, underlying: None, tenors: None, shifts: None },
392            // filtered out: different underlying
393            Shock { factor: RiskFactor::Vol, mode: BumpMode::Absolute, size: 0.1, underlying: Some("OTHER".into()), tenors: None, shifts: None },
394        ];
395        let bumped = market.bumped(&shocks).unwrap();
396        // in order: 100 * 0.8 = 80, then - 1
397        let spot = bumped.get(&Spot("ACME".to_string())).unwrap().value();
398        assert!((spot - 79.0).abs() < 1e-12, "composed spot {spot}");
399        let vol = bumped.get(&Vol("ACME".to_string())).unwrap().vol(100.0, 100.0, 1.0);
400        assert!((vol - 0.25).abs() < 1e-12, "filtered vol shock must not apply, got {vol}");
401    }
402
403    #[test]
404    #[cfg(feature = "stress-config")]
405    fn stress_mtm_matches_direct_repricing_and_aggregates_exactly() {
406        let b = book();
407        let config = StressConfig::from_toml_str(CONFIG).unwrap();
408        let results = stress_mtm(&b, &config).unwrap();
409        assert_eq!(results.len(), 3);
410        let crash = &results[0];
411        assert_eq!(crash.trades.len(), 2);
412        // trade-level equals a direct price_with reprice
413        let call = option("ACME", PutOrCall::Call, 100.0);
414        let expected_stressed = 100.0 * call.price_with(-20.0, 0.10, 0.0, 0.0);
415        assert!(
416            (crash.trades[0].stressed_mtm - expected_stressed).abs() < 1e-10,
417            "{} vs {expected_stressed}",
418            crash.trades[0].stressed_mtm
419        );
420        // aggregation identity: portfolio = sum of trades, exactly
421        for result in &results {
422            let sum_pnl: f64 = result.trades.iter().map(|t| t.stress_pnl).sum();
423            assert!((result.stress_pnl - sum_pnl).abs() < 1e-10, "{}", result.scenario);
424            let sum_base: f64 = result.trades.iter().map(|t| t.base_mtm).sum();
425            assert!((result.base_mtm - sum_base).abs() < 1e-10);
426        }
427    }
428
429    #[test]
430    #[cfg(feature = "stress-config")]
431    fn scenario_economics_move_the_right_trades() {
432        let b = book();
433        let config = StressConfig::from_toml_str(CONFIG).unwrap();
434        let results = stress_mtm(&b, &config).unwrap();
435        let crash = &results[0];
436        // spot -20% + vol +10pts: the long call loses, the long put gains
437        assert!(crash.trades[0].stress_pnl < 0.0, "call {:?}", crash.trades[0]);
438        assert!(crash.trades[1].stress_pnl > 0.0, "put {:?}", crash.trades[1]);
439        // a week of pure decay costs a long-options book money
440        let decay = &results[2];
441        assert!(decay.stress_pnl < 0.0, "theta scenario {:?}", decay.stress_pnl);
442        // the ACME-only rate shock hits every trade in this single-name book
443        assert!(results[1].trades.iter().all(|t| t.stress_pnl != 0.0));
444    }
445
446    #[test]
447    #[cfg(feature = "stress-config")]
448    fn key_rate_shocks_parse_and_reprice_between_parallel_and_base() {
449        let config = StressConfig::from_toml_str(
450            r#"
451            [[scenarios]]
452            name = "front_end_up"
453            [[scenarios.shocks]]
454            factor = "rate"
455            mode = "absolute"
456            size = 0.01
457            tenors = [2.0]
458        "#,
459        )
460        .unwrap();
461        let shock = &config.scenarios[0].shocks[0];
462        assert_eq!(shock.tenors.as_deref(), Some(&[2.0][..]));
463        assert_eq!(shock.shifts, None);
464        assert_eq!(config.arbitrage.policy, ArbitragePolicy::Warn, "default policy");
465
466        // a 1.5y option sits between the 1y (unbumped) and 2y (bumped)
467        // pillars: the 2y key-rate bump must move it less than the
468        // same-size parallel bump, and more than not bumping at all
469        let mid_pillar_option = EquityOptionBuilder::new()
470            .symbol("ACME")
471            .spot(100.0)
472            .strike(100.0)
473            .flat_vol(0.25)
474            .flat_rate(0.03)
475            .valuation_date(NaiveDate::from_ymd_opt(2026, 1, 1).unwrap())
476            .maturity_date(NaiveDate::from_ymd_opt(2027, 7, 1).unwrap())
477            .vanilla(PutOrCall::Call)
478            .engine(Engine::BlackScholes)
479            .build()
480            .expect("option must build");
481        let mut b = EquityPortfolio::new();
482        b.add(mid_pillar_option, 100.0);
483        let key_rate = stress_mtm(&b, &config).unwrap();
484        let parallel = StressConfig::from_toml_str(
485            r#"
486            [[scenarios]]
487            name = "all_up"
488            [[scenarios.shocks]]
489            factor = "rate"
490            mode = "absolute"
491            size = 0.01
492        "#,
493        )
494        .unwrap();
495        let parallel = stress_mtm(&b, &parallel).unwrap();
496        assert!(key_rate[0].stress_pnl.abs() > 1e-8, "key-rate shock must move the book");
497        assert!(
498            key_rate[0].stress_pnl.abs() < parallel[0].stress_pnl.abs(),
499            "key-rate {} vs parallel {}",
500            key_rate[0].stress_pnl,
501            parallel[0].stress_pnl
502        );
503
504        // malformed key-rate configs fail at parse time
505        for bad in [
506            // shifts without tenors
507            r#"
508            [[scenarios]]
509            name = "x"
510            [[scenarios.shocks]]
511            factor = "rate"
512            mode = "absolute"
513            size = 0.01
514            shifts = [0.01]
515            "#,
516            // tenors on a vol shock
517            r#"
518            [[scenarios]]
519            name = "x"
520            [[scenarios.shocks]]
521            factor = "vol"
522            mode = "absolute"
523            size = 0.01
524            tenors = [1.0]
525            "#,
526            // relative key-rate
527            r#"
528            [[scenarios]]
529            name = "x"
530            [[scenarios.shocks]]
531            factor = "rate"
532            mode = "relative"
533            size = 0.01
534            tenors = [1.0]
535            "#,
536            // length mismatch
537            r#"
538            [[scenarios]]
539            name = "x"
540            [[scenarios.shocks]]
541            factor = "rate"
542            mode = "absolute"
543            size = 0.01
544            tenors = [1.0, 2.0]
545            shifts = [0.01]
546            "#,
547            // non-increasing tenors
548            r#"
549            [[scenarios]]
550            name = "x"
551            [[scenarios.shocks]]
552            factor = "rate"
553            mode = "absolute"
554            size = 0.01
555            tenors = [2.0, 1.0]
556            "#,
557        ] {
558            assert!(StressConfig::from_toml_str(bad).is_err(), "must reject: {bad}");
559        }
560    }
561
562    #[test]
563    #[cfg(feature = "stress-config")]
564    fn arbitrage_policy_rejects_curves_with_forwards_below_the_floor() {
565        // -200bp at the 10y pillar alone: the preceding forward goes to
566        // 3% - 2% * 10/3 < 0 (zero bumps amplify into forwards by t/dt)
567        let toml = |policy: &str| {
568            format!(
569                r#"
570                [[scenarios]]
571                name = "long_end_collapse"
572                [[scenarios.shocks]]
573                factor = "rate"
574                mode = "absolute"
575                size = -0.02
576                tenors = [10.0]
577
578                [arbitrage]
579                policy = "{policy}"
580            "#
581            )
582        };
583        let b = book();
584        let rejecting = StressConfig::from_toml_str(&toml("reject")).unwrap();
585        assert_eq!(rejecting.arbitrage.policy, ArbitragePolicy::Reject);
586        let err = stress_mtm(&b, &rejecting).unwrap_err();
587        assert!(err.to_string().contains("long_end_collapse"), "{err}");
588        // warn and allow both let the run complete
589        for policy in ["warn", "allow"] {
590            let config = StressConfig::from_toml_str(&toml(policy)).unwrap();
591            assert!(stress_mtm(&b, &config).is_ok(), "policy {policy} must not fail");
592        }
593        // a floor can also be relaxed instead of the policy
594        let relaxed = StressConfig::from_toml_str(
595            &(toml("reject") + "forward_floor = -0.10\n"),
596        )
597        .unwrap();
598        assert!((relaxed.arbitrage.forward_floor + 0.10).abs() < 1e-12);
599        assert!(stress_mtm(&b, &relaxed).is_ok());
600    }
601
602    #[test]
603    #[cfg(feature = "stress-config")]
604    fn config_file_round_trip() {
605        let path = std::env::temp_dir().join("rustyqlib_stress_test.toml");
606        std::fs::write(&path, CONFIG).unwrap();
607        let config = StressConfig::from_toml_file(path.to_str().unwrap()).unwrap();
608        assert_eq!(config.scenarios.len(), 3);
609        let _ = std::fs::remove_file(&path);
610        assert!(StressConfig::from_toml_file("no_such_file.toml").is_err());
611    }
612}