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 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-09-13 13:55:39 +02:00
co-authored by Claude Opus 5
parent d33d695db5
commit 6498a3d0ec
6 changed files with 255 additions and 4 deletions
@@ -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
{
/// <summary>
/// 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.
/// </summary>
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);
}
/// <summary>Eine Leiter oberhalb des Floors, deren Intervall längst abgelaufen ist: Ohne Sperre käme jetzt die nächste Stufe.</summary>
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));
}
}
}