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::{DurationNanos, UnixNanos};
24use nautilus_model::{
25    data::{
26        QuoteTick,
27        option_chain::{OptionChainSlice, OptionGreeks, OptionStrikeData, StrikeRange},
28    },
29    enums::OptionKind,
30    identifiers::{InstrumentId, OptionSeriesId},
31    types::Price,
32};
33use rust_decimal::prelude::ToPrimitive;
34
35use super::{
36    AtmTracker,
37    constants::{DEFAULT_REBALANCE_COOLDOWN, DEFAULT_REBALANCE_HYSTERESIS},
38};
39
40/// 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: DurationNanos,
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,
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.is_zero()
593            && let Some(last_ts) = self.last_rebalance_ns
594            && now_ns.saturating_duration_since(last_ts) < self.cooldown_ns
595        {
596            return None;
597        }
598
599        // 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: DurationNanos) {
689        self.cooldown_ns = ns;
690    }
691
692    fn pending_greeks_count(&self) -> usize {
693        self.pending_greeks.len()
694    }
695}
696
697#[cfg(test)]
698mod tests {
699    use nautilus_model::{data::greeks::OptionGreekValues, identifiers::Venue, types::Quantity};
700    use rstest::*;
701
702    use super::*;
703
704    fn make_series_id() -> OptionSeriesId {
705        OptionSeriesId::new(
706            Venue::new("DERIBIT"),
707            ustr::Ustr::from("BTC"),
708            ustr::Ustr::from("BTC"),
709            UnixNanos::from(1_700_000_000_000_000_000u64),
710        )
711    }
712
713    fn make_quote(instrument_id: InstrumentId, bid: &str, ask: &str) -> QuoteTick {
714        QuoteTick::new(
715            instrument_id,
716            Price::from(bid),
717            Price::from(ask),
718            Quantity::from("1.0"),
719            Quantity::from("1.0"),
720            UnixNanos::from(1u64),
721            UnixNanos::from(1u64),
722        )
723    }
724
725    fn now() -> UnixNanos {
726        // 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_preserves_subprecision_atm() {
762        let mut atm = Price::from("100.75");
763        atm.precision = 0;
764        let strikes = [Price::from("100"), Price::from("101")];
765        assert_eq!(
766            OptionChainAggregator::find_closest_strike(&strikes, atm),
767            Some(strikes[1])
768        );
769    }
770
771    #[rstest]
772    fn test_find_closest_strike_prefers_exact_high_value_match() {
773        let collapsed = Price::from("9007199253.999000000");
774        let atm = Price::from("9007199253.999000001");
775        let strikes = [collapsed, atm];
776        assert_eq!(collapsed.as_f64(), atm.as_f64());
777
778        let result = OptionChainAggregator::find_closest_strike(&strikes, atm);
779
780        assert_eq!(result, Some(atm));
781    }
782
783    #[rstest]
784    fn test_aggregator_instrument_ids() {
785        let (agg, call_id, put_id) = make_aggregator();
786        let ids = agg.instrument_ids();
787        assert_eq!(ids.len(), 2);
788        assert!(ids.contains(&call_id));
789        assert!(ids.contains(&put_id));
790    }
791
792    #[rstest]
793    fn test_aggregator_update_quote() {
794        let (mut agg, call_id, _) = make_aggregator();
795        let quote = make_quote(call_id, "100.00", "101.00");
796
797        agg.update_quote(&quote);
798
799        assert_eq!(agg.call_buffer_len(), 1);
800        assert_eq!(agg.put_buffer_len(), 0);
801    }
802
803    #[rstest]
804    fn test_aggregator_update_greeks() {
805        let (mut agg, call_id, _) = make_aggregator();
806        let quote = make_quote(call_id, "100.00", "101.00");
807        agg.update_quote(&quote);
808
809        let greeks = OptionGreeks {
810            instrument_id: call_id,
811            greeks: OptionGreekValues {
812                delta: 0.55,
813                ..Default::default()
814            },
815            ..Default::default()
816        };
817        agg.update_greeks(&greeks);
818
819        let strike = Price::from("50000");
820        let data = agg.get_call_greeks_from_buffer(&strike);
821        assert!(data.is_some());
822        assert_eq!(data.unwrap().delta, 0.55);
823    }
824
825    #[rstest]
826    fn test_aggregator_snapshot_preserves_state() {
827        let (mut agg, call_id, _) = make_aggregator();
828        let quote = make_quote(call_id, "100.00", "101.00");
829        agg.update_quote(&quote);
830
831        let slice = agg.snapshot(UnixNanos::from(100u64));
832        assert_eq!(slice.call_count(), 1);
833        assert_eq!(slice.ts_init, UnixNanos::from(100u64));
834
835        // Buffers should still contain data (keep-latest semantics)
836        assert!(!agg.is_buffer_empty());
837
838        // Second snapshot should return the same data
839        let slice2 = agg.snapshot(UnixNanos::from(200u64));
840        assert_eq!(slice2.call_count(), 1);
841        assert_eq!(slice2.ts_init, UnixNanos::from(200u64));
842    }
843
844    #[rstest]
845    fn test_aggregator_ignores_unknown_instrument() {
846        let (mut agg, _, _) = make_aggregator();
847        let unknown_id = InstrumentId::from("ETH-20240101-3000-C.DERIBIT");
848        let quote = make_quote(unknown_id, "100.00", "101.00");
849
850        agg.update_quote(&quote);
851
852        assert!(agg.is_buffer_empty());
853    }
854
855    #[rstest]
856    fn test_check_rebalance_returns_none() {
857        let (agg, _, _) = make_aggregator();
858        assert!(agg.check_rebalance(now()).is_none());
859    }
860
861    // -- Rebalance tests --
862
863    /// Builds instruments with 5 strike prices (45000..55000 step 2500) and `AtmRelative` +-1.
864    /// Hysteresis and cooldown are disabled so existing rebalance tests pass unchanged.
865    fn make_multi_strike_aggregator() -> OptionChainAggregator {
866        let strikes = [45000, 47500, 50000, 52500, 55000];
867        let mut instruments = HashMap::new();
868
869        for s in &strikes {
870            let strike = Price::from(&s.to_string());
871            let call_id = InstrumentId::from(&format!("BTC-20240101-{s}-C.DERIBIT"));
872            let put_id = InstrumentId::from(&format!("BTC-20240101-{s}-P.DERIBIT"));
873            instruments.insert(call_id, (strike, OptionKind::Call));
874            instruments.insert(put_id, (strike, OptionKind::Put));
875        }
876
877        let tracker = AtmTracker::new();
878        let mut agg = OptionChainAggregator::new(
879            make_series_id(),
880            StrikeRange::AtmRelative {
881                strikes_above: 1,
882                strikes_below: 1,
883            },
884            tracker,
885            instruments,
886        );
887        // Disable guards so existing tests exercise pure rebalance logic
888        agg.set_hysteresis(0.0);
889        agg.set_cooldown_ns(DurationNanos::default());
890        agg
891    }
892
893    #[rstest]
894    fn test_check_rebalance_fixed_always_none() {
895        // Fixed range + ATM price set: still returns None
896        let (mut agg, _, _) = make_aggregator();
897        set_atm_via_greeks(&mut agg, 50000.0);
898        assert!(agg.check_rebalance(now()).is_none());
899    }
900
901    #[rstest]
902    fn test_check_rebalance_no_atm_returns_none() {
903        let agg = make_multi_strike_aggregator();
904        // No ATM price set: None
905        assert!(agg.check_rebalance(now()).is_none());
906    }
907
908    #[rstest]
909    fn test_check_rebalance_atm_unchanged_returns_none() {
910        let mut agg = make_multi_strike_aggregator();
911        // Set ATM to 50000 and apply initial rebalance
912        set_atm_via_greeks(&mut agg, 50000.0);
913        // First check detects ATM shift from None to 50000
914        let action = agg.check_rebalance(now()).unwrap();
915        agg.apply_rebalance(&action, now());
916
917        // ATM moves slightly but stays closest to 50000
918        set_atm_via_greeks(&mut agg, 50200.0);
919        assert!(agg.check_rebalance(now()).is_none());
920    }
921
922    #[rstest]
923    fn test_check_rebalance_detects_atm_shift() {
924        let mut agg = make_multi_strike_aggregator();
925        // Set ATM near 50000
926        set_atm_via_greeks(&mut agg, 50000.0);
927        let action = agg.check_rebalance(now()).unwrap();
928        agg.apply_rebalance(&action, now());
929        // Active: 47500, 50000, 52500 (ATM=50000, +-1 strike)
930        assert_eq!(agg.instrument_ids().len(), 6); // 3 strikes * 2
931
932        // Now shift ATM to 55000
933        set_atm_via_greeks(&mut agg, 55000.0);
934        let action2 = agg.check_rebalance(now()).unwrap();
935        // Should have instruments to add (55000) and remove (47500)
936        assert!(!action2.add.is_empty() || !action2.remove.is_empty());
937    }
938
939    #[rstest]
940    fn test_apply_rebalance_updates_instrument_map() {
941        let mut agg = make_multi_strike_aggregator();
942        // Set ATM near 50000
943        set_atm_via_greeks(&mut agg, 50000.0);
944        let action = agg.check_rebalance(now()).unwrap();
945        agg.apply_rebalance(&action, now());
946
947        // Active should be 3 strikes (47500, 50000, 52500)
948        let active_ids = agg.instrument_ids();
949        assert_eq!(active_ids.len(), 6); // 3 strikes * 2 (call + put)
950
951        // Now shift to 55000
952        set_atm_via_greeks(&mut agg, 55000.0);
953        let action2 = agg.check_rebalance(now()).unwrap();
954        agg.apply_rebalance(&action2, now());
955
956        // Active should now be (52500, 55000): 2 strikes at the top end
957        let active_ids2 = agg.instrument_ids();
958        assert_eq!(active_ids2.len(), 4); // 2 strikes * 2
959    }
960
961    #[rstest]
962    fn test_apply_rebalance_cleans_buffers() {
963        let mut agg = make_multi_strike_aggregator();
964        // Set ATM near 50000
965        set_atm_via_greeks(&mut agg, 50000.0);
966        let action = agg.check_rebalance(now()).unwrap();
967        agg.apply_rebalance(&action, now());
968
969        // Feed quotes for the 47500 call
970        let call_47500 = InstrumentId::from("BTC-20240101-47500-C.DERIBIT");
971        let quote = make_quote(call_47500, "100.00", "101.00");
972        agg.update_quote(&quote);
973        assert_eq!(agg.call_buffer_len(), 1);
974
975        // Now shift ATM up so 47500 is out of range
976        set_atm_via_greeks(&mut agg, 55000.0);
977        let action2 = agg.check_rebalance(now()).unwrap();
978        agg.apply_rebalance(&action2, now());
979
980        // Buffer for 47500 should be cleaned
981        assert_eq!(agg.call_buffer_len(), 0);
982    }
983
984    #[rstest]
985    fn test_initial_active_set_empty_when_no_atm() {
986        let agg = make_multi_strike_aggregator();
987        // AtmRelative with no ATM price: empty active set (deferred)
988        assert_eq!(agg.instrument_ids().len(), 0);
989        assert_eq!(agg.all_instrument_ids().len(), 10);
990    }
991
992    #[rstest]
993    fn test_catalog_vs_active_separation() {
994        let mut agg = make_multi_strike_aggregator();
995        // Set ATM near 50000 to narrow active set
996        set_atm_via_greeks(&mut agg, 50000.0);
997        let action = agg.check_rebalance(now()).unwrap();
998        agg.apply_rebalance(&action, now());
999
1000        // Catalog should still have all 10 instruments
1001        assert_eq!(agg.instruments().len(), 10);
1002        // Active should be a subset
1003        assert_eq!(agg.instrument_ids().len(), 6);
1004    }
1005
1006    // -- add_instrument tests --
1007
1008    #[rstest]
1009    fn test_add_instrument_already_known() {
1010        let (mut agg, call_id, _) = make_aggregator();
1011        let strike = Price::from("50000");
1012        let count_before = agg.instruments().len();
1013
1014        let result = agg.add_instrument(call_id, strike, OptionKind::Call);
1015
1016        assert!(!result);
1017        assert_eq!(agg.instruments().len(), count_before);
1018    }
1019
1020    #[rstest]
1021    fn test_add_instrument_new_in_active_range() {
1022        let (mut agg, _, _) = make_aggregator();
1023        // Fixed range includes strike 50000; adding another instrument at same strike
1024        let new_id = InstrumentId::from("BTC-20240101-50000-C2.DERIBIT");
1025        let strike = Price::from("50000");
1026
1027        let result = agg.add_instrument(new_id, strike, OptionKind::Call);
1028
1029        assert!(result);
1030        assert_eq!(agg.instruments().len(), 3);
1031        assert!(agg.active_ids().contains(&new_id));
1032    }
1033
1034    #[rstest]
1035    fn test_add_instrument_new_out_of_range() {
1036        let (mut agg, _, _) = make_aggregator();
1037        // Fixed range only includes 50000; adding instrument at 60000
1038        let new_id = InstrumentId::from("BTC-20240101-60000-C.DERIBIT");
1039        let strike = Price::from("60000");
1040
1041        let result = agg.add_instrument(new_id, strike, OptionKind::Call);
1042
1043        assert!(result);
1044        assert_eq!(agg.instruments().len(), 3);
1045        assert!(!agg.active_ids().contains(&new_id));
1046    }
1047
1048    #[rstest]
1049    fn test_add_instrument_available_for_rebalance() {
1050        let mut agg = make_multi_strike_aggregator();
1051        // Set ATM near 50000 and apply initial rebalance
1052        set_atm_via_greeks(&mut agg, 50000.0);
1053        let action = agg.check_rebalance(now()).unwrap();
1054        agg.apply_rebalance(&action, now());
1055        // Active: 47500, 50000, 52500 (6 instruments)
1056        assert_eq!(agg.instrument_ids().len(), 6);
1057
1058        // Add a new instrument at strike 57500 (out of current range)
1059        let new_id = InstrumentId::from("BTC-20240101-57500-C.DERIBIT");
1060        let strike = Price::from("57500");
1061        let result = agg.add_instrument(new_id, strike, OptionKind::Call);
1062        assert!(result);
1063        assert!(!agg.active_ids().contains(&new_id));
1064
1065        // Shift ATM to 57500: rebalance should pick up the new instrument
1066        set_atm_via_greeks(&mut agg, 57500.0);
1067        let action2 = agg.check_rebalance(now()).unwrap();
1068        agg.apply_rebalance(&action2, now());
1069
1070        assert!(agg.active_ids().contains(&new_id));
1071    }
1072
1073    // -- Hysteresis tests --
1074
1075    #[rstest]
1076    fn test_hysteresis_blocks_small_movement() {
1077        let strikes = [47500, 50000, 52500];
1078        let mut instruments = HashMap::new();
1079
1080        for s in &strikes {
1081            let strike = Price::from(&s.to_string());
1082            let call_id = InstrumentId::from(&format!("BTC-20240101-{s}-C.DERIBIT"));
1083            instruments.insert(call_id, (strike, OptionKind::Call));
1084        }
1085        let tracker = AtmTracker::new();
1086        let mut agg = OptionChainAggregator::new(
1087            make_series_id(),
1088            StrikeRange::AtmRelative {
1089                strikes_above: 1,
1090                strikes_below: 1,
1091            },
1092            tracker,
1093            instruments,
1094        );
1095        agg.set_hysteresis(0.6);
1096        agg.set_cooldown_ns(DurationNanos::default());
1097
1098        // Set ATM to 50000
1099        set_atm_via_greeks(&mut agg, 50000.0);
1100        let action = agg.check_rebalance(now()).unwrap();
1101        agg.apply_rebalance(&action, now());
1102        assert_eq!(agg.last_atm_strike(), Some(Price::from("50000")));
1103
1104        // Move ATM slightly toward 52500: gap=2500, threshold=50000+0.6*2500=51500
1105        // 51000 does NOT cross 51500
1106        set_atm_via_greeks(&mut agg, 51000.0);
1107        assert!(agg.check_rebalance(now()).is_none());
1108    }
1109
1110    #[rstest]
1111    fn test_hysteresis_allows_large_movement() {
1112        let strikes = [47500, 50000, 52500];
1113        let mut instruments = HashMap::new();
1114
1115        for s in &strikes {
1116            let strike = Price::from(&s.to_string());
1117            let call_id = InstrumentId::from(&format!("BTC-20240101-{s}-C.DERIBIT"));
1118            instruments.insert(call_id, (strike, OptionKind::Call));
1119        }
1120        let tracker = AtmTracker::new();
1121        let mut agg = OptionChainAggregator::new(
1122            make_series_id(),
1123            StrikeRange::AtmRelative {
1124                strikes_above: 1,
1125                strikes_below: 1,
1126            },
1127            tracker,
1128            instruments,
1129        );
1130        agg.set_hysteresis(0.6);
1131        agg.set_cooldown_ns(DurationNanos::default());
1132
1133        // Set ATM to 50000
1134        set_atm_via_greeks(&mut agg, 50000.0);
1135        let action = agg.check_rebalance(now()).unwrap();
1136        agg.apply_rebalance(&action, now());
1137
1138        // Move ATM well past threshold: 52000 > 51500
1139        set_atm_via_greeks(&mut agg, 52000.0);
1140        assert!(agg.check_rebalance(now()).is_some());
1141    }
1142
1143    #[rstest]
1144    fn test_hysteresis_exact_strike_gap_boundary() {
1145        let lower = Price::from("9007199253.999000000");
1146        let upper = Price::from("9007199253.999002800");
1147        let blocked = Price::from("9007199253.999001673");
1148        let allowed = Price::from("9007199253.999001680");
1149        let mut instruments = HashMap::new();
1150        instruments.insert(
1151            InstrumentId::from("BTC-LOW-C.DERIBIT"),
1152            (lower, OptionKind::Call),
1153        );
1154        instruments.insert(
1155            InstrumentId::from("BTC-HIGH-C.DERIBIT"),
1156            (upper, OptionKind::Call),
1157        );
1158        let mut tracker = AtmTracker::new();
1159        tracker.set_initial_price(lower);
1160        let mut agg = OptionChainAggregator::new(
1161            make_series_id(),
1162            StrikeRange::AtmRelative {
1163                strikes_above: 0,
1164                strikes_below: 0,
1165            },
1166            tracker,
1167            instruments,
1168        );
1169        agg.set_hysteresis(0.6);
1170        agg.set_cooldown_ns(DurationNanos::default());
1171
1172        agg.atm_tracker_mut().set_initial_price(blocked);
1173        assert!(agg.check_rebalance(now()).is_none());
1174
1175        agg.atm_tracker_mut().set_initial_price(allowed);
1176        assert!(agg.check_rebalance(now()).is_some());
1177    }
1178
1179    #[rstest]
1180    fn test_zero_hysteresis_disables_guard() {
1181        let mut agg = make_multi_strike_aggregator();
1182        agg.set_hysteresis(0.0);
1183        agg.set_cooldown_ns(DurationNanos::default());
1184
1185        set_atm_via_greeks(&mut agg, 50000.0);
1186        let action = agg.check_rebalance(now()).unwrap();
1187        agg.apply_rebalance(&action, now());
1188
1189        // Any shift past the strike boundary triggers rebalance
1190        set_atm_via_greeks(&mut agg, 52500.0);
1191        assert!(agg.check_rebalance(now()).is_some());
1192    }
1193
1194    // -- Cooldown tests --
1195
1196    #[rstest]
1197    fn test_cooldown_blocks_rapid_rebalance() {
1198        let mut agg = make_multi_strike_aggregator();
1199        agg.set_hysteresis(0.0);
1200        agg.set_cooldown_ns(DurationNanos::from_secs(5));
1201
1202        set_atm_via_greeks(&mut agg, 50000.0);
1203        let t0 = now();
1204        let action = agg.check_rebalance(t0).unwrap();
1205        agg.apply_rebalance(&action, t0);
1206
1207        // Shift ATM immediately: cooldown blocks
1208        set_atm_via_greeks(&mut agg, 55000.0);
1209        let t1 = UnixNanos::from(t0.as_u64() + 1_000_000_000); // 1s later
1210        assert!(agg.check_rebalance(t1).is_none());
1211    }
1212
1213    #[rstest]
1214    fn test_cooldown_allows_after_elapsed() {
1215        let mut agg = make_multi_strike_aggregator();
1216        agg.set_hysteresis(0.0);
1217        agg.set_cooldown_ns(DurationNanos::from_secs(5));
1218
1219        set_atm_via_greeks(&mut agg, 50000.0);
1220        let t0 = now();
1221        let action = agg.check_rebalance(t0).unwrap();
1222        agg.apply_rebalance(&action, t0);
1223
1224        // Shift ATM after cooldown elapses
1225        set_atm_via_greeks(&mut agg, 55000.0);
1226        let t1 = UnixNanos::from(t0.as_u64() + 6_000_000_000); // 6s later
1227        assert!(agg.check_rebalance(t1).is_some());
1228    }
1229
1230    #[rstest]
1231    fn test_zero_cooldown_disables_guard() {
1232        let mut agg = make_multi_strike_aggregator();
1233        agg.set_hysteresis(0.0);
1234        agg.set_cooldown_ns(DurationNanos::default());
1235
1236        set_atm_via_greeks(&mut agg, 50000.0);
1237        let t0 = now();
1238        let action = agg.check_rebalance(t0).unwrap();
1239        agg.apply_rebalance(&action, t0);
1240
1241        // Shift ATM immediately: no cooldown block
1242        set_atm_via_greeks(&mut agg, 55000.0);
1243        assert!(agg.check_rebalance(t0).is_some());
1244    }
1245
1246    // -- Pending greeks tests --
1247
1248    #[rstest]
1249    fn test_pending_greeks_consumed_on_first_quote() {
1250        let (mut agg, call_id, _) = make_aggregator();
1251
1252        // Send greeks before any quote
1253        let greeks = OptionGreeks {
1254            instrument_id: call_id,
1255            greeks: OptionGreekValues {
1256                delta: 0.55,
1257                ..Default::default()
1258            },
1259            ..Default::default()
1260        };
1261        agg.update_greeks(&greeks);
1262        assert_eq!(agg.pending_greeks_count(), 1);
1263
1264        // Now send the first quote: pending greeks should be consumed
1265        let quote = make_quote(call_id, "100.00", "101.00");
1266        agg.update_quote(&quote);
1267        assert_eq!(agg.pending_greeks_count(), 0);
1268
1269        // Verify greeks were attached
1270        let strike = Price::from("50000");
1271        let data = agg.get_call_greeks_from_buffer(&strike);
1272        assert!(data.is_some());
1273        assert_eq!(data.unwrap().delta, 0.55);
1274    }
1275
1276    // -- ts_event tracking tests --
1277
1278    #[rstest]
1279    fn test_snapshot_ts_event_reflects_max_quote_timestamp() {
1280        let (mut agg, call_id, put_id) = make_aggregator();
1281
1282        let quote1 = QuoteTick::new(
1283            call_id,
1284            Price::from("100.00"),
1285            Price::from("101.00"),
1286            Quantity::from("1.0"),
1287            Quantity::from("1.0"),
1288            UnixNanos::from(500u64), // ts_event
1289            UnixNanos::from(500u64),
1290        );
1291        agg.update_quote(&quote1);
1292
1293        let quote2 = QuoteTick::new(
1294            put_id,
1295            Price::from("50.00"),
1296            Price::from("51.00"),
1297            Quantity::from("1.0"),
1298            Quantity::from("1.0"),
1299            UnixNanos::from(800u64), // ts_event: later
1300            UnixNanos::from(800u64),
1301        );
1302        agg.update_quote(&quote2);
1303
1304        let slice = agg.snapshot(UnixNanos::from(1000u64));
1305        assert_eq!(slice.ts_event, UnixNanos::from(800u64));
1306        assert_eq!(slice.ts_init, UnixNanos::from(1000u64));
1307    }
1308
1309    #[rstest]
1310    fn test_snapshot_ts_event_fallback_when_no_quotes() {
1311        let (agg, _, _) = make_aggregator();
1312        let slice = agg.snapshot(UnixNanos::from(1000u64));
1313        // No quotes: ts_event falls back to ts_init
1314        assert_eq!(slice.ts_event, UnixNanos::from(1000u64));
1315    }
1316
1317    #[rstest]
1318    fn test_snapshot_retains_buffered_data_during_hysteresis_window() {
1319        // Setup: 3 strikes at 47500/50000/52500, AtmRelative +-1, hysteresis enabled
1320        let strikes = [47500, 50000, 52500];
1321        let mut instruments = HashMap::new();
1322
1323        for s in &strikes {
1324            let strike = Price::from(&s.to_string());
1325            let call_id = InstrumentId::from(&format!("BTC-20240101-{s}-C.DERIBIT"));
1326            instruments.insert(call_id, (strike, OptionKind::Call));
1327        }
1328        let tracker = AtmTracker::new();
1329        let mut agg = OptionChainAggregator::new(
1330            make_series_id(),
1331            StrikeRange::AtmRelative {
1332                strikes_above: 1,
1333                strikes_below: 1,
1334            },
1335            tracker,
1336            instruments,
1337        );
1338        agg.set_hysteresis(0.6);
1339        agg.set_cooldown_ns(DurationNanos::default());
1340
1341        // Set ATM to 50000, rebalance -> active: {47500, 50000, 52500}
1342        set_atm_via_greeks(&mut agg, 50000.0);
1343        let action = agg.check_rebalance(now()).unwrap();
1344        agg.apply_rebalance(&action, now());
1345        assert_eq!(agg.instrument_ids().len(), 3);
1346
1347        // Buffer quotes for all active strikes
1348        let q1 = make_quote(
1349            InstrumentId::from("BTC-20240101-47500-C.DERIBIT"),
1350            "3000.00",
1351            "3100.00",
1352        );
1353        let q2 = make_quote(
1354            InstrumentId::from("BTC-20240101-50000-C.DERIBIT"),
1355            "1500.00",
1356            "1600.00",
1357        );
1358        let q3 = make_quote(
1359            InstrumentId::from("BTC-20240101-52500-C.DERIBIT"),
1360            "500.00",
1361            "600.00",
1362        );
1363        agg.update_quote(&q1);
1364        agg.update_quote(&q2);
1365        agg.update_quote(&q3);
1366        assert_eq!(agg.call_buffer_len(), 3);
1367
1368        // Move ATM slightly toward 52500 but within hysteresis band (no rebalance)
1369        set_atm_via_greeks(&mut agg, 51000.0);
1370        assert!(agg.check_rebalance(now()).is_none());
1371
1372        // Snapshot must still include all 3 buffered strikes
1373        let slice = agg.snapshot(UnixNanos::from(100u64));
1374        assert_eq!(slice.call_count(), 3);
1375    }
1376
1377    #[rstest]
1378    fn test_remove_instrument_from_catalog() {
1379        let (mut agg, call_id, put_id) = make_aggregator();
1380        assert_eq!(agg.instruments().len(), 2);
1381
1382        let removed = agg.remove_instrument(&call_id);
1383        assert!(removed);
1384        assert_eq!(agg.instruments().len(), 1);
1385        assert!(!agg.active_ids().contains(&call_id));
1386        assert!(agg.instruments().contains_key(&put_id));
1387    }
1388
1389    #[rstest]
1390    fn test_remove_instrument_cleans_buffer() {
1391        let (mut agg, call_id, _) = make_aggregator();
1392        let quote = make_quote(call_id, "100.00", "101.00");
1393        agg.update_quote(&quote);
1394        assert_eq!(agg.call_buffer_len(), 1);
1395
1396        let _ = agg.remove_instrument(&call_id);
1397        // No sibling call at same strike, buffer entry should be removed
1398        assert_eq!(agg.call_buffer_len(), 0);
1399    }
1400
1401    #[rstest]
1402    fn test_remove_instrument_preserves_sibling_buffer() {
1403        let (mut agg, call_id, _) = make_aggregator();
1404        // Add a second call at the same strike
1405        let sibling_id = InstrumentId::from("BTC-20240101-50000-C2.DERIBIT");
1406        let strike = Price::from("50000");
1407        let _ = agg.add_instrument(sibling_id, strike, OptionKind::Call);
1408
1409        let quote = make_quote(call_id, "100.00", "101.00");
1410        agg.update_quote(&quote);
1411        assert_eq!(agg.call_buffer_len(), 1);
1412
1413        // Remove original: sibling still shares the strike+kind
1414        let _ = agg.remove_instrument(&call_id);
1415        assert_eq!(agg.call_buffer_len(), 1); // buffer preserved
1416        assert!(agg.instruments().contains_key(&sibling_id));
1417    }
1418
1419    #[rstest]
1420    fn test_remove_instrument_unknown_noop() {
1421        let (mut agg, _, _) = make_aggregator();
1422        let unknown = InstrumentId::from("ETH-20240101-3000-C.DERIBIT");
1423        assert!(!agg.remove_instrument(&unknown));
1424        assert_eq!(agg.instruments().len(), 2);
1425    }
1426
1427    #[rstest]
1428    fn test_remove_instrument_cleans_pending_greeks() {
1429        let (mut agg, call_id, _) = make_aggregator();
1430        let greeks = OptionGreeks {
1431            instrument_id: call_id,
1432            greeks: OptionGreekValues {
1433                delta: 0.55,
1434                ..Default::default()
1435            },
1436            ..Default::default()
1437        };
1438        agg.update_greeks(&greeks);
1439        assert_eq!(agg.pending_greeks_count(), 1);
1440
1441        let _ = agg.remove_instrument(&call_id);
1442        assert_eq!(agg.pending_greeks_count(), 0);
1443    }
1444
1445    #[rstest]
1446    fn test_is_catalog_empty_after_full_removal() {
1447        let (mut agg, call_id, put_id) = make_aggregator();
1448        assert!(!agg.is_catalog_empty());
1449
1450        let _ = agg.remove_instrument(&call_id);
1451        assert!(!agg.is_catalog_empty());
1452
1453        let _ = agg.remove_instrument(&put_id);
1454        assert!(agg.is_catalog_empty());
1455    }
1456
1457    // -- Expiry guard tests --
1458
1459    #[rstest]
1460    fn test_expired_quote_is_dropped() {
1461        let (mut agg, call_id, _) = make_aggregator();
1462        // Series expires at 1_700_000_000_000_000_000; send quote AT that timestamp
1463        let expired_quote = QuoteTick::new(
1464            call_id,
1465            Price::from("100.00"),
1466            Price::from("101.00"),
1467            Quantity::from("1.0"),
1468            Quantity::from("1.0"),
1469            UnixNanos::from(1_700_000_000_000_000_000u64),
1470            UnixNanos::from(1_700_000_000_000_000_000u64),
1471        );
1472        agg.update_quote(&expired_quote);
1473        assert!(agg.is_buffer_empty());
1474    }
1475
1476    #[rstest]
1477    fn test_expired_greeks_are_dropped() {
1478        let (mut agg, call_id, _) = make_aggregator();
1479        // First add a valid quote so greeks would normally land in the buffer
1480        let quote = make_quote(call_id, "100.00", "101.00");
1481        agg.update_quote(&quote);
1482        assert_eq!(agg.call_buffer_len(), 1);
1483
1484        // Send greeks at expiry timestamp: should be dropped
1485        let greeks = OptionGreeks {
1486            instrument_id: call_id,
1487            ts_event: UnixNanos::from(1_700_000_000_000_000_000u64),
1488            greeks: OptionGreekValues {
1489                delta: 0.55,
1490                ..Default::default()
1491            },
1492            ..Default::default()
1493        };
1494        agg.update_greeks(&greeks);
1495
1496        let strike = Price::from("50000");
1497        assert!(agg.get_call_greeks_from_buffer(&strike).is_none());
1498    }
1499
1500    // -- Delta range tests --
1501
1502    /// Builds a `Delta`-range aggregator over `strikes` (call + put per strike),
1503    /// with hysteresis and cooldown disabled so rebalance decisions reflect pure
1504    /// delta resolution.
1505    fn make_delta_aggregator(
1506        strikes: &[i64],
1507        target: f64,
1508        tolerance: f64,
1509    ) -> OptionChainAggregator {
1510        let mut instruments = HashMap::new();
1511
1512        for s in strikes {
1513            let strike = Price::from(&s.to_string());
1514            instruments.insert(option_id(*s, OptionKind::Call), (strike, OptionKind::Call));
1515            instruments.insert(option_id(*s, OptionKind::Put), (strike, OptionKind::Put));
1516        }
1517        let tracker = AtmTracker::new();
1518        let mut agg = OptionChainAggregator::new(
1519            make_series_id(),
1520            StrikeRange::Delta { target, tolerance },
1521            tracker,
1522            instruments,
1523        );
1524        agg.set_hysteresis(0.0);
1525        agg.set_cooldown_ns(DurationNanos::default());
1526        agg
1527    }
1528
1529    fn option_id(strike: i64, kind: OptionKind) -> InstrumentId {
1530        let suffix = match kind {
1531            OptionKind::Call => "C",
1532            OptionKind::Put => "P",
1533        };
1534        InstrumentId::from(&format!("BTC-20240101-{strike}-{suffix}.DERIBIT"))
1535    }
1536
1537    /// Feeds a quote then greeks (with the given `delta`) for one option leg.
1538    fn feed_quote_and_greeks(
1539        agg: &mut OptionChainAggregator,
1540        strike: i64,
1541        kind: OptionKind,
1542        delta: f64,
1543    ) {
1544        let id = option_id(strike, kind);
1545        agg.update_quote(&make_quote(id, "100.00", "101.00"));
1546        agg.update_greeks(&OptionGreeks {
1547            instrument_id: id,
1548            greeks: OptionGreekValues {
1549                delta,
1550                ..Default::default()
1551            },
1552            ..Default::default()
1553        });
1554    }
1555
1556    #[rstest]
1557    #[case(0.30, 0.30, 0.03, true)] // exact target
1558    #[case(-0.30, 0.30, 0.03, true)] // negative delta, magnitude matches
1559    #[case(0.28, 0.30, 0.03, true)] // inside band, below target
1560    #[case(0.32, 0.30, 0.03, true)] // inside band, above target
1561    #[case(0.20, 0.30, 0.03, false)] // below band
1562    #[case(0.40, 0.30, 0.03, false)] // above band
1563    fn test_delta_within_band(
1564        #[case] delta: f64,
1565        #[case] target: f64,
1566        #[case] tolerance: f64,
1567        #[case] expected: bool,
1568    ) {
1569        assert_eq!(
1570            OptionChainAggregator::delta_within_band(delta, target, tolerance),
1571            expected
1572        );
1573    }
1574
1575    #[rstest]
1576    fn test_delta_target_hit() {
1577        let strikes = [40000, 45000, 50000, 55000, 60000];
1578        let mut agg = make_delta_aggregator(&strikes, 0.30, 0.03);
1579        // Bootstrap the ATM-relative fallback so the window is active and greeks can land.
1580        set_atm_via_greeks(&mut agg, 50000.0);
1581        agg.recompute_active_set();
1582        assert_eq!(agg.instrument_ids().len(), 10); // all 5 strikes (fallback)
1583
1584        // Only the 55000 call sits at the 0.30 target.
1585        feed_quote_and_greeks(&mut agg, 40000, OptionKind::Call, 0.95);
1586        feed_quote_and_greeks(&mut agg, 40000, OptionKind::Put, -0.95);
1587        feed_quote_and_greeks(&mut agg, 45000, OptionKind::Call, 0.80);
1588        feed_quote_and_greeks(&mut agg, 45000, OptionKind::Put, -0.80);
1589        feed_quote_and_greeks(&mut agg, 50000, OptionKind::Call, 0.55);
1590        feed_quote_and_greeks(&mut agg, 50000, OptionKind::Put, -0.55);
1591        feed_quote_and_greeks(&mut agg, 55000, OptionKind::Call, 0.30);
1592        feed_quote_and_greeks(&mut agg, 55000, OptionKind::Put, -0.12);
1593        feed_quote_and_greeks(&mut agg, 60000, OptionKind::Call, 0.12);
1594        feed_quote_and_greeks(&mut agg, 60000, OptionKind::Put, -0.10);
1595
1596        let active = agg.recompute_active_set();
1597        assert_eq!(active.len(), 2); // 55000 call + put
1598        assert!(active.contains(&option_id(55000, OptionKind::Call)));
1599        assert!(active.contains(&option_id(55000, OptionKind::Put)));
1600    }
1601
1602    #[rstest]
1603    fn test_delta_tolerance_band() {
1604        let strikes = [45000, 50000, 55000, 60000];
1605        let mut agg = make_delta_aggregator(&strikes, 0.30, 0.05);
1606        set_atm_via_greeks(&mut agg, 50000.0);
1607        agg.recompute_active_set();
1608
1609        // Band is [0.25, 0.35]: 0.50 and 0.10 are outside, 0.32 and 0.30 inside.
1610        feed_quote_and_greeks(&mut agg, 45000, OptionKind::Call, 0.50);
1611        feed_quote_and_greeks(&mut agg, 45000, OptionKind::Put, -0.50);
1612        feed_quote_and_greeks(&mut agg, 50000, OptionKind::Call, 0.32);
1613        feed_quote_and_greeks(&mut agg, 50000, OptionKind::Put, -0.50);
1614        feed_quote_and_greeks(&mut agg, 55000, OptionKind::Call, 0.30);
1615        feed_quote_and_greeks(&mut agg, 55000, OptionKind::Put, -0.50);
1616        feed_quote_and_greeks(&mut agg, 60000, OptionKind::Call, 0.10);
1617        feed_quote_and_greeks(&mut agg, 60000, OptionKind::Put, -0.10);
1618
1619        let active = agg.recompute_active_set();
1620        assert_eq!(active.len(), 4); // 50000 + 55000, both legs each
1621        assert!(active.contains(&option_id(50000, OptionKind::Call)));
1622        assert!(active.contains(&option_id(55000, OptionKind::Call)));
1623        assert!(!active.contains(&option_id(45000, OptionKind::Call)));
1624        assert!(!active.contains(&option_id(60000, OptionKind::Call)));
1625    }
1626
1627    #[rstest]
1628    fn test_delta_matches_put_by_magnitude() {
1629        let strikes = [45000, 50000, 55000];
1630        let mut agg = make_delta_aggregator(&strikes, 0.30, 0.03);
1631        set_atm_via_greeks(&mut agg, 50000.0);
1632        agg.recompute_active_set();
1633
1634        // Only the 45000 put matches the target, isolating put-side matching.
1635        feed_quote_and_greeks(&mut agg, 45000, OptionKind::Call, 0.55);
1636        feed_quote_and_greeks(&mut agg, 45000, OptionKind::Put, -0.30);
1637        feed_quote_and_greeks(&mut agg, 50000, OptionKind::Call, 0.55);
1638        feed_quote_and_greeks(&mut agg, 50000, OptionKind::Put, -0.55);
1639        feed_quote_and_greeks(&mut agg, 55000, OptionKind::Call, 0.55);
1640        feed_quote_and_greeks(&mut agg, 55000, OptionKind::Put, -0.55);
1641
1642        let active = agg.recompute_active_set();
1643        // |-0.30| == target, so the 45000 strike (both legs) is selected.
1644        assert_eq!(active.len(), 2);
1645        assert!(active.contains(&option_id(45000, OptionKind::Put)));
1646        assert!(active.contains(&option_id(45000, OptionKind::Call)));
1647    }
1648
1649    #[rstest]
1650    fn test_delta_no_greeks_falls_back_to_atm_window() {
1651        // 13 strikes so the ATM-relative fallback window is a proper subset.
1652        let strikes: Vec<i64> = (0..13).map(|i| 40000 + i * 1000).collect();
1653        let mut agg = make_delta_aggregator(&strikes, 0.25, 0.05);
1654        set_atm_via_greeks(&mut agg, 46000.0); // centered
1655
1656        let active = agg.recompute_active_set();
1657
1658        // No greeks -> a bounded ATM-relative window: neither empty nor the full chain.
1659        // The exact window width is asserted in the model-level resolve test.
1660        assert!(active.len() > 2);
1661        assert!(active.len() < strikes.len() * 2);
1662        assert!(active.contains(&option_id(46000, OptionKind::Call))); // ATM included
1663        assert!(!active.contains(&option_id(40000, OptionKind::Call))); // extreme excluded
1664        assert!(!active.contains(&option_id(52000, OptionKind::Call))); // extreme excluded
1665    }
1666
1667    #[rstest]
1668    fn test_delta_pending_only_greeks_eligible() {
1669        let strikes = [45000, 50000, 55000];
1670        let mut agg = make_delta_aggregator(&strikes, 0.30, 0.03);
1671        set_atm_via_greeks(&mut agg, 50000.0);
1672        agg.recompute_active_set();
1673
1674        feed_quote_and_greeks(&mut agg, 45000, OptionKind::Call, 0.55);
1675        feed_quote_and_greeks(&mut agg, 45000, OptionKind::Put, -0.55);
1676        feed_quote_and_greeks(&mut agg, 50000, OptionKind::Call, 0.55);
1677        feed_quote_and_greeks(&mut agg, 50000, OptionKind::Put, -0.55);
1678        feed_quote_and_greeks(&mut agg, 55000, OptionKind::Put, -0.55);
1679
1680        // Greeks arrive before any quote, so they land in pending_greeks.
1681        agg.update_greeks(&OptionGreeks {
1682            instrument_id: option_id(55000, OptionKind::Call),
1683            greeks: OptionGreekValues {
1684                delta: 0.30,
1685                ..Default::default()
1686            },
1687            ..Default::default()
1688        });
1689        assert_eq!(agg.pending_greeks_count(), 1);
1690
1691        let active = agg.recompute_active_set();
1692        // Pending-only greeks are eligible for delta resolution.
1693        assert_eq!(active.len(), 2);
1694        assert!(active.contains(&option_id(55000, OptionKind::Call)));
1695    }
1696
1697    #[rstest]
1698    fn test_delta_waits_for_fallback_window_greeks_before_narrowing() {
1699        let strikes = [45000, 50000, 55000];
1700        let mut agg = make_delta_aggregator(&strikes, 0.30, 0.03);
1701        set_atm_via_greeks(&mut agg, 50000.0);
1702        agg.recompute_active_set();
1703        assert_eq!(agg.instrument_ids().len(), 6); // fallback: all 3 strikes
1704
1705        feed_quote_and_greeks(&mut agg, 55000, OptionKind::Call, 0.30);
1706
1707        assert!(agg.check_rebalance(now()).is_none());
1708        assert_eq!(agg.instrument_ids().len(), 6);
1709    }
1710
1711    #[rstest]
1712    fn test_delta_waits_when_fallback_window_shifts_during_warmup() {
1713        let strikes: Vec<i64> = (0..13).map(|i| 40000 + i * 1000).collect();
1714        let mut agg = make_delta_aggregator(&strikes, 0.30, 0.03);
1715        set_atm_via_greeks(&mut agg, 46000.0);
1716        agg.recompute_active_set();
1717        assert!(
1718            agg.active_ids()
1719                .contains(&option_id(42000, OptionKind::Call))
1720        );
1721        assert!(
1722            agg.active_ids()
1723                .contains(&option_id(51000, OptionKind::Put))
1724        );
1725
1726        feed_quote_and_greeks(&mut agg, 50000, OptionKind::Call, 0.30);
1727        set_atm_via_greeks(&mut agg, 47000.0);
1728
1729        let action = agg
1730            .check_rebalance(now())
1731            .expect("fallback window shift should rebalance active legs");
1732
1733        assert!(action.add.contains(&option_id(52000, OptionKind::Call)));
1734        assert!(!action.remove.contains(&option_id(42000, OptionKind::Call)));
1735        assert!(!action.remove.contains(&option_id(51000, OptionKind::Put)));
1736    }
1737
1738    #[rstest]
1739    fn test_delta_rebalances_on_greeks_with_atm_unchanged() {
1740        let strikes = [45000, 50000, 55000];
1741        let mut agg = make_delta_aggregator(&strikes, 0.30, 0.03);
1742        set_atm_via_greeks(&mut agg, 50000.0);
1743        agg.recompute_active_set();
1744        assert_eq!(agg.last_atm_strike(), Some(Price::from("50000")));
1745        assert_eq!(agg.instrument_ids().len(), 6); // fallback: all 3 strikes
1746
1747        // Greeks arrive; only 55000 matches. The closest ATM strike is unchanged.
1748        feed_quote_and_greeks(&mut agg, 45000, OptionKind::Call, 0.55);
1749        feed_quote_and_greeks(&mut agg, 45000, OptionKind::Put, -0.55);
1750        feed_quote_and_greeks(&mut agg, 50000, OptionKind::Call, 0.45);
1751        feed_quote_and_greeks(&mut agg, 50000, OptionKind::Put, -0.45);
1752        feed_quote_and_greeks(&mut agg, 55000, OptionKind::Call, 0.30);
1753        feed_quote_and_greeks(&mut agg, 55000, OptionKind::Put, -0.12);
1754
1755        let action = agg
1756            .check_rebalance(now())
1757            .expect("delta range should rebalance when greeks narrow the set");
1758        assert!(action.add.is_empty());
1759        assert!(!action.remove.is_empty());
1760
1761        agg.apply_rebalance(&action, now());
1762        assert_eq!(agg.instrument_ids().len(), 2); // narrowed to the 55000 legs
1763        assert!(
1764            agg.active_ids()
1765                .contains(&option_id(55000, OptionKind::Call))
1766        );
1767    }
1768
1769    #[rstest]
1770    fn test_delta_no_op_rebalance_returns_none() {
1771        let strikes = [45000, 50000, 55000];
1772        let mut agg = make_delta_aggregator(&strikes, 0.30, 0.03);
1773        set_atm_via_greeks(&mut agg, 50000.0);
1774        agg.recompute_active_set();
1775        feed_quote_and_greeks(&mut agg, 45000, OptionKind::Call, 0.55);
1776        feed_quote_and_greeks(&mut agg, 45000, OptionKind::Put, -0.55);
1777        feed_quote_and_greeks(&mut agg, 50000, OptionKind::Call, 0.45);
1778        feed_quote_and_greeks(&mut agg, 50000, OptionKind::Put, -0.45);
1779        feed_quote_and_greeks(&mut agg, 55000, OptionKind::Call, 0.30);
1780        feed_quote_and_greeks(&mut agg, 55000, OptionKind::Put, -0.12);
1781
1782        // First rebalance narrows to the 55000 legs.
1783        let action = agg.check_rebalance(now()).unwrap();
1784        agg.apply_rebalance(&action, now());
1785        assert_eq!(agg.instrument_ids().len(), 2);
1786
1787        // Greeks unchanged -> stable set -> no-op suppressed (cooldown disabled).
1788        assert!(agg.check_rebalance(now()).is_none());
1789    }
1790}