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