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
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 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)]
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("e.price, price_precision)?;
412
413 let size = parse_quantity("e.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#[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 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 for delta in &deltas.deltas {
705 assert_ne!(delta.flags & RecordFlag::F_SNAPSHOT as u8, 0);
706 }
707
708 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("es.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 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("es.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("es.timestamp).unwrap();
948 let ts_init = UnixNanos::from(1_000_000_000u64);
949
950 let quote = parse_quote_from_price_change(
951 "es.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("es.timestamp).unwrap();
1046 let ts_init = UnixNanos::from(1_000_000_000u64);
1047
1048 let result = parse_quote_from_price_change(
1049 "es.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("es.timestamp).unwrap();
1074 let ts_init = UnixNanos::from(1_000_000_000u64);
1075
1076 let quote = parse_quote_from_price_change(
1077 "es.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("es.timestamp).unwrap();
1106 let ts_init = UnixNanos::from(1_000_000_000u64);
1107
1108 let quote = parse_quote_from_price_change(
1109 "es.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("es.timestamp).unwrap();
1135 let ts_init = UnixNanos::from(1_000_000_000u64);
1136
1137 let result = parse_quote_from_price_change(
1138 "es.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}