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
91pub(crate) fn 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/// Uses the changed level's size when it matches the reported top on that side.
375/// Otherwise, carries each previous size only when its price still matches the
376/// reported top; moved or unknown sides have zero size.
377#[expect(clippy::too_many_arguments)]
378pub fn parse_quote_from_price_change(
379    quote: &PolymarketQuote,
380    instrument_id: InstrumentId,
381    price_precision: u8,
382    size_precision: u8,
383    price_increment: Price,
384    drop_quotes_missing_side: bool,
385    last_quote: Option<&QuoteTick>,
386    ts_event: UnixNanos,
387    ts_init: UnixNanos,
388) -> anyhow::Result<Option<QuoteTick>> {
389    let bid_top = parse_bid_top(quote.best_bid.as_deref(), price_precision)?;
390    let ask_top = parse_ask_top(quote.best_ask.as_deref(), price_precision)?;
391    if drop_quotes_missing_side && (bid_top.is_none() || ask_top.is_none()) {
392        return Ok(None);
393    }
394
395    let (min_price, max_price) = quote_price_bounds(price_increment, price_increment.as_decimal())?;
396    let bid_missing = bid_top.is_none();
397    let ask_missing = ask_top.is_none();
398    let bid_price = match bid_top {
399        Some(price) => price,
400        None => min_price,
401    };
402    let ask_price = match ask_top {
403        Some(price) => price,
404        None => max_price,
405    };
406
407    if !bid_missing && !ask_missing && bid_price >= ask_price {
408        return Ok(None);
409    }
410
411    let changed_price = parse_price(&quote.price, price_precision)?;
412
413    let size = parse_quantity(&quote.size, size_precision)?;
414    let zero = || Quantity::zero(size_precision);
415
416    let bid_size = if bid_missing {
417        zero()
418    } else if quote.side == PolymarketOrderSide::Buy && changed_price == bid_price {
419        size
420    } else {
421        last_quote
422            .filter(|q| q.bid_price == bid_price)
423            .map_or_else(zero, |q| q.bid_size)
424    };
425
426    let ask_size = if ask_missing {
427        zero()
428    } else if quote.side == PolymarketOrderSide::Sell && changed_price == ask_price {
429        size
430    } else {
431        last_quote
432            .filter(|q| q.ask_price == ask_price)
433            .map_or_else(zero, |q| q.ask_size)
434    };
435
436    Ok(Some(QuoteTick::new_checked(
437        instrument_id,
438        bid_price,
439        ask_price,
440        bid_size,
441        ask_size,
442        ts_event,
443        ts_init,
444    )?))
445}
446
447enum BestBidAskTop {
448    Missing,
449    Invalid,
450    Price(Price),
451}
452
453/// Parses a quote tick from a best bid/ask message.
454///
455/// The payload carries top-of-book prices only. Each side's size comes from the supplied known
456/// level when its price matches the message and is zero otherwise.
457///
458/// Returns `None` when a side is missing and `drop_quotes_missing_side` is enabled. When missing
459/// sides are allowed, the quote uses the current tick-relative venue bounds. Returns `None` for
460/// locked, crossed, out-of-range, or off-grid prices.
461#[expect(clippy::too_many_arguments)]
462pub fn parse_quote_from_best_bid_ask(
463    bba: &PolymarketBestBidAsk,
464    instrument_id: InstrumentId,
465    price_precision: u8,
466    size_precision: u8,
467    price_increment: Price,
468    drop_quotes_missing_side: bool,
469    bid_top: Option<(Price, Quantity)>,
470    ask_top: Option<(Price, Quantity)>,
471    ts_event: UnixNanos,
472    ts_init: UnixNanos,
473) -> anyhow::Result<Option<QuoteTick>> {
474    let tick_size = price_increment.as_decimal();
475    let bid = parse_best_bid_ask_top(
476        non_empty(&bba.best_bid),
477        price_precision,
478        tick_size,
479        |value| value <= Decimal::ZERO,
480    )?;
481    let ask = parse_best_bid_ask_top(
482        non_empty(&bba.best_ask),
483        price_precision,
484        tick_size,
485        |value| value >= Decimal::ONE,
486    )?;
487    let (bid, ask) = match (bid, ask) {
488        (BestBidAskTop::Invalid, _) | (_, BestBidAskTop::Invalid) => return Ok(None),
489        (BestBidAskTop::Missing, BestBidAskTop::Missing) => (None, None),
490        (BestBidAskTop::Missing, BestBidAskTop::Price(ask)) => (None, Some(ask)),
491        (BestBidAskTop::Price(bid), BestBidAskTop::Missing) => (Some(bid), None),
492        (BestBidAskTop::Price(bid), BestBidAskTop::Price(ask)) => (Some(bid), Some(ask)),
493    };
494
495    if drop_quotes_missing_side && (bid.is_none() || ask.is_none()) {
496        return Ok(None);
497    }
498
499    let (min_price, max_price) = quote_price_bounds(price_increment, tick_size)?;
500    let bid_price = bid.unwrap_or(min_price);
501    let ask_price = ask.unwrap_or(max_price);
502    if bid_price < min_price || ask_price > max_price || bid_price >= ask_price {
503        return Ok(None);
504    }
505
506    let size_at = |price: Option<Price>, top: Option<(Price, Quantity)>| match (price, top) {
507        (Some(price), Some((top_price, top_size))) if top_price == price => top_size,
508        _ => Quantity::zero(size_precision),
509    };
510
511    Ok(Some(QuoteTick::new_checked(
512        instrument_id,
513        bid_price,
514        ask_price,
515        size_at(bid, bid_top),
516        size_at(ask, ask_top),
517        ts_event,
518        ts_init,
519    )?))
520}
521
522fn quote_price_bounds(
523    price_increment: Price,
524    tick_size: Decimal,
525) -> anyhow::Result<(Price, Price)> {
526    let max_price = Price::from_decimal_dp(Decimal::ONE - tick_size, price_increment.precision)?;
527    Ok((price_increment, max_price))
528}
529
530fn parse_best_bid_ask_top(
531    value: Option<&str>,
532    precision: u8,
533    tick_size: Decimal,
534    is_missing: impl FnOnce(Decimal) -> bool,
535) -> CorrectnessResult<BestBidAskTop> {
536    let Some(value) = value else {
537        return Ok(BestBidAskTop::Missing);
538    };
539    let decimal = parse_decimal_exact(value).map_err(|e| CorrectnessError::PredicateViolation {
540        message: format!("Invalid price '{value}': {e}"),
541    })?;
542
543    if is_missing(decimal) {
544        return Ok(BestBidAskTop::Missing);
545    }
546
547    let price = Price::from_decimal_dp(decimal, precision)?;
548    if price.as_decimal() != decimal || decimal % tick_size != Decimal::ZERO {
549        return Ok(BestBidAskTop::Invalid);
550    }
551
552    Ok(BestBidAskTop::Price(price))
553}
554
555fn non_empty(value: &str) -> Option<&str> {
556    let value = value.trim();
557    (!value.is_empty()).then_some(value)
558}
559
560fn parse_bid_top(value: Option<&str>, precision: u8) -> CorrectnessResult<Option<Price>> {
561    parse_top_price(value, precision, |value| value <= Decimal::ZERO)
562}
563
564fn parse_ask_top(value: Option<&str>, precision: u8) -> CorrectnessResult<Option<Price>> {
565    parse_top_price(value, precision, |value| value >= Decimal::ONE)
566}
567
568fn parse_top_price(
569    value: Option<&str>,
570    precision: u8,
571    is_missing: impl FnOnce(Decimal) -> bool,
572) -> CorrectnessResult<Option<Price>> {
573    let Some(value) = value else {
574        return Ok(None);
575    };
576    let decimal = parse_decimal_exact(value).map_err(|e| CorrectnessError::PredicateViolation {
577        message: format!("Invalid price '{value}': {e}"),
578    })?;
579
580    if is_missing(decimal) {
581        return Ok(None);
582    }
583
584    Ok(Some(Price::from_decimal_dp(decimal, precision)?))
585}
586
587#[cfg(test)]
588mod tests {
589    use nautilus_core::UnixNanos;
590    use nautilus_model::instruments::{Instrument, InstrumentAny};
591    use rstest::rstest;
592    use ustr::Ustr;
593
594    use super::*;
595    use crate::{
596        http::parse::{
597            create_instrument_from_def, parse_gamma_market, rebuild_instrument_with_tick_size,
598        },
599        websocket::messages::PolymarketQuotes,
600    };
601
602    fn load<T: serde::de::DeserializeOwned>(filename: &str) -> T {
603        let content =
604            std::fs::read_to_string(format!("test_data/{filename}")).expect("test data missing");
605        serde_json::from_str(&content).expect("parse failed")
606    }
607
608    fn test_instrument() -> InstrumentAny {
609        let market: crate::http::models::GammaMarket = load("gamma_market.json");
610        let defs = parse_gamma_market(&market).unwrap();
611        create_instrument_from_def(&defs[0], UnixNanos::from(1_000_000_000u64)).unwrap()
612    }
613
614    fn test_instrument_with_tick(tick_size: &str) -> InstrumentAny {
615        let instrument = test_instrument();
616        let ts = UnixNanos::from(1_000_000_000u64);
617        rebuild_instrument_with_tick_size(&instrument, tick_size, ts, ts).unwrap()
618    }
619
620    #[rstest]
621    fn test_parse_timestamp_ms() {
622        let ns = parse_timestamp_ms("1703875200000").unwrap();
623        assert_eq!(ns, UnixNanos::from(1_703_875_200_000_000_000u64));
624    }
625
626    #[rstest]
627    fn test_parse_timestamp_ms_invalid() {
628        assert!(parse_timestamp_ms("not_a_number").is_err());
629    }
630
631    #[rstest]
632    fn test_book_snapshot_hash_matches_captured_snapshot() {
633        let snap: PolymarketBookSnapshot = load("ws_book_snapshot_captured.json");
634
635        assert_eq!(snap.min_order_size, None);
636        assert_eq!(snap.neg_risk, None);
637        assert_eq!(snap.tick_size.as_deref(), Some("0.01"));
638        assert_eq!(snap.last_trade_price.as_deref(), Some("0.920"));
639        assert_eq!(
640            book_snapshot_hash(&snap, Some("5"), Some(false)).unwrap(),
641            Some("ed47eb91f3c7985fac1cb18cb7c19535eddd3c0a".to_string())
642        );
643        assert!(verify_book_snapshot_hash(&snap, Some("5"), Some(false)).unwrap());
644    }
645
646    #[rstest]
647    fn test_book_snapshot_hash_rejects_mismatch() {
648        let mut snap: PolymarketBookSnapshot = load("ws_book_snapshot_captured.json");
649        snap.bids[0].size = "3149725.71".to_string();
650
651        let error = verify_book_snapshot_hash(&snap, Some("5"), Some(false)).unwrap_err();
652
653        assert_eq!(
654            error.to_string(),
655            concat!(
656                "Book snapshot hash mismatch for ",
657                "350977769852917329387037893294763093471844346281449484439085576212613048126: ",
658                "expected ed47eb91f3c7985fac1cb18cb7c19535eddd3c0a, ",
659                "computed 6402b534c270a1ce46a75c62f1d7e3651182cc75"
660            )
661        );
662    }
663
664    #[rstest]
665    fn test_book_snapshot_hash_allows_missing_hash() {
666        let snap: PolymarketBookSnapshot = load("ws_book_snapshot_missing_hash.json");
667
668        assert!(!verify_book_snapshot_hash(&snap, None, None).unwrap());
669    }
670
671    #[rstest]
672    fn test_book_snapshot_hash_allows_incomplete_preimage() {
673        let mut snap: PolymarketBookSnapshot = load("ws_book_snapshot_captured.json");
674        snap.tick_size = None;
675        snap.last_trade_price = None;
676
677        assert!(!verify_book_snapshot_hash(&snap, Some("5"), Some(false)).unwrap());
678    }
679
680    #[rstest]
681    fn test_parse_book_snapshot() {
682        let snap: PolymarketBookSnapshot = load("ws_book_snapshot.json");
683        let instrument = test_instrument();
684        let ts_init = UnixNanos::from(1_000_000_000u64);
685
686        let deltas = parse_book_snapshot(
687            &snap,
688            instrument.id(),
689            instrument.price_precision(),
690            instrument.size_precision(),
691            ts_init,
692        )
693        .unwrap();
694
695        // CLEAR + 3 bids + 3 asks = 7 deltas
696        assert_eq!(deltas.deltas.len(), 7);
697        assert_eq!(deltas.deltas[0].action, BookAction::Clear);
698        assert_eq!(deltas.deltas[1].action, BookAction::Add);
699        assert_eq!(deltas.deltas[1].order.side, Some(OrderSide::Buy));
700        assert_eq!(deltas.deltas[4].action, BookAction::Add);
701        assert_eq!(deltas.deltas[4].order.side, Some(OrderSide::Sell));
702
703        // Every snapshot delta carries F_SNAPSHOT
704        for delta in &deltas.deltas {
705            assert_ne!(delta.flags & RecordFlag::F_SNAPSHOT as u8, 0);
706        }
707
708        // Exactly one delta carries F_LAST, and it must be the last one
709        let f_last_count = deltas
710            .deltas
711            .iter()
712            .filter(|d| d.flags & RecordFlag::F_LAST as u8 != 0)
713            .count();
714        assert_eq!(f_last_count, 1);
715        assert_ne!(
716            deltas.deltas.last().unwrap().flags & RecordFlag::F_LAST as u8,
717            0
718        );
719    }
720
721    #[rstest]
722    fn test_parse_book_snapshot_empty_book_parses_to_lone_clear() {
723        let mut snap: PolymarketBookSnapshot = load("ws_book_snapshot.json");
724        snap.bids.clear();
725        snap.asks.clear();
726        let instrument = test_instrument();
727        let ts_init = UnixNanos::from(1_000_000_000u64);
728
729        let deltas = parse_book_snapshot(
730            &snap,
731            instrument.id(),
732            instrument.price_precision(),
733            instrument.size_precision(),
734            ts_init,
735        )
736        .unwrap();
737
738        assert_eq!(deltas.deltas.len(), 1);
739        assert_eq!(deltas.deltas[0].action, BookAction::Clear);
740        assert_ne!(deltas.deltas[0].flags & RecordFlag::F_SNAPSHOT as u8, 0);
741        assert_ne!(deltas.deltas[0].flags & RecordFlag::F_LAST as u8, 0);
742    }
743
744    #[rstest]
745    fn test_parse_book_deltas() {
746        let quotes: PolymarketQuotes = load("ws_quotes.json");
747        let instrument = test_instrument();
748        let ts_event = parse_timestamp_ms(&quotes.timestamp).unwrap();
749        let ts_init = UnixNanos::from(1_000_000_000u64);
750        let changes = quotes.price_changes.iter().collect::<Vec<_>>();
751
752        let deltas = parse_book_deltas(
753            &changes,
754            instrument.id(),
755            instrument.price_precision(),
756            instrument.size_precision(),
757            ts_event,
758            ts_init,
759        )
760        .into_iter()
761        .collect::<anyhow::Result<Vec<_>>>()
762        .unwrap();
763
764        assert_eq!(deltas.len(), 2);
765
766        // Exactly one delta carries F_LAST, and it must be the last one
767        let f_last_count = deltas
768            .iter()
769            .filter(|d| d.flags & RecordFlag::F_LAST as u8 != 0)
770            .count();
771        assert_eq!(f_last_count, 1);
772        assert_ne!(deltas.last().unwrap().flags & RecordFlag::F_LAST as u8, 0);
773    }
774
775    #[rstest]
776    fn test_parse_book_deltas_zero_size_is_delete() {
777        let mut quotes: PolymarketQuotes = load("ws_quotes.json");
778        quotes.price_changes[0].size = "0".to_string();
779        let instrument = test_instrument();
780        let ts_event = parse_timestamp_ms(&quotes.timestamp).unwrap();
781        let ts_init = UnixNanos::from(1_000_000_000u64);
782        let changes = quotes.price_changes.iter().collect::<Vec<_>>();
783
784        let deltas = parse_book_deltas(
785            &changes,
786            instrument.id(),
787            instrument.price_precision(),
788            instrument.size_precision(),
789            ts_event,
790            ts_init,
791        )
792        .into_iter()
793        .collect::<anyhow::Result<Vec<_>>>()
794        .unwrap();
795
796        assert_eq!(deltas[0].action, BookAction::Delete);
797    }
798
799    #[rstest]
800    fn test_parse_trade_tick() {
801        let trade: PolymarketTrade = load("ws_last_trade.json");
802        let instrument = test_instrument();
803        let ts_init = UnixNanos::from(1_000_000_000u64);
804
805        let tick = parse_trade_tick(
806            &trade,
807            instrument.id(),
808            instrument.price_precision(),
809            instrument.size_precision(),
810            ts_init,
811        )
812        .unwrap();
813
814        assert_eq!(tick.instrument_id, instrument.id());
815        assert_eq!(tick.aggressor_side, AggressorSide::Buy);
816        assert_eq!(tick.ts_event, UnixNanos::from(1_703_875_202_000_000_000u64));
817    }
818
819    #[rstest]
820    fn test_parse_trade_tick_deterministic_id() {
821        let trade: PolymarketTrade = load("ws_last_trade.json");
822        let instrument = test_instrument();
823        let ts_init = UnixNanos::from(1_000_000_000u64);
824
825        let tick1 = parse_trade_tick(
826            &trade,
827            instrument.id(),
828            instrument.price_precision(),
829            instrument.size_precision(),
830            ts_init,
831        )
832        .unwrap();
833        let tick2 = parse_trade_tick(
834            &trade,
835            instrument.id(),
836            instrument.price_precision(),
837            instrument.size_precision(),
838            ts_init,
839        )
840        .unwrap();
841
842        assert_eq!(tick1.trade_id, tick2.trade_id);
843    }
844
845    #[rstest]
846    fn test_parse_quote_from_snapshot() {
847        let snap: PolymarketBookSnapshot = load("ws_book_snapshot.json");
848        let instrument = test_instrument();
849        let ts_init = UnixNanos::from(1_000_000_000u64);
850
851        let quote = parse_quote_from_snapshot(
852            &snap,
853            instrument.id(),
854            instrument.price_precision(),
855            instrument.size_precision(),
856            instrument.price_increment(),
857            true,
858            ts_init,
859        )
860        .unwrap()
861        .unwrap();
862
863        assert_eq!(quote.instrument_id, instrument.id());
864        assert_eq!(quote.bid_price, Price::from("0.50"));
865        assert_eq!(quote.ask_price, Price::from("0.51"));
866        assert_eq!(
867            quote.ts_event,
868            UnixNanos::from(1_703_875_200_000_000_000u64)
869        );
870    }
871
872    #[rstest]
873    fn test_parse_quote_from_snapshot_empty_side_returns_none() {
874        let mut snap: PolymarketBookSnapshot = load("ws_book_snapshot.json");
875        snap.bids.clear();
876        let instrument = test_instrument();
877        let ts_init = UnixNanos::from(1_000_000_000u64);
878
879        let result = parse_quote_from_snapshot(
880            &snap,
881            instrument.id(),
882            instrument.price_precision(),
883            instrument.size_precision(),
884            instrument.price_increment(),
885            true,
886            ts_init,
887        )
888        .unwrap();
889
890        assert!(result.is_none());
891    }
892
893    #[rstest]
894    fn test_parse_quote_from_snapshot_empty_side_uses_boundary_when_drop_disabled() {
895        let mut snap: PolymarketBookSnapshot = load("ws_book_snapshot.json");
896        snap.asks.clear();
897        let instrument = test_instrument_with_tick("0.005");
898        let ts_init = UnixNanos::from(1_000_000_000u64);
899
900        let quote = parse_quote_from_snapshot(
901            &snap,
902            instrument.id(),
903            instrument.price_precision(),
904            instrument.size_precision(),
905            instrument.price_increment(),
906            false,
907            ts_init,
908        )
909        .unwrap()
910        .expect("quote should use boundary ask when drop is disabled");
911
912        assert_eq!(quote.bid_price, Price::from("0.50"));
913        assert_eq!(quote.bid_size, Quantity::from("200.00"));
914        assert_eq!(quote.ask_price, Price::from("0.995"));
915        assert_eq!(quote.ask_size, Quantity::from("0.00"));
916    }
917
918    #[rstest]
919    fn test_parse_quote_from_snapshot_empty_bid_uses_boundary_when_drop_disabled() {
920        let mut snap: PolymarketBookSnapshot = load("ws_book_snapshot.json");
921        snap.bids.clear();
922        let instrument = test_instrument_with_tick("0.0025");
923        let ts_init = UnixNanos::from(1_000_000_000u64);
924
925        let quote = parse_quote_from_snapshot(
926            &snap,
927            instrument.id(),
928            instrument.price_precision(),
929            instrument.size_precision(),
930            instrument.price_increment(),
931            false,
932            ts_init,
933        )
934        .unwrap()
935        .expect("quote should use boundary bid when drop is disabled");
936
937        assert_eq!(quote.bid_price, Price::from("0.0025"));
938        assert_eq!(quote.bid_size, Quantity::from("0.00"));
939        assert_eq!(quote.ask_price, Price::from("0.51"));
940        assert_eq!(quote.ask_size, Quantity::from("150.00"));
941    }
942
943    #[rstest]
944    fn test_parse_quote_from_price_change() {
945        let quotes: PolymarketQuotes = load("ws_quotes.json");
946        let instrument = test_instrument();
947        let ts_event = parse_timestamp_ms(&quotes.timestamp).unwrap();
948        let ts_init = UnixNanos::from(1_000_000_000u64);
949
950        let quote = parse_quote_from_price_change(
951            &quotes.price_changes[0],
952            instrument.id(),
953            instrument.price_precision(),
954            instrument.size_precision(),
955            instrument.price_increment(),
956            true,
957            None,
958            ts_event,
959            ts_init,
960        )
961        .unwrap()
962        .expect("quote should be Some when best_bid/best_ask present");
963
964        assert_eq!(quote.instrument_id, instrument.id());
965    }
966
967    #[rstest]
968    #[case::buy_top(PolymarketOrderSide::Buy, "0.50", "0.50", "0.52", "37", "37", "75")]
969    #[case::sell_top(PolymarketOrderSide::Sell, "0.52", "0.50", "0.52", "37", "100", "37")]
970    #[case::buy_deeper(PolymarketOrderSide::Buy, "0.49", "0.50", "0.52", "37", "100", "75")]
971    #[case::sell_deeper(PolymarketOrderSide::Sell, "0.53", "0.50", "0.52", "37", "100", "75")]
972    #[case::buy_removed(PolymarketOrderSide::Buy, "0.50", "0.49", "0.52", "0", "0", "75")]
973    #[case::sell_removed(PolymarketOrderSide::Sell, "0.52", "0.50", "0.53", "0", "100", "0")]
974    #[case::buy_opposite_moved(PolymarketOrderSide::Buy, "0.50", "0.50", "0.53", "37", "37", "0")]
975    #[case::sell_opposite_moved(PolymarketOrderSide::Sell, "0.52", "0.49", "0.52", "37", "0", "37")]
976    fn test_parse_quote_from_price_change_sizes_only_matching_tops(
977        #[case] side: PolymarketOrderSide,
978        #[case] changed_price: &str,
979        #[case] best_bid: &str,
980        #[case] best_ask: &str,
981        #[case] changed_size: &str,
982        #[case] bid_size: &str,
983        #[case] ask_size: &str,
984    ) {
985        let instrument_id = InstrumentId::from("TEST.POLYMARKET");
986
987        let last_quote = QuoteTick::new(
988            instrument_id,
989            Price::from("0.50"),
990            Price::from("0.52"),
991            Quantity::from("100.00"),
992            Quantity::from("75.00"),
993            UnixNanos::from(1_000_000u64),
994            UnixNanos::from(2_000_000u64),
995        );
996
997        let change = PolymarketQuote {
998            asset_id: Ustr::from("TEST"),
999            price: changed_price.to_string(),
1000            size: changed_size.to_string(),
1001            side,
1002            hash: String::new(),
1003            best_bid: Some(best_bid.to_string()),
1004            best_ask: Some(best_ask.to_string()),
1005        };
1006
1007        let ts_event = UnixNanos::from(3_000_000u64);
1008        let ts_init = UnixNanos::from(4_000_000u64);
1009        let quote = parse_quote_from_price_change(
1010            &change,
1011            instrument_id,
1012            2,
1013            2,
1014            Price::from("0.01"),
1015            true,
1016            Some(&last_quote),
1017            ts_event,
1018            ts_init,
1019        )
1020        .unwrap()
1021        .unwrap();
1022
1023        let expected = QuoteTick::new(
1024            instrument_id,
1025            Price::from(best_bid),
1026            Price::from(best_ask),
1027            quantity(bid_size, 2),
1028            quantity(ask_size, 2),
1029            ts_event,
1030            ts_init,
1031        );
1032
1033        assert_eq!(quote, expected);
1034    }
1035
1036    #[rstest]
1037    #[case(None)]
1038    #[case(Some("1"))]
1039    fn test_parse_quote_from_price_change_missing_side_drops_by_default(
1040        #[case] best_ask: Option<&str>,
1041    ) {
1042        let mut quotes: PolymarketQuotes = load("ws_quotes.json");
1043        quotes.price_changes[0].best_ask = best_ask.map(str::to_string);
1044        let instrument = test_instrument();
1045        let ts_event = parse_timestamp_ms(&quotes.timestamp).unwrap();
1046        let ts_init = UnixNanos::from(1_000_000_000u64);
1047
1048        let result = parse_quote_from_price_change(
1049            &quotes.price_changes[0],
1050            instrument.id(),
1051            instrument.price_precision(),
1052            instrument.size_precision(),
1053            instrument.price_increment(),
1054            true,
1055            None,
1056            ts_event,
1057            ts_init,
1058        )
1059        .unwrap();
1060
1061        assert!(result.is_none());
1062    }
1063
1064    #[rstest]
1065    #[case(None)]
1066    #[case(Some("1"))]
1067    fn test_parse_quote_from_price_change_missing_side_uses_boundary_when_drop_disabled(
1068        #[case] best_ask: Option<&str>,
1069    ) {
1070        let mut quotes: PolymarketQuotes = load("ws_quotes.json");
1071        quotes.price_changes[0].best_ask = best_ask.map(str::to_string);
1072        let instrument = test_instrument_with_tick("0.005");
1073        let ts_event = parse_timestamp_ms(&quotes.timestamp).unwrap();
1074        let ts_init = UnixNanos::from(1_000_000_000u64);
1075
1076        let quote = parse_quote_from_price_change(
1077            &quotes.price_changes[0],
1078            instrument.id(),
1079            instrument.price_precision(),
1080            instrument.size_precision(),
1081            instrument.price_increment(),
1082            false,
1083            None,
1084            ts_event,
1085            ts_init,
1086        )
1087        .unwrap()
1088        .expect("quote should use boundary ask when drop is disabled");
1089
1090        assert_eq!(quote.bid_price, Price::from("0.51"));
1091        assert_eq!(quote.bid_size, Quantity::from("150.00"));
1092        assert_eq!(quote.ask_price, Price::from("0.995"));
1093        assert_eq!(quote.ask_size, Quantity::from("0.00"));
1094    }
1095
1096    #[rstest]
1097    fn test_parse_quote_from_price_change_missing_bid_uses_boundary_when_drop_disabled() {
1098        let mut quotes: PolymarketQuotes = load("ws_quotes.json");
1099        quotes.price_changes[0].side = PolymarketOrderSide::Sell;
1100        quotes.price_changes[0].price = "0.52".to_string();
1101        quotes.price_changes[0].size = "75".to_string();
1102        quotes.price_changes[0].best_bid = Some("0".to_string());
1103        quotes.price_changes[0].best_ask = Some("0.52".to_string());
1104        let instrument = test_instrument_with_tick("0.0025");
1105        let ts_event = parse_timestamp_ms(&quotes.timestamp).unwrap();
1106        let ts_init = UnixNanos::from(1_000_000_000u64);
1107
1108        let quote = parse_quote_from_price_change(
1109            &quotes.price_changes[0],
1110            instrument.id(),
1111            instrument.price_precision(),
1112            instrument.size_precision(),
1113            instrument.price_increment(),
1114            false,
1115            None,
1116            ts_event,
1117            ts_init,
1118        )
1119        .unwrap()
1120        .expect("quote should use boundary bid when drop is disabled");
1121
1122        assert_eq!(quote.bid_price, Price::from("0.0025"));
1123        assert_eq!(quote.bid_size, Quantity::from("0.00"));
1124        assert_eq!(quote.ask_price, Price::from("0.52"));
1125        assert_eq!(quote.ask_size, Quantity::from("75.00"));
1126    }
1127
1128    #[rstest]
1129    fn test_parse_quote_from_price_change_crossed_top_returns_none() {
1130        let mut quotes: PolymarketQuotes = load("ws_quotes.json");
1131        quotes.price_changes[0].best_bid = Some("0.70".to_string());
1132        quotes.price_changes[0].best_ask = Some("0.60".to_string());
1133        let instrument = test_instrument();
1134        let ts_event = parse_timestamp_ms(&quotes.timestamp).unwrap();
1135        let ts_init = UnixNanos::from(1_000_000_000u64);
1136
1137        let result = parse_quote_from_price_change(
1138            &quotes.price_changes[0],
1139            instrument.id(),
1140            instrument.price_precision(),
1141            instrument.size_precision(),
1142            instrument.price_increment(),
1143            false,
1144            None,
1145            ts_event,
1146            ts_init,
1147        )
1148        .unwrap();
1149
1150        assert!(result.is_none());
1151    }
1152
1153    fn best_bid_ask(best_bid: &str, best_ask: &str) -> PolymarketBestBidAsk {
1154        PolymarketBestBidAsk {
1155            market: Ustr::from("0xMARKET"),
1156            asset_id: Ustr::from("0xTOKEN"),
1157            best_bid: best_bid.to_string(),
1158            best_ask: best_ask.to_string(),
1159            spread: String::new(),
1160            timestamp: "1700000003000".to_string(),
1161        }
1162    }
1163
1164    fn quantity(value: &str, precision: u8) -> Quantity {
1165        Quantity::from_decimal_dp(value.parse().unwrap(), precision).unwrap()
1166    }
1167
1168    #[rstest]
1169    fn test_parse_quote_from_best_bid_ask_sizes_only_matching_tops() {
1170        let instrument = test_instrument();
1171        let size_precision = instrument.size_precision();
1172        let ts_event = UnixNanos::from(1_700_000_003_000_000_000u64);
1173        let ts_init = UnixNanos::from(1_000_000_000u64);
1174
1175        let quote = parse_quote_from_best_bid_ask(
1176            &best_bid_ask("0.50", "0.52"),
1177            instrument.id(),
1178            instrument.price_precision(),
1179            size_precision,
1180            instrument.price_increment(),
1181            true,
1182            Some((Price::from("0.50"), quantity("100.00", size_precision))),
1183            Some((Price::from("0.51"), quantity("75.00", size_precision))),
1184            ts_event,
1185            ts_init,
1186        )
1187        .unwrap()
1188        .unwrap();
1189
1190        assert_eq!(quote.instrument_id, instrument.id());
1191        assert_eq!(quote.bid_price, Price::from("0.50"));
1192        assert_eq!(quote.ask_price, Price::from("0.52"));
1193        assert_eq!(quote.bid_size, quantity("100.00", size_precision));
1194        assert_eq!(quote.ask_size, Quantity::zero(size_precision));
1195        assert_eq!(quote.ts_event, ts_event);
1196        assert_eq!(quote.ts_init, ts_init);
1197    }
1198
1199    #[rstest]
1200    #[case("0", "0.52")]
1201    #[case("0.50", "1")]
1202    #[case("", "0.52")]
1203    #[case("0.50", "")]
1204    fn test_parse_quote_from_best_bid_ask_missing_side_drops_by_default(
1205        #[case] best_bid: &str,
1206        #[case] best_ask: &str,
1207    ) {
1208        let instrument = test_instrument();
1209
1210        let result = parse_quote_from_best_bid_ask(
1211            &best_bid_ask(best_bid, best_ask),
1212            instrument.id(),
1213            instrument.price_precision(),
1214            instrument.size_precision(),
1215            instrument.price_increment(),
1216            true,
1217            None,
1218            None,
1219            UnixNanos::default(),
1220            UnixNanos::default(),
1221        )
1222        .unwrap();
1223
1224        assert!(result.is_none());
1225    }
1226
1227    #[rstest]
1228    #[case("0", "0.52", "0.01", "0.52", "0.00", "75.00")]
1229    #[case("0.50", "1", "0.50", "0.99", "100.00", "0.00")]
1230    fn test_parse_quote_from_best_bid_ask_missing_side_uses_tick_bound(
1231        #[case] best_bid: &str,
1232        #[case] best_ask: &str,
1233        #[case] expected_bid: &str,
1234        #[case] expected_ask: &str,
1235        #[case] expected_bid_size: &str,
1236        #[case] expected_ask_size: &str,
1237    ) {
1238        let instrument = test_instrument_with_tick("0.01");
1239        let size_precision = instrument.size_precision();
1240
1241        let quote = parse_quote_from_best_bid_ask(
1242            &best_bid_ask(best_bid, best_ask),
1243            instrument.id(),
1244            instrument.price_precision(),
1245            size_precision,
1246            instrument.price_increment(),
1247            false,
1248            Some((Price::from("0.50"), quantity("100.00", size_precision))),
1249            Some((Price::from("0.52"), quantity("75.00", size_precision))),
1250            UnixNanos::default(),
1251            UnixNanos::default(),
1252        )
1253        .unwrap()
1254        .unwrap();
1255
1256        assert_eq!(quote.instrument_id, instrument.id());
1257        assert_eq!(quote.bid_price, Price::from(expected_bid));
1258        assert_eq!(quote.ask_price, Price::from(expected_ask));
1259        assert_eq!(quote.bid_size, quantity(expected_bid_size, size_precision));
1260        assert_eq!(quote.ask_size, quantity(expected_ask_size, size_precision));
1261        assert_eq!(quote.ts_event, UnixNanos::default());
1262        assert_eq!(quote.ts_init, UnixNanos::default());
1263    }
1264
1265    #[rstest]
1266    #[case("0.60", "0.60")]
1267    #[case("0.70", "0.60")]
1268    #[case("1.10", "1")]
1269    #[case("0", "-0.10")]
1270    fn test_parse_quote_from_best_bid_ask_invalid_range_returns_none(
1271        #[case] best_bid: &str,
1272        #[case] best_ask: &str,
1273    ) {
1274        let instrument = test_instrument_with_tick("0.01");
1275
1276        let result = parse_quote_from_best_bid_ask(
1277            &best_bid_ask(best_bid, best_ask),
1278            instrument.id(),
1279            instrument.price_precision(),
1280            instrument.size_precision(),
1281            instrument.price_increment(),
1282            false,
1283            None,
1284            None,
1285            UnixNanos::default(),
1286            UnixNanos::default(),
1287        )
1288        .unwrap();
1289
1290        assert!(result.is_none());
1291    }
1292
1293    #[rstest]
1294    #[case("0.505", "0.52")]
1295    #[case("0.50", "0.525")]
1296    fn test_parse_quote_from_best_bid_ask_off_grid_returns_none(
1297        #[case] best_bid: &str,
1298        #[case] best_ask: &str,
1299    ) {
1300        let instrument = test_instrument_with_tick("0.01");
1301        assert_eq!(instrument.price_precision(), 4);
1302        assert_eq!(instrument.price_increment(), Price::from("0.01"));
1303        assert_eq!(instrument.price_increment().precision, 4);
1304
1305        let result = parse_quote_from_best_bid_ask(
1306            &best_bid_ask(best_bid, best_ask),
1307            instrument.id(),
1308            instrument.price_precision(),
1309            instrument.size_precision(),
1310            instrument.price_increment(),
1311            false,
1312            None,
1313            None,
1314            UnixNanos::default(),
1315            UnixNanos::default(),
1316        )
1317        .unwrap();
1318
1319        assert!(result.is_none());
1320    }
1321}