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