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: clock.clone(),
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 }
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 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 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 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 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 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 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 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 self.aggregator.apply_rebalance(&action, now_ns);
765 }
766
767 pub fn publish_slice(&mut self, ts: nautilus_core::UnixNanos) {
769 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 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 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("e);
900 }
901
902 #[rstest]
903 fn test_manager_publish_slice_empty() {
904 let (mut manager, _queue) = make_manager();
905 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 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 assert_eq!(manager.aggregator.instrument_ids().len(), 0);
977
978 bootstrap_via_greeks(&mut manager);
980 assert!(manager.bootstrapped);
981 assert_eq!(manager.aggregator.instrument_ids().len(), 6); assert_eq!(queue.borrow().len(), 18);
985
986 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 assert!(!manager.bootstrapped);
1024 assert_eq!(manager.aggregator.instrument_ids().len(), 0);
1025 assert!(queue.borrow().is_empty());
1026
1027 bootstrap_via_greeks(&mut manager);
1029
1030 assert!(manager.bootstrapped);
1031 assert_eq!(manager.aggregator.instrument_ids().len(), 6); assert_eq!(queue.borrow().len(), 18);
1034
1035 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 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 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 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 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 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("e);
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_via_greeks(&mut manager);
1170 assert!(manager.bootstrapped);
1171 let initial_count = manager.aggregator.instruments().len();
1172 queue.borrow_mut().clear(); 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 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 assert!(is_empty);
1247 assert!(queue.borrow().is_empty()); }
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 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 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}