1use std::{cell::RefCell, collections::HashMap, fmt::Debug, rc::Rc};
19
20use ahash::AHashMap;
21use nautilus_core::UnixNanos;
22use nautilus_model::{
23 data::greeks::{
24 GreeksData, OptionGreekValues, PortfolioGreeks, black_scholes_greeks, imply_vol_and_greeks,
25 refine_vol_and_greeks,
26 },
27 enums::{AssetClass, InstrumentClass, OptionKind, PositionSide, PriceType},
28 identifiers::{InstrumentId, StrategyId, Venue},
29 instruments::{Instrument, any::InstrumentAny},
30 position::Position,
31 types::Price,
32};
33
34use crate::{
35 actor::DataActorNative,
36 cache::{Cache, refs::PositionRef},
37 clock::Clock,
38 msgbus,
39 msgbus::TypedHandler,
40};
41
42pub type GreeksFilter = Box<dyn Fn(&GreeksData) -> bool>;
44
45#[derive(Clone)]
47pub enum GreeksFilterCallback {
48 Function(fn(&GreeksData) -> bool),
50 Closure(std::rc::Rc<dyn Fn(&GreeksData) -> bool>),
52}
53
54impl GreeksFilterCallback {
55 pub fn from_fn(f: fn(&GreeksData) -> bool) -> Self {
57 Self::Function(f)
58 }
59
60 pub fn from_closure<F>(f: F) -> Self
62 where
63 F: Fn(&GreeksData) -> bool + 'static,
64 {
65 Self::Closure(std::rc::Rc::new(f))
66 }
67
68 pub fn call(&self, data: &GreeksData) -> bool {
70 match self {
71 Self::Function(f) => f(data),
72 Self::Closure(f) => f(data),
73 }
74 }
75
76 pub fn to_greeks_filter(self) -> GreeksFilter {
78 match self {
79 Self::Function(f) => Box::new(f),
80 Self::Closure(f) => {
81 let f_clone = f.clone();
82 Box::new(move |data| f_clone(data))
83 }
84 }
85 }
86}
87
88impl Debug for GreeksFilterCallback {
89 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
90 match self {
91 Self::Function(_) => f.write_str("GreeksFilterCallback::Function"),
92 Self::Closure(_) => f.write_str("GreeksFilterCallback::Closure"),
93 }
94 }
95}
96
97#[derive(Debug, bon::Builder)]
99pub struct InstrumentGreeksParams {
100 pub instrument_id: InstrumentId,
102 #[builder(default = 0.0425)]
104 pub flat_interest_rate: f64,
105 pub flat_dividend_yield: Option<f64>,
107 #[builder(default = 0.0)]
109 pub spot_shock: f64,
110 #[builder(default = 0.0)]
112 pub vol_shock: f64,
113 #[builder(default = 0.0)]
115 pub time_to_expiry_shock: f64,
116 #[builder(default = false)]
118 pub use_cached_greeks: bool,
119 #[builder(default = false)]
121 pub update_vol: bool,
122 #[builder(default = false)]
124 pub cache_greeks: bool,
125 #[builder(default = false)]
127 pub publish_greeks: bool,
128 pub ts_event: Option<UnixNanos>,
130 pub position: Option<Position>,
132 #[builder(default = false)]
134 pub percent_greeks: bool,
135 pub index_instrument_id: Option<InstrumentId>,
137 pub beta_weights: Option<HashMap<InstrumentId, f64>>,
139 pub vega_time_weight_base: Option<i32>,
141 pub vol_index_instrument_id: Option<InstrumentId>,
143 pub vol_beta_weights: Option<HashMap<InstrumentId, f64>>,
145}
146
147impl InstrumentGreeksParams {
148 pub fn calculate(&self, calculator: &GreeksCalculator) -> anyhow::Result<GreeksData> {
154 calculator.instrument_greeks(
155 self.instrument_id,
156 Some(self.flat_interest_rate),
157 self.flat_dividend_yield,
158 Some(self.spot_shock),
159 Some(self.vol_shock),
160 Some(self.time_to_expiry_shock),
161 Some(self.use_cached_greeks),
162 Some(self.update_vol),
163 Some(self.cache_greeks),
164 Some(self.publish_greeks),
165 self.ts_event,
166 self.position.clone(),
167 Some(self.percent_greeks),
168 self.index_instrument_id,
169 self.beta_weights.as_ref(),
170 self.vega_time_weight_base,
171 self.vol_index_instrument_id,
172 self.vol_beta_weights.as_ref(),
173 )
174 }
175}
176
177#[derive(bon::Builder)]
179pub struct PortfolioGreeksParams {
180 pub underlyings: Option<Vec<String>>,
182 pub venue: Option<Venue>,
184 pub instrument_id: Option<InstrumentId>,
186 pub strategy_id: Option<StrategyId>,
188 pub side: Option<PositionSide>,
190 #[builder(default = 0.0425)]
192 pub flat_interest_rate: f64,
193 pub flat_dividend_yield: Option<f64>,
195 #[builder(default = 0.0)]
197 pub spot_shock: f64,
198 #[builder(default = 0.0)]
200 pub vol_shock: f64,
201 #[builder(default = 0.0)]
203 pub time_to_expiry_shock: f64,
204 #[builder(default = false)]
206 pub use_cached_greeks: bool,
207 #[builder(default = false)]
209 pub update_vol: bool,
210 #[builder(default = false)]
212 pub cache_greeks: bool,
213 #[builder(default = false)]
215 pub publish_greeks: bool,
216 #[builder(default = false)]
218 pub percent_greeks: bool,
219 pub index_instrument_id: Option<InstrumentId>,
221 pub beta_weights: Option<HashMap<InstrumentId, f64>>,
223 pub greeks_filter: Option<GreeksFilterCallback>,
225 pub vega_time_weight_base: Option<i32>,
227 pub vol_index_instrument_id: Option<InstrumentId>,
229 pub vol_beta_weights: Option<HashMap<InstrumentId, f64>>,
231}
232
233impl Debug for PortfolioGreeksParams {
234 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
235 f.debug_struct(stringify!(PortfolioGreeksParams))
236 .field("underlyings", &self.underlyings)
237 .field("venue", &self.venue)
238 .field("instrument_id", &self.instrument_id)
239 .field("strategy_id", &self.strategy_id)
240 .field("side", &self.side)
241 .field("flat_interest_rate", &self.flat_interest_rate)
242 .field("flat_dividend_yield", &self.flat_dividend_yield)
243 .field("spot_shock", &self.spot_shock)
244 .field("vol_shock", &self.vol_shock)
245 .field("time_to_expiry_shock", &self.time_to_expiry_shock)
246 .field("use_cached_greeks", &self.use_cached_greeks)
247 .field("update_vol", &self.update_vol)
248 .field("cache_greeks", &self.cache_greeks)
249 .field("publish_greeks", &self.publish_greeks)
250 .field("percent_greeks", &self.percent_greeks)
251 .field("index_instrument_id", &self.index_instrument_id)
252 .field("beta_weights", &self.beta_weights)
253 .field("greeks_filter", &self.greeks_filter)
254 .field("vega_time_weight_base", &self.vega_time_weight_base)
255 .field("vol_index_instrument_id", &self.vol_index_instrument_id)
256 .field("vol_beta_weights", &self.vol_beta_weights)
257 .finish()
258 }
259}
260
261impl PortfolioGreeksParams {
262 pub fn calculate(&self, calculator: &GreeksCalculator) -> anyhow::Result<PortfolioGreeks> {
268 let greeks_filter = self
269 .greeks_filter
270 .as_ref()
271 .map(|f| f.clone().to_greeks_filter());
272
273 calculator.portfolio_greeks(
274 self.underlyings.as_deref(),
275 self.venue,
276 self.instrument_id,
277 self.strategy_id,
278 self.side,
279 Some(self.flat_interest_rate),
280 self.flat_dividend_yield,
281 Some(self.spot_shock),
282 Some(self.vol_shock),
283 Some(self.time_to_expiry_shock),
284 Some(self.use_cached_greeks),
285 Some(self.update_vol),
286 Some(self.cache_greeks),
287 Some(self.publish_greeks),
288 Some(self.percent_greeks),
289 self.index_instrument_id,
290 self.beta_weights.as_ref(),
291 greeks_filter.as_ref(),
292 self.vega_time_weight_base,
293 self.vol_index_instrument_id,
294 self.vol_beta_weights.as_ref(),
295 )
296 }
297}
298
299#[allow(dead_code)]
314#[derive(Debug)]
315pub struct GreeksCalculator {
316 cache: Rc<RefCell<Cache>>,
317 clock: Rc<RefCell<dyn Clock>>,
318 cached_futures_spreads: RefCell<AHashMap<InstrumentId, (InstrumentId, Price)>>,
319}
320
321impl GreeksCalculator {
322 pub fn new(cache: Rc<RefCell<Cache>>, clock: Rc<RefCell<dyn Clock>>) -> Self {
324 Self {
325 cache,
326 clock,
327 cached_futures_spreads: RefCell::new(AHashMap::new()),
328 }
329 }
330
331 pub fn from_actor(actor: &impl DataActorNative) -> Self {
337 Self::new(actor.cache_rc(), actor.clock_rc())
338 }
339
340 #[expect(clippy::too_many_arguments)]
352 pub fn instrument_greeks(
353 &self,
354 instrument_id: InstrumentId,
355 flat_interest_rate: Option<f64>,
356 flat_dividend_yield: Option<f64>,
357 spot_shock: Option<f64>,
358 vol_shock: Option<f64>,
359 time_to_expiry_shock: Option<f64>,
360 use_cached_greeks: Option<bool>,
361 update_vol: Option<bool>,
362 cache_greeks: Option<bool>,
363 publish_greeks: Option<bool>,
364 ts_event: Option<UnixNanos>,
365 position: Option<Position>,
366 percent_greeks: Option<bool>,
367 index_instrument_id: Option<InstrumentId>,
368 beta_weights: Option<&HashMap<InstrumentId, f64>>,
369 vega_time_weight_base: Option<i32>,
370 vol_index_instrument_id: Option<InstrumentId>,
371 vol_beta_weights: Option<&HashMap<InstrumentId, f64>>,
372 ) -> anyhow::Result<GreeksData> {
373 let flat_interest_rate = flat_interest_rate.unwrap_or(0.0425);
375 let spot_shock = spot_shock.unwrap_or(0.0);
376 let vol_shock = vol_shock.unwrap_or(0.0);
377 let time_to_expiry_shock = time_to_expiry_shock.unwrap_or(0.0);
378 let use_cached_greeks = use_cached_greeks.unwrap_or(false);
379 let update_vol = update_vol.unwrap_or(false);
380 let cache_greeks = cache_greeks.unwrap_or(false);
381 let publish_greeks = publish_greeks.unwrap_or(false);
382 let ts_event = ts_event.unwrap_or_default();
383 let percent_greeks = percent_greeks.unwrap_or(false);
384
385 let instrument = {
386 let cache = self.cache.borrow();
387 cache.try_instrument(&instrument_id)?.clone()
388 };
389
390 if instrument.instrument_class() != InstrumentClass::Option {
391 return self.calculate_non_option_greeks(
392 &instrument,
393 instrument_id,
394 spot_shock,
395 ts_event,
396 position,
397 percent_greeks,
398 index_instrument_id,
399 beta_weights,
400 );
401 }
402
403 let underlying_instrument_id =
404 Self::resolve_underlying_instrument_id(&instrument, instrument_id)?;
405 let mut greeks_data = self.calculate_option_greeks(
406 &instrument,
407 instrument_id,
408 underlying_instrument_id,
409 flat_interest_rate,
410 flat_dividend_yield,
411 use_cached_greeks,
412 update_vol,
413 cache_greeks,
414 publish_greeks,
415 ts_event,
416 percent_greeks,
417 index_instrument_id,
418 beta_weights,
419 vega_time_weight_base,
420 vol_index_instrument_id,
421 vol_beta_weights,
422 )?;
423
424 if spot_shock != 0.0 || vol_shock != 0.0 || time_to_expiry_shock != 0.0 {
425 greeks_data = self.apply_option_greeks_shocks(
426 &greeks_data,
427 underlying_instrument_id,
428 spot_shock,
429 vol_shock,
430 time_to_expiry_shock,
431 percent_greeks,
432 index_instrument_id,
433 beta_weights,
434 vega_time_weight_base,
435 vol_index_instrument_id,
436 vol_beta_weights,
437 )?;
438 }
439
440 if let Some(pos) = position {
441 greeks_data.pnl = greeks_data.price - pos.avg_px_open;
442 }
443
444 Ok(greeks_data)
445 }
446
447 fn resolve_underlying_instrument_id(
448 instrument: &InstrumentAny,
449 instrument_id: InstrumentId,
450 ) -> anyhow::Result<InstrumentId> {
451 let Some(underlying) = instrument.underlying() else {
452 anyhow::bail!("Instrument {instrument_id} has no underlying identifier");
453 };
454
455 Ok(InstrumentId::from(format!(
456 "{}.{}",
457 underlying, instrument_id.venue
458 )))
459 }
460
461 #[expect(clippy::too_many_arguments)]
462 fn calculate_non_option_greeks(
463 &self,
464 instrument: &InstrumentAny,
465 instrument_id: InstrumentId,
466 spot_shock: f64,
467 ts_event: UnixNanos,
468 position: Option<Position>,
469 percent_greeks: bool,
470 index_instrument_id: Option<InstrumentId>,
471 beta_weights: Option<&HashMap<InstrumentId, f64>>,
472 ) -> anyhow::Result<GreeksData> {
473 let multiplier = instrument.multiplier();
474 let underlying_instrument_id = instrument.id();
475 let underlying_price = self
476 .get_price(&underlying_instrument_id)
477 .ok_or_else(|| anyhow::anyhow!("No price available for {underlying_instrument_id}"))?;
478 let (delta, _, _) = self.modify_greeks(
479 1.0,
480 0.0,
481 underlying_instrument_id,
482 underlying_price + spot_shock,
483 underlying_price,
484 percent_greeks,
485 index_instrument_id,
486 beta_weights,
487 0.0,
488 0.0,
489 0,
490 None,
491 0.0,
492 None,
493 None,
494 None,
495 None,
496 )?;
497 let mut greeks_data =
498 GreeksData::from_delta(instrument_id, delta, multiplier.as_f64(), ts_event);
499
500 if let Some(pos) = position {
501 greeks_data.pnl = (underlying_price + spot_shock) - pos.avg_px_open;
502 greeks_data.price = greeks_data.pnl;
503 }
504
505 Ok(greeks_data)
506 }
507
508 #[expect(clippy::too_many_arguments)]
509 fn calculate_option_greeks(
510 &self,
511 instrument: &InstrumentAny,
512 instrument_id: InstrumentId,
513 underlying_instrument_id: InstrumentId,
514 flat_interest_rate: f64,
515 flat_dividend_yield: Option<f64>,
516 use_cached_greeks: bool,
517 update_vol: bool,
518 cache_greeks: bool,
519 publish_greeks: bool,
520 ts_event: UnixNanos,
521 percent_greeks: bool,
522 index_instrument_id: Option<InstrumentId>,
523 beta_weights: Option<&HashMap<InstrumentId, f64>>,
524 vega_time_weight_base: Option<i32>,
525 vol_index_instrument_id: Option<InstrumentId>,
526 vol_beta_weights: Option<&HashMap<InstrumentId, f64>>,
527 ) -> anyhow::Result<GreeksData> {
528 if use_cached_greeks {
529 let cache = self.cache.borrow();
530 if let Some(cached_greeks) = cache.greeks(&instrument_id) {
531 return Ok(cached_greeks);
532 }
533 }
534
535 let utc_now_ns = if ts_event == UnixNanos::default() {
536 self.clock.borrow().timestamp_ns()
537 } else {
538 ts_event
539 };
540 let utc_now = utc_now_ns.to_datetime_utc();
541 let expiry_utc = instrument
542 .expiration_ns()
543 .map(|ns| ns.to_datetime_utc())
544 .unwrap_or_default();
545 let expiry_int = expiry_utc
546 .strftime("%Y%m%d")
547 .to_string()
548 .parse::<i32>()
549 .unwrap_or(0);
550 let raw_days = utc_now.duration_until(expiry_utc).as_hours() / 24;
551 let expiry_in_days = raw_days.max(1) as i32;
552 let expiry_in_years = expiry_in_days as f64 / 365.25;
553 let currency = instrument.quote_currency().code.to_string();
554
555 let cache = self.cache.borrow();
556 let yield_curve = cache.yield_curve(¤cy);
557 let interest_rate = match yield_curve {
558 Some(yield_curve) => yield_curve(expiry_in_years),
559 None => flat_interest_rate,
560 };
561 let dividend_curve = cache.yield_curve(&underlying_instrument_id.to_string());
562 drop(cache);
563
564 let mut cost_of_carry = 0.0;
565
566 if let Some(dividend_curve) = dividend_curve {
567 cost_of_carry = interest_rate - dividend_curve(expiry_in_years);
568 } else if let Some(div_yield) = flat_dividend_yield {
569 cost_of_carry = interest_rate - div_yield;
570 }
571
572 let multiplier = instrument.multiplier();
573 let is_call = instrument.option_kind().unwrap_or(OptionKind::Call) == OptionKind::Call;
574 let strike = instrument.strike_price().unwrap_or_default().as_f64();
575 let option_price = self
576 .get_price(&instrument_id)
577 .ok_or_else(|| anyhow::anyhow!("No price available for {instrument_id}"))?;
578 let underlying_price = self.get_underlying_price(&underlying_instrument_id)?;
579
580 if let Some(vol_index_id) = vol_index_instrument_id {
581 self.get_price(&vol_index_id)
582 .ok_or_else(|| anyhow::anyhow!("No price available for {vol_index_id}"))?;
583 }
584 let greeks = if update_vol {
585 let cached_greeks = self.cache.borrow().greeks(&instrument_id);
586 match cached_greeks {
587 Some(cached_greeks) => refine_vol_and_greeks(
588 underlying_price,
589 interest_rate,
590 cost_of_carry,
591 is_call,
592 strike,
593 expiry_in_years,
594 option_price,
595 cached_greeks.vol,
596 ),
597 None => imply_vol_and_greeks(
598 underlying_price,
599 interest_rate,
600 cost_of_carry,
601 is_call,
602 strike,
603 expiry_in_years,
604 option_price,
605 ),
606 }
607 } else {
608 imply_vol_and_greeks(
609 underlying_price,
610 interest_rate,
611 cost_of_carry,
612 is_call,
613 strike,
614 expiry_in_years,
615 option_price,
616 )
617 };
618 let (delta, gamma, vega) = self.modify_greeks(
619 greeks.delta,
620 greeks.gamma,
621 underlying_instrument_id,
622 underlying_price,
623 underlying_price,
624 percent_greeks,
625 index_instrument_id,
626 beta_weights,
627 greeks.vega,
628 greeks.vol,
629 expiry_in_days,
630 vega_time_weight_base,
631 greeks.vol,
632 vol_index_instrument_id,
633 vol_beta_weights,
634 None,
635 None,
636 )?;
637 let greeks_data = GreeksData::new(
638 utc_now_ns,
639 utc_now_ns,
640 instrument_id,
641 is_call,
642 strike,
643 expiry_int,
644 expiry_in_days,
645 expiry_in_years,
646 multiplier.as_f64(),
647 1.0,
648 underlying_price,
649 interest_rate,
650 cost_of_carry,
651 greeks.vol,
652 0.0,
653 greeks.price,
654 OptionGreekValues {
655 delta,
656 gamma,
657 vega,
658 theta: greeks.theta,
659 rho: 0.0,
660 },
661 greeks.itm_prob,
662 );
663
664 if cache_greeks {
665 let mut cache = self.cache.borrow_mut();
666 cache.add_greeks(greeks_data.clone()).unwrap_or_default();
667 }
668
669 if publish_greeks {
670 let topic = format!(
671 "data.GreeksData.instrument_id={}",
672 instrument_id.symbol.as_str()
673 )
674 .into();
675 msgbus::publish_greeks(topic, &greeks_data);
676 }
677
678 Ok(greeks_data)
679 }
680
681 #[expect(clippy::too_many_arguments)]
682 fn apply_option_greeks_shocks(
683 &self,
684 greeks_data: &GreeksData,
685 underlying_instrument_id: InstrumentId,
686 spot_shock: f64,
687 vol_shock: f64,
688 time_to_expiry_shock: f64,
689 percent_greeks: bool,
690 index_instrument_id: Option<InstrumentId>,
691 beta_weights: Option<&HashMap<InstrumentId, f64>>,
692 vega_time_weight_base: Option<i32>,
693 vol_index_instrument_id: Option<InstrumentId>,
694 vol_beta_weights: Option<&HashMap<InstrumentId, f64>>,
695 ) -> anyhow::Result<GreeksData> {
696 let underlying_price = greeks_data.underlying_price;
697 let shocked_underlying_price = underlying_price + spot_shock;
698 let shocked_vol = greeks_data.vol + vol_shock;
699 let shocked_time_to_expiry = greeks_data.expiry_in_years - time_to_expiry_shock;
700 let shocked_expiry_in_days = (shocked_time_to_expiry * 365.25) as i32;
701
702 let greeks = black_scholes_greeks(
703 shocked_underlying_price,
704 greeks_data.interest_rate,
705 greeks_data.cost_of_carry,
706 shocked_vol,
707 greeks_data.is_call,
708 greeks_data.strike,
709 shocked_time_to_expiry,
710 );
711 let (delta, gamma, vega) = self.modify_greeks(
712 greeks.delta,
713 greeks.gamma,
714 underlying_instrument_id,
715 shocked_underlying_price,
716 underlying_price,
717 percent_greeks,
718 index_instrument_id,
719 beta_weights,
720 greeks.vega,
721 shocked_vol,
722 shocked_expiry_in_days,
723 vega_time_weight_base,
724 greeks_data.vol,
725 vol_index_instrument_id,
726 vol_beta_weights,
727 None,
728 None,
729 )?;
730 Ok(GreeksData::new(
731 greeks_data.ts_event,
732 greeks_data.ts_event,
733 greeks_data.instrument_id,
734 greeks_data.is_call,
735 greeks_data.strike,
736 greeks_data.expiry,
737 shocked_expiry_in_days,
738 shocked_time_to_expiry,
739 greeks_data.multiplier,
740 greeks_data.quantity,
741 shocked_underlying_price,
742 greeks_data.interest_rate,
743 greeks_data.cost_of_carry,
744 shocked_vol,
745 0.0,
746 greeks.price,
747 OptionGreekValues {
748 delta,
749 gamma,
750 vega,
751 theta: greeks.theta,
752 rho: 0.0,
753 },
754 greeks.itm_prob,
755 ))
756 }
757
758 fn get_underlying_price(&self, underlying_instrument_id: &InstrumentId) -> anyhow::Result<f64> {
759 if let Some(underlying_price) = self.get_price(underlying_instrument_id) {
760 return Ok(underlying_price);
761 }
762
763 let is_future_or_absent = {
766 let cache = self.cache.borrow();
767 cache
768 .instrument(underlying_instrument_id)
769 .is_none_or(|inst| inst.instrument_class() == InstrumentClass::Future)
770 };
771
772 if is_future_or_absent
773 && let Some(underlying_price) =
774 self.get_cached_futures_spread_price(*underlying_instrument_id)
775 {
776 return Ok(underlying_price.as_f64());
777 }
778
779 anyhow::bail!("No price available for {underlying_instrument_id}")
780 }
781
782 #[expect(clippy::too_many_arguments)]
808 pub fn modify_greeks(
809 &self,
810 delta_input: f64,
811 gamma_input: f64,
812 underlying_instrument_id: InstrumentId,
813 underlying_price: f64,
814 unshocked_underlying_price: f64,
815 percent_greeks: bool,
816 index_instrument_id: Option<InstrumentId>,
817 beta_weights: Option<&HashMap<InstrumentId, f64>>,
818 vega_input: f64,
819 vol: f64,
820 expiry_in_days: i32,
821 vega_time_weight_base: Option<i32>,
822 unshocked_vol: f64,
823 vol_index_instrument_id: Option<InstrumentId>,
824 vol_beta_weights: Option<&HashMap<InstrumentId, f64>>,
825 index_price: Option<f64>,
826 vol_index_price: Option<f64>,
827 ) -> anyhow::Result<(f64, f64, f64)> {
828 let mut delta = delta_input;
829 let mut gamma = gamma_input;
830 let mut vega = vega_input;
831
832 let mut used_index_price = index_price
833 .or_else(|| index_instrument_id.and_then(|index_id| self.get_price(&index_id)));
834 let mut used_index_vol = vol_index_price;
835 if used_index_vol.is_none()
836 && let Some(vol_index_id) = vol_index_instrument_id
837 {
838 used_index_vol = Some(
839 self.get_price(&vol_index_id)
840 .ok_or_else(|| anyhow::anyhow!("No price available for {vol_index_id}"))?,
841 );
842 }
843
844 if used_index_price.is_some() {
845 let mut beta = 1.0;
846
847 if let Some(weights) = beta_weights
848 && let Some(&weight) = weights.get(&underlying_instrument_id)
849 {
850 beta = weight;
851 }
852
853 if let Some(ref mut idx_price) = used_index_price {
854 #[expect(clippy::float_cmp, reason = "exact-equality baseline check")]
855 if underlying_price != unshocked_underlying_price {
856 *idx_price += 1.0 / beta
857 * (*idx_price / unshocked_underlying_price)
858 * (underlying_price - unshocked_underlying_price);
859 }
860
861 let delta_multiplier = beta * underlying_price / *idx_price;
862 delta *= delta_multiplier;
863 gamma *= delta_multiplier.powi(2);
864 }
865 }
866
867 if used_index_vol.is_some() {
868 let mut vega_beta = 1.0;
869 let used_vol = if unshocked_vol == 0.0 {
870 vol
871 } else {
872 unshocked_vol
873 };
874
875 if let Some(weights) = vol_beta_weights
876 && let Some(&weight) = weights.get(&underlying_instrument_id)
877 {
878 vega_beta = weight;
879 }
880
881 if let Some(ref mut idx_vol) = used_index_vol {
882 *idx_vol *= 0.01;
883
884 #[expect(clippy::float_cmp, reason = "exact-equality baseline check")]
885 if vol != used_vol && used_vol != 0.0 {
886 *idx_vol += 1.0 / vega_beta * (*idx_vol / used_vol) * (vol - used_vol);
887 }
888
889 if *idx_vol != 0.0 {
890 vega *= vega_beta * vol / *idx_vol;
891 }
892 }
893 }
894
895 if percent_greeks {
896 if let Some(idx_price) = used_index_price {
897 delta *= idx_price / 100.0;
898 gamma *= (idx_price / 100.0).powi(2);
899 } else {
900 delta *= underlying_price / 100.0;
901 gamma *= (underlying_price / 100.0).powi(2);
902 }
903
904 if let Some(idx_vol) = used_index_vol {
905 vega *= idx_vol / 100.0;
906 } else {
907 vega *= vol / 100.0;
908 }
909 }
910
911 if let Some(time_base) = vega_time_weight_base
913 && expiry_in_days > 0
914 {
915 let time_weight = (time_base as f64 / expiry_in_days as f64).sqrt();
916 vega *= time_weight;
917 }
918
919 Ok((delta, gamma, vega))
920 }
921
922 #[expect(clippy::too_many_arguments)]
935 pub fn portfolio_greeks(
936 &self,
937 underlyings: Option<&[String]>,
938 venue: Option<Venue>,
939 instrument_id: Option<InstrumentId>,
940 strategy_id: Option<StrategyId>,
941 side: Option<PositionSide>,
942 flat_interest_rate: Option<f64>,
943 flat_dividend_yield: Option<f64>,
944 spot_shock: Option<f64>,
945 vol_shock: Option<f64>,
946 time_to_expiry_shock: Option<f64>,
947 use_cached_greeks: Option<bool>,
948 update_vol: Option<bool>,
949 cache_greeks: Option<bool>,
950 publish_greeks: Option<bool>,
951 percent_greeks: Option<bool>,
952 index_instrument_id: Option<InstrumentId>,
953 beta_weights: Option<&HashMap<InstrumentId, f64>>,
954 greeks_filter: Option<&GreeksFilter>,
955 vega_time_weight_base: Option<i32>,
956 vol_index_instrument_id: Option<InstrumentId>,
957 vol_beta_weights: Option<&HashMap<InstrumentId, f64>>,
958 ) -> anyhow::Result<PortfolioGreeks> {
959 let ts_event = self.clock.borrow().timestamp_ns();
960 let mut portfolio_greeks =
961 PortfolioGreeks::new(ts_event, ts_event, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0);
962
963 let flat_interest_rate = flat_interest_rate.unwrap_or(0.0425);
965 let spot_shock = spot_shock.unwrap_or(0.0);
966 let vol_shock = vol_shock.unwrap_or(0.0);
967 let time_to_expiry_shock = time_to_expiry_shock.unwrap_or(0.0);
968 let use_cached_greeks = use_cached_greeks.unwrap_or(false);
969 let update_vol = update_vol.unwrap_or(false);
970 let cache_greeks = cache_greeks.unwrap_or(false);
971 let publish_greeks = publish_greeks.unwrap_or(false);
972 let percent_greeks = percent_greeks.unwrap_or(false);
973 let cache = self.cache.borrow();
974 let open_positions = cache.positions_open(
975 venue.as_ref(),
976 instrument_id.as_ref(),
977 strategy_id.as_ref(),
978 None, side,
980 );
981 let open_positions: Vec<Position> =
982 open_positions.iter().map(PositionRef::cloned).collect();
983
984 for position in open_positions {
985 let position_instrument_id = position.instrument_id;
986
987 if let Some(underlyings_list) = underlyings {
988 let mut skip_position = true;
989
990 for underlying in underlyings_list {
991 if position_instrument_id
992 .symbol
993 .as_str()
994 .starts_with(underlying)
995 {
996 skip_position = false;
997 break;
998 }
999 }
1000
1001 if skip_position {
1002 continue;
1003 }
1004 }
1005
1006 let quantity = position.signed_qty;
1007 let instrument_greeks = self.instrument_greeks(
1008 position_instrument_id,
1009 Some(flat_interest_rate),
1010 flat_dividend_yield,
1011 Some(spot_shock),
1012 Some(vol_shock),
1013 Some(time_to_expiry_shock),
1014 Some(use_cached_greeks),
1015 Some(update_vol),
1016 Some(cache_greeks),
1017 Some(publish_greeks),
1018 Some(ts_event),
1019 Some(position),
1020 Some(percent_greeks),
1021 index_instrument_id,
1022 beta_weights,
1023 vega_time_weight_base,
1024 vol_index_instrument_id,
1025 vol_beta_weights,
1026 )?;
1027 let position_greeks = quantity * &instrument_greeks;
1028
1029 if greeks_filter.is_none_or(|filter| filter(&position_greeks)) {
1031 portfolio_greeks = portfolio_greeks + PortfolioGreeks::from(position_greeks);
1032 }
1033 }
1034
1035 Ok(portfolio_greeks)
1036 }
1037
1038 pub fn cache_futures_spread(
1044 &self,
1045 call_instrument_id: InstrumentId,
1046 put_instrument_id: InstrumentId,
1047 futures_instrument_id: InstrumentId,
1048 ) -> anyhow::Result<Price> {
1049 let cache = self.cache.borrow();
1050 let call_instrument = cache.instrument(&call_instrument_id).cloned();
1051 let put_instrument = cache.instrument(&put_instrument_id).cloned();
1052 let reference_future_instrument = cache.instrument(&futures_instrument_id).cloned();
1053 drop(cache);
1054
1055 let Some(call_instrument) = call_instrument else {
1056 anyhow::bail!(
1057 "Cannot cache futures spread: missing option instrument {call_instrument_id}"
1058 );
1059 };
1060 let Some(put_instrument) = put_instrument else {
1061 anyhow::bail!(
1062 "Cannot cache futures spread: missing option instrument {put_instrument_id}"
1063 );
1064 };
1065 let Some(reference_future_instrument) = reference_future_instrument else {
1066 anyhow::bail!(
1067 "Cannot cache futures spread: no reference futures instrument for {futures_instrument_id}"
1068 );
1069 };
1070
1071 if call_instrument.instrument_class() != InstrumentClass::Option
1072 || put_instrument.instrument_class() != InstrumentClass::Option
1073 {
1074 anyhow::bail!(
1075 "Cannot cache futures spread: non-option instruments provided call_instrument_id={call_instrument_id} put_instrument_id={put_instrument_id}"
1076 );
1077 }
1078
1079 if call_instrument.option_kind() != Some(OptionKind::Call)
1080 || put_instrument.option_kind() != Some(OptionKind::Put)
1081 {
1082 anyhow::bail!(
1083 "Cannot cache futures spread: expected call/put pair call_instrument_id={call_instrument_id} put_instrument_id={put_instrument_id}"
1084 );
1085 }
1086
1087 let Some(call_underlying) = call_instrument.underlying() else {
1088 anyhow::bail!(
1089 "Cannot cache futures spread: missing call underlying for {call_instrument_id}"
1090 );
1091 };
1092 let Some(put_underlying) = put_instrument.underlying() else {
1093 anyhow::bail!(
1094 "Cannot cache futures spread: missing put underlying for {put_instrument_id}"
1095 );
1096 };
1097
1098 if call_underlying != put_underlying {
1099 anyhow::bail!(
1100 "Cannot cache futures spread: option underlyings differ call_instrument_id={call_instrument_id} put_instrument_id={put_instrument_id}"
1101 );
1102 }
1103
1104 if call_instrument.strike_price() != put_instrument.strike_price() {
1105 anyhow::bail!(
1106 "Cannot cache futures spread: strike prices differ call_instrument_id={call_instrument_id} put_instrument_id={put_instrument_id}"
1107 );
1108 }
1109
1110 if call_instrument.expiration_ns() != put_instrument.expiration_ns() {
1111 anyhow::bail!(
1112 "Cannot cache futures spread: expiration dates differ call_instrument_id={call_instrument_id} put_instrument_id={put_instrument_id}"
1113 );
1114 }
1115
1116 let reference_future_price = self.get_price_object(&futures_instrument_id).ok_or_else(|| {
1117 anyhow::anyhow!(
1118 "Cannot cache futures spread: no reference futures price for {futures_instrument_id}"
1119 )
1120 })?;
1121 let call_price = self.get_price(&call_instrument_id).ok_or_else(|| {
1122 anyhow::anyhow!(
1123 "Cannot cache futures spread: missing option price for {call_instrument_id}"
1124 )
1125 })?;
1126 let put_price = self.get_price(&put_instrument_id).ok_or_else(|| {
1127 anyhow::anyhow!(
1128 "Cannot cache futures spread: missing option price for {put_instrument_id}"
1129 )
1130 })?;
1131
1132 let underlying_instrument_id =
1133 InstrumentId::from(format!("{call_underlying}.{}", call_instrument_id.venue));
1134
1135 {
1137 let cache = self.cache.borrow();
1138 if let Some(underlying) = cache.instrument(&underlying_instrument_id)
1139 && underlying.instrument_class() != InstrumentClass::Future
1140 {
1141 anyhow::bail!(
1142 "Cannot cache futures spread: underlying {underlying_instrument_id} is not a futures contract"
1143 );
1144 }
1145 }
1146
1147 let implied_future_price =
1148 self.calculate_implied_future_price(&call_instrument, call_price, put_price);
1149 let spread = implied_future_price - reference_future_price.as_f64();
1150 let spread_price = reference_future_instrument.make_price(spread);
1151
1152 self.cached_futures_spreads.borrow_mut().insert(
1153 underlying_instrument_id,
1154 (futures_instrument_id, spread_price),
1155 );
1156
1157 Ok(reference_future_price + spread_price)
1158 }
1159
1160 fn calculate_implied_future_price(
1161 &self,
1162 call_instrument: &InstrumentAny,
1163 call_price: f64,
1164 put_price: f64,
1165 ) -> f64 {
1166 let expiry_utc = call_instrument
1167 .expiration_ns()
1168 .map(|ns| ns.to_datetime_utc())
1169 .unwrap_or_default();
1170 let now = self.clock.borrow().timestamp_ns().to_datetime_utc();
1171 let expiry_in_days = (now.duration_until(expiry_utc).as_hours() / 24).max(1) as i32;
1172 let expiry_in_years = expiry_in_days as f64 / 365.25;
1173 let currency = call_instrument.quote_currency().code.to_string();
1174 let interest_rate = self
1175 .cache
1176 .borrow()
1177 .yield_curve(¤cy)
1178 .map_or(0.0425, |yield_curve| yield_curve(expiry_in_years));
1179 let strike = call_instrument.strike_price().unwrap_or_default().as_f64();
1180
1181 strike + (interest_rate * expiry_in_years).exp() * (call_price - put_price)
1182 }
1183
1184 #[must_use]
1186 pub fn get_cached_futures_spread_price(
1187 &self,
1188 underlying_instrument_id: InstrumentId,
1189 ) -> Option<Price> {
1190 let (futures_instrument_id, spread) = self
1191 .cached_futures_spreads
1192 .borrow()
1193 .get(&underlying_instrument_id)
1194 .copied()?;
1195 let reference_future_price = self.get_price_object(&futures_instrument_id)?;
1196
1197 Some(reference_future_price + spread)
1198 }
1199
1200 fn get_price_object(&self, instrument_id: &InstrumentId) -> Option<Price> {
1201 let cache = self.cache.borrow();
1202 let price = cache
1203 .price(instrument_id, PriceType::Mid)
1204 .or_else(|| cache.price(instrument_id, PriceType::Last));
1205
1206 if let Some(instrument) = cache.instrument(instrument_id)
1210 && instrument.asset_class() == AssetClass::Index
1211 {
1212 if instrument.instrument_class() == InstrumentClass::Future && price.is_some() {
1213 return price;
1214 }
1215
1216 if let Some(index_price) = cache.index_price(instrument_id) {
1217 return Some(index_price.value);
1218 }
1219 }
1220
1221 price
1222 }
1223
1224 fn get_price(&self, instrument_id: &InstrumentId) -> Option<f64> {
1225 self.get_price_object(instrument_id)
1226 .map(|price| price.as_f64())
1227 }
1228
1229 pub fn subscribe_greeks<F>(&self, underlying: &str, handler: Option<F>)
1233 where
1234 F: Fn(&GreeksData) + 'static,
1235 {
1236 let pattern = format!("data.GreeksData.instrument_id={underlying}*").into();
1237
1238 if let Some(custom_handler) = handler {
1239 let typed_handler = TypedHandler::from(custom_handler);
1240 msgbus::subscribe_greeks(pattern, typed_handler, None);
1241 } else {
1242 let cache_ref = self.cache.clone();
1243 let typed_handler = TypedHandler::from(move |greeks: &GreeksData| {
1244 let mut cache = cache_ref.borrow_mut();
1245 cache.add_greeks(greeks.clone()).unwrap_or_default();
1246 });
1247 msgbus::subscribe_greeks(pattern, typed_handler, None);
1248 }
1249 }
1250}
1251
1252#[cfg(test)]
1253mod tests {
1254 use std::{cell::RefCell, collections::HashMap, rc::Rc};
1255
1256 use jiff::{Timestamp, civil::Date, tz::Offset};
1257 use nautilus_model::{
1258 data::{IndexPriceUpdate, QuoteTick, YieldCurveData},
1259 enums::{AssetClass, OmsType, OptionKind, OrderSide, PositionSide},
1260 events::order::spec::OrderFilledSpec,
1261 identifiers::{
1262 ClientOrderId, InstrumentId, PositionId, StrategyId, Symbol, TradeId, Venue,
1263 },
1264 instruments::{Equity, FuturesContract, OptionContract, any::InstrumentAny},
1265 types::{Currency, Price, Quantity},
1266 };
1267 use rstest::rstest;
1268 use ustr::Ustr;
1269
1270 use super::*;
1271 use crate::{cache::Cache, clock::VirtualClock};
1272
1273 fn utc_timestamp(year: i16, month: i8, day: i8, hour: i8, minute: i8, second: i8) -> Timestamp {
1274 Offset::UTC
1275 .to_timestamp(
1276 Date::new(year, month, day)
1277 .unwrap()
1278 .at(hour, minute, second, 0),
1279 )
1280 .unwrap()
1281 }
1282
1283 fn create_test_calculator() -> GreeksCalculator {
1284 let cache = Rc::new(RefCell::new(Cache::new(None, None)));
1285 let clock = Rc::new(RefCell::new(VirtualClock::new()));
1286 GreeksCalculator::new(cache, clock)
1287 }
1288
1289 #[rstest]
1290 fn test_greeks_calculator_debug() {
1291 let calculator = create_test_calculator();
1292
1293 let debug_str = format!("{calculator:?}");
1294
1295 assert!(debug_str.starts_with("GreeksCalculator {"), "{debug_str}");
1296 assert!(debug_str.contains("cache:"), "{debug_str}");
1297 assert!(debug_str.contains("clock:"), "{debug_str}");
1298 assert!(
1299 debug_str.contains("cached_futures_spreads: RefCell { value: {} }"),
1300 "{debug_str}"
1301 );
1302 }
1303
1304 #[rstest]
1305 fn test_instrument_greeks_params_builder_default() {
1306 let instrument_id = InstrumentId::from("AAPL.NASDAQ");
1307
1308 let params = InstrumentGreeksParams::builder()
1309 .instrument_id(instrument_id)
1310 .build();
1311
1312 assert_eq!(params.instrument_id, instrument_id);
1313 assert_eq!(params.flat_interest_rate, 0.0425);
1314 assert_eq!(params.flat_dividend_yield, None);
1315 assert_eq!(params.spot_shock, 0.0);
1316 assert_eq!(params.vol_shock, 0.0);
1317 assert_eq!(params.time_to_expiry_shock, 0.0);
1318 assert!(!params.use_cached_greeks);
1319 assert!(!params.cache_greeks);
1320 assert!(!params.publish_greeks);
1321 assert_eq!(params.ts_event, None);
1322 assert_eq!(params.position, None);
1323 assert!(!params.percent_greeks);
1324 assert_eq!(params.index_instrument_id, None);
1325 assert_eq!(params.beta_weights, None);
1326 assert_eq!(params.vol_index_instrument_id, None);
1327 assert_eq!(params.vol_beta_weights, None);
1328 }
1329
1330 #[rstest]
1331 fn test_instrument_greeks_params_builder_custom_values() {
1332 let instrument_id = InstrumentId::from("AAPL.NASDAQ");
1333 let index_id = InstrumentId::from("SPY.NASDAQ");
1334 let vol_index_id = InstrumentId::from("VIX.XCBF");
1335 let mut beta_weights = HashMap::new();
1336 beta_weights.insert(instrument_id, 1.2);
1337 let mut vol_beta_weights = HashMap::new();
1338 vol_beta_weights.insert(instrument_id, 0.8);
1339
1340 let params = InstrumentGreeksParams::builder()
1341 .instrument_id(instrument_id)
1342 .flat_interest_rate(0.05)
1343 .flat_dividend_yield(0.02)
1344 .spot_shock(0.01)
1345 .vol_shock(0.05)
1346 .time_to_expiry_shock(0.1)
1347 .use_cached_greeks(true)
1348 .cache_greeks(true)
1349 .publish_greeks(true)
1350 .percent_greeks(true)
1351 .index_instrument_id(index_id)
1352 .beta_weights(beta_weights.clone())
1353 .vol_index_instrument_id(vol_index_id)
1354 .vol_beta_weights(vol_beta_weights.clone())
1355 .build();
1356
1357 assert_eq!(params.instrument_id, instrument_id);
1358 assert_eq!(params.flat_interest_rate, 0.05);
1359 assert_eq!(params.flat_dividend_yield, Some(0.02));
1360 assert_eq!(params.spot_shock, 0.01);
1361 assert_eq!(params.vol_shock, 0.05);
1362 assert_eq!(params.time_to_expiry_shock, 0.1);
1363 assert!(params.use_cached_greeks);
1364 assert!(params.cache_greeks);
1365 assert!(params.publish_greeks);
1366 assert!(params.percent_greeks);
1367 assert_eq!(params.index_instrument_id, Some(index_id));
1368 assert_eq!(params.beta_weights, Some(beta_weights));
1369 assert_eq!(params.vol_index_instrument_id, Some(vol_index_id));
1370 assert_eq!(params.vol_beta_weights, Some(vol_beta_weights));
1371 }
1372
1373 #[rstest]
1374 fn test_instrument_greeks_params_debug() {
1375 let instrument_id = InstrumentId::from("AAPL.NASDAQ");
1376
1377 let params = InstrumentGreeksParams::builder()
1378 .instrument_id(instrument_id)
1379 .build();
1380
1381 let debug_str = format!("{params:?}");
1382 assert!(debug_str.contains("InstrumentGreeksParams"));
1383 assert!(debug_str.contains("AAPL.NASDAQ"));
1384 }
1385
1386 #[rstest]
1387 fn test_portfolio_greeks_params_builder_default() {
1388 let params = PortfolioGreeksParams::builder().build();
1389
1390 assert_eq!(params.underlyings, None);
1391 assert_eq!(params.venue, None);
1392 assert_eq!(params.instrument_id, None);
1393 assert_eq!(params.strategy_id, None);
1394 assert_eq!(params.side, None);
1395 assert_eq!(params.flat_interest_rate, 0.0425);
1396 assert_eq!(params.flat_dividend_yield, None);
1397 assert_eq!(params.spot_shock, 0.0);
1398 assert_eq!(params.vol_shock, 0.0);
1399 assert_eq!(params.time_to_expiry_shock, 0.0);
1400 assert!(!params.use_cached_greeks);
1401 assert!(!params.cache_greeks);
1402 assert!(!params.publish_greeks);
1403 assert!(!params.percent_greeks);
1404 assert_eq!(params.index_instrument_id, None);
1405 assert_eq!(params.beta_weights, None);
1406 assert_eq!(params.vol_index_instrument_id, None);
1407 assert_eq!(params.vol_beta_weights, None);
1408 }
1409
1410 #[rstest]
1411 fn test_portfolio_greeks_params_builder_custom_values() {
1412 let venue = Venue::from("NASDAQ");
1413 let instrument_id = InstrumentId::from("AAPL.NASDAQ");
1414 let strategy_id = StrategyId::from("test-strategy");
1415 let index_id = InstrumentId::from("SPY.NASDAQ");
1416 let vol_index_id = InstrumentId::from("VIX.XCBF");
1417 let underlyings = vec!["AAPL".to_string(), "MSFT".to_string()];
1418 let mut beta_weights = HashMap::new();
1419 beta_weights.insert(instrument_id, 1.2);
1420 let mut vol_beta_weights = HashMap::new();
1421 vol_beta_weights.insert(instrument_id, 0.8);
1422
1423 let params = PortfolioGreeksParams::builder()
1424 .underlyings(underlyings.clone())
1425 .venue(venue)
1426 .instrument_id(instrument_id)
1427 .strategy_id(strategy_id)
1428 .side(PositionSide::Long)
1429 .flat_interest_rate(0.05)
1430 .flat_dividend_yield(0.02)
1431 .spot_shock(0.01)
1432 .vol_shock(0.05)
1433 .time_to_expiry_shock(0.1)
1434 .use_cached_greeks(true)
1435 .cache_greeks(true)
1436 .publish_greeks(true)
1437 .percent_greeks(true)
1438 .index_instrument_id(index_id)
1439 .beta_weights(beta_weights.clone())
1440 .vol_index_instrument_id(vol_index_id)
1441 .vol_beta_weights(vol_beta_weights.clone())
1442 .build();
1443
1444 assert_eq!(params.underlyings, Some(underlyings));
1445 assert_eq!(params.venue, Some(venue));
1446 assert_eq!(params.instrument_id, Some(instrument_id));
1447 assert_eq!(params.strategy_id, Some(strategy_id));
1448 assert_eq!(params.side, Some(PositionSide::Long));
1449 assert_eq!(params.flat_interest_rate, 0.05);
1450 assert_eq!(params.flat_dividend_yield, Some(0.02));
1451 assert_eq!(params.spot_shock, 0.01);
1452 assert_eq!(params.vol_shock, 0.05);
1453 assert_eq!(params.time_to_expiry_shock, 0.1);
1454 assert!(params.use_cached_greeks);
1455 assert!(params.cache_greeks);
1456 assert!(params.publish_greeks);
1457 assert!(params.percent_greeks);
1458 assert_eq!(params.index_instrument_id, Some(index_id));
1459 assert_eq!(params.beta_weights, Some(beta_weights));
1460 assert_eq!(params.vol_index_instrument_id, Some(vol_index_id));
1461 assert_eq!(params.vol_beta_weights, Some(vol_beta_weights));
1462 }
1463
1464 #[rstest]
1465 fn test_portfolio_greeks_params_debug() {
1466 let venue = Venue::from("NASDAQ");
1467
1468 let params = PortfolioGreeksParams::builder().venue(venue).build();
1469
1470 let debug_str = format!("{params:?}");
1471 assert!(debug_str.contains("PortfolioGreeksParams"));
1472 assert!(debug_str.contains("NASDAQ"));
1473 }
1474
1475 #[rstest]
1476 fn test_portfolio_greeks_params_builder_fluent_api() {
1477 let instrument_id = InstrumentId::from("AAPL.NASDAQ");
1478
1479 let params = PortfolioGreeksParams::builder()
1480 .instrument_id(instrument_id)
1481 .flat_interest_rate(0.05)
1482 .spot_shock(0.01)
1483 .percent_greeks(true)
1484 .build();
1485
1486 assert_eq!(params.instrument_id, Some(instrument_id));
1487 assert_eq!(params.flat_interest_rate, 0.05);
1488 assert_eq!(params.spot_shock, 0.01);
1489 assert!(params.percent_greeks);
1490 }
1491
1492 #[rstest]
1493 fn test_instrument_greeks_params_builder_fluent_chaining() {
1494 let instrument_id = InstrumentId::from("TSLA.NASDAQ");
1495
1496 let params = InstrumentGreeksParams::builder()
1498 .instrument_id(instrument_id)
1499 .flat_interest_rate(0.03)
1500 .spot_shock(0.02)
1501 .vol_shock(0.1)
1502 .use_cached_greeks(true)
1503 .percent_greeks(true)
1504 .build();
1505
1506 assert_eq!(params.instrument_id, instrument_id);
1507 assert_eq!(params.flat_interest_rate, 0.03);
1508 assert_eq!(params.spot_shock, 0.02);
1509 assert_eq!(params.vol_shock, 0.1);
1510 assert!(params.use_cached_greeks);
1511 assert!(params.percent_greeks);
1512 }
1513
1514 #[rstest]
1515 fn test_portfolio_greeks_params_builder_with_underlyings() {
1516 let underlyings = vec!["AAPL".to_string(), "MSFT".to_string(), "GOOGL".to_string()];
1517
1518 let params = PortfolioGreeksParams::builder()
1519 .underlyings(underlyings.clone())
1520 .flat_interest_rate(0.04)
1521 .build();
1522
1523 assert_eq!(params.underlyings, Some(underlyings));
1524 assert_eq!(params.flat_interest_rate, 0.04);
1525 }
1526
1527 #[rstest]
1528 fn test_builders_with_empty_beta_weights() {
1529 let instrument_id = InstrumentId::from("NVDA.NASDAQ");
1530 let empty_beta_weights = HashMap::new();
1531
1532 let instrument_params = InstrumentGreeksParams::builder()
1533 .instrument_id(instrument_id)
1534 .beta_weights(empty_beta_weights.clone())
1535 .vol_beta_weights(empty_beta_weights.clone())
1536 .build();
1537
1538 let portfolio_params = PortfolioGreeksParams::builder()
1539 .beta_weights(empty_beta_weights.clone())
1540 .vol_beta_weights(empty_beta_weights.clone())
1541 .build();
1542
1543 assert_eq!(
1544 instrument_params.beta_weights,
1545 Some(empty_beta_weights.clone())
1546 );
1547 assert_eq!(portfolio_params.beta_weights, Some(empty_beta_weights));
1548 assert_eq!(instrument_params.vol_beta_weights, Some(HashMap::new()));
1549 assert_eq!(portfolio_params.vol_beta_weights, Some(HashMap::new()));
1550 }
1551
1552 #[rstest]
1553 fn test_builders_with_all_shocks() {
1554 let instrument_id = InstrumentId::from("AMD.NASDAQ");
1555
1556 let instrument_params = InstrumentGreeksParams::builder()
1557 .instrument_id(instrument_id)
1558 .spot_shock(0.05)
1559 .vol_shock(0.1)
1560 .time_to_expiry_shock(0.01)
1561 .build();
1562
1563 let portfolio_params = PortfolioGreeksParams::builder()
1564 .spot_shock(0.05)
1565 .vol_shock(0.1)
1566 .time_to_expiry_shock(0.01)
1567 .build();
1568
1569 assert_eq!(instrument_params.spot_shock, 0.05);
1570 assert_eq!(instrument_params.vol_shock, 0.1);
1571 assert_eq!(instrument_params.time_to_expiry_shock, 0.01);
1572
1573 assert_eq!(portfolio_params.spot_shock, 0.05);
1574 assert_eq!(portfolio_params.vol_shock, 0.1);
1575 assert_eq!(portfolio_params.time_to_expiry_shock, 0.01);
1576 }
1577
1578 #[rstest]
1579 fn test_builders_with_all_boolean_flags() {
1580 let instrument_id = InstrumentId::from("META.NASDAQ");
1581
1582 let instrument_params = InstrumentGreeksParams::builder()
1583 .instrument_id(instrument_id)
1584 .use_cached_greeks(true)
1585 .cache_greeks(true)
1586 .publish_greeks(true)
1587 .percent_greeks(true)
1588 .build();
1589
1590 let portfolio_params = PortfolioGreeksParams::builder()
1591 .use_cached_greeks(true)
1592 .cache_greeks(true)
1593 .publish_greeks(true)
1594 .percent_greeks(true)
1595 .build();
1596
1597 assert!(instrument_params.use_cached_greeks);
1598 assert!(instrument_params.cache_greeks);
1599 assert!(instrument_params.publish_greeks);
1600 assert!(instrument_params.percent_greeks);
1601
1602 assert!(portfolio_params.use_cached_greeks);
1603 assert!(portfolio_params.cache_greeks);
1604 assert!(portfolio_params.publish_greeks);
1605 assert!(portfolio_params.percent_greeks);
1606 }
1607
1608 #[rstest]
1609 fn test_greeks_filter_callback_function() {
1610 fn filter_positive_delta(data: &GreeksData) -> bool {
1612 data.delta > 0.0
1613 }
1614
1615 let filter = GreeksFilterCallback::from_fn(filter_positive_delta);
1616
1617 let greeks_data = GreeksData::from_delta(
1619 InstrumentId::from("TEST.NASDAQ"),
1620 0.5,
1621 1.0,
1622 UnixNanos::default(),
1623 );
1624
1625 assert!(filter.call(&greeks_data));
1626
1627 let debug_str = format!("{filter:?}");
1629 assert!(debug_str.contains("GreeksFilterCallback::Function"));
1630 }
1631
1632 #[rstest]
1633 fn test_greeks_filter_callback_closure() {
1634 let min_delta = 0.3;
1636 let filter =
1637 GreeksFilterCallback::from_closure(move |data: &GreeksData| data.delta > min_delta);
1638
1639 let greeks_data = GreeksData::from_delta(
1641 InstrumentId::from("TEST.NASDAQ"),
1642 0.5,
1643 1.0,
1644 UnixNanos::default(),
1645 );
1646
1647 assert!(filter.call(&greeks_data));
1648
1649 let debug_str = format!("{filter:?}");
1651 assert!(debug_str.contains("GreeksFilterCallback::Closure"));
1652 }
1653
1654 #[rstest]
1655 fn test_greeks_filter_callback_clone() {
1656 fn filter_fn(data: &GreeksData) -> bool {
1657 data.delta > 0.0
1658 }
1659
1660 let filter1 = GreeksFilterCallback::from_fn(filter_fn);
1661 let filter2 = filter1.clone();
1662
1663 let greeks_data = GreeksData::from_delta(
1664 InstrumentId::from("TEST.NASDAQ"),
1665 0.5,
1666 1.0,
1667 UnixNanos::default(),
1668 );
1669
1670 assert!(filter1.call(&greeks_data));
1671 assert!(filter2.call(&greeks_data));
1672 }
1673
1674 #[rstest]
1675 fn test_portfolio_greeks_params_with_filter() {
1676 fn filter_high_delta(data: &GreeksData) -> bool {
1677 data.delta.abs() > 0.1
1678 }
1679
1680 let filter = GreeksFilterCallback::from_fn(filter_high_delta);
1681
1682 let params = PortfolioGreeksParams::builder()
1683 .greeks_filter(filter)
1684 .flat_interest_rate(0.05)
1685 .build();
1686
1687 assert!(params.greeks_filter.is_some());
1688 assert_eq!(params.flat_interest_rate, 0.05);
1689
1690 let greeks_data = GreeksData::from_delta(
1692 InstrumentId::from("TEST.NASDAQ"),
1693 0.5,
1694 1.0,
1695 UnixNanos::default(),
1696 );
1697
1698 let filter_ref = params.greeks_filter.as_ref().unwrap();
1699 assert!(filter_ref.call(&greeks_data));
1700 }
1701
1702 #[rstest]
1703 fn test_portfolio_greeks_params_with_closure_filter() {
1704 let min_gamma = 0.01;
1705 let filter =
1706 GreeksFilterCallback::from_closure(move |data: &GreeksData| data.gamma > min_gamma);
1707
1708 let params = PortfolioGreeksParams::builder()
1709 .greeks_filter(filter)
1710 .build();
1711
1712 assert!(params.greeks_filter.is_some());
1713
1714 let debug_str = format!("{params:?}");
1716 assert!(debug_str.contains("greeks_filter"));
1717 }
1718
1719 #[rstest]
1720 fn test_greeks_filter_to_greeks_filter_conversion() {
1721 fn filter_fn(data: &GreeksData) -> bool {
1722 data.delta > 0.0
1723 }
1724
1725 let callback = GreeksFilterCallback::from_fn(filter_fn);
1726 let greeks_filter = callback.to_greeks_filter();
1727
1728 let greeks_data = GreeksData::from_delta(
1729 InstrumentId::from("TEST.NASDAQ"),
1730 0.5,
1731 1.0,
1732 UnixNanos::default(),
1733 );
1734
1735 assert!(greeks_filter(&greeks_data));
1736 }
1737
1738 fn option_with_expiration(instrument_id: &str, expiration_ns: UnixNanos) -> OptionContract {
1739 let activation_ns = UnixNanos::from(utc_timestamp(2021, 9, 17, 0, 0, 0));
1740 OptionContract::builder()
1741 .instrument_id(InstrumentId::from(instrument_id))
1742 .raw_symbol(Symbol::from("AAPL211217C00150000"))
1743 .asset_class(AssetClass::Equity)
1744 .exchange(Ustr::from("GMNI"))
1745 .underlying(Ustr::from("AAPL"))
1746 .option_kind(OptionKind::Call)
1747 .strike_price(Price::from("149.0"))
1748 .currency(Currency::from("USD"))
1749 .activation_ns(activation_ns)
1750 .expiration_ns(expiration_ns)
1751 .price_precision(2)
1752 .price_increment(Price::from("0.01"))
1753 .multiplier(Quantity::from(100))
1754 .lot_size(Quantity::from(1))
1755 .ts_event(UnixNanos::default())
1756 .ts_init(UnixNanos::default())
1757 .build()
1758 .unwrap()
1759 }
1760
1761 fn equity_aapl_opra() -> Equity {
1762 Equity::builder()
1763 .instrument_id(InstrumentId::from("AAPL.OPRA"))
1764 .raw_symbol(Symbol::from("AAPL"))
1765 .isin(Ustr::from("US0378331005"))
1766 .currency(Currency::from("USD"))
1767 .price_precision(2)
1768 .price_increment(Price::from("0.01"))
1769 .ts_event(UnixNanos::default())
1770 .ts_init(UnixNanos::default())
1771 .build()
1772 .unwrap()
1773 }
1774
1775 #[rstest]
1776 fn test_resolve_underlying_instrument_id_errors_without_underlying() {
1777 let instrument = InstrumentAny::Equity(equity_aapl_opra());
1778 let error = GreeksCalculator::resolve_underlying_instrument_id(
1779 &instrument,
1780 InstrumentId::from("AAPL.OPRA"),
1781 )
1782 .unwrap_err();
1783
1784 assert_eq!(
1785 error.to_string(),
1786 "Instrument AAPL.OPRA has no underlying identifier"
1787 );
1788 }
1789
1790 fn future_with_expiration(
1791 instrument_id: &str,
1792 underlying: &str,
1793 expiration_ns: UnixNanos,
1794 ) -> FuturesContract {
1795 FuturesContract::builder()
1796 .instrument_id(InstrumentId::from(instrument_id))
1797 .raw_symbol(Symbol::from(underlying))
1798 .asset_class(AssetClass::Index)
1799 .exchange(Ustr::from("XCME"))
1800 .underlying(Ustr::from(underlying))
1801 .activation_ns(UnixNanos::default())
1802 .expiration_ns(expiration_ns)
1803 .currency(Currency::from("USD"))
1804 .price_precision(2)
1805 .price_increment(Price::from("0.25"))
1806 .multiplier(Quantity::from(1))
1807 .lot_size(Quantity::from(1))
1808 .ts_event(UnixNanos::default())
1809 .ts_init(UnixNanos::default())
1810 .build()
1811 .unwrap()
1812 }
1813
1814 fn future_option_with_expiration(
1815 instrument_id: &str,
1816 raw_symbol: &str,
1817 underlying: &str,
1818 option_kind: OptionKind,
1819 strike: &str,
1820 expiration_ns: UnixNanos,
1821 ) -> OptionContract {
1822 OptionContract::builder()
1823 .instrument_id(InstrumentId::from(instrument_id))
1824 .raw_symbol(Symbol::from(raw_symbol))
1825 .asset_class(AssetClass::Index)
1826 .exchange(Ustr::from("XCME"))
1827 .underlying(Ustr::from(underlying))
1828 .option_kind(option_kind)
1829 .strike_price(Price::from(strike))
1830 .currency(Currency::from("USD"))
1831 .activation_ns(UnixNanos::default())
1832 .expiration_ns(expiration_ns)
1833 .price_precision(2)
1834 .price_increment(Price::from("0.01"))
1835 .multiplier(Quantity::from(1))
1836 .lot_size(Quantity::from(1))
1837 .ts_event(UnixNanos::default())
1838 .ts_init(UnixNanos::default())
1839 .build()
1840 .unwrap()
1841 }
1842
1843 fn setup_cache_with_option_and_quotes(
1844 option: OptionContract,
1845 underlying_id: InstrumentId,
1846 now_ns: UnixNanos,
1847 ) -> Rc<RefCell<Cache>> {
1848 let option_id = option.id();
1849 let cache = Rc::new(RefCell::new(Cache::new(None, None)));
1850 cache
1851 .borrow_mut()
1852 .add_instrument(InstrumentAny::OptionContract(option))
1853 .unwrap();
1854 cache
1855 .borrow_mut()
1856 .add_instrument(InstrumentAny::Equity(equity_aapl_opra()))
1857 .unwrap();
1858 let option_quote = QuoteTick::new(
1859 option_id,
1860 Price::from("10.50"),
1861 Price::from("10.60"),
1862 Quantity::from(100),
1863 Quantity::from(100),
1864 now_ns,
1865 now_ns,
1866 );
1867 let underlying_quote = QuoteTick::new(
1868 underlying_id,
1869 Price::from("150.00"),
1870 Price::from("150.10"),
1871 Quantity::from(100),
1872 Quantity::from(100),
1873 now_ns,
1874 now_ns,
1875 );
1876 cache.borrow_mut().add_quote(option_quote).unwrap();
1877 cache.borrow_mut().add_quote(underlying_quote).unwrap();
1878 cache
1879 }
1880
1881 fn position_from_fill(
1882 instrument: &InstrumentAny,
1883 position_id: &str,
1884 client_order_id: &str,
1885 trade_id: &str,
1886 side: OrderSide,
1887 quantity: u64,
1888 price: &str,
1889 ) -> Position {
1890 let fill = OrderFilledSpec::builder()
1891 .instrument_id(instrument.id())
1892 .client_order_id(ClientOrderId::from(client_order_id))
1893 .trade_id(TradeId::from(trade_id))
1894 .order_side(side)
1895 .last_qty(Quantity::from(quantity))
1896 .last_px(Price::from(price))
1897 .currency(Currency::USD())
1898 .position_id(PositionId::from(position_id))
1899 .build();
1900 Position::new(instrument, fill)
1901 }
1902
1903 fn calculate_portfolio_greeks(
1904 calculator: &GreeksCalculator,
1905 side: Option<PositionSide>,
1906 ) -> anyhow::Result<PortfolioGreeks> {
1907 calculator.portfolio_greeks(
1908 None, None, None, None, side, None, None, None, None, None, None, None, None, None,
1909 None, None, None, None, None, None, None,
1910 )
1911 }
1912
1913 fn assert_portfolio_greeks_eq(actual: &PortfolioGreeks, expected: &PortfolioGreeks) {
1914 assert_eq!(actual.ts_init, expected.ts_init);
1915 assert_eq!(actual.ts_event, expected.ts_event);
1916 assert_eq!(actual.pnl, expected.pnl);
1917 assert_eq!(actual.price, expected.price);
1918 assert_eq!(actual.delta, expected.delta);
1919 assert_eq!(actual.gamma, expected.gamma);
1920 assert_eq!(actual.vega, expected.vega);
1921 assert_eq!(actual.theta, expected.theta);
1922 assert_eq!(actual.rho, expected.rho);
1923 }
1924
1925 #[rstest]
1926 fn test_portfolio_greeks_ignores_closed_position_with_missing_price() {
1927 let now = utc_timestamp(2025, 3, 8, 12, 0, 0);
1928 let expiry = now + jiff::SignedDuration::from_hours(24 * 30);
1929 let now_ns = UnixNanos::from(now);
1930 let expiry_ns = UnixNanos::from(expiry);
1931 let open_option = option_with_expiration("AAPL250417C00150000.OPRA", expiry_ns);
1932 let open_option_id = open_option.id();
1933 let underlying_id = InstrumentId::from("AAPL.OPRA");
1934 let cache = setup_cache_with_option_and_quotes(open_option.clone(), underlying_id, now_ns);
1935 let closed_future = future_with_expiration("CLOSED.GLBX", "CLOSED", expiry_ns);
1936 let closed_future_id = closed_future.id();
1937 let open_instrument = InstrumentAny::OptionContract(open_option);
1938 let closed_instrument = InstrumentAny::FuturesContract(closed_future);
1939
1940 let open_position = position_from_fill(
1941 &open_instrument,
1942 "P-OPEN",
1943 "O-OPEN",
1944 "T-OPEN",
1945 OrderSide::Buy,
1946 2,
1947 "10.50",
1948 );
1949 let mut closed_position = position_from_fill(
1950 &closed_instrument,
1951 "P-CLOSED",
1952 "O-CLOSED-OPEN",
1953 "T-CLOSED-OPEN",
1954 OrderSide::Buy,
1955 1,
1956 "100.00",
1957 );
1958 cache
1959 .borrow_mut()
1960 .add_instrument(closed_instrument)
1961 .unwrap();
1962 cache
1963 .borrow_mut()
1964 .add_position(&open_position, OmsType::Hedging)
1965 .unwrap();
1966 cache
1967 .borrow_mut()
1968 .add_position(&closed_position, OmsType::Hedging)
1969 .unwrap();
1970 let closing_fill = OrderFilledSpec::builder()
1971 .instrument_id(closed_future_id)
1972 .client_order_id(ClientOrderId::from("O-CLOSED-CLOSE"))
1973 .trade_id(TradeId::from("T-CLOSED-CLOSE"))
1974 .order_side(OrderSide::Sell)
1975 .last_qty(Quantity::from(1))
1976 .last_px(Price::from("101.00"))
1977 .currency(Currency::USD())
1978 .position_id(PositionId::from("P-CLOSED"))
1979 .build();
1980 closed_position.apply(&closing_fill);
1981 cache
1982 .borrow_mut()
1983 .update_position(&closed_position)
1984 .unwrap();
1985
1986 assert!(closed_position.is_closed());
1990 assert_eq!(
1991 cache
1992 .borrow()
1993 .positions_open(None, None, None, None, None)
1994 .len(),
1995 1
1996 );
1997
1998 let clock = Rc::new(RefCell::new(VirtualClock::new()));
1999 clock.borrow_mut().set_time(now_ns);
2000 let calculator = GreeksCalculator::new(cache, clock);
2001 let expected = calculator
2002 .instrument_greeks(
2003 open_option_id,
2004 None,
2005 None,
2006 None,
2007 None,
2008 None,
2009 None,
2010 None,
2011 None,
2012 None,
2013 Some(now_ns),
2014 Some(open_position.clone()),
2015 None,
2016 None,
2017 None,
2018 None,
2019 None,
2020 None,
2021 )
2022 .unwrap();
2023 let expected = PortfolioGreeks::from(open_position.signed_qty * &expected);
2024
2025 assert_ne!(expected.delta, 0.0);
2026 assert_portfolio_greeks_eq(
2027 &calculate_portfolio_greeks(&calculator, None).unwrap(),
2028 &expected,
2029 );
2030 assert_portfolio_greeks_eq(
2031 &calculate_portfolio_greeks(&calculator, Some(PositionSide::Flat)).unwrap(),
2032 &PortfolioGreeks::new(now_ns, now_ns, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0),
2033 );
2034 }
2035
2036 #[rstest]
2037 fn test_portfolio_greeks_preserves_open_position_aggregate_and_side_filters() {
2038 let now = utc_timestamp(2025, 3, 8, 12, 0, 0);
2039 let expiry = now + jiff::SignedDuration::from_hours(24 * 30);
2040 let now_ns = UnixNanos::from(now);
2041 let expiry_ns = UnixNanos::from(expiry);
2042 let long_option = option_with_expiration("AAPL250417C00145000.OPRA", expiry_ns);
2043 let short_option = option_with_expiration("AAPL250417C00155000.OPRA", expiry_ns);
2044 let long_instrument = InstrumentAny::OptionContract(long_option.clone());
2045 let short_instrument = InstrumentAny::OptionContract(short_option.clone());
2046 let underlying_id = InstrumentId::from("AAPL.OPRA");
2047 let cache = setup_cache_with_option_and_quotes(long_option, underlying_id, now_ns);
2048 cache
2049 .borrow_mut()
2050 .add_instrument(short_instrument.clone())
2051 .unwrap();
2052 cache
2053 .borrow_mut()
2054 .add_quote(QuoteTick::new(
2055 short_option.id(),
2056 Price::from("3.50"),
2057 Price::from("3.60"),
2058 Quantity::from(100),
2059 Quantity::from(100),
2060 now_ns,
2061 now_ns,
2062 ))
2063 .unwrap();
2064 let long_position = position_from_fill(
2065 &long_instrument,
2066 "P-LONG",
2067 "O-LONG",
2068 "T-LONG",
2069 OrderSide::Buy,
2070 3,
2071 "10.50",
2072 );
2073 let short_position = position_from_fill(
2074 &short_instrument,
2075 "P-SHORT",
2076 "O-SHORT",
2077 "T-SHORT",
2078 OrderSide::Sell,
2079 2,
2080 "3.50",
2081 );
2082 cache
2083 .borrow_mut()
2084 .add_position(&long_position, OmsType::Hedging)
2085 .unwrap();
2086 cache
2087 .borrow_mut()
2088 .add_position(&short_position, OmsType::Hedging)
2089 .unwrap();
2090
2091 let clock = Rc::new(RefCell::new(VirtualClock::new()));
2092 clock.borrow_mut().set_time(now_ns);
2093 let calculator = GreeksCalculator::new(cache, clock);
2094 let long_greeks = calculator
2095 .instrument_greeks(
2096 long_instrument.id(),
2097 None,
2098 None,
2099 None,
2100 None,
2101 None,
2102 None,
2103 None,
2104 None,
2105 None,
2106 Some(now_ns),
2107 Some(long_position.clone()),
2108 None,
2109 None,
2110 None,
2111 None,
2112 None,
2113 None,
2114 )
2115 .unwrap();
2116 let short_greeks = calculator
2117 .instrument_greeks(
2118 short_instrument.id(),
2119 None,
2120 None,
2121 None,
2122 None,
2123 None,
2124 None,
2125 None,
2126 None,
2127 None,
2128 Some(now_ns),
2129 Some(short_position.clone()),
2130 None,
2131 None,
2132 None,
2133 None,
2134 None,
2135 None,
2136 )
2137 .unwrap();
2138 let expected_long = PortfolioGreeks::from(long_position.signed_qty * &long_greeks);
2139 let expected_short = PortfolioGreeks::from(short_position.signed_qty * &short_greeks);
2140 let expected = expected_long + expected_short;
2141
2142 assert_ne!(expected.pnl, 0.0);
2143 assert_ne!(expected.price, 0.0);
2144 assert_ne!(expected.delta, 0.0);
2145 assert_ne!(expected.gamma, 0.0);
2146 assert_ne!(expected.vega, 0.0);
2147 assert_ne!(expected.theta, 0.0);
2148 assert_portfolio_greeks_eq(
2149 &calculate_portfolio_greeks(&calculator, None).unwrap(),
2150 &expected,
2151 );
2152 assert_portfolio_greeks_eq(
2153 &calculate_portfolio_greeks(&calculator, Some(PositionSide::Long)).unwrap(),
2154 &PortfolioGreeks::from(long_position.signed_qty * &long_greeks),
2155 );
2156 assert_portfolio_greeks_eq(
2157 &calculate_portfolio_greeks(&calculator, Some(PositionSide::Short)).unwrap(),
2158 &PortfolioGreeks::from(short_position.signed_qty * &short_greeks),
2159 );
2160 }
2161
2162 #[rstest]
2163 fn test_expiry_in_days_multi_day_unchanged() {
2164 let now = utc_timestamp(2025, 3, 8, 12, 0, 0);
2165 let expiry = now + jiff::SignedDuration::from_hours(24 * (30));
2166 let now_ns = UnixNanos::from(now);
2167 let expiry_ns = UnixNanos::from(expiry);
2168 let option = option_with_expiration("AAPL250417C00150000.OPRA", expiry_ns);
2169 let option_id = option.id();
2170 let underlying_id = InstrumentId::from("AAPL.OPRA");
2171 let cache = setup_cache_with_option_and_quotes(option, underlying_id, now_ns);
2172 let clock = Rc::new(RefCell::new(VirtualClock::new()));
2173 let calculator = GreeksCalculator::new(cache, clock);
2174
2175 let greeks = calculator
2176 .instrument_greeks(
2177 option_id,
2178 None,
2179 None,
2180 None,
2181 None,
2182 None,
2183 None,
2184 None,
2185 None,
2186 None,
2187 Some(now_ns),
2188 None,
2189 None,
2190 None,
2191 None,
2192 None,
2193 None,
2194 None,
2195 )
2196 .unwrap();
2197
2198 assert_eq!(greeks.expiry_in_days, 30);
2199 assert!((greeks.expiry_in_years - 30.0 / 365.25).abs() < 1e-9);
2200 }
2201
2202 #[rstest]
2203 fn test_expiry_in_days_same_day_clamped_to_one() {
2204 let now = utc_timestamp(2025, 3, 8, 12, 0, 0);
2205 let expiry_same_day = utc_timestamp(2025, 3, 8, 18, 0, 0);
2206 let now_ns = UnixNanos::from(now);
2207 let expiry_ns = UnixNanos::from(expiry_same_day);
2208 let option = option_with_expiration("AAPL250308C00150000.OPRA", expiry_ns);
2209 let option_id = option.id();
2210 let underlying_id = InstrumentId::from("AAPL.OPRA");
2211 let cache = setup_cache_with_option_and_quotes(option, underlying_id, now_ns);
2212 let clock = Rc::new(RefCell::new(VirtualClock::new()));
2213 let calculator = GreeksCalculator::new(cache, clock);
2214
2215 let greeks = calculator
2216 .instrument_greeks(
2217 option_id,
2218 None,
2219 None,
2220 None,
2221 None,
2222 None,
2223 None,
2224 None,
2225 None,
2226 None,
2227 Some(now_ns),
2228 None,
2229 None,
2230 None,
2231 None,
2232 None,
2233 None,
2234 None,
2235 )
2236 .unwrap();
2237
2238 assert_eq!(greeks.expiry_in_days, 1);
2239 assert!((greeks.expiry_in_years - 1.0 / 365.25).abs() < 1e-9);
2240 }
2241
2242 #[rstest]
2243 fn test_instrument_greeks_beta_weights_vega_to_vol_index() {
2244 let now = utc_timestamp(2025, 3, 8, 12, 0, 0);
2245 let expiry = now + jiff::SignedDuration::from_hours(24 * (30));
2246 let now_ns = UnixNanos::from(now);
2247 let expiry_ns = UnixNanos::from(expiry);
2248 let option = option_with_expiration("AAPL250417C00150000.OPRA", expiry_ns);
2249 let option_id = option.id();
2250 let underlying_id = InstrumentId::from("AAPL.OPRA");
2251 let vol_index_id = InstrumentId::from("VIX.XCBF");
2252 let cache = setup_cache_with_option_and_quotes(option, underlying_id, now_ns);
2253 cache
2254 .borrow_mut()
2255 .add_quote(QuoteTick::new(
2256 vol_index_id,
2257 Price::from("25.00"),
2258 Price::from("25.00"),
2259 Quantity::from(100),
2260 Quantity::from(100),
2261 now_ns,
2262 now_ns,
2263 ))
2264 .unwrap();
2265
2266 let clock = Rc::new(RefCell::new(VirtualClock::new()));
2267 let calculator = GreeksCalculator::new(cache, clock);
2268 let greeks = calculator
2269 .instrument_greeks(
2270 option_id,
2271 None,
2272 None,
2273 None,
2274 None,
2275 None,
2276 None,
2277 None,
2278 None,
2279 None,
2280 Some(now_ns),
2281 None,
2282 None,
2283 None,
2284 None,
2285 None,
2286 None,
2287 None,
2288 )
2289 .unwrap();
2290
2291 let mut vol_beta_weights = HashMap::new();
2292 vol_beta_weights.insert(underlying_id, 0.75);
2293 let vol_weighted_greeks = calculator
2294 .instrument_greeks(
2295 option_id,
2296 None,
2297 None,
2298 None,
2299 None,
2300 None,
2301 None,
2302 None,
2303 None,
2304 None,
2305 Some(now_ns),
2306 None,
2307 None,
2308 None,
2309 None,
2310 None,
2311 Some(vol_index_id),
2312 Some(&vol_beta_weights),
2313 )
2314 .unwrap();
2315
2316 let expected_vega = greeks.vega * 0.75 * (greeks.vol * 100.0) / 25.0;
2317 assert_eq!(
2318 (vol_weighted_greeks.delta * 1e12).round(),
2319 (greeks.delta * 1e12).round()
2320 );
2321 assert_eq!(
2322 (vol_weighted_greeks.gamma * 1e12).round(),
2323 (greeks.gamma * 1e12).round()
2324 );
2325 assert_eq!(
2326 (vol_weighted_greeks.vega * 1e12).round(),
2327 (expected_vega * 1e12).round()
2328 );
2329 }
2330
2331 #[rstest]
2332 fn test_instrument_greeks_errors_when_vol_index_price_missing() {
2333 let now = utc_timestamp(2025, 3, 8, 12, 0, 0);
2334 let expiry = now + jiff::SignedDuration::from_hours(24 * (30));
2335 let now_ns = UnixNanos::from(now);
2336 let expiry_ns = UnixNanos::from(expiry);
2337 let option = option_with_expiration("AAPL250417C00150000.OPRA", expiry_ns);
2338 let option_id = option.id();
2339 let underlying_id = InstrumentId::from("AAPL.OPRA");
2340 let vol_index_id = InstrumentId::from("VIX.XCBF");
2341 let cache = setup_cache_with_option_and_quotes(option, underlying_id, now_ns);
2342
2343 let clock = Rc::new(RefCell::new(VirtualClock::new()));
2344 let calculator = GreeksCalculator::new(cache, clock);
2345 let error = calculator
2346 .instrument_greeks(
2347 option_id,
2348 None,
2349 None,
2350 None,
2351 None,
2352 None,
2353 None,
2354 None,
2355 None,
2356 None,
2357 Some(now_ns),
2358 None,
2359 None,
2360 None,
2361 None,
2362 None,
2363 Some(vol_index_id),
2364 None,
2365 )
2366 .unwrap_err();
2367
2368 assert_eq!(error.to_string(), "No price available for VIX.XCBF");
2369 }
2370
2371 #[rstest]
2372 fn test_modify_greeks_errors_when_vol_index_price_missing() {
2373 let calculator = create_test_calculator();
2374 let underlying_id = InstrumentId::from("AAPL.OPRA");
2375 let vol_index_id = InstrumentId::from("VIX.XCBF");
2376
2377 let error = calculator
2378 .modify_greeks(
2379 1.0,
2380 2.0,
2381 underlying_id,
2382 150.0,
2383 150.0,
2384 false,
2385 None,
2386 None,
2387 2.0,
2388 0.30,
2389 0,
2390 None,
2391 0.0,
2392 Some(vol_index_id),
2393 None,
2394 None,
2395 None,
2396 )
2397 .unwrap_err();
2398
2399 assert_eq!(error.to_string(), "No price available for VIX.XCBF");
2400 }
2401
2402 #[rstest]
2403 fn test_modify_greeks_accepts_explicit_index_prices() {
2404 let calculator = create_test_calculator();
2405 let underlying_id = InstrumentId::from("AAPL.OPRA");
2406 let mut beta_weights = HashMap::new();
2407 beta_weights.insert(underlying_id, 0.5);
2408 let mut vol_beta_weights = HashMap::new();
2409 vol_beta_weights.insert(underlying_id, 0.75);
2410
2411 let (delta, gamma, vega) = calculator
2412 .modify_greeks(
2413 1.0,
2414 2.0,
2415 underlying_id,
2416 150.0,
2417 150.0,
2418 false,
2419 None,
2420 Some(&beta_weights),
2421 2.0,
2422 0.30,
2423 0,
2424 None,
2425 0.0,
2426 None,
2427 Some(&vol_beta_weights),
2428 Some(200.0),
2429 Some(25.0),
2430 )
2431 .unwrap();
2432
2433 assert_eq!((delta * 1e12).round(), 375_000_000_000.0);
2434 assert_eq!((gamma * 1e12).round(), 281_250_000_000.0);
2435 assert_eq!((vega * 1e12).round(), 1_800_000_000_000.0);
2436
2437 let (delta, gamma, vega) = calculator
2438 .modify_greeks(
2439 1.0,
2440 2.0,
2441 underlying_id,
2442 150.0,
2443 150.0,
2444 true,
2445 None,
2446 Some(&beta_weights),
2447 2.0,
2448 0.30,
2449 0,
2450 None,
2451 0.0,
2452 None,
2453 Some(&vol_beta_weights),
2454 Some(200.0),
2455 Some(25.0),
2456 )
2457 .unwrap();
2458
2459 assert_eq!((delta * 1e12).round(), 750_000_000_000.0);
2460 assert_eq!((gamma * 1e12).round(), 1_125_000_000_000.0);
2461 assert_eq!((vega * 1e12).round(), 4_500_000_000.0);
2462 }
2463
2464 #[rstest]
2465 fn test_instrument_greeks_errors_when_future_underlying_price_missing_without_cached_spread() {
2466 let now = utc_timestamp(2024, 2, 14, 16, 0, 0);
2467 let expiry = utc_timestamp(2024, 3, 15, 16, 0, 0);
2468 let now_ns = UnixNanos::from(now);
2469 let expiry_ns = UnixNanos::from(expiry);
2470
2471 let future = future_with_expiration("ESH4.GLBX", "ESH4", expiry_ns);
2472 let call_option = future_option_with_expiration(
2473 "ESH4C150.GLBX",
2474 "ESH4C150",
2475 "ESH4",
2476 OptionKind::Call,
2477 "150.00",
2478 expiry_ns,
2479 );
2480 let put_option = future_option_with_expiration(
2481 "ESH4P150.GLBX",
2482 "ESH4P150",
2483 "ESH4",
2484 OptionKind::Put,
2485 "150.00",
2486 expiry_ns,
2487 );
2488
2489 let cache = Rc::new(RefCell::new(Cache::new(None, None)));
2490 cache
2491 .borrow_mut()
2492 .add_instrument(InstrumentAny::FuturesContract(future))
2493 .unwrap();
2494 cache
2495 .borrow_mut()
2496 .add_instrument(InstrumentAny::OptionContract(call_option.clone()))
2497 .unwrap();
2498 cache
2499 .borrow_mut()
2500 .add_instrument(InstrumentAny::OptionContract(put_option.clone()))
2501 .unwrap();
2502
2503 let call_quote = QuoteTick::new(
2504 call_option.id(),
2505 Price::from("8.50"),
2506 Price::from("8.50"),
2507 Quantity::from(100),
2508 Quantity::from(100),
2509 now_ns,
2510 now_ns,
2511 );
2512 let put_quote = QuoteTick::new(
2513 put_option.id(),
2514 Price::from("3.33"),
2515 Price::from("3.33"),
2516 Quantity::from(100),
2517 Quantity::from(100),
2518 now_ns,
2519 now_ns,
2520 );
2521 cache.borrow_mut().add_quote(call_quote).unwrap();
2522 cache.borrow_mut().add_quote(put_quote).unwrap();
2523
2524 let clock = Rc::new(RefCell::new(VirtualClock::new()));
2525 clock.borrow_mut().set_time(now_ns);
2526 let calculator = GreeksCalculator::new(cache, clock);
2527
2528 let error = calculator
2529 .instrument_greeks(
2530 call_option.id(),
2531 Some(0.0425),
2532 None,
2533 None,
2534 None,
2535 None,
2536 None,
2537 None,
2538 None,
2539 None,
2540 Some(now_ns),
2541 None,
2542 None,
2543 None,
2544 None,
2545 None,
2546 None,
2547 None,
2548 )
2549 .unwrap_err();
2550
2551 assert_eq!(error.to_string(), "No price available for ESH4.GLBX");
2552 }
2553
2554 #[rstest]
2555 fn test_cache_futures_spread_returns_price_to_reference_future() {
2556 let now = utc_timestamp(2024, 2, 14, 16, 0, 0);
2557 let expiry = utc_timestamp(2024, 3, 15, 16, 0, 0);
2558 let now_ns = UnixNanos::from(now);
2559 let expiry_ns = UnixNanos::from(expiry);
2560
2561 let future = future_with_expiration("ESH4.GLBX", "ESH4", expiry_ns);
2562 let reference_future = future_with_expiration("ESM4.GLBX", "ESM4", expiry_ns);
2563 let call_option = future_option_with_expiration(
2564 "ESH4C150.GLBX",
2565 "ESH4C150",
2566 "ESH4",
2567 OptionKind::Call,
2568 "150.00",
2569 expiry_ns,
2570 );
2571 let put_option = future_option_with_expiration(
2572 "ESH4P150.GLBX",
2573 "ESH4P150",
2574 "ESH4",
2575 OptionKind::Put,
2576 "150.00",
2577 expiry_ns,
2578 );
2579
2580 let cache = Rc::new(RefCell::new(Cache::new(None, None)));
2581 cache
2582 .borrow_mut()
2583 .add_instrument(InstrumentAny::FuturesContract(future))
2584 .unwrap();
2585 cache
2586 .borrow_mut()
2587 .add_instrument(InstrumentAny::FuturesContract(reference_future.clone()))
2588 .unwrap();
2589 cache
2590 .borrow_mut()
2591 .add_instrument(InstrumentAny::OptionContract(call_option.clone()))
2592 .unwrap();
2593 cache
2594 .borrow_mut()
2595 .add_instrument(InstrumentAny::OptionContract(put_option.clone()))
2596 .unwrap();
2597
2598 let call_quote = QuoteTick::new(
2599 call_option.id(),
2600 Price::from("8.50"),
2601 Price::from("8.50"),
2602 Quantity::from(100),
2603 Quantity::from(100),
2604 now_ns,
2605 now_ns,
2606 );
2607 let put_quote = QuoteTick::new(
2608 put_option.id(),
2609 Price::from("3.33"),
2610 Price::from("3.33"),
2611 Quantity::from(100),
2612 Quantity::from(100),
2613 now_ns,
2614 now_ns,
2615 );
2616 let reference_future_quote = QuoteTick::new(
2617 reference_future.id(),
2618 Price::from("155.00"),
2619 Price::from("155.00"),
2620 Quantity::from(100),
2621 Quantity::from(100),
2622 now_ns,
2623 now_ns,
2624 );
2625 cache.borrow_mut().add_quote(call_quote).unwrap();
2626 cache.borrow_mut().add_quote(put_quote).unwrap();
2627 cache
2628 .borrow_mut()
2629 .add_quote(reference_future_quote)
2630 .unwrap();
2631
2632 let clock = Rc::new(RefCell::new(VirtualClock::new()));
2633 clock.borrow_mut().set_time(now_ns);
2634 let calculator = GreeksCalculator::new(cache, clock);
2635
2636 let cached_future_price = calculator
2637 .cache_futures_spread(call_option.id(), put_option.id(), reference_future.id())
2638 .unwrap();
2639
2640 let expected_underlying = 150.0 + (0.0425_f64 * (30.0 / 365.25)).exp() * (8.50 - 3.33);
2641 let expected_cached_underlying = reference_future.make_price(expected_underlying);
2642 assert_eq!(cached_future_price, expected_cached_underlying);
2643 assert_eq!(
2644 calculator.get_cached_futures_spread_price(InstrumentId::from("ESH4.GLBX")),
2645 Some(expected_cached_underlying)
2646 );
2647 }
2648
2649 #[rstest]
2650 fn test_instrument_greeks_uses_cached_futures_spread_when_underlying_price_missing() {
2651 let now = utc_timestamp(2024, 2, 14, 16, 0, 0);
2652 let expiry = utc_timestamp(2024, 3, 15, 16, 0, 0);
2653 let now_ns = UnixNanos::from(now);
2654 let expiry_ns = UnixNanos::from(expiry);
2655
2656 let future = future_with_expiration("ESH4.GLBX", "ESH4", expiry_ns);
2657 let reference_future = future_with_expiration("ESM4.GLBX", "ESM4", expiry_ns);
2658 let call_option = future_option_with_expiration(
2659 "ESH4C150.GLBX",
2660 "ESH4C150",
2661 "ESH4",
2662 OptionKind::Call,
2663 "150.00",
2664 expiry_ns,
2665 );
2666 let put_option = future_option_with_expiration(
2667 "ESH4P150.GLBX",
2668 "ESH4P150",
2669 "ESH4",
2670 OptionKind::Put,
2671 "150.00",
2672 expiry_ns,
2673 );
2674 let target_call_option = future_option_with_expiration(
2675 "ESH4C152.GLBX",
2676 "ESH4C152",
2677 "ESH4",
2678 OptionKind::Call,
2679 "152.00",
2680 expiry_ns,
2681 );
2682
2683 let cache = Rc::new(RefCell::new(Cache::new(None, None)));
2684 cache
2685 .borrow_mut()
2686 .add_instrument(InstrumentAny::FuturesContract(future))
2687 .unwrap();
2688 cache
2689 .borrow_mut()
2690 .add_instrument(InstrumentAny::FuturesContract(reference_future.clone()))
2691 .unwrap();
2692 cache
2693 .borrow_mut()
2694 .add_instrument(InstrumentAny::OptionContract(call_option.clone()))
2695 .unwrap();
2696 cache
2697 .borrow_mut()
2698 .add_instrument(InstrumentAny::OptionContract(put_option.clone()))
2699 .unwrap();
2700 cache
2701 .borrow_mut()
2702 .add_instrument(InstrumentAny::OptionContract(target_call_option.clone()))
2703 .unwrap();
2704
2705 let call_quote = QuoteTick::new(
2706 call_option.id(),
2707 Price::from("8.50"),
2708 Price::from("8.50"),
2709 Quantity::from(100),
2710 Quantity::from(100),
2711 now_ns,
2712 now_ns,
2713 );
2714 let put_quote = QuoteTick::new(
2715 put_option.id(),
2716 Price::from("3.33"),
2717 Price::from("3.33"),
2718 Quantity::from(100),
2719 Quantity::from(100),
2720 now_ns,
2721 now_ns,
2722 );
2723 let target_call_quote = QuoteTick::new(
2724 target_call_option.id(),
2725 Price::from("6.75"),
2726 Price::from("6.75"),
2727 Quantity::from(100),
2728 Quantity::from(100),
2729 now_ns,
2730 now_ns,
2731 );
2732 let reference_future_quote = QuoteTick::new(
2733 reference_future.id(),
2734 Price::from("155.00"),
2735 Price::from("155.00"),
2736 Quantity::from(100),
2737 Quantity::from(100),
2738 now_ns,
2739 now_ns,
2740 );
2741 cache.borrow_mut().add_quote(call_quote).unwrap();
2742 cache.borrow_mut().add_quote(put_quote).unwrap();
2743 cache.borrow_mut().add_quote(target_call_quote).unwrap();
2744 cache
2745 .borrow_mut()
2746 .add_quote(reference_future_quote)
2747 .unwrap();
2748
2749 let clock = Rc::new(RefCell::new(VirtualClock::new()));
2750 clock.borrow_mut().set_time(now_ns);
2751 let calculator = GreeksCalculator::new(cache, clock);
2752 calculator
2753 .cache_futures_spread(call_option.id(), put_option.id(), reference_future.id())
2754 .unwrap();
2755
2756 let greeks = calculator
2757 .instrument_greeks(
2758 target_call_option.id(),
2759 Some(0.0425),
2760 None,
2761 None,
2762 None,
2763 None,
2764 None,
2765 None,
2766 None,
2767 None,
2768 Some(now_ns),
2769 None,
2770 None,
2771 None,
2772 None,
2773 None,
2774 None,
2775 None,
2776 )
2777 .unwrap();
2778
2779 let expected_underlying = reference_future
2780 .make_price(150.0 + (0.0425_f64 * (30.0 / 365.25)).exp() * (8.50 - 3.33))
2781 .as_f64();
2782 assert_eq!(greeks.underlying_price, expected_underlying);
2783 }
2784
2785 #[rstest]
2786 fn test_instrument_greeks_uses_index_price_for_index_underlying() {
2787 let now = utc_timestamp(2024, 2, 14, 16, 0, 0);
2788 let expiry = utc_timestamp(2024, 3, 15, 16, 0, 0);
2789 let now_ns = UnixNanos::from(now);
2790 let expiry_ns = UnixNanos::from(expiry);
2791
2792 let future = future_with_expiration("ESH4.GLBX", "ESH4", expiry_ns);
2793 let call_option = future_option_with_expiration(
2794 "ESH4C150.GLBX",
2795 "ESH4C150",
2796 "ESH4",
2797 OptionKind::Call,
2798 "150.00",
2799 expiry_ns,
2800 );
2801
2802 let cache = Rc::new(RefCell::new(Cache::new(None, None)));
2803 cache
2804 .borrow_mut()
2805 .add_instrument(InstrumentAny::FuturesContract(future))
2806 .unwrap();
2807 cache
2808 .borrow_mut()
2809 .add_instrument(InstrumentAny::OptionContract(call_option.clone()))
2810 .unwrap();
2811
2812 let call_quote = QuoteTick::new(
2813 call_option.id(),
2814 Price::from("8.50"),
2815 Price::from("8.50"),
2816 Quantity::from(100),
2817 Quantity::from(100),
2818 now_ns,
2819 now_ns,
2820 );
2821 cache.borrow_mut().add_quote(call_quote).unwrap();
2822 cache
2823 .borrow_mut()
2824 .add_index_price(IndexPriceUpdate::new(
2825 InstrumentId::from("ESH4.GLBX"),
2826 Price::from("157.25"),
2827 now_ns,
2828 now_ns,
2829 ))
2830 .unwrap();
2831
2832 let clock = Rc::new(RefCell::new(VirtualClock::new()));
2833 clock.borrow_mut().set_time(now_ns);
2834 let calculator = GreeksCalculator::new(cache, clock);
2835
2836 let greeks = calculator
2837 .instrument_greeks(
2838 call_option.id(),
2839 Some(0.0425),
2840 None,
2841 None,
2842 None,
2843 None,
2844 None,
2845 None,
2846 None,
2847 None,
2848 Some(now_ns),
2849 None,
2850 None,
2851 None,
2852 None,
2853 None,
2854 None,
2855 None,
2856 )
2857 .unwrap();
2858
2859 assert_eq!(greeks.underlying_price, 157.25);
2860 }
2861
2862 #[rstest]
2863 fn test_instrument_greeks_prefers_quote_over_index_price_for_index_future() {
2864 let now = utc_timestamp(2024, 2, 14, 16, 0, 0);
2865 let expiry = utc_timestamp(2024, 3, 15, 16, 0, 0);
2866 let now_ns = UnixNanos::from(now);
2867 let expiry_ns = UnixNanos::from(expiry);
2868
2869 let future = future_with_expiration("ESH4.GLBX", "ESH4", expiry_ns);
2870 let call_option = future_option_with_expiration(
2871 "ESH4C150.GLBX",
2872 "ESH4C150",
2873 "ESH4",
2874 OptionKind::Call,
2875 "150.00",
2876 expiry_ns,
2877 );
2878
2879 let cache = Rc::new(RefCell::new(Cache::new(None, None)));
2880 cache
2881 .borrow_mut()
2882 .add_instrument(InstrumentAny::FuturesContract(future))
2883 .unwrap();
2884 cache
2885 .borrow_mut()
2886 .add_instrument(InstrumentAny::OptionContract(call_option.clone()))
2887 .unwrap();
2888
2889 let future_quote = QuoteTick::new(
2891 InstrumentId::from("ESH4.GLBX"),
2892 Price::from("158.50"),
2893 Price::from("159.50"),
2894 Quantity::from(100),
2895 Quantity::from(100),
2896 now_ns,
2897 now_ns,
2898 );
2899 cache.borrow_mut().add_quote(future_quote).unwrap();
2900 cache
2901 .borrow_mut()
2902 .add_index_price(IndexPriceUpdate::new(
2903 InstrumentId::from("ESH4.GLBX"),
2904 Price::from("157.25"),
2905 now_ns,
2906 now_ns,
2907 ))
2908 .unwrap();
2909
2910 let call_quote = QuoteTick::new(
2911 call_option.id(),
2912 Price::from("8.50"),
2913 Price::from("8.50"),
2914 Quantity::from(100),
2915 Quantity::from(100),
2916 now_ns,
2917 now_ns,
2918 );
2919 cache.borrow_mut().add_quote(call_quote).unwrap();
2920
2921 let clock = Rc::new(RefCell::new(VirtualClock::new()));
2922 clock.borrow_mut().set_time(now_ns);
2923 let calculator = GreeksCalculator::new(cache, clock);
2924
2925 let greeks = calculator
2926 .instrument_greeks(
2927 call_option.id(),
2928 Some(0.0425),
2929 None,
2930 None,
2931 None,
2932 None,
2933 None,
2934 None,
2935 None,
2936 None,
2937 Some(now_ns),
2938 None,
2939 None,
2940 None,
2941 None,
2942 None,
2943 None,
2944 None,
2945 )
2946 .unwrap();
2947
2948 assert_eq!(greeks.underlying_price, 159.0);
2950 }
2951
2952 fn option_calculator() -> (GreeksCalculator, InstrumentId, UnixNanos) {
2955 let now = utc_timestamp(2025, 3, 8, 12, 0, 0);
2956 let expiry = now + jiff::SignedDuration::from_hours(24 * 30);
2957 let now_ns = UnixNanos::from(now);
2958 let option = option_with_expiration("AAPL250417C00150000.OPRA", UnixNanos::from(expiry));
2959 let option_id = option.id();
2960 let cache =
2961 setup_cache_with_option_and_quotes(option, InstrumentId::from("AAPL.OPRA"), now_ns);
2962 let clock = Rc::new(RefCell::new(VirtualClock::new()));
2963
2964 (GreeksCalculator::new(cache, clock), option_id, now_ns)
2965 }
2966
2967 fn option_greeks_with_shocks(
2968 calculator: &GreeksCalculator,
2969 option_id: InstrumentId,
2970 ts_event: UnixNanos,
2971 spot_shock: Option<f64>,
2972 vol_shock: Option<f64>,
2973 time_to_expiry_shock: Option<f64>,
2974 ) -> GreeksData {
2975 calculator
2976 .instrument_greeks(
2977 option_id,
2978 None,
2979 None,
2980 spot_shock,
2981 vol_shock,
2982 time_to_expiry_shock,
2983 None,
2984 None,
2985 None,
2986 None,
2987 Some(ts_event),
2988 None,
2989 None,
2990 None,
2991 None,
2992 None,
2993 None,
2994 None,
2995 )
2996 .unwrap()
2997 }
2998
2999 #[rstest]
3000 #[case::spot(Some(5.0), None, None)]
3001 #[case::vol(None, Some(0.05), None)]
3002 #[case::time_to_expiry(None, None, Some(0.01))]
3003 fn test_instrument_greeks_applies_each_shock_dimension_on_its_own(
3004 #[case] spot_shock: Option<f64>,
3005 #[case] vol_shock: Option<f64>,
3006 #[case] time_to_expiry_shock: Option<f64>,
3007 ) {
3008 let (calculator, option_id, now_ns) = option_calculator();
3009
3010 let unshocked = option_greeks_with_shocks(&calculator, option_id, now_ns, None, None, None);
3011 let shocked = option_greeks_with_shocks(
3012 &calculator,
3013 option_id,
3014 now_ns,
3015 spot_shock,
3016 vol_shock,
3017 time_to_expiry_shock,
3018 );
3019
3020 assert_ne!(
3021 scaled(shocked.price),
3022 scaled(unshocked.price),
3023 "a single non-zero shock must still reprice the option"
3024 );
3025 }
3026
3027 #[rstest]
3028 fn test_instrument_greeks_leaves_the_price_unshocked_when_every_shock_is_zero() {
3029 let (calculator, option_id, now_ns) = option_calculator();
3030
3031 let unshocked = option_greeks_with_shocks(&calculator, option_id, now_ns, None, None, None);
3032 let zero_shocks = option_greeks_with_shocks(
3033 &calculator,
3034 option_id,
3035 now_ns,
3036 Some(0.0),
3037 Some(0.0),
3038 Some(0.0),
3039 );
3040
3041 assert_eq!(scaled(zero_shocks.price), scaled(unshocked.price));
3042 }
3043
3044 #[rstest]
3045 fn test_instrument_greeks_pnl_is_price_less_the_position_open_price() {
3046 let (calculator, option_id, now_ns) = option_calculator();
3047 let instrument = calculator
3048 .cache
3049 .borrow()
3050 .instrument(&option_id)
3051 .cloned()
3052 .unwrap();
3053 let position = position_from_fill(
3054 &instrument,
3055 "P-GREEKS-1",
3056 "O-GREEKS-1",
3057 "T-GREEKS-1",
3058 OrderSide::Buy,
3059 1,
3060 "9.00",
3061 );
3062
3063 let unshocked = option_greeks_with_shocks(&calculator, option_id, now_ns, None, None, None);
3064 let with_position = calculator
3065 .instrument_greeks(
3066 option_id,
3067 None,
3068 None,
3069 None,
3070 None,
3071 None,
3072 None,
3073 None,
3074 None,
3075 None,
3076 Some(now_ns),
3077 Some(position),
3078 None,
3079 None,
3080 None,
3081 None,
3082 None,
3083 None,
3084 )
3085 .unwrap();
3086
3087 assert_eq!(scaled(with_position.price), scaled(unshocked.price));
3088 assert_eq!(
3089 scaled(with_position.pnl),
3090 scaled(unshocked.price - 9.0),
3091 "pnl must subtract the position open price from the option price"
3092 );
3093 assert_eq!(scaled(unshocked.pnl), 0.0);
3094 }
3095
3096 #[rstest]
3097 fn test_instrument_greeks_records_the_shocked_market_state() {
3098 let (calculator, option_id, now_ns) = option_calculator();
3099
3100 let unshocked = option_greeks_with_shocks(&calculator, option_id, now_ns, None, None, None);
3101 let shocked = option_greeks_with_shocks(
3102 &calculator,
3103 option_id,
3104 now_ns,
3105 Some(5.0),
3106 Some(0.05),
3107 Some(0.01),
3108 );
3109
3110 assert_eq!(scaled(unshocked.underlying_price), 150_050_000_000_000.0);
3112 assert_eq!(scaled(shocked.underlying_price), 155_050_000_000_000.0);
3113 assert_eq!(scaled(shocked.vol), scaled(unshocked.vol + 0.05));
3114 assert_eq!(
3115 scaled(shocked.expiry_in_years),
3116 scaled(unshocked.expiry_in_years - 0.01)
3117 );
3118 assert_eq!(unshocked.expiry_in_days, 30);
3119 assert_eq!(shocked.expiry_in_days, 26);
3120 }
3121
3122 #[rstest]
3123 fn test_instrument_greeks_cost_of_carry_subtracts_the_flat_dividend_yield() {
3124 let (calculator, option_id, now_ns) = option_calculator();
3125
3126 let greeks = calculator
3127 .instrument_greeks(
3128 option_id,
3129 Some(0.05),
3130 Some(0.02),
3131 None,
3132 None,
3133 None,
3134 None,
3135 None,
3136 None,
3137 None,
3138 Some(now_ns),
3139 None,
3140 None,
3141 None,
3142 None,
3143 None,
3144 None,
3145 None,
3146 )
3147 .unwrap();
3148
3149 assert_eq!(scaled(greeks.interest_rate), 50_000_000_000.0);
3150 assert_eq!(scaled(greeks.cost_of_carry), 30_000_000_000.0);
3151 }
3152
3153 #[rstest]
3154 fn test_instrument_greeks_cost_of_carry_is_zero_without_a_dividend_yield() {
3155 let (calculator, option_id, now_ns) = option_calculator();
3156
3157 let greeks = calculator
3158 .instrument_greeks(
3159 option_id,
3160 Some(0.05),
3161 None,
3162 None,
3163 None,
3164 None,
3165 None,
3166 None,
3167 None,
3168 None,
3169 Some(now_ns),
3170 None,
3171 None,
3172 None,
3173 None,
3174 None,
3175 None,
3176 None,
3177 )
3178 .unwrap();
3179
3180 assert_eq!(scaled(greeks.cost_of_carry), 0.0);
3181 }
3182
3183 #[rstest]
3184 fn test_instrument_greeks_reports_the_option_kind() {
3185 let now = utc_timestamp(2025, 3, 8, 12, 0, 0);
3186 let expiry = now + jiff::SignedDuration::from_hours(24 * 30);
3187 let now_ns = UnixNanos::from(now);
3188 let underlying_id = InstrumentId::from("AAPL.OPRA");
3189 let mut put = option_with_expiration("AAPL250417P00150000.OPRA", UnixNanos::from(expiry));
3190 put.option_kind = OptionKind::Put;
3191 let put_id = put.id();
3192 let cache = setup_cache_with_option_and_quotes(put, underlying_id, now_ns);
3193 cache
3194 .borrow_mut()
3195 .add_quote(QuoteTick::new(
3196 put_id,
3197 Price::from("10.50"),
3198 Price::from("10.60"),
3199 Quantity::from(100),
3200 Quantity::from(100),
3201 now_ns,
3202 now_ns,
3203 ))
3204 .unwrap();
3205 let calculator = GreeksCalculator::new(cache, Rc::new(RefCell::new(VirtualClock::new())));
3206
3207 let greeks = option_greeks_with_shocks(&calculator, put_id, now_ns, None, None, None);
3208
3209 assert!(!greeks.is_call);
3210 }
3211
3212 fn equity_calculator() -> (GreeksCalculator, InstrumentId, UnixNanos) {
3215 let now_ns = UnixNanos::from(utc_timestamp(2025, 3, 8, 12, 0, 0));
3216 let cache = Rc::new(RefCell::new(Cache::new(None, None)));
3217 let equity = equity_aapl_opra();
3218 let equity_id = equity.id();
3219 cache
3220 .borrow_mut()
3221 .add_instrument(InstrumentAny::Equity(equity))
3222 .unwrap();
3223 cache
3224 .borrow_mut()
3225 .add_quote(QuoteTick::new(
3226 equity_id,
3227 Price::from("150.00"),
3228 Price::from("150.10"),
3229 Quantity::from(100),
3230 Quantity::from(100),
3231 now_ns,
3232 now_ns,
3233 ))
3234 .unwrap();
3235 let clock = Rc::new(RefCell::new(VirtualClock::new()));
3236
3237 (GreeksCalculator::new(cache, clock), equity_id, now_ns)
3238 }
3239
3240 #[rstest]
3241 fn test_instrument_greeks_for_a_non_option_shocks_the_spot_and_prices_the_position() {
3242 let (calculator, equity_id, now_ns) = equity_calculator();
3243 let instrument = calculator
3244 .cache
3245 .borrow()
3246 .instrument(&equity_id)
3247 .cloned()
3248 .unwrap();
3249 let position = position_from_fill(
3250 &instrument,
3251 "P-EQUITY-1",
3252 "O-EQUITY-1",
3253 "T-EQUITY-1",
3254 OrderSide::Buy,
3255 10,
3256 "140.00",
3257 );
3258
3259 let greeks = calculator
3260 .instrument_greeks(
3261 equity_id,
3262 None,
3263 None,
3264 Some(5.0),
3265 None,
3266 None,
3267 None,
3268 None,
3269 None,
3270 None,
3271 Some(now_ns),
3272 Some(position),
3273 None,
3274 None,
3275 None,
3276 None,
3277 None,
3278 None,
3279 )
3280 .unwrap();
3281
3282 assert_eq!(scaled(greeks.pnl), 15_050_000_000_000.0);
3284 assert_eq!(scaled(greeks.price), 15_050_000_000_000.0);
3285 }
3286
3287 #[rstest]
3288 fn test_instrument_greeks_for_a_non_option_scales_percent_delta_by_the_shocked_spot() {
3289 let (calculator, equity_id, now_ns) = equity_calculator();
3290
3291 let greeks = calculator
3292 .instrument_greeks(
3293 equity_id,
3294 None,
3295 None,
3296 Some(5.0),
3297 None,
3298 None,
3299 None,
3300 None,
3301 None,
3302 None,
3303 Some(now_ns),
3304 None,
3305 Some(true),
3306 None,
3307 None,
3308 None,
3309 None,
3310 None,
3311 )
3312 .unwrap();
3313
3314 assert_eq!(scaled(greeks.delta), 1_550_500_000_000.0);
3316 }
3317
3318 #[rstest]
3319 fn test_instrument_greeks_cost_of_carry_prefers_the_dividend_curve_over_the_flat_yield() {
3320 let (calculator, option_id, now_ns) = option_calculator();
3321 calculator
3322 .cache
3323 .borrow_mut()
3324 .add_yield_curve(YieldCurveData::new(
3325 now_ns,
3326 now_ns,
3327 "AAPL.OPRA".to_string(),
3328 vec![0.0, 5.0, 10.0],
3329 vec![0.02, 0.02, 0.02],
3330 ))
3331 .unwrap();
3332
3333 let greeks = calculator
3334 .instrument_greeks(
3335 option_id,
3336 Some(0.05),
3337 Some(0.04),
3338 None,
3339 None,
3340 None,
3341 None,
3342 None,
3343 None,
3344 None,
3345 Some(now_ns),
3346 None,
3347 None,
3348 None,
3349 None,
3350 None,
3351 None,
3352 None,
3353 )
3354 .unwrap();
3355
3356 assert_eq!(scaled(greeks.interest_rate), 50_000_000_000.0);
3357 assert_eq!(
3358 scaled(greeks.cost_of_carry),
3359 30_000_000_000.0,
3360 "the dividend curve must take precedence over the flat dividend yield"
3361 );
3362 }
3363
3364 fn scaled(value: f64) -> f64 {
3366 (value * 1e12).round()
3367 }
3368
3369 #[rstest]
3370 fn test_modify_greeks_reprices_the_index_when_the_underlying_is_shocked() {
3371 let calculator = create_test_calculator();
3372 let underlying_id = InstrumentId::from("AAPL.OPRA");
3373 let mut beta_weights = HashMap::new();
3374 beta_weights.insert(underlying_id, 0.5);
3375
3376 let (delta, gamma, vega) = calculator
3377 .modify_greeks(
3378 1.0,
3379 2.0,
3380 underlying_id,
3381 165.0,
3382 150.0,
3383 false,
3384 None,
3385 Some(&beta_weights),
3386 3.0,
3387 0.30,
3388 0,
3389 None,
3390 0.0,
3391 None,
3392 None,
3393 Some(200.0),
3394 None,
3395 )
3396 .unwrap();
3397
3398 assert_eq!(scaled(delta), 343_750_000_000.0);
3401 assert_eq!(scaled(gamma), 236_328_125_000.0);
3402 assert_eq!(scaled(vega), 3_000_000_000_000.0);
3403 }
3404
3405 #[rstest]
3406 fn test_modify_greeks_reprices_the_vol_index_when_the_vol_is_shocked() {
3407 let calculator = create_test_calculator();
3408 let underlying_id = InstrumentId::from("AAPL.OPRA");
3409 let mut vol_beta_weights = HashMap::new();
3410 vol_beta_weights.insert(underlying_id, 0.75);
3411
3412 let (delta, gamma, vega) = calculator
3413 .modify_greeks(
3414 1.0,
3415 2.0,
3416 underlying_id,
3417 150.0,
3418 150.0,
3419 false,
3420 None,
3421 None,
3422 2.0,
3423 0.35,
3424 0,
3425 None,
3426 0.30,
3427 None,
3428 Some(&vol_beta_weights),
3429 None,
3430 Some(25.0),
3431 )
3432 .unwrap();
3433
3434 assert_eq!(scaled(delta), 1_000_000_000_000.0);
3435 assert_eq!(scaled(gamma), 2_000_000_000_000.0);
3436 assert_eq!(scaled(vega), 1_718_181_818_182.0);
3437 }
3438
3439 #[rstest]
3440 fn test_modify_greeks_uses_the_shocked_vol_as_the_baseline_when_unshocked_vol_is_zero() {
3441 let calculator = create_test_calculator();
3442
3443 let (_, _, vega) = calculator
3444 .modify_greeks(
3445 1.0,
3446 2.0,
3447 InstrumentId::from("AAPL.OPRA"),
3448 150.0,
3449 150.0,
3450 false,
3451 None,
3452 None,
3453 2.0,
3454 0.40,
3455 0,
3456 None,
3457 0.0,
3458 None,
3459 None,
3460 None,
3461 Some(25.0),
3462 )
3463 .unwrap();
3464
3465 assert_eq!(scaled(vega), 3_200_000_000_000.0);
3467 }
3468
3469 #[rstest]
3470 fn test_modify_greeks_leaves_vega_untouched_for_a_zero_vol_index() {
3471 let calculator = create_test_calculator();
3472
3473 let (_, _, vega) = calculator
3474 .modify_greeks(
3475 1.0,
3476 2.0,
3477 InstrumentId::from("AAPL.OPRA"),
3478 150.0,
3479 150.0,
3480 false,
3481 None,
3482 None,
3483 2.0,
3484 0.40,
3485 0,
3486 None,
3487 0.0,
3488 None,
3489 None,
3490 None,
3491 Some(0.0),
3492 )
3493 .unwrap();
3494
3495 assert_eq!(scaled(vega), 2_000_000_000_000.0);
3496 }
3497
3498 #[rstest]
3499 fn test_modify_greeks_percent_scaling_falls_back_to_the_underlying_and_vol() {
3500 let calculator = create_test_calculator();
3501
3502 let (delta, gamma, vega) = calculator
3503 .modify_greeks(
3504 1.0,
3505 2.0,
3506 InstrumentId::from("AAPL.OPRA"),
3507 150.0,
3508 150.0,
3509 true,
3510 None,
3511 None,
3512 2.0,
3513 0.30,
3514 0,
3515 None,
3516 0.0,
3517 None,
3518 None,
3519 None,
3520 None,
3521 )
3522 .unwrap();
3523
3524 assert_eq!(scaled(delta), 1_500_000_000_000.0);
3525 assert_eq!(scaled(gamma), 4_500_000_000_000.0);
3526 assert_eq!(scaled(vega), 6_000_000_000.0);
3527 }
3528
3529 #[rstest]
3530 fn test_modify_greeks_applies_vega_time_weighting() {
3531 let calculator = create_test_calculator();
3532
3533 let (_, _, vega) = calculator
3534 .modify_greeks(
3535 1.0,
3536 2.0,
3537 InstrumentId::from("AAPL.OPRA"),
3538 150.0,
3539 150.0,
3540 false,
3541 None,
3542 None,
3543 2.0,
3544 0.30,
3545 120,
3546 Some(30),
3547 0.0,
3548 None,
3549 None,
3550 None,
3551 None,
3552 )
3553 .unwrap();
3554
3555 assert_eq!(scaled(vega), 1_000_000_000_000.0);
3557 }
3558
3559 #[rstest]
3560 fn test_modify_greeks_skips_vega_time_weighting_without_days_to_expiry() {
3561 let calculator = create_test_calculator();
3562
3563 let (_, _, vega) = calculator
3564 .modify_greeks(
3565 1.0,
3566 2.0,
3567 InstrumentId::from("AAPL.OPRA"),
3568 150.0,
3569 150.0,
3570 false,
3571 None,
3572 None,
3573 2.0,
3574 0.30,
3575 0,
3576 Some(30),
3577 0.0,
3578 None,
3579 None,
3580 None,
3581 None,
3582 )
3583 .unwrap();
3584
3585 assert_eq!(scaled(vega), 2_000_000_000_000.0);
3586 }
3587
3588 const SPREAD_CALL_ID: &str = "ESH4C150.GLBX";
3589 const SPREAD_PUT_ID: &str = "ESH4P150.GLBX";
3590 const SPREAD_REFERENCE_ID: &str = "ESM4.GLBX";
3591 const SPREAD_UNDERLYING_ID: &str = "ESH4.GLBX";
3592
3593 fn spread_now_ns() -> UnixNanos {
3594 UnixNanos::from(utc_timestamp(2024, 2, 14, 16, 0, 0))
3595 }
3596
3597 fn spread_expiry_ns() -> UnixNanos {
3598 UnixNanos::from(utc_timestamp(2024, 3, 15, 16, 0, 0))
3599 }
3600
3601 fn spread_call() -> OptionContract {
3602 future_option_with_expiration(
3603 SPREAD_CALL_ID,
3604 "ESH4C150",
3605 "ESH4",
3606 OptionKind::Call,
3607 "150.00",
3608 spread_expiry_ns(),
3609 )
3610 }
3611
3612 fn spread_put() -> OptionContract {
3613 future_option_with_expiration(
3614 SPREAD_PUT_ID,
3615 "ESH4P150",
3616 "ESH4",
3617 OptionKind::Put,
3618 "150.00",
3619 spread_expiry_ns(),
3620 )
3621 }
3622
3623 fn spread_reference_future() -> FuturesContract {
3624 future_with_expiration(SPREAD_REFERENCE_ID, "ESM4", spread_expiry_ns())
3625 }
3626
3627 fn spread_quote(instrument_id: InstrumentId, price: &str) -> QuoteTick {
3628 let price = Price::from(price);
3629
3630 QuoteTick::new(
3631 instrument_id,
3632 price,
3633 price,
3634 Quantity::from(100),
3635 Quantity::from(100),
3636 spread_now_ns(),
3637 spread_now_ns(),
3638 )
3639 }
3640
3641 fn spread_calculator(
3647 instruments: Vec<InstrumentAny>,
3648 quotes: Vec<QuoteTick>,
3649 ) -> GreeksCalculator {
3650 let cache = Rc::new(RefCell::new(Cache::new(None, None)));
3651
3652 for instrument in instruments {
3653 cache.borrow_mut().add_instrument(instrument).unwrap();
3654 }
3655
3656 for quote in quotes {
3657 cache.borrow_mut().add_quote(quote).unwrap();
3658 }
3659
3660 let clock = Rc::new(RefCell::new(VirtualClock::new()));
3661 clock.borrow_mut().set_time(spread_now_ns());
3662
3663 GreeksCalculator::new(cache, clock)
3664 }
3665
3666 fn spread_instruments() -> Vec<InstrumentAny> {
3667 vec![
3668 InstrumentAny::OptionContract(spread_call()),
3669 InstrumentAny::OptionContract(spread_put()),
3670 InstrumentAny::FuturesContract(spread_reference_future()),
3671 ]
3672 }
3673
3674 fn spread_quotes() -> Vec<QuoteTick> {
3675 vec![
3676 spread_quote(InstrumentId::from(SPREAD_CALL_ID), "8.50"),
3677 spread_quote(InstrumentId::from(SPREAD_PUT_ID), "3.33"),
3678 spread_quote(InstrumentId::from(SPREAD_REFERENCE_ID), "155.00"),
3679 ]
3680 }
3681
3682 fn cache_spread_error(instruments: Vec<InstrumentAny>, quotes: Vec<QuoteTick>) -> String {
3683 spread_calculator(instruments, quotes)
3684 .cache_futures_spread(
3685 InstrumentId::from(SPREAD_CALL_ID),
3686 InstrumentId::from(SPREAD_PUT_ID),
3687 InstrumentId::from(SPREAD_REFERENCE_ID),
3688 )
3689 .unwrap_err()
3690 .to_string()
3691 }
3692
3693 #[rstest]
3694 fn test_cache_futures_spread_errors_when_call_instrument_missing() {
3695 let instruments = vec![
3696 InstrumentAny::OptionContract(spread_put()),
3697 InstrumentAny::FuturesContract(spread_reference_future()),
3698 ];
3699
3700 assert_eq!(
3701 cache_spread_error(instruments, spread_quotes()),
3702 "Cannot cache futures spread: missing option instrument ESH4C150.GLBX"
3703 );
3704 }
3705
3706 #[rstest]
3707 fn test_cache_futures_spread_errors_when_put_instrument_missing() {
3708 let instruments = vec![
3709 InstrumentAny::OptionContract(spread_call()),
3710 InstrumentAny::FuturesContract(spread_reference_future()),
3711 ];
3712
3713 assert_eq!(
3714 cache_spread_error(instruments, spread_quotes()),
3715 "Cannot cache futures spread: missing option instrument ESH4P150.GLBX"
3716 );
3717 }
3718
3719 #[rstest]
3720 fn test_cache_futures_spread_errors_when_reference_future_instrument_missing() {
3721 let instruments = vec![
3722 InstrumentAny::OptionContract(spread_call()),
3723 InstrumentAny::OptionContract(spread_put()),
3724 ];
3725
3726 assert_eq!(
3727 cache_spread_error(instruments, spread_quotes()),
3728 "Cannot cache futures spread: no reference futures instrument for ESM4.GLBX"
3729 );
3730 }
3731
3732 #[rstest]
3733 fn test_cache_futures_spread_errors_when_call_leg_is_not_an_option() {
3734 let non_option = future_with_expiration(SPREAD_CALL_ID, "ESH4", spread_expiry_ns());
3735 let instruments = vec![
3736 InstrumentAny::FuturesContract(non_option),
3737 InstrumentAny::OptionContract(spread_put()),
3738 InstrumentAny::FuturesContract(spread_reference_future()),
3739 ];
3740
3741 assert_eq!(
3742 cache_spread_error(instruments, spread_quotes()),
3743 "Cannot cache futures spread: non-option instruments provided \
3744 call_instrument_id=ESH4C150.GLBX put_instrument_id=ESH4P150.GLBX"
3745 );
3746 }
3747
3748 #[rstest]
3749 fn test_cache_futures_spread_errors_when_legs_are_not_a_call_put_pair() {
3750 let second_put = future_option_with_expiration(
3751 SPREAD_CALL_ID,
3752 "ESH4P150",
3753 "ESH4",
3754 OptionKind::Put,
3755 "150.00",
3756 spread_expiry_ns(),
3757 );
3758 let instruments = vec![
3759 InstrumentAny::OptionContract(second_put),
3760 InstrumentAny::OptionContract(spread_put()),
3761 InstrumentAny::FuturesContract(spread_reference_future()),
3762 ];
3763
3764 assert_eq!(
3765 cache_spread_error(instruments, spread_quotes()),
3766 "Cannot cache futures spread: expected call/put pair \
3767 call_instrument_id=ESH4C150.GLBX put_instrument_id=ESH4P150.GLBX"
3768 );
3769 }
3770
3771 #[rstest]
3772 fn test_cache_futures_spread_errors_when_underlyings_differ() {
3773 let mismatched_put = future_option_with_expiration(
3774 SPREAD_PUT_ID,
3775 "ESM4P150",
3776 "ESM4",
3777 OptionKind::Put,
3778 "150.00",
3779 spread_expiry_ns(),
3780 );
3781 let instruments = vec![
3782 InstrumentAny::OptionContract(spread_call()),
3783 InstrumentAny::OptionContract(mismatched_put),
3784 InstrumentAny::FuturesContract(spread_reference_future()),
3785 ];
3786
3787 assert_eq!(
3788 cache_spread_error(instruments, spread_quotes()),
3789 "Cannot cache futures spread: option underlyings differ \
3790 call_instrument_id=ESH4C150.GLBX put_instrument_id=ESH4P150.GLBX"
3791 );
3792 }
3793
3794 #[rstest]
3795 fn test_cache_futures_spread_errors_when_strike_prices_differ() {
3796 let mismatched_put = future_option_with_expiration(
3797 SPREAD_PUT_ID,
3798 "ESH4P155",
3799 "ESH4",
3800 OptionKind::Put,
3801 "155.00",
3802 spread_expiry_ns(),
3803 );
3804 let instruments = vec![
3805 InstrumentAny::OptionContract(spread_call()),
3806 InstrumentAny::OptionContract(mismatched_put),
3807 InstrumentAny::FuturesContract(spread_reference_future()),
3808 ];
3809
3810 assert_eq!(
3811 cache_spread_error(instruments, spread_quotes()),
3812 "Cannot cache futures spread: strike prices differ \
3813 call_instrument_id=ESH4C150.GLBX put_instrument_id=ESH4P150.GLBX"
3814 );
3815 }
3816
3817 #[rstest]
3818 fn test_cache_futures_spread_errors_when_expirations_differ() {
3819 let mismatched_put = future_option_with_expiration(
3820 SPREAD_PUT_ID,
3821 "ESH4P150",
3822 "ESH4",
3823 OptionKind::Put,
3824 "150.00",
3825 UnixNanos::from(utc_timestamp(2024, 6, 21, 16, 0, 0)),
3826 );
3827 let instruments = vec![
3828 InstrumentAny::OptionContract(spread_call()),
3829 InstrumentAny::OptionContract(mismatched_put),
3830 InstrumentAny::FuturesContract(spread_reference_future()),
3831 ];
3832
3833 assert_eq!(
3834 cache_spread_error(instruments, spread_quotes()),
3835 "Cannot cache futures spread: expiration dates differ \
3836 call_instrument_id=ESH4C150.GLBX put_instrument_id=ESH4P150.GLBX"
3837 );
3838 }
3839
3840 #[rstest]
3841 fn test_cache_futures_spread_errors_when_reference_future_price_missing() {
3842 let quotes = vec![
3843 spread_quote(InstrumentId::from(SPREAD_CALL_ID), "8.50"),
3844 spread_quote(InstrumentId::from(SPREAD_PUT_ID), "3.33"),
3845 ];
3846
3847 assert_eq!(
3848 cache_spread_error(spread_instruments(), quotes),
3849 "Cannot cache futures spread: no reference futures price for ESM4.GLBX"
3850 );
3851 }
3852
3853 #[rstest]
3854 fn test_cache_futures_spread_errors_when_call_price_missing() {
3855 let quotes = vec![
3856 spread_quote(InstrumentId::from(SPREAD_PUT_ID), "3.33"),
3857 spread_quote(InstrumentId::from(SPREAD_REFERENCE_ID), "155.00"),
3858 ];
3859
3860 assert_eq!(
3861 cache_spread_error(spread_instruments(), quotes),
3862 "Cannot cache futures spread: missing option price for ESH4C150.GLBX"
3863 );
3864 }
3865
3866 #[rstest]
3867 fn test_cache_futures_spread_errors_when_put_price_missing() {
3868 let quotes = vec![
3869 spread_quote(InstrumentId::from(SPREAD_CALL_ID), "8.50"),
3870 spread_quote(InstrumentId::from(SPREAD_REFERENCE_ID), "155.00"),
3871 ];
3872
3873 assert_eq!(
3874 cache_spread_error(spread_instruments(), quotes),
3875 "Cannot cache futures spread: missing option price for ESH4P150.GLBX"
3876 );
3877 }
3878
3879 #[rstest]
3880 fn test_cache_futures_spread_errors_when_cached_underlying_is_not_a_future() {
3881 let underlying_equity = Equity::builder()
3882 .instrument_id(InstrumentId::from(SPREAD_UNDERLYING_ID))
3883 .raw_symbol(Symbol::from("ESH4"))
3884 .currency(Currency::from("USD"))
3885 .price_precision(2)
3886 .price_increment(Price::from("0.01"))
3887 .ts_event(UnixNanos::default())
3888 .ts_init(UnixNanos::default())
3889 .build()
3890 .unwrap();
3891
3892 let mut instruments = spread_instruments();
3893 instruments.push(InstrumentAny::Equity(underlying_equity));
3894
3895 assert_eq!(
3896 cache_spread_error(instruments, spread_quotes()),
3897 "Cannot cache futures spread: underlying ESH4.GLBX is not a futures contract"
3898 );
3899 }
3900
3901 #[rstest]
3902 fn test_cache_futures_spread_leaves_no_entry_when_validation_fails() {
3903 let calculator = spread_calculator(
3904 vec![
3905 InstrumentAny::OptionContract(spread_call()),
3906 InstrumentAny::OptionContract(spread_put()),
3907 ],
3908 spread_quotes(),
3909 );
3910
3911 calculator
3912 .cache_futures_spread(
3913 InstrumentId::from(SPREAD_CALL_ID),
3914 InstrumentId::from(SPREAD_PUT_ID),
3915 InstrumentId::from(SPREAD_REFERENCE_ID),
3916 )
3917 .unwrap_err();
3918
3919 assert_eq!(
3920 calculator.get_cached_futures_spread_price(InstrumentId::from(SPREAD_UNDERLYING_ID)),
3921 None
3922 );
3923 }
3924
3925 #[rstest]
3926 fn test_get_cached_futures_spread_price_returns_none_for_unknown_underlying() {
3927 let calculator = spread_calculator(spread_instruments(), spread_quotes());
3928
3929 assert_eq!(
3930 calculator.get_cached_futures_spread_price(InstrumentId::from("CLZ4.NYMEX")),
3931 None
3932 );
3933 }
3934}