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