1use 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
42pub 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 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
146pub 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 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
221pub 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
282pub 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
317pub 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 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#[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("e.price, price_precision)?;
405
406 let size = parse_quantity("e.size, size_precision)?;
407 let zero = || Quantity::zero(size_precision);
408
409 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#[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 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 for delta in &deltas.deltas {
713 assert_ne!(delta.flags & RecordFlag::F_SNAPSHOT as u8, 0);
714 }
715
716 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("es.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 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("es.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("es.timestamp).unwrap();
933 let ts_init = UnixNanos::from(1_000_000_000u64);
934
935 let quote = parse_quote_from_price_change(
936 "es.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("es.timestamp).unwrap();
962 let ts_init = UnixNanos::from(1_000_000_000u64);
963
964 let result = 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
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("es.timestamp).unwrap();
990 let ts_init = UnixNanos::from(1_000_000_000u64);
991
992 let quote = parse_quote_from_price_change(
993 "es.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("es.timestamp).unwrap();
1022 let ts_init = UnixNanos::from(1_000_000_000u64);
1023
1024 let quote = parse_quote_from_price_change(
1025 "es.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("es.timestamp).unwrap();
1051 let ts_init = UnixNanos::from(1_000_000_000u64);
1052
1053 let result = 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
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}