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,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;
}
}