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