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));
+ }
}
}