1use std::{
19 cmp::Ordering,
20 collections::{BTreeMap, HashMap, HashSet},
21};
22
23use nautilus_core::{DurationNanos, UnixNanos};
24use nautilus_model::{
25 data::{
26 QuoteTick,
27 option_chain::{OptionChainSlice, OptionGreeks, OptionStrikeData, StrikeRange},
28 },
29 enums::OptionKind,
30 identifiers::{InstrumentId, OptionSeriesId},
31 types::Price,
32};
33use rust_decimal::prelude::ToPrimitive;
34
35use super::{
36 AtmTracker,
37 constants::{DEFAULT_REBALANCE_COOLDOWN, DEFAULT_REBALANCE_HYSTERESIS},
38};
39
40#[derive(Debug)]
45pub struct OptionChainAggregator {
46 series_id: OptionSeriesId,
48 strike_range: StrikeRange,
50 atm_tracker: AtmTracker,
52 instruments: HashMap<InstrumentId, (Price, OptionKind)>,
55 active_ids: HashSet<InstrumentId>,
57 last_atm_strike: Option<Price>,
59 hysteresis: f64,
61 cooldown_ns: DurationNanos,
63 last_rebalance_ns: Option<UnixNanos>,
65 max_ts_event: UnixNanos,
67 pending_greeks: HashMap<InstrumentId, OptionGreeks>,
69 call_buffer: BTreeMap<Price, OptionStrikeData>,
71 put_buffer: BTreeMap<Price, OptionStrikeData>,
73}
74
75impl OptionChainAggregator {
76 pub fn new(
83 series_id: OptionSeriesId,
84 strike_range: StrikeRange,
85 atm_tracker: AtmTracker,
86 instruments: HashMap<InstrumentId, (Price, OptionKind)>,
87 ) -> Self {
88 let mut aggregator = Self {
89 series_id,
90 strike_range,
91 atm_tracker,
92 instruments,
93 active_ids: HashSet::new(),
94 last_atm_strike: None,
95 hysteresis: DEFAULT_REBALANCE_HYSTERESIS,
96 cooldown_ns: DEFAULT_REBALANCE_COOLDOWN,
97 last_rebalance_ns: None,
98 max_ts_event: UnixNanos::default(),
99 pending_greeks: HashMap::new(),
100 call_buffer: BTreeMap::new(),
101 put_buffer: BTreeMap::new(),
102 };
103 aggregator.recompute_active_set();
105 aggregator
106 }
107
108 pub fn atm_tracker_mut(&mut self) -> &mut AtmTracker {
110 &mut self.atm_tracker
111 }
112
113 #[must_use]
115 pub fn instrument_ids(&self) -> Vec<InstrumentId> {
116 self.active_ids.iter().copied().collect()
117 }
118
119 #[must_use]
121 pub fn active_ids(&self) -> &HashSet<InstrumentId> {
122 &self.active_ids
123 }
124
125 #[must_use]
127 pub fn series_id(&self) -> OptionSeriesId {
128 self.series_id
129 }
130
131 #[must_use]
133 pub fn is_expired(&self, now_ns: UnixNanos) -> bool {
134 now_ns >= self.series_id.expiration_ns
135 }
136
137 #[must_use]
139 pub fn instruments(&self) -> &HashMap<InstrumentId, (Price, OptionKind)> {
140 &self.instruments
141 }
142
143 #[must_use]
145 pub fn all_instrument_ids(&self) -> Vec<InstrumentId> {
146 self.instruments.keys().copied().collect()
147 }
148
149 #[must_use]
151 pub fn is_catalog_empty(&self) -> bool {
152 self.instruments.is_empty()
153 }
154
155 #[must_use]
161 pub fn remove_instrument(&mut self, instrument_id: &InstrumentId) -> bool {
162 let Some((strike, kind)) = self.instruments.remove(instrument_id) else {
163 return false;
164 };
165
166 self.active_ids.remove(instrument_id);
167 self.pending_greeks.remove(instrument_id);
168
169 let has_sibling = self
171 .instruments
172 .values()
173 .any(|(s, k)| *s == strike && *k == kind);
174
175 if !has_sibling {
176 let buffer = match kind {
177 OptionKind::Call => &mut self.call_buffer,
178 OptionKind::Put => &mut self.put_buffer,
179 };
180 buffer.remove(&strike);
181 }
182
183 true
184 }
185
186 #[must_use]
188 pub fn atm_tracker(&self) -> &AtmTracker {
189 &self.atm_tracker
190 }
191
192 pub fn recompute_active_set(&mut self) -> Vec<InstrumentId> {
197 let atm_price = self.atm_tracker.atm_price();
198 let all_strikes = Self::sorted_strikes(&self.instruments);
199 let active_strikes: HashSet<Price> = self
200 .resolve_active_strikes(atm_price, &all_strikes)
201 .into_iter()
202 .collect();
203 self.active_ids = self
204 .instruments
205 .iter()
206 .filter(|(_, (strike, _))| active_strikes.contains(strike))
207 .map(|(id, _)| *id)
208 .collect();
209 self.last_atm_strike =
210 atm_price.and_then(|atm| Self::find_closest_strike(&all_strikes, atm));
211 self.active_ids.iter().copied().collect()
212 }
213
214 fn resolve_active_strikes(
219 &self,
220 atm_price: Option<Price>,
221 all_strikes: &[Price],
222 ) -> Vec<Price> {
223 match &self.strike_range {
224 StrikeRange::Delta { target, tolerance } => {
225 self.resolve_delta(*target, *tolerance, atm_price, all_strikes)
226 }
227 _ => self.strike_range.resolve(atm_price, all_strikes),
228 }
229 }
230
231 fn resolve_delta(
244 &self,
245 target: f64,
246 tolerance: f64,
247 atm_price: Option<Price>,
248 all_strikes: &[Price],
249 ) -> Vec<Price> {
250 let selected: Vec<Price> = self
251 .deltas_by_strike()
252 .into_iter()
253 .filter(|(_, deltas)| {
254 deltas
255 .iter()
256 .any(|delta| Self::delta_within_band(*delta, target, tolerance))
257 })
258 .map(|(strike, _)| strike)
259 .collect();
260
261 let fallback_strikes = self.strike_range.resolve(atm_price, all_strikes);
262
263 if selected.is_empty() {
264 return fallback_strikes;
265 }
266
267 let selected_ids = self.instrument_ids_for_strikes(&selected);
268 let fallback_ids = self.instrument_ids_for_strikes(&fallback_strikes);
269
270 if self.active_ids != selected_ids && !self.delta_window_ready(&fallback_ids) {
271 return fallback_strikes;
272 }
273
274 selected
275 }
276
277 fn instrument_ids_for_strikes(&self, strikes: &[Price]) -> HashSet<InstrumentId> {
278 let strike_set: HashSet<Price> = strikes.iter().copied().collect();
279 self.instruments
280 .iter()
281 .filter(|(_, (strike, _))| strike_set.contains(strike))
282 .map(|(id, _)| *id)
283 .collect()
284 }
285
286 fn delta_window_ready(&self, instrument_ids: &HashSet<InstrumentId>) -> bool {
287 !instrument_ids.is_empty()
288 && instrument_ids
289 .iter()
290 .all(|id| self.instrument_has_greeks(id))
291 }
292
293 fn instrument_has_greeks(&self, instrument_id: &InstrumentId) -> bool {
294 if self.pending_greeks.contains_key(instrument_id) {
295 return true;
296 }
297
298 let Some((strike, kind)) = self.instruments.get(instrument_id) else {
299 return false;
300 };
301 let buffer = match kind {
302 OptionKind::Call => &self.call_buffer,
303 OptionKind::Put => &self.put_buffer,
304 };
305
306 buffer
307 .get(strike)
308 .and_then(|data| data.greeks.as_ref())
309 .is_some()
310 }
311
312 fn deltas_by_strike(&self) -> BTreeMap<Price, Vec<f64>> {
315 let mut deltas_by_strike: BTreeMap<Price, Vec<f64>> = BTreeMap::new();
316
317 for (strike, data) in self.call_buffer.iter().chain(self.put_buffer.iter()) {
318 if let Some(greeks) = data.greeks.as_ref() {
319 deltas_by_strike
320 .entry(*strike)
321 .or_default()
322 .push(greeks.delta);
323 }
324 }
325
326 for (id, greeks) in &self.pending_greeks {
327 if let Some((strike, _)) = self.instruments.get(id) {
328 deltas_by_strike
329 .entry(*strike)
330 .or_default()
331 .push(greeks.delta);
332 }
333 }
334
335 deltas_by_strike
336 }
337
338 fn delta_within_band(delta: f64, target: f64, tolerance: f64) -> bool {
343 (delta.abs() - target).abs() <= tolerance
344 }
345
346 #[must_use]
353 pub fn add_instrument(
354 &mut self,
355 instrument_id: InstrumentId,
356 strike: Price,
357 kind: OptionKind,
358 ) -> bool {
359 if self.instruments.contains_key(&instrument_id) {
360 return false;
361 }
362
363 self.instruments.insert(instrument_id, (strike, kind));
364
365 let all_strikes = Self::sorted_strikes(&self.instruments);
367 let atm_price = self.atm_tracker.atm_price();
368 let active_strikes: HashSet<Price> = self
369 .resolve_active_strikes(atm_price, &all_strikes)
370 .into_iter()
371 .collect();
372
373 if active_strikes.contains(&strike) {
374 self.active_ids.insert(instrument_id);
375 }
376
377 true
378 }
379
380 fn sorted_strikes(instruments: &HashMap<InstrumentId, (Price, OptionKind)>) -> Vec<Price> {
382 let mut strikes: Vec<Price> = instruments.values().map(|(s, _)| *s).collect();
383 strikes.sort();
384 strikes.dedup();
385 strikes
386 }
387
388 fn find_closest_strike(all_strikes: &[Price], atm: Price) -> Option<Price> {
390 all_strikes
391 .iter()
392 .min_by_key(|strike| strike.raw().abs_diff(atm.raw()))
393 .copied()
394 }
395
396 pub fn update_quote(&mut self, quote: &QuoteTick) {
398 if self.is_expired(quote.ts_event) {
399 log::warn!(
400 "Dropping quote for {}, series {} expired at {}",
401 quote.instrument_id,
402 self.series_id,
403 self.series_id.expiration_ns,
404 );
405 return;
406 }
407
408 if !self.active_ids.contains("e.instrument_id) {
409 return;
410 }
411
412 if let Some(&(strike, kind)) = self.instruments.get("e.instrument_id) {
413 if quote.ts_event > self.max_ts_event {
415 self.max_ts_event = quote.ts_event;
416 }
417
418 let buffer = match kind {
419 OptionKind::Call => &mut self.call_buffer,
420 OptionKind::Put => &mut self.put_buffer,
421 };
422
423 match buffer.get_mut(&strike) {
424 Some(data) => data.quote = *quote,
425 None => {
426 let greeks = self.pending_greeks.remove("e.instrument_id);
428 buffer.insert(
429 strike,
430 OptionStrikeData {
431 quote: *quote,
432 greeks,
433 },
434 );
435 }
436 }
437 }
438 }
439
440 pub fn update_greeks(&mut self, greeks: &OptionGreeks) {
446 if self.is_expired(greeks.ts_event) {
447 log::warn!(
448 "Dropping greeks for {}, series {} expired at {}",
449 greeks.instrument_id,
450 self.series_id,
451 self.series_id.expiration_ns,
452 );
453 return;
454 }
455
456 if !self.active_ids.contains(&greeks.instrument_id) {
457 return;
458 }
459
460 if let Some(&(strike, kind)) = self.instruments.get(&greeks.instrument_id) {
461 let buffer = match kind {
462 OptionKind::Call => &mut self.call_buffer,
463 OptionKind::Put => &mut self.put_buffer,
464 };
465
466 match buffer.get_mut(&strike) {
467 Some(data) => data.greeks = Some(*greeks),
468 None => {
469 self.pending_greeks.insert(greeks.instrument_id, *greeks);
471 }
472 }
473 }
474 }
475
476 pub fn snapshot(&self, ts_init: UnixNanos) -> OptionChainSlice {
485 let atm_price = self.atm_tracker.atm_price();
486
487 let catalog_strikes = Self::sorted_strikes(&self.instruments);
489 let atm_strike = atm_price.and_then(|atm| Self::find_closest_strike(&catalog_strikes, atm));
490
491 let active_strikes: HashSet<Price> = self
495 .active_ids
496 .iter()
497 .filter_map(|id| self.instruments.get(id).map(|(s, _)| *s))
498 .collect();
499
500 let mut calls = BTreeMap::new();
502
503 for (strike, data) in &self.call_buffer {
504 if active_strikes.contains(strike) {
505 calls.insert(*strike, data.clone());
506 }
507 }
508 let mut puts = BTreeMap::new();
509
510 for (strike, data) in &self.put_buffer {
511 if active_strikes.contains(strike) {
512 puts.insert(*strike, data.clone());
513 }
514 }
515
516 let ts_event = if self.max_ts_event == UnixNanos::default() {
518 ts_init
519 } else {
520 self.max_ts_event
521 };
522
523 OptionChainSlice {
524 series_id: self.series_id,
525 atm_strike,
526 calls,
527 puts,
528 ts_event,
529 ts_init,
530 }
531 }
532
533 #[must_use]
535 pub fn is_buffer_empty(&self) -> bool {
536 self.call_buffer.is_empty() && self.put_buffer.is_empty()
537 }
538
539 #[must_use]
551 pub fn check_rebalance(&self, now_ns: UnixNanos) -> Option<RebalanceAction> {
552 if matches!(self.strike_range, StrikeRange::Fixed(_)) {
554 return None;
555 }
556
557 let atm_price = self.atm_tracker.atm_price()?;
558 let all_strikes = Self::sorted_strikes(&self.instruments);
559 let current_atm_strike = Self::find_closest_strike(&all_strikes, atm_price)?;
560
561 let is_delta = matches!(self.strike_range, StrikeRange::Delta { .. });
562
563 if !is_delta {
564 if self.last_atm_strike == Some(current_atm_strike) {
566 return None;
567 }
568
569 if let Some(last_strike) = self.last_atm_strike
571 && self.hysteresis > 0.0
572 {
573 let next_strike = match atm_price.cmp(&last_strike) {
575 Ordering::Greater => all_strikes.iter().find(|s| **s > last_strike),
576 Ordering::Less => all_strikes.iter().rev().find(|s| **s < last_strike),
577 Ordering::Equal => None,
578 };
579
580 if let Some(next) = next_strike {
581 let progress = (atm_price.as_decimal() - last_strike.as_decimal()).abs();
582 let gap = (next.as_decimal() - last_strike.as_decimal()).abs();
583 let progress_ratio = (progress / gap).to_f64().unwrap_or(f64::MAX);
584 if progress_ratio < self.hysteresis {
585 return None;
586 }
587 }
588 }
589 }
590
591 if !self.cooldown_ns.is_zero()
593 && let Some(last_ts) = self.last_rebalance_ns
594 && now_ns.saturating_duration_since(last_ts) < self.cooldown_ns
595 {
596 return None;
597 }
598
599 let new_active_strikes: HashSet<Price> = self
601 .resolve_active_strikes(Some(atm_price), &all_strikes)
602 .into_iter()
603 .collect();
604 let new_active: HashSet<InstrumentId> = self
605 .instruments
606 .iter()
607 .filter(|(_, (s, _))| new_active_strikes.contains(s))
608 .map(|(id, _)| *id)
609 .collect();
610
611 let add: Vec<InstrumentId> = new_active.difference(&self.active_ids).copied().collect();
612 let remove: Vec<InstrumentId> = self.active_ids.difference(&new_active).copied().collect();
613
614 if is_delta && add.is_empty() && remove.is_empty() {
617 return None;
618 }
619
620 Some(RebalanceAction { add, remove })
621 }
622
623 pub fn apply_rebalance(&mut self, action: &RebalanceAction, now_ns: UnixNanos) {
626 for id in &action.add {
627 self.active_ids.insert(*id);
628 }
629
630 for id in &action.remove {
631 self.active_ids.remove(id);
632 }
633
634 let active_strikes: HashSet<Price> = self
636 .active_ids
637 .iter()
638 .filter_map(|id| self.instruments.get(id))
639 .map(|(s, _)| *s)
640 .collect();
641 self.call_buffer
642 .retain(|strike, _| active_strikes.contains(strike));
643 self.put_buffer
644 .retain(|strike, _| active_strikes.contains(strike));
645 self.pending_greeks
646 .retain(|id, _| self.active_ids.contains(id));
647
648 if let Some(atm) = self.atm_tracker.atm_price() {
650 let all_strikes = Self::sorted_strikes(&self.instruments);
651 self.last_atm_strike = Self::find_closest_strike(&all_strikes, atm);
652 }
653 self.last_rebalance_ns = Some(now_ns);
654 }
655}
656
657#[derive(Clone, Debug, PartialEq, Eq)]
659pub struct RebalanceAction {
660 pub add: Vec<InstrumentId>,
662 pub remove: Vec<InstrumentId>,
664}
665
666#[cfg(test)]
667impl OptionChainAggregator {
668 fn call_buffer_len(&self) -> usize {
669 self.call_buffer.len()
670 }
671
672 fn put_buffer_len(&self) -> usize {
673 self.put_buffer.len()
674 }
675
676 fn get_call_greeks_from_buffer(&self, strike: &Price) -> Option<&OptionGreeks> {
677 self.call_buffer.get(strike).and_then(|d| d.greeks.as_ref())
678 }
679
680 pub(crate) fn last_atm_strike(&self) -> Option<Price> {
681 self.last_atm_strike
682 }
683
684 fn set_hysteresis(&mut self, h: f64) {
685 self.hysteresis = h;
686 }
687
688 fn set_cooldown_ns(&mut self, ns: DurationNanos) {
689 self.cooldown_ns = ns;
690 }
691
692 fn pending_greeks_count(&self) -> usize {
693 self.pending_greeks.len()
694 }
695}
696
697#[cfg(test)]
698mod tests {
699 use nautilus_model::{data::greeks::OptionGreekValues, identifiers::Venue, types::Quantity};
700 use rstest::*;
701
702 use super::*;
703
704 fn make_series_id() -> OptionSeriesId {
705 OptionSeriesId::new(
706 Venue::new("DERIBIT"),
707 ustr::Ustr::from("BTC"),
708 ustr::Ustr::from("BTC"),
709 UnixNanos::from(1_700_000_000_000_000_000u64),
710 )
711 }
712
713 fn make_quote(instrument_id: InstrumentId, bid: &str, ask: &str) -> QuoteTick {
714 QuoteTick::new(
715 instrument_id,
716 Price::from(bid),
717 Price::from(ask),
718 Quantity::from("1.0"),
719 Quantity::from("1.0"),
720 UnixNanos::from(1u64),
721 UnixNanos::from(1u64),
722 )
723 }
724
725 fn now() -> UnixNanos {
726 UnixNanos::from(1_000_000_000_000_000_000u64)
728 }
729
730 fn set_atm_via_greeks(agg: &mut OptionChainAggregator, price: f64) {
732 let greeks = OptionGreeks {
733 instrument_id: InstrumentId::from("BTC-20240101-50000-C.DERIBIT"),
734 underlying_price: Some(price),
735 ..Default::default()
736 };
737 agg.atm_tracker_mut().update_from_option_greeks(&greeks);
738 }
739
740 fn make_aggregator() -> (OptionChainAggregator, InstrumentId, InstrumentId) {
741 let call_id = InstrumentId::from("BTC-20240101-50000-C.DERIBIT");
742 let put_id = InstrumentId::from("BTC-20240101-50000-P.DERIBIT");
743 let strike = Price::from("50000");
744
745 let mut instrument_map = HashMap::new();
746 instrument_map.insert(call_id, (strike, OptionKind::Call));
747 instrument_map.insert(put_id, (strike, OptionKind::Put));
748
749 let tracker = AtmTracker::new();
750 let agg = OptionChainAggregator::new(
751 make_series_id(),
752 StrikeRange::Fixed(vec![strike]),
753 tracker,
754 instrument_map,
755 );
756
757 (agg, call_id, put_id)
758 }
759
760 #[rstest]
761 fn test_find_closest_strike_preserves_subprecision_atm() {
762 let mut atm = Price::from("100.75");
763 atm.precision = 0;
764 let strikes = [Price::from("100"), Price::from("101")];
765 assert_eq!(
766 OptionChainAggregator::find_closest_strike(&strikes, atm),
767 Some(strikes[1])
768 );
769 }
770
771 #[rstest]
772 fn test_find_closest_strike_prefers_exact_high_value_match() {
773 let collapsed = Price::from("9007199253.999000000");
774 let atm = Price::from("9007199253.999000001");
775 let strikes = [collapsed, atm];
776 assert_eq!(collapsed.as_f64(), atm.as_f64());
777
778 let result = OptionChainAggregator::find_closest_strike(&strikes, atm);
779
780 assert_eq!(result, Some(atm));
781 }
782
783 #[rstest]
784 fn test_aggregator_instrument_ids() {
785 let (agg, call_id, put_id) = make_aggregator();
786 let ids = agg.instrument_ids();
787 assert_eq!(ids.len(), 2);
788 assert!(ids.contains(&call_id));
789 assert!(ids.contains(&put_id));
790 }
791
792 #[rstest]
793 fn test_aggregator_update_quote() {
794 let (mut agg, call_id, _) = make_aggregator();
795 let quote = make_quote(call_id, "100.00", "101.00");
796
797 agg.update_quote("e);
798
799 assert_eq!(agg.call_buffer_len(), 1);
800 assert_eq!(agg.put_buffer_len(), 0);
801 }
802
803 #[rstest]
804 fn test_aggregator_update_greeks() {
805 let (mut agg, call_id, _) = make_aggregator();
806 let quote = make_quote(call_id, "100.00", "101.00");
807 agg.update_quote("e);
808
809 let greeks = OptionGreeks {
810 instrument_id: call_id,
811 greeks: OptionGreekValues {
812 delta: 0.55,
813 ..Default::default()
814 },
815 ..Default::default()
816 };
817 agg.update_greeks(&greeks);
818
819 let strike = Price::from("50000");
820 let data = agg.get_call_greeks_from_buffer(&strike);
821 assert!(data.is_some());
822 assert_eq!(data.unwrap().delta, 0.55);
823 }
824
825 #[rstest]
826 fn test_aggregator_snapshot_preserves_state() {
827 let (mut agg, call_id, _) = make_aggregator();
828 let quote = make_quote(call_id, "100.00", "101.00");
829 agg.update_quote("e);
830
831 let slice = agg.snapshot(UnixNanos::from(100u64));
832 assert_eq!(slice.call_count(), 1);
833 assert_eq!(slice.ts_init, UnixNanos::from(100u64));
834
835 assert!(!agg.is_buffer_empty());
837
838 let slice2 = agg.snapshot(UnixNanos::from(200u64));
840 assert_eq!(slice2.call_count(), 1);
841 assert_eq!(slice2.ts_init, UnixNanos::from(200u64));
842 }
843
844 #[rstest]
845 fn test_aggregator_ignores_unknown_instrument() {
846 let (mut agg, _, _) = make_aggregator();
847 let unknown_id = InstrumentId::from("ETH-20240101-3000-C.DERIBIT");
848 let quote = make_quote(unknown_id, "100.00", "101.00");
849
850 agg.update_quote("e);
851
852 assert!(agg.is_buffer_empty());
853 }
854
855 #[rstest]
856 fn test_check_rebalance_returns_none() {
857 let (agg, _, _) = make_aggregator();
858 assert!(agg.check_rebalance(now()).is_none());
859 }
860
861 fn make_multi_strike_aggregator() -> OptionChainAggregator {
866 let strikes = [45000, 47500, 50000, 52500, 55000];
867 let mut instruments = HashMap::new();
868
869 for s in &strikes {
870 let strike = Price::from(&s.to_string());
871 let call_id = InstrumentId::from(&format!("BTC-20240101-{s}-C.DERIBIT"));
872 let put_id = InstrumentId::from(&format!("BTC-20240101-{s}-P.DERIBIT"));
873 instruments.insert(call_id, (strike, OptionKind::Call));
874 instruments.insert(put_id, (strike, OptionKind::Put));
875 }
876
877 let tracker = AtmTracker::new();
878 let mut agg = OptionChainAggregator::new(
879 make_series_id(),
880 StrikeRange::AtmRelative {
881 strikes_above: 1,
882 strikes_below: 1,
883 },
884 tracker,
885 instruments,
886 );
887 agg.set_hysteresis(0.0);
889 agg.set_cooldown_ns(DurationNanos::default());
890 agg
891 }
892
893 #[rstest]
894 fn test_check_rebalance_fixed_always_none() {
895 let (mut agg, _, _) = make_aggregator();
897 set_atm_via_greeks(&mut agg, 50000.0);
898 assert!(agg.check_rebalance(now()).is_none());
899 }
900
901 #[rstest]
902 fn test_check_rebalance_no_atm_returns_none() {
903 let agg = make_multi_strike_aggregator();
904 assert!(agg.check_rebalance(now()).is_none());
906 }
907
908 #[rstest]
909 fn test_check_rebalance_atm_unchanged_returns_none() {
910 let mut agg = make_multi_strike_aggregator();
911 set_atm_via_greeks(&mut agg, 50000.0);
913 let action = agg.check_rebalance(now()).unwrap();
915 agg.apply_rebalance(&action, now());
916
917 set_atm_via_greeks(&mut agg, 50200.0);
919 assert!(agg.check_rebalance(now()).is_none());
920 }
921
922 #[rstest]
923 fn test_check_rebalance_detects_atm_shift() {
924 let mut agg = make_multi_strike_aggregator();
925 set_atm_via_greeks(&mut agg, 50000.0);
927 let action = agg.check_rebalance(now()).unwrap();
928 agg.apply_rebalance(&action, now());
929 assert_eq!(agg.instrument_ids().len(), 6); set_atm_via_greeks(&mut agg, 55000.0);
934 let action2 = agg.check_rebalance(now()).unwrap();
935 assert!(!action2.add.is_empty() || !action2.remove.is_empty());
937 }
938
939 #[rstest]
940 fn test_apply_rebalance_updates_instrument_map() {
941 let mut agg = make_multi_strike_aggregator();
942 set_atm_via_greeks(&mut agg, 50000.0);
944 let action = agg.check_rebalance(now()).unwrap();
945 agg.apply_rebalance(&action, now());
946
947 let active_ids = agg.instrument_ids();
949 assert_eq!(active_ids.len(), 6); set_atm_via_greeks(&mut agg, 55000.0);
953 let action2 = agg.check_rebalance(now()).unwrap();
954 agg.apply_rebalance(&action2, now());
955
956 let active_ids2 = agg.instrument_ids();
958 assert_eq!(active_ids2.len(), 4); }
960
961 #[rstest]
962 fn test_apply_rebalance_cleans_buffers() {
963 let mut agg = make_multi_strike_aggregator();
964 set_atm_via_greeks(&mut agg, 50000.0);
966 let action = agg.check_rebalance(now()).unwrap();
967 agg.apply_rebalance(&action, now());
968
969 let call_47500 = InstrumentId::from("BTC-20240101-47500-C.DERIBIT");
971 let quote = make_quote(call_47500, "100.00", "101.00");
972 agg.update_quote("e);
973 assert_eq!(agg.call_buffer_len(), 1);
974
975 set_atm_via_greeks(&mut agg, 55000.0);
977 let action2 = agg.check_rebalance(now()).unwrap();
978 agg.apply_rebalance(&action2, now());
979
980 assert_eq!(agg.call_buffer_len(), 0);
982 }
983
984 #[rstest]
985 fn test_initial_active_set_empty_when_no_atm() {
986 let agg = make_multi_strike_aggregator();
987 assert_eq!(agg.instrument_ids().len(), 0);
989 assert_eq!(agg.all_instrument_ids().len(), 10);
990 }
991
992 #[rstest]
993 fn test_catalog_vs_active_separation() {
994 let mut agg = make_multi_strike_aggregator();
995 set_atm_via_greeks(&mut agg, 50000.0);
997 let action = agg.check_rebalance(now()).unwrap();
998 agg.apply_rebalance(&action, now());
999
1000 assert_eq!(agg.instruments().len(), 10);
1002 assert_eq!(agg.instrument_ids().len(), 6);
1004 }
1005
1006 #[rstest]
1009 fn test_add_instrument_already_known() {
1010 let (mut agg, call_id, _) = make_aggregator();
1011 let strike = Price::from("50000");
1012 let count_before = agg.instruments().len();
1013
1014 let result = agg.add_instrument(call_id, strike, OptionKind::Call);
1015
1016 assert!(!result);
1017 assert_eq!(agg.instruments().len(), count_before);
1018 }
1019
1020 #[rstest]
1021 fn test_add_instrument_new_in_active_range() {
1022 let (mut agg, _, _) = make_aggregator();
1023 let new_id = InstrumentId::from("BTC-20240101-50000-C2.DERIBIT");
1025 let strike = Price::from("50000");
1026
1027 let result = agg.add_instrument(new_id, strike, OptionKind::Call);
1028
1029 assert!(result);
1030 assert_eq!(agg.instruments().len(), 3);
1031 assert!(agg.active_ids().contains(&new_id));
1032 }
1033
1034 #[rstest]
1035 fn test_add_instrument_new_out_of_range() {
1036 let (mut agg, _, _) = make_aggregator();
1037 let new_id = InstrumentId::from("BTC-20240101-60000-C.DERIBIT");
1039 let strike = Price::from("60000");
1040
1041 let result = agg.add_instrument(new_id, strike, OptionKind::Call);
1042
1043 assert!(result);
1044 assert_eq!(agg.instruments().len(), 3);
1045 assert!(!agg.active_ids().contains(&new_id));
1046 }
1047
1048 #[rstest]
1049 fn test_add_instrument_available_for_rebalance() {
1050 let mut agg = make_multi_strike_aggregator();
1051 set_atm_via_greeks(&mut agg, 50000.0);
1053 let action = agg.check_rebalance(now()).unwrap();
1054 agg.apply_rebalance(&action, now());
1055 assert_eq!(agg.instrument_ids().len(), 6);
1057
1058 let new_id = InstrumentId::from("BTC-20240101-57500-C.DERIBIT");
1060 let strike = Price::from("57500");
1061 let result = agg.add_instrument(new_id, strike, OptionKind::Call);
1062 assert!(result);
1063 assert!(!agg.active_ids().contains(&new_id));
1064
1065 set_atm_via_greeks(&mut agg, 57500.0);
1067 let action2 = agg.check_rebalance(now()).unwrap();
1068 agg.apply_rebalance(&action2, now());
1069
1070 assert!(agg.active_ids().contains(&new_id));
1071 }
1072
1073 #[rstest]
1076 fn test_hysteresis_blocks_small_movement() {
1077 let strikes = [47500, 50000, 52500];
1078 let mut instruments = HashMap::new();
1079
1080 for s in &strikes {
1081 let strike = Price::from(&s.to_string());
1082 let call_id = InstrumentId::from(&format!("BTC-20240101-{s}-C.DERIBIT"));
1083 instruments.insert(call_id, (strike, OptionKind::Call));
1084 }
1085 let tracker = AtmTracker::new();
1086 let mut agg = OptionChainAggregator::new(
1087 make_series_id(),
1088 StrikeRange::AtmRelative {
1089 strikes_above: 1,
1090 strikes_below: 1,
1091 },
1092 tracker,
1093 instruments,
1094 );
1095 agg.set_hysteresis(0.6);
1096 agg.set_cooldown_ns(DurationNanos::default());
1097
1098 set_atm_via_greeks(&mut agg, 50000.0);
1100 let action = agg.check_rebalance(now()).unwrap();
1101 agg.apply_rebalance(&action, now());
1102 assert_eq!(agg.last_atm_strike(), Some(Price::from("50000")));
1103
1104 set_atm_via_greeks(&mut agg, 51000.0);
1107 assert!(agg.check_rebalance(now()).is_none());
1108 }
1109
1110 #[rstest]
1111 fn test_hysteresis_allows_large_movement() {
1112 let strikes = [47500, 50000, 52500];
1113 let mut instruments = HashMap::new();
1114
1115 for s in &strikes {
1116 let strike = Price::from(&s.to_string());
1117 let call_id = InstrumentId::from(&format!("BTC-20240101-{s}-C.DERIBIT"));
1118 instruments.insert(call_id, (strike, OptionKind::Call));
1119 }
1120 let tracker = AtmTracker::new();
1121 let mut agg = OptionChainAggregator::new(
1122 make_series_id(),
1123 StrikeRange::AtmRelative {
1124 strikes_above: 1,
1125 strikes_below: 1,
1126 },
1127 tracker,
1128 instruments,
1129 );
1130 agg.set_hysteresis(0.6);
1131 agg.set_cooldown_ns(DurationNanos::default());
1132
1133 set_atm_via_greeks(&mut agg, 50000.0);
1135 let action = agg.check_rebalance(now()).unwrap();
1136 agg.apply_rebalance(&action, now());
1137
1138 set_atm_via_greeks(&mut agg, 52000.0);
1140 assert!(agg.check_rebalance(now()).is_some());
1141 }
1142
1143 #[rstest]
1144 fn test_hysteresis_exact_strike_gap_boundary() {
1145 let lower = Price::from("9007199253.999000000");
1146 let upper = Price::from("9007199253.999002800");
1147 let blocked = Price::from("9007199253.999001673");
1148 let allowed = Price::from("9007199253.999001680");
1149 let mut instruments = HashMap::new();
1150 instruments.insert(
1151 InstrumentId::from("BTC-LOW-C.DERIBIT"),
1152 (lower, OptionKind::Call),
1153 );
1154 instruments.insert(
1155 InstrumentId::from("BTC-HIGH-C.DERIBIT"),
1156 (upper, OptionKind::Call),
1157 );
1158 let mut tracker = AtmTracker::new();
1159 tracker.set_initial_price(lower);
1160 let mut agg = OptionChainAggregator::new(
1161 make_series_id(),
1162 StrikeRange::AtmRelative {
1163 strikes_above: 0,
1164 strikes_below: 0,
1165 },
1166 tracker,
1167 instruments,
1168 );
1169 agg.set_hysteresis(0.6);
1170 agg.set_cooldown_ns(DurationNanos::default());
1171
1172 agg.atm_tracker_mut().set_initial_price(blocked);
1173 assert!(agg.check_rebalance(now()).is_none());
1174
1175 agg.atm_tracker_mut().set_initial_price(allowed);
1176 assert!(agg.check_rebalance(now()).is_some());
1177 }
1178
1179 #[rstest]
1180 fn test_zero_hysteresis_disables_guard() {
1181 let mut agg = make_multi_strike_aggregator();
1182 agg.set_hysteresis(0.0);
1183 agg.set_cooldown_ns(DurationNanos::default());
1184
1185 set_atm_via_greeks(&mut agg, 50000.0);
1186 let action = agg.check_rebalance(now()).unwrap();
1187 agg.apply_rebalance(&action, now());
1188
1189 set_atm_via_greeks(&mut agg, 52500.0);
1191 assert!(agg.check_rebalance(now()).is_some());
1192 }
1193
1194 #[rstest]
1197 fn test_cooldown_blocks_rapid_rebalance() {
1198 let mut agg = make_multi_strike_aggregator();
1199 agg.set_hysteresis(0.0);
1200 agg.set_cooldown_ns(DurationNanos::from_secs(5));
1201
1202 set_atm_via_greeks(&mut agg, 50000.0);
1203 let t0 = now();
1204 let action = agg.check_rebalance(t0).unwrap();
1205 agg.apply_rebalance(&action, t0);
1206
1207 set_atm_via_greeks(&mut agg, 55000.0);
1209 let t1 = UnixNanos::from(t0.as_u64() + 1_000_000_000); assert!(agg.check_rebalance(t1).is_none());
1211 }
1212
1213 #[rstest]
1214 fn test_cooldown_allows_after_elapsed() {
1215 let mut agg = make_multi_strike_aggregator();
1216 agg.set_hysteresis(0.0);
1217 agg.set_cooldown_ns(DurationNanos::from_secs(5));
1218
1219 set_atm_via_greeks(&mut agg, 50000.0);
1220 let t0 = now();
1221 let action = agg.check_rebalance(t0).unwrap();
1222 agg.apply_rebalance(&action, t0);
1223
1224 set_atm_via_greeks(&mut agg, 55000.0);
1226 let t1 = UnixNanos::from(t0.as_u64() + 6_000_000_000); assert!(agg.check_rebalance(t1).is_some());
1228 }
1229
1230 #[rstest]
1231 fn test_zero_cooldown_disables_guard() {
1232 let mut agg = make_multi_strike_aggregator();
1233 agg.set_hysteresis(0.0);
1234 agg.set_cooldown_ns(DurationNanos::default());
1235
1236 set_atm_via_greeks(&mut agg, 50000.0);
1237 let t0 = now();
1238 let action = agg.check_rebalance(t0).unwrap();
1239 agg.apply_rebalance(&action, t0);
1240
1241 set_atm_via_greeks(&mut agg, 55000.0);
1243 assert!(agg.check_rebalance(t0).is_some());
1244 }
1245
1246 #[rstest]
1249 fn test_pending_greeks_consumed_on_first_quote() {
1250 let (mut agg, call_id, _) = make_aggregator();
1251
1252 let greeks = OptionGreeks {
1254 instrument_id: call_id,
1255 greeks: OptionGreekValues {
1256 delta: 0.55,
1257 ..Default::default()
1258 },
1259 ..Default::default()
1260 };
1261 agg.update_greeks(&greeks);
1262 assert_eq!(agg.pending_greeks_count(), 1);
1263
1264 let quote = make_quote(call_id, "100.00", "101.00");
1266 agg.update_quote("e);
1267 assert_eq!(agg.pending_greeks_count(), 0);
1268
1269 let strike = Price::from("50000");
1271 let data = agg.get_call_greeks_from_buffer(&strike);
1272 assert!(data.is_some());
1273 assert_eq!(data.unwrap().delta, 0.55);
1274 }
1275
1276 #[rstest]
1279 fn test_snapshot_ts_event_reflects_max_quote_timestamp() {
1280 let (mut agg, call_id, put_id) = make_aggregator();
1281
1282 let quote1 = QuoteTick::new(
1283 call_id,
1284 Price::from("100.00"),
1285 Price::from("101.00"),
1286 Quantity::from("1.0"),
1287 Quantity::from("1.0"),
1288 UnixNanos::from(500u64), UnixNanos::from(500u64),
1290 );
1291 agg.update_quote("e1);
1292
1293 let quote2 = QuoteTick::new(
1294 put_id,
1295 Price::from("50.00"),
1296 Price::from("51.00"),
1297 Quantity::from("1.0"),
1298 Quantity::from("1.0"),
1299 UnixNanos::from(800u64), UnixNanos::from(800u64),
1301 );
1302 agg.update_quote("e2);
1303
1304 let slice = agg.snapshot(UnixNanos::from(1000u64));
1305 assert_eq!(slice.ts_event, UnixNanos::from(800u64));
1306 assert_eq!(slice.ts_init, UnixNanos::from(1000u64));
1307 }
1308
1309 #[rstest]
1310 fn test_snapshot_ts_event_fallback_when_no_quotes() {
1311 let (agg, _, _) = make_aggregator();
1312 let slice = agg.snapshot(UnixNanos::from(1000u64));
1313 assert_eq!(slice.ts_event, UnixNanos::from(1000u64));
1315 }
1316
1317 #[rstest]
1318 fn test_snapshot_retains_buffered_data_during_hysteresis_window() {
1319 let strikes = [47500, 50000, 52500];
1321 let mut instruments = HashMap::new();
1322
1323 for s in &strikes {
1324 let strike = Price::from(&s.to_string());
1325 let call_id = InstrumentId::from(&format!("BTC-20240101-{s}-C.DERIBIT"));
1326 instruments.insert(call_id, (strike, OptionKind::Call));
1327 }
1328 let tracker = AtmTracker::new();
1329 let mut agg = OptionChainAggregator::new(
1330 make_series_id(),
1331 StrikeRange::AtmRelative {
1332 strikes_above: 1,
1333 strikes_below: 1,
1334 },
1335 tracker,
1336 instruments,
1337 );
1338 agg.set_hysteresis(0.6);
1339 agg.set_cooldown_ns(DurationNanos::default());
1340
1341 set_atm_via_greeks(&mut agg, 50000.0);
1343 let action = agg.check_rebalance(now()).unwrap();
1344 agg.apply_rebalance(&action, now());
1345 assert_eq!(agg.instrument_ids().len(), 3);
1346
1347 let q1 = make_quote(
1349 InstrumentId::from("BTC-20240101-47500-C.DERIBIT"),
1350 "3000.00",
1351 "3100.00",
1352 );
1353 let q2 = make_quote(
1354 InstrumentId::from("BTC-20240101-50000-C.DERIBIT"),
1355 "1500.00",
1356 "1600.00",
1357 );
1358 let q3 = make_quote(
1359 InstrumentId::from("BTC-20240101-52500-C.DERIBIT"),
1360 "500.00",
1361 "600.00",
1362 );
1363 agg.update_quote(&q1);
1364 agg.update_quote(&q2);
1365 agg.update_quote(&q3);
1366 assert_eq!(agg.call_buffer_len(), 3);
1367
1368 set_atm_via_greeks(&mut agg, 51000.0);
1370 assert!(agg.check_rebalance(now()).is_none());
1371
1372 let slice = agg.snapshot(UnixNanos::from(100u64));
1374 assert_eq!(slice.call_count(), 3);
1375 }
1376
1377 #[rstest]
1378 fn test_remove_instrument_from_catalog() {
1379 let (mut agg, call_id, put_id) = make_aggregator();
1380 assert_eq!(agg.instruments().len(), 2);
1381
1382 let removed = agg.remove_instrument(&call_id);
1383 assert!(removed);
1384 assert_eq!(agg.instruments().len(), 1);
1385 assert!(!agg.active_ids().contains(&call_id));
1386 assert!(agg.instruments().contains_key(&put_id));
1387 }
1388
1389 #[rstest]
1390 fn test_remove_instrument_cleans_buffer() {
1391 let (mut agg, call_id, _) = make_aggregator();
1392 let quote = make_quote(call_id, "100.00", "101.00");
1393 agg.update_quote("e);
1394 assert_eq!(agg.call_buffer_len(), 1);
1395
1396 let _ = agg.remove_instrument(&call_id);
1397 assert_eq!(agg.call_buffer_len(), 0);
1399 }
1400
1401 #[rstest]
1402 fn test_remove_instrument_preserves_sibling_buffer() {
1403 let (mut agg, call_id, _) = make_aggregator();
1404 let sibling_id = InstrumentId::from("BTC-20240101-50000-C2.DERIBIT");
1406 let strike = Price::from("50000");
1407 let _ = agg.add_instrument(sibling_id, strike, OptionKind::Call);
1408
1409 let quote = make_quote(call_id, "100.00", "101.00");
1410 agg.update_quote("e);
1411 assert_eq!(agg.call_buffer_len(), 1);
1412
1413 let _ = agg.remove_instrument(&call_id);
1415 assert_eq!(agg.call_buffer_len(), 1); assert!(agg.instruments().contains_key(&sibling_id));
1417 }
1418
1419 #[rstest]
1420 fn test_remove_instrument_unknown_noop() {
1421 let (mut agg, _, _) = make_aggregator();
1422 let unknown = InstrumentId::from("ETH-20240101-3000-C.DERIBIT");
1423 assert!(!agg.remove_instrument(&unknown));
1424 assert_eq!(agg.instruments().len(), 2);
1425 }
1426
1427 #[rstest]
1428 fn test_remove_instrument_cleans_pending_greeks() {
1429 let (mut agg, call_id, _) = make_aggregator();
1430 let greeks = OptionGreeks {
1431 instrument_id: call_id,
1432 greeks: OptionGreekValues {
1433 delta: 0.55,
1434 ..Default::default()
1435 },
1436 ..Default::default()
1437 };
1438 agg.update_greeks(&greeks);
1439 assert_eq!(agg.pending_greeks_count(), 1);
1440
1441 let _ = agg.remove_instrument(&call_id);
1442 assert_eq!(agg.pending_greeks_count(), 0);
1443 }
1444
1445 #[rstest]
1446 fn test_is_catalog_empty_after_full_removal() {
1447 let (mut agg, call_id, put_id) = make_aggregator();
1448 assert!(!agg.is_catalog_empty());
1449
1450 let _ = agg.remove_instrument(&call_id);
1451 assert!(!agg.is_catalog_empty());
1452
1453 let _ = agg.remove_instrument(&put_id);
1454 assert!(agg.is_catalog_empty());
1455 }
1456
1457 #[rstest]
1460 fn test_expired_quote_is_dropped() {
1461 let (mut agg, call_id, _) = make_aggregator();
1462 let expired_quote = QuoteTick::new(
1464 call_id,
1465 Price::from("100.00"),
1466 Price::from("101.00"),
1467 Quantity::from("1.0"),
1468 Quantity::from("1.0"),
1469 UnixNanos::from(1_700_000_000_000_000_000u64),
1470 UnixNanos::from(1_700_000_000_000_000_000u64),
1471 );
1472 agg.update_quote(&expired_quote);
1473 assert!(agg.is_buffer_empty());
1474 }
1475
1476 #[rstest]
1477 fn test_expired_greeks_are_dropped() {
1478 let (mut agg, call_id, _) = make_aggregator();
1479 let quote = make_quote(call_id, "100.00", "101.00");
1481 agg.update_quote("e);
1482 assert_eq!(agg.call_buffer_len(), 1);
1483
1484 let greeks = OptionGreeks {
1486 instrument_id: call_id,
1487 ts_event: UnixNanos::from(1_700_000_000_000_000_000u64),
1488 greeks: OptionGreekValues {
1489 delta: 0.55,
1490 ..Default::default()
1491 },
1492 ..Default::default()
1493 };
1494 agg.update_greeks(&greeks);
1495
1496 let strike = Price::from("50000");
1497 assert!(agg.get_call_greeks_from_buffer(&strike).is_none());
1498 }
1499
1500 fn make_delta_aggregator(
1506 strikes: &[i64],
1507 target: f64,
1508 tolerance: f64,
1509 ) -> OptionChainAggregator {
1510 let mut instruments = HashMap::new();
1511
1512 for s in strikes {
1513 let strike = Price::from(&s.to_string());
1514 instruments.insert(option_id(*s, OptionKind::Call), (strike, OptionKind::Call));
1515 instruments.insert(option_id(*s, OptionKind::Put), (strike, OptionKind::Put));
1516 }
1517 let tracker = AtmTracker::new();
1518 let mut agg = OptionChainAggregator::new(
1519 make_series_id(),
1520 StrikeRange::Delta { target, tolerance },
1521 tracker,
1522 instruments,
1523 );
1524 agg.set_hysteresis(0.0);
1525 agg.set_cooldown_ns(DurationNanos::default());
1526 agg
1527 }
1528
1529 fn option_id(strike: i64, kind: OptionKind) -> InstrumentId {
1530 let suffix = match kind {
1531 OptionKind::Call => "C",
1532 OptionKind::Put => "P",
1533 };
1534 InstrumentId::from(&format!("BTC-20240101-{strike}-{suffix}.DERIBIT"))
1535 }
1536
1537 fn feed_quote_and_greeks(
1539 agg: &mut OptionChainAggregator,
1540 strike: i64,
1541 kind: OptionKind,
1542 delta: f64,
1543 ) {
1544 let id = option_id(strike, kind);
1545 agg.update_quote(&make_quote(id, "100.00", "101.00"));
1546 agg.update_greeks(&OptionGreeks {
1547 instrument_id: id,
1548 greeks: OptionGreekValues {
1549 delta,
1550 ..Default::default()
1551 },
1552 ..Default::default()
1553 });
1554 }
1555
1556 #[rstest]
1557 #[case(0.30, 0.30, 0.03, true)] #[case(-0.30, 0.30, 0.03, true)] #[case(0.28, 0.30, 0.03, true)] #[case(0.32, 0.30, 0.03, true)] #[case(0.20, 0.30, 0.03, false)] #[case(0.40, 0.30, 0.03, false)] fn test_delta_within_band(
1564 #[case] delta: f64,
1565 #[case] target: f64,
1566 #[case] tolerance: f64,
1567 #[case] expected: bool,
1568 ) {
1569 assert_eq!(
1570 OptionChainAggregator::delta_within_band(delta, target, tolerance),
1571 expected
1572 );
1573 }
1574
1575 #[rstest]
1576 fn test_delta_target_hit() {
1577 let strikes = [40000, 45000, 50000, 55000, 60000];
1578 let mut agg = make_delta_aggregator(&strikes, 0.30, 0.03);
1579 set_atm_via_greeks(&mut agg, 50000.0);
1581 agg.recompute_active_set();
1582 assert_eq!(agg.instrument_ids().len(), 10); feed_quote_and_greeks(&mut agg, 40000, OptionKind::Call, 0.95);
1586 feed_quote_and_greeks(&mut agg, 40000, OptionKind::Put, -0.95);
1587 feed_quote_and_greeks(&mut agg, 45000, OptionKind::Call, 0.80);
1588 feed_quote_and_greeks(&mut agg, 45000, OptionKind::Put, -0.80);
1589 feed_quote_and_greeks(&mut agg, 50000, OptionKind::Call, 0.55);
1590 feed_quote_and_greeks(&mut agg, 50000, OptionKind::Put, -0.55);
1591 feed_quote_and_greeks(&mut agg, 55000, OptionKind::Call, 0.30);
1592 feed_quote_and_greeks(&mut agg, 55000, OptionKind::Put, -0.12);
1593 feed_quote_and_greeks(&mut agg, 60000, OptionKind::Call, 0.12);
1594 feed_quote_and_greeks(&mut agg, 60000, OptionKind::Put, -0.10);
1595
1596 let active = agg.recompute_active_set();
1597 assert_eq!(active.len(), 2); assert!(active.contains(&option_id(55000, OptionKind::Call)));
1599 assert!(active.contains(&option_id(55000, OptionKind::Put)));
1600 }
1601
1602 #[rstest]
1603 fn test_delta_tolerance_band() {
1604 let strikes = [45000, 50000, 55000, 60000];
1605 let mut agg = make_delta_aggregator(&strikes, 0.30, 0.05);
1606 set_atm_via_greeks(&mut agg, 50000.0);
1607 agg.recompute_active_set();
1608
1609 feed_quote_and_greeks(&mut agg, 45000, OptionKind::Call, 0.50);
1611 feed_quote_and_greeks(&mut agg, 45000, OptionKind::Put, -0.50);
1612 feed_quote_and_greeks(&mut agg, 50000, OptionKind::Call, 0.32);
1613 feed_quote_and_greeks(&mut agg, 50000, OptionKind::Put, -0.50);
1614 feed_quote_and_greeks(&mut agg, 55000, OptionKind::Call, 0.30);
1615 feed_quote_and_greeks(&mut agg, 55000, OptionKind::Put, -0.50);
1616 feed_quote_and_greeks(&mut agg, 60000, OptionKind::Call, 0.10);
1617 feed_quote_and_greeks(&mut agg, 60000, OptionKind::Put, -0.10);
1618
1619 let active = agg.recompute_active_set();
1620 assert_eq!(active.len(), 4); assert!(active.contains(&option_id(50000, OptionKind::Call)));
1622 assert!(active.contains(&option_id(55000, OptionKind::Call)));
1623 assert!(!active.contains(&option_id(45000, OptionKind::Call)));
1624 assert!(!active.contains(&option_id(60000, OptionKind::Call)));
1625 }
1626
1627 #[rstest]
1628 fn test_delta_matches_put_by_magnitude() {
1629 let strikes = [45000, 50000, 55000];
1630 let mut agg = make_delta_aggregator(&strikes, 0.30, 0.03);
1631 set_atm_via_greeks(&mut agg, 50000.0);
1632 agg.recompute_active_set();
1633
1634 feed_quote_and_greeks(&mut agg, 45000, OptionKind::Call, 0.55);
1636 feed_quote_and_greeks(&mut agg, 45000, OptionKind::Put, -0.30);
1637 feed_quote_and_greeks(&mut agg, 50000, OptionKind::Call, 0.55);
1638 feed_quote_and_greeks(&mut agg, 50000, OptionKind::Put, -0.55);
1639 feed_quote_and_greeks(&mut agg, 55000, OptionKind::Call, 0.55);
1640 feed_quote_and_greeks(&mut agg, 55000, OptionKind::Put, -0.55);
1641
1642 let active = agg.recompute_active_set();
1643 assert_eq!(active.len(), 2);
1645 assert!(active.contains(&option_id(45000, OptionKind::Put)));
1646 assert!(active.contains(&option_id(45000, OptionKind::Call)));
1647 }
1648
1649 #[rstest]
1650 fn test_delta_no_greeks_falls_back_to_atm_window() {
1651 let strikes: Vec<i64> = (0..13).map(|i| 40000 + i * 1000).collect();
1653 let mut agg = make_delta_aggregator(&strikes, 0.25, 0.05);
1654 set_atm_via_greeks(&mut agg, 46000.0); let active = agg.recompute_active_set();
1657
1658 assert!(active.len() > 2);
1661 assert!(active.len() < strikes.len() * 2);
1662 assert!(active.contains(&option_id(46000, OptionKind::Call))); assert!(!active.contains(&option_id(40000, OptionKind::Call))); assert!(!active.contains(&option_id(52000, OptionKind::Call))); }
1666
1667 #[rstest]
1668 fn test_delta_pending_only_greeks_eligible() {
1669 let strikes = [45000, 50000, 55000];
1670 let mut agg = make_delta_aggregator(&strikes, 0.30, 0.03);
1671 set_atm_via_greeks(&mut agg, 50000.0);
1672 agg.recompute_active_set();
1673
1674 feed_quote_and_greeks(&mut agg, 45000, OptionKind::Call, 0.55);
1675 feed_quote_and_greeks(&mut agg, 45000, OptionKind::Put, -0.55);
1676 feed_quote_and_greeks(&mut agg, 50000, OptionKind::Call, 0.55);
1677 feed_quote_and_greeks(&mut agg, 50000, OptionKind::Put, -0.55);
1678 feed_quote_and_greeks(&mut agg, 55000, OptionKind::Put, -0.55);
1679
1680 agg.update_greeks(&OptionGreeks {
1682 instrument_id: option_id(55000, OptionKind::Call),
1683 greeks: OptionGreekValues {
1684 delta: 0.30,
1685 ..Default::default()
1686 },
1687 ..Default::default()
1688 });
1689 assert_eq!(agg.pending_greeks_count(), 1);
1690
1691 let active = agg.recompute_active_set();
1692 assert_eq!(active.len(), 2);
1694 assert!(active.contains(&option_id(55000, OptionKind::Call)));
1695 }
1696
1697 #[rstest]
1698 fn test_delta_waits_for_fallback_window_greeks_before_narrowing() {
1699 let strikes = [45000, 50000, 55000];
1700 let mut agg = make_delta_aggregator(&strikes, 0.30, 0.03);
1701 set_atm_via_greeks(&mut agg, 50000.0);
1702 agg.recompute_active_set();
1703 assert_eq!(agg.instrument_ids().len(), 6); feed_quote_and_greeks(&mut agg, 55000, OptionKind::Call, 0.30);
1706
1707 assert!(agg.check_rebalance(now()).is_none());
1708 assert_eq!(agg.instrument_ids().len(), 6);
1709 }
1710
1711 #[rstest]
1712 fn test_delta_waits_when_fallback_window_shifts_during_warmup() {
1713 let strikes: Vec<i64> = (0..13).map(|i| 40000 + i * 1000).collect();
1714 let mut agg = make_delta_aggregator(&strikes, 0.30, 0.03);
1715 set_atm_via_greeks(&mut agg, 46000.0);
1716 agg.recompute_active_set();
1717 assert!(
1718 agg.active_ids()
1719 .contains(&option_id(42000, OptionKind::Call))
1720 );
1721 assert!(
1722 agg.active_ids()
1723 .contains(&option_id(51000, OptionKind::Put))
1724 );
1725
1726 feed_quote_and_greeks(&mut agg, 50000, OptionKind::Call, 0.30);
1727 set_atm_via_greeks(&mut agg, 47000.0);
1728
1729 let action = agg
1730 .check_rebalance(now())
1731 .expect("fallback window shift should rebalance active legs");
1732
1733 assert!(action.add.contains(&option_id(52000, OptionKind::Call)));
1734 assert!(!action.remove.contains(&option_id(42000, OptionKind::Call)));
1735 assert!(!action.remove.contains(&option_id(51000, OptionKind::Put)));
1736 }
1737
1738 #[rstest]
1739 fn test_delta_rebalances_on_greeks_with_atm_unchanged() {
1740 let strikes = [45000, 50000, 55000];
1741 let mut agg = make_delta_aggregator(&strikes, 0.30, 0.03);
1742 set_atm_via_greeks(&mut agg, 50000.0);
1743 agg.recompute_active_set();
1744 assert_eq!(agg.last_atm_strike(), Some(Price::from("50000")));
1745 assert_eq!(agg.instrument_ids().len(), 6); feed_quote_and_greeks(&mut agg, 45000, OptionKind::Call, 0.55);
1749 feed_quote_and_greeks(&mut agg, 45000, OptionKind::Put, -0.55);
1750 feed_quote_and_greeks(&mut agg, 50000, OptionKind::Call, 0.45);
1751 feed_quote_and_greeks(&mut agg, 50000, OptionKind::Put, -0.45);
1752 feed_quote_and_greeks(&mut agg, 55000, OptionKind::Call, 0.30);
1753 feed_quote_and_greeks(&mut agg, 55000, OptionKind::Put, -0.12);
1754
1755 let action = agg
1756 .check_rebalance(now())
1757 .expect("delta range should rebalance when greeks narrow the set");
1758 assert!(action.add.is_empty());
1759 assert!(!action.remove.is_empty());
1760
1761 agg.apply_rebalance(&action, now());
1762 assert_eq!(agg.instrument_ids().len(), 2); assert!(
1764 agg.active_ids()
1765 .contains(&option_id(55000, OptionKind::Call))
1766 );
1767 }
1768
1769 #[rstest]
1770 fn test_delta_no_op_rebalance_returns_none() {
1771 let strikes = [45000, 50000, 55000];
1772 let mut agg = make_delta_aggregator(&strikes, 0.30, 0.03);
1773 set_atm_via_greeks(&mut agg, 50000.0);
1774 agg.recompute_active_set();
1775 feed_quote_and_greeks(&mut agg, 45000, OptionKind::Call, 0.55);
1776 feed_quote_and_greeks(&mut agg, 45000, OptionKind::Put, -0.55);
1777 feed_quote_and_greeks(&mut agg, 50000, OptionKind::Call, 0.45);
1778 feed_quote_and_greeks(&mut agg, 50000, OptionKind::Put, -0.45);
1779 feed_quote_and_greeks(&mut agg, 55000, OptionKind::Call, 0.30);
1780 feed_quote_and_greeks(&mut agg, 55000, OptionKind::Put, -0.12);
1781
1782 let action = agg.check_rebalance(now()).unwrap();
1784 agg.apply_rebalance(&action, now());
1785 assert_eq!(agg.instrument_ids().len(), 2);
1786
1787 assert!(agg.check_rebalance(now()).is_none());
1789 }
1790}