UI: Dashboard-Ueberarbeitung (Scope-Filter, KPIs, ScottPlot-Charts, Tradehistorie)
Das Dashboard ist keine stumpfe Trade-Liste mehr, sondern ein Auswertungs-Fenster:
- ToolStrip (via Designer): Scope-Filter Konto/Modul/Live-Demo/Zeitraum + Aktualisieren.
- TabControl mit 2 Tabs:
- Dashboard: KPI-Kacheln (Netto-PnL/Winrate/Trades/O-PnL/Profit-Faktor) + 3 Charts
(Equity-Kurve, PnL je Modul, PnL je Tag) fuer den gewaehlten Scope.
- Tradehistorie: gefilterte Trade-Liste (Spalten via Designer) + Suche + Gewinner/Verlierer.
- Charts via ScottPlot CORE-Paket (nur SkiaSharp, .NET-nativ) -> als Bitmap in PictureBoxen
gerendert; KEINE OpenTK/.NET-Framework-Transitiven (bewusst nicht ScottPlot.WinForms).
- Auswertungslogik pur in TradeAnalytics (getestet). In-Memory-Filter auf gecachtem Recent-Set.
- Smoke-UI konstruiert die DashboardView jetzt direkt -> verifiziert das Chart-Rendering headless.
Build 0 Fehler, 331 Tests gruen, --smoke-ui: [OK] core.dashboard konstruiert (inkl. Charts).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
7d63791e38
commit
b6dff7e210
+209
-26
@@ -1,66 +1,244 @@
|
||||
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.Persistence;
|
||||
using PolyTraderSharp.Models;
|
||||
|
||||
namespace PolyTraderSharp.Ui.Views
|
||||
{
|
||||
/// <summary>
|
||||
/// Modulübergreifendes Dashboard: zeigt die letzten Trades ALLER Module aus dem
|
||||
/// generischen Core-Trade-Log (ITradeLogRepository) und eine Kurzauswertung.
|
||||
/// Designbar (DashboardView.Designer.cs).
|
||||
/// 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 <see cref="TradeAnalytics"/>; Charts via ScottPlot
|
||||
/// (als Bitmap gerendert – kein WinForms-GL-Control, saubere Dependencies).
|
||||
/// </summary>
|
||||
public partial class DashboardView : Form
|
||||
{
|
||||
private ITradeLogRepository? _tradeLog;
|
||||
private TradingState? _state;
|
||||
|
||||
private List<TradeRecord> _allTrades = new(); // zuletzt geladener Roh-Satz
|
||||
private List<DashboardTradeRow> _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";
|
||||
btnRefresh.Click += (_, _) => LoadData();
|
||||
|
||||
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(); };
|
||||
}
|
||||
|
||||
public void Initialize(ITradeLogRepository tradeLog, TradingState state)
|
||||
{
|
||||
_tradeLog = tradeLog;
|
||||
_state = state;
|
||||
LoadData();
|
||||
RefreshData();
|
||||
}
|
||||
|
||||
private void LoadData()
|
||||
// ===== Daten laden / Scope =====
|
||||
|
||||
private void RefreshData()
|
||||
{
|
||||
if (_tradeLog == null) return;
|
||||
try { _allTrades = _tradeLog.GetRecent(5000); }
|
||||
catch { _allTrades = new List<TradeRecord>(); } // DB nicht bereit -> leer statt Absturz
|
||||
|
||||
var rows = _tradeLog.GetRecent(300).Select(r => new DashboardTradeRow
|
||||
{
|
||||
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
|
||||
}).ToList();
|
||||
_loading = true;
|
||||
PopulateScopeCombos();
|
||||
_loading = false;
|
||||
|
||||
dgvTrades.DataSource = new BindingList<DashboardTradeRow>(rows);
|
||||
|
||||
decimal totalPnl = rows.Sum(x => x.RealizedPnl);
|
||||
var perModule = rows.GroupBy(x => x.Module).Select(g => $"{g.Key}: {g.Count()}");
|
||||
lblSummary.Text = $"{rows.Count} Trades | PnL gesamt: {totalPnl:F2} USDC | {string.Join(" · ", perModule)}";
|
||||
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<TradeRecord> 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<TradeRecord> 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<ScottPlot.Plot> 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<DashboardTradeRow> 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<DashboardTradeRow>(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)" : "";
|
||||
@@ -68,9 +246,14 @@ namespace PolyTraderSharp.Ui.Views
|
||||
return acc.Name + suffix;
|
||||
return $"#{accountId}{suffix}";
|
||||
}
|
||||
|
||||
private sealed record FilterAccount(int Id, string Label)
|
||||
{
|
||||
public override string ToString() => Label;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Anzeige-Zeile für das Dashboard-Grid (Account bereits zu Name aufgelöst).</summary>
|
||||
/// <summary>Anzeige-Zeile für das Historie-Grid (Account bereits zu Name aufgelöst).</summary>
|
||||
public class DashboardTradeRow
|
||||
{
|
||||
public string Module { get; set; } = string.Empty;
|
||||
|
||||
Reference in New Issue
Block a user