Files
PolyTraderSharp/tests/PolyTrader.Tests/CopyTradingEngineTests.cs
T
RichardandClaude Opus 4.8 f4045f08e1 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>
2026-07-09 19:38:11 +02:00

170 lines
7.1 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
}
}
}