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