- 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>
214 lines
8.9 KiB
C#
214 lines
8.9 KiB
C#
using System;
|
|
using System.Linq;
|
|
using System.Threading.Tasks;
|
|
using PolyTrader.Tests.Fakes;
|
|
using PolyTraderSharp;
|
|
using PolyTraderSharp.Models;
|
|
using PolyTraderSharp.Services;
|
|
using Xunit;
|
|
|
|
namespace PolyTrader.Tests
|
|
{
|
|
/// <summary>
|
|
/// Integrationstests der SELL-Eskalationsleiter über einen gemockten CLOB-Client
|
|
/// (Slice 1 der Fable-Fixes): atomarer Leiter-Claim (H1) und Floor-Robustheit (K1).
|
|
/// Diese Fehler entstehen zwischen Services und sind durch reine Unit-Tests nicht fangbar.
|
|
/// </summary>
|
|
public class SellLadderServiceTests
|
|
{
|
|
private const string Tok = "tok-1";
|
|
|
|
private static (SellLadderService svc, CopyTradingState copy, TradingState state, FakeClobClient clob, FakePositionRepository repo)
|
|
Build(FakeClobClient? clob = null)
|
|
{
|
|
var copy = new CopyTradingState();
|
|
var state = new TradingState();
|
|
clob ??= new FakeClobClient();
|
|
var logger = new TerminalLogger();
|
|
var notify = new FakeNotificationSink();
|
|
var repo = new FakePositionRepository();
|
|
var svc = new SellLadderService(copy, state, clob, logger, notify, repo, new FakeOrderEventLog());
|
|
return (svc, copy, state, clob, repo);
|
|
}
|
|
|
|
private static (AccountState account, Position pos) LiveAccountWithPosition(TradingState state, decimal size = 100m)
|
|
{
|
|
var account = new AccountState { AccountId = 1, Name = "Live-Test", IsDemo = false };
|
|
var pos = new Position
|
|
{
|
|
TokenId = Tok,
|
|
MarketQuestion = "Wird X passieren?",
|
|
SourceTraderId = 7,
|
|
Size = size,
|
|
EntryPrice = 0.40m,
|
|
CurrentPrice = 0.50m,
|
|
AmountUsd = 40m
|
|
};
|
|
account.OpenPositions[pos.TokenId] = pos;
|
|
state.Accounts[account.AccountId] = account;
|
|
return (account, pos);
|
|
}
|
|
|
|
// ---------- H1: Atomarer Claim ----------
|
|
|
|
[Fact]
|
|
public async Task StartLadder_places_one_order_and_registers_ladder()
|
|
{
|
|
var (svc, copy, state, clob, _) = Build();
|
|
var (account, pos) = LiveAccountWithPosition(state);
|
|
|
|
bool ok = await svc.StartLadderAsync(account, pos, 0.50m, 7, false, 5m, 15m, false, "Master SELL");
|
|
|
|
Assert.True(ok);
|
|
Assert.True(copy.ExitLadders.ContainsKey("1_" + Tok));
|
|
Assert.True(pos.ExitPending);
|
|
Assert.Single(clob.Placed);
|
|
Assert.Equal("SELL", clob.Placed[0].Side);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Second_StartLadder_for_same_position_is_idempotent_no_second_order()
|
|
{
|
|
var (svc, copy, state, clob, _) = Build();
|
|
var (account, pos) = LiveAccountWithPosition(state);
|
|
|
|
bool first = await svc.StartLadderAsync(account, pos, 0.50m, 7, false, 5m, 15m, false, "Master SELL");
|
|
bool second = await svc.StartLadderAsync(account, pos, 0.50m, 7, false, 5m, 15m, false, "Profit Target");
|
|
|
|
Assert.True(first);
|
|
Assert.False(second); // zweiter Aufruf verliert den Claim
|
|
Assert.Single(clob.Placed); // KEINE zweite GTC-Order
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Concurrent_StartLadder_starts_exactly_one_ladder()
|
|
{
|
|
var (svc, copy, state, clob, _) = Build();
|
|
var (account, pos) = LiveAccountWithPosition(state);
|
|
|
|
var results = await Task.WhenAll(
|
|
Enumerable.Range(0, 8).Select(_ =>
|
|
svc.StartLadderAsync(account, pos, 0.50m, 7, false, 5m, 15m, false, "race")));
|
|
|
|
Assert.Equal(1, results.Count(r => r)); // genau ein Gewinner
|
|
Assert.Single(clob.Placed); // genau eine Order
|
|
Assert.True(copy.ExitLadders.ContainsKey("1_" + Tok));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task StartLadder_when_ladder_already_exists_readds_position_as_exitpending()
|
|
{
|
|
// H1-Waisen-Schutz: Engine entfernt die Position vor StartLadderAsync. Verliert dieser Aufruf
|
|
// den Claim (andere Leiter läuft schon), muss die Position trotzdem als ExitPending präsent bleiben.
|
|
var (svc, copy, state, clob, _) = Build();
|
|
var (account, pos) = LiveAccountWithPosition(state);
|
|
|
|
copy.ExitLadders["1_" + Tok] = new ExitLadderState { AccountId = 1, TokenId = Tok, Floor = 0.40m, CurrentLimit = 0.45m };
|
|
account.OpenPositions.TryRemove(Tok, out _); // Engine hat entfernt
|
|
pos.ExitPending = false;
|
|
|
|
bool ok = await svc.StartLadderAsync(account, pos, 0.50m, 7, false, 5m, 15m, false, "Master SELL");
|
|
|
|
Assert.False(ok);
|
|
Assert.True(account.OpenPositions.ContainsKey(Tok)); // wieder da
|
|
Assert.True(pos.ExitPending);
|
|
Assert.Empty(clob.Placed); // keine neue Order
|
|
}
|
|
|
|
[Fact]
|
|
public async Task StartLadder_releases_claim_when_place_fails()
|
|
{
|
|
var clob = new FakeClobClient { PlaceResult = "ERROR: insufficient balance" };
|
|
var (svc, copy, state, _, _) = Build(clob);
|
|
var (account, pos) = LiveAccountWithPosition(state);
|
|
|
|
bool ok = await svc.StartLadderAsync(account, pos, 0.50m, 7, false, 5m, 15m, false, "Master SELL");
|
|
|
|
Assert.False(ok);
|
|
Assert.False(copy.ExitLadders.ContainsKey("1_" + Tok)); // Claim zurückgegeben
|
|
Assert.False(pos.ExitPending);
|
|
Assert.True(account.OpenPositions.ContainsKey(Tok)); // Position bleibt handelbar
|
|
}
|
|
|
|
// ---------- K1: Floor-Robustheit ----------
|
|
|
|
private static ExitLadderState FloorLadder() => new()
|
|
{
|
|
AccountId = 1,
|
|
TokenId = Tok,
|
|
SourceTraderId = 7,
|
|
MarketQuestion = "Wird X passieren?",
|
|
ReferencePrice = 0.50m,
|
|
CurrentLimit = 0.40m,
|
|
Floor = 0.40m, // CurrentLimit == Floor -> IsAtFloor
|
|
IsHf = false,
|
|
Attempt = 5,
|
|
LastActionAt = DateTime.UtcNow.AddMinutes(-10) // Intervall (120s) längst vorbei
|
|
};
|
|
|
|
[Fact]
|
|
public async Task Floor_replaces_order_when_none_resting()
|
|
{
|
|
var (svc, copy, state, clob, _) = Build();
|
|
var (account, pos) = LiveAccountWithPosition(state);
|
|
var ladder = FloorLadder();
|
|
copy.ExitLadders["1_" + Tok] = ladder;
|
|
// FakeClob liefert für den Token keine offenen Orders -> Order ist weg (z.B. Cleanup/extern).
|
|
|
|
await svc.ProcessLadderAsync("1_" + Tok, ladder);
|
|
|
|
Assert.Single(clob.Placed);
|
|
Assert.Equal("SELL", clob.Placed[0].Side);
|
|
Assert.Equal(0.40m, clob.Placed[0].Price); // am Floor neu platziert
|
|
Assert.True(copy.PendingOrderTimestamps.ContainsKey("1_" + Tok));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Floor_holds_when_order_still_resting()
|
|
{
|
|
var (svc, copy, state, clob, _) = Build();
|
|
var (account, pos) = LiveAccountWithPosition(state);
|
|
var ladder = FloorLadder();
|
|
ladder.FloorNotified = true; // Floor-Benachrichtigung überspringen
|
|
copy.ExitLadders["1_" + Tok] = ladder;
|
|
clob.OpenOrdersByAsset[Tok] = new() { ("oid-1", "SELL", 0.40m) };
|
|
|
|
await svc.ProcessLadderAsync("1_" + Tok, ladder);
|
|
|
|
Assert.Empty(clob.Placed); // Order ruht -> nicht neu platzieren
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Ladder_completes_when_position_gone()
|
|
{
|
|
var (svc, copy, state, clob, _) = Build();
|
|
var (account, pos) = LiveAccountWithPosition(state);
|
|
var ladder = FloorLadder();
|
|
copy.ExitLadders["1_" + Tok] = ladder;
|
|
account.OpenPositions.TryRemove(Tok, out _); // Sync hat den Fill erkannt
|
|
|
|
await svc.ProcessLadderAsync("1_" + Tok, ladder);
|
|
|
|
Assert.False(copy.ExitLadders.ContainsKey("1_" + Tok)); // Leiter beendet
|
|
Assert.Empty(clob.Placed);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Ladder_aborts_on_dust_remainder_below_minimum()
|
|
{
|
|
// H4: Dust-Rest unter dem Minimum -> Leiter beenden statt endloser Sub-Minimum-Rejects.
|
|
var (svc, copy, state, clob, _) = Build();
|
|
var (account, pos) = LiveAccountWithPosition(state, size: 3m); // < MinShares (5.5)
|
|
pos.ExitPending = true;
|
|
var ladder = FloorLadder();
|
|
copy.ExitLadders["1_" + Tok] = ladder;
|
|
|
|
await svc.ProcessLadderAsync("1_" + Tok, ladder);
|
|
|
|
Assert.False(copy.ExitLadders.ContainsKey("1_" + Tok)); // Leiter beendet
|
|
Assert.False(pos.ExitPending); // Rest handelbar/gehalten
|
|
Assert.Empty(clob.Placed); // keine Sub-Minimum-Order
|
|
}
|
|
}
|
|
}
|