Slice 3 (Fable-Fixes): K3 Demo-Resolution + M5 Demo-Score/Auto-Pause

K3 – Demo-Auto-Close bei Marktauflösung funktioniert wieder:
- System-SELLs (TraderId==0) sind vom Ownership-Check ausgenommen. Regel in reine
  CopyTradingRisk.IsAuthorizedSell(signalTraderId, positionSourceTraderId) extrahiert
  (System ODER Besitzer) und in der Engine genutzt. Vorher lehnte der Check jedes
  System-Close ab (Copy-Positionen haben SourceTraderId>0) -> Demo-Positionen
  schlossen bei Resolution nie, 30s-Loop feuerte endlos.
- Resolved-Cache (_resolvedDemoMarkets) in PollDemoExpirationsAsync: aufgeloeste
  Maerkte werden nicht erneut angefragt (kein Log-Spam / keine unnoetige API-Last).

M5 – Auto-Pause in der Demo-Phase nicht mehr wirkungslos:
- Anzeige-Spalten (CopyPnl30d etc.) zeigen Live-Kennzahlen, sobald vorhanden, sonst
  Demo-Kennzahlen -> Demo-Validierung ist sichtbar statt leer.
- Harte Auto-Pause wirkt bewusst NUR auf Live-Ergebnissen.
- Kadenz: stuendlicher Light-Check (Auto-Pause) statt nur alle 12h; schwere
  History-Analyse bleibt 12h (schliessen sich pro Tick aus, kein Doppellauf).

Tests: 5 neue (IsAuthorizedSell). Build 0 Fehler, 223 Tests gruen, --smoke-ui ok.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-07-09 13:33:24 +02:00
co-authored by Claude Opus 4.8
parent a3d3d50681
commit 8252219163
5 changed files with 81 additions and 23 deletions
@@ -32,6 +32,16 @@ namespace PolyTrader.Modules.CopyTrading.Logic
public static bool IsBelowPolymarketMinimum(decimal shares, decimal usdc) =>
shares < MinShares || usdc < MinUsdc;
/// <summary>
/// Darf ein SELL diese Position schließen? Erlaubt, wenn das Signal vom besitzenden Master
/// stammt (<paramref name="positionSourceTraderId"/> == <paramref name="signalTraderId"/>)
/// ODER es ein System-Signal ist (<paramref name="signalTraderId"/> == 0, z.B. Demo-Auto-Close
/// bei Marktauflösung). K3: ohne die System-Ausnahme würden Copy-Positionen (SourceTraderId &gt; 0)
/// System-Closes IMMER ablehnen → Demo-Positionen schließen bei Resolution nie.
/// </summary>
public static bool IsAuthorizedSell(int signalTraderId, int positionSourceTraderId) =>
signalTraderId == 0 || positionSourceTraderId == signalTraderId;
/// <summary>
/// Limit-Preis einer BUY-Order: HF-Trader bekommen einen festen Aufschlag von 0.005,
/// sonst einen prozentualen Aufschlag (<paramref name="maxPriceDifferencePct"/>) über dem
@@ -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);
@@ -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
}
/// <summary>
/// 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.
/// </summary>
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}"); }
}
@@ -31,6 +31,9 @@ namespace PolyTraderSharp.Services
private readonly ConcurrentDictionary<string, DateTime> _processedTxHashes = new();
private DateTime _lastHashCleanup = DateTime.UtcNow;
private readonly ConcurrentDictionary<string, bool> _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<string, bool> _resolvedDemoMarkets = new();
private readonly ConcurrentDictionary<string, DateTime> _lastPolled = new();
private readonly ConcurrentDictionary<string, bool> _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}");