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
{
///
/// 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.
///
/// Seit der Umstellung auf die Einfenster-Shell ist dies ein
/// 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
/// .
///
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 _windowButtons = new();
private readonly ObservableCollection _accountRows = new();
private readonly ObservableCollection _kpiTiles = new();
private readonly ObservableCollection _notableRows = new();
private readonly ObservableCollection _alertRows = new();
private int _statusTicks;
/// Ergebnis der letzten Update-Prüfung – für den Klick auf „Jetzt installieren …".
private UpdateCheckResult? _updateCheck;
public LauncherWindow() => AvaloniaXamlLoader.Load(this);
public LauncherWindow(IModuleUiHost uiHost, IServiceProvider services) : this()
{
_uiHost = uiHost;
_services = services;
_state = services.GetRequiredService();
this.FindControl("windowButtons")!.ItemsSource = _windowButtons;
this.FindControl("gridAccounts")!.ItemsSource = _accountRows;
this.FindControl("kpiTiles")!.ItemsSource = _kpiTiles;
this.FindControl("gridNotable")!.ItemsSource = _notableRows;
this.FindControl("gridAlerts")!.ItemsSource = _alertRows;
RebuildWindowButtons();
_uiHost.OpenStateChanged += OnOpenStateChanged;
this.FindControl("btnLiveTrading")!.Click += (_, _) =>
{
_state.LiveTradingMode = Next(_state.LiveTradingMode);
UpdateTradingToggles();
};
this.FindControl("btnDemoTrading")!.Click += (_, _) =>
{
_state.DemoTradingMode = Next(_state.DemoTradingMode);
UpdateTradingToggles();
};
this.FindControl("btnInstallUpdate")!.Click += async (_, _) => await InstallUpdateAsync();
this.FindControl("btnDismissUpdate")!.Click += (_, _) =>
this.FindControl("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;
}
/// Dreistufiger Wechsel wie bisher: Inactive -> SellOnly -> Active -> Inactive.
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);
}
///
/// 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.
///
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("btnLiveTrading")!, "LiveTrading", _state.LiveTradingMode);
Apply(this.FindControl("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);
}
}
///
/// 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.
///
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) =====
///
/// 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.
///
private async Task CheckForUpdateAsync()
{
try
{
var dcOptions = _services.GetService();
if (dcOptions == null || !dcOptions.UpdateCheckEnabled || string.IsNullOrWhiteSpace(dcOptions.BaseUrl))
return;
var settings = _services.GetService();
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("lblUpdateInfo")!.Text =
$"Update verfügbar: v{version}{critical}. {result.LatestRelease.Changelog}".TrimEnd();
this.FindControl("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).
}
}
///
/// Startet den Update-Agent nach Bestätigung. Läuft über denselben geordneten Shutdown-Weg
/// wie „Beenden" (inklusive der 10-Sekunden-Sicherheitsabfrage) - exitCurrentApp: false
/// ist hier Pflicht, kein Environment.Exit bei offenen Positionen (Leitentscheidung 3).
///
private async Task InstallUpdateAsync()
{
if (_updateCheck?.LatestRelease == null) return;
var dcOptions = _services.GetService();
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();
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();
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 trades;
try { trades = tradeLog?.Find(t => t.AccountId == acc.AccountId) ?? new List(); }
catch { trades = new List(); }
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();
try
{
var since = DateTime.UtcNow.AddDays(-30);
List all = tradeLog?.Find(t => t.ClosedAt >= since) ?? new List();
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 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("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();
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();
var since = DateTime.UtcNow.AddDays(-1);
var rows = (tradeLog?.Find(t => t.ClosedAt >= since) ?? new List())
.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("txtSupervisor");
if (txtSupervisor == null) return;
var reports = _services.GetService();
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();
}
}