diff --git a/src/PolyTrader.Modules.CopyTrading/Logic/CopyTradingRisk.cs b/src/PolyTrader.Modules.CopyTrading/Logic/CopyTradingRisk.cs new file mode 100644 index 0000000..4fd4f20 --- /dev/null +++ b/src/PolyTrader.Modules.CopyTrading/Logic/CopyTradingRisk.cs @@ -0,0 +1,97 @@ +using System; +using PolyTraderSharp.Models; + +namespace PolyTrader.Modules.CopyTrading.Logic +{ + /// + /// Reine, seiteneffektfreie Entscheidungs-/Rechenlogik des Copytrading-BUY-Pfads. + /// Bewusst aus CopyTradingEngine herausgezogen, damit die risikorelevanten + /// Berechnungen (Limit-Preis, Zeitfenster, Markt-Budget) vollständig unit-getestet + /// werden können — CLOB-Eingriffe sind hochkritisch (siehe .agents/rules/clob.md). + /// Verhalten ist 1:1 aus der Engine übernommen; Änderungen hier immer mit Tests. + /// + public static class CopyTradingRisk + { + /// Harte Preisobergrenze für jede BUY-Order (nie über 0.99 kaufen). + public const decimal MaxOrderPriceCap = 0.99m; + + /// Fester Limit-Aufschlag für HF-Trader (0,5 ¢). + public const decimal HfLimitOffset = 0.005m; + + /// + /// Limit-Preis einer BUY-Order: HF-Trader bekommen einen festen Aufschlag von 0.005, + /// sonst einen prozentualen Aufschlag () über dem + /// Signalpreis. Gedeckelt durch und die harte Grenze 0.99. + /// + public static decimal CalculateBuyOrderPrice(decimal signalPrice, bool isHfTrader, decimal maxPriceDifferencePct, decimal maxBuyPrice) + { + decimal desired = isHfTrader + ? signalPrice + HfLimitOffset + : signalPrice * (1.0m + maxPriceDifferencePct / 100.0m); + + decimal price = Math.Min(desired, maxBuyPrice); + if (price > MaxOrderPriceCap) price = MaxOrderPriceCap; + return price; + } + + /// Restlaufzeit-Klasse eines Marktes (Zeit bis Marktschluss). + public enum TimeBucket { Under6h, Under24h, Under72h, Over72h } + + public static TimeBucket ResolveTimeBucket(double hoursLeft) => + hoursLeft < 6 ? TimeBucket.Under6h : + hoursLeft < 24 ? TimeBucket.Under24h : + hoursLeft < 72 ? TimeBucket.Under72h : + TimeBucket.Over72h; + + /// Menschenlesbares Label passend zum -Ergebnis. + public static string TimeBucketLabel(TimeBucket bucket) => bucket switch + { + TimeBucket.Under6h => "< 6h", + TimeBucket.Under24h => "< 24h", + TimeBucket.Under72h => "< 72h", + _ => "> 72h" + }; + + /// Das für die Zeitklasse geltende Exposure-Limit (% des Guthabens) aus den Settings. + public static decimal TimeLimitPct(TimeBucket bucket, CopyTradingAccountSettings settings) => bucket switch + { + TimeBucket.Under6h => settings.perMaxTime6h, + TimeBucket.Under24h => settings.perMaxTime24h, + TimeBucket.Under72h => settings.perMaxTime72h, + _ => settings.perMaxTimeNone + }; + + /// + /// Zählt eine offene Position anhand ihres Enddatums zur angegebenen Zeitklasse? + /// (Dedupliziert die vier Filter im Time-Limit-Check der Engine, 1:1-Semantik.) + /// + public static bool IsPositionInBucket(DateTime? expiryDate, TimeBucket bucket, DateTime nowUtc) + { + if (bucket == TimeBucket.Over72h) + return !expiryDate.HasValue || (expiryDate.Value - nowUtc).TotalHours >= 72; + + if (!expiryDate.HasValue) return false; + double h = (expiryDate.Value - nowUtc).TotalHours; + return bucket switch + { + TimeBucket.Under6h => h < 6, + TimeBucket.Under24h => h >= 6 && h < 24, + TimeBucket.Under72h => h >= 24 && h < 72, + _ => false + }; + } + + /// + /// Maximal erlaubter Einsatz pro Markt: normalerweise % + /// des Guthabens. Für kleine Konten greift ein Low-Balance-Bypass (Stufen), der den + /// verfügbaren Betrag begrenzt, damit überhaupt eine Mindest-Order möglich ist. + /// + public static decimal MaxPerMarket(decimal totalBalance, decimal availableBalance, decimal perMarketLimitPct) + { + decimal maxAllowed = totalBalance * (perMarketLimitPct / 100.0m); + if (totalBalance < 150m) maxAllowed = Math.Min(1.20m, Math.Max(availableBalance, 0m)); + else if (totalBalance < 500m) maxAllowed = Math.Min(3.0m, Math.Max(availableBalance, 0m)); + return maxAllowed; + } + } +} diff --git a/src/PolyTrader.Modules.CopyTrading/Persistence/Ef/Migrations/20260707075436_FixPreRedeemLimitScale.Designer.cs b/src/PolyTrader.Modules.CopyTrading/Persistence/Ef/Migrations/20260707075436_FixPreRedeemLimitScale.Designer.cs new file mode 100644 index 0000000..b232906 --- /dev/null +++ b/src/PolyTrader.Modules.CopyTrading/Persistence/Ef/Migrations/20260707075436_FixPreRedeemLimitScale.Designer.cs @@ -0,0 +1,270 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using PolyTrader.Modules.CopyTrading.Persistence.Ef; + +#nullable disable + +namespace PolyTrader.Modules.CopyTrading.Persistence.Ef.Migrations +{ + [DbContext(typeof(CopyTradingDbContext))] + [Migration("20260707075436_FixPreRedeemLimitScale")] + partial class FixPreRedeemLimitScale + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.13") + .HasAnnotation("Relational:MaxIdentifierLength", 64); + + MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder); + + modelBuilder.Entity("PolyTraderSharp.Models.ClosedTrade", b => + { + b.Property("TradeId") + .HasColumnType("int"); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("ClosedAt") + .HasColumnType("datetime(6)"); + + b.Property("EntryPrice") + .HasPrecision(18, 6) + .HasColumnType("decimal(18,6)"); + + b.Property("ExitPrice") + .HasPrecision(18, 6) + .HasColumnType("decimal(18,6)"); + + b.Property("ExitReason") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("IsDemo") + .HasColumnType("tinyint(1)"); + + b.Property("MarketQuestion") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("varchar(1000)"); + + b.Property("MarketSlug") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("varchar(300)"); + + b.Property("OpenedAt") + .HasColumnType("datetime(6)"); + + b.Property("Outcome") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("PnlPercent") + .HasPrecision(18, 6) + .HasColumnType("decimal(18,6)"); + + b.Property("RealizedPnl") + .HasPrecision(18, 6) + .HasColumnType("decimal(18,6)"); + + b.Property("Side") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("varchar(10)"); + + b.Property("Size") + .HasPrecision(18, 6) + .HasColumnType("decimal(18,6)"); + + b.Property("SourceTraderId") + .HasColumnType("int"); + + b.Property("TokenId") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("varchar(120)"); + + b.Property("TotalFees") + .HasPrecision(18, 6) + .HasColumnType("decimal(18,6)"); + + b.HasKey("TradeId"); + + b.HasIndex("AccountId"); + + b.HasIndex("SourceTraderId"); + + b.HasIndex("TokenId"); + + b.ToTable("mod_copytrading_closed_trades", (string)null); + }); + + modelBuilder.Entity("PolyTraderSharp.Models.CopyTradingAccountSettings", b => + { + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("MaxBuyPrice") + .HasPrecision(18, 6) + .HasColumnType("decimal(18,6)"); + + b.Property("MaxPriceDifference") + .HasPrecision(18, 6) + .HasColumnType("decimal(18,6)"); + + b.Property("MaxSpreadPct") + .HasPrecision(18, 6) + .HasColumnType("decimal(18,6)"); + + b.Property("MinSellRatioPct") + .HasPrecision(18, 6) + .HasColumnType("decimal(18,6)"); + + b.Property("PerMarketLimit") + .HasPrecision(18, 6) + .HasColumnType("decimal(18,6)"); + + b.Property("PerMasterLimit") + .HasPrecision(18, 6) + .HasColumnType("decimal(18,6)"); + + b.Property("PreRedeemLimit") + .HasPrecision(18, 6) + .HasColumnType("decimal(18,6)"); + + b.Property("ProfitTarget") + .HasPrecision(18, 6) + .HasColumnType("decimal(18,6)"); + + b.Property("SellFloorPct") + .HasPrecision(18, 6) + .HasColumnType("decimal(18,6)"); + + b.Property("perMaxTime24h") + .HasPrecision(18, 6) + .HasColumnType("decimal(18,6)"); + + b.Property("perMaxTime6h") + .HasPrecision(18, 6) + .HasColumnType("decimal(18,6)"); + + b.Property("perMaxTime72h") + .HasPrecision(18, 6) + .HasColumnType("decimal(18,6)"); + + b.Property("perMaxTimeNone") + .HasPrecision(18, 6) + .HasColumnType("decimal(18,6)"); + + b.HasKey("AccountId"); + + b.ToTable("mod_copytrading_account_settings", (string)null); + }); + + modelBuilder.Entity("PolyTraderSharp.Models.MasterTraderHistoryRecord", b => + { + b.Property("Id") + .HasMaxLength(64) + .HasColumnType("varchar(64)"); + + b.Property("ClosedAt") + .HasColumnType("datetime(6)"); + + b.Property("RealizedPnl") + .HasPrecision(18, 6) + .HasColumnType("decimal(18,6)"); + + b.Property("TokenId") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("varchar(120)"); + + b.Property("TraderId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ClosedAt"); + + b.HasIndex("TraderId"); + + b.ToTable("mod_copytrading_mt_history", (string)null); + }); + + modelBuilder.Entity("PolyTraderSharp.Models.TrackedTrader", b => + { + b.Property("Id") + .HasColumnType("int"); + + b.Property("AssignedAccountIds") + .IsRequired() + .HasColumnType("text"); + + b.Property("AutoPauseEnabled") + .HasColumnType("tinyint(1)"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("varchar(64)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("varchar(1000)"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("IsHidden") + .HasColumnType("tinyint(1)"); + + b.Property("MakerEntry") + .HasColumnType("tinyint(1)"); + + b.Property("Reasoning") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("varchar(1000)"); + + b.Property("TotalPnl") + .HasColumnType("double"); + + b.Property("TotalTrades") + .HasColumnType("int"); + + b.Property("WalletAddress") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("varchar(128)"); + + b.Property("WinningTrades") + .HasColumnType("int"); + + b.Property("Winrate30t") + .HasColumnType("double"); + + b.HasKey("Id"); + + b.ToTable("mod_copytrading_traders", (string)null); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/PolyTrader.Modules.CopyTrading/Persistence/Ef/Migrations/20260707075436_FixPreRedeemLimitScale.cs b/src/PolyTrader.Modules.CopyTrading/Persistence/Ef/Migrations/20260707075436_FixPreRedeemLimitScale.cs new file mode 100644 index 0000000..14cb4e1 --- /dev/null +++ b/src/PolyTrader.Modules.CopyTrading/Persistence/Ef/Migrations/20260707075436_FixPreRedeemLimitScale.cs @@ -0,0 +1,26 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace PolyTrader.Modules.CopyTrading.Persistence.Ef.Migrations +{ + /// + public partial class FixPreRedeemLimitScale : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + // Skalen-Korrektur: PreRedeemLimit wird im Code gegen den 0-1-Preis verglichen + // (z. B. 0.995 = 99,5 ¢). Migrierte Alt-Werte lagen auf Cent-Skala (z. B. 99.5) + // und haetten NIE getriggert. Werte > 1 durch 100 teilen; 0 (=deaktiviert) bleibt 0. + migrationBuilder.Sql( + "UPDATE mod_copytrading_account_settings SET PreRedeemLimit = PreRedeemLimit / 100 WHERE PreRedeemLimit > 1;"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + + } + } +} diff --git a/src/PolyTrader.Modules.CopyTrading/Services/CopyTradingEngine.cs b/src/PolyTrader.Modules.CopyTrading/Services/CopyTradingEngine.cs index 75a2a74..7e884ee 100644 --- a/src/PolyTrader.Modules.CopyTrading/Services/CopyTradingEngine.cs +++ b/src/PolyTrader.Modules.CopyTrading/Services/CopyTradingEngine.cs @@ -6,6 +6,7 @@ using System.Threading.Tasks; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using PolyTraderSharp.Models; +using PolyTrader.Modules.CopyTrading.Logic; using System.Collections.Concurrent; using System.Linq; @@ -292,26 +293,14 @@ namespace PolyTraderSharp.Services decimal investedInMarket = activePositions.FirstOrDefault(p => p.TokenId == signal.TokenId)?.AmountUsd ?? 0m; decimal minTrade = 1.0m; - decimal maxAllowed = account.TotalBalance * (settings.PerMarketLimit / 100.0m); - - // Low Balance Bypass (Stufen-System) ALWAYS APPLIES - if (account.TotalBalance < 150m) maxAllowed = Math.Min(1.20m, Math.Max(account.AvailableBalance, 0m)); - else if (account.TotalBalance < 500m) maxAllowed = Math.Min(3.0m, Math.Max(account.AvailableBalance, 0m)); - + // Markt-Budget inkl. Low-Balance-Bypass (CopyTradingRisk, unit-getestet) + decimal maxAllowed = CopyTradingRisk.MaxPerMarket(account.TotalBalance, account.AvailableBalance, settings.PerMarketLimit); + if (_copyState.SixSharesMinimum && account.TotalBalance < 500m) { // Adjust maxAllowed to cover at least 6 shares * order limit price. - decimal desiredLimitForSix; - if (trader != null && trader.Category == "HF") - { - desiredLimitForSix = signal.Price + 0.005m; - } - else - { - desiredLimitForSix = signal.Price * (1.0m + settings.MaxPriceDifference / 100.0m); - } - decimal orderPriceForSix = Math.Min(desiredLimitForSix, settings.MaxBuyPrice); - if (orderPriceForSix > 0.99m) orderPriceForSix = 0.99m; + decimal orderPriceForSix = CopyTradingRisk.CalculateBuyOrderPrice( + signal.Price, trader != null && trader.Category == "HF", settings.MaxPriceDifference, settings.MaxBuyPrice); decimal costSix = 6m * orderPriceForSix; if (costSix > maxAllowed) @@ -335,38 +324,15 @@ namespace PolyTraderSharp.Services return; } - // Time Limit Restriktion - double hoursLeft = signal.EndDate.HasValue ? (signal.EndDate.Value - DateTime.UtcNow).TotalHours : 999999; - decimal applicableTimeLimitPct; - decimal investedInTimeframe = 0m; - string timeframeLabel = ""; - - var openVals = activePositions; - - if (hoursLeft < 6) - { - applicableTimeLimitPct = settings.perMaxTime6h; - timeframeLabel = "< 6h"; - investedInTimeframe = openVals.Where(p => p.ExpiryDate.HasValue && (p.ExpiryDate.Value - DateTime.UtcNow).TotalHours < 6).Sum(p => (decimal)p.AmountUsd); - } - else if (hoursLeft < 24) - { - applicableTimeLimitPct = settings.perMaxTime24h; - timeframeLabel = "< 24h"; - investedInTimeframe = openVals.Where(p => p.ExpiryDate.HasValue && (p.ExpiryDate.Value - DateTime.UtcNow).TotalHours >= 6 && (p.ExpiryDate.Value - DateTime.UtcNow).TotalHours < 24).Sum(p => (decimal)p.AmountUsd); - } - else if (hoursLeft < 72) - { - applicableTimeLimitPct = settings.perMaxTime72h; - timeframeLabel = "< 72h"; - investedInTimeframe = openVals.Where(p => p.ExpiryDate.HasValue && (p.ExpiryDate.Value - DateTime.UtcNow).TotalHours >= 24 && (p.ExpiryDate.Value - DateTime.UtcNow).TotalHours < 72).Sum(p => (decimal)p.AmountUsd); - } - else - { - applicableTimeLimitPct = settings.perMaxTimeNone; - timeframeLabel = "> 72h"; - investedInTimeframe = openVals.Where(p => !p.ExpiryDate.HasValue || (p.ExpiryDate.Value - DateTime.UtcNow).TotalHours >= 72).Sum(p => (decimal)p.AmountUsd); - } + // Time Limit Restriktion (Zeitfenster-Logik: CopyTradingRisk, unit-getestet) + var nowUtc = DateTime.UtcNow; + double hoursLeft = signal.EndDate.HasValue ? (signal.EndDate.Value - nowUtc).TotalHours : 999999; + var timeBucket = CopyTradingRisk.ResolveTimeBucket(hoursLeft); + decimal applicableTimeLimitPct = CopyTradingRisk.TimeLimitPct(timeBucket, settings); + string timeframeLabel = CopyTradingRisk.TimeBucketLabel(timeBucket); + decimal investedInTimeframe = activePositions + .Where(p => CopyTradingRisk.IsPositionInBucket(p.ExpiryDate, timeBucket, nowUtc)) + .Sum(p => (decimal)p.AmountUsd); decimal maxAllowedTimeframe = account.TotalBalance * (applicableTimeLimitPct / 100.0m); @@ -402,20 +368,9 @@ namespace PolyTraderSharp.Services return; } - decimal desiredLimit; - if (trader != null && trader.Category == "HF") - { - // HF Trader: festes 0.5 Cent (0.005) Limit - desiredLimit = signal.Price + 0.005m; - } - else - { - // Normaler Trader: prozentuales Limit aus Slave-Account Settings - desiredLimit = signal.Price * (1.0m + settings.MaxPriceDifference / 100.0m); - } - - orderPrice = Math.Min(desiredLimit, settings.MaxBuyPrice); - if (orderPrice > 0.99m) orderPrice = 0.99m; + // Limit-Preis (HF-fest / prozentual, gedeckelt) – CopyTradingRisk, unit-getestet + orderPrice = CopyTradingRisk.CalculateBuyOrderPrice( + signal.Price, trader != null && trader.Category == "HF", settings.MaxPriceDifference, settings.MaxBuyPrice); var exact = PolymarketClobClient.CalculateExactOrderAmounts(maxAmountToBuy, orderPrice, orderPrice, "BUY"); if (exact.shares <= 0 || exact.usdc > account.AvailableBalance) diff --git a/tests/PolyTrader.Tests/CopyTradingRiskTests.cs b/tests/PolyTrader.Tests/CopyTradingRiskTests.cs new file mode 100644 index 0000000..a595b4a --- /dev/null +++ b/tests/PolyTrader.Tests/CopyTradingRiskTests.cs @@ -0,0 +1,194 @@ +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)); + } + } +}