Skip to main content

rustyqlib/core/
depth.rs

1//! Limit-order-book depth: the **execution-layer** observable.
2//!
3//! Pricing never walks a ladder — it reads a [`Quote`] mark. Execution
4//! analytics (slippage, liquidation value, sizing) do walk it. Keeping
5//! [`MarketDepth`] as a sibling of [`Quote`] under its own market key
6//! ([`Depth`](crate::core::market::Depth)) preserves that layering: a
7//! trading system publishes the book here and the top-of-book collapses
8//! into a [`Quote`] at the snapshot boundary ([`to_quote`]
9//! (MarketDepth::to_quote)); the pricing layer never changes.
10//!
11//! Scenario shocks do not rewrite ladders: bump the pricing observables
12//! ([`Spot`](crate::core::market::Spot)), let the adapter republish depth.
13
14use crate::core::errors::{Result, RustyQLibError};
15use crate::core::quotes::Quote;
16use crate::core::trade::Transection;
17
18/// One book level: a price and the size displayed at it.
19#[derive(Debug, Clone, Copy, PartialEq)]
20pub struct DepthLevel {
21    pub price: f64,
22    pub size: f64,
23}
24
25/// A price-aggregated (L2) snapshot of one instrument's book: bids best
26/// (highest) first, asks best (lowest) first. Validated on construction —
27/// sorted sides, positive finite sizes, uncrossed top — so consumers can
28/// walk it without re-checking.
29#[derive(Debug, Clone, PartialEq)]
30pub struct MarketDepth {
31    bids: Vec<DepthLevel>,
32    asks: Vec<DepthLevel>,
33}
34
35fn validate_side(levels: &[DepthLevel], side: &str, descending: bool) -> Result<()> {
36    for level in levels {
37        if !level.price.is_finite() || !level.size.is_finite() || level.size <= 0.0 {
38            return Err(RustyQLibError::invalid_input(
39                "market depth",
40                format!("{side} level must have finite price and positive size, got {level:?}"),
41            ));
42        }
43    }
44    let ordered = levels.windows(2).all(|w| {
45        if descending { w[1].price < w[0].price } else { w[1].price > w[0].price }
46    });
47    if !ordered {
48        return Err(RustyQLibError::invalid_input(
49            "market depth",
50            format!("{side} levels must be strictly best-first, got {levels:?}"),
51        ));
52    }
53    Ok(())
54}
55
56impl MarketDepth {
57    /// Build a validated book. Either side may be empty (a one-sided
58    /// market); a crossed top (`best_bid > best_ask`) is rejected.
59    pub fn new(bids: Vec<DepthLevel>, asks: Vec<DepthLevel>) -> Result<Self> {
60        validate_side(&bids, "bid", true)?;
61        validate_side(&asks, "ask", false)?;
62        if let (Some(bid), Some(ask)) = (bids.first(), asks.first()) {
63            if bid.price > ask.price {
64                return Err(RustyQLibError::invalid_input(
65                    "market depth",
66                    format!("crossed book: best bid {} > best ask {}", bid.price, ask.price),
67                ));
68            }
69        }
70        Ok(MarketDepth { bids, asks })
71    }
72
73    pub fn bids(&self) -> &[DepthLevel] {
74        &self.bids
75    }
76
77    pub fn asks(&self) -> &[DepthLevel] {
78        &self.asks
79    }
80
81    pub fn best_bid(&self) -> Option<DepthLevel> {
82        self.bids.first().copied()
83    }
84
85    pub fn best_ask(&self) -> Option<DepthLevel> {
86        self.asks.first().copied()
87    }
88
89    /// Top-of-book midpoint, when both sides exist.
90    pub fn mid(&self) -> Option<f64> {
91        Some(0.5 * (self.best_bid()?.price + self.best_ask()?.price))
92    }
93
94    /// Volume-weighted average execution price for `quantity`, walking
95    /// the book: a `Buy` lifts asks, a `Sell` hits bids. `None` when the
96    /// displayed depth cannot fill the quantity (or it is not positive) —
97    /// the honest answer, not an extrapolation.
98    pub fn vwap_for_size(&self, side: Transection, quantity: f64) -> Option<f64> {
99        if !(quantity > 0.0) || !quantity.is_finite() {
100            return None;
101        }
102        let levels = match side {
103            Transection::Buy => &self.asks,
104            Transection::Sell => &self.bids,
105        };
106        let mut remaining = quantity;
107        let mut cost = 0.0;
108        for level in levels {
109            let fill = remaining.min(level.size);
110            cost += fill * level.price;
111            remaining -= fill;
112            if remaining <= 0.0 {
113                return Some(cost / quantity);
114            }
115        }
116        None
117    }
118
119    /// Slippage of filling `quantity` versus marking at mid: `vwap - mid`
120    /// for a buy, `mid - vwap` for a sell (so it is a cost when positive).
121    pub fn slippage_for_size(&self, side: Transection, quantity: f64) -> Option<f64> {
122        let mid = self.mid()?;
123        let vwap = self.vwap_for_size(side.clone(), quantity)?;
124        Some(match side {
125            Transection::Buy => vwap - mid,
126            Transection::Sell => mid - vwap,
127        })
128    }
129
130    /// Collapse to the pricing observable: a sized top-of-book [`Quote`],
131    /// when both sides exist. This is the snapshot-boundary conversion a
132    /// trading adapter runs to feed the pricing layer.
133    pub fn to_quote(&self) -> Option<Quote> {
134        let bid = self.best_bid()?;
135        let ask = self.best_ask()?;
136        Quote::from_bid_ask_sized(bid.price, bid.size, ask.price, ask.size).ok()
137    }
138}
139
140#[cfg(test)]
141mod tests {
142    use super::*;
143
144    fn level(price: f64, size: f64) -> DepthLevel {
145        DepthLevel { price, size }
146    }
147
148    fn book() -> MarketDepth {
149        MarketDepth::new(
150            vec![level(99.0, 100.0), level(98.5, 200.0), level(98.0, 500.0)],
151            vec![level(101.0, 150.0), level(101.5, 300.0), level(102.0, 400.0)],
152        )
153        .unwrap()
154    }
155
156    #[test]
157    fn construction_validates_ordering_sizes_and_crossing() {
158        // unsorted bids (must be descending)
159        assert!(MarketDepth::new(vec![level(98.0, 1.0), level(99.0, 1.0)], vec![]).is_err());
160        // unsorted asks (must be ascending)
161        assert!(MarketDepth::new(vec![], vec![level(102.0, 1.0), level(101.0, 1.0)]).is_err());
162        // crossed top
163        assert!(
164            MarketDepth::new(vec![level(101.5, 1.0)], vec![level(101.0, 1.0)]).is_err()
165        );
166        // non-positive size
167        assert!(MarketDepth::new(vec![level(99.0, 0.0)], vec![]).is_err());
168        // one-sided and empty books are legal
169        assert!(MarketDepth::new(vec![level(99.0, 1.0)], vec![]).is_ok());
170        assert!(MarketDepth::new(vec![], vec![]).is_ok());
171        assert_eq!(book().mid(), Some(100.0));
172    }
173
174    #[test]
175    fn vwap_walks_the_ladder_and_refuses_to_extrapolate() {
176        let depth = book();
177        // inside the top level: pay the touch
178        assert_eq!(depth.vwap_for_size(Transection::Buy, 150.0), Some(101.0));
179        // 300 lifts 150 @ 101 + 150 @ 101.5
180        let vwap = depth.vwap_for_size(Transection::Buy, 300.0).unwrap();
181        assert!((vwap - (150.0 * 101.0 + 150.0 * 101.5) / 300.0).abs() < 1e-12);
182        // selling walks the bid side
183        let sell = depth.vwap_for_size(Transection::Sell, 250.0).unwrap();
184        assert!((sell - (100.0 * 99.0 + 150.0 * 98.5) / 250.0).abs() < 1e-12);
185        // more than the displayed book: None, not a guess
186        assert_eq!(depth.vwap_for_size(Transection::Buy, 1_000.0), None);
187        assert_eq!(depth.vwap_for_size(Transection::Buy, 0.0), None);
188        // slippage is a positive cost on both sides of this book
189        assert!(depth.slippage_for_size(Transection::Buy, 300.0).unwrap() > 0.0);
190        assert!(depth.slippage_for_size(Transection::Sell, 250.0).unwrap() > 0.0);
191    }
192
193    #[test]
194    fn to_quote_collapses_the_top_of_book_for_pricing() {
195        let quote = book().to_quote().unwrap();
196        assert_eq!(quote.bid(), Some(99.0));
197        assert_eq!(quote.ask(), Some(101.0));
198        assert_eq!(quote.mid(), 100.0);
199        match quote {
200            Quote::Sized { bid_size, ask_size, .. } => {
201                assert_eq!((bid_size, ask_size), (100.0, 150.0));
202            }
203            other => panic!("expected a sized quote, got {other:?}"),
204        }
205        // a one-sided book has no priceable top
206        let one_sided = MarketDepth::new(vec![level(99.0, 1.0)], vec![]).unwrap();
207        assert_eq!(one_sided.to_quote(), None);
208    }
209}