diff --git a/src/PolyTrader.Modules.CopyTrading/Logic/CopyTradingRisk.cs b/src/PolyTrader.Modules.CopyTrading/Logic/CopyTradingRisk.cs index cade186..b168ba4 100644 --- a/src/PolyTrader.Modules.CopyTrading/Logic/CopyTradingRisk.cs +++ b/src/PolyTrader.Modules.CopyTrading/Logic/CopyTradingRisk.cs @@ -32,6 +32,16 @@ namespace PolyTrader.Modules.CopyTrading.Logic public static bool IsBelowPolymarketMinimum(decimal shares, decimal usdc) => shares < MinShares || usdc < MinUsdc; + /// + /// Darf ein SELL diese Position schließen? Erlaubt, wenn das Signal vom besitzenden Master + /// stammt ( == ) + /// ODER es ein System-Signal ist ( == 0, z.B. Demo-Auto-Close + /// bei Marktauflösung). K3: ohne die System-Ausnahme würden Copy-Positionen (SourceTraderId > 0) + /// System-Closes IMMER ablehnen → Demo-Positionen schließen bei Resolution nie. + /// + public static bool IsAuthorizedSell(int signalTraderId, int positionSourceTraderId) => + signalTraderId == 0 || positionSourceTraderId == signalTraderId; + /// /// Limit-Preis einer BUY-Order: HF-Trader bekommen einen festen Aufschlag von 0.005, /// sonst einen prozentualen Aufschlag () über dem diff --git a/src/PolyTrader.Modules.CopyTrading/Services/CopyTradingEngine.cs b/src/PolyTrader.Modules.CopyTrading/Services/CopyTradingEngine.cs index 6cdeb5b..ff969d2 100644 --- a/src/PolyTrader.Modules.CopyTrading/Services/CopyTradingEngine.cs +++ b/src/PolyTrader.Modules.CopyTrading/Services/CopyTradingEngine.cs @@ -613,9 +613,13 @@ namespace PolyTraderSharp.Services } 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) + + // Defense-in-depth: Verify the removed position actually belongs to this trader. + // K3: System-Signale (TraderId == 0, z.B. Demo-Auto-Close bei Marktauflösung) sind vom + // Ownership-Check ausgenommen (CopyTradingRisk.IsAuthorizedSell) – sie gehören keinem + // Master und würden sonst IMMER abgelehnt (Copy-Positionen haben SourceTraderId > 0), + // sodass Demo-Positionen bei Resolution nie schließen und der 30s-Loop endlos feuert. + if (removed && openPos != null && !CopyTradingRisk.IsAuthorizedSell(signal.TraderId, openPos.SourceTraderId)) { // Wrong trader! Put the position back and treat as not found. account.OpenPositions.TryAdd(signal.TokenId, openPos); diff --git a/src/PolyTrader.Modules.CopyTrading/Services/MasterTraderAnalyticsJob.cs b/src/PolyTrader.Modules.CopyTrading/Services/MasterTraderAnalyticsJob.cs index 6da1bfd..28e7de4 100644 --- a/src/PolyTrader.Modules.CopyTrading/Services/MasterTraderAnalyticsJob.cs +++ b/src/PolyTrader.Modules.CopyTrading/Services/MasterTraderAnalyticsJob.cs @@ -56,14 +56,28 @@ namespace PolyTraderSharp.Services { await Task.Delay(TimeSpan.FromSeconds(20), stoppingToken); // Start after other jobs + // M5: Auto-Pause reagiert stündlich (Light-Check aus UNSEREN Trades), die schwere + // Master-History-Analyse (flakige Data-API) läuft weiter nur alle 12h. Beide schließen + // sich pro Tick aus – RunHistoryAnalyticsAsync ruft den Copy-Score selbst auf, also + // kein Doppellauf. + DateTime lastHeavyRun = DateTime.MinValue; while (!stoppingToken.IsCancellationRequested) { if (_jobStatus.IsEnabled) { try { - _jobStatus.StatusText = "Running (Scheduled)..."; - await RunHistoryAnalyticsAsync(); + if ((DateTime.UtcNow - lastHeavyRun) >= TimeSpan.FromHours(12)) + { + _jobStatus.StatusText = "Running (History + Auto-Pause)..."; + await RunHistoryAnalyticsAsync(); // enthält Copy-Score + Auto-Pause + lastHeavyRun = DateTime.UtcNow; + } + else + { + _jobStatus.StatusText = "Running (Auto-Pause-Check)..."; + await UpdateCopyScoresAndAutoPauseAsync(); // stündlicher Light-Check + } _jobStatus.LastRun = DateTime.Now; } catch (Exception ex) @@ -81,9 +95,9 @@ namespace PolyTraderSharp.Services _jobStatus.StatusText = "Paused"; } - // Run twice a day (every 12 hours) - _jobStatus.NextRun = DateTime.Now.AddHours(12); - await Task.Delay(TimeSpan.FromHours(12), stoppingToken); + // Light-Check-Kadenz: stündlich (Auto-Pause reagiert schneller). + _jobStatus.NextRun = DateTime.Now.AddHours(1); + await Task.Delay(TimeSpan.FromHours(1), stoppingToken); } } @@ -204,9 +218,12 @@ namespace PolyTraderSharp.Services } /// - /// Phase 3.1/3.3: Berechnet je Master den Copy-Score (letzte 30 Tage aus unseren - /// geschlossenen Copy-Trades) und pausiert Master hart, die über genügend Trades ins Minus - /// laufen (nur wenn deren AutoPauseEnabled gesetzt ist). Reaktivierung bewusst nur manuell. + /// Phase 3.1/3.3 + M5: Berechnet je Master den Copy-Score (letzte 30 Tage aus unseren + /// geschlossenen Copy-Trades). Die Anzeige-Spalten zeigen Live-Kennzahlen, sobald es + /// Live-Trades gibt, sonst Demo-Kennzahlen – damit die Demo-Validierungsphase nicht blind ist. + /// Die HARTE Auto-Pause wirkt bewusst NUR auf Live-Ergebnissen (Demo hat andere Fill-Annahmen + /// und ist Validierung, kein Kill-Kriterium; in der Demo-Phase pausiert der User manuell). + /// Nur Master mit gesetztem AutoPauseEnabled; Reaktivierung ausschließlich manuell. /// private async Task UpdateCopyScoresAndAutoPauseAsync() { @@ -216,26 +233,32 @@ namespace PolyTraderSharp.Services { try { - var pnls = _tradeLog - .Find(t => t.SourceTraderId == trader.Id && t.ClosedAt >= since && !t.IsDemo) - .Select(t => t.RealizedPnl); - var m = TraderScore.Compute(pnls); + var closed = _tradeLog + .Find(t => t.SourceTraderId == trader.Id && t.ClosedAt >= since) + .ToList(); + var liveMetrics = TraderScore.Compute(closed.Where(t => !t.IsDemo).Select(t => t.RealizedPnl)); - trader.CopyPnl30d = m.CopyPnl; - trader.CopyProfitFactor = m.ProfitFactor; - trader.CopyAvgPnlPerTrade = m.AvgPnlPerTrade; - trader.CopyTradeCount30d = m.TradeCount; + // Anzeige: Live sobald vorhanden, sonst Demo (Validierungssignal statt leerer Spalten). + var display = liveMetrics.TradeCount > 0 + ? liveMetrics + : TraderScore.Compute(closed.Where(t => t.IsDemo).Select(t => t.RealizedPnl)); + trader.CopyPnl30d = display.CopyPnl; + trader.CopyProfitFactor = display.ProfitFactor; + trader.CopyAvgPnlPerTrade = display.AvgPnlPerTrade; + trader.CopyTradeCount30d = display.TradeCount; + + // Auto-Pause NUR auf Live-Kennzahlen. bool pause = trader.IsActive && TraderScore.ShouldAutoPause( - trader.AutoPauseEnabled, m.TradeCount, m.CopyPnl, + trader.AutoPauseEnabled, liveMetrics.TradeCount, liveMetrics.CopyPnl, _copyState.AutoPauseMinTrades, _copyState.AutoPauseDrawdownUsd); if (pause) { trader.IsActive = false; - trader.Reasoning = $"[Auto-Pause {DateTime.UtcNow:yyyy-MM-dd HH:mm} UTC] Copy-PnL {m.CopyPnl:F2} USDC über {m.TradeCount} Trades (30T) unter Schwelle (-{_copyState.AutoPauseDrawdownUsd:F0}). Reaktivierung manuell."; - _logger.Warning($"🛑 [AUTO-PAUSE] Master '{trader.DisplayName}' deaktiviert. Copy-PnL {m.CopyPnl:F2} / {m.TradeCount} Trades. Reaktivierung nur manuell."); - try { await _threema.SendMessageAsync($"🛑 Auto-Pause: Master '{trader.DisplayName}' deaktiviert.\nCopy-PnL 30T: {m.CopyPnl:F2} USDC über {m.TradeCount} Trades.\nReaktivierung manuell."); } + trader.Reasoning = $"[Auto-Pause {DateTime.UtcNow:yyyy-MM-dd HH:mm} UTC] Copy-PnL {liveMetrics.CopyPnl:F2} USDC über {liveMetrics.TradeCount} Live-Trades (30T) unter Schwelle (-{_copyState.AutoPauseDrawdownUsd:F0}). Reaktivierung manuell."; + _logger.Warning($"🛑 [AUTO-PAUSE] Master '{trader.DisplayName}' deaktiviert. Live-Copy-PnL {liveMetrics.CopyPnl:F2} / {liveMetrics.TradeCount} Trades. Reaktivierung nur manuell."); + try { await _threema.SendMessageAsync($"🛑 Auto-Pause: Master '{trader.DisplayName}' deaktiviert.\nLive-Copy-PnL 30T: {liveMetrics.CopyPnl:F2} USDC über {liveMetrics.TradeCount} Trades.\nReaktivierung manuell."); } catch (Exception ex) { _logger.Error($"Threema Auto-Pause-Benachrichtigung fehlgeschlagen: {ex.Message}"); } } diff --git a/src/PolyTrader.Modules.CopyTrading/Services/TraderMonitorService.cs b/src/PolyTrader.Modules.CopyTrading/Services/TraderMonitorService.cs index 6bcc8b2..f91a616 100644 --- a/src/PolyTrader.Modules.CopyTrading/Services/TraderMonitorService.cs +++ b/src/PolyTrader.Modules.CopyTrading/Services/TraderMonitorService.cs @@ -31,6 +31,9 @@ namespace PolyTraderSharp.Services private readonly ConcurrentDictionary _processedTxHashes = new(); private DateTime _lastHashCleanup = DateTime.UtcNow; private readonly ConcurrentDictionary _processedClosures = new(); + // K3: Bereits als aufgelöst erkannte Demo-Märkte (Key: TokenId). Verhindert, dass der + // 30s-Loop denselben resolved Markt mehrfach anfasst (Log-Spam + unnötige API-Last). + private readonly ConcurrentDictionary _resolvedDemoMarkets = new(); private readonly ConcurrentDictionary _lastPolled = new(); private readonly ConcurrentDictionary _activeWssPolls = new(); private DateTime _lastLivePoll = DateTime.MinValue; @@ -498,9 +501,14 @@ namespace PolyTraderSharp.Services foreach (var pos in checkPositions) { + // K3: Bereits aufgelöste Märkte nicht erneut anfragen (verhindert Endlos-Loop + + // API-Last, falls der Close noch nicht durch den Engine-Sync entfernt wurde). + if (_resolvedDemoMarkets.ContainsKey(pos.TokenId)) continue; + var (isClosed, isWinner) = await _api.CheckMarketResolutionAsync(pos.MarketSlug, pos.TokenId); if (isClosed) { + _resolvedDemoMarkets[pos.TokenId] = true; decimal exitPrice = isWinner ? 1.0m : 0.0m; _logger.Info($"🏆 Demo Market {pos.MarketQuestion} aufgelöst! Auszahlung: ${(exitPrice * pos.Size):F2}"); diff --git a/tests/PolyTrader.Tests/CopyTradingRiskTests.cs b/tests/PolyTrader.Tests/CopyTradingRiskTests.cs index f9e1bcb..355c7da 100644 --- a/tests/PolyTrader.Tests/CopyTradingRiskTests.cs +++ b/tests/PolyTrader.Tests/CopyTradingRiskTests.cs @@ -203,5 +203,18 @@ namespace PolyTrader.Tests { Assert.Equal(expected, IsBelowPolymarketMinimum((decimal)shares, (decimal)usdc)); } + + // ---------------- IsAuthorizedSell (K3) ---------------- + + [Theory] + [InlineData(0, 7, true)] // System-Signal schließt fremde Copy-Position (Demo-Resolution) + [InlineData(0, 0, true)] // System-Signal, keine Herkunft + [InlineData(7, 7, true)] // Master schließt eigene Position + [InlineData(7, 9, false)] // Master 7 darf Position von Master 9 nicht schließen + [InlineData(9, 0, false)] // echter Master darf herrenlose Position nicht kapern + public void IsAuthorizedSell_allows_owner_and_system(int signalTraderId, int positionSourceTraderId, bool expected) + { + Assert.Equal(expected, IsAuthorizedSell(signalTraderId, positionSourceTraderId)); + } } }