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) => public static bool IsBelowPolymarketMinimum(decimal shares, decimal usdc) =>
shares < MinShares || usdc < MinUsdc; 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> /// <summary>
/// Limit-Preis einer BUY-Order: HF-Trader bekommen einen festen Aufschlag von 0.005, /// Limit-Preis einer BUY-Order: HF-Trader bekommen einen festen Aufschlag von 0.005,
/// sonst einen prozentualen Aufschlag (<paramref name="maxPriceDifferencePct"/>) über dem /// sonst einen prozentualen Aufschlag (<paramref name="maxPriceDifferencePct"/>) über dem
@@ -614,8 +614,12 @@ namespace PolyTraderSharp.Services
bool removed = account.OpenPositions.TryRemove(signal.TokenId, out var openPos); bool removed = account.OpenPositions.TryRemove(signal.TokenId, out var openPos);
// Defense-in-depth: Verify the removed position actually belongs to this trader // Defense-in-depth: Verify the removed position actually belongs to this trader.
if (removed && openPos != null && openPos.SourceTraderId != signal.TraderId) // 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. // Wrong trader! Put the position back and treat as not found.
account.OpenPositions.TryAdd(signal.TokenId, openPos); account.OpenPositions.TryAdd(signal.TokenId, openPos);
@@ -56,14 +56,28 @@ namespace PolyTraderSharp.Services
{ {
await Task.Delay(TimeSpan.FromSeconds(20), stoppingToken); // Start after other jobs 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) while (!stoppingToken.IsCancellationRequested)
{ {
if (_jobStatus.IsEnabled) if (_jobStatus.IsEnabled)
{ {
try try
{ {
_jobStatus.StatusText = "Running (Scheduled)..."; if ((DateTime.UtcNow - lastHeavyRun) >= TimeSpan.FromHours(12))
await RunHistoryAnalyticsAsync(); {
_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; _jobStatus.LastRun = DateTime.Now;
} }
catch (Exception ex) catch (Exception ex)
@@ -81,9 +95,9 @@ namespace PolyTraderSharp.Services
_jobStatus.StatusText = "Paused"; _jobStatus.StatusText = "Paused";
} }
// Run twice a day (every 12 hours) // Light-Check-Kadenz: stündlich (Auto-Pause reagiert schneller).
_jobStatus.NextRun = DateTime.Now.AddHours(12); _jobStatus.NextRun = DateTime.Now.AddHours(1);
await Task.Delay(TimeSpan.FromHours(12), stoppingToken); await Task.Delay(TimeSpan.FromHours(1), stoppingToken);
} }
} }
@@ -204,9 +218,12 @@ namespace PolyTraderSharp.Services
} }
/// <summary> /// <summary>
/// Phase 3.1/3.3: Berechnet je Master den Copy-Score (letzte 30 Tage aus unseren /// Phase 3.1/3.3 + M5: 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 /// geschlossenen Copy-Trades). Die Anzeige-Spalten zeigen Live-Kennzahlen, sobald es
/// laufen (nur wenn deren AutoPauseEnabled gesetzt ist). Reaktivierung bewusst nur manuell. /// 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> /// </summary>
private async Task UpdateCopyScoresAndAutoPauseAsync() private async Task UpdateCopyScoresAndAutoPauseAsync()
{ {
@@ -216,26 +233,32 @@ namespace PolyTraderSharp.Services
{ {
try try
{ {
var pnls = _tradeLog var closed = _tradeLog
.Find(t => t.SourceTraderId == trader.Id && t.ClosedAt >= since && !t.IsDemo) .Find(t => t.SourceTraderId == trader.Id && t.ClosedAt >= since)
.Select(t => t.RealizedPnl); .ToList();
var m = TraderScore.Compute(pnls); var liveMetrics = TraderScore.Compute(closed.Where(t => !t.IsDemo).Select(t => t.RealizedPnl));
trader.CopyPnl30d = m.CopyPnl; // Anzeige: Live sobald vorhanden, sonst Demo (Validierungssignal statt leerer Spalten).
trader.CopyProfitFactor = m.ProfitFactor; var display = liveMetrics.TradeCount > 0
trader.CopyAvgPnlPerTrade = m.AvgPnlPerTrade; ? liveMetrics
trader.CopyTradeCount30d = m.TradeCount; : 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( bool pause = trader.IsActive && TraderScore.ShouldAutoPause(
trader.AutoPauseEnabled, m.TradeCount, m.CopyPnl, trader.AutoPauseEnabled, liveMetrics.TradeCount, liveMetrics.CopyPnl,
_copyState.AutoPauseMinTrades, _copyState.AutoPauseDrawdownUsd); _copyState.AutoPauseMinTrades, _copyState.AutoPauseDrawdownUsd);
if (pause) if (pause)
{ {
trader.IsActive = false; 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."; 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. Copy-PnL {m.CopyPnl:F2} / {m.TradeCount} Trades. Reaktivierung nur 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.\nCopy-PnL 30T: {m.CopyPnl:F2} USDC über {m.TradeCount} Trades.\nReaktivierung 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}"); } 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 readonly ConcurrentDictionary<string, DateTime> _processedTxHashes = new();
private DateTime _lastHashCleanup = DateTime.UtcNow; private DateTime _lastHashCleanup = DateTime.UtcNow;
private readonly ConcurrentDictionary<string, bool> _processedClosures = new(); 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, DateTime> _lastPolled = new();
private readonly ConcurrentDictionary<string, bool> _activeWssPolls = new(); private readonly ConcurrentDictionary<string, bool> _activeWssPolls = new();
private DateTime _lastLivePoll = DateTime.MinValue; private DateTime _lastLivePoll = DateTime.MinValue;
@@ -498,9 +501,14 @@ namespace PolyTraderSharp.Services
foreach (var pos in checkPositions) 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); var (isClosed, isWinner) = await _api.CheckMarketResolutionAsync(pos.MarketSlug, pos.TokenId);
if (isClosed) if (isClosed)
{ {
_resolvedDemoMarkets[pos.TokenId] = true;
decimal exitPrice = isWinner ? 1.0m : 0.0m; decimal exitPrice = isWinner ? 1.0m : 0.0m;
_logger.Info($"🏆 Demo Market {pos.MarketQuestion} aufgelöst! Auszahlung: ${(exitPrice * pos.Size):F2}"); _logger.Info($"🏆 Demo Market {pos.MarketQuestion} aufgelöst! Auszahlung: ${(exitPrice * pos.Size):F2}");
@@ -203,5 +203,18 @@ namespace PolyTrader.Tests
{ {
Assert.Equal(expected, IsBelowPolymarketMinimum((decimal)shares, (decimal)usdc)); 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));
}
} }
} }