- 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>
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 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);
|
||
}
|
||
}
|
||
}
|