Skip to main content

polyoxide_data/
client.rs

1use polyoxide_core::{
2    HttpClient, HttpClientBuilder, RateLimiter, RetryConfig, DEFAULT_POOL_SIZE, DEFAULT_TIMEOUT_MS,
3};
4
5use crate::{
6    api::{
7        accounting::AccountingApi,
8        builders::BuildersApi,
9        combos::CombosApi,
10        health::Health,
11        holders::Holders,
12        leaderboard::LeaderboardApi,
13        live_volume::LiveVolumeApi,
14        market_positions::MarketPositionsApi,
15        misc::MiscApi,
16        open_interest::OpenInterestApi,
17        pnl::PnlApi,
18        rankings::RankingsApi,
19        trades::Trades,
20        users::{UserApi, UserTraded},
21    },
22    error::DataApiError,
23};
24
25const DEFAULT_BASE_URL: &str = "https://data-api.polymarket.com";
26const DEFAULT_PNL_BASE_URL: &str = "https://user-pnl-api.polymarket.com";
27const DEFAULT_RANKINGS_BASE_URL: &str = "https://lb-api.polymarket.com";
28
29/// Main Data API client
30///
31/// Most namespaces target `data-api.polymarket.com`. Two of them —
32/// [`pnl`](Self::pnl) and [`rankings`](Self::rankings) — target sibling hosts
33/// that Polymarket does not publish an OpenAPI spec for; see those methods for
34/// the stability caveat. All three share one connection pool, rate limiter,
35/// and concurrency budget.
36#[derive(Clone)]
37pub struct DataApi {
38    pub(crate) http_client: HttpClient,
39    pub(crate) pnl_http_client: HttpClient,
40    pub(crate) rankings_http_client: HttpClient,
41}
42
43impl DataApi {
44    /// Create a new Data API client with default configuration
45    pub fn new() -> Result<Self, DataApiError> {
46        Self::builder().build()
47    }
48
49    /// Create a builder for configuring the client
50    pub fn builder() -> DataApiBuilder {
51        DataApiBuilder::new()
52    }
53
54    /// Get health namespace
55    pub fn health(&self) -> Health {
56        Health {
57            http_client: self.http_client.clone(),
58        }
59    }
60
61    /// Get user namespace for user-specific operations
62    pub fn user(&self, user_address: impl Into<String>) -> UserApi {
63        UserApi {
64            http_client: self.http_client.clone(),
65            user_address: user_address.into(),
66        }
67    }
68
69    /// Alias for `user()` - for backwards compatibility
70    pub fn positions(&self, user_address: impl Into<String>) -> UserApi {
71        self.user(user_address)
72    }
73
74    /// Get traded namespace for backwards compatibility
75    pub fn traded(&self, user_address: impl Into<String>) -> Traded {
76        Traded {
77            user_api: self.user(user_address),
78        }
79    }
80
81    /// Get trades namespace
82    pub fn trades(&self) -> Trades {
83        Trades {
84            http_client: self.http_client.clone(),
85        }
86    }
87
88    /// Get holders namespace
89    pub fn holders(&self) -> Holders {
90        Holders {
91            http_client: self.http_client.clone(),
92        }
93    }
94
95    /// Get open interest namespace
96    pub fn open_interest(&self) -> OpenInterestApi {
97        OpenInterestApi {
98            http_client: self.http_client.clone(),
99        }
100    }
101
102    /// Get live volume namespace
103    pub fn live_volume(&self) -> LiveVolumeApi {
104        LiveVolumeApi {
105            http_client: self.http_client.clone(),
106        }
107    }
108
109    /// Get builders namespace
110    pub fn builders(&self) -> BuildersApi {
111        BuildersApi {
112            http_client: self.http_client.clone(),
113        }
114    }
115
116    /// Get leaderboard namespace
117    pub fn leaderboard(&self) -> LeaderboardApi {
118        LeaderboardApi {
119            http_client: self.http_client.clone(),
120        }
121    }
122
123    /// Get market-positions namespace (`/v1/market-positions`)
124    pub fn market_positions(&self) -> MarketPositionsApi {
125        MarketPositionsApi {
126            http_client: self.http_client.clone(),
127        }
128    }
129
130    /// Get accounting namespace (`/v1/accounting/snapshot`, returns ZIP bytes)
131    pub fn accounting(&self) -> AccountingApi {
132        AccountingApi {
133            http_client: self.http_client.clone(),
134        }
135    }
136
137    /// Get combos namespace (`/v1/positions/combos`, `/v1/activity/combos`)
138    pub fn combos(&self) -> CombosApi {
139        CombosApi {
140            http_client: self.http_client.clone(),
141        }
142    }
143
144    /// Get misc namespace (`/other`, `/revisions`)
145    pub fn misc(&self) -> MiscApi {
146        MiscApi {
147            http_client: self.http_client.clone(),
148        }
149    }
150
151    /// Get PnL namespace (`/user-pnl` on `user-pnl-api.polymarket.com`)
152    ///
153    /// This host has no published OpenAPI spec — see [`PnlApi`] for the
154    /// stability caveat.
155    pub fn pnl(&self) -> PnlApi {
156        PnlApi {
157            http_client: self.pnl_http_client.clone(),
158        }
159    }
160
161    /// Get rankings namespace (`/volume`, `/profit` on `lb-api.polymarket.com`)
162    ///
163    /// Distinct from [`Self::leaderboard`], which calls `/v1/leaderboard` on
164    /// the main Data API host. This host has no published OpenAPI spec — see
165    /// [`RankingsApi`] for the stability caveat.
166    pub fn rankings(&self) -> RankingsApi {
167        RankingsApi {
168            http_client: self.rankings_http_client.clone(),
169        }
170    }
171}
172
173/// Builder for configuring Data API client
174pub struct DataApiBuilder {
175    base_url: String,
176    pnl_base_url: String,
177    rankings_base_url: String,
178    timeout_ms: u64,
179    pool_size: usize,
180    retry_config: Option<RetryConfig>,
181    max_concurrent: Option<usize>,
182}
183
184impl DataApiBuilder {
185    fn new() -> Self {
186        Self {
187            base_url: DEFAULT_BASE_URL.to_string(),
188            pnl_base_url: DEFAULT_PNL_BASE_URL.to_string(),
189            rankings_base_url: DEFAULT_RANKINGS_BASE_URL.to_string(),
190            timeout_ms: DEFAULT_TIMEOUT_MS,
191            pool_size: DEFAULT_POOL_SIZE,
192            retry_config: None,
193            max_concurrent: None,
194        }
195    }
196
197    /// Set base URL for the API
198    ///
199    /// This covers every namespace except [`DataApi::pnl`] and
200    /// [`DataApi::rankings`], which live on their own hosts and have their own
201    /// setters.
202    pub fn base_url(mut self, url: impl Into<String>) -> Self {
203        self.base_url = url.into();
204        self
205    }
206
207    /// Set base URL for the PnL host (default:
208    /// `https://user-pnl-api.polymarket.com`)
209    ///
210    /// Give a scheme, host, and port only — a path prefix is silently dropped,
211    /// because request paths are absolute. See
212    /// [`HttpClient::with_base_url`](polyoxide_core::HttpClient::with_base_url).
213    pub fn pnl_base_url(mut self, url: impl Into<String>) -> Self {
214        self.pnl_base_url = url.into();
215        self
216    }
217
218    /// Set base URL for the rankings host (default:
219    /// `https://lb-api.polymarket.com`)
220    ///
221    /// Give a scheme, host, and port only — a path prefix is silently dropped,
222    /// because request paths are absolute. See
223    /// [`HttpClient::with_base_url`](polyoxide_core::HttpClient::with_base_url).
224    pub fn rankings_base_url(mut self, url: impl Into<String>) -> Self {
225        self.rankings_base_url = url.into();
226        self
227    }
228
229    /// Set request timeout in milliseconds
230    pub fn timeout_ms(mut self, timeout: u64) -> Self {
231        self.timeout_ms = timeout;
232        self
233    }
234
235    /// Set connection pool size
236    pub fn pool_size(mut self, size: usize) -> Self {
237        self.pool_size = size;
238        self
239    }
240
241    /// Set retry configuration for 429 responses
242    pub fn with_retry_config(mut self, config: RetryConfig) -> Self {
243        self.retry_config = Some(config);
244        self
245    }
246
247    /// Set the maximum number of concurrent in-flight requests.
248    ///
249    /// Default: 4. Prevents Cloudflare 1015 errors from request bursts.
250    pub fn max_concurrent(mut self, max: usize) -> Self {
251        self.max_concurrent = Some(max);
252        self
253    }
254
255    /// Build the Data API client
256    pub fn build(self) -> Result<DataApi, DataApiError> {
257        let mut builder = HttpClientBuilder::new(&self.base_url)
258            .timeout_ms(self.timeout_ms)
259            .pool_size(self.pool_size)
260            .with_rate_limiter(RateLimiter::data_default())
261            .with_max_concurrent(self.max_concurrent.unwrap_or(4));
262        if let Some(config) = self.retry_config {
263            builder = builder.with_retry_config(config);
264        }
265        let http_client = builder.build()?;
266
267        // Sibling hosts reuse the same reqwest client, rate limiter, and
268        // concurrency permit pool — only the base URL differs.
269        let pnl_http_client = http_client.with_base_url(&self.pnl_base_url)?;
270        let rankings_http_client = http_client.with_base_url(&self.rankings_base_url)?;
271
272        Ok(DataApi {
273            http_client,
274            pnl_http_client,
275            rankings_http_client,
276        })
277    }
278}
279
280impl Default for DataApiBuilder {
281    fn default() -> Self {
282        Self::new()
283    }
284}
285
286/// Wrapper for backwards compatibility with traded() API
287pub struct Traded {
288    user_api: UserApi,
289}
290
291impl Traded {
292    /// Get total markets traded by the user
293    pub async fn get(self) -> std::result::Result<UserTraded, DataApiError> {
294        self.user_api.traded().await
295    }
296}
297
298#[cfg(test)]
299mod tests {
300    use super::*;
301
302    #[test]
303    fn test_builder_default() {
304        let builder = DataApiBuilder::default();
305        assert_eq!(builder.base_url, DEFAULT_BASE_URL);
306    }
307
308    #[test]
309    fn test_builder_custom_retry_config() {
310        let config = RetryConfig {
311            max_retries: 5,
312            initial_backoff_ms: 1000,
313            max_backoff_ms: 30_000,
314        };
315        let builder = DataApiBuilder::new().with_retry_config(config);
316        let config = builder.retry_config.unwrap();
317        assert_eq!(config.max_retries, 5);
318        assert_eq!(config.initial_backoff_ms, 1000);
319    }
320
321    #[test]
322    fn test_builder_custom_max_concurrent() {
323        let builder = DataApiBuilder::new().max_concurrent(10);
324        assert_eq!(builder.max_concurrent, Some(10));
325    }
326
327    #[tokio::test]
328    async fn test_default_concurrency_limit_is_4() {
329        let data = DataApi::new().unwrap();
330        let mut permits = Vec::new();
331        for _ in 0..4 {
332            permits.push(data.http_client.acquire_concurrency().await);
333        }
334        assert!(permits.iter().all(|p| p.is_some()));
335
336        let result = tokio::time::timeout(
337            std::time::Duration::from_millis(50),
338            data.http_client.acquire_concurrency(),
339        )
340        .await;
341        assert!(
342            result.is_err(),
343            "5th permit should block with default limit of 4"
344        );
345    }
346
347    #[test]
348    fn test_builder_build_success() {
349        let data = DataApi::builder().build();
350        assert!(data.is_ok());
351    }
352}