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));
+ }
+ }
+}