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::{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#[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 raw_mode: bool,
76}
77
78impl OptionChainManager {
79 #[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 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 let active_instrument_ids = aggregator.instrument_ids();
116 let all_instrument_ids = aggregator.all_instrument_ids();
117 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 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 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 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 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 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 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 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 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 #[must_use]
321 pub fn all_instrument_ids(&self) -> Vec<InstrumentId> {
322 self.aggregator.all_instrument_ids()
323 }
324
325 #[must_use]
327 pub fn venue(&self) -> Venue {
328 self.aggregator.series_id().venue
329 }
330
331 #[must_use]
333 pub const fn is_bootstrapped(&self) -> bool {
334 self.bootstrapped
335 }
336
337 pub fn teardown(&mut self, clock: &Rc<RefCell<dyn Clock>>) {
339 let instrument_ids = self.aggregator.instrument_ids();
341
342 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 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 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 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 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 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 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 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("e.instrument_id)
470 {
471 self.publish_slice(quote.ts_event);
472 }
473 }
474
475 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 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 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 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 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 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 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 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 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 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 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 self.aggregator.apply_rebalance(&action, now_ns);
747 }
748
749 pub fn publish_slice(&mut self, ts: nautilus_core::UnixNanos) {
751 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 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 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("e);
881 }
882
883 #[rstest]
884 fn test_manager_publish_slice_empty() {
885 let (mut manager, _queue) = make_manager();
886 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 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 assert_eq!(manager.aggregator.instrument_ids().len(), 0);
957
958 bootstrap_via_greeks(&mut manager);
960 assert!(manager.bootstrapped);
961 assert_eq!(manager.aggregator.instrument_ids().len(), 6); assert_eq!(queue.borrow().len(), 18);
965
966 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 assert!(!manager.bootstrapped);
1004 assert_eq!(manager.aggregator.instrument_ids().len(), 0);
1005 assert!(queue.borrow().is_empty());
1006
1007 bootstrap_via_greeks(&mut manager);
1009
1010 assert!(manager.bootstrapped);
1011 assert_eq!(manager.aggregator.instrument_ids().len(), 6); assert_eq!(queue.borrow().len(), 18);
1014
1015 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 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 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 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 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 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("e);
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_via_greeks(&mut manager);
1150 assert!(manager.bootstrapped);
1151 let initial_count = manager.aggregator.instruments().len();
1152 queue.borrow_mut().clear(); 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 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 assert!(is_empty);
1226 assert!(queue.borrow().is_empty()); }
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 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 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}