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:
@@ -1,4 +1,4 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<!-- Plattformneutral: dieselbe Anwendung laeuft unter Windows und Linux. Loest schrittweise
|
||||
@@ -17,11 +17,16 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Avalonia" Version="12.1.1" />
|
||||
<PackageReference Include="Avalonia.Desktop" Version="12.1.1" />
|
||||
<PackageReference Include="Avalonia.Themes.Fluent" Version="12.1.1" />
|
||||
<PackageReference Include="Avalonia.Fonts.Inter" Version="12.1.1" />
|
||||
<PackageReference Include="Avalonia.Controls.DataGrid" Version="12.1.1" />
|
||||
<!-- Bewusst die 11er-Linie: LiveCharts2 2.0.5 (aktuellste Version) ist gegen Avalonia 11
|
||||
gebaut und bricht unter 12 (Avalonia.Input.Gestures.PinchEvent existiert dort nicht mehr).
|
||||
Avalonia 12 ist dem Chart-Oekosystem voraus; 11.3.x ist die reife Linie mit vollstaendiger
|
||||
Paketunterstuetzung. Erst wieder anheben, wenn LiveCharts2 Avalonia 12 unterstuetzt. -->
|
||||
<PackageReference Include="Avalonia" Version="11.3.19" />
|
||||
<PackageReference Include="Avalonia.Desktop" Version="11.3.19" />
|
||||
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.3.19" />
|
||||
<PackageReference Include="Avalonia.Fonts.Inter" Version="11.3.19" />
|
||||
<!-- DataGrid folgt einer eigenen Versionsreihe und endet in der 11er-Linie bei 11.3.13. -->
|
||||
<PackageReference Include="Avalonia.Controls.DataGrid" Version="11.3.13" />
|
||||
<!-- Diagramme (Entscheidung: LiveCharts2 statt ScottPlot, aus optischen Gruenden). -->
|
||||
<PackageReference Include="LiveChartsCore.SkiaSharpView.Avalonia" Version="2.0.5" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.0" />
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using PolyTrader.Core.Modularity;
|
||||
using PolyTraderSharp;
|
||||
using PolyTraderSharp.Services;
|
||||
|
||||
namespace PolyTrader.App.Avalonia.Shell
|
||||
@@ -12,8 +16,25 @@ namespace PolyTrader.App.Avalonia.Shell
|
||||
/// </summary>
|
||||
public static class CoreViews
|
||||
{
|
||||
/// <summary>Quelle/Ziel der Modul-Aktivierung – dieselbe Datei wie im Host.</summary>
|
||||
private const string ServerSettingsPath = "server_settings.xml";
|
||||
|
||||
public static void Register(IModuleUiHost host, IServiceProvider services)
|
||||
{
|
||||
host.RegisterView(new ModuleView
|
||||
{
|
||||
Id = "core.dashboard",
|
||||
Title = "Dashboard",
|
||||
Group = "Core",
|
||||
Order = 10,
|
||||
CreateView = () => new Views.DashboardWindow(
|
||||
host,
|
||||
services.GetRequiredService<PolyTrader.Core.Persistence.ITradeLogRepository>(),
|
||||
services.GetRequiredService<TradingState>(),
|
||||
BuildModuleInfos(services),
|
||||
ServerSettingsPath)
|
||||
});
|
||||
|
||||
host.RegisterView(new ModuleView
|
||||
{
|
||||
Id = "core.jobs",
|
||||
@@ -23,9 +44,40 @@ namespace PolyTrader.App.Avalonia.Shell
|
||||
CreateView = () => new Views.JobsWindow(host, services.GetRequiredService<JobManager>())
|
||||
});
|
||||
|
||||
// TODO Portierung: core.dashboard (Order 10), core.settings (Order 20) und
|
||||
// core.terminal (Order 40) folgen. Aufbau siehe docs/UI-SPEZIFIKATION-WinForms.md,
|
||||
// Originalcode im Git-Tag winforms-final.
|
||||
// TODO Portierung: core.settings (Order 20) und core.terminal (Order 40) folgen.
|
||||
// Aufbau siehe docs/UI-SPEZIFIKATION-WinForms.md, Originalcode im Git-Tag winforms-final.
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Baut die Aktivierungs-Übersicht für den Modul-Tab: alle bekannten Module, ob sie in dieser
|
||||
/// Sitzung laufen, und ein etwaiger Grund gegen die Aktivierung (z.B. fehlender API-Key).
|
||||
/// </summary>
|
||||
private static IReadOnlyList<ModuleActivationInfo> BuildModuleInfos(IServiceProvider services)
|
||||
{
|
||||
var running = services.GetServices<IPolyTraderModule>().ToList();
|
||||
var runningNames = running.Select(m => m.Name).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
var config = services.GetRequiredService<IConfiguration>();
|
||||
|
||||
// Auch die NICHT geladenen Module auflisten – sonst liessen sie sich nie wieder aktivieren.
|
||||
var all = new IPolyTraderModule[]
|
||||
{
|
||||
new PolyTrader.Modules.CopyTrading.CopyTradingModule(),
|
||||
new PolyTrader.Modules.ResolutionFarming.ResolutionFarmingModule(),
|
||||
new PolyTrader.Modules.Supervisor.SupervisorModule(),
|
||||
new PolyTrader.Modules.Accounting.AccountingModule()
|
||||
};
|
||||
|
||||
return all.Select(m => new ModuleActivationInfo(
|
||||
m.Name,
|
||||
runningNames.Contains(m.Name),
|
||||
SafeBlocker(m, config)))
|
||||
.ToList();
|
||||
|
||||
static string? SafeBlocker(IPolyTraderModule m, IConfiguration config)
|
||||
{
|
||||
try { return m.GetActivationBlocker(config); }
|
||||
catch (Exception ex) { return $"Prüfung fehlgeschlagen: {ex.Message}"; }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel;
|
||||
using System.Linq;
|
||||
using System.Runtime.CompilerServices;
|
||||
using LiveChartsCore;
|
||||
using LiveChartsCore.Defaults;
|
||||
using LiveChartsCore.SkiaSharpView;
|
||||
using LiveChartsCore.SkiaSharpView.Painting;
|
||||
using PolyTrader.Core.Analytics;
|
||||
using PolyTraderSharp.Models;
|
||||
using SkiaSharp;
|
||||
|
||||
namespace PolyTrader.App.Avalonia.ViewModels
|
||||
{
|
||||
/// <summary>
|
||||
/// Datenmodell der drei Dashboard-Diagramme (LiveCharts2). Ersetzt das frühere Rendern nach
|
||||
/// Bitmap: die Diagramme sind jetzt echte Steuerelemente – interaktiv und ohne System.Drawing.
|
||||
///
|
||||
/// <para>Die Auswertung selbst kommt unverändert aus <see cref="TradeAnalytics"/>; dieses Modell
|
||||
/// übersetzt deren Ergebnisse nur in Serien und Achsen. Die Trennung war schon vorher da und ist
|
||||
/// der Grund, warum der Chart-Wechsel die Fachlogik nicht berührt.</para>
|
||||
/// </summary>
|
||||
public sealed class DashboardChartsModel : INotifyPropertyChanged
|
||||
{
|
||||
private static readonly SKColor Accent = new(0x1E, 0x88, 0xE5);
|
||||
private static readonly SKColor Positive = new(0x2E, 0x7D, 0x32);
|
||||
private static readonly SKColor Negative = new(0xC6, 0x28, 0x28);
|
||||
|
||||
private ISeries[] _equitySeries = Array.Empty<ISeries>();
|
||||
private ISeries[] _moduleSeries = Array.Empty<ISeries>();
|
||||
private ISeries[] _daySeries = Array.Empty<ISeries>();
|
||||
private Axis[] _equityXAxes = Array.Empty<Axis>();
|
||||
private Axis[] _moduleXAxes = Array.Empty<Axis>();
|
||||
private Axis[] _dayXAxes = Array.Empty<Axis>();
|
||||
|
||||
public ISeries[] EquitySeries { get => _equitySeries; private set => Set(ref _equitySeries, value); }
|
||||
public ISeries[] ModuleSeries { get => _moduleSeries; private set => Set(ref _moduleSeries, value); }
|
||||
public ISeries[] DaySeries { get => _daySeries; private set => Set(ref _daySeries, value); }
|
||||
public Axis[] EquityXAxes { get => _equityXAxes; private set => Set(ref _equityXAxes, value); }
|
||||
public Axis[] ModuleXAxes { get => _moduleXAxes; private set => Set(ref _moduleXAxes, value); }
|
||||
public Axis[] DayXAxes { get => _dayXAxes; private set => Set(ref _dayXAxes, value); }
|
||||
|
||||
/// <summary>Baut alle drei Diagramme aus dem gefilterten Trade-Satz neu auf.</summary>
|
||||
public void Update(IReadOnlyList<TradeRecord> scoped)
|
||||
{
|
||||
BuildEquity(scoped);
|
||||
BuildByModule(scoped);
|
||||
BuildByDay(scoped);
|
||||
}
|
||||
|
||||
private void BuildEquity(IReadOnlyList<TradeRecord> scoped)
|
||||
{
|
||||
var curve = TradeAnalytics.EquityCurve(scoped);
|
||||
|
||||
EquitySeries = new ISeries[]
|
||||
{
|
||||
new LineSeries<DateTimePoint>
|
||||
{
|
||||
Name = "Kumulierter PnL",
|
||||
Values = curve.Select(p => new DateTimePoint(p.At, (double)p.Cumulative)).ToArray(),
|
||||
Stroke = new SolidColorPaint(Accent, 2),
|
||||
GeometrySize = 0, // reine Linie – bei vielen Trades sonst unruhig
|
||||
Fill = null
|
||||
}
|
||||
};
|
||||
|
||||
EquityXAxes = new[]
|
||||
{
|
||||
new Axis
|
||||
{
|
||||
// Ticks kommen als DateTime-Ticks – zurueckrechnen und kurz formatieren.
|
||||
Labeler = value => new DateTime((long)value).ToString("dd.MM."),
|
||||
UnitWidth = TimeSpan.FromDays(1).Ticks,
|
||||
LabelsRotation = 0
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private void BuildByModule(IReadOnlyList<TradeRecord> scoped)
|
||||
{
|
||||
var byMod = TradeAnalytics.PnlByKey(scoped,
|
||||
t => string.IsNullOrEmpty(t.ModuleName) ? "—" : t.ModuleName);
|
||||
|
||||
ModuleSeries = new ISeries[]
|
||||
{
|
||||
new ColumnSeries<double>
|
||||
{
|
||||
Name = "PnL",
|
||||
Values = byMod.Select(x => (double)x.Pnl).ToArray(),
|
||||
Fill = new SolidColorPaint(Accent)
|
||||
}
|
||||
};
|
||||
ModuleXAxes = new[] { new Axis { Labels = byMod.Select(x => x.Key).ToArray(), LabelsRotation = 0 } };
|
||||
}
|
||||
|
||||
private void BuildByDay(IReadOnlyList<TradeRecord> scoped)
|
||||
{
|
||||
var byDay = TradeAnalytics.PnlByDay(scoped);
|
||||
|
||||
DaySeries = new ISeries[]
|
||||
{
|
||||
new ColumnSeries<double>
|
||||
{
|
||||
Name = "PnL",
|
||||
Values = byDay.Select(x => (double)x.Pnl).ToArray(),
|
||||
// Verlusttage rot, Gewinntage gruen – dieselbe Leseweise wie in den Trade-Listen.
|
||||
Fill = new SolidColorPaint(byDay.Sum(x => x.Pnl) >= 0m ? Positive : Negative)
|
||||
}
|
||||
};
|
||||
DayXAxes = new[]
|
||||
{
|
||||
new Axis
|
||||
{
|
||||
Labels = byDay.Select(x => x.Day.ToString("dd.MM")).ToArray(),
|
||||
LabelsRotation = byDay.Count > 20 ? 45 : 0 // bei vielen Tagen sonst unleserlich
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private void Set<T>(ref T field, T value, [CallerMemberName] string? name = null)
|
||||
{
|
||||
field = value;
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
|
||||
}
|
||||
|
||||
public event PropertyChangedEventHandler? PropertyChanged;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
using System;
|
||||
|
||||
namespace PolyTrader.App.Avalonia.ViewModels
|
||||
{
|
||||
/// <summary>Anzeige-Zeile des Historie-Grids (Konto bereits zum Namen aufgelöst).</summary>
|
||||
public sealed class DashboardTradeRow
|
||||
{
|
||||
public string Module { get; init; } = string.Empty;
|
||||
public string Account { get; init; } = string.Empty;
|
||||
public string Market { get; init; } = string.Empty;
|
||||
public string Outcome { get; init; } = string.Empty;
|
||||
public string Side { get; init; } = string.Empty;
|
||||
public decimal EntryPrice { get; init; }
|
||||
public decimal ExitPrice { get; init; }
|
||||
public decimal Size { get; init; }
|
||||
public decimal RealizedPnl { get; init; }
|
||||
public decimal PnlPercent { get; init; }
|
||||
public DateTime ClosedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Anzeige-Zeile des Modul-Grids (Tab „Module").</summary>
|
||||
public sealed class ModuleRow
|
||||
{
|
||||
public string Modul { get; init; } = string.Empty;
|
||||
public string Status { get; init; } = string.Empty;
|
||||
public string Hinweis { get; init; } = string.Empty;
|
||||
public string Aktion { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Falsch, wenn das Modul einen Aktivierungs-Blocker hat (z.B. fehlender API-Key). Die
|
||||
/// WinForms-Fassung zeigte in dem Fall beim Klick einen Hinweisdialog; hier ist die
|
||||
/// Schaltfläche schlicht deaktiviert – der Grund steht ohnehin in der Spalte „Hinweis".
|
||||
/// </summary>
|
||||
public bool CanToggle { get; init; } = true;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user