1use std::{collections::HashMap, convert::Infallible, result::Result as StdResult};
19
20use anyhow::Context;
21use nautilus_core::{UnixNanos, time::get_atomic_clock_realtime};
22use nautilus_model::{
23 data::TradeTick,
24 enums::AggressorSide,
25 identifiers::{InstrumentId, TradeId},
26 types::{Price, Quantity},
27};
28use nautilus_network::{
29 http::{HttpClient, HttpClientError, Method, create_standard_nautilus_headers},
30 websocket::proxy::ProxyUrl,
31};
32use rust_decimal::Decimal;
33
34use crate::{
35 common::{enums::PolymarketOrderSide, urls::data_api_url},
36 http::{
37 error::{Error, Result, decode_response},
38 models::{DataApiPage, DataApiPosition, DataApiTrade},
39 pagination::{
40 CollectAll, Completion, CursorProtocol, FetchOutcome, PageReducer, Paginator,
41 },
42 },
43};
44
45const PATH_POSITIONS: &str = "/v2/positions";
46const PATH_TRADES: &str = "/v2/trades";
47
48const MAX_UNBOUNDED_WALK_ROWS: usize = 10_000;
51
52const TRADE_RETENTION_SECONDS: i64 = 3 * 365 * 86_400;
54
55fn data_api_trade_sort_key(t: &DataApiTrade) -> (i64, &str, &str, &'static str, Decimal, Decimal) {
57 (
58 t.timestamp,
59 t.transaction_hash.as_str(),
60 t.asset.as_str(),
61 match t.side {
62 PolymarketOrderSide::Buy => "BUY",
63 PolymarketOrderSide::Sell => "SELL",
64 },
65 t.price,
66 t.size,
67 )
68}
69
70pub(crate) fn build_polymarket_trade_id(transaction_hash: &str, asset: &str, seq: u32) -> String {
74 let hash_suffix = if transaction_hash.len() > 24 {
75 &transaction_hash[transaction_hash.len() - 24..]
76 } else {
77 transaction_hash
78 };
79 let asset_suffix = if asset.len() > 4 {
80 &asset[asset.len() - 4..]
81 } else {
82 asset
83 };
84 format!("{hash_suffix}-{asset_suffix}-{seq:06}")
85}
86
87fn validate_trade_page_scope(
88 rows: Vec<DataApiTrade>,
89 expected_condition_id: &str,
90) -> anyhow::Result<Vec<DataApiTrade>> {
91 match rows.iter().find(|trade| {
92 !trade
93 .condition_id
94 .eq_ignore_ascii_case(expected_condition_id)
95 }) {
96 Some(trade) => anyhow::bail!(
97 "Polymarket Data API returned trade for condition {} while requesting {expected_condition_id}",
98 trade.condition_id
99 ),
100 None => Ok(rows),
101 }
102}
103
104#[derive(Clone, Debug, Eq, PartialEq)]
105enum TradeTickStop {
106 CallerCapped,
107 OlderThanStart,
108 PageCapReached,
109}
110
111struct TradeTickReducer {
112 rows: Vec<DataApiTrade>,
113 instrument_id: InstrumentId,
114 condition_id: String,
115 token_id: String,
116 price_precision: u8,
117 size_precision: u8,
118 start: Option<UnixNanos>,
119 end: Option<UnixNanos>,
120 limit: Option<usize>,
121}
122
123impl PageReducer<DataApiTrade, anyhow::Error> for TradeTickReducer {
124 type Output = Vec<TradeTick>;
125 type Stop = TradeTickStop;
126
127 fn consume(&mut self, rows: Vec<DataApiTrade>) -> anyhow::Result<Option<Self::Stop>> {
128 let older_than_start = self.start.is_some_and(|start| {
132 let start_secs = (start.as_u64() / 1_000_000_000) as i64;
133 !rows.is_empty() && rows.iter().all(|trade| trade.timestamp < start_secs)
134 });
135
136 let end_secs = self.end.map(|end| (end.as_u64() / 1_000_000_000) as i64);
137 self.rows.extend(rows.into_iter().filter(|trade| {
138 trade.asset == self.token_id && end_secs.is_none_or(|end| trade.timestamp <= end)
139 }));
140
141 if older_than_start {
142 return Ok(Some(TradeTickStop::OlderThanStart));
143 }
144
145 let capped =
146 self.start.is_none() && self.limit.is_some_and(|target| self.rows.len() >= target);
147 if capped {
148 return Ok(Some(TradeTickStop::CallerCapped));
149 }
150
151 let unbounded_capped = self.start.is_none()
154 && self.limit.is_none()
155 && self.rows.len() >= MAX_UNBOUNDED_WALK_ROWS;
156 Ok(unbounded_capped.then_some(TradeTickStop::PageCapReached))
157 }
158
159 fn finish(self, completion: &Completion<Self::Stop>) -> anyhow::Result<Self::Output> {
160 if let Some(start) = self.start
161 && matches!(completion, Completion::WireExhausted)
162 && start_predates_retention_window(
163 (start.as_u64() / 1_000_000_000) as i64,
164 (get_atomic_clock_realtime().get_time_ns().as_u64() / 1_000_000_000) as i64,
165 )
166 {
167 log::warn!(
168 "Polymarket Data API trades start predates the approximate three-year retention window for condition {}; results may be incomplete",
169 self.condition_id
170 );
171 }
172
173 let start_secs = self
174 .start
175 .map(|value| (value.as_u64() / 1_000_000_000) as i64);
176 let end_secs = self
177 .end
178 .map(|value| (value.as_u64() / 1_000_000_000) as i64);
179 let mut trades = parse_trade_ticks(
180 self.rows,
181 self.instrument_id,
182 &self.token_id,
183 self.price_precision,
184 self.size_precision,
185 )?;
186 trades.retain(|trade| {
187 let event_secs = trade.ts_event.as_u64() / 1_000_000_000;
188 start_secs.is_none_or(|start| event_secs >= start as u64)
189 && end_secs.is_none_or(|end| event_secs <= end as u64)
190 });
191
192 if let Some(target) = self.limit
193 && trades.len() > target
194 {
195 if self.start.is_some() {
196 trades.truncate(target);
197 } else {
198 trades.drain(..trades.len() - target);
199 }
200 }
201
202 Ok(trades)
203 }
204}
205
206#[derive(Debug, Clone)]
210pub struct PolymarketDataApiHttpClient {
211 client: HttpClient,
212 base_url: String,
213}
214
215impl PolymarketDataApiHttpClient {
216 pub fn new(base_url: Option<String>, timeout_secs: u64) -> StdResult<Self, HttpClientError> {
222 Self::new_with_proxy(base_url, timeout_secs, None)
223 }
224
225 pub fn new_with_proxy(
231 base_url: Option<String>,
232 timeout_secs: u64,
233 proxy_url: Option<ProxyUrl>,
234 ) -> StdResult<Self, HttpClientError> {
235 let mut headers: HashMap<String, String> =
236 create_standard_nautilus_headers().into_iter().collect();
237 headers.insert("Content-Type".to_string(), "application/json".to_string());
238
239 Ok(Self {
240 client: HttpClient::builder()
241 .headers(headers)
242 .timeout_secs(timeout_secs)
243 .maybe_proxy_url(proxy_url.map(|url| url.expose().to_string()))
244 .build()?,
245 base_url: base_url
246 .unwrap_or_else(|| data_api_url().to_string())
247 .trim_end_matches('/')
248 .to_string(),
249 })
250 }
251
252 pub async fn get_positions(&self, user_address: &str) -> Result<Vec<DataApiPosition>> {
258 const PAGE_SIZE: u32 = 500;
261
262 let protocol = CursorProtocol::<Infallible>::gamma(PATH_POSITIONS);
263 let paginator = Paginator::new(PATH_POSITIONS, protocol, CollectAll::new());
264 let completed = paginator
265 .run(
266 |position| async move {
267 let page = self
268 .get_positions_page(
269 user_address,
270 PAGE_SIZE,
271 position.as_ref().map(|cursor| cursor.as_ref()),
272 )
273 .await?;
274 Ok(FetchOutcome::Page {
275 rows: page.data,
276 wire: page.pagination.next_cursor,
277 })
278 },
279 |e| Error::decode(e.to_string()),
280 )
281 .await?;
282
283 match completed.completion {
284 Completion::WireExhausted => Ok(completed.output),
285 Completion::Stopped(never) => match never {},
286 }
287 }
288
289 async fn get_positions_page(
290 &self,
291 user_address: &str,
292 limit: u32,
293 cursor: Option<&str>,
294 ) -> Result<DataApiPage<DataApiPosition>> {
295 let mut params = vec![
296 ("user".to_string(), user_address.to_string()),
297 ("limit".to_string(), limit.to_string()),
298 ("filter_type".to_string(), "TOKENS".to_string()),
299 ("filter_amount".to_string(), "0".to_string()),
300 ("sort_by".to_string(), "TOKENS".to_string()),
301 ("sort_direction".to_string(), "DESC".to_string()),
302 ];
303
304 if let Some(cursor) = cursor {
305 params.push(("cursor".to_string(), cursor.to_string()));
306 }
307
308 let url = format!("{}{PATH_POSITIONS}", self.base_url);
309 let response = self
310 .client
311 .request_with_params(Method::GET, url, Some(¶ms), None, None, None, None)
312 .await
313 .map_err(Error::from_http_client)?;
314
315 decode_response(&response)
316 }
317
318 pub async fn get_trades(
321 &self,
322 condition_id: &str,
323 limit: Option<u32>,
324 ) -> Result<Vec<DataApiTrade>> {
325 Ok(self.get_trades_page(condition_id, limit, None).await?.data)
326 }
327
328 #[expect(clippy::too_many_arguments)]
339 pub async fn request_trade_ticks(
340 &self,
341 instrument_id: InstrumentId,
342 condition_id: &str,
343 token_id: &str,
344 price_precision: u8,
345 size_precision: u8,
346 start: Option<UnixNanos>,
347 end: Option<UnixNanos>,
348 limit: Option<u32>,
349 ) -> anyhow::Result<Vec<TradeTick>> {
350 const PAGE_SIZE: u32 = 1000;
353
354 if let (Some(start), Some(end)) = (start, end)
355 && start > end
356 {
357 anyhow::bail!("start must not be later than end");
358 }
359
360 if limit == Some(0) {
361 anyhow::bail!("limit must be greater than zero");
362 }
363
364 let protocol = CursorProtocol::<TradeTickStop>::gamma(PATH_TRADES);
365 let reducer = TradeTickReducer {
366 rows: Vec::new(),
367 instrument_id,
368 condition_id: condition_id.to_string(),
369 token_id: token_id.to_string(),
370 price_precision,
371 size_precision,
372 start,
373 end,
374 limit: limit.map(|value| value as usize),
375 };
376 let paginator = Paginator::new(PATH_TRADES, protocol, reducer);
377 let completed = paginator
378 .run(
379 |position| async move {
380 let page = self
381 .get_trades_page(
382 condition_id,
383 Some(PAGE_SIZE),
384 position.as_ref().map(|cursor| cursor.as_ref()),
385 )
386 .await
387 .map_err(anyhow::Error::new)?;
388 let rows = validate_trade_page_scope(page.data, condition_id)?;
389 Ok::<_, anyhow::Error>(FetchOutcome::Page {
390 rows,
391 wire: page.pagination.next_cursor,
392 })
393 },
394 anyhow::Error::new,
395 )
396 .await?;
397
398 match completed.completion {
399 Completion::WireExhausted
400 | Completion::Stopped(TradeTickStop::CallerCapped | TradeTickStop::OlderThanStart) => {
401 Ok(completed.output)
402 }
403 Completion::Stopped(TradeTickStop::PageCapReached) => {
404 log::warn!(
405 "Polymarket Data API trades walk for condition {condition_id} capped at {MAX_UNBOUNDED_WALK_ROWS} rows; returning newest partial results, bound the request with start or limit for more",
406 );
407 Ok(completed.output)
408 }
409 }
410 }
411
412 async fn get_trades_page(
413 &self,
414 condition_id: &str,
415 limit: Option<u32>,
416 cursor: Option<&str>,
417 ) -> Result<DataApiPage<DataApiTrade>> {
418 let mut params = vec![("condition".to_string(), condition_id.to_string())];
419
420 if let Some(limit) = limit {
421 params.push(("limit".to_string(), limit.to_string()));
422 }
423
424 if let Some(cursor) = cursor {
425 params.push(("cursor".to_string(), cursor.to_string()));
426 }
427
428 let url = format!("{}{PATH_TRADES}", self.base_url);
429 let response = self
430 .client
431 .request_with_params(Method::GET, url, Some(¶ms), None, None, None, None)
432 .await
433 .map_err(Error::from_http_client)?;
434
435 decode_response(&response)
436 }
437}
438
439fn start_predates_retention_window(start_secs: i64, now_secs: i64) -> bool {
440 start_secs < now_secs - TRADE_RETENTION_SECONDS
441}
442
443fn parse_trade_ticks(
446 mut data_api_trades: Vec<DataApiTrade>,
447 instrument_id: InstrumentId,
448 token_id: &str,
449 price_precision: u8,
450 size_precision: u8,
451) -> anyhow::Result<Vec<TradeTick>> {
452 data_api_trades.sort_by(|a, b| data_api_trade_sort_key(a).cmp(&data_api_trade_sort_key(b)));
454
455 let mut timestamp_counts: HashMap<u64, u32> = HashMap::new();
456 let mut tx_asset_counts: HashMap<(String, String), u32> = HashMap::new();
457 let mut trades: Vec<TradeTick> = Vec::new();
458
459 for t in data_api_trades {
460 if t.asset != token_id {
461 continue;
462 }
463
464 let price = Price::from_decimal_dp(t.price, price_precision).with_context(|| {
465 format!(
466 "failed to convert Data API trade price {} with precision {price_precision}",
467 t.price
468 )
469 })?;
470 let size = Quantity::from_decimal_dp(t.size, size_precision).with_context(|| {
471 format!(
472 "failed to convert Data API trade size {} with precision {size_precision}",
473 t.size
474 )
475 })?;
476 let aggressor_side = AggressorSide::from(t.side);
477
478 let base_ns = (t.timestamp as u64) * 1_000_000_000;
479 let occurrence = timestamp_counts.entry(base_ns).or_insert(0);
480 let tiebreaker = (*occurrence).min(999_999_999) as u64;
481 *occurrence += 1;
482 let ts_event = nautilus_core::UnixNanos::from(base_ns + tiebreaker);
483
484 let key = (t.transaction_hash.clone(), t.asset.clone());
485 let seq = *tx_asset_counts
486 .entry(key)
487 .and_modify(|n| *n += 1)
488 .or_insert(0);
489 let trade_id = TradeId::new(build_polymarket_trade_id(
490 &t.transaction_hash,
491 &t.asset,
492 seq,
493 ));
494
495 trades.push(TradeTick::new(
496 instrument_id,
497 price,
498 size,
499 aggressor_side,
500 trade_id,
501 ts_event,
502 ts_event,
503 ));
504 }
505
506 Ok(trades)
507}
508
509#[cfg(test)]
510mod tests {
511 use nautilus_model::{enums::AggressorSide, identifiers::InstrumentId};
512 use rstest::rstest;
513 use rust_decimal_macros::dec;
514
515 use super::*;
516 use crate::http::models::{DataApiPosition, DataApiTrade};
517
518 fn load_positions() -> Vec<DataApiPosition> {
519 let path = "test_data/data_api_positions_response.json";
520 let content = std::fs::read_to_string(path).expect("Failed to read test data");
521 let page: DataApiPage<DataApiPosition> =
522 serde_json::from_str(&content).expect("Failed to parse test data");
523 page.data
524 }
525
526 fn load_trades() -> Vec<DataApiTrade> {
527 let path = "test_data/data_api_trades_response.json";
529 let content = std::fs::read_to_string(path).expect("Failed to read test data");
530 let page: DataApiPage<DataApiTrade> =
531 serde_json::from_str(&content).expect("Failed to parse test data");
532 page.data
533 }
534
535 #[rstest]
536 #[case::before(99, true)]
537 #[case::at(100, false)]
538 #[case::after(101, false)]
539 fn test_start_predates_retention_window(#[case] start: i64, #[case] expected: bool) {
540 assert_eq!(
541 start_predates_retention_window(start, TRADE_RETENTION_SECONDS + 100),
542 expected,
543 );
544 }
545
546 #[rstest]
547 #[case::caller_limit(Some(2), TradeTickStop::CallerCapped)]
548 #[case::retained_cap(None, TradeTickStop::PageCapReached)]
549 fn test_trade_reducer_discards_irrelevant_rows(
550 #[case] limit: Option<usize>,
551 #[case] expected_stop: TradeTickStop,
552 ) {
553 let mut reducer = TradeTickReducer {
554 rows: Vec::new(),
555 instrument_id: test_instrument_id(),
556 condition_id: "0xcond".to_string(),
557 token_id: "token_aaa".to_string(),
558 price_precision: 2,
559 size_precision: 2,
560 start: None,
561 end: Some(UnixNanos::from(100_000_000_000_u64)),
562 limit,
563 };
564 let matching = make_trade(
565 100,
566 "0xmatch",
567 "token_aaa",
568 PolymarketOrderSide::Buy,
569 0.5,
570 2.0,
571 );
572 let newer = make_trade(
573 101,
574 "0xnew",
575 "token_aaa",
576 PolymarketOrderSide::Buy,
577 0.6,
578 3.0,
579 );
580 let other = make_trade(
581 99,
582 "0xother",
583 "token_bbb",
584 PolymarketOrderSide::Sell,
585 0.4,
586 4.0,
587 );
588
589 for _ in 0..12 {
590 let stop = reducer.consume(vec![newer.clone(); 1_000]).unwrap();
591 assert_eq!(stop, None);
592 assert_eq!(reducer.rows.len(), 0);
593 }
594
595 let stop = reducer
596 .consume(vec![newer, other, matching.clone()])
597 .unwrap();
598 assert_eq!(stop, None);
599 assert_eq!(reducer.rows.len(), 1);
600 assert_eq!(reducer.rows[0].transaction_hash, "0xmatch");
601
602 let target = limit.unwrap_or(MAX_UNBOUNDED_WALK_ROWS);
603 let stop = reducer.consume(vec![matching; target - 1]).unwrap();
604 assert_eq!(stop, Some(expected_stop));
605 assert_eq!(reducer.rows.len(), target);
606 }
607
608 #[rstest]
609 #[case::start_after_end(
610 Some(nautilus_core::UnixNanos::from(2_u64)),
611 Some(nautilus_core::UnixNanos::from(1_u64)),
612 None,
613 "start must not be later than end"
614 )]
615 #[case::zero_limit(None, None, Some(0), "limit must be greater than zero")]
616 #[tokio::test]
617 async fn test_request_trade_ticks_rejects_invalid_arguments(
618 #[case] start: Option<UnixNanos>,
619 #[case] end: Option<UnixNanos>,
620 #[case] limit: Option<u32>,
621 #[case] expected_error: &str,
622 ) {
623 let client = PolymarketDataApiHttpClient::new(None, 5).unwrap();
624
625 let error = client
626 .request_trade_ticks(
627 test_instrument_id(),
628 "0xcondition_test",
629 "token_aaa",
630 2,
631 2,
632 start,
633 end,
634 limit,
635 )
636 .await
637 .expect_err("invalid arguments must fail before any request");
638
639 assert_eq!(error.to_string(), expected_error);
640 }
641
642 #[rstest]
643 fn test_data_api_position_deserialization() {
644 let positions = load_positions();
645
646 assert_eq!(positions.len(), 4);
647 assert_eq!(positions[0].size, dec!(150.5));
648 assert_eq!(positions[0].avg_price, Some(dec!(0.55)));
649 assert_eq!(
650 positions[0].condition_id,
651 "0xc8f1cf5d4f26e0fd9c8fe89f2a7b3263b902cf14fde7bfccef525753bb492e47"
652 );
653 }
654
655 #[rstest]
656 fn test_data_api_trade_deserialization() {
657 let trades = load_trades();
658
659 assert_eq!(trades.len(), 3);
660
661 assert_eq!(
662 trades[0].asset,
663 "71321045863084981365469005770620412523470745398083994982746259498689308907982"
664 );
665 assert_eq!(
666 trades[0].condition_id,
667 "0xc8f1cf5d4f26e0fd9c8fe89f2a7b3263b902cf14fde7bfccef525753bb492e47"
668 );
669 assert_eq!(trades[0].price, dec!(0.55));
670 assert_eq!(trades[0].size, dec!(100.0));
671 assert_eq!(trades[0].timestamp, 1710000000);
672 assert_eq!(
673 trades[0].transaction_hash,
674 "0xabc123def456789012345678901234567890abcdef1234567890abcdef123456"
675 );
676 }
677
678 #[rstest]
679 fn test_data_api_trade_ignores_extra_fields() {
680 let trades = load_trades();
681 assert_eq!(trades.len(), 3);
683 }
684
685 #[rstest]
686 fn test_build_trade_ticks_filters_by_token_id() {
687 let trades = load_trades();
688 let instrument_id = InstrumentId::from(
689 "0xc8f1cf5d4f26e0fd9c8fe89f2a7b3263b902cf14fde7bfccef525753bb492e47-71321045863084981365469005770620412523470745398083994982746259498689308907982.POLYMARKET",
690 );
691 let token_id =
692 "71321045863084981365469005770620412523470745398083994982746259498689308907982";
693 let price_precision = 2u8;
694 let size_precision = 2u8;
695
696 let ticks: Vec<TradeTick> = trades
697 .into_iter()
698 .filter(|t| t.asset == token_id)
699 .map(|t| {
700 let price = Price::from_decimal_dp(t.price, price_precision).unwrap();
701 let size = Quantity::from_decimal_dp(t.size, size_precision).unwrap();
702 let aggressor_side = AggressorSide::from(t.side);
703 let hash = &t.transaction_hash;
705 let trade_id_str = if hash.len() > 36 {
706 &hash[hash.len() - 36..]
707 } else {
708 hash.as_str()
709 };
710 let trade_id = TradeId::new(trade_id_str);
711 let ts_event = nautilus_core::UnixNanos::from(t.timestamp as u64 * 1_000_000_000);
712
713 TradeTick::new(
714 instrument_id,
715 price,
716 size,
717 aggressor_side,
718 trade_id,
719 ts_event,
720 ts_event,
721 )
722 })
723 .collect();
724
725 assert_eq!(ticks.len(), 2);
727 assert_eq!(ticks[0].aggressor_side, AggressorSide::Buy);
728 assert_eq!(ticks[1].aggressor_side, AggressorSide::Sell);
729 }
730
731 #[rstest]
732 fn test_build_trade_ticks_chronological_order() {
733 let trades = load_trades();
734 let instrument_id = InstrumentId::from(
735 "0xc8f1cf5d4f26e0fd9c8fe89f2a7b3263b902cf14fde7bfccef525753bb492e47-71321045863084981365469005770620412523470745398083994982746259498689308907982.POLYMARKET",
736 );
737 let token_id =
738 "71321045863084981365469005770620412523470745398083994982746259498689308907982";
739
740 let mut ticks: Vec<TradeTick> = trades
741 .into_iter()
742 .filter(|t| t.asset == token_id)
743 .map(|t| {
744 let price = Price::from_decimal_dp(t.price, 2).unwrap();
745 let size = Quantity::from_decimal_dp(t.size, 2).unwrap();
746 let aggressor_side = AggressorSide::from(t.side);
747 let hash = &t.transaction_hash;
749 let trade_id_str = if hash.len() > 36 {
750 &hash[hash.len() - 36..]
751 } else {
752 hash.as_str()
753 };
754 let trade_id = TradeId::new(trade_id_str);
755 let ts_event = nautilus_core::UnixNanos::from(t.timestamp as u64 * 1_000_000_000);
756
757 TradeTick::new(
758 instrument_id,
759 price,
760 size,
761 aggressor_side,
762 trade_id,
763 ts_event,
764 ts_event,
765 )
766 })
767 .collect();
768
769 ticks.reverse();
771
772 assert_eq!(ticks.len(), 2);
773 assert!(ticks[0].ts_event < ticks[1].ts_event);
775 }
776
777 fn make_trade(
778 timestamp: i64,
779 transaction_hash: &str,
780 asset: &str,
781 side: PolymarketOrderSide,
782 price: f64,
783 size: f64,
784 ) -> DataApiTrade {
785 DataApiTrade {
786 proxy_wallet: None,
787 asset: asset.to_string(),
788 condition_id: "0xcond".to_string(),
789 side,
790 price: Decimal::from_str_exact(&price.to_string()).unwrap(),
791 size: Decimal::from_str_exact(&size.to_string()).unwrap(),
792 timestamp,
793 title: None,
794 slug: None,
795 icon: None,
796 event_slug: None,
797 outcome: None,
798 outcome_index: None,
799 name: None,
800 pseudonym: None,
801 bio: None,
802 profile_image: None,
803 profile_image_optimized: None,
804 transaction_hash: transaction_hash.to_string(),
805 }
806 }
807
808 fn test_instrument_id() -> InstrumentId {
809 InstrumentId::from(
810 "0xc8f1cf5d4f26e0fd9c8fe89f2a7b3263b902cf14fde7bfccef525753bb492e47-71321045863084981365469005770620412523470745398083994982746259498689308907982.POLYMARKET",
811 )
812 }
813
814 #[rstest]
815 fn test_data_api_trade_sort_key_orders_pages_deterministically() {
816 let mut trades = [
817 make_trade(1729000005, "0xZ", "T", PolymarketOrderSide::Buy, 0.5, 1.0),
818 make_trade(1729000000, "0xC", "T", PolymarketOrderSide::Buy, 0.5, 1.0),
819 make_trade(1729000000, "0xA", "T", PolymarketOrderSide::Sell, 0.5, 1.0),
820 make_trade(1729000000, "0xB", "T", PolymarketOrderSide::Buy, 0.5, 1.0),
821 ];
822
823 trades.sort_by(|a, b| data_api_trade_sort_key(a).cmp(&data_api_trade_sort_key(b)));
824
825 let order: Vec<&str> = trades.iter().map(|t| t.transaction_hash.as_str()).collect();
826 assert_eq!(order, ["0xA", "0xB", "0xC", "0xZ"]);
827 }
828
829 #[rstest]
830 fn test_data_api_trade_sort_key_uses_full_composite_for_inner_ties() {
831 let mut trades = [
833 make_trade(1, "0xH", "Tb", PolymarketOrderSide::Buy, 0.5, 1.0),
835 make_trade(1, "0xH", "Ta", PolymarketOrderSide::Sell, 0.5, 1.0),
836 make_trade(1, "0xH", "Ta", PolymarketOrderSide::Buy, 0.6, 1.0),
837 make_trade(1, "0xH", "Ta", PolymarketOrderSide::Buy, 0.5, 2.0),
838 make_trade(1, "0xH", "Ta", PolymarketOrderSide::Buy, 0.5, 1.0),
839 ];
840
841 trades.sort_by(|a, b| data_api_trade_sort_key(a).cmp(&data_api_trade_sort_key(b)));
842
843 let key: Vec<(String, String, Decimal, Decimal)> = trades
851 .iter()
852 .map(|t| (t.asset.clone(), t.side.to_string(), t.price, t.size))
853 .collect();
854 assert_eq!(key[0], ("Ta".into(), "BUY".into(), dec!(0.5), dec!(1.0)));
855 assert_eq!(key[1], ("Ta".into(), "BUY".into(), dec!(0.5), dec!(2.0)));
856 assert_eq!(key[2], ("Ta".into(), "BUY".into(), dec!(0.6), dec!(1.0)));
857 assert_eq!(key[3], ("Ta".into(), "SELL".into(), dec!(0.5), dec!(1.0)));
858 assert_eq!(key[4], ("Tb".into(), "BUY".into(), dec!(0.5), dec!(1.0)));
859 }
860
861 #[rstest]
862 fn test_parse_trade_ticks_filters_other_tokens() {
863 let token_id = "T_KEEP";
864 let trades = vec![
865 make_trade(
866 1729000000,
867 "0xa",
868 token_id,
869 PolymarketOrderSide::Buy,
870 0.5,
871 1.0,
872 ),
873 make_trade(
874 1729000000,
875 "0xb",
876 "T_DROP",
877 PolymarketOrderSide::Sell,
878 0.5,
879 1.0,
880 ),
881 ];
882
883 let trades = parse_trade_ticks(trades, test_instrument_id(), token_id, 2, 2).unwrap();
884
885 assert_eq!(trades.len(), 1);
886 assert_eq!(trades[0].aggressor_side, AggressorSide::Buy);
887 }
888
889 #[rstest]
890 fn test_parse_trade_ticks_disambiguates_multi_fill_transaction() {
891 let token_id = "12345token";
893 let same_hash = "0x000000000000000000000000000000000000000000000000000000000000abcdef";
894 let trades = vec![
895 make_trade(
896 1729000000,
897 same_hash,
898 token_id,
899 PolymarketOrderSide::Buy,
900 0.5,
901 1.0,
902 ),
903 make_trade(
904 1729000000,
905 same_hash,
906 token_id,
907 PolymarketOrderSide::Sell,
908 0.5,
909 1.0,
910 ),
911 ];
912
913 let trades = parse_trade_ticks(trades, test_instrument_id(), token_id, 2, 2).unwrap();
914
915 assert_eq!(trades.len(), 2);
916 assert_ne!(trades[0].trade_id, trades[1].trade_id);
917 assert!(trades[0].ts_event < trades[1].ts_event);
919 assert_eq!(
920 u64::from(trades[1].ts_event) - u64::from(trades[0].ts_event),
921 1
922 );
923 assert!(trades[0].trade_id.to_string().ends_with("-000000"));
925 assert!(trades[1].trade_id.to_string().ends_with("-000001"));
926 }
927
928 #[rstest]
929 fn test_parse_trade_ticks_distinct_tx_share_timestamp() {
930 let token_id = "T";
933 let trades = vec![
934 make_trade(
935 1729000000,
936 "0xtx1",
937 token_id,
938 PolymarketOrderSide::Buy,
939 0.5,
940 1.0,
941 ),
942 make_trade(
943 1729000000,
944 "0xtx2",
945 token_id,
946 PolymarketOrderSide::Buy,
947 0.5,
948 1.0,
949 ),
950 make_trade(
951 1729000000,
952 "0xtx3",
953 token_id,
954 PolymarketOrderSide::Buy,
955 0.5,
956 1.0,
957 ),
958 ];
959
960 let trades = parse_trade_ticks(trades, test_instrument_id(), token_id, 2, 2).unwrap();
961
962 assert_eq!(trades.len(), 3);
963 assert!(trades[0].ts_event < trades[1].ts_event);
965 assert!(trades[1].ts_event < trades[2].ts_event);
966 for trade in &trades {
968 assert!(trade.trade_id.to_string().ends_with("-000000"));
969 }
970 }
971
972 #[rstest]
973 fn test_parse_trade_ticks_assigns_per_second_tiebreakers() {
974 let token_id = "T";
977 let mut trades = Vec::new();
978
979 for i in 0..3 {
980 let hash = format!("0x{i:064x}");
981 trades.push(make_trade(
982 1729000000,
983 &hash,
984 token_id,
985 PolymarketOrderSide::Buy,
986 0.5,
987 1.0,
988 ));
989 }
990
991 let trades = parse_trade_ticks(trades, test_instrument_id(), token_id, 2, 2).unwrap();
992
993 assert_eq!(trades.len(), 3);
994 let base_ns = 1_729_000_000u64 * 1_000_000_000;
995
996 for (i, trade) in trades.iter().enumerate() {
997 assert!(u64::from(trade.ts_event) - base_ns < 1_000_000_000);
998 assert_eq!(u64::from(trade.ts_event) - base_ns, i as u64);
999 }
1000 }
1001
1002 #[rstest]
1003 fn test_parse_trade_ticks_sorts_inputs_by_composite_key() {
1004 let token_id = "T";
1007 let trades = vec![
1008 make_trade(
1009 1729000005,
1010 "0xZ",
1011 token_id,
1012 PolymarketOrderSide::Buy,
1013 0.5,
1014 1.0,
1015 ),
1016 make_trade(
1017 1729000000,
1018 "0xC",
1019 token_id,
1020 PolymarketOrderSide::Buy,
1021 0.5,
1022 1.0,
1023 ),
1024 make_trade(
1025 1729000000,
1026 "0xA",
1027 token_id,
1028 PolymarketOrderSide::Sell,
1029 0.5,
1030 1.0,
1031 ),
1032 make_trade(
1033 1729000000,
1034 "0xB",
1035 token_id,
1036 PolymarketOrderSide::Buy,
1037 0.5,
1038 1.0,
1039 ),
1040 ];
1041
1042 let trades = parse_trade_ticks(trades, test_instrument_id(), token_id, 2, 2).unwrap();
1043
1044 assert_eq!(trades.len(), 4);
1045
1046 for i in 1..trades.len() {
1048 assert!(trades[i - 1].ts_event <= trades[i].ts_event);
1049 }
1050
1051 let trade_ids: Vec<String> = trades.iter().map(|t| t.trade_id.to_string()).collect();
1053 assert!(trade_ids[0].contains("0xA"));
1054 assert!(trade_ids[1].contains("0xB"));
1055 assert!(trade_ids[2].contains("0xC"));
1056 assert!(trade_ids[3].contains("0xZ"));
1057 }
1058
1059 #[rstest]
1060 fn test_parse_trade_ticks_propagates_invalid_price() {
1061 let token_id = "T";
1062 let mut trade = make_trade(
1063 1729000000,
1064 "0xtx",
1065 token_id,
1066 PolymarketOrderSide::Buy,
1067 0.5,
1068 1.0,
1069 );
1070 trade.price = Decimal::from_str_exact("99999999999999999999.99").unwrap();
1071
1072 let error = parse_trade_ticks(vec![trade], test_instrument_id(), token_id, 2, 2)
1073 .expect_err("out-of-range price should fail");
1074
1075 assert_eq!(
1076 error.to_string(),
1077 "failed to convert Data API trade price 99999999999999999999.99 with precision 2"
1078 );
1079 assert_eq!(error.chain().count(), 2);
1080 }
1081
1082 #[rstest]
1083 fn test_parse_trade_ticks_propagates_invalid_size() {
1084 let token_id = "T";
1085 let trade = make_trade(
1086 1729000000,
1087 "0xtx",
1088 token_id,
1089 PolymarketOrderSide::Buy,
1090 0.5,
1091 -1.5,
1092 );
1093
1094 let error = parse_trade_ticks(vec![trade], test_instrument_id(), token_id, 2, 2)
1095 .expect_err("negative size should fail");
1096
1097 assert_eq!(
1098 error.to_string(),
1099 "failed to convert Data API trade size -1.5 with precision 2"
1100 );
1101 assert_eq!(error.chain().count(), 2);
1102 }
1103}