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