CLOB-Sicherheitsnetz: BUY-Risikologik extrahiert + breit getestet; PreRedeemLimit-Skala korrigiert

Ziel (Richard): vor den CLOB-Eingriffen ein umfangreiches Testnetz, damit künftige
Änderungen keine neuen Fehler einschleusen. Reiner, verhaltensneutraler Umbau –
alte Version liegt als Rollback in Git (Commit 38f609e), siehe .agents/rules/clob.md.

- Neue pure Klasse CopyTradingRisk (Logic/): CalculateBuyOrderPrice (HF-fest /
  prozentual, gedeckelt durch MaxBuyPrice + harte 0.99), ResolveTimeBucket/
  TimeLimitPct/TimeBucketLabel/IsPositionInBucket (Zeitfenster-Exposure),
  MaxPerMarket (Markt-Budget inkl. Low-Balance-Bypass-Stufen).
- CopyTradingEngine BUY-Pfad ruft diese Funktionen jetzt statt Inline-Mathematik
  (1:1-Semantik, dedupliziert die doppelte Order-Preis-Berechnung).
- CopyTradingRiskTests: 38 Fälle über alle Zweige/Grenzwerte (HF vs. normal,
  MaxBuy-/0.99-Deckel, Bucket-Grenzen 6/24/72h, null/expired Expiry,
  Balance-Stufen 150/500). Gesamt 124 Tests grün.

Fix: PreRedeemLimit-Skalen-Korrektur (Migration FixPreRedeemLimitScale): Alt-Werte
> 1 (z. B. 99.5) werden /100 (0.995); 0 bleibt deaktiviert. Auf MySQL angewendet.

