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: clock.clone(),
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 when the instrument falls in the active
533    /// range and forwards wire-level subscriptions via `client`.
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        }
550
551        let venue = self.aggregator.series_id().venue;
552        Self::forward_instrument_subscriptions(client, instrument_id, venue, clock);
553
554        log::info!(
555            "Added instrument {instrument_id} to option chain {} (active={})",
556            self.aggregator.series_id(),
557            self.aggregator.active_ids().contains(&instrument_id),
558        );
559
560        true
561    }
562
563    fn register_handlers_for_instrument(&self, instrument_id: InstrumentId) {
564        if let Some(qh) = self.quote_handlers.first().cloned() {
565            let topic = switchboard::get_quotes_topic(instrument_id);
566            msgbus::subscribe_quotes(topic.into(), qh, Some(self.msgbus_priority));
567        }
568
569        if let Some(gh) = self.greeks_handlers.first().cloned() {
570            let topic = switchboard::get_option_greeks_topic(instrument_id);
571            msgbus::subscribe_option_greeks(topic.into(), gh, Some(self.msgbus_priority));
572        }
573    }
574
575    /// Pushes deferred subscribe commands (quotes, greeks, instrument status) for a single instrument.
576    fn push_subscribe_commands(&self, instrument_id: InstrumentId) {
577        let venue = self.aggregator.series_id().venue;
578        let ts_init = self.clock.borrow().timestamp_ns();
579        let mut queue = self.deferred_cmd_queue.borrow_mut();
580        queue.push_back(DeferredCommand::Subscribe(SubscribeCommand::Quotes(
581            SubscribeQuotes {
582                instrument_id,
583                client_id: self.client_id,
584                venue: Some(venue),
585                command_id: UUID4::new(),
586                ts_init,
587                correlation_id: None,
588                params: None,
589            },
590        )));
591        queue.push_back(DeferredCommand::Subscribe(SubscribeCommand::OptionGreeks(
592            SubscribeOptionGreeks {
593                instrument_id,
594                client_id: self.client_id,
595                venue: Some(venue),
596                command_id: UUID4::new(),
597                ts_init,
598                correlation_id: None,
599                params: None,
600            },
601        )));
602        queue.push_back(DeferredCommand::Subscribe(
603            SubscribeCommand::InstrumentStatus(SubscribeInstrumentStatus {
604                instrument_id,
605                client_id: self.client_id,
606                venue: Some(venue),
607                command_id: UUID4::new(),
608                ts_init,
609                correlation_id: None,
610                params: None,
611            }),
612        ));
613    }
614
615    /// Pushes deferred unsubscribe commands (quotes, greeks, instrument status) for a single instrument.
616    fn push_unsubscribe_commands(&self, instrument_id: InstrumentId) {
617        let venue = self.aggregator.series_id().venue;
618        let ts_init = self.clock.borrow().timestamp_ns();
619        let mut queue = self.deferred_cmd_queue.borrow_mut();
620        queue.push_back(DeferredCommand::Unsubscribe(UnsubscribeCommand::Quotes(
621            UnsubscribeQuotes {
622                instrument_id,
623                client_id: self.client_id,
624                venue: Some(venue),
625                command_id: UUID4::new(),
626                ts_init,
627                correlation_id: None,
628                params: None,
629            },
630        )));
631        queue.push_back(DeferredCommand::Unsubscribe(
632            UnsubscribeCommand::OptionGreeks(UnsubscribeOptionGreeks {
633                instrument_id,
634                client_id: self.client_id,
635                venue: Some(venue),
636                command_id: UUID4::new(),
637                ts_init,
638                correlation_id: None,
639                params: None,
640            }),
641        ));
642        queue.push_back(DeferredCommand::Unsubscribe(
643            UnsubscribeCommand::InstrumentStatus(UnsubscribeInstrumentStatus {
644                instrument_id,
645                client_id: self.client_id,
646                venue: Some(venue),
647                command_id: UUID4::new(),
648                ts_init,
649                correlation_id: None,
650                params: None,
651            }),
652        ));
653    }
654
655    /// Forwards quote, greeks, and instrument status subscriptions for a single instrument.
656    fn forward_instrument_subscriptions(
657        client: Option<&mut DataClientAdapter>,
658        instrument_id: InstrumentId,
659        venue: Venue,
660        clock: &Rc<RefCell<dyn Clock>>,
661    ) {
662        let Some(client) = client else {
663            log::error!(
664                "Cannot forward subscriptions for {instrument_id}: no client for venue={venue}",
665            );
666            return;
667        };
668
669        let ts_init = clock.borrow().timestamp_ns();
670
671        client.execute_subscribe_intent(SubscribeCommand::Quotes(SubscribeQuotes {
672            instrument_id,
673            client_id: None,
674            venue: Some(venue),
675            command_id: UUID4::new(),
676            ts_init,
677            correlation_id: None,
678            params: None,
679        }));
680        client.execute_subscribe_intent(SubscribeCommand::OptionGreeks(SubscribeOptionGreeks {
681            instrument_id,
682            client_id: None,
683            venue: Some(venue),
684            command_id: UUID4::new(),
685            ts_init,
686            correlation_id: None,
687            params: None,
688        }));
689        client.execute_subscribe_intent(SubscribeCommand::InstrumentStatus(
690            SubscribeInstrumentStatus {
691                instrument_id,
692                client_id: None,
693                venue: Some(venue),
694                command_id: UUID4::new(),
695                ts_init,
696                correlation_id: None,
697                params: None,
698            },
699        ));
700    }
701
702    /// Checks if ATM has shifted and rebalances msgbus subscriptions if needed.
703    fn maybe_rebalance(&mut self, now_ns: nautilus_core::UnixNanos) {
704        let Some(action) = self.aggregator.check_rebalance(now_ns) else {
705            return;
706        };
707
708        // Unsubscribe removed instruments from msgbus
709        if let Some(qh) = self.quote_handlers.first() {
710            for id in &action.remove {
711                msgbus::unsubscribe_quotes(switchboard::get_quotes_topic(*id).into(), qh);
712            }
713        }
714
715        if let Some(gh) = self.greeks_handlers.first() {
716            for id in &action.remove {
717                msgbus::unsubscribe_option_greeks(
718                    switchboard::get_option_greeks_topic(*id).into(),
719                    gh,
720                );
721            }
722        }
723
724        // Subscribe new instruments on msgbus
725        if let Some(qh) = self.quote_handlers.first().cloned() {
726            for id in &action.add {
727                msgbus::subscribe_quotes(
728                    switchboard::get_quotes_topic(*id).into(),
729                    qh.clone(),
730                    Some(self.msgbus_priority),
731                );
732            }
733        }
734
735        if let Some(gh) = self.greeks_handlers.first().cloned() {
736            for id in &action.add {
737                msgbus::subscribe_option_greeks(
738                    switchboard::get_option_greeks_topic(*id).into(),
739                    gh.clone(),
740                    Some(self.msgbus_priority),
741                );
742            }
743        }
744
745        // Push deferred wire-level changes into the shared command queue
746        for &id in &action.add {
747            self.push_subscribe_commands(id);
748        }
749
750        for &id in &action.remove {
751            self.push_unsubscribe_commands(id);
752        }
753
754        if !action.add.is_empty() || !action.remove.is_empty() {
755            log::info!(
756                "Rebalanced option chain for {}: +{} -{} instruments",
757                self.aggregator.series_id(),
758                action.add.len(),
759                action.remove.len(),
760            );
761        }
762
763        // Apply state changes to aggregator
764        self.aggregator.apply_rebalance(&action, now_ns);
765    }
766
767    /// Takes the accumulated snapshot and publishes it to the msgbus.
768    pub fn publish_slice(&mut self, ts: nautilus_core::UnixNanos) {
769        // Proactive expiry safeguard
770        if self.aggregator.is_expired(ts) {
771            self.deferred_cmd_queue
772                .borrow_mut()
773                .push_back(DeferredCommand::ExpireSeries(self.aggregator.series_id()));
774            return;
775        }
776
777        self.maybe_rebalance(ts);
778
779        let series_id = self.aggregator.series_id();
780        let slice = self.aggregator.snapshot(ts);
781
782        if slice.is_empty() {
783            log::debug!("OptionChainSlice empty for {series_id}, skipping publish");
784            return;
785        }
786
787        log::debug!(
788            "Publishing OptionChainSlice for {} (calls={}, puts={})",
789            series_id,
790            slice.call_count(),
791            slice.put_count(),
792        );
793        msgbus::publish_option_chain(self.topic, &slice);
794    }
795
796    /// Resolves instruments from cache that match the given option series.
797    fn resolve_instruments(
798        cache: &Rc<RefCell<Cache>>,
799        series_id: &OptionSeriesId,
800    ) -> HashMap<InstrumentId, (Price, OptionKind)> {
801        let cache = cache.borrow();
802        let mut map = HashMap::new();
803
804        for instrument in cache.instruments(&series_id.venue, Some(&series_id.underlying)) {
805            let Some(expiration) = instrument.expiration_ns() else {
806                continue;
807            };
808
809            if expiration != series_id.expiration_ns {
810                continue;
811            }
812
813            if instrument.settlement_currency().code != series_id.settlement_currency {
814                continue;
815            }
816
817            let Some(strike) = instrument.strike_price() else {
818                continue;
819            };
820
821            let Some(kind) = instrument.option_kind() else {
822                continue;
823            };
824
825            map.insert(instrument.id(), (strike, kind));
826        }
827
828        map
829    }
830}
831
832#[cfg(test)]
833mod tests {
834    use std::collections::VecDeque;
835
836    use nautilus_common::clock::TestClock;
837    use nautilus_core::UnixNanos;
838    use nautilus_model::{data::option_chain::StrikeRange, identifiers::Venue, types::Quantity};
839    use rstest::*;
840
841    use super::*;
842
843    fn make_series_id() -> OptionSeriesId {
844        OptionSeriesId::new(
845            Venue::new("DERIBIT"),
846            ustr::Ustr::from("BTC"),
847            ustr::Ustr::from("BTC"),
848            UnixNanos::from(1_700_000_000_000_000_000u64),
849        )
850    }
851
852    fn make_test_queue() -> DeferredCommandQueue {
853        Rc::new(RefCell::new(VecDeque::new()))
854    }
855
856    fn make_manager() -> (OptionChainManager, DeferredCommandQueue) {
857        let series_id = make_series_id();
858        let topic = switchboard::get_option_chain_topic(series_id);
859        let tracker = AtmTracker::new();
860        let aggregator = OptionChainAggregator::new(
861            series_id,
862            StrikeRange::Fixed(vec![]),
863            tracker,
864            HashMap::new(),
865        );
866        let clock: Rc<RefCell<dyn Clock>> = Rc::new(RefCell::new(TestClock::new()));
867        let queue = make_test_queue();
868
869        let manager = OptionChainManager {
870            aggregator,
871            topic,
872            quote_handlers: Vec::new(),
873            greeks_handlers: Vec::new(),
874            timer_name: None,
875            msgbus_priority: 0,
876            bootstrapped: true,
877            deferred_cmd_queue: queue.clone(),
878            clock,
879            client_id: None,
880            raw_mode: false,
881        };
882        (manager, queue)
883    }
884
885    #[rstest]
886    fn test_manager_handle_quote_no_instrument() {
887        let (mut manager, _queue) = make_manager();
888
889        // Should not panic - quote for unknown instrument
890        let quote = QuoteTick::new(
891            InstrumentId::from("BTC-20240101-50000-C.DERIBIT"),
892            Price::from("100.00"),
893            Price::from("101.00"),
894            Quantity::from("1.0"),
895            Quantity::from("1.0"),
896            UnixNanos::from(1u64),
897            UnixNanos::from(1u64),
898        );
899        manager.handle_quote(&quote);
900    }
901
902    #[rstest]
903    fn test_manager_publish_slice_empty() {
904        let (mut manager, _queue) = make_manager();
905        // Should not panic - empty slice skips publish
906        manager.publish_slice(UnixNanos::from(100u64));
907    }
908
909    #[rstest]
910    fn test_manager_teardown_no_handlers() {
911        let clock: Rc<RefCell<dyn Clock>> = Rc::new(RefCell::new(TestClock::new()));
912        let (mut manager, _queue) = make_manager();
913        // Should not panic - no handlers to unregister
914        manager.teardown(&clock);
915        assert!(manager.quote_handlers.is_empty());
916    }
917
918    fn make_option_chain_manager() -> (OptionChainManager, DeferredCommandQueue) {
919        let series_id = make_series_id();
920        let topic = switchboard::get_option_chain_topic(series_id);
921
922        let strikes = [45000, 47500, 50000, 52500, 55000];
923        let mut instruments = HashMap::new();
924
925        for s in &strikes {
926            let strike = Price::from(&s.to_string());
927            let call_id = InstrumentId::from(&format!("BTC-20240101-{s}-C.DERIBIT"));
928            let put_id = InstrumentId::from(&format!("BTC-20240101-{s}-P.DERIBIT"));
929            instruments.insert(call_id, (strike, OptionKind::Call));
930            instruments.insert(put_id, (strike, OptionKind::Put));
931        }
932
933        let tracker = AtmTracker::new();
934        let aggregator = OptionChainAggregator::new(
935            series_id,
936            StrikeRange::AtmRelative {
937                strikes_above: 1,
938                strikes_below: 1,
939            },
940            tracker,
941            instruments,
942        );
943        let clock: Rc<RefCell<dyn Clock>> = Rc::new(RefCell::new(TestClock::new()));
944        let queue = make_test_queue();
945
946        let manager = OptionChainManager {
947            aggregator,
948            topic,
949            quote_handlers: Vec::new(),
950            greeks_handlers: Vec::new(),
951            timer_name: None,
952            msgbus_priority: 0,
953            bootstrapped: false,
954            deferred_cmd_queue: queue.clone(),
955            clock,
956            client_id: None,
957            raw_mode: false,
958        };
959        (manager, queue)
960    }
961
962    fn bootstrap_via_greeks(manager: &mut OptionChainManager) {
963        use nautilus_model::data::option_chain::OptionGreeks;
964        let greeks = OptionGreeks {
965            instrument_id: InstrumentId::from("BTC-20240101-50000-C.DERIBIT"),
966            underlying_price: Some(50000.0),
967            ..Default::default()
968        };
969        manager.handle_greeks(&greeks);
970    }
971
972    #[rstest]
973    fn test_manager_publish_slice_triggers_rebalance() {
974        let (mut manager, queue) = make_option_chain_manager();
975        // Initially no instruments active (ATM unknown, deferred)
976        assert_eq!(manager.aggregator.instrument_ids().len(), 0);
977
978        // Feed ATM near 50000 via greeks - bootstrap computes active set (3 strikes × 2 = 6)
979        bootstrap_via_greeks(&mut manager);
980        assert!(manager.bootstrapped);
981        assert_eq!(manager.aggregator.instrument_ids().len(), 6); // 3 strikes × 2
982
983        // Deferred queue should contain subscribe commands (6 instruments × 3 = 18 commands)
984        assert_eq!(queue.borrow().len(), 18);
985
986        // publish_slice should still work normally after bootstrap
987        manager.publish_slice(UnixNanos::from(100u64));
988        assert!(manager.aggregator.last_atm_strike().is_some());
989    }
990
991    #[rstest]
992    fn test_manager_add_instrument_new() {
993        let (mut manager, _queue) = make_option_chain_manager();
994        let clock: Rc<RefCell<dyn Clock>> = Rc::new(RefCell::new(TestClock::new()));
995        let new_id = InstrumentId::from("BTC-20240101-57500-C.DERIBIT");
996        let strike = Price::from("57500");
997        let count_before = manager.aggregator.instruments().len();
998
999        let result = manager.add_instrument(new_id, strike, OptionKind::Call, None, &clock);
1000
1001        assert!(result);
1002        assert_eq!(manager.aggregator.instruments().len(), count_before + 1);
1003    }
1004
1005    #[rstest]
1006    fn test_manager_add_instrument_already_known() {
1007        let (mut manager, _queue) = make_option_chain_manager();
1008        let clock: Rc<RefCell<dyn Clock>> = Rc::new(RefCell::new(TestClock::new()));
1009        let existing_id = InstrumentId::from("BTC-20240101-50000-C.DERIBIT");
1010        let strike = Price::from("50000");
1011        let count_before = manager.aggregator.instruments().len();
1012
1013        let result = manager.add_instrument(existing_id, strike, OptionKind::Call, None, &clock);
1014
1015        assert!(!result);
1016        assert_eq!(manager.aggregator.instruments().len(), count_before);
1017    }
1018
1019    #[rstest]
1020    fn test_manager_deferred_bootstrap_on_first_atm() {
1021        let (mut manager, queue) = make_option_chain_manager();
1022        // Initially not bootstrapped, no active instruments
1023        assert!(!manager.bootstrapped);
1024        assert_eq!(manager.aggregator.instrument_ids().len(), 0);
1025        assert!(queue.borrow().is_empty());
1026
1027        // Feed ATM via greeks → triggers bootstrap
1028        bootstrap_via_greeks(&mut manager);
1029
1030        assert!(manager.bootstrapped);
1031        assert_eq!(manager.aggregator.instrument_ids().len(), 6); // 3 strikes × 2
1032        // 6 instruments × 3 commands each (quotes + greeks + instrument_status) = 18 deferred commands
1033        assert_eq!(queue.borrow().len(), 18);
1034
1035        // All commands should be Subscribe variants
1036        assert!(
1037            queue
1038                .borrow()
1039                .iter()
1040                .all(|cmd| matches!(cmd, DeferredCommand::Subscribe(_)))
1041        );
1042    }
1043
1044    #[rstest]
1045    fn test_manager_bootstrap_idempotent() {
1046        use nautilus_model::data::option_chain::OptionGreeks;
1047
1048        let (mut manager, _queue) = make_option_chain_manager();
1049        bootstrap_via_greeks(&mut manager);
1050        assert!(manager.bootstrapped);
1051        let count = manager.aggregator.instrument_ids().len();
1052
1053        // Feed another ATM update - bootstrap should not fire again
1054        let greeks2 = OptionGreeks {
1055            instrument_id: InstrumentId::from("BTC-20240101-50000-C.DERIBIT"),
1056            underlying_price: Some(50200.0),
1057            ..Default::default()
1058        };
1059        manager.handle_greeks(&greeks2);
1060        assert_eq!(manager.aggregator.instrument_ids().len(), count);
1061    }
1062
1063    #[rstest]
1064    fn test_manager_fixed_range_bootstrapped_immediately() {
1065        // Fixed range manager is bootstrapped at creation (no ATM needed)
1066        let (manager, queue) = make_manager();
1067        assert!(manager.bootstrapped);
1068        assert!(queue.borrow().is_empty());
1069    }
1070
1071    #[rstest]
1072    fn test_manager_forward_price_bootstrap_from_greeks() {
1073        use nautilus_model::data::option_chain::OptionGreeks;
1074
1075        let (mut manager, _queue) = make_option_chain_manager();
1076        assert!(!manager.bootstrapped);
1077
1078        // First greeks with underlying_price → updates ATM tracker and triggers bootstrap
1079        let greeks = OptionGreeks {
1080            instrument_id: InstrumentId::from("BTC-20240101-50000-C.DERIBIT"),
1081            underlying_price: Some(50000.0),
1082            ..Default::default()
1083        };
1084        manager.handle_greeks(&greeks);
1085        assert!(manager.bootstrapped);
1086        // 3 strikes × 2 sides = 6 active instruments
1087        assert_eq!(manager.aggregator.instrument_ids().len(), 6);
1088    }
1089
1090    #[rstest]
1091    fn test_manager_forward_price_no_bootstrap_without_underlying() {
1092        use nautilus_model::data::option_chain::OptionGreeks;
1093
1094        let (mut manager, _queue) = make_option_chain_manager();
1095        assert!(!manager.bootstrapped);
1096
1097        // Greeks with no underlying_price → should not bootstrap
1098        let greeks = OptionGreeks {
1099            instrument_id: InstrumentId::from("BTC-20240101-50000-C.DERIBIT"),
1100            underlying_price: None,
1101            ..Default::default()
1102        };
1103        manager.handle_greeks(&greeks);
1104        assert!(!manager.bootstrapped);
1105    }
1106
1107    #[rstest]
1108    fn test_manager_forward_price_rejects_invalid_underlying() {
1109        use nautilus_model::data::option_chain::OptionGreeks;
1110
1111        let (mut manager, queue) = make_option_chain_manager();
1112        let greeks = OptionGreeks {
1113            instrument_id: InstrumentId::from("BTC-20240101-50000-C.DERIBIT"),
1114            underlying_price: Some(f64::NAN),
1115            ..Default::default()
1116        };
1117
1118        manager.handle_greeks(&greeks);
1119
1120        assert!(!manager.bootstrapped);
1121        assert!(manager.aggregator.atm_tracker().atm_price().is_none());
1122        assert!(queue.borrow().is_empty());
1123    }
1124
1125    #[rstest]
1126    fn test_manager_forward_price_rejects_invalid_underlying_without_buffering_greeks() {
1127        use nautilus_model::data::{greeks::OptionGreekValues, option_chain::OptionGreeks};
1128
1129        let (mut manager, queue) = make_option_chain_manager();
1130        bootstrap_via_greeks(&mut manager);
1131        queue.borrow_mut().clear();
1132
1133        let instrument_id = InstrumentId::from("BTC-20240101-50000-C.DERIBIT");
1134        let quote = QuoteTick::new(
1135            instrument_id,
1136            Price::from("100.00"),
1137            Price::from("101.00"),
1138            Quantity::from("1.0"),
1139            Quantity::from("1.0"),
1140            UnixNanos::from(1u64),
1141            UnixNanos::from(1u64),
1142        );
1143        manager.handle_quote(&quote);
1144
1145        let greeks = OptionGreeks {
1146            instrument_id,
1147            underlying_price: Some(f64::NAN),
1148            greeks: OptionGreekValues {
1149                delta: 0.55,
1150                ..Default::default()
1151            },
1152            ..Default::default()
1153        };
1154        manager.handle_greeks(&greeks);
1155
1156        let slice = manager.aggregator.snapshot(UnixNanos::from(2u64));
1157        assert_eq!(
1158            manager.aggregator.atm_tracker().atm_price().unwrap(),
1159            Price::from("50000.00")
1160        );
1161        assert!(slice.get_call_greeks(&Price::from("50000")).is_none());
1162        assert!(queue.borrow().is_empty());
1163    }
1164
1165    #[rstest]
1166    fn test_handle_instrument_expired_removes_from_aggregator() {
1167        let (mut manager, queue) = make_option_chain_manager();
1168        // Bootstrap so instruments are active
1169        bootstrap_via_greeks(&mut manager);
1170        assert!(manager.bootstrapped);
1171        let initial_count = manager.aggregator.instruments().len();
1172        queue.borrow_mut().clear(); // clear bootstrap commands
1173
1174        let expired_id = InstrumentId::from("BTC-20240101-50000-C.DERIBIT");
1175        let is_empty = manager.handle_instrument_expired(&expired_id);
1176
1177        assert!(!is_empty);
1178        assert_eq!(manager.aggregator.instruments().len(), initial_count - 1);
1179        assert!(!manager.aggregator.active_ids().contains(&expired_id));
1180    }
1181
1182    #[rstest]
1183    fn test_handle_instrument_expired_pushes_deferred_unsubscribes() {
1184        let (mut manager, queue) = make_option_chain_manager();
1185        bootstrap_via_greeks(&mut manager);
1186        queue.borrow_mut().clear();
1187
1188        let expired_id = InstrumentId::from("BTC-20240101-50000-C.DERIBIT");
1189        manager.handle_instrument_expired(&expired_id);
1190
1191        // Should push 3 unsubscribe commands (quotes + greeks + instrument_status)
1192        let cmds: Vec<_> = queue.borrow().iter().cloned().collect();
1193        assert_eq!(cmds.len(), 3);
1194        assert!(
1195            cmds.iter()
1196                .all(|c| matches!(c, DeferredCommand::Unsubscribe(_)))
1197        );
1198    }
1199
1200    #[rstest]
1201    fn test_handle_instrument_expired_returns_true_when_last() {
1202        let series_id = make_series_id();
1203        let topic = switchboard::get_option_chain_topic(series_id);
1204        let call_id = InstrumentId::from("BTC-20240101-50000-C.DERIBIT");
1205        let strike = Price::from("50000");
1206        let mut instruments = HashMap::new();
1207        instruments.insert(call_id, (strike, OptionKind::Call));
1208        let tracker = AtmTracker::new();
1209        let aggregator = OptionChainAggregator::new(
1210            series_id,
1211            StrikeRange::Fixed(vec![strike]),
1212            tracker,
1213            instruments,
1214        );
1215        let clock: Rc<RefCell<dyn Clock>> = Rc::new(RefCell::new(TestClock::new()));
1216        let queue = make_test_queue();
1217
1218        let mut manager = OptionChainManager {
1219            aggregator,
1220            topic,
1221            quote_handlers: Vec::new(),
1222            greeks_handlers: Vec::new(),
1223            timer_name: None,
1224            msgbus_priority: 0,
1225            bootstrapped: true,
1226            deferred_cmd_queue: queue,
1227            clock,
1228            client_id: None,
1229            raw_mode: false,
1230        };
1231
1232        let is_empty = manager.handle_instrument_expired(&call_id);
1233        assert!(is_empty);
1234        assert!(manager.aggregator.is_catalog_empty());
1235    }
1236
1237    #[rstest]
1238    fn test_handle_instrument_expired_unknown_noop() {
1239        let (mut manager, queue) = make_manager();
1240        queue.borrow_mut().clear();
1241
1242        let unknown = InstrumentId::from("ETH-20240101-3000-C.DERIBIT");
1243        let is_empty = manager.handle_instrument_expired(&unknown);
1244
1245        // Empty manager returns true (catalog was already empty)
1246        assert!(is_empty);
1247        assert!(queue.borrow().is_empty()); // no deferred commands pushed
1248    }
1249
1250    #[rstest]
1251    fn test_publish_slice_pushes_expire_series_when_expired() {
1252        let (mut manager, queue) = make_option_chain_manager();
1253        bootstrap_via_greeks(&mut manager);
1254        queue.borrow_mut().clear();
1255
1256        // Publish at the expiration timestamp - should push ExpireSeries, not publish
1257        let expiry_ns = manager.aggregator.series_id().expiration_ns;
1258        manager.publish_slice(expiry_ns);
1259
1260        let cmds: Vec<_> = queue.borrow().iter().cloned().collect();
1261        assert_eq!(cmds.len(), 1);
1262        assert!(matches!(cmds[0], DeferredCommand::ExpireSeries(_)));
1263    }
1264
1265    #[rstest]
1266    fn test_expired_instrument_unsubscribes_include_instrument_status() {
1267        let (mut manager, queue) = make_option_chain_manager();
1268        bootstrap_via_greeks(&mut manager);
1269        queue.borrow_mut().clear();
1270
1271        let expired_id = InstrumentId::from("BTC-20240101-50000-C.DERIBIT");
1272        manager.handle_instrument_expired(&expired_id);
1273
1274        let cmds: Vec<_> = queue.borrow().iter().cloned().collect();
1275        // Should have exactly one InstrumentStatus unsubscribe among the 3
1276        let status_unsubs = cmds
1277            .iter()
1278            .filter(|c| {
1279                matches!(
1280                    c,
1281                    DeferredCommand::Unsubscribe(UnsubscribeCommand::InstrumentStatus(_))
1282                )
1283            })
1284            .count();
1285        assert_eq!(status_unsubs, 1);
1286    }
1287}