Skip to main content

nautilus_data/engine/
book.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16use std::{cell::RefCell, num::NonZeroUsize, rc::Rc};
17
18use indexmap::IndexMap;
19use nautilus_common::{
20    cache::Cache,
21    messages::data::{SubscribeBookSnapshots, SubscribeCommand},
22    msgbus::{self, Handler, MStr, Topic, switchboard},
23    timer::TimeEvent,
24};
25use nautilus_model::{
26    data::{OrderBookDeltas, OrderBookDepth, QuoteTick},
27    enums::{BookType, InstrumentClass},
28    identifiers::{ClientId, InstrumentId, Venue},
29    instruments::Instrument,
30    orderbook::OrderBook,
31};
32use ustr::Ustr;
33
34use super::{
35    BookDeltasResponse, BookDepthResponse, DataEngine, DurationNanos, FAILED, OrderBookDelta,
36    PARAMS_IS_PARENT, Params, RecordFlag, SubscribeBookDeltas, SubscribeBookDepth, SubscriptionKey,
37    TimeEventCallback, TypedHandler, UUID4, UnsubscribeBookDeltas, UnsubscribeBookDepth,
38    UnsubscribeBookSnapshots, UnsubscribeCommand, is_parent_subscription,
39    log_error_on_cache_insert,
40};
41
42impl DataEngine {
43    /// Returns all instrument IDs for which book delta subscriptions exist.
44    #[must_use]
45    pub fn subscribed_book_deltas(&self) -> Vec<InstrumentId> {
46        self.collect_subscriptions(|client| &client.subscriptions_book_deltas)
47    }
48
49    /// Returns all instrument IDs for which book depth subscriptions exist.
50    #[must_use]
51    pub fn subscribed_book_depth(&self) -> Vec<InstrumentId> {
52        self.collect_subscriptions(|client| &client.subscriptions_book_depth)
53    }
54
55    /// Returns all instrument IDs for which book snapshot subscriptions exist.
56    #[must_use]
57    pub fn subscribed_book_snapshots(&self) -> Vec<InstrumentId> {
58        self.book_snapshot_counts
59            .keys()
60            .map(|(instrument_id, _)| *instrument_id)
61            .collect()
62    }
63
64    pub(super) fn subscribe_book_deltas(
65        &mut self,
66        cmd: &SubscribeBookDeltas,
67    ) -> anyhow::Result<bool> {
68        if cmd.instrument_id.is_synthetic() {
69            anyhow::bail!("Cannot subscribe for synthetic instrument `OrderBookDelta` data");
70        }
71
72        let had_deltas =
73            self.has_book_delta_subscription_key(cmd.instrument_id, cmd.client_id, cmd.venue);
74
75        self.retain_book_subscription(SubscribeCommand::BookDeltas(cmd.clone()))?;
76
77        self.increment_book_delta_subscription(cmd.instrument_id, cmd.client_id, cmd.venue);
78
79        Ok(!had_deltas)
80    }
81
82    pub(super) fn subscribe_book_depth(
83        &mut self,
84        cmd: &SubscribeBookDepth,
85    ) -> anyhow::Result<bool> {
86        if cmd.instrument_id.is_synthetic() {
87            anyhow::bail!("Cannot subscribe for synthetic instrument `OrderBookDepth` data");
88        }
89
90        let had_depth =
91            self.has_book_depth_subscription_key(cmd.instrument_id, cmd.client_id, cmd.venue);
92
93        self.retain_book_subscription(SubscribeCommand::BookDepth(cmd.clone()))?;
94
95        self.increment_book_depth_subscription(cmd.instrument_id, cmd.client_id, cmd.venue);
96
97        Ok(!had_depth)
98    }
99
100    pub(super) fn subscribe_book_snapshots(
101        &mut self,
102        cmd: &SubscribeBookSnapshots,
103    ) -> anyhow::Result<()> {
104        if cmd.instrument_id.is_synthetic() {
105            anyhow::bail!("Cannot subscribe for synthetic instrument `OrderBookDelta` data");
106        }
107
108        let parent = resolve_parent_components(&cmd.instrument_id, cmd.params.as_ref())?;
109
110        let had_snapshots = self.has_book_snapshot_subscriptions(&cmd.instrument_id);
111
112        self.retain_book_subscription(SubscribeCommand::BookSnapshots(cmd.clone()))?;
113
114        self.increment_book_snapshot_subscription(cmd, parent);
115
116        if !had_snapshots {
117            self.book_snapshot_sources.insert(
118                cmd.instrument_id,
119                BookSnapshotSource {
120                    command: cmd.clone(),
121                    client_command: SubscribeCommand::BookDeltas(SubscribeBookDeltas::new(
122                        cmd.instrument_id,
123                        cmd.book_type,
124                        cmd.client_id,
125                        cmd.venue,
126                        UUID4::new(),
127                        cmd.ts_init,
128                        cmd.depth,
129                        true, // managed
130                        Some(cmd.command_id),
131                        cmd.params.clone(),
132                    )),
133                },
134            );
135        }
136
137        let source = self
138            .book_snapshot_sources
139            .get(&cmd.instrument_id)
140            .cloned()
141            .expect("snapshot source command must exist after increment");
142        self.subscribe_book_snapshot_source(&source.command, source.client_command);
143
144        Ok(())
145    }
146
147    fn subscribe_book_snapshot_source(
148        &mut self,
149        cmd: &SubscribeBookSnapshots,
150        client_command: SubscribeCommand,
151    ) {
152        if let Some(client_id) = cmd.client_id.as_ref()
153            && self.external_clients.contains(client_id)
154        {
155            if self.config.debug {
156                log::debug!("Skipping subscribe command for external client {client_id}: {cmd:?}");
157            }
158
159            return;
160        }
161
162        log::debug!(
163            "Forwarding BookSnapshots as BookDeltas for {}, client_id={:?}, venue={:?}",
164            cmd.instrument_id,
165            cmd.client_id,
166            cmd.venue,
167        );
168
169        if let Some(client) = self.get_command_client(cmd.client_id.as_ref(), cmd.venue.as_ref()) {
170            log::debug!(
171                "Calling client.execute_subscribe for BookDeltas: {}",
172                cmd.instrument_id
173            );
174            client.execute_subscribe(client_command);
175        } else {
176            log::error!(
177                "Cannot handle command: no client found for client_id={:?}, venue={:?}",
178                cmd.client_id,
179                cmd.venue,
180            );
181        }
182    }
183
184    pub(super) fn unsubscribe_book_deltas(&mut self, cmd: &UnsubscribeBookDeltas) -> bool {
185        match self.decrement_book_delta_subscription(cmd.instrument_id, cmd.client_id, cmd.venue) {
186            BookDeltasUnsubscribeResult::NotSubscribed => {
187                log::warn!("Cannot unsubscribe from `OrderBookDeltas` data: not subscribed");
188                return false;
189            }
190            BookDeltasUnsubscribeResult::Decremented => return false,
191            BookDeltasUnsubscribeResult::Removed => {}
192        }
193
194        true
195    }
196
197    pub(super) fn unsubscribe_book_depth(&mut self, cmd: &UnsubscribeBookDepth) -> bool {
198        match self.decrement_book_depth_subscription(cmd.instrument_id, cmd.client_id, cmd.venue) {
199            BookDeltasUnsubscribeResult::NotSubscribed => {
200                log::warn!("Cannot unsubscribe from `OrderBookDepth` data: not subscribed");
201                return false;
202            }
203            BookDeltasUnsubscribeResult::Decremented => return false,
204            BookDeltasUnsubscribeResult::Removed => {}
205        }
206
207        true
208    }
209
210    pub(super) fn unsubscribe_book_snapshots(&mut self, cmd: &UnsubscribeBookSnapshots) {
211        match self.decrement_book_snapshot_subscription(cmd.instrument_id, cmd.interval_ms) {
212            BookSnapshotUnsubscribeResult::NotSubscribed => {
213                log::warn!("Cannot unsubscribe from `OrderBook` snapshots: not subscribed");
214                return;
215            }
216            BookSnapshotUnsubscribeResult::Decremented => return,
217            BookSnapshotUnsubscribeResult::Removed => {}
218        }
219
220        if self.has_book_snapshot_subscriptions(&cmd.instrument_id) {
221            return;
222        }
223
224        let Some(source) = self.book_snapshot_sources.remove(&cmd.instrument_id) else {
225            log::error!(
226                "Cannot release order book snapshot source for {}: command not retained",
227                cmd.instrument_id,
228            );
229            return;
230        };
231
232        if let Some(client_id) = source.command.client_id.as_ref()
233            && self.external_clients.contains(client_id)
234        {
235            return;
236        }
237
238        if let Some(client) = self.get_command_client(
239            source.command.client_id.as_ref(),
240            source.command.venue.as_ref(),
241        ) {
242            let deltas_cmd = UnsubscribeBookDeltas::new(
243                source.command.instrument_id,
244                source.command.client_id,
245                source.command.venue,
246                UUID4::new(),
247                cmd.ts_init,
248                Some(source.command.command_id),
249                source.command.params,
250            );
251            client.execute_unsubscribe(&UnsubscribeCommand::BookDeltas(deltas_cmd));
252        }
253    }
254
255    fn retain_book_subscription(&mut self, command: SubscribeCommand) -> anyhow::Result<()> {
256        let instrument_id = match &command {
257            SubscribeCommand::BookDeltas(cmd) => cmd.instrument_id,
258            SubscribeCommand::BookDepth(cmd) => cmd.instrument_id,
259            SubscribeCommand::BookSnapshots(cmd) => cmd.instrument_id,
260            _ => unreachable!("only book subscriptions are retained"),
261        };
262
263        let parent = resolve_parent_components(&instrument_id, command.params())?;
264
265        let targets = if let Some((root, class)) = parent {
266            self.cache
267                .borrow()
268                .instruments_by_parent(&instrument_id.venue, &root, class)
269                .iter()
270                .map(|instrument| instrument.id())
271                .collect()
272        } else {
273            vec![instrument_id]
274        };
275
276        let client_id = self
277            .get_command_client(command.client_id(), command.venue())
278            .map(|client| client.client_id);
279
280        let subscription = Rc::new(BookSubscriptionOwner {
281            command,
282            client_id,
283            targets,
284        });
285
286        let mut params = subscription.command.params().cloned().unwrap_or_default();
287        params.shift_remove(PARAMS_IS_PARENT);
288
289        for active in subscription
290            .targets
291            .iter()
292            .filter_map(|id| self.book_subscriptions.get(id))
293            .flat_map(|book| &book.owners)
294        {
295            if !subscription.managed()
296                && !active.managed()
297                && subscription.client_id != active.client_id
298            {
299                continue;
300            }
301
302            if subscription.is_depth() != active.is_depth() {
303                anyhow::ensure!(
304                    !subscription.managed() || !active.managed(),
305                    "Conflicting managed book source for {instrument_id}: deltas and depth cannot both manage the same book; use managed=false for the other subscription"
306                );
307                continue;
308            }
309
310            let mut active_params = active.command.params().cloned().unwrap_or_default();
311            active_params.shift_remove(PARAMS_IS_PARENT);
312            anyhow::ensure!(
313                subscription.client_id == active.client_id
314                    && subscription.config() == active.config()
315                    && params == active_params,
316                "Conflicting book subscription for {instrument_id}: shared book sources must use the same client, book type, depth, and parameters"
317            );
318        }
319
320        if subscription.managed() {
321            self.setup_book_updater(
322                &subscription.targets,
323                subscription.config().0,
324                subscription.is_depth(),
325            )?;
326        }
327
328        for target_id in &subscription.targets {
329            self.book_subscriptions
330                .entry(*target_id)
331                .or_default()
332                .owners
333                .push(Rc::clone(&subscription));
334        }
335
336        self.book_subscription_owners
337            .entry(instrument_id)
338            .or_default()
339            .push(subscription);
340        Ok(())
341    }
342
343    pub(super) fn release_book_subscription(&mut self, command: &UnsubscribeCommand) -> bool {
344        let key = SubscriptionKey::from_unsubscribe(command);
345
346        let instrument_id = match command {
347            UnsubscribeCommand::BookDeltas(cmd) => cmd.instrument_id,
348            UnsubscribeCommand::BookDepth(cmd) => cmd.instrument_id,
349            UnsubscribeCommand::BookSnapshots(cmd) => cmd.instrument_id,
350            _ => unreachable!("only book subscriptions are released"),
351        };
352
353        let Some(owners) = self.book_subscription_owners.get_mut(&instrument_id) else {
354            return false;
355        };
356
357        let index = owners.iter().position(|subscription| {
358            SubscriptionKey::from_subscribe(&subscription.command) == key
359                && subscription.command.client_id() == command.client_id()
360                && subscription.command.venue() == command.venue()
361                && command
362                    .correlation_id()
363                    .is_none_or(|id| subscription.command.command_id() == id)
364        });
365
366        let Some(index) = index else {
367            return false;
368        };
369
370        let subscription = owners.remove(index);
371        if owners.is_empty() {
372            self.book_subscription_owners.remove(&instrument_id);
373        }
374
375        for &target_id in &subscription.targets {
376            let book = self
377                .book_subscriptions
378                .get_mut(&target_id)
379                .expect("retained book subscription");
380            book.owners
381                .retain(|owner| !Rc::ptr_eq(owner, &subscription));
382            let managed = book.owners.iter().any(|owner| owner.managed());
383            if book.owners.is_empty() {
384                self.book_subscriptions.remove(&target_id);
385            }
386
387            if managed {
388                continue;
389            }
390
391            let Some(updater) = self.book_updaters.remove(&target_id) else {
392                continue;
393            };
394
395            let deltas_handler: TypedHandler<OrderBookDeltas> =
396                TypedHandler::new(Rc::clone(&updater));
397            let depth_handler: TypedHandler<OrderBookDepth> = TypedHandler::new(updater);
398            msgbus::unsubscribe_book_deltas(
399                switchboard::get_book_deltas_topic(target_id).into(),
400                &deltas_handler,
401            );
402            msgbus::unsubscribe_book_depth(
403                switchboard::get_book_depth_topic(target_id).into(),
404                &depth_handler,
405            );
406        }
407
408        true
409    }
410
411    fn has_book_snapshot_subscriptions(&self, instrument_id: &InstrumentId) -> bool {
412        self.book_snapshot_counts
413            .keys()
414            .any(|(id, _)| id == instrument_id)
415    }
416
417    fn has_book_delta_subscription_key(
418        &self,
419        instrument_id: InstrumentId,
420        client_id: Option<ClientId>,
421        venue: Option<Venue>,
422    ) -> bool {
423        self.book_deltas_counts
424            .contains_key(&(instrument_id, client_id, venue))
425    }
426
427    fn has_book_depth_subscription_key(
428        &self,
429        instrument_id: InstrumentId,
430        client_id: Option<ClientId>,
431        venue: Option<Venue>,
432    ) -> bool {
433        self.book_depth_counts
434            .contains_key(&(instrument_id, client_id, venue))
435    }
436
437    fn increment_book_delta_subscription(
438        &mut self,
439        instrument_id: InstrumentId,
440        client_id: Option<ClientId>,
441        venue: Option<Venue>,
442    ) {
443        let key = (instrument_id, client_id, venue);
444
445        if let Some(count) = self.book_deltas_counts.get_mut(&key) {
446            *count += 1;
447        } else {
448            self.book_deltas_counts.insert(key, 1);
449        }
450    }
451
452    fn decrement_book_delta_subscription(
453        &mut self,
454        instrument_id: InstrumentId,
455        client_id: Option<ClientId>,
456        venue: Option<Venue>,
457    ) -> BookDeltasUnsubscribeResult {
458        let key = (instrument_id, client_id, venue);
459
460        let Some(count) = self.book_deltas_counts.get_mut(&key) else {
461            return BookDeltasUnsubscribeResult::NotSubscribed;
462        };
463
464        if *count > 1 {
465            *count -= 1;
466            return BookDeltasUnsubscribeResult::Decremented;
467        }
468
469        self.book_deltas_counts.shift_remove(&key);
470        BookDeltasUnsubscribeResult::Removed
471    }
472
473    fn increment_book_depth_subscription(
474        &mut self,
475        instrument_id: InstrumentId,
476        client_id: Option<ClientId>,
477        venue: Option<Venue>,
478    ) {
479        let key = (instrument_id, client_id, venue);
480        *self.book_depth_counts.entry(key).or_insert(0) += 1;
481    }
482
483    fn decrement_book_depth_subscription(
484        &mut self,
485        instrument_id: InstrumentId,
486        client_id: Option<ClientId>,
487        venue: Option<Venue>,
488    ) -> BookDeltasUnsubscribeResult {
489        let key = (instrument_id, client_id, venue);
490
491        let Some(count) = self.book_depth_counts.get_mut(&key) else {
492            return BookDeltasUnsubscribeResult::NotSubscribed;
493        };
494
495        if *count > 1 {
496            *count -= 1;
497            return BookDeltasUnsubscribeResult::Decremented;
498        }
499
500        self.book_depth_counts.shift_remove(&key);
501        BookDeltasUnsubscribeResult::Removed
502    }
503
504    fn increment_book_snapshot_subscription(
505        &mut self,
506        cmd: &SubscribeBookSnapshots,
507        parent: Option<(Ustr, InstrumentClass)>,
508    ) -> bool {
509        let key = (cmd.instrument_id, cmd.interval_ms);
510
511        if let Some(count) = self.book_snapshot_counts.get_mut(&key) {
512            *count += 1;
513            return false;
514        }
515
516        self.book_snapshot_counts.insert(key, 1);
517
518        let snapshot_infos = if let Some(snapshot_infos) = self.book_intervals.get(&cmd.interval_ms)
519        {
520            Rc::clone(snapshot_infos)
521        } else {
522            let snapshot_infos = Rc::new(RefCell::new(IndexMap::new()));
523            self.book_intervals
524                .insert(cmd.interval_ms, Rc::clone(&snapshot_infos));
525            self.schedule_book_snapshotter(cmd.interval_ms, Rc::clone(&snapshot_infos));
526            snapshot_infos
527        };
528
529        let topic = switchboard::get_book_snapshots_topic(cmd.instrument_id, cmd.interval_ms);
530
531        let snap_info = BookSnapshotInfo {
532            instrument_id: cmd.instrument_id,
533            venue: cmd.instrument_id.venue,
534            parent,
535            topic,
536            interval_ms: cmd.interval_ms,
537        };
538
539        snapshot_infos
540            .borrow_mut()
541            .insert(cmd.instrument_id, snap_info);
542
543        true
544    }
545
546    fn decrement_book_snapshot_subscription(
547        &mut self,
548        instrument_id: InstrumentId,
549        interval_ms: NonZeroUsize,
550    ) -> BookSnapshotUnsubscribeResult {
551        let key = (instrument_id, interval_ms);
552
553        let Some(count) = self.book_snapshot_counts.get_mut(&key) else {
554            return BookSnapshotUnsubscribeResult::NotSubscribed;
555        };
556
557        if *count > 1 {
558            *count -= 1;
559            return BookSnapshotUnsubscribeResult::Decremented;
560        }
561
562        self.book_snapshot_counts.shift_remove(&key);
563
564        let remove_interval = if let Some(snapshot_infos) = self.book_intervals.get(&interval_ms) {
565            let mut snapshot_infos = snapshot_infos.borrow_mut();
566            snapshot_infos.shift_remove(&instrument_id);
567            snapshot_infos.is_empty()
568        } else {
569            false
570        };
571
572        if remove_interval {
573            self.book_intervals.remove(&interval_ms);
574
575            if let Some(snapshotter) = self.book_snapshotters.remove(&interval_ms) {
576                let timer_name = snapshotter.timer_name;
577                let mut clock = self.clock.borrow_mut();
578                if clock.timer_exists(&timer_name) {
579                    clock.cancel_timer(&timer_name);
580                }
581            }
582        }
583
584        BookSnapshotUnsubscribeResult::Removed
585    }
586
587    fn schedule_book_snapshotter(
588        &mut self,
589        interval_ms: NonZeroUsize,
590        snapshot_infos: BookSnapshotInfos,
591    ) {
592        let interval_ms_u64 =
593            u64::try_from(interval_ms.get()).expect("Snapshot interval exceeds u64");
594        let interval_ns = DurationNanos::from_millis(interval_ms_u64);
595        let now_ns = self.clock.borrow().timestamp_ns();
596        let start_time_ns = now_ns
597            .floor(interval_ns)
598            .checked_add(interval_ns)
599            .expect("Book snapshot timer start exceeds UnixNanos range");
600
601        let snapshotter = Rc::new(BookSnapshotter::new(
602            interval_ms,
603            snapshot_infos,
604            Rc::clone(&self.cache),
605        ));
606        let timer_name = snapshotter.timer_name;
607        let snapshotter_callback = Rc::clone(&snapshotter);
608        let callback_fn: Rc<dyn Fn(TimeEvent)> =
609            Rc::new(move |event| snapshotter_callback.snapshot(event));
610        let callback = TimeEventCallback::from(callback_fn);
611
612        self.clock
613            .borrow_mut()
614            .set_timer_ns(
615                &timer_name,
616                interval_ns,
617                Some(start_time_ns),
618                None,
619                Some(callback),
620                None,
621                None,
622            )
623            .expect(FAILED);
624
625        self.book_snapshotters.insert(interval_ms, snapshotter);
626    }
627
628    // Skip cache writes that would regress a book a `BookUpdater` is maintaining.
629    // Unmanaged subscriptions don't install a `BookUpdater`, so they don't gate writes.
630    fn cache_is_owned_by_live_subscription(&self, instrument_id: &InstrumentId) -> bool {
631        self.book_updaters.contains_key(instrument_id)
632    }
633
634    pub(super) fn handle_book_response(&self, book: &OrderBook) {
635        if self.cache_is_owned_by_live_subscription(&book.instrument_id) {
636            log::debug!(
637                "Skipping cache write for order book {}: live subscription owns the book",
638                book.instrument_id,
639            );
640            return;
641        }
642
643        log::debug!("Adding order book {} to cache", book.instrument_id);
644
645        if let Err(e) = self
646            .cache
647            .as_ref()
648            .borrow_mut()
649            .add_order_book(book.clone())
650        {
651            log_error_on_cache_insert(&e);
652        }
653    }
654
655    pub(super) fn handle_book_deltas_response(&self, resp: &BookDeltasResponse) {
656        if !self.cache_is_owned_by_live_subscription(&resp.instrument_id) {
657            let mut cache = self.cache.as_ref().borrow_mut();
658            if let Some(book) = cache.order_book_mut(&resp.instrument_id) {
659                for delta in &resp.data {
660                    if let Err(e) = book.apply_delta(delta) {
661                        log::error!("Failed to apply historical delta to cache: {e}");
662                    }
663                }
664            } else {
665                log::debug!(
666                    "Skipping cache write for {} historical deltas on {}: no cache book yet",
667                    resp.data.len(),
668                    resp.instrument_id,
669                );
670            }
671        }
672
673        // Group deltas by `F_LAST` so each published batch preserves the original event
674        // boundary and metadata (timestamps and sequence from the closing delta), matching
675        // the live `handle_delta` buffering semantic. Collapsing the whole response into
676        // one batch would surface a synthetic event with the trailing delta's flags only.
677        if resp.data.is_empty() {
678            return;
679        }
680
681        let topic = switchboard::get_pipeline_book_deltas_topic(resp.instrument_id);
682        let mut frame: Vec<OrderBookDelta> = Vec::new();
683
684        for delta in &resp.data {
685            frame.push(*delta);
686            if RecordFlag::F_LAST.matches(delta.flags) {
687                let batch = OrderBookDeltas::new(resp.instrument_id, std::mem::take(&mut frame));
688                msgbus::publish_deltas(topic, &batch);
689            }
690        }
691
692        if !frame.is_empty() {
693            let batch = OrderBookDeltas::new(resp.instrument_id, frame);
694            msgbus::publish_deltas(topic, &batch);
695        }
696    }
697
698    pub(super) fn handle_book_depth_response(&self, resp: &BookDepthResponse) {
699        let topic = switchboard::get_pipeline_book_depth_topic(resp.instrument_id);
700
701        for depth in &resp.data {
702            msgbus::publish_depth(topic, depth);
703        }
704    }
705
706    fn setup_book_updater(
707        &mut self,
708        target_ids: &[InstrumentId],
709        book_type: BookType,
710        depth: bool,
711    ) -> anyhow::Result<()> {
712        {
713            let mut cache = self.cache.borrow_mut();
714
715            for target_id in target_ids {
716                if !cache.has_order_book(target_id) {
717                    cache.add_order_book(OrderBook::new(*target_id, book_type))?;
718                }
719            }
720        }
721
722        for target_id in target_ids {
723            let updater = Rc::clone(self.book_updaters.entry(*target_id).or_insert_with(|| {
724                Rc::new(BookUpdater::new(
725                    target_id,
726                    Rc::clone(&self.cache),
727                    self.config.emit_quotes_from_book,
728                ))
729            }));
730
731            if depth {
732                msgbus::subscribe_book_depth(
733                    switchboard::get_book_depth_topic(*target_id).into(),
734                    TypedHandler::new(updater),
735                    Some(self.msgbus_priority),
736                );
737            } else {
738                msgbus::subscribe_book_deltas(
739                    switchboard::get_book_deltas_topic(*target_id).into(),
740                    TypedHandler::new(updater),
741                    Some(self.msgbus_priority),
742                );
743            }
744        }
745
746        Ok(())
747    }
748}
749
750#[derive(Debug, Default)]
751pub(super) struct BookSubscription {
752    pub(super) owners: Vec<Rc<BookSubscriptionOwner>>,
753}
754
755#[derive(Debug)]
756pub(super) struct BookSubscriptionOwner {
757    pub(super) command: SubscribeCommand,
758    pub(super) client_id: Option<ClientId>,
759    pub(super) targets: Vec<InstrumentId>,
760}
761
762impl BookSubscriptionOwner {
763    pub(super) fn managed(&self) -> bool {
764        match &self.command {
765            SubscribeCommand::BookDeltas(cmd) => cmd.managed,
766            SubscribeCommand::BookDepth(cmd) => cmd.managed,
767            SubscribeCommand::BookSnapshots(_) => true,
768            _ => unreachable!("only book subscriptions are retained"),
769        }
770    }
771
772    pub(super) fn is_depth(&self) -> bool {
773        matches!(self.command, SubscribeCommand::BookDepth(_))
774    }
775
776    pub(super) fn config(&self) -> (BookType, Option<NonZeroUsize>) {
777        match &self.command {
778            SubscribeCommand::BookDeltas(cmd) => (cmd.book_type, cmd.depth),
779            SubscribeCommand::BookDepth(cmd) => (cmd.book_type, cmd.depth),
780            SubscribeCommand::BookSnapshots(cmd) => (cmd.book_type, cmd.depth),
781            _ => unreachable!("only book subscriptions are retained"),
782        }
783    }
784}
785
786#[derive(Clone, Debug)]
787pub(super) struct BookSnapshotSource {
788    pub(super) command: SubscribeBookSnapshots,
789    pub(super) client_command: SubscribeCommand,
790}
791
792/// Contains information for creating snapshots of specific order books.
793#[derive(Clone, Debug)]
794pub struct BookSnapshotInfo {
795    pub instrument_id: InstrumentId,
796    pub venue: Venue,
797    /// Parent expansion components `(root, class)` when this snapshot subscription
798    /// targets a parent symbol. `None` for concrete (exact-instrument) subscriptions.
799    pub parent: Option<(Ustr, InstrumentClass)>,
800    pub topic: MStr<Topic>,
801    pub interval_ms: NonZeroUsize,
802}
803
804/// Reference-counted map of per-instrument book snapshot descriptors.
805///
806/// Shared between the engine (which populates it on subscribe) and the
807/// [`BookSnapshotter`] timer callback (which iterates it on each tick).
808pub(crate) type BookSnapshotInfos = Rc<RefCell<IndexMap<InstrumentId, BookSnapshotInfo>>>;
809
810/// Reference count key for a book snapshot subscription.
811pub(crate) type BookSnapshotKey = (InstrumentId, NonZeroUsize);
812
813/// Outcome of decrementing a book snapshot subscription.
814pub(crate) enum BookSnapshotUnsubscribeResult {
815    /// No matching subscription was found.
816    NotSubscribed,
817    /// The reference count was decremented but other consumers remain.
818    Decremented,
819    /// The last consumer was removed; tear down associated state.
820    Removed,
821}
822
823/// Reference count key for a book deltas subscription.
824pub(crate) type BookDeltasKey = (InstrumentId, Option<ClientId>, Option<Venue>);
825
826/// Outcome of decrementing a book deltas subscription.
827pub(crate) enum BookDeltasUnsubscribeResult {
828    /// No matching subscription was found.
829    NotSubscribed,
830    /// The reference count was decremented but other consumers remain.
831    Decremented,
832    /// The last consumer was removed; tear down associated state.
833    Removed,
834}
835
836/// Handles order book updates and delta processing for a specific instrument.
837///
838/// The `BookUpdater` processes incoming order book deltas and maintains
839/// the current state of an order book. It can handle both incremental
840/// updates and full snapshots for the instrument it's assigned to.
841#[derive(Debug)]
842pub struct BookUpdater {
843    pub id: Ustr,
844    pub instrument_id: InstrumentId,
845    pub cache: Rc<RefCell<Cache>>,
846    pub emit_quotes_from_book: bool,
847}
848
849impl BookUpdater {
850    /// Creates a new [`BookUpdater`] instance.
851    pub fn new(
852        instrument_id: &InstrumentId,
853        cache: Rc<RefCell<Cache>>,
854        emit_quotes_from_book: bool,
855    ) -> Self {
856        Self {
857            id: Ustr::from(&format!("{}-{}", stringify!(BookUpdater), instrument_id)),
858            instrument_id: *instrument_id,
859            cache,
860            emit_quotes_from_book,
861        }
862    }
863}
864
865impl Handler<OrderBookDeltas> for BookUpdater {
866    fn id(&self) -> Ustr {
867        self.id
868    }
869
870    fn handle(&self, deltas: &OrderBookDeltas) {
871        let mut emit: Option<QuoteTick> = None;
872        {
873            let mut cache = self.cache.borrow_mut();
874            if let Some(book) = cache.order_book_mut(&deltas.instrument_id) {
875                if let Err(e) = book.apply_deltas(deltas) {
876                    log::error!("Failed to apply deltas: {e}");
877                    return;
878                }
879
880                if self.emit_quotes_from_book {
881                    emit = derive_quote_from_book(book);
882                }
883            }
884        }
885
886        if let Some(quote) = emit {
887            publish_quote_if_changed(&self.cache, quote);
888        }
889    }
890}
891
892impl Handler<OrderBookDepth> for BookUpdater {
893    fn id(&self) -> Ustr {
894        self.id
895    }
896
897    fn handle(&self, depth: &OrderBookDepth) {
898        let mut emit: Option<QuoteTick> = None;
899        {
900            let mut cache = self.cache.borrow_mut();
901            if let Some(book) = cache.order_book_mut(&depth.instrument_id) {
902                if let Err(e) = book.apply_depth(depth) {
903                    log::error!("Failed to apply depth: {e}");
904                    return;
905                }
906
907                if self.emit_quotes_from_book {
908                    emit = derive_quote_from_book(book);
909                }
910            }
911        }
912
913        if let Some(quote) = emit {
914            publish_quote_if_changed(&self.cache, quote);
915        }
916    }
917}
918
919fn derive_quote_from_book(book: &OrderBook) -> Option<QuoteTick> {
920    let bid_price = book.best_bid_price()?;
921    let ask_price = book.best_ask_price()?;
922    let bid_size = book.best_bid_size()?;
923    let ask_size = book.best_ask_size()?;
924
925    if bid_size.is_zero() || ask_size.is_zero() {
926        return None;
927    }
928
929    Some(QuoteTick::new(
930        book.instrument_id,
931        bid_price,
932        ask_price,
933        bid_size,
934        ask_size,
935        book.ts_last,
936        book.ts_last,
937    ))
938}
939
940/// Publishes the derived `QuoteTick` if top-of-book changed.
941///
942/// Writes to cache and republishes only when bid/ask price or size differs
943/// from the cached quote.
944pub(crate) fn publish_quote_if_changed(cache: &Rc<RefCell<Cache>>, quote: QuoteTick) {
945    let publish = {
946        let cache_ref = cache.borrow();
947        match cache_ref.quote(&quote.instrument_id) {
948            None => true,
949            Some(last) => {
950                last.bid_price != quote.bid_price
951                    || last.ask_price != quote.ask_price
952                    || last.bid_size != quote.bid_size
953                    || last.ask_size != quote.ask_size
954            }
955        }
956    };
957
958    if !publish {
959        return;
960    }
961
962    if let Err(e) = cache.borrow_mut().add_quote(quote) {
963        log::error!("Error on cache insert: {e}");
964    }
965
966    let topic = switchboard::get_quotes_topic(quote.instrument_id);
967    msgbus::publish_quote(topic, &quote);
968}
969
970/// Creates periodic snapshots of order books at configured intervals.
971///
972/// The `BookSnapshotter` generates order book snapshots on timer events,
973/// publishing them as market data. This is useful for providing periodic
974/// full order book state updates in addition to incremental delta updates.
975#[derive(Debug)]
976pub struct BookSnapshotter {
977    pub timer_name: Ustr,
978    pub interval_ms: NonZeroUsize,
979    pub snapshot_infos: Rc<RefCell<IndexMap<InstrumentId, BookSnapshotInfo>>>,
980    pub cache: Rc<RefCell<Cache>>,
981}
982
983impl BookSnapshotter {
984    /// Creates a new [`BookSnapshotter`] instance.
985    pub fn new(
986        interval_ms: NonZeroUsize,
987        snapshot_infos: Rc<RefCell<IndexMap<InstrumentId, BookSnapshotInfo>>>,
988        cache: Rc<RefCell<Cache>>,
989    ) -> Self {
990        let timer_name = format!("OrderBookSnapshots|{interval_ms}");
991
992        Self {
993            timer_name: Ustr::from(&timer_name),
994            interval_ms,
995            snapshot_infos,
996            cache,
997        }
998    }
999
1000    /// Publishes a snapshot for each subscribed book.
1001    ///
1002    /// Books are cloned out of the cache inside a scoped borrow before publishing,
1003    /// so subscribers can mutably borrow the cache (e.g. a strategy submitting an
1004    /// order from `on_book`).
1005    pub fn snapshot(&self, _event: TimeEvent) {
1006        let snapshot_infos: Vec<BookSnapshotInfo> =
1007            self.snapshot_infos.borrow().values().cloned().collect();
1008
1009        log::debug!(
1010            "BookSnapshotter.snapshot called for {} subscriptions at {}ms",
1011            snapshot_infos.len(),
1012            self.interval_ms,
1013        );
1014
1015        let books: Vec<(MStr<Topic>, OrderBook)> = {
1016            let cache = self.cache.borrow();
1017            let mut books = Vec::new();
1018
1019            for snap_info in &snapshot_infos {
1020                self.collect_snapshot(snap_info, &cache, &mut books);
1021            }
1022
1023            books
1024        };
1025
1026        for (topic, book) in books {
1027            msgbus::publish_book(topic, &book);
1028        }
1029    }
1030
1031    fn collect_snapshot(
1032        &self,
1033        snap_info: &BookSnapshotInfo,
1034        cache: &Cache,
1035        books: &mut Vec<(MStr<Topic>, OrderBook)>,
1036    ) {
1037        if let Some((root, class)) = snap_info.parent {
1038            let topic = snap_info.topic;
1039            for instrument in cache.instruments_by_parent(&snap_info.venue, &root, class) {
1040                self.collect_order_book(&instrument.id(), topic, cache, books);
1041            }
1042        } else {
1043            self.collect_order_book(&snap_info.instrument_id, snap_info.topic, cache, books);
1044        }
1045    }
1046
1047    fn collect_order_book(
1048        &self,
1049        instrument_id: &InstrumentId,
1050        topic: MStr<Topic>,
1051        cache: &Cache,
1052        books: &mut Vec<(MStr<Topic>, OrderBook)>,
1053    ) {
1054        let book = match cache.try_order_book(instrument_id) {
1055            Ok(book) => book,
1056            Err(e) => {
1057                log::error!("Cannot publish OrderBook snapshot: {e}");
1058                return;
1059            }
1060        };
1061
1062        if book.update_count == 0 {
1063            log::debug!("OrderBook not yet updated for snapshot: {instrument_id}");
1064            return;
1065        }
1066        log::debug!(
1067            "Publishing OrderBook snapshot for {instrument_id} (update_count={})",
1068            book.update_count
1069        );
1070
1071        books.push((topic, book.clone()));
1072    }
1073}
1074
1075// Resolves parent expansion components for a book subscription command.
1076//
1077// Returns Ok(Some((root, class))) when params carries PARAMS_IS_PARENT=true and
1078// the instrument_id parses as a recognized <root>.<class> shape; Ok(None) for
1079// concrete (non-parent) subscriptions; Err when the caller asserts a parent
1080// subscription but the id cannot be parsed, so subscribe entries can reject up
1081// front before touching state.
1082fn resolve_parent_components(
1083    instrument_id: &InstrumentId,
1084    params: Option<&Params>,
1085) -> anyhow::Result<Option<(Ustr, InstrumentClass)>> {
1086    if !is_parent_subscription(params) {
1087        return Ok(None);
1088    }
1089
1090    let Some((root, class)) = instrument_id.parse_parent_components() else {
1091        anyhow::bail!(
1092            "Cannot expand parent subscription for {instrument_id}: \
1093             symbol does not parse as `<root>.<class>` with a recognized class suffix"
1094        );
1095    };
1096
1097    Ok(Some((Ustr::from(root), class)))
1098}
1099
1100#[cfg(test)]
1101mod tests {
1102    use nautilus_common::msgbus::TypedHandler;
1103    use nautilus_core::{UUID4, UnixNanos};
1104    use nautilus_model::{
1105        data::BookOrder,
1106        enums::{BookType, OrderSide},
1107        types::{Price, Quantity},
1108    };
1109    use rstest::rstest;
1110
1111    use super::*;
1112
1113    #[rstest]
1114    fn snapshot_skips_missing_order_book() {
1115        let instrument_id = InstrumentId::from("AUD/USD.SIM");
1116        let interval_ms = NonZeroUsize::new(100).unwrap();
1117        let topic = switchboard::get_book_snapshots_topic(instrument_id, interval_ms);
1118        let snapshot_infos = Rc::new(RefCell::new(IndexMap::new()));
1119
1120        snapshot_infos.borrow_mut().insert(
1121            instrument_id,
1122            BookSnapshotInfo {
1123                instrument_id,
1124                venue: Venue::new("SIM"),
1125                parent: None,
1126                topic,
1127                interval_ms,
1128            },
1129        );
1130
1131        let snapshotter = BookSnapshotter::new(
1132            interval_ms,
1133            snapshot_infos,
1134            Rc::new(RefCell::new(Cache::default())),
1135        );
1136        let event = TimeEvent::new(
1137            Ustr::from("TEST"),
1138            UUID4::new(),
1139            UnixNanos::default(),
1140            UnixNanos::default(),
1141        );
1142
1143        snapshotter.snapshot(event);
1144    }
1145
1146    #[rstest]
1147    fn snapshot_allows_subscriber_to_mutably_borrow_cache() {
1148        let instrument_id = InstrumentId::from("AUD/USD.SIM");
1149        let interval_ms = NonZeroUsize::new(100).unwrap();
1150        let topic = switchboard::get_book_snapshots_topic(instrument_id, interval_ms);
1151        let snapshot_infos = Rc::new(RefCell::new(IndexMap::new()));
1152
1153        snapshot_infos.borrow_mut().insert(
1154            instrument_id,
1155            BookSnapshotInfo {
1156                instrument_id,
1157                venue: Venue::new("SIM"),
1158                parent: None,
1159                topic,
1160                interval_ms,
1161            },
1162        );
1163
1164        let cache = Rc::new(RefCell::new(Cache::default()));
1165        let mut book = OrderBook::new(instrument_id, BookType::L2_MBP);
1166        book.add(
1167            BookOrder::new(OrderSide::Buy, Price::from("100.00"), Quantity::from(10), 0),
1168            0,
1169            1,
1170            UnixNanos::default(),
1171        );
1172        cache.borrow_mut().add_order_book(book).unwrap();
1173
1174        let received = Rc::new(RefCell::new(Vec::new()));
1175        let handler = CacheWritingBookHandler {
1176            id: Ustr::from("CacheWritingBookHandler"),
1177            cache: Rc::clone(&cache),
1178            received: Rc::clone(&received),
1179        };
1180        msgbus::subscribe_book_snapshots(topic.into(), TypedHandler::new(handler), None);
1181
1182        let snapshotter = BookSnapshotter::new(interval_ms, snapshot_infos, cache);
1183        let event = TimeEvent::new(
1184            Ustr::from("TEST"),
1185            UUID4::new(),
1186            UnixNanos::default(),
1187            UnixNanos::default(),
1188        );
1189
1190        snapshotter.snapshot(event);
1191
1192        let received = received.borrow();
1193        assert_eq!(received.len(), 1);
1194        assert_eq!(received[0].instrument_id, instrument_id);
1195        assert_eq!(received[0].best_bid_price(), Some(Price::from("100.00")));
1196    }
1197
1198    #[rstest]
1199    fn snapshot_skips_book_with_no_updates() {
1200        let instrument_id = InstrumentId::from("AUD/USD.SIM");
1201        let interval_ms = NonZeroUsize::new(100).unwrap();
1202        let topic = switchboard::get_book_snapshots_topic(instrument_id, interval_ms);
1203        let snapshot_infos = Rc::new(RefCell::new(IndexMap::new()));
1204
1205        snapshot_infos.borrow_mut().insert(
1206            instrument_id,
1207            BookSnapshotInfo {
1208                instrument_id,
1209                venue: Venue::new("SIM"),
1210                parent: None,
1211                topic,
1212                interval_ms,
1213            },
1214        );
1215
1216        let cache = Rc::new(RefCell::new(Cache::default()));
1217        cache
1218            .borrow_mut()
1219            .add_order_book(OrderBook::new(instrument_id, BookType::L2_MBP))
1220            .unwrap();
1221
1222        let received = Rc::new(RefCell::new(Vec::new()));
1223        let handler = CacheWritingBookHandler {
1224            id: Ustr::from("CacheWritingBookHandler-NoUpdates"),
1225            cache: Rc::clone(&cache),
1226            received: Rc::clone(&received),
1227        };
1228        msgbus::subscribe_book_snapshots(topic.into(), TypedHandler::new(handler), None);
1229
1230        let snapshotter = BookSnapshotter::new(interval_ms, snapshot_infos, cache);
1231        let event = TimeEvent::new(
1232            Ustr::from("TEST"),
1233            UUID4::new(),
1234            UnixNanos::default(),
1235            UnixNanos::default(),
1236        );
1237
1238        snapshotter.snapshot(event);
1239
1240        assert!(received.borrow().is_empty());
1241    }
1242
1243    struct CacheWritingBookHandler {
1244        id: Ustr,
1245        cache: Rc<RefCell<Cache>>,
1246        received: Rc<RefCell<Vec<OrderBook>>>,
1247    }
1248
1249    impl Handler<OrderBook> for CacheWritingBookHandler {
1250        fn id(&self) -> Ustr {
1251            self.id
1252        }
1253
1254        fn handle(&self, book: &OrderBook) {
1255            // Mirrors a strategy writing to the cache from `on_book`
1256            let mut cache = self.cache.borrow_mut();
1257            let _ = cache.order_book_mut(&book.instrument_id);
1258            self.received.borrow_mut().push(book.clone());
1259        }
1260    }
1261}