use std::time::Duration;
use reqwest::Client;
use serde::Deserialize;
use tracing::{debug, instrument};
use crate::core::profiles_api_url;
use crate::core::{PolymarketError, Result};
use crate::types::{LeaderboardEntry, TraderProfile};
#[derive(Debug, Clone)]
pub struct ProfilesConfig {
pub base_url: String,
pub timeout: Duration,
pub user_agent: String,
}
impl Default for ProfilesConfig {
fn default() -> Self {
Self {
base_url: profiles_api_url(),
timeout: Duration::from_secs(30),
user_agent: "polymarket-sdk/0.1.0".to_string(),
}
}
}
impl ProfilesConfig {
#[must_use]
pub fn builder() -> Self {
Self::default()
}
#[must_use]
pub fn with_base_url(mut self, url: impl Into<String>) -> Self {
self.base_url = url.into();
self
}
#[must_use]
pub fn with_timeout(mut self, timeout: Duration) -> Self {
self.timeout = timeout;
self
}
#[must_use]
pub fn with_user_agent(mut self, user_agent: impl Into<String>) -> Self {
self.user_agent = user_agent.into();
self
}
#[must_use]
#[deprecated(
since = "0.1.0",
note = "Use ProfilesConfig::default() instead. URL override via POLYMARKET_PROFILES_URL env var is already supported."
)]
pub fn from_env() -> Self {
Self::default()
}
}
#[derive(Debug, Clone)]
pub struct ProfilesClient {
config: ProfilesConfig,
client: Client,
}
impl ProfilesClient {
pub fn new(config: ProfilesConfig) -> Result<Self> {
let client = Client::builder()
.timeout(config.timeout)
.user_agent(&config.user_agent)
.gzip(true)
.build()
.map_err(|e| PolymarketError::config(format!("Failed to create HTTP client: {e}")))?;
Ok(Self { config, client })
}
pub fn with_defaults() -> Result<Self> {
Self::new(ProfilesConfig::default())
}
#[deprecated(since = "0.1.0", note = "Use ProfilesClient::with_defaults() instead")]
#[allow(deprecated)]
pub fn from_env() -> Result<Self> {
Self::new(ProfilesConfig::from_env())
}
#[instrument(skip(self), level = "debug")]
pub async fn get_profile(&self, address: &str) -> Result<Option<TraderProfile>> {
let url = format!(
"{}/profiles/{}",
self.config.base_url,
address.to_lowercase()
);
debug!(%url, "Fetching profile");
let response = self.client.get(&url).send().await?;
if response.status() == reqwest::StatusCode::NOT_FOUND {
return Ok(None);
}
self.handle_response::<TraderProfile>(response)
.await
.map(Some)
}
#[instrument(skip(self), level = "debug")]
pub async fn get_profiles(&self, addresses: &[String]) -> Result<Vec<TraderProfile>> {
if addresses.is_empty() {
return Ok(Vec::new());
}
let addresses_param = addresses
.iter()
.map(|a| a.to_lowercase())
.collect::<Vec<_>>()
.join(",");
let url = format!(
"{}/profiles?addresses={}",
self.config.base_url, addresses_param
);
debug!(%url, count = addresses.len(), "Fetching profiles batch");
let response = self.client.get(&url).send().await?;
self.handle_response::<Vec<TraderProfile>>(response).await
}
#[instrument(skip(self), level = "debug")]
pub async fn search_profiles(
&self,
query: &str,
limit: Option<u32>,
) -> Result<Vec<TraderProfile>> {
let mut url = format!(
"{}/profiles/search?q={}",
self.config.base_url,
urlencoding::encode(query)
);
if let Some(limit) = limit {
url.push_str(&format!("&limit={limit}"));
}
debug!(%url, "Searching profiles");
let response = self.client.get(&url).send().await?;
self.handle_response::<Vec<TraderProfile>>(response).await
}
#[instrument(skip(self), level = "debug")]
pub async fn get_leaderboard(
&self,
params: Option<LeaderboardParams>,
) -> Result<Vec<LeaderboardEntry>> {
let mut url = format!("{}/leaderboard", self.config.base_url);
if let Some(params) = params {
let mut query_parts = Vec::new();
if let Some(period) = params.period {
query_parts.push(format!("period={period}"));
}
if let Some(limit) = params.limit {
query_parts.push(format!("limit={limit}"));
}
if let Some(offset) = params.offset {
query_parts.push(format!("offset={offset}"));
}
if !query_parts.is_empty() {
url.push('?');
url.push_str(&query_parts.join("&"));
}
}
debug!(%url, "Fetching leaderboard");
let response = self.client.get(&url).send().await?;
self.handle_response::<Vec<LeaderboardEntry>>(response)
.await
}
#[instrument(skip(self), level = "debug")]
pub async fn get_top_traders(&self, limit: Option<u32>) -> Result<Vec<TraderProfile>> {
let mut url = format!("{}/traders/top", self.config.base_url);
if let Some(limit) = limit {
url.push_str(&format!("?limit={limit}"));
}
debug!(%url, "Fetching top traders");
let response = self.client.get(&url).send().await?;
self.handle_response::<Vec<TraderProfile>>(response).await
}
#[instrument(skip(self), level = "debug")]
pub async fn get_positions(&self, address: &str) -> Result<Vec<Position>> {
let url = format!(
"{}/profiles/{}/positions",
self.config.base_url,
address.to_lowercase()
);
debug!(%url, "Fetching positions");
let response = self.client.get(&url).send().await?;
self.handle_response::<Vec<Position>>(response).await
}
async fn handle_response<T: for<'de> Deserialize<'de>>(
&self,
response: reqwest::Response,
) -> Result<T> {
let status = response.status();
if status.is_success() {
let body = response.text().await?;
serde_json::from_str(&body).map_err(|e| {
PolymarketError::parse_with_source(format!("Failed to parse response: {e}"), e)
})
} else {
let body = response.text().await.unwrap_or_default();
Err(PolymarketError::api(status.as_u16(), body))
}
}
}
#[derive(Debug, Clone, Default)]
pub struct LeaderboardParams {
pub period: Option<String>,
pub limit: Option<u32>,
pub offset: Option<u32>,
}
impl LeaderboardParams {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn with_period(mut self, period: impl Into<String>) -> Self {
self.period = Some(period.into());
self
}
#[must_use]
pub fn with_limit(mut self, limit: u32) -> Self {
self.limit = Some(limit);
self
}
#[must_use]
pub fn with_offset(mut self, offset: u32) -> Self {
self.offset = Some(offset);
self
}
#[must_use]
pub fn daily() -> Self {
Self::new().with_period("daily")
}
#[must_use]
pub fn weekly() -> Self {
Self::new().with_period("weekly")
}
#[must_use]
pub fn monthly() -> Self {
Self::new().with_period("monthly")
}
#[must_use]
pub fn all_time() -> Self {
Self::new().with_period("all-time")
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Position {
pub condition_id: String,
pub token_id: Option<String>,
pub size: f64,
pub avg_price: Option<f64>,
pub value: Option<f64>,
pub realized_pnl: Option<f64>,
pub unrealized_pnl: Option<f64>,
pub outcome: Option<String>,
pub title: Option<String>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_config_builder() {
let config = ProfilesConfig::builder()
.with_base_url("https://custom.example.com")
.with_timeout(Duration::from_secs(60));
assert_eq!(config.base_url, "https://custom.example.com");
assert_eq!(config.timeout, Duration::from_secs(60));
}
#[test]
fn test_leaderboard_params() {
let params = LeaderboardParams::weekly().with_limit(100);
assert_eq!(params.period, Some("weekly".to_string()));
assert_eq!(params.limit, Some(100));
}
}