Skip to main content

nautilus_common/
greeks.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! Greeks calculator for options and futures.
17
18use std::{cell::RefCell, collections::HashMap, fmt::Debug, rc::Rc};
19
20use ahash::AHashMap;
21use nautilus_core::UnixNanos;
22use nautilus_model::{
23    data::greeks::{
24        GreeksData, OptionGreekValues, PortfolioGreeks, black_scholes_greeks, imply_vol_and_greeks,
25        refine_vol_and_greeks,
26    },
27    enums::{AssetClass, InstrumentClass, OptionKind, PositionSide, PriceType},
28    identifiers::{InstrumentId, StrategyId, Venue},
29    instruments::{Instrument, any::InstrumentAny},
30    position::Position,
31    types::Price,
32};
33
34use crate::{
35    actor::DataActorNative,
36    cache::{Cache, refs::PositionRef},
37    clock::Clock,
38    msgbus,
39    msgbus::TypedHandler,
40};
41
42/// Type alias for a greeks filter function.
43pub type GreeksFilter = Box<dyn Fn(&GreeksData) -> bool>;
44
45/// Cloneable wrapper for greeks filter functions.
46#[derive(Clone)]
47pub enum GreeksFilterCallback {
48    /// Function pointer (non-capturing closure)
49    Function(fn(&GreeksData) -> bool),
50    /// Boxed closure (may capture variables)
51    Closure(std::rc::Rc<dyn Fn(&GreeksData) -> bool>),
52}
53
54impl GreeksFilterCallback {
55    /// Create a new filter from a function pointer.
56    pub fn from_fn(f: fn(&GreeksData) -> bool) -> Self {
57        Self::Function(f)
58    }
59
60    /// Create a new filter from a closure.
61    pub fn from_closure<F>(f: F) -> Self
62    where
63        F: Fn(&GreeksData) -> bool + 'static,
64    {
65        Self::Closure(std::rc::Rc::new(f))
66    }
67
68    /// Call the filter function.
69    pub fn call(&self, data: &GreeksData) -> bool {
70        match self {
71            Self::Function(f) => f(data),
72            Self::Closure(f) => f(data),
73        }
74    }
75
76    /// Convert to the original `GreeksFilter` type.
77    pub fn to_greeks_filter(self) -> GreeksFilter {
78        match self {
79            Self::Function(f) => Box::new(f),
80            Self::Closure(f) => {
81                let f_clone = f.clone();
82                Box::new(move |data| f_clone(data))
83            }
84        }
85    }
86}
87
88impl Debug for GreeksFilterCallback {
89    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
90        match self {
91            Self::Function(_) => f.write_str("GreeksFilterCallback::Function"),
92            Self::Closure(_) => f.write_str("GreeksFilterCallback::Closure"),
93        }
94    }
95}
96
97/// Builder for instrument greeks calculation parameters.
98#[derive(Debug, bon::Builder)]
99pub struct InstrumentGreeksParams {
100    /// The instrument ID to calculate greeks for
101    pub instrument_id: InstrumentId,
102    /// Flat interest rate (default: 0.0425)
103    #[builder(default = 0.0425)]
104    pub flat_interest_rate: f64,
105    /// Flat dividend yield
106    pub flat_dividend_yield: Option<f64>,
107    /// Spot price shock (default: 0.0)
108    #[builder(default = 0.0)]
109    pub spot_shock: f64,
110    /// Volatility shock (default: 0.0)
111    #[builder(default = 0.0)]
112    pub vol_shock: f64,
113    /// Time to expiry shock (default: 0.0)
114    #[builder(default = 0.0)]
115    pub time_to_expiry_shock: f64,
116    /// Whether to use cached greeks (default: false)
117    #[builder(default = false)]
118    pub use_cached_greeks: bool,
119    /// Whether to update vol from cached greeks (default: false)
120    #[builder(default = false)]
121    pub update_vol: bool,
122    /// Whether to cache greeks (default: false)
123    #[builder(default = false)]
124    pub cache_greeks: bool,
125    /// Whether to publish greeks (default: false)
126    #[builder(default = false)]
127    pub publish_greeks: bool,
128    /// Event timestamp
129    pub ts_event: Option<UnixNanos>,
130    /// Position for PnL calculation
131    pub position: Option<Position>,
132    /// Whether to compute percent greeks (default: false)
133    #[builder(default = false)]
134    pub percent_greeks: bool,
135    /// Index instrument ID for beta weighting
136    pub index_instrument_id: Option<InstrumentId>,
137    /// Beta weights for portfolio calculations
138    pub beta_weights: Option<HashMap<InstrumentId, f64>>,
139    /// Base value in days for time-weighting vega
140    pub vega_time_weight_base: Option<i32>,
141    /// Volatility index instrument ID for vega beta weighting, for example VIX.
142    pub vol_index_instrument_id: Option<InstrumentId>,
143    /// Volatility beta weights for portfolio vega calculations
144    pub vol_beta_weights: Option<HashMap<InstrumentId, f64>>,
145}
146
147impl InstrumentGreeksParams {
148    /// Calculate instrument greeks using the builder parameters.
149    ///
150    /// # Errors
151    ///
152    /// Returns an error if the greeks calculation fails.
153    pub fn calculate(&self, calculator: &GreeksCalculator) -> anyhow::Result<GreeksData> {
154        calculator.instrument_greeks(
155            self.instrument_id,
156            Some(self.flat_interest_rate),
157            self.flat_dividend_yield,
158            Some(self.spot_shock),
159            Some(self.vol_shock),
160            Some(self.time_to_expiry_shock),
161            Some(self.use_cached_greeks),
162            Some(self.update_vol),
163            Some(self.cache_greeks),
164            Some(self.publish_greeks),
165            self.ts_event,
166            self.position.clone(),
167            Some(self.percent_greeks),
168            self.index_instrument_id,
169            self.beta_weights.as_ref(),
170            self.vega_time_weight_base,
171            self.vol_index_instrument_id,
172            self.vol_beta_weights.as_ref(),
173        )
174    }
175}
176
177/// Builder for portfolio greeks calculation parameters.
178#[derive(bon::Builder)]
179pub struct PortfolioGreeksParams {
180    /// List of underlying symbols to filter by
181    pub underlyings: Option<Vec<String>>,
182    /// Venue to filter positions by
183    pub venue: Option<Venue>,
184    /// Instrument ID to filter positions by
185    pub instrument_id: Option<InstrumentId>,
186    /// Strategy ID to filter positions by
187    pub strategy_id: Option<StrategyId>,
188    /// Position side to filter by (default: `None`)
189    pub side: Option<PositionSide>,
190    /// Flat interest rate (default: 0.0425)
191    #[builder(default = 0.0425)]
192    pub flat_interest_rate: f64,
193    /// Flat dividend yield
194    pub flat_dividend_yield: Option<f64>,
195    /// Spot price shock (default: 0.0)
196    #[builder(default = 0.0)]
197    pub spot_shock: f64,
198    /// Volatility shock (default: 0.0)
199    #[builder(default = 0.0)]
200    pub vol_shock: f64,
201    /// Time to expiry shock (default: 0.0)
202    #[builder(default = 0.0)]
203    pub time_to_expiry_shock: f64,
204    /// Whether to use cached greeks (default: false)
205    #[builder(default = false)]
206    pub use_cached_greeks: bool,
207    /// Whether to update vol from cached greeks (default: false)
208    #[builder(default = false)]
209    pub update_vol: bool,
210    /// Whether to cache greeks (default: false)
211    #[builder(default = false)]
212    pub cache_greeks: bool,
213    /// Whether to publish greeks (default: false)
214    #[builder(default = false)]
215    pub publish_greeks: bool,
216    /// Whether to compute percent greeks (default: false)
217    #[builder(default = false)]
218    pub percent_greeks: bool,
219    /// Index instrument ID for beta weighting
220    pub index_instrument_id: Option<InstrumentId>,
221    /// Beta weights for portfolio calculations
222    pub beta_weights: Option<HashMap<InstrumentId, f64>>,
223    /// Filter function for greeks
224    pub greeks_filter: Option<GreeksFilterCallback>,
225    /// Base value in days for time-weighting vega
226    pub vega_time_weight_base: Option<i32>,
227    /// Volatility index instrument ID for vega beta weighting, for example VIX.
228    pub vol_index_instrument_id: Option<InstrumentId>,
229    /// Volatility beta weights for portfolio vega calculations
230    pub vol_beta_weights: Option<HashMap<InstrumentId, f64>>,
231}
232
233impl Debug for PortfolioGreeksParams {
234    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
235        f.debug_struct(stringify!(PortfolioGreeksParams))
236            .field("underlyings", &self.underlyings)
237            .field("venue", &self.venue)
238            .field("instrument_id", &self.instrument_id)
239            .field("strategy_id", &self.strategy_id)
240            .field("side", &self.side)
241            .field("flat_interest_rate", &self.flat_interest_rate)
242            .field("flat_dividend_yield", &self.flat_dividend_yield)
243            .field("spot_shock", &self.spot_shock)
244            .field("vol_shock", &self.vol_shock)
245            .field("time_to_expiry_shock", &self.time_to_expiry_shock)
246            .field("use_cached_greeks", &self.use_cached_greeks)
247            .field("update_vol", &self.update_vol)
248            .field("cache_greeks", &self.cache_greeks)
249            .field("publish_greeks", &self.publish_greeks)
250            .field("percent_greeks", &self.percent_greeks)
251            .field("index_instrument_id", &self.index_instrument_id)
252            .field("beta_weights", &self.beta_weights)
253            .field("greeks_filter", &self.greeks_filter)
254            .field("vega_time_weight_base", &self.vega_time_weight_base)
255            .field("vol_index_instrument_id", &self.vol_index_instrument_id)
256            .field("vol_beta_weights", &self.vol_beta_weights)
257            .finish()
258    }
259}
260
261impl PortfolioGreeksParams {
262    /// Calculate portfolio greeks using the builder parameters.
263    ///
264    /// # Errors
265    ///
266    /// Returns an error if the portfolio greeks calculation fails.
267    pub fn calculate(&self, calculator: &GreeksCalculator) -> anyhow::Result<PortfolioGreeks> {
268        let greeks_filter = self
269            .greeks_filter
270            .as_ref()
271            .map(|f| f.clone().to_greeks_filter());
272
273        calculator.portfolio_greeks(
274            self.underlyings.as_deref(),
275            self.venue,
276            self.instrument_id,
277            self.strategy_id,
278            self.side,
279            Some(self.flat_interest_rate),
280            self.flat_dividend_yield,
281            Some(self.spot_shock),
282            Some(self.vol_shock),
283            Some(self.time_to_expiry_shock),
284            Some(self.use_cached_greeks),
285            Some(self.update_vol),
286            Some(self.cache_greeks),
287            Some(self.publish_greeks),
288            Some(self.percent_greeks),
289            self.index_instrument_id,
290            self.beta_weights.as_ref(),
291            greeks_filter.as_ref(),
292            self.vega_time_weight_base,
293            self.vol_index_instrument_id,
294            self.vol_beta_weights.as_ref(),
295        )
296    }
297}
298
299/// Calculates instrument and portfolio greeks (sensitivities of price moves with respect to market data moves).
300///
301/// Useful for risk management of options and futures portfolios.
302///
303/// Currently implemented greeks are:
304/// - Delta (first derivative of price with respect to spot move).
305/// - Gamma (second derivative of price with respect to spot move).
306/// - Vega (first derivative of price with respect to implied volatility of an option).
307/// - Theta (first derivative of price with respect to time to expiry).
308///
309/// Vega is expressed in terms of absolute percent changes ((dV / dVol) / 100).
310/// Theta is expressed in terms of daily changes ((dV / d(T-t)) / 365.25, where T is the expiry of an option and t is the current time).
311///
312/// Also note that for ease of implementation we consider that american options (for stock options for example) are european for the computation of greeks.
313#[allow(dead_code)]
314#[derive(Debug)]
315pub struct GreeksCalculator {
316    cache: Rc<RefCell<Cache>>,
317    clock: Rc<RefCell<dyn Clock>>,
318    cached_futures_spreads: RefCell<AHashMap<InstrumentId, (InstrumentId, Price)>>,
319}
320
321impl GreeksCalculator {
322    /// Creates a new [`GreeksCalculator`] instance.
323    pub fn new(cache: Rc<RefCell<Cache>>, clock: Rc<RefCell<dyn Clock>>) -> Self {
324        Self {
325            cache,
326            clock,
327            cached_futures_spreads: RefCell::new(AHashMap::new()),
328        }
329    }
330
331    /// Creates a new [`GreeksCalculator`] from a registered native actor.
332    ///
333    /// # Panics
334    ///
335    /// Panics if the actor has not been registered with a trader.
336    pub fn from_actor(actor: &impl DataActorNative) -> Self {
337        Self::new(actor.cache_rc(), actor.clock_rc())
338    }
339
340    /// Calculates option or underlying greeks for a given instrument and a quantity of 1.
341    ///
342    /// Additional features:
343    /// - Apply shocks to the spot value of the instrument's underlying, implied volatility, or time to expiry.
344    /// - Compute percent greeks.
345    /// - Compute beta-weighted delta, gamma, and vega with respect to an index.
346    ///
347    /// # Errors
348    ///
349    /// Returns an error if the instrument definition is not found, an option instrument
350    /// has no underlying identifier, or greeks calculation fails.
351    #[expect(clippy::too_many_arguments)]
352    pub fn instrument_greeks(
353        &self,
354        instrument_id: InstrumentId,
355        flat_interest_rate: Option<f64>,
356        flat_dividend_yield: Option<f64>,
357        spot_shock: Option<f64>,
358        vol_shock: Option<f64>,
359        time_to_expiry_shock: Option<f64>,
360        use_cached_greeks: Option<bool>,
361        update_vol: Option<bool>,
362        cache_greeks: Option<bool>,
363        publish_greeks: Option<bool>,
364        ts_event: Option<UnixNanos>,
365        position: Option<Position>,
366        percent_greeks: Option<bool>,
367        index_instrument_id: Option<InstrumentId>,
368        beta_weights: Option<&HashMap<InstrumentId, f64>>,
369        vega_time_weight_base: Option<i32>,
370        vol_index_instrument_id: Option<InstrumentId>,
371        vol_beta_weights: Option<&HashMap<InstrumentId, f64>>,
372    ) -> anyhow::Result<GreeksData> {
373        // Set default values
374        let flat_interest_rate = flat_interest_rate.unwrap_or(0.0425);
375        let spot_shock = spot_shock.unwrap_or(0.0);
376        let vol_shock = vol_shock.unwrap_or(0.0);
377        let time_to_expiry_shock = time_to_expiry_shock.unwrap_or(0.0);
378        let use_cached_greeks = use_cached_greeks.unwrap_or(false);
379        let update_vol = update_vol.unwrap_or(false);
380        let cache_greeks = cache_greeks.unwrap_or(false);
381        let publish_greeks = publish_greeks.unwrap_or(false);
382        let ts_event = ts_event.unwrap_or_default();
383        let percent_greeks = percent_greeks.unwrap_or(false);
384
385        let instrument = {
386            let cache = self.cache.borrow();
387            cache.try_instrument(&instrument_id)?.clone()
388        };
389
390        if instrument.instrument_class() != InstrumentClass::Option {
391            return self.calculate_non_option_greeks(
392                &instrument,
393                instrument_id,
394                spot_shock,
395                ts_event,
396                position,
397                percent_greeks,
398                index_instrument_id,
399                beta_weights,
400            );
401        }
402
403        let underlying_instrument_id =
404            Self::resolve_underlying_instrument_id(&instrument, instrument_id)?;
405        let mut greeks_data = self.calculate_option_greeks(
406            &instrument,
407            instrument_id,
408            underlying_instrument_id,
409            flat_interest_rate,
410            flat_dividend_yield,
411            use_cached_greeks,
412            update_vol,
413            cache_greeks,
414            publish_greeks,
415            ts_event,
416            percent_greeks,
417            index_instrument_id,
418            beta_weights,
419            vega_time_weight_base,
420            vol_index_instrument_id,
421            vol_beta_weights,
422        )?;
423
424        if spot_shock != 0.0 || vol_shock != 0.0 || time_to_expiry_shock != 0.0 {
425            greeks_data = self.apply_option_greeks_shocks(
426                &greeks_data,
427                underlying_instrument_id,
428                spot_shock,
429                vol_shock,
430                time_to_expiry_shock,
431                percent_greeks,
432                index_instrument_id,
433                beta_weights,
434                vega_time_weight_base,
435                vol_index_instrument_id,
436                vol_beta_weights,
437            )?;
438        }
439
440        if let Some(pos) = position {
441            greeks_data.pnl = greeks_data.price - pos.avg_px_open;
442        }
443
444        Ok(greeks_data)
445    }
446
447    fn resolve_underlying_instrument_id(
448        instrument: &InstrumentAny,
449        instrument_id: InstrumentId,
450    ) -> anyhow::Result<InstrumentId> {
451        let Some(underlying) = instrument.underlying() else {
452            anyhow::bail!("Instrument {instrument_id} has no underlying identifier");
453        };
454
455        Ok(InstrumentId::from(format!(
456            "{}.{}",
457            underlying, instrument_id.venue
458        )))
459    }
460
461    #[expect(clippy::too_many_arguments)]
462    fn calculate_non_option_greeks(
463        &self,
464        instrument: &InstrumentAny,
465        instrument_id: InstrumentId,
466        spot_shock: f64,
467        ts_event: UnixNanos,
468        position: Option<Position>,
469        percent_greeks: bool,
470        index_instrument_id: Option<InstrumentId>,
471        beta_weights: Option<&HashMap<InstrumentId, f64>>,
472    ) -> anyhow::Result<GreeksData> {
473        let multiplier = instrument.multiplier();
474        let underlying_instrument_id = instrument.id();
475        let underlying_price = self
476            .get_price(&underlying_instrument_id)
477            .ok_or_else(|| anyhow::anyhow!("No price available for {underlying_instrument_id}"))?;
478        let (delta, _, _) = self.modify_greeks(
479            1.0,
480            0.0,
481            underlying_instrument_id,
482            underlying_price + spot_shock,
483            underlying_price,
484            percent_greeks,
485            index_instrument_id,
486            beta_weights,
487            0.0,
488            0.0,
489            0,
490            None,
491            0.0,
492            None,
493            None,
494            None,
495            None,
496        )?;
497        let mut greeks_data =
498            GreeksData::from_delta(instrument_id, delta, multiplier.as_f64(), ts_event);
499
500        if let Some(pos) = position {
501            greeks_data.pnl = (underlying_price + spot_shock) - pos.avg_px_open;
502            greeks_data.price = greeks_data.pnl;
503        }
504
505        Ok(greeks_data)
506    }
507
508    #[expect(clippy::too_many_arguments)]
509    fn calculate_option_greeks(
510        &self,
511        instrument: &InstrumentAny,
512        instrument_id: InstrumentId,
513        underlying_instrument_id: InstrumentId,
514        flat_interest_rate: f64,
515        flat_dividend_yield: Option<f64>,
516        use_cached_greeks: bool,
517        update_vol: bool,
518        cache_greeks: bool,
519        publish_greeks: bool,
520        ts_event: UnixNanos,
521        percent_greeks: bool,
522        index_instrument_id: Option<InstrumentId>,
523        beta_weights: Option<&HashMap<InstrumentId, f64>>,
524        vega_time_weight_base: Option<i32>,
525        vol_index_instrument_id: Option<InstrumentId>,
526        vol_beta_weights: Option<&HashMap<InstrumentId, f64>>,
527    ) -> anyhow::Result<GreeksData> {
528        if use_cached_greeks {
529            let cache = self.cache.borrow();
530            if let Some(cached_greeks) = cache.greeks(&instrument_id) {
531                return Ok(cached_greeks);
532            }
533        }
534
535        let utc_now_ns = if ts_event == UnixNanos::default() {
536            self.clock.borrow().timestamp_ns()
537        } else {
538            ts_event
539        };
540        let utc_now = utc_now_ns.to_datetime_utc();
541        let expiry_utc = instrument
542            .expiration_ns()
543            .map(|ns| ns.to_datetime_utc())
544            .unwrap_or_default();
545        let expiry_int = expiry_utc
546            .strftime("%Y%m%d")
547            .to_string()
548            .parse::<i32>()
549            .unwrap_or(0);
550        let raw_days = utc_now.duration_until(expiry_utc).as_hours() / 24;
551        let expiry_in_days = raw_days.max(1) as i32;
552        let expiry_in_years = expiry_in_days as f64 / 365.25;
553        let currency = instrument.quote_currency().code.to_string();
554
555        let cache = self.cache.borrow();
556        let yield_curve = cache.yield_curve(&currency);
557        let interest_rate = match yield_curve {
558            Some(yield_curve) => yield_curve(expiry_in_years),
559            None => flat_interest_rate,
560        };
561        let dividend_curve = cache.yield_curve(&underlying_instrument_id.to_string());
562        drop(cache);
563
564        let mut cost_of_carry = 0.0;
565
566        if let Some(dividend_curve) = dividend_curve {
567            cost_of_carry = interest_rate - dividend_curve(expiry_in_years);
568        } else if let Some(div_yield) = flat_dividend_yield {
569            cost_of_carry = interest_rate - div_yield;
570        }
571
572        let multiplier = instrument.multiplier();
573        let is_call = instrument.option_kind().unwrap_or(OptionKind::Call) == OptionKind::Call;
574        let strike = instrument.strike_price().unwrap_or_default().as_f64();
575        let option_price = self
576            .get_price(&instrument_id)
577            .ok_or_else(|| anyhow::anyhow!("No price available for {instrument_id}"))?;
578        let underlying_price = self.get_underlying_price(&underlying_instrument_id)?;
579
580        if let Some(vol_index_id) = vol_index_instrument_id {
581            self.get_price(&vol_index_id)
582                .ok_or_else(|| anyhow::anyhow!("No price available for {vol_index_id}"))?;
583        }
584        let greeks = if update_vol {
585            let cached_greeks = self.cache.borrow().greeks(&instrument_id);
586            match cached_greeks {
587                Some(cached_greeks) => refine_vol_and_greeks(
588                    underlying_price,
589                    interest_rate,
590                    cost_of_carry,
591                    is_call,
592                    strike,
593                    expiry_in_years,
594                    option_price,
595                    cached_greeks.vol,
596                ),
597                None => imply_vol_and_greeks(
598                    underlying_price,
599                    interest_rate,
600                    cost_of_carry,
601                    is_call,
602                    strike,
603                    expiry_in_years,
604                    option_price,
605                ),
606            }
607        } else {
608            imply_vol_and_greeks(
609                underlying_price,
610                interest_rate,
611                cost_of_carry,
612                is_call,
613                strike,
614                expiry_in_years,
615                option_price,
616            )
617        };
618        let (delta, gamma, vega) = self.modify_greeks(
619            greeks.delta,
620            greeks.gamma,
621            underlying_instrument_id,
622            underlying_price,
623            underlying_price,
624            percent_greeks,
625            index_instrument_id,
626            beta_weights,
627            greeks.vega,
628            greeks.vol,
629            expiry_in_days,
630            vega_time_weight_base,
631            greeks.vol,
632            vol_index_instrument_id,
633            vol_beta_weights,
634            None,
635            None,
636        )?;
637        let greeks_data = GreeksData::new(
638            utc_now_ns,
639            utc_now_ns,
640            instrument_id,
641            is_call,
642            strike,
643            expiry_int,
644            expiry_in_days,
645            expiry_in_years,
646            multiplier.as_f64(),
647            1.0,
648            underlying_price,
649            interest_rate,
650            cost_of_carry,
651            greeks.vol,
652            0.0,
653            greeks.price,
654            OptionGreekValues {
655                delta,
656                gamma,
657                vega,
658                theta: greeks.theta,
659                rho: 0.0,
660            },
661            greeks.itm_prob,
662        );
663
664        if cache_greeks {
665            let mut cache = self.cache.borrow_mut();
666            cache.add_greeks(greeks_data.clone()).unwrap_or_default();
667        }
668
669        if publish_greeks {
670            let topic = format!(
671                "data.GreeksData.instrument_id={}",
672                instrument_id.symbol.as_str()
673            )
674            .into();
675            msgbus::publish_greeks(topic, &greeks_data);
676        }
677
678        Ok(greeks_data)
679    }
680
681    #[expect(clippy::too_many_arguments)]
682    fn apply_option_greeks_shocks(
683        &self,
684        greeks_data: &GreeksData,
685        underlying_instrument_id: InstrumentId,
686        spot_shock: f64,
687        vol_shock: f64,
688        time_to_expiry_shock: f64,
689        percent_greeks: bool,
690        index_instrument_id: Option<InstrumentId>,
691        beta_weights: Option<&HashMap<InstrumentId, f64>>,
692        vega_time_weight_base: Option<i32>,
693        vol_index_instrument_id: Option<InstrumentId>,
694        vol_beta_weights: Option<&HashMap<InstrumentId, f64>>,
695    ) -> anyhow::Result<GreeksData> {
696        let underlying_price = greeks_data.underlying_price;
697        let shocked_underlying_price = underlying_price + spot_shock;
698        let shocked_vol = greeks_data.vol + vol_shock;
699        let shocked_time_to_expiry = greeks_data.expiry_in_years - time_to_expiry_shock;
700        let shocked_expiry_in_days = (shocked_time_to_expiry * 365.25) as i32;
701
702        let greeks = black_scholes_greeks(
703            shocked_underlying_price,
704            greeks_data.interest_rate,
705            greeks_data.cost_of_carry,
706            shocked_vol,
707            greeks_data.is_call,
708            greeks_data.strike,
709            shocked_time_to_expiry,
710        );
711        let (delta, gamma, vega) = self.modify_greeks(
712            greeks.delta,
713            greeks.gamma,
714            underlying_instrument_id,
715            shocked_underlying_price,
716            underlying_price,
717            percent_greeks,
718            index_instrument_id,
719            beta_weights,
720            greeks.vega,
721            shocked_vol,
722            shocked_expiry_in_days,
723            vega_time_weight_base,
724            greeks_data.vol,
725            vol_index_instrument_id,
726            vol_beta_weights,
727            None,
728            None,
729        )?;
730        Ok(GreeksData::new(
731            greeks_data.ts_event,
732            greeks_data.ts_event,
733            greeks_data.instrument_id,
734            greeks_data.is_call,
735            greeks_data.strike,
736            greeks_data.expiry,
737            shocked_expiry_in_days,
738            shocked_time_to_expiry,
739            greeks_data.multiplier,
740            greeks_data.quantity,
741            shocked_underlying_price,
742            greeks_data.interest_rate,
743            greeks_data.cost_of_carry,
744            shocked_vol,
745            0.0,
746            greeks.price,
747            OptionGreekValues {
748                delta,
749                gamma,
750                vega,
751                theta: greeks.theta,
752                rho: 0.0,
753            },
754            greeks.itm_prob,
755        ))
756    }
757
758    fn get_underlying_price(&self, underlying_instrument_id: &InstrumentId) -> anyhow::Result<f64> {
759        if let Some(underlying_price) = self.get_price(underlying_instrument_id) {
760            return Ok(underlying_price);
761        }
762
763        // Only fall back to cached futures spread when the underlying is a future
764        // (or absent from the cache, since the spread was explicitly cached).
765        let is_future_or_absent = {
766            let cache = self.cache.borrow();
767            cache
768                .instrument(underlying_instrument_id)
769                .is_none_or(|inst| inst.instrument_class() == InstrumentClass::Future)
770        };
771
772        if is_future_or_absent
773            && let Some(underlying_price) =
774                self.get_cached_futures_spread_price(*underlying_instrument_id)
775        {
776            return Ok(underlying_price.as_f64());
777        }
778
779        anyhow::bail!("No price available for {underlying_instrument_id}")
780    }
781
782    /// Modifies delta, gamma, and vega based on beta weighting and percentage calculations.
783    ///
784    /// The beta weighting of delta and gamma follows this equation linking the returns of a stock x to the ones of an index I:
785    /// (x - x0) / x0 = alpha + beta (I - I0) / I0 + epsilon
786    ///
787    /// beta can be obtained by linear regression of `stock_return` = alpha + beta `index_return`, it's equal to:
788    /// beta = Covariance(`stock_returns`, `index_returns`) / Variance(`index_returns`)
789    ///
790    /// Considering alpha == 0:
791    /// x = x0 + beta x0 / I0 (I-I0)
792    /// I = I0 + 1 / beta I0 / x0 (x - x0)
793    ///
794    /// These two last equations explain the beta weighting below, considering the price of an option is V(x) and delta and gamma
795    /// are the first and second derivatives respectively of V.
796    ///
797    /// Vega beta weighting follows the same change of variable with implied volatility and a volatility index.
798    ///
799    /// Also percent greeks assume a change of variable to percent returns by writing:
800    /// V(x = x0 * (1 + `stock_percent_return` / 100))
801    /// or V(I = I0 * (1 + `index_percent_return` / 100))
802    ///
803    /// # Errors
804    ///
805    /// Returns an error if `vol_index_instrument_id` is supplied and no explicit or cached
806    /// volatility index price is available.
807    #[expect(clippy::too_many_arguments)]
808    pub fn modify_greeks(
809        &self,
810        delta_input: f64,
811        gamma_input: f64,
812        underlying_instrument_id: InstrumentId,
813        underlying_price: f64,
814        unshocked_underlying_price: f64,
815        percent_greeks: bool,
816        index_instrument_id: Option<InstrumentId>,
817        beta_weights: Option<&HashMap<InstrumentId, f64>>,
818        vega_input: f64,
819        vol: f64,
820        expiry_in_days: i32,
821        vega_time_weight_base: Option<i32>,
822        unshocked_vol: f64,
823        vol_index_instrument_id: Option<InstrumentId>,
824        vol_beta_weights: Option<&HashMap<InstrumentId, f64>>,
825        index_price: Option<f64>,
826        vol_index_price: Option<f64>,
827    ) -> anyhow::Result<(f64, f64, f64)> {
828        let mut delta = delta_input;
829        let mut gamma = gamma_input;
830        let mut vega = vega_input;
831
832        let mut used_index_price = index_price
833            .or_else(|| index_instrument_id.and_then(|index_id| self.get_price(&index_id)));
834        let mut used_index_vol = vol_index_price;
835        if used_index_vol.is_none()
836            && let Some(vol_index_id) = vol_index_instrument_id
837        {
838            used_index_vol = Some(
839                self.get_price(&vol_index_id)
840                    .ok_or_else(|| anyhow::anyhow!("No price available for {vol_index_id}"))?,
841            );
842        }
843
844        if used_index_price.is_some() {
845            let mut beta = 1.0;
846
847            if let Some(weights) = beta_weights
848                && let Some(&weight) = weights.get(&underlying_instrument_id)
849            {
850                beta = weight;
851            }
852
853            if let Some(ref mut idx_price) = used_index_price {
854                #[expect(clippy::float_cmp, reason = "exact-equality baseline check")]
855                if underlying_price != unshocked_underlying_price {
856                    *idx_price += 1.0 / beta
857                        * (*idx_price / unshocked_underlying_price)
858                        * (underlying_price - unshocked_underlying_price);
859                }
860
861                let delta_multiplier = beta * underlying_price / *idx_price;
862                delta *= delta_multiplier;
863                gamma *= delta_multiplier.powi(2);
864            }
865        }
866
867        if used_index_vol.is_some() {
868            let mut vega_beta = 1.0;
869            let used_vol = if unshocked_vol == 0.0 {
870                vol
871            } else {
872                unshocked_vol
873            };
874
875            if let Some(weights) = vol_beta_weights
876                && let Some(&weight) = weights.get(&underlying_instrument_id)
877            {
878                vega_beta = weight;
879            }
880
881            if let Some(ref mut idx_vol) = used_index_vol {
882                *idx_vol *= 0.01;
883
884                #[expect(clippy::float_cmp, reason = "exact-equality baseline check")]
885                if vol != used_vol && used_vol != 0.0 {
886                    *idx_vol += 1.0 / vega_beta * (*idx_vol / used_vol) * (vol - used_vol);
887                }
888
889                if *idx_vol != 0.0 {
890                    vega *= vega_beta * vol / *idx_vol;
891                }
892            }
893        }
894
895        if percent_greeks {
896            if let Some(idx_price) = used_index_price {
897                delta *= idx_price / 100.0;
898                gamma *= (idx_price / 100.0).powi(2);
899            } else {
900                delta *= underlying_price / 100.0;
901                gamma *= (underlying_price / 100.0).powi(2);
902            }
903
904            if let Some(idx_vol) = used_index_vol {
905                vega *= idx_vol / 100.0;
906            } else {
907                vega *= vol / 100.0;
908            }
909        }
910
911        // Apply time weighting to vega if vega_time_weight_base is provided
912        if let Some(time_base) = vega_time_weight_base
913            && expiry_in_days > 0
914        {
915            let time_weight = (time_base as f64 / expiry_in_days as f64).sqrt();
916            vega *= time_weight;
917        }
918
919        Ok((delta, gamma, vega))
920    }
921
922    /// Calculates the portfolio Greeks for a given set of positions.
923    ///
924    /// Aggregates the Greeks data for all open positions that match the specified criteria.
925    ///
926    /// Additional features:
927    /// - Apply shocks to the spot value of an instrument's underlying, implied volatility, or time to expiry.
928    /// - Compute percent greeks.
929    /// - Compute beta-weighted delta, gamma, and vega with respect to an index.
930    ///
931    /// # Errors
932    ///
933    /// Returns an error if any underlying greeks calculation fails.
934    #[expect(clippy::too_many_arguments)]
935    pub fn portfolio_greeks(
936        &self,
937        underlyings: Option<&[String]>,
938        venue: Option<Venue>,
939        instrument_id: Option<InstrumentId>,
940        strategy_id: Option<StrategyId>,
941        side: Option<PositionSide>,
942        flat_interest_rate: Option<f64>,
943        flat_dividend_yield: Option<f64>,
944        spot_shock: Option<f64>,
945        vol_shock: Option<f64>,
946        time_to_expiry_shock: Option<f64>,
947        use_cached_greeks: Option<bool>,
948        update_vol: Option<bool>,
949        cache_greeks: Option<bool>,
950        publish_greeks: Option<bool>,
951        percent_greeks: Option<bool>,
952        index_instrument_id: Option<InstrumentId>,
953        beta_weights: Option<&HashMap<InstrumentId, f64>>,
954        greeks_filter: Option<&GreeksFilter>,
955        vega_time_weight_base: Option<i32>,
956        vol_index_instrument_id: Option<InstrumentId>,
957        vol_beta_weights: Option<&HashMap<InstrumentId, f64>>,
958    ) -> anyhow::Result<PortfolioGreeks> {
959        let ts_event = self.clock.borrow().timestamp_ns();
960        let mut portfolio_greeks =
961            PortfolioGreeks::new(ts_event, ts_event, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0);
962
963        // Set default values
964        let flat_interest_rate = flat_interest_rate.unwrap_or(0.0425);
965        let spot_shock = spot_shock.unwrap_or(0.0);
966        let vol_shock = vol_shock.unwrap_or(0.0);
967        let time_to_expiry_shock = time_to_expiry_shock.unwrap_or(0.0);
968        let use_cached_greeks = use_cached_greeks.unwrap_or(false);
969        let update_vol = update_vol.unwrap_or(false);
970        let cache_greeks = cache_greeks.unwrap_or(false);
971        let publish_greeks = publish_greeks.unwrap_or(false);
972        let percent_greeks = percent_greeks.unwrap_or(false);
973        let cache = self.cache.borrow();
974        let open_positions = cache.positions_open(
975            venue.as_ref(),
976            instrument_id.as_ref(),
977            strategy_id.as_ref(),
978            None, // account_id
979            side,
980        );
981        let open_positions: Vec<Position> =
982            open_positions.iter().map(PositionRef::cloned).collect();
983
984        for position in open_positions {
985            let position_instrument_id = position.instrument_id;
986
987            if let Some(underlyings_list) = underlyings {
988                let mut skip_position = true;
989
990                for underlying in underlyings_list {
991                    if position_instrument_id
992                        .symbol
993                        .as_str()
994                        .starts_with(underlying)
995                    {
996                        skip_position = false;
997                        break;
998                    }
999                }
1000
1001                if skip_position {
1002                    continue;
1003                }
1004            }
1005
1006            let quantity = position.signed_qty;
1007            let instrument_greeks = self.instrument_greeks(
1008                position_instrument_id,
1009                Some(flat_interest_rate),
1010                flat_dividend_yield,
1011                Some(spot_shock),
1012                Some(vol_shock),
1013                Some(time_to_expiry_shock),
1014                Some(use_cached_greeks),
1015                Some(update_vol),
1016                Some(cache_greeks),
1017                Some(publish_greeks),
1018                Some(ts_event),
1019                Some(position),
1020                Some(percent_greeks),
1021                index_instrument_id,
1022                beta_weights,
1023                vega_time_weight_base,
1024                vol_index_instrument_id,
1025                vol_beta_weights,
1026            )?;
1027            let position_greeks = quantity * &instrument_greeks;
1028
1029            // Apply greeks filter if provided
1030            if greeks_filter.is_none_or(|filter| filter(&position_greeks)) {
1031                portfolio_greeks = portfolio_greeks + PortfolioGreeks::from(position_greeks);
1032            }
1033        }
1034
1035        Ok(portfolio_greeks)
1036    }
1037
1038    /// Cache a futures spread derived from a call/put pair against a reference future.
1039    ///
1040    /// # Errors
1041    ///
1042    /// Returns an error if instruments or prices are missing or inconsistent.
1043    pub fn cache_futures_spread(
1044        &self,
1045        call_instrument_id: InstrumentId,
1046        put_instrument_id: InstrumentId,
1047        futures_instrument_id: InstrumentId,
1048    ) -> anyhow::Result<Price> {
1049        let cache = self.cache.borrow();
1050        let call_instrument = cache.instrument(&call_instrument_id).cloned();
1051        let put_instrument = cache.instrument(&put_instrument_id).cloned();
1052        let reference_future_instrument = cache.instrument(&futures_instrument_id).cloned();
1053        drop(cache);
1054
1055        let Some(call_instrument) = call_instrument else {
1056            anyhow::bail!(
1057                "Cannot cache futures spread: missing option instrument {call_instrument_id}"
1058            );
1059        };
1060        let Some(put_instrument) = put_instrument else {
1061            anyhow::bail!(
1062                "Cannot cache futures spread: missing option instrument {put_instrument_id}"
1063            );
1064        };
1065        let Some(reference_future_instrument) = reference_future_instrument else {
1066            anyhow::bail!(
1067                "Cannot cache futures spread: no reference futures instrument for {futures_instrument_id}"
1068            );
1069        };
1070
1071        if call_instrument.instrument_class() != InstrumentClass::Option
1072            || put_instrument.instrument_class() != InstrumentClass::Option
1073        {
1074            anyhow::bail!(
1075                "Cannot cache futures spread: non-option instruments provided call_instrument_id={call_instrument_id} put_instrument_id={put_instrument_id}"
1076            );
1077        }
1078
1079        if call_instrument.option_kind() != Some(OptionKind::Call)
1080            || put_instrument.option_kind() != Some(OptionKind::Put)
1081        {
1082            anyhow::bail!(
1083                "Cannot cache futures spread: expected call/put pair call_instrument_id={call_instrument_id} put_instrument_id={put_instrument_id}"
1084            );
1085        }
1086
1087        let Some(call_underlying) = call_instrument.underlying() else {
1088            anyhow::bail!(
1089                "Cannot cache futures spread: missing call underlying for {call_instrument_id}"
1090            );
1091        };
1092        let Some(put_underlying) = put_instrument.underlying() else {
1093            anyhow::bail!(
1094                "Cannot cache futures spread: missing put underlying for {put_instrument_id}"
1095            );
1096        };
1097
1098        if call_underlying != put_underlying {
1099            anyhow::bail!(
1100                "Cannot cache futures spread: option underlyings differ call_instrument_id={call_instrument_id} put_instrument_id={put_instrument_id}"
1101            );
1102        }
1103
1104        if call_instrument.strike_price() != put_instrument.strike_price() {
1105            anyhow::bail!(
1106                "Cannot cache futures spread: strike prices differ call_instrument_id={call_instrument_id} put_instrument_id={put_instrument_id}"
1107            );
1108        }
1109
1110        if call_instrument.expiration_ns() != put_instrument.expiration_ns() {
1111            anyhow::bail!(
1112                "Cannot cache futures spread: expiration dates differ call_instrument_id={call_instrument_id} put_instrument_id={put_instrument_id}"
1113            );
1114        }
1115
1116        let reference_future_price = self.get_price_object(&futures_instrument_id).ok_or_else(|| {
1117            anyhow::anyhow!(
1118                "Cannot cache futures spread: no reference futures price for {futures_instrument_id}"
1119            )
1120        })?;
1121        let call_price = self.get_price(&call_instrument_id).ok_or_else(|| {
1122            anyhow::anyhow!(
1123                "Cannot cache futures spread: missing option price for {call_instrument_id}"
1124            )
1125        })?;
1126        let put_price = self.get_price(&put_instrument_id).ok_or_else(|| {
1127            anyhow::anyhow!(
1128                "Cannot cache futures spread: missing option price for {put_instrument_id}"
1129            )
1130        })?;
1131
1132        let underlying_instrument_id =
1133            InstrumentId::from(format!("{call_underlying}.{}", call_instrument_id.venue));
1134
1135        // Reject if the underlying is present in cache but is not a future
1136        {
1137            let cache = self.cache.borrow();
1138            if let Some(underlying) = cache.instrument(&underlying_instrument_id)
1139                && underlying.instrument_class() != InstrumentClass::Future
1140            {
1141                anyhow::bail!(
1142                    "Cannot cache futures spread: underlying {underlying_instrument_id} is not a futures contract"
1143                );
1144            }
1145        }
1146
1147        let implied_future_price =
1148            self.calculate_implied_future_price(&call_instrument, call_price, put_price);
1149        let spread = implied_future_price - reference_future_price.as_f64();
1150        let spread_price = reference_future_instrument.make_price(spread);
1151
1152        self.cached_futures_spreads.borrow_mut().insert(
1153            underlying_instrument_id,
1154            (futures_instrument_id, spread_price),
1155        );
1156
1157        Ok(reference_future_price + spread_price)
1158    }
1159
1160    fn calculate_implied_future_price(
1161        &self,
1162        call_instrument: &InstrumentAny,
1163        call_price: f64,
1164        put_price: f64,
1165    ) -> f64 {
1166        let expiry_utc = call_instrument
1167            .expiration_ns()
1168            .map(|ns| ns.to_datetime_utc())
1169            .unwrap_or_default();
1170        let now = self.clock.borrow().timestamp_ns().to_datetime_utc();
1171        let expiry_in_days = (now.duration_until(expiry_utc).as_hours() / 24).max(1) as i32;
1172        let expiry_in_years = expiry_in_days as f64 / 365.25;
1173        let currency = call_instrument.quote_currency().code.to_string();
1174        let interest_rate = self
1175            .cache
1176            .borrow()
1177            .yield_curve(&currency)
1178            .map_or(0.0425, |yield_curve| yield_curve(expiry_in_years));
1179        let strike = call_instrument.strike_price().unwrap_or_default().as_f64();
1180
1181        strike + (interest_rate * expiry_in_years).exp() * (call_price - put_price)
1182    }
1183
1184    /// Resolve a cached futures spread price for an underlying future.
1185    #[must_use]
1186    pub fn get_cached_futures_spread_price(
1187        &self,
1188        underlying_instrument_id: InstrumentId,
1189    ) -> Option<Price> {
1190        let (futures_instrument_id, spread) = self
1191            .cached_futures_spreads
1192            .borrow()
1193            .get(&underlying_instrument_id)
1194            .copied()?;
1195        let reference_future_price = self.get_price_object(&futures_instrument_id)?;
1196
1197        Some(reference_future_price + spread)
1198    }
1199
1200    fn get_price_object(&self, instrument_id: &InstrumentId) -> Option<Price> {
1201        let cache = self.cache.borrow();
1202        let price = cache
1203            .price(instrument_id, PriceType::Mid)
1204            .or_else(|| cache.price(instrument_id, PriceType::Last));
1205
1206        // For index-class futures, prefer tradable quotes over the published index
1207        // price since index price is the spot level and may diverge from futures basis.
1208        // For true index instruments (non-futures), prefer the published index price.
1209        if let Some(instrument) = cache.instrument(instrument_id)
1210            && instrument.asset_class() == AssetClass::Index
1211        {
1212            if instrument.instrument_class() == InstrumentClass::Future && price.is_some() {
1213                return price;
1214            }
1215
1216            if let Some(index_price) = cache.index_price(instrument_id) {
1217                return Some(index_price.value);
1218            }
1219        }
1220
1221        price
1222    }
1223
1224    fn get_price(&self, instrument_id: &InstrumentId) -> Option<f64> {
1225        self.get_price_object(instrument_id)
1226            .map(|price| price.as_f64())
1227    }
1228
1229    /// Subscribes to Greeks data for a given underlying instrument.
1230    ///
1231    /// Useful for reading greeks from a backtesting data catalog and caching them for later use.
1232    pub fn subscribe_greeks<F>(&self, underlying: &str, handler: Option<F>)
1233    where
1234        F: Fn(&GreeksData) + 'static,
1235    {
1236        let pattern = format!("data.GreeksData.instrument_id={underlying}*").into();
1237
1238        if let Some(custom_handler) = handler {
1239            let typed_handler = TypedHandler::from(custom_handler);
1240            msgbus::subscribe_greeks(pattern, typed_handler, None);
1241        } else {
1242            let cache_ref = self.cache.clone();
1243            let typed_handler = TypedHandler::from(move |greeks: &GreeksData| {
1244                let mut cache = cache_ref.borrow_mut();
1245                cache.add_greeks(greeks.clone()).unwrap_or_default();
1246            });
1247            msgbus::subscribe_greeks(pattern, typed_handler, None);
1248        }
1249    }
1250}
1251
1252#[cfg(test)]
1253mod tests {
1254    use std::{cell::RefCell, collections::HashMap, rc::Rc};
1255
1256    use jiff::{Timestamp, civil::Date, tz::Offset};
1257    use nautilus_model::{
1258        data::{IndexPriceUpdate, QuoteTick, YieldCurveData},
1259        enums::{AssetClass, OmsType, OptionKind, OrderSide, PositionSide},
1260        events::order::spec::OrderFilledSpec,
1261        identifiers::{
1262            ClientOrderId, InstrumentId, PositionId, StrategyId, Symbol, TradeId, Venue,
1263        },
1264        instruments::{Equity, FuturesContract, OptionContract, any::InstrumentAny},
1265        types::{Currency, Price, Quantity},
1266    };
1267    use rstest::rstest;
1268    use ustr::Ustr;
1269
1270    use super::*;
1271    use crate::{cache::Cache, clock::VirtualClock};
1272
1273    fn utc_timestamp(year: i16, month: i8, day: i8, hour: i8, minute: i8, second: i8) -> Timestamp {
1274        Offset::UTC
1275            .to_timestamp(
1276                Date::new(year, month, day)
1277                    .unwrap()
1278                    .at(hour, minute, second, 0),
1279            )
1280            .unwrap()
1281    }
1282
1283    fn create_test_calculator() -> GreeksCalculator {
1284        let cache = Rc::new(RefCell::new(Cache::new(None, None)));
1285        let clock = Rc::new(RefCell::new(VirtualClock::new()));
1286        GreeksCalculator::new(cache, clock)
1287    }
1288
1289    #[rstest]
1290    fn test_greeks_calculator_debug() {
1291        let calculator = create_test_calculator();
1292
1293        let debug_str = format!("{calculator:?}");
1294
1295        assert!(debug_str.starts_with("GreeksCalculator {"), "{debug_str}");
1296        assert!(debug_str.contains("cache:"), "{debug_str}");
1297        assert!(debug_str.contains("clock:"), "{debug_str}");
1298        assert!(
1299            debug_str.contains("cached_futures_spreads: RefCell { value: {} }"),
1300            "{debug_str}"
1301        );
1302    }
1303
1304    #[rstest]
1305    fn test_instrument_greeks_params_builder_default() {
1306        let instrument_id = InstrumentId::from("AAPL.NASDAQ");
1307
1308        let params = InstrumentGreeksParams::builder()
1309            .instrument_id(instrument_id)
1310            .build();
1311
1312        assert_eq!(params.instrument_id, instrument_id);
1313        assert_eq!(params.flat_interest_rate, 0.0425);
1314        assert_eq!(params.flat_dividend_yield, None);
1315        assert_eq!(params.spot_shock, 0.0);
1316        assert_eq!(params.vol_shock, 0.0);
1317        assert_eq!(params.time_to_expiry_shock, 0.0);
1318        assert!(!params.use_cached_greeks);
1319        assert!(!params.cache_greeks);
1320        assert!(!params.publish_greeks);
1321        assert_eq!(params.ts_event, None);
1322        assert_eq!(params.position, None);
1323        assert!(!params.percent_greeks);
1324        assert_eq!(params.index_instrument_id, None);
1325        assert_eq!(params.beta_weights, None);
1326        assert_eq!(params.vol_index_instrument_id, None);
1327        assert_eq!(params.vol_beta_weights, None);
1328    }
1329
1330    #[rstest]
1331    fn test_instrument_greeks_params_builder_custom_values() {
1332        let instrument_id = InstrumentId::from("AAPL.NASDAQ");
1333        let index_id = InstrumentId::from("SPY.NASDAQ");
1334        let vol_index_id = InstrumentId::from("VIX.XCBF");
1335        let mut beta_weights = HashMap::new();
1336        beta_weights.insert(instrument_id, 1.2);
1337        let mut vol_beta_weights = HashMap::new();
1338        vol_beta_weights.insert(instrument_id, 0.8);
1339
1340        let params = InstrumentGreeksParams::builder()
1341            .instrument_id(instrument_id)
1342            .flat_interest_rate(0.05)
1343            .flat_dividend_yield(0.02)
1344            .spot_shock(0.01)
1345            .vol_shock(0.05)
1346            .time_to_expiry_shock(0.1)
1347            .use_cached_greeks(true)
1348            .cache_greeks(true)
1349            .publish_greeks(true)
1350            .percent_greeks(true)
1351            .index_instrument_id(index_id)
1352            .beta_weights(beta_weights.clone())
1353            .vol_index_instrument_id(vol_index_id)
1354            .vol_beta_weights(vol_beta_weights.clone())
1355            .build();
1356
1357        assert_eq!(params.instrument_id, instrument_id);
1358        assert_eq!(params.flat_interest_rate, 0.05);
1359        assert_eq!(params.flat_dividend_yield, Some(0.02));
1360        assert_eq!(params.spot_shock, 0.01);
1361        assert_eq!(params.vol_shock, 0.05);
1362        assert_eq!(params.time_to_expiry_shock, 0.1);
1363        assert!(params.use_cached_greeks);
1364        assert!(params.cache_greeks);
1365        assert!(params.publish_greeks);
1366        assert!(params.percent_greeks);
1367        assert_eq!(params.index_instrument_id, Some(index_id));
1368        assert_eq!(params.beta_weights, Some(beta_weights));
1369        assert_eq!(params.vol_index_instrument_id, Some(vol_index_id));
1370        assert_eq!(params.vol_beta_weights, Some(vol_beta_weights));
1371    }
1372
1373    #[rstest]
1374    fn test_instrument_greeks_params_debug() {
1375        let instrument_id = InstrumentId::from("AAPL.NASDAQ");
1376
1377        let params = InstrumentGreeksParams::builder()
1378            .instrument_id(instrument_id)
1379            .build();
1380
1381        let debug_str = format!("{params:?}");
1382        assert!(debug_str.contains("InstrumentGreeksParams"));
1383        assert!(debug_str.contains("AAPL.NASDAQ"));
1384    }
1385
1386    #[rstest]
1387    fn test_portfolio_greeks_params_builder_default() {
1388        let params = PortfolioGreeksParams::builder().build();
1389
1390        assert_eq!(params.underlyings, None);
1391        assert_eq!(params.venue, None);
1392        assert_eq!(params.instrument_id, None);
1393        assert_eq!(params.strategy_id, None);
1394        assert_eq!(params.side, None);
1395        assert_eq!(params.flat_interest_rate, 0.0425);
1396        assert_eq!(params.flat_dividend_yield, None);
1397        assert_eq!(params.spot_shock, 0.0);
1398        assert_eq!(params.vol_shock, 0.0);
1399        assert_eq!(params.time_to_expiry_shock, 0.0);
1400        assert!(!params.use_cached_greeks);
1401        assert!(!params.cache_greeks);
1402        assert!(!params.publish_greeks);
1403        assert!(!params.percent_greeks);
1404        assert_eq!(params.index_instrument_id, None);
1405        assert_eq!(params.beta_weights, None);
1406        assert_eq!(params.vol_index_instrument_id, None);
1407        assert_eq!(params.vol_beta_weights, None);
1408    }
1409
1410    #[rstest]
1411    fn test_portfolio_greeks_params_builder_custom_values() {
1412        let venue = Venue::from("NASDAQ");
1413        let instrument_id = InstrumentId::from("AAPL.NASDAQ");
1414        let strategy_id = StrategyId::from("test-strategy");
1415        let index_id = InstrumentId::from("SPY.NASDAQ");
1416        let vol_index_id = InstrumentId::from("VIX.XCBF");
1417        let underlyings = vec!["AAPL".to_string(), "MSFT".to_string()];
1418        let mut beta_weights = HashMap::new();
1419        beta_weights.insert(instrument_id, 1.2);
1420        let mut vol_beta_weights = HashMap::new();
1421        vol_beta_weights.insert(instrument_id, 0.8);
1422
1423        let params = PortfolioGreeksParams::builder()
1424            .underlyings(underlyings.clone())
1425            .venue(venue)
1426            .instrument_id(instrument_id)
1427            .strategy_id(strategy_id)
1428            .side(PositionSide::Long)
1429            .flat_interest_rate(0.05)
1430            .flat_dividend_yield(0.02)
1431            .spot_shock(0.01)
1432            .vol_shock(0.05)
1433            .time_to_expiry_shock(0.1)
1434            .use_cached_greeks(true)
1435            .cache_greeks(true)
1436            .publish_greeks(true)
1437            .percent_greeks(true)
1438            .index_instrument_id(index_id)
1439            .beta_weights(beta_weights.clone())
1440            .vol_index_instrument_id(vol_index_id)
1441            .vol_beta_weights(vol_beta_weights.clone())
1442            .build();
1443
1444        assert_eq!(params.underlyings, Some(underlyings));
1445        assert_eq!(params.venue, Some(venue));
1446        assert_eq!(params.instrument_id, Some(instrument_id));
1447        assert_eq!(params.strategy_id, Some(strategy_id));
1448        assert_eq!(params.side, Some(PositionSide::Long));
1449        assert_eq!(params.flat_interest_rate, 0.05);
1450        assert_eq!(params.flat_dividend_yield, Some(0.02));
1451        assert_eq!(params.spot_shock, 0.01);
1452        assert_eq!(params.vol_shock, 0.05);
1453        assert_eq!(params.time_to_expiry_shock, 0.1);
1454        assert!(params.use_cached_greeks);
1455        assert!(params.cache_greeks);
1456        assert!(params.publish_greeks);
1457        assert!(params.percent_greeks);
1458        assert_eq!(params.index_instrument_id, Some(index_id));
1459        assert_eq!(params.beta_weights, Some(beta_weights));
1460        assert_eq!(params.vol_index_instrument_id, Some(vol_index_id));
1461        assert_eq!(params.vol_beta_weights, Some(vol_beta_weights));
1462    }
1463
1464    #[rstest]
1465    fn test_portfolio_greeks_params_debug() {
1466        let venue = Venue::from("NASDAQ");
1467
1468        let params = PortfolioGreeksParams::builder().venue(venue).build();
1469
1470        let debug_str = format!("{params:?}");
1471        assert!(debug_str.contains("PortfolioGreeksParams"));
1472        assert!(debug_str.contains("NASDAQ"));
1473    }
1474
1475    #[rstest]
1476    fn test_portfolio_greeks_params_builder_fluent_api() {
1477        let instrument_id = InstrumentId::from("AAPL.NASDAQ");
1478
1479        let params = PortfolioGreeksParams::builder()
1480            .instrument_id(instrument_id)
1481            .flat_interest_rate(0.05)
1482            .spot_shock(0.01)
1483            .percent_greeks(true)
1484            .build();
1485
1486        assert_eq!(params.instrument_id, Some(instrument_id));
1487        assert_eq!(params.flat_interest_rate, 0.05);
1488        assert_eq!(params.spot_shock, 0.01);
1489        assert!(params.percent_greeks);
1490    }
1491
1492    #[rstest]
1493    fn test_instrument_greeks_params_builder_fluent_chaining() {
1494        let instrument_id = InstrumentId::from("TSLA.NASDAQ");
1495
1496        // Test fluent API chaining
1497        let params = InstrumentGreeksParams::builder()
1498            .instrument_id(instrument_id)
1499            .flat_interest_rate(0.03)
1500            .spot_shock(0.02)
1501            .vol_shock(0.1)
1502            .use_cached_greeks(true)
1503            .percent_greeks(true)
1504            .build();
1505
1506        assert_eq!(params.instrument_id, instrument_id);
1507        assert_eq!(params.flat_interest_rate, 0.03);
1508        assert_eq!(params.spot_shock, 0.02);
1509        assert_eq!(params.vol_shock, 0.1);
1510        assert!(params.use_cached_greeks);
1511        assert!(params.percent_greeks);
1512    }
1513
1514    #[rstest]
1515    fn test_portfolio_greeks_params_builder_with_underlyings() {
1516        let underlyings = vec!["AAPL".to_string(), "MSFT".to_string(), "GOOGL".to_string()];
1517
1518        let params = PortfolioGreeksParams::builder()
1519            .underlyings(underlyings.clone())
1520            .flat_interest_rate(0.04)
1521            .build();
1522
1523        assert_eq!(params.underlyings, Some(underlyings));
1524        assert_eq!(params.flat_interest_rate, 0.04);
1525    }
1526
1527    #[rstest]
1528    fn test_builders_with_empty_beta_weights() {
1529        let instrument_id = InstrumentId::from("NVDA.NASDAQ");
1530        let empty_beta_weights = HashMap::new();
1531
1532        let instrument_params = InstrumentGreeksParams::builder()
1533            .instrument_id(instrument_id)
1534            .beta_weights(empty_beta_weights.clone())
1535            .vol_beta_weights(empty_beta_weights.clone())
1536            .build();
1537
1538        let portfolio_params = PortfolioGreeksParams::builder()
1539            .beta_weights(empty_beta_weights.clone())
1540            .vol_beta_weights(empty_beta_weights.clone())
1541            .build();
1542
1543        assert_eq!(
1544            instrument_params.beta_weights,
1545            Some(empty_beta_weights.clone())
1546        );
1547        assert_eq!(portfolio_params.beta_weights, Some(empty_beta_weights));
1548        assert_eq!(instrument_params.vol_beta_weights, Some(HashMap::new()));
1549        assert_eq!(portfolio_params.vol_beta_weights, Some(HashMap::new()));
1550    }
1551
1552    #[rstest]
1553    fn test_builders_with_all_shocks() {
1554        let instrument_id = InstrumentId::from("AMD.NASDAQ");
1555
1556        let instrument_params = InstrumentGreeksParams::builder()
1557            .instrument_id(instrument_id)
1558            .spot_shock(0.05)
1559            .vol_shock(0.1)
1560            .time_to_expiry_shock(0.01)
1561            .build();
1562
1563        let portfolio_params = PortfolioGreeksParams::builder()
1564            .spot_shock(0.05)
1565            .vol_shock(0.1)
1566            .time_to_expiry_shock(0.01)
1567            .build();
1568
1569        assert_eq!(instrument_params.spot_shock, 0.05);
1570        assert_eq!(instrument_params.vol_shock, 0.1);
1571        assert_eq!(instrument_params.time_to_expiry_shock, 0.01);
1572
1573        assert_eq!(portfolio_params.spot_shock, 0.05);
1574        assert_eq!(portfolio_params.vol_shock, 0.1);
1575        assert_eq!(portfolio_params.time_to_expiry_shock, 0.01);
1576    }
1577
1578    #[rstest]
1579    fn test_builders_with_all_boolean_flags() {
1580        let instrument_id = InstrumentId::from("META.NASDAQ");
1581
1582        let instrument_params = InstrumentGreeksParams::builder()
1583            .instrument_id(instrument_id)
1584            .use_cached_greeks(true)
1585            .cache_greeks(true)
1586            .publish_greeks(true)
1587            .percent_greeks(true)
1588            .build();
1589
1590        let portfolio_params = PortfolioGreeksParams::builder()
1591            .use_cached_greeks(true)
1592            .cache_greeks(true)
1593            .publish_greeks(true)
1594            .percent_greeks(true)
1595            .build();
1596
1597        assert!(instrument_params.use_cached_greeks);
1598        assert!(instrument_params.cache_greeks);
1599        assert!(instrument_params.publish_greeks);
1600        assert!(instrument_params.percent_greeks);
1601
1602        assert!(portfolio_params.use_cached_greeks);
1603        assert!(portfolio_params.cache_greeks);
1604        assert!(portfolio_params.publish_greeks);
1605        assert!(portfolio_params.percent_greeks);
1606    }
1607
1608    #[rstest]
1609    fn test_greeks_filter_callback_function() {
1610        // Test function pointer filter
1611        fn filter_positive_delta(data: &GreeksData) -> bool {
1612            data.delta > 0.0
1613        }
1614
1615        let filter = GreeksFilterCallback::from_fn(filter_positive_delta);
1616
1617        // Create test data
1618        let greeks_data = GreeksData::from_delta(
1619            InstrumentId::from("TEST.NASDAQ"),
1620            0.5,
1621            1.0,
1622            UnixNanos::default(),
1623        );
1624
1625        assert!(filter.call(&greeks_data));
1626
1627        // Test debug formatting
1628        let debug_str = format!("{filter:?}");
1629        assert!(debug_str.contains("GreeksFilterCallback::Function"));
1630    }
1631
1632    #[rstest]
1633    fn test_greeks_filter_callback_closure() {
1634        // Test closure filter that captures a variable
1635        let min_delta = 0.3;
1636        let filter =
1637            GreeksFilterCallback::from_closure(move |data: &GreeksData| data.delta > min_delta);
1638
1639        // Create test data
1640        let greeks_data = GreeksData::from_delta(
1641            InstrumentId::from("TEST.NASDAQ"),
1642            0.5,
1643            1.0,
1644            UnixNanos::default(),
1645        );
1646
1647        assert!(filter.call(&greeks_data));
1648
1649        // Test debug formatting
1650        let debug_str = format!("{filter:?}");
1651        assert!(debug_str.contains("GreeksFilterCallback::Closure"));
1652    }
1653
1654    #[rstest]
1655    fn test_greeks_filter_callback_clone() {
1656        fn filter_fn(data: &GreeksData) -> bool {
1657            data.delta > 0.0
1658        }
1659
1660        let filter1 = GreeksFilterCallback::from_fn(filter_fn);
1661        let filter2 = filter1.clone();
1662
1663        let greeks_data = GreeksData::from_delta(
1664            InstrumentId::from("TEST.NASDAQ"),
1665            0.5,
1666            1.0,
1667            UnixNanos::default(),
1668        );
1669
1670        assert!(filter1.call(&greeks_data));
1671        assert!(filter2.call(&greeks_data));
1672    }
1673
1674    #[rstest]
1675    fn test_portfolio_greeks_params_with_filter() {
1676        fn filter_high_delta(data: &GreeksData) -> bool {
1677            data.delta.abs() > 0.1
1678        }
1679
1680        let filter = GreeksFilterCallback::from_fn(filter_high_delta);
1681
1682        let params = PortfolioGreeksParams::builder()
1683            .greeks_filter(filter)
1684            .flat_interest_rate(0.05)
1685            .build();
1686
1687        assert!(params.greeks_filter.is_some());
1688        assert_eq!(params.flat_interest_rate, 0.05);
1689
1690        // Test that the filter can be called
1691        let greeks_data = GreeksData::from_delta(
1692            InstrumentId::from("TEST.NASDAQ"),
1693            0.5,
1694            1.0,
1695            UnixNanos::default(),
1696        );
1697
1698        let filter_ref = params.greeks_filter.as_ref().unwrap();
1699        assert!(filter_ref.call(&greeks_data));
1700    }
1701
1702    #[rstest]
1703    fn test_portfolio_greeks_params_with_closure_filter() {
1704        let min_gamma = 0.01;
1705        let filter =
1706            GreeksFilterCallback::from_closure(move |data: &GreeksData| data.gamma > min_gamma);
1707
1708        let params = PortfolioGreeksParams::builder()
1709            .greeks_filter(filter)
1710            .build();
1711
1712        assert!(params.greeks_filter.is_some());
1713
1714        // Test debug formatting includes the filter
1715        let debug_str = format!("{params:?}");
1716        assert!(debug_str.contains("greeks_filter"));
1717    }
1718
1719    #[rstest]
1720    fn test_greeks_filter_to_greeks_filter_conversion() {
1721        fn filter_fn(data: &GreeksData) -> bool {
1722            data.delta > 0.0
1723        }
1724
1725        let callback = GreeksFilterCallback::from_fn(filter_fn);
1726        let greeks_filter = callback.to_greeks_filter();
1727
1728        let greeks_data = GreeksData::from_delta(
1729            InstrumentId::from("TEST.NASDAQ"),
1730            0.5,
1731            1.0,
1732            UnixNanos::default(),
1733        );
1734
1735        assert!(greeks_filter(&greeks_data));
1736    }
1737
1738    fn option_with_expiration(instrument_id: &str, expiration_ns: UnixNanos) -> OptionContract {
1739        let activation_ns = UnixNanos::from(utc_timestamp(2021, 9, 17, 0, 0, 0));
1740        OptionContract::builder()
1741            .instrument_id(InstrumentId::from(instrument_id))
1742            .raw_symbol(Symbol::from("AAPL211217C00150000"))
1743            .asset_class(AssetClass::Equity)
1744            .exchange(Ustr::from("GMNI"))
1745            .underlying(Ustr::from("AAPL"))
1746            .option_kind(OptionKind::Call)
1747            .strike_price(Price::from("149.0"))
1748            .currency(Currency::from("USD"))
1749            .activation_ns(activation_ns)
1750            .expiration_ns(expiration_ns)
1751            .price_precision(2)
1752            .price_increment(Price::from("0.01"))
1753            .multiplier(Quantity::from(100))
1754            .lot_size(Quantity::from(1))
1755            .ts_event(UnixNanos::default())
1756            .ts_init(UnixNanos::default())
1757            .build()
1758            .unwrap()
1759    }
1760
1761    fn equity_aapl_opra() -> Equity {
1762        Equity::builder()
1763            .instrument_id(InstrumentId::from("AAPL.OPRA"))
1764            .raw_symbol(Symbol::from("AAPL"))
1765            .isin(Ustr::from("US0378331005"))
1766            .currency(Currency::from("USD"))
1767            .price_precision(2)
1768            .price_increment(Price::from("0.01"))
1769            .ts_event(UnixNanos::default())
1770            .ts_init(UnixNanos::default())
1771            .build()
1772            .unwrap()
1773    }
1774
1775    #[rstest]
1776    fn test_resolve_underlying_instrument_id_errors_without_underlying() {
1777        let instrument = InstrumentAny::Equity(equity_aapl_opra());
1778        let error = GreeksCalculator::resolve_underlying_instrument_id(
1779            &instrument,
1780            InstrumentId::from("AAPL.OPRA"),
1781        )
1782        .unwrap_err();
1783
1784        assert_eq!(
1785            error.to_string(),
1786            "Instrument AAPL.OPRA has no underlying identifier"
1787        );
1788    }
1789
1790    fn future_with_expiration(
1791        instrument_id: &str,
1792        underlying: &str,
1793        expiration_ns: UnixNanos,
1794    ) -> FuturesContract {
1795        FuturesContract::builder()
1796            .instrument_id(InstrumentId::from(instrument_id))
1797            .raw_symbol(Symbol::from(underlying))
1798            .asset_class(AssetClass::Index)
1799            .exchange(Ustr::from("XCME"))
1800            .underlying(Ustr::from(underlying))
1801            .activation_ns(UnixNanos::default())
1802            .expiration_ns(expiration_ns)
1803            .currency(Currency::from("USD"))
1804            .price_precision(2)
1805            .price_increment(Price::from("0.25"))
1806            .multiplier(Quantity::from(1))
1807            .lot_size(Quantity::from(1))
1808            .ts_event(UnixNanos::default())
1809            .ts_init(UnixNanos::default())
1810            .build()
1811            .unwrap()
1812    }
1813
1814    fn future_option_with_expiration(
1815        instrument_id: &str,
1816        raw_symbol: &str,
1817        underlying: &str,
1818        option_kind: OptionKind,
1819        strike: &str,
1820        expiration_ns: UnixNanos,
1821    ) -> OptionContract {
1822        OptionContract::builder()
1823            .instrument_id(InstrumentId::from(instrument_id))
1824            .raw_symbol(Symbol::from(raw_symbol))
1825            .asset_class(AssetClass::Index)
1826            .exchange(Ustr::from("XCME"))
1827            .underlying(Ustr::from(underlying))
1828            .option_kind(option_kind)
1829            .strike_price(Price::from(strike))
1830            .currency(Currency::from("USD"))
1831            .activation_ns(UnixNanos::default())
1832            .expiration_ns(expiration_ns)
1833            .price_precision(2)
1834            .price_increment(Price::from("0.01"))
1835            .multiplier(Quantity::from(1))
1836            .lot_size(Quantity::from(1))
1837            .ts_event(UnixNanos::default())
1838            .ts_init(UnixNanos::default())
1839            .build()
1840            .unwrap()
1841    }
1842
1843    fn setup_cache_with_option_and_quotes(
1844        option: OptionContract,
1845        underlying_id: InstrumentId,
1846        now_ns: UnixNanos,
1847    ) -> Rc<RefCell<Cache>> {
1848        let option_id = option.id();
1849        let cache = Rc::new(RefCell::new(Cache::new(None, None)));
1850        cache
1851            .borrow_mut()
1852            .add_instrument(InstrumentAny::OptionContract(option))
1853            .unwrap();
1854        cache
1855            .borrow_mut()
1856            .add_instrument(InstrumentAny::Equity(equity_aapl_opra()))
1857            .unwrap();
1858        let option_quote = QuoteTick::new(
1859            option_id,
1860            Price::from("10.50"),
1861            Price::from("10.60"),
1862            Quantity::from(100),
1863            Quantity::from(100),
1864            now_ns,
1865            now_ns,
1866        );
1867        let underlying_quote = QuoteTick::new(
1868            underlying_id,
1869            Price::from("150.00"),
1870            Price::from("150.10"),
1871            Quantity::from(100),
1872            Quantity::from(100),
1873            now_ns,
1874            now_ns,
1875        );
1876        cache.borrow_mut().add_quote(option_quote).unwrap();
1877        cache.borrow_mut().add_quote(underlying_quote).unwrap();
1878        cache
1879    }
1880
1881    fn position_from_fill(
1882        instrument: &InstrumentAny,
1883        position_id: &str,
1884        client_order_id: &str,
1885        trade_id: &str,
1886        side: OrderSide,
1887        quantity: u64,
1888        price: &str,
1889    ) -> Position {
1890        let fill = OrderFilledSpec::builder()
1891            .instrument_id(instrument.id())
1892            .client_order_id(ClientOrderId::from(client_order_id))
1893            .trade_id(TradeId::from(trade_id))
1894            .order_side(side)
1895            .last_qty(Quantity::from(quantity))
1896            .last_px(Price::from(price))
1897            .currency(Currency::USD())
1898            .position_id(PositionId::from(position_id))
1899            .build();
1900        Position::new(instrument, fill)
1901    }
1902
1903    fn calculate_portfolio_greeks(
1904        calculator: &GreeksCalculator,
1905        side: Option<PositionSide>,
1906    ) -> anyhow::Result<PortfolioGreeks> {
1907        calculator.portfolio_greeks(
1908            None, None, None, None, side, None, None, None, None, None, None, None, None, None,
1909            None, None, None, None, None, None, None,
1910        )
1911    }
1912
1913    fn assert_portfolio_greeks_eq(actual: &PortfolioGreeks, expected: &PortfolioGreeks) {
1914        assert_eq!(actual.ts_init, expected.ts_init);
1915        assert_eq!(actual.ts_event, expected.ts_event);
1916        assert_eq!(actual.pnl, expected.pnl);
1917        assert_eq!(actual.price, expected.price);
1918        assert_eq!(actual.delta, expected.delta);
1919        assert_eq!(actual.gamma, expected.gamma);
1920        assert_eq!(actual.vega, expected.vega);
1921        assert_eq!(actual.theta, expected.theta);
1922        assert_eq!(actual.rho, expected.rho);
1923    }
1924
1925    #[rstest]
1926    fn test_portfolio_greeks_ignores_closed_position_with_missing_price() {
1927        let now = utc_timestamp(2025, 3, 8, 12, 0, 0);
1928        let expiry = now + jiff::SignedDuration::from_hours(24 * 30);
1929        let now_ns = UnixNanos::from(now);
1930        let expiry_ns = UnixNanos::from(expiry);
1931        let open_option = option_with_expiration("AAPL250417C00150000.OPRA", expiry_ns);
1932        let open_option_id = open_option.id();
1933        let underlying_id = InstrumentId::from("AAPL.OPRA");
1934        let cache = setup_cache_with_option_and_quotes(open_option.clone(), underlying_id, now_ns);
1935        let closed_future = future_with_expiration("CLOSED.GLBX", "CLOSED", expiry_ns);
1936        let closed_future_id = closed_future.id();
1937        let open_instrument = InstrumentAny::OptionContract(open_option);
1938        let closed_instrument = InstrumentAny::FuturesContract(closed_future);
1939
1940        let open_position = position_from_fill(
1941            &open_instrument,
1942            "P-OPEN",
1943            "O-OPEN",
1944            "T-OPEN",
1945            OrderSide::Buy,
1946            2,
1947            "10.50",
1948        );
1949        let mut closed_position = position_from_fill(
1950            &closed_instrument,
1951            "P-CLOSED",
1952            "O-CLOSED-OPEN",
1953            "T-CLOSED-OPEN",
1954            OrderSide::Buy,
1955            1,
1956            "100.00",
1957        );
1958        cache
1959            .borrow_mut()
1960            .add_instrument(closed_instrument)
1961            .unwrap();
1962        cache
1963            .borrow_mut()
1964            .add_position(&open_position, OmsType::Hedging)
1965            .unwrap();
1966        cache
1967            .borrow_mut()
1968            .add_position(&closed_position, OmsType::Hedging)
1969            .unwrap();
1970        let closing_fill = OrderFilledSpec::builder()
1971            .instrument_id(closed_future_id)
1972            .client_order_id(ClientOrderId::from("O-CLOSED-CLOSE"))
1973            .trade_id(TradeId::from("T-CLOSED-CLOSE"))
1974            .order_side(OrderSide::Sell)
1975            .last_qty(Quantity::from(1))
1976            .last_px(Price::from("101.00"))
1977            .currency(Currency::USD())
1978            .position_id(PositionId::from("P-CLOSED"))
1979            .build();
1980        closed_position.apply(&closing_fill);
1981        cache
1982            .borrow_mut()
1983            .update_position(&closed_position)
1984            .unwrap();
1985
1986        // Pin the fixture itself: the closed position must have left the open index,
1987        // or this would exercise `add_position`'s open-index insertion rather than
1988        // the query scope under test.
1989        assert!(closed_position.is_closed());
1990        assert_eq!(
1991            cache
1992                .borrow()
1993                .positions_open(None, None, None, None, None)
1994                .len(),
1995            1
1996        );
1997
1998        let clock = Rc::new(RefCell::new(VirtualClock::new()));
1999        clock.borrow_mut().set_time(now_ns);
2000        let calculator = GreeksCalculator::new(cache, clock);
2001        let expected = calculator
2002            .instrument_greeks(
2003                open_option_id,
2004                None,
2005                None,
2006                None,
2007                None,
2008                None,
2009                None,
2010                None,
2011                None,
2012                None,
2013                Some(now_ns),
2014                Some(open_position.clone()),
2015                None,
2016                None,
2017                None,
2018                None,
2019                None,
2020                None,
2021            )
2022            .unwrap();
2023        let expected = PortfolioGreeks::from(open_position.signed_qty * &expected);
2024
2025        assert_ne!(expected.delta, 0.0);
2026        assert_portfolio_greeks_eq(
2027            &calculate_portfolio_greeks(&calculator, None).unwrap(),
2028            &expected,
2029        );
2030        assert_portfolio_greeks_eq(
2031            &calculate_portfolio_greeks(&calculator, Some(PositionSide::Flat)).unwrap(),
2032            &PortfolioGreeks::new(now_ns, now_ns, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0),
2033        );
2034    }
2035
2036    #[rstest]
2037    fn test_portfolio_greeks_preserves_open_position_aggregate_and_side_filters() {
2038        let now = utc_timestamp(2025, 3, 8, 12, 0, 0);
2039        let expiry = now + jiff::SignedDuration::from_hours(24 * 30);
2040        let now_ns = UnixNanos::from(now);
2041        let expiry_ns = UnixNanos::from(expiry);
2042        let long_option = option_with_expiration("AAPL250417C00145000.OPRA", expiry_ns);
2043        let short_option = option_with_expiration("AAPL250417C00155000.OPRA", expiry_ns);
2044        let long_instrument = InstrumentAny::OptionContract(long_option.clone());
2045        let short_instrument = InstrumentAny::OptionContract(short_option.clone());
2046        let underlying_id = InstrumentId::from("AAPL.OPRA");
2047        let cache = setup_cache_with_option_and_quotes(long_option, underlying_id, now_ns);
2048        cache
2049            .borrow_mut()
2050            .add_instrument(short_instrument.clone())
2051            .unwrap();
2052        cache
2053            .borrow_mut()
2054            .add_quote(QuoteTick::new(
2055                short_option.id(),
2056                Price::from("3.50"),
2057                Price::from("3.60"),
2058                Quantity::from(100),
2059                Quantity::from(100),
2060                now_ns,
2061                now_ns,
2062            ))
2063            .unwrap();
2064        let long_position = position_from_fill(
2065            &long_instrument,
2066            "P-LONG",
2067            "O-LONG",
2068            "T-LONG",
2069            OrderSide::Buy,
2070            3,
2071            "10.50",
2072        );
2073        let short_position = position_from_fill(
2074            &short_instrument,
2075            "P-SHORT",
2076            "O-SHORT",
2077            "T-SHORT",
2078            OrderSide::Sell,
2079            2,
2080            "3.50",
2081        );
2082        cache
2083            .borrow_mut()
2084            .add_position(&long_position, OmsType::Hedging)
2085            .unwrap();
2086        cache
2087            .borrow_mut()
2088            .add_position(&short_position, OmsType::Hedging)
2089            .unwrap();
2090
2091        let clock = Rc::new(RefCell::new(VirtualClock::new()));
2092        clock.borrow_mut().set_time(now_ns);
2093        let calculator = GreeksCalculator::new(cache, clock);
2094        let long_greeks = calculator
2095            .instrument_greeks(
2096                long_instrument.id(),
2097                None,
2098                None,
2099                None,
2100                None,
2101                None,
2102                None,
2103                None,
2104                None,
2105                None,
2106                Some(now_ns),
2107                Some(long_position.clone()),
2108                None,
2109                None,
2110                None,
2111                None,
2112                None,
2113                None,
2114            )
2115            .unwrap();
2116        let short_greeks = calculator
2117            .instrument_greeks(
2118                short_instrument.id(),
2119                None,
2120                None,
2121                None,
2122                None,
2123                None,
2124                None,
2125                None,
2126                None,
2127                None,
2128                Some(now_ns),
2129                Some(short_position.clone()),
2130                None,
2131                None,
2132                None,
2133                None,
2134                None,
2135                None,
2136            )
2137            .unwrap();
2138        let expected_long = PortfolioGreeks::from(long_position.signed_qty * &long_greeks);
2139        let expected_short = PortfolioGreeks::from(short_position.signed_qty * &short_greeks);
2140        let expected = expected_long + expected_short;
2141
2142        assert_ne!(expected.pnl, 0.0);
2143        assert_ne!(expected.price, 0.0);
2144        assert_ne!(expected.delta, 0.0);
2145        assert_ne!(expected.gamma, 0.0);
2146        assert_ne!(expected.vega, 0.0);
2147        assert_ne!(expected.theta, 0.0);
2148        assert_portfolio_greeks_eq(
2149            &calculate_portfolio_greeks(&calculator, None).unwrap(),
2150            &expected,
2151        );
2152        assert_portfolio_greeks_eq(
2153            &calculate_portfolio_greeks(&calculator, Some(PositionSide::Long)).unwrap(),
2154            &PortfolioGreeks::from(long_position.signed_qty * &long_greeks),
2155        );
2156        assert_portfolio_greeks_eq(
2157            &calculate_portfolio_greeks(&calculator, Some(PositionSide::Short)).unwrap(),
2158            &PortfolioGreeks::from(short_position.signed_qty * &short_greeks),
2159        );
2160    }
2161
2162    #[rstest]
2163    fn test_expiry_in_days_multi_day_unchanged() {
2164        let now = utc_timestamp(2025, 3, 8, 12, 0, 0);
2165        let expiry = now + jiff::SignedDuration::from_hours(24 * (30));
2166        let now_ns = UnixNanos::from(now);
2167        let expiry_ns = UnixNanos::from(expiry);
2168        let option = option_with_expiration("AAPL250417C00150000.OPRA", expiry_ns);
2169        let option_id = option.id();
2170        let underlying_id = InstrumentId::from("AAPL.OPRA");
2171        let cache = setup_cache_with_option_and_quotes(option, underlying_id, now_ns);
2172        let clock = Rc::new(RefCell::new(VirtualClock::new()));
2173        let calculator = GreeksCalculator::new(cache, clock);
2174
2175        let greeks = calculator
2176            .instrument_greeks(
2177                option_id,
2178                None,
2179                None,
2180                None,
2181                None,
2182                None,
2183                None,
2184                None,
2185                None,
2186                None,
2187                Some(now_ns),
2188                None,
2189                None,
2190                None,
2191                None,
2192                None,
2193                None,
2194                None,
2195            )
2196            .unwrap();
2197
2198        assert_eq!(greeks.expiry_in_days, 30);
2199        assert!((greeks.expiry_in_years - 30.0 / 365.25).abs() < 1e-9);
2200    }
2201
2202    #[rstest]
2203    fn test_expiry_in_days_same_day_clamped_to_one() {
2204        let now = utc_timestamp(2025, 3, 8, 12, 0, 0);
2205        let expiry_same_day = utc_timestamp(2025, 3, 8, 18, 0, 0);
2206        let now_ns = UnixNanos::from(now);
2207        let expiry_ns = UnixNanos::from(expiry_same_day);
2208        let option = option_with_expiration("AAPL250308C00150000.OPRA", expiry_ns);
2209        let option_id = option.id();
2210        let underlying_id = InstrumentId::from("AAPL.OPRA");
2211        let cache = setup_cache_with_option_and_quotes(option, underlying_id, now_ns);
2212        let clock = Rc::new(RefCell::new(VirtualClock::new()));
2213        let calculator = GreeksCalculator::new(cache, clock);
2214
2215        let greeks = calculator
2216            .instrument_greeks(
2217                option_id,
2218                None,
2219                None,
2220                None,
2221                None,
2222                None,
2223                None,
2224                None,
2225                None,
2226                None,
2227                Some(now_ns),
2228                None,
2229                None,
2230                None,
2231                None,
2232                None,
2233                None,
2234                None,
2235            )
2236            .unwrap();
2237
2238        assert_eq!(greeks.expiry_in_days, 1);
2239        assert!((greeks.expiry_in_years - 1.0 / 365.25).abs() < 1e-9);
2240    }
2241
2242    #[rstest]
2243    fn test_instrument_greeks_beta_weights_vega_to_vol_index() {
2244        let now = utc_timestamp(2025, 3, 8, 12, 0, 0);
2245        let expiry = now + jiff::SignedDuration::from_hours(24 * (30));
2246        let now_ns = UnixNanos::from(now);
2247        let expiry_ns = UnixNanos::from(expiry);
2248        let option = option_with_expiration("AAPL250417C00150000.OPRA", expiry_ns);
2249        let option_id = option.id();
2250        let underlying_id = InstrumentId::from("AAPL.OPRA");
2251        let vol_index_id = InstrumentId::from("VIX.XCBF");
2252        let cache = setup_cache_with_option_and_quotes(option, underlying_id, now_ns);
2253        cache
2254            .borrow_mut()
2255            .add_quote(QuoteTick::new(
2256                vol_index_id,
2257                Price::from("25.00"),
2258                Price::from("25.00"),
2259                Quantity::from(100),
2260                Quantity::from(100),
2261                now_ns,
2262                now_ns,
2263            ))
2264            .unwrap();
2265
2266        let clock = Rc::new(RefCell::new(VirtualClock::new()));
2267        let calculator = GreeksCalculator::new(cache, clock);
2268        let greeks = calculator
2269            .instrument_greeks(
2270                option_id,
2271                None,
2272                None,
2273                None,
2274                None,
2275                None,
2276                None,
2277                None,
2278                None,
2279                None,
2280                Some(now_ns),
2281                None,
2282                None,
2283                None,
2284                None,
2285                None,
2286                None,
2287                None,
2288            )
2289            .unwrap();
2290
2291        let mut vol_beta_weights = HashMap::new();
2292        vol_beta_weights.insert(underlying_id, 0.75);
2293        let vol_weighted_greeks = calculator
2294            .instrument_greeks(
2295                option_id,
2296                None,
2297                None,
2298                None,
2299                None,
2300                None,
2301                None,
2302                None,
2303                None,
2304                None,
2305                Some(now_ns),
2306                None,
2307                None,
2308                None,
2309                None,
2310                None,
2311                Some(vol_index_id),
2312                Some(&vol_beta_weights),
2313            )
2314            .unwrap();
2315
2316        let expected_vega = greeks.vega * 0.75 * (greeks.vol * 100.0) / 25.0;
2317        assert_eq!(
2318            (vol_weighted_greeks.delta * 1e12).round(),
2319            (greeks.delta * 1e12).round()
2320        );
2321        assert_eq!(
2322            (vol_weighted_greeks.gamma * 1e12).round(),
2323            (greeks.gamma * 1e12).round()
2324        );
2325        assert_eq!(
2326            (vol_weighted_greeks.vega * 1e12).round(),
2327            (expected_vega * 1e12).round()
2328        );
2329    }
2330
2331    #[rstest]
2332    fn test_instrument_greeks_errors_when_vol_index_price_missing() {
2333        let now = utc_timestamp(2025, 3, 8, 12, 0, 0);
2334        let expiry = now + jiff::SignedDuration::from_hours(24 * (30));
2335        let now_ns = UnixNanos::from(now);
2336        let expiry_ns = UnixNanos::from(expiry);
2337        let option = option_with_expiration("AAPL250417C00150000.OPRA", expiry_ns);
2338        let option_id = option.id();
2339        let underlying_id = InstrumentId::from("AAPL.OPRA");
2340        let vol_index_id = InstrumentId::from("VIX.XCBF");
2341        let cache = setup_cache_with_option_and_quotes(option, underlying_id, now_ns);
2342
2343        let clock = Rc::new(RefCell::new(VirtualClock::new()));
2344        let calculator = GreeksCalculator::new(cache, clock);
2345        let error = calculator
2346            .instrument_greeks(
2347                option_id,
2348                None,
2349                None,
2350                None,
2351                None,
2352                None,
2353                None,
2354                None,
2355                None,
2356                None,
2357                Some(now_ns),
2358                None,
2359                None,
2360                None,
2361                None,
2362                None,
2363                Some(vol_index_id),
2364                None,
2365            )
2366            .unwrap_err();
2367
2368        assert_eq!(error.to_string(), "No price available for VIX.XCBF");
2369    }
2370
2371    #[rstest]
2372    fn test_modify_greeks_errors_when_vol_index_price_missing() {
2373        let calculator = create_test_calculator();
2374        let underlying_id = InstrumentId::from("AAPL.OPRA");
2375        let vol_index_id = InstrumentId::from("VIX.XCBF");
2376
2377        let error = calculator
2378            .modify_greeks(
2379                1.0,
2380                2.0,
2381                underlying_id,
2382                150.0,
2383                150.0,
2384                false,
2385                None,
2386                None,
2387                2.0,
2388                0.30,
2389                0,
2390                None,
2391                0.0,
2392                Some(vol_index_id),
2393                None,
2394                None,
2395                None,
2396            )
2397            .unwrap_err();
2398
2399        assert_eq!(error.to_string(), "No price available for VIX.XCBF");
2400    }
2401
2402    #[rstest]
2403    fn test_modify_greeks_accepts_explicit_index_prices() {
2404        let calculator = create_test_calculator();
2405        let underlying_id = InstrumentId::from("AAPL.OPRA");
2406        let mut beta_weights = HashMap::new();
2407        beta_weights.insert(underlying_id, 0.5);
2408        let mut vol_beta_weights = HashMap::new();
2409        vol_beta_weights.insert(underlying_id, 0.75);
2410
2411        let (delta, gamma, vega) = calculator
2412            .modify_greeks(
2413                1.0,
2414                2.0,
2415                underlying_id,
2416                150.0,
2417                150.0,
2418                false,
2419                None,
2420                Some(&beta_weights),
2421                2.0,
2422                0.30,
2423                0,
2424                None,
2425                0.0,
2426                None,
2427                Some(&vol_beta_weights),
2428                Some(200.0),
2429                Some(25.0),
2430            )
2431            .unwrap();
2432
2433        assert_eq!((delta * 1e12).round(), 375_000_000_000.0);
2434        assert_eq!((gamma * 1e12).round(), 281_250_000_000.0);
2435        assert_eq!((vega * 1e12).round(), 1_800_000_000_000.0);
2436
2437        let (delta, gamma, vega) = calculator
2438            .modify_greeks(
2439                1.0,
2440                2.0,
2441                underlying_id,
2442                150.0,
2443                150.0,
2444                true,
2445                None,
2446                Some(&beta_weights),
2447                2.0,
2448                0.30,
2449                0,
2450                None,
2451                0.0,
2452                None,
2453                Some(&vol_beta_weights),
2454                Some(200.0),
2455                Some(25.0),
2456            )
2457            .unwrap();
2458
2459        assert_eq!((delta * 1e12).round(), 750_000_000_000.0);
2460        assert_eq!((gamma * 1e12).round(), 1_125_000_000_000.0);
2461        assert_eq!((vega * 1e12).round(), 4_500_000_000.0);
2462    }
2463
2464    #[rstest]
2465    fn test_instrument_greeks_errors_when_future_underlying_price_missing_without_cached_spread() {
2466        let now = utc_timestamp(2024, 2, 14, 16, 0, 0);
2467        let expiry = utc_timestamp(2024, 3, 15, 16, 0, 0);
2468        let now_ns = UnixNanos::from(now);
2469        let expiry_ns = UnixNanos::from(expiry);
2470
2471        let future = future_with_expiration("ESH4.GLBX", "ESH4", expiry_ns);
2472        let call_option = future_option_with_expiration(
2473            "ESH4C150.GLBX",
2474            "ESH4C150",
2475            "ESH4",
2476            OptionKind::Call,
2477            "150.00",
2478            expiry_ns,
2479        );
2480        let put_option = future_option_with_expiration(
2481            "ESH4P150.GLBX",
2482            "ESH4P150",
2483            "ESH4",
2484            OptionKind::Put,
2485            "150.00",
2486            expiry_ns,
2487        );
2488
2489        let cache = Rc::new(RefCell::new(Cache::new(None, None)));
2490        cache
2491            .borrow_mut()
2492            .add_instrument(InstrumentAny::FuturesContract(future))
2493            .unwrap();
2494        cache
2495            .borrow_mut()
2496            .add_instrument(InstrumentAny::OptionContract(call_option.clone()))
2497            .unwrap();
2498        cache
2499            .borrow_mut()
2500            .add_instrument(InstrumentAny::OptionContract(put_option.clone()))
2501            .unwrap();
2502
2503        let call_quote = QuoteTick::new(
2504            call_option.id(),
2505            Price::from("8.50"),
2506            Price::from("8.50"),
2507            Quantity::from(100),
2508            Quantity::from(100),
2509            now_ns,
2510            now_ns,
2511        );
2512        let put_quote = QuoteTick::new(
2513            put_option.id(),
2514            Price::from("3.33"),
2515            Price::from("3.33"),
2516            Quantity::from(100),
2517            Quantity::from(100),
2518            now_ns,
2519            now_ns,
2520        );
2521        cache.borrow_mut().add_quote(call_quote).unwrap();
2522        cache.borrow_mut().add_quote(put_quote).unwrap();
2523
2524        let clock = Rc::new(RefCell::new(VirtualClock::new()));
2525        clock.borrow_mut().set_time(now_ns);
2526        let calculator = GreeksCalculator::new(cache, clock);
2527
2528        let error = calculator
2529            .instrument_greeks(
2530                call_option.id(),
2531                Some(0.0425),
2532                None,
2533                None,
2534                None,
2535                None,
2536                None,
2537                None,
2538                None,
2539                None,
2540                Some(now_ns),
2541                None,
2542                None,
2543                None,
2544                None,
2545                None,
2546                None,
2547                None,
2548            )
2549            .unwrap_err();
2550
2551        assert_eq!(error.to_string(), "No price available for ESH4.GLBX");
2552    }
2553
2554    #[rstest]
2555    fn test_cache_futures_spread_returns_price_to_reference_future() {
2556        let now = utc_timestamp(2024, 2, 14, 16, 0, 0);
2557        let expiry = utc_timestamp(2024, 3, 15, 16, 0, 0);
2558        let now_ns = UnixNanos::from(now);
2559        let expiry_ns = UnixNanos::from(expiry);
2560
2561        let future = future_with_expiration("ESH4.GLBX", "ESH4", expiry_ns);
2562        let reference_future = future_with_expiration("ESM4.GLBX", "ESM4", expiry_ns);
2563        let call_option = future_option_with_expiration(
2564            "ESH4C150.GLBX",
2565            "ESH4C150",
2566            "ESH4",
2567            OptionKind::Call,
2568            "150.00",
2569            expiry_ns,
2570        );
2571        let put_option = future_option_with_expiration(
2572            "ESH4P150.GLBX",
2573            "ESH4P150",
2574            "ESH4",
2575            OptionKind::Put,
2576            "150.00",
2577            expiry_ns,
2578        );
2579
2580        let cache = Rc::new(RefCell::new(Cache::new(None, None)));
2581        cache
2582            .borrow_mut()
2583            .add_instrument(InstrumentAny::FuturesContract(future))
2584            .unwrap();
2585        cache
2586            .borrow_mut()
2587            .add_instrument(InstrumentAny::FuturesContract(reference_future.clone()))
2588            .unwrap();
2589        cache
2590            .borrow_mut()
2591            .add_instrument(InstrumentAny::OptionContract(call_option.clone()))
2592            .unwrap();
2593        cache
2594            .borrow_mut()
2595            .add_instrument(InstrumentAny::OptionContract(put_option.clone()))
2596            .unwrap();
2597
2598        let call_quote = QuoteTick::new(
2599            call_option.id(),
2600            Price::from("8.50"),
2601            Price::from("8.50"),
2602            Quantity::from(100),
2603            Quantity::from(100),
2604            now_ns,
2605            now_ns,
2606        );
2607        let put_quote = QuoteTick::new(
2608            put_option.id(),
2609            Price::from("3.33"),
2610            Price::from("3.33"),
2611            Quantity::from(100),
2612            Quantity::from(100),
2613            now_ns,
2614            now_ns,
2615        );
2616        let reference_future_quote = QuoteTick::new(
2617            reference_future.id(),
2618            Price::from("155.00"),
2619            Price::from("155.00"),
2620            Quantity::from(100),
2621            Quantity::from(100),
2622            now_ns,
2623            now_ns,
2624        );
2625        cache.borrow_mut().add_quote(call_quote).unwrap();
2626        cache.borrow_mut().add_quote(put_quote).unwrap();
2627        cache
2628            .borrow_mut()
2629            .add_quote(reference_future_quote)
2630            .unwrap();
2631
2632        let clock = Rc::new(RefCell::new(VirtualClock::new()));
2633        clock.borrow_mut().set_time(now_ns);
2634        let calculator = GreeksCalculator::new(cache, clock);
2635
2636        let cached_future_price = calculator
2637            .cache_futures_spread(call_option.id(), put_option.id(), reference_future.id())
2638            .unwrap();
2639
2640        let expected_underlying = 150.0 + (0.0425_f64 * (30.0 / 365.25)).exp() * (8.50 - 3.33);
2641        let expected_cached_underlying = reference_future.make_price(expected_underlying);
2642        assert_eq!(cached_future_price, expected_cached_underlying);
2643        assert_eq!(
2644            calculator.get_cached_futures_spread_price(InstrumentId::from("ESH4.GLBX")),
2645            Some(expected_cached_underlying)
2646        );
2647    }
2648
2649    #[rstest]
2650    fn test_instrument_greeks_uses_cached_futures_spread_when_underlying_price_missing() {
2651        let now = utc_timestamp(2024, 2, 14, 16, 0, 0);
2652        let expiry = utc_timestamp(2024, 3, 15, 16, 0, 0);
2653        let now_ns = UnixNanos::from(now);
2654        let expiry_ns = UnixNanos::from(expiry);
2655
2656        let future = future_with_expiration("ESH4.GLBX", "ESH4", expiry_ns);
2657        let reference_future = future_with_expiration("ESM4.GLBX", "ESM4", expiry_ns);
2658        let call_option = future_option_with_expiration(
2659            "ESH4C150.GLBX",
2660            "ESH4C150",
2661            "ESH4",
2662            OptionKind::Call,
2663            "150.00",
2664            expiry_ns,
2665        );
2666        let put_option = future_option_with_expiration(
2667            "ESH4P150.GLBX",
2668            "ESH4P150",
2669            "ESH4",
2670            OptionKind::Put,
2671            "150.00",
2672            expiry_ns,
2673        );
2674        let target_call_option = future_option_with_expiration(
2675            "ESH4C152.GLBX",
2676            "ESH4C152",
2677            "ESH4",
2678            OptionKind::Call,
2679            "152.00",
2680            expiry_ns,
2681        );
2682
2683        let cache = Rc::new(RefCell::new(Cache::new(None, None)));
2684        cache
2685            .borrow_mut()
2686            .add_instrument(InstrumentAny::FuturesContract(future))
2687            .unwrap();
2688        cache
2689            .borrow_mut()
2690            .add_instrument(InstrumentAny::FuturesContract(reference_future.clone()))
2691            .unwrap();
2692        cache
2693            .borrow_mut()
2694            .add_instrument(InstrumentAny::OptionContract(call_option.clone()))
2695            .unwrap();
2696        cache
2697            .borrow_mut()
2698            .add_instrument(InstrumentAny::OptionContract(put_option.clone()))
2699            .unwrap();
2700        cache
2701            .borrow_mut()
2702            .add_instrument(InstrumentAny::OptionContract(target_call_option.clone()))
2703            .unwrap();
2704
2705        let call_quote = QuoteTick::new(
2706            call_option.id(),
2707            Price::from("8.50"),
2708            Price::from("8.50"),
2709            Quantity::from(100),
2710            Quantity::from(100),
2711            now_ns,
2712            now_ns,
2713        );
2714        let put_quote = QuoteTick::new(
2715            put_option.id(),
2716            Price::from("3.33"),
2717            Price::from("3.33"),
2718            Quantity::from(100),
2719            Quantity::from(100),
2720            now_ns,
2721            now_ns,
2722        );
2723        let target_call_quote = QuoteTick::new(
2724            target_call_option.id(),
2725            Price::from("6.75"),
2726            Price::from("6.75"),
2727            Quantity::from(100),
2728            Quantity::from(100),
2729            now_ns,
2730            now_ns,
2731        );
2732        let reference_future_quote = QuoteTick::new(
2733            reference_future.id(),
2734            Price::from("155.00"),
2735            Price::from("155.00"),
2736            Quantity::from(100),
2737            Quantity::from(100),
2738            now_ns,
2739            now_ns,
2740        );
2741        cache.borrow_mut().add_quote(call_quote).unwrap();
2742        cache.borrow_mut().add_quote(put_quote).unwrap();
2743        cache.borrow_mut().add_quote(target_call_quote).unwrap();
2744        cache
2745            .borrow_mut()
2746            .add_quote(reference_future_quote)
2747            .unwrap();
2748
2749        let clock = Rc::new(RefCell::new(VirtualClock::new()));
2750        clock.borrow_mut().set_time(now_ns);
2751        let calculator = GreeksCalculator::new(cache, clock);
2752        calculator
2753            .cache_futures_spread(call_option.id(), put_option.id(), reference_future.id())
2754            .unwrap();
2755
2756        let greeks = calculator
2757            .instrument_greeks(
2758                target_call_option.id(),
2759                Some(0.0425),
2760                None,
2761                None,
2762                None,
2763                None,
2764                None,
2765                None,
2766                None,
2767                None,
2768                Some(now_ns),
2769                None,
2770                None,
2771                None,
2772                None,
2773                None,
2774                None,
2775                None,
2776            )
2777            .unwrap();
2778
2779        let expected_underlying = reference_future
2780            .make_price(150.0 + (0.0425_f64 * (30.0 / 365.25)).exp() * (8.50 - 3.33))
2781            .as_f64();
2782        assert_eq!(greeks.underlying_price, expected_underlying);
2783    }
2784
2785    #[rstest]
2786    fn test_instrument_greeks_uses_index_price_for_index_underlying() {
2787        let now = utc_timestamp(2024, 2, 14, 16, 0, 0);
2788        let expiry = utc_timestamp(2024, 3, 15, 16, 0, 0);
2789        let now_ns = UnixNanos::from(now);
2790        let expiry_ns = UnixNanos::from(expiry);
2791
2792        let future = future_with_expiration("ESH4.GLBX", "ESH4", expiry_ns);
2793        let call_option = future_option_with_expiration(
2794            "ESH4C150.GLBX",
2795            "ESH4C150",
2796            "ESH4",
2797            OptionKind::Call,
2798            "150.00",
2799            expiry_ns,
2800        );
2801
2802        let cache = Rc::new(RefCell::new(Cache::new(None, None)));
2803        cache
2804            .borrow_mut()
2805            .add_instrument(InstrumentAny::FuturesContract(future))
2806            .unwrap();
2807        cache
2808            .borrow_mut()
2809            .add_instrument(InstrumentAny::OptionContract(call_option.clone()))
2810            .unwrap();
2811
2812        let call_quote = QuoteTick::new(
2813            call_option.id(),
2814            Price::from("8.50"),
2815            Price::from("8.50"),
2816            Quantity::from(100),
2817            Quantity::from(100),
2818            now_ns,
2819            now_ns,
2820        );
2821        cache.borrow_mut().add_quote(call_quote).unwrap();
2822        cache
2823            .borrow_mut()
2824            .add_index_price(IndexPriceUpdate::new(
2825                InstrumentId::from("ESH4.GLBX"),
2826                Price::from("157.25"),
2827                now_ns,
2828                now_ns,
2829            ))
2830            .unwrap();
2831
2832        let clock = Rc::new(RefCell::new(VirtualClock::new()));
2833        clock.borrow_mut().set_time(now_ns);
2834        let calculator = GreeksCalculator::new(cache, clock);
2835
2836        let greeks = calculator
2837            .instrument_greeks(
2838                call_option.id(),
2839                Some(0.0425),
2840                None,
2841                None,
2842                None,
2843                None,
2844                None,
2845                None,
2846                None,
2847                None,
2848                Some(now_ns),
2849                None,
2850                None,
2851                None,
2852                None,
2853                None,
2854                None,
2855                None,
2856            )
2857            .unwrap();
2858
2859        assert_eq!(greeks.underlying_price, 157.25);
2860    }
2861
2862    #[rstest]
2863    fn test_instrument_greeks_prefers_quote_over_index_price_for_index_future() {
2864        let now = utc_timestamp(2024, 2, 14, 16, 0, 0);
2865        let expiry = utc_timestamp(2024, 3, 15, 16, 0, 0);
2866        let now_ns = UnixNanos::from(now);
2867        let expiry_ns = UnixNanos::from(expiry);
2868
2869        let future = future_with_expiration("ESH4.GLBX", "ESH4", expiry_ns);
2870        let call_option = future_option_with_expiration(
2871            "ESH4C150.GLBX",
2872            "ESH4C150",
2873            "ESH4",
2874            OptionKind::Call,
2875            "150.00",
2876            expiry_ns,
2877        );
2878
2879        let cache = Rc::new(RefCell::new(Cache::new(None, None)));
2880        cache
2881            .borrow_mut()
2882            .add_instrument(InstrumentAny::FuturesContract(future))
2883            .unwrap();
2884        cache
2885            .borrow_mut()
2886            .add_instrument(InstrumentAny::OptionContract(call_option.clone()))
2887            .unwrap();
2888
2889        // Both a quote and an index price for the underlying future
2890        let future_quote = QuoteTick::new(
2891            InstrumentId::from("ESH4.GLBX"),
2892            Price::from("158.50"),
2893            Price::from("159.50"),
2894            Quantity::from(100),
2895            Quantity::from(100),
2896            now_ns,
2897            now_ns,
2898        );
2899        cache.borrow_mut().add_quote(future_quote).unwrap();
2900        cache
2901            .borrow_mut()
2902            .add_index_price(IndexPriceUpdate::new(
2903                InstrumentId::from("ESH4.GLBX"),
2904                Price::from("157.25"),
2905                now_ns,
2906                now_ns,
2907            ))
2908            .unwrap();
2909
2910        let call_quote = QuoteTick::new(
2911            call_option.id(),
2912            Price::from("8.50"),
2913            Price::from("8.50"),
2914            Quantity::from(100),
2915            Quantity::from(100),
2916            now_ns,
2917            now_ns,
2918        );
2919        cache.borrow_mut().add_quote(call_quote).unwrap();
2920
2921        let clock = Rc::new(RefCell::new(VirtualClock::new()));
2922        clock.borrow_mut().set_time(now_ns);
2923        let calculator = GreeksCalculator::new(cache, clock);
2924
2925        let greeks = calculator
2926            .instrument_greeks(
2927                call_option.id(),
2928                Some(0.0425),
2929                None,
2930                None,
2931                None,
2932                None,
2933                None,
2934                None,
2935                None,
2936                None,
2937                Some(now_ns),
2938                None,
2939                None,
2940                None,
2941                None,
2942                None,
2943                None,
2944                None,
2945            )
2946            .unwrap();
2947
2948        // Should use the MID quote (159.00), not the index price (157.25)
2949        assert_eq!(greeks.underlying_price, 159.0);
2950    }
2951
2952    /// Builds a calculator over the standard option/underlying quote pair used by the
2953    /// `instrument_greeks` tests, plus the option id to price.
2954    fn option_calculator() -> (GreeksCalculator, InstrumentId, UnixNanos) {
2955        let now = utc_timestamp(2025, 3, 8, 12, 0, 0);
2956        let expiry = now + jiff::SignedDuration::from_hours(24 * 30);
2957        let now_ns = UnixNanos::from(now);
2958        let option = option_with_expiration("AAPL250417C00150000.OPRA", UnixNanos::from(expiry));
2959        let option_id = option.id();
2960        let cache =
2961            setup_cache_with_option_and_quotes(option, InstrumentId::from("AAPL.OPRA"), now_ns);
2962        let clock = Rc::new(RefCell::new(VirtualClock::new()));
2963
2964        (GreeksCalculator::new(cache, clock), option_id, now_ns)
2965    }
2966
2967    fn option_greeks_with_shocks(
2968        calculator: &GreeksCalculator,
2969        option_id: InstrumentId,
2970        ts_event: UnixNanos,
2971        spot_shock: Option<f64>,
2972        vol_shock: Option<f64>,
2973        time_to_expiry_shock: Option<f64>,
2974    ) -> GreeksData {
2975        calculator
2976            .instrument_greeks(
2977                option_id,
2978                None,
2979                None,
2980                spot_shock,
2981                vol_shock,
2982                time_to_expiry_shock,
2983                None,
2984                None,
2985                None,
2986                None,
2987                Some(ts_event),
2988                None,
2989                None,
2990                None,
2991                None,
2992                None,
2993                None,
2994                None,
2995            )
2996            .unwrap()
2997    }
2998
2999    #[rstest]
3000    #[case::spot(Some(5.0), None, None)]
3001    #[case::vol(None, Some(0.05), None)]
3002    #[case::time_to_expiry(None, None, Some(0.01))]
3003    fn test_instrument_greeks_applies_each_shock_dimension_on_its_own(
3004        #[case] spot_shock: Option<f64>,
3005        #[case] vol_shock: Option<f64>,
3006        #[case] time_to_expiry_shock: Option<f64>,
3007    ) {
3008        let (calculator, option_id, now_ns) = option_calculator();
3009
3010        let unshocked = option_greeks_with_shocks(&calculator, option_id, now_ns, None, None, None);
3011        let shocked = option_greeks_with_shocks(
3012            &calculator,
3013            option_id,
3014            now_ns,
3015            spot_shock,
3016            vol_shock,
3017            time_to_expiry_shock,
3018        );
3019
3020        assert_ne!(
3021            scaled(shocked.price),
3022            scaled(unshocked.price),
3023            "a single non-zero shock must still reprice the option"
3024        );
3025    }
3026
3027    #[rstest]
3028    fn test_instrument_greeks_leaves_the_price_unshocked_when_every_shock_is_zero() {
3029        let (calculator, option_id, now_ns) = option_calculator();
3030
3031        let unshocked = option_greeks_with_shocks(&calculator, option_id, now_ns, None, None, None);
3032        let zero_shocks = option_greeks_with_shocks(
3033            &calculator,
3034            option_id,
3035            now_ns,
3036            Some(0.0),
3037            Some(0.0),
3038            Some(0.0),
3039        );
3040
3041        assert_eq!(scaled(zero_shocks.price), scaled(unshocked.price));
3042    }
3043
3044    #[rstest]
3045    fn test_instrument_greeks_pnl_is_price_less_the_position_open_price() {
3046        let (calculator, option_id, now_ns) = option_calculator();
3047        let instrument = calculator
3048            .cache
3049            .borrow()
3050            .instrument(&option_id)
3051            .cloned()
3052            .unwrap();
3053        let position = position_from_fill(
3054            &instrument,
3055            "P-GREEKS-1",
3056            "O-GREEKS-1",
3057            "T-GREEKS-1",
3058            OrderSide::Buy,
3059            1,
3060            "9.00",
3061        );
3062
3063        let unshocked = option_greeks_with_shocks(&calculator, option_id, now_ns, None, None, None);
3064        let with_position = calculator
3065            .instrument_greeks(
3066                option_id,
3067                None,
3068                None,
3069                None,
3070                None,
3071                None,
3072                None,
3073                None,
3074                None,
3075                None,
3076                Some(now_ns),
3077                Some(position),
3078                None,
3079                None,
3080                None,
3081                None,
3082                None,
3083                None,
3084            )
3085            .unwrap();
3086
3087        assert_eq!(scaled(with_position.price), scaled(unshocked.price));
3088        assert_eq!(
3089            scaled(with_position.pnl),
3090            scaled(unshocked.price - 9.0),
3091            "pnl must subtract the position open price from the option price"
3092        );
3093        assert_eq!(scaled(unshocked.pnl), 0.0);
3094    }
3095
3096    #[rstest]
3097    fn test_instrument_greeks_records_the_shocked_market_state() {
3098        let (calculator, option_id, now_ns) = option_calculator();
3099
3100        let unshocked = option_greeks_with_shocks(&calculator, option_id, now_ns, None, None, None);
3101        let shocked = option_greeks_with_shocks(
3102            &calculator,
3103            option_id,
3104            now_ns,
3105            Some(5.0),
3106            Some(0.05),
3107            Some(0.01),
3108        );
3109
3110        // The underlying mid of the 150.00 / 150.10 quote plus the spot shock.
3111        assert_eq!(scaled(unshocked.underlying_price), 150_050_000_000_000.0);
3112        assert_eq!(scaled(shocked.underlying_price), 155_050_000_000_000.0);
3113        assert_eq!(scaled(shocked.vol), scaled(unshocked.vol + 0.05));
3114        assert_eq!(
3115            scaled(shocked.expiry_in_years),
3116            scaled(unshocked.expiry_in_years - 0.01)
3117        );
3118        assert_eq!(unshocked.expiry_in_days, 30);
3119        assert_eq!(shocked.expiry_in_days, 26);
3120    }
3121
3122    #[rstest]
3123    fn test_instrument_greeks_cost_of_carry_subtracts_the_flat_dividend_yield() {
3124        let (calculator, option_id, now_ns) = option_calculator();
3125
3126        let greeks = calculator
3127            .instrument_greeks(
3128                option_id,
3129                Some(0.05),
3130                Some(0.02),
3131                None,
3132                None,
3133                None,
3134                None,
3135                None,
3136                None,
3137                None,
3138                Some(now_ns),
3139                None,
3140                None,
3141                None,
3142                None,
3143                None,
3144                None,
3145                None,
3146            )
3147            .unwrap();
3148
3149        assert_eq!(scaled(greeks.interest_rate), 50_000_000_000.0);
3150        assert_eq!(scaled(greeks.cost_of_carry), 30_000_000_000.0);
3151    }
3152
3153    #[rstest]
3154    fn test_instrument_greeks_cost_of_carry_is_zero_without_a_dividend_yield() {
3155        let (calculator, option_id, now_ns) = option_calculator();
3156
3157        let greeks = calculator
3158            .instrument_greeks(
3159                option_id,
3160                Some(0.05),
3161                None,
3162                None,
3163                None,
3164                None,
3165                None,
3166                None,
3167                None,
3168                None,
3169                Some(now_ns),
3170                None,
3171                None,
3172                None,
3173                None,
3174                None,
3175                None,
3176                None,
3177            )
3178            .unwrap();
3179
3180        assert_eq!(scaled(greeks.cost_of_carry), 0.0);
3181    }
3182
3183    #[rstest]
3184    fn test_instrument_greeks_reports_the_option_kind() {
3185        let now = utc_timestamp(2025, 3, 8, 12, 0, 0);
3186        let expiry = now + jiff::SignedDuration::from_hours(24 * 30);
3187        let now_ns = UnixNanos::from(now);
3188        let underlying_id = InstrumentId::from("AAPL.OPRA");
3189        let mut put = option_with_expiration("AAPL250417P00150000.OPRA", UnixNanos::from(expiry));
3190        put.option_kind = OptionKind::Put;
3191        let put_id = put.id();
3192        let cache = setup_cache_with_option_and_quotes(put, underlying_id, now_ns);
3193        cache
3194            .borrow_mut()
3195            .add_quote(QuoteTick::new(
3196                put_id,
3197                Price::from("10.50"),
3198                Price::from("10.60"),
3199                Quantity::from(100),
3200                Quantity::from(100),
3201                now_ns,
3202                now_ns,
3203            ))
3204            .unwrap();
3205        let calculator = GreeksCalculator::new(cache, Rc::new(RefCell::new(VirtualClock::new())));
3206
3207        let greeks = option_greeks_with_shocks(&calculator, put_id, now_ns, None, None, None);
3208
3209        assert!(!greeks.is_call);
3210    }
3211
3212    /// Builds a calculator over a single equity quote, for the `instrument_greeks` paths that
3213    /// price a non-option instrument.
3214    fn equity_calculator() -> (GreeksCalculator, InstrumentId, UnixNanos) {
3215        let now_ns = UnixNanos::from(utc_timestamp(2025, 3, 8, 12, 0, 0));
3216        let cache = Rc::new(RefCell::new(Cache::new(None, None)));
3217        let equity = equity_aapl_opra();
3218        let equity_id = equity.id();
3219        cache
3220            .borrow_mut()
3221            .add_instrument(InstrumentAny::Equity(equity))
3222            .unwrap();
3223        cache
3224            .borrow_mut()
3225            .add_quote(QuoteTick::new(
3226                equity_id,
3227                Price::from("150.00"),
3228                Price::from("150.10"),
3229                Quantity::from(100),
3230                Quantity::from(100),
3231                now_ns,
3232                now_ns,
3233            ))
3234            .unwrap();
3235        let clock = Rc::new(RefCell::new(VirtualClock::new()));
3236
3237        (GreeksCalculator::new(cache, clock), equity_id, now_ns)
3238    }
3239
3240    #[rstest]
3241    fn test_instrument_greeks_for_a_non_option_shocks_the_spot_and_prices_the_position() {
3242        let (calculator, equity_id, now_ns) = equity_calculator();
3243        let instrument = calculator
3244            .cache
3245            .borrow()
3246            .instrument(&equity_id)
3247            .cloned()
3248            .unwrap();
3249        let position = position_from_fill(
3250            &instrument,
3251            "P-EQUITY-1",
3252            "O-EQUITY-1",
3253            "T-EQUITY-1",
3254            OrderSide::Buy,
3255            10,
3256            "140.00",
3257        );
3258
3259        let greeks = calculator
3260            .instrument_greeks(
3261                equity_id,
3262                None,
3263                None,
3264                Some(5.0),
3265                None,
3266                None,
3267                None,
3268                None,
3269                None,
3270                None,
3271                Some(now_ns),
3272                Some(position),
3273                None,
3274                None,
3275                None,
3276                None,
3277                None,
3278                None,
3279            )
3280            .unwrap();
3281
3282        // Underlying mid 150.05 plus the 5.00 spot shock, less the 140.00 open price.
3283        assert_eq!(scaled(greeks.pnl), 15_050_000_000_000.0);
3284        assert_eq!(scaled(greeks.price), 15_050_000_000_000.0);
3285    }
3286
3287    #[rstest]
3288    fn test_instrument_greeks_for_a_non_option_scales_percent_delta_by_the_shocked_spot() {
3289        let (calculator, equity_id, now_ns) = equity_calculator();
3290
3291        let greeks = calculator
3292            .instrument_greeks(
3293                equity_id,
3294                None,
3295                None,
3296                Some(5.0),
3297                None,
3298                None,
3299                None,
3300                None,
3301                None,
3302                None,
3303                Some(now_ns),
3304                None,
3305                Some(true),
3306                None,
3307                None,
3308                None,
3309                None,
3310                None,
3311            )
3312            .unwrap();
3313
3314        // Percent delta scales the unit delta by the shocked spot of 150.05 + 5.00.
3315        assert_eq!(scaled(greeks.delta), 1_550_500_000_000.0);
3316    }
3317
3318    #[rstest]
3319    fn test_instrument_greeks_cost_of_carry_prefers_the_dividend_curve_over_the_flat_yield() {
3320        let (calculator, option_id, now_ns) = option_calculator();
3321        calculator
3322            .cache
3323            .borrow_mut()
3324            .add_yield_curve(YieldCurveData::new(
3325                now_ns,
3326                now_ns,
3327                "AAPL.OPRA".to_string(),
3328                vec![0.0, 5.0, 10.0],
3329                vec![0.02, 0.02, 0.02],
3330            ))
3331            .unwrap();
3332
3333        let greeks = calculator
3334            .instrument_greeks(
3335                option_id,
3336                Some(0.05),
3337                Some(0.04),
3338                None,
3339                None,
3340                None,
3341                None,
3342                None,
3343                None,
3344                None,
3345                Some(now_ns),
3346                None,
3347                None,
3348                None,
3349                None,
3350                None,
3351                None,
3352                None,
3353            )
3354            .unwrap();
3355
3356        assert_eq!(scaled(greeks.interest_rate), 50_000_000_000.0);
3357        assert_eq!(
3358            scaled(greeks.cost_of_carry),
3359            30_000_000_000.0,
3360            "the dividend curve must take precedence over the flat dividend yield"
3361        );
3362    }
3363
3364    /// Scales a Greek to picounits so exact expectations avoid raw float equality.
3365    fn scaled(value: f64) -> f64 {
3366        (value * 1e12).round()
3367    }
3368
3369    #[rstest]
3370    fn test_modify_greeks_reprices_the_index_when_the_underlying_is_shocked() {
3371        let calculator = create_test_calculator();
3372        let underlying_id = InstrumentId::from("AAPL.OPRA");
3373        let mut beta_weights = HashMap::new();
3374        beta_weights.insert(underlying_id, 0.5);
3375
3376        let (delta, gamma, vega) = calculator
3377            .modify_greeks(
3378                1.0,
3379                2.0,
3380                underlying_id,
3381                165.0,
3382                150.0,
3383                false,
3384                None,
3385                Some(&beta_weights),
3386                3.0,
3387                0.30,
3388                0,
3389                None,
3390                0.0,
3391                None,
3392                None,
3393                Some(200.0),
3394                None,
3395            )
3396            .unwrap();
3397
3398        // Index moves to 200 + (1 / 0.5) * (200 / 150) * (165 - 150) = 240, so the delta
3399        // multiplier is 0.5 * 165 / 240.
3400        assert_eq!(scaled(delta), 343_750_000_000.0);
3401        assert_eq!(scaled(gamma), 236_328_125_000.0);
3402        assert_eq!(scaled(vega), 3_000_000_000_000.0);
3403    }
3404
3405    #[rstest]
3406    fn test_modify_greeks_reprices_the_vol_index_when_the_vol_is_shocked() {
3407        let calculator = create_test_calculator();
3408        let underlying_id = InstrumentId::from("AAPL.OPRA");
3409        let mut vol_beta_weights = HashMap::new();
3410        vol_beta_weights.insert(underlying_id, 0.75);
3411
3412        let (delta, gamma, vega) = calculator
3413            .modify_greeks(
3414                1.0,
3415                2.0,
3416                underlying_id,
3417                150.0,
3418                150.0,
3419                false,
3420                None,
3421                None,
3422                2.0,
3423                0.35,
3424                0,
3425                None,
3426                0.30,
3427                None,
3428                Some(&vol_beta_weights),
3429                None,
3430                Some(25.0),
3431            )
3432            .unwrap();
3433
3434        assert_eq!(scaled(delta), 1_000_000_000_000.0);
3435        assert_eq!(scaled(gamma), 2_000_000_000_000.0);
3436        assert_eq!(scaled(vega), 1_718_181_818_182.0);
3437    }
3438
3439    #[rstest]
3440    fn test_modify_greeks_uses_the_shocked_vol_as_the_baseline_when_unshocked_vol_is_zero() {
3441        let calculator = create_test_calculator();
3442
3443        let (_, _, vega) = calculator
3444            .modify_greeks(
3445                1.0,
3446                2.0,
3447                InstrumentId::from("AAPL.OPRA"),
3448                150.0,
3449                150.0,
3450                false,
3451                None,
3452                None,
3453                2.0,
3454                0.40,
3455                0,
3456                None,
3457                0.0,
3458                None,
3459                None,
3460                None,
3461                Some(25.0),
3462            )
3463            .unwrap();
3464
3465        // A zero unshocked vol means no vol shock, so vega only rescales by 0.40 / 0.25.
3466        assert_eq!(scaled(vega), 3_200_000_000_000.0);
3467    }
3468
3469    #[rstest]
3470    fn test_modify_greeks_leaves_vega_untouched_for_a_zero_vol_index() {
3471        let calculator = create_test_calculator();
3472
3473        let (_, _, vega) = calculator
3474            .modify_greeks(
3475                1.0,
3476                2.0,
3477                InstrumentId::from("AAPL.OPRA"),
3478                150.0,
3479                150.0,
3480                false,
3481                None,
3482                None,
3483                2.0,
3484                0.40,
3485                0,
3486                None,
3487                0.0,
3488                None,
3489                None,
3490                None,
3491                Some(0.0),
3492            )
3493            .unwrap();
3494
3495        assert_eq!(scaled(vega), 2_000_000_000_000.0);
3496    }
3497
3498    #[rstest]
3499    fn test_modify_greeks_percent_scaling_falls_back_to_the_underlying_and_vol() {
3500        let calculator = create_test_calculator();
3501
3502        let (delta, gamma, vega) = calculator
3503            .modify_greeks(
3504                1.0,
3505                2.0,
3506                InstrumentId::from("AAPL.OPRA"),
3507                150.0,
3508                150.0,
3509                true,
3510                None,
3511                None,
3512                2.0,
3513                0.30,
3514                0,
3515                None,
3516                0.0,
3517                None,
3518                None,
3519                None,
3520                None,
3521            )
3522            .unwrap();
3523
3524        assert_eq!(scaled(delta), 1_500_000_000_000.0);
3525        assert_eq!(scaled(gamma), 4_500_000_000_000.0);
3526        assert_eq!(scaled(vega), 6_000_000_000.0);
3527    }
3528
3529    #[rstest]
3530    fn test_modify_greeks_applies_vega_time_weighting() {
3531        let calculator = create_test_calculator();
3532
3533        let (_, _, vega) = calculator
3534            .modify_greeks(
3535                1.0,
3536                2.0,
3537                InstrumentId::from("AAPL.OPRA"),
3538                150.0,
3539                150.0,
3540                false,
3541                None,
3542                None,
3543                2.0,
3544                0.30,
3545                120,
3546                Some(30),
3547                0.0,
3548                None,
3549                None,
3550                None,
3551                None,
3552            )
3553            .unwrap();
3554
3555        // sqrt(30 / 120) = 0.5
3556        assert_eq!(scaled(vega), 1_000_000_000_000.0);
3557    }
3558
3559    #[rstest]
3560    fn test_modify_greeks_skips_vega_time_weighting_without_days_to_expiry() {
3561        let calculator = create_test_calculator();
3562
3563        let (_, _, vega) = calculator
3564            .modify_greeks(
3565                1.0,
3566                2.0,
3567                InstrumentId::from("AAPL.OPRA"),
3568                150.0,
3569                150.0,
3570                false,
3571                None,
3572                None,
3573                2.0,
3574                0.30,
3575                0,
3576                Some(30),
3577                0.0,
3578                None,
3579                None,
3580                None,
3581                None,
3582            )
3583            .unwrap();
3584
3585        assert_eq!(scaled(vega), 2_000_000_000_000.0);
3586    }
3587
3588    const SPREAD_CALL_ID: &str = "ESH4C150.GLBX";
3589    const SPREAD_PUT_ID: &str = "ESH4P150.GLBX";
3590    const SPREAD_REFERENCE_ID: &str = "ESM4.GLBX";
3591    const SPREAD_UNDERLYING_ID: &str = "ESH4.GLBX";
3592
3593    fn spread_now_ns() -> UnixNanos {
3594        UnixNanos::from(utc_timestamp(2024, 2, 14, 16, 0, 0))
3595    }
3596
3597    fn spread_expiry_ns() -> UnixNanos {
3598        UnixNanos::from(utc_timestamp(2024, 3, 15, 16, 0, 0))
3599    }
3600
3601    fn spread_call() -> OptionContract {
3602        future_option_with_expiration(
3603            SPREAD_CALL_ID,
3604            "ESH4C150",
3605            "ESH4",
3606            OptionKind::Call,
3607            "150.00",
3608            spread_expiry_ns(),
3609        )
3610    }
3611
3612    fn spread_put() -> OptionContract {
3613        future_option_with_expiration(
3614            SPREAD_PUT_ID,
3615            "ESH4P150",
3616            "ESH4",
3617            OptionKind::Put,
3618            "150.00",
3619            spread_expiry_ns(),
3620        )
3621    }
3622
3623    fn spread_reference_future() -> FuturesContract {
3624        future_with_expiration(SPREAD_REFERENCE_ID, "ESM4", spread_expiry_ns())
3625    }
3626
3627    fn spread_quote(instrument_id: InstrumentId, price: &str) -> QuoteTick {
3628        let price = Price::from(price);
3629
3630        QuoteTick::new(
3631            instrument_id,
3632            price,
3633            price,
3634            Quantity::from(100),
3635            Quantity::from(100),
3636            spread_now_ns(),
3637            spread_now_ns(),
3638        )
3639    }
3640
3641    /// Builds a calculator over exactly the supplied instruments and quotes.
3642    ///
3643    /// Each `cache_futures_spread` error test drops or replaces one entry relative to
3644    /// [`spread_instruments`] and [`spread_quotes`], so the resulting failure isolates a
3645    /// single validation branch.
3646    fn spread_calculator(
3647        instruments: Vec<InstrumentAny>,
3648        quotes: Vec<QuoteTick>,
3649    ) -> GreeksCalculator {
3650        let cache = Rc::new(RefCell::new(Cache::new(None, None)));
3651
3652        for instrument in instruments {
3653            cache.borrow_mut().add_instrument(instrument).unwrap();
3654        }
3655
3656        for quote in quotes {
3657            cache.borrow_mut().add_quote(quote).unwrap();
3658        }
3659
3660        let clock = Rc::new(RefCell::new(VirtualClock::new()));
3661        clock.borrow_mut().set_time(spread_now_ns());
3662
3663        GreeksCalculator::new(cache, clock)
3664    }
3665
3666    fn spread_instruments() -> Vec<InstrumentAny> {
3667        vec![
3668            InstrumentAny::OptionContract(spread_call()),
3669            InstrumentAny::OptionContract(spread_put()),
3670            InstrumentAny::FuturesContract(spread_reference_future()),
3671        ]
3672    }
3673
3674    fn spread_quotes() -> Vec<QuoteTick> {
3675        vec![
3676            spread_quote(InstrumentId::from(SPREAD_CALL_ID), "8.50"),
3677            spread_quote(InstrumentId::from(SPREAD_PUT_ID), "3.33"),
3678            spread_quote(InstrumentId::from(SPREAD_REFERENCE_ID), "155.00"),
3679        ]
3680    }
3681
3682    fn cache_spread_error(instruments: Vec<InstrumentAny>, quotes: Vec<QuoteTick>) -> String {
3683        spread_calculator(instruments, quotes)
3684            .cache_futures_spread(
3685                InstrumentId::from(SPREAD_CALL_ID),
3686                InstrumentId::from(SPREAD_PUT_ID),
3687                InstrumentId::from(SPREAD_REFERENCE_ID),
3688            )
3689            .unwrap_err()
3690            .to_string()
3691    }
3692
3693    #[rstest]
3694    fn test_cache_futures_spread_errors_when_call_instrument_missing() {
3695        let instruments = vec![
3696            InstrumentAny::OptionContract(spread_put()),
3697            InstrumentAny::FuturesContract(spread_reference_future()),
3698        ];
3699
3700        assert_eq!(
3701            cache_spread_error(instruments, spread_quotes()),
3702            "Cannot cache futures spread: missing option instrument ESH4C150.GLBX"
3703        );
3704    }
3705
3706    #[rstest]
3707    fn test_cache_futures_spread_errors_when_put_instrument_missing() {
3708        let instruments = vec![
3709            InstrumentAny::OptionContract(spread_call()),
3710            InstrumentAny::FuturesContract(spread_reference_future()),
3711        ];
3712
3713        assert_eq!(
3714            cache_spread_error(instruments, spread_quotes()),
3715            "Cannot cache futures spread: missing option instrument ESH4P150.GLBX"
3716        );
3717    }
3718
3719    #[rstest]
3720    fn test_cache_futures_spread_errors_when_reference_future_instrument_missing() {
3721        let instruments = vec![
3722            InstrumentAny::OptionContract(spread_call()),
3723            InstrumentAny::OptionContract(spread_put()),
3724        ];
3725
3726        assert_eq!(
3727            cache_spread_error(instruments, spread_quotes()),
3728            "Cannot cache futures spread: no reference futures instrument for ESM4.GLBX"
3729        );
3730    }
3731
3732    #[rstest]
3733    fn test_cache_futures_spread_errors_when_call_leg_is_not_an_option() {
3734        let non_option = future_with_expiration(SPREAD_CALL_ID, "ESH4", spread_expiry_ns());
3735        let instruments = vec![
3736            InstrumentAny::FuturesContract(non_option),
3737            InstrumentAny::OptionContract(spread_put()),
3738            InstrumentAny::FuturesContract(spread_reference_future()),
3739        ];
3740
3741        assert_eq!(
3742            cache_spread_error(instruments, spread_quotes()),
3743            "Cannot cache futures spread: non-option instruments provided \
3744             call_instrument_id=ESH4C150.GLBX put_instrument_id=ESH4P150.GLBX"
3745        );
3746    }
3747
3748    #[rstest]
3749    fn test_cache_futures_spread_errors_when_legs_are_not_a_call_put_pair() {
3750        let second_put = future_option_with_expiration(
3751            SPREAD_CALL_ID,
3752            "ESH4P150",
3753            "ESH4",
3754            OptionKind::Put,
3755            "150.00",
3756            spread_expiry_ns(),
3757        );
3758        let instruments = vec![
3759            InstrumentAny::OptionContract(second_put),
3760            InstrumentAny::OptionContract(spread_put()),
3761            InstrumentAny::FuturesContract(spread_reference_future()),
3762        ];
3763
3764        assert_eq!(
3765            cache_spread_error(instruments, spread_quotes()),
3766            "Cannot cache futures spread: expected call/put pair \
3767             call_instrument_id=ESH4C150.GLBX put_instrument_id=ESH4P150.GLBX"
3768        );
3769    }
3770
3771    #[rstest]
3772    fn test_cache_futures_spread_errors_when_underlyings_differ() {
3773        let mismatched_put = future_option_with_expiration(
3774            SPREAD_PUT_ID,
3775            "ESM4P150",
3776            "ESM4",
3777            OptionKind::Put,
3778            "150.00",
3779            spread_expiry_ns(),
3780        );
3781        let instruments = vec![
3782            InstrumentAny::OptionContract(spread_call()),
3783            InstrumentAny::OptionContract(mismatched_put),
3784            InstrumentAny::FuturesContract(spread_reference_future()),
3785        ];
3786
3787        assert_eq!(
3788            cache_spread_error(instruments, spread_quotes()),
3789            "Cannot cache futures spread: option underlyings differ \
3790             call_instrument_id=ESH4C150.GLBX put_instrument_id=ESH4P150.GLBX"
3791        );
3792    }
3793
3794    #[rstest]
3795    fn test_cache_futures_spread_errors_when_strike_prices_differ() {
3796        let mismatched_put = future_option_with_expiration(
3797            SPREAD_PUT_ID,
3798            "ESH4P155",
3799            "ESH4",
3800            OptionKind::Put,
3801            "155.00",
3802            spread_expiry_ns(),
3803        );
3804        let instruments = vec![
3805            InstrumentAny::OptionContract(spread_call()),
3806            InstrumentAny::OptionContract(mismatched_put),
3807            InstrumentAny::FuturesContract(spread_reference_future()),
3808        ];
3809
3810        assert_eq!(
3811            cache_spread_error(instruments, spread_quotes()),
3812            "Cannot cache futures spread: strike prices differ \
3813             call_instrument_id=ESH4C150.GLBX put_instrument_id=ESH4P150.GLBX"
3814        );
3815    }
3816
3817    #[rstest]
3818    fn test_cache_futures_spread_errors_when_expirations_differ() {
3819        let mismatched_put = future_option_with_expiration(
3820            SPREAD_PUT_ID,
3821            "ESH4P150",
3822            "ESH4",
3823            OptionKind::Put,
3824            "150.00",
3825            UnixNanos::from(utc_timestamp(2024, 6, 21, 16, 0, 0)),
3826        );
3827        let instruments = vec![
3828            InstrumentAny::OptionContract(spread_call()),
3829            InstrumentAny::OptionContract(mismatched_put),
3830            InstrumentAny::FuturesContract(spread_reference_future()),
3831        ];
3832
3833        assert_eq!(
3834            cache_spread_error(instruments, spread_quotes()),
3835            "Cannot cache futures spread: expiration dates differ \
3836             call_instrument_id=ESH4C150.GLBX put_instrument_id=ESH4P150.GLBX"
3837        );
3838    }
3839
3840    #[rstest]
3841    fn test_cache_futures_spread_errors_when_reference_future_price_missing() {
3842        let quotes = vec![
3843            spread_quote(InstrumentId::from(SPREAD_CALL_ID), "8.50"),
3844            spread_quote(InstrumentId::from(SPREAD_PUT_ID), "3.33"),
3845        ];
3846
3847        assert_eq!(
3848            cache_spread_error(spread_instruments(), quotes),
3849            "Cannot cache futures spread: no reference futures price for ESM4.GLBX"
3850        );
3851    }
3852
3853    #[rstest]
3854    fn test_cache_futures_spread_errors_when_call_price_missing() {
3855        let quotes = vec![
3856            spread_quote(InstrumentId::from(SPREAD_PUT_ID), "3.33"),
3857            spread_quote(InstrumentId::from(SPREAD_REFERENCE_ID), "155.00"),
3858        ];
3859
3860        assert_eq!(
3861            cache_spread_error(spread_instruments(), quotes),
3862            "Cannot cache futures spread: missing option price for ESH4C150.GLBX"
3863        );
3864    }
3865
3866    #[rstest]
3867    fn test_cache_futures_spread_errors_when_put_price_missing() {
3868        let quotes = vec![
3869            spread_quote(InstrumentId::from(SPREAD_CALL_ID), "8.50"),
3870            spread_quote(InstrumentId::from(SPREAD_REFERENCE_ID), "155.00"),
3871        ];
3872
3873        assert_eq!(
3874            cache_spread_error(spread_instruments(), quotes),
3875            "Cannot cache futures spread: missing option price for ESH4P150.GLBX"
3876        );
3877    }
3878
3879    #[rstest]
3880    fn test_cache_futures_spread_errors_when_cached_underlying_is_not_a_future() {
3881        let underlying_equity = Equity::builder()
3882            .instrument_id(InstrumentId::from(SPREAD_UNDERLYING_ID))
3883            .raw_symbol(Symbol::from("ESH4"))
3884            .currency(Currency::from("USD"))
3885            .price_precision(2)
3886            .price_increment(Price::from("0.01"))
3887            .ts_event(UnixNanos::default())
3888            .ts_init(UnixNanos::default())
3889            .build()
3890            .unwrap();
3891
3892        let mut instruments = spread_instruments();
3893        instruments.push(InstrumentAny::Equity(underlying_equity));
3894
3895        assert_eq!(
3896            cache_spread_error(instruments, spread_quotes()),
3897            "Cannot cache futures spread: underlying ESH4.GLBX is not a futures contract"
3898        );
3899    }
3900
3901    #[rstest]
3902    fn test_cache_futures_spread_leaves_no_entry_when_validation_fails() {
3903        let calculator = spread_calculator(
3904            vec![
3905                InstrumentAny::OptionContract(spread_call()),
3906                InstrumentAny::OptionContract(spread_put()),
3907            ],
3908            spread_quotes(),
3909        );
3910
3911        calculator
3912            .cache_futures_spread(
3913                InstrumentId::from(SPREAD_CALL_ID),
3914                InstrumentId::from(SPREAD_PUT_ID),
3915                InstrumentId::from(SPREAD_REFERENCE_ID),
3916            )
3917            .unwrap_err();
3918
3919        assert_eq!(
3920            calculator.get_cached_futures_spread_price(InstrumentId::from(SPREAD_UNDERLYING_ID)),
3921            None
3922        );
3923    }
3924
3925    #[rstest]
3926    fn test_get_cached_futures_spread_price_returns_none_for_unknown_underlying() {
3927        let calculator = spread_calculator(spread_instruments(), spread_quotes());
3928
3929        assert_eq!(
3930            calculator.get_cached_futures_spread_price(InstrumentId::from("CLZ4.NYMEX")),
3931            None
3932        );
3933    }
3934}