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,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);
}
}
}