Files
PolyTraderSharp/src/PolyTrader.App.Avalonia/Views/DashboardWindow.axaml.cs
T
RichardandClaude Opus 5 23794304e6
CI / Build & Tests (Linux) (push) Canceled after 0s
CI / Plattformneutralität & Hygiene (push) Canceled after 0s
Build ohne Warnungen, Kalendersymbol unter Linux sichtbar
#155: Die 15 Build-Warnungen sind beseitigt, keine davon war ein echter Fehler.
- CS8848 (DashboardWindow): Klammern um "as string" vor dem switch; die
  Auswertung war schon richtig, jetzt ist sie auch eindeutig lesbar
- CS8602 (PdfExporter): Style "Normal" legt MigraDoc immer an
- 13x CS8618: Felder, die nur der parameterlose XAML-Konstruktor offen laesst

#50: Fluent zeichnet das Kalenderblatt im CalendarDatePicker als 0,5-px-Rahmen.
Unter Linux (Skalierung 1,0) fiel er weg, uebrig blieben Tageszahl und Punkt.
Eigener Style mit Kalenderblatt aus ganzen Pixeln. Auf der VM in beiden
Farbschemata geprueft; der Kalender klappt weiterhin auf.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 11:03:34 +02:00

299 lines
13 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 PolyTrader.Core.Configuration;
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 : UserControl
{
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()
{
_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();
// Die KPI-Farbe wird im Code gesetzt und wechselt daher nicht von selbst mit dem Thema.
// Kein Abmelden mehr: Seiten werden in der Einfenster-Shell einmal erzeugt und leben
// bis zum Programmende (siehe AvaloniaUiHost) – ein Gegenstück zu Window.Closed gibt es
// hier nicht, und Unloaded würde bei jedem Seitenwechsel feuern.
Shell.ThemeManager.ThemeChanged += ApplyScope;
}
// ===== 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, since);
_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 = Shell.ThemeManager.Brush(k.NetPnl >= 0 ? "AppPositiveBrush" : "AppNegativeBrush");
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)
{
// #560: dieselben Regeln wie im WebUI, an einer Stelle (ModuleActivationView).
var view = ModuleActivationView.Describe(m, desiredEnabled: !disabled.Contains(m.Name));
_modules.Add(new ModuleRow
{
Modul = view.Name, Status = view.Status, Hinweis = view.Hint, Aktion = view.Action, CanToggle = view.CanToggle
});
}
}
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 lesen, aendern, atomar schreiben (#560). Vorher ServerSettings.Load/Save: Load
// liefert bei einer unlesbaren Datei still Vorgaben, das anschliessende Save haette damit
// Lizenzschluessel und Watchdog-Token ueberschrieben.
new ServerSettingsFile(_settingsPath!).Update(settings =>
{
var set = new HashSet<string>(settings.DisabledModules, StringComparer.OrdinalIgnoreCase);
if (!set.Remove(info.Name)) set.Add(info.Name);
settings.DisabledModules = set.OrderBy(x => x, StringComparer.OrdinalIgnoreCase).ToList();
});
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
});
}
}
}
}