Skip to main content

digdigdig3_station/
orderbook.rs

1//! OrderBookTracker — in-memory orderbook reconstruction from snapshot + delta stream.
2//!
3//! ## Usage
4//!
5//! ```rust,no_run
6//! use digdigdig3_station::orderbook::{OrderBookTracker, OrderBookError};
7//!
8//! let mut tracker = OrderBookTracker::new("BTCUSDT");
9//! tracker.apply_snapshot(&snapshot)?;
10//! tracker.apply_delta(&delta)?;
11//! let (best_bid, best_ask) = tracker.bbo().unwrap();
12//! ```
13//!
14//! Apply a snapshot first, then deltas. Detects sequence gaps when `prev_update_id` is
15//! populated on the delta and `last_update_id` was set on the previous update.
16
17use std::collections::BTreeMap;
18use rust_decimal::Decimal;
19
20use digdigdig3::core::types::{OrderBook, OrderbookDelta};
21
22// ─────────────────────────────────────────────────────────────────────────────
23// DecimalKey — BTreeMap-safe wrapper
24// ─────────────────────────────────────────────────────────────────────────────
25
26/// Newtype wrapping [`Decimal`] so it can be used as a [`BTreeMap`] key.
27///
28/// [`Decimal`] implements `PartialOrd` but not `Ord` in all versions.
29/// This wrapper provides a total order that matches numeric ordering.
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31struct DecimalKey(Decimal);
32
33impl PartialOrd for DecimalKey {
34    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
35        Some(self.cmp(other))
36    }
37}
38
39impl Ord for DecimalKey {
40    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
41        self.0.cmp(&other.0)
42    }
43}
44
45// ─────────────────────────────────────────────────────────────────────────────
46// Error type
47// ─────────────────────────────────────────────────────────────────────────────
48
49/// Errors that can occur during orderbook reconstruction.
50#[derive(Debug, thiserror::Error)]
51pub enum OrderBookError {
52    /// A delta arrived whose `prev_update_id` does not match the book's `last_update_id`.
53    ///
54    /// The book is now in an inconsistent state; the consumer should re-request a snapshot.
55    #[error("sequence gap: book last_update_id={last}, delta prev_update_id={got}")]
56    SequenceGap { last: u64, got: u64 },
57
58    /// A delta was applied before any snapshot was loaded.
59    #[error("delta applied before snapshot — call apply_snapshot first")]
60    NoSnapshot,
61
62    /// The symbol on the snapshot or delta does not match the tracker's symbol.
63    #[error("symbol mismatch: tracker={tracker}, incoming={incoming}")]
64    SymbolMismatch { tracker: String, incoming: String },
65
66    /// A price or size value could not be converted to `Decimal`.
67    #[error("invalid price/size value: {value}")]
68    InvalidDecimal { value: f64 },
69}
70
71// ─────────────────────────────────────────────────────────────────────────────
72// OrderBookTracker
73// ─────────────────────────────────────────────────────────────────────────────
74
75/// Maintains a live orderbook from snapshot + delta stream.
76///
77/// # Ordering
78/// - `bids`: ascending `DecimalKey` → iterate in **reverse** for descending price order
79/// - `asks`: ascending `DecimalKey` → iterate forward for ascending price order
80///
81/// # Sequence gap detection
82/// When a delta carries `prev_update_id` and the book has a known `last_update_id`,
83/// they must match.  On mismatch [`OrderBookError::SequenceGap`] is returned and the
84/// book is left unchanged — the consumer should request a fresh snapshot.
85#[derive(Debug, Clone)]
86pub struct OrderBookTracker {
87    /// Symbol this tracker was created for.
88    pub symbol: String,
89    /// Bids keyed by price ascending; iterate in reverse for best-bid-first.
90    bids: BTreeMap<DecimalKey, Decimal>,
91    /// Asks keyed by price ascending; iterate forward for best-ask-first.
92    asks: BTreeMap<DecimalKey, Decimal>,
93    /// `last_update_id` from the most recent snapshot or delta.
94    last_update_id: Option<u64>,
95    /// Timestamp (Unix ms) of the most recent update.
96    last_timestamp_ms: i64,
97    /// Whether a snapshot has been applied.
98    has_snapshot: bool,
99}
100
101impl OrderBookTracker {
102    /// Create a new empty tracker for the given symbol.
103    pub fn new(symbol: impl Into<String>) -> Self {
104        Self {
105            symbol: symbol.into(),
106            bids: BTreeMap::new(),
107            asks: BTreeMap::new(),
108            last_update_id: None,
109            last_timestamp_ms: 0,
110            has_snapshot: false,
111        }
112    }
113
114    // ── Snapshot ──────────────────────────────────────────────────────────────
115
116    /// Replace the full book state with the given snapshot.
117    ///
118    /// The `snapshot.symbol` is not checked (snapshots from `OrderBook` REST
119    /// calls do not carry a symbol field — the caller is responsible for
120    /// routing the right snapshot to the right tracker).
121    /// Pass `symbol` explicitly via the second argument to verify.
122    ///
123    /// Clears all previous bids/asks, then populates from the snapshot levels.
124    pub fn apply_snapshot(&mut self, snapshot: &OrderBook) -> Result<(), OrderBookError> {
125        self.bids.clear();
126        self.asks.clear();
127
128        for level in &snapshot.bids {
129            let price = to_decimal(level.price)?;
130            let qty = to_decimal(level.size)?;
131            if qty > Decimal::ZERO {
132                self.bids.insert(DecimalKey(price), qty);
133            }
134        }
135        for level in &snapshot.asks {
136            let price = to_decimal(level.price)?;
137            let qty = to_decimal(level.size)?;
138            if qty > Decimal::ZERO {
139                self.asks.insert(DecimalKey(price), qty);
140            }
141        }
142
143        self.last_update_id = snapshot.last_update_id;
144        self.last_timestamp_ms = snapshot.timestamp;
145        self.has_snapshot = true;
146        Ok(())
147    }
148
149    // ── Delta ─────────────────────────────────────────────────────────────────
150
151    /// Apply an incremental delta to the live book.
152    ///
153    /// Rules:
154    /// - Returns [`OrderBookError::NoSnapshot`] if no snapshot has been applied.
155    /// - Checks `prev_update_id` against `last_update_id` when both are present.
156    /// - A level with size `0.0` removes that price level; otherwise upserts.
157    pub fn apply_delta(&mut self, delta: &OrderbookDelta) -> Result<(), OrderBookError> {
158        if !self.has_snapshot {
159            return Err(OrderBookError::NoSnapshot);
160        }
161
162        // Sequence check: best-effort — not all exchanges populate prev_update_id
163        if let (Some(last), Some(prev)) = (self.last_update_id, delta.prev_update_id) {
164            if prev != last {
165                return Err(OrderBookError::SequenceGap { last, got: prev });
166            }
167        }
168
169        for level in &delta.bids {
170            let price = to_decimal(level.price)?;
171            let qty = to_decimal(level.size)?;
172            if qty == Decimal::ZERO {
173                self.bids.remove(&DecimalKey(price));
174            } else {
175                self.bids.insert(DecimalKey(price), qty);
176            }
177        }
178        for level in &delta.asks {
179            let price = to_decimal(level.price)?;
180            let qty = to_decimal(level.size)?;
181            if qty == Decimal::ZERO {
182                self.asks.remove(&DecimalKey(price));
183            } else {
184                self.asks.insert(DecimalKey(price), qty);
185            }
186        }
187
188        if let Some(uid) = delta.last_update_id {
189            self.last_update_id = Some(uid);
190        }
191        self.last_timestamp_ms = delta.timestamp;
192        Ok(())
193    }
194
195    // ── Queries ───────────────────────────────────────────────────────────────
196
197    /// Top `n` bid levels, highest price first.
198    pub fn top_bids(&self, n: usize) -> Vec<(Decimal, Decimal)> {
199        self.bids.iter().rev().take(n).map(|(k, v)| (k.0, *v)).collect()
200    }
201
202    /// Top `n` ask levels, lowest price first.
203    pub fn top_asks(&self, n: usize) -> Vec<(Decimal, Decimal)> {
204        self.asks.iter().take(n).map(|(k, v)| (k.0, *v)).collect()
205    }
206
207    /// Best bid/ask pair (best_bid, best_ask). `None` if either side is empty.
208    pub fn bbo(&self) -> Option<(Decimal, Decimal)> {
209        let bid = self.bids.iter().rev().next()?.0 .0;
210        let ask = self.asks.iter().next()?.0 .0;
211        Some((bid, ask))
212    }
213
214    /// Mid price: `(best_bid + best_ask) / 2`. `None` if book is empty.
215    pub fn mid(&self) -> Option<Decimal> {
216        let (b, a) = self.bbo()?;
217        Some((b + a) / Decimal::new(2, 0))
218    }
219
220    /// Spread: `best_ask - best_bid`. `None` if book is empty.
221    pub fn spread(&self) -> Option<Decimal> {
222        let (b, a) = self.bbo()?;
223        Some(a - b)
224    }
225
226    /// Sum of all bid quantities.
227    pub fn total_bid_volume(&self) -> Decimal {
228        self.bids.values().copied().sum()
229    }
230
231    /// Sum of all ask quantities.
232    pub fn total_ask_volume(&self) -> Decimal {
233        self.asks.values().copied().sum()
234    }
235
236    /// `(bid_levels, ask_levels)` — number of distinct price levels per side.
237    pub fn depth(&self) -> (usize, usize) {
238        (self.bids.len(), self.asks.len())
239    }
240
241    /// `last_update_id` from the most recent snapshot or delta.
242    pub fn last_update_id(&self) -> Option<u64> {
243        self.last_update_id
244    }
245
246    /// Unix millisecond timestamp of the most recent update.
247    pub fn last_timestamp_ms(&self) -> i64 {
248        self.last_timestamp_ms
249    }
250
251    /// Whether a snapshot has been applied.
252    pub fn has_snapshot(&self) -> bool {
253        self.has_snapshot
254    }
255
256    /// Reset the tracker to an empty state (keeps symbol).
257    pub fn reset(&mut self) {
258        self.bids.clear();
259        self.asks.clear();
260        self.last_update_id = None;
261        self.last_timestamp_ms = 0;
262        self.has_snapshot = false;
263    }
264}
265
266// ─────────────────────────────────────────────────────────────────────────────
267// Helpers
268// ─────────────────────────────────────────────────────────────────────────────
269
270fn to_decimal(v: f64) -> Result<Decimal, OrderBookError> {
271    Decimal::try_from(v).map_err(|_| OrderBookError::InvalidDecimal { value: v })
272}