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