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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Predictalytics.Infrastructure.Providers.Polymarket;
|
||||
|
||||
/// <summary>
|
||||
/// Converter that handles JSON values that may be either a number or a string.
|
||||
/// Polymarket API is inconsistent — some fields are numbers in one endpoint and strings in another.
|
||||
/// </summary>
|
||||
public class FlexibleDoubleConverter : JsonConverter<double>
|
||||
{
|
||||
public override double Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
||||
{
|
||||
return reader.TokenType switch
|
||||
{
|
||||
JsonTokenType.Number => reader.GetDouble(),
|
||||
JsonTokenType.String => double.TryParse(reader.GetString(), System.Globalization.NumberStyles.Any,
|
||||
System.Globalization.CultureInfo.InvariantCulture, out var v) ? v : 0,
|
||||
JsonTokenType.Null => 0,
|
||||
_ => 0
|
||||
};
|
||||
}
|
||||
|
||||
public override void Write(Utf8JsonWriter writer, double value, JsonSerializerOptions options)
|
||||
=> writer.WriteNumberValue(value);
|
||||
}
|
||||
|
||||
public class FlexibleLongConverter : JsonConverter<long>
|
||||
{
|
||||
public override long Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
||||
{
|
||||
return reader.TokenType switch
|
||||
{
|
||||
JsonTokenType.Number => reader.GetInt64(),
|
||||
JsonTokenType.String => long.TryParse(reader.GetString(), out var v) ? v : 0,
|
||||
_ => 0
|
||||
};
|
||||
}
|
||||
|
||||
public override void Write(Utf8JsonWriter writer, long value, JsonSerializerOptions options)
|
||||
=> writer.WriteNumberValue(value);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// Polymarket Data API response models
|
||||
// ═══════════════════════════════════════════════════════
|
||||
|
||||
public class PolymarketTradeResponse
|
||||
{
|
||||
[JsonPropertyName("conditionId")] public string ConditionId { get; set; } = "";
|
||||
[JsonPropertyName("asset")] public string Asset { get; set; } = "";
|
||||
[JsonPropertyName("side")] public string Side { get; set; } = "";
|
||||
[JsonPropertyName("action")] public string Action { get; set; } = "";
|
||||
[JsonPropertyName("type")] public string Type { get; set; } = "";
|
||||
[JsonPropertyName("user")] public string? User { get; set; }
|
||||
[JsonPropertyName("proxyWallet")] public string? ProxyWallet { get; set; }
|
||||
|
||||
[JsonPropertyName("size")]
|
||||
[JsonConverter(typeof(FlexibleDoubleConverter))]
|
||||
public double Size { get; set; }
|
||||
|
||||
[JsonPropertyName("price")]
|
||||
[JsonConverter(typeof(FlexibleDoubleConverter))]
|
||||
public double Price { get; set; }
|
||||
|
||||
[JsonPropertyName("outcome")] public string Outcome { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("timestamp")]
|
||||
[JsonConverter(typeof(FlexibleLongConverter))]
|
||||
public long Timestamp { get; set; }
|
||||
|
||||
[JsonPropertyName("transactionHash")] public string? TransactionHash { get; set; }
|
||||
}
|
||||
|
||||
public class PolymarketPositionResponse
|
||||
{
|
||||
[JsonPropertyName("asset_id")] public string AssetId { get; set; } = "";
|
||||
[JsonPropertyName("market")] public string Market { get; set; } = "";
|
||||
[JsonPropertyName("outcome")] public string Outcome { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("size")]
|
||||
[JsonConverter(typeof(FlexibleDoubleConverter))]
|
||||
public double Size { get; set; }
|
||||
|
||||
[JsonPropertyName("avgPrice")]
|
||||
[JsonConverter(typeof(FlexibleDoubleConverter))]
|
||||
public double AvgPrice { get; set; }
|
||||
|
||||
[JsonPropertyName("currentValue")]
|
||||
[JsonConverter(typeof(FlexibleDoubleConverter))]
|
||||
public double CurrentValue { get; set; }
|
||||
|
||||
[JsonPropertyName("cashPnl")]
|
||||
[JsonConverter(typeof(FlexibleDoubleConverter))]
|
||||
public double CashPnl { get; set; }
|
||||
|
||||
[JsonPropertyName("percentPnl")]
|
||||
[JsonConverter(typeof(FlexibleDoubleConverter))]
|
||||
public double PercentPnl { get; set; }
|
||||
|
||||
[JsonPropertyName("question")] public string Question { get; set; } = "";
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// Gamma API — Market metadata (full market response)
|
||||
// ═══════════════════════════════════════════════════════
|
||||
|
||||
public class GammaMarketResponse
|
||||
{
|
||||
[JsonPropertyName("id")] public string Id { get; set; } = "";
|
||||
[JsonPropertyName("conditionId")] public string ConditionId { get; set; } = "";
|
||||
[JsonPropertyName("question")] public string Question { get; set; } = "";
|
||||
[JsonPropertyName("slug")] public string Slug { get; set; } = "";
|
||||
[JsonPropertyName("description")] public string? Description { get; set; }
|
||||
[JsonPropertyName("image")] public string? Image { get; set; }
|
||||
[JsonPropertyName("category")] public string Category { get; set; } = "";
|
||||
[JsonPropertyName("groupItemTitle")] public string? GroupItemTitle { get; set; }
|
||||
[JsonPropertyName("events")] public List<GammaEventResponse>? Events { get; set; }
|
||||
|
||||
[JsonPropertyName("volumeNum")]
|
||||
[JsonConverter(typeof(FlexibleDoubleConverter))]
|
||||
public double Volume { get; set; }
|
||||
|
||||
[JsonPropertyName("liquidityNum")]
|
||||
[JsonConverter(typeof(FlexibleDoubleConverter))]
|
||||
public double Liquidity { get; set; }
|
||||
|
||||
[JsonPropertyName("endDateIso")] public string? EndDate { get; set; }
|
||||
[JsonPropertyName("startDate")] public string? StartDate { get; set; }
|
||||
[JsonPropertyName("createdAt")] public string? CreatedAt { get; set; }
|
||||
[JsonPropertyName("closed")] public bool Closed { get; set; }
|
||||
[JsonPropertyName("active")] public bool Active { get; set; }
|
||||
[JsonPropertyName("resolved")] public bool Resolved { get; set; }
|
||||
[JsonPropertyName("resolution_outcome")] public string? ResolutionOutcome { get; set; }
|
||||
|
||||
/// <summary>JSON string of outcomes, e.g. "[\"Yes\", \"No\"]"</summary>
|
||||
[JsonPropertyName("outcomes")] public string? Outcomes { get; set; }
|
||||
|
||||
/// <summary>JSON string of outcome prices, e.g. "[\"0.55\", \"0.45\"]"</summary>
|
||||
[JsonPropertyName("outcomePrices")] public string? OutcomePrices { get; set; }
|
||||
|
||||
/// <summary>JSON string of CLOB token IDs, e.g. "[\"12345...\", \"67890...\"]"</summary>
|
||||
[JsonPropertyName("clobTokenIds")] public string? ClobTokenIds { get; set; }
|
||||
}
|
||||
|
||||
public class GammaEventResponse
|
||||
{
|
||||
[JsonPropertyName("id")] public string Id { get; set; } = "";
|
||||
[JsonPropertyName("slug")] public string Slug { get; set; } = "";
|
||||
[JsonPropertyName("title")] public string Title { get; set; } = "";
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// Data API — Holders response
|
||||
// ═══════════════════════════════════════════════════════
|
||||
|
||||
public class HoldersResponse
|
||||
{
|
||||
[JsonPropertyName("token")] public string Token { get; set; } = "";
|
||||
[JsonPropertyName("holders")] public List<HolderEntry> Holders { get; set; } = [];
|
||||
}
|
||||
|
||||
public class HolderEntry
|
||||
{
|
||||
[JsonPropertyName("proxyWallet")] public string ProxyWallet { get; set; } = "";
|
||||
[JsonPropertyName("name")] public string Name { get; set; } = "";
|
||||
[JsonPropertyName("pseudonym")] public string Pseudonym { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("amount")]
|
||||
[JsonConverter(typeof(FlexibleDoubleConverter))]
|
||||
public double Amount { get; set; }
|
||||
|
||||
[JsonPropertyName("outcomeIndex")] public int OutcomeIndex { get; set; }
|
||||
[JsonPropertyName("profileImage")] public string? ProfileImage { get; set; }
|
||||
[JsonPropertyName("verified")] public bool Verified { get; set; }
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// Polymarket Data API v1 Leaderboard response
|
||||
// ═══════════════════════════════════════════════════════
|
||||
|
||||
public class LeaderboardEntry
|
||||
{
|
||||
[JsonPropertyName("rank")] public string Rank { get; set; } = "";
|
||||
[JsonPropertyName("proxyWallet")] public string ProxyWallet { get; set; } = "";
|
||||
[JsonPropertyName("userName")] public string UserName { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("vol")]
|
||||
[JsonConverter(typeof(FlexibleDoubleConverter))]
|
||||
public double Vol { get; set; }
|
||||
|
||||
[JsonPropertyName("pnl")]
|
||||
[JsonConverter(typeof(FlexibleDoubleConverter))]
|
||||
public double Pnl { get; set; }
|
||||
|
||||
[JsonPropertyName("profileImage")] public string? ProfileImage { get; set; }
|
||||
[JsonPropertyName("xUsername")] public string? XUsername { get; set; }
|
||||
[JsonPropertyName("verifiedBadge")] public bool VerifiedBadge { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
using System.Text.Json;
|
||||
using Predictalytics.Domain.Entities;
|
||||
using Predictalytics.Domain.Enums;
|
||||
using Predictalytics.Domain.Interfaces;
|
||||
using Predictalytics.Infrastructure.Logging;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Predictalytics.Infrastructure.Providers.Polymarket;
|
||||
|
||||
/// <summary>
|
||||
/// Full implementation of IPlatformProvider for Polymarket.
|
||||
/// All log entries include Platform=Polymarket for log-file routing.
|
||||
/// </summary>
|
||||
public class PolymarketProvider : IPlatformProvider
|
||||
{
|
||||
private readonly PolymarketApiClient _api;
|
||||
private readonly ILogger<PolymarketProvider> _logger;
|
||||
|
||||
public PlatformType Platform => PlatformType.Polymarket;
|
||||
public string PlatformName => "Polymarket";
|
||||
public bool IsImplemented => true;
|
||||
|
||||
public PolymarketProvider(PolymarketApiClient api, ILogger<PolymarketProvider> logger)
|
||||
{ _api = api; _logger = logger; }
|
||||
|
||||
public async Task<IReadOnlyList<Trade>> GetTraderTradesAsync(string platformUserId, int limit = 1000, CancellationToken ct = default)
|
||||
{
|
||||
using var _ = PlatformLogContext.Push(PlatformName);
|
||||
_logger.LogDebug("Fetching trades for {Wallet} (limit={Limit})", platformUserId, limit);
|
||||
var raw = await _api.GetTradesAsync(platformUserId, limit, ct);
|
||||
_logger.LogInformation("Fetched {Count} trades for {Wallet}", raw.Count, platformUserId);
|
||||
|
||||
var mappedTrades = raw.Select(r =>
|
||||
{
|
||||
var wallet = r.User ?? r.ProxyWallet ?? "";
|
||||
var side = MapTradeSide(r);
|
||||
var sideStr = side.ToString().ToUpperInvariant();
|
||||
// Compact format: {txHash}_{assetId}_{side} — no wallet in ID to reduce index size.
|
||||
// Wallet passed transiently via TransientWallet [NotMapped] for MarketHistoryWorker.
|
||||
return new Trade
|
||||
{
|
||||
Platform = PlatformType.Polymarket,
|
||||
PlatformTradeId = string.IsNullOrEmpty(r.TransactionHash)
|
||||
? $"{r.Timestamp}_{r.Asset}_{sideStr}"
|
||||
: $"{r.TransactionHash}_{r.Asset}_{sideStr}",
|
||||
MarketId = r.ConditionId ?? "",
|
||||
AssetId = r.Asset ?? "",
|
||||
Outcome = r.Outcome ?? "",
|
||||
Side = side,
|
||||
Price = (decimal)r.Price,
|
||||
Size = (decimal)r.Size,
|
||||
Amount = (decimal)(r.Price * r.Size),
|
||||
ExecutedAt = DateTimeOffset.FromUnixTimeSeconds(r.Timestamp).UtcDateTime,
|
||||
TransactionHash = r.TransactionHash,
|
||||
TraderId = 0,
|
||||
TransientWallet = wallet,
|
||||
};
|
||||
}).ToList();
|
||||
|
||||
return mappedTrades.GroupBy(t => t.PlatformTradeId).Select(g => g.First()).ToList();
|
||||
}
|
||||
|
||||
|
||||
public async Task<IReadOnlyList<Trade>> GetMarketTradesAsync(string platformMarketId, int limit = 1000, CancellationToken ct = default)
|
||||
{
|
||||
var raw = await _api.GetMarketTradesAsync(platformMarketId, limit, ct);
|
||||
_logger.LogInformation("Fetched {Count} trades for Market {Market} (limit={Limit})", raw.Count, platformMarketId, limit);
|
||||
|
||||
var mappedTrades = raw.Select(r =>
|
||||
{
|
||||
var wallet = !string.IsNullOrEmpty(r.User) ? r.User : (r.ProxyWallet ?? "");
|
||||
var side = MapTradeSide(r);
|
||||
var sideStr = side.ToString().ToUpperInvariant();
|
||||
return new Trade
|
||||
{
|
||||
Platform = PlatformType.Polymarket,
|
||||
PlatformTradeId = string.IsNullOrEmpty(r.TransactionHash)
|
||||
? $"{r.Timestamp}_{r.Asset}_{sideStr}"
|
||||
: $"{r.TransactionHash}_{r.Asset}_{sideStr}",
|
||||
MarketId = r.ConditionId ?? "",
|
||||
AssetId = r.Asset ?? "",
|
||||
Outcome = r.Outcome ?? "",
|
||||
Side = side,
|
||||
Price = (decimal)r.Price,
|
||||
Size = (decimal)r.Size,
|
||||
Amount = (decimal)(r.Price * r.Size),
|
||||
ExecutedAt = DateTimeOffset.FromUnixTimeSeconds(r.Timestamp).UtcDateTime,
|
||||
TransactionHash = r.TransactionHash,
|
||||
TraderId = 0,
|
||||
TransientWallet = wallet,
|
||||
};
|
||||
}).ToList();
|
||||
|
||||
return mappedTrades.GroupBy(t => t.PlatformTradeId).Select(g => g.First()).ToList();
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<TraderPositionInfo>> GetTraderPositionsAsync(string platformUserId, CancellationToken ct = default)
|
||||
{
|
||||
using var _ = PlatformLogContext.Push(PlatformName);
|
||||
_logger.LogDebug("Fetching positions for {Wallet}", platformUserId);
|
||||
var raw = await _api.GetPositionsAsync(platformUserId, ct);
|
||||
_logger.LogInformation("Fetched {Count} positions for {Wallet}", raw.Count, platformUserId);
|
||||
|
||||
return raw.Select(r => new TraderPositionInfo(
|
||||
platformUserId, r.Market, r.Question, r.Outcome,
|
||||
(decimal)r.Size, (decimal)r.AvgPrice,
|
||||
(decimal)r.CurrentValue, (decimal)r.PercentPnl
|
||||
)).ToList();
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<DiscoveredTrader>> DiscoverTradersAsync(int limit = 50, CancellationToken ct = default)
|
||||
{
|
||||
using var _ = PlatformLogContext.Push(PlatformName);
|
||||
_logger.LogInformation("Running trader discovery via v1/leaderboard (limit={Limit})...", limit);
|
||||
var leaderboard = await _api.GetLeaderboardAsync(limit, ct: ct);
|
||||
_logger.LogInformation("Discovery returned {Count} traders from leaderboard", leaderboard.Count);
|
||||
|
||||
return leaderboard.Select(e => new DiscoveredTrader(
|
||||
e.ProxyWallet,
|
||||
string.IsNullOrEmpty(e.UserName) ? e.ProxyWallet[..10] + "..." : e.UserName,
|
||||
(decimal)e.Vol,
|
||||
0, // trade count not in leaderboard API
|
||||
0 // win rate computed later from trades
|
||||
)).ToList();
|
||||
}
|
||||
|
||||
public async Task<Market?> GetMarketAsync(string platformMarketId, CancellationToken ct = default)
|
||||
{
|
||||
using var _ = PlatformLogContext.Push(PlatformName);
|
||||
_logger.LogDebug("Fetching market {MarketId}", platformMarketId);
|
||||
var raw = await _api.GetMarketAsync(platformMarketId, ct);
|
||||
if (raw == null)
|
||||
{
|
||||
_logger.LogWarning("Market {MarketId} not found", platformMarketId);
|
||||
return null;
|
||||
}
|
||||
|
||||
_logger.LogInformation("Fetched market: {Question}", raw.Question);
|
||||
return MapGammaMarket(raw);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<Market>> GetMarketsAsync(int limit = 100, string? cursor = null, bool includeClosed = false, CancellationToken ct = default)
|
||||
{
|
||||
using var _ = PlatformLogContext.Push(PlatformName);
|
||||
int offset = 0;
|
||||
if (!string.IsNullOrEmpty(cursor) && int.TryParse(cursor, out var parsed))
|
||||
offset = parsed;
|
||||
|
||||
_logger.LogInformation("Fetching markets batch (limit={Limit}, offset={Offset}, includeClosed={Closed})", limit, offset, includeClosed);
|
||||
var raw = await _api.GetMarketsAsync(limit, offset, includeClosed, ct);
|
||||
_logger.LogInformation("Fetched {Count} markets from Gamma API", raw.Count);
|
||||
|
||||
return raw
|
||||
.Where(m => !string.IsNullOrEmpty(m.ConditionId) && !string.IsNullOrEmpty(m.ClobTokenIds))
|
||||
.Select(MapGammaMarket)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<DiscoveredTrader>> GetTopHoldersAsync(string platformMarketId, int limit = 20, CancellationToken ct = default)
|
||||
{
|
||||
using var _ = PlatformLogContext.Push(PlatformName);
|
||||
_logger.LogInformation("Fetching top holders for market {MarketId}", platformMarketId[..12] + "...");
|
||||
var holdersGroups = await _api.GetHoldersAsync(platformMarketId, limit, ct);
|
||||
|
||||
// Flatten all holders across token groups, deduplicate by wallet
|
||||
var uniqueHolders = holdersGroups
|
||||
.SelectMany(g => g.Holders)
|
||||
.GroupBy(h => h.ProxyWallet)
|
||||
.Select(g =>
|
||||
{
|
||||
var first = g.First();
|
||||
var totalAmount = g.Sum(h => h.Amount);
|
||||
var displayName = !string.IsNullOrEmpty(first.Name) ? first.Name
|
||||
: !string.IsNullOrEmpty(first.Pseudonym) ? first.Pseudonym
|
||||
: first.ProxyWallet[..10] + "...";
|
||||
|
||||
return new DiscoveredTrader(
|
||||
first.ProxyWallet,
|
||||
displayName,
|
||||
(decimal)totalAmount,
|
||||
0, 0
|
||||
);
|
||||
})
|
||||
.OrderByDescending(d => d.Volume24h)
|
||||
.ToList();
|
||||
|
||||
_logger.LogInformation("Discovered {Count} unique holders from market {MarketId}",
|
||||
uniqueHolders.Count, platformMarketId[..12] + "...");
|
||||
|
||||
return uniqueHolders;
|
||||
}
|
||||
|
||||
// ── Private helpers ──────────────────────────────────────────
|
||||
|
||||
private Market MapGammaMarket(GammaMarketResponse raw)
|
||||
{
|
||||
var eventSlug = "";
|
||||
if (raw.Events != null && raw.Events.Count > 0 && !string.IsNullOrEmpty(raw.Events[0].Slug))
|
||||
{
|
||||
eventSlug = raw.Events[0].Slug;
|
||||
}
|
||||
|
||||
var market = new Market
|
||||
{
|
||||
Platform = PlatformType.Polymarket,
|
||||
PlatformMarketId = raw.ConditionId,
|
||||
MarketSlug = raw.Slug,
|
||||
EventSlug = eventSlug,
|
||||
Description = raw.Description,
|
||||
ImageUrl = raw.Image,
|
||||
Question = raw.Question,
|
||||
Category = raw.Category,
|
||||
Volume = (decimal)raw.Volume,
|
||||
Liquidity = (decimal)raw.Liquidity,
|
||||
StartDate = DateTime.TryParse(raw.StartDate, out var sd) ? sd : null,
|
||||
EndDate = DateTime.TryParse(raw.EndDate, out var ed) ? ed : null,
|
||||
CreatedAt = DateTime.TryParse(raw.CreatedAt, out var cd) ? cd : DateTime.UtcNow,
|
||||
DbCreatedAt = DateTime.UtcNow,
|
||||
IsResolved = raw.Resolved || raw.Closed, // Prefer resolved flag
|
||||
ResolutionOutcome = raw.ResolutionOutcome,
|
||||
LastUpdatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
// Parse outcomes, prices, and token IDs from JSON strings
|
||||
var outcomeLabels = ParseJsonStringArray(raw.Outcomes);
|
||||
var outcomePrices = ParseJsonStringArray(raw.OutcomePrices);
|
||||
var tokenIds = ParseJsonStringArray(raw.ClobTokenIds);
|
||||
|
||||
for (int i = 0; i < outcomeLabels.Count; i++)
|
||||
{
|
||||
decimal price = 0;
|
||||
if (i < outcomePrices.Count)
|
||||
decimal.TryParse(outcomePrices[i], System.Globalization.NumberStyles.Any,
|
||||
System.Globalization.CultureInfo.InvariantCulture, out price);
|
||||
|
||||
string tokenId = i < tokenIds.Count ? tokenIds[i] : "";
|
||||
|
||||
var label = outcomeLabels[i];
|
||||
if ((label.Equals("Yes", StringComparison.OrdinalIgnoreCase) || label.Equals("No", StringComparison.OrdinalIgnoreCase))
|
||||
&& !string.IsNullOrEmpty(raw.GroupItemTitle))
|
||||
{
|
||||
label = $"{raw.GroupItemTitle} - {label}";
|
||||
}
|
||||
|
||||
market.Outcomes.Add(new MarketOutcome
|
||||
{
|
||||
Label = label,
|
||||
OutcomeIndex = i,
|
||||
TokenId = tokenId,
|
||||
CurrentPrice = price
|
||||
});
|
||||
}
|
||||
|
||||
return market;
|
||||
}
|
||||
|
||||
private static List<string> ParseJsonStringArray(string? json)
|
||||
{
|
||||
if (string.IsNullOrEmpty(json)) return [];
|
||||
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<List<string>>(json) ?? [];
|
||||
}
|
||||
catch
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private static TradeSide MapTradeSide(PolymarketTradeResponse r)
|
||||
{
|
||||
// Check Action/Type field first for special operations
|
||||
var typeOrAction = !string.IsNullOrEmpty(r.Action) ? r.Action
|
||||
: !string.IsNullOrEmpty(r.Type) ? r.Type : "";
|
||||
|
||||
if (!string.IsNullOrEmpty(typeOrAction))
|
||||
{
|
||||
if (typeOrAction.Equals("SPLIT", StringComparison.OrdinalIgnoreCase)) return TradeSide.Split;
|
||||
if (typeOrAction.Equals("MERGE", StringComparison.OrdinalIgnoreCase)) return TradeSide.Merge;
|
||||
if (typeOrAction.Equals("REDEEM", StringComparison.OrdinalIgnoreCase)) return TradeSide.Redeem;
|
||||
if (typeOrAction.Equals("ADD_LIQUIDITY", StringComparison.OrdinalIgnoreCase)) return TradeSide.AddLiquidity;
|
||||
if (typeOrAction.Equals("REMOVE_LIQUIDITY", StringComparison.OrdinalIgnoreCase)) return TradeSide.RemoveLiquidity;
|
||||
// Type field can also contain BUY/SELL directly
|
||||
if (typeOrAction.Equals("BUY", StringComparison.OrdinalIgnoreCase)) return TradeSide.Buy;
|
||||
if (typeOrAction.Equals("SELL", StringComparison.OrdinalIgnoreCase)) return TradeSide.Sell;
|
||||
}
|
||||
|
||||
// Side field (explicit buy/sell direction)
|
||||
if (!string.IsNullOrEmpty(r.Side))
|
||||
{
|
||||
if (r.Side.Equals("BUY", StringComparison.OrdinalIgnoreCase)) return TradeSide.Buy;
|
||||
if (r.Side.Equals("SELL", StringComparison.OrdinalIgnoreCase)) return TradeSide.Sell;
|
||||
}
|
||||
|
||||
return TradeSide.Unknown;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user