From 6498a3d0ecaea9d1c4cfad9b360d8c2bf4c6d08b Mon Sep 17 00:00:00 2001 From: Richard Date: Sun, 13 Sep 2026 13:55:39 +0200 Subject: [PATCH] Pause stoppt jetzt auch Verkaufsleitern und Auto-Redeem Behebt #570. "Trading pausieren" hielt drei Wege nicht an, die Orders am CLOB platzieren: 1. SellLadderService: Eine laufende Verkaufsleiter preiste alle 5 s weiter nach, storniert und neu platziert. Sie pruefte weder Pause noch Modus. Die Engine ignoriert bei Pause zwar alle Signale, die bereits laufende Leiter lief aber ungebremst. 2. TraderMonitorService: Der REST-Pre-Redeem feuert aus der Positions-Synchronisierung, die bewusst auch bei Pause weiterlaeuft. Er pruefte nur LiveTradingMode. 3. PolymarketWssClient: Die Pause wurde nur beim Verbindungsaufbau geprueft. Auf einer bereits offenen Verbindung loesten Kursmeldungen den Auto-Redeem weiter aus. Die Ursache ist allen gemeinsam: Jeder Dienst hat seine Sperre selbst nachgebaut, und jeder hat etwas anderes vergessen. Die Regeln liegen jetzt an einer Stelle, in TradingGate, und alle drei benutzen sie: - Verkaufsleiter: Pause oder Modus des Kontotyps "Inaktiv" friert sie ein, bei "Nur Verkauf" laeuft sie weiter. Das entspricht der Engine. - Pre-Redeem (REST und WSS): wie bisher nur bei Modus Aktiv, jetzt zusaetzlich nicht bei Pause. Eine eingefrorene Leiter storniert nichts und platziert nichts. Das gilt auch am Floor, wenn dort die ruhende Order fehlt. Die ruhende GTC-Order bleibt am Buch, genau wie beim Beenden der Anwendung. LastActionAt bleibt unveraendert, nach der Freigabe geht es an der aktuellen Stufe weiter. Protokolliert wird einmal beim Einfrieren und einmal beim Fortsetzen, nicht alle 5 s. CLOB-kritisch, deshalb: - Vor der Aenderung gesichert unter agentspace/scripts/backups/*.vor-570-*.bak. - Orderaufbau und Preislogik sind nicht angefasst. Der Diff umfasst eine Sperre und drei Bedingungen. - Die Sperre steht vor dem ersten CLOB-Zugriff. Die Harness von SellLadderServiceTests setzt jetzt Live = Aktiv. Mit dem Standard Inaktiv waere Floor_replaces_order rot geworden, und Floor_holds_... waere gruen geblieben, aber aus dem falschen Grund: keine Order, weil eingefroren, nicht weil die Order noch ruht. Neu in SellLadderFreezeTests: Pause, Inaktiv, "Nur Verkauf", Floor im eingefrorenen Zustand, Fortsetzen an der aktuellen Stufe, dazu die Entscheidungstabellen von TradingGate. Co-Authored-By: Claude Opus 5 --- .../Logic/TradingGate.cs | 49 +++++ .../Services/PolymarketWssClient.cs | 8 +- .../Services/SellLadderService.cs | 24 +++ .../Services/TraderMonitorService.cs | 4 +- .../PolyTrader.Tests/SellLadderFreezeTests.cs | 169 ++++++++++++++++++ .../SellLadderServiceTests.cs | 5 +- 6 files changed, 255 insertions(+), 4 deletions(-) create mode 100644 src/PolyTrader.Modules.CopyTrading/Logic/TradingGate.cs create mode 100644 tests/PolyTrader.Tests/SellLadderFreezeTests.cs diff --git a/src/PolyTrader.Modules.CopyTrading/Logic/TradingGate.cs b/src/PolyTrader.Modules.CopyTrading/Logic/TradingGate.cs new file mode 100644 index 0000000..c1124ca --- /dev/null +++ b/src/PolyTrader.Modules.CopyTrading/Logic/TradingGate.cs @@ -0,0 +1,49 @@ +using PolyTraderSharp; + +namespace PolyTrader.Modules.CopyTrading.Logic +{ + /// + /// Die Sperren für automatische Verkäufe an einer Stelle (#570). + /// + /// Bis zum 13.09.2026 baute jeder Dienst seine Sperre selbst nach, und jeder vergaß etwas + /// anderes. Die Verkaufsleiter prüfte weder Pause noch Modus. Der REST-Pre-Redeem im + /// TraderMonitorService prüfte nur den Modus, weil die Positions-Synchronisierung, aus der er + /// feuert, bewusst auch bei Pause weiterläuft. Der WSS-Auto-Redeem prüfte die Pause nur beim + /// Verbindungsaufbau, nicht auf einer bereits offenen Verbindung. "Trading pausieren" hielt damit + /// keinen dieser drei Wege an, und alle drei platzieren Orders am CLOB. + /// + /// Die Regeln selbst sind unverändert übernommen, ergänzt um die Pause: + /// Pre-Redeem verlangt wie bisher den Modus Aktiv. Eine laufende Leiter läuft bei + /// "Nur Verkauf" weiter, wie die Engine dort Verkäufe zulässt. + /// + public static class TradingGate + { + /// + /// Warum eine laufende Verkaufsleiter gerade nicht nachpreisen darf, oder null, wenn sie darf. + /// Die Pause sperrt; der Modus des Kontotyps "Inaktiv" ebenfalls; "Nur Verkauf" und Aktiv lassen sie laufen. + /// + public static string? LadderFreezeReason(TradingState state, bool isDemo) + { + if (state.GlobalTradingPaused) + return "globale Pause ist aktiv"; + + TradingMode mode = isDemo ? state.DemoTradingMode : state.LiveTradingMode; + return mode == TradingMode.Inactive + ? $"{(isDemo ? "Demo" : "Live")}-Modus ist inaktiv" + : null; + } + + /// + /// Ob ein Pre-Redeem-Verkauf (REST oder WSS) ausgelöst werden darf: keine Pause und der Modus des + /// Kontotyps auf Aktiv. + /// + public static bool PreRedeemAllowed(TradingState state, bool isDemo) + { + if (state.GlobalTradingPaused) + return false; + + TradingMode mode = isDemo ? state.DemoTradingMode : state.LiveTradingMode; + return mode == TradingMode.Active; + } + } +} diff --git a/src/PolyTrader.Modules.CopyTrading/Services/PolymarketWssClient.cs b/src/PolyTrader.Modules.CopyTrading/Services/PolymarketWssClient.cs index 231ccb3..af93ff7 100644 --- a/src/PolyTrader.Modules.CopyTrading/Services/PolymarketWssClient.cs +++ b/src/PolyTrader.Modules.CopyTrading/Services/PolymarketWssClient.cs @@ -195,13 +195,17 @@ namespace PolyTraderSharp.Services if ((DateTime.UtcNow - redeemState.LastAttempt).TotalMinutes < 5) continue; // Wait 5 min between attempts } - if (!acc.IsDemo && _state.LiveTradingMode == TradingMode.Active) + // #570: Die Pause wird in ExecuteAsync nur beim Verbindungsaufbau geprueft. Auf einer + // bereits offenen Verbindung kommen die Kursmeldungen weiter hier an - deshalb die + // Sperre an der Stelle, die die Order ausloest. Der Kurs selbst wird weiter + // aktualisiert, die Anzeige bleibt also richtig. + if (!acc.IsDemo && TradingGate.PreRedeemAllowed(_state, isDemo: false)) { _logger.Trade($"🚨 [AUTO REDEEM] {acc.Name} | {pos.MarketQuestion} | Preis >= {_copyState.GetAccountSettings(acc.AccountId).PreRedeemLimit}"); // Best effort non-blocking _ = Task.Run(async () => await ExecuteAutoRedeemLive(acc, pos, price)); } - else if (acc.IsDemo && _state.DemoTradingMode == TradingMode.Active) + else if (acc.IsDemo && TradingGate.PreRedeemAllowed(_state, isDemo: true)) { _logger.Trade($"🚨 [AUTO REDEEM DEMO] {acc.Name} | {pos.MarketQuestion} | Preis >= {_copyState.GetAccountSettings(acc.AccountId).PreRedeemLimit}"); _ = Task.Run(() => ExecuteAutoRedeemDemo(acc, pos, price)); diff --git a/src/PolyTrader.Modules.CopyTrading/Services/SellLadderService.cs b/src/PolyTrader.Modules.CopyTrading/Services/SellLadderService.cs index 0f6c785..6e97011 100644 --- a/src/PolyTrader.Modules.CopyTrading/Services/SellLadderService.cs +++ b/src/PolyTrader.Modules.CopyTrading/Services/SellLadderService.cs @@ -35,6 +35,9 @@ namespace PolyTraderSharp.Services private readonly IPositionRepository _positionRepo; private readonly IOrderEventLog _orderEvents; + /// Leitern, deren Einfrieren bereits protokolliert ist (#570) – eine Meldung je Position statt einer alle 5 s. + private readonly System.Collections.Concurrent.ConcurrentDictionary _frozenLadders = new(); + public SellLadderService( CopyTradingState copyState, TradingState state, @@ -161,6 +164,7 @@ namespace PolyTraderSharp.Services // Startorder fehlgeschlagen: Claim + ExitPending zurücknehmen, Position bleibt handelbar. _copyState.ExitLadders.TryRemove(key, out _); + _frozenLadders.TryRemove(key, out _); pos.ExitPending = false; _copyState.PendingOrderTimestamps[key] = (DateTime.UtcNow.AddSeconds(-15), sourceTraderId, "SELL"); _logger.TradeReasoning($"❌ [SELL-LEITER · {reasonTag}] Startorder fehlgeschlagen: {result}. Position bleibt; neuer Versuch beim nächsten Signal/Sync."); @@ -195,6 +199,7 @@ namespace PolyTraderSharp.Services if (!_state.Accounts.TryGetValue(ladder.AccountId, out var account)) { _copyState.ExitLadders.TryRemove(key, out _); + _frozenLadders.TryRemove(key, out _); return; } @@ -202,6 +207,7 @@ namespace PolyTraderSharp.Services if (!account.OpenPositions.TryGetValue(ladder.TokenId, out var pos)) { _copyState.ExitLadders.TryRemove(key, out _); + _frozenLadders.TryRemove(key, out _); _logger.Trade($"✅ [SELL-LEITER fertig] {account.Name} | {ladder.MarketQuestion} | Position gefüllt/geschlossen (Sync)."); return; } @@ -212,6 +218,7 @@ namespace PolyTraderSharp.Services if (pos.Size < CopyTradingRisk.MinShares) { _copyState.ExitLadders.TryRemove(key, out _); + _frozenLadders.TryRemove(key, out _); pos.ExitPending = false; if (!account.IsDemo) _positionRepo.UpsertLive(account.AccountId, pos); _logger.Warning($"🧹 [SELL-LEITER Dust] {account.Name} | {ladder.MarketQuestion}: Restgröße {pos.Size:F2} < Minimum {CopyTradingRisk.MinShares} Shares – Leiter beendet, Rest gehalten."); @@ -220,6 +227,23 @@ namespace PolyTraderSharp.Services return; } + // #570: Pause und inaktiver Modus frieren die Leiter ein. Bis zum 13.09.2026 preiste sie auch + // nach "Trading pausieren" weiter nach und platzierte Orders am CLOB - die Engine ignoriert bei + // Pause alle Signale, die bereits laufende Leiter lief aber ungebremst. Die Sperre steht vor + // dem ersten CLOB-Zugriff (Existenzpruefung am Floor, Storno, Neuplatzierung). Die ruhende + // GTC-Order bleibt am Buch, genau wie beim Beenden der Anwendung; LastActionAt bleibt + // unveraendert, damit es nach der Freigabe an der aktuellen Stufe weitergeht. + string? freezeReason = TradingGate.LadderFreezeReason(_state, account.IsDemo); + if (freezeReason != null) + { + if (_frozenLadders.TryAdd(key, freezeReason)) + _logger.Warning($"⏸️ [SELL-LEITER eingefroren] {account.Name} | {ladder.MarketQuestion}: {freezeReason} – " + + $"kein Nachpreisen, die ruhende Order bei {ladder.CurrentLimit:F3} bleibt am Buch."); + return; + } + if (_frozenLadders.TryRemove(key, out _)) + _logger.Info($"▶️ [SELL-LEITER fortgesetzt] {account.Name} | {ladder.MarketQuestion}: weiter ab Limit {ladder.CurrentLimit:F3}."); + // Noch nicht Zeit für die nächste Stufe? double waited = (DateTime.UtcNow - ladder.LastActionAt).TotalSeconds; if (waited < SellLogic.LadderIntervalSeconds(ladder.IsHf)) return; diff --git a/src/PolyTrader.Modules.CopyTrading/Services/TraderMonitorService.cs b/src/PolyTrader.Modules.CopyTrading/Services/TraderMonitorService.cs index 736cca6..e7265cb 100644 --- a/src/PolyTrader.Modules.CopyTrading/Services/TraderMonitorService.cs +++ b/src/PolyTrader.Modules.CopyTrading/Services/TraderMonitorService.cs @@ -741,7 +741,9 @@ namespace PolyTraderSharp.Services if (allowAttempt) { - if (!acc.IsDemo && _state.LiveTradingMode == TradingMode.Active) + // #570: Die Positions-Synchronisierung laeuft auch bei Pause weiter ("Always sync"); + // der Pre-Redeem-Verkauf, der aus ihr heraus Orders platziert, darf das nicht. + if (!acc.IsDemo && TradingGate.PreRedeemAllowed(_state, isDemo: false)) { _logger.Trade($"🚨 [REST AUTO REDEEM] {acc.Name} | {existing.MarketQuestion} | Preis >= {_copyState.GetAccountSettings(acc.AccountId).PreRedeemLimit}"); _ = Task.Run(async () => await ExecuteRestAutoRedeemLive(acc, existing)); diff --git a/tests/PolyTrader.Tests/SellLadderFreezeTests.cs b/tests/PolyTrader.Tests/SellLadderFreezeTests.cs new file mode 100644 index 0000000..2e0334c --- /dev/null +++ b/tests/PolyTrader.Tests/SellLadderFreezeTests.cs @@ -0,0 +1,169 @@ +using System; +using PolyTrader.Modules.CopyTrading.Logic; +using PolyTrader.Tests.Fakes; +using PolyTraderSharp; +using PolyTraderSharp.Models; +using PolyTraderSharp.Services; +using Xunit; + +namespace PolyTrader.Tests +{ + /// + /// Pause und inaktiver Modus frieren laufende Verkaufsleitern ein (#570). Bis zum 13.09.2026 preiste + /// eine Leiter auch nach "Trading pausieren" weiter nach und platzierte Orders am CLOB. + /// + public class SellLadderFreezeTests + { + private const string Tok = "tok-1"; + private const string Key = "1_" + Tok; + + private sealed record Rig(SellLadderService Svc, CopyTradingState Copy, TradingState State, FakeClobClient Clob); + + private static Rig Build() + { + var copy = new CopyTradingState(); + var state = new TradingState { LiveTradingMode = TradingMode.Active }; + var clob = new FakeClobClient(); + var svc = new SellLadderService(copy, state, clob, new TerminalLogger(), new FakeNotificationSink(), + new FakePositionRepository(), new FakeOrderEventLog()); + + var account = new AccountState { AccountId = 1, Name = "Live-Test", IsDemo = false }; + account.OpenPositions[Tok] = new Position + { + TokenId = Tok, MarketQuestion = "Wird X passieren?", SourceTraderId = 7, + Size = 100m, EntryPrice = 0.40m, CurrentPrice = 0.50m, AmountUsd = 40m, ExitPending = true + }; + state.Accounts[account.AccountId] = account; + return new Rig(svc, copy, state, clob); + } + + /// Eine Leiter oberhalb des Floors, deren Intervall längst abgelaufen ist: Ohne Sperre käme jetzt die nächste Stufe. + private static ExitLadderState DueStep(DateTime lastActionAt) => new() + { + AccountId = 1, TokenId = Tok, SourceTraderId = 7, MarketQuestion = "Wird X passieren?", + ReferencePrice = 0.50m, CurrentLimit = 0.50m, Floor = 0.30m, IsHf = false, Attempt = 1, + LastActionAt = lastActionAt + }; + + [Fact] + public async Task Ohne_Sperre_kommt_die_naechste_Stufe() + { + var rig = Build(); + var ladder = DueStep(DateTime.UtcNow.AddMinutes(-10)); + rig.Copy.ExitLadders[Key] = ladder; + + await rig.Svc.ProcessLadderAsync(Key, ladder); + + Assert.Single(rig.Clob.ConflictCancels); + Assert.Single(rig.Clob.Placed); + } + + [Fact] + public async Task Pause_friert_die_Leiter_ein_ohne_Storno_und_ohne_Order() + { + var rig = Build(); + rig.State.GlobalTradingPaused = true; + var lastAction = DateTime.UtcNow.AddMinutes(-10); + var ladder = DueStep(lastAction); + rig.Copy.ExitLadders[Key] = ladder; + + await rig.Svc.ProcessLadderAsync(Key, ladder); + + Assert.Empty(rig.Clob.ConflictCancels); + Assert.Empty(rig.Clob.Placed); + Assert.True(rig.Copy.ExitLadders.ContainsKey(Key)); // eingefroren, nicht beendet + Assert.Equal(0.50m, ladder.CurrentLimit); + Assert.Equal(lastAction, ladder.LastActionAt); // nach Freigabe geht es sofort weiter + } + + [Fact] + public async Task Inaktiver_Live_Modus_friert_die_Leiter_ein() + { + var rig = Build(); + rig.State.LiveTradingMode = TradingMode.Inactive; + var ladder = DueStep(DateTime.UtcNow.AddMinutes(-10)); + rig.Copy.ExitLadders[Key] = ladder; + + await rig.Svc.ProcessLadderAsync(Key, ladder); + + Assert.Empty(rig.Clob.ConflictCancels); + Assert.Empty(rig.Clob.Placed); + } + + [Fact] + public async Task Nur_Verkauf_laesst_die_Leiter_weiterlaufen() + { + var rig = Build(); + rig.State.LiveTradingMode = TradingMode.SellOnly; + var ladder = DueStep(DateTime.UtcNow.AddMinutes(-10)); + rig.Copy.ExitLadders[Key] = ladder; + + await rig.Svc.ProcessLadderAsync(Key, ladder); + + Assert.Single(rig.Clob.Placed); + } + + [Fact] + public async Task Eingefrorene_Leiter_am_Floor_setzt_keine_fehlende_Order_neu() + { + var rig = Build(); + rig.State.GlobalTradingPaused = true; + var ladder = DueStep(DateTime.UtcNow.AddMinutes(-10)); + ladder.CurrentLimit = 0.30m; // am Floor, keine ruhende Order im Fake + rig.Copy.ExitLadders[Key] = ladder; + + await rig.Svc.ProcessLadderAsync(Key, ladder); + + Assert.Empty(rig.Clob.Placed); + } + + [Fact] + public async Task Nach_Aufheben_der_Pause_geht_es_an_der_aktuellen_Stufe_weiter() + { + var rig = Build(); + rig.State.GlobalTradingPaused = true; + var ladder = DueStep(DateTime.UtcNow.AddMinutes(-10)); + rig.Copy.ExitLadders[Key] = ladder; + await rig.Svc.ProcessLadderAsync(Key, ladder); + await rig.Svc.ProcessLadderAsync(Key, ladder); + Assert.Empty(rig.Clob.Placed); + + rig.State.GlobalTradingPaused = false; + await rig.Svc.ProcessLadderAsync(Key, ladder); + + var order = Assert.Single(rig.Clob.Placed); + Assert.True(order.Price < 0.50m); // eine Stufe unter dem eingefrorenen Limit + Assert.Equal(2, ladder.Attempt); + } + + // ---- TradingGate ------------------------------------------------------- + + [Theory] + [InlineData(false, TradingMode.Active, TradingMode.Inactive, false, false)] // Live aktiv + [InlineData(false, TradingMode.SellOnly, TradingMode.Inactive, false, false)] // Nur Verkauf laeuft weiter + [InlineData(false, TradingMode.Inactive, TradingMode.Active, false, true)] // Live inaktiv, Demo egal + [InlineData(true, TradingMode.Inactive, TradingMode.Active, false, false)] // Demo-Konto folgt dem Demo-Modus + [InlineData(false, TradingMode.Active, TradingMode.Active, true, true)] // Pause sperrt alles + public void Leiter_Sperre_folgt_Pause_und_Modus_des_Kontotyps( + bool isDemo, TradingMode live, TradingMode demo, bool paused, bool frozen) + { + var state = new TradingState { LiveTradingMode = live, DemoTradingMode = demo, GlobalTradingPaused = paused }; + + Assert.Equal(frozen, TradingGate.LadderFreezeReason(state, isDemo) != null); + } + + [Theory] + [InlineData(false, TradingMode.Active, false, true)] + [InlineData(false, TradingMode.Active, true, false)] // die Luecke: Pause bei aktivem Modus + [InlineData(false, TradingMode.SellOnly, false, false)] // unveraendert: Pre-Redeem verlangt Aktiv + [InlineData(false, TradingMode.Inactive, false, false)] + [InlineData(true, TradingMode.Active, true, false)] + public void PreRedeem_verlangt_aktiven_Modus_und_keine_Pause(bool isDemo, TradingMode mode, bool paused, bool allowed) + { + var state = new TradingState { GlobalTradingPaused = paused }; + if (isDemo) state.DemoTradingMode = mode; else state.LiveTradingMode = mode; + + Assert.Equal(allowed, TradingGate.PreRedeemAllowed(state, isDemo)); + } + } +} diff --git a/tests/PolyTrader.Tests/SellLadderServiceTests.cs b/tests/PolyTrader.Tests/SellLadderServiceTests.cs index e86d787..620a829 100644 --- a/tests/PolyTrader.Tests/SellLadderServiceTests.cs +++ b/tests/PolyTrader.Tests/SellLadderServiceTests.cs @@ -22,7 +22,10 @@ namespace PolyTrader.Tests Build(FakeClobClient? clob = null) { var copy = new CopyTradingState(); - var state = new TradingState(); + // Live-Modus Aktiv: Eine Leiter entsteht nur bei nicht-inaktivem Modus, und seit #570 friert sie + // bei Inaktiv ein. Ohne diese Vorbedingung bestuenden Tests wie Floor_holds_... aus dem falschen + // Grund - keine Order, weil eingefroren, nicht weil die Order noch ruht. + var state = new TradingState { LiveTradingMode = TradingMode.Active }; clob ??= new FakeClobClient(); var logger = new TerminalLogger(); var notify = new FakeNotificationSink();