Skip to main content

bybit/
orderbook_state.rs

1//! Local order book reconstruction.
2//!
3//! Bybit's order book stream is built from a depth snapshot (REST
4//! [`Orderbook`] or a WebSocket `"snapshot"` message) plus a stream of
5//! incremental `"delta"` messages ([`OrderbookDataMsg`]). Each message
6//! carries an `update_id` (`u`), a per-topic sequence number that increases
7//! by exactly one for every message. If the backend restarts, the next
8//! message is a fresh snapshot with `update_id == 1`, which must always
9//! overwrite the local book.
10//!
11//! [`OrderBookState`] hides all of this bookkeeping. Snapshots and diffs can
12//! be fed in any order: a diff that arrives before its snapshot is buffered
13//! until the snapshot arrives, and a snapshot that arrives while diffs are
14//! already buffered bridges over them if they form a contiguous chain.
15//! Every call returns an [`ApplyOutcome`] that tells the caller what
16//! happened and, in particular, whether the book is now out of sync and a
17//! fresh snapshot must be fetched.
18
19use std::collections::BTreeMap;
20
21use rust_decimal::Decimal;
22
23use crate::{
24    http::{Orderbook, OrderbookLevel},
25    ws::{OrderbookDataMsg, OrderbookMsg},
26};
27
28/// One side of the local order book: price -> size, sorted by price.
29type Side = BTreeMap<Decimal, Decimal>;
30
31/// Outcome of [`OrderBookState::apply_snapshot`], [`OrderBookState::apply_diff`]
32/// and [`OrderBookState::apply`].
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub enum ApplyOutcome {
35    /// The diff arrived before any usable snapshot and was stored for later.
36    /// The book is not live yet.
37    Buffered,
38    /// A snapshot was applied and the book is now live and up to date
39    /// (any buffered diffs that contiguously followed the snapshot were
40    /// applied as well).
41    Synced,
42    /// A diff was applied to an already-live book.
43    Applied,
44    /// The snapshot or diff was older than what's already known and was
45    /// dropped without changing the book.
46    Ignored,
47    /// A gap was detected in the update sequence: the local book has been
48    /// discarded because it can no longer be trusted. The caller must fetch
49    /// a fresh snapshot and feed it back via [`OrderBookState::apply_snapshot`]
50    /// (or [`OrderBookState::apply`]).
51    ResyncRequired,
52}
53
54/// Local order book, reconstructed from a depth snapshot and a stream of
55/// diffs.
56///
57/// All internal bookkeeping (buffered diffs, sync state, book contents) is
58/// private. Feed data in with [`apply_snapshot`](Self::apply_snapshot),
59/// [`apply_diff`](Self::apply_diff) or [`apply`](Self::apply), in any order,
60/// and inspect the book with [`bids`](Self::bids), [`asks`](Self::asks),
61/// [`best_bid`](Self::best_bid) and [`best_ask`](Self::best_ask) once
62/// [`is_synced`](Self::is_synced) is `true`.
63#[derive(Debug)]
64pub struct OrderBookState {
65    inner: Inner,
66    /// Maximum number of diffs kept while waiting for a snapshot.
67    max_buffered: usize,
68}
69
70/// Default for [`OrderBookState::with_max_buffered`].
71pub const DEFAULT_MAX_BUFFERED_DIFFS: usize = 1000;
72
73impl Default for OrderBookState {
74    fn default() -> Self {
75        Self {
76            inner: Inner::default(),
77            max_buffered: DEFAULT_MAX_BUFFERED_DIFFS,
78        }
79    }
80}
81
82#[derive(Debug)]
83enum Inner {
84    /// No usable snapshot yet. Diffs are buffered, keyed by `update_id`, so
85    /// they can be drained in order once a snapshot arrives, regardless of
86    /// the order they were received in.
87    Empty {
88        buffered: BTreeMap<i64, OrderbookDataMsg>,
89    },
90    /// Fully synchronized live book.
91    Synced {
92        last_id: i64,
93        bids: Side,
94        asks: Side,
95    },
96}
97
98impl Default for Inner {
99    fn default() -> Self {
100        Self::Empty {
101            buffered: BTreeMap::new(),
102        }
103    }
104}
105
106impl OrderBookState {
107    /// Create an empty state machine with no book yet.
108    pub fn new() -> Self {
109        Self::default()
110    }
111
112    /// Create an empty state machine that keeps at most `max_buffered` diffs
113    /// while waiting for a snapshot; the oldest ones are discarded first.
114    pub fn with_max_buffered(max_buffered: usize) -> Self {
115        Self {
116            inner: Inner::default(),
117            max_buffered,
118        }
119    }
120
121    /// Feed a REST depth snapshot ([`Client::get_orderbook`](crate::http::Client::get_orderbook)).
122    pub fn apply_snapshot(&mut self, snapshot: Orderbook) -> ApplyOutcome {
123        self.set_snapshot(snapshot.update_id, snapshot.bids, snapshot.asks)
124    }
125
126    /// Feed a live diff from the `orderbook.{depth}.{symbol}` WebSocket topic.
127    ///
128    /// If the book is not synced yet, the diff is buffered. Otherwise it
129    /// must extend the live book by exactly one `update_id`; anything else
130    /// (a stale diff or a gap) is reported via [`ApplyOutcome::Ignored`] /
131    /// [`ApplyOutcome::ResyncRequired`].
132    pub fn apply_diff(&mut self, diff: OrderbookDataMsg) -> ApplyOutcome {
133        match &mut self.inner {
134            Inner::Empty { buffered } => {
135                buffered.insert(diff.update_id, diff);
136                while buffered.len() > self.max_buffered {
137                    buffered.pop_first();
138                }
139                ApplyOutcome::Buffered
140            }
141            Inner::Synced {
142                last_id,
143                bids,
144                asks,
145            } => {
146                // `update_id == 1` always means the backend restarted and
147                // this data must be treated as a fresh snapshot, which a
148                // delta message cannot provide on its own.
149                if diff.update_id == 1 {
150                    self.inner = Inner::default();
151                    return ApplyOutcome::ResyncRequired;
152                }
153                if diff.update_id <= *last_id {
154                    return ApplyOutcome::Ignored;
155                }
156                if diff.update_id != *last_id + 1 {
157                    // Gap in the update sequence: the book can no longer be
158                    // trusted. Keep this diff around in case the next
159                    // snapshot bridges it.
160                    self.inner = Inner::Empty {
161                        buffered: BTreeMap::from([(diff.update_id, diff)]),
162                    };
163                    return ApplyOutcome::ResyncRequired;
164                }
165                apply_levels(bids, &diff.bids);
166                apply_levels(asks, &diff.asks);
167                *last_id = diff.update_id;
168                ApplyOutcome::Applied
169            }
170        }
171    }
172
173    /// Feed any message from the `orderbook.{depth}.{symbol}` WebSocket
174    /// topic, dispatching to [`apply_snapshot`](Self::apply_snapshot) or
175    /// [`apply_diff`](Self::apply_diff) as appropriate.
176    pub fn apply(&mut self, msg: OrderbookMsg) -> ApplyOutcome {
177        match msg {
178            OrderbookMsg::Snapshot { data, .. } => {
179                self.set_snapshot(data.update_id, data.bids, data.asks)
180            }
181            OrderbookMsg::Delta { data, .. } => self.apply_diff(data),
182        }
183    }
184
185    /// `true` once a snapshot has been applied and the book is live.
186    pub fn is_synced(&self) -> bool {
187        matches!(self.inner, Inner::Synced { .. })
188    }
189
190    /// The `update_id` of the last applied snapshot or diff, once synced.
191    pub fn last_update_id(&self) -> Option<i64> {
192        match &self.inner {
193            Inner::Synced { last_id, .. } => Some(*last_id),
194            Inner::Empty { .. } => None,
195        }
196    }
197
198    /// The live bid side (price -> size, ascending by price), once synced.
199    pub fn bids(&self) -> Option<&Side> {
200        match &self.inner {
201            Inner::Synced { bids, .. } => Some(bids),
202            Inner::Empty { .. } => None,
203        }
204    }
205
206    /// The live ask side (price -> size, ascending by price), once synced.
207    pub fn asks(&self) -> Option<&Side> {
208        match &self.inner {
209            Inner::Synced { asks, .. } => Some(asks),
210            Inner::Empty { .. } => None,
211        }
212    }
213
214    /// The highest bid (price, size), once synced.
215    pub fn best_bid(&self) -> Option<(Decimal, Decimal)> {
216        self.bids()
217            .and_then(|bids| bids.iter().next_back())
218            .map(|(&price, &size)| (price, size))
219    }
220
221    /// The lowest ask (price, size), once synced.
222    pub fn best_ask(&self) -> Option<(Decimal, Decimal)> {
223        self.asks()
224            .and_then(|asks| asks.iter().next())
225            .map(|(&price, &size)| (price, size))
226    }
227
228    /// Common path for [`apply_snapshot`](Self::apply_snapshot) and the
229    /// snapshot branch of [`apply`](Self::apply): replace the book with the
230    /// given snapshot and try to bridge any buffered diffs on top of it.
231    fn set_snapshot(
232        &mut self,
233        new_id: i64,
234        bids: Vec<OrderbookLevel>,
235        asks: Vec<OrderbookLevel>,
236    ) -> ApplyOutcome {
237        if let Inner::Synced { last_id, .. } = &self.inner {
238            // `update_id == 1` signals a backend restart and must always
239            // overwrite the local book, even if it is numerically smaller
240            // than what we already have.
241            if new_id != 1 && new_id <= *last_id {
242                return ApplyOutcome::Ignored;
243            }
244        }
245
246        let mut bids: Side = bids.into_iter().map(|l| (l.price, l.size)).collect();
247        let mut asks: Side = asks.into_iter().map(|l| (l.price, l.size)).collect();
248
249        let buffered = match std::mem::take(&mut self.inner) {
250            Inner::Empty { buffered } => buffered,
251            Inner::Synced { .. } => BTreeMap::new(),
252        };
253
254        // Drain buffered diffs that contiguously continue from this
255        // snapshot. Anything else (stale, or separated by a gap) is dropped:
256        // the snapshot itself is a valid baseline, and the live stream will
257        // continue from here.
258        let mut last_id = new_id;
259        for (&id, diff) in buffered.range((new_id + 1)..) {
260            if id != last_id + 1 {
261                break;
262            }
263            apply_levels(&mut bids, &diff.bids);
264            apply_levels(&mut asks, &diff.asks);
265            last_id = id;
266        }
267
268        self.inner = Inner::Synced {
269            last_id,
270            bids,
271            asks,
272        };
273        ApplyOutcome::Synced
274    }
275}
276
277/// Apply a list of price levels to one side of the book: a size of zero
278/// removes the price level, otherwise the level is inserted/updated.
279fn apply_levels(side: &mut Side, levels: &[OrderbookLevel]) {
280    for level in levels {
281        if level.size.is_zero() {
282            side.remove(&level.price);
283        } else {
284            side.insert(level.price, level.size);
285        }
286    }
287}
288
289#[cfg(test)]
290mod tests {
291    use rust_decimal::dec;
292
293    use crate::{DepthLevel, Topic};
294
295    use super::*;
296
297    fn level(price: Decimal, size: Decimal) -> OrderbookLevel {
298        OrderbookLevel { price, size }
299    }
300
301    fn snapshot(update_id: i64, bids: Vec<OrderbookLevel>, asks: Vec<OrderbookLevel>) -> Orderbook {
302        Orderbook {
303            symbol: "BTCUSDT".into(),
304            bids,
305            asks,
306            ts: 0,
307            update_id,
308            seq: update_id,
309            cts: None,
310        }
311    }
312
313    fn diff(
314        update_id: i64,
315        bids: Vec<OrderbookLevel>,
316        asks: Vec<OrderbookLevel>,
317    ) -> OrderbookDataMsg {
318        OrderbookDataMsg {
319            symbol: "BTCUSDT".into(),
320            bids,
321            asks,
322            update_id,
323            seq: update_id,
324        }
325    }
326
327    #[test]
328    fn snapshot_then_contiguous_diff_is_applied() {
329        let mut book = OrderBookState::new();
330        assert_eq!(
331            book.apply_snapshot(snapshot(
332                1,
333                vec![level(dec!(100), dec!(1))],
334                vec![level(dec!(101), dec!(2))]
335            )),
336            ApplyOutcome::Synced
337        );
338        assert!(book.is_synced());
339
340        assert_eq!(
341            book.apply_diff(diff(2, vec![level(dec!(100), dec!(1.5))], vec![])),
342            ApplyOutcome::Applied
343        );
344        assert_eq!(book.last_update_id(), Some(2));
345        assert_eq!(book.best_bid(), Some((dec!(100), dec!(1.5))));
346        assert_eq!(book.best_ask(), Some((dec!(101), dec!(2))));
347    }
348
349    #[test]
350    fn diff_buffered_before_snapshot_bridges_on_arrival() {
351        let mut book = OrderBookState::new();
352        assert_eq!(
353            book.apply_diff(diff(2, vec![level(dec!(100), dec!(1.5))], vec![])),
354            ApplyOutcome::Buffered
355        );
356        assert!(!book.is_synced());
357
358        assert_eq!(
359            book.apply_snapshot(snapshot(
360                1,
361                vec![level(dec!(100), dec!(1))],
362                vec![level(dec!(101), dec!(2))]
363            )),
364            ApplyOutcome::Synced
365        );
366        assert_eq!(book.last_update_id(), Some(2));
367        assert_eq!(book.best_bid(), Some((dec!(100), dec!(1.5))));
368    }
369
370    #[test]
371    fn stale_snapshot_is_ignored() {
372        let mut book = OrderBookState::new();
373        book.apply_snapshot(snapshot(5, vec![], vec![]));
374        assert_eq!(
375            book.apply_snapshot(snapshot(3, vec![], vec![])),
376            ApplyOutcome::Ignored
377        );
378        assert_eq!(book.last_update_id(), Some(5));
379    }
380
381    #[test]
382    fn stale_diff_is_ignored() {
383        let mut book = OrderBookState::new();
384        book.apply_snapshot(snapshot(5, vec![], vec![]));
385        assert_eq!(
386            book.apply_diff(diff(4, vec![], vec![])),
387            ApplyOutcome::Ignored
388        );
389        assert_eq!(book.last_update_id(), Some(5));
390    }
391
392    #[test]
393    fn gap_in_diff_stream_requires_resync_and_a_fresh_snapshot_bridges_it() {
394        let mut book = OrderBookState::new();
395        book.apply_snapshot(snapshot(1, vec![], vec![]));
396
397        assert_eq!(
398            book.apply_diff(diff(5, vec![level(dec!(100), dec!(1))], vec![])),
399            ApplyOutcome::ResyncRequired
400        );
401        assert!(!book.is_synced());
402
403        // A fresh snapshot at update_id 4 bridges the buffered diff at 5.
404        assert_eq!(
405            book.apply_snapshot(snapshot(4, vec![], vec![])),
406            ApplyOutcome::Synced
407        );
408        assert_eq!(book.last_update_id(), Some(5));
409        assert_eq!(book.best_bid(), Some((dec!(100), dec!(1))));
410    }
411
412    #[test]
413    fn restart_signal_resets_the_book_even_if_id_is_smaller() {
414        let mut book = OrderBookState::new();
415        book.apply_snapshot(snapshot(50, vec![level(dec!(100), dec!(1))], vec![]));
416        assert!(book.is_synced());
417
418        assert_eq!(
419            book.apply_snapshot(snapshot(1, vec![level(dec!(200), dec!(9))], vec![])),
420            ApplyOutcome::Synced
421        );
422        assert_eq!(book.last_update_id(), Some(1));
423        assert_eq!(book.best_bid(), Some((dec!(200), dec!(9))));
424    }
425
426    #[test]
427    fn zero_size_level_removes_the_price_from_the_book() {
428        let mut book = OrderBookState::new();
429        book.apply_snapshot(snapshot(
430            1,
431            vec![level(dec!(100), dec!(1)), level(dec!(99), dec!(2))],
432            vec![],
433        ));
434
435        assert_eq!(
436            book.apply_diff(diff(2, vec![level(dec!(100), dec!(0))], vec![])),
437            ApplyOutcome::Applied
438        );
439        assert_eq!(book.best_bid(), Some((dec!(99), dec!(2))));
440    }
441
442    #[test]
443    fn apply_dispatches_snapshot_and_delta_messages() {
444        let mut book = OrderBookState::new();
445        let topic = Topic::Orderbook {
446            symbol: "BTCUSDT".into(),
447            depth: DepthLevel::Level50,
448        };
449
450        let snapshot_msg = OrderbookMsg::Snapshot {
451            topic: topic.clone(),
452            ts: 0,
453            data: diff(
454                1,
455                vec![level(dec!(100), dec!(1))],
456                vec![level(dec!(101), dec!(2))],
457            ),
458            cts: 0,
459        };
460        assert_eq!(book.apply(snapshot_msg), ApplyOutcome::Synced);
461
462        let delta_msg = OrderbookMsg::Delta {
463            topic,
464            ts: 0,
465            data: diff(2, vec![level(dec!(100), dec!(1.5))], vec![]),
466            cts: 0,
467        };
468        assert_eq!(book.apply(delta_msg), ApplyOutcome::Applied);
469        assert_eq!(book.best_bid(), Some((dec!(100), dec!(1.5))));
470    }
471
472    #[test]
473    fn buffer_keeps_only_the_newest_diffs() {
474        let mut book = OrderBookState::with_max_buffered(3);
475        for id in 1..=10 {
476            let outcome =
477                book.apply_diff(diff(id, vec![level(dec!(100), Decimal::from(id))], vec![]));
478            assert_eq!(outcome, ApplyOutcome::Buffered);
479        }
480
481        // Diffs 8..=10 survived and bridge on top of snapshot 7.
482        let outcome = book.apply_snapshot(snapshot(7, vec![level(dec!(100), dec!(7))], vec![]));
483
484        assert_eq!(outcome, ApplyOutcome::Synced);
485        assert_eq!(book.last_update_id(), Some(10));
486        assert_eq!(book.best_bid(), Some((dec!(100), dec!(10))));
487    }
488
489    #[test]
490    fn evicted_diffs_do_not_bridge_an_older_snapshot() {
491        let mut book = OrderBookState::with_max_buffered(3);
492        for id in 3..=10 {
493            book.apply_diff(diff(id, vec![level(dec!(100), Decimal::from(id))], vec![]));
494        }
495
496        // Diffs 3..=7 were evicted, so snapshot 2 cannot be bridged.
497        let outcome = book.apply_snapshot(snapshot(2, vec![level(dec!(100), dec!(2))], vec![]));
498
499        assert_eq!(outcome, ApplyOutcome::Synced);
500        assert_eq!(book.last_update_id(), Some(2));
501    }
502}