diff --git a/Models/ClosedTrade.cs.bak_livesync b/Models/ClosedTrade.cs.bak_livesync deleted file mode 100644 index e56d664..0000000 --- a/Models/ClosedTrade.cs.bak_livesync +++ /dev/null @@ -1,32 +0,0 @@ -namespace PolyTraderSharp.Models -{ - public class ClosedTrade - { - public int TradeId { get; set; } - public int AccountId { get; set; } - public int SourceTraderId { get; set; } - public bool IsDemo { get; set; } - - public string TokenId { get; set; } = string.Empty; - public string MarketSlug { get; set; } = string.Empty; - public string MarketQuestion { get; set; } = string.Empty; - public string Outcome { get; set; } = string.Empty; - - public string Side { get; set; } = string.Empty; - public decimal EntryPrice { get; set; } - public decimal ExitPrice { get; set; } - public decimal Size { get; set; } - - public decimal RealizedPnl { get; set; } - public decimal PnlPercent { get; set; } - public decimal TotalFees { get; set; } - - public DateTime OpenedAt { get; set; } - public DateTime ClosedAt { get; set; } - public string ExitReason { get; set; } = string.Empty; - } - public class ClosedTradeRow : ClosedTrade - { - public string AccountName { get; set; } = string.Empty; - } -} diff --git a/polymarket/blockchainlistener.cs b/polymarket/blockchainlistener.cs deleted file mode 100644 index 1052869..0000000 --- a/polymarket/blockchainlistener.cs +++ /dev/null @@ -1,14 +0,0 @@ -using System; -using MongoDB.Driver; -using PolyTraderSharp.Extensions; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - -namespace PolyTraderSharp.polymarket -{ - internal class BlockchainListener - { - } -} diff --git a/polymarket/polymarket-api.cs b/polymarket/polymarket-api.cs deleted file mode 100644 index 9f8d35d..0000000 --- a/polymarket/polymarket-api.cs +++ /dev/null @@ -1,14 +0,0 @@ -using System; -using MongoDB.Driver; -using PolyTraderSharp.Extensions; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - -namespace PolyTraderSharp.polymarket -{ - internal class polymarket_api - { - } -} diff --git a/polymarket/trademanager.cs b/polymarket/trademanager.cs deleted file mode 100644 index 32ae365..0000000 --- a/polymarket/trademanager.cs +++ /dev/null @@ -1,14 +0,0 @@ -using System; -using MongoDB.Driver; -using PolyTraderSharp.Extensions; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - -namespace PolyTraderSharp.polymarket -{ - internal class TradeManager - { - } -} diff --git a/services/CopyTradingEngine.cs.bak3 b/services/CopyTradingEngine.cs.bak3 deleted file mode 100644 index b32740e..0000000 --- a/services/CopyTradingEngine.cs.bak3 +++ /dev/null @@ -1,479 +0,0 @@ -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 _signalReader; - private readonly ChannelWriter _closedTradeWriter; - private readonly TerminalLogger _logger; - private readonly PolymarketClobClient _clob; - private readonly PolymarketApiService _api; - private readonly LiteDB.ILiteDatabase _db; - private static readonly ConcurrentDictionary _marketCache = new(StringComparer.OrdinalIgnoreCase); - - public CopyTradingEngine( - TradingState state, - ChannelReader signalReader, - ChannelWriter 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(); - 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(); - - 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("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($"demo_positions_{account.AccountId}").Upsert(finalPos); - - account.UpdateBalance(account.AvailableBalance - exactUsdc); - if (_db != null) _db.GetCollection("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("accounts").Upsert(account); - - if (_db != null) - { - var liveCol = _db.GetCollection($"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($"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("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("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)."); - } - } - } - - } -} diff --git a/services/CopyTradingEngine.cs.bak4 b/services/CopyTradingEngine.cs.bak4 deleted file mode 100644 index cde8383..0000000 --- a/services/CopyTradingEngine.cs.bak4 +++ /dev/null @@ -1,588 +0,0 @@ -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 _signalReader; - private readonly ChannelWriter _closedTradeWriter; - private readonly TerminalLogger _logger; - private readonly PolymarketClobClient _clob; - private readonly PolymarketApiService _api; - private readonly LiteDB.ILiteDatabase? _db; - private static readonly ConcurrentDictionary _marketCache = new(StringComparer.OrdinalIgnoreCase); - private readonly ConcurrentDictionary _accountSemaphores = new(); - - public CopyTradingEngine( - TradingState state, - ChannelReader signalReader, - ChannelWriter 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(); - 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("markets").Upsert(fetchedMarket); - } - } - catch (Exception ex) - { - _logger.Warning($"Fehler beim Pre-Fetch MarketData: {ex.Message}"); - } - } - } - // ----------------------------------------- - - var accountTasks = new List(); - - 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("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($"demo_positions_{account.AccountId}").Upsert(finalPos); - - account.UpdateBalance(account.AvailableBalance - exactUsdc); - if (_db != null) _db.GetCollection("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("accounts").Upsert(account); - - if (_db != null) - { - var liveCol = _db.GetCollection($"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($"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("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("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(); - } - } - } -} diff --git a/services/CopyTradingEngine.cs.bak5 b/services/CopyTradingEngine.cs.bak5 deleted file mode 100644 index 29b41c9..0000000 --- a/services/CopyTradingEngine.cs.bak5 +++ /dev/null @@ -1,602 +0,0 @@ -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 _signalReader; - private readonly ChannelWriter _closedTradeWriter; - private readonly TerminalLogger _logger; - private readonly PolymarketClobClient _clob; - private readonly PolymarketApiService _api; - private readonly LiteDB.ILiteDatabase? _db; - private static readonly ConcurrentDictionary _marketCache = new(StringComparer.OrdinalIgnoreCase); - private readonly ConcurrentDictionary _accountSemaphores = new(); - - public CopyTradingEngine( - TradingState state, - ChannelReader signalReader, - ChannelWriter 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(); - 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("markets").Upsert(fetchedMarket); - } - } - catch (Exception ex) - { - _logger.Warning($"Fehler beim Pre-Fetch MarketData: {ex.Message}"); - } - } - } - // ----------------------------------------- - - var accountTasks = new List(); - - 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("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($"demo_positions_{account.AccountId}").Upsert(finalPos); - - account.UpdateBalance(account.AvailableBalance - exactUsdc); - if (_db != null) _db.GetCollection("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("accounts").Upsert(account); - - if (_db != null) - { - var liveCol = _db.GetCollection($"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($"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("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("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(); - } - } - } -} diff --git a/services/PolymarketClobClient.cs.bak b/services/PolymarketClobClient.cs.bak deleted file mode 100644 index fd6995c..0000000 --- a/services/PolymarketClobClient.cs.bak +++ /dev/null @@ -1,569 +0,0 @@ -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; - } - - /// - /// Creates an HMAC signature for authenticated requests to the Polymarket CLOB. - /// - 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('/', '_'); - } - - /// - /// Derives a new Polymarket Level 2 API Key using an EIP712 Message signed by the L1 private key. - /// - 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 - { - 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 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); - } - - /// - /// Executes a native EIP-712 signed order (default Fill-Or-Kill) - /// - public async Task 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 - { - 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; - } - } - } -} diff --git a/services/PolymarketClobClient.cs.bak2 b/services/PolymarketClobClient.cs.bak2 deleted file mode 100644 index a7cded8..0000000 --- a/services/PolymarketClobClient.cs.bak2 +++ /dev/null @@ -1,606 +0,0 @@ -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; - } - - /// - /// Creates an HMAC signature for authenticated requests to the Polymarket CLOB. - /// - 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('/', '_'); - } - - /// - /// Derives a new Polymarket Level 2 API Key using an EIP712 Message signed by the L1 private key. - /// - 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 - { - 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 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); - } - - /// - /// Executes a native EIP-712 signed order (default Fill-Or-Kill) - /// - public async Task 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 - { - 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; - } - } - } -} diff --git a/services/PolymarketClobClient.cs.bak4 b/services/PolymarketClobClient.cs.bak4 deleted file mode 100644 index 1eea88a..0000000 --- a/services/PolymarketClobClient.cs.bak4 +++ /dev/null @@ -1,799 +0,0 @@ -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; - } - - /// - /// Creates an HMAC signature for authenticated requests to the Polymarket CLOB. - /// - 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('/', '_'); - } - - /// - /// Derives a new Polymarket Level 2 API Key using an EIP712 Message signed by the L1 private key. - /// - 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 - { - 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 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> 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 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(); - - 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); - } - - /// - /// Executes a native EIP-712 signed order (default Fill-Or-Kill) - /// - public async Task 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 - { - 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; - } - } - } -} diff --git a/services/PolymarketClobClient.cs.bak5 b/services/PolymarketClobClient.cs.bak5 deleted file mode 100644 index ed7a0b5..0000000 --- a/services/PolymarketClobClient.cs.bak5 +++ /dev/null @@ -1,799 +0,0 @@ -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; - } - - /// - /// Creates an HMAC signature for authenticated requests to the Polymarket CLOB. - /// - 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('/', '_'); - } - - /// - /// Derives a new Polymarket Level 2 API Key using an EIP712 Message signed by the L1 private key. - /// - 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 - { - 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 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> 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 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(); - - 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); - } - - /// - /// Executes a native EIP-712 signed order (default Fill-Or-Kill) - /// - public async Task 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 - { - 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; - } - } - } -} diff --git a/services/PolymarketWssClient.cs.bak b/services/PolymarketWssClient.cs.bak deleted file mode 100644 index 4d8ffe7..0000000 --- a/services/PolymarketWssClient.cs.bak +++ /dev/null @@ -1,284 +0,0 @@ -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 _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(); - var subscriptionTask = Task.Run(async () => - { - while (ws.State == WebSocketState.Open && !stoppingToken.IsCancellationRequested && _settings.UsePolymarketWebsockets) - { - var neededAssets = new HashSet(); - 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(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(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($"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("closed_trades").Insert(ct); - _db.GetCollection("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}"); - } - } - } -} diff --git a/services/TraderMonitorService.cs.bak b/services/TraderMonitorService.cs.bak deleted file mode 100644 index bfc15b3..0000000 --- a/services/TraderMonitorService.cs.bak +++ /dev/null @@ -1,550 +0,0 @@ -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 _signalWriter; - private readonly ChannelWriter _closedTradeWriter; - private readonly TerminalLogger _logger; - - // Prevents duplicates. Fast O(1) lookup cache to prevent DB spam. - private readonly ConcurrentDictionary _processedTxHashes = new(); - private DateTime _lastHashCleanup = DateTime.UtcNow; - private readonly ConcurrentDictionary _processedClosures = new(); - private readonly ConcurrentDictionary _lastPolled = new(); - private DateTime _lastLivePoll = DateTime.MinValue; - - public TraderMonitorService( - TradingState state, - PolymarketApiService api, - ChannelWriter signalWriter, - ChannelWriter 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(); - - 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; - } - } - - /// - /// Triggered instantly by the AlchemyWebsocketService when an EVM TransferSingle is detected. - /// - 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(); - - 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}"); - } - } - } -} diff --git a/services/TraderMonitorService.cs.bak2 b/services/TraderMonitorService.cs.bak2 deleted file mode 100644 index e3c69f7..0000000 --- a/services/TraderMonitorService.cs.bak2 +++ /dev/null @@ -1,602 +0,0 @@ -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 _signalWriter; - private readonly ChannelWriter _closedTradeWriter; - private readonly TerminalLogger _logger; - - // Prevents duplicates. Fast O(1) lookup cache to prevent DB spam. - private readonly ConcurrentDictionary _processedTxHashes = new(); - private DateTime _lastHashCleanup = DateTime.UtcNow; - private readonly ConcurrentDictionary _processedClosures = new(); - private readonly ConcurrentDictionary _lastPolled = new(); - private DateTime _lastLivePoll = DateTime.MinValue; - - public TraderMonitorService( - TradingState state, - PolymarketApiService api, - PolymarketClobClient clob, - ChannelWriter signalWriter, - ChannelWriter 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(); - - 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; - } - } - - /// - /// Triggered instantly by the AlchemyWebsocketService when an EVM TransferSingle is detected. - /// - 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(); - - 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}"); - } - } - } -} diff --git a/services/database.cs b/services/database.cs deleted file mode 100644 index f3bbc34..0000000 --- a/services/database.cs +++ /dev/null @@ -1,15 +0,0 @@ -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 - { - - } -} diff --git a/services/settings.cs b/services/settings.cs deleted file mode 100644 index 3f02309..0000000 --- a/services/settings.cs +++ /dev/null @@ -1,30 +0,0 @@ -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() { - - - } - } -}