1use std::{cell::RefCell, collections::HashMap, fmt::Debug, rc::Rc};
19
20use ahash::AHashMap;
21use nautilus_core::UnixNanos;
22use nautilus_model::{
23 data::greeks::{
24 GreeksData, OptionGreekValues, PortfolioGreeks, black_scholes_greeks, imply_vol_and_greeks,
25 refine_vol_and_greeks,
26 },
27 enums::{AssetClass, InstrumentClass, OptionKind, PositionSide, PriceType},
28 identifiers::{InstrumentId, StrategyId, Venue},
29 instruments::{Instrument, any::InstrumentAny},
30 position::Position,
31 types::Price,
32};
33
34use crate::{
35 actor::DataActorNative,
36 cache::{Cache, refs::PositionRef},
37 clock::Clock,
38 msgbus,
39 msgbus::TypedHandler,
40};
41
42pub type GreeksFilter = Box<dyn Fn(&GreeksData) -> bool>;
44
45#[derive(Clone)]
47pub enum GreeksFilterCallback {
48 Function(fn(&GreeksData) -> bool),
50 Closure(std::rc::Rc<dyn Fn(&GreeksData) -> bool>),
52}
53
54impl GreeksFilterCallback {
55 pub fn from_fn(f: fn(&GreeksData) -> bool) -> Self {
57 Self::Function(f)
58 }
59
60 pub fn from_closure<F>(f: F) -> Self
62 where
63 F: Fn(&GreeksData) -> bool + 'static,
64 {
65 Self::Closure(std::rc::Rc::new(f))
66 }
67
68 pub fn call(&self, data: &GreeksData) -> bool {
70 match self {
71 Self::Function(f) => f(data),
72 Self::Closure(f) => f(data),
73 }
74 }
75
76 pub fn to_greeks_filter(self) -> GreeksFilter {
78 match self {
79 Self::Function(f) => Box::new(f),
80 Self::Closure(f) => {
81 let f_clone = f.clone();
82 Box::new(move |data| f_clone(data))
83 }
84 }
85 }
86}
87
88impl Debug for GreeksFilterCallback {
89 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
90 match self {
91 Self::Function(_) => f.write_str("GreeksFilterCallback::Function"),
92 Self::Closure(_) => f.write_str("GreeksFilterCallback::Closure"),
93 }
94 }
95}
96
97#[derive(Debug, bon::Builder)]
99pub struct InstrumentGreeksParams {
100 pub instrument_id: InstrumentId,
102 #[builder(default = 0.0425)]
104 pub flat_interest_rate: f64,
105 pub flat_dividend_yield: Option<f64>,
107 #[builder(default = 0.0)]
109 pub spot_shock: f64,
110 #[builder(default = 0.0)]
112 pub vol_shock: f64,
113 #[builder(default = 0.0)]
115 pub time_to_expiry_shock: f64,
116 #[builder(default = false)]
118 pub use_cached_greeks: bool,
119 #[builder(default = false)]
121 pub update_vol: bool,
122 #[builder(default = false)]
124 pub cache_greeks: bool,
125 #[builder(default = false)]
127 pub publish_greeks: bool,
128 pub ts_event: Option<UnixNanos>,
130 pub position: Option<Position>,
132 #[builder(default = false)]
134 pub percent_greeks: bool,
135 pub index_instrument_id: Option<InstrumentId>,
137 pub beta_weights: Option<HashMap<InstrumentId, f64>>,
139 pub vega_time_weight_base: Option<i32>,
141 pub vol_index_instrument_id: Option<InstrumentId>,
143 pub vol_beta_weights: Option<HashMap<InstrumentId, f64>>,
145}
146
147impl InstrumentGreeksParams {
148 pub fn calculate(&self, calculator: &GreeksCalculator) -> anyhow::Result<GreeksData> {
154 calculator.instrument_greeks(
155 self.instrument_id,
156 Some(self.flat_interest_rate),
157 self.flat_dividend_yield,
158 Some(self.spot_shock),
159 Some(self.vol_shock),
160 Some(self.time_to_expiry_shock),
161 Some(self.use_cached_greeks),
162 Some(self.update_vol),
163 Some(self.cache_greeks),
164 Some(self.publish_greeks),
165 self.ts_event,
166 self.position.clone(),
167 Some(self.percent_greeks),
168 self.index_instrument_id,
169 self.beta_weights.as_ref(),
170 self.vega_time_weight_base,
171 self.vol_index_instrument_id,
172 self.vol_beta_weights.as_ref(),
173 )
174 }
175}
176
177#[derive(bon::Builder)]
179pub struct PortfolioGreeksParams {
180 pub underlyings: Option<Vec<String>>,
182 pub venue: Option<Venue>,
184 pub instrument_id: Option<InstrumentId>,
186 pub strategy_id: Option<StrategyId>,
188 pub side: Option<PositionSide>,
190 #[builder(default = 0.0425)]
192 pub flat_interest_rate: f64,
193 pub flat_dividend_yield: Option<f64>,
195 #[builder(default = 0.0)]
197 pub spot_shock: f64,
198 #[builder(default = 0.0)]
200 pub vol_shock: f64,
201 #[builder(default = 0.0)]
203 pub time_to_expiry_shock: f64,
204 #[builder(default = false)]
206 pub use_cached_greeks: bool,
207 #[builder(default = false)]
209 pub update_vol: bool,
210 #[builder(default = false)]
212 pub cache_greeks: bool,
213 #[builder(default = false)]
215 pub publish_greeks: bool,
216 #[builder(default = false)]
218 pub percent_greeks: bool,
219 pub index_instrument_id: Option<InstrumentId>,
221 pub beta_weights: Option<HashMap<InstrumentId, f64>>,
223 pub greeks_filter: Option<GreeksFilterCallback>,
225 pub vega_time_weight_base: Option<i32>,
227 pub vol_index_instrument_id: Option<InstrumentId>,
229 pub vol_beta_weights: Option<HashMap<InstrumentId, f64>>,
231}
232
233impl Debug for PortfolioGreeksParams {
234 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
235 f.debug_struct(stringify!(PortfolioGreeksParams))
236 .field("underlyings", &self.underlyings)
237 .field("venue", &self.venue)
238 .field("instrument_id", &self.instrument_id)
239 .field("strategy_id", &self.strategy_id)
240 .field("side", &self.side)
241 .field("flat_interest_rate", &self.flat_interest_rate)
242 .field("flat_dividend_yield", &self.flat_dividend_yield)
243 .field("spot_shock", &self.spot_shock)
244 .field("vol_shock", &self.vol_shock)
245 .field("time_to_expiry_shock", &self.time_to_expiry_shock)
246 .field("use_cached_greeks", &self.use_cached_greeks)
247 .field("update_vol", &self.update_vol)
248 .field("cache_greeks", &self.cache_greeks)
249 .field("publish_greeks", &self.publish_greeks)
250 .field("percent_greeks", &self.percent_greeks)
251 .field("index_instrument_id", &self.index_instrument_id)
252 .field("beta_weights", &self.beta_weights)
253 .field("greeks_filter", &self.greeks_filter)
254 .field("vega_time_weight_base", &self.vega_time_weight_base)
255 .field("vol_index_instrument_id", &self.vol_index_instrument_id)
256 .field("vol_beta_weights", &self.vol_beta_weights)
257 .finish()
258 }
259}
260
261impl PortfolioGreeksParams {
262 pub fn calculate(&self, calculator: &GreeksCalculator) -> anyhow::Result<PortfolioGreeks> {
268 let greeks_filter = self
269 .greeks_filter
270 .as_ref()
271 .map(|f| f.clone().to_greeks_filter());
272
273 calculator.portfolio_greeks(
274 self.underlyings.as_deref(),
275 self.venue,
276 self.instrument_id,
277 self.strategy_id,
278 self.side,
279 Some(self.flat_interest_rate),
280 self.flat_dividend_yield,
281 Some(self.spot_shock),
282 Some(self.vol_shock),
283 Some(self.time_to_expiry_shock),
284 Some(self.use_cached_greeks),
285 Some(self.update_vol),
286 Some(self.cache_greeks),
287 Some(self.publish_greeks),
288 Some(self.percent_greeks),
289 self.index_instrument_id,
290 self.beta_weights.as_ref(),
291 greeks_filter.as_ref(),
292 self.vega_time_weight_base,
293 self.vol_index_instrument_id,
294 self.vol_beta_weights.as_ref(),
295 )
296 }
297}
298
299#[allow(dead_code)]
314#[derive(Debug)]
315pub struct GreeksCalculator {
316 cache: Rc<RefCell<Cache>>,
317 clock: Rc<RefCell<dyn Clock>>,
318 cached_futures_spreads: RefCell<AHashMap<InstrumentId, (InstrumentId, Price)>>,
319}
320
321impl GreeksCalculator {
322 pub fn new(cache: Rc<RefCell<Cache>>, clock: Rc<RefCell<dyn Clock>>) -> Self {
324 Self {
325 cache,
326 clock,
327 cached_futures_spreads: RefCell::new(AHashMap::new()),
328 }
329 }
330
331 pub fn from_actor(actor: &impl DataActorNative) -> Self {
337 Self::new(actor.cache_rc(), actor.clock_rc())
338 }
339
340 #[expect(clippy::too_many_arguments)]
352 pub fn instrument_greeks(
353 &self,
354 instrument_id: InstrumentId,
355 flat_interest_rate: Option<f64>,
356 flat_dividend_yield: Option<f64>,
357 spot_shock: Option<f64>,
358 vol_shock: Option<f64>,
359 time_to_expiry_shock: Option<f64>,
360 use_cached_greeks: Option<bool>,
361 update_vol: Option<bool>,
362 cache_greeks: Option<bool>,
363 publish_greeks: Option<bool>,
364 ts_event: Option<UnixNanos>,
365 position: Option<Position>,
366 percent_greeks: Option<bool>,
367 index_instrument_id: Option<InstrumentId>,
368 beta_weights: Option<&HashMap<InstrumentId, f64>>,
369 vega_time_weight_base: Option<i32>,
370 vol_index_instrument_id: Option<InstrumentId>,
371 vol_beta_weights: Option<&HashMap<InstrumentId, f64>>,
372 ) -> anyhow::Result<GreeksData> {
373 let flat_interest_rate = flat_interest_rate.unwrap_or(0.0425);
375 let spot_shock = spot_shock.unwrap_or(0.0);
376 let vol_shock = vol_shock.unwrap_or(0.0);
377 let time_to_expiry_shock = time_to_expiry_shock.unwrap_or(0.0);
378 let use_cached_greeks = use_cached_greeks.unwrap_or(false);
379 let update_vol = update_vol.unwrap_or(false);
380 let cache_greeks = cache_greeks.unwrap_or(false);
381 let publish_greeks = publish_greeks.unwrap_or(false);
382 let ts_event = ts_event.unwrap_or_default();
383 let percent_greeks = percent_greeks.unwrap_or(false);
384
385 let instrument = {
386 let cache = self.cache.borrow();
387 cache.try_instrument(&instrument_id)?.clone()
388 };
389
390 if instrument.instrument_class() != InstrumentClass::Option {
391 return self.calculate_non_option_greeks(
392 &instrument,
393 instrument_id,
394 spot_shock,
395 ts_event,
396 position,
397 percent_greeks,
398 index_instrument_id,
399 beta_weights,
400 );
401 }
402
403 let underlying_instrument_id =
404 Self::resolve_underlying_instrument_id(&instrument, instrument_id)?;
405 let mut greeks_data = self.calculate_option_greeks(
406 &instrument,
407 instrument_id,
408 underlying_instrument_id,
409 flat_interest_rate,
410 flat_dividend_yield,
411 use_cached_greeks,
412 update_vol,
413 cache_greeks,
414 publish_greeks,
415 ts_event,
416 percent_greeks,
417 index_instrument_id,
418 beta_weights,
419 vega_time_weight_base,
420 vol_index_instrument_id,
421 vol_beta_weights,
422 )?;
423
424 if spot_shock != 0.0 || vol_shock != 0.0 || time_to_expiry_shock != 0.0 {
425 greeks_data = self.apply_option_greeks_shocks(
426 &greeks_data,
427 underlying_instrument_id,
428 spot_shock,
429 vol_shock,
430 time_to_expiry_shock,
431 percent_greeks,
432 index_instrument_id,
433 beta_weights,
434 vega_time_weight_base,
435 vol_index_instrument_id,
436 vol_beta_weights,
437 )?;
438 }
439
440 if let Some(pos) = position {
441 greeks_data.pnl = greeks_data.price - pos.avg_px_open;
442 }
443
444 Ok(greeks_data)
445 }
446
447 fn resolve_underlying_instrument_id(
448 instrument: &InstrumentAny,
449 instrument_id: InstrumentId,
450 ) -> anyhow::Result<InstrumentId> {
451 let Some(underlying) = instrument.underlying() else {
452 anyhow::bail!("Instrument {instrument_id} has no underlying identifier");
453 };
454
455 Ok(InstrumentId::from(format!(
456 "{}.{}",
457 underlying, instrument_id.venue
458 )))
459 }
460
461 #[expect(clippy::too_many_arguments)]
462 fn calculate_non_option_greeks(
463 &self,
464 instrument: &InstrumentAny,
465 instrument_id: InstrumentId,
466 spot_shock: f64,
467 ts_event: UnixNanos,
468 position: Option<Position>,
469 percent_greeks: bool,
470 index_instrument_id: Option<InstrumentId>,
471 beta_weights: Option<&HashMap<InstrumentId, f64>>,
472 ) -> anyhow::Result<GreeksData> {
473 let multiplier = instrument.multiplier();
474 let underlying_instrument_id = instrument.id();
475 let underlying_price = self
476 .get_price(&underlying_instrument_id)
477 .ok_or_else(|| anyhow::anyhow!("No price available for {underlying_instrument_id}"))?;
478 let (delta, _, _) = self.modify_greeks(
479 1.0,
480 0.0,
481 underlying_instrument_id,
482 underlying_price + spot_shock,
483 underlying_price,
484 percent_greeks,
485 index_instrument_id,
486 beta_weights,
487 0.0,
488 0.0,
489 0,
490 None,
491 0.0,
492 None,
493 None,
494 None,
495 None,
496 )?;
497 let mut greeks_data =
498 GreeksData::from_delta(instrument_id, delta, multiplier.as_f64(), ts_event);
499
500 if let Some(pos) = position {
501 greeks_data.pnl = (underlying_price + spot_shock) - pos.avg_px_open;
502 greeks_data.price = greeks_data.pnl;
503 }
504
505 Ok(greeks_data)
506 }
507
508 #[expect(clippy::too_many_arguments)]
509 fn calculate_option_greeks(
510 &self,
511 instrument: &InstrumentAny,
512 instrument_id: InstrumentId,
513 underlying_instrument_id: InstrumentId,
514 flat_interest_rate: f64,
515 flat_dividend_yield: Option<f64>,
516 use_cached_greeks: bool,
517 update_vol: bool,
518 cache_greeks: bool,
519 publish_greeks: bool,
520 ts_event: UnixNanos,
521 percent_greeks: bool,
522 index_instrument_id: Option<InstrumentId>,
523 beta_weights: Option<&HashMap<InstrumentId, f64>>,
524 vega_time_weight_base: Option<i32>,
525 vol_index_instrument_id: Option<InstrumentId>,
526 vol_beta_weights: Option<&HashMap<InstrumentId, f64>>,
527 ) -> anyhow::Result<GreeksData> {
528 if use_cached_greeks {
529 let cache = self.cache.borrow();
530 if let Some(cached_greeks) = cache.greeks(&instrument_id) {
531 return Ok(cached_greeks);
532 }
533 }
534
535 let utc_now_ns = if ts_event == UnixNanos::default() {
536 self.clock.borrow().timestamp_ns()
537 } else {
538 ts_event
539 };
540 let utc_now = utc_now_ns.to_datetime_utc();
541 let expiry_utc = instrument
542 .expiration_ns()
543 .map(|ns| ns.to_datetime_utc())
544 .unwrap_or_default();
545 let expiry_int = expiry_utc
546 .format("%Y%m%d")
547 .to_string()
548 .parse::<i32>()
549 .unwrap_or(0);
550 let raw_days = (expiry_utc - utc_now).num_days();
551 let expiry_in_days = raw_days.max(1) as i32;
552 let expiry_in_years = expiry_in_days as f64 / 365.25;
553 let currency = instrument.quote_currency().code.to_string();
554
555 let cache = self.cache.borrow();
556 let yield_curve = cache.yield_curve(¤cy);
557 let interest_rate = match yield_curve {
558 Some(yield_curve) => yield_curve(expiry_in_years),
559 None => flat_interest_rate,
560 };
561 let dividend_curve = cache.yield_curve(&underlying_instrument_id.to_string());
562 drop(cache);
563
564 let mut cost_of_carry = 0.0;
565
566 if let Some(dividend_curve) = dividend_curve {
567 cost_of_carry = interest_rate - dividend_curve(expiry_in_years);
568 } else if let Some(div_yield) = flat_dividend_yield {
569 cost_of_carry = interest_rate - div_yield;
570 }
571
572 let multiplier = instrument.multiplier();
573 let is_call = instrument.option_kind().unwrap_or(OptionKind::Call) == OptionKind::Call;
574 let strike = instrument.strike_price().unwrap_or_default().as_f64();
575 let option_price = self
576 .get_price(&instrument_id)
577 .ok_or_else(|| anyhow::anyhow!("No price available for {instrument_id}"))?;
578 let underlying_price = self.get_underlying_price(&underlying_instrument_id)?;
579
580 if let Some(vol_index_id) = vol_index_instrument_id {
581 self.get_price(&vol_index_id)
582 .ok_or_else(|| anyhow::anyhow!("No price available for {vol_index_id}"))?;
583 }
584 let greeks = if update_vol {
585 let cached_greeks = self.cache.borrow().greeks(&instrument_id);
586 match cached_greeks {
587 Some(cached_greeks) => refine_vol_and_greeks(
588 underlying_price,
589 interest_rate,
590 cost_of_carry,
591 is_call,
592 strike,
593 expiry_in_years,
594 option_price,
595 cached_greeks.vol,
596 ),
597 None => imply_vol_and_greeks(
598 underlying_price,
599 interest_rate,
600 cost_of_carry,
601 is_call,
602 strike,
603 expiry_in_years,
604 option_price,
605 ),
606 }
607 } else {
608 imply_vol_and_greeks(
609 underlying_price,
610 interest_rate,
611 cost_of_carry,
612 is_call,
613 strike,
614 expiry_in_years,
615 option_price,
616 )
617 };
618 let (delta, gamma, vega) = self.modify_greeks(
619 greeks.delta,
620 greeks.gamma,
621 underlying_instrument_id,
622 underlying_price,
623 underlying_price,
624 percent_greeks,
625 index_instrument_id,
626 beta_weights,
627 greeks.vega,
628 greeks.vol,
629 expiry_in_days,
630 vega_time_weight_base,
631 greeks.vol,
632 vol_index_instrument_id,
633 vol_beta_weights,
634 None,
635 None,
636 )?;
637 let greeks_data = GreeksData::new(
638 utc_now_ns,
639 utc_now_ns,
640 instrument_id,
641 is_call,
642 strike,
643 expiry_int,
644 expiry_in_days,
645 expiry_in_years,
646 multiplier.as_f64(),
647 1.0,
648 underlying_price,
649 interest_rate,
650 cost_of_carry,
651 greeks.vol,
652 0.0,
653 greeks.price,
654 OptionGreekValues {
655 delta,
656 gamma,
657 vega,
658 theta: greeks.theta,
659 rho: 0.0,
660 },
661 greeks.itm_prob,
662 );
663
664 if cache_greeks {
665 let mut cache = self.cache.borrow_mut();
666 cache.add_greeks(greeks_data.clone()).unwrap_or_default();
667 }
668
669 if publish_greeks {
670 let topic = format!(
671 "data.GreeksData.instrument_id={}",
672 instrument_id.symbol.as_str()
673 )
674 .into();
675 msgbus::publish_greeks(topic, &greeks_data);
676 }
677
678 Ok(greeks_data)
679 }
680
681 #[expect(clippy::too_many_arguments)]
682 fn apply_option_greeks_shocks(
683 &self,
684 greeks_data: &GreeksData,
685 underlying_instrument_id: InstrumentId,
686 spot_shock: f64,
687 vol_shock: f64,
688 time_to_expiry_shock: f64,
689 percent_greeks: bool,
690 index_instrument_id: Option<InstrumentId>,
691 beta_weights: Option<&HashMap<InstrumentId, f64>>,
692 vega_time_weight_base: Option<i32>,
693 vol_index_instrument_id: Option<InstrumentId>,
694 vol_beta_weights: Option<&HashMap<InstrumentId, f64>>,
695 ) -> anyhow::Result<GreeksData> {
696 let underlying_price = greeks_data.underlying_price;
697 let shocked_underlying_price = underlying_price + spot_shock;
698 let shocked_vol = greeks_data.vol + vol_shock;
699 let shocked_time_to_expiry = greeks_data.expiry_in_years - time_to_expiry_shock;
700 let shocked_expiry_in_days = (shocked_time_to_expiry * 365.25) as i32;
701
702 let greeks = black_scholes_greeks(
703 shocked_underlying_price,
704 greeks_data.interest_rate,
705 greeks_data.cost_of_carry,
706 shocked_vol,
707 greeks_data.is_call,
708 greeks_data.strike,
709 shocked_time_to_expiry,
710 );
711 let (delta, gamma, vega) = self.modify_greeks(
712 greeks.delta,
713 greeks.gamma,
714 underlying_instrument_id,
715 shocked_underlying_price,
716 underlying_price,
717 percent_greeks,
718 index_instrument_id,
719 beta_weights,
720 greeks.vega,
721 shocked_vol,
722 shocked_expiry_in_days,
723 vega_time_weight_base,
724 greeks_data.vol,
725 vol_index_instrument_id,
726 vol_beta_weights,
727 None,
728 None,
729 )?;
730 Ok(GreeksData::new(
731 greeks_data.ts_event,
732 greeks_data.ts_event,
733 greeks_data.instrument_id,
734 greeks_data.is_call,
735 greeks_data.strike,
736 greeks_data.expiry,
737 shocked_expiry_in_days,
738 shocked_time_to_expiry,
739 greeks_data.multiplier,
740 greeks_data.quantity,
741 shocked_underlying_price,
742 greeks_data.interest_rate,
743 greeks_data.cost_of_carry,
744 shocked_vol,
745 0.0,
746 greeks.price,
747 OptionGreekValues {
748 delta,
749 gamma,
750 vega,
751 theta: greeks.theta,
752 rho: 0.0,
753 },
754 greeks.itm_prob,
755 ))
756 }
757
758 fn get_underlying_price(&self, underlying_instrument_id: &InstrumentId) -> anyhow::Result<f64> {
759 if let Some(underlying_price) = self.get_price(underlying_instrument_id) {
760 return Ok(underlying_price);
761 }
762
763 let is_future_or_absent = {
766 let cache = self.cache.borrow();
767 cache
768 .instrument(underlying_instrument_id)
769 .is_none_or(|inst| inst.instrument_class() == InstrumentClass::Future)
770 };
771
772 if is_future_or_absent
773 && let Some(underlying_price) =
774 self.get_cached_futures_spread_price(*underlying_instrument_id)
775 {
776 return Ok(underlying_price.as_f64());
777 }
778
779 anyhow::bail!("No price available for {underlying_instrument_id}")
780 }
781
782 #[expect(clippy::too_many_arguments)]
808 pub fn modify_greeks(
809 &self,
810 delta_input: f64,
811 gamma_input: f64,
812 underlying_instrument_id: InstrumentId,
813 underlying_price: f64,
814 unshocked_underlying_price: f64,
815 percent_greeks: bool,
816 index_instrument_id: Option<InstrumentId>,
817 beta_weights: Option<&HashMap<InstrumentId, f64>>,
818 vega_input: f64,
819 vol: f64,
820 expiry_in_days: i32,
821 vega_time_weight_base: Option<i32>,
822 unshocked_vol: f64,
823 vol_index_instrument_id: Option<InstrumentId>,
824 vol_beta_weights: Option<&HashMap<InstrumentId, f64>>,
825 index_price: Option<f64>,
826 vol_index_price: Option<f64>,
827 ) -> anyhow::Result<(f64, f64, f64)> {
828 let mut delta = delta_input;
829 let mut gamma = gamma_input;
830 let mut vega = vega_input;
831
832 let mut used_index_price = index_price
833 .or_else(|| index_instrument_id.and_then(|index_id| self.get_price(&index_id)));
834 let mut used_index_vol = vol_index_price;
835 if used_index_vol.is_none()
836 && let Some(vol_index_id) = vol_index_instrument_id
837 {
838 used_index_vol = Some(
839 self.get_price(&vol_index_id)
840 .ok_or_else(|| anyhow::anyhow!("No price available for {vol_index_id}"))?,
841 );
842 }
843
844 if used_index_price.is_some() {
845 let mut beta = 1.0;
846
847 if let Some(weights) = beta_weights
848 && let Some(&weight) = weights.get(&underlying_instrument_id)
849 {
850 beta = weight;
851 }
852
853 if let Some(ref mut idx_price) = used_index_price {
854 #[expect(clippy::float_cmp, reason = "exact-equality baseline check")]
855 if underlying_price != unshocked_underlying_price {
856 *idx_price += 1.0 / beta
857 * (*idx_price / unshocked_underlying_price)
858 * (underlying_price - unshocked_underlying_price);
859 }
860
861 let delta_multiplier = beta * underlying_price / *idx_price;
862 delta *= delta_multiplier;
863 gamma *= delta_multiplier.powi(2);
864 }
865 }
866
867 if used_index_vol.is_some() {
868 let mut vega_beta = 1.0;
869 let used_vol = if unshocked_vol == 0.0 {
870 vol
871 } else {
872 unshocked_vol
873 };
874
875 if let Some(weights) = vol_beta_weights
876 && let Some(&weight) = weights.get(&underlying_instrument_id)
877 {
878 vega_beta = weight;
879 }
880
881 if let Some(ref mut idx_vol) = used_index_vol {
882 *idx_vol *= 0.01;
883
884 #[expect(clippy::float_cmp, reason = "exact-equality baseline check")]
885 if vol != used_vol && used_vol != 0.0 {
886 *idx_vol += 1.0 / vega_beta * (*idx_vol / used_vol) * (vol - used_vol);
887 }
888
889 if *idx_vol != 0.0 {
890 vega *= vega_beta * vol / *idx_vol;
891 }
892 }
893 }
894
895 if percent_greeks {
896 if let Some(idx_price) = used_index_price {
897 delta *= idx_price / 100.0;
898 gamma *= (idx_price / 100.0).powi(2);
899 } else {
900 delta *= underlying_price / 100.0;
901 gamma *= (underlying_price / 100.0).powi(2);
902 }
903
904 if let Some(idx_vol) = used_index_vol {
905 vega *= idx_vol / 100.0;
906 } else {
907 vega *= vol / 100.0;
908 }
909 }
910
911 if let Some(time_base) = vega_time_weight_base
913 && expiry_in_days > 0
914 {
915 let time_weight = (time_base as f64 / expiry_in_days as f64).sqrt();
916 vega *= time_weight;
917 }
918
919 Ok((delta, gamma, vega))
920 }
921
922 #[expect(clippy::too_many_arguments)]
936 pub fn portfolio_greeks(
937 &self,
938 underlyings: Option<&[String]>,
939 venue: Option<Venue>,
940 instrument_id: Option<InstrumentId>,
941 strategy_id: Option<StrategyId>,
942 side: Option<PositionSide>,
943 flat_interest_rate: Option<f64>,
944 flat_dividend_yield: Option<f64>,
945 spot_shock: Option<f64>,
946 vol_shock: Option<f64>,
947 time_to_expiry_shock: Option<f64>,
948 use_cached_greeks: Option<bool>,
949 update_vol: Option<bool>,
950 cache_greeks: Option<bool>,
951 publish_greeks: Option<bool>,
952 percent_greeks: Option<bool>,
953 index_instrument_id: Option<InstrumentId>,
954 beta_weights: Option<&HashMap<InstrumentId, f64>>,
955 greeks_filter: Option<&GreeksFilter>,
956 vega_time_weight_base: Option<i32>,
957 vol_index_instrument_id: Option<InstrumentId>,
958 vol_beta_weights: Option<&HashMap<InstrumentId, f64>>,
959 ) -> anyhow::Result<PortfolioGreeks> {
960 let ts_event = self.clock.borrow().timestamp_ns();
961 let mut portfolio_greeks =
962 PortfolioGreeks::new(ts_event, ts_event, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0);
963
964 let flat_interest_rate = flat_interest_rate.unwrap_or(0.0425);
966 let spot_shock = spot_shock.unwrap_or(0.0);
967 let vol_shock = vol_shock.unwrap_or(0.0);
968 let time_to_expiry_shock = time_to_expiry_shock.unwrap_or(0.0);
969 let use_cached_greeks = use_cached_greeks.unwrap_or(false);
970 let update_vol = update_vol.unwrap_or(false);
971 let cache_greeks = cache_greeks.unwrap_or(false);
972 let publish_greeks = publish_greeks.unwrap_or(false);
973 let percent_greeks = percent_greeks.unwrap_or(false);
974 let side = side.unwrap_or(PositionSide::NoPositionSide);
975
976 let cache = self.cache.borrow();
977 let open_positions = cache.positions(
978 venue.as_ref(),
979 instrument_id.as_ref(),
980 strategy_id.as_ref(),
981 None, Some(side),
983 );
984 let open_positions: Vec<Position> =
985 open_positions.iter().map(PositionRef::cloned).collect();
986
987 for position in open_positions {
988 let position_instrument_id = position.instrument_id;
989
990 if let Some(underlyings_list) = underlyings {
991 let mut skip_position = true;
992
993 for underlying in underlyings_list {
994 if position_instrument_id
995 .symbol
996 .as_str()
997 .starts_with(underlying)
998 {
999 skip_position = false;
1000 break;
1001 }
1002 }
1003
1004 if skip_position {
1005 continue;
1006 }
1007 }
1008
1009 let quantity = position.signed_qty;
1010 let instrument_greeks = self.instrument_greeks(
1011 position_instrument_id,
1012 Some(flat_interest_rate),
1013 flat_dividend_yield,
1014 Some(spot_shock),
1015 Some(vol_shock),
1016 Some(time_to_expiry_shock),
1017 Some(use_cached_greeks),
1018 Some(update_vol),
1019 Some(cache_greeks),
1020 Some(publish_greeks),
1021 Some(ts_event),
1022 Some(position),
1023 Some(percent_greeks),
1024 index_instrument_id,
1025 beta_weights,
1026 vega_time_weight_base,
1027 vol_index_instrument_id,
1028 vol_beta_weights,
1029 )?;
1030 let position_greeks = quantity * &instrument_greeks;
1031
1032 if greeks_filter.is_none_or(|filter| filter(&position_greeks)) {
1034 portfolio_greeks = portfolio_greeks + PortfolioGreeks::from(position_greeks);
1035 }
1036 }
1037
1038 Ok(portfolio_greeks)
1039 }
1040
1041 pub fn cache_futures_spread(
1047 &self,
1048 call_instrument_id: InstrumentId,
1049 put_instrument_id: InstrumentId,
1050 futures_instrument_id: InstrumentId,
1051 ) -> anyhow::Result<Price> {
1052 let cache = self.cache.borrow();
1053 let call_instrument = cache.instrument(&call_instrument_id).cloned();
1054 let put_instrument = cache.instrument(&put_instrument_id).cloned();
1055 let reference_future_instrument = cache.instrument(&futures_instrument_id).cloned();
1056 drop(cache);
1057
1058 let Some(call_instrument) = call_instrument else {
1059 anyhow::bail!(
1060 "Cannot cache futures spread: missing option instrument {call_instrument_id}"
1061 );
1062 };
1063 let Some(put_instrument) = put_instrument else {
1064 anyhow::bail!(
1065 "Cannot cache futures spread: missing option instrument {put_instrument_id}"
1066 );
1067 };
1068 let Some(reference_future_instrument) = reference_future_instrument else {
1069 anyhow::bail!(
1070 "Cannot cache futures spread: no reference futures instrument for {futures_instrument_id}"
1071 );
1072 };
1073
1074 if call_instrument.instrument_class() != InstrumentClass::Option
1075 || put_instrument.instrument_class() != InstrumentClass::Option
1076 {
1077 anyhow::bail!(
1078 "Cannot cache futures spread: non-option instruments provided call_instrument_id={call_instrument_id} put_instrument_id={put_instrument_id}"
1079 );
1080 }
1081
1082 if call_instrument.option_kind() != Some(OptionKind::Call)
1083 || put_instrument.option_kind() != Some(OptionKind::Put)
1084 {
1085 anyhow::bail!(
1086 "Cannot cache futures spread: expected call/put pair call_instrument_id={call_instrument_id} put_instrument_id={put_instrument_id}"
1087 );
1088 }
1089
1090 let Some(call_underlying) = call_instrument.underlying() else {
1091 anyhow::bail!(
1092 "Cannot cache futures spread: missing call underlying for {call_instrument_id}"
1093 );
1094 };
1095 let Some(put_underlying) = put_instrument.underlying() else {
1096 anyhow::bail!(
1097 "Cannot cache futures spread: missing put underlying for {put_instrument_id}"
1098 );
1099 };
1100
1101 if call_underlying != put_underlying {
1102 anyhow::bail!(
1103 "Cannot cache futures spread: option underlyings differ call_instrument_id={call_instrument_id} put_instrument_id={put_instrument_id}"
1104 );
1105 }
1106
1107 if call_instrument.strike_price() != put_instrument.strike_price() {
1108 anyhow::bail!(
1109 "Cannot cache futures spread: strike prices differ call_instrument_id={call_instrument_id} put_instrument_id={put_instrument_id}"
1110 );
1111 }
1112
1113 if call_instrument.expiration_ns() != put_instrument.expiration_ns() {
1114 anyhow::bail!(
1115 "Cannot cache futures spread: expiration dates differ call_instrument_id={call_instrument_id} put_instrument_id={put_instrument_id}"
1116 );
1117 }
1118
1119 let reference_future_price = self.get_price_object(&futures_instrument_id).ok_or_else(|| {
1120 anyhow::anyhow!(
1121 "Cannot cache futures spread: no reference futures price for {futures_instrument_id}"
1122 )
1123 })?;
1124 let call_price = self.get_price(&call_instrument_id).ok_or_else(|| {
1125 anyhow::anyhow!(
1126 "Cannot cache futures spread: missing option price for {call_instrument_id}"
1127 )
1128 })?;
1129 let put_price = self.get_price(&put_instrument_id).ok_or_else(|| {
1130 anyhow::anyhow!(
1131 "Cannot cache futures spread: missing option price for {put_instrument_id}"
1132 )
1133 })?;
1134
1135 let underlying_instrument_id =
1136 InstrumentId::from(format!("{call_underlying}.{}", call_instrument_id.venue));
1137
1138 {
1140 let cache = self.cache.borrow();
1141 if let Some(underlying) = cache.instrument(&underlying_instrument_id)
1142 && underlying.instrument_class() != InstrumentClass::Future
1143 {
1144 anyhow::bail!(
1145 "Cannot cache futures spread: underlying {underlying_instrument_id} is not a futures contract"
1146 );
1147 }
1148 }
1149
1150 let implied_future_price =
1151 self.calculate_implied_future_price(&call_instrument, call_price, put_price);
1152 let spread = implied_future_price - reference_future_price.as_f64();
1153 let spread_price = reference_future_instrument.make_price(spread);
1154
1155 self.cached_futures_spreads.borrow_mut().insert(
1156 underlying_instrument_id,
1157 (futures_instrument_id, spread_price),
1158 );
1159
1160 Ok(reference_future_price + spread_price)
1161 }
1162
1163 fn calculate_implied_future_price(
1164 &self,
1165 call_instrument: &InstrumentAny,
1166 call_price: f64,
1167 put_price: f64,
1168 ) -> f64 {
1169 let expiry_utc = call_instrument
1170 .expiration_ns()
1171 .map(|ns| ns.to_datetime_utc())
1172 .unwrap_or_default();
1173 let expiry_in_days = (expiry_utc - self.clock.borrow().timestamp_ns().to_datetime_utc())
1174 .num_days()
1175 .max(1) as i32;
1176 let expiry_in_years = expiry_in_days as f64 / 365.25;
1177 let currency = call_instrument.quote_currency().code.to_string();
1178 let interest_rate = self
1179 .cache
1180 .borrow()
1181 .yield_curve(¤cy)
1182 .map_or(0.0425, |yield_curve| yield_curve(expiry_in_years));
1183 let strike = call_instrument.strike_price().unwrap_or_default().as_f64();
1184
1185 strike + (interest_rate * expiry_in_years).exp() * (call_price - put_price)
1186 }
1187
1188 #[must_use]
1190 pub fn get_cached_futures_spread_price(
1191 &self,
1192 underlying_instrument_id: InstrumentId,
1193 ) -> Option<Price> {
1194 let (futures_instrument_id, spread) = self
1195 .cached_futures_spreads
1196 .borrow()
1197 .get(&underlying_instrument_id)
1198 .copied()?;
1199 let reference_future_price = self.get_price_object(&futures_instrument_id)?;
1200
1201 Some(reference_future_price + spread)
1202 }
1203
1204 fn get_price_object(&self, instrument_id: &InstrumentId) -> Option<Price> {
1205 let cache = self.cache.borrow();
1206 let price = cache
1207 .price(instrument_id, PriceType::Mid)
1208 .or_else(|| cache.price(instrument_id, PriceType::Last));
1209
1210 if let Some(instrument) = cache.instrument(instrument_id)
1214 && instrument.asset_class() == AssetClass::Index
1215 {
1216 if instrument.instrument_class() == InstrumentClass::Future && price.is_some() {
1217 return price;
1218 }
1219
1220 if let Some(index_price) = cache.index_price(instrument_id) {
1221 return Some(index_price.value);
1222 }
1223 }
1224
1225 price
1226 }
1227
1228 fn get_price(&self, instrument_id: &InstrumentId) -> Option<f64> {
1229 self.get_price_object(instrument_id)
1230 .map(|price| price.as_f64())
1231 }
1232
1233 pub fn subscribe_greeks<F>(&self, underlying: &str, handler: Option<F>)
1237 where
1238 F: Fn(&GreeksData) + 'static,
1239 {
1240 let pattern = format!("data.GreeksData.instrument_id={underlying}*").into();
1241
1242 if let Some(custom_handler) = handler {
1243 let typed_handler = TypedHandler::from(custom_handler);
1244 msgbus::subscribe_greeks(pattern, typed_handler, None);
1245 } else {
1246 let cache_ref = self.cache.clone();
1247 let typed_handler = TypedHandler::from(move |greeks: &GreeksData| {
1248 let mut cache = cache_ref.borrow_mut();
1249 cache.add_greeks(greeks.clone()).unwrap_or_default();
1250 });
1251 msgbus::subscribe_greeks(pattern, typed_handler, None);
1252 }
1253 }
1254}
1255
1256#[cfg(test)]
1257mod tests {
1258 use std::{cell::RefCell, collections::HashMap, rc::Rc};
1259
1260 use chrono::{TimeZone, Utc};
1261 use nautilus_model::{
1262 data::{IndexPriceUpdate, QuoteTick},
1263 enums::{AssetClass, OptionKind, PositionSide},
1264 identifiers::{InstrumentId, StrategyId, Symbol, Venue},
1265 instruments::{Equity, FuturesContract, OptionContract, any::InstrumentAny},
1266 types::{Currency, Price, Quantity},
1267 };
1268 use rstest::rstest;
1269 use ustr::Ustr;
1270
1271 use super::*;
1272 use crate::{cache::Cache, clock::TestClock};
1273
1274 fn create_test_calculator() -> GreeksCalculator {
1275 let cache = Rc::new(RefCell::new(Cache::new(None, None)));
1276 let clock = Rc::new(RefCell::new(TestClock::new()));
1277 GreeksCalculator::new(cache, clock)
1278 }
1279
1280 #[rstest]
1281 fn test_greeks_calculator_creation() {
1282 let calculator = create_test_calculator();
1283 assert!(format!("{calculator:?}").contains("GreeksCalculator"));
1285 }
1286
1287 #[rstest]
1288 fn test_greeks_calculator_debug() {
1289 let calculator = create_test_calculator();
1290 let debug_str = format!("{calculator:?}");
1292 assert!(debug_str.contains("GreeksCalculator"));
1293 }
1294
1295 #[rstest]
1296 fn test_greeks_calculator_has_python_bindings() {
1297 let calculator = create_test_calculator();
1300 assert!(format!("{calculator:?}").contains("GreeksCalculator"));
1303 }
1304
1305 #[rstest]
1306 fn test_instrument_greeks_params_builder_default() {
1307 let instrument_id = InstrumentId::from("AAPL.NASDAQ");
1308
1309 let params = InstrumentGreeksParams::builder()
1310 .instrument_id(instrument_id)
1311 .build();
1312
1313 assert_eq!(params.instrument_id, instrument_id);
1314 assert_eq!(params.flat_interest_rate, 0.0425);
1315 assert_eq!(params.flat_dividend_yield, None);
1316 assert_eq!(params.spot_shock, 0.0);
1317 assert_eq!(params.vol_shock, 0.0);
1318 assert_eq!(params.time_to_expiry_shock, 0.0);
1319 assert!(!params.use_cached_greeks);
1320 assert!(!params.cache_greeks);
1321 assert!(!params.publish_greeks);
1322 assert_eq!(params.ts_event, None);
1323 assert_eq!(params.position, None);
1324 assert!(!params.percent_greeks);
1325 assert_eq!(params.index_instrument_id, None);
1326 assert_eq!(params.beta_weights, None);
1327 assert_eq!(params.vol_index_instrument_id, None);
1328 assert_eq!(params.vol_beta_weights, None);
1329 }
1330
1331 #[rstest]
1332 fn test_instrument_greeks_params_builder_custom_values() {
1333 let instrument_id = InstrumentId::from("AAPL.NASDAQ");
1334 let index_id = InstrumentId::from("SPY.NASDAQ");
1335 let vol_index_id = InstrumentId::from("VIX.XCBF");
1336 let mut beta_weights = HashMap::new();
1337 beta_weights.insert(instrument_id, 1.2);
1338 let mut vol_beta_weights = HashMap::new();
1339 vol_beta_weights.insert(instrument_id, 0.8);
1340
1341 let params = InstrumentGreeksParams::builder()
1342 .instrument_id(instrument_id)
1343 .flat_interest_rate(0.05)
1344 .flat_dividend_yield(0.02)
1345 .spot_shock(0.01)
1346 .vol_shock(0.05)
1347 .time_to_expiry_shock(0.1)
1348 .use_cached_greeks(true)
1349 .cache_greeks(true)
1350 .publish_greeks(true)
1351 .percent_greeks(true)
1352 .index_instrument_id(index_id)
1353 .beta_weights(beta_weights.clone())
1354 .vol_index_instrument_id(vol_index_id)
1355 .vol_beta_weights(vol_beta_weights.clone())
1356 .build();
1357
1358 assert_eq!(params.instrument_id, instrument_id);
1359 assert_eq!(params.flat_interest_rate, 0.05);
1360 assert_eq!(params.flat_dividend_yield, Some(0.02));
1361 assert_eq!(params.spot_shock, 0.01);
1362 assert_eq!(params.vol_shock, 0.05);
1363 assert_eq!(params.time_to_expiry_shock, 0.1);
1364 assert!(params.use_cached_greeks);
1365 assert!(params.cache_greeks);
1366 assert!(params.publish_greeks);
1367 assert!(params.percent_greeks);
1368 assert_eq!(params.index_instrument_id, Some(index_id));
1369 assert_eq!(params.beta_weights, Some(beta_weights));
1370 assert_eq!(params.vol_index_instrument_id, Some(vol_index_id));
1371 assert_eq!(params.vol_beta_weights, Some(vol_beta_weights));
1372 }
1373
1374 #[rstest]
1375 fn test_instrument_greeks_params_debug() {
1376 let instrument_id = InstrumentId::from("AAPL.NASDAQ");
1377
1378 let params = InstrumentGreeksParams::builder()
1379 .instrument_id(instrument_id)
1380 .build();
1381
1382 let debug_str = format!("{params:?}");
1383 assert!(debug_str.contains("InstrumentGreeksParams"));
1384 assert!(debug_str.contains("AAPL.NASDAQ"));
1385 }
1386
1387 #[rstest]
1388 fn test_portfolio_greeks_params_builder_default() {
1389 let params = PortfolioGreeksParams::builder().build();
1390
1391 assert_eq!(params.underlyings, None);
1392 assert_eq!(params.venue, None);
1393 assert_eq!(params.instrument_id, None);
1394 assert_eq!(params.strategy_id, None);
1395 assert_eq!(params.side, None);
1396 assert_eq!(params.flat_interest_rate, 0.0425);
1397 assert_eq!(params.flat_dividend_yield, None);
1398 assert_eq!(params.spot_shock, 0.0);
1399 assert_eq!(params.vol_shock, 0.0);
1400 assert_eq!(params.time_to_expiry_shock, 0.0);
1401 assert!(!params.use_cached_greeks);
1402 assert!(!params.cache_greeks);
1403 assert!(!params.publish_greeks);
1404 assert!(!params.percent_greeks);
1405 assert_eq!(params.index_instrument_id, None);
1406 assert_eq!(params.beta_weights, None);
1407 assert_eq!(params.vol_index_instrument_id, None);
1408 assert_eq!(params.vol_beta_weights, None);
1409 }
1410
1411 #[rstest]
1412 fn test_portfolio_greeks_params_builder_custom_values() {
1413 let venue = Venue::from("NASDAQ");
1414 let instrument_id = InstrumentId::from("AAPL.NASDAQ");
1415 let strategy_id = StrategyId::from("test-strategy");
1416 let index_id = InstrumentId::from("SPY.NASDAQ");
1417 let vol_index_id = InstrumentId::from("VIX.XCBF");
1418 let underlyings = vec!["AAPL".to_string(), "MSFT".to_string()];
1419 let mut beta_weights = HashMap::new();
1420 beta_weights.insert(instrument_id, 1.2);
1421 let mut vol_beta_weights = HashMap::new();
1422 vol_beta_weights.insert(instrument_id, 0.8);
1423
1424 let params = PortfolioGreeksParams::builder()
1425 .underlyings(underlyings.clone())
1426 .venue(venue)
1427 .instrument_id(instrument_id)
1428 .strategy_id(strategy_id)
1429 .side(PositionSide::Long)
1430 .flat_interest_rate(0.05)
1431 .flat_dividend_yield(0.02)
1432 .spot_shock(0.01)
1433 .vol_shock(0.05)
1434 .time_to_expiry_shock(0.1)
1435 .use_cached_greeks(true)
1436 .cache_greeks(true)
1437 .publish_greeks(true)
1438 .percent_greeks(true)
1439 .index_instrument_id(index_id)
1440 .beta_weights(beta_weights.clone())
1441 .vol_index_instrument_id(vol_index_id)
1442 .vol_beta_weights(vol_beta_weights.clone())
1443 .build();
1444
1445 assert_eq!(params.underlyings, Some(underlyings));
1446 assert_eq!(params.venue, Some(venue));
1447 assert_eq!(params.instrument_id, Some(instrument_id));
1448 assert_eq!(params.strategy_id, Some(strategy_id));
1449 assert_eq!(params.side, Some(PositionSide::Long));
1450 assert_eq!(params.flat_interest_rate, 0.05);
1451 assert_eq!(params.flat_dividend_yield, Some(0.02));
1452 assert_eq!(params.spot_shock, 0.01);
1453 assert_eq!(params.vol_shock, 0.05);
1454 assert_eq!(params.time_to_expiry_shock, 0.1);
1455 assert!(params.use_cached_greeks);
1456 assert!(params.cache_greeks);
1457 assert!(params.publish_greeks);
1458 assert!(params.percent_greeks);
1459 assert_eq!(params.index_instrument_id, Some(index_id));
1460 assert_eq!(params.beta_weights, Some(beta_weights));
1461 assert_eq!(params.vol_index_instrument_id, Some(vol_index_id));
1462 assert_eq!(params.vol_beta_weights, Some(vol_beta_weights));
1463 }
1464
1465 #[rstest]
1466 fn test_portfolio_greeks_params_debug() {
1467 let venue = Venue::from("NASDAQ");
1468
1469 let params = PortfolioGreeksParams::builder().venue(venue).build();
1470
1471 let debug_str = format!("{params:?}");
1472 assert!(debug_str.contains("PortfolioGreeksParams"));
1473 assert!(debug_str.contains("NASDAQ"));
1474 }
1475
1476 #[rstest]
1477 fn test_portfolio_greeks_params_builder_fluent_api() {
1478 let instrument_id = InstrumentId::from("AAPL.NASDAQ");
1479
1480 let params = PortfolioGreeksParams::builder()
1481 .instrument_id(instrument_id)
1482 .flat_interest_rate(0.05)
1483 .spot_shock(0.01)
1484 .percent_greeks(true)
1485 .build();
1486
1487 assert_eq!(params.instrument_id, Some(instrument_id));
1488 assert_eq!(params.flat_interest_rate, 0.05);
1489 assert_eq!(params.spot_shock, 0.01);
1490 assert!(params.percent_greeks);
1491 }
1492
1493 #[rstest]
1494 fn test_instrument_greeks_params_builder_fluent_chaining() {
1495 let instrument_id = InstrumentId::from("TSLA.NASDAQ");
1496
1497 let params = InstrumentGreeksParams::builder()
1499 .instrument_id(instrument_id)
1500 .flat_interest_rate(0.03)
1501 .spot_shock(0.02)
1502 .vol_shock(0.1)
1503 .use_cached_greeks(true)
1504 .percent_greeks(true)
1505 .build();
1506
1507 assert_eq!(params.instrument_id, instrument_id);
1508 assert_eq!(params.flat_interest_rate, 0.03);
1509 assert_eq!(params.spot_shock, 0.02);
1510 assert_eq!(params.vol_shock, 0.1);
1511 assert!(params.use_cached_greeks);
1512 assert!(params.percent_greeks);
1513 }
1514
1515 #[rstest]
1516 fn test_portfolio_greeks_params_builder_with_underlyings() {
1517 let underlyings = vec!["AAPL".to_string(), "MSFT".to_string(), "GOOGL".to_string()];
1518
1519 let params = PortfolioGreeksParams::builder()
1520 .underlyings(underlyings.clone())
1521 .flat_interest_rate(0.04)
1522 .build();
1523
1524 assert_eq!(params.underlyings, Some(underlyings));
1525 assert_eq!(params.flat_interest_rate, 0.04);
1526 }
1527
1528 #[rstest]
1529 fn test_builders_with_empty_beta_weights() {
1530 let instrument_id = InstrumentId::from("NVDA.NASDAQ");
1531 let empty_beta_weights = HashMap::new();
1532
1533 let instrument_params = InstrumentGreeksParams::builder()
1534 .instrument_id(instrument_id)
1535 .beta_weights(empty_beta_weights.clone())
1536 .vol_beta_weights(empty_beta_weights.clone())
1537 .build();
1538
1539 let portfolio_params = PortfolioGreeksParams::builder()
1540 .beta_weights(empty_beta_weights.clone())
1541 .vol_beta_weights(empty_beta_weights.clone())
1542 .build();
1543
1544 assert_eq!(
1545 instrument_params.beta_weights,
1546 Some(empty_beta_weights.clone())
1547 );
1548 assert_eq!(portfolio_params.beta_weights, Some(empty_beta_weights));
1549 assert_eq!(instrument_params.vol_beta_weights, Some(HashMap::new()));
1550 assert_eq!(portfolio_params.vol_beta_weights, Some(HashMap::new()));
1551 }
1552
1553 #[rstest]
1554 fn test_builders_with_all_shocks() {
1555 let instrument_id = InstrumentId::from("AMD.NASDAQ");
1556
1557 let instrument_params = InstrumentGreeksParams::builder()
1558 .instrument_id(instrument_id)
1559 .spot_shock(0.05)
1560 .vol_shock(0.1)
1561 .time_to_expiry_shock(0.01)
1562 .build();
1563
1564 let portfolio_params = PortfolioGreeksParams::builder()
1565 .spot_shock(0.05)
1566 .vol_shock(0.1)
1567 .time_to_expiry_shock(0.01)
1568 .build();
1569
1570 assert_eq!(instrument_params.spot_shock, 0.05);
1571 assert_eq!(instrument_params.vol_shock, 0.1);
1572 assert_eq!(instrument_params.time_to_expiry_shock, 0.01);
1573
1574 assert_eq!(portfolio_params.spot_shock, 0.05);
1575 assert_eq!(portfolio_params.vol_shock, 0.1);
1576 assert_eq!(portfolio_params.time_to_expiry_shock, 0.01);
1577 }
1578
1579 #[rstest]
1580 fn test_builders_with_all_boolean_flags() {
1581 let instrument_id = InstrumentId::from("META.NASDAQ");
1582
1583 let instrument_params = InstrumentGreeksParams::builder()
1584 .instrument_id(instrument_id)
1585 .use_cached_greeks(true)
1586 .cache_greeks(true)
1587 .publish_greeks(true)
1588 .percent_greeks(true)
1589 .build();
1590
1591 let portfolio_params = PortfolioGreeksParams::builder()
1592 .use_cached_greeks(true)
1593 .cache_greeks(true)
1594 .publish_greeks(true)
1595 .percent_greeks(true)
1596 .build();
1597
1598 assert!(instrument_params.use_cached_greeks);
1599 assert!(instrument_params.cache_greeks);
1600 assert!(instrument_params.publish_greeks);
1601 assert!(instrument_params.percent_greeks);
1602
1603 assert!(portfolio_params.use_cached_greeks);
1604 assert!(portfolio_params.cache_greeks);
1605 assert!(portfolio_params.publish_greeks);
1606 assert!(portfolio_params.percent_greeks);
1607 }
1608
1609 #[rstest]
1610 fn test_greeks_filter_callback_function() {
1611 fn filter_positive_delta(data: &GreeksData) -> bool {
1613 data.delta > 0.0
1614 }
1615
1616 let filter = GreeksFilterCallback::from_fn(filter_positive_delta);
1617
1618 let greeks_data = GreeksData::from_delta(
1620 InstrumentId::from("TEST.NASDAQ"),
1621 0.5,
1622 1.0,
1623 UnixNanos::default(),
1624 );
1625
1626 assert!(filter.call(&greeks_data));
1627
1628 let debug_str = format!("{filter:?}");
1630 assert!(debug_str.contains("GreeksFilterCallback::Function"));
1631 }
1632
1633 #[rstest]
1634 fn test_greeks_filter_callback_closure() {
1635 let min_delta = 0.3;
1637 let filter =
1638 GreeksFilterCallback::from_closure(move |data: &GreeksData| data.delta > min_delta);
1639
1640 let greeks_data = GreeksData::from_delta(
1642 InstrumentId::from("TEST.NASDAQ"),
1643 0.5,
1644 1.0,
1645 UnixNanos::default(),
1646 );
1647
1648 assert!(filter.call(&greeks_data));
1649
1650 let debug_str = format!("{filter:?}");
1652 assert!(debug_str.contains("GreeksFilterCallback::Closure"));
1653 }
1654
1655 #[rstest]
1656 fn test_greeks_filter_callback_clone() {
1657 fn filter_fn(data: &GreeksData) -> bool {
1658 data.delta > 0.0
1659 }
1660
1661 let filter1 = GreeksFilterCallback::from_fn(filter_fn);
1662 let filter2 = filter1.clone();
1663
1664 let greeks_data = GreeksData::from_delta(
1665 InstrumentId::from("TEST.NASDAQ"),
1666 0.5,
1667 1.0,
1668 UnixNanos::default(),
1669 );
1670
1671 assert!(filter1.call(&greeks_data));
1672 assert!(filter2.call(&greeks_data));
1673 }
1674
1675 #[rstest]
1676 fn test_portfolio_greeks_params_with_filter() {
1677 fn filter_high_delta(data: &GreeksData) -> bool {
1678 data.delta.abs() > 0.1
1679 }
1680
1681 let filter = GreeksFilterCallback::from_fn(filter_high_delta);
1682
1683 let params = PortfolioGreeksParams::builder()
1684 .greeks_filter(filter)
1685 .flat_interest_rate(0.05)
1686 .build();
1687
1688 assert!(params.greeks_filter.is_some());
1689 assert_eq!(params.flat_interest_rate, 0.05);
1690
1691 let greeks_data = GreeksData::from_delta(
1693 InstrumentId::from("TEST.NASDAQ"),
1694 0.5,
1695 1.0,
1696 UnixNanos::default(),
1697 );
1698
1699 let filter_ref = params.greeks_filter.as_ref().unwrap();
1700 assert!(filter_ref.call(&greeks_data));
1701 }
1702
1703 #[rstest]
1704 fn test_portfolio_greeks_params_with_closure_filter() {
1705 let min_gamma = 0.01;
1706 let filter =
1707 GreeksFilterCallback::from_closure(move |data: &GreeksData| data.gamma > min_gamma);
1708
1709 let params = PortfolioGreeksParams::builder()
1710 .greeks_filter(filter)
1711 .build();
1712
1713 assert!(params.greeks_filter.is_some());
1714
1715 let debug_str = format!("{params:?}");
1717 assert!(debug_str.contains("greeks_filter"));
1718 }
1719
1720 #[rstest]
1721 fn test_greeks_filter_to_greeks_filter_conversion() {
1722 fn filter_fn(data: &GreeksData) -> bool {
1723 data.delta > 0.0
1724 }
1725
1726 let callback = GreeksFilterCallback::from_fn(filter_fn);
1727 let greeks_filter = callback.to_greeks_filter();
1728
1729 let greeks_data = GreeksData::from_delta(
1730 InstrumentId::from("TEST.NASDAQ"),
1731 0.5,
1732 1.0,
1733 UnixNanos::default(),
1734 );
1735
1736 assert!(greeks_filter(&greeks_data));
1737 }
1738
1739 fn option_with_expiration(instrument_id: &str, expiration_ns: UnixNanos) -> OptionContract {
1740 let activation_ns = UnixNanos::from(Utc.with_ymd_and_hms(2021, 9, 17, 0, 0, 0).unwrap());
1741 OptionContract::new(
1742 InstrumentId::from(instrument_id),
1743 Symbol::from("AAPL211217C00150000"),
1744 AssetClass::Equity,
1745 Some(Ustr::from("GMNI")),
1746 Ustr::from("AAPL"),
1747 OptionKind::Call,
1748 Price::from("149.0"),
1749 Currency::from("USD"),
1750 activation_ns,
1751 expiration_ns,
1752 2,
1753 Price::from("0.01"),
1754 Quantity::from(100),
1755 Quantity::from(1),
1756 None,
1757 None,
1758 None,
1759 None,
1760 None,
1761 None,
1762 None,
1763 None,
1764 None,
1765 None,
1766 UnixNanos::default(),
1767 UnixNanos::default(),
1768 )
1769 }
1770
1771 fn equity_aapl_opra() -> Equity {
1772 Equity::new(
1773 InstrumentId::from("AAPL.OPRA"),
1774 Symbol::from("AAPL"),
1775 Some(Ustr::from("US0378331005")),
1776 Currency::from("USD"),
1777 2,
1778 Price::from("0.01"),
1779 None,
1780 None,
1781 None,
1782 None,
1783 None,
1784 None,
1785 None,
1786 None,
1787 None,
1788 None,
1789 None,
1790 UnixNanos::default(),
1791 UnixNanos::default(),
1792 )
1793 }
1794
1795 #[rstest]
1796 fn test_resolve_underlying_instrument_id_errors_without_underlying() {
1797 let instrument = InstrumentAny::Equity(equity_aapl_opra());
1798 let error = GreeksCalculator::resolve_underlying_instrument_id(
1799 &instrument,
1800 InstrumentId::from("AAPL.OPRA"),
1801 )
1802 .unwrap_err();
1803
1804 assert_eq!(
1805 error.to_string(),
1806 "Instrument AAPL.OPRA has no underlying identifier"
1807 );
1808 }
1809
1810 fn future_with_expiration(
1811 instrument_id: &str,
1812 underlying: &str,
1813 expiration_ns: UnixNanos,
1814 ) -> FuturesContract {
1815 FuturesContract::new(
1816 InstrumentId::from(instrument_id),
1817 Symbol::from(underlying),
1818 AssetClass::Index,
1819 Some(Ustr::from("XCME")),
1820 Ustr::from(underlying),
1821 UnixNanos::default(),
1822 expiration_ns,
1823 Currency::from("USD"),
1824 2,
1825 Price::from("0.25"),
1826 Quantity::from(1),
1827 Quantity::from(1),
1828 None,
1829 None,
1830 None,
1831 None,
1832 None,
1833 None,
1834 None,
1835 None,
1836 None,
1837 None,
1838 UnixNanos::default(),
1839 UnixNanos::default(),
1840 )
1841 }
1842
1843 fn future_option_with_expiration(
1844 instrument_id: &str,
1845 raw_symbol: &str,
1846 underlying: &str,
1847 option_kind: OptionKind,
1848 strike: &str,
1849 expiration_ns: UnixNanos,
1850 ) -> OptionContract {
1851 OptionContract::new(
1852 InstrumentId::from(instrument_id),
1853 Symbol::from(raw_symbol),
1854 AssetClass::Index,
1855 Some(Ustr::from("XCME")),
1856 Ustr::from(underlying),
1857 option_kind,
1858 Price::from(strike),
1859 Currency::from("USD"),
1860 UnixNanos::default(),
1861 expiration_ns,
1862 2,
1863 Price::from("0.01"),
1864 Quantity::from(1),
1865 Quantity::from(1),
1866 None,
1867 None,
1868 None,
1869 None,
1870 None,
1871 None,
1872 None,
1873 None,
1874 None,
1875 None,
1876 UnixNanos::default(),
1877 UnixNanos::default(),
1878 )
1879 }
1880
1881 fn setup_cache_with_option_and_quotes(
1882 option: OptionContract,
1883 underlying_id: InstrumentId,
1884 now_ns: UnixNanos,
1885 ) -> Rc<RefCell<Cache>> {
1886 let option_id = option.id();
1887 let cache = Rc::new(RefCell::new(Cache::new(None, None)));
1888 cache
1889 .borrow_mut()
1890 .add_instrument(InstrumentAny::OptionContract(option))
1891 .unwrap();
1892 cache
1893 .borrow_mut()
1894 .add_instrument(InstrumentAny::Equity(equity_aapl_opra()))
1895 .unwrap();
1896 let option_quote = QuoteTick::new(
1897 option_id,
1898 Price::from("10.50"),
1899 Price::from("10.60"),
1900 Quantity::from(100),
1901 Quantity::from(100),
1902 now_ns,
1903 now_ns,
1904 );
1905 let underlying_quote = QuoteTick::new(
1906 underlying_id,
1907 Price::from("150.00"),
1908 Price::from("150.10"),
1909 Quantity::from(100),
1910 Quantity::from(100),
1911 now_ns,
1912 now_ns,
1913 );
1914 cache.borrow_mut().add_quote(option_quote).unwrap();
1915 cache.borrow_mut().add_quote(underlying_quote).unwrap();
1916 cache
1917 }
1918
1919 #[rstest]
1920 fn test_expiry_in_days_multi_day_unchanged() {
1921 let now = Utc.with_ymd_and_hms(2025, 3, 8, 12, 0, 0).unwrap();
1922 let expiry = now + chrono::Duration::days(30);
1923 let now_ns = UnixNanos::from(now);
1924 let expiry_ns = UnixNanos::from(expiry);
1925 let option = option_with_expiration("AAPL250417C00150000.OPRA", expiry_ns);
1926 let option_id = option.id();
1927 let underlying_id = InstrumentId::from("AAPL.OPRA");
1928 let cache = setup_cache_with_option_and_quotes(option, underlying_id, now_ns);
1929 let clock = Rc::new(RefCell::new(TestClock::new()));
1930 let calculator = GreeksCalculator::new(cache, clock);
1931
1932 let greeks = calculator
1933 .instrument_greeks(
1934 option_id,
1935 None,
1936 None,
1937 None,
1938 None,
1939 None,
1940 None,
1941 None,
1942 None,
1943 None,
1944 Some(now_ns),
1945 None,
1946 None,
1947 None,
1948 None,
1949 None,
1950 None,
1951 None,
1952 )
1953 .unwrap();
1954
1955 assert_eq!(greeks.expiry_in_days, 30);
1956 assert!((greeks.expiry_in_years - 30.0 / 365.25).abs() < 1e-9);
1957 }
1958
1959 #[rstest]
1960 fn test_expiry_in_days_same_day_clamped_to_one() {
1961 let now = Utc.with_ymd_and_hms(2025, 3, 8, 12, 0, 0).unwrap();
1962 let expiry_same_day = Utc.with_ymd_and_hms(2025, 3, 8, 18, 0, 0).unwrap();
1963 let now_ns = UnixNanos::from(now);
1964 let expiry_ns = UnixNanos::from(expiry_same_day);
1965 let option = option_with_expiration("AAPL250308C00150000.OPRA", expiry_ns);
1966 let option_id = option.id();
1967 let underlying_id = InstrumentId::from("AAPL.OPRA");
1968 let cache = setup_cache_with_option_and_quotes(option, underlying_id, now_ns);
1969 let clock = Rc::new(RefCell::new(TestClock::new()));
1970 let calculator = GreeksCalculator::new(cache, clock);
1971
1972 let greeks = calculator
1973 .instrument_greeks(
1974 option_id,
1975 None,
1976 None,
1977 None,
1978 None,
1979 None,
1980 None,
1981 None,
1982 None,
1983 None,
1984 Some(now_ns),
1985 None,
1986 None,
1987 None,
1988 None,
1989 None,
1990 None,
1991 None,
1992 )
1993 .unwrap();
1994
1995 assert_eq!(greeks.expiry_in_days, 1);
1996 assert!((greeks.expiry_in_years - 1.0 / 365.25).abs() < 1e-9);
1997 }
1998
1999 #[rstest]
2000 fn test_instrument_greeks_beta_weights_vega_to_vol_index() {
2001 let now = Utc.with_ymd_and_hms(2025, 3, 8, 12, 0, 0).unwrap();
2002 let expiry = now + chrono::Duration::days(30);
2003 let now_ns = UnixNanos::from(now);
2004 let expiry_ns = UnixNanos::from(expiry);
2005 let option = option_with_expiration("AAPL250417C00150000.OPRA", expiry_ns);
2006 let option_id = option.id();
2007 let underlying_id = InstrumentId::from("AAPL.OPRA");
2008 let vol_index_id = InstrumentId::from("VIX.XCBF");
2009 let cache = setup_cache_with_option_and_quotes(option, underlying_id, now_ns);
2010 cache
2011 .borrow_mut()
2012 .add_quote(QuoteTick::new(
2013 vol_index_id,
2014 Price::from("25.00"),
2015 Price::from("25.00"),
2016 Quantity::from(100),
2017 Quantity::from(100),
2018 now_ns,
2019 now_ns,
2020 ))
2021 .unwrap();
2022
2023 let clock = Rc::new(RefCell::new(TestClock::new()));
2024 let calculator = GreeksCalculator::new(cache, clock);
2025 let greeks = calculator
2026 .instrument_greeks(
2027 option_id,
2028 None,
2029 None,
2030 None,
2031 None,
2032 None,
2033 None,
2034 None,
2035 None,
2036 None,
2037 Some(now_ns),
2038 None,
2039 None,
2040 None,
2041 None,
2042 None,
2043 None,
2044 None,
2045 )
2046 .unwrap();
2047
2048 let mut vol_beta_weights = HashMap::new();
2049 vol_beta_weights.insert(underlying_id, 0.75);
2050 let vol_weighted_greeks = calculator
2051 .instrument_greeks(
2052 option_id,
2053 None,
2054 None,
2055 None,
2056 None,
2057 None,
2058 None,
2059 None,
2060 None,
2061 None,
2062 Some(now_ns),
2063 None,
2064 None,
2065 None,
2066 None,
2067 None,
2068 Some(vol_index_id),
2069 Some(&vol_beta_weights),
2070 )
2071 .unwrap();
2072
2073 let expected_vega = greeks.vega * 0.75 * (greeks.vol * 100.0) / 25.0;
2074 assert_eq!(
2075 (vol_weighted_greeks.delta * 1e12).round(),
2076 (greeks.delta * 1e12).round()
2077 );
2078 assert_eq!(
2079 (vol_weighted_greeks.gamma * 1e12).round(),
2080 (greeks.gamma * 1e12).round()
2081 );
2082 assert_eq!(
2083 (vol_weighted_greeks.vega * 1e12).round(),
2084 (expected_vega * 1e12).round()
2085 );
2086 }
2087
2088 #[rstest]
2089 fn test_instrument_greeks_errors_when_vol_index_price_missing() {
2090 let now = Utc.with_ymd_and_hms(2025, 3, 8, 12, 0, 0).unwrap();
2091 let expiry = now + chrono::Duration::days(30);
2092 let now_ns = UnixNanos::from(now);
2093 let expiry_ns = UnixNanos::from(expiry);
2094 let option = option_with_expiration("AAPL250417C00150000.OPRA", expiry_ns);
2095 let option_id = option.id();
2096 let underlying_id = InstrumentId::from("AAPL.OPRA");
2097 let vol_index_id = InstrumentId::from("VIX.XCBF");
2098 let cache = setup_cache_with_option_and_quotes(option, underlying_id, now_ns);
2099
2100 let clock = Rc::new(RefCell::new(TestClock::new()));
2101 let calculator = GreeksCalculator::new(cache, clock);
2102 let error = calculator
2103 .instrument_greeks(
2104 option_id,
2105 None,
2106 None,
2107 None,
2108 None,
2109 None,
2110 None,
2111 None,
2112 None,
2113 None,
2114 Some(now_ns),
2115 None,
2116 None,
2117 None,
2118 None,
2119 None,
2120 Some(vol_index_id),
2121 None,
2122 )
2123 .unwrap_err();
2124
2125 assert_eq!(error.to_string(), "No price available for VIX.XCBF");
2126 }
2127
2128 #[rstest]
2129 fn test_modify_greeks_errors_when_vol_index_price_missing() {
2130 let calculator = create_test_calculator();
2131 let underlying_id = InstrumentId::from("AAPL.OPRA");
2132 let vol_index_id = InstrumentId::from("VIX.XCBF");
2133
2134 let error = calculator
2135 .modify_greeks(
2136 1.0,
2137 2.0,
2138 underlying_id,
2139 150.0,
2140 150.0,
2141 false,
2142 None,
2143 None,
2144 2.0,
2145 0.30,
2146 0,
2147 None,
2148 0.0,
2149 Some(vol_index_id),
2150 None,
2151 None,
2152 None,
2153 )
2154 .unwrap_err();
2155
2156 assert_eq!(error.to_string(), "No price available for VIX.XCBF");
2157 }
2158
2159 #[rstest]
2160 fn test_modify_greeks_accepts_explicit_index_prices() {
2161 let calculator = create_test_calculator();
2162 let underlying_id = InstrumentId::from("AAPL.OPRA");
2163 let mut beta_weights = HashMap::new();
2164 beta_weights.insert(underlying_id, 0.5);
2165 let mut vol_beta_weights = HashMap::new();
2166 vol_beta_weights.insert(underlying_id, 0.75);
2167
2168 let (delta, gamma, vega) = calculator
2169 .modify_greeks(
2170 1.0,
2171 2.0,
2172 underlying_id,
2173 150.0,
2174 150.0,
2175 false,
2176 None,
2177 Some(&beta_weights),
2178 2.0,
2179 0.30,
2180 0,
2181 None,
2182 0.0,
2183 None,
2184 Some(&vol_beta_weights),
2185 Some(200.0),
2186 Some(25.0),
2187 )
2188 .unwrap();
2189
2190 assert_eq!((delta * 1e12).round(), 375_000_000_000.0);
2191 assert_eq!((gamma * 1e12).round(), 281_250_000_000.0);
2192 assert_eq!((vega * 1e12).round(), 1_800_000_000_000.0);
2193
2194 let (delta, gamma, vega) = calculator
2195 .modify_greeks(
2196 1.0,
2197 2.0,
2198 underlying_id,
2199 150.0,
2200 150.0,
2201 true,
2202 None,
2203 Some(&beta_weights),
2204 2.0,
2205 0.30,
2206 0,
2207 None,
2208 0.0,
2209 None,
2210 Some(&vol_beta_weights),
2211 Some(200.0),
2212 Some(25.0),
2213 )
2214 .unwrap();
2215
2216 assert_eq!((delta * 1e12).round(), 750_000_000_000.0);
2217 assert_eq!((gamma * 1e12).round(), 1_125_000_000_000.0);
2218 assert_eq!((vega * 1e12).round(), 4_500_000_000.0);
2219 }
2220
2221 #[rstest]
2222 fn test_instrument_greeks_errors_when_future_underlying_price_missing_without_cached_spread() {
2223 let now = Utc.with_ymd_and_hms(2024, 2, 14, 16, 0, 0).unwrap();
2224 let expiry = Utc.with_ymd_and_hms(2024, 3, 15, 16, 0, 0).unwrap();
2225 let now_ns = UnixNanos::from(now);
2226 let expiry_ns = UnixNanos::from(expiry);
2227
2228 let future = future_with_expiration("ESH4.GLBX", "ESH4", expiry_ns);
2229 let call_option = future_option_with_expiration(
2230 "ESH4C150.GLBX",
2231 "ESH4C150",
2232 "ESH4",
2233 OptionKind::Call,
2234 "150.00",
2235 expiry_ns,
2236 );
2237 let put_option = future_option_with_expiration(
2238 "ESH4P150.GLBX",
2239 "ESH4P150",
2240 "ESH4",
2241 OptionKind::Put,
2242 "150.00",
2243 expiry_ns,
2244 );
2245
2246 let cache = Rc::new(RefCell::new(Cache::new(None, None)));
2247 cache
2248 .borrow_mut()
2249 .add_instrument(InstrumentAny::FuturesContract(future))
2250 .unwrap();
2251 cache
2252 .borrow_mut()
2253 .add_instrument(InstrumentAny::OptionContract(call_option.clone()))
2254 .unwrap();
2255 cache
2256 .borrow_mut()
2257 .add_instrument(InstrumentAny::OptionContract(put_option.clone()))
2258 .unwrap();
2259
2260 let call_quote = QuoteTick::new(
2261 call_option.id(),
2262 Price::from("8.50"),
2263 Price::from("8.50"),
2264 Quantity::from(100),
2265 Quantity::from(100),
2266 now_ns,
2267 now_ns,
2268 );
2269 let put_quote = QuoteTick::new(
2270 put_option.id(),
2271 Price::from("3.33"),
2272 Price::from("3.33"),
2273 Quantity::from(100),
2274 Quantity::from(100),
2275 now_ns,
2276 now_ns,
2277 );
2278 cache.borrow_mut().add_quote(call_quote).unwrap();
2279 cache.borrow_mut().add_quote(put_quote).unwrap();
2280
2281 let clock = Rc::new(RefCell::new(TestClock::new()));
2282 clock.borrow_mut().set_time(now_ns);
2283 let calculator = GreeksCalculator::new(cache, clock);
2284
2285 let error = calculator
2286 .instrument_greeks(
2287 call_option.id(),
2288 Some(0.0425),
2289 None,
2290 None,
2291 None,
2292 None,
2293 None,
2294 None,
2295 None,
2296 None,
2297 Some(now_ns),
2298 None,
2299 None,
2300 None,
2301 None,
2302 None,
2303 None,
2304 None,
2305 )
2306 .unwrap_err();
2307
2308 assert_eq!(error.to_string(), "No price available for ESH4.GLBX");
2309 }
2310
2311 #[rstest]
2312 fn test_cache_futures_spread_returns_price_to_reference_future() {
2313 let now = Utc.with_ymd_and_hms(2024, 2, 14, 16, 0, 0).unwrap();
2314 let expiry = Utc.with_ymd_and_hms(2024, 3, 15, 16, 0, 0).unwrap();
2315 let now_ns = UnixNanos::from(now);
2316 let expiry_ns = UnixNanos::from(expiry);
2317
2318 let future = future_with_expiration("ESH4.GLBX", "ESH4", expiry_ns);
2319 let reference_future = future_with_expiration("ESM4.GLBX", "ESM4", expiry_ns);
2320 let call_option = future_option_with_expiration(
2321 "ESH4C150.GLBX",
2322 "ESH4C150",
2323 "ESH4",
2324 OptionKind::Call,
2325 "150.00",
2326 expiry_ns,
2327 );
2328 let put_option = future_option_with_expiration(
2329 "ESH4P150.GLBX",
2330 "ESH4P150",
2331 "ESH4",
2332 OptionKind::Put,
2333 "150.00",
2334 expiry_ns,
2335 );
2336
2337 let cache = Rc::new(RefCell::new(Cache::new(None, None)));
2338 cache
2339 .borrow_mut()
2340 .add_instrument(InstrumentAny::FuturesContract(future))
2341 .unwrap();
2342 cache
2343 .borrow_mut()
2344 .add_instrument(InstrumentAny::FuturesContract(reference_future.clone()))
2345 .unwrap();
2346 cache
2347 .borrow_mut()
2348 .add_instrument(InstrumentAny::OptionContract(call_option.clone()))
2349 .unwrap();
2350 cache
2351 .borrow_mut()
2352 .add_instrument(InstrumentAny::OptionContract(put_option.clone()))
2353 .unwrap();
2354
2355 let call_quote = QuoteTick::new(
2356 call_option.id(),
2357 Price::from("8.50"),
2358 Price::from("8.50"),
2359 Quantity::from(100),
2360 Quantity::from(100),
2361 now_ns,
2362 now_ns,
2363 );
2364 let put_quote = QuoteTick::new(
2365 put_option.id(),
2366 Price::from("3.33"),
2367 Price::from("3.33"),
2368 Quantity::from(100),
2369 Quantity::from(100),
2370 now_ns,
2371 now_ns,
2372 );
2373 let reference_future_quote = QuoteTick::new(
2374 reference_future.id(),
2375 Price::from("155.00"),
2376 Price::from("155.00"),
2377 Quantity::from(100),
2378 Quantity::from(100),
2379 now_ns,
2380 now_ns,
2381 );
2382 cache.borrow_mut().add_quote(call_quote).unwrap();
2383 cache.borrow_mut().add_quote(put_quote).unwrap();
2384 cache
2385 .borrow_mut()
2386 .add_quote(reference_future_quote)
2387 .unwrap();
2388
2389 let clock = Rc::new(RefCell::new(TestClock::new()));
2390 clock.borrow_mut().set_time(now_ns);
2391 let calculator = GreeksCalculator::new(cache, clock);
2392
2393 let cached_future_price = calculator
2394 .cache_futures_spread(call_option.id(), put_option.id(), reference_future.id())
2395 .unwrap();
2396
2397 let expected_underlying = 150.0 + (0.0425_f64 * (30.0 / 365.25)).exp() * (8.50 - 3.33);
2398 let expected_cached_underlying = reference_future.make_price(expected_underlying);
2399 assert_eq!(cached_future_price, expected_cached_underlying);
2400 assert_eq!(
2401 calculator.get_cached_futures_spread_price(InstrumentId::from("ESH4.GLBX")),
2402 Some(expected_cached_underlying)
2403 );
2404 }
2405
2406 #[rstest]
2407 fn test_instrument_greeks_uses_cached_futures_spread_when_underlying_price_missing() {
2408 let now = Utc.with_ymd_and_hms(2024, 2, 14, 16, 0, 0).unwrap();
2409 let expiry = Utc.with_ymd_and_hms(2024, 3, 15, 16, 0, 0).unwrap();
2410 let now_ns = UnixNanos::from(now);
2411 let expiry_ns = UnixNanos::from(expiry);
2412
2413 let future = future_with_expiration("ESH4.GLBX", "ESH4", expiry_ns);
2414 let reference_future = future_with_expiration("ESM4.GLBX", "ESM4", expiry_ns);
2415 let call_option = future_option_with_expiration(
2416 "ESH4C150.GLBX",
2417 "ESH4C150",
2418 "ESH4",
2419 OptionKind::Call,
2420 "150.00",
2421 expiry_ns,
2422 );
2423 let put_option = future_option_with_expiration(
2424 "ESH4P150.GLBX",
2425 "ESH4P150",
2426 "ESH4",
2427 OptionKind::Put,
2428 "150.00",
2429 expiry_ns,
2430 );
2431 let target_call_option = future_option_with_expiration(
2432 "ESH4C152.GLBX",
2433 "ESH4C152",
2434 "ESH4",
2435 OptionKind::Call,
2436 "152.00",
2437 expiry_ns,
2438 );
2439
2440 let cache = Rc::new(RefCell::new(Cache::new(None, None)));
2441 cache
2442 .borrow_mut()
2443 .add_instrument(InstrumentAny::FuturesContract(future))
2444 .unwrap();
2445 cache
2446 .borrow_mut()
2447 .add_instrument(InstrumentAny::FuturesContract(reference_future.clone()))
2448 .unwrap();
2449 cache
2450 .borrow_mut()
2451 .add_instrument(InstrumentAny::OptionContract(call_option.clone()))
2452 .unwrap();
2453 cache
2454 .borrow_mut()
2455 .add_instrument(InstrumentAny::OptionContract(put_option.clone()))
2456 .unwrap();
2457 cache
2458 .borrow_mut()
2459 .add_instrument(InstrumentAny::OptionContract(target_call_option.clone()))
2460 .unwrap();
2461
2462 let call_quote = QuoteTick::new(
2463 call_option.id(),
2464 Price::from("8.50"),
2465 Price::from("8.50"),
2466 Quantity::from(100),
2467 Quantity::from(100),
2468 now_ns,
2469 now_ns,
2470 );
2471 let put_quote = QuoteTick::new(
2472 put_option.id(),
2473 Price::from("3.33"),
2474 Price::from("3.33"),
2475 Quantity::from(100),
2476 Quantity::from(100),
2477 now_ns,
2478 now_ns,
2479 );
2480 let target_call_quote = QuoteTick::new(
2481 target_call_option.id(),
2482 Price::from("6.75"),
2483 Price::from("6.75"),
2484 Quantity::from(100),
2485 Quantity::from(100),
2486 now_ns,
2487 now_ns,
2488 );
2489 let reference_future_quote = QuoteTick::new(
2490 reference_future.id(),
2491 Price::from("155.00"),
2492 Price::from("155.00"),
2493 Quantity::from(100),
2494 Quantity::from(100),
2495 now_ns,
2496 now_ns,
2497 );
2498 cache.borrow_mut().add_quote(call_quote).unwrap();
2499 cache.borrow_mut().add_quote(put_quote).unwrap();
2500 cache.borrow_mut().add_quote(target_call_quote).unwrap();
2501 cache
2502 .borrow_mut()
2503 .add_quote(reference_future_quote)
2504 .unwrap();
2505
2506 let clock = Rc::new(RefCell::new(TestClock::new()));
2507 clock.borrow_mut().set_time(now_ns);
2508 let calculator = GreeksCalculator::new(cache, clock);
2509 calculator
2510 .cache_futures_spread(call_option.id(), put_option.id(), reference_future.id())
2511 .unwrap();
2512
2513 let greeks = calculator
2514 .instrument_greeks(
2515 target_call_option.id(),
2516 Some(0.0425),
2517 None,
2518 None,
2519 None,
2520 None,
2521 None,
2522 None,
2523 None,
2524 None,
2525 Some(now_ns),
2526 None,
2527 None,
2528 None,
2529 None,
2530 None,
2531 None,
2532 None,
2533 )
2534 .unwrap();
2535
2536 let expected_underlying = reference_future
2537 .make_price(150.0 + (0.0425_f64 * (30.0 / 365.25)).exp() * (8.50 - 3.33))
2538 .as_f64();
2539 assert_eq!(greeks.underlying_price, expected_underlying);
2540 }
2541
2542 #[rstest]
2543 fn test_instrument_greeks_uses_index_price_for_index_underlying() {
2544 let now = Utc.with_ymd_and_hms(2024, 2, 14, 16, 0, 0).unwrap();
2545 let expiry = Utc.with_ymd_and_hms(2024, 3, 15, 16, 0, 0).unwrap();
2546 let now_ns = UnixNanos::from(now);
2547 let expiry_ns = UnixNanos::from(expiry);
2548
2549 let future = future_with_expiration("ESH4.GLBX", "ESH4", expiry_ns);
2550 let call_option = future_option_with_expiration(
2551 "ESH4C150.GLBX",
2552 "ESH4C150",
2553 "ESH4",
2554 OptionKind::Call,
2555 "150.00",
2556 expiry_ns,
2557 );
2558
2559 let cache = Rc::new(RefCell::new(Cache::new(None, None)));
2560 cache
2561 .borrow_mut()
2562 .add_instrument(InstrumentAny::FuturesContract(future))
2563 .unwrap();
2564 cache
2565 .borrow_mut()
2566 .add_instrument(InstrumentAny::OptionContract(call_option.clone()))
2567 .unwrap();
2568
2569 let call_quote = QuoteTick::new(
2570 call_option.id(),
2571 Price::from("8.50"),
2572 Price::from("8.50"),
2573 Quantity::from(100),
2574 Quantity::from(100),
2575 now_ns,
2576 now_ns,
2577 );
2578 cache.borrow_mut().add_quote(call_quote).unwrap();
2579 cache
2580 .borrow_mut()
2581 .add_index_price(IndexPriceUpdate::new(
2582 InstrumentId::from("ESH4.GLBX"),
2583 Price::from("157.25"),
2584 now_ns,
2585 now_ns,
2586 ))
2587 .unwrap();
2588
2589 let clock = Rc::new(RefCell::new(TestClock::new()));
2590 clock.borrow_mut().set_time(now_ns);
2591 let calculator = GreeksCalculator::new(cache, clock);
2592
2593 let greeks = calculator
2594 .instrument_greeks(
2595 call_option.id(),
2596 Some(0.0425),
2597 None,
2598 None,
2599 None,
2600 None,
2601 None,
2602 None,
2603 None,
2604 None,
2605 Some(now_ns),
2606 None,
2607 None,
2608 None,
2609 None,
2610 None,
2611 None,
2612 None,
2613 )
2614 .unwrap();
2615
2616 assert_eq!(greeks.underlying_price, 157.25);
2617 }
2618
2619 #[rstest]
2620 fn test_instrument_greeks_prefers_quote_over_index_price_for_index_future() {
2621 let now = Utc.with_ymd_and_hms(2024, 2, 14, 16, 0, 0).unwrap();
2622 let expiry = Utc.with_ymd_and_hms(2024, 3, 15, 16, 0, 0).unwrap();
2623 let now_ns = UnixNanos::from(now);
2624 let expiry_ns = UnixNanos::from(expiry);
2625
2626 let future = future_with_expiration("ESH4.GLBX", "ESH4", expiry_ns);
2627 let call_option = future_option_with_expiration(
2628 "ESH4C150.GLBX",
2629 "ESH4C150",
2630 "ESH4",
2631 OptionKind::Call,
2632 "150.00",
2633 expiry_ns,
2634 );
2635
2636 let cache = Rc::new(RefCell::new(Cache::new(None, None)));
2637 cache
2638 .borrow_mut()
2639 .add_instrument(InstrumentAny::FuturesContract(future))
2640 .unwrap();
2641 cache
2642 .borrow_mut()
2643 .add_instrument(InstrumentAny::OptionContract(call_option.clone()))
2644 .unwrap();
2645
2646 let future_quote = QuoteTick::new(
2648 InstrumentId::from("ESH4.GLBX"),
2649 Price::from("158.50"),
2650 Price::from("159.50"),
2651 Quantity::from(100),
2652 Quantity::from(100),
2653 now_ns,
2654 now_ns,
2655 );
2656 cache.borrow_mut().add_quote(future_quote).unwrap();
2657 cache
2658 .borrow_mut()
2659 .add_index_price(IndexPriceUpdate::new(
2660 InstrumentId::from("ESH4.GLBX"),
2661 Price::from("157.25"),
2662 now_ns,
2663 now_ns,
2664 ))
2665 .unwrap();
2666
2667 let call_quote = QuoteTick::new(
2668 call_option.id(),
2669 Price::from("8.50"),
2670 Price::from("8.50"),
2671 Quantity::from(100),
2672 Quantity::from(100),
2673 now_ns,
2674 now_ns,
2675 );
2676 cache.borrow_mut().add_quote(call_quote).unwrap();
2677
2678 let clock = Rc::new(RefCell::new(TestClock::new()));
2679 clock.borrow_mut().set_time(now_ns);
2680 let calculator = GreeksCalculator::new(cache, clock);
2681
2682 let greeks = calculator
2683 .instrument_greeks(
2684 call_option.id(),
2685 Some(0.0425),
2686 None,
2687 None,
2688 None,
2689 None,
2690 None,
2691 None,
2692 None,
2693 None,
2694 Some(now_ns),
2695 None,
2696 None,
2697 None,
2698 None,
2699 None,
2700 None,
2701 None,
2702 )
2703 .unwrap();
2704
2705 assert_eq!(greeks.underlying_price, 159.0);
2707 }
2708}