mirror of
https://github.com/floor-licker/polyfill-rs.git
synced 2026-07-27 20:47:46 +00:00
perf: retain snapshots by generation
This commit is contained in:
@@ -71,7 +71,7 @@ Real-world Polymarket API latency broken down by request phase:
|
||||
| **Order Book Updates (1000 ops)** | 159.6 µs ± 32 µs | 6,260 updates/sec, zero-allocation |
|
||||
| **Spread/Mid Calculations** | 70 ns ± 77 ns | 14.3M ops/sec, optimized BTreeMap |
|
||||
| **JSON Parsing (480KB)** | ~2.3 ms | SIMD-accelerated parsing (1.77x faster than serde_json) |
|
||||
| **WS `book` hot path (decode + apply)** | ~0.27 µs / 7.46 µs / 93.24 µs | 1 / 16 / 64 levels-per-side, strict 4dp fixed-point tape parser, no Decimal/rounding/clamping in the feed path (see `benches/ws_hot_path.rs`) |
|
||||
| **WS `book` hot path (decode + apply)** | ~0.23 µs / 1.73 µs / 6.74 µs | 1 / 16 / 64 levels-per-side, strict fixed-point tape parser with generation-marked snapshot retention (see `benches/ws_hot_path.rs`) |
|
||||
|
||||
Run the WS hot-path benchmark locally with `cargo bench --bench ws_hot_path`.
|
||||
|
||||
|
||||
+100
-70
@@ -9,6 +9,12 @@ use std::collections::BTreeMap; // BTreeMap keeps prices sorted automatically -
|
||||
use std::sync::{Arc, RwLock}; // For thread-safe access across multiple tasks
|
||||
use tracing::{debug, trace, warn}; // Logging for debugging and monitoring
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
struct StoredLevel {
|
||||
qty: Qty,
|
||||
generation: u64,
|
||||
}
|
||||
|
||||
/// High-performance order book implementation
|
||||
///
|
||||
/// This is the core data structure that holds all the live buy/sell orders for a token.
|
||||
@@ -40,20 +46,23 @@ pub struct OrderBook {
|
||||
/// 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>,
|
||||
/// AFTER (fast): bids: BTreeMap<Price, StoredLevel>,
|
||||
///
|
||||
/// 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>,
|
||||
bids: BTreeMap<Price, StoredLevel>,
|
||||
|
||||
/// Ask side (price -> size, sorted ascending) - NOW USING FIXED-POINT!
|
||||
/// BTreeMap keeps lowest asks first - people selling at cheapest prices
|
||||
///
|
||||
/// BEFORE (slow): asks: BTreeMap<Decimal, Decimal>,
|
||||
/// AFTER (fast): asks: BTreeMap<Price, Qty>,
|
||||
asks: BTreeMap<Price, Qty>,
|
||||
/// AFTER (fast): asks: BTreeMap<Price, StoredLevel>,
|
||||
asks: BTreeMap<Price, StoredLevel>,
|
||||
|
||||
/// Snapshot generation used to retain book levels without rescanning input payloads.
|
||||
snapshot_generation: u64,
|
||||
|
||||
/// Minimum tick size for this market in ticks (like 10 for $0.001 increments)
|
||||
/// Some markets only allow certain price increments
|
||||
@@ -93,6 +102,7 @@ impl OrderBook {
|
||||
timestamp: Utc::now(),
|
||||
bids: BTreeMap::new(), // Empty to start - using Price/Qty types
|
||||
asks: BTreeMap::new(), // Empty to start - using Price/Qty types
|
||||
snapshot_generation: 0,
|
||||
tick_size_ticks: None, // We'll set this later when we learn about the market
|
||||
max_depth,
|
||||
}
|
||||
@@ -123,17 +133,14 @@ impl OrderBook {
|
||||
// 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),
|
||||
}
|
||||
})
|
||||
self.bids.iter().next_back().map(|(&price_ticks, level)| {
|
||||
// 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(level.qty),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Get the current best ask (lowest price someone is willing to sell at)
|
||||
@@ -145,12 +152,12 @@ impl OrderBook {
|
||||
// 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)| {
|
||||
self.asks.iter().next().map(|(&price_ticks, level)| {
|
||||
// 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),
|
||||
size: qty_to_decimal(level.qty),
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -161,7 +168,7 @@ impl OrderBook {
|
||||
self.bids
|
||||
.iter()
|
||||
.next_back()
|
||||
.map(|(&price, &size)| FastBookLevel::new(price, size))
|
||||
.map(|(&price, level)| FastBookLevel::new(price, level.qty))
|
||||
}
|
||||
|
||||
/// Get the current best ask in fast internal format
|
||||
@@ -170,7 +177,7 @@ impl OrderBook {
|
||||
self.asks
|
||||
.iter()
|
||||
.next()
|
||||
.map(|(&price, &size)| FastBookLevel::new(price, size))
|
||||
.map(|(&price, level)| FastBookLevel::new(price, level.qty))
|
||||
}
|
||||
|
||||
/// Get the current spread (difference between best ask and best bid)
|
||||
@@ -251,9 +258,9 @@ impl OrderBook {
|
||||
.iter()
|
||||
.rev() // Reverse because we want highest prices first
|
||||
.take(depth) // Only take the top N levels
|
||||
.map(|(&price_ticks, &size_units)| BookLevel {
|
||||
.map(|(&price_ticks, level)| BookLevel {
|
||||
price: price_to_decimal(price_ticks),
|
||||
size: qty_to_decimal(size_units),
|
||||
size: qty_to_decimal(level.qty),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -268,9 +275,9 @@ impl OrderBook {
|
||||
self.asks
|
||||
.iter() // Already in ascending order, so no need to reverse
|
||||
.take(depth) // Only take the top N levels
|
||||
.map(|(&price_ticks, &size_units)| BookLevel {
|
||||
.map(|(&price_ticks, level)| BookLevel {
|
||||
price: price_to_decimal(price_ticks),
|
||||
size: qty_to_decimal(size_units),
|
||||
size: qty_to_decimal(level.qty),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -283,7 +290,7 @@ impl OrderBook {
|
||||
.iter()
|
||||
.rev() // Reverse because we want highest prices first
|
||||
.take(depth) // Only take the top N levels
|
||||
.map(|(&price, &size)| FastBookLevel::new(price, size))
|
||||
.map(|(&price, level)| FastBookLevel::new(price, level.qty))
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -294,7 +301,7 @@ impl OrderBook {
|
||||
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))
|
||||
.map(|(&price, level)| FastBookLevel::new(price, level.qty))
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -415,6 +422,7 @@ impl OrderBook {
|
||||
self.sequence = timestamp;
|
||||
self.timestamp = chrono::DateTime::<Utc>::from_timestamp_millis(timestamp as i64)
|
||||
.unwrap_or_else(Utc::now);
|
||||
self.begin_snapshot();
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
@@ -435,22 +443,14 @@ impl OrderBook {
|
||||
}
|
||||
}
|
||||
|
||||
match side {
|
||||
Side::BUY => self.apply_bid_delta_fast(price_ticks, size_units),
|
||||
Side::SELL => self.apply_ask_delta_fast(price_ticks, size_units),
|
||||
}
|
||||
self.apply_snapshot_level(side, price_ticks, size_units);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Finish applying a WS `book` snapshot.
|
||||
pub(crate) fn finish_ws_book_update(
|
||||
&mut self,
|
||||
mut has_bid: impl FnMut(Price) -> bool,
|
||||
mut has_ask: impl FnMut(Price) -> bool,
|
||||
) {
|
||||
self.bids.retain(|price_ticks, _| has_bid(*price_ticks));
|
||||
self.asks.retain(|price_ticks, _| has_ask(*price_ticks));
|
||||
pub(crate) fn finish_ws_book_update(&mut self) {
|
||||
self.finish_snapshot();
|
||||
self.trim_depth();
|
||||
}
|
||||
|
||||
@@ -479,6 +479,7 @@ impl OrderBook {
|
||||
self.sequence = update.timestamp;
|
||||
self.timestamp = chrono::DateTime::<Utc>::from_timestamp_millis(update.timestamp as i64)
|
||||
.unwrap_or_else(Utc::now);
|
||||
self.begin_snapshot();
|
||||
|
||||
// Apply bids (BUY) and asks (SELL) as level upserts.
|
||||
for level in &update.bids {
|
||||
@@ -493,11 +494,7 @@ impl OrderBook {
|
||||
}
|
||||
}
|
||||
|
||||
if size_units == 0 {
|
||||
self.bids.remove(&price_ticks);
|
||||
} else {
|
||||
self.bids.insert(price_ticks, size_units);
|
||||
}
|
||||
self.apply_snapshot_level(Side::BUY, price_ticks, size_units);
|
||||
}
|
||||
|
||||
for level in &update.asks {
|
||||
@@ -512,17 +509,10 @@ impl OrderBook {
|
||||
}
|
||||
}
|
||||
|
||||
if size_units == 0 {
|
||||
self.asks.remove(&price_ticks);
|
||||
} else {
|
||||
self.asks.insert(price_ticks, size_units);
|
||||
}
|
||||
self.apply_snapshot_level(Side::SELL, price_ticks, size_units);
|
||||
}
|
||||
|
||||
self.bids
|
||||
.retain(|price_ticks, _| book_update_has_level(&update.bids, *price_ticks));
|
||||
self.asks
|
||||
.retain(|price_ticks, _| book_update_has_level(&update.asks, *price_ticks));
|
||||
self.finish_snapshot();
|
||||
self.trim_depth();
|
||||
Ok(())
|
||||
}
|
||||
@@ -568,7 +558,13 @@ impl OrderBook {
|
||||
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
|
||||
self.bids.insert(
|
||||
price_ticks,
|
||||
StoredLevel {
|
||||
qty: size_units,
|
||||
generation: self.snapshot_generation,
|
||||
},
|
||||
); // Update total size at this price
|
||||
}
|
||||
}
|
||||
|
||||
@@ -588,10 +584,49 @@ impl OrderBook {
|
||||
if size_units == 0 {
|
||||
self.asks.remove(&price_ticks); // No more sellers at this price
|
||||
} else {
|
||||
self.asks.insert(price_ticks, size_units); // Update total size at this price
|
||||
self.asks.insert(
|
||||
price_ticks,
|
||||
StoredLevel {
|
||||
qty: size_units,
|
||||
generation: self.snapshot_generation,
|
||||
},
|
||||
); // Update total size at this price
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn begin_snapshot(&mut self) {
|
||||
self.snapshot_generation = self.snapshot_generation.wrapping_add(1);
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn apply_snapshot_level(&mut self, side: Side, price_ticks: Price, size_units: Qty) {
|
||||
let generation = self.snapshot_generation;
|
||||
let map = match side {
|
||||
Side::BUY => &mut self.bids,
|
||||
Side::SELL => &mut self.asks,
|
||||
};
|
||||
|
||||
if size_units == 0 {
|
||||
map.remove(&price_ticks);
|
||||
} else {
|
||||
map.insert(
|
||||
price_ticks,
|
||||
StoredLevel {
|
||||
qty: size_units,
|
||||
generation,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn finish_snapshot(&mut self) {
|
||||
let generation = self.snapshot_generation;
|
||||
self.bids.retain(|_, level| level.generation == generation);
|
||||
self.asks.retain(|_, level| level.generation == generation);
|
||||
}
|
||||
|
||||
/// Trim the book to maintain depth limits
|
||||
/// We don't want to track every single price level - just the best ones
|
||||
///
|
||||
@@ -716,12 +751,20 @@ impl OrderBook {
|
||||
match side {
|
||||
Side::BUY => {
|
||||
// How much we can buy at this price (look at asks)
|
||||
let size_units = self.asks.get(&price_ticks).copied().unwrap_or_default();
|
||||
let size_units = self
|
||||
.asks
|
||||
.get(&price_ticks)
|
||||
.map(|level| level.qty)
|
||||
.unwrap_or_default();
|
||||
qty_to_decimal(size_units)
|
||||
},
|
||||
Side::SELL => {
|
||||
// How much we can sell at this price (look at bids)
|
||||
let size_units = self.bids.get(&price_ticks).copied().unwrap_or_default();
|
||||
let size_units = self
|
||||
.bids
|
||||
.get(&price_ticks)
|
||||
.map(|level| level.qty)
|
||||
.unwrap_or_default();
|
||||
qty_to_decimal(size_units)
|
||||
},
|
||||
}
|
||||
@@ -755,7 +798,7 @@ impl OrderBook {
|
||||
};
|
||||
|
||||
// Sum up the sizes, converting from fixed-point back to Decimal
|
||||
let total_size_units: i64 = levels.into_iter().map(|(_, &size)| size).sum();
|
||||
let total_size_units: i64 = levels.into_iter().map(|(_, level)| level.qty).sum();
|
||||
qty_to_decimal(total_size_units)
|
||||
}
|
||||
|
||||
@@ -769,19 +812,6 @@ impl OrderBook {
|
||||
}
|
||||
}
|
||||
|
||||
fn book_update_has_level(levels: &[OrderSummary], price_ticks: Price) -> bool {
|
||||
levels.iter().any(|level| {
|
||||
let Ok(level_price_ticks) = decimal_to_price(level.price) else {
|
||||
return false;
|
||||
};
|
||||
let Ok(size_units) = decimal_to_qty(level.size) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
size_units != 0 && level_price_ticks == price_ticks
|
||||
})
|
||||
}
|
||||
|
||||
/// Market impact calculation result
|
||||
/// This tells you what would happen if you executed a large order
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -978,8 +1008,8 @@ impl OrderBook {
|
||||
let bid_count = self.bids.len();
|
||||
let ask_count = self.asks.len();
|
||||
// Sum up all bid/ask sizes, converting from fixed-point back to Decimal
|
||||
let total_bid_size_units: i64 = self.bids.values().sum();
|
||||
let total_ask_size_units: i64 = self.asks.values().sum();
|
||||
let total_bid_size_units: i64 = self.bids.values().map(|level| level.qty).sum();
|
||||
let total_ask_size_units: i64 = self.asks.values().map(|level| level.qty).sum();
|
||||
let total_bid_size = qty_to_decimal(total_bid_size_units);
|
||||
let total_ask_size = qty_to_decimal(total_ask_size_units);
|
||||
|
||||
|
||||
+1
-33
@@ -139,10 +139,7 @@ fn process_stream_object<'tape, 'input>(
|
||||
applied += apply_levels(book, Side::SELL, asks)?;
|
||||
}
|
||||
|
||||
book.finish_ws_book_update(
|
||||
|price_ticks| ws_levels_contain_price(bids, price_ticks),
|
||||
|price_ticks| ws_levels_contain_price(asks, price_ticks),
|
||||
);
|
||||
book.finish_ws_book_update();
|
||||
Ok(applied)
|
||||
})?;
|
||||
|
||||
@@ -188,35 +185,6 @@ fn apply_levels<'tape, 'input>(
|
||||
Ok(applied)
|
||||
}
|
||||
|
||||
fn ws_levels_contain_price<'tape, 'input>(
|
||||
levels: Option<simd_json::tape::Array<'tape, 'input>>,
|
||||
price_ticks: Price,
|
||||
) -> bool {
|
||||
let Some(levels) = levels else {
|
||||
return false;
|
||||
};
|
||||
|
||||
levels.iter().any(|level| {
|
||||
let Some(obj) = level.as_object() else {
|
||||
return false;
|
||||
};
|
||||
let Some(price_str) = obj.get("price").and_then(|v| v.into_string()) else {
|
||||
return false;
|
||||
};
|
||||
let Some(size_str) = obj.get("size").and_then(|v| v.into_string()) else {
|
||||
return false;
|
||||
};
|
||||
let Ok(level_price_ticks) = parse_price_ticks_4dp(price_str) else {
|
||||
return false;
|
||||
};
|
||||
let Ok(size_units) = parse_qty_scaled_4dp(size_str) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
size_units != 0 && level_price_ticks == price_ticks
|
||||
})
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn parse_price_ticks_4dp(value: &str) -> Result<Price> {
|
||||
let scaled = parse_scaled_4_u64(value)?;
|
||||
|
||||
Reference in New Issue
Block a user