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, OptionValuationError, PortfolioGreeks,
25        black_scholes_greeks_checked, check_option_inputs, imply_vol_and_greeks_checked,
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};
33use ustr::Ustr;
34
35use crate::{
36    actor::DataActorNative,
37    cache::{Cache, refs::PositionRef},
38    clock::Clock,
39    msgbus,
40    msgbus::TypedHandler,
41};
42
43/// Type alias for a greeks filter function.
44pub type GreeksFilter = Box<dyn Fn(&GreeksData) -> bool>;
45
46/// Cloneable wrapper for greeks filter functions.
47#[derive(Clone)]
48pub enum GreeksFilterCallback {
49    /// Function pointer (non-capturing closure)
50    Function(fn(&GreeksData) -> bool),
51    /// Boxed closure (may capture variables)
52    Closure(std::rc::Rc<dyn Fn(&GreeksData) -> bool>),
53}
54
55impl GreeksFilterCallback {
56    /// Create a new filter from a function pointer.
57    pub fn from_fn(f: fn(&GreeksData) -> bool) -> Self {
58        Self::Function(f)
59    }
60
61    /// Create a new filter from a closure.
62    pub fn from_closure<F>(f: F) -> Self
63    where
64        F: Fn(&GreeksData) -> bool + 'static,
65    {
66        Self::Closure(std::rc::Rc::new(f))
67    }
68
69    /// Call the filter function.
70    pub fn call(&self, data: &GreeksData) -> bool {
71        match self {
72            Self::Function(f) => f(data),
73            Self::Closure(f) => f(data),
74        }
75    }
76
77    /// Convert to the original `GreeksFilter` type.
78    pub fn to_greeks_filter(self) -> GreeksFilter {
79        match self {
80            Self::Function(f) => Box::new(f),
81            Self::Closure(f) => {
82                let f_clone = f.clone();
83                Box::new(move |data| f_clone(data))
84            }
85        }
86    }
87}
88
89impl Debug for GreeksFilterCallback {
90    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
91        match self {
92            Self::Function(_) => f.write_str("GreeksFilterCallback::Function"),
93            Self::Closure(_) => f.write_str("GreeksFilterCallback::Closure"),
94        }
95    }
96}
97
98/// Builder for instrument greeks calculation parameters.
99#[derive(Debug, bon::Builder)]
100pub struct InstrumentGreeksParams {
101    /// The instrument ID to calculate greeks for
102    pub instrument_id: InstrumentId,
103    /// Flat interest rate (default: 0.0425)
104    #[builder(default = 0.0425)]
105    pub flat_interest_rate: f64,
106    /// Flat dividend yield
107    pub flat_dividend_yield: Option<f64>,
108    /// Spot price shock (default: 0.0)
109    #[builder(default = 0.0)]
110    pub spot_shock: f64,
111    /// Volatility shock (default: 0.0)
112    #[builder(default = 0.0)]
113    pub vol_shock: f64,
114    /// Time to expiry shock (default: 0.0)
115    #[builder(default = 0.0)]
116    pub time_to_expiry_shock: f64,
117    /// Whether to use cached greeks (default: false)
118    #[builder(default = false)]
119    pub use_cached_greeks: bool,
120    /// Whether to update vol from cached greeks (default: false)
121    #[builder(default = false)]
122    pub update_vol: bool,
123    /// Whether to cache greeks (default: false)
124    #[builder(default = false)]
125    pub cache_greeks: bool,
126    /// Whether to publish greeks (default: false)
127    #[builder(default = false)]
128    pub publish_greeks: bool,
129    /// Event timestamp
130    pub ts_event: Option<UnixNanos>,
131    /// Position for PnL calculation
132    pub position: Option<Position>,
133    /// Whether to compute percent greeks (default: false)
134    #[builder(default = false)]
135    pub percent_greeks: bool,
136    /// Index instrument ID for beta weighting
137    pub index_instrument_id: Option<InstrumentId>,
138    /// Beta weights for portfolio calculations
139    pub beta_weights: Option<HashMap<InstrumentId, f64>>,
140    /// Base value in days for time-weighting vega
141    pub vega_time_weight_base: Option<i32>,
142    /// Volatility index instrument ID for vega beta weighting, for example VIX.
143    pub vol_index_instrument_id: Option<InstrumentId>,
144    /// Volatility beta weights for portfolio vega calculations
145    pub vol_beta_weights: Option<HashMap<InstrumentId, f64>>,
146}
147
148impl InstrumentGreeksParams {
149    /// Calculate instrument greeks using the builder parameters.
150    ///
151    /// # Errors
152    ///
153    /// Returns an error if the greeks calculation fails.
154    pub fn calculate(&self, calculator: &GreeksCalculator) -> anyhow::Result<GreeksData> {
155        calculator.instrument_greeks(
156            self.instrument_id,
157            Some(self.flat_interest_rate),
158            self.flat_dividend_yield,
159            Some(self.spot_shock),
160            Some(self.vol_shock),
161            Some(self.time_to_expiry_shock),
162            Some(self.use_cached_greeks),
163            Some(self.update_vol),
164            Some(self.cache_greeks),
165            Some(self.publish_greeks),
166            self.ts_event,
167            self.position.clone(),
168            Some(self.percent_greeks),
169            self.index_instrument_id,
170            self.beta_weights.as_ref(),
171            self.vega_time_weight_base,
172            self.vol_index_instrument_id,
173            self.vol_beta_weights.as_ref(),
174        )
175    }
176}
177
178/// Builder for portfolio greeks calculation parameters.
179#[derive(bon::Builder)]
180pub struct PortfolioGreeksParams {
181    /// List of underlying symbols to filter by
182    pub underlyings: Option<Vec<String>>,
183    /// Venue to filter positions by
184    pub venue: Option<Venue>,
185    /// Instrument ID to filter positions by
186    pub instrument_id: Option<InstrumentId>,
187    /// Strategy ID to filter positions by
188    pub strategy_id: Option<StrategyId>,
189    /// Position side to filter by (default: `None`)
190    pub side: Option<PositionSide>,
191    /// Flat interest rate (default: 0.0425)
192    #[builder(default = 0.0425)]
193    pub flat_interest_rate: f64,
194    /// Flat dividend yield
195    pub flat_dividend_yield: Option<f64>,
196    /// Spot price shock (default: 0.0)
197    #[builder(default = 0.0)]
198    pub spot_shock: f64,
199    /// Volatility shock (default: 0.0)
200    #[builder(default = 0.0)]
201    pub vol_shock: f64,
202    /// Time to expiry shock (default: 0.0)
203    #[builder(default = 0.0)]
204    pub time_to_expiry_shock: f64,
205    /// Whether to use cached greeks (default: false)
206    #[builder(default = false)]
207    pub use_cached_greeks: bool,
208    /// Whether to update vol from cached greeks (default: false)
209    #[builder(default = false)]
210    pub update_vol: bool,
211    /// Whether to cache greeks (default: false)
212    #[builder(default = false)]
213    pub cache_greeks: bool,
214    /// Whether to publish greeks (default: false)
215    #[builder(default = false)]
216    pub publish_greeks: bool,
217    /// Whether to compute percent greeks (default: false)
218    #[builder(default = false)]
219    pub percent_greeks: bool,
220    /// Index instrument ID for beta weighting
221    pub index_instrument_id: Option<InstrumentId>,
222    /// Beta weights for portfolio calculations
223    pub beta_weights: Option<HashMap<InstrumentId, f64>>,
224    /// Filter function for greeks
225    pub greeks_filter: Option<GreeksFilterCallback>,
226    /// Base value in days for time-weighting vega
227    pub vega_time_weight_base: Option<i32>,
228    /// Volatility index instrument ID for vega beta weighting, for example VIX.
229    pub vol_index_instrument_id: Option<InstrumentId>,
230    /// Volatility beta weights for portfolio vega calculations
231    pub vol_beta_weights: Option<HashMap<InstrumentId, f64>>,
232}
233
234impl Debug for PortfolioGreeksParams {
235    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
236        f.debug_struct(stringify!(PortfolioGreeksParams))
237            .field("underlyings", &self.underlyings)
238            .field("venue", &self.venue)
239            .field("instrument_id", &self.instrument_id)
240            .field("strategy_id", &self.strategy_id)
241            .field("side", &self.side)
242            .field("flat_interest_rate", &self.flat_interest_rate)
243            .field("flat_dividend_yield", &self.flat_dividend_yield)
244            .field("spot_shock", &self.spot_shock)
245            .field("vol_shock", &self.vol_shock)
246            .field("time_to_expiry_shock", &self.time_to_expiry_shock)
247            .field("use_cached_greeks", &self.use_cached_greeks)
248            .field("update_vol", &self.update_vol)
249            .field("cache_greeks", &self.cache_greeks)
250            .field("publish_greeks", &self.publish_greeks)
251            .field("percent_greeks", &self.percent_greeks)
252            .field("index_instrument_id", &self.index_instrument_id)
253            .field("beta_weights", &self.beta_weights)
254            .field("greeks_filter", &self.greeks_filter)
255            .field("vega_time_weight_base", &self.vega_time_weight_base)
256            .field("vol_index_instrument_id", &self.vol_index_instrument_id)
257            .field("vol_beta_weights", &self.vol_beta_weights)
258            .finish()
259    }
260}
261
262impl PortfolioGreeksParams {
263    /// Calculate portfolio greeks using the builder parameters.
264    ///
265    /// # Errors
266    ///
267    /// Returns an error if the portfolio greeks calculation fails.
268    pub fn calculate(&self, calculator: &GreeksCalculator) -> anyhow::Result<PortfolioGreeks> {
269        let greeks_filter = self
270            .greeks_filter
271            .as_ref()
272            .map(|f| f.clone().to_greeks_filter());
273
274        calculator.portfolio_greeks(
275            self.underlyings.as_deref(),
276            self.venue,
277            self.instrument_id,
278            self.strategy_id,
279            self.side,
280            Some(self.flat_interest_rate),
281            self.flat_dividend_yield,
282            Some(self.spot_shock),
283            Some(self.vol_shock),
284            Some(self.time_to_expiry_shock),
285            Some(self.use_cached_greeks),
286            Some(self.update_vol),
287            Some(self.cache_greeks),
288            Some(self.publish_greeks),
289            Some(self.percent_greeks),
290            self.index_instrument_id,
291            self.beta_weights.as_ref(),
292            greeks_filter.as_ref(),
293            self.vega_time_weight_base,
294            self.vol_index_instrument_id,
295            self.vol_beta_weights.as_ref(),
296        )
297    }
298}
299
300/// Calculates instrument and portfolio greeks (sensitivities of price moves with respect to market data moves).
301///
302/// Useful for risk management of options and futures portfolios.
303///
304/// Currently implemented greeks are:
305/// - Delta (first derivative of price with respect to spot move).
306/// - Gamma (second derivative of price with respect to spot move).
307/// - Vega (first derivative of price with respect to implied volatility of an option).
308/// - Theta (first derivative of price with respect to time to expiry).
309/// - Rho (first derivative of price with respect to the interest rate).
310///
311/// Vega is expressed in terms of absolute percent changes ((dV / dVol) / 100).
312/// Rho is expressed per percentage point of interest rate ((dV / dRate) / 100).
313/// 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).
314///
315/// Also note that for ease of implementation we consider that american options (for stock options for example) are european for the computation of greeks.
316#[allow(dead_code)]
317#[derive(Debug)]
318pub struct GreeksCalculator {
319    cache: Rc<RefCell<Cache>>,
320    clock: Rc<RefCell<dyn Clock>>,
321    cached_futures_spreads: RefCell<AHashMap<InstrumentId, (InstrumentId, Price)>>,
322    cross_venue_underlyings: RefCell<AHashMap<Ustr, InstrumentId>>,
323}
324
325impl GreeksCalculator {
326    /// Creates a new [`GreeksCalculator`] instance.
327    pub fn new(cache: Rc<RefCell<Cache>>, clock: Rc<RefCell<dyn Clock>>) -> Self {
328        Self {
329            cache,
330            clock,
331            cached_futures_spreads: RefCell::new(AHashMap::new()),
332            cross_venue_underlyings: RefCell::new(AHashMap::new()),
333        }
334    }
335
336    /// Creates a new [`GreeksCalculator`] from a registered native actor.
337    ///
338    /// # Panics
339    ///
340    /// Panics if the actor has not been registered with a trader.
341    pub fn from_actor(actor: &impl DataActorNative) -> Self {
342        Self::new(actor.cache_rc(), actor.clock_rc())
343    }
344
345    /// Calculates option or underlying greeks for a given instrument and a quantity of 1.
346    ///
347    /// Additional features:
348    /// - Apply shocks to the spot value of the instrument's underlying, implied volatility, or time to expiry.
349    /// - Compute percent greeks.
350    /// - Compute beta-weighted delta, gamma, and vega with respect to an index.
351    ///
352    /// Expiration is checked before returning cached option Greeks.
353    /// Rho holds carry fixed unless a dividend yield or dividend curve defines carry as
354    /// `r - q`. Equal interest and dividend rates still use that rate sensitivity.
355    /// Shocked cached valuations retain their cached inputs and select carry rate sensitivity
356    /// from the current call's dividend yield or available dividend curve.
357    /// Requested caching and publication use the unshocked option valuation after validation succeeds.
358    /// Cached valuations bypass caching and publication, including when shocks are applied.
359    ///
360    /// # Errors
361    ///
362    /// Returns an error if:
363    /// - Instrument or underlying definitions are missing or ambiguous.
364    /// - Required market prices are unavailable.
365    /// - An option has expired or an option spread is requested.
366    /// - Valuation inputs or results are invalid, or implied volatility cannot be solved.
367    #[expect(clippy::too_many_arguments)]
368    pub fn instrument_greeks(
369        &self,
370        instrument_id: InstrumentId,
371        flat_interest_rate: Option<f64>,
372        flat_dividend_yield: Option<f64>,
373        spot_shock: Option<f64>,
374        vol_shock: Option<f64>,
375        time_to_expiry_shock: Option<f64>,
376        use_cached_greeks: Option<bool>,
377        update_vol: Option<bool>,
378        cache_greeks: Option<bool>,
379        publish_greeks: Option<bool>,
380        ts_event: Option<UnixNanos>,
381        position: Option<Position>,
382        percent_greeks: Option<bool>,
383        index_instrument_id: Option<InstrumentId>,
384        beta_weights: Option<&HashMap<InstrumentId, f64>>,
385        vega_time_weight_base: Option<i32>,
386        vol_index_instrument_id: Option<InstrumentId>,
387        vol_beta_weights: Option<&HashMap<InstrumentId, f64>>,
388    ) -> anyhow::Result<GreeksData> {
389        // Set default values
390        let flat_interest_rate = flat_interest_rate.unwrap_or(0.0425);
391        let spot_shock = spot_shock.unwrap_or(0.0);
392        let vol_shock = vol_shock.unwrap_or(0.0);
393        let time_to_expiry_shock = time_to_expiry_shock.unwrap_or(0.0);
394        let use_cached_greeks = use_cached_greeks.unwrap_or(false);
395        let update_vol = update_vol.unwrap_or(false);
396        let cache_greeks = cache_greeks.unwrap_or(false);
397        let publish_greeks = publish_greeks.unwrap_or(false);
398        let ts_event = ts_event.unwrap_or_default();
399        let percent_greeks = percent_greeks.unwrap_or(false);
400
401        let instrument = {
402            let cache = self.cache.borrow();
403            cache.try_instrument(&instrument_id)?.clone()
404        };
405
406        if instrument.instrument_class() == InstrumentClass::OptionSpread {
407            return Err(OptionValuationError::UnsupportedSpread(instrument_id).into());
408        }
409
410        if instrument.instrument_class() != InstrumentClass::Option {
411            return self.calculate_non_option_greeks(
412                &instrument,
413                instrument_id,
414                spot_shock,
415                ts_event,
416                position,
417                percent_greeks,
418                index_instrument_id,
419                beta_weights,
420            );
421        }
422
423        let ts_event = self.check_option_valuation(
424            &instrument,
425            ts_event,
426            spot_shock,
427            vol_shock,
428            time_to_expiry_shock,
429        )?;
430
431        let has_shocks = spot_shock != 0.0 || vol_shock != 0.0 || time_to_expiry_shock != 0.0;
432
433        // Unshocked cached greeks don't use the underlying, so skip resolving it
434        if use_cached_greeks
435            && !has_shocks
436            && let Some(mut cached_greeks) = self.cache.borrow().greeks(&instrument_id)
437        {
438            check_option_greeks_data(&cached_greeks)?;
439            if let Some(pos) = position {
440                cached_greeks.pnl = cached_greeks.price - pos.avg_px_open.as_f64();
441            }
442
443            check_option_greeks_data(&cached_greeks)?;
444
445            return Ok(cached_greeks);
446        }
447
448        let underlying_instrument_id =
449            self.resolve_underlying_instrument_id(&instrument, instrument_id)?;
450        let (greeks_data, db_dr, is_fresh) = self.calculate_option_greeks(
451            &instrument,
452            instrument_id,
453            underlying_instrument_id,
454            flat_interest_rate,
455            flat_dividend_yield,
456            use_cached_greeks,
457            update_vol,
458            ts_event,
459            percent_greeks,
460            index_instrument_id,
461            beta_weights,
462            vega_time_weight_base,
463            vol_index_instrument_id,
464            vol_beta_weights,
465        )?;
466
467        let shocked_greeks = if has_shocks {
468            Some(self.apply_option_greeks_shocks(
469                &greeks_data,
470                underlying_instrument_id,
471                db_dr,
472                spot_shock,
473                vol_shock,
474                time_to_expiry_shock,
475                percent_greeks,
476                index_instrument_id,
477                beta_weights,
478                vega_time_weight_base,
479                vol_index_instrument_id,
480                vol_beta_weights,
481            )?)
482        } else {
483            None
484        };
485
486        let final_greeks = shocked_greeks.as_ref().unwrap_or(&greeks_data);
487        check_option_greeks_data(final_greeks)?;
488        let pnl = position.map_or(final_greeks.pnl, |pos| {
489            final_greeks.price - pos.avg_px_open.as_f64()
490        });
491
492        check_greeks_values(pnl, final_greeks.price, final_greeks.greeks)?;
493
494        if is_fresh {
495            self.store_option_greeks(
496                &greeks_data,
497                underlying_instrument_id.symbol.as_str(),
498                cache_greeks,
499                publish_greeks,
500            );
501        }
502
503        let mut greeks_data = shocked_greeks.unwrap_or(greeks_data);
504        greeks_data.pnl = pnl;
505
506        Ok(greeks_data)
507    }
508
509    fn check_option_valuation(
510        &self,
511        instrument: &InstrumentAny,
512        ts_event: UnixNanos,
513        spot_shock: f64,
514        vol_shock: f64,
515        time_to_expiry_shock: f64,
516    ) -> Result<UnixNanos, OptionValuationError> {
517        let valuation_time = if ts_event == UnixNanos::default() {
518            self.clock.borrow().timestamp_ns()
519        } else {
520            ts_event
521        };
522
523        let expiration = instrument
524            .expiration_ns()
525            .ok_or(OptionValuationError::InvalidInput("expiration"))?;
526
527        if valuation_time >= expiration {
528            return Err(OptionValuationError::Expired(instrument.id()));
529        }
530
531        for (name, value) in [
532            ("spot shock", spot_shock),
533            ("volatility shock", vol_shock),
534            ("time to expiry shock", time_to_expiry_shock),
535        ] {
536            if !value.is_finite() {
537                return Err(OptionValuationError::InvalidInput(name));
538            }
539        }
540
541        Ok(valuation_time)
542    }
543
544    fn store_option_greeks(&self, data: &GreeksData, underlying: &str, cache: bool, publish: bool) {
545        if cache && let Err(e) = self.cache.borrow_mut().add_greeks(data.clone()) {
546            log::error!("Failed to add GreeksData to cache: {e}");
547        }
548
549        if publish {
550            let topic = greeks_topic(underlying, &data.instrument_id.to_string()).into();
551            msgbus::publish_greeks(topic, data);
552        }
553    }
554
555    fn resolve_underlying_instrument_id(
556        &self,
557        instrument: &InstrumentAny,
558        instrument_id: InstrumentId,
559    ) -> anyhow::Result<InstrumentId> {
560        let Some(underlying) = instrument.underlying() else {
561            anyhow::bail!("Instrument {instrument_id} has no underlying identifier");
562        };
563
564        let same_venue_id = InstrumentId::from(format!("{underlying}.{}", instrument_id.venue));
565        let cache = self.cache.borrow();
566        let mut cross_venue_underlyings = self.cross_venue_underlyings.borrow_mut();
567
568        // Drop a purged match before any early return so a re-added instrument is rechecked
569        let remembered_id = cross_venue_underlyings
570            .get(&underlying)
571            .copied()
572            .filter(|id| cache.instrument(id).is_some());
573
574        if remembered_id.is_none() {
575            cross_venue_underlyings.remove(&underlying);
576        }
577
578        // A futures spread cached for the option's venue stands in for an uncached underlying
579        if cache.instrument(&same_venue_id).is_some()
580            || self
581                .cached_futures_spreads
582                .borrow()
583                .contains_key(&same_venue_id)
584        {
585            return Ok(same_venue_id);
586        }
587
588        if let Some(underlying_id) = remembered_id {
589            return Ok(underlying_id);
590        }
591
592        // Remember a unique cross-venue match to avoid rescanning the cache on later calls
593        let mut matches: Vec<InstrumentId> = cache
594            .instrument_ids(None)
595            .into_iter()
596            .filter(|id| id.symbol.inner() == underlying)
597            .copied()
598            .collect();
599
600        match matches.len() {
601            0 => Ok(same_venue_id),
602            1 => {
603                cross_venue_underlyings.insert(underlying, matches[0]);
604                Ok(matches[0])
605            }
606            _ => {
607                matches.sort();
608                let names = matches
609                    .iter()
610                    .map(ToString::to_string)
611                    .collect::<Vec<_>>()
612                    .join(", ");
613                anyhow::bail!("Ambiguous underlying for option {instrument_id}: {names}")
614            }
615        }
616    }
617
618    #[expect(clippy::too_many_arguments)]
619    fn calculate_non_option_greeks(
620        &self,
621        instrument: &InstrumentAny,
622        instrument_id: InstrumentId,
623        spot_shock: f64,
624        ts_event: UnixNanos,
625        position: Option<Position>,
626        percent_greeks: bool,
627        index_instrument_id: Option<InstrumentId>,
628        beta_weights: Option<&HashMap<InstrumentId, f64>>,
629    ) -> anyhow::Result<GreeksData> {
630        let multiplier = instrument.multiplier();
631        let underlying_instrument_id = instrument.id();
632        let underlying_price = self
633            .get_price(&underlying_instrument_id)
634            .ok_or_else(|| anyhow::anyhow!("No price available for {underlying_instrument_id}"))?;
635        let (delta, _, _) = self.modify_greeks(
636            1.0,
637            0.0,
638            underlying_instrument_id,
639            underlying_price + spot_shock,
640            underlying_price,
641            percent_greeks,
642            index_instrument_id,
643            beta_weights,
644            0.0,
645            0.0,
646            0,
647            None,
648            0.0,
649            None,
650            None,
651            None,
652            None,
653        )?;
654        let mut greeks_data =
655            GreeksData::from_delta(instrument_id, delta, multiplier.as_f64(), ts_event);
656
657        if let Some(pos) = position {
658            greeks_data.pnl = (underlying_price + spot_shock) - pos.avg_px_open.as_f64();
659            greeks_data.price = greeks_data.pnl;
660        }
661
662        Ok(greeks_data)
663    }
664
665    #[expect(clippy::too_many_arguments)]
666    fn calculate_option_greeks(
667        &self,
668        instrument: &InstrumentAny,
669        instrument_id: InstrumentId,
670        underlying_instrument_id: InstrumentId,
671        flat_interest_rate: f64,
672        flat_dividend_yield: Option<f64>,
673        use_cached_greeks: bool,
674        update_vol: bool,
675        ts_event: UnixNanos,
676        percent_greeks: bool,
677        index_instrument_id: Option<InstrumentId>,
678        beta_weights: Option<&HashMap<InstrumentId, f64>>,
679        vega_time_weight_base: Option<i32>,
680        vol_index_instrument_id: Option<InstrumentId>,
681        vol_beta_weights: Option<&HashMap<InstrumentId, f64>>,
682    ) -> anyhow::Result<(GreeksData, f64, bool)> {
683        if use_cached_greeks {
684            let cache = self.cache.borrow();
685            if let Some(cached_greeks) = cache.greeks(&instrument_id) {
686                check_option_greeks_data(&cached_greeks)?;
687                let db_dr = f64::from(u8::from(
688                    flat_dividend_yield.is_some()
689                        || cache
690                            .yield_curve(&underlying_instrument_id.to_string())
691                            .is_some(),
692                ));
693                return Ok((cached_greeks, db_dr, false));
694            }
695        }
696
697        let utc_now_ns = if ts_event == UnixNanos::default() {
698            self.clock.borrow().timestamp_ns()
699        } else {
700            ts_event
701        };
702
703        let utc_now = utc_now_ns.to_datetime_utc();
704        let expiry_utc = instrument
705            .expiration_ns()
706            .map(|ns| ns.to_datetime_utc())
707            .unwrap_or_default();
708        let expiry_int = expiry_utc
709            .strftime("%Y%m%d")
710            .to_string()
711            .parse::<i32>()
712            .unwrap_or(0);
713        let raw_days = utc_now.duration_until(expiry_utc).as_hours() / 24;
714        let expiry_in_days = raw_days.max(1) as i32;
715        let expiry_in_years = expiry_in_days as f64 / 365.25;
716        let currency = instrument.quote_currency().code.to_string();
717
718        let cache = self.cache.borrow();
719        let yield_curve = cache.yield_curve(&currency);
720
721        let interest_rate = match yield_curve {
722            Some(yield_curve) => yield_curve(expiry_in_years),
723            None => flat_interest_rate,
724        };
725
726        let dividend_curve = cache.yield_curve(&underlying_instrument_id.to_string());
727        drop(cache);
728
729        let (cost_of_carry, db_dr) = if let Some(dividend_curve) = dividend_curve {
730            (interest_rate - dividend_curve(expiry_in_years), 1.0)
731        } else if let Some(div_yield) = flat_dividend_yield {
732            (interest_rate - div_yield, 1.0)
733        } else {
734            (0.0, 0.0)
735        };
736
737        let multiplier = instrument.multiplier();
738        let is_call = instrument.option_kind().unwrap_or(OptionKind::Call) == OptionKind::Call;
739        let strike = instrument.strike_price().unwrap_or_default().as_f64();
740        let option_price = self
741            .get_price(&instrument_id)
742            .ok_or_else(|| anyhow::anyhow!("No price available for {instrument_id}"))?;
743        let underlying_price = self.get_underlying_price(&underlying_instrument_id)?;
744
745        if let Some(vol_index_id) = vol_index_instrument_id {
746            self.get_price(&vol_index_id)
747                .ok_or_else(|| anyhow::anyhow!("No price available for {vol_index_id}"))?;
748        }
749
750        let seed_vol = if update_vol {
751            self.cache
752                .borrow()
753                .greeks(&instrument_id)
754                .map(|greeks| greeks.vol)
755        } else {
756            None
757        };
758
759        let greeks = imply_vol_and_greeks_checked(
760            underlying_price,
761            interest_rate,
762            cost_of_carry,
763            is_call,
764            strike,
765            expiry_in_years,
766            option_price,
767            db_dr,
768            seed_vol,
769        )?;
770
771        let (delta, gamma, vega) = self.modify_greeks(
772            greeks.delta,
773            greeks.gamma,
774            underlying_instrument_id,
775            underlying_price,
776            underlying_price,
777            percent_greeks,
778            index_instrument_id,
779            beta_weights,
780            greeks.vega,
781            greeks.vol,
782            expiry_in_days,
783            vega_time_weight_base,
784            greeks.vol,
785            vol_index_instrument_id,
786            vol_beta_weights,
787            None,
788            None,
789        )?;
790
791        let greeks_data = GreeksData::new(
792            instrument_id,
793            is_call,
794            strike,
795            expiry_int,
796            expiry_in_days,
797            expiry_in_years,
798            multiplier.as_f64(),
799            1.0,
800            underlying_price,
801            interest_rate,
802            cost_of_carry,
803            greeks.vol,
804            0.0,
805            greeks.price,
806            OptionGreekValues {
807                delta,
808                gamma,
809                vega,
810                theta: greeks.theta,
811                rho: greeks.rho,
812            },
813            greeks.itm_prob,
814            utc_now_ns,
815            utc_now_ns,
816        );
817
818        check_option_greeks_data(&greeks_data)?;
819
820        Ok((greeks_data, db_dr, true))
821    }
822
823    #[expect(clippy::too_many_arguments)]
824    fn apply_option_greeks_shocks(
825        &self,
826        greeks_data: &GreeksData,
827        underlying_instrument_id: InstrumentId,
828        db_dr: f64,
829        spot_shock: f64,
830        vol_shock: f64,
831        time_to_expiry_shock: f64,
832        percent_greeks: bool,
833        index_instrument_id: Option<InstrumentId>,
834        beta_weights: Option<&HashMap<InstrumentId, f64>>,
835        vega_time_weight_base: Option<i32>,
836        vol_index_instrument_id: Option<InstrumentId>,
837        vol_beta_weights: Option<&HashMap<InstrumentId, f64>>,
838    ) -> anyhow::Result<GreeksData> {
839        let underlying_price = greeks_data.underlying_price;
840        let shocked_underlying_price = underlying_price + spot_shock;
841        let shocked_vol = greeks_data.vol + vol_shock;
842        let shocked_time_to_expiry = greeks_data.expiry_in_years - time_to_expiry_shock;
843        let shocked_expiry_in_days = (shocked_time_to_expiry * 365.25) as i32;
844
845        let greeks = black_scholes_greeks_checked(
846            shocked_underlying_price,
847            greeks_data.interest_rate,
848            greeks_data.cost_of_carry,
849            shocked_vol,
850            greeks_data.is_call,
851            greeks_data.strike,
852            shocked_time_to_expiry,
853            db_dr,
854        )?;
855        let (delta, gamma, vega) = self.modify_greeks(
856            greeks.delta,
857            greeks.gamma,
858            underlying_instrument_id,
859            shocked_underlying_price,
860            underlying_price,
861            percent_greeks,
862            index_instrument_id,
863            beta_weights,
864            greeks.vega,
865            shocked_vol,
866            shocked_expiry_in_days,
867            vega_time_weight_base,
868            greeks_data.vol,
869            vol_index_instrument_id,
870            vol_beta_weights,
871            None,
872            None,
873        )?;
874        Ok(GreeksData::new(
875            greeks_data.instrument_id,
876            greeks_data.is_call,
877            greeks_data.strike,
878            greeks_data.expiry,
879            shocked_expiry_in_days,
880            shocked_time_to_expiry,
881            greeks_data.multiplier,
882            greeks_data.quantity,
883            shocked_underlying_price,
884            greeks_data.interest_rate,
885            greeks_data.cost_of_carry,
886            shocked_vol,
887            0.0,
888            greeks.price,
889            OptionGreekValues {
890                delta,
891                gamma,
892                vega,
893                theta: greeks.theta,
894                rho: greeks.rho,
895            },
896            greeks.itm_prob,
897            greeks_data.ts_event,
898            greeks_data.ts_event,
899        ))
900    }
901
902    fn get_underlying_price(&self, underlying_instrument_id: &InstrumentId) -> anyhow::Result<f64> {
903        if let Some(underlying_price) = self.get_price(underlying_instrument_id) {
904            return Ok(underlying_price);
905        }
906
907        // Only fall back to cached futures spread when the underlying is a future
908        // (or absent from the cache, since the spread was explicitly cached).
909        let is_future_or_absent = {
910            let cache = self.cache.borrow();
911            cache
912                .instrument(underlying_instrument_id)
913                .is_none_or(|inst| inst.instrument_class() == InstrumentClass::Future)
914        };
915
916        if is_future_or_absent
917            && let Some(underlying_price) =
918                self.get_cached_futures_spread_price(*underlying_instrument_id)
919        {
920            return Ok(underlying_price.as_f64());
921        }
922
923        anyhow::bail!("No price available for {underlying_instrument_id}")
924    }
925
926    /// Modifies delta, gamma, and vega based on beta weighting and percentage calculations.
927    ///
928    /// The beta weighting of delta and gamma follows this equation linking the returns of a stock x to the ones of an index I:
929    /// (x - x0) / x0 = alpha + beta (I - I0) / I0 + epsilon
930    ///
931    /// beta can be obtained by linear regression of `stock_return` = alpha + beta `index_return`, it's equal to:
932    /// beta = Covariance(`stock_returns`, `index_returns`) / Variance(`index_returns`)
933    ///
934    /// Considering alpha == 0:
935    /// x = x0 + beta x0 / I0 (I-I0)
936    /// I = I0 + 1 / beta I0 / x0 (x - x0)
937    ///
938    /// These two last equations explain the beta weighting below, considering the price of an option is V(x) and delta and gamma
939    /// are the first and second derivatives respectively of V.
940    ///
941    /// Vega beta weighting follows the same change of variable with implied volatility and a volatility index.
942    ///
943    /// Also percent greeks assume a change of variable to percent returns by writing:
944    /// V(x = x0 * (1 + `stock_percent_return` / 100))
945    /// or V(I = I0 * (1 + `index_percent_return` / 100))
946    ///
947    /// # Errors
948    ///
949    /// Returns an error if `vol_index_instrument_id` is supplied and no explicit or cached
950    /// volatility index price is available.
951    #[expect(clippy::too_many_arguments)]
952    pub fn modify_greeks(
953        &self,
954        delta_input: f64,
955        gamma_input: f64,
956        underlying_instrument_id: InstrumentId,
957        underlying_price: f64,
958        unshocked_underlying_price: f64,
959        percent_greeks: bool,
960        index_instrument_id: Option<InstrumentId>,
961        beta_weights: Option<&HashMap<InstrumentId, f64>>,
962        vega_input: f64,
963        vol: f64,
964        expiry_in_days: i32,
965        vega_time_weight_base: Option<i32>,
966        unshocked_vol: f64,
967        vol_index_instrument_id: Option<InstrumentId>,
968        vol_beta_weights: Option<&HashMap<InstrumentId, f64>>,
969        index_price: Option<f64>,
970        vol_index_price: Option<f64>,
971    ) -> anyhow::Result<(f64, f64, f64)> {
972        let mut delta = delta_input;
973        let mut gamma = gamma_input;
974        let mut vega = vega_input;
975
976        let mut used_index_price = index_price
977            .or_else(|| index_instrument_id.and_then(|index_id| self.get_price(&index_id)));
978        let mut used_index_vol = vol_index_price;
979        if used_index_vol.is_none()
980            && let Some(vol_index_id) = vol_index_instrument_id
981        {
982            used_index_vol = Some(
983                self.get_price(&vol_index_id)
984                    .ok_or_else(|| anyhow::anyhow!("No price available for {vol_index_id}"))?,
985            );
986        }
987
988        if used_index_price.is_some() {
989            let mut beta = 1.0;
990            if let Some(weights) = beta_weights
991                && let Some(&weight) = weights.get(&underlying_instrument_id)
992            {
993                beta = weight;
994            }
995
996            if let Some(ref mut idx_price) = used_index_price {
997                #[expect(clippy::float_cmp, reason = "exact-equality baseline check")]
998                if underlying_price != unshocked_underlying_price {
999                    *idx_price += 1.0 / beta
1000                        * (*idx_price / unshocked_underlying_price)
1001                        * (underlying_price - unshocked_underlying_price);
1002                }
1003
1004                let delta_multiplier = beta * underlying_price / *idx_price;
1005                delta *= delta_multiplier;
1006                gamma *= delta_multiplier.powi(2);
1007            }
1008        }
1009
1010        if used_index_vol.is_some() {
1011            let mut vega_beta = 1.0;
1012
1013            let used_vol = if unshocked_vol == 0.0 {
1014                vol
1015            } else {
1016                unshocked_vol
1017            };
1018
1019            if let Some(weights) = vol_beta_weights
1020                && let Some(&weight) = weights.get(&underlying_instrument_id)
1021            {
1022                vega_beta = weight;
1023            }
1024
1025            if let Some(ref mut idx_vol) = used_index_vol {
1026                *idx_vol *= 0.01;
1027
1028                #[expect(clippy::float_cmp, reason = "exact-equality baseline check")]
1029                if vol != used_vol && used_vol != 0.0 {
1030                    *idx_vol += 1.0 / vega_beta * (*idx_vol / used_vol) * (vol - used_vol);
1031                }
1032
1033                if *idx_vol != 0.0 {
1034                    vega *= vega_beta * vol / *idx_vol;
1035                }
1036            }
1037        }
1038
1039        if percent_greeks {
1040            if let Some(idx_price) = used_index_price {
1041                delta *= idx_price / 100.0;
1042                gamma *= (idx_price / 100.0).powi(2);
1043            } else {
1044                delta *= underlying_price / 100.0;
1045                gamma *= (underlying_price / 100.0).powi(2);
1046            }
1047
1048            if let Some(idx_vol) = used_index_vol {
1049                vega *= idx_vol / 100.0;
1050            } else {
1051                vega *= vol / 100.0;
1052            }
1053        }
1054
1055        // Apply time weighting to vega if vega_time_weight_base is provided
1056        if let Some(time_base) = vega_time_weight_base
1057            && expiry_in_days > 0
1058        {
1059            let time_weight = (time_base as f64 / expiry_in_days as f64).sqrt();
1060            vega *= time_weight;
1061        }
1062
1063        Ok((delta, gamma, vega))
1064    }
1065
1066    /// Calculates the portfolio Greeks for a given set of positions.
1067    ///
1068    /// Aggregates the Greeks data for all open positions that match the specified criteria.
1069    ///
1070    /// Additional features:
1071    /// - Apply shocks to the spot value of an instrument's underlying, implied volatility, or time to expiry.
1072    /// - Compute percent greeks.
1073    /// - Compute beta-weighted delta, gamma, and vega with respect to an index.
1074    ///
1075    /// # Errors
1076    ///
1077    /// Returns an error if:
1078    /// - Any instrument valuation fails.
1079    /// - The aggregate is nonfinite.
1080    #[expect(clippy::too_many_arguments)]
1081    pub fn portfolio_greeks(
1082        &self,
1083        underlyings: Option<&[String]>,
1084        venue: Option<Venue>,
1085        instrument_id: Option<InstrumentId>,
1086        strategy_id: Option<StrategyId>,
1087        side: Option<PositionSide>,
1088        flat_interest_rate: Option<f64>,
1089        flat_dividend_yield: Option<f64>,
1090        spot_shock: Option<f64>,
1091        vol_shock: Option<f64>,
1092        time_to_expiry_shock: Option<f64>,
1093        use_cached_greeks: Option<bool>,
1094        update_vol: Option<bool>,
1095        cache_greeks: Option<bool>,
1096        publish_greeks: Option<bool>,
1097        percent_greeks: Option<bool>,
1098        index_instrument_id: Option<InstrumentId>,
1099        beta_weights: Option<&HashMap<InstrumentId, f64>>,
1100        greeks_filter: Option<&GreeksFilter>,
1101        vega_time_weight_base: Option<i32>,
1102        vol_index_instrument_id: Option<InstrumentId>,
1103        vol_beta_weights: Option<&HashMap<InstrumentId, f64>>,
1104    ) -> anyhow::Result<PortfolioGreeks> {
1105        let ts_event = self.clock.borrow().timestamp_ns();
1106        let mut portfolio_greeks =
1107            PortfolioGreeks::new(0.0, 0.0, 0.0, 0.0, 0.0, 0.0, ts_event, ts_event);
1108
1109        // Set default values
1110        let flat_interest_rate = flat_interest_rate.unwrap_or(0.0425);
1111        let spot_shock = spot_shock.unwrap_or(0.0);
1112        let vol_shock = vol_shock.unwrap_or(0.0);
1113        let time_to_expiry_shock = time_to_expiry_shock.unwrap_or(0.0);
1114        let use_cached_greeks = use_cached_greeks.unwrap_or(false);
1115        let update_vol = update_vol.unwrap_or(false);
1116        let cache_greeks = cache_greeks.unwrap_or(false);
1117        let publish_greeks = publish_greeks.unwrap_or(false);
1118        let percent_greeks = percent_greeks.unwrap_or(false);
1119
1120        // Release the cache borrow before calculations and callbacks can write to it
1121        let open_positions: Vec<Position> = {
1122            let cache = self.cache.borrow();
1123            cache
1124                .positions_open(
1125                    venue.as_ref(),
1126                    instrument_id.as_ref(),
1127                    strategy_id.as_ref(),
1128                    None, // account_id
1129                    side,
1130                )
1131                .iter()
1132                .map(PositionRef::cloned)
1133                .collect()
1134        };
1135
1136        for position in open_positions {
1137            let position_instrument_id = position.instrument_id;
1138
1139            if let Some(underlyings_list) = underlyings {
1140                let mut skip_position = true;
1141
1142                for underlying in underlyings_list {
1143                    if position_instrument_id
1144                        .symbol
1145                        .as_str()
1146                        .starts_with(underlying)
1147                    {
1148                        skip_position = false;
1149                        break;
1150                    }
1151                }
1152
1153                if skip_position {
1154                    continue;
1155                }
1156            }
1157
1158            let quantity = position.signed_qty.as_f64();
1159            let instrument_greeks = self.instrument_greeks(
1160                position_instrument_id,
1161                Some(flat_interest_rate),
1162                flat_dividend_yield,
1163                Some(spot_shock),
1164                Some(vol_shock),
1165                Some(time_to_expiry_shock),
1166                Some(use_cached_greeks),
1167                Some(update_vol),
1168                Some(cache_greeks),
1169                Some(publish_greeks),
1170                Some(ts_event),
1171                Some(position),
1172                Some(percent_greeks),
1173                index_instrument_id,
1174                beta_weights,
1175                vega_time_weight_base,
1176                vol_index_instrument_id,
1177                vol_beta_weights,
1178            )?;
1179            let position_portfolio_greeks = quantity * &instrument_greeks.to_portfolio_greeks();
1180
1181            let position_greeks = GreeksData {
1182                pnl: position_portfolio_greeks.pnl,
1183                price: position_portfolio_greeks.price,
1184                greeks: position_portfolio_greeks.greeks,
1185                ..instrument_greeks
1186            };
1187
1188            // Apply greeks filter if provided
1189            if greeks_filter.is_none_or(|filter| filter(&position_greeks)) {
1190                portfolio_greeks = portfolio_greeks + position_portfolio_greeks;
1191                check_greeks_values(
1192                    portfolio_greeks.pnl,
1193                    portfolio_greeks.price,
1194                    portfolio_greeks.greeks,
1195                )?;
1196            }
1197        }
1198
1199        Ok(portfolio_greeks)
1200    }
1201
1202    /// Cache a futures spread derived from a call/put pair against a reference future.
1203    ///
1204    /// # Errors
1205    ///
1206    /// Returns an error if instruments or prices are missing or inconsistent.
1207    pub fn cache_futures_spread(
1208        &self,
1209        call_instrument_id: InstrumentId,
1210        put_instrument_id: InstrumentId,
1211        futures_instrument_id: InstrumentId,
1212    ) -> anyhow::Result<Price> {
1213        let cache = self.cache.borrow();
1214        let call_instrument = cache.instrument(&call_instrument_id).cloned();
1215        let put_instrument = cache.instrument(&put_instrument_id).cloned();
1216        let reference_future_instrument = cache.instrument(&futures_instrument_id).cloned();
1217        drop(cache);
1218
1219        let Some(call_instrument) = call_instrument else {
1220            anyhow::bail!(
1221                "Cannot cache futures spread: missing option instrument {call_instrument_id}"
1222            );
1223        };
1224
1225        let Some(put_instrument) = put_instrument else {
1226            anyhow::bail!(
1227                "Cannot cache futures spread: missing option instrument {put_instrument_id}"
1228            );
1229        };
1230
1231        let Some(reference_future_instrument) = reference_future_instrument else {
1232            anyhow::bail!(
1233                "Cannot cache futures spread: no reference futures instrument for {futures_instrument_id}"
1234            );
1235        };
1236
1237        if call_instrument.instrument_class() != InstrumentClass::Option
1238            || put_instrument.instrument_class() != InstrumentClass::Option
1239        {
1240            anyhow::bail!(
1241                "Cannot cache futures spread: non-option instruments provided call_instrument_id={call_instrument_id} put_instrument_id={put_instrument_id}"
1242            );
1243        }
1244
1245        if call_instrument.option_kind() != Some(OptionKind::Call)
1246            || put_instrument.option_kind() != Some(OptionKind::Put)
1247        {
1248            anyhow::bail!(
1249                "Cannot cache futures spread: expected call/put pair call_instrument_id={call_instrument_id} put_instrument_id={put_instrument_id}"
1250            );
1251        }
1252
1253        let Some(call_underlying) = call_instrument.underlying() else {
1254            anyhow::bail!(
1255                "Cannot cache futures spread: missing call underlying for {call_instrument_id}"
1256            );
1257        };
1258
1259        let Some(put_underlying) = put_instrument.underlying() else {
1260            anyhow::bail!(
1261                "Cannot cache futures spread: missing put underlying for {put_instrument_id}"
1262            );
1263        };
1264
1265        if call_underlying != put_underlying {
1266            anyhow::bail!(
1267                "Cannot cache futures spread: option underlyings differ call_instrument_id={call_instrument_id} put_instrument_id={put_instrument_id}"
1268            );
1269        }
1270
1271        if call_instrument.strike_price() != put_instrument.strike_price() {
1272            anyhow::bail!(
1273                "Cannot cache futures spread: strike prices differ call_instrument_id={call_instrument_id} put_instrument_id={put_instrument_id}"
1274            );
1275        }
1276
1277        if call_instrument.expiration_ns() != put_instrument.expiration_ns() {
1278            anyhow::bail!(
1279                "Cannot cache futures spread: expiration dates differ call_instrument_id={call_instrument_id} put_instrument_id={put_instrument_id}"
1280            );
1281        }
1282
1283        let reference_future_price = self.get_price_object(&futures_instrument_id).ok_or_else(|| {
1284            anyhow::anyhow!(
1285                "Cannot cache futures spread: no reference futures price for {futures_instrument_id}"
1286            )
1287        })?;
1288
1289        let call_price = self.get_price(&call_instrument_id).ok_or_else(|| {
1290            anyhow::anyhow!(
1291                "Cannot cache futures spread: missing option price for {call_instrument_id}"
1292            )
1293        })?;
1294
1295        let put_price = self.get_price(&put_instrument_id).ok_or_else(|| {
1296            anyhow::anyhow!(
1297                "Cannot cache futures spread: missing option price for {put_instrument_id}"
1298            )
1299        })?;
1300
1301        let underlying_instrument_id =
1302            InstrumentId::from(format!("{call_underlying}.{}", call_instrument_id.venue));
1303
1304        // Reject if the underlying is present in cache but is not a future
1305        {
1306            let cache = self.cache.borrow();
1307            if let Some(underlying) = cache.instrument(&underlying_instrument_id)
1308                && underlying.instrument_class() != InstrumentClass::Future
1309            {
1310                anyhow::bail!(
1311                    "Cannot cache futures spread: underlying {underlying_instrument_id} is not a futures contract"
1312                );
1313            }
1314        }
1315
1316        let implied_future_price =
1317            self.calculate_implied_future_price(&call_instrument, call_price, put_price);
1318        let spread = implied_future_price - reference_future_price.as_f64();
1319        let spread_price = reference_future_instrument.make_price(spread);
1320
1321        self.cached_futures_spreads.borrow_mut().insert(
1322            underlying_instrument_id,
1323            (futures_instrument_id, spread_price),
1324        );
1325
1326        Ok(reference_future_price + spread_price)
1327    }
1328
1329    fn calculate_implied_future_price(
1330        &self,
1331        call_instrument: &InstrumentAny,
1332        call_price: f64,
1333        put_price: f64,
1334    ) -> f64 {
1335        let expiry_utc = call_instrument
1336            .expiration_ns()
1337            .map(|ns| ns.to_datetime_utc())
1338            .unwrap_or_default();
1339        let now = self.clock.borrow().timestamp_ns().to_datetime_utc();
1340        let expiry_in_days = (now.duration_until(expiry_utc).as_hours() / 24).max(1) as i32;
1341        let expiry_in_years = expiry_in_days as f64 / 365.25;
1342        let currency = call_instrument.quote_currency().code.to_string();
1343        let interest_rate = self
1344            .cache
1345            .borrow()
1346            .yield_curve(&currency)
1347            .map_or(0.0425, |yield_curve| yield_curve(expiry_in_years));
1348        let strike = call_instrument.strike_price().unwrap_or_default().as_f64();
1349
1350        strike + (interest_rate * expiry_in_years).exp() * (call_price - put_price)
1351    }
1352
1353    /// Resolve a cached futures spread price for an underlying future.
1354    #[must_use]
1355    pub fn get_cached_futures_spread_price(
1356        &self,
1357        underlying_instrument_id: InstrumentId,
1358    ) -> Option<Price> {
1359        let (futures_instrument_id, spread) = self
1360            .cached_futures_spreads
1361            .borrow()
1362            .get(&underlying_instrument_id)
1363            .copied()?;
1364        let reference_future_price = self.get_price_object(&futures_instrument_id)?;
1365
1366        Some(reference_future_price + spread)
1367    }
1368
1369    fn get_price_object(&self, instrument_id: &InstrumentId) -> Option<Price> {
1370        let cache = self.cache.borrow();
1371        let price = cache
1372            .price(instrument_id, PriceType::Mid)
1373            .or_else(|| cache.price(instrument_id, PriceType::Last));
1374
1375        // For index-class futures, prefer tradable quotes over the published index
1376        // price since index price is the spot level and may diverge from futures basis.
1377        // For true index instruments (non-futures), prefer the published index price.
1378        if let Some(instrument) = cache.instrument(instrument_id)
1379            && instrument.asset_class() == AssetClass::Index
1380        {
1381            if instrument.instrument_class() == InstrumentClass::Future && price.is_some() {
1382                return price;
1383            }
1384
1385            if let Some(index_price) = cache.index_price(instrument_id) {
1386                return Some(index_price.value);
1387            }
1388        }
1389
1390        price
1391    }
1392
1393    fn get_price(&self, instrument_id: &InstrumentId) -> Option<f64> {
1394        self.get_price_object(instrument_id)
1395            .map(|price| price.as_f64())
1396    }
1397
1398    /// Subscribes to Greeks data for options whose underlying symbol equals `underlying`.
1399    ///
1400    /// Pass `"*"` to receive Greeks for every underlying. Without a `handler`, the subscription
1401    /// adds received Greeks to the cache.
1402    pub fn subscribe_greeks<F>(&self, underlying: &str, handler: Option<F>)
1403    where
1404        F: Fn(&GreeksData) + 'static,
1405    {
1406        let pattern = greeks_topic(underlying, "*").into();
1407
1408        if let Some(custom_handler) = handler {
1409            let typed_handler = TypedHandler::from(custom_handler);
1410            msgbus::subscribe_greeks(pattern, typed_handler, None);
1411        } else {
1412            let cache_ref = self.cache.clone();
1413            let typed_handler = TypedHandler::from(move |greeks: &GreeksData| {
1414                if let Err(e) = cache_ref.borrow_mut().add_greeks(greeks.clone()) {
1415                    log::error!("Failed to add GreeksData to cache: {e}");
1416                }
1417            });
1418
1419            msgbus::subscribe_greeks(pattern, typed_handler, None);
1420        }
1421    }
1422}
1423
1424// Keyed by underlying symbol and full instrument ID so venues and symbol prefixes never collide
1425fn greeks_topic(underlying: &str, instrument_id: &str) -> String {
1426    format!("data.GreeksData.underlying={underlying}.instrument_id={instrument_id}")
1427}
1428
1429fn check_option_greeks_data(data: &GreeksData) -> Result<(), OptionValuationError> {
1430    check_option_inputs(
1431        data.underlying_price,
1432        data.interest_rate,
1433        data.cost_of_carry,
1434        data.vol,
1435        data.strike,
1436        data.expiry_in_years,
1437    )?;
1438    check_greeks_values(data.pnl, data.price, data.greeks)?;
1439    if !(0.0..=1.0).contains(&data.itm_prob)
1440        || data.price < 0.0
1441        || !data.multiplier.is_finite()
1442        || !data.quantity.is_finite()
1443    {
1444        return Err(OptionValuationError::InvalidResult);
1445    }
1446
1447    Ok(())
1448}
1449
1450fn check_greeks_values(
1451    pnl: f64,
1452    price: f64,
1453    greeks: OptionGreekValues,
1454) -> Result<(), OptionValuationError> {
1455    if [
1456        pnl,
1457        price,
1458        greeks.delta,
1459        greeks.gamma,
1460        greeks.vega,
1461        greeks.theta,
1462        greeks.rho,
1463    ]
1464    .iter()
1465    .any(|value| !value.is_finite())
1466    {
1467        return Err(OptionValuationError::InvalidResult);
1468    }
1469
1470    Ok(())
1471}
1472
1473#[cfg(test)]
1474mod tests {
1475    use std::{cell::RefCell, collections::HashMap, rc::Rc};
1476
1477    use jiff::{Timestamp, civil::Date, tz::Offset};
1478    use nautilus_model::{
1479        data::{IndexPriceUpdate, QuoteTick, YieldCurve},
1480        enums::{AssetClass, OmsType, OptionKind, OrderSide, PositionSide},
1481        events::order::spec::OrderFilledSpec,
1482        identifiers::{
1483            ClientOrderId, InstrumentId, PositionId, StrategyId, Symbol, TradeId, Venue,
1484        },
1485        instruments::{
1486            Equity, FuturesContract, OptionContract,
1487            any::InstrumentAny,
1488            stubs::{crypto_option_spread_btc_deribit, option_spread},
1489        },
1490        types::{Currency, Price, Quantity},
1491    };
1492    use rstest::rstest;
1493    use rust_decimal::Decimal;
1494    use ustr::Ustr;
1495
1496    use super::*;
1497    use crate::{cache::Cache, clock::VirtualClock};
1498
1499    fn utc_timestamp(year: i16, month: i8, day: i8, hour: i8, minute: i8, second: i8) -> Timestamp {
1500        Offset::UTC
1501            .to_timestamp(
1502                Date::new(year, month, day)
1503                    .unwrap()
1504                    .at(hour, minute, second, 0),
1505            )
1506            .unwrap()
1507    }
1508
1509    fn create_test_calculator() -> GreeksCalculator {
1510        let cache = Rc::new(RefCell::new(Cache::new(None, None)));
1511        let clock = Rc::new(RefCell::new(VirtualClock::new()));
1512        GreeksCalculator::new(cache, clock)
1513    }
1514
1515    #[rstest]
1516    fn test_greeks_calculator_debug() {
1517        let calculator = create_test_calculator();
1518
1519        let debug_str = format!("{calculator:?}");
1520
1521        assert!(debug_str.starts_with("GreeksCalculator {"), "{debug_str}");
1522        assert!(debug_str.contains("cache:"), "{debug_str}");
1523        assert!(debug_str.contains("clock:"), "{debug_str}");
1524        assert!(
1525            debug_str.contains("cached_futures_spreads: RefCell { value: {} }"),
1526            "{debug_str}"
1527        );
1528        assert!(
1529            debug_str.contains("cross_venue_underlyings: RefCell { value: {} }"),
1530            "{debug_str}"
1531        );
1532    }
1533
1534    #[rstest]
1535    fn test_instrument_greeks_params_builder_default() {
1536        let instrument_id = InstrumentId::from("AAPL.NASDAQ");
1537
1538        let params = InstrumentGreeksParams::builder()
1539            .instrument_id(instrument_id)
1540            .build();
1541
1542        assert_eq!(params.instrument_id, instrument_id);
1543        assert_eq!(params.flat_interest_rate, 0.0425);
1544        assert_eq!(params.flat_dividend_yield, None);
1545        assert_eq!(params.spot_shock, 0.0);
1546        assert_eq!(params.vol_shock, 0.0);
1547        assert_eq!(params.time_to_expiry_shock, 0.0);
1548        assert!(!params.use_cached_greeks);
1549        assert!(!params.cache_greeks);
1550        assert!(!params.publish_greeks);
1551        assert_eq!(params.ts_event, None);
1552        assert_eq!(params.position, None);
1553        assert!(!params.percent_greeks);
1554        assert_eq!(params.index_instrument_id, None);
1555        assert_eq!(params.beta_weights, None);
1556        assert_eq!(params.vol_index_instrument_id, None);
1557        assert_eq!(params.vol_beta_weights, None);
1558    }
1559
1560    #[rstest]
1561    fn test_instrument_greeks_params_builder_custom_values() {
1562        let instrument_id = InstrumentId::from("AAPL.NASDAQ");
1563        let index_id = InstrumentId::from("SPY.NASDAQ");
1564        let vol_index_id = InstrumentId::from("VIX.XCBF");
1565        let mut beta_weights = HashMap::new();
1566        beta_weights.insert(instrument_id, 1.2);
1567        let mut vol_beta_weights = HashMap::new();
1568        vol_beta_weights.insert(instrument_id, 0.8);
1569
1570        let params = InstrumentGreeksParams::builder()
1571            .instrument_id(instrument_id)
1572            .flat_interest_rate(0.05)
1573            .flat_dividend_yield(0.02)
1574            .spot_shock(0.01)
1575            .vol_shock(0.05)
1576            .time_to_expiry_shock(0.1)
1577            .use_cached_greeks(true)
1578            .cache_greeks(true)
1579            .publish_greeks(true)
1580            .percent_greeks(true)
1581            .index_instrument_id(index_id)
1582            .beta_weights(beta_weights.clone())
1583            .vol_index_instrument_id(vol_index_id)
1584            .vol_beta_weights(vol_beta_weights.clone())
1585            .build();
1586
1587        assert_eq!(params.instrument_id, instrument_id);
1588        assert_eq!(params.flat_interest_rate, 0.05);
1589        assert_eq!(params.flat_dividend_yield, Some(0.02));
1590        assert_eq!(params.spot_shock, 0.01);
1591        assert_eq!(params.vol_shock, 0.05);
1592        assert_eq!(params.time_to_expiry_shock, 0.1);
1593        assert!(params.use_cached_greeks);
1594        assert!(params.cache_greeks);
1595        assert!(params.publish_greeks);
1596        assert!(params.percent_greeks);
1597        assert_eq!(params.index_instrument_id, Some(index_id));
1598        assert_eq!(params.beta_weights, Some(beta_weights));
1599        assert_eq!(params.vol_index_instrument_id, Some(vol_index_id));
1600        assert_eq!(params.vol_beta_weights, Some(vol_beta_weights));
1601    }
1602
1603    #[rstest]
1604    fn test_instrument_greeks_params_debug() {
1605        let instrument_id = InstrumentId::from("AAPL.NASDAQ");
1606
1607        let params = InstrumentGreeksParams::builder()
1608            .instrument_id(instrument_id)
1609            .build();
1610
1611        let debug_str = format!("{params:?}");
1612        assert!(debug_str.contains("InstrumentGreeksParams"));
1613        assert!(debug_str.contains("AAPL.NASDAQ"));
1614    }
1615
1616    #[rstest]
1617    fn test_portfolio_greeks_params_builder_default() {
1618        let params = PortfolioGreeksParams::builder().build();
1619
1620        assert_eq!(params.underlyings, None);
1621        assert_eq!(params.venue, None);
1622        assert_eq!(params.instrument_id, None);
1623        assert_eq!(params.strategy_id, None);
1624        assert_eq!(params.side, None);
1625        assert_eq!(params.flat_interest_rate, 0.0425);
1626        assert_eq!(params.flat_dividend_yield, None);
1627        assert_eq!(params.spot_shock, 0.0);
1628        assert_eq!(params.vol_shock, 0.0);
1629        assert_eq!(params.time_to_expiry_shock, 0.0);
1630        assert!(!params.use_cached_greeks);
1631        assert!(!params.cache_greeks);
1632        assert!(!params.publish_greeks);
1633        assert!(!params.percent_greeks);
1634        assert_eq!(params.index_instrument_id, None);
1635        assert_eq!(params.beta_weights, None);
1636        assert_eq!(params.vol_index_instrument_id, None);
1637        assert_eq!(params.vol_beta_weights, None);
1638    }
1639
1640    #[rstest]
1641    fn test_portfolio_greeks_params_builder_custom_values() {
1642        let venue = Venue::from("NASDAQ");
1643        let instrument_id = InstrumentId::from("AAPL.NASDAQ");
1644        let strategy_id = StrategyId::from("test-strategy");
1645        let index_id = InstrumentId::from("SPY.NASDAQ");
1646        let vol_index_id = InstrumentId::from("VIX.XCBF");
1647        let underlyings = vec!["AAPL".to_string(), "MSFT".to_string()];
1648        let mut beta_weights = HashMap::new();
1649        beta_weights.insert(instrument_id, 1.2);
1650        let mut vol_beta_weights = HashMap::new();
1651        vol_beta_weights.insert(instrument_id, 0.8);
1652
1653        let params = PortfolioGreeksParams::builder()
1654            .underlyings(underlyings.clone())
1655            .venue(venue)
1656            .instrument_id(instrument_id)
1657            .strategy_id(strategy_id)
1658            .side(PositionSide::Long)
1659            .flat_interest_rate(0.05)
1660            .flat_dividend_yield(0.02)
1661            .spot_shock(0.01)
1662            .vol_shock(0.05)
1663            .time_to_expiry_shock(0.1)
1664            .use_cached_greeks(true)
1665            .cache_greeks(true)
1666            .publish_greeks(true)
1667            .percent_greeks(true)
1668            .index_instrument_id(index_id)
1669            .beta_weights(beta_weights.clone())
1670            .vol_index_instrument_id(vol_index_id)
1671            .vol_beta_weights(vol_beta_weights.clone())
1672            .build();
1673
1674        assert_eq!(params.underlyings, Some(underlyings));
1675        assert_eq!(params.venue, Some(venue));
1676        assert_eq!(params.instrument_id, Some(instrument_id));
1677        assert_eq!(params.strategy_id, Some(strategy_id));
1678        assert_eq!(params.side, Some(PositionSide::Long));
1679        assert_eq!(params.flat_interest_rate, 0.05);
1680        assert_eq!(params.flat_dividend_yield, Some(0.02));
1681        assert_eq!(params.spot_shock, 0.01);
1682        assert_eq!(params.vol_shock, 0.05);
1683        assert_eq!(params.time_to_expiry_shock, 0.1);
1684        assert!(params.use_cached_greeks);
1685        assert!(params.cache_greeks);
1686        assert!(params.publish_greeks);
1687        assert!(params.percent_greeks);
1688        assert_eq!(params.index_instrument_id, Some(index_id));
1689        assert_eq!(params.beta_weights, Some(beta_weights));
1690        assert_eq!(params.vol_index_instrument_id, Some(vol_index_id));
1691        assert_eq!(params.vol_beta_weights, Some(vol_beta_weights));
1692    }
1693
1694    #[rstest]
1695    fn test_portfolio_greeks_params_debug() {
1696        let venue = Venue::from("NASDAQ");
1697
1698        let params = PortfolioGreeksParams::builder().venue(venue).build();
1699
1700        let debug_str = format!("{params:?}");
1701        assert!(debug_str.contains("PortfolioGreeksParams"));
1702        assert!(debug_str.contains("NASDAQ"));
1703    }
1704
1705    #[rstest]
1706    fn test_portfolio_greeks_params_builder_fluent_api() {
1707        let instrument_id = InstrumentId::from("AAPL.NASDAQ");
1708
1709        let params = PortfolioGreeksParams::builder()
1710            .instrument_id(instrument_id)
1711            .flat_interest_rate(0.05)
1712            .spot_shock(0.01)
1713            .percent_greeks(true)
1714            .build();
1715
1716        assert_eq!(params.instrument_id, Some(instrument_id));
1717        assert_eq!(params.flat_interest_rate, 0.05);
1718        assert_eq!(params.spot_shock, 0.01);
1719        assert!(params.percent_greeks);
1720    }
1721
1722    #[rstest]
1723    fn test_instrument_greeks_params_builder_fluent_chaining() {
1724        let instrument_id = InstrumentId::from("TSLA.NASDAQ");
1725
1726        // Test fluent API chaining
1727        let params = InstrumentGreeksParams::builder()
1728            .instrument_id(instrument_id)
1729            .flat_interest_rate(0.03)
1730            .spot_shock(0.02)
1731            .vol_shock(0.1)
1732            .use_cached_greeks(true)
1733            .percent_greeks(true)
1734            .build();
1735
1736        assert_eq!(params.instrument_id, instrument_id);
1737        assert_eq!(params.flat_interest_rate, 0.03);
1738        assert_eq!(params.spot_shock, 0.02);
1739        assert_eq!(params.vol_shock, 0.1);
1740        assert!(params.use_cached_greeks);
1741        assert!(params.percent_greeks);
1742    }
1743
1744    #[rstest]
1745    fn test_portfolio_greeks_params_builder_with_underlyings() {
1746        let underlyings = vec!["AAPL".to_string(), "MSFT".to_string(), "GOOGL".to_string()];
1747
1748        let params = PortfolioGreeksParams::builder()
1749            .underlyings(underlyings.clone())
1750            .flat_interest_rate(0.04)
1751            .build();
1752
1753        assert_eq!(params.underlyings, Some(underlyings));
1754        assert_eq!(params.flat_interest_rate, 0.04);
1755    }
1756
1757    #[rstest]
1758    fn test_builders_with_empty_beta_weights() {
1759        let instrument_id = InstrumentId::from("NVDA.NASDAQ");
1760        let empty_beta_weights = HashMap::new();
1761
1762        let instrument_params = InstrumentGreeksParams::builder()
1763            .instrument_id(instrument_id)
1764            .beta_weights(empty_beta_weights.clone())
1765            .vol_beta_weights(empty_beta_weights.clone())
1766            .build();
1767
1768        let portfolio_params = PortfolioGreeksParams::builder()
1769            .beta_weights(empty_beta_weights.clone())
1770            .vol_beta_weights(empty_beta_weights.clone())
1771            .build();
1772
1773        assert_eq!(
1774            instrument_params.beta_weights,
1775            Some(empty_beta_weights.clone())
1776        );
1777        assert_eq!(portfolio_params.beta_weights, Some(empty_beta_weights));
1778        assert_eq!(instrument_params.vol_beta_weights, Some(HashMap::new()));
1779        assert_eq!(portfolio_params.vol_beta_weights, Some(HashMap::new()));
1780    }
1781
1782    #[rstest]
1783    fn test_builders_with_all_shocks() {
1784        let instrument_id = InstrumentId::from("AMD.NASDAQ");
1785
1786        let instrument_params = InstrumentGreeksParams::builder()
1787            .instrument_id(instrument_id)
1788            .spot_shock(0.05)
1789            .vol_shock(0.1)
1790            .time_to_expiry_shock(0.01)
1791            .build();
1792
1793        let portfolio_params = PortfolioGreeksParams::builder()
1794            .spot_shock(0.05)
1795            .vol_shock(0.1)
1796            .time_to_expiry_shock(0.01)
1797            .build();
1798
1799        assert_eq!(instrument_params.spot_shock, 0.05);
1800        assert_eq!(instrument_params.vol_shock, 0.1);
1801        assert_eq!(instrument_params.time_to_expiry_shock, 0.01);
1802
1803        assert_eq!(portfolio_params.spot_shock, 0.05);
1804        assert_eq!(portfolio_params.vol_shock, 0.1);
1805        assert_eq!(portfolio_params.time_to_expiry_shock, 0.01);
1806    }
1807
1808    #[rstest]
1809    fn test_builders_with_all_boolean_flags() {
1810        let instrument_id = InstrumentId::from("META.NASDAQ");
1811
1812        let instrument_params = InstrumentGreeksParams::builder()
1813            .instrument_id(instrument_id)
1814            .use_cached_greeks(true)
1815            .cache_greeks(true)
1816            .publish_greeks(true)
1817            .percent_greeks(true)
1818            .build();
1819
1820        let portfolio_params = PortfolioGreeksParams::builder()
1821            .use_cached_greeks(true)
1822            .cache_greeks(true)
1823            .publish_greeks(true)
1824            .percent_greeks(true)
1825            .build();
1826
1827        assert!(instrument_params.use_cached_greeks);
1828        assert!(instrument_params.cache_greeks);
1829        assert!(instrument_params.publish_greeks);
1830        assert!(instrument_params.percent_greeks);
1831
1832        assert!(portfolio_params.use_cached_greeks);
1833        assert!(portfolio_params.cache_greeks);
1834        assert!(portfolio_params.publish_greeks);
1835        assert!(portfolio_params.percent_greeks);
1836    }
1837
1838    #[rstest]
1839    fn test_greeks_filter_callback_function() {
1840        // Test function pointer filter
1841        fn filter_positive_delta(data: &GreeksData) -> bool {
1842            data.delta > 0.0
1843        }
1844
1845        let filter = GreeksFilterCallback::from_fn(filter_positive_delta);
1846
1847        // Create test data
1848        let greeks_data = GreeksData::from_delta(
1849            InstrumentId::from("TEST.NASDAQ"),
1850            0.5,
1851            1.0,
1852            UnixNanos::default(),
1853        );
1854
1855        assert!(filter.call(&greeks_data));
1856
1857        // Test debug formatting
1858        let debug_str = format!("{filter:?}");
1859        assert!(debug_str.contains("GreeksFilterCallback::Function"));
1860    }
1861
1862    #[rstest]
1863    fn test_greeks_filter_callback_closure() {
1864        // Test closure filter that captures a variable
1865        let min_delta = 0.3;
1866        let filter =
1867            GreeksFilterCallback::from_closure(move |data: &GreeksData| data.delta > min_delta);
1868
1869        // Create test data
1870        let greeks_data = GreeksData::from_delta(
1871            InstrumentId::from("TEST.NASDAQ"),
1872            0.5,
1873            1.0,
1874            UnixNanos::default(),
1875        );
1876
1877        assert!(filter.call(&greeks_data));
1878
1879        // Test debug formatting
1880        let debug_str = format!("{filter:?}");
1881        assert!(debug_str.contains("GreeksFilterCallback::Closure"));
1882    }
1883
1884    #[rstest]
1885    fn test_greeks_filter_callback_clone() {
1886        fn filter_fn(data: &GreeksData) -> bool {
1887            data.delta > 0.0
1888        }
1889
1890        let filter1 = GreeksFilterCallback::from_fn(filter_fn);
1891        let filter2 = filter1.clone();
1892
1893        let greeks_data = GreeksData::from_delta(
1894            InstrumentId::from("TEST.NASDAQ"),
1895            0.5,
1896            1.0,
1897            UnixNanos::default(),
1898        );
1899
1900        assert!(filter1.call(&greeks_data));
1901        assert!(filter2.call(&greeks_data));
1902    }
1903
1904    #[rstest]
1905    fn test_portfolio_greeks_params_with_filter() {
1906        fn filter_high_delta(data: &GreeksData) -> bool {
1907            data.delta.abs() > 0.1
1908        }
1909
1910        let filter = GreeksFilterCallback::from_fn(filter_high_delta);
1911
1912        let params = PortfolioGreeksParams::builder()
1913            .greeks_filter(filter)
1914            .flat_interest_rate(0.05)
1915            .build();
1916
1917        assert!(params.greeks_filter.is_some());
1918        assert_eq!(params.flat_interest_rate, 0.05);
1919
1920        // Test that the filter can be called
1921        let greeks_data = GreeksData::from_delta(
1922            InstrumentId::from("TEST.NASDAQ"),
1923            0.5,
1924            1.0,
1925            UnixNanos::default(),
1926        );
1927
1928        let filter_ref = params.greeks_filter.as_ref().unwrap();
1929        assert!(filter_ref.call(&greeks_data));
1930    }
1931
1932    #[rstest]
1933    fn test_portfolio_greeks_params_with_closure_filter() {
1934        let min_gamma = 0.01;
1935        let filter =
1936            GreeksFilterCallback::from_closure(move |data: &GreeksData| data.gamma > min_gamma);
1937
1938        let params = PortfolioGreeksParams::builder()
1939            .greeks_filter(filter)
1940            .build();
1941
1942        assert!(params.greeks_filter.is_some());
1943
1944        // Test debug formatting includes the filter
1945        let debug_str = format!("{params:?}");
1946        assert!(debug_str.contains("greeks_filter"));
1947    }
1948
1949    #[rstest]
1950    fn test_greeks_filter_to_greeks_filter_conversion() {
1951        fn filter_fn(data: &GreeksData) -> bool {
1952            data.delta > 0.0
1953        }
1954
1955        let callback = GreeksFilterCallback::from_fn(filter_fn);
1956        let greeks_filter = callback.to_greeks_filter();
1957
1958        let greeks_data = GreeksData::from_delta(
1959            InstrumentId::from("TEST.NASDAQ"),
1960            0.5,
1961            1.0,
1962            UnixNanos::default(),
1963        );
1964
1965        assert!(greeks_filter(&greeks_data));
1966    }
1967
1968    fn option_with_expiration(instrument_id: &str, expiration_ns: UnixNanos) -> OptionContract {
1969        let activation_ns = UnixNanos::from(utc_timestamp(2021, 9, 17, 0, 0, 0));
1970        OptionContract::builder()
1971            .instrument_id(InstrumentId::from(instrument_id))
1972            .raw_symbol(Symbol::from("AAPL211217C00150000"))
1973            .asset_class(AssetClass::Equity)
1974            .exchange(Ustr::from("GMNI"))
1975            .underlying(Ustr::from("AAPL"))
1976            .option_kind(OptionKind::Call)
1977            .strike_price(Price::from("149.0"))
1978            .currency(Currency::from("USD"))
1979            .activation_ns(activation_ns)
1980            .expiration_ns(expiration_ns)
1981            .price_precision(2)
1982            .price_increment(Price::from("0.01"))
1983            .multiplier(Quantity::from(100))
1984            .lot_size(Quantity::from(1))
1985            .ts_event(UnixNanos::default())
1986            .ts_init(UnixNanos::default())
1987            .build()
1988            .unwrap()
1989    }
1990
1991    fn equity_aapl_opra() -> Equity {
1992        Equity::builder()
1993            .instrument_id(InstrumentId::from("AAPL.OPRA"))
1994            .raw_symbol(Symbol::from("AAPL"))
1995            .isin(Ustr::from("US0378331005"))
1996            .currency(Currency::from("USD"))
1997            .price_precision(2)
1998            .price_increment(Price::from("0.01"))
1999            .ts_event(UnixNanos::default())
2000            .ts_init(UnixNanos::default())
2001            .build()
2002            .unwrap()
2003    }
2004
2005    #[rstest]
2006    fn test_resolve_underlying_instrument_id_errors_without_underlying() {
2007        let instrument = InstrumentAny::Equity(equity_aapl_opra());
2008        let error = create_test_calculator()
2009            .resolve_underlying_instrument_id(&instrument, InstrumentId::from("AAPL.OPRA"))
2010            .unwrap_err();
2011
2012        assert_eq!(
2013            error.to_string(),
2014            "Instrument AAPL.OPRA has no underlying identifier"
2015        );
2016    }
2017
2018    fn equity_with_id(instrument_id: &str) -> Equity {
2019        let instrument_id = InstrumentId::from(instrument_id);
2020        Equity::builder()
2021            .instrument_id(instrument_id)
2022            .raw_symbol(instrument_id.symbol)
2023            .currency(Currency::from("USD"))
2024            .price_precision(2)
2025            .price_increment(Price::from("0.01"))
2026            .ts_event(UnixNanos::default())
2027            .ts_init(UnixNanos::default())
2028            .build()
2029            .unwrap()
2030    }
2031
2032    fn calculator_with_option_and_equities(
2033        option: OptionContract,
2034        equity_ids: &[&str],
2035    ) -> GreeksCalculator {
2036        let cache = Rc::new(RefCell::new(Cache::new(None, None)));
2037        cache
2038            .borrow_mut()
2039            .add_instrument(InstrumentAny::OptionContract(option))
2040            .unwrap();
2041
2042        for equity_id in equity_ids {
2043            cache
2044                .borrow_mut()
2045                .add_instrument(InstrumentAny::Equity(equity_with_id(equity_id)))
2046                .unwrap();
2047        }
2048
2049        let clock = Rc::new(RefCell::new(VirtualClock::new()));
2050        GreeksCalculator::new(cache, clock)
2051    }
2052
2053    #[rstest]
2054    #[case::same_venue_preferred(&["AAPL.XNAS", "AAPL.OPRA", "AAPL.ARCX"], "AAPL.OPRA")]
2055    #[case::unique_cross_venue_match(&["AAPL.XNAS", "MSFT.XNAS"], "AAPL.XNAS")]
2056    #[case::no_match_keeps_option_venue(&["MSFT.XNAS"], "AAPL.OPRA")]
2057    fn test_resolve_underlying_instrument_id(#[case] equity_ids: &[&str], #[case] expected: &str) {
2058        let option = option_with_expiration("AAPL250417C00150000.OPRA", UnixNanos::default());
2059        let option_id = option.id();
2060        let instrument = InstrumentAny::OptionContract(option.clone());
2061        let calculator = calculator_with_option_and_equities(option, equity_ids);
2062
2063        let underlying_id = calculator
2064            .resolve_underlying_instrument_id(&instrument, option_id)
2065            .unwrap();
2066
2067        assert_eq!(underlying_id, InstrumentId::from(expected));
2068    }
2069
2070    #[rstest]
2071    fn test_resolve_underlying_instrument_id_errors_when_cross_venue_match_is_ambiguous() {
2072        let option = option_with_expiration("AAPL250417C00150000.OPRA", UnixNanos::default());
2073        let option_id = option.id();
2074        let instrument = InstrumentAny::OptionContract(option.clone());
2075        let calculator = calculator_with_option_and_equities(option, &["AAPL.XNAS", "AAPL.ARCX"]);
2076
2077        let error = calculator
2078            .resolve_underlying_instrument_id(&instrument, option_id)
2079            .unwrap_err();
2080
2081        assert_eq!(
2082            error.to_string(),
2083            "Ambiguous underlying for option AAPL250417C00150000.OPRA: AAPL.ARCX, AAPL.XNAS"
2084        );
2085    }
2086
2087    #[rstest]
2088    fn test_resolve_underlying_instrument_id_reuses_cross_venue_match() {
2089        let option = option_with_expiration("AAPL250417C00150000.OPRA", UnixNanos::default());
2090        let option_id = option.id();
2091        let instrument = InstrumentAny::OptionContract(option.clone());
2092        let calculator = calculator_with_option_and_equities(option, &["AAPL.XNAS"]);
2093        let first = calculator
2094            .resolve_underlying_instrument_id(&instrument, option_id)
2095            .unwrap();
2096
2097        calculator
2098            .cache
2099            .borrow_mut()
2100            .add_instrument(InstrumentAny::Equity(equity_with_id("AAPL.ARCX")))
2101            .unwrap();
2102        let second = calculator
2103            .resolve_underlying_instrument_id(&instrument, option_id)
2104            .unwrap();
2105
2106        assert_eq!(first, InstrumentId::from("AAPL.XNAS"));
2107        assert_eq!(second, InstrumentId::from("AAPL.XNAS"));
2108    }
2109
2110    #[rstest]
2111    fn test_resolve_underlying_instrument_id_rescans_after_match_is_purged() {
2112        let option = option_with_expiration("AAPL250417C00150000.OPRA", UnixNanos::default());
2113        let option_id = option.id();
2114        let instrument = InstrumentAny::OptionContract(option.clone());
2115        let calculator = calculator_with_option_and_equities(option, &["AAPL.XNAS"]);
2116        calculator
2117            .resolve_underlying_instrument_id(&instrument, option_id)
2118            .unwrap();
2119
2120        {
2121            let mut cache = calculator.cache.borrow_mut();
2122            cache
2123                .add_instrument(InstrumentAny::Equity(equity_with_id("AAPL.ARCX")))
2124                .unwrap();
2125            cache.purge_instrument(InstrumentId::from("AAPL.XNAS"));
2126        }
2127
2128        let underlying_id = calculator
2129            .resolve_underlying_instrument_id(&instrument, option_id)
2130            .unwrap();
2131
2132        assert_eq!(underlying_id, InstrumentId::from("AAPL.ARCX"));
2133    }
2134
2135    #[rstest]
2136    #[case::no_underlying_cached(&[])]
2137    #[case::same_venue_cached(&["AAPL.OPRA"])]
2138    fn test_resolve_underlying_instrument_id_forgets_match_once_purged(
2139        #[case] cached_while_purged: &[&str],
2140    ) {
2141        let option = option_with_expiration("AAPL250417C00150000.OPRA", UnixNanos::default());
2142        let option_id = option.id();
2143        let instrument = InstrumentAny::OptionContract(option.clone());
2144        let calculator = calculator_with_option_and_equities(option, &["AAPL.XNAS"]);
2145        calculator
2146            .resolve_underlying_instrument_id(&instrument, option_id)
2147            .unwrap();
2148
2149        {
2150            let mut cache = calculator.cache.borrow_mut();
2151            cache.purge_instrument(InstrumentId::from("AAPL.XNAS"));
2152            for equity_id in cached_while_purged {
2153                cache
2154                    .add_instrument(InstrumentAny::Equity(equity_with_id(equity_id)))
2155                    .unwrap();
2156            }
2157        }
2158
2159        calculator
2160            .resolve_underlying_instrument_id(&instrument, option_id)
2161            .unwrap();
2162
2163        {
2164            let mut cache = calculator.cache.borrow_mut();
2165            for equity_id in cached_while_purged {
2166                cache.purge_instrument(InstrumentId::from(*equity_id));
2167            }
2168
2169            for equity_id in ["AAPL.XNAS", "AAPL.ARCX"] {
2170                cache
2171                    .add_instrument(InstrumentAny::Equity(equity_with_id(equity_id)))
2172                    .unwrap();
2173            }
2174        }
2175
2176        let error = calculator
2177            .resolve_underlying_instrument_id(&instrument, option_id)
2178            .unwrap_err();
2179
2180        assert_eq!(
2181            error.to_string(),
2182            "Ambiguous underlying for option AAPL250417C00150000.OPRA: AAPL.ARCX, AAPL.XNAS"
2183        );
2184    }
2185
2186    #[rstest]
2187    fn test_instrument_greeks_reads_cached_greeks_when_underlying_is_ambiguous() {
2188        let option =
2189            option_with_expiration("AAPL250417C00150000.OPRA", UnixNanos::from(1_000_000_000));
2190        let option_id = option.id();
2191        let position = position_from_fill(
2192            &InstrumentAny::OptionContract(option.clone()),
2193            "P-GREEKS-1",
2194            "O-GREEKS-1",
2195            "T-GREEKS-1",
2196            OrderSide::Buy,
2197            1,
2198            "9.00",
2199        );
2200        let calculator = calculator_with_option_and_equities(option, &["AAPL.XNAS", "AAPL.ARCX"]);
2201        let mut cached_greeks = GreeksData::from_delta(option_id, 0.5, 100.0, UnixNanos::default());
2202        cached_greeks.price = 10.5;
2203        cached_greeks.underlying_price = 150.0;
2204        cached_greeks.strike = 150.0;
2205        cached_greeks.vol = 0.2;
2206        cached_greeks.expiry_in_years = 1.0;
2207        calculator
2208            .cache
2209            .borrow_mut()
2210            .add_greeks(cached_greeks.clone())
2211            .unwrap();
2212
2213        let greeks = calculator
2214            .instrument_greeks(
2215                option_id,
2216                None,
2217                None,
2218                None,
2219                None,
2220                None,
2221                Some(true),
2222                None,
2223                None,
2224                None,
2225                None,
2226                Some(position),
2227                None,
2228                None,
2229                None,
2230                None,
2231                None,
2232                None,
2233            )
2234            .unwrap();
2235
2236        assert_eq!(greeks.instrument_id, option_id);
2237        assert_eq!(greeks.greeks, cached_greeks.greeks);
2238        assert_eq!(greeks.pnl, 1.5);
2239    }
2240
2241    #[rstest]
2242    fn test_instrument_greeks_prices_underlying_on_another_venue() {
2243        let now = utc_timestamp(2025, 3, 8, 12, 0, 0);
2244        let expiry = now + jiff::SignedDuration::from_hours(24 * 30);
2245        let now_ns = UnixNanos::from(now);
2246        let expiry_ns = UnixNanos::from(expiry);
2247        let option = option_with_expiration("AAPL250417C00150000.OPRA", expiry_ns);
2248        let option_id = option.id();
2249        let same_venue_cache = setup_cache_with_option_and_quotes(
2250            option.clone(),
2251            InstrumentId::from("AAPL.OPRA"),
2252            now_ns,
2253        );
2254        let cross_venue_cache = Rc::new(RefCell::new(Cache::new(None, None)));
2255        cross_venue_cache
2256            .borrow_mut()
2257            .add_instrument(InstrumentAny::OptionContract(option))
2258            .unwrap();
2259        cross_venue_cache
2260            .borrow_mut()
2261            .add_instrument(InstrumentAny::Equity(equity_with_id("AAPL.XNAS")))
2262            .unwrap();
2263
2264        for (instrument_id, bid, ask) in [
2265            (option_id, "10.50", "10.60"),
2266            (InstrumentId::from("AAPL.XNAS"), "150.00", "150.10"),
2267        ] {
2268            cross_venue_cache
2269                .borrow_mut()
2270                .add_quote(QuoteTick::new(
2271                    instrument_id,
2272                    Price::from(bid),
2273                    Price::from(ask),
2274                    Quantity::from(100),
2275                    Quantity::from(100),
2276                    now_ns,
2277                    now_ns,
2278                ))
2279                .unwrap();
2280        }
2281
2282        let greeks_for = |cache: Rc<RefCell<Cache>>| {
2283            let clock = Rc::new(RefCell::new(VirtualClock::new()));
2284            GreeksCalculator::new(cache, clock)
2285                .instrument_greeks(
2286                    option_id,
2287                    None,
2288                    None,
2289                    None,
2290                    None,
2291                    None,
2292                    None,
2293                    None,
2294                    None,
2295                    None,
2296                    Some(now_ns),
2297                    None,
2298                    None,
2299                    None,
2300                    None,
2301                    None,
2302                    None,
2303                    None,
2304                )
2305                .unwrap()
2306        };
2307
2308        let expected = greeks_for(same_venue_cache);
2309        let greeks = greeks_for(cross_venue_cache);
2310
2311        assert_eq!(greeks.underlying_price, expected.underlying_price);
2312        assert_eq!(greeks.vol, expected.vol);
2313        assert_eq!(greeks.price, expected.price);
2314        assert_eq!(greeks.greeks, expected.greeks);
2315    }
2316
2317    fn future_with_expiration(
2318        instrument_id: &str,
2319        underlying: &str,
2320        expiration_ns: UnixNanos,
2321    ) -> FuturesContract {
2322        FuturesContract::builder()
2323            .instrument_id(InstrumentId::from(instrument_id))
2324            .raw_symbol(Symbol::from(underlying))
2325            .asset_class(AssetClass::Index)
2326            .exchange(Ustr::from("XCME"))
2327            .underlying(Ustr::from(underlying))
2328            .activation_ns(UnixNanos::default())
2329            .expiration_ns(expiration_ns)
2330            .currency(Currency::from("USD"))
2331            .price_precision(2)
2332            .price_increment(Price::from("0.25"))
2333            .multiplier(Quantity::from(1))
2334            .lot_size(Quantity::from(1))
2335            .ts_event(UnixNanos::default())
2336            .ts_init(UnixNanos::default())
2337            .build()
2338            .unwrap()
2339    }
2340
2341    fn future_option_with_expiration(
2342        instrument_id: &str,
2343        raw_symbol: &str,
2344        underlying: &str,
2345        option_kind: OptionKind,
2346        strike: &str,
2347        expiration_ns: UnixNanos,
2348    ) -> OptionContract {
2349        OptionContract::builder()
2350            .instrument_id(InstrumentId::from(instrument_id))
2351            .raw_symbol(Symbol::from(raw_symbol))
2352            .asset_class(AssetClass::Index)
2353            .exchange(Ustr::from("XCME"))
2354            .underlying(Ustr::from(underlying))
2355            .option_kind(option_kind)
2356            .strike_price(Price::from(strike))
2357            .currency(Currency::from("USD"))
2358            .activation_ns(UnixNanos::default())
2359            .expiration_ns(expiration_ns)
2360            .price_precision(2)
2361            .price_increment(Price::from("0.01"))
2362            .multiplier(Quantity::from(1))
2363            .lot_size(Quantity::from(1))
2364            .ts_event(UnixNanos::default())
2365            .ts_init(UnixNanos::default())
2366            .build()
2367            .unwrap()
2368    }
2369
2370    fn setup_cache_with_option_and_quotes(
2371        option: OptionContract,
2372        underlying_id: InstrumentId,
2373        now_ns: UnixNanos,
2374    ) -> Rc<RefCell<Cache>> {
2375        let option_id = option.id();
2376        let cache = Rc::new(RefCell::new(Cache::new(None, None)));
2377        cache
2378            .borrow_mut()
2379            .add_instrument(InstrumentAny::OptionContract(option))
2380            .unwrap();
2381        cache
2382            .borrow_mut()
2383            .add_instrument(InstrumentAny::Equity(equity_aapl_opra()))
2384            .unwrap();
2385
2386        let option_quote = QuoteTick::new(
2387            option_id,
2388            Price::from("10.50"),
2389            Price::from("10.60"),
2390            Quantity::from(100),
2391            Quantity::from(100),
2392            now_ns,
2393            now_ns,
2394        );
2395
2396        let underlying_quote = QuoteTick::new(
2397            underlying_id,
2398            Price::from("150.00"),
2399            Price::from("150.10"),
2400            Quantity::from(100),
2401            Quantity::from(100),
2402            now_ns,
2403            now_ns,
2404        );
2405        cache.borrow_mut().add_quote(option_quote).unwrap();
2406        cache.borrow_mut().add_quote(underlying_quote).unwrap();
2407        cache
2408    }
2409
2410    fn position_from_fill(
2411        instrument: &InstrumentAny,
2412        position_id: &str,
2413        client_order_id: &str,
2414        trade_id: &str,
2415        side: OrderSide,
2416        quantity: u64,
2417        price: &str,
2418    ) -> Position {
2419        let fill = OrderFilledSpec::builder()
2420            .instrument_id(instrument.id())
2421            .client_order_id(ClientOrderId::from(client_order_id))
2422            .trade_id(TradeId::from(trade_id))
2423            .order_side(side)
2424            .last_qty(Quantity::from(quantity))
2425            .last_px(Price::from(price))
2426            .currency(Currency::USD())
2427            .position_id(PositionId::from(position_id))
2428            .build();
2429        Position::new(instrument, fill)
2430    }
2431
2432    fn calculate_portfolio_greeks(
2433        calculator: &GreeksCalculator,
2434        side: Option<PositionSide>,
2435    ) -> anyhow::Result<PortfolioGreeks> {
2436        calculator.portfolio_greeks(
2437            None, None, None, None, side, None, None, None, None, None, None, None, None, None,
2438            None, None, None, None, None, None, None,
2439        )
2440    }
2441
2442    fn assert_portfolio_greeks_eq(actual: &PortfolioGreeks, expected: &PortfolioGreeks) {
2443        assert_eq!(actual.ts_init, expected.ts_init);
2444        assert_eq!(actual.ts_event, expected.ts_event);
2445        assert_eq!(actual.pnl, expected.pnl);
2446        assert_eq!(actual.price, expected.price);
2447        assert_eq!(actual.delta, expected.delta);
2448        assert_eq!(actual.gamma, expected.gamma);
2449        assert_eq!(actual.vega, expected.vega);
2450        assert_eq!(actual.theta, expected.theta);
2451        assert_eq!(actual.rho, expected.rho);
2452    }
2453
2454    #[rstest]
2455    #[case::neither(false, false)]
2456    #[case::cache(true, false)]
2457    #[case::publish(false, true)]
2458    #[case::cache_and_publish(true, true)]
2459    fn test_portfolio_greeks_cache_and_publish(
2460        #[case] cache_greeks: bool,
2461        #[case] publish_greeks: bool,
2462    ) {
2463        msgbus::set_message_bus(Rc::new(RefCell::new(msgbus::MessageBus::default())));
2464        let (calculator, position, now_ns) = portfolio_option_calculator();
2465        let option_id = position.instrument_id;
2466        let expected = InstrumentGreeksParams::builder()
2467            .instrument_id(option_id)
2468            .ts_event(now_ns)
2469            .build()
2470            .calculate(&calculator)
2471            .unwrap();
2472        let published = Rc::new(RefCell::new(Vec::new()));
2473        let captured = published.clone();
2474        calculator.subscribe_greeks("AAPL", None::<fn(&GreeksData)>);
2475        calculator.subscribe_greeks(
2476            "AAPL",
2477            Some(move |data: &GreeksData| {
2478                captured.borrow_mut().push(data.clone());
2479            }),
2480        );
2481
2482        let result = PortfolioGreeksParams::builder()
2483            .cache_greeks(cache_greeks)
2484            .publish_greeks(publish_greeks)
2485            .build()
2486            .calculate(&calculator)
2487            .unwrap();
2488        let cached = calculator.cache.borrow().greeks(&option_id);
2489        msgbus::set_message_bus(Rc::new(RefCell::new(msgbus::MessageBus::default())));
2490
2491        let mut expected_position = expected.clone();
2492        expected_position.pnl = expected_position.price - position.avg_px_open.as_f64();
2493        assert_portfolio_greeks_eq(
2494            &result,
2495            &(position.signed_qty.as_f64() * &expected_position.to_portfolio_greeks()),
2496        );
2497        assert_eq!(cached.is_some(), cache_greeks || publish_greeks);
2498        assert_eq!(published.borrow().len(), usize::from(publish_greeks));
2499
2500        for actual in cached.iter().chain(published.borrow().iter()) {
2501            assert_greeks_eq(actual, &expected);
2502        }
2503    }
2504
2505    #[rstest]
2506    #[case::underlying("AAPL", 1)]
2507    #[case::underlying_prefix("AAP", 0)]
2508    #[case::all_underlyings("*", 1)]
2509    fn test_subscribe_greeks_matches_underlying_symbol_exactly(
2510        #[case] underlying: &str,
2511        #[case] expected_count: usize,
2512    ) {
2513        msgbus::set_message_bus(Rc::new(RefCell::new(msgbus::MessageBus::default())));
2514        let (calculator, option_id, now_ns) = option_calculator();
2515        let received = Rc::new(RefCell::new(Vec::new()));
2516        let captured = received.clone();
2517        calculator.subscribe_greeks(
2518            underlying,
2519            Some(move |data: &GreeksData| captured.borrow_mut().push(data.instrument_id)),
2520        );
2521
2522        InstrumentGreeksParams::builder()
2523            .instrument_id(option_id)
2524            .publish_greeks(true)
2525            .ts_event(now_ns)
2526            .build()
2527            .calculate(&calculator)
2528            .unwrap();
2529        msgbus::set_message_bus(Rc::new(RefCell::new(msgbus::MessageBus::default())));
2530
2531        assert_eq!(*received.borrow(), vec![option_id; expected_count]);
2532    }
2533
2534    #[rstest]
2535    fn test_publish_greeks_topic_includes_underlying_and_instrument_id() {
2536        msgbus::set_message_bus(Rc::new(RefCell::new(msgbus::MessageBus::default())));
2537        let (calculator, option_id, now_ns) = option_calculator();
2538        let received = Rc::new(RefCell::new(Vec::new()));
2539        let captured = received.clone();
2540        let topic = "data.GreeksData.underlying=AAPL.instrument_id=AAPL250417C00150000.OPRA";
2541
2542        let handler = TypedHandler::from(move |data: &GreeksData| {
2543            captured.borrow_mut().push(data.instrument_id);
2544        });
2545
2546        msgbus::subscribe_greeks(topic.into(), handler, None);
2547
2548        InstrumentGreeksParams::builder()
2549            .instrument_id(option_id)
2550            .publish_greeks(true)
2551            .ts_event(now_ns)
2552            .build()
2553            .calculate(&calculator)
2554            .unwrap();
2555        msgbus::set_message_bus(Rc::new(RefCell::new(msgbus::MessageBus::default())));
2556
2557        assert_eq!(*received.borrow(), vec![option_id]);
2558    }
2559
2560    #[rstest]
2561    fn test_portfolio_greeks_filter_reenters_calculator_and_caches() {
2562        let (calculator, position, now_ns) = portfolio_option_calculator();
2563        let option_id = position.instrument_id;
2564        let expected = InstrumentGreeksParams::builder()
2565            .instrument_id(option_id)
2566            .ts_event(now_ns)
2567            .build()
2568            .calculate(&calculator)
2569            .unwrap();
2570        let calculator = Rc::new(calculator);
2571        let captured = calculator.clone();
2572        let observed = Rc::new(RefCell::new(Vec::new()));
2573        let captured_observed = observed.clone();
2574
2575        let filter = GreeksFilterCallback::from_closure(move |data| {
2576            InstrumentGreeksParams::builder()
2577                .instrument_id(data.instrument_id)
2578                .ts_event(now_ns)
2579                .cache_greeks(true)
2580                .build()
2581                .calculate(&captured)
2582                .unwrap();
2583            captured_observed.borrow_mut().push(data.clone());
2584            true
2585        });
2586
2587        let result = PortfolioGreeksParams::builder()
2588            .greeks_filter(filter)
2589            .build()
2590            .calculate(&calculator)
2591            .unwrap();
2592
2593        let mut expected_position = expected.clone();
2594        expected_position.pnl = expected_position.price - position.avg_px_open.as_f64();
2595        let expected_portfolio =
2596            position.signed_qty.as_f64() * &expected_position.to_portfolio_greeks();
2597        assert_portfolio_greeks_eq(&result, &expected_portfolio);
2598        assert_greeks_eq(
2599            &calculator.cache.borrow().greeks(&option_id).unwrap(),
2600            &expected,
2601        );
2602        expected_position.pnl = expected_portfolio.pnl;
2603        expected_position.price = expected_portfolio.price;
2604        expected_position.greeks = expected_portfolio.greeks;
2605        let observed = observed.borrow();
2606        assert_eq!(observed.len(), 1);
2607        assert_greeks_eq(&observed[0], &expected_position);
2608    }
2609
2610    fn portfolio_option_calculator() -> (GreeksCalculator, Position, UnixNanos) {
2611        let (calculator, option_id, now_ns) = option_calculator();
2612        let clock = Rc::new(RefCell::new(VirtualClock::new()));
2613        clock.borrow_mut().set_time(now_ns);
2614        let calculator = GreeksCalculator::new(calculator.cache, clock);
2615        let instrument = calculator
2616            .cache
2617            .borrow()
2618            .instrument(&option_id)
2619            .cloned()
2620            .unwrap();
2621        let position = position_from_fill(
2622            &instrument,
2623            "P-OPTION",
2624            "O-OPTION",
2625            "T-OPTION",
2626            OrderSide::Buy,
2627            3,
2628            "9.00",
2629        );
2630        calculator
2631            .cache
2632            .borrow_mut()
2633            .add_position(&position, OmsType::Hedging)
2634            .unwrap();
2635
2636        (calculator, position, now_ns)
2637    }
2638
2639    fn assert_greeks_eq(actual: &GreeksData, expected: &GreeksData) {
2640        assert_eq!(actual.ts_init, expected.ts_init);
2641        assert_eq!(actual.ts_event, expected.ts_event);
2642        assert_eq!(actual.instrument_id, expected.instrument_id);
2643        assert_eq!(actual.is_call, expected.is_call);
2644        assert_eq!(actual.strike, expected.strike);
2645        assert_eq!(actual.expiry, expected.expiry);
2646        assert_eq!(actual.expiry_in_days, expected.expiry_in_days);
2647        assert_eq!(actual.expiry_in_years, expected.expiry_in_years);
2648        assert_eq!(actual.multiplier, expected.multiplier);
2649        assert_eq!(actual.quantity, expected.quantity);
2650        assert_eq!(actual.underlying_price, expected.underlying_price);
2651        assert_eq!(actual.interest_rate, expected.interest_rate);
2652        assert_eq!(actual.cost_of_carry, expected.cost_of_carry);
2653        assert_eq!(actual.vol, expected.vol);
2654        assert_eq!(actual.pnl, expected.pnl);
2655        assert_eq!(actual.price, expected.price);
2656        assert_eq!(actual.greeks, expected.greeks);
2657        assert_eq!(actual.itm_prob, expected.itm_prob);
2658    }
2659
2660    #[rstest]
2661    #[case(OrderSide::Buy, 3, [300.0, 300.0, 150.0])]
2662    #[case(OrderSide::Sell, 2, [-200.0, -200.0, -100.0])]
2663    fn test_portfolio_greeks_scales_futures_multiplier(
2664        #[case] side: OrderSide,
2665        #[case] quantity: u64,
2666        #[case] expected: [f64; 3],
2667    ) {
2668        let now_ns = UnixNanos::from(1_700_000_000_000_000_000);
2669        let mut future =
2670            future_with_expiration("ES.GLBX", "ES", UnixNanos::from(1_800_000_000_000_000_000));
2671        future.multiplier = Quantity::from(50);
2672        let instrument = InstrumentAny::FuturesContract(future);
2673        let instrument_id = instrument.id();
2674        let position = position_from_fill(
2675            &instrument,
2676            "P-FUTURE",
2677            "O-FUTURE",
2678            "T-FUTURE",
2679            side,
2680            quantity,
2681            "100.00",
2682        );
2683        let cache = Rc::new(RefCell::new(Cache::default()));
2684        cache.borrow_mut().add_instrument(instrument).unwrap();
2685        cache
2686            .borrow_mut()
2687            .add_position(&position, OmsType::Hedging)
2688            .unwrap();
2689        cache
2690            .borrow_mut()
2691            .add_quote(QuoteTick::new(
2692                instrument_id,
2693                Price::from("101.75"),
2694                Price::from("102.25"),
2695                Quantity::from(100),
2696                Quantity::from(100),
2697                now_ns,
2698                now_ns,
2699            ))
2700            .unwrap();
2701        let clock = Rc::new(RefCell::new(VirtualClock::new()));
2702        clock.borrow_mut().set_time(now_ns);
2703        let calculator = GreeksCalculator::new(cache, clock);
2704
2705        let result = calculate_portfolio_greeks(&calculator, None).unwrap();
2706
2707        assert_portfolio_greeks_eq(
2708            &result,
2709            &PortfolioGreeks::new(
2710                expected[0],
2711                expected[1],
2712                expected[2],
2713                0.0,
2714                0.0,
2715                0.0,
2716                now_ns,
2717                now_ns,
2718            ),
2719        );
2720    }
2721
2722    #[rstest]
2723    fn test_portfolio_greeks_ignores_closed_position_with_missing_price() {
2724        let now = utc_timestamp(2025, 3, 8, 12, 0, 0);
2725        let expiry = now + jiff::SignedDuration::from_hours(24 * 30);
2726        let now_ns = UnixNanos::from(now);
2727        let expiry_ns = UnixNanos::from(expiry);
2728        let open_option = option_with_expiration("AAPL250417C00150000.OPRA", expiry_ns);
2729        let open_option_id = open_option.id();
2730        let underlying_id = InstrumentId::from("AAPL.OPRA");
2731        let cache = setup_cache_with_option_and_quotes(open_option.clone(), underlying_id, now_ns);
2732        let closed_future = future_with_expiration("CLOSED.GLBX", "CLOSED", expiry_ns);
2733        let closed_future_id = closed_future.id();
2734        let open_instrument = InstrumentAny::OptionContract(open_option);
2735        let closed_instrument = InstrumentAny::FuturesContract(closed_future);
2736
2737        let open_position = position_from_fill(
2738            &open_instrument,
2739            "P-OPEN",
2740            "O-OPEN",
2741            "T-OPEN",
2742            OrderSide::Buy,
2743            2,
2744            "10.50",
2745        );
2746        let mut closed_position = position_from_fill(
2747            &closed_instrument,
2748            "P-CLOSED",
2749            "O-CLOSED-OPEN",
2750            "T-CLOSED-OPEN",
2751            OrderSide::Buy,
2752            1,
2753            "100.00",
2754        );
2755        cache
2756            .borrow_mut()
2757            .add_instrument(closed_instrument)
2758            .unwrap();
2759        cache
2760            .borrow_mut()
2761            .add_position(&open_position, OmsType::Hedging)
2762            .unwrap();
2763        cache
2764            .borrow_mut()
2765            .add_position(&closed_position, OmsType::Hedging)
2766            .unwrap();
2767        let closing_fill = OrderFilledSpec::builder()
2768            .instrument_id(closed_future_id)
2769            .client_order_id(ClientOrderId::from("O-CLOSED-CLOSE"))
2770            .trade_id(TradeId::from("T-CLOSED-CLOSE"))
2771            .order_side(OrderSide::Sell)
2772            .last_qty(Quantity::from(1))
2773            .last_px(Price::from("101.00"))
2774            .currency(Currency::USD())
2775            .position_id(PositionId::from("P-CLOSED"))
2776            .build();
2777        closed_position.apply(&closing_fill);
2778        cache
2779            .borrow_mut()
2780            .update_position(&closed_position)
2781            .unwrap();
2782
2783        // Pin the fixture itself: the closed position must have left the open index,
2784        // or this would exercise `add_position`'s open-index insertion rather than
2785        // the query scope under test.
2786        assert!(closed_position.is_closed());
2787        assert_eq!(
2788            cache
2789                .borrow()
2790                .positions_open(None, None, None, None, None)
2791                .len(),
2792            1
2793        );
2794
2795        let clock = Rc::new(RefCell::new(VirtualClock::new()));
2796        clock.borrow_mut().set_time(now_ns);
2797        let calculator = GreeksCalculator::new(cache, clock);
2798        let expected = calculator
2799            .instrument_greeks(
2800                open_option_id,
2801                None,
2802                None,
2803                None,
2804                None,
2805                None,
2806                None,
2807                None,
2808                None,
2809                None,
2810                Some(now_ns),
2811                Some(open_position.clone()),
2812                None,
2813                None,
2814                None,
2815                None,
2816                None,
2817                None,
2818            )
2819            .unwrap();
2820        let expected = open_position.signed_qty.as_f64() * &expected.to_portfolio_greeks();
2821
2822        assert_ne!(expected.delta, 0.0);
2823        assert_portfolio_greeks_eq(
2824            &calculate_portfolio_greeks(&calculator, None).unwrap(),
2825            &expected,
2826        );
2827        assert_portfolio_greeks_eq(
2828            &calculate_portfolio_greeks(&calculator, Some(PositionSide::Flat)).unwrap(),
2829            &PortfolioGreeks::new(0.0, 0.0, 0.0, 0.0, 0.0, 0.0, now_ns, now_ns),
2830        );
2831    }
2832
2833    #[rstest]
2834    fn test_portfolio_greeks_preserves_open_position_aggregate_and_side_filters() {
2835        let now = utc_timestamp(2025, 3, 8, 12, 0, 0);
2836        let expiry = now + jiff::SignedDuration::from_hours(24 * 30);
2837        let now_ns = UnixNanos::from(now);
2838        let expiry_ns = UnixNanos::from(expiry);
2839        let long_option = option_with_expiration("AAPL250417C00145000.OPRA", expiry_ns);
2840        let short_option = option_with_expiration("AAPL250417C00155000.OPRA", expiry_ns);
2841        let long_instrument = InstrumentAny::OptionContract(long_option.clone());
2842        let short_instrument = InstrumentAny::OptionContract(short_option.clone());
2843        let underlying_id = InstrumentId::from("AAPL.OPRA");
2844        let cache = setup_cache_with_option_and_quotes(long_option, underlying_id, now_ns);
2845        cache
2846            .borrow_mut()
2847            .add_instrument(short_instrument.clone())
2848            .unwrap();
2849        cache
2850            .borrow_mut()
2851            .add_quote(QuoteTick::new(
2852                short_option.id(),
2853                Price::from("3.50"),
2854                Price::from("3.60"),
2855                Quantity::from(100),
2856                Quantity::from(100),
2857                now_ns,
2858                now_ns,
2859            ))
2860            .unwrap();
2861        let long_position = position_from_fill(
2862            &long_instrument,
2863            "P-LONG",
2864            "O-LONG",
2865            "T-LONG",
2866            OrderSide::Buy,
2867            3,
2868            "10.50",
2869        );
2870        let short_position = position_from_fill(
2871            &short_instrument,
2872            "P-SHORT",
2873            "O-SHORT",
2874            "T-SHORT",
2875            OrderSide::Sell,
2876            2,
2877            "3.50",
2878        );
2879        cache
2880            .borrow_mut()
2881            .add_position(&long_position, OmsType::Hedging)
2882            .unwrap();
2883        cache
2884            .borrow_mut()
2885            .add_position(&short_position, OmsType::Hedging)
2886            .unwrap();
2887
2888        let clock = Rc::new(RefCell::new(VirtualClock::new()));
2889        clock.borrow_mut().set_time(now_ns);
2890        let calculator = GreeksCalculator::new(cache, clock);
2891        let long_greeks = calculator
2892            .instrument_greeks(
2893                long_instrument.id(),
2894                None,
2895                None,
2896                None,
2897                None,
2898                None,
2899                None,
2900                None,
2901                None,
2902                None,
2903                Some(now_ns),
2904                Some(long_position.clone()),
2905                None,
2906                None,
2907                None,
2908                None,
2909                None,
2910                None,
2911            )
2912            .unwrap();
2913        let short_greeks = calculator
2914            .instrument_greeks(
2915                short_instrument.id(),
2916                None,
2917                None,
2918                None,
2919                None,
2920                None,
2921                None,
2922                None,
2923                None,
2924                None,
2925                Some(now_ns),
2926                Some(short_position.clone()),
2927                None,
2928                None,
2929                None,
2930                None,
2931                None,
2932                None,
2933            )
2934            .unwrap();
2935        let expected_long = long_position.signed_qty.as_f64() * &long_greeks.to_portfolio_greeks();
2936        let expected_short =
2937            short_position.signed_qty.as_f64() * &short_greeks.to_portfolio_greeks();
2938        let expected = expected_long + expected_short;
2939
2940        assert_ne!(expected.pnl, 0.0);
2941        assert_ne!(expected.price, 0.0);
2942        assert_ne!(expected.delta, 0.0);
2943        assert_ne!(expected.gamma, 0.0);
2944        assert_ne!(expected.vega, 0.0);
2945        assert_ne!(expected.theta, 0.0);
2946        assert_portfolio_greeks_eq(
2947            &calculate_portfolio_greeks(&calculator, None).unwrap(),
2948            &expected,
2949        );
2950        assert_portfolio_greeks_eq(
2951            &calculate_portfolio_greeks(&calculator, Some(PositionSide::Long)).unwrap(),
2952            &(long_position.signed_qty.as_f64() * &long_greeks.to_portfolio_greeks()),
2953        );
2954        assert_portfolio_greeks_eq(
2955            &calculate_portfolio_greeks(&calculator, Some(PositionSide::Short)).unwrap(),
2956            &(short_position.signed_qty.as_f64() * &short_greeks.to_portfolio_greeks()),
2957        );
2958    }
2959
2960    #[rstest]
2961    #[case(true, [-200.0, -1700.0, -1600.0, -1500.0, -1400.0, 1300.0, -1200.0])]
2962    #[case(false, [600.0, 900.0, 1200.0, 1500.0, 1800.0, -2100.0, 2400.0])]
2963    fn test_portfolio_greeks_scales_before_filter(
2964        #[case] include_short: bool,
2965        #[case] expected: [f64; 7],
2966    ) {
2967        let now_ns = UnixNanos::from(1_700_000_000_000_000_000);
2968        let expiry_ns = UnixNanos::from(1_800_000_000_000_000_000);
2969        let long_option = option_with_expiration("AAPL250417C00145000.OPRA", expiry_ns);
2970        let short_option = option_with_expiration("AAPL250417C00155000.OPRA", expiry_ns);
2971        let long_id = long_option.id();
2972        let short_id = short_option.id();
2973        let cache = Rc::new(RefCell::new(Cache::default()));
2974        let observed = Rc::new(RefCell::new(Vec::new()));
2975
2976        for (option, side, quantity, entry, price, greeks) in [
2977            (
2978                long_option,
2979                OrderSide::Buy,
2980                3,
2981                "1.00",
2982                3.0,
2983                OptionGreekValues {
2984                    delta: 4.0,
2985                    gamma: 5.0,
2986                    vega: 6.0,
2987                    theta: -7.0,
2988                    rho: 8.0,
2989                },
2990            ),
2991            (
2992                short_option,
2993                OrderSide::Sell,
2994                2,
2995                "9.00",
2996                13.0,
2997                OptionGreekValues {
2998                    delta: 14.0,
2999                    gamma: 15.0,
3000                    vega: 16.0,
3001                    theta: -17.0,
3002                    rho: 18.0,
3003                },
3004            ),
3005        ] {
3006            let instrument = InstrumentAny::OptionContract(option);
3007            let position = position_from_fill(
3008                &instrument,
3009                instrument.id().symbol.as_str(),
3010                instrument.id().symbol.as_str(),
3011                instrument.id().symbol.as_str(),
3012                side,
3013                quantity,
3014                entry,
3015            );
3016
3017            let data = GreeksData {
3018                instrument_id: instrument.id(),
3019                is_call: false,
3020                strike: 19.0,
3021                expiry: 20_271_015,
3022                expiry_in_days: 23,
3023                expiry_in_years: 0.25,
3024                multiplier: 100.0,
3025                quantity: 1.0,
3026                underlying_price: 29.0,
3027                interest_rate: 0.05,
3028                cost_of_carry: 0.02,
3029                vol: 0.5,
3030                pnl: 31.0,
3031                price,
3032                greeks,
3033                itm_prob: 0.75,
3034                ts_event: UnixNanos::from(13),
3035                ts_init: UnixNanos::from(11),
3036            };
3037
3038            let mut cache = cache.borrow_mut();
3039            cache.add_instrument(instrument).unwrap();
3040            cache.add_position(&position, OmsType::Hedging).unwrap();
3041            cache.add_greeks(data).unwrap();
3042        }
3043
3044        let clock = Rc::new(RefCell::new(VirtualClock::new()));
3045        clock.borrow_mut().set_time(now_ns);
3046        let calculator = GreeksCalculator::new(cache.clone(), clock);
3047        let captured = observed.clone();
3048        let filter: GreeksFilter = Box::new(move |data| {
3049            captured.borrow_mut().push(data.clone());
3050            include_short || data.delta > 0.0
3051        });
3052
3053        let result = calculator
3054            .portfolio_greeks(
3055                None,
3056                None,
3057                None,
3058                None,
3059                None,
3060                None,
3061                None,
3062                None,
3063                None,
3064                None,
3065                Some(true),
3066                None,
3067                None,
3068                None,
3069                None,
3070                None,
3071                None,
3072                Some(&filter),
3073                None,
3074                None,
3075                None,
3076            )
3077            .unwrap();
3078
3079        assert_eq!(result.ts_init, now_ns);
3080        assert_eq!(result.ts_event, now_ns);
3081        assert_eq!(
3082            [
3083                result.pnl,
3084                result.price,
3085                result.delta,
3086                result.gamma,
3087                result.vega,
3088                result.theta,
3089                result.rho
3090            ],
3091            expected
3092        );
3093        let mut observed = observed.borrow_mut();
3094        observed.sort_by_key(|data| data.instrument_id);
3095        assert_eq!(observed.len(), 2);
3096
3097        for (data, id, values) in [
3098            (
3099                &observed[0],
3100                long_id,
3101                [600.0, 900.0, 1200.0, 1500.0, 1800.0, -2100.0, 2400.0],
3102            ),
3103            (
3104                &observed[1],
3105                short_id,
3106                [-800.0, -2600.0, -2800.0, -3000.0, -3200.0, 3400.0, -3600.0],
3107            ),
3108        ] {
3109            assert_eq!(
3110                [
3111                    data.pnl, data.price, data.delta, data.gamma, data.vega, data.theta, data.rho
3112                ],
3113                values
3114            );
3115            assert_eq!(data.ts_init, UnixNanos::from(11));
3116            assert_eq!(data.ts_event, UnixNanos::from(13));
3117            assert_eq!(data.instrument_id, id);
3118            assert!(!data.is_call);
3119            assert_eq!(data.strike, 19.0);
3120            assert_eq!(data.expiry, 20_271_015);
3121            assert_eq!(data.expiry_in_days, 23);
3122            assert_eq!(data.expiry_in_years, 0.25);
3123            assert_eq!(data.multiplier, 100.0);
3124            assert_eq!(data.quantity, 1.0);
3125            assert_eq!(data.underlying_price, 29.0);
3126            assert_eq!(data.interest_rate, 0.05);
3127            assert_eq!(data.cost_of_carry, 0.02);
3128            assert_eq!(data.vol, 0.5);
3129            assert_eq!(data.itm_prob, 0.75);
3130        }
3131
3132        for (id, price, greeks) in [
3133            (
3134                long_id,
3135                3.0,
3136                OptionGreekValues {
3137                    delta: 4.0,
3138                    gamma: 5.0,
3139                    vega: 6.0,
3140                    theta: -7.0,
3141                    rho: 8.0,
3142                },
3143            ),
3144            (
3145                short_id,
3146                13.0,
3147                OptionGreekValues {
3148                    delta: 14.0,
3149                    gamma: 15.0,
3150                    vega: 16.0,
3151                    theta: -17.0,
3152                    rho: 18.0,
3153                },
3154            ),
3155        ] {
3156            let cached = cache.borrow().greeks(&id).unwrap();
3157            assert_eq!(cached.pnl, 31.0);
3158            assert_eq!(cached.price, price);
3159            assert_eq!(cached.greeks, greeks);
3160        }
3161    }
3162
3163    #[rstest]
3164    fn test_expiry_in_days_multi_day_unchanged() {
3165        let now = utc_timestamp(2025, 3, 8, 12, 0, 0);
3166        let expiry = now + jiff::SignedDuration::from_hours(24 * (30));
3167        let now_ns = UnixNanos::from(now);
3168        let expiry_ns = UnixNanos::from(expiry);
3169        let option = option_with_expiration("AAPL250417C00150000.OPRA", expiry_ns);
3170        let option_id = option.id();
3171        let underlying_id = InstrumentId::from("AAPL.OPRA");
3172        let cache = setup_cache_with_option_and_quotes(option, underlying_id, now_ns);
3173        let clock = Rc::new(RefCell::new(VirtualClock::new()));
3174        let calculator = GreeksCalculator::new(cache, clock);
3175
3176        let greeks = calculator
3177            .instrument_greeks(
3178                option_id,
3179                None,
3180                None,
3181                None,
3182                None,
3183                None,
3184                None,
3185                None,
3186                None,
3187                None,
3188                Some(now_ns),
3189                None,
3190                None,
3191                None,
3192                None,
3193                None,
3194                None,
3195                None,
3196            )
3197            .unwrap();
3198
3199        assert_eq!(greeks.expiry_in_days, 30);
3200        assert!((greeks.expiry_in_years - 30.0 / 365.25).abs() < 1e-9);
3201    }
3202
3203    #[rstest]
3204    fn test_expiry_in_days_same_day_clamped_to_one() {
3205        let now = utc_timestamp(2025, 3, 8, 12, 0, 0);
3206        let expiry_same_day = utc_timestamp(2025, 3, 8, 18, 0, 0);
3207        let now_ns = UnixNanos::from(now);
3208        let expiry_ns = UnixNanos::from(expiry_same_day);
3209        let option = option_with_expiration("AAPL250308C00150000.OPRA", expiry_ns);
3210        let option_id = option.id();
3211        let underlying_id = InstrumentId::from("AAPL.OPRA");
3212        let cache = setup_cache_with_option_and_quotes(option, underlying_id, now_ns);
3213        let clock = Rc::new(RefCell::new(VirtualClock::new()));
3214        let calculator = GreeksCalculator::new(cache, clock);
3215
3216        let greeks = calculator
3217            .instrument_greeks(
3218                option_id,
3219                None,
3220                None,
3221                None,
3222                None,
3223                None,
3224                None,
3225                None,
3226                None,
3227                None,
3228                Some(now_ns),
3229                None,
3230                None,
3231                None,
3232                None,
3233                None,
3234                None,
3235                None,
3236            )
3237            .unwrap();
3238
3239        assert_eq!(greeks.expiry_in_days, 1);
3240        assert!((greeks.expiry_in_years - 1.0 / 365.25).abs() < 1e-9);
3241    }
3242
3243    #[rstest]
3244    fn test_instrument_greeks_beta_weights_vega_to_vol_index() {
3245        let now = utc_timestamp(2025, 3, 8, 12, 0, 0);
3246        let expiry = now + jiff::SignedDuration::from_hours(24 * (30));
3247        let now_ns = UnixNanos::from(now);
3248        let expiry_ns = UnixNanos::from(expiry);
3249        let option = option_with_expiration("AAPL250417C00150000.OPRA", expiry_ns);
3250        let option_id = option.id();
3251        let underlying_id = InstrumentId::from("AAPL.OPRA");
3252        let vol_index_id = InstrumentId::from("VIX.XCBF");
3253        let cache = setup_cache_with_option_and_quotes(option, underlying_id, now_ns);
3254        cache
3255            .borrow_mut()
3256            .add_quote(QuoteTick::new(
3257                vol_index_id,
3258                Price::from("25.00"),
3259                Price::from("25.00"),
3260                Quantity::from(100),
3261                Quantity::from(100),
3262                now_ns,
3263                now_ns,
3264            ))
3265            .unwrap();
3266
3267        let clock = Rc::new(RefCell::new(VirtualClock::new()));
3268        let calculator = GreeksCalculator::new(cache, clock);
3269        let greeks = calculator
3270            .instrument_greeks(
3271                option_id,
3272                None,
3273                None,
3274                None,
3275                None,
3276                None,
3277                None,
3278                None,
3279                None,
3280                None,
3281                Some(now_ns),
3282                None,
3283                None,
3284                None,
3285                None,
3286                None,
3287                None,
3288                None,
3289            )
3290            .unwrap();
3291
3292        let mut vol_beta_weights = HashMap::new();
3293        vol_beta_weights.insert(underlying_id, 0.75);
3294        let vol_weighted_greeks = calculator
3295            .instrument_greeks(
3296                option_id,
3297                None,
3298                None,
3299                None,
3300                None,
3301                None,
3302                None,
3303                None,
3304                None,
3305                None,
3306                Some(now_ns),
3307                None,
3308                None,
3309                None,
3310                None,
3311                None,
3312                Some(vol_index_id),
3313                Some(&vol_beta_weights),
3314            )
3315            .unwrap();
3316
3317        let expected_vega = greeks.vega * 0.75 * (greeks.vol * 100.0) / 25.0;
3318        assert_eq!(
3319            (vol_weighted_greeks.delta * 1e12).round(),
3320            (greeks.delta * 1e12).round()
3321        );
3322        assert_eq!(
3323            (vol_weighted_greeks.gamma * 1e12).round(),
3324            (greeks.gamma * 1e12).round()
3325        );
3326        assert_eq!(
3327            (vol_weighted_greeks.vega * 1e12).round(),
3328            (expected_vega * 1e12).round()
3329        );
3330    }
3331
3332    #[rstest]
3333    fn test_instrument_greeks_errors_when_vol_index_price_missing() {
3334        let now = utc_timestamp(2025, 3, 8, 12, 0, 0);
3335        let expiry = now + jiff::SignedDuration::from_hours(24 * (30));
3336        let now_ns = UnixNanos::from(now);
3337        let expiry_ns = UnixNanos::from(expiry);
3338        let option = option_with_expiration("AAPL250417C00150000.OPRA", expiry_ns);
3339        let option_id = option.id();
3340        let underlying_id = InstrumentId::from("AAPL.OPRA");
3341        let vol_index_id = InstrumentId::from("VIX.XCBF");
3342        let cache = setup_cache_with_option_and_quotes(option, underlying_id, now_ns);
3343
3344        let clock = Rc::new(RefCell::new(VirtualClock::new()));
3345        let calculator = GreeksCalculator::new(cache, clock);
3346        let error = calculator
3347            .instrument_greeks(
3348                option_id,
3349                None,
3350                None,
3351                None,
3352                None,
3353                None,
3354                None,
3355                None,
3356                None,
3357                None,
3358                Some(now_ns),
3359                None,
3360                None,
3361                None,
3362                None,
3363                None,
3364                Some(vol_index_id),
3365                None,
3366            )
3367            .unwrap_err();
3368
3369        assert_eq!(error.to_string(), "No price available for VIX.XCBF");
3370    }
3371
3372    #[rstest]
3373    fn test_modify_greeks_errors_when_vol_index_price_missing() {
3374        let calculator = create_test_calculator();
3375        let underlying_id = InstrumentId::from("AAPL.OPRA");
3376        let vol_index_id = InstrumentId::from("VIX.XCBF");
3377
3378        let error = calculator
3379            .modify_greeks(
3380                1.0,
3381                2.0,
3382                underlying_id,
3383                150.0,
3384                150.0,
3385                false,
3386                None,
3387                None,
3388                2.0,
3389                0.30,
3390                0,
3391                None,
3392                0.0,
3393                Some(vol_index_id),
3394                None,
3395                None,
3396                None,
3397            )
3398            .unwrap_err();
3399
3400        assert_eq!(error.to_string(), "No price available for VIX.XCBF");
3401    }
3402
3403    #[rstest]
3404    fn test_modify_greeks_accepts_explicit_index_prices() {
3405        let calculator = create_test_calculator();
3406        let underlying_id = InstrumentId::from("AAPL.OPRA");
3407        let mut beta_weights = HashMap::new();
3408        beta_weights.insert(underlying_id, 0.5);
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                Some(&beta_weights),
3422                2.0,
3423                0.30,
3424                0,
3425                None,
3426                0.0,
3427                None,
3428                Some(&vol_beta_weights),
3429                Some(200.0),
3430                Some(25.0),
3431            )
3432            .unwrap();
3433
3434        assert_eq!((delta * 1e12).round(), 375_000_000_000.0);
3435        assert_eq!((gamma * 1e12).round(), 281_250_000_000.0);
3436        assert_eq!((vega * 1e12).round(), 1_800_000_000_000.0);
3437
3438        let (delta, gamma, vega) = calculator
3439            .modify_greeks(
3440                1.0,
3441                2.0,
3442                underlying_id,
3443                150.0,
3444                150.0,
3445                true,
3446                None,
3447                Some(&beta_weights),
3448                2.0,
3449                0.30,
3450                0,
3451                None,
3452                0.0,
3453                None,
3454                Some(&vol_beta_weights),
3455                Some(200.0),
3456                Some(25.0),
3457            )
3458            .unwrap();
3459
3460        assert_eq!((delta * 1e12).round(), 750_000_000_000.0);
3461        assert_eq!((gamma * 1e12).round(), 1_125_000_000_000.0);
3462        assert_eq!((vega * 1e12).round(), 4_500_000_000.0);
3463    }
3464
3465    #[rstest]
3466    fn test_instrument_greeks_errors_when_future_underlying_price_missing_without_cached_spread() {
3467        let now = utc_timestamp(2024, 2, 14, 16, 0, 0);
3468        let expiry = utc_timestamp(2024, 3, 15, 16, 0, 0);
3469        let now_ns = UnixNanos::from(now);
3470        let expiry_ns = UnixNanos::from(expiry);
3471
3472        let future = future_with_expiration("ESH4.GLBX", "ESH4", expiry_ns);
3473        let call_option = future_option_with_expiration(
3474            "ESH4C150.GLBX",
3475            "ESH4C150",
3476            "ESH4",
3477            OptionKind::Call,
3478            "150.00",
3479            expiry_ns,
3480        );
3481        let put_option = future_option_with_expiration(
3482            "ESH4P150.GLBX",
3483            "ESH4P150",
3484            "ESH4",
3485            OptionKind::Put,
3486            "150.00",
3487            expiry_ns,
3488        );
3489
3490        let cache = Rc::new(RefCell::new(Cache::new(None, None)));
3491        cache
3492            .borrow_mut()
3493            .add_instrument(InstrumentAny::FuturesContract(future))
3494            .unwrap();
3495        cache
3496            .borrow_mut()
3497            .add_instrument(InstrumentAny::OptionContract(call_option.clone()))
3498            .unwrap();
3499        cache
3500            .borrow_mut()
3501            .add_instrument(InstrumentAny::OptionContract(put_option.clone()))
3502            .unwrap();
3503
3504        let call_quote = QuoteTick::new(
3505            call_option.id(),
3506            Price::from("8.50"),
3507            Price::from("8.50"),
3508            Quantity::from(100),
3509            Quantity::from(100),
3510            now_ns,
3511            now_ns,
3512        );
3513
3514        let put_quote = QuoteTick::new(
3515            put_option.id(),
3516            Price::from("3.33"),
3517            Price::from("3.33"),
3518            Quantity::from(100),
3519            Quantity::from(100),
3520            now_ns,
3521            now_ns,
3522        );
3523        cache.borrow_mut().add_quote(call_quote).unwrap();
3524        cache.borrow_mut().add_quote(put_quote).unwrap();
3525
3526        let clock = Rc::new(RefCell::new(VirtualClock::new()));
3527        clock.borrow_mut().set_time(now_ns);
3528        let calculator = GreeksCalculator::new(cache, clock);
3529
3530        let error = calculator
3531            .instrument_greeks(
3532                call_option.id(),
3533                Some(0.0425),
3534                None,
3535                None,
3536                None,
3537                None,
3538                None,
3539                None,
3540                None,
3541                None,
3542                Some(now_ns),
3543                None,
3544                None,
3545                None,
3546                None,
3547                None,
3548                None,
3549                None,
3550            )
3551            .unwrap_err();
3552
3553        assert_eq!(error.to_string(), "No price available for ESH4.GLBX");
3554    }
3555
3556    #[rstest]
3557    fn test_cache_futures_spread_returns_price_to_reference_future() {
3558        let now = utc_timestamp(2024, 2, 14, 16, 0, 0);
3559        let expiry = utc_timestamp(2024, 3, 15, 16, 0, 0);
3560        let now_ns = UnixNanos::from(now);
3561        let expiry_ns = UnixNanos::from(expiry);
3562
3563        let future = future_with_expiration("ESH4.GLBX", "ESH4", expiry_ns);
3564        let reference_future = future_with_expiration("ESM4.GLBX", "ESM4", expiry_ns);
3565        let call_option = future_option_with_expiration(
3566            "ESH4C150.GLBX",
3567            "ESH4C150",
3568            "ESH4",
3569            OptionKind::Call,
3570            "150.00",
3571            expiry_ns,
3572        );
3573        let put_option = future_option_with_expiration(
3574            "ESH4P150.GLBX",
3575            "ESH4P150",
3576            "ESH4",
3577            OptionKind::Put,
3578            "150.00",
3579            expiry_ns,
3580        );
3581
3582        let cache = Rc::new(RefCell::new(Cache::new(None, None)));
3583        cache
3584            .borrow_mut()
3585            .add_instrument(InstrumentAny::FuturesContract(future))
3586            .unwrap();
3587        cache
3588            .borrow_mut()
3589            .add_instrument(InstrumentAny::FuturesContract(reference_future.clone()))
3590            .unwrap();
3591        cache
3592            .borrow_mut()
3593            .add_instrument(InstrumentAny::OptionContract(call_option.clone()))
3594            .unwrap();
3595        cache
3596            .borrow_mut()
3597            .add_instrument(InstrumentAny::OptionContract(put_option.clone()))
3598            .unwrap();
3599
3600        let call_quote = QuoteTick::new(
3601            call_option.id(),
3602            Price::from("8.50"),
3603            Price::from("8.50"),
3604            Quantity::from(100),
3605            Quantity::from(100),
3606            now_ns,
3607            now_ns,
3608        );
3609
3610        let put_quote = QuoteTick::new(
3611            put_option.id(),
3612            Price::from("3.33"),
3613            Price::from("3.33"),
3614            Quantity::from(100),
3615            Quantity::from(100),
3616            now_ns,
3617            now_ns,
3618        );
3619
3620        let reference_future_quote = QuoteTick::new(
3621            reference_future.id(),
3622            Price::from("155.00"),
3623            Price::from("155.00"),
3624            Quantity::from(100),
3625            Quantity::from(100),
3626            now_ns,
3627            now_ns,
3628        );
3629        cache.borrow_mut().add_quote(call_quote).unwrap();
3630        cache.borrow_mut().add_quote(put_quote).unwrap();
3631        cache
3632            .borrow_mut()
3633            .add_quote(reference_future_quote)
3634            .unwrap();
3635
3636        let clock = Rc::new(RefCell::new(VirtualClock::new()));
3637        clock.borrow_mut().set_time(now_ns);
3638        let calculator = GreeksCalculator::new(cache, clock);
3639
3640        let cached_future_price = calculator
3641            .cache_futures_spread(call_option.id(), put_option.id(), reference_future.id())
3642            .unwrap();
3643
3644        let expected_underlying = 150.0 + (0.0425_f64 * (30.0 / 365.25)).exp() * (8.50 - 3.33);
3645        let expected_cached_underlying = reference_future.make_price(expected_underlying);
3646        assert_eq!(cached_future_price, expected_cached_underlying);
3647        assert_eq!(
3648            calculator.get_cached_futures_spread_price(InstrumentId::from("ESH4.GLBX")),
3649            Some(expected_cached_underlying)
3650        );
3651    }
3652
3653    #[rstest]
3654    fn test_instrument_greeks_uses_cached_futures_spread_when_underlying_price_missing() {
3655        let now = utc_timestamp(2024, 2, 14, 16, 0, 0);
3656        let expiry = utc_timestamp(2024, 3, 15, 16, 0, 0);
3657        let now_ns = UnixNanos::from(now);
3658        let expiry_ns = UnixNanos::from(expiry);
3659
3660        let future = future_with_expiration("ESH4.GLBX", "ESH4", expiry_ns);
3661        let reference_future = future_with_expiration("ESM4.GLBX", "ESM4", expiry_ns);
3662        let call_option = future_option_with_expiration(
3663            "ESH4C150.GLBX",
3664            "ESH4C150",
3665            "ESH4",
3666            OptionKind::Call,
3667            "150.00",
3668            expiry_ns,
3669        );
3670        let put_option = future_option_with_expiration(
3671            "ESH4P150.GLBX",
3672            "ESH4P150",
3673            "ESH4",
3674            OptionKind::Put,
3675            "150.00",
3676            expiry_ns,
3677        );
3678        let target_call_option = future_option_with_expiration(
3679            "ESH4C152.GLBX",
3680            "ESH4C152",
3681            "ESH4",
3682            OptionKind::Call,
3683            "152.00",
3684            expiry_ns,
3685        );
3686
3687        let cache = Rc::new(RefCell::new(Cache::new(None, None)));
3688        cache
3689            .borrow_mut()
3690            .add_instrument(InstrumentAny::FuturesContract(future))
3691            .unwrap();
3692        cache
3693            .borrow_mut()
3694            .add_instrument(InstrumentAny::FuturesContract(reference_future.clone()))
3695            .unwrap();
3696        cache
3697            .borrow_mut()
3698            .add_instrument(InstrumentAny::OptionContract(call_option.clone()))
3699            .unwrap();
3700        cache
3701            .borrow_mut()
3702            .add_instrument(InstrumentAny::OptionContract(put_option.clone()))
3703            .unwrap();
3704        cache
3705            .borrow_mut()
3706            .add_instrument(InstrumentAny::OptionContract(target_call_option.clone()))
3707            .unwrap();
3708
3709        let call_quote = QuoteTick::new(
3710            call_option.id(),
3711            Price::from("8.50"),
3712            Price::from("8.50"),
3713            Quantity::from(100),
3714            Quantity::from(100),
3715            now_ns,
3716            now_ns,
3717        );
3718
3719        let put_quote = QuoteTick::new(
3720            put_option.id(),
3721            Price::from("3.33"),
3722            Price::from("3.33"),
3723            Quantity::from(100),
3724            Quantity::from(100),
3725            now_ns,
3726            now_ns,
3727        );
3728
3729        let target_call_quote = QuoteTick::new(
3730            target_call_option.id(),
3731            Price::from("6.75"),
3732            Price::from("6.75"),
3733            Quantity::from(100),
3734            Quantity::from(100),
3735            now_ns,
3736            now_ns,
3737        );
3738
3739        let reference_future_quote = QuoteTick::new(
3740            reference_future.id(),
3741            Price::from("155.00"),
3742            Price::from("155.00"),
3743            Quantity::from(100),
3744            Quantity::from(100),
3745            now_ns,
3746            now_ns,
3747        );
3748        cache.borrow_mut().add_quote(call_quote).unwrap();
3749        cache.borrow_mut().add_quote(put_quote).unwrap();
3750        cache.borrow_mut().add_quote(target_call_quote).unwrap();
3751        cache
3752            .borrow_mut()
3753            .add_quote(reference_future_quote)
3754            .unwrap();
3755
3756        let clock = Rc::new(RefCell::new(VirtualClock::new()));
3757        clock.borrow_mut().set_time(now_ns);
3758        let calculator = GreeksCalculator::new(cache, clock);
3759        calculator
3760            .cache_futures_spread(call_option.id(), put_option.id(), reference_future.id())
3761            .unwrap();
3762
3763        let greeks = calculator
3764            .instrument_greeks(
3765                target_call_option.id(),
3766                Some(0.0425),
3767                None,
3768                None,
3769                None,
3770                None,
3771                None,
3772                None,
3773                None,
3774                None,
3775                Some(now_ns),
3776                None,
3777                None,
3778                None,
3779                None,
3780                None,
3781                None,
3782                None,
3783            )
3784            .unwrap();
3785
3786        let expected_underlying = reference_future
3787            .make_price(150.0 + (0.0425_f64 * (30.0 / 365.25)).exp() * (8.50 - 3.33))
3788            .as_f64();
3789        assert_eq!(greeks.underlying_price, expected_underlying);
3790    }
3791
3792    #[rstest]
3793    fn test_instrument_greeks_uses_index_price_for_index_underlying() {
3794        let now = utc_timestamp(2024, 2, 14, 16, 0, 0);
3795        let expiry = utc_timestamp(2024, 3, 15, 16, 0, 0);
3796        let now_ns = UnixNanos::from(now);
3797        let expiry_ns = UnixNanos::from(expiry);
3798
3799        let future = future_with_expiration("ESH4.GLBX", "ESH4", expiry_ns);
3800        let call_option = future_option_with_expiration(
3801            "ESH4C150.GLBX",
3802            "ESH4C150",
3803            "ESH4",
3804            OptionKind::Call,
3805            "150.00",
3806            expiry_ns,
3807        );
3808
3809        let cache = Rc::new(RefCell::new(Cache::new(None, None)));
3810        cache
3811            .borrow_mut()
3812            .add_instrument(InstrumentAny::FuturesContract(future))
3813            .unwrap();
3814        cache
3815            .borrow_mut()
3816            .add_instrument(InstrumentAny::OptionContract(call_option.clone()))
3817            .unwrap();
3818
3819        let call_quote = QuoteTick::new(
3820            call_option.id(),
3821            Price::from("8.50"),
3822            Price::from("8.50"),
3823            Quantity::from(100),
3824            Quantity::from(100),
3825            now_ns,
3826            now_ns,
3827        );
3828        cache.borrow_mut().add_quote(call_quote).unwrap();
3829        cache
3830            .borrow_mut()
3831            .add_index_price(IndexPriceUpdate::new(
3832                InstrumentId::from("ESH4.GLBX"),
3833                Price::from("157.25"),
3834                now_ns,
3835                now_ns,
3836            ))
3837            .unwrap();
3838
3839        let clock = Rc::new(RefCell::new(VirtualClock::new()));
3840        clock.borrow_mut().set_time(now_ns);
3841        let calculator = GreeksCalculator::new(cache, clock);
3842
3843        let greeks = calculator
3844            .instrument_greeks(
3845                call_option.id(),
3846                Some(0.0425),
3847                None,
3848                None,
3849                None,
3850                None,
3851                None,
3852                None,
3853                None,
3854                None,
3855                Some(now_ns),
3856                None,
3857                None,
3858                None,
3859                None,
3860                None,
3861                None,
3862                None,
3863            )
3864            .unwrap();
3865
3866        assert_eq!(greeks.underlying_price, 157.25);
3867    }
3868
3869    #[rstest]
3870    fn test_instrument_greeks_prefers_quote_over_index_price_for_index_future() {
3871        let now = utc_timestamp(2024, 2, 14, 16, 0, 0);
3872        let expiry = utc_timestamp(2024, 3, 15, 16, 0, 0);
3873        let now_ns = UnixNanos::from(now);
3874        let expiry_ns = UnixNanos::from(expiry);
3875
3876        let future = future_with_expiration("ESH4.GLBX", "ESH4", expiry_ns);
3877        let call_option = future_option_with_expiration(
3878            "ESH4C5500.GLBX",
3879            "ESH4C5500",
3880            "ESH4",
3881            OptionKind::Call,
3882            "5500.00",
3883            expiry_ns,
3884        );
3885
3886        let cache = Rc::new(RefCell::new(Cache::new(None, None)));
3887        cache
3888            .borrow_mut()
3889            .add_instrument(InstrumentAny::FuturesContract(future))
3890            .unwrap();
3891        cache
3892            .borrow_mut()
3893            .add_instrument(InstrumentAny::OptionContract(call_option.clone()))
3894            .unwrap();
3895
3896        // Both a quote and an index price for the underlying future
3897        let future_quote = QuoteTick::new(
3898            InstrumentId::from("ESH4.GLBX"),
3899            Price::from("158.50"),
3900            Price::from("159.50"),
3901            Quantity::from(100),
3902            Quantity::from(100),
3903            now_ns,
3904            now_ns,
3905        );
3906        cache.borrow_mut().add_quote(future_quote).unwrap();
3907        cache
3908            .borrow_mut()
3909            .add_index_price(IndexPriceUpdate::new(
3910                InstrumentId::from("ESH4.GLBX"),
3911                Price::from("157.25"),
3912                now_ns,
3913                now_ns,
3914            ))
3915            .unwrap();
3916
3917        let call_quote = QuoteTick::new(
3918            call_option.id(),
3919            Price::from("8.50"),
3920            Price::from("8.50"),
3921            Quantity::from(100),
3922            Quantity::from(100),
3923            now_ns,
3924            now_ns,
3925        );
3926        cache.borrow_mut().add_quote(call_quote).unwrap();
3927
3928        let clock = Rc::new(RefCell::new(VirtualClock::new()));
3929        clock.borrow_mut().set_time(now_ns);
3930        let calculator = GreeksCalculator::new(cache, clock);
3931
3932        let greeks = calculator
3933            .instrument_greeks(
3934                call_option.id(),
3935                Some(0.0425),
3936                None,
3937                None,
3938                None,
3939                None,
3940                None,
3941                None,
3942                None,
3943                None,
3944                Some(now_ns),
3945                None,
3946                None,
3947                None,
3948                None,
3949                None,
3950                None,
3951                None,
3952            )
3953            .unwrap();
3954
3955        // Should use the MID quote (159.00), not the index price (157.25)
3956        assert_eq!(greeks.underlying_price, 159.0);
3957    }
3958
3959    /// Builds a calculator over the standard option/underlying quote pair used by the
3960    /// `instrument_greeks` tests, plus the option id to price.
3961    fn option_calculator() -> (GreeksCalculator, InstrumentId, UnixNanos) {
3962        let now = utc_timestamp(2025, 3, 8, 12, 0, 0);
3963        let expiry = now + jiff::SignedDuration::from_hours(24 * 30);
3964        let now_ns = UnixNanos::from(now);
3965        let option = option_with_expiration("AAPL250417C00150000.OPRA", UnixNanos::from(expiry));
3966        let option_id = option.id();
3967        let cache =
3968            setup_cache_with_option_and_quotes(option, InstrumentId::from("AAPL.OPRA"), now_ns);
3969        let clock = Rc::new(RefCell::new(VirtualClock::new()));
3970
3971        (GreeksCalculator::new(cache, clock), option_id, now_ns)
3972    }
3973
3974    fn option_greeks_with_shocks(
3975        calculator: &GreeksCalculator,
3976        option_id: InstrumentId,
3977        ts_event: UnixNanos,
3978        spot_shock: Option<f64>,
3979        vol_shock: Option<f64>,
3980        time_to_expiry_shock: Option<f64>,
3981    ) -> GreeksData {
3982        calculator
3983            .instrument_greeks(
3984                option_id,
3985                None,
3986                None,
3987                spot_shock,
3988                vol_shock,
3989                time_to_expiry_shock,
3990                None,
3991                None,
3992                None,
3993                None,
3994                Some(ts_event),
3995                None,
3996                None,
3997                None,
3998                None,
3999                None,
4000                None,
4001                None,
4002            )
4003            .unwrap()
4004    }
4005
4006    #[rstest]
4007    #[case::spot(Some(5.0), None, None)]
4008    #[case::vol(None, Some(0.05), None)]
4009    #[case::time_to_expiry(None, None, Some(0.01))]
4010    fn test_instrument_greeks_applies_each_shock_dimension_on_its_own(
4011        #[case] spot_shock: Option<f64>,
4012        #[case] vol_shock: Option<f64>,
4013        #[case] time_to_expiry_shock: Option<f64>,
4014    ) {
4015        let (calculator, option_id, now_ns) = option_calculator();
4016
4017        let unshocked = option_greeks_with_shocks(&calculator, option_id, now_ns, None, None, None);
4018        let shocked = option_greeks_with_shocks(
4019            &calculator,
4020            option_id,
4021            now_ns,
4022            spot_shock,
4023            vol_shock,
4024            time_to_expiry_shock,
4025        );
4026
4027        assert_ne!(
4028            scaled(shocked.price),
4029            scaled(unshocked.price),
4030            "a single non-zero shock must still reprice the option"
4031        );
4032    }
4033
4034    #[rstest]
4035    fn test_instrument_greeks_leaves_the_price_unshocked_when_every_shock_is_zero() {
4036        let (calculator, option_id, now_ns) = option_calculator();
4037
4038        let unshocked = option_greeks_with_shocks(&calculator, option_id, now_ns, None, None, None);
4039        let zero_shocks = option_greeks_with_shocks(
4040            &calculator,
4041            option_id,
4042            now_ns,
4043            Some(0.0),
4044            Some(0.0),
4045            Some(0.0),
4046        );
4047
4048        assert_eq!(scaled(zero_shocks.price), scaled(unshocked.price));
4049    }
4050
4051    #[rstest]
4052    fn test_instrument_greeks_pnl_is_price_less_the_position_open_price() {
4053        let (calculator, option_id, now_ns) = option_calculator();
4054        let instrument = calculator
4055            .cache
4056            .borrow()
4057            .instrument(&option_id)
4058            .cloned()
4059            .unwrap();
4060        let position = position_from_fill(
4061            &instrument,
4062            "P-GREEKS-1",
4063            "O-GREEKS-1",
4064            "T-GREEKS-1",
4065            OrderSide::Buy,
4066            1,
4067            "9.00",
4068        );
4069
4070        let unshocked = option_greeks_with_shocks(&calculator, option_id, now_ns, None, None, None);
4071        let with_position = calculator
4072            .instrument_greeks(
4073                option_id,
4074                None,
4075                None,
4076                None,
4077                None,
4078                None,
4079                None,
4080                None,
4081                None,
4082                None,
4083                Some(now_ns),
4084                Some(position),
4085                None,
4086                None,
4087                None,
4088                None,
4089                None,
4090                None,
4091            )
4092            .unwrap();
4093
4094        assert_eq!(scaled(with_position.price), scaled(unshocked.price));
4095        assert_eq!(
4096            scaled(with_position.pnl),
4097            scaled(unshocked.price - 9.0),
4098            "pnl must subtract the position open price from the option price"
4099        );
4100        assert_eq!(scaled(unshocked.pnl), 0.0);
4101    }
4102
4103    #[rstest]
4104    fn test_instrument_greeks_records_the_shocked_market_state() {
4105        let (calculator, option_id, now_ns) = option_calculator();
4106
4107        let unshocked = option_greeks_with_shocks(&calculator, option_id, now_ns, None, None, None);
4108        let shocked = option_greeks_with_shocks(
4109            &calculator,
4110            option_id,
4111            now_ns,
4112            Some(5.0),
4113            Some(0.05),
4114            Some(0.01),
4115        );
4116
4117        // The underlying mid of the 150.00 / 150.10 quote plus the spot shock.
4118        assert_eq!(scaled(unshocked.underlying_price), 150_050_000_000_000.0);
4119        assert_eq!(scaled(shocked.underlying_price), 155_050_000_000_000.0);
4120        assert_eq!(scaled(shocked.vol), scaled(unshocked.vol + 0.05));
4121        assert_eq!(
4122            scaled(shocked.expiry_in_years),
4123            scaled(unshocked.expiry_in_years - 0.01)
4124        );
4125        assert_eq!(unshocked.expiry_in_days, 30);
4126        assert_eq!(shocked.expiry_in_days, 26);
4127    }
4128
4129    #[rstest]
4130    fn test_instrument_greeks_cost_of_carry_subtracts_the_flat_dividend_yield() {
4131        let (calculator, option_id, now_ns) = option_calculator();
4132
4133        let greeks = calculator
4134            .instrument_greeks(
4135                option_id,
4136                Some(0.05),
4137                Some(0.02),
4138                None,
4139                None,
4140                None,
4141                None,
4142                None,
4143                None,
4144                None,
4145                Some(now_ns),
4146                None,
4147                None,
4148                None,
4149                None,
4150                None,
4151                None,
4152                None,
4153            )
4154            .unwrap();
4155
4156        assert_eq!(scaled(greeks.interest_rate), 50_000_000_000.0);
4157        assert_eq!(scaled(greeks.cost_of_carry), 30_000_000_000.0);
4158    }
4159
4160    #[rstest]
4161    fn test_instrument_greeks_cost_of_carry_is_zero_without_a_dividend_yield() {
4162        let (calculator, option_id, now_ns) = option_calculator();
4163
4164        let greeks = calculator
4165            .instrument_greeks(
4166                option_id,
4167                Some(0.05),
4168                None,
4169                None,
4170                None,
4171                None,
4172                None,
4173                None,
4174                None,
4175                None,
4176                Some(now_ns),
4177                None,
4178                None,
4179                None,
4180                None,
4181                None,
4182                None,
4183                None,
4184            )
4185            .unwrap();
4186
4187        assert_eq!(scaled(greeks.cost_of_carry), 0.0);
4188    }
4189
4190    #[rstest]
4191    fn test_instrument_greeks_reports_the_option_kind() {
4192        let now = utc_timestamp(2025, 3, 8, 12, 0, 0);
4193        let expiry = now + jiff::SignedDuration::from_hours(24 * 30);
4194        let now_ns = UnixNanos::from(now);
4195        let underlying_id = InstrumentId::from("AAPL.OPRA");
4196        let mut put = option_with_expiration("AAPL250417P00150000.OPRA", UnixNanos::from(expiry));
4197        put.option_kind = OptionKind::Put;
4198        let put_id = put.id();
4199        let cache = setup_cache_with_option_and_quotes(put, underlying_id, now_ns);
4200        cache
4201            .borrow_mut()
4202            .add_quote(QuoteTick::new(
4203                put_id,
4204                Price::from("10.50"),
4205                Price::from("10.60"),
4206                Quantity::from(100),
4207                Quantity::from(100),
4208                now_ns,
4209                now_ns,
4210            ))
4211            .unwrap();
4212        let calculator = GreeksCalculator::new(cache, Rc::new(RefCell::new(VirtualClock::new())));
4213
4214        let greeks = option_greeks_with_shocks(&calculator, put_id, now_ns, None, None, None);
4215
4216        assert!(!greeks.is_call);
4217    }
4218
4219    /// Builds a calculator over a single equity quote, for the `instrument_greeks` paths that
4220    /// price a non-option instrument.
4221    fn equity_calculator() -> (GreeksCalculator, InstrumentId, UnixNanos) {
4222        let now_ns = UnixNanos::from(utc_timestamp(2025, 3, 8, 12, 0, 0));
4223        let cache = Rc::new(RefCell::new(Cache::new(None, None)));
4224        let equity = equity_aapl_opra();
4225        let equity_id = equity.id();
4226        cache
4227            .borrow_mut()
4228            .add_instrument(InstrumentAny::Equity(equity))
4229            .unwrap();
4230        cache
4231            .borrow_mut()
4232            .add_quote(QuoteTick::new(
4233                equity_id,
4234                Price::from("150.00"),
4235                Price::from("150.10"),
4236                Quantity::from(100),
4237                Quantity::from(100),
4238                now_ns,
4239                now_ns,
4240            ))
4241            .unwrap();
4242        let clock = Rc::new(RefCell::new(VirtualClock::new()));
4243
4244        (GreeksCalculator::new(cache, clock), equity_id, now_ns)
4245    }
4246
4247    #[rstest]
4248    fn test_instrument_greeks_for_a_non_option_shocks_the_spot_and_prices_the_position() {
4249        let (calculator, equity_id, now_ns) = equity_calculator();
4250        let instrument = calculator
4251            .cache
4252            .borrow()
4253            .instrument(&equity_id)
4254            .cloned()
4255            .unwrap();
4256        let position = position_from_fill(
4257            &instrument,
4258            "P-EQUITY-1",
4259            "O-EQUITY-1",
4260            "T-EQUITY-1",
4261            OrderSide::Buy,
4262            10,
4263            "140.00",
4264        );
4265
4266        let greeks = calculator
4267            .instrument_greeks(
4268                equity_id,
4269                None,
4270                None,
4271                Some(5.0),
4272                None,
4273                None,
4274                None,
4275                None,
4276                None,
4277                None,
4278                Some(now_ns),
4279                Some(position),
4280                None,
4281                None,
4282                None,
4283                None,
4284                None,
4285                None,
4286            )
4287            .unwrap();
4288
4289        // Underlying mid 150.05 plus the 5.00 spot shock, less the 140.00 open price.
4290        assert_eq!(scaled(greeks.pnl), 15_050_000_000_000.0);
4291        assert_eq!(scaled(greeks.price), 15_050_000_000_000.0);
4292    }
4293
4294    #[rstest]
4295    fn test_instrument_greeks_for_a_non_option_scales_percent_delta_by_the_shocked_spot() {
4296        let (calculator, equity_id, now_ns) = equity_calculator();
4297
4298        let greeks = calculator
4299            .instrument_greeks(
4300                equity_id,
4301                None,
4302                None,
4303                Some(5.0),
4304                None,
4305                None,
4306                None,
4307                None,
4308                None,
4309                None,
4310                Some(now_ns),
4311                None,
4312                Some(true),
4313                None,
4314                None,
4315                None,
4316                None,
4317                None,
4318            )
4319            .unwrap();
4320
4321        // Percent delta scales the unit delta by the shocked spot of 150.05 + 5.00.
4322        assert_eq!(scaled(greeks.delta), 1_550_500_000_000.0);
4323    }
4324
4325    #[rstest]
4326    fn test_instrument_greeks_cost_of_carry_prefers_the_dividend_curve_over_the_flat_yield() {
4327        let (calculator, option_id, now_ns) = option_calculator();
4328        calculator
4329            .cache
4330            .borrow_mut()
4331            .add_yield_curve(YieldCurve::new(
4332                "AAPL.OPRA".to_string(),
4333                vec![0.0, 5.0, 10.0],
4334                vec![0.02, 0.02, 0.02],
4335                now_ns,
4336                now_ns,
4337            ))
4338            .unwrap();
4339
4340        let greeks = calculator
4341            .instrument_greeks(
4342                option_id,
4343                Some(0.05),
4344                Some(0.04),
4345                None,
4346                None,
4347                None,
4348                None,
4349                None,
4350                None,
4351                None,
4352                Some(now_ns),
4353                None,
4354                None,
4355                None,
4356                None,
4357                None,
4358                None,
4359                None,
4360            )
4361            .unwrap();
4362
4363        assert_eq!(scaled(greeks.interest_rate), 50_000_000_000.0);
4364        assert_eq!(
4365            scaled(greeks.cost_of_carry),
4366            30_000_000_000.0,
4367            "the dividend curve must take precedence over the flat dividend yield"
4368        );
4369    }
4370
4371    /// Scales a Greek to picounits so exact expectations avoid raw float equality.
4372    fn scaled(value: f64) -> f64 {
4373        (value * 1e12).round()
4374    }
4375
4376    #[rstest]
4377    fn test_modify_greeks_reprices_the_index_when_the_underlying_is_shocked() {
4378        let calculator = create_test_calculator();
4379        let underlying_id = InstrumentId::from("AAPL.OPRA");
4380        let mut beta_weights = HashMap::new();
4381        beta_weights.insert(underlying_id, 0.5);
4382
4383        let (delta, gamma, vega) = calculator
4384            .modify_greeks(
4385                1.0,
4386                2.0,
4387                underlying_id,
4388                165.0,
4389                150.0,
4390                false,
4391                None,
4392                Some(&beta_weights),
4393                3.0,
4394                0.30,
4395                0,
4396                None,
4397                0.0,
4398                None,
4399                None,
4400                Some(200.0),
4401                None,
4402            )
4403            .unwrap();
4404
4405        // Index moves to 200 + (1 / 0.5) * (200 / 150) * (165 - 150) = 240, so the delta
4406        // multiplier is 0.5 * 165 / 240.
4407        assert_eq!(scaled(delta), 343_750_000_000.0);
4408        assert_eq!(scaled(gamma), 236_328_125_000.0);
4409        assert_eq!(scaled(vega), 3_000_000_000_000.0);
4410    }
4411
4412    #[rstest]
4413    fn test_modify_greeks_reprices_the_vol_index_when_the_vol_is_shocked() {
4414        let calculator = create_test_calculator();
4415        let underlying_id = InstrumentId::from("AAPL.OPRA");
4416        let mut vol_beta_weights = HashMap::new();
4417        vol_beta_weights.insert(underlying_id, 0.75);
4418
4419        let (delta, gamma, vega) = calculator
4420            .modify_greeks(
4421                1.0,
4422                2.0,
4423                underlying_id,
4424                150.0,
4425                150.0,
4426                false,
4427                None,
4428                None,
4429                2.0,
4430                0.35,
4431                0,
4432                None,
4433                0.30,
4434                None,
4435                Some(&vol_beta_weights),
4436                None,
4437                Some(25.0),
4438            )
4439            .unwrap();
4440
4441        assert_eq!(scaled(delta), 1_000_000_000_000.0);
4442        assert_eq!(scaled(gamma), 2_000_000_000_000.0);
4443        assert_eq!(scaled(vega), 1_718_181_818_182.0);
4444    }
4445
4446    #[rstest]
4447    fn test_modify_greeks_uses_the_shocked_vol_as_the_baseline_when_unshocked_vol_is_zero() {
4448        let calculator = create_test_calculator();
4449
4450        let (_, _, vega) = calculator
4451            .modify_greeks(
4452                1.0,
4453                2.0,
4454                InstrumentId::from("AAPL.OPRA"),
4455                150.0,
4456                150.0,
4457                false,
4458                None,
4459                None,
4460                2.0,
4461                0.40,
4462                0,
4463                None,
4464                0.0,
4465                None,
4466                None,
4467                None,
4468                Some(25.0),
4469            )
4470            .unwrap();
4471
4472        // A zero unshocked vol means no vol shock, so vega only rescales by 0.40 / 0.25.
4473        assert_eq!(scaled(vega), 3_200_000_000_000.0);
4474    }
4475
4476    #[rstest]
4477    fn test_modify_greeks_leaves_vega_untouched_for_a_zero_vol_index() {
4478        let calculator = create_test_calculator();
4479
4480        let (_, _, vega) = calculator
4481            .modify_greeks(
4482                1.0,
4483                2.0,
4484                InstrumentId::from("AAPL.OPRA"),
4485                150.0,
4486                150.0,
4487                false,
4488                None,
4489                None,
4490                2.0,
4491                0.40,
4492                0,
4493                None,
4494                0.0,
4495                None,
4496                None,
4497                None,
4498                Some(0.0),
4499            )
4500            .unwrap();
4501
4502        assert_eq!(scaled(vega), 2_000_000_000_000.0);
4503    }
4504
4505    #[rstest]
4506    fn test_modify_greeks_percent_scaling_falls_back_to_the_underlying_and_vol() {
4507        let calculator = create_test_calculator();
4508
4509        let (delta, gamma, vega) = calculator
4510            .modify_greeks(
4511                1.0,
4512                2.0,
4513                InstrumentId::from("AAPL.OPRA"),
4514                150.0,
4515                150.0,
4516                true,
4517                None,
4518                None,
4519                2.0,
4520                0.30,
4521                0,
4522                None,
4523                0.0,
4524                None,
4525                None,
4526                None,
4527                None,
4528            )
4529            .unwrap();
4530
4531        assert_eq!(scaled(delta), 1_500_000_000_000.0);
4532        assert_eq!(scaled(gamma), 4_500_000_000_000.0);
4533        assert_eq!(scaled(vega), 6_000_000_000.0);
4534    }
4535
4536    #[rstest]
4537    fn test_modify_greeks_applies_vega_time_weighting() {
4538        let calculator = create_test_calculator();
4539
4540        let (_, _, vega) = calculator
4541            .modify_greeks(
4542                1.0,
4543                2.0,
4544                InstrumentId::from("AAPL.OPRA"),
4545                150.0,
4546                150.0,
4547                false,
4548                None,
4549                None,
4550                2.0,
4551                0.30,
4552                120,
4553                Some(30),
4554                0.0,
4555                None,
4556                None,
4557                None,
4558                None,
4559            )
4560            .unwrap();
4561
4562        // sqrt(30 / 120) = 0.5
4563        assert_eq!(scaled(vega), 1_000_000_000_000.0);
4564    }
4565
4566    #[rstest]
4567    fn test_modify_greeks_skips_vega_time_weighting_without_days_to_expiry() {
4568        let calculator = create_test_calculator();
4569
4570        let (_, _, vega) = calculator
4571            .modify_greeks(
4572                1.0,
4573                2.0,
4574                InstrumentId::from("AAPL.OPRA"),
4575                150.0,
4576                150.0,
4577                false,
4578                None,
4579                None,
4580                2.0,
4581                0.30,
4582                0,
4583                Some(30),
4584                0.0,
4585                None,
4586                None,
4587                None,
4588                None,
4589            )
4590            .unwrap();
4591
4592        assert_eq!(scaled(vega), 2_000_000_000_000.0);
4593    }
4594
4595    const SPREAD_CALL_ID: &str = "ESH4C150.GLBX";
4596    const SPREAD_PUT_ID: &str = "ESH4P150.GLBX";
4597    const SPREAD_REFERENCE_ID: &str = "ESM4.GLBX";
4598    const SPREAD_UNDERLYING_ID: &str = "ESH4.GLBX";
4599
4600    fn spread_now_ns() -> UnixNanos {
4601        UnixNanos::from(utc_timestamp(2024, 2, 14, 16, 0, 0))
4602    }
4603
4604    fn spread_expiry_ns() -> UnixNanos {
4605        UnixNanos::from(utc_timestamp(2024, 3, 15, 16, 0, 0))
4606    }
4607
4608    fn spread_call() -> OptionContract {
4609        future_option_with_expiration(
4610            SPREAD_CALL_ID,
4611            "ESH4C150",
4612            "ESH4",
4613            OptionKind::Call,
4614            "150.00",
4615            spread_expiry_ns(),
4616        )
4617    }
4618
4619    fn spread_put() -> OptionContract {
4620        future_option_with_expiration(
4621            SPREAD_PUT_ID,
4622            "ESH4P150",
4623            "ESH4",
4624            OptionKind::Put,
4625            "150.00",
4626            spread_expiry_ns(),
4627        )
4628    }
4629
4630    fn spread_reference_future() -> FuturesContract {
4631        future_with_expiration(SPREAD_REFERENCE_ID, "ESM4", spread_expiry_ns())
4632    }
4633
4634    fn spread_quote(instrument_id: InstrumentId, price: &str) -> QuoteTick {
4635        let price = Price::from(price);
4636
4637        QuoteTick::new(
4638            instrument_id,
4639            price,
4640            price,
4641            Quantity::from(100),
4642            Quantity::from(100),
4643            spread_now_ns(),
4644            spread_now_ns(),
4645        )
4646    }
4647
4648    /// Builds a calculator over exactly the supplied instruments and quotes.
4649    ///
4650    /// Each `cache_futures_spread` error test drops or replaces one entry relative to
4651    /// [`spread_instruments`] and [`spread_quotes`], so the resulting failure isolates a
4652    /// single validation branch.
4653    fn spread_calculator(
4654        instruments: Vec<InstrumentAny>,
4655        quotes: Vec<QuoteTick>,
4656    ) -> GreeksCalculator {
4657        let cache = Rc::new(RefCell::new(Cache::new(None, None)));
4658        for instrument in instruments {
4659            cache.borrow_mut().add_instrument(instrument).unwrap();
4660        }
4661
4662        for quote in quotes {
4663            cache.borrow_mut().add_quote(quote).unwrap();
4664        }
4665
4666        let clock = Rc::new(RefCell::new(VirtualClock::new()));
4667        clock.borrow_mut().set_time(spread_now_ns());
4668
4669        GreeksCalculator::new(cache, clock)
4670    }
4671
4672    fn spread_instruments() -> Vec<InstrumentAny> {
4673        vec![
4674            InstrumentAny::OptionContract(spread_call()),
4675            InstrumentAny::OptionContract(spread_put()),
4676            InstrumentAny::FuturesContract(spread_reference_future()),
4677        ]
4678    }
4679
4680    fn spread_quotes() -> Vec<QuoteTick> {
4681        vec![
4682            spread_quote(InstrumentId::from(SPREAD_CALL_ID), "8.50"),
4683            spread_quote(InstrumentId::from(SPREAD_PUT_ID), "3.33"),
4684            spread_quote(InstrumentId::from(SPREAD_REFERENCE_ID), "155.00"),
4685        ]
4686    }
4687
4688    fn cache_spread_error(instruments: Vec<InstrumentAny>, quotes: Vec<QuoteTick>) -> String {
4689        spread_calculator(instruments, quotes)
4690            .cache_futures_spread(
4691                InstrumentId::from(SPREAD_CALL_ID),
4692                InstrumentId::from(SPREAD_PUT_ID),
4693                InstrumentId::from(SPREAD_REFERENCE_ID),
4694            )
4695            .unwrap_err()
4696            .to_string()
4697    }
4698
4699    #[rstest]
4700    fn test_cache_futures_spread_errors_when_call_instrument_missing() {
4701        let instruments = vec![
4702            InstrumentAny::OptionContract(spread_put()),
4703            InstrumentAny::FuturesContract(spread_reference_future()),
4704        ];
4705
4706        assert_eq!(
4707            cache_spread_error(instruments, spread_quotes()),
4708            "Cannot cache futures spread: missing option instrument ESH4C150.GLBX"
4709        );
4710    }
4711
4712    #[rstest]
4713    fn test_cache_futures_spread_errors_when_put_instrument_missing() {
4714        let instruments = vec![
4715            InstrumentAny::OptionContract(spread_call()),
4716            InstrumentAny::FuturesContract(spread_reference_future()),
4717        ];
4718
4719        assert_eq!(
4720            cache_spread_error(instruments, spread_quotes()),
4721            "Cannot cache futures spread: missing option instrument ESH4P150.GLBX"
4722        );
4723    }
4724
4725    #[rstest]
4726    fn test_cache_futures_spread_errors_when_reference_future_instrument_missing() {
4727        let instruments = vec![
4728            InstrumentAny::OptionContract(spread_call()),
4729            InstrumentAny::OptionContract(spread_put()),
4730        ];
4731
4732        assert_eq!(
4733            cache_spread_error(instruments, spread_quotes()),
4734            "Cannot cache futures spread: no reference futures instrument for ESM4.GLBX"
4735        );
4736    }
4737
4738    #[rstest]
4739    fn test_cache_futures_spread_errors_when_call_leg_is_not_an_option() {
4740        let non_option = future_with_expiration(SPREAD_CALL_ID, "ESH4", spread_expiry_ns());
4741        let instruments = vec![
4742            InstrumentAny::FuturesContract(non_option),
4743            InstrumentAny::OptionContract(spread_put()),
4744            InstrumentAny::FuturesContract(spread_reference_future()),
4745        ];
4746
4747        assert_eq!(
4748            cache_spread_error(instruments, spread_quotes()),
4749            "Cannot cache futures spread: non-option instruments provided \
4750             call_instrument_id=ESH4C150.GLBX put_instrument_id=ESH4P150.GLBX"
4751        );
4752    }
4753
4754    #[rstest]
4755    fn test_cache_futures_spread_errors_when_legs_are_not_a_call_put_pair() {
4756        let second_put = future_option_with_expiration(
4757            SPREAD_CALL_ID,
4758            "ESH4P150",
4759            "ESH4",
4760            OptionKind::Put,
4761            "150.00",
4762            spread_expiry_ns(),
4763        );
4764        let instruments = vec![
4765            InstrumentAny::OptionContract(second_put),
4766            InstrumentAny::OptionContract(spread_put()),
4767            InstrumentAny::FuturesContract(spread_reference_future()),
4768        ];
4769
4770        assert_eq!(
4771            cache_spread_error(instruments, spread_quotes()),
4772            "Cannot cache futures spread: expected call/put pair \
4773             call_instrument_id=ESH4C150.GLBX put_instrument_id=ESH4P150.GLBX"
4774        );
4775    }
4776
4777    #[rstest]
4778    fn test_cache_futures_spread_errors_when_underlyings_differ() {
4779        let mismatched_put = future_option_with_expiration(
4780            SPREAD_PUT_ID,
4781            "ESM4P150",
4782            "ESM4",
4783            OptionKind::Put,
4784            "150.00",
4785            spread_expiry_ns(),
4786        );
4787        let instruments = vec![
4788            InstrumentAny::OptionContract(spread_call()),
4789            InstrumentAny::OptionContract(mismatched_put),
4790            InstrumentAny::FuturesContract(spread_reference_future()),
4791        ];
4792
4793        assert_eq!(
4794            cache_spread_error(instruments, spread_quotes()),
4795            "Cannot cache futures spread: option underlyings differ \
4796             call_instrument_id=ESH4C150.GLBX put_instrument_id=ESH4P150.GLBX"
4797        );
4798    }
4799
4800    #[rstest]
4801    fn test_cache_futures_spread_errors_when_strike_prices_differ() {
4802        let mismatched_put = future_option_with_expiration(
4803            SPREAD_PUT_ID,
4804            "ESH4P155",
4805            "ESH4",
4806            OptionKind::Put,
4807            "155.00",
4808            spread_expiry_ns(),
4809        );
4810        let instruments = vec![
4811            InstrumentAny::OptionContract(spread_call()),
4812            InstrumentAny::OptionContract(mismatched_put),
4813            InstrumentAny::FuturesContract(spread_reference_future()),
4814        ];
4815
4816        assert_eq!(
4817            cache_spread_error(instruments, spread_quotes()),
4818            "Cannot cache futures spread: strike prices differ \
4819             call_instrument_id=ESH4C150.GLBX put_instrument_id=ESH4P150.GLBX"
4820        );
4821    }
4822
4823    #[rstest]
4824    fn test_cache_futures_spread_errors_when_expirations_differ() {
4825        let mismatched_put = future_option_with_expiration(
4826            SPREAD_PUT_ID,
4827            "ESH4P150",
4828            "ESH4",
4829            OptionKind::Put,
4830            "150.00",
4831            UnixNanos::from(utc_timestamp(2024, 6, 21, 16, 0, 0)),
4832        );
4833        let instruments = vec![
4834            InstrumentAny::OptionContract(spread_call()),
4835            InstrumentAny::OptionContract(mismatched_put),
4836            InstrumentAny::FuturesContract(spread_reference_future()),
4837        ];
4838
4839        assert_eq!(
4840            cache_spread_error(instruments, spread_quotes()),
4841            "Cannot cache futures spread: expiration dates differ \
4842             call_instrument_id=ESH4C150.GLBX put_instrument_id=ESH4P150.GLBX"
4843        );
4844    }
4845
4846    #[rstest]
4847    fn test_cache_futures_spread_errors_when_reference_future_price_missing() {
4848        let quotes = vec![
4849            spread_quote(InstrumentId::from(SPREAD_CALL_ID), "8.50"),
4850            spread_quote(InstrumentId::from(SPREAD_PUT_ID), "3.33"),
4851        ];
4852
4853        assert_eq!(
4854            cache_spread_error(spread_instruments(), quotes),
4855            "Cannot cache futures spread: no reference futures price for ESM4.GLBX"
4856        );
4857    }
4858
4859    #[rstest]
4860    fn test_cache_futures_spread_errors_when_call_price_missing() {
4861        let quotes = vec![
4862            spread_quote(InstrumentId::from(SPREAD_PUT_ID), "3.33"),
4863            spread_quote(InstrumentId::from(SPREAD_REFERENCE_ID), "155.00"),
4864        ];
4865
4866        assert_eq!(
4867            cache_spread_error(spread_instruments(), quotes),
4868            "Cannot cache futures spread: missing option price for ESH4C150.GLBX"
4869        );
4870    }
4871
4872    #[rstest]
4873    fn test_cache_futures_spread_errors_when_put_price_missing() {
4874        let quotes = vec![
4875            spread_quote(InstrumentId::from(SPREAD_CALL_ID), "8.50"),
4876            spread_quote(InstrumentId::from(SPREAD_REFERENCE_ID), "155.00"),
4877        ];
4878
4879        assert_eq!(
4880            cache_spread_error(spread_instruments(), quotes),
4881            "Cannot cache futures spread: missing option price for ESH4P150.GLBX"
4882        );
4883    }
4884
4885    #[rstest]
4886    fn test_cache_futures_spread_errors_when_cached_underlying_is_not_a_future() {
4887        let underlying_equity = Equity::builder()
4888            .instrument_id(InstrumentId::from(SPREAD_UNDERLYING_ID))
4889            .raw_symbol(Symbol::from("ESH4"))
4890            .currency(Currency::from("USD"))
4891            .price_precision(2)
4892            .price_increment(Price::from("0.01"))
4893            .ts_event(UnixNanos::default())
4894            .ts_init(UnixNanos::default())
4895            .build()
4896            .unwrap();
4897
4898        let mut instruments = spread_instruments();
4899        instruments.push(InstrumentAny::Equity(underlying_equity));
4900
4901        assert_eq!(
4902            cache_spread_error(instruments, spread_quotes()),
4903            "Cannot cache futures spread: underlying ESH4.GLBX is not a futures contract"
4904        );
4905    }
4906
4907    #[rstest]
4908    fn test_cache_futures_spread_leaves_no_entry_when_validation_fails() {
4909        let calculator = spread_calculator(
4910            vec![
4911                InstrumentAny::OptionContract(spread_call()),
4912                InstrumentAny::OptionContract(spread_put()),
4913            ],
4914            spread_quotes(),
4915        );
4916
4917        calculator
4918            .cache_futures_spread(
4919                InstrumentId::from(SPREAD_CALL_ID),
4920                InstrumentId::from(SPREAD_PUT_ID),
4921                InstrumentId::from(SPREAD_REFERENCE_ID),
4922            )
4923            .unwrap_err();
4924
4925        assert_eq!(
4926            calculator.get_cached_futures_spread_price(InstrumentId::from(SPREAD_UNDERLYING_ID)),
4927            None
4928        );
4929    }
4930
4931    #[rstest]
4932    fn test_resolve_underlying_instrument_id_prefers_cached_futures_spread_over_cross_venue_match()
4933    {
4934        let mut instruments = spread_instruments();
4935        instruments.push(InstrumentAny::FuturesContract(future_with_expiration(
4936            "ESH4.XCME",
4937            "ESH4",
4938            spread_expiry_ns(),
4939        )));
4940        let calculator = spread_calculator(instruments, spread_quotes());
4941        calculator
4942            .cache_futures_spread(
4943                InstrumentId::from(SPREAD_CALL_ID),
4944                InstrumentId::from(SPREAD_PUT_ID),
4945                InstrumentId::from(SPREAD_REFERENCE_ID),
4946            )
4947            .unwrap();
4948
4949        let underlying_id = calculator
4950            .resolve_underlying_instrument_id(
4951                &InstrumentAny::OptionContract(spread_call()),
4952                InstrumentId::from(SPREAD_CALL_ID),
4953            )
4954            .unwrap();
4955
4956        assert_eq!(underlying_id, InstrumentId::from(SPREAD_UNDERLYING_ID));
4957    }
4958
4959    #[rstest]
4960    fn test_get_cached_futures_spread_price_returns_none_for_unknown_underlying() {
4961        let calculator = spread_calculator(spread_instruments(), spread_quotes());
4962
4963        assert_eq!(
4964            calculator.get_cached_futures_spread_price(InstrumentId::from("CLZ4.NYMEX")),
4965            None
4966        );
4967    }
4968
4969    #[cfg(feature = "defi")]
4970    #[rstest]
4971    fn test_instrument_greeks_converts_mid_of_max_float_precision_quote() {
4972        let now = utc_timestamp(2025, 3, 8, 12, 0, 0);
4973        let now_ns = UnixNanos::from(now);
4974        let expiry_ns = UnixNanos::from(now + jiff::SignedDuration::from_hours(24 * 30));
4975        let future = future_with_expiration("ES.GLBX", "ES", expiry_ns);
4976        let instrument = InstrumentAny::FuturesContract(future.clone());
4977        let cache = Rc::new(RefCell::new(Cache::new(None, None)));
4978        cache
4979            .borrow_mut()
4980            .add_instrument(instrument.clone())
4981            .unwrap();
4982
4983        let quote = QuoteTick::new(
4984            future.id(),
4985            Price::from("100.0000000000000000"),
4986            Price::from("100.0000000000000001"),
4987            Quantity::from(1),
4988            Quantity::from(1),
4989            now_ns,
4990            now_ns,
4991        );
4992        cache.borrow_mut().add_quote(quote).unwrap();
4993        let position =
4994            position_from_fill(&instrument, "P-1", "O-1", "T-1", OrderSide::Buy, 1, "99.00");
4995        let clock = Rc::new(RefCell::new(VirtualClock::new()));
4996        clock.borrow_mut().set_time(now_ns);
4997        let calculator = GreeksCalculator::new(cache, clock);
4998
4999        let greeks = calculator
5000            .instrument_greeks(
5001                future.id(),
5002                None,
5003                None,
5004                None,
5005                None,
5006                None,
5007                None,
5008                None,
5009                None,
5010                None,
5011                Some(now_ns),
5012                Some(position),
5013                None,
5014                None,
5015                None,
5016                None,
5017                None,
5018                None,
5019            )
5020            .unwrap();
5021
5022        assert_eq!(greeks.delta, 1.0);
5023        assert_eq!(greeks.pnl, 1.0);
5024        assert_eq!(greeks.price, 1.0);
5025    }
5026
5027    #[rstest]
5028    #[case(false, -1)]
5029    #[case(false, 0)]
5030    #[case(true, -1)]
5031    #[case(true, 0)]
5032    fn test_instrument_greeks_rejects_expired_options_before_cached_return(
5033        #[case] cached: bool,
5034        #[case] offset_ns: i64,
5035        #[values(false, true)] clock_default: bool,
5036    ) {
5037        let now_ns = UnixNanos::from(1_000_000_000);
5038        let expiry_ns = UnixNanos::from((1_000_000_000_i64 + offset_ns) as u64);
5039        let option = option_with_expiration("AAPL250417C00150000.OPRA", expiry_ns);
5040        let id = option.id();
5041        let calculator = calculator_with_option_and_equities(option, &[]);
5042        let clock = Rc::new(RefCell::new(VirtualClock::new()));
5043        clock.borrow_mut().set_time(now_ns);
5044        let calculator = GreeksCalculator::new(calculator.cache, clock);
5045
5046        if cached {
5047            let mut data = GreeksData::from_delta(id, 0.5, 100.0, now_ns);
5048            data.underlying_price = 150.0;
5049            data.strike = 150.0;
5050            data.vol = 0.2;
5051            data.expiry_in_years = 1.0;
5052            calculator.cache.borrow_mut().add_greeks(data).unwrap();
5053        }
5054
5055        let mut params = InstrumentGreeksParams::builder()
5056            .instrument_id(id)
5057            .ts_event(now_ns)
5058            .use_cached_greeks(cached)
5059            .build();
5060
5061        if clock_default {
5062            params.ts_event = None;
5063        }
5064
5065        let result = params.calculate(&calculator).unwrap_err();
5066        assert_eq!(
5067            result.downcast_ref::<OptionValuationError>(),
5068            Some(&OptionValuationError::Expired(id))
5069        );
5070    }
5071
5072    #[rstest]
5073    #[case(InstrumentAny::OptionSpread(option_spread()))]
5074    #[case(InstrumentAny::CryptoOptionSpread(crypto_option_spread_btc_deribit()))]
5075    fn test_instrument_greeks_rejects_option_spreads(#[case] instrument: InstrumentAny) {
5076        let id = instrument.id();
5077        let calculator = create_test_calculator();
5078        calculator
5079            .cache
5080            .borrow_mut()
5081            .add_instrument(instrument)
5082            .unwrap();
5083        let result = InstrumentGreeksParams::builder()
5084            .instrument_id(id)
5085            .build()
5086            .calculate(&calculator)
5087            .unwrap_err();
5088        assert_eq!(
5089            result.downcast_ref::<OptionValuationError>(),
5090            Some(&OptionValuationError::UnsupportedSpread(id))
5091        );
5092    }
5093
5094    #[rstest]
5095    #[case(-200.0, 0.0, 0.0, "underlying price")]
5096    #[case(0.0, -2.0, 0.0, "volatility")]
5097    #[case(0.0, 0.0, 1.0, "time to expiry")]
5098    #[case(f64::NAN, 0.0, 0.0, "spot shock")]
5099    #[case(0.0, f64::INFINITY, 0.0, "volatility shock")]
5100    #[case(0.0, 0.0, f64::NAN, "time to expiry shock")]
5101    fn test_instrument_greeks_rejects_invalid_shocks_without_caching(
5102        #[case] spot: f64,
5103        #[case] vol: f64,
5104        #[case] time: f64,
5105        #[case] input: &'static str,
5106    ) {
5107        let (calculator, id, now) = option_calculator();
5108        let result = InstrumentGreeksParams::builder()
5109            .instrument_id(id)
5110            .ts_event(now)
5111            .spot_shock(spot)
5112            .vol_shock(vol)
5113            .time_to_expiry_shock(time)
5114            .cache_greeks(true)
5115            .build()
5116            .calculate(&calculator)
5117            .unwrap_err();
5118        assert_eq!(
5119            result.downcast_ref::<OptionValuationError>(),
5120            Some(&OptionValuationError::InvalidInput(input))
5121        );
5122        assert!(calculator.cache.borrow().greeks(&id).is_none());
5123    }
5124
5125    #[rstest]
5126    fn test_instrument_greeks_rejects_unattainable_price() {
5127        let (calculator, id, now) = option_calculator();
5128        calculator
5129            .cache
5130            .borrow_mut()
5131            .add_quote(QuoteTick::new(
5132                id,
5133                Price::from("200.00"),
5134                Price::from("201.00"),
5135                Quantity::from(100),
5136                Quantity::from(100),
5137                now,
5138                now,
5139            ))
5140            .unwrap();
5141        let result = InstrumentGreeksParams::builder()
5142            .instrument_id(id)
5143            .ts_event(now)
5144            .cache_greeks(true)
5145            .build()
5146            .calculate(&calculator)
5147            .unwrap_err();
5148        assert_eq!(
5149            result.downcast_ref::<OptionValuationError>(),
5150            Some(&OptionValuationError::ImpliedVolatilityUnavailable)
5151        );
5152        assert!(calculator.cache.borrow().greeks(&id).is_none());
5153    }
5154
5155    #[rstest]
5156    #[case(false)]
5157    #[case(true)]
5158    fn test_instrument_greeks_rho_uses_explicit_carry_source(#[case] dividend_linked: bool) {
5159        let (calculator, id, now) = option_calculator();
5160        let mut params = InstrumentGreeksParams::builder()
5161            .instrument_id(id)
5162            .ts_event(now)
5163            .flat_interest_rate(0.05)
5164            .build();
5165        params.flat_dividend_yield = dividend_linked.then_some(0.05);
5166        let greeks = params.calculate(&calculator).unwrap();
5167        let expected = if dividend_linked {
5168            greeks.expiry_in_years
5169                * greeks.strike
5170                * (-greeks.interest_rate * greeks.expiry_in_years).exp()
5171                * greeks.itm_prob
5172                * 0.01
5173        } else {
5174            -greeks.expiry_in_years * greeks.price * 0.01
5175        };
5176
5177        assert_eq!(greeks.cost_of_carry, 0.0);
5178        assert!((greeks.rho - expected).abs() < 1e-12);
5179    }
5180
5181    #[rstest]
5182    fn test_portfolio_greeks_propagates_invalid_valuation() {
5183        let (calculator, position, now) = portfolio_option_calculator();
5184        let id = position.instrument_id;
5185        calculator
5186            .cache
5187            .borrow_mut()
5188            .add_quote(QuoteTick::new(
5189                id,
5190                Price::from("200.00"),
5191                Price::from("201.00"),
5192                Quantity::from(100),
5193                Quantity::from(100),
5194                now,
5195                now,
5196            ))
5197            .unwrap();
5198        let result = PortfolioGreeksParams::builder()
5199            .instrument_id(id)
5200            .build()
5201            .calculate(&calculator)
5202            .unwrap_err();
5203        assert_eq!(
5204            result.downcast_ref::<OptionValuationError>(),
5205            Some(&OptionValuationError::ImpliedVolatilityUnavailable)
5206        );
5207    }
5208
5209    #[rstest]
5210    #[case::unshocked(0.0)]
5211    #[case::shocked(1.0)]
5212    fn test_instrument_greeks_cached_valuation_does_not_publish(#[case] spot_shock: f64) {
5213        msgbus::set_message_bus(Rc::new(RefCell::new(msgbus::MessageBus::default())));
5214        let (calculator, id, now) = option_calculator();
5215        let base = InstrumentGreeksParams::builder()
5216            .instrument_id(id)
5217            .ts_event(now)
5218            .cache_greeks(true)
5219            .build()
5220            .calculate(&calculator)
5221            .unwrap();
5222        let observed = Rc::new(RefCell::new(Vec::new()));
5223        let captured = Rc::clone(&observed);
5224        calculator.subscribe_greeks(
5225            "AAPL",
5226            Some(move |data: &GreeksData| {
5227                captured.borrow_mut().push(data.clone());
5228            }),
5229        );
5230
5231        let result = InstrumentGreeksParams::builder()
5232            .instrument_id(id)
5233            .ts_event(UnixNanos::from(now.as_u64() + 1_000_000_000))
5234            .use_cached_greeks(true)
5235            .spot_shock(spot_shock)
5236            .cache_greeks(true)
5237            .publish_greeks(true)
5238            .build()
5239            .calculate(&calculator)
5240            .unwrap();
5241        let cached = calculator.cache.borrow().greeks(&id).unwrap();
5242
5243        assert!(observed.borrow().is_empty());
5244        assert_greeks_eq(&cached, &base);
5245        assert_eq!(result.underlying_price, base.underlying_price + spot_shock);
5246        assert_eq!(result.ts_event, base.ts_event);
5247    }
5248
5249    #[rstest]
5250    fn test_instrument_greeks_rejects_invalid_shocks_without_publishing() {
5251        let (calculator, id, now) = option_calculator();
5252        let observed = Rc::new(RefCell::new(Vec::new()));
5253        let captured = Rc::clone(&observed);
5254        calculator.subscribe_greeks(
5255            "AAPL",
5256            Some(move |data: &GreeksData| {
5257                captured.borrow_mut().push(data.clone());
5258            }),
5259        );
5260
5261        let mut params = InstrumentGreeksParams::builder()
5262            .instrument_id(id)
5263            .ts_event(now)
5264            .spot_shock(-200.0)
5265            .cache_greeks(true)
5266            .publish_greeks(true)
5267            .build();
5268        let e = params.calculate(&calculator).unwrap_err();
5269        assert_eq!(
5270            e.downcast_ref::<OptionValuationError>(),
5271            Some(&OptionValuationError::InvalidInput("underlying price"))
5272        );
5273        assert_eq!(observed.borrow().len(), 0);
5274        assert!(calculator.cache.borrow().greeks(&id).is_none());
5275        params.spot_shock = 1.0;
5276        let result = params.calculate(&calculator).unwrap();
5277        let cached = calculator.cache.borrow().greeks(&id).unwrap();
5278        let observed = observed.borrow();
5279        assert_eq!(observed.len(), 1);
5280        assert_greeks_eq(&observed[0], &cached);
5281        assert_eq!(cached.underlying_price, 150.05);
5282        assert_eq!(result.underlying_price, 151.05);
5283    }
5284
5285    #[rstest]
5286    #[case("probability below zero", -0.1)]
5287    #[case("probability above one", 1.1)]
5288    #[case("price", -1.0)]
5289    #[case("multiplier", f64::NAN)]
5290    #[case("quantity", f64::INFINITY)]
5291    fn test_instrument_greeks_rejects_invalid_cached_results(
5292        #[case] field: &str,
5293        #[case] value: f64,
5294    ) {
5295        let (calculator, id, now) = option_calculator();
5296        let mut data = InstrumentGreeksParams::builder()
5297            .instrument_id(id)
5298            .ts_event(now)
5299            .build()
5300            .calculate(&calculator)
5301            .unwrap();
5302
5303        match field {
5304            "probability below zero" | "probability above one" => data.itm_prob = value,
5305            "price" => data.price = value,
5306            "multiplier" => data.multiplier = value,
5307            "quantity" => data.quantity = value,
5308            _ => unreachable!(),
5309        }
5310
5311        calculator.cache.borrow_mut().add_greeks(data).unwrap();
5312        let error = InstrumentGreeksParams::builder()
5313            .instrument_id(id)
5314            .ts_event(now)
5315            .use_cached_greeks(true)
5316            .build()
5317            .calculate(&calculator)
5318            .unwrap_err();
5319        assert_eq!(
5320            error.downcast_ref::<OptionValuationError>(),
5321            Some(&OptionValuationError::InvalidResult)
5322        );
5323    }
5324
5325    #[rstest]
5326    fn test_instrument_greeks_rejects_invalid_cached_volatility_seed() {
5327        let (calculator, id, now) = option_calculator();
5328        let mut data = InstrumentGreeksParams::builder()
5329            .instrument_id(id)
5330            .ts_event(now)
5331            .build()
5332            .calculate(&calculator)
5333            .unwrap();
5334        data.vol = f64::NAN;
5335        calculator.cache.borrow_mut().add_greeks(data).unwrap();
5336        let error = InstrumentGreeksParams::builder()
5337            .instrument_id(id)
5338            .ts_event(now)
5339            .update_vol(true)
5340            .build()
5341            .calculate(&calculator)
5342            .unwrap_err();
5343        assert_eq!(
5344            error.downcast_ref::<OptionValuationError>(),
5345            Some(&OptionValuationError::InvalidInput("volatility"))
5346        );
5347    }
5348
5349    #[rstest]
5350    fn test_portfolio_greeks_rejects_overflow_after_scaling() {
5351        let (calculator, position, now) = portfolio_option_calculator();
5352        let id = position.instrument_id;
5353        let mut data = InstrumentGreeksParams::builder()
5354            .instrument_id(id)
5355            .ts_event(now)
5356            .build()
5357            .calculate(&calculator)
5358            .unwrap();
5359        data.multiplier = f64::MAX;
5360        calculator.cache.borrow_mut().add_greeks(data).unwrap();
5361        let error = PortfolioGreeksParams::builder()
5362            .instrument_id(id)
5363            .use_cached_greeks(true)
5364            .build()
5365            .calculate(&calculator)
5366            .unwrap_err();
5367        assert_eq!(
5368            error.downcast_ref::<OptionValuationError>(),
5369            Some(&OptionValuationError::InvalidResult)
5370        );
5371    }
5372
5373    #[rstest]
5374    fn test_instrument_greeks_projects_extreme_decimal_price() {
5375        let (calculator, mut position, now) = portfolio_option_calculator();
5376        let id = position.instrument_id;
5377        position.avg_px_open = Decimal::MAX;
5378        let observed = Rc::new(RefCell::new(Vec::new()));
5379        let captured = Rc::clone(&observed);
5380        calculator.subscribe_greeks(
5381            "AAPL",
5382            Some(move |data: &GreeksData| {
5383                captured.borrow_mut().push(data.clone());
5384            }),
5385        );
5386
5387        let result = InstrumentGreeksParams::builder()
5388            .instrument_id(id)
5389            .ts_event(now)
5390            .position(position)
5391            .spot_shock(1.0)
5392            .cache_greeks(true)
5393            .publish_greeks(true)
5394            .build()
5395            .calculate(&calculator)
5396            .unwrap();
5397        assert_eq!(result.pnl, result.price - Decimal::MAX.as_f64());
5398        let cached = calculator.cache.borrow().greeks(&id).unwrap();
5399        assert_eq!(observed.borrow().len(), 1);
5400        assert_greeks_eq(&observed.borrow()[0], &cached);
5401    }
5402
5403    #[rstest]
5404    fn test_instrument_greeks_rho_uses_dividend_curve_source() {
5405        let (calculator, id, now) = option_calculator();
5406        calculator
5407            .cache
5408            .borrow_mut()
5409            .add_yield_curve(YieldCurve::new(
5410                "AAPL.OPRA".to_string(),
5411                vec![0.0],
5412                vec![0.05],
5413                now,
5414                now,
5415            ))
5416            .unwrap();
5417        let result = InstrumentGreeksParams::builder()
5418            .instrument_id(id)
5419            .ts_event(now)
5420            .flat_interest_rate(0.05)
5421            .build()
5422            .calculate(&calculator)
5423            .unwrap();
5424        assert_eq!(result.cost_of_carry, 0.0);
5425        let reference = result.expiry_in_years
5426            * result.strike
5427            * (-result.interest_rate * result.expiry_in_years).exp()
5428            * result.itm_prob
5429            * 0.01;
5430        assert!((result.rho - reference).abs() < 1e-12);
5431    }
5432
5433    #[rstest]
5434    fn test_instrument_greeks_shocked_cache_uses_current_carry_sensitivity() {
5435        let (calculator, id, now) = option_calculator();
5436        let base = InstrumentGreeksParams::builder()
5437            .instrument_id(id)
5438            .ts_event(now)
5439            .cache_greeks(true)
5440            .build()
5441            .calculate(&calculator)
5442            .unwrap();
5443        let mut params = InstrumentGreeksParams::builder()
5444            .instrument_id(id)
5445            .ts_event(now)
5446            .use_cached_greeks(true)
5447            .spot_shock(1.0)
5448            .build();
5449        params.flat_dividend_yield = Some(base.interest_rate);
5450        let result = params.calculate(&calculator).unwrap();
5451        assert_eq!(result.cost_of_carry, 0.0);
5452        let reference = result.expiry_in_years
5453            * result.strike
5454            * (-result.interest_rate * result.expiry_in_years).exp()
5455            * result.itm_prob
5456            * 0.01;
5457        assert!((result.rho - reference).abs() < 1e-12);
5458        assert_eq!(base.rho, -base.expiry_in_years * base.price * 0.01);
5459    }
5460
5461    #[rstest]
5462    #[case(false)]
5463    #[case(true)]
5464    fn test_instrument_greeks_rejects_nonfinite_transformed_sensitivities(
5465        #[case] beta_weight: bool,
5466    ) {
5467        let (calculator, id, now) = option_calculator();
5468        let mut params = InstrumentGreeksParams::builder()
5469            .instrument_id(id)
5470            .ts_event(now)
5471            .cache_greeks(true)
5472            .build();
5473
5474        if beta_weight {
5475            let underlying = InstrumentId::from("AAPL.OPRA");
5476            params.index_instrument_id = Some(underlying);
5477            params.beta_weights = Some(HashMap::from([(underlying, f64::NAN)]));
5478        } else {
5479            params.vega_time_weight_base = Some(-1);
5480        }
5481
5482        let e = params.calculate(&calculator).unwrap_err();
5483        assert_eq!(
5484            e.downcast_ref::<OptionValuationError>(),
5485            Some(&OptionValuationError::InvalidResult)
5486        );
5487        assert!(calculator.cache.borrow().greeks(&id).is_none());
5488    }
5489}