using System; using MongoDB.Driver; using PolyTraderSharp.Extensions; using System.Threading; using System.Threading.Channels; using System.Threading.Tasks; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using PolyTraderSharp.Models; using System.Collections.Concurrent; using System.Linq; namespace PolyTraderSharp.Services { public class CopyTradingEngine : BackgroundService { private readonly TradingState _state; private readonly ChannelReader _signalReader; private readonly ChannelWriter _closedTradeWriter; private readonly TerminalLogger _logger; private readonly PolymarketClobClient _clob; private readonly PolymarketApiService _api; private readonly IMongoDatabase? _db; private readonly ConcurrentDictionary _accountSemaphores = new(); private readonly ConcurrentDictionary _lastInactiveLogPerTrader = new(); public CopyTradingEngine( TradingState state, ChannelReader signalReader, ChannelWriter closedTradeWriter, TerminalLogger logger, PolymarketClobClient clob, PolymarketApiService api, IMongoDatabase? db = null) { _state = state; _signalReader = signalReader; _closedTradeWriter = closedTradeWriter; _logger = logger; _clob = clob; _api = api; _db = db; } public override async Task StartAsync(CancellationToken cancellationToken) { _logger.Info("Starte Preload des MarketCache aus MongoDB um Flaschenhälse zu vermeiden..."); if (_db != null) { var coll = _db.GetCollection("markets"); // Initialize cache for EVERYTHING in DB that is not closed! var activeMarkets = coll.LiteFind(x => !x.Closed); int loaded = 0; foreach (var md in activeMarkets) { if (!string.IsNullOrEmpty(md.ClobTokenIds)) { try { var tokenIds = System.Text.Json.JsonSerializer.Deserialize>(md.ClobTokenIds); if (tokenIds != null) { foreach (var token in tokenIds) { _state.MarketCache[token] = md; loaded++; } } } catch { } // Ignore malformed JSON cleanly } } _logger.Info($"MarketCache Preload abgeschlossen: {loaded} Token herangeführt."); } await base.StartAsync(cancellationToken); } protected override async Task ExecuteAsync(CancellationToken stoppingToken) { _logger.Info("CopyTradingEngine Channel Listener started (Concurrent)."); var semaphore = new SemaphoreSlim(15, 15); await foreach (var signal in _signalReader.ReadAllAsync(stoppingToken)) { await semaphore.WaitAsync(stoppingToken); _ = Task.Run(async () => { try { await ProcessSignalAsync(signal); } catch (Exception ex) { _logger.Error($"Absturz im SignalProcessor: {ex.Message}"); } finally { semaphore.Release(); } }, stoppingToken); } } private async Task ProcessSignalAsync(CopySignal signal) { if (_state.GlobalTradingPaused) { _logger.TradeReasoning($"⏸️ Trade {signal.Side} [{signal.MarketQuestion}] ignoriert: GlobalTradingPaused ist aktiv."); return; } // ========================================== // GLOBAL EXPENSIVE DB/API MARKET CACHE HYDRATION // Runs once per signal, before any account locks! // ========================================== bool isNegRisk = false; if (!string.IsNullOrEmpty(signal.TokenId)) { if (_state.MarketCache.TryGetValue(signal.TokenId, out var cachedData)) { if (!string.IsNullOrEmpty(cachedData.Slug)) signal.MarketSlug = cachedData.Slug; if (!string.IsNullOrEmpty(cachedData.Question)) signal.MarketQuestion = cachedData.Question; if (cachedData.EndDate.HasValue) signal.EndDate = cachedData.EndDate; isNegRisk = cachedData.NegRisk; } else if (_db != null) { try { var marketColl = _db.GetCollection("markets"); var marketData = marketColl.LiteFind(x => x.ClobTokenIds != null && x.ClobTokenIds.Contains(signal.TokenId)).FirstOrDefault(); if (marketData == null) { var fetchedMarket = await _api.GetMarketByTokenIdAsync(signal.TokenId); if (fetchedMarket != null) { marketColl.Upsert(fetchedMarket); marketData = fetchedMarket; } } if (marketData == null && !string.IsNullOrEmpty(signal.MarketSlug) && !signal.MarketSlug.StartsWith("0x")) { var fetchedMarkets = await _api.GetMarketsByEventSlugAsync(signal.MarketSlug); foreach (var fetched in fetchedMarkets) { marketColl.Upsert(fetched); if (fetched.ClobTokenIds != null && fetched.ClobTokenIds.Contains(signal.TokenId)) marketData = fetched; } } if (marketData != null) { if (!string.IsNullOrEmpty(marketData.Slug)) signal.MarketSlug = marketData.Slug; if (!string.IsNullOrEmpty(marketData.Question)) signal.MarketQuestion = marketData.Question; if (marketData.EndDate.HasValue) signal.EndDate = marketData.EndDate; isNegRisk = marketData.NegRisk; // Add to Cache for fast lookup _state.MarketCache[signal.TokenId] = marketData; } } catch (Exception ex) { _logger.Warning($"Fehler beim Abrufen von MarketData f\u00fcr Token {signal.TokenId}: {ex.Message}"); } } } // ========================================== // Internal System Signal (e.g. Demo Auto-Close) if (signal.TraderId == 0) { var sysaccountTasks = new List(); foreach (var account in _state.Accounts.Values.Where(a => a.IsDemo && a.IsActive)) { if (account.OpenPositions.ContainsKey(signal.TokenId)) { sysaccountTasks.Add(ProcessAccountOrderAsync(account, null, signal, isNegRisk)); } } await Task.WhenAll(sysaccountTasks); return; } if (!_state.Traders.TryGetValue(signal.TraderId, out var trader) || !trader.IsActive) { _logger.TradeReasoning($"\u23f8\ufe0f Trade {signal.Side} [{signal.MarketQuestion}] ignoriert: Trader (ID={signal.TraderId}) nicht gefunden oder inaktiv."); return; } var accountTasks = new List(); foreach (var accountId in trader.AssignedAccountIds) { if (!_state.Accounts.TryGetValue(accountId, out var account) || !account.IsActive) { _logger.TradeReasoning($"\u23f8\ufe0f Trade {signal.Side} [{signal.MarketQuestion}] ignoriert: Account (ID={accountId}) nicht gefunden oder inaktiv."); continue; } accountTasks.Add(ProcessAccountOrderAsync(account, trader, signal, isNegRisk)); } await Task.WhenAll(accountTasks); } private async Task ProcessAccountOrderAsync(AccountState account, TrackedTrader? trader, CopySignal signal, bool isNegRisk) { var mode = account.IsDemo ? _state.DemoTradingMode : _state.LiveTradingMode; if (mode == TradingMode.Inactive) { // Rate-limited log: max 1 per trader per 60s to prevent HF spam var traderId = signal.TraderId; var now = DateTime.UtcNow; if (!_lastInactiveLogPerTrader.TryGetValue(traderId, out var lastLog) || (now - lastLog).TotalSeconds >= 60) { _lastInactiveLogPerTrader[traderId] = now; string modeLabel = account.IsDemo ? "Demo" : "Live"; _logger.TradeReasoning($"⏸️ Trade {signal.Side} [{signal.MarketQuestion}] [{(string.IsNullOrEmpty(signal.Outcome) ? signal.Side : signal.Outcome)}] ignoriert:\n" + $" Konto: {account.Name}\n" + $" Begründung: {modeLabel}-Trading Modus ist 'Inactive'. Weitere Trades dieses Traders werden für 60s nicht geloggt."); } return; } // Restrict BUY operations if mode is SellOnly if (mode == TradingMode.SellOnly && signal.Side == "BUY") { _logger.TradeReasoning($"⏸️ Trade BUY [{signal.MarketQuestion}] [{(string.IsNullOrEmpty(signal.Outcome) ? signal.Side : signal.Outcome)}] ignoriert:\n" + $" Konto: {account.Name}\n" + $" Begründung: Trading Modus ist 'SellOnly' — BUY-Trades werden nicht kopiert."); return; } string shareType = string.IsNullOrEmpty(signal.Outcome) ? signal.Side : signal.Outcome; var accountSemaphore = _accountSemaphores.GetOrAdd(account.AccountId, _ => new SemaphoreSlim(1, 1)); await accountSemaphore.WaitAsync(); try { // ========================================== // OPEN ORDER CLEANUP (LIVE ACCOUNTS ONLY) // ========================================== // Wenn ein neues Signal für diesen Markt reinkommt, prüfen wir auf veraltete offene Orders. // Identische Preise bleiben bestehen. Abweichende verhindern ungültiges Blockieren von Funds. if (!account.IsDemo && !string.IsNullOrEmpty(signal.TokenId)) { if (account.HasOpenLimitOrders) { await _clob.CancelConflictingOrdersAsync(account, signal.TokenId, signal.Price, signal.Side); } } if (_state.MarketCache.TryGetValue(signal.TokenId, out var fastCachedData)) { if (fastCachedData.EndDate.HasValue) signal.EndDate = fastCachedData.EndDate; } // ========================================== // PRE-FLIGHT RISK CHECKS (Before DB/API!) // ========================================== decimal exactShares = 0m; decimal exactUsdc = 0m; decimal orderPrice = signal.Price; if (signal.Side == "BUY") { if (signal.Price > account.MaxBuyPrice) { _logger.TradeReasoning($"❌ Trade BUY [{signal.MarketQuestion}] [{shareType}] verworfen (Risk Limit):\n" + $" Konto: {account.Name}\n" + $" Begründung: Preis (${signal.Price:F3}) übersteigt das MaxBuy Limit (${account.MaxBuyPrice:F3})"); return; } var activePositions = account.OpenPositions.Values.Where(p => IsPositionMarketActive(p)).ToList(); decimal investedInMarket = activePositions.FirstOrDefault(p => p.TokenId == signal.TokenId)?.AmountUsd ?? 0m; decimal minTrade = 1.0m; decimal maxAllowed = account.TotalBalance * (account.PerMarketLimit / 100.0m); // Low Balance Bypass (Stufen-System) ALWAYS APPLIES if (account.TotalBalance < 150m) maxAllowed = Math.Min(1.20m, Math.Max(account.AvailableBalance, 0m)); else if (account.TotalBalance < 500m) maxAllowed = Math.Min(3.0m, Math.Max(account.AvailableBalance, 0m)); if (_state.SixSharesMinimum && account.TotalBalance < 500m) { // Adjust maxAllowed to cover at least 6 shares * order limit price. decimal desiredLimitForSix; if (trader != null && trader.Category == "HF") { desiredLimitForSix = signal.Price + 0.005m; } else { desiredLimitForSix = signal.Price * (1.0m + account.MaxPriceDifference / 100.0m); } decimal orderPriceForSix = Math.Min(desiredLimitForSix, account.MaxBuyPrice); if (orderPriceForSix > 0.99m) orderPriceForSix = 0.99m; decimal costSix = 6m * orderPriceForSix; if (costSix > maxAllowed) { maxAllowed = Math.Min(costSix, Math.Max(account.AvailableBalance, 0m)); } } decimal maxAmountToBuy = maxAllowed - investedInMarket; decimal investedInMaster = trader != null ? activePositions.Where(p => p.SourceTraderId == trader.Id).Sum(p => (decimal)p.AmountUsd) : 0m; decimal maxAllowedPerMaster = account.TotalBalance * (account.PerMasterLimit / 100.0m); if (trader != null && (investedInMaster + maxAmountToBuy) > maxAllowedPerMaster) { decimal pctInvested = account.TotalBalance > 0 ? (investedInMaster / account.TotalBalance) * 100m : 0m; _logger.TradeReasoning($"❌ Trade BUY [{signal.MarketQuestion}] [{shareType}] verworfen:\n" + $" Konto: {account.Name}\n" + $" Begründung: PerMasterLimit ({account.PerMasterLimit:F1}%) erreicht. Bisher investiert in '{trader.DisplayName}': ${investedInMaster:F2} ({pctInvested:F1}%)."); return; } // Time Limit Restriktion double hoursLeft = signal.EndDate.HasValue ? (signal.EndDate.Value - DateTime.UtcNow).TotalHours : 999999; decimal applicableTimeLimitPct; decimal investedInTimeframe = 0m; string timeframeLabel = ""; var openVals = activePositions; if (hoursLeft < 6) { applicableTimeLimitPct = account.perMaxTime6h; timeframeLabel = "< 6h"; investedInTimeframe = openVals.Where(p => p.ExpiryDate.HasValue && (p.ExpiryDate.Value - DateTime.UtcNow).TotalHours < 6).Sum(p => (decimal)p.AmountUsd); } else if (hoursLeft < 24) { applicableTimeLimitPct = account.perMaxTime24h; timeframeLabel = "< 24h"; investedInTimeframe = openVals.Where(p => p.ExpiryDate.HasValue && (p.ExpiryDate.Value - DateTime.UtcNow).TotalHours >= 6 && (p.ExpiryDate.Value - DateTime.UtcNow).TotalHours < 24).Sum(p => (decimal)p.AmountUsd); } else if (hoursLeft < 72) { applicableTimeLimitPct = account.perMaxTime72h; timeframeLabel = "< 72h"; investedInTimeframe = openVals.Where(p => p.ExpiryDate.HasValue && (p.ExpiryDate.Value - DateTime.UtcNow).TotalHours >= 24 && (p.ExpiryDate.Value - DateTime.UtcNow).TotalHours < 72).Sum(p => (decimal)p.AmountUsd); } else { applicableTimeLimitPct = account.perMaxTimeNone; timeframeLabel = "> 72h"; investedInTimeframe = openVals.Where(p => !p.ExpiryDate.HasValue || (p.ExpiryDate.Value - DateTime.UtcNow).TotalHours >= 72).Sum(p => (decimal)p.AmountUsd); } decimal maxAllowedTimeframe = account.TotalBalance * (applicableTimeLimitPct / 100.0m); if ((investedInTimeframe + maxAmountToBuy) > maxAllowedTimeframe) { decimal remainingForTimeframe = maxAllowedTimeframe - investedInTimeframe; if (remainingForTimeframe < minTrade) { _logger.TradeReasoning($"❌ Trade BUY [{signal.MarketQuestion}] [{shareType}] verworfen:\n" + $" Konto: {account.Name}\n" + $" Begründung: TimeLimit '{timeframeLabel}' ({applicableTimeLimitPct:F1}%) erreicht. Bisher investiert: ${investedInTimeframe:F2} / max. ${maxAllowedTimeframe:F2}"); return; } else { maxAmountToBuy = remainingForTimeframe; } } if (maxAmountToBuy < minTrade) { _logger.TradeReasoning($"❌ Trade BUY [{signal.MarketQuestion}] [{shareType}] verworfen:\n" + $" Konto: {account.Name}\n" + $" Begründung: Kauflimit (${maxAllowed:F2}) bereits in Markt investiert (${investedInMarket:F2}). Rest: ${maxAmountToBuy:F2} < MinTrade (${minTrade:F2})"); return; } if (maxAmountToBuy > account.AvailableBalance) { _logger.TradeReasoning($"❌ Trade BUY [{signal.MarketQuestion}] [{shareType}] verworfen:\n" + $" Konto: {account.Name}\n" + $" Begründung: Kontostand (${account.AvailableBalance:F2}) nicht ausreichend für errechnetes Size (${maxAmountToBuy:F2})"); return; } decimal desiredLimit; if (trader != null && trader.Category == "HF") { // HF Trader: festes 0.5 Cent (0.005) Limit desiredLimit = signal.Price + 0.005m; } else { // Normaler Trader: prozentuales Limit aus Slave-Account Settings desiredLimit = signal.Price * (1.0m + account.MaxPriceDifference / 100.0m); } orderPrice = Math.Min(desiredLimit, account.MaxBuyPrice); if (orderPrice > 0.99m) orderPrice = 0.99m; var exact = PolymarketClobClient.CalculateExactOrderAmounts(maxAmountToBuy, orderPrice, orderPrice, "BUY"); if (exact.shares <= 0 || exact.usdc > account.AvailableBalance) { _logger.TradeReasoning($"❌ Trade BUY [{signal.MarketQuestion}] [{shareType}] gestoppt:\n" + $" Begründung: Mathematisch unmöglicher Trade ({exact.shares} Shares für ${exact.usdc:F2}). Kontostand (${account.AvailableBalance:F2}) reicht für Minimum nicht aus."); return; } // ===== MICRO-ORDER FILTER: Polymarket Minimum Size Enforcement ===== // Polymarket lehnt Orders mit < 5 Shares ab ("Size lower than the minimum: 5"). // Statt die API zu belasten und Fehler-Logs zu erzeugen, filtern wir hier sofort. if (exact.shares < 5.5m || exact.usdc < 0.10m) { _logger.TradeReasoning($"❌ Trade BUY [{signal.MarketQuestion}] [{shareType}] gestoppt:\n" + $" Konto: {account.Name}\n" + $" Begründung: Unter Polymarket Minimum ({exact.shares:F1} Shares / ${exact.usdc:F2} USDC). Min: 5.5 Shares / $0.10."); return; } exactShares = exact.shares; exactUsdc = exact.usdc; } else if (signal.Side == "SELL") { // PRE-FLIGHT SELL Check: Exists in portfolio AND opened by the SAME master trader? // CRITICAL: We must NOT sell a position opened by Trader A based on a SELL signal from Trader B. var inPortfolio = account.OpenPositions.Values.FirstOrDefault(p => (p.TokenId == signal.TokenId || (p.MarketSlug == signal.MarketSlug && p.Outcome == signal.Outcome)) && p.SourceTraderId == signal.TraderId); if (inPortfolio == null) { // Check if position exists but belongs to a different trader (for clearer logging) var wrongTraderPos = account.OpenPositions.Values.FirstOrDefault(p => p.TokenId == signal.TokenId || (p.MarketSlug == signal.MarketSlug && p.Outcome == signal.Outcome)); if (wrongTraderPos != null) { _logger.Info($"❌ Trade SELL [{signal.MarketQuestion}] [{shareType}] ignoriert:\n" + $" Konto: {account.Name}\n" + $" Begründung: Position gehört Trader '{wrongTraderPos.SourceTraderName}' (ID {wrongTraderPos.SourceTraderId}), SELL kam aber von Trader ID {signal.TraderId}."); } else { _logger.Info($"❌ Trade SELL [{signal.MarketQuestion}] [{shareType}] ignoriert:\n" + $" Konto: {account.Name}\n" + $" Begründung: Position nicht im Portfolio gefunden (möglicherweise zuvor gefiltert)."); } return; } // PROPORTIONALITY CHECK: Only copy SELL if the master trader is exiting a significant portion (≥30%) of their position. // Active day-traders like SwissTony buy 500 shares then sell 2 (0.4%) — we should NOT copy that. // But if they sell 200 of 500 (40%), that's a real exit signal we must copy. string masterPosKey = $"{signal.TraderId}_{inPortfolio.TokenId}"; if (_state.MasterTraderPositions.TryGetValue(masterPosKey, out var masterPos)) { decimal masterShares = masterPos.Shares; if (masterShares > 0 && signal.Size > 0) { // Calculate what percentage of the master's known position this SELL represents decimal sellRatio = signal.Size / (masterShares + signal.Size); // +signal.Size because the position was already reduced if (sellRatio < 0.30m) { _logger.TradeReasoning($"📊 Trade SELL [{signal.MarketQuestion}] [{shareType}] ignoriert:\n" + $" Konto: {account.Name}\n" + $" Begründung: Teilverkauf ({sellRatio:P0} des Bestands). Master hält noch {masterShares:F1} Shares. Signal nur {signal.Size:F1} Shares. Schwelle: 30%."); return; } _logger.TradeReasoning($"📊 Trade SELL [{signal.MarketQuestion}] [{shareType}] FREIGEGEBEN:\n" + $" Konto: {account.Name}\n" + $" Begründung: Signifikanter Verkauf ({sellRatio:P0} des Bestands). Master hatte {masterShares + signal.Size:F1} Shares, verkauft {signal.Size:F1}."); } else if (masterShares <= 0) { // Master has 0 shares according to our tracking, but a SELL signal came in. // This is an inconsistency — either our tracking is stale, or it's a phantom signal. // Do NOT sell blindly. Wait for the next background sync to update the real position. _logger.TradeReasoning($"📊 Trade SELL [{signal.MarketQuestion}] [{shareType}] ignoriert:\n" + $" Konto: {account.Name}\n" + $" Begründung: Master hält laut Tracking 0 Shares, aber SELL Signal mit {signal.Size:F1} Shares erhalten. Inkonsistenz — ignoriert."); return; } } else { // No tracking data yet — apply soft grace period (2 min) as fallback until first sync completes double holdingMinutes = (DateTime.UtcNow - inPortfolio.OpenedAt).TotalMinutes; if (holdingMinutes < 2.0) { _logger.TradeReasoning($"⏳ Trade SELL [{signal.MarketQuestion}] [{shareType}] ignoriert:\n" + $" Konto: {account.Name}\n" + $" Begründung: Kein Master-Position-Tracking verfügbar und Haltezeit erst {holdingMinutes:F1} Min. Warte auf ersten Sync."); return; } } } // Market Metadata loaded globally. Ready for execution. // If BUY -> Invest if (signal.Side == "BUY") { if (account.IsDemo) { var pos = new Position { TokenId = signal.TokenId, MarketSlug = signal.MarketSlug, SourceTraderId = trader?.Id ?? 0, SourceTraderName = trader?.DisplayName ?? "System", SourceTraderAddress = trader?.WalletAddress ?? "", MarketQuestion = signal.MarketQuestion, Outcome = signal.Outcome, Side = "BUY", EntryPrice = orderPrice, Size = exactShares, AmountUsd = exactUsdc, ExpiryDate = signal.EndDate ?? DateTime.UtcNow.AddDays(14) }; _state.GetNextTradeId(); var finalPos = account.OpenPositions.AddOrUpdate(signal.TokenId, pos, (k, old) => { old.Size += pos.Size; old.AmountUsd += pos.AmountUsd; old.EntryPrice = old.AmountUsd / old.Size; // weighted average return old; }); if (_db != null) _db.GetCollection($"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})"); var result = await _clob.PlaceOrderAsync(account, signal.TokenId, signal.Side, exactUsdc, orderPrice, "GTD", _state.DebugOrderPayloadLog, isNegRisk); if (result == "OK") { var pos = new Position { TokenId = signal.TokenId, MarketSlug = signal.MarketSlug, SourceTraderId = trader?.Id ?? 0, SourceTraderName = trader?.DisplayName ?? "System", SourceTraderAddress = trader?.WalletAddress ?? "", MarketQuestion = signal.MarketQuestion, Outcome = signal.Outcome, Side = "BUY", EntryPrice = orderPrice, // Real execution price will update on next SyncOpenPositions poll Size = exactShares, AmountUsd = exactUsdc, ExpiryDate = signal.EndDate ?? DateTime.UtcNow.AddDays(14) }; _state.GetNextTradeId(); account.OpenPositions.AddOrUpdate(signal.TokenId, pos, (k, old) => { old.Size += pos.Size; old.AmountUsd += pos.AmountUsd; old.EntryPrice = old.AmountUsd / old.Size; return old; }); account.UpdateBalance(account.AvailableBalance - exactUsdc); if (_db != null) _db.GetCollection("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); } } // Track order placement time for stale order cleanup string orderKey = $"{account.AccountId}_{signal.TokenId}"; _state.PendingOrderTimestamps[orderKey] = (DateTime.UtcNow, signal.TraderId); // Initialize master position tracking with signal size if not yet tracked // The background sync will update with the real value within 30 seconds string masterKey = $"{signal.TraderId}_{signal.TokenId}"; _state.MasterTraderPositions.TryAdd(masterKey, (signal.Size, DateTime.UtcNow)); } } } // If SELL -> Divest else if (signal.Side == "SELL") { string orderKey = $"{account.AccountId}_{signal.TokenId}"; if (_state.PendingOrderTimestamps.TryGetValue(orderKey, out var pendingInfo)) { if ((DateTime.UtcNow - pendingInfo.PlacedAt).TotalSeconds < 20) { return; // Spam-Blockade: Die Order wurde in den letzten 20 Sekunden bereits versendet } } bool removed = account.OpenPositions.TryRemove(signal.TokenId, out var openPos); // Defense-in-depth: Verify the removed position actually belongs to this trader if (removed && openPos != null && openPos.SourceTraderId != signal.TraderId) { // Wrong trader! Put the position back and treat as not found. account.OpenPositions.TryAdd(signal.TokenId, openPos); removed = false; openPos = null; _logger.Info($"❌ Trade SELL [{signal.MarketQuestion}] [{shareType}] ignoriert:\n" + $" Konto: {account.Name}\n" + $" Begründung: Position gehört einem anderen Trader (Safety Check)."); } if (!removed && !string.IsNullOrEmpty(signal.MarketSlug)) { // Fallback matching must ALSO respect SourceTraderId! var altPos = account.OpenPositions.Values.FirstOrDefault(p => p.MarketSlug == signal.MarketSlug && p.Outcome == signal.Outcome && p.SourceTraderId == signal.TraderId); if (altPos != null) { removed = account.OpenPositions.TryRemove(altPos.TokenId, out openPos); if (removed) { _logger.Info($"Fallback: Position für SELL über Slug+Outcome gefunden ({altPos.TokenId}) statt TokenId ({signal.TokenId})"); signal.TokenId = altPos.TokenId; // Fix for further processing } } } if (removed && openPos != null) { if (account.IsDemo) { if (_db != null) _db.GetCollection($"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.GetNextTradeId(), AccountId = account.AccountId, SourceTraderId = signal.TraderId, IsDemo = account.IsDemo, MarketSlug = signal.MarketSlug, MarketQuestion = signal.MarketQuestion, Outcome = signal.Outcome, Side = signal.Side, EntryPrice = openPos.EntryPrice, ExitPrice = signal.Price, Size = openPos.Size, RealizedPnl = realizedPnl, PnlPercent = openPos.AmountUsd > 0 ? (realizedPnl / openPos.AmountUsd * 100m) : 0m, OpenedAt = openPos.OpenedAt, ClosedAt = DateTime.UtcNow, ExitReason = signal.Reason }; _closedTradeWriter.TryWrite(ct); _logger.Trade($"✅ [DEMO GESCHLOSSEN]\n" + $" Konto: {account.Name}\n" + $" Markt: {signal.MarketQuestion}\n" + $" SELL: {openPos.Size:F2} Shares [{shareType}] @ ${signal.Price:F3} (Gewinn: ${realizedPnl:F2})"); } else { decimal sellLimit = 0.01m; // Market Order Fallback Limit (PolyMarket Safety) decimal expectedUsdc = openPos.Size * sellLimit; var exact = PolymarketClobClient.CalculateExactOrderAmounts(expectedUsdc, sellLimit, sellLimit, "SELL", "MARKET"); if (exact.shares <= 0) { _logger.TradeReasoning($"❌ Trade SELL [{signal.MarketQuestion}] [{shareType}] fehlgeschlagen!\n" + $" Konto: {account.Name}\n" + $" Grund: Mathematical Order Size Error (Dust Token)."); // We don't return to OpenPositions to let dust drop gracefully return; } _logger.Info($"🌐 [LIVE-EXECUTION] Sende MARKET SELL an Polymarket CTF-Router...\n" + $" Account: {account.Name}\n" + $" Order: MARKET (Target: {signal.Price:F3})"); var result = await _clob.PlaceOrderAsync(account, signal.TokenId, signal.Side, expectedUsdc, sellLimit, "MARKET", _state.DebugOrderPayloadLog, isNegRisk); if (result == "OK") { // Track order placement time for stale order cleanup / sync routines _state.PendingOrderTimestamps[orderKey] = (DateTime.UtcNow, signal.TraderId); _logger.Trade($"✅ [LIVE MARKET SELL PLATZIERT] - {account.Name} - MARKET Swept. Gewinne/Verluste in Kürze im API Sync sichtbar."); } else { // Call failed, log it so the user knows Sells are being attempted but failing. _logger.TradeReasoning($"❌ Trade SELL [{signal.MarketQuestion}] [{shareType}] fehlgeschlagen!\n" + $" Konto: {account.Name}\n" + $" Grund: {result}\n" + $" Aktion: Position bleibt vorerst im Portfolio erhalten."); // Temporären Cooldown (5 Sek) setzen, um Log-Spam durch wiederholte API-Fehler zu vermeiden _state.PendingOrderTimestamps[orderKey] = (DateTime.UtcNow.AddSeconds(-15), signal.TraderId); // Reverse the TryRemove if it failed, so the next poll can try again account.OpenPositions.TryAdd(signal.TokenId, openPos); } } } else { _logger.Info($"❌ Trade SELL [{signal.MarketQuestion}] [{shareType}] ignoriert:\n" + $" Konto: {account.Name}\n" + $" Begründung: Position nicht im Portfolio gefunden (möglicherweise zuvor gefiltert)."); } } } finally { accountSemaphore.Release(); } } private bool IsPositionMarketActive(Position pos) { // O(1) RAM Lookup. Eliminated LiteDB queries for ultra-low latency. if (_state.MarketCache.TryGetValue(pos.TokenId, out var md)) { return !md.Closed; } // Defaults to active until cache hydrates. // Better to assume active and restrict budget than auto-open budget on unknown markets. return true; } } }