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