polymarket_sdk/client/
data.rs

1//! Data API Client
2//!
3//! Provides access to Polymarket's Data API for trader profiles, positions,
4//! trades, activity, and leaderboards.
5//!
6//! ## Example
7//!
8//! ```rust,ignore
9//! use polymarket_sdk::data::{DataClient, DataConfig};
10//!
11//! let client = DataClient::new(DataConfig::default())?;
12//!
13//! // Get trader profile
14//! let profile = client.get_trader_profile("0x...").await?;
15//!
16//! // Get biggest winners
17//! let winners = client.get_biggest_winners(&BiggestWinnersQuery::default()).await?;
18//! ```
19
20use std::time::Duration;
21
22use reqwest::Client;
23use serde::Deserialize;
24use tracing::{debug, instrument};
25
26use crate::core::{clob_api_url, data_api_url};
27use crate::core::{PolymarketError, Result};
28use crate::types::{
29    BiggestWinner, BiggestWinnersQuery, ClosedPosition, DataApiActivity, DataApiPosition,
30    DataApiTrade, DataApiTrader,
31};
32
33/// Data API configuration
34#[derive(Debug, Clone)]
35pub struct DataConfig {
36    /// Base URL for the Data API
37    pub base_url: String,
38    /// CLOB API base URL
39    pub clob_base_url: String,
40    /// Request timeout
41    pub timeout: Duration,
42    /// User agent string
43    pub user_agent: String,
44}
45
46impl Default for DataConfig {
47    fn default() -> Self {
48        Self {
49            // Use helper functions to support env var overrides
50            base_url: data_api_url(),
51            clob_base_url: clob_api_url(),
52            timeout: Duration::from_secs(30),
53            user_agent: "polymarket-sdk/0.1.0".to_string(),
54        }
55    }
56}
57
58impl DataConfig {
59    /// Create a new configuration builder
60    #[must_use]
61    pub fn builder() -> Self {
62        Self::default()
63    }
64
65    /// Set base URL
66    #[must_use]
67    pub fn with_base_url(mut self, url: impl Into<String>) -> Self {
68        self.base_url = url.into();
69        self
70    }
71
72    /// Set CLOB base URL
73    #[must_use]
74    pub fn with_clob_base_url(mut self, url: impl Into<String>) -> Self {
75        self.clob_base_url = url.into();
76        self
77    }
78
79    /// Set request timeout
80    #[must_use]
81    pub fn with_timeout(mut self, timeout: Duration) -> Self {
82        self.timeout = timeout;
83        self
84    }
85
86    /// Set user agent string
87    #[must_use]
88    pub fn with_user_agent(mut self, user_agent: impl Into<String>) -> Self {
89        self.user_agent = user_agent.into();
90        self
91    }
92
93    /// Create config from environment variables.
94    ///
95    /// **Deprecated**: Use `DataConfig::default()` instead.
96    /// The default implementation already supports env var overrides.
97    #[must_use]
98    #[deprecated(
99        since = "0.1.0",
100        note = "Use DataConfig::default() instead. URL overrides via \
101                POLYMARKET_DATA_URL and POLYMARKET_CLOB_URL env vars are already supported."
102    )]
103    pub fn from_env() -> Self {
104        Self::default()
105    }
106}
107
108/// Data API client for trader data, positions, and leaderboards
109#[derive(Debug, Clone)]
110pub struct DataClient {
111    config: DataConfig,
112    client: Client,
113}
114
115impl DataClient {
116    /// Create a new Data API client
117    pub fn new(config: DataConfig) -> Result<Self> {
118        let client = Client::builder()
119            .timeout(config.timeout)
120            .user_agent(&config.user_agent)
121            .gzip(true)
122            .build()
123            .map_err(|e| PolymarketError::config(format!("Failed to create HTTP client: {e}")))?;
124
125        Ok(Self { config, client })
126    }
127
128    /// Create client with default configuration
129    pub fn with_defaults() -> Result<Self> {
130        Self::new(DataConfig::default())
131    }
132
133    /// Create client from environment variables.
134    ///
135    /// **Deprecated**: Use `DataClient::with_defaults()` instead.
136    #[deprecated(since = "0.1.0", note = "Use DataClient::with_defaults() instead")]
137    #[allow(deprecated)]
138    pub fn from_env() -> Result<Self> {
139        Self::new(DataConfig::from_env())
140    }
141
142    /// Get trader profile by wallet address
143    #[instrument(skip(self), level = "debug")]
144    pub async fn get_trader_profile(&self, address: &str) -> Result<DataApiTrader> {
145        let url = format!("{}/profile/{}", self.config.base_url, address);
146        debug!(%url, "Fetching trader profile");
147
148        let response = self.client.get(&url).send().await?;
149        self.handle_response::<DataApiTrader>(response).await
150    }
151
152    /// Get positions for a wallet address
153    #[instrument(skip(self), level = "debug")]
154    pub async fn get_positions(&self, address: &str) -> Result<Vec<DataApiPosition>> {
155        let url = format!("{}/positions?user={}", self.config.base_url, address);
156        debug!(%url, "Fetching positions");
157
158        let response = self.client.get(&url).send().await?;
159        self.handle_response::<Vec<DataApiPosition>>(response).await
160    }
161
162    /// Get trades for a wallet address
163    #[instrument(skip(self), level = "debug")]
164    pub async fn get_trades(&self, address: &str, limit: Option<u32>) -> Result<Vec<DataApiTrade>> {
165        let limit = limit.unwrap_or(100);
166        let url = format!(
167            "{}/trades?user={}&limit={}",
168            self.config.base_url, address, limit
169        );
170        debug!(%url, "Fetching trades");
171
172        let response = self.client.get(&url).send().await?;
173        self.handle_response::<Vec<DataApiTrade>>(response).await
174    }
175
176    /// Get user activity (trades, position changes)
177    #[instrument(skip(self), level = "debug")]
178    pub async fn get_user_activity(
179        &self,
180        address: &str,
181        limit: Option<u32>,
182        offset: Option<u32>,
183    ) -> Result<Vec<DataApiActivity>> {
184        let limit = limit.unwrap_or(100);
185        let offset = offset.unwrap_or(0);
186        let url = format!(
187            "{}/activity?user={}&limit={}&offset={}",
188            self.config.base_url, address, limit, offset
189        );
190        debug!(%url, "Fetching user activity");
191
192        let response = self.client.get(&url).send().await?;
193        self.handle_response::<Vec<DataApiActivity>>(response).await
194    }
195
196    /// Get closed positions for a user (for PnL calculation)
197    #[instrument(skip(self), level = "debug")]
198    pub async fn get_closed_positions(
199        &self,
200        address: &str,
201        limit: Option<u32>,
202        offset: Option<u32>,
203    ) -> Result<Vec<ClosedPosition>> {
204        let limit = limit.unwrap_or(100);
205        let offset = offset.unwrap_or(0);
206        let url = format!(
207            "{}/closed-positions?user={}&limit={}&offset={}",
208            self.config.base_url, address, limit, offset
209        );
210        debug!(%url, "Fetching closed positions");
211
212        let response = self.client.get(&url).send().await?;
213        self.handle_response::<Vec<ClosedPosition>>(response).await
214    }
215
216    /// Get biggest winners by category and time period
217    #[instrument(skip(self), level = "debug")]
218    pub async fn get_biggest_winners(
219        &self,
220        query: &BiggestWinnersQuery,
221    ) -> Result<Vec<BiggestWinner>> {
222        let url = format!(
223            "{}/v1/biggest-winners?timePeriod={}&limit={}&offset={}&category={}",
224            self.config.base_url, query.time_period, query.limit, query.offset, query.category
225        );
226        debug!(%url, "Fetching biggest winners");
227
228        let response = self.client.get(&url).send().await?;
229        self.handle_response::<Vec<BiggestWinner>>(response).await
230    }
231
232    /// Get top biggest winners with auto-pagination
233    ///
234    /// Fetches winners in batches of 100 until reaching total_limit
235    #[instrument(skip(self), level = "debug")]
236    pub async fn get_top_biggest_winners(
237        &self,
238        category: &str,
239        time_period: &str,
240        total_limit: usize,
241    ) -> Result<Vec<BiggestWinner>> {
242        let mut all_winners = Vec::new();
243        let batch_size = 100; // API max per request
244        let mut offset = 0;
245
246        while all_winners.len() < total_limit {
247            let remaining = total_limit - all_winners.len();
248            let limit = std::cmp::min(batch_size, remaining);
249
250            let query = BiggestWinnersQuery {
251                time_period: time_period.to_string(),
252                limit,
253                offset,
254                category: category.to_string(),
255            };
256
257            debug!(
258                category,
259                time_period, offset, limit, "Fetching biggest winners batch"
260            );
261
262            let batch = self.get_biggest_winners(&query).await?;
263
264            if batch.is_empty() {
265                debug!(category, "No more winners available");
266                break;
267            }
268
269            let batch_len = batch.len();
270            all_winners.extend(batch);
271            offset += batch_len;
272
273            debug!(
274                category,
275                batch_count = batch_len,
276                total = all_winners.len(),
277                "Fetched biggest winners batch"
278            );
279
280            // If we got less than requested, no more pages
281            if batch_len < limit {
282                break;
283            }
284
285            // Small delay to avoid rate limiting
286            tokio::time::sleep(Duration::from_millis(100)).await;
287        }
288
289        // Truncate to exact limit
290        all_winners.truncate(total_limit);
291
292        tracing::info!(
293            category,
294            total = all_winners.len(),
295            "Fetched all biggest winners"
296        );
297
298        Ok(all_winners)
299    }
300
301    /// Get token midpoint price from CLOB
302    #[instrument(skip(self), level = "debug")]
303    pub async fn get_token_midpoint(&self, token_id: &str) -> Result<f64> {
304        let url = format!(
305            "{}/midpoint?token_id={}",
306            self.config.clob_base_url, token_id
307        );
308        debug!(%url, "Fetching token midpoint");
309
310        let response = self.client.get(&url).send().await?;
311
312        if !response.status().is_success() {
313            // Return default 0.5 for failed requests
314            return Ok(0.5);
315        }
316
317        let data: serde_json::Value = response.json().await.map_err(|e| {
318            PolymarketError::parse_with_source(format!("Failed to parse midpoint response: {e}"), e)
319        })?;
320
321        let price = data["mid"]
322            .as_str()
323            .and_then(|p| p.parse::<f64>().ok())
324            .unwrap_or(0.5);
325
326        Ok(price)
327    }
328
329    /// Get order book for a token
330    #[instrument(skip(self), level = "debug")]
331    pub async fn get_order_book(&self, token_id: &str) -> Result<serde_json::Value> {
332        let url = format!("{}/book?token_id={}", self.config.clob_base_url, token_id);
333        debug!(%url, "Fetching order book");
334
335        let response = self.client.get(&url).send().await?;
336        self.handle_response::<serde_json::Value>(response).await
337    }
338
339    /// Handle API response
340    async fn handle_response<T: for<'de> Deserialize<'de>>(
341        &self,
342        response: reqwest::Response,
343    ) -> Result<T> {
344        let status = response.status();
345
346        if status.is_success() {
347            let body = response.text().await?;
348            serde_json::from_str(&body).map_err(|e| {
349                PolymarketError::parse_with_source(format!("Failed to parse response: {e}"), e)
350            })
351        } else {
352            let body = response.text().await.unwrap_or_default();
353            Err(PolymarketError::api(status.as_u16(), body))
354        }
355    }
356}
357
358#[cfg(test)]
359mod tests {
360    use super::*;
361
362    #[test]
363    fn test_config_builder() {
364        let config = DataConfig::builder()
365            .with_base_url("https://custom.example.com")
366            .with_timeout(Duration::from_secs(60));
367
368        assert_eq!(config.base_url, "https://custom.example.com");
369        assert_eq!(config.timeout, Duration::from_secs(60));
370    }
371
372    #[test]
373    fn test_biggest_winners_query() {
374        let query = BiggestWinnersQuery::new()
375            .with_category("politics")
376            .with_time_period("week")
377            .with_limit(50);
378
379        assert_eq!(query.category, "politics");
380        assert_eq!(query.time_period, "week");
381        assert_eq!(query.limit, 50);
382    }
383}