Skip to main content

lightcone_sdk/websocket/state/
orderbook.rs

1//! Local orderbook state management.
2//!
3//! Maintains a local copy of the orderbook state, applying deltas from
4//! WebSocket updates.
5//!
6//! Note: Internally uses String keys/values to match the String-based API.
7//! For numeric comparisons, parse the strings as needed.
8
9use std::cmp::Ordering;
10use std::collections::BTreeMap;
11use std::str::FromStr;
12
13use rust_decimal::Decimal;
14
15use crate::websocket::error::WebSocketError;
16use crate::websocket::types::{BookUpdateData, PriceLevel};
17
18/// A price key wrapper that provides numeric ordering for string prices.
19///
20/// This ensures prices are sorted numerically (e.g., "0.5" > "0.10") rather than
21/// lexicographically (where "0.10" > "0.5" as strings).
22#[derive(Debug, Clone, Eq, PartialEq)]
23struct PriceKey(String);
24
25impl PriceKey {
26    fn new(price: String) -> Self {
27        Self(price)
28    }
29
30    fn as_str(&self) -> &str {
31        &self.0
32    }
33}
34
35impl Ord for PriceKey {
36    fn cmp(&self, other: &Self) -> Ordering {
37        let self_dec = Decimal::from_str(&self.0).unwrap_or(Decimal::ZERO);
38        let other_dec = Decimal::from_str(&other.0).unwrap_or(Decimal::ZERO);
39        self_dec.cmp(&other_dec)
40    }
41}
42
43impl PartialOrd for PriceKey {
44    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
45        Some(self.cmp(other))
46    }
47}
48
49/// Check if a size string represents zero using precise decimal comparison.
50fn is_zero_size(s: &str) -> bool {
51    Decimal::from_str(s)
52        .map(|v| v.is_zero())
53        .unwrap_or(false)
54}
55
56/// Local orderbook state
57#[derive(Debug, Clone)]
58pub struct LocalOrderbook {
59    /// Orderbook identifier
60    pub orderbook_id: String,
61    /// Bid levels (price -> size), sorted numerically by price
62    bids: BTreeMap<PriceKey, String>,
63    /// Ask levels (price -> size), sorted numerically by price
64    asks: BTreeMap<PriceKey, String>,
65    /// Expected next sequence number
66    expected_sequence: u64,
67    /// Whether initial snapshot has been received
68    has_snapshot: bool,
69    /// Last update timestamp
70    last_timestamp: Option<String>,
71}
72
73impl LocalOrderbook {
74    /// Create a new empty orderbook
75    pub fn new(orderbook_id: String) -> Self {
76        Self {
77            orderbook_id,
78            bids: BTreeMap::new(),
79            asks: BTreeMap::new(),
80            expected_sequence: 0,
81            has_snapshot: false,
82            last_timestamp: None,
83        }
84    }
85
86    /// Apply a snapshot (full orderbook state)
87    pub fn apply_snapshot(&mut self, update: &BookUpdateData) {
88        // Clear existing state
89        self.bids.clear();
90        self.asks.clear();
91
92        // Apply all levels
93        for level in &update.bids {
94            if !is_zero_size(&level.size) {
95                self.bids
96                    .insert(PriceKey::new(level.price.clone()), level.size.clone());
97            }
98        }
99
100        for level in &update.asks {
101            if !is_zero_size(&level.size) {
102                self.asks
103                    .insert(PriceKey::new(level.price.clone()), level.size.clone());
104            }
105        }
106
107        self.expected_sequence = update.sequence + 1;
108        self.has_snapshot = true;
109        self.last_timestamp = Some(update.timestamp.clone());
110    }
111
112    /// Apply a delta update
113    ///
114    /// Returns an error if a sequence gap is detected.
115    pub fn apply_delta(&mut self, update: &BookUpdateData) -> Result<(), WebSocketError> {
116        // Check sequence number
117        if update.sequence != self.expected_sequence {
118            return Err(WebSocketError::SequenceGap {
119                expected: self.expected_sequence,
120                received: update.sequence,
121            });
122        }
123
124        // Apply bid updates
125        for level in &update.bids {
126            let key = PriceKey::new(level.price.clone());
127            if is_zero_size(&level.size) {
128                self.bids.remove(&key);
129            } else {
130                self.bids.insert(key, level.size.clone());
131            }
132        }
133
134        // Apply ask updates
135        for level in &update.asks {
136            let key = PriceKey::new(level.price.clone());
137            if is_zero_size(&level.size) {
138                self.asks.remove(&key);
139            } else {
140                self.asks.insert(key, level.size.clone());
141            }
142        }
143
144        self.expected_sequence = update.sequence + 1;
145        self.last_timestamp = Some(update.timestamp.clone());
146        Ok(())
147    }
148
149    /// Apply an update (snapshot or delta)
150    pub fn apply_update(&mut self, update: &BookUpdateData) -> Result<(), WebSocketError> {
151        if update.is_snapshot {
152            self.apply_snapshot(update);
153            Ok(())
154        } else {
155            self.apply_delta(update)
156        }
157    }
158
159    /// Get all bid levels sorted by price (descending - highest first)
160    pub fn get_bids(&self) -> Vec<PriceLevel> {
161        self.bids
162            .iter()
163            .rev()
164            .map(|(price, size)| PriceLevel {
165                side: "bid".to_string(),
166                price: price.as_str().to_string(),
167                size: size.clone(),
168            })
169            .collect()
170    }
171
172    /// Get all ask levels sorted by price (ascending - lowest first)
173    pub fn get_asks(&self) -> Vec<PriceLevel> {
174        self.asks
175            .iter()
176            .map(|(price, size)| PriceLevel {
177                side: "ask".to_string(),
178                price: price.as_str().to_string(),
179                size: size.clone(),
180            })
181            .collect()
182    }
183
184    /// Get top N bid levels (highest prices first)
185    pub fn get_top_bids(&self, n: usize) -> Vec<PriceLevel> {
186        self.bids
187            .iter()
188            .rev()
189            .take(n)
190            .map(|(price, size)| PriceLevel {
191                side: "bid".to_string(),
192                price: price.as_str().to_string(),
193                size: size.clone(),
194            })
195            .collect()
196    }
197
198    /// Get top N ask levels (lowest prices first)
199    pub fn get_top_asks(&self, n: usize) -> Vec<PriceLevel> {
200        self.asks
201            .iter()
202            .take(n)
203            .map(|(price, size)| PriceLevel {
204                side: "ask".to_string(),
205                price: price.as_str().to_string(),
206                size: size.clone(),
207            })
208            .collect()
209    }
210
211    /// Get the best bid (highest bid price) as (price_string, size_string)
212    pub fn best_bid(&self) -> Option<(String, String)> {
213        self.bids
214            .iter()
215            .next_back()
216            .map(|(p, s)| (p.as_str().to_string(), s.clone()))
217    }
218
219    /// Get the best ask (lowest ask price) as (price_string, size_string)
220    pub fn best_ask(&self) -> Option<(String, String)> {
221        self.asks
222            .iter()
223            .next()
224            .map(|(p, s)| (p.as_str().to_string(), s.clone()))
225    }
226
227    /// Get the spread as a string (best_ask - best_bid)
228    /// Uses Decimal for precise calculation to preserve backend precision.
229    pub fn spread(&self) -> Option<String> {
230        match (self.best_bid(), self.best_ask()) {
231            (Some((bid, _)), Some((ask, _))) => {
232                let bid_dec = Decimal::from_str(&bid).ok()?;
233                let ask_dec = Decimal::from_str(&ask).ok()?;
234                if ask_dec > bid_dec {
235                    Some((ask_dec - bid_dec).to_string())
236                } else {
237                    Some(Decimal::ZERO.to_string())
238                }
239            }
240            _ => None,
241        }
242    }
243
244    /// Get the midpoint price as a string
245    /// Uses Decimal for precise calculation to preserve backend precision.
246    pub fn midpoint(&self) -> Option<String> {
247        match (self.best_bid(), self.best_ask()) {
248            (Some((bid, _)), Some((ask, _))) => {
249                let bid_dec = Decimal::from_str(&bid).ok()?;
250                let ask_dec = Decimal::from_str(&ask).ok()?;
251                let two = Decimal::from(2);
252                Some(((bid_dec + ask_dec) / two).to_string())
253            }
254            _ => None,
255        }
256    }
257
258    /// Get size at a specific bid price
259    pub fn bid_size_at(&self, price: &str) -> Option<String> {
260        self.bids.get(&PriceKey::new(price.to_string())).cloned()
261    }
262
263    /// Get size at a specific ask price
264    pub fn ask_size_at(&self, price: &str) -> Option<String> {
265        self.asks.get(&PriceKey::new(price.to_string())).cloned()
266    }
267
268    /// Get total bid depth (sum of all bid sizes)
269    /// Uses Decimal for precise calculation to preserve backend precision.
270    pub fn total_bid_depth(&self) -> Decimal {
271        self.bids
272            .values()
273            .filter_map(|s| Decimal::from_str(s).ok())
274            .fold(Decimal::ZERO, |acc, x| acc + x)
275    }
276
277    /// Get total ask depth (sum of all ask sizes)
278    /// Uses Decimal for precise calculation to preserve backend precision.
279    pub fn total_ask_depth(&self) -> Decimal {
280        self.asks
281            .values()
282            .filter_map(|s| Decimal::from_str(s).ok())
283            .fold(Decimal::ZERO, |acc, x| acc + x)
284    }
285
286    /// Number of bid levels
287    pub fn bid_count(&self) -> usize {
288        self.bids.len()
289    }
290
291    /// Number of ask levels
292    pub fn ask_count(&self) -> usize {
293        self.asks.len()
294    }
295
296    /// Whether the orderbook has received its initial snapshot
297    pub fn has_snapshot(&self) -> bool {
298        self.has_snapshot
299    }
300
301    /// Current expected sequence number
302    pub fn expected_sequence(&self) -> u64 {
303        self.expected_sequence
304    }
305
306    /// Last update timestamp
307    pub fn last_timestamp(&self) -> Option<&str> {
308        self.last_timestamp.as_deref()
309    }
310
311    /// Clear the orderbook state (for resync)
312    pub fn clear(&mut self) {
313        self.bids.clear();
314        self.asks.clear();
315        self.expected_sequence = 0;
316        self.has_snapshot = false;
317        self.last_timestamp = None;
318    }
319}
320
321#[cfg(test)]
322mod tests {
323    use super::*;
324
325    fn create_snapshot() -> BookUpdateData {
326        BookUpdateData {
327            orderbook_id: "test".to_string(),
328            timestamp: "2024-01-01T00:00:00.000Z".to_string(),
329            sequence: 0,
330            bids: vec![
331                PriceLevel {
332                    side: "bid".to_string(),
333                    price: "0.500000".to_string(),
334                    size: "0.001000".to_string(),
335                },
336                PriceLevel {
337                    side: "bid".to_string(),
338                    price: "0.490000".to_string(),
339                    size: "0.002000".to_string(),
340                },
341            ],
342            asks: vec![
343                PriceLevel {
344                    side: "ask".to_string(),
345                    price: "0.510000".to_string(),
346                    size: "0.000500".to_string(),
347                },
348                PriceLevel {
349                    side: "ask".to_string(),
350                    price: "0.520000".to_string(),
351                    size: "0.001500".to_string(),
352                },
353            ],
354            is_snapshot: true,
355            resync: false,
356            message: None,
357        }
358    }
359
360    #[test]
361    fn test_apply_snapshot() {
362        let mut book = LocalOrderbook::new("test".to_string());
363        let snapshot = create_snapshot();
364
365        book.apply_snapshot(&snapshot);
366
367        assert!(book.has_snapshot());
368        assert_eq!(book.expected_sequence(), 1);
369        assert_eq!(book.bid_count(), 2);
370        assert_eq!(book.ask_count(), 2);
371        assert_eq!(book.best_bid(), Some(("0.500000".to_string(), "0.001000".to_string())));
372        assert_eq!(book.best_ask(), Some(("0.510000".to_string(), "0.000500".to_string())));
373    }
374
375    #[test]
376    fn test_apply_delta() {
377        let mut book = LocalOrderbook::new("test".to_string());
378        book.apply_snapshot(&create_snapshot());
379
380        let delta = BookUpdateData {
381            orderbook_id: "test".to_string(),
382            timestamp: "2024-01-01T00:00:00.050Z".to_string(),
383            sequence: 1,
384            bids: vec![PriceLevel {
385                side: "bid".to_string(),
386                price: "0.500000".to_string(),
387                size: "0.001500".to_string(), // Updated size
388            }],
389            asks: vec![PriceLevel {
390                side: "ask".to_string(),
391                price: "0.510000".to_string(),
392                size: "0".to_string(), // Remove level
393            }],
394            is_snapshot: false,
395            resync: false,
396            message: None,
397        };
398
399        book.apply_delta(&delta).unwrap();
400
401        assert_eq!(book.best_bid(), Some(("0.500000".to_string(), "0.001500".to_string())));
402        assert_eq!(book.best_ask(), Some(("0.520000".to_string(), "0.001500".to_string())));
403        assert_eq!(book.ask_count(), 1);
404    }
405
406    #[test]
407    fn test_sequence_gap_detection() {
408        let mut book = LocalOrderbook::new("test".to_string());
409        book.apply_snapshot(&create_snapshot());
410
411        let delta = BookUpdateData {
412            orderbook_id: "test".to_string(),
413            timestamp: "2024-01-01T00:00:00.050Z".to_string(),
414            sequence: 5, // Gap!
415            bids: vec![],
416            asks: vec![],
417            is_snapshot: false,
418            resync: false,
419            message: None,
420        };
421
422        let result = book.apply_delta(&delta);
423        assert!(matches!(result, Err(WebSocketError::SequenceGap { .. })));
424    }
425
426    #[test]
427    fn test_spread_and_midpoint() {
428        let mut book = LocalOrderbook::new("test".to_string());
429        book.apply_snapshot(&create_snapshot());
430
431        // Decimal preserves precision from input strings
432        assert_eq!(book.spread(), Some("0.010000".to_string()));
433        assert_eq!(book.midpoint(), Some("0.505000".to_string()));
434    }
435
436    #[test]
437    fn test_depth() {
438        let mut book = LocalOrderbook::new("test".to_string());
439        book.apply_snapshot(&create_snapshot());
440
441        // Returns Decimal now for precise calculations
442        assert_eq!(book.total_bid_depth(), Decimal::from_str("0.003").unwrap());
443        assert_eq!(book.total_ask_depth(), Decimal::from_str("0.002").unwrap());
444    }
445
446    #[test]
447    fn test_variable_precision_price_sorting() {
448        // This test ensures prices are sorted numerically, not lexicographically.
449        // Lexicographic: "0.10" > "0.5" (because '1' > '0' in second char)
450        // Numeric: "0.5" > "0.10" (because 0.5 > 0.1)
451        let mut book = LocalOrderbook::new("test".to_string());
452
453        let snapshot = BookUpdateData {
454            orderbook_id: "test".to_string(),
455            timestamp: "2024-01-01T00:00:00.000Z".to_string(),
456            sequence: 0,
457            bids: vec![
458                PriceLevel {
459                    side: "bid".to_string(),
460                    price: "0.10".to_string(), // 0.1
461                    size: "1.0".to_string(),
462                },
463                PriceLevel {
464                    side: "bid".to_string(),
465                    price: "0.5".to_string(), // 0.5 - should be best bid
466                    size: "2.0".to_string(),
467                },
468                PriceLevel {
469                    side: "bid".to_string(),
470                    price: "0.100000".to_string(), // Also 0.1, same as first
471                    size: "3.0".to_string(),
472                },
473            ],
474            asks: vec![
475                PriceLevel {
476                    side: "ask".to_string(),
477                    price: "0.9".to_string(), // 0.9
478                    size: "1.0".to_string(),
479                },
480                PriceLevel {
481                    side: "ask".to_string(),
482                    price: "0.51".to_string(), // 0.51 - should be best ask
483                    size: "2.0".to_string(),
484                },
485            ],
486            is_snapshot: true,
487            resync: false,
488            message: None,
489        };
490
491        book.apply_snapshot(&snapshot);
492
493        // Best bid should be 0.5 (highest), not "0.10" (which would be lexicographically highest)
494        let best_bid = book.best_bid().unwrap();
495        assert_eq!(best_bid.0, "0.5", "Best bid should be 0.5, not {}", best_bid.0);
496
497        // Best ask should be 0.51 (lowest), not "0.9" (which would be lexicographically lowest)
498        let best_ask = book.best_ask().unwrap();
499        assert_eq!(
500            best_ask.0, "0.51",
501            "Best ask should be 0.51, not {}",
502            best_ask.0
503        );
504
505        // Verify bid ordering: should be [0.5, 0.100000] (descending by numeric value)
506        // Note: "0.10" and "0.100000" both represent 0.1, so one overwrites the other
507        let bids = book.get_bids();
508        assert_eq!(bids.len(), 2); // 0.5 and 0.1 (one of the 0.1s)
509        assert_eq!(bids[0].price, "0.5"); // Highest first
510    }
511
512    #[test]
513    fn test_is_zero_size() {
514        // Test the is_zero_size helper
515        assert!(super::is_zero_size("0"));
516        assert!(super::is_zero_size("0.0"));
517        assert!(super::is_zero_size("0.000000"));
518        assert!(super::is_zero_size("0.00000000000"));
519        assert!(!super::is_zero_size("0.001"));
520        assert!(!super::is_zero_size("1"));
521        assert!(!super::is_zero_size("0.0000001"));
522    }
523}