WebUI: Module, Analytics und Buchhaltung als eigene Ansichten
CI / Build & Tests (Linux) (push) Canceled after 0s
CI / Plattformneutralität & Hygiene (push) Canceled after 0s

Das WebGUI konnte bisher Dashboard, Konten, Jobs, Logs und Einstellungen.
Die vier Strategie- und Analysemodule waren nur in der Avalonia-Oberflaeche
erreichbar - wer den Server aus der Ferne bediente, sah sie nicht.

Fuenf neue Endpunktgruppen holen sie herein:

  /api/analytics                  KPIs, Equity-Kurve, PnL je Modul und Tag,
                                  durchsuchbare Trade-Historie mit Seitenlauf
  /api/modules/copytrading        Master-Trader (CRUD), offene und
                                  geschlossene Trades, Risikolimits je Konto
  /api/modules/resolutionfarming  Scanner-Kandidaten, Positionen, Historie,
                                  Farming-Parameter je Konto
  /api/modules/supervisor         Profile, Agentenfragen, Berichtsarchiv,
                                  Signal-Dossiers, Counterfactuals
  /api/modules/accounting         BWA-Periodenabrechnung, Ledger-Journal,
                                  PDF- und CSV-Export, Ingest-Laeufe

Dazu im Bestand:
- Dashboard bekommt /overview-widgets: Modul-KPIs (heute/7T/30T), auffaellige
  Trades der letzten 24 Stunden, Warnungen und Fehler des Tages, den letzten
  Supervisor-Bericht.
- Die Kontenliste zeigt 3-Tage-PnL, Winrate, Gesamtergebnis und die
  beteiligten Module statt nur Salden.
- Jobs lassen sich ueber /toggle und /state ein- und ausschalten, nicht nur
  ausloesen.

Die Oberflaeche bleibt ohne Fremdbibliotheken: alle Diagramme sind Inline-SVG,
kein CDN, kein npm. Alle neuen Pfade liegen unter /api/ und damit hinter der
bestehenden Token-Pruefung.

Zwei Fehler nebenbei behoben:

- AccountingReportService.ResolveCurrency lief in eine NullReferenceException,
  wenn der FX-Dienst nicht gesetzt war. Betrifft jeden EUR-Aufruf ohne
  geladene EZB-Kurse.
- Ein Master-Trader, dessen Name ein Apostroph enthaelt, hatte einen toten
  Loeschen-Knopf: escapeHtml laesst ' unberuehrt, und im onclick-Attribut
  beendete das Zeichen das JavaScript-Literal vorzeitig. Laengerer Text mit
  Anfuehrungszeichen konnte darueber eigenen Code einschleusen. Neu setzt
  jsArg() den Wert als JSON-Literal ein; geprueft mit
  O'Brien "quote" <b>bold</b> \ backslash.

McpLightServerTests weicht jetzt aus, wenn HttpListener mangels URL-ACL nicht
binden darf, statt den Lauf scheitern zu lassen.

Geprueft auf der Linux-Test-VM (Ubuntu 26.04, .NET 10): Build ohne Fehler,
533 von 533 Tests bestanden, alle 11 Ansichten und 15 Unteransichten geladen,
alle Endpunkte mit 200 beantwortet, Schreibwege bis in die Datenbank
nachverfolgt, PDF- und CSV-Export gueltig, SSE-Logstrom live.

