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