Files
PolyTraderSharp/src/PolyTrader.App.Avalonia/Views/LauncherWindow.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

523 lines
22 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.IO;
using System.Linq;
using System.Threading.Tasks;
using Avalonia.Controls;
using Avalonia.Controls.Primitives;
using Avalonia.Interactivity;
using Avalonia.Markup.Xaml;
using Avalonia.Threading;
using Deploymentcenter.Client;
using Microsoft.Extensions.DependencyInjection;
using PolyTrader.App.Avalonia.Shell;
using PolyTrader.App.Avalonia.ViewModels;
using PolyTrader.Core;
using PolyTrader.Core.Analytics;
using PolyTrader.Core.Configuration;
using PolyTrader.Core.Modularity;
using PolyTrader.Core.Persistence;
using PolyTrader.Core.Security;
using PolyTrader.Modules.Supervisor.Persistence;
using PolyTraderSharp;
using PolyTraderSharp.Models;
using PolyTraderSharp.Services;
namespace PolyTrader.App.Avalonia.Views
{
/// <summary>
/// Startseite der Anwendung – Gegenstück zur bisherigen LauncherForm. Führt zu den übrigen
/// Ansichten, zeigt Kernkennzahlen und beinhaltet die Live-Überblick-Widgets.
/// Layout vollständig in LauncherWindow.axaml.
///
/// <para>Seit der Umstellung auf die Einfenster-Shell ist dies ein <see cref="UserControl"/>
/// und kein Fenster mehr; der Klassenname bleibt vorerst, um die parallele
/// Deploymentcenter-Arbeit an dieser Datei nicht mit einer Umbenennung zu kollidieren.
/// Die Statusleiste (Trading/Module/API) und das Beenden liegen jetzt in
/// <see cref="ShellWindow"/>.</para>
/// </summary>
public partial class LauncherWindow : UserControl
{
private readonly IModuleUiHost _uiHost;
private readonly IServiceProvider _services;
private readonly TradingState _state;
private readonly DispatcherTimer _statusTimer = new() { Interval = TimeSpan.FromSeconds(1) };
private readonly ObservableCollection<ViewTile> _windowButtons = new();
private readonly ObservableCollection<AccountOverviewRow> _accountRows = new();
private readonly ObservableCollection<ModuleKpiTile> _kpiTiles = new();
private readonly ObservableCollection<NotableTradeRow> _notableRows = new();
private readonly ObservableCollection<LogAlertRow> _alertRows = new();
private int _statusTicks;
/// <summary>Ergebnis der letzten Update-Prüfung – für den Klick auf „Jetzt installieren …".</summary>
private UpdateCheckResult? _updateCheck;
public LauncherWindow() => AvaloniaXamlLoader.Load(this);
public LauncherWindow(IModuleUiHost uiHost, IServiceProvider services) : this()
{
_uiHost = uiHost;
_services = services;
_state = services.GetRequiredService<TradingState>();
this.FindControl<ItemsControl>("windowButtons")!.ItemsSource = _windowButtons;
this.FindControl<DataGrid>("gridAccounts")!.ItemsSource = _accountRows;
this.FindControl<ItemsControl>("kpiTiles")!.ItemsSource = _kpiTiles;
this.FindControl<DataGrid>("gridNotable")!.ItemsSource = _notableRows;
this.FindControl<DataGrid>("gridAlerts")!.ItemsSource = _alertRows;
RebuildWindowButtons();
_uiHost.OpenStateChanged += OnOpenStateChanged;
this.FindControl<Button>("btnLiveTrading")!.Click += (_, _) =>
{
_state.LiveTradingMode = Next(_state.LiveTradingMode);
UpdateTradingToggles();
};
this.FindControl<Button>("btnDemoTrading")!.Click += (_, _) =>
{
_state.DemoTradingMode = Next(_state.DemoTradingMode);
UpdateTradingToggles();
};
this.FindControl<Button>("btnInstallUpdate")!.Click += async (_, _) => await InstallUpdateAsync();
this.FindControl<Button>("btnDismissUpdate")!.Click += (_, _) =>
this.FindControl<Border>("pnlUpdateBanner")!.IsVisible = false;
_statusTimer.Tick += (_, _) => UpdateStatus();
_statusTimer.Start();
UpdateStatus();
RefreshData();
// D-4: einmalige, stille Prüfung beim Start - fire-and-forget, darf den Start nie
// verzögern oder blockieren. Nur aktiv, wenn Deploymentcenter:UpdateCheckEnabled gesetzt ist.
_ = CheckForUpdateAsync();
// Die Umschalterflaechen werden im Code gesetzt und folgen dem Thema nicht von selbst.
// Kein Abmelden: die Seite lebt in der Einfenster-Shell bis zum Programmende, ein
// Gegenstueck zu Window.Closed gibt es nicht (Sicherheitsabfrage und Beenden liegen
// jetzt im ShellWindow).
ThemeManager.ThemeChanged += UpdateTradingToggles;
}
/// <summary>Dreistufiger Wechsel wie bisher: Inactive -> SellOnly -> Active -> Inactive.</summary>
private static TradingMode Next(TradingMode mode) => mode switch
{
TradingMode.Inactive => TradingMode.SellOnly,
TradingMode.SellOnly => TradingMode.Active,
_ => TradingMode.Inactive
};
private void OnOpenStateChanged()
{
if (Dispatcher.UIThread.CheckAccess()) RebuildWindowButtons();
else Dispatcher.UIThread.Post(RebuildWindowButtons);
}
/// <summary>
/// Kacheln zu den übrigen Ansichten. Die Startseite selbst bleibt aussen vor – sie ist ja
/// gerade zu sehen; ihre Kachel würde nur ins Leere führen und dauerhaft als „offen" gelten.
/// </summary>
private void RebuildWindowButtons()
{
_windowButtons.Clear();
foreach (var view in _uiHost.Views
.Where(v => v.Id != AvaloniaUiHost.StartViewId)
.OrderBy(v => v.Order).ThenBy(v => v.Title))
{
string id = view.Id;
bool open = _uiHost.IsOpen(id);
_windowButtons.Add(new ViewTile
{
Title = view.Title,
Icon = ViewIcons.Resolve(view.IconKey),
IsCurrent = open,
IsChecked = open,
Command = new RelayCommand(() => _uiHost.OpenView(id))
});
}
}
private void UpdateTradingToggles()
{
Apply(this.FindControl<Button>("btnLiveTrading")!, "LiveTrading", _state.LiveTradingMode);
Apply(this.FindControl<Button>("btnDemoTrading")!, "DemoTrading", _state.DemoTradingMode);
static void Apply(Button btn, string label, TradingMode mode)
{
(string text, string brushKey) = mode switch
{
TradingMode.Active => ($"{label} (AKTIV)", "AppToggleActiveBrush"),
TradingMode.SellOnly => ($"{label} (SELL-ONLY)", "AppToggleSellOnlyBrush"),
_ => ($"{label} (DEAKTIVIERT)", "AppToggleInactiveBrush")
};
btn.Content = text;
btn.Background = ThemeManager.Brush(brushKey);
}
}
/// <summary>
/// Hält die Umschalter und die Widgets frisch. Die frühere Statusleiste dieser Seite ist
/// in die Shell gewandert und wird dort im eigenen Takt aktualisiert.
/// </summary>
private void UpdateStatus()
{
UpdateTradingToggles();
// Account-Übersicht + Widgets alle 30 s aktualisieren
if (_statusTicks++ % 30 == 0)
{
RefreshData();
}
}
private void RefreshData()
{
LoadAccountOverview();
UpdateModuleKpis();
UpdateAlerts();
UpdateNotable();
UpdateSupervisor();
}
// ===== Update-Prüfung (D-4) =====
/// <summary>
/// Prüft beim Start still auf ein neues Release. Installiert NIE automatisch (Leitentscheidung
/// 4 aus dem Umsetzungsplan) - ein `is_critical`-Release ändert nur den Hinweistext, nicht das
/// Verhalten. Wirft nie: ein nicht erreichbares Deploymentcenter darf den Start nicht stören.
/// </summary>
private async Task CheckForUpdateAsync()
{
try
{
var dcOptions = _services.GetService<DeploymentcenterOptions>();
if (dcOptions == null || !dcOptions.UpdateCheckEnabled || string.IsNullOrWhiteSpace(dcOptions.BaseUrl))
return;
var settings = _services.GetService<ServerSettings>();
string? licenseKey = null;
try { licenseKey = SecretProtection.Unprotect(settings?.LicenseKey ?? string.Empty); }
catch { /* nicht lesbarer Schlüssel -> Prüfung läuft ohne Zugangsdaten weiter */ }
var updateClient = new UpdateClient();
var result = await updateClient.CheckForUpdateAsync(
baseUrl: dcOptions.BaseUrl,
projectId: dcOptions.ProjectSlug,
currentVersion: BuildInfo.Version,
channel: dcOptions.Channel,
credentials: ReleaseCredentials.FromLicenseKey(licenseKey));
if (result.Unauthorized)
{
// Kein Netzwerkfehler: die Lizenz trägt nicht mehr für den Paketabruf. Kein
// Alarm im Launcher nötig - die Lizenzprüfung selbst (D-2) hat das schon gemeldet.
return;
}
if (!result.UpdateAvailable || result.LatestRelease == null) return;
_updateCheck = result;
string version = result.LatestRelease.Version;
string critical = result.IsCritical ? " (empfohlenes Sicherheitsupdate)" : "";
Dispatcher.UIThread.Post(() =>
{
this.FindControl<TextBlock>("lblUpdateInfo")!.Text =
$"Update verfügbar: v{version}{critical}. {result.LatestRelease.Changelog}".TrimEnd();
this.FindControl<Border>("pnlUpdateBanner")!.IsVisible = true;
});
}
catch
{
// Update-Prüfung darf den Start nie stören - stiller Rückzug, kein Log-Spam beim
// vermutlich häufigsten Fall (Deploymentcenter nicht erreichbar).
}
}
/// <summary>
/// Startet den Update-Agent nach Bestätigung. Läuft über denselben geordneten Shutdown-Weg
/// wie „Beenden" (inklusive der 10-Sekunden-Sicherheitsabfrage) - <c>exitCurrentApp: false</c>
/// ist hier Pflicht, kein Environment.Exit bei offenen Positionen (Leitentscheidung 3).
/// </summary>
private async Task InstallUpdateAsync()
{
if (_updateCheck?.LatestRelease == null) return;
var dcOptions = _services.GetService<DeploymentcenterOptions>();
if (dcOptions == null) return;
string? agentPath = UpdateClient.ResolveAgentPath();
if (agentPath == null)
{
await DialogWindow.Info(this, "Update",
"Kein Update-Agent im Installationsverzeichnis gefunden. Diese Installation kann " +
"sich nicht selbst aktualisieren - bitte das neue Paket von Hand einspielen.");
return;
}
bool confirmed = await DialogWindow.Confirm(this, "Update installieren",
$"Version {_updateCheck.LatestRelease.Version} herunterladen und PolyTrader danach " +
"neu starten?\n\nOffene Vorgänge werden dabei genauso geordnet beendet wie über „Beenden\".",
"Installieren");
if (!confirmed) return;
var settings = _services.GetService<ServerSettings>();
string? licenseKey = null;
try { licenseKey = SecretProtection.Unprotect(settings?.LicenseKey ?? string.Empty); }
catch { /* siehe CheckForUpdateAsync */ }
UpdateClient.LaunchUpdateAgent(
agentPath: agentPath,
projectId: dcOptions.ProjectSlug,
channel: dcOptions.Channel,
action: "update",
version: "latest",
currentVersion: BuildInfo.Version,
exitCurrentApp: false,
waitForCurrentProcess: true,
waitTimeoutSeconds: 40,
licenseKey: licenseKey);
// Derselbe Weg wie ein Klick auf "Beenden" - inklusive Sicherheitsabfrage. Der Agent
// wartet ohnehin per --wait-for-pid auf unser Prozessende.
_uiHost.RequestShutdown();
}
// ===== Account-Übersicht =====
private void LoadAccountOverview()
{
var tradeLog = _services.GetService<ITradeLogRepository>();
DateTime since3d = DateTime.UtcNow.AddDays(-3);
_accountRows.Clear();
if (_state?.Accounts == null) return;
foreach (var acc in _state.Accounts.Values.OrderBy(a => a.AccountId))
{
List<TradeRecord> trades;
try { trades = tradeLog?.Find(t => t.AccountId == acc.AccountId) ?? new List<TradeRecord>(); }
catch { trades = new List<TradeRecord>(); }
var (pnl3d, win3d, _) = TradeAnalytics.WindowSummary(trades.Where(t => t.ClosedAt >= since3d));
string modules = trades
.Select(t => t.ModuleName)
.Where(m => !string.IsNullOrEmpty(m))
.Distinct().OrderBy(m => m)
.DefaultIfEmpty("—")
.Aggregate((a, b) => a + ", " + b);
_accountRows.Add(new AccountOverviewRow
{
AccountId = acc.AccountId,
Name = (string.IsNullOrEmpty(acc.Name) ? $"#{acc.AccountId}" : acc.Name) + (acc.IsDemo ? " (Demo)" : ""),
Modules = modules,
WalletAddress = acc.WalletAddress ?? string.Empty,
Balance = acc.TotalBalance,
Pnl3d = pnl3d,
WinRate3d = win3d,
OverallPnl = trades.Sum(t => t.RealizedPnl)
});
}
}
public async void OnOpenPolymarketProfileClick(object? sender, RoutedEventArgs e)
{
if ((sender as Button)?.DataContext is AccountOverviewRow row)
{
if (string.IsNullOrWhiteSpace(row.WalletAddress))
{
await DialogWindow.Info(this, "Polymarket", "Für diesen Account ist keine Wallet-Adresse hinterlegt.");
return;
}
try
{
var topLevel = TopLevel.GetTopLevel(this);
if (topLevel != null)
{
await topLevel.Launcher.LaunchUriAsync(new Uri($"https://polymarket.com/profile/{row.WalletAddress}"));
}
}
catch (Exception ex)
{
await DialogWindow.Info(this, "Fehler", $"Konnte Polymarket nicht öffnen: {ex.Message}");
}
}
}
// ===== Modul-KPI-Kacheln =====
private void UpdateModuleKpis()
{
_kpiTiles.Clear();
var tradeLog = _services.GetService<ITradeLogRepository>();
try
{
var since = DateTime.UtcNow.AddDays(-30);
List<TradeRecord> all = tradeLog?.Find(t => t.ClosedAt >= since) ?? new List<TradeRecord>();
DateTime today = DateTime.UtcNow.Date;
DateTime week = DateTime.UtcNow.AddDays(-7);
var modules = all.Select(t => t.ModuleName).Where(m => !string.IsNullOrEmpty(m)).Distinct().OrderBy(m => m).ToList();
foreach (var module in modules)
{
var moduleTrades = all.Where(t => t.ModuleName == module).ToList();
_kpiTiles.Add(BuildTile(module, moduleTrades, today, week));
}
_kpiTiles.Add(BuildTile("Gesamt", all, today, week));
if (_kpiTiles.Count == 1 && all.Count == 0)
{
_kpiTiles.Add(new ModuleKpiTile
{
ModuleName = "Hinweis",
Text = "Noch keine abgeschlossenen Trades.",
IsPositive = true
});
}
}
catch (Exception ex)
{
_kpiTiles.Add(new ModuleKpiTile
{
ModuleName = "Fehler",
Text = $"KPIs n/v: {ex.Message}",
IsPositive = false
});
}
}
private static ModuleKpiTile BuildTile(string module, List<TradeRecord> moduleTrades, DateTime today, DateTime week)
{
decimal todayPnl = moduleTrades.Where(t => t.ClosedAt >= today).Sum(t => t.RealizedPnl);
var k7 = TradeAnalytics.ComputeKpis(moduleTrades.Where(t => t.ClosedAt >= week));
var k30 = TradeAnalytics.ComputeKpis(moduleTrades);
// Ohne den Modulnamen: die Kachel traegt ihn bereits als Ueberschrift, im Textblock
// stand er ein zweites Mal ("CopyTrading / CopyTrading / Heute: ...") und sah aus
// wie ein Darstellungsfehler (Befund B-7 des Linux-Sichttests).
string text = $"Heute: {todayPnl:+0.00;-0.00} USDC\n" +
$"7T: {k7.NetPnl:+0.00;-0.00} · {k7.WinRatePct:0}% · {k7.TradeCount} Tr.\n" +
$"30T: {k30.NetPnl:+0.00;-0.00} · {k30.WinRatePct:0}%";
return new ModuleKpiTile
{
ModuleName = module,
Text = text,
IsPositive = k30.NetPnl >= 0
};
}
// ===== Warnungen & Fehler =====
private void UpdateAlerts()
{
var grpAlerts = this.FindControl<HeaderedContentControl>("grpAlerts");
try
{
string logsDir = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Logs");
// Tagesdatei nach der Anzeige-Zeitzone, nicht nach der Systemzeit - sonst greift
// die Startseite auf einem UTC-Server abends in die Datei des Vortags (§8).
string path = Path.Combine(logsDir, $"{AppTimeZone.Today:yyyy-MM-dd}.jsonl");
var rows = new List<LogAlertRow>();
if (File.Exists(path))
{
foreach (var line in File.ReadLines(path))
{
var p = LogJson.ParseLine(line);
if (p == null) continue;
if (p.Level != "Error" && p.Level != "Warning") continue;
rows.Add(new LogAlertRow
{
Time = ShortTime(p.Time),
Level = p.Level,
Message = OneLine(p.Message)
});
}
}
rows.Reverse(); // neueste zuerst
_alertRows.Clear();
foreach (var r in rows.Take(200)) _alertRows.Add(r);
if (grpAlerts != null) grpAlerts.Header = $"Warnungen & Fehler (heute): {rows.Count}";
}
catch (Exception ex)
{
if (grpAlerts != null) grpAlerts.Header = $"Warnungen & Fehler – n/v ({ex.Message})";
}
}
// ===== Auffällige Trades (24h) =====
private void UpdateNotable()
{
try
{
var tradeLog = _services.GetService<ITradeLogRepository>();
var since = DateTime.UtcNow.AddDays(-1);
var rows = (tradeLog?.Find(t => t.ClosedAt >= since) ?? new List<TradeRecord>())
.OrderByDescending(t => Math.Abs(t.RealizedPnl))
.Take(30)
.Select(t => new NotableTradeRow
{
Module = t.ModuleName ?? string.Empty,
Market = t.MarketQuestion ?? string.Empty,
Pnl = t.RealizedPnl,
PnlPct = t.PnlPercent
})
.ToList();
_notableRows.Clear();
foreach (var r in rows) _notableRows.Add(r);
}
catch
{
_notableRows.Clear();
}
}
// ===== Supervisor-KI-Kurzfassung =====
private void UpdateSupervisor()
{
var txtSupervisor = this.FindControl<SelectableTextBlock>("txtSupervisor");
if (txtSupervisor == null) return;
var reports = _services.GetService<ISupervisorReportRepository>();
if (reports == null)
{
txtSupervisor.Text = "Supervisor-Modul nicht verfügbar.";
return;
}
try
{
var last = reports.GetRecent(1).FirstOrDefault();
txtSupervisor.Text = last == null
? "Noch kein Supervisor-Bericht. (OpenRouter-Key setzen und im Supervisor-Fenster eine Analyse starten.)"
: $"[{last.CreatedAt:dd.MM. HH:mm}] {last.Profile} · {last.Model}\n\n{last.Answer}";
}
catch (Exception ex)
{
txtSupervisor.Text = $"Supervisor-Berichte n/v: {ex.Message}";
}
}
// ===== Helfer =====
// Als DateTimeOffset lesen und die Uhrzeit SO anzeigen, wie sie in der Datei steht:
// DateTime.TryParse haette den Zeitstempel zusaetzlich in die Systemzeit gerueckt.
private static string ShortTime(string iso) =>
DateTimeOffset.TryParse(iso, System.Globalization.CultureInfo.InvariantCulture,
System.Globalization.DateTimeStyles.None, out var dt)
? dt.ToString("HH:mm:ss")
: iso;
private static string OneLine(string s) =>
(s ?? string.Empty).Replace("\r", " ").Replace("\n", " ").Trim();
}
}