- SellLogic.IsProfitTargetReached (pure, getestet): currentPrice >= entry*(1+pct/100); pct<=0 oder Default 9999 = inaktiv. - Ladder-Start-Logik konsolidiert: SellLadderService.StartLadderAsync ist jetzt die gemeinsame Quelle fuer Master-SELLs (Engine) UND eigene Exits (Profit-Target). SellLadderService als Singleton+Hosted registriert; Engine + TraderMonitor injizieren es. Engine-SELL-Block ruft nur noch StartLadderAsync (verhaltensgleich). - TraderMonitorService.CheckProfitTargetsAsync im 30s-Live-Sync: erreicht eine Live-Position ihre Schwelle, Exit ueber die Leiter (Startlimit = aktueller Preis, ExitReason "Profit Target"). PreRedeemLimit hat Vorrang. Dormant, da ProfitTarget projektweit 9999. 169 Tests gruen. Build/Smoke gruen. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
135 lines
4.7 KiB
C#
135 lines
4.7 KiB
C#
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));
|
||
}
|
||
|
||
[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
|
||
}
|
||
}
|
||
}
|