1use std::{
19 collections::{BTreeMap, HashSet},
20 fmt::Display,
21 ops::Deref,
22};
23
24use nautilus_core::{UnixNanos, serialization::Serializable};
25use rust_decimal::prelude::ToPrimitive;
26use serde::{Deserialize, Serialize};
27
28use super::HasTsInit;
29use crate::{
30 data::{
31 QuoteTick,
32 greeks::{HasGreeks, OptionGreekValues},
33 },
34 enums::GreeksConvention,
35 identifiers::{InstrumentId, OptionSeriesId},
36 types::Price,
37};
38
39pub(crate) const DEFAULT_DELTA_FALLBACK_STRIKES: usize = 5;
42
43#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
45pub enum StrikeRange {
46 Fixed(Vec<Price>),
48 AtmRelative {
50 strikes_above: usize,
51 strikes_below: usize,
52 },
53 AtmPercent { pct: f64 },
55 Delta { target: f64, tolerance: f64 },
62}
63
64impl StrikeRange {
65 #[must_use]
77 pub fn resolve(&self, atm_price: Option<Price>, all_strikes: &[Price]) -> Vec<Price> {
78 match self {
79 Self::Fixed(strikes) => {
80 if all_strikes.is_empty() {
81 strikes.clone()
82 } else {
83 let available: HashSet<Price> = all_strikes.iter().copied().collect();
84 strikes
85 .iter()
86 .filter(|s| available.contains(s))
87 .copied()
88 .collect()
89 }
90 }
91 Self::AtmRelative {
92 strikes_above,
93 strikes_below,
94 } => {
95 let Some(atm) = atm_price else {
96 return vec![]; };
98 let atm_idx = match all_strikes.binary_search(&atm) {
100 Ok(idx) => idx,
101 Err(idx) => {
102 if idx == 0 {
103 0
104 } else if idx >= all_strikes.len() {
105 all_strikes.len() - 1
106 } else {
107 let diff_below = all_strikes[idx - 1].raw.abs_diff(atm.raw);
109 let diff_above = all_strikes[idx].raw.abs_diff(atm.raw);
110 if diff_below <= diff_above {
111 idx - 1
112 } else {
113 idx
114 }
115 }
116 }
117 };
118 let start = atm_idx.saturating_sub(*strikes_below);
119 let end = atm_idx
120 .saturating_add(*strikes_above)
121 .saturating_add(1)
122 .min(all_strikes.len());
123 all_strikes[start..end].to_vec()
124 }
125 Self::AtmPercent { pct } => {
126 let Some(atm) = atm_price else {
127 return vec![]; };
129 let atm_decimal = atm.as_decimal();
130 if atm_decimal.is_zero() {
131 return all_strikes.to_vec();
132 }
133 all_strikes
134 .iter()
135 .filter(|s| {
136 let distance = (s.as_decimal() - atm_decimal).abs();
137 let pct_diff = distance / atm_decimal.abs();
138 pct_diff.to_f64().is_some_and(|pct_diff| pct_diff <= *pct)
139 })
140 .copied()
141 .collect()
142 }
143 Self::Delta { .. } => Self::AtmRelative {
144 strikes_above: DEFAULT_DELTA_FALLBACK_STRIKES,
145 strikes_below: DEFAULT_DELTA_FALLBACK_STRIKES,
146 }
147 .resolve(atm_price, all_strikes),
148 }
149 }
150}
151
152#[repr(C)]
154#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
155#[serde(tag = "type")]
156#[cfg_attr(
157 feature = "python",
158 pyo3::pyclass(module = "nautilus_trader.core.nautilus_pyo3.model", from_py_object)
159)]
160#[cfg_attr(
161 feature = "python",
162 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.model")
163)]
164pub struct OptionGreeks {
165 pub instrument_id: InstrumentId,
167 pub convention: GreeksConvention,
169 pub greeks: OptionGreekValues,
171 pub mark_iv: Option<f64>,
173 pub bid_iv: Option<f64>,
175 pub ask_iv: Option<f64>,
177 pub underlying_price: Option<f64>,
179 pub open_interest: Option<f64>,
181 pub ts_event: UnixNanos,
183 pub ts_init: UnixNanos,
185}
186
187impl HasTsInit for OptionGreeks {
188 fn ts_init(&self) -> UnixNanos {
189 self.ts_init
190 }
191}
192
193impl Deref for OptionGreeks {
194 type Target = OptionGreekValues;
195 fn deref(&self) -> &Self::Target {
196 &self.greeks
197 }
198}
199
200impl HasGreeks for OptionGreeks {
201 fn greeks(&self) -> OptionGreekValues {
202 self.greeks
203 }
204}
205
206impl Default for OptionGreeks {
207 fn default() -> Self {
208 Self {
209 instrument_id: InstrumentId::from("NULL.NULL"),
210 convention: GreeksConvention::default(),
211 greeks: OptionGreekValues::default(),
212 mark_iv: None,
213 bid_iv: None,
214 ask_iv: None,
215 underlying_price: None,
216 open_interest: None,
217 ts_event: UnixNanos::default(),
218 ts_init: UnixNanos::default(),
219 }
220 }
221}
222
223impl Display for OptionGreeks {
224 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
225 write!(
226 f,
227 "OptionGreeks({}, {}, delta={:.4}, gamma={:.4}, vega={:.4}, theta={:.4}, mark_iv={:?})",
228 self.instrument_id,
229 self.convention,
230 self.delta,
231 self.gamma,
232 self.vega,
233 self.theta,
234 self.mark_iv
235 )
236 }
237}
238
239impl Serializable for OptionGreeks {}
240
241#[derive(Clone, Debug)]
243#[cfg_attr(
244 feature = "python",
245 pyo3::pyclass(module = "nautilus_trader.core.nautilus_pyo3.model", from_py_object)
246)]
247#[cfg_attr(
248 feature = "python",
249 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.model")
250)]
251pub struct OptionStrikeData {
252 pub quote: QuoteTick,
254 pub greeks: Option<OptionGreeks>,
256}
257
258#[derive(Clone, Debug)]
260#[cfg_attr(
261 feature = "python",
262 pyo3::pyclass(module = "nautilus_trader.core.nautilus_pyo3.model", from_py_object)
263)]
264#[cfg_attr(
265 feature = "python",
266 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.model")
267)]
268pub struct OptionChainSlice {
269 pub series_id: OptionSeriesId,
271 pub atm_strike: Option<Price>,
273 pub calls: BTreeMap<Price, OptionStrikeData>,
275 pub puts: BTreeMap<Price, OptionStrikeData>,
277 pub ts_event: UnixNanos,
279 pub ts_init: UnixNanos,
281}
282
283impl HasTsInit for OptionChainSlice {
284 fn ts_init(&self) -> UnixNanos {
285 self.ts_init
286 }
287}
288
289impl Display for OptionChainSlice {
290 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
291 write!(
292 f,
293 "OptionChainSlice({}, atm={:?}, calls={}, puts={})",
294 self.series_id,
295 self.atm_strike,
296 self.calls.len(),
297 self.puts.len()
298 )
299 }
300}
301
302impl OptionChainSlice {
303 #[must_use]
305 pub fn new(series_id: OptionSeriesId) -> Self {
306 Self {
307 series_id,
308 atm_strike: None,
309 calls: BTreeMap::new(),
310 puts: BTreeMap::new(),
311 ts_event: UnixNanos::default(),
312 ts_init: UnixNanos::default(),
313 }
314 }
315
316 #[must_use]
318 pub fn call_count(&self) -> usize {
319 self.calls.len()
320 }
321
322 #[must_use]
324 pub fn put_count(&self) -> usize {
325 self.puts.len()
326 }
327
328 #[must_use]
330 pub fn get_call(&self, strike: &Price) -> Option<&OptionStrikeData> {
331 self.calls.get(strike)
332 }
333
334 #[must_use]
336 pub fn get_put(&self, strike: &Price) -> Option<&OptionStrikeData> {
337 self.puts.get(strike)
338 }
339
340 #[must_use]
342 pub fn get_call_quote(&self, strike: &Price) -> Option<&QuoteTick> {
343 self.calls.get(strike).map(|d| &d.quote)
344 }
345
346 #[must_use]
348 pub fn get_call_greeks(&self, strike: &Price) -> Option<&OptionGreeks> {
349 self.calls.get(strike).and_then(|d| d.greeks.as_ref())
350 }
351
352 #[must_use]
354 pub fn get_put_quote(&self, strike: &Price) -> Option<&QuoteTick> {
355 self.puts.get(strike).map(|d| &d.quote)
356 }
357
358 #[must_use]
360 pub fn get_put_greeks(&self, strike: &Price) -> Option<&OptionGreeks> {
361 self.puts.get(strike).and_then(|d| d.greeks.as_ref())
362 }
363
364 #[must_use]
366 pub fn strikes(&self) -> Vec<Price> {
367 let mut strikes: Vec<Price> = self.calls.keys().chain(self.puts.keys()).copied().collect();
368 strikes.sort();
369 strikes.dedup();
370 strikes
371 }
372
373 #[must_use]
375 pub fn strike_count(&self) -> usize {
376 self.strikes().len()
377 }
378
379 #[must_use]
381 pub fn is_empty(&self) -> bool {
382 self.calls.is_empty() && self.puts.is_empty()
383 }
384}
385
386#[cfg(test)]
387mod tests {
388 use rstest::*;
389
390 use super::*;
391 use crate::{identifiers::Venue, types::Quantity};
392
393 fn make_quote(instrument_id: InstrumentId) -> QuoteTick {
394 QuoteTick::new(
395 instrument_id,
396 Price::from("100.00"),
397 Price::from("101.00"),
398 Quantity::from("1.0"),
399 Quantity::from("1.0"),
400 UnixNanos::from(1u64),
401 UnixNanos::from(1u64),
402 )
403 }
404
405 fn make_series_id() -> OptionSeriesId {
406 OptionSeriesId::new(
407 Venue::new("DERIBIT"),
408 ustr::Ustr::from("BTC"),
409 ustr::Ustr::from("BTC"),
410 UnixNanos::from(1_700_000_000_000_000_000u64),
411 )
412 }
413
414 #[rstest]
415 fn test_strike_range_fixed() {
416 let range = StrikeRange::Fixed(vec![Price::from("50000"), Price::from("55000")]);
417 assert_eq!(
418 range,
419 StrikeRange::Fixed(vec![Price::from("50000"), Price::from("55000")])
420 );
421 }
422
423 #[rstest]
424 fn test_strike_range_atm_relative() {
425 let range = StrikeRange::AtmRelative {
426 strikes_above: 5,
427 strikes_below: 5,
428 };
429
430 if let StrikeRange::AtmRelative {
431 strikes_above,
432 strikes_below,
433 } = range
434 {
435 assert_eq!(strikes_above, 5);
436 assert_eq!(strikes_below, 5);
437 } else {
438 panic!("Expected AtmRelative variant");
439 }
440 }
441
442 #[rstest]
443 fn test_strike_range_atm_percent() {
444 let range = StrikeRange::AtmPercent { pct: 0.1 };
445 if let StrikeRange::AtmPercent { pct } = range {
446 assert!((pct - 0.1).abs() < f64::EPSILON);
447 } else {
448 panic!("Expected AtmPercent variant");
449 }
450 }
451
452 #[rstest]
453 fn test_option_greeks_default_fields() {
454 let greeks = OptionGreeks {
455 instrument_id: InstrumentId::from("BTC-20240101-50000-C.DERIBIT"),
456 convention: GreeksConvention::BlackScholes,
457 greeks: OptionGreekValues::default(),
458 mark_iv: None,
459 bid_iv: None,
460 ask_iv: None,
461 underlying_price: None,
462 open_interest: None,
463 ts_event: UnixNanos::default(),
464 ts_init: UnixNanos::default(),
465 };
466 assert_eq!(greeks.delta, 0.0);
467 assert_eq!(greeks.gamma, 0.0);
468 assert_eq!(greeks.vega, 0.0);
469 assert_eq!(greeks.theta, 0.0);
470 assert!(greeks.mark_iv.is_none());
471 assert_eq!(greeks.convention, GreeksConvention::BlackScholes);
472 }
473
474 #[rstest]
475 fn test_option_greeks_default_is_black_scholes() {
476 let greeks = OptionGreeks::default();
477 assert_eq!(greeks.convention, GreeksConvention::BlackScholes);
478 }
479
480 #[rstest]
481 fn test_option_greeks_display() {
482 let greeks = OptionGreeks {
483 instrument_id: InstrumentId::from("BTC-20240101-50000-C.DERIBIT"),
484 convention: GreeksConvention::PriceAdjusted,
485 greeks: OptionGreekValues {
486 delta: 0.55,
487 gamma: 0.001,
488 vega: 10.0,
489 theta: -5.0,
490 rho: 0.0,
491 },
492 mark_iv: Some(0.65),
493 bid_iv: None,
494 ask_iv: None,
495 underlying_price: None,
496 open_interest: None,
497 ts_event: UnixNanos::default(),
498 ts_init: UnixNanos::default(),
499 };
500 let display = format!("{greeks}");
501 assert!(display.contains("OptionGreeks"));
502 assert!(display.contains("PRICE_ADJUSTED"));
503 assert!(display.contains("0.55"));
504 }
505
506 #[rstest]
507 fn test_option_greeks_data_serde_round_trip() {
508 let greeks = OptionGreeks {
509 instrument_id: InstrumentId::from("BTC-20240101-50000-C.DERIBIT"),
510 convention: GreeksConvention::PriceAdjusted,
511 greeks: OptionGreekValues {
512 delta: 0.55,
513 gamma: 0.001,
514 vega: 10.0,
515 theta: -5.0,
516 rho: 0.2,
517 },
518 mark_iv: Some(0.65),
519 bid_iv: None,
520 ask_iv: Some(0.66),
521 underlying_price: Some(50_000.0),
522 open_interest: None,
523 ts_event: UnixNanos::from(1u64),
524 ts_init: UnixNanos::from(2u64),
525 };
526 let data = crate::data::Data::OptionGreeks(greeks);
527
528 let json = serde_json::to_string(&data).unwrap();
529 let roundtripped: crate::data::Data = serde_json::from_str(&json).unwrap();
530
531 assert_eq!(roundtripped, data);
532 }
533
534 #[rstest]
535 fn test_option_chain_slice_empty() {
536 let slice = OptionChainSlice {
537 series_id: make_series_id(),
538 atm_strike: None,
539 calls: BTreeMap::new(),
540 puts: BTreeMap::new(),
541 ts_event: UnixNanos::from(1u64),
542 ts_init: UnixNanos::from(1u64),
543 };
544
545 assert!(slice.is_empty());
546 assert_eq!(slice.strike_count(), 0);
547 assert!(slice.strikes().is_empty());
548 }
549
550 #[rstest]
551 fn test_option_chain_slice_with_data() {
552 let call_id = InstrumentId::from("BTC-20240101-50000-C.DERIBIT");
553 let put_id = InstrumentId::from("BTC-20240101-50000-P.DERIBIT");
554 let strike = Price::from("50000");
555
556 let mut calls = BTreeMap::new();
557 calls.insert(
558 strike,
559 OptionStrikeData {
560 quote: make_quote(call_id),
561 greeks: Some(OptionGreeks {
562 instrument_id: call_id,
563 greeks: OptionGreekValues {
564 delta: 0.55,
565 ..Default::default()
566 },
567 ..Default::default()
568 }),
569 },
570 );
571
572 let mut puts = BTreeMap::new();
573 puts.insert(
574 strike,
575 OptionStrikeData {
576 quote: make_quote(put_id),
577 greeks: None,
578 },
579 );
580
581 let slice = OptionChainSlice {
582 series_id: make_series_id(),
583 atm_strike: Some(strike),
584 calls,
585 puts,
586 ts_event: UnixNanos::from(1u64),
587 ts_init: UnixNanos::from(1u64),
588 };
589
590 assert!(!slice.is_empty());
591 assert_eq!(slice.strike_count(), 1);
592 assert_eq!(slice.strikes(), vec![strike]);
593 assert!(slice.get_call(&strike).is_some());
594 assert!(slice.get_put(&strike).is_some());
595 assert!(slice.get_call_greeks(&strike).is_some());
596 assert!(slice.get_put_greeks(&strike).is_none());
597 assert_eq!(slice.get_call_greeks(&strike).unwrap().delta, 0.55);
598 }
599
600 #[rstest]
601 fn test_option_chain_slice_display() {
602 let slice = OptionChainSlice {
603 series_id: make_series_id(),
604 atm_strike: None,
605 calls: BTreeMap::new(),
606 puts: BTreeMap::new(),
607 ts_event: UnixNanos::from(1u64),
608 ts_init: UnixNanos::from(1u64),
609 };
610
611 let display = format!("{slice}");
612 assert!(display.contains("OptionChainSlice"));
613 assert!(display.contains("DERIBIT"));
614 }
615
616 #[rstest]
617 fn test_option_chain_slice_ts_init() {
618 let slice = OptionChainSlice {
619 series_id: make_series_id(),
620 atm_strike: None,
621 calls: BTreeMap::new(),
622 puts: BTreeMap::new(),
623 ts_event: UnixNanos::from(1u64),
624 ts_init: UnixNanos::from(42u64),
625 };
626
627 assert_eq!(slice.ts_init(), UnixNanos::from(42u64));
628 }
629
630 #[rstest]
633 fn test_strike_range_resolve_fixed() {
634 let range = StrikeRange::Fixed(vec![Price::from("50000"), Price::from("55000")]);
635 let result = range.resolve(None, &[]);
636 assert_eq!(result, vec![Price::from("50000"), Price::from("55000")]);
637 }
638
639 #[rstest]
640 fn test_strike_range_resolve_atm_relative() {
641 let range = StrikeRange::AtmRelative {
642 strikes_above: 2,
643 strikes_below: 2,
644 };
645 let strikes: Vec<Price> = [45000, 47000, 50000, 53000, 55000, 57000]
646 .iter()
647 .map(|s| Price::from(&s.to_string()))
648 .collect();
649 let atm = Some(Price::from("50000"));
650 let result = range.resolve(atm, &strikes);
651 assert_eq!(result.len(), 5);
653 assert_eq!(result[0], Price::from("45000"));
654 assert_eq!(result[4], Price::from("55000"));
655 }
656
657 #[rstest]
658 fn test_strike_range_resolve_atm_relative_exact_high_value() {
659 let range = StrikeRange::AtmRelative {
660 strikes_above: 0,
661 strikes_below: 0,
662 };
663 let atm = Price::from("9007199253.999000000");
664 let collapsed = Price::from("9007199253.999000001");
665 let strikes = [atm, collapsed];
666 assert_eq!(collapsed.as_f64(), atm.as_f64());
667
668 let result = range.resolve(Some(atm), &strikes);
669
670 assert_eq!(result, vec![atm]);
671 }
672
673 #[rstest]
674 fn test_strike_range_resolve_atm_relative_saturates_extreme_window() {
675 let range = StrikeRange::AtmRelative {
677 strikes_above: usize::MAX,
678 strikes_below: usize::MAX,
679 };
680 let strikes: Vec<Price> = [45000, 50000, 55000]
681 .iter()
682 .map(|s| Price::from(&s.to_string()))
683 .collect();
684 let atm = Some(Price::from("50000"));
685
686 let result = range.resolve(atm, &strikes);
687
688 assert_eq!(result, strikes);
689 }
690
691 #[rstest]
692 fn test_strike_range_resolve_atm_relative_no_atm() {
693 let range = StrikeRange::AtmRelative {
694 strikes_above: 2,
695 strikes_below: 2,
696 };
697 let strikes = vec![Price::from("50000"), Price::from("55000")];
698 let result = range.resolve(None, &strikes);
699 assert!(result.is_empty());
701 }
702
703 #[rstest]
704 fn test_strike_range_resolve_atm_percent() {
705 let range = StrikeRange::AtmPercent { pct: 0.1 }; let strikes: Vec<Price> = [45000, 48000, 50000, 52000, 55000, 60000]
707 .iter()
708 .map(|s| Price::from(&s.to_string()))
709 .collect();
710 let atm = Some(Price::from("50000"));
711 let result = range.resolve(atm, &strikes);
712 assert_eq!(result.len(), 5); assert!(result.contains(&Price::from("45000")));
715 assert!(result.contains(&Price::from("48000")));
716 assert!(result.contains(&Price::from("50000")));
717 assert!(result.contains(&Price::from("52000")));
718 assert!(result.contains(&Price::from("55000")));
719 }
720
721 #[rstest]
722 fn test_strike_range_resolve_atm_percent_zero_exact_high_value() {
723 let range = StrikeRange::AtmPercent { pct: 0.0 };
724 let atm = Price::from("9007199253.999000000");
725 let collapsed = Price::from("9007199253.999000001");
726 let strikes = [atm, collapsed];
727 assert_eq!(atm.as_f64(), collapsed.as_f64());
728
729 let result = range.resolve(Some(atm), &strikes);
730
731 assert_eq!(result, vec![atm]);
732 }
733
734 #[rstest]
735 fn test_option_chain_slice_new_empty() {
736 let slice = OptionChainSlice::new(make_series_id());
737 assert!(slice.is_empty());
738 assert_eq!(slice.call_count(), 0);
739 assert_eq!(slice.put_count(), 0);
740 assert!(slice.atm_strike.is_none());
741 }
742
743 #[rstest]
744 fn test_strike_range_resolve_delta_falls_back_to_atm_relative() {
745 let strikes: Vec<Price> = (0..=20)
748 .map(|i| Price::from(&(40000 + i * 1000).to_string()))
749 .collect();
750 let atm = Some(Price::from("50000")); let delta = StrikeRange::Delta {
752 target: 0.25,
753 tolerance: 0.05,
754 };
755 let expected = StrikeRange::AtmRelative {
756 strikes_above: DEFAULT_DELTA_FALLBACK_STRIKES,
757 strikes_below: DEFAULT_DELTA_FALLBACK_STRIKES,
758 }
759 .resolve(atm, &strikes);
760
761 let result = delta.resolve(atm, &strikes);
762 assert_eq!(result, expected);
763 assert_eq!(result.len(), 2 * DEFAULT_DELTA_FALLBACK_STRIKES + 1);
764 assert!(result.contains(&Price::from("50000")));
765 assert!(!result.contains(&Price::from("40000")));
766 assert!(!result.contains(&Price::from("60000")));
767 }
768
769 #[rstest]
770 fn test_strike_range_resolve_delta_empty_without_atm() {
771 let delta = StrikeRange::Delta {
772 target: 0.25,
773 tolerance: 0.05,
774 };
775 let strikes = vec![Price::from("50000"), Price::from("55000")];
776 assert!(delta.resolve(None, &strikes).is_empty());
778 }
779}