Files
PolyTraderSharp/tests/PolyTrader.Tests/CopyTradingEngineTests.cs
T
RichardandClaude Opus 5 fd6d62618f Kultur-Bug behoben + Threema entfernt (INotificationSink)
- TraderMonitorService las API-Preise kulturabhaengig: unter de-DE wurde aus
  "0.53" der Wert 53 (Faktor-100-Fehler im Einstandspreis). Nutzt jetzt den
  bereits vorhandenen invarianten Helper ParseDecimal.
- Gleiche Fehlerklasse in PolymarketClobClient (6x) und MasterTraderAnalyticsJob
  vorsorglich auf InvariantCulture gestellt.
- Neuer Regressionstest ApiNumberParsingTests (10 Faelle unter erzwungener de-DE-Kultur).
- Threema komplett entfernt (Entscheidung Richard): ThreemaService, vendorte
  Bibliothek libs/Threema-MsgApi-Net-Core, ServerSettings-Block, DI-Verdrahtung.
- Ersetzt durch neutrale INotificationSink (No-Throw-Vertrag) + LogNotificationSink
  als Uebergang; RocketChat/Telegram folgen spaeter.
- Entfernt nebenbei libsodium 1.0.16, die einzige Registry-Nutzung im Build,
  den HttpListener-Webhook und System.Web.HttpUtility (alles Linux-Hindernisse).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 12:01:13 +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 notify = new FakeNotificationSink();
var journal = new FakeDecisionJournal();
var orderEvents = new FakeOrderEventLog();
var ladder = new SellLadderService(copy, state, clob, logger, notify, 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);
}
}
}