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 ActivityQuery, BiggestWinner, BiggestWinnersQuery, ClosedPosition, DataApiActivity,
30 DataApiPosition, DataApiTrade, DataApiTrader, PositionsQuery,
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")]
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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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; 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 batch_len < limit {
446 break;
447 }
448
449 tokio::time::sleep(Duration::from_millis(100)).await;
451 }
452
453 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 #[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 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 #[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 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}