1use std::str::FromStr;
19
20use nautilus_core::{
21 UnixNanos,
22 correctness::{CorrectnessError, CorrectnessResult},
23 datetime::NANOSECONDS_IN_MILLISECOND,
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;
32
33use super::messages::{PolymarketBookSnapshot, PolymarketQuote, PolymarketQuotes, PolymarketTrade};
34use crate::common::{enums::PolymarketOrderSide, parse::determine_trade_id};
35
36pub fn parse_timestamp_ms(ts: &str) -> anyhow::Result<UnixNanos> {
38 let ms: u64 = ts
39 .parse()
40 .map_err(|e| anyhow::anyhow!("Invalid timestamp '{ts}': {e}"))?;
41 let ns = ms
42 .checked_mul(NANOSECONDS_IN_MILLISECOND)
43 .ok_or_else(|| anyhow::anyhow!("Timestamp overflow for '{ts}'"))?;
44 Ok(UnixNanos::from(ns))
45}
46
47pub(crate) fn parse_price(s: &str, precision: u8) -> CorrectnessResult<Price> {
48 let value = Decimal::from_str(s).map_err(|e| CorrectnessError::PredicateViolation {
49 message: format!("Invalid price '{s}': {e}"),
50 })?;
51 Price::from_decimal_dp(value, precision)
52}
53
54pub(crate) fn parse_quantity(s: &str, precision: u8) -> CorrectnessResult<Quantity> {
55 let value = Decimal::from_str(s).map_err(|e| CorrectnessError::PredicateViolation {
56 message: format!("Invalid quantity '{s}': {e}"),
57 })?;
58 Quantity::from_decimal_dp(value, precision)
59}
60
61pub fn parse_book_snapshot(
63 snap: &PolymarketBookSnapshot,
64 instrument_id: InstrumentId,
65 price_precision: u8,
66 size_precision: u8,
67 ts_init: UnixNanos,
68) -> anyhow::Result<OrderBookDeltas> {
69 let ts_event = parse_timestamp_ms(&snap.timestamp)?;
70
71 let bids_len = snap.bids.len();
72 let asks_len = snap.asks.len();
73
74 if bids_len == 0 && asks_len == 0 {
75 anyhow::bail!("Empty book snapshot for {instrument_id}");
76 }
77
78 let total = bids_len + asks_len;
79 let mut deltas = Vec::with_capacity(total + 1);
80
81 let snapshot_flag = RecordFlag::F_SNAPSHOT as u8;
85 deltas.push(OrderBookDelta::clear(instrument_id, 0, ts_event, ts_init));
86
87 let mut count = 0;
88
89 for level in &snap.bids {
90 count += 1;
91 let price = parse_price(&level.price, price_precision)?;
92 let size = parse_quantity(&level.size, size_precision)?;
93 let order = BookOrder::new(OrderSide::Buy, price, size, 0);
94
95 let mut flags = snapshot_flag;
96 if count == total {
97 flags |= RecordFlag::F_LAST as u8;
98 }
99
100 deltas.push(OrderBookDelta::new_checked(
101 instrument_id,
102 BookAction::Add,
103 order,
104 flags,
105 0,
106 ts_event,
107 ts_init,
108 )?);
109 }
110
111 for level in &snap.asks {
112 count += 1;
113 let price = parse_price(&level.price, price_precision)?;
114 let size = parse_quantity(&level.size, size_precision)?;
115 let order = BookOrder::new(OrderSide::Sell, price, size, 0);
116
117 let mut flags = snapshot_flag;
118 if count == total {
119 flags |= RecordFlag::F_LAST as u8;
120 }
121
122 deltas.push(OrderBookDelta::new_checked(
123 instrument_id,
124 BookAction::Add,
125 order,
126 flags,
127 0,
128 ts_event,
129 ts_init,
130 )?);
131 }
132
133 Ok(OrderBookDeltas::new(instrument_id, deltas))
134}
135
136pub fn parse_book_deltas(
138 quotes: &PolymarketQuotes,
139 instrument_id: InstrumentId,
140 price_precision: u8,
141 size_precision: u8,
142 ts_init: UnixNanos,
143) -> anyhow::Result<OrderBookDeltas> {
144 let ts_event = parse_timestamp_ms("es.timestamp)?;
145
146 let total = quotes.price_changes.len();
147 let mut deltas = Vec::with_capacity(total);
148
149 for (idx, change) in quotes.price_changes.iter().enumerate() {
150 let price = parse_price(&change.price, price_precision)?;
151 let size = parse_quantity(&change.size, size_precision)?;
152 let side = match change.side {
153 PolymarketOrderSide::Buy => OrderSide::Buy,
154 PolymarketOrderSide::Sell => OrderSide::Sell,
155 };
156
157 let (action, order_size) = if size.is_zero() {
158 (BookAction::Delete, Quantity::zero(size_precision))
159 } else {
160 (BookAction::Update, size)
161 };
162
163 let order = BookOrder::new(side, price, order_size, 0);
164 let flags = if idx == total - 1 {
165 RecordFlag::F_LAST as u8
166 } else {
167 0
168 };
169
170 deltas.push(OrderBookDelta::new_checked(
171 instrument_id,
172 action,
173 order,
174 flags,
175 0,
176 ts_event,
177 ts_init,
178 )?);
179 }
180
181 Ok(OrderBookDeltas::new(instrument_id, deltas))
182}
183
184pub fn parse_trade_tick(
186 trade: &PolymarketTrade,
187 instrument_id: InstrumentId,
188 price_precision: u8,
189 size_precision: u8,
190 ts_init: UnixNanos,
191) -> anyhow::Result<TradeTick> {
192 let price = parse_price(&trade.price, price_precision)?;
193 let size = parse_quantity(&trade.size, size_precision)?;
194 let aggressor_side = match trade.side {
195 PolymarketOrderSide::Buy => AggressorSide::Buyer,
196 PolymarketOrderSide::Sell => AggressorSide::Seller,
197 };
198 let ts_event = parse_timestamp_ms(&trade.timestamp)?;
199
200 let trade_id = determine_trade_id(
201 &trade.asset_id,
202 trade.side,
203 &trade.price,
204 &trade.size,
205 &trade.timestamp,
206 );
207
208 TradeTick::new_checked(
209 instrument_id,
210 price,
211 size,
212 aggressor_side,
213 trade_id,
214 ts_event,
215 ts_init,
216 )
217}
218
219pub fn parse_quote_from_snapshot(
228 snap: &PolymarketBookSnapshot,
229 instrument_id: InstrumentId,
230 price_precision: u8,
231 size_precision: u8,
232 ts_init: UnixNanos,
233) -> anyhow::Result<Option<QuoteTick>> {
234 if snap.bids.is_empty() || snap.asks.is_empty() {
235 return Ok(None);
236 }
237
238 let ts_event = parse_timestamp_ms(&snap.timestamp)?;
239
240 let best_bid = snap.bids.last().expect("bids not empty");
242 let best_ask = snap.asks.last().expect("asks not empty");
243
244 let bid_price = parse_price(&best_bid.price, price_precision)?;
245 let ask_price = parse_price(&best_ask.price, price_precision)?;
246 let bid_size = parse_quantity(&best_bid.size, size_precision)?;
247 let ask_size = parse_quantity(&best_ask.size, size_precision)?;
248
249 Ok(Some(QuoteTick::new_checked(
250 instrument_id,
251 bid_price,
252 ask_price,
253 bid_size,
254 ask_size,
255 ts_event,
256 ts_init,
257 )?))
258}
259
260pub fn parse_quote_from_price_change(
266 quote: &PolymarketQuote,
267 instrument_id: InstrumentId,
268 price_precision: u8,
269 size_precision: u8,
270 last_quote: Option<&QuoteTick>,
271 ts_event: UnixNanos,
272 ts_init: UnixNanos,
273) -> anyhow::Result<Option<QuoteTick>> {
274 let (Some(best_bid), Some(best_ask)) = ("e.best_bid, "e.best_ask) else {
275 return Ok(None);
276 };
277 let bid_price = parse_price(best_bid, price_precision)?;
278 let ask_price = parse_price(best_ask, price_precision)?;
279 let changed_price = parse_price("e.price, price_precision)?;
280
281 let size = parse_quantity("e.size, size_precision)?;
282 let zero = || Quantity::zero(size_precision);
283
284 let (bid_size, ask_size) = match quote.side {
287 PolymarketOrderSide::Buy => {
288 let bid_size = if changed_price == bid_price {
289 size
290 } else {
291 last_quote.map_or_else(zero, |q| q.bid_size)
292 };
293 let ask_size = last_quote.map_or_else(zero, |q| q.ask_size);
294 (bid_size, ask_size)
295 }
296 PolymarketOrderSide::Sell => {
297 let ask_size = if changed_price == ask_price {
298 size
299 } else {
300 last_quote.map_or_else(zero, |q| q.ask_size)
301 };
302 let bid_size = last_quote.map_or_else(zero, |q| q.bid_size);
303 (bid_size, ask_size)
304 }
305 };
306
307 Ok(Some(QuoteTick::new_checked(
308 instrument_id,
309 bid_price,
310 ask_price,
311 bid_size,
312 ask_size,
313 ts_event,
314 ts_init,
315 )?))
316}
317
318#[cfg(test)]
319mod tests {
320 use nautilus_core::UnixNanos;
321 use nautilus_model::instruments::{Instrument, InstrumentAny};
322 use rstest::rstest;
323
324 use super::*;
325 use crate::http::parse::{create_instrument_from_def, parse_gamma_market};
326
327 fn load<T: serde::de::DeserializeOwned>(filename: &str) -> T {
328 let content =
329 std::fs::read_to_string(format!("test_data/{filename}")).expect("test data missing");
330 serde_json::from_str(&content).expect("parse failed")
331 }
332
333 fn test_instrument() -> InstrumentAny {
334 let market: crate::http::models::GammaMarket = load("gamma_market.json");
335 let defs = parse_gamma_market(&market).unwrap();
336 create_instrument_from_def(&defs[0], UnixNanos::from(1_000_000_000u64)).unwrap()
337 }
338
339 #[rstest]
340 fn test_parse_timestamp_ms() {
341 let ns = parse_timestamp_ms("1703875200000").unwrap();
342 assert_eq!(ns, UnixNanos::from(1_703_875_200_000_000_000u64));
343 }
344
345 #[rstest]
346 fn test_parse_timestamp_ms_invalid() {
347 assert!(parse_timestamp_ms("not_a_number").is_err());
348 }
349
350 #[rstest]
351 fn test_parse_book_snapshot() {
352 let snap: PolymarketBookSnapshot = load("ws_book_snapshot.json");
353 let instrument = test_instrument();
354 let ts_init = UnixNanos::from(1_000_000_000u64);
355
356 let deltas = parse_book_snapshot(
357 &snap,
358 instrument.id(),
359 instrument.price_precision(),
360 instrument.size_precision(),
361 ts_init,
362 )
363 .unwrap();
364
365 assert_eq!(deltas.deltas.len(), 7);
367 assert_eq!(deltas.deltas[0].action, BookAction::Clear);
368 assert_eq!(deltas.deltas[1].action, BookAction::Add);
369 assert_eq!(deltas.deltas[1].order.side, OrderSide::Buy);
370 assert_eq!(deltas.deltas[4].action, BookAction::Add);
371 assert_eq!(deltas.deltas[4].order.side, OrderSide::Sell);
372
373 for delta in &deltas.deltas {
375 assert_ne!(delta.flags & RecordFlag::F_SNAPSHOT as u8, 0);
376 }
377
378 let f_last_count = deltas
380 .deltas
381 .iter()
382 .filter(|d| d.flags & RecordFlag::F_LAST as u8 != 0)
383 .count();
384 assert_eq!(f_last_count, 1);
385 assert_ne!(
386 deltas.deltas.last().unwrap().flags & RecordFlag::F_LAST as u8,
387 0
388 );
389 }
390
391 #[rstest]
392 fn test_parse_book_deltas() {
393 let quotes: PolymarketQuotes = load("ws_quotes.json");
394 let instrument = test_instrument();
395 let ts_init = UnixNanos::from(1_000_000_000u64);
396
397 let deltas = parse_book_deltas(
398 "es,
399 instrument.id(),
400 instrument.price_precision(),
401 instrument.size_precision(),
402 ts_init,
403 )
404 .unwrap();
405
406 assert_eq!(deltas.deltas.len(), 2);
407
408 let f_last_count = deltas
410 .deltas
411 .iter()
412 .filter(|d| d.flags & RecordFlag::F_LAST as u8 != 0)
413 .count();
414 assert_eq!(f_last_count, 1);
415 assert_ne!(
416 deltas.deltas.last().unwrap().flags & RecordFlag::F_LAST as u8,
417 0
418 );
419 }
420
421 #[rstest]
422 fn test_parse_book_deltas_zero_size_is_delete() {
423 let mut quotes: PolymarketQuotes = load("ws_quotes.json");
424 quotes.price_changes[0].size = "0".to_string();
425 let instrument = test_instrument();
426 let ts_init = UnixNanos::from(1_000_000_000u64);
427
428 let deltas = parse_book_deltas(
429 "es,
430 instrument.id(),
431 instrument.price_precision(),
432 instrument.size_precision(),
433 ts_init,
434 )
435 .unwrap();
436
437 assert_eq!(deltas.deltas[0].action, BookAction::Delete);
438 }
439
440 #[rstest]
441 fn test_parse_trade_tick() {
442 let trade: PolymarketTrade = load("ws_last_trade.json");
443 let instrument = test_instrument();
444 let ts_init = UnixNanos::from(1_000_000_000u64);
445
446 let tick = parse_trade_tick(
447 &trade,
448 instrument.id(),
449 instrument.price_precision(),
450 instrument.size_precision(),
451 ts_init,
452 )
453 .unwrap();
454
455 assert_eq!(tick.instrument_id, instrument.id());
456 assert_eq!(tick.aggressor_side, AggressorSide::Buyer);
457 assert_eq!(tick.ts_event, UnixNanos::from(1_703_875_202_000_000_000u64));
458 }
459
460 #[rstest]
461 fn test_parse_trade_tick_deterministic_id() {
462 let trade: PolymarketTrade = load("ws_last_trade.json");
463 let instrument = test_instrument();
464 let ts_init = UnixNanos::from(1_000_000_000u64);
465
466 let tick1 = parse_trade_tick(
467 &trade,
468 instrument.id(),
469 instrument.price_precision(),
470 instrument.size_precision(),
471 ts_init,
472 )
473 .unwrap();
474 let tick2 = parse_trade_tick(
475 &trade,
476 instrument.id(),
477 instrument.price_precision(),
478 instrument.size_precision(),
479 ts_init,
480 )
481 .unwrap();
482
483 assert_eq!(tick1.trade_id, tick2.trade_id);
484 }
485
486 #[rstest]
487 fn test_parse_quote_from_snapshot() {
488 let snap: PolymarketBookSnapshot = load("ws_book_snapshot.json");
489 let instrument = test_instrument();
490 let ts_init = UnixNanos::from(1_000_000_000u64);
491
492 let quote = parse_quote_from_snapshot(
493 &snap,
494 instrument.id(),
495 instrument.price_precision(),
496 instrument.size_precision(),
497 ts_init,
498 )
499 .unwrap()
500 .unwrap();
501
502 assert_eq!(quote.instrument_id, instrument.id());
503 assert_eq!(quote.bid_price, Price::from("0.50"));
504 assert_eq!(quote.ask_price, Price::from("0.51"));
505 assert_eq!(
506 quote.ts_event,
507 UnixNanos::from(1_703_875_200_000_000_000u64)
508 );
509 }
510
511 #[rstest]
512 fn test_parse_quote_from_snapshot_empty_side_returns_none() {
513 let mut snap: PolymarketBookSnapshot = load("ws_book_snapshot.json");
514 snap.bids.clear();
515 let instrument = test_instrument();
516 let ts_init = UnixNanos::from(1_000_000_000u64);
517
518 let result = parse_quote_from_snapshot(
519 &snap,
520 instrument.id(),
521 instrument.price_precision(),
522 instrument.size_precision(),
523 ts_init,
524 )
525 .unwrap();
526
527 assert!(result.is_none());
528 }
529
530 #[rstest]
531 fn test_parse_quote_from_price_change() {
532 let quotes: PolymarketQuotes = load("ws_quotes.json");
533 let instrument = test_instrument();
534 let ts_event = parse_timestamp_ms("es.timestamp).unwrap();
535 let ts_init = UnixNanos::from(1_000_000_000u64);
536
537 let quote = parse_quote_from_price_change(
538 "es.price_changes[0],
539 instrument.id(),
540 instrument.price_precision(),
541 instrument.size_precision(),
542 None,
543 ts_event,
544 ts_init,
545 )
546 .unwrap()
547 .expect("quote should be Some when best_bid/best_ask present");
548
549 assert_eq!(quote.instrument_id, instrument.id());
550 }
551}