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:
co-authored by
Claude Opus 4.8
parent
38f609ed31
commit
360f264ed8
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+270
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+26
@@ -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)
|
||||
|
||||
@@ -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
|
||||
{
|
||||
/// <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));
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user