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