nautilus_polymarket/http/
data_api.rs1use 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::http::{
30 error::{Error, Result},
31 models::{DataApiPosition, DataApiTrade},
32};
33
34const POLYMARKET_DATA_API_URL: &str = "https://data-api.polymarket.com";
35
36#[derive(Debug, Clone)]
40pub struct PolymarketDataApiHttpClient {
41 client: HttpClient,
42 base_url: String,
43}
44
45impl PolymarketDataApiHttpClient {
46 pub fn new(base_url: Option<String>, timeout_secs: u64) -> StdResult<Self, HttpClientError> {
52 Ok(Self {
53 client: HttpClient::new(
54 HashMap::from([
55 (USER_AGENT.to_string(), NAUTILUS_USER_AGENT.to_string()),
56 ("Content-Type".to_string(), "application/json".to_string()),
57 ]),
58 vec![],
59 vec![],
60 None,
61 Some(timeout_secs),
62 None,
63 )?,
64 base_url: base_url
65 .unwrap_or_else(|| POLYMARKET_DATA_API_URL.to_string())
66 .trim_end_matches('/')
67 .to_string(),
68 })
69 }
70
71 pub async fn get_positions(&self, user_address: &str) -> Result<Vec<DataApiPosition>> {
76 const PAGE_SIZE: u32 = 100;
77
78 let mut all_positions: Vec<DataApiPosition> = Vec::new();
79 let mut offset: u32 = 0;
80
81 loop {
82 let params = vec![
83 ("user".to_string(), user_address.to_string()),
84 ("limit".to_string(), PAGE_SIZE.to_string()),
85 ("offset".to_string(), offset.to_string()),
86 ("sizeThreshold".to_string(), "0".to_string()),
87 ("sortBy".to_string(), "TOKENS".to_string()),
88 ("sortDirection".to_string(), "DESC".to_string()),
89 ];
90
91 let url = format!("{}/positions", self.base_url);
92 let response = self
93 .client
94 .request_with_params(Method::GET, url, Some(¶ms), None, None, None, None)
95 .await
96 .map_err(Error::from_http_client)?;
97
98 if response.status.is_success() {
99 let page: Vec<DataApiPosition> =
100 serde_json::from_slice(&response.body).map_err(Error::Serde)?;
101 let count = page.len() as u32;
102 all_positions.extend(page);
103
104 if count < PAGE_SIZE {
105 break;
106 }
107 offset += count;
108 } else {
109 return Err(Error::from_status_code(
110 response.status.as_u16(),
111 &response.body,
112 ));
113 }
114 }
115
116 Ok(all_positions)
117 }
118
119 pub async fn get_trades(
121 &self,
122 condition_id: &str,
123 limit: Option<u32>,
124 offset: Option<u32>,
125 ) -> Result<Vec<DataApiTrade>> {
126 let mut params = vec![("market".to_string(), condition_id.to_string())];
127
128 if let Some(l) = limit {
129 params.push(("limit".to_string(), l.to_string()));
130 }
131
132 if let Some(o) = offset {
133 params.push(("offset".to_string(), o.to_string()));
134 }
135
136 let url = format!("{}/trades", self.base_url);
137 let response = self
138 .client
139 .request_with_params(Method::GET, url, Some(¶ms), None, None, None, None)
140 .await
141 .map_err(Error::from_http_client)?;
142
143 if response.status.is_success() {
144 serde_json::from_slice(&response.body).map_err(Error::Serde)
145 } else {
146 Err(Error::from_status_code(
147 response.status.as_u16(),
148 &response.body,
149 ))
150 }
151 }
152
153 pub async fn request_trade_ticks(
160 &self,
161 instrument_id: InstrumentId,
162 condition_id: &str,
163 token_id: &str,
164 price_precision: u8,
165 size_precision: u8,
166 limit: Option<u32>,
167 ) -> anyhow::Result<Vec<TradeTick>> {
168 const PAGE_SIZE: u32 = 500;
169 const MAX_OFFSET: u32 = 3000;
171
172 let page_size = limit.map_or(PAGE_SIZE, |l| l.min(PAGE_SIZE));
173 let mut all_trades: Vec<DataApiTrade> = Vec::new();
174 let mut offset: u32 = 0;
175
176 loop {
177 let page = self
178 .get_trades(condition_id, Some(page_size), Some(offset))
179 .await
180 .map_err(|e| anyhow::anyhow!(e))?;
181
182 let count = page.len() as u32;
183 all_trades.extend(page);
184
185 if count < page_size {
187 break;
188 }
189 if let Some(target) = limit
191 && all_trades.len() as u32 >= target
192 {
193 break;
194 }
195 offset += count;
196 if offset >= MAX_OFFSET {
198 break;
199 }
200 }
201
202 if let Some(target) = limit {
204 all_trades.truncate(target as usize);
205 }
206
207 let mut ticks: Vec<TradeTick> = all_trades
208 .into_iter()
209 .filter(|t| t.asset == token_id)
210 .map(|t| {
211 let price = Price::new(t.price, price_precision);
212 let size = Quantity::new(t.size, size_precision);
213 let aggressor_side = AggressorSide::from(t.side);
214 let hash = &t.transaction_hash;
216 let trade_id_str = if hash.len() > 36 {
217 &hash[hash.len() - 36..]
218 } else {
219 hash.as_str()
220 };
221 let trade_id = TradeId::new(trade_id_str);
222 let ts_event = nautilus_core::UnixNanos::from(t.timestamp as u64 * 1_000_000_000);
224
225 TradeTick::new(
226 instrument_id,
227 price,
228 size,
229 aggressor_side,
230 trade_id,
231 ts_event,
232 ts_event,
233 )
234 })
235 .collect();
236
237 ticks.reverse();
239
240 Ok(ticks)
241 }
242}
243
244#[cfg(test)]
245mod tests {
246 use nautilus_model::{
247 enums::AggressorSide,
248 identifiers::{AccountId, InstrumentId},
249 };
250 use rstest::rstest;
251 use rust_decimal::Decimal;
252
253 use super::*;
254 use crate::{
255 common::consts::USDC_DECIMALS,
256 execution::reconciliation::build_position_reports,
257 http::models::{DataApiPosition, DataApiTrade},
258 };
259
260 fn load_positions() -> Vec<DataApiPosition> {
261 let path = "test_data/data_api_positions_response.json";
262 let content = std::fs::read_to_string(path).expect("Failed to read test data");
263 serde_json::from_str(&content).expect("Failed to parse test data")
264 }
265
266 fn load_trades() -> Vec<DataApiTrade> {
267 let path = "test_data/data_api_trades_response.json";
268 let content = std::fs::read_to_string(path).expect("Failed to read test data");
269 serde_json::from_str(&content).expect("Failed to parse test data")
270 }
271
272 #[rstest]
273 fn test_data_api_position_deserialization() {
274 let positions = load_positions();
275
276 assert_eq!(positions.len(), 4);
277 assert_eq!(positions[0].size, 150.5);
278 assert_eq!(positions[0].avg_price, Some(0.55));
279 assert_eq!(
280 positions[0].condition_id,
281 "0xc8f1cf5d4f26e0fd9c8fe89f2a7b3263b902cf14fde7bfccef525753bb492e47"
282 );
283 }
284
285 #[rstest]
286 fn test_build_position_reports_filters_dust_and_zero() {
287 let positions = load_positions();
288 let account_id = AccountId::from("POLYMARKET-001");
289 let ts_now = nautilus_core::UnixNanos::from(1_000_000_000u64);
290
291 let reports = build_position_reports(&positions, account_id, ts_now);
292
293 assert_eq!(reports.len(), 2);
296 assert!(reports[0].is_long());
297 assert!(reports[1].is_long());
298 }
299
300 #[rstest]
301 fn test_build_position_reports_carries_avg_price() {
302 let positions = load_positions();
303 let account_id = AccountId::from("POLYMARKET-001");
304 let ts_now = nautilus_core::UnixNanos::from(1_000_000_000u64);
305
306 let reports = build_position_reports(&positions, account_id, ts_now);
307
308 assert_eq!(reports.len(), 2);
309 assert_eq!(
310 reports[0].avg_px_open,
311 Some(Decimal::try_from(0.55).unwrap())
312 );
313 assert_eq!(
314 reports[1].avg_px_open,
315 Some(Decimal::try_from(0.3).unwrap())
316 );
317 }
318
319 #[rstest]
320 fn test_build_position_reports_uses_usdc_precision() {
321 let positions = load_positions();
322 let account_id = AccountId::from("POLYMARKET-001");
323 let ts_now = nautilus_core::UnixNanos::from(1_000_000_000u64);
324
325 let reports = build_position_reports(&positions, account_id, ts_now);
326
327 assert_eq!(reports.len(), 2);
328 assert_eq!(reports[0].quantity.precision, USDC_DECIMALS as u8);
329 assert_eq!(reports[1].quantity.precision, USDC_DECIMALS as u8);
330 }
331
332 #[rstest]
333 fn test_build_position_reports_handles_missing_avg_price() {
334 let positions = vec![DataApiPosition {
335 asset: "123".to_string(),
336 condition_id: "0xabc".to_string(),
337 size: 10.0,
338 avg_price: None,
339 }];
340 let account_id = AccountId::from("POLYMARKET-001");
341 let ts_now = nautilus_core::UnixNanos::from(1_000_000_000u64);
342
343 let reports = build_position_reports(&positions, account_id, ts_now);
344
345 assert_eq!(reports.len(), 1);
346 assert_eq!(reports[0].avg_px_open, None);
347 }
348
349 #[rstest]
350 fn test_data_api_trade_deserialization() {
351 let trades = load_trades();
352
353 assert_eq!(trades.len(), 3);
354
355 assert_eq!(
356 trades[0].asset,
357 "71321045863084981365469005770620412523470745398083994982746259498689308907982"
358 );
359 assert_eq!(
360 trades[0].condition_id,
361 "0xc8f1cf5d4f26e0fd9c8fe89f2a7b3263b902cf14fde7bfccef525753bb492e47"
362 );
363 assert_eq!(trades[0].price, 0.55);
364 assert_eq!(trades[0].size, 100.0);
365 assert_eq!(trades[0].timestamp, 1710000000);
366 assert_eq!(
367 trades[0].transaction_hash,
368 "0xabc123def456789012345678901234567890abcdef1234567890abcdef123456"
369 );
370 }
371
372 #[rstest]
373 fn test_data_api_trade_ignores_extra_fields() {
374 let trades = load_trades();
375 assert_eq!(trades.len(), 3);
377 }
378
379 #[rstest]
380 fn test_build_trade_ticks_filters_by_token_id() {
381 let trades = load_trades();
382 let instrument_id = InstrumentId::from(
383 "0xc8f1cf5d4f26e0fd9c8fe89f2a7b3263b902cf14fde7bfccef525753bb492e47-71321045863084981365469005770620412523470745398083994982746259498689308907982.POLYMARKET",
384 );
385 let token_id =
386 "71321045863084981365469005770620412523470745398083994982746259498689308907982";
387 let price_precision = 2u8;
388 let size_precision = 2u8;
389
390 let ticks: Vec<TradeTick> = trades
391 .into_iter()
392 .filter(|t| t.asset == token_id)
393 .map(|t| {
394 let price = Price::new(t.price, price_precision);
395 let size = Quantity::new(t.size, size_precision);
396 let aggressor_side = AggressorSide::from(t.side);
397 let hash = &t.transaction_hash;
399 let trade_id_str = if hash.len() > 36 {
400 &hash[hash.len() - 36..]
401 } else {
402 hash.as_str()
403 };
404 let trade_id = TradeId::new(trade_id_str);
405 let ts_event = nautilus_core::UnixNanos::from(t.timestamp as u64 * 1_000_000_000);
406
407 TradeTick::new(
408 instrument_id,
409 price,
410 size,
411 aggressor_side,
412 trade_id,
413 ts_event,
414 ts_event,
415 )
416 })
417 .collect();
418
419 assert_eq!(ticks.len(), 2);
421 assert_eq!(ticks[0].aggressor_side, AggressorSide::Buyer);
422 assert_eq!(ticks[1].aggressor_side, AggressorSide::Seller);
423 }
424
425 #[rstest]
426 fn test_build_trade_ticks_chronological_order() {
427 let trades = load_trades();
428 let instrument_id = InstrumentId::from(
429 "0xc8f1cf5d4f26e0fd9c8fe89f2a7b3263b902cf14fde7bfccef525753bb492e47-71321045863084981365469005770620412523470745398083994982746259498689308907982.POLYMARKET",
430 );
431 let token_id =
432 "71321045863084981365469005770620412523470745398083994982746259498689308907982";
433
434 let mut ticks: Vec<TradeTick> = trades
435 .into_iter()
436 .filter(|t| t.asset == token_id)
437 .map(|t| {
438 let price = Price::new(t.price, 2);
439 let size = Quantity::new(t.size, 2);
440 let aggressor_side = AggressorSide::from(t.side);
441 let hash = &t.transaction_hash;
443 let trade_id_str = if hash.len() > 36 {
444 &hash[hash.len() - 36..]
445 } else {
446 hash.as_str()
447 };
448 let trade_id = TradeId::new(trade_id_str);
449 let ts_event = nautilus_core::UnixNanos::from(t.timestamp as u64 * 1_000_000_000);
450
451 TradeTick::new(
452 instrument_id,
453 price,
454 size,
455 aggressor_side,
456 trade_id,
457 ts_event,
458 ts_event,
459 )
460 })
461 .collect();
462
463 ticks.reverse();
465
466 assert_eq!(ticks.len(), 2);
467 assert!(ticks[0].ts_event < ticks[1].ts_event);
469 }
470}