Files
PolyTraderSharp/src/PolyTrader.App.Avalonia/Views/DashboardWindow.axaml.cs
T
RichardandClaude Opus 5 9b2294cb4c
CI / Build & Tests (Linux) (push) Canceled after 0s
CI / Plattformneutralität & Hygiene (push) Canceled after 0s
Testanleitung: eigenes X-Display je Projekt fuer Parallelbetrieb
Damit mehrere Agenten gleichzeitig Oberflaechen testen koennen, bekommt jedes
Projekt eine feste Displaynummer (Vorgabe Richard):

  :99 PolyTrader, :98 Predictalytics, :97 ClawdDotNet, :96 IBKRTrader,
  :95 und absteigend fuer kuenftige Projekte.

Zwei Prozesse auf demselben Display sehen die Fenster des jeweils anderen -
Klicks landen im falschen Programm, Aufnahmen zeigen fremde Anwendungen.

Beim Durcharbeiten fielen zwei weitere Kollisionspunkte auf, die zum
Parallelbetrieb dazugehoeren und mit aufgenommen wurden:

- Screenshot- und Logdateien in /tmp brauchen ein Projektpraefix. /tmp/v_1.png
  ueberschreiben sich parallel laufende Projekte gegenseitig.
- "xvfb-run -a" darf NICHT mehr verwendet werden: Das -a sucht sich selbst ein
  freies Display und beginnt dabei bei :99 - ausgerechnet PolyTraders Nummer.
  Stattdessen "xvfb-run -n <nummer>".

Die Warnung ist nicht theoretisch: Auf der VM lag ein verwaister X98-Socket,
entstanden durch genau so einen "xvfb-run -a"-Aufruf beim smoke-ui-Test, als
:99 belegt war. Er blockierte Predictalytics' reservierte Nummer, ohne dass ein
Prozess lief. Entfernt, und die Anleitung erklaert jetzt, wie man verwaiste
Sockets erkennt (ls /tmp/.X11-unix/ gegen pgrep -a Xvfb lesen) und aufraeumt.

Weiter ergaenzt:
- pkill nie ohne Displaynummer - "pkill Xvfb" erschlaegt die Sitzungen aller
  anderen Projekte.
- DISPLAY in jedem Skript setzen, sonst landet man auf :0, dem echten Desktop.
- Die Skriptvorlage hat jetzt einen Kopf mit PROJEKT/DISP/FENSTER; das ist
  alles, was sich je Projekt unterscheidet. Praktisch gegengetestet.
- Aufraeumabschnitt und Belegungsliste um Displays und Verzeichnisse erweitert.

Nebenbei aufgefallen: Das PolyTrader-Publish lag unter ~/pt-test statt
~/polytrader-test und verletzte damit die eigene Verzeichniskonvention.
Umbenannt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 20:49:28 +02:00

321 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 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)
{
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
});
}
}
}
}