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