Phase 4.6a: CopySignal + PolymarketApiService in den Core
- CopySignal als generischer Core-Typ (Namespace PolyTraderSharp.Models beibehalten). - PolymarketApiService nach Core (nutzt nur TerminalLogger + HttpClient, keine DB); ungenutzte DB-Usings entfernt. - Build 0 Fehler. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
827193c774
commit
9c068b448e
@@ -1,943 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user