Initial commit: Predictalytics solution
Clean Architecture .NET 8 solution (Domain/Application/Infrastructure/Api/Worker/WinFormsHost) for analyzing Polymarket traders for copytrading/strategy-replication candidates. Includes EF Core InitialBaseline migration and DB secrets removed from source/config in preparation for version control.
This commit is contained in:
@@ -0,0 +1,164 @@
|
||||
using System.Net.Http.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Predictalytics.Application.Interfaces;
|
||||
using Predictalytics.Domain.Enums;
|
||||
|
||||
namespace Predictalytics.Infrastructure.Providers.Polymarket;
|
||||
|
||||
/// <summary>
|
||||
/// HTTP client for Polymarket Data API.
|
||||
/// All endpoints use https://data-api.polymarket.com
|
||||
/// </summary>
|
||||
public class PolymarketApiClient
|
||||
{
|
||||
private readonly HttpClient _client;
|
||||
private readonly HttpClient _gammaClient;
|
||||
private readonly IRateLimiter _rateLimiter;
|
||||
private readonly ILogger<PolymarketApiClient> _logger;
|
||||
|
||||
private const string DataApiBase = "https://data-api.polymarket.com";
|
||||
private const string GammaApiBase = "https://gamma-api.polymarket.com";
|
||||
|
||||
public PolymarketApiClient(IHttpClientFactory httpFactory, IRateLimiter rateLimiter, ILogger<PolymarketApiClient> 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");
|
||||
|
||||
_rateLimiter = rateLimiter;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<List<PolymarketTradeResponse>> GetTradesAsync(string walletAddress, int limit = 1000, CancellationToken ct = default)
|
||||
{
|
||||
var url = $"/activity?user={walletAddress}&limit={limit}";
|
||||
return await ExecuteWithRetryAsync<List<PolymarketTradeResponse>>(_client, url, ct) ?? [];
|
||||
}
|
||||
|
||||
public async Task<List<PolymarketTradeResponse>> GetMarketTradesAsync(string conditionId, int limit = 1000, CancellationToken ct = default)
|
||||
{
|
||||
var url = $"/trades?condition_id={conditionId}&limit={limit}";
|
||||
return await ExecuteWithRetryAsync<List<PolymarketTradeResponse>>(_client, url, ct) ?? [];
|
||||
}
|
||||
|
||||
public async Task<List<PolymarketPositionResponse>> GetPositionsAsync(string walletAddress, CancellationToken ct = default)
|
||||
{
|
||||
var url = $"/positions?user={walletAddress}&sizeThreshold=0.1&sortBy=CURRENT&sortOrder=DESC";
|
||||
return await ExecuteWithRetryAsync<List<PolymarketPositionResponse>>(_client, url, ct) ?? [];
|
||||
}
|
||||
|
||||
public async Task<GammaMarketResponse?> GetMarketAsync(string conditionId, CancellationToken ct = default)
|
||||
{
|
||||
var url = $"/markets?condition_id={conditionId}";
|
||||
var results = await ExecuteWithRetryAsync<List<GammaMarketResponse>>(_gammaClient, url, ct);
|
||||
return results?.FirstOrDefault();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fetch a batch of markets from the Gamma API with pagination.
|
||||
/// Supports offset-based pagination via the offset parameter.
|
||||
/// </summary>
|
||||
public async Task<List<GammaMarketResponse>> GetMarketsAsync(int limit = 1000, int offset = 0, bool includeClosed = false, CancellationToken ct = default)
|
||||
{
|
||||
var activeOnly = !includeClosed;
|
||||
var url = $"/markets?limit={limit}&offset={offset}&active={activeOnly.ToString().ToLower()}&closed={includeClosed.ToString().ToLower()}";
|
||||
_logger.LogDebug("Fetching markets: {Url}", url);
|
||||
var result = await ExecuteWithRetryAsync<List<GammaMarketResponse>>(_gammaClient, url, ct);
|
||||
_logger.LogInformation("Fetched {Count} markets (offset={Offset}, closed={Closed})", result?.Count ?? 0, offset, includeClosed);
|
||||
return result ?? [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fetch top holders for a specific market (conditionId) from the Data API.
|
||||
/// Returns holders grouped by token (outcome).
|
||||
/// </summary>
|
||||
public async Task<List<HoldersResponse>> 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<List<HoldersResponse>>(_client, url, ct);
|
||||
_logger.LogInformation("Fetched holders for {Market}: {Count} token groups",
|
||||
conditionId.Length > 12 ? conditionId[..12] + "..." : conditionId, result?.Count ?? 0);
|
||||
return result ?? [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get leaderboard from the official Polymarket Data API v1.
|
||||
/// Endpoint: GET https://data-api.polymarket.com/v1/leaderboard
|
||||
/// </summary>
|
||||
public async Task<List<LeaderboardEntry>> 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<List<LeaderboardEntry>>(_client, url, ct);
|
||||
_logger.LogInformation("Leaderboard returned {Count} entries", result?.Count ?? 0);
|
||||
return result ?? [];
|
||||
}
|
||||
|
||||
private async Task<T?> ExecuteWithRetryAsync<T>(HttpClient client, string url, CancellationToken ct, int attempt = 1)
|
||||
{
|
||||
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. Pausing for {WaitTime}s...", (int)waitTime.TotalSeconds);
|
||||
|
||||
_rateLimiter.ReportRateLimitExceeded(PlatformType.Polymarket, waitTime);
|
||||
|
||||
if (attempt < 3)
|
||||
{
|
||||
await _rateLimiter.WaitAsync(PlatformType.Polymarket, ct);
|
||||
_logger.LogWarning("Retrying {Url} (attempt {NextAttempt})...", url, attempt + 1);
|
||||
return await ExecuteWithRetryAsync<T>(client, url, 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<T>(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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user