using System; using PolyTrader.Modules.CopyTrading.Logic; using PolyTraderSharp.Models; using Xunit; using static PolyTrader.Modules.CopyTrading.Logic.CopyTradingRisk; namespace PolyTrader.Tests { /// /// 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. /// 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)); } // ---------------- IsBelowPolymarketMinimum ---------------- [Theory] [InlineData(5.5, 1.0, false)] // genau an der Share-Grenze -> ok [InlineData(5.4, 1.0, true)] // zu wenige Shares [InlineData(6.0, 0.10, false)] // genau an der USDC-Grenze -> ok [InlineData(6.0, 0.09, true)] // zu wenig USDC [InlineData(100.0, 50.0, false)] public void IsBelowPolymarketMinimum_enforces_share_and_usdc_floor(double shares, double usdc, bool expected) { Assert.Equal(expected, IsBelowPolymarketMinimum((decimal)shares, (decimal)usdc)); } // ---------------- IsAuthorizedSell (K3) ---------------- [Theory] [InlineData(0, 7, true)] // System-Signal schließt fremde Copy-Position (Demo-Resolution) [InlineData(0, 0, true)] // System-Signal, keine Herkunft [InlineData(7, 7, true)] // Master schließt eigene Position [InlineData(7, 9, false)] // Master 7 darf Position von Master 9 nicht schließen [InlineData(9, 0, false)] // echter Master darf herrenlose Position nicht kapern public void IsAuthorizedSell_allows_owner_and_system(int signalTraderId, int positionSourceTraderId, bool expected) { Assert.Equal(expected, IsAuthorizedSell(signalTraderId, positionSourceTraderId)); } } }