using System; using System.Net.Http; using System.Threading.Channels; using System.Threading.Tasks; using PolyTrader.Tests.Fakes; using PolyTraderSharp; using PolyTraderSharp.Models; using PolyTraderSharp.Services; using Xunit; namespace PolyTrader.Tests { /// /// Integrationstests des geldkritischen Engine-Entscheidungspfads (ProcessAccountOrderAsync) über /// einen gemockten CLOB-Client. Deckt die Fable-Guards ab, die zwischen Services entstehen und /// durch reine Unit-Tests nicht fangbar sind: H3 (BUY-Skip bei ExitPending), Doppel-SELL-Guard, /// K3 (System-Close TraderId==0) und H2 (Order-Cleanup schont aktive Leiter). /// public class CopyTradingEngineTests { private const string Tok = "tok-eng"; private sealed class Harness { public CopyTradingEngine Engine = null!; public TradingState State = null!; public CopyTradingState Copy = null!; public FakeClobClient Clob = null!; public ChannelReader ClosedReader = null!; public FakeDecisionJournal Journal = null!; } private static Harness Build() { var state = new TradingState { LiveTradingMode = TradingMode.Active, DemoTradingMode = TradingMode.Active }; var copy = new CopyTradingState(); var clob = new FakeClobClient(); var logger = new TerminalLogger(); var api = new PolymarketApiService(logger, new HttpClient()); var posRepo = new FakePositionRepository(); var marketRepo = new FakeMarketRepository(); var accountRepo = new FakeAccountRepository(); var threema = new ThreemaService(logger, new JobManager()); var journal = new FakeDecisionJournal(); var orderEvents = new FakeOrderEventLog(); var ladder = new SellLadderService(copy, state, clob, logger, threema, posRepo, orderEvents); var signalCh = Channel.CreateUnbounded(); var closedCh = Channel.CreateUnbounded(); var engine = new CopyTradingEngine(state, copy, signalCh.Reader, closedCh.Writer, logger, clob, api, posRepo, marketRepo, accountRepo, ladder, journal, orderEvents); // MarketData cachen, damit der API-Pfad (Cache-Miss) nie läuft. state.MarketCache[Tok] = new MarketData { Slug = "slug", Question = "Frage?", Category = "Politics", TakerFeeBps = 0, NegRisk = false }; return new Harness { Engine = engine, State = state, Copy = copy, Clob = clob, ClosedReader = closedCh.Reader, Journal = journal }; } private static AccountState Account(bool demo) => new() { AccountId = 1, Name = demo ? "Demo" : "Live", IsDemo = demo, TotalBalance = 1000m, AvailableBalance = 1000m }; private static Position Pos(bool exitPending = false, decimal size = 100m) => new() { TokenId = Tok, MarketQuestion = "Frage?", MarketSlug = "slug", SourceTraderId = 7, Size = size, EntryPrice = 0.40m, CurrentPrice = 0.50m, AmountUsd = 40m, OpenedAt = DateTime.UtcNow.AddHours(-1), ExitPending = exitPending }; private static CopySignal Signal(string side, int traderId, decimal price) => new() { TraderId = traderId, TokenId = Tok, MarketSlug = "slug", MarketQuestion = "Frage?", Outcome = "Yes", Side = side, Price = price, Size = 100m, Timestamp = DateTime.UtcNow, Reason = "test" }; private static TrackedTrader Trader() => new() { Id = 7, IsActive = true, Category = "" }; // ---------- H3: BUY-Skip während ExitPending ---------- [Fact] public async Task Buy_is_skipped_while_position_exit_pending() { var h = Build(); var acc = Account(demo: false); acc.OpenPositions[Tok] = Pos(exitPending: true); await h.Engine.ProcessAccountOrderAsync(acc, Trader(), Signal("BUY", 7, 0.50m), false); Assert.Empty(h.Clob.Placed); // kein Zukauf während des Ausstiegs Assert.True(acc.OpenPositions[Tok].ExitPending); // S-0: Entscheidung strukturiert im Journal (nicht nur Freitext-Log). Assert.Contains(h.Journal.Written, d => d.Decision == TradeDecision.Skipped && d.Reason == DecisionReason.ExitPendingBuySkip && d.TokenId == Tok); } // ---------- Doppel-SELL-Guard ---------- [Fact] public async Task Sell_is_ignored_while_ladder_already_running() { var h = Build(); var acc = Account(demo: false); acc.OpenPositions[Tok] = Pos(exitPending: true); await h.Engine.ProcessAccountOrderAsync(acc, Trader(), Signal("SELL", 7, 0.45m), false); Assert.Empty(h.Clob.Placed); // keine zweite Leiter/Order Assert.True(acc.OpenPositions.ContainsKey(Tok)); } // ---------- K3: System-Close (TraderId == 0) ---------- [Fact] public async Task System_close_resolves_demo_position_despite_foreign_owner() { var h = Build(); var acc = Account(demo: true); acc.OpenPositions[Tok] = Pos(); // SourceTraderId = 7 // System-SELL (TraderId 0) bei Marktauflösung – trader ist null. await h.Engine.ProcessAccountOrderAsync(acc, null, Signal("SELL", 0, 1.0m), false); Assert.False(acc.OpenPositions.ContainsKey(Tok)); // Demo-Position geschlossen Assert.True(h.ClosedReader.TryRead(out var ct)); // ClosedTrade geschrieben Assert.Equal(Tok, ct!.TokenId); Assert.False(string.IsNullOrEmpty(ct.SignalId)); // S-0: Korrelation gesetzt Assert.Contains(h.Journal.Written, d => d.Decision == TradeDecision.Executed && d.Reason == DecisionReason.SystemResolutionClose); } [Fact] public async Task Foreign_trader_sell_is_still_rejected() { // Regression: der Ownership-Check bleibt für echte Master (TraderId != 0) scharf. var h = Build(); var acc = Account(demo: true); acc.OpenPositions[Tok] = Pos(); // gehört Trader 7 await h.Engine.ProcessAccountOrderAsync(acc, new TrackedTrader { Id = 9, IsActive = true }, Signal("SELL", 9, 1.0m), false); Assert.True(acc.OpenPositions.ContainsKey(Tok)); // NICHT geschlossen Assert.False(h.ClosedReader.TryRead(out _)); } // ---------- H2: Order-Cleanup schont aktive Leiter ---------- [Fact] public async Task Pre_signal_cleanup_is_skipped_when_ladder_active() { var h = Build(); var acc = Account(demo: false); acc.HasOpenLimitOrders = true; acc.OpenPositions[Tok] = Pos(exitPending: true); h.Copy.ExitLadders["1_" + Tok] = new ExitLadderState { AccountId = 1, TokenId = Tok, Floor = 0.40m, CurrentLimit = 0.45m }; await h.Engine.ProcessAccountOrderAsync(acc, Trader(), Signal("SELL", 7, 0.45m), false); Assert.Empty(h.Clob.ConflictCancels); // Leiter-Order NICHT weggeräumt } [Fact] public async Task Pre_signal_cleanup_runs_when_no_ladder_active() { // Kontrast: ohne aktive Leiter räumt der Cleanup konfligierende Orders auf. var h = Build(); var acc = Account(demo: false); acc.HasOpenLimitOrders = true; acc.OpenPositions[Tok] = Pos(); // nicht ExitPending, keine Leiter await h.Engine.ProcessAccountOrderAsync(acc, Trader(), Signal("SELL", 7, 0.45m), false); Assert.Single(h.Clob.ConflictCancels); } } }