Skip to main content

nautilus_data/option_chains/
aggregator.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! Per-series option chain aggregator for event accumulation and snapshots.
17
18use 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/// Per-series aggregator that accumulates quotes and greeks between snapshots.
41///
42/// Owns mutable accumulator buffers and produces immutable `OptionChainSlice`
43/// snapshots on each timer tick.
44#[derive(Debug)]
45pub struct OptionChainAggregator {
46    /// The option series identifier for this aggregator.
47    series_id: OptionSeriesId,
48    /// Defines which strikes to include in the active set.
49    strike_range: StrikeRange,
50    /// Tracks the current ATM price from market data events.
51    atm_tracker: AtmTracker,
52    /// All instruments for this series. Grows dynamically when the exchange
53    /// lists new strikes via [`Self::add_instrument`].
54    instruments: HashMap<InstrumentId, (Price, OptionKind)>,
55    /// Currently active instrument IDs (subset of `instruments`).
56    active_ids: HashSet<InstrumentId>,
57    /// The closest ATM strike at the time of the last rebalance.
58    last_atm_strike: Option<Price>,
59    /// Hysteresis band for ATM rebalancing.
60    hysteresis: f64,
61    /// Minimum nanoseconds between rebalances.
62    cooldown_ns: u64,
63    /// Timestamp of the last rebalance.
64    last_rebalance_ns: Option<UnixNanos>,
65    /// Maximum `ts_event` seen across all quote updates.
66    max_ts_event: UnixNanos,
67    /// Greeks received before the corresponding quote arrived.
68    pending_greeks: HashMap<InstrumentId, OptionGreeks>,
69    /// Call option accumulator buffer keyed by strike price.
70    call_buffer: BTreeMap<Price, OptionStrikeData>,
71    /// Put option accumulator buffer keyed by strike price.
72    put_buffer: BTreeMap<Price, OptionStrikeData>,
73}
74
75impl OptionChainAggregator {
76    /// Creates a new aggregator for the given series.
77    ///
78    /// `instruments` contains ALL instruments for the series. The initial
79    /// `active_ids` subset is resolved from the strike range and the current
80    /// ATM price (if available). When no ATM price is set for ATM-based
81    /// ranges, all instruments are active.
82    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        // No Greeks exist at construction, so a `Delta` range resolves to its ATM fallback.
104        aggregator.recompute_active_set();
105        aggregator
106    }
107
108    /// Returns a mutable reference to the ATM tracker.
109    pub fn atm_tracker_mut(&mut self) -> &mut AtmTracker {
110        &mut self.atm_tracker
111    }
112
113    /// Returns the currently active instrument IDs.
114    #[must_use]
115    pub fn instrument_ids(&self) -> Vec<InstrumentId> {
116        self.active_ids.iter().copied().collect()
117    }
118
119    /// Returns a reference to the active instrument ID set.
120    #[must_use]
121    pub fn active_ids(&self) -> &HashSet<InstrumentId> {
122        &self.active_ids
123    }
124
125    /// Returns the series ID.
126    #[must_use]
127    pub fn series_id(&self) -> OptionSeriesId {
128        self.series_id
129    }
130
131    /// Returns `true` if the given timestamp is at or past the series expiration.
132    #[must_use]
133    pub fn is_expired(&self, now_ns: UnixNanos) -> bool {
134        now_ns >= self.series_id.expiration_ns
135    }
136
137    /// Returns a reference to the full instrument set.
138    #[must_use]
139    pub fn instruments(&self) -> &HashMap<InstrumentId, (Price, OptionKind)> {
140        &self.instruments
141    }
142
143    /// Returns all instrument IDs in the full set.
144    #[must_use]
145    pub fn all_instrument_ids(&self) -> Vec<InstrumentId> {
146        self.instruments.keys().copied().collect()
147    }
148
149    /// Returns `true` if the instrument catalog is empty.
150    #[must_use]
151    pub fn is_catalog_empty(&self) -> bool {
152        self.instruments.is_empty()
153    }
154
155    /// Permanently removes an instrument from the catalog.
156    ///
157    /// Removes from `instruments`, `active_ids`, `pending_greeks`, and cleans
158    /// buffer entries (only if no other instrument shares the same strike+kind).
159    /// Returns `true` if the instrument was found and removed.
160    #[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        // Only remove buffer entry if no sibling instrument shares the same strike+kind
170        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    /// Returns a reference to the ATM tracker.
187    #[must_use]
188    pub fn atm_tracker(&self) -> &AtmTracker {
189        &self.atm_tracker
190    }
191
192    /// Recomputes the active instrument set from the current ATM price.
193    ///
194    /// Returns the new active instrument IDs. Used during bootstrap when the
195    /// first ATM price arrives after deferred subscription setup.
196    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    /// Resolves the active strikes for the current strike range.
215    ///
216    /// `Delta` is resolved here from stored Greeks (see [`Self::resolve_delta`]);
217    /// the price-based variants delegate to [`StrikeRange::resolve`].
218    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    /// Resolves strikes whose buffered or pending Greeks have an absolute delta
232    /// within `tolerance` of `target`.
233    ///
234    /// A strike qualifies when either its call or put delta magnitude matches
235    /// (calls have positive delta, puts negative; both are compared by absolute
236    /// value), so a typical target selects an OTM strike on each side of ATM.
237    /// Strikes with only pending Greeks (received before their first quote) are
238    /// eligible. Before the resolver changes from a fallback set to a selected
239    /// set, every current fallback leg must have Greeks. This avoids unsubscribing
240    /// legs whose Greeks have not arrived yet, including when the fallback window
241    /// shifts with ATM. When no Greeks fall in the band, this falls back to the
242    /// ATM-relative window from [`StrikeRange::resolve`].
243    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    /// Collects every reported delta per strike, from buffered Greeks and from
313    /// Greeks still pending their first quote.
314    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    /// Returns `true` when `delta`'s magnitude is within `tolerance` of `target`.
339    ///
340    /// Compares by absolute value so a put (negative delta) matches the same
341    /// target as the equivalent call.
342    fn delta_within_band(delta: f64, target: f64, tolerance: f64) -> bool {
343        (delta.abs() - target).abs() <= tolerance
344    }
345
346    /// Adds a newly discovered instrument to the series.
347    ///
348    /// Returns `true` if the instrument was newly inserted. Returns `false`
349    /// if it was already known (no-op). When the new instrument's strike
350    /// falls within the current active range, it is immediately added to
351    /// `active_ids`.
352    #[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        // Determine if the new strike is in the current active range
366        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    /// Returns sorted, deduplicated strikes from the given instruments.
381    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    /// Finds the strike in `all_strikes` closest to `atm`.
389    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    /// Handles an incoming quote tick by updating the accumulator buffers.
397    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(&quote.instrument_id) {
409            return;
410        }
411
412        if let Some(&(strike, kind)) = self.instruments.get(&quote.instrument_id) {
413            // Track max ts_event across all quotes
414            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                    // Check for pending greeks that arrived before this first quote
427                    let greeks = self.pending_greeks.remove(&quote.instrument_id);
428                    buffer.insert(
429                        strike,
430                        OptionStrikeData {
431                            quote: *quote,
432                            greeks,
433                        },
434                    );
435                }
436            }
437        }
438    }
439
440    /// Handles incoming greeks by updating the accumulator buffers.
441    ///
442    /// If no quote has arrived yet for this instrument (no buffer entry),
443    /// the greeks are stored in `pending_greeks` and will be attached when
444    /// the first quote arrives.
445    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                    // No quote yet: park the greeks for later
470                    self.pending_greeks.insert(greeks.instrument_id, *greeks);
471                }
472            }
473        }
474    }
475
476    /// Creates a point-in-time snapshot from accumulated buffers, applying strike filtering.
477    ///
478    /// Buffers are preserved (keep-latest semantics) so instruments that didn't
479    /// quote since the last tick are still included in subsequent snapshots.
480    ///
481    /// # Panics
482    ///
483    /// Panics if strike prices cannot be compared (NaN values).
484    pub fn snapshot(&self, ts_init: UnixNanos) -> OptionChainSlice {
485        let atm_price = self.atm_tracker.atm_price();
486
487        // Use catalog strikes for ATM strike (most accurate closest-strike lookup)
488        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        // Filter buffers using active set strikes directly. The active set is already
492        // the result of strike range resolution from the last rebalance. Re-resolving
493        // here would shift the window during hysteresis/cooldown, dropping buffered data.
494        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        // Build filtered snapshot (clone from buffers)
501        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        // Use the max observed ts_event from quotes, falling back to ts_init
517        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    /// Returns `true` if both buffers are empty.
534    #[must_use]
535    pub fn is_buffer_empty(&self) -> bool {
536        self.call_buffer.is_empty() && self.put_buffer.is_empty()
537    }
538
539    /// Checks whether the instrument set should be rebalanced around the current ATM.
540    ///
541    /// Returns `None` when no rebalancing is needed (fixed ranges, no ATM price,
542    /// ATM strike unchanged, hysteresis not exceeded, or cooldown not elapsed).
543    /// Returns `Some(RebalanceAction)` with instrument add/remove lists when the
544    /// closest ATM strike shifts past the hysteresis threshold.
545    ///
546    /// `Delta` ranges resolve from Greeks rather than an ATM window, so their
547    /// active set can change while the closest ATM strike is unchanged. They skip
548    /// the ATM-shift and hysteresis gates and rebalance on any resolved-set change,
549    /// with the cooldown still applied to throttle churn.
550    #[must_use]
551    pub fn check_rebalance(&self, now_ns: UnixNanos) -> Option<RebalanceAction> {
552        // Fixed ranges never rebalance
553        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            // No change: no rebalance
565            if self.last_atm_strike == Some(current_atm_strike) {
566                return None;
567            }
568
569            // Hysteresis check: price must cross hysteresis fraction of the gap to next strike
570            if let Some(last_strike) = self.last_atm_strike
571                && self.hysteresis > 0.0
572            {
573                // Find the next strike in the direction of price movement
574                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        // Cooldown check
592        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        // Compute new active set
600        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        // Suppress no-op delta rebalances so the cooldown timestamp is not reset on
615        // every snapshot while the resolved set is stable.
616        if is_delta && add.is_empty() && remove.is_empty() {
617            return None;
618        }
619
620        Some(RebalanceAction { add, remove })
621    }
622
623    /// Applies a rebalance action: updates the active ID set, cleans stale buffers,
624    /// and records the rebalance timestamp.
625    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        // Clean buffers for strikes no longer in active set
635        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        // Update last_atm_strike and record rebalance timestamp
649        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/// Describes instruments to add and remove during an ATM rebalance.
658#[derive(Clone, Debug, PartialEq, Eq)]
659pub struct RebalanceAction {
660    /// Instruments to subscribe to (newly in range).
661    pub add: Vec<InstrumentId>,
662    /// Instruments to unsubscribe from (no longer in range).
663    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        // A base timestamp for tests (far enough from zero to avoid edge cases)
727        UnixNanos::from(1_000_000_000_000_000_000u64)
728    }
729
730    /// Sets ATM price on an aggregator via a synthetic `OptionGreeks` with the given forward price.
731    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(&quote);
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(&quote);
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(&quote);
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        // Buffers should still contain data (keep-latest semantics)
825        assert!(!agg.is_buffer_empty());
826
827        // Second snapshot should return the same data
828        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(&quote);
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    // -- Rebalance tests --
851
852    /// Builds instruments with 5 strike prices (45000..55000 step 2500) and `AtmRelative` +-1.
853    /// Hysteresis and cooldown are disabled so existing rebalance tests pass unchanged.
854    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        // Disable guards so existing tests exercise pure rebalance logic
877        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        // Fixed range + ATM price set: still returns None
885        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        // No ATM price set: None
894        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 to 50000 and apply initial rebalance
901        set_atm_via_greeks(&mut agg, 50000.0);
902        // First check detects ATM shift from None to 50000
903        let action = agg.check_rebalance(now()).unwrap();
904        agg.apply_rebalance(&action, now());
905
906        // ATM moves slightly but stays closest to 50000
907        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 near 50000
915        set_atm_via_greeks(&mut agg, 50000.0);
916        let action = agg.check_rebalance(now()).unwrap();
917        agg.apply_rebalance(&action, now());
918        // Active: 47500, 50000, 52500 (ATM=50000, +-1 strike)
919        assert_eq!(agg.instrument_ids().len(), 6); // 3 strikes * 2
920
921        // Now shift ATM to 55000
922        set_atm_via_greeks(&mut agg, 55000.0);
923        let action2 = agg.check_rebalance(now()).unwrap();
924        // Should have instruments to add (55000) and remove (47500)
925        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 near 50000
932        set_atm_via_greeks(&mut agg, 50000.0);
933        let action = agg.check_rebalance(now()).unwrap();
934        agg.apply_rebalance(&action, now());
935
936        // Active should be 3 strikes (47500, 50000, 52500)
937        let active_ids = agg.instrument_ids();
938        assert_eq!(active_ids.len(), 6); // 3 strikes * 2 (call + put)
939
940        // Now shift to 55000
941        set_atm_via_greeks(&mut agg, 55000.0);
942        let action2 = agg.check_rebalance(now()).unwrap();
943        agg.apply_rebalance(&action2, now());
944
945        // Active should now be (52500, 55000): 2 strikes at the top end
946        let active_ids2 = agg.instrument_ids();
947        assert_eq!(active_ids2.len(), 4); // 2 strikes * 2
948    }
949
950    #[rstest]
951    fn test_apply_rebalance_cleans_buffers() {
952        let mut agg = make_multi_strike_aggregator();
953        // Set ATM near 50000
954        set_atm_via_greeks(&mut agg, 50000.0);
955        let action = agg.check_rebalance(now()).unwrap();
956        agg.apply_rebalance(&action, now());
957
958        // Feed quotes for the 47500 call
959        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(&quote);
962        assert_eq!(agg.call_buffer_len(), 1);
963
964        // Now shift ATM up so 47500 is out of range
965        set_atm_via_greeks(&mut agg, 55000.0);
966        let action2 = agg.check_rebalance(now()).unwrap();
967        agg.apply_rebalance(&action2, now());
968
969        // Buffer for 47500 should be cleaned
970        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        // AtmRelative with no ATM price: empty active set (deferred)
977        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 near 50000 to narrow active set
985        set_atm_via_greeks(&mut agg, 50000.0);
986        let action = agg.check_rebalance(now()).unwrap();
987        agg.apply_rebalance(&action, now());
988
989        // Catalog should still have all 10 instruments
990        assert_eq!(agg.instruments().len(), 10);
991        // Active should be a subset
992        assert_eq!(agg.instrument_ids().len(), 6);
993    }
994
995    // -- add_instrument tests --
996
997    #[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        // Fixed range includes strike 50000; adding another instrument at same strike
1013        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        // Fixed range only includes 50000; adding instrument at 60000
1027        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 near 50000 and apply initial rebalance
1041        set_atm_via_greeks(&mut agg, 50000.0);
1042        let action = agg.check_rebalance(now()).unwrap();
1043        agg.apply_rebalance(&action, now());
1044        // Active: 47500, 50000, 52500 (6 instruments)
1045        assert_eq!(agg.instrument_ids().len(), 6);
1046
1047        // Add a new instrument at strike 57500 (out of current range)
1048        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        // Shift ATM to 57500: rebalance should pick up the new instrument
1055        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    // -- Hysteresis tests --
1063
1064    #[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 to 50000
1088        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        // Move ATM slightly toward 52500: gap=2500, threshold=50000+0.6*2500=51500
1094        // 51000 does NOT cross 51500
1095        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 to 50000
1123        set_atm_via_greeks(&mut agg, 50000.0);
1124        let action = agg.check_rebalance(now()).unwrap();
1125        agg.apply_rebalance(&action, now());
1126
1127        // Move ATM well past threshold: 52000 > 51500
1128        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        // Any shift past the strike boundary triggers rebalance
1179        set_atm_via_greeks(&mut agg, 52500.0);
1180        assert!(agg.check_rebalance(now()).is_some());
1181    }
1182
1183    // -- Cooldown tests --
1184
1185    #[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); // 5s
1190
1191        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        // Shift ATM immediately: cooldown blocks
1197        set_atm_via_greeks(&mut agg, 55000.0);
1198        let t1 = UnixNanos::from(t0.as_u64() + 1_000_000_000); // 1s later
1199        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); // 5s
1207
1208        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        // Shift ATM after cooldown elapses
1214        set_atm_via_greeks(&mut agg, 55000.0);
1215        let t1 = UnixNanos::from(t0.as_u64() + 6_000_000_000); // 6s later
1216        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        // Shift ATM immediately: no cooldown block
1231        set_atm_via_greeks(&mut agg, 55000.0);
1232        assert!(agg.check_rebalance(t0).is_some());
1233    }
1234
1235    // -- Pending greeks tests --
1236
1237    #[rstest]
1238    fn test_pending_greeks_consumed_on_first_quote() {
1239        let (mut agg, call_id, _) = make_aggregator();
1240
1241        // Send greeks before any quote
1242        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        // Now send the first quote: pending greeks should be consumed
1254        let quote = make_quote(call_id, "100.00", "101.00");
1255        agg.update_quote(&quote);
1256        assert_eq!(agg.pending_greeks_count(), 0);
1257
1258        // Verify greeks were attached
1259        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    // -- ts_event tracking tests --
1266
1267    #[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), // ts_event
1278            UnixNanos::from(500u64),
1279        );
1280        agg.update_quote(&quote1);
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), // ts_event: later
1289            UnixNanos::from(800u64),
1290        );
1291        agg.update_quote(&quote2);
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        // No quotes: ts_event falls back to ts_init
1303        assert_eq!(slice.ts_event, UnixNanos::from(1000u64));
1304    }
1305
1306    #[rstest]
1307    fn test_snapshot_retains_buffered_data_during_hysteresis_window() {
1308        // Setup: 3 strikes at 47500/50000/52500, AtmRelative +-1, hysteresis enabled
1309        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 to 50000, rebalance -> active: {47500, 50000, 52500}
1331        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        // Buffer quotes for all active strikes
1337        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        // Move ATM slightly toward 52500 but within hysteresis band (no rebalance)
1358        set_atm_via_greeks(&mut agg, 51000.0);
1359        assert!(agg.check_rebalance(now()).is_none());
1360
1361        // Snapshot must still include all 3 buffered strikes
1362        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(&quote);
1383        assert_eq!(agg.call_buffer_len(), 1);
1384
1385        let _ = agg.remove_instrument(&call_id);
1386        // No sibling call at same strike, buffer entry should be removed
1387        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        // Add a second call at the same strike
1394        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(&quote);
1400        assert_eq!(agg.call_buffer_len(), 1);
1401
1402        // Remove original: sibling still shares the strike+kind
1403        let _ = agg.remove_instrument(&call_id);
1404        assert_eq!(agg.call_buffer_len(), 1); // buffer preserved
1405        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    // -- Expiry guard tests --
1447
1448    #[rstest]
1449    fn test_expired_quote_is_dropped() {
1450        let (mut agg, call_id, _) = make_aggregator();
1451        // Series expires at 1_700_000_000_000_000_000; send quote AT that timestamp
1452        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        // First add a valid quote so greeks would normally land in the buffer
1469        let quote = make_quote(call_id, "100.00", "101.00");
1470        agg.update_quote(&quote);
1471        assert_eq!(agg.call_buffer_len(), 1);
1472
1473        // Send greeks at expiry timestamp: should be dropped
1474        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    // -- Delta range tests --
1490
1491    /// Builds a `Delta`-range aggregator over `strikes` (call + put per strike),
1492    /// with hysteresis and cooldown disabled so rebalance decisions reflect pure
1493    /// delta resolution.
1494    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    /// Feeds a quote then greeks (with the given `delta`) for one option leg.
1527    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)] // exact target
1547    #[case(-0.30, 0.30, 0.03, true)] // negative delta, magnitude matches
1548    #[case(0.28, 0.30, 0.03, true)] // inside band, below target
1549    #[case(0.32, 0.30, 0.03, true)] // inside band, above target
1550    #[case(0.20, 0.30, 0.03, false)] // below band
1551    #[case(0.40, 0.30, 0.03, false)] // above band
1552    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        // Bootstrap the ATM-relative fallback so the window is active and greeks can land.
1569        set_atm_via_greeks(&mut agg, 50000.0);
1570        agg.recompute_active_set();
1571        assert_eq!(agg.instrument_ids().len(), 10); // all 5 strikes (fallback)
1572
1573        // Only the 55000 call sits at the 0.30 target.
1574        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); // 55000 call + put
1587        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        // Band is [0.25, 0.35]: 0.50 and 0.10 are outside, 0.32 and 0.30 inside.
1599        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); // 50000 + 55000, both legs each
1610        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        // Only the 45000 put matches the target, isolating put-side matching.
1624        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        // |-0.30| == target, so the 45000 strike (both legs) is selected.
1633        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        // 13 strikes so the ATM-relative fallback window is a proper subset.
1641        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); // centered
1644
1645        let active = agg.recompute_active_set();
1646
1647        // No greeks -> a bounded ATM-relative window: neither empty nor the full chain.
1648        // The exact window width is asserted in the model-level resolve test.
1649        assert!(active.len() > 2);
1650        assert!(active.len() < strikes.len() * 2);
1651        assert!(active.contains(&option_id(46000, OptionKind::Call))); // ATM included
1652        assert!(!active.contains(&option_id(40000, OptionKind::Call))); // extreme excluded
1653        assert!(!active.contains(&option_id(52000, OptionKind::Call))); // extreme excluded
1654    }
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        // Greeks arrive before any quote, so they land in pending_greeks.
1670        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        // Pending-only greeks are eligible for delta resolution.
1682        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); // fallback: all 3 strikes
1693
1694        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); // fallback: all 3 strikes
1735
1736        // Greeks arrive; only 55000 matches. The closest ATM strike is unchanged.
1737        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); // narrowed to the 55000 legs
1752        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        // First rebalance narrows to the 55000 legs.
1772        let action = agg.check_rebalance(now()).unwrap();
1773        agg.apply_rebalance(&action, now());
1774        assert_eq!(agg.instrument_ids().len(), 2);
1775
1776        // Greeks unchanged -> stable set -> no-op suppressed (cooldown disabled).
1777        assert!(agg.check_rebalance(now()).is_none());
1778    }
1779}