use serde::{Deserialize, Serialize};
use std::str::FromStr;
use crate::{
quotes::{
quote::{Quote, QuoteLevels},
quotestore::QuoteStore,
},
utils::errors::{QSError, Result},
};
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum ScenarioType {
Absolute,
Relative,
}
impl FromStr for ScenarioType {
type Err = QSError;
fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
if s.eq_ignore_ascii_case("absolute") {
Ok(Self::Absolute)
} else if s.eq_ignore_ascii_case("relative") {
Ok(Self::Relative)
} else {
Err(QSError::InvalidValueErr(format!(
"Unknown scenario type: {s} (expected 'Absolute' or 'Relative')"
)))
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[allow(clippy::struct_field_names)]
pub struct Scenario {
target: String,
shock: f64,
scenario_type: ScenarioType,
}
impl Scenario {
#[must_use]
pub fn new(target: impl Into<String>, shock: f64, scenario_type: ScenarioType) -> Self {
Self {
target: target.into(),
shock,
scenario_type,
}
}
#[must_use]
pub fn target(&self) -> &str {
&self.target
}
#[must_use]
pub const fn shock(&self) -> f64 {
self.shock
}
#[must_use]
pub const fn scenario_type(&self) -> ScenarioType {
self.scenario_type
}
#[must_use]
pub fn matches(&self, identifier: &str) -> bool {
if self.target == identifier {
return true;
}
let segments: Vec<&str> = identifier.split('_').collect();
self.target.split('_').all(|part| segments.contains(&part))
}
#[must_use]
pub fn shocked_value(&self, value: f64) -> f64 {
match self.scenario_type {
ScenarioType::Absolute => value + self.shock,
ScenarioType::Relative => value * (1.0 + self.shock),
}
}
pub fn apply(&self, store: &mut QuoteStore) -> Result<usize> {
let shocked: Vec<Quote> = store
.quotes()
.values()
.filter(|q| self.matches(&q.details().identifier()))
.map(|q| {
let levels = q.levels();
let shocked_levels = QuoteLevels::new(
levels.mid().map(|v| self.shocked_value(v)),
levels.bid().map(|v| self.shocked_value(v)),
levels.ask().map(|v| self.shocked_value(v)),
);
Quote::new(q.details().clone(), shocked_levels)
})
.collect();
if shocked.is_empty() {
return Err(QSError::NotFoundErr(format!(
"Scenario target '{}' matched no quotes in the store",
self.target
)));
}
let count = shocked.len();
for quote in shocked {
store.add_quote(quote);
}
Ok(count)
}
}
#[cfg(test)]
mod tests {
use super::{Scenario, ScenarioType};
use crate::{
quotes::{
quote::{Quote, QuoteDetails, QuoteLevels},
quotestore::QuoteStore,
},
time::date::Date,
};
use std::str::FromStr;
fn store_with(identifiers: &[(&str, f64)]) -> QuoteStore {
let mut store = QuoteStore::new(Date::new(2025, 11, 11));
for (id, mid) in identifiers {
let details = QuoteDetails::from_str(id).expect("valid identifier");
store.add_quote(Quote::new(details, QuoteLevels::with_mid(*mid)));
}
store
}
#[test]
fn matches_exact_identifier() {
let scenario = Scenario::new("OIS_USD_SOFR_1Y", 0.01, ScenarioType::Absolute);
assert!(scenario.matches("OIS_USD_SOFR_1Y"));
assert!(!scenario.matches("OIS_USD_SOFR_2Y"));
}
#[test]
fn matches_segment_subset() {
let scenario = Scenario::new("SOFR", 0.01, ScenarioType::Absolute);
assert!(scenario.matches("OIS_USD_SOFR_1Y"));
assert!(scenario.matches("OIS_USD_SOFR_10Y"));
assert!(!scenario.matches("BasisSwap_USD_TermSOFR3m_TermSOFR3m_1Y"));
let partial = Scenario::new("OIS_USD_SOFR", 0.01, ScenarioType::Absolute);
assert!(partial.matches("OIS_USD_SOFR_2Y"));
assert!(!partial.matches("FixedRateDeposit_USD_SOFR_3M"));
}
#[test]
fn applies_absolute_shock() {
let mut store = store_with(&[("OIS_USD_SOFR_1Y", 0.04), ("OIS_USD_SOFR_2Y", 0.042)]);
let scenario = Scenario::new("SOFR", 0.01, ScenarioType::Absolute);
let count = scenario.apply(&mut store).expect("scenario applies");
assert_eq!(count, 2);
let mid = store
.quote("OIS_USD_SOFR_1Y")
.and_then(|q| q.levels().mid())
.expect("mid exists");
assert!((mid - 0.05).abs() < 1e-12);
}
#[test]
fn applies_relative_shock() {
let mut store = store_with(&[("OIS_USD_SOFR_1Y", 0.04)]);
let scenario = Scenario::new("OIS_USD_SOFR_1Y", 0.5, ScenarioType::Relative);
scenario.apply(&mut store).expect("scenario applies");
let mid = store
.quote("OIS_USD_SOFR_1Y")
.and_then(|q| q.levels().mid())
.expect("mid exists");
assert!((mid - 0.06).abs() < 1e-12);
}
#[test]
fn errors_when_no_quote_matches() {
let mut store = store_with(&[("OIS_USD_SOFR_1Y", 0.04)]);
let scenario = Scenario::new("EURIBOR", 0.01, ScenarioType::Absolute);
assert!(scenario.apply(&mut store).is_err());
}
#[test]
fn parses_scenario_type() {
assert_eq!(
"absolute".parse::<ScenarioType>().expect("parses"),
ScenarioType::Absolute
);
assert_eq!(
"Relative".parse::<ScenarioType>().expect("parses"),
ScenarioType::Relative
);
assert!("banana".parse::<ScenarioType>().is_err());
}
}