nautilus_model/orderbook/
book.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2025 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::UnixNanos;
23use rust_decimal::Decimal;
24
25use super::{
26    aggregation::pre_process_order, analysis, display::pprint_book, level::BookLevel,
27    own::OwnOrderBook,
28};
29use crate::{
30    data::{BookOrder, OrderBookDelta, OrderBookDeltas, OrderBookDepth10, QuoteTick, TradeTick},
31    enums::{BookAction, BookType, OrderSide, OrderSideSpecified, OrderStatus},
32    identifiers::InstrumentId,
33    orderbook::{BookIntegrityError, InvalidBookOperation, ladder::BookLadder},
34    types::{
35        Price, Quantity,
36        price::{PRICE_ERROR, PRICE_UNDEF},
37    },
38};
39
40/// Provides a high-performance, versatile order book.
41///
42/// Maintains buy (bid) and sell (ask) orders in price-time priority, supporting multiple
43/// market data formats:
44/// - L3 (MBO): Market By Order - tracks individual orders with unique IDs.
45/// - L2 (MBP): Market By Price - aggregates orders at each price level.
46/// - L1 (MBP): Top-of-Book - maintains only the best bid and ask prices.
47#[derive(Clone, Debug)]
48#[cfg_attr(
49    feature = "python",
50    pyo3::pyclass(module = "nautilus_trader.core.nautilus_pyo3.model")
51)]
52pub struct OrderBook {
53    /// The instrument ID for the order book.
54    pub instrument_id: InstrumentId,
55    /// The order book type (MBP types will aggregate orders).
56    pub book_type: BookType,
57    /// The last event sequence number for the order book.
58    pub sequence: u64,
59    /// The timestamp of the last event applied to the order book.
60    pub ts_last: UnixNanos,
61    /// The current count of updates applied to the order book.
62    pub update_count: u64,
63    pub(crate) bids: BookLadder,
64    pub(crate) asks: BookLadder,
65}
66
67impl PartialEq for OrderBook {
68    fn eq(&self, other: &Self) -> bool {
69        self.instrument_id == other.instrument_id && self.book_type == other.book_type
70    }
71}
72
73impl Eq for OrderBook {}
74
75impl Display for OrderBook {
76    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
77        write!(
78            f,
79            "{}(instrument_id={}, book_type={}, update_count={})",
80            stringify!(OrderBook),
81            self.instrument_id,
82            self.book_type,
83            self.update_count,
84        )
85    }
86}
87
88impl OrderBook {
89    /// Creates a new [`OrderBook`] instance.
90    #[must_use]
91    pub fn new(instrument_id: InstrumentId, book_type: BookType) -> Self {
92        Self {
93            instrument_id,
94            book_type,
95            sequence: 0,
96            ts_last: UnixNanos::default(),
97            update_count: 0,
98            bids: BookLadder::new(OrderSideSpecified::Buy, book_type),
99            asks: BookLadder::new(OrderSideSpecified::Sell, book_type),
100        }
101    }
102
103    /// Resets the order book to its initial empty state.
104    pub fn reset(&mut self) {
105        self.bids.clear();
106        self.asks.clear();
107        self.sequence = 0;
108        self.ts_last = UnixNanos::default();
109        self.update_count = 0;
110    }
111
112    /// Adds an order to the book after preprocessing based on book type.
113    pub fn add(&mut self, order: BookOrder, flags: u8, sequence: u64, ts_event: UnixNanos) {
114        let order = pre_process_order(self.book_type, order, flags);
115        match order.side.as_specified() {
116            OrderSideSpecified::Buy => self.bids.add(order, flags),
117            OrderSideSpecified::Sell => self.asks.add(order, flags),
118        }
119
120        self.increment(sequence, ts_event);
121    }
122
123    /// Updates an existing order in the book after preprocessing based on book type.
124    pub fn update(&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.as_specified() {
127            OrderSideSpecified::Buy => self.bids.update(order, flags),
128            OrderSideSpecified::Sell => self.asks.update(order, flags),
129        }
130
131        self.increment(sequence, ts_event);
132    }
133
134    /// Deletes an order from the book after preprocessing based on book type.
135    pub fn delete(&mut self, order: BookOrder, flags: u8, sequence: u64, ts_event: UnixNanos) {
136        let order = pre_process_order(self.book_type, order, flags);
137        match order.side.as_specified() {
138            OrderSideSpecified::Buy => self.bids.delete(order, sequence, ts_event),
139            OrderSideSpecified::Sell => self.asks.delete(order, sequence, ts_event),
140        }
141
142        self.increment(sequence, ts_event);
143    }
144
145    /// Clears all orders from both sides of the book.
146    pub fn clear(&mut self, sequence: u64, ts_event: UnixNanos) {
147        self.bids.clear();
148        self.asks.clear();
149        self.increment(sequence, ts_event);
150    }
151
152    /// Clears all bid orders from the book.
153    pub fn clear_bids(&mut self, sequence: u64, ts_event: UnixNanos) {
154        self.bids.clear();
155        self.increment(sequence, ts_event);
156    }
157
158    /// Clears all ask orders from the book.
159    pub fn clear_asks(&mut self, sequence: u64, ts_event: UnixNanos) {
160        self.asks.clear();
161        self.increment(sequence, ts_event);
162    }
163
164    /// Removes overlapped bid/ask levels when the book is strictly crossed (best bid > best ask)
165    ///
166    /// - Acts only when both sides exist and the book is crossed.
167    /// - Deletes by removing whole price levels via the ladder API to preserve invariants.
168    /// - `side=None` or `NoOrderSide` clears both overlapped ranges (conservative, may widen spread).
169    /// - `side=Buy` clears crossed bids only; side=Sell clears crossed asks only.
170    /// - Returns removed price levels (crossed bids first, then crossed asks), or None if nothing removed.
171    pub fn clear_stale_levels(&mut self, side: Option<OrderSide>) -> Option<Vec<BookLevel>> {
172        if self.book_type == BookType::L1_MBP {
173            // L1_MBP maintains a single top-of-book price per side; nothing to do
174            return None;
175        }
176
177        let (Some(best_bid), Some(best_ask)) = (self.best_bid_price(), self.best_ask_price())
178        else {
179            return None;
180        };
181
182        if best_bid <= best_ask {
183            return None;
184        }
185
186        let mut removed_levels = Vec::new();
187        let mut clear_bids = false;
188        let mut clear_asks = false;
189
190        match side {
191            Some(OrderSide::Buy) => clear_bids = true,
192            Some(OrderSide::Sell) => clear_asks = true,
193            _ => {
194                clear_bids = true;
195                clear_asks = true;
196            }
197        }
198
199        // Collect prices to remove for asks (prices <= best_bid)
200        let mut ask_prices_to_remove = Vec::new();
201        if clear_asks {
202            for bp in self.asks.levels.keys() {
203                if bp.value <= best_bid {
204                    ask_prices_to_remove.push(*bp);
205                } else {
206                    break;
207                }
208            }
209        }
210
211        // Collect prices to remove for bids (prices >= best_ask)
212        let mut bid_prices_to_remove = Vec::new();
213        if clear_bids {
214            for bp in self.bids.levels.keys() {
215                if bp.value >= best_ask {
216                    bid_prices_to_remove.push(*bp);
217                } else {
218                    break;
219                }
220            }
221        }
222
223        if ask_prices_to_remove.is_empty() && bid_prices_to_remove.is_empty() {
224            return None;
225        }
226
227        let bid_count = bid_prices_to_remove.len();
228        let ask_count = ask_prices_to_remove.len();
229
230        // Remove and collect bid levels
231        for price in bid_prices_to_remove {
232            if let Some(level) = self.bids.remove_level(price) {
233                removed_levels.push(level);
234            }
235        }
236
237        // Remove and collect ask levels
238        for price in ask_prices_to_remove {
239            if let Some(level) = self.asks.remove_level(price) {
240                removed_levels.push(level);
241            }
242        }
243
244        self.increment(self.sequence, self.ts_last);
245
246        if removed_levels.is_empty() {
247            None
248        } else {
249            let total_orders: usize = removed_levels.iter().map(|level| level.orders.len()).sum();
250
251            log::warn!(
252                "Removed {} stale/crossed levels (instrument_id={}, bid_levels={}, ask_levels={}, total_orders={}), book was crossed with best_bid={} > best_ask={}",
253                removed_levels.len(),
254                self.instrument_id,
255                bid_count,
256                ask_count,
257                total_orders,
258                best_bid,
259                best_ask
260            );
261
262            Some(removed_levels)
263        }
264    }
265
266    /// Applies a single order book delta operation.
267    ///
268    /// # Errors
269    ///
270    /// Returns an error if:
271    /// - The delta's instrument ID does not match this book's instrument ID.
272    /// - An `Add` is given with `NoOrderSide` (either explicitly or because the cache lookup failed).
273    /// - After resolution the delta still has `NoOrderSide` but its action is not `Clear`.
274    pub fn apply_delta(&mut self, delta: &OrderBookDelta) -> Result<(), BookIntegrityError> {
275        if delta.instrument_id != self.instrument_id {
276            return Err(BookIntegrityError::InstrumentMismatch(
277                self.instrument_id,
278                delta.instrument_id,
279            ));
280        }
281        self.apply_delta_unchecked(delta)
282    }
283
284    /// Applies a single order book delta operation without instrument ID validation.
285    ///
286    /// "Unchecked" refers only to skipping the instrument ID match - other validations
287    /// still apply and errors are still returned. This exists because `Ustr` interning
288    /// is not shared across FFI boundaries, causing pointer-based equality to fail even
289    /// when string values match. This limitation may be resolved in a future version.
290    ///
291    /// # Errors
292    ///
293    /// Returns an error if:
294    /// - An `Add` is given with `NoOrderSide` (either explicitly or because the cache lookup failed).
295    /// - After resolution the delta still has `NoOrderSide` but its action is not `Clear`.
296    pub fn apply_delta_unchecked(
297        &mut self,
298        delta: &OrderBookDelta,
299    ) -> Result<(), BookIntegrityError> {
300        let mut order = delta.order;
301
302        if order.side == OrderSide::NoOrderSide && order.order_id != 0 {
303            match self.resolve_no_side_order(order) {
304                Ok(resolved) => order = resolved,
305                Err(BookIntegrityError::OrderNotFoundForSideResolution(order_id)) => {
306                    match delta.action {
307                        BookAction::Add => return Err(BookIntegrityError::NoOrderSide),
308                        BookAction::Update | BookAction::Delete => {
309                            // Already consistent
310                            log::debug!(
311                                "Skipping {:?} for unknown order_id={order_id}",
312                                delta.action
313                            );
314                            return Ok(());
315                        }
316                        BookAction::Clear => {} // Won't hit this (order_id != 0)
317                    }
318                }
319                Err(e) => return Err(e),
320            }
321        }
322
323        if order.side == OrderSide::NoOrderSide && delta.action != BookAction::Clear {
324            return Err(BookIntegrityError::NoOrderSide);
325        }
326
327        let flags = delta.flags;
328        let sequence = delta.sequence;
329        let ts_event = delta.ts_event;
330
331        match delta.action {
332            BookAction::Add => self.add(order, flags, sequence, ts_event),
333            BookAction::Update => self.update(order, flags, sequence, ts_event),
334            BookAction::Delete => self.delete(order, flags, sequence, ts_event),
335            BookAction::Clear => self.clear(sequence, ts_event),
336        }
337
338        Ok(())
339    }
340
341    /// Applies multiple order book delta operations.
342    ///
343    /// # Errors
344    ///
345    /// Returns an error if:
346    /// - The deltas' instrument ID does not match this book's instrument ID.
347    /// - Any individual delta application fails (see [`Self::apply_delta`]).
348    pub fn apply_deltas(&mut self, deltas: &OrderBookDeltas) -> Result<(), BookIntegrityError> {
349        if deltas.instrument_id != self.instrument_id {
350            return Err(BookIntegrityError::InstrumentMismatch(
351                self.instrument_id,
352                deltas.instrument_id,
353            ));
354        }
355        self.apply_deltas_unchecked(deltas)
356    }
357
358    /// Applies multiple order book delta operations without instrument ID validation.
359    ///
360    /// See [`Self::apply_delta_unchecked`] for details on why this function exists.
361    ///
362    /// # Errors
363    ///
364    /// Returns an error if any individual delta application fails.
365    pub fn apply_deltas_unchecked(
366        &mut self,
367        deltas: &OrderBookDeltas,
368    ) -> Result<(), BookIntegrityError> {
369        for delta in &deltas.deltas {
370            self.apply_delta_unchecked(delta)?;
371        }
372        Ok(())
373    }
374
375    /// Replaces current book state with a depth snapshot.
376    ///
377    /// # Errors
378    ///
379    /// Returns an error if the depth's instrument ID does not match this book's instrument ID.
380    pub fn apply_depth(&mut self, depth: &OrderBookDepth10) -> Result<(), BookIntegrityError> {
381        if depth.instrument_id != self.instrument_id {
382            return Err(BookIntegrityError::InstrumentMismatch(
383                self.instrument_id,
384                depth.instrument_id,
385            ));
386        }
387        self.apply_depth_unchecked(depth)
388    }
389
390    /// Replaces current book state with a depth snapshot without instrument ID validation.
391    ///
392    /// See [`Self::apply_delta_unchecked`] for details on why this function exists.
393    ///
394    /// # Errors
395    ///
396    /// This function currently does not return errors, but returns `Result` for API consistency.
397    pub fn apply_depth_unchecked(
398        &mut self,
399        depth: &OrderBookDepth10,
400    ) -> Result<(), BookIntegrityError> {
401        self.bids.clear();
402        self.asks.clear();
403
404        for order in depth.bids {
405            // Skip padding entries
406            if order.side == OrderSide::NoOrderSide || !order.size.is_positive() {
407                continue;
408            }
409
410            debug_assert_eq!(
411                order.side,
412                OrderSide::Buy,
413                "Bid order must have Buy side, was {:?}",
414                order.side
415            );
416
417            let order = pre_process_order(self.book_type, order, depth.flags);
418            self.bids.add(order, depth.flags);
419        }
420
421        for order in depth.asks {
422            // Skip padding entries
423            if order.side == OrderSide::NoOrderSide || !order.size.is_positive() {
424                continue;
425            }
426
427            debug_assert_eq!(
428                order.side,
429                OrderSide::Sell,
430                "Ask order must have Sell side, was {:?}",
431                order.side
432            );
433
434            let order = pre_process_order(self.book_type, order, depth.flags);
435            self.asks.add(order, depth.flags);
436        }
437
438        self.increment(depth.sequence, depth.ts_event);
439
440        Ok(())
441    }
442
443    fn resolve_no_side_order(&self, mut order: BookOrder) -> Result<BookOrder, BookIntegrityError> {
444        let resolved_side = self
445            .bids
446            .cache
447            .get(&order.order_id)
448            .or_else(|| self.asks.cache.get(&order.order_id))
449            .map(|book_price| match book_price.side {
450                OrderSideSpecified::Buy => OrderSide::Buy,
451                OrderSideSpecified::Sell => OrderSide::Sell,
452            })
453            .ok_or(BookIntegrityError::OrderNotFoundForSideResolution(
454                order.order_id,
455            ))?;
456
457        order.side = resolved_side;
458
459        Ok(order)
460    }
461
462    /// Returns an iterator over bid price levels.
463    pub fn bids(&self, depth: Option<usize>) -> impl Iterator<Item = &BookLevel> {
464        self.bids.levels.values().take(depth.unwrap_or(usize::MAX))
465    }
466
467    /// Returns an iterator over ask price levels.
468    pub fn asks(&self, depth: Option<usize>) -> impl Iterator<Item = &BookLevel> {
469        self.asks.levels.values().take(depth.unwrap_or(usize::MAX))
470    }
471
472    /// Returns bid price levels as a map of price to size.
473    pub fn bids_as_map(&self, depth: Option<usize>) -> IndexMap<Decimal, Decimal> {
474        self.bids(depth)
475            .map(|level| (level.price.value.as_decimal(), level.size_decimal()))
476            .collect()
477    }
478
479    /// Returns ask price levels as a map of price to size.
480    pub fn asks_as_map(&self, depth: Option<usize>) -> IndexMap<Decimal, Decimal> {
481        self.asks(depth)
482            .map(|level| (level.price.value.as_decimal(), level.size_decimal()))
483            .collect()
484    }
485
486    /// Groups bid quantities by price into buckets, limited by depth.
487    pub fn group_bids(
488        &self,
489        group_size: Decimal,
490        depth: Option<usize>,
491    ) -> IndexMap<Decimal, Decimal> {
492        group_levels(self.bids(None), group_size, depth, true)
493    }
494
495    /// Groups ask quantities by price into buckets, limited by depth.
496    pub fn group_asks(
497        &self,
498        group_size: Decimal,
499        depth: Option<usize>,
500    ) -> IndexMap<Decimal, Decimal> {
501        group_levels(self.asks(None), group_size, depth, false)
502    }
503
504    /// Maps bid prices to total public size per level, excluding own orders up to a depth limit.
505    ///
506    /// With `own_book`, subtracts own order sizes, filtered by `status` if provided.
507    /// Uses `accepted_buffer_ns` to include only orders accepted at least that many
508    /// nanoseconds before `now` (defaults to now).
509    pub fn bids_filtered_as_map(
510        &self,
511        depth: Option<usize>,
512        own_book: Option<&OwnOrderBook>,
513        status: Option<AHashSet<OrderStatus>>,
514        accepted_buffer_ns: Option<u64>,
515        now: Option<u64>,
516    ) -> IndexMap<Decimal, Decimal> {
517        let mut public_map = self
518            .bids(depth)
519            .map(|level| (level.price.value.as_decimal(), level.size_decimal()))
520            .collect::<IndexMap<Decimal, Decimal>>();
521
522        if let Some(own_book) = own_book {
523            filter_quantities(
524                &mut public_map,
525                own_book.bid_quantity(status, None, None, accepted_buffer_ns, now),
526            );
527        }
528
529        public_map
530    }
531
532    /// Maps ask prices to total public size per level, excluding own orders up to a depth limit.
533    ///
534    /// With `own_book`, subtracts own order sizes, filtered by `status` if provided.
535    /// Uses `accepted_buffer_ns` to include only orders accepted at least that many
536    /// nanoseconds before `now` (defaults to now).
537    pub fn asks_filtered_as_map(
538        &self,
539        depth: Option<usize>,
540        own_book: Option<&OwnOrderBook>,
541        status: Option<AHashSet<OrderStatus>>,
542        accepted_buffer_ns: Option<u64>,
543        now: Option<u64>,
544    ) -> IndexMap<Decimal, Decimal> {
545        let mut public_map = self
546            .asks(depth)
547            .map(|level| (level.price.value.as_decimal(), level.size_decimal()))
548            .collect::<IndexMap<Decimal, Decimal>>();
549
550        if let Some(own_book) = own_book {
551            filter_quantities(
552                &mut public_map,
553                own_book.ask_quantity(status, None, None, accepted_buffer_ns, now),
554            );
555        }
556
557        public_map
558    }
559
560    /// Groups bid quantities into price buckets, truncating to a maximum depth, excluding own orders.
561    ///
562    /// With `own_book`, subtracts own order sizes, filtered by `status` if provided.
563    /// Uses `accepted_buffer_ns` to include only orders accepted at least that many
564    /// nanoseconds before `now` (defaults to now).
565    pub fn group_bids_filtered(
566        &self,
567        group_size: Decimal,
568        depth: Option<usize>,
569        own_book: Option<&OwnOrderBook>,
570        status: Option<AHashSet<OrderStatus>>,
571        accepted_buffer_ns: Option<u64>,
572        now: Option<u64>,
573    ) -> IndexMap<Decimal, Decimal> {
574        let mut public_map = group_levels(self.bids(None), group_size, depth, true);
575
576        if let Some(own_book) = own_book {
577            filter_quantities(
578                &mut public_map,
579                own_book.bid_quantity(status, depth, Some(group_size), accepted_buffer_ns, now),
580            );
581        }
582
583        public_map
584    }
585
586    /// Groups ask quantities into price buckets, truncating to a maximum depth, excluding own orders.
587    ///
588    /// With `own_book`, subtracts own order sizes, filtered by `status` if provided.
589    /// Uses `accepted_buffer_ns` to include only orders accepted at least that many
590    /// nanoseconds before `now` (defaults to now).
591    pub fn group_asks_filtered(
592        &self,
593        group_size: Decimal,
594        depth: Option<usize>,
595        own_book: Option<&OwnOrderBook>,
596        status: Option<AHashSet<OrderStatus>>,
597        accepted_buffer_ns: Option<u64>,
598        now: Option<u64>,
599    ) -> IndexMap<Decimal, Decimal> {
600        let mut public_map = group_levels(self.asks(None), group_size, depth, false);
601
602        if let Some(own_book) = own_book {
603            filter_quantities(
604                &mut public_map,
605                own_book.ask_quantity(status, depth, Some(group_size), accepted_buffer_ns, now),
606            );
607        }
608
609        public_map
610    }
611
612    /// Returns true if the book has any bid orders.
613    #[must_use]
614    pub fn has_bid(&self) -> bool {
615        self.bids.top().is_some_and(|top| !top.orders.is_empty())
616    }
617
618    /// Returns true if the book has any ask orders.
619    #[must_use]
620    pub fn has_ask(&self) -> bool {
621        self.asks.top().is_some_and(|top| !top.orders.is_empty())
622    }
623
624    /// Returns the best bid price if available.
625    #[must_use]
626    pub fn best_bid_price(&self) -> Option<Price> {
627        self.bids.top().map(|top| top.price.value)
628    }
629
630    /// Returns the best ask price if available.
631    #[must_use]
632    pub fn best_ask_price(&self) -> Option<Price> {
633        self.asks.top().map(|top| top.price.value)
634    }
635
636    /// Returns the size at the best bid price if available.
637    #[must_use]
638    pub fn best_bid_size(&self) -> Option<Quantity> {
639        self.bids
640            .top()
641            .and_then(|top| top.first().map(|order| order.size))
642    }
643
644    /// Returns the size at the best ask price if available.
645    #[must_use]
646    pub fn best_ask_size(&self) -> Option<Quantity> {
647        self.asks
648            .top()
649            .and_then(|top| top.first().map(|order| order.size))
650    }
651
652    /// Returns the spread between best ask and bid prices if both exist.
653    #[must_use]
654    pub fn spread(&self) -> Option<f64> {
655        match (self.best_ask_price(), self.best_bid_price()) {
656            (Some(ask), Some(bid)) => Some(ask.as_f64() - bid.as_f64()),
657            _ => None,
658        }
659    }
660
661    /// Returns the midpoint between best ask and bid prices if both exist.
662    #[must_use]
663    pub fn midpoint(&self) -> Option<f64> {
664        match (self.best_ask_price(), self.best_bid_price()) {
665            (Some(ask), Some(bid)) => Some((ask.as_f64() + bid.as_f64()) / 2.0),
666            _ => None,
667        }
668    }
669
670    /// Calculates the average price to fill the specified quantity.
671    #[must_use]
672    pub fn get_avg_px_for_quantity(&self, qty: Quantity, order_side: OrderSide) -> f64 {
673        let levels = match order_side.as_specified() {
674            OrderSideSpecified::Buy => &self.asks.levels,
675            OrderSideSpecified::Sell => &self.bids.levels,
676        };
677
678        analysis::get_avg_px_for_quantity(qty, levels)
679    }
680
681    /// Calculates average price and quantity for target exposure. Returns (price, quantity, executed_exposure).
682    #[must_use]
683    pub fn get_avg_px_qty_for_exposure(
684        &self,
685        target_exposure: Quantity,
686        order_side: OrderSide,
687    ) -> (f64, f64, f64) {
688        let levels = match order_side.as_specified() {
689            OrderSideSpecified::Buy => &self.asks.levels,
690            OrderSideSpecified::Sell => &self.bids.levels,
691        };
692
693        analysis::get_avg_px_qty_for_exposure(target_exposure, levels)
694    }
695
696    /// Returns the total quantity available at specified price level.
697    #[must_use]
698    pub fn get_quantity_for_price(&self, price: Price, order_side: OrderSide) -> f64 {
699        let levels = match order_side.as_specified() {
700            OrderSideSpecified::Buy => &self.asks.levels,
701            OrderSideSpecified::Sell => &self.bids.levels,
702        };
703
704        analysis::get_quantity_for_price(price, order_side, levels)
705    }
706
707    /// Simulates fills for an order, returning list of (price, quantity) tuples.
708    #[must_use]
709    pub fn simulate_fills(&self, order: &BookOrder) -> Vec<(Price, Quantity)> {
710        match order.side.as_specified() {
711            OrderSideSpecified::Buy => self.asks.simulate_fills(order),
712            OrderSideSpecified::Sell => self.bids.simulate_fills(order),
713        }
714    }
715
716    /// Return a formatted string representation of the order book.
717    #[must_use]
718    pub fn pprint(&self, num_levels: usize, group_size: Option<Decimal>) -> String {
719        pprint_book(self, num_levels, group_size)
720    }
721
722    fn increment(&mut self, sequence: u64, ts_event: UnixNanos) {
723        // Critical invariant checks: panic in debug, warn in release
724        if sequence < self.sequence {
725            let msg = format!(
726                "Sequence number should not go backwards: old={}, new={}",
727                self.sequence, sequence
728            );
729            debug_assert!(sequence >= self.sequence, "{}", msg);
730            log::warn!("{msg}");
731        }
732
733        if ts_event < self.ts_last {
734            let msg = format!(
735                "Timestamp should not go backwards: old={}, new={}",
736                self.ts_last, ts_event
737            );
738            debug_assert!(ts_event >= self.ts_last, "{}", msg);
739            log::warn!("{msg}");
740        }
741
742        if self.update_count == u64::MAX {
743            // Debug assert to catch in development
744            debug_assert!(
745                self.update_count < u64::MAX,
746                "Update count at u64::MAX limit (about to overflow): {}",
747                self.update_count
748            );
749
750            // Spam warnings in production when at/near u64::MAX
751            log::warn!(
752                "Update count at u64::MAX: {} (instrument_id={})",
753                self.update_count,
754                self.instrument_id
755            );
756        }
757
758        self.sequence = sequence;
759        self.ts_last = ts_event;
760        self.update_count = self.update_count.saturating_add(1);
761    }
762
763    /// Updates L1 book state from a quote tick. Only valid for L1_MBP book type.
764    ///
765    /// # Errors
766    ///
767    /// Returns an error if the book type is not `L1_MBP`.
768    pub fn update_quote_tick(&mut self, quote: &QuoteTick) -> Result<(), InvalidBookOperation> {
769        if self.book_type != BookType::L1_MBP {
770            return Err(InvalidBookOperation::Update(self.book_type));
771        }
772
773        // Crossed quotes (bid > ask) can occur temporarily in volatile markets
774        if cfg!(debug_assertions) && quote.bid_price > quote.ask_price {
775            log::warn!(
776                "Quote has crossed prices: bid={}, ask={} for {}",
777                quote.bid_price,
778                quote.ask_price,
779                self.instrument_id
780            );
781        }
782
783        let bid = BookOrder::new(
784            OrderSide::Buy,
785            quote.bid_price,
786            quote.bid_size,
787            OrderSide::Buy as u64,
788        );
789
790        let ask = BookOrder::new(
791            OrderSide::Sell,
792            quote.ask_price,
793            quote.ask_size,
794            OrderSide::Sell as u64,
795        );
796
797        self.update_book_bid(bid, quote.ts_event);
798        self.update_book_ask(ask, quote.ts_event);
799
800        self.increment(self.sequence.saturating_add(1), quote.ts_event);
801
802        Ok(())
803    }
804
805    /// Updates L1 book state from a trade tick. Only valid for L1_MBP book type.
806    ///
807    /// # Errors
808    ///
809    /// Returns an error if the book type is not `L1_MBP`.
810    pub fn update_trade_tick(&mut self, trade: &TradeTick) -> Result<(), InvalidBookOperation> {
811        if self.book_type != BookType::L1_MBP {
812            return Err(InvalidBookOperation::Update(self.book_type));
813        }
814
815        // Prices can be zero or negative for certain instruments (options, spreads)
816        debug_assert!(
817            trade.price.raw != PRICE_UNDEF && trade.price.raw != PRICE_ERROR,
818            "Trade has invalid/uninitialized price: {}",
819            trade.price
820        );
821
822        // TradeTick enforces positive size at construction, but assert as sanity check
823        debug_assert!(
824            trade.size.is_positive(),
825            "Trade has non-positive size: {}",
826            trade.size
827        );
828
829        let bid = BookOrder::new(
830            OrderSide::Buy,
831            trade.price,
832            trade.size,
833            OrderSide::Buy as u64,
834        );
835
836        let ask = BookOrder::new(
837            OrderSide::Sell,
838            trade.price,
839            trade.size,
840            OrderSide::Sell as u64,
841        );
842
843        self.update_book_bid(bid, trade.ts_event);
844        self.update_book_ask(ask, trade.ts_event);
845
846        self.increment(self.sequence.saturating_add(1), trade.ts_event);
847
848        Ok(())
849    }
850
851    fn update_book_bid(&mut self, order: BookOrder, ts_event: UnixNanos) {
852        if let Some(top_bids) = self.bids.top()
853            && let Some(top_bid) = top_bids.first()
854        {
855            self.bids.remove_order(top_bid.order_id, 0, ts_event);
856        }
857        self.bids.add(order, 0); // Internal replacement, no F_MBP flags
858    }
859
860    fn update_book_ask(&mut self, order: BookOrder, ts_event: UnixNanos) {
861        if let Some(top_asks) = self.asks.top()
862            && let Some(top_ask) = top_asks.first()
863        {
864            self.asks.remove_order(top_ask.order_id, 0, ts_event);
865        }
866        self.asks.add(order, 0); // Internal replacement, no F_MBP flags
867    }
868}
869
870fn filter_quantities(
871    public_map: &mut IndexMap<Decimal, Decimal>,
872    own_map: IndexMap<Decimal, Decimal>,
873) {
874    for (price, own_size) in own_map {
875        if let Some(public_size) = public_map.get_mut(&price) {
876            *public_size = (*public_size - own_size).max(Decimal::ZERO);
877
878            if *public_size == Decimal::ZERO {
879                public_map.shift_remove(&price);
880            }
881        }
882    }
883}
884
885fn group_levels<'a>(
886    levels_iter: impl Iterator<Item = &'a BookLevel>,
887    group_size: Decimal,
888    depth: Option<usize>,
889    is_bid: bool,
890) -> IndexMap<Decimal, Decimal> {
891    if group_size <= Decimal::ZERO {
892        log::error!("Invalid group_size: {group_size}, must be positive; returning empty map");
893        return IndexMap::new();
894    }
895
896    let mut levels = IndexMap::new();
897    let depth = depth.unwrap_or(usize::MAX);
898
899    for level in levels_iter {
900        let price = level.price.value.as_decimal();
901        let grouped_price = if is_bid {
902            (price / group_size).floor() * group_size
903        } else {
904            (price / group_size).ceil() * group_size
905        };
906        let size = level.size_decimal();
907
908        levels
909            .entry(grouped_price)
910            .and_modify(|total| *total += size)
911            .or_insert(size);
912
913        if levels.len() > depth {
914            levels.pop();
915            break;
916        }
917    }
918
919    levels
920}