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,190 @@
using System;
using System.Collections.Generic;
using System.Linq;
using PolyTrader.Core.Analytics;
using PolyTraderSharp.Models;
using Xunit;
namespace PolyTrader.Tests.WebUi
{
public class AnalyticsEndpointsTests
{
private List<TradeRecord> CreateSampleTrades()
{
return new List<TradeRecord>
{
new TradeRecord
{
Id = "trade-1",
AccountId = 1,
ModuleName = "CopyTrading",
MarketQuestion = "Will Bitcoin hit 100k in 2026?",
Outcome = "Yes",
Side = "BUY",
IsDemo = false,
EntryPrice = 0.40m,
ExitPrice = 0.80m,
Size = 100m,
RealizedPnl = 40m,
PnlPercent = 100m,
ClosedAt = DateTime.UtcNow.AddDays(-2)
},
new TradeRecord
{
Id = "trade-2",
AccountId = 1,
ModuleName = "CopyTrading",
MarketQuestion = "Will Ethereum hit 5k in 2026?",
Outcome = "No",
Side = "BUY",
IsDemo = false,
EntryPrice = 0.50m,
ExitPrice = 0.20m,
Size = 50m,
RealizedPnl = -15m,
PnlPercent = -60m,
ClosedAt = DateTime.UtcNow.AddDays(-1)
},
new TradeRecord
{
Id = "trade-3",
AccountId = 2,
ModuleName = "ResolutionFarming",
MarketQuestion = "Fed interest rate cut in Q1?",
Outcome = "Yes",
Side = "BUY",
IsDemo = true,
EntryPrice = 0.90m,
ExitPrice = 1.00m,
Size = 200m,
RealizedPnl = 20m,
PnlPercent = 11.1m,
ClosedAt = DateTime.UtcNow.AddDays(-5)
},
new TradeRecord
{
Id = "trade-4",
AccountId = 2,
ModuleName = "ResolutionFarming",
MarketQuestion = "US GDP growth above 2%?",
Outcome = "Yes",
Side = "BUY",
IsDemo = false,
EntryPrice = 0.92m,
ExitPrice = 1.00m,
Size = 100m,
RealizedPnl = 8m,
PnlPercent = 8.7m,
ClosedAt = DateTime.UtcNow.AddDays(-15)
}
};
}
[Fact]
public void Analytics_FilterByAccount_ReturnsOnlyMatchingTrades()
{
var trades = CreateSampleTrades();
int filterAccId = 1;
var filtered = trades.Where(t => t.AccountId == filterAccId).ToList();
Assert.Equal(2, filtered.Count);
Assert.All(filtered, t => Assert.Equal(1, t.AccountId));
}
[Fact]
public void Analytics_FilterByModule_ReturnsOnlyMatchingModuleTrades()
{
var trades = CreateSampleTrades();
string selectedModule = "ResolutionFarming";
var filtered = trades.Where(t => string.Equals(t.ModuleName, selectedModule, StringComparison.OrdinalIgnoreCase)).ToList();
Assert.Equal(2, filtered.Count);
Assert.All(filtered, t => Assert.Equal("ResolutionFarming", t.ModuleName));
}
[Fact]
public void Analytics_FilterByMode_SeparatesLiveAndDemo()
{
var trades = CreateSampleTrades();
var liveTrades = trades.Where(t => !t.IsDemo).ToList();
var demoTrades = trades.Where(t => t.IsDemo).ToList();
Assert.Equal(3, liveTrades.Count);
var singleDemo = Assert.Single(demoTrades);
Assert.Equal("trade-3", singleDemo.Id);
}
[Fact]
public void Analytics_FilterByDays_AppliesTimeWindow()
{
var trades = CreateSampleTrades();
var since7d = DateTime.UtcNow.AddDays(-7);
var recentTrades = trades.Where(t => t.ClosedAt >= since7d).ToList();
Assert.Equal(3, recentTrades.Count);
Assert.DoesNotContain(recentTrades, t => t.Id == "trade-4"); // Id trade-4 is 15 days ago
}
[Fact]
public void Analytics_SearchFilter_MatchesMarketQuestionOrOutcome()
{
var trades = CreateSampleTrades();
string query = "Bitcoin";
var matched = trades.Where(t =>
(t.MarketQuestion != null && t.MarketQuestion.Contains(query, StringComparison.OrdinalIgnoreCase)) ||
(t.Outcome != null && t.Outcome.Contains(query, StringComparison.OrdinalIgnoreCase))
).ToList();
var match = Assert.Single(matched);
Assert.Equal("trade-1", match.Id);
}
[Fact]
public void Analytics_WinLossFilter_SeparatesProfitableAndLosingTrades()
{
var trades = CreateSampleTrades();
var winners = trades.Where(t => t.RealizedPnl > 0).ToList();
var losers = trades.Where(t => t.RealizedPnl < 0).ToList();
Assert.Equal(3, winners.Count);
var singleLoser = Assert.Single(losers);
Assert.Equal("trade-2", singleLoser.Id);
}
[Fact]
public void Analytics_ComputeKpis_ReturnsAccurateMetrics()
{
var trades = CreateSampleTrades();
var kpis = TradeAnalytics.ComputeKpis(trades);
Assert.Equal(4, kpis.TradeCount);
Assert.Equal(53m, kpis.NetPnl); // 40 - 15 + 20 + 8 = 53
Assert.Equal(75m, kpis.WinRatePct); // 3 wins out of 4 = 75%
Assert.Equal(13.25m, kpis.AvgPnlPerTrade); // 53 / 4 = 13.25
Assert.True(kpis.ProfitFactor > 4.5m); // Gross wins: 68, Gross loss: 15 -> 4.53
}
[Fact]
public void Analytics_Charts_CalculatesSeriesProperly()
{
var trades = CreateSampleTrades();
var equityCurve = TradeAnalytics.EquityCurve(trades).ToList();
Assert.NotEmpty(equityCurve);
Assert.Equal(53m, equityCurve.Last().Cumulative);
var pnlByModule = TradeAnalytics.PnlByKey(trades, t => t.ModuleName).ToList();
Assert.Equal(2, pnlByModule.Count);
var copyTradingPnl = pnlByModule.First(m => m.Key == "CopyTrading");
Assert.Equal(25m, copyTradingPnl.Pnl); // 40 - 15 = 25
var resFarmingPnl = pnlByModule.First(m => m.Key == "ResolutionFarming");
Assert.Equal(28m, resFarmingPnl.Pnl); // 20 + 8 = 28
}
}
}