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