Files
PolyTraderSharp/tests/PolyTrader.Tests/CopyTradingEngineTests.cs
T
RichardandClaude Opus 4.8 bf3e012f8b Supervisor S-0a/b: Entscheidungsjournal, Order-Events, SignalId, JSONL-Logs
Datenfundament fuer die KI-Analyse (docs/konzepte/KONZEPT-Modul-Supervisor.md, Phase S-0).
Sofortnutzen auch ohne KI: abfragbare Rejects, rekonstruierbare Order-Ketten.

- core_decision_journal (DecisionRecord): JEDE Handelsentscheidung strukturiert -
  Decision (Executed/Rejected/Skipped/Failed) + ReasonCode-Enum (als String, erweiterbar)
  + Kontext-JSON (kulturinvariant) + MarketSlug/EndDate (counterfactual-ready).
- core_order_events (OrderEventRecord): Order-Lifecycle als Daten (Placed/PlaceFailed/
  LadderStart/LadderStep/FloorReplaced/DustAbort ...) inkl. CLOB-Response.
- SignalId (GUID) auf CopySignal, durchgereicht bis ClosedTrade + Core-TradeRecord
  (Dual-Write) und in die Leiter (ExitLadderState.SignalId) -> Dossier-Korrelation.
- CopyTradingEngine: ~20 Entscheidungs-Sites journalisieren zusaetzlich zum Freitext-Log
  (Mode/SellOnly/H3/MaxBuyPrice/PerMaster/TimeLimit/Budget/Balance/Minimum/SpamBlock/
  Leiter/Ownership/NotFound/Teilverkauf/Inkonsistenz/Grace/Demo-Fill/-Close/Live-BUY).
- SellLadderService: Order-Events fuer Start/Step/Floor-Replace/Dust; IOrderEventLog injiziert.
- TerminalLogger: JSONL-Sink (eine Datei/Tag, LogJson pur) + CorrelationId-Support. Dual-Sink.
- Journal-Writes fehlertolerant (brechen den Geld-Pfad nie).
- Migrationen offline generiert UND angewendet (rein additiv): Core (Widen F1 + Journal),
  CopyTrading (SignalId), RF (Initial) - DB war bis AddMarketTakerFeeBps migriert, verifiziert.
- Launcher-Designer: Account-Spalten-Instanziierung wiederhergestellt (war bei Designer-
  Reserialisierung verloren gegangen -> NullReference im Smoke).

Tests: +5 (LogJson, Journal-Roundtrips, SignalId) + Journal-Assertions in Engine-Tests.
Build 0 Fehler, 336 Tests gruen, --smoke-ui komplett gruen (alle Views inkl. RF gegen echte Tabellen).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 18:48:25 +02:00

179 lines
7.8 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!;
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<CopySignal>();
var closedCh = Channel.CreateUnbounded<ClosedTrade>();
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);
}
}
}