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
@@ -0,0 +1,280 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
using PolyTrader.Modules.Accounting.Logic;
using PolyTrader.Modules.Accounting.Models;
using PolyTrader.Modules.Accounting.Persistence;
using PolyTrader.Modules.Accounting.Services;
using PolyTraderSharp;
namespace PolyTrader.Web.Endpoints
{
public static class AccountingEndpoints
{
public static IEndpointRouteBuilder MapAccountingEndpoints(this IEndpointRouteBuilder routes)
{
var group = routes.MapGroup("/api/modules/accounting");
// 1. Periodenabrechnung (Statement)
group.MapGet("/statement", (
IServiceProvider sp,
int? accountId,
DateTime? from,
DateTime? to,
string? currency) =>
{
var reports = sp.GetService<AccountingReportService>();
if (reports == null) return Results.StatusCode(503);
int? accId = accountId.HasValue && accountId.Value > 0 ? accountId.Value : null;
var now = DateTime.UtcNow;
var fromDate = from ?? new DateTime(now.Year, now.Month, 1);
var toDate = to ?? now.Date;
string currCode = string.IsNullOrWhiteSpace(currency) ? "USDC" : currency.Trim().ToUpperInvariant();
var currCtx = reports.ResolveCurrency(currCode, toDate);
var statement = reports.BuildStatement(accId, fromDate, toDate);
var monthly = reports.BuildMonthly(accId, fromDate, toDate);
decimal V(decimal usdc) => Math.Round(usdc * currCtx.Factor, 2, MidpointRounding.AwayFromZero);
var monthlyRows = monthly.Select(m => new
{
from = m.From.ToString("yyyy-MM-dd"),
to = m.To.ToString("yyyy-MM-dd"),
monthLabel = m.From.ToString("MMMM yyyy"),
openingBalance = V(m.OpeningBalanceUsdc),
closingBalance = V(m.ClosingBalanceUsdc),
balanceChange = V(m.BalanceChange),
deposits = V(m.Deposits),
withdrawals = V(m.Withdrawals),
tradeVolume = V(m.TradeVolume),
redeems = V(m.Redeems),
rewards = V(m.Rewards),
fees = V(m.Fees),
netTradingResult = V(m.NetTradingResultUsdc),
tradeCount = m.TradeCount,
entryCount = m.EntryCount
}).ToList();
return Results.Ok(new
{
currency = new
{
code = currCtx.Code,
factor = currCtx.Factor,
available = currCtx.Available,
note = currCtx.Note
},
statement = new
{
accountId = statement.AccountId,
from = statement.From.ToString("yyyy-MM-dd"),
to = statement.To.ToString("yyyy-MM-dd"),
openingBalance = V(statement.OpeningBalanceUsdc),
closingBalance = V(statement.ClosingBalanceUsdc),
balanceChange = V(statement.BalanceChange),
deposits = V(statement.Deposits),
withdrawals = V(statement.Withdrawals),
tradeVolume = V(statement.TradeVolume),
redeems = V(statement.Redeems),
rewards = V(statement.Rewards),
fees = V(statement.Fees),
netTradingResult = V(statement.NetTradingResultUsdc),
tradeCount = statement.TradeCount,
entryCount = statement.EntryCount
},
monthly = monthlyRows
});
});
// 2. PDF-Export
group.MapGet("/export/pdf", (
IServiceProvider sp,
int? accountId,
DateTime? from,
DateTime? to,
string? currency) =>
{
var reports = sp.GetService<AccountingReportService>();
var ledgerRepo = sp.GetService<ILedgerRepository>();
if (reports == null || ledgerRepo == null) return Results.StatusCode(503);
int? accId = accountId.HasValue && accountId.Value > 0 ? accountId.Value : null;
var now = DateTime.UtcNow;
var fromDate = from ?? new DateTime(now.Year, now.Month, 1);
var toDate = to ?? now.Date;
string currCode = string.IsNullOrWhiteSpace(currency) ? "USDC" : currency.Trim().ToUpperInvariant();
var currCtx = reports.ResolveCurrency(currCode, toDate);
var statement = reports.BuildStatement(accId, fromDate, toDate);
var monthly = reports.BuildMonthly(accId, fromDate, toDate);
var periodEntries = ledgerRepo.Query(accId, fromDate, toDate, 20000);
try
{
byte[] pdfBytes = PdfExporter.Render(
statement,
monthly,
periodEntries,
currCtx.Code,
currCtx.Factor,
currCtx.Note);
string fileName = $"Abrechnung_{currCtx.Code}_{fromDate:yyyyMMdd}_{toDate:yyyyMMdd}.pdf";
return Results.File(pdfBytes, "application/pdf", fileName);
}
catch (Exception ex)
{
return Results.BadRequest(new { error = $"PDF-Generierung fehlgeschlagen: {ex.Message}" });
}
});
// 3. CSV-Export
group.MapGet("/export/csv", (
IServiceProvider sp,
int? accountId,
DateTime? from,
DateTime? to) =>
{
var ledgerRepo = sp.GetService<ILedgerRepository>();
if (ledgerRepo == null) return Results.StatusCode(503);
int? accId = accountId.HasValue && accountId.Value > 0 ? accountId.Value : null;
var now = DateTime.UtcNow;
var fromDate = from ?? new DateTime(now.Year, now.Month, 1);
var toDate = to ?? now.Date;
var entries = ledgerRepo.Query(accId, fromDate, toDate, 50000);
string csv = CsvExporter.Ledger(entries);
string fileName = $"Ledger_{fromDate:yyyyMMdd}_{toDate:yyyyMMdd}.csv";
return Results.File(Encoding.UTF8.GetBytes(csv), "text/csv", fileName);
});
// 4. Ledger-Journal (Buchungssätze abfragen mit Paginierung & Suche)
group.MapGet("/ledger", (
IServiceProvider sp,
int? accountId,
DateTime? from,
DateTime? to,
string? search,
int? page,
int? pageSize) =>
{
var ledgerRepo = sp.GetService<ILedgerRepository>();
if (ledgerRepo == null) return Results.StatusCode(503);
int? accId = accountId.HasValue && accountId.Value > 0 ? accountId.Value : null;
var entries = ledgerRepo.Query(accId, from, to, 10000);
var query = entries.AsEnumerable();
if (!string.IsNullOrWhiteSpace(search))
{
string q = search.Trim();
query = query.Where(e =>
(e.MarketSlug != null && e.MarketSlug.Contains(q, StringComparison.OrdinalIgnoreCase)) ||
(e.Outcome != null && e.Outcome.Contains(q, StringComparison.OrdinalIgnoreCase)) ||
(e.TxHash != null && e.TxHash.Contains(q, StringComparison.OrdinalIgnoreCase)) ||
e.EventType.ToString().Contains(q, StringComparison.OrdinalIgnoreCase));
}
var filtered = query.OrderByDescending(e => e.Timestamp).ToList();
int totalCount = filtered.Count;
int curPage = Math.Max(1, page.GetValueOrDefault(1));
int curPageSize = Math.Clamp(pageSize.GetValueOrDefault(50), 10, 200);
int totalPages = (int)Math.Ceiling(totalCount / (double)curPageSize);
var items = filtered
.Skip((curPage - 1) * curPageSize)
.Take(curPageSize)
.Select(e => new
{
timestamp = e.Timestamp.ToString("yyyy-MM-dd HH:mm:ss"),
accountId = e.AccountId,
eventType = e.EventType.ToString(),
side = e.Side,
marketSlug = e.MarketSlug,
outcome = e.Outcome,
size = e.Size,
priceUsdc = e.PriceUsdc,
grossUsdc = e.GrossUsdc,
feeUsdc = e.FeeUsdc,
netUsdc = e.NetUsdc,
txHash = e.TxHash,
source = e.Source
})
.ToList();
return Results.Ok(new
{
totalCount,
page = curPage,
pageSize = curPageSize,
totalPages,
items
});
});
// 5. Ingest-Läufe (Status)
group.MapGet("/runs", (IServiceProvider sp, int? accountId, int? limit) =>
{
var runRepo = sp.GetService<IIngestRunRepository>();
List<IngestRun> runs;
try
{
runs = runRepo?.GetRecent(accountId, limit.GetValueOrDefault(50)) ?? new List<IngestRun>();
}
catch
{
runs = new List<IngestRun>();
}
var list = runs.Select(r => new
{
id = r.Id,
accountId = r.AccountId,
startedAt = r.StartedAt.ToString("yyyy-MM-dd HH:mm:ss"),
completedAt = r.FinishedAt.HasValue ? r.FinishedAt.Value.ToString("yyyy-MM-dd HH:mm:ss") : "—",
backfill = r.Backfill,
newEntries = r.NewEntries,
duplicateEntries = r.DuplicateEntries,
balanceDeltaUsdc = r.BalanceDeltaUsdc,
success = r.Success,
message = r.Message
}).ToList();
return Results.Ok(list);
});
// 6. Ingest manuell auslösen
group.MapPost("/ingest", async (IServiceProvider sp, AccountingIngestRequest? request, CancellationToken ct) =>
{
var ingest = sp.GetService<AccountingIngestService>();
if (ingest == null) return Results.StatusCode(503);
bool backfill = request?.Backfill ?? false;
try
{
await ingest.IngestAllAsync(backfill, ct);
return Results.Ok(new { success = true, backfill, message = "Ingest-Durchlauf erfolgreich abgeschlossen." });
}
catch (Exception ex)
{
return Results.BadRequest(new { error = ex.Message });
}
});
return routes;
}
public record AccountingIngestRequest(bool Backfill);
}
}