Offene Befunde aus der Abnahme liegen als #532 (Kopfleiste laeuft unter
1623 px ueber), #533 (EUR-PDF ohne EZB-Kurse liefert Nullbetraege) und #534
(Einstellungen melden Erfolg trotz gescheitertem Speichern) im Bugtracker.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-09-10 09:17:49 +02:00
co-authored by Claude Opus 5
parent 78a2f85b94
commit dcc764dec1
27 changed files with 6493 additions and 39 deletions
@@ -1,9 +1,16 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
using PolyTrader.Core.Analytics;
using PolyTrader.Core.Persistence;
using PolyTrader.Modules.Supervisor.Persistence;
using PolyTraderSharp;
using PolyTraderSharp.Models;
using PolyTraderSharp.Services;
namespace PolyTrader.Web.Endpoints
@@ -71,6 +78,177 @@ namespace PolyTrader.Web.Endpoints
return Results.BadRequest(new { error = "Ungültiger Modus oder Parametertyp." });
});
group.MapGet("/overview-widgets", (IServiceProvider sp) =>
{
var tradeLog = sp.GetService<ITradeLogRepository>();
DateTime now = DateTime.UtcNow;
DateTime today = now.Date;
DateTime week = now.AddDays(-7);
DateTime month = now.AddDays(-30);
// 1. Modul-KPIs
List<TradeRecord> monthTrades;
try { monthTrades = tradeLog?.Find(t => t.ClosedAt >= month) ?? new List<TradeRecord>(); }
catch { monthTrades = new List<TradeRecord>(); }
var moduleNames = monthTrades.Select(t => t.ModuleName).Where(m => !string.IsNullOrEmpty(m)).Distinct().OrderBy(m => m).ToList();
var kpiTiles = new List<object>();
foreach (var module in moduleNames)
{
var moduleTrades = monthTrades.Where(t => t.ModuleName == module).ToList();
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);
kpiTiles.Add(new
{
moduleName = module,
todayPnl,
pnl7d = k7.NetPnl,
winRate7d = k7.WinRatePct,
count7d = k7.TradeCount,
pnl30d = k30.NetPnl,
winRate30d = k30.WinRatePct,
text = $"Heute: {todayPnl:+0.00;-0.00} USDC\n7T: {k7.NetPnl:+0.00;-0.00} · {k7.WinRatePct:0}% · {k7.TradeCount} Tr.\n30T: {k30.NetPnl:+0.00;-0.00} · {k30.WinRatePct:0}%",
isPositive = k30.NetPnl >= 0
});
}
decimal totalTodayPnl = monthTrades.Where(t => t.ClosedAt >= today).Sum(t => t.RealizedPnl);
var totalK7 = TradeAnalytics.ComputeKpis(monthTrades.Where(t => t.ClosedAt >= week));
var totalK30 = TradeAnalytics.ComputeKpis(monthTrades);
kpiTiles.Add(new
{
moduleName = "Gesamt",
todayPnl = totalTodayPnl,
pnl7d = totalK7.NetPnl,
winRate7d = totalK7.WinRatePct,
count7d = totalK7.TradeCount,
pnl30d = totalK30.NetPnl,
winRate30d = totalK30.WinRatePct,
text = $"Heute: {totalTodayPnl:+0.00;-0.00} USDC\n7T: {totalK7.NetPnl:+0.00;-0.00} · {totalK7.WinRatePct:0}% · {totalK7.TradeCount} Tr.\n30T: {totalK30.NetPnl:+0.00;-0.00} · {totalK30.WinRatePct:0}%",
isPositive = totalK30.NetPnl >= 0
});
// 2. Auffällige Trades (24h)
DateTime since24h = now.AddDays(-1);
List<TradeRecord> dayTrades;
try { dayTrades = tradeLog?.Find(t => t.ClosedAt >= since24h) ?? new List<TradeRecord>(); }
catch { dayTrades = new List<TradeRecord>(); }
var notable = dayTrades
.OrderByDescending(t => Math.Abs(t.RealizedPnl))
.Take(25)
.Select(t => new
{
module = t.ModuleName ?? "",
market = t.MarketQuestion ?? "",
pnl = t.RealizedPnl,
pnlPct = t.PnlPercent,
closedAt = t.ClosedAt
})
.ToList();
// 3. Warnungen & Fehler (heute)
var alerts = new List<object>();
try
{
string logsDir = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Logs");
string path = Path.Combine(logsDir, $"{AppTimeZone.Today:yyyy-MM-dd}.jsonl");
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;
alerts.Add(new
{
time = DateTimeOffset.TryParse(p.Time, System.Globalization.CultureInfo.InvariantCulture, System.Globalization.DateTimeStyles.None, out var dt) ? dt.ToString("HH:mm:ss") : p.Time,
level = p.Level,
message = (p.Message ?? string.Empty).Replace("\r", " ").Replace("\n", " ").Trim()
});
}
alerts.Reverse();
}
else
{
var logger = sp.GetService<TerminalLogger>();
if (logger != null)
{
var memAlerts = logger.GetHistory(TimeSpan.FromHours(24))
.Where(e => e.Level == LogLevel.Warning || e.Level == LogLevel.Error)
.Select(e => new
{
time = e.Timestamp.ToString("HH:mm:ss"),
level = e.Level.ToString(),
message = e.Message.Replace("\r", " ").Replace("\n", " ").Trim()
})
.Reverse()
.Take(100)
.ToList();
alerts.AddRange(memAlerts);
}
}
}
catch { /* stiller Rückzug */ }
// 4. Supervisor-KI (letzter Bericht)
var supervisorReports = sp.GetService<ISupervisorReportRepository>();
object? supervisorSnippet = null;
if (supervisorReports != null)
{
try
{
var last = supervisorReports.GetRecent(1).FirstOrDefault();
if (last != null)
{
supervisorSnippet = new
{
available = true,
createdAt = last.CreatedAt.ToString("dd.MM. HH:mm"),
profile = last.Profile,
model = last.Model,
answer = last.Answer
};
}
else
{
supervisorSnippet = new
{
available = false,
answer = "Noch kein Supervisor-Bericht vorhanden. (OpenRouter-Key setzen und im Supervisor-Fenster eine Analyse starten.)"
};
}
}
catch (Exception ex)
{
supervisorSnippet = new
{
available = false,
answer = $"Supervisor-Berichte nicht verfügbar: {ex.Message}"
};
}
}
else
{
supervisorSnippet = new
{
available = false,
answer = "Supervisor-Modul nicht geladen oder nicht lizenziert."
};
}
return Results.Ok(new
{
moduleKpis = kpiTiles,
notableTrades = notable,
alerts = alerts.Take(100),
supervisorSnippet
});
});
return routes;
}
}