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