1use 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
58pub use crate::core::market::{BumpMode, RiskFactor, Shock};
61
62#[derive(Debug, Clone, Deserialize)]
64pub struct StressScenario {
65 pub name: String,
66 pub shocks: Vec<Shock>,
67}
68
69#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)]
73#[serde(rename_all = "lowercase")]
74pub enum ArbitragePolicy {
75 Allow,
77 #[default]
80 Warn,
81 Reject,
83}
84
85#[derive(Debug, Clone, Copy, Deserialize)]
93#[serde(default)]
94pub struct ArbitrageCheck {
95 pub policy: ArbitragePolicy,
96 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#[derive(Debug, Clone, Deserialize)]
110pub struct StressConfig {
111 pub scenarios: Vec<StressScenario>,
112 #[serde(default)]
114 pub arbitrage: ArbitrageCheck,
115}
116
117impl StressConfig {
118 #[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 #[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#[derive(Debug, Clone)]
183pub struct TradeStress {
184 pub label: String,
186 pub quantity: f64,
187 pub base_mtm: f64,
188 pub stressed_mtm: f64,
189 pub stress_pnl: f64,
191}
192
193#[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
213fn 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
243pub fn stress_mtm(
252 book: &EquityPortfolio,
253 config: &StressConfig,
254) -> Result<Vec<ScenarioResult>, RustyQLibError> {
255 let base_market = book.snapshot_market();
256 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
288const _: 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 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 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 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 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 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 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 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 let decay = &results[2];
441 assert!(decay.stress_pnl < 0.0, "theta scenario {:?}", decay.stress_pnl);
442 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 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, ¶llel).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 for bad in [
506 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 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 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 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 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 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 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 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}