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