Enhance UI, add AI integration, improve logging and database stats

This commit is contained in:
Richard
2026-07-04 21:11:31 +02:00
parent 7a44914d9d
commit d102af2965
57 changed files with 4025 additions and 229 deletions
@@ -43,39 +43,39 @@ public class PolymarketApiClient
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) ?? [];
return await ExecuteWithRetryAsync<List<PolymarketTradeResponse>>(_client, url, "Data", 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) ?? [];
return await ExecuteWithRetryAsync<List<PolymarketTradeResponse>>(_client, url, "Data", 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) ?? [];
return await ExecuteWithRetryAsync<List<PolymarketPositionResponse>>(_client, url, "Data", 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);
var results = await ExecuteWithRetryAsync<List<GammaMarketResponse>>(_gammaClient, url, "Gamma", ct);
return results?.FirstOrDefault();
}
/// <summary>
/// Fetch a batch of markets from the Gamma API with pagination.
/// Fetch a batch of events (and their nested 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)
public async Task<List<GammaEventResponse>> GetEventsAsync(int limit = 100, 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);
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<List<GammaEventResponse>>(_gammaClient, url, "Gamma", ct);
_logger.LogInformation("Fetched {Count} events (offset={Offset}, closed={Closed})", result?.Count ?? 0, offset, includeClosed);
return result ?? [];
}
@@ -87,7 +87,7 @@ public class PolymarketApiClient
{
var url = $"/holders?market={conditionId}&limit={limit}";
_logger.LogDebug("Fetching holders: {Url}", url);
var result = await ExecuteWithRetryAsync<List<HoldersResponse>>(_client, url, ct);
var result = await ExecuteWithRetryAsync<List<HoldersResponse>>(_client, url, "Data", ct);
_logger.LogInformation("Fetched holders for {Market}: {Count} token groups",
conditionId.Length > 12 ? conditionId[..12] + "..." : conditionId, result?.Count ?? 0);
return result ?? [];
@@ -106,13 +106,15 @@ public class PolymarketApiClient
{
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);
var result = await ExecuteWithRetryAsync<List<LeaderboardEntry>>(_client, url, "Data", 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)
private async Task<T?> ExecuteWithRetryAsync<T>(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);
@@ -135,15 +137,14 @@ public class PolymarketApiClient
waitTime = TimeSpan.FromSeconds(30);
}
_logger.LogWarning("⚠️ Rate limit exceeded (429) for Polymarket. Pausing for {WaitTime}s...", (int)waitTime.TotalSeconds);
_logger.LogWarning("⚠️ Rate limit exceeded (429) for Polymarket {Group}. Pausing for {WaitTime}s...", endpointGroup, (int)waitTime.TotalSeconds);
_rateLimiter.ReportRateLimitExceeded(PlatformType.Polymarket, waitTime);
_rateLimiter.ReportRateLimitExceeded(PlatformType.Polymarket, waitTime, endpointGroup);
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 await ExecuteWithRetryAsync<T>(client, url, endpointGroup, ct, attempt + 1);
}
return default;
@@ -172,7 +173,7 @@ public class PolymarketApiClient
public async Task<List<PriceHistoryEntry>> GetPricesHistoryAsync(string clobTokenId, string interval = "6h", CancellationToken ct = default)
{
var url = $"/prices-history?market={clobTokenId}&interval={interval}";
var result = await ExecuteWithRetryAsync<PolymarketPriceHistoryResponse>(_clobClient, url, ct);
var result = await ExecuteWithRetryAsync<PolymarketPriceHistoryResponse>(_clobClient, url, "Clob", ct);
return result?.History ?? [];
}
}
@@ -109,6 +109,7 @@ public class GammaMarketResponse
{
[JsonPropertyName("id")] public string Id { get; set; } = "";
[JsonPropertyName("conditionId")] public string ConditionId { get; set; } = "";
[JsonPropertyName("questionID")] public string QuestionId { get; set; } = "";
[JsonPropertyName("question")] public string Question { get; set; } = "";
[JsonPropertyName("slug")] public string Slug { get; set; } = "";
[JsonPropertyName("description")] public string? Description { get; set; }
@@ -121,11 +122,16 @@ public class GammaMarketResponse
[JsonConverter(typeof(FlexibleDoubleConverter))]
public double Volume { get; set; }
[JsonPropertyName("volume24hr")]
[JsonConverter(typeof(FlexibleDoubleConverter))]
public double Volume24hr { get; set; }
[JsonPropertyName("liquidityNum")]
[JsonConverter(typeof(FlexibleDoubleConverter))]
public double Liquidity { get; set; }
[JsonPropertyName("endDateIso")] public string? EndDate { get; set; }
[JsonPropertyName("endDateIso")] public string? EndDateIso { get; set; }
[JsonPropertyName("endDate")] 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; }
@@ -136,7 +142,7 @@ public class GammaMarketResponse
/// <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>
/// <summary>JSON string of outcomePrices, 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>
@@ -148,6 +154,22 @@ public class GammaEventResponse
[JsonPropertyName("id")] public string Id { get; set; } = "";
[JsonPropertyName("slug")] public string Slug { get; set; } = "";
[JsonPropertyName("title")] public string Title { get; set; } = "";
[JsonPropertyName("description")] public string? Description { get; set; }
[JsonPropertyName("image")] public string? Image { get; set; }
[JsonPropertyName("startDate")] public string? StartDate { get; set; }
[JsonPropertyName("endDate")] public string? EndDate { get; set; }
[JsonPropertyName("createdAt")] public string? CreatedAt { get; set; }
[JsonPropertyName("active")] public bool Active { get; set; }
[JsonPropertyName("closed")] public bool Closed { get; set; }
[JsonPropertyName("tags")] public List<GammaTagResponse> Tags { get; set; } = [];
[JsonPropertyName("markets")] public List<GammaMarketResponse> Markets { get; set; } = [];
}
public class GammaTagResponse
{
[JsonPropertyName("id")] public string Id { get; set; } = "";
[JsonPropertyName("label")] public string Label { get; set; } = "";
[JsonPropertyName("slug")] public string Slug { get; set; } = "";
}
// ═══════════════════════════════════════════════════════
@@ -32,17 +32,19 @@ public class PolymarketProvider : IPlatformProvider
var mappedTrades = raw.Select(r =>
{
var wallet = r.User ?? r.ProxyWallet ?? "";
var wallet = !string.IsNullOrEmpty(r.User) ? r.User :
!string.IsNullOrEmpty(r.ProxyWallet) ? r.ProxyWallet :
platformUserId;
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.
// Format: {txHash}_{wallet}_{assetId}_{side}
// Wallet must be included to avoid cross-user collisions in the global IX_Trades_Platform_PlatformTradeId index.
return new Trade
{
Platform = PlatformType.Polymarket,
PlatformTradeId = string.IsNullOrEmpty(r.TransactionHash)
? $"{r.Timestamp}_{r.Asset}_{sideStr}"
: $"{r.TransactionHash}_{r.Asset}_{sideStr}",
? $"{r.Timestamp}_{wallet}_{r.Asset}_{sideStr}"
: $"{r.TransactionHash.ToLowerInvariant()}_{wallet}_{r.Asset}_{sideStr}",
MarketId = r.ConditionId ?? "",
AssetId = r.Asset ?? "",
Outcome = r.Outcome ?? "",
@@ -51,13 +53,13 @@ public class PolymarketProvider : IPlatformProvider
Size = (decimal)r.Size,
Amount = (decimal)(r.Price * r.Size),
ExecutedAt = DateTimeOffset.FromUnixTimeSeconds(r.Timestamp).UtcDateTime,
TransactionHash = r.TransactionHash,
TransactionHash = r.TransactionHash?.ToLowerInvariant(),
TraderId = 0,
TransientWallet = wallet,
};
}).ToList();
return mappedTrades.GroupBy(t => t.PlatformTradeId).Select(g => g.First()).ToList();
return mappedTrades.GroupBy(t => t.PlatformTradeId, StringComparer.OrdinalIgnoreCase).Select(g => g.First()).ToList();
}
@@ -75,8 +77,8 @@ public class PolymarketProvider : IPlatformProvider
{
Platform = PlatformType.Polymarket,
PlatformTradeId = string.IsNullOrEmpty(r.TransactionHash)
? $"{r.Timestamp}_{r.Asset}_{sideStr}"
: $"{r.TransactionHash}_{r.Asset}_{sideStr}",
? $"{r.Timestamp}_{wallet}_{r.Asset}_{sideStr}"
: $"{r.TransactionHash.ToLowerInvariant()}_{wallet}_{r.Asset}_{sideStr}",
MarketId = r.ConditionId ?? "",
AssetId = r.Asset ?? "",
Outcome = r.Outcome ?? "",
@@ -127,33 +129,76 @@ public class PolymarketProvider : IPlatformProvider
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)
if (raw == null || string.IsNullOrEmpty(raw.ConditionId)) return null;
var parentTags = "";
if (raw.Events != null && raw.Events.Count > 0)
{
_logger.LogWarning("Market {MarketId} not found", platformMarketId);
return null;
var ev = raw.Events[0];
parentTags = ev.Tags != null ? string.Join(", ", ev.Tags.Select(t => t.Label)) : "";
}
var market = MapGammaMarket(raw, parentTags);
// Map the parent Event if available in the Market response
if (raw.Events != null && raw.Events.Count > 0)
{
var rawEv = raw.Events[0];
long.TryParse(rawEv.Id, out var numericEventId);
market.Event = new Event
{
Platform = PlatformType.Polymarket,
PlatformEventId = numericEventId,
Slug = rawEv.Slug,
Title = rawEv.Title,
Description = rawEv.Description,
ImageUrl = rawEv.Image,
StartDate = DateTime.TryParse(rawEv.StartDate, out var esd) ? esd : null,
EndDate = DateTime.TryParse(rawEv.EndDate, out var eed) ? eed : null,
CreatedAt = DateTime.TryParse(rawEv.CreatedAt, out var ecd) ? ecd : DateTime.UtcNow,
DbCreatedAt = DateTime.UtcNow,
IsActive = rawEv.Active,
IsClosed = rawEv.Closed,
Tags = rawEv.Tags != null && rawEv.Tags.Count > 0 ? string.Join(", ", rawEv.Tags.Select(t => t.Label)) : string.Empty,
LastUpdatedAt = DateTime.UtcNow
};
}
else
{
// Fallback empty event if missing (should rarely happen for valid Polymarket markets)
market.Event = new Event
{
Platform = PlatformType.Polymarket,
Slug = "unknown-" + market.ConditionId,
Title = "Unknown Event",
DbCreatedAt = DateTime.UtcNow,
LastUpdatedAt = DateTime.UtcNow
};
}
_logger.LogInformation("Fetched market: {Question}", raw.Question);
return MapGammaMarket(raw);
return market;
}
public async Task<IReadOnlyList<Market>> GetMarketsAsync(int limit = 100, string? cursor = null, bool includeClosed = false, CancellationToken ct = default)
public async Task<IReadOnlyList<Event>> GetEventsAsync(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);
_logger.LogInformation("Fetching events batch (limit={Limit}, offset={Offset}, includeClosed={Closed})", limit, offset, includeClosed);
var rawEvents = await _api.GetEventsAsync(limit, offset, includeClosed, ct);
_logger.LogInformation("Fetched {Count} events from Gamma API", rawEvents.Count);
return raw
.Where(m => !string.IsNullOrEmpty(m.ConditionId) && !string.IsNullOrEmpty(m.ClobTokenIds))
.Select(MapGammaMarket)
.ToList();
var events = new List<Event>();
foreach (var rawEvent in rawEvents)
{
var ev = MapGammaEvent(rawEvent);
events.Add(ev);
}
return events;
}
public async Task<IReadOnlyList<DiscoveredTrader>> GetTopHoldersAsync(string platformMarketId, int limit = 20, CancellationToken ct = default)
@@ -192,31 +237,67 @@ public class PolymarketProvider : IPlatformProvider
// ── Private helpers ──────────────────────────────────────────
private Market MapGammaMarket(GammaMarketResponse raw)
private Event MapGammaEvent(GammaEventResponse rawEvent)
{
var eventSlug = "";
if (raw.Events != null && raw.Events.Count > 0 && !string.IsNullOrEmpty(raw.Events[0].Slug))
long.TryParse(rawEvent.Id, out var numericId);
var ev = new Event
{
eventSlug = raw.Events[0].Slug;
Platform = PlatformType.Polymarket,
PlatformEventId = numericId,
Slug = rawEvent.Slug,
Title = rawEvent.Title,
Description = rawEvent.Description,
ImageUrl = rawEvent.Image,
StartDate = DateTime.TryParse(rawEvent.StartDate, out var sd) ? sd : null,
EndDate = DateTime.TryParse(rawEvent.EndDate, out var ed) ? ed : null,
CreatedAt = DateTime.TryParse(rawEvent.CreatedAt, out var cd) ? cd : DateTime.UtcNow,
DbCreatedAt = DateTime.UtcNow,
IsActive = rawEvent.Active,
IsClosed = rawEvent.Closed,
Tags = rawEvent.Tags != null && rawEvent.Tags.Count > 0
? string.Join(", ", rawEvent.Tags.Select(t => t.Label))
: string.Empty,
LastUpdatedAt = DateTime.UtcNow
};
if (rawEvent.Markets != null)
{
foreach (var rawMarket in rawEvent.Markets)
{
if (string.IsNullOrEmpty(rawMarket.ConditionId) || string.IsNullOrEmpty(rawMarket.ClobTokenIds)) continue;
var market = MapGammaMarket(rawMarket, ev.Tags ?? "");
ev.Markets.Add(market);
}
}
return ev;
}
private Market MapGammaMarket(GammaMarketResponse raw, string parentTags = "")
{
long.TryParse(raw.Id, out var marketNumericId);
var market = new Market
{
Platform = PlatformType.Polymarket,
PlatformMarketId = raw.ConditionId,
PlatformMarketId = marketNumericId,
ConditionId = raw.ConditionId,
QuestionId = raw.QuestionId,
MarketSlug = raw.Slug,
EventSlug = eventSlug,
Description = raw.Description,
ImageUrl = raw.Image,
Question = raw.Question,
Category = raw.Category,
Category = string.IsNullOrWhiteSpace(raw.Category) ? parentTags : raw.Category,
Volume = (decimal)raw.Volume,
Volume24h = (decimal)raw.Volume24hr,
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,
StartDate = DateTime.TryParse(raw.StartDate, out var msd) ? msd : null,
EndDate = DateTime.TryParse(raw.EndDate ?? raw.EndDateIso, out var med) ? med : null,
CreatedAt = DateTime.TryParse(raw.CreatedAt, out var mcd) ? mcd : DateTime.UtcNow,
DbCreatedAt = DateTime.UtcNow,
IsResolved = raw.Resolved || raw.Closed, // Prefer resolved flag
IsResolved = raw.Resolved || raw.Closed,
ResolutionOutcome = raw.ResolutionOutcome,
LastUpdatedAt = DateTime.UtcNow
};