CLOB-Sicherheitsnetz: BUY-Risikologik extrahiert + breit getestet; PreRedeemLimit-Skala korrigiert

Ziel (Richard): vor den CLOB-Eingriffen ein umfangreiches Testnetz, damit künftige
Änderungen keine neuen Fehler einschleusen. Reiner, verhaltensneutraler Umbau –
alte Version liegt als Rollback in Git (Commit 38f609e), siehe .agents/rules/clob.md.

- Neue pure Klasse CopyTradingRisk (Logic/): CalculateBuyOrderPrice (HF-fest /
  prozentual, gedeckelt durch MaxBuyPrice + harte 0.99), ResolveTimeBucket/
  TimeLimitPct/TimeBucketLabel/IsPositionInBucket (Zeitfenster-Exposure),
  MaxPerMarket (Markt-Budget inkl. Low-Balance-Bypass-Stufen).
- CopyTradingEngine BUY-Pfad ruft diese Funktionen jetzt statt Inline-Mathematik
  (1:1-Semantik, dedupliziert die doppelte Order-Preis-Berechnung).
- CopyTradingRiskTests: 38 Fälle über alle Zweige/Grenzwerte (HF vs. normal,
  MaxBuy-/0.99-Deckel, Bucket-Grenzen 6/24/72h, null/expired Expiry,
  Balance-Stufen 150/500). Gesamt 124 Tests grün.

Fix: PreRedeemLimit-Skalen-Korrektur (Migration FixPreRedeemLimitScale): Alt-Werte
> 1 (z. B. 99.5) werden /100 (0.995); 0 bleibt deaktiviert. Auf MySQL angewendet.

