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}