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,241 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
using PolyTrader.Modules.ResolutionFarming.Models;
using PolyTrader.Modules.ResolutionFarming.Persistence;
using PolyTraderSharp;
namespace PolyTrader.Web.Endpoints
{
public static class ResolutionFarmingEndpoints
{
public static IEndpointRouteBuilder MapResolutionFarmingEndpoints(this IEndpointRouteBuilder routes)
{
var group = routes.MapGroup("/api/modules/resolutionfarming");
// 1. Kandidaten (Scanner-Ergebnisse)
group.MapGet("/candidates", (IServiceProvider sp, TradingState state, int? accountId) =>
{
int accId = accountId.GetValueOrDefault(-1);
if (accId <= 0)
{
var firstAcc = state.Accounts.Values.OrderBy(a => a.AccountId).FirstOrDefault();
if (firstAcc != null) accId = firstAcc.AccountId;
}
var repo = sp.GetService<IRfCandidateRepository>();
List<RfCandidate> candidates;
try
{
candidates = repo?.GetRecent(accId, 500) ?? new List<RfCandidate>();
}
catch
{
candidates = new List<RfCandidate>();
}
var list = candidates.Select(c => new
{
id = c.Id,
scannedAt = c.ScannedAt.ToString("yyyy-MM-dd HH:mm:ss"),
accountId = c.AccountId,
tokenId = c.TokenId,
marketSlug = c.MarketSlug,
marketQuestion = c.MarketQuestion,
outcome = c.Outcome,
category = c.Category,
clusterKey = c.ClusterKey,
ask = c.Ask,
feeBps = c.FeeBps,
netEdgePct = c.NetEdgePct,
score = c.Score,
endDate = c.EndDate.HasValue ? c.EndDate.Value.ToString("yyyy-MM-dd HH:mm") : "—",
accepted = c.Accepted,
rejectReason = c.RejectReason
}).ToList();
return Results.Ok(new
{
accountId = accId,
count = list.Count,
candidates = list
});
});
// 2. Offene Positionen
group.MapGet("/positions", (IServiceProvider sp, TradingState state, int? accountId) =>
{
int accId = accountId.GetValueOrDefault(-1);
var repo = sp.GetService<IRfPositionRepository>();
List<RfPosition> positions;
try
{
if (accId > 0)
positions = repo?.GetOpen(accId) ?? new List<RfPosition>();
else
positions = repo?.GetAllOpen() ?? new List<RfPosition>();
}
catch
{
positions = new List<RfPosition>();
}
var ordered = positions.OrderByDescending(p => p.OpenedAt).ToList();
decimal totalAmount = ordered.Sum(p => p.AmountUsd);
var list = ordered.Select(p =>
{
string accName = $"#{p.AccountId}";
if (state.Accounts.TryGetValue(p.AccountId, out var acc) && !string.IsNullOrEmpty(acc.Name))
accName = acc.Name + (p.IsDemo ? " (Demo)" : "");
return new
{
accountId = p.AccountId,
account = accName,
isDemo = p.IsDemo,
tokenId = p.TokenId,
marketSlug = p.MarketSlug,
marketQuestion = p.MarketQuestion,
outcome = p.Outcome,
category = p.Category,
clusterKey = p.ClusterKey,
entryPrice = p.EntryPrice,
size = p.Size,
amountUsd = p.AmountUsd,
entryFeeBps = p.EntryFeeBps,
openedAt = p.OpenedAt.ToString("yyyy-MM-dd HH:mm"),
endDate = p.EndDate.HasValue ? p.EndDate.Value.ToString("yyyy-MM-dd HH:mm") : "—",
status = p.Status
};
}).ToList();
return Results.Ok(new
{
accountId = accId,
count = list.Count,
totalAmount,
positions = list
});
});
// 3. Historie (abgeschlossene Trades)
group.MapGet("/history", (IServiceProvider sp, TradingState state, int? accountId, string? search) =>
{
int accId = accountId.GetValueOrDefault(-1);
var repo = sp.GetService<IRfClosedTradeRepository>();
List<RfClosedTrade> allClosed;
try
{
allClosed = repo?.Find(t => accId <= 0 || t.AccountId == accId) ?? new List<RfClosedTrade>();
}
catch
{
allClosed = new List<RfClosedTrade>();
}
var query = allClosed.AsEnumerable();
if (!string.IsNullOrWhiteSpace(search))
{
string q = search.Trim();
query = query.Where(t =>
(t.MarketQuestion != null && t.MarketQuestion.Contains(q, StringComparison.OrdinalIgnoreCase)) ||
(t.Outcome != null && t.Outcome.Contains(q, StringComparison.OrdinalIgnoreCase)) ||
(t.Category != null && t.Category.Contains(q, StringComparison.OrdinalIgnoreCase)));
}
var filtered = query.OrderByDescending(t => t.ClosedAt).ToList();
decimal totalPnl = filtered.Sum(t => t.RealizedPnl);
int wins = filtered.Count(t => t.RealizedPnl > 0);
decimal winrate = filtered.Count > 0 ? 100m * wins / filtered.Count : 0m;
var list = filtered.Select(t =>
{
string accName = $"#{t.AccountId}";
if (state.Accounts.TryGetValue(t.AccountId, out var acc) && !string.IsNullOrEmpty(acc.Name))
accName = acc.Name + (t.IsDemo ? " (Demo)" : "");
return new
{
id = t.Id,
accountId = t.AccountId,
account = accName,
isDemo = t.IsDemo,
tokenId = t.TokenId,
marketSlug = t.MarketSlug,
marketQuestion = t.MarketQuestion,
outcome = t.Outcome,
category = t.Category,
clusterKey = t.ClusterKey,
entryPrice = t.EntryPrice,
exitPrice = t.ExitPrice,
size = t.Size,
realizedPnl = t.RealizedPnl,
pnlPercent = t.PnlPercent,
totalFees = t.TotalFees,
openedAt = t.OpenedAt.ToString("yyyy-MM-dd HH:mm"),
closedAt = t.ClosedAt.ToString("yyyy-MM-dd HH:mm"),
exitReason = t.ExitReason,
redeemStatus = t.RedeemStatus
};
}).ToList();
return Results.Ok(new
{
accountId = accId,
summary = new
{
count = list.Count,
pnl = totalPnl,
wins,
winRate = winrate
},
trades = list
});
});
// 4. Einstellungen abrufen
group.MapGet("/settings/{accountId:int}", (IServiceProvider sp, int accountId) =>
{
var repo = sp.GetService<IRfSettingsRepository>();
RfSettings? settings = null;
try
{
settings = repo?.Get(accountId);
}
catch { }
if (settings == null)
settings = new RfSettings { AccountId = accountId };
return Results.Ok(settings);
});
// 5. Einstellungen speichern
group.MapPost("/settings/{accountId:int}", (IServiceProvider sp, int accountId, RfSettings input) =>
{
input.AccountId = accountId;
var repo = sp.GetService<IRfSettingsRepository>();
if (repo != null)
{
try { repo.Upsert(input); }
catch { }
}
return Results.Ok(new
{
success = true,
settings = input
});
});
return routes;
}
}
}