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//! - Thread-safe: OrderBook is Send + Sync via internal RwLock
12
13use crate::error::StreamError;
14use rust_decimal::Decimal;
15use std::collections::BTreeMap;
16
17/// Side of the order book.
18#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
19pub enum BookSide {
20    Bid,
21    Ask,
22}
23
24/// A single price level in the order book.
25#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
26pub struct PriceLevel {
27    pub price: Decimal,
28    pub quantity: Decimal,
29}
30
31impl PriceLevel {
32    pub fn new(price: Decimal, quantity: Decimal) -> Self {
33        Self { price, quantity }
34    }
35}
36
37/// Incremental order book update.
38#[derive(Debug, Clone)]
39pub struct BookDelta {
40    pub symbol: String,
41    pub side: BookSide,
42    /// Price level to update. quantity == 0 means remove.
43    pub price: Decimal,
44    pub quantity: Decimal,
45    pub sequence: Option<u64>,
46}
47
48impl BookDelta {
49    pub fn new(symbol: impl Into<String>, side: BookSide, price: Decimal, quantity: Decimal) -> Self {
50        Self { symbol: symbol.into(), side, price, quantity, sequence: None }
51    }
52
53    pub fn with_sequence(mut self, seq: u64) -> Self {
54        self.sequence = Some(seq);
55        self
56    }
57}
58
59/// Live order book for a single symbol.
60pub struct OrderBook {
61    symbol: String,
62    bids: BTreeMap<Decimal, Decimal>, // price → quantity, descending iteration via neg key
63    asks: BTreeMap<Decimal, Decimal>, // price → quantity, ascending
64    last_sequence: Option<u64>,
65}
66
67impl OrderBook {
68    pub fn new(symbol: impl Into<String>) -> Self {
69        Self {
70            symbol: symbol.into(),
71            bids: BTreeMap::new(),
72            asks: BTreeMap::new(),
73            last_sequence: None,
74        }
75    }
76
77    /// Apply an incremental delta. quantity == 0 removes the level.
78    pub fn apply(&mut self, delta: BookDelta) -> Result<(), StreamError> {
79        if delta.symbol != self.symbol {
80            return Err(StreamError::BookReconstructionFailed {
81                symbol: self.symbol.clone(),
82                reason: format!("delta symbol '{}' does not match book '{}'", delta.symbol, self.symbol),
83            });
84        }
85        let map = match delta.side {
86            BookSide::Bid => &mut self.bids,
87            BookSide::Ask => &mut self.asks,
88        };
89        if delta.quantity.is_zero() {
90            map.remove(&delta.price);
91        } else {
92            map.insert(delta.price, delta.quantity);
93        }
94        if let Some(seq) = delta.sequence {
95            self.last_sequence = Some(seq);
96        }
97        self.check_crossed()
98    }
99
100    /// Reset the book from a full snapshot.
101    pub fn reset(
102        &mut self,
103        bids: Vec<PriceLevel>,
104        asks: Vec<PriceLevel>,
105    ) -> Result<(), StreamError> {
106        self.bids.clear();
107        self.asks.clear();
108        for lvl in bids {
109            if !lvl.quantity.is_zero() {
110                self.bids.insert(lvl.price, lvl.quantity);
111            }
112        }
113        for lvl in asks {
114            if !lvl.quantity.is_zero() {
115                self.asks.insert(lvl.price, lvl.quantity);
116            }
117        }
118        self.check_crossed()
119    }
120
121    /// Best bid (highest).
122    pub fn best_bid(&self) -> Option<PriceLevel> {
123        self.bids.iter().next_back().map(|(p, q)| PriceLevel::new(*p, *q))
124    }
125
126    /// Best ask (lowest).
127    pub fn best_ask(&self) -> Option<PriceLevel> {
128        self.asks.iter().next().map(|(p, q)| PriceLevel::new(*p, *q))
129    }
130
131    /// Mid price.
132    pub fn mid_price(&self) -> Option<Decimal> {
133        let bid = self.best_bid()?.price;
134        let ask = self.best_ask()?.price;
135        Some((bid + ask) / Decimal::from(2))
136    }
137
138    /// Spread.
139    pub fn spread(&self) -> Option<Decimal> {
140        let bid = self.best_bid()?.price;
141        let ask = self.best_ask()?.price;
142        Some(ask - bid)
143    }
144
145    /// Number of bid levels.
146    pub fn bid_depth(&self) -> usize { self.bids.len() }
147
148    /// Number of ask levels.
149    pub fn ask_depth(&self) -> usize { self.asks.len() }
150
151    pub fn symbol(&self) -> &str { &self.symbol }
152    pub fn last_sequence(&self) -> Option<u64> { self.last_sequence }
153
154    /// Top N bids (descending by price).
155    pub fn top_bids(&self, n: usize) -> Vec<PriceLevel> {
156        self.bids.iter().rev().take(n).map(|(p, q)| PriceLevel::new(*p, *q)).collect()
157    }
158
159    /// Top N asks (ascending by price).
160    pub fn top_asks(&self, n: usize) -> Vec<PriceLevel> {
161        self.asks.iter().take(n).map(|(p, q)| PriceLevel::new(*p, *q)).collect()
162    }
163
164    fn check_crossed(&self) -> Result<(), StreamError> {
165        if let (Some(bid), Some(ask)) = (self.best_bid(), self.best_ask()) {
166            if bid.price >= ask.price {
167                return Err(StreamError::BookCrossed {
168                    symbol: self.symbol.clone(),
169                    bid: bid.price.to_string(),
170                    ask: ask.price.to_string(),
171                });
172            }
173        }
174        Ok(())
175    }
176}
177
178#[cfg(test)]
179mod tests {
180    use super::*;
181    use rust_decimal_macros::dec;
182
183    fn book(symbol: &str) -> OrderBook { OrderBook::new(symbol) }
184
185    fn delta(symbol: &str, side: BookSide, price: Decimal, qty: Decimal) -> BookDelta {
186        BookDelta::new(symbol, side, price, qty)
187    }
188
189    #[test]
190    fn test_order_book_apply_bid_level() {
191        let mut b = book("BTC-USD");
192        b.apply(delta("BTC-USD", BookSide::Bid, dec!(50000), dec!(1))).unwrap();
193        assert_eq!(b.best_bid().unwrap().price, dec!(50000));
194    }
195
196    #[test]
197    fn test_order_book_apply_ask_level() {
198        let mut b = book("BTC-USD");
199        b.apply(delta("BTC-USD", BookSide::Ask, dec!(50100), dec!(2))).unwrap();
200        assert_eq!(b.best_ask().unwrap().price, dec!(50100));
201    }
202
203    #[test]
204    fn test_order_book_remove_level_with_zero_qty() {
205        let mut b = book("BTC-USD");
206        b.apply(delta("BTC-USD", BookSide::Bid, dec!(50000), dec!(1))).unwrap();
207        b.apply(delta("BTC-USD", BookSide::Bid, dec!(50000), dec!(0))).unwrap();
208        assert!(b.best_bid().is_none());
209    }
210
211    #[test]
212    fn test_order_book_best_bid_is_highest() {
213        let mut b = book("BTC-USD");
214        b.apply(delta("BTC-USD", BookSide::Bid, dec!(49900), dec!(1))).unwrap();
215        b.apply(delta("BTC-USD", BookSide::Bid, dec!(50000), dec!(2))).unwrap();
216        b.apply(delta("BTC-USD", BookSide::Bid, dec!(49800), dec!(3))).unwrap();
217        assert_eq!(b.best_bid().unwrap().price, dec!(50000));
218    }
219
220    #[test]
221    fn test_order_book_best_ask_is_lowest() {
222        let mut b = book("BTC-USD");
223        b.apply(delta("BTC-USD", BookSide::Ask, dec!(50200), dec!(1))).unwrap();
224        b.apply(delta("BTC-USD", BookSide::Ask, dec!(50100), dec!(2))).unwrap();
225        b.apply(delta("BTC-USD", BookSide::Ask, dec!(50300), dec!(3))).unwrap();
226        assert_eq!(b.best_ask().unwrap().price, dec!(50100));
227    }
228
229    #[test]
230    fn test_order_book_mid_price() {
231        let mut b = book("BTC-USD");
232        b.apply(delta("BTC-USD", BookSide::Bid, dec!(50000), dec!(1))).unwrap();
233        b.apply(delta("BTC-USD", BookSide::Ask, dec!(50100), dec!(1))).unwrap();
234        assert_eq!(b.mid_price().unwrap(), dec!(50050));
235    }
236
237    #[test]
238    fn test_order_book_spread() {
239        let mut b = book("BTC-USD");
240        b.apply(delta("BTC-USD", BookSide::Bid, dec!(50000), dec!(1))).unwrap();
241        b.apply(delta("BTC-USD", BookSide::Ask, dec!(50100), dec!(1))).unwrap();
242        assert_eq!(b.spread().unwrap(), dec!(100));
243    }
244
245    #[test]
246    fn test_order_book_crossed_returns_error() {
247        let mut b = book("BTC-USD");
248        b.apply(delta("BTC-USD", BookSide::Ask, dec!(50000), dec!(1))).unwrap();
249        let result = b.apply(delta("BTC-USD", BookSide::Bid, dec!(50001), dec!(1)));
250        assert!(matches!(result, Err(StreamError::BookCrossed { .. })));
251    }
252
253    #[test]
254    fn test_order_book_wrong_symbol_delta_rejected() {
255        let mut b = book("BTC-USD");
256        let result = b.apply(delta("ETH-USD", BookSide::Bid, dec!(3000), dec!(1)));
257        assert!(matches!(result, Err(StreamError::BookReconstructionFailed { .. })));
258    }
259
260    #[test]
261    fn test_order_book_reset_clears_and_reloads() {
262        let mut b = book("BTC-USD");
263        b.apply(delta("BTC-USD", BookSide::Bid, dec!(49000), dec!(5))).unwrap();
264        b.reset(
265            vec![PriceLevel::new(dec!(50000), dec!(1))],
266            vec![PriceLevel::new(dec!(50100), dec!(1))],
267        ).unwrap();
268        assert_eq!(b.bid_depth(), 1);
269        assert_eq!(b.best_bid().unwrap().price, dec!(50000));
270    }
271
272    #[test]
273    fn test_order_book_reset_ignores_zero_qty_levels() {
274        let mut b = book("BTC-USD");
275        b.reset(
276            vec![
277                PriceLevel::new(dec!(50000), dec!(1)),
278                PriceLevel::new(dec!(49900), dec!(0)),
279            ],
280            vec![PriceLevel::new(dec!(50100), dec!(1))],
281        ).unwrap();
282        assert_eq!(b.bid_depth(), 1);
283    }
284
285    #[test]
286    fn test_order_book_depth_counts() {
287        let mut b = book("BTC-USD");
288        b.apply(delta("BTC-USD", BookSide::Bid, dec!(49900), dec!(1))).unwrap();
289        b.apply(delta("BTC-USD", BookSide::Bid, dec!(49800), dec!(1))).unwrap();
290        b.apply(delta("BTC-USD", BookSide::Ask, dec!(50100), dec!(1))).unwrap();
291        assert_eq!(b.bid_depth(), 2);
292        assert_eq!(b.ask_depth(), 1);
293    }
294
295    #[test]
296    fn test_order_book_top_bids_descending() {
297        let mut b = book("BTC-USD");
298        b.apply(delta("BTC-USD", BookSide::Bid, dec!(49800), dec!(3))).unwrap();
299        b.apply(delta("BTC-USD", BookSide::Bid, dec!(50000), dec!(1))).unwrap();
300        b.apply(delta("BTC-USD", BookSide::Bid, dec!(49900), dec!(2))).unwrap();
301        let top = b.top_bids(2);
302        assert_eq!(top[0].price, dec!(50000));
303        assert_eq!(top[1].price, dec!(49900));
304    }
305
306    #[test]
307    fn test_order_book_top_asks_ascending() {
308        let mut b = book("BTC-USD");
309        b.apply(delta("BTC-USD", BookSide::Ask, dec!(50300), dec!(3))).unwrap();
310        b.apply(delta("BTC-USD", BookSide::Ask, dec!(50100), dec!(1))).unwrap();
311        b.apply(delta("BTC-USD", BookSide::Ask, dec!(50200), dec!(2))).unwrap();
312        let top = b.top_asks(2);
313        assert_eq!(top[0].price, dec!(50100));
314        assert_eq!(top[1].price, dec!(50200));
315    }
316
317    #[test]
318    fn test_book_delta_with_sequence() {
319        let d = BookDelta::new("BTC-USD", BookSide::Bid, dec!(50000), dec!(1))
320            .with_sequence(42);
321        assert_eq!(d.sequence, Some(42));
322    }
323
324    #[test]
325    fn test_order_book_sequence_tracking() {
326        let mut b = book("BTC-USD");
327        b.apply(
328            delta("BTC-USD", BookSide::Bid, dec!(50000), dec!(1)).with_sequence(7)
329        ).unwrap();
330        assert_eq!(b.last_sequence(), Some(7));
331    }
332
333    #[test]
334    fn test_order_book_mid_price_empty_returns_none() {
335        let b = book("BTC-USD");
336        assert!(b.mid_price().is_none());
337    }
338
339    #[test]
340    fn test_price_level_new() {
341        let lvl = PriceLevel::new(dec!(100), dec!(5));
342        assert_eq!(lvl.price, dec!(100));
343        assert_eq!(lvl.quantity, dec!(5));
344    }
345}