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>
217 lines
9.2 KiB
C#
217 lines
9.2 KiB
C#
using System;
|
|
using System.Linq;
|
|
using System.Threading.Tasks;
|
|
using PolyTrader.Tests.Fakes;
|
|
using PolyTraderSharp;
|
|
using PolyTraderSharp.Models;
|
|
using PolyTraderSharp.Services;
|
|
using Xunit;
|
|
|
|
namespace PolyTrader.Tests
|
|
{
|
|
/// <summary>
|
|
/// Integrationstests der SELL-Eskalationsleiter über einen gemockten CLOB-Client
|
|
/// (Slice 1 der Fable-Fixes): atomarer Leiter-Claim (H1) und Floor-Robustheit (K1).
|
|
/// Diese Fehler entstehen zwischen Services und sind durch reine Unit-Tests nicht fangbar.
|
|
/// </summary>
|
|
public class SellLadderServiceTests
|
|
{
|
|
private const string Tok = "tok-1";
|
|
|
|
private static (SellLadderService svc, CopyTradingState copy, TradingState state, FakeClobClient clob, FakePositionRepository repo)
|
|
Build(FakeClobClient? clob = null)
|
|
{
|
|
var copy = new CopyTradingState();
|
|
// 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();
|
|
var repo = new FakePositionRepository();
|
|
var svc = new SellLadderService(copy, state, clob, logger, notify, repo, new FakeOrderEventLog());
|
|
return (svc, copy, state, clob, repo);
|
|
}
|
|
|
|
private static (AccountState account, Position pos) LiveAccountWithPosition(TradingState state, decimal size = 100m)
|
|
{
|
|
var account = new AccountState { AccountId = 1, Name = "Live-Test", IsDemo = false };
|
|
var pos = new Position
|
|
{
|
|
TokenId = Tok,
|
|
MarketQuestion = "Wird X passieren?",
|
|
SourceTraderId = 7,
|
|
Size = size,
|
|
EntryPrice = 0.40m,
|
|
CurrentPrice = 0.50m,
|
|
AmountUsd = 40m
|
|
};
|
|
account.OpenPositions[pos.TokenId] = pos;
|
|
state.Accounts[account.AccountId] = account;
|
|
return (account, pos);
|
|
}
|
|
|
|
// ---------- H1: Atomarer Claim ----------
|
|
|
|
[Fact]
|
|
public async Task StartLadder_places_one_order_and_registers_ladder()
|
|
{
|
|
var (svc, copy, state, clob, _) = Build();
|
|
var (account, pos) = LiveAccountWithPosition(state);
|
|
|
|
bool ok = await svc.StartLadderAsync(account, pos, 0.50m, 7, false, 5m, 15m, false, "Master SELL");
|
|
|
|
Assert.True(ok);
|
|
Assert.True(copy.ExitLadders.ContainsKey("1_" + Tok));
|
|
Assert.True(pos.ExitPending);
|
|
Assert.Single(clob.Placed);
|
|
Assert.Equal("SELL", clob.Placed[0].Side);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Second_StartLadder_for_same_position_is_idempotent_no_second_order()
|
|
{
|
|
var (svc, copy, state, clob, _) = Build();
|
|
var (account, pos) = LiveAccountWithPosition(state);
|
|
|
|
bool first = await svc.StartLadderAsync(account, pos, 0.50m, 7, false, 5m, 15m, false, "Master SELL");
|
|
bool second = await svc.StartLadderAsync(account, pos, 0.50m, 7, false, 5m, 15m, false, "Profit Target");
|
|
|
|
Assert.True(first);
|
|
Assert.False(second); // zweiter Aufruf verliert den Claim
|
|
Assert.Single(clob.Placed); // KEINE zweite GTC-Order
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Concurrent_StartLadder_starts_exactly_one_ladder()
|
|
{
|
|
var (svc, copy, state, clob, _) = Build();
|
|
var (account, pos) = LiveAccountWithPosition(state);
|
|
|
|
var results = await Task.WhenAll(
|
|
Enumerable.Range(0, 8).Select(_ =>
|
|
svc.StartLadderAsync(account, pos, 0.50m, 7, false, 5m, 15m, false, "race")));
|
|
|
|
Assert.Equal(1, results.Count(r => r)); // genau ein Gewinner
|
|
Assert.Single(clob.Placed); // genau eine Order
|
|
Assert.True(copy.ExitLadders.ContainsKey("1_" + Tok));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task StartLadder_when_ladder_already_exists_readds_position_as_exitpending()
|
|
{
|
|
// H1-Waisen-Schutz: Engine entfernt die Position vor StartLadderAsync. Verliert dieser Aufruf
|
|
// den Claim (andere Leiter läuft schon), muss die Position trotzdem als ExitPending präsent bleiben.
|
|
var (svc, copy, state, clob, _) = Build();
|
|
var (account, pos) = LiveAccountWithPosition(state);
|
|
|
|
copy.ExitLadders["1_" + Tok] = new ExitLadderState { AccountId = 1, TokenId = Tok, Floor = 0.40m, CurrentLimit = 0.45m };
|
|
account.OpenPositions.TryRemove(Tok, out _); // Engine hat entfernt
|
|
pos.ExitPending = false;
|
|
|
|
bool ok = await svc.StartLadderAsync(account, pos, 0.50m, 7, false, 5m, 15m, false, "Master SELL");
|
|
|
|
Assert.False(ok);
|
|
Assert.True(account.OpenPositions.ContainsKey(Tok)); // wieder da
|
|
Assert.True(pos.ExitPending);
|
|
Assert.Empty(clob.Placed); // keine neue Order
|
|
}
|
|
|
|
[Fact]
|
|
public async Task StartLadder_releases_claim_when_place_fails()
|
|
{
|
|
var clob = new FakeClobClient { PlaceResult = "ERROR: insufficient balance" };
|
|
var (svc, copy, state, _, _) = Build(clob);
|
|
var (account, pos) = LiveAccountWithPosition(state);
|
|
|
|
bool ok = await svc.StartLadderAsync(account, pos, 0.50m, 7, false, 5m, 15m, false, "Master SELL");
|
|
|
|
Assert.False(ok);
|
|
Assert.False(copy.ExitLadders.ContainsKey("1_" + Tok)); // Claim zurückgegeben
|
|
Assert.False(pos.ExitPending);
|
|
Assert.True(account.OpenPositions.ContainsKey(Tok)); // Position bleibt handelbar
|
|
}
|
|
|
|
// ---------- K1: Floor-Robustheit ----------
|
|
|
|
private static ExitLadderState FloorLadder() => new()
|
|
{
|
|
AccountId = 1,
|
|
TokenId = Tok,
|
|
SourceTraderId = 7,
|
|
MarketQuestion = "Wird X passieren?",
|
|
ReferencePrice = 0.50m,
|
|
CurrentLimit = 0.40m,
|
|
Floor = 0.40m, // CurrentLimit == Floor -> IsAtFloor
|
|
IsHf = false,
|
|
Attempt = 5,
|
|
LastActionAt = DateTime.UtcNow.AddMinutes(-10) // Intervall (120s) längst vorbei
|
|
};
|
|
|
|
[Fact]
|
|
public async Task Floor_replaces_order_when_none_resting()
|
|
{
|
|
var (svc, copy, state, clob, _) = Build();
|
|
var (account, pos) = LiveAccountWithPosition(state);
|
|
var ladder = FloorLadder();
|
|
copy.ExitLadders["1_" + Tok] = ladder;
|
|
// FakeClob liefert für den Token keine offenen Orders -> Order ist weg (z.B. Cleanup/extern).
|
|
|
|
await svc.ProcessLadderAsync("1_" + Tok, ladder);
|
|
|
|
Assert.Single(clob.Placed);
|
|
Assert.Equal("SELL", clob.Placed[0].Side);
|
|
Assert.Equal(0.40m, clob.Placed[0].Price); // am Floor neu platziert
|
|
Assert.True(copy.PendingOrderTimestamps.ContainsKey("1_" + Tok));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Floor_holds_when_order_still_resting()
|
|
{
|
|
var (svc, copy, state, clob, _) = Build();
|
|
var (account, pos) = LiveAccountWithPosition(state);
|
|
var ladder = FloorLadder();
|
|
ladder.FloorNotified = true; // Floor-Benachrichtigung überspringen
|
|
copy.ExitLadders["1_" + Tok] = ladder;
|
|
clob.OpenOrdersByAsset[Tok] = new() { ("oid-1", "SELL", 0.40m) };
|
|
|
|
await svc.ProcessLadderAsync("1_" + Tok, ladder);
|
|
|
|
Assert.Empty(clob.Placed); // Order ruht -> nicht neu platzieren
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Ladder_completes_when_position_gone()
|
|
{
|
|
var (svc, copy, state, clob, _) = Build();
|
|
var (account, pos) = LiveAccountWithPosition(state);
|
|
var ladder = FloorLadder();
|
|
copy.ExitLadders["1_" + Tok] = ladder;
|
|
account.OpenPositions.TryRemove(Tok, out _); // Sync hat den Fill erkannt
|
|
|
|
await svc.ProcessLadderAsync("1_" + Tok, ladder);
|
|
|
|
Assert.False(copy.ExitLadders.ContainsKey("1_" + Tok)); // Leiter beendet
|
|
Assert.Empty(clob.Placed);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Ladder_aborts_on_dust_remainder_below_minimum()
|
|
{
|
|
// H4: Dust-Rest unter dem Minimum -> Leiter beenden statt endloser Sub-Minimum-Rejects.
|
|
var (svc, copy, state, clob, _) = Build();
|
|
var (account, pos) = LiveAccountWithPosition(state, size: 3m); // < MinShares (5.5)
|
|
pos.ExitPending = true;
|
|
var ladder = FloorLadder();
|
|
copy.ExitLadders["1_" + Tok] = ladder;
|
|
|
|
await svc.ProcessLadderAsync("1_" + Tok, ladder);
|
|
|
|
Assert.False(copy.ExitLadders.ContainsKey("1_" + Tok)); // Leiter beendet
|
|
Assert.False(pos.ExitPending); // Rest handelbar/gehalten
|
|
Assert.Empty(clob.Placed); // keine Sub-Minimum-Order
|
|
}
|
|
}
|
|
}
|