Files
PolyTraderSharp/tests/PolyTrader.Tests/SellLogicTests.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

204 lines
7.0 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 PolyTrader.Modules.CopyTrading.Logic;
using Xunit;
using static PolyTrader.Modules.CopyTrading.Logic.SellLogic;
namespace PolyTrader.Tests
{
/// <summary>
/// Sicherheitsnetz für die SELL-Logik: Proportionalität (aktuelles Verhalten, Phase-2-Umbau)
/// und die Eskalationsleiter (Phase 0.1 Preis-Stufen, Floor-Clamping).
/// </summary>
public class SellLogicTests
{
// ---------------- SellProportion ----------------
[Fact]
public void SellProportion_is_share_over_total_before_sell()
{
// Master hält noch 300, verkauft 200 -> 200/500 = 0.40
Assert.Equal(0.40m, SellProportion(masterSharesRemaining: 300m, signalSize: 200m));
}
[Fact]
public void SellProportion_small_partial_is_below_threshold()
{
// 498 Rest, 2 verkauft -> 0.004 < 0.30
var ratio = SellProportion(498m, 2m);
Assert.True(ratio < MinSignificantSellRatio);
}
[Fact]
public void SellProportion_full_exit_is_one()
{
Assert.Equal(1.0m, SellProportion(0m, 10m));
}
[Theory]
[InlineData(0, 0)] // gar nichts
[InlineData(-5, 5)] // Denominator 0
[InlineData(-10, 5)] // Denominator negativ
public void SellProportion_guards_non_positive_denominator(double remaining, double signal)
{
Assert.Equal(0m, SellProportion((decimal)remaining, (decimal)signal));
}
// ---------------- Eskalationsleiter ----------------
[Fact]
public void FirstLimit_normal_trader_applies_percentage_discount()
{
// 0.99 * (1 - 2%) = 0.9702
Assert.Equal(0.9702m, FirstLimit(0.99m, isHf: false, maxPriceDifferencePct: 2m));
}
[Fact]
public void FirstLimit_hf_trader_applies_fixed_half_cent_discount()
{
Assert.Equal(0.985m, FirstLimit(0.99m, isHf: true, maxPriceDifferencePct: 99m));
}
[Fact]
public void Floor_is_reference_minus_floor_pct()
{
// 0.99 * (1 - 15%) = 0.8415
Assert.Equal(0.8415m, Floor(0.99m, 15m));
}
[Fact]
public void NextPrice_steps_down_relative_when_above_floor()
{
// 0.90 * (1 - 3%) = 0.873, über Floor 0.80
Assert.Equal(0.873m, NextPrice(0.90m, stepPct: 3m, floor: 0.80m));
}
[Fact]
public void NextPrice_clamps_to_floor()
{
// 0.82 * 0.97 = 0.7954 -> unter Floor -> 0.80
Assert.Equal(0.80m, NextPrice(0.82m, 3m, 0.80m));
}
[Fact]
public void IsAtFloor_is_inclusive()
{
Assert.True(IsAtFloor(0.80m, 0.80m));
Assert.True(IsAtFloor(0.79m, 0.80m));
Assert.False(IsAtFloor(0.81m, 0.80m));
}
[Theory]
[InlineData(true, 20)] // HF-Trader: schnelles Nachpreisen
[InlineData(false, 120)] // Standard
public void LadderIntervalSeconds_hf_is_faster(bool isHf, int expected)
{
Assert.Equal(expected, LadderIntervalSeconds(isHf));
}
[Theory]
[InlineData(0.50, 0.55, 10, true)] // genau an der Schwelle (entry*1.10)
[InlineData(0.50, 0.60, 10, true)] // darüber
[InlineData(0.50, 0.54, 10, false)] // darunter
[InlineData(0.50, 0.99, 9999, false)] // Default 9999 -> nie erreicht (inaktiv)
[InlineData(0.50, 0.99, 0, false)] // 0 -> deaktiviert
public void IsProfitTargetReached_threshold(double entry, double current, double pct, bool expected)
{
Assert.Equal(expected, IsProfitTargetReached((decimal)current, (decimal)entry, (decimal)pct));
}
[Fact]
public void IsProfitTargetReached_zero_entry_is_false()
{
Assert.False(IsProfitTargetReached(0.9m, 0m, 10m));
}
// ---------------- SharesToSell (Phase 2) ----------------
[Fact]
public void SharesToSell_mirrors_partial_ratio()
{
// 40 % von 100, Rest 60 -> Teilverkauf 40
Assert.Equal(40m, SharesToSell(ourShares: 100m, sellRatio: 0.40m, minShares: 5.5m, minSellRatioPct: 10m));
}
[Fact]
public void SharesToSell_ignores_noise_below_min_ratio()
{
Assert.Equal(0m, SharesToSell(100m, 0.05m, 5.5m, 10m));
}
[Fact]
public void SharesToSell_full_exit_when_master_fully_out()
{
Assert.Equal(100m, SharesToSell(100m, 1.0m, 5.5m, 10m));
}
[Fact]
public void SharesToSell_full_exit_when_partial_would_be_dust()
{
// 30 % von 10 = 3 Shares < Minimum -> Voll-Exit
Assert.Equal(10m, SharesToSell(10m, 0.30m, 5.5m, 10m));
}
[Fact]
public void SharesToSell_full_exit_when_remainder_would_be_dust()
{
// 98 % von 100 -> Rest 2 < Minimum -> Voll-Exit
Assert.Equal(100m, SharesToSell(100m, 0.98m, 5.5m, 10m));
}
[Fact]
public void SharesToSell_zero_position_is_zero()
{
Assert.Equal(0m, SharesToSell(0m, 0.5m, 5.5m, 10m));
}
[Fact]
public void SharesToSell_exactly_at_min_ratio_is_not_ignored()
{
Assert.Equal(10m, SharesToSell(100m, 0.10m, 5.5m, 10m));
}
[Fact]
public void Ladder_walks_down_in_steps_until_floor()
{
// Simuliert den Service: von Startlimit in 3%-Schritten bis zum Floor, dann Stopp.
decimal reference = 0.99m;
decimal price = FirstLimit(reference, isHf: false, maxPriceDifferencePct: 2m); // 0.9702
decimal floor = Floor(reference, 15m); // 0.8415
int steps = 0;
while (!IsAtFloor(price, floor) && steps < 100)
{
price = NextPrice(price, LadderStepPct, floor);
steps++;
}
Assert.True(IsAtFloor(price, floor));
Assert.Equal(floor, price); // endet exakt auf dem Floor (geclamped)
Assert.InRange(steps, 1, 20); // terminiert in wenigen Schritten
}
// ----- RoundToTick (H4) -----
[Theory]
[InlineData(0.9702, 0.970)] // typischer Startlimit-Wert -> 3 Dezimalen
[InlineData(0.84150, 0.842)] // AwayFromZero an der Hälfte
[InlineData(0.5, 0.500)]
[InlineData(0.123456, 0.123)]
public void RoundToTick_rounds_to_three_decimals(double input, double expected)
{
Assert.Equal((decimal)expected, RoundToTick((decimal)input));
}
[Fact]
public void RoundToTick_makes_usdc_over_price_yield_exact_size()
{
// Kern von H4: mit gerundetem Preis geht size = usdc / price exakt auf.
decimal size = 100m;
decimal price = RoundToTick(NextPrice(0.9702m, LadderStepPct, 0.80m));
decimal usdc = size * price;
Assert.Equal(size, usdc / price);
}
}
}