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 (buy) side.
21    Bid,
22    /// Ask (sell) side.
23    Ask,
24}
25
26/// A single price level in the order book.
27#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
28pub struct PriceLevel {
29    /// Price of this level.
30    pub price: Decimal,
31    /// Resting quantity at this price.
32    pub quantity: Decimal,
33}
34
35impl PriceLevel {
36    /// Construct a price level from a price and resting quantity.
37    pub fn new(price: Decimal, quantity: Decimal) -> Self {
38        Self { price, quantity }
39    }
40}
41
42/// Incremental order book update.
43#[derive(Debug, Clone)]
44pub struct BookDelta {
45    /// Symbol this delta applies to.
46    pub symbol: String,
47    /// Side of the book (bid or ask).
48    pub side: BookSide,
49    /// Price level to update. `quantity == 0` means remove the level.
50    pub price: Decimal,
51    /// New resting quantity at this price. Zero removes the level.
52    pub quantity: Decimal,
53    /// Optional exchange-assigned sequence number for gap detection.
54    pub sequence: Option<u64>,
55}
56
57impl BookDelta {
58    /// Construct a delta without a sequence number.
59    ///
60    /// Use [`BookDelta::with_sequence`] to attach the exchange sequence number
61    /// when available; sequenced deltas enable gap detection.
62    pub fn new(
63        symbol: impl Into<String>,
64        side: BookSide,
65        price: Decimal,
66        quantity: Decimal,
67    ) -> Self {
68        Self {
69            symbol: symbol.into(),
70            side,
71            price,
72            quantity,
73            sequence: None,
74        }
75    }
76
77    /// Attach an exchange sequence number to this delta.
78    pub fn with_sequence(mut self, seq: u64) -> Self {
79        self.sequence = Some(seq);
80        self
81    }
82}
83
84/// Live order book for a single symbol.
85pub struct OrderBook {
86    symbol: String,
87    bids: BTreeMap<Decimal, Decimal>, // price → quantity, descending iteration via neg key
88    asks: BTreeMap<Decimal, Decimal>, // price → quantity, ascending
89    last_sequence: Option<u64>,
90}
91
92impl OrderBook {
93    /// Create an empty order book for the given symbol.
94    pub fn new(symbol: impl Into<String>) -> Self {
95        Self {
96            symbol: symbol.into(),
97            bids: BTreeMap::new(),
98            asks: BTreeMap::new(),
99            last_sequence: None,
100        }
101    }
102
103    /// Apply an incremental delta. quantity == 0 removes the level.
104    pub fn apply(&mut self, delta: BookDelta) -> Result<(), StreamError> {
105        if delta.symbol != self.symbol {
106            return Err(StreamError::BookReconstructionFailed {
107                symbol: self.symbol.clone(),
108                reason: format!(
109                    "delta symbol '{}' does not match book '{}'",
110                    delta.symbol, self.symbol
111                ),
112            });
113        }
114        let map = match delta.side {
115            BookSide::Bid => &mut self.bids,
116            BookSide::Ask => &mut self.asks,
117        };
118        if delta.quantity.is_zero() {
119            map.remove(&delta.price);
120        } else {
121            map.insert(delta.price, delta.quantity);
122        }
123        if let Some(seq) = delta.sequence {
124            self.last_sequence = Some(seq);
125        }
126        self.check_crossed()
127    }
128
129    /// Reset the book from a full snapshot.
130    pub fn reset(
131        &mut self,
132        bids: Vec<PriceLevel>,
133        asks: Vec<PriceLevel>,
134    ) -> Result<(), StreamError> {
135        self.bids.clear();
136        self.asks.clear();
137        for lvl in bids {
138            if !lvl.quantity.is_zero() {
139                self.bids.insert(lvl.price, lvl.quantity);
140            }
141        }
142        for lvl in asks {
143            if !lvl.quantity.is_zero() {
144                self.asks.insert(lvl.price, lvl.quantity);
145            }
146        }
147        self.check_crossed()
148    }
149
150    /// Best bid (highest).
151    pub fn best_bid(&self) -> Option<PriceLevel> {
152        self.bids
153            .iter()
154            .next_back()
155            .map(|(p, q)| PriceLevel::new(*p, *q))
156    }
157
158    /// Best ask (lowest).
159    pub fn best_ask(&self) -> Option<PriceLevel> {
160        self.asks
161            .iter()
162            .next()
163            .map(|(p, q)| PriceLevel::new(*p, *q))
164    }
165
166    /// Mid price.
167    pub fn mid_price(&self) -> Option<Decimal> {
168        let bid = self.best_bid()?.price;
169        let ask = self.best_ask()?.price;
170        Some((bid + ask) / Decimal::from(2))
171    }
172
173    /// Spread.
174    pub fn spread(&self) -> Option<Decimal> {
175        let bid = self.best_bid()?.price;
176        let ask = self.best_ask()?.price;
177        Some(ask - bid)
178    }
179
180    /// Number of bid levels.
181    pub fn bid_depth(&self) -> usize {
182        self.bids.len()
183    }
184
185    /// Number of ask levels.
186    pub fn ask_depth(&self) -> usize {
187        self.asks.len()
188    }
189
190    /// The symbol this order book tracks.
191    pub fn symbol(&self) -> &str {
192        &self.symbol
193    }
194
195    /// The sequence number of the most recently applied delta, if any.
196    pub fn last_sequence(&self) -> Option<u64> {
197        self.last_sequence
198    }
199
200    /// Top N bids (descending by price).
201    pub fn top_bids(&self, n: usize) -> Vec<PriceLevel> {
202        self.bids
203            .iter()
204            .rev()
205            .take(n)
206            .map(|(p, q)| PriceLevel::new(*p, *q))
207            .collect()
208    }
209
210    /// Top N asks (ascending by price).
211    pub fn top_asks(&self, n: usize) -> Vec<PriceLevel> {
212        self.asks
213            .iter()
214            .take(n)
215            .map(|(p, q)| PriceLevel::new(*p, *q))
216            .collect()
217    }
218
219    fn check_crossed(&self) -> Result<(), StreamError> {
220        if let (Some(bid), Some(ask)) = (self.best_bid(), self.best_ask()) {
221            if bid.price >= ask.price {
222                return Err(StreamError::BookCrossed {
223                    symbol: self.symbol.clone(),
224                    bid: bid.price.to_string(),
225                    ask: ask.price.to_string(),
226                });
227            }
228        }
229        Ok(())
230    }
231}
232
233#[cfg(test)]
234mod tests {
235    use super::*;
236    use rust_decimal_macros::dec;
237
238    fn book(symbol: &str) -> OrderBook {
239        OrderBook::new(symbol)
240    }
241
242    fn delta(symbol: &str, side: BookSide, price: Decimal, qty: Decimal) -> BookDelta {
243        BookDelta::new(symbol, side, price, qty)
244    }
245
246    #[test]
247    fn test_order_book_apply_bid_level() {
248        let mut b = book("BTC-USD");
249        b.apply(delta("BTC-USD", BookSide::Bid, dec!(50000), dec!(1)))
250            .unwrap();
251        assert_eq!(b.best_bid().unwrap().price, dec!(50000));
252    }
253
254    #[test]
255    fn test_order_book_apply_ask_level() {
256        let mut b = book("BTC-USD");
257        b.apply(delta("BTC-USD", BookSide::Ask, dec!(50100), dec!(2)))
258            .unwrap();
259        assert_eq!(b.best_ask().unwrap().price, dec!(50100));
260    }
261
262    #[test]
263    fn test_order_book_remove_level_with_zero_qty() {
264        let mut b = book("BTC-USD");
265        b.apply(delta("BTC-USD", BookSide::Bid, dec!(50000), dec!(1)))
266            .unwrap();
267        b.apply(delta("BTC-USD", BookSide::Bid, dec!(50000), dec!(0)))
268            .unwrap();
269        assert!(b.best_bid().is_none());
270    }
271
272    #[test]
273    fn test_order_book_best_bid_is_highest() {
274        let mut b = book("BTC-USD");
275        b.apply(delta("BTC-USD", BookSide::Bid, dec!(49900), dec!(1)))
276            .unwrap();
277        b.apply(delta("BTC-USD", BookSide::Bid, dec!(50000), dec!(2)))
278            .unwrap();
279        b.apply(delta("BTC-USD", BookSide::Bid, dec!(49800), dec!(3)))
280            .unwrap();
281        assert_eq!(b.best_bid().unwrap().price, dec!(50000));
282    }
283
284    #[test]
285    fn test_order_book_best_ask_is_lowest() {
286        let mut b = book("BTC-USD");
287        b.apply(delta("BTC-USD", BookSide::Ask, dec!(50200), dec!(1)))
288            .unwrap();
289        b.apply(delta("BTC-USD", BookSide::Ask, dec!(50100), dec!(2)))
290            .unwrap();
291        b.apply(delta("BTC-USD", BookSide::Ask, dec!(50300), dec!(3)))
292            .unwrap();
293        assert_eq!(b.best_ask().unwrap().price, dec!(50100));
294    }
295
296    #[test]
297    fn test_order_book_mid_price() {
298        let mut b = book("BTC-USD");
299        b.apply(delta("BTC-USD", BookSide::Bid, dec!(50000), dec!(1)))
300            .unwrap();
301        b.apply(delta("BTC-USD", BookSide::Ask, dec!(50100), dec!(1)))
302            .unwrap();
303        assert_eq!(b.mid_price().unwrap(), dec!(50050));
304    }
305
306    #[test]
307    fn test_order_book_spread() {
308        let mut b = book("BTC-USD");
309        b.apply(delta("BTC-USD", BookSide::Bid, dec!(50000), dec!(1)))
310            .unwrap();
311        b.apply(delta("BTC-USD", BookSide::Ask, dec!(50100), dec!(1)))
312            .unwrap();
313        assert_eq!(b.spread().unwrap(), dec!(100));
314    }
315
316    #[test]
317    fn test_order_book_crossed_returns_error() {
318        let mut b = book("BTC-USD");
319        b.apply(delta("BTC-USD", BookSide::Ask, dec!(50000), dec!(1)))
320            .unwrap();
321        let result = b.apply(delta("BTC-USD", BookSide::Bid, dec!(50001), dec!(1)));
322        assert!(matches!(result, Err(StreamError::BookCrossed { .. })));
323    }
324
325    #[test]
326    fn test_order_book_wrong_symbol_delta_rejected() {
327        let mut b = book("BTC-USD");
328        let result = b.apply(delta("ETH-USD", BookSide::Bid, dec!(3000), dec!(1)));
329        assert!(matches!(
330            result,
331            Err(StreamError::BookReconstructionFailed { .. })
332        ));
333    }
334
335    #[test]
336    fn test_order_book_reset_clears_and_reloads() {
337        let mut b = book("BTC-USD");
338        b.apply(delta("BTC-USD", BookSide::Bid, dec!(49000), dec!(5)))
339            .unwrap();
340        b.reset(
341            vec![PriceLevel::new(dec!(50000), dec!(1))],
342            vec![PriceLevel::new(dec!(50100), dec!(1))],
343        )
344        .unwrap();
345        assert_eq!(b.bid_depth(), 1);
346        assert_eq!(b.best_bid().unwrap().price, dec!(50000));
347    }
348
349    #[test]
350    fn test_order_book_reset_ignores_zero_qty_levels() {
351        let mut b = book("BTC-USD");
352        b.reset(
353            vec![
354                PriceLevel::new(dec!(50000), dec!(1)),
355                PriceLevel::new(dec!(49900), dec!(0)),
356            ],
357            vec![PriceLevel::new(dec!(50100), dec!(1))],
358        )
359        .unwrap();
360        assert_eq!(b.bid_depth(), 1);
361    }
362
363    #[test]
364    fn test_order_book_depth_counts() {
365        let mut b = book("BTC-USD");
366        b.apply(delta("BTC-USD", BookSide::Bid, dec!(49900), dec!(1)))
367            .unwrap();
368        b.apply(delta("BTC-USD", BookSide::Bid, dec!(49800), dec!(1)))
369            .unwrap();
370        b.apply(delta("BTC-USD", BookSide::Ask, dec!(50100), dec!(1)))
371            .unwrap();
372        assert_eq!(b.bid_depth(), 2);
373        assert_eq!(b.ask_depth(), 1);
374    }
375
376    #[test]
377    fn test_order_book_top_bids_descending() {
378        let mut b = book("BTC-USD");
379        b.apply(delta("BTC-USD", BookSide::Bid, dec!(49800), dec!(3)))
380            .unwrap();
381        b.apply(delta("BTC-USD", BookSide::Bid, dec!(50000), dec!(1)))
382            .unwrap();
383        b.apply(delta("BTC-USD", BookSide::Bid, dec!(49900), dec!(2)))
384            .unwrap();
385        let top = b.top_bids(2);
386        assert_eq!(top[0].price, dec!(50000));
387        assert_eq!(top[1].price, dec!(49900));
388    }
389
390    #[test]
391    fn test_order_book_top_asks_ascending() {
392        let mut b = book("BTC-USD");
393        b.apply(delta("BTC-USD", BookSide::Ask, dec!(50300), dec!(3)))
394            .unwrap();
395        b.apply(delta("BTC-USD", BookSide::Ask, dec!(50100), dec!(1)))
396            .unwrap();
397        b.apply(delta("BTC-USD", BookSide::Ask, dec!(50200), dec!(2)))
398            .unwrap();
399        let top = b.top_asks(2);
400        assert_eq!(top[0].price, dec!(50100));
401        assert_eq!(top[1].price, dec!(50200));
402    }
403
404    #[test]
405    fn test_book_delta_with_sequence() {
406        let d = BookDelta::new("BTC-USD", BookSide::Bid, dec!(50000), dec!(1)).with_sequence(42);
407        assert_eq!(d.sequence, Some(42));
408    }
409
410    #[test]
411    fn test_order_book_sequence_tracking() {
412        let mut b = book("BTC-USD");
413        b.apply(delta("BTC-USD", BookSide::Bid, dec!(50000), dec!(1)).with_sequence(7))
414            .unwrap();
415        assert_eq!(b.last_sequence(), Some(7));
416    }
417
418    #[test]
419    fn test_order_book_mid_price_empty_returns_none() {
420        let b = book("BTC-USD");
421        assert!(b.mid_price().is_none());
422    }
423
424    #[test]
425    fn test_price_level_new() {
426        let lvl = PriceLevel::new(dec!(100), dec!(5));
427        assert_eq!(lvl.price, dec!(100));
428        assert_eq!(lvl.quantity, dec!(5));
429    }
430}