Skip to main content

nautilus_common/
greeks.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! Greeks calculator for options and futures.
17
18use std::{cell::RefCell, collections::HashMap, fmt::Debug, rc::Rc};
19
20use ahash::AHashMap;
21use derive_builder::Builder;
22use nautilus_core::UnixNanos;
23use nautilus_model::{
24    data::greeks::{
25        GreeksData, OptionGreekValues, PortfolioGreeks, black_scholes_greeks, imply_vol_and_greeks,
26        refine_vol_and_greeks,
27    },
28    enums::{AssetClass, InstrumentClass, OptionKind, PositionSide, PriceType},
29    identifiers::{InstrumentId, StrategyId, Venue},
30    instruments::{Instrument, any::InstrumentAny},
31    position::Position,
32    types::Price,
33};
34
35use crate::{cache::Cache, clock::Clock, msgbus, msgbus::TypedHandler};
36
37/// Type alias for a greeks filter function.
38pub type GreeksFilter = Box<dyn Fn(&GreeksData) -> bool>;
39
40/// Cloneable wrapper for greeks filter functions.
41#[derive(Clone)]
42pub enum GreeksFilterCallback {
43    /// Function pointer (non-capturing closure)
44    Function(fn(&GreeksData) -> bool),
45    /// Boxed closure (may capture variables)
46    Closure(std::rc::Rc<dyn Fn(&GreeksData) -> bool>),
47}
48
49impl GreeksFilterCallback {
50    /// Create a new filter from a function pointer.
51    pub fn from_fn(f: fn(&GreeksData) -> bool) -> Self {
52        Self::Function(f)
53    }
54
55    /// Create a new filter from a closure.
56    pub fn from_closure<F>(f: F) -> Self
57    where
58        F: Fn(&GreeksData) -> bool + 'static,
59    {
60        Self::Closure(std::rc::Rc::new(f))
61    }
62
63    /// Call the filter function.
64    pub fn call(&self, data: &GreeksData) -> bool {
65        match self {
66            Self::Function(f) => f(data),
67            Self::Closure(f) => f(data),
68        }
69    }
70
71    /// Convert to the original GreeksFilter type.
72    pub fn to_greeks_filter(self) -> GreeksFilter {
73        match self {
74            Self::Function(f) => Box::new(f),
75            Self::Closure(f) => {
76                let f_clone = f.clone();
77                Box::new(move |data| f_clone(data))
78            }
79        }
80    }
81}
82
83impl Debug for GreeksFilterCallback {
84    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
85        match self {
86            Self::Function(_) => f.write_str("GreeksFilterCallback::Function"),
87            Self::Closure(_) => f.write_str("GreeksFilterCallback::Closure"),
88        }
89    }
90}
91
92/// Builder for instrument greeks calculation parameters.
93#[derive(Debug, Builder)]
94#[builder(setter(into), derive(Debug))]
95pub struct InstrumentGreeksParams {
96    /// The instrument ID to calculate greeks for
97    pub instrument_id: InstrumentId,
98    /// Flat interest rate (default: 0.0425)
99    #[builder(default = "0.0425")]
100    pub flat_interest_rate: f64,
101    /// Flat dividend yield
102    #[builder(default)]
103    pub flat_dividend_yield: Option<f64>,
104    /// Spot price shock (default: 0.0)
105    #[builder(default = "0.0")]
106    pub spot_shock: f64,
107    /// Volatility shock (default: 0.0)
108    #[builder(default = "0.0")]
109    pub vol_shock: f64,
110    /// Time to expiry shock (default: 0.0)
111    #[builder(default = "0.0")]
112    pub time_to_expiry_shock: f64,
113    /// Whether to use cached greeks (default: false)
114    #[builder(default = "false")]
115    pub use_cached_greeks: bool,
116    /// Whether to update vol from cached greeks (default: false)
117    #[builder(default = "false")]
118    pub update_vol: bool,
119    /// Whether to cache greeks (default: false)
120    #[builder(default = "false")]
121    pub cache_greeks: bool,
122    /// Whether to publish greeks (default: false)
123    #[builder(default = "false")]
124    pub publish_greeks: bool,
125    /// Event timestamp
126    #[builder(default)]
127    pub ts_event: Option<UnixNanos>,
128    /// Position for PnL calculation
129    #[builder(default)]
130    pub position: Option<Position>,
131    /// Whether to compute percent greeks (default: false)
132    #[builder(default = "false")]
133    pub percent_greeks: bool,
134    /// Index instrument ID for beta weighting
135    #[builder(default)]
136    pub index_instrument_id: Option<InstrumentId>,
137    /// Beta weights for portfolio calculations
138    #[builder(default)]
139    pub beta_weights: Option<HashMap<InstrumentId, f64>>,
140    /// Base value in days for time-weighting vega
141    #[builder(default)]
142    pub vega_time_weight_base: Option<i32>,
143}
144
145impl InstrumentGreeksParams {
146    /// Calculate instrument greeks using the builder parameters.
147    ///
148    /// # Errors
149    ///
150    /// Returns an error if the greeks calculation fails.
151    pub fn calculate(&self, calculator: &GreeksCalculator) -> anyhow::Result<GreeksData> {
152        calculator.instrument_greeks(
153            self.instrument_id,
154            Some(self.flat_interest_rate),
155            self.flat_dividend_yield,
156            Some(self.spot_shock),
157            Some(self.vol_shock),
158            Some(self.time_to_expiry_shock),
159            Some(self.use_cached_greeks),
160            Some(self.update_vol),
161            Some(self.cache_greeks),
162            Some(self.publish_greeks),
163            self.ts_event,
164            self.position.clone(),
165            Some(self.percent_greeks),
166            self.index_instrument_id,
167            self.beta_weights.as_ref(),
168            self.vega_time_weight_base,
169        )
170    }
171}
172
173/// Builder for portfolio greeks calculation parameters.
174#[derive(Builder)]
175#[builder(setter(into))]
176pub struct PortfolioGreeksParams {
177    /// List of underlying symbols to filter by
178    #[builder(default)]
179    pub underlyings: Option<Vec<String>>,
180    /// Venue to filter positions by
181    #[builder(default)]
182    pub venue: Option<Venue>,
183    /// Instrument ID to filter positions by
184    #[builder(default)]
185    pub instrument_id: Option<InstrumentId>,
186    /// Strategy ID to filter positions by
187    #[builder(default)]
188    pub strategy_id: Option<StrategyId>,
189    /// Position side to filter by (default: NoPositionSide)
190    #[builder(default)]
191    pub side: Option<PositionSide>,
192    /// Flat interest rate (default: 0.0425)
193    #[builder(default = "0.0425")]
194    pub flat_interest_rate: f64,
195    /// Flat dividend yield
196    #[builder(default)]
197    pub flat_dividend_yield: Option<f64>,
198    /// Spot price shock (default: 0.0)
199    #[builder(default = "0.0")]
200    pub spot_shock: f64,
201    /// Volatility shock (default: 0.0)
202    #[builder(default = "0.0")]
203    pub vol_shock: f64,
204    /// Time to expiry shock (default: 0.0)
205    #[builder(default = "0.0")]
206    pub time_to_expiry_shock: f64,
207    /// Whether to use cached greeks (default: false)
208    #[builder(default = "false")]
209    pub use_cached_greeks: bool,
210    /// Whether to update vol from cached greeks (default: false)
211    #[builder(default = "false")]
212    pub update_vol: bool,
213    /// Whether to cache greeks (default: false)
214    #[builder(default = "false")]
215    pub cache_greeks: bool,
216    /// Whether to publish greeks (default: false)
217    #[builder(default = "false")]
218    pub publish_greeks: bool,
219    /// Whether to compute percent greeks (default: false)
220    #[builder(default = "false")]
221    pub percent_greeks: bool,
222    /// Index instrument ID for beta weighting
223    #[builder(default)]
224    pub index_instrument_id: Option<InstrumentId>,
225    /// Beta weights for portfolio calculations
226    #[builder(default)]
227    pub beta_weights: Option<HashMap<InstrumentId, f64>>,
228    /// Filter function for greeks
229    #[builder(default)]
230    pub greeks_filter: Option<GreeksFilterCallback>,
231    /// Base value in days for time-weighting vega
232    #[builder(default)]
233    pub vega_time_weight_base: Option<i32>,
234}
235
236impl Debug for PortfolioGreeksParams {
237    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
238        f.debug_struct(stringify!(PortfolioGreeksParams))
239            .field("underlyings", &self.underlyings)
240            .field("venue", &self.venue)
241            .field("instrument_id", &self.instrument_id)
242            .field("strategy_id", &self.strategy_id)
243            .field("side", &self.side)
244            .field("flat_interest_rate", &self.flat_interest_rate)
245            .field("flat_dividend_yield", &self.flat_dividend_yield)
246            .field("spot_shock", &self.spot_shock)
247            .field("vol_shock", &self.vol_shock)
248            .field("time_to_expiry_shock", &self.time_to_expiry_shock)
249            .field("use_cached_greeks", &self.use_cached_greeks)
250            .field("update_vol", &self.update_vol)
251            .field("cache_greeks", &self.cache_greeks)
252            .field("publish_greeks", &self.publish_greeks)
253            .field("percent_greeks", &self.percent_greeks)
254            .field("index_instrument_id", &self.index_instrument_id)
255            .field("beta_weights", &self.beta_weights)
256            .field("greeks_filter", &self.greeks_filter)
257            .finish()
258    }
259}
260
261impl PortfolioGreeksParams {
262    /// Calculate portfolio greeks using the builder parameters.
263    ///
264    /// # Errors
265    ///
266    /// Returns an error if the portfolio greeks calculation fails.
267    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        )
294    }
295}
296
297/// Calculates instrument and portfolio greeks (sensitivities of price moves with respect to market data moves).
298///
299/// Useful for risk management of options and futures portfolios.
300///
301/// Currently implemented greeks are:
302/// - Delta (first derivative of price with respect to spot move).
303/// - Gamma (second derivative of price with respect to spot move).
304/// - Vega (first derivative of price with respect to implied volatility of an option).
305/// - Theta (first derivative of price with respect to time to expiry).
306///
307/// Vega is expressed in terms of absolute percent changes ((dV / dVol) / 100).
308/// Theta is expressed in terms of daily changes ((dV / d(T-t)) / 365.25, where T is the expiry of an option and t is the current time).
309///
310/// Also note that for ease of implementation we consider that american options (for stock options for example) are european for the computation of greeks.
311#[allow(dead_code)]
312#[derive(Debug)]
313pub struct GreeksCalculator {
314    cache: Rc<RefCell<Cache>>,
315    clock: Rc<RefCell<dyn Clock>>,
316    cached_futures_spreads: RefCell<AHashMap<InstrumentId, (InstrumentId, Price)>>,
317}
318
319impl GreeksCalculator {
320    /// Creates a new [`GreeksCalculator`] instance.
321    pub fn new(cache: Rc<RefCell<Cache>>, clock: Rc<RefCell<dyn Clock>>) -> Self {
322        Self {
323            cache,
324            clock,
325            cached_futures_spreads: RefCell::new(AHashMap::new()),
326        }
327    }
328
329    /// Calculates option or underlying greeks for a given instrument and a quantity of 1.
330    ///
331    /// Additional features:
332    /// - Apply shocks to the spot value of the instrument's underlying, implied volatility or time to expiry.
333    /// - Compute percent greeks.
334    /// - Compute beta-weighted delta and gamma with respect to an index.
335    ///
336    /// # Errors
337    ///
338    /// Returns an error if the instrument definition is not found or greeks calculation fails.
339    ///
340    /// # Panics
341    ///
342    /// Panics if the instrument has no underlying identifier.
343    #[expect(clippy::too_many_arguments)]
344    pub fn instrument_greeks(
345        &self,
346        instrument_id: InstrumentId,
347        flat_interest_rate: Option<f64>,
348        flat_dividend_yield: Option<f64>,
349        spot_shock: Option<f64>,
350        vol_shock: Option<f64>,
351        time_to_expiry_shock: Option<f64>,
352        use_cached_greeks: Option<bool>,
353        update_vol: Option<bool>,
354        cache_greeks: Option<bool>,
355        publish_greeks: Option<bool>,
356        ts_event: Option<UnixNanos>,
357        position: Option<Position>,
358        percent_greeks: Option<bool>,
359        index_instrument_id: Option<InstrumentId>,
360        beta_weights: Option<&HashMap<InstrumentId, f64>>,
361        vega_time_weight_base: Option<i32>,
362    ) -> anyhow::Result<GreeksData> {
363        // Set default values
364        let flat_interest_rate = flat_interest_rate.unwrap_or(0.0425);
365        let spot_shock = spot_shock.unwrap_or(0.0);
366        let vol_shock = vol_shock.unwrap_or(0.0);
367        let time_to_expiry_shock = time_to_expiry_shock.unwrap_or(0.0);
368        let use_cached_greeks = use_cached_greeks.unwrap_or(false);
369        let update_vol = update_vol.unwrap_or(false);
370        let cache_greeks = cache_greeks.unwrap_or(false);
371        let publish_greeks = publish_greeks.unwrap_or(false);
372        let ts_event = ts_event.unwrap_or_default();
373        let percent_greeks = percent_greeks.unwrap_or(false);
374
375        let instrument = {
376            let cache = self.cache.borrow();
377            match cache.instrument(&instrument_id) {
378                Some(instrument) => instrument.clone(),
379                None => anyhow::bail!("Instrument definition for {instrument_id} not found"),
380            }
381        };
382
383        if instrument.instrument_class() != InstrumentClass::Option {
384            return self.calculate_non_option_greeks(
385                &instrument,
386                instrument_id,
387                spot_shock,
388                ts_event,
389                position,
390                percent_greeks,
391                index_instrument_id,
392                beta_weights,
393            );
394        }
395
396        let underlying = instrument.underlying().unwrap();
397        let underlying_str = format!("{}.{}", underlying, instrument_id.venue);
398        let underlying_instrument_id = InstrumentId::from(underlying_str);
399        let mut greeks_data = self.calculate_option_greeks(
400            &instrument,
401            instrument_id,
402            underlying_instrument_id,
403            flat_interest_rate,
404            flat_dividend_yield,
405            use_cached_greeks,
406            update_vol,
407            cache_greeks,
408            publish_greeks,
409            ts_event,
410            percent_greeks,
411            index_instrument_id,
412            beta_weights,
413            vega_time_weight_base,
414        )?;
415
416        if spot_shock != 0.0 || vol_shock != 0.0 || time_to_expiry_shock != 0.0 {
417            greeks_data = self.apply_option_greeks_shocks(
418                &greeks_data,
419                underlying_instrument_id,
420                spot_shock,
421                vol_shock,
422                time_to_expiry_shock,
423                percent_greeks,
424                index_instrument_id,
425                beta_weights,
426                vega_time_weight_base,
427            );
428        }
429
430        if let Some(pos) = position {
431            greeks_data.pnl = greeks_data.price - pos.avg_px_open;
432        }
433
434        Ok(greeks_data)
435    }
436
437    #[expect(clippy::too_many_arguments)]
438    fn calculate_non_option_greeks(
439        &self,
440        instrument: &InstrumentAny,
441        instrument_id: InstrumentId,
442        spot_shock: f64,
443        ts_event: UnixNanos,
444        position: Option<Position>,
445        percent_greeks: bool,
446        index_instrument_id: Option<InstrumentId>,
447        beta_weights: Option<&HashMap<InstrumentId, f64>>,
448    ) -> anyhow::Result<GreeksData> {
449        let multiplier = instrument.multiplier();
450        let underlying_instrument_id = instrument.id();
451        let underlying_price = self
452            .get_price(&underlying_instrument_id)
453            .ok_or_else(|| anyhow::anyhow!("No price available for {underlying_instrument_id}"))?;
454        let (delta, _, _) = self.modify_greeks(
455            1.0,
456            0.0,
457            underlying_instrument_id,
458            underlying_price + spot_shock,
459            underlying_price,
460            percent_greeks,
461            index_instrument_id,
462            beta_weights,
463            0.0,
464            0.0,
465            0,
466            None,
467        );
468        let mut greeks_data =
469            GreeksData::from_delta(instrument_id, delta, multiplier.as_f64(), ts_event);
470
471        if let Some(pos) = position {
472            greeks_data.pnl = (underlying_price + spot_shock) - pos.avg_px_open;
473            greeks_data.price = greeks_data.pnl;
474        }
475
476        Ok(greeks_data)
477    }
478
479    #[expect(clippy::too_many_arguments)]
480    fn calculate_option_greeks(
481        &self,
482        instrument: &InstrumentAny,
483        instrument_id: InstrumentId,
484        underlying_instrument_id: InstrumentId,
485        flat_interest_rate: f64,
486        flat_dividend_yield: Option<f64>,
487        use_cached_greeks: bool,
488        update_vol: bool,
489        cache_greeks: bool,
490        publish_greeks: bool,
491        ts_event: UnixNanos,
492        percent_greeks: bool,
493        index_instrument_id: Option<InstrumentId>,
494        beta_weights: Option<&HashMap<InstrumentId, f64>>,
495        vega_time_weight_base: Option<i32>,
496    ) -> anyhow::Result<GreeksData> {
497        if use_cached_greeks {
498            let cache = self.cache.borrow();
499            if let Some(cached_greeks) = cache.greeks(&instrument_id) {
500                return Ok(cached_greeks);
501            }
502        }
503
504        let utc_now_ns = if ts_event == UnixNanos::default() {
505            self.clock.borrow().timestamp_ns()
506        } else {
507            ts_event
508        };
509        let utc_now = utc_now_ns.to_datetime_utc();
510        let expiry_utc = instrument
511            .expiration_ns()
512            .map(|ns| ns.to_datetime_utc())
513            .unwrap_or_default();
514        let expiry_int = expiry_utc
515            .format("%Y%m%d")
516            .to_string()
517            .parse::<i32>()
518            .unwrap_or(0);
519        let raw_days = (expiry_utc - utc_now).num_days();
520        let expiry_in_days = raw_days.max(1) as i32;
521        let expiry_in_years = expiry_in_days as f64 / 365.25;
522        let currency = instrument.quote_currency().code.to_string();
523
524        let cache = self.cache.borrow();
525        let yield_curve = cache.yield_curve(&currency);
526        let interest_rate = match yield_curve {
527            Some(yield_curve) => yield_curve(expiry_in_years),
528            None => flat_interest_rate,
529        };
530        let dividend_curve = cache.yield_curve(&underlying_instrument_id.to_string());
531        drop(cache);
532
533        let mut cost_of_carry = 0.0;
534
535        if let Some(dividend_curve) = dividend_curve {
536            cost_of_carry = interest_rate - dividend_curve(expiry_in_years);
537        } else if let Some(div_yield) = flat_dividend_yield {
538            cost_of_carry = interest_rate - div_yield;
539        }
540
541        let multiplier = instrument.multiplier();
542        let is_call = instrument.option_kind().unwrap_or(OptionKind::Call) == OptionKind::Call;
543        let strike = instrument.strike_price().unwrap_or_default().as_f64();
544        let option_price = self
545            .get_price(&instrument_id)
546            .ok_or_else(|| anyhow::anyhow!("No price available for {instrument_id}"))?;
547        let underlying_price = self.get_underlying_price(&underlying_instrument_id)?;
548        let greeks = if update_vol {
549            let cached_greeks = self.cache.borrow().greeks(&instrument_id);
550            match cached_greeks {
551                Some(cached_greeks) => refine_vol_and_greeks(
552                    underlying_price,
553                    interest_rate,
554                    cost_of_carry,
555                    is_call,
556                    strike,
557                    expiry_in_years,
558                    option_price,
559                    cached_greeks.vol,
560                ),
561                None => imply_vol_and_greeks(
562                    underlying_price,
563                    interest_rate,
564                    cost_of_carry,
565                    is_call,
566                    strike,
567                    expiry_in_years,
568                    option_price,
569                ),
570            }
571        } else {
572            imply_vol_and_greeks(
573                underlying_price,
574                interest_rate,
575                cost_of_carry,
576                is_call,
577                strike,
578                expiry_in_years,
579                option_price,
580            )
581        };
582        let (delta, gamma, vega) = self.modify_greeks(
583            greeks.delta,
584            greeks.gamma,
585            underlying_instrument_id,
586            underlying_price,
587            underlying_price,
588            percent_greeks,
589            index_instrument_id,
590            beta_weights,
591            greeks.vega,
592            greeks.vol,
593            expiry_in_days,
594            vega_time_weight_base,
595        );
596        let greeks_data = GreeksData::new(
597            utc_now_ns,
598            utc_now_ns,
599            instrument_id,
600            is_call,
601            strike,
602            expiry_int,
603            expiry_in_days,
604            expiry_in_years,
605            multiplier.as_f64(),
606            1.0,
607            underlying_price,
608            interest_rate,
609            cost_of_carry,
610            greeks.vol,
611            0.0,
612            greeks.price,
613            OptionGreekValues {
614                delta,
615                gamma,
616                vega,
617                theta: greeks.theta,
618                rho: 0.0,
619            },
620            greeks.itm_prob,
621        );
622
623        if cache_greeks {
624            let mut cache = self.cache.borrow_mut();
625            cache.add_greeks(greeks_data.clone()).unwrap_or_default();
626        }
627
628        if publish_greeks {
629            let topic = format!(
630                "data.GreeksData.instrument_id={}",
631                instrument_id.symbol.as_str()
632            )
633            .into();
634            msgbus::publish_greeks(topic, &greeks_data);
635        }
636
637        Ok(greeks_data)
638    }
639
640    #[expect(clippy::too_many_arguments)]
641    fn apply_option_greeks_shocks(
642        &self,
643        greeks_data: &GreeksData,
644        underlying_instrument_id: InstrumentId,
645        spot_shock: f64,
646        vol_shock: f64,
647        time_to_expiry_shock: f64,
648        percent_greeks: bool,
649        index_instrument_id: Option<InstrumentId>,
650        beta_weights: Option<&HashMap<InstrumentId, f64>>,
651        vega_time_weight_base: Option<i32>,
652    ) -> GreeksData {
653        let underlying_price = greeks_data.underlying_price;
654        let shocked_underlying_price = underlying_price + spot_shock;
655        let shocked_vol = greeks_data.vol + vol_shock;
656        let shocked_time_to_expiry = greeks_data.expiry_in_years - time_to_expiry_shock;
657        let shocked_expiry_in_days = (shocked_time_to_expiry * 365.25) as i32;
658
659        let greeks = black_scholes_greeks(
660            shocked_underlying_price,
661            greeks_data.interest_rate,
662            greeks_data.cost_of_carry,
663            shocked_vol,
664            greeks_data.is_call,
665            greeks_data.strike,
666            shocked_time_to_expiry,
667        );
668        let (delta, gamma, vega) = self.modify_greeks(
669            greeks.delta,
670            greeks.gamma,
671            underlying_instrument_id,
672            shocked_underlying_price,
673            underlying_price,
674            percent_greeks,
675            index_instrument_id,
676            beta_weights,
677            greeks.vega,
678            shocked_vol,
679            shocked_expiry_in_days,
680            vega_time_weight_base,
681        );
682        GreeksData::new(
683            greeks_data.ts_event,
684            greeks_data.ts_event,
685            greeks_data.instrument_id,
686            greeks_data.is_call,
687            greeks_data.strike,
688            greeks_data.expiry,
689            shocked_expiry_in_days,
690            shocked_time_to_expiry,
691            greeks_data.multiplier,
692            greeks_data.quantity,
693            shocked_underlying_price,
694            greeks_data.interest_rate,
695            greeks_data.cost_of_carry,
696            shocked_vol,
697            0.0,
698            greeks.price,
699            OptionGreekValues {
700                delta,
701                gamma,
702                vega,
703                theta: greeks.theta,
704                rho: 0.0,
705            },
706            greeks.itm_prob,
707        )
708    }
709
710    fn get_underlying_price(&self, underlying_instrument_id: &InstrumentId) -> anyhow::Result<f64> {
711        if let Some(underlying_price) = self.get_price(underlying_instrument_id) {
712            return Ok(underlying_price);
713        }
714
715        // Only fall back to cached futures spread when the underlying is a future
716        // (or absent from the cache, since the spread was explicitly cached).
717        let is_future_or_absent = {
718            let cache = self.cache.borrow();
719            cache
720                .instrument(underlying_instrument_id)
721                .is_none_or(|inst| inst.instrument_class() == InstrumentClass::Future)
722        };
723
724        if is_future_or_absent
725            && let Some(underlying_price) =
726                self.get_cached_futures_spread_price(*underlying_instrument_id)
727        {
728            return Ok(underlying_price.as_f64());
729        }
730
731        anyhow::bail!("No price available for {underlying_instrument_id}")
732    }
733
734    /// Modifies delta and gamma based on beta weighting and percentage calculations.
735    ///
736    /// The beta weighting of delta and gamma follows this equation linking the returns of a stock x to the ones of an index I:
737    /// (x - x0) / x0 = alpha + beta (I - I0) / I0 + epsilon
738    ///
739    /// beta can be obtained by linear regression of stock_return = alpha + beta index_return, it's equal to:
740    /// beta = Covariance(stock_returns, index_returns) / Variance(index_returns)
741    ///
742    /// Considering alpha == 0:
743    /// x = x0 + beta x0 / I0 (I-I0)
744    /// I = I0 + 1 / beta I0 / x0 (x - x0)
745    ///
746    /// These two last equations explain the beta weighting below, considering the price of an option is V(x) and delta and gamma
747    /// are the first and second derivatives respectively of V.
748    ///
749    /// Also percent greeks assume a change of variable to percent returns by writing:
750    /// V(x = x0 * (1 + stock_percent_return / 100))
751    /// or V(I = I0 * (1 + index_percent_return / 100))
752    #[expect(clippy::too_many_arguments)]
753    pub fn modify_greeks(
754        &self,
755        delta_input: f64,
756        gamma_input: f64,
757        underlying_instrument_id: InstrumentId,
758        underlying_price: f64,
759        unshocked_underlying_price: f64,
760        percent_greeks: bool,
761        index_instrument_id: Option<InstrumentId>,
762        beta_weights: Option<&HashMap<InstrumentId, f64>>,
763        vega_input: f64,
764        vol: f64,
765        expiry_in_days: i32,
766        vega_time_weight_base: Option<i32>,
767    ) -> (f64, f64, f64) {
768        let mut delta = delta_input;
769        let mut gamma = gamma_input;
770        let mut vega = vega_input;
771
772        let mut index_price = None;
773
774        if let Some(index_id) = index_instrument_id {
775            let cache = self.cache.borrow();
776            index_price = Some(
777                cache
778                    .price(&index_id, PriceType::Last)
779                    .unwrap_or_default()
780                    .as_f64(),
781            );
782
783            let mut beta = 1.0;
784
785            if let Some(weights) = beta_weights
786                && let Some(&weight) = weights.get(&underlying_instrument_id)
787            {
788                beta = weight;
789            }
790
791            if let Some(ref mut idx_price) = index_price {
792                if underlying_price != unshocked_underlying_price {
793                    *idx_price += 1.0 / beta
794                        * (*idx_price / unshocked_underlying_price)
795                        * (underlying_price - unshocked_underlying_price);
796                }
797
798                let delta_multiplier = beta * underlying_price / *idx_price;
799                delta *= delta_multiplier;
800                gamma *= delta_multiplier.powi(2);
801            }
802        }
803
804        if percent_greeks {
805            if let Some(idx_price) = index_price {
806                delta *= idx_price / 100.0;
807                gamma *= (idx_price / 100.0).powi(2);
808            } else {
809                delta *= underlying_price / 100.0;
810                gamma *= (underlying_price / 100.0).powi(2);
811            }
812
813            // Apply percent vega when percent_greeks is True
814            vega *= vol / 100.0;
815        }
816
817        // Apply time weighting to vega if vega_time_weight_base is provided
818        if let Some(time_base) = vega_time_weight_base
819            && expiry_in_days > 0
820        {
821            let time_weight = (time_base as f64 / expiry_in_days as f64).sqrt();
822            vega *= time_weight;
823        }
824
825        (delta, gamma, vega)
826    }
827
828    /// Calculates the portfolio Greeks for a given set of positions.
829    ///
830    /// Aggregates the Greeks data for all open positions that match the specified criteria.
831    ///
832    /// Additional features:
833    /// - Apply shocks to the spot value of an instrument's underlying, implied volatility or time to expiry.
834    /// - Compute percent greeks.
835    /// - Compute beta-weighted delta and gamma with respect to an index.
836    ///
837    /// # Errors
838    ///
839    /// Returns an error if any underlying greeks calculation fails.
840    ///
841    #[expect(clippy::too_many_arguments)]
842    #[expect(clippy::missing_panics_doc)] // Guarded by is_none check
843    pub fn portfolio_greeks(
844        &self,
845        underlyings: Option<&[String]>,
846        venue: Option<Venue>,
847        instrument_id: Option<InstrumentId>,
848        strategy_id: Option<StrategyId>,
849        side: Option<PositionSide>,
850        flat_interest_rate: Option<f64>,
851        flat_dividend_yield: Option<f64>,
852        spot_shock: Option<f64>,
853        vol_shock: Option<f64>,
854        time_to_expiry_shock: Option<f64>,
855        use_cached_greeks: Option<bool>,
856        update_vol: Option<bool>,
857        cache_greeks: Option<bool>,
858        publish_greeks: Option<bool>,
859        percent_greeks: Option<bool>,
860        index_instrument_id: Option<InstrumentId>,
861        beta_weights: Option<&HashMap<InstrumentId, f64>>,
862        greeks_filter: Option<&GreeksFilter>,
863        vega_time_weight_base: Option<i32>,
864    ) -> anyhow::Result<PortfolioGreeks> {
865        let ts_event = self.clock.borrow().timestamp_ns();
866        let mut portfolio_greeks =
867            PortfolioGreeks::new(ts_event, ts_event, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0);
868
869        // Set default values
870        let flat_interest_rate = flat_interest_rate.unwrap_or(0.0425);
871        let spot_shock = spot_shock.unwrap_or(0.0);
872        let vol_shock = vol_shock.unwrap_or(0.0);
873        let time_to_expiry_shock = time_to_expiry_shock.unwrap_or(0.0);
874        let use_cached_greeks = use_cached_greeks.unwrap_or(false);
875        let update_vol = update_vol.unwrap_or(false);
876        let cache_greeks = cache_greeks.unwrap_or(false);
877        let publish_greeks = publish_greeks.unwrap_or(false);
878        let percent_greeks = percent_greeks.unwrap_or(false);
879        let side = side.unwrap_or(PositionSide::NoPositionSide);
880
881        let cache = self.cache.borrow();
882        let open_positions = cache.positions(
883            venue.as_ref(),
884            instrument_id.as_ref(),
885            strategy_id.as_ref(),
886            None, // account_id
887            Some(side),
888        );
889        let open_positions: Vec<Position> = open_positions.iter().map(|&p| p.clone()).collect();
890
891        for position in open_positions {
892            let position_instrument_id = position.instrument_id;
893
894            if let Some(underlyings_list) = underlyings {
895                let mut skip_position = true;
896
897                for underlying in underlyings_list {
898                    if position_instrument_id
899                        .symbol
900                        .as_str()
901                        .starts_with(underlying)
902                    {
903                        skip_position = false;
904                        break;
905                    }
906                }
907
908                if skip_position {
909                    continue;
910                }
911            }
912
913            let quantity = position.signed_qty;
914            let instrument_greeks = self.instrument_greeks(
915                position_instrument_id,
916                Some(flat_interest_rate),
917                flat_dividend_yield,
918                Some(spot_shock),
919                Some(vol_shock),
920                Some(time_to_expiry_shock),
921                Some(use_cached_greeks),
922                Some(update_vol),
923                Some(cache_greeks),
924                Some(publish_greeks),
925                Some(ts_event),
926                Some(position),
927                Some(percent_greeks),
928                index_instrument_id,
929                beta_weights,
930                vega_time_weight_base,
931            )?;
932            let position_greeks = quantity * &instrument_greeks;
933
934            // Apply greeks filter if provided
935            if greeks_filter.is_none() || greeks_filter.unwrap()(&position_greeks) {
936                portfolio_greeks = portfolio_greeks + PortfolioGreeks::from(position_greeks);
937            }
938        }
939
940        Ok(portfolio_greeks)
941    }
942
943    /// Cache a futures spread derived from a call/put pair against a reference future.
944    ///
945    /// # Errors
946    ///
947    /// Returns an error if instruments or prices are missing or inconsistent.
948    pub fn cache_futures_spread(
949        &self,
950        call_instrument_id: InstrumentId,
951        put_instrument_id: InstrumentId,
952        futures_instrument_id: InstrumentId,
953    ) -> anyhow::Result<Price> {
954        let cache = self.cache.borrow();
955        let call_instrument = cache.instrument(&call_instrument_id).cloned();
956        let put_instrument = cache.instrument(&put_instrument_id).cloned();
957        let reference_future_instrument = cache.instrument(&futures_instrument_id).cloned();
958        drop(cache);
959
960        let Some(call_instrument) = call_instrument else {
961            anyhow::bail!(
962                "Cannot cache futures spread: missing option instrument {call_instrument_id}"
963            );
964        };
965        let Some(put_instrument) = put_instrument else {
966            anyhow::bail!(
967                "Cannot cache futures spread: missing option instrument {put_instrument_id}"
968            );
969        };
970        let Some(reference_future_instrument) = reference_future_instrument else {
971            anyhow::bail!(
972                "Cannot cache futures spread: no reference futures instrument for {futures_instrument_id}"
973            );
974        };
975
976        if call_instrument.instrument_class() != InstrumentClass::Option
977            || put_instrument.instrument_class() != InstrumentClass::Option
978        {
979            anyhow::bail!(
980                "Cannot cache futures spread: non-option instruments provided call_instrument_id={call_instrument_id} put_instrument_id={put_instrument_id}"
981            );
982        }
983
984        if call_instrument.option_kind() != Some(OptionKind::Call)
985            || put_instrument.option_kind() != Some(OptionKind::Put)
986        {
987            anyhow::bail!(
988                "Cannot cache futures spread: expected call/put pair call_instrument_id={call_instrument_id} put_instrument_id={put_instrument_id}"
989            );
990        }
991
992        let Some(call_underlying) = call_instrument.underlying() else {
993            anyhow::bail!(
994                "Cannot cache futures spread: missing call underlying for {call_instrument_id}"
995            );
996        };
997        let Some(put_underlying) = put_instrument.underlying() else {
998            anyhow::bail!(
999                "Cannot cache futures spread: missing put underlying for {put_instrument_id}"
1000            );
1001        };
1002
1003        if call_underlying != put_underlying {
1004            anyhow::bail!(
1005                "Cannot cache futures spread: option underlyings differ call_instrument_id={call_instrument_id} put_instrument_id={put_instrument_id}"
1006            );
1007        }
1008
1009        if call_instrument.strike_price() != put_instrument.strike_price() {
1010            anyhow::bail!(
1011                "Cannot cache futures spread: strike prices differ call_instrument_id={call_instrument_id} put_instrument_id={put_instrument_id}"
1012            );
1013        }
1014
1015        if call_instrument.expiration_ns() != put_instrument.expiration_ns() {
1016            anyhow::bail!(
1017                "Cannot cache futures spread: expiration dates differ call_instrument_id={call_instrument_id} put_instrument_id={put_instrument_id}"
1018            );
1019        }
1020
1021        let reference_future_price = self.get_price_object(&futures_instrument_id).ok_or_else(|| {
1022            anyhow::anyhow!(
1023                "Cannot cache futures spread: no reference futures price for {futures_instrument_id}"
1024            )
1025        })?;
1026        let call_price = self.get_price(&call_instrument_id).ok_or_else(|| {
1027            anyhow::anyhow!(
1028                "Cannot cache futures spread: missing option price for {call_instrument_id}"
1029            )
1030        })?;
1031        let put_price = self.get_price(&put_instrument_id).ok_or_else(|| {
1032            anyhow::anyhow!(
1033                "Cannot cache futures spread: missing option price for {put_instrument_id}"
1034            )
1035        })?;
1036
1037        let underlying_instrument_id =
1038            InstrumentId::from(format!("{call_underlying}.{}", call_instrument_id.venue));
1039
1040        // Reject if the underlying is present in cache but is not a future
1041        {
1042            let cache = self.cache.borrow();
1043            if let Some(underlying) = cache.instrument(&underlying_instrument_id)
1044                && underlying.instrument_class() != InstrumentClass::Future
1045            {
1046                anyhow::bail!(
1047                    "Cannot cache futures spread: underlying {underlying_instrument_id} is not a futures contract"
1048                );
1049            }
1050        }
1051
1052        let implied_future_price =
1053            self.calculate_implied_future_price(&call_instrument, call_price, put_price);
1054        let spread = implied_future_price - reference_future_price.as_f64();
1055        let spread_price = reference_future_instrument.make_price(spread);
1056
1057        self.cached_futures_spreads.borrow_mut().insert(
1058            underlying_instrument_id,
1059            (futures_instrument_id, spread_price),
1060        );
1061
1062        Ok(reference_future_price + spread_price)
1063    }
1064
1065    fn calculate_implied_future_price(
1066        &self,
1067        call_instrument: &InstrumentAny,
1068        call_price: f64,
1069        put_price: f64,
1070    ) -> f64 {
1071        let expiry_utc = call_instrument
1072            .expiration_ns()
1073            .map(|ns| ns.to_datetime_utc())
1074            .unwrap_or_default();
1075        let expiry_in_days = (expiry_utc - self.clock.borrow().timestamp_ns().to_datetime_utc())
1076            .num_days()
1077            .max(1) as i32;
1078        let expiry_in_years = expiry_in_days as f64 / 365.25;
1079        let currency = call_instrument.quote_currency().code.to_string();
1080        let interest_rate = self
1081            .cache
1082            .borrow()
1083            .yield_curve(&currency)
1084            .map_or(0.0425, |yield_curve| yield_curve(expiry_in_years));
1085        let strike = call_instrument.strike_price().unwrap_or_default().as_f64();
1086
1087        strike + (interest_rate * expiry_in_years).exp() * (call_price - put_price)
1088    }
1089
1090    /// Resolve a cached futures spread price for an underlying future.
1091    #[must_use]
1092    pub fn get_cached_futures_spread_price(
1093        &self,
1094        underlying_instrument_id: InstrumentId,
1095    ) -> Option<Price> {
1096        let (futures_instrument_id, spread) = self
1097            .cached_futures_spreads
1098            .borrow()
1099            .get(&underlying_instrument_id)
1100            .copied()?;
1101        let reference_future_price = self.get_price_object(&futures_instrument_id)?;
1102
1103        Some(reference_future_price + spread)
1104    }
1105
1106    fn get_price_object(&self, instrument_id: &InstrumentId) -> Option<Price> {
1107        let cache = self.cache.borrow();
1108
1109        // For index-class futures, prefer tradable quotes over the published index
1110        // price since index price is the spot level and may diverge from futures basis.
1111        // For true index instruments (non-futures), prefer the published index price.
1112        if let Some(instrument) = cache.instrument(instrument_id)
1113            && instrument.asset_class() == AssetClass::Index
1114        {
1115            if instrument.instrument_class() == InstrumentClass::Future {
1116                let tradable = cache
1117                    .price(instrument_id, PriceType::Mid)
1118                    .or_else(|| cache.price(instrument_id, PriceType::Last));
1119
1120                if tradable.is_some() {
1121                    return tradable;
1122                }
1123            }
1124
1125            if let Some(index_price) = cache.index_price(instrument_id) {
1126                return Some(index_price.value);
1127            }
1128        }
1129
1130        cache
1131            .price(instrument_id, PriceType::Mid)
1132            .or_else(|| cache.price(instrument_id, PriceType::Last))
1133    }
1134
1135    fn get_price(&self, instrument_id: &InstrumentId) -> Option<f64> {
1136        self.get_price_object(instrument_id)
1137            .map(|price| price.as_f64())
1138    }
1139
1140    /// Subscribes to Greeks data for a given underlying instrument.
1141    ///
1142    /// Useful for reading greeks from a backtesting data catalog and caching them for later use.
1143    pub fn subscribe_greeks<F>(&self, underlying: &str, handler: Option<F>)
1144    where
1145        F: Fn(&GreeksData) + 'static,
1146    {
1147        let pattern = format!("data.GreeksData.instrument_id={underlying}*").into();
1148
1149        if let Some(custom_handler) = handler {
1150            let typed_handler = TypedHandler::from(custom_handler);
1151            msgbus::subscribe_greeks(pattern, typed_handler, None);
1152        } else {
1153            let cache_ref = self.cache.clone();
1154            let typed_handler = TypedHandler::from(move |greeks: &GreeksData| {
1155                let mut cache = cache_ref.borrow_mut();
1156                cache.add_greeks(greeks.clone()).unwrap_or_default();
1157            });
1158            msgbus::subscribe_greeks(pattern, typed_handler, None);
1159        }
1160    }
1161}
1162
1163#[cfg(test)]
1164mod tests {
1165    use std::{cell::RefCell, collections::HashMap, rc::Rc};
1166
1167    use chrono::{TimeZone, Utc};
1168    use nautilus_model::{
1169        data::{IndexPriceUpdate, QuoteTick},
1170        enums::{AssetClass, OptionKind, PositionSide},
1171        identifiers::{InstrumentId, StrategyId, Symbol, Venue},
1172        instruments::{Equity, FuturesContract, OptionContract, any::InstrumentAny},
1173        types::{Currency, Price, Quantity},
1174    };
1175    use rstest::rstest;
1176    use ustr::Ustr;
1177
1178    use super::*;
1179    use crate::{cache::Cache, clock::TestClock};
1180
1181    fn create_test_calculator() -> GreeksCalculator {
1182        let cache = Rc::new(RefCell::new(Cache::new(None, None)));
1183        let clock = Rc::new(RefCell::new(TestClock::new()));
1184        GreeksCalculator::new(cache, clock)
1185    }
1186
1187    #[rstest]
1188    fn test_greeks_calculator_creation() {
1189        let calculator = create_test_calculator();
1190        // Test that the calculator can be created
1191        assert!(format!("{calculator:?}").contains("GreeksCalculator"));
1192    }
1193
1194    #[rstest]
1195    fn test_greeks_calculator_debug() {
1196        let calculator = create_test_calculator();
1197        // Test the debug representation
1198        let debug_str = format!("{calculator:?}");
1199        assert!(debug_str.contains("GreeksCalculator"));
1200    }
1201
1202    #[rstest]
1203    fn test_greeks_calculator_has_python_bindings() {
1204        // This test just verifies that the GreeksCalculator struct
1205        // can be compiled with Python bindings enabled
1206        let calculator = create_test_calculator();
1207        // The Python methods are only accessible from Python,
1208        // but we can verify the struct compiles correctly
1209        assert!(format!("{calculator:?}").contains("GreeksCalculator"));
1210    }
1211
1212    #[rstest]
1213    fn test_instrument_greeks_params_builder_default() {
1214        let instrument_id = InstrumentId::from("AAPL.NASDAQ");
1215
1216        let params = InstrumentGreeksParamsBuilder::default()
1217            .instrument_id(instrument_id)
1218            .build()
1219            .expect("Failed to build InstrumentGreeksParams");
1220
1221        assert_eq!(params.instrument_id, instrument_id);
1222        assert_eq!(params.flat_interest_rate, 0.0425);
1223        assert_eq!(params.flat_dividend_yield, None);
1224        assert_eq!(params.spot_shock, 0.0);
1225        assert_eq!(params.vol_shock, 0.0);
1226        assert_eq!(params.time_to_expiry_shock, 0.0);
1227        assert!(!params.use_cached_greeks);
1228        assert!(!params.cache_greeks);
1229        assert!(!params.publish_greeks);
1230        assert_eq!(params.ts_event, None);
1231        assert_eq!(params.position, None);
1232        assert!(!params.percent_greeks);
1233        assert_eq!(params.index_instrument_id, None);
1234        assert_eq!(params.beta_weights, None);
1235    }
1236
1237    #[rstest]
1238    fn test_instrument_greeks_params_builder_custom_values() {
1239        let instrument_id = InstrumentId::from("AAPL.NASDAQ");
1240        let index_id = InstrumentId::from("SPY.NASDAQ");
1241        let mut beta_weights = HashMap::new();
1242        beta_weights.insert(instrument_id, 1.2);
1243
1244        let params = InstrumentGreeksParamsBuilder::default()
1245            .instrument_id(instrument_id)
1246            .flat_interest_rate(0.05)
1247            .flat_dividend_yield(Some(0.02))
1248            .spot_shock(0.01)
1249            .vol_shock(0.05)
1250            .time_to_expiry_shock(0.1)
1251            .use_cached_greeks(true)
1252            .cache_greeks(true)
1253            .publish_greeks(true)
1254            .percent_greeks(true)
1255            .index_instrument_id(Some(index_id))
1256            .beta_weights(Some(beta_weights.clone()))
1257            .build()
1258            .expect("Failed to build InstrumentGreeksParams");
1259
1260        assert_eq!(params.instrument_id, instrument_id);
1261        assert_eq!(params.flat_interest_rate, 0.05);
1262        assert_eq!(params.flat_dividend_yield, Some(0.02));
1263        assert_eq!(params.spot_shock, 0.01);
1264        assert_eq!(params.vol_shock, 0.05);
1265        assert_eq!(params.time_to_expiry_shock, 0.1);
1266        assert!(params.use_cached_greeks);
1267        assert!(params.cache_greeks);
1268        assert!(params.publish_greeks);
1269        assert!(params.percent_greeks);
1270        assert_eq!(params.index_instrument_id, Some(index_id));
1271        assert_eq!(params.beta_weights, Some(beta_weights));
1272    }
1273
1274    #[rstest]
1275    fn test_instrument_greeks_params_debug() {
1276        let instrument_id = InstrumentId::from("AAPL.NASDAQ");
1277
1278        let params = InstrumentGreeksParamsBuilder::default()
1279            .instrument_id(instrument_id)
1280            .build()
1281            .expect("Failed to build InstrumentGreeksParams");
1282
1283        let debug_str = format!("{params:?}");
1284        assert!(debug_str.contains("InstrumentGreeksParams"));
1285        assert!(debug_str.contains("AAPL.NASDAQ"));
1286    }
1287
1288    #[rstest]
1289    fn test_portfolio_greeks_params_builder_default() {
1290        let params = PortfolioGreeksParamsBuilder::default()
1291            .build()
1292            .expect("Failed to build PortfolioGreeksParams");
1293
1294        assert_eq!(params.underlyings, None);
1295        assert_eq!(params.venue, None);
1296        assert_eq!(params.instrument_id, None);
1297        assert_eq!(params.strategy_id, None);
1298        assert_eq!(params.side, None);
1299        assert_eq!(params.flat_interest_rate, 0.0425);
1300        assert_eq!(params.flat_dividend_yield, None);
1301        assert_eq!(params.spot_shock, 0.0);
1302        assert_eq!(params.vol_shock, 0.0);
1303        assert_eq!(params.time_to_expiry_shock, 0.0);
1304        assert!(!params.use_cached_greeks);
1305        assert!(!params.cache_greeks);
1306        assert!(!params.publish_greeks);
1307        assert!(!params.percent_greeks);
1308        assert_eq!(params.index_instrument_id, None);
1309        assert_eq!(params.beta_weights, None);
1310    }
1311
1312    #[rstest]
1313    fn test_portfolio_greeks_params_builder_custom_values() {
1314        let venue = Venue::from("NASDAQ");
1315        let instrument_id = InstrumentId::from("AAPL.NASDAQ");
1316        let strategy_id = StrategyId::from("test-strategy");
1317        let index_id = InstrumentId::from("SPY.NASDAQ");
1318        let underlyings = vec!["AAPL".to_string(), "MSFT".to_string()];
1319        let mut beta_weights = HashMap::new();
1320        beta_weights.insert(instrument_id, 1.2);
1321
1322        let params = PortfolioGreeksParamsBuilder::default()
1323            .underlyings(Some(underlyings.clone()))
1324            .venue(Some(venue))
1325            .instrument_id(Some(instrument_id))
1326            .strategy_id(Some(strategy_id))
1327            .side(Some(PositionSide::Long))
1328            .flat_interest_rate(0.05)
1329            .flat_dividend_yield(Some(0.02))
1330            .spot_shock(0.01)
1331            .vol_shock(0.05)
1332            .time_to_expiry_shock(0.1)
1333            .use_cached_greeks(true)
1334            .cache_greeks(true)
1335            .publish_greeks(true)
1336            .percent_greeks(true)
1337            .index_instrument_id(Some(index_id))
1338            .beta_weights(Some(beta_weights.clone()))
1339            .build()
1340            .expect("Failed to build PortfolioGreeksParams");
1341
1342        assert_eq!(params.underlyings, Some(underlyings));
1343        assert_eq!(params.venue, Some(venue));
1344        assert_eq!(params.instrument_id, Some(instrument_id));
1345        assert_eq!(params.strategy_id, Some(strategy_id));
1346        assert_eq!(params.side, Some(PositionSide::Long));
1347        assert_eq!(params.flat_interest_rate, 0.05);
1348        assert_eq!(params.flat_dividend_yield, Some(0.02));
1349        assert_eq!(params.spot_shock, 0.01);
1350        assert_eq!(params.vol_shock, 0.05);
1351        assert_eq!(params.time_to_expiry_shock, 0.1);
1352        assert!(params.use_cached_greeks);
1353        assert!(params.cache_greeks);
1354        assert!(params.publish_greeks);
1355        assert!(params.percent_greeks);
1356        assert_eq!(params.index_instrument_id, Some(index_id));
1357        assert_eq!(params.beta_weights, Some(beta_weights));
1358    }
1359
1360    #[rstest]
1361    fn test_portfolio_greeks_params_debug() {
1362        let venue = Venue::from("NASDAQ");
1363
1364        let params = PortfolioGreeksParamsBuilder::default()
1365            .venue(Some(venue))
1366            .build()
1367            .expect("Failed to build PortfolioGreeksParams");
1368
1369        let debug_str = format!("{params:?}");
1370        assert!(debug_str.contains("PortfolioGreeksParams"));
1371        assert!(debug_str.contains("NASDAQ"));
1372    }
1373
1374    #[rstest]
1375    fn test_instrument_greeks_params_builder_missing_required_field() {
1376        // Test that building without required instrument_id fails
1377        let result = InstrumentGreeksParamsBuilder::default().build();
1378        assert!(result.is_err());
1379    }
1380
1381    #[rstest]
1382    fn test_portfolio_greeks_params_builder_fluent_api() {
1383        let instrument_id = InstrumentId::from("AAPL.NASDAQ");
1384
1385        let params = PortfolioGreeksParamsBuilder::default()
1386            .instrument_id(Some(instrument_id))
1387            .flat_interest_rate(0.05)
1388            .spot_shock(0.01)
1389            .percent_greeks(true)
1390            .build()
1391            .expect("Failed to build PortfolioGreeksParams");
1392
1393        assert_eq!(params.instrument_id, Some(instrument_id));
1394        assert_eq!(params.flat_interest_rate, 0.05);
1395        assert_eq!(params.spot_shock, 0.01);
1396        assert!(params.percent_greeks);
1397    }
1398
1399    #[rstest]
1400    fn test_instrument_greeks_params_builder_fluent_chaining() {
1401        let instrument_id = InstrumentId::from("TSLA.NASDAQ");
1402
1403        // Test fluent API chaining
1404        let params = InstrumentGreeksParamsBuilder::default()
1405            .instrument_id(instrument_id)
1406            .flat_interest_rate(0.03)
1407            .spot_shock(0.02)
1408            .vol_shock(0.1)
1409            .use_cached_greeks(true)
1410            .percent_greeks(true)
1411            .build()
1412            .expect("Failed to build InstrumentGreeksParams");
1413
1414        assert_eq!(params.instrument_id, instrument_id);
1415        assert_eq!(params.flat_interest_rate, 0.03);
1416        assert_eq!(params.spot_shock, 0.02);
1417        assert_eq!(params.vol_shock, 0.1);
1418        assert!(params.use_cached_greeks);
1419        assert!(params.percent_greeks);
1420    }
1421
1422    #[rstest]
1423    fn test_portfolio_greeks_params_builder_with_underlyings() {
1424        let underlyings = vec!["AAPL".to_string(), "MSFT".to_string(), "GOOGL".to_string()];
1425
1426        let params = PortfolioGreeksParamsBuilder::default()
1427            .underlyings(Some(underlyings.clone()))
1428            .flat_interest_rate(0.04)
1429            .build()
1430            .expect("Failed to build PortfolioGreeksParams");
1431
1432        assert_eq!(params.underlyings, Some(underlyings));
1433        assert_eq!(params.flat_interest_rate, 0.04);
1434    }
1435
1436    #[rstest]
1437    fn test_builders_with_empty_beta_weights() {
1438        let instrument_id = InstrumentId::from("NVDA.NASDAQ");
1439        let empty_beta_weights = HashMap::new();
1440
1441        let instrument_params = InstrumentGreeksParamsBuilder::default()
1442            .instrument_id(instrument_id)
1443            .beta_weights(Some(empty_beta_weights.clone()))
1444            .build()
1445            .expect("Failed to build InstrumentGreeksParams");
1446
1447        let portfolio_params = PortfolioGreeksParamsBuilder::default()
1448            .beta_weights(Some(empty_beta_weights.clone()))
1449            .build()
1450            .expect("Failed to build PortfolioGreeksParams");
1451
1452        assert_eq!(
1453            instrument_params.beta_weights,
1454            Some(empty_beta_weights.clone())
1455        );
1456        assert_eq!(portfolio_params.beta_weights, Some(empty_beta_weights));
1457    }
1458
1459    #[rstest]
1460    fn test_builders_with_all_shocks() {
1461        let instrument_id = InstrumentId::from("AMD.NASDAQ");
1462
1463        let instrument_params = InstrumentGreeksParamsBuilder::default()
1464            .instrument_id(instrument_id)
1465            .spot_shock(0.05)
1466            .vol_shock(0.1)
1467            .time_to_expiry_shock(0.01)
1468            .build()
1469            .expect("Failed to build InstrumentGreeksParams");
1470
1471        let portfolio_params = PortfolioGreeksParamsBuilder::default()
1472            .spot_shock(0.05)
1473            .vol_shock(0.1)
1474            .time_to_expiry_shock(0.01)
1475            .build()
1476            .expect("Failed to build PortfolioGreeksParams");
1477
1478        assert_eq!(instrument_params.spot_shock, 0.05);
1479        assert_eq!(instrument_params.vol_shock, 0.1);
1480        assert_eq!(instrument_params.time_to_expiry_shock, 0.01);
1481
1482        assert_eq!(portfolio_params.spot_shock, 0.05);
1483        assert_eq!(portfolio_params.vol_shock, 0.1);
1484        assert_eq!(portfolio_params.time_to_expiry_shock, 0.01);
1485    }
1486
1487    #[rstest]
1488    fn test_builders_with_all_boolean_flags() {
1489        let instrument_id = InstrumentId::from("META.NASDAQ");
1490
1491        let instrument_params = InstrumentGreeksParamsBuilder::default()
1492            .instrument_id(instrument_id)
1493            .use_cached_greeks(true)
1494            .cache_greeks(true)
1495            .publish_greeks(true)
1496            .percent_greeks(true)
1497            .build()
1498            .expect("Failed to build InstrumentGreeksParams");
1499
1500        let portfolio_params = PortfolioGreeksParamsBuilder::default()
1501            .use_cached_greeks(true)
1502            .cache_greeks(true)
1503            .publish_greeks(true)
1504            .percent_greeks(true)
1505            .build()
1506            .expect("Failed to build PortfolioGreeksParams");
1507
1508        assert!(instrument_params.use_cached_greeks);
1509        assert!(instrument_params.cache_greeks);
1510        assert!(instrument_params.publish_greeks);
1511        assert!(instrument_params.percent_greeks);
1512
1513        assert!(portfolio_params.use_cached_greeks);
1514        assert!(portfolio_params.cache_greeks);
1515        assert!(portfolio_params.publish_greeks);
1516        assert!(portfolio_params.percent_greeks);
1517    }
1518
1519    #[rstest]
1520    fn test_greeks_filter_callback_function() {
1521        // Test function pointer filter
1522        fn filter_positive_delta(data: &GreeksData) -> bool {
1523            data.delta > 0.0
1524        }
1525
1526        let filter = GreeksFilterCallback::from_fn(filter_positive_delta);
1527
1528        // Create test data
1529        let greeks_data = GreeksData::from_delta(
1530            InstrumentId::from("TEST.NASDAQ"),
1531            0.5,
1532            1.0,
1533            UnixNanos::default(),
1534        );
1535
1536        assert!(filter.call(&greeks_data));
1537
1538        // Test debug formatting
1539        let debug_str = format!("{filter:?}");
1540        assert!(debug_str.contains("GreeksFilterCallback::Function"));
1541    }
1542
1543    #[rstest]
1544    fn test_greeks_filter_callback_closure() {
1545        // Test closure filter that captures a variable
1546        let min_delta = 0.3;
1547        let filter =
1548            GreeksFilterCallback::from_closure(move |data: &GreeksData| data.delta > min_delta);
1549
1550        // Create test data
1551        let greeks_data = GreeksData::from_delta(
1552            InstrumentId::from("TEST.NASDAQ"),
1553            0.5,
1554            1.0,
1555            UnixNanos::default(),
1556        );
1557
1558        assert!(filter.call(&greeks_data));
1559
1560        // Test debug formatting
1561        let debug_str = format!("{filter:?}");
1562        assert!(debug_str.contains("GreeksFilterCallback::Closure"));
1563    }
1564
1565    #[rstest]
1566    fn test_greeks_filter_callback_clone() {
1567        fn filter_fn(data: &GreeksData) -> bool {
1568            data.delta > 0.0
1569        }
1570
1571        let filter1 = GreeksFilterCallback::from_fn(filter_fn);
1572        let filter2 = filter1.clone();
1573
1574        let greeks_data = GreeksData::from_delta(
1575            InstrumentId::from("TEST.NASDAQ"),
1576            0.5,
1577            1.0,
1578            UnixNanos::default(),
1579        );
1580
1581        assert!(filter1.call(&greeks_data));
1582        assert!(filter2.call(&greeks_data));
1583    }
1584
1585    #[rstest]
1586    fn test_portfolio_greeks_params_with_filter() {
1587        fn filter_high_delta(data: &GreeksData) -> bool {
1588            data.delta.abs() > 0.1
1589        }
1590
1591        let filter = GreeksFilterCallback::from_fn(filter_high_delta);
1592
1593        let params = PortfolioGreeksParamsBuilder::default()
1594            .greeks_filter(Some(filter))
1595            .flat_interest_rate(0.05)
1596            .build()
1597            .expect("Failed to build PortfolioGreeksParams");
1598
1599        assert!(params.greeks_filter.is_some());
1600        assert_eq!(params.flat_interest_rate, 0.05);
1601
1602        // Test that the filter can be called
1603        let greeks_data = GreeksData::from_delta(
1604            InstrumentId::from("TEST.NASDAQ"),
1605            0.5,
1606            1.0,
1607            UnixNanos::default(),
1608        );
1609
1610        let filter_ref = params.greeks_filter.as_ref().unwrap();
1611        assert!(filter_ref.call(&greeks_data));
1612    }
1613
1614    #[rstest]
1615    fn test_portfolio_greeks_params_with_closure_filter() {
1616        let min_gamma = 0.01;
1617        let filter =
1618            GreeksFilterCallback::from_closure(move |data: &GreeksData| data.gamma > min_gamma);
1619
1620        let params = PortfolioGreeksParamsBuilder::default()
1621            .greeks_filter(Some(filter))
1622            .build()
1623            .expect("Failed to build PortfolioGreeksParams");
1624
1625        assert!(params.greeks_filter.is_some());
1626
1627        // Test debug formatting includes the filter
1628        let debug_str = format!("{params:?}");
1629        assert!(debug_str.contains("greeks_filter"));
1630    }
1631
1632    #[rstest]
1633    fn test_greeks_filter_to_greeks_filter_conversion() {
1634        fn filter_fn(data: &GreeksData) -> bool {
1635            data.delta > 0.0
1636        }
1637
1638        let callback = GreeksFilterCallback::from_fn(filter_fn);
1639        let greeks_filter = callback.to_greeks_filter();
1640
1641        let greeks_data = GreeksData::from_delta(
1642            InstrumentId::from("TEST.NASDAQ"),
1643            0.5,
1644            1.0,
1645            UnixNanos::default(),
1646        );
1647
1648        assert!(greeks_filter(&greeks_data));
1649    }
1650
1651    fn option_with_expiration(instrument_id: &str, expiration_ns: UnixNanos) -> OptionContract {
1652        let activation_ns = UnixNanos::from(Utc.with_ymd_and_hms(2021, 9, 17, 0, 0, 0).unwrap());
1653        OptionContract::new(
1654            InstrumentId::from(instrument_id),
1655            Symbol::from("AAPL211217C00150000"),
1656            AssetClass::Equity,
1657            Some(Ustr::from("GMNI")),
1658            Ustr::from("AAPL"),
1659            OptionKind::Call,
1660            Price::from("149.0"),
1661            Currency::from("USD"),
1662            activation_ns,
1663            expiration_ns,
1664            2,
1665            Price::from("0.01"),
1666            Quantity::from(100),
1667            Quantity::from(1),
1668            None,
1669            None,
1670            None,
1671            None,
1672            None,
1673            None,
1674            None,
1675            None,
1676            None,
1677            UnixNanos::default(),
1678            UnixNanos::default(),
1679        )
1680    }
1681
1682    fn equity_aapl_opra() -> Equity {
1683        Equity::new(
1684            InstrumentId::from("AAPL.OPRA"),
1685            Symbol::from("AAPL"),
1686            Some(Ustr::from("US0378331005")),
1687            Currency::from("USD"),
1688            2,
1689            Price::from("0.01"),
1690            None,
1691            None,
1692            None,
1693            None,
1694            None,
1695            None,
1696            None,
1697            None,
1698            None,
1699            None,
1700            UnixNanos::default(),
1701            UnixNanos::default(),
1702        )
1703    }
1704
1705    fn future_with_expiration(
1706        instrument_id: &str,
1707        underlying: &str,
1708        expiration_ns: UnixNanos,
1709    ) -> FuturesContract {
1710        FuturesContract::new(
1711            InstrumentId::from(instrument_id),
1712            Symbol::from(underlying),
1713            AssetClass::Index,
1714            Some(Ustr::from("XCME")),
1715            Ustr::from(underlying),
1716            UnixNanos::default(),
1717            expiration_ns,
1718            Currency::from("USD"),
1719            2,
1720            Price::from("0.25"),
1721            Quantity::from(1),
1722            Quantity::from(1),
1723            None,
1724            None,
1725            None,
1726            None,
1727            None,
1728            None,
1729            None,
1730            None,
1731            None,
1732            UnixNanos::default(),
1733            UnixNanos::default(),
1734        )
1735    }
1736
1737    fn future_option_with_expiration(
1738        instrument_id: &str,
1739        raw_symbol: &str,
1740        underlying: &str,
1741        option_kind: OptionKind,
1742        strike: &str,
1743        expiration_ns: UnixNanos,
1744    ) -> OptionContract {
1745        OptionContract::new(
1746            InstrumentId::from(instrument_id),
1747            Symbol::from(raw_symbol),
1748            AssetClass::Index,
1749            Some(Ustr::from("XCME")),
1750            Ustr::from(underlying),
1751            option_kind,
1752            Price::from(strike),
1753            Currency::from("USD"),
1754            UnixNanos::default(),
1755            expiration_ns,
1756            2,
1757            Price::from("0.01"),
1758            Quantity::from(1),
1759            Quantity::from(1),
1760            None,
1761            None,
1762            None,
1763            None,
1764            None,
1765            None,
1766            None,
1767            None,
1768            None,
1769            UnixNanos::default(),
1770            UnixNanos::default(),
1771        )
1772    }
1773
1774    fn setup_cache_with_option_and_quotes(
1775        option: OptionContract,
1776        underlying_id: InstrumentId,
1777        now_ns: UnixNanos,
1778    ) -> Rc<RefCell<Cache>> {
1779        let option_id = option.id();
1780        let cache = Rc::new(RefCell::new(Cache::new(None, None)));
1781        cache
1782            .borrow_mut()
1783            .add_instrument(InstrumentAny::OptionContract(option))
1784            .unwrap();
1785        cache
1786            .borrow_mut()
1787            .add_instrument(InstrumentAny::Equity(equity_aapl_opra()))
1788            .unwrap();
1789        let option_quote = QuoteTick::new(
1790            option_id,
1791            Price::from("10.50"),
1792            Price::from("10.60"),
1793            Quantity::from(100),
1794            Quantity::from(100),
1795            now_ns,
1796            now_ns,
1797        );
1798        let underlying_quote = QuoteTick::new(
1799            underlying_id,
1800            Price::from("150.00"),
1801            Price::from("150.10"),
1802            Quantity::from(100),
1803            Quantity::from(100),
1804            now_ns,
1805            now_ns,
1806        );
1807        cache.borrow_mut().add_quote(option_quote).unwrap();
1808        cache.borrow_mut().add_quote(underlying_quote).unwrap();
1809        cache
1810    }
1811
1812    #[rstest]
1813    fn test_expiry_in_days_multi_day_unchanged() {
1814        let now = Utc.with_ymd_and_hms(2025, 3, 8, 12, 0, 0).unwrap();
1815        let expiry = now + chrono::Duration::days(30);
1816        let now_ns = UnixNanos::from(now);
1817        let expiry_ns = UnixNanos::from(expiry);
1818        let option = option_with_expiration("AAPL250417C00150000.OPRA", expiry_ns);
1819        let option_id = option.id();
1820        let underlying_id = InstrumentId::from("AAPL.OPRA");
1821        let cache = setup_cache_with_option_and_quotes(option, underlying_id, now_ns);
1822        let clock = Rc::new(RefCell::new(TestClock::new()));
1823        let calculator = GreeksCalculator::new(cache, clock);
1824
1825        let greeks = calculator
1826            .instrument_greeks(
1827                option_id,
1828                None,
1829                None,
1830                None,
1831                None,
1832                None,
1833                None,
1834                None,
1835                None,
1836                None,
1837                Some(now_ns),
1838                None,
1839                None,
1840                None,
1841                None,
1842                None,
1843            )
1844            .unwrap();
1845
1846        assert_eq!(greeks.expiry_in_days, 30);
1847        assert!((greeks.expiry_in_years - 30.0 / 365.25).abs() < 1e-9);
1848    }
1849
1850    #[rstest]
1851    fn test_expiry_in_days_same_day_clamped_to_one() {
1852        let now = Utc.with_ymd_and_hms(2025, 3, 8, 12, 0, 0).unwrap();
1853        let expiry_same_day = Utc.with_ymd_and_hms(2025, 3, 8, 18, 0, 0).unwrap();
1854        let now_ns = UnixNanos::from(now);
1855        let expiry_ns = UnixNanos::from(expiry_same_day);
1856        let option = option_with_expiration("AAPL250308C00150000.OPRA", expiry_ns);
1857        let option_id = option.id();
1858        let underlying_id = InstrumentId::from("AAPL.OPRA");
1859        let cache = setup_cache_with_option_and_quotes(option, underlying_id, now_ns);
1860        let clock = Rc::new(RefCell::new(TestClock::new()));
1861        let calculator = GreeksCalculator::new(cache, clock);
1862
1863        let greeks = calculator
1864            .instrument_greeks(
1865                option_id,
1866                None,
1867                None,
1868                None,
1869                None,
1870                None,
1871                None,
1872                None,
1873                None,
1874                None,
1875                Some(now_ns),
1876                None,
1877                None,
1878                None,
1879                None,
1880                None,
1881            )
1882            .unwrap();
1883
1884        assert_eq!(greeks.expiry_in_days, 1);
1885        assert!((greeks.expiry_in_years - 1.0 / 365.25).abs() < 1e-9);
1886    }
1887
1888    #[rstest]
1889    fn test_instrument_greeks_errors_when_future_underlying_price_missing_without_cached_spread() {
1890        let now = Utc.with_ymd_and_hms(2024, 2, 14, 16, 0, 0).unwrap();
1891        let expiry = Utc.with_ymd_and_hms(2024, 3, 15, 16, 0, 0).unwrap();
1892        let now_ns = UnixNanos::from(now);
1893        let expiry_ns = UnixNanos::from(expiry);
1894
1895        let future = future_with_expiration("ESH4.GLBX", "ESH4", expiry_ns);
1896        let call_option = future_option_with_expiration(
1897            "ESH4C150.GLBX",
1898            "ESH4C150",
1899            "ESH4",
1900            OptionKind::Call,
1901            "150.00",
1902            expiry_ns,
1903        );
1904        let put_option = future_option_with_expiration(
1905            "ESH4P150.GLBX",
1906            "ESH4P150",
1907            "ESH4",
1908            OptionKind::Put,
1909            "150.00",
1910            expiry_ns,
1911        );
1912
1913        let cache = Rc::new(RefCell::new(Cache::new(None, None)));
1914        cache
1915            .borrow_mut()
1916            .add_instrument(InstrumentAny::FuturesContract(future))
1917            .unwrap();
1918        cache
1919            .borrow_mut()
1920            .add_instrument(InstrumentAny::OptionContract(call_option.clone()))
1921            .unwrap();
1922        cache
1923            .borrow_mut()
1924            .add_instrument(InstrumentAny::OptionContract(put_option.clone()))
1925            .unwrap();
1926
1927        let call_quote = QuoteTick::new(
1928            call_option.id(),
1929            Price::from("8.50"),
1930            Price::from("8.50"),
1931            Quantity::from(100),
1932            Quantity::from(100),
1933            now_ns,
1934            now_ns,
1935        );
1936        let put_quote = QuoteTick::new(
1937            put_option.id(),
1938            Price::from("3.33"),
1939            Price::from("3.33"),
1940            Quantity::from(100),
1941            Quantity::from(100),
1942            now_ns,
1943            now_ns,
1944        );
1945        cache.borrow_mut().add_quote(call_quote).unwrap();
1946        cache.borrow_mut().add_quote(put_quote).unwrap();
1947
1948        let clock = Rc::new(RefCell::new(TestClock::new()));
1949        clock.borrow_mut().set_time(now_ns);
1950        let calculator = GreeksCalculator::new(cache, clock);
1951
1952        let error = calculator
1953            .instrument_greeks(
1954                call_option.id(),
1955                Some(0.0425),
1956                None,
1957                None,
1958                None,
1959                None,
1960                None,
1961                None,
1962                None,
1963                None,
1964                Some(now_ns),
1965                None,
1966                None,
1967                None,
1968                None,
1969                None,
1970            )
1971            .unwrap_err();
1972
1973        assert_eq!(error.to_string(), "No price available for ESH4.GLBX");
1974    }
1975
1976    #[rstest]
1977    fn test_cache_futures_spread_returns_price_to_reference_future() {
1978        let now = Utc.with_ymd_and_hms(2024, 2, 14, 16, 0, 0).unwrap();
1979        let expiry = Utc.with_ymd_and_hms(2024, 3, 15, 16, 0, 0).unwrap();
1980        let now_ns = UnixNanos::from(now);
1981        let expiry_ns = UnixNanos::from(expiry);
1982
1983        let future = future_with_expiration("ESH4.GLBX", "ESH4", expiry_ns);
1984        let reference_future = future_with_expiration("ESM4.GLBX", "ESM4", expiry_ns);
1985        let call_option = future_option_with_expiration(
1986            "ESH4C150.GLBX",
1987            "ESH4C150",
1988            "ESH4",
1989            OptionKind::Call,
1990            "150.00",
1991            expiry_ns,
1992        );
1993        let put_option = future_option_with_expiration(
1994            "ESH4P150.GLBX",
1995            "ESH4P150",
1996            "ESH4",
1997            OptionKind::Put,
1998            "150.00",
1999            expiry_ns,
2000        );
2001
2002        let cache = Rc::new(RefCell::new(Cache::new(None, None)));
2003        cache
2004            .borrow_mut()
2005            .add_instrument(InstrumentAny::FuturesContract(future))
2006            .unwrap();
2007        cache
2008            .borrow_mut()
2009            .add_instrument(InstrumentAny::FuturesContract(reference_future.clone()))
2010            .unwrap();
2011        cache
2012            .borrow_mut()
2013            .add_instrument(InstrumentAny::OptionContract(call_option.clone()))
2014            .unwrap();
2015        cache
2016            .borrow_mut()
2017            .add_instrument(InstrumentAny::OptionContract(put_option.clone()))
2018            .unwrap();
2019
2020        let call_quote = QuoteTick::new(
2021            call_option.id(),
2022            Price::from("8.50"),
2023            Price::from("8.50"),
2024            Quantity::from(100),
2025            Quantity::from(100),
2026            now_ns,
2027            now_ns,
2028        );
2029        let put_quote = QuoteTick::new(
2030            put_option.id(),
2031            Price::from("3.33"),
2032            Price::from("3.33"),
2033            Quantity::from(100),
2034            Quantity::from(100),
2035            now_ns,
2036            now_ns,
2037        );
2038        let reference_future_quote = QuoteTick::new(
2039            reference_future.id(),
2040            Price::from("155.00"),
2041            Price::from("155.00"),
2042            Quantity::from(100),
2043            Quantity::from(100),
2044            now_ns,
2045            now_ns,
2046        );
2047        cache.borrow_mut().add_quote(call_quote).unwrap();
2048        cache.borrow_mut().add_quote(put_quote).unwrap();
2049        cache
2050            .borrow_mut()
2051            .add_quote(reference_future_quote)
2052            .unwrap();
2053
2054        let clock = Rc::new(RefCell::new(TestClock::new()));
2055        clock.borrow_mut().set_time(now_ns);
2056        let calculator = GreeksCalculator::new(cache, clock);
2057
2058        let cached_future_price = calculator
2059            .cache_futures_spread(call_option.id(), put_option.id(), reference_future.id())
2060            .unwrap();
2061
2062        let expected_underlying = 150.0 + (0.0425_f64 * (30.0 / 365.25)).exp() * (8.50 - 3.33);
2063        let expected_cached_underlying = reference_future.make_price(expected_underlying);
2064        assert_eq!(cached_future_price, expected_cached_underlying);
2065        assert_eq!(
2066            calculator.get_cached_futures_spread_price(InstrumentId::from("ESH4.GLBX")),
2067            Some(expected_cached_underlying)
2068        );
2069    }
2070
2071    #[rstest]
2072    fn test_instrument_greeks_uses_cached_futures_spread_when_underlying_price_missing() {
2073        let now = Utc.with_ymd_and_hms(2024, 2, 14, 16, 0, 0).unwrap();
2074        let expiry = Utc.with_ymd_and_hms(2024, 3, 15, 16, 0, 0).unwrap();
2075        let now_ns = UnixNanos::from(now);
2076        let expiry_ns = UnixNanos::from(expiry);
2077
2078        let future = future_with_expiration("ESH4.GLBX", "ESH4", expiry_ns);
2079        let reference_future = future_with_expiration("ESM4.GLBX", "ESM4", expiry_ns);
2080        let call_option = future_option_with_expiration(
2081            "ESH4C150.GLBX",
2082            "ESH4C150",
2083            "ESH4",
2084            OptionKind::Call,
2085            "150.00",
2086            expiry_ns,
2087        );
2088        let put_option = future_option_with_expiration(
2089            "ESH4P150.GLBX",
2090            "ESH4P150",
2091            "ESH4",
2092            OptionKind::Put,
2093            "150.00",
2094            expiry_ns,
2095        );
2096        let target_call_option = future_option_with_expiration(
2097            "ESH4C152.GLBX",
2098            "ESH4C152",
2099            "ESH4",
2100            OptionKind::Call,
2101            "152.00",
2102            expiry_ns,
2103        );
2104
2105        let cache = Rc::new(RefCell::new(Cache::new(None, None)));
2106        cache
2107            .borrow_mut()
2108            .add_instrument(InstrumentAny::FuturesContract(future))
2109            .unwrap();
2110        cache
2111            .borrow_mut()
2112            .add_instrument(InstrumentAny::FuturesContract(reference_future.clone()))
2113            .unwrap();
2114        cache
2115            .borrow_mut()
2116            .add_instrument(InstrumentAny::OptionContract(call_option.clone()))
2117            .unwrap();
2118        cache
2119            .borrow_mut()
2120            .add_instrument(InstrumentAny::OptionContract(put_option.clone()))
2121            .unwrap();
2122        cache
2123            .borrow_mut()
2124            .add_instrument(InstrumentAny::OptionContract(target_call_option.clone()))
2125            .unwrap();
2126
2127        let call_quote = QuoteTick::new(
2128            call_option.id(),
2129            Price::from("8.50"),
2130            Price::from("8.50"),
2131            Quantity::from(100),
2132            Quantity::from(100),
2133            now_ns,
2134            now_ns,
2135        );
2136        let put_quote = QuoteTick::new(
2137            put_option.id(),
2138            Price::from("3.33"),
2139            Price::from("3.33"),
2140            Quantity::from(100),
2141            Quantity::from(100),
2142            now_ns,
2143            now_ns,
2144        );
2145        let target_call_quote = QuoteTick::new(
2146            target_call_option.id(),
2147            Price::from("6.75"),
2148            Price::from("6.75"),
2149            Quantity::from(100),
2150            Quantity::from(100),
2151            now_ns,
2152            now_ns,
2153        );
2154        let reference_future_quote = QuoteTick::new(
2155            reference_future.id(),
2156            Price::from("155.00"),
2157            Price::from("155.00"),
2158            Quantity::from(100),
2159            Quantity::from(100),
2160            now_ns,
2161            now_ns,
2162        );
2163        cache.borrow_mut().add_quote(call_quote).unwrap();
2164        cache.borrow_mut().add_quote(put_quote).unwrap();
2165        cache.borrow_mut().add_quote(target_call_quote).unwrap();
2166        cache
2167            .borrow_mut()
2168            .add_quote(reference_future_quote)
2169            .unwrap();
2170
2171        let clock = Rc::new(RefCell::new(TestClock::new()));
2172        clock.borrow_mut().set_time(now_ns);
2173        let calculator = GreeksCalculator::new(cache, clock);
2174        calculator
2175            .cache_futures_spread(call_option.id(), put_option.id(), reference_future.id())
2176            .unwrap();
2177
2178        let greeks = calculator
2179            .instrument_greeks(
2180                target_call_option.id(),
2181                Some(0.0425),
2182                None,
2183                None,
2184                None,
2185                None,
2186                None,
2187                None,
2188                None,
2189                None,
2190                Some(now_ns),
2191                None,
2192                None,
2193                None,
2194                None,
2195                None,
2196            )
2197            .unwrap();
2198
2199        let expected_underlying = reference_future
2200            .make_price(150.0 + (0.0425_f64 * (30.0 / 365.25)).exp() * (8.50 - 3.33))
2201            .as_f64();
2202        assert_eq!(greeks.underlying_price, expected_underlying);
2203    }
2204
2205    #[rstest]
2206    fn test_instrument_greeks_uses_index_price_for_index_underlying() {
2207        let now = Utc.with_ymd_and_hms(2024, 2, 14, 16, 0, 0).unwrap();
2208        let expiry = Utc.with_ymd_and_hms(2024, 3, 15, 16, 0, 0).unwrap();
2209        let now_ns = UnixNanos::from(now);
2210        let expiry_ns = UnixNanos::from(expiry);
2211
2212        let future = future_with_expiration("ESH4.GLBX", "ESH4", expiry_ns);
2213        let call_option = future_option_with_expiration(
2214            "ESH4C150.GLBX",
2215            "ESH4C150",
2216            "ESH4",
2217            OptionKind::Call,
2218            "150.00",
2219            expiry_ns,
2220        );
2221
2222        let cache = Rc::new(RefCell::new(Cache::new(None, None)));
2223        cache
2224            .borrow_mut()
2225            .add_instrument(InstrumentAny::FuturesContract(future))
2226            .unwrap();
2227        cache
2228            .borrow_mut()
2229            .add_instrument(InstrumentAny::OptionContract(call_option.clone()))
2230            .unwrap();
2231
2232        let call_quote = QuoteTick::new(
2233            call_option.id(),
2234            Price::from("8.50"),
2235            Price::from("8.50"),
2236            Quantity::from(100),
2237            Quantity::from(100),
2238            now_ns,
2239            now_ns,
2240        );
2241        cache.borrow_mut().add_quote(call_quote).unwrap();
2242        cache
2243            .borrow_mut()
2244            .add_index_price(IndexPriceUpdate::new(
2245                InstrumentId::from("ESH4.GLBX"),
2246                Price::from("157.25"),
2247                now_ns,
2248                now_ns,
2249            ))
2250            .unwrap();
2251
2252        let clock = Rc::new(RefCell::new(TestClock::new()));
2253        clock.borrow_mut().set_time(now_ns);
2254        let calculator = GreeksCalculator::new(cache, clock);
2255
2256        let greeks = calculator
2257            .instrument_greeks(
2258                call_option.id(),
2259                Some(0.0425),
2260                None,
2261                None,
2262                None,
2263                None,
2264                None,
2265                None,
2266                None,
2267                None,
2268                Some(now_ns),
2269                None,
2270                None,
2271                None,
2272                None,
2273                None,
2274            )
2275            .unwrap();
2276
2277        assert_eq!(greeks.underlying_price, 157.25);
2278    }
2279
2280    #[rstest]
2281    fn test_instrument_greeks_prefers_quote_over_index_price_for_index_future() {
2282        let now = Utc.with_ymd_and_hms(2024, 2, 14, 16, 0, 0).unwrap();
2283        let expiry = Utc.with_ymd_and_hms(2024, 3, 15, 16, 0, 0).unwrap();
2284        let now_ns = UnixNanos::from(now);
2285        let expiry_ns = UnixNanos::from(expiry);
2286
2287        let future = future_with_expiration("ESH4.GLBX", "ESH4", expiry_ns);
2288        let call_option = future_option_with_expiration(
2289            "ESH4C150.GLBX",
2290            "ESH4C150",
2291            "ESH4",
2292            OptionKind::Call,
2293            "150.00",
2294            expiry_ns,
2295        );
2296
2297        let cache = Rc::new(RefCell::new(Cache::new(None, None)));
2298        cache
2299            .borrow_mut()
2300            .add_instrument(InstrumentAny::FuturesContract(future))
2301            .unwrap();
2302        cache
2303            .borrow_mut()
2304            .add_instrument(InstrumentAny::OptionContract(call_option.clone()))
2305            .unwrap();
2306
2307        // Both a quote and an index price for the underlying future
2308        let future_quote = QuoteTick::new(
2309            InstrumentId::from("ESH4.GLBX"),
2310            Price::from("158.50"),
2311            Price::from("159.50"),
2312            Quantity::from(100),
2313            Quantity::from(100),
2314            now_ns,
2315            now_ns,
2316        );
2317        cache.borrow_mut().add_quote(future_quote).unwrap();
2318        cache
2319            .borrow_mut()
2320            .add_index_price(IndexPriceUpdate::new(
2321                InstrumentId::from("ESH4.GLBX"),
2322                Price::from("157.25"),
2323                now_ns,
2324                now_ns,
2325            ))
2326            .unwrap();
2327
2328        let call_quote = QuoteTick::new(
2329            call_option.id(),
2330            Price::from("8.50"),
2331            Price::from("8.50"),
2332            Quantity::from(100),
2333            Quantity::from(100),
2334            now_ns,
2335            now_ns,
2336        );
2337        cache.borrow_mut().add_quote(call_quote).unwrap();
2338
2339        let clock = Rc::new(RefCell::new(TestClock::new()));
2340        clock.borrow_mut().set_time(now_ns);
2341        let calculator = GreeksCalculator::new(cache, clock);
2342
2343        let greeks = calculator
2344            .instrument_greeks(
2345                call_option.id(),
2346                Some(0.0425),
2347                None,
2348                None,
2349                None,
2350                None,
2351                None,
2352                None,
2353                None,
2354                None,
2355                Some(now_ns),
2356                None,
2357                None,
2358                None,
2359                None,
2360                None,
2361            )
2362            .unwrap();
2363
2364        // Should use the MID quote (159.00), not the index price (157.25)
2365        assert_eq!(greeks.underlying_price, 159.0);
2366    }
2367}