Files
PolyTraderSharp/tests/PolyTrader.Tests/WebUi/SupervisorAndAccountingEndpointsTests.cs
T
RichardandClaude Opus 5 dcc764dec1
CI / Build & Tests (Linux) (push) Canceled after 0s
CI / Plattformneutralität & Hygiene (push) Canceled after 0s
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>
2026-09-10 09:17:49 +02:00

283 lines
11 KiB
C#

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using PolyTrader.Modules.Accounting.Logic;
using PolyTrader.Modules.Accounting.Models;
using PolyTrader.Modules.Accounting.Services;
using PolyTrader.Modules.Supervisor.Agent;
using PolyTrader.Modules.Supervisor.Counterfactual;
using PolyTrader.Modules.Supervisor.Persistence;
using PolyTrader.Web.Endpoints;
using Xunit;
namespace PolyTrader.Tests.WebUi
{
public class SupervisorAndAccountingEndpointsTests
{
[Fact]
public void Supervisor_Profiles_ContainsStandardProfiles()
{
var profiles = SupervisorProfiles.All;
Assert.NotNull(profiles);
Assert.NotEmpty(profiles);
var allgemein = SupervisorProfiles.ByName("Allgemein");
var technik = SupervisorProfiles.ByName("Technik");
var copyTrading = SupervisorProfiles.ByName("CopyTrading");
var rf = SupervisorProfiles.ByName("ResolutionFarming");
Assert.Equal("Allgemein", allgemein.Name);
Assert.Equal("Technik", technik.Name);
Assert.Equal("CopyTrading", copyTrading.Name);
Assert.Equal("ResolutionFarming", rf.Name);
// Unknown fallback to Allgemein
var fallback = SupervisorProfiles.ByName("UnknownRandomProfile");
Assert.Equal(SupervisorProfiles.Allgemein.Name, fallback.Name);
}
[Fact]
public void Supervisor_AskRequest_RejectsEmptyQuestion()
{
var reqEmpty = new SupervisorEndpoints.SupervisorAskRequest("", "Allgemein", null);
var reqWhitespace = new SupervisorEndpoints.SupervisorAskRequest(" ", "Technik", null);
var reqValid = new SupervisorEndpoints.SupervisorAskRequest("Wie ist der Tages-PnL?", "Allgemein", "gpt-4o");
Assert.True(string.IsNullOrWhiteSpace(reqEmpty.Question));
Assert.True(string.IsNullOrWhiteSpace(reqWhitespace.Question));
Assert.False(string.IsNullOrWhiteSpace(reqValid.Question));
Assert.Equal("gpt-4o", reqValid.Model);
}
[Fact]
public void Supervisor_Report_SerializesAndDeserializesToolCalls()
{
var toolCalls = new List<object>
{
new { tool = "query_pnl", args = new { period = "today" } },
new { tool = "query_positions", args = new { minSize = 10 } }
};
var json = JsonSerializer.Serialize(toolCalls);
var report = new SupervisorReport
{
Id = 42,
CreatedAt = DateTime.UtcNow,
Profile = "Technik",
Model = "gpt-4o-mini",
Question = "Gibt es offene Fehlermuster?",
Answer = "Keine kritischen Fehler gefunden.",
ToolCallsJson = json,
ToolCallCount = 2,
PromptTokens = 450,
CompletionTokens = 120
};
Assert.Equal(42, report.Id);
Assert.Equal("Technik", report.Profile);
Assert.Equal(2, report.ToolCallCount);
Assert.Equal(570, report.PromptTokens + report.CompletionTokens);
Assert.Contains("query_pnl", report.ToolCallsJson);
Assert.Contains("query_positions", report.ToolCallsJson);
}
[Fact]
public void Supervisor_CounterfactualRecord_MapsPropertiesAccurately()
{
var record = new CounterfactualRecord
{
Id = 1,
CheckedAt = DateTime.UtcNow,
DecisionId = 100,
SignalId = "sig-abc-123",
ModuleName = "CopyTrading",
AccountId = 2,
MarketSlug = "fed-rate-cut-2026",
MarketQuestion = "Will Fed cut rates in March 2026?",
Reason = "Price exceeds max buy price threshold",
SignalPrice = 0.85m,
IsWinner = true,
HypotheticalPnlPerShare = 0.15m
};
Assert.Equal("sig-abc-123", record.SignalId);
Assert.Equal("CopyTrading", record.ModuleName);
Assert.True(record.IsWinner);
Assert.Equal(0.15m, record.HypotheticalPnlPerShare);
Assert.Equal(0.85m, record.SignalPrice);
}
[Fact]
public void Accounting_ResolveCurrency_ResolvesUSDCAndUSDAtParity()
{
var service = new AccountingReportService(
ledger: null!,
fx: null!);
var usdc = service.ResolveCurrency("USDC", DateTime.UtcNow);
var usd = service.ResolveCurrency("USD", DateTime.UtcNow);
var eur = service.ResolveCurrency("EUR", DateTime.UtcNow);
Assert.Equal("USDC", usdc.Code);
Assert.Equal(1.0m, usdc.Factor);
Assert.Equal("USD", usd.Code);
Assert.Equal(1.0m, usd.Factor);
Assert.Equal("EUR", eur.Code);
// Ohne FX-Repo liefert EUR available = false und factor = 0
Assert.False(eur.Available);
}
[Fact]
public void Accounting_PeriodStatement_CalculatesBalanceChangeAndTotals()
{
var statement = new PeriodStatement(
AccountId: 1,
From: new DateTime(2026, 3, 1),
To: new DateTime(2026, 3, 31),
OpeningBalanceUsdc: 1000m,
ClosingBalanceUsdc: 1250m,
Deposits: 200m,
Withdrawals: 50m,
TradeVolume: 5000m,
Redeems: 107.5m,
Rewards: 5.0m,
Fees: 12.5m,
NetTradingResultUsdc: 100m,
TradeCount: 25,
EntryCount: 60
);
decimal balanceChange = statement.BalanceChange; // Closing - Opening
Assert.Equal(250m, balanceChange);
// Factor scaling test (e.g. converting to EUR at factor 0.90)
decimal factor = 0.90m;
decimal eurNetResult = Math.Round(statement.NetTradingResultUsdc * factor, 2);
decimal eurOpening = Math.Round(statement.OpeningBalanceUsdc * factor, 2);
decimal eurClosing = Math.Round(statement.ClosingBalanceUsdc * factor, 2);
Assert.Equal(90.00m, eurNetResult);
Assert.Equal(900.00m, eurOpening);
Assert.Equal(1125.00m, eurClosing);
}
[Fact]
public void Accounting_LedgerQuery_SearchAndPaginationLogicWorks()
{
var entries = new List<LedgerEntry>
{
new LedgerEntry
{
Id = 1,
Timestamp = DateTime.UtcNow.AddMinutes(-30),
AccountId = 1,
EventType = LedgerEventType.TradeBuy,
MarketSlug = "will-btc-hit-150k",
Outcome = "Yes",
Side = "BUY",
Size = 100m,
PriceUsdc = 0.55m,
GrossUsdc = 55m,
FeeUsdc = 0.55m,
NetUsdc = -55.55m,
TxHash = "0xaaa111"
},
new LedgerEntry
{
Id = 2,
Timestamp = DateTime.UtcNow.AddMinutes(-20),
AccountId = 1,
EventType = LedgerEventType.TradeSell,
MarketSlug = "will-eth-hit-5k",
Outcome = "Yes",
Side = "SELL",
Size = 50m,
PriceUsdc = 0.80m,
GrossUsdc = 40m,
FeeUsdc = 0.40m,
NetUsdc = 39.60m,
TxHash = "0xbbb222"
},
new LedgerEntry
{
Id = 3,
Timestamp = DateTime.UtcNow.AddMinutes(-10),
AccountId = 2,
EventType = LedgerEventType.Redeem,
MarketSlug = "will-solana-etf-pass",
Outcome = "Yes",
Side = "",
Size = 200m,
PriceUsdc = 1.00m,
GrossUsdc = 200m,
FeeUsdc = 0m,
NetUsdc = 200m,
TxHash = "0xccc333"
}
};
// 1. Search by market slug
string search = "btc";
var filtered = entries.Where(e =>
(e.MarketSlug != null && e.MarketSlug.Contains(search, StringComparison.OrdinalIgnoreCase)) ||
(e.Outcome != null && e.Outcome.Contains(search, StringComparison.OrdinalIgnoreCase)) ||
(e.TxHash != null && e.TxHash.Contains(search, StringComparison.OrdinalIgnoreCase)) ||
e.EventType.ToString().Contains(search, StringComparison.OrdinalIgnoreCase)
).ToList();
Assert.Single(filtered);
Assert.Equal("0xaaa111", filtered[0].TxHash);
// 2. Search by event type
search = "Redeem";
filtered = entries.Where(e =>
(e.MarketSlug != null && e.MarketSlug.Contains(search, StringComparison.OrdinalIgnoreCase)) ||
e.EventType.ToString().Contains(search, StringComparison.OrdinalIgnoreCase)
).ToList();
Assert.Single(filtered);
Assert.Equal(3, filtered[0].Id);
// 3. Pagination math
int totalCount = entries.Count;
int pageSize = 2;
int totalPages = (int)Math.Ceiling(totalCount / (double)pageSize);
Assert.Equal(2, totalPages);
var page1 = entries.OrderByDescending(e => e.Timestamp).Take(pageSize).ToList();
Assert.Equal(2, page1.Count);
Assert.Equal(3, page1[0].Id);
Assert.Equal(2, page1[1].Id);
}
[Fact]
public void Accounting_IngestRun_TracksExecutionStatus()
{
var run = new IngestRun
{
Id = 1,
AccountId = 1,
StartedAt = DateTime.UtcNow.AddMinutes(-5),
FinishedAt = DateTime.UtcNow,
Backfill = true,
NewEntries = 15,
DuplicateEntries = 42,
BalanceDeltaUsdc = 120.50m,
Success = true,
Message = "Sync erfolgreich abgeschlossen"
};
Assert.Equal(1, run.Id);
Assert.True(run.Success);
Assert.True(run.Backfill);
Assert.Equal(15, run.NewEntries);
Assert.Equal(42, run.DuplicateEntries);
Assert.Equal("Sync erfolgreich abgeschlossen", run.Message);
Assert.NotNull(run.FinishedAt);
}
}
}