Build/Smoke grün.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-07-07 10:03:53 +02:00
co-authored by Claude Opus 4.8
parent 38f609ed31
commit 360f264ed8
5 changed files with 605 additions and 63 deletions
@@ -0,0 +1,194 @@
using System;
using PolyTrader.Modules.CopyTrading.Logic;
using PolyTraderSharp.Models;
using Xunit;
using static PolyTrader.Modules.CopyTrading.Logic.CopyTradingRisk;
namespace PolyTrader.Tests
{
/// <summary>
/// Umfangreiches Sicherheitsnetz für die reine BUY-Risikologik der Engine
/// (Limit-Preis, Zeitfenster, Markt-Budget). Diese Werte fließen direkt in CLOB-Orders
/// jede Regression hier ist teuer. Verhalten ist 1:1 aus CopyTradingEngine übernommen.
/// </summary>
public class CopyTradingRiskTests
{
// ---------------- CalculateBuyOrderPrice ----------------
[Fact]
public void OrderPrice_normal_trader_applies_percentage_markup()
{
// 0.50 + 2 % = 0.51
Assert.Equal(0.51m, CalculateBuyOrderPrice(0.50m, isHfTrader: false, maxPriceDifferencePct: 2m, maxBuyPrice: 0.98m));
}
[Fact]
public void OrderPrice_hf_trader_applies_fixed_half_cent_markup()
{
// 0.50 + 0.005 = 0.505 (Prozentwert wird ignoriert)
Assert.Equal(0.505m, CalculateBuyOrderPrice(0.50m, isHfTrader: true, maxPriceDifferencePct: 99m, maxBuyPrice: 0.98m));
}
[Fact]
public void OrderPrice_is_capped_by_max_buy_price()
{
// desired 0.50*2 = 1.00, MaxBuy 0.80 -> 0.80
Assert.Equal(0.80m, CalculateBuyOrderPrice(0.50m, false, 100m, 0.80m));
}
[Fact]
public void OrderPrice_never_exceeds_hard_cap_099()
{
// desired 0.98*1.05 = 1.029, MaxBuy 1.00 -> Min = 1.00 -> hart auf 0.99
Assert.Equal(0.99m, CalculateBuyOrderPrice(0.98m, false, 5m, 1.00m));
}
[Fact]
public void OrderPrice_hard_cap_beats_higher_max_buy_price()
{
Assert.Equal(0.99m, CalculateBuyOrderPrice(0.995m, true, 0m, 1.00m));
}
[Fact]
public void OrderPrice_zero_markup_returns_signal_price()
{
Assert.Equal(0.42m, CalculateBuyOrderPrice(0.42m, false, 0m, 0.98m));
}
[Fact]
public void OrderPrice_exactly_099_stays()
{
Assert.Equal(0.99m, CalculateBuyOrderPrice(0.99m, false, 0m, 0.99m));
}
// ---------------- ResolveTimeBucket ----------------
[Theory]
[InlineData(-1.0, TimeBucket.Under6h)] // bereits abgelaufen
[InlineData(0.0, TimeBucket.Under6h)]
[InlineData(5.99, TimeBucket.Under6h)]
[InlineData(6.0, TimeBucket.Under24h)] // Grenze: < 6 ist false
[InlineData(23.99, TimeBucket.Under24h)]
[InlineData(24.0, TimeBucket.Under72h)]
[InlineData(71.99, TimeBucket.Under72h)]
[InlineData(72.0, TimeBucket.Over72h)]
[InlineData(999999.0, TimeBucket.Over72h)]
public void ResolveTimeBucket_maps_hours_to_bucket(double hoursLeft, TimeBucket expected)
{
Assert.Equal(expected, ResolveTimeBucket(hoursLeft));
}
// ---------------- TimeLimitPct / Label ----------------
[Fact]
public void TimeLimitPct_maps_each_bucket_to_its_setting()
{
var s = new CopyTradingAccountSettings
{
perMaxTime6h = 1m,
perMaxTime24h = 2m,
perMaxTime72h = 3m,
perMaxTimeNone = 4m
};
Assert.Equal(1m, TimeLimitPct(TimeBucket.Under6h, s));
Assert.Equal(2m, TimeLimitPct(TimeBucket.Under24h, s));
Assert.Equal(3m, TimeLimitPct(TimeBucket.Under72h, s));
Assert.Equal(4m, TimeLimitPct(TimeBucket.Over72h, s));
}
[Theory]
[InlineData(TimeBucket.Under6h, "< 6h")]
[InlineData(TimeBucket.Under24h, "< 24h")]
[InlineData(TimeBucket.Under72h, "< 72h")]
[InlineData(TimeBucket.Over72h, "> 72h")]
public void TimeBucketLabel_matches_bucket(TimeBucket bucket, string expected)
{
Assert.Equal(expected, TimeBucketLabel(bucket));
}
// ---------------- IsPositionInBucket ----------------
private static readonly DateTime Now = new(2026, 7, 7, 12, 0, 0, DateTimeKind.Utc);
[Fact]
public void PositionInBucket_null_expiry_counts_only_for_over72h()
{
Assert.True(IsPositionInBucket(null, TimeBucket.Over72h, Now));
Assert.False(IsPositionInBucket(null, TimeBucket.Under6h, Now));
Assert.False(IsPositionInBucket(null, TimeBucket.Under24h, Now));
Assert.False(IsPositionInBucket(null, TimeBucket.Under72h, Now));
}
[Theory]
[InlineData(3, TimeBucket.Under6h, true)]
[InlineData(3, TimeBucket.Under24h, false)]
[InlineData(12, TimeBucket.Under24h, true)]
[InlineData(12, TimeBucket.Under6h, false)]
[InlineData(48, TimeBucket.Under72h, true)]
[InlineData(48, TimeBucket.Under24h, false)]
[InlineData(100, TimeBucket.Over72h, true)]
[InlineData(100, TimeBucket.Under72h, false)]
public void PositionInBucket_classifies_by_hours_to_expiry(int hoursToExpiry, TimeBucket bucket, bool expected)
{
var expiry = Now.AddHours(hoursToExpiry);
Assert.Equal(expected, IsPositionInBucket(expiry, bucket, Now));
}
[Fact]
public void PositionInBucket_boundaries_are_consistent_with_engine()
{
// exakt 6h: nicht < 6h, aber in < 24h
Assert.False(IsPositionInBucket(Now.AddHours(6), TimeBucket.Under6h, Now));
Assert.True(IsPositionInBucket(Now.AddHours(6), TimeBucket.Under24h, Now));
// exakt 72h: nicht < 72h, aber > 72h
Assert.False(IsPositionInBucket(Now.AddHours(72), TimeBucket.Under72h, Now));
Assert.True(IsPositionInBucket(Now.AddHours(72), TimeBucket.Over72h, Now));
}
[Fact]
public void PositionInBucket_expired_position_counts_as_under6h()
{
Assert.True(IsPositionInBucket(Now.AddHours(-5), TimeBucket.Under6h, Now));
Assert.False(IsPositionInBucket(Now.AddHours(-5), TimeBucket.Over72h, Now));
}
// ---------------- MaxPerMarket ----------------
[Fact]
public void MaxPerMarket_normal_balance_uses_percentage()
{
// 1000 * 5 % = 50
Assert.Equal(50m, MaxPerMarket(totalBalance: 1000m, availableBalance: 800m, perMarketLimitPct: 5m));
}
[Fact]
public void MaxPerMarket_tiny_balance_below_150_capped_at_1_20()
{
Assert.Equal(1.20m, MaxPerMarket(100m, 800m, 5m));
// von verfügbarem Guthaben begrenzt
Assert.Equal(0.5m, MaxPerMarket(100m, 0.5m, 5m));
}
[Fact]
public void MaxPerMarket_below_500_capped_at_3()
{
Assert.Equal(3.0m, MaxPerMarket(300m, 200m, 5m));
Assert.Equal(1m, MaxPerMarket(300m, 1m, 5m));
}
[Fact]
public void MaxPerMarket_negative_available_clamps_to_zero()
{
Assert.Equal(0m, MaxPerMarket(100m, -20m, 5m));
}
[Theory]
[InlineData(150, 3.0)] // genau 150 -> Stufe < 500
[InlineData(500, 25.0)] // genau 500 -> normale Prozentregel (500*5%)
public void MaxPerMarket_tier_boundaries(double balance, double expected)
{
// available großzügig, damit die Stufen-Caps nicht durch Verfügbarkeit greifen
Assert.Equal((decimal)expected, MaxPerMarket((decimal)balance, 100000m, 5m));
}
}
}