- Fenster-Menue: WindowMenu fuellt die oberste MenuStrip mit Top-Level-Eintraegen nebeneinander (mit Icon) statt Untermenue "Fenster"; ModuleView.Icon zentral in der App zugewiesen (AssignMenuIcons). Kein miFenster mehr im Launcher-Designer. - Sicheres Beenden: ShutdownConfirmDialog (10s-Timer sperrt "Jetzt beenden", Abbrechen jederzeit) via IModuleUiHost.RequestShutdown(). Nur der Launcher (Hauptprozess) bietet "Beenden"; andere Fenster nur "Fenster schliessen" (kein App-Shutdown, Module laufen weiter). Launcher-Schliessen-X routet ueber dieselbe Abfrage. - Modul-Aktivierung (restart-basiert): ServerSettings.DisabledModules, in Program.Main vor der DI-Registrierung gefiltert -> deaktivierte Module werden nicht geladen. Dashboard-Tab "Module" zeigt Status + schaltet um (Hinweis: greift nach Neustart). Optionaler IPolyTraderModule.GetActivationBlocker fuer "nicht aktivierbar"-Info. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
413 lines
17 KiB
C#
413 lines
17 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 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>
|
||
/// Aktivierungs-Info eines Moduls fürs Dashboard: Name, ob es in dieser Session läuft und ein
|
||
/// optionaler Grund, warum es nicht aktivierbar ist (z.B. fehlender API-Key).
|
||
/// </summary>
|
||
public sealed record ModuleActivationInfo(string Name, bool IsRunning, string? Blocker);
|
||
|
||
/// <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; }
|
||
}
|
||
}
|