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