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