Skip to main content

rustyqlib/equity/
market.rs

1//! Binding equity instruments to a shared [`Market`]: the **pricing
2//! context**, separated from contracts.
3//!
4//! Instruments constructed from JSON or the builder embed the market they
5//! were built with — convenient for a stateless pricing service, but a
6//! desk wants the other shape too: one market snapshot shared across a
7//! book, bumped once, and the whole book repriced under it:
8//!
9//! ```text
10//! let market  = book.snapshot_market();              // typed store: Spot/Vol/Discount
11//! let crash   = market.bumped(&scenario.shocks)?;    // -20% spot, +10 vol pts, ...
12//! let pnl     = book.npv_in(&crash)? - book.npv_in(&market)?;
13//! ```
14//!
15//! The store itself ([`core::market`](crate::core::market)) holds real
16//! objects — [`Quote`](crate::core::quotes::Quote) spots,
17//! [`VolSurface`](crate::core::vols::VolSurface)s,
18//! [`YieldCurve`](crate::core::curves::YieldCurve)s — keyed by symbol and
19//! currency, and each object owns its own bump semantics. This module is
20//! the equity wiring between the store and the instrument's **bound**
21//! market ([`EquityMarketData`](crate::equity::vanilla_option::EquityMarketData),
22//! the `market` field engines read): snapshotting a bound market into a
23//! store, and rebinding an instrument to a store for repricing on its own
24//! engine (full revaluation; Monte Carlo keeps its seed, so bumped-minus-
25//! base differences are free of sampling noise). The TOML stress runner
26//! ([`risk::stress`](crate::risk::stress)) is a consumer of these
27//! primitives.
28//!
29//! Dividend yield, borrow cost and discrete cash dividends live on the
30//! bound [`EquityMarketData`](crate::equity::vanilla_option::EquityMarketData)
31//! but are not yet keyed in the store; they gain keys when a consumer
32//! needs to bump them.
33
34use crate::core::errors::RustyQLibError;
35use crate::core::market::{Discount, Market, Spot, Vol};
36use crate::core::traits::Instrument;
37use crate::equity::portfolio::EquityPortfolio;
38use crate::equity::vanilla_option::EquityOption;
39
40impl EquityOption {
41    /// Snapshot this option's embedded market objects into a typed
42    /// [`Market`] anchored at the option's valuation date. Repricing under
43    /// the unmodified snapshot reproduces `npv()` exactly.
44    pub fn snapshot_market(&self) -> Market {
45        Market::new(self.market.valuation_date)
46            .with(Spot(self.base.symbol.clone()), self.market.spot.clone())
47            .with(Vol(self.base.symbol.clone()), self.market.vol_surface.clone())
48            .with(
49                Discount(self.base.currency_code().to_string()),
50                self.market.discount_curve.clone(),
51            )
52    }
53
54    /// This contract rebound to `market`: spot, vol surface and discount
55    /// curve are taken from the store (by symbol / currency code) and the
56    /// valuation date from the snapshot; contract terms and engine are
57    /// unchanged. Errors name the missing key when the market lacks data
58    /// for this option.
59    ///
60    /// The model moves with the market where it must: a Heston model's
61    /// parameters follow the surface's parallel shift (measured at this
62    /// contract's strike and maturity) via
63    /// [`Model::with_vol_shift`](crate::equity::utils::Model::with_vol_shift),
64    /// so vol scenarios reach Heston-priced positions without
65    /// recalibration.
66    ///
67    /// The market's objects are expected to be anchored at its valuation
68    /// date (as [`snapshot_market`](Self::snapshot_market) guarantees).
69    pub fn with_market(&self, market: &Market) -> Result<EquityOption, RustyQLibError> {
70        let spot = market.get(&Spot(self.base.symbol.clone()))?;
71        let vol = market.get(&Vol(self.base.symbol.clone()))?;
72        let curve = market.get(&Discount(self.base.currency_code().to_string()))?;
73        let mut option = self.clone();
74        option.market.spot = spot.clone();
75        option.market.vol_surface = vol.clone();
76        option.market.discount_curve = curve.clone();
77        option.market.valuation_date = market.valuation_date();
78        if option.model.is_heston() {
79            let t = option.time_to_maturity();
80            if t > 0.0 {
81                // the surface's parallel shift at this contract's anchor
82                // (strike, spot-as-forward-proxy, maturity)
83                let k = option.base.strike_price;
84                let f = option.market.spot.value();
85                let shift = option.market.vol_surface.vol(k, f, t)
86                    - self.market.vol_surface.vol(k, f, t);
87                if shift != 0.0 {
88                    option.model = option.model.with_vol_shift(shift);
89                }
90            }
91        }
92        Ok(option)
93    }
94
95    /// Value under a typed market snapshot: rebind, then price on the
96    /// option's own engine through the ordinary `npv` path.
97    pub fn npv_in(&self, market: &Market) -> Result<f64, RustyQLibError> {
98        self.with_market(market)?.try_npv()
99    }
100}
101
102impl EquityPortfolio {
103    /// Snapshot the market embedded in a book into a typed [`Market`]:
104    /// valuation date and discount curve from the first position, one
105    /// spot/vol entry per underlying (first position on each symbol wins).
106    pub fn snapshot_market(&self) -> Market {
107        match self.positions.first() {
108            Some(first) => {
109                let mut market = first.option.snapshot_market();
110                for position in &self.positions[1..] {
111                    let option = &position.option;
112                    if !market.contains(&Spot(option.base.symbol.clone())) {
113                        market
114                            .insert(Spot(option.base.symbol.clone()), option.market.spot.clone());
115                        market.insert(
116                            Vol(option.base.symbol.clone()),
117                            option.market.vol_surface.clone(),
118                        );
119                    }
120                }
121                market
122            }
123            None => Market::new(chrono::Local::now().date_naive()),
124        }
125    }
126
127    /// Book value under a typed market snapshot (quantity-weighted).
128    pub fn npv_in(&self, market: &Market) -> Result<f64, RustyQLibError> {
129        let mut total = 0.0;
130        for position in &self.positions {
131            total += position.quantity * position.option.npv_in(market)?;
132        }
133        Ok(total)
134    }
135
136    /// Per-position values under a typed market snapshot, in book order.
137    pub fn position_values_in(
138        &self,
139        market: &Market,
140    ) -> Result<Vec<f64>, RustyQLibError> {
141        self.positions
142            .iter()
143            .map(|p| p.option.npv_in(market).map(|v| p.quantity * v))
144            .collect()
145    }
146}
147
148#[cfg(test)]
149mod tests {
150    use super::*;
151    use crate::core::market::{BumpMode, RiskFactor, Shock};
152    use crate::core::trade::PutOrCall;
153    use crate::equity::builder::EquityOptionBuilder;
154    use crate::equity::utils::{Engine, Model};
155    use chrono::NaiveDate;
156
157    fn option(symbol: &str, strike: f64, engine: Engine) -> EquityOption {
158        EquityOptionBuilder::new()
159            .symbol(symbol)
160            .spot(100.0)
161            .strike(strike)
162            .flat_vol(0.25)
163            .flat_rate(0.03)
164            .valuation_date(NaiveDate::from_ymd_opt(2026, 1, 5).unwrap())
165            .maturity_date(NaiveDate::from_ymd_opt(2027, 1, 4).unwrap())
166            .vanilla(PutOrCall::Call)
167            .engine(engine)
168            .build()
169            .expect("option must build")
170    }
171
172    fn shock(factor: RiskFactor, mode: BumpMode, size: f64) -> Shock {
173        Shock { factor, mode, size, underlying: None, tenors: None, shifts: None }
174    }
175
176    // ── snapshot / rebind parity ────────────────────────────────────
177
178    #[test]
179    fn snapshot_market_reproduces_npv_on_every_engine() {
180        for engine in [
181            Engine::BlackScholes,
182            Engine::Binomial,
183            Engine::FiniteDifference,
184            Engine::MonteCarlo,
185        ] {
186            let label = format!("{engine:?}");
187            let opt = option("ACME", 100.0, engine);
188            let market = opt.snapshot_market();
189            let rebound = opt.npv_in(&market).expect("snapshot must price");
190            let direct = opt.npv();
191            assert!(
192                (rebound - direct).abs() < 1e-12,
193                "{label}: rebound {rebound} direct {direct}"
194            );
195        }
196    }
197
198    #[test]
199    fn rebinding_to_a_moved_market_prices_the_new_levels() {
200        let opt = option("ACME", 100.0, Engine::BlackScholes);
201        let mut market = opt.snapshot_market();
202        market.insert(Spot("ACME".to_string()), crate::core::quotes::Quote::new(110.0));
203        let moved = opt.npv_in(&market).unwrap();
204        // reference: the same contract built directly at the new spot
205        let rebuilt = EquityOptionBuilder::new()
206            .symbol("ACME")
207            .spot(110.0)
208            .strike(100.0)
209            .flat_vol(0.25)
210            .flat_rate(0.03)
211            .valuation_date(NaiveDate::from_ymd_opt(2026, 1, 5).unwrap())
212            .maturity_date(NaiveDate::from_ymd_opt(2027, 1, 4).unwrap())
213            .vanilla(PutOrCall::Call)
214            .engine(Engine::BlackScholes)
215            .build()
216            .unwrap();
217        assert!((moved - rebuilt.npv()).abs() < 1e-12, "moved {moved} rebuilt {}", rebuilt.npv());
218        // the original instrument is untouched
219        assert_eq!(opt.market.spot.value(), 100.0);
220    }
221
222    #[test]
223    fn npv_in_missing_symbol_names_the_key() {
224        let opt = option("ACME", 100.0, Engine::BlackScholes);
225        let empty = Market::new(NaiveDate::from_ymd_opt(2026, 1, 5).unwrap());
226        match opt.npv_in(&empty) {
227            Err(RustyQLibError::MissingMarketData { key }) => {
228                assert!(key.contains("Spot") && key.contains("ACME"), "got key `{key}`");
229            }
230            other => panic!("expected MissingMarketData, got {other:?}"),
231        }
232    }
233
234    // ── bumped markets against the price_with reference ────────────
235    //
236    // While the per-engine `price_with` scalar path still exists, it is
237    // the independent reference implementation for these parities: a
238    // bumped market repriced through `npv_in` must agree with the same
239    // shifts applied as scalar deltas.
240
241    #[test]
242    fn spot_vol_and_rate_bumps_match_price_with_on_every_engine() {
243        for engine in [
244            Engine::BlackScholes,
245            Engine::Binomial,
246            Engine::FiniteDifference,
247            Engine::MonteCarlo,
248        ] {
249            let label = format!("{engine:?}");
250            let opt = option("ACME", 100.0, engine);
251            let market = opt.snapshot_market();
252            let cases: [(&str, Shock, [f64; 4]); 4] = [
253                (
254                    "spot -20%",
255                    shock(RiskFactor::Spot, BumpMode::Relative, -0.20),
256                    [-20.0, 0.0, 0.0, 0.0],
257                ),
258                (
259                    "vol +10pts",
260                    shock(RiskFactor::Vol, BumpMode::Absolute, 0.10),
261                    [0.0, 0.10, 0.0, 0.0],
262                ),
263                (
264                    "rate +100bp",
265                    shock(RiskFactor::Rate, BumpMode::Absolute, 0.01),
266                    [0.0, 0.0, 0.01, 0.0],
267                ),
268                (
269                    "vol +10% relative",
270                    shock(RiskFactor::Vol, BumpMode::Relative, 0.10),
271                    [0.0, 0.025, 0.0, 0.0], // 0.25 * 10%
272                ),
273            ];
274            for (name, s, [ds, dv, dr, dt]) in cases {
275                let bumped = market.bumped(std::slice::from_ref(&s)).unwrap();
276                let via_market = opt.npv_in(&bumped).unwrap();
277                let via_deltas = opt.price_with(ds, dv, dr, dt);
278                assert!(
279                    (via_market - via_deltas).abs() < 1e-10,
280                    "{label} {name}: market {via_market} deltas {via_deltas}"
281                );
282            }
283        }
284    }
285
286    #[test]
287    fn time_bump_advances_the_valuation_date_and_decays_value() {
288        let opt = option("ACME", 100.0, Engine::BlackScholes);
289        let market = opt.snapshot_market();
290        let month = shock(RiskFactor::Time, BumpMode::Absolute, 30.0);
291        let later = market.bumped(std::slice::from_ref(&month)).unwrap();
292        assert_eq!(later.valuation_date(), NaiveDate::from_ymd_opt(2026, 2, 4).unwrap());
293        let aged = opt.npv_in(&later).unwrap();
294        let expected = opt.price_with(0.0, 0.0, 0.0, 30.0 / 365.0);
295        assert!((aged - expected).abs() < 1e-10, "aged {aged} expected {expected}");
296        assert!(aged < opt.npv(), "a long option must decay");
297        // relative time shocks are rejected
298        let bad = shock(RiskFactor::Time, BumpMode::Relative, 0.1);
299        assert!(market.bumped(std::slice::from_ref(&bad)).is_err());
300    }
301
302    #[test]
303    fn shocks_apply_in_order_and_filters_spare_other_names() {
304        let acme = option("ACME", 100.0, Engine::BlackScholes);
305        let zeno = option("ZENO", 100.0, Engine::FiniteDifference);
306        let market = acme
307            .snapshot_market()
308            .with(Spot("ZENO".to_string()), zeno.market.spot.clone())
309            .with(Vol("ZENO".to_string()), zeno.market.vol_surface.clone());
310        // -10% then +2 absolute, ACME only: 100 * 0.9 + 2 = 92
311        let shocks = [
312            Shock {
313                factor: RiskFactor::Spot,
314                mode: BumpMode::Relative,
315                size: -0.10,
316                underlying: Some("ACME".to_string()),
317                tenors: None,
318                shifts: None,
319            },
320            Shock {
321                factor: RiskFactor::Spot,
322                mode: BumpMode::Absolute,
323                size: 2.0,
324                underlying: Some("ACME".to_string()),
325                tenors: None,
326                shifts: None,
327            },
328        ];
329        let bumped = market.bumped(&shocks).unwrap();
330        assert!((bumped.get(&Spot("ACME".to_string())).unwrap().value() - 92.0).abs() < 1e-12);
331        // ZENO untouched under the same bumped market
332        assert!((zeno.npv_in(&bumped).unwrap() - zeno.npv()).abs() < 1e-10);
333        assert!((acme.npv_in(&bumped).unwrap() - acme.price_with(-8.0, 0.0, 0.0, 0.0)).abs() < 1e-10);
334    }
335
336    #[test]
337    fn heston_model_follows_the_surface_shift() {
338        use crate::equity::heston::HestonParams;
339        let mut opt = option("ACME", 100.0, Engine::BlackScholes);
340        opt.model = Model::Heston(HestonParams {
341            v0: 0.0625,
342            kappa: 1.5,
343            theta: 0.0625,
344            vol_of_vol: 0.4,
345            rho: -0.6,
346        });
347        let market = opt.snapshot_market();
348        // base parity first
349        assert!((opt.npv_in(&market).unwrap() - opt.npv()).abs() < 1e-12);
350        // a +2pt vol scenario must reach the Heston params (the reference
351        // scalar path shifts sqrt(v0)/sqrt(theta) — with_market must agree)
352        let bumped = market
353            .bumped(&[shock(RiskFactor::Vol, BumpMode::Absolute, 0.02)])
354            .unwrap();
355        let via_market = opt.npv_in(&bumped).unwrap();
356        let via_deltas = opt.price_with(0.0, 0.02, 0.0, 0.0);
357        assert!(
358            (via_market - via_deltas).abs() < 1e-10,
359            "market {via_market} deltas {via_deltas}"
360        );
361        assert!(via_market > opt.npv(), "long vega: higher vol must raise the value");
362    }
363
364    // ── portfolio-level ─────────────────────────────────────────────
365
366    #[test]
367    fn portfolio_snapshot_covers_every_underlying_and_reprices_exactly() {
368        // EquityPortfolio books are single-underlying; multi-underlying
369        // repricing is exercised option-by-option against one Market
370        let mut book = EquityPortfolio::new();
371        book.add(option("ACME", 95.0, Engine::BlackScholes), 10.0);
372        book.add(option("ACME", 105.0, Engine::Binomial), -5.0);
373        book.add(option("ACME", 100.0, Engine::FiniteDifference), 3.0);
374        let market = book.snapshot_market();
375        assert!(market.contains(&Spot("ACME".to_string())));
376        assert!(market.contains(&Vol("ACME".to_string())));
377        let direct: f64 = book.positions.iter().map(|p| p.quantity * p.option.npv()).sum();
378        let under = book.npv_in(&market).unwrap();
379        assert!((under - direct).abs() < 1e-10, "under {under} direct {direct}");
380        // per-position values sum to the book value
381        let values = book.position_values_in(&market).unwrap();
382        let sum: f64 = values.iter().sum();
383        assert!((sum - under).abs() < 1e-12);
384    }
385}