Files
PolyTraderSharp/services/PolymarketApiService.cs
T
bergmandClaude Opus 4.8 475d396f80 Baseline: Ausgangszustand vor Modularisierung
Erster Commit des bestehenden monolithischen WinForms-Copytraders,
inklusive der Alt-Backups (*.bak), damit diese dauerhaft in der
Historie rekonstruierbar bleiben. Threema-Lib unter libs/ wurde
vendored (nested .git entfernt).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 13:16:16 +02:00

944 lines
47 KiB
C#

using System;
using MongoDB.Driver;
using PolyTraderSharp.Extensions;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Net.Http;
using System.Text.Json;
using System.Threading.Tasks;
namespace PolyTraderSharp.Services
{
public class PolymarketApiService
{
private readonly TerminalLogger _logger;
private readonly HttpClient _httpClient;
private readonly string _dataHost = "https://data-api.polymarket.com";
private readonly string _clobHost = "https://clob.polymarket.com";
// Per-endpoint rate tracking (10-second windows matching Polymarket limits)
// Data API has per-endpoint limits that are stricter than the general 1000/10s
private readonly ConcurrentQueue<DateTime> _dataActivityTimestamps = new(); // /activity → General 1000/10s
private readonly ConcurrentQueue<DateTime> _dataPositionsTimestamps = new(); // /positions → 150/10s
private readonly ConcurrentQueue<DateTime> _gammaApiTimestamps = new(); // /events → 500/10s
private readonly ConcurrentQueue<DateTime> _clobApiTimestamps = new(); // General 9000/10s
private long _lastPingMs = 0;
// Polymarket documented rate limits per 10 seconds (per endpoint we use)
public static readonly Dictionary<string, int> RateLimits = new()
{
{ "Activity", 1000 }, // Data API /activity (General limit, no specific)
{ "Positions", 150 }, // Data API /positions (specific endpoint limit!)
{ "Gamma", 500 }, // Gamma API /events (specific endpoint limit)
{ "CLOB", 9000 } // CLOB API General
};
public PolymarketApiService(TerminalLogger logger, HttpClient httpClient)
{
_logger = logger;
_httpClient = httpClient;
_httpClient.DefaultRequestHeaders.Add("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36");
_httpClient.Timeout = TimeSpan.FromSeconds(30);
}
public long GetLastPing() => _lastPingMs;
/// <summary>
/// Returns per-endpoint request counts in the last 10 seconds.
/// Keys match the RateLimits dictionary.
/// </summary>
public Dictionary<string, int> GetRateLimitsPerTenSeconds()
{
var cutoff = DateTime.UtcNow.AddSeconds(-10);
return new Dictionary<string, int>
{
{ "Activity", CountRecent(_dataActivityTimestamps, cutoff) },
{ "Positions", CountRecent(_dataPositionsTimestamps, cutoff) },
{ "Gamma", CountRecent(_gammaApiTimestamps, cutoff) },
{ "CLOB", CountRecent(_clobApiTimestamps, cutoff) }
};
}
private static int CountRecent(ConcurrentQueue<DateTime> queue, DateTime cutoff)
{
int count = 0;
foreach (var dt in queue.ToArray())
{
if (dt >= cutoff) count++;
}
return count;
}
private void TrackRequest(string apiType)
{
var queue = apiType switch
{
"Activity" => _dataActivityTimestamps,
"Positions" => _dataPositionsTimestamps,
"Gamma" => _gammaApiTimestamps,
"CLOB" => _clobApiTimestamps,
_ => _clobApiTimestamps
};
queue.Enqueue(DateTime.UtcNow);
while (queue.TryPeek(out DateTime oldest) && oldest < DateTime.UtcNow.AddSeconds(-10))
{
queue.TryDequeue(out _);
}
}
public async Task<int> MeasurePingAsync()
{
try
{
TrackRequest("CLOB");
var sw = Stopwatch.StartNew();
using var response = await _httpClient.GetAsync($"{_clobHost}/time");
sw.Stop();
if (response.IsSuccessStatusCode)
{
_lastPingMs = sw.ElapsedMilliseconds;
return (int)_lastPingMs;
}
}
catch { }
return -1;
}
private async Task<HttpResponseMessage> GetWithRetryAsync(string url)
{
int maxRetries = 3;
for (int i = 0; i < maxRetries; i++)
{
try
{
var response = await _httpClient.GetAsync(url);
if ((int)response.StatusCode == 429) // Rate limit
{
var delay = Math.Pow(2, i + 1);
_logger.Warning($"API Rate-Limit (429) auf {url}. Retry in {delay}s...");
await Task.Delay(TimeSpan.FromSeconds(delay));
continue;
}
return response;
}
catch (TaskCanceledException)
{
if (i == maxRetries - 1) throw;
var delay = Math.Pow(2, i + 1);
_logger.Warning($"API Timeout auf {url}. Retry in {delay}s...");
await Task.Delay(TimeSpan.FromSeconds(delay));
}
catch (HttpRequestException)
{
if (i == maxRetries - 1) throw;
var delay = Math.Pow(2, i + 1);
_logger.Warning($"Netzwerkfehler auf {url}. Retry in {delay}s...");
await Task.Delay(TimeSpan.FromSeconds(delay));
}
}
return await _httpClient.GetAsync(url); //Fallback
}
/// <summary>
/// Fetches the recent trading activity for a given wallet address.
/// </summary>
public async Task<List<JsonElement>> GetTraderActivityAsync(string walletAddress, int limit = 50)
{
TrackRequest("Activity");
try
{
long cb = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
string url = $"{_dataHost}/activity?limit={limit}&user={walletAddress}&type=TRADE&_cb={cb}";
using var response = await GetWithRetryAsync(url);
if (!response.IsSuccessStatusCode)
{
_logger.Warning($"API returned {response.StatusCode} for {walletAddress}");
return new List<JsonElement>();
}
var jsonStr = await response.Content.ReadAsStringAsync();
using var document = JsonDocument.Parse(jsonStr);
var list = new List<JsonElement>();
if (document.RootElement.ValueKind == JsonValueKind.Array)
{
foreach (var element in document.RootElement.EnumerateArray())
{
list.Add(element.Clone());
}
}
return list;
}
catch (Exception ex)
{
_logger.Error($"Failed to fetch activity for {walletAddress}: {ex.Message}");
return new List<JsonElement>();
}
}
/// <summary>
/// Fetches the current best price from the CLOB orderbook for a given token.
/// For SELL: returns the best bid (highest buy offer).
/// For BUY: returns the best ask (lowest sell offer).
/// </summary>
public async Task<decimal?> GetOrderBookPriceAsync(string tokenId, string side = "SELL")
{
TrackRequest("CLOB");
try
{
string url = $"{_clobHost}/book?token_id={tokenId}";
using var response = await GetWithRetryAsync(url);
if (!response.IsSuccessStatusCode)
{
_logger.Warning($"Orderbook request failed: {response.StatusCode}");
return null;
}
var jsonStr = await response.Content.ReadAsStringAsync();
using var doc = JsonDocument.Parse(jsonStr);
// For SELL we want the best bid (buyer's highest price)
// For BUY we want the best ask (seller's lowest price)
string bookSide = side.ToUpper() == "SELL" ? "bids" : "asks";
if (doc.RootElement.TryGetProperty(bookSide, out var orders) &&
orders.ValueKind == JsonValueKind.Array && orders.GetArrayLength() > 0)
{
var priceList = new List<decimal>();
foreach (var order in orders.EnumerateArray())
{
if (order.TryGetProperty("price", out var priceProp))
{
string priceStr = priceProp.GetString() ?? "";
if (decimal.TryParse(priceStr, System.Globalization.NumberStyles.Any,
System.Globalization.CultureInfo.InvariantCulture, out decimal p))
{
priceList.Add(p);
}
}
}
if (priceList.Count > 0)
{
// Seller wants the highest bid. Buyer wants the lowest ask.
if (side.ToUpper() == "SELL") return priceList.Max();
else return priceList.Min();
}
}
_logger.Warning($"Orderbook leer oder kein Preis gefunden für Token {tokenId}");
return null;
}
catch (Exception ex)
{
_logger.Error($"GetOrderBookPriceAsync Fehler: {ex.Message}");
return null;
}
}
public async Task<decimal> GetUsdcBalanceAsync(string walletAddress)
{
if (string.IsNullOrEmpty(walletAddress)) return 0;
decimal totalBalance = 0;
try
{
string addressObj = walletAddress.Replace("0x", "").PadLeft(64, '0');
string data = "0x70a08231" + addressObj;
string[] rpcs = { "https://polygon-rpc.com", "https://polygon.llamarpc.com", "https://rpc.ankr.com/polygon" };
string[] contracts = { "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174", "0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359" };
foreach (var usdcContract in contracts)
{
bool success = false;
foreach (var rpcUrl in rpcs)
{
var payload = new
{
jsonrpc = "2.0",
method = "eth_call",
@params = new object[]
{
new { to = usdcContract, data },
"latest"
},
id = 1
};
try
{
var content = new StringContent(JsonSerializer.Serialize(payload), System.Text.Encoding.UTF8, "application/json");
using var response = await _httpClient.PostAsync(rpcUrl, content);
if (response.IsSuccessStatusCode)
{
var json = await response.Content.ReadAsStringAsync();
using var doc = JsonDocument.Parse(json);
if (doc.RootElement.TryGetProperty("result", out var res) && res.ValueKind == JsonValueKind.String)
{
string hexBal = res.GetString() ?? "0x0";
if (hexBal.StartsWith("0x")) hexBal = hexBal.Substring(2);
if (!string.IsNullOrEmpty(hexBal))
{
long rawBalance = Convert.ToInt64(hexBal, 16);
decimal pVal = (decimal)rawBalance / 1_000_000m;
totalBalance += pVal;
if (pVal > 0) _logger.Info($"🌐 [{walletAddress.Substring(0, 6)}...] Balance gefunden: ${pVal:F2} auf Contract {usdcContract}");
}
}
success = true;
break;
}
}
catch (Exception exInner) { _logger.Error($"USDC Balance RPC Exception on {rpcUrl}: {exInner.Message}"); }
}
if (!success) _logger.Warning($"Fehler beim Abruf von USDC Token {usdcContract}");
}
}
catch (Exception ex)
{
_logger.Error($"USDC Balance fetch failed for {walletAddress}: {ex.Message}");
}
return totalBalance;
}
public async Task<(bool isResolved, bool isWinner)> CheckMarketResolutionAsync(string slug, string tokenId)
{
if (string.IsNullOrEmpty(tokenId)) return (false, false);
try
{
TrackRequest("Gamma");
using var response = await GetWithRetryAsync($"https://gamma-api.polymarket.com/markets?clob_token_ids={tokenId}");
if (response.IsSuccessStatusCode)
{
var jsonStr = await response.Content.ReadAsStringAsync();
using var doc = JsonDocument.Parse(jsonStr);
if (doc.RootElement.ValueKind == JsonValueKind.Array && doc.RootElement.GetArrayLength() > 0)
{
var mkt = doc.RootElement[0];
bool mktClosed = mkt.TryGetProperty("closed", out var mc) && mc.GetBoolean();
if (!mktClosed) return (false, false);
if (mkt.TryGetProperty("clobTokenIds", out var cIdsStr) && mkt.TryGetProperty("outcomePrices", out var pricesStr))
{
using var cDoc = JsonDocument.Parse(cIdsStr.GetString() ?? "[]");
using var pDoc = JsonDocument.Parse(pricesStr.GetString() ?? "[]");
var ids = cDoc.RootElement.EnumerateArray().ToList();
var prices = pDoc.RootElement.EnumerateArray().ToList();
for (int i = 0; i < ids.Count; i++)
{
if (ids[i].GetString() == tokenId)
{
if (i < prices.Count)
{
if (decimal.TryParse(prices[i].GetString(), System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out var px) && px >= 0.99m)
return (true, true);
else
return (true, false);
}
}
}
}
}
}
}
catch (Exception ex)
{
_logger.Error($"Error checking market resolution for token {tokenId}: {ex.Message}");
}
return (false, false);
}
public async Task<List<JsonElement>?> SyncOpenPositionsAsync(string walletAddress)
{
if (string.IsNullOrEmpty(walletAddress)) return new List<JsonElement>();
try
{
var allPositions = new List<JsonElement>();
int limit = 500;
int offset = 0;
while (true)
{
TrackRequest("Positions");
using var response = await GetWithRetryAsync($"https://data-api.polymarket.com/positions?user={walletAddress}&limit={limit}&offset={offset}");
if (response.IsSuccessStatusCode)
{
var json = await response.Content.ReadAsStringAsync();
using var doc = JsonDocument.Parse(json);
if (doc.RootElement.ValueKind == JsonValueKind.Array)
{
int count = 0;
foreach (var el in doc.RootElement.EnumerateArray())
{
allPositions.Add(el.Clone());
count++;
}
if (count < limit) break; // Reached the end
offset += limit;
}
else
{
break;
}
}
else
{
_logger.Error($"Failed to fetch open positions (HTTP {(int)response.StatusCode}): {response.ReasonPhrase}");
break;
}
}
return allPositions.Count > 0 ? allPositions : null;
}
catch (Exception ex)
{
_logger.Error($"Failed to fetch open positions for {walletAddress}: {ex.Message}");
}
return null;
}
/// <summary>
/// Fetches the current position sizes a master trader holds for a set of token IDs.
/// Returns a Dictionary mapping TokenId -> Shares held. Only includes tokens with size > 0.
/// </summary>
public async Task<Dictionary<string, decimal>> GetTraderPositionSizesAsync(string walletAddress, HashSet<string> relevantTokenIds)
{
var result = new Dictionary<string, decimal>();
if (string.IsNullOrEmpty(walletAddress) || relevantTokenIds.Count == 0) return result;
try
{
int limit = 500;
int offset = 0;
while (true)
{
TrackRequest("Positions");
using var response = await GetWithRetryAsync($"https://data-api.polymarket.com/positions?user={walletAddress}&limit={limit}&offset={offset}&sizeThreshold=0.1");
if (!response.IsSuccessStatusCode) break;
var json = await response.Content.ReadAsStringAsync();
using var doc = JsonDocument.Parse(json);
if (doc.RootElement.ValueKind != JsonValueKind.Array) break;
int count = 0;
foreach (var el in doc.RootElement.EnumerateArray())
{
count++;
string asset = el.TryGetProperty("asset", out var ap) ? ap.GetString() ?? "" : "";
if (!string.IsNullOrEmpty(asset) && relevantTokenIds.Contains(asset))
{
decimal size = 0;
if (el.TryGetProperty("size", out var sp))
{
if (sp.ValueKind == JsonValueKind.Number) size = sp.GetDecimal();
else if (sp.ValueKind == JsonValueKind.String) decimal.TryParse(sp.GetString(), System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out size);
}
if (size > 0) result[asset] = size;
}
}
if (count < limit) break; // Reached end
offset += limit;
if (offset > 5000) break; // Safety cap
}
}
catch (Exception ex)
{
_logger.Error($"Failed to fetch trader positions for {walletAddress}: {ex.Message}");
}
return result;
}
public async Task<List<JsonElement>> SyncClosedPositionsAsync(string walletAddress, int limit = 100)
{
if (string.IsNullOrEmpty(walletAddress)) return new List<JsonElement>();
try
{
TrackRequest("Positions");
using var response = await GetWithRetryAsync($"https://data-api.polymarket.com/closed-positions?user={walletAddress}&limit={limit}&sortBy=TIMESTAMP&sortDirection=DESC");
if (response.IsSuccessStatusCode)
{
var json = await response.Content.ReadAsStringAsync();
using var doc = JsonDocument.Parse(json);
var list = new List<JsonElement>();
if (doc.RootElement.ValueKind == JsonValueKind.Array)
{
foreach (var el in doc.RootElement.EnumerateArray())
list.Add(el.Clone());
}
return list;
}
else
{
_logger.Error($"Failed to fetch closed positions (HTTP {(int)response.StatusCode}): {response.ReasonPhrase}");
}
}
catch (Exception ex)
{
_logger.Error($"Failed to fetch closed positions for {walletAddress}: {ex.Message}");
}
return new List<JsonElement>();
}
/// <summary>
/// Future placeholder for Live trading (Requires CLOB credentials context).
/// </summary>
public async Task<bool> PlaceOrderAsync(int accountId, string tokenId, decimal price, decimal size, string side)
{
TrackRequest("CLOB");
_logger.Info($"Placing {side} order on Account {accountId} for Token {tokenId}. Size: {size} @ {price}");
await Task.Delay(100);
return true;
}
public async Task<List<PolyTraderSharp.Models.MarketData>> GetRecentMarketsAsync(int limit = 1000)
{
TrackRequest("Gamma");
var results = new List<PolyTraderSharp.Models.MarketData>();
try
{
string url = $"https://gamma-api.polymarket.com/markets?limit={limit}&order=id&ascending=false";
using var response = await GetWithRetryAsync(url);
if (response.IsSuccessStatusCode)
{
var jsonStr = await response.Content.ReadAsStringAsync();
using var doc = JsonDocument.Parse(jsonStr);
if (doc.RootElement.ValueKind == JsonValueKind.Array)
{
foreach (var mkt in doc.RootElement.EnumerateArray())
{
var md = new PolyTraderSharp.Models.MarketData();
md.Id = mkt.TryGetProperty("id", out var p1) ? p1.GetString() ?? "" : "";
md.ConditionId = mkt.TryGetProperty("conditionId", out var p2) ? p2.GetString() ?? "" : "";
md.Question = mkt.TryGetProperty("question", out var p3) ? p3.GetString() ?? "" : "";
md.Active = mkt.TryGetProperty("active", out var p5) && p5.GetBoolean();
md.Closed = mkt.TryGetProperty("closed", out var p6) && p6.GetBoolean();
md.ClobTokenIds = mkt.TryGetProperty("clobTokenIds", out var p7) ? p7.GetString() ?? "" : "";
md.Slug = mkt.TryGetProperty("slug", out var p4) ? p4.GetString() ?? "" : "";
if (mkt.TryGetProperty("events", out var evts) && evts.ValueKind == JsonValueKind.Array && evts.GetArrayLength() > 0)
{
var evSlug = evts[0].TryGetProperty("slug", out var evp) ? evp.GetString() : "";
if (!string.IsNullOrEmpty(evSlug)) md.Slug = evSlug;
md.NegRisk = evts[0].TryGetProperty("enableNegRisk", out var pNeg) && pNeg.ValueKind == JsonValueKind.True;
}
if (mkt.TryGetProperty("endDate", out var ep) && ep.ValueKind == JsonValueKind.String)
{
if (DateTime.TryParse(ep.GetString(), null, System.Globalization.DateTimeStyles.RoundtripKind, out var endDt))
{
md.EndDate = endDt.ToUniversalTime();
}
}
results.Add(md);
}
}
}
}
catch (Exception ex)
{
_logger.Error($"Failed to fetch recent markets: {ex.Message}");
}
return results;
}
public async Task<PolyTraderSharp.Models.MarketData?> GetMarketByTokenIdAsync(string tokenId)
{
TrackRequest("Gamma");
if (string.IsNullOrEmpty(tokenId)) return null;
try
{
// Must use clob_token_ids! If you use clobTokenIds it ignores it and returns the oldest market (Joe Biden)
string url = $"https://gamma-api.polymarket.com/markets?clob_token_ids={tokenId}";
using var response = await GetWithRetryAsync(url);
if (response.IsSuccessStatusCode)
{
var jsonStr = await response.Content.ReadAsStringAsync();
using var doc = JsonDocument.Parse(jsonStr);
if (doc.RootElement.ValueKind == JsonValueKind.Array && doc.RootElement.GetArrayLength() > 0)
{
var mkt = doc.RootElement[0];
var md = new PolyTraderSharp.Models.MarketData();
md.Id = mkt.TryGetProperty("id", out var p1) ? p1.GetString() ?? "" : "";
md.ConditionId = mkt.TryGetProperty("conditionId", out var p2) ? p2.GetString() ?? "" : "";
md.Question = mkt.TryGetProperty("question", out var p3) ? p3.GetString() ?? "" : "";
md.Active = mkt.TryGetProperty("active", out var p5) && p5.GetBoolean();
md.Closed = mkt.TryGetProperty("closed", out var p6) && p6.GetBoolean();
md.ClobTokenIds = mkt.TryGetProperty("clobTokenIds", out var p7) ? (p7.ValueKind == JsonValueKind.String ? p7.GetString() ?? "" : p7.GetRawText()) : "";
md.Outcomes = mkt.TryGetProperty("outcomes", out var p8) ? (p8.ValueKind == JsonValueKind.String ? p8.GetString() ?? "" : p8.GetRawText()) : "";
md.Slug = mkt.TryGetProperty("slug", out var p4) ? p4.GetString() ?? "" : "";
if (mkt.TryGetProperty("events", out var evts) && evts.ValueKind == JsonValueKind.Array && evts.GetArrayLength() > 0)
{
var evt = evts[0];
var evSlug = evt.TryGetProperty("slug", out var evp) ? evp.GetString() : "";
if (!string.IsNullOrEmpty(evSlug)) md.Slug = evSlug;
if (evt.TryGetProperty("enableNegRisk", out var pNeg) && pNeg.ValueKind == JsonValueKind.True)
{
md.NegRisk = true;
}
if (evt.TryGetProperty("endDate", out var et) && DateTime.TryParse(et.GetString(), out var dt))
{
md.EndDate = DateTime.SpecifyKind(dt, DateTimeKind.Utc);
}
}
else
{
if (mkt.TryGetProperty("endDate", out var et) && DateTime.TryParse(et.GetString(), out var dt))
{
md.EndDate = DateTime.SpecifyKind(dt, DateTimeKind.Utc);
}
}
// Security Validation: Ensure the API actually returned the market we asked for!
if (string.IsNullOrEmpty(md.ClobTokenIds) || !md.ClobTokenIds.Contains(tokenId))
{
_logger.Warning($"GetMarketByTokenIdAsync: API returned a mismatching market '{md.Question}' for Token {tokenId}. Skipping.");
return null;
}
return md;
}
}
}
catch (Exception ex)
{
_logger.Error($"Failed to fetch market by token ID ({tokenId}): {ex.Message}");
}
return null;
}
public async Task<List<PolyTraderSharp.Models.MarketData>> GetMarketsByEventSlugAsync(string slug)
{
TrackRequest("Gamma");
var results = new List<PolyTraderSharp.Models.MarketData>();
if (string.IsNullOrEmpty(slug)) return results;
try
{
string url = $"https://gamma-api.polymarket.com/events?slug={slug}";
using var response = await GetWithRetryAsync(url);
if (response.IsSuccessStatusCode)
{
var jsonStr = await response.Content.ReadAsStringAsync();
using var doc = JsonDocument.Parse(jsonStr);
if (doc.RootElement.ValueKind == JsonValueKind.Array && doc.RootElement.GetArrayLength() > 0)
{
var ev = doc.RootElement[0];
if (ev.TryGetProperty("markets", out var marketsArr) && marketsArr.ValueKind == JsonValueKind.Array)
{
foreach (var mkt in marketsArr.EnumerateArray())
{
var md = new PolyTraderSharp.Models.MarketData();
md.Id = mkt.TryGetProperty("id", out var p1) ? p1.GetString() ?? "" : "";
md.ConditionId = mkt.TryGetProperty("conditionId", out var p2) ? p2.GetString() ?? "" : "";
md.Question = mkt.TryGetProperty("question", out var p3) ? p3.GetString() ?? "" : "";
md.Active = mkt.TryGetProperty("active", out var p5) && p5.GetBoolean();
md.Closed = mkt.TryGetProperty("closed", out var p6) && p6.GetBoolean();
md.ClobTokenIds = mkt.TryGetProperty("clobTokenIds", out var p7) ? (p7.ValueKind == JsonValueKind.String ? p7.GetString() ?? "" : p7.GetRawText()) : "";
md.Outcomes = mkt.TryGetProperty("outcomes", out var p8) ? (p8.ValueKind == JsonValueKind.String ? p8.GetString() ?? "" : p8.GetRawText()) : "";
md.Slug = slug;
md.NegRisk = ev.TryGetProperty("enableNegRisk", out var evNeg) && evNeg.ValueKind == JsonValueKind.True;
if (mkt.TryGetProperty("events", out var evts) && evts.ValueKind == JsonValueKind.Array && evts.GetArrayLength() > 0)
{
var evSlug = evts[0].TryGetProperty("slug", out var evp) ? evp.GetString() : "";
if (!string.IsNullOrEmpty(evSlug)) md.Slug = evSlug;
// if missing on event root but present in nested events (rare), fallback to it
if (!md.NegRisk && evts[0].TryGetProperty("enableNegRisk", out var pNeg) && pNeg.ValueKind == JsonValueKind.True)
{
md.NegRisk = true;
}
}
if (mkt.TryGetProperty("endDate", out var ep) && ep.ValueKind == JsonValueKind.String)
{
if (DateTime.TryParse(ep.GetString(), null, System.Globalization.DateTimeStyles.RoundtripKind, out var endDt))
{
md.EndDate = endDt.ToUniversalTime();
}
}
results.Add(md);
}
}
}
}
}
catch (Exception ex)
{
_logger.Error($"Failed to fetch markets by slug ({slug}): {ex.Message}");
}
return results;
}
public async Task<string> ResolveEventSlugAsync(string fallbackSlug, string tokenId)
{
if (string.IsNullOrEmpty(tokenId)) return fallbackSlug;
try
{
// Gamma API will resolve the market object along with its parent event properties
string url = $"https://gamma-api.polymarket.com/markets?clob_token_ids={tokenId}";
using var response = await GetWithRetryAsync(url);
if (response.IsSuccessStatusCode)
{
var jsonStr = await response.Content.ReadAsStringAsync();
using var doc = JsonDocument.Parse(jsonStr);
if (doc.RootElement.ValueKind == JsonValueKind.Array && doc.RootElement.GetArrayLength() > 0)
{
var mkt = doc.RootElement[0];
if (mkt.TryGetProperty("events", out var evts) && evts.ValueKind == JsonValueKind.Array && evts.GetArrayLength() > 0)
{
var evSlug = evts[0].TryGetProperty("slug", out var evs) ? evs.GetString() : "";
if (!string.IsNullOrEmpty(evSlug)) return evSlug;
}
}
}
}
catch (Exception ex)
{
_logger.Error($"Error resolving Event Slug for Token {tokenId}: {ex.Message}");
}
return fallbackSlug;
}
public async Task<List<PolyTraderSharp.Models.CopySignal>> ParseBlockchainTransactionAsync(string txHash, string rpcUrl, string masterWallet)
{
var results = new List<PolyTraderSharp.Models.CopySignal>();
try
{
// Convert wss:// to https://
if (rpcUrl.StartsWith("wss://")) rpcUrl = "https://" + rpcUrl.Substring(6);
var rpcPayload = new
{
jsonrpc = "2.0",
method = "eth_getTransactionReceipt",
@params = new object[] { txHash },
id = 1
};
using var request = new HttpRequestMessage(HttpMethod.Post, rpcUrl);
request.Content = new StringContent(JsonSerializer.Serialize(rpcPayload), System.Text.Encoding.UTF8, "application/json");
using var response = await _httpClient.SendAsync(request);
if (!response.IsSuccessStatusCode) return results;
var jsonStr = await response.Content.ReadAsStringAsync();
using var doc = JsonDocument.Parse(jsonStr);
if (!doc.RootElement.TryGetProperty("result", out var result) || result.ValueKind != JsonValueKind.Object)
return results;
if (!result.TryGetProperty("logs", out var logs) || logs.ValueKind != JsonValueKind.Array)
return results;
string rxFrom = "";
if (result.TryGetProperty("from", out var fVal) && fVal.ValueKind == JsonValueKind.String)
rxFrom = fVal.GetString()?.ToLowerInvariant() ?? "";
decimal usdcAmount = 0m;
string action = "";
// Track parsed CTF transfers: Dictionary<TokenId, Shares>
var parsedTransfers = new Dictionary<string, decimal>();
string masterWalletLower = masterWallet.ToLowerInvariant().Replace("0x", "");
string masterWalletPadded = "0x000000000000000000000000" + masterWalletLower;
string ctfExchangePadded = "0x0000000000000000000000004bfb41d5b3570defd03c39a9a4d8de6bd8b8982e";
bool isMasterTxOwner = rxFrom == ("0x" + masterWalletLower);
foreach (var log in logs.EnumerateArray())
{
string address = log.GetProperty("address").GetString()?.ToLowerInvariant() ?? "";
if (!log.TryGetProperty("topics", out var topicsArr) || topicsArr.ValueKind != JsonValueKind.Array || topicsArr.GetArrayLength() == 0) continue;
var topics = topicsArr.EnumerateArray().Select(t => t.GetString()?.ToLowerInvariant()).ToList();
string data = log.GetProperty("data").GetString()?.ToLowerInvariant() ?? "0x";
string topic0 = topics[0] ?? "";
// USDC Transfer (or USDC.e)
if (topic0 == "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef")
{
if (address == "0x2791bca1f2de4661ed88a30c99a7a9449aa84174" || address == "0x3c499c542cef5e3811e1192ce70d8cc03d5c3359")
{
if (topics.Count >= 3)
{
string fromTopic = topics[1] ?? "";
string toTopic = topics[2] ?? "";
if (isMasterTxOwner || fromTopic == masterWalletPadded || toTopic == masterWalletPadded || fromTopic == ctfExchangePadded || toTopic == ctfExchangePadded)
{
string cleanData = data.Replace("0x", "");
if (cleanData.Length >= 64)
{
var amountBI = System.Numerics.BigInteger.Parse("0" + cleanData.Substring(0, 64), System.Globalization.NumberStyles.HexNumber);
decimal amount = (decimal)amountBI / 1_000_000m; // 6 decimals USDC
usdcAmount = Math.Max(usdcAmount, amount);
if (toTopic == ctfExchangePadded) action = "BUY";
else if (fromTopic == ctfExchangePadded) action = "SELL";
else if (fromTopic == masterWalletPadded) action = "BUY";
else if (toTopic == masterWalletPadded) action = "SELL";
}
}
}
}
}
// CTF TransferSingle
if (topic0 == "0xc3d58168c5ae7397731d063d5bbf3d657854427343f4c083240f7aacaa2d0f62")
{
if (address == "0x4d97dcd97ec945f40cf65f87097ace5ea0476045")
{
if (topics.Count >= 4)
{
string fromTopic = topics[2] ?? "";
string toTopic = topics[3] ?? "";
if (isMasterTxOwner || fromTopic == masterWalletPadded || toTopic == masterWalletPadded)
{
string cleanData = data.Replace("0x", "");
if (cleanData.Length >= 128)
{
string idHex = cleanData.Substring(0, 64);
string valueHex = cleanData.Substring(64, 64);
var idBI = System.Numerics.BigInteger.Parse("0" + idHex, System.Globalization.NumberStyles.HexNumber);
var valueBI = System.Numerics.BigInteger.Parse("0" + valueHex, System.Globalization.NumberStyles.HexNumber);
string tid = idBI.ToString();
decimal sh = (decimal)valueBI / 1_000_000m; // 6 decimals CTF
if (parsedTransfers.ContainsKey(tid)) parsedTransfers[tid] += sh;
else parsedTransfers[tid] = sh;
}
}
}
}
}
// CTF TransferBatch
if (topic0 == "0x4a39dc06d4c0dbc64b70af90fd698a233a518aa5d07e595d983b8c0526c8f7ce")
{
if (address == "0x4d97dcd97ec945f40cf65f87097ace5ea0476045")
{
if (topics.Count >= 4)
{
string fromTopic = topics[2] ?? "";
string toTopic = topics[3] ?? "";
if (isMasterTxOwner || fromTopic == masterWalletPadded || toTopic == masterWalletPadded)
{
string cleanData = data.Replace("0x", "");
if (cleanData.Length >= 256)
{
try
{
var chunks = Enumerable.Range(0, cleanData.Length / 64).Select(i => cleanData.Substring(i * 64, 64)).ToList();
if (chunks.Count >= 4)
{
int idsOffsetWord = int.Parse(chunks[0], System.Globalization.NumberStyles.HexNumber) / 32;
int valsOffsetWord = int.Parse(chunks[1], System.Globalization.NumberStyles.HexNumber) / 32;
if (idsOffsetWord < chunks.Count && valsOffsetWord < chunks.Count)
{
int idsLen = int.Parse(chunks[idsOffsetWord], System.Globalization.NumberStyles.HexNumber);
int valsLen = int.Parse(chunks[valsOffsetWord], System.Globalization.NumberStyles.HexNumber);
int maxLen = Math.Min(idsLen, valsLen);
for (int i = 0; i < maxLen; i++)
{
if (idsOffsetWord + 1 + i < chunks.Count && valsOffsetWord + 1 + i < chunks.Count)
{
string idHex = chunks[idsOffsetWord + 1 + i];
string valHex = chunks[valsOffsetWord + 1 + i];
var idBI = System.Numerics.BigInteger.Parse("0" + idHex, System.Globalization.NumberStyles.HexNumber);
var valueBI = System.Numerics.BigInteger.Parse("0" + valHex, System.Globalization.NumberStyles.HexNumber);
string tid = idBI.ToString();
decimal sh = (decimal)valueBI / 1_000_000m;
if (parsedTransfers.ContainsKey(tid)) parsedTransfers[tid] += sh;
else parsedTransfers[tid] = sh;
}
}
}
}
}
catch (Exception ex)
{
_logger.Error($"Error parsing TransferBatch for TX {txHash}: {ex.Message}");
}
}
}
}
}
}
}
if (parsedTransfers.Count > 0 && !string.IsNullOrEmpty(action) && usdcAmount > 0)
{
decimal totalSharesForAllTokens = parsedTransfers.Values.Sum();
decimal globalAvgPrice = totalSharesForAllTokens > 0 ? usdcAmount / totalSharesForAllTokens : 0;
// Filter: Redeems yield exactly $1.00 USD per share. Merges also yield $1.00 USD for a full set.
// If the master trader "sells" at >= 0.99 on-chain, it is guaranteed to be a Redeem/Winnings Claim, NOT an orderbook trade.
// We must filter this out so the copy trading engine doesn't dump our tickets at market price!
if (action == "SELL" && globalAvgPrice >= 0.99m)
{
_logger.Debug($"FastTrack Parser: Ignored Fake SELL (Redeem/Merge) with Return Price ${globalAvgPrice:F3} for TX {txHash}");
return results;
}
if (globalAvgPrice > 0.999m) globalAvgPrice = 0.99m;
foreach (var pt in parsedTransfers)
{
var signal = new PolyTraderSharp.Models.CopySignal
{
TokenId = pt.Key,
Side = action,
Size = pt.Value,
Price = globalAvgPrice,
Timestamp = DateTime.UtcNow
};
results.Add(signal);
}
}
}
catch (Exception ex)
{
_logger.Error($"Blockchain Parser Error: {ex.Message}");
}
return results;
}
}
}