Files
PolyTraderSharp/tests/PolyTrader.Tests/CopyTradingRiskTests.cs
T
RichardandClaude Opus 4.8 581755029a Testnetz: Micro-Order-Minimum + SELL-Proportionalität extrahiert; SELL-Leiter (Phase 0.1) vorbereitet
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>
2026-07-07 11:03:44 +02:00

208 lines
8.0 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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));
}
}
}