1use 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#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
40pub enum StrikeRange {
41 Fixed(Vec<Price>),
43 AtmRelative {
45 strikes_above: usize,
46 strikes_below: usize,
47 },
48 AtmPercent { pct: f64 },
50}
51
52impl StrikeRange {
53 #[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![]; };
87 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 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![]; };
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#[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 pub instrument_id: InstrumentId,
147 pub convention: GreeksConvention,
149 pub greeks: OptionGreekValues,
151 pub mark_iv: Option<f64>,
153 pub bid_iv: Option<f64>,
155 pub ask_iv: Option<f64>,
157 pub underlying_price: Option<f64>,
159 pub open_interest: Option<f64>,
161 pub ts_event: UnixNanos,
163 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#[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 pub quote: QuoteTick,
232 pub greeks: Option<OptionGreeks>,
234}
235
236#[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 pub series_id: OptionSeriesId,
249 pub atm_strike: Option<Price>,
251 pub calls: BTreeMap<Price, OptionStrikeData>,
253 pub puts: BTreeMap<Price, OptionStrikeData>,
255 pub ts_event: UnixNanos,
257 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 #[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 #[must_use]
296 pub fn call_count(&self) -> usize {
297 self.calls.len()
298 }
299
300 #[must_use]
302 pub fn put_count(&self) -> usize {
303 self.puts.len()
304 }
305
306 #[must_use]
308 pub fn get_call(&self, strike: &Price) -> Option<&OptionStrikeData> {
309 self.calls.get(strike)
310 }
311
312 #[must_use]
314 pub fn get_put(&self, strike: &Price) -> Option<&OptionStrikeData> {
315 self.puts.get(strike)
316 }
317
318 #[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 #[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 #[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 #[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 #[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 #[must_use]
353 pub fn strike_count(&self) -> usize {
354 self.strikes().len()
355 }
356
357 #[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 #[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 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 assert!(result.is_empty());
617 }
618
619 #[rstest]
620 fn test_strike_range_resolve_atm_percent() {
621 let range = StrikeRange::AtmPercent { pct: 0.1 }; 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 assert_eq!(result.len(), 5); 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}