Skip to main content

nautilus_model/data/
option_chain.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//! Option chain data types for aggregated option series snapshots.
17
18use std::{
19    collections::{BTreeMap, HashSet},
20    fmt::Display,
21    ops::Deref,
22};
23
24use nautilus_core::{UnixNanos, serialization::Serializable};
25use rust_decimal::{Decimal, prelude::ToPrimitive};
26use serde::{Deserialize, Serialize};
27
28use super::HasTsInit;
29use crate::{
30    data::{
31        QuoteTick,
32        greeks::{HasGreeks, OptionGreekValues},
33    },
34    enums::GreeksConvention,
35    identifiers::{InstrumentId, OptionSeriesId},
36    types::Price,
37};
38
39/// Number of strikes either side of ATM that [`StrikeRange::Delta`] selects as a
40/// fallback when Greeks are not yet available for delta resolution.
41pub(crate) const DEFAULT_DELTA_FALLBACK_STRIKES: usize = 5;
42
43/// An ordered set of strike grids and a bound on definition candidates per slot.
44///
45/// Increments express discovery preference. They do not prove that a strike is listed.
46#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
47pub struct StrikeSearchProfile {
48    increments: Vec<Decimal>,
49    origin: Decimal,
50    max_candidates_per_slot: usize,
51}
52
53impl StrikeSearchProfile {
54    /// Creates a checked strike-search profile.
55    ///
56    /// # Errors
57    ///
58    /// Returns an error if:
59    /// - The increments are empty, non-positive, or duplicated.
60    /// - The origin is negative.
61    /// - The candidate bound is zero.
62    pub fn new(
63        increments: Vec<Decimal>,
64        origin: Decimal,
65        max_candidates_per_slot: usize,
66    ) -> anyhow::Result<Self> {
67        anyhow::ensure!(
68            !increments.is_empty(),
69            "strike search profile requires at least one increment"
70        );
71        anyhow::ensure!(
72            increments.iter().all(|value| *value > Decimal::ZERO),
73            "strike search profile increments must be positive"
74        );
75        let mut seen = HashSet::with_capacity(increments.len());
76        for increment in &increments {
77            anyhow::ensure!(
78                seen.insert(increment.normalize()),
79                "strike search profile increments must be unique (duplicate {increment})"
80            );
81        }
82
83        anyhow::ensure!(
84            origin >= Decimal::ZERO,
85            "strike search profile origin must be non-negative"
86        );
87        anyhow::ensure!(
88            max_candidates_per_slot > 0,
89            "strike search profile requires a positive candidate bound"
90        );
91        Ok(Self {
92            increments,
93            origin,
94            max_candidates_per_slot,
95        })
96    }
97
98    /// Returns strike increments in discovery preference order.
99    #[must_use]
100    pub fn increments(&self) -> &[Decimal] {
101        &self.increments
102    }
103
104    /// Returns the origin shared by the strike grids.
105    #[must_use]
106    pub const fn origin(&self) -> Decimal {
107        self.origin
108    }
109
110    /// Returns the maximum number of candidates probed per strike slot.
111    #[must_use]
112    pub const fn max_candidates_per_slot(&self) -> usize {
113        self.max_candidates_per_slot
114    }
115}
116
117impl<'de> Deserialize<'de> for StrikeSearchProfile {
118    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
119    where
120        D: serde::Deserializer<'de>,
121    {
122        #[derive(Deserialize)]
123        struct Fields {
124            increments: Vec<Decimal>,
125            origin: Decimal,
126            max_candidates_per_slot: usize,
127        }
128        let fields = Fields::deserialize(deserializer)?;
129        Self::new(
130            fields.increments,
131            fields.origin,
132            fields.max_candidates_per_slot,
133        )
134        .map_err(serde::de::Error::custom)
135    }
136}
137
138/// Defines which strikes to include in an option chain subscription.
139#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
140pub enum StrikeRange {
141    /// Subscribe to a fixed set of strike prices.
142    Fixed(Vec<Price>),
143    /// Subscribe to strikes relative to ATM: N strikes above and N below.
144    AtmRelative {
145        strikes_above: usize,
146        strikes_below: usize,
147    },
148    /// Subscribe to strikes within a percentage band around ATM price.
149    AtmPercent { pct: f64 },
150    /// Subscribe to strikes whose absolute option delta is near `target`.
151    ///
152    /// Delta resolution needs Greeks, so the option chain aggregator performs it.
153    /// The model-level [`StrikeRange::resolve`] has no Greeks and falls back to an
154    /// ATM-relative window of `DEFAULT_DELTA_FALLBACK_STRIKES` strikes either side
155    /// of ATM until Greeks are available.
156    Delta { target: f64, tolerance: f64 },
157}
158
159impl StrikeRange {
160    /// Resolves the filtered set of strikes from all available strikes.
161    ///
162    /// - `Fixed`: returns the fixed strikes directly (intersected with available).
163    /// - `AtmRelative`: finds the closest strike to ATM, takes N above and N below.
164    /// - `AtmPercent`: filters strikes within a percentage band around ATM.
165    /// - `Delta`: has no Greeks at this level, so it falls back to an ATM-relative
166    ///   window of `DEFAULT_DELTA_FALLBACK_STRIKES` strikes either side of ATM. The
167    ///   option chain aggregator resolves `Delta` from Greeks instead.
168    ///
169    /// If `atm_price` is `None` for ATM-based variants, returns an empty vec
170    /// (subscriptions are deferred until ATM is known).
171    #[must_use]
172    pub fn resolve(&self, atm_price: Option<Price>, all_strikes: &[Price]) -> Vec<Price> {
173        match self {
174            Self::Fixed(strikes) => {
175                if all_strikes.is_empty() {
176                    strikes.clone()
177                } else {
178                    let available: HashSet<Price> = all_strikes.iter().copied().collect();
179                    strikes
180                        .iter()
181                        .filter(|s| available.contains(s))
182                        .copied()
183                        .collect()
184                }
185            }
186            Self::AtmRelative {
187                strikes_above,
188                strikes_below,
189            } => {
190                let Some(atm) = atm_price else {
191                    return vec![]; // Defer until ATM is known
192                };
193                // Find index of closest strike to ATM
194                let atm_idx = match all_strikes.binary_search(&atm) {
195                    Ok(idx) => idx,
196                    Err(idx) => {
197                        if idx == 0 {
198                            0
199                        } else if idx >= all_strikes.len() {
200                            all_strikes.len() - 1
201                        } else {
202                            // Pick the closer of the two neighbors
203                            let diff_below = all_strikes[idx - 1].raw().abs_diff(atm.raw());
204                            let diff_above = all_strikes[idx].raw().abs_diff(atm.raw());
205                            if diff_below <= diff_above {
206                                idx - 1
207                            } else {
208                                idx
209                            }
210                        }
211                    }
212                };
213                let start = atm_idx.saturating_sub(*strikes_below);
214                let end = atm_idx
215                    .saturating_add(*strikes_above)
216                    .saturating_add(1)
217                    .min(all_strikes.len());
218                all_strikes[start..end].to_vec()
219            }
220            Self::AtmPercent { pct } => {
221                let Some(atm) = atm_price else {
222                    return vec![]; // Defer until ATM is known
223                };
224                let atm_decimal = atm.as_decimal();
225                if atm_decimal.is_zero() {
226                    return all_strikes.to_vec();
227                }
228                all_strikes
229                    .iter()
230                    .filter(|s| {
231                        let distance = (s.as_decimal() - atm_decimal).abs();
232                        let pct_diff = distance / atm_decimal.abs();
233                        pct_diff.to_f64().is_some_and(|pct_diff| pct_diff <= *pct)
234                    })
235                    .copied()
236                    .collect()
237            }
238            Self::Delta { .. } => Self::AtmRelative {
239                strikes_above: DEFAULT_DELTA_FALLBACK_STRIKES,
240                strikes_below: DEFAULT_DELTA_FALLBACK_STRIKES,
241            }
242            .resolve(atm_price, all_strikes),
243        }
244    }
245}
246
247/// Exchange-provided option Greeks and implied volatility for a single instrument.
248#[repr(C)]
249#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
250#[serde(tag = "type")]
251#[cfg_attr(
252    feature = "python",
253    pyo3::pyclass(module = "nautilus_trader.model", from_py_object)
254)]
255#[cfg_attr(
256    feature = "python",
257    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.model")
258)]
259pub struct OptionGreeks {
260    /// The instrument ID these Greeks apply to.
261    pub instrument_id: InstrumentId,
262    /// The numeraire convention these Greeks are expressed in.
263    pub convention: GreeksConvention,
264    /// Core Greek sensitivity values.
265    pub greeks: OptionGreekValues,
266    /// Mark implied volatility.
267    pub mark_iv: Option<f64>,
268    /// Bid implied volatility.
269    pub bid_iv: Option<f64>,
270    /// Ask implied volatility.
271    pub ask_iv: Option<f64>,
272    /// Underlying price at time of Greeks calculation.
273    pub underlying_price: Option<f64>,
274    /// Open interest for the instrument.
275    pub open_interest: Option<f64>,
276    /// UNIX timestamp (nanoseconds) when the event occurred.
277    pub ts_event: UnixNanos,
278    /// UNIX timestamp (nanoseconds) when the instance was initialized.
279    pub ts_init: UnixNanos,
280}
281
282impl HasTsInit for OptionGreeks {
283    fn ts_init(&self) -> UnixNanos {
284        self.ts_init
285    }
286}
287
288impl Deref for OptionGreeks {
289    type Target = OptionGreekValues;
290    fn deref(&self) -> &Self::Target {
291        &self.greeks
292    }
293}
294
295impl HasGreeks for OptionGreeks {
296    fn greeks(&self) -> OptionGreekValues {
297        self.greeks
298    }
299}
300
301impl Default for OptionGreeks {
302    fn default() -> Self {
303        Self {
304            instrument_id: InstrumentId::from("NULL.NULL"),
305            convention: GreeksConvention::default(),
306            greeks: OptionGreekValues::default(),
307            mark_iv: None,
308            bid_iv: None,
309            ask_iv: None,
310            underlying_price: None,
311            open_interest: None,
312            ts_event: UnixNanos::default(),
313            ts_init: UnixNanos::default(),
314        }
315    }
316}
317
318impl Display for OptionGreeks {
319    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
320        write!(
321            f,
322            "OptionGreeks({}, {}, delta={:.4}, gamma={:.4}, vega={:.4}, theta={:.4}, mark_iv={:?})",
323            self.instrument_id,
324            self.convention,
325            self.delta,
326            self.gamma,
327            self.vega,
328            self.theta,
329            self.mark_iv
330        )
331    }
332}
333
334impl Serializable for OptionGreeks {}
335
336/// Combined quote and Greeks data for a single strike in an option chain.
337#[derive(Clone, Debug)]
338#[cfg_attr(
339    feature = "python",
340    pyo3::pyclass(module = "nautilus_trader.model", from_py_object)
341)]
342#[cfg_attr(
343    feature = "python",
344    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.model")
345)]
346pub struct OptionStrikeData {
347    /// The latest quote for this strike.
348    pub quote: QuoteTick,
349    /// Exchange-provided Greeks (if available).
350    pub greeks: Option<OptionGreeks>,
351}
352
353/// A point-in-time snapshot of an option chain for a single series.
354#[derive(Clone, Debug)]
355#[cfg_attr(
356    feature = "python",
357    pyo3::pyclass(module = "nautilus_trader.model", from_py_object)
358)]
359#[cfg_attr(
360    feature = "python",
361    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.model")
362)]
363pub struct OptionChainSlice {
364    /// The option series identifier.
365    pub series_id: OptionSeriesId,
366    /// The current ATM strike price (if determined).
367    pub atm_strike: Option<Price>,
368    /// Call option data keyed by strike price (sorted).
369    pub calls: BTreeMap<Price, OptionStrikeData>,
370    /// Put option data keyed by strike price (sorted).
371    pub puts: BTreeMap<Price, OptionStrikeData>,
372    /// UNIX timestamp (nanoseconds) when the snapshot event occurred.
373    pub ts_event: UnixNanos,
374    /// UNIX timestamp (nanoseconds) when the instance was initialized.
375    pub ts_init: UnixNanos,
376}
377
378impl HasTsInit for OptionChainSlice {
379    fn ts_init(&self) -> UnixNanos {
380        self.ts_init
381    }
382}
383
384impl Display for OptionChainSlice {
385    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
386        write!(
387            f,
388            "OptionChainSlice({}, atm={:?}, calls={}, puts={})",
389            self.series_id,
390            self.atm_strike,
391            self.calls.len(),
392            self.puts.len()
393        )
394    }
395}
396
397impl OptionChainSlice {
398    /// Creates a new empty [`OptionChainSlice`] for the given series.
399    #[must_use]
400    pub fn new(series_id: OptionSeriesId) -> Self {
401        Self {
402            series_id,
403            atm_strike: None,
404            calls: BTreeMap::new(),
405            puts: BTreeMap::new(),
406            ts_event: UnixNanos::default(),
407            ts_init: UnixNanos::default(),
408        }
409    }
410
411    /// Returns the number of call entries.
412    #[must_use]
413    pub fn call_count(&self) -> usize {
414        self.calls.len()
415    }
416
417    /// Returns the number of put entries.
418    #[must_use]
419    pub fn put_count(&self) -> usize {
420        self.puts.len()
421    }
422
423    /// Returns the call data for a given strike price.
424    #[must_use]
425    pub fn get_call(&self, strike: &Price) -> Option<&OptionStrikeData> {
426        self.calls.get(strike)
427    }
428
429    /// Returns the put data for a given strike price.
430    #[must_use]
431    pub fn get_put(&self, strike: &Price) -> Option<&OptionStrikeData> {
432        self.puts.get(strike)
433    }
434
435    /// Returns the call quote for a given strike price.
436    #[must_use]
437    pub fn get_call_quote(&self, strike: &Price) -> Option<&QuoteTick> {
438        self.calls.get(strike).map(|d| &d.quote)
439    }
440
441    /// Returns the call Greeks for a given strike price.
442    #[must_use]
443    pub fn get_call_greeks(&self, strike: &Price) -> Option<&OptionGreeks> {
444        self.calls.get(strike).and_then(|d| d.greeks.as_ref())
445    }
446
447    /// Returns the put quote for a given strike price.
448    #[must_use]
449    pub fn get_put_quote(&self, strike: &Price) -> Option<&QuoteTick> {
450        self.puts.get(strike).map(|d| &d.quote)
451    }
452
453    /// Returns the put Greeks for a given strike price.
454    #[must_use]
455    pub fn get_put_greeks(&self, strike: &Price) -> Option<&OptionGreeks> {
456        self.puts.get(strike).and_then(|d| d.greeks.as_ref())
457    }
458
459    /// Returns all strike prices present in the chain (union of calls and puts).
460    #[must_use]
461    pub fn strikes(&self) -> Vec<Price> {
462        let mut strikes: Vec<Price> = self.calls.keys().chain(self.puts.keys()).copied().collect();
463        strikes.sort();
464        strikes.dedup();
465        strikes
466    }
467
468    /// Returns the total number of unique strikes.
469    #[must_use]
470    pub fn strike_count(&self) -> usize {
471        self.strikes().len()
472    }
473
474    /// Returns `true` if the chain has no data.
475    #[must_use]
476    pub fn is_empty(&self) -> bool {
477        self.calls.is_empty() && self.puts.is_empty()
478    }
479}
480
481#[cfg(test)]
482mod tests {
483    use rstest::*;
484
485    use super::*;
486    use crate::{identifiers::Venue, types::Quantity};
487
488    fn make_quote(instrument_id: InstrumentId) -> QuoteTick {
489        QuoteTick::new(
490            instrument_id,
491            Price::from("100.00"),
492            Price::from("101.00"),
493            Quantity::from("1.0"),
494            Quantity::from("1.0"),
495            UnixNanos::from(1u64),
496            UnixNanos::from(1u64),
497        )
498    }
499
500    fn make_series_id() -> OptionSeriesId {
501        OptionSeriesId::new_derived(
502            Venue::new("DERIBIT"),
503            ustr::Ustr::from("BTC"),
504            ustr::Ustr::from("BTC"),
505            UnixNanos::from(1_700_000_000_000_000_000u64),
506        )
507    }
508
509    #[rstest]
510    fn test_strike_range_fixed() {
511        let range = StrikeRange::Fixed(vec![Price::from("50000"), Price::from("55000")]);
512        assert_eq!(
513            range,
514            StrikeRange::Fixed(vec![Price::from("50000"), Price::from("55000")])
515        );
516    }
517
518    #[rstest]
519    fn test_strike_range_atm_relative() {
520        let range = StrikeRange::AtmRelative {
521            strikes_above: 5,
522            strikes_below: 5,
523        };
524
525        if let StrikeRange::AtmRelative {
526            strikes_above,
527            strikes_below,
528        } = range
529        {
530            assert_eq!(strikes_above, 5);
531            assert_eq!(strikes_below, 5);
532        } else {
533            panic!("Expected AtmRelative variant");
534        }
535    }
536
537    #[rstest]
538    fn test_strike_range_atm_percent() {
539        let range = StrikeRange::AtmPercent { pct: 0.1 };
540        if let StrikeRange::AtmPercent { pct } = range {
541            assert!((pct - 0.1).abs() < f64::EPSILON);
542        } else {
543            panic!("Expected AtmPercent variant");
544        }
545    }
546
547    #[rstest]
548    fn test_option_greeks_default_fields() {
549        let greeks = OptionGreeks {
550            instrument_id: InstrumentId::from("BTC-20240101-50000-C.DERIBIT"),
551            convention: GreeksConvention::BlackScholes,
552            greeks: OptionGreekValues::default(),
553            mark_iv: None,
554            bid_iv: None,
555            ask_iv: None,
556            underlying_price: None,
557            open_interest: None,
558            ts_event: UnixNanos::default(),
559            ts_init: UnixNanos::default(),
560        };
561        assert_eq!(greeks.delta, 0.0);
562        assert_eq!(greeks.gamma, 0.0);
563        assert_eq!(greeks.vega, 0.0);
564        assert_eq!(greeks.theta, 0.0);
565        assert!(greeks.mark_iv.is_none());
566        assert_eq!(greeks.convention, GreeksConvention::BlackScholes);
567    }
568
569    #[rstest]
570    fn test_option_greeks_default_is_black_scholes() {
571        let greeks = OptionGreeks::default();
572        assert_eq!(greeks.convention, GreeksConvention::BlackScholes);
573    }
574
575    #[rstest]
576    fn test_option_greeks_display() {
577        let greeks = OptionGreeks {
578            instrument_id: InstrumentId::from("BTC-20240101-50000-C.DERIBIT"),
579            convention: GreeksConvention::PriceAdjusted,
580            greeks: OptionGreekValues {
581                delta: 0.55,
582                gamma: 0.001,
583                vega: 10.0,
584                theta: -5.0,
585                rho: 0.0,
586            },
587            mark_iv: Some(0.65),
588            bid_iv: None,
589            ask_iv: None,
590            underlying_price: None,
591            open_interest: None,
592            ts_event: UnixNanos::default(),
593            ts_init: UnixNanos::default(),
594        };
595        let display = format!("{greeks}");
596        assert!(display.contains("OptionGreeks"));
597        assert!(display.contains("PRICE_ADJUSTED"));
598        assert!(display.contains("0.55"));
599    }
600
601    #[rstest]
602    fn test_option_greeks_data_serde_round_trip() {
603        let greeks = OptionGreeks {
604            instrument_id: InstrumentId::from("BTC-20240101-50000-C.DERIBIT"),
605            convention: GreeksConvention::PriceAdjusted,
606            greeks: OptionGreekValues {
607                delta: 0.55,
608                gamma: 0.001,
609                vega: 10.0,
610                theta: -5.0,
611                rho: 0.2,
612            },
613            mark_iv: Some(0.65),
614            bid_iv: None,
615            ask_iv: Some(0.66),
616            underlying_price: Some(50_000.0),
617            open_interest: None,
618            ts_event: UnixNanos::from(1u64),
619            ts_init: UnixNanos::from(2u64),
620        };
621        let data = crate::data::Data::OptionGreeks(greeks);
622
623        let json = serde_json::to_string(&data).unwrap();
624        let roundtripped: crate::data::Data = serde_json::from_str(&json).unwrap();
625
626        assert_eq!(roundtripped, data);
627    }
628
629    #[rstest]
630    fn test_option_chain_slice_empty() {
631        let slice = OptionChainSlice {
632            series_id: make_series_id(),
633            atm_strike: None,
634            calls: BTreeMap::new(),
635            puts: BTreeMap::new(),
636            ts_event: UnixNanos::from(1u64),
637            ts_init: UnixNanos::from(1u64),
638        };
639
640        assert!(slice.is_empty());
641        assert_eq!(slice.strike_count(), 0);
642        assert!(slice.strikes().is_empty());
643    }
644
645    #[rstest]
646    fn test_option_chain_slice_with_data() {
647        let call_id = InstrumentId::from("BTC-20240101-50000-C.DERIBIT");
648        let put_id = InstrumentId::from("BTC-20240101-50000-P.DERIBIT");
649        let strike = Price::from("50000");
650
651        let mut calls = BTreeMap::new();
652        calls.insert(
653            strike,
654            OptionStrikeData {
655                quote: make_quote(call_id),
656                greeks: Some(OptionGreeks {
657                    instrument_id: call_id,
658                    greeks: OptionGreekValues {
659                        delta: 0.55,
660                        ..Default::default()
661                    },
662                    ..Default::default()
663                }),
664            },
665        );
666
667        let mut puts = BTreeMap::new();
668        puts.insert(
669            strike,
670            OptionStrikeData {
671                quote: make_quote(put_id),
672                greeks: None,
673            },
674        );
675
676        let slice = OptionChainSlice {
677            series_id: make_series_id(),
678            atm_strike: Some(strike),
679            calls,
680            puts,
681            ts_event: UnixNanos::from(1u64),
682            ts_init: UnixNanos::from(1u64),
683        };
684
685        assert!(!slice.is_empty());
686        assert_eq!(slice.strike_count(), 1);
687        assert_eq!(slice.strikes(), vec![strike]);
688        assert!(slice.get_call(&strike).is_some());
689        assert!(slice.get_put(&strike).is_some());
690        assert!(slice.get_call_greeks(&strike).is_some());
691        assert!(slice.get_put_greeks(&strike).is_none());
692        assert_eq!(slice.get_call_greeks(&strike).unwrap().delta, 0.55);
693    }
694
695    #[rstest]
696    fn test_option_chain_slice_display() {
697        let slice = OptionChainSlice {
698            series_id: make_series_id(),
699            atm_strike: None,
700            calls: BTreeMap::new(),
701            puts: BTreeMap::new(),
702            ts_event: UnixNanos::from(1u64),
703            ts_init: UnixNanos::from(1u64),
704        };
705
706        let display = format!("{slice}");
707        assert!(display.contains("OptionChainSlice"));
708        assert!(display.contains("DERIBIT"));
709    }
710
711    #[rstest]
712    fn test_option_chain_slice_ts_init() {
713        let slice = OptionChainSlice {
714            series_id: make_series_id(),
715            atm_strike: None,
716            calls: BTreeMap::new(),
717            puts: BTreeMap::new(),
718            ts_event: UnixNanos::from(1u64),
719            ts_init: UnixNanos::from(42u64),
720        };
721
722        assert_eq!(slice.ts_init(), UnixNanos::from(42u64));
723    }
724
725    // -- StrikeRange::resolve tests --
726
727    #[rstest]
728    fn test_strike_range_preserves_subprecision_atm() {
729        let mut atm = Price::from("100.75");
730        atm.precision = 0;
731        let strikes = [Price::from("100"), Price::from("101")];
732
733        let range = StrikeRange::AtmRelative {
734            strikes_above: 0,
735            strikes_below: 0,
736        };
737
738        assert_eq!(range.resolve(Some(atm), &strikes), vec![strikes[1]]);
739    }
740
741    #[rstest]
742    fn test_strike_range_resolve_fixed() {
743        let range = StrikeRange::Fixed(vec![Price::from("50000"), Price::from("55000")]);
744        let result = range.resolve(None, &[]);
745        assert_eq!(result, vec![Price::from("50000"), Price::from("55000")]);
746    }
747
748    #[rstest]
749    fn test_strike_range_resolve_fixed_intersects_available_strikes() {
750        let range = StrikeRange::Fixed(vec![
751            Price::from("50000"),
752            Price::from("55000"),
753            Price::from("60000"),
754        ]);
755        let available = [
756            Price::from("45000"),
757            Price::from("50000"),
758            Price::from("60000"),
759        ];
760
761        let result = range.resolve(None, &available);
762
763        assert_eq!(result, vec![Price::from("50000"), Price::from("60000")]);
764    }
765
766    #[rstest]
767    fn test_strike_range_resolve_atm_relative() {
768        let range = StrikeRange::AtmRelative {
769            strikes_above: 2,
770            strikes_below: 2,
771        };
772        let strikes: Vec<Price> = [45000, 47000, 50000, 53000, 55000, 57000]
773            .iter()
774            .map(|s| Price::from(&s.to_string()))
775            .collect();
776        let atm = Some(Price::from("50000"));
777        let result = range.resolve(atm, &strikes);
778        // ATM at index 2, below=2 → start=0, above=2 → end=5
779        assert_eq!(result.len(), 5);
780        assert_eq!(result[0], Price::from("45000"));
781        assert_eq!(result[4], Price::from("55000"));
782    }
783
784    #[rstest]
785    #[case("49750", "50000")]
786    #[case("50250", "50000")]
787    #[case("51750", "52000")]
788    fn test_strike_range_resolve_atm_relative_selects_nearest_strike(
789        #[case] atm: &str,
790        #[case] expected: &str,
791    ) {
792        let range = StrikeRange::AtmRelative {
793            strikes_above: 0,
794            strikes_below: 0,
795        };
796        let strikes = [
797            Price::from("48000"),
798            Price::from("50000"),
799            Price::from("52000"),
800        ];
801
802        let result = range.resolve(Some(Price::from(atm)), &strikes);
803
804        assert_eq!(result, vec![Price::from(expected)]);
805    }
806
807    #[rstest]
808    fn test_strike_range_resolve_atm_relative_exact_high_value() {
809        let range = StrikeRange::AtmRelative {
810            strikes_above: 0,
811            strikes_below: 0,
812        };
813        let atm = Price::from("9007199253.999000000");
814        let collapsed = Price::from("9007199253.999000001");
815        let strikes = [atm, collapsed];
816        assert_eq!(collapsed.as_f64(), atm.as_f64());
817
818        let result = range.resolve(Some(atm), &strikes);
819
820        assert_eq!(result, vec![atm]);
821    }
822
823    #[rstest]
824    fn test_strike_range_resolve_atm_relative_saturates_extreme_window() {
825        // An extreme window must clamp to the available strikes without overflowing
826        let range = StrikeRange::AtmRelative {
827            strikes_above: usize::MAX,
828            strikes_below: usize::MAX,
829        };
830        let strikes: Vec<Price> = [45000, 50000, 55000]
831            .iter()
832            .map(|s| Price::from(&s.to_string()))
833            .collect();
834        let atm = Some(Price::from("50000"));
835
836        let result = range.resolve(atm, &strikes);
837
838        assert_eq!(result, strikes);
839    }
840
841    #[rstest]
842    fn test_strike_range_resolve_atm_relative_no_atm() {
843        let range = StrikeRange::AtmRelative {
844            strikes_above: 2,
845            strikes_below: 2,
846        };
847        let strikes = vec![Price::from("50000"), Price::from("55000")];
848        let result = range.resolve(None, &strikes);
849        // No ATM → return empty (deferred until ATM known)
850        assert!(result.is_empty());
851    }
852
853    #[rstest]
854    fn test_strike_range_resolve_atm_percent() {
855        let range = StrikeRange::AtmPercent { pct: 0.1 }; // 10%
856        let strikes: Vec<Price> = [45000, 48000, 50000, 52000, 55000, 60000]
857            .iter()
858            .map(|s| Price::from(&s.to_string()))
859            .collect();
860        let atm = Some(Price::from("50000"));
861        let result = range.resolve(atm, &strikes);
862        // 10% of 50000 = 5000, so [45000..55000] inclusive (<=)
863        assert_eq!(result.len(), 5); // 45000, 48000, 50000, 52000, 55000
864        assert!(result.contains(&Price::from("45000")));
865        assert!(result.contains(&Price::from("48000")));
866        assert!(result.contains(&Price::from("50000")));
867        assert!(result.contains(&Price::from("52000")));
868        assert!(result.contains(&Price::from("55000")));
869    }
870
871    #[rstest]
872    fn test_strike_range_resolve_atm_percent_zero_exact_high_value() {
873        let range = StrikeRange::AtmPercent { pct: 0.0 };
874        let atm = Price::from("9007199253.999000000");
875        let collapsed = Price::from("9007199253.999000001");
876        let strikes = [atm, collapsed];
877        assert_eq!(atm.as_f64(), collapsed.as_f64());
878
879        let result = range.resolve(Some(atm), &strikes);
880
881        assert_eq!(result, vec![atm]);
882    }
883
884    #[rstest]
885    fn test_option_chain_slice_new_empty() {
886        let slice = OptionChainSlice::new(make_series_id());
887        assert!(slice.is_empty());
888        assert_eq!(slice.call_count(), 0);
889        assert_eq!(slice.put_count(), 0);
890        assert!(slice.atm_strike.is_none());
891    }
892
893    #[rstest]
894    fn test_strike_range_resolve_delta_falls_back_to_atm_relative() {
895        // The model-level resolve has no Greeks, so Delta delegates to an
896        // AtmRelative window of DEFAULT_DELTA_FALLBACK_STRIKES either side of ATM.
897        let strikes: Vec<Price> = (0..=20)
898            .map(|i| Price::from(&(40000 + i * 1000).to_string()))
899            .collect();
900        let atm = Some(Price::from("50000")); // index 10
901        let delta = StrikeRange::Delta {
902            target: 0.25,
903            tolerance: 0.05,
904        };
905        let expected = StrikeRange::AtmRelative {
906            strikes_above: DEFAULT_DELTA_FALLBACK_STRIKES,
907            strikes_below: DEFAULT_DELTA_FALLBACK_STRIKES,
908        }
909        .resolve(atm, &strikes);
910
911        let result = delta.resolve(atm, &strikes);
912        assert_eq!(result, expected);
913        assert_eq!(result.len(), 2 * DEFAULT_DELTA_FALLBACK_STRIKES + 1);
914        assert!(result.contains(&Price::from("50000")));
915        assert!(!result.contains(&Price::from("40000")));
916        assert!(!result.contains(&Price::from("60000")));
917    }
918
919    #[rstest]
920    fn test_strike_range_resolve_delta_empty_without_atm() {
921        let delta = StrikeRange::Delta {
922            target: 0.25,
923            tolerance: 0.05,
924        };
925        let strikes = vec![Price::from("50000"), Price::from("55000")];
926        // No ATM -> deferred (empty), matching ATM-relative behavior.
927        assert!(delta.resolve(None, &strikes).is_empty());
928    }
929
930    #[rstest]
931    fn test_strike_search_profile_roundtrip() {
932        let profile = StrikeSearchProfile::new(
933            vec![Decimal::from(5), Decimal::from(25)],
934            Decimal::from(2),
935            7,
936        )
937        .unwrap();
938        let encoded = serde_json::to_value(&profile).unwrap();
939        let restored: StrikeSearchProfile = serde_json::from_value(encoded.clone()).unwrap();
940        assert_eq!(restored, profile);
941        assert_eq!(profile.increments(), &[Decimal::from(5), Decimal::from(25)]);
942        assert_eq!(profile.origin(), Decimal::from(2));
943        assert_eq!(profile.max_candidates_per_slot(), 7);
944        assert_eq!(encoded["max_candidates_per_slot"], serde_json::json!(7));
945    }
946
947    #[rstest]
948    #[case(vec![], Decimal::ZERO, 7, "at least one increment")]
949    #[case(vec![Decimal::ZERO], Decimal::ZERO, 7, "increments must be positive")]
950    #[case(vec![Decimal::from(-1)], Decimal::ZERO, 7, "increments must be positive")]
951    #[case(vec![Decimal::from(5), Decimal::new(50, 1)], Decimal::ZERO, 7, "increments must be unique")]
952    #[case(vec![Decimal::from(5)], Decimal::from(-1), 7, "origin must be non-negative")]
953    #[case(vec![Decimal::from(5)], Decimal::ZERO, 0, "positive candidate bound")]
954    fn test_strike_search_profile_invalid(
955        #[case] increments: Vec<Decimal>,
956        #[case] origin: Decimal,
957        #[case] candidates: usize,
958        #[case] expected: &str,
959    ) {
960        let encoded = serde_json::json!({"increments": increments, "origin": origin, "max_candidates_per_slot": candidates});
961        let error = StrikeSearchProfile::new(increments, origin, candidates).unwrap_err();
962        let decoded_error = serde_json::from_value::<StrikeSearchProfile>(encoded).unwrap_err();
963        assert!(error.to_string().contains(expected));
964        assert!(decoded_error.to_string().contains(expected));
965    }
966}