Skip to main content

nautilus_polymarket/http/
data_api.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! Provides the HTTP client for the Polymarket Data API.
17
18use 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/// Provides an unauthenticated HTTP client for the Polymarket Data API.
37///
38/// Used for fetching historical trade data from `GET /trades`.
39#[derive(Debug, Clone)]
40pub struct PolymarketDataApiHttpClient {
41    client: HttpClient,
42    base_url: String,
43}
44
45impl PolymarketDataApiHttpClient {
46    /// Creates a new [`PolymarketDataApiHttpClient`].
47    ///
48    /// # Errors
49    ///
50    /// Returns an error if the HTTP client cannot be created.
51    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    /// Fetches all positions for a user from the Data API.
72    ///
73    /// Paginates through `GET /positions?user={address}&sizeThreshold=0`
74    /// until a partial page is returned.
75    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(&params), 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    /// Fetches trades from the Data API for the given condition ID.
120    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(&params), 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    /// Fetches trades and converts them to [`TradeTick`] for the given instrument.
154    ///
155    /// Automatically paginates through all available results (up to `limit`
156    /// if specified, with a safety cap of 100 pages). Filters by `token_id`
157    /// (since the API returns trades for all outcomes of the condition) and
158    /// returns results in chronological order.
159    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        // Polymarket Data API rejects offsets >= 3000
170        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            // Partial page means no more data available
186            if count < page_size {
187                break;
188            }
189            // If we've collected enough for the caller's target, stop
190            if let Some(target) = limit
191                && all_trades.len() as u32 >= target
192            {
193                break;
194            }
195            offset += count;
196            // API hard limit on offset
197            if offset >= MAX_OFFSET {
198                break;
199            }
200        }
201
202        // Apply final truncation to honour the caller's limit
203        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                // TradeId max length is 36; tx hash is 66 chars, take last 36
215                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                // Data API timestamp is in epoch seconds
223                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        // API returns newest-first; reverse for chronological order
238        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        // 4 positions: 150.5, 0.0, 42.0, 0.005 (dust)
294        // Only 150.5 and 42.0 pass the DUST_POSITION_THRESHOLD (0.01)
295        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        // proxyWallet, title, slug should be silently ignored
376        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                // TradeId max length is 36; tx hash is 66 chars, take last 36
398                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        // Should filter out the third trade (different asset)
420        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                // TradeId max length is 36; tx hash is 66 chars, take last 36
442                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        // Reverse to get chronological order (API returns newest-first)
464        ticks.reverse();
465
466        assert_eq!(ticks.len(), 2);
467        // First tick should be the older one (lower timestamp)
468        assert!(ticks[0].ts_event < ticks[1].ts_event);
469    }
470}