1use 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#[derive(Debug, Clone)]
35pub struct DataConfig {
36 pub base_url: String,
38 pub clob_base_url: String,
40 pub timeout: Duration,
42 pub user_agent: String,
44}
45
46impl Default for DataConfig {
47 fn default() -> Self {
48 Self {
49 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 #[must_use]
61 pub fn builder() -> Self {
62 Self::default()
63 }
64
65 #[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 #[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 #[must_use]
81 pub fn with_timeout(mut self, timeout: Duration) -> Self {
82 self.timeout = timeout;
83 self
84 }
85
86 #[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 #[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#[derive(Debug, Clone)]
110pub struct DataClient {
111 config: DataConfig,
112 client: Client,
113}
114
115impl DataClient {
116 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 pub fn with_defaults() -> Result<Self> {
130 Self::new(DataConfig::default())
131 }
132
133 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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; 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 batch_len < limit {
282 break;
283 }
284
285 tokio::time::sleep(Duration::from_millis(100)).await;
287 }
288
289 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 #[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 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 #[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 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}