1use std::{collections::HashMap, result::Result as StdResult};
19
20use nautilus_core::consts::NAUTILUS_USER_AGENT;
21use nautilus_model::{
22 data::TradeTick,
23 enums::AggressorSide,
24 identifiers::{InstrumentId, TradeId},
25 types::{Price, Quantity},
26};
27use nautilus_network::http::{HttpClient, HttpClientError, Method, USER_AGENT};
28
29use crate::{
30 common::enums::PolymarketOrderSide,
31 http::{
32 error::{Error, Result},
33 models::{DataApiPosition, DataApiTrade},
34 },
35};
36
37fn data_api_trade_sort_key(t: &DataApiTrade) -> (i64, &str, &str, &'static str, String, String) {
39 (
40 t.timestamp,
41 t.transaction_hash.as_str(),
42 t.asset.as_str(),
43 match t.side {
44 PolymarketOrderSide::Buy => "BUY",
45 PolymarketOrderSide::Sell => "SELL",
46 },
47 t.price.to_string(),
48 t.size.to_string(),
49 )
50}
51
52pub(crate) fn build_polymarket_trade_id(transaction_hash: &str, asset: &str, seq: u32) -> String {
56 let hash_suffix = if transaction_hash.len() > 24 {
57 &transaction_hash[transaction_hash.len() - 24..]
58 } else {
59 transaction_hash
60 };
61 let asset_suffix = if asset.len() > 4 {
62 &asset[asset.len() - 4..]
63 } else {
64 asset
65 };
66 format!("{hash_suffix}-{asset_suffix}-{seq:06}")
67}
68
69const POLYMARKET_DATA_API_URL: &str = "https://data-api.polymarket.com";
70
71#[derive(Debug, Clone)]
75pub struct PolymarketDataApiHttpClient {
76 client: HttpClient,
77 base_url: String,
78}
79
80impl PolymarketDataApiHttpClient {
81 pub fn new(base_url: Option<String>, timeout_secs: u64) -> StdResult<Self, HttpClientError> {
87 Ok(Self {
88 client: HttpClient::new(
89 HashMap::from([
90 (USER_AGENT.to_string(), NAUTILUS_USER_AGENT.to_string()),
91 ("Content-Type".to_string(), "application/json".to_string()),
92 ]),
93 vec![],
94 vec![],
95 None,
96 Some(timeout_secs),
97 None,
98 )?,
99 base_url: base_url
100 .unwrap_or_else(|| POLYMARKET_DATA_API_URL.to_string())
101 .trim_end_matches('/')
102 .to_string(),
103 })
104 }
105
106 pub async fn get_positions(&self, user_address: &str) -> Result<Vec<DataApiPosition>> {
111 const PAGE_SIZE: u32 = 100;
112
113 let mut all_positions: Vec<DataApiPosition> = Vec::new();
114 let mut offset: u32 = 0;
115
116 loop {
117 let params = vec![
118 ("user".to_string(), user_address.to_string()),
119 ("limit".to_string(), PAGE_SIZE.to_string()),
120 ("offset".to_string(), offset.to_string()),
121 ("sizeThreshold".to_string(), "0".to_string()),
122 ("sortBy".to_string(), "TOKENS".to_string()),
123 ("sortDirection".to_string(), "DESC".to_string()),
124 ];
125
126 let url = format!("{}/positions", self.base_url);
127 let response = self
128 .client
129 .request_with_params(Method::GET, url, Some(¶ms), None, None, None, None)
130 .await
131 .map_err(Error::from_http_client)?;
132
133 if response.status.is_success() {
134 let page: Vec<DataApiPosition> =
135 serde_json::from_slice(&response.body).map_err(Error::Serde)?;
136 let count = page.len() as u32;
137 all_positions.extend(page);
138
139 if count < PAGE_SIZE {
140 break;
141 }
142 offset += count;
143 } else {
144 return Err(Error::from_status_code(
145 response.status.as_u16(),
146 &response.body,
147 ));
148 }
149 }
150
151 Ok(all_positions)
152 }
153
154 pub async fn get_trades(
156 &self,
157 condition_id: &str,
158 limit: Option<u32>,
159 offset: Option<u32>,
160 ) -> Result<Vec<DataApiTrade>> {
161 let mut params = vec![("market".to_string(), condition_id.to_string())];
162
163 if let Some(l) = limit {
164 params.push(("limit".to_string(), l.to_string()));
165 }
166
167 if let Some(o) = offset {
168 params.push(("offset".to_string(), o.to_string()));
169 }
170
171 let url = format!("{}/trades", self.base_url);
172 let response = self
173 .client
174 .request_with_params(Method::GET, url, Some(¶ms), None, None, None, None)
175 .await
176 .map_err(Error::from_http_client)?;
177
178 if response.status.is_success() {
179 serde_json::from_slice(&response.body).map_err(Error::Serde)
180 } else {
181 Err(Error::from_status_code(
182 response.status.as_u16(),
183 &response.body,
184 ))
185 }
186 }
187
188 pub async fn request_trade_ticks(
199 &self,
200 instrument_id: InstrumentId,
201 condition_id: &str,
202 token_id: &str,
203 price_precision: u8,
204 size_precision: u8,
205 limit: Option<u32>,
206 ) -> anyhow::Result<Vec<TradeTick>> {
207 const PAGE_SIZE: u32 = 500;
208 const MAX_OFFSET: u32 = 3000;
210
211 let page_size = limit.map_or(PAGE_SIZE, |l| l.min(PAGE_SIZE));
212 let mut all_trades: Vec<DataApiTrade> = Vec::new();
213 let mut offset: u32 = 0;
214
215 loop {
216 let page = match self
217 .get_trades(condition_id, Some(page_size), Some(offset))
218 .await
219 {
220 Ok(page) => page,
221 Err(e) => {
222 if format!("{e}").contains("max historical activity offset") {
223 log::warn!(
225 "Polymarket public trades API hit its historical offset \
226 ceiling for condition {condition_id}; returning partial \
227 results: {e}",
228 );
229 break;
230 }
231 anyhow::bail!(e);
232 }
233 };
234
235 let count = page.len() as u32;
236 all_trades.extend(page);
237
238 if count < page_size {
240 break;
241 }
242 if let Some(target) = limit
244 && all_trades.len() as u32 >= target
245 {
246 break;
247 }
248 offset += count;
249 if offset >= MAX_OFFSET {
251 break;
252 }
253 }
254
255 if let Some(target) = limit {
257 all_trades.truncate(target as usize);
258 }
259
260 Ok(parse_trade_ticks(
261 all_trades,
262 instrument_id,
263 token_id,
264 price_precision,
265 size_precision,
266 ))
267 }
268}
269
270fn parse_trade_ticks(
273 mut data_api_trades: Vec<DataApiTrade>,
274 instrument_id: InstrumentId,
275 token_id: &str,
276 price_precision: u8,
277 size_precision: u8,
278) -> Vec<TradeTick> {
279 data_api_trades.sort_by(|a, b| data_api_trade_sort_key(a).cmp(&data_api_trade_sort_key(b)));
281
282 let mut timestamp_counts: HashMap<u64, u32> = HashMap::new();
283 let mut tx_asset_counts: HashMap<(String, String), u32> = HashMap::new();
284 let mut trades: Vec<TradeTick> = Vec::new();
285
286 for t in data_api_trades {
287 if t.asset != token_id {
288 continue;
289 }
290
291 let price = Price::new(t.price, price_precision);
292 let size = Quantity::new(t.size, size_precision);
293 let aggressor_side = AggressorSide::from(t.side);
294
295 let base_ns = (t.timestamp as u64) * 1_000_000_000;
296 let occurrence = timestamp_counts.entry(base_ns).or_insert(0);
297 let tiebreaker = (*occurrence).min(999_999_999) as u64;
298 *occurrence += 1;
299 let ts_event = nautilus_core::UnixNanos::from(base_ns + tiebreaker);
300
301 let key = (t.transaction_hash.clone(), t.asset.clone());
302 let seq = *tx_asset_counts
303 .entry(key)
304 .and_modify(|n| *n += 1)
305 .or_insert(0);
306 let trade_id = TradeId::new(build_polymarket_trade_id(
307 &t.transaction_hash,
308 &t.asset,
309 seq,
310 ));
311
312 trades.push(TradeTick::new(
313 instrument_id,
314 price,
315 size,
316 aggressor_side,
317 trade_id,
318 ts_event,
319 ts_event,
320 ));
321 }
322
323 trades
324}
325
326#[cfg(test)]
327mod tests {
328 use nautilus_model::{
329 enums::AggressorSide,
330 identifiers::{AccountId, InstrumentId},
331 };
332 use rstest::rstest;
333 use rust_decimal::Decimal;
334
335 use super::*;
336 use crate::{
337 common::consts::USDC_DECIMALS,
338 execution::reconciliation::build_position_reports,
339 http::models::{DataApiPosition, DataApiTrade},
340 };
341
342 fn load_positions() -> Vec<DataApiPosition> {
343 let path = "test_data/data_api_positions_response.json";
344 let content = std::fs::read_to_string(path).expect("Failed to read test data");
345 serde_json::from_str(&content).expect("Failed to parse test data")
346 }
347
348 fn load_trades() -> Vec<DataApiTrade> {
349 let path = "test_data/data_api_trades_response.json";
350 let content = std::fs::read_to_string(path).expect("Failed to read test data");
351 serde_json::from_str(&content).expect("Failed to parse test data")
352 }
353
354 #[rstest]
355 fn test_data_api_position_deserialization() {
356 let positions = load_positions();
357
358 assert_eq!(positions.len(), 4);
359 assert_eq!(positions[0].size, 150.5);
360 assert_eq!(positions[0].avg_price, Some(0.55));
361 assert_eq!(
362 positions[0].condition_id,
363 "0xc8f1cf5d4f26e0fd9c8fe89f2a7b3263b902cf14fde7bfccef525753bb492e47"
364 );
365 }
366
367 #[rstest]
368 fn test_build_position_reports_filters_dust_and_zero() {
369 let positions = load_positions();
370 let account_id = AccountId::from("POLYMARKET-001");
371 let ts_now = nautilus_core::UnixNanos::from(1_000_000_000u64);
372
373 let reports = build_position_reports(&positions, account_id, ts_now);
374
375 assert_eq!(reports.len(), 2);
378 assert!(reports[0].is_long());
379 assert!(reports[1].is_long());
380 }
381
382 #[rstest]
383 fn test_build_position_reports_carries_avg_price() {
384 let positions = load_positions();
385 let account_id = AccountId::from("POLYMARKET-001");
386 let ts_now = nautilus_core::UnixNanos::from(1_000_000_000u64);
387
388 let reports = build_position_reports(&positions, account_id, ts_now);
389
390 assert_eq!(reports.len(), 2);
391 assert_eq!(
392 reports[0].avg_px_open,
393 Some(Decimal::try_from(0.55).unwrap())
394 );
395 assert_eq!(
396 reports[1].avg_px_open,
397 Some(Decimal::try_from(0.3).unwrap())
398 );
399 }
400
401 #[rstest]
402 fn test_build_position_reports_uses_usdc_precision() {
403 let positions = load_positions();
404 let account_id = AccountId::from("POLYMARKET-001");
405 let ts_now = nautilus_core::UnixNanos::from(1_000_000_000u64);
406
407 let reports = build_position_reports(&positions, account_id, ts_now);
408
409 assert_eq!(reports.len(), 2);
410 assert_eq!(reports[0].quantity.precision, USDC_DECIMALS as u8);
411 assert_eq!(reports[1].quantity.precision, USDC_DECIMALS as u8);
412 }
413
414 #[rstest]
415 fn test_build_position_reports_handles_missing_avg_price() {
416 let positions = vec![DataApiPosition {
417 asset: "123".to_string(),
418 condition_id: "0xabc".to_string(),
419 size: 10.0,
420 avg_price: None,
421 }];
422 let account_id = AccountId::from("POLYMARKET-001");
423 let ts_now = nautilus_core::UnixNanos::from(1_000_000_000u64);
424
425 let reports = build_position_reports(&positions, account_id, ts_now);
426
427 assert_eq!(reports.len(), 1);
428 assert_eq!(reports[0].avg_px_open, None);
429 }
430
431 #[rstest]
432 fn test_data_api_trade_deserialization() {
433 let trades = load_trades();
434
435 assert_eq!(trades.len(), 3);
436
437 assert_eq!(
438 trades[0].asset,
439 "71321045863084981365469005770620412523470745398083994982746259498689308907982"
440 );
441 assert_eq!(
442 trades[0].condition_id,
443 "0xc8f1cf5d4f26e0fd9c8fe89f2a7b3263b902cf14fde7bfccef525753bb492e47"
444 );
445 assert_eq!(trades[0].price, 0.55);
446 assert_eq!(trades[0].size, 100.0);
447 assert_eq!(trades[0].timestamp, 1710000000);
448 assert_eq!(
449 trades[0].transaction_hash,
450 "0xabc123def456789012345678901234567890abcdef1234567890abcdef123456"
451 );
452 }
453
454 #[rstest]
455 fn test_data_api_trade_ignores_extra_fields() {
456 let trades = load_trades();
457 assert_eq!(trades.len(), 3);
459 }
460
461 #[rstest]
462 fn test_build_trade_ticks_filters_by_token_id() {
463 let trades = load_trades();
464 let instrument_id = InstrumentId::from(
465 "0xc8f1cf5d4f26e0fd9c8fe89f2a7b3263b902cf14fde7bfccef525753bb492e47-71321045863084981365469005770620412523470745398083994982746259498689308907982.POLYMARKET",
466 );
467 let token_id =
468 "71321045863084981365469005770620412523470745398083994982746259498689308907982";
469 let price_precision = 2u8;
470 let size_precision = 2u8;
471
472 let ticks: Vec<TradeTick> = trades
473 .into_iter()
474 .filter(|t| t.asset == token_id)
475 .map(|t| {
476 let price = Price::new(t.price, price_precision);
477 let size = Quantity::new(t.size, size_precision);
478 let aggressor_side = AggressorSide::from(t.side);
479 let hash = &t.transaction_hash;
481 let trade_id_str = if hash.len() > 36 {
482 &hash[hash.len() - 36..]
483 } else {
484 hash.as_str()
485 };
486 let trade_id = TradeId::new(trade_id_str);
487 let ts_event = nautilus_core::UnixNanos::from(t.timestamp as u64 * 1_000_000_000);
488
489 TradeTick::new(
490 instrument_id,
491 price,
492 size,
493 aggressor_side,
494 trade_id,
495 ts_event,
496 ts_event,
497 )
498 })
499 .collect();
500
501 assert_eq!(ticks.len(), 2);
503 assert_eq!(ticks[0].aggressor_side, AggressorSide::Buyer);
504 assert_eq!(ticks[1].aggressor_side, AggressorSide::Seller);
505 }
506
507 #[rstest]
508 fn test_build_trade_ticks_chronological_order() {
509 let trades = load_trades();
510 let instrument_id = InstrumentId::from(
511 "0xc8f1cf5d4f26e0fd9c8fe89f2a7b3263b902cf14fde7bfccef525753bb492e47-71321045863084981365469005770620412523470745398083994982746259498689308907982.POLYMARKET",
512 );
513 let token_id =
514 "71321045863084981365469005770620412523470745398083994982746259498689308907982";
515
516 let mut ticks: Vec<TradeTick> = trades
517 .into_iter()
518 .filter(|t| t.asset == token_id)
519 .map(|t| {
520 let price = Price::new(t.price, 2);
521 let size = Quantity::new(t.size, 2);
522 let aggressor_side = AggressorSide::from(t.side);
523 let hash = &t.transaction_hash;
525 let trade_id_str = if hash.len() > 36 {
526 &hash[hash.len() - 36..]
527 } else {
528 hash.as_str()
529 };
530 let trade_id = TradeId::new(trade_id_str);
531 let ts_event = nautilus_core::UnixNanos::from(t.timestamp as u64 * 1_000_000_000);
532
533 TradeTick::new(
534 instrument_id,
535 price,
536 size,
537 aggressor_side,
538 trade_id,
539 ts_event,
540 ts_event,
541 )
542 })
543 .collect();
544
545 ticks.reverse();
547
548 assert_eq!(ticks.len(), 2);
549 assert!(ticks[0].ts_event < ticks[1].ts_event);
551 }
552
553 fn make_trade(
554 timestamp: i64,
555 transaction_hash: &str,
556 asset: &str,
557 side: PolymarketOrderSide,
558 price: f64,
559 size: f64,
560 ) -> DataApiTrade {
561 DataApiTrade {
562 asset: asset.to_string(),
563 condition_id: "0xcond".to_string(),
564 side,
565 price,
566 size,
567 timestamp,
568 transaction_hash: transaction_hash.to_string(),
569 }
570 }
571
572 fn test_instrument_id() -> InstrumentId {
573 InstrumentId::from(
574 "0xc8f1cf5d4f26e0fd9c8fe89f2a7b3263b902cf14fde7bfccef525753bb492e47-71321045863084981365469005770620412523470745398083994982746259498689308907982.POLYMARKET",
575 )
576 }
577
578 #[rstest]
579 fn test_data_api_trade_sort_key_orders_pages_deterministically() {
580 let mut trades = [
581 make_trade(1729000005, "0xZ", "T", PolymarketOrderSide::Buy, 0.5, 1.0),
582 make_trade(1729000000, "0xC", "T", PolymarketOrderSide::Buy, 0.5, 1.0),
583 make_trade(1729000000, "0xA", "T", PolymarketOrderSide::Sell, 0.5, 1.0),
584 make_trade(1729000000, "0xB", "T", PolymarketOrderSide::Buy, 0.5, 1.0),
585 ];
586
587 trades.sort_by(|a, b| data_api_trade_sort_key(a).cmp(&data_api_trade_sort_key(b)));
588
589 let order: Vec<&str> = trades.iter().map(|t| t.transaction_hash.as_str()).collect();
590 assert_eq!(order, ["0xA", "0xB", "0xC", "0xZ"]);
591 }
592
593 #[rstest]
594 fn test_data_api_trade_sort_key_uses_full_composite_for_inner_ties() {
595 let mut trades = [
597 make_trade(1, "0xH", "Tb", PolymarketOrderSide::Buy, 0.5, 1.0),
599 make_trade(1, "0xH", "Ta", PolymarketOrderSide::Sell, 0.5, 1.0),
600 make_trade(1, "0xH", "Ta", PolymarketOrderSide::Buy, 0.6, 1.0),
601 make_trade(1, "0xH", "Ta", PolymarketOrderSide::Buy, 0.5, 2.0),
602 make_trade(1, "0xH", "Ta", PolymarketOrderSide::Buy, 0.5, 1.0),
603 ];
604
605 trades.sort_by(|a, b| data_api_trade_sort_key(a).cmp(&data_api_trade_sort_key(b)));
606
607 let key: Vec<(String, String, f64, f64)> = trades
615 .iter()
616 .map(|t| (t.asset.clone(), t.side.to_string(), t.price, t.size))
617 .collect();
618 assert_eq!(key[0], ("Ta".into(), "BUY".into(), 0.5, 1.0));
619 assert_eq!(key[1], ("Ta".into(), "BUY".into(), 0.5, 2.0));
620 assert_eq!(key[2], ("Ta".into(), "BUY".into(), 0.6, 1.0));
621 assert_eq!(key[3], ("Ta".into(), "SELL".into(), 0.5, 1.0));
622 assert_eq!(key[4], ("Tb".into(), "BUY".into(), 0.5, 1.0));
623 }
624
625 #[rstest]
626 fn test_parse_trade_ticks_filters_other_tokens() {
627 let token_id = "T_KEEP";
628 let trades = vec![
629 make_trade(
630 1729000000,
631 "0xa",
632 token_id,
633 PolymarketOrderSide::Buy,
634 0.5,
635 1.0,
636 ),
637 make_trade(
638 1729000000,
639 "0xb",
640 "T_DROP",
641 PolymarketOrderSide::Sell,
642 0.5,
643 1.0,
644 ),
645 ];
646
647 let trades = parse_trade_ticks(trades, test_instrument_id(), token_id, 2, 2);
648
649 assert_eq!(trades.len(), 1);
650 assert_eq!(trades[0].aggressor_side, AggressorSide::Buyer);
651 }
652
653 #[rstest]
654 fn test_parse_trade_ticks_disambiguates_multi_fill_transaction() {
655 let token_id = "12345token";
657 let same_hash = "0x000000000000000000000000000000000000000000000000000000000000abcdef";
658 let trades = vec![
659 make_trade(
660 1729000000,
661 same_hash,
662 token_id,
663 PolymarketOrderSide::Buy,
664 0.5,
665 1.0,
666 ),
667 make_trade(
668 1729000000,
669 same_hash,
670 token_id,
671 PolymarketOrderSide::Sell,
672 0.5,
673 1.0,
674 ),
675 ];
676
677 let trades = parse_trade_ticks(trades, test_instrument_id(), token_id, 2, 2);
678
679 assert_eq!(trades.len(), 2);
680 assert_ne!(trades[0].trade_id, trades[1].trade_id);
681 assert!(trades[0].ts_event < trades[1].ts_event);
683 assert_eq!(
684 u64::from(trades[1].ts_event) - u64::from(trades[0].ts_event),
685 1
686 );
687 assert!(trades[0].trade_id.to_string().ends_with("-000000"));
689 assert!(trades[1].trade_id.to_string().ends_with("-000001"));
690 }
691
692 #[rstest]
693 fn test_parse_trade_ticks_distinct_tx_share_timestamp() {
694 let token_id = "T";
697 let trades = vec![
698 make_trade(
699 1729000000,
700 "0xtx1",
701 token_id,
702 PolymarketOrderSide::Buy,
703 0.5,
704 1.0,
705 ),
706 make_trade(
707 1729000000,
708 "0xtx2",
709 token_id,
710 PolymarketOrderSide::Buy,
711 0.5,
712 1.0,
713 ),
714 make_trade(
715 1729000000,
716 "0xtx3",
717 token_id,
718 PolymarketOrderSide::Buy,
719 0.5,
720 1.0,
721 ),
722 ];
723
724 let trades = parse_trade_ticks(trades, test_instrument_id(), token_id, 2, 2);
725
726 assert_eq!(trades.len(), 3);
727 assert!(trades[0].ts_event < trades[1].ts_event);
729 assert!(trades[1].ts_event < trades[2].ts_event);
730 for trade in &trades {
732 assert!(trade.trade_id.to_string().ends_with("-000000"));
733 }
734 }
735
736 #[rstest]
737 fn test_parse_trade_ticks_assigns_per_second_tiebreakers() {
738 let token_id = "T";
741 let mut trades = Vec::new();
742
743 for i in 0..3 {
744 let hash = format!("0x{i:064x}");
745 trades.push(make_trade(
746 1729000000,
747 &hash,
748 token_id,
749 PolymarketOrderSide::Buy,
750 0.5,
751 1.0,
752 ));
753 }
754
755 let trades = parse_trade_ticks(trades, test_instrument_id(), token_id, 2, 2);
756
757 assert_eq!(trades.len(), 3);
758 let base_ns = 1_729_000_000u64 * 1_000_000_000;
759
760 for (i, trade) in trades.iter().enumerate() {
761 assert!(u64::from(trade.ts_event) - base_ns < 1_000_000_000);
762 assert_eq!(u64::from(trade.ts_event) - base_ns, i as u64);
763 }
764 }
765
766 #[rstest]
767 fn test_parse_trade_ticks_sorts_inputs_by_composite_key() {
768 let token_id = "T";
771 let trades = vec![
772 make_trade(
773 1729000005,
774 "0xZ",
775 token_id,
776 PolymarketOrderSide::Buy,
777 0.5,
778 1.0,
779 ),
780 make_trade(
781 1729000000,
782 "0xC",
783 token_id,
784 PolymarketOrderSide::Buy,
785 0.5,
786 1.0,
787 ),
788 make_trade(
789 1729000000,
790 "0xA",
791 token_id,
792 PolymarketOrderSide::Sell,
793 0.5,
794 1.0,
795 ),
796 make_trade(
797 1729000000,
798 "0xB",
799 token_id,
800 PolymarketOrderSide::Buy,
801 0.5,
802 1.0,
803 ),
804 ];
805
806 let trades = parse_trade_ticks(trades, test_instrument_id(), token_id, 2, 2);
807
808 assert_eq!(trades.len(), 4);
809
810 for i in 1..trades.len() {
812 assert!(trades[i - 1].ts_event <= trades[i].ts_event);
813 }
814
815 let trade_ids: Vec<String> = trades.iter().map(|t| t.trade_id.to_string()).collect();
817 assert!(trade_ids[0].contains("0xA"));
818 assert!(trade_ids[1].contains("0xB"));
819 assert!(trade_ids[2].contains("0xC"));
820 assert!(trade_ids[3].contains("0xZ"));
821 }
822}