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