Files
PolyTraderSharp/tests/PolyTrader.Tests/SellLadderServiceTests.cs
T
RichardandClaude Opus 4.8 bdcd3641e4 Slice 4 (Fable-Fixes): H4/M1/M2/M3-min/M4/M6 + Doku
H4 – Rundungs-Dust-Reject-Schleife:
- SellLogic.RoundToTick (0.001, AwayFromZero, wie CalculateExactOrderAmounts). Die
  Leiter platziert Preise gerundet -> usdc = size × Preis geht exakt auf (kein Dust).
- ProcessLadderAsync bricht ab, wenn pos.Size < MinShares (Dust-Rest gehalten,
  ExitPending=false), statt endlos Sub-Minimum-Orders zu schicken.

M1 – GlobalPnl-Doppelzaehlung: in beiden Live-Close-Bloecken jetzt NUR innerhalb des
_processedClosures-Dedup-Guards gebucht (API-Lag zaehlte sonst doppelt).
M2 – TokenId in beiden Live-Close-Records gesetzt (sonst greift die Dedup nach Neustart nicht).
M3 (Minimum) – TradeId-Init: serverseitiges GetMaxTradeId() statt Full-Table-Find(_=>true).Max();
  Fehlschlag wird laut geloggt statt still geschluckt. (Autoincrement-Migration bewusst als
  Follow-up aufgeschoben – Schema-Aenderung an der Trade-Persistenz erst im Zielland live verifizieren.)
M4 – MongoExportParser ProfitTarget-Default 50 -> 9999 (Take-Profit bleibt dormant).
M6 – Fee-Satz (real oder Kategorie-Fallback, FeeModel.ResolveBps) in alle signierten Orders:
  Engine-BUY, SELL-Leiter (Start/Step/Floor), PreRedeem (WSS + REST). API-gated, im Zielland verifizieren.
Doku – stale [Description]: ProfitTarget/SellFloorPct als IMPLEMENTIERT markiert.

Tests: +10 (RoundToTick, Dust-Abbruch, ResolveBps). Build 0 Fehler, 233 gruen, --smoke-ui ok.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 14:03:00 +02:00

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 threema = new ThreemaService(logger, new JobManager());
var repo = new FakePositionRepository();
var svc = new SellLadderService(copy, state, clob, logger, threema, repo);
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; // Threema-Notify ü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
}
}
}