WebUI: Module, Analytics und Buchhaltung als eigene Ansichten
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:
@@ -0,0 +1,183 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
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 PolyTraderSharp;
|
||||
using PolyTraderSharp.Models;
|
||||
|
||||
namespace PolyTrader.Web.Endpoints
|
||||
{
|
||||
public static class AnalyticsEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapAnalyticsEndpoints(this IEndpointRouteBuilder routes)
|
||||
{
|
||||
var group = routes.MapGroup("/api/analytics");
|
||||
|
||||
group.MapGet("/", (
|
||||
IServiceProvider sp,
|
||||
TradingState state,
|
||||
int? accountId,
|
||||
string? module,
|
||||
string? mode,
|
||||
int? days,
|
||||
string? search,
|
||||
string? winLoss,
|
||||
int? page,
|
||||
int? pageSize) =>
|
||||
{
|
||||
var tradeLog = sp.GetService<ITradeLogRepository>();
|
||||
List<TradeRecord> allTrades;
|
||||
try { allTrades = tradeLog?.GetRecent(5000) ?? new List<TradeRecord>(); }
|
||||
catch { allTrades = new List<TradeRecord>(); }
|
||||
|
||||
// Filter options
|
||||
var availableAccounts = new List<object> { new { id = -1, name = "Alle Konten" } };
|
||||
availableAccounts.AddRange(state.Accounts.Values
|
||||
.OrderBy(a => a.AccountId)
|
||||
.Select(a => new
|
||||
{
|
||||
id = a.AccountId,
|
||||
name = (string.IsNullOrEmpty(a.Name) ? $"#{a.AccountId}" : a.Name) + (a.IsDemo ? " (Demo)" : "")
|
||||
}));
|
||||
|
||||
var availableModules = new List<string> { "Alle Module" };
|
||||
availableModules.AddRange(allTrades
|
||||
.Select(t => t.ModuleName)
|
||||
.Where(m => !string.IsNullOrEmpty(m))
|
||||
.Distinct()
|
||||
.OrderBy(m => m));
|
||||
|
||||
// Apply Scope Filtering
|
||||
int accId = accountId.GetValueOrDefault(-1);
|
||||
string selectedModule = module ?? "Alle Module";
|
||||
string selectedMode = mode ?? "Alle";
|
||||
DateTime? since = days.HasValue && days.Value > 0 ? DateTime.UtcNow.AddDays(-days.Value) : null;
|
||||
|
||||
var scoped = allTrades.Where(t =>
|
||||
(accId < 0 || t.AccountId == accId) &&
|
||||
(selectedModule == "Alle Module" || string.Equals(t.ModuleName, selectedModule, StringComparison.OrdinalIgnoreCase)) &&
|
||||
(selectedMode == "Alle" || (selectedMode == "Live" && !t.IsDemo) || (selectedMode == "Demo" && t.IsDemo)) &&
|
||||
(!since.HasValue || t.ClosedAt >= since.Value)
|
||||
).ToList();
|
||||
|
||||
// Compute KPIs
|
||||
var kpis = TradeAnalytics.ComputeKpis(scoped);
|
||||
|
||||
// Compute Charts
|
||||
var equityCurve = TradeAnalytics.EquityCurve(scoped)
|
||||
.Select(p => new { at = p.At.ToString("yyyy-MM-dd HH:mm:ss"), cumulative = p.Cumulative })
|
||||
.ToList();
|
||||
|
||||
var pnlByModule = TradeAnalytics.PnlByKey(scoped, t => string.IsNullOrEmpty(t.ModuleName) ? "Unbekannt" : t.ModuleName)
|
||||
.Select(p => new { module = p.Key, pnl = p.Pnl, count = p.Count })
|
||||
.ToList();
|
||||
|
||||
var pnlByDay = TradeAnalytics.PnlByDay(scoped)
|
||||
.Select(p => new { day = p.Day.ToString("yyyy-MM-dd"), pnl = p.Pnl })
|
||||
.ToList();
|
||||
|
||||
// Filtered History
|
||||
var historyBase = scoped.OrderByDescending(t => t.ClosedAt);
|
||||
IEnumerable<TradeRecord> filteredHistory = historyBase;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(search))
|
||||
{
|
||||
string q = search.Trim();
|
||||
filteredHistory = filteredHistory.Where(t =>
|
||||
(t.MarketQuestion != null && t.MarketQuestion.Contains(q, StringComparison.OrdinalIgnoreCase)) ||
|
||||
(t.Outcome != null && t.Outcome.Contains(q, StringComparison.OrdinalIgnoreCase)));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(winLoss))
|
||||
{
|
||||
if (winLoss.Equals("Gewinner", StringComparison.OrdinalIgnoreCase))
|
||||
filteredHistory = filteredHistory.Where(t => t.RealizedPnl > 0);
|
||||
else if (winLoss.Equals("Verlierer", StringComparison.OrdinalIgnoreCase))
|
||||
filteredHistory = filteredHistory.Where(t => t.RealizedPnl < 0);
|
||||
}
|
||||
|
||||
var filteredList = filteredHistory.ToList();
|
||||
int totalTradesCount = filteredList.Count;
|
||||
int curPage = Math.Max(1, page.GetValueOrDefault(1));
|
||||
int curPageSize = Math.Clamp(pageSize.GetValueOrDefault(50), 10, 200);
|
||||
int totalPages = (int)Math.Ceiling(totalTradesCount / (double)curPageSize);
|
||||
|
||||
var pagedTrades = filteredList
|
||||
.Skip((curPage - 1) * curPageSize)
|
||||
.Take(curPageSize)
|
||||
.Select(t =>
|
||||
{
|
||||
string accName = $"#{t.AccountId}";
|
||||
if (state.Accounts.TryGetValue(t.AccountId, out var acc) && !string.IsNullOrEmpty(acc.Name))
|
||||
accName = acc.Name + (t.IsDemo ? " (Demo)" : "");
|
||||
|
||||
return new
|
||||
{
|
||||
id = t.Id,
|
||||
closedAt = t.ClosedAt.ToString("yyyy-MM-dd HH:mm"),
|
||||
module = t.ModuleName ?? "—",
|
||||
account = accName,
|
||||
accountId = t.AccountId,
|
||||
isDemo = t.IsDemo,
|
||||
market = t.MarketQuestion ?? "—",
|
||||
outcome = t.Outcome ?? "—",
|
||||
side = t.Side ?? "—",
|
||||
entryPrice = t.EntryPrice,
|
||||
exitPrice = t.ExitPrice,
|
||||
size = t.Size,
|
||||
realizedPnl = t.RealizedPnl,
|
||||
pnlPercent = t.PnlPercent
|
||||
};
|
||||
})
|
||||
.ToList();
|
||||
|
||||
return Results.Ok(new
|
||||
{
|
||||
filterOptions = new
|
||||
{
|
||||
accounts = availableAccounts,
|
||||
modules = availableModules,
|
||||
modes = new[] { "Alle", "Live", "Demo" },
|
||||
ranges = new[]
|
||||
{
|
||||
new { days = 7, label = "7 Tage" },
|
||||
new { days = 30, label = "30 Tage" },
|
||||
new { days = 90, label = "90 Tage" },
|
||||
new { days = 0, label = "Alle" }
|
||||
}
|
||||
},
|
||||
kpis = new
|
||||
{
|
||||
tradeCount = kpis.TradeCount,
|
||||
netPnl = kpis.NetPnl,
|
||||
winRatePct = kpis.WinRatePct,
|
||||
avgPnlPerTrade = kpis.AvgPnlPerTrade,
|
||||
profitFactor = kpis.ProfitFactor >= TradeAnalytics.NoLossProfitFactor ? "∞" : kpis.ProfitFactor.ToString("N2"),
|
||||
isNoLoss = kpis.ProfitFactor >= TradeAnalytics.NoLossProfitFactor
|
||||
},
|
||||
charts = new
|
||||
{
|
||||
equityCurve,
|
||||
pnlByModule,
|
||||
pnlByDay
|
||||
},
|
||||
trades = new
|
||||
{
|
||||
totalCount = totalTradesCount,
|
||||
page = curPage,
|
||||
pageSize = curPageSize,
|
||||
totalPages,
|
||||
items = pagedTrades
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return routes;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user