Skip to main content

nautilus_polymarket/websocket/
parse.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! Parse functions for converting Polymarket WebSocket messages to Nautilus data types.
17
18use aws_lc_rs::digest::{SHA1_FOR_LEGACY_USE_ONLY, digest};
19use nautilus_core::{
20    UnixNanos,
21    correctness::{CorrectnessError, CorrectnessResult},
22    datetime::NANOSECONDS_IN_MILLISECOND,
23    hex,
24};
25use nautilus_model::{
26    data::{BookOrder, OrderBookDelta, OrderBookDeltas, QuoteTick, TradeTick},
27    enums::{AggressorSide, BookAction, OrderSide, RecordFlag},
28    identifiers::InstrumentId,
29    types::{Price, Quantity},
30};
31use rust_decimal::Decimal;
32use serde::Serialize;
33
34use super::messages::{
35    PolymarketBestBidAsk, PolymarketBookLevel, PolymarketBookSnapshot, PolymarketQuote,
36    PolymarketTrade,
37};
38use crate::common::{
39    enums::PolymarketOrderSide,
40    parse::{determine_trade_id, parse_decimal_exact},
41};
42
43/// Parses a millisecond epoch timestamp string into [`UnixNanos`].
44pub fn parse_timestamp_ms(ts: &str) -> anyhow::Result<UnixNanos> {
45    let ms: u64 = ts
46        .parse()
47        .map_err(|e| anyhow::anyhow!("Invalid timestamp '{ts}': {e}"))?;
48    let ns = ms
49        .checked_mul(NANOSECONDS_IN_MILLISECOND)
50        .ok_or_else(|| anyhow::anyhow!("Timestamp overflow for '{ts}'"))?;
51    Ok(UnixNanos::from(ns))
52}
53
54pub(crate) fn parse_price(s: &str, precision: u8) -> CorrectnessResult<Price> {
55    let value = parse_decimal_exact(s).map_err(|e| CorrectnessError::PredicateViolation {
56        message: format!("Invalid price '{s}': {e}"),
57    })?;
58    Price::from_decimal_dp(value, precision)
59}
60
61pub(crate) fn parse_quantity(s: &str, precision: u8) -> CorrectnessResult<Quantity> {
62    let value = parse_decimal_exact(s).map_err(|e| CorrectnessError::PredicateViolation {
63        message: format!("Invalid quantity '{s}': {e}"),
64    })?;
65    Quantity::from_decimal_dp(value, precision)
66}
67
68pub(crate) fn verify_book_snapshot_hash(
69    snap: &PolymarketBookSnapshot,
70    min_order_size: Option<&str>,
71    neg_risk: Option<bool>,
72) -> anyhow::Result<bool> {
73    let Some(expected) = snap.hash.as_deref() else {
74        return Ok(false);
75    };
76
77    let Some(computed) = book_snapshot_hash(snap, min_order_size, neg_risk)? else {
78        return Ok(false);
79    };
80
81    if computed != expected {
82        anyhow::bail!(
83            "Book snapshot hash mismatch for {}: expected {expected}, computed {computed}",
84            snap.asset_id
85        );
86    }
87
88    Ok(true)
89}
90
91fn book_snapshot_hash(
92    snap: &PolymarketBookSnapshot,
93    min_order_size: Option<&str>,
94    neg_risk: Option<bool>,
95) -> anyhow::Result<Option<String>> {
96    let Some(min_order_size) = snap.min_order_size.as_deref().or(min_order_size) else {
97        return Ok(None);
98    };
99
100    let Some(tick_size) = snap.tick_size.as_deref() else {
101        return Ok(None);
102    };
103
104    let Some(neg_risk) = snap.neg_risk.or(neg_risk) else {
105        return Ok(None);
106    };
107
108    let Some(last_trade_price) = snap.last_trade_price.as_deref() else {
109        return Ok(None);
110    };
111
112    // Keep field order aligned with the server-compatible payload in the official SDK:
113    // Polymarket/py-clob-client-v2@215fc63a8fd6ec3a10c7edb73997c9772d8686d3:utilities.py
114    let preimage = BookSnapshotHashPreimage {
115        market: snap.market.as_str(),
116        asset_id: snap.asset_id.as_str(),
117        timestamp: &snap.timestamp,
118        hash: "",
119        bids: &snap.bids,
120        asks: &snap.asks,
121        min_order_size,
122        tick_size,
123        neg_risk,
124        last_trade_price,
125    };
126
127    let serialized = serde_json::to_vec(&preimage)?;
128    let hash = digest(&SHA1_FOR_LEGACY_USE_ONLY, &serialized);
129
130    Ok(Some(hex::encode(hash)))
131}
132
133#[derive(Serialize)]
134struct BookSnapshotHashPreimage<'a> {
135    market: &'a str,
136    asset_id: &'a str,
137    timestamp: &'a str,
138    hash: &'static str,
139    bids: &'a [PolymarketBookLevel],
140    asks: &'a [PolymarketBookLevel],
141    min_order_size: &'a str,
142    tick_size: &'a str,
143    neg_risk: bool,
144    last_trade_price: &'a str,
145}
146
147/// Parses a book snapshot into [`OrderBookDeltas`] (CLEAR + ADD).
148///
149/// A book with no resting orders is a valid snapshot: it parses to a lone
150/// CLEAR so the baseline is accepted instead of burning recovery budget.
151pub fn parse_book_snapshot(
152    snap: &PolymarketBookSnapshot,
153    instrument_id: InstrumentId,
154    price_precision: u8,
155    size_precision: u8,
156    ts_init: UnixNanos,
157) -> anyhow::Result<OrderBookDeltas> {
158    let ts_event = parse_timestamp_ms(&snap.timestamp)?;
159
160    let bids_len = snap.bids.len();
161    let asks_len = snap.asks.len();
162
163    let total = bids_len + asks_len;
164    let mut deltas = Vec::with_capacity(total + 1);
165
166    // Every snapshot delta (including the opening CLEAR) carries F_SNAPSHOT so
167    // downstream consumers can recognize the rebuild; F_LAST closes the batch
168    // on the final delta. `OrderBookDelta::clear` already sets F_SNAPSHOT.
169    let snapshot_flag = RecordFlag::F_SNAPSHOT as u8;
170    let mut clear = OrderBookDelta::clear(instrument_id, 0, ts_event, ts_init);
171
172    if total == 0 {
173        clear.flags |= RecordFlag::F_LAST as u8;
174    }
175
176    deltas.push(clear);
177
178    let mut count = 0;
179
180    for level in &snap.bids {
181        count += 1;
182        let price = parse_price(&level.price, price_precision)?;
183        let size = parse_quantity(&level.size, size_precision)?;
184        let order = BookOrder::new(OrderSide::Buy, price, size, 0);
185
186        let mut flags = snapshot_flag;
187        if count == total {
188            flags |= RecordFlag::F_LAST as u8;
189        }
190
191        deltas.push(OrderBookDelta::new_checked(
192            instrument_id,
193            BookAction::Add,
194            order,
195            flags,
196            0,
197            ts_event,
198            ts_init,
199        )?);
200    }
201
202    for level in &snap.asks {
203        count += 1;
204        let price = parse_price(&level.price, price_precision)?;
205        let size = parse_quantity(&level.size, size_precision)?;
206        let order = BookOrder::new(OrderSide::Sell, price, size, 0);
207
208        let mut flags = snapshot_flag;
209        if count == total {
210            flags |= RecordFlag::F_LAST as u8;
211        }
212
213        deltas.push(OrderBookDelta::new_checked(
214            instrument_id,
215            BookAction::Add,
216            order,
217            flags,
218            0,
219            ts_event,
220            ts_init,
221        )?);
222    }
223
224    Ok(OrderBookDeltas::new(instrument_id, deltas))
225}
226
227/// Parses price change quotes into incremental book deltas.
228///
229/// Each result corresponds to one quote. The final successful delta carries
230/// [`RecordFlag::F_LAST`], including when later quotes fail to parse.
231pub fn parse_book_deltas(
232    quotes: &[&PolymarketQuote],
233    instrument_id: InstrumentId,
234    price_precision: u8,
235    size_precision: u8,
236    ts_event: UnixNanos,
237    ts_init: UnixNanos,
238) -> Vec<anyhow::Result<OrderBookDelta>> {
239    let mut deltas = quotes
240        .iter()
241        .map(|change| {
242            parse_book_delta(
243                change,
244                instrument_id,
245                price_precision,
246                size_precision,
247                ts_event,
248                ts_init,
249            )
250        })
251        .collect::<Vec<_>>();
252
253    if let Some(delta) = deltas
254        .iter_mut()
255        .rev()
256        .find_map(|result| result.as_mut().ok())
257    {
258        delta.flags |= RecordFlag::F_LAST as u8;
259    }
260
261    deltas
262}
263
264fn parse_book_delta(
265    change: &PolymarketQuote,
266    instrument_id: InstrumentId,
267    price_precision: u8,
268    size_precision: u8,
269    ts_event: UnixNanos,
270    ts_init: UnixNanos,
271) -> anyhow::Result<OrderBookDelta> {
272    let price = parse_price(&change.price, price_precision)?;
273    let size = parse_quantity(&change.size, size_precision)?;
274    let side = match change.side {
275        PolymarketOrderSide::Buy => OrderSide::Buy,
276        PolymarketOrderSide::Sell => OrderSide::Sell,
277    };
278    let (action, order_size) = if size.is_zero() {
279        (BookAction::Delete, Quantity::zero(size_precision))
280    } else {
281        (BookAction::Update, size)
282    };
283    let order = BookOrder::new(side, price, order_size, 0);
284
285    OrderBookDelta::new_checked(instrument_id, action, order, 0, 0, ts_event, ts_init)
286}
287
288/// Parses a trade message into a [`TradeTick`].
289pub fn parse_trade_tick(
290    trade: &PolymarketTrade,
291    instrument_id: InstrumentId,
292    price_precision: u8,
293    size_precision: u8,
294    ts_init: UnixNanos,
295) -> anyhow::Result<TradeTick> {
296    let price = parse_price(&trade.price, price_precision)?;
297    let size = parse_quantity(&trade.size, size_precision)?;
298    let aggressor_side = match trade.side {
299        PolymarketOrderSide::Buy => AggressorSide::Buy,
300        PolymarketOrderSide::Sell => AggressorSide::Sell,
301    };
302    let ts_event = parse_timestamp_ms(&trade.timestamp)?;
303
304    let trade_id = determine_trade_id(
305        &trade.asset_id,
306        trade.side,
307        &trade.price,
308        &trade.size,
309        &trade.timestamp,
310    );
311
312    TradeTick::new_checked(
313        instrument_id,
314        price,
315        size,
316        aggressor_side,
317        trade_id,
318        ts_event,
319        ts_init,
320    )
321}
322
323/// Extracts a top-of-book [`QuoteTick`] from a book snapshot.
324///
325/// Returns `None` if either side is empty and `drop_quotes_missing_side` is enabled.
326pub fn parse_quote_from_snapshot(
327    snap: &PolymarketBookSnapshot,
328    instrument_id: InstrumentId,
329    price_precision: u8,
330    size_precision: u8,
331    price_increment: Price,
332    drop_quotes_missing_side: bool,
333    ts_init: UnixNanos,
334) -> anyhow::Result<Option<QuoteTick>> {
335    if drop_quotes_missing_side && (snap.bids.is_empty() || snap.asks.is_empty()) {
336        return Ok(None);
337    }
338
339    let ts_event = parse_timestamp_ms(&snap.timestamp)?;
340    let (min_price, max_price) = quote_price_bounds(price_increment, price_increment.as_decimal())?;
341
342    // Polymarket sends bids ascending and asks descending, so best-of-book is last.
343    let (bid_price, bid_size) = match snap.bids.last() {
344        Some(best_bid) => (
345            parse_price(&best_bid.price, price_precision)?,
346            parse_quantity(&best_bid.size, size_precision)?,
347        ),
348        None => (min_price, Quantity::zero(size_precision)),
349    };
350    let (ask_price, ask_size) = match snap.asks.last() {
351        Some(best_ask) => (
352            parse_price(&best_ask.price, price_precision)?,
353            parse_quantity(&best_ask.size, size_precision)?,
354        ),
355        None => (max_price, Quantity::zero(size_precision)),
356    };
357
358    Ok(Some(QuoteTick::new_checked(
359        instrument_id,
360        bid_price,
361        ask_price,
362        bid_size,
363        ask_size,
364        ts_event,
365        ts_init,
366    )?))
367}
368
369/// Parses a quote tick from a price change message using its best_bid/best_ask fields.
370///
371/// Returns `None` when either top-of-book side is absent or at the resolution
372/// boundary and `drop_quotes_missing_side` is enabled.
373/// Returns `None` for locked or crossed top-of-book prices.
374/// When `last_quote` is provided the opposite side's size is carried forward
375/// instead of being set to zero, matching the Python adapter's behavior.
376#[expect(clippy::too_many_arguments)]
377pub fn parse_quote_from_price_change(
378    quote: &PolymarketQuote,
379    instrument_id: InstrumentId,
380    price_precision: u8,
381    size_precision: u8,
382    price_increment: Price,
383    drop_quotes_missing_side: bool,
384    last_quote: Option<&QuoteTick>,
385    ts_event: UnixNanos,
386    ts_init: UnixNanos,
387) -> anyhow::Result<Option<QuoteTick>> {
388    let bid_top = parse_bid_top(quote.best_bid.as_deref(), price_precision)?;
389    let ask_top = parse_ask_top(quote.best_ask.as_deref(), price_precision)?;
390    if drop_quotes_missing_side && (bid_top.is_none() || ask_top.is_none()) {
391        return Ok(None);
392    }
393
394    let (min_price, max_price) = quote_price_bounds(price_increment, price_increment.as_decimal())?;
395    let bid_missing = bid_top.is_none();
396    let ask_missing = ask_top.is_none();
397    let bid_price = match bid_top {
398        Some(price) => price,
399        None => min_price,
400    };
401    let ask_price = match ask_top {
402        Some(price) => price,
403        None => max_price,
404    };
405
406    if !bid_missing && !ask_missing && bid_price >= ask_price {
407        return Ok(None);
408    }
409
410    let changed_price = parse_price(&quote.price, price_precision)?;
411
412    let size = parse_quantity(&quote.size, size_precision)?;
413    let zero = || Quantity::zero(size_precision);
414
415    // Only use the changed level's size when it matches the best price,
416    // otherwise preserve the previous quote's size for that side
417    let (bid_size, ask_size) = match quote.side {
418        PolymarketOrderSide::Buy => {
419            let bid_size = if bid_missing {
420                zero()
421            } else if changed_price == bid_price {
422                size
423            } else {
424                last_quote.map_or_else(zero, |q| q.bid_size)
425            };
426            let ask_size = if ask_missing {
427                zero()
428            } else {
429                last_quote.map_or_else(zero, |q| q.ask_size)
430            };
431            (bid_size, ask_size)
432        }
433        PolymarketOrderSide::Sell => {
434            let ask_size = if ask_missing {
435                zero()
436            } else if changed_price == ask_price {
437                size
438            } else {
439                last_quote.map_or_else(zero, |q| q.ask_size)
440            };
441            let bid_size = if bid_missing {
442                zero()
443            } else {
444                last_quote.map_or_else(zero, |q| q.bid_size)
445            };
446            (bid_size, ask_size)
447        }
448    };
449
450    Ok(Some(QuoteTick::new_checked(
451        instrument_id,
452        bid_price,
453        ask_price,
454        bid_size,
455        ask_size,
456        ts_event,
457        ts_init,
458    )?))
459}
460
461enum BestBidAskTop {
462    Missing,
463    Invalid,
464    Price(Price),
465}
466
467/// Parses a quote tick from a best bid/ask message.
468///
469/// The payload carries top-of-book prices only. Each side's size comes from the supplied known
470/// level when its price matches the message and is zero otherwise.
471///
472/// Returns `None` when a side is missing and `drop_quotes_missing_side` is enabled. When missing
473/// sides are allowed, the quote uses the current tick-relative venue bounds. Returns `None` for
474/// locked, crossed, out-of-range, or off-grid prices.
475#[expect(clippy::too_many_arguments)]
476pub fn parse_quote_from_best_bid_ask(
477    bba: &PolymarketBestBidAsk,
478    instrument_id: InstrumentId,
479    price_precision: u8,
480    size_precision: u8,
481    price_increment: Price,
482    drop_quotes_missing_side: bool,
483    bid_top: Option<(Price, Quantity)>,
484    ask_top: Option<(Price, Quantity)>,
485    ts_event: UnixNanos,
486    ts_init: UnixNanos,
487) -> anyhow::Result<Option<QuoteTick>> {
488    let tick_size = price_increment.as_decimal();
489    let bid = parse_best_bid_ask_top(
490        non_empty(&bba.best_bid),
491        price_precision,
492        tick_size,
493        |value| value <= Decimal::ZERO,
494    )?;
495    let ask = parse_best_bid_ask_top(
496        non_empty(&bba.best_ask),
497        price_precision,
498        tick_size,
499        |value| value >= Decimal::ONE,
500    )?;
501    let (bid, ask) = match (bid, ask) {
502        (BestBidAskTop::Invalid, _) | (_, BestBidAskTop::Invalid) => return Ok(None),
503        (BestBidAskTop::Missing, BestBidAskTop::Missing) => (None, None),
504        (BestBidAskTop::Missing, BestBidAskTop::Price(ask)) => (None, Some(ask)),
505        (BestBidAskTop::Price(bid), BestBidAskTop::Missing) => (Some(bid), None),
506        (BestBidAskTop::Price(bid), BestBidAskTop::Price(ask)) => (Some(bid), Some(ask)),
507    };
508
509    if drop_quotes_missing_side && (bid.is_none() || ask.is_none()) {
510        return Ok(None);
511    }
512
513    let (min_price, max_price) = quote_price_bounds(price_increment, tick_size)?;
514    let bid_price = bid.unwrap_or(min_price);
515    let ask_price = ask.unwrap_or(max_price);
516    if bid_price < min_price || ask_price > max_price || bid_price >= ask_price {
517        return Ok(None);
518    }
519
520    let size_at = |price: Option<Price>, top: Option<(Price, Quantity)>| match (price, top) {
521        (Some(price), Some((top_price, top_size))) if top_price == price => top_size,
522        _ => Quantity::zero(size_precision),
523    };
524
525    Ok(Some(QuoteTick::new_checked(
526        instrument_id,
527        bid_price,
528        ask_price,
529        size_at(bid, bid_top),
530        size_at(ask, ask_top),
531        ts_event,
532        ts_init,
533    )?))
534}
535
536fn quote_price_bounds(
537    price_increment: Price,
538    tick_size: Decimal,
539) -> anyhow::Result<(Price, Price)> {
540    let max_price = Price::from_decimal_dp(Decimal::ONE - tick_size, price_increment.precision)?;
541    Ok((price_increment, max_price))
542}
543
544fn parse_best_bid_ask_top(
545    value: Option<&str>,
546    precision: u8,
547    tick_size: Decimal,
548    is_missing: impl FnOnce(Decimal) -> bool,
549) -> CorrectnessResult<BestBidAskTop> {
550    let Some(value) = value else {
551        return Ok(BestBidAskTop::Missing);
552    };
553    let decimal = parse_decimal_exact(value).map_err(|e| CorrectnessError::PredicateViolation {
554        message: format!("Invalid price '{value}': {e}"),
555    })?;
556
557    if is_missing(decimal) {
558        return Ok(BestBidAskTop::Missing);
559    }
560
561    let price = Price::from_decimal_dp(decimal, precision)?;
562    if price.as_decimal() != decimal || decimal % tick_size != Decimal::ZERO {
563        return Ok(BestBidAskTop::Invalid);
564    }
565
566    Ok(BestBidAskTop::Price(price))
567}
568
569fn non_empty(value: &str) -> Option<&str> {
570    let value = value.trim();
571    (!value.is_empty()).then_some(value)
572}
573
574fn parse_bid_top(value: Option<&str>, precision: u8) -> CorrectnessResult<Option<Price>> {
575    parse_top_price(value, precision, |value| value <= Decimal::ZERO)
576}
577
578fn parse_ask_top(value: Option<&str>, precision: u8) -> CorrectnessResult<Option<Price>> {
579    parse_top_price(value, precision, |value| value >= Decimal::ONE)
580}
581
582fn parse_top_price(
583    value: Option<&str>,
584    precision: u8,
585    is_missing: impl FnOnce(Decimal) -> bool,
586) -> CorrectnessResult<Option<Price>> {
587    let Some(value) = value else {
588        return Ok(None);
589    };
590    let decimal = parse_decimal_exact(value).map_err(|e| CorrectnessError::PredicateViolation {
591        message: format!("Invalid price '{value}': {e}"),
592    })?;
593
594    if is_missing(decimal) {
595        return Ok(None);
596    }
597
598    Ok(Some(Price::from_decimal_dp(decimal, precision)?))
599}
600
601#[cfg(test)]
602mod tests {
603    use nautilus_core::UnixNanos;
604    use nautilus_model::instruments::{Instrument, InstrumentAny};
605    use rstest::rstest;
606    use ustr::Ustr;
607
608    use super::*;
609    use crate::{
610        http::parse::{
611            create_instrument_from_def, parse_gamma_market, rebuild_instrument_with_tick_size,
612        },
613        websocket::messages::PolymarketQuotes,
614    };
615
616    fn load<T: serde::de::DeserializeOwned>(filename: &str) -> T {
617        let content =
618            std::fs::read_to_string(format!("test_data/{filename}")).expect("test data missing");
619        serde_json::from_str(&content).expect("parse failed")
620    }
621
622    fn test_instrument() -> InstrumentAny {
623        let market: crate::http::models::GammaMarket = load("gamma_market.json");
624        let defs = parse_gamma_market(&market).unwrap();
625        create_instrument_from_def(&defs[0], UnixNanos::from(1_000_000_000u64)).unwrap()
626    }
627
628    fn test_instrument_with_tick(tick_size: &str) -> InstrumentAny {
629        let instrument = test_instrument();
630        let ts = UnixNanos::from(1_000_000_000u64);
631        rebuild_instrument_with_tick_size(&instrument, tick_size, ts, ts).unwrap()
632    }
633
634    #[rstest]
635    fn test_parse_timestamp_ms() {
636        let ns = parse_timestamp_ms("1703875200000").unwrap();
637        assert_eq!(ns, UnixNanos::from(1_703_875_200_000_000_000u64));
638    }
639
640    #[rstest]
641    fn test_parse_timestamp_ms_invalid() {
642        assert!(parse_timestamp_ms("not_a_number").is_err());
643    }
644
645    #[rstest]
646    fn test_book_snapshot_hash_matches_captured_snapshot() {
647        let snap: PolymarketBookSnapshot = load("ws_book_snapshot_captured.json");
648
649        assert_eq!(snap.min_order_size, None);
650        assert_eq!(snap.neg_risk, None);
651        assert_eq!(snap.tick_size.as_deref(), Some("0.01"));
652        assert_eq!(snap.last_trade_price.as_deref(), Some("0.920"));
653        assert_eq!(
654            book_snapshot_hash(&snap, Some("5"), Some(false)).unwrap(),
655            Some("ed47eb91f3c7985fac1cb18cb7c19535eddd3c0a".to_string())
656        );
657        assert!(verify_book_snapshot_hash(&snap, Some("5"), Some(false)).unwrap());
658    }
659
660    #[rstest]
661    fn test_book_snapshot_hash_rejects_mismatch() {
662        let mut snap: PolymarketBookSnapshot = load("ws_book_snapshot_captured.json");
663        snap.bids[0].size = "3149725.71".to_string();
664
665        let error = verify_book_snapshot_hash(&snap, Some("5"), Some(false)).unwrap_err();
666
667        assert_eq!(
668            error.to_string(),
669            concat!(
670                "Book snapshot hash mismatch for ",
671                "350977769852917329387037893294763093471844346281449484439085576212613048126: ",
672                "expected ed47eb91f3c7985fac1cb18cb7c19535eddd3c0a, ",
673                "computed 6402b534c270a1ce46a75c62f1d7e3651182cc75"
674            )
675        );
676    }
677
678    #[rstest]
679    fn test_book_snapshot_hash_allows_missing_hash() {
680        let snap: PolymarketBookSnapshot = load("ws_book_snapshot_missing_hash.json");
681
682        assert!(!verify_book_snapshot_hash(&snap, None, None).unwrap());
683    }
684
685    #[rstest]
686    fn test_book_snapshot_hash_allows_incomplete_preimage() {
687        let mut snap: PolymarketBookSnapshot = load("ws_book_snapshot_captured.json");
688        snap.tick_size = None;
689        snap.last_trade_price = None;
690
691        assert!(!verify_book_snapshot_hash(&snap, Some("5"), Some(false)).unwrap());
692    }
693
694    #[rstest]
695    fn test_parse_book_snapshot() {
696        let snap: PolymarketBookSnapshot = load("ws_book_snapshot.json");
697        let instrument = test_instrument();
698        let ts_init = UnixNanos::from(1_000_000_000u64);
699
700        let deltas = parse_book_snapshot(
701            &snap,
702            instrument.id(),
703            instrument.price_precision(),
704            instrument.size_precision(),
705            ts_init,
706        )
707        .unwrap();
708
709        // CLEAR + 3 bids + 3 asks = 7 deltas
710        assert_eq!(deltas.deltas.len(), 7);
711        assert_eq!(deltas.deltas[0].action, BookAction::Clear);
712        assert_eq!(deltas.deltas[1].action, BookAction::Add);
713        assert_eq!(deltas.deltas[1].order.side, Some(OrderSide::Buy));
714        assert_eq!(deltas.deltas[4].action, BookAction::Add);
715        assert_eq!(deltas.deltas[4].order.side, Some(OrderSide::Sell));
716
717        // Every snapshot delta carries F_SNAPSHOT
718        for delta in &deltas.deltas {
719            assert_ne!(delta.flags & RecordFlag::F_SNAPSHOT as u8, 0);
720        }
721
722        // Exactly one delta carries F_LAST, and it must be the last one
723        let f_last_count = deltas
724            .deltas
725            .iter()
726            .filter(|d| d.flags & RecordFlag::F_LAST as u8 != 0)
727            .count();
728        assert_eq!(f_last_count, 1);
729        assert_ne!(
730            deltas.deltas.last().unwrap().flags & RecordFlag::F_LAST as u8,
731            0
732        );
733    }
734
735    #[rstest]
736    fn test_parse_book_snapshot_empty_book_parses_to_lone_clear() {
737        let mut snap: PolymarketBookSnapshot = load("ws_book_snapshot.json");
738        snap.bids.clear();
739        snap.asks.clear();
740        let instrument = test_instrument();
741        let ts_init = UnixNanos::from(1_000_000_000u64);
742
743        let deltas = parse_book_snapshot(
744            &snap,
745            instrument.id(),
746            instrument.price_precision(),
747            instrument.size_precision(),
748            ts_init,
749        )
750        .unwrap();
751
752        assert_eq!(deltas.deltas.len(), 1);
753        assert_eq!(deltas.deltas[0].action, BookAction::Clear);
754        assert_ne!(deltas.deltas[0].flags & RecordFlag::F_SNAPSHOT as u8, 0);
755        assert_ne!(deltas.deltas[0].flags & RecordFlag::F_LAST as u8, 0);
756    }
757
758    #[rstest]
759    fn test_parse_book_deltas() {
760        let quotes: PolymarketQuotes = load("ws_quotes.json");
761        let instrument = test_instrument();
762        let ts_event = parse_timestamp_ms(&quotes.timestamp).unwrap();
763        let ts_init = UnixNanos::from(1_000_000_000u64);
764        let changes = quotes.price_changes.iter().collect::<Vec<_>>();
765
766        let deltas = parse_book_deltas(
767            &changes,
768            instrument.id(),
769            instrument.price_precision(),
770            instrument.size_precision(),
771            ts_event,
772            ts_init,
773        )
774        .into_iter()
775        .collect::<anyhow::Result<Vec<_>>>()
776        .unwrap();
777
778        assert_eq!(deltas.len(), 2);
779
780        // Exactly one delta carries F_LAST, and it must be the last one
781        let f_last_count = deltas
782            .iter()
783            .filter(|d| d.flags & RecordFlag::F_LAST as u8 != 0)
784            .count();
785        assert_eq!(f_last_count, 1);
786        assert_ne!(deltas.last().unwrap().flags & RecordFlag::F_LAST as u8, 0);
787    }
788
789    #[rstest]
790    fn test_parse_book_deltas_zero_size_is_delete() {
791        let mut quotes: PolymarketQuotes = load("ws_quotes.json");
792        quotes.price_changes[0].size = "0".to_string();
793        let instrument = test_instrument();
794        let ts_event = parse_timestamp_ms(&quotes.timestamp).unwrap();
795        let ts_init = UnixNanos::from(1_000_000_000u64);
796        let changes = quotes.price_changes.iter().collect::<Vec<_>>();
797
798        let deltas = parse_book_deltas(
799            &changes,
800            instrument.id(),
801            instrument.price_precision(),
802            instrument.size_precision(),
803            ts_event,
804            ts_init,
805        )
806        .into_iter()
807        .collect::<anyhow::Result<Vec<_>>>()
808        .unwrap();
809
810        assert_eq!(deltas[0].action, BookAction::Delete);
811    }
812
813    #[rstest]
814    fn test_parse_trade_tick() {
815        let trade: PolymarketTrade = load("ws_last_trade.json");
816        let instrument = test_instrument();
817        let ts_init = UnixNanos::from(1_000_000_000u64);
818
819        let tick = parse_trade_tick(
820            &trade,
821            instrument.id(),
822            instrument.price_precision(),
823            instrument.size_precision(),
824            ts_init,
825        )
826        .unwrap();
827
828        assert_eq!(tick.instrument_id, instrument.id());
829        assert_eq!(tick.aggressor_side, AggressorSide::Buy);
830        assert_eq!(tick.ts_event, UnixNanos::from(1_703_875_202_000_000_000u64));
831    }
832
833    #[rstest]
834    fn test_parse_trade_tick_deterministic_id() {
835        let trade: PolymarketTrade = load("ws_last_trade.json");
836        let instrument = test_instrument();
837        let ts_init = UnixNanos::from(1_000_000_000u64);
838
839        let tick1 = parse_trade_tick(
840            &trade,
841            instrument.id(),
842            instrument.price_precision(),
843            instrument.size_precision(),
844            ts_init,
845        )
846        .unwrap();
847        let tick2 = parse_trade_tick(
848            &trade,
849            instrument.id(),
850            instrument.price_precision(),
851            instrument.size_precision(),
852            ts_init,
853        )
854        .unwrap();
855
856        assert_eq!(tick1.trade_id, tick2.trade_id);
857    }
858
859    #[rstest]
860    fn test_parse_quote_from_snapshot() {
861        let snap: PolymarketBookSnapshot = load("ws_book_snapshot.json");
862        let instrument = test_instrument();
863        let ts_init = UnixNanos::from(1_000_000_000u64);
864
865        let quote = parse_quote_from_snapshot(
866            &snap,
867            instrument.id(),
868            instrument.price_precision(),
869            instrument.size_precision(),
870            instrument.price_increment(),
871            true,
872            ts_init,
873        )
874        .unwrap()
875        .unwrap();
876
877        assert_eq!(quote.instrument_id, instrument.id());
878        assert_eq!(quote.bid_price, Price::from("0.50"));
879        assert_eq!(quote.ask_price, Price::from("0.51"));
880        assert_eq!(
881            quote.ts_event,
882            UnixNanos::from(1_703_875_200_000_000_000u64)
883        );
884    }
885
886    #[rstest]
887    fn test_parse_quote_from_snapshot_empty_side_returns_none() {
888        let mut snap: PolymarketBookSnapshot = load("ws_book_snapshot.json");
889        snap.bids.clear();
890        let instrument = test_instrument();
891        let ts_init = UnixNanos::from(1_000_000_000u64);
892
893        let result = parse_quote_from_snapshot(
894            &snap,
895            instrument.id(),
896            instrument.price_precision(),
897            instrument.size_precision(),
898            instrument.price_increment(),
899            true,
900            ts_init,
901        )
902        .unwrap();
903
904        assert!(result.is_none());
905    }
906
907    #[rstest]
908    fn test_parse_quote_from_snapshot_empty_side_uses_boundary_when_drop_disabled() {
909        let mut snap: PolymarketBookSnapshot = load("ws_book_snapshot.json");
910        snap.asks.clear();
911        let instrument = test_instrument_with_tick("0.005");
912        let ts_init = UnixNanos::from(1_000_000_000u64);
913
914        let quote = parse_quote_from_snapshot(
915            &snap,
916            instrument.id(),
917            instrument.price_precision(),
918            instrument.size_precision(),
919            instrument.price_increment(),
920            false,
921            ts_init,
922        )
923        .unwrap()
924        .expect("quote should use boundary ask when drop is disabled");
925
926        assert_eq!(quote.bid_price, Price::from("0.50"));
927        assert_eq!(quote.bid_size, Quantity::from("200.00"));
928        assert_eq!(quote.ask_price, Price::from("0.995"));
929        assert_eq!(quote.ask_size, Quantity::from("0.00"));
930    }
931
932    #[rstest]
933    fn test_parse_quote_from_snapshot_empty_bid_uses_boundary_when_drop_disabled() {
934        let mut snap: PolymarketBookSnapshot = load("ws_book_snapshot.json");
935        snap.bids.clear();
936        let instrument = test_instrument_with_tick("0.0025");
937        let ts_init = UnixNanos::from(1_000_000_000u64);
938
939        let quote = parse_quote_from_snapshot(
940            &snap,
941            instrument.id(),
942            instrument.price_precision(),
943            instrument.size_precision(),
944            instrument.price_increment(),
945            false,
946            ts_init,
947        )
948        .unwrap()
949        .expect("quote should use boundary bid when drop is disabled");
950
951        assert_eq!(quote.bid_price, Price::from("0.0025"));
952        assert_eq!(quote.bid_size, Quantity::from("0.00"));
953        assert_eq!(quote.ask_price, Price::from("0.51"));
954        assert_eq!(quote.ask_size, Quantity::from("150.00"));
955    }
956
957    #[rstest]
958    fn test_parse_quote_from_price_change() {
959        let quotes: PolymarketQuotes = load("ws_quotes.json");
960        let instrument = test_instrument();
961        let ts_event = parse_timestamp_ms(&quotes.timestamp).unwrap();
962        let ts_init = UnixNanos::from(1_000_000_000u64);
963
964        let quote = parse_quote_from_price_change(
965            &quotes.price_changes[0],
966            instrument.id(),
967            instrument.price_precision(),
968            instrument.size_precision(),
969            instrument.price_increment(),
970            true,
971            None,
972            ts_event,
973            ts_init,
974        )
975        .unwrap()
976        .expect("quote should be Some when best_bid/best_ask present");
977
978        assert_eq!(quote.instrument_id, instrument.id());
979    }
980
981    #[rstest]
982    #[case(None)]
983    #[case(Some("1"))]
984    fn test_parse_quote_from_price_change_missing_side_drops_by_default(
985        #[case] best_ask: Option<&str>,
986    ) {
987        let mut quotes: PolymarketQuotes = load("ws_quotes.json");
988        quotes.price_changes[0].best_ask = best_ask.map(str::to_string);
989        let instrument = test_instrument();
990        let ts_event = parse_timestamp_ms(&quotes.timestamp).unwrap();
991        let ts_init = UnixNanos::from(1_000_000_000u64);
992
993        let result = parse_quote_from_price_change(
994            &quotes.price_changes[0],
995            instrument.id(),
996            instrument.price_precision(),
997            instrument.size_precision(),
998            instrument.price_increment(),
999            true,
1000            None,
1001            ts_event,
1002            ts_init,
1003        )
1004        .unwrap();
1005
1006        assert!(result.is_none());
1007    }
1008
1009    #[rstest]
1010    #[case(None)]
1011    #[case(Some("1"))]
1012    fn test_parse_quote_from_price_change_missing_side_uses_boundary_when_drop_disabled(
1013        #[case] best_ask: Option<&str>,
1014    ) {
1015        let mut quotes: PolymarketQuotes = load("ws_quotes.json");
1016        quotes.price_changes[0].best_ask = best_ask.map(str::to_string);
1017        let instrument = test_instrument_with_tick("0.005");
1018        let ts_event = parse_timestamp_ms(&quotes.timestamp).unwrap();
1019        let ts_init = UnixNanos::from(1_000_000_000u64);
1020
1021        let quote = parse_quote_from_price_change(
1022            &quotes.price_changes[0],
1023            instrument.id(),
1024            instrument.price_precision(),
1025            instrument.size_precision(),
1026            instrument.price_increment(),
1027            false,
1028            None,
1029            ts_event,
1030            ts_init,
1031        )
1032        .unwrap()
1033        .expect("quote should use boundary ask when drop is disabled");
1034
1035        assert_eq!(quote.bid_price, Price::from("0.51"));
1036        assert_eq!(quote.bid_size, Quantity::from("150.00"));
1037        assert_eq!(quote.ask_price, Price::from("0.995"));
1038        assert_eq!(quote.ask_size, Quantity::from("0.00"));
1039    }
1040
1041    #[rstest]
1042    fn test_parse_quote_from_price_change_missing_bid_uses_boundary_when_drop_disabled() {
1043        let mut quotes: PolymarketQuotes = load("ws_quotes.json");
1044        quotes.price_changes[0].side = PolymarketOrderSide::Sell;
1045        quotes.price_changes[0].price = "0.52".to_string();
1046        quotes.price_changes[0].size = "75".to_string();
1047        quotes.price_changes[0].best_bid = Some("0".to_string());
1048        quotes.price_changes[0].best_ask = Some("0.52".to_string());
1049        let instrument = test_instrument_with_tick("0.0025");
1050        let ts_event = parse_timestamp_ms(&quotes.timestamp).unwrap();
1051        let ts_init = UnixNanos::from(1_000_000_000u64);
1052
1053        let quote = parse_quote_from_price_change(
1054            &quotes.price_changes[0],
1055            instrument.id(),
1056            instrument.price_precision(),
1057            instrument.size_precision(),
1058            instrument.price_increment(),
1059            false,
1060            None,
1061            ts_event,
1062            ts_init,
1063        )
1064        .unwrap()
1065        .expect("quote should use boundary bid when drop is disabled");
1066
1067        assert_eq!(quote.bid_price, Price::from("0.0025"));
1068        assert_eq!(quote.bid_size, Quantity::from("0.00"));
1069        assert_eq!(quote.ask_price, Price::from("0.52"));
1070        assert_eq!(quote.ask_size, Quantity::from("75.00"));
1071    }
1072
1073    #[rstest]
1074    fn test_parse_quote_from_price_change_crossed_top_returns_none() {
1075        let mut quotes: PolymarketQuotes = load("ws_quotes.json");
1076        quotes.price_changes[0].best_bid = Some("0.70".to_string());
1077        quotes.price_changes[0].best_ask = Some("0.60".to_string());
1078        let instrument = test_instrument();
1079        let ts_event = parse_timestamp_ms(&quotes.timestamp).unwrap();
1080        let ts_init = UnixNanos::from(1_000_000_000u64);
1081
1082        let result = parse_quote_from_price_change(
1083            &quotes.price_changes[0],
1084            instrument.id(),
1085            instrument.price_precision(),
1086            instrument.size_precision(),
1087            instrument.price_increment(),
1088            false,
1089            None,
1090            ts_event,
1091            ts_init,
1092        )
1093        .unwrap();
1094
1095        assert!(result.is_none());
1096    }
1097
1098    fn best_bid_ask(best_bid: &str, best_ask: &str) -> PolymarketBestBidAsk {
1099        PolymarketBestBidAsk {
1100            market: Ustr::from("0xMARKET"),
1101            asset_id: Ustr::from("0xTOKEN"),
1102            best_bid: best_bid.to_string(),
1103            best_ask: best_ask.to_string(),
1104            spread: String::new(),
1105            timestamp: "1700000003000".to_string(),
1106        }
1107    }
1108
1109    fn quantity(value: &str, precision: u8) -> Quantity {
1110        Quantity::from_decimal_dp(value.parse().unwrap(), precision).unwrap()
1111    }
1112
1113    #[rstest]
1114    fn test_parse_quote_from_best_bid_ask_sizes_only_matching_tops() {
1115        let instrument = test_instrument();
1116        let size_precision = instrument.size_precision();
1117        let ts_event = UnixNanos::from(1_700_000_003_000_000_000u64);
1118        let ts_init = UnixNanos::from(1_000_000_000u64);
1119
1120        let quote = parse_quote_from_best_bid_ask(
1121            &best_bid_ask("0.50", "0.52"),
1122            instrument.id(),
1123            instrument.price_precision(),
1124            size_precision,
1125            instrument.price_increment(),
1126            true,
1127            Some((Price::from("0.50"), quantity("100.00", size_precision))),
1128            Some((Price::from("0.51"), quantity("75.00", size_precision))),
1129            ts_event,
1130            ts_init,
1131        )
1132        .unwrap()
1133        .unwrap();
1134
1135        assert_eq!(quote.instrument_id, instrument.id());
1136        assert_eq!(quote.bid_price, Price::from("0.50"));
1137        assert_eq!(quote.ask_price, Price::from("0.52"));
1138        assert_eq!(quote.bid_size, quantity("100.00", size_precision));
1139        assert_eq!(quote.ask_size, Quantity::zero(size_precision));
1140        assert_eq!(quote.ts_event, ts_event);
1141        assert_eq!(quote.ts_init, ts_init);
1142    }
1143
1144    #[rstest]
1145    #[case("0", "0.52")]
1146    #[case("0.50", "1")]
1147    #[case("", "0.52")]
1148    #[case("0.50", "")]
1149    fn test_parse_quote_from_best_bid_ask_missing_side_drops_by_default(
1150        #[case] best_bid: &str,
1151        #[case] best_ask: &str,
1152    ) {
1153        let instrument = test_instrument();
1154
1155        let result = parse_quote_from_best_bid_ask(
1156            &best_bid_ask(best_bid, best_ask),
1157            instrument.id(),
1158            instrument.price_precision(),
1159            instrument.size_precision(),
1160            instrument.price_increment(),
1161            true,
1162            None,
1163            None,
1164            UnixNanos::default(),
1165            UnixNanos::default(),
1166        )
1167        .unwrap();
1168
1169        assert!(result.is_none());
1170    }
1171
1172    #[rstest]
1173    #[case("0", "0.52", "0.01", "0.52", "0.00", "75.00")]
1174    #[case("0.50", "1", "0.50", "0.99", "100.00", "0.00")]
1175    fn test_parse_quote_from_best_bid_ask_missing_side_uses_tick_bound(
1176        #[case] best_bid: &str,
1177        #[case] best_ask: &str,
1178        #[case] expected_bid: &str,
1179        #[case] expected_ask: &str,
1180        #[case] expected_bid_size: &str,
1181        #[case] expected_ask_size: &str,
1182    ) {
1183        let instrument = test_instrument_with_tick("0.01");
1184        let size_precision = instrument.size_precision();
1185
1186        let quote = parse_quote_from_best_bid_ask(
1187            &best_bid_ask(best_bid, best_ask),
1188            instrument.id(),
1189            instrument.price_precision(),
1190            size_precision,
1191            instrument.price_increment(),
1192            false,
1193            Some((Price::from("0.50"), quantity("100.00", size_precision))),
1194            Some((Price::from("0.52"), quantity("75.00", size_precision))),
1195            UnixNanos::default(),
1196            UnixNanos::default(),
1197        )
1198        .unwrap()
1199        .unwrap();
1200
1201        assert_eq!(quote.instrument_id, instrument.id());
1202        assert_eq!(quote.bid_price, Price::from(expected_bid));
1203        assert_eq!(quote.ask_price, Price::from(expected_ask));
1204        assert_eq!(quote.bid_size, quantity(expected_bid_size, size_precision));
1205        assert_eq!(quote.ask_size, quantity(expected_ask_size, size_precision));
1206        assert_eq!(quote.ts_event, UnixNanos::default());
1207        assert_eq!(quote.ts_init, UnixNanos::default());
1208    }
1209
1210    #[rstest]
1211    #[case("0.60", "0.60")]
1212    #[case("0.70", "0.60")]
1213    #[case("1.10", "1")]
1214    #[case("0", "-0.10")]
1215    fn test_parse_quote_from_best_bid_ask_invalid_range_returns_none(
1216        #[case] best_bid: &str,
1217        #[case] best_ask: &str,
1218    ) {
1219        let instrument = test_instrument_with_tick("0.01");
1220
1221        let result = parse_quote_from_best_bid_ask(
1222            &best_bid_ask(best_bid, best_ask),
1223            instrument.id(),
1224            instrument.price_precision(),
1225            instrument.size_precision(),
1226            instrument.price_increment(),
1227            false,
1228            None,
1229            None,
1230            UnixNanos::default(),
1231            UnixNanos::default(),
1232        )
1233        .unwrap();
1234
1235        assert!(result.is_none());
1236    }
1237
1238    #[rstest]
1239    #[case("0.505", "0.52")]
1240    #[case("0.50", "0.525")]
1241    fn test_parse_quote_from_best_bid_ask_off_grid_returns_none(
1242        #[case] best_bid: &str,
1243        #[case] best_ask: &str,
1244    ) {
1245        let instrument = test_instrument_with_tick("0.01");
1246        assert_eq!(instrument.price_precision(), 4);
1247        assert_eq!(instrument.price_increment(), Price::from("0.01"));
1248        assert_eq!(instrument.price_increment().precision, 4);
1249
1250        let result = parse_quote_from_best_bid_ask(
1251            &best_bid_ask(best_bid, best_ask),
1252            instrument.id(),
1253            instrument.price_precision(),
1254            instrument.size_precision(),
1255            instrument.price_increment(),
1256            false,
1257            None,
1258            None,
1259            UnixNanos::default(),
1260            UnixNanos::default(),
1261        )
1262        .unwrap();
1263
1264        assert!(result.is_none());
1265    }
1266}