Skip to main content

polymarket_rs_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    ActivityQuery, BiggestWinner, BiggestWinnersQuery, ClosedPosition, DataApiActivity,
30    DataApiPosition, DataApiTrade, DataApiTrader, PositionsQuery,
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 with query parameters
153    ///
154    /// # Examples
155    ///
156    /// ```rust,ignore
157    /// use polymarket_sdk::data::{DataClient, DataConfig};
158    /// use polymarket_sdk::types::{PositionsQuery, PositionSortBy, SortDirection};
159    ///
160    /// let client = DataClient::new(DataConfig::default())?;
161    ///
162    /// // Simple query - just user address
163    /// let positions = client.get_positions_with_query(
164    ///     &PositionsQuery::new("0x...")
165    /// ).await?;
166    ///
167    /// // Advanced query with filters
168    /// let query = PositionsQuery::new("0x...")
169    ///     .with_size_threshold(10.0)
170    ///     .redeemable_only()
171    ///     .with_limit(50)
172    ///     .sort_by(PositionSortBy::CashPnl)
173    ///     .sort_direction(SortDirection::Desc);
174    ///
175    /// let positions = client.get_positions_with_query(&query).await?;
176    /// ```
177    #[instrument(skip(self), level = "debug")]
178    pub async fn get_positions_with_query(
179        &self,
180        query: &PositionsQuery,
181    ) -> Result<Vec<DataApiPosition>> {
182        let query_string = query.to_query_string();
183        let url = format!("{}/positions?{}", self.config.base_url, query_string);
184        debug!(%url, "Fetching positions with query");
185
186        let response = self.client.get(&url).send().await?;
187        self.handle_response::<Vec<DataApiPosition>>(response).await
188    }
189
190    /// Get positions for a wallet address (simple version)
191    ///
192    /// For more control over query parameters, use [`Self::get_positions_with_query`].
193    #[instrument(skip(self), level = "debug")]
194    pub async fn get_positions(&self, address: &str) -> Result<Vec<DataApiPosition>> {
195        let query = PositionsQuery::new(address);
196        self.get_positions_with_query(&query).await
197    }
198
199    /// Get trades for a wallet address
200    #[instrument(skip(self), level = "debug")]
201    pub async fn get_trades(&self, address: &str, limit: Option<u32>) -> Result<Vec<DataApiTrade>> {
202        let limit = limit.unwrap_or(100);
203        let url = format!(
204            "{}/trades?user={}&limit={}",
205            self.config.base_url, address, limit
206        );
207        debug!(%url, "Fetching trades");
208
209        let response = self.client.get(&url).send().await?;
210        self.handle_response::<Vec<DataApiTrade>>(response).await
211    }
212
213    /// Get user activity with full query options
214    ///
215    /// Use [`ActivityQuery`] to build complex queries with filtering, sorting, and pagination.
216    ///
217    /// # Example
218    ///
219    /// ```rust,ignore
220    /// use polymarket_sdk::types::{ActivityQuery, ActivityType, ActivitySortBy, SortDirection, Side};
221    ///
222    /// // Get recent trades for a user
223    /// let query = ActivityQuery::new("0x...")
224    ///     .trades_only()
225    ///     .with_limit(50)
226    ///     .newest_first();
227    ///
228    /// let activity = client.get_user_activity_with_query(&query).await?;
229    ///
230    /// // Get buy trades in a specific time range
231    /// let query = ActivityQuery::new("0x...")
232    ///     .with_type(ActivityType::Trade)
233    ///     .buys_only()
234    ///     .with_time_range(start_ts, end_ts)
235    ///     .largest_first();
236    ///
237    /// let activity = client.get_user_activity_with_query(&query).await?;
238    /// ```
239    #[instrument(skip(self), level = "debug")]
240    pub async fn get_user_activity_with_query(
241        &self,
242        query: &ActivityQuery,
243    ) -> Result<Vec<DataApiActivity>> {
244        let query_string = query.to_query_string();
245        let url = format!("{}/activity?{}", self.config.base_url, query_string);
246        debug!(%url, "Fetching user activity with query");
247
248        let response = self.client.get(&url).send().await?;
249        self.handle_response::<Vec<DataApiActivity>>(response).await
250    }
251
252    /// Get user activity (trades, position changes) - simple version
253    ///
254    /// For more control over query parameters, use [`Self::get_user_activity_with_query`].
255    #[instrument(skip(self), level = "debug")]
256    pub async fn get_user_activity(
257        &self,
258        address: &str,
259        limit: Option<u32>,
260        offset: Option<u32>,
261    ) -> Result<Vec<DataApiActivity>> {
262        let mut query = ActivityQuery::new(address);
263        if let Some(l) = limit {
264            query = query.with_limit(l);
265        }
266        if let Some(o) = offset {
267            query = query.with_offset(o);
268        }
269        self.get_user_activity_with_query(&query).await
270    }
271
272    /// Get closed positions for a user (for PnL calculation)
273    #[instrument(skip(self), level = "debug")]
274    pub async fn get_closed_positions(
275        &self,
276        address: &str,
277        limit: Option<u32>,
278        offset: Option<u32>,
279    ) -> Result<Vec<ClosedPosition>> {
280        let limit = limit.unwrap_or(100);
281        let offset = offset.unwrap_or(0);
282        let url = format!(
283            "{}/closed-positions?user={}&limit={}&offset={}",
284            self.config.base_url, address, limit, offset
285        );
286        debug!(%url, "Fetching closed positions");
287
288        let response = self.client.get(&url).send().await?;
289        self.handle_response::<Vec<ClosedPosition>>(response).await
290    }
291
292    /// Get all redeemable positions for a user
293    ///
294    /// Convenience method to fetch positions that can be redeemed
295    #[instrument(skip(self), level = "debug")]
296    pub async fn get_redeemable_positions(&self, address: &str) -> Result<Vec<DataApiPosition>> {
297        let query = PositionsQuery::new(address).redeemable_only();
298        self.get_positions_with_query(&query).await
299    }
300
301    /// Get all mergeable positions for a user
302    ///
303    /// Convenience method to fetch positions that can be merged
304    #[instrument(skip(self), level = "debug")]
305    pub async fn get_mergeable_positions(&self, address: &str) -> Result<Vec<DataApiPosition>> {
306        let query = PositionsQuery::new(address).mergeable_only();
307        self.get_positions_with_query(&query).await
308    }
309
310    /// Get positions for specific markets
311    ///
312    /// # Arguments
313    ///
314    /// * `address` - User wallet address
315    /// * `market_ids` - Vector of market condition IDs
316    #[instrument(skip(self), level = "debug")]
317    pub async fn get_positions_for_markets(
318        &self,
319        address: &str,
320        market_ids: Vec<String>,
321    ) -> Result<Vec<DataApiPosition>> {
322        let query = PositionsQuery::new(address).with_markets(market_ids);
323        self.get_positions_with_query(&query).await
324    }
325
326    /// Get positions for specific events
327    ///
328    /// # Arguments
329    ///
330    /// * `address` - User wallet address
331    /// * `event_ids` - Vector of event IDs
332    #[instrument(skip(self), level = "debug")]
333    pub async fn get_positions_for_events(
334        &self,
335        address: &str,
336        event_ids: Vec<i64>,
337    ) -> Result<Vec<DataApiPosition>> {
338        let query = PositionsQuery::new(address).with_event_ids(event_ids);
339        self.get_positions_with_query(&query).await
340    }
341
342    /// Get top profitable positions sorted by PnL
343    ///
344    /// # Arguments
345    ///
346    /// * `address` - User wallet address
347    /// * `limit` - Number of positions to return (default: 10)
348    #[instrument(skip(self), level = "debug")]
349    pub async fn get_top_profitable_positions(
350        &self,
351        address: &str,
352        limit: Option<u32>,
353    ) -> Result<Vec<DataApiPosition>> {
354        use crate::types::{PositionSortBy, SortDirection};
355
356        let query = PositionsQuery::new(address)
357            .with_limit(limit.unwrap_or(10))
358            .sort_by(PositionSortBy::CashPnl)
359            .sort_direction(SortDirection::Desc);
360
361        self.get_positions_with_query(&query).await
362    }
363
364    /// Get positions above a certain size threshold
365    ///
366    /// # Arguments
367    ///
368    /// * `address` - User wallet address
369    /// * `threshold` - Minimum position size
370    #[instrument(skip(self), level = "debug")]
371    pub async fn get_positions_above_size(
372        &self,
373        address: &str,
374        threshold: f64,
375    ) -> Result<Vec<DataApiPosition>> {
376        let query = PositionsQuery::new(address).with_size_threshold(threshold);
377        self.get_positions_with_query(&query).await
378    }
379
380    /// Get biggest winners by category and time period
381    #[instrument(skip(self), level = "debug")]
382    pub async fn get_biggest_winners(
383        &self,
384        query: &BiggestWinnersQuery,
385    ) -> Result<Vec<BiggestWinner>> {
386        let url = format!(
387            "{}/v1/biggest-winners?timePeriod={}&limit={}&offset={}&category={}",
388            self.config.base_url, query.time_period, query.limit, query.offset, query.category
389        );
390        debug!(%url, "Fetching biggest winners");
391
392        let response = self.client.get(&url).send().await?;
393        self.handle_response::<Vec<BiggestWinner>>(response).await
394    }
395
396    /// Get top biggest winners with auto-pagination
397    ///
398    /// Fetches winners in batches of 100 until reaching total_limit
399    #[instrument(skip(self), level = "debug")]
400    pub async fn get_top_biggest_winners(
401        &self,
402        category: &str,
403        time_period: &str,
404        total_limit: usize,
405    ) -> Result<Vec<BiggestWinner>> {
406        let mut all_winners = Vec::new();
407        let batch_size = 100; // API max per request
408        let mut offset = 0;
409
410        while all_winners.len() < total_limit {
411            let remaining = total_limit - all_winners.len();
412            let limit = std::cmp::min(batch_size, remaining);
413
414            let query = BiggestWinnersQuery {
415                time_period: time_period.to_string(),
416                limit,
417                offset,
418                category: category.to_string(),
419            };
420
421            debug!(
422                category,
423                time_period, offset, limit, "Fetching biggest winners batch"
424            );
425
426            let batch = self.get_biggest_winners(&query).await?;
427
428            if batch.is_empty() {
429                debug!(category, "No more winners available");
430                break;
431            }
432
433            let batch_len = batch.len();
434            all_winners.extend(batch);
435            offset += batch_len;
436
437            debug!(
438                category,
439                batch_count = batch_len,
440                total = all_winners.len(),
441                "Fetched biggest winners batch"
442            );
443
444            // If we got less than requested, no more pages
445            if batch_len < limit {
446                break;
447            }
448
449            // Small delay to avoid rate limiting
450            tokio::time::sleep(Duration::from_millis(100)).await;
451        }
452
453        // Truncate to exact limit
454        all_winners.truncate(total_limit);
455
456        tracing::info!(
457            category,
458            total = all_winners.len(),
459            "Fetched all biggest winners"
460        );
461
462        Ok(all_winners)
463    }
464
465    /// Get token midpoint price from CLOB
466    #[instrument(skip(self), level = "debug")]
467    pub async fn get_token_midpoint(&self, token_id: &str) -> Result<f64> {
468        let url = format!(
469            "{}/midpoint?token_id={}",
470            self.config.clob_base_url, token_id
471        );
472        debug!(%url, "Fetching token midpoint");
473
474        let response = self.client.get(&url).send().await?;
475
476        if !response.status().is_success() {
477            // Return default 0.5 for failed requests
478            return Ok(0.5);
479        }
480
481        let data: serde_json::Value = response.json().await.map_err(|e| {
482            PolymarketError::parse_with_source(format!("Failed to parse midpoint response: {e}"), e)
483        })?;
484
485        let price = data["mid"]
486            .as_str()
487            .and_then(|p| p.parse::<f64>().ok())
488            .unwrap_or(0.5);
489
490        Ok(price)
491    }
492
493    /// Get order book for a token
494    #[instrument(skip(self), level = "debug")]
495    pub async fn get_order_book(&self, token_id: &str) -> Result<serde_json::Value> {
496        let url = format!("{}/book?token_id={}", self.config.clob_base_url, token_id);
497        debug!(%url, "Fetching order book");
498
499        let response = self.client.get(&url).send().await?;
500        self.handle_response::<serde_json::Value>(response).await
501    }
502
503    /// Handle API response
504    async fn handle_response<T: for<'de> Deserialize<'de>>(
505        &self,
506        response: reqwest::Response,
507    ) -> Result<T> {
508        let status = response.status();
509
510        if status.is_success() {
511            let body = response.text().await?;
512            serde_json::from_str(&body).map_err(|e| {
513                PolymarketError::parse_with_source(format!("Failed to parse response: {e}"), e)
514            })
515        } else {
516            let body = response.text().await.unwrap_or_default();
517            Err(PolymarketError::api(status.as_u16(), body))
518        }
519    }
520}
521
522#[cfg(test)]
523mod tests {
524    use super::*;
525
526    #[test]
527    fn test_config_builder() {
528        let config = DataConfig::builder()
529            .with_base_url("https://custom.example.com")
530            .with_timeout(Duration::from_secs(60));
531
532        assert_eq!(config.base_url, "https://custom.example.com");
533        assert_eq!(config.timeout, Duration::from_secs(60));
534    }
535
536    #[test]
537    fn test_biggest_winners_query() {
538        let query = BiggestWinnersQuery::new()
539            .with_category("politics")
540            .with_time_period("week")
541            .with_limit(50);
542
543        assert_eq!(query.category, "politics");
544        assert_eq!(query.time_period, "week");
545        assert_eq!(query.limit, 50);
546    }
547
548    #[test]
549    fn test_positions_query_builder() {
550        use crate::types::{PositionSortBy, SortDirection};
551
552        let query = PositionsQuery::new("0x1234567890123456789012345678901234567890")
553            .with_size_threshold(10.0)
554            .redeemable_only()
555            .with_limit(50)
556            .with_offset(10)
557            .sort_by(PositionSortBy::CashPnl)
558            .sort_direction(SortDirection::Desc);
559
560        assert_eq!(query.user, "0x1234567890123456789012345678901234567890");
561        assert_eq!(query.size_threshold, Some(10.0));
562        assert_eq!(query.redeemable, Some(true));
563        assert_eq!(query.limit, Some(50));
564        assert_eq!(query.offset, Some(10));
565        assert_eq!(query.sort_by, Some(PositionSortBy::CashPnl));
566        assert_eq!(query.sort_direction, Some(SortDirection::Desc));
567    }
568
569    #[test]
570    fn test_positions_query_to_string() {
571        let query = PositionsQuery::new("0xabc")
572            .with_size_threshold(5.0)
573            .with_limit(20);
574
575        let query_string = query.to_query_string();
576
577        assert!(query_string.contains("user=0xabc"));
578        assert!(query_string.contains("sizeThreshold=5"));
579        assert!(query_string.contains("limit=20"));
580    }
581
582    #[test]
583    fn test_positions_query_with_markets() {
584        let markets = vec![
585            "0xdd22472e552920b8438158ea7238bfadfa4f736aa4cee91a6b86c39ead110917".to_string(),
586            "0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890".to_string(),
587        ];
588
589        let query = PositionsQuery::new("0xuser").with_markets(markets.clone());
590
591        assert_eq!(query.markets, Some(markets));
592
593        let query_string = query.to_query_string();
594        assert!(query_string.contains("market="));
595        assert!(query_string
596            .contains("0xdd22472e552920b8438158ea7238bfadfa4f736aa4cee91a6b86c39ead110917"));
597    }
598
599    #[test]
600    fn test_positions_query_with_event_ids() {
601        let event_ids = vec![123, 456, 789];
602        let query = PositionsQuery::new("0xuser").with_event_ids(event_ids.clone());
603
604        assert_eq!(query.event_ids, Some(event_ids));
605
606        let query_string = query.to_query_string();
607        assert!(query_string.contains("eventId=123,456,789"));
608    }
609
610    #[test]
611    fn test_position_sort_by_as_str() {
612        use crate::types::PositionSortBy;
613
614        assert_eq!(PositionSortBy::Current.as_str(), "CURRENT");
615        assert_eq!(PositionSortBy::Initial.as_str(), "INITIAL");
616        assert_eq!(PositionSortBy::Tokens.as_str(), "TOKENS");
617        assert_eq!(PositionSortBy::CashPnl.as_str(), "CASHPNL");
618        assert_eq!(PositionSortBy::PercentPnl.as_str(), "PERCENTPNL");
619        assert_eq!(PositionSortBy::Title.as_str(), "TITLE");
620        assert_eq!(PositionSortBy::Resolving.as_str(), "RESOLVING");
621        assert_eq!(PositionSortBy::Price.as_str(), "PRICE");
622        assert_eq!(PositionSortBy::AvgPrice.as_str(), "AVGPRICE");
623    }
624
625    #[test]
626    fn test_sort_direction_as_str() {
627        use crate::types::SortDirection;
628
629        assert_eq!(SortDirection::Asc.as_str(), "ASC");
630        assert_eq!(SortDirection::Desc.as_str(), "DESC");
631    }
632}