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,375 @@
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.CopyTrading.Persistence;
using PolyTraderSharp;
using PolyTraderSharp.Models;
namespace PolyTrader.Web.Endpoints
{
public static class CopyTradingEndpoints
{
public static IEndpointRouteBuilder MapCopyTradingEndpoints(this IEndpointRouteBuilder routes)
{
var group = routes.MapGroup("/api/modules/copytrading");
// 1. Master-Trader abrufen
group.MapGet("/traders", (IServiceProvider sp, CopyTradingState copyState) =>
{
var repo = sp.GetService<ITrackedTraderRepository>();
List<TrackedTrader> traders;
try
{
traders = repo?.GetAll() ?? copyState.Traders.Values.OrderBy(t => t.Id).ToList();
if (traders.Count == 0 && copyState.Traders.Count > 0)
traders = copyState.Traders.Values.OrderBy(t => t.Id).ToList();
}
catch
{
traders = copyState.Traders.Values.OrderBy(t => t.Id).ToList();
}
var list = traders.Select(t => new
{
id = t.Id,
displayName = t.DisplayName,
walletAddress = t.WalletAddress,
category = t.Category,
description = t.Description,
reasoning = t.Reasoning,
isActive = t.IsActive,
isHidden = t.IsHidden,
autoPauseEnabled = t.AutoPauseEnabled,
makerEntry = t.MakerEntry,
assignedAccountIds = t.AssignedAccountIds?.ToList() ?? new List<int>(),
totalTrades = t.TotalTrades,
winningTrades = t.WinningTrades,
winrate30t = t.Winrate30t,
totalPnl = t.TotalPnl,
copyPnl30d = t.CopyPnl30d,
copyProfitFactor = t.CopyProfitFactor,
copyAvgPnlPerTrade = t.CopyAvgPnlPerTrade,
copyTradeCount30d = t.CopyTradeCount30d
}).ToList();
return Results.Ok(list);
});
// 2. Master-Trader anlegen / aktualisieren
group.MapPost("/traders", (IServiceProvider sp, CopyTradingState copyState, TrackedTraderInput input) =>
{
var repo = sp.GetService<ITrackedTraderRepository>();
int id = input.Id;
if (id <= 0)
{
int maxId = copyState.Traders.Count > 0 ? copyState.Traders.Keys.Max() : 0;
if (repo != null)
{
try
{
var existing = repo.GetAll();
if (existing.Count > 0)
maxId = Math.Max(maxId, existing.Max(t => t.Id));
}
catch { }
}
id = maxId + 1;
}
var trader = new TrackedTrader
{
Id = id,
DisplayName = input.DisplayName ?? $"Trader #{id}",
WalletAddress = (input.WalletAddress ?? string.Empty).Trim(),
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 = input.AssignedAccountIds != null ? new HashSet<int>(input.AssignedAccountIds) : new HashSet<int>()
};
// Wenn bereits vorhanden, bestehende Stats übernehmen
if (copyState.Traders.TryGetValue(id, out var existingTrader))
{
trader.TotalTrades = existingTrader.TotalTrades;
trader.WinningTrades = existingTrader.WinningTrades;
trader.Winrate30t = existingTrader.Winrate30t;
trader.TotalPnl = existingTrader.TotalPnl;
trader.CopyPnl30d = existingTrader.CopyPnl30d;
trader.CopyProfitFactor = existingTrader.CopyProfitFactor;
trader.CopyAvgPnlPerTrade = existingTrader.CopyAvgPnlPerTrade;
trader.CopyTradeCount30d = existingTrader.CopyTradeCount30d;
}
if (repo != null)
{
try { repo.Upsert(trader); }
catch { /* Fallback in-memory */ }
}
copyState.Traders[id] = trader;
return Results.Ok(new
{
success = true,
trader = new
{
id = trader.Id,
displayName = trader.DisplayName,
walletAddress = trader.WalletAddress,
category = trader.Category,
description = trader.Description,
reasoning = trader.Reasoning,
isActive = trader.IsActive,
isHidden = trader.IsHidden,
autoPauseEnabled = trader.AutoPauseEnabled,
makerEntry = trader.MakerEntry,
assignedAccountIds = trader.AssignedAccountIds.ToList()
}
});
});
// 3. Master-Trader löschen
group.MapDelete("/traders/{id:int}", (IServiceProvider sp, CopyTradingState copyState, int id) =>
{
var repo = sp.GetService<ITrackedTraderRepository>();
if (repo != null)
{
try { repo.Delete(id); }
catch { }
}
copyState.Traders.TryRemove(id, out _);
return Results.Ok(new { success = true, id });
});
// 4. Offene Trades
group.MapGet("/open-trades", (TradingState state, CopyTradingState copyState) =>
{
var openTrades = new List<object>();
foreach (var acc in state.Accounts.Values.OrderBy(a => a.AccountId))
{
foreach (var p in acc.OpenPositions.Values.OrderByDescending(p => p.OpenedAt))
{
decimal unrealized = p.CurrentValueUsd - p.AmountUsd;
decimal pct = p.AmountUsd > 0m ? 100m * unrealized / p.AmountUsd : 0m;
string traderName = p.SourceTraderName;
if (string.IsNullOrEmpty(traderName) && p.SourceTraderId > 0)
{
if (copyState.Traders.TryGetValue(p.SourceTraderId, out var tr))
traderName = tr.DisplayName;
else
traderName = $"#{p.SourceTraderId}";
}
openTrades.Add(new
{
account = (string.IsNullOrEmpty(acc.Name) ? $"#{acc.AccountId}" : acc.Name) + (acc.IsDemo ? " (Demo)" : ""),
accountId = acc.AccountId,
isDemo = acc.IsDemo,
sourceTraderId = p.SourceTraderId,
trader = string.IsNullOrEmpty(traderName) ? "—" : traderName,
market = p.MarketQuestion ?? "—",
outcome = p.Outcome ?? "—",
side = p.Side ?? "BUY",
entryPrice = p.EntryPrice,
currentPrice = p.CurrentPrice,
size = p.Size,
amountUsd = p.AmountUsd,
currentValueUsd = p.CurrentValueUsd,
unrealizedPnl = unrealized,
unrealizedPct = pct,
status = p.ExitPending ? "Exit läuft" : "Offen",
openedAt = p.OpenedAt.ToString("yyyy-MM-dd HH:mm")
});
}
}
return Results.Ok(new
{
count = openTrades.Count,
totalAmount = openTrades.Count > 0 ? state.Accounts.Values.SelectMany(a => a.OpenPositions.Values).Sum(p => p.AmountUsd) : 0m,
totalUnrealized = openTrades.Count > 0 ? state.Accounts.Values.SelectMany(a => a.OpenPositions.Values).Sum(p => p.CurrentValueUsd - p.AmountUsd) : 0m,
trades = openTrades
});
});
// 5. Geschlossene Trades (mit Filter & Paginierung)
group.MapGet("/closed-trades", (
IServiceProvider sp,
TradingState state,
CopyTradingState copyState,
int? traderId,
string? result,
string? search,
int? page,
int? pageSize) =>
{
var repo = sp.GetService<ICopyTradeLogRepository>();
List<ClosedTrade> trades;
try
{
trades = repo?.Find(_ => true) ?? new List<ClosedTrade>();
}
catch
{
trades = new List<ClosedTrade>();
}
var query = trades.AsEnumerable();
if (traderId.HasValue && traderId.Value > 0)
{
query = query.Where(t => t.SourceTraderId == traderId.Value);
}
if (!string.IsNullOrWhiteSpace(result))
{
if (result.Equals("Gewinner", StringComparison.OrdinalIgnoreCase))
query = query.Where(t => t.RealizedPnl > 0);
else if (result.Equals("Verlierer", StringComparison.OrdinalIgnoreCase))
query = query.Where(t => t.RealizedPnl < 0);
}
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)));
}
var filtered = query.OrderByDescending(t => t.ClosedAt).ToList();
int totalCount = filtered.Count;
decimal totalPnl = filtered.Sum(t => t.RealizedPnl);
int winCount = filtered.Count(t => t.RealizedPnl > 0);
decimal winrate = totalCount > 0 ? 100m * winCount / totalCount : 0m;
int curPage = Math.Max(1, page.GetValueOrDefault(1));
int curPageSize = Math.Clamp(pageSize.GetValueOrDefault(50), 10, 200);
int totalPages = (int)Math.Ceiling(totalCount / (double)curPageSize);
var items = filtered
.Skip((curPage - 1) * curPageSize)
.Take(curPageSize)
.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)" : "");
string traderName = $"#{t.SourceTraderId}";
if (copyState.Traders.TryGetValue(t.SourceTraderId, out var tr) && !string.IsNullOrEmpty(tr.DisplayName))
traderName = tr.DisplayName;
return new
{
tradeId = t.TradeId,
account = accName,
accountId = t.AccountId,
isDemo = t.IsDemo,
sourceTraderId = t.SourceTraderId,
trader = traderName,
market = t.MarketQuestion ?? "—",
outcome = t.Outcome ?? "—",
side = t.Side ?? "—",
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
};
})
.ToList();
return Results.Ok(new
{
summary = new
{
totalTrades = totalCount,
totalPnl,
winCount,
winRate = winrate
},
totalCount,
page = curPage,
pageSize = curPageSize,
totalPages,
items
});
});
// 6. Account-Einstellungen abrufen
group.MapGet("/settings/{accountId:int}", (IServiceProvider sp, CopyTradingState copyState, int accountId) =>
{
var repo = sp.GetService<ICopyTradingAccountSettingsRepository>();
CopyTradingAccountSettings? settings = null;
try
{
settings = repo?.Get(accountId);
}
catch { }
if (settings == null)
settings = copyState.GetAccountSettings(accountId);
return Results.Ok(settings);
});
// 7. Account-Einstellungen speichern
group.MapPost("/settings/{accountId:int}", (
IServiceProvider sp,
CopyTradingState copyState,
int accountId,
CopyTradingAccountSettings input) =>
{
input.AccountId = accountId;
var repo = sp.GetService<ICopyTradingAccountSettingsRepository>();
if (repo != null)
{
try { repo.Upsert(input); }
catch { }
}
copyState.AccountSettings[accountId] = input;
return Results.Ok(new
{
success = true,
settings = input
});
});
return routes;
}
public record TrackedTraderInput(
int Id,
string? DisplayName,
string? WalletAddress,
string? Category,
string? Description,
string? Reasoning,
bool IsActive,
bool IsHidden,
bool AutoPauseEnabled,
bool MakerEntry,
List<int>? AssignedAccountIds
);
}
}