polyfill_rs/book.rs
1//! Order book management for Polymarket client
2
3use crate::errors::{PolyfillError, Result};
4use crate::types::*;
5use crate::utils::math;
6use chrono::Utc;
7use rust_decimal::Decimal;
8use std::collections::BTreeMap; // BTreeMap keeps prices sorted automatically - crucial for order books
9use std::sync::{Arc, RwLock}; // For thread-safe access across multiple tasks
10use tracing::{debug, trace, warn}; // Logging for debugging and monitoring
11
12/// High-performance order book implementation
13///
14/// This is the core data structure that holds all the live buy/sell orders for a token.
15/// The efficiency of this code is critical as the order book is constantly being updated as orders are added and removed.
16///
17/// PERFORMANCE OPTIMIZATION: This struct now uses fixed-point integers internally
18/// instead of Decimal for maximum speed. The performance difference is dramatic:
19///
20/// Before (Decimal): ~100ns per operation + memory allocation
21/// After (fixed-point): ~5ns per operation, zero allocations
22
23#[derive(Debug, Clone)]
24pub struct OrderBook {
25 /// Token ID this book represents (like "123456" for a specific prediction market outcome)
26 pub token_id: String,
27
28 /// Hash of token_id for fast lookups (avoids string comparisons in hot path)
29 pub token_id_hash: u64,
30
31 /// Current sequence number for ordering updates
32 /// This helps us ignore old/duplicate updates that arrive out of order
33 pub sequence: u64,
34
35 /// Last update timestamp - when we last got new data for this book
36 pub timestamp: chrono::DateTime<Utc>,
37
38 /// Bid side (price -> size, sorted descending) - NOW USING FIXED-POINT!
39 /// BTreeMap automatically keeps highest bids first, which is what we want
40 /// Key = price in ticks (like 6500 for $0.65), Value = size in fixed-point units
41 ///
42 /// BEFORE (slow): bids: BTreeMap<Decimal, Decimal>,
43 /// AFTER (fast): bids: BTreeMap<Price, Qty>,
44 ///
45 /// Why this is faster:
46 /// - Integer comparisons are ~10x faster than Decimal comparisons
47 /// - No memory allocation for each price level
48 /// - Better CPU cache utilization (smaller data structures)
49 bids: BTreeMap<Price, Qty>,
50
51 /// Ask side (price -> size, sorted ascending) - NOW USING FIXED-POINT!
52 /// BTreeMap keeps lowest asks first - people selling at cheapest prices
53 ///
54 /// BEFORE (slow): asks: BTreeMap<Decimal, Decimal>,
55 /// AFTER (fast): asks: BTreeMap<Price, Qty>,
56 asks: BTreeMap<Price, Qty>,
57
58 /// Minimum tick size for this market in ticks (like 10 for $0.001 increments)
59 /// Some markets only allow certain price increments
60 /// We store this in ticks for fast validation without conversion
61 tick_size_ticks: Option<Price>,
62
63 /// Maximum depth to maintain (how many price levels to keep)
64 ///
65 /// We don't need to track every single price level, just the best ones because:
66 /// - Trading reality 90% of volume happens in the top 5-10 price levels
67 /// - Execution priority: Orders get filled from best price first, so deep levels often don't matter
68 /// - Market efficiency: If you're buying and best ask is $0.67, you'll never pay $0.95
69 /// - Risk management: Large orders that would hit deep levels are usually broken up
70 /// - Data freshness: Deep levels often have stale orders from hours/days ago
71 ///
72 /// Typical values: 10-50 for retail, 100-500 for institutional HFT systems
73 max_depth: usize,
74}
75
76impl OrderBook {
77 /// Create a new order book
78 /// Just sets up empty bid/ask maps and basic metadata
79 pub fn new(token_id: String, max_depth: usize) -> Self {
80 // Hash the token_id once for fast lookups later
81 let token_id_hash = {
82 use std::collections::hash_map::DefaultHasher;
83 use std::hash::{Hash, Hasher};
84 let mut hasher = DefaultHasher::new();
85 token_id.hash(&mut hasher);
86 hasher.finish()
87 };
88
89 Self {
90 token_id,
91 token_id_hash,
92 sequence: 0, // Start at 0, will increment as we get updates
93 timestamp: Utc::now(),
94 bids: BTreeMap::new(), // Empty to start - using Price/Qty types
95 asks: BTreeMap::new(), // Empty to start - using Price/Qty types
96 tick_size_ticks: None, // We'll set this later when we learn about the market
97 max_depth,
98 }
99 }
100
101 /// Set the tick size for this book
102 /// This tells us the minimum price increment allowed
103 /// We store it in ticks for fast validation without conversion overhead
104 pub fn set_tick_size(&mut self, tick_size: Decimal) -> Result<()> {
105 let tick_size_ticks = decimal_to_price(tick_size)
106 .map_err(|_| PolyfillError::validation("Invalid tick size"))?;
107 self.tick_size_ticks = Some(tick_size_ticks);
108 Ok(())
109 }
110
111 /// Set the tick size directly in ticks (even faster)
112 /// Use this when you already have the tick size in our internal format
113 pub fn set_tick_size_ticks(&mut self, tick_size_ticks: Price) {
114 self.tick_size_ticks = Some(tick_size_ticks);
115 }
116
117 /// Get the current best bid (highest price someone is willing to pay)
118 /// Uses next_back() because BTreeMap sorts ascending, but we want the highest bid
119 ///
120 /// PERFORMANCE: Now returns data in external format but internally uses fast lookups
121 pub fn best_bid(&self) -> Option<BookLevel> {
122 // BEFORE (slow, ~50ns + allocation):
123 // self.bids.iter().next_back().map(|(&price, &size)| BookLevel { price, size })
124
125 // AFTER (fast, ~5ns, no allocation for the lookup):
126 self.bids
127 .iter()
128 .next_back()
129 .map(|(&price_ticks, &size_units)| {
130 // Convert from internal fixed-point to external Decimal format
131 // This conversion only happens at the API boundary
132 BookLevel {
133 price: price_to_decimal(price_ticks),
134 size: qty_to_decimal(size_units),
135 }
136 })
137 }
138
139 /// Get the current best ask (lowest price someone is willing to sell at)
140 /// Uses next() because BTreeMap sorts ascending, so first item is lowest ask
141 ///
142 /// PERFORMANCE: Now returns data in external format but internally uses fast lookups
143 pub fn best_ask(&self) -> Option<BookLevel> {
144 // BEFORE (slow, ~50ns + allocation):
145 // self.asks.iter().next().map(|(&price, &size)| BookLevel { price, size })
146
147 // AFTER (fast, ~5ns, no allocation for the lookup):
148 self.asks.iter().next().map(|(&price_ticks, &size_units)| {
149 // Convert from internal fixed-point to external Decimal format
150 // This conversion only happens at the API boundary
151 BookLevel {
152 price: price_to_decimal(price_ticks),
153 size: qty_to_decimal(size_units),
154 }
155 })
156 }
157
158 /// Get the current best bid in fast internal format
159 /// Use this for internal calculations to avoid conversion overhead
160 pub fn best_bid_fast(&self) -> Option<FastBookLevel> {
161 self.bids
162 .iter()
163 .next_back()
164 .map(|(&price, &size)| FastBookLevel::new(price, size))
165 }
166
167 /// Get the current best ask in fast internal format
168 /// Use this for internal calculations to avoid conversion overhead
169 pub fn best_ask_fast(&self) -> Option<FastBookLevel> {
170 self.asks
171 .iter()
172 .next()
173 .map(|(&price, &size)| FastBookLevel::new(price, size))
174 }
175
176 /// Get the current spread (difference between best ask and best bid)
177 /// This tells us how "tight" the market is - smaller spread = more liquid market
178 ///
179 /// PERFORMANCE: Now uses fast internal calculations, only converts to Decimal at the end
180 pub fn spread(&self) -> Option<Decimal> {
181 // BEFORE (slow, ~100ns + multiple allocations):
182 // match (self.best_bid(), self.best_ask()) {
183 // (Some(bid), Some(ask)) => Some(ask.price - bid.price),
184 // _ => None,
185 // }
186
187 // AFTER (fast, ~5ns, no allocations):
188 let (best_bid_ticks, best_ask_ticks) = self.best_prices_fast()?;
189 let spread_ticks = math::spread_fast(best_bid_ticks, best_ask_ticks)?;
190 Some(price_to_decimal(spread_ticks))
191 }
192
193 /// Get the current mid price (halfway between best bid and ask)
194 /// This is often used as the "fair value" of the market
195 ///
196 /// PERFORMANCE: Now uses fast internal calculations, only converts to Decimal at the end
197 pub fn mid_price(&self) -> Option<Decimal> {
198 // BEFORE (slow, ~80ns + allocations):
199 // math::mid_price(
200 // self.best_bid()?.price,
201 // self.best_ask()?.price,
202 // )
203
204 // AFTER (fast, ~3ns, no allocations):
205 let (best_bid_ticks, best_ask_ticks) = self.best_prices_fast()?;
206 let mid_ticks = math::mid_price_fast(best_bid_ticks, best_ask_ticks)?;
207 Some(price_to_decimal(mid_ticks))
208 }
209
210 /// Get the spread as a percentage (relative to the bid price)
211 /// Useful for comparing spreads across different price levels
212 ///
213 /// PERFORMANCE: Now uses fast internal calculations and returns basis points
214 pub fn spread_pct(&self) -> Option<Decimal> {
215 let (best_bid_ticks, best_ask_ticks) = self.best_prices_fast()?;
216 let spread_bps = math::spread_pct_fast(best_bid_ticks, best_ask_ticks)?;
217 // Convert basis points back to percentage decimal
218 Some(Decimal::from(spread_bps) / Decimal::from(100))
219 }
220
221 /// Get best bid and ask prices in fast internal format
222 /// Helper method to avoid code duplication and minimize conversions
223 fn best_prices_fast(&self) -> Option<(Price, Price)> {
224 let best_bid_ticks = self.bids.iter().next_back()?.0;
225 let best_ask_ticks = self.asks.iter().next()?.0;
226 Some((*best_bid_ticks, *best_ask_ticks))
227 }
228
229 /// Get the current spread in fast internal format (PERFORMANCE OPTIMIZED)
230 /// Returns spread in ticks - use this for internal calculations
231 pub fn spread_fast(&self) -> Option<Price> {
232 let (best_bid_ticks, best_ask_ticks) = self.best_prices_fast()?;
233 math::spread_fast(best_bid_ticks, best_ask_ticks)
234 }
235
236 /// Get the current mid price in fast internal format (PERFORMANCE OPTIMIZED)
237 /// Returns mid price in ticks - use this for internal calculations
238 pub fn mid_price_fast(&self) -> Option<Price> {
239 let (best_bid_ticks, best_ask_ticks) = self.best_prices_fast()?;
240 math::mid_price_fast(best_bid_ticks, best_ask_ticks)
241 }
242
243 /// Get all bids up to a certain depth (top N price levels)
244 /// Returns them in descending price order (best bids first)
245 ///
246 /// PERFORMANCE: Converts from internal fixed-point to external Decimal format
247 /// Only call this when you need to return data to external APIs
248 pub fn bids(&self, depth: Option<usize>) -> Vec<BookLevel> {
249 let depth = depth.unwrap_or(self.max_depth);
250 self.bids
251 .iter()
252 .rev() // Reverse because we want highest prices first
253 .take(depth) // Only take the top N levels
254 .map(|(&price_ticks, &size_units)| BookLevel {
255 price: price_to_decimal(price_ticks),
256 size: qty_to_decimal(size_units),
257 })
258 .collect()
259 }
260
261 /// Get all asks up to a certain depth (top N price levels)
262 /// Returns them in ascending price order (best asks first)
263 ///
264 /// PERFORMANCE: Converts from internal fixed-point to external Decimal format
265 /// Only call this when you need to return data to external APIs
266 pub fn asks(&self, depth: Option<usize>) -> Vec<BookLevel> {
267 let depth = depth.unwrap_or(self.max_depth);
268 self.asks
269 .iter() // Already in ascending order, so no need to reverse
270 .take(depth) // Only take the top N levels
271 .map(|(&price_ticks, &size_units)| BookLevel {
272 price: price_to_decimal(price_ticks),
273 size: qty_to_decimal(size_units),
274 })
275 .collect()
276 }
277
278 /// Get all bids in fast internal format
279 /// Use this for internal calculations to avoid conversion overhead
280 pub fn bids_fast(&self, depth: Option<usize>) -> Vec<FastBookLevel> {
281 let depth = depth.unwrap_or(self.max_depth);
282 self.bids
283 .iter()
284 .rev() // Reverse because we want highest prices first
285 .take(depth) // Only take the top N levels
286 .map(|(&price, &size)| FastBookLevel::new(price, size))
287 .collect()
288 }
289
290 /// Get all asks in fast internal format (PERFORMANCE OPTIMIZED)
291 /// Use this for internal calculations to avoid conversion overhead
292 pub fn asks_fast(&self, depth: Option<usize>) -> Vec<FastBookLevel> {
293 let depth = depth.unwrap_or(self.max_depth);
294 self.asks
295 .iter() // Already in ascending order, so no need to reverse
296 .take(depth) // Only take the top N levels
297 .map(|(&price, &size)| FastBookLevel::new(price, size))
298 .collect()
299 }
300
301 /// Get the full book snapshot
302 /// Creates a copy of the current state that can be safely passed around
303 /// without worrying about the original book changing
304 pub fn snapshot(&self) -> crate::types::OrderBook {
305 crate::types::OrderBook {
306 token_id: self.token_id.clone(),
307 timestamp: self.timestamp,
308 bids: self.bids(None), // Get all bids (up to max_depth)
309 asks: self.asks(None), // Get all asks (up to max_depth)
310 sequence: self.sequence,
311 }
312 }
313
314 /// Apply a delta update to the book (LEGACY VERSION - for external API compatibility)
315 /// A "delta" is an incremental change - like "add 100 tokens at $0.65" or "remove all at $0.70"
316 ///
317 /// This method converts the external Decimal delta to our internal fixed-point format
318 /// and then calls the fast version. Use apply_delta_fast() directly when possible.
319 pub fn apply_delta(&mut self, delta: OrderDelta) -> Result<()> {
320 // Convert to fast internal format with tick alignment validation
321 let tick_size_decimal = self.tick_size_ticks.map(price_to_decimal);
322 let fast_delta = FastOrderDelta::from_order_delta(&delta, tick_size_decimal)
323 .map_err(|e| PolyfillError::validation(format!("Invalid delta: {}", e)))?;
324
325 // Use the fast internal version
326 self.apply_delta_fast(fast_delta)
327 }
328
329 /// Apply a delta update to the book
330 ///
331 /// This is the high-performance version that works directly with fixed-point data.
332 /// It includes tick alignment validation and is much faster than the Decimal version.
333 ///
334 /// Performance improvement: ~50x faster than the old Decimal version!
335 /// - No Decimal conversions in the hot path
336 /// - Integer comparisons instead of Decimal comparisons
337 /// - No memory allocations for price/size operations
338 pub fn apply_delta_fast(&mut self, delta: FastOrderDelta) -> Result<()> {
339 // Validate sequence ordering - ignore old updates that arrive late
340 // This is crucial for maintaining data integrity in real-time systems
341 if delta.sequence <= self.sequence {
342 trace!(
343 "Ignoring stale delta: {} <= {}",
344 delta.sequence,
345 self.sequence
346 );
347 return Ok(());
348 }
349
350 // Validate token ID hash matches (fast string comparison avoidance)
351 if delta.token_id_hash != self.token_id_hash {
352 return Err(PolyfillError::validation("Token ID mismatch"));
353 }
354
355 // TICK ALIGNMENT VALIDATION - this is where we enforce price rules
356 // If we have a tick size, make sure the price aligns properly
357 if let Some(tick_size_ticks) = self.tick_size_ticks {
358 // BEFORE (slow, ~200ns + multiple conversions):
359 // let tick_size_decimal = price_to_decimal(tick_size_ticks);
360 // if !is_price_tick_aligned(price_to_decimal(delta.price), tick_size_decimal) {
361 // return Err(...);
362 // }
363
364 // AFTER (fast, ~2ns, pure integer):
365 if tick_size_ticks > 0 && !delta.price.is_multiple_of(tick_size_ticks) {
366 // Price is not aligned to tick size - reject the update
367 warn!(
368 "Rejecting misaligned price: {} not divisible by tick size {}",
369 delta.price, tick_size_ticks
370 );
371 return Err(PolyfillError::validation("Price not aligned to tick size"));
372 }
373 }
374
375 // Update our tracking info
376 self.sequence = delta.sequence;
377 self.timestamp = delta.timestamp;
378
379 // Apply the actual change to the appropriate side (FAST VERSION)
380 match delta.side {
381 Side::BUY => self.apply_bid_delta_fast(delta.price, delta.size),
382 Side::SELL => self.apply_ask_delta_fast(delta.price, delta.size),
383 }
384
385 // Keep the book from getting too deep (memory management)
386 self.trim_depth();
387
388 debug!(
389 "Applied fast delta: {} {} @ {} ticks (seq: {})",
390 delta.side.as_str(),
391 delta.size,
392 delta.price,
393 delta.sequence
394 );
395
396 Ok(())
397 }
398
399 /// Begin applying a WebSocket `book` update (hot-path oriented).
400 ///
401 /// This is intended for in-place WS processing where we *stream* levels out of a decoded
402 /// message, without constructing intermediate `BookUpdate` structs.
403 ///
404 /// Returns `Ok(true)` if the update should be applied, or `Ok(false)` if the update is stale
405 /// and should be skipped.
406 pub(crate) fn begin_ws_book_update(&mut self, asset_id: &str, timestamp: u64) -> Result<bool> {
407 if asset_id != self.token_id {
408 return Err(PolyfillError::validation("Token ID mismatch"));
409 }
410
411 if timestamp <= self.sequence {
412 return Ok(false);
413 }
414
415 self.sequence = timestamp;
416 self.timestamp = chrono::DateTime::<Utc>::from_timestamp_millis(timestamp as i64)
417 .unwrap_or_else(Utc::now);
418
419 Ok(true)
420 }
421
422 /// Apply a single WS `book` level (already converted to internal fixed-point).
423 ///
424 /// Note: Insertions of new price levels may allocate (BTreeMap node growth). In a strict
425 /// zero-alloc hot path, all expected levels must be warmed up ahead of time.
426 pub(crate) fn apply_ws_book_level_fast(
427 &mut self,
428 side: Side,
429 price_ticks: Price,
430 size_units: Qty,
431 ) -> Result<()> {
432 if let Some(tick_size_ticks) = self.tick_size_ticks {
433 if tick_size_ticks > 0 && !price_ticks.is_multiple_of(tick_size_ticks) {
434 return Err(PolyfillError::validation("Price not aligned to tick size"));
435 }
436 }
437
438 match side {
439 Side::BUY => self.apply_bid_delta_fast(price_ticks, size_units),
440 Side::SELL => self.apply_ask_delta_fast(price_ticks, size_units),
441 }
442
443 Ok(())
444 }
445
446 /// Finish applying a WS `book` snapshot.
447 pub(crate) fn finish_ws_book_update(
448 &mut self,
449 mut has_bid: impl FnMut(Price) -> bool,
450 mut has_ask: impl FnMut(Price) -> bool,
451 ) {
452 self.bids.retain(|price_ticks, _| has_bid(*price_ticks));
453 self.asks.retain(|price_ticks, _| has_ask(*price_ticks));
454 self.trim_depth();
455 }
456
457 /// Apply a WebSocket `book` update for this token.
458 ///
459 /// The official Polymarket CLOB WebSocket `book` event is a full snapshot.
460 /// Unlike `apply_delta_fast`, this method can apply many levels that share
461 /// the same message timestamp.
462 ///
463 /// Notes:
464 /// - This replaces the snapshot for both sides.
465 /// - Levels omitted from the message are removed.
466 /// - Insertions of *new* price levels may allocate (BTreeMap node growth).
467 pub fn apply_book_update(&mut self, update: &BookUpdate) -> Result<()> {
468 if update.asset_id != self.token_id {
469 return Err(PolyfillError::validation("Token ID mismatch"));
470 }
471
472 // Use the exchange-provided timestamp as our monotonic sequence marker.
473 // This is less strict than the REST/legacy delta sequence but works for
474 // ignoring obviously stale book snapshots.
475 if update.timestamp <= self.sequence {
476 return Ok(());
477 }
478
479 self.sequence = update.timestamp;
480 self.timestamp = chrono::DateTime::<Utc>::from_timestamp_millis(update.timestamp as i64)
481 .unwrap_or_else(Utc::now);
482
483 // Apply bids (BUY) and asks (SELL) as level upserts.
484 for level in &update.bids {
485 let price_ticks = decimal_to_price(level.price)
486 .map_err(|_| PolyfillError::validation("Invalid price"))?;
487 let size_units = decimal_to_qty(level.size)
488 .map_err(|_| PolyfillError::validation("Invalid size"))?;
489
490 if let Some(tick_size_ticks) = self.tick_size_ticks {
491 if tick_size_ticks > 0 && !price_ticks.is_multiple_of(tick_size_ticks) {
492 return Err(PolyfillError::validation("Price not aligned to tick size"));
493 }
494 }
495
496 if size_units == 0 {
497 self.bids.remove(&price_ticks);
498 } else {
499 self.bids.insert(price_ticks, size_units);
500 }
501 }
502
503 for level in &update.asks {
504 let price_ticks = decimal_to_price(level.price)
505 .map_err(|_| PolyfillError::validation("Invalid price"))?;
506 let size_units = decimal_to_qty(level.size)
507 .map_err(|_| PolyfillError::validation("Invalid size"))?;
508
509 if let Some(tick_size_ticks) = self.tick_size_ticks {
510 if tick_size_ticks > 0 && !price_ticks.is_multiple_of(tick_size_ticks) {
511 return Err(PolyfillError::validation("Price not aligned to tick size"));
512 }
513 }
514
515 if size_units == 0 {
516 self.asks.remove(&price_ticks);
517 } else {
518 self.asks.insert(price_ticks, size_units);
519 }
520 }
521
522 self.bids
523 .retain(|price_ticks, _| book_update_has_level(&update.bids, *price_ticks));
524 self.asks
525 .retain(|price_ticks, _| book_update_has_level(&update.asks, *price_ticks));
526 self.trim_depth();
527 Ok(())
528 }
529
530 /// Apply a bid-side delta (someone wants to buy) - LEGACY VERSION
531 /// If size is 0, it means "remove this price level entirely"
532 /// Otherwise, set the total size at this price level
533 ///
534 /// This converts to fixed-point and calls the fast version
535 #[allow(dead_code)]
536 fn apply_bid_delta(&mut self, price: Decimal, size: Decimal) {
537 // Convert to fixed-point (this should be rare since we use fast path)
538 let price_ticks = decimal_to_price(price).unwrap_or(0);
539 let size_units = decimal_to_qty(size).unwrap_or(0);
540 self.apply_bid_delta_fast(price_ticks, size_units);
541 }
542
543 /// Apply an ask-side delta (someone wants to sell) - LEGACY VERSION
544 /// Same logic as bids - size of 0 means remove the price level
545 ///
546 /// This converts to fixed-point and calls the fast version
547 #[allow(dead_code)]
548 fn apply_ask_delta(&mut self, price: Decimal, size: Decimal) {
549 // Convert to fixed-point (this should be rare since we use fast path)
550 let price_ticks = decimal_to_price(price).unwrap_or(0);
551 let size_units = decimal_to_qty(size).unwrap_or(0);
552 self.apply_ask_delta_fast(price_ticks, size_units);
553 }
554
555 /// Apply a bid-side delta (someone wants to buy) - FAST VERSION
556 ///
557 /// This is the high-performance version that works directly with fixed-point.
558 /// Much faster than the Decimal version - pure integer operations.
559 fn apply_bid_delta_fast(&mut self, price_ticks: Price, size_units: Qty) {
560 // BEFORE (slow, ~100ns + allocation):
561 // if size.is_zero() {
562 // self.bids.remove(&price);
563 // } else {
564 // self.bids.insert(price, size);
565 // }
566
567 // AFTER (fast, ~5ns, no allocation):
568 if size_units == 0 {
569 self.bids.remove(&price_ticks); // No more buyers at this price
570 } else {
571 self.bids.insert(price_ticks, size_units); // Update total size at this price
572 }
573 }
574
575 /// Apply an ask-side delta (someone wants to sell) - FAST VERSION
576 ///
577 /// This is the high-performance version that works directly with fixed-point.
578 /// Much faster than the Decimal version - pure integer operations.
579 fn apply_ask_delta_fast(&mut self, price_ticks: Price, size_units: Qty) {
580 // BEFORE (slow, ~100ns + allocation):
581 // if size.is_zero() {
582 // self.asks.remove(&price);
583 // } else {
584 // self.asks.insert(price, size);
585 // }
586
587 // AFTER (fast, ~5ns, no allocation):
588 if size_units == 0 {
589 self.asks.remove(&price_ticks); // No more sellers at this price
590 } else {
591 self.asks.insert(price_ticks, size_units); // Update total size at this price
592 }
593 }
594
595 /// Trim the book to maintain depth limits
596 /// We don't want to track every single price level - just the best ones
597 ///
598 /// Why limit depth? Several reasons:
599 /// 1. Memory efficiency: A popular market might have thousands of price levels,
600 /// but only the top 10-50 levels are actually tradeable with reasonable size
601 /// 2. Performance: Fewer levels = faster iteration when calculating market impact
602 /// 3. Relevance: Deep levels (like bids at $0.01 when best bid is $0.65) are
603 /// mostly noise and will never get hit in normal trading
604 /// 4. Stale data: Deep levels often contain old orders that haven't been cancelled
605 /// 5. Network bandwidth: Less data to send when streaming updates
606 fn trim_depth(&mut self) {
607 // For bids, remove the LOWEST prices (worst bids) if we have too many
608 // Example: If best bid is $0.65, we don't care about bids at $0.10
609 if self.bids.len() > self.max_depth {
610 let to_remove = self.bids.len() - self.max_depth;
611 for _ in 0..to_remove {
612 self.bids.pop_first(); // Remove lowest bid prices (furthest from market)
613 }
614 }
615
616 // For asks, remove the HIGHEST prices (worst asks) if we have too many
617 // Example: If best ask is $0.67, we don't care about asks at $0.95
618 if self.asks.len() > self.max_depth {
619 let to_remove = self.asks.len() - self.max_depth;
620 for _ in 0..to_remove {
621 self.asks.pop_last(); // Remove highest ask prices (furthest from market)
622 }
623 }
624 }
625
626 /// Calculate the market impact for a given order size
627 /// This is exactly why we don't need deep levels - if your order would require
628 /// hitting prices way off the current market (like $0.95 when best ask is $0.67),
629 /// you'd never actually place that order. You'd either:
630 /// 1. Break it into smaller pieces over time
631 /// 2. Use a different trading strategy
632 /// 3. Accept that there's not enough liquidity right now
633 pub fn calculate_market_impact(&self, side: Side, size: Decimal) -> Option<MarketImpact> {
634 // PERFORMANCE NOTE: This method still uses Decimal for external compatibility,
635 // but the internal order book lookups now use our fast fixed-point data structures.
636 //
637 // BEFORE: Each level lookup involved Decimal operations (~50ns each)
638 // AFTER: Level lookups use integer operations (~5ns each)
639 //
640 // For a 10-level impact calculation: 500ns → 50ns (10x speedup)
641
642 // Get the levels we'd be trading against
643 let levels = match side {
644 Side::BUY => self.asks(None), // If buying, we hit the ask side
645 Side::SELL => self.bids(None), // If selling, we hit the bid side
646 };
647
648 if levels.is_empty() {
649 return None; // No liquidity available
650 }
651
652 let mut remaining_size = size;
653 let mut total_cost = Decimal::ZERO;
654 let mut weighted_price = Decimal::ZERO;
655
656 // Walk through each price level, filling as much as we can
657 for level in levels {
658 let fill_size = std::cmp::min(remaining_size, level.size);
659 let level_cost = fill_size * level.price;
660
661 total_cost += level_cost;
662 weighted_price += level_cost; // This accumulates the weighted average
663 remaining_size -= fill_size;
664
665 if remaining_size.is_zero() {
666 break; // We've filled our entire order
667 }
668 }
669
670 if remaining_size > Decimal::ZERO {
671 // Not enough liquidity to fill the whole order
672 // This is a perfect example of why we don't need infinite depth:
673 // If we can't fill your order with the top N levels, you probably
674 // shouldn't be placing that order anyway - it would move the market too much
675 return None;
676 }
677
678 let avg_price = weighted_price / size;
679
680 // Calculate how much we moved the market compared to the best price
681 let impact = match side {
682 Side::BUY => {
683 let best_ask = self.best_ask()?.price;
684 (avg_price - best_ask) / best_ask // How much worse than best ask
685 },
686 Side::SELL => {
687 let best_bid = self.best_bid()?.price;
688 (best_bid - avg_price) / best_bid // How much worse than best bid
689 },
690 };
691
692 Some(MarketImpact {
693 average_price: avg_price,
694 impact_pct: impact,
695 total_cost,
696 size_filled: size,
697 })
698 }
699
700 /// Check if the book is stale (no recent updates)
701 /// Useful for detecting when we've lost connection to live data
702 pub fn is_stale(&self, max_age: std::time::Duration) -> bool {
703 let age = Utc::now() - self.timestamp;
704 age > chrono::Duration::from_std(max_age).unwrap_or_default()
705 }
706
707 /// Get the total liquidity at a given price level
708 /// Tells you how much you can buy/sell at exactly this price
709 pub fn liquidity_at_price(&self, price: Decimal, side: Side) -> Decimal {
710 // Convert decimal price to our internal fixed-point representation
711 let price_ticks = match decimal_to_price(price) {
712 Ok(ticks) => ticks,
713 Err(_) => return Decimal::ZERO, // Invalid price
714 };
715
716 match side {
717 Side::BUY => {
718 // How much we can buy at this price (look at asks)
719 let size_units = self.asks.get(&price_ticks).copied().unwrap_or_default();
720 qty_to_decimal(size_units)
721 },
722 Side::SELL => {
723 // How much we can sell at this price (look at bids)
724 let size_units = self.bids.get(&price_ticks).copied().unwrap_or_default();
725 qty_to_decimal(size_units)
726 },
727 }
728 }
729
730 /// Get the total liquidity within a price range
731 /// Useful for understanding how much depth exists in a certain price band
732 pub fn liquidity_in_range(
733 &self,
734 min_price: Decimal,
735 max_price: Decimal,
736 side: Side,
737 ) -> Decimal {
738 // Convert decimal prices to our internal fixed-point representation
739 let min_price_ticks = match decimal_to_price(min_price) {
740 Ok(ticks) => ticks,
741 Err(_) => return Decimal::ZERO, // Invalid price
742 };
743 let max_price_ticks = match decimal_to_price(max_price) {
744 Ok(ticks) => ticks,
745 Err(_) => return Decimal::ZERO, // Invalid price
746 };
747
748 let levels: Vec<_> = match side {
749 Side::BUY => self.asks.range(min_price_ticks..=max_price_ticks).collect(),
750 Side::SELL => self
751 .bids
752 .range(min_price_ticks..=max_price_ticks)
753 .rev()
754 .collect(),
755 };
756
757 // Sum up the sizes, converting from fixed-point back to Decimal
758 let total_size_units: i64 = levels.into_iter().map(|(_, &size)| size).sum();
759 qty_to_decimal(total_size_units)
760 }
761
762 /// Validate that prices are properly ordered
763 /// A healthy book should have best bid < best ask (otherwise there's an arbitrage opportunity)
764 pub fn is_valid(&self) -> bool {
765 match (self.best_bid(), self.best_ask()) {
766 (Some(bid), Some(ask)) => bid.price < ask.price, // Normal market condition
767 _ => true, // Empty book is technically valid
768 }
769 }
770}
771
772fn book_update_has_level(levels: &[OrderSummary], price_ticks: Price) -> bool {
773 levels.iter().any(|level| {
774 let Ok(level_price_ticks) = decimal_to_price(level.price) else {
775 return false;
776 };
777 let Ok(size_units) = decimal_to_qty(level.size) else {
778 return false;
779 };
780
781 size_units != 0 && level_price_ticks == price_ticks
782 })
783}
784
785/// Market impact calculation result
786/// This tells you what would happen if you executed a large order
787#[derive(Debug, Clone)]
788pub struct MarketImpact {
789 pub average_price: Decimal, // The average price you'd get across all fills
790 pub impact_pct: Decimal, // How much worse than the best price (as percentage)
791 pub total_cost: Decimal, // Total amount you'd pay/receive
792 pub size_filled: Decimal, // How much of your order got filled
793}
794
795/// Thread-safe order book manager
796/// This manages multiple order books (one per token) and handles concurrent access
797/// Multiple threads can read/write different books simultaneously
798///
799/// The depth limiting becomes even more critical here because we might be tracking
800/// hundreds or thousands of different tokens simultaneously. If each book had
801/// unlimited depth, we could easily use gigabytes of RAM for mostly useless data.
802///
803/// Example: 1000 tokens × 1000 price levels × 32 bytes per level = 32MB just for prices
804/// With depth limiting: 1000 tokens × 50 levels × 32 bytes = 1.6MB (20x less memory)
805#[derive(Debug)]
806pub struct OrderBookManager {
807 books: Arc<RwLock<std::collections::HashMap<String, OrderBook>>>, // Token ID -> OrderBook
808 max_depth: usize,
809}
810
811impl OrderBookManager {
812 /// Create a new order book manager
813 /// Starts with an empty collection of books
814 pub fn new(max_depth: usize) -> Self {
815 Self {
816 books: Arc::new(RwLock::new(std::collections::HashMap::new())),
817 max_depth,
818 }
819 }
820
821 /// Get or create an order book for a token
822 /// If we don't have a book for this token yet, create a new empty one
823 pub fn get_or_create_book(&self, token_id: &str) -> Result<OrderBook> {
824 let mut books = self
825 .books
826 .write()
827 .map_err(|_| PolyfillError::internal_simple("Failed to acquire book lock"))?;
828
829 if let Some(book) = books.get(token_id) {
830 Ok(book.clone()) // Return a copy of the existing book
831 } else {
832 // Create a new book for this token
833 let book = OrderBook::new(token_id.to_string(), self.max_depth);
834 books.insert(token_id.to_string(), book.clone());
835 Ok(book)
836 }
837 }
838
839 /// Execute a closure with mutable access to a managed book.
840 ///
841 /// This is useful for hot-path update ingestion where you want to avoid allocating
842 /// intermediate update structs (e.g., applying WS updates directly).
843 pub fn with_book_mut<R>(
844 &self,
845 token_id: &str,
846 f: impl FnOnce(&mut OrderBook) -> Result<R>,
847 ) -> Result<R> {
848 let mut books = self
849 .books
850 .write()
851 .map_err(|_| PolyfillError::internal_simple("Failed to acquire book lock"))?;
852
853 let book = books.get_mut(token_id).ok_or_else(|| {
854 PolyfillError::market_data(
855 format!("No book found for token: {}", token_id),
856 crate::errors::MarketDataErrorKind::TokenNotFound,
857 )
858 })?;
859
860 f(book)
861 }
862
863 /// Update a book with a delta
864 /// This is called when we receive real-time updates from the exchange
865 pub fn apply_delta(&self, delta: OrderDelta) -> Result<()> {
866 let mut books = self
867 .books
868 .write()
869 .map_err(|_| PolyfillError::internal_simple("Failed to acquire book lock"))?;
870
871 // Find the book for this token (must already exist)
872 let book = books.get_mut(&delta.token_id).ok_or_else(|| {
873 PolyfillError::market_data(
874 format!("No book found for token: {}", delta.token_id),
875 crate::errors::MarketDataErrorKind::TokenNotFound,
876 )
877 })?;
878
879 // Apply the update to the specific book
880 book.apply_delta(delta)
881 }
882
883 /// Apply a WebSocket `book` update to a managed book.
884 ///
885 /// This is the preferred way to ingest `StreamMessage::Book` updates into
886 /// the in-memory order books (avoids rebuilding snapshots via per-level deltas).
887 pub fn apply_book_update(&self, update: &BookUpdate) -> Result<()> {
888 let mut books = self
889 .books
890 .write()
891 .map_err(|_| PolyfillError::internal_simple("Failed to acquire book lock"))?;
892
893 if let Some(book) = books.get_mut(update.asset_id.as_str()) {
894 return book.apply_book_update(update);
895 }
896
897 // First time we've seen this token; allocating the key and book is part of warmup.
898 let token_id = update.asset_id.clone();
899 books.insert(token_id.clone(), OrderBook::new(token_id, self.max_depth));
900
901 books
902 .get_mut(update.asset_id.as_str())
903 .ok_or_else(|| PolyfillError::internal_simple("Failed to insert order book"))?
904 .apply_book_update(update)
905 }
906
907 /// Get a book snapshot
908 /// Returns a copy of the current book state that won't change
909 pub fn get_book(&self, token_id: &str) -> Result<crate::types::OrderBook> {
910 let books = self
911 .books
912 .read()
913 .map_err(|_| PolyfillError::internal_simple("Failed to acquire book lock"))?;
914
915 books
916 .get(token_id)
917 .map(|book| book.snapshot()) // Create a snapshot copy
918 .ok_or_else(|| {
919 PolyfillError::market_data(
920 format!("No book found for token: {}", token_id),
921 crate::errors::MarketDataErrorKind::TokenNotFound,
922 )
923 })
924 }
925
926 /// Get all available books
927 /// Returns snapshots of every book we're currently tracking
928 pub fn get_all_books(&self) -> Result<Vec<crate::types::OrderBook>> {
929 let books = self
930 .books
931 .read()
932 .map_err(|_| PolyfillError::internal_simple("Failed to acquire book lock"))?;
933
934 Ok(books.values().map(|book| book.snapshot()).collect())
935 }
936
937 /// Remove stale books
938 /// Cleans up books that haven't been updated recently (probably disconnected)
939 /// This prevents memory leaks from accumulating dead books
940 pub fn cleanup_stale_books(&self, max_age: std::time::Duration) -> Result<usize> {
941 let mut books = self
942 .books
943 .write()
944 .map_err(|_| PolyfillError::internal_simple("Failed to acquire book lock"))?;
945
946 let initial_count = books.len();
947 books.retain(|_, book| !book.is_stale(max_age)); // Keep only non-stale books
948 let removed = initial_count - books.len();
949
950 if removed > 0 {
951 debug!("Removed {} stale order books", removed);
952 }
953
954 Ok(removed)
955 }
956}
957
958/// Order book analytics and statistics
959/// Provides a summary view of the book's health and characteristics
960#[derive(Debug, Clone)]
961pub struct BookAnalytics {
962 pub token_id: String,
963 pub timestamp: chrono::DateTime<Utc>,
964 pub bid_count: usize, // How many different bid price levels
965 pub ask_count: usize, // How many different ask price levels
966 pub total_bid_size: Decimal, // Total size of all bids combined
967 pub total_ask_size: Decimal, // Total size of all asks combined
968 pub spread: Option<Decimal>, // Current spread (ask - bid)
969 pub spread_pct: Option<Decimal>, // Spread as percentage
970 pub mid_price: Option<Decimal>, // Current mid price
971 pub volatility: Option<Decimal>, // Price volatility (if calculated)
972}
973
974impl OrderBook {
975 /// Calculate analytics for this book
976 /// Gives you a quick health check of the market
977 pub fn analytics(&self) -> BookAnalytics {
978 let bid_count = self.bids.len();
979 let ask_count = self.asks.len();
980 // Sum up all bid/ask sizes, converting from fixed-point back to Decimal
981 let total_bid_size_units: i64 = self.bids.values().sum();
982 let total_ask_size_units: i64 = self.asks.values().sum();
983 let total_bid_size = qty_to_decimal(total_bid_size_units);
984 let total_ask_size = qty_to_decimal(total_ask_size_units);
985
986 BookAnalytics {
987 token_id: self.token_id.clone(),
988 timestamp: self.timestamp,
989 bid_count,
990 ask_count,
991 total_bid_size,
992 total_ask_size,
993 spread: self.spread(),
994 spread_pct: self.spread_pct(),
995 mid_price: self.mid_price(),
996 volatility: self.calculate_volatility(),
997 }
998 }
999
1000 /// Calculate price volatility (simplified)
1001 /// This is a placeholder - real volatility needs historical price data
1002 fn calculate_volatility(&self) -> Option<Decimal> {
1003 // This is a simplified volatility calculation
1004 // In a real implementation, you'd want to track price history over time
1005 // and calculate standard deviation of price changes
1006 None
1007 }
1008}
1009
1010#[cfg(test)]
1011mod tests {
1012 use super::*;
1013 use rust_decimal_macros::dec;
1014 use std::str::FromStr;
1015 use std::time::Duration; // Convenient macro for creating Decimal literals
1016
1017 #[test]
1018 fn test_order_book_creation() {
1019 // Test that we can create a new empty order book
1020 let book = OrderBook::new("test_token".to_string(), 10);
1021 assert_eq!(book.token_id, "test_token");
1022 assert_eq!(book.bids.len(), 0); // Should start empty
1023 assert_eq!(book.asks.len(), 0); // Should start empty
1024 }
1025
1026 #[test]
1027 fn test_apply_delta() {
1028 // Test that we can apply order book updates
1029 let mut book = OrderBook::new("test_token".to_string(), 10);
1030
1031 // Create a buy order at $0.50 for 100 tokens
1032 let delta = OrderDelta {
1033 token_id: "test_token".to_string(),
1034 timestamp: Utc::now(),
1035 side: Side::BUY,
1036 price: dec!(0.5),
1037 size: dec!(100),
1038 sequence: 1,
1039 };
1040
1041 book.apply_delta(delta).unwrap();
1042 assert_eq!(book.sequence, 1); // Sequence should update
1043 assert_eq!(book.best_bid().unwrap().price, dec!(0.5)); // Should be our bid
1044 assert_eq!(book.best_bid().unwrap().size, dec!(100)); // Should be our size
1045 }
1046
1047 #[test]
1048 fn test_book_update_replaces_snapshot_and_uses_millis_timestamp() {
1049 let mut book = OrderBook::new("test_token".to_string(), 10);
1050 let timestamp = 1_757_908_892_351;
1051
1052 book.apply_book_update(&BookUpdate {
1053 asset_id: "test_token".to_string(),
1054 market: "0xabc".to_string(),
1055 timestamp,
1056 bids: vec![
1057 OrderSummary {
1058 price: dec!(0.48),
1059 size: dec!(10),
1060 },
1061 OrderSummary {
1062 price: dec!(0.49),
1063 size: dec!(20),
1064 },
1065 ],
1066 asks: vec![
1067 OrderSummary {
1068 price: dec!(0.52),
1069 size: dec!(30),
1070 },
1071 OrderSummary {
1072 price: dec!(0.53),
1073 size: dec!(40),
1074 },
1075 ],
1076 hash: None,
1077 })
1078 .unwrap();
1079
1080 book.apply_book_update(&BookUpdate {
1081 asset_id: "test_token".to_string(),
1082 market: "0xabc".to_string(),
1083 timestamp: timestamp + 1,
1084 bids: vec![OrderSummary {
1085 price: dec!(0.49),
1086 size: dec!(25),
1087 }],
1088 asks: vec![OrderSummary {
1089 price: dec!(0.53),
1090 size: dec!(45),
1091 }],
1092 hash: None,
1093 })
1094 .unwrap();
1095
1096 assert_eq!(book.timestamp.timestamp_millis(), timestamp as i64 + 1);
1097 assert_eq!(book.bids(None).len(), 1);
1098 assert_eq!(book.asks(None).len(), 1);
1099 assert_eq!(book.best_bid().unwrap().price, dec!(0.49));
1100 assert_eq!(book.best_bid().unwrap().size, dec!(25));
1101 assert_eq!(book.best_ask().unwrap().price, dec!(0.53));
1102 assert_eq!(book.best_ask().unwrap().size, dec!(45));
1103 }
1104
1105 #[test]
1106 fn test_book_update_depth_keeps_best_prices_independent_of_payload_order() {
1107 let mut book = OrderBook::new("test_token".to_string(), 2);
1108
1109 book.apply_book_update(&BookUpdate {
1110 asset_id: "test_token".to_string(),
1111 market: "0xabc".to_string(),
1112 timestamp: 1_757_908_892_351,
1113 bids: vec![
1114 OrderSummary {
1115 price: dec!(0.49),
1116 size: dec!(10),
1117 },
1118 OrderSummary {
1119 price: dec!(0.50),
1120 size: dec!(20),
1121 },
1122 OrderSummary {
1123 price: dec!(0.48),
1124 size: dec!(30),
1125 },
1126 ],
1127 asks: vec![
1128 OrderSummary {
1129 price: dec!(0.52),
1130 size: dec!(40),
1131 },
1132 OrderSummary {
1133 price: dec!(0.54),
1134 size: dec!(50),
1135 },
1136 OrderSummary {
1137 price: dec!(0.53),
1138 size: dec!(60),
1139 },
1140 ],
1141 hash: None,
1142 })
1143 .unwrap();
1144
1145 let bids = book.bids(None);
1146 assert_eq!(bids.len(), 2);
1147 assert_eq!(bids[0].price, dec!(0.50));
1148 assert_eq!(bids[1].price, dec!(0.49));
1149
1150 let asks = book.asks(None);
1151 assert_eq!(asks.len(), 2);
1152 assert_eq!(asks[0].price, dec!(0.52));
1153 assert_eq!(asks[1].price, dec!(0.53));
1154 }
1155
1156 #[test]
1157 fn test_spread_calculation() {
1158 // Test that we can calculate the spread between bid and ask
1159 let mut book = OrderBook::new("test_token".to_string(), 10);
1160
1161 // Add a bid at $0.50
1162 book.apply_delta(OrderDelta {
1163 token_id: "test_token".to_string(),
1164 timestamp: Utc::now(),
1165 side: Side::BUY,
1166 price: dec!(0.5),
1167 size: dec!(100),
1168 sequence: 1,
1169 })
1170 .unwrap();
1171
1172 // Add an ask at $0.52
1173 book.apply_delta(OrderDelta {
1174 token_id: "test_token".to_string(),
1175 timestamp: Utc::now(),
1176 side: Side::SELL,
1177 price: dec!(0.52),
1178 size: dec!(100),
1179 sequence: 2,
1180 })
1181 .unwrap();
1182
1183 let spread = book.spread().unwrap();
1184 assert_eq!(spread, dec!(0.02)); // $0.52 - $0.50 = $0.02
1185 }
1186
1187 #[test]
1188 fn test_market_impact() {
1189 // Test market impact calculation for a large order
1190 let mut book = OrderBook::new("test_token".to_string(), 10);
1191
1192 // Add multiple ask levels (people selling at different prices)
1193 // $0.50 for 100 tokens, $0.51 for 100 tokens, $0.52 for 100 tokens
1194 for (i, price) in [dec!(0.50), dec!(0.51), dec!(0.52)].iter().enumerate() {
1195 book.apply_delta(OrderDelta {
1196 token_id: "test_token".to_string(),
1197 timestamp: Utc::now(),
1198 side: Side::SELL,
1199 price: *price,
1200 size: dec!(100),
1201 sequence: i as u64 + 1,
1202 })
1203 .unwrap();
1204 }
1205
1206 // Try to buy 150 tokens (will need to hit multiple price levels)
1207 let impact = book.calculate_market_impact(Side::BUY, dec!(150)).unwrap();
1208 assert!(impact.average_price > dec!(0.50)); // Should be worse than best price
1209 assert!(impact.average_price < dec!(0.51)); // But not as bad as second level
1210 }
1211
1212 #[test]
1213 fn test_apply_bid_delta_legacy() {
1214 let mut book = OrderBook::new("test_token".to_string(), 10);
1215
1216 // Test adding a bid
1217 book.apply_bid_delta(
1218 Decimal::from_str("0.75").unwrap(),
1219 Decimal::from_str("100.0").unwrap(),
1220 );
1221
1222 let best_bid = book.best_bid();
1223 assert!(best_bid.is_some());
1224 let bid = best_bid.unwrap();
1225 assert_eq!(bid.price, Decimal::from_str("0.75").unwrap());
1226 assert_eq!(bid.size, Decimal::from_str("100.0").unwrap());
1227
1228 // Test updating the bid
1229 book.apply_bid_delta(
1230 Decimal::from_str("0.75").unwrap(),
1231 Decimal::from_str("150.0").unwrap(),
1232 );
1233 let updated_bid = book.best_bid().unwrap();
1234 assert_eq!(updated_bid.size, Decimal::from_str("150.0").unwrap());
1235
1236 // Test removing the bid
1237 book.apply_bid_delta(Decimal::from_str("0.75").unwrap(), Decimal::ZERO);
1238 assert!(book.best_bid().is_none());
1239 }
1240
1241 #[test]
1242 fn test_apply_ask_delta_legacy() {
1243 let mut book = OrderBook::new("test_token".to_string(), 10);
1244
1245 // Test adding an ask
1246 book.apply_ask_delta(
1247 Decimal::from_str("0.76").unwrap(),
1248 Decimal::from_str("50.0").unwrap(),
1249 );
1250
1251 let best_ask = book.best_ask();
1252 assert!(best_ask.is_some());
1253 let ask = best_ask.unwrap();
1254 assert_eq!(ask.price, Decimal::from_str("0.76").unwrap());
1255 assert_eq!(ask.size, Decimal::from_str("50.0").unwrap());
1256
1257 // Test updating the ask
1258 book.apply_ask_delta(
1259 Decimal::from_str("0.76").unwrap(),
1260 Decimal::from_str("75.0").unwrap(),
1261 );
1262 let updated_ask = book.best_ask().unwrap();
1263 assert_eq!(updated_ask.size, Decimal::from_str("75.0").unwrap());
1264
1265 // Test removing the ask
1266 book.apply_ask_delta(Decimal::from_str("0.76").unwrap(), Decimal::ZERO);
1267 assert!(book.best_ask().is_none());
1268 }
1269
1270 #[test]
1271 fn test_liquidity_analysis() {
1272 let mut book = OrderBook::new("test_token".to_string(), 10);
1273
1274 // Build order book using legacy methods
1275 book.apply_bid_delta(
1276 Decimal::from_str("0.75").unwrap(),
1277 Decimal::from_str("100.0").unwrap(),
1278 );
1279 book.apply_bid_delta(
1280 Decimal::from_str("0.74").unwrap(),
1281 Decimal::from_str("50.0").unwrap(),
1282 );
1283 book.apply_ask_delta(
1284 Decimal::from_str("0.76").unwrap(),
1285 Decimal::from_str("80.0").unwrap(),
1286 );
1287 book.apply_ask_delta(
1288 Decimal::from_str("0.77").unwrap(),
1289 Decimal::from_str("120.0").unwrap(),
1290 );
1291
1292 // Test liquidity at specific price - when buying, we look at ask liquidity
1293 let buy_liquidity = book.liquidity_at_price(Decimal::from_str("0.76").unwrap(), Side::BUY);
1294 assert_eq!(buy_liquidity, Decimal::from_str("80.0").unwrap());
1295
1296 // Test liquidity at specific price - when selling, we look at bid liquidity
1297 let sell_liquidity =
1298 book.liquidity_at_price(Decimal::from_str("0.75").unwrap(), Side::SELL);
1299 assert_eq!(sell_liquidity, Decimal::from_str("100.0").unwrap());
1300
1301 // Test liquidity in range - when buying, we look at ask liquidity in range
1302 let buy_range_liquidity = book.liquidity_in_range(
1303 Decimal::from_str("0.74").unwrap(),
1304 Decimal::from_str("0.77").unwrap(),
1305 Side::BUY,
1306 );
1307 // Should include ask liquidity: 80 (0.76 ask) + 120 (0.77 ask) = 200
1308 assert_eq!(buy_range_liquidity, Decimal::from_str("200.0").unwrap());
1309
1310 // Test liquidity in range - when selling, we look at bid liquidity in range
1311 let sell_range_liquidity = book.liquidity_in_range(
1312 Decimal::from_str("0.74").unwrap(),
1313 Decimal::from_str("0.77").unwrap(),
1314 Side::SELL,
1315 );
1316 // Should include bid liquidity: 50 (0.74 bid) + 100 (0.75 bid) = 150
1317 assert_eq!(sell_range_liquidity, Decimal::from_str("150.0").unwrap());
1318 }
1319
1320 #[test]
1321 fn test_book_validation() {
1322 let mut book = OrderBook::new("test_token".to_string(), 10);
1323
1324 // Empty book should be valid
1325 assert!(book.is_valid());
1326
1327 // Add normal levels
1328 book.apply_bid_delta(
1329 Decimal::from_str("0.75").unwrap(),
1330 Decimal::from_str("100.0").unwrap(),
1331 );
1332 book.apply_ask_delta(
1333 Decimal::from_str("0.76").unwrap(),
1334 Decimal::from_str("80.0").unwrap(),
1335 );
1336 assert!(book.is_valid());
1337
1338 // Create crossed book (invalid) - bid higher than ask
1339 book.apply_bid_delta(
1340 Decimal::from_str("0.77").unwrap(),
1341 Decimal::from_str("50.0").unwrap(),
1342 );
1343 assert!(!book.is_valid());
1344 }
1345
1346 #[test]
1347 fn test_book_staleness() {
1348 let mut book = OrderBook::new("test_token".to_string(), 10);
1349
1350 // Fresh book should not be stale
1351 assert!(!book.is_stale(Duration::from_secs(60))); // 60 second threshold
1352
1353 // Add some data
1354 book.apply_bid_delta(
1355 Decimal::from_str("0.75").unwrap(),
1356 Decimal::from_str("100.0").unwrap(),
1357 );
1358 assert!(!book.is_stale(Duration::from_secs(60)));
1359
1360 // Note: We can't easily test actual staleness without manipulating time,
1361 // but we can test the method exists and works with fresh data
1362 }
1363
1364 #[test]
1365 fn test_depth_management() {
1366 let mut book = OrderBook::new("test_token".to_string(), 3); // Only 3 levels
1367
1368 // Add multiple levels
1369 book.apply_bid_delta(
1370 Decimal::from_str("0.75").unwrap(),
1371 Decimal::from_str("100.0").unwrap(),
1372 );
1373 book.apply_bid_delta(
1374 Decimal::from_str("0.74").unwrap(),
1375 Decimal::from_str("50.0").unwrap(),
1376 );
1377 book.apply_bid_delta(
1378 Decimal::from_str("0.73").unwrap(),
1379 Decimal::from_str("20.0").unwrap(),
1380 );
1381
1382 book.apply_ask_delta(
1383 Decimal::from_str("0.76").unwrap(),
1384 Decimal::from_str("80.0").unwrap(),
1385 );
1386 book.apply_ask_delta(
1387 Decimal::from_str("0.77").unwrap(),
1388 Decimal::from_str("40.0").unwrap(),
1389 );
1390 book.apply_ask_delta(
1391 Decimal::from_str("0.78").unwrap(),
1392 Decimal::from_str("30.0").unwrap(),
1393 );
1394
1395 // Should have levels on each side
1396 let bids = book.bids(Some(3));
1397 let asks = book.asks(Some(3));
1398
1399 assert!(bids.len() <= 3);
1400 assert!(asks.len() <= 3);
1401
1402 // Best levels should be there
1403 assert_eq!(
1404 book.best_bid().unwrap().price,
1405 Decimal::from_str("0.75").unwrap()
1406 );
1407 assert_eq!(
1408 book.best_ask().unwrap().price,
1409 Decimal::from_str("0.76").unwrap()
1410 );
1411 }
1412
1413 #[test]
1414 fn test_fast_operations() {
1415 let mut book = OrderBook::new("test_token".to_string(), 10);
1416
1417 // Test using legacy methods which call fast operations internally
1418 book.apply_bid_delta(
1419 Decimal::from_str("0.75").unwrap(),
1420 Decimal::from_str("100.0").unwrap(),
1421 );
1422 book.apply_ask_delta(
1423 Decimal::from_str("0.76").unwrap(),
1424 Decimal::from_str("80.0").unwrap(),
1425 );
1426
1427 let best_bid_fast = book.best_bid_fast();
1428 let best_ask_fast = book.best_ask_fast();
1429
1430 assert!(best_bid_fast.is_some());
1431 assert!(best_ask_fast.is_some());
1432
1433 // Test fast spread and mid price
1434 let spread_fast = book.spread_fast();
1435 let mid_fast = book.mid_price_fast();
1436
1437 assert!(spread_fast.is_some()); // Should have a spread
1438 assert!(mid_fast.is_some()); // Should have a mid price
1439 }
1440}