Skip to main content

polyester/orderbook/
mod.rs

1//! Local orderbook helpers (Go `orderbook` package parity).
2
3mod subscription;
4
5pub use subscription::Subscription;
6
7use std::collections::BTreeMap;
8
9use crate::codecs::decode::{decode_price_ticks, decode_qty_scaled};
10use crate::errors::{Error, Result};
11use crate::models::{OrderBookDeltaUpdate, OrderbookData, OrderbookLevel};
12
13pub type BookSide = BTreeMap<i64, i64>; // price_ticks -> qty_scaled
14
15pub fn apply_side_delta(book: &mut BookSide, pairs: &[(i64, i64)]) {
16    for &(price, qty) in pairs {
17        // Negative price/qty is wire corruption; never materialize it into the book.
18        if price < 0 || qty < 0 {
19            continue;
20        }
21        if qty == 0 {
22            book.remove(&price);
23        } else {
24            book.insert(price, qty);
25        }
26    }
27}
28
29/// Parse a bucket string into a positive tick size. Empty means no bucketing.
30pub fn parse_bucket_ticks(bucket: &str) -> Result<i64> {
31    if bucket.trim().is_empty() {
32        return Ok(0);
33    }
34    let ticks = crate::codecs::scalars::parse_price_ticks_str(bucket, "bucket")?;
35    if ticks <= 0 {
36        return Err(Error::validation(
37            "bucket must be a positive price increment",
38        ));
39    }
40    Ok(ticks)
41}
42
43/// Aggregate levels into executable-side-safe price buckets.
44///
45/// Bids round down; asks round up so displayed asks never appear below their
46/// executable price.
47pub fn bucket_side(book: &BookSide, bucket_ticks: i64, asks: bool) -> Result<BookSide> {
48    if bucket_ticks <= 0 {
49        for (&price, &qty) in book {
50            if price < 0 {
51                return Err(Error::validation(
52                    "orderbook price ticks must be non-negative",
53                ));
54            }
55            if qty < 0 {
56                return Err(Error::validation("orderbook quantity must be non-negative"));
57            }
58        }
59        return Ok(book.clone());
60    }
61    let mut out = BookSide::new();
62    for (&price, &qty) in book {
63        if price < 0 {
64            return Err(Error::validation(
65                "orderbook price ticks must be non-negative",
66            ));
67        }
68        if qty <= 0 {
69            continue;
70        }
71        let quotient = price.div_euclid(bucket_ticks);
72        let floor = quotient
73            .checked_mul(bucket_ticks)
74            .ok_or_else(|| Error::validation("orderbook bucket price overflow"))?;
75        let bucket = if asks && price.rem_euclid(bucket_ticks) != 0 {
76            floor
77                .checked_add(bucket_ticks)
78                .ok_or_else(|| Error::validation("ask bucket price overflow"))?
79        } else {
80            floor
81        };
82        let entry = out.entry(bucket).or_insert(0);
83        *entry = entry
84            .checked_add(qty)
85            .ok_or_else(|| Error::validation("orderbook bucket quantity overflow"))?;
86    }
87    Ok(out)
88}
89
90/// Build a book side from `(price_ticks, qty_scaled)` pairs (zero qty skipped).
91pub fn levels_from_pairs(levels: impl IntoIterator<Item = (i64, i64)>) -> BookSide {
92    let mut book = BookSide::new();
93    for (price, qty) in levels {
94        if price < 0 || qty <= 0 {
95            continue;
96        }
97        book.insert(price, qty);
98    }
99    book
100}
101
102/// Build a book side from decoded [`OrderbookLevel`] rows.
103pub fn levels_from_orderbook_side(levels: &[OrderbookLevel]) -> BookSide {
104    levels_from_pairs(levels.iter().filter_map(|l| {
105        let price = l.price.as_ref()?.as_ticks();
106        let qty = l.qty.as_ref()?.as_scaled();
107        Some((price, qty))
108    }))
109}
110
111/// Apply a delta; returns `(new_seq, needs_refresh)`.
112pub fn apply_delta(
113    bids: &mut BookSide,
114    asks: &mut BookSide,
115    mut current_seq: u64,
116    delta: &OrderBookDeltaUpdate,
117) -> (u64, bool) {
118    // Reject the whole update atomically. Skipping only corrupt rows while
119    // advancing the sequence leaves a stale book that can no longer self-heal.
120    if delta
121        .bids
122        .iter()
123        .chain(&delta.asks)
124        .any(|pair| pair.price_ticks < 0 || pair.qty_scaled < 0)
125    {
126        return (current_seq, true);
127    }
128    // Keep seq as u64 end-to-end. Never coerce parse failures to 0 (that disables
129    // gap detection). Invalid/overflowing sequences fail toward refresh.
130    let seq_start = delta.book_seq_start;
131    let seq_end = delta.book_seq_end;
132    if seq_end < seq_start {
133        return (current_seq, true);
134    }
135    let comparison_seq = if delta.reset { 0 } else { current_seq };
136    if comparison_seq != 0 && seq_start > comparison_seq.saturating_add(1) {
137        return (current_seq, true);
138    }
139    if !delta.reset && seq_end <= current_seq {
140        return (current_seq, false);
141    }
142    if delta.reset {
143        bids.clear();
144        asks.clear();
145        current_seq = 0;
146    }
147    let bid_pairs: Vec<(i64, i64)> = delta
148        .bids
149        .iter()
150        .map(|p| (p.price_ticks, p.qty_scaled))
151        .collect();
152    let ask_pairs: Vec<(i64, i64)> = delta
153        .asks
154        .iter()
155        .map(|p| (p.price_ticks, p.qty_scaled))
156        .collect();
157    apply_side_delta(bids, &bid_pairs);
158    apply_side_delta(asks, &ask_pairs);
159    if seq_end > current_seq {
160        current_seq = seq_end;
161    }
162    (current_seq, false)
163}
164
165fn side_to_levels(
166    book: &BookSide,
167    side: &str,
168    limit: usize,
169    bucket_ticks: i64,
170    symbol: &str,
171    quantity_scale: u32,
172) -> Result<Vec<OrderbookLevel>> {
173    let view = bucket_side(book, bucket_ticks, side == "asks")?;
174    let mut entries: Vec<(i64, i64)> = view.into_iter().collect();
175    if side == "bids" {
176        entries.sort_by_key(|b| std::cmp::Reverse(b.0));
177    } else {
178        entries.sort_by_key(|a| a.0);
179    }
180    let limit = limit.min(entries.len());
181    let symbol = Some(symbol.to_owned());
182    let mut levels = Vec::with_capacity(limit);
183    for (price, qty) in entries.into_iter().take(limit) {
184        let price = decode_price_ticks(price, symbol.clone())
185            .ok_or_else(|| Error::validation("orderbook level has invalid or missing price"))?;
186        let qty = decode_qty_scaled(qty, Some(quantity_scale), symbol.clone(), None)
187            .ok_or_else(|| Error::validation("orderbook level has invalid or missing quantity"))?;
188        levels.push(OrderbookLevel {
189            price: Some(price),
190            qty: Some(qty),
191        });
192    }
193    Ok(levels)
194}
195
196/// Render the current in-memory book as [`OrderbookData`].
197pub fn build_orderbook_data(
198    symbol: &str,
199    depth: u32,
200    book_seq: u64,
201    bids: &BookSide,
202    asks: &BookSide,
203    bucket_ticks: i64,
204    quantity_scale: u32,
205) -> Result<OrderbookData> {
206    let limit = depth as usize;
207    Ok(OrderbookData {
208        symbol: symbol.to_owned(),
209        depth,
210        book_seq: book_seq.to_string(),
211        bids: side_to_levels(bids, "bids", limit, bucket_ticks, symbol, quantity_scale)?,
212        asks: side_to_levels(asks, "asks", limit, bucket_ticks, symbol, quantity_scale)?,
213    })
214}
215
216#[cfg(test)]
217mod tests {
218    use super::*;
219    use crate::models::PriceQtyPair;
220
221    fn delta(
222        start: u64,
223        end: u64,
224        bids: &[(i64, i64)],
225        asks: &[(i64, i64)],
226        reset: bool,
227    ) -> OrderBookDeltaUpdate {
228        OrderBookDeltaUpdate {
229            symbol_id: 1,
230            book_seq_start: start,
231            book_seq_end: end,
232            reset,
233            bids: bids
234                .iter()
235                .map(|&(price_ticks, qty_scaled)| PriceQtyPair {
236                    price_ticks,
237                    qty_scaled,
238                })
239                .collect(),
240            asks: asks
241                .iter()
242                .map(|&(price_ticks, qty_scaled)| PriceQtyPair {
243                    price_ticks,
244                    qty_scaled,
245                })
246                .collect(),
247        }
248    }
249
250    #[test]
251    fn apply_side_delta_updates_and_deletes() {
252        let mut book = BookSide::from([(100, 5)]);
253        apply_side_delta(&mut book, &[(100, 7), (101, 2)]);
254        assert_eq!(book.get(&100), Some(&7));
255        assert_eq!(book.get(&101), Some(&2));
256        apply_side_delta(&mut book, &[(101, 0)]);
257        assert!(!book.contains_key(&101));
258    }
259
260    #[test]
261    fn apply_delta_detects_gap() {
262        let mut bids = BookSide::from([(100, 5)]);
263        let mut asks = BookSide::from([(200, 3)]);
264        let (seq, needs_refresh) = apply_delta(
265            &mut bids,
266            &mut asks,
267            3,
268            &delta(5, 6, &[(100, 7)], &[], false),
269        );
270        assert!(needs_refresh);
271        assert_eq!(seq, 3);
272        assert_eq!(bids.get(&100), Some(&5));
273    }
274
275    #[test]
276    fn apply_delta_updates_book() {
277        let mut bids = BookSide::from([(100, 5)]);
278        let mut asks = BookSide::from([(200, 3)]);
279        let (seq, needs_refresh) = apply_delta(
280            &mut bids,
281            &mut asks,
282            3,
283            &delta(3, 4, &[(100, 7)], &[], false),
284        );
285        assert!(!needs_refresh);
286        assert_eq!(seq, 4);
287        assert_eq!(bids.get(&100), Some(&7));
288    }
289
290    #[test]
291    fn apply_delta_skips_stale() {
292        let mut bids = BookSide::from([(100, 5)]);
293        let mut asks = BookSide::new();
294        let (seq, needs_refresh) = apply_delta(
295            &mut bids,
296            &mut asks,
297            5,
298            &delta(3, 4, &[(100, 9)], &[], false),
299        );
300        assert!(!needs_refresh);
301        assert_eq!(seq, 5);
302        assert_eq!(bids.get(&100), Some(&5));
303    }
304
305    #[test]
306    fn apply_delta_inverted_seq_fails_toward_refresh() {
307        let mut bids = BookSide::from([(100, 5)]);
308        let mut asks = BookSide::new();
309        let (seq, needs_refresh) = apply_delta(
310            &mut bids,
311            &mut asks,
312            3,
313            &delta(9, 2, &[(100, 9)], &[], false),
314        );
315        assert!(needs_refresh);
316        assert_eq!(seq, 3);
317        assert_eq!(bids.get(&100), Some(&5));
318    }
319
320    #[test]
321    fn apply_delta_rejects_malformed_levels_without_advancing_or_mutating() {
322        let mut bids = BookSide::from([(100, 5)]);
323        let mut asks = BookSide::from([(200, 3)]);
324        let (seq, needs_refresh) = apply_delta(
325            &mut bids,
326            &mut asks,
327            1,
328            &delta(2, 2, &[(100, -1), (101, 4)], &[], false),
329        );
330        assert!(needs_refresh);
331        assert_eq!(seq, 1);
332        assert_eq!(bids, BookSide::from([(100, 5)]));
333        assert_eq!(asks, BookSide::from([(200, 3)]));
334    }
335
336    #[test]
337    fn apply_delta_reset_clears_book() {
338        let mut bids = BookSide::from([(100, 5)]);
339        let mut asks = BookSide::from([(200, 3)]);
340        let (seq, needs_refresh) = apply_delta(
341            &mut bids,
342            &mut asks,
343            9,
344            &delta(1, 2, &[(101, 4)], &[], true),
345        );
346        assert!(!needs_refresh);
347        assert_eq!(seq, 2);
348        assert_eq!(bids.get(&101), Some(&4));
349        assert!(!bids.contains_key(&100));
350        assert!(asks.is_empty());
351    }
352
353    #[test]
354    fn bucket_side_aggregates() {
355        let book = BookSide::from([(101, 2), (105, 3)]);
356        let bucketed = bucket_side(&book, 10, false).unwrap();
357        assert_eq!(bucketed.get(&100), Some(&5));
358        let asks = bucket_side(&book, 10, true).unwrap();
359        assert_eq!(asks.get(&110), Some(&5));
360        assert!(bucket_side(&BookSide::from([(i64::MAX, 1)]), 10, true).is_err());
361    }
362
363    #[test]
364    fn bucket_side_rejects_negative_price_and_qty_overflow() {
365        assert!(bucket_side(&BookSide::from([(-1, 1)]), 10, false).is_err());
366        assert!(bucket_side(&BookSide::from([(100, i64::MAX), (101, 1)]), 10, false).is_err());
367        // Extreme floor multiply must fail closed instead of wrapping/panicking.
368        assert!(bucket_side(&BookSide::from([(i64::MIN + 1, 1)]), 10, false).is_err());
369    }
370
371    #[test]
372    fn apply_side_delta_ignores_negative_levels() {
373        let mut book = BookSide::from([(100, 5)]);
374        apply_side_delta(&mut book, &[(-1, 3), (100, -2), (101, 4)]);
375        assert_eq!(book.get(&100), Some(&5));
376        assert_eq!(book.get(&101), Some(&4));
377        assert!(!book.contains_key(&-1));
378    }
379
380    #[test]
381    fn build_orderbook_data_rejects_negative_levels() {
382        let bids = BookSide::from([(-5, 1)]);
383        let asks = BookSide::from([(200, 1)]);
384        assert!(build_orderbook_data("BTC-USDT", 2, 7, &bids, &asks, 0, 8).is_err());
385    }
386
387    #[test]
388    fn parse_bucket_ticks_empty_is_zero() {
389        assert_eq!(parse_bucket_ticks("").unwrap(), 0);
390        assert!(parse_bucket_ticks("nope").is_err());
391        assert!(parse_bucket_ticks("-1").is_err());
392    }
393
394    #[test]
395    fn build_orderbook_data_sorts_and_limits() {
396        let bids = BookSide::from([(100, 1), (110, 2), (120, 3)]);
397        let asks = BookSide::from([(200, 4), (210, 5)]);
398        let data = build_orderbook_data("BTC-USDT", 2, 7, &bids, &asks, 0, 8).unwrap();
399        assert_eq!(data.book_seq, "7");
400        assert_eq!(data.bids.len(), 2);
401        assert_eq!(data.bids[0].price.as_ref().unwrap().as_ticks(), 120);
402        assert_eq!(data.asks[0].price.as_ref().unwrap().as_ticks(), 200);
403    }
404}