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>
523 lines
22 KiB
C#
523 lines
22 KiB
C#
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();
|
||
}
|
||
}
|