Weiter im Muster extrahieren->testen (verhaltensneutral, clob.md): - CopyTradingRisk.IsBelowPolymarketMinimum (Shares < 5.5 || USDC < 0.10) – BUY-Micro-Order-Filter verdrahtet + Tests. - SellLogic.SellProportion (signalSize/(remaining+signalSize), Denominator<=0 -> 0) + Konstante MinSignificantSellRatio (0.30); SELL-Proportionalitätscheck der Engine ruft sie jetzt (Charakterisierung, sichert Phase-2-Umbau ab). - SellLogic-Eskalationsleiter (Phase 0.1, NOCH NICHT verdrahtet): FirstLimit (HF-fest/prozentual), Floor (SellFloorPct), NextPrice (relative Stufe, auf Floor geclamped), IsAtFloor. Reine Grundlage fuer das spaetere SELL-Wiring. - Tests: CopyTradingRisk-Minimum (5 Faelle) + SellLogicTests (Proportion + Leiter). Gesamt 154 gruen. Build/Smoke gruen. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
208 lines
8.0 KiB
C#
208 lines
8.0 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));
|
||
}
|
||
|
||
// ---------------- 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));
|
||
}
|
||
}
|
||
}
|