Engine-Testabdeckung (Guards) + K3-Korrektur am echten Bug-Ort

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 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-07-09 19:38:11 +02:00
co-authored by Claude Opus 4.8
parent 782c08a860
commit f4045f08e1
3 changed files with 204 additions and 5 deletions
@@ -19,7 +19,7 @@ namespace PolyTraderSharp.Services
private readonly ChannelReader<CopySignal> _signalReader; private readonly ChannelReader<CopySignal> _signalReader;
private readonly ChannelWriter<ClosedTrade> _closedTradeWriter; private readonly ChannelWriter<ClosedTrade> _closedTradeWriter;
private readonly TerminalLogger _logger; private readonly TerminalLogger _logger;
private readonly PolymarketClobClient _clob; private readonly IClobClient _clob;
private readonly PolymarketApiService _api; private readonly PolymarketApiService _api;
private readonly IPositionRepository _positionRepo; private readonly IPositionRepository _positionRepo;
private readonly IMarketRepository _marketRepo; private readonly IMarketRepository _marketRepo;
@@ -34,7 +34,7 @@ namespace PolyTraderSharp.Services
ChannelReader<CopySignal> signalReader, ChannelReader<CopySignal> signalReader,
ChannelWriter<ClosedTrade> closedTradeWriter, ChannelWriter<ClosedTrade> closedTradeWriter,
TerminalLogger logger, TerminalLogger logger,
PolymarketClobClient clob, IClobClient clob,
PolymarketApiService api, PolymarketApiService api,
IPositionRepository positionRepo, IPositionRepository positionRepo,
IMarketRepository marketRepo, IMarketRepository marketRepo,
@@ -219,7 +219,7 @@ namespace PolyTraderSharp.Services
await Task.WhenAll(accountTasks); 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. // Copytrading-Detail-Einstellungen (Limits) dieses Accounts.
var settings = _copyState.GetAccountSettings(account.AccountId); 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? // 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. // 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.TokenId == signal.TokenId || (p.MarketSlug == signal.MarketSlug && p.Outcome == signal.Outcome))
&& p.SourceTraderId == signal.TraderId); && CopyTradingRisk.IsAuthorizedSell(signal.TraderId, p.SourceTraderId));
if (inPortfolio == null) if (inPortfolio == null)
{ {
// Check if position exists but belongs to a different trader (for clearer logging) // Check if position exists but belongs to a different trader (for clearer logging)
@@ -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
{
/// <summary>
/// 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).
/// </summary>
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<ClosedTrade> 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<CopySignal>();
var closedCh = Channel.CreateUnbounded<ClosedTrade>();
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);
}
}
}
@@ -0,0 +1,27 @@
using System.Collections.Generic;
using PolyTrader.Core.Persistence;
using PolyTraderSharp.Models;
namespace PolyTrader.Tests.Fakes
{
/// <summary>No-op-Stub für <see cref="IMarketRepository"/> (Engine-Tests nutzen den MarketCache).</summary>
public sealed class FakeMarketRepository : IMarketRepository
{
public MarketData? GetById(string id) => null;
public MarketData? FindByTokenId(string tokenId) => null;
public List<MarketData> GetActive() => new();
public void Upsert(MarketData market) { }
public void Insert(MarketData market) { }
public void Update(MarketData market) { }
public void EnsureIndexes() { }
}
/// <summary>In-Memory-Stub für <see cref="IAccountRepository"/>.</summary>
public sealed class FakeAccountRepository : IAccountRepository
{
public List<(int AccountId, decimal Balance)> Upserts { get; } = new();
public List<AccountState> GetAll() => new();
public void Upsert(AccountState account) => Upserts.Add((account.AccountId, account.AvailableBalance));
public void Delete(int accountId) { }
}
}