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: `NoPositionSide`)
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            .format("%Y%m%d")
547            .to_string()
548            .parse::<i32>()
549            .unwrap_or(0);
550        let raw_days = (expiry_utc - utc_now).num_days();
551        let expiry_in_days = raw_days.max(1) as i32;
552        let expiry_in_years = expiry_in_days as f64 / 365.25;
553        let currency = instrument.quote_currency().code.to_string();
554
555        let cache = self.cache.borrow();
556        let yield_curve = cache.yield_curve(&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    ///
935    #[expect(clippy::too_many_arguments)]
936    pub fn portfolio_greeks(
937        &self,
938        underlyings: Option<&[String]>,
939        venue: Option<Venue>,
940        instrument_id: Option<InstrumentId>,
941        strategy_id: Option<StrategyId>,
942        side: Option<PositionSide>,
943        flat_interest_rate: Option<f64>,
944        flat_dividend_yield: Option<f64>,
945        spot_shock: Option<f64>,
946        vol_shock: Option<f64>,
947        time_to_expiry_shock: Option<f64>,
948        use_cached_greeks: Option<bool>,
949        update_vol: Option<bool>,
950        cache_greeks: Option<bool>,
951        publish_greeks: Option<bool>,
952        percent_greeks: Option<bool>,
953        index_instrument_id: Option<InstrumentId>,
954        beta_weights: Option<&HashMap<InstrumentId, f64>>,
955        greeks_filter: Option<&GreeksFilter>,
956        vega_time_weight_base: Option<i32>,
957        vol_index_instrument_id: Option<InstrumentId>,
958        vol_beta_weights: Option<&HashMap<InstrumentId, f64>>,
959    ) -> anyhow::Result<PortfolioGreeks> {
960        let ts_event = self.clock.borrow().timestamp_ns();
961        let mut portfolio_greeks =
962            PortfolioGreeks::new(ts_event, ts_event, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0);
963
964        // Set default values
965        let flat_interest_rate = flat_interest_rate.unwrap_or(0.0425);
966        let spot_shock = spot_shock.unwrap_or(0.0);
967        let vol_shock = vol_shock.unwrap_or(0.0);
968        let time_to_expiry_shock = time_to_expiry_shock.unwrap_or(0.0);
969        let use_cached_greeks = use_cached_greeks.unwrap_or(false);
970        let update_vol = update_vol.unwrap_or(false);
971        let cache_greeks = cache_greeks.unwrap_or(false);
972        let publish_greeks = publish_greeks.unwrap_or(false);
973        let percent_greeks = percent_greeks.unwrap_or(false);
974        let side = side.unwrap_or(PositionSide::NoPositionSide);
975
976        let cache = self.cache.borrow();
977        let open_positions = cache.positions(
978            venue.as_ref(),
979            instrument_id.as_ref(),
980            strategy_id.as_ref(),
981            None, // account_id
982            Some(side),
983        );
984        let open_positions: Vec<Position> =
985            open_positions.iter().map(PositionRef::cloned).collect();
986
987        for position in open_positions {
988            let position_instrument_id = position.instrument_id;
989
990            if let Some(underlyings_list) = underlyings {
991                let mut skip_position = true;
992
993                for underlying in underlyings_list {
994                    if position_instrument_id
995                        .symbol
996                        .as_str()
997                        .starts_with(underlying)
998                    {
999                        skip_position = false;
1000                        break;
1001                    }
1002                }
1003
1004                if skip_position {
1005                    continue;
1006                }
1007            }
1008
1009            let quantity = position.signed_qty;
1010            let instrument_greeks = self.instrument_greeks(
1011                position_instrument_id,
1012                Some(flat_interest_rate),
1013                flat_dividend_yield,
1014                Some(spot_shock),
1015                Some(vol_shock),
1016                Some(time_to_expiry_shock),
1017                Some(use_cached_greeks),
1018                Some(update_vol),
1019                Some(cache_greeks),
1020                Some(publish_greeks),
1021                Some(ts_event),
1022                Some(position),
1023                Some(percent_greeks),
1024                index_instrument_id,
1025                beta_weights,
1026                vega_time_weight_base,
1027                vol_index_instrument_id,
1028                vol_beta_weights,
1029            )?;
1030            let position_greeks = quantity * &instrument_greeks;
1031
1032            // Apply greeks filter if provided
1033            if greeks_filter.is_none_or(|filter| filter(&position_greeks)) {
1034                portfolio_greeks = portfolio_greeks + PortfolioGreeks::from(position_greeks);
1035            }
1036        }
1037
1038        Ok(portfolio_greeks)
1039    }
1040
1041    /// Cache a futures spread derived from a call/put pair against a reference future.
1042    ///
1043    /// # Errors
1044    ///
1045    /// Returns an error if instruments or prices are missing or inconsistent.
1046    pub fn cache_futures_spread(
1047        &self,
1048        call_instrument_id: InstrumentId,
1049        put_instrument_id: InstrumentId,
1050        futures_instrument_id: InstrumentId,
1051    ) -> anyhow::Result<Price> {
1052        let cache = self.cache.borrow();
1053        let call_instrument = cache.instrument(&call_instrument_id).cloned();
1054        let put_instrument = cache.instrument(&put_instrument_id).cloned();
1055        let reference_future_instrument = cache.instrument(&futures_instrument_id).cloned();
1056        drop(cache);
1057
1058        let Some(call_instrument) = call_instrument else {
1059            anyhow::bail!(
1060                "Cannot cache futures spread: missing option instrument {call_instrument_id}"
1061            );
1062        };
1063        let Some(put_instrument) = put_instrument else {
1064            anyhow::bail!(
1065                "Cannot cache futures spread: missing option instrument {put_instrument_id}"
1066            );
1067        };
1068        let Some(reference_future_instrument) = reference_future_instrument else {
1069            anyhow::bail!(
1070                "Cannot cache futures spread: no reference futures instrument for {futures_instrument_id}"
1071            );
1072        };
1073
1074        if call_instrument.instrument_class() != InstrumentClass::Option
1075            || put_instrument.instrument_class() != InstrumentClass::Option
1076        {
1077            anyhow::bail!(
1078                "Cannot cache futures spread: non-option instruments provided call_instrument_id={call_instrument_id} put_instrument_id={put_instrument_id}"
1079            );
1080        }
1081
1082        if call_instrument.option_kind() != Some(OptionKind::Call)
1083            || put_instrument.option_kind() != Some(OptionKind::Put)
1084        {
1085            anyhow::bail!(
1086                "Cannot cache futures spread: expected call/put pair call_instrument_id={call_instrument_id} put_instrument_id={put_instrument_id}"
1087            );
1088        }
1089
1090        let Some(call_underlying) = call_instrument.underlying() else {
1091            anyhow::bail!(
1092                "Cannot cache futures spread: missing call underlying for {call_instrument_id}"
1093            );
1094        };
1095        let Some(put_underlying) = put_instrument.underlying() else {
1096            anyhow::bail!(
1097                "Cannot cache futures spread: missing put underlying for {put_instrument_id}"
1098            );
1099        };
1100
1101        if call_underlying != put_underlying {
1102            anyhow::bail!(
1103                "Cannot cache futures spread: option underlyings differ call_instrument_id={call_instrument_id} put_instrument_id={put_instrument_id}"
1104            );
1105        }
1106
1107        if call_instrument.strike_price() != put_instrument.strike_price() {
1108            anyhow::bail!(
1109                "Cannot cache futures spread: strike prices differ call_instrument_id={call_instrument_id} put_instrument_id={put_instrument_id}"
1110            );
1111        }
1112
1113        if call_instrument.expiration_ns() != put_instrument.expiration_ns() {
1114            anyhow::bail!(
1115                "Cannot cache futures spread: expiration dates differ call_instrument_id={call_instrument_id} put_instrument_id={put_instrument_id}"
1116            );
1117        }
1118
1119        let reference_future_price = self.get_price_object(&futures_instrument_id).ok_or_else(|| {
1120            anyhow::anyhow!(
1121                "Cannot cache futures spread: no reference futures price for {futures_instrument_id}"
1122            )
1123        })?;
1124        let call_price = self.get_price(&call_instrument_id).ok_or_else(|| {
1125            anyhow::anyhow!(
1126                "Cannot cache futures spread: missing option price for {call_instrument_id}"
1127            )
1128        })?;
1129        let put_price = self.get_price(&put_instrument_id).ok_or_else(|| {
1130            anyhow::anyhow!(
1131                "Cannot cache futures spread: missing option price for {put_instrument_id}"
1132            )
1133        })?;
1134
1135        let underlying_instrument_id =
1136            InstrumentId::from(format!("{call_underlying}.{}", call_instrument_id.venue));
1137
1138        // Reject if the underlying is present in cache but is not a future
1139        {
1140            let cache = self.cache.borrow();
1141            if let Some(underlying) = cache.instrument(&underlying_instrument_id)
1142                && underlying.instrument_class() != InstrumentClass::Future
1143            {
1144                anyhow::bail!(
1145                    "Cannot cache futures spread: underlying {underlying_instrument_id} is not a futures contract"
1146                );
1147            }
1148        }
1149
1150        let implied_future_price =
1151            self.calculate_implied_future_price(&call_instrument, call_price, put_price);
1152        let spread = implied_future_price - reference_future_price.as_f64();
1153        let spread_price = reference_future_instrument.make_price(spread);
1154
1155        self.cached_futures_spreads.borrow_mut().insert(
1156            underlying_instrument_id,
1157            (futures_instrument_id, spread_price),
1158        );
1159
1160        Ok(reference_future_price + spread_price)
1161    }
1162
1163    fn calculate_implied_future_price(
1164        &self,
1165        call_instrument: &InstrumentAny,
1166        call_price: f64,
1167        put_price: f64,
1168    ) -> f64 {
1169        let expiry_utc = call_instrument
1170            .expiration_ns()
1171            .map(|ns| ns.to_datetime_utc())
1172            .unwrap_or_default();
1173        let expiry_in_days = (expiry_utc - self.clock.borrow().timestamp_ns().to_datetime_utc())
1174            .num_days()
1175            .max(1) as i32;
1176        let expiry_in_years = expiry_in_days as f64 / 365.25;
1177        let currency = call_instrument.quote_currency().code.to_string();
1178        let interest_rate = self
1179            .cache
1180            .borrow()
1181            .yield_curve(&currency)
1182            .map_or(0.0425, |yield_curve| yield_curve(expiry_in_years));
1183        let strike = call_instrument.strike_price().unwrap_or_default().as_f64();
1184
1185        strike + (interest_rate * expiry_in_years).exp() * (call_price - put_price)
1186    }
1187
1188    /// Resolve a cached futures spread price for an underlying future.
1189    #[must_use]
1190    pub fn get_cached_futures_spread_price(
1191        &self,
1192        underlying_instrument_id: InstrumentId,
1193    ) -> Option<Price> {
1194        let (futures_instrument_id, spread) = self
1195            .cached_futures_spreads
1196            .borrow()
1197            .get(&underlying_instrument_id)
1198            .copied()?;
1199        let reference_future_price = self.get_price_object(&futures_instrument_id)?;
1200
1201        Some(reference_future_price + spread)
1202    }
1203
1204    fn get_price_object(&self, instrument_id: &InstrumentId) -> Option<Price> {
1205        let cache = self.cache.borrow();
1206        let price = cache
1207            .price(instrument_id, PriceType::Mid)
1208            .or_else(|| cache.price(instrument_id, PriceType::Last));
1209
1210        // For index-class futures, prefer tradable quotes over the published index
1211        // price since index price is the spot level and may diverge from futures basis.
1212        // For true index instruments (non-futures), prefer the published index price.
1213        if let Some(instrument) = cache.instrument(instrument_id)
1214            && instrument.asset_class() == AssetClass::Index
1215        {
1216            if instrument.instrument_class() == InstrumentClass::Future && price.is_some() {
1217                return price;
1218            }
1219
1220            if let Some(index_price) = cache.index_price(instrument_id) {
1221                return Some(index_price.value);
1222            }
1223        }
1224
1225        price
1226    }
1227
1228    fn get_price(&self, instrument_id: &InstrumentId) -> Option<f64> {
1229        self.get_price_object(instrument_id)
1230            .map(|price| price.as_f64())
1231    }
1232
1233    /// Subscribes to Greeks data for a given underlying instrument.
1234    ///
1235    /// Useful for reading greeks from a backtesting data catalog and caching them for later use.
1236    pub fn subscribe_greeks<F>(&self, underlying: &str, handler: Option<F>)
1237    where
1238        F: Fn(&GreeksData) + 'static,
1239    {
1240        let pattern = format!("data.GreeksData.instrument_id={underlying}*").into();
1241
1242        if let Some(custom_handler) = handler {
1243            let typed_handler = TypedHandler::from(custom_handler);
1244            msgbus::subscribe_greeks(pattern, typed_handler, None);
1245        } else {
1246            let cache_ref = self.cache.clone();
1247            let typed_handler = TypedHandler::from(move |greeks: &GreeksData| {
1248                let mut cache = cache_ref.borrow_mut();
1249                cache.add_greeks(greeks.clone()).unwrap_or_default();
1250            });
1251            msgbus::subscribe_greeks(pattern, typed_handler, None);
1252        }
1253    }
1254}
1255
1256#[cfg(test)]
1257mod tests {
1258    use std::{cell::RefCell, collections::HashMap, rc::Rc};
1259
1260    use chrono::{TimeZone, Utc};
1261    use nautilus_model::{
1262        data::{IndexPriceUpdate, QuoteTick},
1263        enums::{AssetClass, OptionKind, PositionSide},
1264        identifiers::{InstrumentId, StrategyId, Symbol, Venue},
1265        instruments::{Equity, FuturesContract, OptionContract, any::InstrumentAny},
1266        types::{Currency, Price, Quantity},
1267    };
1268    use rstest::rstest;
1269    use ustr::Ustr;
1270
1271    use super::*;
1272    use crate::{cache::Cache, clock::TestClock};
1273
1274    fn create_test_calculator() -> GreeksCalculator {
1275        let cache = Rc::new(RefCell::new(Cache::new(None, None)));
1276        let clock = Rc::new(RefCell::new(TestClock::new()));
1277        GreeksCalculator::new(cache, clock)
1278    }
1279
1280    #[rstest]
1281    fn test_greeks_calculator_creation() {
1282        let calculator = create_test_calculator();
1283        // Test that the calculator can be created
1284        assert!(format!("{calculator:?}").contains("GreeksCalculator"));
1285    }
1286
1287    #[rstest]
1288    fn test_greeks_calculator_debug() {
1289        let calculator = create_test_calculator();
1290        // Test the debug representation
1291        let debug_str = format!("{calculator:?}");
1292        assert!(debug_str.contains("GreeksCalculator"));
1293    }
1294
1295    #[rstest]
1296    fn test_greeks_calculator_has_python_bindings() {
1297        // This test just verifies that the GreeksCalculator struct
1298        // can be compiled with Python bindings enabled
1299        let calculator = create_test_calculator();
1300        // The Python methods are only accessible from Python,
1301        // but we can verify the struct compiles correctly
1302        assert!(format!("{calculator:?}").contains("GreeksCalculator"));
1303    }
1304
1305    #[rstest]
1306    fn test_instrument_greeks_params_builder_default() {
1307        let instrument_id = InstrumentId::from("AAPL.NASDAQ");
1308
1309        let params = InstrumentGreeksParams::builder()
1310            .instrument_id(instrument_id)
1311            .build();
1312
1313        assert_eq!(params.instrument_id, instrument_id);
1314        assert_eq!(params.flat_interest_rate, 0.0425);
1315        assert_eq!(params.flat_dividend_yield, None);
1316        assert_eq!(params.spot_shock, 0.0);
1317        assert_eq!(params.vol_shock, 0.0);
1318        assert_eq!(params.time_to_expiry_shock, 0.0);
1319        assert!(!params.use_cached_greeks);
1320        assert!(!params.cache_greeks);
1321        assert!(!params.publish_greeks);
1322        assert_eq!(params.ts_event, None);
1323        assert_eq!(params.position, None);
1324        assert!(!params.percent_greeks);
1325        assert_eq!(params.index_instrument_id, None);
1326        assert_eq!(params.beta_weights, None);
1327        assert_eq!(params.vol_index_instrument_id, None);
1328        assert_eq!(params.vol_beta_weights, None);
1329    }
1330
1331    #[rstest]
1332    fn test_instrument_greeks_params_builder_custom_values() {
1333        let instrument_id = InstrumentId::from("AAPL.NASDAQ");
1334        let index_id = InstrumentId::from("SPY.NASDAQ");
1335        let vol_index_id = InstrumentId::from("VIX.XCBF");
1336        let mut beta_weights = HashMap::new();
1337        beta_weights.insert(instrument_id, 1.2);
1338        let mut vol_beta_weights = HashMap::new();
1339        vol_beta_weights.insert(instrument_id, 0.8);
1340
1341        let params = InstrumentGreeksParams::builder()
1342            .instrument_id(instrument_id)
1343            .flat_interest_rate(0.05)
1344            .flat_dividend_yield(0.02)
1345            .spot_shock(0.01)
1346            .vol_shock(0.05)
1347            .time_to_expiry_shock(0.1)
1348            .use_cached_greeks(true)
1349            .cache_greeks(true)
1350            .publish_greeks(true)
1351            .percent_greeks(true)
1352            .index_instrument_id(index_id)
1353            .beta_weights(beta_weights.clone())
1354            .vol_index_instrument_id(vol_index_id)
1355            .vol_beta_weights(vol_beta_weights.clone())
1356            .build();
1357
1358        assert_eq!(params.instrument_id, instrument_id);
1359        assert_eq!(params.flat_interest_rate, 0.05);
1360        assert_eq!(params.flat_dividend_yield, Some(0.02));
1361        assert_eq!(params.spot_shock, 0.01);
1362        assert_eq!(params.vol_shock, 0.05);
1363        assert_eq!(params.time_to_expiry_shock, 0.1);
1364        assert!(params.use_cached_greeks);
1365        assert!(params.cache_greeks);
1366        assert!(params.publish_greeks);
1367        assert!(params.percent_greeks);
1368        assert_eq!(params.index_instrument_id, Some(index_id));
1369        assert_eq!(params.beta_weights, Some(beta_weights));
1370        assert_eq!(params.vol_index_instrument_id, Some(vol_index_id));
1371        assert_eq!(params.vol_beta_weights, Some(vol_beta_weights));
1372    }
1373
1374    #[rstest]
1375    fn test_instrument_greeks_params_debug() {
1376        let instrument_id = InstrumentId::from("AAPL.NASDAQ");
1377
1378        let params = InstrumentGreeksParams::builder()
1379            .instrument_id(instrument_id)
1380            .build();
1381
1382        let debug_str = format!("{params:?}");
1383        assert!(debug_str.contains("InstrumentGreeksParams"));
1384        assert!(debug_str.contains("AAPL.NASDAQ"));
1385    }
1386
1387    #[rstest]
1388    fn test_portfolio_greeks_params_builder_default() {
1389        let params = PortfolioGreeksParams::builder().build();
1390
1391        assert_eq!(params.underlyings, None);
1392        assert_eq!(params.venue, None);
1393        assert_eq!(params.instrument_id, None);
1394        assert_eq!(params.strategy_id, None);
1395        assert_eq!(params.side, None);
1396        assert_eq!(params.flat_interest_rate, 0.0425);
1397        assert_eq!(params.flat_dividend_yield, None);
1398        assert_eq!(params.spot_shock, 0.0);
1399        assert_eq!(params.vol_shock, 0.0);
1400        assert_eq!(params.time_to_expiry_shock, 0.0);
1401        assert!(!params.use_cached_greeks);
1402        assert!(!params.cache_greeks);
1403        assert!(!params.publish_greeks);
1404        assert!(!params.percent_greeks);
1405        assert_eq!(params.index_instrument_id, None);
1406        assert_eq!(params.beta_weights, None);
1407        assert_eq!(params.vol_index_instrument_id, None);
1408        assert_eq!(params.vol_beta_weights, None);
1409    }
1410
1411    #[rstest]
1412    fn test_portfolio_greeks_params_builder_custom_values() {
1413        let venue = Venue::from("NASDAQ");
1414        let instrument_id = InstrumentId::from("AAPL.NASDAQ");
1415        let strategy_id = StrategyId::from("test-strategy");
1416        let index_id = InstrumentId::from("SPY.NASDAQ");
1417        let vol_index_id = InstrumentId::from("VIX.XCBF");
1418        let underlyings = vec!["AAPL".to_string(), "MSFT".to_string()];
1419        let mut beta_weights = HashMap::new();
1420        beta_weights.insert(instrument_id, 1.2);
1421        let mut vol_beta_weights = HashMap::new();
1422        vol_beta_weights.insert(instrument_id, 0.8);
1423
1424        let params = PortfolioGreeksParams::builder()
1425            .underlyings(underlyings.clone())
1426            .venue(venue)
1427            .instrument_id(instrument_id)
1428            .strategy_id(strategy_id)
1429            .side(PositionSide::Long)
1430            .flat_interest_rate(0.05)
1431            .flat_dividend_yield(0.02)
1432            .spot_shock(0.01)
1433            .vol_shock(0.05)
1434            .time_to_expiry_shock(0.1)
1435            .use_cached_greeks(true)
1436            .cache_greeks(true)
1437            .publish_greeks(true)
1438            .percent_greeks(true)
1439            .index_instrument_id(index_id)
1440            .beta_weights(beta_weights.clone())
1441            .vol_index_instrument_id(vol_index_id)
1442            .vol_beta_weights(vol_beta_weights.clone())
1443            .build();
1444
1445        assert_eq!(params.underlyings, Some(underlyings));
1446        assert_eq!(params.venue, Some(venue));
1447        assert_eq!(params.instrument_id, Some(instrument_id));
1448        assert_eq!(params.strategy_id, Some(strategy_id));
1449        assert_eq!(params.side, Some(PositionSide::Long));
1450        assert_eq!(params.flat_interest_rate, 0.05);
1451        assert_eq!(params.flat_dividend_yield, Some(0.02));
1452        assert_eq!(params.spot_shock, 0.01);
1453        assert_eq!(params.vol_shock, 0.05);
1454        assert_eq!(params.time_to_expiry_shock, 0.1);
1455        assert!(params.use_cached_greeks);
1456        assert!(params.cache_greeks);
1457        assert!(params.publish_greeks);
1458        assert!(params.percent_greeks);
1459        assert_eq!(params.index_instrument_id, Some(index_id));
1460        assert_eq!(params.beta_weights, Some(beta_weights));
1461        assert_eq!(params.vol_index_instrument_id, Some(vol_index_id));
1462        assert_eq!(params.vol_beta_weights, Some(vol_beta_weights));
1463    }
1464
1465    #[rstest]
1466    fn test_portfolio_greeks_params_debug() {
1467        let venue = Venue::from("NASDAQ");
1468
1469        let params = PortfolioGreeksParams::builder().venue(venue).build();
1470
1471        let debug_str = format!("{params:?}");
1472        assert!(debug_str.contains("PortfolioGreeksParams"));
1473        assert!(debug_str.contains("NASDAQ"));
1474    }
1475
1476    #[rstest]
1477    fn test_portfolio_greeks_params_builder_fluent_api() {
1478        let instrument_id = InstrumentId::from("AAPL.NASDAQ");
1479
1480        let params = PortfolioGreeksParams::builder()
1481            .instrument_id(instrument_id)
1482            .flat_interest_rate(0.05)
1483            .spot_shock(0.01)
1484            .percent_greeks(true)
1485            .build();
1486
1487        assert_eq!(params.instrument_id, Some(instrument_id));
1488        assert_eq!(params.flat_interest_rate, 0.05);
1489        assert_eq!(params.spot_shock, 0.01);
1490        assert!(params.percent_greeks);
1491    }
1492
1493    #[rstest]
1494    fn test_instrument_greeks_params_builder_fluent_chaining() {
1495        let instrument_id = InstrumentId::from("TSLA.NASDAQ");
1496
1497        // Test fluent API chaining
1498        let params = InstrumentGreeksParams::builder()
1499            .instrument_id(instrument_id)
1500            .flat_interest_rate(0.03)
1501            .spot_shock(0.02)
1502            .vol_shock(0.1)
1503            .use_cached_greeks(true)
1504            .percent_greeks(true)
1505            .build();
1506
1507        assert_eq!(params.instrument_id, instrument_id);
1508        assert_eq!(params.flat_interest_rate, 0.03);
1509        assert_eq!(params.spot_shock, 0.02);
1510        assert_eq!(params.vol_shock, 0.1);
1511        assert!(params.use_cached_greeks);
1512        assert!(params.percent_greeks);
1513    }
1514
1515    #[rstest]
1516    fn test_portfolio_greeks_params_builder_with_underlyings() {
1517        let underlyings = vec!["AAPL".to_string(), "MSFT".to_string(), "GOOGL".to_string()];
1518
1519        let params = PortfolioGreeksParams::builder()
1520            .underlyings(underlyings.clone())
1521            .flat_interest_rate(0.04)
1522            .build();
1523
1524        assert_eq!(params.underlyings, Some(underlyings));
1525        assert_eq!(params.flat_interest_rate, 0.04);
1526    }
1527
1528    #[rstest]
1529    fn test_builders_with_empty_beta_weights() {
1530        let instrument_id = InstrumentId::from("NVDA.NASDAQ");
1531        let empty_beta_weights = HashMap::new();
1532
1533        let instrument_params = InstrumentGreeksParams::builder()
1534            .instrument_id(instrument_id)
1535            .beta_weights(empty_beta_weights.clone())
1536            .vol_beta_weights(empty_beta_weights.clone())
1537            .build();
1538
1539        let portfolio_params = PortfolioGreeksParams::builder()
1540            .beta_weights(empty_beta_weights.clone())
1541            .vol_beta_weights(empty_beta_weights.clone())
1542            .build();
1543
1544        assert_eq!(
1545            instrument_params.beta_weights,
1546            Some(empty_beta_weights.clone())
1547        );
1548        assert_eq!(portfolio_params.beta_weights, Some(empty_beta_weights));
1549        assert_eq!(instrument_params.vol_beta_weights, Some(HashMap::new()));
1550        assert_eq!(portfolio_params.vol_beta_weights, Some(HashMap::new()));
1551    }
1552
1553    #[rstest]
1554    fn test_builders_with_all_shocks() {
1555        let instrument_id = InstrumentId::from("AMD.NASDAQ");
1556
1557        let instrument_params = InstrumentGreeksParams::builder()
1558            .instrument_id(instrument_id)
1559            .spot_shock(0.05)
1560            .vol_shock(0.1)
1561            .time_to_expiry_shock(0.01)
1562            .build();
1563
1564        let portfolio_params = PortfolioGreeksParams::builder()
1565            .spot_shock(0.05)
1566            .vol_shock(0.1)
1567            .time_to_expiry_shock(0.01)
1568            .build();
1569
1570        assert_eq!(instrument_params.spot_shock, 0.05);
1571        assert_eq!(instrument_params.vol_shock, 0.1);
1572        assert_eq!(instrument_params.time_to_expiry_shock, 0.01);
1573
1574        assert_eq!(portfolio_params.spot_shock, 0.05);
1575        assert_eq!(portfolio_params.vol_shock, 0.1);
1576        assert_eq!(portfolio_params.time_to_expiry_shock, 0.01);
1577    }
1578
1579    #[rstest]
1580    fn test_builders_with_all_boolean_flags() {
1581        let instrument_id = InstrumentId::from("META.NASDAQ");
1582
1583        let instrument_params = InstrumentGreeksParams::builder()
1584            .instrument_id(instrument_id)
1585            .use_cached_greeks(true)
1586            .cache_greeks(true)
1587            .publish_greeks(true)
1588            .percent_greeks(true)
1589            .build();
1590
1591        let portfolio_params = PortfolioGreeksParams::builder()
1592            .use_cached_greeks(true)
1593            .cache_greeks(true)
1594            .publish_greeks(true)
1595            .percent_greeks(true)
1596            .build();
1597
1598        assert!(instrument_params.use_cached_greeks);
1599        assert!(instrument_params.cache_greeks);
1600        assert!(instrument_params.publish_greeks);
1601        assert!(instrument_params.percent_greeks);
1602
1603        assert!(portfolio_params.use_cached_greeks);
1604        assert!(portfolio_params.cache_greeks);
1605        assert!(portfolio_params.publish_greeks);
1606        assert!(portfolio_params.percent_greeks);
1607    }
1608
1609    #[rstest]
1610    fn test_greeks_filter_callback_function() {
1611        // Test function pointer filter
1612        fn filter_positive_delta(data: &GreeksData) -> bool {
1613            data.delta > 0.0
1614        }
1615
1616        let filter = GreeksFilterCallback::from_fn(filter_positive_delta);
1617
1618        // Create test data
1619        let greeks_data = GreeksData::from_delta(
1620            InstrumentId::from("TEST.NASDAQ"),
1621            0.5,
1622            1.0,
1623            UnixNanos::default(),
1624        );
1625
1626        assert!(filter.call(&greeks_data));
1627
1628        // Test debug formatting
1629        let debug_str = format!("{filter:?}");
1630        assert!(debug_str.contains("GreeksFilterCallback::Function"));
1631    }
1632
1633    #[rstest]
1634    fn test_greeks_filter_callback_closure() {
1635        // Test closure filter that captures a variable
1636        let min_delta = 0.3;
1637        let filter =
1638            GreeksFilterCallback::from_closure(move |data: &GreeksData| data.delta > min_delta);
1639
1640        // Create test data
1641        let greeks_data = GreeksData::from_delta(
1642            InstrumentId::from("TEST.NASDAQ"),
1643            0.5,
1644            1.0,
1645            UnixNanos::default(),
1646        );
1647
1648        assert!(filter.call(&greeks_data));
1649
1650        // Test debug formatting
1651        let debug_str = format!("{filter:?}");
1652        assert!(debug_str.contains("GreeksFilterCallback::Closure"));
1653    }
1654
1655    #[rstest]
1656    fn test_greeks_filter_callback_clone() {
1657        fn filter_fn(data: &GreeksData) -> bool {
1658            data.delta > 0.0
1659        }
1660
1661        let filter1 = GreeksFilterCallback::from_fn(filter_fn);
1662        let filter2 = filter1.clone();
1663
1664        let greeks_data = GreeksData::from_delta(
1665            InstrumentId::from("TEST.NASDAQ"),
1666            0.5,
1667            1.0,
1668            UnixNanos::default(),
1669        );
1670
1671        assert!(filter1.call(&greeks_data));
1672        assert!(filter2.call(&greeks_data));
1673    }
1674
1675    #[rstest]
1676    fn test_portfolio_greeks_params_with_filter() {
1677        fn filter_high_delta(data: &GreeksData) -> bool {
1678            data.delta.abs() > 0.1
1679        }
1680
1681        let filter = GreeksFilterCallback::from_fn(filter_high_delta);
1682
1683        let params = PortfolioGreeksParams::builder()
1684            .greeks_filter(filter)
1685            .flat_interest_rate(0.05)
1686            .build();
1687
1688        assert!(params.greeks_filter.is_some());
1689        assert_eq!(params.flat_interest_rate, 0.05);
1690
1691        // Test that the filter can be called
1692        let greeks_data = GreeksData::from_delta(
1693            InstrumentId::from("TEST.NASDAQ"),
1694            0.5,
1695            1.0,
1696            UnixNanos::default(),
1697        );
1698
1699        let filter_ref = params.greeks_filter.as_ref().unwrap();
1700        assert!(filter_ref.call(&greeks_data));
1701    }
1702
1703    #[rstest]
1704    fn test_portfolio_greeks_params_with_closure_filter() {
1705        let min_gamma = 0.01;
1706        let filter =
1707            GreeksFilterCallback::from_closure(move |data: &GreeksData| data.gamma > min_gamma);
1708
1709        let params = PortfolioGreeksParams::builder()
1710            .greeks_filter(filter)
1711            .build();
1712
1713        assert!(params.greeks_filter.is_some());
1714
1715        // Test debug formatting includes the filter
1716        let debug_str = format!("{params:?}");
1717        assert!(debug_str.contains("greeks_filter"));
1718    }
1719
1720    #[rstest]
1721    fn test_greeks_filter_to_greeks_filter_conversion() {
1722        fn filter_fn(data: &GreeksData) -> bool {
1723            data.delta > 0.0
1724        }
1725
1726        let callback = GreeksFilterCallback::from_fn(filter_fn);
1727        let greeks_filter = callback.to_greeks_filter();
1728
1729        let greeks_data = GreeksData::from_delta(
1730            InstrumentId::from("TEST.NASDAQ"),
1731            0.5,
1732            1.0,
1733            UnixNanos::default(),
1734        );
1735
1736        assert!(greeks_filter(&greeks_data));
1737    }
1738
1739    fn option_with_expiration(instrument_id: &str, expiration_ns: UnixNanos) -> OptionContract {
1740        let activation_ns = UnixNanos::from(Utc.with_ymd_and_hms(2021, 9, 17, 0, 0, 0).unwrap());
1741        OptionContract::new(
1742            InstrumentId::from(instrument_id),
1743            Symbol::from("AAPL211217C00150000"),
1744            AssetClass::Equity,
1745            Some(Ustr::from("GMNI")),
1746            Ustr::from("AAPL"),
1747            OptionKind::Call,
1748            Price::from("149.0"),
1749            Currency::from("USD"),
1750            activation_ns,
1751            expiration_ns,
1752            2,
1753            Price::from("0.01"),
1754            Quantity::from(100),
1755            Quantity::from(1),
1756            None,
1757            None,
1758            None,
1759            None,
1760            None,
1761            None,
1762            None,
1763            None,
1764            None,
1765            None,
1766            UnixNanos::default(),
1767            UnixNanos::default(),
1768        )
1769    }
1770
1771    fn equity_aapl_opra() -> Equity {
1772        Equity::new(
1773            InstrumentId::from("AAPL.OPRA"),
1774            Symbol::from("AAPL"),
1775            Some(Ustr::from("US0378331005")),
1776            Currency::from("USD"),
1777            2,
1778            Price::from("0.01"),
1779            None,
1780            None,
1781            None,
1782            None,
1783            None,
1784            None,
1785            None,
1786            None,
1787            None,
1788            None,
1789            None,
1790            UnixNanos::default(),
1791            UnixNanos::default(),
1792        )
1793    }
1794
1795    #[rstest]
1796    fn test_resolve_underlying_instrument_id_errors_without_underlying() {
1797        let instrument = InstrumentAny::Equity(equity_aapl_opra());
1798        let error = GreeksCalculator::resolve_underlying_instrument_id(
1799            &instrument,
1800            InstrumentId::from("AAPL.OPRA"),
1801        )
1802        .unwrap_err();
1803
1804        assert_eq!(
1805            error.to_string(),
1806            "Instrument AAPL.OPRA has no underlying identifier"
1807        );
1808    }
1809
1810    fn future_with_expiration(
1811        instrument_id: &str,
1812        underlying: &str,
1813        expiration_ns: UnixNanos,
1814    ) -> FuturesContract {
1815        FuturesContract::new(
1816            InstrumentId::from(instrument_id),
1817            Symbol::from(underlying),
1818            AssetClass::Index,
1819            Some(Ustr::from("XCME")),
1820            Ustr::from(underlying),
1821            UnixNanos::default(),
1822            expiration_ns,
1823            Currency::from("USD"),
1824            2,
1825            Price::from("0.25"),
1826            Quantity::from(1),
1827            Quantity::from(1),
1828            None,
1829            None,
1830            None,
1831            None,
1832            None,
1833            None,
1834            None,
1835            None,
1836            None,
1837            None,
1838            UnixNanos::default(),
1839            UnixNanos::default(),
1840        )
1841    }
1842
1843    fn future_option_with_expiration(
1844        instrument_id: &str,
1845        raw_symbol: &str,
1846        underlying: &str,
1847        option_kind: OptionKind,
1848        strike: &str,
1849        expiration_ns: UnixNanos,
1850    ) -> OptionContract {
1851        OptionContract::new(
1852            InstrumentId::from(instrument_id),
1853            Symbol::from(raw_symbol),
1854            AssetClass::Index,
1855            Some(Ustr::from("XCME")),
1856            Ustr::from(underlying),
1857            option_kind,
1858            Price::from(strike),
1859            Currency::from("USD"),
1860            UnixNanos::default(),
1861            expiration_ns,
1862            2,
1863            Price::from("0.01"),
1864            Quantity::from(1),
1865            Quantity::from(1),
1866            None,
1867            None,
1868            None,
1869            None,
1870            None,
1871            None,
1872            None,
1873            None,
1874            None,
1875            None,
1876            UnixNanos::default(),
1877            UnixNanos::default(),
1878        )
1879    }
1880
1881    fn setup_cache_with_option_and_quotes(
1882        option: OptionContract,
1883        underlying_id: InstrumentId,
1884        now_ns: UnixNanos,
1885    ) -> Rc<RefCell<Cache>> {
1886        let option_id = option.id();
1887        let cache = Rc::new(RefCell::new(Cache::new(None, None)));
1888        cache
1889            .borrow_mut()
1890            .add_instrument(InstrumentAny::OptionContract(option))
1891            .unwrap();
1892        cache
1893            .borrow_mut()
1894            .add_instrument(InstrumentAny::Equity(equity_aapl_opra()))
1895            .unwrap();
1896        let option_quote = QuoteTick::new(
1897            option_id,
1898            Price::from("10.50"),
1899            Price::from("10.60"),
1900            Quantity::from(100),
1901            Quantity::from(100),
1902            now_ns,
1903            now_ns,
1904        );
1905        let underlying_quote = QuoteTick::new(
1906            underlying_id,
1907            Price::from("150.00"),
1908            Price::from("150.10"),
1909            Quantity::from(100),
1910            Quantity::from(100),
1911            now_ns,
1912            now_ns,
1913        );
1914        cache.borrow_mut().add_quote(option_quote).unwrap();
1915        cache.borrow_mut().add_quote(underlying_quote).unwrap();
1916        cache
1917    }
1918
1919    #[rstest]
1920    fn test_expiry_in_days_multi_day_unchanged() {
1921        let now = Utc.with_ymd_and_hms(2025, 3, 8, 12, 0, 0).unwrap();
1922        let expiry = now + chrono::Duration::days(30);
1923        let now_ns = UnixNanos::from(now);
1924        let expiry_ns = UnixNanos::from(expiry);
1925        let option = option_with_expiration("AAPL250417C00150000.OPRA", expiry_ns);
1926        let option_id = option.id();
1927        let underlying_id = InstrumentId::from("AAPL.OPRA");
1928        let cache = setup_cache_with_option_and_quotes(option, underlying_id, now_ns);
1929        let clock = Rc::new(RefCell::new(TestClock::new()));
1930        let calculator = GreeksCalculator::new(cache, clock);
1931
1932        let greeks = calculator
1933            .instrument_greeks(
1934                option_id,
1935                None,
1936                None,
1937                None,
1938                None,
1939                None,
1940                None,
1941                None,
1942                None,
1943                None,
1944                Some(now_ns),
1945                None,
1946                None,
1947                None,
1948                None,
1949                None,
1950                None,
1951                None,
1952            )
1953            .unwrap();
1954
1955        assert_eq!(greeks.expiry_in_days, 30);
1956        assert!((greeks.expiry_in_years - 30.0 / 365.25).abs() < 1e-9);
1957    }
1958
1959    #[rstest]
1960    fn test_expiry_in_days_same_day_clamped_to_one() {
1961        let now = Utc.with_ymd_and_hms(2025, 3, 8, 12, 0, 0).unwrap();
1962        let expiry_same_day = Utc.with_ymd_and_hms(2025, 3, 8, 18, 0, 0).unwrap();
1963        let now_ns = UnixNanos::from(now);
1964        let expiry_ns = UnixNanos::from(expiry_same_day);
1965        let option = option_with_expiration("AAPL250308C00150000.OPRA", expiry_ns);
1966        let option_id = option.id();
1967        let underlying_id = InstrumentId::from("AAPL.OPRA");
1968        let cache = setup_cache_with_option_and_quotes(option, underlying_id, now_ns);
1969        let clock = Rc::new(RefCell::new(TestClock::new()));
1970        let calculator = GreeksCalculator::new(cache, clock);
1971
1972        let greeks = calculator
1973            .instrument_greeks(
1974                option_id,
1975                None,
1976                None,
1977                None,
1978                None,
1979                None,
1980                None,
1981                None,
1982                None,
1983                None,
1984                Some(now_ns),
1985                None,
1986                None,
1987                None,
1988                None,
1989                None,
1990                None,
1991                None,
1992            )
1993            .unwrap();
1994
1995        assert_eq!(greeks.expiry_in_days, 1);
1996        assert!((greeks.expiry_in_years - 1.0 / 365.25).abs() < 1e-9);
1997    }
1998
1999    #[rstest]
2000    fn test_instrument_greeks_beta_weights_vega_to_vol_index() {
2001        let now = Utc.with_ymd_and_hms(2025, 3, 8, 12, 0, 0).unwrap();
2002        let expiry = now + chrono::Duration::days(30);
2003        let now_ns = UnixNanos::from(now);
2004        let expiry_ns = UnixNanos::from(expiry);
2005        let option = option_with_expiration("AAPL250417C00150000.OPRA", expiry_ns);
2006        let option_id = option.id();
2007        let underlying_id = InstrumentId::from("AAPL.OPRA");
2008        let vol_index_id = InstrumentId::from("VIX.XCBF");
2009        let cache = setup_cache_with_option_and_quotes(option, underlying_id, now_ns);
2010        cache
2011            .borrow_mut()
2012            .add_quote(QuoteTick::new(
2013                vol_index_id,
2014                Price::from("25.00"),
2015                Price::from("25.00"),
2016                Quantity::from(100),
2017                Quantity::from(100),
2018                now_ns,
2019                now_ns,
2020            ))
2021            .unwrap();
2022
2023        let clock = Rc::new(RefCell::new(TestClock::new()));
2024        let calculator = GreeksCalculator::new(cache, clock);
2025        let greeks = calculator
2026            .instrument_greeks(
2027                option_id,
2028                None,
2029                None,
2030                None,
2031                None,
2032                None,
2033                None,
2034                None,
2035                None,
2036                None,
2037                Some(now_ns),
2038                None,
2039                None,
2040                None,
2041                None,
2042                None,
2043                None,
2044                None,
2045            )
2046            .unwrap();
2047
2048        let mut vol_beta_weights = HashMap::new();
2049        vol_beta_weights.insert(underlying_id, 0.75);
2050        let vol_weighted_greeks = calculator
2051            .instrument_greeks(
2052                option_id,
2053                None,
2054                None,
2055                None,
2056                None,
2057                None,
2058                None,
2059                None,
2060                None,
2061                None,
2062                Some(now_ns),
2063                None,
2064                None,
2065                None,
2066                None,
2067                None,
2068                Some(vol_index_id),
2069                Some(&vol_beta_weights),
2070            )
2071            .unwrap();
2072
2073        let expected_vega = greeks.vega * 0.75 * (greeks.vol * 100.0) / 25.0;
2074        assert_eq!(
2075            (vol_weighted_greeks.delta * 1e12).round(),
2076            (greeks.delta * 1e12).round()
2077        );
2078        assert_eq!(
2079            (vol_weighted_greeks.gamma * 1e12).round(),
2080            (greeks.gamma * 1e12).round()
2081        );
2082        assert_eq!(
2083            (vol_weighted_greeks.vega * 1e12).round(),
2084            (expected_vega * 1e12).round()
2085        );
2086    }
2087
2088    #[rstest]
2089    fn test_instrument_greeks_errors_when_vol_index_price_missing() {
2090        let now = Utc.with_ymd_and_hms(2025, 3, 8, 12, 0, 0).unwrap();
2091        let expiry = now + chrono::Duration::days(30);
2092        let now_ns = UnixNanos::from(now);
2093        let expiry_ns = UnixNanos::from(expiry);
2094        let option = option_with_expiration("AAPL250417C00150000.OPRA", expiry_ns);
2095        let option_id = option.id();
2096        let underlying_id = InstrumentId::from("AAPL.OPRA");
2097        let vol_index_id = InstrumentId::from("VIX.XCBF");
2098        let cache = setup_cache_with_option_and_quotes(option, underlying_id, now_ns);
2099
2100        let clock = Rc::new(RefCell::new(TestClock::new()));
2101        let calculator = GreeksCalculator::new(cache, clock);
2102        let error = calculator
2103            .instrument_greeks(
2104                option_id,
2105                None,
2106                None,
2107                None,
2108                None,
2109                None,
2110                None,
2111                None,
2112                None,
2113                None,
2114                Some(now_ns),
2115                None,
2116                None,
2117                None,
2118                None,
2119                None,
2120                Some(vol_index_id),
2121                None,
2122            )
2123            .unwrap_err();
2124
2125        assert_eq!(error.to_string(), "No price available for VIX.XCBF");
2126    }
2127
2128    #[rstest]
2129    fn test_modify_greeks_errors_when_vol_index_price_missing() {
2130        let calculator = create_test_calculator();
2131        let underlying_id = InstrumentId::from("AAPL.OPRA");
2132        let vol_index_id = InstrumentId::from("VIX.XCBF");
2133
2134        let error = calculator
2135            .modify_greeks(
2136                1.0,
2137                2.0,
2138                underlying_id,
2139                150.0,
2140                150.0,
2141                false,
2142                None,
2143                None,
2144                2.0,
2145                0.30,
2146                0,
2147                None,
2148                0.0,
2149                Some(vol_index_id),
2150                None,
2151                None,
2152                None,
2153            )
2154            .unwrap_err();
2155
2156        assert_eq!(error.to_string(), "No price available for VIX.XCBF");
2157    }
2158
2159    #[rstest]
2160    fn test_modify_greeks_accepts_explicit_index_prices() {
2161        let calculator = create_test_calculator();
2162        let underlying_id = InstrumentId::from("AAPL.OPRA");
2163        let mut beta_weights = HashMap::new();
2164        beta_weights.insert(underlying_id, 0.5);
2165        let mut vol_beta_weights = HashMap::new();
2166        vol_beta_weights.insert(underlying_id, 0.75);
2167
2168        let (delta, gamma, vega) = calculator
2169            .modify_greeks(
2170                1.0,
2171                2.0,
2172                underlying_id,
2173                150.0,
2174                150.0,
2175                false,
2176                None,
2177                Some(&beta_weights),
2178                2.0,
2179                0.30,
2180                0,
2181                None,
2182                0.0,
2183                None,
2184                Some(&vol_beta_weights),
2185                Some(200.0),
2186                Some(25.0),
2187            )
2188            .unwrap();
2189
2190        assert_eq!((delta * 1e12).round(), 375_000_000_000.0);
2191        assert_eq!((gamma * 1e12).round(), 281_250_000_000.0);
2192        assert_eq!((vega * 1e12).round(), 1_800_000_000_000.0);
2193
2194        let (delta, gamma, vega) = calculator
2195            .modify_greeks(
2196                1.0,
2197                2.0,
2198                underlying_id,
2199                150.0,
2200                150.0,
2201                true,
2202                None,
2203                Some(&beta_weights),
2204                2.0,
2205                0.30,
2206                0,
2207                None,
2208                0.0,
2209                None,
2210                Some(&vol_beta_weights),
2211                Some(200.0),
2212                Some(25.0),
2213            )
2214            .unwrap();
2215
2216        assert_eq!((delta * 1e12).round(), 750_000_000_000.0);
2217        assert_eq!((gamma * 1e12).round(), 1_125_000_000_000.0);
2218        assert_eq!((vega * 1e12).round(), 4_500_000_000.0);
2219    }
2220
2221    #[rstest]
2222    fn test_instrument_greeks_errors_when_future_underlying_price_missing_without_cached_spread() {
2223        let now = Utc.with_ymd_and_hms(2024, 2, 14, 16, 0, 0).unwrap();
2224        let expiry = Utc.with_ymd_and_hms(2024, 3, 15, 16, 0, 0).unwrap();
2225        let now_ns = UnixNanos::from(now);
2226        let expiry_ns = UnixNanos::from(expiry);
2227
2228        let future = future_with_expiration("ESH4.GLBX", "ESH4", expiry_ns);
2229        let call_option = future_option_with_expiration(
2230            "ESH4C150.GLBX",
2231            "ESH4C150",
2232            "ESH4",
2233            OptionKind::Call,
2234            "150.00",
2235            expiry_ns,
2236        );
2237        let put_option = future_option_with_expiration(
2238            "ESH4P150.GLBX",
2239            "ESH4P150",
2240            "ESH4",
2241            OptionKind::Put,
2242            "150.00",
2243            expiry_ns,
2244        );
2245
2246        let cache = Rc::new(RefCell::new(Cache::new(None, None)));
2247        cache
2248            .borrow_mut()
2249            .add_instrument(InstrumentAny::FuturesContract(future))
2250            .unwrap();
2251        cache
2252            .borrow_mut()
2253            .add_instrument(InstrumentAny::OptionContract(call_option.clone()))
2254            .unwrap();
2255        cache
2256            .borrow_mut()
2257            .add_instrument(InstrumentAny::OptionContract(put_option.clone()))
2258            .unwrap();
2259
2260        let call_quote = QuoteTick::new(
2261            call_option.id(),
2262            Price::from("8.50"),
2263            Price::from("8.50"),
2264            Quantity::from(100),
2265            Quantity::from(100),
2266            now_ns,
2267            now_ns,
2268        );
2269        let put_quote = QuoteTick::new(
2270            put_option.id(),
2271            Price::from("3.33"),
2272            Price::from("3.33"),
2273            Quantity::from(100),
2274            Quantity::from(100),
2275            now_ns,
2276            now_ns,
2277        );
2278        cache.borrow_mut().add_quote(call_quote).unwrap();
2279        cache.borrow_mut().add_quote(put_quote).unwrap();
2280
2281        let clock = Rc::new(RefCell::new(TestClock::new()));
2282        clock.borrow_mut().set_time(now_ns);
2283        let calculator = GreeksCalculator::new(cache, clock);
2284
2285        let error = calculator
2286            .instrument_greeks(
2287                call_option.id(),
2288                Some(0.0425),
2289                None,
2290                None,
2291                None,
2292                None,
2293                None,
2294                None,
2295                None,
2296                None,
2297                Some(now_ns),
2298                None,
2299                None,
2300                None,
2301                None,
2302                None,
2303                None,
2304                None,
2305            )
2306            .unwrap_err();
2307
2308        assert_eq!(error.to_string(), "No price available for ESH4.GLBX");
2309    }
2310
2311    #[rstest]
2312    fn test_cache_futures_spread_returns_price_to_reference_future() {
2313        let now = Utc.with_ymd_and_hms(2024, 2, 14, 16, 0, 0).unwrap();
2314        let expiry = Utc.with_ymd_and_hms(2024, 3, 15, 16, 0, 0).unwrap();
2315        let now_ns = UnixNanos::from(now);
2316        let expiry_ns = UnixNanos::from(expiry);
2317
2318        let future = future_with_expiration("ESH4.GLBX", "ESH4", expiry_ns);
2319        let reference_future = future_with_expiration("ESM4.GLBX", "ESM4", expiry_ns);
2320        let call_option = future_option_with_expiration(
2321            "ESH4C150.GLBX",
2322            "ESH4C150",
2323            "ESH4",
2324            OptionKind::Call,
2325            "150.00",
2326            expiry_ns,
2327        );
2328        let put_option = future_option_with_expiration(
2329            "ESH4P150.GLBX",
2330            "ESH4P150",
2331            "ESH4",
2332            OptionKind::Put,
2333            "150.00",
2334            expiry_ns,
2335        );
2336
2337        let cache = Rc::new(RefCell::new(Cache::new(None, None)));
2338        cache
2339            .borrow_mut()
2340            .add_instrument(InstrumentAny::FuturesContract(future))
2341            .unwrap();
2342        cache
2343            .borrow_mut()
2344            .add_instrument(InstrumentAny::FuturesContract(reference_future.clone()))
2345            .unwrap();
2346        cache
2347            .borrow_mut()
2348            .add_instrument(InstrumentAny::OptionContract(call_option.clone()))
2349            .unwrap();
2350        cache
2351            .borrow_mut()
2352            .add_instrument(InstrumentAny::OptionContract(put_option.clone()))
2353            .unwrap();
2354
2355        let call_quote = QuoteTick::new(
2356            call_option.id(),
2357            Price::from("8.50"),
2358            Price::from("8.50"),
2359            Quantity::from(100),
2360            Quantity::from(100),
2361            now_ns,
2362            now_ns,
2363        );
2364        let put_quote = QuoteTick::new(
2365            put_option.id(),
2366            Price::from("3.33"),
2367            Price::from("3.33"),
2368            Quantity::from(100),
2369            Quantity::from(100),
2370            now_ns,
2371            now_ns,
2372        );
2373        let reference_future_quote = QuoteTick::new(
2374            reference_future.id(),
2375            Price::from("155.00"),
2376            Price::from("155.00"),
2377            Quantity::from(100),
2378            Quantity::from(100),
2379            now_ns,
2380            now_ns,
2381        );
2382        cache.borrow_mut().add_quote(call_quote).unwrap();
2383        cache.borrow_mut().add_quote(put_quote).unwrap();
2384        cache
2385            .borrow_mut()
2386            .add_quote(reference_future_quote)
2387            .unwrap();
2388
2389        let clock = Rc::new(RefCell::new(TestClock::new()));
2390        clock.borrow_mut().set_time(now_ns);
2391        let calculator = GreeksCalculator::new(cache, clock);
2392
2393        let cached_future_price = calculator
2394            .cache_futures_spread(call_option.id(), put_option.id(), reference_future.id())
2395            .unwrap();
2396
2397        let expected_underlying = 150.0 + (0.0425_f64 * (30.0 / 365.25)).exp() * (8.50 - 3.33);
2398        let expected_cached_underlying = reference_future.make_price(expected_underlying);
2399        assert_eq!(cached_future_price, expected_cached_underlying);
2400        assert_eq!(
2401            calculator.get_cached_futures_spread_price(InstrumentId::from("ESH4.GLBX")),
2402            Some(expected_cached_underlying)
2403        );
2404    }
2405
2406    #[rstest]
2407    fn test_instrument_greeks_uses_cached_futures_spread_when_underlying_price_missing() {
2408        let now = Utc.with_ymd_and_hms(2024, 2, 14, 16, 0, 0).unwrap();
2409        let expiry = Utc.with_ymd_and_hms(2024, 3, 15, 16, 0, 0).unwrap();
2410        let now_ns = UnixNanos::from(now);
2411        let expiry_ns = UnixNanos::from(expiry);
2412
2413        let future = future_with_expiration("ESH4.GLBX", "ESH4", expiry_ns);
2414        let reference_future = future_with_expiration("ESM4.GLBX", "ESM4", expiry_ns);
2415        let call_option = future_option_with_expiration(
2416            "ESH4C150.GLBX",
2417            "ESH4C150",
2418            "ESH4",
2419            OptionKind::Call,
2420            "150.00",
2421            expiry_ns,
2422        );
2423        let put_option = future_option_with_expiration(
2424            "ESH4P150.GLBX",
2425            "ESH4P150",
2426            "ESH4",
2427            OptionKind::Put,
2428            "150.00",
2429            expiry_ns,
2430        );
2431        let target_call_option = future_option_with_expiration(
2432            "ESH4C152.GLBX",
2433            "ESH4C152",
2434            "ESH4",
2435            OptionKind::Call,
2436            "152.00",
2437            expiry_ns,
2438        );
2439
2440        let cache = Rc::new(RefCell::new(Cache::new(None, None)));
2441        cache
2442            .borrow_mut()
2443            .add_instrument(InstrumentAny::FuturesContract(future))
2444            .unwrap();
2445        cache
2446            .borrow_mut()
2447            .add_instrument(InstrumentAny::FuturesContract(reference_future.clone()))
2448            .unwrap();
2449        cache
2450            .borrow_mut()
2451            .add_instrument(InstrumentAny::OptionContract(call_option.clone()))
2452            .unwrap();
2453        cache
2454            .borrow_mut()
2455            .add_instrument(InstrumentAny::OptionContract(put_option.clone()))
2456            .unwrap();
2457        cache
2458            .borrow_mut()
2459            .add_instrument(InstrumentAny::OptionContract(target_call_option.clone()))
2460            .unwrap();
2461
2462        let call_quote = QuoteTick::new(
2463            call_option.id(),
2464            Price::from("8.50"),
2465            Price::from("8.50"),
2466            Quantity::from(100),
2467            Quantity::from(100),
2468            now_ns,
2469            now_ns,
2470        );
2471        let put_quote = QuoteTick::new(
2472            put_option.id(),
2473            Price::from("3.33"),
2474            Price::from("3.33"),
2475            Quantity::from(100),
2476            Quantity::from(100),
2477            now_ns,
2478            now_ns,
2479        );
2480        let target_call_quote = QuoteTick::new(
2481            target_call_option.id(),
2482            Price::from("6.75"),
2483            Price::from("6.75"),
2484            Quantity::from(100),
2485            Quantity::from(100),
2486            now_ns,
2487            now_ns,
2488        );
2489        let reference_future_quote = QuoteTick::new(
2490            reference_future.id(),
2491            Price::from("155.00"),
2492            Price::from("155.00"),
2493            Quantity::from(100),
2494            Quantity::from(100),
2495            now_ns,
2496            now_ns,
2497        );
2498        cache.borrow_mut().add_quote(call_quote).unwrap();
2499        cache.borrow_mut().add_quote(put_quote).unwrap();
2500        cache.borrow_mut().add_quote(target_call_quote).unwrap();
2501        cache
2502            .borrow_mut()
2503            .add_quote(reference_future_quote)
2504            .unwrap();
2505
2506        let clock = Rc::new(RefCell::new(TestClock::new()));
2507        clock.borrow_mut().set_time(now_ns);
2508        let calculator = GreeksCalculator::new(cache, clock);
2509        calculator
2510            .cache_futures_spread(call_option.id(), put_option.id(), reference_future.id())
2511            .unwrap();
2512
2513        let greeks = calculator
2514            .instrument_greeks(
2515                target_call_option.id(),
2516                Some(0.0425),
2517                None,
2518                None,
2519                None,
2520                None,
2521                None,
2522                None,
2523                None,
2524                None,
2525                Some(now_ns),
2526                None,
2527                None,
2528                None,
2529                None,
2530                None,
2531                None,
2532                None,
2533            )
2534            .unwrap();
2535
2536        let expected_underlying = reference_future
2537            .make_price(150.0 + (0.0425_f64 * (30.0 / 365.25)).exp() * (8.50 - 3.33))
2538            .as_f64();
2539        assert_eq!(greeks.underlying_price, expected_underlying);
2540    }
2541
2542    #[rstest]
2543    fn test_instrument_greeks_uses_index_price_for_index_underlying() {
2544        let now = Utc.with_ymd_and_hms(2024, 2, 14, 16, 0, 0).unwrap();
2545        let expiry = Utc.with_ymd_and_hms(2024, 3, 15, 16, 0, 0).unwrap();
2546        let now_ns = UnixNanos::from(now);
2547        let expiry_ns = UnixNanos::from(expiry);
2548
2549        let future = future_with_expiration("ESH4.GLBX", "ESH4", expiry_ns);
2550        let call_option = future_option_with_expiration(
2551            "ESH4C150.GLBX",
2552            "ESH4C150",
2553            "ESH4",
2554            OptionKind::Call,
2555            "150.00",
2556            expiry_ns,
2557        );
2558
2559        let cache = Rc::new(RefCell::new(Cache::new(None, None)));
2560        cache
2561            .borrow_mut()
2562            .add_instrument(InstrumentAny::FuturesContract(future))
2563            .unwrap();
2564        cache
2565            .borrow_mut()
2566            .add_instrument(InstrumentAny::OptionContract(call_option.clone()))
2567            .unwrap();
2568
2569        let call_quote = QuoteTick::new(
2570            call_option.id(),
2571            Price::from("8.50"),
2572            Price::from("8.50"),
2573            Quantity::from(100),
2574            Quantity::from(100),
2575            now_ns,
2576            now_ns,
2577        );
2578        cache.borrow_mut().add_quote(call_quote).unwrap();
2579        cache
2580            .borrow_mut()
2581            .add_index_price(IndexPriceUpdate::new(
2582                InstrumentId::from("ESH4.GLBX"),
2583                Price::from("157.25"),
2584                now_ns,
2585                now_ns,
2586            ))
2587            .unwrap();
2588
2589        let clock = Rc::new(RefCell::new(TestClock::new()));
2590        clock.borrow_mut().set_time(now_ns);
2591        let calculator = GreeksCalculator::new(cache, clock);
2592
2593        let greeks = calculator
2594            .instrument_greeks(
2595                call_option.id(),
2596                Some(0.0425),
2597                None,
2598                None,
2599                None,
2600                None,
2601                None,
2602                None,
2603                None,
2604                None,
2605                Some(now_ns),
2606                None,
2607                None,
2608                None,
2609                None,
2610                None,
2611                None,
2612                None,
2613            )
2614            .unwrap();
2615
2616        assert_eq!(greeks.underlying_price, 157.25);
2617    }
2618
2619    #[rstest]
2620    fn test_instrument_greeks_prefers_quote_over_index_price_for_index_future() {
2621        let now = Utc.with_ymd_and_hms(2024, 2, 14, 16, 0, 0).unwrap();
2622        let expiry = Utc.with_ymd_and_hms(2024, 3, 15, 16, 0, 0).unwrap();
2623        let now_ns = UnixNanos::from(now);
2624        let expiry_ns = UnixNanos::from(expiry);
2625
2626        let future = future_with_expiration("ESH4.GLBX", "ESH4", expiry_ns);
2627        let call_option = future_option_with_expiration(
2628            "ESH4C150.GLBX",
2629            "ESH4C150",
2630            "ESH4",
2631            OptionKind::Call,
2632            "150.00",
2633            expiry_ns,
2634        );
2635
2636        let cache = Rc::new(RefCell::new(Cache::new(None, None)));
2637        cache
2638            .borrow_mut()
2639            .add_instrument(InstrumentAny::FuturesContract(future))
2640            .unwrap();
2641        cache
2642            .borrow_mut()
2643            .add_instrument(InstrumentAny::OptionContract(call_option.clone()))
2644            .unwrap();
2645
2646        // Both a quote and an index price for the underlying future
2647        let future_quote = QuoteTick::new(
2648            InstrumentId::from("ESH4.GLBX"),
2649            Price::from("158.50"),
2650            Price::from("159.50"),
2651            Quantity::from(100),
2652            Quantity::from(100),
2653            now_ns,
2654            now_ns,
2655        );
2656        cache.borrow_mut().add_quote(future_quote).unwrap();
2657        cache
2658            .borrow_mut()
2659            .add_index_price(IndexPriceUpdate::new(
2660                InstrumentId::from("ESH4.GLBX"),
2661                Price::from("157.25"),
2662                now_ns,
2663                now_ns,
2664            ))
2665            .unwrap();
2666
2667        let call_quote = QuoteTick::new(
2668            call_option.id(),
2669            Price::from("8.50"),
2670            Price::from("8.50"),
2671            Quantity::from(100),
2672            Quantity::from(100),
2673            now_ns,
2674            now_ns,
2675        );
2676        cache.borrow_mut().add_quote(call_quote).unwrap();
2677
2678        let clock = Rc::new(RefCell::new(TestClock::new()));
2679        clock.borrow_mut().set_time(now_ns);
2680        let calculator = GreeksCalculator::new(cache, clock);
2681
2682        let greeks = calculator
2683            .instrument_greeks(
2684                call_option.id(),
2685                Some(0.0425),
2686                None,
2687                None,
2688                None,
2689                None,
2690                None,
2691                None,
2692                None,
2693                None,
2694                Some(now_ns),
2695                None,
2696                None,
2697                None,
2698                None,
2699                None,
2700                None,
2701                None,
2702            )
2703            .unwrap();
2704
2705        // Should use the MID quote (159.00), not the index price (157.25)
2706        assert_eq!(greeks.underlying_price, 159.0);
2707    }
2708}