using System; using MongoDB.Driver; using PolyTrader.Core.Persistence; using PolyTraderSharp.Extensions; 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; private readonly IMongoDatabase? _db; private readonly IPositionRepository _positionRepo; private readonly IMarketRepository _marketRepo; // 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 readonly ConcurrentDictionary _activeWssPolls = new(); private DateTime _lastLivePoll = DateTime.MinValue; private DateTime _lastClosedPoll = DateTime.MinValue; private DateTime _lastMasterPositionPoll = DateTime.MinValue; // AutoRedeem Tracker for REST updates private static readonly ConcurrentDictionary _restRedeemAttempts = new(); public TraderMonitorService( TradingState state, PolymarketApiService api, PolymarketClobClient clob, ChannelWriter signalWriter, ChannelWriter closedTradeWriter, TerminalLogger logger, IPositionRepository positionRepo, IMarketRepository marketRepo, IMongoDatabase? db = null) { _state = state; _api = api; _clob = clob; _signalWriter = signalWriter; _closedTradeWriter = closedTradeWriter; _logger = logger; _positionRepo = positionRepo; _marketRepo = marketRepo; _db = db; } protected override async Task ExecuteAsync(CancellationToken stoppingToken) { _logger.Info("TraderMonitorService started background API priority polling..."); // ===== STARTUP: _processedClosures aus DB vorladen ===== // Verhindert, dass nach einem Neustart alle historischen geschlossenen Trades // erneut als ClosedTrade-Records in die Datenbank geschrieben werden. if (_db != null) { try { var closedCol = _db.GetCollection("closed_trades"); var allClosed = closedCol.LiteFind(x => !x.IsDemo); int preloaded = 0; foreach (var ct in allClosed) { if (!string.IsNullOrEmpty(ct.TokenId)) { string key = $"{ct.AccountId}_{ct.TokenId}"; _processedClosures.TryAdd(key, true); preloaded++; } } _logger.Info($"_processedClosures vorgeladen: {preloaded} Einträge aus closed_trades geladen (verhindert Duplikate nach Neustart)."); } catch (Exception ex) { _logger.Warning($"_processedClosures Preload fehlgeschlagen: {ex.Message}"); } } // ===== STARTUP: Sofortiger Warmup der Live-Positionen und Master-Tracker ===== // Ohne diesen Warmup ist der Proportionalitätsfilter nach einem Neustart 30s lang blind, // und die 2-Min-Karenzzeit erlaubt unberechtigte SELLs. try { _logger.Info("Startup: Lade Live-Positionen und Master-Tracker-Cache..."); await PollLiveAccountsAsync(stoppingToken); _lastLivePoll = DateTime.UtcNow; await SyncMasterTraderPositionsAsync(stoppingToken); _lastMasterPositionPoll = DateTime.UtcNow; _logger.Info($"Startup: MasterTraderPositions warmup abgeschlossen ({_state.MasterTraderPositions.Count} Einträge)."); } catch (Exception ex) { _logger.Warning($"Startup Warmup fehlgeschlagen (nicht kritisch, wird im Loop nachgeholt): {ex.Message}"); } 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; } // Master Trader Position Tracking (Runs every 30s, offset from live poll) if ((DateTime.UtcNow - _lastMasterPositionPoll).TotalSeconds > 30) { await SyncMasterTraderPositionsAsync(stoppingToken); _lastMasterPositionPoll = DateTime.UtcNow; } // Background Closed Trades Sync (Runs every 2 minutes decoupled from local state) if ((DateTime.UtcNow - _lastClosedPoll).TotalMinutes > 2) { await PollClosedAccountsAsync(stoppingToken); _lastClosedPoll = 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."); } ProcessActivityItemsMerged(activity, 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; } } public void TriggerFastBlockchainPoll(string txHash, string rpcUrl, string walletAddress) { var trader = _state.Traders.Values.FirstOrDefault(t => t.WalletAddress.Equals(walletAddress, StringComparison.OrdinalIgnoreCase)); if (trader == null || !trader.IsActive) return; if (_state.EnableBlockchainParser) { if (!_processedTxHashes.TryAdd(txHash, DateTime.UtcNow)) return; // Debounce txHash duplicate WSS events Task.Run(async () => { try { var signals = await _api.ParseBlockchainTransactionAsync(txHash, rpcUrl, trader.WalletAddress); if (signals != null && signals.Count > 0) { foreach (var signal in signals) { signal.TraderId = trader.Id; if (_state.MarketCache.TryGetValue(signal.TokenId, out var fastCachedData)) { signal.MarketQuestion = fastCachedData.Question; signal.MarketSlug = fastCachedData.Slug; signal.EndDate = fastCachedData.EndDate; if (!string.IsNullOrEmpty(fastCachedData.ClobTokenIds)) { try { var tokenArr = System.Text.Json.JsonSerializer.Deserialize>(fastCachedData.ClobTokenIds); if (tokenArr != null) { int idx = tokenArr.IndexOf(signal.TokenId); if (idx >= 0) { if (!string.IsNullOrEmpty(fastCachedData.Outcomes)) { var outcomesArr = System.Text.Json.JsonSerializer.Deserialize>(fastCachedData.Outcomes); if (outcomesArr != null && idx < outcomesArr.Count) { signal.Outcome = outcomesArr[idx]; } } // Fallback, if Outcomes array is empty or index out of bounds if (string.IsNullOrEmpty(signal.Outcome)) { if (idx == 0) signal.Outcome = "Yes"; else if (idx == 1) signal.Outcome = "No"; else if (idx > 1) signal.Outcome = $"Out{idx}"; } } } } catch {} } } else { // One-Time Cold Hit from Gamma API (eliminating LiteDB Full-Table-Scan blockage) var coldItem = await _api.GetMarketByTokenIdAsync(signal.TokenId); if (coldItem != null) { signal.MarketQuestion = coldItem.Question; signal.MarketSlug = coldItem.Slug; signal.EndDate = coldItem.EndDate; if (!string.IsNullOrEmpty(coldItem.ClobTokenIds)) { try { var tokenArr = System.Text.Json.JsonSerializer.Deserialize>(coldItem.ClobTokenIds); if (tokenArr != null) { int idx = tokenArr.IndexOf(signal.TokenId); if (idx >= 0) { if (!string.IsNullOrEmpty(coldItem.Outcomes)) { var outcomesArr = System.Text.Json.JsonSerializer.Deserialize>(coldItem.Outcomes); if (outcomesArr != null && idx < outcomesArr.Count) { signal.Outcome = outcomesArr[idx]; } } // Fallback, if Outcomes array is empty or index out of bounds if (string.IsNullOrEmpty(signal.Outcome)) { if (idx == 0) signal.Outcome = "Yes"; else if (idx == 1) signal.Outcome = "No"; else if (idx > 1) signal.Outcome = $"Out{idx}"; } } } } catch {} } // Update Cache for 0ms next time _state.MarketCache[signal.TokenId] = coldItem; // Asynchronously persist to LiteDB snapshot without blocking Hot Path if (_db != null) { _ = Task.Run(() => { try { _marketRepo.Upsert(coldItem); } catch { } // Failsafe }); } } else { // Provide fallback display if un-cached and API fails signal.MarketQuestion = "Unbekannter Markt (Lädt...)"; signal.Outcome = signal.TokenId.Substring(0, 6); } } bool added = _signalWriter.TryWrite(signal); if (added) { string shareType = string.IsNullOrEmpty(signal.Outcome) ? signal.Side : signal.Outcome; _logger.Trade($"🚨 [QUELLE: {trader.DisplayName}] WSS\n" + $" Markt: {signal.MarketQuestion} \n" + $" Aktion: {signal.Side} {shareType} ({signal.Size:F2} Shares @ ${signal.Price:F3})\n" + $" Zeit: {signal.Timestamp.ToString("HH:mm:ss")} UTC\n" + $" TxHash: {txHash}"); } } return; // Fast Track Successful! } // If it returns null, parser failed to decode this specific proxy trace. Fall back below. _logger.Debug($"FastTrack failed for TX {txHash}. Falling back to Data API loop."); _processedTxHashes.TryRemove(txHash, out _); // Revert the lockout so the API can process these! } catch (Exception ex) { _logger.Error($"FastTrack Parser Error: {ex.Message}"); _processedTxHashes.TryRemove(txHash, out _); // Revert on exception too } // Fallback Trigger: Delegate to normal API indexer with retry loop TriggerManualPoll(trader.WalletAddress); }); } else { TriggerManualPoll(walletAddress); // Fallback if Parser is globally disabled } } /// /// 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) { if (!_activeWssPolls.TryAdd(trader.WalletAddress, true)) { // Ein WSS-Poll Event läuft bereits für diesen Trader! Vermeidet 429 API-Spam bei Mikro-Trades. return; } // Force an immediate poll on a separate unblocked thread, completely bypassing the 1s loop delay Task.Run(async () => { try { int retries = 15; while(retries > 0) { int hashCountBefore = _processedTxHashes.Count; // Increase limit to 20 for WSS polling to ensure rapid batch waves aren't truncated by the API response length, // which had previously caused trades to be completely hidden. var activity = await _api.GetTraderActivityAsync(trader.WalletAddress, limit: 20); ProcessActivityItemsMerged(activity, trader); if (_processedTxHashes.Count > hashCountBefore) { // Hat einen neuen Trade gefunden! Schleife beenden. break; } // API DB Indexer hat WSS-Event noch nicht verarbeitet, 1s warten... await Task.Delay(1000); retries--; } } catch (Exception ex) { _logger.Error($"WSS API-Poll Fehler für {trader.DisplayName}: {ex.Message}"); } finally { _activeWssPolls.TryRemove(trader.WalletAddress, out _); } }); } } private async Task ExecuteRestAutoRedeemLive(AccountState acc, Position pos) { string redeemKey = $"{acc.AccountId}_{pos.TokenId}"; if (pos.Size < 5.0m) { var state = _restRedeemAttempts.GetOrAdd(redeemKey, _ => (0, DateTime.MinValue)); int newCount = state.Count + 1; _restRedeemAttempts[redeemKey] = (newCount, DateTime.UtcNow); if (newCount <= 1) _logger.Warning($"[REST AUTO REDEEM] Position {pos.MarketQuestion} zu klein für Limit Order (< 5 Shares). Max. 1 Retry in 5 Min."); return; } _restRedeemAttempts.AddOrUpdate(redeemKey, _ => (1, DateTime.UtcNow), (_, old) => (old.Count + 1, DateTime.UtcNow)); try { decimal expectedFillPrice = acc.PreRedeemLimit; decimal amountUsdc = Math.Max(pos.Size * expectedFillPrice, 0.01m); var result = await _clob.PlaceOrderAsync(acc, pos.TokenId, "SELL", amountUsdc, expectedFillPrice, "GTC", false, false); if (result == "OK") { _logger.Info($"✅ REST Auto-Redeem Sell sent for {acc.Name} at exact Limit {expectedFillPrice:F3} USD (GTC)."); } else { _logger.Error($"❌ REST Auto-Redeem failed or rejected: {result}."); } } catch (Exception ex) { _logger.Error($"REST Auto Redeem Exception: {ex.Message}"); } } 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 PollClosedAccountsAsync(CancellationToken ct) { 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 closedPositions = await _api.SyncClosedPositionsAsync(acc.WalletAddress, 50); if (closedPositions == null || closedPositions.Count == 0) continue; foreach (var cm in closedPositions) { string asset = cm.TryGetProperty("asset", out var ap) ? ap.GetString() ?? "" : ""; if (string.IsNullOrEmpty(asset)) continue; // 1. Immediately extract and clear from OpenPositions cache int resolvedSourceId = 0; if (acc.OpenPositions.TryRemove(asset, out var removedPos)) { resolvedSourceId = removedPos.SourceTraderId; } // 2. Fallback: Wenn TryRemove fehlschlägt (Race Condition mit PollLiveAccountsAsync), // SourceTraderId aus der MongoDB open_positions-Tabelle wiederherstellen. if (resolvedSourceId <= 0 && _db != null) { try { var dbPos = _positionRepo.FindLive(acc.AccountId, asset); if (dbPos != null && dbPos.SourceTraderId > 0) { resolvedSourceId = dbPos.SourceTraderId; // Auch removedPos füllen für MarketQuestion/Outcome weiter unten if (removedPos == null) removedPos = dbPos; } } catch { } } // 3. Fallback: Prüfe ob ein anderer Account dieselbe TokenId mit SourceTraderId hat if (resolvedSourceId <= 0) { foreach (var otherAcc in _state.Accounts.Values) { if (otherAcc.AccountId == acc.AccountId) continue; if (otherAcc.OpenPositions.TryGetValue(asset, out var otherPos) && otherPos.SourceTraderId > 0) { resolvedSourceId = otherPos.SourceTraderId; if (removedPos == null) removedPos = otherPos; break; } } } // 2. Prevent DB Duplicates! Fast check in MongoDB if available. bool dbExists = false; if (_db != null) { var col = _db.GetCollection("closed_trades"); dbExists = col.Find(x => x.AccountId == acc.AccountId && x.TokenId == asset).FirstOrDefault() != null; } string duplicateKey = $"{acc.AccountId}_{asset}"; if (!dbExists && !_processedClosures.ContainsKey(duplicateKey)) { decimal realizedPnl = 0m, entryPrice = 0m, size = 0m, exitPrice = 0m; DateTime resolvedTimestamp = DateTime.UtcNow; if (cm.TryGetProperty("timestamp", out var tsProp) || cm.TryGetProperty("updatedAt", out tsProp) || cm.TryGetProperty("closedAt", out tsProp) || cm.TryGetProperty("createdAt", out tsProp)) { try { if (tsProp.ValueKind == JsonValueKind.Number) { long ts = tsProp.GetInt64(); if (ts > 9999999999) ts /= 1000; resolvedTimestamp = DateTimeOffset.FromUnixTimeSeconds(ts).UtcDateTime; } else if (tsProp.ValueKind == JsonValueKind.String && DateTime.TryParse(tsProp.GetString(), out var parsedDate)) { resolvedTimestamp = parsedDate.ToUniversalTime(); } } catch { } } if (cm.TryGetProperty("realizedPnl", out var rPnlProp)) realizedPnl = ParseDecimal(rPnlProp); if (cm.TryGetProperty("avgPrice", out var epProp)) entryPrice = ParseDecimal(epProp); if (cm.TryGetProperty("totalBought", out var szProp)) size = ParseDecimal(szProp); // Approximate exit price based on PnL decimal investment = size * entryPrice; if (size > 0) exitPrice = (investment + realizedPnl) / size; string orderKey = $"{acc.AccountId}_{asset}"; bool soldByUs = _state.PendingOrderTimestamps.ContainsKey(orderKey); string exitReason = soldByUs ? "Master Trader Sold" : "Manuell Geschlossen / System"; var ctRecord = new ClosedTrade { TradeId = _state.GetNextTradeId(), AccountId = acc.AccountId, SourceTraderId = resolvedSourceId, IsDemo = false, TokenId = asset, MarketSlug = removedPos != null ? removedPos.MarketSlug : (cm.TryGetProperty("slug", out var sp) ? sp.GetString() ?? "" : ""), MarketQuestion = removedPos != null ? removedPos.MarketQuestion : (cm.TryGetProperty("title", out var tp) ? tp.GetString() ?? "Unknown Market" : "Unknown Market"), Outcome = cm.TryGetProperty("outcome", out var op) ? op.GetString() ?? "" : "", Side = "SELL", EntryPrice = entryPrice, ExitPrice = exitPrice, Size = size, RealizedPnl = realizedPnl, PnlPercent = investment > 0 ? (realizedPnl / investment * 100m) : 0m, OpenedAt = removedPos?.OpenedAt ?? resolvedTimestamp, ClosedAt = resolvedTimestamp, ExitReason = exitReason }; _processedClosures.TryAdd(duplicateKey, true); _closedTradeWriter.TryWrite(ctRecord); _logger.Info($"🏆 Trade {ctRecord.MarketQuestion} synchronisiert (Hintergrund)! PnL: ${realizedPnl:F2}"); } } } } 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 == null) continue; // Skip on API error var currentTokens = new HashSet(); foreach (var posJson in posList) { string asset = posJson.TryGetProperty("asset", out var ap) ? ap.GetString() ?? "" : ""; if (string.IsNullOrEmpty(asset)) continue; decimal size = 0m, entryPrice = 0m, amountUsd = 0m, curPrice = 0m, curValue = 0m; if (posJson.TryGetProperty("size", out var sprop)) size = ParseDecimal(sprop); if (size < 0.001m) continue; // Exclude closed positions from API so Live Sync can process them as closed 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"; 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; try { _positionRepo.UpsertLive(acc.AccountId, existing); } catch { } // Auto-Redeem Fallback via REST if (acc.PreRedeemLimit > 0 && curPrice >= acc.PreRedeemLimit && acc.IsActive) { string redeemKey = $"{acc.AccountId}_{asset}"; bool allowAttempt = true; if (_restRedeemAttempts.TryGetValue(redeemKey, out var state)) { if (state.Count >= 2) allowAttempt = false; if ((DateTime.UtcNow - state.LastAttempt).TotalMinutes < 5) allowAttempt = false; } if (allowAttempt) { if (!acc.IsDemo && _state.LiveTradingMode == TradingMode.Active) { _logger.Trade($"🚨 [REST AUTO REDEEM] {acc.Name} | {existing.MarketQuestion} | Preis >= {acc.PreRedeemLimit}"); _ = Task.Run(async () => await ExecuteRestAutoRedeemLive(acc, existing)); } } } } else { // --- Smart Master-Trader Zuordnung --- // Versuch den Quell-Trader zu identifizieren statt pauschal "Live Sync" zu vergeben int resolvedTraderId = 0; string resolvedTraderName = "Live Sync"; string resolvedTraderAddress = ""; // 1. Prüfe PendingOrderTimestamps (CopyTradingEngine hat diese Order kürzlich platziert) string orderKey = $"{acc.AccountId}_{asset}"; if (_state.PendingOrderTimestamps.TryGetValue(orderKey, out var pending) && pending.SourceTraderId > 0) { resolvedTraderId = pending.SourceTraderId; if (_state.Traders.TryGetValue(resolvedTraderId, out var pendingTrader)) { resolvedTraderName = pendingTrader.DisplayName; resolvedTraderAddress = pendingTrader.WalletAddress; } } // 2. Prüfe ob ein anderer Account dieselbe TokenId bereits mit echtem SourceTraderId hat if (resolvedTraderId == 0) { foreach (var otherAcc in _state.Accounts.Values) { if (otherAcc.AccountId == acc.AccountId) continue; if (otherAcc.OpenPositions.TryGetValue(asset, out var otherPos) && otherPos.SourceTraderId > 0) { resolvedTraderId = otherPos.SourceTraderId; resolvedTraderName = otherPos.SourceTraderName; resolvedTraderAddress = otherPos.SourceTraderAddress; break; } } } // 3. Prüfe ob ein anderer Account denselben Markt (Slug+Outcome) bereits zugeordnet hat if (resolvedTraderId == 0 && !string.IsNullOrEmpty(slug)) { string resolvedOutcome = opp == "Yes" ? "No" : "Yes"; foreach (var otherAcc in _state.Accounts.Values) { if (otherAcc.AccountId == acc.AccountId) continue; var match = otherAcc.OpenPositions.Values.FirstOrDefault(p => p.MarketSlug == slug && p.Outcome == resolvedOutcome && p.SourceTraderId > 0); if (match != null) { resolvedTraderId = match.SourceTraderId; resolvedTraderName = match.SourceTraderName; resolvedTraderAddress = match.SourceTraderAddress; break; } } } DateTime resolvedOpenedAt = DateTime.UtcNow; // 4. Prüfe lokale DB-Tabelle für den Fall eines Programm-Neustarts / API-Syncs if (resolvedTraderId == 0 && _db != null) { try { var dbPos = _positionRepo.FindLive(acc.AccountId, asset); if (dbPos != null) { if (dbPos.SourceTraderId > 0) { resolvedTraderId = dbPos.SourceTraderId; resolvedTraderName = dbPos.SourceTraderName; resolvedTraderAddress = dbPos.SourceTraderAddress; } if (dbPos.OpenedAt > DateTime.MinValue) { resolvedOpenedAt = dbPos.OpenedAt; } } } catch { } } var newPos = new Position { TokenId = asset, MarketSlug = slug, MarketQuestion = title, Outcome = opp == "Yes" ? "No" : "Yes", SourceTraderId = resolvedTraderId, SourceTraderName = resolvedTraderName, SourceTraderAddress = resolvedTraderAddress, Side = "BUY", Size = size, EntryPrice = entryPrice, AmountUsd = amountUsd, CurrentPrice = curPrice, CurrentValueUsd = curValue, ExpiryDate = expiry, OpenedAt = resolvedOpenedAt }; acc.OpenPositions.TryAdd(asset, newPos); try { _positionRepo.UpsertLive(acc.AccountId, newPos); } catch { } if (resolvedTraderId > 0) _logger.Info($"🌐 Live Position erkannt: {title} ({newPos.Outcome}) - ${amountUsd} - Account: {acc.Name} [Zugeordnet: {resolvedTraderName}]"); else _logger.Info($"🌐 Live Position erkannt: {title} ({newPos.Outcome}) - ${amountUsd} - Account: {acc.Name} [Kein Master-Trader zugeordnet]"); } } var tokensToInvestigate = acc.OpenPositions .Where(kvp => !currentTokens.Contains(kvp.Key)) .Where(kvp => (DateTime.UtcNow - kvp.Value.OpenedAt).TotalMinutes > 5) .Select(kvp => kvp.Key) .ToList(); if (tokensToInvestigate.Count > 0) { var closedPositions = await _api.SyncClosedPositionsAsync(acc.WalletAddress, 50); foreach (var k in tokensToInvestigate) { if (acc.OpenPositions.TryGetValue(k, out var removedPos)) { // 2. Fallback: Auch im Live Sync: Wenn SourceTraderId verloren ging, // aus der MongoDB open_positions-Tabelle wiederherstellen. if (removedPos.SourceTraderId <= 0 && _db != null) { try { var dbPos = _positionRepo.FindLive(acc.AccountId, k); if (dbPos != null && dbPos.SourceTraderId > 0) { removedPos.SourceTraderId = dbPos.SourceTraderId; } } catch { } } JsonElement? matchedClose = null; foreach (var cm in closedPositions) { if (cm.TryGetProperty("asset", out var ap) && ap.GetString() == k) { matchedClose = cm; break; } } if (matchedClose.HasValue) { acc.OpenPositions.TryRemove(k, out _); // Safe removal! try { _positionRepo.DeleteLive(acc.AccountId, k); } catch { } decimal realizedPnl = 0m; DateTime resolvedClosedAt = DateTime.UtcNow; if (matchedClose.Value.TryGetProperty("timestamp", out var tsProp) || matchedClose.Value.TryGetProperty("updatedAt", out tsProp) || matchedClose.Value.TryGetProperty("closedAt", out tsProp) || matchedClose.Value.TryGetProperty("createdAt", out tsProp)) { try { if (tsProp.ValueKind == JsonValueKind.Number) { long ts = tsProp.GetInt64(); if (ts > 9999999999) ts /= 1000; resolvedClosedAt = DateTimeOffset.FromUnixTimeSeconds(ts).UtcDateTime; } else if (tsProp.ValueKind == JsonValueKind.String && DateTime.TryParse(tsProp.GetString(), out var parsedDate)) { resolvedClosedAt = parsedDate.ToUniversalTime(); } } catch { } } 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}"); string orderKey = $"{acc.AccountId}_{removedPos.TokenId}"; bool soldByUs = _state.PendingOrderTimestamps.ContainsKey(orderKey); string exitReason = soldByUs ? "Master Trader Sold" : "Market Resolved"; var ctRecord = new ClosedTrade { TradeId = _state.GetNextTradeId(), 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 = resolvedClosedAt, ExitReason = exitReason }; if (soldByUs) _state.PendingOrderTimestamps.TryRemove(orderKey, out _); _processedClosures.TryAdd(duplicateKey, true); _closedTradeWriter.TryWrite(ctRecord); } } else { var (isClosed, isWinner) = await _api.CheckMarketResolutionAsync(removedPos.MarketSlug, removedPos.TokenId); if (isClosed) { acc.OpenPositions.TryRemove(k, out _); // Safe removal! try { _positionRepo.DeleteLive(acc.AccountId, k); } catch { } 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}"); string orderKey = $"{acc.AccountId}_{removedPos.TokenId}"; bool soldByUs = _state.PendingOrderTimestamps.ContainsKey(orderKey); string exitReason = soldByUs ? "Master Trader Sold" : "Market Resolved"; var ctRecord = new ClosedTrade { TradeId = _state.GetNextTradeId(), 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 = exitReason }; if (soldByUs) _state.PendingOrderTimestamps.TryRemove(orderKey, out _); _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 { var ageMinutes = (DateTime.UtcNow - removedPos.OpenedAt).TotalMinutes; if (ageMinutes >= 60) { if (acc.OpenPositions.TryRemove(k, out _)) { try { _positionRepo.DeleteLive(acc.AccountId, k); } catch { } _logger.Info($"🌐 Live Position {removedPos.MarketQuestion} final entfernt (Ext. Verkauft/Wartend nach {ageMinutes:F0} Min.)"); } } } } } } } await Task.Delay(500, ct); } } /// /// Periodically syncs how many shares each master trader holds for tokens we've copied. /// This data is used by CopyTradingEngine to determine if a SELL signal is a partial sell (ignore) or a full exit (copy). /// private async Task SyncMasterTraderPositionsAsync(CancellationToken ct) { try { // Step 1: Collect all (TraderId -> Set) from our open positions across all accounts var traderTokenMap = new Dictionary>(); foreach (var acc in _state.Accounts.Values.Where(a => a.IsActive)) { foreach (var pos in acc.OpenPositions.Values) { if (pos.SourceTraderId <= 0 || string.IsNullOrEmpty(pos.TokenId)) continue; if (!traderTokenMap.TryGetValue(pos.SourceTraderId, out var tokens)) { tokens = new HashSet(); traderTokenMap[pos.SourceTraderId] = tokens; } tokens.Add(pos.TokenId); } } if (traderTokenMap.Count == 0) return; // Step 2: For each trader, fetch their current positions and update the cache foreach (var (traderId, tokenIds) in traderTokenMap) { if (ct.IsCancellationRequested) break; if (!_state.Traders.TryGetValue(traderId, out var trader) || string.IsNullOrEmpty(trader.WalletAddress)) continue; var positionSizes = await _api.GetTraderPositionSizesAsync(trader.WalletAddress, tokenIds); // Update cache for all tokens this trader is supposed to hold foreach (var tokenId in tokenIds) { string key = $"{traderId}_{tokenId}"; decimal shares = positionSizes.ContainsKey(tokenId) ? positionSizes[tokenId] : 0m; _state.MasterTraderPositions[key] = (shares, DateTime.UtcNow); } await Task.Delay(200, ct); // Brief delay between traders to avoid rate limits } } catch (OperationCanceledException) { } catch (Exception ex) { _logger.Error($"SyncMasterTraderPositions Error: {ex.Message}"); } } 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 ProcessActivityItemsMerged(List activity, TrackedTrader trader) { try { // Group fragmented trades by txHash + asset + side so dust-fills do not block major fills var validItems = activity.Where(act => { string type = act.GetProperty("type").GetString()?.ToUpper() ?? ""; return type == "TRADE" || type == "BUY" || type == "SELL"; }).ToList(); var grouped = validItems.GroupBy(act => { string tx = act.GetProperty("transactionHash").GetString() ?? ""; string sideStr = act.GetProperty("type").GetString() ?? ""; if (act.TryGetProperty("side", out var sProp) && sProp.ValueKind == JsonValueKind.String) sideStr = sProp.GetString() ?? sideStr; else if (act.TryGetProperty("action", out var acProp) && acProp.ValueKind == JsonValueKind.String) sideStr = acProp.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 ap) && ap.ValueKind == JsonValueKind.String) asset = ap.GetString() ?? ""; if (string.IsNullOrEmpty(asset) && act.TryGetProperty("tokenId", out var tidProp) && tidProp.ValueKind == JsonValueKind.String) asset = tidProp.GetString() ?? ""; string parsedSide = sideStr.ToUpper().Contains("SELL") ? "SELL" : "BUY"; return $"{tx}_{asset}_{parsedSide}"; }).ToList(); foreach (var group in grouped) { string uniqueTradeKey = group.Key; if (string.IsNullOrEmpty(uniqueTradeKey) || uniqueTradeKey.StartsWith("_")) continue; var parts = uniqueTradeKey.Split('_'); if (parts.Length < 3) continue; string txHash = parts[0]; string asset = parts[1]; string parsedSide = parts.Last(); // "BUY" or "SELL" var elements = group.ToList(); var firstAct = elements.First(); // we take metadata like timestamps/titles from the first item // Accumulate size and define weighted price decimal totalSize = 0m; decimal weightedPriceSum = 0m; foreach (var act in elements) { 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); } totalSize += size; weightedPriceSum += (price * size); } if (totalSize <= 0m) continue; decimal avgPrice = weightedPriceSum / totalSize; // Parse timestamp to prevent old trades DateTime tradeTs = DateTime.UtcNow; if (firstAct.TryGetProperty("timestamp", out var tsProp)) { if (tsProp.ValueKind == JsonValueKind.Number) // Unix { long rawTs = tsProp.GetInt64(); if (rawTs > 1000000000000) tradeTs = DateTimeOffset.FromUnixTimeMilliseconds(rawTs).UtcDateTime; else tradeTs = DateTimeOffset.FromUnixTimeSeconds(rawTs).UtcDateTime; } else if (tsProp.ValueKind == JsonValueKind.String && DateTime.TryParse(tsProp.GetString(), null, System.Globalization.DateTimeStyles.AssumeUniversal | System.Globalization.DateTimeStyles.AdjustToUniversal, out var dt)) tradeTs = dt; } // If trade is older than 120 seconds or has an impossible future date (clock drift / timezone bug), skip double ageSeconds = (DateTime.UtcNow - tradeTs).TotalSeconds; if (ageSeconds > 120 || ageSeconds < -120) { if (ageSeconds < 86400) { if (_state.DebugPollingLog) _logger.Debug($"Activity skipped due to age ({ageSeconds}s / Date: {tradeTs:O}): {txHash}"); } continue; } // CRITICAL: Check if the bare txHash was already processed by the Fast-Track parser. // Fast-Track stores just "txHash", but here we use "txHash_asset_side". // Without this check, a SELL processed by Fast-Track would be re-ingested as a BUY // by the API (since the API sees both sides of the orderbook) — causing phantom purchases! if (_processedTxHashes.ContainsKey(txHash)) continue; // Already handled by Fast-Track blockchain parser // Only place the hash lock AFTER all filtering is successful (preventing dust fragments locking out main batches)! if (!_processedTxHashes.TryAdd(uniqueTradeKey, DateTime.UtcNow)) continue; // Duplicate trade or redundant polling request if (avgPrice <= 0.005m || totalSize <= 1.0m) continue; // Prevent absolute dust trades spanning if (avgPrice > 0.99m) continue; string displayQuestion = "Unknown Market"; if (firstAct.TryGetProperty("title", out var titleProp)) displayQuestion = titleProp.GetString() ?? "Unknown Market"; if (string.IsNullOrEmpty(displayQuestion) || displayQuestion == "Unknown Market") { // Fallback title evaluation if (firstAct.TryGetProperty("marketQuestion", out var mqProp)) displayQuestion = mqProp.GetString() ?? "Unknown Market"; } var signal = new CopySignal { TraderId = trader.Id, TokenId = asset, ConditionId = "", MarketSlug = firstAct.TryGetProperty("slug", out var sp) ? sp.GetString() ?? "" : (firstAct.TryGetProperty("marketSlug", out var msp) ? msp.GetString() ?? "" : ""), Side = parsedSide, Price = avgPrice, Size = totalSize, Timestamp = tradeTs, MarketQuestion = displayQuestion, Outcome = firstAct.TryGetProperty("outcome", out var outProp) ? outProp.GetString() ?? "" : "", Reason = parsedSide.ToUpper().Contains("SELL") ? "Master Trader Sold" : "" }; // Parse endDate from activity JSON for market expiry if (firstAct.TryGetProperty("endDate", out var endDateProp)) { if (endDateProp.ValueKind == JsonValueKind.String && DateTime.TryParse(endDateProp.GetString(), null, System.Globalization.DateTimeStyles.AssumeUniversal | System.Globalization.DateTimeStyles.AdjustToUniversal, out var endDt)) signal.EndDate = endDt; else if (endDateProp.ValueKind == JsonValueKind.Number) signal.EndDate = DateTimeOffset.FromUnixTimeSeconds(endDateProp.GetInt64()).UtcDateTime; } else if (firstAct.TryGetProperty("end_date_iso", out var endIso) && endIso.ValueKind == JsonValueKind.String) { if (DateTime.TryParse(endIso.GetString(), null, System.Globalization.DateTimeStyles.AssumeUniversal | System.Globalization.DateTimeStyles.AdjustToUniversal, out var endDt2)) signal.EndDate = endDt2; } string shareType = string.IsNullOrEmpty(signal.Outcome) ? signal.Side : signal.Outcome; bool wAdded = _signalWriter.TryWrite(signal); if (wAdded) { _logger.Trade($"🚨 [QUELLE: {trader.DisplayName}] API\n" + $" Markt: {signal.MarketQuestion}\n" + $" Aktion: {signal.Side} {shareType} ({signal.Size:F2} Shares @ ${signal.Price:F3})\n" + $" Zeit: {signal.Timestamp:HH:mm:ss} UTC"); } } } catch (Exception ex) { _logger.Warning($"Fehler beim Parsen einer Activity JSON (Merged): {ex.Message}"); } } } }