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
34#[derive(Debug, Default)]
35pub(super) struct BookSubscription {
36    pub(super) owners: Vec<Rc<BookSubscriptionOwner>>,
37}
38
39#[derive(Debug)]
40pub(super) struct BookSubscriptionOwner {
41    pub(super) command: SubscribeCommand,
42    pub(super) client_id: Option<ClientId>,
43    pub(super) targets: Vec<InstrumentId>,
44}
45
46impl BookSubscriptionOwner {
47    pub(super) fn managed(&self) -> bool {
48        match &self.command {
49            SubscribeCommand::BookDeltas(cmd) => cmd.managed,
50            SubscribeCommand::BookDepth(cmd) => cmd.managed,
51            SubscribeCommand::BookSnapshots(_) => true,
52            _ => unreachable!("only book subscriptions are retained"),
53        }
54    }
55
56    pub(super) fn is_depth(&self) -> bool {
57        matches!(self.command, SubscribeCommand::BookDepth(_))
58    }
59
60    pub(super) fn config(&self) -> (BookType, Option<NonZeroUsize>) {
61        match &self.command {
62            SubscribeCommand::BookDeltas(cmd) => (cmd.book_type, cmd.depth),
63            SubscribeCommand::BookDepth(cmd) => (cmd.book_type, cmd.depth),
64            SubscribeCommand::BookSnapshots(cmd) => (cmd.book_type, cmd.depth),
65            _ => unreachable!("only book subscriptions are retained"),
66        }
67    }
68}
69
70#[derive(Clone, Debug)]
71pub(super) struct BookSnapshotSource {
72    pub(super) command: SubscribeBookSnapshots,
73    pub(super) client_command: SubscribeCommand,
74}
75
76/// Contains information for creating snapshots of specific order books.
77#[derive(Clone, Debug)]
78pub struct BookSnapshotInfo {
79    pub instrument_id: InstrumentId,
80    pub venue: Venue,
81    /// Parent expansion components `(root, class)` when this snapshot subscription
82    /// targets a parent symbol. `None` for concrete (exact-instrument) subscriptions.
83    pub parent: Option<(Ustr, InstrumentClass)>,
84    pub topic: MStr<Topic>,
85    pub interval_ms: NonZeroUsize,
86}
87
88/// Reference-counted map of per-instrument book snapshot descriptors.
89///
90/// Shared between the engine (which populates it on subscribe) and the
91/// [`BookSnapshotter`] timer callback (which iterates it on each tick).
92pub(crate) type BookSnapshotInfos = Rc<RefCell<IndexMap<InstrumentId, BookSnapshotInfo>>>;
93
94/// Reference count key for a book snapshot subscription.
95pub(crate) type BookSnapshotKey = (InstrumentId, NonZeroUsize);
96
97/// Outcome of decrementing a book snapshot subscription.
98pub(crate) enum BookSnapshotUnsubscribeResult {
99    /// No matching subscription was found.
100    NotSubscribed,
101    /// The reference count was decremented but other consumers remain.
102    Decremented,
103    /// The last consumer was removed; tear down associated state.
104    Removed,
105}
106
107/// Reference count key for a book deltas subscription.
108pub(crate) type BookDeltasKey = (InstrumentId, Option<ClientId>, Option<Venue>);
109
110/// Outcome of decrementing a book deltas subscription.
111pub(crate) enum BookDeltasUnsubscribeResult {
112    /// No matching subscription was found.
113    NotSubscribed,
114    /// The reference count was decremented but other consumers remain.
115    Decremented,
116    /// The last consumer was removed; tear down associated state.
117    Removed,
118}
119
120/// Handles order book updates and delta processing for a specific instrument.
121///
122/// The `BookUpdater` processes incoming order book deltas and maintains
123/// the current state of an order book. It can handle both incremental
124/// updates and full snapshots for the instrument it's assigned to.
125#[derive(Debug)]
126pub struct BookUpdater {
127    pub id: Ustr,
128    pub instrument_id: InstrumentId,
129    pub cache: Rc<RefCell<Cache>>,
130    pub emit_quotes_from_book: bool,
131}
132
133impl BookUpdater {
134    /// Creates a new [`BookUpdater`] instance.
135    pub fn new(
136        instrument_id: &InstrumentId,
137        cache: Rc<RefCell<Cache>>,
138        emit_quotes_from_book: bool,
139    ) -> Self {
140        Self {
141            id: Ustr::from(&format!("{}-{}", stringify!(BookUpdater), instrument_id)),
142            instrument_id: *instrument_id,
143            cache,
144            emit_quotes_from_book,
145        }
146    }
147}
148
149impl Handler<OrderBookDeltas> for BookUpdater {
150    fn id(&self) -> Ustr {
151        self.id
152    }
153
154    fn handle(&self, deltas: &OrderBookDeltas) {
155        let mut emit: Option<QuoteTick> = None;
156        {
157            let mut cache = self.cache.borrow_mut();
158            if let Some(book) = cache.order_book_mut(&deltas.instrument_id) {
159                if let Err(e) = book.apply_deltas(deltas) {
160                    log::error!("Failed to apply deltas: {e}");
161                    return;
162                }
163
164                if self.emit_quotes_from_book {
165                    emit = derive_quote_from_book(book);
166                }
167            }
168        }
169
170        if let Some(quote) = emit {
171            publish_quote_if_changed(&self.cache, quote);
172        }
173    }
174}
175
176impl Handler<OrderBookDepth> for BookUpdater {
177    fn id(&self) -> Ustr {
178        self.id
179    }
180
181    fn handle(&self, depth: &OrderBookDepth) {
182        let mut emit: Option<QuoteTick> = None;
183        {
184            let mut cache = self.cache.borrow_mut();
185            if let Some(book) = cache.order_book_mut(&depth.instrument_id) {
186                if let Err(e) = book.apply_depth(depth) {
187                    log::error!("Failed to apply depth: {e}");
188                    return;
189                }
190
191                if self.emit_quotes_from_book {
192                    emit = derive_quote_from_book(book);
193                }
194            }
195        }
196
197        if let Some(quote) = emit {
198            publish_quote_if_changed(&self.cache, quote);
199        }
200    }
201}
202
203fn derive_quote_from_book(book: &OrderBook) -> Option<QuoteTick> {
204    let bid_price = book.best_bid_price()?;
205    let ask_price = book.best_ask_price()?;
206    let bid_size = book.best_bid_size()?;
207    let ask_size = book.best_ask_size()?;
208
209    if bid_size.is_zero() || ask_size.is_zero() {
210        return None;
211    }
212
213    Some(QuoteTick::new(
214        book.instrument_id,
215        bid_price,
216        ask_price,
217        bid_size,
218        ask_size,
219        book.ts_last,
220        book.ts_last,
221    ))
222}
223
224/// Publishes the derived `QuoteTick` if top-of-book changed.
225///
226/// Writes to cache and republishes only when bid/ask price or size differs
227/// from the cached quote.
228pub(crate) fn publish_quote_if_changed(cache: &Rc<RefCell<Cache>>, quote: QuoteTick) {
229    let publish = {
230        let cache_ref = cache.borrow();
231        match cache_ref.quote(&quote.instrument_id) {
232            None => true,
233            Some(last) => {
234                last.bid_price != quote.bid_price
235                    || last.ask_price != quote.ask_price
236                    || last.bid_size != quote.bid_size
237                    || last.ask_size != quote.ask_size
238            }
239        }
240    };
241
242    if !publish {
243        return;
244    }
245
246    if let Err(e) = cache.borrow_mut().add_quote(quote) {
247        log::error!("Error on cache insert: {e}");
248    }
249
250    let topic = switchboard::get_quotes_topic(quote.instrument_id);
251    msgbus::publish_quote(topic, &quote);
252}
253
254/// Creates periodic snapshots of order books at configured intervals.
255///
256/// The `BookSnapshotter` generates order book snapshots on timer events,
257/// publishing them as market data. This is useful for providing periodic
258/// full order book state updates in addition to incremental delta updates.
259#[derive(Debug)]
260pub struct BookSnapshotter {
261    pub timer_name: Ustr,
262    pub interval_ms: NonZeroUsize,
263    pub snapshot_infos: Rc<RefCell<IndexMap<InstrumentId, BookSnapshotInfo>>>,
264    pub cache: Rc<RefCell<Cache>>,
265}
266
267impl BookSnapshotter {
268    /// Creates a new [`BookSnapshotter`] instance.
269    pub fn new(
270        interval_ms: NonZeroUsize,
271        snapshot_infos: Rc<RefCell<IndexMap<InstrumentId, BookSnapshotInfo>>>,
272        cache: Rc<RefCell<Cache>>,
273    ) -> Self {
274        let timer_name = format!("OrderBookSnapshots|{interval_ms}");
275
276        Self {
277            timer_name: Ustr::from(&timer_name),
278            interval_ms,
279            snapshot_infos,
280            cache,
281        }
282    }
283
284    /// Publishes a snapshot for each subscribed book.
285    ///
286    /// Books are cloned out of the cache inside a scoped borrow before publishing,
287    /// so subscribers can mutably borrow the cache (e.g. a strategy submitting an
288    /// order from `on_book`).
289    pub fn snapshot(&self, _event: TimeEvent) {
290        let snapshot_infos: Vec<BookSnapshotInfo> =
291            self.snapshot_infos.borrow().values().cloned().collect();
292
293        log::debug!(
294            "BookSnapshotter.snapshot called for {} subscriptions at {}ms",
295            snapshot_infos.len(),
296            self.interval_ms,
297        );
298
299        let books: Vec<(MStr<Topic>, OrderBook)> = {
300            let cache = self.cache.borrow();
301            let mut books = Vec::new();
302
303            for snap_info in &snapshot_infos {
304                self.collect_snapshot(snap_info, &cache, &mut books);
305            }
306
307            books
308        };
309
310        for (topic, book) in books {
311            msgbus::publish_book(topic, &book);
312        }
313    }
314
315    fn collect_snapshot(
316        &self,
317        snap_info: &BookSnapshotInfo,
318        cache: &Cache,
319        books: &mut Vec<(MStr<Topic>, OrderBook)>,
320    ) {
321        if let Some((root, class)) = snap_info.parent {
322            let topic = snap_info.topic;
323            for instrument in cache.instruments_by_parent(&snap_info.venue, &root, class) {
324                self.collect_order_book(&instrument.id(), topic, cache, books);
325            }
326        } else {
327            self.collect_order_book(&snap_info.instrument_id, snap_info.topic, cache, books);
328        }
329    }
330
331    fn collect_order_book(
332        &self,
333        instrument_id: &InstrumentId,
334        topic: MStr<Topic>,
335        cache: &Cache,
336        books: &mut Vec<(MStr<Topic>, OrderBook)>,
337    ) {
338        let book = match cache.try_order_book(instrument_id) {
339            Ok(book) => book,
340            Err(e) => {
341                log::error!("Cannot publish OrderBook snapshot: {e}");
342                return;
343            }
344        };
345
346        if book.update_count == 0 {
347            log::debug!("OrderBook not yet updated for snapshot: {instrument_id}");
348            return;
349        }
350        log::debug!(
351            "Publishing OrderBook snapshot for {instrument_id} (update_count={})",
352            book.update_count
353        );
354
355        books.push((topic, book.clone()));
356    }
357}
358
359#[cfg(test)]
360mod tests {
361    use nautilus_common::msgbus::TypedHandler;
362    use nautilus_core::{UUID4, UnixNanos};
363    use nautilus_model::{
364        data::BookOrder,
365        enums::{BookType, OrderSide},
366        types::{Price, Quantity},
367    };
368    use rstest::rstest;
369
370    use super::*;
371
372    #[rstest]
373    fn snapshot_skips_missing_order_book() {
374        let instrument_id = InstrumentId::from("AUD/USD.SIM");
375        let interval_ms = NonZeroUsize::new(100).unwrap();
376        let topic = switchboard::get_book_snapshots_topic(instrument_id, interval_ms);
377        let snapshot_infos = Rc::new(RefCell::new(IndexMap::new()));
378
379        snapshot_infos.borrow_mut().insert(
380            instrument_id,
381            BookSnapshotInfo {
382                instrument_id,
383                venue: Venue::new("SIM"),
384                parent: None,
385                topic,
386                interval_ms,
387            },
388        );
389
390        let snapshotter = BookSnapshotter::new(
391            interval_ms,
392            snapshot_infos,
393            Rc::new(RefCell::new(Cache::default())),
394        );
395        let event = TimeEvent::new(
396            Ustr::from("TEST"),
397            UUID4::new(),
398            UnixNanos::default(),
399            UnixNanos::default(),
400        );
401
402        snapshotter.snapshot(event);
403    }
404
405    #[rstest]
406    fn snapshot_allows_subscriber_to_mutably_borrow_cache() {
407        let instrument_id = InstrumentId::from("AUD/USD.SIM");
408        let interval_ms = NonZeroUsize::new(100).unwrap();
409        let topic = switchboard::get_book_snapshots_topic(instrument_id, interval_ms);
410        let snapshot_infos = Rc::new(RefCell::new(IndexMap::new()));
411
412        snapshot_infos.borrow_mut().insert(
413            instrument_id,
414            BookSnapshotInfo {
415                instrument_id,
416                venue: Venue::new("SIM"),
417                parent: None,
418                topic,
419                interval_ms,
420            },
421        );
422
423        let cache = Rc::new(RefCell::new(Cache::default()));
424        let mut book = OrderBook::new(instrument_id, BookType::L2_MBP);
425        book.add(
426            BookOrder::new(OrderSide::Buy, Price::from("100.00"), Quantity::from(10), 0),
427            0,
428            1,
429            UnixNanos::default(),
430        );
431        cache.borrow_mut().add_order_book(book).unwrap();
432
433        let received = Rc::new(RefCell::new(Vec::new()));
434        let handler = CacheWritingBookHandler {
435            id: Ustr::from("CacheWritingBookHandler"),
436            cache: cache.clone(),
437            received: received.clone(),
438        };
439        msgbus::subscribe_book_snapshots(topic.into(), TypedHandler::new(handler), None);
440
441        let snapshotter = BookSnapshotter::new(interval_ms, snapshot_infos, cache);
442        let event = TimeEvent::new(
443            Ustr::from("TEST"),
444            UUID4::new(),
445            UnixNanos::default(),
446            UnixNanos::default(),
447        );
448
449        snapshotter.snapshot(event);
450
451        let received = received.borrow();
452        assert_eq!(received.len(), 1);
453        assert_eq!(received[0].instrument_id, instrument_id);
454        assert_eq!(received[0].best_bid_price(), Some(Price::from("100.00")));
455    }
456
457    #[rstest]
458    fn snapshot_skips_book_with_no_updates() {
459        let instrument_id = InstrumentId::from("AUD/USD.SIM");
460        let interval_ms = NonZeroUsize::new(100).unwrap();
461        let topic = switchboard::get_book_snapshots_topic(instrument_id, interval_ms);
462        let snapshot_infos = Rc::new(RefCell::new(IndexMap::new()));
463
464        snapshot_infos.borrow_mut().insert(
465            instrument_id,
466            BookSnapshotInfo {
467                instrument_id,
468                venue: Venue::new("SIM"),
469                parent: None,
470                topic,
471                interval_ms,
472            },
473        );
474
475        let cache = Rc::new(RefCell::new(Cache::default()));
476        cache
477            .borrow_mut()
478            .add_order_book(OrderBook::new(instrument_id, BookType::L2_MBP))
479            .unwrap();
480
481        let received = Rc::new(RefCell::new(Vec::new()));
482        let handler = CacheWritingBookHandler {
483            id: Ustr::from("CacheWritingBookHandler-NoUpdates"),
484            cache: cache.clone(),
485            received: received.clone(),
486        };
487        msgbus::subscribe_book_snapshots(topic.into(), TypedHandler::new(handler), None);
488
489        let snapshotter = BookSnapshotter::new(interval_ms, snapshot_infos, cache);
490        let event = TimeEvent::new(
491            Ustr::from("TEST"),
492            UUID4::new(),
493            UnixNanos::default(),
494            UnixNanos::default(),
495        );
496
497        snapshotter.snapshot(event);
498
499        assert!(received.borrow().is_empty());
500    }
501
502    struct CacheWritingBookHandler {
503        id: Ustr,
504        cache: Rc<RefCell<Cache>>,
505        received: Rc<RefCell<Vec<OrderBook>>>,
506    }
507
508    impl Handler<OrderBook> for CacheWritingBookHandler {
509        fn id(&self) -> Ustr {
510            self.id
511        }
512
513        fn handle(&self, book: &OrderBook) {
514            // Mirrors a strategy writing to the cache from `on_book`
515            let mut cache = self.cache.borrow_mut();
516            let _ = cache.order_book_mut(&book.instrument_id);
517            self.received.borrow_mut().push(book.clone());
518        }
519    }
520}