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