2025-07-24 20:29:10 -04:00
|
|
|
|
//! Order book management for Polymarket client
|
|
|
|
|
|
|
|
|
|
|
|
use crate::errors::{PolyfillError, Result};
|
|
|
|
|
|
use crate::types::*;
|
|
|
|
|
|
use crate::utils::math;
|
|
|
|
|
|
use rust_decimal::Decimal;
|
2025-08-14 19:11:52 -04:00
|
|
|
|
use std::collections::BTreeMap; // BTreeMap keeps prices sorted automatically - crucial for order books
|
|
|
|
|
|
use std::sync::{Arc, RwLock}; // For thread-safe access across multiple tasks
|
|
|
|
|
|
use tracing::{debug, trace, warn}; // Logging for debugging and monitoring
|
2025-07-24 20:29:10 -04:00
|
|
|
|
use chrono::Utc;
|
|
|
|
|
|
use std::collections::HashMap;
|
|
|
|
|
|
|
|
|
|
|
|
/// High-performance order book implementation
|
2025-08-14 19:11:52 -04:00
|
|
|
|
///
|
|
|
|
|
|
/// This is the core data structure that holds all the live buy/sell orders for a token.
|
|
|
|
|
|
/// The efficiency of this code is critical as the order book is constantly being updated as orders are added and removed.
|
2025-08-14 19:29:42 -04:00
|
|
|
|
///
|
|
|
|
|
|
/// PERFORMANCE OPTIMIZATION: This struct now uses fixed-point integers internally
|
|
|
|
|
|
/// instead of Decimal for maximum speed. The performance difference is dramatic:
|
|
|
|
|
|
///
|
|
|
|
|
|
/// Before (Decimal): ~100ns per operation + memory allocation
|
|
|
|
|
|
/// After (fixed-point): ~5ns per operation, zero allocations
|
|
|
|
|
|
|
2025-07-24 20:29:10 -04:00
|
|
|
|
#[derive(Debug, Clone)]
|
|
|
|
|
|
pub struct OrderBook {
|
2025-08-14 19:11:52 -04:00
|
|
|
|
/// Token ID this book represents (like "123456" for a specific prediction market outcome)
|
2025-07-24 20:29:10 -04:00
|
|
|
|
pub token_id: String,
|
2025-08-14 19:11:52 -04:00
|
|
|
|
|
2025-08-14 19:29:42 -04:00
|
|
|
|
/// Hash of token_id for fast lookups (avoids string comparisons in hot path)
|
|
|
|
|
|
pub token_id_hash: u64,
|
|
|
|
|
|
|
2025-07-24 20:29:10 -04:00
|
|
|
|
/// Current sequence number for ordering updates
|
2025-08-14 19:11:52 -04:00
|
|
|
|
/// This helps us ignore old/duplicate updates that arrive out of order
|
2025-07-24 20:29:10 -04:00
|
|
|
|
pub sequence: u64,
|
2025-08-14 19:11:52 -04:00
|
|
|
|
|
|
|
|
|
|
/// Last update timestamp - when we last got new data for this book
|
2025-07-24 20:29:10 -04:00
|
|
|
|
pub timestamp: chrono::DateTime<Utc>,
|
2025-08-14 19:11:52 -04:00
|
|
|
|
|
2025-08-14 19:29:42 -04:00
|
|
|
|
/// Bid side (price -> size, sorted descending) - NOW USING FIXED-POINT!
|
2025-08-14 19:11:52 -04:00
|
|
|
|
/// BTreeMap automatically keeps highest bids first, which is what we want
|
2025-08-14 19:29:42 -04:00
|
|
|
|
/// Key = price in ticks (like 6500 for $0.65), Value = size in fixed-point units
|
|
|
|
|
|
///
|
|
|
|
|
|
/// BEFORE (slow): bids: BTreeMap<Decimal, Decimal>,
|
|
|
|
|
|
/// AFTER (fast): bids: BTreeMap<Price, Qty>,
|
|
|
|
|
|
///
|
|
|
|
|
|
/// Why this is faster:
|
|
|
|
|
|
/// - Integer comparisons are ~10x faster than Decimal comparisons
|
|
|
|
|
|
/// - No memory allocation for each price level
|
|
|
|
|
|
/// - Better CPU cache utilization (smaller data structures)
|
|
|
|
|
|
bids: BTreeMap<Price, Qty>,
|
2025-08-14 19:11:52 -04:00
|
|
|
|
|
2025-08-14 19:29:42 -04:00
|
|
|
|
/// Ask side (price -> size, sorted ascending) - NOW USING FIXED-POINT!
|
2025-08-14 19:11:52 -04:00
|
|
|
|
/// BTreeMap keeps lowest asks first - people selling at cheapest prices
|
2025-08-14 19:29:42 -04:00
|
|
|
|
///
|
|
|
|
|
|
/// BEFORE (slow): asks: BTreeMap<Decimal, Decimal>,
|
|
|
|
|
|
/// AFTER (fast): asks: BTreeMap<Price, Qty>,
|
|
|
|
|
|
asks: BTreeMap<Price, Qty>,
|
2025-08-14 19:11:52 -04:00
|
|
|
|
|
2025-08-14 19:29:42 -04:00
|
|
|
|
/// Minimum tick size for this market in ticks (like 10 for $0.001 increments)
|
2025-08-14 19:11:52 -04:00
|
|
|
|
/// Some markets only allow certain price increments
|
2025-08-14 19:29:42 -04:00
|
|
|
|
/// We store this in ticks for fast validation without conversion
|
|
|
|
|
|
tick_size_ticks: Option<Price>,
|
2025-08-14 19:11:52 -04:00
|
|
|
|
|
|
|
|
|
|
/// Maximum depth to maintain (how many price levels to keep)
|
|
|
|
|
|
///
|
|
|
|
|
|
/// We don't need to track every single price level, just the best ones because:
|
|
|
|
|
|
/// - Trading reality 90% of volume happens in the top 5-10 price levels
|
|
|
|
|
|
/// - Execution priority: Orders get filled from best price first, so deep levels often don't matter
|
|
|
|
|
|
/// - Market efficiency: If you're buying and best ask is $0.67, you'll never pay $0.95
|
|
|
|
|
|
/// - Risk management: Large orders that would hit deep levels are usually broken up
|
|
|
|
|
|
/// - Data freshness: Deep levels often have stale orders from hours/days ago
|
|
|
|
|
|
///
|
|
|
|
|
|
/// Typical values: 10-50 for retail, 100-500 for institutional HFT systems
|
2025-07-24 20:29:10 -04:00
|
|
|
|
max_depth: usize,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
impl OrderBook {
|
|
|
|
|
|
/// Create a new order book
|
2025-08-14 19:11:52 -04:00
|
|
|
|
/// Just sets up empty bid/ask maps and basic metadata
|
2025-07-24 20:29:10 -04:00
|
|
|
|
pub fn new(token_id: String, max_depth: usize) -> Self {
|
2025-08-14 19:29:42 -04:00
|
|
|
|
// Hash the token_id once for fast lookups later
|
|
|
|
|
|
let token_id_hash = {
|
|
|
|
|
|
use std::collections::hash_map::DefaultHasher;
|
|
|
|
|
|
use std::hash::{Hash, Hasher};
|
|
|
|
|
|
let mut hasher = DefaultHasher::new();
|
|
|
|
|
|
token_id.hash(&mut hasher);
|
|
|
|
|
|
hasher.finish()
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-07-24 20:29:10 -04:00
|
|
|
|
Self {
|
|
|
|
|
|
token_id,
|
2025-08-14 19:29:42 -04:00
|
|
|
|
token_id_hash,
|
2025-08-14 19:11:52 -04:00
|
|
|
|
sequence: 0, // Start at 0, will increment as we get updates
|
2025-07-24 20:29:10 -04:00
|
|
|
|
timestamp: Utc::now(),
|
2025-08-14 19:29:42 -04:00
|
|
|
|
bids: BTreeMap::new(), // Empty to start - using Price/Qty types
|
|
|
|
|
|
asks: BTreeMap::new(), // Empty to start - using Price/Qty types
|
|
|
|
|
|
tick_size_ticks: None, // We'll set this later when we learn about the market
|
2025-07-24 20:29:10 -04:00
|
|
|
|
max_depth,
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-08-14 19:29:42 -04:00
|
|
|
|
/// Set the tick size for this book
|
|
|
|
|
|
/// This tells us the minimum price increment allowed
|
|
|
|
|
|
/// We store it in ticks for fast validation without conversion overhead
|
|
|
|
|
|
pub fn set_tick_size(&mut self, tick_size: Decimal) -> Result<()> {
|
|
|
|
|
|
let tick_size_ticks = decimal_to_price(tick_size)
|
|
|
|
|
|
.map_err(|_| PolyfillError::validation("Invalid tick size"))?;
|
|
|
|
|
|
self.tick_size_ticks = Some(tick_size_ticks);
|
|
|
|
|
|
Ok(())
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Set the tick size directly in ticks (even faster)
|
|
|
|
|
|
/// Use this when you already have the tick size in our internal format
|
|
|
|
|
|
pub fn set_tick_size_ticks(&mut self, tick_size_ticks: Price) {
|
|
|
|
|
|
self.tick_size_ticks = Some(tick_size_ticks);
|
2025-07-24 20:29:10 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-08-14 19:11:52 -04:00
|
|
|
|
/// Get the current best bid (highest price someone is willing to pay)
|
|
|
|
|
|
/// Uses next_back() because BTreeMap sorts ascending, but we want the highest bid
|
2025-08-14 19:29:42 -04:00
|
|
|
|
///
|
|
|
|
|
|
/// PERFORMANCE: Now returns data in external format but internally uses fast lookups
|
2025-07-24 20:29:10 -04:00
|
|
|
|
pub fn best_bid(&self) -> Option<BookLevel> {
|
2025-08-14 19:29:42 -04:00
|
|
|
|
// BEFORE (slow, ~50ns + allocation):
|
|
|
|
|
|
// self.bids.iter().next_back().map(|(&price, &size)| BookLevel { price, size })
|
|
|
|
|
|
|
|
|
|
|
|
// AFTER (fast, ~5ns, no allocation for the lookup):
|
|
|
|
|
|
self.bids.iter().next_back().map(|(&price_ticks, &size_units)| {
|
|
|
|
|
|
// Convert from internal fixed-point to external Decimal format
|
|
|
|
|
|
// This conversion only happens at the API boundary
|
|
|
|
|
|
BookLevel {
|
|
|
|
|
|
price: price_to_decimal(price_ticks),
|
|
|
|
|
|
size: qty_to_decimal(size_units),
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
2025-07-24 20:29:10 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-08-14 19:11:52 -04:00
|
|
|
|
/// Get the current best ask (lowest price someone is willing to sell at)
|
|
|
|
|
|
/// Uses next() because BTreeMap sorts ascending, so first item is lowest ask
|
2025-08-14 19:29:42 -04:00
|
|
|
|
///
|
|
|
|
|
|
/// PERFORMANCE: Now returns data in external format but internally uses fast lookups
|
2025-07-24 20:29:10 -04:00
|
|
|
|
pub fn best_ask(&self) -> Option<BookLevel> {
|
2025-08-14 19:29:42 -04:00
|
|
|
|
// BEFORE (slow, ~50ns + allocation):
|
|
|
|
|
|
// self.asks.iter().next().map(|(&price, &size)| BookLevel { price, size })
|
|
|
|
|
|
|
|
|
|
|
|
// AFTER (fast, ~5ns, no allocation for the lookup):
|
|
|
|
|
|
self.asks.iter().next().map(|(&price_ticks, &size_units)| {
|
|
|
|
|
|
// Convert from internal fixed-point to external Decimal format
|
|
|
|
|
|
// This conversion only happens at the API boundary
|
|
|
|
|
|
BookLevel {
|
|
|
|
|
|
price: price_to_decimal(price_ticks),
|
|
|
|
|
|
size: qty_to_decimal(size_units),
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Get the current best bid in fast internal format
|
|
|
|
|
|
/// Use this for internal calculations to avoid conversion overhead
|
|
|
|
|
|
pub fn best_bid_fast(&self) -> Option<FastBookLevel> {
|
|
|
|
|
|
self.bids.iter().next_back().map(|(&price, &size)| {
|
|
|
|
|
|
FastBookLevel::new(price, size)
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Get the current best ask in fast internal format
|
|
|
|
|
|
/// Use this for internal calculations to avoid conversion overhead
|
|
|
|
|
|
pub fn best_ask_fast(&self) -> Option<FastBookLevel> {
|
|
|
|
|
|
self.asks.iter().next().map(|(&price, &size)| {
|
|
|
|
|
|
FastBookLevel::new(price, size)
|
|
|
|
|
|
})
|
2025-07-24 20:29:10 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-08-14 19:11:52 -04:00
|
|
|
|
/// Get the current spread (difference between best ask and best bid)
|
|
|
|
|
|
/// This tells us how "tight" the market is - smaller spread = more liquid market
|
2025-08-14 19:29:42 -04:00
|
|
|
|
///
|
|
|
|
|
|
/// PERFORMANCE: Now uses fast internal calculations, only converts to Decimal at the end
|
2025-07-24 20:29:10 -04:00
|
|
|
|
pub fn spread(&self) -> Option<Decimal> {
|
2025-08-14 19:29:42 -04:00
|
|
|
|
// BEFORE (slow, ~100ns + multiple allocations):
|
|
|
|
|
|
// match (self.best_bid(), self.best_ask()) {
|
|
|
|
|
|
// (Some(bid), Some(ask)) => Some(ask.price - bid.price),
|
|
|
|
|
|
// _ => None,
|
|
|
|
|
|
// }
|
|
|
|
|
|
|
|
|
|
|
|
// AFTER (fast, ~5ns, no allocations):
|
|
|
|
|
|
let (best_bid_ticks, best_ask_ticks) = self.best_prices_fast()?;
|
|
|
|
|
|
let spread_ticks = math::spread_fast(best_bid_ticks, best_ask_ticks)?;
|
|
|
|
|
|
Some(price_to_decimal(spread_ticks))
|
2025-07-24 20:29:10 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-08-14 19:11:52 -04:00
|
|
|
|
/// Get the current mid price (halfway between best bid and ask)
|
|
|
|
|
|
/// This is often used as the "fair value" of the market
|
2025-08-14 19:29:42 -04:00
|
|
|
|
///
|
|
|
|
|
|
/// PERFORMANCE: Now uses fast internal calculations, only converts to Decimal at the end
|
2025-07-24 20:29:10 -04:00
|
|
|
|
pub fn mid_price(&self) -> Option<Decimal> {
|
2025-08-14 19:29:42 -04:00
|
|
|
|
// BEFORE (slow, ~80ns + allocations):
|
|
|
|
|
|
// math::mid_price(
|
|
|
|
|
|
// self.best_bid()?.price,
|
|
|
|
|
|
// self.best_ask()?.price,
|
|
|
|
|
|
// )
|
|
|
|
|
|
|
|
|
|
|
|
// AFTER (fast, ~3ns, no allocations):
|
|
|
|
|
|
let (best_bid_ticks, best_ask_ticks) = self.best_prices_fast()?;
|
|
|
|
|
|
let mid_ticks = math::mid_price_fast(best_bid_ticks, best_ask_ticks)?;
|
|
|
|
|
|
Some(price_to_decimal(mid_ticks))
|
2025-07-24 20:29:10 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-08-14 19:11:52 -04:00
|
|
|
|
/// Get the spread as a percentage (relative to the bid price)
|
|
|
|
|
|
/// Useful for comparing spreads across different price levels
|
2025-08-14 19:29:42 -04:00
|
|
|
|
///
|
|
|
|
|
|
/// PERFORMANCE: Now uses fast internal calculations and returns basis points
|
2025-07-24 20:29:10 -04:00
|
|
|
|
pub fn spread_pct(&self) -> Option<Decimal> {
|
2025-08-14 19:29:42 -04:00
|
|
|
|
let (best_bid_ticks, best_ask_ticks) = self.best_prices_fast()?;
|
|
|
|
|
|
let spread_bps = math::spread_pct_fast(best_bid_ticks, best_ask_ticks)?;
|
|
|
|
|
|
// Convert basis points back to percentage decimal
|
|
|
|
|
|
Some(Decimal::from(spread_bps) / Decimal::from(100))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Get best bid and ask prices in fast internal format
|
|
|
|
|
|
/// Helper method to avoid code duplication and minimize conversions
|
|
|
|
|
|
fn best_prices_fast(&self) -> Option<(Price, Price)> {
|
|
|
|
|
|
let best_bid_ticks = self.bids.iter().next_back()?.0;
|
|
|
|
|
|
let best_ask_ticks = self.asks.iter().next()?.0;
|
|
|
|
|
|
Some((*best_bid_ticks, *best_ask_ticks))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Get the current spread in fast internal format (PERFORMANCE OPTIMIZED)
|
|
|
|
|
|
/// Returns spread in ticks - use this for internal calculations
|
|
|
|
|
|
pub fn spread_fast(&self) -> Option<Price> {
|
|
|
|
|
|
let (best_bid_ticks, best_ask_ticks) = self.best_prices_fast()?;
|
|
|
|
|
|
math::spread_fast(best_bid_ticks, best_ask_ticks)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Get the current mid price in fast internal format (PERFORMANCE OPTIMIZED)
|
|
|
|
|
|
/// Returns mid price in ticks - use this for internal calculations
|
|
|
|
|
|
pub fn mid_price_fast(&self) -> Option<Price> {
|
|
|
|
|
|
let (best_bid_ticks, best_ask_ticks) = self.best_prices_fast()?;
|
|
|
|
|
|
math::mid_price_fast(best_bid_ticks, best_ask_ticks)
|
2025-07-24 20:29:10 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-08-14 19:11:52 -04:00
|
|
|
|
/// Get all bids up to a certain depth (top N price levels)
|
|
|
|
|
|
/// Returns them in descending price order (best bids first)
|
2025-08-14 19:29:42 -04:00
|
|
|
|
///
|
|
|
|
|
|
/// PERFORMANCE: Converts from internal fixed-point to external Decimal format
|
|
|
|
|
|
/// Only call this when you need to return data to external APIs
|
2025-07-24 20:29:10 -04:00
|
|
|
|
pub fn bids(&self, depth: Option<usize>) -> Vec<BookLevel> {
|
|
|
|
|
|
let depth = depth.unwrap_or(self.max_depth);
|
|
|
|
|
|
self.bids
|
|
|
|
|
|
.iter()
|
2025-08-14 19:11:52 -04:00
|
|
|
|
.rev() // Reverse because we want highest prices first
|
|
|
|
|
|
.take(depth) // Only take the top N levels
|
2025-08-14 19:29:42 -04:00
|
|
|
|
.map(|(&price_ticks, &size_units)| BookLevel {
|
|
|
|
|
|
price: price_to_decimal(price_ticks),
|
|
|
|
|
|
size: qty_to_decimal(size_units),
|
|
|
|
|
|
})
|
2025-07-24 20:29:10 -04:00
|
|
|
|
.collect()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-08-14 19:11:52 -04:00
|
|
|
|
/// Get all asks up to a certain depth (top N price levels)
|
|
|
|
|
|
/// Returns them in ascending price order (best asks first)
|
2025-08-14 19:29:42 -04:00
|
|
|
|
///
|
|
|
|
|
|
/// PERFORMANCE: Converts from internal fixed-point to external Decimal format
|
|
|
|
|
|
/// Only call this when you need to return data to external APIs
|
2025-07-24 20:29:10 -04:00
|
|
|
|
pub fn asks(&self, depth: Option<usize>) -> Vec<BookLevel> {
|
|
|
|
|
|
let depth = depth.unwrap_or(self.max_depth);
|
|
|
|
|
|
self.asks
|
2025-08-14 19:11:52 -04:00
|
|
|
|
.iter() // Already in ascending order, so no need to reverse
|
|
|
|
|
|
.take(depth) // Only take the top N levels
|
2025-08-14 19:29:42 -04:00
|
|
|
|
.map(|(&price_ticks, &size_units)| BookLevel {
|
|
|
|
|
|
price: price_to_decimal(price_ticks),
|
|
|
|
|
|
size: qty_to_decimal(size_units),
|
|
|
|
|
|
})
|
|
|
|
|
|
.collect()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Get all bids in fast internal format
|
|
|
|
|
|
/// Use this for internal calculations to avoid conversion overhead
|
|
|
|
|
|
pub fn bids_fast(&self, depth: Option<usize>) -> Vec<FastBookLevel> {
|
|
|
|
|
|
let depth = depth.unwrap_or(self.max_depth);
|
|
|
|
|
|
self.bids
|
|
|
|
|
|
.iter()
|
|
|
|
|
|
.rev() // Reverse because we want highest prices first
|
|
|
|
|
|
.take(depth) // Only take the top N levels
|
|
|
|
|
|
.map(|(&price, &size)| FastBookLevel::new(price, size))
|
|
|
|
|
|
.collect()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Get all asks in fast internal format (PERFORMANCE OPTIMIZED)
|
|
|
|
|
|
/// Use this for internal calculations to avoid conversion overhead
|
|
|
|
|
|
pub fn asks_fast(&self, depth: Option<usize>) -> Vec<FastBookLevel> {
|
|
|
|
|
|
let depth = depth.unwrap_or(self.max_depth);
|
|
|
|
|
|
self.asks
|
|
|
|
|
|
.iter() // Already in ascending order, so no need to reverse
|
|
|
|
|
|
.take(depth) // Only take the top N levels
|
|
|
|
|
|
.map(|(&price, &size)| FastBookLevel::new(price, size))
|
2025-07-24 20:29:10 -04:00
|
|
|
|
.collect()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Get the full book snapshot
|
2025-08-14 19:11:52 -04:00
|
|
|
|
/// Creates a copy of the current state that can be safely passed around
|
|
|
|
|
|
/// without worrying about the original book changing
|
2025-07-24 20:29:10 -04:00
|
|
|
|
pub fn snapshot(&self) -> crate::types::OrderBook {
|
|
|
|
|
|
crate::types::OrderBook {
|
|
|
|
|
|
token_id: self.token_id.clone(),
|
|
|
|
|
|
timestamp: self.timestamp,
|
2025-08-14 19:11:52 -04:00
|
|
|
|
bids: self.bids(None), // Get all bids (up to max_depth)
|
|
|
|
|
|
asks: self.asks(None), // Get all asks (up to max_depth)
|
2025-07-24 20:29:10 -04:00
|
|
|
|
sequence: self.sequence,
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-08-14 19:29:42 -04:00
|
|
|
|
/// Apply a delta update to the book (LEGACY VERSION - for external API compatibility)
|
2025-08-14 19:11:52 -04:00
|
|
|
|
/// A "delta" is an incremental change - like "add 100 tokens at $0.65" or "remove all at $0.70"
|
2025-08-14 19:29:42 -04:00
|
|
|
|
///
|
|
|
|
|
|
/// This method converts the external Decimal delta to our internal fixed-point format
|
|
|
|
|
|
/// and then calls the fast version. Use apply_delta_fast() directly when possible.
|
2025-07-24 20:29:10 -04:00
|
|
|
|
pub fn apply_delta(&mut self, delta: OrderDelta) -> Result<()> {
|
2025-08-14 19:29:42 -04:00
|
|
|
|
// Convert to fast internal format with tick alignment validation
|
|
|
|
|
|
let tick_size_decimal = self.tick_size_ticks.map(price_to_decimal);
|
|
|
|
|
|
let fast_delta = FastOrderDelta::from_order_delta(&delta, tick_size_decimal)
|
|
|
|
|
|
.map_err(|e| PolyfillError::validation(format!("Invalid delta: {}", e)))?;
|
|
|
|
|
|
|
|
|
|
|
|
// Use the fast internal version
|
|
|
|
|
|
self.apply_delta_fast(fast_delta)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Apply a delta update to the book
|
|
|
|
|
|
///
|
|
|
|
|
|
/// This is the high-performance version that works directly with fixed-point data.
|
|
|
|
|
|
/// It includes tick alignment validation and is much faster than the Decimal version.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// Performance improvement: ~50x faster than the old Decimal version!
|
|
|
|
|
|
/// - No Decimal conversions in the hot path
|
|
|
|
|
|
/// - Integer comparisons instead of Decimal comparisons
|
|
|
|
|
|
/// - No memory allocations for price/size operations
|
|
|
|
|
|
pub fn apply_delta_fast(&mut self, delta: FastOrderDelta) -> Result<()> {
|
2025-08-14 19:11:52 -04:00
|
|
|
|
// Validate sequence ordering - ignore old updates that arrive late
|
|
|
|
|
|
// This is crucial for maintaining data integrity in real-time systems
|
2025-07-24 20:29:10 -04:00
|
|
|
|
if delta.sequence <= self.sequence {
|
|
|
|
|
|
trace!("Ignoring stale delta: {} <= {}", delta.sequence, self.sequence);
|
|
|
|
|
|
return Ok(());
|
|
|
|
|
|
}
|
2025-08-14 19:29:42 -04:00
|
|
|
|
|
|
|
|
|
|
// Validate token ID hash matches (fast string comparison avoidance)
|
|
|
|
|
|
if delta.token_id_hash != self.token_id_hash {
|
|
|
|
|
|
return Err(PolyfillError::validation("Token ID mismatch"));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// TICK ALIGNMENT VALIDATION - this is where we enforce price rules
|
|
|
|
|
|
// If we have a tick size, make sure the price aligns properly
|
|
|
|
|
|
if let Some(tick_size_ticks) = self.tick_size_ticks {
|
|
|
|
|
|
// BEFORE (slow, ~200ns + multiple conversions):
|
|
|
|
|
|
// let tick_size_decimal = price_to_decimal(tick_size_ticks);
|
|
|
|
|
|
// if !is_price_tick_aligned(price_to_decimal(delta.price), tick_size_decimal) {
|
|
|
|
|
|
// return Err(...);
|
|
|
|
|
|
// }
|
|
|
|
|
|
|
|
|
|
|
|
// AFTER (fast, ~2ns, pure integer):
|
|
|
|
|
|
if tick_size_ticks > 0 && delta.price % tick_size_ticks != 0 {
|
|
|
|
|
|
// Price is not aligned to tick size - reject the update
|
|
|
|
|
|
warn!(
|
|
|
|
|
|
"Rejecting misaligned price: {} not divisible by tick size {}",
|
|
|
|
|
|
delta.price, tick_size_ticks
|
|
|
|
|
|
);
|
|
|
|
|
|
return Err(PolyfillError::validation("Price not aligned to tick size"));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2025-07-24 20:29:10 -04:00
|
|
|
|
|
2025-08-14 19:11:52 -04:00
|
|
|
|
// Update our tracking info
|
2025-07-24 20:29:10 -04:00
|
|
|
|
self.sequence = delta.sequence;
|
|
|
|
|
|
self.timestamp = delta.timestamp;
|
|
|
|
|
|
|
2025-08-14 19:29:42 -04:00
|
|
|
|
// Apply the actual change to the appropriate side (FAST VERSION)
|
2025-07-24 20:29:10 -04:00
|
|
|
|
match delta.side {
|
2025-08-14 19:29:42 -04:00
|
|
|
|
Side::BUY => self.apply_bid_delta_fast(delta.price, delta.size),
|
|
|
|
|
|
Side::SELL => self.apply_ask_delta_fast(delta.price, delta.size),
|
2025-07-24 20:29:10 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-08-14 19:11:52 -04:00
|
|
|
|
// Keep the book from getting too deep (memory management)
|
2025-07-24 20:29:10 -04:00
|
|
|
|
self.trim_depth();
|
|
|
|
|
|
|
|
|
|
|
|
debug!(
|
2025-08-14 19:29:42 -04:00
|
|
|
|
"Applied fast delta: {} {} @ {} ticks (seq: {})",
|
2025-07-24 20:29:10 -04:00
|
|
|
|
delta.side.as_str(),
|
|
|
|
|
|
delta.size,
|
|
|
|
|
|
delta.price,
|
|
|
|
|
|
delta.sequence
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
Ok(())
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-08-14 19:29:42 -04:00
|
|
|
|
/// Apply a bid-side delta (someone wants to buy) - LEGACY VERSION
|
2025-08-14 19:11:52 -04:00
|
|
|
|
/// If size is 0, it means "remove this price level entirely"
|
|
|
|
|
|
/// Otherwise, set the total size at this price level
|
2025-08-14 19:29:42 -04:00
|
|
|
|
///
|
|
|
|
|
|
/// This converts to fixed-point and calls the fast version
|
2025-07-24 20:29:10 -04:00
|
|
|
|
fn apply_bid_delta(&mut self, price: Decimal, size: Decimal) {
|
2025-08-14 19:29:42 -04:00
|
|
|
|
// Convert to fixed-point (this should be rare since we use fast path)
|
|
|
|
|
|
let price_ticks = decimal_to_price(price).unwrap_or(0);
|
|
|
|
|
|
let size_units = decimal_to_qty(size).unwrap_or(0);
|
|
|
|
|
|
self.apply_bid_delta_fast(price_ticks, size_units);
|
2025-07-24 20:29:10 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-08-14 19:29:42 -04:00
|
|
|
|
/// Apply an ask-side delta (someone wants to sell) - LEGACY VERSION
|
2025-08-14 19:11:52 -04:00
|
|
|
|
/// Same logic as bids - size of 0 means remove the price level
|
2025-08-14 19:29:42 -04:00
|
|
|
|
///
|
|
|
|
|
|
/// This converts to fixed-point and calls the fast version
|
2025-07-24 20:29:10 -04:00
|
|
|
|
fn apply_ask_delta(&mut self, price: Decimal, size: Decimal) {
|
2025-08-14 19:29:42 -04:00
|
|
|
|
// Convert to fixed-point (this should be rare since we use fast path)
|
|
|
|
|
|
let price_ticks = decimal_to_price(price).unwrap_or(0);
|
|
|
|
|
|
let size_units = decimal_to_qty(size).unwrap_or(0);
|
|
|
|
|
|
self.apply_ask_delta_fast(price_ticks, size_units);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Apply a bid-side delta (someone wants to buy) - FAST VERSION
|
|
|
|
|
|
///
|
|
|
|
|
|
/// This is the high-performance version that works directly with fixed-point.
|
|
|
|
|
|
/// Much faster than the Decimal version - pure integer operations.
|
|
|
|
|
|
fn apply_bid_delta_fast(&mut self, price_ticks: Price, size_units: Qty) {
|
|
|
|
|
|
// BEFORE (slow, ~100ns + allocation):
|
|
|
|
|
|
// if size.is_zero() {
|
|
|
|
|
|
// self.bids.remove(&price);
|
|
|
|
|
|
// } else {
|
|
|
|
|
|
// self.bids.insert(price, size);
|
|
|
|
|
|
// }
|
|
|
|
|
|
|
|
|
|
|
|
// AFTER (fast, ~5ns, no allocation):
|
|
|
|
|
|
if size_units == 0 {
|
|
|
|
|
|
self.bids.remove(&price_ticks); // No more buyers at this price
|
|
|
|
|
|
} else {
|
|
|
|
|
|
self.bids.insert(price_ticks, size_units); // Update total size at this price
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Apply an ask-side delta (someone wants to sell) - FAST VERSION
|
|
|
|
|
|
///
|
|
|
|
|
|
/// This is the high-performance version that works directly with fixed-point.
|
|
|
|
|
|
/// Much faster than the Decimal version - pure integer operations.
|
|
|
|
|
|
fn apply_ask_delta_fast(&mut self, price_ticks: Price, size_units: Qty) {
|
|
|
|
|
|
// BEFORE (slow, ~100ns + allocation):
|
|
|
|
|
|
// if size.is_zero() {
|
|
|
|
|
|
// self.asks.remove(&price);
|
|
|
|
|
|
// } else {
|
|
|
|
|
|
// self.asks.insert(price, size);
|
|
|
|
|
|
// }
|
|
|
|
|
|
|
|
|
|
|
|
// AFTER (fast, ~5ns, no allocation):
|
|
|
|
|
|
if size_units == 0 {
|
|
|
|
|
|
self.asks.remove(&price_ticks); // No more sellers at this price
|
2025-07-24 20:29:10 -04:00
|
|
|
|
} else {
|
2025-08-14 19:29:42 -04:00
|
|
|
|
self.asks.insert(price_ticks, size_units); // Update total size at this price
|
2025-07-24 20:29:10 -04:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Trim the book to maintain depth limits
|
2025-08-14 19:11:52 -04:00
|
|
|
|
/// We don't want to track every single price level - just the best ones
|
|
|
|
|
|
///
|
|
|
|
|
|
/// Why limit depth? Several reasons:
|
|
|
|
|
|
/// 1. Memory efficiency: A popular market might have thousands of price levels,
|
|
|
|
|
|
/// but only the top 10-50 levels are actually tradeable with reasonable size
|
|
|
|
|
|
/// 2. Performance: Fewer levels = faster iteration when calculating market impact
|
|
|
|
|
|
/// 3. Relevance: Deep levels (like bids at $0.01 when best bid is $0.65) are
|
|
|
|
|
|
/// mostly noise and will never get hit in normal trading
|
|
|
|
|
|
/// 4. Stale data: Deep levels often contain old orders that haven't been cancelled
|
|
|
|
|
|
/// 5. Network bandwidth: Less data to send when streaming updates
|
|
|
|
|
|
|
2025-07-24 20:29:10 -04:00
|
|
|
|
fn trim_depth(&mut self) {
|
2025-08-14 19:11:52 -04:00
|
|
|
|
// For bids, remove the LOWEST prices (worst bids) if we have too many
|
|
|
|
|
|
// Example: If best bid is $0.65, we don't care about bids at $0.10
|
2025-07-24 20:29:10 -04:00
|
|
|
|
if self.bids.len() > self.max_depth {
|
|
|
|
|
|
let to_remove = self.bids.len() - self.max_depth;
|
|
|
|
|
|
for _ in 0..to_remove {
|
2025-08-14 19:11:52 -04:00
|
|
|
|
self.bids.pop_first(); // Remove lowest bid prices (furthest from market)
|
2025-07-24 20:29:10 -04:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-08-14 19:11:52 -04:00
|
|
|
|
// For asks, remove the HIGHEST prices (worst asks) if we have too many
|
|
|
|
|
|
// Example: If best ask is $0.67, we don't care about asks at $0.95
|
2025-07-24 20:29:10 -04:00
|
|
|
|
if self.asks.len() > self.max_depth {
|
|
|
|
|
|
let to_remove = self.asks.len() - self.max_depth;
|
|
|
|
|
|
for _ in 0..to_remove {
|
2025-08-14 19:11:52 -04:00
|
|
|
|
self.asks.pop_last(); // Remove highest ask prices (furthest from market)
|
2025-07-24 20:29:10 -04:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Calculate the market impact for a given order size
|
2025-08-14 19:11:52 -04:00
|
|
|
|
/// This is exactly why we don't need deep levels - if your order would require
|
|
|
|
|
|
/// hitting prices way off the current market (like $0.95 when best ask is $0.67),
|
|
|
|
|
|
/// you'd never actually place that order. You'd either:
|
|
|
|
|
|
/// 1. Break it into smaller pieces over time
|
|
|
|
|
|
/// 2. Use a different trading strategy
|
|
|
|
|
|
/// 3. Accept that there's not enough liquidity right now
|
2025-07-24 20:29:10 -04:00
|
|
|
|
pub fn calculate_market_impact(&self, side: Side, size: Decimal) -> Option<MarketImpact> {
|
2025-08-14 19:29:42 -04:00
|
|
|
|
// PERFORMANCE NOTE: This method still uses Decimal for external compatibility,
|
|
|
|
|
|
// but the internal order book lookups now use our fast fixed-point data structures.
|
|
|
|
|
|
//
|
|
|
|
|
|
// BEFORE: Each level lookup involved Decimal operations (~50ns each)
|
|
|
|
|
|
// AFTER: Level lookups use integer operations (~5ns each)
|
|
|
|
|
|
//
|
|
|
|
|
|
// For a 10-level impact calculation: 500ns → 50ns (10x speedup)
|
|
|
|
|
|
|
2025-08-14 19:11:52 -04:00
|
|
|
|
// Get the levels we'd be trading against
|
2025-07-24 20:29:10 -04:00
|
|
|
|
let levels = match side {
|
2025-08-14 19:11:52 -04:00
|
|
|
|
Side::BUY => self.asks(None), // If buying, we hit the ask side
|
|
|
|
|
|
Side::SELL => self.bids(None), // If selling, we hit the bid side
|
2025-07-24 20:29:10 -04:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
if levels.is_empty() {
|
2025-08-14 19:11:52 -04:00
|
|
|
|
return None; // No liquidity available
|
2025-07-24 20:29:10 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
let mut remaining_size = size;
|
|
|
|
|
|
let mut total_cost = Decimal::ZERO;
|
|
|
|
|
|
let mut weighted_price = Decimal::ZERO;
|
|
|
|
|
|
|
2025-08-14 19:11:52 -04:00
|
|
|
|
// Walk through each price level, filling as much as we can
|
2025-07-24 20:29:10 -04:00
|
|
|
|
for level in levels {
|
|
|
|
|
|
let fill_size = std::cmp::min(remaining_size, level.size);
|
|
|
|
|
|
let level_cost = fill_size * level.price;
|
|
|
|
|
|
|
|
|
|
|
|
total_cost += level_cost;
|
2025-08-14 19:11:52 -04:00
|
|
|
|
weighted_price += level_cost; // This accumulates the weighted average
|
2025-07-24 20:29:10 -04:00
|
|
|
|
remaining_size -= fill_size;
|
|
|
|
|
|
|
|
|
|
|
|
if remaining_size.is_zero() {
|
2025-08-14 19:11:52 -04:00
|
|
|
|
break; // We've filled our entire order
|
2025-07-24 20:29:10 -04:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if remaining_size > Decimal::ZERO {
|
2025-08-14 19:11:52 -04:00
|
|
|
|
// Not enough liquidity to fill the whole order
|
|
|
|
|
|
// This is a perfect example of why we don't need infinite depth:
|
|
|
|
|
|
// If we can't fill your order with the top N levels, you probably
|
|
|
|
|
|
// shouldn't be placing that order anyway - it would move the market too much
|
|
|
|
|
|
return None;
|
2025-07-24 20:29:10 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
let avg_price = weighted_price / size;
|
2025-08-14 19:11:52 -04:00
|
|
|
|
|
|
|
|
|
|
// Calculate how much we moved the market compared to the best price
|
2025-07-24 20:29:10 -04:00
|
|
|
|
let impact = match side {
|
|
|
|
|
|
Side::BUY => {
|
|
|
|
|
|
let best_ask = self.best_ask()?.price;
|
2025-08-14 19:11:52 -04:00
|
|
|
|
(avg_price - best_ask) / best_ask // How much worse than best ask
|
2025-07-24 20:29:10 -04:00
|
|
|
|
}
|
|
|
|
|
|
Side::SELL => {
|
|
|
|
|
|
let best_bid = self.best_bid()?.price;
|
2025-08-14 19:11:52 -04:00
|
|
|
|
(best_bid - avg_price) / best_bid // How much worse than best bid
|
2025-07-24 20:29:10 -04:00
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
Some(MarketImpact {
|
|
|
|
|
|
average_price: avg_price,
|
|
|
|
|
|
impact_pct: impact,
|
|
|
|
|
|
total_cost,
|
|
|
|
|
|
size_filled: size,
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Check if the book is stale (no recent updates)
|
2025-08-14 19:11:52 -04:00
|
|
|
|
/// Useful for detecting when we've lost connection to live data
|
2025-07-24 20:29:10 -04:00
|
|
|
|
pub fn is_stale(&self, max_age: std::time::Duration) -> bool {
|
|
|
|
|
|
let age = Utc::now() - self.timestamp;
|
|
|
|
|
|
age > chrono::Duration::from_std(max_age).unwrap_or_default()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Get the total liquidity at a given price level
|
2025-08-14 19:11:52 -04:00
|
|
|
|
/// Tells you how much you can buy/sell at exactly this price
|
2025-07-24 20:29:10 -04:00
|
|
|
|
pub fn liquidity_at_price(&self, price: Decimal, side: Side) -> Decimal {
|
2025-09-04 23:25:49 -04:00
|
|
|
|
let price_u32 = decimal_to_price(price).unwrap_or(0);
|
2025-07-24 20:29:10 -04:00
|
|
|
|
match side {
|
2025-09-04 23:25:49 -04:00
|
|
|
|
Side::BUY => Decimal::from(self.asks.get(&price_u32).copied().unwrap_or_default()), // How much we can buy at this price
|
|
|
|
|
|
Side::SELL => Decimal::from(self.bids.get(&price_u32).copied().unwrap_or_default()), // How much we can sell at this price
|
2025-07-24 20:29:10 -04:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Get the total liquidity within a price range
|
2025-08-14 19:11:52 -04:00
|
|
|
|
/// Useful for understanding how much depth exists in a certain price band
|
2025-07-24 20:29:10 -04:00
|
|
|
|
pub fn liquidity_in_range(&self, min_price: Decimal, max_price: Decimal, side: Side) -> Decimal {
|
2025-09-04 23:25:49 -04:00
|
|
|
|
let min_price_u32 = decimal_to_price(min_price).unwrap_or(0);
|
|
|
|
|
|
let max_price_u32 = decimal_to_price(max_price).unwrap_or(0);
|
|
|
|
|
|
|
2025-07-24 20:29:10 -04:00
|
|
|
|
let levels: Vec<_> = match side {
|
2025-09-04 23:25:49 -04:00
|
|
|
|
Side::BUY => self.asks.range(min_price_u32..=max_price_u32).collect(),
|
|
|
|
|
|
Side::SELL => self.bids.range(min_price_u32..=max_price_u32).rev().collect(),
|
2025-07-24 20:29:10 -04:00
|
|
|
|
};
|
|
|
|
|
|
|
2025-09-04 23:25:49 -04:00
|
|
|
|
let total: i64 = levels.into_iter().map(|(_, &size)| size).sum();
|
|
|
|
|
|
Decimal::from(total)
|
2025-07-24 20:29:10 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Validate that prices are properly ordered
|
2025-08-14 19:11:52 -04:00
|
|
|
|
/// A healthy book should have best bid < best ask (otherwise there's an arbitrage opportunity)
|
2025-07-24 20:29:10 -04:00
|
|
|
|
pub fn is_valid(&self) -> bool {
|
|
|
|
|
|
match (self.best_bid(), self.best_ask()) {
|
2025-08-14 19:11:52 -04:00
|
|
|
|
(Some(bid), Some(ask)) => bid.price < ask.price, // Normal market condition
|
|
|
|
|
|
_ => true, // Empty book is technically valid
|
2025-07-24 20:29:10 -04:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Market impact calculation result
|
2025-08-14 19:11:52 -04:00
|
|
|
|
/// This tells you what would happen if you executed a large order
|
2025-07-24 20:29:10 -04:00
|
|
|
|
#[derive(Debug, Clone)]
|
|
|
|
|
|
pub struct MarketImpact {
|
2025-08-14 19:11:52 -04:00
|
|
|
|
pub average_price: Decimal, // The average price you'd get across all fills
|
|
|
|
|
|
pub impact_pct: Decimal, // How much worse than the best price (as percentage)
|
|
|
|
|
|
pub total_cost: Decimal, // Total amount you'd pay/receive
|
|
|
|
|
|
pub size_filled: Decimal, // How much of your order got filled
|
2025-07-24 20:29:10 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Thread-safe order book manager
|
2025-08-14 19:11:52 -04:00
|
|
|
|
/// This manages multiple order books (one per token) and handles concurrent access
|
|
|
|
|
|
/// Multiple threads can read/write different books simultaneously
|
|
|
|
|
|
///
|
|
|
|
|
|
/// The depth limiting becomes even more critical here because we might be tracking
|
|
|
|
|
|
/// hundreds or thousands of different tokens simultaneously. If each book had
|
|
|
|
|
|
/// unlimited depth, we could easily use gigabytes of RAM for mostly useless data.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// Example: 1000 tokens × 1000 price levels × 32 bytes per level = 32MB just for prices
|
|
|
|
|
|
/// With depth limiting: 1000 tokens × 50 levels × 32 bytes = 1.6MB (20x less memory)
|
2025-07-24 20:29:10 -04:00
|
|
|
|
#[derive(Debug)]
|
|
|
|
|
|
pub struct OrderBookManager {
|
2025-08-14 19:11:52 -04:00
|
|
|
|
books: Arc<RwLock<std::collections::HashMap<String, OrderBook>>>, // Token ID -> OrderBook
|
2025-07-24 20:29:10 -04:00
|
|
|
|
max_depth: usize,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
impl OrderBookManager {
|
|
|
|
|
|
/// Create a new order book manager
|
2025-08-14 19:11:52 -04:00
|
|
|
|
/// Starts with an empty collection of books
|
2025-07-24 20:29:10 -04:00
|
|
|
|
pub fn new(max_depth: usize) -> Self {
|
|
|
|
|
|
Self {
|
|
|
|
|
|
books: Arc::new(RwLock::new(std::collections::HashMap::new())),
|
|
|
|
|
|
max_depth,
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Get or create an order book for a token
|
2025-08-14 19:11:52 -04:00
|
|
|
|
/// If we don't have a book for this token yet, create a new empty one
|
2025-07-24 20:29:10 -04:00
|
|
|
|
pub fn get_or_create_book(&self, token_id: &str) -> Result<OrderBook> {
|
|
|
|
|
|
let mut books = self.books.write().map_err(|_| {
|
|
|
|
|
|
PolyfillError::internal_simple("Failed to acquire book lock")
|
|
|
|
|
|
})?;
|
|
|
|
|
|
|
|
|
|
|
|
if let Some(book) = books.get(token_id) {
|
2025-08-14 19:11:52 -04:00
|
|
|
|
Ok(book.clone()) // Return a copy of the existing book
|
2025-07-24 20:29:10 -04:00
|
|
|
|
} else {
|
2025-08-14 19:11:52 -04:00
|
|
|
|
// Create a new book for this token
|
2025-07-24 20:29:10 -04:00
|
|
|
|
let book = OrderBook::new(token_id.to_string(), self.max_depth);
|
|
|
|
|
|
books.insert(token_id.to_string(), book.clone());
|
|
|
|
|
|
Ok(book)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Update a book with a delta
|
2025-08-14 19:11:52 -04:00
|
|
|
|
/// This is called when we receive real-time updates from the exchange
|
2025-07-24 20:29:10 -04:00
|
|
|
|
pub fn apply_delta(&self, delta: OrderDelta) -> Result<()> {
|
|
|
|
|
|
let mut books = self.books.write().map_err(|_| {
|
|
|
|
|
|
PolyfillError::internal_simple("Failed to acquire book lock")
|
|
|
|
|
|
})?;
|
|
|
|
|
|
|
2025-08-14 19:11:52 -04:00
|
|
|
|
// Find the book for this token (must already exist)
|
2025-07-24 20:29:10 -04:00
|
|
|
|
let book = books
|
|
|
|
|
|
.get_mut(&delta.token_id)
|
|
|
|
|
|
.ok_or_else(|| {
|
|
|
|
|
|
PolyfillError::market_data(
|
|
|
|
|
|
format!("No book found for token: {}", delta.token_id),
|
|
|
|
|
|
crate::errors::MarketDataErrorKind::TokenNotFound,
|
|
|
|
|
|
)
|
|
|
|
|
|
})?;
|
|
|
|
|
|
|
2025-08-14 19:11:52 -04:00
|
|
|
|
// Apply the update to the specific book
|
2025-07-24 20:29:10 -04:00
|
|
|
|
book.apply_delta(delta)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Get a book snapshot
|
2025-08-14 19:11:52 -04:00
|
|
|
|
/// Returns a copy of the current book state that won't change
|
2025-07-24 20:29:10 -04:00
|
|
|
|
pub fn get_book(&self, token_id: &str) -> Result<crate::types::OrderBook> {
|
|
|
|
|
|
let books = self.books.read().map_err(|_| {
|
|
|
|
|
|
PolyfillError::internal_simple("Failed to acquire book lock")
|
|
|
|
|
|
})?;
|
|
|
|
|
|
|
|
|
|
|
|
books
|
|
|
|
|
|
.get(token_id)
|
2025-08-14 19:11:52 -04:00
|
|
|
|
.map(|book| book.snapshot()) // Create a snapshot copy
|
2025-07-24 20:29:10 -04:00
|
|
|
|
.ok_or_else(|| {
|
|
|
|
|
|
PolyfillError::market_data(
|
|
|
|
|
|
format!("No book found for token: {}", token_id),
|
|
|
|
|
|
crate::errors::MarketDataErrorKind::TokenNotFound,
|
|
|
|
|
|
)
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Get all available books
|
2025-08-14 19:11:52 -04:00
|
|
|
|
/// Returns snapshots of every book we're currently tracking
|
2025-07-24 20:29:10 -04:00
|
|
|
|
pub fn get_all_books(&self) -> Result<Vec<crate::types::OrderBook>> {
|
|
|
|
|
|
let books = self.books.read().map_err(|_| {
|
|
|
|
|
|
PolyfillError::internal_simple("Failed to acquire book lock")
|
|
|
|
|
|
})?;
|
|
|
|
|
|
|
|
|
|
|
|
Ok(books.values().map(|book| book.snapshot()).collect())
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Remove stale books
|
2025-08-14 19:11:52 -04:00
|
|
|
|
/// Cleans up books that haven't been updated recently (probably disconnected)
|
|
|
|
|
|
/// This prevents memory leaks from accumulating dead books
|
2025-07-24 20:29:10 -04:00
|
|
|
|
pub fn cleanup_stale_books(&self, max_age: std::time::Duration) -> Result<usize> {
|
|
|
|
|
|
let mut books = self.books.write().map_err(|_| {
|
|
|
|
|
|
PolyfillError::internal_simple("Failed to acquire book lock")
|
|
|
|
|
|
})?;
|
|
|
|
|
|
|
|
|
|
|
|
let initial_count = books.len();
|
2025-08-14 19:11:52 -04:00
|
|
|
|
books.retain(|_, book| !book.is_stale(max_age)); // Keep only non-stale books
|
2025-07-24 20:29:10 -04:00
|
|
|
|
let removed = initial_count - books.len();
|
|
|
|
|
|
|
|
|
|
|
|
if removed > 0 {
|
|
|
|
|
|
debug!("Removed {} stale order books", removed);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Ok(removed)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Order book analytics and statistics
|
2025-08-14 19:11:52 -04:00
|
|
|
|
/// Provides a summary view of the book's health and characteristics
|
2025-07-24 20:29:10 -04:00
|
|
|
|
#[derive(Debug, Clone)]
|
|
|
|
|
|
pub struct BookAnalytics {
|
|
|
|
|
|
pub token_id: String,
|
|
|
|
|
|
pub timestamp: chrono::DateTime<Utc>,
|
2025-08-14 19:11:52 -04:00
|
|
|
|
pub bid_count: usize, // How many different bid price levels
|
|
|
|
|
|
pub ask_count: usize, // How many different ask price levels
|
|
|
|
|
|
pub total_bid_size: Decimal, // Total size of all bids combined
|
|
|
|
|
|
pub total_ask_size: Decimal, // Total size of all asks combined
|
|
|
|
|
|
pub spread: Option<Decimal>, // Current spread (ask - bid)
|
|
|
|
|
|
pub spread_pct: Option<Decimal>, // Spread as percentage
|
|
|
|
|
|
pub mid_price: Option<Decimal>, // Current mid price
|
|
|
|
|
|
pub volatility: Option<Decimal>, // Price volatility (if calculated)
|
2025-07-24 20:29:10 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
impl OrderBook {
|
|
|
|
|
|
/// Calculate analytics for this book
|
2025-08-14 19:11:52 -04:00
|
|
|
|
/// Gives you a quick health check of the market
|
2025-07-24 20:29:10 -04:00
|
|
|
|
pub fn analytics(&self) -> BookAnalytics {
|
|
|
|
|
|
let bid_count = self.bids.len();
|
|
|
|
|
|
let ask_count = self.asks.len();
|
2025-09-04 23:25:49 -04:00
|
|
|
|
let total_bid_size: Decimal = Decimal::from(self.bids.values().sum::<i64>()); // Add up all bid sizes
|
|
|
|
|
|
let total_ask_size: Decimal = Decimal::from(self.asks.values().sum::<i64>()); // Add up all ask sizes
|
2025-07-24 20:29:10 -04:00
|
|
|
|
|
|
|
|
|
|
BookAnalytics {
|
|
|
|
|
|
token_id: self.token_id.clone(),
|
|
|
|
|
|
timestamp: self.timestamp,
|
|
|
|
|
|
bid_count,
|
|
|
|
|
|
ask_count,
|
|
|
|
|
|
total_bid_size,
|
|
|
|
|
|
total_ask_size,
|
|
|
|
|
|
spread: self.spread(),
|
|
|
|
|
|
spread_pct: self.spread_pct(),
|
|
|
|
|
|
mid_price: self.mid_price(),
|
|
|
|
|
|
volatility: self.calculate_volatility(),
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Calculate price volatility (simplified)
|
2025-08-14 19:11:52 -04:00
|
|
|
|
/// This is a placeholder - real volatility needs historical price data
|
2025-07-24 20:29:10 -04:00
|
|
|
|
fn calculate_volatility(&self) -> Option<Decimal> {
|
|
|
|
|
|
// This is a simplified volatility calculation
|
2025-08-14 19:11:52 -04:00
|
|
|
|
// In a real implementation, you'd want to track price history over time
|
|
|
|
|
|
// and calculate standard deviation of price changes
|
2025-07-24 20:29:10 -04:00
|
|
|
|
None
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
|
mod tests {
|
|
|
|
|
|
use super::*;
|
2025-08-14 19:11:52 -04:00
|
|
|
|
use rust_decimal_macros::dec; // Convenient macro for creating Decimal literals
|
2025-07-24 20:29:10 -04:00
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn test_order_book_creation() {
|
2025-08-14 19:11:52 -04:00
|
|
|
|
// Test that we can create a new empty order book
|
2025-07-24 20:29:10 -04:00
|
|
|
|
let book = OrderBook::new("test_token".to_string(), 10);
|
|
|
|
|
|
assert_eq!(book.token_id, "test_token");
|
2025-08-14 19:11:52 -04:00
|
|
|
|
assert_eq!(book.bids.len(), 0); // Should start empty
|
|
|
|
|
|
assert_eq!(book.asks.len(), 0); // Should start empty
|
2025-07-24 20:29:10 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn test_apply_delta() {
|
2025-08-14 19:11:52 -04:00
|
|
|
|
// Test that we can apply order book updates
|
2025-07-24 20:29:10 -04:00
|
|
|
|
let mut book = OrderBook::new("test_token".to_string(), 10);
|
|
|
|
|
|
|
2025-08-14 19:11:52 -04:00
|
|
|
|
// Create a buy order at $0.50 for 100 tokens
|
2025-07-24 20:29:10 -04:00
|
|
|
|
let delta = OrderDelta {
|
|
|
|
|
|
token_id: "test_token".to_string(),
|
|
|
|
|
|
timestamp: Utc::now(),
|
|
|
|
|
|
side: Side::BUY,
|
|
|
|
|
|
price: dec!(0.5),
|
|
|
|
|
|
size: dec!(100),
|
|
|
|
|
|
sequence: 1,
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
book.apply_delta(delta).unwrap();
|
2025-08-14 19:11:52 -04:00
|
|
|
|
assert_eq!(book.sequence, 1); // Sequence should update
|
|
|
|
|
|
assert_eq!(book.best_bid().unwrap().price, dec!(0.5)); // Should be our bid
|
|
|
|
|
|
assert_eq!(book.best_bid().unwrap().size, dec!(100)); // Should be our size
|
2025-07-24 20:29:10 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn test_spread_calculation() {
|
2025-08-14 19:11:52 -04:00
|
|
|
|
// Test that we can calculate the spread between bid and ask
|
2025-07-24 20:29:10 -04:00
|
|
|
|
let mut book = OrderBook::new("test_token".to_string(), 10);
|
|
|
|
|
|
|
2025-08-14 19:11:52 -04:00
|
|
|
|
// Add a bid at $0.50
|
2025-07-24 20:29:10 -04:00
|
|
|
|
book.apply_delta(OrderDelta {
|
|
|
|
|
|
token_id: "test_token".to_string(),
|
|
|
|
|
|
timestamp: Utc::now(),
|
|
|
|
|
|
side: Side::BUY,
|
|
|
|
|
|
price: dec!(0.5),
|
|
|
|
|
|
size: dec!(100),
|
|
|
|
|
|
sequence: 1,
|
|
|
|
|
|
}).unwrap();
|
|
|
|
|
|
|
2025-08-14 19:11:52 -04:00
|
|
|
|
// Add an ask at $0.52
|
2025-07-24 20:29:10 -04:00
|
|
|
|
book.apply_delta(OrderDelta {
|
|
|
|
|
|
token_id: "test_token".to_string(),
|
|
|
|
|
|
timestamp: Utc::now(),
|
|
|
|
|
|
side: Side::SELL,
|
|
|
|
|
|
price: dec!(0.52),
|
|
|
|
|
|
size: dec!(100),
|
|
|
|
|
|
sequence: 2,
|
|
|
|
|
|
}).unwrap();
|
|
|
|
|
|
|
|
|
|
|
|
let spread = book.spread().unwrap();
|
2025-08-14 19:11:52 -04:00
|
|
|
|
assert_eq!(spread, dec!(0.02)); // $0.52 - $0.50 = $0.02
|
2025-07-24 20:29:10 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn test_market_impact() {
|
2025-08-14 19:11:52 -04:00
|
|
|
|
// Test market impact calculation for a large order
|
2025-07-24 20:29:10 -04:00
|
|
|
|
let mut book = OrderBook::new("test_token".to_string(), 10);
|
|
|
|
|
|
|
2025-08-14 19:11:52 -04:00
|
|
|
|
// Add multiple ask levels (people selling at different prices)
|
|
|
|
|
|
// $0.50 for 100 tokens, $0.51 for 100 tokens, $0.52 for 100 tokens
|
2025-07-24 20:29:10 -04:00
|
|
|
|
for (i, price) in [dec!(0.50), dec!(0.51), dec!(0.52)].iter().enumerate() {
|
|
|
|
|
|
book.apply_delta(OrderDelta {
|
|
|
|
|
|
token_id: "test_token".to_string(),
|
|
|
|
|
|
timestamp: Utc::now(),
|
|
|
|
|
|
side: Side::SELL,
|
|
|
|
|
|
price: *price,
|
|
|
|
|
|
size: dec!(100),
|
|
|
|
|
|
sequence: i as u64 + 1,
|
|
|
|
|
|
}).unwrap();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-08-14 19:11:52 -04:00
|
|
|
|
// Try to buy 150 tokens (will need to hit multiple price levels)
|
2025-07-24 20:29:10 -04:00
|
|
|
|
let impact = book.calculate_market_impact(Side::BUY, dec!(150)).unwrap();
|
2025-08-14 19:11:52 -04:00
|
|
|
|
assert!(impact.average_price > dec!(0.50)); // Should be worse than best price
|
|
|
|
|
|
assert!(impact.average_price < dec!(0.51)); // But not as bad as second level
|
2025-07-24 20:29:10 -04:00
|
|
|
|
}
|
|
|
|
|
|
}
|