using System.Net.Http.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.Logging;
using Predictalytics.Application.Interfaces;
using Predictalytics.Domain.Enums;
namespace Predictalytics.Infrastructure.Providers.Polymarket;
///
/// HTTP client for Polymarket Data API.
/// All endpoints use https://data-api.polymarket.com
///
public class PolymarketApiClient
{
private readonly HttpClient _client;
private readonly HttpClient _gammaClient;
private readonly HttpClient _clobClient;
private readonly IRateLimiter _rateLimiter;
private readonly ILogger _logger;
private const string DataApiBase = "https://data-api.polymarket.com";
private const string GammaApiBase = "https://gamma-api.polymarket.com";
private const string ClobApiBase = "https://clob.polymarket.com";
public PolymarketApiClient(IHttpClientFactory httpFactory, IRateLimiter rateLimiter, ILogger logger)
{
_client = httpFactory.CreateClient("PolymarketData");
_client.BaseAddress = new Uri(DataApiBase);
_client.DefaultRequestHeaders.Add("Accept", "application/json");
_gammaClient = httpFactory.CreateClient("PolymarketGamma");
_gammaClient.BaseAddress = new Uri(GammaApiBase);
_gammaClient.DefaultRequestHeaders.Add("Accept", "application/json");
_clobClient = httpFactory.CreateClient("PolymarketClob");
_clobClient.BaseAddress = new Uri(ClobApiBase);
_clobClient.DefaultRequestHeaders.Add("Accept", "application/json");
_rateLimiter = rateLimiter;
_logger = logger;
}
public async Task> GetTradesAsync(string walletAddress, int limit = 1000, CancellationToken ct = default)
{
var url = $"/activity?user={walletAddress}&limit={limit}";
return await ExecuteWithRetryAsync>(_client, url, "Data", ct) ?? [];
}
public async Task> GetTradesPagedAsync(string walletAddress, int limit = 500, CancellationToken ct = default)
{
var allTrades = new List();
long? endTimestamp = null;
while (true)
{
var url = $"/activity?user={walletAddress}&limit={limit}";
if (endTimestamp.HasValue)
{
url += $"&end={endTimestamp.Value}";
}
var batch = await ExecuteWithRetryAsync>(_client, url, "Data", ct);
if (batch == null || batch.Count == 0)
{
break;
}
allTrades.AddRange(batch);
if (batch.Count < limit)
{
break;
}
var oldestTimestamp = batch.Min(t => t.Timestamp);
endTimestamp = oldestTimestamp - 1;
}
return allTrades;
}
public async Task> GetMarketTradesAsync(string conditionId, int limit = 1000, int offset = 0, CancellationToken ct = default)
{
var url = $"/trades?condition_id={conditionId}&limit={limit}&offset={offset}";
return await ExecuteWithRetryAsync>(_client, url, "Data", ct) ?? [];
}
public async Task> GetPositionsAsync(string walletAddress, CancellationToken ct = default)
{
var url = $"/positions?user={walletAddress}&sizeThreshold=0.1&sortBy=CURRENT&sortOrder=DESC";
return await ExecuteWithRetryAsync>(_client, url, "Data", ct) ?? [];
}
public async Task GetMarketAsync(string conditionId, CancellationToken ct = default)
{
var url = $"/markets?condition_id={conditionId}";
var results = await ExecuteWithRetryAsync>(_gammaClient, url, "Gamma", ct);
return results?.FirstOrDefault();
}
///
/// Fetch a batch of events (and their nested markets) from the Gamma API with pagination.
/// Supports offset-based pagination via the offset parameter.
///
public async Task> GetEventsAsync(int limit = 100, int offset = 0, bool includeClosed = false, CancellationToken ct = default)
{
var activeOnly = !includeClosed;
var url = $"/events?limit={limit}&offset={offset}&active={activeOnly.ToString().ToLower()}&closed={includeClosed.ToString().ToLower()}";
_logger.LogDebug("Fetching events: {Url}", url);
var result = await ExecuteWithRetryAsync>(_gammaClient, url, "Gamma", ct);
_logger.LogInformation("Fetched {Count} events (offset={Offset}, closed={Closed})", result?.Count ?? 0, offset, includeClosed);
return result ?? [];
}
///
/// Fetch top holders for a specific market (conditionId) from the Data API.
/// Returns holders grouped by token (outcome).
///
public async Task> GetHoldersAsync(string conditionId, int limit = 20, CancellationToken ct = default)
{
var url = $"/holders?market={conditionId}&limit={limit}";
_logger.LogDebug("Fetching holders: {Url}", url);
var result = await ExecuteWithRetryAsync>(_client, url, "Data", ct);
_logger.LogInformation("Fetched holders for {Market}: {Count} token groups",
conditionId.Length > 12 ? conditionId[..12] + "..." : conditionId, result?.Count ?? 0);
return result ?? [];
}
///
/// Get leaderboard from the official Polymarket Data API v1.
/// Endpoint: GET https://data-api.polymarket.com/v1/leaderboard
///
public async Task> GetLeaderboardAsync(
int limit = 50,
string timePeriod = "ALL",
string orderBy = "PNL",
string category = "OVERALL",
CancellationToken ct = default)
{
var url = $"/v1/leaderboard?limit={Math.Min(limit, 50)}&time_period={timePeriod}&order_by={orderBy}&category={category}";
_logger.LogDebug("Fetching leaderboard: {Url}", url);
var result = await ExecuteWithRetryAsync>(_client, url, "Data", ct);
_logger.LogInformation("Leaderboard returned {Count} entries", result?.Count ?? 0);
return result ?? [];
}
///
/// Fetch CLOB orderbook for a given token ID.
///
public async Task GetOrderBookAsync(string tokenId, CancellationToken ct = default)
{
var url = $"/book?token_id={tokenId}";
_logger.LogDebug("Fetching orderbook for token: {TokenId}", tokenId);
return await ExecuteWithRetryAsync(_clobClient, url, "CLOB", ct);
}
private async Task ExecuteWithRetryAsync(HttpClient client, string url, string endpointGroup, CancellationToken ct, int attempt = 1)
{
await _rateLimiter.WaitAsync(PlatformType.Polymarket, ct, endpointGroup);
try
{
var response = await client.GetAsync(url, ct);
if (response.StatusCode == System.Net.HttpStatusCode.TooManyRequests)
{
TimeSpan? retryAfter = null;
if (response.Headers.RetryAfter != null)
{
retryAfter = response.Headers.RetryAfter.Delta ??
(response.Headers.RetryAfter.Date.HasValue
? response.Headers.RetryAfter.Date.Value - DateTimeOffset.UtcNow
: null);
}
var waitTime = retryAfter ?? TimeSpan.FromSeconds(30);
if (waitTime.TotalSeconds < 5)
{
_logger.LogWarning("Got 429 but Retry-After was {RawWait}s. Enforcing 30s minimum.", waitTime.TotalSeconds);
waitTime = TimeSpan.FromSeconds(30);
}
_logger.LogWarning("⚠️ Rate limit exceeded (429) for Polymarket {Group}. Pausing for {WaitTime}s...", endpointGroup, (int)waitTime.TotalSeconds);
_rateLimiter.ReportRateLimitExceeded(PlatformType.Polymarket, waitTime, endpointGroup);
if (attempt < 3)
{
_logger.LogWarning("Retrying {Url} (attempt {NextAttempt})...", url, attempt + 1);
return await ExecuteWithRetryAsync(client, url, endpointGroup, ct, attempt + 1);
}
return default;
}
if ((int)response.StatusCode == 422)
{
_logger.LogInformation("End of data reached (422) for {Url}. Stopping pagination.", url);
return default;
}
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync(cancellationToken: ct);
}
catch (Exception ex)
{
if (ex is HttpRequestException hex && hex.StatusCode == System.Net.HttpStatusCode.TooManyRequests)
{
_logger.LogCritical("Unhandled 429 in PolymarketApiClient for {Url}. This should have been caught by the status code check.", url);
}
_logger.LogError(ex, "Failed to fetch from {Url} (attempt {Attempt})", url, attempt);
return default;
}
}
public async Task> GetPricesHistoryAsync(string clobTokenId, string interval = "6h", CancellationToken ct = default)
{
var url = $"/prices-history?market={clobTokenId}&interval={interval}";
var result = await ExecuteWithRetryAsync(_clobClient, url, "Clob", ct);
return result?.History ?? [];
}
}
public class PolymarketPriceHistoryResponse
{
[JsonPropertyName("history")] public List History { get; set; } = [];
}
public class PriceHistoryEntry
{
[JsonPropertyName("t")] public long Timestamp { get; set; }
[JsonPropertyName("p")] public double Price { get; set; }
}