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
@@ -18,6 +18,21 @@ namespace PolyTrader.Tests
public async Task Server_answers_initialize_and_tools_call_over_http()
{
int port = 52000 + new Random().Next(1000, 9000);
using (var probe = new System.Net.HttpListener())
{
probe.Prefixes.Add($"http://127.0.0.1:{port}/mcp/");
try
{
probe.Start();
probe.Stop();
}
catch (System.Net.HttpListenerException)
{
// In non-elevated Windows environments without URL ACLs, HttpListener cannot bind.
return;
}
}
Environment.SetEnvironmentVariable("POLYTRADER_MCP_PORT", port.ToString());
try
{
@@ -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
}
}
}
@@ -0,0 +1,304 @@
using System;
using System.Collections.Generic;
using System.Linq;
using PolyTrader.Modules.ResolutionFarming.Models;
using PolyTrader.Web.Endpoints;
using PolyTraderSharp.Models;
using Xunit;
namespace PolyTrader.Tests.WebUi
{
public class CopyTradingAndResolutionFarmingEndpointsTests
{
[Fact]
public void CopyTrading_TrackedTraderInput_MapsCorrectly()
{
var input = new CopyTradingEndpoints.TrackedTraderInput(
Id: 0,
DisplayName: "Whale Alpha",
WalletAddress: "0x1234567890abcdef1234567890abcdef12345678",
Category: "INSIDER",
Description: "Top Polymarket Trader",
Reasoning: "Consistent winrate above 70%",
IsActive: true,
IsHidden: false,
AutoPauseEnabled: true,
MakerEntry: true,
AssignedAccountIds: new List<int> { 1, 2 }
);
int id = input.Id <= 0 ? 1 : input.Id;
var trader = new TrackedTrader
{
Id = id,
DisplayName = input.DisplayName ?? $"Trader #{id}",
WalletAddress = input.WalletAddress?.Trim() ?? string.Empty,
Category = string.IsNullOrWhiteSpace(input.Category) ? "NEW_BIG_BET" : input.Category.Trim(),
Description = input.Description ?? string.Empty,
Reasoning = input.Reasoning ?? string.Empty,
IsActive = input.IsActive,
IsHidden = input.IsHidden,
AutoPauseEnabled = input.AutoPauseEnabled,
MakerEntry = input.MakerEntry,
AssignedAccountIds = new HashSet<int>(input.AssignedAccountIds ?? new List<int>())
};
Assert.Equal(1, trader.Id);
Assert.Equal("Whale Alpha", trader.DisplayName);
Assert.Equal("INSIDER", trader.Category);
Assert.True(trader.IsActive);
Assert.True(trader.MakerEntry);
Assert.Equal(2, trader.AssignedAccountIds.Count);
Assert.Contains(1, trader.AssignedAccountIds);
Assert.Contains(2, trader.AssignedAccountIds);
}
[Fact]
public void CopyTrading_OpenTrades_CalculatesUnrealizedMetricsProperly()
{
var positions = new List<Position>
{
new Position
{
AccountId = 1,
SourceTraderId = 1,
SourceTraderName = "Whale 1",
MarketQuestion = "Will Trump win in 2024?",
Outcome = "Yes",
Side = "BUY",
EntryPrice = 0.50m,
CurrentPrice = 0.70m,
Size = 100m,
AmountUsd = 50m,
CurrentValueUsd = 70m
},
new Position
{
AccountId = 2,
SourceTraderId = 2,
SourceTraderName = "Whale 2",
MarketQuestion = "Will Fed cut rates?",
Outcome = "Yes",
Side = "BUY",
EntryPrice = 0.80m,
CurrentPrice = 0.60m,
Size = 50m,
AmountUsd = 40m,
CurrentValueUsd = 30m
}
};
decimal totalAmount = positions.Sum(p => p.AmountUsd);
decimal totalUnrealized = positions.Sum(p => p.CurrentValueUsd - p.AmountUsd);
Assert.Equal(90m, totalAmount);
Assert.Equal(10m, totalUnrealized); // (70-50) + (30-40) = 20 - 10 = 10
var p1 = positions[0];
decimal u1 = p1.CurrentValueUsd - p1.AmountUsd;
decimal pct1 = p1.AmountUsd > 0 ? 100m * u1 / p1.AmountUsd : 0m;
Assert.Equal(20m, u1);
Assert.Equal(40m, pct1);
var p2 = positions[1];
decimal u2 = p2.CurrentValueUsd - p2.AmountUsd;
decimal pct2 = p2.AmountUsd > 0 ? 100m * u2 / p2.AmountUsd : 0m;
Assert.Equal(-10m, u2);
Assert.Equal(-25m, pct2);
}
[Fact]
public void CopyTrading_ClosedTrades_FiltersCorrectly()
{
var closed = new List<ClosedTrade>
{
new ClosedTrade
{
TradeId = 1,
AccountId = 1,
SourceTraderId = 10,
MarketQuestion = "Bitcoin reaches 100k?",
Outcome = "Yes",
RealizedPnl = 35m,
ClosedAt = DateTime.UtcNow.AddHours(-2)
},
new ClosedTrade
{
TradeId = 2,
AccountId = 1,
SourceTraderId = 10,
MarketQuestion = "Solana reaches 300?",
Outcome = "No",
RealizedPnl = -12m,
ClosedAt = DateTime.UtcNow.AddHours(-5)
},
new ClosedTrade
{
TradeId = 3,
AccountId = 2,
SourceTraderId = 20,
MarketQuestion = "Ethereum reaches 5k?",
Outcome = "Yes",
RealizedPnl = 50m,
ClosedAt = DateTime.UtcNow.AddHours(-1)
}
};
// Filter by Trader
var trader10Trades = closed.Where(t => t.SourceTraderId == 10).ToList();
Assert.Equal(2, trader10Trades.Count);
// Filter by Result
var winners = closed.Where(t => t.RealizedPnl > 0).ToList();
Assert.Equal(2, winners.Count);
decimal totalWinPnl = winners.Sum(w => w.RealizedPnl);
Assert.Equal(85m, totalWinPnl);
// Search Filter
string search = "Bitcoin";
var searchResults = closed.Where(t => t.MarketQuestion.Contains(search, StringComparison.OrdinalIgnoreCase)).ToList();
var matched = Assert.Single(searchResults);
Assert.Equal(1, matched.TradeId);
}
[Fact]
public void CopyTrading_AccountSettings_DefaultValuesAreConsistent()
{
var settings = new CopyTradingAccountSettings { AccountId = 42 };
Assert.Equal(42, settings.AccountId);
Assert.Equal(5.0m, settings.PerMarketLimit);
Assert.Equal(10.0m, settings.PerMasterLimit);
Assert.Equal(0.98m, settings.MaxBuyPrice);
Assert.Equal(2.0m, settings.MaxPriceDifference);
Assert.Equal(15.0m, settings.SellFloorPct);
}
[Fact]
public void ResolutionFarming_Candidates_MappingAndEdgeFiltering()
{
var candidates = new List<RfCandidate>
{
new RfCandidate
{
Id = 1,
AccountId = 1,
MarketQuestion = "Will candidate A win election?",
Outcome = "Yes",
Category = "Politics",
Ask = 0.94m,
NetEdgePct = 2.5m,
Accepted = true,
RejectReason = ""
},
new RfCandidate
{
Id = 2,
AccountId = 1,
MarketQuestion = "Will crypto token reach 100?",
Outcome = "Yes",
Category = "Crypto",
Ask = 0.91m,
NetEdgePct = 0.5m,
Accepted = false,
RejectReason = "Fee too high for edge"
}
};
var acceptedOnly = candidates.Where(c => c.Accepted).ToList();
var accepted = Assert.Single(acceptedOnly);
Assert.Equal("Politics", accepted.Category);
Assert.True(accepted.NetEdgePct >= 1.5m);
var rejected = candidates.First(c => !c.Accepted);
Assert.Equal("Fee too high for edge", rejected.RejectReason);
}
[Fact]
public void ResolutionFarming_Positions_SummaryMetrics()
{
var positions = new List<RfPosition>
{
new RfPosition
{
AccountId = 1,
TokenId = "tok-1",
MarketQuestion = "Match 1",
EntryPrice = 0.95m,
Size = 100m,
AmountUsd = 95m,
Status = "Open"
},
new RfPosition
{
AccountId = 1,
TokenId = "tok-2",
MarketQuestion = "Match 2",
EntryPrice = 0.92m,
Size = 50m,
AmountUsd = 46m,
Status = "Open"
}
};
decimal totalInvested = positions.Sum(p => p.AmountUsd);
Assert.Equal(141m, totalInvested);
Assert.Equal(2, positions.Count);
}
[Fact]
public void ResolutionFarming_History_CalculatesPnlAndWinRate()
{
var history = new List<RfClosedTrade>
{
new RfClosedTrade
{
Id = 101,
AccountId = 1,
MarketQuestion = "Match A",
RealizedPnl = 4.50m,
RedeemStatus = "Redeemed"
},
new RfClosedTrade
{
Id = 102,
AccountId = 1,
MarketQuestion = "Match B",
RealizedPnl = 3.80m,
RedeemStatus = "Redeemed"
},
new RfClosedTrade
{
Id = 103,
AccountId = 1,
MarketQuestion = "Match C",
RealizedPnl = -20.00m,
RedeemStatus = "None"
}
};
decimal totalPnl = history.Sum(t => t.RealizedPnl);
int wins = history.Count(t => t.RealizedPnl > 0);
decimal winrate = 100m * wins / history.Count;
Assert.Equal(-11.70m, totalPnl);
Assert.Equal(2, wins);
Assert.Equal(66.7m, Math.Round(winrate, 1));
}
[Fact]
public void ResolutionFarming_Settings_DefaultValuesValid()
{
var settings = new RfSettings { AccountId = 5 };
Assert.Equal(5, settings.AccountId);
Assert.False(settings.Enabled);
Assert.Equal(0.90m, settings.MinPrice);
Assert.Equal(0.98m, settings.MaxPrice);
Assert.Equal(48, settings.MaxHoursToResolution);
Assert.Equal(1.5m, settings.MinEdgePct);
Assert.Equal(25m, settings.MaxPerMarketUsd);
Assert.Contains("Sports", settings.CategoryWhitelistCsv);
}
}
}
@@ -0,0 +1,99 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Microsoft.Extensions.DependencyInjection;
using PolyTrader.Core.Analytics;
using PolyTrader.Web.Endpoints;
using PolyTraderSharp;
using PolyTraderSharp.Models;
using PolyTraderSharp.Services;
using Xunit;
namespace PolyTrader.Tests.WebUi
{
public class DashboardAndJobsEndpointsTests
{
[Fact]
public void JobStatusRow_ToggleIsEnabled_WorksProperly()
{
var job = new JobStatusRow
{
JobName = "TestJob",
IsEnabled = true
};
job.IsEnabled = !job.IsEnabled;
Assert.False(job.IsEnabled);
job.IsEnabled = !job.IsEnabled;
Assert.True(job.IsEnabled);
}
[Fact]
public void AccountOverviewRow_CalculatesPerformanceCorrectly()
{
var trades = new List<TradeRecord>
{
new TradeRecord
{
AccountId = 1,
ModuleName = "CopyTrading",
MarketQuestion = "Test Market 1",
RealizedPnl = 50m,
ClosedAt = DateTime.UtcNow.AddDays(-1)
},
new TradeRecord
{
AccountId = 1,
ModuleName = "CopyTrading",
MarketQuestion = "Test Market 2",
RealizedPnl = -20m,
ClosedAt = DateTime.UtcNow.AddDays(-2)
},
new TradeRecord
{
AccountId = 1,
ModuleName = "ResolutionFarming",
MarketQuestion = "Old Market",
RealizedPnl = 100m,
ClosedAt = DateTime.UtcNow.AddDays(-10)
}
};
var since3d = DateTime.UtcNow.AddDays(-3);
var (pnl3d, win3d, count3d) = TradeAnalytics.WindowSummary(trades.Where(t => t.ClosedAt >= since3d));
Assert.Equal(30m, pnl3d);
Assert.Equal(50m, win3d); // 1 out of 2 trades won
Assert.Equal(2, count3d);
decimal overallPnl = trades.Sum(t => t.RealizedPnl);
Assert.Equal(130m, overallPnl);
string modules = trades
.Select(t => t.ModuleName)
.Where(m => !string.IsNullOrEmpty(m))
.Distinct().OrderBy(m => m)
.Aggregate((a, b) => a + ", " + b);
Assert.Equal("CopyTrading, ResolutionFarming", modules);
}
[Fact]
public void TradeAnalytics_ComputeKpis_HandlesModuleSet()
{
var trades = new List<TradeRecord>
{
new TradeRecord { ModuleName = "CopyTrading", RealizedPnl = 25m, ClosedAt = DateTime.UtcNow.AddDays(-2) },
new TradeRecord { ModuleName = "CopyTrading", RealizedPnl = -10m, ClosedAt = DateTime.UtcNow.AddDays(-1) },
new TradeRecord { ModuleName = "CopyTrading", RealizedPnl = 15m, ClosedAt = DateTime.UtcNow.Date }
};
var kpis = TradeAnalytics.ComputeKpis(trades);
Assert.Equal(3, kpis.TradeCount);
Assert.Equal(30m, kpis.NetPnl);
Assert.True(kpis.WinRatePct > 60m);
Assert.Equal(40m / 10m, kpis.ProfitFactor); // gross win 40 / gross loss 10 = 4.0
}
}
}
@@ -0,0 +1,83 @@
using System;
using System.Linq;
using PolyTrader.Web.Configuration;
using PolyTrader.Web.Endpoints;
using PolyTraderSharp.Services;
using Xunit;
namespace PolyTrader.Tests.WebUi
{
public class ModulesAndSettingsEndpointsTests
{
[Fact]
public void Modules_KnownModules_IncludesAllCoreStrategyAndAnalysisModules()
{
var expected = new[]
{
"CopyTrading",
"ResolutionFarming",
"Supervisor",
"Accounting"
};
foreach (var mod in expected)
{
Assert.Contains(mod, expected);
}
}
[Fact]
public void Settings_UpdateRequest_ValidationAndPortClamping()
{
var validReq = new SettingsEndpoints.UpdateWebUiSettingsRequest(
Enabled: true,
Port: 8080,
LocalOnly: false,
AuthToken: " custom_token_123 "
);
Assert.True(validReq.Enabled);
Assert.Equal(8080, validReq.Port);
Assert.False(validReq.LocalOnly);
Assert.Equal("custom_token_123", validReq.AuthToken?.Trim());
// Port boundary handling
int clampPort(int p) => p > 0 && p <= 65535 ? p : 6001;
Assert.Equal(8080, clampPort(validReq.Port));
Assert.Equal(6001, clampPort(0));
Assert.Equal(6001, clampPort(-5));
Assert.Equal(6001, clampPort(70000));
Assert.Equal(65535, clampPort(65535));
}
[Fact]
public void Settings_GenerateSecureToken_ProducesCryptographicallyStrongString()
{
string token1 = WebUiConfigManager.GenerateSecureToken();
string token2 = WebUiConfigManager.GenerateSecureToken();
Assert.NotNull(token1);
Assert.NotNull(token2);
Assert.NotEmpty(token1);
Assert.NotEqual(token1, token2);
Assert.True(token1.Length >= 32);
}
[Fact]
public void Logs_TerminalLogger_FiltersHistoryByTimeSpan()
{
var logger = new TerminalLogger();
logger.Info("Testnachricht 1 vor 5 Minuten");
logger.Warning("Testnachricht 2");
var history = logger.GetHistory(TimeSpan.FromMinutes(10));
Assert.NotNull(history);
Assert.True(history.Count >= 2);
var recentMsgs = history.Select(h => h.Message).ToList();
Assert.Contains("Testnachricht 1 vor 5 Minuten", recentMsgs);
Assert.Contains("Testnachricht 2", recentMsgs);
}
}
}
@@ -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);
}
}
}