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; /// /// Full implementation of IPlatformProvider for Polymarket. /// All log entries include Platform=Polymarket for log-file routing. /// public class PolymarketProvider : IPlatformProvider { private readonly PolymarketApiClient _api; private readonly ILogger _logger; public PlatformType Platform => PlatformType.Polymarket; public string PlatformName => "Polymarket"; public bool IsImplemented => true; public PolymarketProvider(PolymarketApiClient api, ILogger logger) { _api = api; _logger = logger; } public async Task> 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 = !string.IsNullOrEmpty(r.User) ? r.User : !string.IsNullOrEmpty(r.ProxyWallet) ? r.ProxyWallet : platformUserId; var side = MapTradeSide(r); var sideStr = side.ToString().ToUpperInvariant(); // 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}_{wallet}_{r.Asset}_{sideStr}" : $"{r.TransactionHash.ToLowerInvariant()}_{wallet}_{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?.ToLowerInvariant(), TraderId = 0, TransientWallet = wallet, TransientDisplayName = !string.IsNullOrEmpty(r.Name) ? r.Name : r.Pseudonym }; }).ToList(); return mappedTrades.GroupBy(t => t.PlatformTradeId, StringComparer.OrdinalIgnoreCase).Select(g => g.First()).ToList(); } public async Task> GetTradesPagedAsync(string platformUserId, int limitPerRequest = 500, CancellationToken ct = default) { using var _ = PlatformLogContext.Push(PlatformName); _logger.LogDebug("Fetching all paged trades for {Wallet}", platformUserId); var raw = await _api.GetTradesPagedAsync(platformUserId, limitPerRequest, ct); _logger.LogInformation("Fetched {Count} trades total for {Wallet}", raw.Count, platformUserId); var mappedTrades = raw.Select(r => { var wallet = !string.IsNullOrEmpty(r.User) ? r.User : !string.IsNullOrEmpty(r.ProxyWallet) ? r.ProxyWallet : platformUserId; var side = MapTradeSide(r); var sideStr = side.ToString().ToUpperInvariant(); return new Trade { Platform = PlatformType.Polymarket, PlatformTradeId = string.IsNullOrEmpty(r.TransactionHash) ? $"{r.Timestamp}_{wallet}_{r.Asset}_{sideStr}" : $"{r.TransactionHash.ToLowerInvariant()}_{wallet}_{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?.ToLowerInvariant(), TraderId = 0, TransientWallet = wallet, TransientDisplayName = !string.IsNullOrEmpty(r.Name) ? r.Name : r.Pseudonym }; }).ToList(); return mappedTrades.GroupBy(t => t.PlatformTradeId, StringComparer.OrdinalIgnoreCase).Select(g => g.First()).ToList(); } public async Task> GetMarketTradesAsync(string platformMarketId, int limit = 1000, CancellationToken ct = default) { var raw = await _api.GetMarketTradesAsync(platformMarketId, limit, 0, 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}_{wallet}_{r.Asset}_{sideStr}" : $"{r.TransactionHash.ToLowerInvariant()}_{wallet}_{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, TransientDisplayName = !string.IsNullOrEmpty(r.Name) ? r.Name : r.Pseudonym }; }).ToList(); return mappedTrades.GroupBy(t => t.PlatformTradeId).Select(g => g.First()).ToList(); } public async Task> 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, r.AssetId )).ToList(); } public async Task> 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 GetMarketAsync(string platformMarketId, CancellationToken ct = default) { using var _ = PlatformLogContext.Push(PlatformName); var raw = await _api.GetMarketAsync(platformMarketId, ct); if (raw == null || string.IsNullOrEmpty(raw.ConditionId)) return null; var parentTags = ""; if (raw.Events != null && raw.Events.Count > 0) { 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 market; } public async Task> 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 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); var events = new List(); foreach (var rawEvent in rawEvents) { var ev = MapGammaEvent(rawEvent); events.Add(ev); } return events; } public async Task> 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 Event MapGammaEvent(GammaEventResponse rawEvent) { long.TryParse(rawEvent.Id, out var numericId); var ev = new Event { 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 catMap = Predictalytics.Infrastructure.Helpers.MarketCategoryMapper.Map(raw.Category ?? "", parentTags, raw.Question ?? ""); var market = new Market { Platform = PlatformType.Polymarket, PlatformMarketId = marketNumericId, ConditionId = raw.ConditionId, QuestionId = raw.QuestionId, MarketSlug = raw.Slug, Description = raw.Description, ImageUrl = raw.Image, Question = raw.Question, Category = catMap.Category, Subcategory = catMap.Subcategory, Volume = (decimal)raw.Volume, Volume24h = (decimal)raw.Volume24hr, Liquidity = (decimal)raw.Liquidity, 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, LastUpdatedAt = DateTime.UtcNow, FeeRateBps = (decimal)(raw.TakerFee * 10000), IsNegRisk = raw.NegRisk, ClosedAt = DateTime.TryParse(raw.ClosedTime, out var mct) ? mct : null }; // Parse outcomes, prices, and token IDs from JSON strings var outcomeLabels = ParseJsonStringArray(raw.Outcomes); var outcomePrices = ParseJsonStringArray(raw.OutcomePrices); var tokenIds = ParseJsonStringArray(raw.ClobTokenIds); bool pricesSnapped = false; bool hasHighPrice = false; bool allSnapped = outcomePrices.Count > 0; string? snappedWinnerLabel = null; 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); var label = outcomeLabels[i]; if ((label.Equals("Yes", StringComparison.OrdinalIgnoreCase) || label.Equals("No", StringComparison.OrdinalIgnoreCase)) && !string.IsNullOrEmpty(raw.GroupItemTitle)) { label = $"{raw.GroupItemTitle} - {label}"; } if (price <= 0.02m || price >= 0.98m) { if (price >= 0.98m) { hasHighPrice = true; snappedWinnerLabel = label; } } else { allSnapped = false; } string tokenId = i < tokenIds.Count ? tokenIds[i] : ""; market.Outcomes.Add(new MarketOutcome { Label = label, OutcomeIndex = i, TokenId = tokenId, CurrentPrice = price }); } pricesSnapped = allSnapped && hasHighPrice; market.IsResolved = raw.UmaResolutionStatus == "resolved" || (raw.Closed && pricesSnapped); market.ResolutionOutcome = market.IsResolved ? snappedWinnerLabel : null; return market; } private static List ParseJsonStringArray(string? json) { if (string.IsNullOrEmpty(json)) return []; try { return JsonSerializer.Deserialize>(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; } public async Task> GetPriceHistoryAsync(string tokenId, CancellationToken ct = default) { using var _ = PlatformLogContext.Push(PlatformName); _logger.LogDebug("Fetching price history for CLOB Token {TokenId}", tokenId); var rawHistory = await _api.GetPricesHistoryAsync(tokenId, "6h", ct); _logger.LogInformation("Fetched {Count} price history entries for {TokenId}", rawHistory.Count, tokenId); return rawHistory.Select(r => new MarketOutcomePriceSnapshot { Price = (decimal)r.Price, Timestamp = DateTimeOffset.FromUnixTimeSeconds(r.Timestamp).UtcDateTime, MarketOutcomeId = 0 }).ToList(); } }