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