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>
321 lines
13 KiB
C#
321 lines
13 KiB
C#
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
|
||
});
|
||
}
|
||
}
|
||
}
|
||
}
|