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 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
42/// Type alias for a greeks filter function.
43pub type GreeksFilter = Box<dyn Fn(&GreeksData) -> bool>;
44
45/// Cloneable wrapper for greeks filter functions.
46#[derive(Clone)]
47pub enum GreeksFilterCallback {
48    /// Function pointer (non-capturing closure)
49    Function(fn(&GreeksData) -> bool),
50    /// Boxed closure (may capture variables)
51    Closure(std::rc::Rc<dyn Fn(&GreeksData) -> bool>),
52}
53
54impl GreeksFilterCallback {
55    /// Create a new filter from a function pointer.
56    pub fn from_fn(f: fn(&GreeksData) -> bool) -> Self {
57        Self::Function(f)
58    }
59
60    /// Create a new filter from a closure.
61    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    /// Call the filter function.
69    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    /// Convert to the original `GreeksFilter` type.
77    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/// Builder for instrument greeks calculation parameters.
98#[derive(Debug, bon::Builder)]
99pub struct InstrumentGreeksParams {
100    /// The instrument ID to calculate greeks for
101    pub instrument_id: InstrumentId,
102    /// Flat interest rate (default: 0.0425)
103    #[builder(default = 0.0425)]
104    pub flat_interest_rate: f64,
105    /// Flat dividend yield
106    pub flat_dividend_yield: Option<f64>,
107    /// Spot price shock (default: 0.0)
108    #[builder(default = 0.0)]
109    pub spot_shock: f64,
110    /// Volatility shock (default: 0.0)
111    #[builder(default = 0.0)]
112    pub vol_shock: f64,
113    /// Time to expiry shock (default: 0.0)
114    #[builder(default = 0.0)]
115    pub time_to_expiry_shock: f64,
116    /// Whether to use cached greeks (default: false)
117    #[builder(default = false)]
118    pub use_cached_greeks: bool,
119    /// Whether to update vol from cached greeks (default: false)
120    #[builder(default = false)]
121    pub update_vol: bool,
122    /// Whether to cache greeks (default: false)
123    #[builder(default = false)]
124    pub cache_greeks: bool,
125    /// Whether to publish greeks (default: false)
126    #[builder(default = false)]
127    pub publish_greeks: bool,
128    /// Event timestamp
129    pub ts_event: Option<UnixNanos>,
130    /// Position for PnL calculation
131    pub position: Option<Position>,
132    /// Whether to compute percent greeks (default: false)
133    #[builder(default = false)]
134    pub percent_greeks: bool,
135    /// Index instrument ID for beta weighting
136    pub index_instrument_id: Option<InstrumentId>,
137    /// Beta weights for portfolio calculations
138    pub beta_weights: Option<HashMap<InstrumentId, f64>>,
139    /// Base value in days for time-weighting vega
140    pub vega_time_weight_base: Option<i32>,
141    /// Volatility index instrument ID for vega beta weighting, for example VIX.
142    pub vol_index_instrument_id: Option<InstrumentId>,
143    /// Volatility beta weights for portfolio vega calculations
144    pub vol_beta_weights: Option<HashMap<InstrumentId, f64>>,
145}
146
147impl InstrumentGreeksParams {
148    /// Calculate instrument greeks using the builder parameters.
149    ///
150    /// # Errors
151    ///
152    /// Returns an error if the greeks calculation fails.
153    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/// Builder for portfolio greeks calculation parameters.
178#[derive(bon::Builder)]
179pub struct PortfolioGreeksParams {
180    /// List of underlying symbols to filter by
181    pub underlyings: Option<Vec<String>>,
182    /// Venue to filter positions by
183    pub venue: Option<Venue>,
184    /// Instrument ID to filter positions by
185    pub instrument_id: Option<InstrumentId>,
186    /// Strategy ID to filter positions by
187    pub strategy_id: Option<StrategyId>,
188    /// Position side to filter by (default: `None`)
189    pub side: Option<PositionSide>,
190    /// Flat interest rate (default: 0.0425)
191    #[builder(default = 0.0425)]
192    pub flat_interest_rate: f64,
193    /// Flat dividend yield
194    pub flat_dividend_yield: Option<f64>,
195    /// Spot price shock (default: 0.0)
196    #[builder(default = 0.0)]
197    pub spot_shock: f64,
198    /// Volatility shock (default: 0.0)
199    #[builder(default = 0.0)]
200    pub vol_shock: f64,
201    /// Time to expiry shock (default: 0.0)
202    #[builder(default = 0.0)]
203    pub time_to_expiry_shock: f64,
204    /// Whether to use cached greeks (default: false)
205    #[builder(default = false)]
206    pub use_cached_greeks: bool,
207    /// Whether to update vol from cached greeks (default: false)
208    #[builder(default = false)]
209    pub update_vol: bool,
210    /// Whether to cache greeks (default: false)
211    #[builder(default = false)]
212    pub cache_greeks: bool,
213    /// Whether to publish greeks (default: false)
214    #[builder(default = false)]
215    pub publish_greeks: bool,
216    /// Whether to compute percent greeks (default: false)
217    #[builder(default = false)]
218    pub percent_greeks: bool,
219    /// Index instrument ID for beta weighting
220    pub index_instrument_id: Option<InstrumentId>,
221    /// Beta weights for portfolio calculations
222    pub beta_weights: Option<HashMap<InstrumentId, f64>>,
223    /// Filter function for greeks
224    pub greeks_filter: Option<GreeksFilterCallback>,
225    /// Base value in days for time-weighting vega
226    pub vega_time_weight_base: Option<i32>,
227    /// Volatility index instrument ID for vega beta weighting, for example VIX.
228    pub vol_index_instrument_id: Option<InstrumentId>,
229    /// Volatility beta weights for portfolio vega calculations
230    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    /// 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            self.vol_index_instrument_id,
294            self.vol_beta_weights.as_ref(),
295        )
296    }
297}
298
299/// Calculates instrument and portfolio greeks (sensitivities of price moves with respect to market data moves).
300///
301/// Useful for risk management of options and futures portfolios.
302///
303/// Currently implemented greeks are:
304/// - Delta (first derivative of price with respect to spot move).
305/// - Gamma (second derivative of price with respect to spot move).
306/// - Vega (first derivative of price with respect to implied volatility of an option).
307/// - Theta (first derivative of price with respect to time to expiry).
308///
309/// Vega is expressed in terms of absolute percent changes ((dV / dVol) / 100).
310/// 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).
311///
312/// Also note that for ease of implementation we consider that american options (for stock options for example) are european for the computation of greeks.
313#[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    /// Creates a new [`GreeksCalculator`] instance.
323    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    /// Creates a new [`GreeksCalculator`] from a registered native actor.
332    ///
333    /// # Panics
334    ///
335    /// Panics if the actor has not been registered with a trader.
336    pub fn from_actor(actor: &impl DataActorNative) -> Self {
337        Self::new(actor.cache_rc(), actor.clock_rc())
338    }
339
340    /// Calculates option or underlying greeks for a given instrument and a quantity of 1.
341    ///
342    /// Additional features:
343    /// - Apply shocks to the spot value of the instrument's underlying, implied volatility, or time to expiry.
344    /// - Compute percent greeks.
345    /// - Compute beta-weighted delta, gamma, and vega with respect to an index.
346    ///
347    /// # Errors
348    ///
349    /// Returns an error if the instrument definition is not found, an option instrument
350    /// has no underlying identifier, or greeks calculation fails.
351    #[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        // Set default values
374        let flat_interest_rate = flat_interest_rate.unwrap_or(0.0425);
375        let spot_shock = spot_shock.unwrap_or(0.0);
376        let vol_shock = vol_shock.unwrap_or(0.0);
377        let time_to_expiry_shock = time_to_expiry_shock.unwrap_or(0.0);
378        let use_cached_greeks = use_cached_greeks.unwrap_or(false);
379        let update_vol = update_vol.unwrap_or(false);
380        let cache_greeks = cache_greeks.unwrap_or(false);
381        let publish_greeks = publish_greeks.unwrap_or(false);
382        let ts_event = ts_event.unwrap_or_default();
383        let percent_greeks = percent_greeks.unwrap_or(false);
384
385        let instrument = {
386            let cache = self.cache.borrow();
387            cache.try_instrument(&instrument_id)?.clone()
388        };
389
390        if instrument.instrument_class() != InstrumentClass::Option {
391            return self.calculate_non_option_greeks(
392                &instrument,
393                instrument_id,
394                spot_shock,
395                ts_event,
396                position,
397                percent_greeks,
398                index_instrument_id,
399                beta_weights,
400            );
401        }
402
403        let underlying_instrument_id =
404            Self::resolve_underlying_instrument_id(&instrument, instrument_id)?;
405        let mut greeks_data = self.calculate_option_greeks(
406            &instrument,
407            instrument_id,
408            underlying_instrument_id,
409            flat_interest_rate,
410            flat_dividend_yield,
411            use_cached_greeks,
412            update_vol,
413            cache_greeks,
414            publish_greeks,
415            ts_event,
416            percent_greeks,
417            index_instrument_id,
418            beta_weights,
419            vega_time_weight_base,
420            vol_index_instrument_id,
421            vol_beta_weights,
422        )?;
423
424        if spot_shock != 0.0 || vol_shock != 0.0 || time_to_expiry_shock != 0.0 {
425            greeks_data = self.apply_option_greeks_shocks(
426                &greeks_data,
427                underlying_instrument_id,
428                spot_shock,
429                vol_shock,
430                time_to_expiry_shock,
431                percent_greeks,
432                index_instrument_id,
433                beta_weights,
434                vega_time_weight_base,
435                vol_index_instrument_id,
436                vol_beta_weights,
437            )?;
438        }
439
440        if let Some(pos) = position {
441            greeks_data.pnl = greeks_data.price - pos.avg_px_open;
442        }
443
444        Ok(greeks_data)
445    }
446
447    fn resolve_underlying_instrument_id(
448        instrument: &InstrumentAny,
449        instrument_id: InstrumentId,
450    ) -> anyhow::Result<InstrumentId> {
451        let Some(underlying) = instrument.underlying() else {
452            anyhow::bail!("Instrument {instrument_id} has no underlying identifier");
453        };
454
455        Ok(InstrumentId::from(format!(
456            "{}.{}",
457            underlying, instrument_id.venue
458        )))
459    }
460
461    #[expect(clippy::too_many_arguments)]
462    fn calculate_non_option_greeks(
463        &self,
464        instrument: &InstrumentAny,
465        instrument_id: InstrumentId,
466        spot_shock: f64,
467        ts_event: UnixNanos,
468        position: Option<Position>,
469        percent_greeks: bool,
470        index_instrument_id: Option<InstrumentId>,
471        beta_weights: Option<&HashMap<InstrumentId, f64>>,
472    ) -> anyhow::Result<GreeksData> {
473        let multiplier = instrument.multiplier();
474        let underlying_instrument_id = instrument.id();
475        let underlying_price = self
476            .get_price(&underlying_instrument_id)
477            .ok_or_else(|| anyhow::anyhow!("No price available for {underlying_instrument_id}"))?;
478        let (delta, _, _) = self.modify_greeks(
479            1.0,
480            0.0,
481            underlying_instrument_id,
482            underlying_price + spot_shock,
483            underlying_price,
484            percent_greeks,
485            index_instrument_id,
486            beta_weights,
487            0.0,
488            0.0,
489            0,
490            None,
491            0.0,
492            None,
493            None,
494            None,
495            None,
496        )?;
497        let mut greeks_data =
498            GreeksData::from_delta(instrument_id, delta, multiplier.as_f64(), ts_event);
499
500        if let Some(pos) = position {
501            greeks_data.pnl = (underlying_price + spot_shock) - pos.avg_px_open;
502            greeks_data.price = greeks_data.pnl;
503        }
504
505        Ok(greeks_data)
506    }
507
508    #[expect(clippy::too_many_arguments)]
509    fn calculate_option_greeks(
510        &self,
511        instrument: &InstrumentAny,
512        instrument_id: InstrumentId,
513        underlying_instrument_id: InstrumentId,
514        flat_interest_rate: f64,
515        flat_dividend_yield: Option<f64>,
516        use_cached_greeks: bool,
517        update_vol: bool,
518        cache_greeks: bool,
519        publish_greeks: bool,
520        ts_event: UnixNanos,
521        percent_greeks: bool,
522        index_instrument_id: Option<InstrumentId>,
523        beta_weights: Option<&HashMap<InstrumentId, f64>>,
524        vega_time_weight_base: Option<i32>,
525        vol_index_instrument_id: Option<InstrumentId>,
526        vol_beta_weights: Option<&HashMap<InstrumentId, f64>>,
527    ) -> anyhow::Result<GreeksData> {
528        if use_cached_greeks {
529            let cache = self.cache.borrow();
530            if let Some(cached_greeks) = cache.greeks(&instrument_id) {
531                return Ok(cached_greeks);
532            }
533        }
534
535        let utc_now_ns = if ts_event == UnixNanos::default() {
536            self.clock.borrow().timestamp_ns()
537        } else {
538            ts_event
539        };
540        let utc_now = utc_now_ns.to_datetime_utc();
541        let expiry_utc = instrument
542            .expiration_ns()
543            .map(|ns| ns.to_datetime_utc())
544            .unwrap_or_default();
545        let expiry_int = expiry_utc
546            .strftime("%Y%m%d")
547            .to_string()
548            .parse::<i32>()
549            .unwrap_or(0);
550        let raw_days = utc_now.duration_until(expiry_utc).as_hours() / 24;
551        let expiry_in_days = raw_days.max(1) as i32;
552        let expiry_in_years = expiry_in_days as f64 / 365.25;
553        let currency = instrument.quote_currency().code.to_string();
554
555        let cache = self.cache.borrow();
556        let yield_curve = cache.yield_curve(&currency);
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        // Only fall back to cached futures spread when the underlying is a future
764        // (or absent from the cache, since the spread was explicitly cached).
765        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    /// Modifies delta, gamma, and vega based on beta weighting and percentage calculations.
783    ///
784    /// The beta weighting of delta and gamma follows this equation linking the returns of a stock x to the ones of an index I:
785    /// (x - x0) / x0 = alpha + beta (I - I0) / I0 + epsilon
786    ///
787    /// beta can be obtained by linear regression of `stock_return` = alpha + beta `index_return`, it's equal to:
788    /// beta = Covariance(`stock_returns`, `index_returns`) / Variance(`index_returns`)
789    ///
790    /// Considering alpha == 0:
791    /// x = x0 + beta x0 / I0 (I-I0)
792    /// I = I0 + 1 / beta I0 / x0 (x - x0)
793    ///
794    /// These two last equations explain the beta weighting below, considering the price of an option is V(x) and delta and gamma
795    /// are the first and second derivatives respectively of V.
796    ///
797    /// Vega beta weighting follows the same change of variable with implied volatility and a volatility index.
798    ///
799    /// Also percent greeks assume a change of variable to percent returns by writing:
800    /// V(x = x0 * (1 + `stock_percent_return` / 100))
801    /// or V(I = I0 * (1 + `index_percent_return` / 100))
802    ///
803    /// # Errors
804    ///
805    /// Returns an error if `vol_index_instrument_id` is supplied and no explicit or cached
806    /// volatility index price is available.
807    #[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        // Apply time weighting to vega if vega_time_weight_base is provided
912        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    /// Calculates the portfolio Greeks for a given set of positions.
923    ///
924    /// Aggregates the Greeks data for all open positions that match the specified criteria.
925    ///
926    /// Additional features:
927    /// - Apply shocks to the spot value of an instrument's underlying, implied volatility, or time to expiry.
928    /// - Compute percent greeks.
929    /// - Compute beta-weighted delta, gamma, and vega with respect to an index.
930    ///
931    /// # Errors
932    ///
933    /// Returns an error if any underlying greeks calculation fails.
934    #[expect(clippy::too_many_arguments)]
935    pub fn portfolio_greeks(
936        &self,
937        underlyings: Option<&[String]>,
938        venue: Option<Venue>,
939        instrument_id: Option<InstrumentId>,
940        strategy_id: Option<StrategyId>,
941        side: Option<PositionSide>,
942        flat_interest_rate: Option<f64>,
943        flat_dividend_yield: Option<f64>,
944        spot_shock: Option<f64>,
945        vol_shock: Option<f64>,
946        time_to_expiry_shock: Option<f64>,
947        use_cached_greeks: Option<bool>,
948        update_vol: Option<bool>,
949        cache_greeks: Option<bool>,
950        publish_greeks: Option<bool>,
951        percent_greeks: Option<bool>,
952        index_instrument_id: Option<InstrumentId>,
953        beta_weights: Option<&HashMap<InstrumentId, f64>>,
954        greeks_filter: Option<&GreeksFilter>,
955        vega_time_weight_base: Option<i32>,
956        vol_index_instrument_id: Option<InstrumentId>,
957        vol_beta_weights: Option<&HashMap<InstrumentId, f64>>,
958    ) -> anyhow::Result<PortfolioGreeks> {
959        let ts_event = self.clock.borrow().timestamp_ns();
960        let mut portfolio_greeks =
961            PortfolioGreeks::new(ts_event, ts_event, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0);
962
963        // Set default values
964        let flat_interest_rate = flat_interest_rate.unwrap_or(0.0425);
965        let spot_shock = spot_shock.unwrap_or(0.0);
966        let vol_shock = vol_shock.unwrap_or(0.0);
967        let time_to_expiry_shock = time_to_expiry_shock.unwrap_or(0.0);
968        let use_cached_greeks = use_cached_greeks.unwrap_or(false);
969        let update_vol = update_vol.unwrap_or(false);
970        let cache_greeks = cache_greeks.unwrap_or(false);
971        let publish_greeks = publish_greeks.unwrap_or(false);
972        let percent_greeks = percent_greeks.unwrap_or(false);
973        let cache = self.cache.borrow();
974        let open_positions = cache.positions_open(
975            venue.as_ref(),
976            instrument_id.as_ref(),
977            strategy_id.as_ref(),
978            None, // account_id
979            side,
980        );
981        let open_positions: Vec<Position> =
982            open_positions.iter().map(PositionRef::cloned).collect();
983
984        for position in open_positions {
985            let position_instrument_id = position.instrument_id;
986
987            if let Some(underlyings_list) = underlyings {
988                let mut skip_position = true;
989
990                for underlying in underlyings_list {
991                    if position_instrument_id
992                        .symbol
993                        .as_str()
994                        .starts_with(underlying)
995                    {
996                        skip_position = false;
997                        break;
998                    }
999                }
1000
1001                if skip_position {
1002                    continue;
1003                }
1004            }
1005
1006            let quantity = position.signed_qty;
1007            let instrument_greeks = self.instrument_greeks(
1008                position_instrument_id,
1009                Some(flat_interest_rate),
1010                flat_dividend_yield,
1011                Some(spot_shock),
1012                Some(vol_shock),
1013                Some(time_to_expiry_shock),
1014                Some(use_cached_greeks),
1015                Some(update_vol),
1016                Some(cache_greeks),
1017                Some(publish_greeks),
1018                Some(ts_event),
1019                Some(position),
1020                Some(percent_greeks),
1021                index_instrument_id,
1022                beta_weights,
1023                vega_time_weight_base,
1024                vol_index_instrument_id,
1025                vol_beta_weights,
1026            )?;
1027            let position_greeks = quantity * &instrument_greeks;
1028
1029            // Apply greeks filter if provided
1030            if greeks_filter.is_none_or(|filter| filter(&position_greeks)) {
1031                portfolio_greeks = portfolio_greeks + PortfolioGreeks::from(position_greeks);
1032            }
1033        }
1034
1035        Ok(portfolio_greeks)
1036    }
1037
1038    /// Cache a futures spread derived from a call/put pair against a reference future.
1039    ///
1040    /// # Errors
1041    ///
1042    /// Returns an error if instruments or prices are missing or inconsistent.
1043    pub fn cache_futures_spread(
1044        &self,
1045        call_instrument_id: InstrumentId,
1046        put_instrument_id: InstrumentId,
1047        futures_instrument_id: InstrumentId,
1048    ) -> anyhow::Result<Price> {
1049        let cache = self.cache.borrow();
1050        let call_instrument = cache.instrument(&call_instrument_id).cloned();
1051        let put_instrument = cache.instrument(&put_instrument_id).cloned();
1052        let reference_future_instrument = cache.instrument(&futures_instrument_id).cloned();
1053        drop(cache);
1054
1055        let Some(call_instrument) = call_instrument else {
1056            anyhow::bail!(
1057                "Cannot cache futures spread: missing option instrument {call_instrument_id}"
1058            );
1059        };
1060        let Some(put_instrument) = put_instrument else {
1061            anyhow::bail!(
1062                "Cannot cache futures spread: missing option instrument {put_instrument_id}"
1063            );
1064        };
1065        let Some(reference_future_instrument) = reference_future_instrument else {
1066            anyhow::bail!(
1067                "Cannot cache futures spread: no reference futures instrument for {futures_instrument_id}"
1068            );
1069        };
1070
1071        if call_instrument.instrument_class() != InstrumentClass::Option
1072            || put_instrument.instrument_class() != InstrumentClass::Option
1073        {
1074            anyhow::bail!(
1075                "Cannot cache futures spread: non-option instruments provided call_instrument_id={call_instrument_id} put_instrument_id={put_instrument_id}"
1076            );
1077        }
1078
1079        if call_instrument.option_kind() != Some(OptionKind::Call)
1080            || put_instrument.option_kind() != Some(OptionKind::Put)
1081        {
1082            anyhow::bail!(
1083                "Cannot cache futures spread: expected call/put pair call_instrument_id={call_instrument_id} put_instrument_id={put_instrument_id}"
1084            );
1085        }
1086
1087        let Some(call_underlying) = call_instrument.underlying() else {
1088            anyhow::bail!(
1089                "Cannot cache futures spread: missing call underlying for {call_instrument_id}"
1090            );
1091        };
1092        let Some(put_underlying) = put_instrument.underlying() else {
1093            anyhow::bail!(
1094                "Cannot cache futures spread: missing put underlying for {put_instrument_id}"
1095            );
1096        };
1097
1098        if call_underlying != put_underlying {
1099            anyhow::bail!(
1100                "Cannot cache futures spread: option underlyings differ call_instrument_id={call_instrument_id} put_instrument_id={put_instrument_id}"
1101            );
1102        }
1103
1104        if call_instrument.strike_price() != put_instrument.strike_price() {
1105            anyhow::bail!(
1106                "Cannot cache futures spread: strike prices differ call_instrument_id={call_instrument_id} put_instrument_id={put_instrument_id}"
1107            );
1108        }
1109
1110        if call_instrument.expiration_ns() != put_instrument.expiration_ns() {
1111            anyhow::bail!(
1112                "Cannot cache futures spread: expiration dates differ call_instrument_id={call_instrument_id} put_instrument_id={put_instrument_id}"
1113            );
1114        }
1115
1116        let reference_future_price = self.get_price_object(&futures_instrument_id).ok_or_else(|| {
1117            anyhow::anyhow!(
1118                "Cannot cache futures spread: no reference futures price for {futures_instrument_id}"
1119            )
1120        })?;
1121        let call_price = self.get_price(&call_instrument_id).ok_or_else(|| {
1122            anyhow::anyhow!(
1123                "Cannot cache futures spread: missing option price for {call_instrument_id}"
1124            )
1125        })?;
1126        let put_price = self.get_price(&put_instrument_id).ok_or_else(|| {
1127            anyhow::anyhow!(
1128                "Cannot cache futures spread: missing option price for {put_instrument_id}"
1129            )
1130        })?;
1131
1132        let underlying_instrument_id =
1133            InstrumentId::from(format!("{call_underlying}.{}", call_instrument_id.venue));
1134
1135        // Reject if the underlying is present in cache but is not a future
1136        {
1137            let cache = self.cache.borrow();
1138            if let Some(underlying) = cache.instrument(&underlying_instrument_id)
1139                && underlying.instrument_class() != InstrumentClass::Future
1140            {
1141                anyhow::bail!(
1142                    "Cannot cache futures spread: underlying {underlying_instrument_id} is not a futures contract"
1143                );
1144            }
1145        }
1146
1147        let implied_future_price =
1148            self.calculate_implied_future_price(&call_instrument, call_price, put_price);
1149        let spread = implied_future_price - reference_future_price.as_f64();
1150        let spread_price = reference_future_instrument.make_price(spread);
1151
1152        self.cached_futures_spreads.borrow_mut().insert(
1153            underlying_instrument_id,
1154            (futures_instrument_id, spread_price),
1155        );
1156
1157        Ok(reference_future_price + spread_price)
1158    }
1159
1160    fn calculate_implied_future_price(
1161        &self,
1162        call_instrument: &InstrumentAny,
1163        call_price: f64,
1164        put_price: f64,
1165    ) -> f64 {
1166        let expiry_utc = call_instrument
1167            .expiration_ns()
1168            .map(|ns| ns.to_datetime_utc())
1169            .unwrap_or_default();
1170        let now = self.clock.borrow().timestamp_ns().to_datetime_utc();
1171        let expiry_in_days = (now.duration_until(expiry_utc).as_hours() / 24).max(1) as i32;
1172        let expiry_in_years = expiry_in_days as f64 / 365.25;
1173        let currency = call_instrument.quote_currency().code.to_string();
1174        let interest_rate = self
1175            .cache
1176            .borrow()
1177            .yield_curve(&currency)
1178            .map_or(0.0425, |yield_curve| yield_curve(expiry_in_years));
1179        let strike = call_instrument.strike_price().unwrap_or_default().as_f64();
1180
1181        strike + (interest_rate * expiry_in_years).exp() * (call_price - put_price)
1182    }
1183
1184    /// Resolve a cached futures spread price for an underlying future.
1185    #[must_use]
1186    pub fn get_cached_futures_spread_price(
1187        &self,
1188        underlying_instrument_id: InstrumentId,
1189    ) -> Option<Price> {
1190        let (futures_instrument_id, spread) = self
1191            .cached_futures_spreads
1192            .borrow()
1193            .get(&underlying_instrument_id)
1194            .copied()?;
1195        let reference_future_price = self.get_price_object(&futures_instrument_id)?;
1196
1197        Some(reference_future_price + spread)
1198    }
1199
1200    fn get_price_object(&self, instrument_id: &InstrumentId) -> Option<Price> {
1201        let cache = self.cache.borrow();
1202        let price = cache
1203            .price(instrument_id, PriceType::Mid)
1204            .or_else(|| cache.price(instrument_id, PriceType::Last));
1205
1206        // For index-class futures, prefer tradable quotes over the published index
1207        // price since index price is the spot level and may diverge from futures basis.
1208        // For true index instruments (non-futures), prefer the published index price.
1209        if let Some(instrument) = cache.instrument(instrument_id)
1210            && instrument.asset_class() == AssetClass::Index
1211        {
1212            if instrument.instrument_class() == InstrumentClass::Future && price.is_some() {
1213                return price;
1214            }
1215
1216            if let Some(index_price) = cache.index_price(instrument_id) {
1217                return Some(index_price.value);
1218            }
1219        }
1220
1221        price
1222    }
1223
1224    fn get_price(&self, instrument_id: &InstrumentId) -> Option<f64> {
1225        self.get_price_object(instrument_id)
1226            .map(|price| price.as_f64())
1227    }
1228
1229    /// Subscribes to Greeks data for a given underlying instrument.
1230    ///
1231    /// Useful for reading greeks from a backtesting data catalog and caching them for later use.
1232    pub fn subscribe_greeks<F>(&self, underlying: &str, handler: Option<F>)
1233    where
1234        F: Fn(&GreeksData) + 'static,
1235    {
1236        let pattern = format!("data.GreeksData.instrument_id={underlying}*").into();
1237
1238        if let Some(custom_handler) = handler {
1239            let typed_handler = TypedHandler::from(custom_handler);
1240            msgbus::subscribe_greeks(pattern, typed_handler, None);
1241        } else {
1242            let cache_ref = self.cache.clone();
1243            let typed_handler = TypedHandler::from(move |greeks: &GreeksData| {
1244                let mut cache = cache_ref.borrow_mut();
1245                cache.add_greeks(greeks.clone()).unwrap_or_default();
1246            });
1247            msgbus::subscribe_greeks(pattern, typed_handler, None);
1248        }
1249    }
1250}
1251
1252#[cfg(test)]
1253mod tests {
1254    use std::{cell::RefCell, collections::HashMap, rc::Rc};
1255
1256    use jiff::{Timestamp, civil::Date, tz::Offset};
1257    use nautilus_model::{
1258        data::{IndexPriceUpdate, QuoteTick},
1259        enums::{AssetClass, OmsType, OptionKind, OrderSide, PositionSide},
1260        events::order::spec::OrderFilledSpec,
1261        identifiers::{
1262            ClientOrderId, InstrumentId, PositionId, StrategyId, Symbol, TradeId, Venue,
1263        },
1264        instruments::{Equity, FuturesContract, OptionContract, any::InstrumentAny},
1265        types::{Currency, Price, Quantity},
1266    };
1267    use rstest::rstest;
1268    use ustr::Ustr;
1269
1270    use super::*;
1271    use crate::{cache::Cache, clock::TestClock};
1272
1273    fn utc_timestamp(year: i16, month: i8, day: i8, hour: i8, minute: i8, second: i8) -> Timestamp {
1274        Offset::UTC
1275            .to_timestamp(
1276                Date::new(year, month, day)
1277                    .unwrap()
1278                    .at(hour, minute, second, 0),
1279            )
1280            .unwrap()
1281    }
1282
1283    fn create_test_calculator() -> GreeksCalculator {
1284        let cache = Rc::new(RefCell::new(Cache::new(None, None)));
1285        let clock = Rc::new(RefCell::new(TestClock::new()));
1286        GreeksCalculator::new(cache, clock)
1287    }
1288
1289    #[rstest]
1290    fn test_greeks_calculator_creation() {
1291        let calculator = create_test_calculator();
1292        // Test that the calculator can be created
1293        assert!(format!("{calculator:?}").contains("GreeksCalculator"));
1294    }
1295
1296    #[rstest]
1297    fn test_greeks_calculator_debug() {
1298        let calculator = create_test_calculator();
1299        // Test the debug representation
1300        let debug_str = format!("{calculator:?}");
1301        assert!(debug_str.contains("GreeksCalculator"));
1302    }
1303
1304    #[rstest]
1305    fn test_greeks_calculator_has_python_bindings() {
1306        // This test just verifies that the GreeksCalculator struct
1307        // can be compiled with Python bindings enabled
1308        let calculator = create_test_calculator();
1309        // The Python methods are only accessible from Python,
1310        // but we can verify the struct compiles correctly
1311        assert!(format!("{calculator:?}").contains("GreeksCalculator"));
1312    }
1313
1314    #[rstest]
1315    fn test_instrument_greeks_params_builder_default() {
1316        let instrument_id = InstrumentId::from("AAPL.NASDAQ");
1317
1318        let params = InstrumentGreeksParams::builder()
1319            .instrument_id(instrument_id)
1320            .build();
1321
1322        assert_eq!(params.instrument_id, instrument_id);
1323        assert_eq!(params.flat_interest_rate, 0.0425);
1324        assert_eq!(params.flat_dividend_yield, None);
1325        assert_eq!(params.spot_shock, 0.0);
1326        assert_eq!(params.vol_shock, 0.0);
1327        assert_eq!(params.time_to_expiry_shock, 0.0);
1328        assert!(!params.use_cached_greeks);
1329        assert!(!params.cache_greeks);
1330        assert!(!params.publish_greeks);
1331        assert_eq!(params.ts_event, None);
1332        assert_eq!(params.position, None);
1333        assert!(!params.percent_greeks);
1334        assert_eq!(params.index_instrument_id, None);
1335        assert_eq!(params.beta_weights, None);
1336        assert_eq!(params.vol_index_instrument_id, None);
1337        assert_eq!(params.vol_beta_weights, None);
1338    }
1339
1340    #[rstest]
1341    fn test_instrument_greeks_params_builder_custom_values() {
1342        let instrument_id = InstrumentId::from("AAPL.NASDAQ");
1343        let index_id = InstrumentId::from("SPY.NASDAQ");
1344        let vol_index_id = InstrumentId::from("VIX.XCBF");
1345        let mut beta_weights = HashMap::new();
1346        beta_weights.insert(instrument_id, 1.2);
1347        let mut vol_beta_weights = HashMap::new();
1348        vol_beta_weights.insert(instrument_id, 0.8);
1349
1350        let params = InstrumentGreeksParams::builder()
1351            .instrument_id(instrument_id)
1352            .flat_interest_rate(0.05)
1353            .flat_dividend_yield(0.02)
1354            .spot_shock(0.01)
1355            .vol_shock(0.05)
1356            .time_to_expiry_shock(0.1)
1357            .use_cached_greeks(true)
1358            .cache_greeks(true)
1359            .publish_greeks(true)
1360            .percent_greeks(true)
1361            .index_instrument_id(index_id)
1362            .beta_weights(beta_weights.clone())
1363            .vol_index_instrument_id(vol_index_id)
1364            .vol_beta_weights(vol_beta_weights.clone())
1365            .build();
1366
1367        assert_eq!(params.instrument_id, instrument_id);
1368        assert_eq!(params.flat_interest_rate, 0.05);
1369        assert_eq!(params.flat_dividend_yield, Some(0.02));
1370        assert_eq!(params.spot_shock, 0.01);
1371        assert_eq!(params.vol_shock, 0.05);
1372        assert_eq!(params.time_to_expiry_shock, 0.1);
1373        assert!(params.use_cached_greeks);
1374        assert!(params.cache_greeks);
1375        assert!(params.publish_greeks);
1376        assert!(params.percent_greeks);
1377        assert_eq!(params.index_instrument_id, Some(index_id));
1378        assert_eq!(params.beta_weights, Some(beta_weights));
1379        assert_eq!(params.vol_index_instrument_id, Some(vol_index_id));
1380        assert_eq!(params.vol_beta_weights, Some(vol_beta_weights));
1381    }
1382
1383    #[rstest]
1384    fn test_instrument_greeks_params_debug() {
1385        let instrument_id = InstrumentId::from("AAPL.NASDAQ");
1386
1387        let params = InstrumentGreeksParams::builder()
1388            .instrument_id(instrument_id)
1389            .build();
1390
1391        let debug_str = format!("{params:?}");
1392        assert!(debug_str.contains("InstrumentGreeksParams"));
1393        assert!(debug_str.contains("AAPL.NASDAQ"));
1394    }
1395
1396    #[rstest]
1397    fn test_portfolio_greeks_params_builder_default() {
1398        let params = PortfolioGreeksParams::builder().build();
1399
1400        assert_eq!(params.underlyings, None);
1401        assert_eq!(params.venue, None);
1402        assert_eq!(params.instrument_id, None);
1403        assert_eq!(params.strategy_id, None);
1404        assert_eq!(params.side, None);
1405        assert_eq!(params.flat_interest_rate, 0.0425);
1406        assert_eq!(params.flat_dividend_yield, None);
1407        assert_eq!(params.spot_shock, 0.0);
1408        assert_eq!(params.vol_shock, 0.0);
1409        assert_eq!(params.time_to_expiry_shock, 0.0);
1410        assert!(!params.use_cached_greeks);
1411        assert!(!params.cache_greeks);
1412        assert!(!params.publish_greeks);
1413        assert!(!params.percent_greeks);
1414        assert_eq!(params.index_instrument_id, None);
1415        assert_eq!(params.beta_weights, None);
1416        assert_eq!(params.vol_index_instrument_id, None);
1417        assert_eq!(params.vol_beta_weights, None);
1418    }
1419
1420    #[rstest]
1421    fn test_portfolio_greeks_params_builder_custom_values() {
1422        let venue = Venue::from("NASDAQ");
1423        let instrument_id = InstrumentId::from("AAPL.NASDAQ");
1424        let strategy_id = StrategyId::from("test-strategy");
1425        let index_id = InstrumentId::from("SPY.NASDAQ");
1426        let vol_index_id = InstrumentId::from("VIX.XCBF");
1427        let underlyings = vec!["AAPL".to_string(), "MSFT".to_string()];
1428        let mut beta_weights = HashMap::new();
1429        beta_weights.insert(instrument_id, 1.2);
1430        let mut vol_beta_weights = HashMap::new();
1431        vol_beta_weights.insert(instrument_id, 0.8);
1432
1433        let params = PortfolioGreeksParams::builder()
1434            .underlyings(underlyings.clone())
1435            .venue(venue)
1436            .instrument_id(instrument_id)
1437            .strategy_id(strategy_id)
1438            .side(PositionSide::Long)
1439            .flat_interest_rate(0.05)
1440            .flat_dividend_yield(0.02)
1441            .spot_shock(0.01)
1442            .vol_shock(0.05)
1443            .time_to_expiry_shock(0.1)
1444            .use_cached_greeks(true)
1445            .cache_greeks(true)
1446            .publish_greeks(true)
1447            .percent_greeks(true)
1448            .index_instrument_id(index_id)
1449            .beta_weights(beta_weights.clone())
1450            .vol_index_instrument_id(vol_index_id)
1451            .vol_beta_weights(vol_beta_weights.clone())
1452            .build();
1453
1454        assert_eq!(params.underlyings, Some(underlyings));
1455        assert_eq!(params.venue, Some(venue));
1456        assert_eq!(params.instrument_id, Some(instrument_id));
1457        assert_eq!(params.strategy_id, Some(strategy_id));
1458        assert_eq!(params.side, Some(PositionSide::Long));
1459        assert_eq!(params.flat_interest_rate, 0.05);
1460        assert_eq!(params.flat_dividend_yield, Some(0.02));
1461        assert_eq!(params.spot_shock, 0.01);
1462        assert_eq!(params.vol_shock, 0.05);
1463        assert_eq!(params.time_to_expiry_shock, 0.1);
1464        assert!(params.use_cached_greeks);
1465        assert!(params.cache_greeks);
1466        assert!(params.publish_greeks);
1467        assert!(params.percent_greeks);
1468        assert_eq!(params.index_instrument_id, Some(index_id));
1469        assert_eq!(params.beta_weights, Some(beta_weights));
1470        assert_eq!(params.vol_index_instrument_id, Some(vol_index_id));
1471        assert_eq!(params.vol_beta_weights, Some(vol_beta_weights));
1472    }
1473
1474    #[rstest]
1475    fn test_portfolio_greeks_params_debug() {
1476        let venue = Venue::from("NASDAQ");
1477
1478        let params = PortfolioGreeksParams::builder().venue(venue).build();
1479
1480        let debug_str = format!("{params:?}");
1481        assert!(debug_str.contains("PortfolioGreeksParams"));
1482        assert!(debug_str.contains("NASDAQ"));
1483    }
1484
1485    #[rstest]
1486    fn test_portfolio_greeks_params_builder_fluent_api() {
1487        let instrument_id = InstrumentId::from("AAPL.NASDAQ");
1488
1489        let params = PortfolioGreeksParams::builder()
1490            .instrument_id(instrument_id)
1491            .flat_interest_rate(0.05)
1492            .spot_shock(0.01)
1493            .percent_greeks(true)
1494            .build();
1495
1496        assert_eq!(params.instrument_id, Some(instrument_id));
1497        assert_eq!(params.flat_interest_rate, 0.05);
1498        assert_eq!(params.spot_shock, 0.01);
1499        assert!(params.percent_greeks);
1500    }
1501
1502    #[rstest]
1503    fn test_instrument_greeks_params_builder_fluent_chaining() {
1504        let instrument_id = InstrumentId::from("TSLA.NASDAQ");
1505
1506        // Test fluent API chaining
1507        let params = InstrumentGreeksParams::builder()
1508            .instrument_id(instrument_id)
1509            .flat_interest_rate(0.03)
1510            .spot_shock(0.02)
1511            .vol_shock(0.1)
1512            .use_cached_greeks(true)
1513            .percent_greeks(true)
1514            .build();
1515
1516        assert_eq!(params.instrument_id, instrument_id);
1517        assert_eq!(params.flat_interest_rate, 0.03);
1518        assert_eq!(params.spot_shock, 0.02);
1519        assert_eq!(params.vol_shock, 0.1);
1520        assert!(params.use_cached_greeks);
1521        assert!(params.percent_greeks);
1522    }
1523
1524    #[rstest]
1525    fn test_portfolio_greeks_params_builder_with_underlyings() {
1526        let underlyings = vec!["AAPL".to_string(), "MSFT".to_string(), "GOOGL".to_string()];
1527
1528        let params = PortfolioGreeksParams::builder()
1529            .underlyings(underlyings.clone())
1530            .flat_interest_rate(0.04)
1531            .build();
1532
1533        assert_eq!(params.underlyings, Some(underlyings));
1534        assert_eq!(params.flat_interest_rate, 0.04);
1535    }
1536
1537    #[rstest]
1538    fn test_builders_with_empty_beta_weights() {
1539        let instrument_id = InstrumentId::from("NVDA.NASDAQ");
1540        let empty_beta_weights = HashMap::new();
1541
1542        let instrument_params = InstrumentGreeksParams::builder()
1543            .instrument_id(instrument_id)
1544            .beta_weights(empty_beta_weights.clone())
1545            .vol_beta_weights(empty_beta_weights.clone())
1546            .build();
1547
1548        let portfolio_params = PortfolioGreeksParams::builder()
1549            .beta_weights(empty_beta_weights.clone())
1550            .vol_beta_weights(empty_beta_weights.clone())
1551            .build();
1552
1553        assert_eq!(
1554            instrument_params.beta_weights,
1555            Some(empty_beta_weights.clone())
1556        );
1557        assert_eq!(portfolio_params.beta_weights, Some(empty_beta_weights));
1558        assert_eq!(instrument_params.vol_beta_weights, Some(HashMap::new()));
1559        assert_eq!(portfolio_params.vol_beta_weights, Some(HashMap::new()));
1560    }
1561
1562    #[rstest]
1563    fn test_builders_with_all_shocks() {
1564        let instrument_id = InstrumentId::from("AMD.NASDAQ");
1565
1566        let instrument_params = InstrumentGreeksParams::builder()
1567            .instrument_id(instrument_id)
1568            .spot_shock(0.05)
1569            .vol_shock(0.1)
1570            .time_to_expiry_shock(0.01)
1571            .build();
1572
1573        let portfolio_params = PortfolioGreeksParams::builder()
1574            .spot_shock(0.05)
1575            .vol_shock(0.1)
1576            .time_to_expiry_shock(0.01)
1577            .build();
1578
1579        assert_eq!(instrument_params.spot_shock, 0.05);
1580        assert_eq!(instrument_params.vol_shock, 0.1);
1581        assert_eq!(instrument_params.time_to_expiry_shock, 0.01);
1582
1583        assert_eq!(portfolio_params.spot_shock, 0.05);
1584        assert_eq!(portfolio_params.vol_shock, 0.1);
1585        assert_eq!(portfolio_params.time_to_expiry_shock, 0.01);
1586    }
1587
1588    #[rstest]
1589    fn test_builders_with_all_boolean_flags() {
1590        let instrument_id = InstrumentId::from("META.NASDAQ");
1591
1592        let instrument_params = InstrumentGreeksParams::builder()
1593            .instrument_id(instrument_id)
1594            .use_cached_greeks(true)
1595            .cache_greeks(true)
1596            .publish_greeks(true)
1597            .percent_greeks(true)
1598            .build();
1599
1600        let portfolio_params = PortfolioGreeksParams::builder()
1601            .use_cached_greeks(true)
1602            .cache_greeks(true)
1603            .publish_greeks(true)
1604            .percent_greeks(true)
1605            .build();
1606
1607        assert!(instrument_params.use_cached_greeks);
1608        assert!(instrument_params.cache_greeks);
1609        assert!(instrument_params.publish_greeks);
1610        assert!(instrument_params.percent_greeks);
1611
1612        assert!(portfolio_params.use_cached_greeks);
1613        assert!(portfolio_params.cache_greeks);
1614        assert!(portfolio_params.publish_greeks);
1615        assert!(portfolio_params.percent_greeks);
1616    }
1617
1618    #[rstest]
1619    fn test_greeks_filter_callback_function() {
1620        // Test function pointer filter
1621        fn filter_positive_delta(data: &GreeksData) -> bool {
1622            data.delta > 0.0
1623        }
1624
1625        let filter = GreeksFilterCallback::from_fn(filter_positive_delta);
1626
1627        // Create test data
1628        let greeks_data = GreeksData::from_delta(
1629            InstrumentId::from("TEST.NASDAQ"),
1630            0.5,
1631            1.0,
1632            UnixNanos::default(),
1633        );
1634
1635        assert!(filter.call(&greeks_data));
1636
1637        // Test debug formatting
1638        let debug_str = format!("{filter:?}");
1639        assert!(debug_str.contains("GreeksFilterCallback::Function"));
1640    }
1641
1642    #[rstest]
1643    fn test_greeks_filter_callback_closure() {
1644        // Test closure filter that captures a variable
1645        let min_delta = 0.3;
1646        let filter =
1647            GreeksFilterCallback::from_closure(move |data: &GreeksData| data.delta > min_delta);
1648
1649        // Create test data
1650        let greeks_data = GreeksData::from_delta(
1651            InstrumentId::from("TEST.NASDAQ"),
1652            0.5,
1653            1.0,
1654            UnixNanos::default(),
1655        );
1656
1657        assert!(filter.call(&greeks_data));
1658
1659        // Test debug formatting
1660        let debug_str = format!("{filter:?}");
1661        assert!(debug_str.contains("GreeksFilterCallback::Closure"));
1662    }
1663
1664    #[rstest]
1665    fn test_greeks_filter_callback_clone() {
1666        fn filter_fn(data: &GreeksData) -> bool {
1667            data.delta > 0.0
1668        }
1669
1670        let filter1 = GreeksFilterCallback::from_fn(filter_fn);
1671        let filter2 = filter1.clone();
1672
1673        let greeks_data = GreeksData::from_delta(
1674            InstrumentId::from("TEST.NASDAQ"),
1675            0.5,
1676            1.0,
1677            UnixNanos::default(),
1678        );
1679
1680        assert!(filter1.call(&greeks_data));
1681        assert!(filter2.call(&greeks_data));
1682    }
1683
1684    #[rstest]
1685    fn test_portfolio_greeks_params_with_filter() {
1686        fn filter_high_delta(data: &GreeksData) -> bool {
1687            data.delta.abs() > 0.1
1688        }
1689
1690        let filter = GreeksFilterCallback::from_fn(filter_high_delta);
1691
1692        let params = PortfolioGreeksParams::builder()
1693            .greeks_filter(filter)
1694            .flat_interest_rate(0.05)
1695            .build();
1696
1697        assert!(params.greeks_filter.is_some());
1698        assert_eq!(params.flat_interest_rate, 0.05);
1699
1700        // Test that the filter can be called
1701        let greeks_data = GreeksData::from_delta(
1702            InstrumentId::from("TEST.NASDAQ"),
1703            0.5,
1704            1.0,
1705            UnixNanos::default(),
1706        );
1707
1708        let filter_ref = params.greeks_filter.as_ref().unwrap();
1709        assert!(filter_ref.call(&greeks_data));
1710    }
1711
1712    #[rstest]
1713    fn test_portfolio_greeks_params_with_closure_filter() {
1714        let min_gamma = 0.01;
1715        let filter =
1716            GreeksFilterCallback::from_closure(move |data: &GreeksData| data.gamma > min_gamma);
1717
1718        let params = PortfolioGreeksParams::builder()
1719            .greeks_filter(filter)
1720            .build();
1721
1722        assert!(params.greeks_filter.is_some());
1723
1724        // Test debug formatting includes the filter
1725        let debug_str = format!("{params:?}");
1726        assert!(debug_str.contains("greeks_filter"));
1727    }
1728
1729    #[rstest]
1730    fn test_greeks_filter_to_greeks_filter_conversion() {
1731        fn filter_fn(data: &GreeksData) -> bool {
1732            data.delta > 0.0
1733        }
1734
1735        let callback = GreeksFilterCallback::from_fn(filter_fn);
1736        let greeks_filter = callback.to_greeks_filter();
1737
1738        let greeks_data = GreeksData::from_delta(
1739            InstrumentId::from("TEST.NASDAQ"),
1740            0.5,
1741            1.0,
1742            UnixNanos::default(),
1743        );
1744
1745        assert!(greeks_filter(&greeks_data));
1746    }
1747
1748    fn option_with_expiration(instrument_id: &str, expiration_ns: UnixNanos) -> OptionContract {
1749        let activation_ns = UnixNanos::from(utc_timestamp(2021, 9, 17, 0, 0, 0));
1750        OptionContract::builder()
1751            .instrument_id(InstrumentId::from(instrument_id))
1752            .raw_symbol(Symbol::from("AAPL211217C00150000"))
1753            .asset_class(AssetClass::Equity)
1754            .exchange(Ustr::from("GMNI"))
1755            .underlying(Ustr::from("AAPL"))
1756            .option_kind(OptionKind::Call)
1757            .strike_price(Price::from("149.0"))
1758            .currency(Currency::from("USD"))
1759            .activation_ns(activation_ns)
1760            .expiration_ns(expiration_ns)
1761            .price_precision(2)
1762            .price_increment(Price::from("0.01"))
1763            .multiplier(Quantity::from(100))
1764            .lot_size(Quantity::from(1))
1765            .ts_event(UnixNanos::default())
1766            .ts_init(UnixNanos::default())
1767            .build()
1768            .unwrap()
1769    }
1770
1771    fn equity_aapl_opra() -> Equity {
1772        Equity::builder()
1773            .instrument_id(InstrumentId::from("AAPL.OPRA"))
1774            .raw_symbol(Symbol::from("AAPL"))
1775            .isin(Ustr::from("US0378331005"))
1776            .currency(Currency::from("USD"))
1777            .price_precision(2)
1778            .price_increment(Price::from("0.01"))
1779            .ts_event(UnixNanos::default())
1780            .ts_init(UnixNanos::default())
1781            .build()
1782            .unwrap()
1783    }
1784
1785    #[rstest]
1786    fn test_resolve_underlying_instrument_id_errors_without_underlying() {
1787        let instrument = InstrumentAny::Equity(equity_aapl_opra());
1788        let error = GreeksCalculator::resolve_underlying_instrument_id(
1789            &instrument,
1790            InstrumentId::from("AAPL.OPRA"),
1791        )
1792        .unwrap_err();
1793
1794        assert_eq!(
1795            error.to_string(),
1796            "Instrument AAPL.OPRA has no underlying identifier"
1797        );
1798    }
1799
1800    fn future_with_expiration(
1801        instrument_id: &str,
1802        underlying: &str,
1803        expiration_ns: UnixNanos,
1804    ) -> FuturesContract {
1805        FuturesContract::builder()
1806            .instrument_id(InstrumentId::from(instrument_id))
1807            .raw_symbol(Symbol::from(underlying))
1808            .asset_class(AssetClass::Index)
1809            .exchange(Ustr::from("XCME"))
1810            .underlying(Ustr::from(underlying))
1811            .activation_ns(UnixNanos::default())
1812            .expiration_ns(expiration_ns)
1813            .currency(Currency::from("USD"))
1814            .price_precision(2)
1815            .price_increment(Price::from("0.25"))
1816            .multiplier(Quantity::from(1))
1817            .lot_size(Quantity::from(1))
1818            .ts_event(UnixNanos::default())
1819            .ts_init(UnixNanos::default())
1820            .build()
1821            .unwrap()
1822    }
1823
1824    fn future_option_with_expiration(
1825        instrument_id: &str,
1826        raw_symbol: &str,
1827        underlying: &str,
1828        option_kind: OptionKind,
1829        strike: &str,
1830        expiration_ns: UnixNanos,
1831    ) -> OptionContract {
1832        OptionContract::builder()
1833            .instrument_id(InstrumentId::from(instrument_id))
1834            .raw_symbol(Symbol::from(raw_symbol))
1835            .asset_class(AssetClass::Index)
1836            .exchange(Ustr::from("XCME"))
1837            .underlying(Ustr::from(underlying))
1838            .option_kind(option_kind)
1839            .strike_price(Price::from(strike))
1840            .currency(Currency::from("USD"))
1841            .activation_ns(UnixNanos::default())
1842            .expiration_ns(expiration_ns)
1843            .price_precision(2)
1844            .price_increment(Price::from("0.01"))
1845            .multiplier(Quantity::from(1))
1846            .lot_size(Quantity::from(1))
1847            .ts_event(UnixNanos::default())
1848            .ts_init(UnixNanos::default())
1849            .build()
1850            .unwrap()
1851    }
1852
1853    fn setup_cache_with_option_and_quotes(
1854        option: OptionContract,
1855        underlying_id: InstrumentId,
1856        now_ns: UnixNanos,
1857    ) -> Rc<RefCell<Cache>> {
1858        let option_id = option.id();
1859        let cache = Rc::new(RefCell::new(Cache::new(None, None)));
1860        cache
1861            .borrow_mut()
1862            .add_instrument(InstrumentAny::OptionContract(option))
1863            .unwrap();
1864        cache
1865            .borrow_mut()
1866            .add_instrument(InstrumentAny::Equity(equity_aapl_opra()))
1867            .unwrap();
1868        let option_quote = QuoteTick::new(
1869            option_id,
1870            Price::from("10.50"),
1871            Price::from("10.60"),
1872            Quantity::from(100),
1873            Quantity::from(100),
1874            now_ns,
1875            now_ns,
1876        );
1877        let underlying_quote = QuoteTick::new(
1878            underlying_id,
1879            Price::from("150.00"),
1880            Price::from("150.10"),
1881            Quantity::from(100),
1882            Quantity::from(100),
1883            now_ns,
1884            now_ns,
1885        );
1886        cache.borrow_mut().add_quote(option_quote).unwrap();
1887        cache.borrow_mut().add_quote(underlying_quote).unwrap();
1888        cache
1889    }
1890
1891    fn position_from_fill(
1892        instrument: &InstrumentAny,
1893        position_id: &str,
1894        client_order_id: &str,
1895        trade_id: &str,
1896        side: OrderSide,
1897        quantity: u64,
1898        price: &str,
1899    ) -> Position {
1900        let fill = OrderFilledSpec::builder()
1901            .instrument_id(instrument.id())
1902            .client_order_id(ClientOrderId::from(client_order_id))
1903            .trade_id(TradeId::from(trade_id))
1904            .order_side(side)
1905            .last_qty(Quantity::from(quantity))
1906            .last_px(Price::from(price))
1907            .currency(Currency::USD())
1908            .position_id(PositionId::from(position_id))
1909            .build();
1910        Position::new(instrument, fill)
1911    }
1912
1913    fn calculate_portfolio_greeks(
1914        calculator: &GreeksCalculator,
1915        side: Option<PositionSide>,
1916    ) -> anyhow::Result<PortfolioGreeks> {
1917        calculator.portfolio_greeks(
1918            None, None, None, None, side, None, None, None, None, None, None, None, None, None,
1919            None, None, None, None, None, None, None,
1920        )
1921    }
1922
1923    fn assert_portfolio_greeks_eq(actual: &PortfolioGreeks, expected: &PortfolioGreeks) {
1924        assert_eq!(actual.ts_init, expected.ts_init);
1925        assert_eq!(actual.ts_event, expected.ts_event);
1926        assert_eq!(actual.pnl, expected.pnl);
1927        assert_eq!(actual.price, expected.price);
1928        assert_eq!(actual.delta, expected.delta);
1929        assert_eq!(actual.gamma, expected.gamma);
1930        assert_eq!(actual.vega, expected.vega);
1931        assert_eq!(actual.theta, expected.theta);
1932        assert_eq!(actual.rho, expected.rho);
1933    }
1934
1935    #[rstest]
1936    fn test_portfolio_greeks_ignores_closed_position_with_missing_price() {
1937        let now = utc_timestamp(2025, 3, 8, 12, 0, 0);
1938        let expiry = now + jiff::SignedDuration::from_hours(24 * 30);
1939        let now_ns = UnixNanos::from(now);
1940        let expiry_ns = UnixNanos::from(expiry);
1941        let open_option = option_with_expiration("AAPL250417C00150000.OPRA", expiry_ns);
1942        let open_option_id = open_option.id();
1943        let underlying_id = InstrumentId::from("AAPL.OPRA");
1944        let cache = setup_cache_with_option_and_quotes(open_option.clone(), underlying_id, now_ns);
1945        let closed_future = future_with_expiration("CLOSED.GLBX", "CLOSED", expiry_ns);
1946        let closed_future_id = closed_future.id();
1947        let open_instrument = InstrumentAny::OptionContract(open_option);
1948        let closed_instrument = InstrumentAny::FuturesContract(closed_future);
1949
1950        let open_position = position_from_fill(
1951            &open_instrument,
1952            "P-OPEN",
1953            "O-OPEN",
1954            "T-OPEN",
1955            OrderSide::Buy,
1956            2,
1957            "10.50",
1958        );
1959        let mut closed_position = position_from_fill(
1960            &closed_instrument,
1961            "P-CLOSED",
1962            "O-CLOSED-OPEN",
1963            "T-CLOSED-OPEN",
1964            OrderSide::Buy,
1965            1,
1966            "100.00",
1967        );
1968        cache
1969            .borrow_mut()
1970            .add_instrument(closed_instrument)
1971            .unwrap();
1972        cache
1973            .borrow_mut()
1974            .add_position(&open_position, OmsType::Hedging)
1975            .unwrap();
1976        cache
1977            .borrow_mut()
1978            .add_position(&closed_position, OmsType::Hedging)
1979            .unwrap();
1980        let closing_fill = OrderFilledSpec::builder()
1981            .instrument_id(closed_future_id)
1982            .client_order_id(ClientOrderId::from("O-CLOSED-CLOSE"))
1983            .trade_id(TradeId::from("T-CLOSED-CLOSE"))
1984            .order_side(OrderSide::Sell)
1985            .last_qty(Quantity::from(1))
1986            .last_px(Price::from("101.00"))
1987            .currency(Currency::USD())
1988            .position_id(PositionId::from("P-CLOSED"))
1989            .build();
1990        closed_position.apply(&closing_fill);
1991        cache
1992            .borrow_mut()
1993            .update_position(&closed_position)
1994            .unwrap();
1995
1996        // Pin the fixture itself: the closed position must have left the open index,
1997        // or this would exercise `add_position`'s open-index insertion rather than
1998        // the query scope under test.
1999        assert!(closed_position.is_closed());
2000        assert_eq!(
2001            cache
2002                .borrow()
2003                .positions_open(None, None, None, None, None)
2004                .len(),
2005            1
2006        );
2007
2008        let clock = Rc::new(RefCell::new(TestClock::new()));
2009        clock.borrow_mut().set_time(now_ns);
2010        let calculator = GreeksCalculator::new(cache, clock);
2011        let expected = calculator
2012            .instrument_greeks(
2013                open_option_id,
2014                None,
2015                None,
2016                None,
2017                None,
2018                None,
2019                None,
2020                None,
2021                None,
2022                None,
2023                Some(now_ns),
2024                Some(open_position.clone()),
2025                None,
2026                None,
2027                None,
2028                None,
2029                None,
2030                None,
2031            )
2032            .unwrap();
2033        let expected = PortfolioGreeks::from(open_position.signed_qty * &expected);
2034
2035        assert_ne!(expected.delta, 0.0);
2036        assert_portfolio_greeks_eq(
2037            &calculate_portfolio_greeks(&calculator, None).unwrap(),
2038            &expected,
2039        );
2040        assert_portfolio_greeks_eq(
2041            &calculate_portfolio_greeks(&calculator, Some(PositionSide::Flat)).unwrap(),
2042            &PortfolioGreeks::new(now_ns, now_ns, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0),
2043        );
2044    }
2045
2046    #[rstest]
2047    fn test_portfolio_greeks_preserves_open_position_aggregate_and_side_filters() {
2048        let now = utc_timestamp(2025, 3, 8, 12, 0, 0);
2049        let expiry = now + jiff::SignedDuration::from_hours(24 * 30);
2050        let now_ns = UnixNanos::from(now);
2051        let expiry_ns = UnixNanos::from(expiry);
2052        let long_option = option_with_expiration("AAPL250417C00145000.OPRA", expiry_ns);
2053        let short_option = option_with_expiration("AAPL250417C00155000.OPRA", expiry_ns);
2054        let long_instrument = InstrumentAny::OptionContract(long_option.clone());
2055        let short_instrument = InstrumentAny::OptionContract(short_option.clone());
2056        let underlying_id = InstrumentId::from("AAPL.OPRA");
2057        let cache = setup_cache_with_option_and_quotes(long_option, underlying_id, now_ns);
2058        cache
2059            .borrow_mut()
2060            .add_instrument(short_instrument.clone())
2061            .unwrap();
2062        cache
2063            .borrow_mut()
2064            .add_quote(QuoteTick::new(
2065                short_option.id(),
2066                Price::from("3.50"),
2067                Price::from("3.60"),
2068                Quantity::from(100),
2069                Quantity::from(100),
2070                now_ns,
2071                now_ns,
2072            ))
2073            .unwrap();
2074        let long_position = position_from_fill(
2075            &long_instrument,
2076            "P-LONG",
2077            "O-LONG",
2078            "T-LONG",
2079            OrderSide::Buy,
2080            3,
2081            "10.50",
2082        );
2083        let short_position = position_from_fill(
2084            &short_instrument,
2085            "P-SHORT",
2086            "O-SHORT",
2087            "T-SHORT",
2088            OrderSide::Sell,
2089            2,
2090            "3.50",
2091        );
2092        cache
2093            .borrow_mut()
2094            .add_position(&long_position, OmsType::Hedging)
2095            .unwrap();
2096        cache
2097            .borrow_mut()
2098            .add_position(&short_position, OmsType::Hedging)
2099            .unwrap();
2100
2101        let clock = Rc::new(RefCell::new(TestClock::new()));
2102        clock.borrow_mut().set_time(now_ns);
2103        let calculator = GreeksCalculator::new(cache, clock);
2104        let long_greeks = calculator
2105            .instrument_greeks(
2106                long_instrument.id(),
2107                None,
2108                None,
2109                None,
2110                None,
2111                None,
2112                None,
2113                None,
2114                None,
2115                None,
2116                Some(now_ns),
2117                Some(long_position.clone()),
2118                None,
2119                None,
2120                None,
2121                None,
2122                None,
2123                None,
2124            )
2125            .unwrap();
2126        let short_greeks = calculator
2127            .instrument_greeks(
2128                short_instrument.id(),
2129                None,
2130                None,
2131                None,
2132                None,
2133                None,
2134                None,
2135                None,
2136                None,
2137                None,
2138                Some(now_ns),
2139                Some(short_position.clone()),
2140                None,
2141                None,
2142                None,
2143                None,
2144                None,
2145                None,
2146            )
2147            .unwrap();
2148        let expected_long = PortfolioGreeks::from(long_position.signed_qty * &long_greeks);
2149        let expected_short = PortfolioGreeks::from(short_position.signed_qty * &short_greeks);
2150        let expected = expected_long + expected_short;
2151
2152        assert_ne!(expected.pnl, 0.0);
2153        assert_ne!(expected.price, 0.0);
2154        assert_ne!(expected.delta, 0.0);
2155        assert_ne!(expected.gamma, 0.0);
2156        assert_ne!(expected.vega, 0.0);
2157        assert_ne!(expected.theta, 0.0);
2158        assert_portfolio_greeks_eq(
2159            &calculate_portfolio_greeks(&calculator, None).unwrap(),
2160            &expected,
2161        );
2162        assert_portfolio_greeks_eq(
2163            &calculate_portfolio_greeks(&calculator, Some(PositionSide::Long)).unwrap(),
2164            &PortfolioGreeks::from(long_position.signed_qty * &long_greeks),
2165        );
2166        assert_portfolio_greeks_eq(
2167            &calculate_portfolio_greeks(&calculator, Some(PositionSide::Short)).unwrap(),
2168            &PortfolioGreeks::from(short_position.signed_qty * &short_greeks),
2169        );
2170    }
2171
2172    #[rstest]
2173    fn test_expiry_in_days_multi_day_unchanged() {
2174        let now = utc_timestamp(2025, 3, 8, 12, 0, 0);
2175        let expiry = now + jiff::SignedDuration::from_hours(24 * (30));
2176        let now_ns = UnixNanos::from(now);
2177        let expiry_ns = UnixNanos::from(expiry);
2178        let option = option_with_expiration("AAPL250417C00150000.OPRA", expiry_ns);
2179        let option_id = option.id();
2180        let underlying_id = InstrumentId::from("AAPL.OPRA");
2181        let cache = setup_cache_with_option_and_quotes(option, underlying_id, now_ns);
2182        let clock = Rc::new(RefCell::new(TestClock::new()));
2183        let calculator = GreeksCalculator::new(cache, clock);
2184
2185        let greeks = calculator
2186            .instrument_greeks(
2187                option_id,
2188                None,
2189                None,
2190                None,
2191                None,
2192                None,
2193                None,
2194                None,
2195                None,
2196                None,
2197                Some(now_ns),
2198                None,
2199                None,
2200                None,
2201                None,
2202                None,
2203                None,
2204                None,
2205            )
2206            .unwrap();
2207
2208        assert_eq!(greeks.expiry_in_days, 30);
2209        assert!((greeks.expiry_in_years - 30.0 / 365.25).abs() < 1e-9);
2210    }
2211
2212    #[rstest]
2213    fn test_expiry_in_days_same_day_clamped_to_one() {
2214        let now = utc_timestamp(2025, 3, 8, 12, 0, 0);
2215        let expiry_same_day = utc_timestamp(2025, 3, 8, 18, 0, 0);
2216        let now_ns = UnixNanos::from(now);
2217        let expiry_ns = UnixNanos::from(expiry_same_day);
2218        let option = option_with_expiration("AAPL250308C00150000.OPRA", expiry_ns);
2219        let option_id = option.id();
2220        let underlying_id = InstrumentId::from("AAPL.OPRA");
2221        let cache = setup_cache_with_option_and_quotes(option, underlying_id, now_ns);
2222        let clock = Rc::new(RefCell::new(TestClock::new()));
2223        let calculator = GreeksCalculator::new(cache, clock);
2224
2225        let greeks = calculator
2226            .instrument_greeks(
2227                option_id,
2228                None,
2229                None,
2230                None,
2231                None,
2232                None,
2233                None,
2234                None,
2235                None,
2236                None,
2237                Some(now_ns),
2238                None,
2239                None,
2240                None,
2241                None,
2242                None,
2243                None,
2244                None,
2245            )
2246            .unwrap();
2247
2248        assert_eq!(greeks.expiry_in_days, 1);
2249        assert!((greeks.expiry_in_years - 1.0 / 365.25).abs() < 1e-9);
2250    }
2251
2252    #[rstest]
2253    fn test_instrument_greeks_beta_weights_vega_to_vol_index() {
2254        let now = utc_timestamp(2025, 3, 8, 12, 0, 0);
2255        let expiry = now + jiff::SignedDuration::from_hours(24 * (30));
2256        let now_ns = UnixNanos::from(now);
2257        let expiry_ns = UnixNanos::from(expiry);
2258        let option = option_with_expiration("AAPL250417C00150000.OPRA", expiry_ns);
2259        let option_id = option.id();
2260        let underlying_id = InstrumentId::from("AAPL.OPRA");
2261        let vol_index_id = InstrumentId::from("VIX.XCBF");
2262        let cache = setup_cache_with_option_and_quotes(option, underlying_id, now_ns);
2263        cache
2264            .borrow_mut()
2265            .add_quote(QuoteTick::new(
2266                vol_index_id,
2267                Price::from("25.00"),
2268                Price::from("25.00"),
2269                Quantity::from(100),
2270                Quantity::from(100),
2271                now_ns,
2272                now_ns,
2273            ))
2274            .unwrap();
2275
2276        let clock = Rc::new(RefCell::new(TestClock::new()));
2277        let calculator = GreeksCalculator::new(cache, clock);
2278        let greeks = calculator
2279            .instrument_greeks(
2280                option_id,
2281                None,
2282                None,
2283                None,
2284                None,
2285                None,
2286                None,
2287                None,
2288                None,
2289                None,
2290                Some(now_ns),
2291                None,
2292                None,
2293                None,
2294                None,
2295                None,
2296                None,
2297                None,
2298            )
2299            .unwrap();
2300
2301        let mut vol_beta_weights = HashMap::new();
2302        vol_beta_weights.insert(underlying_id, 0.75);
2303        let vol_weighted_greeks = calculator
2304            .instrument_greeks(
2305                option_id,
2306                None,
2307                None,
2308                None,
2309                None,
2310                None,
2311                None,
2312                None,
2313                None,
2314                None,
2315                Some(now_ns),
2316                None,
2317                None,
2318                None,
2319                None,
2320                None,
2321                Some(vol_index_id),
2322                Some(&vol_beta_weights),
2323            )
2324            .unwrap();
2325
2326        let expected_vega = greeks.vega * 0.75 * (greeks.vol * 100.0) / 25.0;
2327        assert_eq!(
2328            (vol_weighted_greeks.delta * 1e12).round(),
2329            (greeks.delta * 1e12).round()
2330        );
2331        assert_eq!(
2332            (vol_weighted_greeks.gamma * 1e12).round(),
2333            (greeks.gamma * 1e12).round()
2334        );
2335        assert_eq!(
2336            (vol_weighted_greeks.vega * 1e12).round(),
2337            (expected_vega * 1e12).round()
2338        );
2339    }
2340
2341    #[rstest]
2342    fn test_instrument_greeks_errors_when_vol_index_price_missing() {
2343        let now = utc_timestamp(2025, 3, 8, 12, 0, 0);
2344        let expiry = now + jiff::SignedDuration::from_hours(24 * (30));
2345        let now_ns = UnixNanos::from(now);
2346        let expiry_ns = UnixNanos::from(expiry);
2347        let option = option_with_expiration("AAPL250417C00150000.OPRA", expiry_ns);
2348        let option_id = option.id();
2349        let underlying_id = InstrumentId::from("AAPL.OPRA");
2350        let vol_index_id = InstrumentId::from("VIX.XCBF");
2351        let cache = setup_cache_with_option_and_quotes(option, underlying_id, now_ns);
2352
2353        let clock = Rc::new(RefCell::new(TestClock::new()));
2354        let calculator = GreeksCalculator::new(cache, clock);
2355        let error = calculator
2356            .instrument_greeks(
2357                option_id,
2358                None,
2359                None,
2360                None,
2361                None,
2362                None,
2363                None,
2364                None,
2365                None,
2366                None,
2367                Some(now_ns),
2368                None,
2369                None,
2370                None,
2371                None,
2372                None,
2373                Some(vol_index_id),
2374                None,
2375            )
2376            .unwrap_err();
2377
2378        assert_eq!(error.to_string(), "No price available for VIX.XCBF");
2379    }
2380
2381    #[rstest]
2382    fn test_modify_greeks_errors_when_vol_index_price_missing() {
2383        let calculator = create_test_calculator();
2384        let underlying_id = InstrumentId::from("AAPL.OPRA");
2385        let vol_index_id = InstrumentId::from("VIX.XCBF");
2386
2387        let error = calculator
2388            .modify_greeks(
2389                1.0,
2390                2.0,
2391                underlying_id,
2392                150.0,
2393                150.0,
2394                false,
2395                None,
2396                None,
2397                2.0,
2398                0.30,
2399                0,
2400                None,
2401                0.0,
2402                Some(vol_index_id),
2403                None,
2404                None,
2405                None,
2406            )
2407            .unwrap_err();
2408
2409        assert_eq!(error.to_string(), "No price available for VIX.XCBF");
2410    }
2411
2412    #[rstest]
2413    fn test_modify_greeks_accepts_explicit_index_prices() {
2414        let calculator = create_test_calculator();
2415        let underlying_id = InstrumentId::from("AAPL.OPRA");
2416        let mut beta_weights = HashMap::new();
2417        beta_weights.insert(underlying_id, 0.5);
2418        let mut vol_beta_weights = HashMap::new();
2419        vol_beta_weights.insert(underlying_id, 0.75);
2420
2421        let (delta, gamma, vega) = calculator
2422            .modify_greeks(
2423                1.0,
2424                2.0,
2425                underlying_id,
2426                150.0,
2427                150.0,
2428                false,
2429                None,
2430                Some(&beta_weights),
2431                2.0,
2432                0.30,
2433                0,
2434                None,
2435                0.0,
2436                None,
2437                Some(&vol_beta_weights),
2438                Some(200.0),
2439                Some(25.0),
2440            )
2441            .unwrap();
2442
2443        assert_eq!((delta * 1e12).round(), 375_000_000_000.0);
2444        assert_eq!((gamma * 1e12).round(), 281_250_000_000.0);
2445        assert_eq!((vega * 1e12).round(), 1_800_000_000_000.0);
2446
2447        let (delta, gamma, vega) = calculator
2448            .modify_greeks(
2449                1.0,
2450                2.0,
2451                underlying_id,
2452                150.0,
2453                150.0,
2454                true,
2455                None,
2456                Some(&beta_weights),
2457                2.0,
2458                0.30,
2459                0,
2460                None,
2461                0.0,
2462                None,
2463                Some(&vol_beta_weights),
2464                Some(200.0),
2465                Some(25.0),
2466            )
2467            .unwrap();
2468
2469        assert_eq!((delta * 1e12).round(), 750_000_000_000.0);
2470        assert_eq!((gamma * 1e12).round(), 1_125_000_000_000.0);
2471        assert_eq!((vega * 1e12).round(), 4_500_000_000.0);
2472    }
2473
2474    #[rstest]
2475    fn test_instrument_greeks_errors_when_future_underlying_price_missing_without_cached_spread() {
2476        let now = utc_timestamp(2024, 2, 14, 16, 0, 0);
2477        let expiry = utc_timestamp(2024, 3, 15, 16, 0, 0);
2478        let now_ns = UnixNanos::from(now);
2479        let expiry_ns = UnixNanos::from(expiry);
2480
2481        let future = future_with_expiration("ESH4.GLBX", "ESH4", expiry_ns);
2482        let call_option = future_option_with_expiration(
2483            "ESH4C150.GLBX",
2484            "ESH4C150",
2485            "ESH4",
2486            OptionKind::Call,
2487            "150.00",
2488            expiry_ns,
2489        );
2490        let put_option = future_option_with_expiration(
2491            "ESH4P150.GLBX",
2492            "ESH4P150",
2493            "ESH4",
2494            OptionKind::Put,
2495            "150.00",
2496            expiry_ns,
2497        );
2498
2499        let cache = Rc::new(RefCell::new(Cache::new(None, None)));
2500        cache
2501            .borrow_mut()
2502            .add_instrument(InstrumentAny::FuturesContract(future))
2503            .unwrap();
2504        cache
2505            .borrow_mut()
2506            .add_instrument(InstrumentAny::OptionContract(call_option.clone()))
2507            .unwrap();
2508        cache
2509            .borrow_mut()
2510            .add_instrument(InstrumentAny::OptionContract(put_option.clone()))
2511            .unwrap();
2512
2513        let call_quote = QuoteTick::new(
2514            call_option.id(),
2515            Price::from("8.50"),
2516            Price::from("8.50"),
2517            Quantity::from(100),
2518            Quantity::from(100),
2519            now_ns,
2520            now_ns,
2521        );
2522        let put_quote = QuoteTick::new(
2523            put_option.id(),
2524            Price::from("3.33"),
2525            Price::from("3.33"),
2526            Quantity::from(100),
2527            Quantity::from(100),
2528            now_ns,
2529            now_ns,
2530        );
2531        cache.borrow_mut().add_quote(call_quote).unwrap();
2532        cache.borrow_mut().add_quote(put_quote).unwrap();
2533
2534        let clock = Rc::new(RefCell::new(TestClock::new()));
2535        clock.borrow_mut().set_time(now_ns);
2536        let calculator = GreeksCalculator::new(cache, clock);
2537
2538        let error = calculator
2539            .instrument_greeks(
2540                call_option.id(),
2541                Some(0.0425),
2542                None,
2543                None,
2544                None,
2545                None,
2546                None,
2547                None,
2548                None,
2549                None,
2550                Some(now_ns),
2551                None,
2552                None,
2553                None,
2554                None,
2555                None,
2556                None,
2557                None,
2558            )
2559            .unwrap_err();
2560
2561        assert_eq!(error.to_string(), "No price available for ESH4.GLBX");
2562    }
2563
2564    #[rstest]
2565    fn test_cache_futures_spread_returns_price_to_reference_future() {
2566        let now = utc_timestamp(2024, 2, 14, 16, 0, 0);
2567        let expiry = utc_timestamp(2024, 3, 15, 16, 0, 0);
2568        let now_ns = UnixNanos::from(now);
2569        let expiry_ns = UnixNanos::from(expiry);
2570
2571        let future = future_with_expiration("ESH4.GLBX", "ESH4", expiry_ns);
2572        let reference_future = future_with_expiration("ESM4.GLBX", "ESM4", expiry_ns);
2573        let call_option = future_option_with_expiration(
2574            "ESH4C150.GLBX",
2575            "ESH4C150",
2576            "ESH4",
2577            OptionKind::Call,
2578            "150.00",
2579            expiry_ns,
2580        );
2581        let put_option = future_option_with_expiration(
2582            "ESH4P150.GLBX",
2583            "ESH4P150",
2584            "ESH4",
2585            OptionKind::Put,
2586            "150.00",
2587            expiry_ns,
2588        );
2589
2590        let cache = Rc::new(RefCell::new(Cache::new(None, None)));
2591        cache
2592            .borrow_mut()
2593            .add_instrument(InstrumentAny::FuturesContract(future))
2594            .unwrap();
2595        cache
2596            .borrow_mut()
2597            .add_instrument(InstrumentAny::FuturesContract(reference_future.clone()))
2598            .unwrap();
2599        cache
2600            .borrow_mut()
2601            .add_instrument(InstrumentAny::OptionContract(call_option.clone()))
2602            .unwrap();
2603        cache
2604            .borrow_mut()
2605            .add_instrument(InstrumentAny::OptionContract(put_option.clone()))
2606            .unwrap();
2607
2608        let call_quote = QuoteTick::new(
2609            call_option.id(),
2610            Price::from("8.50"),
2611            Price::from("8.50"),
2612            Quantity::from(100),
2613            Quantity::from(100),
2614            now_ns,
2615            now_ns,
2616        );
2617        let put_quote = QuoteTick::new(
2618            put_option.id(),
2619            Price::from("3.33"),
2620            Price::from("3.33"),
2621            Quantity::from(100),
2622            Quantity::from(100),
2623            now_ns,
2624            now_ns,
2625        );
2626        let reference_future_quote = QuoteTick::new(
2627            reference_future.id(),
2628            Price::from("155.00"),
2629            Price::from("155.00"),
2630            Quantity::from(100),
2631            Quantity::from(100),
2632            now_ns,
2633            now_ns,
2634        );
2635        cache.borrow_mut().add_quote(call_quote).unwrap();
2636        cache.borrow_mut().add_quote(put_quote).unwrap();
2637        cache
2638            .borrow_mut()
2639            .add_quote(reference_future_quote)
2640            .unwrap();
2641
2642        let clock = Rc::new(RefCell::new(TestClock::new()));
2643        clock.borrow_mut().set_time(now_ns);
2644        let calculator = GreeksCalculator::new(cache, clock);
2645
2646        let cached_future_price = calculator
2647            .cache_futures_spread(call_option.id(), put_option.id(), reference_future.id())
2648            .unwrap();
2649
2650        let expected_underlying = 150.0 + (0.0425_f64 * (30.0 / 365.25)).exp() * (8.50 - 3.33);
2651        let expected_cached_underlying = reference_future.make_price(expected_underlying);
2652        assert_eq!(cached_future_price, expected_cached_underlying);
2653        assert_eq!(
2654            calculator.get_cached_futures_spread_price(InstrumentId::from("ESH4.GLBX")),
2655            Some(expected_cached_underlying)
2656        );
2657    }
2658
2659    #[rstest]
2660    fn test_instrument_greeks_uses_cached_futures_spread_when_underlying_price_missing() {
2661        let now = utc_timestamp(2024, 2, 14, 16, 0, 0);
2662        let expiry = utc_timestamp(2024, 3, 15, 16, 0, 0);
2663        let now_ns = UnixNanos::from(now);
2664        let expiry_ns = UnixNanos::from(expiry);
2665
2666        let future = future_with_expiration("ESH4.GLBX", "ESH4", expiry_ns);
2667        let reference_future = future_with_expiration("ESM4.GLBX", "ESM4", expiry_ns);
2668        let call_option = future_option_with_expiration(
2669            "ESH4C150.GLBX",
2670            "ESH4C150",
2671            "ESH4",
2672            OptionKind::Call,
2673            "150.00",
2674            expiry_ns,
2675        );
2676        let put_option = future_option_with_expiration(
2677            "ESH4P150.GLBX",
2678            "ESH4P150",
2679            "ESH4",
2680            OptionKind::Put,
2681            "150.00",
2682            expiry_ns,
2683        );
2684        let target_call_option = future_option_with_expiration(
2685            "ESH4C152.GLBX",
2686            "ESH4C152",
2687            "ESH4",
2688            OptionKind::Call,
2689            "152.00",
2690            expiry_ns,
2691        );
2692
2693        let cache = Rc::new(RefCell::new(Cache::new(None, None)));
2694        cache
2695            .borrow_mut()
2696            .add_instrument(InstrumentAny::FuturesContract(future))
2697            .unwrap();
2698        cache
2699            .borrow_mut()
2700            .add_instrument(InstrumentAny::FuturesContract(reference_future.clone()))
2701            .unwrap();
2702        cache
2703            .borrow_mut()
2704            .add_instrument(InstrumentAny::OptionContract(call_option.clone()))
2705            .unwrap();
2706        cache
2707            .borrow_mut()
2708            .add_instrument(InstrumentAny::OptionContract(put_option.clone()))
2709            .unwrap();
2710        cache
2711            .borrow_mut()
2712            .add_instrument(InstrumentAny::OptionContract(target_call_option.clone()))
2713            .unwrap();
2714
2715        let call_quote = QuoteTick::new(
2716            call_option.id(),
2717            Price::from("8.50"),
2718            Price::from("8.50"),
2719            Quantity::from(100),
2720            Quantity::from(100),
2721            now_ns,
2722            now_ns,
2723        );
2724        let put_quote = QuoteTick::new(
2725            put_option.id(),
2726            Price::from("3.33"),
2727            Price::from("3.33"),
2728            Quantity::from(100),
2729            Quantity::from(100),
2730            now_ns,
2731            now_ns,
2732        );
2733        let target_call_quote = QuoteTick::new(
2734            target_call_option.id(),
2735            Price::from("6.75"),
2736            Price::from("6.75"),
2737            Quantity::from(100),
2738            Quantity::from(100),
2739            now_ns,
2740            now_ns,
2741        );
2742        let reference_future_quote = QuoteTick::new(
2743            reference_future.id(),
2744            Price::from("155.00"),
2745            Price::from("155.00"),
2746            Quantity::from(100),
2747            Quantity::from(100),
2748            now_ns,
2749            now_ns,
2750        );
2751        cache.borrow_mut().add_quote(call_quote).unwrap();
2752        cache.borrow_mut().add_quote(put_quote).unwrap();
2753        cache.borrow_mut().add_quote(target_call_quote).unwrap();
2754        cache
2755            .borrow_mut()
2756            .add_quote(reference_future_quote)
2757            .unwrap();
2758
2759        let clock = Rc::new(RefCell::new(TestClock::new()));
2760        clock.borrow_mut().set_time(now_ns);
2761        let calculator = GreeksCalculator::new(cache, clock);
2762        calculator
2763            .cache_futures_spread(call_option.id(), put_option.id(), reference_future.id())
2764            .unwrap();
2765
2766        let greeks = calculator
2767            .instrument_greeks(
2768                target_call_option.id(),
2769                Some(0.0425),
2770                None,
2771                None,
2772                None,
2773                None,
2774                None,
2775                None,
2776                None,
2777                None,
2778                Some(now_ns),
2779                None,
2780                None,
2781                None,
2782                None,
2783                None,
2784                None,
2785                None,
2786            )
2787            .unwrap();
2788
2789        let expected_underlying = reference_future
2790            .make_price(150.0 + (0.0425_f64 * (30.0 / 365.25)).exp() * (8.50 - 3.33))
2791            .as_f64();
2792        assert_eq!(greeks.underlying_price, expected_underlying);
2793    }
2794
2795    #[rstest]
2796    fn test_instrument_greeks_uses_index_price_for_index_underlying() {
2797        let now = utc_timestamp(2024, 2, 14, 16, 0, 0);
2798        let expiry = utc_timestamp(2024, 3, 15, 16, 0, 0);
2799        let now_ns = UnixNanos::from(now);
2800        let expiry_ns = UnixNanos::from(expiry);
2801
2802        let future = future_with_expiration("ESH4.GLBX", "ESH4", expiry_ns);
2803        let call_option = future_option_with_expiration(
2804            "ESH4C150.GLBX",
2805            "ESH4C150",
2806            "ESH4",
2807            OptionKind::Call,
2808            "150.00",
2809            expiry_ns,
2810        );
2811
2812        let cache = Rc::new(RefCell::new(Cache::new(None, None)));
2813        cache
2814            .borrow_mut()
2815            .add_instrument(InstrumentAny::FuturesContract(future))
2816            .unwrap();
2817        cache
2818            .borrow_mut()
2819            .add_instrument(InstrumentAny::OptionContract(call_option.clone()))
2820            .unwrap();
2821
2822        let call_quote = QuoteTick::new(
2823            call_option.id(),
2824            Price::from("8.50"),
2825            Price::from("8.50"),
2826            Quantity::from(100),
2827            Quantity::from(100),
2828            now_ns,
2829            now_ns,
2830        );
2831        cache.borrow_mut().add_quote(call_quote).unwrap();
2832        cache
2833            .borrow_mut()
2834            .add_index_price(IndexPriceUpdate::new(
2835                InstrumentId::from("ESH4.GLBX"),
2836                Price::from("157.25"),
2837                now_ns,
2838                now_ns,
2839            ))
2840            .unwrap();
2841
2842        let clock = Rc::new(RefCell::new(TestClock::new()));
2843        clock.borrow_mut().set_time(now_ns);
2844        let calculator = GreeksCalculator::new(cache, clock);
2845
2846        let greeks = calculator
2847            .instrument_greeks(
2848                call_option.id(),
2849                Some(0.0425),
2850                None,
2851                None,
2852                None,
2853                None,
2854                None,
2855                None,
2856                None,
2857                None,
2858                Some(now_ns),
2859                None,
2860                None,
2861                None,
2862                None,
2863                None,
2864                None,
2865                None,
2866            )
2867            .unwrap();
2868
2869        assert_eq!(greeks.underlying_price, 157.25);
2870    }
2871
2872    #[rstest]
2873    fn test_instrument_greeks_prefers_quote_over_index_price_for_index_future() {
2874        let now = utc_timestamp(2024, 2, 14, 16, 0, 0);
2875        let expiry = utc_timestamp(2024, 3, 15, 16, 0, 0);
2876        let now_ns = UnixNanos::from(now);
2877        let expiry_ns = UnixNanos::from(expiry);
2878
2879        let future = future_with_expiration("ESH4.GLBX", "ESH4", expiry_ns);
2880        let call_option = future_option_with_expiration(
2881            "ESH4C150.GLBX",
2882            "ESH4C150",
2883            "ESH4",
2884            OptionKind::Call,
2885            "150.00",
2886            expiry_ns,
2887        );
2888
2889        let cache = Rc::new(RefCell::new(Cache::new(None, None)));
2890        cache
2891            .borrow_mut()
2892            .add_instrument(InstrumentAny::FuturesContract(future))
2893            .unwrap();
2894        cache
2895            .borrow_mut()
2896            .add_instrument(InstrumentAny::OptionContract(call_option.clone()))
2897            .unwrap();
2898
2899        // Both a quote and an index price for the underlying future
2900        let future_quote = QuoteTick::new(
2901            InstrumentId::from("ESH4.GLBX"),
2902            Price::from("158.50"),
2903            Price::from("159.50"),
2904            Quantity::from(100),
2905            Quantity::from(100),
2906            now_ns,
2907            now_ns,
2908        );
2909        cache.borrow_mut().add_quote(future_quote).unwrap();
2910        cache
2911            .borrow_mut()
2912            .add_index_price(IndexPriceUpdate::new(
2913                InstrumentId::from("ESH4.GLBX"),
2914                Price::from("157.25"),
2915                now_ns,
2916                now_ns,
2917            ))
2918            .unwrap();
2919
2920        let call_quote = QuoteTick::new(
2921            call_option.id(),
2922            Price::from("8.50"),
2923            Price::from("8.50"),
2924            Quantity::from(100),
2925            Quantity::from(100),
2926            now_ns,
2927            now_ns,
2928        );
2929        cache.borrow_mut().add_quote(call_quote).unwrap();
2930
2931        let clock = Rc::new(RefCell::new(TestClock::new()));
2932        clock.borrow_mut().set_time(now_ns);
2933        let calculator = GreeksCalculator::new(cache, clock);
2934
2935        let greeks = calculator
2936            .instrument_greeks(
2937                call_option.id(),
2938                Some(0.0425),
2939                None,
2940                None,
2941                None,
2942                None,
2943                None,
2944                None,
2945                None,
2946                None,
2947                Some(now_ns),
2948                None,
2949                None,
2950                None,
2951                None,
2952                None,
2953                None,
2954                None,
2955            )
2956            .unwrap();
2957
2958        // Should use the MID quote (159.00), not the index price (157.25)
2959        assert_eq!(greeks.underlying_price, 159.0);
2960    }
2961}