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,282 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user