1use std::{
19 collections::{BTreeMap, HashSet},
20 fmt::Display,
21 ops::Deref,
22};
23
24use nautilus_core::{UnixNanos, serialization::Serializable};
25use rust_decimal::{Decimal, prelude::ToPrimitive};
26use serde::{Deserialize, Serialize};
27
28use super::HasTsInit;
29use crate::{
30 data::{
31 QuoteTick,
32 greeks::{HasGreeks, OptionGreekValues},
33 },
34 enums::GreeksConvention,
35 identifiers::{InstrumentId, OptionSeriesId},
36 types::Price,
37};
38
39pub(crate) const DEFAULT_DELTA_FALLBACK_STRIKES: usize = 5;
42
43#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
47pub struct StrikeSearchProfile {
48 increments: Vec<Decimal>,
49 origin: Decimal,
50 max_candidates_per_slot: usize,
51}
52
53impl StrikeSearchProfile {
54 pub fn new(
63 increments: Vec<Decimal>,
64 origin: Decimal,
65 max_candidates_per_slot: usize,
66 ) -> anyhow::Result<Self> {
67 anyhow::ensure!(
68 !increments.is_empty(),
69 "strike search profile requires at least one increment"
70 );
71 anyhow::ensure!(
72 increments.iter().all(|value| *value > Decimal::ZERO),
73 "strike search profile increments must be positive"
74 );
75 let mut seen = HashSet::with_capacity(increments.len());
76 for increment in &increments {
77 anyhow::ensure!(
78 seen.insert(increment.normalize()),
79 "strike search profile increments must be unique (duplicate {increment})"
80 );
81 }
82
83 anyhow::ensure!(
84 origin >= Decimal::ZERO,
85 "strike search profile origin must be non-negative"
86 );
87 anyhow::ensure!(
88 max_candidates_per_slot > 0,
89 "strike search profile requires a positive candidate bound"
90 );
91 Ok(Self {
92 increments,
93 origin,
94 max_candidates_per_slot,
95 })
96 }
97
98 #[must_use]
100 pub fn increments(&self) -> &[Decimal] {
101 &self.increments
102 }
103
104 #[must_use]
106 pub const fn origin(&self) -> Decimal {
107 self.origin
108 }
109
110 #[must_use]
112 pub const fn max_candidates_per_slot(&self) -> usize {
113 self.max_candidates_per_slot
114 }
115}
116
117impl<'de> Deserialize<'de> for StrikeSearchProfile {
118 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
119 where
120 D: serde::Deserializer<'de>,
121 {
122 #[derive(Deserialize)]
123 struct Fields {
124 increments: Vec<Decimal>,
125 origin: Decimal,
126 max_candidates_per_slot: usize,
127 }
128 let fields = Fields::deserialize(deserializer)?;
129 Self::new(
130 fields.increments,
131 fields.origin,
132 fields.max_candidates_per_slot,
133 )
134 .map_err(serde::de::Error::custom)
135 }
136}
137
138#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
140pub enum StrikeRange {
141 Fixed(Vec<Price>),
143 AtmRelative {
145 strikes_above: usize,
146 strikes_below: usize,
147 },
148 AtmPercent { pct: f64 },
150 Delta { target: f64, tolerance: f64 },
157}
158
159impl StrikeRange {
160 #[must_use]
172 pub fn resolve(&self, atm_price: Option<Price>, all_strikes: &[Price]) -> Vec<Price> {
173 match self {
174 Self::Fixed(strikes) => {
175 if all_strikes.is_empty() {
176 strikes.clone()
177 } else {
178 let available: HashSet<Price> = all_strikes.iter().copied().collect();
179 strikes
180 .iter()
181 .filter(|s| available.contains(s))
182 .copied()
183 .collect()
184 }
185 }
186 Self::AtmRelative {
187 strikes_above,
188 strikes_below,
189 } => {
190 let Some(atm) = atm_price else {
191 return vec![]; };
193 let atm_idx = match all_strikes.binary_search(&atm) {
195 Ok(idx) => idx,
196 Err(idx) => {
197 if idx == 0 {
198 0
199 } else if idx >= all_strikes.len() {
200 all_strikes.len() - 1
201 } else {
202 let diff_below = all_strikes[idx - 1].raw().abs_diff(atm.raw());
204 let diff_above = all_strikes[idx].raw().abs_diff(atm.raw());
205 if diff_below <= diff_above {
206 idx - 1
207 } else {
208 idx
209 }
210 }
211 }
212 };
213 let start = atm_idx.saturating_sub(*strikes_below);
214 let end = atm_idx
215 .saturating_add(*strikes_above)
216 .saturating_add(1)
217 .min(all_strikes.len());
218 all_strikes[start..end].to_vec()
219 }
220 Self::AtmPercent { pct } => {
221 let Some(atm) = atm_price else {
222 return vec![]; };
224 let atm_decimal = atm.as_decimal();
225 if atm_decimal.is_zero() {
226 return all_strikes.to_vec();
227 }
228 all_strikes
229 .iter()
230 .filter(|s| {
231 let distance = (s.as_decimal() - atm_decimal).abs();
232 let pct_diff = distance / atm_decimal.abs();
233 pct_diff.to_f64().is_some_and(|pct_diff| pct_diff <= *pct)
234 })
235 .copied()
236 .collect()
237 }
238 Self::Delta { .. } => Self::AtmRelative {
239 strikes_above: DEFAULT_DELTA_FALLBACK_STRIKES,
240 strikes_below: DEFAULT_DELTA_FALLBACK_STRIKES,
241 }
242 .resolve(atm_price, all_strikes),
243 }
244 }
245}
246
247#[repr(C)]
249#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
250#[serde(tag = "type")]
251#[cfg_attr(
252 feature = "python",
253 pyo3::pyclass(module = "nautilus_trader.model", from_py_object)
254)]
255#[cfg_attr(
256 feature = "python",
257 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.model")
258)]
259pub struct OptionGreeks {
260 pub instrument_id: InstrumentId,
262 pub convention: GreeksConvention,
264 pub greeks: OptionGreekValues,
266 pub mark_iv: Option<f64>,
268 pub bid_iv: Option<f64>,
270 pub ask_iv: Option<f64>,
272 pub underlying_price: Option<f64>,
274 pub open_interest: Option<f64>,
276 pub ts_event: UnixNanos,
278 pub ts_init: UnixNanos,
280}
281
282impl HasTsInit for OptionGreeks {
283 fn ts_init(&self) -> UnixNanos {
284 self.ts_init
285 }
286}
287
288impl Deref for OptionGreeks {
289 type Target = OptionGreekValues;
290 fn deref(&self) -> &Self::Target {
291 &self.greeks
292 }
293}
294
295impl HasGreeks for OptionGreeks {
296 fn greeks(&self) -> OptionGreekValues {
297 self.greeks
298 }
299}
300
301impl Default for OptionGreeks {
302 fn default() -> Self {
303 Self {
304 instrument_id: InstrumentId::from("NULL.NULL"),
305 convention: GreeksConvention::default(),
306 greeks: OptionGreekValues::default(),
307 mark_iv: None,
308 bid_iv: None,
309 ask_iv: None,
310 underlying_price: None,
311 open_interest: None,
312 ts_event: UnixNanos::default(),
313 ts_init: UnixNanos::default(),
314 }
315 }
316}
317
318impl Display for OptionGreeks {
319 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
320 write!(
321 f,
322 "OptionGreeks({}, {}, delta={:.4}, gamma={:.4}, vega={:.4}, theta={:.4}, mark_iv={:?})",
323 self.instrument_id,
324 self.convention,
325 self.delta,
326 self.gamma,
327 self.vega,
328 self.theta,
329 self.mark_iv
330 )
331 }
332}
333
334impl Serializable for OptionGreeks {}
335
336#[derive(Clone, Debug)]
338#[cfg_attr(
339 feature = "python",
340 pyo3::pyclass(module = "nautilus_trader.model", from_py_object)
341)]
342#[cfg_attr(
343 feature = "python",
344 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.model")
345)]
346pub struct OptionStrikeData {
347 pub quote: QuoteTick,
349 pub greeks: Option<OptionGreeks>,
351}
352
353#[derive(Clone, Debug)]
355#[cfg_attr(
356 feature = "python",
357 pyo3::pyclass(module = "nautilus_trader.model", from_py_object)
358)]
359#[cfg_attr(
360 feature = "python",
361 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.model")
362)]
363pub struct OptionChainSlice {
364 pub series_id: OptionSeriesId,
366 pub atm_strike: Option<Price>,
368 pub calls: BTreeMap<Price, OptionStrikeData>,
370 pub puts: BTreeMap<Price, OptionStrikeData>,
372 pub ts_event: UnixNanos,
374 pub ts_init: UnixNanos,
376}
377
378impl HasTsInit for OptionChainSlice {
379 fn ts_init(&self) -> UnixNanos {
380 self.ts_init
381 }
382}
383
384impl Display for OptionChainSlice {
385 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
386 write!(
387 f,
388 "OptionChainSlice({}, atm={:?}, calls={}, puts={})",
389 self.series_id,
390 self.atm_strike,
391 self.calls.len(),
392 self.puts.len()
393 )
394 }
395}
396
397impl OptionChainSlice {
398 #[must_use]
400 pub fn new(series_id: OptionSeriesId) -> Self {
401 Self {
402 series_id,
403 atm_strike: None,
404 calls: BTreeMap::new(),
405 puts: BTreeMap::new(),
406 ts_event: UnixNanos::default(),
407 ts_init: UnixNanos::default(),
408 }
409 }
410
411 #[must_use]
413 pub fn call_count(&self) -> usize {
414 self.calls.len()
415 }
416
417 #[must_use]
419 pub fn put_count(&self) -> usize {
420 self.puts.len()
421 }
422
423 #[must_use]
425 pub fn get_call(&self, strike: &Price) -> Option<&OptionStrikeData> {
426 self.calls.get(strike)
427 }
428
429 #[must_use]
431 pub fn get_put(&self, strike: &Price) -> Option<&OptionStrikeData> {
432 self.puts.get(strike)
433 }
434
435 #[must_use]
437 pub fn get_call_quote(&self, strike: &Price) -> Option<&QuoteTick> {
438 self.calls.get(strike).map(|d| &d.quote)
439 }
440
441 #[must_use]
443 pub fn get_call_greeks(&self, strike: &Price) -> Option<&OptionGreeks> {
444 self.calls.get(strike).and_then(|d| d.greeks.as_ref())
445 }
446
447 #[must_use]
449 pub fn get_put_quote(&self, strike: &Price) -> Option<&QuoteTick> {
450 self.puts.get(strike).map(|d| &d.quote)
451 }
452
453 #[must_use]
455 pub fn get_put_greeks(&self, strike: &Price) -> Option<&OptionGreeks> {
456 self.puts.get(strike).and_then(|d| d.greeks.as_ref())
457 }
458
459 #[must_use]
461 pub fn strikes(&self) -> Vec<Price> {
462 let mut strikes: Vec<Price> = self.calls.keys().chain(self.puts.keys()).copied().collect();
463 strikes.sort();
464 strikes.dedup();
465 strikes
466 }
467
468 #[must_use]
470 pub fn strike_count(&self) -> usize {
471 self.strikes().len()
472 }
473
474 #[must_use]
476 pub fn is_empty(&self) -> bool {
477 self.calls.is_empty() && self.puts.is_empty()
478 }
479}
480
481#[cfg(test)]
482mod tests {
483 use rstest::*;
484
485 use super::*;
486 use crate::{identifiers::Venue, types::Quantity};
487
488 fn make_quote(instrument_id: InstrumentId) -> QuoteTick {
489 QuoteTick::new(
490 instrument_id,
491 Price::from("100.00"),
492 Price::from("101.00"),
493 Quantity::from("1.0"),
494 Quantity::from("1.0"),
495 UnixNanos::from(1u64),
496 UnixNanos::from(1u64),
497 )
498 }
499
500 fn make_series_id() -> OptionSeriesId {
501 OptionSeriesId::new_derived(
502 Venue::new("DERIBIT"),
503 ustr::Ustr::from("BTC"),
504 ustr::Ustr::from("BTC"),
505 UnixNanos::from(1_700_000_000_000_000_000u64),
506 )
507 }
508
509 #[rstest]
510 fn test_strike_range_fixed() {
511 let range = StrikeRange::Fixed(vec![Price::from("50000"), Price::from("55000")]);
512 assert_eq!(
513 range,
514 StrikeRange::Fixed(vec![Price::from("50000"), Price::from("55000")])
515 );
516 }
517
518 #[rstest]
519 fn test_strike_range_atm_relative() {
520 let range = StrikeRange::AtmRelative {
521 strikes_above: 5,
522 strikes_below: 5,
523 };
524
525 if let StrikeRange::AtmRelative {
526 strikes_above,
527 strikes_below,
528 } = range
529 {
530 assert_eq!(strikes_above, 5);
531 assert_eq!(strikes_below, 5);
532 } else {
533 panic!("Expected AtmRelative variant");
534 }
535 }
536
537 #[rstest]
538 fn test_strike_range_atm_percent() {
539 let range = StrikeRange::AtmPercent { pct: 0.1 };
540 if let StrikeRange::AtmPercent { pct } = range {
541 assert!((pct - 0.1).abs() < f64::EPSILON);
542 } else {
543 panic!("Expected AtmPercent variant");
544 }
545 }
546
547 #[rstest]
548 fn test_option_greeks_default_fields() {
549 let greeks = OptionGreeks {
550 instrument_id: InstrumentId::from("BTC-20240101-50000-C.DERIBIT"),
551 convention: GreeksConvention::BlackScholes,
552 greeks: OptionGreekValues::default(),
553 mark_iv: None,
554 bid_iv: None,
555 ask_iv: None,
556 underlying_price: None,
557 open_interest: None,
558 ts_event: UnixNanos::default(),
559 ts_init: UnixNanos::default(),
560 };
561 assert_eq!(greeks.delta, 0.0);
562 assert_eq!(greeks.gamma, 0.0);
563 assert_eq!(greeks.vega, 0.0);
564 assert_eq!(greeks.theta, 0.0);
565 assert!(greeks.mark_iv.is_none());
566 assert_eq!(greeks.convention, GreeksConvention::BlackScholes);
567 }
568
569 #[rstest]
570 fn test_option_greeks_default_is_black_scholes() {
571 let greeks = OptionGreeks::default();
572 assert_eq!(greeks.convention, GreeksConvention::BlackScholes);
573 }
574
575 #[rstest]
576 fn test_option_greeks_display() {
577 let greeks = OptionGreeks {
578 instrument_id: InstrumentId::from("BTC-20240101-50000-C.DERIBIT"),
579 convention: GreeksConvention::PriceAdjusted,
580 greeks: OptionGreekValues {
581 delta: 0.55,
582 gamma: 0.001,
583 vega: 10.0,
584 theta: -5.0,
585 rho: 0.0,
586 },
587 mark_iv: Some(0.65),
588 bid_iv: None,
589 ask_iv: None,
590 underlying_price: None,
591 open_interest: None,
592 ts_event: UnixNanos::default(),
593 ts_init: UnixNanos::default(),
594 };
595 let display = format!("{greeks}");
596 assert!(display.contains("OptionGreeks"));
597 assert!(display.contains("PRICE_ADJUSTED"));
598 assert!(display.contains("0.55"));
599 }
600
601 #[rstest]
602 fn test_option_greeks_data_serde_round_trip() {
603 let greeks = OptionGreeks {
604 instrument_id: InstrumentId::from("BTC-20240101-50000-C.DERIBIT"),
605 convention: GreeksConvention::PriceAdjusted,
606 greeks: OptionGreekValues {
607 delta: 0.55,
608 gamma: 0.001,
609 vega: 10.0,
610 theta: -5.0,
611 rho: 0.2,
612 },
613 mark_iv: Some(0.65),
614 bid_iv: None,
615 ask_iv: Some(0.66),
616 underlying_price: Some(50_000.0),
617 open_interest: None,
618 ts_event: UnixNanos::from(1u64),
619 ts_init: UnixNanos::from(2u64),
620 };
621 let data = crate::data::Data::OptionGreeks(greeks);
622
623 let json = serde_json::to_string(&data).unwrap();
624 let roundtripped: crate::data::Data = serde_json::from_str(&json).unwrap();
625
626 assert_eq!(roundtripped, data);
627 }
628
629 #[rstest]
630 fn test_option_chain_slice_empty() {
631 let slice = OptionChainSlice {
632 series_id: make_series_id(),
633 atm_strike: None,
634 calls: BTreeMap::new(),
635 puts: BTreeMap::new(),
636 ts_event: UnixNanos::from(1u64),
637 ts_init: UnixNanos::from(1u64),
638 };
639
640 assert!(slice.is_empty());
641 assert_eq!(slice.strike_count(), 0);
642 assert!(slice.strikes().is_empty());
643 }
644
645 #[rstest]
646 fn test_option_chain_slice_with_data() {
647 let call_id = InstrumentId::from("BTC-20240101-50000-C.DERIBIT");
648 let put_id = InstrumentId::from("BTC-20240101-50000-P.DERIBIT");
649 let strike = Price::from("50000");
650
651 let mut calls = BTreeMap::new();
652 calls.insert(
653 strike,
654 OptionStrikeData {
655 quote: make_quote(call_id),
656 greeks: Some(OptionGreeks {
657 instrument_id: call_id,
658 greeks: OptionGreekValues {
659 delta: 0.55,
660 ..Default::default()
661 },
662 ..Default::default()
663 }),
664 },
665 );
666
667 let mut puts = BTreeMap::new();
668 puts.insert(
669 strike,
670 OptionStrikeData {
671 quote: make_quote(put_id),
672 greeks: None,
673 },
674 );
675
676 let slice = OptionChainSlice {
677 series_id: make_series_id(),
678 atm_strike: Some(strike),
679 calls,
680 puts,
681 ts_event: UnixNanos::from(1u64),
682 ts_init: UnixNanos::from(1u64),
683 };
684
685 assert!(!slice.is_empty());
686 assert_eq!(slice.strike_count(), 1);
687 assert_eq!(slice.strikes(), vec![strike]);
688 assert!(slice.get_call(&strike).is_some());
689 assert!(slice.get_put(&strike).is_some());
690 assert!(slice.get_call_greeks(&strike).is_some());
691 assert!(slice.get_put_greeks(&strike).is_none());
692 assert_eq!(slice.get_call_greeks(&strike).unwrap().delta, 0.55);
693 }
694
695 #[rstest]
696 fn test_option_chain_slice_display() {
697 let slice = OptionChainSlice {
698 series_id: make_series_id(),
699 atm_strike: None,
700 calls: BTreeMap::new(),
701 puts: BTreeMap::new(),
702 ts_event: UnixNanos::from(1u64),
703 ts_init: UnixNanos::from(1u64),
704 };
705
706 let display = format!("{slice}");
707 assert!(display.contains("OptionChainSlice"));
708 assert!(display.contains("DERIBIT"));
709 }
710
711 #[rstest]
712 fn test_option_chain_slice_ts_init() {
713 let slice = OptionChainSlice {
714 series_id: make_series_id(),
715 atm_strike: None,
716 calls: BTreeMap::new(),
717 puts: BTreeMap::new(),
718 ts_event: UnixNanos::from(1u64),
719 ts_init: UnixNanos::from(42u64),
720 };
721
722 assert_eq!(slice.ts_init(), UnixNanos::from(42u64));
723 }
724
725 #[rstest]
728 fn test_strike_range_preserves_subprecision_atm() {
729 let mut atm = Price::from("100.75");
730 atm.precision = 0;
731 let strikes = [Price::from("100"), Price::from("101")];
732
733 let range = StrikeRange::AtmRelative {
734 strikes_above: 0,
735 strikes_below: 0,
736 };
737
738 assert_eq!(range.resolve(Some(atm), &strikes), vec![strikes[1]]);
739 }
740
741 #[rstest]
742 fn test_strike_range_resolve_fixed() {
743 let range = StrikeRange::Fixed(vec![Price::from("50000"), Price::from("55000")]);
744 let result = range.resolve(None, &[]);
745 assert_eq!(result, vec![Price::from("50000"), Price::from("55000")]);
746 }
747
748 #[rstest]
749 fn test_strike_range_resolve_fixed_intersects_available_strikes() {
750 let range = StrikeRange::Fixed(vec![
751 Price::from("50000"),
752 Price::from("55000"),
753 Price::from("60000"),
754 ]);
755 let available = [
756 Price::from("45000"),
757 Price::from("50000"),
758 Price::from("60000"),
759 ];
760
761 let result = range.resolve(None, &available);
762
763 assert_eq!(result, vec![Price::from("50000"), Price::from("60000")]);
764 }
765
766 #[rstest]
767 fn test_strike_range_resolve_atm_relative() {
768 let range = StrikeRange::AtmRelative {
769 strikes_above: 2,
770 strikes_below: 2,
771 };
772 let strikes: Vec<Price> = [45000, 47000, 50000, 53000, 55000, 57000]
773 .iter()
774 .map(|s| Price::from(&s.to_string()))
775 .collect();
776 let atm = Some(Price::from("50000"));
777 let result = range.resolve(atm, &strikes);
778 assert_eq!(result.len(), 5);
780 assert_eq!(result[0], Price::from("45000"));
781 assert_eq!(result[4], Price::from("55000"));
782 }
783
784 #[rstest]
785 #[case("49750", "50000")]
786 #[case("50250", "50000")]
787 #[case("51750", "52000")]
788 fn test_strike_range_resolve_atm_relative_selects_nearest_strike(
789 #[case] atm: &str,
790 #[case] expected: &str,
791 ) {
792 let range = StrikeRange::AtmRelative {
793 strikes_above: 0,
794 strikes_below: 0,
795 };
796 let strikes = [
797 Price::from("48000"),
798 Price::from("50000"),
799 Price::from("52000"),
800 ];
801
802 let result = range.resolve(Some(Price::from(atm)), &strikes);
803
804 assert_eq!(result, vec![Price::from(expected)]);
805 }
806
807 #[rstest]
808 fn test_strike_range_resolve_atm_relative_exact_high_value() {
809 let range = StrikeRange::AtmRelative {
810 strikes_above: 0,
811 strikes_below: 0,
812 };
813 let atm = Price::from("9007199253.999000000");
814 let collapsed = Price::from("9007199253.999000001");
815 let strikes = [atm, collapsed];
816 assert_eq!(collapsed.as_f64(), atm.as_f64());
817
818 let result = range.resolve(Some(atm), &strikes);
819
820 assert_eq!(result, vec![atm]);
821 }
822
823 #[rstest]
824 fn test_strike_range_resolve_atm_relative_saturates_extreme_window() {
825 let range = StrikeRange::AtmRelative {
827 strikes_above: usize::MAX,
828 strikes_below: usize::MAX,
829 };
830 let strikes: Vec<Price> = [45000, 50000, 55000]
831 .iter()
832 .map(|s| Price::from(&s.to_string()))
833 .collect();
834 let atm = Some(Price::from("50000"));
835
836 let result = range.resolve(atm, &strikes);
837
838 assert_eq!(result, strikes);
839 }
840
841 #[rstest]
842 fn test_strike_range_resolve_atm_relative_no_atm() {
843 let range = StrikeRange::AtmRelative {
844 strikes_above: 2,
845 strikes_below: 2,
846 };
847 let strikes = vec![Price::from("50000"), Price::from("55000")];
848 let result = range.resolve(None, &strikes);
849 assert!(result.is_empty());
851 }
852
853 #[rstest]
854 fn test_strike_range_resolve_atm_percent() {
855 let range = StrikeRange::AtmPercent { pct: 0.1 }; let strikes: Vec<Price> = [45000, 48000, 50000, 52000, 55000, 60000]
857 .iter()
858 .map(|s| Price::from(&s.to_string()))
859 .collect();
860 let atm = Some(Price::from("50000"));
861 let result = range.resolve(atm, &strikes);
862 assert_eq!(result.len(), 5); assert!(result.contains(&Price::from("45000")));
865 assert!(result.contains(&Price::from("48000")));
866 assert!(result.contains(&Price::from("50000")));
867 assert!(result.contains(&Price::from("52000")));
868 assert!(result.contains(&Price::from("55000")));
869 }
870
871 #[rstest]
872 fn test_strike_range_resolve_atm_percent_zero_exact_high_value() {
873 let range = StrikeRange::AtmPercent { pct: 0.0 };
874 let atm = Price::from("9007199253.999000000");
875 let collapsed = Price::from("9007199253.999000001");
876 let strikes = [atm, collapsed];
877 assert_eq!(atm.as_f64(), collapsed.as_f64());
878
879 let result = range.resolve(Some(atm), &strikes);
880
881 assert_eq!(result, vec![atm]);
882 }
883
884 #[rstest]
885 fn test_option_chain_slice_new_empty() {
886 let slice = OptionChainSlice::new(make_series_id());
887 assert!(slice.is_empty());
888 assert_eq!(slice.call_count(), 0);
889 assert_eq!(slice.put_count(), 0);
890 assert!(slice.atm_strike.is_none());
891 }
892
893 #[rstest]
894 fn test_strike_range_resolve_delta_falls_back_to_atm_relative() {
895 let strikes: Vec<Price> = (0..=20)
898 .map(|i| Price::from(&(40000 + i * 1000).to_string()))
899 .collect();
900 let atm = Some(Price::from("50000")); let delta = StrikeRange::Delta {
902 target: 0.25,
903 tolerance: 0.05,
904 };
905 let expected = StrikeRange::AtmRelative {
906 strikes_above: DEFAULT_DELTA_FALLBACK_STRIKES,
907 strikes_below: DEFAULT_DELTA_FALLBACK_STRIKES,
908 }
909 .resolve(atm, &strikes);
910
911 let result = delta.resolve(atm, &strikes);
912 assert_eq!(result, expected);
913 assert_eq!(result.len(), 2 * DEFAULT_DELTA_FALLBACK_STRIKES + 1);
914 assert!(result.contains(&Price::from("50000")));
915 assert!(!result.contains(&Price::from("40000")));
916 assert!(!result.contains(&Price::from("60000")));
917 }
918
919 #[rstest]
920 fn test_strike_range_resolve_delta_empty_without_atm() {
921 let delta = StrikeRange::Delta {
922 target: 0.25,
923 tolerance: 0.05,
924 };
925 let strikes = vec![Price::from("50000"), Price::from("55000")];
926 assert!(delta.resolve(None, &strikes).is_empty());
928 }
929
930 #[rstest]
931 fn test_strike_search_profile_roundtrip() {
932 let profile = StrikeSearchProfile::new(
933 vec![Decimal::from(5), Decimal::from(25)],
934 Decimal::from(2),
935 7,
936 )
937 .unwrap();
938 let encoded = serde_json::to_value(&profile).unwrap();
939 let restored: StrikeSearchProfile = serde_json::from_value(encoded.clone()).unwrap();
940 assert_eq!(restored, profile);
941 assert_eq!(profile.increments(), &[Decimal::from(5), Decimal::from(25)]);
942 assert_eq!(profile.origin(), Decimal::from(2));
943 assert_eq!(profile.max_candidates_per_slot(), 7);
944 assert_eq!(encoded["max_candidates_per_slot"], serde_json::json!(7));
945 }
946
947 #[rstest]
948 #[case(vec![], Decimal::ZERO, 7, "at least one increment")]
949 #[case(vec![Decimal::ZERO], Decimal::ZERO, 7, "increments must be positive")]
950 #[case(vec![Decimal::from(-1)], Decimal::ZERO, 7, "increments must be positive")]
951 #[case(vec![Decimal::from(5), Decimal::new(50, 1)], Decimal::ZERO, 7, "increments must be unique")]
952 #[case(vec![Decimal::from(5)], Decimal::from(-1), 7, "origin must be non-negative")]
953 #[case(vec![Decimal::from(5)], Decimal::ZERO, 0, "positive candidate bound")]
954 fn test_strike_search_profile_invalid(
955 #[case] increments: Vec<Decimal>,
956 #[case] origin: Decimal,
957 #[case] candidates: usize,
958 #[case] expected: &str,
959 ) {
960 let encoded = serde_json::json!({"increments": increments, "origin": origin, "max_candidates_per_slot": candidates});
961 let error = StrikeSearchProfile::new(increments, origin, candidates).unwrap_err();
962 let decoded_error = serde_json::from_value::<StrikeSearchProfile>(encoded).unwrap_err();
963 assert!(error.to_string().contains(expected));
964 assert!(decoded_error.to_string().contains(expected));
965 }
966}