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