From 83859229906dec02e19365ec0aea2e3c5d164ed5 Mon Sep 17 00:00:00 2001 From: floor-licker Date: Mon, 22 Jun 2026 21:06:37 -0400 Subject: [PATCH 1/2] fix: expose book snapshot clocks --- src/book.rs | 7 ++++++ src/decode.rs | 2 ++ src/types.rs | 59 ++++++++++++++++++++++++++++++++++++++++++++-- src/ws_hot_path.rs | 6 +++++ 4 files changed, 72 insertions(+), 2 deletions(-) diff --git a/src/book.rs b/src/book.rs index 24be36e..3fa6030 100644 --- a/src/book.rs +++ b/src/book.rs @@ -460,6 +460,8 @@ impl OrderBook { bids: self.bids(None), // Get all bids (up to max_depth) asks: self.asks(None), // Get all asks (up to max_depth) sequence: self.last_delta_sequence, + last_delta_sequence: self.last_delta_sequence, + last_snapshot_timestamp_ms: self.last_snapshot_timestamp_ms, } } @@ -1526,6 +1528,11 @@ mod tests { assert_eq!(book.last_snapshot_timestamp_ms, 1_000); assert_eq!(book.best_bid().unwrap().price, dec!(0.50)); assert_eq!(book.best_ask().unwrap().price, dec!(0.60)); + + let snapshot = book.snapshot(); + assert_eq!(snapshot.sequence, 10_000); + assert_eq!(snapshot.last_delta_sequence, 10_000); + assert_eq!(snapshot.last_snapshot_timestamp_ms, 1_000); } #[test] diff --git a/src/decode.rs b/src/decode.rs index d81624b..75a7a6a 100644 --- a/src/decode.rs +++ b/src/decode.rs @@ -341,6 +341,8 @@ impl Decoder for RawOrderBookResponse { bids, asks, sequence: 0, // TODO: Get from response if available + last_delta_sequence: 0, + last_snapshot_timestamp_ms: 0, }) } } diff --git a/src/types.rs b/src/types.rs index 27d9abd..c3f4da8 100644 --- a/src/types.rs +++ b/src/types.rs @@ -358,7 +358,7 @@ impl FastBookLevel { } /// Full order book state -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize)] pub struct OrderBook { /// Token ID pub token_id: String, @@ -368,8 +368,47 @@ pub struct OrderBook { pub bids: Vec, /// Ask orders pub asks: Vec, - /// Sequence number + /// Legacy/incremental delta sequence number. + /// + /// This is kept for compatibility. For snapshots produced by this client, + /// it is equivalent to [`Self::last_delta_sequence`]. WebSocket snapshot + /// timestamps are exposed separately in [`Self::last_snapshot_timestamp_ms`]. pub sequence: u64, + /// Last accepted legacy/incremental delta sequence number. + pub last_delta_sequence: u64, + /// Last accepted full-book snapshot timestamp in milliseconds. + pub last_snapshot_timestamp_ms: u64, +} + +impl<'de> Deserialize<'de> for OrderBook { + fn deserialize(deserializer: D) -> std::result::Result + where + D: serde::Deserializer<'de>, + { + #[derive(Deserialize)] + struct WireOrderBook { + token_id: String, + timestamp: DateTime, + bids: Vec, + asks: Vec, + sequence: u64, + #[serde(default)] + last_delta_sequence: Option, + #[serde(default)] + last_snapshot_timestamp_ms: Option, + } + + let wire = WireOrderBook::deserialize(deserializer)?; + Ok(Self { + token_id: wire.token_id, + timestamp: wire.timestamp, + bids: wire.bids, + asks: wire.asks, + sequence: wire.sequence, + last_delta_sequence: wire.last_delta_sequence.unwrap_or(wire.sequence), + last_snapshot_timestamp_ms: wire.last_snapshot_timestamp_ms.unwrap_or_default(), + }) + } } /// Order book delta for streaming updates - EXTERNAL API VERSION @@ -1922,4 +1961,20 @@ mod tests { Decimal::from_str("0.00005").unwrap() )); } + + #[test] + fn order_book_snapshot_clocks_default_for_legacy_payloads() { + let json = r#"{ + "token_id":"test_token", + "timestamp":"2026-06-23T00:00:00Z", + "bids":[], + "asks":[], + "sequence":42 + }"#; + + let book: OrderBook = serde_json::from_str(json).unwrap(); + assert_eq!(book.sequence, 42); + assert_eq!(book.last_delta_sequence, 42); + assert_eq!(book.last_snapshot_timestamp_ms, 0); + } } diff --git a/src/ws_hot_path.rs b/src/ws_hot_path.rs index bc42aed..d78d8dc 100644 --- a/src/ws_hot_path.rs +++ b/src/ws_hot_path.rs @@ -371,6 +371,8 @@ mod tests { let snapshot = books.get_book("test_asset_id").unwrap(); assert_eq!(snapshot.sequence, 0); + assert_eq!(snapshot.last_delta_sequence, 0); + assert_eq!(snapshot.last_snapshot_timestamp_ms, 1000); assert_eq!(snapshot.timestamp.timestamp_millis(), 1000); assert_eq!(snapshot.bids.len(), 1); assert_eq!(snapshot.asks.len(), 1); @@ -409,6 +411,8 @@ mod tests { let snapshot = books.get_book("test_asset_id").unwrap(); assert_eq!(snapshot.sequence, 0); + assert_eq!(snapshot.last_delta_sequence, 0); + assert_eq!(snapshot.last_snapshot_timestamp_ms, 1000); assert_eq!(snapshot.timestamp.timestamp_millis(), 1000); assert_eq!(snapshot.bids.len(), 1); assert_eq!(snapshot.asks.len(), 1); @@ -436,6 +440,8 @@ mod tests { let snapshot = books.get_book("test_asset_id").unwrap(); assert_eq!(snapshot.sequence, 0); + assert_eq!(snapshot.last_delta_sequence, 0); + assert_eq!(snapshot.last_snapshot_timestamp_ms, 1000); assert_eq!(snapshot.timestamp.timestamp_millis(), 1000); assert_eq!(snapshot.bids[0].price, dec!(0.51)); assert_eq!(snapshot.bids[0].size, dec!(11)); From ab491d11c6f2bc8a1212fa3b602afbc16a1a3873 Mon Sep 17 00:00:00 2001 From: floor-licker Date: Mon, 22 Jun 2026 21:09:23 -0400 Subject: [PATCH 2/2] docs: clarify websocket snapshot ordering --- README.md | 2 ++ src/book.rs | 9 ++++++--- src/types.rs | 10 ++++++++++ 3 files changed, 18 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index d60e73d..9bd7e5a 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,8 @@ Run the WS hot-path benchmark locally with `cargo bench --bench ws_hot_path`. **Parsing paths:** `polyfill-rs` keeps two parsing layers on purpose. The allocation-sensitive WS `book` path uses `WsBookUpdateProcessor` in `src/ws_hot_path.rs`, which walks a reusable `simd-json` tape and applies fixed-point book levels directly. The generic stream parser in `src/decode.rs` is an ergonomic compatibility path: it parses through `serde_json::Value` so it can tolerate batches, unknown event types, and mixed message shapes. Likewise, several generic numeric/decimal deserializers in `src/decode.rs` accept string-or-number API fields through `serde_json::Value`; they are not the zero-allocation hot path. +**WebSocket snapshot ordering:** Polymarket `book` messages expose a millisecond `timestamp` and optional `hash`, but no monotonic server sequence/version. The book applier treats newer timestamps as newer, rejects older timestamps, suppresses exact same-timestamp/same-hash duplicates, and accepts same-timestamp/different-hash snapshots in websocket arrival order. The hash distinguishes duplicate vs distinct state; it is not a logical ordering key. + **Key Performance Optimizations:** The 21.4% performance improvement comes from HTTP/2 tuning with 512KB stream windows optimized for 469KB payloads, explicit Polymarket request headers, SIMD-backed parsing where the client uses the typed fast-response helper for large REST market responses, and opt-in connection prewarming/keep-alive support. diff --git a/src/book.rs b/src/book.rs index 3fa6030..5c9da00 100644 --- a/src/book.rs +++ b/src/book.rs @@ -601,9 +601,10 @@ impl OrderBook { return Err(PolyfillError::validation("Token ID mismatch")); } - // Use the exchange-provided timestamp as the monotonic snapshot marker. - // If the feed emits two snapshots in the same millisecond, a different hash is treated as - // a distinct newer state. Equal timestamp without a hash is considered duplicate/stale. + // Use the exchange-provided timestamp as the primary snapshot marker. The WS `book` + // message does not expose a monotonic server sequence/version, so same-millisecond + // snapshots with different hashes are ordered by websocket arrival order. The hash + // distinguishes duplicate vs distinct state; it is not a logical ordering key. // Snapshot timestamps are intentionally separate from legacy delta sequence numbers. if !self.should_apply_snapshot(update.timestamp, update.hash.as_deref()) { return Ok(()); @@ -725,6 +726,8 @@ impl OrderBook { #[inline] fn should_apply_snapshot(&self, timestamp: u64, hash: Option<&str>) -> bool { + // Without a server sequence/version, equal-timestamp distinct hashes can only be ordered + // by arrival. Older timestamps are always stale; exact same timestamp/hash is duplicate. if timestamp > self.last_snapshot_timestamp_ms { return true; } diff --git a/src/types.rs b/src/types.rs index c3f4da8..bee884b 100644 --- a/src/types.rs +++ b/src/types.rs @@ -976,16 +976,26 @@ pub enum StreamMessage { } /// Orderbook update message (full snapshot or delta). +/// +/// WebSocket `book` messages expose a millisecond timestamp and optional book hash, but no +/// monotonic server sequence/version. Same-timestamp messages with different hashes are therefore +/// ordered by websocket arrival order by the book applier; the hash is a duplicate/state +/// discriminator, not a logical ordering key. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct BookUpdate { pub asset_id: String, pub market: String, + /// Exchange-provided snapshot timestamp in milliseconds. #[serde(deserialize_with = "crate::decode::deserializers::number_from_string")] pub timestamp: u64, #[serde(deserialize_with = "crate::decode::deserializers::vec_from_null")] pub bids: Vec, #[serde(deserialize_with = "crate::decode::deserializers::vec_from_null")] pub asks: Vec, + /// Exchange-provided snapshot hash. + /// + /// Used to suppress exact duplicate same-timestamp snapshots and to allow distinct + /// same-timestamp states. It does not encode ordering. #[serde(default)] pub hash: Option, }