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>
This commit is contained in:
Richard
2026-08-06 20:45:07 +02:00
co-authored by Claude Opus 5
parent cd59e5c0a5
commit 0b8728b25f
9 changed files with 737 additions and 18 deletions
@@ -0,0 +1,171 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:controls="using:PolyTrader.App.Avalonia.Controls"
xmlns:vm="using:PolyTrader.App.Avalonia.ViewModels"
xmlns:lvc="using:LiveChartsCore.SkiaSharpView.Avalonia"
x:Class="PolyTrader.App.Avalonia.Views.DashboardWindow"
x:DataType="vm:DashboardChartsModel"
Title="Dashboard"
Width="1714" Height="1083">
<!--
Modulübergreifendes Dashboard aus dem Core-Trade-Log.
Aufbau übernommen aus der WinForms-Fassung (docs/UI-SPEZIFIKATION-WinForms.md):
Tab „Dashboard" mit KPI-Kacheln und drei Diagrammen, Tab „Tradehistorie" mit
gefilterter Liste, Tab „Module" zum Aktivieren/Deaktivieren (wirkt nach Neustart).
Die Diagramme sind jetzt echte Steuerelemente (LiveCharts2) statt gerenderter Bitmaps —
damit interaktiv (Tooltips, Zoom) und ohne System.Drawing.
-->
<DockPanel>
<controls:WindowMenuBar Name="menuBar" DockPanel.Dock="Top" />
<Border Classes="toolbar" DockPanel.Dock="Top">
<StackPanel Orientation="Horizontal" Spacing="8">
<Button Name="btnRefresh" Content="Aktualisieren" />
<TextBlock Text="Konto:" VerticalAlignment="Center" />
<ComboBox Name="cbAccount" MinWidth="180" />
<TextBlock Text="Modul:" VerticalAlignment="Center" />
<ComboBox Name="cbModule" MinWidth="150" />
<TextBlock Text="Modus:" VerticalAlignment="Center" />
<ComboBox Name="cbMode" MinWidth="100" />
<TextBlock Text="Zeitraum:" VerticalAlignment="Center" />
<ComboBox Name="cbRange" MinWidth="110" />
</StackPanel>
</Border>
<TabControl Name="tabs">
<TabItem Header="Dashboard">
<Grid RowDefinitions="Auto,*" Margin="10">
<!-- KPI-Kacheln: zweizeilig (Titel oben, Wert darunter) wie zuvor. -->
<WrapPanel Grid.Row="0" Orientation="Horizontal">
<Border Classes="kpi">
<StackPanel>
<TextBlock Classes="caption" Text="Netto-PnL" />
<TextBlock Name="kpiPnl" Classes="value" Text="—" />
</StackPanel>
</Border>
<Border Classes="kpi">
<StackPanel>
<TextBlock Classes="caption" Text="Winrate" />
<TextBlock Name="kpiWin" Classes="value" Text="—" />
</StackPanel>
</Border>
<Border Classes="kpi">
<StackPanel>
<TextBlock Classes="caption" Text="Trades" />
<TextBlock Name="kpiTrades" Classes="value" Text="—" />
</StackPanel>
</Border>
<Border Classes="kpi">
<StackPanel>
<TextBlock Classes="caption" Text="Ø PnL/Trade" />
<TextBlock Name="kpiAvg" Classes="value" Text="—" />
</StackPanel>
</Border>
<Border Classes="kpi">
<StackPanel>
<TextBlock Classes="caption" Text="Profit-Faktor" />
<TextBlock Name="kpiPf" Classes="value" Text="—" />
</StackPanel>
</Border>
</WrapPanel>
<Grid Grid.Row="1" RowDefinitions="*,*" ColumnDefinitions="*,*" Margin="0,12,0,0">
<Border Grid.Row="0" Grid.Column="0" Grid.ColumnSpan="2"
BorderBrush="#DDDDDD" BorderThickness="1" Margin="0,0,0,8">
<DockPanel>
<TextBlock DockPanel.Dock="Top" Margin="8,6"
FontWeight="SemiBold" Text="Equity-Kurve (kumulierter PnL)" />
<lvc:CartesianChart Name="chartEquity"
Series="{Binding EquitySeries}"
XAxes="{Binding EquityXAxes}"
TooltipPosition="Top" />
</DockPanel>
</Border>
<Border Grid.Row="1" Grid.Column="0"
BorderBrush="#DDDDDD" BorderThickness="1" Margin="0,0,4,0">
<DockPanel>
<TextBlock DockPanel.Dock="Top" Margin="8,6"
FontWeight="SemiBold" Text="PnL je Modul" />
<lvc:CartesianChart Name="chartModule"
Series="{Binding ModuleSeries}"
XAxes="{Binding ModuleXAxes}" />
</DockPanel>
</Border>
<Border Grid.Row="1" Grid.Column="1"
BorderBrush="#DDDDDD" BorderThickness="1" Margin="4,0,0,0">
<DockPanel>
<TextBlock DockPanel.Dock="Top" Margin="8,6"
FontWeight="SemiBold" Text="PnL je Tag" />
<lvc:CartesianChart Name="chartDay"
Series="{Binding DaySeries}"
XAxes="{Binding DayXAxes}" />
</DockPanel>
</Border>
</Grid>
</Grid>
</TabItem>
<TabItem Header="Tradehistorie">
<DockPanel>
<Border Classes="toolbar" DockPanel.Dock="Top">
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock Text="Suche:" VerticalAlignment="Center" />
<TextBox Name="tbSearch" MinWidth="260" Watermark="Markt oder Outcome …" />
<TextBlock Text="Ergebnis:" VerticalAlignment="Center" />
<ComboBox Name="cbWinLoss" MinWidth="130" />
</StackPanel>
</Border>
<DataGrid Name="gridTrades" x:DataType="vm:DashboardTradeRow">
<DataGrid.Columns>
<DataGridTextColumn Header="Modul" Width="110" Binding="{Binding Module}" />
<DataGridTextColumn Header="Konto" Width="130" Binding="{Binding Account}" />
<DataGridTextColumn Header="Markt" Width="*" Binding="{Binding Market}" />
<DataGridTextColumn Header="Outcome" Width="80" Binding="{Binding Outcome}" />
<DataGridTextColumn Header="Side" Width="60" Binding="{Binding Side}" />
<DataGridTextColumn Header="Entry" Width="70" Binding="{Binding EntryPrice, StringFormat=\{0:F3\}}" />
<DataGridTextColumn Header="Exit" Width="70" Binding="{Binding ExitPrice, StringFormat=\{0:F3\}}" />
<DataGridTextColumn Header="Size" Width="70" Binding="{Binding Size, StringFormat=\{0:F2\}}" />
<DataGridTextColumn Header="PnL" Width="80" Binding="{Binding RealizedPnl, StringFormat=\{0:F2\}}" />
<DataGridTextColumn Header="PnL %" Width="70" Binding="{Binding PnlPercent, StringFormat=\{0:F1\}}" />
<DataGridTextColumn Header="Geschlossen" Width="140" Binding="{Binding ClosedAt, StringFormat=\{0:dd.MM.yyyy HH:mm\}}" />
</DataGrid.Columns>
</DataGrid>
</DockPanel>
</TabItem>
<TabItem Name="tabModules" Header="Module">
<DockPanel>
<TextBlock DockPanel.Dock="Top" Margin="10" TextWrapping="Wrap"
Text="Module aktivieren/deaktivieren. Änderungen greifen nach dem nächsten Neustart von PolyTrader." />
<DataGrid Name="gridModules" x:DataType="vm:ModuleRow">
<DataGrid.Columns>
<DataGridTextColumn Header="Modul" Width="220" Binding="{Binding Modul}" />
<DataGridTextColumn Header="Status" Width="260" Binding="{Binding Status}" />
<DataGridTextColumn Header="Hinweis" Width="*" Binding="{Binding Hinweis}" />
<DataGridTemplateColumn Header="Aktion" Width="160">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate x:DataType="vm:ModuleRow">
<Button Content="{Binding Aktion}"
Margin="2" Padding="8,2"
Click="OnModuleActionClick"
IsEnabled="{Binding CanToggle}" />
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
</DataGrid.Columns>
</DataGrid>
</DockPanel>
</TabItem>
</TabControl>
</DockPanel>
</Window>
@@ -0,0 +1,318 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Markup.Xaml;
using PolyTrader.App.Avalonia.ViewModels;
using PolyTrader.Core.Modularity;
using PolyTrader.Core.Persistence;
using PolyTraderSharp;
using PolyTraderSharp.Models;
namespace PolyTrader.App.Avalonia.Views
{
/// <summary>
/// Modulübergreifendes Dashboard aus dem Core-Trade-Log. Aufbau und Verhalten entsprechen der
/// bisherigen WinForms-Ansicht; die Auswertung kommt unverändert aus <c>TradeAnalytics</c>,
/// die Diagramme jetzt aus <see cref="DashboardChartsModel"/> (LiveCharts2).
/// Layout vollständig in DashboardWindow.axaml.
/// </summary>
public partial class DashboardWindow : Window
{
private readonly DashboardChartsModel _charts = new();
private readonly ObservableCollection<DashboardTradeRow> _history = new();
private readonly ObservableCollection<ModuleRow> _modules = new();
private ITradeLogRepository? _tradeLog;
private TradingState? _state;
private IReadOnlyList<ModuleActivationInfo>? _moduleInfos;
private string? _settingsPath;
private List<TradeRecord> _allTrades = new();
private List<DashboardTradeRow> _historyBase = new();
private bool _loading;
public DashboardWindow() => AvaloniaXamlLoader.Load(this);
/// <param name="moduleInfos">
/// Alle bekannten Module (auch deaktivierte) für den Tab „Module". <c>null</c> = kein
/// Modul-Management (Tab wird ausgeblendet, z.B. im Smoke-Test).
/// </param>
public DashboardWindow(IModuleUiHost host, ITradeLogRepository tradeLog, TradingState state,
IReadOnlyList<ModuleActivationInfo>? moduleInfos = null, string? settingsPath = null) : this()
{
this.FindControl<Controls.WindowMenuBar>("menuBar")!.Attach(host, "core.dashboard", this);
_tradeLog = tradeLog;
_state = state;
_moduleInfos = moduleInfos;
_settingsPath = settingsPath;
DataContext = _charts;
this.FindControl<DataGrid>("gridTrades")!.ItemsSource = _history;
this.FindControl<DataGrid>("gridModules")!.ItemsSource = _modules;
var cbMode = this.FindControl<ComboBox>("cbMode")!;
var cbRange = this.FindControl<ComboBox>("cbRange")!;
var cbWinLoss = this.FindControl<ComboBox>("cbWinLoss")!;
cbMode.ItemsSource = new[] { "Alle", "Live", "Demo" };
cbRange.ItemsSource = new[] { "7 Tage", "30 Tage", "90 Tage", "Alle" };
cbWinLoss.ItemsSource = new[] { "Alle", "Gewinner", "Verlierer" };
cbMode.SelectedIndex = 0;
cbRange.SelectedIndex = 1; // 30 Tage, wie bisher
cbWinLoss.SelectedIndex = 0;
this.FindControl<Button>("btnRefresh")!.Click += (_, _) => RefreshData();
foreach (var name in new[] { "cbAccount", "cbModule", "cbMode", "cbRange" })
this.FindControl<ComboBox>(name)!.SelectionChanged += (_, _) => { if (!_loading) ApplyScope(); };
this.FindControl<TextBox>("tbSearch")!.TextChanged += (_, _) => { if (!_loading) ApplyHistoryFilter(); };
cbWinLoss.SelectionChanged += (_, _) => { if (!_loading) ApplyHistoryFilter(); };
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()
{
var cbAccount = this.FindControl<ComboBox>("cbAccount")!;
var cbModule = this.FindControl<ComboBox>("cbModule")!;
int selectedAcc = (cbAccount.SelectedItem as FilterAccount)?.Id ?? -1;
var accounts = new List<FilterAccount> { new(-1, "Alle Konten") };
if (_state != null)
accounts.AddRange(_state.Accounts.Values.OrderBy(a => a.AccountId)
.Select(a => new FilterAccount(a.AccountId,
(string.IsNullOrEmpty(a.Name) ? $"#{a.AccountId}" : a.Name) + (a.IsDemo ? " (Demo)" : ""))));
cbAccount.ItemsSource = accounts;
int accIdx = accounts.FindIndex(a => a.Id == selectedAcc);
cbAccount.SelectedIndex = accIdx >= 0 ? accIdx : 0;
string selectedMod = cbModule.SelectedItem as string ?? "Alle Module";
var mods = new List<string> { "Alle Module" };
mods.AddRange(_allTrades.Select(t => t.ModuleName)
.Where(m => !string.IsNullOrEmpty(m))
.Distinct().OrderBy(m => m));
cbModule.ItemsSource = mods;
int modIdx = mods.IndexOf(selectedMod);
cbModule.SelectedIndex = modIdx >= 0 ? modIdx : 0;
}
private void ApplyScope()
{
int accId = (this.FindControl<ComboBox>("cbAccount")!.SelectedItem as FilterAccount)?.Id ?? -1;
string module = this.FindControl<ComboBox>("cbModule")!.SelectedItem as string ?? "Alle Module";
string mode = this.FindControl<ComboBox>("cbMode")!.SelectedItem as string ?? "Alle";
int? days = this.FindControl<ComboBox>("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);
_charts.Update(scoped);
_historyBase = scoped.OrderByDescending(t => t.ClosedAt).Select(ToRow).ToList();
ApplyHistoryFilter();
}
// ===== KPIs =====
private void UpdateKpis(List<TradeRecord> scoped)
{
var k = Core.Analytics.TradeAnalytics.ComputeKpis(scoped);
var pnl = this.FindControl<TextBlock>("kpiPnl")!;
pnl.Text = $"{k.NetPnl:N2} USDC";
pnl.Foreground = new global::Avalonia.Media.SolidColorBrush(
k.NetPnl >= 0 ? global::Avalonia.Media.Color.FromRgb(0x2E, 0x7D, 0x32)
: global::Avalonia.Media.Color.FromRgb(0xC6, 0x28, 0x28));
this.FindControl<TextBlock>("kpiWin")!.Text = $"{k.WinRatePct:N1} %";
this.FindControl<TextBlock>("kpiTrades")!.Text = k.TradeCount.ToString();
this.FindControl<TextBlock>("kpiAvg")!.Text = $"{k.AvgPnlPerTrade:N2}";
// Verlustfreies Set wird als ∞ dargestellt (wie bisher), nicht als 999.
this.FindControl<TextBlock>("kpiPf")!.Text =
k.ProfitFactor >= Core.Analytics.TradeAnalytics.NoLossProfitFactor
? "∞"
: k.ProfitFactor.ToString("N2");
}
// ===== Tradehistorie =====
private void ApplyHistoryFilter()
{
string search = this.FindControl<TextBox>("tbSearch")!.Text?.Trim() ?? string.Empty;
string winLoss = this.FindControl<ComboBox>("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);
_history.Clear();
foreach (var r in rows) _history.Add(r);
}
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()
{
var tab = this.FindControl<TabItem>("tabModules")!;
if (_moduleInfos == null || string.IsNullOrEmpty(_settingsPath))
{
tab.IsVisible = false; // kein Modul-Management (z.B. Smoke-Test)
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();
_modules.Clear();
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";
}
_modules.Add(new ModuleRow
{
Modul = m.Name, Status = status, Hinweis = hint, Aktion = action, CanToggle = !blocked
});
}
}
private void OnModuleActionClick(object? sender, RoutedEventArgs e)
{
if (_moduleInfos == null || string.IsNullOrEmpty(_settingsPath)) return;
if (sender is not Button { DataContext: ModuleRow row }) return;
var info = _moduleInfos.FirstOrDefault(m => m.Name == row.Modul);
if (info == null || !string.IsNullOrEmpty(info.Blocker)) return;
try
{
// Frisch aus der Datei lesen, damit parallele Änderungen nicht überschrieben werden.
var settings = ServerSettings.Load(_settingsPath!);
var set = new HashSet<string>(settings.DisabledModules, StringComparer.OrdinalIgnoreCase);
if (set.Contains(info.Name)) set.Remove(info.Name);
else set.Add(info.Name);
settings.DisabledModules = set.OrderBy(x => x).ToList();
settings.Save(_settingsPath!);
PopulateModules();
}
catch (Exception ex)
{
// Fehler landet im Status der Zeile statt in einem Dialog die Ansicht bleibt bedienbar.
_modules.Clear();
_modules.Add(new ModuleRow
{
Modul = info.Name,
Status = "Speichern fehlgeschlagen",
Hinweis = ex.Message,
Aktion = "—",
CanToggle = false
});
}
}
}
}