Phase 0.4: Konsistenz-Fixes (Spam-Blockade, GlobalPnl, Demo-TokenId)

- SELL-Spam-Blockade greift nur noch bei kürzlichem SELL: PendingOrderTimestamps
  bekommt eine Side ("BUY"/"SELL"); ein vorheriger BUY blockiert einen legitimen
  schnellen SELL des Masters nicht mehr (alle Schreibstellen aktualisiert).
- GlobalPnl: PollClosedAccountsAsync addiert den realisierten PnL jetzt auch
  (wie PollLiveAccountsAsync) -> Anzeige driftet nicht mehr.
- Demo-ClosedTrade erhält TokenId (der _processedClosures-Preload filtert darauf;
  fehlte -> Duplikat-/Dedup-Risiko nach Neustart).

163 Tests gruen. Build/Smoke gruen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-07-07 17:46:26 +02:00
co-authored by Claude Opus 4.8
parent e22a6e3091
commit b378bc3499
4 changed files with 15 additions and 9 deletions
@@ -49,8 +49,10 @@ namespace PolyTraderSharp
public ConcurrentDictionary<int, List<TraderAnalyticsResult>> TraderAnalyticsCache { get; } = new(); public ConcurrentDictionary<int, List<TraderAnalyticsResult>> TraderAnalyticsCache { get; } = new();
// Tracks when live orders were placed for stale order cleanup. // Tracks when live orders were placed for stale order cleanup.
// Key: "AccountId_TokenId", Value: (PlacedAt, SourceTraderId) // Key: "AccountId_TokenId", Value: (PlacedAt, SourceTraderId, Side "BUY"/"SELL").
public ConcurrentDictionary<string, (DateTime PlacedAt, int SourceTraderId)> PendingOrderTimestamps { get; } = new(); // Side ermöglicht, dass die 20s-Blockade im SELL-Pfad nur greift, wenn zuletzt ein
// SELL platziert wurde (ein vorheriger BUY blockiert einen legitimen SELL nicht mehr).
public ConcurrentDictionary<string, (DateTime PlacedAt, int SourceTraderId, string Side)> PendingOrderTimestamps { get; } = new();
// Master Trader Position Tracker (Key: "{TraderId}_{TokenId}", Value: (Shares, LastUpdated)) // Master Trader Position Tracker (Key: "{TraderId}_{TokenId}", Value: (Shares, LastUpdated))
// Entscheidet, ob ein SELL-Signal ein Teilverkauf (ignorieren) oder ein Voll-Exit (kopieren) ist. // Entscheidet, ob ein SELL-Signal ein Teilverkauf (ignorieren) oder ein Voll-Exit (kopieren) ist.
@@ -555,7 +555,7 @@ namespace PolyTraderSharp.Services
// Track order placement time for stale order cleanup // Track order placement time for stale order cleanup
string orderKey = $"{account.AccountId}_{signal.TokenId}"; string orderKey = $"{account.AccountId}_{signal.TokenId}";
_copyState.PendingOrderTimestamps[orderKey] = (DateTime.UtcNow, signal.TraderId); _copyState.PendingOrderTimestamps[orderKey] = (DateTime.UtcNow, signal.TraderId, "BUY");
// Initialize master position tracking with signal size if not yet tracked // Initialize master position tracking with signal size if not yet tracked
// The background sync will update with the real value within 30 seconds // The background sync will update with the real value within 30 seconds
@@ -570,9 +570,11 @@ namespace PolyTraderSharp.Services
string orderKey = $"{account.AccountId}_{signal.TokenId}"; string orderKey = $"{account.AccountId}_{signal.TokenId}";
if (_copyState.PendingOrderTimestamps.TryGetValue(orderKey, out var pendingInfo)) if (_copyState.PendingOrderTimestamps.TryGetValue(orderKey, out var pendingInfo))
{ {
if ((DateTime.UtcNow - pendingInfo.PlacedAt).TotalSeconds < 20) // Nur ein KÜRZLICHER SELL blockiert (Doppel-SELL/Retry-Spam); ein vorheriger
// BUY blockiert einen legitimen schnellen SELL des Masters NICHT mehr.
if (pendingInfo.Side == "SELL" && (DateTime.UtcNow - pendingInfo.PlacedAt).TotalSeconds < 20)
{ {
return; // Spam-Blockade: Die Order wurde in den letzten 20 Sekunden bereits versendet return; // Spam-Blockade: SELL wurde in den letzten 20 Sekunden bereits versendet
} }
} }
@@ -633,6 +635,7 @@ namespace PolyTraderSharp.Services
AccountId = account.AccountId, AccountId = account.AccountId,
SourceTraderId = signal.TraderId, SourceTraderId = signal.TraderId,
IsDemo = account.IsDemo, IsDemo = account.IsDemo,
TokenId = signal.TokenId,
MarketSlug = signal.MarketSlug, MarketSlug = signal.MarketSlug,
MarketQuestion = signal.MarketQuestion, MarketQuestion = signal.MarketQuestion,
Outcome = signal.Outcome, Outcome = signal.Outcome,
@@ -705,14 +708,14 @@ namespace PolyTraderSharp.Services
Attempt = 1, Attempt = 1,
LastActionAt = DateTime.UtcNow LastActionAt = DateTime.UtcNow
}; };
_copyState.PendingOrderTimestamps[orderKey] = (DateTime.UtcNow, signal.TraderId); _copyState.PendingOrderTimestamps[orderKey] = (DateTime.UtcNow, signal.TraderId, "SELL");
_logger.Trade($"✅ [LIVE SELL-LEITER platziert] {account.Name} | GTC-Limit {firstLimit:F3} für {openPos.Size:F2} Shares."); _logger.Trade($"✅ [LIVE SELL-LEITER platziert] {account.Name} | GTC-Limit {firstLimit:F3} für {openPos.Size:F2} Shares.");
} }
else else
{ {
// Startorder fehlgeschlagen: Position bleibt (ExitPending zurücksetzen), Cooldown. // Startorder fehlgeschlagen: Position bleibt (ExitPending zurücksetzen), Cooldown.
openPos.ExitPending = false; openPos.ExitPending = false;
_copyState.PendingOrderTimestamps[orderKey] = (DateTime.UtcNow.AddSeconds(-15), signal.TraderId); _copyState.PendingOrderTimestamps[orderKey] = (DateTime.UtcNow.AddSeconds(-15), signal.TraderId, "SELL");
_logger.TradeReasoning($"❌ [LIVE SELL-LEITER] Startorder fehlgeschlagen: {result}. Position bleibt im Portfolio; neuer Versuch beim nächsten Signal/Sync."); _logger.TradeReasoning($"❌ [LIVE SELL-LEITER] Startorder fehlgeschlagen: {result}. Position bleibt im Portfolio; neuer Versuch beim nächsten Signal/Sync.");
} }
} }
@@ -122,7 +122,7 @@ namespace PolyTraderSharp.Services
ladder.CurrentLimit = next; ladder.CurrentLimit = next;
ladder.Attempt++; ladder.Attempt++;
ladder.LastActionAt = DateTime.UtcNow; ladder.LastActionAt = DateTime.UtcNow;
_copyState.PendingOrderTimestamps[key] = (DateTime.UtcNow, ladder.SourceTraderId); _copyState.PendingOrderTimestamps[key] = (DateTime.UtcNow, ladder.SourceTraderId, "SELL");
_logger.Trade($"✅ [SELL-LEITER Stufe {ladder.Attempt}] {account.Name} | neues GTC-Limit {next:F3} für {pos.Size:F2} Shares."); _logger.Trade($"✅ [SELL-LEITER Stufe {ladder.Attempt}] {account.Name} | neues GTC-Limit {next:F3} für {pos.Size:F2} Shares.");
} }
else else
@@ -639,6 +639,7 @@ namespace PolyTraderSharp.Services
ExitReason = exitReason ExitReason = exitReason
}; };
_state.GlobalPnl += realizedPnl; // Konsistenz: wie in PollLiveAccountsAsync (Anzeige driftete sonst)
_processedClosures.TryAdd(duplicateKey, true); _processedClosures.TryAdd(duplicateKey, true);
_closedTradeWriter.TryWrite(ctRecord); _closedTradeWriter.TryWrite(ctRecord);