WebUI: Module, Analytics und Buchhaltung als eigene Ansichten
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:
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user