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 { /// /// 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. /// 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(); var state = new TradingState(); clob ??= new FakeClobClient(); var logger = new TerminalLogger(); var threema = new ThreemaService(logger, new JobManager()); var repo = new FakePositionRepository(); var svc = new SellLadderService(copy, state, clob, logger, threema, repo); 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; // Threema-Notify ü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); } } }