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