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 serde::{Deserialize, Serialize};
26
27use super::HasTsInit;
28use crate::{
29    data::{
30        QuoteTick,
31        greeks::{HasGreeks, OptionGreekValues},
32    },
33    enums::GreeksConvention,
34    identifiers::{InstrumentId, OptionSeriesId},
35    types::Price,
36};
37
38/// Number of strikes either side of ATM that [`StrikeRange::Delta`] selects as a
39/// fallback when Greeks are not yet available for delta resolution.
40pub(crate) const DEFAULT_DELTA_FALLBACK_STRIKES: usize = 5;
41
42/// Defines which strikes to include in an option chain subscription.
43#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
44pub enum StrikeRange {
45    /// Subscribe to a fixed set of strike prices.
46    Fixed(Vec<Price>),
47    /// Subscribe to strikes relative to ATM: N strikes above and N below.
48    AtmRelative {
49        strikes_above: usize,
50        strikes_below: usize,
51    },
52    /// Subscribe to strikes within a percentage band around ATM price.
53    AtmPercent { pct: f64 },
54    /// Subscribe to strikes whose absolute option delta is near `target`.
55    ///
56    /// Delta resolution needs Greeks, so the option chain aggregator performs it.
57    /// The model-level [`StrikeRange::resolve`] has no Greeks and falls back to an
58    /// ATM-relative window of `DEFAULT_DELTA_FALLBACK_STRIKES` strikes either side
59    /// of ATM until Greeks are available.
60    Delta { target: f64, tolerance: f64 },
61}
62
63impl StrikeRange {
64    /// Resolves the filtered set of strikes from all available strikes.
65    ///
66    /// - `Fixed`: returns the fixed strikes directly (intersected with available).
67    /// - `AtmRelative`: finds the closest strike to ATM, takes N above and N below.
68    /// - `AtmPercent`: filters strikes within a percentage band around ATM.
69    /// - `Delta`: has no Greeks at this level, so it falls back to an ATM-relative
70    ///   window of `DEFAULT_DELTA_FALLBACK_STRIKES` strikes either side of ATM. The
71    ///   option chain aggregator resolves `Delta` from Greeks instead.
72    ///
73    /// If `atm_price` is `None` for ATM-based variants, returns an empty vec
74    /// (subscriptions are deferred until ATM is known).
75    ///
76    /// # Panics
77    ///
78    /// Panics if a strike price comparison returns `None` (i.e. a NaN price value).
79    #[must_use]
80    pub fn resolve(&self, atm_price: Option<Price>, all_strikes: &[Price]) -> Vec<Price> {
81        match self {
82            Self::Fixed(strikes) => {
83                if all_strikes.is_empty() {
84                    strikes.clone()
85                } else {
86                    let available: HashSet<Price> = all_strikes.iter().copied().collect();
87                    strikes
88                        .iter()
89                        .filter(|s| available.contains(s))
90                        .copied()
91                        .collect()
92                }
93            }
94            Self::AtmRelative {
95                strikes_above,
96                strikes_below,
97            } => {
98                let Some(atm) = atm_price else {
99                    return vec![]; // Defer until ATM is known
100                };
101                // Find index of closest strike to ATM
102                let atm_idx = match all_strikes
103                    .binary_search_by(|s| s.as_f64().partial_cmp(&atm.as_f64()).unwrap())
104                {
105                    Ok(idx) => idx,
106                    Err(idx) => {
107                        if idx == 0 {
108                            0
109                        } else if idx >= all_strikes.len() {
110                            all_strikes.len() - 1
111                        } else {
112                            // Pick the closer of the two neighbors
113                            let diff_below = (all_strikes[idx - 1].as_f64() - atm.as_f64()).abs();
114                            let diff_above = (all_strikes[idx].as_f64() - atm.as_f64()).abs();
115                            if diff_below <= diff_above {
116                                idx - 1
117                            } else {
118                                idx
119                            }
120                        }
121                    }
122                };
123                let start = atm_idx.saturating_sub(*strikes_below);
124                let end = atm_idx
125                    .saturating_add(*strikes_above)
126                    .saturating_add(1)
127                    .min(all_strikes.len());
128                all_strikes[start..end].to_vec()
129            }
130            Self::AtmPercent { pct } => {
131                let Some(atm) = atm_price else {
132                    return vec![]; // Defer until ATM is known
133                };
134                let atm_f = atm.as_f64();
135                if atm_f == 0.0 {
136                    return all_strikes.to_vec();
137                }
138                all_strikes
139                    .iter()
140                    .filter(|s| {
141                        let pct_diff = ((s.as_f64() - atm_f) / atm_f).abs();
142                        pct_diff <= *pct
143                    })
144                    .copied()
145                    .collect()
146            }
147            Self::Delta { .. } => Self::AtmRelative {
148                strikes_above: DEFAULT_DELTA_FALLBACK_STRIKES,
149                strikes_below: DEFAULT_DELTA_FALLBACK_STRIKES,
150            }
151            .resolve(atm_price, all_strikes),
152        }
153    }
154}
155
156/// Exchange-provided option Greeks and implied volatility for a single instrument.
157#[repr(C)]
158#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
159#[serde(tag = "type")]
160#[cfg_attr(
161    feature = "python",
162    pyo3::pyclass(module = "nautilus_trader.core.nautilus_pyo3.model", from_py_object)
163)]
164#[cfg_attr(
165    feature = "python",
166    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.model")
167)]
168pub struct OptionGreeks {
169    /// The instrument ID these Greeks apply to.
170    pub instrument_id: InstrumentId,
171    /// The numeraire convention these Greeks are expressed in.
172    pub convention: GreeksConvention,
173    /// Core Greek sensitivity values.
174    pub greeks: OptionGreekValues,
175    /// Mark implied volatility.
176    pub mark_iv: Option<f64>,
177    /// Bid implied volatility.
178    pub bid_iv: Option<f64>,
179    /// Ask implied volatility.
180    pub ask_iv: Option<f64>,
181    /// Underlying price at time of Greeks calculation.
182    pub underlying_price: Option<f64>,
183    /// Open interest for the instrument.
184    pub open_interest: Option<f64>,
185    /// UNIX timestamp (nanoseconds) when the event occurred.
186    pub ts_event: UnixNanos,
187    /// UNIX timestamp (nanoseconds) when the instance was initialized.
188    pub ts_init: UnixNanos,
189}
190
191impl HasTsInit for OptionGreeks {
192    fn ts_init(&self) -> UnixNanos {
193        self.ts_init
194    }
195}
196
197impl Deref for OptionGreeks {
198    type Target = OptionGreekValues;
199    fn deref(&self) -> &Self::Target {
200        &self.greeks
201    }
202}
203
204impl HasGreeks for OptionGreeks {
205    fn greeks(&self) -> OptionGreekValues {
206        self.greeks
207    }
208}
209
210impl Default for OptionGreeks {
211    fn default() -> Self {
212        Self {
213            instrument_id: InstrumentId::from("NULL.NULL"),
214            convention: GreeksConvention::default(),
215            greeks: OptionGreekValues::default(),
216            mark_iv: None,
217            bid_iv: None,
218            ask_iv: None,
219            underlying_price: None,
220            open_interest: None,
221            ts_event: UnixNanos::default(),
222            ts_init: UnixNanos::default(),
223        }
224    }
225}
226
227impl Display for OptionGreeks {
228    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
229        write!(
230            f,
231            "OptionGreeks({}, {}, delta={:.4}, gamma={:.4}, vega={:.4}, theta={:.4}, mark_iv={:?})",
232            self.instrument_id,
233            self.convention,
234            self.delta,
235            self.gamma,
236            self.vega,
237            self.theta,
238            self.mark_iv
239        )
240    }
241}
242
243impl Serializable for OptionGreeks {}
244
245/// Combined quote and Greeks data for a single strike in an option chain.
246#[derive(Clone, Debug)]
247#[cfg_attr(
248    feature = "python",
249    pyo3::pyclass(module = "nautilus_trader.core.nautilus_pyo3.model", from_py_object)
250)]
251#[cfg_attr(
252    feature = "python",
253    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.model")
254)]
255pub struct OptionStrikeData {
256    /// The latest quote for this strike.
257    pub quote: QuoteTick,
258    /// Exchange-provided Greeks (if available).
259    pub greeks: Option<OptionGreeks>,
260}
261
262/// A point-in-time snapshot of an option chain for a single series.
263#[derive(Clone, Debug)]
264#[cfg_attr(
265    feature = "python",
266    pyo3::pyclass(module = "nautilus_trader.core.nautilus_pyo3.model", from_py_object)
267)]
268#[cfg_attr(
269    feature = "python",
270    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.model")
271)]
272pub struct OptionChainSlice {
273    /// The option series identifier.
274    pub series_id: OptionSeriesId,
275    /// The current ATM strike price (if determined).
276    pub atm_strike: Option<Price>,
277    /// Call option data keyed by strike price (sorted).
278    pub calls: BTreeMap<Price, OptionStrikeData>,
279    /// Put option data keyed by strike price (sorted).
280    pub puts: BTreeMap<Price, OptionStrikeData>,
281    /// UNIX timestamp (nanoseconds) when the snapshot event occurred.
282    pub ts_event: UnixNanos,
283    /// UNIX timestamp (nanoseconds) when the instance was initialized.
284    pub ts_init: UnixNanos,
285}
286
287impl HasTsInit for OptionChainSlice {
288    fn ts_init(&self) -> UnixNanos {
289        self.ts_init
290    }
291}
292
293impl Display for OptionChainSlice {
294    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
295        write!(
296            f,
297            "OptionChainSlice({}, atm={:?}, calls={}, puts={})",
298            self.series_id,
299            self.atm_strike,
300            self.calls.len(),
301            self.puts.len()
302        )
303    }
304}
305
306impl OptionChainSlice {
307    /// Creates a new empty [`OptionChainSlice`] for the given series.
308    #[must_use]
309    pub fn new(series_id: OptionSeriesId) -> Self {
310        Self {
311            series_id,
312            atm_strike: None,
313            calls: BTreeMap::new(),
314            puts: BTreeMap::new(),
315            ts_event: UnixNanos::default(),
316            ts_init: UnixNanos::default(),
317        }
318    }
319
320    /// Returns the number of call entries.
321    #[must_use]
322    pub fn call_count(&self) -> usize {
323        self.calls.len()
324    }
325
326    /// Returns the number of put entries.
327    #[must_use]
328    pub fn put_count(&self) -> usize {
329        self.puts.len()
330    }
331
332    /// Returns the call data for a given strike price.
333    #[must_use]
334    pub fn get_call(&self, strike: &Price) -> Option<&OptionStrikeData> {
335        self.calls.get(strike)
336    }
337
338    /// Returns the put data for a given strike price.
339    #[must_use]
340    pub fn get_put(&self, strike: &Price) -> Option<&OptionStrikeData> {
341        self.puts.get(strike)
342    }
343
344    /// Returns the call quote for a given strike price.
345    #[must_use]
346    pub fn get_call_quote(&self, strike: &Price) -> Option<&QuoteTick> {
347        self.calls.get(strike).map(|d| &d.quote)
348    }
349
350    /// Returns the call Greeks for a given strike price.
351    #[must_use]
352    pub fn get_call_greeks(&self, strike: &Price) -> Option<&OptionGreeks> {
353        self.calls.get(strike).and_then(|d| d.greeks.as_ref())
354    }
355
356    /// Returns the put quote for a given strike price.
357    #[must_use]
358    pub fn get_put_quote(&self, strike: &Price) -> Option<&QuoteTick> {
359        self.puts.get(strike).map(|d| &d.quote)
360    }
361
362    /// Returns the put Greeks for a given strike price.
363    #[must_use]
364    pub fn get_put_greeks(&self, strike: &Price) -> Option<&OptionGreeks> {
365        self.puts.get(strike).and_then(|d| d.greeks.as_ref())
366    }
367
368    /// Returns all strike prices present in the chain (union of calls and puts).
369    #[must_use]
370    pub fn strikes(&self) -> Vec<Price> {
371        let mut strikes: Vec<Price> = self.calls.keys().chain(self.puts.keys()).copied().collect();
372        strikes.sort();
373        strikes.dedup();
374        strikes
375    }
376
377    /// Returns the total number of unique strikes.
378    #[must_use]
379    pub fn strike_count(&self) -> usize {
380        self.strikes().len()
381    }
382
383    /// Returns `true` if the chain has no data.
384    #[must_use]
385    pub fn is_empty(&self) -> bool {
386        self.calls.is_empty() && self.puts.is_empty()
387    }
388}
389
390#[cfg(test)]
391mod tests {
392    use rstest::*;
393
394    use super::*;
395    use crate::{identifiers::Venue, types::Quantity};
396
397    fn make_quote(instrument_id: InstrumentId) -> QuoteTick {
398        QuoteTick::new(
399            instrument_id,
400            Price::from("100.00"),
401            Price::from("101.00"),
402            Quantity::from("1.0"),
403            Quantity::from("1.0"),
404            UnixNanos::from(1u64),
405            UnixNanos::from(1u64),
406        )
407    }
408
409    fn make_series_id() -> OptionSeriesId {
410        OptionSeriesId::new(
411            Venue::new("DERIBIT"),
412            ustr::Ustr::from("BTC"),
413            ustr::Ustr::from("BTC"),
414            UnixNanos::from(1_700_000_000_000_000_000u64),
415        )
416    }
417
418    #[rstest]
419    fn test_strike_range_fixed() {
420        let range = StrikeRange::Fixed(vec![Price::from("50000"), Price::from("55000")]);
421        assert_eq!(
422            range,
423            StrikeRange::Fixed(vec![Price::from("50000"), Price::from("55000")])
424        );
425    }
426
427    #[rstest]
428    fn test_strike_range_atm_relative() {
429        let range = StrikeRange::AtmRelative {
430            strikes_above: 5,
431            strikes_below: 5,
432        };
433
434        if let StrikeRange::AtmRelative {
435            strikes_above,
436            strikes_below,
437        } = range
438        {
439            assert_eq!(strikes_above, 5);
440            assert_eq!(strikes_below, 5);
441        } else {
442            panic!("Expected AtmRelative variant");
443        }
444    }
445
446    #[rstest]
447    fn test_strike_range_atm_percent() {
448        let range = StrikeRange::AtmPercent { pct: 0.1 };
449        if let StrikeRange::AtmPercent { pct } = range {
450            assert!((pct - 0.1).abs() < f64::EPSILON);
451        } else {
452            panic!("Expected AtmPercent variant");
453        }
454    }
455
456    #[rstest]
457    fn test_option_greeks_default_fields() {
458        let greeks = OptionGreeks {
459            instrument_id: InstrumentId::from("BTC-20240101-50000-C.DERIBIT"),
460            convention: GreeksConvention::BlackScholes,
461            greeks: OptionGreekValues::default(),
462            mark_iv: None,
463            bid_iv: None,
464            ask_iv: None,
465            underlying_price: None,
466            open_interest: None,
467            ts_event: UnixNanos::default(),
468            ts_init: UnixNanos::default(),
469        };
470        assert_eq!(greeks.delta, 0.0);
471        assert_eq!(greeks.gamma, 0.0);
472        assert_eq!(greeks.vega, 0.0);
473        assert_eq!(greeks.theta, 0.0);
474        assert!(greeks.mark_iv.is_none());
475        assert_eq!(greeks.convention, GreeksConvention::BlackScholes);
476    }
477
478    #[rstest]
479    fn test_option_greeks_default_is_black_scholes() {
480        let greeks = OptionGreeks::default();
481        assert_eq!(greeks.convention, GreeksConvention::BlackScholes);
482    }
483
484    #[rstest]
485    fn test_option_greeks_display() {
486        let greeks = OptionGreeks {
487            instrument_id: InstrumentId::from("BTC-20240101-50000-C.DERIBIT"),
488            convention: GreeksConvention::PriceAdjusted,
489            greeks: OptionGreekValues {
490                delta: 0.55,
491                gamma: 0.001,
492                vega: 10.0,
493                theta: -5.0,
494                rho: 0.0,
495            },
496            mark_iv: Some(0.65),
497            bid_iv: None,
498            ask_iv: None,
499            underlying_price: None,
500            open_interest: None,
501            ts_event: UnixNanos::default(),
502            ts_init: UnixNanos::default(),
503        };
504        let display = format!("{greeks}");
505        assert!(display.contains("OptionGreeks"));
506        assert!(display.contains("PRICE_ADJUSTED"));
507        assert!(display.contains("0.55"));
508    }
509
510    #[rstest]
511    fn test_option_greeks_data_serde_round_trip() {
512        let greeks = OptionGreeks {
513            instrument_id: InstrumentId::from("BTC-20240101-50000-C.DERIBIT"),
514            convention: GreeksConvention::PriceAdjusted,
515            greeks: OptionGreekValues {
516                delta: 0.55,
517                gamma: 0.001,
518                vega: 10.0,
519                theta: -5.0,
520                rho: 0.2,
521            },
522            mark_iv: Some(0.65),
523            bid_iv: None,
524            ask_iv: Some(0.66),
525            underlying_price: Some(50_000.0),
526            open_interest: None,
527            ts_event: UnixNanos::from(1u64),
528            ts_init: UnixNanos::from(2u64),
529        };
530        let data = crate::data::Data::OptionGreeks(greeks);
531
532        let json = serde_json::to_string(&data).unwrap();
533        let roundtripped: crate::data::Data = serde_json::from_str(&json).unwrap();
534
535        assert_eq!(roundtripped, data);
536    }
537
538    #[rstest]
539    fn test_option_chain_slice_empty() {
540        let slice = OptionChainSlice {
541            series_id: make_series_id(),
542            atm_strike: None,
543            calls: BTreeMap::new(),
544            puts: BTreeMap::new(),
545            ts_event: UnixNanos::from(1u64),
546            ts_init: UnixNanos::from(1u64),
547        };
548
549        assert!(slice.is_empty());
550        assert_eq!(slice.strike_count(), 0);
551        assert!(slice.strikes().is_empty());
552    }
553
554    #[rstest]
555    fn test_option_chain_slice_with_data() {
556        let call_id = InstrumentId::from("BTC-20240101-50000-C.DERIBIT");
557        let put_id = InstrumentId::from("BTC-20240101-50000-P.DERIBIT");
558        let strike = Price::from("50000");
559
560        let mut calls = BTreeMap::new();
561        calls.insert(
562            strike,
563            OptionStrikeData {
564                quote: make_quote(call_id),
565                greeks: Some(OptionGreeks {
566                    instrument_id: call_id,
567                    greeks: OptionGreekValues {
568                        delta: 0.55,
569                        ..Default::default()
570                    },
571                    ..Default::default()
572                }),
573            },
574        );
575
576        let mut puts = BTreeMap::new();
577        puts.insert(
578            strike,
579            OptionStrikeData {
580                quote: make_quote(put_id),
581                greeks: None,
582            },
583        );
584
585        let slice = OptionChainSlice {
586            series_id: make_series_id(),
587            atm_strike: Some(strike),
588            calls,
589            puts,
590            ts_event: UnixNanos::from(1u64),
591            ts_init: UnixNanos::from(1u64),
592        };
593
594        assert!(!slice.is_empty());
595        assert_eq!(slice.strike_count(), 1);
596        assert_eq!(slice.strikes(), vec![strike]);
597        assert!(slice.get_call(&strike).is_some());
598        assert!(slice.get_put(&strike).is_some());
599        assert!(slice.get_call_greeks(&strike).is_some());
600        assert!(slice.get_put_greeks(&strike).is_none());
601        assert_eq!(slice.get_call_greeks(&strike).unwrap().delta, 0.55);
602    }
603
604    #[rstest]
605    fn test_option_chain_slice_display() {
606        let slice = OptionChainSlice {
607            series_id: make_series_id(),
608            atm_strike: None,
609            calls: BTreeMap::new(),
610            puts: BTreeMap::new(),
611            ts_event: UnixNanos::from(1u64),
612            ts_init: UnixNanos::from(1u64),
613        };
614
615        let display = format!("{slice}");
616        assert!(display.contains("OptionChainSlice"));
617        assert!(display.contains("DERIBIT"));
618    }
619
620    #[rstest]
621    fn test_option_chain_slice_ts_init() {
622        let slice = OptionChainSlice {
623            series_id: make_series_id(),
624            atm_strike: None,
625            calls: BTreeMap::new(),
626            puts: BTreeMap::new(),
627            ts_event: UnixNanos::from(1u64),
628            ts_init: UnixNanos::from(42u64),
629        };
630
631        assert_eq!(slice.ts_init(), UnixNanos::from(42u64));
632    }
633
634    // -- StrikeRange::resolve tests --
635
636    #[rstest]
637    fn test_strike_range_resolve_fixed() {
638        let range = StrikeRange::Fixed(vec![Price::from("50000"), Price::from("55000")]);
639        let result = range.resolve(None, &[]);
640        assert_eq!(result, vec![Price::from("50000"), Price::from("55000")]);
641    }
642
643    #[rstest]
644    fn test_strike_range_resolve_atm_relative() {
645        let range = StrikeRange::AtmRelative {
646            strikes_above: 2,
647            strikes_below: 2,
648        };
649        let strikes: Vec<Price> = [45000, 47000, 50000, 53000, 55000, 57000]
650            .iter()
651            .map(|s| Price::from(&s.to_string()))
652            .collect();
653        let atm = Some(Price::from("50000"));
654        let result = range.resolve(atm, &strikes);
655        // ATM at index 2, below=2 → start=0, above=2 → end=5
656        assert_eq!(result.len(), 5);
657        assert_eq!(result[0], Price::from("45000"));
658        assert_eq!(result[4], Price::from("55000"));
659    }
660
661    #[rstest]
662    fn test_strike_range_resolve_atm_relative_saturates_extreme_window() {
663        // An extreme window must clamp to the available strikes without overflowing
664        let range = StrikeRange::AtmRelative {
665            strikes_above: usize::MAX,
666            strikes_below: usize::MAX,
667        };
668        let strikes: Vec<Price> = [45000, 50000, 55000]
669            .iter()
670            .map(|s| Price::from(&s.to_string()))
671            .collect();
672        let atm = Some(Price::from("50000"));
673
674        let result = range.resolve(atm, &strikes);
675
676        assert_eq!(result, strikes);
677    }
678
679    #[rstest]
680    fn test_strike_range_resolve_atm_relative_no_atm() {
681        let range = StrikeRange::AtmRelative {
682            strikes_above: 2,
683            strikes_below: 2,
684        };
685        let strikes = vec![Price::from("50000"), Price::from("55000")];
686        let result = range.resolve(None, &strikes);
687        // No ATM → return empty (deferred until ATM known)
688        assert!(result.is_empty());
689    }
690
691    #[rstest]
692    fn test_strike_range_resolve_atm_percent() {
693        let range = StrikeRange::AtmPercent { pct: 0.1 }; // 10%
694        let strikes: Vec<Price> = [45000, 48000, 50000, 52000, 55000, 60000]
695            .iter()
696            .map(|s| Price::from(&s.to_string()))
697            .collect();
698        let atm = Some(Price::from("50000"));
699        let result = range.resolve(atm, &strikes);
700        // 10% of 50000 = 5000, so [45000..55000] inclusive (<=)
701        assert_eq!(result.len(), 5); // 45000, 48000, 50000, 52000, 55000
702        assert!(result.contains(&Price::from("45000")));
703        assert!(result.contains(&Price::from("48000")));
704        assert!(result.contains(&Price::from("50000")));
705        assert!(result.contains(&Price::from("52000")));
706        assert!(result.contains(&Price::from("55000")));
707    }
708
709    #[rstest]
710    fn test_option_chain_slice_new_empty() {
711        let slice = OptionChainSlice::new(make_series_id());
712        assert!(slice.is_empty());
713        assert_eq!(slice.call_count(), 0);
714        assert_eq!(slice.put_count(), 0);
715        assert!(slice.atm_strike.is_none());
716    }
717
718    #[rstest]
719    fn test_strike_range_resolve_delta_falls_back_to_atm_relative() {
720        // The model-level resolve has no Greeks, so Delta delegates to an
721        // AtmRelative window of DEFAULT_DELTA_FALLBACK_STRIKES either side of ATM.
722        let strikes: Vec<Price> = (0..=20)
723            .map(|i| Price::from(&(40000 + i * 1000).to_string()))
724            .collect();
725        let atm = Some(Price::from("50000")); // index 10
726        let delta = StrikeRange::Delta {
727            target: 0.25,
728            tolerance: 0.05,
729        };
730        let expected = StrikeRange::AtmRelative {
731            strikes_above: DEFAULT_DELTA_FALLBACK_STRIKES,
732            strikes_below: DEFAULT_DELTA_FALLBACK_STRIKES,
733        }
734        .resolve(atm, &strikes);
735
736        let result = delta.resolve(atm, &strikes);
737        assert_eq!(result, expected);
738        assert_eq!(result.len(), 2 * DEFAULT_DELTA_FALLBACK_STRIKES + 1);
739        assert!(result.contains(&Price::from("50000")));
740        assert!(!result.contains(&Price::from("40000")));
741        assert!(!result.contains(&Price::from("60000")));
742    }
743
744    #[rstest]
745    fn test_strike_range_resolve_delta_empty_without_atm() {
746        let delta = StrikeRange::Delta {
747            target: 0.25,
748            tolerance: 0.05,
749        };
750        let strikes = vec![Price::from("50000"), Price::from("55000")];
751        // No ATM -> deferred (empty), matching ATM-relative behaviour.
752        assert!(delta.resolve(None, &strikes).is_empty());
753    }
754}