From 7d63791e38450c5222a324626ea77cdae9759502 Mon Sep 17 00:00:00 2001 From: Richard Date: Wed, 15 Jul 2026 09:59:10 +0200 Subject: [PATCH] UI: Launcher-Account-Uebersicht + pure TradeAnalytics-Fundament MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - TradeAnalytics (Core, pur/testbar): KPIs (Netto-PnL/Winrate/Ø/Profit-Faktor), Equity-Kurve, PnL je Modul/Account/Tag, Window-Summary. Speist Dashboard + Launcher. 7 Tests. - Launcher dgv_accountlist: Spalten via Designer (Account, Module, Polymarket-Button, Wallet-USDC, 3T-PnL, 3T-Winrate, Overall P/L). Daten je Account aus dem Core-Trade-Log via TradeAnalytics; Auto-Refresh alle 30 s; Polymarket-Button oeffnet das Wallet-Profil. DB-Abfragen fehlertolerant. Build 0 Fehler, 331 Tests gruen, --smoke-ui ok (Launcher laedt Uebersicht). Co-Authored-By: Claude Opus 4.8 --- Ui/LauncherForm.Designer.cs | 77 ++++++++++++- Ui/LauncherForm.cs | 100 ++++++++++++++++ .../Analytics/TradeAnalytics.cs | 82 ++++++++++++++ tests/PolyTrader.Tests/TradeAnalyticsTests.cs | 107 ++++++++++++++++++ 4 files changed, 365 insertions(+), 1 deletion(-) create mode 100644 src/PolyTrader.Core/Analytics/TradeAnalytics.cs create mode 100644 tests/PolyTrader.Tests/TradeAnalyticsTests.cs diff --git a/Ui/LauncherForm.Designer.cs b/Ui/LauncherForm.Designer.cs index 409be8d..d6252c6 100644 --- a/Ui/LauncherForm.Designer.cs +++ b/Ui/LauncherForm.Designer.cs @@ -33,6 +33,13 @@ namespace PolyTraderSharp.Ui lbl_modules = new ToolStripStatusLabel(); lbl_ratelimit = new ToolStripStatusLabel(); dgv_accountlist = new DataGridView(); + colAccName = new DataGridViewTextBoxColumn(); + colAccModules = new DataGridViewTextBoxColumn(); + colAccPoly = new DataGridViewButtonColumn(); + colAccBalance = new DataGridViewTextBoxColumn(); + colAccPnl3d = new DataGridViewTextBoxColumn(); + colAccWin3d = new DataGridViewTextBoxColumn(); + colAccOverall = new DataGridViewTextBoxColumn(); btn_accounting = new ToolStripButton(); toolStripSeparator1 = new ToolStripSeparator(); menuStrip.SuspendLayout(); @@ -172,15 +179,76 @@ namespace PolyTraderSharp.Ui // dgv_accountlist.AllowUserToAddRows = false; dgv_accountlist.AllowUserToDeleteRows = false; + dgv_accountlist.AutoGenerateColumns = false; dgv_accountlist.ColumnHeadersHeightSizeMode = DataGridViewColumnHeadersHeightSizeMode.AutoSize; + dgv_accountlist.Columns.AddRange(new DataGridViewColumn[] { + colAccName, colAccModules, colAccPoly, colAccBalance, colAccPnl3d, colAccWin3d, colAccOverall }); dgv_accountlist.Dock = DockStyle.Fill; dgv_accountlist.Location = new Point(0, 139); dgv_accountlist.Name = "dgv_accountlist"; dgv_accountlist.ReadOnly = true; + dgv_accountlist.RowHeadersVisible = false; dgv_accountlist.RowHeadersWidth = 62; + dgv_accountlist.SelectionMode = DataGridViewSelectionMode.FullRowSelect; dgv_accountlist.Size = new Size(2599, 498); dgv_accountlist.TabIndex = 6; - // + // + // colAccName + // + colAccName.DataPropertyName = "Name"; + colAccName.HeaderText = "Account"; + colAccName.Name = "colAccName"; + colAccName.ReadOnly = true; + colAccName.Width = 200; + // + // colAccModules + // + colAccModules.DataPropertyName = "Modules"; + colAccModules.HeaderText = "Module"; + colAccModules.Name = "colAccModules"; + colAccModules.ReadOnly = true; + colAccModules.Width = 220; + // + // colAccPoly + // + colAccPoly.HeaderText = "Polymarket"; + colAccPoly.Name = "colAccPoly"; + colAccPoly.Text = "Öffnen"; + colAccPoly.UseColumnTextForButtonValue = true; + colAccPoly.Width = 100; + // + // colAccBalance + // + colAccBalance.DataPropertyName = "Balance"; + colAccBalance.HeaderText = "Wallet (USDC)"; + colAccBalance.Name = "colAccBalance"; + colAccBalance.ReadOnly = true; + colAccBalance.Width = 130; + // + // colAccPnl3d + // + colAccPnl3d.DataPropertyName = "Pnl3d"; + colAccPnl3d.HeaderText = "3T PnL"; + colAccPnl3d.Name = "colAccPnl3d"; + colAccPnl3d.ReadOnly = true; + colAccPnl3d.Width = 110; + // + // colAccWin3d + // + colAccWin3d.DataPropertyName = "WinRate3d"; + colAccWin3d.HeaderText = "3T Winrate %"; + colAccWin3d.Name = "colAccWin3d"; + colAccWin3d.ReadOnly = true; + colAccWin3d.Width = 110; + // + // colAccOverall + // + colAccOverall.DataPropertyName = "OverallPnl"; + colAccOverall.HeaderText = "Overall P/L"; + colAccOverall.Name = "colAccOverall"; + colAccOverall.ReadOnly = true; + colAccOverall.Width = 130; + // // btn_accounting // btn_accounting.Image = Properties.Resources.coins_in_hand; @@ -245,5 +313,12 @@ namespace PolyTraderSharp.Ui private ToolStripButton btn_accounting; private ToolStripSeparator toolStripSeparator1; private DataGridView dgv_accountlist; + private DataGridViewTextBoxColumn colAccName; + private DataGridViewTextBoxColumn colAccModules; + private DataGridViewButtonColumn colAccPoly; + private DataGridViewTextBoxColumn colAccBalance; + private DataGridViewTextBoxColumn colAccPnl3d; + private DataGridViewTextBoxColumn colAccWin3d; + private DataGridViewTextBoxColumn colAccOverall; } } diff --git a/Ui/LauncherForm.cs b/Ui/LauncherForm.cs index 043f647..5d31ec1 100644 --- a/Ui/LauncherForm.cs +++ b/Ui/LauncherForm.cs @@ -1,9 +1,13 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.Linq; using System.Windows.Forms; using Microsoft.Extensions.DependencyInjection; +using PolyTrader.Core.Analytics; using PolyTrader.Core.Modularity; +using PolyTrader.Core.Persistence; +using PolyTraderSharp.Models; namespace PolyTraderSharp.Ui { @@ -21,6 +25,7 @@ namespace PolyTraderSharp.Ui private readonly TradingState _state; private readonly System.Windows.Forms.Timer _statusTimer = new() { Interval = 1000 }; private readonly Dictionary _viewButtons; + private int _statusTicks; public LauncherForm(ShellUiHost uiHost, IServiceProvider services) { @@ -74,6 +79,13 @@ namespace PolyTraderSharp.Ui // Offen-Status der Fenster spiegeln (Button „checked", wenn Fenster offen). _uiHost.OpenStateChanged += UpdateWindowButtonStates; + // Account-Übersicht (dgv_accountlist): Zahlenformate + Polymarket-Button. + colAccBalance.DefaultCellStyle.Format = "N2"; + colAccPnl3d.DefaultCellStyle.Format = "N2"; + colAccWin3d.DefaultCellStyle.Format = "N1"; + colAccOverall.DefaultCellStyle.Format = "N2"; + dgv_accountlist.CellContentClick += AccountList_CellContentClick; + _statusTimer.Tick += (_, _) => UpdateStatus(); _statusTimer.Start(); UpdateStatus(); @@ -149,6 +161,94 @@ namespace PolyTraderSharp.Ui UpdateTradingToggles(); UpdateWindowButtonStates(); + + // Account-Übersicht alle 30 s aktualisieren (DB-Abfrage je Account – nicht jede Sekunde). + if (_statusTicks++ % 30 == 0) LoadAccountOverview(); + } + + // ===== Account-Übersicht (dgv_accountlist) ===== + + private void LoadAccountOverview() + { + if (IsDisposed) return; + var tradeLog = _services.GetService(); + if (tradeLog == null) return; + + DateTime since3d = DateTime.UtcNow.AddDays(-3); + var rows = new List(); + + foreach (var acc in _state.Accounts.Values.OrderBy(a => a.AccountId)) + { + List trades; + try { trades = tradeLog.Find(t => t.AccountId == acc.AccountId); } + catch { trades = new List(); } // DB nicht bereit -> leer statt Absturz + + var (pnl3d, win3d, _) = TradeAnalytics.WindowSummary(trades.Where(t => t.ClosedAt >= since3d)); + string modules = trades + .Select(t => t.ModuleName) + .Where(m => !string.IsNullOrEmpty(m)) + .Distinct().OrderBy(m => m) + .DefaultIfEmpty("—") + .Aggregate((a, b) => a + ", " + b); + + rows.Add(new AccountOverviewRow + { + AccountId = acc.AccountId, + Name = (string.IsNullOrEmpty(acc.Name) ? $"#{acc.AccountId}" : acc.Name) + (acc.IsDemo ? " (Demo)" : ""), + Modules = modules, + WalletAddress = acc.WalletAddress, + Balance = acc.TotalBalance, + Pnl3d = pnl3d, + WinRate3d = win3d, + OverallPnl = trades.Sum(t => t.RealizedPnl) + }); + } + + dgv_accountlist.DataSource = rows; + } + + private void AccountList_CellContentClick(object? sender, DataGridViewCellEventArgs e) + { + if (e.RowIndex < 0 || e.ColumnIndex < 0) return; + if (dgv_accountlist.Columns[e.ColumnIndex].Name != "colAccPoly") return; + if (dgv_accountlist.Rows[e.RowIndex].DataBoundItem is AccountOverviewRow row) + OpenPolymarketProfile(row.WalletAddress); + } + + private void OpenPolymarketProfile(string walletAddress) + { + if (string.IsNullOrWhiteSpace(walletAddress)) + { + MessageBox.Show("Für diesen Account ist keine Wallet-Adresse hinterlegt.", "Polymarket", + MessageBoxButtons.OK, MessageBoxIcon.Information); + return; + } + try + { + Process.Start(new ProcessStartInfo + { + FileName = $"https://polymarket.com/profile/{walletAddress}", + UseShellExecute = true + }); + } + catch (Exception ex) + { + MessageBox.Show($"Konnte Polymarket nicht öffnen: {ex.Message}", "Fehler", + MessageBoxButtons.OK, MessageBoxIcon.Error); + } + } + + /// Anzeige-Zeile der Account-Übersicht (Bindung an dgv_accountlist über DataPropertyName). + private sealed class AccountOverviewRow + { + public int AccountId { get; set; } + public string Name { get; set; } = string.Empty; + public string Modules { get; set; } = string.Empty; + public string WalletAddress { get; set; } = string.Empty; + public decimal Balance { get; set; } + public decimal Pnl3d { get; set; } + public decimal WinRate3d { get; set; } + public decimal OverallPnl { get; set; } } } } diff --git a/src/PolyTrader.Core/Analytics/TradeAnalytics.cs b/src/PolyTrader.Core/Analytics/TradeAnalytics.cs new file mode 100644 index 0000000..6d4ade9 --- /dev/null +++ b/src/PolyTrader.Core/Analytics/TradeAnalytics.cs @@ -0,0 +1,82 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using PolyTraderSharp.Models; + +namespace PolyTrader.Core.Analytics +{ + /// + /// Reine, seiteneffektfreie Auswertungslogik über den generischen Core-Trade-Log + /// (). Speist Dashboard-Kennzahlen/Charts und die Launcher-Account-Übersicht. + /// Die Filterung (Account/Modul/Demo/Zeitraum) trifft der Aufrufer; hier wird nur aggregiert. + /// Vollständig unit-getestet, weil „profitabel ja/nein" davon abhängt. + /// + public static class TradeAnalytics + { + /// Kernkennzahlen eines Trade-Sets (ohne Fees – die liegen nur in den Modul-Logs). + public readonly record struct Kpis( + int TradeCount, decimal NetPnl, decimal WinRatePct, decimal AvgPnlPerTrade, decimal ProfitFactor); + + /// Profit-Faktor bei verlustfreiem Set (∞) – als großer, endlicher Anzeigewert. + public const decimal NoLossProfitFactor = 999m; + + public static Kpis ComputeKpis(IEnumerable trades) + { + var list = trades as IReadOnlyList ?? trades.ToList(); + int n = list.Count; + if (n == 0) return new Kpis(0, 0m, 0m, 0m, 0m); + + decimal net = list.Sum(t => t.RealizedPnl); + int wins = list.Count(t => t.RealizedPnl > 0m); + decimal grossWin = list.Where(t => t.RealizedPnl > 0m).Sum(t => t.RealizedPnl); + decimal grossLoss = list.Where(t => t.RealizedPnl < 0m).Sum(t => -t.RealizedPnl); + decimal pf = grossLoss > 0m ? grossWin / grossLoss : (grossWin > 0m ? NoLossProfitFactor : 0m); + + return new Kpis(n, net, 100m * wins / n, net / n, pf); + } + + /// Equity-Kurve: nach Abschlusszeit sortiert, kumulierter realisierter PnL. + public static List<(DateTime At, decimal Cumulative)> EquityCurve(IEnumerable trades) + { + var result = new List<(DateTime, decimal)>(); + decimal cum = 0m; + foreach (var t in trades.OrderBy(t => t.ClosedAt)) + { + cum += t.RealizedPnl; + result.Add((t.ClosedAt, cum)); + } + return result; + } + + /// PnL + Anzahl je Gruppierungsschlüssel (z. B. Modul oder Account), absteigend nach PnL. + public static List<(string Key, decimal Pnl, int Count)> PnlByKey( + IEnumerable trades, Func keySelector) + { + return trades + .GroupBy(keySelector) + .Select(g => (Key: g.Key, Pnl: g.Sum(t => t.RealizedPnl), Count: g.Count())) + .OrderByDescending(x => x.Pnl) + .ToList(); + } + + /// PnL je Kalendertag (nach ), chronologisch. + public static List<(DateTime Day, decimal Pnl)> PnlByDay(IEnumerable trades) + { + return trades + .GroupBy(t => t.ClosedAt.Date) + .Select(g => (Day: g.Key, Pnl: g.Sum(t => t.RealizedPnl))) + .OrderBy(x => x.Day) + .ToList(); + } + + /// Kurz-Zusammenfassung für die Launcher-Übersicht (z. B. „letzte 3 Tage" je Account). + public static (decimal Pnl, decimal WinRatePct, int Count) WindowSummary(IEnumerable trades) + { + var list = trades as IReadOnlyList ?? trades.ToList(); + int n = list.Count; + if (n == 0) return (0m, 0m, 0); + int wins = list.Count(t => t.RealizedPnl > 0m); + return (list.Sum(t => t.RealizedPnl), 100m * wins / n, n); + } + } +} diff --git a/tests/PolyTrader.Tests/TradeAnalyticsTests.cs b/tests/PolyTrader.Tests/TradeAnalyticsTests.cs new file mode 100644 index 0000000..6cc8a35 --- /dev/null +++ b/tests/PolyTrader.Tests/TradeAnalyticsTests.cs @@ -0,0 +1,107 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using PolyTrader.Core.Analytics; +using PolyTraderSharp.Models; +using Xunit; + +namespace PolyTrader.Tests +{ + /// Sicherheitsnetz für die Dashboard-/Launcher-Auswertungen (KPIs, Equity-Kurve, Gruppierungen). + public class TradeAnalyticsTests + { + private static TradeRecord Rec(decimal pnl, DateTime closedAt, string module = "M", int account = 1) => + new() { RealizedPnl = pnl, ClosedAt = closedAt, ModuleName = module, AccountId = account }; + + private static readonly DateTime T0 = new(2026, 7, 1, 12, 0, 0, DateTimeKind.Utc); + + [Fact] + public void ComputeKpis_basic_set() + { + var k = TradeAnalytics.ComputeKpis(new[] + { + Rec(10m, T0), Rec(-4m, T0), Rec(6m, T0), Rec(-2m, T0) + }); + + Assert.Equal(4, k.TradeCount); + Assert.Equal(10m, k.NetPnl); + Assert.Equal(50m, k.WinRatePct); + Assert.Equal(2.5m, k.AvgPnlPerTrade); + Assert.Equal(2.6667m, Math.Round(k.ProfitFactor, 4)); // 16 / 6 + } + + [Fact] + public void ComputeKpis_no_losses_uses_sentinel_profitfactor() + { + var k = TradeAnalytics.ComputeKpis(new[] { Rec(5m, T0), Rec(3m, T0) }); + Assert.Equal(100m, k.WinRatePct); + Assert.Equal(TradeAnalytics.NoLossProfitFactor, k.ProfitFactor); + } + + [Fact] + public void ComputeKpis_empty_is_zero() + { + var k = TradeAnalytics.ComputeKpis(Array.Empty()); + Assert.Equal(0, k.TradeCount); + Assert.Equal(0m, k.NetPnl); + Assert.Equal(0m, k.ProfitFactor); + } + + [Fact] + public void EquityCurve_is_cumulative_and_time_ordered() + { + var curve = TradeAnalytics.EquityCurve(new[] + { + Rec(5m, T0.AddMinutes(2)), + Rec(10m, T0.AddMinutes(1)), + Rec(-3m, T0.AddMinutes(3)) + }); + + Assert.Equal(3, curve.Count); + Assert.Equal(10m, curve[0].Cumulative); // frühester zuerst + Assert.Equal(15m, curve[1].Cumulative); + Assert.Equal(12m, curve[2].Cumulative); + } + + [Fact] + public void PnlByKey_groups_and_orders_desc() + { + var byModule = TradeAnalytics.PnlByKey(new[] + { + Rec(10m, T0, module: "A"), Rec(-2m, T0, module: "A"), Rec(5m, T0, module: "B") + }, t => t.ModuleName); + + Assert.Equal(2, byModule.Count); + Assert.Equal("A", byModule[0].Key); // 8 vor 5 + Assert.Equal(8m, byModule[0].Pnl); + Assert.Equal(2, byModule[0].Count); + Assert.Equal("B", byModule[1].Key); + } + + [Fact] + public void PnlByDay_groups_by_calendar_day() + { + var byDay = TradeAnalytics.PnlByDay(new[] + { + Rec(4m, T0), Rec(6m, T0.AddHours(2)), Rec(-1m, T0.AddDays(1)) + }); + + Assert.Equal(2, byDay.Count); + Assert.Equal(10m, byDay[0].Pnl); // Tag 1: 4+6 + Assert.Equal(-1m, byDay[1].Pnl); // Tag 2 + } + + [Fact] + public void WindowSummary_pnl_winrate_count() + { + var (pnl, winRate, count) = TradeAnalytics.WindowSummary(new[] + { + Rec(3m, T0), Rec(-1m, T0), Rec(2m, T0), Rec(-4m, T0) + }); + + Assert.Equal(0m, pnl); + Assert.Equal(50m, winRate); + Assert.Equal(4, count); + } + } +}