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; } }