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