using System; using System.Collections.Generic; using System.ComponentModel; using System.IO; using System.Linq; using System.Windows.Forms; using PolyTrader.Core.Analytics; using PolyTrader.Core.Modularity; using PolyTrader.Core.Persistence; using PolyTraderSharp.Models; namespace PolyTraderSharp.Ui.Views { /// /// Modulübergreifendes Dashboard aus dem generischen Core-Trade-Log (ITradeLogRepository). /// Tab „Dashboard": KPI-Kacheln + Charts (Equity-Kurve, PnL je Modul, PnL je Tag) für den im /// ToolStrip gewählten Scope (Konto/Modul/Live-Demo/Zeitraum). Tab „Tradehistorie": gefilterte /// Trade-Liste. Auswertungslogik pur in ; Charts via ScottPlot /// (als Bitmap gerendert – kein WinForms-GL-Control, saubere Dependencies). /// public partial class DashboardView : Form { private ITradeLogRepository? _tradeLog; private TradingState? _state; private IReadOnlyList? _moduleInfos; // alle Module (auch deaktivierte) private string? _settingsPath; // Quelle/Ziel der Modul-Aktivierung private List _allTrades = new(); // zuletzt geladener Roh-Satz private List _historyBase = new(); // Scope-gefiltert, Basis für die Historie-Filter private bool _loading; public DashboardView() { InitializeComponent(); colEntry.DefaultCellStyle.Format = "F3"; colExit.DefaultCellStyle.Format = "F3"; colSize.DefaultCellStyle.Format = "F2"; colPnl.DefaultCellStyle.Format = "F2"; colPnlPct.DefaultCellStyle.Format = "F1"; colClosedAt.DefaultCellStyle.Format = "dd.MM.yyyy HH:mm"; cbMode.Items.AddRange(new object[] { "Alle", "Live", "Demo" }); cbRange.Items.AddRange(new object[] { "7 Tage", "30 Tage", "90 Tage", "Alle" }); cbWinLoss.Items.AddRange(new object[] { "Alle", "Gewinner", "Verlierer" }); tsRefresh.Click += (_, _) => RefreshData(); cbAccount.SelectedIndexChanged += (_, _) => { if (!_loading) ApplyScope(); }; cbModule.SelectedIndexChanged += (_, _) => { if (!_loading) ApplyScope(); }; cbMode.SelectedIndexChanged += (_, _) => { if (!_loading) ApplyScope(); }; cbRange.SelectedIndexChanged += (_, _) => { if (!_loading) ApplyScope(); }; tbSearch.TextChanged += (_, _) => { if (!_loading) ApplyHistoryFilter(); }; cbWinLoss.SelectedIndexChanged += (_, _) => { if (!_loading) ApplyHistoryFilter(); }; dgvModules.CellContentClick += Modules_CellContentClick; } /// /// Alle bekannten Module (auch deaktivierte) für den Tab „Module". null = kein /// Modul-Management (Tab wird entfernt, z.B. im Headless-Smoke-Test). /// /// Pfad der Server-Settings-Datei (Ziel für die Ein/Aus-Persistenz). public void Initialize(ITradeLogRepository tradeLog, TradingState state, IReadOnlyList? modules = null, string? settingsPath = null) { _tradeLog = tradeLog; _state = state; _moduleInfos = modules; _settingsPath = settingsPath; RefreshData(); InitModulesTab(); } // ===== Daten laden / Scope ===== private void RefreshData() { if (_tradeLog == null) return; try { _allTrades = _tradeLog.GetRecent(5000); } catch { _allTrades = new List(); } // DB nicht bereit -> leer statt Absturz _loading = true; PopulateScopeCombos(); _loading = false; ApplyScope(); } private void PopulateScopeCombos() { // Konten var selectedAcc = (cbAccount.SelectedItem as FilterAccount)?.Id ?? -1; cbAccount.Items.Clear(); cbAccount.Items.Add(new FilterAccount(-1, "Alle Konten")); if (_state != null) foreach (var a in _state.Accounts.Values.OrderBy(a => a.AccountId)) cbAccount.Items.Add(new FilterAccount(a.AccountId, (string.IsNullOrEmpty(a.Name) ? $"#{a.AccountId}" : a.Name) + (a.IsDemo ? " (Demo)" : ""))); cbAccount.SelectedIndex = Math.Max(0, IndexOfAccount(selectedAcc)); // Module (aus den vorhandenen Daten) string selectedMod = cbModule.SelectedItem as string ?? "Alle Module"; cbModule.Items.Clear(); cbModule.Items.Add("Alle Module"); foreach (var m in _allTrades.Select(t => t.ModuleName).Where(m => !string.IsNullOrEmpty(m)).Distinct().OrderBy(m => m)) cbModule.Items.Add(m); int modIdx = cbModule.Items.IndexOf(selectedMod); cbModule.SelectedIndex = modIdx >= 0 ? modIdx : 0; if (cbMode.SelectedIndex < 0) cbMode.SelectedIndex = 0; // Alle if (cbRange.SelectedIndex < 0) cbRange.SelectedIndex = 1; // 30 Tage if (cbWinLoss.SelectedIndex < 0) cbWinLoss.SelectedIndex = 0; } private int IndexOfAccount(int id) { for (int i = 0; i < cbAccount.Items.Count; i++) if (cbAccount.Items[i] is FilterAccount fa && fa.Id == id) return i; return 0; } private void ApplyScope() { int accId = (cbAccount.SelectedItem as FilterAccount)?.Id ?? -1; string module = cbModule.SelectedItem as string ?? "Alle Module"; string mode = cbMode.SelectedItem as string ?? "Alle"; int? days = (cbRange.SelectedItem as string) switch { "7 Tage" => 7, "30 Tage" => 30, "90 Tage" => 90, _ => (int?)null }; DateTime? since = days.HasValue ? DateTime.UtcNow.AddDays(-days.Value) : null; var scoped = _allTrades.Where(t => (accId < 0 || t.AccountId == accId) && (module == "Alle Module" || t.ModuleName == module) && (mode == "Alle" || (mode == "Live" && !t.IsDemo) || (mode == "Demo" && t.IsDemo)) && (!since.HasValue || t.ClosedAt >= since.Value)).ToList(); UpdateKpis(scoped); RenderCharts(scoped); _historyBase = scoped .OrderByDescending(t => t.ClosedAt) .Select(ToRow) .ToList(); ApplyHistoryFilter(); } // ===== KPIs ===== private void UpdateKpis(List scoped) { var k = TradeAnalytics.ComputeKpis(scoped); lblKpiPnl.Text = $"Netto-PnL\n{k.NetPnl:N2} USDC"; lblKpiPnl.ForeColor = k.NetPnl >= 0 ? System.Drawing.Color.ForestGreen : System.Drawing.Color.Firebrick; lblKpiWin.Text = $"Winrate\n{k.WinRatePct:N1} %"; lblKpiTrades.Text = $"Trades\n{k.TradeCount}"; lblKpiAvg.Text = $"Ø PnL/Trade\n{k.AvgPnlPerTrade:N2}"; lblKpiPf.Text = $"Profit-Faktor\n{(k.ProfitFactor >= TradeAnalytics.NoLossProfitFactor ? "∞" : k.ProfitFactor.ToString("N2"))}"; } // ===== Charts (ScottPlot -> Bitmap) ===== private void RenderCharts(List scoped) { RenderPlot(picEquity, plot => { var curve = TradeAnalytics.EquityCurve(scoped); plot.Title("Equity-Kurve (kumulierter PnL)"); if (curve.Count == 0) return; double[] xs = curve.Select(p => p.At.ToOADate()).ToArray(); double[] ys = curve.Select(p => (double)p.Cumulative).ToArray(); plot.Add.Scatter(xs, ys); plot.Axes.DateTimeTicksBottom(); }); RenderPlot(picModule, plot => { var byMod = TradeAnalytics.PnlByKey(scoped, t => string.IsNullOrEmpty(t.ModuleName) ? "—" : t.ModuleName); plot.Title("PnL je Modul"); if (byMod.Count == 0) return; plot.Add.Bars(byMod.Select(x => (double)x.Pnl).ToArray()); SetCategoryTicks(plot, byMod.Select(x => x.Key).ToArray()); }); RenderPlot(picDay, plot => { var byDay = TradeAnalytics.PnlByDay(scoped); plot.Title("PnL je Tag"); if (byDay.Count == 0) return; plot.Add.Bars(byDay.Select(x => (double)x.Pnl).ToArray()); SetCategoryTicks(plot, byDay.Select(x => x.Day.ToString("dd.MM")).ToArray()); }); } private static void SetCategoryTicks(ScottPlot.Plot plot, string[] labels) { var ticks = new ScottPlot.TickGenerators.NumericManual(); for (int i = 0; i < labels.Length; i++) ticks.AddMajor(i, labels[i]); plot.Axes.Bottom.TickGenerator = ticks; } private static void RenderPlot(PictureBox pic, Action build) { int w = Math.Max(pic.ClientSize.Width, 300); int h = Math.Max(pic.ClientSize.Height, 200); var plot = new ScottPlot.Plot(); try { build(plot); } catch { /* Chart-Rendering darf die UI nie killen */ } byte[] png = plot.GetImage(w, h).GetImageBytes(); using var ms = new MemoryStream(png); using var img = System.Drawing.Image.FromStream(ms); var old = pic.Image; pic.Image = new System.Drawing.Bitmap(img); old?.Dispose(); } // ===== Tradehistorie ===== private void ApplyHistoryFilter() { string search = tbSearch.Text.Trim(); string winLoss = cbWinLoss.SelectedItem as string ?? "Alle"; IEnumerable rows = _historyBase; if (search.Length > 0) rows = rows.Where(r => (r.Market?.Contains(search, StringComparison.OrdinalIgnoreCase) ?? false) || (r.Outcome?.Contains(search, StringComparison.OrdinalIgnoreCase) ?? false)); if (winLoss == "Gewinner") rows = rows.Where(r => r.RealizedPnl > 0m); else if (winLoss == "Verlierer") rows = rows.Where(r => r.RealizedPnl < 0m); dgvTrades.DataSource = new BindingList(rows.ToList()); } private DashboardTradeRow ToRow(TradeRecord r) => new() { Module = r.ModuleName, Account = ResolveAccount(r.AccountId, r.IsDemo), Market = r.MarketQuestion, Outcome = r.Outcome, Side = r.Side, EntryPrice = r.EntryPrice, ExitPrice = r.ExitPrice, Size = r.Size, RealizedPnl = r.RealizedPnl, PnlPercent = r.PnlPercent, ClosedAt = r.ClosedAt }; private string ResolveAccount(int accountId, bool isDemo) { string suffix = isDemo ? " (Demo)" : ""; if (_state != null && _state.Accounts.TryGetValue(accountId, out var acc) && !string.IsNullOrEmpty(acc.Name)) return acc.Name + suffix; return $"#{accountId}{suffix}"; } private sealed record FilterAccount(int Id, string Label) { public override string ToString() => Label; } // ===== Module aktivieren/deaktivieren (neustart-basiert) ===== private void InitModulesTab() { if (_moduleInfos == null || string.IsNullOrEmpty(_settingsPath)) { // Kein Modul-Management (z.B. Smoke-Test) -> Tab entfernen statt leer anzuzeigen. if (tabControlDash.TabPages.Contains(tabModules)) tabControlDash.TabPages.Remove(tabModules); return; } PopulateModules(); } private HashSet LoadDisabledModules() { try { var s = ServerSettings.Load(_settingsPath!); return new HashSet(s.DisabledModules, StringComparer.OrdinalIgnoreCase); } catch { return new HashSet(StringComparer.OrdinalIgnoreCase); } } private void PopulateModules() { if (_moduleInfos == null) return; var disabled = LoadDisabledModules(); var rows = new List(); foreach (var m in _moduleInfos) { bool blocked = !string.IsNullOrEmpty(m.Blocker); bool desiredEnabled = !disabled.Contains(m.Name); string status, hint, action; if (blocked) { status = "⚠ Nicht aktivierbar"; hint = m.Blocker!; action = "—"; } else if (m.IsRunning && desiredEnabled) { status = "Aktiv"; hint = ""; action = "Deaktivieren"; } else if (m.IsRunning && !desiredEnabled) { status = "Aktiv – stoppt nach Neustart"; hint = "Änderung greift nach Neustart"; action = "Aktivieren"; } else if (!m.IsRunning && desiredEnabled) { status = "Startet nach Neustart"; hint = "Änderung greift nach Neustart"; action = "Deaktivieren"; } else { status = "Deaktiviert"; hint = ""; action = "Aktivieren"; } rows.Add(new ModuleRow { Modul = m.Name, Status = status, Hinweis = hint, Aktion = action }); } dgvModules.DataSource = new BindingList(rows); } private void Modules_CellContentClick(object? sender, DataGridViewCellEventArgs e) { if (_moduleInfos == null || string.IsNullOrEmpty(_settingsPath)) return; if (e.RowIndex < 0 || e.ColumnIndex < 0) return; if (dgvModules.Columns[e.ColumnIndex].Name != "colModAction") return; if (dgvModules.Rows[e.RowIndex].DataBoundItem is not ModuleRow row) return; var info = _moduleInfos.FirstOrDefault(m => m.Name == row.Modul); if (info == null) return; if (!string.IsNullOrEmpty(info.Blocker)) { MessageBox.Show($"Modul „{info.Name}“ kann nicht aktiviert werden:\n\n{info.Blocker}", "Aktivierung nicht möglich", MessageBoxButtons.OK, MessageBoxIcon.Information); return; } try { // Frisch aus der Datei lesen, damit parallele Änderungen (z.B. Settings-Fenster) nicht überschrieben werden. var settings = ServerSettings.Load(_settingsPath!); var set = new HashSet(settings.DisabledModules, StringComparer.OrdinalIgnoreCase); bool nowDisabled; if (set.Contains(info.Name)) { set.Remove(info.Name); nowDisabled = false; } else { set.Add(info.Name); nowDisabled = true; } settings.DisabledModules = set.OrderBy(x => x).ToList(); settings.Save(_settingsPath!); PopulateModules(); MessageBox.Show( $"Modul „{info.Name}“ wird beim nächsten Start {(nowDisabled ? "NICHT mehr geladen" : "geladen")}.\n\n" + "Die Änderung greift erst nach einem Neustart von PolyTrader.", "Gespeichert", MessageBoxButtons.OK, MessageBoxIcon.Information); } catch (Exception ex) { MessageBox.Show($"Konnte die Modul-Einstellung nicht speichern: {ex.Message}", "Fehler", MessageBoxButtons.OK, MessageBoxIcon.Error); } } /// Anzeige-Zeile des Modul-Grids (Bindung über DataPropertyName). private sealed class ModuleRow { public string Modul { get; set; } = string.Empty; public string Status { get; set; } = string.Empty; public string Hinweis { get; set; } = string.Empty; public string Aktion { get; set; } = string.Empty; } } /// Anzeige-Zeile für das Historie-Grid (Account bereits zu Name aufgelöst). public class DashboardTradeRow { public string Module { get; set; } = string.Empty; public string Account { get; set; } = string.Empty; public string Market { get; set; } = string.Empty; public string Outcome { get; set; } = string.Empty; public string Side { get; set; } = string.Empty; public decimal EntryPrice { get; set; } public decimal ExitPrice { get; set; } public decimal Size { get; set; } public decimal RealizedPnl { get; set; } public decimal PnlPercent { get; set; } public DateTime ClosedAt { get; set; } } }