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, OptionValuationError, PortfolioGreeks,
25 black_scholes_greeks_checked, check_option_inputs, imply_vol_and_greeks_checked,
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};
33use ustr::Ustr;
34
35use crate::{
36 actor::DataActorNative,
37 cache::{Cache, refs::PositionRef},
38 clock::Clock,
39 msgbus,
40 msgbus::TypedHandler,
41};
42
43pub type GreeksFilter = Box<dyn Fn(&GreeksData) -> bool>;
45
46#[derive(Clone)]
48pub enum GreeksFilterCallback {
49 Function(fn(&GreeksData) -> bool),
51 Closure(std::rc::Rc<dyn Fn(&GreeksData) -> bool>),
53}
54
55impl GreeksFilterCallback {
56 pub fn from_fn(f: fn(&GreeksData) -> bool) -> Self {
58 Self::Function(f)
59 }
60
61 pub fn from_closure<F>(f: F) -> Self
63 where
64 F: Fn(&GreeksData) -> bool + 'static,
65 {
66 Self::Closure(std::rc::Rc::new(f))
67 }
68
69 pub fn call(&self, data: &GreeksData) -> bool {
71 match self {
72 Self::Function(f) => f(data),
73 Self::Closure(f) => f(data),
74 }
75 }
76
77 pub fn to_greeks_filter(self) -> GreeksFilter {
79 match self {
80 Self::Function(f) => Box::new(f),
81 Self::Closure(f) => {
82 let f_clone = f.clone();
83 Box::new(move |data| f_clone(data))
84 }
85 }
86 }
87}
88
89impl Debug for GreeksFilterCallback {
90 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
91 match self {
92 Self::Function(_) => f.write_str("GreeksFilterCallback::Function"),
93 Self::Closure(_) => f.write_str("GreeksFilterCallback::Closure"),
94 }
95 }
96}
97
98#[derive(Debug, bon::Builder)]
100pub struct InstrumentGreeksParams {
101 pub instrument_id: InstrumentId,
103 #[builder(default = 0.0425)]
105 pub flat_interest_rate: f64,
106 pub flat_dividend_yield: Option<f64>,
108 #[builder(default = 0.0)]
110 pub spot_shock: f64,
111 #[builder(default = 0.0)]
113 pub vol_shock: f64,
114 #[builder(default = 0.0)]
116 pub time_to_expiry_shock: f64,
117 #[builder(default = false)]
119 pub use_cached_greeks: bool,
120 #[builder(default = false)]
122 pub update_vol: bool,
123 #[builder(default = false)]
125 pub cache_greeks: bool,
126 #[builder(default = false)]
128 pub publish_greeks: bool,
129 pub ts_event: Option<UnixNanos>,
131 pub position: Option<Position>,
133 #[builder(default = false)]
135 pub percent_greeks: bool,
136 pub index_instrument_id: Option<InstrumentId>,
138 pub beta_weights: Option<HashMap<InstrumentId, f64>>,
140 pub vega_time_weight_base: Option<i32>,
142 pub vol_index_instrument_id: Option<InstrumentId>,
144 pub vol_beta_weights: Option<HashMap<InstrumentId, f64>>,
146}
147
148impl InstrumentGreeksParams {
149 pub fn calculate(&self, calculator: &GreeksCalculator) -> anyhow::Result<GreeksData> {
155 calculator.instrument_greeks(
156 self.instrument_id,
157 Some(self.flat_interest_rate),
158 self.flat_dividend_yield,
159 Some(self.spot_shock),
160 Some(self.vol_shock),
161 Some(self.time_to_expiry_shock),
162 Some(self.use_cached_greeks),
163 Some(self.update_vol),
164 Some(self.cache_greeks),
165 Some(self.publish_greeks),
166 self.ts_event,
167 self.position.clone(),
168 Some(self.percent_greeks),
169 self.index_instrument_id,
170 self.beta_weights.as_ref(),
171 self.vega_time_weight_base,
172 self.vol_index_instrument_id,
173 self.vol_beta_weights.as_ref(),
174 )
175 }
176}
177
178#[derive(bon::Builder)]
180pub struct PortfolioGreeksParams {
181 pub underlyings: Option<Vec<String>>,
183 pub venue: Option<Venue>,
185 pub instrument_id: Option<InstrumentId>,
187 pub strategy_id: Option<StrategyId>,
189 pub side: Option<PositionSide>,
191 #[builder(default = 0.0425)]
193 pub flat_interest_rate: f64,
194 pub flat_dividend_yield: Option<f64>,
196 #[builder(default = 0.0)]
198 pub spot_shock: f64,
199 #[builder(default = 0.0)]
201 pub vol_shock: f64,
202 #[builder(default = 0.0)]
204 pub time_to_expiry_shock: f64,
205 #[builder(default = false)]
207 pub use_cached_greeks: bool,
208 #[builder(default = false)]
210 pub update_vol: bool,
211 #[builder(default = false)]
213 pub cache_greeks: bool,
214 #[builder(default = false)]
216 pub publish_greeks: bool,
217 #[builder(default = false)]
219 pub percent_greeks: bool,
220 pub index_instrument_id: Option<InstrumentId>,
222 pub beta_weights: Option<HashMap<InstrumentId, f64>>,
224 pub greeks_filter: Option<GreeksFilterCallback>,
226 pub vega_time_weight_base: Option<i32>,
228 pub vol_index_instrument_id: Option<InstrumentId>,
230 pub vol_beta_weights: Option<HashMap<InstrumentId, f64>>,
232}
233
234impl Debug for PortfolioGreeksParams {
235 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
236 f.debug_struct(stringify!(PortfolioGreeksParams))
237 .field("underlyings", &self.underlyings)
238 .field("venue", &self.venue)
239 .field("instrument_id", &self.instrument_id)
240 .field("strategy_id", &self.strategy_id)
241 .field("side", &self.side)
242 .field("flat_interest_rate", &self.flat_interest_rate)
243 .field("flat_dividend_yield", &self.flat_dividend_yield)
244 .field("spot_shock", &self.spot_shock)
245 .field("vol_shock", &self.vol_shock)
246 .field("time_to_expiry_shock", &self.time_to_expiry_shock)
247 .field("use_cached_greeks", &self.use_cached_greeks)
248 .field("update_vol", &self.update_vol)
249 .field("cache_greeks", &self.cache_greeks)
250 .field("publish_greeks", &self.publish_greeks)
251 .field("percent_greeks", &self.percent_greeks)
252 .field("index_instrument_id", &self.index_instrument_id)
253 .field("beta_weights", &self.beta_weights)
254 .field("greeks_filter", &self.greeks_filter)
255 .field("vega_time_weight_base", &self.vega_time_weight_base)
256 .field("vol_index_instrument_id", &self.vol_index_instrument_id)
257 .field("vol_beta_weights", &self.vol_beta_weights)
258 .finish()
259 }
260}
261
262impl PortfolioGreeksParams {
263 pub fn calculate(&self, calculator: &GreeksCalculator) -> anyhow::Result<PortfolioGreeks> {
269 let greeks_filter = self
270 .greeks_filter
271 .as_ref()
272 .map(|f| f.clone().to_greeks_filter());
273
274 calculator.portfolio_greeks(
275 self.underlyings.as_deref(),
276 self.venue,
277 self.instrument_id,
278 self.strategy_id,
279 self.side,
280 Some(self.flat_interest_rate),
281 self.flat_dividend_yield,
282 Some(self.spot_shock),
283 Some(self.vol_shock),
284 Some(self.time_to_expiry_shock),
285 Some(self.use_cached_greeks),
286 Some(self.update_vol),
287 Some(self.cache_greeks),
288 Some(self.publish_greeks),
289 Some(self.percent_greeks),
290 self.index_instrument_id,
291 self.beta_weights.as_ref(),
292 greeks_filter.as_ref(),
293 self.vega_time_weight_base,
294 self.vol_index_instrument_id,
295 self.vol_beta_weights.as_ref(),
296 )
297 }
298}
299
300#[allow(dead_code)]
317#[derive(Debug)]
318pub struct GreeksCalculator {
319 cache: Rc<RefCell<Cache>>,
320 clock: Rc<RefCell<dyn Clock>>,
321 cached_futures_spreads: RefCell<AHashMap<InstrumentId, (InstrumentId, Price)>>,
322 cross_venue_underlyings: RefCell<AHashMap<Ustr, InstrumentId>>,
323}
324
325impl GreeksCalculator {
326 pub fn new(cache: Rc<RefCell<Cache>>, clock: Rc<RefCell<dyn Clock>>) -> Self {
328 Self {
329 cache,
330 clock,
331 cached_futures_spreads: RefCell::new(AHashMap::new()),
332 cross_venue_underlyings: RefCell::new(AHashMap::new()),
333 }
334 }
335
336 pub fn from_actor(actor: &impl DataActorNative) -> Self {
342 Self::new(actor.cache_rc(), actor.clock_rc())
343 }
344
345 #[expect(clippy::too_many_arguments)]
368 pub fn instrument_greeks(
369 &self,
370 instrument_id: InstrumentId,
371 flat_interest_rate: Option<f64>,
372 flat_dividend_yield: Option<f64>,
373 spot_shock: Option<f64>,
374 vol_shock: Option<f64>,
375 time_to_expiry_shock: Option<f64>,
376 use_cached_greeks: Option<bool>,
377 update_vol: Option<bool>,
378 cache_greeks: Option<bool>,
379 publish_greeks: Option<bool>,
380 ts_event: Option<UnixNanos>,
381 position: Option<Position>,
382 percent_greeks: Option<bool>,
383 index_instrument_id: Option<InstrumentId>,
384 beta_weights: Option<&HashMap<InstrumentId, f64>>,
385 vega_time_weight_base: Option<i32>,
386 vol_index_instrument_id: Option<InstrumentId>,
387 vol_beta_weights: Option<&HashMap<InstrumentId, f64>>,
388 ) -> anyhow::Result<GreeksData> {
389 let flat_interest_rate = flat_interest_rate.unwrap_or(0.0425);
391 let spot_shock = spot_shock.unwrap_or(0.0);
392 let vol_shock = vol_shock.unwrap_or(0.0);
393 let time_to_expiry_shock = time_to_expiry_shock.unwrap_or(0.0);
394 let use_cached_greeks = use_cached_greeks.unwrap_or(false);
395 let update_vol = update_vol.unwrap_or(false);
396 let cache_greeks = cache_greeks.unwrap_or(false);
397 let publish_greeks = publish_greeks.unwrap_or(false);
398 let ts_event = ts_event.unwrap_or_default();
399 let percent_greeks = percent_greeks.unwrap_or(false);
400
401 let instrument = {
402 let cache = self.cache.borrow();
403 cache.try_instrument(&instrument_id)?.clone()
404 };
405
406 if instrument.instrument_class() == InstrumentClass::OptionSpread {
407 return Err(OptionValuationError::UnsupportedSpread(instrument_id).into());
408 }
409
410 if instrument.instrument_class() != InstrumentClass::Option {
411 return self.calculate_non_option_greeks(
412 &instrument,
413 instrument_id,
414 spot_shock,
415 ts_event,
416 position,
417 percent_greeks,
418 index_instrument_id,
419 beta_weights,
420 );
421 }
422
423 let ts_event = self.check_option_valuation(
424 &instrument,
425 ts_event,
426 spot_shock,
427 vol_shock,
428 time_to_expiry_shock,
429 )?;
430
431 let has_shocks = spot_shock != 0.0 || vol_shock != 0.0 || time_to_expiry_shock != 0.0;
432
433 if use_cached_greeks
435 && !has_shocks
436 && let Some(mut cached_greeks) = self.cache.borrow().greeks(&instrument_id)
437 {
438 check_option_greeks_data(&cached_greeks)?;
439 if let Some(pos) = position {
440 cached_greeks.pnl = cached_greeks.price - pos.avg_px_open.as_f64();
441 }
442
443 check_option_greeks_data(&cached_greeks)?;
444
445 return Ok(cached_greeks);
446 }
447
448 let underlying_instrument_id =
449 self.resolve_underlying_instrument_id(&instrument, instrument_id)?;
450 let (greeks_data, db_dr, is_fresh) = self.calculate_option_greeks(
451 &instrument,
452 instrument_id,
453 underlying_instrument_id,
454 flat_interest_rate,
455 flat_dividend_yield,
456 use_cached_greeks,
457 update_vol,
458 ts_event,
459 percent_greeks,
460 index_instrument_id,
461 beta_weights,
462 vega_time_weight_base,
463 vol_index_instrument_id,
464 vol_beta_weights,
465 )?;
466
467 let shocked_greeks = if has_shocks {
468 Some(self.apply_option_greeks_shocks(
469 &greeks_data,
470 underlying_instrument_id,
471 db_dr,
472 spot_shock,
473 vol_shock,
474 time_to_expiry_shock,
475 percent_greeks,
476 index_instrument_id,
477 beta_weights,
478 vega_time_weight_base,
479 vol_index_instrument_id,
480 vol_beta_weights,
481 )?)
482 } else {
483 None
484 };
485
486 let final_greeks = shocked_greeks.as_ref().unwrap_or(&greeks_data);
487 check_option_greeks_data(final_greeks)?;
488 let pnl = position.map_or(final_greeks.pnl, |pos| {
489 final_greeks.price - pos.avg_px_open.as_f64()
490 });
491
492 check_greeks_values(pnl, final_greeks.price, final_greeks.greeks)?;
493
494 if is_fresh {
495 self.store_option_greeks(
496 &greeks_data,
497 underlying_instrument_id.symbol.as_str(),
498 cache_greeks,
499 publish_greeks,
500 );
501 }
502
503 let mut greeks_data = shocked_greeks.unwrap_or(greeks_data);
504 greeks_data.pnl = pnl;
505
506 Ok(greeks_data)
507 }
508
509 fn check_option_valuation(
510 &self,
511 instrument: &InstrumentAny,
512 ts_event: UnixNanos,
513 spot_shock: f64,
514 vol_shock: f64,
515 time_to_expiry_shock: f64,
516 ) -> Result<UnixNanos, OptionValuationError> {
517 let valuation_time = if ts_event == UnixNanos::default() {
518 self.clock.borrow().timestamp_ns()
519 } else {
520 ts_event
521 };
522
523 let expiration = instrument
524 .expiration_ns()
525 .ok_or(OptionValuationError::InvalidInput("expiration"))?;
526
527 if valuation_time >= expiration {
528 return Err(OptionValuationError::Expired(instrument.id()));
529 }
530
531 for (name, value) in [
532 ("spot shock", spot_shock),
533 ("volatility shock", vol_shock),
534 ("time to expiry shock", time_to_expiry_shock),
535 ] {
536 if !value.is_finite() {
537 return Err(OptionValuationError::InvalidInput(name));
538 }
539 }
540
541 Ok(valuation_time)
542 }
543
544 fn store_option_greeks(&self, data: &GreeksData, underlying: &str, cache: bool, publish: bool) {
545 if cache && let Err(e) = self.cache.borrow_mut().add_greeks(data.clone()) {
546 log::error!("Failed to add GreeksData to cache: {e}");
547 }
548
549 if publish {
550 let topic = greeks_topic(underlying, &data.instrument_id.to_string()).into();
551 msgbus::publish_greeks(topic, data);
552 }
553 }
554
555 fn resolve_underlying_instrument_id(
556 &self,
557 instrument: &InstrumentAny,
558 instrument_id: InstrumentId,
559 ) -> anyhow::Result<InstrumentId> {
560 let Some(underlying) = instrument.underlying() else {
561 anyhow::bail!("Instrument {instrument_id} has no underlying identifier");
562 };
563
564 let same_venue_id = InstrumentId::from(format!("{underlying}.{}", instrument_id.venue));
565 let cache = self.cache.borrow();
566 let mut cross_venue_underlyings = self.cross_venue_underlyings.borrow_mut();
567
568 let remembered_id = cross_venue_underlyings
570 .get(&underlying)
571 .copied()
572 .filter(|id| cache.instrument(id).is_some());
573
574 if remembered_id.is_none() {
575 cross_venue_underlyings.remove(&underlying);
576 }
577
578 if cache.instrument(&same_venue_id).is_some()
580 || self
581 .cached_futures_spreads
582 .borrow()
583 .contains_key(&same_venue_id)
584 {
585 return Ok(same_venue_id);
586 }
587
588 if let Some(underlying_id) = remembered_id {
589 return Ok(underlying_id);
590 }
591
592 let mut matches: Vec<InstrumentId> = cache
594 .instrument_ids(None)
595 .into_iter()
596 .filter(|id| id.symbol.inner() == underlying)
597 .copied()
598 .collect();
599
600 match matches.len() {
601 0 => Ok(same_venue_id),
602 1 => {
603 cross_venue_underlyings.insert(underlying, matches[0]);
604 Ok(matches[0])
605 }
606 _ => {
607 matches.sort();
608 let names = matches
609 .iter()
610 .map(ToString::to_string)
611 .collect::<Vec<_>>()
612 .join(", ");
613 anyhow::bail!("Ambiguous underlying for option {instrument_id}: {names}")
614 }
615 }
616 }
617
618 #[expect(clippy::too_many_arguments)]
619 fn calculate_non_option_greeks(
620 &self,
621 instrument: &InstrumentAny,
622 instrument_id: InstrumentId,
623 spot_shock: f64,
624 ts_event: UnixNanos,
625 position: Option<Position>,
626 percent_greeks: bool,
627 index_instrument_id: Option<InstrumentId>,
628 beta_weights: Option<&HashMap<InstrumentId, f64>>,
629 ) -> anyhow::Result<GreeksData> {
630 let multiplier = instrument.multiplier();
631 let underlying_instrument_id = instrument.id();
632 let underlying_price = self
633 .get_price(&underlying_instrument_id)
634 .ok_or_else(|| anyhow::anyhow!("No price available for {underlying_instrument_id}"))?;
635 let (delta, _, _) = self.modify_greeks(
636 1.0,
637 0.0,
638 underlying_instrument_id,
639 underlying_price + spot_shock,
640 underlying_price,
641 percent_greeks,
642 index_instrument_id,
643 beta_weights,
644 0.0,
645 0.0,
646 0,
647 None,
648 0.0,
649 None,
650 None,
651 None,
652 None,
653 )?;
654 let mut greeks_data =
655 GreeksData::from_delta(instrument_id, delta, multiplier.as_f64(), ts_event);
656
657 if let Some(pos) = position {
658 greeks_data.pnl = (underlying_price + spot_shock) - pos.avg_px_open.as_f64();
659 greeks_data.price = greeks_data.pnl;
660 }
661
662 Ok(greeks_data)
663 }
664
665 #[expect(clippy::too_many_arguments)]
666 fn calculate_option_greeks(
667 &self,
668 instrument: &InstrumentAny,
669 instrument_id: InstrumentId,
670 underlying_instrument_id: InstrumentId,
671 flat_interest_rate: f64,
672 flat_dividend_yield: Option<f64>,
673 use_cached_greeks: bool,
674 update_vol: bool,
675 ts_event: UnixNanos,
676 percent_greeks: bool,
677 index_instrument_id: Option<InstrumentId>,
678 beta_weights: Option<&HashMap<InstrumentId, f64>>,
679 vega_time_weight_base: Option<i32>,
680 vol_index_instrument_id: Option<InstrumentId>,
681 vol_beta_weights: Option<&HashMap<InstrumentId, f64>>,
682 ) -> anyhow::Result<(GreeksData, f64, bool)> {
683 if use_cached_greeks {
684 let cache = self.cache.borrow();
685 if let Some(cached_greeks) = cache.greeks(&instrument_id) {
686 check_option_greeks_data(&cached_greeks)?;
687 let db_dr = f64::from(u8::from(
688 flat_dividend_yield.is_some()
689 || cache
690 .yield_curve(&underlying_instrument_id.to_string())
691 .is_some(),
692 ));
693 return Ok((cached_greeks, db_dr, false));
694 }
695 }
696
697 let utc_now_ns = if ts_event == UnixNanos::default() {
698 self.clock.borrow().timestamp_ns()
699 } else {
700 ts_event
701 };
702
703 let utc_now = utc_now_ns.to_datetime_utc();
704 let expiry_utc = instrument
705 .expiration_ns()
706 .map(|ns| ns.to_datetime_utc())
707 .unwrap_or_default();
708 let expiry_int = expiry_utc
709 .strftime("%Y%m%d")
710 .to_string()
711 .parse::<i32>()
712 .unwrap_or(0);
713 let raw_days = utc_now.duration_until(expiry_utc).as_hours() / 24;
714 let expiry_in_days = raw_days.max(1) as i32;
715 let expiry_in_years = expiry_in_days as f64 / 365.25;
716 let currency = instrument.quote_currency().code.to_string();
717
718 let cache = self.cache.borrow();
719 let yield_curve = cache.yield_curve(¤cy);
720
721 let interest_rate = match yield_curve {
722 Some(yield_curve) => yield_curve(expiry_in_years),
723 None => flat_interest_rate,
724 };
725
726 let dividend_curve = cache.yield_curve(&underlying_instrument_id.to_string());
727 drop(cache);
728
729 let (cost_of_carry, db_dr) = if let Some(dividend_curve) = dividend_curve {
730 (interest_rate - dividend_curve(expiry_in_years), 1.0)
731 } else if let Some(div_yield) = flat_dividend_yield {
732 (interest_rate - div_yield, 1.0)
733 } else {
734 (0.0, 0.0)
735 };
736
737 let multiplier = instrument.multiplier();
738 let is_call = instrument.option_kind().unwrap_or(OptionKind::Call) == OptionKind::Call;
739 let strike = instrument.strike_price().unwrap_or_default().as_f64();
740 let option_price = self
741 .get_price(&instrument_id)
742 .ok_or_else(|| anyhow::anyhow!("No price available for {instrument_id}"))?;
743 let underlying_price = self.get_underlying_price(&underlying_instrument_id)?;
744
745 if let Some(vol_index_id) = vol_index_instrument_id {
746 self.get_price(&vol_index_id)
747 .ok_or_else(|| anyhow::anyhow!("No price available for {vol_index_id}"))?;
748 }
749
750 let seed_vol = if update_vol {
751 self.cache
752 .borrow()
753 .greeks(&instrument_id)
754 .map(|greeks| greeks.vol)
755 } else {
756 None
757 };
758
759 let greeks = imply_vol_and_greeks_checked(
760 underlying_price,
761 interest_rate,
762 cost_of_carry,
763 is_call,
764 strike,
765 expiry_in_years,
766 option_price,
767 db_dr,
768 seed_vol,
769 )?;
770
771 let (delta, gamma, vega) = self.modify_greeks(
772 greeks.delta,
773 greeks.gamma,
774 underlying_instrument_id,
775 underlying_price,
776 underlying_price,
777 percent_greeks,
778 index_instrument_id,
779 beta_weights,
780 greeks.vega,
781 greeks.vol,
782 expiry_in_days,
783 vega_time_weight_base,
784 greeks.vol,
785 vol_index_instrument_id,
786 vol_beta_weights,
787 None,
788 None,
789 )?;
790
791 let greeks_data = GreeksData::new(
792 instrument_id,
793 is_call,
794 strike,
795 expiry_int,
796 expiry_in_days,
797 expiry_in_years,
798 multiplier.as_f64(),
799 1.0,
800 underlying_price,
801 interest_rate,
802 cost_of_carry,
803 greeks.vol,
804 0.0,
805 greeks.price,
806 OptionGreekValues {
807 delta,
808 gamma,
809 vega,
810 theta: greeks.theta,
811 rho: greeks.rho,
812 },
813 greeks.itm_prob,
814 utc_now_ns,
815 utc_now_ns,
816 );
817
818 check_option_greeks_data(&greeks_data)?;
819
820 Ok((greeks_data, db_dr, true))
821 }
822
823 #[expect(clippy::too_many_arguments)]
824 fn apply_option_greeks_shocks(
825 &self,
826 greeks_data: &GreeksData,
827 underlying_instrument_id: InstrumentId,
828 db_dr: f64,
829 spot_shock: f64,
830 vol_shock: f64,
831 time_to_expiry_shock: f64,
832 percent_greeks: bool,
833 index_instrument_id: Option<InstrumentId>,
834 beta_weights: Option<&HashMap<InstrumentId, f64>>,
835 vega_time_weight_base: Option<i32>,
836 vol_index_instrument_id: Option<InstrumentId>,
837 vol_beta_weights: Option<&HashMap<InstrumentId, f64>>,
838 ) -> anyhow::Result<GreeksData> {
839 let underlying_price = greeks_data.underlying_price;
840 let shocked_underlying_price = underlying_price + spot_shock;
841 let shocked_vol = greeks_data.vol + vol_shock;
842 let shocked_time_to_expiry = greeks_data.expiry_in_years - time_to_expiry_shock;
843 let shocked_expiry_in_days = (shocked_time_to_expiry * 365.25) as i32;
844
845 let greeks = black_scholes_greeks_checked(
846 shocked_underlying_price,
847 greeks_data.interest_rate,
848 greeks_data.cost_of_carry,
849 shocked_vol,
850 greeks_data.is_call,
851 greeks_data.strike,
852 shocked_time_to_expiry,
853 db_dr,
854 )?;
855 let (delta, gamma, vega) = self.modify_greeks(
856 greeks.delta,
857 greeks.gamma,
858 underlying_instrument_id,
859 shocked_underlying_price,
860 underlying_price,
861 percent_greeks,
862 index_instrument_id,
863 beta_weights,
864 greeks.vega,
865 shocked_vol,
866 shocked_expiry_in_days,
867 vega_time_weight_base,
868 greeks_data.vol,
869 vol_index_instrument_id,
870 vol_beta_weights,
871 None,
872 None,
873 )?;
874 Ok(GreeksData::new(
875 greeks_data.instrument_id,
876 greeks_data.is_call,
877 greeks_data.strike,
878 greeks_data.expiry,
879 shocked_expiry_in_days,
880 shocked_time_to_expiry,
881 greeks_data.multiplier,
882 greeks_data.quantity,
883 shocked_underlying_price,
884 greeks_data.interest_rate,
885 greeks_data.cost_of_carry,
886 shocked_vol,
887 0.0,
888 greeks.price,
889 OptionGreekValues {
890 delta,
891 gamma,
892 vega,
893 theta: greeks.theta,
894 rho: greeks.rho,
895 },
896 greeks.itm_prob,
897 greeks_data.ts_event,
898 greeks_data.ts_event,
899 ))
900 }
901
902 fn get_underlying_price(&self, underlying_instrument_id: &InstrumentId) -> anyhow::Result<f64> {
903 if let Some(underlying_price) = self.get_price(underlying_instrument_id) {
904 return Ok(underlying_price);
905 }
906
907 let is_future_or_absent = {
910 let cache = self.cache.borrow();
911 cache
912 .instrument(underlying_instrument_id)
913 .is_none_or(|inst| inst.instrument_class() == InstrumentClass::Future)
914 };
915
916 if is_future_or_absent
917 && let Some(underlying_price) =
918 self.get_cached_futures_spread_price(*underlying_instrument_id)
919 {
920 return Ok(underlying_price.as_f64());
921 }
922
923 anyhow::bail!("No price available for {underlying_instrument_id}")
924 }
925
926 #[expect(clippy::too_many_arguments)]
952 pub fn modify_greeks(
953 &self,
954 delta_input: f64,
955 gamma_input: f64,
956 underlying_instrument_id: InstrumentId,
957 underlying_price: f64,
958 unshocked_underlying_price: f64,
959 percent_greeks: bool,
960 index_instrument_id: Option<InstrumentId>,
961 beta_weights: Option<&HashMap<InstrumentId, f64>>,
962 vega_input: f64,
963 vol: f64,
964 expiry_in_days: i32,
965 vega_time_weight_base: Option<i32>,
966 unshocked_vol: f64,
967 vol_index_instrument_id: Option<InstrumentId>,
968 vol_beta_weights: Option<&HashMap<InstrumentId, f64>>,
969 index_price: Option<f64>,
970 vol_index_price: Option<f64>,
971 ) -> anyhow::Result<(f64, f64, f64)> {
972 let mut delta = delta_input;
973 let mut gamma = gamma_input;
974 let mut vega = vega_input;
975
976 let mut used_index_price = index_price
977 .or_else(|| index_instrument_id.and_then(|index_id| self.get_price(&index_id)));
978 let mut used_index_vol = vol_index_price;
979 if used_index_vol.is_none()
980 && let Some(vol_index_id) = vol_index_instrument_id
981 {
982 used_index_vol = Some(
983 self.get_price(&vol_index_id)
984 .ok_or_else(|| anyhow::anyhow!("No price available for {vol_index_id}"))?,
985 );
986 }
987
988 if used_index_price.is_some() {
989 let mut beta = 1.0;
990 if let Some(weights) = beta_weights
991 && let Some(&weight) = weights.get(&underlying_instrument_id)
992 {
993 beta = weight;
994 }
995
996 if let Some(ref mut idx_price) = used_index_price {
997 #[expect(clippy::float_cmp, reason = "exact-equality baseline check")]
998 if underlying_price != unshocked_underlying_price {
999 *idx_price += 1.0 / beta
1000 * (*idx_price / unshocked_underlying_price)
1001 * (underlying_price - unshocked_underlying_price);
1002 }
1003
1004 let delta_multiplier = beta * underlying_price / *idx_price;
1005 delta *= delta_multiplier;
1006 gamma *= delta_multiplier.powi(2);
1007 }
1008 }
1009
1010 if used_index_vol.is_some() {
1011 let mut vega_beta = 1.0;
1012
1013 let used_vol = if unshocked_vol == 0.0 {
1014 vol
1015 } else {
1016 unshocked_vol
1017 };
1018
1019 if let Some(weights) = vol_beta_weights
1020 && let Some(&weight) = weights.get(&underlying_instrument_id)
1021 {
1022 vega_beta = weight;
1023 }
1024
1025 if let Some(ref mut idx_vol) = used_index_vol {
1026 *idx_vol *= 0.01;
1027
1028 #[expect(clippy::float_cmp, reason = "exact-equality baseline check")]
1029 if vol != used_vol && used_vol != 0.0 {
1030 *idx_vol += 1.0 / vega_beta * (*idx_vol / used_vol) * (vol - used_vol);
1031 }
1032
1033 if *idx_vol != 0.0 {
1034 vega *= vega_beta * vol / *idx_vol;
1035 }
1036 }
1037 }
1038
1039 if percent_greeks {
1040 if let Some(idx_price) = used_index_price {
1041 delta *= idx_price / 100.0;
1042 gamma *= (idx_price / 100.0).powi(2);
1043 } else {
1044 delta *= underlying_price / 100.0;
1045 gamma *= (underlying_price / 100.0).powi(2);
1046 }
1047
1048 if let Some(idx_vol) = used_index_vol {
1049 vega *= idx_vol / 100.0;
1050 } else {
1051 vega *= vol / 100.0;
1052 }
1053 }
1054
1055 if let Some(time_base) = vega_time_weight_base
1057 && expiry_in_days > 0
1058 {
1059 let time_weight = (time_base as f64 / expiry_in_days as f64).sqrt();
1060 vega *= time_weight;
1061 }
1062
1063 Ok((delta, gamma, vega))
1064 }
1065
1066 #[expect(clippy::too_many_arguments)]
1081 pub fn portfolio_greeks(
1082 &self,
1083 underlyings: Option<&[String]>,
1084 venue: Option<Venue>,
1085 instrument_id: Option<InstrumentId>,
1086 strategy_id: Option<StrategyId>,
1087 side: Option<PositionSide>,
1088 flat_interest_rate: Option<f64>,
1089 flat_dividend_yield: Option<f64>,
1090 spot_shock: Option<f64>,
1091 vol_shock: Option<f64>,
1092 time_to_expiry_shock: Option<f64>,
1093 use_cached_greeks: Option<bool>,
1094 update_vol: Option<bool>,
1095 cache_greeks: Option<bool>,
1096 publish_greeks: Option<bool>,
1097 percent_greeks: Option<bool>,
1098 index_instrument_id: Option<InstrumentId>,
1099 beta_weights: Option<&HashMap<InstrumentId, f64>>,
1100 greeks_filter: Option<&GreeksFilter>,
1101 vega_time_weight_base: Option<i32>,
1102 vol_index_instrument_id: Option<InstrumentId>,
1103 vol_beta_weights: Option<&HashMap<InstrumentId, f64>>,
1104 ) -> anyhow::Result<PortfolioGreeks> {
1105 let ts_event = self.clock.borrow().timestamp_ns();
1106 let mut portfolio_greeks =
1107 PortfolioGreeks::new(0.0, 0.0, 0.0, 0.0, 0.0, 0.0, ts_event, ts_event);
1108
1109 let flat_interest_rate = flat_interest_rate.unwrap_or(0.0425);
1111 let spot_shock = spot_shock.unwrap_or(0.0);
1112 let vol_shock = vol_shock.unwrap_or(0.0);
1113 let time_to_expiry_shock = time_to_expiry_shock.unwrap_or(0.0);
1114 let use_cached_greeks = use_cached_greeks.unwrap_or(false);
1115 let update_vol = update_vol.unwrap_or(false);
1116 let cache_greeks = cache_greeks.unwrap_or(false);
1117 let publish_greeks = publish_greeks.unwrap_or(false);
1118 let percent_greeks = percent_greeks.unwrap_or(false);
1119
1120 let open_positions: Vec<Position> = {
1122 let cache = self.cache.borrow();
1123 cache
1124 .positions_open(
1125 venue.as_ref(),
1126 instrument_id.as_ref(),
1127 strategy_id.as_ref(),
1128 None, side,
1130 )
1131 .iter()
1132 .map(PositionRef::cloned)
1133 .collect()
1134 };
1135
1136 for position in open_positions {
1137 let position_instrument_id = position.instrument_id;
1138
1139 if let Some(underlyings_list) = underlyings {
1140 let mut skip_position = true;
1141
1142 for underlying in underlyings_list {
1143 if position_instrument_id
1144 .symbol
1145 .as_str()
1146 .starts_with(underlying)
1147 {
1148 skip_position = false;
1149 break;
1150 }
1151 }
1152
1153 if skip_position {
1154 continue;
1155 }
1156 }
1157
1158 let quantity = position.signed_qty.as_f64();
1159 let instrument_greeks = self.instrument_greeks(
1160 position_instrument_id,
1161 Some(flat_interest_rate),
1162 flat_dividend_yield,
1163 Some(spot_shock),
1164 Some(vol_shock),
1165 Some(time_to_expiry_shock),
1166 Some(use_cached_greeks),
1167 Some(update_vol),
1168 Some(cache_greeks),
1169 Some(publish_greeks),
1170 Some(ts_event),
1171 Some(position),
1172 Some(percent_greeks),
1173 index_instrument_id,
1174 beta_weights,
1175 vega_time_weight_base,
1176 vol_index_instrument_id,
1177 vol_beta_weights,
1178 )?;
1179 let position_portfolio_greeks = quantity * &instrument_greeks.to_portfolio_greeks();
1180
1181 let position_greeks = GreeksData {
1182 pnl: position_portfolio_greeks.pnl,
1183 price: position_portfolio_greeks.price,
1184 greeks: position_portfolio_greeks.greeks,
1185 ..instrument_greeks
1186 };
1187
1188 if greeks_filter.is_none_or(|filter| filter(&position_greeks)) {
1190 portfolio_greeks = portfolio_greeks + position_portfolio_greeks;
1191 check_greeks_values(
1192 portfolio_greeks.pnl,
1193 portfolio_greeks.price,
1194 portfolio_greeks.greeks,
1195 )?;
1196 }
1197 }
1198
1199 Ok(portfolio_greeks)
1200 }
1201
1202 pub fn cache_futures_spread(
1208 &self,
1209 call_instrument_id: InstrumentId,
1210 put_instrument_id: InstrumentId,
1211 futures_instrument_id: InstrumentId,
1212 ) -> anyhow::Result<Price> {
1213 let cache = self.cache.borrow();
1214 let call_instrument = cache.instrument(&call_instrument_id).cloned();
1215 let put_instrument = cache.instrument(&put_instrument_id).cloned();
1216 let reference_future_instrument = cache.instrument(&futures_instrument_id).cloned();
1217 drop(cache);
1218
1219 let Some(call_instrument) = call_instrument else {
1220 anyhow::bail!(
1221 "Cannot cache futures spread: missing option instrument {call_instrument_id}"
1222 );
1223 };
1224
1225 let Some(put_instrument) = put_instrument else {
1226 anyhow::bail!(
1227 "Cannot cache futures spread: missing option instrument {put_instrument_id}"
1228 );
1229 };
1230
1231 let Some(reference_future_instrument) = reference_future_instrument else {
1232 anyhow::bail!(
1233 "Cannot cache futures spread: no reference futures instrument for {futures_instrument_id}"
1234 );
1235 };
1236
1237 if call_instrument.instrument_class() != InstrumentClass::Option
1238 || put_instrument.instrument_class() != InstrumentClass::Option
1239 {
1240 anyhow::bail!(
1241 "Cannot cache futures spread: non-option instruments provided call_instrument_id={call_instrument_id} put_instrument_id={put_instrument_id}"
1242 );
1243 }
1244
1245 if call_instrument.option_kind() != Some(OptionKind::Call)
1246 || put_instrument.option_kind() != Some(OptionKind::Put)
1247 {
1248 anyhow::bail!(
1249 "Cannot cache futures spread: expected call/put pair call_instrument_id={call_instrument_id} put_instrument_id={put_instrument_id}"
1250 );
1251 }
1252
1253 let Some(call_underlying) = call_instrument.underlying() else {
1254 anyhow::bail!(
1255 "Cannot cache futures spread: missing call underlying for {call_instrument_id}"
1256 );
1257 };
1258
1259 let Some(put_underlying) = put_instrument.underlying() else {
1260 anyhow::bail!(
1261 "Cannot cache futures spread: missing put underlying for {put_instrument_id}"
1262 );
1263 };
1264
1265 if call_underlying != put_underlying {
1266 anyhow::bail!(
1267 "Cannot cache futures spread: option underlyings differ call_instrument_id={call_instrument_id} put_instrument_id={put_instrument_id}"
1268 );
1269 }
1270
1271 if call_instrument.strike_price() != put_instrument.strike_price() {
1272 anyhow::bail!(
1273 "Cannot cache futures spread: strike prices differ call_instrument_id={call_instrument_id} put_instrument_id={put_instrument_id}"
1274 );
1275 }
1276
1277 if call_instrument.expiration_ns() != put_instrument.expiration_ns() {
1278 anyhow::bail!(
1279 "Cannot cache futures spread: expiration dates differ call_instrument_id={call_instrument_id} put_instrument_id={put_instrument_id}"
1280 );
1281 }
1282
1283 let reference_future_price = self.get_price_object(&futures_instrument_id).ok_or_else(|| {
1284 anyhow::anyhow!(
1285 "Cannot cache futures spread: no reference futures price for {futures_instrument_id}"
1286 )
1287 })?;
1288
1289 let call_price = self.get_price(&call_instrument_id).ok_or_else(|| {
1290 anyhow::anyhow!(
1291 "Cannot cache futures spread: missing option price for {call_instrument_id}"
1292 )
1293 })?;
1294
1295 let put_price = self.get_price(&put_instrument_id).ok_or_else(|| {
1296 anyhow::anyhow!(
1297 "Cannot cache futures spread: missing option price for {put_instrument_id}"
1298 )
1299 })?;
1300
1301 let underlying_instrument_id =
1302 InstrumentId::from(format!("{call_underlying}.{}", call_instrument_id.venue));
1303
1304 {
1306 let cache = self.cache.borrow();
1307 if let Some(underlying) = cache.instrument(&underlying_instrument_id)
1308 && underlying.instrument_class() != InstrumentClass::Future
1309 {
1310 anyhow::bail!(
1311 "Cannot cache futures spread: underlying {underlying_instrument_id} is not a futures contract"
1312 );
1313 }
1314 }
1315
1316 let implied_future_price =
1317 self.calculate_implied_future_price(&call_instrument, call_price, put_price);
1318 let spread = implied_future_price - reference_future_price.as_f64();
1319 let spread_price = reference_future_instrument.make_price(spread);
1320
1321 self.cached_futures_spreads.borrow_mut().insert(
1322 underlying_instrument_id,
1323 (futures_instrument_id, spread_price),
1324 );
1325
1326 Ok(reference_future_price + spread_price)
1327 }
1328
1329 fn calculate_implied_future_price(
1330 &self,
1331 call_instrument: &InstrumentAny,
1332 call_price: f64,
1333 put_price: f64,
1334 ) -> f64 {
1335 let expiry_utc = call_instrument
1336 .expiration_ns()
1337 .map(|ns| ns.to_datetime_utc())
1338 .unwrap_or_default();
1339 let now = self.clock.borrow().timestamp_ns().to_datetime_utc();
1340 let expiry_in_days = (now.duration_until(expiry_utc).as_hours() / 24).max(1) as i32;
1341 let expiry_in_years = expiry_in_days as f64 / 365.25;
1342 let currency = call_instrument.quote_currency().code.to_string();
1343 let interest_rate = self
1344 .cache
1345 .borrow()
1346 .yield_curve(¤cy)
1347 .map_or(0.0425, |yield_curve| yield_curve(expiry_in_years));
1348 let strike = call_instrument.strike_price().unwrap_or_default().as_f64();
1349
1350 strike + (interest_rate * expiry_in_years).exp() * (call_price - put_price)
1351 }
1352
1353 #[must_use]
1355 pub fn get_cached_futures_spread_price(
1356 &self,
1357 underlying_instrument_id: InstrumentId,
1358 ) -> Option<Price> {
1359 let (futures_instrument_id, spread) = self
1360 .cached_futures_spreads
1361 .borrow()
1362 .get(&underlying_instrument_id)
1363 .copied()?;
1364 let reference_future_price = self.get_price_object(&futures_instrument_id)?;
1365
1366 Some(reference_future_price + spread)
1367 }
1368
1369 fn get_price_object(&self, instrument_id: &InstrumentId) -> Option<Price> {
1370 let cache = self.cache.borrow();
1371 let price = cache
1372 .price(instrument_id, PriceType::Mid)
1373 .or_else(|| cache.price(instrument_id, PriceType::Last));
1374
1375 if let Some(instrument) = cache.instrument(instrument_id)
1379 && instrument.asset_class() == AssetClass::Index
1380 {
1381 if instrument.instrument_class() == InstrumentClass::Future && price.is_some() {
1382 return price;
1383 }
1384
1385 if let Some(index_price) = cache.index_price(instrument_id) {
1386 return Some(index_price.value);
1387 }
1388 }
1389
1390 price
1391 }
1392
1393 fn get_price(&self, instrument_id: &InstrumentId) -> Option<f64> {
1394 self.get_price_object(instrument_id)
1395 .map(|price| price.as_f64())
1396 }
1397
1398 pub fn subscribe_greeks<F>(&self, underlying: &str, handler: Option<F>)
1403 where
1404 F: Fn(&GreeksData) + 'static,
1405 {
1406 let pattern = greeks_topic(underlying, "*").into();
1407
1408 if let Some(custom_handler) = handler {
1409 let typed_handler = TypedHandler::from(custom_handler);
1410 msgbus::subscribe_greeks(pattern, typed_handler, None);
1411 } else {
1412 let cache_ref = self.cache.clone();
1413 let typed_handler = TypedHandler::from(move |greeks: &GreeksData| {
1414 if let Err(e) = cache_ref.borrow_mut().add_greeks(greeks.clone()) {
1415 log::error!("Failed to add GreeksData to cache: {e}");
1416 }
1417 });
1418
1419 msgbus::subscribe_greeks(pattern, typed_handler, None);
1420 }
1421 }
1422}
1423
1424fn greeks_topic(underlying: &str, instrument_id: &str) -> String {
1426 format!("data.GreeksData.underlying={underlying}.instrument_id={instrument_id}")
1427}
1428
1429fn check_option_greeks_data(data: &GreeksData) -> Result<(), OptionValuationError> {
1430 check_option_inputs(
1431 data.underlying_price,
1432 data.interest_rate,
1433 data.cost_of_carry,
1434 data.vol,
1435 data.strike,
1436 data.expiry_in_years,
1437 )?;
1438 check_greeks_values(data.pnl, data.price, data.greeks)?;
1439 if !(0.0..=1.0).contains(&data.itm_prob)
1440 || data.price < 0.0
1441 || !data.multiplier.is_finite()
1442 || !data.quantity.is_finite()
1443 {
1444 return Err(OptionValuationError::InvalidResult);
1445 }
1446
1447 Ok(())
1448}
1449
1450fn check_greeks_values(
1451 pnl: f64,
1452 price: f64,
1453 greeks: OptionGreekValues,
1454) -> Result<(), OptionValuationError> {
1455 if [
1456 pnl,
1457 price,
1458 greeks.delta,
1459 greeks.gamma,
1460 greeks.vega,
1461 greeks.theta,
1462 greeks.rho,
1463 ]
1464 .iter()
1465 .any(|value| !value.is_finite())
1466 {
1467 return Err(OptionValuationError::InvalidResult);
1468 }
1469
1470 Ok(())
1471}
1472
1473#[cfg(test)]
1474mod tests {
1475 use std::{cell::RefCell, collections::HashMap, rc::Rc};
1476
1477 use jiff::{Timestamp, civil::Date, tz::Offset};
1478 use nautilus_model::{
1479 data::{IndexPriceUpdate, QuoteTick, YieldCurve},
1480 enums::{AssetClass, OmsType, OptionKind, OrderSide, PositionSide},
1481 events::order::spec::OrderFilledSpec,
1482 identifiers::{
1483 ClientOrderId, InstrumentId, PositionId, StrategyId, Symbol, TradeId, Venue,
1484 },
1485 instruments::{
1486 Equity, FuturesContract, OptionContract,
1487 any::InstrumentAny,
1488 stubs::{crypto_option_spread_btc_deribit, option_spread},
1489 },
1490 types::{Currency, Price, Quantity},
1491 };
1492 use rstest::rstest;
1493 use rust_decimal::Decimal;
1494 use ustr::Ustr;
1495
1496 use super::*;
1497 use crate::{cache::Cache, clock::VirtualClock};
1498
1499 fn utc_timestamp(year: i16, month: i8, day: i8, hour: i8, minute: i8, second: i8) -> Timestamp {
1500 Offset::UTC
1501 .to_timestamp(
1502 Date::new(year, month, day)
1503 .unwrap()
1504 .at(hour, minute, second, 0),
1505 )
1506 .unwrap()
1507 }
1508
1509 fn create_test_calculator() -> GreeksCalculator {
1510 let cache = Rc::new(RefCell::new(Cache::new(None, None)));
1511 let clock = Rc::new(RefCell::new(VirtualClock::new()));
1512 GreeksCalculator::new(cache, clock)
1513 }
1514
1515 #[rstest]
1516 fn test_greeks_calculator_debug() {
1517 let calculator = create_test_calculator();
1518
1519 let debug_str = format!("{calculator:?}");
1520
1521 assert!(debug_str.starts_with("GreeksCalculator {"), "{debug_str}");
1522 assert!(debug_str.contains("cache:"), "{debug_str}");
1523 assert!(debug_str.contains("clock:"), "{debug_str}");
1524 assert!(
1525 debug_str.contains("cached_futures_spreads: RefCell { value: {} }"),
1526 "{debug_str}"
1527 );
1528 assert!(
1529 debug_str.contains("cross_venue_underlyings: RefCell { value: {} }"),
1530 "{debug_str}"
1531 );
1532 }
1533
1534 #[rstest]
1535 fn test_instrument_greeks_params_builder_default() {
1536 let instrument_id = InstrumentId::from("AAPL.NASDAQ");
1537
1538 let params = InstrumentGreeksParams::builder()
1539 .instrument_id(instrument_id)
1540 .build();
1541
1542 assert_eq!(params.instrument_id, instrument_id);
1543 assert_eq!(params.flat_interest_rate, 0.0425);
1544 assert_eq!(params.flat_dividend_yield, None);
1545 assert_eq!(params.spot_shock, 0.0);
1546 assert_eq!(params.vol_shock, 0.0);
1547 assert_eq!(params.time_to_expiry_shock, 0.0);
1548 assert!(!params.use_cached_greeks);
1549 assert!(!params.cache_greeks);
1550 assert!(!params.publish_greeks);
1551 assert_eq!(params.ts_event, None);
1552 assert_eq!(params.position, None);
1553 assert!(!params.percent_greeks);
1554 assert_eq!(params.index_instrument_id, None);
1555 assert_eq!(params.beta_weights, None);
1556 assert_eq!(params.vol_index_instrument_id, None);
1557 assert_eq!(params.vol_beta_weights, None);
1558 }
1559
1560 #[rstest]
1561 fn test_instrument_greeks_params_builder_custom_values() {
1562 let instrument_id = InstrumentId::from("AAPL.NASDAQ");
1563 let index_id = InstrumentId::from("SPY.NASDAQ");
1564 let vol_index_id = InstrumentId::from("VIX.XCBF");
1565 let mut beta_weights = HashMap::new();
1566 beta_weights.insert(instrument_id, 1.2);
1567 let mut vol_beta_weights = HashMap::new();
1568 vol_beta_weights.insert(instrument_id, 0.8);
1569
1570 let params = InstrumentGreeksParams::builder()
1571 .instrument_id(instrument_id)
1572 .flat_interest_rate(0.05)
1573 .flat_dividend_yield(0.02)
1574 .spot_shock(0.01)
1575 .vol_shock(0.05)
1576 .time_to_expiry_shock(0.1)
1577 .use_cached_greeks(true)
1578 .cache_greeks(true)
1579 .publish_greeks(true)
1580 .percent_greeks(true)
1581 .index_instrument_id(index_id)
1582 .beta_weights(beta_weights.clone())
1583 .vol_index_instrument_id(vol_index_id)
1584 .vol_beta_weights(vol_beta_weights.clone())
1585 .build();
1586
1587 assert_eq!(params.instrument_id, instrument_id);
1588 assert_eq!(params.flat_interest_rate, 0.05);
1589 assert_eq!(params.flat_dividend_yield, Some(0.02));
1590 assert_eq!(params.spot_shock, 0.01);
1591 assert_eq!(params.vol_shock, 0.05);
1592 assert_eq!(params.time_to_expiry_shock, 0.1);
1593 assert!(params.use_cached_greeks);
1594 assert!(params.cache_greeks);
1595 assert!(params.publish_greeks);
1596 assert!(params.percent_greeks);
1597 assert_eq!(params.index_instrument_id, Some(index_id));
1598 assert_eq!(params.beta_weights, Some(beta_weights));
1599 assert_eq!(params.vol_index_instrument_id, Some(vol_index_id));
1600 assert_eq!(params.vol_beta_weights, Some(vol_beta_weights));
1601 }
1602
1603 #[rstest]
1604 fn test_instrument_greeks_params_debug() {
1605 let instrument_id = InstrumentId::from("AAPL.NASDAQ");
1606
1607 let params = InstrumentGreeksParams::builder()
1608 .instrument_id(instrument_id)
1609 .build();
1610
1611 let debug_str = format!("{params:?}");
1612 assert!(debug_str.contains("InstrumentGreeksParams"));
1613 assert!(debug_str.contains("AAPL.NASDAQ"));
1614 }
1615
1616 #[rstest]
1617 fn test_portfolio_greeks_params_builder_default() {
1618 let params = PortfolioGreeksParams::builder().build();
1619
1620 assert_eq!(params.underlyings, None);
1621 assert_eq!(params.venue, None);
1622 assert_eq!(params.instrument_id, None);
1623 assert_eq!(params.strategy_id, None);
1624 assert_eq!(params.side, None);
1625 assert_eq!(params.flat_interest_rate, 0.0425);
1626 assert_eq!(params.flat_dividend_yield, None);
1627 assert_eq!(params.spot_shock, 0.0);
1628 assert_eq!(params.vol_shock, 0.0);
1629 assert_eq!(params.time_to_expiry_shock, 0.0);
1630 assert!(!params.use_cached_greeks);
1631 assert!(!params.cache_greeks);
1632 assert!(!params.publish_greeks);
1633 assert!(!params.percent_greeks);
1634 assert_eq!(params.index_instrument_id, None);
1635 assert_eq!(params.beta_weights, None);
1636 assert_eq!(params.vol_index_instrument_id, None);
1637 assert_eq!(params.vol_beta_weights, None);
1638 }
1639
1640 #[rstest]
1641 fn test_portfolio_greeks_params_builder_custom_values() {
1642 let venue = Venue::from("NASDAQ");
1643 let instrument_id = InstrumentId::from("AAPL.NASDAQ");
1644 let strategy_id = StrategyId::from("test-strategy");
1645 let index_id = InstrumentId::from("SPY.NASDAQ");
1646 let vol_index_id = InstrumentId::from("VIX.XCBF");
1647 let underlyings = vec!["AAPL".to_string(), "MSFT".to_string()];
1648 let mut beta_weights = HashMap::new();
1649 beta_weights.insert(instrument_id, 1.2);
1650 let mut vol_beta_weights = HashMap::new();
1651 vol_beta_weights.insert(instrument_id, 0.8);
1652
1653 let params = PortfolioGreeksParams::builder()
1654 .underlyings(underlyings.clone())
1655 .venue(venue)
1656 .instrument_id(instrument_id)
1657 .strategy_id(strategy_id)
1658 .side(PositionSide::Long)
1659 .flat_interest_rate(0.05)
1660 .flat_dividend_yield(0.02)
1661 .spot_shock(0.01)
1662 .vol_shock(0.05)
1663 .time_to_expiry_shock(0.1)
1664 .use_cached_greeks(true)
1665 .cache_greeks(true)
1666 .publish_greeks(true)
1667 .percent_greeks(true)
1668 .index_instrument_id(index_id)
1669 .beta_weights(beta_weights.clone())
1670 .vol_index_instrument_id(vol_index_id)
1671 .vol_beta_weights(vol_beta_weights.clone())
1672 .build();
1673
1674 assert_eq!(params.underlyings, Some(underlyings));
1675 assert_eq!(params.venue, Some(venue));
1676 assert_eq!(params.instrument_id, Some(instrument_id));
1677 assert_eq!(params.strategy_id, Some(strategy_id));
1678 assert_eq!(params.side, Some(PositionSide::Long));
1679 assert_eq!(params.flat_interest_rate, 0.05);
1680 assert_eq!(params.flat_dividend_yield, Some(0.02));
1681 assert_eq!(params.spot_shock, 0.01);
1682 assert_eq!(params.vol_shock, 0.05);
1683 assert_eq!(params.time_to_expiry_shock, 0.1);
1684 assert!(params.use_cached_greeks);
1685 assert!(params.cache_greeks);
1686 assert!(params.publish_greeks);
1687 assert!(params.percent_greeks);
1688 assert_eq!(params.index_instrument_id, Some(index_id));
1689 assert_eq!(params.beta_weights, Some(beta_weights));
1690 assert_eq!(params.vol_index_instrument_id, Some(vol_index_id));
1691 assert_eq!(params.vol_beta_weights, Some(vol_beta_weights));
1692 }
1693
1694 #[rstest]
1695 fn test_portfolio_greeks_params_debug() {
1696 let venue = Venue::from("NASDAQ");
1697
1698 let params = PortfolioGreeksParams::builder().venue(venue).build();
1699
1700 let debug_str = format!("{params:?}");
1701 assert!(debug_str.contains("PortfolioGreeksParams"));
1702 assert!(debug_str.contains("NASDAQ"));
1703 }
1704
1705 #[rstest]
1706 fn test_portfolio_greeks_params_builder_fluent_api() {
1707 let instrument_id = InstrumentId::from("AAPL.NASDAQ");
1708
1709 let params = PortfolioGreeksParams::builder()
1710 .instrument_id(instrument_id)
1711 .flat_interest_rate(0.05)
1712 .spot_shock(0.01)
1713 .percent_greeks(true)
1714 .build();
1715
1716 assert_eq!(params.instrument_id, Some(instrument_id));
1717 assert_eq!(params.flat_interest_rate, 0.05);
1718 assert_eq!(params.spot_shock, 0.01);
1719 assert!(params.percent_greeks);
1720 }
1721
1722 #[rstest]
1723 fn test_instrument_greeks_params_builder_fluent_chaining() {
1724 let instrument_id = InstrumentId::from("TSLA.NASDAQ");
1725
1726 let params = InstrumentGreeksParams::builder()
1728 .instrument_id(instrument_id)
1729 .flat_interest_rate(0.03)
1730 .spot_shock(0.02)
1731 .vol_shock(0.1)
1732 .use_cached_greeks(true)
1733 .percent_greeks(true)
1734 .build();
1735
1736 assert_eq!(params.instrument_id, instrument_id);
1737 assert_eq!(params.flat_interest_rate, 0.03);
1738 assert_eq!(params.spot_shock, 0.02);
1739 assert_eq!(params.vol_shock, 0.1);
1740 assert!(params.use_cached_greeks);
1741 assert!(params.percent_greeks);
1742 }
1743
1744 #[rstest]
1745 fn test_portfolio_greeks_params_builder_with_underlyings() {
1746 let underlyings = vec!["AAPL".to_string(), "MSFT".to_string(), "GOOGL".to_string()];
1747
1748 let params = PortfolioGreeksParams::builder()
1749 .underlyings(underlyings.clone())
1750 .flat_interest_rate(0.04)
1751 .build();
1752
1753 assert_eq!(params.underlyings, Some(underlyings));
1754 assert_eq!(params.flat_interest_rate, 0.04);
1755 }
1756
1757 #[rstest]
1758 fn test_builders_with_empty_beta_weights() {
1759 let instrument_id = InstrumentId::from("NVDA.NASDAQ");
1760 let empty_beta_weights = HashMap::new();
1761
1762 let instrument_params = InstrumentGreeksParams::builder()
1763 .instrument_id(instrument_id)
1764 .beta_weights(empty_beta_weights.clone())
1765 .vol_beta_weights(empty_beta_weights.clone())
1766 .build();
1767
1768 let portfolio_params = PortfolioGreeksParams::builder()
1769 .beta_weights(empty_beta_weights.clone())
1770 .vol_beta_weights(empty_beta_weights.clone())
1771 .build();
1772
1773 assert_eq!(
1774 instrument_params.beta_weights,
1775 Some(empty_beta_weights.clone())
1776 );
1777 assert_eq!(portfolio_params.beta_weights, Some(empty_beta_weights));
1778 assert_eq!(instrument_params.vol_beta_weights, Some(HashMap::new()));
1779 assert_eq!(portfolio_params.vol_beta_weights, Some(HashMap::new()));
1780 }
1781
1782 #[rstest]
1783 fn test_builders_with_all_shocks() {
1784 let instrument_id = InstrumentId::from("AMD.NASDAQ");
1785
1786 let instrument_params = InstrumentGreeksParams::builder()
1787 .instrument_id(instrument_id)
1788 .spot_shock(0.05)
1789 .vol_shock(0.1)
1790 .time_to_expiry_shock(0.01)
1791 .build();
1792
1793 let portfolio_params = PortfolioGreeksParams::builder()
1794 .spot_shock(0.05)
1795 .vol_shock(0.1)
1796 .time_to_expiry_shock(0.01)
1797 .build();
1798
1799 assert_eq!(instrument_params.spot_shock, 0.05);
1800 assert_eq!(instrument_params.vol_shock, 0.1);
1801 assert_eq!(instrument_params.time_to_expiry_shock, 0.01);
1802
1803 assert_eq!(portfolio_params.spot_shock, 0.05);
1804 assert_eq!(portfolio_params.vol_shock, 0.1);
1805 assert_eq!(portfolio_params.time_to_expiry_shock, 0.01);
1806 }
1807
1808 #[rstest]
1809 fn test_builders_with_all_boolean_flags() {
1810 let instrument_id = InstrumentId::from("META.NASDAQ");
1811
1812 let instrument_params = InstrumentGreeksParams::builder()
1813 .instrument_id(instrument_id)
1814 .use_cached_greeks(true)
1815 .cache_greeks(true)
1816 .publish_greeks(true)
1817 .percent_greeks(true)
1818 .build();
1819
1820 let portfolio_params = PortfolioGreeksParams::builder()
1821 .use_cached_greeks(true)
1822 .cache_greeks(true)
1823 .publish_greeks(true)
1824 .percent_greeks(true)
1825 .build();
1826
1827 assert!(instrument_params.use_cached_greeks);
1828 assert!(instrument_params.cache_greeks);
1829 assert!(instrument_params.publish_greeks);
1830 assert!(instrument_params.percent_greeks);
1831
1832 assert!(portfolio_params.use_cached_greeks);
1833 assert!(portfolio_params.cache_greeks);
1834 assert!(portfolio_params.publish_greeks);
1835 assert!(portfolio_params.percent_greeks);
1836 }
1837
1838 #[rstest]
1839 fn test_greeks_filter_callback_function() {
1840 fn filter_positive_delta(data: &GreeksData) -> bool {
1842 data.delta > 0.0
1843 }
1844
1845 let filter = GreeksFilterCallback::from_fn(filter_positive_delta);
1846
1847 let greeks_data = GreeksData::from_delta(
1849 InstrumentId::from("TEST.NASDAQ"),
1850 0.5,
1851 1.0,
1852 UnixNanos::default(),
1853 );
1854
1855 assert!(filter.call(&greeks_data));
1856
1857 let debug_str = format!("{filter:?}");
1859 assert!(debug_str.contains("GreeksFilterCallback::Function"));
1860 }
1861
1862 #[rstest]
1863 fn test_greeks_filter_callback_closure() {
1864 let min_delta = 0.3;
1866 let filter =
1867 GreeksFilterCallback::from_closure(move |data: &GreeksData| data.delta > min_delta);
1868
1869 let greeks_data = GreeksData::from_delta(
1871 InstrumentId::from("TEST.NASDAQ"),
1872 0.5,
1873 1.0,
1874 UnixNanos::default(),
1875 );
1876
1877 assert!(filter.call(&greeks_data));
1878
1879 let debug_str = format!("{filter:?}");
1881 assert!(debug_str.contains("GreeksFilterCallback::Closure"));
1882 }
1883
1884 #[rstest]
1885 fn test_greeks_filter_callback_clone() {
1886 fn filter_fn(data: &GreeksData) -> bool {
1887 data.delta > 0.0
1888 }
1889
1890 let filter1 = GreeksFilterCallback::from_fn(filter_fn);
1891 let filter2 = filter1.clone();
1892
1893 let greeks_data = GreeksData::from_delta(
1894 InstrumentId::from("TEST.NASDAQ"),
1895 0.5,
1896 1.0,
1897 UnixNanos::default(),
1898 );
1899
1900 assert!(filter1.call(&greeks_data));
1901 assert!(filter2.call(&greeks_data));
1902 }
1903
1904 #[rstest]
1905 fn test_portfolio_greeks_params_with_filter() {
1906 fn filter_high_delta(data: &GreeksData) -> bool {
1907 data.delta.abs() > 0.1
1908 }
1909
1910 let filter = GreeksFilterCallback::from_fn(filter_high_delta);
1911
1912 let params = PortfolioGreeksParams::builder()
1913 .greeks_filter(filter)
1914 .flat_interest_rate(0.05)
1915 .build();
1916
1917 assert!(params.greeks_filter.is_some());
1918 assert_eq!(params.flat_interest_rate, 0.05);
1919
1920 let greeks_data = GreeksData::from_delta(
1922 InstrumentId::from("TEST.NASDAQ"),
1923 0.5,
1924 1.0,
1925 UnixNanos::default(),
1926 );
1927
1928 let filter_ref = params.greeks_filter.as_ref().unwrap();
1929 assert!(filter_ref.call(&greeks_data));
1930 }
1931
1932 #[rstest]
1933 fn test_portfolio_greeks_params_with_closure_filter() {
1934 let min_gamma = 0.01;
1935 let filter =
1936 GreeksFilterCallback::from_closure(move |data: &GreeksData| data.gamma > min_gamma);
1937
1938 let params = PortfolioGreeksParams::builder()
1939 .greeks_filter(filter)
1940 .build();
1941
1942 assert!(params.greeks_filter.is_some());
1943
1944 let debug_str = format!("{params:?}");
1946 assert!(debug_str.contains("greeks_filter"));
1947 }
1948
1949 #[rstest]
1950 fn test_greeks_filter_to_greeks_filter_conversion() {
1951 fn filter_fn(data: &GreeksData) -> bool {
1952 data.delta > 0.0
1953 }
1954
1955 let callback = GreeksFilterCallback::from_fn(filter_fn);
1956 let greeks_filter = callback.to_greeks_filter();
1957
1958 let greeks_data = GreeksData::from_delta(
1959 InstrumentId::from("TEST.NASDAQ"),
1960 0.5,
1961 1.0,
1962 UnixNanos::default(),
1963 );
1964
1965 assert!(greeks_filter(&greeks_data));
1966 }
1967
1968 fn option_with_expiration(instrument_id: &str, expiration_ns: UnixNanos) -> OptionContract {
1969 let activation_ns = UnixNanos::from(utc_timestamp(2021, 9, 17, 0, 0, 0));
1970 OptionContract::builder()
1971 .instrument_id(InstrumentId::from(instrument_id))
1972 .raw_symbol(Symbol::from("AAPL211217C00150000"))
1973 .asset_class(AssetClass::Equity)
1974 .exchange(Ustr::from("GMNI"))
1975 .underlying(Ustr::from("AAPL"))
1976 .option_kind(OptionKind::Call)
1977 .strike_price(Price::from("149.0"))
1978 .currency(Currency::from("USD"))
1979 .activation_ns(activation_ns)
1980 .expiration_ns(expiration_ns)
1981 .price_precision(2)
1982 .price_increment(Price::from("0.01"))
1983 .multiplier(Quantity::from(100))
1984 .lot_size(Quantity::from(1))
1985 .ts_event(UnixNanos::default())
1986 .ts_init(UnixNanos::default())
1987 .build()
1988 .unwrap()
1989 }
1990
1991 fn equity_aapl_opra() -> Equity {
1992 Equity::builder()
1993 .instrument_id(InstrumentId::from("AAPL.OPRA"))
1994 .raw_symbol(Symbol::from("AAPL"))
1995 .isin(Ustr::from("US0378331005"))
1996 .currency(Currency::from("USD"))
1997 .price_precision(2)
1998 .price_increment(Price::from("0.01"))
1999 .ts_event(UnixNanos::default())
2000 .ts_init(UnixNanos::default())
2001 .build()
2002 .unwrap()
2003 }
2004
2005 #[rstest]
2006 fn test_resolve_underlying_instrument_id_errors_without_underlying() {
2007 let instrument = InstrumentAny::Equity(equity_aapl_opra());
2008 let error = create_test_calculator()
2009 .resolve_underlying_instrument_id(&instrument, InstrumentId::from("AAPL.OPRA"))
2010 .unwrap_err();
2011
2012 assert_eq!(
2013 error.to_string(),
2014 "Instrument AAPL.OPRA has no underlying identifier"
2015 );
2016 }
2017
2018 fn equity_with_id(instrument_id: &str) -> Equity {
2019 let instrument_id = InstrumentId::from(instrument_id);
2020 Equity::builder()
2021 .instrument_id(instrument_id)
2022 .raw_symbol(instrument_id.symbol)
2023 .currency(Currency::from("USD"))
2024 .price_precision(2)
2025 .price_increment(Price::from("0.01"))
2026 .ts_event(UnixNanos::default())
2027 .ts_init(UnixNanos::default())
2028 .build()
2029 .unwrap()
2030 }
2031
2032 fn calculator_with_option_and_equities(
2033 option: OptionContract,
2034 equity_ids: &[&str],
2035 ) -> GreeksCalculator {
2036 let cache = Rc::new(RefCell::new(Cache::new(None, None)));
2037 cache
2038 .borrow_mut()
2039 .add_instrument(InstrumentAny::OptionContract(option))
2040 .unwrap();
2041
2042 for equity_id in equity_ids {
2043 cache
2044 .borrow_mut()
2045 .add_instrument(InstrumentAny::Equity(equity_with_id(equity_id)))
2046 .unwrap();
2047 }
2048
2049 let clock = Rc::new(RefCell::new(VirtualClock::new()));
2050 GreeksCalculator::new(cache, clock)
2051 }
2052
2053 #[rstest]
2054 #[case::same_venue_preferred(&["AAPL.XNAS", "AAPL.OPRA", "AAPL.ARCX"], "AAPL.OPRA")]
2055 #[case::unique_cross_venue_match(&["AAPL.XNAS", "MSFT.XNAS"], "AAPL.XNAS")]
2056 #[case::no_match_keeps_option_venue(&["MSFT.XNAS"], "AAPL.OPRA")]
2057 fn test_resolve_underlying_instrument_id(#[case] equity_ids: &[&str], #[case] expected: &str) {
2058 let option = option_with_expiration("AAPL250417C00150000.OPRA", UnixNanos::default());
2059 let option_id = option.id();
2060 let instrument = InstrumentAny::OptionContract(option.clone());
2061 let calculator = calculator_with_option_and_equities(option, equity_ids);
2062
2063 let underlying_id = calculator
2064 .resolve_underlying_instrument_id(&instrument, option_id)
2065 .unwrap();
2066
2067 assert_eq!(underlying_id, InstrumentId::from(expected));
2068 }
2069
2070 #[rstest]
2071 fn test_resolve_underlying_instrument_id_errors_when_cross_venue_match_is_ambiguous() {
2072 let option = option_with_expiration("AAPL250417C00150000.OPRA", UnixNanos::default());
2073 let option_id = option.id();
2074 let instrument = InstrumentAny::OptionContract(option.clone());
2075 let calculator = calculator_with_option_and_equities(option, &["AAPL.XNAS", "AAPL.ARCX"]);
2076
2077 let error = calculator
2078 .resolve_underlying_instrument_id(&instrument, option_id)
2079 .unwrap_err();
2080
2081 assert_eq!(
2082 error.to_string(),
2083 "Ambiguous underlying for option AAPL250417C00150000.OPRA: AAPL.ARCX, AAPL.XNAS"
2084 );
2085 }
2086
2087 #[rstest]
2088 fn test_resolve_underlying_instrument_id_reuses_cross_venue_match() {
2089 let option = option_with_expiration("AAPL250417C00150000.OPRA", UnixNanos::default());
2090 let option_id = option.id();
2091 let instrument = InstrumentAny::OptionContract(option.clone());
2092 let calculator = calculator_with_option_and_equities(option, &["AAPL.XNAS"]);
2093 let first = calculator
2094 .resolve_underlying_instrument_id(&instrument, option_id)
2095 .unwrap();
2096
2097 calculator
2098 .cache
2099 .borrow_mut()
2100 .add_instrument(InstrumentAny::Equity(equity_with_id("AAPL.ARCX")))
2101 .unwrap();
2102 let second = calculator
2103 .resolve_underlying_instrument_id(&instrument, option_id)
2104 .unwrap();
2105
2106 assert_eq!(first, InstrumentId::from("AAPL.XNAS"));
2107 assert_eq!(second, InstrumentId::from("AAPL.XNAS"));
2108 }
2109
2110 #[rstest]
2111 fn test_resolve_underlying_instrument_id_rescans_after_match_is_purged() {
2112 let option = option_with_expiration("AAPL250417C00150000.OPRA", UnixNanos::default());
2113 let option_id = option.id();
2114 let instrument = InstrumentAny::OptionContract(option.clone());
2115 let calculator = calculator_with_option_and_equities(option, &["AAPL.XNAS"]);
2116 calculator
2117 .resolve_underlying_instrument_id(&instrument, option_id)
2118 .unwrap();
2119
2120 {
2121 let mut cache = calculator.cache.borrow_mut();
2122 cache
2123 .add_instrument(InstrumentAny::Equity(equity_with_id("AAPL.ARCX")))
2124 .unwrap();
2125 cache.purge_instrument(InstrumentId::from("AAPL.XNAS"));
2126 }
2127
2128 let underlying_id = calculator
2129 .resolve_underlying_instrument_id(&instrument, option_id)
2130 .unwrap();
2131
2132 assert_eq!(underlying_id, InstrumentId::from("AAPL.ARCX"));
2133 }
2134
2135 #[rstest]
2136 #[case::no_underlying_cached(&[])]
2137 #[case::same_venue_cached(&["AAPL.OPRA"])]
2138 fn test_resolve_underlying_instrument_id_forgets_match_once_purged(
2139 #[case] cached_while_purged: &[&str],
2140 ) {
2141 let option = option_with_expiration("AAPL250417C00150000.OPRA", UnixNanos::default());
2142 let option_id = option.id();
2143 let instrument = InstrumentAny::OptionContract(option.clone());
2144 let calculator = calculator_with_option_and_equities(option, &["AAPL.XNAS"]);
2145 calculator
2146 .resolve_underlying_instrument_id(&instrument, option_id)
2147 .unwrap();
2148
2149 {
2150 let mut cache = calculator.cache.borrow_mut();
2151 cache.purge_instrument(InstrumentId::from("AAPL.XNAS"));
2152 for equity_id in cached_while_purged {
2153 cache
2154 .add_instrument(InstrumentAny::Equity(equity_with_id(equity_id)))
2155 .unwrap();
2156 }
2157 }
2158
2159 calculator
2160 .resolve_underlying_instrument_id(&instrument, option_id)
2161 .unwrap();
2162
2163 {
2164 let mut cache = calculator.cache.borrow_mut();
2165 for equity_id in cached_while_purged {
2166 cache.purge_instrument(InstrumentId::from(*equity_id));
2167 }
2168
2169 for equity_id in ["AAPL.XNAS", "AAPL.ARCX"] {
2170 cache
2171 .add_instrument(InstrumentAny::Equity(equity_with_id(equity_id)))
2172 .unwrap();
2173 }
2174 }
2175
2176 let error = calculator
2177 .resolve_underlying_instrument_id(&instrument, option_id)
2178 .unwrap_err();
2179
2180 assert_eq!(
2181 error.to_string(),
2182 "Ambiguous underlying for option AAPL250417C00150000.OPRA: AAPL.ARCX, AAPL.XNAS"
2183 );
2184 }
2185
2186 #[rstest]
2187 fn test_instrument_greeks_reads_cached_greeks_when_underlying_is_ambiguous() {
2188 let option =
2189 option_with_expiration("AAPL250417C00150000.OPRA", UnixNanos::from(1_000_000_000));
2190 let option_id = option.id();
2191 let position = position_from_fill(
2192 &InstrumentAny::OptionContract(option.clone()),
2193 "P-GREEKS-1",
2194 "O-GREEKS-1",
2195 "T-GREEKS-1",
2196 OrderSide::Buy,
2197 1,
2198 "9.00",
2199 );
2200 let calculator = calculator_with_option_and_equities(option, &["AAPL.XNAS", "AAPL.ARCX"]);
2201 let mut cached_greeks = GreeksData::from_delta(option_id, 0.5, 100.0, UnixNanos::default());
2202 cached_greeks.price = 10.5;
2203 cached_greeks.underlying_price = 150.0;
2204 cached_greeks.strike = 150.0;
2205 cached_greeks.vol = 0.2;
2206 cached_greeks.expiry_in_years = 1.0;
2207 calculator
2208 .cache
2209 .borrow_mut()
2210 .add_greeks(cached_greeks.clone())
2211 .unwrap();
2212
2213 let greeks = calculator
2214 .instrument_greeks(
2215 option_id,
2216 None,
2217 None,
2218 None,
2219 None,
2220 None,
2221 Some(true),
2222 None,
2223 None,
2224 None,
2225 None,
2226 Some(position),
2227 None,
2228 None,
2229 None,
2230 None,
2231 None,
2232 None,
2233 )
2234 .unwrap();
2235
2236 assert_eq!(greeks.instrument_id, option_id);
2237 assert_eq!(greeks.greeks, cached_greeks.greeks);
2238 assert_eq!(greeks.pnl, 1.5);
2239 }
2240
2241 #[rstest]
2242 fn test_instrument_greeks_prices_underlying_on_another_venue() {
2243 let now = utc_timestamp(2025, 3, 8, 12, 0, 0);
2244 let expiry = now + jiff::SignedDuration::from_hours(24 * 30);
2245 let now_ns = UnixNanos::from(now);
2246 let expiry_ns = UnixNanos::from(expiry);
2247 let option = option_with_expiration("AAPL250417C00150000.OPRA", expiry_ns);
2248 let option_id = option.id();
2249 let same_venue_cache = setup_cache_with_option_and_quotes(
2250 option.clone(),
2251 InstrumentId::from("AAPL.OPRA"),
2252 now_ns,
2253 );
2254 let cross_venue_cache = Rc::new(RefCell::new(Cache::new(None, None)));
2255 cross_venue_cache
2256 .borrow_mut()
2257 .add_instrument(InstrumentAny::OptionContract(option))
2258 .unwrap();
2259 cross_venue_cache
2260 .borrow_mut()
2261 .add_instrument(InstrumentAny::Equity(equity_with_id("AAPL.XNAS")))
2262 .unwrap();
2263
2264 for (instrument_id, bid, ask) in [
2265 (option_id, "10.50", "10.60"),
2266 (InstrumentId::from("AAPL.XNAS"), "150.00", "150.10"),
2267 ] {
2268 cross_venue_cache
2269 .borrow_mut()
2270 .add_quote(QuoteTick::new(
2271 instrument_id,
2272 Price::from(bid),
2273 Price::from(ask),
2274 Quantity::from(100),
2275 Quantity::from(100),
2276 now_ns,
2277 now_ns,
2278 ))
2279 .unwrap();
2280 }
2281
2282 let greeks_for = |cache: Rc<RefCell<Cache>>| {
2283 let clock = Rc::new(RefCell::new(VirtualClock::new()));
2284 GreeksCalculator::new(cache, clock)
2285 .instrument_greeks(
2286 option_id,
2287 None,
2288 None,
2289 None,
2290 None,
2291 None,
2292 None,
2293 None,
2294 None,
2295 None,
2296 Some(now_ns),
2297 None,
2298 None,
2299 None,
2300 None,
2301 None,
2302 None,
2303 None,
2304 )
2305 .unwrap()
2306 };
2307
2308 let expected = greeks_for(same_venue_cache);
2309 let greeks = greeks_for(cross_venue_cache);
2310
2311 assert_eq!(greeks.underlying_price, expected.underlying_price);
2312 assert_eq!(greeks.vol, expected.vol);
2313 assert_eq!(greeks.price, expected.price);
2314 assert_eq!(greeks.greeks, expected.greeks);
2315 }
2316
2317 fn future_with_expiration(
2318 instrument_id: &str,
2319 underlying: &str,
2320 expiration_ns: UnixNanos,
2321 ) -> FuturesContract {
2322 FuturesContract::builder()
2323 .instrument_id(InstrumentId::from(instrument_id))
2324 .raw_symbol(Symbol::from(underlying))
2325 .asset_class(AssetClass::Index)
2326 .exchange(Ustr::from("XCME"))
2327 .underlying(Ustr::from(underlying))
2328 .activation_ns(UnixNanos::default())
2329 .expiration_ns(expiration_ns)
2330 .currency(Currency::from("USD"))
2331 .price_precision(2)
2332 .price_increment(Price::from("0.25"))
2333 .multiplier(Quantity::from(1))
2334 .lot_size(Quantity::from(1))
2335 .ts_event(UnixNanos::default())
2336 .ts_init(UnixNanos::default())
2337 .build()
2338 .unwrap()
2339 }
2340
2341 fn future_option_with_expiration(
2342 instrument_id: &str,
2343 raw_symbol: &str,
2344 underlying: &str,
2345 option_kind: OptionKind,
2346 strike: &str,
2347 expiration_ns: UnixNanos,
2348 ) -> OptionContract {
2349 OptionContract::builder()
2350 .instrument_id(InstrumentId::from(instrument_id))
2351 .raw_symbol(Symbol::from(raw_symbol))
2352 .asset_class(AssetClass::Index)
2353 .exchange(Ustr::from("XCME"))
2354 .underlying(Ustr::from(underlying))
2355 .option_kind(option_kind)
2356 .strike_price(Price::from(strike))
2357 .currency(Currency::from("USD"))
2358 .activation_ns(UnixNanos::default())
2359 .expiration_ns(expiration_ns)
2360 .price_precision(2)
2361 .price_increment(Price::from("0.01"))
2362 .multiplier(Quantity::from(1))
2363 .lot_size(Quantity::from(1))
2364 .ts_event(UnixNanos::default())
2365 .ts_init(UnixNanos::default())
2366 .build()
2367 .unwrap()
2368 }
2369
2370 fn setup_cache_with_option_and_quotes(
2371 option: OptionContract,
2372 underlying_id: InstrumentId,
2373 now_ns: UnixNanos,
2374 ) -> Rc<RefCell<Cache>> {
2375 let option_id = option.id();
2376 let cache = Rc::new(RefCell::new(Cache::new(None, None)));
2377 cache
2378 .borrow_mut()
2379 .add_instrument(InstrumentAny::OptionContract(option))
2380 .unwrap();
2381 cache
2382 .borrow_mut()
2383 .add_instrument(InstrumentAny::Equity(equity_aapl_opra()))
2384 .unwrap();
2385
2386 let option_quote = QuoteTick::new(
2387 option_id,
2388 Price::from("10.50"),
2389 Price::from("10.60"),
2390 Quantity::from(100),
2391 Quantity::from(100),
2392 now_ns,
2393 now_ns,
2394 );
2395
2396 let underlying_quote = QuoteTick::new(
2397 underlying_id,
2398 Price::from("150.00"),
2399 Price::from("150.10"),
2400 Quantity::from(100),
2401 Quantity::from(100),
2402 now_ns,
2403 now_ns,
2404 );
2405 cache.borrow_mut().add_quote(option_quote).unwrap();
2406 cache.borrow_mut().add_quote(underlying_quote).unwrap();
2407 cache
2408 }
2409
2410 fn position_from_fill(
2411 instrument: &InstrumentAny,
2412 position_id: &str,
2413 client_order_id: &str,
2414 trade_id: &str,
2415 side: OrderSide,
2416 quantity: u64,
2417 price: &str,
2418 ) -> Position {
2419 let fill = OrderFilledSpec::builder()
2420 .instrument_id(instrument.id())
2421 .client_order_id(ClientOrderId::from(client_order_id))
2422 .trade_id(TradeId::from(trade_id))
2423 .order_side(side)
2424 .last_qty(Quantity::from(quantity))
2425 .last_px(Price::from(price))
2426 .currency(Currency::USD())
2427 .position_id(PositionId::from(position_id))
2428 .build();
2429 Position::new(instrument, fill)
2430 }
2431
2432 fn calculate_portfolio_greeks(
2433 calculator: &GreeksCalculator,
2434 side: Option<PositionSide>,
2435 ) -> anyhow::Result<PortfolioGreeks> {
2436 calculator.portfolio_greeks(
2437 None, None, None, None, side, None, None, None, None, None, None, None, None, None,
2438 None, None, None, None, None, None, None,
2439 )
2440 }
2441
2442 fn assert_portfolio_greeks_eq(actual: &PortfolioGreeks, expected: &PortfolioGreeks) {
2443 assert_eq!(actual.ts_init, expected.ts_init);
2444 assert_eq!(actual.ts_event, expected.ts_event);
2445 assert_eq!(actual.pnl, expected.pnl);
2446 assert_eq!(actual.price, expected.price);
2447 assert_eq!(actual.delta, expected.delta);
2448 assert_eq!(actual.gamma, expected.gamma);
2449 assert_eq!(actual.vega, expected.vega);
2450 assert_eq!(actual.theta, expected.theta);
2451 assert_eq!(actual.rho, expected.rho);
2452 }
2453
2454 #[rstest]
2455 #[case::neither(false, false)]
2456 #[case::cache(true, false)]
2457 #[case::publish(false, true)]
2458 #[case::cache_and_publish(true, true)]
2459 fn test_portfolio_greeks_cache_and_publish(
2460 #[case] cache_greeks: bool,
2461 #[case] publish_greeks: bool,
2462 ) {
2463 msgbus::set_message_bus(Rc::new(RefCell::new(msgbus::MessageBus::default())));
2464 let (calculator, position, now_ns) = portfolio_option_calculator();
2465 let option_id = position.instrument_id;
2466 let expected = InstrumentGreeksParams::builder()
2467 .instrument_id(option_id)
2468 .ts_event(now_ns)
2469 .build()
2470 .calculate(&calculator)
2471 .unwrap();
2472 let published = Rc::new(RefCell::new(Vec::new()));
2473 let captured = published.clone();
2474 calculator.subscribe_greeks("AAPL", None::<fn(&GreeksData)>);
2475 calculator.subscribe_greeks(
2476 "AAPL",
2477 Some(move |data: &GreeksData| {
2478 captured.borrow_mut().push(data.clone());
2479 }),
2480 );
2481
2482 let result = PortfolioGreeksParams::builder()
2483 .cache_greeks(cache_greeks)
2484 .publish_greeks(publish_greeks)
2485 .build()
2486 .calculate(&calculator)
2487 .unwrap();
2488 let cached = calculator.cache.borrow().greeks(&option_id);
2489 msgbus::set_message_bus(Rc::new(RefCell::new(msgbus::MessageBus::default())));
2490
2491 let mut expected_position = expected.clone();
2492 expected_position.pnl = expected_position.price - position.avg_px_open.as_f64();
2493 assert_portfolio_greeks_eq(
2494 &result,
2495 &(position.signed_qty.as_f64() * &expected_position.to_portfolio_greeks()),
2496 );
2497 assert_eq!(cached.is_some(), cache_greeks || publish_greeks);
2498 assert_eq!(published.borrow().len(), usize::from(publish_greeks));
2499
2500 for actual in cached.iter().chain(published.borrow().iter()) {
2501 assert_greeks_eq(actual, &expected);
2502 }
2503 }
2504
2505 #[rstest]
2506 #[case::underlying("AAPL", 1)]
2507 #[case::underlying_prefix("AAP", 0)]
2508 #[case::all_underlyings("*", 1)]
2509 fn test_subscribe_greeks_matches_underlying_symbol_exactly(
2510 #[case] underlying: &str,
2511 #[case] expected_count: usize,
2512 ) {
2513 msgbus::set_message_bus(Rc::new(RefCell::new(msgbus::MessageBus::default())));
2514 let (calculator, option_id, now_ns) = option_calculator();
2515 let received = Rc::new(RefCell::new(Vec::new()));
2516 let captured = received.clone();
2517 calculator.subscribe_greeks(
2518 underlying,
2519 Some(move |data: &GreeksData| captured.borrow_mut().push(data.instrument_id)),
2520 );
2521
2522 InstrumentGreeksParams::builder()
2523 .instrument_id(option_id)
2524 .publish_greeks(true)
2525 .ts_event(now_ns)
2526 .build()
2527 .calculate(&calculator)
2528 .unwrap();
2529 msgbus::set_message_bus(Rc::new(RefCell::new(msgbus::MessageBus::default())));
2530
2531 assert_eq!(*received.borrow(), vec![option_id; expected_count]);
2532 }
2533
2534 #[rstest]
2535 fn test_publish_greeks_topic_includes_underlying_and_instrument_id() {
2536 msgbus::set_message_bus(Rc::new(RefCell::new(msgbus::MessageBus::default())));
2537 let (calculator, option_id, now_ns) = option_calculator();
2538 let received = Rc::new(RefCell::new(Vec::new()));
2539 let captured = received.clone();
2540 let topic = "data.GreeksData.underlying=AAPL.instrument_id=AAPL250417C00150000.OPRA";
2541
2542 let handler = TypedHandler::from(move |data: &GreeksData| {
2543 captured.borrow_mut().push(data.instrument_id);
2544 });
2545
2546 msgbus::subscribe_greeks(topic.into(), handler, None);
2547
2548 InstrumentGreeksParams::builder()
2549 .instrument_id(option_id)
2550 .publish_greeks(true)
2551 .ts_event(now_ns)
2552 .build()
2553 .calculate(&calculator)
2554 .unwrap();
2555 msgbus::set_message_bus(Rc::new(RefCell::new(msgbus::MessageBus::default())));
2556
2557 assert_eq!(*received.borrow(), vec![option_id]);
2558 }
2559
2560 #[rstest]
2561 fn test_portfolio_greeks_filter_reenters_calculator_and_caches() {
2562 let (calculator, position, now_ns) = portfolio_option_calculator();
2563 let option_id = position.instrument_id;
2564 let expected = InstrumentGreeksParams::builder()
2565 .instrument_id(option_id)
2566 .ts_event(now_ns)
2567 .build()
2568 .calculate(&calculator)
2569 .unwrap();
2570 let calculator = Rc::new(calculator);
2571 let captured = calculator.clone();
2572 let observed = Rc::new(RefCell::new(Vec::new()));
2573 let captured_observed = observed.clone();
2574
2575 let filter = GreeksFilterCallback::from_closure(move |data| {
2576 InstrumentGreeksParams::builder()
2577 .instrument_id(data.instrument_id)
2578 .ts_event(now_ns)
2579 .cache_greeks(true)
2580 .build()
2581 .calculate(&captured)
2582 .unwrap();
2583 captured_observed.borrow_mut().push(data.clone());
2584 true
2585 });
2586
2587 let result = PortfolioGreeksParams::builder()
2588 .greeks_filter(filter)
2589 .build()
2590 .calculate(&calculator)
2591 .unwrap();
2592
2593 let mut expected_position = expected.clone();
2594 expected_position.pnl = expected_position.price - position.avg_px_open.as_f64();
2595 let expected_portfolio =
2596 position.signed_qty.as_f64() * &expected_position.to_portfolio_greeks();
2597 assert_portfolio_greeks_eq(&result, &expected_portfolio);
2598 assert_greeks_eq(
2599 &calculator.cache.borrow().greeks(&option_id).unwrap(),
2600 &expected,
2601 );
2602 expected_position.pnl = expected_portfolio.pnl;
2603 expected_position.price = expected_portfolio.price;
2604 expected_position.greeks = expected_portfolio.greeks;
2605 let observed = observed.borrow();
2606 assert_eq!(observed.len(), 1);
2607 assert_greeks_eq(&observed[0], &expected_position);
2608 }
2609
2610 fn portfolio_option_calculator() -> (GreeksCalculator, Position, UnixNanos) {
2611 let (calculator, option_id, now_ns) = option_calculator();
2612 let clock = Rc::new(RefCell::new(VirtualClock::new()));
2613 clock.borrow_mut().set_time(now_ns);
2614 let calculator = GreeksCalculator::new(calculator.cache, clock);
2615 let instrument = calculator
2616 .cache
2617 .borrow()
2618 .instrument(&option_id)
2619 .cloned()
2620 .unwrap();
2621 let position = position_from_fill(
2622 &instrument,
2623 "P-OPTION",
2624 "O-OPTION",
2625 "T-OPTION",
2626 OrderSide::Buy,
2627 3,
2628 "9.00",
2629 );
2630 calculator
2631 .cache
2632 .borrow_mut()
2633 .add_position(&position, OmsType::Hedging)
2634 .unwrap();
2635
2636 (calculator, position, now_ns)
2637 }
2638
2639 fn assert_greeks_eq(actual: &GreeksData, expected: &GreeksData) {
2640 assert_eq!(actual.ts_init, expected.ts_init);
2641 assert_eq!(actual.ts_event, expected.ts_event);
2642 assert_eq!(actual.instrument_id, expected.instrument_id);
2643 assert_eq!(actual.is_call, expected.is_call);
2644 assert_eq!(actual.strike, expected.strike);
2645 assert_eq!(actual.expiry, expected.expiry);
2646 assert_eq!(actual.expiry_in_days, expected.expiry_in_days);
2647 assert_eq!(actual.expiry_in_years, expected.expiry_in_years);
2648 assert_eq!(actual.multiplier, expected.multiplier);
2649 assert_eq!(actual.quantity, expected.quantity);
2650 assert_eq!(actual.underlying_price, expected.underlying_price);
2651 assert_eq!(actual.interest_rate, expected.interest_rate);
2652 assert_eq!(actual.cost_of_carry, expected.cost_of_carry);
2653 assert_eq!(actual.vol, expected.vol);
2654 assert_eq!(actual.pnl, expected.pnl);
2655 assert_eq!(actual.price, expected.price);
2656 assert_eq!(actual.greeks, expected.greeks);
2657 assert_eq!(actual.itm_prob, expected.itm_prob);
2658 }
2659
2660 #[rstest]
2661 #[case(OrderSide::Buy, 3, [300.0, 300.0, 150.0])]
2662 #[case(OrderSide::Sell, 2, [-200.0, -200.0, -100.0])]
2663 fn test_portfolio_greeks_scales_futures_multiplier(
2664 #[case] side: OrderSide,
2665 #[case] quantity: u64,
2666 #[case] expected: [f64; 3],
2667 ) {
2668 let now_ns = UnixNanos::from(1_700_000_000_000_000_000);
2669 let mut future =
2670 future_with_expiration("ES.GLBX", "ES", UnixNanos::from(1_800_000_000_000_000_000));
2671 future.multiplier = Quantity::from(50);
2672 let instrument = InstrumentAny::FuturesContract(future);
2673 let instrument_id = instrument.id();
2674 let position = position_from_fill(
2675 &instrument,
2676 "P-FUTURE",
2677 "O-FUTURE",
2678 "T-FUTURE",
2679 side,
2680 quantity,
2681 "100.00",
2682 );
2683 let cache = Rc::new(RefCell::new(Cache::default()));
2684 cache.borrow_mut().add_instrument(instrument).unwrap();
2685 cache
2686 .borrow_mut()
2687 .add_position(&position, OmsType::Hedging)
2688 .unwrap();
2689 cache
2690 .borrow_mut()
2691 .add_quote(QuoteTick::new(
2692 instrument_id,
2693 Price::from("101.75"),
2694 Price::from("102.25"),
2695 Quantity::from(100),
2696 Quantity::from(100),
2697 now_ns,
2698 now_ns,
2699 ))
2700 .unwrap();
2701 let clock = Rc::new(RefCell::new(VirtualClock::new()));
2702 clock.borrow_mut().set_time(now_ns);
2703 let calculator = GreeksCalculator::new(cache, clock);
2704
2705 let result = calculate_portfolio_greeks(&calculator, None).unwrap();
2706
2707 assert_portfolio_greeks_eq(
2708 &result,
2709 &PortfolioGreeks::new(
2710 expected[0],
2711 expected[1],
2712 expected[2],
2713 0.0,
2714 0.0,
2715 0.0,
2716 now_ns,
2717 now_ns,
2718 ),
2719 );
2720 }
2721
2722 #[rstest]
2723 fn test_portfolio_greeks_ignores_closed_position_with_missing_price() {
2724 let now = utc_timestamp(2025, 3, 8, 12, 0, 0);
2725 let expiry = now + jiff::SignedDuration::from_hours(24 * 30);
2726 let now_ns = UnixNanos::from(now);
2727 let expiry_ns = UnixNanos::from(expiry);
2728 let open_option = option_with_expiration("AAPL250417C00150000.OPRA", expiry_ns);
2729 let open_option_id = open_option.id();
2730 let underlying_id = InstrumentId::from("AAPL.OPRA");
2731 let cache = setup_cache_with_option_and_quotes(open_option.clone(), underlying_id, now_ns);
2732 let closed_future = future_with_expiration("CLOSED.GLBX", "CLOSED", expiry_ns);
2733 let closed_future_id = closed_future.id();
2734 let open_instrument = InstrumentAny::OptionContract(open_option);
2735 let closed_instrument = InstrumentAny::FuturesContract(closed_future);
2736
2737 let open_position = position_from_fill(
2738 &open_instrument,
2739 "P-OPEN",
2740 "O-OPEN",
2741 "T-OPEN",
2742 OrderSide::Buy,
2743 2,
2744 "10.50",
2745 );
2746 let mut closed_position = position_from_fill(
2747 &closed_instrument,
2748 "P-CLOSED",
2749 "O-CLOSED-OPEN",
2750 "T-CLOSED-OPEN",
2751 OrderSide::Buy,
2752 1,
2753 "100.00",
2754 );
2755 cache
2756 .borrow_mut()
2757 .add_instrument(closed_instrument)
2758 .unwrap();
2759 cache
2760 .borrow_mut()
2761 .add_position(&open_position, OmsType::Hedging)
2762 .unwrap();
2763 cache
2764 .borrow_mut()
2765 .add_position(&closed_position, OmsType::Hedging)
2766 .unwrap();
2767 let closing_fill = OrderFilledSpec::builder()
2768 .instrument_id(closed_future_id)
2769 .client_order_id(ClientOrderId::from("O-CLOSED-CLOSE"))
2770 .trade_id(TradeId::from("T-CLOSED-CLOSE"))
2771 .order_side(OrderSide::Sell)
2772 .last_qty(Quantity::from(1))
2773 .last_px(Price::from("101.00"))
2774 .currency(Currency::USD())
2775 .position_id(PositionId::from("P-CLOSED"))
2776 .build();
2777 closed_position.apply(&closing_fill);
2778 cache
2779 .borrow_mut()
2780 .update_position(&closed_position)
2781 .unwrap();
2782
2783 assert!(closed_position.is_closed());
2787 assert_eq!(
2788 cache
2789 .borrow()
2790 .positions_open(None, None, None, None, None)
2791 .len(),
2792 1
2793 );
2794
2795 let clock = Rc::new(RefCell::new(VirtualClock::new()));
2796 clock.borrow_mut().set_time(now_ns);
2797 let calculator = GreeksCalculator::new(cache, clock);
2798 let expected = calculator
2799 .instrument_greeks(
2800 open_option_id,
2801 None,
2802 None,
2803 None,
2804 None,
2805 None,
2806 None,
2807 None,
2808 None,
2809 None,
2810 Some(now_ns),
2811 Some(open_position.clone()),
2812 None,
2813 None,
2814 None,
2815 None,
2816 None,
2817 None,
2818 )
2819 .unwrap();
2820 let expected = open_position.signed_qty.as_f64() * &expected.to_portfolio_greeks();
2821
2822 assert_ne!(expected.delta, 0.0);
2823 assert_portfolio_greeks_eq(
2824 &calculate_portfolio_greeks(&calculator, None).unwrap(),
2825 &expected,
2826 );
2827 assert_portfolio_greeks_eq(
2828 &calculate_portfolio_greeks(&calculator, Some(PositionSide::Flat)).unwrap(),
2829 &PortfolioGreeks::new(0.0, 0.0, 0.0, 0.0, 0.0, 0.0, now_ns, now_ns),
2830 );
2831 }
2832
2833 #[rstest]
2834 fn test_portfolio_greeks_preserves_open_position_aggregate_and_side_filters() {
2835 let now = utc_timestamp(2025, 3, 8, 12, 0, 0);
2836 let expiry = now + jiff::SignedDuration::from_hours(24 * 30);
2837 let now_ns = UnixNanos::from(now);
2838 let expiry_ns = UnixNanos::from(expiry);
2839 let long_option = option_with_expiration("AAPL250417C00145000.OPRA", expiry_ns);
2840 let short_option = option_with_expiration("AAPL250417C00155000.OPRA", expiry_ns);
2841 let long_instrument = InstrumentAny::OptionContract(long_option.clone());
2842 let short_instrument = InstrumentAny::OptionContract(short_option.clone());
2843 let underlying_id = InstrumentId::from("AAPL.OPRA");
2844 let cache = setup_cache_with_option_and_quotes(long_option, underlying_id, now_ns);
2845 cache
2846 .borrow_mut()
2847 .add_instrument(short_instrument.clone())
2848 .unwrap();
2849 cache
2850 .borrow_mut()
2851 .add_quote(QuoteTick::new(
2852 short_option.id(),
2853 Price::from("3.50"),
2854 Price::from("3.60"),
2855 Quantity::from(100),
2856 Quantity::from(100),
2857 now_ns,
2858 now_ns,
2859 ))
2860 .unwrap();
2861 let long_position = position_from_fill(
2862 &long_instrument,
2863 "P-LONG",
2864 "O-LONG",
2865 "T-LONG",
2866 OrderSide::Buy,
2867 3,
2868 "10.50",
2869 );
2870 let short_position = position_from_fill(
2871 &short_instrument,
2872 "P-SHORT",
2873 "O-SHORT",
2874 "T-SHORT",
2875 OrderSide::Sell,
2876 2,
2877 "3.50",
2878 );
2879 cache
2880 .borrow_mut()
2881 .add_position(&long_position, OmsType::Hedging)
2882 .unwrap();
2883 cache
2884 .borrow_mut()
2885 .add_position(&short_position, OmsType::Hedging)
2886 .unwrap();
2887
2888 let clock = Rc::new(RefCell::new(VirtualClock::new()));
2889 clock.borrow_mut().set_time(now_ns);
2890 let calculator = GreeksCalculator::new(cache, clock);
2891 let long_greeks = calculator
2892 .instrument_greeks(
2893 long_instrument.id(),
2894 None,
2895 None,
2896 None,
2897 None,
2898 None,
2899 None,
2900 None,
2901 None,
2902 None,
2903 Some(now_ns),
2904 Some(long_position.clone()),
2905 None,
2906 None,
2907 None,
2908 None,
2909 None,
2910 None,
2911 )
2912 .unwrap();
2913 let short_greeks = calculator
2914 .instrument_greeks(
2915 short_instrument.id(),
2916 None,
2917 None,
2918 None,
2919 None,
2920 None,
2921 None,
2922 None,
2923 None,
2924 None,
2925 Some(now_ns),
2926 Some(short_position.clone()),
2927 None,
2928 None,
2929 None,
2930 None,
2931 None,
2932 None,
2933 )
2934 .unwrap();
2935 let expected_long = long_position.signed_qty.as_f64() * &long_greeks.to_portfolio_greeks();
2936 let expected_short =
2937 short_position.signed_qty.as_f64() * &short_greeks.to_portfolio_greeks();
2938 let expected = expected_long + expected_short;
2939
2940 assert_ne!(expected.pnl, 0.0);
2941 assert_ne!(expected.price, 0.0);
2942 assert_ne!(expected.delta, 0.0);
2943 assert_ne!(expected.gamma, 0.0);
2944 assert_ne!(expected.vega, 0.0);
2945 assert_ne!(expected.theta, 0.0);
2946 assert_portfolio_greeks_eq(
2947 &calculate_portfolio_greeks(&calculator, None).unwrap(),
2948 &expected,
2949 );
2950 assert_portfolio_greeks_eq(
2951 &calculate_portfolio_greeks(&calculator, Some(PositionSide::Long)).unwrap(),
2952 &(long_position.signed_qty.as_f64() * &long_greeks.to_portfolio_greeks()),
2953 );
2954 assert_portfolio_greeks_eq(
2955 &calculate_portfolio_greeks(&calculator, Some(PositionSide::Short)).unwrap(),
2956 &(short_position.signed_qty.as_f64() * &short_greeks.to_portfolio_greeks()),
2957 );
2958 }
2959
2960 #[rstest]
2961 #[case(true, [-200.0, -1700.0, -1600.0, -1500.0, -1400.0, 1300.0, -1200.0])]
2962 #[case(false, [600.0, 900.0, 1200.0, 1500.0, 1800.0, -2100.0, 2400.0])]
2963 fn test_portfolio_greeks_scales_before_filter(
2964 #[case] include_short: bool,
2965 #[case] expected: [f64; 7],
2966 ) {
2967 let now_ns = UnixNanos::from(1_700_000_000_000_000_000);
2968 let expiry_ns = UnixNanos::from(1_800_000_000_000_000_000);
2969 let long_option = option_with_expiration("AAPL250417C00145000.OPRA", expiry_ns);
2970 let short_option = option_with_expiration("AAPL250417C00155000.OPRA", expiry_ns);
2971 let long_id = long_option.id();
2972 let short_id = short_option.id();
2973 let cache = Rc::new(RefCell::new(Cache::default()));
2974 let observed = Rc::new(RefCell::new(Vec::new()));
2975
2976 for (option, side, quantity, entry, price, greeks) in [
2977 (
2978 long_option,
2979 OrderSide::Buy,
2980 3,
2981 "1.00",
2982 3.0,
2983 OptionGreekValues {
2984 delta: 4.0,
2985 gamma: 5.0,
2986 vega: 6.0,
2987 theta: -7.0,
2988 rho: 8.0,
2989 },
2990 ),
2991 (
2992 short_option,
2993 OrderSide::Sell,
2994 2,
2995 "9.00",
2996 13.0,
2997 OptionGreekValues {
2998 delta: 14.0,
2999 gamma: 15.0,
3000 vega: 16.0,
3001 theta: -17.0,
3002 rho: 18.0,
3003 },
3004 ),
3005 ] {
3006 let instrument = InstrumentAny::OptionContract(option);
3007 let position = position_from_fill(
3008 &instrument,
3009 instrument.id().symbol.as_str(),
3010 instrument.id().symbol.as_str(),
3011 instrument.id().symbol.as_str(),
3012 side,
3013 quantity,
3014 entry,
3015 );
3016
3017 let data = GreeksData {
3018 instrument_id: instrument.id(),
3019 is_call: false,
3020 strike: 19.0,
3021 expiry: 20_271_015,
3022 expiry_in_days: 23,
3023 expiry_in_years: 0.25,
3024 multiplier: 100.0,
3025 quantity: 1.0,
3026 underlying_price: 29.0,
3027 interest_rate: 0.05,
3028 cost_of_carry: 0.02,
3029 vol: 0.5,
3030 pnl: 31.0,
3031 price,
3032 greeks,
3033 itm_prob: 0.75,
3034 ts_event: UnixNanos::from(13),
3035 ts_init: UnixNanos::from(11),
3036 };
3037
3038 let mut cache = cache.borrow_mut();
3039 cache.add_instrument(instrument).unwrap();
3040 cache.add_position(&position, OmsType::Hedging).unwrap();
3041 cache.add_greeks(data).unwrap();
3042 }
3043
3044 let clock = Rc::new(RefCell::new(VirtualClock::new()));
3045 clock.borrow_mut().set_time(now_ns);
3046 let calculator = GreeksCalculator::new(cache.clone(), clock);
3047 let captured = observed.clone();
3048 let filter: GreeksFilter = Box::new(move |data| {
3049 captured.borrow_mut().push(data.clone());
3050 include_short || data.delta > 0.0
3051 });
3052
3053 let result = calculator
3054 .portfolio_greeks(
3055 None,
3056 None,
3057 None,
3058 None,
3059 None,
3060 None,
3061 None,
3062 None,
3063 None,
3064 None,
3065 Some(true),
3066 None,
3067 None,
3068 None,
3069 None,
3070 None,
3071 None,
3072 Some(&filter),
3073 None,
3074 None,
3075 None,
3076 )
3077 .unwrap();
3078
3079 assert_eq!(result.ts_init, now_ns);
3080 assert_eq!(result.ts_event, now_ns);
3081 assert_eq!(
3082 [
3083 result.pnl,
3084 result.price,
3085 result.delta,
3086 result.gamma,
3087 result.vega,
3088 result.theta,
3089 result.rho
3090 ],
3091 expected
3092 );
3093 let mut observed = observed.borrow_mut();
3094 observed.sort_by_key(|data| data.instrument_id);
3095 assert_eq!(observed.len(), 2);
3096
3097 for (data, id, values) in [
3098 (
3099 &observed[0],
3100 long_id,
3101 [600.0, 900.0, 1200.0, 1500.0, 1800.0, -2100.0, 2400.0],
3102 ),
3103 (
3104 &observed[1],
3105 short_id,
3106 [-800.0, -2600.0, -2800.0, -3000.0, -3200.0, 3400.0, -3600.0],
3107 ),
3108 ] {
3109 assert_eq!(
3110 [
3111 data.pnl, data.price, data.delta, data.gamma, data.vega, data.theta, data.rho
3112 ],
3113 values
3114 );
3115 assert_eq!(data.ts_init, UnixNanos::from(11));
3116 assert_eq!(data.ts_event, UnixNanos::from(13));
3117 assert_eq!(data.instrument_id, id);
3118 assert!(!data.is_call);
3119 assert_eq!(data.strike, 19.0);
3120 assert_eq!(data.expiry, 20_271_015);
3121 assert_eq!(data.expiry_in_days, 23);
3122 assert_eq!(data.expiry_in_years, 0.25);
3123 assert_eq!(data.multiplier, 100.0);
3124 assert_eq!(data.quantity, 1.0);
3125 assert_eq!(data.underlying_price, 29.0);
3126 assert_eq!(data.interest_rate, 0.05);
3127 assert_eq!(data.cost_of_carry, 0.02);
3128 assert_eq!(data.vol, 0.5);
3129 assert_eq!(data.itm_prob, 0.75);
3130 }
3131
3132 for (id, price, greeks) in [
3133 (
3134 long_id,
3135 3.0,
3136 OptionGreekValues {
3137 delta: 4.0,
3138 gamma: 5.0,
3139 vega: 6.0,
3140 theta: -7.0,
3141 rho: 8.0,
3142 },
3143 ),
3144 (
3145 short_id,
3146 13.0,
3147 OptionGreekValues {
3148 delta: 14.0,
3149 gamma: 15.0,
3150 vega: 16.0,
3151 theta: -17.0,
3152 rho: 18.0,
3153 },
3154 ),
3155 ] {
3156 let cached = cache.borrow().greeks(&id).unwrap();
3157 assert_eq!(cached.pnl, 31.0);
3158 assert_eq!(cached.price, price);
3159 assert_eq!(cached.greeks, greeks);
3160 }
3161 }
3162
3163 #[rstest]
3164 fn test_expiry_in_days_multi_day_unchanged() {
3165 let now = utc_timestamp(2025, 3, 8, 12, 0, 0);
3166 let expiry = now + jiff::SignedDuration::from_hours(24 * (30));
3167 let now_ns = UnixNanos::from(now);
3168 let expiry_ns = UnixNanos::from(expiry);
3169 let option = option_with_expiration("AAPL250417C00150000.OPRA", expiry_ns);
3170 let option_id = option.id();
3171 let underlying_id = InstrumentId::from("AAPL.OPRA");
3172 let cache = setup_cache_with_option_and_quotes(option, underlying_id, now_ns);
3173 let clock = Rc::new(RefCell::new(VirtualClock::new()));
3174 let calculator = GreeksCalculator::new(cache, clock);
3175
3176 let greeks = calculator
3177 .instrument_greeks(
3178 option_id,
3179 None,
3180 None,
3181 None,
3182 None,
3183 None,
3184 None,
3185 None,
3186 None,
3187 None,
3188 Some(now_ns),
3189 None,
3190 None,
3191 None,
3192 None,
3193 None,
3194 None,
3195 None,
3196 )
3197 .unwrap();
3198
3199 assert_eq!(greeks.expiry_in_days, 30);
3200 assert!((greeks.expiry_in_years - 30.0 / 365.25).abs() < 1e-9);
3201 }
3202
3203 #[rstest]
3204 fn test_expiry_in_days_same_day_clamped_to_one() {
3205 let now = utc_timestamp(2025, 3, 8, 12, 0, 0);
3206 let expiry_same_day = utc_timestamp(2025, 3, 8, 18, 0, 0);
3207 let now_ns = UnixNanos::from(now);
3208 let expiry_ns = UnixNanos::from(expiry_same_day);
3209 let option = option_with_expiration("AAPL250308C00150000.OPRA", expiry_ns);
3210 let option_id = option.id();
3211 let underlying_id = InstrumentId::from("AAPL.OPRA");
3212 let cache = setup_cache_with_option_and_quotes(option, underlying_id, now_ns);
3213 let clock = Rc::new(RefCell::new(VirtualClock::new()));
3214 let calculator = GreeksCalculator::new(cache, clock);
3215
3216 let greeks = calculator
3217 .instrument_greeks(
3218 option_id,
3219 None,
3220 None,
3221 None,
3222 None,
3223 None,
3224 None,
3225 None,
3226 None,
3227 None,
3228 Some(now_ns),
3229 None,
3230 None,
3231 None,
3232 None,
3233 None,
3234 None,
3235 None,
3236 )
3237 .unwrap();
3238
3239 assert_eq!(greeks.expiry_in_days, 1);
3240 assert!((greeks.expiry_in_years - 1.0 / 365.25).abs() < 1e-9);
3241 }
3242
3243 #[rstest]
3244 fn test_instrument_greeks_beta_weights_vega_to_vol_index() {
3245 let now = utc_timestamp(2025, 3, 8, 12, 0, 0);
3246 let expiry = now + jiff::SignedDuration::from_hours(24 * (30));
3247 let now_ns = UnixNanos::from(now);
3248 let expiry_ns = UnixNanos::from(expiry);
3249 let option = option_with_expiration("AAPL250417C00150000.OPRA", expiry_ns);
3250 let option_id = option.id();
3251 let underlying_id = InstrumentId::from("AAPL.OPRA");
3252 let vol_index_id = InstrumentId::from("VIX.XCBF");
3253 let cache = setup_cache_with_option_and_quotes(option, underlying_id, now_ns);
3254 cache
3255 .borrow_mut()
3256 .add_quote(QuoteTick::new(
3257 vol_index_id,
3258 Price::from("25.00"),
3259 Price::from("25.00"),
3260 Quantity::from(100),
3261 Quantity::from(100),
3262 now_ns,
3263 now_ns,
3264 ))
3265 .unwrap();
3266
3267 let clock = Rc::new(RefCell::new(VirtualClock::new()));
3268 let calculator = GreeksCalculator::new(cache, clock);
3269 let greeks = calculator
3270 .instrument_greeks(
3271 option_id,
3272 None,
3273 None,
3274 None,
3275 None,
3276 None,
3277 None,
3278 None,
3279 None,
3280 None,
3281 Some(now_ns),
3282 None,
3283 None,
3284 None,
3285 None,
3286 None,
3287 None,
3288 None,
3289 )
3290 .unwrap();
3291
3292 let mut vol_beta_weights = HashMap::new();
3293 vol_beta_weights.insert(underlying_id, 0.75);
3294 let vol_weighted_greeks = calculator
3295 .instrument_greeks(
3296 option_id,
3297 None,
3298 None,
3299 None,
3300 None,
3301 None,
3302 None,
3303 None,
3304 None,
3305 None,
3306 Some(now_ns),
3307 None,
3308 None,
3309 None,
3310 None,
3311 None,
3312 Some(vol_index_id),
3313 Some(&vol_beta_weights),
3314 )
3315 .unwrap();
3316
3317 let expected_vega = greeks.vega * 0.75 * (greeks.vol * 100.0) / 25.0;
3318 assert_eq!(
3319 (vol_weighted_greeks.delta * 1e12).round(),
3320 (greeks.delta * 1e12).round()
3321 );
3322 assert_eq!(
3323 (vol_weighted_greeks.gamma * 1e12).round(),
3324 (greeks.gamma * 1e12).round()
3325 );
3326 assert_eq!(
3327 (vol_weighted_greeks.vega * 1e12).round(),
3328 (expected_vega * 1e12).round()
3329 );
3330 }
3331
3332 #[rstest]
3333 fn test_instrument_greeks_errors_when_vol_index_price_missing() {
3334 let now = utc_timestamp(2025, 3, 8, 12, 0, 0);
3335 let expiry = now + jiff::SignedDuration::from_hours(24 * (30));
3336 let now_ns = UnixNanos::from(now);
3337 let expiry_ns = UnixNanos::from(expiry);
3338 let option = option_with_expiration("AAPL250417C00150000.OPRA", expiry_ns);
3339 let option_id = option.id();
3340 let underlying_id = InstrumentId::from("AAPL.OPRA");
3341 let vol_index_id = InstrumentId::from("VIX.XCBF");
3342 let cache = setup_cache_with_option_and_quotes(option, underlying_id, now_ns);
3343
3344 let clock = Rc::new(RefCell::new(VirtualClock::new()));
3345 let calculator = GreeksCalculator::new(cache, clock);
3346 let error = calculator
3347 .instrument_greeks(
3348 option_id,
3349 None,
3350 None,
3351 None,
3352 None,
3353 None,
3354 None,
3355 None,
3356 None,
3357 None,
3358 Some(now_ns),
3359 None,
3360 None,
3361 None,
3362 None,
3363 None,
3364 Some(vol_index_id),
3365 None,
3366 )
3367 .unwrap_err();
3368
3369 assert_eq!(error.to_string(), "No price available for VIX.XCBF");
3370 }
3371
3372 #[rstest]
3373 fn test_modify_greeks_errors_when_vol_index_price_missing() {
3374 let calculator = create_test_calculator();
3375 let underlying_id = InstrumentId::from("AAPL.OPRA");
3376 let vol_index_id = InstrumentId::from("VIX.XCBF");
3377
3378 let error = calculator
3379 .modify_greeks(
3380 1.0,
3381 2.0,
3382 underlying_id,
3383 150.0,
3384 150.0,
3385 false,
3386 None,
3387 None,
3388 2.0,
3389 0.30,
3390 0,
3391 None,
3392 0.0,
3393 Some(vol_index_id),
3394 None,
3395 None,
3396 None,
3397 )
3398 .unwrap_err();
3399
3400 assert_eq!(error.to_string(), "No price available for VIX.XCBF");
3401 }
3402
3403 #[rstest]
3404 fn test_modify_greeks_accepts_explicit_index_prices() {
3405 let calculator = create_test_calculator();
3406 let underlying_id = InstrumentId::from("AAPL.OPRA");
3407 let mut beta_weights = HashMap::new();
3408 beta_weights.insert(underlying_id, 0.5);
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 Some(&beta_weights),
3422 2.0,
3423 0.30,
3424 0,
3425 None,
3426 0.0,
3427 None,
3428 Some(&vol_beta_weights),
3429 Some(200.0),
3430 Some(25.0),
3431 )
3432 .unwrap();
3433
3434 assert_eq!((delta * 1e12).round(), 375_000_000_000.0);
3435 assert_eq!((gamma * 1e12).round(), 281_250_000_000.0);
3436 assert_eq!((vega * 1e12).round(), 1_800_000_000_000.0);
3437
3438 let (delta, gamma, vega) = calculator
3439 .modify_greeks(
3440 1.0,
3441 2.0,
3442 underlying_id,
3443 150.0,
3444 150.0,
3445 true,
3446 None,
3447 Some(&beta_weights),
3448 2.0,
3449 0.30,
3450 0,
3451 None,
3452 0.0,
3453 None,
3454 Some(&vol_beta_weights),
3455 Some(200.0),
3456 Some(25.0),
3457 )
3458 .unwrap();
3459
3460 assert_eq!((delta * 1e12).round(), 750_000_000_000.0);
3461 assert_eq!((gamma * 1e12).round(), 1_125_000_000_000.0);
3462 assert_eq!((vega * 1e12).round(), 4_500_000_000.0);
3463 }
3464
3465 #[rstest]
3466 fn test_instrument_greeks_errors_when_future_underlying_price_missing_without_cached_spread() {
3467 let now = utc_timestamp(2024, 2, 14, 16, 0, 0);
3468 let expiry = utc_timestamp(2024, 3, 15, 16, 0, 0);
3469 let now_ns = UnixNanos::from(now);
3470 let expiry_ns = UnixNanos::from(expiry);
3471
3472 let future = future_with_expiration("ESH4.GLBX", "ESH4", expiry_ns);
3473 let call_option = future_option_with_expiration(
3474 "ESH4C150.GLBX",
3475 "ESH4C150",
3476 "ESH4",
3477 OptionKind::Call,
3478 "150.00",
3479 expiry_ns,
3480 );
3481 let put_option = future_option_with_expiration(
3482 "ESH4P150.GLBX",
3483 "ESH4P150",
3484 "ESH4",
3485 OptionKind::Put,
3486 "150.00",
3487 expiry_ns,
3488 );
3489
3490 let cache = Rc::new(RefCell::new(Cache::new(None, None)));
3491 cache
3492 .borrow_mut()
3493 .add_instrument(InstrumentAny::FuturesContract(future))
3494 .unwrap();
3495 cache
3496 .borrow_mut()
3497 .add_instrument(InstrumentAny::OptionContract(call_option.clone()))
3498 .unwrap();
3499 cache
3500 .borrow_mut()
3501 .add_instrument(InstrumentAny::OptionContract(put_option.clone()))
3502 .unwrap();
3503
3504 let call_quote = QuoteTick::new(
3505 call_option.id(),
3506 Price::from("8.50"),
3507 Price::from("8.50"),
3508 Quantity::from(100),
3509 Quantity::from(100),
3510 now_ns,
3511 now_ns,
3512 );
3513
3514 let put_quote = QuoteTick::new(
3515 put_option.id(),
3516 Price::from("3.33"),
3517 Price::from("3.33"),
3518 Quantity::from(100),
3519 Quantity::from(100),
3520 now_ns,
3521 now_ns,
3522 );
3523 cache.borrow_mut().add_quote(call_quote).unwrap();
3524 cache.borrow_mut().add_quote(put_quote).unwrap();
3525
3526 let clock = Rc::new(RefCell::new(VirtualClock::new()));
3527 clock.borrow_mut().set_time(now_ns);
3528 let calculator = GreeksCalculator::new(cache, clock);
3529
3530 let error = calculator
3531 .instrument_greeks(
3532 call_option.id(),
3533 Some(0.0425),
3534 None,
3535 None,
3536 None,
3537 None,
3538 None,
3539 None,
3540 None,
3541 None,
3542 Some(now_ns),
3543 None,
3544 None,
3545 None,
3546 None,
3547 None,
3548 None,
3549 None,
3550 )
3551 .unwrap_err();
3552
3553 assert_eq!(error.to_string(), "No price available for ESH4.GLBX");
3554 }
3555
3556 #[rstest]
3557 fn test_cache_futures_spread_returns_price_to_reference_future() {
3558 let now = utc_timestamp(2024, 2, 14, 16, 0, 0);
3559 let expiry = utc_timestamp(2024, 3, 15, 16, 0, 0);
3560 let now_ns = UnixNanos::from(now);
3561 let expiry_ns = UnixNanos::from(expiry);
3562
3563 let future = future_with_expiration("ESH4.GLBX", "ESH4", expiry_ns);
3564 let reference_future = future_with_expiration("ESM4.GLBX", "ESM4", expiry_ns);
3565 let call_option = future_option_with_expiration(
3566 "ESH4C150.GLBX",
3567 "ESH4C150",
3568 "ESH4",
3569 OptionKind::Call,
3570 "150.00",
3571 expiry_ns,
3572 );
3573 let put_option = future_option_with_expiration(
3574 "ESH4P150.GLBX",
3575 "ESH4P150",
3576 "ESH4",
3577 OptionKind::Put,
3578 "150.00",
3579 expiry_ns,
3580 );
3581
3582 let cache = Rc::new(RefCell::new(Cache::new(None, None)));
3583 cache
3584 .borrow_mut()
3585 .add_instrument(InstrumentAny::FuturesContract(future))
3586 .unwrap();
3587 cache
3588 .borrow_mut()
3589 .add_instrument(InstrumentAny::FuturesContract(reference_future.clone()))
3590 .unwrap();
3591 cache
3592 .borrow_mut()
3593 .add_instrument(InstrumentAny::OptionContract(call_option.clone()))
3594 .unwrap();
3595 cache
3596 .borrow_mut()
3597 .add_instrument(InstrumentAny::OptionContract(put_option.clone()))
3598 .unwrap();
3599
3600 let call_quote = QuoteTick::new(
3601 call_option.id(),
3602 Price::from("8.50"),
3603 Price::from("8.50"),
3604 Quantity::from(100),
3605 Quantity::from(100),
3606 now_ns,
3607 now_ns,
3608 );
3609
3610 let put_quote = QuoteTick::new(
3611 put_option.id(),
3612 Price::from("3.33"),
3613 Price::from("3.33"),
3614 Quantity::from(100),
3615 Quantity::from(100),
3616 now_ns,
3617 now_ns,
3618 );
3619
3620 let reference_future_quote = QuoteTick::new(
3621 reference_future.id(),
3622 Price::from("155.00"),
3623 Price::from("155.00"),
3624 Quantity::from(100),
3625 Quantity::from(100),
3626 now_ns,
3627 now_ns,
3628 );
3629 cache.borrow_mut().add_quote(call_quote).unwrap();
3630 cache.borrow_mut().add_quote(put_quote).unwrap();
3631 cache
3632 .borrow_mut()
3633 .add_quote(reference_future_quote)
3634 .unwrap();
3635
3636 let clock = Rc::new(RefCell::new(VirtualClock::new()));
3637 clock.borrow_mut().set_time(now_ns);
3638 let calculator = GreeksCalculator::new(cache, clock);
3639
3640 let cached_future_price = calculator
3641 .cache_futures_spread(call_option.id(), put_option.id(), reference_future.id())
3642 .unwrap();
3643
3644 let expected_underlying = 150.0 + (0.0425_f64 * (30.0 / 365.25)).exp() * (8.50 - 3.33);
3645 let expected_cached_underlying = reference_future.make_price(expected_underlying);
3646 assert_eq!(cached_future_price, expected_cached_underlying);
3647 assert_eq!(
3648 calculator.get_cached_futures_spread_price(InstrumentId::from("ESH4.GLBX")),
3649 Some(expected_cached_underlying)
3650 );
3651 }
3652
3653 #[rstest]
3654 fn test_instrument_greeks_uses_cached_futures_spread_when_underlying_price_missing() {
3655 let now = utc_timestamp(2024, 2, 14, 16, 0, 0);
3656 let expiry = utc_timestamp(2024, 3, 15, 16, 0, 0);
3657 let now_ns = UnixNanos::from(now);
3658 let expiry_ns = UnixNanos::from(expiry);
3659
3660 let future = future_with_expiration("ESH4.GLBX", "ESH4", expiry_ns);
3661 let reference_future = future_with_expiration("ESM4.GLBX", "ESM4", expiry_ns);
3662 let call_option = future_option_with_expiration(
3663 "ESH4C150.GLBX",
3664 "ESH4C150",
3665 "ESH4",
3666 OptionKind::Call,
3667 "150.00",
3668 expiry_ns,
3669 );
3670 let put_option = future_option_with_expiration(
3671 "ESH4P150.GLBX",
3672 "ESH4P150",
3673 "ESH4",
3674 OptionKind::Put,
3675 "150.00",
3676 expiry_ns,
3677 );
3678 let target_call_option = future_option_with_expiration(
3679 "ESH4C152.GLBX",
3680 "ESH4C152",
3681 "ESH4",
3682 OptionKind::Call,
3683 "152.00",
3684 expiry_ns,
3685 );
3686
3687 let cache = Rc::new(RefCell::new(Cache::new(None, None)));
3688 cache
3689 .borrow_mut()
3690 .add_instrument(InstrumentAny::FuturesContract(future))
3691 .unwrap();
3692 cache
3693 .borrow_mut()
3694 .add_instrument(InstrumentAny::FuturesContract(reference_future.clone()))
3695 .unwrap();
3696 cache
3697 .borrow_mut()
3698 .add_instrument(InstrumentAny::OptionContract(call_option.clone()))
3699 .unwrap();
3700 cache
3701 .borrow_mut()
3702 .add_instrument(InstrumentAny::OptionContract(put_option.clone()))
3703 .unwrap();
3704 cache
3705 .borrow_mut()
3706 .add_instrument(InstrumentAny::OptionContract(target_call_option.clone()))
3707 .unwrap();
3708
3709 let call_quote = QuoteTick::new(
3710 call_option.id(),
3711 Price::from("8.50"),
3712 Price::from("8.50"),
3713 Quantity::from(100),
3714 Quantity::from(100),
3715 now_ns,
3716 now_ns,
3717 );
3718
3719 let put_quote = QuoteTick::new(
3720 put_option.id(),
3721 Price::from("3.33"),
3722 Price::from("3.33"),
3723 Quantity::from(100),
3724 Quantity::from(100),
3725 now_ns,
3726 now_ns,
3727 );
3728
3729 let target_call_quote = QuoteTick::new(
3730 target_call_option.id(),
3731 Price::from("6.75"),
3732 Price::from("6.75"),
3733 Quantity::from(100),
3734 Quantity::from(100),
3735 now_ns,
3736 now_ns,
3737 );
3738
3739 let reference_future_quote = QuoteTick::new(
3740 reference_future.id(),
3741 Price::from("155.00"),
3742 Price::from("155.00"),
3743 Quantity::from(100),
3744 Quantity::from(100),
3745 now_ns,
3746 now_ns,
3747 );
3748 cache.borrow_mut().add_quote(call_quote).unwrap();
3749 cache.borrow_mut().add_quote(put_quote).unwrap();
3750 cache.borrow_mut().add_quote(target_call_quote).unwrap();
3751 cache
3752 .borrow_mut()
3753 .add_quote(reference_future_quote)
3754 .unwrap();
3755
3756 let clock = Rc::new(RefCell::new(VirtualClock::new()));
3757 clock.borrow_mut().set_time(now_ns);
3758 let calculator = GreeksCalculator::new(cache, clock);
3759 calculator
3760 .cache_futures_spread(call_option.id(), put_option.id(), reference_future.id())
3761 .unwrap();
3762
3763 let greeks = calculator
3764 .instrument_greeks(
3765 target_call_option.id(),
3766 Some(0.0425),
3767 None,
3768 None,
3769 None,
3770 None,
3771 None,
3772 None,
3773 None,
3774 None,
3775 Some(now_ns),
3776 None,
3777 None,
3778 None,
3779 None,
3780 None,
3781 None,
3782 None,
3783 )
3784 .unwrap();
3785
3786 let expected_underlying = reference_future
3787 .make_price(150.0 + (0.0425_f64 * (30.0 / 365.25)).exp() * (8.50 - 3.33))
3788 .as_f64();
3789 assert_eq!(greeks.underlying_price, expected_underlying);
3790 }
3791
3792 #[rstest]
3793 fn test_instrument_greeks_uses_index_price_for_index_underlying() {
3794 let now = utc_timestamp(2024, 2, 14, 16, 0, 0);
3795 let expiry = utc_timestamp(2024, 3, 15, 16, 0, 0);
3796 let now_ns = UnixNanos::from(now);
3797 let expiry_ns = UnixNanos::from(expiry);
3798
3799 let future = future_with_expiration("ESH4.GLBX", "ESH4", expiry_ns);
3800 let call_option = future_option_with_expiration(
3801 "ESH4C150.GLBX",
3802 "ESH4C150",
3803 "ESH4",
3804 OptionKind::Call,
3805 "150.00",
3806 expiry_ns,
3807 );
3808
3809 let cache = Rc::new(RefCell::new(Cache::new(None, None)));
3810 cache
3811 .borrow_mut()
3812 .add_instrument(InstrumentAny::FuturesContract(future))
3813 .unwrap();
3814 cache
3815 .borrow_mut()
3816 .add_instrument(InstrumentAny::OptionContract(call_option.clone()))
3817 .unwrap();
3818
3819 let call_quote = QuoteTick::new(
3820 call_option.id(),
3821 Price::from("8.50"),
3822 Price::from("8.50"),
3823 Quantity::from(100),
3824 Quantity::from(100),
3825 now_ns,
3826 now_ns,
3827 );
3828 cache.borrow_mut().add_quote(call_quote).unwrap();
3829 cache
3830 .borrow_mut()
3831 .add_index_price(IndexPriceUpdate::new(
3832 InstrumentId::from("ESH4.GLBX"),
3833 Price::from("157.25"),
3834 now_ns,
3835 now_ns,
3836 ))
3837 .unwrap();
3838
3839 let clock = Rc::new(RefCell::new(VirtualClock::new()));
3840 clock.borrow_mut().set_time(now_ns);
3841 let calculator = GreeksCalculator::new(cache, clock);
3842
3843 let greeks = calculator
3844 .instrument_greeks(
3845 call_option.id(),
3846 Some(0.0425),
3847 None,
3848 None,
3849 None,
3850 None,
3851 None,
3852 None,
3853 None,
3854 None,
3855 Some(now_ns),
3856 None,
3857 None,
3858 None,
3859 None,
3860 None,
3861 None,
3862 None,
3863 )
3864 .unwrap();
3865
3866 assert_eq!(greeks.underlying_price, 157.25);
3867 }
3868
3869 #[rstest]
3870 fn test_instrument_greeks_prefers_quote_over_index_price_for_index_future() {
3871 let now = utc_timestamp(2024, 2, 14, 16, 0, 0);
3872 let expiry = utc_timestamp(2024, 3, 15, 16, 0, 0);
3873 let now_ns = UnixNanos::from(now);
3874 let expiry_ns = UnixNanos::from(expiry);
3875
3876 let future = future_with_expiration("ESH4.GLBX", "ESH4", expiry_ns);
3877 let call_option = future_option_with_expiration(
3878 "ESH4C5500.GLBX",
3879 "ESH4C5500",
3880 "ESH4",
3881 OptionKind::Call,
3882 "5500.00",
3883 expiry_ns,
3884 );
3885
3886 let cache = Rc::new(RefCell::new(Cache::new(None, None)));
3887 cache
3888 .borrow_mut()
3889 .add_instrument(InstrumentAny::FuturesContract(future))
3890 .unwrap();
3891 cache
3892 .borrow_mut()
3893 .add_instrument(InstrumentAny::OptionContract(call_option.clone()))
3894 .unwrap();
3895
3896 let future_quote = QuoteTick::new(
3898 InstrumentId::from("ESH4.GLBX"),
3899 Price::from("158.50"),
3900 Price::from("159.50"),
3901 Quantity::from(100),
3902 Quantity::from(100),
3903 now_ns,
3904 now_ns,
3905 );
3906 cache.borrow_mut().add_quote(future_quote).unwrap();
3907 cache
3908 .borrow_mut()
3909 .add_index_price(IndexPriceUpdate::new(
3910 InstrumentId::from("ESH4.GLBX"),
3911 Price::from("157.25"),
3912 now_ns,
3913 now_ns,
3914 ))
3915 .unwrap();
3916
3917 let call_quote = QuoteTick::new(
3918 call_option.id(),
3919 Price::from("8.50"),
3920 Price::from("8.50"),
3921 Quantity::from(100),
3922 Quantity::from(100),
3923 now_ns,
3924 now_ns,
3925 );
3926 cache.borrow_mut().add_quote(call_quote).unwrap();
3927
3928 let clock = Rc::new(RefCell::new(VirtualClock::new()));
3929 clock.borrow_mut().set_time(now_ns);
3930 let calculator = GreeksCalculator::new(cache, clock);
3931
3932 let greeks = calculator
3933 .instrument_greeks(
3934 call_option.id(),
3935 Some(0.0425),
3936 None,
3937 None,
3938 None,
3939 None,
3940 None,
3941 None,
3942 None,
3943 None,
3944 Some(now_ns),
3945 None,
3946 None,
3947 None,
3948 None,
3949 None,
3950 None,
3951 None,
3952 )
3953 .unwrap();
3954
3955 assert_eq!(greeks.underlying_price, 159.0);
3957 }
3958
3959 fn option_calculator() -> (GreeksCalculator, InstrumentId, UnixNanos) {
3962 let now = utc_timestamp(2025, 3, 8, 12, 0, 0);
3963 let expiry = now + jiff::SignedDuration::from_hours(24 * 30);
3964 let now_ns = UnixNanos::from(now);
3965 let option = option_with_expiration("AAPL250417C00150000.OPRA", UnixNanos::from(expiry));
3966 let option_id = option.id();
3967 let cache =
3968 setup_cache_with_option_and_quotes(option, InstrumentId::from("AAPL.OPRA"), now_ns);
3969 let clock = Rc::new(RefCell::new(VirtualClock::new()));
3970
3971 (GreeksCalculator::new(cache, clock), option_id, now_ns)
3972 }
3973
3974 fn option_greeks_with_shocks(
3975 calculator: &GreeksCalculator,
3976 option_id: InstrumentId,
3977 ts_event: UnixNanos,
3978 spot_shock: Option<f64>,
3979 vol_shock: Option<f64>,
3980 time_to_expiry_shock: Option<f64>,
3981 ) -> GreeksData {
3982 calculator
3983 .instrument_greeks(
3984 option_id,
3985 None,
3986 None,
3987 spot_shock,
3988 vol_shock,
3989 time_to_expiry_shock,
3990 None,
3991 None,
3992 None,
3993 None,
3994 Some(ts_event),
3995 None,
3996 None,
3997 None,
3998 None,
3999 None,
4000 None,
4001 None,
4002 )
4003 .unwrap()
4004 }
4005
4006 #[rstest]
4007 #[case::spot(Some(5.0), None, None)]
4008 #[case::vol(None, Some(0.05), None)]
4009 #[case::time_to_expiry(None, None, Some(0.01))]
4010 fn test_instrument_greeks_applies_each_shock_dimension_on_its_own(
4011 #[case] spot_shock: Option<f64>,
4012 #[case] vol_shock: Option<f64>,
4013 #[case] time_to_expiry_shock: Option<f64>,
4014 ) {
4015 let (calculator, option_id, now_ns) = option_calculator();
4016
4017 let unshocked = option_greeks_with_shocks(&calculator, option_id, now_ns, None, None, None);
4018 let shocked = option_greeks_with_shocks(
4019 &calculator,
4020 option_id,
4021 now_ns,
4022 spot_shock,
4023 vol_shock,
4024 time_to_expiry_shock,
4025 );
4026
4027 assert_ne!(
4028 scaled(shocked.price),
4029 scaled(unshocked.price),
4030 "a single non-zero shock must still reprice the option"
4031 );
4032 }
4033
4034 #[rstest]
4035 fn test_instrument_greeks_leaves_the_price_unshocked_when_every_shock_is_zero() {
4036 let (calculator, option_id, now_ns) = option_calculator();
4037
4038 let unshocked = option_greeks_with_shocks(&calculator, option_id, now_ns, None, None, None);
4039 let zero_shocks = option_greeks_with_shocks(
4040 &calculator,
4041 option_id,
4042 now_ns,
4043 Some(0.0),
4044 Some(0.0),
4045 Some(0.0),
4046 );
4047
4048 assert_eq!(scaled(zero_shocks.price), scaled(unshocked.price));
4049 }
4050
4051 #[rstest]
4052 fn test_instrument_greeks_pnl_is_price_less_the_position_open_price() {
4053 let (calculator, option_id, now_ns) = option_calculator();
4054 let instrument = calculator
4055 .cache
4056 .borrow()
4057 .instrument(&option_id)
4058 .cloned()
4059 .unwrap();
4060 let position = position_from_fill(
4061 &instrument,
4062 "P-GREEKS-1",
4063 "O-GREEKS-1",
4064 "T-GREEKS-1",
4065 OrderSide::Buy,
4066 1,
4067 "9.00",
4068 );
4069
4070 let unshocked = option_greeks_with_shocks(&calculator, option_id, now_ns, None, None, None);
4071 let with_position = calculator
4072 .instrument_greeks(
4073 option_id,
4074 None,
4075 None,
4076 None,
4077 None,
4078 None,
4079 None,
4080 None,
4081 None,
4082 None,
4083 Some(now_ns),
4084 Some(position),
4085 None,
4086 None,
4087 None,
4088 None,
4089 None,
4090 None,
4091 )
4092 .unwrap();
4093
4094 assert_eq!(scaled(with_position.price), scaled(unshocked.price));
4095 assert_eq!(
4096 scaled(with_position.pnl),
4097 scaled(unshocked.price - 9.0),
4098 "pnl must subtract the position open price from the option price"
4099 );
4100 assert_eq!(scaled(unshocked.pnl), 0.0);
4101 }
4102
4103 #[rstest]
4104 fn test_instrument_greeks_records_the_shocked_market_state() {
4105 let (calculator, option_id, now_ns) = option_calculator();
4106
4107 let unshocked = option_greeks_with_shocks(&calculator, option_id, now_ns, None, None, None);
4108 let shocked = option_greeks_with_shocks(
4109 &calculator,
4110 option_id,
4111 now_ns,
4112 Some(5.0),
4113 Some(0.05),
4114 Some(0.01),
4115 );
4116
4117 assert_eq!(scaled(unshocked.underlying_price), 150_050_000_000_000.0);
4119 assert_eq!(scaled(shocked.underlying_price), 155_050_000_000_000.0);
4120 assert_eq!(scaled(shocked.vol), scaled(unshocked.vol + 0.05));
4121 assert_eq!(
4122 scaled(shocked.expiry_in_years),
4123 scaled(unshocked.expiry_in_years - 0.01)
4124 );
4125 assert_eq!(unshocked.expiry_in_days, 30);
4126 assert_eq!(shocked.expiry_in_days, 26);
4127 }
4128
4129 #[rstest]
4130 fn test_instrument_greeks_cost_of_carry_subtracts_the_flat_dividend_yield() {
4131 let (calculator, option_id, now_ns) = option_calculator();
4132
4133 let greeks = calculator
4134 .instrument_greeks(
4135 option_id,
4136 Some(0.05),
4137 Some(0.02),
4138 None,
4139 None,
4140 None,
4141 None,
4142 None,
4143 None,
4144 None,
4145 Some(now_ns),
4146 None,
4147 None,
4148 None,
4149 None,
4150 None,
4151 None,
4152 None,
4153 )
4154 .unwrap();
4155
4156 assert_eq!(scaled(greeks.interest_rate), 50_000_000_000.0);
4157 assert_eq!(scaled(greeks.cost_of_carry), 30_000_000_000.0);
4158 }
4159
4160 #[rstest]
4161 fn test_instrument_greeks_cost_of_carry_is_zero_without_a_dividend_yield() {
4162 let (calculator, option_id, now_ns) = option_calculator();
4163
4164 let greeks = calculator
4165 .instrument_greeks(
4166 option_id,
4167 Some(0.05),
4168 None,
4169 None,
4170 None,
4171 None,
4172 None,
4173 None,
4174 None,
4175 None,
4176 Some(now_ns),
4177 None,
4178 None,
4179 None,
4180 None,
4181 None,
4182 None,
4183 None,
4184 )
4185 .unwrap();
4186
4187 assert_eq!(scaled(greeks.cost_of_carry), 0.0);
4188 }
4189
4190 #[rstest]
4191 fn test_instrument_greeks_reports_the_option_kind() {
4192 let now = utc_timestamp(2025, 3, 8, 12, 0, 0);
4193 let expiry = now + jiff::SignedDuration::from_hours(24 * 30);
4194 let now_ns = UnixNanos::from(now);
4195 let underlying_id = InstrumentId::from("AAPL.OPRA");
4196 let mut put = option_with_expiration("AAPL250417P00150000.OPRA", UnixNanos::from(expiry));
4197 put.option_kind = OptionKind::Put;
4198 let put_id = put.id();
4199 let cache = setup_cache_with_option_and_quotes(put, underlying_id, now_ns);
4200 cache
4201 .borrow_mut()
4202 .add_quote(QuoteTick::new(
4203 put_id,
4204 Price::from("10.50"),
4205 Price::from("10.60"),
4206 Quantity::from(100),
4207 Quantity::from(100),
4208 now_ns,
4209 now_ns,
4210 ))
4211 .unwrap();
4212 let calculator = GreeksCalculator::new(cache, Rc::new(RefCell::new(VirtualClock::new())));
4213
4214 let greeks = option_greeks_with_shocks(&calculator, put_id, now_ns, None, None, None);
4215
4216 assert!(!greeks.is_call);
4217 }
4218
4219 fn equity_calculator() -> (GreeksCalculator, InstrumentId, UnixNanos) {
4222 let now_ns = UnixNanos::from(utc_timestamp(2025, 3, 8, 12, 0, 0));
4223 let cache = Rc::new(RefCell::new(Cache::new(None, None)));
4224 let equity = equity_aapl_opra();
4225 let equity_id = equity.id();
4226 cache
4227 .borrow_mut()
4228 .add_instrument(InstrumentAny::Equity(equity))
4229 .unwrap();
4230 cache
4231 .borrow_mut()
4232 .add_quote(QuoteTick::new(
4233 equity_id,
4234 Price::from("150.00"),
4235 Price::from("150.10"),
4236 Quantity::from(100),
4237 Quantity::from(100),
4238 now_ns,
4239 now_ns,
4240 ))
4241 .unwrap();
4242 let clock = Rc::new(RefCell::new(VirtualClock::new()));
4243
4244 (GreeksCalculator::new(cache, clock), equity_id, now_ns)
4245 }
4246
4247 #[rstest]
4248 fn test_instrument_greeks_for_a_non_option_shocks_the_spot_and_prices_the_position() {
4249 let (calculator, equity_id, now_ns) = equity_calculator();
4250 let instrument = calculator
4251 .cache
4252 .borrow()
4253 .instrument(&equity_id)
4254 .cloned()
4255 .unwrap();
4256 let position = position_from_fill(
4257 &instrument,
4258 "P-EQUITY-1",
4259 "O-EQUITY-1",
4260 "T-EQUITY-1",
4261 OrderSide::Buy,
4262 10,
4263 "140.00",
4264 );
4265
4266 let greeks = calculator
4267 .instrument_greeks(
4268 equity_id,
4269 None,
4270 None,
4271 Some(5.0),
4272 None,
4273 None,
4274 None,
4275 None,
4276 None,
4277 None,
4278 Some(now_ns),
4279 Some(position),
4280 None,
4281 None,
4282 None,
4283 None,
4284 None,
4285 None,
4286 )
4287 .unwrap();
4288
4289 assert_eq!(scaled(greeks.pnl), 15_050_000_000_000.0);
4291 assert_eq!(scaled(greeks.price), 15_050_000_000_000.0);
4292 }
4293
4294 #[rstest]
4295 fn test_instrument_greeks_for_a_non_option_scales_percent_delta_by_the_shocked_spot() {
4296 let (calculator, equity_id, now_ns) = equity_calculator();
4297
4298 let greeks = calculator
4299 .instrument_greeks(
4300 equity_id,
4301 None,
4302 None,
4303 Some(5.0),
4304 None,
4305 None,
4306 None,
4307 None,
4308 None,
4309 None,
4310 Some(now_ns),
4311 None,
4312 Some(true),
4313 None,
4314 None,
4315 None,
4316 None,
4317 None,
4318 )
4319 .unwrap();
4320
4321 assert_eq!(scaled(greeks.delta), 1_550_500_000_000.0);
4323 }
4324
4325 #[rstest]
4326 fn test_instrument_greeks_cost_of_carry_prefers_the_dividend_curve_over_the_flat_yield() {
4327 let (calculator, option_id, now_ns) = option_calculator();
4328 calculator
4329 .cache
4330 .borrow_mut()
4331 .add_yield_curve(YieldCurve::new(
4332 "AAPL.OPRA".to_string(),
4333 vec![0.0, 5.0, 10.0],
4334 vec![0.02, 0.02, 0.02],
4335 now_ns,
4336 now_ns,
4337 ))
4338 .unwrap();
4339
4340 let greeks = calculator
4341 .instrument_greeks(
4342 option_id,
4343 Some(0.05),
4344 Some(0.04),
4345 None,
4346 None,
4347 None,
4348 None,
4349 None,
4350 None,
4351 None,
4352 Some(now_ns),
4353 None,
4354 None,
4355 None,
4356 None,
4357 None,
4358 None,
4359 None,
4360 )
4361 .unwrap();
4362
4363 assert_eq!(scaled(greeks.interest_rate), 50_000_000_000.0);
4364 assert_eq!(
4365 scaled(greeks.cost_of_carry),
4366 30_000_000_000.0,
4367 "the dividend curve must take precedence over the flat dividend yield"
4368 );
4369 }
4370
4371 fn scaled(value: f64) -> f64 {
4373 (value * 1e12).round()
4374 }
4375
4376 #[rstest]
4377 fn test_modify_greeks_reprices_the_index_when_the_underlying_is_shocked() {
4378 let calculator = create_test_calculator();
4379 let underlying_id = InstrumentId::from("AAPL.OPRA");
4380 let mut beta_weights = HashMap::new();
4381 beta_weights.insert(underlying_id, 0.5);
4382
4383 let (delta, gamma, vega) = calculator
4384 .modify_greeks(
4385 1.0,
4386 2.0,
4387 underlying_id,
4388 165.0,
4389 150.0,
4390 false,
4391 None,
4392 Some(&beta_weights),
4393 3.0,
4394 0.30,
4395 0,
4396 None,
4397 0.0,
4398 None,
4399 None,
4400 Some(200.0),
4401 None,
4402 )
4403 .unwrap();
4404
4405 assert_eq!(scaled(delta), 343_750_000_000.0);
4408 assert_eq!(scaled(gamma), 236_328_125_000.0);
4409 assert_eq!(scaled(vega), 3_000_000_000_000.0);
4410 }
4411
4412 #[rstest]
4413 fn test_modify_greeks_reprices_the_vol_index_when_the_vol_is_shocked() {
4414 let calculator = create_test_calculator();
4415 let underlying_id = InstrumentId::from("AAPL.OPRA");
4416 let mut vol_beta_weights = HashMap::new();
4417 vol_beta_weights.insert(underlying_id, 0.75);
4418
4419 let (delta, gamma, vega) = calculator
4420 .modify_greeks(
4421 1.0,
4422 2.0,
4423 underlying_id,
4424 150.0,
4425 150.0,
4426 false,
4427 None,
4428 None,
4429 2.0,
4430 0.35,
4431 0,
4432 None,
4433 0.30,
4434 None,
4435 Some(&vol_beta_weights),
4436 None,
4437 Some(25.0),
4438 )
4439 .unwrap();
4440
4441 assert_eq!(scaled(delta), 1_000_000_000_000.0);
4442 assert_eq!(scaled(gamma), 2_000_000_000_000.0);
4443 assert_eq!(scaled(vega), 1_718_181_818_182.0);
4444 }
4445
4446 #[rstest]
4447 fn test_modify_greeks_uses_the_shocked_vol_as_the_baseline_when_unshocked_vol_is_zero() {
4448 let calculator = create_test_calculator();
4449
4450 let (_, _, vega) = calculator
4451 .modify_greeks(
4452 1.0,
4453 2.0,
4454 InstrumentId::from("AAPL.OPRA"),
4455 150.0,
4456 150.0,
4457 false,
4458 None,
4459 None,
4460 2.0,
4461 0.40,
4462 0,
4463 None,
4464 0.0,
4465 None,
4466 None,
4467 None,
4468 Some(25.0),
4469 )
4470 .unwrap();
4471
4472 assert_eq!(scaled(vega), 3_200_000_000_000.0);
4474 }
4475
4476 #[rstest]
4477 fn test_modify_greeks_leaves_vega_untouched_for_a_zero_vol_index() {
4478 let calculator = create_test_calculator();
4479
4480 let (_, _, vega) = calculator
4481 .modify_greeks(
4482 1.0,
4483 2.0,
4484 InstrumentId::from("AAPL.OPRA"),
4485 150.0,
4486 150.0,
4487 false,
4488 None,
4489 None,
4490 2.0,
4491 0.40,
4492 0,
4493 None,
4494 0.0,
4495 None,
4496 None,
4497 None,
4498 Some(0.0),
4499 )
4500 .unwrap();
4501
4502 assert_eq!(scaled(vega), 2_000_000_000_000.0);
4503 }
4504
4505 #[rstest]
4506 fn test_modify_greeks_percent_scaling_falls_back_to_the_underlying_and_vol() {
4507 let calculator = create_test_calculator();
4508
4509 let (delta, gamma, vega) = calculator
4510 .modify_greeks(
4511 1.0,
4512 2.0,
4513 InstrumentId::from("AAPL.OPRA"),
4514 150.0,
4515 150.0,
4516 true,
4517 None,
4518 None,
4519 2.0,
4520 0.30,
4521 0,
4522 None,
4523 0.0,
4524 None,
4525 None,
4526 None,
4527 None,
4528 )
4529 .unwrap();
4530
4531 assert_eq!(scaled(delta), 1_500_000_000_000.0);
4532 assert_eq!(scaled(gamma), 4_500_000_000_000.0);
4533 assert_eq!(scaled(vega), 6_000_000_000.0);
4534 }
4535
4536 #[rstest]
4537 fn test_modify_greeks_applies_vega_time_weighting() {
4538 let calculator = create_test_calculator();
4539
4540 let (_, _, vega) = calculator
4541 .modify_greeks(
4542 1.0,
4543 2.0,
4544 InstrumentId::from("AAPL.OPRA"),
4545 150.0,
4546 150.0,
4547 false,
4548 None,
4549 None,
4550 2.0,
4551 0.30,
4552 120,
4553 Some(30),
4554 0.0,
4555 None,
4556 None,
4557 None,
4558 None,
4559 )
4560 .unwrap();
4561
4562 assert_eq!(scaled(vega), 1_000_000_000_000.0);
4564 }
4565
4566 #[rstest]
4567 fn test_modify_greeks_skips_vega_time_weighting_without_days_to_expiry() {
4568 let calculator = create_test_calculator();
4569
4570 let (_, _, vega) = calculator
4571 .modify_greeks(
4572 1.0,
4573 2.0,
4574 InstrumentId::from("AAPL.OPRA"),
4575 150.0,
4576 150.0,
4577 false,
4578 None,
4579 None,
4580 2.0,
4581 0.30,
4582 0,
4583 Some(30),
4584 0.0,
4585 None,
4586 None,
4587 None,
4588 None,
4589 )
4590 .unwrap();
4591
4592 assert_eq!(scaled(vega), 2_000_000_000_000.0);
4593 }
4594
4595 const SPREAD_CALL_ID: &str = "ESH4C150.GLBX";
4596 const SPREAD_PUT_ID: &str = "ESH4P150.GLBX";
4597 const SPREAD_REFERENCE_ID: &str = "ESM4.GLBX";
4598 const SPREAD_UNDERLYING_ID: &str = "ESH4.GLBX";
4599
4600 fn spread_now_ns() -> UnixNanos {
4601 UnixNanos::from(utc_timestamp(2024, 2, 14, 16, 0, 0))
4602 }
4603
4604 fn spread_expiry_ns() -> UnixNanos {
4605 UnixNanos::from(utc_timestamp(2024, 3, 15, 16, 0, 0))
4606 }
4607
4608 fn spread_call() -> OptionContract {
4609 future_option_with_expiration(
4610 SPREAD_CALL_ID,
4611 "ESH4C150",
4612 "ESH4",
4613 OptionKind::Call,
4614 "150.00",
4615 spread_expiry_ns(),
4616 )
4617 }
4618
4619 fn spread_put() -> OptionContract {
4620 future_option_with_expiration(
4621 SPREAD_PUT_ID,
4622 "ESH4P150",
4623 "ESH4",
4624 OptionKind::Put,
4625 "150.00",
4626 spread_expiry_ns(),
4627 )
4628 }
4629
4630 fn spread_reference_future() -> FuturesContract {
4631 future_with_expiration(SPREAD_REFERENCE_ID, "ESM4", spread_expiry_ns())
4632 }
4633
4634 fn spread_quote(instrument_id: InstrumentId, price: &str) -> QuoteTick {
4635 let price = Price::from(price);
4636
4637 QuoteTick::new(
4638 instrument_id,
4639 price,
4640 price,
4641 Quantity::from(100),
4642 Quantity::from(100),
4643 spread_now_ns(),
4644 spread_now_ns(),
4645 )
4646 }
4647
4648 fn spread_calculator(
4654 instruments: Vec<InstrumentAny>,
4655 quotes: Vec<QuoteTick>,
4656 ) -> GreeksCalculator {
4657 let cache = Rc::new(RefCell::new(Cache::new(None, None)));
4658 for instrument in instruments {
4659 cache.borrow_mut().add_instrument(instrument).unwrap();
4660 }
4661
4662 for quote in quotes {
4663 cache.borrow_mut().add_quote(quote).unwrap();
4664 }
4665
4666 let clock = Rc::new(RefCell::new(VirtualClock::new()));
4667 clock.borrow_mut().set_time(spread_now_ns());
4668
4669 GreeksCalculator::new(cache, clock)
4670 }
4671
4672 fn spread_instruments() -> Vec<InstrumentAny> {
4673 vec![
4674 InstrumentAny::OptionContract(spread_call()),
4675 InstrumentAny::OptionContract(spread_put()),
4676 InstrumentAny::FuturesContract(spread_reference_future()),
4677 ]
4678 }
4679
4680 fn spread_quotes() -> Vec<QuoteTick> {
4681 vec![
4682 spread_quote(InstrumentId::from(SPREAD_CALL_ID), "8.50"),
4683 spread_quote(InstrumentId::from(SPREAD_PUT_ID), "3.33"),
4684 spread_quote(InstrumentId::from(SPREAD_REFERENCE_ID), "155.00"),
4685 ]
4686 }
4687
4688 fn cache_spread_error(instruments: Vec<InstrumentAny>, quotes: Vec<QuoteTick>) -> String {
4689 spread_calculator(instruments, quotes)
4690 .cache_futures_spread(
4691 InstrumentId::from(SPREAD_CALL_ID),
4692 InstrumentId::from(SPREAD_PUT_ID),
4693 InstrumentId::from(SPREAD_REFERENCE_ID),
4694 )
4695 .unwrap_err()
4696 .to_string()
4697 }
4698
4699 #[rstest]
4700 fn test_cache_futures_spread_errors_when_call_instrument_missing() {
4701 let instruments = vec![
4702 InstrumentAny::OptionContract(spread_put()),
4703 InstrumentAny::FuturesContract(spread_reference_future()),
4704 ];
4705
4706 assert_eq!(
4707 cache_spread_error(instruments, spread_quotes()),
4708 "Cannot cache futures spread: missing option instrument ESH4C150.GLBX"
4709 );
4710 }
4711
4712 #[rstest]
4713 fn test_cache_futures_spread_errors_when_put_instrument_missing() {
4714 let instruments = vec![
4715 InstrumentAny::OptionContract(spread_call()),
4716 InstrumentAny::FuturesContract(spread_reference_future()),
4717 ];
4718
4719 assert_eq!(
4720 cache_spread_error(instruments, spread_quotes()),
4721 "Cannot cache futures spread: missing option instrument ESH4P150.GLBX"
4722 );
4723 }
4724
4725 #[rstest]
4726 fn test_cache_futures_spread_errors_when_reference_future_instrument_missing() {
4727 let instruments = vec![
4728 InstrumentAny::OptionContract(spread_call()),
4729 InstrumentAny::OptionContract(spread_put()),
4730 ];
4731
4732 assert_eq!(
4733 cache_spread_error(instruments, spread_quotes()),
4734 "Cannot cache futures spread: no reference futures instrument for ESM4.GLBX"
4735 );
4736 }
4737
4738 #[rstest]
4739 fn test_cache_futures_spread_errors_when_call_leg_is_not_an_option() {
4740 let non_option = future_with_expiration(SPREAD_CALL_ID, "ESH4", spread_expiry_ns());
4741 let instruments = vec![
4742 InstrumentAny::FuturesContract(non_option),
4743 InstrumentAny::OptionContract(spread_put()),
4744 InstrumentAny::FuturesContract(spread_reference_future()),
4745 ];
4746
4747 assert_eq!(
4748 cache_spread_error(instruments, spread_quotes()),
4749 "Cannot cache futures spread: non-option instruments provided \
4750 call_instrument_id=ESH4C150.GLBX put_instrument_id=ESH4P150.GLBX"
4751 );
4752 }
4753
4754 #[rstest]
4755 fn test_cache_futures_spread_errors_when_legs_are_not_a_call_put_pair() {
4756 let second_put = future_option_with_expiration(
4757 SPREAD_CALL_ID,
4758 "ESH4P150",
4759 "ESH4",
4760 OptionKind::Put,
4761 "150.00",
4762 spread_expiry_ns(),
4763 );
4764 let instruments = vec![
4765 InstrumentAny::OptionContract(second_put),
4766 InstrumentAny::OptionContract(spread_put()),
4767 InstrumentAny::FuturesContract(spread_reference_future()),
4768 ];
4769
4770 assert_eq!(
4771 cache_spread_error(instruments, spread_quotes()),
4772 "Cannot cache futures spread: expected call/put pair \
4773 call_instrument_id=ESH4C150.GLBX put_instrument_id=ESH4P150.GLBX"
4774 );
4775 }
4776
4777 #[rstest]
4778 fn test_cache_futures_spread_errors_when_underlyings_differ() {
4779 let mismatched_put = future_option_with_expiration(
4780 SPREAD_PUT_ID,
4781 "ESM4P150",
4782 "ESM4",
4783 OptionKind::Put,
4784 "150.00",
4785 spread_expiry_ns(),
4786 );
4787 let instruments = vec![
4788 InstrumentAny::OptionContract(spread_call()),
4789 InstrumentAny::OptionContract(mismatched_put),
4790 InstrumentAny::FuturesContract(spread_reference_future()),
4791 ];
4792
4793 assert_eq!(
4794 cache_spread_error(instruments, spread_quotes()),
4795 "Cannot cache futures spread: option underlyings differ \
4796 call_instrument_id=ESH4C150.GLBX put_instrument_id=ESH4P150.GLBX"
4797 );
4798 }
4799
4800 #[rstest]
4801 fn test_cache_futures_spread_errors_when_strike_prices_differ() {
4802 let mismatched_put = future_option_with_expiration(
4803 SPREAD_PUT_ID,
4804 "ESH4P155",
4805 "ESH4",
4806 OptionKind::Put,
4807 "155.00",
4808 spread_expiry_ns(),
4809 );
4810 let instruments = vec![
4811 InstrumentAny::OptionContract(spread_call()),
4812 InstrumentAny::OptionContract(mismatched_put),
4813 InstrumentAny::FuturesContract(spread_reference_future()),
4814 ];
4815
4816 assert_eq!(
4817 cache_spread_error(instruments, spread_quotes()),
4818 "Cannot cache futures spread: strike prices differ \
4819 call_instrument_id=ESH4C150.GLBX put_instrument_id=ESH4P150.GLBX"
4820 );
4821 }
4822
4823 #[rstest]
4824 fn test_cache_futures_spread_errors_when_expirations_differ() {
4825 let mismatched_put = future_option_with_expiration(
4826 SPREAD_PUT_ID,
4827 "ESH4P150",
4828 "ESH4",
4829 OptionKind::Put,
4830 "150.00",
4831 UnixNanos::from(utc_timestamp(2024, 6, 21, 16, 0, 0)),
4832 );
4833 let instruments = vec![
4834 InstrumentAny::OptionContract(spread_call()),
4835 InstrumentAny::OptionContract(mismatched_put),
4836 InstrumentAny::FuturesContract(spread_reference_future()),
4837 ];
4838
4839 assert_eq!(
4840 cache_spread_error(instruments, spread_quotes()),
4841 "Cannot cache futures spread: expiration dates differ \
4842 call_instrument_id=ESH4C150.GLBX put_instrument_id=ESH4P150.GLBX"
4843 );
4844 }
4845
4846 #[rstest]
4847 fn test_cache_futures_spread_errors_when_reference_future_price_missing() {
4848 let quotes = vec![
4849 spread_quote(InstrumentId::from(SPREAD_CALL_ID), "8.50"),
4850 spread_quote(InstrumentId::from(SPREAD_PUT_ID), "3.33"),
4851 ];
4852
4853 assert_eq!(
4854 cache_spread_error(spread_instruments(), quotes),
4855 "Cannot cache futures spread: no reference futures price for ESM4.GLBX"
4856 );
4857 }
4858
4859 #[rstest]
4860 fn test_cache_futures_spread_errors_when_call_price_missing() {
4861 let quotes = vec![
4862 spread_quote(InstrumentId::from(SPREAD_PUT_ID), "3.33"),
4863 spread_quote(InstrumentId::from(SPREAD_REFERENCE_ID), "155.00"),
4864 ];
4865
4866 assert_eq!(
4867 cache_spread_error(spread_instruments(), quotes),
4868 "Cannot cache futures spread: missing option price for ESH4C150.GLBX"
4869 );
4870 }
4871
4872 #[rstest]
4873 fn test_cache_futures_spread_errors_when_put_price_missing() {
4874 let quotes = vec![
4875 spread_quote(InstrumentId::from(SPREAD_CALL_ID), "8.50"),
4876 spread_quote(InstrumentId::from(SPREAD_REFERENCE_ID), "155.00"),
4877 ];
4878
4879 assert_eq!(
4880 cache_spread_error(spread_instruments(), quotes),
4881 "Cannot cache futures spread: missing option price for ESH4P150.GLBX"
4882 );
4883 }
4884
4885 #[rstest]
4886 fn test_cache_futures_spread_errors_when_cached_underlying_is_not_a_future() {
4887 let underlying_equity = Equity::builder()
4888 .instrument_id(InstrumentId::from(SPREAD_UNDERLYING_ID))
4889 .raw_symbol(Symbol::from("ESH4"))
4890 .currency(Currency::from("USD"))
4891 .price_precision(2)
4892 .price_increment(Price::from("0.01"))
4893 .ts_event(UnixNanos::default())
4894 .ts_init(UnixNanos::default())
4895 .build()
4896 .unwrap();
4897
4898 let mut instruments = spread_instruments();
4899 instruments.push(InstrumentAny::Equity(underlying_equity));
4900
4901 assert_eq!(
4902 cache_spread_error(instruments, spread_quotes()),
4903 "Cannot cache futures spread: underlying ESH4.GLBX is not a futures contract"
4904 );
4905 }
4906
4907 #[rstest]
4908 fn test_cache_futures_spread_leaves_no_entry_when_validation_fails() {
4909 let calculator = spread_calculator(
4910 vec![
4911 InstrumentAny::OptionContract(spread_call()),
4912 InstrumentAny::OptionContract(spread_put()),
4913 ],
4914 spread_quotes(),
4915 );
4916
4917 calculator
4918 .cache_futures_spread(
4919 InstrumentId::from(SPREAD_CALL_ID),
4920 InstrumentId::from(SPREAD_PUT_ID),
4921 InstrumentId::from(SPREAD_REFERENCE_ID),
4922 )
4923 .unwrap_err();
4924
4925 assert_eq!(
4926 calculator.get_cached_futures_spread_price(InstrumentId::from(SPREAD_UNDERLYING_ID)),
4927 None
4928 );
4929 }
4930
4931 #[rstest]
4932 fn test_resolve_underlying_instrument_id_prefers_cached_futures_spread_over_cross_venue_match()
4933 {
4934 let mut instruments = spread_instruments();
4935 instruments.push(InstrumentAny::FuturesContract(future_with_expiration(
4936 "ESH4.XCME",
4937 "ESH4",
4938 spread_expiry_ns(),
4939 )));
4940 let calculator = spread_calculator(instruments, spread_quotes());
4941 calculator
4942 .cache_futures_spread(
4943 InstrumentId::from(SPREAD_CALL_ID),
4944 InstrumentId::from(SPREAD_PUT_ID),
4945 InstrumentId::from(SPREAD_REFERENCE_ID),
4946 )
4947 .unwrap();
4948
4949 let underlying_id = calculator
4950 .resolve_underlying_instrument_id(
4951 &InstrumentAny::OptionContract(spread_call()),
4952 InstrumentId::from(SPREAD_CALL_ID),
4953 )
4954 .unwrap();
4955
4956 assert_eq!(underlying_id, InstrumentId::from(SPREAD_UNDERLYING_ID));
4957 }
4958
4959 #[rstest]
4960 fn test_get_cached_futures_spread_price_returns_none_for_unknown_underlying() {
4961 let calculator = spread_calculator(spread_instruments(), spread_quotes());
4962
4963 assert_eq!(
4964 calculator.get_cached_futures_spread_price(InstrumentId::from("CLZ4.NYMEX")),
4965 None
4966 );
4967 }
4968
4969 #[cfg(feature = "defi")]
4970 #[rstest]
4971 fn test_instrument_greeks_converts_mid_of_max_float_precision_quote() {
4972 let now = utc_timestamp(2025, 3, 8, 12, 0, 0);
4973 let now_ns = UnixNanos::from(now);
4974 let expiry_ns = UnixNanos::from(now + jiff::SignedDuration::from_hours(24 * 30));
4975 let future = future_with_expiration("ES.GLBX", "ES", expiry_ns);
4976 let instrument = InstrumentAny::FuturesContract(future.clone());
4977 let cache = Rc::new(RefCell::new(Cache::new(None, None)));
4978 cache
4979 .borrow_mut()
4980 .add_instrument(instrument.clone())
4981 .unwrap();
4982
4983 let quote = QuoteTick::new(
4984 future.id(),
4985 Price::from("100.0000000000000000"),
4986 Price::from("100.0000000000000001"),
4987 Quantity::from(1),
4988 Quantity::from(1),
4989 now_ns,
4990 now_ns,
4991 );
4992 cache.borrow_mut().add_quote(quote).unwrap();
4993 let position =
4994 position_from_fill(&instrument, "P-1", "O-1", "T-1", OrderSide::Buy, 1, "99.00");
4995 let clock = Rc::new(RefCell::new(VirtualClock::new()));
4996 clock.borrow_mut().set_time(now_ns);
4997 let calculator = GreeksCalculator::new(cache, clock);
4998
4999 let greeks = calculator
5000 .instrument_greeks(
5001 future.id(),
5002 None,
5003 None,
5004 None,
5005 None,
5006 None,
5007 None,
5008 None,
5009 None,
5010 None,
5011 Some(now_ns),
5012 Some(position),
5013 None,
5014 None,
5015 None,
5016 None,
5017 None,
5018 None,
5019 )
5020 .unwrap();
5021
5022 assert_eq!(greeks.delta, 1.0);
5023 assert_eq!(greeks.pnl, 1.0);
5024 assert_eq!(greeks.price, 1.0);
5025 }
5026
5027 #[rstest]
5028 #[case(false, -1)]
5029 #[case(false, 0)]
5030 #[case(true, -1)]
5031 #[case(true, 0)]
5032 fn test_instrument_greeks_rejects_expired_options_before_cached_return(
5033 #[case] cached: bool,
5034 #[case] offset_ns: i64,
5035 #[values(false, true)] clock_default: bool,
5036 ) {
5037 let now_ns = UnixNanos::from(1_000_000_000);
5038 let expiry_ns = UnixNanos::from((1_000_000_000_i64 + offset_ns) as u64);
5039 let option = option_with_expiration("AAPL250417C00150000.OPRA", expiry_ns);
5040 let id = option.id();
5041 let calculator = calculator_with_option_and_equities(option, &[]);
5042 let clock = Rc::new(RefCell::new(VirtualClock::new()));
5043 clock.borrow_mut().set_time(now_ns);
5044 let calculator = GreeksCalculator::new(calculator.cache, clock);
5045
5046 if cached {
5047 let mut data = GreeksData::from_delta(id, 0.5, 100.0, now_ns);
5048 data.underlying_price = 150.0;
5049 data.strike = 150.0;
5050 data.vol = 0.2;
5051 data.expiry_in_years = 1.0;
5052 calculator.cache.borrow_mut().add_greeks(data).unwrap();
5053 }
5054
5055 let mut params = InstrumentGreeksParams::builder()
5056 .instrument_id(id)
5057 .ts_event(now_ns)
5058 .use_cached_greeks(cached)
5059 .build();
5060
5061 if clock_default {
5062 params.ts_event = None;
5063 }
5064
5065 let result = params.calculate(&calculator).unwrap_err();
5066 assert_eq!(
5067 result.downcast_ref::<OptionValuationError>(),
5068 Some(&OptionValuationError::Expired(id))
5069 );
5070 }
5071
5072 #[rstest]
5073 #[case(InstrumentAny::OptionSpread(option_spread()))]
5074 #[case(InstrumentAny::CryptoOptionSpread(crypto_option_spread_btc_deribit()))]
5075 fn test_instrument_greeks_rejects_option_spreads(#[case] instrument: InstrumentAny) {
5076 let id = instrument.id();
5077 let calculator = create_test_calculator();
5078 calculator
5079 .cache
5080 .borrow_mut()
5081 .add_instrument(instrument)
5082 .unwrap();
5083 let result = InstrumentGreeksParams::builder()
5084 .instrument_id(id)
5085 .build()
5086 .calculate(&calculator)
5087 .unwrap_err();
5088 assert_eq!(
5089 result.downcast_ref::<OptionValuationError>(),
5090 Some(&OptionValuationError::UnsupportedSpread(id))
5091 );
5092 }
5093
5094 #[rstest]
5095 #[case(-200.0, 0.0, 0.0, "underlying price")]
5096 #[case(0.0, -2.0, 0.0, "volatility")]
5097 #[case(0.0, 0.0, 1.0, "time to expiry")]
5098 #[case(f64::NAN, 0.0, 0.0, "spot shock")]
5099 #[case(0.0, f64::INFINITY, 0.0, "volatility shock")]
5100 #[case(0.0, 0.0, f64::NAN, "time to expiry shock")]
5101 fn test_instrument_greeks_rejects_invalid_shocks_without_caching(
5102 #[case] spot: f64,
5103 #[case] vol: f64,
5104 #[case] time: f64,
5105 #[case] input: &'static str,
5106 ) {
5107 let (calculator, id, now) = option_calculator();
5108 let result = InstrumentGreeksParams::builder()
5109 .instrument_id(id)
5110 .ts_event(now)
5111 .spot_shock(spot)
5112 .vol_shock(vol)
5113 .time_to_expiry_shock(time)
5114 .cache_greeks(true)
5115 .build()
5116 .calculate(&calculator)
5117 .unwrap_err();
5118 assert_eq!(
5119 result.downcast_ref::<OptionValuationError>(),
5120 Some(&OptionValuationError::InvalidInput(input))
5121 );
5122 assert!(calculator.cache.borrow().greeks(&id).is_none());
5123 }
5124
5125 #[rstest]
5126 fn test_instrument_greeks_rejects_unattainable_price() {
5127 let (calculator, id, now) = option_calculator();
5128 calculator
5129 .cache
5130 .borrow_mut()
5131 .add_quote(QuoteTick::new(
5132 id,
5133 Price::from("200.00"),
5134 Price::from("201.00"),
5135 Quantity::from(100),
5136 Quantity::from(100),
5137 now,
5138 now,
5139 ))
5140 .unwrap();
5141 let result = InstrumentGreeksParams::builder()
5142 .instrument_id(id)
5143 .ts_event(now)
5144 .cache_greeks(true)
5145 .build()
5146 .calculate(&calculator)
5147 .unwrap_err();
5148 assert_eq!(
5149 result.downcast_ref::<OptionValuationError>(),
5150 Some(&OptionValuationError::ImpliedVolatilityUnavailable)
5151 );
5152 assert!(calculator.cache.borrow().greeks(&id).is_none());
5153 }
5154
5155 #[rstest]
5156 #[case(false)]
5157 #[case(true)]
5158 fn test_instrument_greeks_rho_uses_explicit_carry_source(#[case] dividend_linked: bool) {
5159 let (calculator, id, now) = option_calculator();
5160 let mut params = InstrumentGreeksParams::builder()
5161 .instrument_id(id)
5162 .ts_event(now)
5163 .flat_interest_rate(0.05)
5164 .build();
5165 params.flat_dividend_yield = dividend_linked.then_some(0.05);
5166 let greeks = params.calculate(&calculator).unwrap();
5167 let expected = if dividend_linked {
5168 greeks.expiry_in_years
5169 * greeks.strike
5170 * (-greeks.interest_rate * greeks.expiry_in_years).exp()
5171 * greeks.itm_prob
5172 * 0.01
5173 } else {
5174 -greeks.expiry_in_years * greeks.price * 0.01
5175 };
5176
5177 assert_eq!(greeks.cost_of_carry, 0.0);
5178 assert!((greeks.rho - expected).abs() < 1e-12);
5179 }
5180
5181 #[rstest]
5182 fn test_portfolio_greeks_propagates_invalid_valuation() {
5183 let (calculator, position, now) = portfolio_option_calculator();
5184 let id = position.instrument_id;
5185 calculator
5186 .cache
5187 .borrow_mut()
5188 .add_quote(QuoteTick::new(
5189 id,
5190 Price::from("200.00"),
5191 Price::from("201.00"),
5192 Quantity::from(100),
5193 Quantity::from(100),
5194 now,
5195 now,
5196 ))
5197 .unwrap();
5198 let result = PortfolioGreeksParams::builder()
5199 .instrument_id(id)
5200 .build()
5201 .calculate(&calculator)
5202 .unwrap_err();
5203 assert_eq!(
5204 result.downcast_ref::<OptionValuationError>(),
5205 Some(&OptionValuationError::ImpliedVolatilityUnavailable)
5206 );
5207 }
5208
5209 #[rstest]
5210 #[case::unshocked(0.0)]
5211 #[case::shocked(1.0)]
5212 fn test_instrument_greeks_cached_valuation_does_not_publish(#[case] spot_shock: f64) {
5213 msgbus::set_message_bus(Rc::new(RefCell::new(msgbus::MessageBus::default())));
5214 let (calculator, id, now) = option_calculator();
5215 let base = InstrumentGreeksParams::builder()
5216 .instrument_id(id)
5217 .ts_event(now)
5218 .cache_greeks(true)
5219 .build()
5220 .calculate(&calculator)
5221 .unwrap();
5222 let observed = Rc::new(RefCell::new(Vec::new()));
5223 let captured = Rc::clone(&observed);
5224 calculator.subscribe_greeks(
5225 "AAPL",
5226 Some(move |data: &GreeksData| {
5227 captured.borrow_mut().push(data.clone());
5228 }),
5229 );
5230
5231 let result = InstrumentGreeksParams::builder()
5232 .instrument_id(id)
5233 .ts_event(UnixNanos::from(now.as_u64() + 1_000_000_000))
5234 .use_cached_greeks(true)
5235 .spot_shock(spot_shock)
5236 .cache_greeks(true)
5237 .publish_greeks(true)
5238 .build()
5239 .calculate(&calculator)
5240 .unwrap();
5241 let cached = calculator.cache.borrow().greeks(&id).unwrap();
5242
5243 assert!(observed.borrow().is_empty());
5244 assert_greeks_eq(&cached, &base);
5245 assert_eq!(result.underlying_price, base.underlying_price + spot_shock);
5246 assert_eq!(result.ts_event, base.ts_event);
5247 }
5248
5249 #[rstest]
5250 fn test_instrument_greeks_rejects_invalid_shocks_without_publishing() {
5251 let (calculator, id, now) = option_calculator();
5252 let observed = Rc::new(RefCell::new(Vec::new()));
5253 let captured = Rc::clone(&observed);
5254 calculator.subscribe_greeks(
5255 "AAPL",
5256 Some(move |data: &GreeksData| {
5257 captured.borrow_mut().push(data.clone());
5258 }),
5259 );
5260
5261 let mut params = InstrumentGreeksParams::builder()
5262 .instrument_id(id)
5263 .ts_event(now)
5264 .spot_shock(-200.0)
5265 .cache_greeks(true)
5266 .publish_greeks(true)
5267 .build();
5268 let e = params.calculate(&calculator).unwrap_err();
5269 assert_eq!(
5270 e.downcast_ref::<OptionValuationError>(),
5271 Some(&OptionValuationError::InvalidInput("underlying price"))
5272 );
5273 assert_eq!(observed.borrow().len(), 0);
5274 assert!(calculator.cache.borrow().greeks(&id).is_none());
5275 params.spot_shock = 1.0;
5276 let result = params.calculate(&calculator).unwrap();
5277 let cached = calculator.cache.borrow().greeks(&id).unwrap();
5278 let observed = observed.borrow();
5279 assert_eq!(observed.len(), 1);
5280 assert_greeks_eq(&observed[0], &cached);
5281 assert_eq!(cached.underlying_price, 150.05);
5282 assert_eq!(result.underlying_price, 151.05);
5283 }
5284
5285 #[rstest]
5286 #[case("probability below zero", -0.1)]
5287 #[case("probability above one", 1.1)]
5288 #[case("price", -1.0)]
5289 #[case("multiplier", f64::NAN)]
5290 #[case("quantity", f64::INFINITY)]
5291 fn test_instrument_greeks_rejects_invalid_cached_results(
5292 #[case] field: &str,
5293 #[case] value: f64,
5294 ) {
5295 let (calculator, id, now) = option_calculator();
5296 let mut data = InstrumentGreeksParams::builder()
5297 .instrument_id(id)
5298 .ts_event(now)
5299 .build()
5300 .calculate(&calculator)
5301 .unwrap();
5302
5303 match field {
5304 "probability below zero" | "probability above one" => data.itm_prob = value,
5305 "price" => data.price = value,
5306 "multiplier" => data.multiplier = value,
5307 "quantity" => data.quantity = value,
5308 _ => unreachable!(),
5309 }
5310
5311 calculator.cache.borrow_mut().add_greeks(data).unwrap();
5312 let error = InstrumentGreeksParams::builder()
5313 .instrument_id(id)
5314 .ts_event(now)
5315 .use_cached_greeks(true)
5316 .build()
5317 .calculate(&calculator)
5318 .unwrap_err();
5319 assert_eq!(
5320 error.downcast_ref::<OptionValuationError>(),
5321 Some(&OptionValuationError::InvalidResult)
5322 );
5323 }
5324
5325 #[rstest]
5326 fn test_instrument_greeks_rejects_invalid_cached_volatility_seed() {
5327 let (calculator, id, now) = option_calculator();
5328 let mut data = InstrumentGreeksParams::builder()
5329 .instrument_id(id)
5330 .ts_event(now)
5331 .build()
5332 .calculate(&calculator)
5333 .unwrap();
5334 data.vol = f64::NAN;
5335 calculator.cache.borrow_mut().add_greeks(data).unwrap();
5336 let error = InstrumentGreeksParams::builder()
5337 .instrument_id(id)
5338 .ts_event(now)
5339 .update_vol(true)
5340 .build()
5341 .calculate(&calculator)
5342 .unwrap_err();
5343 assert_eq!(
5344 error.downcast_ref::<OptionValuationError>(),
5345 Some(&OptionValuationError::InvalidInput("volatility"))
5346 );
5347 }
5348
5349 #[rstest]
5350 fn test_portfolio_greeks_rejects_overflow_after_scaling() {
5351 let (calculator, position, now) = portfolio_option_calculator();
5352 let id = position.instrument_id;
5353 let mut data = InstrumentGreeksParams::builder()
5354 .instrument_id(id)
5355 .ts_event(now)
5356 .build()
5357 .calculate(&calculator)
5358 .unwrap();
5359 data.multiplier = f64::MAX;
5360 calculator.cache.borrow_mut().add_greeks(data).unwrap();
5361 let error = PortfolioGreeksParams::builder()
5362 .instrument_id(id)
5363 .use_cached_greeks(true)
5364 .build()
5365 .calculate(&calculator)
5366 .unwrap_err();
5367 assert_eq!(
5368 error.downcast_ref::<OptionValuationError>(),
5369 Some(&OptionValuationError::InvalidResult)
5370 );
5371 }
5372
5373 #[rstest]
5374 fn test_instrument_greeks_projects_extreme_decimal_price() {
5375 let (calculator, mut position, now) = portfolio_option_calculator();
5376 let id = position.instrument_id;
5377 position.avg_px_open = Decimal::MAX;
5378 let observed = Rc::new(RefCell::new(Vec::new()));
5379 let captured = Rc::clone(&observed);
5380 calculator.subscribe_greeks(
5381 "AAPL",
5382 Some(move |data: &GreeksData| {
5383 captured.borrow_mut().push(data.clone());
5384 }),
5385 );
5386
5387 let result = InstrumentGreeksParams::builder()
5388 .instrument_id(id)
5389 .ts_event(now)
5390 .position(position)
5391 .spot_shock(1.0)
5392 .cache_greeks(true)
5393 .publish_greeks(true)
5394 .build()
5395 .calculate(&calculator)
5396 .unwrap();
5397 assert_eq!(result.pnl, result.price - Decimal::MAX.as_f64());
5398 let cached = calculator.cache.borrow().greeks(&id).unwrap();
5399 assert_eq!(observed.borrow().len(), 1);
5400 assert_greeks_eq(&observed.borrow()[0], &cached);
5401 }
5402
5403 #[rstest]
5404 fn test_instrument_greeks_rho_uses_dividend_curve_source() {
5405 let (calculator, id, now) = option_calculator();
5406 calculator
5407 .cache
5408 .borrow_mut()
5409 .add_yield_curve(YieldCurve::new(
5410 "AAPL.OPRA".to_string(),
5411 vec![0.0],
5412 vec![0.05],
5413 now,
5414 now,
5415 ))
5416 .unwrap();
5417 let result = InstrumentGreeksParams::builder()
5418 .instrument_id(id)
5419 .ts_event(now)
5420 .flat_interest_rate(0.05)
5421 .build()
5422 .calculate(&calculator)
5423 .unwrap();
5424 assert_eq!(result.cost_of_carry, 0.0);
5425 let reference = result.expiry_in_years
5426 * result.strike
5427 * (-result.interest_rate * result.expiry_in_years).exp()
5428 * result.itm_prob
5429 * 0.01;
5430 assert!((result.rho - reference).abs() < 1e-12);
5431 }
5432
5433 #[rstest]
5434 fn test_instrument_greeks_shocked_cache_uses_current_carry_sensitivity() {
5435 let (calculator, id, now) = option_calculator();
5436 let base = InstrumentGreeksParams::builder()
5437 .instrument_id(id)
5438 .ts_event(now)
5439 .cache_greeks(true)
5440 .build()
5441 .calculate(&calculator)
5442 .unwrap();
5443 let mut params = InstrumentGreeksParams::builder()
5444 .instrument_id(id)
5445 .ts_event(now)
5446 .use_cached_greeks(true)
5447 .spot_shock(1.0)
5448 .build();
5449 params.flat_dividend_yield = Some(base.interest_rate);
5450 let result = params.calculate(&calculator).unwrap();
5451 assert_eq!(result.cost_of_carry, 0.0);
5452 let reference = result.expiry_in_years
5453 * result.strike
5454 * (-result.interest_rate * result.expiry_in_years).exp()
5455 * result.itm_prob
5456 * 0.01;
5457 assert!((result.rho - reference).abs() < 1e-12);
5458 assert_eq!(base.rho, -base.expiry_in_years * base.price * 0.01);
5459 }
5460
5461 #[rstest]
5462 #[case(false)]
5463 #[case(true)]
5464 fn test_instrument_greeks_rejects_nonfinite_transformed_sensitivities(
5465 #[case] beta_weight: bool,
5466 ) {
5467 let (calculator, id, now) = option_calculator();
5468 let mut params = InstrumentGreeksParams::builder()
5469 .instrument_id(id)
5470 .ts_event(now)
5471 .cache_greeks(true)
5472 .build();
5473
5474 if beta_weight {
5475 let underlying = InstrumentId::from("AAPL.OPRA");
5476 params.index_instrument_id = Some(underlying);
5477 params.beta_weights = Some(HashMap::from([(underlying, f64::NAN)]));
5478 } else {
5479 params.vega_time_weight_base = Some(-1);
5480 }
5481
5482 let e = params.calculate(&calculator).unwrap_err();
5483 assert_eq!(
5484 e.downcast_ref::<OptionValuationError>(),
5485 Some(&OptionValuationError::InvalidResult)
5486 );
5487 assert!(calculator.cache.borrow().greeks(&id).is_none());
5488 }
5489}