From f4045f08e16d4d89eebfd1f216405bee54714337 Mon Sep 17 00:00:00 2001 From: Richard Date: Thu, 9 Jul 2026 19:38:11 +0200 Subject: [PATCH] Engine-Testabdeckung (Guards) + K3-Korrektur am echten Bug-Ort MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Testabdeckung fuer den geldkritischen Engine-Pfad (bisher 0 Tests), da die Fable-Guards zwischen Services entstehen und Unit-Tests sie nicht fangen: - Engine._clob -> IClobClient (verhaltensneutral, DI registriert IClobClient bereits); ProcessAccountOrderAsync internal. FakeMarketRepository/FakeAccountRepository ergaenzt. - 7 Integrationstests (CopyTradingEngineTests) ueber gemockten CLOB: H3 BUY-Skip bei ExitPending, Doppel-SELL-Guard, K3 System-Close (TraderId==0) schliesst Fremd-Position, Fremd-Trader-SELL bleibt abgewiesen, H2 Cleanup schont Leiter (+ Kontrast ohne Leiter). DABEI ECHTEN BUG GEFANGEN: Der K3-Fix aus Slice 3 sass an der falschen Stelle (IsAuthorizedSell nach dem Position-Remove, Zeile ~643) – der eigentliche Ownership-Check ist der fruehe inPortfolio-Lookup (Zeile 437, p.SourceTraderId == signal.TraderId), der System-Signale schon vorher mit early return abwies. Fix jetzt am richtigen Ort; der downstream-Check bleibt als Defense-in-depth. Ohne den Engine-Test waere das unentdeckt geblieben. Build 0 Fehler, 242 Tests gruen, --smoke-ui ok. Co-Authored-By: Claude Opus 4.8 --- .../Services/CopyTradingEngine.cs | 13 +- .../CopyTradingEngineTests.cs | 169 ++++++++++++++++++ .../Fakes/FakeRepositories.cs | 27 +++ 3 files changed, 204 insertions(+), 5 deletions(-) create mode 100644 tests/PolyTrader.Tests/CopyTradingEngineTests.cs create mode 100644 tests/PolyTrader.Tests/Fakes/FakeRepositories.cs diff --git a/src/PolyTrader.Modules.CopyTrading/Services/CopyTradingEngine.cs b/src/PolyTrader.Modules.CopyTrading/Services/CopyTradingEngine.cs index 26f9e96..9409ca4 100644 --- a/src/PolyTrader.Modules.CopyTrading/Services/CopyTradingEngine.cs +++ b/src/PolyTrader.Modules.CopyTrading/Services/CopyTradingEngine.cs @@ -19,7 +19,7 @@ namespace PolyTraderSharp.Services private readonly ChannelReader _signalReader; private readonly ChannelWriter _closedTradeWriter; private readonly TerminalLogger _logger; - private readonly PolymarketClobClient _clob; + private readonly IClobClient _clob; private readonly PolymarketApiService _api; private readonly IPositionRepository _positionRepo; private readonly IMarketRepository _marketRepo; @@ -34,7 +34,7 @@ namespace PolyTraderSharp.Services ChannelReader signalReader, ChannelWriter closedTradeWriter, TerminalLogger logger, - PolymarketClobClient clob, + IClobClient clob, PolymarketApiService api, IPositionRepository positionRepo, IMarketRepository marketRepo, @@ -219,7 +219,7 @@ namespace PolyTraderSharp.Services await Task.WhenAll(accountTasks); } - private async Task ProcessAccountOrderAsync(AccountState account, TrackedTrader? trader, CopySignal signal, bool isNegRisk) + internal async Task ProcessAccountOrderAsync(AccountState account, TrackedTrader? trader, CopySignal signal, bool isNegRisk) { // Copytrading-Detail-Einstellungen (Limits) dieses Accounts. var settings = _copyState.GetAccountSettings(account.AccountId); @@ -434,9 +434,12 @@ namespace PolyTraderSharp.Services { // PRE-FLIGHT SELL Check: Exists in portfolio AND opened by the SAME master trader? // CRITICAL: We must NOT sell a position opened by Trader A based on a SELL signal from Trader B. - var inPortfolio = account.OpenPositions.Values.FirstOrDefault(p => + // K3: System-Signale (TraderId == 0, Demo-Auto-Close bei Marktauflösung) sind vom + // Ownership-Check ausgenommen (CopyTradingRisk.IsAuthorizedSell) – sonst wird die Position + // hier als "gehört anderem Trader" abgewiesen und schließt bei Resolution nie. + var inPortfolio = account.OpenPositions.Values.FirstOrDefault(p => (p.TokenId == signal.TokenId || (p.MarketSlug == signal.MarketSlug && p.Outcome == signal.Outcome)) - && p.SourceTraderId == signal.TraderId); + && CopyTradingRisk.IsAuthorizedSell(signal.TraderId, p.SourceTraderId)); if (inPortfolio == null) { // Check if position exists but belongs to a different trader (for clearer logging) diff --git a/tests/PolyTrader.Tests/CopyTradingEngineTests.cs b/tests/PolyTrader.Tests/CopyTradingEngineTests.cs new file mode 100644 index 0000000..b246478 --- /dev/null +++ b/tests/PolyTrader.Tests/CopyTradingEngineTests.cs @@ -0,0 +1,169 @@ +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!; + } + + 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 ladder = new SellLadderService(copy, state, clob, logger, threema, posRepo); + + 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); + + // 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 }; + } + + 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); + } + + // ---------- 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); + } + + [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); + } + } +} diff --git a/tests/PolyTrader.Tests/Fakes/FakeRepositories.cs b/tests/PolyTrader.Tests/Fakes/FakeRepositories.cs new file mode 100644 index 0000000..b722293 --- /dev/null +++ b/tests/PolyTrader.Tests/Fakes/FakeRepositories.cs @@ -0,0 +1,27 @@ +using System.Collections.Generic; +using PolyTrader.Core.Persistence; +using PolyTraderSharp.Models; + +namespace PolyTrader.Tests.Fakes +{ + /// No-op-Stub für (Engine-Tests nutzen den MarketCache). + public sealed class FakeMarketRepository : IMarketRepository + { + public MarketData? GetById(string id) => null; + public MarketData? FindByTokenId(string tokenId) => null; + public List GetActive() => new(); + public void Upsert(MarketData market) { } + public void Insert(MarketData market) { } + public void Update(MarketData market) { } + public void EnsureIndexes() { } + } + + /// In-Memory-Stub für . + public sealed class FakeAccountRepository : IAccountRepository + { + public List<(int AccountId, decimal Balance)> Upserts { get; } = new(); + public List GetAll() => new(); + public void Upsert(AccountState account) => Upserts.Add((account.AccountId, account.AvailableBalance)); + public void Delete(int accountId) { } + } +}