Skip to main content

rustyqlib/core/
market.rs

1//! The typed, open-ended market data container: the **pricing context**.
2//!
3//! A [`Market`] is not a struct of fields — it is a type-safe dictionary.
4//! Each kind of market datum is addressed by its own **key type** (a
5//! [`MarketKey`] implementor), and the key pins the value type at compile
6//! time: looking up [`Spot`] returns a [`Quote`], looking up [`Vol`]
7//! returns a shared [`Arc<VolSurface>`]. Heavy objects (surfaces,
8//! curves) live behind `Arc`, so cloning a market for a scenario shares
9//! everything untouched. Storage is open-ended — a new instrument that
10//! needs a datum nobody anticipated (a correlation term structure, an FX
11//! vol, a forecast curve) defines a new key type and inserts it; no core
12//! code changes.
13//!
14//! ```text
15//! Spot("ACME")     -> Quote(100.0)
16//! Vol("ACME")      -> VolSurface {...}
17//! Discount("USD")  -> YieldCurve {...}
18//! ```
19//!
20//! The only runtime failure mode is *missing data*
21//! ([`RustyQLibError::MissingMarketData`]) — a wrong-type lookup cannot be
22//! written, because the key's [`MarketKey::Value`] fixes the return type.
23//!
24//! Instruments consume a market through their own wiring (e.g.
25//! [`EquityOption::npv_in`](crate::equity::vanilla_option::EquityOption)),
26//! keeping contract terms (immutable) separate from market state (shared,
27//! bumped, repriced).
28
29use 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
44// ── The key protocol ────────────────────────────────────────────────────
45
46/// A typed address for one piece of market data.
47///
48/// Implementing this trait is the extension point of the whole design:
49/// the key names *what* is identified (symbol, currency, pair, ...) and
50/// its associated `Value` fixes *which type* a lookup returns. Anyone —
51/// including downstream crates and tests — can add key types without
52/// touching [`Market`].
53///
54/// ```ignore
55/// #[derive(Debug, Clone, PartialEq, Eq, Hash)]
56/// struct Correlation(String, String);
57/// impl MarketKey for Correlation { type Value = CorrelationTermStructure; }
58/// ```
59pub trait MarketKey: Clone + Eq + Hash + Debug + Send + Sync + 'static {
60    /// The type a lookup with this key returns. `Clone` so a whole market
61    /// can be cloned (the basis of scenario bumping: copy, then perturb).
62    type Value: Clone + Debug + Send + Sync + 'static;
63}
64
65// ── Built-in keys ───────────────────────────────────────────────────────
66
67/// Spot quote of one underlying, by symbol.
68#[derive(Debug, Clone, PartialEq, Eq, Hash)]
69pub struct Spot(pub String);
70impl MarketKey for Spot {
71    type Value = Quote;
72}
73
74/// Implied volatility surface of one underlying, by symbol.
75///
76/// Stored behind [`Arc`]: cloning a market (the first step of every
77/// scenario bump) shares untouched surfaces instead of deep-copying
78/// them, and rebinding an instrument to a market is a refcount bump.
79/// Bumps are copy-on-write — a shocked surface is a **new** `Arc`, so
80/// snapshots stay immutable.
81#[derive(Debug, Clone, PartialEq, Eq, Hash)]
82pub struct Vol(pub String);
83impl MarketKey for Vol {
84    type Value = Arc<VolSurface>;
85}
86
87/// Discount curve, by currency code (e.g. `"USD"`). `Arc`-shared and
88/// copy-on-write, like [`Vol`].
89#[derive(Debug, Clone, PartialEq, Eq, Hash)]
90pub struct Discount(pub String);
91impl MarketKey for Discount {
92    type Value = Arc<YieldCurve>;
93}
94
95/// Limit-order-book depth of one underlying, by symbol — the
96/// **execution-layer** observable ([`MarketDepth`]), kept separate from
97/// the pricing mark ([`Spot`]). A trading adapter publishes both and
98/// collapses the book into the `Spot` quote at the snapshot boundary
99/// ([`MarketDepth::to_quote`]); pricing engines never walk a ladder.
100/// Scenario shocks bump `Spot` and leave depth untouched.
101#[derive(Debug, Clone, PartialEq, Eq, Hash)]
102pub struct Depth(pub String);
103impl MarketKey for Depth {
104    type Value = Arc<MarketDepth>;
105}
106
107/// Currency assumed when an instrument does not state one, so that such
108/// instruments and hand-built markets agree on the same [`Discount`] key.
109pub const DEFAULT_CURRENCY: &str = "USD";
110
111// ── Type-erased storage ─────────────────────────────────────────────────
112//
113// One inner `HashMap<K, K::Value>` per key type, held behind a small
114// object-safe trait so the outer map can store them uniformly and clone
115// the whole market. The downcasts below are the only ones in the design,
116// and they cannot fail: the outer map is keyed by `TypeId::of::<K>()`, so
117// the entry for that id *is* a `HashMap<K, K::Value>` by construction.
118
119trait 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
141// ── The container ───────────────────────────────────────────────────────
142
143/// A market snapshot: valuation date plus a typed store of market data,
144/// shared across a book. See the [module docs](self) for the design.
145pub 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    /// Add or replace the datum at `key`.
178    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    /// Chainable [`insert`](Self::insert), for building markets by hand.
189    pub fn with<K: MarketKey>(mut self, key: K, value: K::Value) -> Self {
190        self.insert(key, value);
191        self
192    }
193
194    /// The datum at `key`, or `None` when absent.
195    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    /// The datum at `key`, or a typed
205    /// [`MissingMarketData`](RustyQLibError::MissingMarketData) error naming
206    /// the key (e.g. `Spot("ACME")`).
207    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    /// Every key of type `K` in the store (in no particular order).
217    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    /// Total number of stored data across all key types.
231    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// ── Scenarios: the shock vocabulary ─────────────────────────────────────
241
242/// How a shock size is applied.
243#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
244#[serde(rename_all = "lowercase")]
245pub enum BumpMode {
246    /// `size` scales the current level (spot x `(1+size)`, vols/zero rates
247    /// scaled by `(1+size)`).
248    Relative,
249    /// `size` is added to the current level (for `time`: days).
250    Absolute,
251}
252
253/// The risk factor a shock applies to.
254#[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    /// Calendar decay, in days (absolute only).
263    Time,
264}
265
266/// One shock on one factor. The *how* of each bump lives with the bumped
267/// type ([`VolSurface::bumped`], [`YieldCurve::bumped`]); a shock only
268/// names the factor, the sizing and an optional underlying filter.
269#[derive(Debug, Clone, Deserialize)]
270pub struct Shock {
271    pub factor: RiskFactor,
272    pub mode: BumpMode,
273    pub size: f64,
274    /// Restrict a spot/vol shock to one underlying symbol (`None` / `"*"`
275    /// = every one). Rate and time shocks are market-wide; the filter is
276    /// ignored for them.
277    pub underlying: Option<String>,
278    /// Key-rate bump tenors in year fractions, strictly increasing —
279    /// restricts a `rate` shock to those parts of the curve (see
280    /// [`RateShift::KeyRateAbsolute`]). `None` bumps the whole curve in
281    /// parallel. Absolute mode only; rejected on other factors.
282    pub tenors: Option<Vec<f64>>,
283    /// Per-tenor sizes for a key-rate shock, same length as `tenors`;
284    /// omitted means `size` at every tenor.
285    pub shifts: Option<Vec<f64>>,
286}
287
288impl Shock {
289    /// Whether this shock applies to `symbol` (case-insensitive filter).
290    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    /// Apply a scenario, returning the bumped market. Shocks apply **in
300    /// order**, each to the market produced by the previous one; each
301    /// datum performs its own bump ([`VolSurface::bumped`],
302    /// [`YieldCurve::bumped`]). Spot/vol shocks honour the `underlying`
303    /// filter; rate shocks hit every discount curve; time shocks advance
304    /// the valuation date and must be absolute (in days).
305    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                        // the quote bumps itself: all levels move, the
329                        // shape (top-of-book, sizes) is preserved
330                        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        // the Vol lookup statically returns a VolSurface — vol() is callable
423        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        // "ACME" exists as a Spot key and a Vol key; each resolves its own type
432        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        // the open-world guarantee: a key type defined OUTSIDE core (here,
459        // in a test) stores and retrieves its own value type
460        #[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        // the filter spares ZENO
491        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        // the base market is untouched
500        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        // full bump inside [1y, 2y], untouched at the adjacent pillars
517        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        // tenors on a non-rate factor and shifts without tenors are rejected
521        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        // and a relative key-rate shock is refused
528        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        // the bump scaled every level and kept the top-of-book shape
555        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        // depth is an execution observable: scenarios leave it untouched
560        let depth = crash.get(&Depth("BOOK".into())).unwrap();
561        assert_eq!(depth.best_ask().unwrap().price, 101.0);
562        // and it collapses into a pricing quote at the snapshot boundary
563        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}