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>
195 lines
7.4 KiB
C#
195 lines
7.4 KiB
C#
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));
|
||
}
|
||
}
|
||
}
|