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>
272 lines
11 KiB
C#
272 lines
11 KiB
C#
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 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";
|
||
|
||
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;
|
||
RefreshData();
|
||
}
|
||
|
||
// ===== 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
|
||
|
||
_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<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)" : "";
|
||
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;
|
||
}
|
||
}
|
||
|
||
/// <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;
|
||
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; }
|
||
}
|
||
}
|