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