From 4619edbb997d41f0d63d5ef1e94ac55256112a9b Mon Sep 17 00:00:00 2001 From: Richard Date: Tue, 7 Jul 2026 18:19:41 +0200 Subject: [PATCH] Phase 3: Copy-Score + Auto-Pause-Kill-Switch (Trader-Intelligence) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Aus UNSEREN geschlossenen Trades (nicht der externen Data-API) berechnet -> jetzt machbar. - TraderScore (pure, getestet): Compute (CopyPnl/ProfitFactor/AvgPnlPerTrade/Count aus realisierten PnLs) + ShouldAutoPause (enabled & count>=minTrades & pnl<=-threshold). - TrackedTrader: CopyPnl30d/CopyProfitFactor/CopyAvgPnlPerTrade/CopyTradeCount30d (mit Erklärungen) + Migration AddTraderCopyScore (auf MySQL angewendet). - CopyTradingState: globale Auto-Pause-Config (AutoPauseMinTrades 10, AutoPauseDrawdownUsd 10). Per-Master-Schalter TrackedTrader.AutoPauseEnabled. - MasterTraderAnalyticsJob.UpdateCopyScoresAndAutoPauseAsync (entkoppelt von der flakigen Master-History-API): Copy-Score je Master (30T), harte Auto-Pause bei Verlust über Schwelle (IsActive=false, Reasoning+Zeitstempel, Threema; Reaktivierung nur manuell). Injiziert ICopyTradeLogRepository + ThreemaService. - MasterTradersView: 4 Copy-Score-Spalten. 193 Tests gruen. Build/Smoke gruen. OFFEN (3.2, API-abhaengig, Zielland): Sniper-Metriken (MedianHold/SellWithin5Min) aus der Data-API (Portierung analyze_snipers.py). Co-Authored-By: Claude Opus 4.8 --- .../CopyTradingState.cs | 4 + .../Logic/TraderScore.cs | 58 ++++ .../Models/TrackedTrader.cs | 28 +- .../Persistence/Ef/CopyTradingDbContext.cs | 3 + ...60707160622_AddTraderCopyScore.Designer.cs | 285 ++++++++++++++++++ .../20260707160622_AddTraderCopyScore.cs | 68 +++++ .../CopyTradingDbContextModelSnapshot.cs | 15 + .../Services/MasterTraderAnalyticsJob.cs | 57 +++- .../Ui/MasterTradersView.Designer.cs | 46 ++- .../Ui/MasterTradersView.cs | 3 + tests/PolyTrader.Tests/TraderScoreTests.cs | 62 ++++ 11 files changed, 626 insertions(+), 3 deletions(-) create mode 100644 src/PolyTrader.Modules.CopyTrading/Logic/TraderScore.cs create mode 100644 src/PolyTrader.Modules.CopyTrading/Persistence/Ef/Migrations/20260707160622_AddTraderCopyScore.Designer.cs create mode 100644 src/PolyTrader.Modules.CopyTrading/Persistence/Ef/Migrations/20260707160622_AddTraderCopyScore.cs create mode 100644 tests/PolyTrader.Tests/TraderScoreTests.cs diff --git a/src/PolyTrader.Modules.CopyTrading/CopyTradingState.cs b/src/PolyTrader.Modules.CopyTrading/CopyTradingState.cs index 2b1249d..736ec41 100644 --- a/src/PolyTrader.Modules.CopyTrading/CopyTradingState.cs +++ b/src/PolyTrader.Modules.CopyTrading/CopyTradingState.cs @@ -18,6 +18,10 @@ namespace PolyTraderSharp // Copytrading-Risk-Regel: mindestens 6 Shares pro Order erzwingen. public bool SixSharesMinimum { get; set; } = true; + // Auto-Pause-Kill-Switch (Phase 3.3), global. Pro-Master-Schalter: TrackedTrader.AutoPauseEnabled. + public int AutoPauseMinTrades { get; set; } = 10; // Mindest-Stichprobe vor Pause + public decimal AutoPauseDrawdownUsd { get; set; } = 10m; // Copy-PnL (30T) darunter -> Pause + // Kopierte Master-Trader (TraderId -> TrackedTrader) public ConcurrentDictionary Traders { get; } = new(); diff --git a/src/PolyTrader.Modules.CopyTrading/Logic/TraderScore.cs b/src/PolyTrader.Modules.CopyTrading/Logic/TraderScore.cs new file mode 100644 index 0000000..699d769 --- /dev/null +++ b/src/PolyTrader.Modules.CopyTrading/Logic/TraderScore.cs @@ -0,0 +1,58 @@ +using System.Collections.Generic; + +namespace PolyTrader.Modules.CopyTrading.Logic +{ + /// + /// Reine Trader-Intelligence-Logik (Phase 3): Kennzahlen dazu, was WIR mit einem Master + /// verdient haben (Copy-PnL aus unseren geschlossenen Trades), plus die Auto-Pause-Entscheidung. + /// Bewusst pur/testbar; die Datenbeschaffung (Filter SourceTraderId + Zeitfenster) macht der Job. + /// + public static class TraderScore + { + /// Sentinel für Profit-Faktor ohne jeden Verlust (sonst Division durch 0). + public const decimal NoLossProfitFactor = 999m; + + public readonly record struct CopyMetrics(int TradeCount, decimal CopyPnl, decimal ProfitFactor, decimal AvgPnlPerTrade); + + /// + /// Berechnet Copy-Kennzahlen aus den realisierten PnLs unserer Trades eines Masters + /// (bereits gefiltert auf Master + Zeitfenster). + /// + public static CopyMetrics Compute(IEnumerable realizedPnls) + { + int count = 0; + decimal sum = 0m, grossProfit = 0m, grossLoss = 0m; + + foreach (var pnl in realizedPnls) + { + count++; + sum += pnl; + if (pnl > 0m) grossProfit += pnl; + else if (pnl < 0m) grossLoss += -pnl; + } + + decimal profitFactor = + grossLoss > 0m ? grossProfit / grossLoss : + grossProfit > 0m ? NoLossProfitFactor : 0m; + + decimal avg = count > 0 ? sum / count : 0m; + return new CopyMetrics(count, sum, profitFactor, avg); + } + + /// + /// Auto-Pause-Kill-Switch (Phase 3.3): pausiert einen Master hart, wenn er über genügend + /// Trades ins Minus läuft. Reaktivierung bewusst nur manuell (hier nicht abgebildet). + /// + /// Per-Master-Flag (Default an). + /// Anzahl Copy-Trades im Fenster. + /// Summierter Copy-PnL im Fenster. + /// Mindestanzahl Trades, bevor pausiert wird (Rausch-Schutz). + /// Max. erlaubter Verlust (positiv); darunter → Pause. + public static bool ShouldAutoPause(bool autoPauseEnabled, int tradeCount, decimal copyPnl, int minTrades, decimal drawdownThresholdUsd) + { + if (!autoPauseEnabled) return false; + if (tradeCount < minTrades) return false; + return copyPnl <= -drawdownThresholdUsd; + } + } +} diff --git a/src/PolyTrader.Modules.CopyTrading/Models/TrackedTrader.cs b/src/PolyTrader.Modules.CopyTrading/Models/TrackedTrader.cs index 1245357..939583b 100644 --- a/src/PolyTrader.Modules.CopyTrading/Models/TrackedTrader.cs +++ b/src/PolyTrader.Modules.CopyTrading/Models/TrackedTrader.cs @@ -91,9 +91,35 @@ namespace PolyTraderSharp.Models [Category("04. Statistics")] [ReadOnly(true)] [DisplayName("PnL (7 Tage)")] - [Description("Realisierter Gewinn/Verlust des MASTERS im Fenster (nicht unser Copy-Ergebnis). Copy-PnL folgt in Phase 3.")] + [Description("Realisierter Gewinn/Verlust des MASTERS im Fenster (nicht unser Copy-Ergebnis).")] public double TotalPnl { get; set; } = 0.0; + // --- Copy-Score (Phase 3): was WIR mit diesem Master verdient haben, letzte 30 Tage --- + + [Category("05. Copy-Score (30 Tage)")] + [ReadOnly(true)] + [DisplayName("Copy-PnL (30T)")] + [Description("Summierter realisierter Gewinn/Verlust UNSERER kopierten Trades dieses Masters der letzten 30 Tage (inkl. unserer Slippage/Fees). Primäre Rentabilitätskennzahl – aussagekräftiger als die Master-eigene Winrate.")] + public decimal CopyPnl30d { get; set; } + + [Category("05. Copy-Score (30 Tage)")] + [ReadOnly(true)] + [DisplayName("Profit-Faktor (30T)")] + [Description("Bruttogewinn / Bruttoverlust unserer Copy-Trades (30T). > 1 = profitabel, < 1 = Verlust. 999 = bisher kein Verlust. Robuster als die reine Winrate.")] + public decimal CopyProfitFactor { get; set; } + + [Category("05. Copy-Score (30 Tage)")] + [ReadOnly(true)] + [DisplayName("Ø PnL/Trade (30T)")] + [Description("Durchschnittlicher Copy-PnL pro Trade (30T). Negativ = dieser Master kostet uns im Schnitt Geld.")] + public decimal CopyAvgPnlPerTrade { get; set; } + + [Category("05. Copy-Score (30 Tage)")] + [ReadOnly(true)] + [DisplayName("Copy-Trades (30T)")] + [Description("Anzahl unserer geschlossenen Copy-Trades dieses Masters in den letzten 30 Tagen (Stichprobengröße für die obigen Kennzahlen).")] + public int CopyTradeCount30d { get; set; } + [Browsable(false)] public HashSet AssignedAccountIds { get; set; } = new(); } diff --git a/src/PolyTrader.Modules.CopyTrading/Persistence/Ef/CopyTradingDbContext.cs b/src/PolyTrader.Modules.CopyTrading/Persistence/Ef/CopyTradingDbContext.cs index ea2f161..99d148d 100644 --- a/src/PolyTrader.Modules.CopyTrading/Persistence/Ef/CopyTradingDbContext.cs +++ b/src/PolyTrader.Modules.CopyTrading/Persistence/Ef/CopyTradingDbContext.cs @@ -57,6 +57,9 @@ namespace PolyTrader.Modules.CopyTrading.Persistence.Ef e.Property(x => x.Category).HasMaxLength(64); e.Property(x => x.Description).HasMaxLength(1000); e.Property(x => x.Reasoning).HasMaxLength(1000); + e.Property(x => x.CopyPnl30d).HasPrecision(18, 6); + e.Property(x => x.CopyProfitFactor).HasPrecision(18, 6); + e.Property(x => x.CopyAvgPnlPerTrade).HasPrecision(18, 6); var comparer = new ValueComparer>( (a, c) => (a == null && c == null) || (a != null && c != null && a.SetEquals(c)), diff --git a/src/PolyTrader.Modules.CopyTrading/Persistence/Ef/Migrations/20260707160622_AddTraderCopyScore.Designer.cs b/src/PolyTrader.Modules.CopyTrading/Persistence/Ef/Migrations/20260707160622_AddTraderCopyScore.Designer.cs new file mode 100644 index 0000000..a5746c3 --- /dev/null +++ b/src/PolyTrader.Modules.CopyTrading/Persistence/Ef/Migrations/20260707160622_AddTraderCopyScore.Designer.cs @@ -0,0 +1,285 @@ +// +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("20260707160622_AddTraderCopyScore")] + partial class AddTraderCopyScore + { + /// + 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("CopyAvgPnlPerTrade") + .HasPrecision(18, 6) + .HasColumnType("decimal(18,6)"); + + b.Property("CopyPnl30d") + .HasPrecision(18, 6) + .HasColumnType("decimal(18,6)"); + + b.Property("CopyProfitFactor") + .HasPrecision(18, 6) + .HasColumnType("decimal(18,6)"); + + b.Property("CopyTradeCount30d") + .HasColumnType("int"); + + 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/20260707160622_AddTraderCopyScore.cs b/src/PolyTrader.Modules.CopyTrading/Persistence/Ef/Migrations/20260707160622_AddTraderCopyScore.cs new file mode 100644 index 0000000..b637da5 --- /dev/null +++ b/src/PolyTrader.Modules.CopyTrading/Persistence/Ef/Migrations/20260707160622_AddTraderCopyScore.cs @@ -0,0 +1,68 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace PolyTrader.Modules.CopyTrading.Persistence.Ef.Migrations +{ + /// + public partial class AddTraderCopyScore : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "CopyAvgPnlPerTrade", + table: "mod_copytrading_traders", + type: "decimal(18,6)", + precision: 18, + scale: 6, + nullable: false, + defaultValue: 0m); + + migrationBuilder.AddColumn( + name: "CopyPnl30d", + table: "mod_copytrading_traders", + type: "decimal(18,6)", + precision: 18, + scale: 6, + nullable: false, + defaultValue: 0m); + + migrationBuilder.AddColumn( + name: "CopyProfitFactor", + table: "mod_copytrading_traders", + type: "decimal(18,6)", + precision: 18, + scale: 6, + nullable: false, + defaultValue: 0m); + + migrationBuilder.AddColumn( + name: "CopyTradeCount30d", + table: "mod_copytrading_traders", + type: "int", + nullable: false, + defaultValue: 0); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "CopyAvgPnlPerTrade", + table: "mod_copytrading_traders"); + + migrationBuilder.DropColumn( + name: "CopyPnl30d", + table: "mod_copytrading_traders"); + + migrationBuilder.DropColumn( + name: "CopyProfitFactor", + table: "mod_copytrading_traders"); + + migrationBuilder.DropColumn( + name: "CopyTradeCount30d", + table: "mod_copytrading_traders"); + } + } +} diff --git a/src/PolyTrader.Modules.CopyTrading/Persistence/Ef/Migrations/CopyTradingDbContextModelSnapshot.cs b/src/PolyTrader.Modules.CopyTrading/Persistence/Ef/Migrations/CopyTradingDbContextModelSnapshot.cs index 183cadc..5e55ba2 100644 --- a/src/PolyTrader.Modules.CopyTrading/Persistence/Ef/Migrations/CopyTradingDbContextModelSnapshot.cs +++ b/src/PolyTrader.Modules.CopyTrading/Persistence/Ef/Migrations/CopyTradingDbContextModelSnapshot.cs @@ -216,6 +216,21 @@ namespace PolyTrader.Modules.CopyTrading.Persistence.Ef.Migrations .HasMaxLength(64) .HasColumnType("varchar(64)"); + b.Property("CopyAvgPnlPerTrade") + .HasPrecision(18, 6) + .HasColumnType("decimal(18,6)"); + + b.Property("CopyPnl30d") + .HasPrecision(18, 6) + .HasColumnType("decimal(18,6)"); + + b.Property("CopyProfitFactor") + .HasPrecision(18, 6) + .HasColumnType("decimal(18,6)"); + + b.Property("CopyTradeCount30d") + .HasColumnType("int"); + b.Property("Description") .IsRequired() .HasMaxLength(1000) diff --git a/src/PolyTrader.Modules.CopyTrading/Services/MasterTraderAnalyticsJob.cs b/src/PolyTrader.Modules.CopyTrading/Services/MasterTraderAnalyticsJob.cs index 220867a..6da1bfd 100644 --- a/src/PolyTrader.Modules.CopyTrading/Services/MasterTraderAnalyticsJob.cs +++ b/src/PolyTrader.Modules.CopyTrading/Services/MasterTraderAnalyticsJob.cs @@ -1,4 +1,5 @@ using System; +using PolyTrader.Modules.CopyTrading.Logic; using PolyTrader.Modules.CopyTrading.Persistence; using System.Collections.Generic; using System.Linq; @@ -17,16 +18,20 @@ namespace PolyTraderSharp.Services private readonly TerminalLogger _logger; private readonly IMasterTraderHistoryRepository _historyRepo; private readonly ITrackedTraderRepository _traderRepo; + private readonly ICopyTradeLogRepository _tradeLog; + private readonly ThreemaService _threema; private readonly JobStatusRow _jobStatus; private readonly PolymarketApiService _api; - public MasterTraderAnalyticsJob(TradingState state, CopyTradingState copyState, TerminalLogger logger, IMasterTraderHistoryRepository historyRepo, ITrackedTraderRepository traderRepo, JobManager jobManager, PolymarketApiService api) + public MasterTraderAnalyticsJob(TradingState state, CopyTradingState copyState, TerminalLogger logger, IMasterTraderHistoryRepository historyRepo, ITrackedTraderRepository traderRepo, ICopyTradeLogRepository tradeLog, ThreemaService threema, JobManager jobManager, PolymarketApiService api) { _state = state; _copyState = copyState; _logger = logger; _historyRepo = historyRepo; _traderRepo = traderRepo; + _tradeLog = tradeLog; + _threema = threema; _api = api; _jobStatus = new JobStatusRow @@ -187,11 +192,61 @@ namespace PolyTraderSharp.Services } _logger.Info("✅ Master-Trader Historien-Analyse abgeschlossen."); + + // Phase 3: Copy-Score + Auto-Pause – entkoppelt von der (flakigen) Master-History-API, + // da aus UNSEREN geschlossenen Trades berechnet. + await UpdateCopyScoresAndAutoPauseAsync(); } catch (Exception ex) { _logger.Error($"MasterTraderAnalyticsJob Exception: {ex}"); } } + + /// + /// Phase 3.1/3.3: Berechnet je Master den Copy-Score (letzte 30 Tage aus unseren + /// geschlossenen Copy-Trades) und pausiert Master hart, die über genügend Trades ins Minus + /// laufen (nur wenn deren AutoPauseEnabled gesetzt ist). Reaktivierung bewusst nur manuell. + /// + private async Task UpdateCopyScoresAndAutoPauseAsync() + { + var since = DateTime.UtcNow.AddDays(-30); + + foreach (var trader in _copyState.Traders.Values.ToList()) + { + try + { + var pnls = _tradeLog + .Find(t => t.SourceTraderId == trader.Id && t.ClosedAt >= since && !t.IsDemo) + .Select(t => t.RealizedPnl); + var m = TraderScore.Compute(pnls); + + trader.CopyPnl30d = m.CopyPnl; + trader.CopyProfitFactor = m.ProfitFactor; + trader.CopyAvgPnlPerTrade = m.AvgPnlPerTrade; + trader.CopyTradeCount30d = m.TradeCount; + + bool pause = trader.IsActive && TraderScore.ShouldAutoPause( + trader.AutoPauseEnabled, m.TradeCount, m.CopyPnl, + _copyState.AutoPauseMinTrades, _copyState.AutoPauseDrawdownUsd); + + if (pause) + { + trader.IsActive = false; + trader.Reasoning = $"[Auto-Pause {DateTime.UtcNow:yyyy-MM-dd HH:mm} UTC] Copy-PnL {m.CopyPnl:F2} USDC über {m.TradeCount} Trades (30T) unter Schwelle (-{_copyState.AutoPauseDrawdownUsd:F0}). Reaktivierung manuell."; + _logger.Warning($"🛑 [AUTO-PAUSE] Master '{trader.DisplayName}' deaktiviert. Copy-PnL {m.CopyPnl:F2} / {m.TradeCount} Trades. Reaktivierung nur manuell."); + try { await _threema.SendMessageAsync($"🛑 Auto-Pause: Master '{trader.DisplayName}' deaktiviert.\nCopy-PnL 30T: {m.CopyPnl:F2} USDC über {m.TradeCount} Trades.\nReaktivierung manuell."); } + catch (Exception ex) { _logger.Error($"Threema Auto-Pause-Benachrichtigung fehlgeschlagen: {ex.Message}"); } + } + + _copyState.Traders[trader.Id] = trader; // Hot-Path-State synchron halten + _traderRepo.Update(trader); + } + catch (Exception ex) + { + _logger.Error($"Copy-Score/Auto-Pause für '{trader.DisplayName}' fehlgeschlagen: {ex.Message}"); + } + } + } } } diff --git a/src/PolyTrader.Modules.CopyTrading/Ui/MasterTradersView.Designer.cs b/src/PolyTrader.Modules.CopyTrading/Ui/MasterTradersView.Designer.cs index 5089098..ca82405 100644 --- a/src/PolyTrader.Modules.CopyTrading/Ui/MasterTradersView.Designer.cs +++ b/src/PolyTrader.Modules.CopyTrading/Ui/MasterTradersView.Designer.cs @@ -32,6 +32,10 @@ namespace PolyTrader.Modules.CopyTrading.Ui this.colTrades = new System.Windows.Forms.DataGridViewTextBoxColumn(); this.colWinrate = new System.Windows.Forms.DataGridViewTextBoxColumn(); this.colPnl = new System.Windows.Forms.DataGridViewTextBoxColumn(); + this.colCopyPnl = new System.Windows.Forms.DataGridViewTextBoxColumn(); + this.colCopyPf = new System.Windows.Forms.DataGridViewTextBoxColumn(); + this.colCopyAvg = new System.Windows.Forms.DataGridViewTextBoxColumn(); + this.colCopyCount = new System.Windows.Forms.DataGridViewTextBoxColumn(); this.splitter = new System.Windows.Forms.Splitter(); this.rightPanel = new System.Windows.Forms.Panel(); this.pgDetail = new System.Windows.Forms.PropertyGrid(); @@ -107,7 +111,11 @@ namespace PolyTrader.Modules.CopyTrading.Ui this.colActive, this.colTrades, this.colWinrate, - this.colPnl}); + this.colPnl, + this.colCopyPnl, + this.colCopyPf, + this.colCopyAvg, + this.colCopyCount}); this.grid.Dock = System.Windows.Forms.DockStyle.Fill; this.grid.Location = new System.Drawing.Point(0, 25); this.grid.MultiSelect = false; @@ -182,6 +190,38 @@ namespace PolyTrader.Modules.CopyTrading.Ui this.colPnl.ReadOnly = true; this.colPnl.Width = 100; // + // colCopyPnl + // + this.colCopyPnl.DataPropertyName = "CopyPnl30d"; + this.colCopyPnl.HeaderText = "Copy-PnL (30T)"; + this.colCopyPnl.Name = "colCopyPnl"; + this.colCopyPnl.ReadOnly = true; + this.colCopyPnl.Width = 110; + // + // colCopyPf + // + this.colCopyPf.DataPropertyName = "CopyProfitFactor"; + this.colCopyPf.HeaderText = "Profit-Faktor"; + this.colCopyPf.Name = "colCopyPf"; + this.colCopyPf.ReadOnly = true; + this.colCopyPf.Width = 100; + // + // colCopyAvg + // + this.colCopyAvg.DataPropertyName = "CopyAvgPnlPerTrade"; + this.colCopyAvg.HeaderText = "Ø PnL/Trade"; + this.colCopyAvg.Name = "colCopyAvg"; + this.colCopyAvg.ReadOnly = true; + this.colCopyAvg.Width = 100; + // + // colCopyCount + // + this.colCopyCount.DataPropertyName = "CopyTradeCount30d"; + this.colCopyCount.HeaderText = "Copy-Trades"; + this.colCopyCount.Name = "colCopyCount"; + this.colCopyCount.ReadOnly = true; + this.colCopyCount.Width = 90; + // // splitter // this.splitter.Dock = System.Windows.Forms.DockStyle.Right; @@ -292,6 +332,10 @@ namespace PolyTrader.Modules.CopyTrading.Ui private System.Windows.Forms.DataGridViewTextBoxColumn colTrades; private System.Windows.Forms.DataGridViewTextBoxColumn colWinrate; private System.Windows.Forms.DataGridViewTextBoxColumn colPnl; + private System.Windows.Forms.DataGridViewTextBoxColumn colCopyPnl; + private System.Windows.Forms.DataGridViewTextBoxColumn colCopyPf; + private System.Windows.Forms.DataGridViewTextBoxColumn colCopyAvg; + private System.Windows.Forms.DataGridViewTextBoxColumn colCopyCount; private System.Windows.Forms.Splitter splitter; private System.Windows.Forms.Panel rightPanel; private System.Windows.Forms.PropertyGrid pgDetail; diff --git a/src/PolyTrader.Modules.CopyTrading/Ui/MasterTradersView.cs b/src/PolyTrader.Modules.CopyTrading/Ui/MasterTradersView.cs index 30af4fa..0f0c88e 100644 --- a/src/PolyTrader.Modules.CopyTrading/Ui/MasterTradersView.cs +++ b/src/PolyTrader.Modules.CopyTrading/Ui/MasterTradersView.cs @@ -31,6 +31,9 @@ namespace PolyTrader.Modules.CopyTrading.Ui colWinrate.DefaultCellStyle.Format = "F1"; colPnl.DefaultCellStyle.Format = "F2"; + colCopyPnl.DefaultCellStyle.Format = "F2"; + colCopyPf.DefaultCellStyle.Format = "F2"; + colCopyAvg.DefaultCellStyle.Format = "F2"; tsNew.Click += (_, _) => AddNew(); tsSave.Click += (_, _) => SaveCurrent(); diff --git a/tests/PolyTrader.Tests/TraderScoreTests.cs b/tests/PolyTrader.Tests/TraderScoreTests.cs new file mode 100644 index 0000000..fbb2690 --- /dev/null +++ b/tests/PolyTrader.Tests/TraderScoreTests.cs @@ -0,0 +1,62 @@ +using System.Linq; +using PolyTrader.Modules.CopyTrading.Logic; +using Xunit; +using static PolyTrader.Modules.CopyTrading.Logic.TraderScore; + +namespace PolyTrader.Tests +{ + /// + /// Sicherheitsnetz für die Copy-Score-Kennzahlen und den Auto-Pause-Kill-Switch (Phase 3). + /// + public class TraderScoreTests + { + [Fact] + public void Compute_metrics_from_mixed_pnls() + { + var m = Compute(new[] { 10m, -3m, 5m, -2m }); + Assert.Equal(4, m.TradeCount); + Assert.Equal(10m, m.CopyPnl); + Assert.Equal(3.0m, m.ProfitFactor); // grossProfit 15 / grossLoss 5 + Assert.Equal(2.5m, m.AvgPnlPerTrade); // 10 / 4 + } + + [Fact] + public void Compute_empty_is_all_zero() + { + var m = Compute(Enumerable.Empty()); + Assert.Equal(0, m.TradeCount); + Assert.Equal(0m, m.CopyPnl); + Assert.Equal(0m, m.ProfitFactor); + Assert.Equal(0m, m.AvgPnlPerTrade); + } + + [Fact] + public void Compute_all_wins_uses_no_loss_sentinel() + { + var m = Compute(new[] { 5m, 5m }); + Assert.Equal(NoLossProfitFactor, m.ProfitFactor); + Assert.Equal(10m, m.CopyPnl); + } + + [Fact] + public void Compute_all_losses_profit_factor_zero() + { + var m = Compute(new[] { -5m, -5m }); + Assert.Equal(0m, m.ProfitFactor); + Assert.Equal(-10m, m.CopyPnl); + Assert.Equal(-5m, m.AvgPnlPerTrade); + } + + [Theory] + [InlineData(false, 20, -50, 10, 10, false)] // deaktiviert + [InlineData(true, 5, -50, 10, 10, false)] // zu wenige Trades + [InlineData(true, 12, -15, 10, 10, true)] // Verlust über Schwelle + [InlineData(true, 12, -5, 10, 10, false)] // Verlust unter Schwelle + [InlineData(true, 12, 20, 10, 10, false)] // im Plus + [InlineData(true, 12, -10, 10, 10, true)] // genau auf der Schwelle + public void ShouldAutoPause_rules(bool enabled, int count, double pnl, int minTrades, double threshold, bool expected) + { + Assert.Equal(expected, ShouldAutoPause(enabled, count, (decimal)pnl, minTrades, (decimal)threshold)); + } + } +}