1use 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 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 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 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 pub fn npv_in(&self, market: &Market) -> Result<f64, RustyQLibError> {
98 self.with_market(market)?.try_npv()
99 }
100}
101
102impl EquityPortfolio {
103 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 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 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 #[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 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 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 #[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], ),
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 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 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 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 assert!((opt.npv_in(&market).unwrap() - opt.npv()).abs() < 1e-12);
350 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 #[test]
367 fn portfolio_snapshot_covers_every_underlying_and_reprices_exactly() {
368 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 let values = book.position_values_in(&market).unwrap();
382 let sum: f64 = values.iter().sum();
383 assert!((sum - under).abs() < 1e-12);
384 }
385}