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>
283 lines
11 KiB
C#
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);
|
|
}
|
|
}
|
|
}
|