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();