1use std::{
19 cmp::Ordering,
20 collections::{BTreeMap, HashMap, HashSet},
21};
22
23use nautilus_core::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_NS, 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: u64,
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_NS,
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 > 0
593 && let Some(last_ts) = self.last_rebalance_ns
594 && now_ns.as_u64().saturating_sub(last_ts.as_u64()) < 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: u64) {
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_prefers_exact_high_value_match() {
762 let collapsed = Price::from("9007199253.999000000");
763 let atm = Price::from("9007199253.999000001");
764 let strikes = [collapsed, atm];
765 assert_eq!(collapsed.as_f64(), atm.as_f64());
766
767 let result = OptionChainAggregator::find_closest_strike(&strikes, atm);
768
769 assert_eq!(result, Some(atm));
770 }
771
772 #[rstest]
773 fn test_aggregator_instrument_ids() {
774 let (agg, call_id, put_id) = make_aggregator();
775 let ids = agg.instrument_ids();
776 assert_eq!(ids.len(), 2);
777 assert!(ids.contains(&call_id));
778 assert!(ids.contains(&put_id));
779 }
780
781 #[rstest]
782 fn test_aggregator_update_quote() {
783 let (mut agg, call_id, _) = make_aggregator();
784 let quote = make_quote(call_id, "100.00", "101.00");
785
786 agg.update_quote("e);
787
788 assert_eq!(agg.call_buffer_len(), 1);
789 assert_eq!(agg.put_buffer_len(), 0);
790 }
791
792 #[rstest]
793 fn test_aggregator_update_greeks() {
794 let (mut agg, call_id, _) = make_aggregator();
795 let quote = make_quote(call_id, "100.00", "101.00");
796 agg.update_quote("e);
797
798 let greeks = OptionGreeks {
799 instrument_id: call_id,
800 greeks: OptionGreekValues {
801 delta: 0.55,
802 ..Default::default()
803 },
804 ..Default::default()
805 };
806 agg.update_greeks(&greeks);
807
808 let strike = Price::from("50000");
809 let data = agg.get_call_greeks_from_buffer(&strike);
810 assert!(data.is_some());
811 assert_eq!(data.unwrap().delta, 0.55);
812 }
813
814 #[rstest]
815 fn test_aggregator_snapshot_preserves_state() {
816 let (mut agg, call_id, _) = make_aggregator();
817 let quote = make_quote(call_id, "100.00", "101.00");
818 agg.update_quote("e);
819
820 let slice = agg.snapshot(UnixNanos::from(100u64));
821 assert_eq!(slice.call_count(), 1);
822 assert_eq!(slice.ts_init, UnixNanos::from(100u64));
823
824 assert!(!agg.is_buffer_empty());
826
827 let slice2 = agg.snapshot(UnixNanos::from(200u64));
829 assert_eq!(slice2.call_count(), 1);
830 assert_eq!(slice2.ts_init, UnixNanos::from(200u64));
831 }
832
833 #[rstest]
834 fn test_aggregator_ignores_unknown_instrument() {
835 let (mut agg, _, _) = make_aggregator();
836 let unknown_id = InstrumentId::from("ETH-20240101-3000-C.DERIBIT");
837 let quote = make_quote(unknown_id, "100.00", "101.00");
838
839 agg.update_quote("e);
840
841 assert!(agg.is_buffer_empty());
842 }
843
844 #[rstest]
845 fn test_check_rebalance_returns_none() {
846 let (agg, _, _) = make_aggregator();
847 assert!(agg.check_rebalance(now()).is_none());
848 }
849
850 fn make_multi_strike_aggregator() -> OptionChainAggregator {
855 let strikes = [45000, 47500, 50000, 52500, 55000];
856 let mut instruments = HashMap::new();
857
858 for s in &strikes {
859 let strike = Price::from(&s.to_string());
860 let call_id = InstrumentId::from(&format!("BTC-20240101-{s}-C.DERIBIT"));
861 let put_id = InstrumentId::from(&format!("BTC-20240101-{s}-P.DERIBIT"));
862 instruments.insert(call_id, (strike, OptionKind::Call));
863 instruments.insert(put_id, (strike, OptionKind::Put));
864 }
865
866 let tracker = AtmTracker::new();
867 let mut agg = OptionChainAggregator::new(
868 make_series_id(),
869 StrikeRange::AtmRelative {
870 strikes_above: 1,
871 strikes_below: 1,
872 },
873 tracker,
874 instruments,
875 );
876 agg.set_hysteresis(0.0);
878 agg.set_cooldown_ns(0);
879 agg
880 }
881
882 #[rstest]
883 fn test_check_rebalance_fixed_always_none() {
884 let (mut agg, _, _) = make_aggregator();
886 set_atm_via_greeks(&mut agg, 50000.0);
887 assert!(agg.check_rebalance(now()).is_none());
888 }
889
890 #[rstest]
891 fn test_check_rebalance_no_atm_returns_none() {
892 let agg = make_multi_strike_aggregator();
893 assert!(agg.check_rebalance(now()).is_none());
895 }
896
897 #[rstest]
898 fn test_check_rebalance_atm_unchanged_returns_none() {
899 let mut agg = make_multi_strike_aggregator();
900 set_atm_via_greeks(&mut agg, 50000.0);
902 let action = agg.check_rebalance(now()).unwrap();
904 agg.apply_rebalance(&action, now());
905
906 set_atm_via_greeks(&mut agg, 50200.0);
908 assert!(agg.check_rebalance(now()).is_none());
909 }
910
911 #[rstest]
912 fn test_check_rebalance_detects_atm_shift() {
913 let mut agg = make_multi_strike_aggregator();
914 set_atm_via_greeks(&mut agg, 50000.0);
916 let action = agg.check_rebalance(now()).unwrap();
917 agg.apply_rebalance(&action, now());
918 assert_eq!(agg.instrument_ids().len(), 6); set_atm_via_greeks(&mut agg, 55000.0);
923 let action2 = agg.check_rebalance(now()).unwrap();
924 assert!(!action2.add.is_empty() || !action2.remove.is_empty());
926 }
927
928 #[rstest]
929 fn test_apply_rebalance_updates_instrument_map() {
930 let mut agg = make_multi_strike_aggregator();
931 set_atm_via_greeks(&mut agg, 50000.0);
933 let action = agg.check_rebalance(now()).unwrap();
934 agg.apply_rebalance(&action, now());
935
936 let active_ids = agg.instrument_ids();
938 assert_eq!(active_ids.len(), 6); set_atm_via_greeks(&mut agg, 55000.0);
942 let action2 = agg.check_rebalance(now()).unwrap();
943 agg.apply_rebalance(&action2, now());
944
945 let active_ids2 = agg.instrument_ids();
947 assert_eq!(active_ids2.len(), 4); }
949
950 #[rstest]
951 fn test_apply_rebalance_cleans_buffers() {
952 let mut agg = make_multi_strike_aggregator();
953 set_atm_via_greeks(&mut agg, 50000.0);
955 let action = agg.check_rebalance(now()).unwrap();
956 agg.apply_rebalance(&action, now());
957
958 let call_47500 = InstrumentId::from("BTC-20240101-47500-C.DERIBIT");
960 let quote = make_quote(call_47500, "100.00", "101.00");
961 agg.update_quote("e);
962 assert_eq!(agg.call_buffer_len(), 1);
963
964 set_atm_via_greeks(&mut agg, 55000.0);
966 let action2 = agg.check_rebalance(now()).unwrap();
967 agg.apply_rebalance(&action2, now());
968
969 assert_eq!(agg.call_buffer_len(), 0);
971 }
972
973 #[rstest]
974 fn test_initial_active_set_empty_when_no_atm() {
975 let agg = make_multi_strike_aggregator();
976 assert_eq!(agg.instrument_ids().len(), 0);
978 assert_eq!(agg.all_instrument_ids().len(), 10);
979 }
980
981 #[rstest]
982 fn test_catalog_vs_active_separation() {
983 let mut agg = make_multi_strike_aggregator();
984 set_atm_via_greeks(&mut agg, 50000.0);
986 let action = agg.check_rebalance(now()).unwrap();
987 agg.apply_rebalance(&action, now());
988
989 assert_eq!(agg.instruments().len(), 10);
991 assert_eq!(agg.instrument_ids().len(), 6);
993 }
994
995 #[rstest]
998 fn test_add_instrument_already_known() {
999 let (mut agg, call_id, _) = make_aggregator();
1000 let strike = Price::from("50000");
1001 let count_before = agg.instruments().len();
1002
1003 let result = agg.add_instrument(call_id, strike, OptionKind::Call);
1004
1005 assert!(!result);
1006 assert_eq!(agg.instruments().len(), count_before);
1007 }
1008
1009 #[rstest]
1010 fn test_add_instrument_new_in_active_range() {
1011 let (mut agg, _, _) = make_aggregator();
1012 let new_id = InstrumentId::from("BTC-20240101-50000-C2.DERIBIT");
1014 let strike = Price::from("50000");
1015
1016 let result = agg.add_instrument(new_id, strike, OptionKind::Call);
1017
1018 assert!(result);
1019 assert_eq!(agg.instruments().len(), 3);
1020 assert!(agg.active_ids().contains(&new_id));
1021 }
1022
1023 #[rstest]
1024 fn test_add_instrument_new_out_of_range() {
1025 let (mut agg, _, _) = make_aggregator();
1026 let new_id = InstrumentId::from("BTC-20240101-60000-C.DERIBIT");
1028 let strike = Price::from("60000");
1029
1030 let result = agg.add_instrument(new_id, strike, OptionKind::Call);
1031
1032 assert!(result);
1033 assert_eq!(agg.instruments().len(), 3);
1034 assert!(!agg.active_ids().contains(&new_id));
1035 }
1036
1037 #[rstest]
1038 fn test_add_instrument_available_for_rebalance() {
1039 let mut agg = make_multi_strike_aggregator();
1040 set_atm_via_greeks(&mut agg, 50000.0);
1042 let action = agg.check_rebalance(now()).unwrap();
1043 agg.apply_rebalance(&action, now());
1044 assert_eq!(agg.instrument_ids().len(), 6);
1046
1047 let new_id = InstrumentId::from("BTC-20240101-57500-C.DERIBIT");
1049 let strike = Price::from("57500");
1050 let result = agg.add_instrument(new_id, strike, OptionKind::Call);
1051 assert!(result);
1052 assert!(!agg.active_ids().contains(&new_id));
1053
1054 set_atm_via_greeks(&mut agg, 57500.0);
1056 let action2 = agg.check_rebalance(now()).unwrap();
1057 agg.apply_rebalance(&action2, now());
1058
1059 assert!(agg.active_ids().contains(&new_id));
1060 }
1061
1062 #[rstest]
1065 fn test_hysteresis_blocks_small_movement() {
1066 let strikes = [47500, 50000, 52500];
1067 let mut instruments = HashMap::new();
1068
1069 for s in &strikes {
1070 let strike = Price::from(&s.to_string());
1071 let call_id = InstrumentId::from(&format!("BTC-20240101-{s}-C.DERIBIT"));
1072 instruments.insert(call_id, (strike, OptionKind::Call));
1073 }
1074 let tracker = AtmTracker::new();
1075 let mut agg = OptionChainAggregator::new(
1076 make_series_id(),
1077 StrikeRange::AtmRelative {
1078 strikes_above: 1,
1079 strikes_below: 1,
1080 },
1081 tracker,
1082 instruments,
1083 );
1084 agg.set_hysteresis(0.6);
1085 agg.set_cooldown_ns(0);
1086
1087 set_atm_via_greeks(&mut agg, 50000.0);
1089 let action = agg.check_rebalance(now()).unwrap();
1090 agg.apply_rebalance(&action, now());
1091 assert_eq!(agg.last_atm_strike(), Some(Price::from("50000")));
1092
1093 set_atm_via_greeks(&mut agg, 51000.0);
1096 assert!(agg.check_rebalance(now()).is_none());
1097 }
1098
1099 #[rstest]
1100 fn test_hysteresis_allows_large_movement() {
1101 let strikes = [47500, 50000, 52500];
1102 let mut instruments = HashMap::new();
1103
1104 for s in &strikes {
1105 let strike = Price::from(&s.to_string());
1106 let call_id = InstrumentId::from(&format!("BTC-20240101-{s}-C.DERIBIT"));
1107 instruments.insert(call_id, (strike, OptionKind::Call));
1108 }
1109 let tracker = AtmTracker::new();
1110 let mut agg = OptionChainAggregator::new(
1111 make_series_id(),
1112 StrikeRange::AtmRelative {
1113 strikes_above: 1,
1114 strikes_below: 1,
1115 },
1116 tracker,
1117 instruments,
1118 );
1119 agg.set_hysteresis(0.6);
1120 agg.set_cooldown_ns(0);
1121
1122 set_atm_via_greeks(&mut agg, 50000.0);
1124 let action = agg.check_rebalance(now()).unwrap();
1125 agg.apply_rebalance(&action, now());
1126
1127 set_atm_via_greeks(&mut agg, 52000.0);
1129 assert!(agg.check_rebalance(now()).is_some());
1130 }
1131
1132 #[rstest]
1133 fn test_hysteresis_exact_strike_gap_boundary() {
1134 let lower = Price::from("9007199253.999000000");
1135 let upper = Price::from("9007199253.999002800");
1136 let blocked = Price::from("9007199253.999001673");
1137 let allowed = Price::from("9007199253.999001680");
1138 let mut instruments = HashMap::new();
1139 instruments.insert(
1140 InstrumentId::from("BTC-LOW-C.DERIBIT"),
1141 (lower, OptionKind::Call),
1142 );
1143 instruments.insert(
1144 InstrumentId::from("BTC-HIGH-C.DERIBIT"),
1145 (upper, OptionKind::Call),
1146 );
1147 let mut tracker = AtmTracker::new();
1148 tracker.set_initial_price(lower);
1149 let mut agg = OptionChainAggregator::new(
1150 make_series_id(),
1151 StrikeRange::AtmRelative {
1152 strikes_above: 0,
1153 strikes_below: 0,
1154 },
1155 tracker,
1156 instruments,
1157 );
1158 agg.set_hysteresis(0.6);
1159 agg.set_cooldown_ns(0);
1160
1161 agg.atm_tracker_mut().set_initial_price(blocked);
1162 assert!(agg.check_rebalance(now()).is_none());
1163
1164 agg.atm_tracker_mut().set_initial_price(allowed);
1165 assert!(agg.check_rebalance(now()).is_some());
1166 }
1167
1168 #[rstest]
1169 fn test_zero_hysteresis_disables_guard() {
1170 let mut agg = make_multi_strike_aggregator();
1171 agg.set_hysteresis(0.0);
1172 agg.set_cooldown_ns(0);
1173
1174 set_atm_via_greeks(&mut agg, 50000.0);
1175 let action = agg.check_rebalance(now()).unwrap();
1176 agg.apply_rebalance(&action, now());
1177
1178 set_atm_via_greeks(&mut agg, 52500.0);
1180 assert!(agg.check_rebalance(now()).is_some());
1181 }
1182
1183 #[rstest]
1186 fn test_cooldown_blocks_rapid_rebalance() {
1187 let mut agg = make_multi_strike_aggregator();
1188 agg.set_hysteresis(0.0);
1189 agg.set_cooldown_ns(5_000_000_000); set_atm_via_greeks(&mut agg, 50000.0);
1192 let t0 = now();
1193 let action = agg.check_rebalance(t0).unwrap();
1194 agg.apply_rebalance(&action, t0);
1195
1196 set_atm_via_greeks(&mut agg, 55000.0);
1198 let t1 = UnixNanos::from(t0.as_u64() + 1_000_000_000); assert!(agg.check_rebalance(t1).is_none());
1200 }
1201
1202 #[rstest]
1203 fn test_cooldown_allows_after_elapsed() {
1204 let mut agg = make_multi_strike_aggregator();
1205 agg.set_hysteresis(0.0);
1206 agg.set_cooldown_ns(5_000_000_000); set_atm_via_greeks(&mut agg, 50000.0);
1209 let t0 = now();
1210 let action = agg.check_rebalance(t0).unwrap();
1211 agg.apply_rebalance(&action, t0);
1212
1213 set_atm_via_greeks(&mut agg, 55000.0);
1215 let t1 = UnixNanos::from(t0.as_u64() + 6_000_000_000); assert!(agg.check_rebalance(t1).is_some());
1217 }
1218
1219 #[rstest]
1220 fn test_zero_cooldown_disables_guard() {
1221 let mut agg = make_multi_strike_aggregator();
1222 agg.set_hysteresis(0.0);
1223 agg.set_cooldown_ns(0);
1224
1225 set_atm_via_greeks(&mut agg, 50000.0);
1226 let t0 = now();
1227 let action = agg.check_rebalance(t0).unwrap();
1228 agg.apply_rebalance(&action, t0);
1229
1230 set_atm_via_greeks(&mut agg, 55000.0);
1232 assert!(agg.check_rebalance(t0).is_some());
1233 }
1234
1235 #[rstest]
1238 fn test_pending_greeks_consumed_on_first_quote() {
1239 let (mut agg, call_id, _) = make_aggregator();
1240
1241 let greeks = OptionGreeks {
1243 instrument_id: call_id,
1244 greeks: OptionGreekValues {
1245 delta: 0.55,
1246 ..Default::default()
1247 },
1248 ..Default::default()
1249 };
1250 agg.update_greeks(&greeks);
1251 assert_eq!(agg.pending_greeks_count(), 1);
1252
1253 let quote = make_quote(call_id, "100.00", "101.00");
1255 agg.update_quote("e);
1256 assert_eq!(agg.pending_greeks_count(), 0);
1257
1258 let strike = Price::from("50000");
1260 let data = agg.get_call_greeks_from_buffer(&strike);
1261 assert!(data.is_some());
1262 assert_eq!(data.unwrap().delta, 0.55);
1263 }
1264
1265 #[rstest]
1268 fn test_snapshot_ts_event_reflects_max_quote_timestamp() {
1269 let (mut agg, call_id, put_id) = make_aggregator();
1270
1271 let quote1 = QuoteTick::new(
1272 call_id,
1273 Price::from("100.00"),
1274 Price::from("101.00"),
1275 Quantity::from("1.0"),
1276 Quantity::from("1.0"),
1277 UnixNanos::from(500u64), UnixNanos::from(500u64),
1279 );
1280 agg.update_quote("e1);
1281
1282 let quote2 = QuoteTick::new(
1283 put_id,
1284 Price::from("50.00"),
1285 Price::from("51.00"),
1286 Quantity::from("1.0"),
1287 Quantity::from("1.0"),
1288 UnixNanos::from(800u64), UnixNanos::from(800u64),
1290 );
1291 agg.update_quote("e2);
1292
1293 let slice = agg.snapshot(UnixNanos::from(1000u64));
1294 assert_eq!(slice.ts_event, UnixNanos::from(800u64));
1295 assert_eq!(slice.ts_init, UnixNanos::from(1000u64));
1296 }
1297
1298 #[rstest]
1299 fn test_snapshot_ts_event_fallback_when_no_quotes() {
1300 let (agg, _, _) = make_aggregator();
1301 let slice = agg.snapshot(UnixNanos::from(1000u64));
1302 assert_eq!(slice.ts_event, UnixNanos::from(1000u64));
1304 }
1305
1306 #[rstest]
1307 fn test_snapshot_retains_buffered_data_during_hysteresis_window() {
1308 let strikes = [47500, 50000, 52500];
1310 let mut instruments = HashMap::new();
1311
1312 for s in &strikes {
1313 let strike = Price::from(&s.to_string());
1314 let call_id = InstrumentId::from(&format!("BTC-20240101-{s}-C.DERIBIT"));
1315 instruments.insert(call_id, (strike, OptionKind::Call));
1316 }
1317 let tracker = AtmTracker::new();
1318 let mut agg = OptionChainAggregator::new(
1319 make_series_id(),
1320 StrikeRange::AtmRelative {
1321 strikes_above: 1,
1322 strikes_below: 1,
1323 },
1324 tracker,
1325 instruments,
1326 );
1327 agg.set_hysteresis(0.6);
1328 agg.set_cooldown_ns(0);
1329
1330 set_atm_via_greeks(&mut agg, 50000.0);
1332 let action = agg.check_rebalance(now()).unwrap();
1333 agg.apply_rebalance(&action, now());
1334 assert_eq!(agg.instrument_ids().len(), 3);
1335
1336 let q1 = make_quote(
1338 InstrumentId::from("BTC-20240101-47500-C.DERIBIT"),
1339 "3000.00",
1340 "3100.00",
1341 );
1342 let q2 = make_quote(
1343 InstrumentId::from("BTC-20240101-50000-C.DERIBIT"),
1344 "1500.00",
1345 "1600.00",
1346 );
1347 let q3 = make_quote(
1348 InstrumentId::from("BTC-20240101-52500-C.DERIBIT"),
1349 "500.00",
1350 "600.00",
1351 );
1352 agg.update_quote(&q1);
1353 agg.update_quote(&q2);
1354 agg.update_quote(&q3);
1355 assert_eq!(agg.call_buffer_len(), 3);
1356
1357 set_atm_via_greeks(&mut agg, 51000.0);
1359 assert!(agg.check_rebalance(now()).is_none());
1360
1361 let slice = agg.snapshot(UnixNanos::from(100u64));
1363 assert_eq!(slice.call_count(), 3);
1364 }
1365
1366 #[rstest]
1367 fn test_remove_instrument_from_catalog() {
1368 let (mut agg, call_id, put_id) = make_aggregator();
1369 assert_eq!(agg.instruments().len(), 2);
1370
1371 let removed = agg.remove_instrument(&call_id);
1372 assert!(removed);
1373 assert_eq!(agg.instruments().len(), 1);
1374 assert!(!agg.active_ids().contains(&call_id));
1375 assert!(agg.instruments().contains_key(&put_id));
1376 }
1377
1378 #[rstest]
1379 fn test_remove_instrument_cleans_buffer() {
1380 let (mut agg, call_id, _) = make_aggregator();
1381 let quote = make_quote(call_id, "100.00", "101.00");
1382 agg.update_quote("e);
1383 assert_eq!(agg.call_buffer_len(), 1);
1384
1385 let _ = agg.remove_instrument(&call_id);
1386 assert_eq!(agg.call_buffer_len(), 0);
1388 }
1389
1390 #[rstest]
1391 fn test_remove_instrument_preserves_sibling_buffer() {
1392 let (mut agg, call_id, _) = make_aggregator();
1393 let sibling_id = InstrumentId::from("BTC-20240101-50000-C2.DERIBIT");
1395 let strike = Price::from("50000");
1396 let _ = agg.add_instrument(sibling_id, strike, OptionKind::Call);
1397
1398 let quote = make_quote(call_id, "100.00", "101.00");
1399 agg.update_quote("e);
1400 assert_eq!(agg.call_buffer_len(), 1);
1401
1402 let _ = agg.remove_instrument(&call_id);
1404 assert_eq!(agg.call_buffer_len(), 1); assert!(agg.instruments().contains_key(&sibling_id));
1406 }
1407
1408 #[rstest]
1409 fn test_remove_instrument_unknown_noop() {
1410 let (mut agg, _, _) = make_aggregator();
1411 let unknown = InstrumentId::from("ETH-20240101-3000-C.DERIBIT");
1412 assert!(!agg.remove_instrument(&unknown));
1413 assert_eq!(agg.instruments().len(), 2);
1414 }
1415
1416 #[rstest]
1417 fn test_remove_instrument_cleans_pending_greeks() {
1418 let (mut agg, call_id, _) = make_aggregator();
1419 let greeks = OptionGreeks {
1420 instrument_id: call_id,
1421 greeks: OptionGreekValues {
1422 delta: 0.55,
1423 ..Default::default()
1424 },
1425 ..Default::default()
1426 };
1427 agg.update_greeks(&greeks);
1428 assert_eq!(agg.pending_greeks_count(), 1);
1429
1430 let _ = agg.remove_instrument(&call_id);
1431 assert_eq!(agg.pending_greeks_count(), 0);
1432 }
1433
1434 #[rstest]
1435 fn test_is_catalog_empty_after_full_removal() {
1436 let (mut agg, call_id, put_id) = make_aggregator();
1437 assert!(!agg.is_catalog_empty());
1438
1439 let _ = agg.remove_instrument(&call_id);
1440 assert!(!agg.is_catalog_empty());
1441
1442 let _ = agg.remove_instrument(&put_id);
1443 assert!(agg.is_catalog_empty());
1444 }
1445
1446 #[rstest]
1449 fn test_expired_quote_is_dropped() {
1450 let (mut agg, call_id, _) = make_aggregator();
1451 let expired_quote = QuoteTick::new(
1453 call_id,
1454 Price::from("100.00"),
1455 Price::from("101.00"),
1456 Quantity::from("1.0"),
1457 Quantity::from("1.0"),
1458 UnixNanos::from(1_700_000_000_000_000_000u64),
1459 UnixNanos::from(1_700_000_000_000_000_000u64),
1460 );
1461 agg.update_quote(&expired_quote);
1462 assert!(agg.is_buffer_empty());
1463 }
1464
1465 #[rstest]
1466 fn test_expired_greeks_are_dropped() {
1467 let (mut agg, call_id, _) = make_aggregator();
1468 let quote = make_quote(call_id, "100.00", "101.00");
1470 agg.update_quote("e);
1471 assert_eq!(agg.call_buffer_len(), 1);
1472
1473 let greeks = OptionGreeks {
1475 instrument_id: call_id,
1476 ts_event: UnixNanos::from(1_700_000_000_000_000_000u64),
1477 greeks: OptionGreekValues {
1478 delta: 0.55,
1479 ..Default::default()
1480 },
1481 ..Default::default()
1482 };
1483 agg.update_greeks(&greeks);
1484
1485 let strike = Price::from("50000");
1486 assert!(agg.get_call_greeks_from_buffer(&strike).is_none());
1487 }
1488
1489 fn make_delta_aggregator(
1495 strikes: &[i64],
1496 target: f64,
1497 tolerance: f64,
1498 ) -> OptionChainAggregator {
1499 let mut instruments = HashMap::new();
1500
1501 for s in strikes {
1502 let strike = Price::from(&s.to_string());
1503 instruments.insert(option_id(*s, OptionKind::Call), (strike, OptionKind::Call));
1504 instruments.insert(option_id(*s, OptionKind::Put), (strike, OptionKind::Put));
1505 }
1506 let tracker = AtmTracker::new();
1507 let mut agg = OptionChainAggregator::new(
1508 make_series_id(),
1509 StrikeRange::Delta { target, tolerance },
1510 tracker,
1511 instruments,
1512 );
1513 agg.set_hysteresis(0.0);
1514 agg.set_cooldown_ns(0);
1515 agg
1516 }
1517
1518 fn option_id(strike: i64, kind: OptionKind) -> InstrumentId {
1519 let suffix = match kind {
1520 OptionKind::Call => "C",
1521 OptionKind::Put => "P",
1522 };
1523 InstrumentId::from(&format!("BTC-20240101-{strike}-{suffix}.DERIBIT"))
1524 }
1525
1526 fn feed_quote_and_greeks(
1528 agg: &mut OptionChainAggregator,
1529 strike: i64,
1530 kind: OptionKind,
1531 delta: f64,
1532 ) {
1533 let id = option_id(strike, kind);
1534 agg.update_quote(&make_quote(id, "100.00", "101.00"));
1535 agg.update_greeks(&OptionGreeks {
1536 instrument_id: id,
1537 greeks: OptionGreekValues {
1538 delta,
1539 ..Default::default()
1540 },
1541 ..Default::default()
1542 });
1543 }
1544
1545 #[rstest]
1546 #[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(
1553 #[case] delta: f64,
1554 #[case] target: f64,
1555 #[case] tolerance: f64,
1556 #[case] expected: bool,
1557 ) {
1558 assert_eq!(
1559 OptionChainAggregator::delta_within_band(delta, target, tolerance),
1560 expected
1561 );
1562 }
1563
1564 #[rstest]
1565 fn test_delta_target_hit() {
1566 let strikes = [40000, 45000, 50000, 55000, 60000];
1567 let mut agg = make_delta_aggregator(&strikes, 0.30, 0.03);
1568 set_atm_via_greeks(&mut agg, 50000.0);
1570 agg.recompute_active_set();
1571 assert_eq!(agg.instrument_ids().len(), 10); feed_quote_and_greeks(&mut agg, 40000, OptionKind::Call, 0.95);
1575 feed_quote_and_greeks(&mut agg, 40000, OptionKind::Put, -0.95);
1576 feed_quote_and_greeks(&mut agg, 45000, OptionKind::Call, 0.80);
1577 feed_quote_and_greeks(&mut agg, 45000, OptionKind::Put, -0.80);
1578 feed_quote_and_greeks(&mut agg, 50000, OptionKind::Call, 0.55);
1579 feed_quote_and_greeks(&mut agg, 50000, OptionKind::Put, -0.55);
1580 feed_quote_and_greeks(&mut agg, 55000, OptionKind::Call, 0.30);
1581 feed_quote_and_greeks(&mut agg, 55000, OptionKind::Put, -0.12);
1582 feed_quote_and_greeks(&mut agg, 60000, OptionKind::Call, 0.12);
1583 feed_quote_and_greeks(&mut agg, 60000, OptionKind::Put, -0.10);
1584
1585 let active = agg.recompute_active_set();
1586 assert_eq!(active.len(), 2); assert!(active.contains(&option_id(55000, OptionKind::Call)));
1588 assert!(active.contains(&option_id(55000, OptionKind::Put)));
1589 }
1590
1591 #[rstest]
1592 fn test_delta_tolerance_band() {
1593 let strikes = [45000, 50000, 55000, 60000];
1594 let mut agg = make_delta_aggregator(&strikes, 0.30, 0.05);
1595 set_atm_via_greeks(&mut agg, 50000.0);
1596 agg.recompute_active_set();
1597
1598 feed_quote_and_greeks(&mut agg, 45000, OptionKind::Call, 0.50);
1600 feed_quote_and_greeks(&mut agg, 45000, OptionKind::Put, -0.50);
1601 feed_quote_and_greeks(&mut agg, 50000, OptionKind::Call, 0.32);
1602 feed_quote_and_greeks(&mut agg, 50000, OptionKind::Put, -0.50);
1603 feed_quote_and_greeks(&mut agg, 55000, OptionKind::Call, 0.30);
1604 feed_quote_and_greeks(&mut agg, 55000, OptionKind::Put, -0.50);
1605 feed_quote_and_greeks(&mut agg, 60000, OptionKind::Call, 0.10);
1606 feed_quote_and_greeks(&mut agg, 60000, OptionKind::Put, -0.10);
1607
1608 let active = agg.recompute_active_set();
1609 assert_eq!(active.len(), 4); assert!(active.contains(&option_id(50000, OptionKind::Call)));
1611 assert!(active.contains(&option_id(55000, OptionKind::Call)));
1612 assert!(!active.contains(&option_id(45000, OptionKind::Call)));
1613 assert!(!active.contains(&option_id(60000, OptionKind::Call)));
1614 }
1615
1616 #[rstest]
1617 fn test_delta_matches_put_by_magnitude() {
1618 let strikes = [45000, 50000, 55000];
1619 let mut agg = make_delta_aggregator(&strikes, 0.30, 0.03);
1620 set_atm_via_greeks(&mut agg, 50000.0);
1621 agg.recompute_active_set();
1622
1623 feed_quote_and_greeks(&mut agg, 45000, OptionKind::Call, 0.55);
1625 feed_quote_and_greeks(&mut agg, 45000, OptionKind::Put, -0.30);
1626 feed_quote_and_greeks(&mut agg, 50000, OptionKind::Call, 0.55);
1627 feed_quote_and_greeks(&mut agg, 50000, OptionKind::Put, -0.55);
1628 feed_quote_and_greeks(&mut agg, 55000, OptionKind::Call, 0.55);
1629 feed_quote_and_greeks(&mut agg, 55000, OptionKind::Put, -0.55);
1630
1631 let active = agg.recompute_active_set();
1632 assert_eq!(active.len(), 2);
1634 assert!(active.contains(&option_id(45000, OptionKind::Put)));
1635 assert!(active.contains(&option_id(45000, OptionKind::Call)));
1636 }
1637
1638 #[rstest]
1639 fn test_delta_no_greeks_falls_back_to_atm_window() {
1640 let strikes: Vec<i64> = (0..13).map(|i| 40000 + i * 1000).collect();
1642 let mut agg = make_delta_aggregator(&strikes, 0.25, 0.05);
1643 set_atm_via_greeks(&mut agg, 46000.0); let active = agg.recompute_active_set();
1646
1647 assert!(active.len() > 2);
1650 assert!(active.len() < strikes.len() * 2);
1651 assert!(active.contains(&option_id(46000, OptionKind::Call))); assert!(!active.contains(&option_id(40000, OptionKind::Call))); assert!(!active.contains(&option_id(52000, OptionKind::Call))); }
1655
1656 #[rstest]
1657 fn test_delta_pending_only_greeks_eligible() {
1658 let strikes = [45000, 50000, 55000];
1659 let mut agg = make_delta_aggregator(&strikes, 0.30, 0.03);
1660 set_atm_via_greeks(&mut agg, 50000.0);
1661 agg.recompute_active_set();
1662
1663 feed_quote_and_greeks(&mut agg, 45000, OptionKind::Call, 0.55);
1664 feed_quote_and_greeks(&mut agg, 45000, OptionKind::Put, -0.55);
1665 feed_quote_and_greeks(&mut agg, 50000, OptionKind::Call, 0.55);
1666 feed_quote_and_greeks(&mut agg, 50000, OptionKind::Put, -0.55);
1667 feed_quote_and_greeks(&mut agg, 55000, OptionKind::Put, -0.55);
1668
1669 agg.update_greeks(&OptionGreeks {
1671 instrument_id: option_id(55000, OptionKind::Call),
1672 greeks: OptionGreekValues {
1673 delta: 0.30,
1674 ..Default::default()
1675 },
1676 ..Default::default()
1677 });
1678 assert_eq!(agg.pending_greeks_count(), 1);
1679
1680 let active = agg.recompute_active_set();
1681 assert_eq!(active.len(), 2);
1683 assert!(active.contains(&option_id(55000, OptionKind::Call)));
1684 }
1685
1686 #[rstest]
1687 fn test_delta_waits_for_fallback_window_greeks_before_narrowing() {
1688 let strikes = [45000, 50000, 55000];
1689 let mut agg = make_delta_aggregator(&strikes, 0.30, 0.03);
1690 set_atm_via_greeks(&mut agg, 50000.0);
1691 agg.recompute_active_set();
1692 assert_eq!(agg.instrument_ids().len(), 6); feed_quote_and_greeks(&mut agg, 55000, OptionKind::Call, 0.30);
1695
1696 assert!(agg.check_rebalance(now()).is_none());
1697 assert_eq!(agg.instrument_ids().len(), 6);
1698 }
1699
1700 #[rstest]
1701 fn test_delta_waits_when_fallback_window_shifts_during_warmup() {
1702 let strikes: Vec<i64> = (0..13).map(|i| 40000 + i * 1000).collect();
1703 let mut agg = make_delta_aggregator(&strikes, 0.30, 0.03);
1704 set_atm_via_greeks(&mut agg, 46000.0);
1705 agg.recompute_active_set();
1706 assert!(
1707 agg.active_ids()
1708 .contains(&option_id(42000, OptionKind::Call))
1709 );
1710 assert!(
1711 agg.active_ids()
1712 .contains(&option_id(51000, OptionKind::Put))
1713 );
1714
1715 feed_quote_and_greeks(&mut agg, 50000, OptionKind::Call, 0.30);
1716 set_atm_via_greeks(&mut agg, 47000.0);
1717
1718 let action = agg
1719 .check_rebalance(now())
1720 .expect("fallback window shift should rebalance active legs");
1721
1722 assert!(action.add.contains(&option_id(52000, OptionKind::Call)));
1723 assert!(!action.remove.contains(&option_id(42000, OptionKind::Call)));
1724 assert!(!action.remove.contains(&option_id(51000, OptionKind::Put)));
1725 }
1726
1727 #[rstest]
1728 fn test_delta_rebalances_on_greeks_with_atm_unchanged() {
1729 let strikes = [45000, 50000, 55000];
1730 let mut agg = make_delta_aggregator(&strikes, 0.30, 0.03);
1731 set_atm_via_greeks(&mut agg, 50000.0);
1732 agg.recompute_active_set();
1733 assert_eq!(agg.last_atm_strike(), Some(Price::from("50000")));
1734 assert_eq!(agg.instrument_ids().len(), 6); feed_quote_and_greeks(&mut agg, 45000, OptionKind::Call, 0.55);
1738 feed_quote_and_greeks(&mut agg, 45000, OptionKind::Put, -0.55);
1739 feed_quote_and_greeks(&mut agg, 50000, OptionKind::Call, 0.45);
1740 feed_quote_and_greeks(&mut agg, 50000, OptionKind::Put, -0.45);
1741 feed_quote_and_greeks(&mut agg, 55000, OptionKind::Call, 0.30);
1742 feed_quote_and_greeks(&mut agg, 55000, OptionKind::Put, -0.12);
1743
1744 let action = agg
1745 .check_rebalance(now())
1746 .expect("delta range should rebalance when greeks narrow the set");
1747 assert!(action.add.is_empty());
1748 assert!(!action.remove.is_empty());
1749
1750 agg.apply_rebalance(&action, now());
1751 assert_eq!(agg.instrument_ids().len(), 2); assert!(
1753 agg.active_ids()
1754 .contains(&option_id(55000, OptionKind::Call))
1755 );
1756 }
1757
1758 #[rstest]
1759 fn test_delta_no_op_rebalance_returns_none() {
1760 let strikes = [45000, 50000, 55000];
1761 let mut agg = make_delta_aggregator(&strikes, 0.30, 0.03);
1762 set_atm_via_greeks(&mut agg, 50000.0);
1763 agg.recompute_active_set();
1764 feed_quote_and_greeks(&mut agg, 45000, OptionKind::Call, 0.55);
1765 feed_quote_and_greeks(&mut agg, 45000, OptionKind::Put, -0.55);
1766 feed_quote_and_greeks(&mut agg, 50000, OptionKind::Call, 0.45);
1767 feed_quote_and_greeks(&mut agg, 50000, OptionKind::Put, -0.45);
1768 feed_quote_and_greeks(&mut agg, 55000, OptionKind::Call, 0.30);
1769 feed_quote_and_greeks(&mut agg, 55000, OptionKind::Put, -0.12);
1770
1771 let action = agg.check_rebalance(now()).unwrap();
1773 agg.apply_rebalance(&action, now());
1774 assert_eq!(agg.instrument_ids().len(), 2);
1775
1776 assert!(agg.check_rebalance(now()).is_none());
1778 }
1779}