Files
PolyTraderSharp/Ui/Views/DashboardView.cs
T
RichardandClaude Opus 5 0b8728b25f Dashboard nach Avalonia portiert - LiveCharts2 laeuft
Drittes Fenster: KPI-Kacheln, drei Diagramme, Tradehistorie mit Suche/Filter und
Modul-Aktivierung. Aufbau nach docs/UI-SPEZIFIKATION-WinForms.md.

- Diagramme sind jetzt echte Steuerelemente (LiveCharts2) statt nach Bitmap gerenderter
  ScottPlot-Bilder: interaktiv (Tooltips/Zoom) und ohne System.Drawing. Die Auswertung
  kommt unveraendert aus TradeAnalytics - der Chart-Wechsel beruehrte keine Fachlogik.
  Das war der Zweck der bestehenden Trennung und hat sich hier ausgezahlt.
- ModuleActivationInfo vom UI-Typ in den Core verschoben (PolyTrader.Core.Modularity):
  Aussage ueber die Modularitaet, keine Darstellungsfrage - beide Shells brauchen sie.
- Modul-Tab listet auch NICHT geladene Module, sonst liessen sie sich nie reaktivieren.
  Blockierte Module haben eine deaktivierte Schaltflaeche statt eines Hinweisdialogs;
  der Grund steht ohnehin in der Spalte 'Hinweis'.

WICHTIG - Avalonia 12 -> 11.3.19 zurueckgenommen:
Der erste Wurf zog per Version='*' Avalonia 12.1.1. LiveCharts2 2.0.5 (die aktuellste
Version) ist gegen Avalonia 11 gebaut und bricht dort zur Laufzeit:
MissingFieldException 'Avalonia.Input.Gestures.PinchEvent'. Avalonia 12 ist dem
Chart-Oekosystem voraus. Jetzt 11.3.19 (DataGrid folgt eigener Reihe: 11.3.13).
Erst wieder anheben, wenn LiveCharts2 Avalonia 12 unterstuetzt.

Verifiziert: Solution baut, 442 Tests gruen, --smoke-ui gruen (alle 4 Fenster),
App laeuft real mit allen Trading-Diensten, publisht fuer linux-x64.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 20:45:07 +02:00

408 lines
17 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
{
/// <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 IReadOnlyList<ModuleActivationInfo>? _moduleInfos; // alle Module (auch deaktivierte)
private string? _settingsPath; // Quelle/Ziel der Modul-Aktivierung
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(); };
dgvModules.CellContentClick += Modules_CellContentClick;
}
/// <param name="modules">
/// Alle bekannten Module (auch deaktivierte) für den Tab „Module". <c>null</c> = kein
/// Modul-Management (Tab wird entfernt, z.B. im Headless-Smoke-Test).
/// </param>
/// <param name="settingsPath">Pfad der Server-Settings-Datei (Ziel für die Ein/Aus-Persistenz).</param>
public void Initialize(ITradeLogRepository tradeLog, TradingState state,
IReadOnlyList<ModuleActivationInfo>? 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<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;
}
// ===== 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<string> LoadDisabledModules()
{
try
{
var s = ServerSettings.Load(_settingsPath!);
return new HashSet<string>(s.DisabledModules, StringComparer.OrdinalIgnoreCase);
}
catch
{
return new HashSet<string>(StringComparer.OrdinalIgnoreCase);
}
}
private void PopulateModules()
{
if (_moduleInfos == null) return;
var disabled = LoadDisabledModules();
var rows = new List<ModuleRow>();
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<ModuleRow>(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<string>(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);
}
}
/// <summary>Anzeige-Zeile des Modul-Grids (Bindung über DataPropertyName).</summary>
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;
}
}
/// <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; }
}
}