1use std::{
19 collections::{BTreeMap, HashSet},
20 fmt::Display,
21 ops::Deref,
22};
23
24use nautilus_core::UnixNanos;
25
26use super::HasTsInit;
27use crate::{
28 data::{
29 QuoteTick,
30 greeks::{HasGreeks, OptionGreekValues},
31 },
32 enums::GreeksConvention,
33 identifiers::{InstrumentId, OptionSeriesId},
34 types::Price,
35};
36
37#[derive(Clone, Debug, PartialEq)]
39pub enum StrikeRange {
40 Fixed(Vec<Price>),
42 AtmRelative {
44 strikes_above: usize,
45 strikes_below: usize,
46 },
47 AtmPercent { pct: f64 },
49}
50
51impl StrikeRange {
52 #[must_use]
65 pub fn resolve(&self, atm_price: Option<Price>, all_strikes: &[Price]) -> Vec<Price> {
66 match self {
67 Self::Fixed(strikes) => {
68 if all_strikes.is_empty() {
69 strikes.clone()
70 } else {
71 let available: HashSet<Price> = all_strikes.iter().copied().collect();
72 strikes
73 .iter()
74 .filter(|s| available.contains(s))
75 .copied()
76 .collect()
77 }
78 }
79 Self::AtmRelative {
80 strikes_above,
81 strikes_below,
82 } => {
83 let Some(atm) = atm_price else {
84 return vec![]; };
86 let atm_idx = match all_strikes
88 .binary_search_by(|s| s.as_f64().partial_cmp(&atm.as_f64()).unwrap())
89 {
90 Ok(idx) => idx,
91 Err(idx) => {
92 if idx == 0 {
93 0
94 } else if idx >= all_strikes.len() {
95 all_strikes.len() - 1
96 } else {
97 let diff_below = (all_strikes[idx - 1].as_f64() - atm.as_f64()).abs();
99 let diff_above = (all_strikes[idx].as_f64() - atm.as_f64()).abs();
100 if diff_below <= diff_above {
101 idx - 1
102 } else {
103 idx
104 }
105 }
106 }
107 };
108 let start = atm_idx.saturating_sub(*strikes_below);
109 let end = (atm_idx + strikes_above + 1).min(all_strikes.len());
110 all_strikes[start..end].to_vec()
111 }
112 Self::AtmPercent { pct } => {
113 let Some(atm) = atm_price else {
114 return vec![]; };
116 let atm_f = atm.as_f64();
117 if atm_f == 0.0 {
118 return all_strikes.to_vec();
119 }
120 all_strikes
121 .iter()
122 .filter(|s| {
123 let pct_diff = ((s.as_f64() - atm_f) / atm_f).abs();
124 pct_diff <= *pct
125 })
126 .copied()
127 .collect()
128 }
129 }
130 }
131}
132
133#[derive(Clone, Copy, Debug, PartialEq)]
135#[cfg_attr(
136 feature = "python",
137 pyo3::pyclass(module = "nautilus_trader.core.nautilus_pyo3.model", from_py_object)
138)]
139#[cfg_attr(
140 feature = "python",
141 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.model")
142)]
143pub struct OptionGreeks {
144 pub instrument_id: InstrumentId,
146 pub convention: GreeksConvention,
148 pub greeks: OptionGreekValues,
150 pub mark_iv: Option<f64>,
152 pub bid_iv: Option<f64>,
154 pub ask_iv: Option<f64>,
156 pub underlying_price: Option<f64>,
158 pub open_interest: Option<f64>,
160 pub ts_event: UnixNanos,
162 pub ts_init: UnixNanos,
164}
165
166impl HasTsInit for OptionGreeks {
167 fn ts_init(&self) -> UnixNanos {
168 self.ts_init
169 }
170}
171
172impl Deref for OptionGreeks {
173 type Target = OptionGreekValues;
174 fn deref(&self) -> &Self::Target {
175 &self.greeks
176 }
177}
178
179impl HasGreeks for OptionGreeks {
180 fn greeks(&self) -> OptionGreekValues {
181 self.greeks
182 }
183}
184
185impl Default for OptionGreeks {
186 fn default() -> Self {
187 Self {
188 instrument_id: InstrumentId::from("NULL.NULL"),
189 convention: GreeksConvention::default(),
190 greeks: OptionGreekValues::default(),
191 mark_iv: None,
192 bid_iv: None,
193 ask_iv: None,
194 underlying_price: None,
195 open_interest: None,
196 ts_event: UnixNanos::default(),
197 ts_init: UnixNanos::default(),
198 }
199 }
200}
201
202impl Display for OptionGreeks {
203 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
204 write!(
205 f,
206 "OptionGreeks({}, {}, delta={:.4}, gamma={:.4}, vega={:.4}, theta={:.4}, mark_iv={:?})",
207 self.instrument_id,
208 self.convention,
209 self.delta,
210 self.gamma,
211 self.vega,
212 self.theta,
213 self.mark_iv
214 )
215 }
216}
217
218#[derive(Clone, Debug)]
220#[cfg_attr(
221 feature = "python",
222 pyo3::pyclass(module = "nautilus_trader.core.nautilus_pyo3.model", from_py_object)
223)]
224#[cfg_attr(
225 feature = "python",
226 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.model")
227)]
228pub struct OptionStrikeData {
229 pub quote: QuoteTick,
231 pub greeks: Option<OptionGreeks>,
233}
234
235#[derive(Clone, Debug)]
237#[cfg_attr(
238 feature = "python",
239 pyo3::pyclass(module = "nautilus_trader.core.nautilus_pyo3.model", from_py_object)
240)]
241#[cfg_attr(
242 feature = "python",
243 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.model")
244)]
245pub struct OptionChainSlice {
246 pub series_id: OptionSeriesId,
248 pub atm_strike: Option<Price>,
250 pub calls: BTreeMap<Price, OptionStrikeData>,
252 pub puts: BTreeMap<Price, OptionStrikeData>,
254 pub ts_event: UnixNanos,
256 pub ts_init: UnixNanos,
258}
259
260impl HasTsInit for OptionChainSlice {
261 fn ts_init(&self) -> UnixNanos {
262 self.ts_init
263 }
264}
265
266impl Display for OptionChainSlice {
267 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
268 write!(
269 f,
270 "OptionChainSlice({}, atm={:?}, calls={}, puts={})",
271 self.series_id,
272 self.atm_strike,
273 self.calls.len(),
274 self.puts.len()
275 )
276 }
277}
278
279impl OptionChainSlice {
280 #[must_use]
282 pub fn new(series_id: OptionSeriesId) -> Self {
283 Self {
284 series_id,
285 atm_strike: None,
286 calls: BTreeMap::new(),
287 puts: BTreeMap::new(),
288 ts_event: UnixNanos::default(),
289 ts_init: UnixNanos::default(),
290 }
291 }
292
293 #[must_use]
295 pub fn call_count(&self) -> usize {
296 self.calls.len()
297 }
298
299 #[must_use]
301 pub fn put_count(&self) -> usize {
302 self.puts.len()
303 }
304
305 #[must_use]
307 pub fn get_call(&self, strike: &Price) -> Option<&OptionStrikeData> {
308 self.calls.get(strike)
309 }
310
311 #[must_use]
313 pub fn get_put(&self, strike: &Price) -> Option<&OptionStrikeData> {
314 self.puts.get(strike)
315 }
316
317 #[must_use]
319 pub fn get_call_quote(&self, strike: &Price) -> Option<&QuoteTick> {
320 self.calls.get(strike).map(|d| &d.quote)
321 }
322
323 #[must_use]
325 pub fn get_call_greeks(&self, strike: &Price) -> Option<&OptionGreeks> {
326 self.calls.get(strike).and_then(|d| d.greeks.as_ref())
327 }
328
329 #[must_use]
331 pub fn get_put_quote(&self, strike: &Price) -> Option<&QuoteTick> {
332 self.puts.get(strike).map(|d| &d.quote)
333 }
334
335 #[must_use]
337 pub fn get_put_greeks(&self, strike: &Price) -> Option<&OptionGreeks> {
338 self.puts.get(strike).and_then(|d| d.greeks.as_ref())
339 }
340
341 #[must_use]
343 pub fn strikes(&self) -> Vec<Price> {
344 let mut strikes: Vec<Price> = self.calls.keys().chain(self.puts.keys()).copied().collect();
345 strikes.sort();
346 strikes.dedup();
347 strikes
348 }
349
350 #[must_use]
352 pub fn strike_count(&self) -> usize {
353 self.strikes().len()
354 }
355
356 #[must_use]
358 pub fn is_empty(&self) -> bool {
359 self.calls.is_empty() && self.puts.is_empty()
360 }
361}
362
363#[cfg(test)]
364mod tests {
365 use rstest::*;
366
367 use super::*;
368 use crate::{identifiers::Venue, types::Quantity};
369
370 fn make_quote(instrument_id: InstrumentId) -> QuoteTick {
371 QuoteTick::new(
372 instrument_id,
373 Price::from("100.00"),
374 Price::from("101.00"),
375 Quantity::from("1.0"),
376 Quantity::from("1.0"),
377 UnixNanos::from(1u64),
378 UnixNanos::from(1u64),
379 )
380 }
381
382 fn make_series_id() -> OptionSeriesId {
383 OptionSeriesId::new(
384 Venue::new("DERIBIT"),
385 ustr::Ustr::from("BTC"),
386 ustr::Ustr::from("BTC"),
387 UnixNanos::from(1_700_000_000_000_000_000u64),
388 )
389 }
390
391 #[rstest]
392 fn test_strike_range_fixed() {
393 let range = StrikeRange::Fixed(vec![Price::from("50000"), Price::from("55000")]);
394 assert_eq!(
395 range,
396 StrikeRange::Fixed(vec![Price::from("50000"), Price::from("55000")])
397 );
398 }
399
400 #[rstest]
401 fn test_strike_range_atm_relative() {
402 let range = StrikeRange::AtmRelative {
403 strikes_above: 5,
404 strikes_below: 5,
405 };
406
407 if let StrikeRange::AtmRelative {
408 strikes_above,
409 strikes_below,
410 } = range
411 {
412 assert_eq!(strikes_above, 5);
413 assert_eq!(strikes_below, 5);
414 } else {
415 panic!("Expected AtmRelative variant");
416 }
417 }
418
419 #[rstest]
420 fn test_strike_range_atm_percent() {
421 let range = StrikeRange::AtmPercent { pct: 0.1 };
422 if let StrikeRange::AtmPercent { pct } = range {
423 assert!((pct - 0.1).abs() < f64::EPSILON);
424 } else {
425 panic!("Expected AtmPercent variant");
426 }
427 }
428
429 #[rstest]
430 fn test_option_greeks_default_fields() {
431 let greeks = OptionGreeks {
432 instrument_id: InstrumentId::from("BTC-20240101-50000-C.DERIBIT"),
433 convention: GreeksConvention::BlackScholes,
434 greeks: OptionGreekValues::default(),
435 mark_iv: None,
436 bid_iv: None,
437 ask_iv: None,
438 underlying_price: None,
439 open_interest: None,
440 ts_event: UnixNanos::default(),
441 ts_init: UnixNanos::default(),
442 };
443 assert_eq!(greeks.delta, 0.0);
444 assert_eq!(greeks.gamma, 0.0);
445 assert_eq!(greeks.vega, 0.0);
446 assert_eq!(greeks.theta, 0.0);
447 assert!(greeks.mark_iv.is_none());
448 assert_eq!(greeks.convention, GreeksConvention::BlackScholes);
449 }
450
451 #[rstest]
452 fn test_option_greeks_default_is_black_scholes() {
453 let greeks = OptionGreeks::default();
454 assert_eq!(greeks.convention, GreeksConvention::BlackScholes);
455 }
456
457 #[rstest]
458 fn test_option_greeks_display() {
459 let greeks = OptionGreeks {
460 instrument_id: InstrumentId::from("BTC-20240101-50000-C.DERIBIT"),
461 convention: GreeksConvention::PriceAdjusted,
462 greeks: OptionGreekValues {
463 delta: 0.55,
464 gamma: 0.001,
465 vega: 10.0,
466 theta: -5.0,
467 rho: 0.0,
468 },
469 mark_iv: Some(0.65),
470 bid_iv: None,
471 ask_iv: None,
472 underlying_price: None,
473 open_interest: None,
474 ts_event: UnixNanos::default(),
475 ts_init: UnixNanos::default(),
476 };
477 let display = format!("{greeks}");
478 assert!(display.contains("OptionGreeks"));
479 assert!(display.contains("PRICE_ADJUSTED"));
480 assert!(display.contains("0.55"));
481 }
482
483 #[rstest]
484 fn test_option_chain_slice_empty() {
485 let slice = OptionChainSlice {
486 series_id: make_series_id(),
487 atm_strike: None,
488 calls: BTreeMap::new(),
489 puts: BTreeMap::new(),
490 ts_event: UnixNanos::from(1u64),
491 ts_init: UnixNanos::from(1u64),
492 };
493
494 assert!(slice.is_empty());
495 assert_eq!(slice.strike_count(), 0);
496 assert!(slice.strikes().is_empty());
497 }
498
499 #[rstest]
500 fn test_option_chain_slice_with_data() {
501 let call_id = InstrumentId::from("BTC-20240101-50000-C.DERIBIT");
502 let put_id = InstrumentId::from("BTC-20240101-50000-P.DERIBIT");
503 let strike = Price::from("50000");
504
505 let mut calls = BTreeMap::new();
506 calls.insert(
507 strike,
508 OptionStrikeData {
509 quote: make_quote(call_id),
510 greeks: Some(OptionGreeks {
511 instrument_id: call_id,
512 greeks: OptionGreekValues {
513 delta: 0.55,
514 ..Default::default()
515 },
516 ..Default::default()
517 }),
518 },
519 );
520
521 let mut puts = BTreeMap::new();
522 puts.insert(
523 strike,
524 OptionStrikeData {
525 quote: make_quote(put_id),
526 greeks: None,
527 },
528 );
529
530 let slice = OptionChainSlice {
531 series_id: make_series_id(),
532 atm_strike: Some(strike),
533 calls,
534 puts,
535 ts_event: UnixNanos::from(1u64),
536 ts_init: UnixNanos::from(1u64),
537 };
538
539 assert!(!slice.is_empty());
540 assert_eq!(slice.strike_count(), 1);
541 assert_eq!(slice.strikes(), vec![strike]);
542 assert!(slice.get_call(&strike).is_some());
543 assert!(slice.get_put(&strike).is_some());
544 assert!(slice.get_call_greeks(&strike).is_some());
545 assert!(slice.get_put_greeks(&strike).is_none());
546 assert_eq!(slice.get_call_greeks(&strike).unwrap().delta, 0.55);
547 }
548
549 #[rstest]
550 fn test_option_chain_slice_display() {
551 let slice = OptionChainSlice {
552 series_id: make_series_id(),
553 atm_strike: None,
554 calls: BTreeMap::new(),
555 puts: BTreeMap::new(),
556 ts_event: UnixNanos::from(1u64),
557 ts_init: UnixNanos::from(1u64),
558 };
559
560 let display = format!("{slice}");
561 assert!(display.contains("OptionChainSlice"));
562 assert!(display.contains("DERIBIT"));
563 }
564
565 #[rstest]
566 fn test_option_chain_slice_ts_init() {
567 let slice = OptionChainSlice {
568 series_id: make_series_id(),
569 atm_strike: None,
570 calls: BTreeMap::new(),
571 puts: BTreeMap::new(),
572 ts_event: UnixNanos::from(1u64),
573 ts_init: UnixNanos::from(42u64),
574 };
575
576 assert_eq!(slice.ts_init(), UnixNanos::from(42u64));
577 }
578
579 #[rstest]
582 fn test_strike_range_resolve_fixed() {
583 let range = StrikeRange::Fixed(vec![Price::from("50000"), Price::from("55000")]);
584 let result = range.resolve(None, &[]);
585 assert_eq!(result, vec![Price::from("50000"), Price::from("55000")]);
586 }
587
588 #[rstest]
589 fn test_strike_range_resolve_atm_relative() {
590 let range = StrikeRange::AtmRelative {
591 strikes_above: 2,
592 strikes_below: 2,
593 };
594 let strikes: Vec<Price> = [45000, 47000, 50000, 53000, 55000, 57000]
595 .iter()
596 .map(|s| Price::from(&s.to_string()))
597 .collect();
598 let atm = Some(Price::from("50000"));
599 let result = range.resolve(atm, &strikes);
600 assert_eq!(result.len(), 5);
602 assert_eq!(result[0], Price::from("45000"));
603 assert_eq!(result[4], Price::from("55000"));
604 }
605
606 #[rstest]
607 fn test_strike_range_resolve_atm_relative_no_atm() {
608 let range = StrikeRange::AtmRelative {
609 strikes_above: 2,
610 strikes_below: 2,
611 };
612 let strikes = vec![Price::from("50000"), Price::from("55000")];
613 let result = range.resolve(None, &strikes);
614 assert!(result.is_empty());
616 }
617
618 #[rstest]
619 fn test_strike_range_resolve_atm_percent() {
620 let range = StrikeRange::AtmPercent { pct: 0.1 }; let strikes: Vec<Price> = [45000, 48000, 50000, 52000, 55000, 60000]
622 .iter()
623 .map(|s| Price::from(&s.to_string()))
624 .collect();
625 let atm = Some(Price::from("50000"));
626 let result = range.resolve(atm, &strikes);
627 assert_eq!(result.len(), 5); assert!(result.contains(&Price::from("45000")));
630 assert!(result.contains(&Price::from("48000")));
631 assert!(result.contains(&Price::from("50000")));
632 assert!(result.contains(&Price::from("52000")));
633 assert!(result.contains(&Price::from("55000")));
634 }
635
636 #[rstest]
637 fn test_option_chain_slice_new_empty() {
638 let slice = OptionChainSlice::new(make_series_id());
639 assert!(slice.is_empty());
640 assert_eq!(slice.call_count(), 0);
641 assert_eq!(slice.put_count(), 0);
642 assert!(slice.atm_strike.is_none());
643 }
644}