Skip to main content

algo_sdk/
book.rs

1//! Order book types — L2 depth, price levels.
2
3// =============================================================================
4// PRICE LEVEL
5// =============================================================================
6
7/// Single price level in the order book.
8#[derive(Debug, Clone, Copy, Default)]
9#[repr(C)]
10pub struct Level {
11    pub px_1e9: u64, // Price × 10⁹
12    pub sz_1e8: u64, // Size × 10⁸
13}
14
15impl Level {
16    pub const EMPTY: Self = Self {
17        px_1e9: 0,
18        sz_1e8: 0,
19    };
20
21    #[inline(always)]
22    pub fn is_valid(&self) -> bool {
23        self.px_1e9 > 0
24    }
25}
26
27// =============================================================================
28// L2 ORDER BOOK - 20 levels each side
29// =============================================================================
30
31/// L2 order book with up to 20 levels per side.
32/// Total size: 688 bytes (fits in L1 cache).
33#[derive(Clone, Copy)]
34#[repr(C)]
35pub struct L2Book {
36    pub bids: [Level; 20], // Best (index 0) to worst
37    pub asks: [Level; 20], // Best (index 0) to worst
38    pub bid_ct: u8,        // Valid bid levels
39    pub ask_ct: u8,        // Valid ask levels
40    pub symbol_id: u16,
41    pub _pad: u32,
42    pub recv_ns: u64, // Receive timestamp
43}
44
45impl Default for L2Book {
46    fn default() -> Self {
47        Self {
48            bids: [Level::EMPTY; 20],
49            asks: [Level::EMPTY; 20],
50            bid_ct: 0,
51            ask_ct: 0,
52            symbol_id: 0,
53            _pad: 0,
54            recv_ns: 0,
55        }
56    }
57}
58
59impl L2Book {
60    #[inline(always)]
61    pub fn best_bid(&self) -> Option<&Level> {
62        if self.bid_ct > 0 && self.bids[0].px_1e9 > 0 {
63            Some(&self.bids[0])
64        } else {
65            None
66        }
67    }
68
69    #[inline(always)]
70    pub fn best_ask(&self) -> Option<&Level> {
71        if self.ask_ct > 0 && self.asks[0].px_1e9 > 0 {
72            Some(&self.asks[0])
73        } else {
74            None
75        }
76    }
77
78    #[inline(always)]
79    pub fn mid_px_1e9(&self) -> u64 {
80        if self.bid_ct == 0 || self.ask_ct == 0 {
81            return 0;
82        }
83        (self.bids[0].px_1e9 + self.asks[0].px_1e9) / 2
84    }
85
86    /// Raw unsigned spread in 1e9 price units.
87    /// Returns 0 for crossed books (ask < bid) due to `saturating_sub`.
88    /// Use `spread_signed_1e9()` or `is_crossed()` for proper handling.
89    #[inline(always)]
90    pub fn spread_1e9(&self) -> u64 {
91        if self.bid_ct == 0 || self.ask_ct == 0 {
92            return u64::MAX;
93        }
94        self.asks[0].px_1e9.saturating_sub(self.bids[0].px_1e9)
95    }
96
97    /// Signed spread in 1e9 price units. Negative = crossed book.
98    #[inline(always)]
99    pub fn spread_signed_1e9(&self) -> i64 {
100        if self.bid_ct == 0 || self.ask_ct == 0 {
101            return i64::MAX;
102        }
103        self.asks[0].px_1e9 as i64 - self.bids[0].px_1e9 as i64
104    }
105
106    /// Spread in integer basis points (truncated).
107    /// WARNING: Returns 0 for sub-bps spreads common on liquid assets (BTC, ETH).
108    /// Use `spread_bps_x1000()` for milli-bps precision on liquid markets.
109    #[inline(always)]
110    pub fn spread_bps(&self) -> u32 {
111        let mid = self.mid_px_1e9();
112        if mid == 0 {
113            return u32::MAX;
114        }
115        ((self.spread_1e9() * 10_000) / mid) as u32
116    }
117
118    /// Spread in milli-basis-points (1 bps = 1000 milli-bps), signed.
119    /// Handles sub-bps precision for liquid markets and negative values for crossed books.
120    /// Uses u128 intermediate to avoid overflow on high-priced assets.
121    #[inline(always)]
122    pub fn spread_bps_x1000(&self) -> i32 {
123        let mid = self.mid_px_1e9();
124        if mid == 0 {
125            return i32::MAX;
126        }
127        let spread = self.asks[0].px_1e9 as i128 - self.bids[0].px_1e9 as i128;
128        ((spread * 10_000_000) / mid as i128) as i32
129    }
130
131    /// Whether the book is crossed (best bid > best ask).
132    /// Common in consolidated NBBO when different venues have different prices.
133    #[inline(always)]
134    pub fn is_crossed(&self) -> bool {
135        self.bid_ct > 0 && self.ask_ct > 0 && self.bids[0].px_1e9 > self.asks[0].px_1e9
136    }
137
138    #[inline(always)]
139    pub fn bid_depth_1e8(&self, levels: usize) -> u64 {
140        let n = levels.min(self.bid_ct as usize);
141        let mut sum = 0u64;
142        for i in 0..n {
143            sum += self.bids[i].sz_1e8;
144        }
145        sum
146    }
147
148    #[inline(always)]
149    pub fn ask_depth_1e8(&self, levels: usize) -> u64 {
150        let n = levels.min(self.ask_ct as usize);
151        let mut sum = 0u64;
152        for i in 0..n {
153            sum += self.asks[i].sz_1e8;
154        }
155        sum
156    }
157
158    #[inline(always)]
159    pub fn imbalance_bps(&self, levels: usize) -> i32 {
160        let bid_depth = self.bid_depth_1e8(levels);
161        let ask_depth = self.ask_depth_1e8(levels);
162        let total = bid_depth + ask_depth;
163        if total == 0 {
164            return 0;
165        }
166        (((bid_depth as i64 - ask_depth as i64) * 10_000) / total as i64) as i32
167    }
168}