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,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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user