Skip to main content

fin_stream/book/
mod.rs

1//! Order book — delta streaming with full reconstruction.
2//!
3//! ## Responsibility
4//! Maintain a live order book per symbol by applying incremental deltas
5//! received from exchange WebSocket feeds. Supports full snapshot reset
6//! and crossed-book detection.
7//!
8//! ## Guarantees
9//! - Deterministic: applying the same delta sequence always yields the same book
10//! - Non-panicking: all mutations return Result
11//! - Single-owner mutable access: all mutation methods take `&mut self`; wrap
12//!   in `Arc<Mutex<OrderBook>>` or `Arc<RwLock<OrderBook>>` for shared
13//!   concurrent access across threads
14
15use crate::error::StreamError;
16use rust_decimal::Decimal;
17use std::collections::BTreeMap;
18
19/// Side of the order book.
20#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
21pub enum BookSide {
22    /// Bid (buy) side.
23    Bid,
24    /// Ask (sell) side.
25    Ask,
26}
27
28/// A single price level in the order book.
29#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
30pub struct PriceLevel {
31    /// Price of this level.
32    pub price: Decimal,
33    /// Resting quantity at this price.
34    pub quantity: Decimal,
35}
36
37impl PriceLevel {
38    /// Construct a price level from a price and resting quantity.
39    pub fn new(price: Decimal, quantity: Decimal) -> Self {
40        Self { price, quantity }
41    }
42
43    /// Notional value of this level: `price × quantity`.
44    pub fn notional(&self) -> Decimal {
45        self.price * self.quantity
46    }
47}
48
49/// Incremental order book update.
50#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
51pub struct BookDelta {
52    /// Symbol this delta applies to.
53    pub symbol: String,
54    /// Side of the book (bid or ask).
55    pub side: BookSide,
56    /// Price level to update. `quantity == 0` means remove the level.
57    pub price: Decimal,
58    /// New resting quantity at this price. Zero removes the level.
59    pub quantity: Decimal,
60    /// Optional exchange-assigned sequence number for gap detection.
61    pub sequence: Option<u64>,
62}
63
64impl BookDelta {
65    /// Construct a delta without a sequence number.
66    ///
67    /// Use [`BookDelta::with_sequence`] to attach the exchange sequence number
68    /// when available; sequenced deltas enable gap detection.
69    pub fn new(
70        symbol: impl Into<String>,
71        side: BookSide,
72        price: Decimal,
73        quantity: Decimal,
74    ) -> Self {
75        Self {
76            symbol: symbol.into(),
77            side,
78            price,
79            quantity,
80            sequence: None,
81        }
82    }
83
84    /// Attach an exchange sequence number to this delta.
85    pub fn with_sequence(mut self, seq: u64) -> Self {
86        self.sequence = Some(seq);
87        self
88    }
89
90    /// Returns `true` if this delta signals a level deletion (`quantity == 0`).
91    ///
92    /// Exchanges signal the removal of a price level by sending a delta with
93    /// zero quantity. Checking `is_delete()` is clearer than comparing with
94    /// `Decimal::ZERO` at every call site.
95    pub fn is_delete(&self) -> bool {
96        self.quantity.is_zero()
97    }
98
99    /// Returns `true` if this delta adds or updates a price level (`quantity > 0`).
100    ///
101    /// The logical complement of [`is_delete`](Self::is_delete).
102    pub fn is_add(&self) -> bool {
103        !self.is_delete()
104    }
105}
106
107impl std::fmt::Display for BookDelta {
108    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
109        let side = match self.side {
110            BookSide::Bid => "Bid",
111            BookSide::Ask => "Ask",
112        };
113        match self.sequence {
114            Some(seq) => write!(
115                f,
116                "{} {} {} x {} seq={}",
117                self.symbol, side, self.price, self.quantity, seq
118            ),
119            None => write!(
120                f,
121                "{} {} {} x {}",
122                self.symbol, side, self.price, self.quantity
123            ),
124        }
125    }
126}
127
128/// Live order book for a single symbol.
129pub struct OrderBook {
130    symbol: String,
131    bids: BTreeMap<Decimal, Decimal>, // price → quantity
132    asks: BTreeMap<Decimal, Decimal>, // price → quantity
133    last_sequence: Option<u64>,
134}
135
136impl OrderBook {
137    /// Create an empty order book for the given symbol.
138    pub fn new(symbol: impl Into<String>) -> Self {
139        Self {
140            symbol: symbol.into(),
141            bids: BTreeMap::new(),
142            asks: BTreeMap::new(),
143            last_sequence: None,
144        }
145    }
146
147    /// Apply an incremental delta. quantity == 0 removes the level.
148    ///
149    /// # Errors
150    ///
151    /// - [`StreamError::BookReconstructionFailed`] if the delta's symbol does
152    ///   not match this book.
153    /// - [`StreamError::SequenceGap`] if the delta carries a sequence number
154    ///   that is not exactly one greater than the last applied sequence.
155    /// - [`StreamError::BookCrossed`] if applying the delta would leave the
156    ///   best bid >= best ask.
157    #[must_use = "errors from apply() must be handled to avoid missed gaps or crossed-book state"]
158    pub fn apply(&mut self, delta: BookDelta) -> Result<(), StreamError> {
159        if delta.symbol != self.symbol {
160            return Err(StreamError::BookReconstructionFailed {
161                symbol: self.symbol.clone(),
162                reason: format!(
163                    "delta symbol '{}' does not match book '{}'",
164                    delta.symbol, self.symbol
165                ),
166            });
167        }
168
169        // Sequence gap detection: if both the book and the delta carry a
170        // sequence number, they must be consecutive.
171        if let (Some(last), Some(incoming)) = (self.last_sequence, delta.sequence) {
172            let expected = last + 1;
173            if incoming != expected {
174                return Err(StreamError::SequenceGap {
175                    symbol: self.symbol.clone(),
176                    expected,
177                    got: incoming,
178                });
179            }
180        }
181
182        let map = match delta.side {
183            BookSide::Bid => &mut self.bids,
184            BookSide::Ask => &mut self.asks,
185        };
186        if delta.quantity.is_zero() {
187            map.remove(&delta.price);
188        } else {
189            map.insert(delta.price, delta.quantity);
190        }
191        if let Some(seq) = delta.sequence {
192            self.last_sequence = Some(seq);
193        }
194        self.check_crossed()
195    }
196
197    /// Reset the book from a full snapshot, also resetting the sequence counter.
198    #[must_use = "errors from reset() indicate a crossed snapshot and must be handled"]
199    pub fn reset(
200        &mut self,
201        bids: Vec<PriceLevel>,
202        asks: Vec<PriceLevel>,
203    ) -> Result<(), StreamError> {
204        self.bids.clear();
205        self.asks.clear();
206        self.last_sequence = None;
207        for lvl in bids {
208            if !lvl.quantity.is_zero() {
209                self.bids.insert(lvl.price, lvl.quantity);
210            }
211        }
212        for lvl in asks {
213            if !lvl.quantity.is_zero() {
214                self.asks.insert(lvl.price, lvl.quantity);
215            }
216        }
217        self.check_crossed()
218    }
219
220    /// Best bid (highest).
221    pub fn best_bid(&self) -> Option<PriceLevel> {
222        self.bids
223            .iter()
224            .next_back()
225            .map(|(p, q)| PriceLevel::new(*p, *q))
226    }
227
228    /// Best ask (lowest).
229    pub fn best_ask(&self) -> Option<PriceLevel> {
230        self.asks
231            .iter()
232            .next()
233            .map(|(p, q)| PriceLevel::new(*p, *q))
234    }
235
236    /// Resting quantity at the best bid level.
237    ///
238    /// Shorthand for `self.best_bid().map(|l| l.quantity)`.
239    pub fn best_bid_qty(&self) -> Option<Decimal> {
240        self.best_bid().map(|l| l.quantity)
241    }
242
243    /// Resting quantity at the best ask level.
244    ///
245    /// Shorthand for `self.best_ask().map(|l| l.quantity)`.
246    pub fn best_ask_qty(&self) -> Option<Decimal> {
247        self.best_ask().map(|l| l.quantity)
248    }
249
250    /// Mid price.
251    pub fn mid_price(&self) -> Option<Decimal> {
252        let bid = self.best_bid()?.price;
253        let ask = self.best_ask()?.price;
254        Some((bid + ask) / Decimal::from(2))
255    }
256
257    /// Quantity-weighted mid price.
258    ///
259    /// `(bid_price × ask_qty + ask_price × bid_qty) / (bid_qty + ask_qty)`.
260    ///
261    /// Gives a better estimate of fair value than the arithmetic mid when the
262    /// best-bid and best-ask have very different resting quantities.
263    /// Returns `None` if either side is empty or total quantity is zero.
264    pub fn weighted_mid_price(&self) -> Option<Decimal> {
265        let bid = self.best_bid()?;
266        let ask = self.best_ask()?;
267        let total_qty = bid.quantity + ask.quantity;
268        if total_qty.is_zero() {
269            return None;
270        }
271        Some((bid.price * ask.quantity + ask.price * bid.quantity) / total_qty)
272    }
273
274    /// Returns `(best_bid, best_ask)` in a single call, or `None` if either side is absent.
275    pub fn top_of_book(&self) -> Option<(PriceLevel, PriceLevel)> {
276        Some((self.best_bid()?, self.best_ask()?))
277    }
278
279    /// Full displayed price range: `best_ask - worst_displayed_bid`.
280    ///
281    /// Wider than `spread()` which only uses the top-of-book.
282    /// Returns `None` if either side is empty.
283    pub fn price_range(&self) -> Option<Decimal> {
284        let worst_bid = *self.bids.iter().next()?.0; // lowest bid price
285        let best_ask = *self.asks.iter().next()?.0;  // lowest ask price
286        Some(best_ask - worst_bid)
287    }
288
289    /// Spread.
290    pub fn spread(&self) -> Option<Decimal> {
291        let bid = self.best_bid()?.price;
292        let ask = self.best_ask()?.price;
293        Some(ask - bid)
294    }
295
296    /// Returns `true` if both sides of the book have no resting orders.
297    pub fn is_empty(&self) -> bool {
298        self.bids.is_empty() && self.asks.is_empty()
299    }
300
301    /// Combined notional value (price × quantity) across both bid and ask sides.
302    pub fn total_notional_both_sides(&self) -> Decimal {
303        self.total_notional(BookSide::Bid) + self.total_notional(BookSide::Ask)
304    }
305
306    /// Returns `true` if a resting order exists at `price` on `side`.
307    pub fn price_level_exists(&self, side: BookSide, price: Decimal) -> bool {
308        match side {
309            BookSide::Bid => self.bids.contains_key(&price),
310            BookSide::Ask => self.asks.contains_key(&price),
311        }
312    }
313
314    /// Remove all price levels from both sides of the book.
315    ///
316    /// Also clears the last seen sequence number. Useful when reconnecting to
317    /// an exchange feed and waiting for a fresh snapshot before applying deltas.
318    pub fn clear(&mut self) {
319        self.bids.clear();
320        self.asks.clear();
321        self.last_sequence = None;
322    }
323
324    /// Number of bid levels.
325    pub fn bid_depth(&self) -> usize {
326        self.bids.len()
327    }
328
329    /// Number of ask levels.
330    pub fn ask_depth(&self) -> usize {
331        self.asks.len()
332    }
333
334    /// Total resting quantity across all bid levels.
335    pub fn bid_volume_total(&self) -> Decimal {
336        self.bids.values().copied().sum()
337    }
338
339    /// Total resting quantity across all ask levels.
340    pub fn ask_volume_total(&self) -> Decimal {
341        self.asks.values().copied().sum()
342    }
343
344    /// Total notional value (`Σ price × quantity`) across all levels on the
345    /// given side.
346    ///
347    /// Useful for comparing the dollar value committed to each side of the
348    /// book, rather than just the raw quantity.
349    pub fn total_notional(&self, side: BookSide) -> Decimal {
350        match side {
351            BookSide::Bid => self.bids.iter().map(|(p, q)| *p * *q).sum(),
352            BookSide::Ask => self.asks.iter().map(|(p, q)| *p * *q).sum(),
353        }
354    }
355
356    /// Returns `true` if exactly one side (bids or asks) has levels and the
357    /// other is empty. An empty book returns `false`.
358    pub fn is_one_sided(&self) -> bool {
359        let has_bids = !self.bids.is_empty();
360        let has_asks = !self.asks.is_empty();
361        has_bids != has_asks
362    }
363
364    /// Bid-ask spread expressed in basis points: `(ask − bid) / mid × 10 000`.
365    ///
366    /// Returns `None` if either side is empty or the mid price is zero.
367    /// Imbalance between number of bid and ask price levels.
368    ///
369    /// Returns `(bid_levels - ask_levels) / (bid_levels + ask_levels)` as f64
370    /// in the range `[-1.0, 1.0]`. Returns `None` when the book is empty.
371    pub fn level_count_imbalance(&self) -> Option<f64> {
372        let total = self.bids.len() + self.asks.len();
373        if total == 0 {
374            return None;
375        }
376        let diff = self.bids.len() as f64 - self.asks.len() as f64;
377        Some(diff / total as f64)
378    }
379
380    /// Bid-ask spread in basis points: `(ask − bid) / mid × 10_000`.
381    ///
382    /// Returns `None` if either side of the book is empty or the mid-price is zero.
383    pub fn bid_ask_spread_bps(&self) -> Option<f64> {
384        let bid = self.best_bid()?.price;
385        let ask = self.best_ask()?.price;
386        let mid = (bid + ask) / Decimal::from(2);
387        if mid.is_zero() {
388            return None;
389        }
390        let spread = ask - bid;
391        let spread_f: f64 = spread.to_string().parse().ok()?;
392        let mid_f: f64 = mid.to_string().parse().ok()?;
393        Some(spread_f / mid_f * 10_000.0)
394    }
395
396    /// Sum of the top `n` bid levels' quantities (best `n` bids).
397    ///
398    /// Total quantity across all bid levels.
399    pub fn total_bid_volume(&self) -> Decimal {
400        self.bids.values().copied().sum()
401    }
402
403    /// Total quantity across all ask levels.
404    pub fn total_ask_volume(&self) -> Decimal {
405        self.asks.values().copied().sum()
406    }
407
408    /// Sum of the top `n` bid levels' quantities (best `n` bids).
409    ///
410    /// If fewer than `n` bid levels exist, sums all available levels. Returns
411    /// `Decimal::ZERO` when the bid side is empty.
412    pub fn cumulative_bid_volume(&self, n: usize) -> Decimal {
413        self.bids.values().rev().take(n).copied().sum()
414    }
415
416    /// Sum of the top `n` ask levels' quantities (best `n` asks).
417    ///
418    /// If fewer than `n` ask levels exist, sums all available levels. Returns
419    /// `Decimal::ZERO` when the ask side is empty.
420    pub fn cumulative_ask_volume(&self, n: usize) -> Decimal {
421        self.asks.values().take(n).copied().sum()
422    }
423
424    /// Best `n` bid levels as [`PriceLevel`]s, sorted best-first (highest price first).
425    ///
426    /// If fewer than `n` levels exist, all are returned.
427    pub fn top_n_bids(&self, n: usize) -> Vec<PriceLevel> {
428        self.bids
429            .iter()
430            .rev()
431            .take(n)
432            .map(|(p, q)| PriceLevel::new(*p, *q))
433            .collect()
434    }
435
436    /// Best `n` ask levels as [`PriceLevel`]s, sorted best-first (lowest price first).
437    ///
438    /// If fewer than `n` levels exist, all are returned.
439    pub fn top_n_asks(&self, n: usize) -> Vec<PriceLevel> {
440        self.asks
441            .iter()
442            .take(n)
443            .map(|(p, q)| PriceLevel::new(*p, *q))
444            .collect()
445    }
446
447    /// Ratio of cumulative bid volume to cumulative ask volume across top `n` levels.
448    ///
449    /// Returns `None` when the ask side is empty (avoids division by zero).
450    /// Values > 1.0 indicate buy-side pressure; values < 1.0 indicate sell-side pressure.
451    pub fn depth_ratio(&self, n: usize) -> Option<f64> {
452        use rust_decimal::prelude::ToPrimitive;
453        let ask_vol = self.cumulative_ask_volume(n);
454        if ask_vol.is_zero() {
455            return None;
456        }
457        (self.cumulative_bid_volume(n) / ask_vol).to_f64()
458    }
459
460    /// The cheapest ask level with quantity ≥ `min_qty`, or `None` if no such level exists.
461    ///
462    /// Scans ask levels from the tightest (best) price outward. Useful for
463    /// detecting large sell walls sitting near the top of the book.
464    pub fn ask_wall(&self, min_qty: Decimal) -> Option<PriceLevel> {
465        self.asks
466            .iter()
467            .find(|(_, qty)| **qty >= min_qty)
468            .map(|(price, qty)| PriceLevel::new(*price, *qty))
469    }
470
471    /// The highest bid level with quantity ≥ `min_qty`, or `None` if no such level exists.
472    ///
473    /// Scans bid levels from the best (highest) price downward. Useful for
474    /// detecting large buy walls sitting near the top of the book.
475    pub fn bid_wall(&self, min_qty: Decimal) -> Option<PriceLevel> {
476        self.bids
477            .iter()
478            .rev()
479            .find(|(_, qty)| **qty >= min_qty)
480            .map(|(price, qty)| PriceLevel::new(*price, *qty))
481    }
482
483    /// Number of bid levels with price strictly above `price`.
484    ///
485    /// Useful for measuring how much resting bid interest sits above a given
486    /// reference price (e.g. the last trade price).
487    pub fn bid_levels_above(&self, price: Decimal) -> usize {
488        self.bids.range((std::ops::Bound::Excluded(&price), std::ops::Bound::Unbounded)).count()
489    }
490
491    /// Number of ask levels with price strictly below `price`.
492    ///
493    /// Useful for measuring how much resting ask interest sits below a given
494    /// reference price (e.g. the last trade price).
495    pub fn ask_levels_below(&self, price: Decimal) -> usize {
496        self.asks.range(..price).count()
497    }
498
499    /// Ratio of total bid volume to total ask volume.
500    ///
501    /// Returns `None` when either side has zero volume (avoids division by
502    /// zero and meaningless ratios on empty books).  A value > 1.0 means more
503    /// buying interest; < 1.0 means more selling pressure.
504    pub fn bid_ask_volume_ratio(&self) -> Option<f64> {
505        use rust_decimal::prelude::ToPrimitive;
506        let bid = self.total_bid_volume();
507        let ask = self.total_ask_volume();
508        if bid.is_zero() || ask.is_zero() {
509            return None;
510        }
511        let bid_f = bid.to_f64()?;
512        let ask_f = ask.to_f64()?;
513        Some(bid_f / ask_f)
514    }
515
516    /// Total volume across the top `n` bid price levels (best-to-worst order).
517    ///
518    /// If there are fewer than `n` levels, the volume of all existing levels is
519    /// returned. Returns `Decimal::ZERO` for an empty bid side.
520    pub fn top_n_bid_volume(&self, n: usize) -> Decimal {
521        self.bids.iter().rev().take(n).map(|(_, qty)| *qty).sum()
522    }
523
524    /// Normalised order-book imbalance: `(bid_vol − ask_vol) / (bid_vol + ask_vol)`.
525    ///
526    /// Returns a value in `[-1.0, 1.0]`.  `+1.0` means all volume is on the
527    /// bid side (strong buying pressure); `-1.0` means all volume is on the
528    /// ask side (strong selling pressure).  Returns `None` when both sides are
529    /// empty (sum is zero).
530    pub fn imbalance_ratio(&self) -> Option<f64> {
531        use rust_decimal::prelude::ToPrimitive;
532        let bid = self.total_bid_volume();
533        let ask = self.total_ask_volume();
534        let total = bid + ask;
535        if total.is_zero() {
536            return None;
537        }
538        let bid_f = bid.to_f64()?;
539        let ask_f = ask.to_f64()?;
540        let total_f = bid_f + ask_f;
541        Some((bid_f - ask_f) / total_f)
542    }
543
544    /// Total volume across the top `n` ask price levels (best-to-worst order,
545    /// i.e. lowest asks first).
546    ///
547    /// If there are fewer than `n` levels, the volume of all existing levels is
548    /// returned. Returns `Decimal::ZERO` for an empty ask side.
549    pub fn top_n_ask_volume(&self, n: usize) -> Decimal {
550        self.asks.iter().take(n).map(|(_, qty)| *qty).sum()
551    }
552
553    /// Returns `true` if there is a non-zero ask entry at exactly `price`.
554    pub fn has_ask_at(&self, price: Decimal) -> bool {
555        self.asks.get(&price).map(|q| !q.is_zero()).unwrap_or(false)
556    }
557
558    /// Returns `(bid_levels, ask_levels)` — the number of distinct price levels
559    /// on each side of the book.
560    pub fn bid_ask_depth(&self) -> (usize, usize) {
561        (self.bids.len(), self.asks.len())
562    }
563
564    /// Total volume across all bid and ask levels combined.
565    pub fn total_book_volume(&self) -> Decimal {
566        self.total_bid_volume() + self.total_ask_volume()
567    }
568
569    /// Price distance from the best bid to the worst (lowest) bid.
570    ///
571    /// Returns `None` if there are fewer than 2 bid levels.
572    pub fn price_range_bids(&self) -> Option<Decimal> {
573        if self.bids.len() < 2 {
574            return None;
575        }
576        let best = *self.bids.keys().next_back()?;
577        let worst = *self.bids.keys().next()?;
578        Some(best - worst)
579    }
580
581    /// Spread as a percentage of the mid-price: `spread / mid × 100`.
582    ///
583    /// Returns `None` if either best bid or best ask is absent, or if the
584    /// mid-price is zero.
585    pub fn spread_pct(&self) -> Option<f64> {
586        use rust_decimal::prelude::ToPrimitive;
587        let mid = self.mid_price()?;
588        if mid.is_zero() {
589            return None;
590        }
591        let spread = self.spread()?;
592        (spread / mid * Decimal::from(100)).to_f64()
593    }
594
595    /// Returns `true` if the bid-ask spread is at or below `threshold`.
596    ///
597    /// Returns `false` when either side is empty (no spread to compare).
598    pub fn is_tight_spread(&self, threshold: Decimal) -> bool {
599        match self.spread() {
600            Some(s) => s <= threshold,
601            None => false,
602        }
603    }
604
605    /// Total number of price levels across both sides of the book.
606    ///
607    /// Equivalent to `bid_depth() + ask_depth()`.
608    pub fn total_depth(&self) -> usize {
609        self.bids.len() + self.asks.len()
610    }
611
612    /// Total resting quantity across both sides of the book.
613    ///
614    /// Equivalent to `bid_volume_total() + ask_volume_total()`.
615    pub fn total_volume(&self) -> Decimal {
616        self.bid_volume_total() + self.ask_volume_total()
617    }
618
619    /// The symbol this order book tracks.
620    pub fn symbol(&self) -> &str {
621        &self.symbol
622    }
623
624    /// The sequence number of the most recently applied delta, if any.
625    pub fn last_sequence(&self) -> Option<u64> {
626        self.last_sequence
627    }
628
629    /// Best-bid quantity as a fraction of `(best_bid_qty + best_ask_qty)`.
630    ///
631    /// Values near `1.0` indicate the best bid has dominant size; near `0.0` the best ask
632    /// dominates. Returns `None` when either side is empty or both quantities are zero.
633    pub fn quote_imbalance(&self) -> Option<f64> {
634        use rust_decimal::prelude::ToPrimitive;
635        let bid_qty = *self.bids.iter().next_back()?.1;
636        let ask_qty = *self.asks.iter().next()?.1;
637        let total = bid_qty + ask_qty;
638        if total.is_zero() {
639            return None;
640        }
641        (bid_qty / total).to_f64()
642    }
643
644    /// Returns `true` if a bid level exists at exactly `price`.
645    pub fn contains_bid(&self, price: Decimal) -> bool {
646        self.bids.contains_key(&price)
647    }
648
649    /// Returns `true` if an ask level exists at exactly `price`.
650    pub fn contains_ask(&self, price: Decimal) -> bool {
651        self.asks.contains_key(&price)
652    }
653
654    /// Returns the resting quantity at `price` on the bid side, or `None` if absent.
655    pub fn volume_at_bid(&self, price: Decimal) -> Option<Decimal> {
656        self.bids.get(&price).copied()
657    }
658
659    /// Returns the resting quantity at `price` on the ask side, or `None` if absent.
660    pub fn volume_at_ask(&self, price: Decimal) -> Option<Decimal> {
661        self.asks.get(&price).copied()
662    }
663
664    /// Number of resting price levels on the given side.
665    ///
666    /// Unified version of [`bid_depth`](Self::bid_depth) /
667    /// [`ask_depth`](Self::ask_depth) for runtime dispatch by side.
668    pub fn level_count(&self, side: BookSide) -> usize {
669        match side {
670            BookSide::Bid => self.bids.len(),
671            BookSide::Ask => self.asks.len(),
672        }
673    }
674
675    /// Total number of distinct price levels across both bid and ask sides.
676    pub fn level_count_both_sides(&self) -> usize {
677        self.bids.len() + self.asks.len()
678    }
679
680    /// The `n`th-best ask price (0 = best/lowest ask).
681    ///
682    /// Returns `None` if there are fewer than `n + 1` ask levels.
683    pub fn ask_price_at_rank(&self, n: usize) -> Option<Decimal> {
684        self.asks.keys().nth(n).copied()
685    }
686
687    /// The `n`th-best bid price (0 = best/highest bid).
688    ///
689    /// Returns `None` if there are fewer than `n + 1` bid levels.
690    pub fn bid_price_at_rank(&self, n: usize) -> Option<Decimal> {
691        self.bids.keys().nth_back(n).copied()
692    }
693
694    /// Number of distinct price levels per unit of price range on the given side.
695    ///
696    /// `quote_density = level_count / (max_price - min_price)`.
697    /// Returns `None` if the side has fewer than 2 levels (range is zero).
698    pub fn quote_density(&self, side: BookSide) -> Option<Decimal> {
699        let map = match side {
700            BookSide::Bid => &self.bids,
701            BookSide::Ask => &self.asks,
702        };
703        if map.len() < 2 { return None; }
704        let min_p = *map.keys().next()?;
705        let max_p = *map.keys().next_back()?;
706        let range = max_p - min_p;
707        if range.is_zero() { return None; }
708        Some(Decimal::from(map.len()) / range)
709    }
710
711    /// Ratio of total bid quantity to total ask quantity.
712    ///
713    /// Values > 1 indicate heavier buy-side resting volume; < 1 more sell-side.
714    /// Returns `None` if the ask side has zero volume.
715    pub fn bid_ask_qty_ratio(&self) -> Option<f64> {
716        use rust_decimal::prelude::ToPrimitive;
717        let ask_vol = self.ask_volume_total();
718        if ask_vol.is_zero() { return None; }
719        (self.bid_volume_total() / ask_vol).to_f64()
720    }
721
722    /// Quantity resting at the best bid price.
723    ///
724    /// Returns `None` if the bid side is empty.
725    pub fn top_bid_qty(&self) -> Option<Decimal> {
726        self.bids.iter().next_back().map(|(_, &q)| q)
727    }
728
729    /// Quantity resting at the best ask price.
730    ///
731    /// Returns `None` if the ask side is empty.
732    pub fn top_ask_qty(&self) -> Option<Decimal> {
733        self.asks.iter().next().map(|(_, &q)| q)
734    }
735
736    /// Sum of quantity across the best `n` bid levels.
737    ///
738    /// Returns total bid quantity if `n >= bid_count`.
739    pub fn cumulative_bid_qty(&self, n: usize) -> Decimal {
740        self.bids.iter().rev().take(n).map(|(_, &q)| q).sum()
741    }
742
743    /// Sum of quantity across the best `n` ask levels.
744    ///
745    /// Returns total ask quantity if `n >= ask_count`.
746    pub fn cumulative_ask_qty(&self, n: usize) -> Decimal {
747        self.asks.iter().take(n).map(|(_, &q)| q).sum()
748    }
749
750    /// Ratio of top-`n` bid quantity to top-`n` ask quantity.
751    ///
752    /// Values > 1 indicate more buy-side depth in the top `n` levels.
753    /// Returns `None` if ask side has no volume in top `n` levels.
754    pub fn ladder_balance(&self, n: usize) -> Option<f64> {
755        use rust_decimal::prelude::ToPrimitive;
756        let ask_qty = self.cumulative_ask_qty(n);
757        if ask_qty.is_zero() { return None; }
758        (self.cumulative_bid_qty(n) / ask_qty).to_f64()
759    }
760
761    /// Ratio of ask levels to bid levels: `ask_count / bid_count`.
762    ///
763    /// Values > 1 indicate more ask granularity; < 1 more bid granularity.
764    /// Returns `None` if the bid side is empty.
765    pub fn ask_bid_level_ratio(&self) -> Option<f64> {
766        if self.bids.is_empty() { return None; }
767        Some(self.asks.len() as f64 / self.bids.len() as f64)
768    }
769
770    /// Resting quantity at an exact price level on the given side.
771    ///
772    /// Returns `None` if there is no resting order at that price. This is a
773    /// unified alternative to calling `volume_at_bid` / `volume_at_ask`
774    /// separately when the side is determined at runtime.
775    pub fn depth_at_price(&self, price: Decimal, side: BookSide) -> Option<Decimal> {
776        match side {
777            BookSide::Bid => self.bids.get(&price).copied(),
778            BookSide::Ask => self.asks.get(&price).copied(),
779        }
780    }
781
782    /// Ratio of total bid volume to total ask volume: `bid_volume_total / ask_volume_total`.
783    ///
784    /// Returns `None` if the ask side is empty (to avoid division by zero).
785    /// Values > 1.0 indicate more buy-side depth; < 1.0 indicates more sell-side depth.
786    pub fn bid_ask_ratio(&self) -> Option<f64> {
787        use rust_decimal::prelude::ToPrimitive;
788        let ask = self.ask_volume_total();
789        if ask.is_zero() {
790            return None;
791        }
792        (self.bid_volume_total() / ask).to_f64()
793    }
794
795    /// All bid levels, sorted descending by price (highest first).
796    ///
797    /// Equivalent to `top_bids(usize::MAX)` but more expressive when you want
798    /// the complete depth without specifying a level count.
799    pub fn all_bids(&self) -> Vec<PriceLevel> {
800        self.bids
801            .iter()
802            .rev()
803            .map(|(p, q)| PriceLevel::new(*p, *q))
804            .collect()
805    }
806
807    /// All ask levels, sorted ascending by price (lowest first).
808    ///
809    /// Equivalent to `top_asks(usize::MAX)` but more expressive when you want
810    /// the complete depth without specifying a level count.
811    pub fn all_asks(&self) -> Vec<PriceLevel> {
812        self.asks
813            .iter()
814            .map(|(p, q)| PriceLevel::new(*p, *q))
815            .collect()
816    }
817
818    /// Top N bids (descending by price).
819    pub fn top_bids(&self, n: usize) -> Vec<PriceLevel> {
820        self.bids
821            .iter()
822            .rev()
823            .take(n)
824            .map(|(p, q)| PriceLevel::new(*p, *q))
825            .collect()
826    }
827
828    /// Top N asks (ascending by price).
829    pub fn top_asks(&self, n: usize) -> Vec<PriceLevel> {
830        self.asks
831            .iter()
832            .take(n)
833            .map(|(p, q)| PriceLevel::new(*p, *q))
834            .collect()
835    }
836
837    /// Order-book imbalance at the best bid/ask: `(bid_qty - ask_qty) / (bid_qty + ask_qty)`.
838    ///
839    /// Returns a value in `[-1.0, 1.0]`:
840    /// - `+1.0` means the entire resting quantity is on the bid side (maximum buy pressure).
841    /// - `-1.0` means the entire resting quantity is on the ask side (maximum sell pressure).
842    /// - `0.0` means perfectly balanced.
843    ///
844    /// Returns `None` if either side has no best level.
845    pub fn imbalance(&self) -> Option<f64> {
846        use rust_decimal::prelude::ToPrimitive;
847        let bid_qty = self.best_bid()?.quantity;
848        let ask_qty = self.best_ask()?.quantity;
849        let total = bid_qty + ask_qty;
850        if total.is_zero() {
851            return None;
852        }
853        let imb = (bid_qty - ask_qty) / total;
854        imb.to_f64()
855    }
856
857    /// Order-book imbalance using the top `n` levels on each side.
858    ///
859    /// `(Σ bid_qty - Σ ask_qty) / (Σ bid_qty + Σ ask_qty)` in `[-1.0, 1.0]`.
860    ///
861    /// Returns `None` if either side has no levels or total volume is zero.
862    pub fn bid_ask_imbalance(&self, n: usize) -> Option<f64> {
863        use rust_decimal::prelude::ToPrimitive;
864        let bid_vol: Decimal = self.top_bids(n).iter().map(|l| l.quantity).sum();
865        let ask_vol: Decimal = self.top_asks(n).iter().map(|l| l.quantity).sum();
866        if bid_vol.is_zero() || ask_vol.is_zero() {
867            return None;
868        }
869        let total = bid_vol + ask_vol;
870        if total.is_zero() {
871            return None;
872        }
873        ((bid_vol - ask_vol) / total).to_f64()
874    }
875
876    /// Volume-weighted average price (VWAP) of the top `n` resting levels on `side`.
877    ///
878    /// `Σ(price × qty) / Σ(qty)`. Returns `None` if the side has no levels or
879    /// total volume is zero.
880    pub fn vwap(&self, side: BookSide, n: usize) -> Option<Decimal> {
881        let levels = match side {
882            BookSide::Bid => self.top_bids(n),
883            BookSide::Ask => self.top_asks(n),
884        };
885        let total_vol: Decimal = levels.iter().map(|l| l.quantity).sum();
886        if total_vol.is_zero() {
887            return None;
888        }
889        let price_vol_sum: Decimal = levels.iter().map(|l| l.price * l.quantity).sum();
890        Some(price_vol_sum / total_vol)
891    }
892
893    /// Walk the book on `side` and return the average fill price to absorb `target_volume`.
894    ///
895    /// Sweeps levels from best to worst until `target_volume` is consumed, computing
896    /// the VWAP of the executed portion. If the book has less total volume than
897    /// `target_volume`, returns the VWAP of all available liquidity anyway.
898    ///
899    /// Returns `None` if the side is empty or `target_volume` is zero.
900    pub fn price_at_volume(&self, side: BookSide, target_volume: Decimal) -> Option<Decimal> {
901        if target_volume.is_zero() {
902            return None;
903        }
904        let levels: Vec<(Decimal, Decimal)> = match side {
905            BookSide::Bid => self.bids.iter().rev().map(|(p, q)| (*p, *q)).collect(),
906            BookSide::Ask => self.asks.iter().map(|(p, q)| (*p, *q)).collect(),
907        };
908        if levels.is_empty() {
909            return None;
910        }
911        let mut remaining = target_volume;
912        let mut notional = Decimal::ZERO;
913        let mut filled = Decimal::ZERO;
914        for (price, qty) in &levels {
915            if remaining.is_zero() {
916                break;
917            }
918            let take = (*qty).min(remaining);
919            notional += price * take;
920            filled += take;
921            remaining -= take;
922        }
923        if filled.is_zero() {
924            return None;
925        }
926        Some(notional / filled)
927    }
928
929    /// Volume imbalance over the top-`n` price levels on each side: `(bid_vol - ask_vol) / (bid_vol + ask_vol)`.
930    ///
931    /// Returns a value in `[-1, 1]`: positive means more resting bid volume, negative means
932    /// more resting ask volume. Returns `None` if both sides have zero volume or `n == 0`.
933    ///
934    /// Unlike [`imbalance`](Self::imbalance) which only uses the best bid/ask quantity,
935    /// `depth_imbalance` aggregates across up to `n` levels providing a broader picture of
936    /// order book pressure.
937    pub fn depth_imbalance(&self, n: usize) -> Option<f64> {
938        use rust_decimal::prelude::ToPrimitive;
939        if n == 0 {
940            return None;
941        }
942        let bid_vol: Decimal = self.bids.values().rev().take(n).copied().sum();
943        let ask_vol: Decimal = self.asks.values().take(n).copied().sum();
944        let total = bid_vol + ask_vol;
945        if total.is_zero() {
946            return None;
947        }
948        ((bid_vol - ask_vol) / total).to_f64()
949    }
950
951    /// Returns the top-`n` price levels for the given side, sorted best-first.
952    ///
953    /// For bids, levels are sorted descending (highest price first).
954    /// For asks, levels are sorted ascending (lowest price first).
955    /// If `n` exceeds the available levels, all levels are returned.
956    pub fn levels(&self, side: BookSide, n: usize) -> Vec<PriceLevel> {
957        match side {
958            BookSide::Bid => self
959                .bids
960                .iter()
961                .rev()
962                .take(n)
963                .map(|(p, q)| PriceLevel::new(*p, *q))
964                .collect(),
965            BookSide::Ask => self
966                .asks
967                .iter()
968                .take(n)
969                .map(|(p, q)| PriceLevel::new(*p, *q))
970                .collect(),
971        }
972    }
973
974    /// Returns the resting quantity at a specific bid price level, or `None` if absent.
975    pub fn bid_volume_at_price(&self, price: Decimal) -> Option<Decimal> {
976        self.bids.get(&price).copied()
977    }
978
979    /// Returns the resting quantity at a specific ask price level, or `None` if absent.
980    pub fn ask_volume_at_price(&self, price: Decimal) -> Option<Decimal> {
981        self.asks.get(&price).copied()
982    }
983
984    /// Return a full snapshot of all bid and ask levels.
985    ///
986    /// The returned tuple is `(bids, asks)`:
987    /// - `bids` are sorted descending by price (highest first).
988    /// - `asks` are sorted ascending by price (lowest first).
989    ///
990    /// Use this after receiving a [`StreamError::SequenceGap`] to rebuild the
991    /// book from a fresh exchange snapshot: call [`reset`](Self::reset) with
992    /// the snapshot levels, then resume applying deltas from the new sequence.
993    pub fn snapshot(&self) -> (Vec<PriceLevel>, Vec<PriceLevel>) {
994        let bids = self
995            .bids
996            .iter()
997            .rev()
998            .map(|(p, q)| PriceLevel::new(*p, *q))
999            .collect();
1000        let asks = self
1001            .asks
1002            .iter()
1003            .map(|(p, q)| PriceLevel::new(*p, *q))
1004            .collect();
1005        (bids, asks)
1006    }
1007
1008    /// Returns the best bid price, or `None` if the bid side is empty.
1009    pub fn best_bid_price(&self) -> Option<Decimal> {
1010        self.best_bid().map(|l| l.price)
1011    }
1012
1013    /// Returns the best ask price, or `None` if the ask side is empty.
1014    pub fn best_ask_price(&self) -> Option<Decimal> {
1015        self.best_ask().map(|l| l.price)
1016    }
1017
1018    /// Returns `true` if the book is crossed: best bid ≥ best ask.
1019    ///
1020    /// A crossed book indicates an invalid state (stale snapshot or missed
1021    /// delta). Under normal operation this should always be `false`.
1022    pub fn is_crossed(&self) -> bool {
1023        match (self.best_bid(), self.best_ask()) {
1024            (Some(bid), Some(ask)) => bid.price >= ask.price,
1025            _ => false,
1026        }
1027    }
1028
1029    /// Returns `true` if there is at least one bid level in the book.
1030    pub fn has_bids(&self) -> bool {
1031        !self.bids.is_empty()
1032    }
1033
1034    /// Returns `true` if there is at least one ask level in the book.
1035    pub fn has_asks(&self) -> bool {
1036        !self.asks.is_empty()
1037    }
1038
1039    /// Price distance from best ask to worst ask (highest ask price - lowest ask price).
1040    ///
1041    /// Returns `None` if the ask side is empty.
1042    pub fn ask_price_range(&self) -> Option<Decimal> {
1043        if self.asks.is_empty() {
1044            return None;
1045        }
1046        let best = *self.asks.keys().next()?;
1047        let worst = *self.asks.keys().next_back()?;
1048        Some(worst - best)
1049    }
1050
1051    /// Price distance from best bid to worst bid (highest bid price - lowest bid price).
1052    ///
1053    /// Returns `None` if the bid side is empty.
1054    pub fn bid_price_range(&self) -> Option<Decimal> {
1055        if self.bids.is_empty() {
1056            return None;
1057        }
1058        let best = *self.bids.keys().next_back()?;
1059        let worst = *self.bids.keys().next()?;
1060        Some(best - worst)
1061    }
1062
1063    /// Spread as a fraction of the mid price: `spread / mid_price`.
1064    ///
1065    /// Returns `None` if the book has no bid or ask, or mid price is zero.
1066    pub fn mid_spread_ratio(&self) -> Option<f64> {
1067        use rust_decimal::prelude::ToPrimitive;
1068        let spread = self.spread()?;
1069        let mid = self.mid_price()?;
1070        if mid.is_zero() {
1071            return None;
1072        }
1073        (spread / mid).to_f64()
1074    }
1075
1076    /// Bid-ask volume imbalance: `(bid_vol - ask_vol) / (bid_vol + ask_vol)`.
1077    ///
1078    /// Returns a value in `[-1.0, 1.0]`. Positive = more bid volume; negative = more ask volume.
1079    /// Returns `None` if both sides are empty.
1080    pub fn volume_imbalance(&self) -> Option<f64> {
1081        use rust_decimal::prelude::ToPrimitive;
1082        let bid = self.total_bid_volume();
1083        let ask = self.total_ask_volume();
1084        let total = bid + ask;
1085        if total.is_zero() {
1086            return None;
1087        }
1088        ((bid - ask) / total).to_f64()
1089    }
1090
1091    fn check_crossed(&self) -> Result<(), StreamError> {
1092        if let (Some(bid), Some(ask)) = (self.best_bid(), self.best_ask()) {
1093            if bid.price >= ask.price {
1094                return Err(StreamError::BookCrossed {
1095                    symbol: self.symbol.clone(),
1096                    bid: bid.price,
1097                    ask: ask.price,
1098                });
1099            }
1100        }
1101        Ok(())
1102    }
1103
1104    /// Estimated trading fee for a market order of `qty` on `side`, given a fee in basis points.
1105    ///
1106    /// `fee ≈ fill_price * qty * fee_bps / 10_000`. Returns fee in base currency.
1107    /// Returns `None` if the side is empty, qty ≤ 0, or not enough liquidity to fill.
1108    pub fn fee_estimate(&self, side: BookSide, qty: Decimal, fee_bps: Decimal) -> Option<Decimal> {
1109        if qty <= Decimal::ZERO { return None; }
1110        let best_price = match side {
1111            BookSide::Bid => self.bids.keys().next_back().copied()?,
1112            BookSide::Ask => self.asks.keys().next().copied()?,
1113        };
1114        let impact = self.price_impact(side, qty).unwrap_or(Decimal::ZERO);
1115        let fill_price = best_price + impact;
1116        Some(fill_price * qty * fee_bps / Decimal::from(10_000u32))
1117    }
1118
1119    /// Spread expressed as number of ticks: `spread / tick_size`.
1120    ///
1121    /// Returns `None` if either side is empty or tick_size is zero.
1122    pub fn spread_ticks(&self, tick_size: Decimal) -> Option<Decimal> {
1123        if tick_size.is_zero() { return None; }
1124        let spread = self.spread()?;
1125        Some(spread / tick_size)
1126    }
1127
1128    /// Spread expressed in basis points relative to mid-price: `(ask - bid) / mid × 10_000`.
1129    ///
1130    /// Returns `None` when either side is empty or mid-price is zero.
1131    pub fn spread_bps(&self) -> Option<f64> {
1132        use rust_decimal::prelude::ToPrimitive;
1133        let mid = self.mid_price()?;
1134        if mid.is_zero() {
1135            return None;
1136        }
1137        let spread = self.spread()?;
1138        (spread / mid * Decimal::from(10_000u32)).to_f64()
1139    }
1140
1141    /// Cumulative volume within `pct` percent of the best price on a given side.
1142    ///
1143    /// For bids: sums quantity at all levels where `price >= best_bid * (1 - pct/100)`.
1144    /// For asks: sums quantity at all levels where `price <= best_ask * (1 + pct/100)`.
1145    ///
1146    /// Returns `None` if the side is empty or `pct` is negative.
1147    pub fn depth_at_pct(&self, side: BookSide, pct: f64) -> Option<Decimal> {
1148        use rust_decimal::prelude::FromPrimitive;
1149        if pct < 0.0 { return None; }
1150        let pct_dec = Decimal::from_f64(pct / 100.0)?;
1151        match side {
1152            BookSide::Bid => {
1153                let best = *self.bids.keys().next_back()?;
1154                let threshold = best * (Decimal::ONE - pct_dec);
1155                Some(self.bids.range(threshold..).map(|(_, q)| q).sum())
1156            }
1157            BookSide::Ask => {
1158                let best = *self.asks.keys().next()?;
1159                let threshold = best * (Decimal::ONE + pct_dec);
1160                Some(self.asks.range(..=threshold).map(|(_, q)| q).sum())
1161            }
1162        }
1163    }
1164
1165    /// Microprice: volume-weighted mid using top-of-book quantities.
1166    ///
1167    /// `microprice = (ask_qty * best_bid + bid_qty * best_ask) / (bid_qty + ask_qty)`
1168    ///
1169    /// More accurate than simple mid when the order book is imbalanced.
1170    /// Returns `None` if either side is empty or total quantity is zero.
1171    pub fn microprice(&self) -> Option<Decimal> {
1172        let best_bid = *self.bids.keys().next_back()?;
1173        let best_ask = *self.asks.keys().next()?;
1174        let bid_qty = *self.bids.get(&best_bid)?;
1175        let ask_qty = *self.asks.get(&best_ask)?;
1176        let total_qty = bid_qty + ask_qty;
1177        if total_qty.is_zero() { return None; }
1178        Some((ask_qty * best_bid + bid_qty * best_ask) / total_qty)
1179    }
1180
1181    /// Returns the top `n` price levels on a given side as `(price, quantity)` pairs.
1182    ///
1183    /// Bid levels are returned in descending price order (best bid first).
1184    /// Ask levels are returned in ascending price order (best ask first).
1185    /// Returns fewer than `n` entries if the side has fewer levels.
1186    pub fn best_n_levels(&self, side: BookSide, n: usize) -> Vec<(Decimal, Decimal)> {
1187        match side {
1188            BookSide::Bid => self.bids.iter().rev().take(n)
1189                .map(|(&p, &q)| (p, q)).collect(),
1190            BookSide::Ask => self.asks.iter().take(n)
1191                .map(|(&p, &q)| (p, q)).collect(),
1192        }
1193    }
1194
1195    /// Estimated price impact of a market order of `qty` on the given side.
1196    ///
1197    /// Walks the book, consuming levels until `qty` is filled. Returns the
1198    /// weighted average fill price minus the best price (positive = adverse).
1199    /// Returns `None` if the side is empty or `qty` is zero/negative.
1200    pub fn price_impact(&self, side: BookSide, qty: Decimal) -> Option<Decimal> {
1201        if qty <= Decimal::ZERO { return None; }
1202        let best_price = match side {
1203            BookSide::Bid => self.bids.keys().next_back().copied()?,
1204            BookSide::Ask => self.asks.keys().next().copied()?,
1205        };
1206        let mut remaining = qty;
1207        let mut cost = Decimal::ZERO;
1208        let levels: Box<dyn Iterator<Item = (&Decimal, &Decimal)>> = match side {
1209            BookSide::Bid => Box::new(self.bids.iter().rev()),
1210            BookSide::Ask => Box::new(self.asks.iter()),
1211        };
1212        for (&price, &level_qty) in levels {
1213            if remaining <= Decimal::ZERO { break; }
1214            let filled = remaining.min(level_qty);
1215            cost += price * filled;
1216            remaining -= filled;
1217        }
1218        if remaining > Decimal::ZERO { return None; } // not enough liquidity
1219        let avg_fill = cost / qty;
1220        Some((avg_fill - best_price).abs())
1221    }
1222
1223    /// Total notional value (price × quantity) at a specific price level on a given side.
1224    ///
1225    /// Returns `None` if no level exists at `price`.
1226    pub fn total_value_at_level(&self, side: BookSide, price: Decimal) -> Option<Decimal> {
1227        match side {
1228            BookSide::Bid => self.bids.get(&price).map(|&q| price * q),
1229            BookSide::Ask => self.asks.get(&price).map(|&q| price * q),
1230        }
1231    }
1232
1233    /// Estimated volume-weighted average execution price for a market buy of `quantity`.
1234    ///
1235    /// Walks up the ask side. Returns `None` if insufficient liquidity.
1236    pub fn price_impact_buy(&self, quantity: Decimal) -> Option<Decimal> {
1237        if quantity <= Decimal::ZERO {
1238            return None;
1239        }
1240        let mut remaining = quantity;
1241        let mut cost = Decimal::ZERO;
1242        for (&price, &qty) in &self.asks {
1243            if remaining.is_zero() { break; }
1244            let fill = remaining.min(qty);
1245            cost += fill * price;
1246            remaining -= fill;
1247        }
1248        if !remaining.is_zero() { return None; }
1249        Some(cost / quantity)
1250    }
1251
1252    /// Estimated volume-weighted average execution price for a market sell of `quantity`.
1253    ///
1254    /// Walks down the bid side. Returns `None` if insufficient liquidity.
1255    pub fn price_impact_sell(&self, quantity: Decimal) -> Option<Decimal> {
1256        if quantity <= Decimal::ZERO {
1257            return None;
1258        }
1259        let mut remaining = quantity;
1260        let mut proceeds = Decimal::ZERO;
1261        for (&price, &qty) in self.bids.iter().rev() {
1262            if remaining.is_zero() { break; }
1263            let fill = remaining.min(qty);
1264            proceeds += fill * price;
1265            remaining -= fill;
1266        }
1267        if !remaining.is_zero() { return None; }
1268        Some(proceeds / quantity)
1269    }
1270
1271    /// Number of distinct price levels on the ask side.
1272    pub fn ask_level_count(&self) -> usize {
1273        self.asks.len()
1274    }
1275
1276    /// Number of distinct price levels on the bid side.
1277    pub fn bid_level_count(&self) -> usize {
1278        self.bids.len()
1279    }
1280
1281    /// Cumulative ask volume at levels within `price_range` of the best ask.
1282    ///
1283    /// Sums all ask quantities where `price <= best_ask + price_range`.
1284    /// Returns `Decimal::ZERO` if the ask side is empty.
1285    pub fn ask_volume_within(&self, price_range: Decimal) -> Decimal {
1286        match self.best_ask() {
1287            None => Decimal::ZERO,
1288            Some(best) => {
1289                let ceiling = best.price + price_range;
1290                self.asks.range(..=ceiling).map(|(_, &q)| q).sum()
1291            }
1292        }
1293    }
1294
1295    /// Cumulative bid volume at levels within `price_range` of the best bid.
1296    ///
1297    /// Sums all bid quantities where `price >= best_bid - price_range`.
1298    /// Returns `Decimal::ZERO` if the bid side is empty.
1299    pub fn bid_volume_within(&self, price_range: Decimal) -> Decimal {
1300        match self.best_bid() {
1301            None => Decimal::ZERO,
1302            Some(best) => {
1303                let floor = best.price - price_range;
1304                self.bids.range(floor..).map(|(_, &q)| q).sum()
1305            }
1306        }
1307    }
1308
1309    /// Total ask quantity at price levels strictly above `price`.
1310    pub fn ask_volume_above(&self, price: Decimal) -> Decimal {
1311        use std::ops::Bound::Excluded;
1312        self.asks
1313            .range((Excluded(price), std::ops::Bound::Unbounded))
1314            .map(|(_, &q)| q)
1315            .sum()
1316    }
1317
1318    /// Total bid quantity at price levels strictly below `price`.
1319    pub fn bid_volume_below(&self, price: Decimal) -> Decimal {
1320        use std::ops::Bound::Unbounded;
1321        use std::ops::Bound::Excluded;
1322        self.bids
1323            .range((Unbounded, Excluded(price)))
1324            .map(|(_, &q)| q)
1325            .sum()
1326    }
1327}
1328
1329#[cfg(test)]
1330mod tests {
1331    use super::*;
1332    use rust_decimal_macros::dec;
1333
1334    fn book(symbol: &str) -> OrderBook {
1335        OrderBook::new(symbol)
1336    }
1337
1338    fn delta(symbol: &str, side: BookSide, price: Decimal, qty: Decimal) -> BookDelta {
1339        BookDelta::new(symbol, side, price, qty)
1340    }
1341
1342    #[test]
1343    fn test_order_book_apply_bid_level() {
1344        let mut b = book("BTC-USD");
1345        b.apply(delta("BTC-USD", BookSide::Bid, dec!(50000), dec!(1)))
1346            .unwrap();
1347        assert_eq!(b.best_bid().unwrap().price, dec!(50000));
1348    }
1349
1350    #[test]
1351    fn test_order_book_apply_ask_level() {
1352        let mut b = book("BTC-USD");
1353        b.apply(delta("BTC-USD", BookSide::Ask, dec!(50100), dec!(2)))
1354            .unwrap();
1355        assert_eq!(b.best_ask().unwrap().price, dec!(50100));
1356    }
1357
1358    #[test]
1359    fn test_order_book_remove_level_with_zero_qty() {
1360        let mut b = book("BTC-USD");
1361        b.apply(delta("BTC-USD", BookSide::Bid, dec!(50000), dec!(1)))
1362            .unwrap();
1363        b.apply(delta("BTC-USD", BookSide::Bid, dec!(50000), dec!(0)))
1364            .unwrap();
1365        assert!(b.best_bid().is_none());
1366    }
1367
1368    #[test]
1369    fn test_order_book_best_bid_is_highest() {
1370        let mut b = book("BTC-USD");
1371        b.apply(delta("BTC-USD", BookSide::Bid, dec!(49900), dec!(1)))
1372            .unwrap();
1373        b.apply(delta("BTC-USD", BookSide::Bid, dec!(50000), dec!(2)))
1374            .unwrap();
1375        b.apply(delta("BTC-USD", BookSide::Bid, dec!(49800), dec!(3)))
1376            .unwrap();
1377        assert_eq!(b.best_bid().unwrap().price, dec!(50000));
1378    }
1379
1380    #[test]
1381    fn test_order_book_best_ask_is_lowest() {
1382        let mut b = book("BTC-USD");
1383        b.apply(delta("BTC-USD", BookSide::Ask, dec!(50200), dec!(1)))
1384            .unwrap();
1385        b.apply(delta("BTC-USD", BookSide::Ask, dec!(50100), dec!(2)))
1386            .unwrap();
1387        b.apply(delta("BTC-USD", BookSide::Ask, dec!(50300), dec!(3)))
1388            .unwrap();
1389        assert_eq!(b.best_ask().unwrap().price, dec!(50100));
1390    }
1391
1392    #[test]
1393    fn test_order_book_mid_price() {
1394        let mut b = book("BTC-USD");
1395        b.apply(delta("BTC-USD", BookSide::Bid, dec!(50000), dec!(1)))
1396            .unwrap();
1397        b.apply(delta("BTC-USD", BookSide::Ask, dec!(50100), dec!(1)))
1398            .unwrap();
1399        assert_eq!(b.mid_price().unwrap(), dec!(50050));
1400    }
1401
1402    #[test]
1403    fn test_order_book_spread() {
1404        let mut b = book("BTC-USD");
1405        b.apply(delta("BTC-USD", BookSide::Bid, dec!(50000), dec!(1)))
1406            .unwrap();
1407        b.apply(delta("BTC-USD", BookSide::Ask, dec!(50100), dec!(1)))
1408            .unwrap();
1409        assert_eq!(b.spread().unwrap(), dec!(100));
1410    }
1411
1412    #[test]
1413    fn test_order_book_crossed_returns_error() {
1414        let mut b = book("BTC-USD");
1415        b.apply(delta("BTC-USD", BookSide::Ask, dec!(50000), dec!(1)))
1416            .unwrap();
1417        let result = b.apply(delta("BTC-USD", BookSide::Bid, dec!(50001), dec!(1)));
1418        assert!(matches!(result, Err(StreamError::BookCrossed { .. })));
1419    }
1420
1421    #[test]
1422    fn test_order_book_wrong_symbol_delta_rejected() {
1423        let mut b = book("BTC-USD");
1424        let result = b.apply(delta("ETH-USD", BookSide::Bid, dec!(3000), dec!(1)));
1425        assert!(matches!(
1426            result,
1427            Err(StreamError::BookReconstructionFailed { .. })
1428        ));
1429    }
1430
1431    #[test]
1432    fn test_order_book_reset_clears_and_reloads() {
1433        let mut b = book("BTC-USD");
1434        b.apply(delta("BTC-USD", BookSide::Bid, dec!(49000), dec!(5)))
1435            .unwrap();
1436        b.reset(
1437            vec![PriceLevel::new(dec!(50000), dec!(1))],
1438            vec![PriceLevel::new(dec!(50100), dec!(1))],
1439        )
1440        .unwrap();
1441        assert_eq!(b.bid_depth(), 1);
1442        assert_eq!(b.best_bid().unwrap().price, dec!(50000));
1443    }
1444
1445    #[test]
1446    fn test_order_book_reset_ignores_zero_qty_levels() {
1447        let mut b = book("BTC-USD");
1448        b.reset(
1449            vec![
1450                PriceLevel::new(dec!(50000), dec!(1)),
1451                PriceLevel::new(dec!(49900), dec!(0)),
1452            ],
1453            vec![PriceLevel::new(dec!(50100), dec!(1))],
1454        )
1455        .unwrap();
1456        assert_eq!(b.bid_depth(), 1);
1457    }
1458
1459    #[test]
1460    fn test_order_book_reset_clears_sequence() {
1461        let mut b = book("BTC-USD");
1462        b.apply(
1463            delta("BTC-USD", BookSide::Bid, dec!(49900), dec!(1)).with_sequence(5),
1464        )
1465        .unwrap();
1466        assert_eq!(b.last_sequence(), Some(5));
1467        b.reset(
1468            vec![PriceLevel::new(dec!(50000), dec!(1))],
1469            vec![PriceLevel::new(dec!(50100), dec!(1))],
1470        )
1471        .unwrap();
1472        assert_eq!(b.last_sequence(), None);
1473    }
1474
1475    #[test]
1476    fn test_order_book_depth_counts() {
1477        let mut b = book("BTC-USD");
1478        b.apply(delta("BTC-USD", BookSide::Bid, dec!(49900), dec!(1)))
1479            .unwrap();
1480        b.apply(delta("BTC-USD", BookSide::Bid, dec!(49800), dec!(1)))
1481            .unwrap();
1482        b.apply(delta("BTC-USD", BookSide::Ask, dec!(50100), dec!(1)))
1483            .unwrap();
1484        assert_eq!(b.bid_depth(), 2);
1485        assert_eq!(b.ask_depth(), 1);
1486    }
1487
1488    #[test]
1489    fn test_order_book_top_bids_descending() {
1490        let mut b = book("BTC-USD");
1491        b.apply(delta("BTC-USD", BookSide::Bid, dec!(49800), dec!(3)))
1492            .unwrap();
1493        b.apply(delta("BTC-USD", BookSide::Bid, dec!(50000), dec!(1)))
1494            .unwrap();
1495        b.apply(delta("BTC-USD", BookSide::Bid, dec!(49900), dec!(2)))
1496            .unwrap();
1497        let top = b.top_bids(2);
1498        assert_eq!(top[0].price, dec!(50000));
1499        assert_eq!(top[1].price, dec!(49900));
1500    }
1501
1502    #[test]
1503    fn test_order_book_top_asks_ascending() {
1504        let mut b = book("BTC-USD");
1505        b.apply(delta("BTC-USD", BookSide::Ask, dec!(50300), dec!(3)))
1506            .unwrap();
1507        b.apply(delta("BTC-USD", BookSide::Ask, dec!(50100), dec!(1)))
1508            .unwrap();
1509        b.apply(delta("BTC-USD", BookSide::Ask, dec!(50200), dec!(2)))
1510            .unwrap();
1511        let top = b.top_asks(2);
1512        assert_eq!(top[0].price, dec!(50100));
1513        assert_eq!(top[1].price, dec!(50200));
1514    }
1515
1516    #[test]
1517    fn test_book_delta_with_sequence() {
1518        let d = BookDelta::new("BTC-USD", BookSide::Bid, dec!(50000), dec!(1)).with_sequence(42);
1519        assert_eq!(d.sequence, Some(42));
1520    }
1521
1522    #[test]
1523    fn test_order_book_sequence_tracking() {
1524        let mut b = book("BTC-USD");
1525        b.apply(delta("BTC-USD", BookSide::Bid, dec!(50000), dec!(1)).with_sequence(7))
1526            .unwrap();
1527        assert_eq!(b.last_sequence(), Some(7));
1528    }
1529
1530    #[test]
1531    fn test_order_book_sequence_gap_detected() {
1532        let mut b = book("BTC-USD");
1533        b.apply(delta("BTC-USD", BookSide::Bid, dec!(49900), dec!(1)).with_sequence(1))
1534            .unwrap();
1535        // Skip sequence 2, send 3 → gap
1536        let result =
1537            b.apply(delta("BTC-USD", BookSide::Ask, dec!(50100), dec!(1)).with_sequence(3));
1538        assert!(matches!(
1539            result,
1540            Err(StreamError::SequenceGap { expected: 2, got: 3, .. })
1541        ));
1542    }
1543
1544    #[test]
1545    fn test_order_book_sequential_deltas_accepted() {
1546        let mut b = book("BTC-USD");
1547        b.apply(delta("BTC-USD", BookSide::Bid, dec!(49900), dec!(1)).with_sequence(1))
1548            .unwrap();
1549        b.apply(delta("BTC-USD", BookSide::Ask, dec!(50100), dec!(1)).with_sequence(2))
1550            .unwrap();
1551        assert_eq!(b.last_sequence(), Some(2));
1552    }
1553
1554    #[test]
1555    fn test_order_book_mid_price_empty_returns_none() {
1556        let b = book("BTC-USD");
1557        assert!(b.mid_price().is_none());
1558    }
1559
1560    #[test]
1561    fn test_price_level_new() {
1562        let lvl = PriceLevel::new(dec!(100), dec!(5));
1563        assert_eq!(lvl.price, dec!(100));
1564        assert_eq!(lvl.quantity, dec!(5));
1565    }
1566
1567    #[test]
1568    fn test_contains_bid_present() {
1569        let mut b = book("BTC-USD");
1570        b.apply(delta("BTC-USD", BookSide::Bid, dec!(50000), dec!(1)))
1571            .unwrap();
1572        assert!(b.contains_bid(dec!(50000)));
1573        assert!(!b.contains_bid(dec!(49999)));
1574    }
1575
1576    #[test]
1577    fn test_contains_ask_present() {
1578        let mut b = book("BTC-USD");
1579        b.apply(delta("BTC-USD", BookSide::Ask, dec!(50100), dec!(2)))
1580            .unwrap();
1581        assert!(b.contains_ask(dec!(50100)));
1582        assert!(!b.contains_ask(dec!(50200)));
1583    }
1584
1585    #[test]
1586    fn test_contains_bid_removed_after_zero_qty() {
1587        let mut b = book("BTC-USD");
1588        b.apply(delta("BTC-USD", BookSide::Bid, dec!(50000), dec!(1)))
1589            .unwrap();
1590        b.apply(delta("BTC-USD", BookSide::Bid, dec!(50000), dec!(0)))
1591            .unwrap();
1592        assert!(!b.contains_bid(dec!(50000)));
1593    }
1594
1595    #[test]
1596    fn test_book_delta_serde_roundtrip() {
1597        let d = BookDelta::new("BTC-USD", BookSide::Bid, dec!(50000), dec!(1))
1598            .with_sequence(42);
1599        let json = serde_json::to_string(&d).unwrap();
1600        let d2: BookDelta = serde_json::from_str(&json).unwrap();
1601        assert_eq!(d2.symbol, "BTC-USD");
1602        assert_eq!(d2.price, dec!(50000));
1603        assert_eq!(d2.sequence, Some(42));
1604    }
1605
1606    #[test]
1607    fn test_volume_at_bid_present() {
1608        let mut b = book("BTC-USD");
1609        b.apply(delta("BTC-USD", BookSide::Bid, dec!(50000), dec!(3)))
1610            .unwrap();
1611        assert_eq!(b.volume_at_bid(dec!(50000)), Some(dec!(3)));
1612        assert_eq!(b.volume_at_bid(dec!(49999)), None);
1613    }
1614
1615    #[test]
1616    fn test_volume_at_ask_present() {
1617        let mut b = book("BTC-USD");
1618        b.apply(delta("BTC-USD", BookSide::Ask, dec!(50100), dec!(5)))
1619            .unwrap();
1620        assert_eq!(b.volume_at_ask(dec!(50100)), Some(dec!(5)));
1621        assert_eq!(b.volume_at_ask(dec!(50200)), None);
1622    }
1623
1624    #[test]
1625    fn test_book_delta_display_with_sequence() {
1626        let d = BookDelta::new("BTC-USD", BookSide::Bid, dec!(50000), dec!(1))
1627            .with_sequence(42);
1628        let s = d.to_string();
1629        assert!(s.contains("BTC-USD"));
1630        assert!(s.contains("Bid"));
1631        assert!(s.contains("seq=42"));
1632    }
1633
1634    #[test]
1635    fn test_book_delta_display_without_sequence() {
1636        let d = BookDelta::new("ETH-USD", BookSide::Ask, dec!(3000), dec!(2));
1637        let s = d.to_string();
1638        assert!(s.contains("ETH-USD"));
1639        assert!(s.contains("Ask"));
1640        assert!(!s.contains("seq="));
1641    }
1642
1643    #[test]
1644    fn test_book_delta_is_delete_zero_qty() {
1645        let d = BookDelta::new("BTC-USD", BookSide::Bid, dec!(50000), dec!(0));
1646        assert!(d.is_delete());
1647    }
1648
1649    #[test]
1650    fn test_book_delta_is_delete_nonzero_qty() {
1651        let d = BookDelta::new("BTC-USD", BookSide::Bid, dec!(50000), dec!(1));
1652        assert!(!d.is_delete());
1653    }
1654
1655    #[test]
1656    fn test_snapshot_bids_descending_asks_ascending() {
1657        let mut b = book("BTC-USD");
1658        b.apply(delta("BTC-USD", BookSide::Bid, dec!(49800), dec!(1)))
1659            .unwrap();
1660        b.apply(delta("BTC-USD", BookSide::Bid, dec!(50000), dec!(2)))
1661            .unwrap();
1662        b.apply(delta("BTC-USD", BookSide::Ask, dec!(50200), dec!(1)))
1663            .unwrap();
1664        b.apply(delta("BTC-USD", BookSide::Ask, dec!(50100), dec!(3)))
1665            .unwrap();
1666        let (bids, asks) = b.snapshot();
1667        assert_eq!(bids[0].price, dec!(50000));
1668        assert_eq!(bids[1].price, dec!(49800));
1669        assert_eq!(asks[0].price, dec!(50100));
1670        assert_eq!(asks[1].price, dec!(50200));
1671    }
1672
1673    // ── bid_ask_imbalance ─────────────────────────────────────────────────────
1674
1675    #[test]
1676    fn test_bid_ask_imbalance_balanced() {
1677        let mut b = book("BTC-USD");
1678        b.apply(delta("BTC-USD", BookSide::Bid, dec!(50000), dec!(1))).unwrap();
1679        b.apply(delta("BTC-USD", BookSide::Ask, dec!(50100), dec!(1))).unwrap();
1680        let imb = b.bid_ask_imbalance(1).unwrap();
1681        assert!((imb).abs() < 1e-9, "equal qty → ~0");
1682    }
1683
1684    #[test]
1685    fn test_bid_ask_imbalance_full_bid_pressure() {
1686        let mut b = book("BTC-USD");
1687        b.apply(delta("BTC-USD", BookSide::Bid, dec!(50000), dec!(10))).unwrap();
1688        b.apply(delta("BTC-USD", BookSide::Ask, dec!(50100), dec!(0))).unwrap();
1689        // ask qty = 0 → None
1690        assert!(b.bid_ask_imbalance(1).is_none());
1691    }
1692
1693    #[test]
1694    fn test_bid_ask_imbalance_two_levels() {
1695        let mut b = book("BTC-USD");
1696        b.apply(delta("BTC-USD", BookSide::Bid, dec!(50000), dec!(3))).unwrap();
1697        b.apply(delta("BTC-USD", BookSide::Bid, dec!(49900), dec!(1))).unwrap();
1698        b.apply(delta("BTC-USD", BookSide::Ask, dec!(50100), dec!(2))).unwrap();
1699        b.apply(delta("BTC-USD", BookSide::Ask, dec!(50200), dec!(2))).unwrap();
1700        // bid_vol = 4, ask_vol = 4 → imbalance = 0
1701        let imb = b.bid_ask_imbalance(2).unwrap();
1702        assert!((imb).abs() < 1e-9);
1703    }
1704
1705    // ── vwap ──────────────────────────────────────────────────────────────────
1706
1707    #[test]
1708    fn test_vwap_single_level_equals_price() {
1709        let mut b = book("BTC-USD");
1710        b.apply(delta("BTC-USD", BookSide::Ask, dec!(50100), dec!(5))).unwrap();
1711        assert_eq!(b.vwap(BookSide::Ask, 1), Some(dec!(50100)));
1712    }
1713
1714    #[test]
1715    fn test_vwap_two_equal_qty_levels() {
1716        let mut b = book("BTC-USD");
1717        b.apply(delta("BTC-USD", BookSide::Bid, dec!(50000), dec!(1))).unwrap();
1718        b.apply(delta("BTC-USD", BookSide::Bid, dec!(49800), dec!(1))).unwrap();
1719        // vwap = (50000 + 49800) / 2 = 49900
1720        assert_eq!(b.vwap(BookSide::Bid, 2), Some(dec!(49900)));
1721    }
1722
1723    #[test]
1724    fn test_vwap_empty_side_returns_none() {
1725        let b = book("BTC-USD");
1726        assert!(b.vwap(BookSide::Ask, 5).is_none());
1727    }
1728
1729    // ── OrderBook::depth_at_price / bid_ask_ratio ─────────────────────────────
1730
1731    #[test]
1732    fn test_depth_at_price_bid_present() {
1733        let mut b = book("BTC-USD");
1734        b.apply(delta("BTC-USD", BookSide::Bid, dec!(50000), dec!(3))).unwrap();
1735        assert_eq!(b.depth_at_price(dec!(50000), BookSide::Bid), Some(dec!(3)));
1736    }
1737
1738    #[test]
1739    fn test_depth_at_price_ask_present() {
1740        let mut b = book("BTC-USD");
1741        b.apply(delta("BTC-USD", BookSide::Ask, dec!(50100), dec!(2))).unwrap();
1742        assert_eq!(b.depth_at_price(dec!(50100), BookSide::Ask), Some(dec!(2)));
1743    }
1744
1745    #[test]
1746    fn test_depth_at_price_absent_returns_none() {
1747        let b = book("BTC-USD");
1748        assert!(b.depth_at_price(dec!(99999), BookSide::Bid).is_none());
1749        assert!(b.depth_at_price(dec!(99999), BookSide::Ask).is_none());
1750    }
1751
1752    #[test]
1753    fn test_bid_ask_ratio_equal_sides() {
1754        let mut b = book("BTC-USD");
1755        b.apply(delta("BTC-USD", BookSide::Bid, dec!(49900), dec!(5))).unwrap();
1756        b.apply(delta("BTC-USD", BookSide::Ask, dec!(50100), dec!(5))).unwrap();
1757        let ratio = b.bid_ask_ratio().unwrap();
1758        assert!((ratio - 1.0).abs() < 1e-9);
1759    }
1760
1761    #[test]
1762    fn test_bid_ask_ratio_more_bids() {
1763        let mut b = book("BTC-USD");
1764        b.apply(delta("BTC-USD", BookSide::Bid, dec!(49900), dec!(10))).unwrap();
1765        b.apply(delta("BTC-USD", BookSide::Ask, dec!(50100), dec!(5))).unwrap();
1766        let ratio = b.bid_ask_ratio().unwrap();
1767        assert!((ratio - 2.0).abs() < 1e-9);
1768    }
1769
1770    #[test]
1771    fn test_bid_ask_ratio_no_asks_returns_none() {
1772        let mut b = book("BTC-USD");
1773        b.apply(delta("BTC-USD", BookSide::Bid, dec!(49900), dec!(5))).unwrap();
1774        assert!(b.bid_ask_ratio().is_none());
1775    }
1776
1777    // ── OrderBook::all_bids / all_asks ────────────────────────────────────────
1778
1779    #[test]
1780    fn test_all_bids_sorted_descending() {
1781        let mut b = book("BTC-USD");
1782        b.apply(delta("BTC-USD", BookSide::Bid, dec!(49800), dec!(1))).unwrap();
1783        b.apply(delta("BTC-USD", BookSide::Bid, dec!(50000), dec!(2))).unwrap();
1784        b.apply(delta("BTC-USD", BookSide::Bid, dec!(49900), dec!(3))).unwrap();
1785        let bids = b.all_bids();
1786        assert_eq!(bids.len(), 3);
1787        assert_eq!(bids[0].price, dec!(50000));
1788        assert_eq!(bids[1].price, dec!(49900));
1789        assert_eq!(bids[2].price, dec!(49800));
1790    }
1791
1792    #[test]
1793    fn test_all_asks_sorted_ascending() {
1794        let mut b = book("BTC-USD");
1795        b.apply(delta("BTC-USD", BookSide::Ask, dec!(50100), dec!(1))).unwrap();
1796        b.apply(delta("BTC-USD", BookSide::Ask, dec!(50300), dec!(2))).unwrap();
1797        b.apply(delta("BTC-USD", BookSide::Ask, dec!(50200), dec!(3))).unwrap();
1798        let asks = b.all_asks();
1799        assert_eq!(asks.len(), 3);
1800        assert_eq!(asks[0].price, dec!(50100));
1801        assert_eq!(asks[1].price, dec!(50200));
1802        assert_eq!(asks[2].price, dec!(50300));
1803    }
1804
1805    #[test]
1806    fn test_all_bids_empty_returns_empty() {
1807        let b = book("BTC-USD");
1808        assert!(b.all_bids().is_empty());
1809    }
1810
1811    // ── spread_pct / total_depth / total_volume ──────────────────────────────
1812
1813    #[test]
1814    fn test_spread_pct_basic() {
1815        // bid=100, ask=101 → spread=1, mid=100.5 → pct ≈ 0.995%
1816        let mut b = book("X");
1817        b.apply(delta("X", BookSide::Bid, dec!(100), dec!(1))).unwrap();
1818        b.apply(delta("X", BookSide::Ask, dec!(101), dec!(1))).unwrap();
1819        let pct = b.spread_pct().unwrap();
1820        assert!((pct - 100.0 / 100.5).abs() < 1e-9, "got {pct}");
1821    }
1822
1823    #[test]
1824    fn test_spread_pct_empty_book_returns_none() {
1825        let b = book("X");
1826        assert!(b.spread_pct().is_none());
1827    }
1828
1829    #[test]
1830    fn test_total_depth_counts_both_sides() {
1831        let mut b = book("X");
1832        b.apply(delta("X", BookSide::Bid, dec!(99), dec!(1))).unwrap();
1833        b.apply(delta("X", BookSide::Bid, dec!(98), dec!(1))).unwrap();
1834        b.apply(delta("X", BookSide::Ask, dec!(101), dec!(1))).unwrap();
1835        assert_eq!(b.total_depth(), 3);
1836    }
1837
1838    #[test]
1839    fn test_total_depth_empty_is_zero() {
1840        let b = book("X");
1841        assert_eq!(b.total_depth(), 0);
1842    }
1843
1844    #[test]
1845    fn test_total_volume_sums_both_sides() {
1846        let mut b = book("X");
1847        b.apply(delta("X", BookSide::Bid, dec!(99), dec!(3))).unwrap();
1848        b.apply(delta("X", BookSide::Ask, dec!(101), dec!(5))).unwrap();
1849        assert_eq!(b.total_volume(), dec!(8));
1850    }
1851
1852    #[test]
1853    fn test_total_volume_empty_is_zero() {
1854        let b = book("X");
1855        assert_eq!(b.total_volume(), dec!(0));
1856    }
1857
1858    // ── OrderBook::level_count ────────────────────────────────────────────────
1859
1860    #[test]
1861    fn test_level_count_empty() {
1862        let b = book("BTC-USD");
1863        assert_eq!(b.level_count(BookSide::Bid), 0);
1864        assert_eq!(b.level_count(BookSide::Ask), 0);
1865    }
1866
1867    #[test]
1868    fn test_level_count_matches_depth_methods() {
1869        let mut b = book("BTC-USD");
1870        b.apply(delta("BTC-USD", BookSide::Bid, dec!(100), dec!(1))).unwrap();
1871        b.apply(delta("BTC-USD", BookSide::Bid, dec!(99), dec!(1))).unwrap();
1872        b.apply(delta("BTC-USD", BookSide::Ask, dec!(101), dec!(2))).unwrap();
1873        assert_eq!(b.level_count(BookSide::Bid), b.bid_depth());
1874        assert_eq!(b.level_count(BookSide::Ask), b.ask_depth());
1875    }
1876
1877    // ── PriceLevel::notional ─────────────────────────────────────────────────
1878
1879    #[test]
1880    fn test_price_level_notional() {
1881        let level = PriceLevel::new(dec!(50000), dec!(2));
1882        assert_eq!(level.notional(), dec!(100000));
1883    }
1884
1885    #[test]
1886    fn test_price_level_notional_zero_qty() {
1887        let level = PriceLevel::new(dec!(100), dec!(0));
1888        assert_eq!(level.notional(), dec!(0));
1889    }
1890
1891    // ── OrderBook::weighted_mid_price ────────────────────────────────────────
1892
1893    #[test]
1894    fn test_weighted_mid_price_equal_qtys_is_arithmetic_mid() {
1895        // bid=100 qty=1, ask=102 qty=1 → wmid = (100*1 + 102*1) / 2 = 101
1896        let mut b = book("X");
1897        b.apply(delta("X", BookSide::Bid, dec!(100), dec!(1))).unwrap();
1898        b.apply(delta("X", BookSide::Ask, dec!(102), dec!(1))).unwrap();
1899        assert_eq!(b.weighted_mid_price().unwrap(), dec!(101));
1900    }
1901
1902    #[test]
1903    fn test_weighted_mid_price_skews_toward_larger_qty() {
1904        // bid=100 qty=1, ask=102 qty=3 → wmid = (100*3 + 102*1) / 4 = 402/4 = 100.5
1905        let mut b = book("X");
1906        b.apply(delta("X", BookSide::Bid, dec!(100), dec!(1))).unwrap();
1907        b.apply(delta("X", BookSide::Ask, dec!(102), dec!(3))).unwrap();
1908        assert_eq!(b.weighted_mid_price().unwrap(), dec!(100.5));
1909    }
1910
1911    #[test]
1912    fn test_weighted_mid_price_empty_returns_none() {
1913        let b = book("X");
1914        assert!(b.weighted_mid_price().is_none());
1915    }
1916
1917    // ── OrderBook::is_empty ───────────────────────────────────────────────────
1918
1919    #[test]
1920    fn test_is_empty_new_book() {
1921        let b = book("BTC-USD");
1922        assert!(b.is_empty());
1923    }
1924
1925    #[test]
1926    fn test_is_empty_false_with_bid() {
1927        let mut b = book("BTC-USD");
1928        b.apply(delta("BTC-USD", BookSide::Bid, dec!(100), dec!(1))).unwrap();
1929        assert!(!b.is_empty());
1930    }
1931
1932    #[test]
1933    fn test_is_empty_false_with_ask() {
1934        let mut b = book("BTC-USD");
1935        b.apply(delta("BTC-USD", BookSide::Ask, dec!(101), dec!(1))).unwrap();
1936        assert!(!b.is_empty());
1937    }
1938
1939    #[test]
1940    fn test_is_empty_true_after_removing_all_levels() {
1941        let mut b = book("BTC-USD");
1942        b.apply(delta("BTC-USD", BookSide::Bid, dec!(100), dec!(1))).unwrap();
1943        b.apply(delta("BTC-USD", BookSide::Bid, dec!(100), dec!(0))).unwrap(); // remove
1944        assert!(b.is_empty());
1945    }
1946
1947    // ── OrderBook::clear ──────────────────────────────────────────────────────
1948
1949    #[test]
1950    fn test_clear_empty_book_is_noop() {
1951        let mut b = book("BTC-USD");
1952        b.clear();
1953        assert!(b.is_empty());
1954    }
1955
1956    #[test]
1957    fn test_clear_removes_all_levels() {
1958        let mut b = book("BTC-USD");
1959        b.apply(delta("BTC-USD", BookSide::Bid, dec!(100), dec!(1))).unwrap();
1960        b.apply(delta("BTC-USD", BookSide::Ask, dec!(101), dec!(2))).unwrap();
1961        b.clear();
1962        assert!(b.is_empty());
1963    }
1964
1965    #[test]
1966    fn test_clear_allows_fresh_apply_after() {
1967        let mut b = book("BTC-USD");
1968        b.apply(delta("BTC-USD", BookSide::Bid, dec!(100), dec!(1))).unwrap();
1969        b.clear();
1970        // After clear, a new bid should work without sequence issues
1971        b.apply(delta("BTC-USD", BookSide::Bid, dec!(99), dec!(5))).unwrap();
1972        assert_eq!(b.bid_depth(), 1);
1973    }
1974
1975    // ── OrderBook::total_notional ─────────────────────────────────────────────
1976
1977    #[test]
1978    fn test_total_notional_bid_side() {
1979        let mut b = book("BTC-USD");
1980        b.apply(delta("BTC-USD", BookSide::Bid, dec!(50000), dec!(2))).unwrap();
1981        b.apply(delta("BTC-USD", BookSide::Bid, dec!(49900), dec!(3))).unwrap();
1982        // 50000*2 + 49900*3 = 100000 + 149700 = 249700
1983        assert_eq!(b.total_notional(BookSide::Bid), dec!(249700));
1984    }
1985
1986    #[test]
1987    fn test_total_notional_ask_side() {
1988        let mut b = book("BTC-USD");
1989        b.apply(delta("BTC-USD", BookSide::Ask, dec!(50100), dec!(1))).unwrap();
1990        b.apply(delta("BTC-USD", BookSide::Ask, dec!(50200), dec!(2))).unwrap();
1991        // 50100*1 + 50200*2 = 50100 + 100400 = 150500
1992        assert_eq!(b.total_notional(BookSide::Ask), dec!(150500));
1993    }
1994
1995    #[test]
1996    fn test_total_notional_empty_side_is_zero() {
1997        let b = book("BTC-USD");
1998        assert_eq!(b.total_notional(BookSide::Bid), dec!(0));
1999        assert_eq!(b.total_notional(BookSide::Ask), dec!(0));
2000    }
2001
2002    #[test]
2003    fn test_cumulative_bid_volume_top_two() {
2004        let mut b = book("BTC-USD");
2005        b.apply(delta("BTC-USD", BookSide::Bid, dec!(100), dec!(5))).unwrap();
2006        b.apply(delta("BTC-USD", BookSide::Bid, dec!(99), dec!(3))).unwrap();
2007        b.apply(delta("BTC-USD", BookSide::Bid, dec!(98), dec!(2))).unwrap();
2008        // best 2 bids: 100 (qty=5), 99 (qty=3)
2009        assert_eq!(b.cumulative_bid_volume(2), dec!(8));
2010    }
2011
2012    #[test]
2013    fn test_cumulative_ask_volume_top_two() {
2014        let mut b = book("BTC-USD");
2015        b.apply(delta("BTC-USD", BookSide::Ask, dec!(101), dec!(4))).unwrap();
2016        b.apply(delta("BTC-USD", BookSide::Ask, dec!(102), dec!(6))).unwrap();
2017        b.apply(delta("BTC-USD", BookSide::Ask, dec!(103), dec!(1))).unwrap();
2018        // best 2 asks: 101 (qty=4), 102 (qty=6)
2019        assert_eq!(b.cumulative_ask_volume(2), dec!(10));
2020    }
2021
2022    #[test]
2023    fn test_cumulative_volume_empty_returns_zero() {
2024        let b = book("BTC-USD");
2025        assert_eq!(b.cumulative_bid_volume(5), dec!(0));
2026        assert_eq!(b.cumulative_ask_volume(5), dec!(0));
2027    }
2028
2029    #[test]
2030    fn test_top_n_bids_best_first() {
2031        let mut b = book("BTC-USD");
2032        b.apply(delta("BTC-USD", BookSide::Bid, dec!(100), dec!(1))).unwrap();
2033        b.apply(delta("BTC-USD", BookSide::Bid, dec!(99), dec!(2))).unwrap();
2034        b.apply(delta("BTC-USD", BookSide::Bid, dec!(98), dec!(3))).unwrap();
2035        let top2 = b.top_n_bids(2);
2036        assert_eq!(top2.len(), 2);
2037        assert_eq!(top2[0].price, dec!(100)); // best bid first
2038        assert_eq!(top2[1].price, dec!(99));
2039    }
2040
2041    #[test]
2042    fn test_top_n_asks_best_first() {
2043        let mut b = book("BTC-USD");
2044        b.apply(delta("BTC-USD", BookSide::Ask, dec!(101), dec!(1))).unwrap();
2045        b.apply(delta("BTC-USD", BookSide::Ask, dec!(102), dec!(2))).unwrap();
2046        b.apply(delta("BTC-USD", BookSide::Ask, dec!(103), dec!(3))).unwrap();
2047        let top2 = b.top_n_asks(2);
2048        assert_eq!(top2.len(), 2);
2049        assert_eq!(top2[0].price, dec!(101)); // best ask first
2050        assert_eq!(top2[1].price, dec!(102));
2051    }
2052
2053    #[test]
2054    fn test_depth_ratio_balanced_book() {
2055        let mut b = book("BTC-USD");
2056        b.apply(delta("BTC-USD", BookSide::Bid, dec!(100), dec!(5))).unwrap();
2057        b.apply(delta("BTC-USD", BookSide::Ask, dec!(101), dec!(5))).unwrap();
2058        let ratio = b.depth_ratio(1).unwrap();
2059        assert!((ratio - 1.0).abs() < 1e-9);
2060    }
2061
2062    #[test]
2063    fn test_depth_ratio_empty_asks_returns_none() {
2064        let mut b = book("BTC-USD");
2065        b.apply(delta("BTC-USD", BookSide::Bid, dec!(100), dec!(5))).unwrap();
2066        assert!(b.depth_ratio(1).is_none());
2067    }
2068
2069    // ── OrderBook::is_one_sided ───────────────────────────────────────────────
2070
2071    #[test]
2072    fn test_is_one_sided_bids_only() {
2073        let mut b = book("BTC-USD");
2074        b.apply(delta("BTC-USD", BookSide::Bid, dec!(100), dec!(1))).unwrap();
2075        assert!(b.is_one_sided());
2076    }
2077
2078    #[test]
2079    fn test_is_one_sided_asks_only() {
2080        let mut b = book("BTC-USD");
2081        b.apply(delta("BTC-USD", BookSide::Ask, dec!(101), dec!(1))).unwrap();
2082        assert!(b.is_one_sided());
2083    }
2084
2085    #[test]
2086    fn test_is_one_sided_false_with_both_sides() {
2087        let mut b = book("BTC-USD");
2088        b.apply(delta("BTC-USD", BookSide::Bid, dec!(100), dec!(1))).unwrap();
2089        b.apply(delta("BTC-USD", BookSide::Ask, dec!(101), dec!(1))).unwrap();
2090        assert!(!b.is_one_sided());
2091    }
2092
2093    #[test]
2094    fn test_is_one_sided_false_for_empty_book() {
2095        let b = book("BTC-USD");
2096        assert!(!b.is_one_sided());
2097    }
2098
2099    // ── OrderBook::bid_ask_spread_bps ─────────────────────────────────────────
2100
2101    #[test]
2102    fn test_bid_ask_spread_bps_known_value() {
2103        let mut b = book("BTC-USD");
2104        b.apply(delta("BTC-USD", BookSide::Bid, dec!(100), dec!(1))).unwrap();
2105        b.apply(delta("BTC-USD", BookSide::Ask, dec!(101), dec!(1))).unwrap();
2106        // spread=1, mid=100.5, bps = 1/100.5*10000 ≈ 99.5
2107        let bps = b.bid_ask_spread_bps().unwrap();
2108        assert!((bps - 1.0 / 100.5 * 10_000.0).abs() < 0.01);
2109    }
2110
2111    #[test]
2112    fn test_bid_ask_spread_bps_none_when_one_sided() {
2113        let mut b = book("BTC-USD");
2114        b.apply(delta("BTC-USD", BookSide::Bid, dec!(100), dec!(1))).unwrap();
2115        assert!(b.bid_ask_spread_bps().is_none());
2116    }
2117
2118    #[test]
2119    fn test_bid_ask_spread_bps_none_for_empty_book() {
2120        let b = book("BTC-USD");
2121        assert!(b.bid_ask_spread_bps().is_none());
2122    }
2123
2124    // --- ask_wall / bid_wall ---
2125
2126    #[test]
2127    fn test_ask_wall_returns_cheapest_ask_above_threshold() {
2128        let mut b = book("BTC-USD");
2129        b.apply(delta("BTC-USD", BookSide::Ask, dec!(50100), dec!(2))).unwrap();
2130        b.apply(delta("BTC-USD", BookSide::Ask, dec!(50200), dec!(10))).unwrap();
2131        // ask_wall(5) should find 50200 (qty=10 >= 5); 50100 has qty=2 < 5
2132        let wall = b.ask_wall(dec!(5)).unwrap();
2133        assert_eq!(wall.price, dec!(50200));
2134        assert_eq!(wall.quantity, dec!(10));
2135    }
2136
2137    #[test]
2138    fn test_ask_wall_none_when_no_level_meets_threshold() {
2139        let mut b = book("BTC-USD");
2140        b.apply(delta("BTC-USD", BookSide::Ask, dec!(50100), dec!(1))).unwrap();
2141        assert!(b.ask_wall(dec!(5)).is_none());
2142    }
2143
2144    #[test]
2145    fn test_bid_wall_returns_highest_bid_above_threshold() {
2146        let mut b = book("BTC-USD");
2147        b.apply(delta("BTC-USD", BookSide::Bid, dec!(49900), dec!(10))).unwrap();
2148        b.apply(delta("BTC-USD", BookSide::Bid, dec!(49800), dec!(2))).unwrap();
2149        // bid_wall(5) scans from best (49900) → qty=10 >= 5, so returns 49900
2150        let wall = b.bid_wall(dec!(5)).unwrap();
2151        assert_eq!(wall.price, dec!(49900));
2152        assert_eq!(wall.quantity, dec!(10));
2153    }
2154
2155    #[test]
2156    fn test_bid_wall_none_when_no_level_meets_threshold() {
2157        let mut b = book("BTC-USD");
2158        b.apply(delta("BTC-USD", BookSide::Bid, dec!(49900), dec!(1))).unwrap();
2159        assert!(b.bid_wall(dec!(5)).is_none());
2160    }
2161
2162    // ── OrderBook::level_count_imbalance ──────────────────────────────────────
2163
2164    #[test]
2165    fn test_level_count_imbalance_balanced_sides() {
2166        let mut b = book("BTC-USD");
2167        b.apply(delta("BTC-USD", BookSide::Bid, dec!(99), dec!(1))).unwrap();
2168        b.apply(delta("BTC-USD", BookSide::Ask, dec!(101), dec!(1))).unwrap();
2169        // 1 bid, 1 ask → (1-1)/(1+1) = 0.0
2170        assert_eq!(b.level_count_imbalance(), Some(0.0));
2171    }
2172
2173    #[test]
2174    fn test_level_count_imbalance_bids_only() {
2175        let mut b = book("BTC-USD");
2176        b.apply(delta("BTC-USD", BookSide::Bid, dec!(99), dec!(1))).unwrap();
2177        b.apply(delta("BTC-USD", BookSide::Bid, dec!(98), dec!(1))).unwrap();
2178        // 2 bids, 0 asks → (2-0)/(2+0) = 1.0
2179        assert_eq!(b.level_count_imbalance(), Some(1.0));
2180    }
2181
2182    #[test]
2183    fn test_level_count_imbalance_none_for_empty_book() {
2184        let b = book("BTC-USD");
2185        assert!(b.level_count_imbalance().is_none());
2186    }
2187
2188    // ── OrderBook::total_bid_volume / total_ask_volume ────────────────────────
2189
2190    #[test]
2191    fn test_total_bid_volume_sums_all_levels() {
2192        let mut b = book("BTC-USD");
2193        b.apply(delta("BTC-USD", BookSide::Bid, dec!(99), dec!(3))).unwrap();
2194        b.apply(delta("BTC-USD", BookSide::Bid, dec!(98), dec!(2))).unwrap();
2195        assert_eq!(b.total_bid_volume(), dec!(5));
2196    }
2197
2198    #[test]
2199    fn test_total_ask_volume_sums_all_levels() {
2200        let mut b = book("BTC-USD");
2201        b.apply(delta("BTC-USD", BookSide::Ask, dec!(101), dec!(4))).unwrap();
2202        b.apply(delta("BTC-USD", BookSide::Ask, dec!(102), dec!(1))).unwrap();
2203        assert_eq!(b.total_ask_volume(), dec!(5));
2204    }
2205
2206    #[test]
2207    fn test_total_bid_volume_zero_for_empty_side() {
2208        let b = book("BTC-USD");
2209        assert_eq!(b.total_bid_volume(), dec!(0));
2210    }
2211
2212    // --- bid_levels_above / ask_levels_below ---
2213
2214    #[test]
2215    fn test_bid_levels_above_counts_strictly_above() {
2216        let mut b = book("BTC-USD");
2217        b.apply(delta("BTC-USD", BookSide::Bid, dec!(100), dec!(1))).unwrap();
2218        b.apply(delta("BTC-USD", BookSide::Bid, dec!(101), dec!(1))).unwrap();
2219        b.apply(delta("BTC-USD", BookSide::Bid, dec!(102), dec!(1))).unwrap();
2220        // levels above 100: 101 and 102 → 2
2221        assert_eq!(b.bid_levels_above(dec!(100)), 2);
2222    }
2223
2224    #[test]
2225    fn test_bid_levels_above_zero_when_none_above() {
2226        let mut b = book("BTC-USD");
2227        b.apply(delta("BTC-USD", BookSide::Bid, dec!(99), dec!(1))).unwrap();
2228        assert_eq!(b.bid_levels_above(dec!(100)), 0);
2229    }
2230
2231    #[test]
2232    fn test_ask_levels_below_counts_strictly_below() {
2233        let mut b = book("BTC-USD");
2234        b.apply(delta("BTC-USD", BookSide::Ask, dec!(100), dec!(1))).unwrap();
2235        b.apply(delta("BTC-USD", BookSide::Ask, dec!(101), dec!(1))).unwrap();
2236        b.apply(delta("BTC-USD", BookSide::Ask, dec!(102), dec!(1))).unwrap();
2237        // levels below 102: 100 and 101 → 2
2238        assert_eq!(b.ask_levels_below(dec!(102)), 2);
2239    }
2240
2241    #[test]
2242    fn test_ask_levels_below_zero_for_empty_book() {
2243        let b = book("BTC-USD");
2244        assert_eq!(b.ask_levels_below(dec!(100)), 0);
2245    }
2246
2247    // --- bid_ask_volume_ratio / top_n_bid_volume ---
2248
2249    #[test]
2250    fn test_bid_ask_volume_ratio_returns_correct_ratio() {
2251        let mut b = book("BTC-USD");
2252        b.apply(delta("BTC-USD", BookSide::Bid, dec!(100), dec!(3))).unwrap();
2253        b.apply(delta("BTC-USD", BookSide::Ask, dec!(101), dec!(1))).unwrap();
2254        // 3 / 1 = 3.0
2255        let ratio = b.bid_ask_volume_ratio().unwrap();
2256        assert!((ratio - 3.0).abs() < 1e-10);
2257    }
2258
2259    #[test]
2260    fn test_bid_ask_volume_ratio_none_when_ask_empty() {
2261        let mut b = book("BTC-USD");
2262        b.apply(delta("BTC-USD", BookSide::Bid, dec!(100), dec!(1))).unwrap();
2263        assert!(b.bid_ask_volume_ratio().is_none());
2264    }
2265
2266    #[test]
2267    fn test_bid_ask_volume_ratio_none_when_bid_empty() {
2268        let mut b = book("BTC-USD");
2269        b.apply(delta("BTC-USD", BookSide::Ask, dec!(101), dec!(1))).unwrap();
2270        assert!(b.bid_ask_volume_ratio().is_none());
2271    }
2272
2273    #[test]
2274    fn test_top_n_bid_volume_sums_top_levels() {
2275        let mut b = book("BTC-USD");
2276        b.apply(delta("BTC-USD", BookSide::Bid, dec!(100), dec!(1))).unwrap(); // worst
2277        b.apply(delta("BTC-USD", BookSide::Bid, dec!(101), dec!(2))).unwrap();
2278        b.apply(delta("BTC-USD", BookSide::Bid, dec!(102), dec!(3))).unwrap(); // best
2279        // top 2: 102 (3) + 101 (2) = 5
2280        assert_eq!(b.top_n_bid_volume(2), dec!(5));
2281    }
2282
2283    #[test]
2284    fn test_top_n_bid_volume_all_when_n_exceeds_levels() {
2285        let mut b = book("BTC-USD");
2286        b.apply(delta("BTC-USD", BookSide::Bid, dec!(100), dec!(1))).unwrap();
2287        b.apply(delta("BTC-USD", BookSide::Bid, dec!(101), dec!(2))).unwrap();
2288        // n=5 but only 2 levels → total = 3
2289        assert_eq!(b.top_n_bid_volume(5), dec!(3));
2290    }
2291
2292    #[test]
2293    fn test_top_n_bid_volume_zero_for_empty_book() {
2294        let b = book("BTC-USD");
2295        assert_eq!(b.top_n_bid_volume(3), dec!(0));
2296    }
2297
2298    // --- imbalance_ratio / top_n_ask_volume ---
2299
2300    #[test]
2301    fn test_imbalance_ratio_positive_when_more_bids() {
2302        let mut b = book("BTC-USD");
2303        b.apply(delta("BTC-USD", BookSide::Bid, dec!(100), dec!(3))).unwrap();
2304        b.apply(delta("BTC-USD", BookSide::Ask, dec!(101), dec!(1))).unwrap();
2305        // (3 - 1) / (3 + 1) = 0.5
2306        let ratio = b.imbalance_ratio().unwrap();
2307        assert!((ratio - 0.5).abs() < 1e-10);
2308    }
2309
2310    #[test]
2311    fn test_imbalance_ratio_negative_when_more_asks() {
2312        let mut b = book("BTC-USD");
2313        b.apply(delta("BTC-USD", BookSide::Bid, dec!(100), dec!(1))).unwrap();
2314        b.apply(delta("BTC-USD", BookSide::Ask, dec!(101), dec!(3))).unwrap();
2315        // (1 - 3) / (1 + 3) = -0.5
2316        let ratio = b.imbalance_ratio().unwrap();
2317        assert!((ratio - (-0.5)).abs() < 1e-10);
2318    }
2319
2320    #[test]
2321    fn test_imbalance_ratio_none_when_both_empty() {
2322        let b = book("BTC-USD");
2323        assert!(b.imbalance_ratio().is_none());
2324    }
2325
2326    #[test]
2327    fn test_top_n_ask_volume_sums_lowest_asks() {
2328        let mut b = book("BTC-USD");
2329        b.apply(delta("BTC-USD", BookSide::Ask, dec!(100), dec!(2))).unwrap(); // best ask
2330        b.apply(delta("BTC-USD", BookSide::Ask, dec!(101), dec!(3))).unwrap();
2331        b.apply(delta("BTC-USD", BookSide::Ask, dec!(102), dec!(5))).unwrap(); // worst
2332        // top 2: 100 (2) + 101 (3) = 5
2333        assert_eq!(b.top_n_ask_volume(2), dec!(5));
2334    }
2335
2336    #[test]
2337    fn test_top_n_ask_volume_zero_for_empty_book() {
2338        let b = book("BTC-USD");
2339        assert_eq!(b.top_n_ask_volume(3), dec!(0));
2340    }
2341
2342    // --- has_ask_at / bid_ask_depth ---
2343
2344    #[test]
2345    fn test_has_ask_at_true_when_ask_exists() {
2346        let mut b = book("BTC-USD");
2347        b.apply(delta("BTC-USD", BookSide::Ask, dec!(101), dec!(2))).unwrap();
2348        assert!(b.has_ask_at(dec!(101)));
2349    }
2350
2351    #[test]
2352    fn test_has_ask_at_false_when_no_ask_at_price() {
2353        let mut b = book("BTC-USD");
2354        b.apply(delta("BTC-USD", BookSide::Ask, dec!(102), dec!(1))).unwrap();
2355        assert!(!b.has_ask_at(dec!(101)));
2356    }
2357
2358    #[test]
2359    fn test_bid_ask_depth_correct_counts() {
2360        let mut b = book("BTC-USD");
2361        b.apply(delta("BTC-USD", BookSide::Bid, dec!(100), dec!(1))).unwrap();
2362        b.apply(delta("BTC-USD", BookSide::Bid, dec!(99), dec!(1))).unwrap();
2363        b.apply(delta("BTC-USD", BookSide::Ask, dec!(101), dec!(1))).unwrap();
2364        assert_eq!(b.bid_ask_depth(), (2, 1));
2365    }
2366
2367    #[test]
2368    fn test_bid_ask_depth_zero_for_empty_book() {
2369        let b = book("BTC-USD");
2370        assert_eq!(b.bid_ask_depth(), (0, 0));
2371    }
2372
2373    // --- OrderBook::best_bid_qty / best_ask_qty ---
2374    #[test]
2375    fn test_best_bid_qty_returns_top_bid_quantity() {
2376        let mut b = book("BTC-USD");
2377        b.apply(delta("BTC-USD", BookSide::Bid, dec!(100), dec!(3))).unwrap();
2378        b.apply(delta("BTC-USD", BookSide::Bid, dec!(99), dec!(5))).unwrap();
2379        // best bid = 100 with qty=3
2380        assert_eq!(b.best_bid_qty(), Some(dec!(3)));
2381    }
2382
2383    #[test]
2384    fn test_best_ask_qty_returns_top_ask_quantity() {
2385        let mut b = book("BTC-USD");
2386        b.apply(delta("BTC-USD", BookSide::Ask, dec!(101), dec!(7))).unwrap();
2387        b.apply(delta("BTC-USD", BookSide::Ask, dec!(102), dec!(2))).unwrap();
2388        // best ask = 101 with qty=7
2389        assert_eq!(b.best_ask_qty(), Some(dec!(7)));
2390    }
2391
2392    #[test]
2393    fn test_best_bid_qty_none_when_no_bids() {
2394        let b = book("BTC-USD");
2395        assert!(b.best_bid_qty().is_none());
2396    }
2397
2398    #[test]
2399    fn test_best_ask_qty_none_when_no_asks() {
2400        let b = book("BTC-USD");
2401        assert!(b.best_ask_qty().is_none());
2402    }
2403
2404    // --- OrderBook::total_book_volume ---
2405    #[test]
2406    fn test_total_book_volume_sum_of_bids_and_asks() {
2407        let mut b = book("BTC-USD");
2408        b.apply(delta("BTC-USD", BookSide::Bid, dec!(100), dec!(3))).unwrap();
2409        b.apply(delta("BTC-USD", BookSide::Ask, dec!(101), dec!(2))).unwrap();
2410        assert_eq!(b.total_book_volume(), dec!(5));
2411    }
2412
2413    #[test]
2414    fn test_total_book_volume_zero_on_empty_book() {
2415        let b = book("BTC-USD");
2416        assert_eq!(b.total_book_volume(), dec!(0));
2417    }
2418
2419    // --- OrderBook::price_range_bids ---
2420    #[test]
2421    fn test_price_range_bids_correct_range() {
2422        let mut b = book("BTC-USD");
2423        b.apply(delta("BTC-USD", BookSide::Bid, dec!(100), dec!(1))).unwrap();
2424        b.apply(delta("BTC-USD", BookSide::Bid, dec!(99), dec!(1))).unwrap();
2425        b.apply(delta("BTC-USD", BookSide::Bid, dec!(97), dec!(1))).unwrap();
2426        // best=100, worst=97 → range=3
2427        assert_eq!(b.price_range_bids(), Some(dec!(3)));
2428    }
2429
2430    #[test]
2431    fn test_price_range_bids_none_with_single_bid() {
2432        let mut b = book("BTC-USD");
2433        b.apply(delta("BTC-USD", BookSide::Bid, dec!(100), dec!(1))).unwrap();
2434        assert!(b.price_range_bids().is_none());
2435    }
2436
2437    // ── OrderBook::is_tight_spread ────────────────────────────────────────────
2438
2439    #[test]
2440    fn test_is_tight_spread_true_when_spread_at_threshold() {
2441        let mut b = book("BTC-USD");
2442        b.apply(delta("BTC-USD", BookSide::Bid, dec!(100), dec!(1))).unwrap();
2443        b.apply(delta("BTC-USD", BookSide::Ask, dec!(101), dec!(1))).unwrap();
2444        // spread = 1, threshold = 1 → tight
2445        assert!(b.is_tight_spread(dec!(1)));
2446    }
2447
2448    #[test]
2449    fn test_is_tight_spread_false_when_spread_above_threshold() {
2450        let mut b = book("BTC-USD");
2451        b.apply(delta("BTC-USD", BookSide::Bid, dec!(100), dec!(1))).unwrap();
2452        b.apply(delta("BTC-USD", BookSide::Ask, dec!(103), dec!(1))).unwrap();
2453        // spread = 3, threshold = 1 → not tight
2454        assert!(!b.is_tight_spread(dec!(1)));
2455    }
2456
2457    #[test]
2458    fn test_is_tight_spread_false_when_empty() {
2459        let b = book("BTC-USD");
2460        assert!(!b.is_tight_spread(dec!(10)));
2461    }
2462
2463    // ── OrderBook::best_bid_price / best_ask_price ────────────────────────────
2464
2465    #[test]
2466    fn test_best_bid_price_returns_price() {
2467        let mut b = book("X");
2468        b.apply(delta("X", BookSide::Bid, dec!(99), dec!(5))).unwrap();
2469        b.apply(delta("X", BookSide::Bid, dec!(98), dec!(3))).unwrap();
2470        assert_eq!(b.best_bid_price(), Some(dec!(99)));
2471    }
2472
2473    #[test]
2474    fn test_best_ask_price_returns_price() {
2475        let mut b = book("X");
2476        b.apply(delta("X", BookSide::Ask, dec!(101), dec!(5))).unwrap();
2477        b.apply(delta("X", BookSide::Ask, dec!(102), dec!(3))).unwrap();
2478        assert_eq!(b.best_ask_price(), Some(dec!(101)));
2479    }
2480
2481    #[test]
2482    fn test_best_bid_price_none_when_empty() {
2483        assert_eq!(book("X").best_bid_price(), None);
2484    }
2485
2486    #[test]
2487    fn test_best_ask_price_none_when_empty() {
2488        assert_eq!(book("X").best_ask_price(), None);
2489    }
2490
2491    // ── OrderBook::is_crossed ─────────────────────────────────────────────────
2492
2493    #[test]
2494    fn test_is_crossed_false_when_normal() {
2495        let mut b = book("X");
2496        b.apply(delta("X", BookSide::Bid, dec!(99), dec!(1))).unwrap();
2497        b.apply(delta("X", BookSide::Ask, dec!(101), dec!(1))).unwrap();
2498        assert!(!b.is_crossed());
2499    }
2500
2501    #[test]
2502    fn test_is_crossed_false_when_empty() {
2503        assert!(!book("X").is_crossed());
2504    }
2505
2506    // ── OrderBook::has_bids / has_asks ────────────────────────────────────────
2507
2508    #[test]
2509    fn test_has_bids_true_when_bid_present() {
2510        let mut b = book("X");
2511        b.apply(delta("X", BookSide::Bid, dec!(99), dec!(1))).unwrap();
2512        assert!(b.has_bids());
2513    }
2514
2515    #[test]
2516    fn test_has_bids_false_when_empty() {
2517        assert!(!book("X").has_bids());
2518    }
2519
2520    #[test]
2521    fn test_has_asks_true_when_ask_present() {
2522        let mut b = book("X");
2523        b.apply(delta("X", BookSide::Ask, dec!(101), dec!(1))).unwrap();
2524        assert!(b.has_asks());
2525    }
2526
2527    #[test]
2528    fn test_has_asks_false_when_empty() {
2529        assert!(!book("X").has_asks());
2530    }
2531
2532    // ── OrderBook::ask_price_range / bid_price_range ──────────────────────────
2533
2534    #[test]
2535    fn test_ask_price_range_none_when_empty() {
2536        assert_eq!(book("X").ask_price_range(), None);
2537    }
2538
2539    #[test]
2540    fn test_ask_price_range_zero_when_single_level() {
2541        let mut b = book("X");
2542        b.apply(delta("X", BookSide::Ask, dec!(100), dec!(1))).unwrap();
2543        assert_eq!(b.ask_price_range(), Some(dec!(0)));
2544    }
2545
2546    #[test]
2547    fn test_ask_price_range_correct_with_multiple_levels() {
2548        let mut b = book("X");
2549        b.apply(delta("X", BookSide::Ask, dec!(100), dec!(1))).unwrap();
2550        b.apply(delta("X", BookSide::Ask, dec!(102), dec!(1))).unwrap();
2551        b.apply(delta("X", BookSide::Ask, dec!(105), dec!(1))).unwrap();
2552        assert_eq!(b.ask_price_range(), Some(dec!(5)));
2553    }
2554
2555    #[test]
2556    fn test_bid_price_range_none_when_empty() {
2557        assert_eq!(book("X").bid_price_range(), None);
2558    }
2559
2560    #[test]
2561    fn test_bid_price_range_correct_with_multiple_levels() {
2562        let mut b = book("X");
2563        b.apply(delta("X", BookSide::Bid, dec!(98), dec!(1))).unwrap();
2564        b.apply(delta("X", BookSide::Bid, dec!(96), dec!(1))).unwrap();
2565        b.apply(delta("X", BookSide::Bid, dec!(94), dec!(1))).unwrap();
2566        // best = 98, worst = 94, range = 4
2567        assert_eq!(b.bid_price_range(), Some(dec!(4)));
2568    }
2569
2570    // ── OrderBook::mid_spread_ratio ───────────────────────────────────────────
2571
2572    #[test]
2573    fn test_mid_spread_ratio_none_when_empty() {
2574        assert_eq!(book("X").mid_spread_ratio(), None);
2575    }
2576
2577    #[test]
2578    fn test_mid_spread_ratio_correct() {
2579        let mut b = book("X");
2580        b.apply(delta("X", BookSide::Bid, dec!(99), dec!(1))).unwrap();
2581        b.apply(delta("X", BookSide::Ask, dec!(101), dec!(1))).unwrap();
2582        // spread = 2, mid = 100, ratio = 2/100 = 0.02
2583        let ratio = b.mid_spread_ratio().unwrap();
2584        assert!((ratio - 0.02).abs() < 1e-9);
2585    }
2586
2587    // ── OrderBook::volume_imbalance ────────────────────────────────────────────
2588
2589    #[test]
2590    fn test_volume_imbalance_none_when_empty() {
2591        assert_eq!(book("X").volume_imbalance(), None);
2592    }
2593
2594    #[test]
2595    fn test_volume_imbalance_positive_when_more_bids() {
2596        let mut b = book("X");
2597        b.apply(delta("X", BookSide::Bid, dec!(99), dec!(3))).unwrap();
2598        b.apply(delta("X", BookSide::Ask, dec!(101), dec!(1))).unwrap();
2599        // (3 - 1) / (3 + 1) = 0.5
2600        let imb = b.volume_imbalance().unwrap();
2601        assert!((imb - 0.5).abs() < 1e-9);
2602    }
2603
2604    #[test]
2605    fn test_volume_imbalance_negative_when_more_asks() {
2606        let mut b = book("X");
2607        b.apply(delta("X", BookSide::Bid, dec!(99), dec!(1))).unwrap();
2608        b.apply(delta("X", BookSide::Ask, dec!(101), dec!(3))).unwrap();
2609        // (1 - 3) / (1 + 3) = -0.5
2610        let imb = b.volume_imbalance().unwrap();
2611        assert!((imb - (-0.5)).abs() < 1e-9);
2612    }
2613
2614    #[test]
2615    fn test_volume_imbalance_zero_when_equal() {
2616        let mut b = book("X");
2617        b.apply(delta("X", BookSide::Bid, dec!(99), dec!(2))).unwrap();
2618        b.apply(delta("X", BookSide::Ask, dec!(101), dec!(2))).unwrap();
2619        let imb = b.volume_imbalance().unwrap();
2620        assert!(imb.abs() < 1e-9);
2621    }
2622
2623    // ── OrderBook::ask_volume_within / bid_volume_within ─────────────────────
2624
2625    #[test]
2626    fn test_ask_volume_within_sums_levels_in_range() {
2627        let mut b = book("X");
2628        b.apply(delta("X", BookSide::Ask, dec!(100), dec!(2))).unwrap();
2629        b.apply(delta("X", BookSide::Ask, dec!(101), dec!(3))).unwrap();
2630        b.apply(delta("X", BookSide::Ask, dec!(105), dec!(10))).unwrap();
2631        // best ask = 100, range = 2 → include 100 and 101 (102 is limit, 105 outside)
2632        let vol = b.ask_volume_within(dec!(2));
2633        assert_eq!(vol, dec!(5)); // 2 + 3
2634    }
2635
2636    #[test]
2637    fn test_ask_volume_within_zero_when_empty() {
2638        assert_eq!(book("X").ask_volume_within(dec!(10)), dec!(0));
2639    }
2640
2641    #[test]
2642    fn test_bid_volume_within_sums_levels_in_range() {
2643        let mut b = book("X");
2644        b.apply(delta("X", BookSide::Bid, dec!(100), dec!(5))).unwrap();
2645        b.apply(delta("X", BookSide::Bid, dec!(99), dec!(3))).unwrap();
2646        b.apply(delta("X", BookSide::Bid, dec!(95), dec!(10))).unwrap();
2647        // best bid = 100, range = 2 → include 100 and 99 (floor=98, 95 outside)
2648        let vol = b.bid_volume_within(dec!(2));
2649        assert_eq!(vol, dec!(8)); // 5 + 3
2650    }
2651
2652    #[test]
2653    fn test_bid_volume_within_zero_when_empty() {
2654        assert_eq!(book("X").bid_volume_within(dec!(10)), dec!(0));
2655    }
2656
2657    // ── OrderBook::ask_level_count / bid_level_count ─────────────────────────
2658
2659    #[test]
2660    fn test_ask_level_count_zero_when_empty() {
2661        assert_eq!(book("X").ask_level_count(), 0);
2662    }
2663
2664    #[test]
2665    fn test_ask_level_count_correct() {
2666        let mut b = book("X");
2667        b.apply(delta("X", BookSide::Ask, dec!(100), dec!(1))).unwrap();
2668        b.apply(delta("X", BookSide::Ask, dec!(101), dec!(2))).unwrap();
2669        b.apply(delta("X", BookSide::Ask, dec!(102), dec!(3))).unwrap();
2670        assert_eq!(b.ask_level_count(), 3);
2671    }
2672
2673    #[test]
2674    fn test_bid_level_count_correct() {
2675        let mut b = book("X");
2676        b.apply(delta("X", BookSide::Bid, dec!(99), dec!(1))).unwrap();
2677        b.apply(delta("X", BookSide::Bid, dec!(98), dec!(2))).unwrap();
2678        assert_eq!(b.bid_level_count(), 2);
2679    }
2680
2681    // ── OrderBook::price_impact_buy / price_impact_sell ──────────────────────
2682
2683    #[test]
2684    fn test_price_impact_buy_correct_single_level() {
2685        let mut b = book("X");
2686        b.apply(delta("X", BookSide::Ask, dec!(100), dec!(10))).unwrap();
2687        // buy 5 units all at 100 → avg = 100
2688        assert_eq!(b.price_impact_buy(dec!(5)), Some(dec!(100)));
2689    }
2690
2691    #[test]
2692    fn test_price_impact_buy_spans_levels() {
2693        let mut b = book("X");
2694        b.apply(delta("X", BookSide::Ask, dec!(100), dec!(5))).unwrap();
2695        b.apply(delta("X", BookSide::Ask, dec!(101), dec!(5))).unwrap();
2696        // buy 10: 5@100 + 5@101 → avg = 100.5
2697        assert_eq!(b.price_impact_buy(dec!(10)), Some(dec!(100.5)));
2698    }
2699
2700    #[test]
2701    fn test_price_impact_buy_none_when_insufficient_liquidity() {
2702        let mut b = book("X");
2703        b.apply(delta("X", BookSide::Ask, dec!(100), dec!(3))).unwrap();
2704        assert!(b.price_impact_buy(dec!(5)).is_none());
2705    }
2706
2707    #[test]
2708    fn test_price_impact_sell_correct_single_level() {
2709        let mut b = book("X");
2710        b.apply(delta("X", BookSide::Bid, dec!(99), dec!(10))).unwrap();
2711        assert_eq!(b.price_impact_sell(dec!(5)), Some(dec!(99)));
2712    }
2713
2714    // ── total_value_at_level ──────────────────────────────────────────────────
2715
2716    #[test]
2717    fn test_total_value_at_level_bid_returns_price_times_qty() {
2718        let mut b = book("X");
2719        b.apply(delta("X", BookSide::Bid, dec!(100), dec!(5))).unwrap();
2720        assert_eq!(b.total_value_at_level(BookSide::Bid, dec!(100)), Some(dec!(500)));
2721    }
2722
2723    #[test]
2724    fn test_total_value_at_level_ask_returns_price_times_qty() {
2725        let mut b = book("X");
2726        b.apply(delta("X", BookSide::Ask, dec!(105), dec!(3))).unwrap();
2727        assert_eq!(b.total_value_at_level(BookSide::Ask, dec!(105)), Some(dec!(315)));
2728    }
2729
2730    #[test]
2731    fn test_total_value_at_level_none_when_price_missing() {
2732        let b = book("X");
2733        assert!(b.total_value_at_level(BookSide::Bid, dec!(100)).is_none());
2734    }
2735
2736    // ── ask_volume_above / bid_volume_below ───────────────────────────────────
2737
2738    #[test]
2739    fn test_ask_volume_above_sums_asks_above_price() {
2740        let mut b = book("X");
2741        b.apply(delta("X", BookSide::Ask, dec!(101), dec!(5))).unwrap();
2742        b.apply(delta("X", BookSide::Ask, dec!(102), dec!(3))).unwrap();
2743        b.apply(delta("X", BookSide::Ask, dec!(103), dec!(2))).unwrap();
2744        // volume above 101: 3+2=5
2745        assert_eq!(b.ask_volume_above(dec!(101)), dec!(5));
2746    }
2747
2748    #[test]
2749    fn test_ask_volume_above_zero_when_no_asks_above() {
2750        let mut b = book("X");
2751        b.apply(delta("X", BookSide::Ask, dec!(100), dec!(10))).unwrap();
2752        assert_eq!(b.ask_volume_above(dec!(100)), dec!(0));
2753    }
2754
2755    #[test]
2756    fn test_bid_volume_below_sums_bids_below_price() {
2757        let mut b = book("X");
2758        b.apply(delta("X", BookSide::Bid, dec!(98), dec!(4))).unwrap();
2759        b.apply(delta("X", BookSide::Bid, dec!(99), dec!(6))).unwrap();
2760        b.apply(delta("X", BookSide::Bid, dec!(100), dec!(2))).unwrap();
2761        // volume below 100: 4+6=10
2762        assert_eq!(b.bid_volume_below(dec!(100)), dec!(10));
2763    }
2764
2765    #[test]
2766    fn test_bid_volume_below_zero_when_no_bids_below() {
2767        let mut b = book("X");
2768        b.apply(delta("X", BookSide::Bid, dec!(100), dec!(5))).unwrap();
2769        assert_eq!(b.bid_volume_below(dec!(100)), dec!(0));
2770    }
2771
2772    // ── total_notional_both_sides ─────────────────────────────────────────────
2773
2774    #[test]
2775    fn test_total_notional_both_sides_sums_both() {
2776        let mut b = book("X");
2777        b.apply(delta("X", BookSide::Bid, dec!(100), dec!(2))).unwrap(); // 200
2778        b.apply(delta("X", BookSide::Ask, dec!(105), dec!(3))).unwrap(); // 315
2779        assert_eq!(b.total_notional_both_sides(), dec!(515));
2780    }
2781
2782    #[test]
2783    fn test_total_notional_both_sides_zero_when_empty() {
2784        let b = book("X");
2785        assert_eq!(b.total_notional_both_sides(), dec!(0));
2786    }
2787
2788    // ── level_count_both_sides ────────────────────────────────────────────────
2789
2790    #[test]
2791    fn test_level_count_both_sides_zero_when_empty() {
2792        let b = book("X");
2793        assert_eq!(b.level_count_both_sides(), 0);
2794    }
2795
2796    #[test]
2797    fn test_level_count_both_sides_counts_all_levels() {
2798        let mut b = book("X");
2799        b.apply(delta("X", BookSide::Bid, dec!(99), dec!(1))).unwrap();
2800        b.apply(delta("X", BookSide::Bid, dec!(98), dec!(1))).unwrap();
2801        b.apply(delta("X", BookSide::Ask, dec!(101), dec!(1))).unwrap();
2802        assert_eq!(b.level_count_both_sides(), 3);
2803    }
2804
2805    // ── ask_price_at_rank / bid_price_at_rank ─────────────────────────────────
2806
2807    #[test]
2808    fn test_ask_price_at_rank_best_ask_at_zero() {
2809        let mut b = book("X");
2810        b.apply(delta("X", BookSide::Ask, dec!(101), dec!(1))).unwrap();
2811        b.apply(delta("X", BookSide::Ask, dec!(102), dec!(1))).unwrap();
2812        assert_eq!(b.ask_price_at_rank(0), Some(dec!(101)));
2813    }
2814
2815    #[test]
2816    fn test_bid_price_at_rank_best_bid_at_zero() {
2817        let mut b = book("X");
2818        b.apply(delta("X", BookSide::Bid, dec!(99), dec!(1))).unwrap();
2819        b.apply(delta("X", BookSide::Bid, dec!(98), dec!(1))).unwrap();
2820        assert_eq!(b.bid_price_at_rank(0), Some(dec!(99)));
2821    }
2822
2823    #[test]
2824    fn test_ask_price_at_rank_none_out_of_bounds() {
2825        let b = book("X");
2826        assert!(b.ask_price_at_rank(0).is_none());
2827    }
2828
2829    // ── price_level_exists ────────────────────────────────────────────────────
2830
2831    #[test]
2832    fn test_price_level_exists_true_when_present() {
2833        let mut b = book("X");
2834        b.apply(delta("X", BookSide::Bid, dec!(100), dec!(1))).unwrap();
2835        assert!(b.price_level_exists(BookSide::Bid, dec!(100)));
2836    }
2837
2838    #[test]
2839    fn test_price_level_exists_false_when_absent() {
2840        let b = book("X");
2841        assert!(!b.price_level_exists(BookSide::Bid, dec!(100)));
2842    }
2843
2844    #[test]
2845    fn test_price_level_exists_ask_side() {
2846        let mut b = book("X");
2847        b.apply(delta("X", BookSide::Ask, dec!(101), dec!(5))).unwrap();
2848        assert!(b.price_level_exists(BookSide::Ask, dec!(101)));
2849        assert!(!b.price_level_exists(BookSide::Ask, dec!(102)));
2850    }
2851}