Build/Smoke grün.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-07-07 10:03:53 +02:00
co-authored by Claude Opus 4.8
parent 38f609ed31
commit 360f264ed8
5 changed files with 605 additions and 63 deletions
@@ -0,0 +1,97 @@
using System;
using PolyTraderSharp.Models;
namespace PolyTrader.Modules.CopyTrading.Logic
{
/// <summary>
/// Reine, seiteneffektfreie Entscheidungs-/Rechenlogik des Copytrading-BUY-Pfads.
/// Bewusst aus <c>CopyTradingEngine</c> 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.
/// </summary>
public static class CopyTradingRisk
{
/// <summary>Harte Preisobergrenze für jede BUY-Order (nie über 0.99 kaufen).</summary>
public const decimal MaxOrderPriceCap = 0.99m;
/// <summary>Fester Limit-Aufschlag für HF-Trader (0,5 ¢).</summary>
public const decimal HfLimitOffset = 0.005m;
/// <summary>
/// Limit-Preis einer BUY-Order: HF-Trader bekommen einen festen Aufschlag von 0.005,
/// sonst einen prozentualen Aufschlag (<paramref name="maxPriceDifferencePct"/>) über dem
/// Signalpreis. Gedeckelt durch <paramref name="maxBuyPrice"/> und die harte Grenze 0.99.
/// </summary>
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;
}
/// <summary>Restlaufzeit-Klasse eines Marktes (Zeit bis Marktschluss).</summary>
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;
/// <summary>Menschenlesbares Label passend zum <see cref="ResolveTimeBucket"/>-Ergebnis.</summary>
public static string TimeBucketLabel(TimeBucket bucket) => bucket switch
{
TimeBucket.Under6h => "< 6h",
TimeBucket.Under24h => "< 24h",
TimeBucket.Under72h => "< 72h",
_ => "> 72h"
};
/// <summary>Das für die Zeitklasse geltende Exposure-Limit (% des Guthabens) aus den Settings.</summary>
public static decimal TimeLimitPct(TimeBucket bucket, CopyTradingAccountSettings settings) => bucket switch
{
TimeBucket.Under6h => settings.perMaxTime6h,
TimeBucket.Under24h => settings.perMaxTime24h,
TimeBucket.Under72h => settings.perMaxTime72h,
_ => settings.perMaxTimeNone
};
/// <summary>
/// Zählt eine offene Position anhand ihres Enddatums zur angegebenen Zeitklasse?
/// (Dedupliziert die vier Filter im Time-Limit-Check der Engine, 1:1-Semantik.)
/// </summary>
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
};
}
/// <summary>
/// Maximal erlaubter Einsatz pro Markt: normalerweise <paramref name="perMarketLimitPct"/> %
/// 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.
/// </summary>
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;
}
}
}
@@ -0,0 +1,270 @@
// <auto-generated />
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
{
/// <inheritdoc />
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<int>("TradeId")
.HasColumnType("int");
b.Property<int>("AccountId")
.HasColumnType("int");
b.Property<DateTime>("ClosedAt")
.HasColumnType("datetime(6)");
b.Property<decimal>("EntryPrice")
.HasPrecision(18, 6)
.HasColumnType("decimal(18,6)");
b.Property<decimal>("ExitPrice")
.HasPrecision(18, 6)
.HasColumnType("decimal(18,6)");
b.Property<string>("ExitReason")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("varchar(200)");
b.Property<bool>("IsDemo")
.HasColumnType("tinyint(1)");
b.Property<string>("MarketQuestion")
.IsRequired()
.HasMaxLength(1000)
.HasColumnType("varchar(1000)");
b.Property<string>("MarketSlug")
.IsRequired()
.HasMaxLength(300)
.HasColumnType("varchar(300)");
b.Property<DateTime>("OpenedAt")
.HasColumnType("datetime(6)");
b.Property<string>("Outcome")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("varchar(200)");
b.Property<decimal>("PnlPercent")
.HasPrecision(18, 6)
.HasColumnType("decimal(18,6)");
b.Property<decimal>("RealizedPnl")
.HasPrecision(18, 6)
.HasColumnType("decimal(18,6)");
b.Property<string>("Side")
.IsRequired()
.HasMaxLength(10)
.HasColumnType("varchar(10)");
b.Property<decimal>("Size")
.HasPrecision(18, 6)
.HasColumnType("decimal(18,6)");
b.Property<int>("SourceTraderId")
.HasColumnType("int");
b.Property<string>("TokenId")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("varchar(120)");
b.Property<decimal>("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<int>("AccountId")
.HasColumnType("int");
b.Property<decimal>("MaxBuyPrice")
.HasPrecision(18, 6)
.HasColumnType("decimal(18,6)");
b.Property<decimal>("MaxPriceDifference")
.HasPrecision(18, 6)
.HasColumnType("decimal(18,6)");
b.Property<decimal>("MaxSpreadPct")
.HasPrecision(18, 6)
.HasColumnType("decimal(18,6)");
b.Property<decimal>("MinSellRatioPct")
.HasPrecision(18, 6)
.HasColumnType("decimal(18,6)");
b.Property<decimal>("PerMarketLimit")
.HasPrecision(18, 6)
.HasColumnType("decimal(18,6)");
b.Property<decimal>("PerMasterLimit")
.HasPrecision(18, 6)
.HasColumnType("decimal(18,6)");
b.Property<decimal>("PreRedeemLimit")
.HasPrecision(18, 6)
.HasColumnType("decimal(18,6)");
b.Property<decimal>("ProfitTarget")
.HasPrecision(18, 6)
.HasColumnType("decimal(18,6)");
b.Property<decimal>("SellFloorPct")
.HasPrecision(18, 6)
.HasColumnType("decimal(18,6)");
b.Property<decimal>("perMaxTime24h")
.HasPrecision(18, 6)
.HasColumnType("decimal(18,6)");
b.Property<decimal>("perMaxTime6h")
.HasPrecision(18, 6)
.HasColumnType("decimal(18,6)");
b.Property<decimal>("perMaxTime72h")
.HasPrecision(18, 6)
.HasColumnType("decimal(18,6)");
b.Property<decimal>("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<string>("Id")
.HasMaxLength(64)
.HasColumnType("varchar(64)");
b.Property<DateTime>("ClosedAt")
.HasColumnType("datetime(6)");
b.Property<decimal>("RealizedPnl")
.HasPrecision(18, 6)
.HasColumnType("decimal(18,6)");
b.Property<string>("TokenId")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("varchar(120)");
b.Property<int>("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<int>("Id")
.HasColumnType("int");
b.Property<string>("AssignedAccountIds")
.IsRequired()
.HasColumnType("text");
b.Property<bool>("AutoPauseEnabled")
.HasColumnType("tinyint(1)");
b.Property<string>("Category")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("varchar(64)");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(1000)
.HasColumnType("varchar(1000)");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("varchar(200)");
b.Property<bool>("IsActive")
.HasColumnType("tinyint(1)");
b.Property<bool>("IsHidden")
.HasColumnType("tinyint(1)");
b.Property<bool>("MakerEntry")
.HasColumnType("tinyint(1)");
b.Property<string>("Reasoning")
.IsRequired()
.HasMaxLength(1000)
.HasColumnType("varchar(1000)");
b.Property<double>("TotalPnl")
.HasColumnType("double");
b.Property<int>("TotalTrades")
.HasColumnType("int");
b.Property<string>("WalletAddress")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("varchar(128)");
b.Property<int>("WinningTrades")
.HasColumnType("int");
b.Property<double>("Winrate30t")
.HasColumnType("double");
b.HasKey("Id");
b.ToTable("mod_copytrading_traders", (string)null);
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,26 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PolyTrader.Modules.CopyTrading.Persistence.Ef.Migrations
{
/// <inheritdoc />
public partial class FixPreRedeemLimitScale : Migration
{
/// <inheritdoc />
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;");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
}
}
}
@@ -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)