Skip to main content

nautilus_data/option_chains/
manager.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 manager.
17//!
18//! Each [`OptionChainManager`] instance is self-contained: it owns its aggregator,
19//! msgbus handlers, and timer for a single option series. The `DataEngine` holds
20//! one manager per active series in
21//! `AHashMap<OptionSeriesId, Rc<RefCell<OptionChainManager>>>`.
22
23use std::{cell::RefCell, collections::HashMap, rc::Rc};
24
25use nautilus_common::{
26    cache::Cache,
27    clock::Clock,
28    messages::data::{
29        SubscribeCommand, SubscribeInstrumentStatus, SubscribeOptionChain, SubscribeOptionGreeks,
30        SubscribeQuotes, UnsubscribeCommand, UnsubscribeInstrumentStatus, UnsubscribeOptionGreeks,
31        UnsubscribeQuotes,
32    },
33    msgbus::{self, MStr, Topic, TypedHandler, switchboard},
34    timer::{TimeEvent, TimeEventCallback},
35};
36use nautilus_core::{DurationNanos, UUID4, correctness::FAILED};
37use nautilus_model::{
38    data::{QuoteTick, option_chain::OptionGreeks},
39    enums::OptionKind,
40    identifiers::{ClientId, InstrumentId, OptionSeriesId, Venue},
41    instruments::Instrument,
42    types::Price,
43};
44use ustr::Ustr;
45
46use super::{
47    AtmTracker, OptionChainAggregator,
48    handlers::{OptionChainGreeksHandler, OptionChainQuoteHandler, OptionChainSlicePublisher},
49};
50use crate::{
51    client::DataClientAdapter,
52    engine::{DeferredCommand, DeferredCommandQueue},
53};
54
55/// Per-series option chain manager.
56///
57/// Each instance manages a single option series: its aggregator,
58/// handlers, timer, and lifecycle. The `DataEngine` holds one
59/// manager per active series.
60#[derive(Debug)]
61pub struct OptionChainManager {
62    aggregator: OptionChainAggregator,
63    topic: MStr<Topic>,
64    quote_handlers: Vec<TypedHandler<QuoteTick>>,
65    greeks_handlers: Vec<TypedHandler<OptionGreeks>>,
66    timer_name: Option<Ustr>,
67    msgbus_priority: u32,
68    /// Whether the first ATM price has been received and the active set bootstrapped.
69    bootstrapped: bool,
70    /// Shared deferred command queue - the `DataEngine` drains this on each data tick.
71    deferred_cmd_queue: DeferredCommandQueue,
72    /// Clock reference for constructing command timestamps.
73    clock: Rc<RefCell<dyn Clock>>,
74    client_id: Option<ClientId>,
75    /// When `true`, every quote/greeks update for an active instrument immediately publishes a snapshot.
76    raw_mode: bool,
77}
78
79impl OptionChainManager {
80    /// Factory method that creates a per-series manager, registers all msgbus
81    /// handlers, forwards subscribe commands to the data client, and sets up
82    /// the snapshot timer.
83    ///
84    /// Returns the manager wrapped in `Rc<RefCell<>>` (needed for `WeakCell`
85    /// handler pattern).
86    #[expect(clippy::too_many_arguments)]
87    pub(crate) fn create_and_setup(
88        series_id: OptionSeriesId,
89        cache: &Rc<RefCell<Cache>>,
90        cmd: &SubscribeOptionChain,
91        clock: &Rc<RefCell<dyn Clock>>,
92        msgbus_priority: u32,
93        client: Option<&mut DataClientAdapter>,
94        initial_atm_price: Option<Price>,
95        deferred_cmd_queue: DeferredCommandQueue,
96    ) -> Rc<RefCell<Self>> {
97        let topic = switchboard::get_option_chain_topic(series_id);
98        let instruments = Self::resolve_instruments(cache, &series_id);
99        let client_id = client.as_ref().map(|client| client.client_id);
100
101        let mut tracker = AtmTracker::new();
102
103        // Derive forward price precision from instrument strike prices
104        if let Some((strike, _)) = instruments.values().next() {
105            tracker.set_forward_precision(strike.precision);
106        }
107
108        if let Some(price) = initial_atm_price {
109            tracker.set_initial_price(price);
110            log::info!("Pre-populated ATM with forward price: {price}");
111        }
112        let aggregator =
113            OptionChainAggregator::new(series_id, cmd.strike_range.clone(), tracker, instruments);
114
115        // Initial active set for msgbus handlers (subset of all instruments).
116        // When ATM is unknown (ATM-based ranges), this is empty - deferred until bootstrap.
117        let active_instrument_ids = aggregator.instrument_ids();
118        let all_instrument_ids = aggregator.all_instrument_ids();
119        // If active set is already populated (Fixed range or ATM provided), we're bootstrapped
120        let bootstrapped = !active_instrument_ids.is_empty() || all_instrument_ids.is_empty();
121
122        let raw_mode = cmd.snapshot_interval_ms.is_none();
123
124        let manager = Self {
125            aggregator,
126            topic,
127            quote_handlers: Vec::new(),
128            greeks_handlers: Vec::new(),
129            timer_name: None,
130            msgbus_priority,
131            bootstrapped,
132            deferred_cmd_queue,
133            clock: Rc::clone(clock),
134            client_id,
135            raw_mode,
136        };
137        let manager_rc = Rc::new(RefCell::new(manager));
138
139        // Register msgbus handlers for initial active set only
140        let (quote_handlers, _quote_handler) = Self::register_quote_handlers(
141            &manager_rc,
142            &active_instrument_ids,
143            series_id,
144            msgbus_priority,
145        );
146        let greeks_handlers = Self::register_greeks_handlers(
147            &manager_rc,
148            &active_instrument_ids,
149            series_id,
150            msgbus_priority,
151        );
152
153        // Forward wire-level subscriptions for the active set.
154        // When ATM is unknown, active set is empty - deferred until bootstrap.
155        Self::forward_client_subscriptions(
156            client,
157            &active_instrument_ids,
158            cmd,
159            series_id.venue,
160            clock,
161        );
162
163        let timer_name = cmd
164            .snapshot_interval_ms
165            .map(|ms| Self::setup_timer(&manager_rc, series_id, ms, clock));
166
167        {
168            let mut mgr = manager_rc.borrow_mut();
169            mgr.quote_handlers = quote_handlers;
170            mgr.greeks_handlers = greeks_handlers;
171            mgr.timer_name = timer_name;
172        }
173
174        let mode_str = match cmd.snapshot_interval_ms {
175            Some(ms) => format!("interval={ms}ms"),
176            None => "mode=raw".to_string(),
177        };
178        log::info!(
179            "Subscribed option chain for {series_id} ({} active/{} total instruments, {mode_str})",
180            active_instrument_ids.len(),
181            all_instrument_ids.len(),
182        );
183
184        manager_rc
185    }
186
187    /// Registers quote handlers on the msgbus for each instrument.
188    ///
189    /// Always stores the handler prototype as the first element so that
190    /// `register_handlers_for_instrument` can clone it during deferred bootstrap.
191    fn register_quote_handlers(
192        manager_rc: &Rc<RefCell<Self>>,
193        instrument_ids: &[InstrumentId],
194        series_id: OptionSeriesId,
195        priority: u32,
196    ) -> (Vec<TypedHandler<QuoteTick>>, TypedHandler<QuoteTick>) {
197        let quote_handler = TypedHandler::new(OptionChainQuoteHandler::new(manager_rc, series_id));
198        // Always store prototype as first element for bootstrap cloning
199        let mut handlers = Vec::with_capacity(instrument_ids.len() + 1);
200        handlers.push(quote_handler.clone());
201
202        for instrument_id in instrument_ids {
203            let topic = switchboard::get_quotes_topic(*instrument_id);
204            msgbus::subscribe_quotes(topic.into(), quote_handler.clone(), Some(priority));
205            handlers.push(quote_handler.clone());
206        }
207        (handlers, quote_handler)
208    }
209
210    /// Registers greeks handlers on the msgbus for each instrument.
211    ///
212    /// Always stores the handler prototype as the first element so that
213    /// `register_handlers_for_instrument` can clone it during deferred bootstrap.
214    fn register_greeks_handlers(
215        manager_rc: &Rc<RefCell<Self>>,
216        instrument_ids: &[InstrumentId],
217        series_id: OptionSeriesId,
218        priority: u32,
219    ) -> Vec<TypedHandler<OptionGreeks>> {
220        let greeks_handler =
221            TypedHandler::new(OptionChainGreeksHandler::new(manager_rc, series_id));
222        // Always store prototype as first element for bootstrap cloning
223        let mut handlers = Vec::with_capacity(instrument_ids.len() + 1);
224        handlers.push(greeks_handler.clone());
225
226        for instrument_id in instrument_ids {
227            let topic = switchboard::get_option_greeks_topic(*instrument_id);
228            msgbus::subscribe_option_greeks(topic.into(), greeks_handler.clone(), Some(priority));
229            handlers.push(greeks_handler.clone());
230        }
231        handlers
232    }
233
234    /// Forwards subscribe commands to the data client for all instruments.
235    fn forward_client_subscriptions(
236        client: Option<&mut DataClientAdapter>,
237        instrument_ids: &[InstrumentId],
238        cmd: &SubscribeOptionChain,
239        venue: Venue,
240        clock: &Rc<RefCell<dyn Clock>>,
241    ) {
242        let ts_init = clock.borrow().timestamp_ns();
243
244        let Some(client) = client else {
245            log::error!(
246                "Cannot forward option chain subscriptions: no client found for venue={venue}",
247            );
248            return;
249        };
250
251        for instrument_id in instrument_ids {
252            client.execute_subscribe_intent(SubscribeCommand::Quotes(SubscribeQuotes {
253                instrument_id: *instrument_id,
254                client_id: cmd.client_id,
255                venue: Some(venue),
256                command_id: UUID4::new(),
257                ts_init,
258                correlation_id: None,
259                params: None,
260            }));
261            client.execute_subscribe_intent(SubscribeCommand::OptionGreeks(
262                SubscribeOptionGreeks {
263                    instrument_id: *instrument_id,
264                    client_id: cmd.client_id,
265                    venue: Some(venue),
266                    command_id: UUID4::new(),
267                    ts_init,
268                    correlation_id: None,
269                    params: None,
270                },
271            ));
272            client.execute_subscribe_intent(SubscribeCommand::InstrumentStatus(
273                SubscribeInstrumentStatus {
274                    instrument_id: *instrument_id,
275                    client_id: cmd.client_id,
276                    venue: Some(venue),
277                    command_id: UUID4::new(),
278                    ts_init,
279                    correlation_id: None,
280                    params: None,
281                },
282            ));
283        }
284
285        log::info!(
286            "Forwarded {} quote + greeks + instrument status subscriptions to DataClient",
287            instrument_ids.len(),
288        );
289    }
290
291    /// Sets up the snapshot timer for periodic publishing.
292    fn setup_timer(
293        manager_rc: &Rc<RefCell<Self>>,
294        series_id: OptionSeriesId,
295        interval_ms: u64,
296        clock: &Rc<RefCell<dyn Clock>>,
297    ) -> Ustr {
298        let interval_ns = DurationNanos::from_millis(interval_ms);
299        let publisher = OptionChainSlicePublisher::new(manager_rc);
300        let timer_name = Ustr::from(&format!("OptionChain|{series_id}|{interval_ms}"));
301
302        let now_ns = clock.borrow().timestamp_ns();
303        let start_time_ns = now_ns
304            .floor(interval_ns)
305            .checked_add(interval_ns)
306            .expect("Option chain timer start exceeds UnixNanos range");
307
308        let callback_fn: Rc<dyn Fn(TimeEvent)> = Rc::new(move |event| publisher.publish(&event));
309        let callback = TimeEventCallback::from(callback_fn);
310
311        clock
312            .borrow_mut()
313            .set_timer_ns(
314                &timer_name,
315                interval_ns,
316                Some(start_time_ns),
317                None,
318                Some(callback),
319                None,
320                None,
321            )
322            .expect(FAILED);
323
324        timer_name
325    }
326
327    /// Returns all instrument IDs in the full catalog (not just the active set).
328    #[must_use]
329    pub fn all_instrument_ids(&self) -> Vec<InstrumentId> {
330        self.aggregator.all_instrument_ids()
331    }
332
333    /// Returns the venue for this option chain.
334    #[must_use]
335    pub fn venue(&self) -> Venue {
336        self.aggregator.series_id().venue
337    }
338
339    /// Returns whether the active instrument set has been bootstrapped.
340    #[must_use]
341    pub const fn is_bootstrapped(&self) -> bool {
342        self.bootstrapped
343    }
344
345    #[must_use]
346    pub(crate) fn is_instrument_active(&self, instrument_id: &InstrumentId) -> bool {
347        self.aggregator.active_ids().contains(instrument_id)
348    }
349
350    #[must_use]
351    pub(crate) const fn client_id(&self) -> Option<ClientId> {
352        self.client_id
353    }
354
355    /// Tears down this manager: unregisters all msgbus handlers and cancels the timer.
356    pub fn teardown(&mut self, clock: &Rc<RefCell<dyn Clock>>) {
357        // Unsubscribe from all currently active instruments
358        let instrument_ids = self.aggregator.instrument_ids();
359
360        // Unregister quote handlers
361        if let Some(handler) = self.quote_handlers.first() {
362            for instrument_id in &instrument_ids {
363                let topic = switchboard::get_quotes_topic(*instrument_id);
364                msgbus::unsubscribe_quotes(topic.into(), handler);
365            }
366        }
367
368        // Unregister greeks handlers
369        if let Some(handler) = self.greeks_handlers.first() {
370            for instrument_id in &instrument_ids {
371                let topic = switchboard::get_option_greeks_topic(*instrument_id);
372                msgbus::unsubscribe_option_greeks(topic.into(), handler);
373            }
374        }
375
376        // Cancel timer
377        if let Some(timer_name) = self.timer_name.take() {
378            let mut clk = clock.borrow_mut();
379            if clk.timer_exists(&timer_name) {
380                clk.cancel_timer(&timer_name);
381            }
382        }
383
384        self.quote_handlers.clear();
385        self.greeks_handlers.clear();
386    }
387
388    /// Routes incoming greeks to the aggregator.
389    ///
390    /// Also updates the ATM tracker from the reference price when one is available,
391    /// and triggers deferred bootstrap on the first arrival.
392    pub fn handle_greeks(&mut self, greeks: &OptionGreeks) {
393        if self.aggregator.is_expired(greeks.ts_event) {
394            log::warn!(
395                "Dropping greeks for {}, series {} expired",
396                greeks.instrument_id,
397                self.aggregator.series_id(),
398            );
399            self.deferred_cmd_queue
400                .borrow_mut()
401                .push_back(DeferredCommand::ExpireInstrument(greeks.instrument_id));
402            return;
403        }
404
405        if let Err(e) = self
406            .aggregator
407            .atm_tracker_mut()
408            .try_update_from_option_greeks(greeks)
409        {
410            log::warn!(
411                "Dropping greeks for {}: invalid forward price: {e}",
412                greeks.instrument_id,
413            );
414            return;
415        }
416
417        self.aggregator.update_greeks(greeks);
418        self.maybe_bootstrap();
419
420        if self.raw_mode
421            && self.bootstrapped
422            && self.aggregator.active_ids().contains(&greeks.instrument_id)
423        {
424            self.publish_slice(greeks.ts_event);
425        }
426    }
427
428    /// Handles an expired/settled instrument by removing it from the aggregator,
429    /// unregistering msgbus handlers, and pushing deferred wire unsubscribes.
430    ///
431    /// Returns `true` if the aggregator catalog is now empty (all instruments expired),
432    /// signaling the engine to tear down this entire manager.
433    pub fn handle_instrument_expired(&mut self, instrument_id: &InstrumentId) -> bool {
434        let was_active = self.aggregator.active_ids().contains(instrument_id);
435
436        if !self.aggregator.remove_instrument(instrument_id) {
437            return self.aggregator.is_catalog_empty();
438        }
439
440        if was_active {
441            // Unregister msgbus handlers for this instrument
442            if let Some(qh) = self.quote_handlers.first() {
443                let topic = switchboard::get_quotes_topic(*instrument_id);
444                msgbus::unsubscribe_quotes(topic.into(), qh);
445            }
446
447            if let Some(gh) = self.greeks_handlers.first() {
448                let topic = switchboard::get_option_greeks_topic(*instrument_id);
449                msgbus::unsubscribe_option_greeks(topic.into(), gh);
450            }
451
452            // Push deferred wire unsubscribes
453            self.push_unsubscribe_commands(*instrument_id);
454        }
455
456        log::info!(
457            "Removed expired instrument {instrument_id} from option chain {} (was_active={was_active}, remaining={})",
458            self.aggregator.series_id(),
459            self.aggregator.instruments().len(),
460        );
461
462        self.aggregator.is_catalog_empty()
463    }
464
465    /// Routes an incoming quote tick to the aggregator, then bootstraps if ready.
466    ///
467    /// This handles both option instrument quotes (aggregator) and ATM source quotes
468    /// (the aggregator's ATM tracker handles filtering internally).
469    pub fn handle_quote(&mut self, quote: &QuoteTick) {
470        if self.aggregator.is_expired(quote.ts_event) {
471            log::warn!(
472                "Dropping quote for {}, series {} expired",
473                quote.instrument_id,
474                self.aggregator.series_id(),
475            );
476            self.deferred_cmd_queue
477                .borrow_mut()
478                .push_back(DeferredCommand::ExpireInstrument(quote.instrument_id));
479            return;
480        }
481
482        self.aggregator.update_quote(quote);
483        self.maybe_bootstrap();
484
485        if self.raw_mode
486            && self.bootstrapped
487            && self.aggregator.active_ids().contains(&quote.instrument_id)
488        {
489            self.publish_slice(quote.ts_event);
490        }
491    }
492
493    /// Bootstraps the active instrument set on the first ATM price arrival.
494    ///
495    /// Computes active strikes, registers msgbus handlers for those instruments,
496    /// and pushes deferred wire subscriptions into the shared command queue.
497    fn maybe_bootstrap(&mut self) {
498        if self.bootstrapped {
499            return;
500        }
501
502        if self.aggregator.atm_tracker().atm_price().is_none() {
503            return;
504        }
505
506        // First ATM received - compute active set and register handlers
507        let active_ids = self.aggregator.recompute_active_set();
508        self.register_handlers_for_instruments_bulk(&active_ids);
509
510        for &id in &active_ids {
511            self.push_subscribe_commands(id);
512        }
513
514        self.bootstrapped = true;
515
516        log::info!(
517            "Bootstrapped option chain for {} ({} active instruments)",
518            self.aggregator.series_id(),
519            active_ids.len(),
520        );
521    }
522
523    /// Registers msgbus handlers for a batch of instruments.
524    fn register_handlers_for_instruments_bulk(&self, instrument_ids: &[InstrumentId]) {
525        for &id in instrument_ids {
526            self.register_handlers_for_instrument(id);
527        }
528    }
529
530    /// Adds a dynamically discovered instrument to this option chain.
531    ///
532    /// Registers msgbus handlers and forwards wire-level subscriptions via
533    /// `client` when the instrument falls in the active range.
534    /// Returns `true` if the instrument was newly inserted.
535    pub fn add_instrument(
536        &mut self,
537        instrument_id: InstrumentId,
538        strike: Price,
539        kind: OptionKind,
540        client: Option<&mut DataClientAdapter>,
541        clock: &Rc<RefCell<dyn Clock>>,
542    ) -> bool {
543        if !self.aggregator.add_instrument(instrument_id, strike, kind) {
544            return false;
545        }
546
547        if self.aggregator.active_ids().contains(&instrument_id) {
548            self.register_handlers_for_instrument(instrument_id);
549            let venue = self.aggregator.series_id().venue;
550            Self::forward_instrument_subscriptions(client, instrument_id, venue, clock);
551        }
552
553        log::info!(
554            "Added instrument {instrument_id} to option chain {} (active={})",
555            self.aggregator.series_id(),
556            self.aggregator.active_ids().contains(&instrument_id),
557        );
558
559        true
560    }
561
562    fn register_handlers_for_instrument(&self, instrument_id: InstrumentId) {
563        if let Some(qh) = self.quote_handlers.first().cloned() {
564            let topic = switchboard::get_quotes_topic(instrument_id);
565            msgbus::subscribe_quotes(topic.into(), qh, Some(self.msgbus_priority));
566        }
567
568        if let Some(gh) = self.greeks_handlers.first().cloned() {
569            let topic = switchboard::get_option_greeks_topic(instrument_id);
570            msgbus::subscribe_option_greeks(topic.into(), gh, Some(self.msgbus_priority));
571        }
572    }
573
574    /// Pushes deferred subscribe commands (quotes, greeks, instrument status) for a single instrument.
575    fn push_subscribe_commands(&self, instrument_id: InstrumentId) {
576        let venue = self.aggregator.series_id().venue;
577        let ts_init = self.clock.borrow().timestamp_ns();
578        let mut queue = self.deferred_cmd_queue.borrow_mut();
579        queue.push_back(DeferredCommand::Subscribe(SubscribeCommand::Quotes(
580            SubscribeQuotes {
581                instrument_id,
582                client_id: self.client_id,
583                venue: Some(venue),
584                command_id: UUID4::new(),
585                ts_init,
586                correlation_id: None,
587                params: None,
588            },
589        )));
590        queue.push_back(DeferredCommand::Subscribe(SubscribeCommand::OptionGreeks(
591            SubscribeOptionGreeks {
592                instrument_id,
593                client_id: self.client_id,
594                venue: Some(venue),
595                command_id: UUID4::new(),
596                ts_init,
597                correlation_id: None,
598                params: None,
599            },
600        )));
601        queue.push_back(DeferredCommand::Subscribe(
602            SubscribeCommand::InstrumentStatus(SubscribeInstrumentStatus {
603                instrument_id,
604                client_id: self.client_id,
605                venue: Some(venue),
606                command_id: UUID4::new(),
607                ts_init,
608                correlation_id: None,
609                params: None,
610            }),
611        ));
612    }
613
614    /// Pushes deferred unsubscribe commands (quotes, greeks, instrument status) for a single instrument.
615    fn push_unsubscribe_commands(&self, instrument_id: InstrumentId) {
616        let venue = self.aggregator.series_id().venue;
617        let ts_init = self.clock.borrow().timestamp_ns();
618        let mut queue = self.deferred_cmd_queue.borrow_mut();
619        queue.push_back(DeferredCommand::Unsubscribe(UnsubscribeCommand::Quotes(
620            UnsubscribeQuotes {
621                instrument_id,
622                client_id: self.client_id,
623                venue: Some(venue),
624                command_id: UUID4::new(),
625                ts_init,
626                correlation_id: None,
627                params: None,
628            },
629        )));
630        queue.push_back(DeferredCommand::Unsubscribe(
631            UnsubscribeCommand::OptionGreeks(UnsubscribeOptionGreeks {
632                instrument_id,
633                client_id: self.client_id,
634                venue: Some(venue),
635                command_id: UUID4::new(),
636                ts_init,
637                correlation_id: None,
638                params: None,
639            }),
640        ));
641        queue.push_back(DeferredCommand::Unsubscribe(
642            UnsubscribeCommand::InstrumentStatus(UnsubscribeInstrumentStatus {
643                instrument_id,
644                client_id: self.client_id,
645                venue: Some(venue),
646                command_id: UUID4::new(),
647                ts_init,
648                correlation_id: None,
649                params: None,
650            }),
651        ));
652    }
653
654    /// Forwards quote, greeks, and instrument status subscriptions for a single instrument.
655    fn forward_instrument_subscriptions(
656        client: Option<&mut DataClientAdapter>,
657        instrument_id: InstrumentId,
658        venue: Venue,
659        clock: &Rc<RefCell<dyn Clock>>,
660    ) {
661        let Some(client) = client else {
662            log::error!(
663                "Cannot forward subscriptions for {instrument_id}: no client for venue={venue}",
664            );
665            return;
666        };
667
668        let ts_init = clock.borrow().timestamp_ns();
669
670        client.execute_subscribe_intent(SubscribeCommand::Quotes(SubscribeQuotes {
671            instrument_id,
672            client_id: None,
673            venue: Some(venue),
674            command_id: UUID4::new(),
675            ts_init,
676            correlation_id: None,
677            params: None,
678        }));
679        client.execute_subscribe_intent(SubscribeCommand::OptionGreeks(SubscribeOptionGreeks {
680            instrument_id,
681            client_id: None,
682            venue: Some(venue),
683            command_id: UUID4::new(),
684            ts_init,
685            correlation_id: None,
686            params: None,
687        }));
688        client.execute_subscribe_intent(SubscribeCommand::InstrumentStatus(
689            SubscribeInstrumentStatus {
690                instrument_id,
691                client_id: None,
692                venue: Some(venue),
693                command_id: UUID4::new(),
694                ts_init,
695                correlation_id: None,
696                params: None,
697            },
698        ));
699    }
700
701    /// Checks if ATM has shifted and rebalances msgbus subscriptions if needed.
702    fn maybe_rebalance(&mut self, now_ns: nautilus_core::UnixNanos) {
703        let Some(action) = self.aggregator.check_rebalance(now_ns) else {
704            return;
705        };
706
707        // Unsubscribe removed instruments from msgbus
708        if let Some(qh) = self.quote_handlers.first() {
709            for id in &action.remove {
710                msgbus::unsubscribe_quotes(switchboard::get_quotes_topic(*id).into(), qh);
711            }
712        }
713
714        if let Some(gh) = self.greeks_handlers.first() {
715            for id in &action.remove {
716                msgbus::unsubscribe_option_greeks(
717                    switchboard::get_option_greeks_topic(*id).into(),
718                    gh,
719                );
720            }
721        }
722
723        // Subscribe new instruments on msgbus
724        if let Some(qh) = self.quote_handlers.first().cloned() {
725            for id in &action.add {
726                msgbus::subscribe_quotes(
727                    switchboard::get_quotes_topic(*id).into(),
728                    qh.clone(),
729                    Some(self.msgbus_priority),
730                );
731            }
732        }
733
734        if let Some(gh) = self.greeks_handlers.first().cloned() {
735            for id in &action.add {
736                msgbus::subscribe_option_greeks(
737                    switchboard::get_option_greeks_topic(*id).into(),
738                    gh.clone(),
739                    Some(self.msgbus_priority),
740                );
741            }
742        }
743
744        // Push deferred wire-level changes into the shared command queue
745        for &id in &action.add {
746            self.push_subscribe_commands(id);
747        }
748
749        for &id in &action.remove {
750            self.push_unsubscribe_commands(id);
751        }
752
753        if !action.add.is_empty() || !action.remove.is_empty() {
754            log::info!(
755                "Rebalanced option chain for {}: +{} -{} instruments",
756                self.aggregator.series_id(),
757                action.add.len(),
758                action.remove.len(),
759            );
760        }
761
762        // Apply state changes to aggregator
763        self.aggregator.apply_rebalance(&action, now_ns);
764    }
765
766    /// Takes the accumulated snapshot and publishes it to the msgbus.
767    pub fn publish_slice(&mut self, ts: nautilus_core::UnixNanos) {
768        // Proactive expiry safeguard
769        if self.aggregator.is_expired(ts) {
770            self.deferred_cmd_queue
771                .borrow_mut()
772                .push_back(DeferredCommand::ExpireSeries(self.aggregator.series_id()));
773            return;
774        }
775
776        self.maybe_rebalance(ts);
777
778        let series_id = self.aggregator.series_id();
779        let slice = self.aggregator.snapshot(ts);
780
781        if slice.is_empty() {
782            log::debug!("OptionChainSlice empty for {series_id}, skipping publish");
783            return;
784        }
785
786        log::debug!(
787            "Publishing OptionChainSlice for {} (calls={}, puts={})",
788            series_id,
789            slice.call_count(),
790            slice.put_count(),
791        );
792        msgbus::publish_option_chain(self.topic, &slice);
793    }
794
795    /// Resolves instruments from cache that match the given option series.
796    fn resolve_instruments(
797        cache: &Rc<RefCell<Cache>>,
798        series_id: &OptionSeriesId,
799    ) -> HashMap<InstrumentId, (Price, OptionKind)> {
800        let cache = cache.borrow();
801        let mut map = HashMap::new();
802
803        for instrument in cache.instruments(&series_id.venue, Some(&series_id.underlying)) {
804            let Some(expiration) = instrument.expiration_ns() else {
805                continue;
806            };
807
808            if expiration != series_id.expiration_ns {
809                continue;
810            }
811
812            if instrument.settlement_currency().code != series_id.settlement_currency {
813                continue;
814            }
815
816            let Some(strike) = instrument.strike_price() else {
817                continue;
818            };
819
820            let Some(kind) = instrument.option_kind() else {
821                continue;
822            };
823
824            map.insert(instrument.id(), (strike, kind));
825        }
826
827        map
828    }
829}
830
831#[cfg(test)]
832mod tests {
833    use std::collections::VecDeque;
834
835    use nautilus_common::clock::VirtualClock;
836    use nautilus_core::UnixNanos;
837    use nautilus_model::{data::option_chain::StrikeRange, identifiers::Venue, types::Quantity};
838    use rstest::*;
839
840    use super::*;
841
842    fn make_series_id() -> OptionSeriesId {
843        OptionSeriesId::new_derived(
844            Venue::new("DERIBIT"),
845            ustr::Ustr::from("BTC"),
846            ustr::Ustr::from("BTC"),
847            UnixNanos::from(1_700_000_000_000_000_000u64),
848        )
849    }
850
851    fn make_test_queue() -> DeferredCommandQueue {
852        Rc::new(RefCell::new(VecDeque::new()))
853    }
854
855    fn make_manager() -> (OptionChainManager, DeferredCommandQueue) {
856        let series_id = make_series_id();
857        let topic = switchboard::get_option_chain_topic(series_id);
858        let tracker = AtmTracker::new();
859        let aggregator = OptionChainAggregator::new(
860            series_id,
861            StrikeRange::Fixed(vec![]),
862            tracker,
863            HashMap::new(),
864        );
865        let clock: Rc<RefCell<dyn Clock>> = Rc::new(RefCell::new(VirtualClock::new()));
866        let queue = make_test_queue();
867
868        let manager = OptionChainManager {
869            aggregator,
870            topic,
871            quote_handlers: Vec::new(),
872            greeks_handlers: Vec::new(),
873            timer_name: None,
874            msgbus_priority: 0,
875            bootstrapped: true,
876            deferred_cmd_queue: Rc::clone(&queue),
877            clock,
878            client_id: None,
879            raw_mode: false,
880        };
881        (manager, queue)
882    }
883
884    #[rstest]
885    fn test_manager_handle_quote_no_instrument() {
886        let (mut manager, _queue) = make_manager();
887
888        // Should not panic - quote for unknown instrument
889        let quote = QuoteTick::new(
890            InstrumentId::from("BTC-20240101-50000-C.DERIBIT"),
891            Price::from("100.00"),
892            Price::from("101.00"),
893            Quantity::from("1.0"),
894            Quantity::from("1.0"),
895            UnixNanos::from(1u64),
896            UnixNanos::from(1u64),
897        );
898        manager.handle_quote(&quote);
899    }
900
901    #[rstest]
902    fn test_manager_publish_slice_empty() {
903        let (mut manager, _queue) = make_manager();
904        // Should not panic - empty slice skips publish
905        manager.publish_slice(UnixNanos::from(100u64));
906    }
907
908    #[rstest]
909    fn test_manager_teardown_no_handlers() {
910        let clock: Rc<RefCell<dyn Clock>> = Rc::new(RefCell::new(VirtualClock::new()));
911        let (mut manager, _queue) = make_manager();
912        // Should not panic - no handlers to unregister
913        manager.teardown(&clock);
914        assert!(manager.quote_handlers.is_empty());
915    }
916
917    fn make_option_chain_manager() -> (OptionChainManager, DeferredCommandQueue) {
918        let series_id = make_series_id();
919        let topic = switchboard::get_option_chain_topic(series_id);
920
921        let strikes = [45000, 47500, 50000, 52500, 55000];
922        let mut instruments = HashMap::new();
923
924        for s in &strikes {
925            let strike = Price::from(&s.to_string());
926            let call_id = InstrumentId::from(&format!("BTC-20240101-{s}-C.DERIBIT"));
927            let put_id = InstrumentId::from(&format!("BTC-20240101-{s}-P.DERIBIT"));
928            instruments.insert(call_id, (strike, OptionKind::Call));
929            instruments.insert(put_id, (strike, OptionKind::Put));
930        }
931
932        let tracker = AtmTracker::new();
933        let aggregator = OptionChainAggregator::new(
934            series_id,
935            StrikeRange::AtmRelative {
936                strikes_above: 1,
937                strikes_below: 1,
938            },
939            tracker,
940            instruments,
941        );
942        let clock: Rc<RefCell<dyn Clock>> = Rc::new(RefCell::new(VirtualClock::new()));
943        let queue = make_test_queue();
944
945        let manager = OptionChainManager {
946            aggregator,
947            topic,
948            quote_handlers: Vec::new(),
949            greeks_handlers: Vec::new(),
950            timer_name: None,
951            msgbus_priority: 0,
952            bootstrapped: false,
953            deferred_cmd_queue: Rc::clone(&queue),
954            clock,
955            client_id: None,
956            raw_mode: false,
957        };
958        (manager, queue)
959    }
960
961    fn bootstrap_via_greeks(manager: &mut OptionChainManager) {
962        use nautilus_model::data::option_chain::OptionGreeks;
963        let greeks = OptionGreeks {
964            instrument_id: InstrumentId::from("BTC-20240101-50000-C.DERIBIT"),
965            underlying_price: Some(50000.0),
966            ..Default::default()
967        };
968        manager.handle_greeks(&greeks);
969    }
970
971    #[rstest]
972    fn test_manager_publish_slice_triggers_rebalance() {
973        let (mut manager, queue) = make_option_chain_manager();
974        // Initially no instruments active (ATM unknown, deferred)
975        assert_eq!(manager.aggregator.instrument_ids().len(), 0);
976
977        // Feed ATM near 50000 via greeks - bootstrap computes active set (3 strikes × 2 = 6)
978        bootstrap_via_greeks(&mut manager);
979        assert!(manager.bootstrapped);
980        assert_eq!(manager.aggregator.instrument_ids().len(), 6); // 3 strikes × 2
981
982        // Deferred queue should contain subscribe commands (6 instruments × 3 = 18 commands)
983        assert_eq!(queue.borrow().len(), 18);
984
985        // publish_slice should still work normally after bootstrap
986        manager.publish_slice(UnixNanos::from(100u64));
987        assert!(manager.aggregator.last_atm_strike().is_some());
988    }
989
990    #[rstest]
991    fn test_manager_add_instrument_new() {
992        let (mut manager, _queue) = make_option_chain_manager();
993        let clock: Rc<RefCell<dyn Clock>> = Rc::new(RefCell::new(VirtualClock::new()));
994        let new_id = InstrumentId::from("BTC-20240101-57500-C.DERIBIT");
995        let strike = Price::from("57500");
996        let count_before = manager.aggregator.instruments().len();
997
998        let result = manager.add_instrument(new_id, strike, OptionKind::Call, None, &clock);
999
1000        assert!(result);
1001        assert_eq!(manager.aggregator.instruments().len(), count_before + 1);
1002    }
1003
1004    #[rstest]
1005    fn test_manager_add_instrument_already_known() {
1006        let (mut manager, _queue) = make_option_chain_manager();
1007        let clock: Rc<RefCell<dyn Clock>> = Rc::new(RefCell::new(VirtualClock::new()));
1008        let existing_id = InstrumentId::from("BTC-20240101-50000-C.DERIBIT");
1009        let strike = Price::from("50000");
1010        let count_before = manager.aggregator.instruments().len();
1011
1012        let result = manager.add_instrument(existing_id, strike, OptionKind::Call, None, &clock);
1013
1014        assert!(!result);
1015        assert_eq!(manager.aggregator.instruments().len(), count_before);
1016    }
1017
1018    #[rstest]
1019    fn test_manager_deferred_bootstrap_on_first_atm() {
1020        let (mut manager, queue) = make_option_chain_manager();
1021        // Initially not bootstrapped, no active instruments
1022        assert!(!manager.bootstrapped);
1023        assert_eq!(manager.aggregator.instrument_ids().len(), 0);
1024        assert!(queue.borrow().is_empty());
1025
1026        // Feed ATM via greeks → triggers bootstrap
1027        bootstrap_via_greeks(&mut manager);
1028
1029        assert!(manager.bootstrapped);
1030        assert_eq!(manager.aggregator.instrument_ids().len(), 6); // 3 strikes × 2
1031        // 6 instruments × 3 commands each (quotes + greeks + instrument_status) = 18 deferred commands
1032        assert_eq!(queue.borrow().len(), 18);
1033
1034        // All commands should be Subscribe variants
1035        assert!(
1036            queue
1037                .borrow()
1038                .iter()
1039                .all(|cmd| matches!(cmd, DeferredCommand::Subscribe(_)))
1040        );
1041    }
1042
1043    #[rstest]
1044    fn test_manager_bootstrap_idempotent() {
1045        use nautilus_model::data::option_chain::OptionGreeks;
1046
1047        let (mut manager, _queue) = make_option_chain_manager();
1048        bootstrap_via_greeks(&mut manager);
1049        assert!(manager.bootstrapped);
1050        let count = manager.aggregator.instrument_ids().len();
1051
1052        // Feed another ATM update - bootstrap should not fire again
1053        let greeks2 = OptionGreeks {
1054            instrument_id: InstrumentId::from("BTC-20240101-50000-C.DERIBIT"),
1055            underlying_price: Some(50200.0),
1056            ..Default::default()
1057        };
1058        manager.handle_greeks(&greeks2);
1059        assert_eq!(manager.aggregator.instrument_ids().len(), count);
1060    }
1061
1062    #[rstest]
1063    fn test_manager_fixed_range_bootstrapped_immediately() {
1064        // Fixed range manager is bootstrapped at creation (no ATM needed)
1065        let (manager, queue) = make_manager();
1066        assert!(manager.bootstrapped);
1067        assert!(queue.borrow().is_empty());
1068    }
1069
1070    #[rstest]
1071    fn test_manager_forward_price_bootstrap_from_greeks() {
1072        use nautilus_model::data::option_chain::OptionGreeks;
1073
1074        let (mut manager, _queue) = make_option_chain_manager();
1075        assert!(!manager.bootstrapped);
1076
1077        // First greeks with underlying_price → updates ATM tracker and triggers bootstrap
1078        let greeks = OptionGreeks {
1079            instrument_id: InstrumentId::from("BTC-20240101-50000-C.DERIBIT"),
1080            underlying_price: Some(50000.0),
1081            ..Default::default()
1082        };
1083        manager.handle_greeks(&greeks);
1084        assert!(manager.bootstrapped);
1085        // 3 strikes × 2 sides = 6 active instruments
1086        assert_eq!(manager.aggregator.instrument_ids().len(), 6);
1087    }
1088
1089    #[rstest]
1090    fn test_manager_forward_price_no_bootstrap_without_underlying() {
1091        use nautilus_model::data::option_chain::OptionGreeks;
1092
1093        let (mut manager, _queue) = make_option_chain_manager();
1094        assert!(!manager.bootstrapped);
1095
1096        // Greeks with no underlying_price → should not bootstrap
1097        let greeks = OptionGreeks {
1098            instrument_id: InstrumentId::from("BTC-20240101-50000-C.DERIBIT"),
1099            underlying_price: None,
1100            ..Default::default()
1101        };
1102        manager.handle_greeks(&greeks);
1103        assert!(!manager.bootstrapped);
1104    }
1105
1106    #[rstest]
1107    fn test_manager_forward_price_rejects_invalid_underlying() {
1108        use nautilus_model::data::option_chain::OptionGreeks;
1109
1110        let (mut manager, queue) = make_option_chain_manager();
1111        let greeks = OptionGreeks {
1112            instrument_id: InstrumentId::from("BTC-20240101-50000-C.DERIBIT"),
1113            underlying_price: Some(f64::NAN),
1114            ..Default::default()
1115        };
1116
1117        manager.handle_greeks(&greeks);
1118
1119        assert!(!manager.bootstrapped);
1120        assert!(manager.aggregator.atm_tracker().atm_price().is_none());
1121        assert!(queue.borrow().is_empty());
1122    }
1123
1124    #[rstest]
1125    fn test_manager_forward_price_rejects_invalid_underlying_without_buffering_greeks() {
1126        use nautilus_model::data::{greeks::OptionGreekValues, option_chain::OptionGreeks};
1127
1128        let (mut manager, queue) = make_option_chain_manager();
1129        bootstrap_via_greeks(&mut manager);
1130        queue.borrow_mut().clear();
1131
1132        let instrument_id = InstrumentId::from("BTC-20240101-50000-C.DERIBIT");
1133        let quote = QuoteTick::new(
1134            instrument_id,
1135            Price::from("100.00"),
1136            Price::from("101.00"),
1137            Quantity::from("1.0"),
1138            Quantity::from("1.0"),
1139            UnixNanos::from(1u64),
1140            UnixNanos::from(1u64),
1141        );
1142        manager.handle_quote(&quote);
1143
1144        let greeks = OptionGreeks {
1145            instrument_id,
1146            underlying_price: Some(f64::NAN),
1147            greeks: OptionGreekValues {
1148                delta: 0.55,
1149                ..Default::default()
1150            },
1151            ..Default::default()
1152        };
1153        manager.handle_greeks(&greeks);
1154
1155        let slice = manager.aggregator.snapshot(UnixNanos::from(2u64));
1156        assert_eq!(
1157            manager.aggregator.atm_tracker().atm_price().unwrap(),
1158            Price::from("50000.00")
1159        );
1160        assert!(slice.get_call_greeks(&Price::from("50000")).is_none());
1161        assert!(queue.borrow().is_empty());
1162    }
1163
1164    #[rstest]
1165    fn test_handle_instrument_expired_removes_from_aggregator() {
1166        let (mut manager, queue) = make_option_chain_manager();
1167        // Bootstrap so instruments are active
1168        bootstrap_via_greeks(&mut manager);
1169        assert!(manager.bootstrapped);
1170        let initial_count = manager.aggregator.instruments().len();
1171        queue.borrow_mut().clear(); // clear bootstrap commands
1172
1173        let expired_id = InstrumentId::from("BTC-20240101-50000-C.DERIBIT");
1174        let is_empty = manager.handle_instrument_expired(&expired_id);
1175
1176        assert!(!is_empty);
1177        assert_eq!(manager.aggregator.instruments().len(), initial_count - 1);
1178        assert!(!manager.aggregator.active_ids().contains(&expired_id));
1179    }
1180
1181    #[rstest]
1182    fn test_handle_instrument_expired_pushes_deferred_unsubscribes() {
1183        let (mut manager, queue) = make_option_chain_manager();
1184        bootstrap_via_greeks(&mut manager);
1185        queue.borrow_mut().clear();
1186
1187        let expired_id = InstrumentId::from("BTC-20240101-50000-C.DERIBIT");
1188        manager.handle_instrument_expired(&expired_id);
1189
1190        // Should push 3 unsubscribe commands (quotes + greeks + instrument_status)
1191        let cmds: Vec<_> = queue.borrow().iter().cloned().collect();
1192        assert_eq!(cmds.len(), 3);
1193        assert!(
1194            cmds.iter()
1195                .all(|c| matches!(c, DeferredCommand::Unsubscribe(_)))
1196        );
1197    }
1198
1199    #[rstest]
1200    fn test_handle_instrument_expired_returns_true_when_last() {
1201        let series_id = make_series_id();
1202        let topic = switchboard::get_option_chain_topic(series_id);
1203        let call_id = InstrumentId::from("BTC-20240101-50000-C.DERIBIT");
1204        let strike = Price::from("50000");
1205        let mut instruments = HashMap::new();
1206        instruments.insert(call_id, (strike, OptionKind::Call));
1207        let tracker = AtmTracker::new();
1208        let aggregator = OptionChainAggregator::new(
1209            series_id,
1210            StrikeRange::Fixed(vec![strike]),
1211            tracker,
1212            instruments,
1213        );
1214        let clock: Rc<RefCell<dyn Clock>> = Rc::new(RefCell::new(VirtualClock::new()));
1215        let queue = make_test_queue();
1216
1217        let mut manager = OptionChainManager {
1218            aggregator,
1219            topic,
1220            quote_handlers: Vec::new(),
1221            greeks_handlers: Vec::new(),
1222            timer_name: None,
1223            msgbus_priority: 0,
1224            bootstrapped: true,
1225            deferred_cmd_queue: queue,
1226            clock,
1227            client_id: None,
1228            raw_mode: false,
1229        };
1230
1231        let is_empty = manager.handle_instrument_expired(&call_id);
1232        assert!(is_empty);
1233        assert!(manager.aggregator.is_catalog_empty());
1234    }
1235
1236    #[rstest]
1237    fn test_handle_instrument_expired_unknown_noop() {
1238        let (mut manager, queue) = make_manager();
1239        queue.borrow_mut().clear();
1240
1241        let unknown = InstrumentId::from("ETH-20240101-3000-C.DERIBIT");
1242        let is_empty = manager.handle_instrument_expired(&unknown);
1243
1244        // Empty manager returns true (catalog was already empty)
1245        assert!(is_empty);
1246        assert!(queue.borrow().is_empty()); // no deferred commands pushed
1247    }
1248
1249    #[rstest]
1250    fn test_publish_slice_pushes_expire_series_when_expired() {
1251        let (mut manager, queue) = make_option_chain_manager();
1252        bootstrap_via_greeks(&mut manager);
1253        queue.borrow_mut().clear();
1254
1255        // Publish at the expiration timestamp - should push ExpireSeries, not publish
1256        let expiry_ns = manager.aggregator.series_id().expiration_ns;
1257        manager.publish_slice(expiry_ns);
1258
1259        let cmds: Vec<_> = queue.borrow().iter().cloned().collect();
1260        assert_eq!(cmds.len(), 1);
1261        assert!(matches!(cmds[0], DeferredCommand::ExpireSeries(_)));
1262    }
1263
1264    #[rstest]
1265    fn test_expired_instrument_unsubscribes_include_instrument_status() {
1266        let (mut manager, queue) = make_option_chain_manager();
1267        bootstrap_via_greeks(&mut manager);
1268        queue.borrow_mut().clear();
1269
1270        let expired_id = InstrumentId::from("BTC-20240101-50000-C.DERIBIT");
1271        manager.handle_instrument_expired(&expired_id);
1272
1273        let cmds: Vec<_> = queue.borrow().iter().cloned().collect();
1274        // Should have exactly one InstrumentStatus unsubscribe among the 3
1275        let status_unsubs = cmds
1276            .iter()
1277            .filter(|c| {
1278                matches!(
1279                    c,
1280                    DeferredCommand::Unsubscribe(UnsubscribeCommand::InstrumentStatus(_))
1281                )
1282            })
1283            .count();
1284        assert_eq!(status_unsubs, 1);
1285    }
1286}