Skip to main content

nautilus_model/orderbook/
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
16//! A performant, generic, multi-purpose order book.
17
18use std::fmt::Display;
19
20use ahash::AHashSet;
21use indexmap::IndexMap;
22use nautilus_core::{
23    UnixNanos,
24    correctness::{CorrectnessResult, CorrectnessResultExt, FAILED},
25};
26use rust_decimal::Decimal;
27
28use super::{
29    BookViewError, aggregation::pre_process_order, analysis, display::pprint_book,
30    level::BookLevel, own::OwnOrderBook,
31};
32use crate::{
33    data::{BookOrder, OrderBookDelta, OrderBookDeltas, OrderBookDepth, QuoteTick, TradeTick},
34    enums::{BookAction, BookType, OrderSide, OrderStatus, RecordFlag},
35    identifiers::InstrumentId,
36    orderbook::{
37        BookIntegrityError, InvalidBookOperation,
38        ladder::{BookLadder, BookPrice},
39    },
40    types::{Price, Quantity, fixed::check_fixed_precision},
41};
42
43/// Provides a high-performance, versatile order book.
44///
45/// Maintains buy (bid) and sell (ask) orders in price-time priority, supporting multiple
46/// market data formats:
47/// - L3 (MBO): Market By Order - tracks individual orders with unique IDs.
48/// - L2 (MBP): Market By Price - aggregates orders at each price level.
49/// - L1 (MBP): Top-of-Book - maintains only the best bid and ask prices.
50#[derive(Clone, Debug)]
51#[cfg_attr(
52    feature = "python",
53    pyo3::pyclass(module = "nautilus_trader.model", from_py_object)
54)]
55#[cfg_attr(
56    feature = "python",
57    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.model")
58)]
59pub struct OrderBook {
60    /// The instrument ID for the order book.
61    pub instrument_id: InstrumentId,
62    /// The order book type (MBP types will aggregate orders).
63    pub book_type: BookType,
64    /// The last event sequence number for the order book.
65    pub sequence: u64,
66    /// The timestamp of the last event applied to the order book.
67    pub ts_last: UnixNanos,
68    /// The current count of updates applied to the order book.
69    pub update_count: u64,
70    pub(crate) bids: BookLadder,
71    pub(crate) asks: BookLadder,
72}
73
74impl PartialEq for OrderBook {
75    fn eq(&self, other: &Self) -> bool {
76        self.instrument_id == other.instrument_id && self.book_type == other.book_type
77    }
78}
79
80impl Eq for OrderBook {}
81
82impl Display for OrderBook {
83    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
84        write!(
85            f,
86            "{}(instrument_id={}, book_type={}, update_count={})",
87            stringify!(OrderBook),
88            self.instrument_id,
89            self.book_type,
90            self.update_count,
91        )
92    }
93}
94
95impl OrderBook {
96    /// Creates a new [`OrderBook`] instance.
97    #[must_use]
98    pub fn new(instrument_id: InstrumentId, book_type: BookType) -> Self {
99        Self {
100            instrument_id,
101            book_type,
102            sequence: 0,
103            ts_last: UnixNanos::default(),
104            update_count: 0,
105            bids: BookLadder::new(OrderSide::Buy, book_type),
106            asks: BookLadder::new(OrderSide::Sell, book_type),
107        }
108    }
109
110    /// Returns the serialized L1 batch state for one side.
111    ///
112    /// Codes are 0 for no batch, 1 for an MBP batch, and 2 for a snapshot batch.
113    #[must_use]
114    pub fn batch_state_code(&self, side: OrderSide) -> u8 {
115        match side {
116            OrderSide::Buy => self.bids.batch_state_code(),
117            OrderSide::Sell => self.asks.batch_state_code(),
118        }
119    }
120
121    /// Restores one side's serialized L1 batch state after restoring its orders.
122    ///
123    /// # Errors
124    ///
125    /// Returns an error if `code` is unknown, or nonzero on a non-L1 book.
126    pub fn set_batch_state_code(&mut self, side: OrderSide, code: u8) -> CorrectnessResult<()> {
127        match side {
128            OrderSide::Buy => self.bids.set_batch_state_code(code),
129            OrderSide::Sell => self.asks.set_batch_state_code(code),
130        }
131    }
132
133    /// Resets the order book to its initial empty state.
134    pub fn reset(&mut self) {
135        self.bids.clear();
136        self.asks.clear();
137        self.sequence = 0;
138        self.ts_last = UnixNanos::default();
139        self.update_count = 0;
140    }
141
142    /// Adds an order to the book after preprocessing based on book type.
143    ///
144    /// # Panics
145    ///
146    /// Panics if `order.side` is `None`.
147    pub fn add(&mut self, order: BookOrder, flags: u8, sequence: u64, ts_event: UnixNanos) {
148        let order = pre_process_order(self.book_type, order, flags);
149        match order.side.expect("BookOrder side must be Buy or Sell") {
150            OrderSide::Buy => self.bids.add(order, flags),
151            OrderSide::Sell => self.asks.add(order, flags),
152        }
153
154        self.increment(sequence, ts_event, flags);
155    }
156
157    /// Updates an existing order in the book after preprocessing based on book type.
158    ///
159    /// # Panics
160    ///
161    /// Panics if `order.side` is `None`.
162    pub fn update(&mut self, order: BookOrder, flags: u8, sequence: u64, ts_event: UnixNanos) {
163        let order = pre_process_order(self.book_type, order, flags);
164        match order.side.expect("BookOrder side must be Buy or Sell") {
165            OrderSide::Buy => self.bids.update(order, flags),
166            OrderSide::Sell => self.asks.update(order, flags),
167        }
168
169        self.increment(sequence, ts_event, flags);
170    }
171
172    /// Deletes an order from the book after preprocessing based on book type.
173    ///
174    /// # Panics
175    ///
176    /// Panics if `order.side` is `None`.
177    pub fn delete(&mut self, order: BookOrder, flags: u8, sequence: u64, ts_event: UnixNanos) {
178        let order = pre_process_order(self.book_type, order, flags);
179        match order.side.expect("BookOrder side must be Buy or Sell") {
180            OrderSide::Buy => self.bids.delete(order, sequence, ts_event),
181            OrderSide::Sell => self.asks.delete(order, sequence, ts_event),
182        }
183
184        self.increment(sequence, ts_event, flags);
185    }
186
187    /// Clears all orders from both sides of the book.
188    ///
189    /// A full clear uses its `sequence` as the new sequence high-water.
190    /// `clear_bids` and `clear_asks` preserve the current high-water.
191    pub fn clear(&mut self, sequence: u64, ts_event: UnixNanos) {
192        self.clear_with_flags(sequence, ts_event, 0);
193    }
194
195    /// Clears all bid orders from the book.
196    pub fn clear_bids(&mut self, sequence: u64, ts_event: UnixNanos) {
197        self.bids.clear();
198        self.increment(sequence, ts_event, 0);
199    }
200
201    /// Clears all ask orders from the book.
202    pub fn clear_asks(&mut self, sequence: u64, ts_event: UnixNanos) {
203        self.asks.clear();
204        self.increment(sequence, ts_event, 0);
205    }
206
207    fn clear_with_flags(&mut self, sequence: u64, ts_event: UnixNanos, flags: u8) {
208        self.bids.clear();
209        self.asks.clear();
210        self.increment(sequence, ts_event, flags);
211
212        // Check the clear against the old high-water before using its sequence as the new one
213        if !RecordFlag::F_SNAPSHOT.matches(flags) {
214            self.sequence = sequence;
215        }
216    }
217
218    /// Removes overlapped bid/ask levels when the book is strictly crossed (best bid > best ask)
219    ///
220    /// - Acts only when both sides exist and the book is crossed.
221    /// - Deletes by removing whole price levels via the ladder API to preserve invariants.
222    /// - `side=None` clears both overlapped ranges (conservative, may widen spread).
223    /// - `side=Buy` clears crossed bids only; side=Sell clears crossed asks only.
224    /// - Returns removed price levels (crossed bids first, then crossed asks), or None if nothing removed.
225    pub fn clear_stale_levels(&mut self, side: Option<OrderSide>) -> Option<Vec<BookLevel>> {
226        if self.book_type == BookType::L1_MBP {
227            // L1_MBP maintains a single top-of-book price per side; nothing to do
228            return None;
229        }
230
231        let (Some(best_bid), Some(best_ask)) = (self.best_bid_price(), self.best_ask_price())
232        else {
233            return None;
234        };
235
236        if best_bid <= best_ask {
237            return None;
238        }
239
240        let mut removed_levels = Vec::new();
241        let (clear_bids, clear_asks) = match side {
242            Some(OrderSide::Buy) => (true, false),
243            Some(OrderSide::Sell) => (false, true),
244            None => (true, true),
245        };
246
247        // Collect prices to remove for asks (prices <= best_bid)
248        let mut ask_prices_to_remove = Vec::new();
249
250        if clear_asks {
251            for bp in self.asks.levels.keys() {
252                if bp.value <= best_bid {
253                    ask_prices_to_remove.push(*bp);
254                } else {
255                    break;
256                }
257            }
258        }
259
260        // Collect prices to remove for bids (prices >= best_ask)
261        let mut bid_prices_to_remove = Vec::new();
262
263        if clear_bids {
264            for bp in self.bids.levels.keys() {
265                if bp.value >= best_ask {
266                    bid_prices_to_remove.push(*bp);
267                } else {
268                    break;
269                }
270            }
271        }
272
273        if ask_prices_to_remove.is_empty() && bid_prices_to_remove.is_empty() {
274            return None;
275        }
276
277        let bid_count = bid_prices_to_remove.len();
278        let ask_count = ask_prices_to_remove.len();
279
280        // Remove and collect bid levels
281        for price in bid_prices_to_remove {
282            if let Some(level) = self.bids.remove_level(price) {
283                removed_levels.push(level);
284            }
285        }
286
287        // Remove and collect ask levels
288        for price in ask_prices_to_remove {
289            if let Some(level) = self.asks.remove_level(price) {
290                removed_levels.push(level);
291            }
292        }
293
294        self.increment(self.sequence, self.ts_last, 0);
295
296        if removed_levels.is_empty() {
297            None
298        } else {
299            let total_orders: usize = removed_levels.iter().map(|level| level.orders.len()).sum();
300
301            log::warn!(
302                "Removed {} stale/crossed levels (instrument_id={}, bid_levels={}, ask_levels={}, total_orders={}), book was crossed with best_bid={} > best_ask={}",
303                removed_levels.len(),
304                self.instrument_id,
305                bid_count,
306                ask_count,
307                total_orders,
308                best_bid,
309                best_ask
310            );
311
312            Some(removed_levels)
313        }
314    }
315
316    /// Applies a single order book delta operation.
317    ///
318    /// # Errors
319    ///
320    /// Returns an error if:
321    /// - The delta's instrument ID does not match this book's instrument ID.
322    /// - An `Add` is given with no side, either explicitly or because the cache lookup failed.
323    /// - An `Add` with no side matches an order ID on both sides of the book.
324    /// - After resolution the delta still has no side but its action is not `Clear`.
325    ///
326    /// # Notes
327    ///
328    /// An ambiguous no-side `Update` or `Delete` is skipped with a warning.
329    pub fn apply_delta(&mut self, delta: &OrderBookDelta) -> Result<(), BookIntegrityError> {
330        if delta.instrument_id != self.instrument_id {
331            return Err(BookIntegrityError::InstrumentMismatch(
332                self.instrument_id,
333                delta.instrument_id,
334            ));
335        }
336        self.apply_delta_unchecked(delta)
337    }
338
339    /// Applies a single order book delta operation without instrument ID validation.
340    ///
341    /// "Unchecked" refers only to skipping the instrument ID match - other validations
342    /// still apply and errors are still returned. This exists because `Ustr` interning
343    /// is not shared across FFI boundaries, causing pointer-based equality to fail even
344    /// when string values match. This limitation may be resolved in a future version.
345    ///
346    /// # Errors
347    ///
348    /// Returns an error if:
349    /// - An `Add` is given with no side, either explicitly or because the cache lookup failed.
350    /// - An `Add` with no side matches an order ID on both sides of the book.
351    /// - After resolution the delta still has no side but its action is not `Clear`.
352    ///
353    /// # Notes
354    ///
355    /// An ambiguous no-side `Update` or `Delete` is skipped with a warning.
356    pub fn apply_delta_unchecked(
357        &mut self,
358        delta: &OrderBookDelta,
359    ) -> Result<(), BookIntegrityError> {
360        // No batch wraps a standalone delta, so it reports its own stale metadata
361        self.report_out_of_order_snapshot(delta.flags, delta.sequence, delta.ts_event, 1);
362        self.apply_delta_inner(delta)
363    }
364
365    fn apply_delta_inner(&mut self, delta: &OrderBookDelta) -> Result<(), BookIntegrityError> {
366        let mut order = delta.order;
367
368        if order.side.is_none() && order.order_id != 0 {
369            match self.resolve_no_side_order(order) {
370                Ok(resolved) => order = resolved,
371                Err(BookIntegrityError::OrderNotFoundForSideResolution(order_id)) => {
372                    match delta.action {
373                        BookAction::Add => return Err(BookIntegrityError::NoOrderSide),
374                        BookAction::Update | BookAction::Delete => {
375                            // Already consistent
376                            log::debug!(
377                                "Skipping {:?} for unknown order_id={order_id}",
378                                delta.action
379                            );
380                            return Ok(());
381                        }
382                        BookAction::Clear => {} // Won't hit this (order_id != 0)
383                    }
384                }
385                Err(BookIntegrityError::AmbiguousOrderSide(order_id)) => {
386                    match delta.action {
387                        BookAction::Add => {
388                            return Err(BookIntegrityError::AmbiguousOrderSide(order_id));
389                        }
390                        BookAction::Update | BookAction::Delete => {
391                            log::warn!(
392                                "Skipping {:?} for order_id={order_id} found on both book sides",
393                                delta.action
394                            );
395                            return Ok(());
396                        }
397                        BookAction::Clear => {} // Won't hit this (order_id != 0)
398                    }
399                }
400                Err(e) => return Err(e),
401            }
402        }
403
404        if order.side.is_none() && delta.action != BookAction::Clear {
405            return Err(BookIntegrityError::NoOrderSide);
406        }
407
408        let flags = delta.flags;
409        let sequence = delta.sequence;
410        let ts_event = delta.ts_event;
411
412        match delta.action {
413            BookAction::Add => self.add(order, flags, sequence, ts_event),
414            BookAction::Update => self.update(order, flags, sequence, ts_event),
415            BookAction::Delete => self.delete(order, flags, sequence, ts_event),
416            BookAction::Clear => self.clear_with_flags(sequence, ts_event, flags),
417        }
418
419        Ok(())
420    }
421
422    /// Applies multiple order book delta operations.
423    ///
424    /// # Errors
425    ///
426    /// Returns an error if:
427    /// - The deltas' instrument ID does not match this book's instrument ID.
428    /// - Any individual delta application fails (see [`Self::apply_delta`]).
429    pub fn apply_deltas(&mut self, deltas: &OrderBookDeltas) -> Result<(), BookIntegrityError> {
430        if deltas.instrument_id != self.instrument_id {
431            return Err(BookIntegrityError::InstrumentMismatch(
432                self.instrument_id,
433                deltas.instrument_id,
434            ));
435        }
436        self.apply_deltas_unchecked(deltas)
437    }
438
439    /// Applies multiple order book delta operations without instrument ID validation.
440    ///
441    /// See [`Self::apply_delta_unchecked`] for details on why this function exists.
442    ///
443    /// # Errors
444    ///
445    /// Returns an error if any individual delta application fails.
446    ///
447    /// # Notes
448    ///
449    /// A snapshot batch carrying metadata earlier than the last applied update is reported once
450    /// here rather than per delta, since every delta in the batch shares the snapshot sequence and
451    /// timestamp.
452    pub fn apply_deltas_unchecked(
453        &mut self,
454        deltas: &OrderBookDeltas,
455    ) -> Result<(), BookIntegrityError> {
456        self.report_out_of_order_snapshot(
457            deltas.flags,
458            deltas.sequence,
459            deltas.ts_event,
460            deltas.deltas.len(),
461        );
462
463        for delta in &deltas.deltas {
464            self.apply_delta_inner(delta)?;
465        }
466
467        Ok(())
468    }
469
470    // Reports the incoming snapshot, so the result does not depend on whether every delta in it
471    // reaches the book
472    fn report_out_of_order_snapshot(
473        &self,
474        flags: u8,
475        sequence: u64,
476        ts_event: UnixNanos,
477        count: usize,
478    ) {
479        if !RecordFlag::F_SNAPSHOT.matches(flags) {
480            return;
481        }
482
483        if sequence > 0 && sequence < self.sequence {
484            log::warn!(
485                "Out-of-order snapshot: sequence {} < {} (deltas={}, instrument_id={})",
486                sequence,
487                self.sequence,
488                count,
489                self.instrument_id
490            );
491        }
492
493        if ts_event < self.ts_last {
494            log::warn!(
495                "Out-of-order snapshot: ts_event {} < {} (deltas={}, instrument_id={})",
496                ts_event,
497                self.ts_last,
498                count,
499                self.instrument_id
500            );
501        }
502    }
503
504    /// Creates an `OrderBookDeltas` snapshot from the current order book state.
505    ///
506    /// This is the reverse operation of `apply_deltas`: it converts the current book state
507    /// back into a snapshot format with a `Clear` delta followed by `Add` deltas for all orders.
508    ///
509    /// # Parameters
510    ///
511    /// * `ts_event` - UNIX timestamp (nanoseconds) when the book event occurred.
512    /// * `ts_init` - UNIX timestamp (nanoseconds) when the instance was created.
513    ///
514    /// # Returns
515    ///
516    /// An `OrderBookDeltas` containing a snapshot of the current order book state.
517    #[must_use]
518    pub fn to_deltas(&self, ts_event: UnixNanos, ts_init: UnixNanos) -> OrderBookDeltas {
519        let mut deltas = Vec::new();
520
521        let total_orders = self.bids(None).map(BookLevel::len).sum::<usize>()
522            + self.asks(None).map(BookLevel::len).sum::<usize>();
523
524        // Set F_LAST on clear when book is empty so buffered consumers flush
525        let mut clear = OrderBookDelta::clear(self.instrument_id, self.sequence, ts_event, ts_init);
526
527        if total_orders == 0 {
528            clear.flags |= RecordFlag::F_LAST as u8;
529        }
530        deltas.push(clear);
531
532        let mut order_count = 0;
533
534        for level in self.bids(None).chain(self.asks(None)) {
535            for order in level.iter() {
536                order_count += 1;
537                let flags = if order_count == total_orders {
538                    RecordFlag::F_SNAPSHOT as u8 | RecordFlag::F_LAST as u8
539                } else {
540                    RecordFlag::F_SNAPSHOT as u8
541                };
542
543                deltas.push(OrderBookDelta::new(
544                    self.instrument_id,
545                    BookAction::Add,
546                    *order,
547                    flags,
548                    self.sequence,
549                    ts_event,
550                    ts_init,
551                ));
552            }
553        }
554
555        OrderBookDeltas::new(self.instrument_id, deltas)
556    }
557
558    /// Replaces current book state with a depth snapshot.
559    ///
560    /// # Errors
561    ///
562    /// Returns an error if the depth's instrument ID does not match this book's instrument ID.
563    pub fn apply_depth(&mut self, depth: &OrderBookDepth) -> Result<(), BookIntegrityError> {
564        if depth.instrument_id != self.instrument_id {
565            return Err(BookIntegrityError::InstrumentMismatch(
566                self.instrument_id,
567                depth.instrument_id,
568            ));
569        }
570        self.apply_depth_unchecked(depth)
571    }
572
573    /// Replaces current book state with a depth snapshot without instrument ID validation.
574    ///
575    /// See [`Self::apply_delta_unchecked`] for details on why this function exists.
576    ///
577    /// This function does not return errors, but returns `Result` for API consistency.
578    #[allow(
579        clippy::missing_errors_doc,
580        reason = "unchecked depth application returns no integrity errors"
581    )]
582    pub fn apply_depth_unchecked(
583        &mut self,
584        depth: &OrderBookDepth,
585    ) -> Result<(), BookIntegrityError> {
586        self.bids.clear();
587        self.asks.clear();
588
589        for &order in &depth.bids {
590            // Skip padding entries
591            if order.side.is_none() || !order.size.is_positive() {
592                continue;
593            }
594
595            if order.side != Some(OrderSide::Buy) {
596                debug_assert_eq!(
597                    order.side,
598                    Some(OrderSide::Buy),
599                    "Bid order must have Buy side, was {:?}",
600                    order.side
601                );
602                log::warn!(
603                    "Skipping bid order with wrong side {:?} (instrument_id={})",
604                    order.side,
605                    self.instrument_id
606                );
607                continue;
608            }
609
610            let order = pre_process_order(self.book_type, order, depth.flags);
611            self.bids.add(order, depth.flags);
612        }
613
614        for &order in &depth.asks {
615            // Skip padding entries
616            if order.side.is_none() || !order.size.is_positive() {
617                continue;
618            }
619
620            if order.side != Some(OrderSide::Sell) {
621                debug_assert_eq!(
622                    order.side,
623                    Some(OrderSide::Sell),
624                    "Ask order must have Sell side, was {:?}",
625                    order.side
626                );
627                log::warn!(
628                    "Skipping ask order with wrong side {:?} (instrument_id={})",
629                    order.side,
630                    self.instrument_id
631                );
632                continue;
633            }
634
635            let order = pre_process_order(self.book_type, order, depth.flags);
636            self.asks.add(order, depth.flags);
637        }
638
639        // Depth increments once per snapshot, so there is no per-level flood to suppress and
640        // the existing single warning is already the one-per-rebuild signal
641        self.increment(depth.sequence, depth.ts_event, 0);
642
643        Ok(())
644    }
645
646    fn resolve_no_side_order(&self, mut order: BookOrder) -> Result<BookOrder, BookIntegrityError> {
647        let bid_price = self.bids.cache.get(&order.order_id);
648        let ask_price = self.asks.cache.get(&order.order_id);
649
650        // For L2 books the order ID is a pure price hash, so in a locked market the
651        // same ID exists on both sides and the side cannot be resolved safely.
652        let book_price = match (bid_price, ask_price) {
653            (Some(_), Some(_)) => {
654                return Err(BookIntegrityError::AmbiguousOrderSide(order.order_id));
655            }
656            (Some(book_price), None) | (None, Some(book_price)) => book_price,
657            (None, None) => {
658                return Err(BookIntegrityError::OrderNotFoundForSideResolution(
659                    order.order_id,
660                ));
661            }
662        };
663
664        order.side = book_price.side.into();
665
666        Ok(order)
667    }
668
669    /// Returns an iterator over bid price levels.
670    pub fn bids(&self, depth: Option<usize>) -> impl Iterator<Item = &BookLevel> {
671        self.bids.levels.values().take(depth.unwrap_or(usize::MAX))
672    }
673
674    /// Returns an iterator over ask price levels.
675    pub fn asks(&self, depth: Option<usize>) -> impl Iterator<Item = &BookLevel> {
676        self.asks.levels.values().take(depth.unwrap_or(usize::MAX))
677    }
678
679    /// Returns bid price levels as a map of price to size.
680    #[must_use]
681    pub fn bids_as_map(&self, depth: Option<usize>) -> IndexMap<Decimal, Decimal> {
682        self.bids(depth)
683            .map(|level| (level.price.value.as_decimal(), level.size_decimal()))
684            .collect()
685    }
686
687    /// Returns ask price levels as a map of price to size.
688    #[must_use]
689    pub fn asks_as_map(&self, depth: Option<usize>) -> IndexMap<Decimal, Decimal> {
690        self.asks(depth)
691            .map(|level| (level.price.value.as_decimal(), level.size_decimal()))
692            .collect()
693    }
694
695    /// Groups bid quantities by price into buckets, limited by depth.
696    #[must_use]
697    pub fn group_bids(
698        &self,
699        group_size: Decimal,
700        depth: Option<usize>,
701    ) -> IndexMap<Decimal, Decimal> {
702        group_levels(self.bids(None), group_size, depth, true)
703    }
704
705    /// Groups ask quantities by price into buckets, limited by depth.
706    #[must_use]
707    pub fn group_asks(
708        &self,
709        group_size: Decimal,
710        depth: Option<usize>,
711    ) -> IndexMap<Decimal, Decimal> {
712        group_levels(self.asks(None), group_size, depth, false)
713    }
714
715    /// Maps bid prices to total public size per level, excluding own orders up to a depth limit.
716    ///
717    /// With `own_book`, subtracts own order sizes, filtered by `status` if provided.
718    /// When `now` is provided, only subtracts orders whose acceptance time plus
719    /// `accepted_buffer_ns` is at or before `now`. When `now` is `None`, acceptance-time
720    /// filtering is disabled.
721    ///
722    /// # Panics
723    ///
724    /// Panics if `own_book` is `Some`, `accepted_buffer_ns` is positive, and `now` is `None`.
725    #[must_use]
726    pub fn bids_filtered_as_map(
727        &self,
728        depth: Option<usize>,
729        own_book: Option<&OwnOrderBook>,
730        status: Option<&AHashSet<OrderStatus>>,
731        accepted_buffer_ns: Option<u64>,
732        now: Option<u64>,
733    ) -> IndexMap<Decimal, Decimal> {
734        let mut public_map = self
735            .bids(depth)
736            .map(|level| (level.price.value.as_decimal(), level.size_decimal()))
737            .collect::<IndexMap<Decimal, Decimal>>();
738
739        if let Some(own_book) = own_book {
740            filter_quantities(
741                &mut public_map,
742                own_book.bid_quantity(status, None, None, accepted_buffer_ns, now),
743            );
744        }
745
746        public_map
747    }
748
749    /// Maps ask prices to total public size per level, excluding own orders up to a depth limit.
750    ///
751    /// With `own_book`, subtracts own order sizes, filtered by `status` if provided.
752    /// When `now` is provided, only subtracts orders whose acceptance time plus
753    /// `accepted_buffer_ns` is at or before `now`. When `now` is `None`, acceptance-time
754    /// filtering is disabled.
755    ///
756    /// # Panics
757    ///
758    /// Panics if `own_book` is `Some`, `accepted_buffer_ns` is positive, and `now` is `None`.
759    #[must_use]
760    pub fn asks_filtered_as_map(
761        &self,
762        depth: Option<usize>,
763        own_book: Option<&OwnOrderBook>,
764        status: Option<&AHashSet<OrderStatus>>,
765        accepted_buffer_ns: Option<u64>,
766        now: Option<u64>,
767    ) -> IndexMap<Decimal, Decimal> {
768        let mut public_map = self
769            .asks(depth)
770            .map(|level| (level.price.value.as_decimal(), level.size_decimal()))
771            .collect::<IndexMap<Decimal, Decimal>>();
772
773        if let Some(own_book) = own_book {
774            filter_quantities(
775                &mut public_map,
776                own_book.ask_quantity(status, None, None, accepted_buffer_ns, now),
777            );
778        }
779
780        public_map
781    }
782
783    /// Returns a filtered [`OrderBook`] view with own sizes subtracted from public levels.
784    ///
785    /// # Panics
786    ///
787    /// Panics if `self` and `own_book` have different instrument IDs.
788    /// Panics if `own_book` is `Some`, `accepted_buffer_ns` is positive, and `now` is `None`.
789    ///
790    /// [`Self::filtered_view_checked`] for fallible construction.
791    #[must_use]
792    pub fn filtered_view(
793        &self,
794        own_book: Option<&OwnOrderBook>,
795        depth: Option<usize>,
796        status: Option<&AHashSet<OrderStatus>>,
797        accepted_buffer_ns: Option<u64>,
798        now: Option<u64>,
799    ) -> Self {
800        self.filtered_view_checked(own_book, depth, status, accepted_buffer_ns, now)
801            .expect(FAILED)
802    }
803
804    /// Fallible version of [`Self::filtered_view`].
805    ///
806    /// # Errors
807    ///
808    /// Returns an error if `self` and `own_book` have different instrument IDs.
809    ///
810    /// # Panics
811    ///
812    /// Panics if `own_book` is `Some`, `accepted_buffer_ns` is positive, and `now` is `None`.
813    /// Panics if `Price::from_decimal` or `Quantity::from_decimal` fails when
814    /// reconstructing filtered levels.
815    pub fn filtered_view_checked(
816        &self,
817        own_book: Option<&OwnOrderBook>,
818        depth: Option<usize>,
819        status: Option<&AHashSet<OrderStatus>>,
820        accepted_buffer_ns: Option<u64>,
821        now: Option<u64>,
822    ) -> Result<Self, BookViewError> {
823        if let Some(own_book) = own_book
824            && self.instrument_id != own_book.instrument_id
825        {
826            return Err(BookViewError::InstrumentMismatch(
827                self.instrument_id,
828                own_book.instrument_id,
829            ));
830        }
831
832        let bids_map = self.bids_filtered_as_map(depth, own_book, status, accepted_buffer_ns, now);
833        let asks_map = self.asks_filtered_as_map(depth, own_book, status, accepted_buffer_ns, now);
834
835        let mut filtered_book = Self::new(self.instrument_id, self.book_type);
836        filtered_book.sequence = self.sequence;
837        filtered_book.ts_last = self.ts_last;
838
839        let sequence = self.sequence;
840        let ts_event = self.ts_last;
841
842        let mut order_id = 1_u64;
843
844        for (price, quantity) in bids_map {
845            if quantity <= Decimal::ZERO {
846                continue;
847            }
848
849            let order = BookOrder::new(
850                OrderSide::Buy,
851                Price::from_decimal(price).expect("Invalid bid price for OrderBook::filtered_view"),
852                Quantity::from_decimal(quantity)
853                    .expect("Invalid bid quantity for OrderBook::filtered_view"),
854                order_id,
855            );
856            order_id += 1;
857            filtered_book.add(order, 0, sequence, ts_event);
858        }
859
860        for (price, quantity) in asks_map {
861            if quantity <= Decimal::ZERO {
862                continue;
863            }
864
865            let order = BookOrder::new(
866                OrderSide::Sell,
867                Price::from_decimal(price).expect("Invalid ask price for OrderBook::filtered_view"),
868                Quantity::from_decimal(quantity)
869                    .expect("Invalid ask quantity for OrderBook::filtered_view"),
870                order_id,
871            );
872            order_id += 1;
873            filtered_book.add(order, 0, sequence, ts_event);
874        }
875
876        Ok(filtered_book)
877    }
878
879    /// Groups bid quantities into price buckets, truncating to a maximum depth, excluding own orders.
880    ///
881    /// With `own_book`, subtracts own order sizes, filtered by `status` if provided.
882    /// When `now` is provided, only subtracts orders whose acceptance time plus
883    /// `accepted_buffer_ns` is at or before `now`. When `now` is `None`, acceptance-time
884    /// filtering is disabled.
885    ///
886    /// # Panics
887    ///
888    /// Panics if `own_book` is `Some`, `accepted_buffer_ns` is positive, and `now` is `None`.
889    #[must_use]
890    pub fn group_bids_filtered(
891        &self,
892        group_size: Decimal,
893        depth: Option<usize>,
894        own_book: Option<&OwnOrderBook>,
895        status: Option<&AHashSet<OrderStatus>>,
896        accepted_buffer_ns: Option<u64>,
897        now: Option<u64>,
898    ) -> IndexMap<Decimal, Decimal> {
899        let mut public_map = group_levels(self.bids(None), group_size, depth, true);
900
901        if let Some(own_book) = own_book {
902            // Leave own buckets untruncated so better-priced ones cannot evict those within depth
903            filter_quantities(
904                &mut public_map,
905                own_book.bid_quantity(status, None, Some(group_size), accepted_buffer_ns, now),
906            );
907        }
908
909        public_map
910    }
911
912    /// Groups ask quantities into price buckets, truncating to a maximum depth, excluding own orders.
913    ///
914    /// With `own_book`, subtracts own order sizes, filtered by `status` if provided.
915    /// When `now` is provided, only subtracts orders whose acceptance time plus
916    /// `accepted_buffer_ns` is at or before `now`. When `now` is `None`, acceptance-time
917    /// filtering is disabled.
918    ///
919    /// # Panics
920    ///
921    /// Panics if `own_book` is `Some`, `accepted_buffer_ns` is positive, and `now` is `None`.
922    #[must_use]
923    pub fn group_asks_filtered(
924        &self,
925        group_size: Decimal,
926        depth: Option<usize>,
927        own_book: Option<&OwnOrderBook>,
928        status: Option<&AHashSet<OrderStatus>>,
929        accepted_buffer_ns: Option<u64>,
930        now: Option<u64>,
931    ) -> IndexMap<Decimal, Decimal> {
932        let mut public_map = group_levels(self.asks(None), group_size, depth, false);
933
934        if let Some(own_book) = own_book {
935            // Leave own buckets untruncated so better-priced ones cannot evict those within depth
936            filter_quantities(
937                &mut public_map,
938                own_book.ask_quantity(status, None, Some(group_size), accepted_buffer_ns, now),
939            );
940        }
941
942        public_map
943    }
944
945    /// Returns true if the book has any bid orders.
946    #[must_use]
947    pub fn has_bid(&self) -> bool {
948        self.bids.top().is_some_and(|top| !top.orders.is_empty())
949    }
950
951    /// Returns true if the book has any ask orders.
952    #[must_use]
953    pub fn has_ask(&self) -> bool {
954        self.asks.top().is_some_and(|top| !top.orders.is_empty())
955    }
956
957    /// Returns the best bid price if available.
958    #[must_use]
959    pub fn best_bid_price(&self) -> Option<Price> {
960        self.bids.top().map(|top| top.price.value)
961    }
962
963    /// Returns the best ask price if available.
964    #[must_use]
965    pub fn best_ask_price(&self) -> Option<Price> {
966        self.asks.top().map(|top| top.price.value)
967    }
968
969    /// Returns the size at the best bid price if available.
970    #[must_use]
971    pub fn best_bid_size(&self) -> Option<Quantity> {
972        self.bids
973            .top()
974            .and_then(|top| top.first().map(|order| order.size))
975    }
976
977    /// Returns the size at the best ask price if available.
978    #[must_use]
979    pub fn best_ask_size(&self) -> Option<Quantity> {
980        self.asks
981            .top()
982            .and_then(|top| top.first().map(|order| order.size))
983    }
984
985    /// Returns the spread between best ask and bid prices if both exist.
986    #[must_use]
987    pub fn spread(&self) -> Option<f64> {
988        match (self.best_ask_price(), self.best_bid_price()) {
989            (Some(ask), Some(bid)) => Some(ask.as_f64() - bid.as_f64()),
990            _ => None,
991        }
992    }
993
994    /// Returns the midpoint between best ask and bid prices if both exist.
995    #[must_use]
996    pub fn midpoint(&self) -> Option<f64> {
997        match (self.best_ask_price(), self.best_bid_price()) {
998            (Some(ask), Some(bid)) => Some(f64::midpoint(ask.as_f64(), bid.as_f64())),
999            _ => None,
1000        }
1001    }
1002
1003    /// Calculates the average price to fill the specified quantity.
1004    #[must_use]
1005    pub fn get_avg_px_for_quantity(&self, qty: Quantity, order_side: OrderSide) -> f64 {
1006        let levels = match order_side {
1007            OrderSide::Buy => &self.asks.levels,
1008            OrderSide::Sell => &self.bids.levels,
1009        };
1010
1011        analysis::get_avg_px_for_quantity(qty, levels)
1012    }
1013
1014    /// Calculates the worst (last-touched) price to fill the specified quantity.
1015    #[must_use]
1016    pub fn get_worst_px_for_quantity(&self, qty: Quantity, order_side: OrderSide) -> Option<Price> {
1017        let levels = match order_side {
1018            OrderSide::Buy => &self.asks.levels,
1019            OrderSide::Sell => &self.bids.levels,
1020        };
1021
1022        analysis::get_worst_px_for_quantity(qty, levels)
1023    }
1024
1025    /// Calculates average price and quantity for target exposure.
1026    ///
1027    /// Returns (average price, quantity, last-touched price).
1028    #[must_use]
1029    pub fn get_avg_px_qty_for_exposure(
1030        &self,
1031        target_exposure: Quantity,
1032        order_side: OrderSide,
1033    ) -> (f64, f64, f64) {
1034        let levels = match order_side {
1035            OrderSide::Buy => &self.asks.levels,
1036            OrderSide::Sell => &self.bids.levels,
1037        };
1038
1039        analysis::get_avg_px_qty_for_exposure(target_exposure, levels)
1040    }
1041
1042    /// Returns the cumulative quantity available at or better than the specified price.
1043    ///
1044    /// For a BUY order, sums ask levels at or below the price.
1045    /// For a SELL order, sums bid levels at or above the price.
1046    #[must_use]
1047    pub fn get_quantity_for_price(&self, price: Price, order_side: OrderSide) -> f64 {
1048        let levels = match order_side {
1049            OrderSide::Buy => &self.asks.levels,
1050            OrderSide::Sell => &self.bids.levels,
1051        };
1052
1053        analysis::get_quantity_for_price(price, order_side, levels)
1054    }
1055
1056    /// Returns the quantity at a specific price level only, or 0 if no level exists.
1057    ///
1058    /// Unlike `get_quantity_for_price` which returns cumulative quantity across
1059    /// multiple levels, this returns only the quantity at the exact price level.
1060    ///
1061    /// The Python binding raises `ValueError` for an unsupported `size_precision` or an
1062    /// aggregated level size that cannot be represented as a [`Quantity`].
1063    ///
1064    /// # Panics
1065    ///
1066    /// Panics if:
1067    /// - `size_precision` exceeds the supported fixed precision.
1068    /// - The aggregated level size cannot be represented as a [`Quantity`].
1069    #[must_use]
1070    pub fn get_quantity_at_level(
1071        &self,
1072        price: Price,
1073        order_side: OrderSide,
1074        size_precision: u8,
1075    ) -> Quantity {
1076        self.get_quantity_at_level_checked(price, order_side, size_precision)
1077            .expect_display("Failed to get order book level quantity")
1078    }
1079
1080    /// Returns the quantity at a specific price level only, or zero if no level exists.
1081    ///
1082    /// # Errors
1083    ///
1084    /// Returns an error if:
1085    /// - `size_precision` exceeds the supported fixed precision.
1086    /// - The aggregated level size cannot be represented as a [`Quantity`].
1087    pub(crate) fn get_quantity_at_level_checked(
1088        &self,
1089        price: Price,
1090        order_side: OrderSide,
1091        size_precision: u8,
1092    ) -> CorrectnessResult<Quantity> {
1093        check_fixed_precision(size_precision)?;
1094
1095        // For a BUY order, we look in asks (sell side); for SELL order, we look in bids (buy side)
1096        // BookPrice keys use the side of orders IN the book, not the incoming order side
1097        let (levels, book_side) = match order_side {
1098            OrderSide::Buy => (&self.asks.levels, OrderSide::Sell),
1099            OrderSide::Sell => (&self.bids.levels, OrderSide::Buy),
1100        };
1101
1102        let book_price = BookPrice::new(price, book_side);
1103
1104        match levels.get(&book_price) {
1105            Some(level) => Quantity::from_raw_checked(level.size_raw_checked()?, size_precision),
1106            None => Quantity::from_raw_checked(0, size_precision),
1107        }
1108    }
1109
1110    /// Returns the orders at a specific price level in FIFO order, or an empty vec if no level exists.
1111    ///
1112    /// Follows the same side convention as `get_quantity_at_level`: for a BUY
1113    /// order this reads the asks (sell side), for a SELL order the bids.
1114    #[must_use]
1115    pub fn get_orders_at_level(&self, price: Price, order_side: OrderSide) -> Vec<BookOrder> {
1116        let (levels, book_side) = match order_side {
1117            OrderSide::Buy => (&self.asks.levels, OrderSide::Sell),
1118            OrderSide::Sell => (&self.bids.levels, OrderSide::Buy),
1119        };
1120
1121        let book_price = BookPrice::new(price, book_side);
1122
1123        levels
1124            .get(&book_price)
1125            .map_or_else(Vec::new, BookLevel::get_orders)
1126    }
1127
1128    /// Simulates fills for an order, returning list of (price, quantity) tuples.
1129    ///
1130    /// # Panics
1131    ///
1132    /// Panics if `order.side` is `None`.
1133    #[must_use]
1134    pub fn simulate_fills(&self, order: &BookOrder) -> Vec<(Price, Quantity)> {
1135        match order.side.expect("BookOrder side must be Buy or Sell") {
1136            OrderSide::Buy => self.asks.simulate_fills(order),
1137            OrderSide::Sell => self.bids.simulate_fills(order),
1138        }
1139    }
1140
1141    /// Returns all price levels crossed by an order at the given price and side.
1142    ///
1143    /// Unlike `simulate_fills`, this returns ALL crossed levels regardless of
1144    /// order quantity. Used when liquidity consumption tracking needs visibility
1145    /// into all available levels.
1146    ///
1147    /// The Python binding raises `ValueError` for an unsupported `size_precision` or an
1148    /// aggregated level size that cannot be represented as a [`Quantity`].
1149    ///
1150    /// # Panics
1151    ///
1152    /// Panics if:
1153    /// - `size_precision` exceeds the supported fixed precision.
1154    /// - An aggregated level size cannot be represented as a [`Quantity`].
1155    #[must_use]
1156    pub fn get_all_crossed_levels(
1157        &self,
1158        order_side: OrderSide,
1159        price: Price,
1160        size_precision: u8,
1161    ) -> Vec<(Price, Quantity)> {
1162        self.get_all_crossed_levels_checked(order_side, price, size_precision)
1163            .expect_display("Failed to collect crossed order book levels")
1164    }
1165
1166    /// Returns all price levels crossed by an order at the given price and side.
1167    ///
1168    /// # Errors
1169    ///
1170    /// Returns an error if:
1171    /// - `size_precision` exceeds the supported fixed precision.
1172    /// - An aggregated level size cannot be represented as a [`Quantity`].
1173    pub(crate) fn get_all_crossed_levels_checked(
1174        &self,
1175        order_side: OrderSide,
1176        price: Price,
1177        size_precision: u8,
1178    ) -> CorrectnessResult<Vec<(Price, Quantity)>> {
1179        let levels = match order_side {
1180            OrderSide::Buy => &self.asks.levels,
1181            OrderSide::Sell => &self.bids.levels,
1182        };
1183
1184        analysis::get_levels_for_price_checked(price, order_side, levels, size_precision)
1185    }
1186
1187    /// Return a formatted string representation of the order book.
1188    #[must_use]
1189    pub fn pprint(&self, num_levels: usize, group_size: Option<Decimal>) -> String {
1190        pprint_book(self, num_levels, group_size)
1191    }
1192
1193    fn increment(&mut self, sequence: u64, ts_event: UnixNanos, flags: u8) {
1194        // A snapshot rebuild legitimately carries metadata behind the last applied update
1195        let is_snapshot = RecordFlag::F_SNAPSHOT.matches(flags);
1196
1197        if !is_snapshot && sequence > 0 && sequence < self.sequence {
1198            log::warn!(
1199                "Out-of-order update: sequence {} < {} (instrument_id={})",
1200                sequence,
1201                self.sequence,
1202                self.instrument_id
1203            );
1204        }
1205
1206        if !is_snapshot && ts_event < self.ts_last {
1207            log::warn!(
1208                "Out-of-order update: ts_event {} < {} (instrument_id={})",
1209                ts_event,
1210                self.ts_last,
1211                self.instrument_id
1212            );
1213        }
1214
1215        if self.update_count == u64::MAX {
1216            debug_assert!(
1217                self.update_count < u64::MAX,
1218                "Update count at u64::MAX limit (about to overflow): {}",
1219                self.update_count
1220            );
1221            log::warn!(
1222                "Update count at u64::MAX: {} (instrument_id={})",
1223                self.update_count,
1224                self.instrument_id
1225            );
1226        }
1227
1228        // High-water mark prevents metadata regression from out-of-order updates
1229        self.sequence = sequence.max(self.sequence);
1230        self.ts_last = ts_event.max(self.ts_last);
1231        self.update_count = self.update_count.saturating_add(1);
1232    }
1233
1234    /// Updates L1 book state from a quote tick. Only valid for `L1_MBP` book type.
1235    ///
1236    /// # Errors
1237    ///
1238    /// Returns an error if the book type is not `L1_MBP`.
1239    pub fn update_quote_tick(&mut self, quote: &QuoteTick) -> Result<(), InvalidBookOperation> {
1240        if self.book_type != BookType::L1_MBP {
1241            return Err(InvalidBookOperation::Update(self.book_type));
1242        }
1243
1244        if quote.ts_event < self.ts_last {
1245            log::warn!(
1246                "Skipping stale quote: ts_event {} < ts_last {} (instrument_id={})",
1247                quote.ts_event,
1248                self.ts_last,
1249                self.instrument_id
1250            );
1251            return Ok(());
1252        }
1253
1254        // Crossed quotes (bid > ask) can occur temporarily in volatile markets
1255        if cfg!(debug_assertions) && quote.bid_price > quote.ask_price {
1256            log::warn!(
1257                "Quote has crossed prices: bid={}, ask={} for {}",
1258                quote.bid_price,
1259                quote.ask_price,
1260                self.instrument_id
1261            );
1262        }
1263
1264        let bid = BookOrder::new(
1265            OrderSide::Buy,
1266            quote.bid_price,
1267            quote.bid_size,
1268            OrderSide::Buy as u64,
1269        );
1270
1271        let ask = BookOrder::new(
1272            OrderSide::Sell,
1273            quote.ask_price,
1274            quote.ask_size,
1275            OrderSide::Sell as u64,
1276        );
1277
1278        self.update_book_bid(bid);
1279        self.update_book_ask(ask);
1280
1281        self.increment(self.sequence.saturating_add(1), quote.ts_event, 0);
1282
1283        Ok(())
1284    }
1285
1286    /// Updates L1 book state from a trade tick. Only valid for `L1_MBP` book type.
1287    ///
1288    /// # Errors
1289    ///
1290    /// Returns an error if the book type is not `L1_MBP`.
1291    pub fn update_trade_tick(&mut self, trade: &TradeTick) -> Result<(), InvalidBookOperation> {
1292        if self.book_type != BookType::L1_MBP {
1293            return Err(InvalidBookOperation::Update(self.book_type));
1294        }
1295
1296        if trade.ts_event < self.ts_last {
1297            log::warn!(
1298                "Skipping stale trade: ts_event {} < ts_last {} (instrument_id={})",
1299                trade.ts_event,
1300                self.ts_last,
1301                self.instrument_id
1302            );
1303            return Ok(());
1304        }
1305
1306        // Prices can be zero or negative for certain instruments (options, spreads)
1307        debug_assert!(
1308            !trade.price.is_undefined() && !trade.price.is_error(),
1309            "Trade has invalid/uninitialized price: {}",
1310            trade.price
1311        );
1312
1313        // TradeTick enforces positive size at construction, but assert as sanity check
1314        debug_assert!(
1315            trade.size.is_positive(),
1316            "Trade has non-positive size: {}",
1317            trade.size
1318        );
1319
1320        let bid = BookOrder::new(
1321            OrderSide::Buy,
1322            trade.price,
1323            trade.size,
1324            OrderSide::Buy as u64,
1325        );
1326
1327        let ask = BookOrder::new(
1328            OrderSide::Sell,
1329            trade.price,
1330            trade.size,
1331            OrderSide::Sell as u64,
1332        );
1333
1334        self.update_book_bid(bid);
1335        self.update_book_ask(ask);
1336
1337        self.increment(self.sequence.saturating_add(1), trade.ts_event, 0);
1338
1339        Ok(())
1340    }
1341
1342    fn update_book_bid(&mut self, order: BookOrder) {
1343        self.bids.replace_l1(order);
1344    }
1345
1346    fn update_book_ask(&mut self, order: BookOrder) {
1347        self.asks.replace_l1(order);
1348    }
1349
1350    /// Replays `deltas` through a fresh book of the given type and returns
1351    /// a [`QuoteTick`] for every best-bid/ask price change.
1352    ///
1353    /// # Panics
1354    ///
1355    /// Panics if `deltas` is empty.
1356    #[must_use]
1357    pub fn deltas_to_quotes(book_type: BookType, deltas: &[OrderBookDelta]) -> Vec<QuoteTick> {
1358        assert!(!deltas.is_empty(), "`deltas` must not be empty");
1359
1360        let instrument_id = deltas[0].instrument_id;
1361        let mut book = Self::new(instrument_id, book_type);
1362        let mut quotes = Vec::new();
1363        let mut last_bbo: Option<(Price, Price)> = None;
1364
1365        for delta in deltas {
1366            book.apply_delta(delta).unwrap();
1367            let Some((bid_px, ask_px)) = book.best_bid_price().zip(book.best_ask_price()) else {
1368                last_bbo = None;
1369                continue;
1370            };
1371
1372            let bbo = (bid_px, ask_px);
1373
1374            if last_bbo == Some(bbo) {
1375                continue;
1376            }
1377
1378            last_bbo = Some(bbo);
1379            let bid_level = book.bids.top().unwrap();
1380            let ask_level = book.asks.top().unwrap();
1381            let precision = bid_level.first().unwrap().size.precision;
1382            let bid_sz = Quantity::from_raw(bid_level.size_raw(), precision);
1383            let ask_sz = Quantity::from_raw(ask_level.size_raw(), precision);
1384            let quote = QuoteTick::new(
1385                instrument_id,
1386                bid_px,
1387                ask_px,
1388                bid_sz,
1389                ask_sz,
1390                delta.ts_event,
1391                delta.ts_init,
1392            );
1393
1394            quotes.push(quote);
1395        }
1396
1397        quotes
1398    }
1399}
1400
1401fn filter_quantities(
1402    public_map: &mut IndexMap<Decimal, Decimal>,
1403    own_map: IndexMap<Decimal, Decimal>,
1404) {
1405    for (price, own_size) in own_map {
1406        if let Some(public_size) = public_map.get_mut(&price) {
1407            *public_size = (*public_size - own_size).max(Decimal::ZERO);
1408
1409            if *public_size == Decimal::ZERO {
1410                public_map.shift_remove(&price);
1411            }
1412        }
1413    }
1414}
1415
1416fn group_levels<'a>(
1417    levels_iter: impl Iterator<Item = &'a BookLevel>,
1418    group_size: Decimal,
1419    depth: Option<usize>,
1420    is_bid: bool,
1421) -> IndexMap<Decimal, Decimal> {
1422    if group_size <= Decimal::ZERO {
1423        log::warn!("Invalid group_size: {group_size}, must be positive; returning empty map");
1424        return IndexMap::new();
1425    }
1426
1427    let mut levels = IndexMap::new();
1428    let depth = depth.unwrap_or(usize::MAX);
1429
1430    for level in levels_iter {
1431        let price = level.price.value.as_decimal();
1432        let grouped_price = if is_bid {
1433            (price / group_size).floor() * group_size
1434        } else {
1435            (price / group_size).ceil() * group_size
1436        };
1437        let size = level.size_decimal();
1438
1439        levels
1440            .entry(grouped_price)
1441            .and_modify(|total| *total += size)
1442            .or_insert(size);
1443
1444        if levels.len() > depth {
1445            levels.pop();
1446            break;
1447        }
1448    }
1449
1450    levels
1451}