1use std::any::{Any, TypeId};
30use std::collections::HashMap;
31use std::fmt::{self, Debug};
32use std::hash::Hash;
33use std::sync::Arc;
34
35use chrono::NaiveDate;
36use serde::Deserialize;
37
38use crate::core::curves::{RateShift, YieldCurve};
39use crate::core::depth::MarketDepth;
40use crate::core::errors::{Result, RustyQLibError};
41use crate::core::quotes::Quote;
42use crate::core::vols::{VolShift, VolSurface};
43
44pub trait MarketKey: Clone + Eq + Hash + Debug + Send + Sync + 'static {
60 type Value: Clone + Debug + Send + Sync + 'static;
63}
64
65#[derive(Debug, Clone, PartialEq, Eq, Hash)]
69pub struct Spot(pub String);
70impl MarketKey for Spot {
71 type Value = Quote;
72}
73
74#[derive(Debug, Clone, PartialEq, Eq, Hash)]
82pub struct Vol(pub String);
83impl MarketKey for Vol {
84 type Value = Arc<VolSurface>;
85}
86
87#[derive(Debug, Clone, PartialEq, Eq, Hash)]
90pub struct Discount(pub String);
91impl MarketKey for Discount {
92 type Value = Arc<YieldCurve>;
93}
94
95#[derive(Debug, Clone, PartialEq, Eq, Hash)]
102pub struct Depth(pub String);
103impl MarketKey for Depth {
104 type Value = Arc<MarketDepth>;
105}
106
107pub const DEFAULT_CURRENCY: &str = "USD";
110
111trait AnyStore: Send + Sync {
120 fn as_any(&self) -> &dyn Any;
121 fn as_any_mut(&mut self) -> &mut dyn Any;
122 fn clone_box(&self) -> Box<dyn AnyStore>;
123 fn len(&self) -> usize;
124}
125
126impl<K: MarketKey> AnyStore for HashMap<K, K::Value> {
127 fn as_any(&self) -> &dyn Any {
128 self
129 }
130 fn as_any_mut(&mut self) -> &mut dyn Any {
131 self
132 }
133 fn clone_box(&self) -> Box<dyn AnyStore> {
134 Box::new(self.clone())
135 }
136 fn len(&self) -> usize {
137 HashMap::len(self)
138 }
139}
140
141pub struct Market {
146 valuation_date: NaiveDate,
147 stores: HashMap<TypeId, Box<dyn AnyStore>>,
148}
149
150impl Clone for Market {
151 fn clone(&self) -> Self {
152 Market {
153 valuation_date: self.valuation_date,
154 stores: self.stores.iter().map(|(&id, s)| (id, s.clone_box())).collect(),
155 }
156 }
157}
158
159impl Debug for Market {
160 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
161 f.debug_struct("Market")
162 .field("valuation_date", &self.valuation_date)
163 .field("entries", &self.len())
164 .finish()
165 }
166}
167
168impl Market {
169 pub fn new(valuation_date: NaiveDate) -> Self {
170 Market { valuation_date, stores: HashMap::new() }
171 }
172
173 pub fn valuation_date(&self) -> NaiveDate {
174 self.valuation_date
175 }
176
177 pub fn insert<K: MarketKey>(&mut self, key: K, value: K::Value) {
179 self.stores
180 .entry(TypeId::of::<K>())
181 .or_insert_with(|| Box::new(HashMap::<K, K::Value>::new()))
182 .as_any_mut()
183 .downcast_mut::<HashMap<K, K::Value>>()
184 .expect("store type is pinned by the TypeId key")
185 .insert(key, value);
186 }
187
188 pub fn with<K: MarketKey>(mut self, key: K, value: K::Value) -> Self {
190 self.insert(key, value);
191 self
192 }
193
194 pub fn try_get<K: MarketKey>(&self, key: &K) -> Option<&K::Value> {
196 self.stores
197 .get(&TypeId::of::<K>())?
198 .as_any()
199 .downcast_ref::<HashMap<K, K::Value>>()
200 .expect("store type is pinned by the TypeId key")
201 .get(key)
202 }
203
204 pub fn get<K: MarketKey>(&self, key: &K) -> Result<&K::Value> {
208 self.try_get(key)
209 .ok_or_else(|| RustyQLibError::MissingMarketData { key: format!("{key:?}") })
210 }
211
212 pub fn contains<K: MarketKey>(&self, key: &K) -> bool {
213 self.try_get(key).is_some()
214 }
215
216 pub fn keys<K: MarketKey>(&self) -> impl Iterator<Item = &K> {
218 self.stores
219 .get(&TypeId::of::<K>())
220 .map(|s| {
221 s.as_any()
222 .downcast_ref::<HashMap<K, K::Value>>()
223 .expect("store type is pinned by the TypeId key")
224 .keys()
225 })
226 .into_iter()
227 .flatten()
228 }
229
230 pub fn len(&self) -> usize {
232 self.stores.values().map(|s| s.len()).sum()
233 }
234
235 pub fn is_empty(&self) -> bool {
236 self.len() == 0
237 }
238}
239
240#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
244#[serde(rename_all = "lowercase")]
245pub enum BumpMode {
246 Relative,
249 Absolute,
251}
252
253#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
255#[serde(rename_all = "lowercase")]
256pub enum RiskFactor {
257 Spot,
258 #[serde(alias = "volatility")]
259 Vol,
260 #[serde(alias = "rates")]
261 Rate,
262 Time,
264}
265
266#[derive(Debug, Clone, Deserialize)]
270pub struct Shock {
271 pub factor: RiskFactor,
272 pub mode: BumpMode,
273 pub size: f64,
274 pub underlying: Option<String>,
278 pub tenors: Option<Vec<f64>>,
283 pub shifts: Option<Vec<f64>>,
286}
287
288impl Shock {
289 pub fn applies_to(&self, symbol: &str) -> bool {
291 match self.underlying.as_deref() {
292 None | Some("*") => true,
293 Some(name) => name.eq_ignore_ascii_case(symbol),
294 }
295 }
296}
297
298impl Market {
299 pub fn bumped(&self, shocks: &[Shock]) -> Result<Market> {
306 let mut bumped = self.clone();
307 for shock in shocks {
308 if shock.tenors.is_some() && shock.factor != RiskFactor::Rate {
309 return Err(RustyQLibError::invalid_input(
310 "shock",
311 "tenors are only supported on rate shocks",
312 ));
313 }
314 if shock.shifts.is_some() && shock.tenors.is_none() {
315 return Err(RustyQLibError::invalid_input(
316 "shock",
317 "shifts require tenors",
318 ));
319 }
320 match shock.factor {
321 RiskFactor::Spot => {
322 let keys: Vec<Spot> = bumped
323 .keys::<Spot>()
324 .filter(|k| shock.applies_to(&k.0))
325 .cloned()
326 .collect();
327 for key in keys {
328 let quote = *bumped.get(&key)?;
331 let shifted = match shock.mode {
332 BumpMode::Relative => quote.scaled(1.0 + shock.size),
333 BumpMode::Absolute => quote.shifted(shock.size),
334 };
335 bumped.insert(key, shifted);
336 }
337 }
338 RiskFactor::Vol => {
339 let shift = match shock.mode {
340 BumpMode::Relative => VolShift::ParallelRelative(shock.size),
341 BumpMode::Absolute => VolShift::ParallelAbsolute(shock.size),
342 };
343 let keys: Vec<Vol> = bumped
344 .keys::<Vol>()
345 .filter(|k| shock.applies_to(&k.0))
346 .cloned()
347 .collect();
348 for key in keys {
349 let surface = bumped.get(&key)?.bumped(shift)?;
350 bumped.insert(key, Arc::new(surface));
351 }
352 }
353 RiskFactor::Rate => {
354 let shift = match (&shock.tenors, shock.mode) {
355 (Some(_), BumpMode::Relative) => {
356 return Err(RustyQLibError::invalid_input(
357 "shock",
358 "key-rate rate shocks must be absolute",
359 ));
360 }
361 (Some(tenors), BumpMode::Absolute) => RateShift::KeyRateAbsolute {
362 tenors: tenors.clone(),
363 shifts: shock
364 .shifts
365 .clone()
366 .unwrap_or_else(|| vec![shock.size; tenors.len()]),
367 },
368 (None, BumpMode::Relative) => RateShift::ParallelRelative(shock.size),
369 (None, BumpMode::Absolute) => RateShift::ParallelAbsolute(shock.size),
370 };
371 let keys: Vec<Discount> = bumped.keys::<Discount>().cloned().collect();
372 for key in keys {
373 let curve = bumped.get(&key)?.bumped(&shift)?;
374 bumped.insert(key, Arc::new(curve));
375 }
376 }
377 RiskFactor::Time => {
378 if shock.mode == BumpMode::Relative {
379 return Err(RustyQLibError::invalid_input(
380 "shock",
381 "time shocks are absolute horizons in days; relative makes no sense",
382 ));
383 }
384 bumped.valuation_date += chrono::Duration::days(shock.size.round() as i64);
385 }
386 }
387 }
388 Ok(bumped)
389 }
390}
391
392#[cfg(test)]
393mod tests {
394 use super::*;
395 use crate::core::daycount::DayCountConvention;
396 use crate::core::curves::Compounding;
397
398 fn date() -> NaiveDate {
399 NaiveDate::from_ymd_opt(2026, 1, 5).unwrap()
400 }
401
402 fn shock(factor: RiskFactor, mode: BumpMode, size: f64) -> Shock {
403 Shock { factor, mode, size, underlying: None, tenors: None, shifts: None }
404 }
405
406 fn sample_market() -> Market {
407 let curve = YieldCurve::flat(0.03, date(), DayCountConvention::Act365, Compounding::Continuous)
408 .expect("curve must build");
409 let surf = VolSurface::flat(0.25, date(), DayCountConvention::Act365).expect("surface");
410 Market::new(date())
411 .with(Spot("ACME".into()), Quote::new(100.0))
412 .with(Spot("ZENO".into()), Quote::new(50.0))
413 .with(Vol("ACME".into()), Arc::new(surf))
414 .with(Discount("USD".into()), Arc::new(curve))
415 }
416
417 #[test]
418 fn typed_roundtrip_per_key() {
419 let market = sample_market();
420 assert_eq!(market.get(&Spot("ACME".into())).unwrap().value(), 100.0);
421 assert_eq!(market.get(&Spot("ZENO".into())).unwrap().value(), 50.0);
422 let sigma = market.get(&Vol("ACME".into())).unwrap().vol(100.0, 100.0, 1.0);
424 assert!((sigma - 0.25).abs() < 1e-12);
425 assert_eq!(market.len(), 4);
426 }
427
428 #[test]
429 fn same_name_under_different_key_types_does_not_collide() {
430 let market = sample_market();
431 assert!(market.contains(&Spot("ACME".into())));
433 assert!(market.contains(&Vol("ACME".into())));
434 assert!(!market.contains(&Vol("ZENO".into())), "no surface stored for ZENO");
435 }
436
437 #[test]
438 fn missing_data_is_a_typed_error_naming_the_key() {
439 let market = sample_market();
440 match market.get(&Vol("ZENO".into())) {
441 Err(RustyQLibError::MissingMarketData { key }) => {
442 assert!(key.contains("Vol") && key.contains("ZENO"), "got key `{key}`");
443 }
444 other => panic!("expected MissingMarketData, got {other:?}"),
445 }
446 }
447
448 #[test]
449 fn insert_replaces_existing_entry() {
450 let mut market = sample_market();
451 market.insert(Spot("ACME".into()), Quote::new(120.0));
452 assert_eq!(market.get(&Spot("ACME".into())).unwrap().value(), 120.0);
453 assert_eq!(market.len(), 4, "replace must not grow the store");
454 }
455
456 #[test]
457 fn user_defined_key_types_extend_the_market() {
458 #[derive(Debug, Clone, PartialEq, Eq, Hash)]
461 struct Correlation(String, String);
462 impl MarketKey for Correlation {
463 type Value = f64;
464 }
465
466 let market =
467 sample_market().with(Correlation("ACME".into(), "ZENO".into()), 0.65);
468 let rho = market.get(&Correlation("ACME".into(), "ZENO".into())).unwrap();
469 assert_eq!(*rho, 0.65);
470 assert!(market.get(&Correlation("ACME".into(), "OTHER".into())).is_err());
471 }
472
473 #[test]
474 fn bumped_market_delegates_to_each_factor_and_honours_filters() {
475 let market = sample_market();
476 let shocks = [
477 Shock {
478 factor: RiskFactor::Spot,
479 mode: BumpMode::Relative,
480 size: -0.20,
481 underlying: Some("ACME".into()),
482 tenors: None,
483 shifts: None,
484 },
485 shock(RiskFactor::Vol, BumpMode::Absolute, 0.05),
486 shock(RiskFactor::Rate, BumpMode::Absolute, 0.01),
487 ];
488 let bumped = market.bumped(&shocks).unwrap();
489 assert!((bumped.get(&Spot("ACME".into())).unwrap().value() - 80.0).abs() < 1e-12);
490 assert!((bumped.get(&Spot("ZENO".into())).unwrap().value() - 50.0).abs() < 1e-12);
492 let vol = bumped.get(&Vol("ACME".into())).unwrap().vol(100.0, 100.0, 1.0);
493 assert!((vol - 0.30).abs() < 1e-12);
494 let zero = bumped
495 .get(&Discount("USD".into()))
496 .unwrap()
497 .zero_rate_with(1.0, Compounding::Continuous);
498 assert!((zero - 0.04).abs() < 1e-12);
499 assert!((market.get(&Spot("ACME".into())).unwrap().value() - 100.0).abs() < 1e-12);
501 }
502
503 #[test]
504 fn key_rate_shock_moves_only_the_listed_part_of_the_curve() {
505 let market = sample_market();
506 let key_rate = [Shock {
507 factor: RiskFactor::Rate,
508 mode: BumpMode::Absolute,
509 size: 0.01,
510 underlying: None,
511 tenors: Some(vec![1.0, 2.0]),
512 shifts: None,
513 }];
514 let bumped = market.bumped(&key_rate).unwrap();
515 let curve = bumped.get(&Discount("USD".into())).unwrap();
516 assert!((curve.zero_rate_with(1.5, Compounding::Continuous) - 0.04).abs() < 1e-12);
518 assert!((curve.zero_rate_with(0.5, Compounding::Continuous) - 0.03).abs() < 1e-12);
519 assert!((curve.zero_rate_with(3.0, Compounding::Continuous) - 0.03).abs() < 1e-12);
520 let mut bad = key_rate[0].clone();
522 bad.factor = RiskFactor::Vol;
523 assert!(market.bumped(std::slice::from_ref(&bad)).is_err());
524 let mut orphan = shock(RiskFactor::Rate, BumpMode::Absolute, 0.01);
525 orphan.shifts = Some(vec![0.01]);
526 assert!(market.bumped(std::slice::from_ref(&orphan)).is_err());
527 let mut relative = key_rate[0].clone();
529 relative.mode = BumpMode::Relative;
530 assert!(market.bumped(std::slice::from_ref(&relative)).is_err());
531 }
532
533 #[test]
534 fn spot_bumps_preserve_quote_shape_and_depth_stores_under_its_own_key() {
535 use crate::core::depth::{DepthLevel, MarketDepth};
536 let book = MarketDepth::new(
537 vec![DepthLevel { price: 99.0, size: 100.0 }],
538 vec![DepthLevel { price: 101.0, size: 150.0 }],
539 )
540 .unwrap();
541 let market = sample_market()
542 .with(Spot("BOOK".into()), Quote::from_bid_ask(99.0, 101.0).unwrap())
543 .with(Depth("BOOK".into()), Arc::new(book));
544 let crash = market
545 .bumped(&[Shock {
546 factor: RiskFactor::Spot,
547 mode: BumpMode::Relative,
548 size: -0.20,
549 underlying: Some("BOOK".into()),
550 tenors: None,
551 shifts: None,
552 }])
553 .unwrap();
554 let quote = crash.get(&Spot("BOOK".into())).unwrap();
556 assert!((quote.mid() - 80.0).abs() < 1e-12);
557 assert!((quote.bid().unwrap() - 99.0 * 0.8).abs() < 1e-12);
558 assert!((quote.ask().unwrap() - 101.0 * 0.8).abs() < 1e-12);
559 let depth = crash.get(&Depth("BOOK".into())).unwrap();
561 assert_eq!(depth.best_ask().unwrap().price, 101.0);
562 assert_eq!(depth.to_quote().unwrap().mid(), 100.0);
564 }
565
566 #[test]
567 fn time_shocks_advance_the_date_and_must_be_absolute() {
568 let market = sample_market();
569 let week = [shock(RiskFactor::Time, BumpMode::Absolute, 7.0)];
570 let later = market.bumped(&week).unwrap();
571 assert_eq!(later.valuation_date(), NaiveDate::from_ymd_opt(2026, 1, 12).unwrap());
572 let bad = [shock(RiskFactor::Time, BumpMode::Relative, 0.1)];
573 assert!(market.bumped(&bad).is_err());
574 }
575
576 #[test]
577 fn cloned_market_is_independent() {
578 let market = sample_market();
579 let mut bumped = market.clone();
580 bumped.insert(Spot("ACME".into()), Quote::new(80.0));
581 assert_eq!(bumped.get(&Spot("ACME".into())).unwrap().value(), 80.0);
582 assert_eq!(
583 market.get(&Spot("ACME".into())).unwrap().value(),
584 100.0,
585 "clone must not alias the original"
586 );
587 }
588}