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)]
160#[non_exhaustive]
161pub enum StrikeRange {
162 Fixed(Vec<Price>),
164 AtmRelative {
166 strikes_above: usize,
167 strikes_below: usize,
168 },
169 AtmPercent { pct: f64 },
171 Delta { target: f64, tolerance: f64 },
178}
179
180impl StrikeRange {
181 #[must_use]
193 pub fn resolve(&self, atm_price: Option<Price>, all_strikes: &[Price]) -> Vec<Price> {
194 match self {
195 Self::Fixed(strikes) => {
196 if all_strikes.is_empty() {
197 strikes.clone()
198 } else {
199 let available: HashSet<Price> = all_strikes.iter().copied().collect();
200 strikes
201 .iter()
202 .filter(|s| available.contains(s))
203 .copied()
204 .collect()
205 }
206 }
207 Self::AtmRelative {
208 strikes_above,
209 strikes_below,
210 } => {
211 let Some(atm) = atm_price else {
212 return vec![]; };
214 let atm_idx = match all_strikes.binary_search(&atm) {
216 Ok(idx) => idx,
217 Err(idx) => {
218 if idx == 0 {
219 0
220 } else if idx >= all_strikes.len() {
221 all_strikes.len() - 1
222 } else {
223 let diff_below = all_strikes[idx - 1].raw().abs_diff(atm.raw());
225 let diff_above = all_strikes[idx].raw().abs_diff(atm.raw());
226 if diff_below <= diff_above {
227 idx - 1
228 } else {
229 idx
230 }
231 }
232 }
233 };
234 let start = atm_idx.saturating_sub(*strikes_below);
235 let end = atm_idx
236 .saturating_add(*strikes_above)
237 .saturating_add(1)
238 .min(all_strikes.len());
239 all_strikes[start..end].to_vec()
240 }
241 Self::AtmPercent { pct } => {
242 let Some(atm) = atm_price else {
243 return vec![]; };
245 let atm_decimal = atm.as_decimal();
246 if atm_decimal.is_zero() {
247 return all_strikes.to_vec();
248 }
249 all_strikes
250 .iter()
251 .filter(|s| {
252 let distance = (s.as_decimal() - atm_decimal).abs();
253 let pct_diff = distance / atm_decimal.abs();
254 pct_diff.to_f64().is_some_and(|pct_diff| pct_diff <= *pct)
255 })
256 .copied()
257 .collect()
258 }
259 Self::Delta { .. } => Self::AtmRelative {
260 strikes_above: DEFAULT_DELTA_FALLBACK_STRIKES,
261 strikes_below: DEFAULT_DELTA_FALLBACK_STRIKES,
262 }
263 .resolve(atm_price, all_strikes),
264 }
265 }
266}
267
268#[repr(C)]
270#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
271#[serde(tag = "type")]
272#[cfg_attr(
273 feature = "python",
274 pyo3::pyclass(module = "nautilus_trader.model", from_py_object)
275)]
276#[cfg_attr(
277 feature = "python",
278 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.model")
279)]
280pub struct OptionGreeks {
281 pub instrument_id: InstrumentId,
283 pub convention: GreeksConvention,
285 pub greeks: OptionGreekValues,
287 pub mark_iv: Option<f64>,
289 pub bid_iv: Option<f64>,
291 pub ask_iv: Option<f64>,
293 pub underlying_price: Option<f64>,
295 pub open_interest: Option<f64>,
297 pub ts_event: UnixNanos,
299 pub ts_init: UnixNanos,
301}
302
303impl HasTsInit for OptionGreeks {
304 fn ts_init(&self) -> UnixNanos {
305 self.ts_init
306 }
307}
308
309impl Deref for OptionGreeks {
310 type Target = OptionGreekValues;
311 fn deref(&self) -> &Self::Target {
312 &self.greeks
313 }
314}
315
316impl HasGreeks for OptionGreeks {
317 fn greeks(&self) -> OptionGreekValues {
318 self.greeks
319 }
320}
321
322impl Default for OptionGreeks {
323 fn default() -> Self {
324 Self {
325 instrument_id: InstrumentId::from("NULL.NULL"),
326 convention: GreeksConvention::default(),
327 greeks: OptionGreekValues::default(),
328 mark_iv: None,
329 bid_iv: None,
330 ask_iv: None,
331 underlying_price: None,
332 open_interest: None,
333 ts_event: UnixNanos::default(),
334 ts_init: UnixNanos::default(),
335 }
336 }
337}
338
339impl Display for OptionGreeks {
340 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
341 write!(
342 f,
343 "OptionGreeks({}, {}, delta={:.4}, gamma={:.4}, vega={:.4}, theta={:.4}, mark_iv={:?})",
344 self.instrument_id,
345 self.convention,
346 self.delta,
347 self.gamma,
348 self.vega,
349 self.theta,
350 self.mark_iv
351 )
352 }
353}
354
355impl Serializable for OptionGreeks {}
356
357#[derive(Clone, Debug)]
359#[cfg_attr(
360 feature = "python",
361 pyo3::pyclass(module = "nautilus_trader.model", from_py_object)
362)]
363#[cfg_attr(
364 feature = "python",
365 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.model")
366)]
367pub struct OptionStrikeData {
368 pub quote: QuoteTick,
370 pub greeks: Option<OptionGreeks>,
372}
373
374#[derive(Clone, Debug)]
376#[cfg_attr(
377 feature = "python",
378 pyo3::pyclass(module = "nautilus_trader.model", from_py_object)
379)]
380#[cfg_attr(
381 feature = "python",
382 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.model")
383)]
384pub struct OptionChainSlice {
385 pub series_id: OptionSeriesId,
387 pub atm_strike: Option<Price>,
389 pub calls: BTreeMap<Price, OptionStrikeData>,
391 pub puts: BTreeMap<Price, OptionStrikeData>,
393 pub ts_event: UnixNanos,
395 pub ts_init: UnixNanos,
397}
398
399impl HasTsInit for OptionChainSlice {
400 fn ts_init(&self) -> UnixNanos {
401 self.ts_init
402 }
403}
404
405impl Display for OptionChainSlice {
406 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
407 write!(
408 f,
409 "OptionChainSlice({}, atm={:?}, calls={}, puts={})",
410 self.series_id,
411 self.atm_strike,
412 self.calls.len(),
413 self.puts.len()
414 )
415 }
416}
417
418impl OptionChainSlice {
419 #[must_use]
421 pub fn new(series_id: OptionSeriesId) -> Self {
422 Self {
423 series_id,
424 atm_strike: None,
425 calls: BTreeMap::new(),
426 puts: BTreeMap::new(),
427 ts_event: UnixNanos::default(),
428 ts_init: UnixNanos::default(),
429 }
430 }
431
432 #[must_use]
434 pub fn call_count(&self) -> usize {
435 self.calls.len()
436 }
437
438 #[must_use]
440 pub fn put_count(&self) -> usize {
441 self.puts.len()
442 }
443
444 #[must_use]
446 pub fn get_call(&self, strike: &Price) -> Option<&OptionStrikeData> {
447 self.calls.get(strike)
448 }
449
450 #[must_use]
452 pub fn get_put(&self, strike: &Price) -> Option<&OptionStrikeData> {
453 self.puts.get(strike)
454 }
455
456 #[must_use]
458 pub fn get_call_quote(&self, strike: &Price) -> Option<&QuoteTick> {
459 self.calls.get(strike).map(|d| &d.quote)
460 }
461
462 #[must_use]
464 pub fn get_call_greeks(&self, strike: &Price) -> Option<&OptionGreeks> {
465 self.calls.get(strike).and_then(|d| d.greeks.as_ref())
466 }
467
468 #[must_use]
470 pub fn get_put_quote(&self, strike: &Price) -> Option<&QuoteTick> {
471 self.puts.get(strike).map(|d| &d.quote)
472 }
473
474 #[must_use]
476 pub fn get_put_greeks(&self, strike: &Price) -> Option<&OptionGreeks> {
477 self.puts.get(strike).and_then(|d| d.greeks.as_ref())
478 }
479
480 #[must_use]
482 pub fn strikes(&self) -> Vec<Price> {
483 let mut strikes: Vec<Price> = self.calls.keys().chain(self.puts.keys()).copied().collect();
484 strikes.sort();
485 strikes.dedup();
486 strikes
487 }
488
489 #[must_use]
491 pub fn strike_count(&self) -> usize {
492 self.strikes().len()
493 }
494
495 #[must_use]
497 pub fn is_empty(&self) -> bool {
498 self.calls.is_empty() && self.puts.is_empty()
499 }
500}
501
502#[cfg(test)]
503mod tests {
504 use rstest::*;
505
506 use super::*;
507 use crate::{identifiers::Venue, types::Quantity};
508
509 fn make_quote(instrument_id: InstrumentId) -> QuoteTick {
510 QuoteTick::new(
511 instrument_id,
512 Price::from("100.00"),
513 Price::from("101.00"),
514 Quantity::from("1.0"),
515 Quantity::from("1.0"),
516 UnixNanos::from(1u64),
517 UnixNanos::from(1u64),
518 )
519 }
520
521 fn make_series_id() -> OptionSeriesId {
522 OptionSeriesId::new_derived(
523 Venue::new("DERIBIT"),
524 ustr::Ustr::from("BTC"),
525 ustr::Ustr::from("BTC"),
526 UnixNanos::from(1_700_000_000_000_000_000u64),
527 )
528 }
529
530 #[rstest]
531 fn test_strike_range_fixed() {
532 let range = StrikeRange::Fixed(vec![Price::from("50000"), Price::from("55000")]);
533 assert_eq!(
534 range,
535 StrikeRange::Fixed(vec![Price::from("50000"), Price::from("55000")])
536 );
537 }
538
539 #[rstest]
540 fn test_strike_range_atm_relative() {
541 let range = StrikeRange::AtmRelative {
542 strikes_above: 5,
543 strikes_below: 5,
544 };
545
546 if let StrikeRange::AtmRelative {
547 strikes_above,
548 strikes_below,
549 } = range
550 {
551 assert_eq!(strikes_above, 5);
552 assert_eq!(strikes_below, 5);
553 } else {
554 panic!("Expected AtmRelative variant");
555 }
556 }
557
558 #[rstest]
559 fn test_strike_range_atm_percent() {
560 let range = StrikeRange::AtmPercent { pct: 0.1 };
561 if let StrikeRange::AtmPercent { pct } = range {
562 assert!((pct - 0.1).abs() < f64::EPSILON);
563 } else {
564 panic!("Expected AtmPercent variant");
565 }
566 }
567
568 #[rstest]
569 fn test_option_greeks_default_fields() {
570 let greeks = OptionGreeks {
571 instrument_id: InstrumentId::from("BTC-20240101-50000-C.DERIBIT"),
572 convention: GreeksConvention::BlackScholes,
573 greeks: OptionGreekValues::default(),
574 mark_iv: None,
575 bid_iv: None,
576 ask_iv: None,
577 underlying_price: None,
578 open_interest: None,
579 ts_event: UnixNanos::default(),
580 ts_init: UnixNanos::default(),
581 };
582 assert_eq!(greeks.delta, 0.0);
583 assert_eq!(greeks.gamma, 0.0);
584 assert_eq!(greeks.vega, 0.0);
585 assert_eq!(greeks.theta, 0.0);
586 assert!(greeks.mark_iv.is_none());
587 assert_eq!(greeks.convention, GreeksConvention::BlackScholes);
588 }
589
590 #[rstest]
591 fn test_option_greeks_default_is_black_scholes() {
592 let greeks = OptionGreeks::default();
593 assert_eq!(greeks.convention, GreeksConvention::BlackScholes);
594 }
595
596 #[rstest]
597 fn test_option_greeks_display() {
598 let greeks = OptionGreeks {
599 instrument_id: InstrumentId::from("BTC-20240101-50000-C.DERIBIT"),
600 convention: GreeksConvention::PriceAdjusted,
601 greeks: OptionGreekValues {
602 delta: 0.55,
603 gamma: 0.001,
604 vega: 10.0,
605 theta: -5.0,
606 rho: 0.0,
607 },
608 mark_iv: Some(0.65),
609 bid_iv: None,
610 ask_iv: None,
611 underlying_price: None,
612 open_interest: None,
613 ts_event: UnixNanos::default(),
614 ts_init: UnixNanos::default(),
615 };
616 let display = format!("{greeks}");
617 assert!(display.contains("OptionGreeks"));
618 assert!(display.contains("PRICE_ADJUSTED"));
619 assert!(display.contains("0.55"));
620 }
621
622 #[rstest]
623 fn test_option_greeks_data_serde_round_trip() {
624 let greeks = OptionGreeks {
625 instrument_id: InstrumentId::from("BTC-20240101-50000-C.DERIBIT"),
626 convention: GreeksConvention::PriceAdjusted,
627 greeks: OptionGreekValues {
628 delta: 0.55,
629 gamma: 0.001,
630 vega: 10.0,
631 theta: -5.0,
632 rho: 0.2,
633 },
634 mark_iv: Some(0.65),
635 bid_iv: None,
636 ask_iv: Some(0.66),
637 underlying_price: Some(50_000.0),
638 open_interest: None,
639 ts_event: UnixNanos::from(1u64),
640 ts_init: UnixNanos::from(2u64),
641 };
642 let data = crate::data::Data::OptionGreeks(greeks);
643
644 let json = serde_json::to_string(&data).unwrap();
645 let roundtripped: crate::data::Data = serde_json::from_str(&json).unwrap();
646
647 assert_eq!(roundtripped, data);
648 }
649
650 #[rstest]
651 fn test_option_chain_slice_empty() {
652 let slice = OptionChainSlice {
653 series_id: make_series_id(),
654 atm_strike: None,
655 calls: BTreeMap::new(),
656 puts: BTreeMap::new(),
657 ts_event: UnixNanos::from(1u64),
658 ts_init: UnixNanos::from(1u64),
659 };
660
661 assert!(slice.is_empty());
662 assert_eq!(slice.strike_count(), 0);
663 assert!(slice.strikes().is_empty());
664 }
665
666 #[rstest]
667 fn test_option_chain_slice_with_data() {
668 let call_id = InstrumentId::from("BTC-20240101-50000-C.DERIBIT");
669 let put_id = InstrumentId::from("BTC-20240101-50000-P.DERIBIT");
670 let strike = Price::from("50000");
671
672 let mut calls = BTreeMap::new();
673 calls.insert(
674 strike,
675 OptionStrikeData {
676 quote: make_quote(call_id),
677 greeks: Some(OptionGreeks {
678 instrument_id: call_id,
679 greeks: OptionGreekValues {
680 delta: 0.55,
681 ..Default::default()
682 },
683 ..Default::default()
684 }),
685 },
686 );
687
688 let mut puts = BTreeMap::new();
689 puts.insert(
690 strike,
691 OptionStrikeData {
692 quote: make_quote(put_id),
693 greeks: None,
694 },
695 );
696
697 let slice = OptionChainSlice {
698 series_id: make_series_id(),
699 atm_strike: Some(strike),
700 calls,
701 puts,
702 ts_event: UnixNanos::from(1u64),
703 ts_init: UnixNanos::from(1u64),
704 };
705
706 assert!(!slice.is_empty());
707 assert_eq!(slice.strike_count(), 1);
708 assert_eq!(slice.strikes(), vec![strike]);
709 assert!(slice.get_call(&strike).is_some());
710 assert!(slice.get_put(&strike).is_some());
711 assert!(slice.get_call_greeks(&strike).is_some());
712 assert!(slice.get_put_greeks(&strike).is_none());
713 assert_eq!(slice.get_call_greeks(&strike).unwrap().delta, 0.55);
714 }
715
716 #[rstest]
717 fn test_option_chain_slice_display() {
718 let slice = OptionChainSlice {
719 series_id: make_series_id(),
720 atm_strike: None,
721 calls: BTreeMap::new(),
722 puts: BTreeMap::new(),
723 ts_event: UnixNanos::from(1u64),
724 ts_init: UnixNanos::from(1u64),
725 };
726
727 let display = format!("{slice}");
728 assert!(display.contains("OptionChainSlice"));
729 assert!(display.contains("DERIBIT"));
730 }
731
732 #[rstest]
733 fn test_option_chain_slice_ts_init() {
734 let slice = OptionChainSlice {
735 series_id: make_series_id(),
736 atm_strike: None,
737 calls: BTreeMap::new(),
738 puts: BTreeMap::new(),
739 ts_event: UnixNanos::from(1u64),
740 ts_init: UnixNanos::from(42u64),
741 };
742
743 assert_eq!(slice.ts_init(), UnixNanos::from(42u64));
744 }
745
746 #[rstest]
749 fn test_strike_range_preserves_subprecision_atm() {
750 let mut atm = Price::from("100.75");
751 atm.precision = 0;
752 let strikes = [Price::from("100"), Price::from("101")];
753
754 let range = StrikeRange::AtmRelative {
755 strikes_above: 0,
756 strikes_below: 0,
757 };
758
759 assert_eq!(range.resolve(Some(atm), &strikes), vec![strikes[1]]);
760 }
761
762 #[rstest]
763 fn test_strike_range_resolve_fixed() {
764 let range = StrikeRange::Fixed(vec![Price::from("50000"), Price::from("55000")]);
765 let result = range.resolve(None, &[]);
766 assert_eq!(result, vec![Price::from("50000"), Price::from("55000")]);
767 }
768
769 #[rstest]
770 fn test_strike_range_resolve_fixed_intersects_available_strikes() {
771 let range = StrikeRange::Fixed(vec![
772 Price::from("50000"),
773 Price::from("55000"),
774 Price::from("60000"),
775 ]);
776 let available = [
777 Price::from("45000"),
778 Price::from("50000"),
779 Price::from("60000"),
780 ];
781
782 let result = range.resolve(None, &available);
783
784 assert_eq!(result, vec![Price::from("50000"), Price::from("60000")]);
785 }
786
787 #[rstest]
788 fn test_strike_range_resolve_atm_relative() {
789 let range = StrikeRange::AtmRelative {
790 strikes_above: 2,
791 strikes_below: 2,
792 };
793 let strikes: Vec<Price> = [45000, 47000, 50000, 53000, 55000, 57000]
794 .iter()
795 .map(|s| Price::from(&s.to_string()))
796 .collect();
797 let atm = Some(Price::from("50000"));
798 let result = range.resolve(atm, &strikes);
799 assert_eq!(result.len(), 5);
801 assert_eq!(result[0], Price::from("45000"));
802 assert_eq!(result[4], Price::from("55000"));
803 }
804
805 #[rstest]
806 #[case("49750", "50000")]
807 #[case("50250", "50000")]
808 #[case("51750", "52000")]
809 fn test_strike_range_resolve_atm_relative_selects_nearest_strike(
810 #[case] atm: &str,
811 #[case] expected: &str,
812 ) {
813 let range = StrikeRange::AtmRelative {
814 strikes_above: 0,
815 strikes_below: 0,
816 };
817 let strikes = [
818 Price::from("48000"),
819 Price::from("50000"),
820 Price::from("52000"),
821 ];
822
823 let result = range.resolve(Some(Price::from(atm)), &strikes);
824
825 assert_eq!(result, vec![Price::from(expected)]);
826 }
827
828 #[rstest]
829 fn test_strike_range_resolve_atm_relative_exact_high_value() {
830 let range = StrikeRange::AtmRelative {
831 strikes_above: 0,
832 strikes_below: 0,
833 };
834 let atm = Price::from("9007199253.999000000");
835 let collapsed = Price::from("9007199253.999000001");
836 let strikes = [atm, collapsed];
837 assert_eq!(collapsed.as_f64(), atm.as_f64());
838
839 let result = range.resolve(Some(atm), &strikes);
840
841 assert_eq!(result, vec![atm]);
842 }
843
844 #[rstest]
845 fn test_strike_range_resolve_atm_relative_saturates_extreme_window() {
846 let range = StrikeRange::AtmRelative {
848 strikes_above: usize::MAX,
849 strikes_below: usize::MAX,
850 };
851 let strikes: Vec<Price> = [45000, 50000, 55000]
852 .iter()
853 .map(|s| Price::from(&s.to_string()))
854 .collect();
855 let atm = Some(Price::from("50000"));
856
857 let result = range.resolve(atm, &strikes);
858
859 assert_eq!(result, strikes);
860 }
861
862 #[rstest]
863 fn test_strike_range_resolve_atm_relative_no_atm() {
864 let range = StrikeRange::AtmRelative {
865 strikes_above: 2,
866 strikes_below: 2,
867 };
868 let strikes = vec![Price::from("50000"), Price::from("55000")];
869 let result = range.resolve(None, &strikes);
870 assert!(result.is_empty());
872 }
873
874 #[rstest]
875 fn test_strike_range_resolve_atm_percent() {
876 let range = StrikeRange::AtmPercent { pct: 0.1 }; let strikes: Vec<Price> = [45000, 48000, 50000, 52000, 55000, 60000]
878 .iter()
879 .map(|s| Price::from(&s.to_string()))
880 .collect();
881 let atm = Some(Price::from("50000"));
882 let result = range.resolve(atm, &strikes);
883 assert_eq!(result.len(), 5); assert!(result.contains(&Price::from("45000")));
886 assert!(result.contains(&Price::from("48000")));
887 assert!(result.contains(&Price::from("50000")));
888 assert!(result.contains(&Price::from("52000")));
889 assert!(result.contains(&Price::from("55000")));
890 }
891
892 #[rstest]
893 fn test_strike_range_resolve_atm_percent_zero_exact_high_value() {
894 let range = StrikeRange::AtmPercent { pct: 0.0 };
895 let atm = Price::from("9007199253.999000000");
896 let collapsed = Price::from("9007199253.999000001");
897 let strikes = [atm, collapsed];
898 assert_eq!(atm.as_f64(), collapsed.as_f64());
899
900 let result = range.resolve(Some(atm), &strikes);
901
902 assert_eq!(result, vec![atm]);
903 }
904
905 #[rstest]
906 fn test_option_chain_slice_new_empty() {
907 let slice = OptionChainSlice::new(make_series_id());
908 assert!(slice.is_empty());
909 assert_eq!(slice.call_count(), 0);
910 assert_eq!(slice.put_count(), 0);
911 assert!(slice.atm_strike.is_none());
912 }
913
914 #[rstest]
915 fn test_strike_range_resolve_delta_falls_back_to_atm_relative() {
916 let strikes: Vec<Price> = (0..=20)
919 .map(|i| Price::from(&(40000 + i * 1000).to_string()))
920 .collect();
921 let atm = Some(Price::from("50000")); let delta = StrikeRange::Delta {
923 target: 0.25,
924 tolerance: 0.05,
925 };
926 let expected = StrikeRange::AtmRelative {
927 strikes_above: DEFAULT_DELTA_FALLBACK_STRIKES,
928 strikes_below: DEFAULT_DELTA_FALLBACK_STRIKES,
929 }
930 .resolve(atm, &strikes);
931
932 let result = delta.resolve(atm, &strikes);
933 assert_eq!(result, expected);
934 assert_eq!(result.len(), 2 * DEFAULT_DELTA_FALLBACK_STRIKES + 1);
935 assert!(result.contains(&Price::from("50000")));
936 assert!(!result.contains(&Price::from("40000")));
937 assert!(!result.contains(&Price::from("60000")));
938 }
939
940 #[rstest]
941 fn test_strike_range_resolve_delta_empty_without_atm() {
942 let delta = StrikeRange::Delta {
943 target: 0.25,
944 tolerance: 0.05,
945 };
946 let strikes = vec![Price::from("50000"), Price::from("55000")];
947 assert!(delta.resolve(None, &strikes).is_empty());
949 }
950
951 #[rstest]
952 fn test_strike_search_profile_roundtrip() {
953 let profile = StrikeSearchProfile::new(
954 vec![Decimal::from(5), Decimal::from(25)],
955 Decimal::from(2),
956 7,
957 )
958 .unwrap();
959 let encoded = serde_json::to_value(&profile).unwrap();
960 let restored: StrikeSearchProfile = serde_json::from_value(encoded.clone()).unwrap();
961 assert_eq!(restored, profile);
962 assert_eq!(profile.increments(), &[Decimal::from(5), Decimal::from(25)]);
963 assert_eq!(profile.origin(), Decimal::from(2));
964 assert_eq!(profile.max_candidates_per_slot(), 7);
965 assert_eq!(encoded["max_candidates_per_slot"], serde_json::json!(7));
966 }
967
968 #[rstest]
969 #[case(vec![], Decimal::ZERO, 7, "at least one increment")]
970 #[case(vec![Decimal::ZERO], Decimal::ZERO, 7, "increments must be positive")]
971 #[case(vec![Decimal::from(-1)], Decimal::ZERO, 7, "increments must be positive")]
972 #[case(vec![Decimal::from(5), Decimal::new(50, 1)], Decimal::ZERO, 7, "increments must be unique")]
973 #[case(vec![Decimal::from(5)], Decimal::from(-1), 7, "origin must be non-negative")]
974 #[case(vec![Decimal::from(5)], Decimal::ZERO, 0, "positive candidate bound")]
975 fn test_strike_search_profile_invalid(
976 #[case] increments: Vec<Decimal>,
977 #[case] origin: Decimal,
978 #[case] candidates: usize,
979 #[case] expected: &str,
980 ) {
981 let encoded = serde_json::json!({"increments": increments, "origin": origin, "max_candidates_per_slot": candidates});
982 let error = StrikeSearchProfile::new(increments, origin, candidates).unwrap_err();
983 let decoded_error = serde_json::from_value::<StrikeSearchProfile>(encoded).unwrap_err();
984 assert!(error.to_string().contains(expected));
985 assert!(decoded_error.to_string().contains(expected));
986 }
987}