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>
179 lines
7.8 KiB
C#
179 lines
7.8 KiB
C#
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);
|
||
}
|
||
}
|
||
}
|