Files
RichardandClaude Opus 4.8 8252219163 Slice 3 (Fable-Fixes): K3 Demo-Resolution + M5 Demo-Score/Auto-Pause
K3 – Demo-Auto-Close bei Marktauflösung funktioniert wieder:
- System-SELLs (TraderId==0) sind vom Ownership-Check ausgenommen. Regel in reine
  CopyTradingRisk.IsAuthorizedSell(signalTraderId, positionSourceTraderId) extrahiert
  (System ODER Besitzer) und in der Engine genutzt. Vorher lehnte der Check jedes
  System-Close ab (Copy-Positionen haben SourceTraderId>0) -> Demo-Positionen
  schlossen bei Resolution nie, 30s-Loop feuerte endlos.
- Resolved-Cache (_resolvedDemoMarkets) in PollDemoExpirationsAsync: aufgeloeste
  Maerkte werden nicht erneut angefragt (kein Log-Spam / keine unnoetige API-Last).

M5 – Auto-Pause in der Demo-Phase nicht mehr wirkungslos:
- Anzeige-Spalten (CopyPnl30d etc.) zeigen Live-Kennzahlen, sobald vorhanden, sonst
  Demo-Kennzahlen -> Demo-Validierung ist sichtbar statt leer.
- Harte Auto-Pause wirkt bewusst NUR auf Live-Ergebnissen.
- Kadenz: stuendlicher Light-Check (Auto-Pause) statt nur alle 12h; schwere
  History-Analyse bleibt 12h (schliessen sich pro Tick aus, kein Doppellauf).

Tests: 5 neue (IsAuthorizedSell). Build 0 Fehler, 223 Tests gruen, --smoke-ui ok.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 13:33:24 +02:00

221 lines
8.8 KiB
C#
Raw Permalink 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));
}
// ---------------- 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));
}
}
}