Files
Predictalytics/src/Predictalytics.Infrastructure/Providers/Polymarket/PolymarketProvider.cs
T

315 lines
14 KiB
C#

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;
}
public async Task<IReadOnlyList<MarketOutcomePriceSnapshot>> 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();
}
}