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>
This commit is contained in:
bergm
2026-07-01 13:16:16 +02:00
co-authored by Claude Opus 4.8
commit 475d396f80
147 changed files with 25455 additions and 0 deletions
+252
View File
@@ -0,0 +1,252 @@
using System;
using MongoDB.Driver;
using PolyTraderSharp.Extensions;
using System.Linq;
using System.Net.WebSockets;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Hosting;
using PolyTraderSharp.Models;
namespace PolyTraderSharp.Services
{
public class AlchemyWebsocketService : BackgroundService
{
private const string CtfContractAddress = "0x4D97DCd97eC945f40cF65F87097ACe5EA0476045";
private const string TransferSingleTopic = "0xc3d58168c5ae7397731d063d5bbf3d657854427343f4c083240f7aacaa2d0f62";
private const string TransferBatchTopic = "0x4a39dc06d4c0dbc64b70af90fd698a233a518aa5d07e595d983b8c0526c8f7ce";
private readonly TradingState _state;
private readonly ServerSettings _settings;
private readonly TraderMonitorService _traderMonitor;
private readonly TerminalLogger _logger;
public AlchemyWebsocketService(
TradingState state,
ServerSettings settings,
TraderMonitorService traderMonitor,
TerminalLogger logger)
{
_state = state;
_settings = settings;
_traderMonitor = traderMonitor;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
if (!_settings.EnableBlockchainListener || string.IsNullOrEmpty(_settings.PolygonRpcUrl))
{
_logger.Info("Blockchain Listener is disabled in settings. Using raw polling.");
_state.IsAlchemyHealthy = false;
return;
}
_logger.Info("Alchemy WSS Service starting up...");
while (!stoppingToken.IsCancellationRequested)
{
if (_state.GlobalTradingPaused ||
(_state.LiveTradingMode == TradingMode.Inactive && _state.DemoTradingMode == TradingMode.Inactive))
{
_state.IsAlchemyHealthy = false;
await Task.Delay(5000, stoppingToken);
continue;
}
try
{
await ConnectAndListenAsync(stoppingToken);
}
catch (WebSocketException ex)
{
// Usually indicates a connection drop or 429
_logger.Warning($"Alchemy WSS drop: {ex.Message}. Falling back to API polling for 5 minutes.");
_state.IsAlchemyHealthy = false;
await Task.Delay(TimeSpan.FromMinutes(5), stoppingToken);
}
catch (Exception ex)
{
if (ex.Message.Contains("429") || ex.Message.Contains("Too Many Requests"))
{
_logger.Error($"Alchemy HTTP 429 Limit reached. Suspending WSS for 5 minutes.");
_state.IsAlchemyHealthy = false;
await Task.Delay(TimeSpan.FromMinutes(5), stoppingToken);
}
else
{
_logger.Error($"Alchemy WSS Error: {ex.Message}. Retrying in 10s...");
_state.IsAlchemyHealthy = false;
await Task.Delay(10000, stoppingToken);
}
}
}
}
private async Task ConnectAndListenAsync(CancellationToken stoppingToken)
{
using var ws = new ClientWebSocket();
var wssUrl = _settings.PolygonRpcUrl.Replace("https://", "wss://").Replace("http://", "ws://");
_logger.Info($"Connecting to Alchemy WebSocket: {wssUrl.Substring(0, Math.Min(35, wssUrl.Length))}...");
await ws.ConnectAsync(new Uri(wssUrl), stoppingToken);
_state.IsAlchemyHealthy = true;
_logger.Info("✅ Alchemy WSS Connected. Dynamic API-Throttling activated.");
var activeTraders = _state.Traders.Values.Where(t => t.IsActive).ToList();
var activeStateHash = string.Join(",", activeTraders.OrderBy(t => t.Id).Select(t => t.WalletAddress.ToLowerInvariant()));
if (activeTraders.Count > 0)
{
var paddedAddresses = activeTraders.Select(t => PadAddress(t.WalletAddress)).ToList();
int batchSize = 3; // Alchemy limits Topic arrays to a max of 3/4 entries
int reqId = 1;
for (int i = 0; i < paddedAddresses.Count; i += batchSize)
{
var chunk = paddedAddresses.Skip(i).Take(batchSize).ToList();
var addrJson = JsonSerializer.Serialize(chunk);
// Buys: Master Trader is the receiver (Topic 3)
var subscribeBuysStr = $@"{{
""jsonrpc"": ""2.0"",
""id"": {reqId++},
""method"": ""eth_subscribe"",
""params"": [
""logs"",
{{
""address"": ""{CtfContractAddress}"",
""topics"": [
[""{TransferSingleTopic}"", ""{TransferBatchTopic}""],
null,
null,
{addrJson}
]
}}
]
}}";
// Sells: Master Trader is the sender (Topic 2)
var subscribeSellsStr = $@"{{
""jsonrpc"": ""2.0"",
""id"": {reqId++},
""method"": ""eth_subscribe"",
""params"": [
""logs"",
{{
""address"": ""{CtfContractAddress}"",
""topics"": [
[""{TransferSingleTopic}"", ""{TransferBatchTopic}""],
null,
{addrJson}
]
}}
]
}}";
await ws.SendAsync(new ArraySegment<byte>(Encoding.UTF8.GetBytes(subscribeBuysStr)), WebSocketMessageType.Text, true, stoppingToken);
await ws.SendAsync(new ArraySegment<byte>(Encoding.UTF8.GetBytes(subscribeSellsStr)), WebSocketMessageType.Text, true, stoppingToken);
}
}
else
{
_logger.Info("Keine aktiven Master-Trader. Socket läuft im Standby...");
}
var buffer = new byte[1024 * 64];
var loopCts = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken);
var monitorTask = Task.Run(async () =>
{
while (!loopCts.IsCancellationRequested)
{
await Task.Delay(5000, loopCts.Token);
var currentTraders = _state.Traders.Values.Where(t => t.IsActive).ToList();
var currentHash = string.Join(",", currentTraders.OrderBy(t => t.Id).Select(t => t.WalletAddress.ToLowerInvariant()));
if (currentHash != activeStateHash)
{
_logger.Info("🔄 Master-Trader Liste hat sich geändert. Starte Alchemy WSS mit neuen Filtern neu...");
loopCts.Cancel();
break;
}
}
});
try
{
while (ws.State == WebSocketState.Open && !loopCts.IsCancellationRequested)
{
var result = await ws.ReceiveAsync(new ArraySegment<byte>(buffer), loopCts.Token);
if (result.MessageType == WebSocketMessageType.Close)
{
break;
}
var message = Encoding.UTF8.GetString(buffer, 0, result.Count);
try
{
ProcessMessage(message);
}
catch (Exception ex)
{
_logger.Error($"Error parsing WSS msg: {ex.Message}");
}
}
}
catch (OperationCanceledException)
{
// Expected when reconnecting due to trader list change
}
finally
{
if (!loopCts.IsCancellationRequested) loopCts.Cancel();
}
}
private void ProcessMessage(string jsonStr)
{
using var doc = JsonDocument.Parse(jsonStr);
var root = doc.RootElement;
if (!root.TryGetProperty("params", out var paramsEl)) return;
if (!paramsEl.TryGetProperty("result", out var resultEl)) return;
if (!resultEl.TryGetProperty("topics", out var topicsEl) || topicsEl.GetArrayLength() < 4) return;
var topics = topicsEl.EnumerateArray().Select(t => t.GetString()).ToList();
var fromTopic = topics[2]?.ToLowerInvariant();
var toTopic = topics[3]?.ToLowerInvariant();
if (fromTopic == null || toTopic == null) return;
var activeTraders = _state.Traders.Values.Where(t => t.IsActive).ToList();
string? triggeredAddress = null;
foreach (var trader in activeTraders)
{
var padded = PadAddress(trader.WalletAddress);
if (fromTopic == padded || toTopic == padded)
{
triggeredAddress = trader.WalletAddress;
break;
}
}
if (!string.IsNullOrEmpty(triggeredAddress))
{
string txHash = resultEl.TryGetProperty("transactionHash", out var th) ? th.GetString() ?? "unknown" : "unknown";
_traderMonitor.TriggerFastBlockchainPoll(txHash, _settings.PolygonRpcUrl, triggeredAddress);
}
}
private string PadAddress(string address)
{
string stripped = address.Replace("0x", "", StringComparison.OrdinalIgnoreCase).ToLowerInvariant();
return "0x" + stripped.PadLeft(64, '0');
}
}
}
+754
View File
@@ -0,0 +1,754 @@
using System;
using MongoDB.Driver;
using PolyTraderSharp.Extensions;
using System.Threading;
using System.Threading.Channels;
using System.Threading.Tasks;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using PolyTraderSharp.Models;
using System.Collections.Concurrent;
using System.Linq;
namespace PolyTraderSharp.Services
{
public class CopyTradingEngine : BackgroundService
{
private readonly TradingState _state;
private readonly ChannelReader<CopySignal> _signalReader;
private readonly ChannelWriter<ClosedTrade> _closedTradeWriter;
private readonly TerminalLogger _logger;
private readonly PolymarketClobClient _clob;
private readonly PolymarketApiService _api;
private readonly IMongoDatabase? _db;
private readonly ConcurrentDictionary<int, SemaphoreSlim> _accountSemaphores = new();
private readonly ConcurrentDictionary<int, DateTime> _lastInactiveLogPerTrader = new();
public CopyTradingEngine(
TradingState state,
ChannelReader<CopySignal> signalReader,
ChannelWriter<ClosedTrade> closedTradeWriter,
TerminalLogger logger,
PolymarketClobClient clob,
PolymarketApiService api,
IMongoDatabase? db = null)
{
_state = state;
_signalReader = signalReader;
_closedTradeWriter = closedTradeWriter;
_logger = logger;
_clob = clob;
_api = api;
_db = db;
}
public override async Task StartAsync(CancellationToken cancellationToken)
{
_logger.Info("Starte Preload des MarketCache aus MongoDB um Flaschenhälse zu vermeiden...");
if (_db != null)
{
var coll = _db.GetCollection<MarketData>("markets");
// Initialize cache for EVERYTHING in DB that is not closed!
var activeMarkets = coll.LiteFind(x => !x.Closed);
int loaded = 0;
foreach (var md in activeMarkets)
{
if (!string.IsNullOrEmpty(md.ClobTokenIds))
{
try
{
var tokenIds = System.Text.Json.JsonSerializer.Deserialize<List<string>>(md.ClobTokenIds);
if (tokenIds != null)
{
foreach (var token in tokenIds)
{
_state.MarketCache[token] = md;
loaded++;
}
}
}
catch { } // Ignore malformed JSON cleanly
}
}
_logger.Info($"MarketCache Preload abgeschlossen: {loaded} Token herangeführt.");
}
await base.StartAsync(cancellationToken);
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.Info("CopyTradingEngine Channel Listener started (Concurrent).");
var semaphore = new SemaphoreSlim(15, 15);
await foreach (var signal in _signalReader.ReadAllAsync(stoppingToken))
{
await semaphore.WaitAsync(stoppingToken);
_ = Task.Run(async () =>
{
try
{
await ProcessSignalAsync(signal);
}
catch (Exception ex)
{
_logger.Error($"Absturz im SignalProcessor: {ex.Message}");
}
finally
{
semaphore.Release();
}
}, stoppingToken);
}
}
private async Task ProcessSignalAsync(CopySignal signal)
{
if (_state.GlobalTradingPaused)
{
_logger.TradeReasoning($"⏸️ Trade {signal.Side} [{signal.MarketQuestion}] ignoriert: GlobalTradingPaused ist aktiv.");
return;
}
// ==========================================
// GLOBAL EXPENSIVE DB/API MARKET CACHE HYDRATION
// Runs once per signal, before any account locks!
// ==========================================
bool isNegRisk = false;
if (!string.IsNullOrEmpty(signal.TokenId))
{
if (_state.MarketCache.TryGetValue(signal.TokenId, out var cachedData))
{
if (!string.IsNullOrEmpty(cachedData.Slug)) signal.MarketSlug = cachedData.Slug;
if (!string.IsNullOrEmpty(cachedData.Question)) signal.MarketQuestion = cachedData.Question;
if (cachedData.EndDate.HasValue) signal.EndDate = cachedData.EndDate;
isNegRisk = cachedData.NegRisk;
}
else if (_db != null)
{
try
{
var marketColl = _db.GetCollection<MarketData>("markets");
var marketData = marketColl.LiteFind(x => x.ClobTokenIds != null && x.ClobTokenIds.Contains(signal.TokenId)).FirstOrDefault();
if (marketData == null)
{
var fetchedMarket = await _api.GetMarketByTokenIdAsync(signal.TokenId);
if (fetchedMarket != null) { marketColl.Upsert(fetchedMarket); marketData = fetchedMarket; }
}
if (marketData == null && !string.IsNullOrEmpty(signal.MarketSlug) && !signal.MarketSlug.StartsWith("0x"))
{
var fetchedMarkets = await _api.GetMarketsByEventSlugAsync(signal.MarketSlug);
foreach (var fetched in fetchedMarkets) {
marketColl.Upsert(fetched);
if (fetched.ClobTokenIds != null && fetched.ClobTokenIds.Contains(signal.TokenId)) marketData = fetched;
}
}
if (marketData != null)
{
if (!string.IsNullOrEmpty(marketData.Slug)) signal.MarketSlug = marketData.Slug;
if (!string.IsNullOrEmpty(marketData.Question)) signal.MarketQuestion = marketData.Question;
if (marketData.EndDate.HasValue) signal.EndDate = marketData.EndDate;
isNegRisk = marketData.NegRisk;
// Add to Cache for fast lookup
_state.MarketCache[signal.TokenId] = marketData;
}
}
catch (Exception ex)
{
_logger.Warning($"Fehler beim Abrufen von MarketData f\u00fcr Token {signal.TokenId}: {ex.Message}");
}
}
}
// ==========================================
// Internal System Signal (e.g. Demo Auto-Close)
if (signal.TraderId == 0)
{
var sysaccountTasks = new List<Task>();
foreach (var account in _state.Accounts.Values.Where(a => a.IsDemo && a.IsActive))
{
if (account.OpenPositions.ContainsKey(signal.TokenId))
{
sysaccountTasks.Add(ProcessAccountOrderAsync(account, null, signal, isNegRisk));
}
}
await Task.WhenAll(sysaccountTasks);
return;
}
if (!_state.Traders.TryGetValue(signal.TraderId, out var trader) || !trader.IsActive)
{
_logger.TradeReasoning($"\u23f8\ufe0f Trade {signal.Side} [{signal.MarketQuestion}] ignoriert: Trader (ID={signal.TraderId}) nicht gefunden oder inaktiv.");
return;
}
var accountTasks = new List<Task>();
foreach (var accountId in trader.AssignedAccountIds)
{
if (!_state.Accounts.TryGetValue(accountId, out var account) || !account.IsActive)
{
_logger.TradeReasoning($"\u23f8\ufe0f Trade {signal.Side} [{signal.MarketQuestion}] ignoriert: Account (ID={accountId}) nicht gefunden oder inaktiv.");
continue;
}
accountTasks.Add(ProcessAccountOrderAsync(account, trader, signal, isNegRisk));
}
await Task.WhenAll(accountTasks);
}
private async Task ProcessAccountOrderAsync(AccountState account, TrackedTrader? trader, CopySignal signal, bool isNegRisk)
{
var mode = account.IsDemo ? _state.DemoTradingMode : _state.LiveTradingMode;
if (mode == TradingMode.Inactive)
{
// Rate-limited log: max 1 per trader per 60s to prevent HF spam
var traderId = signal.TraderId;
var now = DateTime.UtcNow;
if (!_lastInactiveLogPerTrader.TryGetValue(traderId, out var lastLog) || (now - lastLog).TotalSeconds >= 60)
{
_lastInactiveLogPerTrader[traderId] = now;
string modeLabel = account.IsDemo ? "Demo" : "Live";
_logger.TradeReasoning($"⏸️ Trade {signal.Side} [{signal.MarketQuestion}] [{(string.IsNullOrEmpty(signal.Outcome) ? signal.Side : signal.Outcome)}] ignoriert:\n" +
$" Konto: {account.Name}\n" +
$" Begründung: {modeLabel}-Trading Modus ist 'Inactive'. Weitere Trades dieses Traders werden für 60s nicht geloggt.");
}
return;
}
// Restrict BUY operations if mode is SellOnly
if (mode == TradingMode.SellOnly && signal.Side == "BUY")
{
_logger.TradeReasoning($"⏸️ Trade BUY [{signal.MarketQuestion}] [{(string.IsNullOrEmpty(signal.Outcome) ? signal.Side : signal.Outcome)}] ignoriert:\n" +
$" Konto: {account.Name}\n" +
$" Begründung: Trading Modus ist 'SellOnly' — BUY-Trades werden nicht kopiert.");
return;
}
string shareType = string.IsNullOrEmpty(signal.Outcome) ? signal.Side : signal.Outcome;
var accountSemaphore = _accountSemaphores.GetOrAdd(account.AccountId, _ => new SemaphoreSlim(1, 1));
await accountSemaphore.WaitAsync();
try
{
// ==========================================
// OPEN ORDER CLEANUP (LIVE ACCOUNTS ONLY)
// ==========================================
// Wenn ein neues Signal für diesen Markt reinkommt, prüfen wir auf veraltete offene Orders.
// Identische Preise bleiben bestehen. Abweichende verhindern ungültiges Blockieren von Funds.
if (!account.IsDemo && !string.IsNullOrEmpty(signal.TokenId))
{
if (account.HasOpenLimitOrders)
{
await _clob.CancelConflictingOrdersAsync(account, signal.TokenId, signal.Price, signal.Side);
}
}
if (_state.MarketCache.TryGetValue(signal.TokenId, out var fastCachedData))
{
if (fastCachedData.EndDate.HasValue) signal.EndDate = fastCachedData.EndDate;
}
// ==========================================
// PRE-FLIGHT RISK CHECKS (Before DB/API!)
// ==========================================
decimal exactShares = 0m;
decimal exactUsdc = 0m;
decimal orderPrice = signal.Price;
if (signal.Side == "BUY")
{
if (signal.Price > account.MaxBuyPrice)
{
_logger.TradeReasoning($"❌ Trade BUY [{signal.MarketQuestion}] [{shareType}] verworfen (Risk Limit):\n" +
$" Konto: {account.Name}\n" +
$" Begründung: Preis (${signal.Price:F3}) übersteigt das MaxBuy Limit (${account.MaxBuyPrice:F3})");
return;
}
var activePositions = account.OpenPositions.Values.Where(p => IsPositionMarketActive(p)).ToList();
decimal investedInMarket = activePositions.FirstOrDefault(p => p.TokenId == signal.TokenId)?.AmountUsd ?? 0m;
decimal minTrade = 1.0m;
decimal maxAllowed = account.TotalBalance * (account.PerMarketLimit / 100.0m);
// Low Balance Bypass (Stufen-System) ALWAYS APPLIES
if (account.TotalBalance < 150m) maxAllowed = Math.Min(1.20m, Math.Max(account.AvailableBalance, 0m));
else if (account.TotalBalance < 500m) maxAllowed = Math.Min(3.0m, Math.Max(account.AvailableBalance, 0m));
if (_state.SixSharesMinimum && account.TotalBalance < 500m)
{
// Adjust maxAllowed to cover at least 6 shares * order limit price.
decimal desiredLimitForSix;
if (trader != null && trader.Category == "HF")
{
desiredLimitForSix = signal.Price + 0.005m;
}
else
{
desiredLimitForSix = signal.Price * (1.0m + account.MaxPriceDifference / 100.0m);
}
decimal orderPriceForSix = Math.Min(desiredLimitForSix, account.MaxBuyPrice);
if (orderPriceForSix > 0.99m) orderPriceForSix = 0.99m;
decimal costSix = 6m * orderPriceForSix;
if (costSix > maxAllowed)
{
maxAllowed = Math.Min(costSix, Math.Max(account.AvailableBalance, 0m));
}
}
decimal maxAmountToBuy = maxAllowed - investedInMarket;
decimal investedInMaster = trader != null ? activePositions.Where(p => p.SourceTraderId == trader.Id).Sum(p => (decimal)p.AmountUsd) : 0m;
decimal maxAllowedPerMaster = account.TotalBalance * (account.PerMasterLimit / 100.0m);
if (trader != null && (investedInMaster + maxAmountToBuy) > maxAllowedPerMaster)
{
decimal pctInvested = account.TotalBalance > 0 ? (investedInMaster / account.TotalBalance) * 100m : 0m;
_logger.TradeReasoning($"❌ Trade BUY [{signal.MarketQuestion}] [{shareType}] verworfen:\n" +
$" Konto: {account.Name}\n" +
$" Begründung: PerMasterLimit ({account.PerMasterLimit:F1}%) erreicht. Bisher investiert in '{trader.DisplayName}': ${investedInMaster:F2} ({pctInvested:F1}%).");
return;
}
// Time Limit Restriktion
double hoursLeft = signal.EndDate.HasValue ? (signal.EndDate.Value - DateTime.UtcNow).TotalHours : 999999;
decimal applicableTimeLimitPct;
decimal investedInTimeframe = 0m;
string timeframeLabel = "";
var openVals = activePositions;
if (hoursLeft < 6)
{
applicableTimeLimitPct = account.perMaxTime6h;
timeframeLabel = "< 6h";
investedInTimeframe = openVals.Where(p => p.ExpiryDate.HasValue && (p.ExpiryDate.Value - DateTime.UtcNow).TotalHours < 6).Sum(p => (decimal)p.AmountUsd);
}
else if (hoursLeft < 24)
{
applicableTimeLimitPct = account.perMaxTime24h;
timeframeLabel = "< 24h";
investedInTimeframe = openVals.Where(p => p.ExpiryDate.HasValue && (p.ExpiryDate.Value - DateTime.UtcNow).TotalHours >= 6 && (p.ExpiryDate.Value - DateTime.UtcNow).TotalHours < 24).Sum(p => (decimal)p.AmountUsd);
}
else if (hoursLeft < 72)
{
applicableTimeLimitPct = account.perMaxTime72h;
timeframeLabel = "< 72h";
investedInTimeframe = openVals.Where(p => p.ExpiryDate.HasValue && (p.ExpiryDate.Value - DateTime.UtcNow).TotalHours >= 24 && (p.ExpiryDate.Value - DateTime.UtcNow).TotalHours < 72).Sum(p => (decimal)p.AmountUsd);
}
else
{
applicableTimeLimitPct = account.perMaxTimeNone;
timeframeLabel = "> 72h";
investedInTimeframe = openVals.Where(p => !p.ExpiryDate.HasValue || (p.ExpiryDate.Value - DateTime.UtcNow).TotalHours >= 72).Sum(p => (decimal)p.AmountUsd);
}
decimal maxAllowedTimeframe = account.TotalBalance * (applicableTimeLimitPct / 100.0m);
if ((investedInTimeframe + maxAmountToBuy) > maxAllowedTimeframe)
{
decimal remainingForTimeframe = maxAllowedTimeframe - investedInTimeframe;
if (remainingForTimeframe < minTrade)
{
_logger.TradeReasoning($"❌ Trade BUY [{signal.MarketQuestion}] [{shareType}] verworfen:\n" +
$" Konto: {account.Name}\n" +
$" Begründung: TimeLimit '{timeframeLabel}' ({applicableTimeLimitPct:F1}%) erreicht. Bisher investiert: ${investedInTimeframe:F2} / max. ${maxAllowedTimeframe:F2}");
return;
}
else
{
maxAmountToBuy = remainingForTimeframe;
}
}
if (maxAmountToBuy < minTrade)
{
_logger.TradeReasoning($"❌ Trade BUY [{signal.MarketQuestion}] [{shareType}] verworfen:\n" +
$" Konto: {account.Name}\n" +
$" Begründung: Kauflimit (${maxAllowed:F2}) bereits in Markt investiert (${investedInMarket:F2}). Rest: ${maxAmountToBuy:F2} < MinTrade (${minTrade:F2})");
return;
}
if (maxAmountToBuy > account.AvailableBalance)
{
_logger.TradeReasoning($"❌ Trade BUY [{signal.MarketQuestion}] [{shareType}] verworfen:\n" +
$" Konto: {account.Name}\n" +
$" Begründung: Kontostand (${account.AvailableBalance:F2}) nicht ausreichend für errechnetes Size (${maxAmountToBuy:F2})");
return;
}
decimal desiredLimit;
if (trader != null && trader.Category == "HF")
{
// HF Trader: festes 0.5 Cent (0.005) Limit
desiredLimit = signal.Price + 0.005m;
}
else
{
// Normaler Trader: prozentuales Limit aus Slave-Account Settings
desiredLimit = signal.Price * (1.0m + account.MaxPriceDifference / 100.0m);
}
orderPrice = Math.Min(desiredLimit, account.MaxBuyPrice);
if (orderPrice > 0.99m) orderPrice = 0.99m;
var exact = PolymarketClobClient.CalculateExactOrderAmounts(maxAmountToBuy, orderPrice, orderPrice, "BUY");
if (exact.shares <= 0 || exact.usdc > account.AvailableBalance)
{
_logger.TradeReasoning($"❌ Trade BUY [{signal.MarketQuestion}] [{shareType}] gestoppt:\n" +
$" Begründung: Mathematisch unmöglicher Trade ({exact.shares} Shares für ${exact.usdc:F2}). Kontostand (${account.AvailableBalance:F2}) reicht für Minimum nicht aus.");
return;
}
// ===== MICRO-ORDER FILTER: Polymarket Minimum Size Enforcement =====
// Polymarket lehnt Orders mit < 5 Shares ab ("Size lower than the minimum: 5").
// Statt die API zu belasten und Fehler-Logs zu erzeugen, filtern wir hier sofort.
if (exact.shares < 5.5m || exact.usdc < 0.10m)
{
_logger.TradeReasoning($"❌ Trade BUY [{signal.MarketQuestion}] [{shareType}] gestoppt:\n" +
$" Konto: {account.Name}\n" +
$" Begründung: Unter Polymarket Minimum ({exact.shares:F1} Shares / ${exact.usdc:F2} USDC). Min: 5.5 Shares / $0.10.");
return;
}
exactShares = exact.shares;
exactUsdc = exact.usdc;
}
else if (signal.Side == "SELL")
{
// PRE-FLIGHT SELL Check: Exists in portfolio AND opened by the SAME master trader?
// CRITICAL: We must NOT sell a position opened by Trader A based on a SELL signal from Trader B.
var inPortfolio = account.OpenPositions.Values.FirstOrDefault(p =>
(p.TokenId == signal.TokenId || (p.MarketSlug == signal.MarketSlug && p.Outcome == signal.Outcome))
&& p.SourceTraderId == signal.TraderId);
if (inPortfolio == null)
{
// Check if position exists but belongs to a different trader (for clearer logging)
var wrongTraderPos = account.OpenPositions.Values.FirstOrDefault(p =>
p.TokenId == signal.TokenId || (p.MarketSlug == signal.MarketSlug && p.Outcome == signal.Outcome));
if (wrongTraderPos != null)
{
_logger.Info($"❌ Trade SELL [{signal.MarketQuestion}] [{shareType}] ignoriert:\n" +
$" Konto: {account.Name}\n" +
$" Begründung: Position gehört Trader '{wrongTraderPos.SourceTraderName}' (ID {wrongTraderPos.SourceTraderId}), SELL kam aber von Trader ID {signal.TraderId}.");
}
else
{
_logger.Info($"❌ Trade SELL [{signal.MarketQuestion}] [{shareType}] ignoriert:\n" +
$" Konto: {account.Name}\n" +
$" Begründung: Position nicht im Portfolio gefunden (möglicherweise zuvor gefiltert).");
}
return;
}
// PROPORTIONALITY CHECK: Only copy SELL if the master trader is exiting a significant portion (≥30%) of their position.
// Active day-traders like SwissTony buy 500 shares then sell 2 (0.4%) — we should NOT copy that.
// But if they sell 200 of 500 (40%), that's a real exit signal we must copy.
string masterPosKey = $"{signal.TraderId}_{inPortfolio.TokenId}";
if (_state.MasterTraderPositions.TryGetValue(masterPosKey, out var masterPos))
{
decimal masterShares = masterPos.Shares;
if (masterShares > 0 && signal.Size > 0)
{
// Calculate what percentage of the master's known position this SELL represents
decimal sellRatio = signal.Size / (masterShares + signal.Size); // +signal.Size because the position was already reduced
if (sellRatio < 0.30m)
{
_logger.TradeReasoning($"📊 Trade SELL [{signal.MarketQuestion}] [{shareType}] ignoriert:\n" +
$" Konto: {account.Name}\n" +
$" Begründung: Teilverkauf ({sellRatio:P0} des Bestands). Master hält noch {masterShares:F1} Shares. Signal nur {signal.Size:F1} Shares. Schwelle: 30%.");
return;
}
_logger.TradeReasoning($"📊 Trade SELL [{signal.MarketQuestion}] [{shareType}] FREIGEGEBEN:\n" +
$" Konto: {account.Name}\n" +
$" Begründung: Signifikanter Verkauf ({sellRatio:P0} des Bestands). Master hatte {masterShares + signal.Size:F1} Shares, verkauft {signal.Size:F1}.");
}
else if (masterShares <= 0)
{
// Master has 0 shares according to our tracking, but a SELL signal came in.
// This is an inconsistency — either our tracking is stale, or it's a phantom signal.
// Do NOT sell blindly. Wait for the next background sync to update the real position.
_logger.TradeReasoning($"📊 Trade SELL [{signal.MarketQuestion}] [{shareType}] ignoriert:\n" +
$" Konto: {account.Name}\n" +
$" Begründung: Master hält laut Tracking 0 Shares, aber SELL Signal mit {signal.Size:F1} Shares erhalten. Inkonsistenz — ignoriert.");
return;
}
}
else
{
// No tracking data yet — apply soft grace period (2 min) as fallback until first sync completes
double holdingMinutes = (DateTime.UtcNow - inPortfolio.OpenedAt).TotalMinutes;
if (holdingMinutes < 2.0)
{
_logger.TradeReasoning($"⏳ Trade SELL [{signal.MarketQuestion}] [{shareType}] ignoriert:\n" +
$" Konto: {account.Name}\n" +
$" Begründung: Kein Master-Position-Tracking verfügbar und Haltezeit erst {holdingMinutes:F1} Min. Warte auf ersten Sync.");
return;
}
}
}
// Market Metadata loaded globally. Ready for execution.
// If BUY -> Invest
if (signal.Side == "BUY")
{
if (account.IsDemo)
{
var pos = new Position
{
TokenId = signal.TokenId,
MarketSlug = signal.MarketSlug,
SourceTraderId = trader?.Id ?? 0,
SourceTraderName = trader?.DisplayName ?? "System",
SourceTraderAddress = trader?.WalletAddress ?? "",
MarketQuestion = signal.MarketQuestion,
Outcome = signal.Outcome,
Side = "BUY",
EntryPrice = orderPrice,
Size = exactShares,
AmountUsd = exactUsdc,
ExpiryDate = signal.EndDate ?? DateTime.UtcNow.AddDays(14)
};
_state.GetNextTradeId();
var finalPos = account.OpenPositions.AddOrUpdate(signal.TokenId, pos, (k, old) =>
{
old.Size += pos.Size;
old.AmountUsd += pos.AmountUsd;
old.EntryPrice = old.AmountUsd / old.Size; // weighted average
return old;
});
if (_db != null) _db.GetCollection<Position>($"demo_positions_{account.AccountId}").Upsert(finalPos);
account.UpdateBalance(account.AvailableBalance - exactUsdc);
if (_db != null) _db.GetCollection<AccountState>("accounts").Upsert(account);
_logger.Trade($"✅ [DEMO AUSGEFÜHRT]\n" +
$" Konto: {account.Name}\n" +
$" Markt: {signal.MarketQuestion}\n" +
$" BUY: {exactShares:F4} Shares [{shareType}] @ ${orderPrice:F3} (Gesamt: ${exactUsdc:F2})");
}
else
{
_logger.Info($"🌐 [LIVE-EXECUTION] Sende MARKET BUY an Polymarket CTF-Router...\n" +
$" Account: {account.Name}\n" +
$" Limit: ${orderPrice:F3} (Target: {signal.Price:F3})");
var result = await _clob.PlaceOrderAsync(account, signal.TokenId, signal.Side, exactUsdc, orderPrice, "GTD", _state.DebugOrderPayloadLog, isNegRisk);
if (result == "OK")
{
var pos = new Position
{
TokenId = signal.TokenId,
MarketSlug = signal.MarketSlug,
SourceTraderId = trader?.Id ?? 0,
SourceTraderName = trader?.DisplayName ?? "System",
SourceTraderAddress = trader?.WalletAddress ?? "",
MarketQuestion = signal.MarketQuestion,
Outcome = signal.Outcome,
Side = "BUY",
EntryPrice = orderPrice, // Real execution price will update on next SyncOpenPositions poll
Size = exactShares,
AmountUsd = exactUsdc,
ExpiryDate = signal.EndDate ?? DateTime.UtcNow.AddDays(14)
};
_state.GetNextTradeId();
account.OpenPositions.AddOrUpdate(signal.TokenId, pos, (k, old) =>
{
old.Size += pos.Size;
old.AmountUsd += pos.AmountUsd;
old.EntryPrice = old.AmountUsd / old.Size;
return old;
});
account.UpdateBalance(account.AvailableBalance - exactUsdc);
if (_db != null) _db.GetCollection<AccountState>("accounts").Upsert(account);
if (_db != null)
{
var liveCol = _db.GetCollection<Position>($"open_positions_{account.AccountId}");
if (account.OpenPositions.TryGetValue(signal.TokenId, out var savedPos))
{
liveCol.Upsert(savedPos);
}
}
// Track order placement time for stale order cleanup
string orderKey = $"{account.AccountId}_{signal.TokenId}";
_state.PendingOrderTimestamps[orderKey] = (DateTime.UtcNow, signal.TraderId);
// Initialize master position tracking with signal size if not yet tracked
// The background sync will update with the real value within 30 seconds
string masterKey = $"{signal.TraderId}_{signal.TokenId}";
_state.MasterTraderPositions.TryAdd(masterKey, (signal.Size, DateTime.UtcNow));
}
}
}
// If SELL -> Divest
else if (signal.Side == "SELL")
{
string orderKey = $"{account.AccountId}_{signal.TokenId}";
if (_state.PendingOrderTimestamps.TryGetValue(orderKey, out var pendingInfo))
{
if ((DateTime.UtcNow - pendingInfo.PlacedAt).TotalSeconds < 20)
{
return; // Spam-Blockade: Die Order wurde in den letzten 20 Sekunden bereits versendet
}
}
bool removed = account.OpenPositions.TryRemove(signal.TokenId, out var openPos);
// Defense-in-depth: Verify the removed position actually belongs to this trader
if (removed && openPos != null && openPos.SourceTraderId != signal.TraderId)
{
// Wrong trader! Put the position back and treat as not found.
account.OpenPositions.TryAdd(signal.TokenId, openPos);
removed = false;
openPos = null;
_logger.Info($"❌ Trade SELL [{signal.MarketQuestion}] [{shareType}] ignoriert:\n" +
$" Konto: {account.Name}\n" +
$" Begründung: Position gehört einem anderen Trader (Safety Check).");
}
if (!removed && !string.IsNullOrEmpty(signal.MarketSlug))
{
// Fallback matching must ALSO respect SourceTraderId!
var altPos = account.OpenPositions.Values.FirstOrDefault(p =>
p.MarketSlug == signal.MarketSlug && p.Outcome == signal.Outcome && p.SourceTraderId == signal.TraderId);
if (altPos != null)
{
removed = account.OpenPositions.TryRemove(altPos.TokenId, out openPos);
if (removed)
{
_logger.Info($"Fallback: Position für SELL über Slug+Outcome gefunden ({altPos.TokenId}) statt TokenId ({signal.TokenId})");
signal.TokenId = altPos.TokenId; // Fix for further processing
}
}
}
if (removed && openPos != null)
{
if (account.IsDemo)
{
if (_db != null) _db.GetCollection<Position>($"demo_positions_{account.AccountId}").Delete(signal.TokenId);
decimal exitUsd = openPos.Size * signal.Price;
decimal realizedPnl = exitUsd - openPos.AmountUsd;
_state.GlobalPnl += realizedPnl;
account.UpdateBalance(account.AvailableBalance + exitUsd);
if (_db != null) _db.GetCollection<AccountState>("accounts").Upsert(account);
var ct = new ClosedTrade
{
TradeId = _state.GetNextTradeId(),
AccountId = account.AccountId,
SourceTraderId = signal.TraderId,
IsDemo = account.IsDemo,
MarketSlug = signal.MarketSlug,
MarketQuestion = signal.MarketQuestion,
Outcome = signal.Outcome,
Side = signal.Side,
EntryPrice = openPos.EntryPrice,
ExitPrice = signal.Price,
Size = openPos.Size,
RealizedPnl = realizedPnl,
PnlPercent = openPos.AmountUsd > 0 ? (realizedPnl / openPos.AmountUsd * 100m) : 0m,
OpenedAt = openPos.OpenedAt,
ClosedAt = DateTime.UtcNow,
ExitReason = signal.Reason
};
_closedTradeWriter.TryWrite(ct);
_logger.Trade($"✅ [DEMO GESCHLOSSEN]\n" +
$" Konto: {account.Name}\n" +
$" Markt: {signal.MarketQuestion}\n" +
$" SELL: {openPos.Size:F2} Shares [{shareType}] @ ${signal.Price:F3} (Gewinn: ${realizedPnl:F2})");
}
else
{
decimal sellLimit = 0.01m; // Market Order Fallback Limit (PolyMarket Safety)
decimal expectedUsdc = openPos.Size * sellLimit;
var exact = PolymarketClobClient.CalculateExactOrderAmounts(expectedUsdc, sellLimit, sellLimit, "SELL", "MARKET");
if (exact.shares <= 0)
{
_logger.TradeReasoning($"❌ Trade SELL [{signal.MarketQuestion}] [{shareType}] fehlgeschlagen!\n" +
$" Konto: {account.Name}\n" +
$" Grund: Mathematical Order Size Error (Dust Token).");
// We don't return to OpenPositions to let dust drop gracefully
return;
}
_logger.Info($"🌐 [LIVE-EXECUTION] Sende MARKET SELL an Polymarket CTF-Router...\n" +
$" Account: {account.Name}\n" +
$" Order: MARKET (Target: {signal.Price:F3})");
var result = await _clob.PlaceOrderAsync(account, signal.TokenId, signal.Side, expectedUsdc, sellLimit, "MARKET", _state.DebugOrderPayloadLog, isNegRisk);
if (result == "OK")
{
// Track order placement time for stale order cleanup / sync routines
_state.PendingOrderTimestamps[orderKey] = (DateTime.UtcNow, signal.TraderId);
_logger.Trade($"✅ [LIVE MARKET SELL PLATZIERT] - {account.Name} - MARKET Swept. Gewinne/Verluste in Kürze im API Sync sichtbar.");
}
else
{
// Call failed, log it so the user knows Sells are being attempted but failing.
_logger.TradeReasoning($"❌ Trade SELL [{signal.MarketQuestion}] [{shareType}] fehlgeschlagen!\n" +
$" Konto: {account.Name}\n" +
$" Grund: {result}\n" +
$" Aktion: Position bleibt vorerst im Portfolio erhalten.");
// Temporären Cooldown (5 Sek) setzen, um Log-Spam durch wiederholte API-Fehler zu vermeiden
_state.PendingOrderTimestamps[orderKey] = (DateTime.UtcNow.AddSeconds(-15), signal.TraderId);
// Reverse the TryRemove if it failed, so the next poll can try again
account.OpenPositions.TryAdd(signal.TokenId, openPos);
}
}
}
else
{
_logger.Info($"❌ Trade SELL [{signal.MarketQuestion}] [{shareType}] ignoriert:\n" +
$" Konto: {account.Name}\n" +
$" Begründung: Position nicht im Portfolio gefunden (möglicherweise zuvor gefiltert).");
}
}
}
finally
{
accountSemaphore.Release();
}
}
private bool IsPositionMarketActive(Position pos)
{
// O(1) RAM Lookup. Eliminated LiteDB queries for ultra-low latency.
if (_state.MarketCache.TryGetValue(pos.TokenId, out var md))
{
return !md.Closed;
}
// Defaults to active until cache hydrates.
// Better to assume active and restrict budget than auto-open budget on unknown markets.
return true;
}
}
}
+479
View File
@@ -0,0 +1,479 @@
using System;
using System.Threading;
using System.Threading.Channels;
using System.Threading.Tasks;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using PolyTraderSharp.Models;
using System.Collections.Concurrent;
using System.Linq;
namespace PolyTraderSharp.Services
{
public class CopyTradingEngine : BackgroundService
{
private readonly TradingState _state;
private readonly ChannelReader<CopySignal> _signalReader;
private readonly ChannelWriter<ClosedTrade> _closedTradeWriter;
private readonly TerminalLogger _logger;
private readonly PolymarketClobClient _clob;
private readonly PolymarketApiService _api;
private readonly LiteDB.ILiteDatabase _db;
private static readonly ConcurrentDictionary<string, MarketData> _marketCache = new(StringComparer.OrdinalIgnoreCase);
public CopyTradingEngine(
TradingState state,
ChannelReader<CopySignal> signalReader,
ChannelWriter<ClosedTrade> closedTradeWriter,
TerminalLogger logger,
PolymarketClobClient clob,
PolymarketApiService api,
LiteDB.ILiteDatabase db = null)
{
_state = state;
_signalReader = signalReader;
_closedTradeWriter = closedTradeWriter;
_logger = logger;
_clob = clob;
_api = api;
_db = db;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.Info("CopyTradingEngine Channel Listener started (Concurrent).");
var semaphore = new SemaphoreSlim(15, 15);
await foreach (var signal in _signalReader.ReadAllAsync(stoppingToken))
{
await semaphore.WaitAsync(stoppingToken);
_ = Task.Run(async () =>
{
try
{
await ProcessSignalAsync(signal);
}
catch (Exception ex)
{
_logger.Error($"Absturz im SignalProcessor: {ex.Message}");
}
finally
{
semaphore.Release();
}
}, stoppingToken);
}
}
private async Task ProcessSignalAsync(CopySignal signal)
{
if (_state.GlobalTradingPaused)
{
return;
}
// Internal System Signal (e.g. Demo Auto-Close)
if (signal.TraderId == 0)
{
var sysaccountTasks = new List<Task>();
foreach (var account in _state.Accounts.Values.Where(a => a.IsDemo && a.IsActive))
{
if (account.OpenPositions.ContainsKey(signal.TokenId))
{
sysaccountTasks.Add(ProcessAccountOrderAsync(account, null, signal));
}
}
await Task.WhenAll(sysaccountTasks);
return;
}
if (!_state.Traders.TryGetValue(signal.TraderId, out var trader) || !trader.IsActive)
return;
var accountTasks = new List<Task>();
foreach (var accountId in trader.AssignedAccountIds)
{
if (!_state.Accounts.TryGetValue(accountId, out var account) || !account.IsActive)
continue;
accountTasks.Add(ProcessAccountOrderAsync(account, trader, signal));
}
await Task.WhenAll(accountTasks);
}
private async Task ProcessAccountOrderAsync(AccountState account, TrackedTrader trader, CopySignal signal)
{
var mode = account.IsDemo ? _state.DemoTradingMode : _state.LiveTradingMode;
if (mode == TradingMode.Inactive)
return;
// Restrict BUY operations if mode is SellOnly
if (mode == TradingMode.SellOnly && signal.Side == "BUY")
return;
string shareType = string.IsNullOrEmpty(signal.Outcome) ? signal.Side : signal.Outcome;
// ==========================================
// PRE-FLIGHT RISK CHECKS (Before DB/API!)
// ==========================================
decimal exactShares = 0m;
decimal exactUsdc = 0m;
decimal orderPrice = signal.Price;
if (signal.Side == "BUY")
{
if (signal.Price > account.MaxBuyPrice && account.TotalBalance >= 500m)
{
_logger.TradeReasoning($"❌ Trade BUY [{signal.MarketQuestion}] [{shareType}] verworfen:\n" +
$" Konto: {account.Name}\n" +
$" Begründung: Preis (${signal.Price:F3}) übersteigt das MaxBuy Limit (${account.MaxBuyPrice:F3})");
return;
}
decimal investedInMarket = account.OpenPositions.TryGetValue(signal.TokenId, out var ep) ? ep.AmountUsd : 0m;
decimal minTrade = 1.0m;
decimal maxAllowed = account.TotalBalance * (account.MaxTradePercent / 100.0m);
// Low Balance Bypass (Stufen-System)
// Erhöht auf 1.20m um Puffer für das API Min-Limit von $1.00 zu gewährleisten
if (account.TotalBalance < 150m) maxAllowed = Math.Min(1.20m, Math.Max(account.AvailableBalance, 0m));
else if (account.TotalBalance < 500m) maxAllowed = Math.Min(3.0m, Math.Max(account.AvailableBalance, 0m));
decimal maxAmountToBuy = maxAllowed - investedInMarket;
decimal investedInMaster = account.OpenPositions.Values.Where(p => p.SourceTraderId == trader.Id).Sum(p => (decimal)p.AmountUsd);
decimal maxAllowedPerMaster = account.TotalBalance * (account.PerMasterLimit / 100.0m);
if ((investedInMaster + maxAmountToBuy) > maxAllowedPerMaster)
{
decimal pctInvested = account.TotalBalance > 0 ? (investedInMaster / account.TotalBalance) * 100m : 0m;
_logger.TradeReasoning($"❌ Trade BUY [{signal.MarketQuestion}] [{shareType}] verworfen:\n" +
$" Konto: {account.Name}\n" +
$" Begründung: PerMasterLimit ({account.PerMasterLimit:F1}%) erreicht. Bisher investiert in '{trader.DisplayName}': ${investedInMaster:F2} ({pctInvested:F1}%).");
return;
}
if (maxAmountToBuy < minTrade)
{
_logger.TradeReasoning($"❌ Trade BUY [{signal.MarketQuestion}] [{shareType}] verworfen:\n" +
$" Konto: {account.Name}\n" +
$" Begründung: Kauflimit (${maxAllowed:F2}) bereits in Markt investiert (${investedInMarket:F2}). Rest: ${maxAmountToBuy:F2} < MinTrade (${minTrade:F2})");
return;
}
if (maxAmountToBuy > account.AvailableBalance)
{
_logger.TradeReasoning($"❌ Trade BUY [{signal.MarketQuestion}] [{shareType}] verworfen:\n" +
$" Konto: {account.Name}\n" +
$" Begründung: Kontostand (${account.AvailableBalance:F2}) nicht ausreichend für errechnetes Size (${maxAmountToBuy:F2})");
return;
}
decimal desiredLimit = signal.Price * 1.05m;
orderPrice = Math.Min(desiredLimit, account.MaxBuyPrice);
if (orderPrice > 0.99m) orderPrice = 0.99m;
var exact = PolymarketClobClient.CalculateExactOrderAmounts(maxAmountToBuy, orderPrice, orderPrice, "BUY");
if (exact.shares <= 0 || exact.usdc > account.AvailableBalance)
{
_logger.TradeReasoning($"❌ Trade BUY [{signal.MarketQuestion}] [{shareType}] gestoppt:\n" +
$" Begründung: Mathematisch unmöglicher Trade ({exact.shares} Shares für ${exact.usdc:F2}). Kontostand (${account.AvailableBalance:F2}) reicht für Minimum nicht aus.");
return;
}
exactShares = exact.shares;
exactUsdc = exact.usdc;
}
else if (signal.Side == "SELL")
{
// PRE-FLIGHT SELL Check: Exists in portfolio?
// Allow fallback matching by slug and outcome if tokenId is slightly off or missing
var inPortfolio = account.OpenPositions.Values.FirstOrDefault(p => p.TokenId == signal.TokenId || (p.MarketSlug == signal.MarketSlug && p.Outcome == signal.Outcome));
if (inPortfolio == null)
{
_logger.Info($"❌ Trade SELL [{signal.MarketQuestion}] [{shareType}] ignoriert:\n" +
$" Konto: {account.Name}\n" +
$" Begründung: Position nicht im Portfolio gefunden (möglicherweise zuvor gefiltert).");
return;
}
}
// ==========================================
// EXPENSIVE DB/API MARKET LOOKUP
// ==========================================
bool isNegRisk = false;
if (_marketCache.TryGetValue(signal.TokenId, out var cachedData))
{
if (!string.IsNullOrEmpty(cachedData.Slug)) signal.MarketSlug = cachedData.Slug;
if (!string.IsNullOrEmpty(cachedData.Question)) signal.MarketQuestion = cachedData.Question;
if (cachedData.EndDate.HasValue) signal.EndDate = cachedData.EndDate;
isNegRisk = cachedData.NegRisk;
}
else if (_db != null)
{
try
{
var marketColl = _db.GetCollection<MarketData>("markets");
var marketData = marketColl.Find(x => x.ClobTokenIds != null && x.ClobTokenIds.Contains(signal.TokenId)).FirstOrDefault();
if (marketData == null && !string.IsNullOrEmpty(signal.TokenId))
{
var fetchedMarket = await _api.GetMarketByTokenIdAsync(signal.TokenId);
if (fetchedMarket != null) { marketColl.Upsert(fetchedMarket); marketData = fetchedMarket; }
}
if (marketData == null && !string.IsNullOrEmpty(signal.MarketSlug) && !signal.MarketSlug.StartsWith("0x"))
{
var fetchedMarkets = await _api.GetMarketsByEventSlugAsync(signal.MarketSlug);
foreach (var fetched in fetchedMarkets) {
marketColl.Upsert(fetched);
if (fetched.ClobTokenIds != null && fetched.ClobTokenIds.Contains(signal.TokenId)) marketData = fetched;
}
}
if (marketData != null)
{
if (!string.IsNullOrEmpty(marketData.Slug)) signal.MarketSlug = marketData.Slug;
if (!string.IsNullOrEmpty(marketData.Question)) signal.MarketQuestion = marketData.Question;
if (marketData.EndDate.HasValue) signal.EndDate = marketData.EndDate;
isNegRisk = marketData.NegRisk;
// Add to Cache for fast lookup
_marketCache[signal.TokenId] = marketData;
}
}
catch (Exception ex)
{
_logger.Warning($"Fehler beim Abrufen von MarketData für Token {signal.TokenId}: {ex.Message}");
}
}
// If BUY -> Invest
if (signal.Side == "BUY")
{
if (account.IsDemo)
{
var pos = new Position
{
TokenId = signal.TokenId,
MarketSlug = signal.MarketSlug,
SourceTraderId = trader.Id,
SourceTraderName = trader.DisplayName,
SourceTraderAddress = trader.WalletAddress,
MarketQuestion = signal.MarketQuestion,
Outcome = signal.Outcome,
Side = "BUY",
EntryPrice = orderPrice,
Size = exactShares,
AmountUsd = exactUsdc,
ExpiryDate = signal.EndDate ?? DateTime.UtcNow.AddDays(14)
};
_state.TotalCopyTrades++;
var finalPos = account.OpenPositions.AddOrUpdate(signal.TokenId, pos, (k, old) =>
{
old.Size += pos.Size;
old.AmountUsd += pos.AmountUsd;
old.EntryPrice = old.AmountUsd / old.Size; // weighted average
return old;
});
if (_db != null) _db.GetCollection<Position>($"demo_positions_{account.AccountId}").Upsert(finalPos);
account.UpdateBalance(account.AvailableBalance - exactUsdc);
if (_db != null) _db.GetCollection<AccountState>("accounts").Upsert(account);
_logger.Trade($"✅ [DEMO AUSGEFÜHRT]\n" +
$" Konto: {account.Name}\n" +
$" Markt: {signal.MarketQuestion}\n" +
$" BUY: {exactShares:F4} Shares [{shareType}] @ ${orderPrice:F3} (Gesamt: ${exactUsdc:F2})");
}
else
{
_logger.Info($"🌐 [LIVE-EXECUTION] Sende MARKET BUY an Polymarket CTF-Router...\n" +
$" Account: {account.Name}\n" +
$" Limit: ${orderPrice:F3} (Target: {signal.Price:F3} + 5%)");
var result = await _clob.PlaceOrderAsync(account, signal.TokenId, signal.Side, exactUsdc, orderPrice, "MARKET", _state.DebugOrderPayloadLog, isNegRisk);
if (result == "OK")
{
var pos = new Position
{
TokenId = signal.TokenId,
MarketSlug = signal.MarketSlug,
SourceTraderId = trader.Id,
SourceTraderName = trader.DisplayName,
SourceTraderAddress = trader.WalletAddress,
MarketQuestion = signal.MarketQuestion,
Outcome = signal.Outcome,
Side = "BUY",
EntryPrice = orderPrice, // Real execution price will update on next SyncOpenPositions poll
Size = exactShares,
AmountUsd = exactUsdc,
ExpiryDate = signal.EndDate ?? DateTime.UtcNow.AddDays(14)
};
_state.TotalCopyTrades++;
account.OpenPositions.AddOrUpdate(signal.TokenId, pos, (k, old) =>
{
old.Size += pos.Size;
old.AmountUsd += pos.AmountUsd;
old.EntryPrice = old.AmountUsd / old.Size;
return old;
});
account.UpdateBalance(account.AvailableBalance - exactUsdc);
if (_db != null) _db.GetCollection<AccountState>("accounts").Upsert(account);
if (_db != null)
{
var liveCol = _db.GetCollection<Position>($"open_positions_{account.AccountId}");
if (account.OpenPositions.TryGetValue(signal.TokenId, out var savedPos))
{
liveCol.Upsert(savedPos);
}
}
}
}
}
// If SELL -> Divest
else if (signal.Side == "SELL")
{
bool removed = account.OpenPositions.TryRemove(signal.TokenId, out var openPos);
if (!removed && !string.IsNullOrEmpty(signal.MarketSlug))
{
var altPos = account.OpenPositions.Values.FirstOrDefault(p => p.MarketSlug == signal.MarketSlug && p.Outcome == signal.Outcome);
if (altPos != null)
{
removed = account.OpenPositions.TryRemove(altPos.TokenId, out openPos);
if (removed)
{
_logger.Info($"Fallback: Position für SELL über Slug+Outcome gefunden ({altPos.TokenId}) statt TokenId ({signal.TokenId})");
signal.TokenId = altPos.TokenId; // Fix for further processing
}
}
}
if (removed)
{
if (account.IsDemo)
{
if (_db != null) _db.GetCollection<Position>($"demo_positions_{account.AccountId}").Delete(signal.TokenId);
decimal exitUsd = openPos.Size * signal.Price;
decimal realizedPnl = exitUsd - openPos.AmountUsd;
_state.GlobalPnl += realizedPnl;
account.UpdateBalance(account.AvailableBalance + exitUsd);
if (_db != null) _db.GetCollection<AccountState>("accounts").Upsert(account);
var ct = new ClosedTrade
{
TradeId = _state.TotalCopyTrades,
AccountId = account.AccountId,
SourceTraderId = signal.TraderId,
IsDemo = account.IsDemo,
MarketSlug = signal.MarketSlug,
MarketQuestion = signal.MarketQuestion,
Outcome = signal.Outcome,
Side = signal.Side,
EntryPrice = openPos.EntryPrice,
ExitPrice = signal.Price,
Size = openPos.Size,
RealizedPnl = realizedPnl,
PnlPercent = openPos.AmountUsd > 0 ? (realizedPnl / openPos.AmountUsd * 100m) : 0m,
OpenedAt = openPos.OpenedAt,
ClosedAt = DateTime.UtcNow,
ExitReason = signal.Reason
};
_closedTradeWriter.TryWrite(ct);
_logger.Trade($"✅ [DEMO GESCHLOSSEN]\n" +
$" Konto: {account.Name}\n" +
$" Markt: {signal.MarketQuestion}\n" +
$" SELL: {openPos.Size:F2} Shares [{shareType}] @ ${signal.Price:F3} (Gewinn: ${realizedPnl:F2})");
}
else
{
decimal sellLimit = 0.01m; // Slippage Limit (Min $0.01/share)
decimal maxInvest = openPos.Size * sellLimit;
var exact = PolymarketClobClient.CalculateExactOrderAmounts(maxInvest, sellLimit, sellLimit, "SELL", "MARKET");
if (exact.shares <= 0)
{
_logger.TradeReasoning($"❌ Trade SELL [{signal.MarketQuestion}] [{shareType}] fehlgeschlagen!\n" +
$" Konto: {account.Name}\n" +
$" Grund: Mathematical Order Size Error (Dust Token).");
account.OpenPositions.TryAdd(signal.TokenId, openPos);
return;
}
_logger.Info($"🌐 [LIVE-EXECUTION] Sende MARKET SELL an Polymarket CTF-Router...\n" +
$" Account: {account.Name}\n" +
$" Typ: MARKET Order");
var result = await _clob.PlaceOrderAsync(account, signal.TokenId, signal.Side, maxInvest, sellLimit, "MARKET", _state.DebugOrderPayloadLog, isNegRisk);
if (result == "OK")
{
// Simulate fill at expected price for immediate UI accuracy
// (Exact executed amounts will auto-correct on next SyncOpenPositions poll)
decimal exitUsd = exact.shares * signal.Price;
decimal realizedPnl = exitUsd - openPos.AmountUsd;
_state.GlobalPnl += realizedPnl;
account.UpdateBalance(account.AvailableBalance + exitUsd);
if (_db != null) _db.GetCollection<AccountState>("accounts").Upsert(account);
var ct = new ClosedTrade
{
TradeId = _state.TotalCopyTrades,
AccountId = account.AccountId,
SourceTraderId = signal.TraderId,
IsDemo = false,
MarketSlug = signal.MarketSlug,
MarketQuestion = signal.MarketQuestion,
Outcome = signal.Outcome,
Side = signal.Side,
EntryPrice = openPos.EntryPrice,
ExitPrice = signal.Price,
Size = openPos.Size,
RealizedPnl = realizedPnl,
PnlPercent = openPos.AmountUsd > 0 ? (realizedPnl / openPos.AmountUsd * 100m) : 0m,
OpenedAt = openPos.OpenedAt,
ClosedAt = DateTime.UtcNow,
ExitReason = signal.Reason
};
_closedTradeWriter.TryWrite(ct);
_logger.Trade($"✅ [LIVE GESCHLOSSEN] - {account.Name} - Gewinne/Verluste in Kürze im API Sync sichtbar.");
}
else
{
// Call failed, log it so the user knows Sells are being attempted but failing.
_logger.TradeReasoning($"❌ Trade SELL [{signal.MarketQuestion}] [{shareType}] fehlgeschlagen!\n" +
$" Konto: {account.Name}\n" +
$" Grund: {result}\n" +
$" Aktion: Position bleibt vorerst im Portfolio erhalten.");
// Reverse the TryRemove if it failed, so the next poll can try again
account.OpenPositions.TryAdd(signal.TokenId, openPos);
}
}
}
else
{
_logger.Info($"❌ Trade SELL [{signal.MarketQuestion}] [{shareType}] ignoriert:\n" +
$" Konto: {account.Name}\n" +
$" Begründung: Position nicht im Portfolio gefunden (möglicherweise zuvor gefiltert).");
}
}
}
}
}
+588
View File
@@ -0,0 +1,588 @@
using System;
using System.Threading;
using System.Threading.Channels;
using System.Threading.Tasks;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using PolyTraderSharp.Models;
using System.Collections.Concurrent;
using System.Linq;
namespace PolyTraderSharp.Services
{
public class CopyTradingEngine : BackgroundService
{
private readonly TradingState _state;
private readonly ChannelReader<CopySignal> _signalReader;
private readonly ChannelWriter<ClosedTrade> _closedTradeWriter;
private readonly TerminalLogger _logger;
private readonly PolymarketClobClient _clob;
private readonly PolymarketApiService _api;
private readonly LiteDB.ILiteDatabase? _db;
private static readonly ConcurrentDictionary<string, MarketData> _marketCache = new(StringComparer.OrdinalIgnoreCase);
private readonly ConcurrentDictionary<int, SemaphoreSlim> _accountSemaphores = new();
public CopyTradingEngine(
TradingState state,
ChannelReader<CopySignal> signalReader,
ChannelWriter<ClosedTrade> closedTradeWriter,
TerminalLogger logger,
PolymarketClobClient clob,
PolymarketApiService api,
LiteDB.ILiteDatabase? db = null)
{
_state = state;
_signalReader = signalReader;
_closedTradeWriter = closedTradeWriter;
_logger = logger;
_clob = clob;
_api = api;
_db = db;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.Info("CopyTradingEngine Channel Listener started (Concurrent).");
var semaphore = new SemaphoreSlim(15, 15);
await foreach (var signal in _signalReader.ReadAllAsync(stoppingToken))
{
await semaphore.WaitAsync(stoppingToken);
_ = Task.Run(async () =>
{
try
{
await ProcessSignalAsync(signal);
}
catch (Exception ex)
{
_logger.Error($"Absturz im SignalProcessor: {ex.Message}");
}
finally
{
semaphore.Release();
}
}, stoppingToken);
}
}
private async Task ProcessSignalAsync(CopySignal signal)
{
if (_state.GlobalTradingPaused)
{
return;
}
// Internal System Signal (e.g. Demo Auto-Close)
if (signal.TraderId == 0)
{
var sysaccountTasks = new List<Task>();
foreach (var account in _state.Accounts.Values.Where(a => a.IsDemo && a.IsActive))
{
if (account.OpenPositions.ContainsKey(signal.TokenId))
{
sysaccountTasks.Add(ProcessAccountOrderAsync(account, null, signal));
}
}
await Task.WhenAll(sysaccountTasks);
return;
}
if (!_state.Traders.TryGetValue(signal.TraderId, out var trader) || !trader.IsActive)
return;
// --- Pre-Fetch EndDate für Time Limits ---
if (signal.Side == "BUY" && !signal.EndDate.HasValue && !string.IsNullOrEmpty(signal.TokenId))
{
if (_marketCache.TryGetValue(signal.TokenId, out var cachedData) && cachedData.EndDate.HasValue)
{
signal.EndDate = cachedData.EndDate;
}
else if (_api != null)
{
try
{
var fetchedMarket = await _api.GetMarketByTokenIdAsync(signal.TokenId);
if (fetchedMarket != null)
{
if (fetchedMarket.EndDate.HasValue) signal.EndDate = fetchedMarket.EndDate;
_marketCache[signal.TokenId] = fetchedMarket;
if (_db != null) _db.GetCollection<MarketData>("markets").Upsert(fetchedMarket);
}
}
catch (Exception ex)
{
_logger.Warning($"Fehler beim Pre-Fetch MarketData: {ex.Message}");
}
}
}
// -----------------------------------------
var accountTasks = new List<Task>();
foreach (var accountId in trader.AssignedAccountIds)
{
if (!_state.Accounts.TryGetValue(accountId, out var account) || !account.IsActive)
continue;
accountTasks.Add(ProcessAccountOrderAsync(account, trader, signal));
}
await Task.WhenAll(accountTasks);
}
private async Task ProcessAccountOrderAsync(AccountState account, TrackedTrader? trader, CopySignal signal)
{
var mode = account.IsDemo ? _state.DemoTradingMode : _state.LiveTradingMode;
if (mode == TradingMode.Inactive)
return;
// Restrict BUY operations if mode is SellOnly
if (mode == TradingMode.SellOnly && signal.Side == "BUY")
return;
string shareType = string.IsNullOrEmpty(signal.Outcome) ? signal.Side : signal.Outcome;
var accountSemaphore = _accountSemaphores.GetOrAdd(account.AccountId, _ => new SemaphoreSlim(1, 1));
await accountSemaphore.WaitAsync();
try
{
// ==========================================
// OPEN ORDER CLEANUP (LIVE ACCOUNTS ONLY)
// ==========================================
// Wenn ein neues Signal für diesen Markt reinkommt, prüfen wir auf veraltete offene Orders.
// Identische Preise bleiben bestehen. Abweichende verhindern ungültiges Blockieren von Funds.
if (!account.IsDemo && !string.IsNullOrEmpty(signal.TokenId))
{
await _clob.CancelConflictingOrdersAsync(account, signal.TokenId, signal.Price, signal.Side);
}
if (_marketCache.TryGetValue(signal.TokenId, out var fastCachedData))
{
if (fastCachedData.EndDate.HasValue) signal.EndDate = fastCachedData.EndDate;
}
// ==========================================
// PRE-FLIGHT RISK CHECKS (Before DB/API!)
// ==========================================
decimal exactShares = 0m;
decimal exactUsdc = 0m;
decimal orderPrice = signal.Price;
if (signal.Side == "BUY")
{
if (signal.Price > account.MaxBuyPrice && account.TotalBalance >= 500m)
{
_logger.TradeReasoning($"❌ Trade BUY [{signal.MarketQuestion}] [{shareType}] verworfen:\n" +
$" Konto: {account.Name}\n" +
$" Begründung: Preis (${signal.Price:F3}) übersteigt das MaxBuy Limit (${account.MaxBuyPrice:F3})");
return;
}
decimal investedInMarket = account.OpenPositions.TryGetValue(signal.TokenId, out var ep) ? ep.AmountUsd : 0m;
decimal minTrade = 1.0m;
decimal maxAllowed = account.TotalBalance * (account.PerMarketLimit / 100.0m);
// Low Balance Bypass (Stufen-System) ONLY IF NOT YET INVESTED
if (investedInMarket == 0)
{
if (account.TotalBalance < 150m) maxAllowed = Math.Min(1.20m, Math.Max(account.AvailableBalance, 0m));
else if (account.TotalBalance < 500m) maxAllowed = Math.Min(3.0m, Math.Max(account.AvailableBalance, 0m));
}
decimal maxAmountToBuy = maxAllowed - investedInMarket;
decimal investedInMaster = trader != null ? account.OpenPositions.Values.Where(p => p.SourceTraderId == trader.Id).Sum(p => (decimal)p.AmountUsd) : 0m;
decimal maxAllowedPerMaster = account.TotalBalance * (account.PerMasterLimit / 100.0m);
if (trader != null && (investedInMaster + maxAmountToBuy) > maxAllowedPerMaster)
{
decimal pctInvested = account.TotalBalance > 0 ? (investedInMaster / account.TotalBalance) * 100m : 0m;
_logger.TradeReasoning($"❌ Trade BUY [{signal.MarketQuestion}] [{shareType}] verworfen:\n" +
$" Konto: {account.Name}\n" +
$" Begründung: PerMasterLimit ({account.PerMasterLimit:F1}%) erreicht. Bisher investiert in '{trader.DisplayName}': ${investedInMaster:F2} ({pctInvested:F1}%).");
return;
}
// Time Limit Restriktion
double hoursLeft = signal.EndDate.HasValue ? (signal.EndDate.Value - DateTime.UtcNow).TotalHours : 999999;
decimal applicableTimeLimitPct;
decimal investedInTimeframe = 0m;
string timeframeLabel = "";
var openVals = account.OpenPositions.Values;
if (hoursLeft < 6)
{
applicableTimeLimitPct = account.perMaxTime6h;
timeframeLabel = "< 6h";
investedInTimeframe = openVals.Where(p => p.ExpiryDate.HasValue && (p.ExpiryDate.Value - DateTime.UtcNow).TotalHours < 6).Sum(p => (decimal)p.AmountUsd);
}
else if (hoursLeft < 24)
{
applicableTimeLimitPct = account.perMaxTime24h;
timeframeLabel = "< 24h";
investedInTimeframe = openVals.Where(p => p.ExpiryDate.HasValue && (p.ExpiryDate.Value - DateTime.UtcNow).TotalHours >= 6 && (p.ExpiryDate.Value - DateTime.UtcNow).TotalHours < 24).Sum(p => (decimal)p.AmountUsd);
}
else if (hoursLeft < 72)
{
applicableTimeLimitPct = account.perMaxTime72h;
timeframeLabel = "< 72h";
investedInTimeframe = openVals.Where(p => p.ExpiryDate.HasValue && (p.ExpiryDate.Value - DateTime.UtcNow).TotalHours >= 24 && (p.ExpiryDate.Value - DateTime.UtcNow).TotalHours < 72).Sum(p => (decimal)p.AmountUsd);
}
else
{
applicableTimeLimitPct = account.perMaxTimeNone;
timeframeLabel = "> 72h";
investedInTimeframe = openVals.Where(p => !p.ExpiryDate.HasValue || (p.ExpiryDate.Value - DateTime.UtcNow).TotalHours >= 72).Sum(p => (decimal)p.AmountUsd);
}
decimal maxAllowedTimeframe = account.TotalBalance * (applicableTimeLimitPct / 100.0m);
if ((investedInTimeframe + maxAmountToBuy) > maxAllowedTimeframe)
{
decimal remainingForTimeframe = maxAllowedTimeframe - investedInTimeframe;
if (remainingForTimeframe < minTrade)
{
_logger.TradeReasoning($"❌ Trade BUY [{signal.MarketQuestion}] [{shareType}] verworfen:\n" +
$" Konto: {account.Name}\n" +
$" Begründung: TimeLimit '{timeframeLabel}' ({applicableTimeLimitPct:F1}%) erreicht. Bisher investiert: ${investedInTimeframe:F2} / max. ${maxAllowedTimeframe:F2}");
return;
}
else
{
maxAmountToBuy = remainingForTimeframe;
}
}
if (maxAmountToBuy < minTrade)
{
_logger.TradeReasoning($"❌ Trade BUY [{signal.MarketQuestion}] [{shareType}] verworfen:\n" +
$" Konto: {account.Name}\n" +
$" Begründung: Kauflimit (${maxAllowed:F2}) bereits in Markt investiert (${investedInMarket:F2}). Rest: ${maxAmountToBuy:F2} < MinTrade (${minTrade:F2})");
return;
}
if (maxAmountToBuy > account.AvailableBalance)
{
_logger.TradeReasoning($"❌ Trade BUY [{signal.MarketQuestion}] [{shareType}] verworfen:\n" +
$" Konto: {account.Name}\n" +
$" Begründung: Kontostand (${account.AvailableBalance:F2}) nicht ausreichend für errechnetes Size (${maxAmountToBuy:F2})");
return;
}
decimal desiredLimit = signal.Price * 1.05m;
orderPrice = Math.Min(desiredLimit, account.MaxBuyPrice);
if (orderPrice > 0.99m) orderPrice = 0.99m;
var exact = PolymarketClobClient.CalculateExactOrderAmounts(maxAmountToBuy, orderPrice, orderPrice, "BUY");
if (exact.shares <= 0 || exact.usdc > account.AvailableBalance)
{
_logger.TradeReasoning($"❌ Trade BUY [{signal.MarketQuestion}] [{shareType}] gestoppt:\n" +
$" Begründung: Mathematisch unmöglicher Trade ({exact.shares} Shares für ${exact.usdc:F2}). Kontostand (${account.AvailableBalance:F2}) reicht für Minimum nicht aus.");
return;
}
exactShares = exact.shares;
exactUsdc = exact.usdc;
}
else if (signal.Side == "SELL")
{
// PRE-FLIGHT SELL Check: Exists in portfolio?
// Allow fallback matching by slug and outcome if tokenId is slightly off or missing
var inPortfolio = account.OpenPositions.Values.FirstOrDefault(p => p.TokenId == signal.TokenId || (p.MarketSlug == signal.MarketSlug && p.Outcome == signal.Outcome));
if (inPortfolio == null)
{
_logger.Info($"❌ Trade SELL [{signal.MarketQuestion}] [{shareType}] ignoriert:\n" +
$" Konto: {account.Name}\n" +
$" Begründung: Position nicht im Portfolio gefunden (möglicherweise zuvor gefiltert).");
return;
}
}
// ==========================================
// EXPENSIVE DB/API MARKET LOOKUP
// ==========================================
bool isNegRisk = false;
if (_marketCache.TryGetValue(signal.TokenId, out var cachedData))
{
if (!string.IsNullOrEmpty(cachedData.Slug)) signal.MarketSlug = cachedData.Slug;
if (!string.IsNullOrEmpty(cachedData.Question)) signal.MarketQuestion = cachedData.Question;
if (cachedData.EndDate.HasValue) signal.EndDate = cachedData.EndDate;
isNegRisk = cachedData.NegRisk;
}
else if (_db != null)
{
try
{
var marketColl = _db.GetCollection<MarketData>("markets");
var marketData = marketColl.Find(x => x.ClobTokenIds != null && x.ClobTokenIds.Contains(signal.TokenId)).FirstOrDefault();
if (marketData == null && !string.IsNullOrEmpty(signal.TokenId))
{
var fetchedMarket = await _api.GetMarketByTokenIdAsync(signal.TokenId);
if (fetchedMarket != null) { marketColl.Upsert(fetchedMarket); marketData = fetchedMarket; }
}
if (marketData == null && !string.IsNullOrEmpty(signal.MarketSlug) && !signal.MarketSlug.StartsWith("0x"))
{
var fetchedMarkets = await _api.GetMarketsByEventSlugAsync(signal.MarketSlug);
foreach (var fetched in fetchedMarkets) {
marketColl.Upsert(fetched);
if (fetched.ClobTokenIds != null && fetched.ClobTokenIds.Contains(signal.TokenId)) marketData = fetched;
}
}
if (marketData != null)
{
if (!string.IsNullOrEmpty(marketData.Slug)) signal.MarketSlug = marketData.Slug;
if (!string.IsNullOrEmpty(marketData.Question)) signal.MarketQuestion = marketData.Question;
if (marketData.EndDate.HasValue) signal.EndDate = marketData.EndDate;
isNegRisk = marketData.NegRisk;
// Add to Cache for fast lookup
_marketCache[signal.TokenId] = marketData;
}
}
catch (Exception ex)
{
_logger.Warning($"Fehler beim Abrufen von MarketData für Token {signal.TokenId}: {ex.Message}");
}
}
// If BUY -> Invest
if (signal.Side == "BUY")
{
if (account.IsDemo)
{
var pos = new Position
{
TokenId = signal.TokenId,
MarketSlug = signal.MarketSlug,
SourceTraderId = trader?.Id ?? 0,
SourceTraderName = trader?.DisplayName ?? "System",
SourceTraderAddress = trader?.WalletAddress ?? "",
MarketQuestion = signal.MarketQuestion,
Outcome = signal.Outcome,
Side = "BUY",
EntryPrice = orderPrice,
Size = exactShares,
AmountUsd = exactUsdc,
ExpiryDate = signal.EndDate ?? DateTime.UtcNow.AddDays(14)
};
_state.TotalCopyTrades++;
var finalPos = account.OpenPositions.AddOrUpdate(signal.TokenId, pos, (k, old) =>
{
old.Size += pos.Size;
old.AmountUsd += pos.AmountUsd;
old.EntryPrice = old.AmountUsd / old.Size; // weighted average
return old;
});
if (_db != null) _db.GetCollection<Position>($"demo_positions_{account.AccountId}").Upsert(finalPos);
account.UpdateBalance(account.AvailableBalance - exactUsdc);
if (_db != null) _db.GetCollection<AccountState>("accounts").Upsert(account);
_logger.Trade($"✅ [DEMO AUSGEFÜHRT]\n" +
$" Konto: {account.Name}\n" +
$" Markt: {signal.MarketQuestion}\n" +
$" BUY: {exactShares:F4} Shares [{shareType}] @ ${orderPrice:F3} (Gesamt: ${exactUsdc:F2})");
}
else
{
_logger.Info($"🌐 [LIVE-EXECUTION] Sende MARKET BUY an Polymarket CTF-Router...\n" +
$" Account: {account.Name}\n" +
$" Limit: ${orderPrice:F3} (Target: {signal.Price:F3} + 5%)");
var result = await _clob.PlaceOrderAsync(account, signal.TokenId, signal.Side, exactUsdc, orderPrice, "MARKET", _state.DebugOrderPayloadLog, isNegRisk);
if (result == "OK")
{
var pos = new Position
{
TokenId = signal.TokenId,
MarketSlug = signal.MarketSlug,
SourceTraderId = trader?.Id ?? 0,
SourceTraderName = trader?.DisplayName ?? "System",
SourceTraderAddress = trader?.WalletAddress ?? "",
MarketQuestion = signal.MarketQuestion,
Outcome = signal.Outcome,
Side = "BUY",
EntryPrice = orderPrice, // Real execution price will update on next SyncOpenPositions poll
Size = exactShares,
AmountUsd = exactUsdc,
ExpiryDate = signal.EndDate ?? DateTime.UtcNow.AddDays(14)
};
_state.TotalCopyTrades++;
account.OpenPositions.AddOrUpdate(signal.TokenId, pos, (k, old) =>
{
old.Size += pos.Size;
old.AmountUsd += pos.AmountUsd;
old.EntryPrice = old.AmountUsd / old.Size;
return old;
});
account.UpdateBalance(account.AvailableBalance - exactUsdc);
if (_db != null) _db.GetCollection<AccountState>("accounts").Upsert(account);
if (_db != null)
{
var liveCol = _db.GetCollection<Position>($"open_positions_{account.AccountId}");
if (account.OpenPositions.TryGetValue(signal.TokenId, out var savedPos))
{
liveCol.Upsert(savedPos);
}
}
}
}
}
// If SELL -> Divest
else if (signal.Side == "SELL")
{
bool removed = account.OpenPositions.TryRemove(signal.TokenId, out var openPos);
if (!removed && !string.IsNullOrEmpty(signal.MarketSlug))
{
var altPos = account.OpenPositions.Values.FirstOrDefault(p => p.MarketSlug == signal.MarketSlug && p.Outcome == signal.Outcome);
if (altPos != null)
{
removed = account.OpenPositions.TryRemove(altPos.TokenId, out openPos);
if (removed)
{
_logger.Info($"Fallback: Position für SELL über Slug+Outcome gefunden ({altPos.TokenId}) statt TokenId ({signal.TokenId})");
signal.TokenId = altPos.TokenId; // Fix for further processing
}
}
}
if (removed && openPos != null)
{
if (account.IsDemo)
{
if (_db != null) _db.GetCollection<Position>($"demo_positions_{account.AccountId}").Delete(signal.TokenId);
decimal exitUsd = openPos.Size * signal.Price;
decimal realizedPnl = exitUsd - openPos.AmountUsd;
_state.GlobalPnl += realizedPnl;
account.UpdateBalance(account.AvailableBalance + exitUsd);
if (_db != null) _db.GetCollection<AccountState>("accounts").Upsert(account);
var ct = new ClosedTrade
{
TradeId = _state.TotalCopyTrades,
AccountId = account.AccountId,
SourceTraderId = signal.TraderId,
IsDemo = account.IsDemo,
MarketSlug = signal.MarketSlug,
MarketQuestion = signal.MarketQuestion,
Outcome = signal.Outcome,
Side = signal.Side,
EntryPrice = openPos.EntryPrice,
ExitPrice = signal.Price,
Size = openPos.Size,
RealizedPnl = realizedPnl,
PnlPercent = openPos.AmountUsd > 0 ? (realizedPnl / openPos.AmountUsd * 100m) : 0m,
OpenedAt = openPos.OpenedAt,
ClosedAt = DateTime.UtcNow,
ExitReason = signal.Reason
};
_closedTradeWriter.TryWrite(ct);
_logger.Trade($"✅ [DEMO GESCHLOSSEN]\n" +
$" Konto: {account.Name}\n" +
$" Markt: {signal.MarketQuestion}\n" +
$" SELL: {openPos.Size:F2} Shares [{shareType}] @ ${signal.Price:F3} (Gewinn: ${realizedPnl:F2})");
}
else
{
decimal sellLimit = 0.01m; // Slippage Limit (Min $0.01/share)
decimal maxInvest = openPos.Size * sellLimit;
var exact = PolymarketClobClient.CalculateExactOrderAmounts(maxInvest, sellLimit, sellLimit, "SELL", "MARKET");
if (exact.shares <= 0)
{
_logger.TradeReasoning($"❌ Trade SELL [{signal.MarketQuestion}] [{shareType}] fehlgeschlagen!\n" +
$" Konto: {account.Name}\n" +
$" Grund: Mathematical Order Size Error (Dust Token).");
account.OpenPositions.TryAdd(signal.TokenId, openPos);
return;
}
_logger.Info($"🌐 [LIVE-EXECUTION] Sende MARKET SELL an Polymarket CTF-Router...\n" +
$" Account: {account.Name}\n" +
$" Typ: MARKET Order");
var result = await _clob.PlaceOrderAsync(account, signal.TokenId, signal.Side, maxInvest, sellLimit, "MARKET", _state.DebugOrderPayloadLog, isNegRisk);
if (result == "OK")
{
// Simulate fill at expected price for immediate UI accuracy
// (Exact executed amounts will auto-correct on next SyncOpenPositions poll)
decimal exitUsd = exact.shares * signal.Price;
decimal realizedPnl = exitUsd - openPos.AmountUsd;
_state.GlobalPnl += realizedPnl;
account.UpdateBalance(account.AvailableBalance + exitUsd);
if (_db != null) _db.GetCollection<AccountState>("accounts").Upsert(account);
var ct = new ClosedTrade
{
TradeId = _state.TotalCopyTrades,
AccountId = account.AccountId,
SourceTraderId = signal.TraderId,
IsDemo = false,
MarketSlug = signal.MarketSlug,
MarketQuestion = signal.MarketQuestion,
Outcome = signal.Outcome,
Side = signal.Side,
EntryPrice = openPos.EntryPrice,
ExitPrice = signal.Price,
Size = openPos.Size,
RealizedPnl = realizedPnl,
PnlPercent = openPos.AmountUsd > 0 ? (realizedPnl / openPos.AmountUsd * 100m) : 0m,
OpenedAt = openPos.OpenedAt,
ClosedAt = DateTime.UtcNow,
ExitReason = signal.Reason
};
_closedTradeWriter.TryWrite(ct);
_logger.Trade($"✅ [LIVE GESCHLOSSEN] - {account.Name} - Gewinne/Verluste in Kürze im API Sync sichtbar.");
}
else
{
// Call failed, log it so the user knows Sells are being attempted but failing.
_logger.TradeReasoning($"❌ Trade SELL [{signal.MarketQuestion}] [{shareType}] fehlgeschlagen!\n" +
$" Konto: {account.Name}\n" +
$" Grund: {result}\n" +
$" Aktion: Position bleibt vorerst im Portfolio erhalten.");
// Reverse the TryRemove if it failed, so the next poll can try again
account.OpenPositions.TryAdd(signal.TokenId, openPos);
}
}
}
else
{
_logger.Info($"❌ Trade SELL [{signal.MarketQuestion}] [{shareType}] ignoriert:\n" +
$" Konto: {account.Name}\n" +
$" Begründung: Position nicht im Portfolio gefunden (möglicherweise zuvor gefiltert).");
}
}
}
finally
{
accountSemaphore.Release();
}
}
}
}
+602
View File
@@ -0,0 +1,602 @@
using System;
using System.Threading;
using System.Threading.Channels;
using System.Threading.Tasks;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using PolyTraderSharp.Models;
using System.Collections.Concurrent;
using System.Linq;
namespace PolyTraderSharp.Services
{
public class CopyTradingEngine : BackgroundService
{
private readonly TradingState _state;
private readonly ChannelReader<CopySignal> _signalReader;
private readonly ChannelWriter<ClosedTrade> _closedTradeWriter;
private readonly TerminalLogger _logger;
private readonly PolymarketClobClient _clob;
private readonly PolymarketApiService _api;
private readonly LiteDB.ILiteDatabase? _db;
private static readonly ConcurrentDictionary<string, MarketData> _marketCache = new(StringComparer.OrdinalIgnoreCase);
private readonly ConcurrentDictionary<int, SemaphoreSlim> _accountSemaphores = new();
public CopyTradingEngine(
TradingState state,
ChannelReader<CopySignal> signalReader,
ChannelWriter<ClosedTrade> closedTradeWriter,
TerminalLogger logger,
PolymarketClobClient clob,
PolymarketApiService api,
LiteDB.ILiteDatabase? db = null)
{
_state = state;
_signalReader = signalReader;
_closedTradeWriter = closedTradeWriter;
_logger = logger;
_clob = clob;
_api = api;
_db = db;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.Info("CopyTradingEngine Channel Listener started (Concurrent).");
var semaphore = new SemaphoreSlim(15, 15);
await foreach (var signal in _signalReader.ReadAllAsync(stoppingToken))
{
await semaphore.WaitAsync(stoppingToken);
_ = Task.Run(async () =>
{
try
{
await ProcessSignalAsync(signal);
}
catch (Exception ex)
{
_logger.Error($"Absturz im SignalProcessor: {ex.Message}");
}
finally
{
semaphore.Release();
}
}, stoppingToken);
}
}
private async Task ProcessSignalAsync(CopySignal signal)
{
if (_state.GlobalTradingPaused)
{
return;
}
// Internal System Signal (e.g. Demo Auto-Close)
if (signal.TraderId == 0)
{
var sysaccountTasks = new List<Task>();
foreach (var account in _state.Accounts.Values.Where(a => a.IsDemo && a.IsActive))
{
if (account.OpenPositions.ContainsKey(signal.TokenId))
{
sysaccountTasks.Add(ProcessAccountOrderAsync(account, null, signal));
}
}
await Task.WhenAll(sysaccountTasks);
return;
}
if (!_state.Traders.TryGetValue(signal.TraderId, out var trader) || !trader.IsActive)
return;
// --- Pre-Fetch EndDate für Time Limits ---
if (signal.Side == "BUY" && !signal.EndDate.HasValue && !string.IsNullOrEmpty(signal.TokenId))
{
if (_marketCache.TryGetValue(signal.TokenId, out var cachedData) && cachedData.EndDate.HasValue)
{
signal.EndDate = cachedData.EndDate;
}
else if (_api != null)
{
try
{
var fetchedMarket = await _api.GetMarketByTokenIdAsync(signal.TokenId);
if (fetchedMarket != null)
{
if (fetchedMarket.EndDate.HasValue) signal.EndDate = fetchedMarket.EndDate;
_marketCache[signal.TokenId] = fetchedMarket;
if (_db != null) _db.GetCollection<MarketData>("markets").Upsert(fetchedMarket);
}
}
catch (Exception ex)
{
_logger.Warning($"Fehler beim Pre-Fetch MarketData: {ex.Message}");
}
}
}
// -----------------------------------------
var accountTasks = new List<Task>();
foreach (var accountId in trader.AssignedAccountIds)
{
if (!_state.Accounts.TryGetValue(accountId, out var account) || !account.IsActive)
continue;
accountTasks.Add(ProcessAccountOrderAsync(account, trader, signal));
}
await Task.WhenAll(accountTasks);
}
private async Task ProcessAccountOrderAsync(AccountState account, TrackedTrader? trader, CopySignal signal)
{
var mode = account.IsDemo ? _state.DemoTradingMode : _state.LiveTradingMode;
if (mode == TradingMode.Inactive)
return;
// Restrict BUY operations if mode is SellOnly
if (mode == TradingMode.SellOnly && signal.Side == "BUY")
return;
string shareType = string.IsNullOrEmpty(signal.Outcome) ? signal.Side : signal.Outcome;
var accountSemaphore = _accountSemaphores.GetOrAdd(account.AccountId, _ => new SemaphoreSlim(1, 1));
await accountSemaphore.WaitAsync();
try
{
// ==========================================
// OPEN ORDER CLEANUP (LIVE ACCOUNTS ONLY)
// ==========================================
// Wenn ein neues Signal für diesen Markt reinkommt, prüfen wir auf veraltete offene Orders.
// Identische Preise bleiben bestehen. Abweichende verhindern ungültiges Blockieren von Funds.
if (!account.IsDemo && !string.IsNullOrEmpty(signal.TokenId))
{
await _clob.CancelConflictingOrdersAsync(account, signal.TokenId, signal.Price, signal.Side);
}
if (_marketCache.TryGetValue(signal.TokenId, out var fastCachedData))
{
if (fastCachedData.EndDate.HasValue) signal.EndDate = fastCachedData.EndDate;
}
// ==========================================
// PRE-FLIGHT RISK CHECKS (Before DB/API!)
// ==========================================
decimal exactShares = 0m;
decimal exactUsdc = 0m;
decimal orderPrice = signal.Price;
if (signal.Side == "BUY")
{
if (signal.Price > account.MaxBuyPrice && account.TotalBalance >= 500m)
{
_logger.TradeReasoning($"❌ Trade BUY [{signal.MarketQuestion}] [{shareType}] verworfen:\n" +
$" Konto: {account.Name}\n" +
$" Begründung: Preis (${signal.Price:F3}) übersteigt das MaxBuy Limit (${account.MaxBuyPrice:F3})");
return;
}
decimal investedInMarket = account.OpenPositions.TryGetValue(signal.TokenId, out var ep) ? ep.AmountUsd : 0m;
decimal minTrade = 1.0m;
decimal maxAllowed = account.TotalBalance * (account.PerMarketLimit / 100.0m);
// Low Balance Bypass (Stufen-System) ONLY IF NOT YET INVESTED
if (investedInMarket == 0)
{
if (account.TotalBalance < 150m) maxAllowed = Math.Min(1.20m, Math.Max(account.AvailableBalance, 0m));
else if (account.TotalBalance < 500m) maxAllowed = Math.Min(3.0m, Math.Max(account.AvailableBalance, 0m));
if (_state.SixSharesMinimum && account.TotalBalance < 500m)
{
// Adjust maxAllowed to cover at least 6 shares * order limit price.
decimal desiredLimitForSix = signal.Price * 1.05m;
decimal orderPriceForSix = Math.Min(desiredLimitForSix, account.MaxBuyPrice);
if (orderPriceForSix > 0.99m) orderPriceForSix = 0.99m;
decimal costSix = 6m * orderPriceForSix;
if (costSix > maxAllowed)
{
maxAllowed = Math.Min(costSix, Math.Max(account.AvailableBalance, 0m));
}
}
}
decimal maxAmountToBuy = maxAllowed - investedInMarket;
decimal investedInMaster = trader != null ? account.OpenPositions.Values.Where(p => p.SourceTraderId == trader.Id).Sum(p => (decimal)p.AmountUsd) : 0m;
decimal maxAllowedPerMaster = account.TotalBalance * (account.PerMasterLimit / 100.0m);
if (trader != null && (investedInMaster + maxAmountToBuy) > maxAllowedPerMaster)
{
decimal pctInvested = account.TotalBalance > 0 ? (investedInMaster / account.TotalBalance) * 100m : 0m;
_logger.TradeReasoning($"❌ Trade BUY [{signal.MarketQuestion}] [{shareType}] verworfen:\n" +
$" Konto: {account.Name}\n" +
$" Begründung: PerMasterLimit ({account.PerMasterLimit:F1}%) erreicht. Bisher investiert in '{trader.DisplayName}': ${investedInMaster:F2} ({pctInvested:F1}%).");
return;
}
// Time Limit Restriktion
double hoursLeft = signal.EndDate.HasValue ? (signal.EndDate.Value - DateTime.UtcNow).TotalHours : 999999;
decimal applicableTimeLimitPct;
decimal investedInTimeframe = 0m;
string timeframeLabel = "";
var openVals = account.OpenPositions.Values;
if (hoursLeft < 6)
{
applicableTimeLimitPct = account.perMaxTime6h;
timeframeLabel = "< 6h";
investedInTimeframe = openVals.Where(p => p.ExpiryDate.HasValue && (p.ExpiryDate.Value - DateTime.UtcNow).TotalHours < 6).Sum(p => (decimal)p.AmountUsd);
}
else if (hoursLeft < 24)
{
applicableTimeLimitPct = account.perMaxTime24h;
timeframeLabel = "< 24h";
investedInTimeframe = openVals.Where(p => p.ExpiryDate.HasValue && (p.ExpiryDate.Value - DateTime.UtcNow).TotalHours >= 6 && (p.ExpiryDate.Value - DateTime.UtcNow).TotalHours < 24).Sum(p => (decimal)p.AmountUsd);
}
else if (hoursLeft < 72)
{
applicableTimeLimitPct = account.perMaxTime72h;
timeframeLabel = "< 72h";
investedInTimeframe = openVals.Where(p => p.ExpiryDate.HasValue && (p.ExpiryDate.Value - DateTime.UtcNow).TotalHours >= 24 && (p.ExpiryDate.Value - DateTime.UtcNow).TotalHours < 72).Sum(p => (decimal)p.AmountUsd);
}
else
{
applicableTimeLimitPct = account.perMaxTimeNone;
timeframeLabel = "> 72h";
investedInTimeframe = openVals.Where(p => !p.ExpiryDate.HasValue || (p.ExpiryDate.Value - DateTime.UtcNow).TotalHours >= 72).Sum(p => (decimal)p.AmountUsd);
}
decimal maxAllowedTimeframe = account.TotalBalance * (applicableTimeLimitPct / 100.0m);
if ((investedInTimeframe + maxAmountToBuy) > maxAllowedTimeframe)
{
decimal remainingForTimeframe = maxAllowedTimeframe - investedInTimeframe;
if (remainingForTimeframe < minTrade)
{
_logger.TradeReasoning($"❌ Trade BUY [{signal.MarketQuestion}] [{shareType}] verworfen:\n" +
$" Konto: {account.Name}\n" +
$" Begründung: TimeLimit '{timeframeLabel}' ({applicableTimeLimitPct:F1}%) erreicht. Bisher investiert: ${investedInTimeframe:F2} / max. ${maxAllowedTimeframe:F2}");
return;
}
else
{
maxAmountToBuy = remainingForTimeframe;
}
}
if (maxAmountToBuy < minTrade)
{
_logger.TradeReasoning($"❌ Trade BUY [{signal.MarketQuestion}] [{shareType}] verworfen:\n" +
$" Konto: {account.Name}\n" +
$" Begründung: Kauflimit (${maxAllowed:F2}) bereits in Markt investiert (${investedInMarket:F2}). Rest: ${maxAmountToBuy:F2} < MinTrade (${minTrade:F2})");
return;
}
if (maxAmountToBuy > account.AvailableBalance)
{
_logger.TradeReasoning($"❌ Trade BUY [{signal.MarketQuestion}] [{shareType}] verworfen:\n" +
$" Konto: {account.Name}\n" +
$" Begründung: Kontostand (${account.AvailableBalance:F2}) nicht ausreichend für errechnetes Size (${maxAmountToBuy:F2})");
return;
}
decimal desiredLimit = signal.Price * 1.05m;
orderPrice = Math.Min(desiredLimit, account.MaxBuyPrice);
if (orderPrice > 0.99m) orderPrice = 0.99m;
var exact = PolymarketClobClient.CalculateExactOrderAmounts(maxAmountToBuy, orderPrice, orderPrice, "BUY");
if (exact.shares <= 0 || exact.usdc > account.AvailableBalance)
{
_logger.TradeReasoning($"❌ Trade BUY [{signal.MarketQuestion}] [{shareType}] gestoppt:\n" +
$" Begründung: Mathematisch unmöglicher Trade ({exact.shares} Shares für ${exact.usdc:F2}). Kontostand (${account.AvailableBalance:F2}) reicht für Minimum nicht aus.");
return;
}
exactShares = exact.shares;
exactUsdc = exact.usdc;
}
else if (signal.Side == "SELL")
{
// PRE-FLIGHT SELL Check: Exists in portfolio?
// Allow fallback matching by slug and outcome if tokenId is slightly off or missing
var inPortfolio = account.OpenPositions.Values.FirstOrDefault(p => p.TokenId == signal.TokenId || (p.MarketSlug == signal.MarketSlug && p.Outcome == signal.Outcome));
if (inPortfolio == null)
{
_logger.Info($"❌ Trade SELL [{signal.MarketQuestion}] [{shareType}] ignoriert:\n" +
$" Konto: {account.Name}\n" +
$" Begründung: Position nicht im Portfolio gefunden (möglicherweise zuvor gefiltert).");
return;
}
}
// ==========================================
// EXPENSIVE DB/API MARKET LOOKUP
// ==========================================
bool isNegRisk = false;
if (_marketCache.TryGetValue(signal.TokenId, out var cachedData))
{
if (!string.IsNullOrEmpty(cachedData.Slug)) signal.MarketSlug = cachedData.Slug;
if (!string.IsNullOrEmpty(cachedData.Question)) signal.MarketQuestion = cachedData.Question;
if (cachedData.EndDate.HasValue) signal.EndDate = cachedData.EndDate;
isNegRisk = cachedData.NegRisk;
}
else if (_db != null)
{
try
{
var marketColl = _db.GetCollection<MarketData>("markets");
var marketData = marketColl.Find(x => x.ClobTokenIds != null && x.ClobTokenIds.Contains(signal.TokenId)).FirstOrDefault();
if (marketData == null && !string.IsNullOrEmpty(signal.TokenId))
{
var fetchedMarket = await _api.GetMarketByTokenIdAsync(signal.TokenId);
if (fetchedMarket != null) { marketColl.Upsert(fetchedMarket); marketData = fetchedMarket; }
}
if (marketData == null && !string.IsNullOrEmpty(signal.MarketSlug) && !signal.MarketSlug.StartsWith("0x"))
{
var fetchedMarkets = await _api.GetMarketsByEventSlugAsync(signal.MarketSlug);
foreach (var fetched in fetchedMarkets) {
marketColl.Upsert(fetched);
if (fetched.ClobTokenIds != null && fetched.ClobTokenIds.Contains(signal.TokenId)) marketData = fetched;
}
}
if (marketData != null)
{
if (!string.IsNullOrEmpty(marketData.Slug)) signal.MarketSlug = marketData.Slug;
if (!string.IsNullOrEmpty(marketData.Question)) signal.MarketQuestion = marketData.Question;
if (marketData.EndDate.HasValue) signal.EndDate = marketData.EndDate;
isNegRisk = marketData.NegRisk;
// Add to Cache for fast lookup
_marketCache[signal.TokenId] = marketData;
}
}
catch (Exception ex)
{
_logger.Warning($"Fehler beim Abrufen von MarketData für Token {signal.TokenId}: {ex.Message}");
}
}
// If BUY -> Invest
if (signal.Side == "BUY")
{
if (account.IsDemo)
{
var pos = new Position
{
TokenId = signal.TokenId,
MarketSlug = signal.MarketSlug,
SourceTraderId = trader?.Id ?? 0,
SourceTraderName = trader?.DisplayName ?? "System",
SourceTraderAddress = trader?.WalletAddress ?? "",
MarketQuestion = signal.MarketQuestion,
Outcome = signal.Outcome,
Side = "BUY",
EntryPrice = orderPrice,
Size = exactShares,
AmountUsd = exactUsdc,
ExpiryDate = signal.EndDate ?? DateTime.UtcNow.AddDays(14)
};
_state.TotalCopyTrades++;
var finalPos = account.OpenPositions.AddOrUpdate(signal.TokenId, pos, (k, old) =>
{
old.Size += pos.Size;
old.AmountUsd += pos.AmountUsd;
old.EntryPrice = old.AmountUsd / old.Size; // weighted average
return old;
});
if (_db != null) _db.GetCollection<Position>($"demo_positions_{account.AccountId}").Upsert(finalPos);
account.UpdateBalance(account.AvailableBalance - exactUsdc);
if (_db != null) _db.GetCollection<AccountState>("accounts").Upsert(account);
_logger.Trade($"✅ [DEMO AUSGEFÜHRT]\n" +
$" Konto: {account.Name}\n" +
$" Markt: {signal.MarketQuestion}\n" +
$" BUY: {exactShares:F4} Shares [{shareType}] @ ${orderPrice:F3} (Gesamt: ${exactUsdc:F2})");
}
else
{
_logger.Info($"🌐 [LIVE-EXECUTION] Sende MARKET BUY an Polymarket CTF-Router...\n" +
$" Account: {account.Name}\n" +
$" Limit: ${orderPrice:F3} (Target: {signal.Price:F3} + 5%)");
var result = await _clob.PlaceOrderAsync(account, signal.TokenId, signal.Side, exactUsdc, orderPrice, "MARKET", _state.DebugOrderPayloadLog, isNegRisk);
if (result == "OK")
{
var pos = new Position
{
TokenId = signal.TokenId,
MarketSlug = signal.MarketSlug,
SourceTraderId = trader?.Id ?? 0,
SourceTraderName = trader?.DisplayName ?? "System",
SourceTraderAddress = trader?.WalletAddress ?? "",
MarketQuestion = signal.MarketQuestion,
Outcome = signal.Outcome,
Side = "BUY",
EntryPrice = orderPrice, // Real execution price will update on next SyncOpenPositions poll
Size = exactShares,
AmountUsd = exactUsdc,
ExpiryDate = signal.EndDate ?? DateTime.UtcNow.AddDays(14)
};
_state.TotalCopyTrades++;
account.OpenPositions.AddOrUpdate(signal.TokenId, pos, (k, old) =>
{
old.Size += pos.Size;
old.AmountUsd += pos.AmountUsd;
old.EntryPrice = old.AmountUsd / old.Size;
return old;
});
account.UpdateBalance(account.AvailableBalance - exactUsdc);
if (_db != null) _db.GetCollection<AccountState>("accounts").Upsert(account);
if (_db != null)
{
var liveCol = _db.GetCollection<Position>($"open_positions_{account.AccountId}");
if (account.OpenPositions.TryGetValue(signal.TokenId, out var savedPos))
{
liveCol.Upsert(savedPos);
}
}
}
}
}
// If SELL -> Divest
else if (signal.Side == "SELL")
{
bool removed = account.OpenPositions.TryRemove(signal.TokenId, out var openPos);
if (!removed && !string.IsNullOrEmpty(signal.MarketSlug))
{
var altPos = account.OpenPositions.Values.FirstOrDefault(p => p.MarketSlug == signal.MarketSlug && p.Outcome == signal.Outcome);
if (altPos != null)
{
removed = account.OpenPositions.TryRemove(altPos.TokenId, out openPos);
if (removed)
{
_logger.Info($"Fallback: Position für SELL über Slug+Outcome gefunden ({altPos.TokenId}) statt TokenId ({signal.TokenId})");
signal.TokenId = altPos.TokenId; // Fix for further processing
}
}
}
if (removed && openPos != null)
{
if (account.IsDemo)
{
if (_db != null) _db.GetCollection<Position>($"demo_positions_{account.AccountId}").Delete(signal.TokenId);
decimal exitUsd = openPos.Size * signal.Price;
decimal realizedPnl = exitUsd - openPos.AmountUsd;
_state.GlobalPnl += realizedPnl;
account.UpdateBalance(account.AvailableBalance + exitUsd);
if (_db != null) _db.GetCollection<AccountState>("accounts").Upsert(account);
var ct = new ClosedTrade
{
TradeId = _state.TotalCopyTrades,
AccountId = account.AccountId,
SourceTraderId = signal.TraderId,
IsDemo = account.IsDemo,
MarketSlug = signal.MarketSlug,
MarketQuestion = signal.MarketQuestion,
Outcome = signal.Outcome,
Side = signal.Side,
EntryPrice = openPos.EntryPrice,
ExitPrice = signal.Price,
Size = openPos.Size,
RealizedPnl = realizedPnl,
PnlPercent = openPos.AmountUsd > 0 ? (realizedPnl / openPos.AmountUsd * 100m) : 0m,
OpenedAt = openPos.OpenedAt,
ClosedAt = DateTime.UtcNow,
ExitReason = signal.Reason
};
_closedTradeWriter.TryWrite(ct);
_logger.Trade($"✅ [DEMO GESCHLOSSEN]\n" +
$" Konto: {account.Name}\n" +
$" Markt: {signal.MarketQuestion}\n" +
$" SELL: {openPos.Size:F2} Shares [{shareType}] @ ${signal.Price:F3} (Gewinn: ${realizedPnl:F2})");
}
else
{
decimal sellLimit = 0.01m; // Slippage Limit (Min $0.01/share)
decimal maxInvest = openPos.Size * sellLimit;
var exact = PolymarketClobClient.CalculateExactOrderAmounts(maxInvest, sellLimit, sellLimit, "SELL", "MARKET");
if (exact.shares <= 0)
{
_logger.TradeReasoning($"❌ Trade SELL [{signal.MarketQuestion}] [{shareType}] fehlgeschlagen!\n" +
$" Konto: {account.Name}\n" +
$" Grund: Mathematical Order Size Error (Dust Token).");
account.OpenPositions.TryAdd(signal.TokenId, openPos);
return;
}
_logger.Info($"🌐 [LIVE-EXECUTION] Sende MARKET SELL an Polymarket CTF-Router...\n" +
$" Account: {account.Name}\n" +
$" Typ: MARKET Order");
var result = await _clob.PlaceOrderAsync(account, signal.TokenId, signal.Side, maxInvest, sellLimit, "MARKET", _state.DebugOrderPayloadLog, isNegRisk);
if (result == "OK")
{
// Simulate fill at expected price for immediate UI accuracy
// (Exact executed amounts will auto-correct on next SyncOpenPositions poll)
decimal exitUsd = exact.shares * signal.Price;
decimal realizedPnl = exitUsd - openPos.AmountUsd;
_state.GlobalPnl += realizedPnl;
account.UpdateBalance(account.AvailableBalance + exitUsd);
if (_db != null) _db.GetCollection<AccountState>("accounts").Upsert(account);
var ct = new ClosedTrade
{
TradeId = _state.TotalCopyTrades,
AccountId = account.AccountId,
SourceTraderId = signal.TraderId,
IsDemo = false,
MarketSlug = signal.MarketSlug,
MarketQuestion = signal.MarketQuestion,
Outcome = signal.Outcome,
Side = signal.Side,
EntryPrice = openPos.EntryPrice,
ExitPrice = signal.Price,
Size = openPos.Size,
RealizedPnl = realizedPnl,
PnlPercent = openPos.AmountUsd > 0 ? (realizedPnl / openPos.AmountUsd * 100m) : 0m,
OpenedAt = openPos.OpenedAt,
ClosedAt = DateTime.UtcNow,
ExitReason = signal.Reason
};
_closedTradeWriter.TryWrite(ct);
_logger.Trade($"✅ [LIVE GESCHLOSSEN] - {account.Name} - Gewinne/Verluste in Kürze im API Sync sichtbar.");
}
else
{
// Call failed, log it so the user knows Sells are being attempted but failing.
_logger.TradeReasoning($"❌ Trade SELL [{signal.MarketQuestion}] [{shareType}] fehlgeschlagen!\n" +
$" Konto: {account.Name}\n" +
$" Grund: {result}\n" +
$" Aktion: Position bleibt vorerst im Portfolio erhalten.");
// Reverse the TryRemove if it failed, so the next poll can try again
account.OpenPositions.TryAdd(signal.TokenId, openPos);
}
}
}
else
{
_logger.Info($"❌ Trade SELL [{signal.MarketQuestion}] [{shareType}] ignoriert:\n" +
$" Konto: {account.Name}\n" +
$" Begründung: Position nicht im Portfolio gefunden (möglicherweise zuvor gefiltert).");
}
}
}
finally
{
accountSemaphore.Release();
}
}
}
}
+15
View File
@@ -0,0 +1,15 @@
using System.ComponentModel;
using PolyTraderSharp.Models;
namespace PolyTraderSharp.Services
{
public class JobManager
{
public BindingList<JobStatusRow> Jobs { get; } = new BindingList<JobStatusRow>();
public void RegisterJob(JobStatusRow job)
{
Jobs.Add(job);
}
}
}
+174
View File
@@ -0,0 +1,174 @@
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using MongoDB.Driver;
using PolyTraderSharp.Extensions;
using PolyTraderSharp.Models;
using PolyTraderSharp.Services;
namespace PolyTraderSharp.Services
{
public class MarketSyncService : BackgroundService
{
private readonly PolymarketApiService _apiService;
private readonly IMongoDatabase _db;
private readonly TerminalLogger _logger;
private readonly JobStatusRow _jobStatus;
private readonly TradingState _state;
public MarketSyncService(PolymarketApiService apiService, IMongoDatabase db, TerminalLogger logger, JobManager jobManager, TradingState state)
{
_apiService = apiService;
_db = db;
_logger = logger;
_state = state;
_jobStatus = new JobStatusRow
{
JobName = "Market Data Sync",
Description = "Polls Polymarket Gamma API for the 1000 newest markets.",
StatusText = "Pending Initial Delay..."
};
_jobStatus.ManualTriggerAction = async () =>
{
string oldStatus = _jobStatus.StatusText;
_jobStatus.StatusText = "Running (Manual)...";
await SyncMarketsAsync();
_jobStatus.StatusText = "Idle";
};
jobManager.RegisterJob(_jobStatus);
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.Info("MarketSyncService started. Will sync markets every 1 hour.");
// Give the app some time to start up before initial sync
await Task.Delay(TimeSpan.FromSeconds(10), stoppingToken);
_jobStatus.StatusText = "Idle";
while (!stoppingToken.IsCancellationRequested)
{
if (_jobStatus.IsEnabled)
{
try
{
_jobStatus.StatusText = "Running (Scheduled)...";
await SyncMarketsAsync();
_jobStatus.LastRun = DateTime.Now;
}
catch (Exception ex)
{
_logger.Error($"MarketSyncService loop error: {ex.Message}");
_jobStatus.StatusText = "Error!";
}
finally
{
if (_jobStatus.StatusText != "Error!")
_jobStatus.StatusText = "Idle";
}
}
else
{
_jobStatus.StatusText = "Paused";
}
// Sleep for 3 minutes to keep the Cache extremely fresh against high-frequency listings
_jobStatus.NextRun = DateTime.Now.AddMinutes(3);
await Task.Delay(TimeSpan.FromMinutes(3), stoppingToken);
}
}
private async Task SyncMarketsAsync()
{
_logger.Info("Syncing newest markets from Polymarket API...");
var newMarkets = await _apiService.GetRecentMarketsAsync(1000);
if (newMarkets.Count == 0)
{
_logger.Warning("No markets returned from Polymarket API during sync.");
return;
}
var col = _db.GetCollection<MarketData>("markets");
col.EnsureIndex(x => x.Id);
int inserted = 0;
int updated = 0;
foreach (var market in newMarkets)
{
var existing = col.LiteFindOne(x => x.Id == market.Id);
if (existing == null)
{
col.Insert(market);
inserted++;
// NEW: Hot-Load active markets directly into RAM Cache
if (!market.Closed && !string.IsNullOrEmpty(market.ClobTokenIds))
{
try
{
var tokenIds = System.Text.Json.JsonSerializer.Deserialize<List<string>>(market.ClobTokenIds);
if (tokenIds != null)
{
foreach(var t in tokenIds)
{
_state.MarketCache[t] = market;
}
}
} catch { } // Ignore JSON parse error if malformed
}
}
else
{
// Update dynamic fields like EndDate, Active, Closed
existing.EndDate = market.EndDate;
existing.Active = market.Active;
existing.Closed = market.Closed;
existing.NegRisk = market.NegRisk;
// The API can sometimes be slow to assign ClobTokenIds. Update them if we got new ones.
if (string.IsNullOrEmpty(existing.ClobTokenIds) && !string.IsNullOrEmpty(market.ClobTokenIds))
{
existing.ClobTokenIds = market.ClobTokenIds;
}
col.Update(existing);
updated++;
// Keep RAM cache synchronized to prevent using stale active/closed flags
if (!string.IsNullOrEmpty(existing.ClobTokenIds))
{
try
{
var tokenIds = System.Text.Json.JsonSerializer.Deserialize<List<string>>(existing.ClobTokenIds);
if (tokenIds != null)
{
foreach(var t in tokenIds)
{
if (!existing.Closed)
{
// Unconditionally keep active markets hot in the cache
_state.MarketCache[t] = existing;
}
else
{
// Only update if it is already there (e.g. to flag it as closed for running logic)
if (_state.MarketCache.ContainsKey(t))
_state.MarketCache[t] = existing;
}
}
}
} catch { } // Ignore JSON parse error if malformed
}
}
}
_logger.Info($"Market Sync Complete: {inserted} new markets, {updated} updated.");
}
}
}
+197
View File
@@ -0,0 +1,197 @@
using System;
using MongoDB.Driver;
using PolyTraderSharp.Extensions;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Hosting;
using PolyTraderSharp.Models;
namespace PolyTraderSharp.Services
{
public class MasterTraderAnalyticsJob : BackgroundService
{
private readonly TradingState _state;
private readonly TerminalLogger _logger;
private readonly IMongoDatabase _db;
private readonly JobStatusRow _jobStatus;
private readonly PolymarketApiService _api;
public MasterTraderAnalyticsJob(TradingState state, TerminalLogger logger, IMongoDatabase db, JobManager jobManager, PolymarketApiService api)
{
_state = state;
_logger = logger;
_db = db;
_api = api;
_jobStatus = new JobStatusRow
{
JobName = "MasterTrader History",
Description = "Überwacht die Performance aller Master-Trader (P&L, Winrate 7D).",
StatusText = "Pending Initial Delay..."
};
_jobStatus.ManualTriggerAction = async () =>
{
_jobStatus.StatusText = "Running (Manual)...";
await RunHistoryAnalyticsAsync();
_jobStatus.StatusText = "Idle";
_jobStatus.LastRun = DateTime.Now;
};
jobManager.RegisterJob(_jobStatus);
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
await Task.Delay(TimeSpan.FromSeconds(20), stoppingToken); // Start after other jobs
while (!stoppingToken.IsCancellationRequested)
{
if (_jobStatus.IsEnabled)
{
try
{
_jobStatus.StatusText = "Running (Scheduled)...";
await RunHistoryAnalyticsAsync();
_jobStatus.LastRun = DateTime.Now;
}
catch (Exception ex)
{
_logger.Error($"Error in MasterTraderAnalyticsJob: {ex.Message}");
_jobStatus.StatusText = "Error!";
}
finally
{
if (_jobStatus.StatusText != "Error!") _jobStatus.StatusText = "Idle";
}
}
else
{
_jobStatus.StatusText = "Paused";
}
// Run twice a day (every 12 hours)
_jobStatus.NextRun = DateTime.Now.AddHours(12);
await Task.Delay(TimeSpan.FromHours(12), stoppingToken);
}
}
public async Task RunHistoryAnalyticsAsync()
{
try
{
_logger.Info("🔄 Starte Master-Trader Historien-Download und Performance-Analyse...");
var historyColl = _db.GetCollection<MasterTraderHistoryRecord>("mt_history");
historyColl.EnsureIndex(x => x.TraderId);
historyColl.EnsureIndex(x => x.ClosedAt);
DateTime cutoff7Days = DateTime.UtcNow.AddDays(-7);
var tradersToAnalyze = _state.Traders.Values.Where(t => t.IsActive && !string.IsNullOrEmpty(t.WalletAddress)).ToList();
foreach (var trader in tradersToAnalyze)
{
try
{
// 1. Fetch History from Data API (100 is usually enough for 7 days)
var closedPositions = await _api.SyncClosedPositionsAsync(trader.WalletAddress, 200);
if (closedPositions.Count == 0)
{
continue; // Might be deleted or no history
}
int inserted = 0;
foreach (var cp in closedPositions)
{
// parse timestamp
DateTime closedTs = DateTime.UnixEpoch;
if (cp.TryGetProperty("timestamp", out var tsProp))
{
if (tsProp.ValueKind == JsonValueKind.Number)
{
long tsRaw = tsProp.GetInt64();
// if it's 13 digits (ms) vs 10 digits (s)
if (tsRaw > 1000000000000) closedTs = DateTimeOffset.FromUnixTimeMilliseconds(tsRaw).UtcDateTime;
else closedTs = DateTimeOffset.FromUnixTimeSeconds(tsRaw).UtcDateTime;
}
else if (tsProp.ValueKind == JsonValueKind.String && long.TryParse(tsProp.GetString(), out long tsStrRaw))
{
if (tsStrRaw > 1000000000000) closedTs = DateTimeOffset.FromUnixTimeMilliseconds(tsStrRaw).UtcDateTime;
else closedTs = DateTimeOffset.FromUnixTimeSeconds(tsStrRaw).UtcDateTime;
}
}
// If trade is older than 14 days, ignore parsing to save DB space
if (closedTs < DateTime.UtcNow.AddDays(-14)) continue;
string tokenId = cp.TryGetProperty("asset", out var aProp) ? aProp.GetString() ?? "" : "";
decimal pnl = 0m;
if (cp.TryGetProperty("realizedPnl", out var pProp))
{
if (pProp.ValueKind == JsonValueKind.Number) pnl = pProp.GetDecimal();
else if (pProp.ValueKind == JsonValueKind.String && decimal.TryParse(pProp.GetString(), System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out decimal nPnl))
{
pnl = nPnl;
}
}
// We can approximate uniqueness with TokenId & exact Time (+- 2 seconds)
DateTime windowStart = closedTs.AddSeconds(-2);
DateTime windowEnd = closedTs.AddSeconds(2);
bool exists = historyColl.LiteFindOne(x => x.TraderId == trader.Id && x.TokenId == tokenId && x.ClosedAt >= windowStart && x.ClosedAt <= windowEnd) != null;
if (!exists)
{
var record = new MasterTraderHistoryRecord
{
TraderId = trader.Id,
TokenId = tokenId,
ClosedAt = closedTs,
RealizedPnl = pnl
};
historyColl.Insert(record);
inserted++;
}
}
// Sleep to respect 10/s limits or general rate limits
await Task.Delay(200);
// 2. Calculate Stats from DB
var last7DaysTrades = historyColl.LiteFind(x => x.TraderId == trader.Id && x.ClosedAt >= cutoff7Days).ToList();
trader.TotalTrades = last7DaysTrades.Count;
trader.TotalPnl = (double)last7DaysTrades.Sum(x => x.RealizedPnl);
// Treat positive PnL as win
trader.WinningTrades = last7DaysTrades.Count(x => x.RealizedPnl > 0);
trader.Winrate30t = trader.TotalTrades > 0 ? Math.Round(((double)trader.WinningTrades / trader.TotalTrades) * 100, 2) : 0;
// Save updated trader to DB so UI updates
var tColl = _db.GetCollection<TrackedTrader>("tracked_traders");
tColl.Update(trader);
if (inserted > 0 && trader.TotalTrades > 0)
{
_logger.Info($"📊 [MasterTrader: {trader.DisplayName}] - {inserted} neue Trades geladen. 7D: {trader.TotalTrades} Trades | PnL: ${trader.TotalPnl:F2} | Winrate: {trader.Winrate30t}%");
}
}
catch (Exception exInner)
{
_logger.Error($"Error processing history for MasterTrader {trader.DisplayName}: {exInner}");
}
}
_logger.Info("✅ Master-Trader Historien-Analyse abgeschlossen.");
}
catch (Exception ex)
{
_logger.Error($"MasterTraderAnalyticsJob Exception: {ex}");
}
}
}
}
+164
View File
@@ -0,0 +1,164 @@
using System;
using MongoDB.Driver;
using PolyTraderSharp.Extensions;
using System.Diagnostics;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Hosting;
using PolyTraderSharp.Models;
namespace PolyTraderSharp.Services
{
public class MullvadVpnService : BackgroundService
{
private readonly TerminalLogger _logger;
private ServerSettings _settings;
private readonly string _settingsPath = "server_settings.xml";
private bool _isConnected = false;
private int _consecutiveFailures = 0;
private readonly int _maxRetries = 3;
public bool IsConnected => _isConnected;
public MullvadVpnService(TerminalLogger logger)
{
_logger = logger;
_settings = ServerSettings.Load(_settingsPath);
}
public void ReloadSettings()
{
_settings = ServerSettings.Load(_settingsPath);
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
if (_settings.VpnEnabled)
{
await HealthCheckAsync();
}
await Task.Delay(TimeSpan.FromSeconds(30), stoppingToken);
}
}
public async Task<bool> HealthCheckAsync()
{
if (!_settings.VpnEnabled) return true;
string status = await RunCliAsync("status");
if (status.Contains("Connected"))
{
_isConnected = true;
_consecutiveFailures = 0;
return true;
}
// Try reconnecting
_logger.Warning("VPN is not connected. Attempting to reconnect...");
if (await ConnectAsync()) return true;
_consecutiveFailures++;
if (_consecutiveFailures >= _maxRetries)
{
_logger.Error($"VPN unrecoverable after {_maxRetries} retries.");
}
return false;
}
public async Task<bool> ConnectAsync()
{
if (!_settings.VpnEnabled) return true;
string status = await RunCliAsync("status");
if (status.Contains("Connected"))
{
if (string.IsNullOrEmpty(_settings.VpnLocation) || status.Contains(_settings.VpnLocation, StringComparison.OrdinalIgnoreCase))
{
_logger.Info($"VPN already connected tightly to {_settings.VpnLocation}");
_isConnected = true;
_consecutiveFailures = 0;
return true;
}
}
if (!string.IsNullOrEmpty(_settings.MullvadAccount))
{
await RunCliAsync($"account login {_settings.MullvadAccount}");
await Task.Delay(1000);
}
await RunCliAsync("lan set allow");
if (!string.IsNullOrEmpty(_settings.VpnLocation))
{
await RunCliAsync($"relay set location {_settings.VpnLocation}");
await Task.Delay(1000);
}
await RunCliAsync("connect");
for (int i = 0; i < 10; i++)
{
await Task.Delay(2000);
string check = await RunCliAsync("status");
if (check.Contains("Connected"))
{
_isConnected = true;
_consecutiveFailures = 0;
_logger.Info($"VPN connected successfully: {check.Trim()}");
return true;
}
}
_logger.Error("VPN failed to connect after waiting");
_consecutiveFailures++;
return false;
}
public async Task<bool> DisconnectAsync()
{
string result = await RunCliAsync("disconnect");
_isConnected = false;
_logger.Info("VPN disconnected.");
return true;
}
private async Task<string> RunCliAsync(string args)
{
try
{
if (!File.Exists(_settings.MullvadCliPath))
{
_logger.Error($"Mullvad CLI not found at: {_settings.MullvadCliPath}");
return string.Empty;
}
var psi = new ProcessStartInfo
{
FileName = _settings.MullvadCliPath,
Arguments = args,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
};
using var process = Process.Start(psi);
if (process == null) return string.Empty;
await process.WaitForExitAsync();
string output = await process.StandardOutput.ReadToEndAsync();
string err = await process.StandardError.ReadToEndAsync();
return string.IsNullOrWhiteSpace(output) ? err : output;
}
catch (Exception ex)
{
_logger.Error($"Mullvad CLI exc: {ex.Message}");
return string.Empty;
}
}
}
}
+97
View File
@@ -0,0 +1,97 @@
using System.Threading.Channels;
using MongoDB.Driver;
using PolyTraderSharp.Extensions;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using PolyTraderSharp.Models;
namespace PolyTraderSharp.Services
{
public class PersistenceService : BackgroundService
{
private readonly ChannelReader<ClosedTrade> _tradeReader;
private readonly IMongoDatabase _db;
private readonly TerminalLogger _logger;
private readonly JobStatusRow _jobStatus;
public PersistenceService(ChannelReader<ClosedTrade> tradeReader, IMongoDatabase db, TerminalLogger logger, JobManager jobManager)
{
_tradeReader = tradeReader;
_db = db;
_logger = logger;
_jobStatus = new JobStatusRow
{
JobName = "MongoDB Transaction Log",
Description = "Awaits internal signals to write Closed Trades to the database safely.",
StatusText = "Pending Initial Delay..."
};
_jobStatus.ManualTriggerAction = async () =>
{
_jobStatus.StatusText = "Manual trigger not supported for Channel Reader";
await Task.Delay(2000);
_jobStatus.StatusText = "Listening (Channel)...";
};
jobManager.RegisterJob(_jobStatus);
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.Info("PersistenceService started writing background DB logs.");
_jobStatus.StatusText = "Listening (Channel)...";
// One-time index setup (moved out of hot loop)
var col = _db.GetCollection<ClosedTrade>("closed_trades");
col.EnsureIndex(x => x.TradeId);
col.EnsureIndex(x => x.AccountId);
col.EnsureIndex(x => x.TokenId);
// We do a loop waiting for items in the channel
await foreach(var trade in _tradeReader.ReadAllAsync(stoppingToken))
{
if (!_jobStatus.IsEnabled)
{
// If paused, we just drop the trade for now or log a warning
_logger.Warning("PersistenceService is paused, ignoring trade log.");
continue;
}
try
{
_jobStatus.StatusText = "Writing to DB...";
// ===== DEDUPLIZIERUNG: Verhindert das Mehrfach-Einfügen desselben Trades =====
// Prüft ob für diesen Account + TokenId bereits ein ClosedTrade existiert.
// Dies verhindert den "Background Sync Duplicate Bug", bei dem geschlossene
// Trades bei jedem Sync-Zyklus oder nach einem Neustart erneut eingefügt werden.
if (!string.IsNullOrEmpty(trade.TokenId))
{
var existing = col.LiteFindOne(x => x.AccountId == trade.AccountId && x.TokenId == trade.TokenId);
if (existing != null)
{
_logger.Debug($"Duplikat ignoriert: ClosedTrade für Account {trade.AccountId} + Token {trade.TokenId.Substring(0, Math.Min(10, trade.TokenId.Length))}... existiert bereits (DB-ID: {existing.TradeId}).");
continue;
}
}
col.Insert(trade);
_logger.Debug($"Saved ClosedTrade {trade.TradeId} to MongoDB");
_jobStatus.LastRun = DateTime.Now;
}
catch (Exception ex)
{
_logger.Error($"Failed to persist ClosedTrade (ID: {trade.TradeId}): {ex.Message}");
_jobStatus.StatusText = "Error!";
}
finally
{
if (_jobStatus.StatusText != "Error!")
_jobStatus.StatusText = "Listening (Channel)...";
}
}
}
}
}
+943
View File
@@ -0,0 +1,943 @@
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;
}
}
}
+864
View File
@@ -0,0 +1,864 @@
using System;
using MongoDB.Driver;
using PolyTraderSharp.Extensions;
using System.Linq;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;
using Nethereum.Signer;
using Nethereum.Signer.EIP712;
using Nethereum.ABI.FunctionEncoding.Attributes;
using Nethereum.ABI.EIP712;
using Nethereum.Util;
using PolyTraderSharp.Models;
namespace PolyTraderSharp.Services
{
[Struct("EIP712Domain")]
public class ClobDomain
{
[Parameter("string", "name", 1)]
public string Name { get; set; } = string.Empty;
[Parameter("string", "version", 2)]
public string Version { get; set; } = "";
[Parameter("uint256", "chainId", 3)]
public System.Numerics.BigInteger ChainId { get; set; }
}
[Struct("EIP712Domain")]
public class CtfDomain
{
[Parameter("string", "name", 1)]
public string Name { get; set; } = string.Empty;
[Parameter("string", "version", 2)]
public string Version { get; set; } = string.Empty;
[Parameter("uint256", "chainId", 3)]
public ulong ChainId { get; set; }
[Parameter("address", "verifyingContract", 4)]
public string VerifyingContract { get; set; } = string.Empty;
}
[Struct("ClobAuth")]
public class ClobAuth
{
[Parameter("address", "address", 1)]
public string Address { get; set; } = string.Empty;
[Parameter("string", "timestamp", 2)]
public string Timestamp { get; set; } = "";
[Parameter("uint256", "nonce", 3)]
public System.Numerics.BigInteger Nonce { get; set; }
[Parameter("string", "message", 4)]
public string Message { get; set; } = string.Empty;
}
[Struct("Order")]
public class CtfOrder
{
[Parameter("uint256", "salt", 1)]
public System.Numerics.BigInteger Salt { get; set; }
[Parameter("address", "maker", 2)]
public string Maker { get; set; } = string.Empty;
[Parameter("address", "signer", 3)]
public string Signer { get; set; } = string.Empty;
[Parameter("address", "taker", 4)]
public string Taker { get; set; } = string.Empty;
[Parameter("uint256", "tokenId", 5)]
public System.Numerics.BigInteger TokenId { get; set; }
[Parameter("uint256", "makerAmount", 6)]
public System.Numerics.BigInteger MakerAmount { get; set; }
[Parameter("uint256", "takerAmount", 7)]
public System.Numerics.BigInteger TakerAmount { get; set; }
[Parameter("uint256", "expiration", 8)]
public System.Numerics.BigInteger Expiration { get; set; }
[Parameter("uint256", "nonce", 9)]
public System.Numerics.BigInteger Nonce { get; set; }
[Parameter("uint256", "feeRateBps", 10)]
public System.Numerics.BigInteger FeeRateBps { get; set; }
[Parameter("uint8", "side", 11)]
public byte Side { get; set; }
[Parameter("uint8", "signatureType", 12)]
public byte SignatureType { get; set; }
}
public class PolymarketClobClient
{
private readonly HttpClient _httpClient;
private readonly TerminalLogger _logger;
private const string ClobHost = "https://clob.polymarket.com";
private const int ChainId = 137;
private static readonly object _fileLock = new object();
public PolymarketClobClient(TerminalLogger logger, HttpClient httpClient)
{
_logger = logger;
_httpClient = httpClient;
}
/// <summary>
/// Creates an HMAC signature for authenticated requests to the Polymarket CLOB.
/// </summary>
private static string GenerateHmacSignature(string secret, string timestamp, string method, string requestPath, string body = "")
{
string payload = timestamp + method + requestPath + body;
// Convert URL-Safe Base64 back to Standard Base64
string b64 = secret.Replace('-', '+').Replace('_', '/');
switch (b64.Length % 4)
{
case 2: b64 += "=="; break;
case 3: b64 += "="; break;
}
byte[] secretBytes = Convert.FromBase64String(b64);
byte[] payloadBytes = Encoding.UTF8.GetBytes(payload);
using var hmac = new HMACSHA256(secretBytes);
byte[] hash = hmac.ComputeHash(payloadBytes);
string signature = Convert.ToBase64String(hash);
return signature.Replace('+', '-').Replace('/', '_');
}
private static long _serverTimeDeltaSeconds = 0;
private static DateTime _lastTimeSync = DateTime.MinValue;
public async Task SyncServerTimeAsync()
{
if ((DateTime.UtcNow - _lastTimeSync).TotalMinutes < 15) return;
try
{
using var response = await _httpClient.GetAsync($"{ClobHost}/time");
if (response.IsSuccessStatusCode)
{
string jsonStr = await response.Content.ReadAsStringAsync();
using var doc = JsonDocument.Parse(jsonStr);
long epochSecs = 0;
if (doc.RootElement.ValueKind == JsonValueKind.Number)
{
epochSecs = doc.RootElement.GetInt64();
if (epochSecs > 1000000000000) epochSecs /= 1000;
DateTime serverTime = DateTimeOffset.FromUnixTimeSeconds(epochSecs).UtcDateTime;
_serverTimeDeltaSeconds = (long)(serverTime - DateTime.UtcNow).TotalSeconds;
_lastTimeSync = DateTime.UtcNow;
_logger.Info($"🕒 CLOB Server Time Sync: Offset ist {_serverTimeDeltaSeconds} Sekunden.");
}
else if (doc.RootElement.ValueKind == JsonValueKind.Object && doc.RootElement.TryGetProperty("iso", out var isoProp) && DateTime.TryParse(isoProp.GetString(), null, System.Globalization.DateTimeStyles.RoundtripKind, out DateTime serverTime))
{
serverTime = serverTime.ToUniversalTime();
_serverTimeDeltaSeconds = (long)(serverTime - DateTime.UtcNow).TotalSeconds;
_lastTimeSync = DateTime.UtcNow;
_logger.Info($"🕒 CLOB Server Time Sync: Offset ist {_serverTimeDeltaSeconds} Sekunden.");
}
}
}
catch (Exception ex)
{
_logger.Warning($"🕒 Time Sync Error: {ex.Message}");
}
}
private string GetClobTimestamp()
{
// Background fire-and-forget sync if expired
if ((DateTime.UtcNow - _lastTimeSync).TotalMinutes > 15)
{
_ = SyncServerTimeAsync();
}
return (DateTimeOffset.UtcNow.ToUnixTimeSeconds() + _serverTimeDeltaSeconds).ToString(System.Globalization.CultureInfo.InvariantCulture);
}
/// <summary>
/// Derives a new Polymarket Level 2 API Key using an EIP712 Message signed by the L1 private key.
/// </summary>
public async Task<(string ApiKey, string ApiSecret, string ApiPassphrase)> DeriveApiKeyAsync(string privateKey, string walletAddress)
{
try
{
var signer = new Eip712TypedDataSigner();
var key = new EthECKey(privateKey);
string computedAddress = key.GetPublicAddress();
string timestamp = GetClobTimestamp();
var typedData = new TypedData<ClobDomain>
{
Domain = new ClobDomain
{
Name = "ClobAuthDomain",
Version = "1",
ChainId = new System.Numerics.BigInteger(ChainId)
},
Types = Nethereum.ABI.EIP712.MemberDescriptionFactory.GetTypesMemberDescription(typeof(ClobDomain), typeof(ClobAuth)),
PrimaryType = "ClobAuth"
};
var clobAuth = new ClobAuth
{
Address = computedAddress,
Timestamp = timestamp,
Nonce = new System.Numerics.BigInteger(0),
Message = "This message attests that I control the given wallet"
};
var encoder = new Nethereum.ABI.EIP712.Eip712TypedDataEncoder();
var rawData = encoder.EncodeTypedData(clobAuth, typedData);
_logger.Warning($"DEBUG_CS_RAW_DATA: {Nethereum.Hex.HexConvertors.Extensions.HexByteConvertorExtensions.ToHex(rawData)}");
string signature = signer.SignTypedDataV4(clobAuth, typedData, key);
_logger.Warning($"DEBUG_CS_SIG: {signature}");
var request = new HttpRequestMessage(HttpMethod.Get, $"{ClobHost}/auth/derive-api-key");
request.Headers.Add("POLY_ADDRESS", computedAddress);
request.Headers.Add("POLY_SIGNATURE", signature);
request.Headers.Add("POLY_TIMESTAMP", timestamp);
request.Headers.Add("POLY_NONCE", "0");
using (var response = await _httpClient.SendAsync(request))
{
if (response.IsSuccessStatusCode)
{
var jsonStr = await response.Content.ReadAsStringAsync();
using var doc = JsonDocument.Parse(jsonStr);
string apiKey = doc.RootElement.GetProperty("apiKey").GetString() ?? "";
string secret = doc.RootElement.GetProperty("secret").GetString() ?? "";
string passphrase = doc.RootElement.GetProperty("passphrase").GetString() ?? "";
return (apiKey, secret, passphrase);
}
}
_logger.Warning($"Derivation failed. Attempting to CREATE new Api Key L2 instead...");
using (var request2 = new HttpRequestMessage(HttpMethod.Post, $"{ClobHost}/auth/api-key"))
{
request2.Headers.Add("POLY_ADDRESS", computedAddress);
request2.Headers.Add("POLY_SIGNATURE", signature);
request2.Headers.Add("POLY_TIMESTAMP", timestamp);
request2.Headers.Add("POLY_NONCE", "0");
using (var response2 = await _httpClient.SendAsync(request2))
{
if (response2.IsSuccessStatusCode)
{
var jsonStr = await response2.Content.ReadAsStringAsync();
using var doc = JsonDocument.Parse(jsonStr);
string apiKey = doc.RootElement.GetProperty("apiKey").GetString() ?? "";
string secret = doc.RootElement.GetProperty("secret").GetString() ?? "";
string passphrase = doc.RootElement.GetProperty("passphrase").GetString() ?? "";
return (apiKey, secret, passphrase);
}
else
{
string err = await response2.Content.ReadAsStringAsync();
_logger.Error($"Failed to execute L1 Auth: {response2.StatusCode} {err}");
}
}
}
}
catch (Exception ex)
{
_logger.Error($"DeriveApiKeyAsync Exception: {ex.Message}");
}
return (string.Empty, string.Empty, string.Empty);
}
public async Task<decimal> GetUsdcBalanceAsync(AccountState acc, bool isRetry = false)
{
if (string.IsNullOrEmpty(acc.ApiKey) || string.IsNullOrEmpty(acc.ApiSecret) || string.IsNullOrEmpty(acc.ApiPassphrase) || string.IsNullOrEmpty(acc.PrivateKey))
{
_logger.Warning($"🔑 [{acc.Name}] Skipping balance fetch: ApiKey={!string.IsNullOrEmpty(acc.ApiKey)}, Secret={!string.IsNullOrEmpty(acc.ApiSecret)}, Pass={!string.IsNullOrEmpty(acc.ApiPassphrase)}, PK={!string.IsNullOrEmpty(acc.PrivateKey)}");
return 0;
}
try
{
string endpoint = "/balance-allowance";
string requestUrl = $"{endpoint}?asset_type=COLLATERAL&signature_type=2";
string timestamp = GetClobTimestamp();
// Python SDK signs ONLY the base path, not the query params
string signature = GenerateHmacSignature(acc.ApiSecret, timestamp, "GET", endpoint);
var request = new HttpRequestMessage(HttpMethod.Get, $"{ClobHost}{requestUrl}");
var keyObj = new EthECKey(acc.PrivateKey.Replace("0x", ""));
request.Headers.Add("POLY_ADDRESS", keyObj.GetPublicAddress());
request.Headers.Add("POLY_API_KEY", acc.ApiKey);
request.Headers.Add("POLY_SIGNATURE", signature);
request.Headers.Add("POLY_TIMESTAMP", timestamp);
request.Headers.Add("POLY_PASSPHRASE", acc.ApiPassphrase);
using var response = await _httpClient.SendAsync(request);
if (response.IsSuccessStatusCode)
{
var jsonStr = await response.Content.ReadAsStringAsync();
_logger.Info($"💰 [{acc.Name}] Balance API Response: {jsonStr}");
using var doc = JsonDocument.Parse(jsonStr);
if (doc.RootElement.ValueKind == JsonValueKind.Object && doc.RootElement.TryGetProperty("balance", out var balProp))
{
var balanceStr = balProp.GetString();
if (decimal.TryParse(balanceStr, System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out decimal balRaw))
{
decimal finalBal = balRaw / 1_000_000m;
_logger.Info($"💰 [{acc.Name}] Parsed Balance: {finalBal} USDC (raw: {balRaw})");
return finalBal;
}
}
_logger.Warning($"💰 [{acc.Name}] Could not parse 'balance' from response: {jsonStr}");
}
else if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized || response.StatusCode == System.Net.HttpStatusCode.Forbidden)
{
string errStr = await response.Content.ReadAsStringAsync();
_logger.Warning($"🌐 [{acc.Name}] API Keys expired/invalid. Deriving new L2 Keys from PrivateKey...");
if (!isRetry && !string.IsNullOrEmpty(acc.PrivateKey) && !string.IsNullOrEmpty(acc.WalletAddress))
{
var fallbackKeyObj = new EthECKey(acc.PrivateKey.Replace("0x", ""));
var newKeys = await DeriveApiKeyAsync(acc.PrivateKey, fallbackKeyObj.GetPublicAddress());
if (!string.IsNullOrEmpty(newKeys.ApiKey))
{
acc.ApiKey = newKeys.ApiKey;
acc.ApiSecret = newKeys.ApiSecret;
acc.ApiPassphrase = newKeys.ApiPassphrase;
_logger.Info($"🌐 [{acc.Name}] Successfully derived new L2 Keys! Resuming in 2.5s...");
// Await propagation of new keys inside Polymarket's Gamma backend
await Task.Delay(2500);
// Retry recursively strictly once
return await GetUsdcBalanceAsync(acc, true);
}
}
_logger.Error($"CLOB Balance Fetch failed: {response.StatusCode} {errStr}");
}
else
{
string errStr = await response.Content.ReadAsStringAsync();
_logger.Error($"CLOB Balance Fetch failed: {response.StatusCode} {errStr}");
}
}
catch (Exception ex)
{
_logger.Error($"CLOB Balance Fetch Error: {ex.Message}");
}
return 0;
}
public async Task<System.Collections.Generic.List<(string Id, string Side, decimal Price)>> GetOpenOrdersAsync(AccountState acc, string assetId)
{
var result = new System.Collections.Generic.List<(string Id, string Side, decimal Price)>();
if (string.IsNullOrEmpty(acc.ApiKey) || string.IsNullOrEmpty(acc.ApiSecret) || string.IsNullOrEmpty(acc.ApiPassphrase) || string.IsNullOrEmpty(acc.PrivateKey))
return result;
try
{
string endpoint = "/data/orders";
string requestUrl = $"{endpoint}?asset_id={assetId}";
string timestamp = GetClobTimestamp();
string signature = GenerateHmacSignature(acc.ApiSecret, timestamp, "GET", endpoint);
var request = new HttpRequestMessage(HttpMethod.Get, $"{ClobHost}{requestUrl}");
var keyObj = new EthECKey(acc.PrivateKey.Replace("0x", ""));
request.Headers.Add("POLY_ADDRESS", keyObj.GetPublicAddress());
request.Headers.Add("POLY_API_KEY", acc.ApiKey);
request.Headers.Add("POLY_SIGNATURE", signature);
request.Headers.Add("POLY_TIMESTAMP", timestamp);
request.Headers.Add("POLY_PASSPHRASE", acc.ApiPassphrase);
using var response = await _httpClient.SendAsync(request);
if (response.IsSuccessStatusCode)
{
var jsonStr = await response.Content.ReadAsStringAsync();
using var doc = JsonDocument.Parse(jsonStr);
if (doc.RootElement.TryGetProperty("data", out var dataArr) && dataArr.ValueKind == JsonValueKind.Array)
{
foreach (var orderLine in dataArr.EnumerateArray())
{
if (orderLine.TryGetProperty("orderID", out var oid) || orderLine.TryGetProperty("id", out oid))
{
string idStr = oid.GetString() ?? "";
string sideStr = orderLine.TryGetProperty("side", out var s) ? (s.GetString() ?? "") : "";
string priceStr = orderLine.TryGetProperty("price", out var p) ? (p.GetString() ?? "0") : "0";
decimal.TryParse(priceStr, System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out decimal priceDec);
if (!string.IsNullOrEmpty(idStr))
result.Add((idStr, sideStr, priceDec));
}
}
}
else if (doc.RootElement.ValueKind == JsonValueKind.Array)
{
foreach (var orderLine in doc.RootElement.EnumerateArray())
{
if (orderLine.TryGetProperty("orderID", out var oid) || orderLine.TryGetProperty("id", out oid))
{
string idStr = oid.GetString() ?? "";
string sideStr = orderLine.TryGetProperty("side", out var s) ? (s.GetString() ?? "") : "";
string priceStr = orderLine.TryGetProperty("price", out var p) ? (p.GetString() ?? "0") : "0";
decimal.TryParse(priceStr, System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out decimal priceDec);
if (!string.IsNullOrEmpty(idStr))
result.Add((idStr, sideStr, priceDec));
}
}
}
}
else
{
string errStr = await response.Content.ReadAsStringAsync();
_logger.Warning($"Failed to GET open orders for {assetId}: {response.StatusCode} {errStr}");
}
}
catch (Exception ex)
{
_logger.Error($"GetOpenOrdersAsync Error: {ex.Message}");
}
return result;
}
public async Task<bool> CancelOrderAsync(AccountState acc, string orderId)
{
if (string.IsNullOrEmpty(acc.ApiKey) || string.IsNullOrEmpty(acc.ApiSecret) || string.IsNullOrEmpty(acc.ApiPassphrase) || string.IsNullOrEmpty(acc.PrivateKey))
return false;
try
{
string endpoint = "/order";
var reqBody = new { orderID = orderId };
string jsonBody = JsonSerializer.Serialize(reqBody);
string timestamp = GetClobTimestamp();
string signature = GenerateHmacSignature(acc.ApiSecret, timestamp, "DELETE", endpoint, jsonBody);
using var request = new HttpRequestMessage(HttpMethod.Delete, $"{ClobHost}{endpoint}");
var keyObj = new EthECKey(acc.PrivateKey.Replace("0x", ""));
request.Headers.Add("POLY_ADDRESS", keyObj.GetPublicAddress());
request.Headers.Add("POLY_API_KEY", acc.ApiKey);
request.Headers.Add("POLY_SIGNATURE", signature);
request.Headers.Add("POLY_TIMESTAMP", timestamp);
request.Headers.Add("POLY_PASSPHRASE", acc.ApiPassphrase);
request.Content = new StringContent(jsonBody, Encoding.UTF8, "application/json");
using var response = await _httpClient.SendAsync(request);
if (response.IsSuccessStatusCode)
{
_logger.Info($"🚮 [{acc.Name}] Stornierung erfolgreich. OrderID: {orderId}");
return true;
}
else
{
string errStr = await response.Content.ReadAsStringAsync();
_logger.Warning($"Failed to cancel order {orderId}: {response.StatusCode} {errStr}");
return false;
}
}
catch (Exception ex)
{
_logger.Error($"CancelOrderAsync Error: {ex.Message}");
return false;
}
}
public async Task CancelConflictingOrdersAsync(AccountState acc, string assetId, decimal newPrice, string sideStr)
{
var openOrders = await GetOpenOrdersAsync(acc, assetId);
if (openOrders.Count > 0)
{
var tasks = new System.Collections.Generic.List<Task>();
foreach (var order in openOrders)
{
bool shouldCancel = false;
if (sideStr.Equals("SELL", StringComparison.OrdinalIgnoreCase))
{
shouldCancel = true;
_logger.Info($"⚠️ [{acc.Name}] Storniere Order {order.Id} wegen Verkaufs-Signal des Master-Traders.");
}
else if (sideStr.Equals("BUY", StringComparison.OrdinalIgnoreCase) && order.Side.Equals("BUY", StringComparison.OrdinalIgnoreCase))
{
if (Math.Abs(order.Price - newPrice) > 0.001m)
{
shouldCancel = true;
_logger.Info($"⚠️ [{acc.Name}] Storniere veraltete Order {order.Id} (Alter Preis: {order.Price:F3}, Neuer Preis: {newPrice:F3})");
}
else
{
_logger.Info($"✅ [{acc.Name}] Behalte bestehende Order {order.Id} (Preis identisch: {order.Price:F3})");
}
}
if (shouldCancel)
{
tasks.Add(CancelOrderAsync(acc, order.Id));
}
}
if (tasks.Count > 0)
{
await Task.WhenAll(tasks);
// Minimal delay to ensure rapid executions don't conflict with in-flight deletions
await Task.Delay(150);
}
}
}
private static System.Numerics.BigInteger GenerateSalt()
{
// Generate a salt similar to Py Clob Client (fits safely in a standard 64-bit int / JS Number)
long t = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
int r = System.Security.Cryptography.RandomNumberGenerator.GetInt32(0, 10000);
return new System.Numerics.BigInteger(t * 10000 + r);
}
public static (decimal shares, decimal usdc, decimal makerRaw, decimal takerRaw) CalculateExactOrderAmounts(decimal investAmountUsd, decimal rawPrice, decimal limitPrice, string sideStr, string orderType = "FOK", decimal? overrideTickSize = null, int? overrideMakerDecimals = null, int? overrideTakerDecimals = null)
{
decimal tickSize = overrideTickSize ?? 0.001m;
int priceDec, sizeDec, amtDec;
if (tickSize >= 0.1m) { priceDec = 1; sizeDec = 2; amtDec = 3; }
else if (tickSize >= 0.01m) { priceDec = 2; sizeDec = 2; amtDec = 4; }
else if (tickSize >= 0.001m) { priceDec = 3; sizeDec = 2; amtDec = 5; }
else { priceDec = 4; sizeDec = 2; amtDec = 6; }
decimal priceRounded = Math.Round(limitPrice > 0 ? limitPrice : rawPrice, priceDec, MidpointRounding.AwayFromZero);
if (priceRounded < tickSize) priceRounded = tickSize;
decimal executedShares = 0m;
decimal executedUsdc = 0m;
decimal finalMakerAmountRaw = 0m;
decimal finalTakerAmountRaw = 0m;
if (sideStr.ToUpper() == "BUY")
{
decimal rawTakerShares = investAmountUsd / priceRounded;
decimal multiplier = (decimal)Math.Pow(10, sizeDec);
decimal takerShares = Math.Floor(rawTakerShares * multiplier) / multiplier;
if (takerShares <= 0) return (-1, -1, 0, 0);
decimal makerUsd = 0m;
// Polymarket strictly enforces $1.00 minimum for MARKET BUYS and verifies it against the supported shares.
// We increment takerShares until the floored USDC amount supports the exact shares without dropping below $1.00.
decimal step = 1.0m / multiplier;
while (takerShares > 0)
{
makerUsd = takerShares * priceRounded;
int actDec = BitConverter.GetBytes(decimal.GetBits(makerUsd)[3])[2];
if (actDec > amtDec)
{
decimal mul2 = (decimal)Math.Pow(10, amtDec + 4);
makerUsd = Math.Ceiling(makerUsd * mul2) / mul2;
if (BitConverter.GetBytes(decimal.GetBits(makerUsd)[3])[2] > amtDec)
{
decimal mul3 = (decimal)Math.Pow(10, amtDec);
makerUsd = Math.Floor(makerUsd * mul3) / mul3;
}
}
decimal supportedShares = Math.Floor((makerUsd / priceRounded) * multiplier) / multiplier;
if (makerUsd >= 1.0m && supportedShares >= takerShares)
break;
takerShares += step;
}
finalTakerAmountRaw = Math.Round(takerShares * 1_000_000m);
finalMakerAmountRaw = Math.Round(makerUsd * 1_000_000m);
executedShares = takerShares;
executedUsdc = makerUsd;
}
else
{
decimal sharesRaw = investAmountUsd / priceRounded;
decimal multiplier = (decimal)Math.Pow(10, sizeDec);
decimal makerShares = Math.Floor(sharesRaw * multiplier) / multiplier;
decimal takerUsd = makerShares * priceRounded;
int actDec = BitConverter.GetBytes(decimal.GetBits(takerUsd)[3])[2];
if (actDec > amtDec)
{
decimal mul2 = (decimal)Math.Pow(10, amtDec + 4);
takerUsd = Math.Ceiling(takerUsd * mul2) / mul2;
if (BitConverter.GetBytes(decimal.GetBits(takerUsd)[3])[2] > amtDec)
{
decimal mul3 = (decimal)Math.Pow(10, amtDec);
takerUsd = Math.Floor(takerUsd * mul3) / mul3;
}
}
finalMakerAmountRaw = Math.Round(makerShares * 1_000_000m);
finalTakerAmountRaw = Math.Round(takerUsd * 1_000_000m);
executedShares = makerShares;
executedUsdc = takerUsd;
}
return (executedShares, executedUsdc, finalMakerAmountRaw, finalTakerAmountRaw);
}
/// <summary>
/// Executes a native EIP-712 signed order (default Fill-Or-Kill)
/// </summary>
public async Task<string> PlaceOrderAsync(AccountState account, string tokenId, string sideStr, decimal investAmountUsd, decimal limitPrice, string orderType = "FOK", bool debugPayloadLog = false, bool isNegRisk = false, int actualFeeBps = 0, decimal? overrideTickSize = null, int? overrideMakerDecimals = null, int? overrideTakerDecimals = null)
{
if (string.IsNullOrEmpty(account.PrivateKey) || string.IsNullOrEmpty(account.ApiKey))
return "Error: Missing API or Private Keys";
try
{
var signer = new Eip712TypedDataSigner();
var key = new EthECKey(account.PrivateKey);
var typedData = new TypedData<CtfDomain>
{
Domain = new CtfDomain
{
Name = "Polymarket CTF Exchange",
Version = "1",
ChainId = ChainId,
VerifyingContract = isNegRisk ? "0xC5d563A36AE78145C45a50134d48A1215220f80a" : "0x4bFb41d5B3570DeFd03C39a9A4D8dE6Bd8B8982E"
},
Types = Nethereum.ABI.EIP712.MemberDescriptionFactory.GetTypesMemberDescription(typeof(CtfDomain), typeof(CtfOrder)),
PrimaryType = "Order"
};
var amounts = CalculateExactOrderAmounts(investAmountUsd, limitPrice, limitPrice, sideStr, orderType, overrideTickSize, overrideMakerDecimals, overrideTakerDecimals);
if (amounts.shares <= 0)
return $"Mathematical tick size error: Balance too small to meet fractional quantum limit for exact price matching";
decimal makerAmountRaw = amounts.makerRaw;
decimal takerAmountRaw = amounts.takerRaw;
System.Numerics.BigInteger parsedTokenId;
if (tokenId.StartsWith("0x") || tokenId.Any(c => "abcdefABCDEF".Contains(c)))
{
parsedTokenId = new Nethereum.Hex.HexTypes.HexBigInteger(tokenId.StartsWith("0x") ? tokenId : "0x" + tokenId).Value;
}
else
{
parsedTokenId = System.Numerics.BigInteger.Parse(tokenId);
}
var ctfOrder = new CtfOrder
{
Salt = GenerateSalt(),
Maker = account.WalletAddress,
Signer = key.GetPublicAddress(),
Taker = "0x0000000000000000000000000000000000000000",
TokenId = parsedTokenId,
MakerAmount = new System.Numerics.BigInteger(makerAmountRaw),
TakerAmount = new System.Numerics.BigInteger(takerAmountRaw),
Expiration = orderType == "GTD" ? long.Parse(GetClobTimestamp()) + 300 : 0,
Nonce = 0,
FeeRateBps = new System.Numerics.BigInteger(actualFeeBps),
Side = sideStr.ToUpper() == "BUY" ? (byte)0 : (byte)1,
SignatureType = 2
};
string signature = signer.SignTypedDataV4(ctfOrder, typedData, key);
var reqBody = new
{
order = new
{
salt = (long)ctfOrder.Salt,
maker = ctfOrder.Maker.ToLower(),
signer = ctfOrder.Signer.ToLower(),
taker = ctfOrder.Taker.ToLower(),
tokenId = ctfOrder.TokenId.ToString(),
makerAmount = ctfOrder.MakerAmount.ToString(),
takerAmount = ctfOrder.TakerAmount.ToString(),
expiration = ctfOrder.Expiration.ToString(),
nonce = ctfOrder.Nonce.ToString(),
feeRateBps = ctfOrder.FeeRateBps.ToString(),
side = ctfOrder.Side == 0 ? "BUY" : "SELL",
signatureType = ctfOrder.SignatureType,
signature = signature
},
owner = account.ApiKey,
orderType = orderType
};
string jsonBody = JsonSerializer.Serialize(reqBody);
string timestamp = GetClobTimestamp();
string requestPath = "/order";
string hmacSig = GenerateHmacSignature(account.ApiSecret, timestamp, "POST", requestPath, jsonBody);
using var request = new HttpRequestMessage(HttpMethod.Post, $"{ClobHost}{requestPath}");
var keyObj = new EthECKey(account.PrivateKey.Replace("0x", ""));
request.Headers.Add("POLY_ADDRESS", keyObj.GetPublicAddress());
request.Headers.Add("POLY_API_KEY", account.ApiKey);
request.Headers.Add("POLY_TIMESTAMP", timestamp);
request.Headers.Add("POLY_SIGNATURE", hmacSig);
request.Headers.Add("POLY_PASSPHRASE", account.ApiPassphrase);
request.Content = new StringContent(jsonBody, Encoding.UTF8, "application/json");
if (debugPayloadLog)
{
_logger.Debug($"[CLOB-PAYLOAD] -> {jsonBody}");
}
using var response = await _httpClient.SendAsync(request);
var responseContent = await response.Content.ReadAsStringAsync();
if (!response.IsSuccessStatusCode)
{
bool isFokFail = responseContent.Contains("FOK orders are fully filled or killed");
if (isFokFail && sideStr == "BUY")
{
// Dampen FOK failed BUY logs. Usually means target price/liquidity not met for full copy size.
// We skip it silently.
return "SKIPPED_LIQUIDITY";
}
lock (_fileLock)
{
System.IO.File.WriteAllText("last_invalid_payload.json", jsonBody);
}
if (isFokFail && sideStr == "SELL")
{
_logger.Warning($"Liquidität für FOK SELL reicht nicht aus. (Orderbook Size limit). Rest-Shares bleiben erhalten.");
return "Nicht genügend Liquidität für vollumfänglichen Verkauf auf diesem Preisniveau (FOK).";
}
else
{
var tickMatch = System.Text.RegularExpressions.Regex.Match(responseContent, @"breaks minimum tick size rule: ([\d\.]+)");
if (tickMatch.Success && overrideTickSize == null)
{
if (decimal.TryParse(tickMatch.Groups[1].Value, System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out decimal newTickSize))
{
_logger.Info($"🔄 Automatische Anpassung an Markt Tick-Size ({newTickSize}). Order wird erneut berechnet und platziert...");
return await PlaceOrderAsync(account, tokenId, sideStr, investAmountUsd, limitPrice, orderType, debugPayloadLog, isNegRisk, actualFeeBps, newTickSize, overrideMakerDecimals, overrideTakerDecimals);
}
}
var decMatch = System.Text.RegularExpressions.Regex.Match(responseContent, @"maker amount supports a max accuracy of (\d+) decimals, taker amount a max of (\d+) decimals");
if (decMatch.Success && overrideMakerDecimals == null)
{
if (int.TryParse(decMatch.Groups[1].Value, out int newMaker) && int.TryParse(decMatch.Groups[2].Value, out int newTaker))
{
_logger.Info($"🔄 Automatische Anpassung an Dezimalregeln (Maker: {newMaker}, Taker: {newTaker}). Order wird neu berechnet...");
return await PlaceOrderAsync(account, tokenId, sideStr, investAmountUsd, limitPrice, orderType, debugPayloadLog, isNegRisk, actualFeeBps, overrideTickSize, newMaker, newTaker);
}
}
// Check if error is "invalid fee rate" -> Extract required fee -> Retry!
var match = System.Text.RegularExpressions.Regex.Match(responseContent, @"invalid fee rate \(\d+\), current market's (?:taker|maker) fee: (\d+)");
if (match.Success && actualFeeBps == 0) // Only retry once
{
if (int.TryParse(match.Groups[1].Value, out int newFeeBps))
{
_logger.Info($"🔄 Automatische Anpassung an Fee Rate ({newFeeBps} bps). Order wird erneut platziert...");
return await PlaceOrderAsync(account, tokenId, sideStr, investAmountUsd, limitPrice, orderType, debugPayloadLog, isNegRisk, newFeeBps, overrideTickSize);
}
}
// Check if error is "Size lower than minimum 5" -> Fallback to MARKET
var sizeMatch = System.Text.RegularExpressions.Regex.Match(responseContent, @"Size \([\d\.]+\) lower than the minimum: (\d+)");
if (sizeMatch.Success)
{
if (decimal.TryParse(sizeMatch.Groups[1].Value, out decimal minReq))
{
if (orderType != "MARKET")
{
_logger.Info($"🔄 Automatische Anpassung an Minimum Size Limit (Limitorder < {minReq}). Order wird als MARKET platziert...");
return await PlaceOrderAsync(account, tokenId, sideStr, investAmountUsd, limitPrice, "MARKET", debugPayloadLog, isNegRisk, actualFeeBps, overrideTickSize, overrideMakerDecimals, overrideTakerDecimals);
}
else if (sideStr == "SELL")
{
_logger.Warning($"Verkauf von unter {minReq} Shares auf Polymarket nicht möglich (Orderbook Limit). Position muss aufgestockt werden oder auslaufen.");
return $"Börsenlimit: Mindestens {minReq} Shares erforderlich.";
}
}
}
var balMatch1 = System.Text.RegularExpressions.Regex.Match(responseContent, @"balance: (\d+), sum of active orders: (\d+)");
var balMatch2 = System.Text.RegularExpressions.Regex.Match(responseContent, @"balance: (\d+), order amount: (\d+)");
if ((balMatch1.Success || balMatch2.Success) && sideStr == "SELL")
{
decimal totalBal = 0m, activeOrders = 0m;
if (balMatch1.Success)
{
_ = decimal.TryParse(balMatch1.Groups[1].Value, out totalBal);
_ = decimal.TryParse(balMatch1.Groups[2].Value, out activeOrders);
}
else if (balMatch2.Success)
{
_ = decimal.TryParse(balMatch2.Groups[1].Value, out totalBal);
activeOrders = 0m;
}
decimal availableSharesRaw = totalBal - activeOrders;
decimal availableShares = availableSharesRaw / 1_000_000m;
decimal requiredShares = investAmountUsd / limitPrice;
if (availableShares > 0 && Math.Abs(availableShares - requiredShares) > 0.001m && availableShares < requiredShares)
{
decimal newInvestAmount = availableShares * limitPrice;
_logger.Info($"🔄 Automatische Anpassung an verfügbare Shares (Reale Balance: {totalBal / 1000000m} / Aktive Orders: {activeOrders / 1000000m} Shares). Verkaufe exakte {availableShares} Shares...");
return await PlaceOrderAsync(account, tokenId, sideStr, newInvestAmount, limitPrice, orderType, debugPayloadLog, isNegRisk, actualFeeBps, overrideTickSize, overrideMakerDecimals, overrideTakerDecimals);
}
}
_logger.Error($"CLOB Order Error ({response.StatusCode}): {responseContent}");
}
return "ERROR";
}
if (response.IsSuccessStatusCode)
{
_logger.Info($"\u2705 Order Platzierung Erfolgreich! {sideStr} @ {limitPrice:F3}");
if (orderType == "GTC" || orderType == "GTD")
{
account.HasOpenLimitOrders = true;
}
return "OK";
}
else
{
_logger.Error($"❌ Order Fehler: {response.StatusCode} - {responseContent}");
return responseContent;
}
}
catch (Exception ex)
{
_logger.Error($"PlaceFokOrderAsync Runtime Fehler: {ex.Message}");
return ex.Message;
}
}
}
}
+569
View File
@@ -0,0 +1,569 @@
using System;
using System.Linq;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;
using Nethereum.Signer;
using Nethereum.Signer.EIP712;
using Nethereum.ABI.FunctionEncoding.Attributes;
using Nethereum.ABI.EIP712;
using Nethereum.Util;
using PolyTraderSharp.Models;
namespace PolyTraderSharp.Services
{
[Struct("EIP712Domain")]
public class ClobDomain
{
[Parameter("string", "name", 1)]
public string Name { get; set; }
[Parameter("string", "version", 2)]
public string Version { get; set; } = "";
[Parameter("uint256", "chainId", 3)]
public System.Numerics.BigInteger ChainId { get; set; }
}
[Struct("EIP712Domain")]
public class CtfDomain
{
[Parameter("string", "name", 1)]
public string Name { get; set; }
[Parameter("string", "version", 2)]
public string Version { get; set; }
[Parameter("uint256", "chainId", 3)]
public ulong ChainId { get; set; }
[Parameter("address", "verifyingContract", 4)]
public string VerifyingContract { get; set; }
}
[Struct("ClobAuth")]
public class ClobAuth
{
[Parameter("address", "address", 1)]
public string Address { get; set; }
[Parameter("string", "timestamp", 2)]
public string Timestamp { get; set; } = "";
[Parameter("uint256", "nonce", 3)]
public System.Numerics.BigInteger Nonce { get; set; }
[Parameter("string", "message", 4)]
public string Message { get; set; }
}
[Struct("Order")]
public class CtfOrder
{
[Parameter("uint256", "salt", 1)]
public System.Numerics.BigInteger Salt { get; set; }
[Parameter("address", "maker", 2)]
public string Maker { get; set; }
[Parameter("address", "signer", 3)]
public string Signer { get; set; }
[Parameter("address", "taker", 4)]
public string Taker { get; set; }
[Parameter("uint256", "tokenId", 5)]
public System.Numerics.BigInteger TokenId { get; set; }
[Parameter("uint256", "makerAmount", 6)]
public System.Numerics.BigInteger MakerAmount { get; set; }
[Parameter("uint256", "takerAmount", 7)]
public System.Numerics.BigInteger TakerAmount { get; set; }
[Parameter("uint256", "expiration", 8)]
public System.Numerics.BigInteger Expiration { get; set; }
[Parameter("uint256", "nonce", 9)]
public System.Numerics.BigInteger Nonce { get; set; }
[Parameter("uint256", "feeRateBps", 10)]
public System.Numerics.BigInteger FeeRateBps { get; set; }
[Parameter("uint8", "side", 11)]
public byte Side { get; set; }
[Parameter("uint8", "signatureType", 12)]
public byte SignatureType { get; set; }
}
public class PolymarketClobClient
{
private readonly HttpClient _httpClient;
private readonly TerminalLogger _logger;
private const string ClobHost = "https://clob.polymarket.com";
private const int ChainId = 137;
public PolymarketClobClient(TerminalLogger logger, HttpClient httpClient)
{
_logger = logger;
_httpClient = httpClient;
}
/// <summary>
/// Creates an HMAC signature for authenticated requests to the Polymarket CLOB.
/// </summary>
private static string GenerateHmacSignature(string secret, string timestamp, string method, string requestPath, string body = "")
{
string payload = timestamp + method + requestPath + body;
// Convert URL-Safe Base64 back to Standard Base64
string b64 = secret.Replace('-', '+').Replace('_', '/');
switch (b64.Length % 4)
{
case 2: b64 += "=="; break;
case 3: b64 += "="; break;
}
byte[] secretBytes = Convert.FromBase64String(b64);
byte[] payloadBytes = Encoding.UTF8.GetBytes(payload);
using var hmac = new HMACSHA256(secretBytes);
byte[] hash = hmac.ComputeHash(payloadBytes);
string signature = Convert.ToBase64String(hash);
return signature.Replace('+', '-').Replace('/', '_');
}
/// <summary>
/// Derives a new Polymarket Level 2 API Key using an EIP712 Message signed by the L1 private key.
/// </summary>
public async Task<(string ApiKey, string ApiSecret, string ApiPassphrase)> DeriveApiKeyAsync(string privateKey, string walletAddress)
{
try
{
var signer = new Eip712TypedDataSigner();
var key = new EthECKey(privateKey);
string computedAddress = key.GetPublicAddress();
string timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString();
var typedData = new TypedData<ClobDomain>
{
Domain = new ClobDomain
{
Name = "ClobAuthDomain",
Version = "1",
ChainId = new System.Numerics.BigInteger(ChainId)
},
Types = Nethereum.ABI.EIP712.MemberDescriptionFactory.GetTypesMemberDescription(typeof(ClobDomain), typeof(ClobAuth)),
PrimaryType = "ClobAuth"
};
var clobAuth = new ClobAuth
{
Address = computedAddress,
Timestamp = timestamp,
Nonce = new System.Numerics.BigInteger(0),
Message = "This message attests that I control the given wallet"
};
var encoder = new Nethereum.ABI.EIP712.Eip712TypedDataEncoder();
var rawData = encoder.EncodeTypedData(clobAuth, typedData);
_logger.Warning($"DEBUG_CS_RAW_DATA: {Nethereum.Hex.HexConvertors.Extensions.HexByteConvertorExtensions.ToHex(rawData)}");
string signature = signer.SignTypedDataV4(clobAuth, typedData, key);
_logger.Warning($"DEBUG_CS_SIG: {signature}");
var request = new HttpRequestMessage(HttpMethod.Get, $"{ClobHost}/auth/derive-api-key");
request.Headers.Add("POLY_ADDRESS", computedAddress);
request.Headers.Add("POLY_SIGNATURE", signature);
request.Headers.Add("POLY_TIMESTAMP", timestamp);
request.Headers.Add("POLY_NONCE", "0");
var response = await _httpClient.SendAsync(request);
// If the key has not been created yet on Polymarket, derive might fail. We then try to create it.
if (!response.IsSuccessStatusCode)
{
_logger.Warning($"Derivation failed. Attempting to CREATE new Api Key L2 instead...");
request = new HttpRequestMessage(HttpMethod.Post, $"{ClobHost}/auth/api-key");
request.Headers.Add("POLY_ADDRESS", computedAddress);
request.Headers.Add("POLY_SIGNATURE", signature);
request.Headers.Add("POLY_TIMESTAMP", timestamp);
request.Headers.Add("POLY_NONCE", "0");
response = await _httpClient.SendAsync(request);
}
if (response.IsSuccessStatusCode)
{
var jsonStr = await response.Content.ReadAsStringAsync();
using var doc = JsonDocument.Parse(jsonStr);
string apiKey = doc.RootElement.GetProperty("apiKey").GetString() ?? "";
string secret = doc.RootElement.GetProperty("secret").GetString() ?? "";
string passphrase = doc.RootElement.GetProperty("passphrase").GetString() ?? "";
return (apiKey, secret, passphrase);
}
else
{
string err = await response.Content.ReadAsStringAsync();
_logger.Error($"Failed to execute L1 Auth: {response.StatusCode} {err}");
}
}
catch (Exception ex)
{
_logger.Error($"DeriveApiKeyAsync Exception: {ex.Message}");
}
return (string.Empty, string.Empty, string.Empty);
}
public async Task<decimal> GetUsdcBalanceAsync(AccountState acc, bool isRetry = false)
{
if (string.IsNullOrEmpty(acc.ApiKey) || string.IsNullOrEmpty(acc.ApiSecret) || string.IsNullOrEmpty(acc.ApiPassphrase) || string.IsNullOrEmpty(acc.PrivateKey))
{
_logger.Warning($"🔑 [{acc.Name}] Skipping balance fetch: ApiKey={!string.IsNullOrEmpty(acc.ApiKey)}, Secret={!string.IsNullOrEmpty(acc.ApiSecret)}, Pass={!string.IsNullOrEmpty(acc.ApiPassphrase)}, PK={!string.IsNullOrEmpty(acc.PrivateKey)}");
return 0;
}
try
{
string endpoint = "/balance-allowance";
string requestUrl = $"{endpoint}?asset_type=COLLATERAL&signature_type=2";
string timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString();
// Python SDK signs ONLY the base path, not the query params
string signature = GenerateHmacSignature(acc.ApiSecret, timestamp, "GET", endpoint);
var request = new HttpRequestMessage(HttpMethod.Get, $"{ClobHost}{requestUrl}");
var keyObj = new EthECKey(acc.PrivateKey.Replace("0x", ""));
request.Headers.Add("POLY_ADDRESS", keyObj.GetPublicAddress());
request.Headers.Add("POLY_API_KEY", acc.ApiKey);
request.Headers.Add("POLY_SIGNATURE", signature);
request.Headers.Add("POLY_TIMESTAMP", timestamp);
request.Headers.Add("POLY_PASSPHRASE", acc.ApiPassphrase);
var response = await _httpClient.SendAsync(request);
if (response.IsSuccessStatusCode)
{
var jsonStr = await response.Content.ReadAsStringAsync();
_logger.Info($"💰 [{acc.Name}] Balance API Response: {jsonStr}");
using var doc = JsonDocument.Parse(jsonStr);
if (doc.RootElement.ValueKind == JsonValueKind.Object && doc.RootElement.TryGetProperty("balance", out var balProp))
{
var balanceStr = balProp.GetString();
if (decimal.TryParse(balanceStr, System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out decimal balRaw))
{
decimal finalBal = balRaw / 1_000_000m;
_logger.Info($"💰 [{acc.Name}] Parsed Balance: {finalBal} USDC (raw: {balRaw})");
return finalBal;
}
}
_logger.Warning($"💰 [{acc.Name}] Could not parse 'balance' from response: {jsonStr}");
}
else if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized || response.StatusCode == System.Net.HttpStatusCode.Forbidden)
{
string errStr = await response.Content.ReadAsStringAsync();
_logger.Warning($"🌐 [{acc.Name}] API Keys expired/invalid. Deriving new L2 Keys from PrivateKey...");
if (!isRetry && !string.IsNullOrEmpty(acc.PrivateKey) && !string.IsNullOrEmpty(acc.WalletAddress))
{
var fallbackKeyObj = new EthECKey(acc.PrivateKey.Replace("0x", ""));
var newKeys = await DeriveApiKeyAsync(acc.PrivateKey, fallbackKeyObj.GetPublicAddress());
if (!string.IsNullOrEmpty(newKeys.ApiKey))
{
acc.ApiKey = newKeys.ApiKey;
acc.ApiSecret = newKeys.ApiSecret;
acc.ApiPassphrase = newKeys.ApiPassphrase;
_logger.Info($"🌐 [{acc.Name}] Successfully derived new L2 Keys! Resuming in 2.5s...");
// Await propagation of new keys inside Polymarket's Gamma backend
await Task.Delay(2500);
// Retry recursively strictly once
return await GetUsdcBalanceAsync(acc, true);
}
}
_logger.Error($"CLOB Balance Fetch failed: {response.StatusCode} {errStr}");
}
else
{
string errStr = await response.Content.ReadAsStringAsync();
_logger.Error($"CLOB Balance Fetch failed: {response.StatusCode} {errStr}");
}
}
catch (Exception ex)
{
_logger.Error($"CLOB Balance Fetch Error: {ex.Message}");
}
return 0;
}
private static System.Numerics.BigInteger GenerateSalt()
{
// Generate a salt similar to Py Clob Client (fits safely in a standard 64-bit int / JS Number)
long t = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
int r = System.Security.Cryptography.RandomNumberGenerator.GetInt32(0, 10000);
return new System.Numerics.BigInteger(t * 10000 + r);
}
public static (decimal shares, decimal usdc, decimal makerRaw, decimal takerRaw) CalculateExactOrderAmounts(decimal investAmountUsd, decimal rawPrice, decimal limitPrice, string sideStr, string orderType = "FOK", decimal? overrideTickSize = null, int? overrideMakerDecimals = null, int? overrideTakerDecimals = null)
{
decimal tick = overrideTickSize ?? 0.001m;
decimal priceRounded = Math.Round(rawPrice / tick) * tick;
if (priceRounded < tick) priceRounded = tick;
long priceTicks = (long)Math.Round(priceRounded * 1000m);
long makerDecimals = overrideMakerDecimals ?? (sideStr.ToUpper() == "BUY" ? 2 : 4);
long takerDecimals = overrideTakerDecimals ?? (sideStr.ToUpper() == "BUY" ? 4 : 2);
long makerStepRaw = (long)Math.Pow(10, 6 - makerDecimals);
long takerStepRaw = (long)Math.Pow(10, 6 - takerDecimals);
long numerator = 1000L * takerStepRaw;
long denominator = makerStepRaw * priceTicks;
long a = numerator, b = denominator;
while (a != 0 && b != 0) { if (a > b) a %= b; else b %= a; }
long gcd = a | b;
long N = numerator / gcd;
long baseMakerRaw = N * makerStepRaw;
decimal quantumShares;
if (sideStr.ToUpper() == "BUY")
{
long baseTakerRaw = baseMakerRaw * priceTicks / 1000L;
quantumShares = baseTakerRaw / 1000000m;
}
else
{
quantumShares = baseMakerRaw / 1000000m;
}
decimal executedShares = 0;
decimal executedUsdc = 0;
decimal finalMakerAmountRaw = 0;
decimal finalTakerAmountRaw = 0;
if (sideStr.ToUpper() == "BUY")
{
decimal sharesRaw = investAmountUsd / priceRounded;
decimal takerShares = Math.Floor(sharesRaw / quantumShares) * quantumShares;
if (takerShares < quantumShares) takerShares = quantumShares;
while (takerShares * priceRounded < 1.0m || (orderType.ToUpper() != "MARKET" && takerShares < 5.0m))
{
takerShares += quantumShares;
}
finalTakerAmountRaw = Math.Round(takerShares * 1_000_000m);
finalMakerAmountRaw = Math.Round(finalTakerAmountRaw * priceRounded);
executedShares = takerShares;
executedUsdc = finalMakerAmountRaw / 1_000_000m;
}
else
{
decimal sharesRaw = investAmountUsd / limitPrice;
decimal makerShares = Math.Floor(sharesRaw / quantumShares) * quantumShares;
if (makerShares <= 0) return (-1, -1, 0, 0);
finalMakerAmountRaw = Math.Round(makerShares * 1_000_000m);
finalTakerAmountRaw = Math.Round(finalMakerAmountRaw * priceRounded);
executedShares = makerShares;
executedUsdc = finalTakerAmountRaw / 1_000_000m;
}
return (executedShares, executedUsdc, finalMakerAmountRaw, finalTakerAmountRaw);
}
/// <summary>
/// Executes a native EIP-712 signed order (default Fill-Or-Kill)
/// </summary>
public async Task<string> PlaceOrderAsync(AccountState account, string tokenId, string sideStr, decimal investAmountUsd, decimal limitPrice, string orderType = "FOK", bool debugPayloadLog = false, bool isNegRisk = false, int actualFeeBps = 0, decimal? overrideTickSize = null, int? overrideMakerDecimals = null, int? overrideTakerDecimals = null)
{
if (string.IsNullOrEmpty(account.PrivateKey) || string.IsNullOrEmpty(account.ApiKey))
return "Error: Missing API or Private Keys";
try
{
var signer = new Eip712TypedDataSigner();
var key = new EthECKey(account.PrivateKey);
var typedData = new TypedData<CtfDomain>
{
Domain = new CtfDomain
{
Name = "Polymarket CTF Exchange",
Version = "1",
ChainId = ChainId,
VerifyingContract = isNegRisk ? "0xC5d563A36AE78145C45a50134d48A1215220f80a" : "0x4bFb41d5B3570DeFd03C39a9A4D8dE6Bd8B8982E"
},
Types = Nethereum.ABI.EIP712.MemberDescriptionFactory.GetTypesMemberDescription(typeof(CtfDomain), typeof(CtfOrder)),
PrimaryType = "Order"
};
var amounts = CalculateExactOrderAmounts(investAmountUsd, limitPrice, limitPrice, sideStr, orderType, overrideTickSize, overrideMakerDecimals, overrideTakerDecimals);
if (amounts.shares <= 0)
return $"Mathematical tick size error: Balance too small to meet fractional quantum limit for exact price matching";
decimal makerAmountRaw = amounts.makerRaw;
decimal takerAmountRaw = amounts.takerRaw;
System.Numerics.BigInteger parsedTokenId;
if (tokenId.StartsWith("0x") || tokenId.Any(c => "abcdefABCDEF".Contains(c)))
{
parsedTokenId = new Nethereum.Hex.HexTypes.HexBigInteger(tokenId.StartsWith("0x") ? tokenId : "0x" + tokenId).Value;
}
else
{
parsedTokenId = System.Numerics.BigInteger.Parse(tokenId);
}
var ctfOrder = new CtfOrder
{
Salt = GenerateSalt(),
Maker = account.WalletAddress,
Signer = key.GetPublicAddress(),
Taker = "0x0000000000000000000000000000000000000000",
TokenId = parsedTokenId,
MakerAmount = new System.Numerics.BigInteger(makerAmountRaw),
TakerAmount = new System.Numerics.BigInteger(takerAmountRaw),
Expiration = 0,
Nonce = 0,
FeeRateBps = new System.Numerics.BigInteger(actualFeeBps),
Side = sideStr.ToUpper() == "BUY" ? (byte)0 : (byte)1,
SignatureType = 2
};
string signature = signer.SignTypedDataV4(ctfOrder, typedData, key);
var reqBody = new
{
order = new
{
salt = (long)ctfOrder.Salt,
maker = ctfOrder.Maker.ToLower(),
signer = ctfOrder.Signer.ToLower(),
taker = ctfOrder.Taker.ToLower(),
tokenId = ctfOrder.TokenId.ToString(),
makerAmount = ctfOrder.MakerAmount.ToString(),
takerAmount = ctfOrder.TakerAmount.ToString(),
expiration = ctfOrder.Expiration.ToString(),
nonce = ctfOrder.Nonce.ToString(),
feeRateBps = ctfOrder.FeeRateBps.ToString(),
side = ctfOrder.Side == 0 ? "BUY" : "SELL",
signatureType = ctfOrder.SignatureType,
signature = signature
},
owner = account.ApiKey,
orderType = orderType
};
string jsonBody = JsonSerializer.Serialize(reqBody);
string timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString();
string requestPath = "/order";
string hmacSig = GenerateHmacSignature(account.ApiSecret, timestamp, "POST", requestPath, jsonBody);
using var request = new HttpRequestMessage(HttpMethod.Post, $"{ClobHost}{requestPath}");
var keyObj = new EthECKey(account.PrivateKey.Replace("0x", ""));
request.Headers.Add("POLY_ADDRESS", keyObj.GetPublicAddress());
request.Headers.Add("POLY_API_KEY", account.ApiKey);
request.Headers.Add("POLY_TIMESTAMP", timestamp);
request.Headers.Add("POLY_SIGNATURE", hmacSig);
request.Headers.Add("POLY_PASSPHRASE", account.ApiPassphrase);
request.Content = new StringContent(jsonBody, Encoding.UTF8, "application/json");
if (debugPayloadLog)
{
_logger.Debug($"[CLOB-PAYLOAD] -> {jsonBody}");
}
var response = await _httpClient.SendAsync(request);
var responseContent = await response.Content.ReadAsStringAsync();
if (!response.IsSuccessStatusCode)
{
bool isFokFail = responseContent.Contains("FOK orders are fully filled or killed");
if (isFokFail && sideStr == "BUY")
{
// Dampen FOK failed BUY logs. Usually means target price/liquidity not met for full copy size.
// We skip it silently.
return "SKIPPED_LIQUIDITY";
}
System.IO.File.WriteAllText("last_invalid_payload.json", jsonBody);
if (isFokFail && sideStr == "SELL")
{
_logger.Warning($"Liquidität für FOK SELL reicht nicht aus. (Orderbook Size limit). Rest-Shares bleiben erhalten.");
return "Nicht genügend Liquidität für vollumfänglichen Verkauf auf diesem Preisniveau (FOK).";
}
else
{
var tickMatch = System.Text.RegularExpressions.Regex.Match(responseContent, @"breaks minimum tick size rule: ([\d\.]+)");
if (tickMatch.Success && overrideTickSize == null)
{
if (decimal.TryParse(tickMatch.Groups[1].Value, System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out decimal newTickSize))
{
_logger.Info($"🔄 Automatische Anpassung an Markt Tick-Size ({newTickSize}). Order wird erneut berechnet und platziert...");
return await PlaceOrderAsync(account, tokenId, sideStr, investAmountUsd, limitPrice, orderType, debugPayloadLog, isNegRisk, actualFeeBps, newTickSize, overrideMakerDecimals, overrideTakerDecimals);
}
}
var decMatch = System.Text.RegularExpressions.Regex.Match(responseContent, @"maker amount supports a max accuracy of (\d+) decimals, taker amount a max of (\d+) decimals");
if (decMatch.Success && overrideMakerDecimals == null)
{
if (int.TryParse(decMatch.Groups[1].Value, out int newMaker) && int.TryParse(decMatch.Groups[2].Value, out int newTaker))
{
_logger.Info($"🔄 Automatische Anpassung an Dezimalregeln (Maker: {newMaker}, Taker: {newTaker}). Order wird neu berechnet...");
return await PlaceOrderAsync(account, tokenId, sideStr, investAmountUsd, limitPrice, orderType, debugPayloadLog, isNegRisk, actualFeeBps, overrideTickSize, newMaker, newTaker);
}
}
// Check if error is "invalid fee rate" -> Extract required fee -> Retry!
var match = System.Text.RegularExpressions.Regex.Match(responseContent, @"invalid fee rate \(\d+\), current market's taker fee: (\d+)");
if (match.Success && actualFeeBps == 0) // Only retry once
{
if (int.TryParse(match.Groups[1].Value, out int newFeeBps))
{
_logger.Info($"🔄 Automatische Anpassung an Taker Fee ({newFeeBps} bps). Order wird erneut platziert...");
return await PlaceOrderAsync(account, tokenId, sideStr, investAmountUsd, limitPrice, orderType, debugPayloadLog, isNegRisk, newFeeBps, overrideTickSize);
}
}
_logger.Error($"CLOB Order Error ({response.StatusCode}): {responseContent}");
}
return "ERROR";
}
if (response.IsSuccessStatusCode)
{
_logger.Info($"✅ Order Platzierung Erfolgreich! {sideStr} @ {limitPrice:F3}");
return "OK";
}
else
{
_logger.Error($"❌ Order Fehler: {response.StatusCode} - {responseContent}");
return responseContent;
}
}
catch (Exception ex)
{
_logger.Error($"PlaceFokOrderAsync Runtime Fehler: {ex.Message}");
return ex.Message;
}
}
}
}
+606
View File
@@ -0,0 +1,606 @@
using System;
using System.Linq;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;
using Nethereum.Signer;
using Nethereum.Signer.EIP712;
using Nethereum.ABI.FunctionEncoding.Attributes;
using Nethereum.ABI.EIP712;
using Nethereum.Util;
using PolyTraderSharp.Models;
namespace PolyTraderSharp.Services
{
[Struct("EIP712Domain")]
public class ClobDomain
{
[Parameter("string", "name", 1)]
public string Name { get; set; }
[Parameter("string", "version", 2)]
public string Version { get; set; } = "";
[Parameter("uint256", "chainId", 3)]
public System.Numerics.BigInteger ChainId { get; set; }
}
[Struct("EIP712Domain")]
public class CtfDomain
{
[Parameter("string", "name", 1)]
public string Name { get; set; }
[Parameter("string", "version", 2)]
public string Version { get; set; }
[Parameter("uint256", "chainId", 3)]
public ulong ChainId { get; set; }
[Parameter("address", "verifyingContract", 4)]
public string VerifyingContract { get; set; }
}
[Struct("ClobAuth")]
public class ClobAuth
{
[Parameter("address", "address", 1)]
public string Address { get; set; }
[Parameter("string", "timestamp", 2)]
public string Timestamp { get; set; } = "";
[Parameter("uint256", "nonce", 3)]
public System.Numerics.BigInteger Nonce { get; set; }
[Parameter("string", "message", 4)]
public string Message { get; set; }
}
[Struct("Order")]
public class CtfOrder
{
[Parameter("uint256", "salt", 1)]
public System.Numerics.BigInteger Salt { get; set; }
[Parameter("address", "maker", 2)]
public string Maker { get; set; }
[Parameter("address", "signer", 3)]
public string Signer { get; set; }
[Parameter("address", "taker", 4)]
public string Taker { get; set; }
[Parameter("uint256", "tokenId", 5)]
public System.Numerics.BigInteger TokenId { get; set; }
[Parameter("uint256", "makerAmount", 6)]
public System.Numerics.BigInteger MakerAmount { get; set; }
[Parameter("uint256", "takerAmount", 7)]
public System.Numerics.BigInteger TakerAmount { get; set; }
[Parameter("uint256", "expiration", 8)]
public System.Numerics.BigInteger Expiration { get; set; }
[Parameter("uint256", "nonce", 9)]
public System.Numerics.BigInteger Nonce { get; set; }
[Parameter("uint256", "feeRateBps", 10)]
public System.Numerics.BigInteger FeeRateBps { get; set; }
[Parameter("uint8", "side", 11)]
public byte Side { get; set; }
[Parameter("uint8", "signatureType", 12)]
public byte SignatureType { get; set; }
}
public class PolymarketClobClient
{
private readonly HttpClient _httpClient;
private readonly TerminalLogger _logger;
private const string ClobHost = "https://clob.polymarket.com";
private const int ChainId = 137;
public PolymarketClobClient(TerminalLogger logger, HttpClient httpClient)
{
_logger = logger;
_httpClient = httpClient;
}
/// <summary>
/// Creates an HMAC signature for authenticated requests to the Polymarket CLOB.
/// </summary>
private static string GenerateHmacSignature(string secret, string timestamp, string method, string requestPath, string body = "")
{
string payload = timestamp + method + requestPath + body;
// Convert URL-Safe Base64 back to Standard Base64
string b64 = secret.Replace('-', '+').Replace('_', '/');
switch (b64.Length % 4)
{
case 2: b64 += "=="; break;
case 3: b64 += "="; break;
}
byte[] secretBytes = Convert.FromBase64String(b64);
byte[] payloadBytes = Encoding.UTF8.GetBytes(payload);
using var hmac = new HMACSHA256(secretBytes);
byte[] hash = hmac.ComputeHash(payloadBytes);
string signature = Convert.ToBase64String(hash);
return signature.Replace('+', '-').Replace('/', '_');
}
/// <summary>
/// Derives a new Polymarket Level 2 API Key using an EIP712 Message signed by the L1 private key.
/// </summary>
public async Task<(string ApiKey, string ApiSecret, string ApiPassphrase)> DeriveApiKeyAsync(string privateKey, string walletAddress)
{
try
{
var signer = new Eip712TypedDataSigner();
var key = new EthECKey(privateKey);
string computedAddress = key.GetPublicAddress();
string timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString();
var typedData = new TypedData<ClobDomain>
{
Domain = new ClobDomain
{
Name = "ClobAuthDomain",
Version = "1",
ChainId = new System.Numerics.BigInteger(ChainId)
},
Types = Nethereum.ABI.EIP712.MemberDescriptionFactory.GetTypesMemberDescription(typeof(ClobDomain), typeof(ClobAuth)),
PrimaryType = "ClobAuth"
};
var clobAuth = new ClobAuth
{
Address = computedAddress,
Timestamp = timestamp,
Nonce = new System.Numerics.BigInteger(0),
Message = "This message attests that I control the given wallet"
};
var encoder = new Nethereum.ABI.EIP712.Eip712TypedDataEncoder();
var rawData = encoder.EncodeTypedData(clobAuth, typedData);
_logger.Warning($"DEBUG_CS_RAW_DATA: {Nethereum.Hex.HexConvertors.Extensions.HexByteConvertorExtensions.ToHex(rawData)}");
string signature = signer.SignTypedDataV4(clobAuth, typedData, key);
_logger.Warning($"DEBUG_CS_SIG: {signature}");
var request = new HttpRequestMessage(HttpMethod.Get, $"{ClobHost}/auth/derive-api-key");
request.Headers.Add("POLY_ADDRESS", computedAddress);
request.Headers.Add("POLY_SIGNATURE", signature);
request.Headers.Add("POLY_TIMESTAMP", timestamp);
request.Headers.Add("POLY_NONCE", "0");
var response = await _httpClient.SendAsync(request);
// If the key has not been created yet on Polymarket, derive might fail. We then try to create it.
if (!response.IsSuccessStatusCode)
{
_logger.Warning($"Derivation failed. Attempting to CREATE new Api Key L2 instead...");
request = new HttpRequestMessage(HttpMethod.Post, $"{ClobHost}/auth/api-key");
request.Headers.Add("POLY_ADDRESS", computedAddress);
request.Headers.Add("POLY_SIGNATURE", signature);
request.Headers.Add("POLY_TIMESTAMP", timestamp);
request.Headers.Add("POLY_NONCE", "0");
response = await _httpClient.SendAsync(request);
}
if (response.IsSuccessStatusCode)
{
var jsonStr = await response.Content.ReadAsStringAsync();
using var doc = JsonDocument.Parse(jsonStr);
string apiKey = doc.RootElement.GetProperty("apiKey").GetString() ?? "";
string secret = doc.RootElement.GetProperty("secret").GetString() ?? "";
string passphrase = doc.RootElement.GetProperty("passphrase").GetString() ?? "";
return (apiKey, secret, passphrase);
}
else
{
string err = await response.Content.ReadAsStringAsync();
_logger.Error($"Failed to execute L1 Auth: {response.StatusCode} {err}");
}
}
catch (Exception ex)
{
_logger.Error($"DeriveApiKeyAsync Exception: {ex.Message}");
}
return (string.Empty, string.Empty, string.Empty);
}
public async Task<decimal> GetUsdcBalanceAsync(AccountState acc, bool isRetry = false)
{
if (string.IsNullOrEmpty(acc.ApiKey) || string.IsNullOrEmpty(acc.ApiSecret) || string.IsNullOrEmpty(acc.ApiPassphrase) || string.IsNullOrEmpty(acc.PrivateKey))
{
_logger.Warning($"🔑 [{acc.Name}] Skipping balance fetch: ApiKey={!string.IsNullOrEmpty(acc.ApiKey)}, Secret={!string.IsNullOrEmpty(acc.ApiSecret)}, Pass={!string.IsNullOrEmpty(acc.ApiPassphrase)}, PK={!string.IsNullOrEmpty(acc.PrivateKey)}");
return 0;
}
try
{
string endpoint = "/balance-allowance";
string requestUrl = $"{endpoint}?asset_type=COLLATERAL&signature_type=2";
string timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString();
// Python SDK signs ONLY the base path, not the query params
string signature = GenerateHmacSignature(acc.ApiSecret, timestamp, "GET", endpoint);
var request = new HttpRequestMessage(HttpMethod.Get, $"{ClobHost}{requestUrl}");
var keyObj = new EthECKey(acc.PrivateKey.Replace("0x", ""));
request.Headers.Add("POLY_ADDRESS", keyObj.GetPublicAddress());
request.Headers.Add("POLY_API_KEY", acc.ApiKey);
request.Headers.Add("POLY_SIGNATURE", signature);
request.Headers.Add("POLY_TIMESTAMP", timestamp);
request.Headers.Add("POLY_PASSPHRASE", acc.ApiPassphrase);
var response = await _httpClient.SendAsync(request);
if (response.IsSuccessStatusCode)
{
var jsonStr = await response.Content.ReadAsStringAsync();
_logger.Info($"💰 [{acc.Name}] Balance API Response: {jsonStr}");
using var doc = JsonDocument.Parse(jsonStr);
if (doc.RootElement.ValueKind == JsonValueKind.Object && doc.RootElement.TryGetProperty("balance", out var balProp))
{
var balanceStr = balProp.GetString();
if (decimal.TryParse(balanceStr, System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out decimal balRaw))
{
decimal finalBal = balRaw / 1_000_000m;
_logger.Info($"💰 [{acc.Name}] Parsed Balance: {finalBal} USDC (raw: {balRaw})");
return finalBal;
}
}
_logger.Warning($"💰 [{acc.Name}] Could not parse 'balance' from response: {jsonStr}");
}
else if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized || response.StatusCode == System.Net.HttpStatusCode.Forbidden)
{
string errStr = await response.Content.ReadAsStringAsync();
_logger.Warning($"🌐 [{acc.Name}] API Keys expired/invalid. Deriving new L2 Keys from PrivateKey...");
if (!isRetry && !string.IsNullOrEmpty(acc.PrivateKey) && !string.IsNullOrEmpty(acc.WalletAddress))
{
var fallbackKeyObj = new EthECKey(acc.PrivateKey.Replace("0x", ""));
var newKeys = await DeriveApiKeyAsync(acc.PrivateKey, fallbackKeyObj.GetPublicAddress());
if (!string.IsNullOrEmpty(newKeys.ApiKey))
{
acc.ApiKey = newKeys.ApiKey;
acc.ApiSecret = newKeys.ApiSecret;
acc.ApiPassphrase = newKeys.ApiPassphrase;
_logger.Info($"🌐 [{acc.Name}] Successfully derived new L2 Keys! Resuming in 2.5s...");
// Await propagation of new keys inside Polymarket's Gamma backend
await Task.Delay(2500);
// Retry recursively strictly once
return await GetUsdcBalanceAsync(acc, true);
}
}
_logger.Error($"CLOB Balance Fetch failed: {response.StatusCode} {errStr}");
}
else
{
string errStr = await response.Content.ReadAsStringAsync();
_logger.Error($"CLOB Balance Fetch failed: {response.StatusCode} {errStr}");
}
}
catch (Exception ex)
{
_logger.Error($"CLOB Balance Fetch Error: {ex.Message}");
}
return 0;
}
private static System.Numerics.BigInteger GenerateSalt()
{
// Generate a salt similar to Py Clob Client (fits safely in a standard 64-bit int / JS Number)
long t = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
int r = System.Security.Cryptography.RandomNumberGenerator.GetInt32(0, 10000);
return new System.Numerics.BigInteger(t * 10000 + r);
}
public static (decimal shares, decimal usdc, decimal makerRaw, decimal takerRaw) CalculateExactOrderAmounts(decimal investAmountUsd, decimal rawPrice, decimal limitPrice, string sideStr, string orderType = "FOK", decimal? overrideTickSize = null, int? overrideMakerDecimals = null, int? overrideTakerDecimals = null)
{
decimal tick = overrideTickSize ?? 0.001m;
decimal priceRounded = Math.Round(rawPrice / tick) * tick;
if (priceRounded < tick) priceRounded = tick;
long priceTicks = (long)Math.Round(priceRounded * 1000m);
long makerDecimals = overrideMakerDecimals ?? (sideStr.ToUpper() == "BUY" ? 2 : 4);
long takerDecimals = overrideTakerDecimals ?? (sideStr.ToUpper() == "BUY" ? 4 : 2);
long makerStepRaw = (long)Math.Pow(10, 6 - makerDecimals);
long takerStepRaw = (long)Math.Pow(10, 6 - takerDecimals);
long numerator = 1000L * takerStepRaw;
long denominator = makerStepRaw * priceTicks;
long a = numerator, b = denominator;
while (a != 0 && b != 0) { if (a > b) a %= b; else b %= a; }
long gcd = a | b;
long N = numerator / gcd;
long baseMakerRaw = N * makerStepRaw;
decimal quantumShares;
if (sideStr.ToUpper() == "BUY")
{
long baseTakerRaw = baseMakerRaw * priceTicks / 1000L;
quantumShares = baseTakerRaw / 1000000m;
}
else
{
quantumShares = baseMakerRaw / 1000000m;
}
decimal executedShares = 0;
decimal executedUsdc = 0;
decimal finalMakerAmountRaw = 0;
decimal finalTakerAmountRaw = 0;
if (sideStr.ToUpper() == "BUY")
{
decimal sharesRaw = investAmountUsd / priceRounded;
decimal takerShares = Math.Floor(sharesRaw / quantumShares) * quantumShares;
if (takerShares < quantumShares) takerShares = quantumShares;
while (takerShares * priceRounded < 1.0m || (orderType.ToUpper() != "MARKET" && takerShares < 5.0m))
{
takerShares += quantumShares;
}
finalTakerAmountRaw = Math.Round(takerShares * 1_000_000m);
finalMakerAmountRaw = Math.Round(finalTakerAmountRaw * priceRounded);
executedShares = takerShares;
executedUsdc = finalMakerAmountRaw / 1_000_000m;
}
else
{
decimal sharesRaw = investAmountUsd / limitPrice;
decimal makerShares = Math.Floor(sharesRaw / quantumShares) * quantumShares;
if (makerShares <= 0) return (-1, -1, 0, 0);
finalMakerAmountRaw = Math.Round(makerShares * 1_000_000m);
finalTakerAmountRaw = Math.Round(finalMakerAmountRaw * priceRounded);
executedShares = makerShares;
executedUsdc = finalTakerAmountRaw / 1_000_000m;
}
return (executedShares, executedUsdc, finalMakerAmountRaw, finalTakerAmountRaw);
}
/// <summary>
/// Executes a native EIP-712 signed order (default Fill-Or-Kill)
/// </summary>
public async Task<string> PlaceOrderAsync(AccountState account, string tokenId, string sideStr, decimal investAmountUsd, decimal limitPrice, string orderType = "FOK", bool debugPayloadLog = false, bool isNegRisk = false, int actualFeeBps = 0, decimal? overrideTickSize = null, int? overrideMakerDecimals = null, int? overrideTakerDecimals = null)
{
if (string.IsNullOrEmpty(account.PrivateKey) || string.IsNullOrEmpty(account.ApiKey))
return "Error: Missing API or Private Keys";
try
{
var signer = new Eip712TypedDataSigner();
var key = new EthECKey(account.PrivateKey);
var typedData = new TypedData<CtfDomain>
{
Domain = new CtfDomain
{
Name = "Polymarket CTF Exchange",
Version = "1",
ChainId = ChainId,
VerifyingContract = isNegRisk ? "0xC5d563A36AE78145C45a50134d48A1215220f80a" : "0x4bFb41d5B3570DeFd03C39a9A4D8dE6Bd8B8982E"
},
Types = Nethereum.ABI.EIP712.MemberDescriptionFactory.GetTypesMemberDescription(typeof(CtfDomain), typeof(CtfOrder)),
PrimaryType = "Order"
};
var amounts = CalculateExactOrderAmounts(investAmountUsd, limitPrice, limitPrice, sideStr, orderType, overrideTickSize, overrideMakerDecimals, overrideTakerDecimals);
if (amounts.shares <= 0)
return $"Mathematical tick size error: Balance too small to meet fractional quantum limit for exact price matching";
decimal makerAmountRaw = amounts.makerRaw;
decimal takerAmountRaw = amounts.takerRaw;
System.Numerics.BigInteger parsedTokenId;
if (tokenId.StartsWith("0x") || tokenId.Any(c => "abcdefABCDEF".Contains(c)))
{
parsedTokenId = new Nethereum.Hex.HexTypes.HexBigInteger(tokenId.StartsWith("0x") ? tokenId : "0x" + tokenId).Value;
}
else
{
parsedTokenId = System.Numerics.BigInteger.Parse(tokenId);
}
var ctfOrder = new CtfOrder
{
Salt = GenerateSalt(),
Maker = account.WalletAddress,
Signer = key.GetPublicAddress(),
Taker = "0x0000000000000000000000000000000000000000",
TokenId = parsedTokenId,
MakerAmount = new System.Numerics.BigInteger(makerAmountRaw),
TakerAmount = new System.Numerics.BigInteger(takerAmountRaw),
Expiration = 0,
Nonce = 0,
FeeRateBps = new System.Numerics.BigInteger(actualFeeBps),
Side = sideStr.ToUpper() == "BUY" ? (byte)0 : (byte)1,
SignatureType = 2
};
string signature = signer.SignTypedDataV4(ctfOrder, typedData, key);
var reqBody = new
{
order = new
{
salt = (long)ctfOrder.Salt,
maker = ctfOrder.Maker.ToLower(),
signer = ctfOrder.Signer.ToLower(),
taker = ctfOrder.Taker.ToLower(),
tokenId = ctfOrder.TokenId.ToString(),
makerAmount = ctfOrder.MakerAmount.ToString(),
takerAmount = ctfOrder.TakerAmount.ToString(),
expiration = ctfOrder.Expiration.ToString(),
nonce = ctfOrder.Nonce.ToString(),
feeRateBps = ctfOrder.FeeRateBps.ToString(),
side = ctfOrder.Side == 0 ? "BUY" : "SELL",
signatureType = ctfOrder.SignatureType,
signature = signature
},
owner = account.ApiKey,
orderType = orderType
};
string jsonBody = JsonSerializer.Serialize(reqBody);
string timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString();
string requestPath = "/order";
string hmacSig = GenerateHmacSignature(account.ApiSecret, timestamp, "POST", requestPath, jsonBody);
using var request = new HttpRequestMessage(HttpMethod.Post, $"{ClobHost}{requestPath}");
var keyObj = new EthECKey(account.PrivateKey.Replace("0x", ""));
request.Headers.Add("POLY_ADDRESS", keyObj.GetPublicAddress());
request.Headers.Add("POLY_API_KEY", account.ApiKey);
request.Headers.Add("POLY_TIMESTAMP", timestamp);
request.Headers.Add("POLY_SIGNATURE", hmacSig);
request.Headers.Add("POLY_PASSPHRASE", account.ApiPassphrase);
request.Content = new StringContent(jsonBody, Encoding.UTF8, "application/json");
if (debugPayloadLog)
{
_logger.Debug($"[CLOB-PAYLOAD] -> {jsonBody}");
}
var response = await _httpClient.SendAsync(request);
var responseContent = await response.Content.ReadAsStringAsync();
if (!response.IsSuccessStatusCode)
{
bool isFokFail = responseContent.Contains("FOK orders are fully filled or killed");
if (isFokFail && sideStr == "BUY")
{
// Dampen FOK failed BUY logs. Usually means target price/liquidity not met for full copy size.
// We skip it silently.
return "SKIPPED_LIQUIDITY";
}
System.IO.File.WriteAllText("last_invalid_payload.json", jsonBody);
if (isFokFail && sideStr == "SELL")
{
_logger.Warning($"Liquidität für FOK SELL reicht nicht aus. (Orderbook Size limit). Rest-Shares bleiben erhalten.");
return "Nicht genügend Liquidität für vollumfänglichen Verkauf auf diesem Preisniveau (FOK).";
}
else
{
var tickMatch = System.Text.RegularExpressions.Regex.Match(responseContent, @"breaks minimum tick size rule: ([\d\.]+)");
if (tickMatch.Success && overrideTickSize == null)
{
if (decimal.TryParse(tickMatch.Groups[1].Value, System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out decimal newTickSize))
{
_logger.Info($"🔄 Automatische Anpassung an Markt Tick-Size ({newTickSize}). Order wird erneut berechnet und platziert...");
return await PlaceOrderAsync(account, tokenId, sideStr, investAmountUsd, limitPrice, orderType, debugPayloadLog, isNegRisk, actualFeeBps, newTickSize, overrideMakerDecimals, overrideTakerDecimals);
}
}
var decMatch = System.Text.RegularExpressions.Regex.Match(responseContent, @"maker amount supports a max accuracy of (\d+) decimals, taker amount a max of (\d+) decimals");
if (decMatch.Success && overrideMakerDecimals == null)
{
if (int.TryParse(decMatch.Groups[1].Value, out int newMaker) && int.TryParse(decMatch.Groups[2].Value, out int newTaker))
{
_logger.Info($"🔄 Automatische Anpassung an Dezimalregeln (Maker: {newMaker}, Taker: {newTaker}). Order wird neu berechnet...");
return await PlaceOrderAsync(account, tokenId, sideStr, investAmountUsd, limitPrice, orderType, debugPayloadLog, isNegRisk, actualFeeBps, overrideTickSize, newMaker, newTaker);
}
}
// Check if error is "invalid fee rate" -> Extract required fee -> Retry!
var match = System.Text.RegularExpressions.Regex.Match(responseContent, @"invalid fee rate \(\d+\), current market's (?:taker|maker) fee: (\d+)");
if (match.Success && actualFeeBps == 0) // Only retry once
{
if (int.TryParse(match.Groups[1].Value, out int newFeeBps))
{
_logger.Info($"🔄 Automatische Anpassung an Fee Rate ({newFeeBps} bps). Order wird erneut platziert...");
return await PlaceOrderAsync(account, tokenId, sideStr, investAmountUsd, limitPrice, orderType, debugPayloadLog, isNegRisk, newFeeBps, overrideTickSize);
}
}
// Check if error is "Size lower than minimum 5" -> Fallback to MARKET
var sizeMatch = System.Text.RegularExpressions.Regex.Match(responseContent, @"Size \([\d\.]+\) lower than the minimum: (\d+)");
if (sizeMatch.Success)
{
if (decimal.TryParse(sizeMatch.Groups[1].Value, out decimal minReq))
{
if (orderType != "MARKET")
{
_logger.Info($"🔄 Automatische Anpassung an Minimum Size Limit (Limitorder < {minReq}). Order wird als MARKET platziert...");
return await PlaceOrderAsync(account, tokenId, sideStr, investAmountUsd, limitPrice, "MARKET", debugPayloadLog, isNegRisk, actualFeeBps, overrideTickSize, overrideMakerDecimals, overrideTakerDecimals);
}
else if (sideStr == "SELL")
{
_logger.Warning($"Verkauf von unter {minReq} Shares auf Polymarket nicht möglich (Orderbook Limit). Position muss aufgestockt werden oder auslaufen.");
return $"Börsenlimit: Mindestens {minReq} Shares erforderlich.";
}
}
}
var balMatch = System.Text.RegularExpressions.Regex.Match(responseContent, @"balance: (\d+), sum of active orders: (\d+)");
if (balMatch.Success && sideStr == "SELL")
{
if (decimal.TryParse(balMatch.Groups[1].Value, out decimal totalBal) && decimal.TryParse(balMatch.Groups[2].Value, out decimal activeOrders))
{
decimal availableSharesRaw = totalBal - activeOrders;
decimal availableShares = availableSharesRaw / 1_000_000m;
decimal requiredShares = investAmountUsd / limitPrice;
if (availableShares > 0 && Math.Abs(availableShares - requiredShares) > 0.001m && availableShares < requiredShares)
{
decimal newInvestAmount = availableShares * limitPrice;
_logger.Info($"🔄 Automatische Anpassung an verfügbare Shares (Aktive Orders blockieren {activeOrders / 1000000m} Shares). Verkaufe restliche {availableShares} Shares...");
return await PlaceOrderAsync(account, tokenId, sideStr, newInvestAmount, limitPrice, orderType, debugPayloadLog, isNegRisk, actualFeeBps, overrideTickSize, overrideMakerDecimals, overrideTakerDecimals);
}
}
}
_logger.Error($"CLOB Order Error ({response.StatusCode}): {responseContent}");
}
return "ERROR";
}
if (response.IsSuccessStatusCode)
{
_logger.Info($"✅ Order Platzierung Erfolgreich! {sideStr} @ {limitPrice:F3}");
return "OK";
}
else
{
_logger.Error($"❌ Order Fehler: {response.StatusCode} - {responseContent}");
return responseContent;
}
}
catch (Exception ex)
{
_logger.Error($"PlaceFokOrderAsync Runtime Fehler: {ex.Message}");
return ex.Message;
}
}
}
}
+799
View File
@@ -0,0 +1,799 @@
using System;
using System.Linq;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;
using Nethereum.Signer;
using Nethereum.Signer.EIP712;
using Nethereum.ABI.FunctionEncoding.Attributes;
using Nethereum.ABI.EIP712;
using Nethereum.Util;
using PolyTraderSharp.Models;
namespace PolyTraderSharp.Services
{
[Struct("EIP712Domain")]
public class ClobDomain
{
[Parameter("string", "name", 1)]
public string Name { get; set; } = string.Empty;
[Parameter("string", "version", 2)]
public string Version { get; set; } = "";
[Parameter("uint256", "chainId", 3)]
public System.Numerics.BigInteger ChainId { get; set; }
}
[Struct("EIP712Domain")]
public class CtfDomain
{
[Parameter("string", "name", 1)]
public string Name { get; set; } = string.Empty;
[Parameter("string", "version", 2)]
public string Version { get; set; } = string.Empty;
[Parameter("uint256", "chainId", 3)]
public ulong ChainId { get; set; }
[Parameter("address", "verifyingContract", 4)]
public string VerifyingContract { get; set; } = string.Empty;
}
[Struct("ClobAuth")]
public class ClobAuth
{
[Parameter("address", "address", 1)]
public string Address { get; set; } = string.Empty;
[Parameter("string", "timestamp", 2)]
public string Timestamp { get; set; } = "";
[Parameter("uint256", "nonce", 3)]
public System.Numerics.BigInteger Nonce { get; set; }
[Parameter("string", "message", 4)]
public string Message { get; set; } = string.Empty;
}
[Struct("Order")]
public class CtfOrder
{
[Parameter("uint256", "salt", 1)]
public System.Numerics.BigInteger Salt { get; set; }
[Parameter("address", "maker", 2)]
public string Maker { get; set; } = string.Empty;
[Parameter("address", "signer", 3)]
public string Signer { get; set; } = string.Empty;
[Parameter("address", "taker", 4)]
public string Taker { get; set; } = string.Empty;
[Parameter("uint256", "tokenId", 5)]
public System.Numerics.BigInteger TokenId { get; set; }
[Parameter("uint256", "makerAmount", 6)]
public System.Numerics.BigInteger MakerAmount { get; set; }
[Parameter("uint256", "takerAmount", 7)]
public System.Numerics.BigInteger TakerAmount { get; set; }
[Parameter("uint256", "expiration", 8)]
public System.Numerics.BigInteger Expiration { get; set; }
[Parameter("uint256", "nonce", 9)]
public System.Numerics.BigInteger Nonce { get; set; }
[Parameter("uint256", "feeRateBps", 10)]
public System.Numerics.BigInteger FeeRateBps { get; set; }
[Parameter("uint8", "side", 11)]
public byte Side { get; set; }
[Parameter("uint8", "signatureType", 12)]
public byte SignatureType { get; set; }
}
public class PolymarketClobClient
{
private readonly HttpClient _httpClient;
private readonly TerminalLogger _logger;
private const string ClobHost = "https://clob.polymarket.com";
private const int ChainId = 137;
private static readonly object _fileLock = new object();
public PolymarketClobClient(TerminalLogger logger, HttpClient httpClient)
{
_logger = logger;
_httpClient = httpClient;
}
/// <summary>
/// Creates an HMAC signature for authenticated requests to the Polymarket CLOB.
/// </summary>
private static string GenerateHmacSignature(string secret, string timestamp, string method, string requestPath, string body = "")
{
string payload = timestamp + method + requestPath + body;
// Convert URL-Safe Base64 back to Standard Base64
string b64 = secret.Replace('-', '+').Replace('_', '/');
switch (b64.Length % 4)
{
case 2: b64 += "=="; break;
case 3: b64 += "="; break;
}
byte[] secretBytes = Convert.FromBase64String(b64);
byte[] payloadBytes = Encoding.UTF8.GetBytes(payload);
using var hmac = new HMACSHA256(secretBytes);
byte[] hash = hmac.ComputeHash(payloadBytes);
string signature = Convert.ToBase64String(hash);
return signature.Replace('+', '-').Replace('/', '_');
}
/// <summary>
/// Derives a new Polymarket Level 2 API Key using an EIP712 Message signed by the L1 private key.
/// </summary>
public async Task<(string ApiKey, string ApiSecret, string ApiPassphrase)> DeriveApiKeyAsync(string privateKey, string walletAddress)
{
try
{
var signer = new Eip712TypedDataSigner();
var key = new EthECKey(privateKey);
string computedAddress = key.GetPublicAddress();
string timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString();
var typedData = new TypedData<ClobDomain>
{
Domain = new ClobDomain
{
Name = "ClobAuthDomain",
Version = "1",
ChainId = new System.Numerics.BigInteger(ChainId)
},
Types = Nethereum.ABI.EIP712.MemberDescriptionFactory.GetTypesMemberDescription(typeof(ClobDomain), typeof(ClobAuth)),
PrimaryType = "ClobAuth"
};
var clobAuth = new ClobAuth
{
Address = computedAddress,
Timestamp = timestamp,
Nonce = new System.Numerics.BigInteger(0),
Message = "This message attests that I control the given wallet"
};
var encoder = new Nethereum.ABI.EIP712.Eip712TypedDataEncoder();
var rawData = encoder.EncodeTypedData(clobAuth, typedData);
_logger.Warning($"DEBUG_CS_RAW_DATA: {Nethereum.Hex.HexConvertors.Extensions.HexByteConvertorExtensions.ToHex(rawData)}");
string signature = signer.SignTypedDataV4(clobAuth, typedData, key);
_logger.Warning($"DEBUG_CS_SIG: {signature}");
var request = new HttpRequestMessage(HttpMethod.Get, $"{ClobHost}/auth/derive-api-key");
request.Headers.Add("POLY_ADDRESS", computedAddress);
request.Headers.Add("POLY_SIGNATURE", signature);
request.Headers.Add("POLY_TIMESTAMP", timestamp);
request.Headers.Add("POLY_NONCE", "0");
using (var response = await _httpClient.SendAsync(request))
{
if (response.IsSuccessStatusCode)
{
var jsonStr = await response.Content.ReadAsStringAsync();
using var doc = JsonDocument.Parse(jsonStr);
string apiKey = doc.RootElement.GetProperty("apiKey").GetString() ?? "";
string secret = doc.RootElement.GetProperty("secret").GetString() ?? "";
string passphrase = doc.RootElement.GetProperty("passphrase").GetString() ?? "";
return (apiKey, secret, passphrase);
}
}
_logger.Warning($"Derivation failed. Attempting to CREATE new Api Key L2 instead...");
using (var request2 = new HttpRequestMessage(HttpMethod.Post, $"{ClobHost}/auth/api-key"))
{
request2.Headers.Add("POLY_ADDRESS", computedAddress);
request2.Headers.Add("POLY_SIGNATURE", signature);
request2.Headers.Add("POLY_TIMESTAMP", timestamp);
request2.Headers.Add("POLY_NONCE", "0");
using (var response2 = await _httpClient.SendAsync(request2))
{
if (response2.IsSuccessStatusCode)
{
var jsonStr = await response2.Content.ReadAsStringAsync();
using var doc = JsonDocument.Parse(jsonStr);
string apiKey = doc.RootElement.GetProperty("apiKey").GetString() ?? "";
string secret = doc.RootElement.GetProperty("secret").GetString() ?? "";
string passphrase = doc.RootElement.GetProperty("passphrase").GetString() ?? "";
return (apiKey, secret, passphrase);
}
else
{
string err = await response2.Content.ReadAsStringAsync();
_logger.Error($"Failed to execute L1 Auth: {response2.StatusCode} {err}");
}
}
}
}
catch (Exception ex)
{
_logger.Error($"DeriveApiKeyAsync Exception: {ex.Message}");
}
return (string.Empty, string.Empty, string.Empty);
}
public async Task<decimal> GetUsdcBalanceAsync(AccountState acc, bool isRetry = false)
{
if (string.IsNullOrEmpty(acc.ApiKey) || string.IsNullOrEmpty(acc.ApiSecret) || string.IsNullOrEmpty(acc.ApiPassphrase) || string.IsNullOrEmpty(acc.PrivateKey))
{
_logger.Warning($"🔑 [{acc.Name}] Skipping balance fetch: ApiKey={!string.IsNullOrEmpty(acc.ApiKey)}, Secret={!string.IsNullOrEmpty(acc.ApiSecret)}, Pass={!string.IsNullOrEmpty(acc.ApiPassphrase)}, PK={!string.IsNullOrEmpty(acc.PrivateKey)}");
return 0;
}
try
{
string endpoint = "/balance-allowance";
string requestUrl = $"{endpoint}?asset_type=COLLATERAL&signature_type=2";
string timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString();
// Python SDK signs ONLY the base path, not the query params
string signature = GenerateHmacSignature(acc.ApiSecret, timestamp, "GET", endpoint);
var request = new HttpRequestMessage(HttpMethod.Get, $"{ClobHost}{requestUrl}");
var keyObj = new EthECKey(acc.PrivateKey.Replace("0x", ""));
request.Headers.Add("POLY_ADDRESS", keyObj.GetPublicAddress());
request.Headers.Add("POLY_API_KEY", acc.ApiKey);
request.Headers.Add("POLY_SIGNATURE", signature);
request.Headers.Add("POLY_TIMESTAMP", timestamp);
request.Headers.Add("POLY_PASSPHRASE", acc.ApiPassphrase);
using var response = await _httpClient.SendAsync(request);
if (response.IsSuccessStatusCode)
{
var jsonStr = await response.Content.ReadAsStringAsync();
_logger.Info($"💰 [{acc.Name}] Balance API Response: {jsonStr}");
using var doc = JsonDocument.Parse(jsonStr);
if (doc.RootElement.ValueKind == JsonValueKind.Object && doc.RootElement.TryGetProperty("balance", out var balProp))
{
var balanceStr = balProp.GetString();
if (decimal.TryParse(balanceStr, System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out decimal balRaw))
{
decimal finalBal = balRaw / 1_000_000m;
_logger.Info($"💰 [{acc.Name}] Parsed Balance: {finalBal} USDC (raw: {balRaw})");
return finalBal;
}
}
_logger.Warning($"💰 [{acc.Name}] Could not parse 'balance' from response: {jsonStr}");
}
else if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized || response.StatusCode == System.Net.HttpStatusCode.Forbidden)
{
string errStr = await response.Content.ReadAsStringAsync();
_logger.Warning($"🌐 [{acc.Name}] API Keys expired/invalid. Deriving new L2 Keys from PrivateKey...");
if (!isRetry && !string.IsNullOrEmpty(acc.PrivateKey) && !string.IsNullOrEmpty(acc.WalletAddress))
{
var fallbackKeyObj = new EthECKey(acc.PrivateKey.Replace("0x", ""));
var newKeys = await DeriveApiKeyAsync(acc.PrivateKey, fallbackKeyObj.GetPublicAddress());
if (!string.IsNullOrEmpty(newKeys.ApiKey))
{
acc.ApiKey = newKeys.ApiKey;
acc.ApiSecret = newKeys.ApiSecret;
acc.ApiPassphrase = newKeys.ApiPassphrase;
_logger.Info($"🌐 [{acc.Name}] Successfully derived new L2 Keys! Resuming in 2.5s...");
// Await propagation of new keys inside Polymarket's Gamma backend
await Task.Delay(2500);
// Retry recursively strictly once
return await GetUsdcBalanceAsync(acc, true);
}
}
_logger.Error($"CLOB Balance Fetch failed: {response.StatusCode} {errStr}");
}
else
{
string errStr = await response.Content.ReadAsStringAsync();
_logger.Error($"CLOB Balance Fetch failed: {response.StatusCode} {errStr}");
}
}
catch (Exception ex)
{
_logger.Error($"CLOB Balance Fetch Error: {ex.Message}");
}
return 0;
}
public async Task<System.Collections.Generic.List<(string Id, string Side, decimal Price)>> GetOpenOrdersAsync(AccountState acc, string assetId)
{
var result = new System.Collections.Generic.List<(string Id, string Side, decimal Price)>();
if (string.IsNullOrEmpty(acc.ApiKey) || string.IsNullOrEmpty(acc.ApiSecret) || string.IsNullOrEmpty(acc.ApiPassphrase) || string.IsNullOrEmpty(acc.PrivateKey))
return result;
try
{
string endpoint = "/orders";
string requestUrl = $"{endpoint}?asset_id={assetId}";
string timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString();
string signature = GenerateHmacSignature(acc.ApiSecret, timestamp, "GET", endpoint);
var request = new HttpRequestMessage(HttpMethod.Get, $"{ClobHost}{requestUrl}");
var keyObj = new EthECKey(acc.PrivateKey.Replace("0x", ""));
request.Headers.Add("POLY_ADDRESS", keyObj.GetPublicAddress());
request.Headers.Add("POLY_API_KEY", acc.ApiKey);
request.Headers.Add("POLY_SIGNATURE", signature);
request.Headers.Add("POLY_TIMESTAMP", timestamp);
request.Headers.Add("POLY_PASSPHRASE", acc.ApiPassphrase);
using var response = await _httpClient.SendAsync(request);
if (response.IsSuccessStatusCode)
{
var jsonStr = await response.Content.ReadAsStringAsync();
using var doc = JsonDocument.Parse(jsonStr);
if (doc.RootElement.TryGetProperty("data", out var dataArr) && dataArr.ValueKind == JsonValueKind.Array)
{
foreach (var orderLine in dataArr.EnumerateArray())
{
if (orderLine.TryGetProperty("orderID", out var oid) || orderLine.TryGetProperty("id", out oid))
{
string idStr = oid.GetString() ?? "";
string sideStr = orderLine.TryGetProperty("side", out var s) ? (s.GetString() ?? "") : "";
string priceStr = orderLine.TryGetProperty("price", out var p) ? (p.GetString() ?? "0") : "0";
decimal.TryParse(priceStr, System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out decimal priceDec);
if (!string.IsNullOrEmpty(idStr))
result.Add((idStr, sideStr, priceDec));
}
}
}
else if (doc.RootElement.ValueKind == JsonValueKind.Array)
{
foreach (var orderLine in doc.RootElement.EnumerateArray())
{
if (orderLine.TryGetProperty("orderID", out var oid) || orderLine.TryGetProperty("id", out oid))
{
string idStr = oid.GetString() ?? "";
string sideStr = orderLine.TryGetProperty("side", out var s) ? (s.GetString() ?? "") : "";
string priceStr = orderLine.TryGetProperty("price", out var p) ? (p.GetString() ?? "0") : "0";
decimal.TryParse(priceStr, System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out decimal priceDec);
if (!string.IsNullOrEmpty(idStr))
result.Add((idStr, sideStr, priceDec));
}
}
}
}
else
{
string errStr = await response.Content.ReadAsStringAsync();
_logger.Warning($"Failed to GET open orders for {assetId}: {response.StatusCode} {errStr}");
}
}
catch (Exception ex)
{
_logger.Error($"GetOpenOrdersAsync Error: {ex.Message}");
}
return result;
}
public async Task<bool> CancelOrderAsync(AccountState acc, string orderId)
{
if (string.IsNullOrEmpty(acc.ApiKey) || string.IsNullOrEmpty(acc.ApiSecret) || string.IsNullOrEmpty(acc.ApiPassphrase) || string.IsNullOrEmpty(acc.PrivateKey))
return false;
try
{
string endpoint = "/order";
var reqBody = new { orderID = orderId };
string jsonBody = JsonSerializer.Serialize(reqBody);
string timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString();
string signature = GenerateHmacSignature(acc.ApiSecret, timestamp, "DELETE", endpoint, jsonBody);
using var request = new HttpRequestMessage(HttpMethod.Delete, $"{ClobHost}{endpoint}");
var keyObj = new EthECKey(acc.PrivateKey.Replace("0x", ""));
request.Headers.Add("POLY_ADDRESS", keyObj.GetPublicAddress());
request.Headers.Add("POLY_API_KEY", acc.ApiKey);
request.Headers.Add("POLY_SIGNATURE", signature);
request.Headers.Add("POLY_TIMESTAMP", timestamp);
request.Headers.Add("POLY_PASSPHRASE", acc.ApiPassphrase);
request.Content = new StringContent(jsonBody, Encoding.UTF8, "application/json");
using var response = await _httpClient.SendAsync(request);
if (response.IsSuccessStatusCode)
{
_logger.Info($"🚮 [{acc.Name}] Stornierung erfolgreich. OrderID: {orderId}");
return true;
}
else
{
string errStr = await response.Content.ReadAsStringAsync();
_logger.Warning($"Failed to cancel order {orderId}: {response.StatusCode} {errStr}");
return false;
}
}
catch (Exception ex)
{
_logger.Error($"CancelOrderAsync Error: {ex.Message}");
return false;
}
}
public async Task CancelConflictingOrdersAsync(AccountState acc, string assetId, decimal newPrice, string sideStr)
{
var openOrders = await GetOpenOrdersAsync(acc, assetId);
if (openOrders.Count > 0)
{
var tasks = new System.Collections.Generic.List<Task>();
foreach (var order in openOrders)
{
bool shouldCancel = false;
if (sideStr.Equals("SELL", StringComparison.OrdinalIgnoreCase))
{
shouldCancel = true;
_logger.Info($"⚠️ [{acc.Name}] Storniere Order {order.Id} wegen Verkaufs-Signal des Master-Traders.");
}
else if (sideStr.Equals("BUY", StringComparison.OrdinalIgnoreCase) && order.Side.Equals("BUY", StringComparison.OrdinalIgnoreCase))
{
if (Math.Abs(order.Price - newPrice) > 0.001m)
{
shouldCancel = true;
_logger.Info($"⚠️ [{acc.Name}] Storniere veraltete Order {order.Id} (Alter Preis: {order.Price:F3}, Neuer Preis: {newPrice:F3})");
}
else
{
_logger.Info($"✅ [{acc.Name}] Behalte bestehende Order {order.Id} (Preis identisch: {order.Price:F3})");
}
}
if (shouldCancel)
{
tasks.Add(CancelOrderAsync(acc, order.Id));
}
}
if (tasks.Count > 0)
{
await Task.WhenAll(tasks);
// Minimal delay to ensure rapid executions don't conflict with in-flight deletions
await Task.Delay(150);
}
}
}
private static System.Numerics.BigInteger GenerateSalt()
{
// Generate a salt similar to Py Clob Client (fits safely in a standard 64-bit int / JS Number)
long t = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
int r = System.Security.Cryptography.RandomNumberGenerator.GetInt32(0, 10000);
return new System.Numerics.BigInteger(t * 10000 + r);
}
public static (decimal shares, decimal usdc, decimal makerRaw, decimal takerRaw) CalculateExactOrderAmounts(decimal investAmountUsd, decimal rawPrice, decimal limitPrice, string sideStr, string orderType = "FOK", decimal? overrideTickSize = null, int? overrideMakerDecimals = null, int? overrideTakerDecimals = null)
{
decimal tickSize = overrideTickSize ?? 0.001m;
int priceDec, sizeDec, amtDec;
if (tickSize >= 0.1m) { priceDec = 1; sizeDec = 2; amtDec = 3; }
else if (tickSize >= 0.01m) { priceDec = 2; sizeDec = 2; amtDec = 4; }
else if (tickSize >= 0.001m) { priceDec = 3; sizeDec = 2; amtDec = 5; }
else { priceDec = 4; sizeDec = 2; amtDec = 6; }
decimal priceRounded = Math.Round(limitPrice > 0 ? limitPrice : rawPrice, priceDec, MidpointRounding.AwayFromZero);
if (priceRounded < tickSize) priceRounded = tickSize;
decimal executedShares = 0m;
decimal executedUsdc = 0m;
decimal finalMakerAmountRaw = 0m;
decimal finalTakerAmountRaw = 0m;
if (sideStr.ToUpper() == "BUY")
{
decimal rawTakerShares = investAmountUsd / priceRounded;
decimal multiplier = (decimal)Math.Pow(10, sizeDec);
decimal takerShares = Math.Floor(rawTakerShares * multiplier) / multiplier;
if (takerShares <= 0) return (-1, -1, 0, 0);
decimal makerUsd = 0m;
// Polymarket strictly enforces $1.00 minimum for MARKET BUYS and verifies it against the supported shares.
// We increment takerShares until the floored USDC amount supports the exact shares without dropping below $1.00.
decimal step = 1.0m / multiplier;
while (takerShares > 0)
{
makerUsd = takerShares * priceRounded;
int actDec = BitConverter.GetBytes(decimal.GetBits(makerUsd)[3])[2];
if (actDec > amtDec)
{
decimal mul2 = (decimal)Math.Pow(10, amtDec + 4);
makerUsd = Math.Ceiling(makerUsd * mul2) / mul2;
if (BitConverter.GetBytes(decimal.GetBits(makerUsd)[3])[2] > amtDec)
{
decimal mul3 = (decimal)Math.Pow(10, amtDec);
makerUsd = Math.Floor(makerUsd * mul3) / mul3;
}
}
decimal supportedShares = Math.Floor((makerUsd / priceRounded) * multiplier) / multiplier;
if (makerUsd >= 1.0m && supportedShares >= takerShares)
break;
takerShares += step;
}
finalTakerAmountRaw = Math.Round(takerShares * 1_000_000m);
finalMakerAmountRaw = Math.Round(makerUsd * 1_000_000m);
executedShares = takerShares;
executedUsdc = makerUsd;
}
else
{
decimal sharesRaw = investAmountUsd / priceRounded;
decimal multiplier = (decimal)Math.Pow(10, sizeDec);
decimal makerShares = Math.Floor(sharesRaw * multiplier) / multiplier;
// Polymarket STRICTLY enforces a 5 share minimum for ANY sell order on the CLOB
if (makerShares < 5.0m) return (-1, -1, 0, 0);
decimal takerUsd = makerShares * priceRounded;
int actDec = BitConverter.GetBytes(decimal.GetBits(takerUsd)[3])[2];
if (actDec > amtDec)
{
decimal mul2 = (decimal)Math.Pow(10, amtDec + 4);
takerUsd = Math.Ceiling(takerUsd * mul2) / mul2;
if (BitConverter.GetBytes(decimal.GetBits(takerUsd)[3])[2] > amtDec)
{
decimal mul3 = (decimal)Math.Pow(10, amtDec);
takerUsd = Math.Floor(takerUsd * mul3) / mul3;
}
}
finalMakerAmountRaw = Math.Round(makerShares * 1_000_000m);
finalTakerAmountRaw = Math.Round(takerUsd * 1_000_000m);
executedShares = makerShares;
executedUsdc = takerUsd;
}
return (executedShares, executedUsdc, finalMakerAmountRaw, finalTakerAmountRaw);
}
/// <summary>
/// Executes a native EIP-712 signed order (default Fill-Or-Kill)
/// </summary>
public async Task<string> PlaceOrderAsync(AccountState account, string tokenId, string sideStr, decimal investAmountUsd, decimal limitPrice, string orderType = "FOK", bool debugPayloadLog = false, bool isNegRisk = false, int actualFeeBps = 0, decimal? overrideTickSize = null, int? overrideMakerDecimals = null, int? overrideTakerDecimals = null)
{
if (string.IsNullOrEmpty(account.PrivateKey) || string.IsNullOrEmpty(account.ApiKey))
return "Error: Missing API or Private Keys";
try
{
var signer = new Eip712TypedDataSigner();
var key = new EthECKey(account.PrivateKey);
var typedData = new TypedData<CtfDomain>
{
Domain = new CtfDomain
{
Name = "Polymarket CTF Exchange",
Version = "1",
ChainId = ChainId,
VerifyingContract = isNegRisk ? "0xC5d563A36AE78145C45a50134d48A1215220f80a" : "0x4bFb41d5B3570DeFd03C39a9A4D8dE6Bd8B8982E"
},
Types = Nethereum.ABI.EIP712.MemberDescriptionFactory.GetTypesMemberDescription(typeof(CtfDomain), typeof(CtfOrder)),
PrimaryType = "Order"
};
var amounts = CalculateExactOrderAmounts(investAmountUsd, limitPrice, limitPrice, sideStr, orderType, overrideTickSize, overrideMakerDecimals, overrideTakerDecimals);
if (amounts.shares <= 0)
return $"Mathematical tick size error: Balance too small to meet fractional quantum limit for exact price matching";
decimal makerAmountRaw = amounts.makerRaw;
decimal takerAmountRaw = amounts.takerRaw;
System.Numerics.BigInteger parsedTokenId;
if (tokenId.StartsWith("0x") || tokenId.Any(c => "abcdefABCDEF".Contains(c)))
{
parsedTokenId = new Nethereum.Hex.HexTypes.HexBigInteger(tokenId.StartsWith("0x") ? tokenId : "0x" + tokenId).Value;
}
else
{
parsedTokenId = System.Numerics.BigInteger.Parse(tokenId);
}
var ctfOrder = new CtfOrder
{
Salt = GenerateSalt(),
Maker = account.WalletAddress,
Signer = key.GetPublicAddress(),
Taker = "0x0000000000000000000000000000000000000000",
TokenId = parsedTokenId,
MakerAmount = new System.Numerics.BigInteger(makerAmountRaw),
TakerAmount = new System.Numerics.BigInteger(takerAmountRaw),
Expiration = 0,
Nonce = 0,
FeeRateBps = new System.Numerics.BigInteger(actualFeeBps),
Side = sideStr.ToUpper() == "BUY" ? (byte)0 : (byte)1,
SignatureType = 2
};
string signature = signer.SignTypedDataV4(ctfOrder, typedData, key);
var reqBody = new
{
order = new
{
salt = (long)ctfOrder.Salt,
maker = ctfOrder.Maker.ToLower(),
signer = ctfOrder.Signer.ToLower(),
taker = ctfOrder.Taker.ToLower(),
tokenId = ctfOrder.TokenId.ToString(),
makerAmount = ctfOrder.MakerAmount.ToString(),
takerAmount = ctfOrder.TakerAmount.ToString(),
expiration = ctfOrder.Expiration.ToString(),
nonce = ctfOrder.Nonce.ToString(),
feeRateBps = ctfOrder.FeeRateBps.ToString(),
side = ctfOrder.Side == 0 ? "BUY" : "SELL",
signatureType = ctfOrder.SignatureType,
signature = signature
},
owner = account.ApiKey,
orderType = orderType
};
string jsonBody = JsonSerializer.Serialize(reqBody);
string timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString();
string requestPath = "/order";
string hmacSig = GenerateHmacSignature(account.ApiSecret, timestamp, "POST", requestPath, jsonBody);
using var request = new HttpRequestMessage(HttpMethod.Post, $"{ClobHost}{requestPath}");
var keyObj = new EthECKey(account.PrivateKey.Replace("0x", ""));
request.Headers.Add("POLY_ADDRESS", keyObj.GetPublicAddress());
request.Headers.Add("POLY_API_KEY", account.ApiKey);
request.Headers.Add("POLY_TIMESTAMP", timestamp);
request.Headers.Add("POLY_SIGNATURE", hmacSig);
request.Headers.Add("POLY_PASSPHRASE", account.ApiPassphrase);
request.Content = new StringContent(jsonBody, Encoding.UTF8, "application/json");
if (debugPayloadLog)
{
_logger.Debug($"[CLOB-PAYLOAD] -> {jsonBody}");
}
using var response = await _httpClient.SendAsync(request);
var responseContent = await response.Content.ReadAsStringAsync();
if (!response.IsSuccessStatusCode)
{
bool isFokFail = responseContent.Contains("FOK orders are fully filled or killed");
if (isFokFail && sideStr == "BUY")
{
// Dampen FOK failed BUY logs. Usually means target price/liquidity not met for full copy size.
// We skip it silently.
return "SKIPPED_LIQUIDITY";
}
lock (_fileLock)
{
System.IO.File.WriteAllText("last_invalid_payload.json", jsonBody);
}
if (isFokFail && sideStr == "SELL")
{
_logger.Warning($"Liquidität für FOK SELL reicht nicht aus. (Orderbook Size limit). Rest-Shares bleiben erhalten.");
return "Nicht genügend Liquidität für vollumfänglichen Verkauf auf diesem Preisniveau (FOK).";
}
else
{
var tickMatch = System.Text.RegularExpressions.Regex.Match(responseContent, @"breaks minimum tick size rule: ([\d\.]+)");
if (tickMatch.Success && overrideTickSize == null)
{
if (decimal.TryParse(tickMatch.Groups[1].Value, System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out decimal newTickSize))
{
_logger.Info($"🔄 Automatische Anpassung an Markt Tick-Size ({newTickSize}). Order wird erneut berechnet und platziert...");
return await PlaceOrderAsync(account, tokenId, sideStr, investAmountUsd, limitPrice, orderType, debugPayloadLog, isNegRisk, actualFeeBps, newTickSize, overrideMakerDecimals, overrideTakerDecimals);
}
}
var decMatch = System.Text.RegularExpressions.Regex.Match(responseContent, @"maker amount supports a max accuracy of (\d+) decimals, taker amount a max of (\d+) decimals");
if (decMatch.Success && overrideMakerDecimals == null)
{
if (int.TryParse(decMatch.Groups[1].Value, out int newMaker) && int.TryParse(decMatch.Groups[2].Value, out int newTaker))
{
_logger.Info($"🔄 Automatische Anpassung an Dezimalregeln (Maker: {newMaker}, Taker: {newTaker}). Order wird neu berechnet...");
return await PlaceOrderAsync(account, tokenId, sideStr, investAmountUsd, limitPrice, orderType, debugPayloadLog, isNegRisk, actualFeeBps, overrideTickSize, newMaker, newTaker);
}
}
// Check if error is "invalid fee rate" -> Extract required fee -> Retry!
var match = System.Text.RegularExpressions.Regex.Match(responseContent, @"invalid fee rate \(\d+\), current market's (?:taker|maker) fee: (\d+)");
if (match.Success && actualFeeBps == 0) // Only retry once
{
if (int.TryParse(match.Groups[1].Value, out int newFeeBps))
{
_logger.Info($"🔄 Automatische Anpassung an Fee Rate ({newFeeBps} bps). Order wird erneut platziert...");
return await PlaceOrderAsync(account, tokenId, sideStr, investAmountUsd, limitPrice, orderType, debugPayloadLog, isNegRisk, newFeeBps, overrideTickSize);
}
}
// Check if error is "Size lower than minimum 5" -> Fallback to MARKET
var sizeMatch = System.Text.RegularExpressions.Regex.Match(responseContent, @"Size \([\d\.]+\) lower than the minimum: (\d+)");
if (sizeMatch.Success)
{
if (decimal.TryParse(sizeMatch.Groups[1].Value, out decimal minReq))
{
if (orderType != "MARKET")
{
_logger.Info($"🔄 Automatische Anpassung an Minimum Size Limit (Limitorder < {minReq}). Order wird als MARKET platziert...");
return await PlaceOrderAsync(account, tokenId, sideStr, investAmountUsd, limitPrice, "MARKET", debugPayloadLog, isNegRisk, actualFeeBps, overrideTickSize, overrideMakerDecimals, overrideTakerDecimals);
}
else if (sideStr == "SELL")
{
_logger.Warning($"Verkauf von unter {minReq} Shares auf Polymarket nicht möglich (Orderbook Limit). Position muss aufgestockt werden oder auslaufen.");
return $"Börsenlimit: Mindestens {minReq} Shares erforderlich.";
}
}
}
var balMatch = System.Text.RegularExpressions.Regex.Match(responseContent, @"balance: (\d+), sum of active orders: (\d+)");
if (balMatch.Success && sideStr == "SELL")
{
if (decimal.TryParse(balMatch.Groups[1].Value, out decimal totalBal) && decimal.TryParse(balMatch.Groups[2].Value, out decimal activeOrders))
{
decimal availableSharesRaw = totalBal - activeOrders;
decimal availableShares = availableSharesRaw / 1_000_000m;
decimal requiredShares = investAmountUsd / limitPrice;
if (availableShares > 0 && Math.Abs(availableShares - requiredShares) > 0.001m && availableShares < requiredShares)
{
decimal newInvestAmount = availableShares * limitPrice;
_logger.Info($"🔄 Automatische Anpassung an verfügbare Shares (Aktive Orders blockieren {activeOrders / 1000000m} Shares). Verkaufe restliche {availableShares} Shares...");
return await PlaceOrderAsync(account, tokenId, sideStr, newInvestAmount, limitPrice, orderType, debugPayloadLog, isNegRisk, actualFeeBps, overrideTickSize, overrideMakerDecimals, overrideTakerDecimals);
}
}
}
_logger.Error($"CLOB Order Error ({response.StatusCode}): {responseContent}");
}
return "ERROR";
}
if (response.IsSuccessStatusCode)
{
_logger.Info($"✅ Order Platzierung Erfolgreich! {sideStr} @ {limitPrice:F3}");
return "OK";
}
else
{
_logger.Error($"❌ Order Fehler: {response.StatusCode} - {responseContent}");
return responseContent;
}
}
catch (Exception ex)
{
_logger.Error($"PlaceFokOrderAsync Runtime Fehler: {ex.Message}");
return ex.Message;
}
}
}
}
+799
View File
@@ -0,0 +1,799 @@
using System;
using System.Linq;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;
using Nethereum.Signer;
using Nethereum.Signer.EIP712;
using Nethereum.ABI.FunctionEncoding.Attributes;
using Nethereum.ABI.EIP712;
using Nethereum.Util;
using PolyTraderSharp.Models;
namespace PolyTraderSharp.Services
{
[Struct("EIP712Domain")]
public class ClobDomain
{
[Parameter("string", "name", 1)]
public string Name { get; set; } = string.Empty;
[Parameter("string", "version", 2)]
public string Version { get; set; } = "";
[Parameter("uint256", "chainId", 3)]
public System.Numerics.BigInteger ChainId { get; set; }
}
[Struct("EIP712Domain")]
public class CtfDomain
{
[Parameter("string", "name", 1)]
public string Name { get; set; } = string.Empty;
[Parameter("string", "version", 2)]
public string Version { get; set; } = string.Empty;
[Parameter("uint256", "chainId", 3)]
public ulong ChainId { get; set; }
[Parameter("address", "verifyingContract", 4)]
public string VerifyingContract { get; set; } = string.Empty;
}
[Struct("ClobAuth")]
public class ClobAuth
{
[Parameter("address", "address", 1)]
public string Address { get; set; } = string.Empty;
[Parameter("string", "timestamp", 2)]
public string Timestamp { get; set; } = "";
[Parameter("uint256", "nonce", 3)]
public System.Numerics.BigInteger Nonce { get; set; }
[Parameter("string", "message", 4)]
public string Message { get; set; } = string.Empty;
}
[Struct("Order")]
public class CtfOrder
{
[Parameter("uint256", "salt", 1)]
public System.Numerics.BigInteger Salt { get; set; }
[Parameter("address", "maker", 2)]
public string Maker { get; set; } = string.Empty;
[Parameter("address", "signer", 3)]
public string Signer { get; set; } = string.Empty;
[Parameter("address", "taker", 4)]
public string Taker { get; set; } = string.Empty;
[Parameter("uint256", "tokenId", 5)]
public System.Numerics.BigInteger TokenId { get; set; }
[Parameter("uint256", "makerAmount", 6)]
public System.Numerics.BigInteger MakerAmount { get; set; }
[Parameter("uint256", "takerAmount", 7)]
public System.Numerics.BigInteger TakerAmount { get; set; }
[Parameter("uint256", "expiration", 8)]
public System.Numerics.BigInteger Expiration { get; set; }
[Parameter("uint256", "nonce", 9)]
public System.Numerics.BigInteger Nonce { get; set; }
[Parameter("uint256", "feeRateBps", 10)]
public System.Numerics.BigInteger FeeRateBps { get; set; }
[Parameter("uint8", "side", 11)]
public byte Side { get; set; }
[Parameter("uint8", "signatureType", 12)]
public byte SignatureType { get; set; }
}
public class PolymarketClobClient
{
private readonly HttpClient _httpClient;
private readonly TerminalLogger _logger;
private const string ClobHost = "https://clob.polymarket.com";
private const int ChainId = 137;
private static readonly object _fileLock = new object();
public PolymarketClobClient(TerminalLogger logger, HttpClient httpClient)
{
_logger = logger;
_httpClient = httpClient;
}
/// <summary>
/// Creates an HMAC signature for authenticated requests to the Polymarket CLOB.
/// </summary>
private static string GenerateHmacSignature(string secret, string timestamp, string method, string requestPath, string body = "")
{
string payload = timestamp + method + requestPath + body;
// Convert URL-Safe Base64 back to Standard Base64
string b64 = secret.Replace('-', '+').Replace('_', '/');
switch (b64.Length % 4)
{
case 2: b64 += "=="; break;
case 3: b64 += "="; break;
}
byte[] secretBytes = Convert.FromBase64String(b64);
byte[] payloadBytes = Encoding.UTF8.GetBytes(payload);
using var hmac = new HMACSHA256(secretBytes);
byte[] hash = hmac.ComputeHash(payloadBytes);
string signature = Convert.ToBase64String(hash);
return signature.Replace('+', '-').Replace('/', '_');
}
/// <summary>
/// Derives a new Polymarket Level 2 API Key using an EIP712 Message signed by the L1 private key.
/// </summary>
public async Task<(string ApiKey, string ApiSecret, string ApiPassphrase)> DeriveApiKeyAsync(string privateKey, string walletAddress)
{
try
{
var signer = new Eip712TypedDataSigner();
var key = new EthECKey(privateKey);
string computedAddress = key.GetPublicAddress();
string timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString();
var typedData = new TypedData<ClobDomain>
{
Domain = new ClobDomain
{
Name = "ClobAuthDomain",
Version = "1",
ChainId = new System.Numerics.BigInteger(ChainId)
},
Types = Nethereum.ABI.EIP712.MemberDescriptionFactory.GetTypesMemberDescription(typeof(ClobDomain), typeof(ClobAuth)),
PrimaryType = "ClobAuth"
};
var clobAuth = new ClobAuth
{
Address = computedAddress,
Timestamp = timestamp,
Nonce = new System.Numerics.BigInteger(0),
Message = "This message attests that I control the given wallet"
};
var encoder = new Nethereum.ABI.EIP712.Eip712TypedDataEncoder();
var rawData = encoder.EncodeTypedData(clobAuth, typedData);
_logger.Warning($"DEBUG_CS_RAW_DATA: {Nethereum.Hex.HexConvertors.Extensions.HexByteConvertorExtensions.ToHex(rawData)}");
string signature = signer.SignTypedDataV4(clobAuth, typedData, key);
_logger.Warning($"DEBUG_CS_SIG: {signature}");
var request = new HttpRequestMessage(HttpMethod.Get, $"{ClobHost}/auth/derive-api-key");
request.Headers.Add("POLY_ADDRESS", computedAddress);
request.Headers.Add("POLY_SIGNATURE", signature);
request.Headers.Add("POLY_TIMESTAMP", timestamp);
request.Headers.Add("POLY_NONCE", "0");
using (var response = await _httpClient.SendAsync(request))
{
if (response.IsSuccessStatusCode)
{
var jsonStr = await response.Content.ReadAsStringAsync();
using var doc = JsonDocument.Parse(jsonStr);
string apiKey = doc.RootElement.GetProperty("apiKey").GetString() ?? "";
string secret = doc.RootElement.GetProperty("secret").GetString() ?? "";
string passphrase = doc.RootElement.GetProperty("passphrase").GetString() ?? "";
return (apiKey, secret, passphrase);
}
}
_logger.Warning($"Derivation failed. Attempting to CREATE new Api Key L2 instead...");
using (var request2 = new HttpRequestMessage(HttpMethod.Post, $"{ClobHost}/auth/api-key"))
{
request2.Headers.Add("POLY_ADDRESS", computedAddress);
request2.Headers.Add("POLY_SIGNATURE", signature);
request2.Headers.Add("POLY_TIMESTAMP", timestamp);
request2.Headers.Add("POLY_NONCE", "0");
using (var response2 = await _httpClient.SendAsync(request2))
{
if (response2.IsSuccessStatusCode)
{
var jsonStr = await response2.Content.ReadAsStringAsync();
using var doc = JsonDocument.Parse(jsonStr);
string apiKey = doc.RootElement.GetProperty("apiKey").GetString() ?? "";
string secret = doc.RootElement.GetProperty("secret").GetString() ?? "";
string passphrase = doc.RootElement.GetProperty("passphrase").GetString() ?? "";
return (apiKey, secret, passphrase);
}
else
{
string err = await response2.Content.ReadAsStringAsync();
_logger.Error($"Failed to execute L1 Auth: {response2.StatusCode} {err}");
}
}
}
}
catch (Exception ex)
{
_logger.Error($"DeriveApiKeyAsync Exception: {ex.Message}");
}
return (string.Empty, string.Empty, string.Empty);
}
public async Task<decimal> GetUsdcBalanceAsync(AccountState acc, bool isRetry = false)
{
if (string.IsNullOrEmpty(acc.ApiKey) || string.IsNullOrEmpty(acc.ApiSecret) || string.IsNullOrEmpty(acc.ApiPassphrase) || string.IsNullOrEmpty(acc.PrivateKey))
{
_logger.Warning($"🔑 [{acc.Name}] Skipping balance fetch: ApiKey={!string.IsNullOrEmpty(acc.ApiKey)}, Secret={!string.IsNullOrEmpty(acc.ApiSecret)}, Pass={!string.IsNullOrEmpty(acc.ApiPassphrase)}, PK={!string.IsNullOrEmpty(acc.PrivateKey)}");
return 0;
}
try
{
string endpoint = "/balance-allowance";
string requestUrl = $"{endpoint}?asset_type=COLLATERAL&signature_type=2";
string timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString();
// Python SDK signs ONLY the base path, not the query params
string signature = GenerateHmacSignature(acc.ApiSecret, timestamp, "GET", endpoint);
var request = new HttpRequestMessage(HttpMethod.Get, $"{ClobHost}{requestUrl}");
var keyObj = new EthECKey(acc.PrivateKey.Replace("0x", ""));
request.Headers.Add("POLY_ADDRESS", keyObj.GetPublicAddress());
request.Headers.Add("POLY_API_KEY", acc.ApiKey);
request.Headers.Add("POLY_SIGNATURE", signature);
request.Headers.Add("POLY_TIMESTAMP", timestamp);
request.Headers.Add("POLY_PASSPHRASE", acc.ApiPassphrase);
using var response = await _httpClient.SendAsync(request);
if (response.IsSuccessStatusCode)
{
var jsonStr = await response.Content.ReadAsStringAsync();
_logger.Info($"💰 [{acc.Name}] Balance API Response: {jsonStr}");
using var doc = JsonDocument.Parse(jsonStr);
if (doc.RootElement.ValueKind == JsonValueKind.Object && doc.RootElement.TryGetProperty("balance", out var balProp))
{
var balanceStr = balProp.GetString();
if (decimal.TryParse(balanceStr, System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out decimal balRaw))
{
decimal finalBal = balRaw / 1_000_000m;
_logger.Info($"💰 [{acc.Name}] Parsed Balance: {finalBal} USDC (raw: {balRaw})");
return finalBal;
}
}
_logger.Warning($"💰 [{acc.Name}] Could not parse 'balance' from response: {jsonStr}");
}
else if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized || response.StatusCode == System.Net.HttpStatusCode.Forbidden)
{
string errStr = await response.Content.ReadAsStringAsync();
_logger.Warning($"🌐 [{acc.Name}] API Keys expired/invalid. Deriving new L2 Keys from PrivateKey...");
if (!isRetry && !string.IsNullOrEmpty(acc.PrivateKey) && !string.IsNullOrEmpty(acc.WalletAddress))
{
var fallbackKeyObj = new EthECKey(acc.PrivateKey.Replace("0x", ""));
var newKeys = await DeriveApiKeyAsync(acc.PrivateKey, fallbackKeyObj.GetPublicAddress());
if (!string.IsNullOrEmpty(newKeys.ApiKey))
{
acc.ApiKey = newKeys.ApiKey;
acc.ApiSecret = newKeys.ApiSecret;
acc.ApiPassphrase = newKeys.ApiPassphrase;
_logger.Info($"🌐 [{acc.Name}] Successfully derived new L2 Keys! Resuming in 2.5s...");
// Await propagation of new keys inside Polymarket's Gamma backend
await Task.Delay(2500);
// Retry recursively strictly once
return await GetUsdcBalanceAsync(acc, true);
}
}
_logger.Error($"CLOB Balance Fetch failed: {response.StatusCode} {errStr}");
}
else
{
string errStr = await response.Content.ReadAsStringAsync();
_logger.Error($"CLOB Balance Fetch failed: {response.StatusCode} {errStr}");
}
}
catch (Exception ex)
{
_logger.Error($"CLOB Balance Fetch Error: {ex.Message}");
}
return 0;
}
public async Task<System.Collections.Generic.List<(string Id, string Side, decimal Price)>> GetOpenOrdersAsync(AccountState acc, string assetId)
{
var result = new System.Collections.Generic.List<(string Id, string Side, decimal Price)>();
if (string.IsNullOrEmpty(acc.ApiKey) || string.IsNullOrEmpty(acc.ApiSecret) || string.IsNullOrEmpty(acc.ApiPassphrase) || string.IsNullOrEmpty(acc.PrivateKey))
return result;
try
{
string endpoint = "/data/orders";
string requestUrl = $"{endpoint}?asset_id={assetId}";
string timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString();
string signature = GenerateHmacSignature(acc.ApiSecret, timestamp, "GET", endpoint);
var request = new HttpRequestMessage(HttpMethod.Get, $"{ClobHost}{requestUrl}");
var keyObj = new EthECKey(acc.PrivateKey.Replace("0x", ""));
request.Headers.Add("POLY_ADDRESS", keyObj.GetPublicAddress());
request.Headers.Add("POLY_API_KEY", acc.ApiKey);
request.Headers.Add("POLY_SIGNATURE", signature);
request.Headers.Add("POLY_TIMESTAMP", timestamp);
request.Headers.Add("POLY_PASSPHRASE", acc.ApiPassphrase);
using var response = await _httpClient.SendAsync(request);
if (response.IsSuccessStatusCode)
{
var jsonStr = await response.Content.ReadAsStringAsync();
using var doc = JsonDocument.Parse(jsonStr);
if (doc.RootElement.TryGetProperty("data", out var dataArr) && dataArr.ValueKind == JsonValueKind.Array)
{
foreach (var orderLine in dataArr.EnumerateArray())
{
if (orderLine.TryGetProperty("orderID", out var oid) || orderLine.TryGetProperty("id", out oid))
{
string idStr = oid.GetString() ?? "";
string sideStr = orderLine.TryGetProperty("side", out var s) ? (s.GetString() ?? "") : "";
string priceStr = orderLine.TryGetProperty("price", out var p) ? (p.GetString() ?? "0") : "0";
decimal.TryParse(priceStr, System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out decimal priceDec);
if (!string.IsNullOrEmpty(idStr))
result.Add((idStr, sideStr, priceDec));
}
}
}
else if (doc.RootElement.ValueKind == JsonValueKind.Array)
{
foreach (var orderLine in doc.RootElement.EnumerateArray())
{
if (orderLine.TryGetProperty("orderID", out var oid) || orderLine.TryGetProperty("id", out oid))
{
string idStr = oid.GetString() ?? "";
string sideStr = orderLine.TryGetProperty("side", out var s) ? (s.GetString() ?? "") : "";
string priceStr = orderLine.TryGetProperty("price", out var p) ? (p.GetString() ?? "0") : "0";
decimal.TryParse(priceStr, System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out decimal priceDec);
if (!string.IsNullOrEmpty(idStr))
result.Add((idStr, sideStr, priceDec));
}
}
}
}
else
{
string errStr = await response.Content.ReadAsStringAsync();
_logger.Warning($"Failed to GET open orders for {assetId}: {response.StatusCode} {errStr}");
}
}
catch (Exception ex)
{
_logger.Error($"GetOpenOrdersAsync Error: {ex.Message}");
}
return result;
}
public async Task<bool> CancelOrderAsync(AccountState acc, string orderId)
{
if (string.IsNullOrEmpty(acc.ApiKey) || string.IsNullOrEmpty(acc.ApiSecret) || string.IsNullOrEmpty(acc.ApiPassphrase) || string.IsNullOrEmpty(acc.PrivateKey))
return false;
try
{
string endpoint = "/order";
var reqBody = new { orderID = orderId };
string jsonBody = JsonSerializer.Serialize(reqBody);
string timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString();
string signature = GenerateHmacSignature(acc.ApiSecret, timestamp, "DELETE", endpoint, jsonBody);
using var request = new HttpRequestMessage(HttpMethod.Delete, $"{ClobHost}{endpoint}");
var keyObj = new EthECKey(acc.PrivateKey.Replace("0x", ""));
request.Headers.Add("POLY_ADDRESS", keyObj.GetPublicAddress());
request.Headers.Add("POLY_API_KEY", acc.ApiKey);
request.Headers.Add("POLY_SIGNATURE", signature);
request.Headers.Add("POLY_TIMESTAMP", timestamp);
request.Headers.Add("POLY_PASSPHRASE", acc.ApiPassphrase);
request.Content = new StringContent(jsonBody, Encoding.UTF8, "application/json");
using var response = await _httpClient.SendAsync(request);
if (response.IsSuccessStatusCode)
{
_logger.Info($"🚮 [{acc.Name}] Stornierung erfolgreich. OrderID: {orderId}");
return true;
}
else
{
string errStr = await response.Content.ReadAsStringAsync();
_logger.Warning($"Failed to cancel order {orderId}: {response.StatusCode} {errStr}");
return false;
}
}
catch (Exception ex)
{
_logger.Error($"CancelOrderAsync Error: {ex.Message}");
return false;
}
}
public async Task CancelConflictingOrdersAsync(AccountState acc, string assetId, decimal newPrice, string sideStr)
{
var openOrders = await GetOpenOrdersAsync(acc, assetId);
if (openOrders.Count > 0)
{
var tasks = new System.Collections.Generic.List<Task>();
foreach (var order in openOrders)
{
bool shouldCancel = false;
if (sideStr.Equals("SELL", StringComparison.OrdinalIgnoreCase))
{
shouldCancel = true;
_logger.Info($"⚠️ [{acc.Name}] Storniere Order {order.Id} wegen Verkaufs-Signal des Master-Traders.");
}
else if (sideStr.Equals("BUY", StringComparison.OrdinalIgnoreCase) && order.Side.Equals("BUY", StringComparison.OrdinalIgnoreCase))
{
if (Math.Abs(order.Price - newPrice) > 0.001m)
{
shouldCancel = true;
_logger.Info($"⚠️ [{acc.Name}] Storniere veraltete Order {order.Id} (Alter Preis: {order.Price:F3}, Neuer Preis: {newPrice:F3})");
}
else
{
_logger.Info($"✅ [{acc.Name}] Behalte bestehende Order {order.Id} (Preis identisch: {order.Price:F3})");
}
}
if (shouldCancel)
{
tasks.Add(CancelOrderAsync(acc, order.Id));
}
}
if (tasks.Count > 0)
{
await Task.WhenAll(tasks);
// Minimal delay to ensure rapid executions don't conflict with in-flight deletions
await Task.Delay(150);
}
}
}
private static System.Numerics.BigInteger GenerateSalt()
{
// Generate a salt similar to Py Clob Client (fits safely in a standard 64-bit int / JS Number)
long t = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
int r = System.Security.Cryptography.RandomNumberGenerator.GetInt32(0, 10000);
return new System.Numerics.BigInteger(t * 10000 + r);
}
public static (decimal shares, decimal usdc, decimal makerRaw, decimal takerRaw) CalculateExactOrderAmounts(decimal investAmountUsd, decimal rawPrice, decimal limitPrice, string sideStr, string orderType = "FOK", decimal? overrideTickSize = null, int? overrideMakerDecimals = null, int? overrideTakerDecimals = null)
{
decimal tickSize = overrideTickSize ?? 0.001m;
int priceDec, sizeDec, amtDec;
if (tickSize >= 0.1m) { priceDec = 1; sizeDec = 2; amtDec = 3; }
else if (tickSize >= 0.01m) { priceDec = 2; sizeDec = 2; amtDec = 4; }
else if (tickSize >= 0.001m) { priceDec = 3; sizeDec = 2; amtDec = 5; }
else { priceDec = 4; sizeDec = 2; amtDec = 6; }
decimal priceRounded = Math.Round(limitPrice > 0 ? limitPrice : rawPrice, priceDec, MidpointRounding.AwayFromZero);
if (priceRounded < tickSize) priceRounded = tickSize;
decimal executedShares = 0m;
decimal executedUsdc = 0m;
decimal finalMakerAmountRaw = 0m;
decimal finalTakerAmountRaw = 0m;
if (sideStr.ToUpper() == "BUY")
{
decimal rawTakerShares = investAmountUsd / priceRounded;
decimal multiplier = (decimal)Math.Pow(10, sizeDec);
decimal takerShares = Math.Floor(rawTakerShares * multiplier) / multiplier;
if (takerShares <= 0) return (-1, -1, 0, 0);
decimal makerUsd = 0m;
// Polymarket strictly enforces $1.00 minimum for MARKET BUYS and verifies it against the supported shares.
// We increment takerShares until the floored USDC amount supports the exact shares without dropping below $1.00.
decimal step = 1.0m / multiplier;
while (takerShares > 0)
{
makerUsd = takerShares * priceRounded;
int actDec = BitConverter.GetBytes(decimal.GetBits(makerUsd)[3])[2];
if (actDec > amtDec)
{
decimal mul2 = (decimal)Math.Pow(10, amtDec + 4);
makerUsd = Math.Ceiling(makerUsd * mul2) / mul2;
if (BitConverter.GetBytes(decimal.GetBits(makerUsd)[3])[2] > amtDec)
{
decimal mul3 = (decimal)Math.Pow(10, amtDec);
makerUsd = Math.Floor(makerUsd * mul3) / mul3;
}
}
decimal supportedShares = Math.Floor((makerUsd / priceRounded) * multiplier) / multiplier;
if (makerUsd >= 1.0m && supportedShares >= takerShares)
break;
takerShares += step;
}
finalTakerAmountRaw = Math.Round(takerShares * 1_000_000m);
finalMakerAmountRaw = Math.Round(makerUsd * 1_000_000m);
executedShares = takerShares;
executedUsdc = makerUsd;
}
else
{
decimal sharesRaw = investAmountUsd / priceRounded;
decimal multiplier = (decimal)Math.Pow(10, sizeDec);
decimal makerShares = Math.Floor(sharesRaw * multiplier) / multiplier;
// Polymarket STRICTLY enforces a 5 share minimum for ANY sell order on the CLOB
if (makerShares < 5.0m) return (-1, -1, 0, 0);
decimal takerUsd = makerShares * priceRounded;
int actDec = BitConverter.GetBytes(decimal.GetBits(takerUsd)[3])[2];
if (actDec > amtDec)
{
decimal mul2 = (decimal)Math.Pow(10, amtDec + 4);
takerUsd = Math.Ceiling(takerUsd * mul2) / mul2;
if (BitConverter.GetBytes(decimal.GetBits(takerUsd)[3])[2] > amtDec)
{
decimal mul3 = (decimal)Math.Pow(10, amtDec);
takerUsd = Math.Floor(takerUsd * mul3) / mul3;
}
}
finalMakerAmountRaw = Math.Round(makerShares * 1_000_000m);
finalTakerAmountRaw = Math.Round(takerUsd * 1_000_000m);
executedShares = makerShares;
executedUsdc = takerUsd;
}
return (executedShares, executedUsdc, finalMakerAmountRaw, finalTakerAmountRaw);
}
/// <summary>
/// Executes a native EIP-712 signed order (default Fill-Or-Kill)
/// </summary>
public async Task<string> PlaceOrderAsync(AccountState account, string tokenId, string sideStr, decimal investAmountUsd, decimal limitPrice, string orderType = "FOK", bool debugPayloadLog = false, bool isNegRisk = false, int actualFeeBps = 0, decimal? overrideTickSize = null, int? overrideMakerDecimals = null, int? overrideTakerDecimals = null)
{
if (string.IsNullOrEmpty(account.PrivateKey) || string.IsNullOrEmpty(account.ApiKey))
return "Error: Missing API or Private Keys";
try
{
var signer = new Eip712TypedDataSigner();
var key = new EthECKey(account.PrivateKey);
var typedData = new TypedData<CtfDomain>
{
Domain = new CtfDomain
{
Name = "Polymarket CTF Exchange",
Version = "1",
ChainId = ChainId,
VerifyingContract = isNegRisk ? "0xC5d563A36AE78145C45a50134d48A1215220f80a" : "0x4bFb41d5B3570DeFd03C39a9A4D8dE6Bd8B8982E"
},
Types = Nethereum.ABI.EIP712.MemberDescriptionFactory.GetTypesMemberDescription(typeof(CtfDomain), typeof(CtfOrder)),
PrimaryType = "Order"
};
var amounts = CalculateExactOrderAmounts(investAmountUsd, limitPrice, limitPrice, sideStr, orderType, overrideTickSize, overrideMakerDecimals, overrideTakerDecimals);
if (amounts.shares <= 0)
return $"Mathematical tick size error: Balance too small to meet fractional quantum limit for exact price matching";
decimal makerAmountRaw = amounts.makerRaw;
decimal takerAmountRaw = amounts.takerRaw;
System.Numerics.BigInteger parsedTokenId;
if (tokenId.StartsWith("0x") || tokenId.Any(c => "abcdefABCDEF".Contains(c)))
{
parsedTokenId = new Nethereum.Hex.HexTypes.HexBigInteger(tokenId.StartsWith("0x") ? tokenId : "0x" + tokenId).Value;
}
else
{
parsedTokenId = System.Numerics.BigInteger.Parse(tokenId);
}
var ctfOrder = new CtfOrder
{
Salt = GenerateSalt(),
Maker = account.WalletAddress,
Signer = key.GetPublicAddress(),
Taker = "0x0000000000000000000000000000000000000000",
TokenId = parsedTokenId,
MakerAmount = new System.Numerics.BigInteger(makerAmountRaw),
TakerAmount = new System.Numerics.BigInteger(takerAmountRaw),
Expiration = 0,
Nonce = 0,
FeeRateBps = new System.Numerics.BigInteger(actualFeeBps),
Side = sideStr.ToUpper() == "BUY" ? (byte)0 : (byte)1,
SignatureType = 2
};
string signature = signer.SignTypedDataV4(ctfOrder, typedData, key);
var reqBody = new
{
order = new
{
salt = (long)ctfOrder.Salt,
maker = ctfOrder.Maker.ToLower(),
signer = ctfOrder.Signer.ToLower(),
taker = ctfOrder.Taker.ToLower(),
tokenId = ctfOrder.TokenId.ToString(),
makerAmount = ctfOrder.MakerAmount.ToString(),
takerAmount = ctfOrder.TakerAmount.ToString(),
expiration = ctfOrder.Expiration.ToString(),
nonce = ctfOrder.Nonce.ToString(),
feeRateBps = ctfOrder.FeeRateBps.ToString(),
side = ctfOrder.Side == 0 ? "BUY" : "SELL",
signatureType = ctfOrder.SignatureType,
signature = signature
},
owner = account.ApiKey,
orderType = orderType
};
string jsonBody = JsonSerializer.Serialize(reqBody);
string timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString();
string requestPath = "/order";
string hmacSig = GenerateHmacSignature(account.ApiSecret, timestamp, "POST", requestPath, jsonBody);
using var request = new HttpRequestMessage(HttpMethod.Post, $"{ClobHost}{requestPath}");
var keyObj = new EthECKey(account.PrivateKey.Replace("0x", ""));
request.Headers.Add("POLY_ADDRESS", keyObj.GetPublicAddress());
request.Headers.Add("POLY_API_KEY", account.ApiKey);
request.Headers.Add("POLY_TIMESTAMP", timestamp);
request.Headers.Add("POLY_SIGNATURE", hmacSig);
request.Headers.Add("POLY_PASSPHRASE", account.ApiPassphrase);
request.Content = new StringContent(jsonBody, Encoding.UTF8, "application/json");
if (debugPayloadLog)
{
_logger.Debug($"[CLOB-PAYLOAD] -> {jsonBody}");
}
using var response = await _httpClient.SendAsync(request);
var responseContent = await response.Content.ReadAsStringAsync();
if (!response.IsSuccessStatusCode)
{
bool isFokFail = responseContent.Contains("FOK orders are fully filled or killed");
if (isFokFail && sideStr == "BUY")
{
// Dampen FOK failed BUY logs. Usually means target price/liquidity not met for full copy size.
// We skip it silently.
return "SKIPPED_LIQUIDITY";
}
lock (_fileLock)
{
System.IO.File.WriteAllText("last_invalid_payload.json", jsonBody);
}
if (isFokFail && sideStr == "SELL")
{
_logger.Warning($"Liquidität für FOK SELL reicht nicht aus. (Orderbook Size limit). Rest-Shares bleiben erhalten.");
return "Nicht genügend Liquidität für vollumfänglichen Verkauf auf diesem Preisniveau (FOK).";
}
else
{
var tickMatch = System.Text.RegularExpressions.Regex.Match(responseContent, @"breaks minimum tick size rule: ([\d\.]+)");
if (tickMatch.Success && overrideTickSize == null)
{
if (decimal.TryParse(tickMatch.Groups[1].Value, System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out decimal newTickSize))
{
_logger.Info($"🔄 Automatische Anpassung an Markt Tick-Size ({newTickSize}). Order wird erneut berechnet und platziert...");
return await PlaceOrderAsync(account, tokenId, sideStr, investAmountUsd, limitPrice, orderType, debugPayloadLog, isNegRisk, actualFeeBps, newTickSize, overrideMakerDecimals, overrideTakerDecimals);
}
}
var decMatch = System.Text.RegularExpressions.Regex.Match(responseContent, @"maker amount supports a max accuracy of (\d+) decimals, taker amount a max of (\d+) decimals");
if (decMatch.Success && overrideMakerDecimals == null)
{
if (int.TryParse(decMatch.Groups[1].Value, out int newMaker) && int.TryParse(decMatch.Groups[2].Value, out int newTaker))
{
_logger.Info($"🔄 Automatische Anpassung an Dezimalregeln (Maker: {newMaker}, Taker: {newTaker}). Order wird neu berechnet...");
return await PlaceOrderAsync(account, tokenId, sideStr, investAmountUsd, limitPrice, orderType, debugPayloadLog, isNegRisk, actualFeeBps, overrideTickSize, newMaker, newTaker);
}
}
// Check if error is "invalid fee rate" -> Extract required fee -> Retry!
var match = System.Text.RegularExpressions.Regex.Match(responseContent, @"invalid fee rate \(\d+\), current market's (?:taker|maker) fee: (\d+)");
if (match.Success && actualFeeBps == 0) // Only retry once
{
if (int.TryParse(match.Groups[1].Value, out int newFeeBps))
{
_logger.Info($"🔄 Automatische Anpassung an Fee Rate ({newFeeBps} bps). Order wird erneut platziert...");
return await PlaceOrderAsync(account, tokenId, sideStr, investAmountUsd, limitPrice, orderType, debugPayloadLog, isNegRisk, newFeeBps, overrideTickSize);
}
}
// Check if error is "Size lower than minimum 5" -> Fallback to MARKET
var sizeMatch = System.Text.RegularExpressions.Regex.Match(responseContent, @"Size \([\d\.]+\) lower than the minimum: (\d+)");
if (sizeMatch.Success)
{
if (decimal.TryParse(sizeMatch.Groups[1].Value, out decimal minReq))
{
if (orderType != "MARKET")
{
_logger.Info($"🔄 Automatische Anpassung an Minimum Size Limit (Limitorder < {minReq}). Order wird als MARKET platziert...");
return await PlaceOrderAsync(account, tokenId, sideStr, investAmountUsd, limitPrice, "MARKET", debugPayloadLog, isNegRisk, actualFeeBps, overrideTickSize, overrideMakerDecimals, overrideTakerDecimals);
}
else if (sideStr == "SELL")
{
_logger.Warning($"Verkauf von unter {minReq} Shares auf Polymarket nicht möglich (Orderbook Limit). Position muss aufgestockt werden oder auslaufen.");
return $"Börsenlimit: Mindestens {minReq} Shares erforderlich.";
}
}
}
var balMatch = System.Text.RegularExpressions.Regex.Match(responseContent, @"balance: (\d+), sum of active orders: (\d+)");
if (balMatch.Success && sideStr == "SELL")
{
if (decimal.TryParse(balMatch.Groups[1].Value, out decimal totalBal) && decimal.TryParse(balMatch.Groups[2].Value, out decimal activeOrders))
{
decimal availableSharesRaw = totalBal - activeOrders;
decimal availableShares = availableSharesRaw / 1_000_000m;
decimal requiredShares = investAmountUsd / limitPrice;
if (availableShares > 0 && Math.Abs(availableShares - requiredShares) > 0.001m && availableShares < requiredShares)
{
decimal newInvestAmount = availableShares * limitPrice;
_logger.Info($"🔄 Automatische Anpassung an verfügbare Shares (Aktive Orders blockieren {activeOrders / 1000000m} Shares). Verkaufe restliche {availableShares} Shares...");
return await PlaceOrderAsync(account, tokenId, sideStr, newInvestAmount, limitPrice, orderType, debugPayloadLog, isNegRisk, actualFeeBps, overrideTickSize, overrideMakerDecimals, overrideTakerDecimals);
}
}
}
_logger.Error($"CLOB Order Error ({response.StatusCode}): {responseContent}");
}
return "ERROR";
}
if (response.IsSuccessStatusCode)
{
_logger.Info($"✅ Order Platzierung Erfolgreich! {sideStr} @ {limitPrice:F3}");
return "OK";
}
else
{
_logger.Error($"❌ Order Fehler: {response.StatusCode} - {responseContent}");
return responseContent;
}
}
catch (Exception ex)
{
_logger.Error($"PlaceFokOrderAsync Runtime Fehler: {ex.Message}");
return ex.Message;
}
}
}
}
+292
View File
@@ -0,0 +1,292 @@
using System;
using MongoDB.Driver;
using PolyTraderSharp.Extensions;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Net.WebSockets;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Hosting;
using PolyTraderSharp.Models;
namespace PolyTraderSharp.Services
{
public class PolymarketWssClient : BackgroundService
{
private const string MarketWssUrl = "wss://ws-subscriptions-clob.polymarket.com/ws/market";
private readonly TradingState _state;
private readonly ServerSettings _settings;
private readonly PolymarketClobClient _clob;
private readonly TerminalLogger _logger;
private readonly IMongoDatabase _db;
// Tracking rate limits for auto redeem: max 2 attempts per position, 5 min apart
private readonly ConcurrentDictionary<string, (int Count, DateTime LastAttempt)> _redeemAttempts = new();
public PolymarketWssClient(
TradingState state,
ServerSettings settings,
PolymarketClobClient clob,
TerminalLogger logger,
IMongoDatabase db)
{
_state = state;
_settings = settings;
_clob = clob;
_logger = logger;
_db = db;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
if (!_settings.UsePolymarketWebsockets || _state.GlobalTradingPaused)
{
await Task.Delay(5000, stoppingToken);
continue;
}
try
{
await ConnectMarketWssAsync(stoppingToken);
}
catch (Exception ex)
{
_logger.Warning($"Polymarket WSS disconnected ({ex.Message}). Retrying in 5s...");
await Task.Delay(5000, stoppingToken);
}
}
}
private async Task ConnectMarketWssAsync(CancellationToken stoppingToken)
{
using var ws = new ClientWebSocket();
_logger.Info("Connecting to Polymarket WSS (Market Stream) for live pricing...");
await ws.ConnectAsync(new Uri(MarketWssUrl), stoppingToken);
_logger.Info("✅ Polymarket Market WSS Connected.");
var allSubscriptions = new HashSet<string>();
var subscriptionTask = Task.Run(async () =>
{
while (ws.State == WebSocketState.Open && !stoppingToken.IsCancellationRequested && _settings.UsePolymarketWebsockets)
{
var neededAssets = new HashSet<string>();
foreach (var acc in _state.Accounts.Values.Where(a => a.IsActive))
foreach (var token in acc.OpenPositions.Keys)
neededAssets.Add(token);
var missing = neededAssets.Except(allSubscriptions).ToList();
if (missing.Any())
{
var req = new
{
assets_ids = missing,
type = "market"
};
var json = System.Text.Json.JsonSerializer.Serialize(req);
var bytes = Encoding.UTF8.GetBytes(json);
await ws.SendAsync(new ArraySegment<byte>(bytes), WebSocketMessageType.Text, true, stoppingToken);
foreach (var m in missing) allSubscriptions.Add(m);
_logger.Info($"📡 Polymarket WSS: Subscribed to {missing.Count} new assets. Total: {allSubscriptions.Count}");
}
await Task.Delay(5000, stoppingToken); // Check for new positions every 5s
}
}, stoppingToken);
var buffer = new byte[1024 * 64]; // 64kb buffer
while (ws.State == WebSocketState.Open && !stoppingToken.IsCancellationRequested && _settings.UsePolymarketWebsockets)
{
var result = await ws.ReceiveAsync(new ArraySegment<byte>(buffer), stoppingToken);
if (result.MessageType == WebSocketMessageType.Close) break;
var message = Encoding.UTF8.GetString(buffer, 0, result.Count);
if (!string.IsNullOrEmpty(message))
{
try { ProcessMarketMessage(message); } catch { }
}
}
}
private void ProcessMarketMessage(string jsonStr)
{
try
{
using var doc = JsonDocument.Parse(jsonStr);
var root = doc.RootElement;
if (!root.TryGetProperty("event_type", out var evtTypeProp)) return;
var eventType = evtTypeProp.GetString();
if (eventType == "price_change")
{
if (root.TryGetProperty("price_changes", out var changes) && changes.ValueKind == JsonValueKind.Array)
{
foreach (var change in changes.EnumerateArray())
{
if (change.TryGetProperty("asset_id", out var assetIdProp) &&
change.TryGetProperty("price", out var priceProp))
{
string assetId = assetIdProp.GetString()!;
decimal.TryParse(priceProp.GetString(), System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out decimal price);
decimal bestBid = price;
if (change.TryGetProperty("best_bid", out var bidProp) && decimal.TryParse(bidProp.GetString(), System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out decimal bBid))
{
if (bBid > 0) bestBid = bBid;
}
UpdateAssetPriceAndCheckAutoRedeem(assetId, bestBid);
}
}
}
}
else if (eventType == "last_trade_price")
{
if (root.TryGetProperty("asset_id", out var assetIdProp) && root.TryGetProperty("price", out var priceProp))
{
string assetId = assetIdProp.GetString()!;
decimal.TryParse(priceProp.GetString(), System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out decimal price);
UpdateAssetPriceAndCheckAutoRedeem(assetId, price);
}
}
}
catch { }
}
private void UpdateAssetPriceAndCheckAutoRedeem(string assetId, decimal price)
{
if (price <= 0 || string.IsNullOrEmpty(assetId)) return;
foreach (var acc in _state.Accounts.Values)
{
if (acc.OpenPositions.TryGetValue(assetId, out var pos))
{
pos.CurrentPrice = price;
pos.CurrentValueUsd = pos.Size * price;
// Execute Auto-Redeem if config conditions are met
if (acc.PreRedeemLimit > 0 && price >= acc.PreRedeemLimit && acc.IsActive)
{
string redeemKey = $"{acc.AccountId}_{assetId}";
// Spam protection: max 2 attempts per position, 5 minutes apart
if (_redeemAttempts.TryGetValue(redeemKey, out var redeemState))
{
if (redeemState.Count >= 2) continue; // Permanently ignore after 2 failed attempts
if ((DateTime.UtcNow - redeemState.LastAttempt).TotalMinutes < 5) continue; // Wait 5 min between attempts
}
if (!acc.IsDemo && _state.LiveTradingMode == TradingMode.Active)
{
_logger.Trade($"🚨 [AUTO REDEEM] {acc.Name} | {pos.MarketQuestion} | Preis >= {acc.PreRedeemLimit}");
// Best effort non-blocking
_ = Task.Run(async () => await ExecuteAutoRedeemLive(acc, pos, price));
}
else if (acc.IsDemo && _state.DemoTradingMode == TradingMode.Active)
{
_logger.Trade($"🚨 [AUTO REDEEM DEMO] {acc.Name} | {pos.MarketQuestion} | Preis >= {acc.PreRedeemLimit}");
_ = Task.Run(() => ExecuteAutoRedeemDemo(acc, pos, price));
}
}
}
}
}
private async Task ExecuteAutoRedeemLive(AccountState acc, Position pos, decimal triggerPrice)
{
string redeemKey = $"{acc.AccountId}_{pos.TokenId}";
if (pos.Size < 5.0m)
{
var state = _redeemAttempts.GetOrAdd(redeemKey, _ => (0, DateTime.MinValue));
int newCount = state.Count + 1;
_redeemAttempts[redeemKey] = (newCount, DateTime.UtcNow);
if (newCount <= 1) // Only log once
_logger.Warning($"[AUTO REDEEM] Position {pos.MarketQuestion} zu klein für Limit Order (< 5 Shares). Max. 1 Retry in 5 Min.");
return;
}
// Track successful attempt
_redeemAttempts.AddOrUpdate(redeemKey, _ => (1, DateTime.UtcNow), (_, old) => (old.Count + 1, DateTime.UtcNow));
try
{
// The user explicitly requested an exact GTC order using the configured PreRedeemLimit, without slippage
decimal expectedFillPrice = acc.PreRedeemLimit;
decimal amountUsdc = Math.Max(pos.Size * expectedFillPrice, 0.01m);
// Fire and forget SELL via ClobClient
var result = await _clob.PlaceOrderAsync(acc, pos.TokenId, "SELL", amountUsdc, expectedFillPrice, "GTC", false, false);
if (result == "OK")
{
_logger.Info($"✅ Auto-Redeem Sell sent for {acc.Name} at exact Limit {expectedFillPrice:F3} USD (GTC).");
// Assume it's an open matching order. Clob/Market API will sync actual status later.
// DO NOT remove from OpenPositions here. Wait for Live Sync to detect the closure
// via the API so it can properly fetch the Realized PnL and save the ClosedTrade record!
}
else
{
_logger.Error($"❌ Auto-Redeem failed or rejected: {result}.");
}
}
catch (Exception ex)
{
_logger.Error($"Auto Redeem Exception: {ex.Message}");
}
}
private void ExecuteAutoRedeemDemo(AccountState acc, Position pos, decimal triggerPrice)
{
try
{
if (acc.OpenPositions.TryRemove(pos.TokenId, out _))
{
_db.GetCollection<Position>($"demo_positions_{acc.AccountId}").Delete(pos.TokenId);
decimal exactLimitPrice = acc.PreRedeemLimit;
decimal exitUsd = pos.Size * exactLimitPrice;
decimal realizedPnl = exitUsd - pos.AmountUsd;
_state.GlobalPnl += realizedPnl;
acc.UpdateBalance(acc.AvailableBalance + exitUsd);
var ct = new ClosedTrade
{
TradeId = _state.GetNextTradeId(),
AccountId = acc.AccountId,
IsDemo = true,
MarketSlug = pos.MarketSlug,
MarketQuestion = pos.MarketQuestion,
TokenId = pos.TokenId,
Outcome = pos.Outcome,
Side = "SELL",
EntryPrice = pos.EntryPrice,
ExitPrice = exactLimitPrice,
Size = pos.Size,
RealizedPnl = realizedPnl,
PnlPercent = pos.AmountUsd > 0 ? (realizedPnl / pos.AmountUsd * 100m) : 0m,
OpenedAt = pos.OpenedAt,
ClosedAt = DateTime.UtcNow,
ExitReason = "Pre Redeem"
};
_db.GetCollection<ClosedTrade>("closed_trades").Insert(ct);
_db.GetCollection<AccountState>("accounts").Upsert(acc);
_logger.Trade($"✅ [AUTO REDEEM DEMO ERFOLGREICH] {pos.MarketQuestion} | Exit: {pos.Size:F2} @ {exactLimitPrice:F3} | PnL: ${realizedPnl:F2}");
}
}
catch (Exception ex)
{
_logger.Error($"Demo Auto Redeem failed: {ex.Message}");
}
}
}
}
+284
View File
@@ -0,0 +1,284 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Net.WebSockets;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Hosting;
using PolyTraderSharp.Models;
using LiteDB;
namespace PolyTraderSharp.Services
{
public class PolymarketWssClient : BackgroundService
{
private const string MarketWssUrl = "wss://ws-subscriptions-clob.polymarket.com/ws/market";
private readonly TradingState _state;
private readonly ServerSettings _settings;
private readonly PolymarketClobClient _clob;
private readonly TerminalLogger _logger;
private readonly ILiteDatabase _db;
// Tracking rate limits for auto redeem to avoid spam
private readonly ConcurrentDictionary<string, DateTime> _lastRedeemAttempt = new();
public PolymarketWssClient(
TradingState state,
ServerSettings settings,
PolymarketClobClient clob,
TerminalLogger logger,
ILiteDatabase db)
{
_state = state;
_settings = settings;
_clob = clob;
_logger = logger;
_db = db;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
if (!_settings.UsePolymarketWebsockets || _state.GlobalTradingPaused)
{
await Task.Delay(5000, stoppingToken);
continue;
}
try
{
await ConnectMarketWssAsync(stoppingToken);
}
catch (Exception ex)
{
_logger.Warning($"Polymarket WSS disconnected ({ex.Message}). Retrying in 5s...");
await Task.Delay(5000, stoppingToken);
}
}
}
private async Task ConnectMarketWssAsync(CancellationToken stoppingToken)
{
using var ws = new ClientWebSocket();
_logger.Info("Connecting to Polymarket WSS (Market Stream) for live pricing...");
await ws.ConnectAsync(new Uri(MarketWssUrl), stoppingToken);
_logger.Info("✅ Polymarket Market WSS Connected.");
var allSubscriptions = new HashSet<string>();
var subscriptionTask = Task.Run(async () =>
{
while (ws.State == WebSocketState.Open && !stoppingToken.IsCancellationRequested && _settings.UsePolymarketWebsockets)
{
var neededAssets = new HashSet<string>();
foreach (var acc in _state.Accounts.Values.Where(a => a.IsActive))
foreach (var token in acc.OpenPositions.Keys)
neededAssets.Add(token);
var missing = neededAssets.Except(allSubscriptions).ToList();
if (missing.Any())
{
var req = new
{
assets_ids = missing,
type = "market"
};
var json = System.Text.Json.JsonSerializer.Serialize(req);
var bytes = Encoding.UTF8.GetBytes(json);
await ws.SendAsync(new ArraySegment<byte>(bytes), WebSocketMessageType.Text, true, stoppingToken);
foreach (var m in missing) allSubscriptions.Add(m);
_logger.Info($"📡 Polymarket WSS: Subscribed to {missing.Count} new assets. Total: {allSubscriptions.Count}");
}
await Task.Delay(5000, stoppingToken); // Check for new positions every 5s
}
}, stoppingToken);
var buffer = new byte[1024 * 64]; // 64kb buffer
while (ws.State == WebSocketState.Open && !stoppingToken.IsCancellationRequested && _settings.UsePolymarketWebsockets)
{
var result = await ws.ReceiveAsync(new ArraySegment<byte>(buffer), stoppingToken);
if (result.MessageType == WebSocketMessageType.Close) break;
var message = Encoding.UTF8.GetString(buffer, 0, result.Count);
if (!string.IsNullOrEmpty(message))
{
try { ProcessMarketMessage(message); } catch { }
}
}
}
private void ProcessMarketMessage(string jsonStr)
{
try
{
using var doc = JsonDocument.Parse(jsonStr);
var root = doc.RootElement;
if (!root.TryGetProperty("event_type", out var evtTypeProp)) return;
var eventType = evtTypeProp.GetString();
if (eventType == "price_change")
{
if (root.TryGetProperty("price_changes", out var changes) && changes.ValueKind == JsonValueKind.Array)
{
foreach (var change in changes.EnumerateArray())
{
if (change.TryGetProperty("asset_id", out var assetIdProp) &&
change.TryGetProperty("price", out var priceProp))
{
string assetId = assetIdProp.GetString()!;
decimal.TryParse(priceProp.GetString(), out decimal price);
decimal bestBid = price;
if (change.TryGetProperty("best_bid", out var bidProp) && decimal.TryParse(bidProp.GetString(), out decimal bBid))
{
if (bBid > 0) bestBid = bBid;
}
UpdateAssetPriceAndCheckAutoRedeem(assetId, bestBid);
}
}
}
}
else if (eventType == "last_trade_price")
{
if (root.TryGetProperty("asset_id", out var assetIdProp) && root.TryGetProperty("price", out var priceProp))
{
string assetId = assetIdProp.GetString()!;
decimal.TryParse(priceProp.GetString(), out decimal price);
UpdateAssetPriceAndCheckAutoRedeem(assetId, price);
}
}
}
catch { }
}
private void UpdateAssetPriceAndCheckAutoRedeem(string assetId, decimal price)
{
if (price <= 0 || string.IsNullOrEmpty(assetId)) return;
foreach (var acc in _state.Accounts.Values)
{
if (acc.OpenPositions.TryGetValue(assetId, out var pos))
{
pos.CurrentPrice = price;
pos.CurrentValueUsd = pos.Size * price;
// Execute Auto-Redeem if config conditions are met
if (acc.PreRedeemLimit > 0 && price >= acc.PreRedeemLimit && acc.IsActive)
{
string redeemKey = $"{acc.AccountId}_{assetId}";
// Spam protection: try only once every 10 seconds per position
if (_lastRedeemAttempt.TryGetValue(redeemKey, out var lastAttempt) && (DateTime.UtcNow - lastAttempt).TotalSeconds < 10)
continue;
_lastRedeemAttempt[redeemKey] = DateTime.UtcNow;
if (!acc.IsDemo && _state.LiveTradingMode == TradingMode.Active)
{
_logger.Trade($"🚨 [AUTO REDEEM] {acc.Name} | {pos.MarketQuestion} | Preis >= {acc.PreRedeemLimit}");
// Best effort non-blocking
_ = Task.Run(async () => await ExecuteAutoRedeemLive(acc, pos, price));
}
else if (acc.IsDemo && _state.DemoTradingMode == TradingMode.Active)
{
_logger.Trade($"🚨 [AUTO REDEEM DEMO] {acc.Name} | {pos.MarketQuestion} | Preis >= {acc.PreRedeemLimit}");
_ = Task.Run(() => ExecuteAutoRedeemDemo(acc, pos, price));
}
}
}
}
}
private async Task ExecuteAutoRedeemLive(AccountState acc, Position pos, decimal triggerPrice)
{
if (pos.Size < 5.0m)
{
_logger.Warning($"[AUTO REDEEM] Position {pos.MarketQuestion} zu klein für Limit Order (< 5 Shares). Wird ignoriert um Error-Spam zu vermeiden.");
return;
}
try
{
// The user explicitly requested an exact GTC order using the configured PreRedeemLimit, without slippage
decimal expectedFillPrice = acc.PreRedeemLimit;
decimal amountUsdc = Math.Max(pos.Size * expectedFillPrice, 0.01m);
// Fire and forget SELL via ClobClient
var result = await _clob.PlaceOrderAsync(acc, pos.TokenId, "SELL", amountUsdc, expectedFillPrice, "GTC", false, false);
if (result == "OK")
{
_logger.Info($"✅ Auto-Redeem Sell sent for {acc.Name} at exact Limit {expectedFillPrice:F3} USD (GTC).");
// Assume it's an open matching order. Clob/Market API will sync actual status later.
if (acc.OpenPositions.TryRemove(pos.TokenId, out _)) {
// Live position updates handle ClosedTrade DB insertion elsewhere normally via Sync
}
}
else
{
_logger.Error($"❌ Auto-Redeem failed or rejected: {result}.");
}
}
catch (Exception ex)
{
_logger.Error($"Auto Redeem Exception: {ex.Message}");
}
}
private void ExecuteAutoRedeemDemo(AccountState acc, Position pos, decimal triggerPrice)
{
try
{
if (acc.OpenPositions.TryRemove(pos.TokenId, out _))
{
_db.GetCollection<Position>($"demo_positions_{acc.AccountId}").Delete(pos.TokenId);
decimal exactLimitPrice = acc.PreRedeemLimit;
decimal exitUsd = pos.Size * exactLimitPrice;
decimal realizedPnl = exitUsd - pos.AmountUsd;
_state.GlobalPnl += realizedPnl;
acc.UpdateBalance(acc.AvailableBalance + exitUsd);
var ct = new ClosedTrade
{
TradeId = _state.TotalCopyTrades,
AccountId = acc.AccountId,
IsDemo = true,
MarketSlug = pos.MarketSlug,
MarketQuestion = pos.MarketQuestion,
TokenId = pos.TokenId,
Outcome = pos.Outcome,
Side = "SELL",
EntryPrice = pos.EntryPrice,
ExitPrice = exactLimitPrice,
Size = pos.Size,
RealizedPnl = realizedPnl,
PnlPercent = pos.AmountUsd > 0 ? (realizedPnl / pos.AmountUsd * 100m) : 0m,
OpenedAt = pos.OpenedAt,
ClosedAt = DateTime.UtcNow,
ExitReason = "Pre Redeem"
};
_db.GetCollection<ClosedTrade>("closed_trades").Insert(ct);
_db.GetCollection<AccountState>("accounts").Upsert(acc);
_logger.Trade($"✅ [AUTO REDEEM DEMO ERFOLGREICH] {pos.MarketQuestion} | Exit: {pos.Size:F2} @ {exactLimitPrice:F3} | PnL: ${realizedPnl:F2}");
}
}
catch (Exception ex)
{
_logger.Error($"Demo Auto Redeem failed: {ex.Message}");
}
}
}
}
+150
View File
@@ -0,0 +1,150 @@
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
using PolyTraderSharp.Models;
namespace PolyTraderSharp.Services
{
public class SnapshotService : BackgroundService
{
private readonly TradingState _state;
private readonly ILogger<SnapshotService> _logger;
private readonly string _snapshotPath = "snapshot.json";
private readonly TimeSpan _interval = TimeSpan.FromSeconds(30);
private readonly JobStatusRow _jobStatus;
public SnapshotService(TradingState state, ILogger<SnapshotService> logger, JobManager jobManager)
{
_state = state;
_logger = logger;
_jobStatus = new JobStatusRow
{
JobName = "LiteDB State Snapshot",
Description = "Saves active application state (balances, open pos) to snapshot.json.",
StatusText = "Pending Initial Delay..."
};
_jobStatus.ManualTriggerAction = async () =>
{
string oldStatus = _jobStatus.StatusText;
_jobStatus.StatusText = "Running (Manual)...";
await SaveSnapshotAsync();
_jobStatus.StatusText = "Idle";
};
jobManager.RegisterJob(_jobStatus);
}
public override async Task StartAsync(CancellationToken cancellationToken)
{
// Load state on startup
if (File.Exists(_snapshotPath))
{
try
{
string json = await File.ReadAllTextAsync(_snapshotPath, cancellationToken);
var snapshot = JsonConvert.DeserializeObject<StateSnapshot>(json);
if (snapshot != null)
{
_state.LiveTradingMode = snapshot.LiveMode;
_state.DemoTradingMode = snapshot.DemoMode;
_state.TotalCopyTrades = snapshot.CopyTrades;
_state.GlobalPnl = snapshot.GlobalPnl;
int restoredPositions = 0;
// Restore OpenPositions to matching accounts
foreach (var kvp in snapshot.OpenPositions)
{
if (_state.Accounts.TryGetValue(kvp.Key, out var acc))
{
foreach (var pos in kvp.Value)
{
acc.OpenPositions.TryAdd(pos.Key, pos.Value);
restoredPositions++;
}
}
}
_logger.LogInformation($"Snapshot loaded. Restored {restoredPositions} positions. Modes: Live={snapshot.LiveMode}, Demo={snapshot.DemoMode}");
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to load snapshot on startup");
}
}
await base.StartAsync(cancellationToken);
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_jobStatus.StatusText = "Idle";
while (!stoppingToken.IsCancellationRequested)
{
if (_jobStatus.IsEnabled)
{
try
{
_jobStatus.StatusText = "Running (Scheduled)...";
await SaveSnapshotAsync();
_jobStatus.LastRun = DateTime.Now;
}
catch (TaskCanceledException)
{
break;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error saving TradingState snapshot");
_jobStatus.StatusText = "Error!";
}
finally
{
if (_jobStatus.StatusText != "Error!") _jobStatus.StatusText = "Idle";
}
}
else
{
_jobStatus.StatusText = "Paused";
}
_jobStatus.NextRun = DateTime.Now.Add(_interval);
await Task.Delay(_interval, stoppingToken);
}
}
private async Task SaveSnapshotAsync()
{
var snapshot = new StateSnapshot
{
LiveMode = _state.LiveTradingMode,
DemoMode = _state.DemoTradingMode,
CopyTrades = _state.TotalCopyTrades,
GlobalPnl = _state.GlobalPnl,
OpenPositions = _state.Accounts.ToDictionary(
a => a.Key,
a => a.Value.OpenPositions.ToDictionary(p => p.Key, p => p.Value)
)
};
string json = JsonConvert.SerializeObject(snapshot, Formatting.Indented);
await File.WriteAllTextAsync(_snapshotPath, json);
_logger.LogTrace("TradingState snapshot saved.");
}
private class StateSnapshot
{
public TradingMode LiveMode { get; set; }
public TradingMode DemoMode { get; set; }
public int CopyTrades { get; set; }
public decimal GlobalPnl { get; set; }
public Dictionary<int, Dictionary<string, Position>> OpenPositions { get; set; } = new();
}
}
}
+118
View File
@@ -0,0 +1,118 @@
using System;
using MongoDB.Driver;
using PolyTraderSharp.Extensions;
using System.IO;
using System.Threading.Tasks;
using System.Threading.Channels;
namespace PolyTraderSharp.Services
{
public enum LogLevel { Debug, Info, Warning, Error, Trade, TradeReasoning }
public class LogMessageEventArgs : EventArgs
{
public string Message { get; }
public LogLevel Level { get; }
public DateTime Timestamp { get; }
public LogMessageEventArgs(string message, LogLevel level)
{
Message = message;
Level = level;
Timestamp = DateTime.Now;
}
}
public class TerminalLogger
{
public event EventHandler<LogMessageEventArgs>? OnLogMessage;
private readonly List<LogMessageEventArgs> _history = new();
private readonly object _lock = new();
private readonly string _logsDirectory;
private readonly Channel<LogMessageEventArgs> _logChannel;
public TerminalLogger()
{
_logsDirectory = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Logs");
if (!Directory.Exists(_logsDirectory))
{
Directory.CreateDirectory(_logsDirectory);
}
_logChannel = Channel.CreateUnbounded<LogMessageEventArgs>(new UnboundedChannelOptions
{
SingleReader = true
});
Task.Run(ProcessLogQueueAsync);
}
private async Task ProcessLogQueueAsync()
{
await foreach (var e in _logChannel.Reader.ReadAllAsync())
{
try
{
string dateStr = e.Timestamp.ToString("dd-MM-yyyy");
string fileName = $"{dateStr}-{e.Level}.log";
string fullPath = Path.Combine(_logsDirectory, fileName);
// Remove Emojis (Surrogate pairs and common symbols)
string safeMsg = System.Text.RegularExpressions.Regex.Replace(e.Message, @"\p{Cs}|[✅❌🌐📈🔴🧪ℹ️🚨🏆💰⬇️⬆️🔹🔸✨🔥📊📝🔄⏸️]", "");
safeMsg = safeMsg.Replace("\r\n", " | ").Replace("\n", " | ").Replace(" ", " ").Trim();
string logLine = $"[{e.Timestamp:HH:mm:ss}] {safeMsg}{Environment.NewLine}";
await File.AppendAllTextAsync(fullPath, logLine);
}
catch
{
// Ignored to prevent cascading lockups
}
}
}
public void Log(string message, LogLevel level = LogLevel.Info)
{
var e = new LogMessageEventArgs(message, level);
lock (_lock)
{
_history.Add(e);
// Optimize list pruning to avoid heavy O(N) operations per log
if (_history.Count > 10500)
{
// Remove older items efficiently in a batch
int itemsToRemove = _history.Count - 9000;
_history.RemoveRange(0, itemsToRemove);
}
}
try
{
OnLogMessage?.Invoke(this, e);
}
catch { }
// Standard Console output as fallback/debug
Console.WriteLine($"[{DateTime.Now:HH:mm:ss}] [{level}] {message}");
_logChannel.Writer.TryWrite(e);
}
public void Info(string message) => Log(message, LogLevel.Info);
public void Debug(string message) => Log(message, LogLevel.Debug);
public void Warning(string message) => Log(message, LogLevel.Warning);
public void Error(string message) => Log(message, LogLevel.Error);
public void Trade(string message) => Log(message, LogLevel.Trade);
public void TradeReasoning(string message) => Log(message, LogLevel.TradeReasoning);
public List<LogMessageEventArgs> GetHistory(TimeSpan maxAge)
{
lock (_lock)
{
var cutoff = DateTime.Now - maxAge;
return _history.Where(x => x.Timestamp >= cutoff).ToList();
}
}
}
}
+270
View File
@@ -0,0 +1,270 @@
using System;
using System.IO;
using System.Net;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Hosting;
using PolyTraderSharp.Models;
using IcgSoftware.Threema.CoreMsgApi;
using IcgSoftware.Threema.CoreMsgApi.Exceptions;
namespace PolyTraderSharp.Services
{
public class ThreemaService : BackgroundService
{
public event Action<string>? OnCommandReceived;
private readonly TerminalLogger _logger;
private ServerSettings _settings;
private readonly string _settingsPath = "server_settings.xml";
private readonly JobStatusRow _jobStatus;
private HttpListener? _httpListener;
private APIConnector? _apiConnector;
public ThreemaService(TerminalLogger logger, JobManager jobManager)
{
_logger = logger;
_settings = ServerSettings.Load(_settingsPath);
_jobStatus = new JobStatusRow
{
JobName = "Threema Webhook Listener",
Description = "Listens for incoming Threema Gateway Webhooks on the configured port.",
StatusText = "Pending Initial Delay..."
};
_jobStatus.ManualTriggerAction = async () =>
{
_jobStatus.StatusText = "Manual trigger not supported for Webhook";
await Task.Delay(2000);
};
jobManager.RegisterJob(_jobStatus);
InitConnector();
}
public void ReloadSettings()
{
_settings = ServerSettings.Load(_settingsPath);
InitConnector();
}
private void InitConnector()
{
if (_settings.ThreemaEnabled && !string.IsNullOrEmpty(_settings.ThreemaGatewayId) && !string.IsNullOrEmpty(_settings.ThreemaSecret))
{
// Initialize the pt-icg SDK APIConnector
_apiConnector = new APIConnector(_settings.ThreemaGatewayId, _settings.ThreemaSecret, new PublicKeyStoreNone());
}
}
public async Task<bool> SendMessageAsync(string text, string parseMode = "")
{
if (!_settings.ThreemaEnabled || _apiConnector == null)
{
return false;
}
// Using the GroupID field as the target (could be a Threema ID)
string targetId = _settings.ThreemaGroupId;
if (string.IsNullOrEmpty(targetId))
{
_logger.Error("Threema send failed: Target ID (Group ID) is not configured.");
return false;
}
try
{
return await Task.Run(() =>
{
// If no private key is configured, fallback to Basic mode (SendTextMessageSimple)
// Note: Basic mode does not support actual Group messaging, so targetId must be a personal Threema ID.
// If a private key IS configured, we would use E2E mode, but without the official 2.0 SDK's
// SendGroupTextMessage, we just send a direct E2E message.
if (string.IsNullOrEmpty(_settings.ThreemaPrivateKey))
{
string msgId = _apiConnector.SendTextMessageSimple(targetId, text);
if (!string.IsNullOrEmpty(msgId))
{
_logger.Info($"Threema basic message sent. ID: {msgId}");
return true;
}
}
else
{
// E2E Mode Direct Message
byte[] privateKey = DataUtils.HexStringToByteArray(_settings.ThreemaPrivateKey);
byte[] publicKey = _apiConnector.LookupKey(targetId);
if (publicKey == null)
{
_logger.Error($"Threema E2E failed: Could not lookup public key for {targetId}");
return false;
}
byte[] nonce = CryptTool.RandomNonce();
var encryptResult = CryptTool.EncryptTextMessage(text, privateKey, publicKey);
if (encryptResult != null && encryptResult.Result != null)
{
byte[] box = encryptResult.Result;
string msgId = _apiConnector.SendE2EMessage(targetId, encryptResult.Nonce, box);
if (!string.IsNullOrEmpty(msgId))
{
_logger.Info($"Threema E2E message sent. ID: {msgId}");
return true;
}
}
}
return false;
});
}
catch (Exception ex)
{
_logger.Error($"Threema send failed: {ex.Message}");
return false;
}
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_jobStatus.StatusText = "Idle";
while (!stoppingToken.IsCancellationRequested)
{
if (!_settings.ThreemaEnabled || !_jobStatus.IsEnabled)
{
_jobStatus.StatusText = "Paused / Disabled";
if (_httpListener != null && _httpListener.IsListening)
{
_httpListener.Stop();
}
await Task.Delay(5000, stoppingToken);
continue;
}
try
{
if (_httpListener == null || !_httpListener.IsListening)
{
_httpListener = new HttpListener();
_httpListener.Prefixes.Add($"http://*:{_settings.ThreemaWebhookPort}/");
_httpListener.Start();
_jobStatus.StatusText = $"Listening on port {_settings.ThreemaWebhookPort}...";
_logger.Info($"[Threema] Webhook listener started on port {_settings.ThreemaWebhookPort}");
}
_jobStatus.LastRun = DateTime.Now;
var getContextTask = _httpListener.GetContextAsync();
var delayTask = Task.Delay(5000, stoppingToken);
var completedTask = await Task.WhenAny(getContextTask, delayTask);
if (completedTask == getContextTask)
{
var context = await getContextTask;
_ = Task.Run(() => HandleIncomingWebhook(context), stoppingToken);
}
}
catch (TaskCanceledException) { }
catch (Exception ex)
{
if (ex is HttpListenerException hle && hle.ErrorCode == 5)
{
_logger.Error($"[Threema] Access Denied starting Webhook. Try running as Administrator or run: netsh http add urlacl url=http://*:{_settings.ThreemaWebhookPort}/ user=Everyone");
}
else
{
_logger.Error($"[Threema] Listener error: {ex.Message}");
}
_jobStatus.StatusText = "Error! Retrying in 5s...";
if (_httpListener != null)
{
try { _httpListener.Close(); } catch { }
_httpListener = null;
}
await Task.Delay(5000, stoppingToken);
}
_jobStatus.NextRun = DateTime.Now;
}
if (_httpListener != null)
{
try { _httpListener.Close(); } catch { }
}
}
private void HandleIncomingWebhook(HttpListenerContext context)
{
try
{
var request = context.Request;
var response = context.Response;
if (request.HttpMethod == "POST")
{
using (var reader = new StreamReader(request.InputStream, request.ContentEncoding))
{
string body = reader.ReadToEnd();
var parsedParams = System.Web.HttpUtility.ParseQueryString(body);
string? from = parsedParams["from"];
string? to = parsedParams["to"];
string? nonceStr = parsedParams["nonce"];
string? boxStr = parsedParams["box"];
string? macStr = parsedParams["mac"];
// Decrypt the message if E2E
if (!string.IsNullOrEmpty(from) && !string.IsNullOrEmpty(nonceStr) && !string.IsNullOrEmpty(boxStr) && !string.IsNullOrEmpty(_settings.ThreemaPrivateKey) && _apiConnector != null)
{
try
{
byte[] privateKey = DataUtils.HexStringToByteArray(_settings.ThreemaPrivateKey);
byte[] publicKey = _apiConnector.LookupKey(from);
byte[] nonce = DataUtils.HexStringToByteArray(nonceStr);
byte[] box = DataUtils.HexStringToByteArray(boxStr);
if (publicKey != null)
{
var msg = CryptTool.DecryptMessage(box, privateKey, publicKey, nonce);
if (msg is IcgSoftware.Threema.CoreMsgApi.Messages.TextMessage textMsg)
{
string text = textMsg.Text;
string logText = text.Length > 200 ? text.Substring(0, 200) + "..." : text;
_logger.Info($"[Threema] Empfangen von {from}: {logText}");
if (text.StartsWith("/"))
{
OnCommandReceived?.Invoke(text);
}
}
}
}
catch (Exception dex)
{
_logger.Warning($"[Threema] Failed to decrypt incoming message: {dex.Message}");
}
}
else
{
_logger.Info($"[Threema] Received webhook, but cannot process (Basic mode doesn't support incoming, or missing E2E keys).");
}
}
}
response.StatusCode = 200;
response.Close();
}
catch (Exception ex)
{
_logger.Error($"[Threema] Webhook handling error: {ex.Message}");
}
}
}
}
+156
View File
@@ -0,0 +1,156 @@
using System;
using MongoDB.Driver;
using PolyTraderSharp.Extensions;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Hosting;
using PolyTraderSharp.Models;
namespace PolyTraderSharp.Services
{
public class TraderAnalyticsJob : BackgroundService
{
private readonly TradingState _state;
private readonly TerminalLogger _logger;
private readonly IMongoDatabase _db;
private readonly JobStatusRow _jobStatus;
public TraderAnalyticsJob(TradingState state, TerminalLogger logger, IMongoDatabase db, JobManager jobManager)
{
_state = state;
_logger = logger;
_db = db;
_jobStatus = new JobStatusRow
{
JobName = "Trader Analytics",
Description = "Analysiert Master-Trader-Performance pro Account (letzte 30 Trades, 7D Volumen).",
StatusText = "Pending Initial Delay..."
};
_jobStatus.ManualTriggerAction = async () =>
{
_jobStatus.StatusText = "Running (Manual)...";
await RunAnalyticsAsync();
_jobStatus.StatusText = "Idle";
_jobStatus.LastRun = DateTime.Now;
};
jobManager.RegisterJob(_jobStatus);
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
// Initial wait so the application can start smoothly
await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken);
while (!stoppingToken.IsCancellationRequested)
{
if (_jobStatus.IsEnabled)
{
try
{
_jobStatus.StatusText = "Running (Scheduled)...";
await RunAnalyticsAsync();
_jobStatus.LastRun = DateTime.Now;
}
catch (Exception ex)
{
_logger.Error($"Error in TraderAnalyticsJob: {ex.Message}");
_jobStatus.StatusText = "Error!";
}
finally
{
if (_jobStatus.StatusText != "Error!") _jobStatus.StatusText = "Idle";
}
}
else
{
_jobStatus.StatusText = "Paused";
}
_jobStatus.NextRun = DateTime.Now.AddHours(6);
await Task.Delay(TimeSpan.FromHours(6), stoppingToken);
}
}
private Task RunAnalyticsAsync()
{
return Task.Run(() =>
{
try
{
_logger.Info("🔄 Starte Trader Analytics (7D / Letzte 30 Trades)...");
var closedTradesColl = _db.GetCollection<ClosedTrade>("closed_trades");
// Ensure indexes
closedTradesColl.EnsureIndex(x => x.AccountId);
closedTradesColl.EnsureIndex(x => x.SourceTraderId);
DateTime sevenDaysAgo = DateTime.UtcNow.AddDays(-7);
foreach (var acc in _state.Accounts.Values)
{
var results = new List<TraderAnalyticsResult>();
// Find all master traders that this account has copied successfully in their entire history
// Or we just find MTs that were copied in the last 7 days?
// The requirement says: "Welche Trades ... in den letzten 7 Tagen kopiert ... und wie hoch war die Winrate der letzten 30 Trades"
// Thus we only care about MTs that had at least 1 trade in the last 7 days!
int accId = acc.AccountId;
var recentMTs = closedTradesColl.LiteFind(x => x.AccountId == accId && x.ClosedAt >= sevenDaysAgo)
.Select(x => x.SourceTraderId)
.Distinct()
.Where(id => id != 0) // Ignore orphaned historical trades (API resolved/auto-redeem before ID tracking patch)
.ToList();
foreach (var mtId in recentMTs)
{
var mtInfo = _state.Traders.Values.FirstOrDefault(t => t.Id == mtId);
string name = mtInfo?.DisplayName ?? $"MT #{mtId}";
string address = mtInfo?.WalletAddress ?? "";
// 1. Trades im 7D Fenster zählen
int trades7D = closedTradesColl.LiteFind(x => x.AccountId == accId && x.SourceTraderId == mtId && x.ClosedAt >= sevenDaysAgo).Count();
// 2. Letzte 30 Trades holen
var last30 = closedTradesColl.LiteFind(x => x.AccountId == accId && x.SourceTraderId == mtId)
.OrderByDescending(x => x.ClosedAt)
.Take(30)
.ToList();
if (last30.Count == 0) continue;
decimal pnl30T = last30.Sum(x => x.RealizedPnl);
int wins = last30.Count(x => x.RealizedPnl > 0);
// Exakt 0 ist kein Win, nur > 0
decimal winrate = ((decimal)wins / last30.Count) * 100m;
results.Add(new TraderAnalyticsResult
{
AccountId = acc.AccountId,
SourceTraderId = mtId,
SourceTraderName = name,
SourceTraderAddress = address,
Winrate30T = winrate,
Pnl30T = pnl30T,
Trades7D = trades7D
});
}
// Save to cache
_state.TraderAnalyticsCache[acc.AccountId] = results;
}
_logger.Info("✅ Trader Analytics erfolgreich abgeschlossen und im Cache aktualisiert.");
}
catch (Exception ex)
{
_logger.Error($"TraderAnalyticsJob Exception: {ex}");
}
});
}
}
}
File diff suppressed because it is too large Load Diff
+550
View File
@@ -0,0 +1,550 @@
using System;
using System.Collections.Concurrent;
using System.Linq;
using System.Text.Json;
using System.Threading;
using System.Threading.Channels;
using System.Threading.Tasks;
using Microsoft.Extensions.Hosting;
using PolyTraderSharp.Models;
namespace PolyTraderSharp.Services
{
public class TraderMonitorService : BackgroundService
{
private readonly TradingState _state;
private readonly PolymarketApiService _api;
private readonly ChannelWriter<CopySignal> _signalWriter;
private readonly ChannelWriter<ClosedTrade> _closedTradeWriter;
private readonly TerminalLogger _logger;
// Prevents duplicates. Fast O(1) lookup cache to prevent DB spam.
private readonly ConcurrentDictionary<string, DateTime> _processedTxHashes = new();
private DateTime _lastHashCleanup = DateTime.UtcNow;
private readonly ConcurrentDictionary<string, bool> _processedClosures = new();
private readonly ConcurrentDictionary<string, DateTime> _lastPolled = new();
private DateTime _lastLivePoll = DateTime.MinValue;
public TraderMonitorService(
TradingState state,
PolymarketApiService api,
ChannelWriter<CopySignal> signalWriter,
ChannelWriter<ClosedTrade> closedTradeWriter,
TerminalLogger logger)
{
_state = state;
_api = api;
_signalWriter = signalWriter;
_closedTradeWriter = closedTradeWriter;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.Info("TraderMonitorService started background API priority polling...");
while (!stoppingToken.IsCancellationRequested)
{
try
{
await PollActiveTradersAsync(stoppingToken);
// Live Accounts open positions sync (Runs every 30s instead of slamming API constantly)
if ((DateTime.UtcNow - _lastLivePoll).TotalSeconds > 30)
{
await PollLiveAccountsAsync(stoppingToken);
await PollDemoExpirationsAsync(stoppingToken);
_lastLivePoll = DateTime.UtcNow;
}
}
catch (Exception ex)
{
_logger.Error($"TraderMonitor polling error: {ex.Message}");
}
// Global Engine Tick (dynamic queue evaluation)
await Task.Delay(1000, stoppingToken);
}
}
private async Task PollActiveTradersAsync(CancellationToken ct)
{
// Only process ACTIVE trader copies if not paused/inactive
if (_state.GlobalTradingPaused ||
(_state.DemoTradingMode == TradingMode.Inactive && _state.LiveTradingMode == TradingMode.Inactive))
{
return;
}
var activeTraders = _state.Traders.Values.Where(t => t.IsActive).ToList();
if (activeTraders.Count == 0) return;
var now = DateTime.UtcNow;
var toPoll = new List<TrackedTrader>();
bool isWssHealthy = _state.IsAlchemyHealthy;
// Calculate Dynamic Priorities
// Data API rate limit: 1000 req/10s (general).
// Worst case: 30 traders × high prio (3s) = ~100 req/10s = 10% capacity.
// With medium prio at 10s and batches of 10: well within limits.
foreach (var trader in activeTraders)
{
if (!_lastPolled.TryGetValue(trader.WalletAddress, out var lastPoll))
lastPoll = DateTime.MinValue;
double secondsSinceLastPoll = (now - lastPoll).TotalSeconds;
int requiredInterval = 10; // Medium Prio Default (Data API: 1000/10s headroom)
if (isWssHealthy)
{
// If WSS is healthy, fall back to safety-net polling
requiredInterval = 60; // 1 minute (was 2 min)
}
else
{
if (trader.TotalTrades > 20 || trader.Winrate30t >= 60.0)
requiredInterval = 3; // High Prio (unchanged — already fast)
else if (trader.TotalTrades < 5)
requiredInterval = 30; // Low Prio (was 120s)
}
if (secondsSinceLastPoll >= requiredInterval)
{
toPoll.Add(trader);
}
}
if (toPoll.Count == 0) return;
// Batch Execution (Max 10 Concurrent Requests to respect API limits)
int batchSize = 10;
for (int i = 0; i < toPoll.Count; i += batchSize)
{
if (ct.IsCancellationRequested) break;
var batch = toPoll.Skip(i).Take(batchSize);
var tasks = batch.Select(async trader =>
{
_lastPolled[trader.WalletAddress] = DateTime.UtcNow;
System.Diagnostics.Stopwatch? sw = null;
if (_state.DebugPollingLog) sw = System.Diagnostics.Stopwatch.StartNew();
var activity = await _api.GetTraderActivityAsync(trader.WalletAddress, limit: 50);
if (_state.DebugPollingLog && sw != null)
{
sw.Stop();
_logger.Debug($"[API-Profiler] Activity-Request für Trader {trader.DisplayName} dauerte {sw.ElapsedMilliseconds} ms.");
}
foreach (var act in activity)
{
ProcessActivityItem(act, trader);
}
});
await Task.WhenAll(tasks);
await Task.Delay(200, ct); // Tiny 200ms breath between batches
}
// Cleanup old hashes periodically (keep for 24 hours to prevent ANY duplicates)
if ((DateTime.UtcNow - _lastHashCleanup).TotalHours > 1)
{
var cutoff = DateTime.UtcNow.AddHours(-24);
var expired = _processedTxHashes.Where(x => x.Value < cutoff).Select(x => x.Key).ToList();
foreach (var k in expired) _processedTxHashes.TryRemove(k, out _);
_lastHashCleanup = DateTime.UtcNow;
}
}
/// <summary>
/// Triggered instantly by the AlchemyWebsocketService when an EVM TransferSingle is detected.
/// </summary>
public void TriggerManualPoll(string walletAddress)
{
var trader = _state.Traders.Values.FirstOrDefault(t => t.WalletAddress.Equals(walletAddress, StringComparison.OrdinalIgnoreCase));
if (trader != null && trader.IsActive)
{
// Force an immediate poll on the next tick by artificially advancing the last poll date
_lastPolled[trader.WalletAddress] = DateTime.MinValue;
}
}
private async Task PollDemoExpirationsAsync(CancellationToken ct)
{
var demoAccounts = _state.Accounts.Values.Where(a => a.IsDemo && a.IsActive).ToList();
if (demoAccounts.Count == 0) return;
foreach (var acc in demoAccounts)
{
if (ct.IsCancellationRequested) break;
// Check positions that are near expiry, recently expired, or have no expiry but have a slug
var checkPositions = acc.OpenPositions.Values.Where(p =>
!string.IsNullOrEmpty(p.MarketSlug) &&
(
// Has expiry and is within check window (-1 day to +30 days)
(p.ExpiryDate.HasValue &&
(DateTime.UtcNow - p.ExpiryDate.Value).TotalDays > -1 &&
(DateTime.UtcNow - p.ExpiryDate.Value).TotalDays < 30)
||
// No expiry date at all — always check via API
!p.ExpiryDate.HasValue
)).ToList();
foreach (var pos in checkPositions)
{
var (isClosed, isWinner) = await _api.CheckMarketResolutionAsync(pos.MarketSlug, pos.TokenId);
if (isClosed)
{
decimal exitPrice = isWinner ? 1.0m : 0.0m;
_logger.Info($"🏆 Demo Market {pos.MarketQuestion} aufgelöst! Auszahlung: ${(exitPrice * pos.Size):F2}");
var signal = new CopySignal
{
TraderId = 0,
TokenId = pos.TokenId,
MarketSlug = pos.MarketSlug,
MarketQuestion = pos.MarketQuestion,
Outcome = pos.Outcome,
Side = "SELL",
Price = exitPrice,
Size = pos.Size,
Timestamp = DateTime.UtcNow,
Reason = "Market Resolved"
};
_signalWriter.TryWrite(signal);
await Task.Delay(500, ct);
}
}
}
}
private async Task PollLiveAccountsAsync(CancellationToken ct)
{
// Always sync live positions so the Dashboard UI accurately reflects open PnL and portfolio balance
var liveAccounts = _state.Accounts.Values.Where(a => !a.IsDemo && a.IsActive && !string.IsNullOrEmpty(a.WalletAddress)).ToList();
if (liveAccounts.Count == 0) return;
foreach (var acc in liveAccounts)
{
if (ct.IsCancellationRequested) break;
var posList = await _api.SyncOpenPositionsAsync(acc.WalletAddress);
if (posList.Count == 0) continue;
var currentTokens = new HashSet<string>();
foreach (var posJson in posList)
{
string asset = posJson.TryGetProperty("asset", out var ap) ? ap.GetString() ?? "" : "";
if (string.IsNullOrEmpty(asset)) continue;
currentTokens.Add(asset);
string slug = posJson.TryGetProperty("slug", out var sp) ? sp.GetString() ?? "" : "";
string title = posJson.TryGetProperty("title", out var tp) ? tp.GetString() ?? "" : "";
string opp = posJson.TryGetProperty("oppositeOutcome", out var op) ? op.GetString() ?? "" : "No";
decimal size = 0m, entryPrice = 0m, amountUsd = 0m, curPrice = 0m, curValue = 0m;
if (posJson.TryGetProperty("size", out var sprop)) size = ParseDecimal(sprop);
if (posJson.TryGetProperty("avgPrice", out var aprop)) entryPrice = ParseDecimal(aprop);
// Critical Fix: "totalBought" is size. "initialValue" is original USD investment cost.
if (posJson.TryGetProperty("initialValue", out var tbprop)) amountUsd = ParseDecimal(tbprop);
if (posJson.TryGetProperty("curPrice", out var cpprop)) curPrice = ParseDecimal(cpprop);
if (posJson.TryGetProperty("currentValue", out var cvprop)) curValue = ParseDecimal(cvprop);
DateTime? expiry = null;
if (posJson.TryGetProperty("endDate", out var ep))
{
if (DateTime.TryParse(ep.GetString(), out var ed)) expiry = DateTime.SpecifyKind(ed.Date, DateTimeKind.Utc);
}
if (acc.OpenPositions.TryGetValue(asset, out var existing))
{
existing.Size = size;
existing.EntryPrice = entryPrice;
existing.AmountUsd = amountUsd;
existing.CurrentPrice = curPrice;
existing.CurrentValueUsd = curValue;
if (expiry.HasValue) existing.ExpiryDate = expiry;
}
else
{
var newPos = new Position
{
TokenId = asset,
MarketSlug = slug,
MarketQuestion = title,
Outcome = opp == "Yes" ? "No" : "Yes",
SourceTraderName = "Live Sync",
Side = "BUY",
Size = size,
EntryPrice = entryPrice,
AmountUsd = amountUsd,
CurrentPrice = curPrice,
CurrentValueUsd = curValue,
ExpiryDate = expiry
};
acc.OpenPositions.TryAdd(asset, newPos);
_logger.Info($"🌐 Live Position erkannt: {title} ({newPos.Outcome}) - ${amountUsd} - Account: {acc.Name}");
}
}
var tokensToRemove = acc.OpenPositions
.Where(kvp => !currentTokens.Contains(kvp.Key))
.Where(kvp => (DateTime.UtcNow - kvp.Value.OpenedAt).TotalMinutes > 5)
.Select(kvp => kvp.Key)
.ToList();
if (tokensToRemove.Count > 0)
{
var closedPositions = await _api.SyncClosedPositionsAsync(acc.WalletAddress, 50);
foreach (var k in tokensToRemove)
{
if (acc.OpenPositions.TryRemove(k, out var removedPos))
{
JsonElement? matchedClose = null;
foreach (var cm in closedPositions)
{
if (cm.TryGetProperty("asset", out var ap) && ap.GetString() == k)
{
matchedClose = cm;
break;
}
}
if (matchedClose.HasValue)
{
decimal realizedPnl = 0m;
if (matchedClose.Value.TryGetProperty("realizedPnl", out var rPnlProp)) realizedPnl = ParseDecimal(rPnlProp);
_state.GlobalPnl += realizedPnl;
decimal exitPrice = removedPos.Size > 0 ? (removedPos.AmountUsd + realizedPnl) / removedPos.Size : 0m;
string duplicateKey = $"{acc.AccountId}_{removedPos.TokenId}";
if (!_processedClosures.ContainsKey(duplicateKey))
{
_logger.Info($"🏆 Live Market {removedPos.MarketQuestion} geschlossen! PnL: ${(realizedPnl):F2}");
var ctRecord = new ClosedTrade
{
TradeId = _state.TotalCopyTrades,
AccountId = acc.AccountId,
SourceTraderId = removedPos.SourceTraderId,
IsDemo = false,
MarketSlug = removedPos.MarketSlug,
MarketQuestion = removedPos.MarketQuestion,
Outcome = removedPos.Outcome,
Side = "SELL",
EntryPrice = removedPos.EntryPrice,
ExitPrice = exitPrice,
Size = removedPos.Size,
RealizedPnl = realizedPnl,
PnlPercent = removedPos.AmountUsd > 0 ? (realizedPnl / removedPos.AmountUsd * 100m) : 0m,
OpenedAt = removedPos.OpenedAt,
ClosedAt = DateTime.UtcNow,
ExitReason = "API Closed"
};
_processedClosures.TryAdd(duplicateKey, true);
_closedTradeWriter.TryWrite(ctRecord);
}
}
else
{
var (isClosed, isWinner) = await _api.CheckMarketResolutionAsync(removedPos.MarketSlug, removedPos.TokenId);
if (isClosed)
{
decimal exitPrice = isWinner ? 1.0m : 0.0m;
decimal exitUsd = removedPos.Size * exitPrice;
decimal realizedPnl = exitUsd - removedPos.AmountUsd;
_state.GlobalPnl += realizedPnl;
string duplicateKey = $"{acc.AccountId}_{removedPos.TokenId}";
if (!_processedClosures.ContainsKey(duplicateKey))
{
_logger.Info($"🏆 Live Market {removedPos.MarketQuestion} aufgelöst (Fallback)! Auszahlung: ${(exitPrice * removedPos.Size):F2}");
var ctRecord = new ClosedTrade
{
TradeId = _state.TotalCopyTrades,
AccountId = acc.AccountId,
SourceTraderId = removedPos.SourceTraderId,
IsDemo = false,
MarketSlug = removedPos.MarketSlug,
MarketQuestion = removedPos.MarketQuestion,
Outcome = removedPos.Outcome,
Side = "SELL",
EntryPrice = removedPos.EntryPrice,
ExitPrice = exitPrice,
Size = removedPos.Size,
RealizedPnl = realizedPnl,
PnlPercent = removedPos.AmountUsd > 0 ? (realizedPnl / removedPos.AmountUsd * 100m) : 0m,
OpenedAt = removedPos.OpenedAt,
ClosedAt = DateTime.UtcNow,
ExitReason = "API Resolved"
};
_processedClosures.TryAdd(duplicateKey, true);
_closedTradeWriter.TryWrite(ctRecord);
}
if (isWinner)
{
/*
* DEATIVIERT: Automatischer Redeem via Python Script ist vorerst pausiert.
* User kann die gewonnenen Shares per Klick im Polymarket Web-Interface redeemen.
* Die Datenbank hat die PnL trotzdem bereits korrekt aufgezeichnet!
*
try
{
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
{
FileName = "python",
Arguments = $"redeem_markets.py {removedPos.TokenId} {acc.ApiKey} {acc.PrivateKey} {acc.ApiPassphrase}",
UseShellExecute = false,
CreateNoWindow = true
});
_logger.Info($"Python Redeem Script für Token {removedPos.TokenId} asynchron ausgeführt.");
}
catch (Exception ex)
{
_logger.Error($"Fehler beim Starten von redeem_markets.py: {ex.Message}");
}
*/
_logger.Info($"🏆 Token {removedPos.TokenId} bereit für manuellen Redeem via Polymarket-Webseite. (P&L wurde bereits gebucht).");
}
}
else
{
_logger.Info($"🌐 Live Position {removedPos.MarketQuestion} (Ext. Verkauft/Wartend)");
}
}
}
}
}
await Task.Delay(500, ct);
}
}
private decimal ParseDecimal(JsonElement prop)
{
if (prop.ValueKind == JsonValueKind.Number) return prop.GetDecimal();
if (prop.ValueKind == JsonValueKind.String && decimal.TryParse(prop.GetString(), System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out var parsed)) return parsed;
return 0m;
}
private void ProcessActivityItem(JsonElement act, TrackedTrader trader)
{
try
{
string txHash = act.GetProperty("transactionHash").GetString() ?? "";
if (string.IsNullOrEmpty(txHash) || _processedTxHashes.ContainsKey(txHash))
return; // Duplicate or invalid
string type = act.GetProperty("type").GetString() ?? "";
if (type.ToUpper() != "TRADE" && type.ToUpper() != "BUY" && type.ToUpper() != "SELL")
return;
string sideStr = type; // Fallback to type
if (act.TryGetProperty("side", out var sideProp) && sideProp.ValueKind == JsonValueKind.String) sideStr = sideProp.GetString() ?? sideStr;
else if (act.TryGetProperty("action", out var actionProp) && actionProp.ValueKind == JsonValueKind.String) sideStr = actionProp.GetString() ?? sideStr;
else if (act.TryGetProperty("tradeType", out var ttProp) && ttProp.ValueKind == JsonValueKind.String) sideStr = ttProp.GetString() ?? sideStr;
string asset = "";
if (act.TryGetProperty("asset", out var assetProp) && assetProp.ValueKind == JsonValueKind.String) asset = assetProp.GetString() ?? "";
if (string.IsNullOrEmpty(asset) && act.TryGetProperty("tokenId", out var tidProp) && tidProp.ValueKind == JsonValueKind.String) asset = tidProp.GetString() ?? "";
if (string.IsNullOrEmpty(asset) && act.TryGetProperty("token_id", out var t_idProp) && t_idProp.ValueKind == JsonValueKind.String) asset = t_idProp.GetString() ?? "";
if (string.IsNullOrEmpty(asset) && act.TryGetProperty("conditionId", out var cidProp) && cidProp.ValueKind == JsonValueKind.String) asset = cidProp.GetString() ?? "";
if (string.IsNullOrEmpty(asset) && act.TryGetProperty("condition_id", out var c_idProp) && c_idProp.ValueKind == JsonValueKind.String) asset = c_idProp.GetString() ?? "";
decimal price = 0m;
if (act.TryGetProperty("price", out var priceProp))
{
if (priceProp.ValueKind == JsonValueKind.Number) price = priceProp.GetDecimal();
else if (priceProp.ValueKind == JsonValueKind.String) decimal.TryParse(priceProp.GetString(), out price);
}
decimal size = 0m;
if (act.TryGetProperty("size", out var sizeProp))
{
if (sizeProp.ValueKind == JsonValueKind.Number) size = sizeProp.GetDecimal();
else if (sizeProp.ValueKind == JsonValueKind.String) decimal.TryParse(sizeProp.GetString(), out size);
}
// Parse timestamp to prevent old trades
DateTime tradeTs = DateTime.UtcNow;
if (act.TryGetProperty("timestamp", out var tsProp))
{
if (tsProp.ValueKind == JsonValueKind.Number) // Unix
tradeTs = DateTimeOffset.FromUnixTimeSeconds(tsProp.GetInt64()).UtcDateTime;
else if (tsProp.ValueKind == JsonValueKind.String && DateTime.TryParse(tsProp.GetString(), out var dt))
tradeTs = dt.ToUniversalTime();
}
// If trade is older than 120 seconds, skip
if ((DateTime.UtcNow - tradeTs).TotalSeconds > 120)
{
// Still add to seen so we don't re-parse it
_processedTxHashes.TryAdd(txHash, DateTime.UtcNow);
return;
}
_processedTxHashes.TryAdd(txHash, DateTime.UtcNow);
var displayQuestion = "";
if (act.TryGetProperty("title", out var titleProp)) displayQuestion = titleProp.GetString() ?? "";
var signal = new CopySignal
{
TraderId = trader.Id,
TokenId = asset,
ConditionId = "",
MarketSlug = act.TryGetProperty("slug", out var sp) ? sp.GetString() ?? "" : (act.TryGetProperty("marketSlug", out var msp) ? msp.GetString() ?? "" : ""),
Side = sideStr.ToUpper().Contains("SELL") ? "SELL" : "BUY",
Price = price,
Size = size,
Timestamp = tradeTs,
MarketQuestion = displayQuestion,
Outcome = act.TryGetProperty("outcome", out var outProp) ? outProp.GetString() ?? "" : "",
Reason = sideStr.ToUpper().Contains("SELL") ? "Master Trader Sold" : ""
};
// Parse endDate from activity JSON for market expiry
if (act.TryGetProperty("endDate", out var endDateProp))
{
if (endDateProp.ValueKind == JsonValueKind.String && DateTime.TryParse(endDateProp.GetString(), null, System.Globalization.DateTimeStyles.RoundtripKind, out var endDt))
signal.EndDate = endDt.ToUniversalTime();
else if (endDateProp.ValueKind == JsonValueKind.Number)
signal.EndDate = DateTimeOffset.FromUnixTimeSeconds(endDateProp.GetInt64()).UtcDateTime;
}
else if (act.TryGetProperty("end_date_iso", out var endIso) && endIso.ValueKind == JsonValueKind.String)
{
if (DateTime.TryParse(endIso.GetString(), null, System.Globalization.DateTimeStyles.RoundtripKind, out var endDt2))
signal.EndDate = endDt2.ToUniversalTime();
}
string shareType = string.IsNullOrEmpty(signal.Outcome) ? signal.Side : signal.Outcome;
_logger.Trade($"🚨 [QUELLE: {trader.DisplayName}] Neuer Trade erkannt!\n" +
$" Markt: {signal.MarketQuestion}\n" +
$" Aktion: {signal.Side} {shareType} ({signal.Size:F2} Shares @ ${signal.Price:F3})\n" +
$" Zeit: {signal.Timestamp:HH:mm:ss} UTC");
// Push to the processing queue
_signalWriter.TryWrite(signal);
}
catch (Exception ex)
{
_logger.Warning($"Fehler beim Parsen einer Activity JSON: {ex.Message}");
}
}
}
}
+602
View File
@@ -0,0 +1,602 @@
using System;
using System.Collections.Concurrent;
using System.Linq;
using System.Text.Json;
using System.Threading;
using System.Threading.Channels;
using System.Threading.Tasks;
using Microsoft.Extensions.Hosting;
using PolyTraderSharp.Models;
namespace PolyTraderSharp.Services
{
public class TraderMonitorService : BackgroundService
{
private readonly TradingState _state;
private readonly PolymarketApiService _api;
private readonly PolymarketClobClient _clob;
private readonly ChannelWriter<CopySignal> _signalWriter;
private readonly ChannelWriter<ClosedTrade> _closedTradeWriter;
private readonly TerminalLogger _logger;
// Prevents duplicates. Fast O(1) lookup cache to prevent DB spam.
private readonly ConcurrentDictionary<string, DateTime> _processedTxHashes = new();
private DateTime _lastHashCleanup = DateTime.UtcNow;
private readonly ConcurrentDictionary<string, bool> _processedClosures = new();
private readonly ConcurrentDictionary<string, DateTime> _lastPolled = new();
private DateTime _lastLivePoll = DateTime.MinValue;
public TraderMonitorService(
TradingState state,
PolymarketApiService api,
PolymarketClobClient clob,
ChannelWriter<CopySignal> signalWriter,
ChannelWriter<ClosedTrade> closedTradeWriter,
TerminalLogger logger)
{
_state = state;
_api = api;
_clob = clob;
_signalWriter = signalWriter;
_closedTradeWriter = closedTradeWriter;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.Info("TraderMonitorService started background API priority polling...");
while (!stoppingToken.IsCancellationRequested)
{
try
{
await PollActiveTradersAsync(stoppingToken);
// Live Accounts open positions sync (Runs every 30s instead of slamming API constantly)
if ((DateTime.UtcNow - _lastLivePoll).TotalSeconds > 30)
{
await PollLiveAccountsAsync(stoppingToken);
await PollDemoExpirationsAsync(stoppingToken);
await CleanupStaleOpenOrdersAsync(stoppingToken);
_lastLivePoll = DateTime.UtcNow;
}
}
catch (Exception ex)
{
_logger.Error($"TraderMonitor polling error: {ex.Message}");
}
// Global Engine Tick (dynamic queue evaluation)
await Task.Delay(1000, stoppingToken);
}
}
private async Task PollActiveTradersAsync(CancellationToken ct)
{
// Only process ACTIVE trader copies if not paused/inactive
if (_state.GlobalTradingPaused ||
(_state.DemoTradingMode == TradingMode.Inactive && _state.LiveTradingMode == TradingMode.Inactive))
{
return;
}
var activeTraders = _state.Traders.Values.Where(t => t.IsActive).ToList();
if (activeTraders.Count == 0) return;
var now = DateTime.UtcNow;
var toPoll = new List<TrackedTrader>();
bool isWssHealthy = _state.IsAlchemyHealthy;
// Calculate Dynamic Priorities
// Data API rate limit: 1000 req/10s (general).
// Worst case: 30 traders × high prio (3s) = ~100 req/10s = 10% capacity.
// With medium prio at 10s and batches of 10: well within limits.
foreach (var trader in activeTraders)
{
if (!_lastPolled.TryGetValue(trader.WalletAddress, out var lastPoll))
lastPoll = DateTime.MinValue;
double secondsSinceLastPoll = (now - lastPoll).TotalSeconds;
int requiredInterval = 10; // Medium Prio Default (Data API: 1000/10s headroom)
if (isWssHealthy)
{
// If WSS is healthy, fall back to safety-net polling
requiredInterval = 60; // 1 minute (was 2 min)
}
else
{
if (trader.TotalTrades > 20 || trader.Winrate30t >= 60.0)
requiredInterval = 3; // High Prio (unchanged — already fast)
else if (trader.TotalTrades < 5)
requiredInterval = 30; // Low Prio (was 120s)
}
if (secondsSinceLastPoll >= requiredInterval)
{
toPoll.Add(trader);
}
}
if (toPoll.Count == 0) return;
// Batch Execution (Max 10 Concurrent Requests to respect API limits)
int batchSize = 10;
for (int i = 0; i < toPoll.Count; i += batchSize)
{
if (ct.IsCancellationRequested) break;
var batch = toPoll.Skip(i).Take(batchSize);
var tasks = batch.Select(async trader =>
{
_lastPolled[trader.WalletAddress] = DateTime.UtcNow;
System.Diagnostics.Stopwatch? sw = null;
if (_state.DebugPollingLog) sw = System.Diagnostics.Stopwatch.StartNew();
var activity = await _api.GetTraderActivityAsync(trader.WalletAddress, limit: 50);
if (_state.DebugPollingLog && sw != null)
{
sw.Stop();
_logger.Debug($"[API-Profiler] Activity-Request für Trader {trader.DisplayName} dauerte {sw.ElapsedMilliseconds} ms.");
}
foreach (var act in activity)
{
ProcessActivityItem(act, trader);
}
});
await Task.WhenAll(tasks);
await Task.Delay(200, ct); // Tiny 200ms breath between batches
}
// Cleanup old hashes periodically (keep for 24 hours to prevent ANY duplicates)
if ((DateTime.UtcNow - _lastHashCleanup).TotalHours > 1)
{
var cutoff = DateTime.UtcNow.AddHours(-24);
var expired = _processedTxHashes.Where(x => x.Value < cutoff).Select(x => x.Key).ToList();
foreach (var k in expired) _processedTxHashes.TryRemove(k, out _);
_lastHashCleanup = DateTime.UtcNow;
}
}
/// <summary>
/// Triggered instantly by the AlchemyWebsocketService when an EVM TransferSingle is detected.
/// </summary>
public void TriggerManualPoll(string walletAddress)
{
var trader = _state.Traders.Values.FirstOrDefault(t => t.WalletAddress.Equals(walletAddress, StringComparison.OrdinalIgnoreCase));
if (trader != null && trader.IsActive)
{
// Force an immediate poll on the next tick by artificially advancing the last poll date
_lastPolled[trader.WalletAddress] = DateTime.MinValue;
}
}
private async Task PollDemoExpirationsAsync(CancellationToken ct)
{
var demoAccounts = _state.Accounts.Values.Where(a => a.IsDemo && a.IsActive).ToList();
if (demoAccounts.Count == 0) return;
foreach (var acc in demoAccounts)
{
if (ct.IsCancellationRequested) break;
// Check positions that are near expiry, recently expired, or have no expiry but have a slug
var checkPositions = acc.OpenPositions.Values.Where(p =>
!string.IsNullOrEmpty(p.MarketSlug) &&
(
// Has expiry and is within check window (-1 day to +30 days)
(p.ExpiryDate.HasValue &&
(DateTime.UtcNow - p.ExpiryDate.Value).TotalDays > -1 &&
(DateTime.UtcNow - p.ExpiryDate.Value).TotalDays < 30)
||
// No expiry date at all — always check via API
!p.ExpiryDate.HasValue
)).ToList();
foreach (var pos in checkPositions)
{
var (isClosed, isWinner) = await _api.CheckMarketResolutionAsync(pos.MarketSlug, pos.TokenId);
if (isClosed)
{
decimal exitPrice = isWinner ? 1.0m : 0.0m;
_logger.Info($"🏆 Demo Market {pos.MarketQuestion} aufgelöst! Auszahlung: ${(exitPrice * pos.Size):F2}");
var signal = new CopySignal
{
TraderId = 0,
TokenId = pos.TokenId,
MarketSlug = pos.MarketSlug,
MarketQuestion = pos.MarketQuestion,
Outcome = pos.Outcome,
Side = "SELL",
Price = exitPrice,
Size = pos.Size,
Timestamp = DateTime.UtcNow,
Reason = "Market Resolved"
};
_signalWriter.TryWrite(signal);
await Task.Delay(500, ct);
}
}
}
}
private async Task PollLiveAccountsAsync(CancellationToken ct)
{
// Always sync live positions so the Dashboard UI accurately reflects open PnL and portfolio balance
var liveAccounts = _state.Accounts.Values.Where(a => !a.IsDemo && a.IsActive && !string.IsNullOrEmpty(a.WalletAddress)).ToList();
if (liveAccounts.Count == 0) return;
foreach (var acc in liveAccounts)
{
if (ct.IsCancellationRequested) break;
var posList = await _api.SyncOpenPositionsAsync(acc.WalletAddress);
if (posList.Count == 0) continue;
var currentTokens = new HashSet<string>();
foreach (var posJson in posList)
{
string asset = posJson.TryGetProperty("asset", out var ap) ? ap.GetString() ?? "" : "";
if (string.IsNullOrEmpty(asset)) continue;
currentTokens.Add(asset);
string slug = posJson.TryGetProperty("slug", out var sp) ? sp.GetString() ?? "" : "";
string title = posJson.TryGetProperty("title", out var tp) ? tp.GetString() ?? "" : "";
string opp = posJson.TryGetProperty("oppositeOutcome", out var op) ? op.GetString() ?? "" : "No";
decimal size = 0m, entryPrice = 0m, amountUsd = 0m, curPrice = 0m, curValue = 0m;
if (posJson.TryGetProperty("size", out var sprop)) size = ParseDecimal(sprop);
if (posJson.TryGetProperty("avgPrice", out var aprop)) entryPrice = ParseDecimal(aprop);
// Critical Fix: "totalBought" is size. "initialValue" is original USD investment cost.
if (posJson.TryGetProperty("initialValue", out var tbprop)) amountUsd = ParseDecimal(tbprop);
if (posJson.TryGetProperty("curPrice", out var cpprop)) curPrice = ParseDecimal(cpprop);
if (posJson.TryGetProperty("currentValue", out var cvprop)) curValue = ParseDecimal(cvprop);
DateTime? expiry = null;
if (posJson.TryGetProperty("endDate", out var ep))
{
if (DateTime.TryParse(ep.GetString(), out var ed)) expiry = DateTime.SpecifyKind(ed.Date, DateTimeKind.Utc);
}
if (acc.OpenPositions.TryGetValue(asset, out var existing))
{
existing.Size = size;
existing.EntryPrice = entryPrice;
existing.AmountUsd = amountUsd;
existing.CurrentPrice = curPrice;
existing.CurrentValueUsd = curValue;
if (expiry.HasValue) existing.ExpiryDate = expiry;
}
else
{
var newPos = new Position
{
TokenId = asset,
MarketSlug = slug,
MarketQuestion = title,
Outcome = opp == "Yes" ? "No" : "Yes",
SourceTraderName = "Live Sync",
Side = "BUY",
Size = size,
EntryPrice = entryPrice,
AmountUsd = amountUsd,
CurrentPrice = curPrice,
CurrentValueUsd = curValue,
ExpiryDate = expiry
};
acc.OpenPositions.TryAdd(asset, newPos);
_logger.Info($"🌐 Live Position erkannt: {title} ({newPos.Outcome}) - ${amountUsd} - Account: {acc.Name}");
}
}
var tokensToRemove = acc.OpenPositions
.Where(kvp => !currentTokens.Contains(kvp.Key))
.Where(kvp => (DateTime.UtcNow - kvp.Value.OpenedAt).TotalMinutes > 5)
.Select(kvp => kvp.Key)
.ToList();
if (tokensToRemove.Count > 0)
{
var closedPositions = await _api.SyncClosedPositionsAsync(acc.WalletAddress, 50);
foreach (var k in tokensToRemove)
{
if (acc.OpenPositions.TryRemove(k, out var removedPos))
{
JsonElement? matchedClose = null;
foreach (var cm in closedPositions)
{
if (cm.TryGetProperty("asset", out var ap) && ap.GetString() == k)
{
matchedClose = cm;
break;
}
}
if (matchedClose.HasValue)
{
decimal realizedPnl = 0m;
if (matchedClose.Value.TryGetProperty("realizedPnl", out var rPnlProp)) realizedPnl = ParseDecimal(rPnlProp);
_state.GlobalPnl += realizedPnl;
decimal exitPrice = removedPos.Size > 0 ? (removedPos.AmountUsd + realizedPnl) / removedPos.Size : 0m;
string duplicateKey = $"{acc.AccountId}_{removedPos.TokenId}";
if (!_processedClosures.ContainsKey(duplicateKey))
{
_logger.Info($"🏆 Live Market {removedPos.MarketQuestion} geschlossen! PnL: ${(realizedPnl):F2}");
var ctRecord = new ClosedTrade
{
TradeId = _state.TotalCopyTrades,
AccountId = acc.AccountId,
SourceTraderId = removedPos.SourceTraderId,
IsDemo = false,
MarketSlug = removedPos.MarketSlug,
MarketQuestion = removedPos.MarketQuestion,
Outcome = removedPos.Outcome,
Side = "SELL",
EntryPrice = removedPos.EntryPrice,
ExitPrice = exitPrice,
Size = removedPos.Size,
RealizedPnl = realizedPnl,
PnlPercent = removedPos.AmountUsd > 0 ? (realizedPnl / removedPos.AmountUsd * 100m) : 0m,
OpenedAt = removedPos.OpenedAt,
ClosedAt = DateTime.UtcNow,
ExitReason = "API Closed"
};
_processedClosures.TryAdd(duplicateKey, true);
_closedTradeWriter.TryWrite(ctRecord);
}
}
else
{
var (isClosed, isWinner) = await _api.CheckMarketResolutionAsync(removedPos.MarketSlug, removedPos.TokenId);
if (isClosed)
{
decimal exitPrice = isWinner ? 1.0m : 0.0m;
decimal exitUsd = removedPos.Size * exitPrice;
decimal realizedPnl = exitUsd - removedPos.AmountUsd;
_state.GlobalPnl += realizedPnl;
string duplicateKey = $"{acc.AccountId}_{removedPos.TokenId}";
if (!_processedClosures.ContainsKey(duplicateKey))
{
_logger.Info($"🏆 Live Market {removedPos.MarketQuestion} aufgelöst (Fallback)! Auszahlung: ${(exitPrice * removedPos.Size):F2}");
var ctRecord = new ClosedTrade
{
TradeId = _state.TotalCopyTrades,
AccountId = acc.AccountId,
SourceTraderId = removedPos.SourceTraderId,
IsDemo = false,
MarketSlug = removedPos.MarketSlug,
MarketQuestion = removedPos.MarketQuestion,
Outcome = removedPos.Outcome,
Side = "SELL",
EntryPrice = removedPos.EntryPrice,
ExitPrice = exitPrice,
Size = removedPos.Size,
RealizedPnl = realizedPnl,
PnlPercent = removedPos.AmountUsd > 0 ? (realizedPnl / removedPos.AmountUsd * 100m) : 0m,
OpenedAt = removedPos.OpenedAt,
ClosedAt = DateTime.UtcNow,
ExitReason = "API Resolved"
};
_processedClosures.TryAdd(duplicateKey, true);
_closedTradeWriter.TryWrite(ctRecord);
}
if (isWinner)
{
/*
* DEATIVIERT: Automatischer Redeem via Python Script ist vorerst pausiert.
* User kann die gewonnenen Shares per Klick im Polymarket Web-Interface redeemen.
* Die Datenbank hat die PnL trotzdem bereits korrekt aufgezeichnet!
*
try
{
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
{
FileName = "python",
Arguments = $"redeem_markets.py {removedPos.TokenId} {acc.ApiKey} {acc.PrivateKey} {acc.ApiPassphrase}",
UseShellExecute = false,
CreateNoWindow = true
});
_logger.Info($"Python Redeem Script für Token {removedPos.TokenId} asynchron ausgeführt.");
}
catch (Exception ex)
{
_logger.Error($"Fehler beim Starten von redeem_markets.py: {ex.Message}");
}
*/
_logger.Info($"🏆 Token {removedPos.TokenId} bereit für manuellen Redeem via Polymarket-Webseite. (P&L wurde bereits gebucht).");
}
}
else
{
_logger.Info($"🌐 Live Position {removedPos.MarketQuestion} (Ext. Verkauft/Wartend)");
}
}
}
}
}
await Task.Delay(500, ct);
}
}
private async Task CleanupStaleOpenOrdersAsync(CancellationToken ct)
{
var keysToProcess = _state.PendingOrderTimestamps.ToArray();
if (keysToProcess.Length == 0) return;
foreach (var kvp in keysToProcess)
{
if (ct.IsCancellationRequested) break;
var parts = kvp.Key.Split('_', 2);
if (parts.Length != 2 || !int.TryParse(parts[0], out int accountId)) continue;
string tokenId = parts[1];
if (!_state.Accounts.TryGetValue(accountId, out var account) || account.IsDemo) continue;
// Determine timeout based on trader category
int timeoutMinutes = 30; // Default: 30 min
if (_state.Traders.TryGetValue(kvp.Value.SourceTraderId, out var trader) && trader.Category == "HF")
{
timeoutMinutes = 3; // HF Trader: 3 min
}
double ageMinutes = (DateTime.UtcNow - kvp.Value.PlacedAt).TotalMinutes;
if (ageMinutes < timeoutMinutes) continue;
// Order is stale — cancel it
try
{
var openOrders = await _clob.GetOpenOrdersAsync(account, tokenId);
if (openOrders.Count > 0)
{
foreach (var order in openOrders)
{
_logger.Warning($"⏰ [{account.Name}] Stale Order Timeout ({ageMinutes:F0} min > {timeoutMinutes} min). Storniere Order {order.Id} für {tokenId.Substring(0, Math.Min(10, tokenId.Length))}...");
await _clob.CancelOrderAsync(account, order.Id);
}
}
}
catch (Exception ex)
{
_logger.Error($"Stale Order Cleanup Error: {ex.Message}");
}
// Remove from tracking regardless (even if cancel failed, we don't want to spam retries)
_state.PendingOrderTimestamps.TryRemove(kvp.Key, out _);
}
}
private decimal ParseDecimal(JsonElement prop)
{
if (prop.ValueKind == JsonValueKind.Number) return prop.GetDecimal();
if (prop.ValueKind == JsonValueKind.String && decimal.TryParse(prop.GetString(), System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out var parsed)) return parsed;
return 0m;
}
private void ProcessActivityItem(JsonElement act, TrackedTrader trader)
{
try
{
string txHash = act.GetProperty("transactionHash").GetString() ?? "";
if (string.IsNullOrEmpty(txHash) || _processedTxHashes.ContainsKey(txHash))
return; // Duplicate or invalid
string type = act.GetProperty("type").GetString() ?? "";
if (type.ToUpper() != "TRADE" && type.ToUpper() != "BUY" && type.ToUpper() != "SELL")
return;
string sideStr = type; // Fallback to type
if (act.TryGetProperty("side", out var sideProp) && sideProp.ValueKind == JsonValueKind.String) sideStr = sideProp.GetString() ?? sideStr;
else if (act.TryGetProperty("action", out var actionProp) && actionProp.ValueKind == JsonValueKind.String) sideStr = actionProp.GetString() ?? sideStr;
else if (act.TryGetProperty("tradeType", out var ttProp) && ttProp.ValueKind == JsonValueKind.String) sideStr = ttProp.GetString() ?? sideStr;
string asset = "";
if (act.TryGetProperty("asset", out var assetProp) && assetProp.ValueKind == JsonValueKind.String) asset = assetProp.GetString() ?? "";
if (string.IsNullOrEmpty(asset) && act.TryGetProperty("tokenId", out var tidProp) && tidProp.ValueKind == JsonValueKind.String) asset = tidProp.GetString() ?? "";
if (string.IsNullOrEmpty(asset) && act.TryGetProperty("token_id", out var t_idProp) && t_idProp.ValueKind == JsonValueKind.String) asset = t_idProp.GetString() ?? "";
if (string.IsNullOrEmpty(asset) && act.TryGetProperty("conditionId", out var cidProp) && cidProp.ValueKind == JsonValueKind.String) asset = cidProp.GetString() ?? "";
if (string.IsNullOrEmpty(asset) && act.TryGetProperty("condition_id", out var c_idProp) && c_idProp.ValueKind == JsonValueKind.String) asset = c_idProp.GetString() ?? "";
decimal price = 0m;
if (act.TryGetProperty("price", out var priceProp))
{
if (priceProp.ValueKind == JsonValueKind.Number) price = priceProp.GetDecimal();
else if (priceProp.ValueKind == JsonValueKind.String) decimal.TryParse(priceProp.GetString(), out price);
}
decimal size = 0m;
if (act.TryGetProperty("size", out var sizeProp))
{
if (sizeProp.ValueKind == JsonValueKind.Number) size = sizeProp.GetDecimal();
else if (sizeProp.ValueKind == JsonValueKind.String) decimal.TryParse(sizeProp.GetString(), out size);
}
// Parse timestamp to prevent old trades
DateTime tradeTs = DateTime.UtcNow;
if (act.TryGetProperty("timestamp", out var tsProp))
{
if (tsProp.ValueKind == JsonValueKind.Number) // Unix
tradeTs = DateTimeOffset.FromUnixTimeSeconds(tsProp.GetInt64()).UtcDateTime;
else if (tsProp.ValueKind == JsonValueKind.String && DateTime.TryParse(tsProp.GetString(), out var dt))
tradeTs = dt.ToUniversalTime();
}
// If trade is older than 120 seconds, skip
if ((DateTime.UtcNow - tradeTs).TotalSeconds > 120)
{
// Still add to seen so we don't re-parse it
_processedTxHashes.TryAdd(txHash, DateTime.UtcNow);
return;
}
_processedTxHashes.TryAdd(txHash, DateTime.UtcNow);
var displayQuestion = "";
if (act.TryGetProperty("title", out var titleProp)) displayQuestion = titleProp.GetString() ?? "";
var signal = new CopySignal
{
TraderId = trader.Id,
TokenId = asset,
ConditionId = "",
MarketSlug = act.TryGetProperty("slug", out var sp) ? sp.GetString() ?? "" : (act.TryGetProperty("marketSlug", out var msp) ? msp.GetString() ?? "" : ""),
Side = sideStr.ToUpper().Contains("SELL") ? "SELL" : "BUY",
Price = price,
Size = size,
Timestamp = tradeTs,
MarketQuestion = displayQuestion,
Outcome = act.TryGetProperty("outcome", out var outProp) ? outProp.GetString() ?? "" : "",
Reason = sideStr.ToUpper().Contains("SELL") ? "Master Trader Sold" : ""
};
// Parse endDate from activity JSON for market expiry
if (act.TryGetProperty("endDate", out var endDateProp))
{
if (endDateProp.ValueKind == JsonValueKind.String && DateTime.TryParse(endDateProp.GetString(), null, System.Globalization.DateTimeStyles.RoundtripKind, out var endDt))
signal.EndDate = endDt.ToUniversalTime();
else if (endDateProp.ValueKind == JsonValueKind.Number)
signal.EndDate = DateTimeOffset.FromUnixTimeSeconds(endDateProp.GetInt64()).UtcDateTime;
}
else if (act.TryGetProperty("end_date_iso", out var endIso) && endIso.ValueKind == JsonValueKind.String)
{
if (DateTime.TryParse(endIso.GetString(), null, System.Globalization.DateTimeStyles.RoundtripKind, out var endDt2))
signal.EndDate = endDt2.ToUniversalTime();
}
string shareType = string.IsNullOrEmpty(signal.Outcome) ? signal.Side : signal.Outcome;
_logger.Trade($"🚨 [QUELLE: {trader.DisplayName}] Neuer Trade erkannt!\n" +
$" Markt: {signal.MarketQuestion}\n" +
$" Aktion: {signal.Side} {shareType} ({signal.Size:F2} Shares @ ${signal.Price:F3})\n" +
$" Zeit: {signal.Timestamp:HH:mm:ss} UTC");
// Push to the processing queue
_signalWriter.TryWrite(signal);
}
catch (Exception ex)
{
_logger.Warning($"Fehler beim Parsen einer Activity JSON: {ex.Message}");
}
}
}
}
+15
View File
@@ -0,0 +1,15 @@
using System;
using MongoDB.Driver;
using PolyTraderSharp.Extensions;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace PolyTraderSharp.services
{
internal class DatabaseService
{
}
}
+28
View File
@@ -0,0 +1,28 @@
using System;
using MongoDB.Driver;
using PolyTraderSharp.Extensions;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace PolyTraderSharp.services
{
/*
* Verwaltet alles was mit dem Logging und der Ausgabe im Terminal zu Tun hat.
*/
internal class Logging
{
/// <summary>
/// Die Loglevel Debug, Info und Error sollten klar sein.
/// Das Loglevel Trade soll Informationen zu von uns platzierten oder versucht zu platzierten Trades erhalten.
/// Das Loglevel TradeReasoning dient rein zu Analysezwecken. hier wollen wir auswerten können warum wir uns für oder gegen einen Trade entschieden haben.
/// </summary>
public enum LogLevel { Debug, Info, Trade,TradeReasoning, Error }
public void LogSchreiben( string message, LogLevel l = LogLevel.Info, bool TerminalOut = false) {
}
}
}
+18
View File
@@ -0,0 +1,18 @@
using System;
using MongoDB.Driver;
using PolyTraderSharp.Extensions;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace PolyTraderSharp.services
{ /*
* Verwaltet alles was mit Mullvad zu tun hat. Also VPN verbindung aktivieren / deaktivieren / prüfen
*/
internal class Mullvad
{
}
}
+30
View File
@@ -0,0 +1,30 @@
using System;
using MongoDB.Driver;
using PolyTraderSharp.Extensions;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace PolyTraderSharp.services
{
/*
* Hier sollen alle Server bezogenen Einstellungen, die im Settings Tab gesetzt werden in einer XML Datei im Programmordner gespeichert und geladen werden können.
Zusätzlich soll ein Reload das neuladen von geänderten Einstellungen in allen bereichen anstoßen.
*/
internal class Settings
{
public void ReloadSettings()
{
}
public void LoadSettings() {
}
public void SaveSettings() {
}
}
}