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
@@ -45,7 +45,7 @@ namespace PolyTrader.Modules.Accounting.Services
case "USD":
return new CurrencyContext("USD", UsdcToUsd, true, $"USDC≈USD (1:1-Annahme, Faktor {UsdcToUsd}).");
case "EUR":
var rate = FxConverter.NearestOnOrBefore(_fx.GetAll(), asOf);
var rate = _fx != null ? FxConverter.NearestOnOrBefore(_fx.GetAll(), asOf) : null;
if (rate == null)
return new CurrencyContext("EUR", 0m, false, "Keine EZB-Tageskurse geladen – EUR nicht verfügbar.");
return new CurrencyContext("EUR", UsdcToUsd * rate.UsdToEur, true,
@@ -0,0 +1,280 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
using PolyTrader.Modules.Accounting.Logic;
using PolyTrader.Modules.Accounting.Models;
using PolyTrader.Modules.Accounting.Persistence;
using PolyTrader.Modules.Accounting.Services;
using PolyTraderSharp;
namespace PolyTrader.Web.Endpoints
{
public static class AccountingEndpoints
{
public static IEndpointRouteBuilder MapAccountingEndpoints(this IEndpointRouteBuilder routes)
{
var group = routes.MapGroup("/api/modules/accounting");
// 1. Periodenabrechnung (Statement)
group.MapGet("/statement", (
IServiceProvider sp,
int? accountId,
DateTime? from,
DateTime? to,
string? currency) =>
{
var reports = sp.GetService<AccountingReportService>();
if (reports == null) return Results.StatusCode(503);
int? accId = accountId.HasValue && accountId.Value > 0 ? accountId.Value : null;
var now = DateTime.UtcNow;
var fromDate = from ?? new DateTime(now.Year, now.Month, 1);
var toDate = to ?? now.Date;
string currCode = string.IsNullOrWhiteSpace(currency) ? "USDC" : currency.Trim().ToUpperInvariant();
var currCtx = reports.ResolveCurrency(currCode, toDate);
var statement = reports.BuildStatement(accId, fromDate, toDate);
var monthly = reports.BuildMonthly(accId, fromDate, toDate);
decimal V(decimal usdc) => Math.Round(usdc * currCtx.Factor, 2, MidpointRounding.AwayFromZero);
var monthlyRows = monthly.Select(m => new
{
from = m.From.ToString("yyyy-MM-dd"),
to = m.To.ToString("yyyy-MM-dd"),
monthLabel = m.From.ToString("MMMM yyyy"),
openingBalance = V(m.OpeningBalanceUsdc),
closingBalance = V(m.ClosingBalanceUsdc),
balanceChange = V(m.BalanceChange),
deposits = V(m.Deposits),
withdrawals = V(m.Withdrawals),
tradeVolume = V(m.TradeVolume),
redeems = V(m.Redeems),
rewards = V(m.Rewards),
fees = V(m.Fees),
netTradingResult = V(m.NetTradingResultUsdc),
tradeCount = m.TradeCount,
entryCount = m.EntryCount
}).ToList();
return Results.Ok(new
{
currency = new
{
code = currCtx.Code,
factor = currCtx.Factor,
available = currCtx.Available,
note = currCtx.Note
},
statement = new
{
accountId = statement.AccountId,
from = statement.From.ToString("yyyy-MM-dd"),
to = statement.To.ToString("yyyy-MM-dd"),
openingBalance = V(statement.OpeningBalanceUsdc),
closingBalance = V(statement.ClosingBalanceUsdc),
balanceChange = V(statement.BalanceChange),
deposits = V(statement.Deposits),
withdrawals = V(statement.Withdrawals),
tradeVolume = V(statement.TradeVolume),
redeems = V(statement.Redeems),
rewards = V(statement.Rewards),
fees = V(statement.Fees),
netTradingResult = V(statement.NetTradingResultUsdc),
tradeCount = statement.TradeCount,
entryCount = statement.EntryCount
},
monthly = monthlyRows
});
});
// 2. PDF-Export
group.MapGet("/export/pdf", (
IServiceProvider sp,
int? accountId,
DateTime? from,
DateTime? to,
string? currency) =>
{
var reports = sp.GetService<AccountingReportService>();
var ledgerRepo = sp.GetService<ILedgerRepository>();
if (reports == null || ledgerRepo == null) return Results.StatusCode(503);
int? accId = accountId.HasValue && accountId.Value > 0 ? accountId.Value : null;
var now = DateTime.UtcNow;
var fromDate = from ?? new DateTime(now.Year, now.Month, 1);
var toDate = to ?? now.Date;
string currCode = string.IsNullOrWhiteSpace(currency) ? "USDC" : currency.Trim().ToUpperInvariant();
var currCtx = reports.ResolveCurrency(currCode, toDate);
var statement = reports.BuildStatement(accId, fromDate, toDate);
var monthly = reports.BuildMonthly(accId, fromDate, toDate);
var periodEntries = ledgerRepo.Query(accId, fromDate, toDate, 20000);
try
{
byte[] pdfBytes = PdfExporter.Render(
statement,
monthly,
periodEntries,
currCtx.Code,
currCtx.Factor,
currCtx.Note);
string fileName = $"Abrechnung_{currCtx.Code}_{fromDate:yyyyMMdd}_{toDate:yyyyMMdd}.pdf";
return Results.File(pdfBytes, "application/pdf", fileName);
}
catch (Exception ex)
{
return Results.BadRequest(new { error = $"PDF-Generierung fehlgeschlagen: {ex.Message}" });
}
});
// 3. CSV-Export
group.MapGet("/export/csv", (
IServiceProvider sp,
int? accountId,
DateTime? from,
DateTime? to) =>
{
var ledgerRepo = sp.GetService<ILedgerRepository>();
if (ledgerRepo == null) return Results.StatusCode(503);
int? accId = accountId.HasValue && accountId.Value > 0 ? accountId.Value : null;
var now = DateTime.UtcNow;
var fromDate = from ?? new DateTime(now.Year, now.Month, 1);
var toDate = to ?? now.Date;
var entries = ledgerRepo.Query(accId, fromDate, toDate, 50000);
string csv = CsvExporter.Ledger(entries);
string fileName = $"Ledger_{fromDate:yyyyMMdd}_{toDate:yyyyMMdd}.csv";
return Results.File(Encoding.UTF8.GetBytes(csv), "text/csv", fileName);
});
// 4. Ledger-Journal (Buchungssätze abfragen mit Paginierung & Suche)
group.MapGet("/ledger", (
IServiceProvider sp,
int? accountId,
DateTime? from,
DateTime? to,
string? search,
int? page,
int? pageSize) =>
{
var ledgerRepo = sp.GetService<ILedgerRepository>();
if (ledgerRepo == null) return Results.StatusCode(503);
int? accId = accountId.HasValue && accountId.Value > 0 ? accountId.Value : null;
var entries = ledgerRepo.Query(accId, from, to, 10000);
var query = entries.AsEnumerable();
if (!string.IsNullOrWhiteSpace(search))
{
string q = search.Trim();
query = query.Where(e =>
(e.MarketSlug != null && e.MarketSlug.Contains(q, StringComparison.OrdinalIgnoreCase)) ||
(e.Outcome != null && e.Outcome.Contains(q, StringComparison.OrdinalIgnoreCase)) ||
(e.TxHash != null && e.TxHash.Contains(q, StringComparison.OrdinalIgnoreCase)) ||
e.EventType.ToString().Contains(q, StringComparison.OrdinalIgnoreCase));
}
var filtered = query.OrderByDescending(e => e.Timestamp).ToList();
int totalCount = filtered.Count;
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(e => new
{
timestamp = e.Timestamp.ToString("yyyy-MM-dd HH:mm:ss"),
accountId = e.AccountId,
eventType = e.EventType.ToString(),
side = e.Side,
marketSlug = e.MarketSlug,
outcome = e.Outcome,
size = e.Size,
priceUsdc = e.PriceUsdc,
grossUsdc = e.GrossUsdc,
feeUsdc = e.FeeUsdc,
netUsdc = e.NetUsdc,
txHash = e.TxHash,
source = e.Source
})
.ToList();
return Results.Ok(new
{
totalCount,
page = curPage,
pageSize = curPageSize,
totalPages,
items
});
});
// 5. Ingest-Läufe (Status)
group.MapGet("/runs", (IServiceProvider sp, int? accountId, int? limit) =>
{
var runRepo = sp.GetService<IIngestRunRepository>();
List<IngestRun> runs;
try
{
runs = runRepo?.GetRecent(accountId, limit.GetValueOrDefault(50)) ?? new List<IngestRun>();
}
catch
{
runs = new List<IngestRun>();
}
var list = runs.Select(r => new
{
id = r.Id,
accountId = r.AccountId,
startedAt = r.StartedAt.ToString("yyyy-MM-dd HH:mm:ss"),
completedAt = r.FinishedAt.HasValue ? r.FinishedAt.Value.ToString("yyyy-MM-dd HH:mm:ss") : "—",
backfill = r.Backfill,
newEntries = r.NewEntries,
duplicateEntries = r.DuplicateEntries,
balanceDeltaUsdc = r.BalanceDeltaUsdc,
success = r.Success,
message = r.Message
}).ToList();
return Results.Ok(list);
});
// 6. Ingest manuell auslösen
group.MapPost("/ingest", async (IServiceProvider sp, AccountingIngestRequest? request, CancellationToken ct) =>
{
var ingest = sp.GetService<AccountingIngestService>();
if (ingest == null) return Results.StatusCode(503);
bool backfill = request?.Backfill ?? false;
try
{
await ingest.IngestAllAsync(backfill, ct);
return Results.Ok(new { success = true, backfill, message = "Ingest-Durchlauf erfolgreich abgeschlossen." });
}
catch (Exception ex)
{
return Results.BadRequest(new { error = ex.Message });
}
});
return routes;
}
public record AccountingIngestRequest(bool Backfill);
}
}
@@ -1,8 +1,14 @@
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.Core.Analytics;
using PolyTrader.Core.Persistence;
using PolyTraderSharp;
using PolyTraderSharp.Models;
namespace PolyTrader.Web.Endpoints
{
@@ -12,21 +18,47 @@ namespace PolyTrader.Web.Endpoints
{
var group = routes.MapGroup("/api/accounts");
group.MapGet("/", (TradingState state) =>
group.MapGet("/", (TradingState state, IServiceProvider sp) =>
{
var tradeLog = sp.GetService<ITradeLogRepository>();
DateTime since3d = DateTime.UtcNow.AddDays(-3);
var list = state.Accounts.Values
.OrderBy(a => a.AccountId)
.Select(a => new
.Select(a =>
{
accountId = a.AccountId,
name = a.Name,
isActive = a.IsActive,
isDemo = a.IsDemo,
closeOnlyMode = a.CloseOnlyMode,
usdcBalance = a.AvailableBalance,
portfolioValue = a.TotalBalance,
walletAddress = a.WalletAddress,
openPositionsCount = a.OpenPositions.Count
List<TradeRecord> trades;
try { trades = tradeLog?.Find(t => t.AccountId == a.AccountId) ?? new List<TradeRecord>(); }
catch { trades = new List<TradeRecord>(); }
var (pnl3d, win3d, _) = TradeAnalytics.WindowSummary(trades.Where(t => t.ClosedAt >= since3d));
string modules = trades
.Select(t => t.ModuleName)
.Where(m => !string.IsNullOrEmpty(m))
.Distinct().OrderBy(m => m)
.DefaultIfEmpty("—")
.Aggregate((x, y) => x + ", " + y);
return new
{
accountId = a.AccountId,
name = a.Name,
isActive = a.IsActive,
isDemo = a.IsDemo,
closeOnlyMode = a.CloseOnlyMode,
usdcBalance = a.AvailableBalance,
portfolioValue = a.TotalBalance,
walletAddress = a.WalletAddress,
proxyWalletAddress = a.WalletAddress,
polymarketProfileUrl = !string.IsNullOrWhiteSpace(a.WalletAddress)
? $"https://polymarket.com/profile/{a.WalletAddress}"
: null,
pnl3d,
winRate3d = win3d,
overallPnl = trades.Sum(t => t.RealizedPnl),
modules,
openPositionsCount = a.OpenPositions.Count
};
})
.ToList();
@@ -0,0 +1,183 @@
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.Core.Analytics;
using PolyTrader.Core.Persistence;
using PolyTraderSharp;
using PolyTraderSharp.Models;
namespace PolyTrader.Web.Endpoints
{
public static class AnalyticsEndpoints
{
public static IEndpointRouteBuilder MapAnalyticsEndpoints(this IEndpointRouteBuilder routes)
{
var group = routes.MapGroup("/api/analytics");
group.MapGet("/", (
IServiceProvider sp,
TradingState state,
int? accountId,
string? module,
string? mode,
int? days,
string? search,
string? winLoss,
int? page,
int? pageSize) =>
{
var tradeLog = sp.GetService<ITradeLogRepository>();
List<TradeRecord> allTrades;
try { allTrades = tradeLog?.GetRecent(5000) ?? new List<TradeRecord>(); }
catch { allTrades = new List<TradeRecord>(); }
// Filter options
var availableAccounts = new List<object> { new { id = -1, name = "Alle Konten" } };
availableAccounts.AddRange(state.Accounts.Values
.OrderBy(a => a.AccountId)
.Select(a => new
{
id = a.AccountId,
name = (string.IsNullOrEmpty(a.Name) ? $"#{a.AccountId}" : a.Name) + (a.IsDemo ? " (Demo)" : "")
}));
var availableModules = new List<string> { "Alle Module" };
availableModules.AddRange(allTrades
.Select(t => t.ModuleName)
.Where(m => !string.IsNullOrEmpty(m))
.Distinct()
.OrderBy(m => m));
// Apply Scope Filtering
int accId = accountId.GetValueOrDefault(-1);
string selectedModule = module ?? "Alle Module";
string selectedMode = mode ?? "Alle";
DateTime? since = days.HasValue && days.Value > 0 ? DateTime.UtcNow.AddDays(-days.Value) : null;
var scoped = allTrades.Where(t =>
(accId < 0 || t.AccountId == accId) &&
(selectedModule == "Alle Module" || string.Equals(t.ModuleName, selectedModule, StringComparison.OrdinalIgnoreCase)) &&
(selectedMode == "Alle" || (selectedMode == "Live" && !t.IsDemo) || (selectedMode == "Demo" && t.IsDemo)) &&
(!since.HasValue || t.ClosedAt >= since.Value)
).ToList();
// Compute KPIs
var kpis = TradeAnalytics.ComputeKpis(scoped);
// Compute Charts
var equityCurve = TradeAnalytics.EquityCurve(scoped)
.Select(p => new { at = p.At.ToString("yyyy-MM-dd HH:mm:ss"), cumulative = p.Cumulative })
.ToList();
var pnlByModule = TradeAnalytics.PnlByKey(scoped, t => string.IsNullOrEmpty(t.ModuleName) ? "Unbekannt" : t.ModuleName)
.Select(p => new { module = p.Key, pnl = p.Pnl, count = p.Count })
.ToList();
var pnlByDay = TradeAnalytics.PnlByDay(scoped)
.Select(p => new { day = p.Day.ToString("yyyy-MM-dd"), pnl = p.Pnl })
.ToList();
// Filtered History
var historyBase = scoped.OrderByDescending(t => t.ClosedAt);
IEnumerable<TradeRecord> filteredHistory = historyBase;
if (!string.IsNullOrWhiteSpace(search))
{
string q = search.Trim();
filteredHistory = filteredHistory.Where(t =>
(t.MarketQuestion != null && t.MarketQuestion.Contains(q, StringComparison.OrdinalIgnoreCase)) ||
(t.Outcome != null && t.Outcome.Contains(q, StringComparison.OrdinalIgnoreCase)));
}
if (!string.IsNullOrWhiteSpace(winLoss))
{
if (winLoss.Equals("Gewinner", StringComparison.OrdinalIgnoreCase))
filteredHistory = filteredHistory.Where(t => t.RealizedPnl > 0);
else if (winLoss.Equals("Verlierer", StringComparison.OrdinalIgnoreCase))
filteredHistory = filteredHistory.Where(t => t.RealizedPnl < 0);
}
var filteredList = filteredHistory.ToList();
int totalTradesCount = filteredList.Count;
int curPage = Math.Max(1, page.GetValueOrDefault(1));
int curPageSize = Math.Clamp(pageSize.GetValueOrDefault(50), 10, 200);
int totalPages = (int)Math.Ceiling(totalTradesCount / (double)curPageSize);
var pagedTrades = filteredList
.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)" : "");
return new
{
id = t.Id,
closedAt = t.ClosedAt.ToString("yyyy-MM-dd HH:mm"),
module = t.ModuleName ?? "—",
account = accName,
accountId = t.AccountId,
isDemo = t.IsDemo,
market = t.MarketQuestion ?? "—",
outcome = t.Outcome ?? "—",
side = t.Side ?? "—",
entryPrice = t.EntryPrice,
exitPrice = t.ExitPrice,
size = t.Size,
realizedPnl = t.RealizedPnl,
pnlPercent = t.PnlPercent
};
})
.ToList();
return Results.Ok(new
{
filterOptions = new
{
accounts = availableAccounts,
modules = availableModules,
modes = new[] { "Alle", "Live", "Demo" },
ranges = new[]
{
new { days = 7, label = "7 Tage" },
new { days = 30, label = "30 Tage" },
new { days = 90, label = "90 Tage" },
new { days = 0, label = "Alle" }
}
},
kpis = new
{
tradeCount = kpis.TradeCount,
netPnl = kpis.NetPnl,
winRatePct = kpis.WinRatePct,
avgPnlPerTrade = kpis.AvgPnlPerTrade,
profitFactor = kpis.ProfitFactor >= TradeAnalytics.NoLossProfitFactor ? "∞" : kpis.ProfitFactor.ToString("N2"),
isNoLoss = kpis.ProfitFactor >= TradeAnalytics.NoLossProfitFactor
},
charts = new
{
equityCurve,
pnlByModule,
pnlByDay
},
trades = new
{
totalCount = totalTradesCount,
page = curPage,
pageSize = curPageSize,
totalPages,
items = pagedTrades
}
});
});
return routes;
}
}
}
@@ -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
);
}
}
@@ -1,9 +1,16 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
using PolyTrader.Core.Analytics;
using PolyTrader.Core.Persistence;
using PolyTrader.Modules.Supervisor.Persistence;
using PolyTraderSharp;
using PolyTraderSharp.Models;
using PolyTraderSharp.Services;
namespace PolyTrader.Web.Endpoints
@@ -71,6 +78,177 @@ namespace PolyTrader.Web.Endpoints
return Results.BadRequest(new { error = "Ungültiger Modus oder Parametertyp." });
});
group.MapGet("/overview-widgets", (IServiceProvider sp) =>
{
var tradeLog = sp.GetService<ITradeLogRepository>();
DateTime now = DateTime.UtcNow;
DateTime today = now.Date;
DateTime week = now.AddDays(-7);
DateTime month = now.AddDays(-30);
// 1. Modul-KPIs
List<TradeRecord> monthTrades;
try { monthTrades = tradeLog?.Find(t => t.ClosedAt >= month) ?? new List<TradeRecord>(); }
catch { monthTrades = new List<TradeRecord>(); }
var moduleNames = monthTrades.Select(t => t.ModuleName).Where(m => !string.IsNullOrEmpty(m)).Distinct().OrderBy(m => m).ToList();
var kpiTiles = new List<object>();
foreach (var module in moduleNames)
{
var moduleTrades = monthTrades.Where(t => t.ModuleName == module).ToList();
decimal todayPnl = moduleTrades.Where(t => t.ClosedAt >= today).Sum(t => t.RealizedPnl);
var k7 = TradeAnalytics.ComputeKpis(moduleTrades.Where(t => t.ClosedAt >= week));
var k30 = TradeAnalytics.ComputeKpis(moduleTrades);
kpiTiles.Add(new
{
moduleName = module,
todayPnl,
pnl7d = k7.NetPnl,
winRate7d = k7.WinRatePct,
count7d = k7.TradeCount,
pnl30d = k30.NetPnl,
winRate30d = k30.WinRatePct,
text = $"Heute: {todayPnl:+0.00;-0.00} USDC\n7T: {k7.NetPnl:+0.00;-0.00} · {k7.WinRatePct:0}% · {k7.TradeCount} Tr.\n30T: {k30.NetPnl:+0.00;-0.00} · {k30.WinRatePct:0}%",
isPositive = k30.NetPnl >= 0
});
}
decimal totalTodayPnl = monthTrades.Where(t => t.ClosedAt >= today).Sum(t => t.RealizedPnl);
var totalK7 = TradeAnalytics.ComputeKpis(monthTrades.Where(t => t.ClosedAt >= week));
var totalK30 = TradeAnalytics.ComputeKpis(monthTrades);
kpiTiles.Add(new
{
moduleName = "Gesamt",
todayPnl = totalTodayPnl,
pnl7d = totalK7.NetPnl,
winRate7d = totalK7.WinRatePct,
count7d = totalK7.TradeCount,
pnl30d = totalK30.NetPnl,
winRate30d = totalK30.WinRatePct,
text = $"Heute: {totalTodayPnl:+0.00;-0.00} USDC\n7T: {totalK7.NetPnl:+0.00;-0.00} · {totalK7.WinRatePct:0}% · {totalK7.TradeCount} Tr.\n30T: {totalK30.NetPnl:+0.00;-0.00} · {totalK30.WinRatePct:0}%",
isPositive = totalK30.NetPnl >= 0
});
// 2. Auffällige Trades (24h)
DateTime since24h = now.AddDays(-1);
List<TradeRecord> dayTrades;
try { dayTrades = tradeLog?.Find(t => t.ClosedAt >= since24h) ?? new List<TradeRecord>(); }
catch { dayTrades = new List<TradeRecord>(); }
var notable = dayTrades
.OrderByDescending(t => Math.Abs(t.RealizedPnl))
.Take(25)
.Select(t => new
{
module = t.ModuleName ?? "",
market = t.MarketQuestion ?? "",
pnl = t.RealizedPnl,
pnlPct = t.PnlPercent,
closedAt = t.ClosedAt
})
.ToList();
// 3. Warnungen & Fehler (heute)
var alerts = new List<object>();
try
{
string logsDir = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Logs");
string path = Path.Combine(logsDir, $"{AppTimeZone.Today:yyyy-MM-dd}.jsonl");
if (File.Exists(path))
{
foreach (var line in File.ReadLines(path))
{
var p = LogJson.ParseLine(line);
if (p == null) continue;
if (p.Level != "Error" && p.Level != "Warning") continue;
alerts.Add(new
{
time = DateTimeOffset.TryParse(p.Time, System.Globalization.CultureInfo.InvariantCulture, System.Globalization.DateTimeStyles.None, out var dt) ? dt.ToString("HH:mm:ss") : p.Time,
level = p.Level,
message = (p.Message ?? string.Empty).Replace("\r", " ").Replace("\n", " ").Trim()
});
}
alerts.Reverse();
}
else
{
var logger = sp.GetService<TerminalLogger>();
if (logger != null)
{
var memAlerts = logger.GetHistory(TimeSpan.FromHours(24))
.Where(e => e.Level == LogLevel.Warning || e.Level == LogLevel.Error)
.Select(e => new
{
time = e.Timestamp.ToString("HH:mm:ss"),
level = e.Level.ToString(),
message = e.Message.Replace("\r", " ").Replace("\n", " ").Trim()
})
.Reverse()
.Take(100)
.ToList();
alerts.AddRange(memAlerts);
}
}
}
catch { /* stiller Rückzug */ }
// 4. Supervisor-KI (letzter Bericht)
var supervisorReports = sp.GetService<ISupervisorReportRepository>();
object? supervisorSnippet = null;
if (supervisorReports != null)
{
try
{
var last = supervisorReports.GetRecent(1).FirstOrDefault();
if (last != null)
{
supervisorSnippet = new
{
available = true,
createdAt = last.CreatedAt.ToString("dd.MM. HH:mm"),
profile = last.Profile,
model = last.Model,
answer = last.Answer
};
}
else
{
supervisorSnippet = new
{
available = false,
answer = "Noch kein Supervisor-Bericht vorhanden. (OpenRouter-Key setzen und im Supervisor-Fenster eine Analyse starten.)"
};
}
}
catch (Exception ex)
{
supervisorSnippet = new
{
available = false,
answer = $"Supervisor-Berichte nicht verfügbar: {ex.Message}"
};
}
}
else
{
supervisorSnippet = new
{
available = false,
answer = "Supervisor-Modul nicht geladen oder nicht lizenziert."
};
}
return Results.Ok(new
{
moduleKpis = kpiTiles,
notableTrades = notable,
alerts = alerts.Take(100),
supervisorSnippet
});
});
return routes;
}
}
@@ -57,7 +57,31 @@ namespace PolyTrader.Web.Endpoints
}
});
group.MapPost("/{jobName}/toggle", (string jobName, JobManager jobManager, TerminalLogger logger) =>
{
var job = jobManager.Jobs.FirstOrDefault(j => j.JobName.Equals(jobName, StringComparison.OrdinalIgnoreCase));
if (job == null)
return Results.NotFound(new { error = $"Job '{jobName}' nicht gefunden." });
job.IsEnabled = !job.IsEnabled;
logger.Info($"🌐 WebUI: Job '{job.JobName}' IsEnabled auf '{job.IsEnabled}' umgeschaltet.");
return Results.Ok(new { success = true, jobName = job.JobName, isEnabled = job.IsEnabled });
});
group.MapPost("/{jobName}/state", (string jobName, SetJobStateRequest req, JobManager jobManager, TerminalLogger logger) =>
{
var job = jobManager.Jobs.FirstOrDefault(j => j.JobName.Equals(jobName, StringComparison.OrdinalIgnoreCase));
if (job == null)
return Results.NotFound(new { error = $"Job '{jobName}' nicht gefunden." });
job.IsEnabled = req.IsEnabled;
logger.Info($"🌐 WebUI: Job '{job.JobName}' IsEnabled auf '{job.IsEnabled}' gesetzt.");
return Results.Ok(new { success = true, jobName = job.JobName, isEnabled = job.IsEnabled });
});
return routes;
}
public record SetJobStateRequest(bool IsEnabled);
}
}
@@ -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;
}
}
}
@@ -0,0 +1,258 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
using PolyTrader.Modules.Supervisor.Agent;
using PolyTrader.Modules.Supervisor.Counterfactual;
using PolyTrader.Modules.Supervisor.Persistence;
using PolyTrader.Modules.Supervisor.Services;
namespace PolyTrader.Web.Endpoints
{
public static class SupervisorEndpoints
{
public static IEndpointRouteBuilder MapSupervisorEndpoints(this IEndpointRouteBuilder routes)
{
var group = routes.MapGroup("/api/modules/supervisor");
// 1. Profile abrufen
group.MapGet("/profiles", () =>
{
var profiles = SupervisorProfiles.All.Select(p => new
{
name = p.Name,
promptAddendum = p.PromptAddendum,
hasToolFilter = p.ToolFilter != null,
toolFilter = p.ToolFilter
}).ToList();
return Results.Ok(profiles);
});
// 2. Frage an den Agenten stellen
group.MapPost("/ask", async (IServiceProvider sp, SupervisorAskRequest request, CancellationToken ct) =>
{
if (string.IsNullOrWhiteSpace(request.Question))
return Results.BadRequest(new { error = "Frage darf nicht leer sein." });
var agent = sp.GetService<SupervisorAgent>();
if (agent == null)
return Results.StatusCode(503);
var profile = SupervisorProfiles.ByName(request.Profile);
var reportRepo = sp.GetService<ISupervisorReportRepository>();
try
{
var result = await agent.AskAsync(request.Question, request.Model, null, profile, ct);
var toolCalls = result.ToolInvocations.Select(t => new
{
tool = t.Tool,
arguments = t.Arguments,
result = t.Result
}).ToList();
// Als Bericht persistieren
if (reportRepo != null)
{
try
{
var report = new SupervisorReport
{
CreatedAt = DateTime.UtcNow,
Profile = profile.Name,
Model = string.IsNullOrWhiteSpace(request.Model) ? SupervisorAgent.DefaultModel : request.Model,
Question = request.Question,
Answer = result.Answer,
ToolCallsJson = JsonSerializer.Serialize(toolCalls.Select(tc => new { tool = tc.tool, args = tc.arguments })),
ToolCallCount = result.ToolInvocations.Count,
PromptTokens = result.PromptTokens,
CompletionTokens = result.CompletionTokens
};
reportRepo.Insert(report);
}
catch { /* Ignoriert bei DB-Offline */ }
}
return Results.Ok(new
{
answer = result.Answer,
profile = profile.Name,
model = string.IsNullOrWhiteSpace(request.Model) ? SupervisorAgent.DefaultModel : request.Model,
toolInvocations = toolCalls,
toolCallCount = result.ToolInvocations.Count,
promptTokens = result.PromptTokens,
completionTokens = result.CompletionTokens,
totalTokens = result.PromptTokens + result.CompletionTokens
});
}
catch (Exception ex)
{
return Results.Ok(new
{
error = ex.Message,
answer = $"Fehler bei der Supervisor-Anfrage: {ex.Message}",
toolInvocations = new List<object>(),
toolCallCount = 0,
promptTokens = 0,
completionTokens = 0,
totalTokens = 0
});
}
});
// 3. Gespeicherte Berichte
group.MapGet("/reports", (IServiceProvider sp, int? limit) =>
{
var repo = sp.GetService<ISupervisorReportRepository>();
List<SupervisorReport> reports;
try
{
reports = repo?.GetRecent(limit.GetValueOrDefault(50)) ?? new List<SupervisorReport>();
}
catch
{
reports = new List<SupervisorReport>();
}
var list = reports.Select(r => new
{
id = r.Id,
createdAt = r.CreatedAt.ToString("yyyy-MM-dd HH:mm:ss"),
profile = r.Profile,
model = r.Model,
question = r.Question,
answer = r.Answer,
toolCallCount = r.ToolCallCount,
totalTokens = r.PromptTokens + r.CompletionTokens
}).ToList();
return Results.Ok(list);
});
// 4. Einzelner Bericht Details
group.MapGet("/reports/{id:long}", (IServiceProvider sp, long id) =>
{
var repo = sp.GetService<ISupervisorReportRepository>();
var reports = repo?.GetRecent(200) ?? new List<SupervisorReport>();
var report = reports.FirstOrDefault(r => r.Id == id);
if (report == null)
return Results.NotFound(new { error = $"Bericht #{id} nicht gefunden." });
return Results.Ok(new
{
id = report.Id,
createdAt = report.CreatedAt.ToString("yyyy-MM-dd HH:mm:ss"),
profile = report.Profile,
model = report.Model,
question = report.Question,
answer = report.Answer,
toolCallsJson = report.ToolCallsJson,
toolCallCount = report.ToolCallCount,
promptTokens = report.PromptTokens,
completionTokens = report.CompletionTokens,
totalTokens = report.PromptTokens + report.CompletionTokens
});
});
// 5. Signal-Dossiers
group.MapGet("/dossiers", (IServiceProvider sp, int? limit) =>
{
var dossiers = sp.GetService<DossierService>();
List<SignalSummary> signals;
try
{
signals = dossiers?.RecentSignals(limit.GetValueOrDefault(100)) ?? new List<SignalSummary>();
}
catch
{
signals = new List<SignalSummary>();
}
var list = signals.Select(s => new
{
signalId = s.SignalId,
firstSeen = s.FirstSeen.ToString("yyyy-MM-dd HH:mm:ss"),
module = s.ModuleName,
accountId = s.AccountId,
side = s.Side,
market = s.MarketQuestion,
lastDecision = s.LastDecision,
lastReason = s.LastReason,
decisionCount = s.DecisionCount
}).ToList();
return Results.Ok(list);
});
// 6. Signal-Dossier Detail
group.MapGet("/dossiers/{signalId}", (IServiceProvider sp, string signalId) =>
{
var dossiers = sp.GetService<DossierService>();
if (dossiers == null)
return Results.StatusCode(503);
try
{
var dossier = dossiers.BuildForSignal(signalId);
return Results.Ok(new
{
signalId = dossier.SignalId,
module = dossier.ModuleName,
accountId = dossier.AccountId,
market = dossier.MarketQuestion,
side = dossier.Side,
renderedMarkdown = PolyTrader.Core.Analytics.DossierBuilder.ToMarkdown(dossier)
});
}
catch (Exception ex)
{
return Results.BadRequest(new { error = ex.Message });
}
});
// 7. Counterfactuals
group.MapGet("/counterfactuals", (IServiceProvider sp, int? limit) =>
{
var repo = sp.GetService<ISupervisorCounterfactualRepository>();
List<CounterfactualRecord> list;
try
{
list = repo?.GetRecent(limit.GetValueOrDefault(50)) ?? new List<CounterfactualRecord>();
}
catch
{
list = new List<CounterfactualRecord>();
}
var result = list.Select(c => new
{
id = c.Id,
checkedAt = c.CheckedAt.ToString("yyyy-MM-dd HH:mm:ss"),
decisionId = c.DecisionId,
signalId = c.SignalId,
moduleName = c.ModuleName,
accountId = c.AccountId,
market = c.MarketQuestion,
reason = c.Reason,
signalPrice = c.SignalPrice,
isWinner = c.IsWinner,
hypotheticalPnlPerShare = c.HypotheticalPnlPerShare
}).ToList();
return Results.Ok(result);
});
return routes;
}
public record SupervisorAskRequest(string Question, string? Profile, string? Model);
}
}
@@ -62,6 +62,11 @@ namespace PolyTrader.Web.Hosting
endpoints.MapLogsEndpoints();
endpoints.MapSettingsEndpoints();
endpoints.MapModulesEndpoints();
endpoints.MapAnalyticsEndpoints();
endpoints.MapCopyTradingEndpoints();
endpoints.MapResolutionFarmingEndpoints();
endpoints.MapSupervisorEndpoints();
endpoints.MapAccountingEndpoints();
endpoints.MapGet("/login", async httpContext =>
{
+642 -5
View File
@@ -36,27 +36,38 @@ body {
.app-header {
background: var(--surface);
border-bottom: 1px solid var(--border);
padding: 0 20px;
height: 56px;
padding: 0 16px;
min-height: 56px;
display: flex;
align-items: center;
justify-content: space-between;
position: sticky;
top: 0;
z-index: 100;
gap: 12px;
}
.header-brand {
display: flex;
align-items: center;
gap: 10px;
gap: 8px;
font-weight: 700;
font-size: 16px;
font-size: 15px;
white-space: nowrap;
flex-shrink: 0;
}
.header-nav {
display: flex;
gap: 4px;
gap: 2px;
overflow-x: auto;
white-space: nowrap;
scrollbar-width: none;
-ms-overflow-style: none;
}
.header-nav::-webkit-scrollbar {
display: none;
}
.nav-link {
@@ -365,3 +376,629 @@ tr:hover td {
gap: 8px;
cursor: pointer;
}
/* Dashboard Sections & Widgets */
.dashboard-section-title {
font-size: 14px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--muted);
margin: 24px 0 12px 0;
display: flex;
align-items: center;
gap: 8px;
}
.kpi-tiles-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 12px;
margin-bottom: 20px;
}
.module-kpi-tile {
background: var(--card);
border: 1px solid var(--border);
border-radius: 8px;
padding: 12px 14px;
display: flex;
flex-direction: column;
gap: 6px;
transition: transform 0.15s, border-color 0.15s;
}
.module-kpi-tile:hover {
border-color: var(--accent);
}
.module-kpi-tile .mod-name {
font-size: 13px;
font-weight: 600;
color: var(--text);
}
.module-kpi-tile .mod-body {
font-family: var(--font-mono);
font-size: 11px;
line-height: 1.5;
white-space: pre-line;
}
.module-kpi-tile.positive .mod-body {
color: var(--positive);
}
.module-kpi-tile.negative .mod-body {
color: var(--negative);
}
.grid-3 {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
margin-bottom: 24px;
}
@media (max-width: 1024px) {
.grid-3 {
grid-template-columns: 1fr;
}
}
.widget-card {
display: flex;
flex-direction: column;
height: 320px;
padding: 14px;
}
.widget-card .card-title {
margin-bottom: 10px;
padding-bottom: 6px;
border-bottom: 1px solid var(--border);
display: flex;
justify-content: space-between;
align-items: center;
}
.widget-table-container {
flex: 1;
overflow-y: auto;
}
.compact-table {
font-size: 11.5px;
}
.compact-table th, .compact-table td {
padding: 6px 8px;
}
.widget-logs-container {
flex: 1;
overflow-y: auto;
font-family: var(--font-mono);
font-size: 11px;
display: flex;
flex-direction: column;
gap: 4px;
}
.widget-alert-entry {
display: flex;
gap: 6px;
padding: 4px 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
}
.widget-alert-entry .time {
color: var(--muted);
flex-shrink: 0;
}
.widget-alert-entry .level {
font-weight: 700;
flex-shrink: 0;
font-size: 10px;
padding: 1px 4px;
border-radius: 3px;
text-transform: uppercase;
}
.widget-alert-entry .level.Warning {
background: rgba(249, 226, 175, 0.15);
color: var(--warning);
}
.widget-alert-entry .level.Error {
background: rgba(243, 139, 168, 0.15);
color: var(--negative);
}
.widget-alert-entry .msg {
color: var(--text);
word-break: break-word;
flex: 1;
}
.supervisor-snippet {
flex: 1;
overflow-y: auto;
font-family: var(--font-mono);
font-size: 12px;
line-height: 1.6;
color: var(--text);
background: var(--surface-alt);
padding: 10px 12px;
border-radius: 6px;
border: 1px solid var(--border);
white-space: pre-wrap;
}
/* Switches & Buttons */
.btn-sm {
padding: 3px 8px;
font-size: 11px;
}
.btn-profile {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
font-size: 11px;
border-radius: 4px;
background: var(--surface-alt);
color: var(--accent);
border: 1px solid var(--border);
text-decoration: none;
cursor: pointer;
transition: all 0.2s;
}
.btn-profile:hover {
background: var(--accent);
color: #11111b;
}
.badge-active {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border-radius: 12px;
font-size: 11px;
font-weight: 600;
cursor: pointer;
border: 1px solid transparent;
background: transparent;
transition: all 0.2s;
}
.badge-active.is-on {
background: rgba(166, 227, 161, 0.15);
color: var(--positive);
border-color: rgba(166, 227, 161, 0.3);
}
.badge-active.is-off {
background: rgba(166, 173, 200, 0.1);
color: var(--muted);
border-color: rgba(166, 173, 200, 0.2);
}
.badge-active:hover {
filter: brightness(1.2);
}
/* Analytics View Styles */
.analytics-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
padding: 10px 14px;
margin-bottom: 20px;
gap: 16px;
flex-wrap: wrap;
}
.toolbar-group {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.toolbar-item {
display: flex;
align-items: center;
gap: 6px;
}
.toolbar-item label {
font-size: 12px;
color: var(--muted);
font-weight: 500;
}
.form-select {
padding: 5px 10px;
font-size: 13px;
width: auto;
cursor: pointer;
}
.analytics-subnav {
display: flex;
gap: 4px;
background: var(--surface-alt);
padding: 3px;
border-radius: 6px;
border: 1px solid var(--border);
}
.subnav-tab {
background: transparent;
border: none;
color: var(--muted);
padding: 5px 12px;
font-size: 12px;
font-weight: 600;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s;
}
.subnav-tab:hover {
color: var(--text);
}
.subnav-tab.active {
background: var(--card);
color: var(--accent);
box-shadow: 0 1px 3px rgba(0,0,0,0.2);
}
.grid-5 {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 12px;
}
@media (max-width: 1200px) {
.grid-5 {
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
}
}
.chart-card {
padding: 16px;
display: flex;
flex-direction: column;
}
.chart-container {
height: 260px;
width: 100%;
position: relative;
overflow: hidden;
}
.chart-svg {
width: 100%;
height: 100%;
overflow: visible;
}
.history-toolbar {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
padding: 10px 14px;
}
.pagination-bar {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 14px;
padding: 8px 4px;
}
/* Module Tabs */
.module-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
flex-wrap: wrap;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
padding: 10px 16px;
margin-bottom: 16px;
}
.module-tabs {
display: flex;
gap: 4px;
background: var(--surface-alt);
padding: 3px;
border-radius: 6px;
border: 1px solid var(--border);
}
.module-tab-btn {
background: transparent;
border: none;
color: var(--muted);
padding: 6px 14px;
font-size: 13px;
font-weight: 600;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s;
}
.module-tab-btn:hover {
color: var(--text);
}
.module-tab-btn.active {
background: var(--card);
color: var(--accent);
box-shadow: 0 1px 3px rgba(0,0,0,0.2);
}
/* Modal Dialog */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.75);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
backdrop-filter: blur(2px);
}
.modal-dialog {
background: var(--card);
border: 1px solid var(--border);
border-radius: 10px;
max-width: 680px;
width: 95%;
max-height: 90vh;
overflow-y: auto;
padding: 24px;
box-shadow: 0 10px 30px rgba(0,0,0,0.5);
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 18px;
padding-bottom: 12px;
border-bottom: 1px solid var(--border);
}
.modal-close {
background: transparent;
border: none;
font-size: 24px;
line-height: 1;
color: var(--muted);
cursor: pointer;
padding: 0 4px;
}
.modal-close:hover {
color: var(--text);
}
.table-action-btn {
padding: 3px 8px;
font-size: 11px;
border-radius: 4px;
cursor: pointer;
border: 1px solid var(--border);
background: var(--surface-alt);
color: var(--text);
transition: all 0.15s;
}
.table-action-btn:hover {
background: var(--surface);
border-color: var(--accent);
color: var(--accent);
}
.table-action-btn.delete:hover {
border-color: var(--negative);
color: var(--negative);
}
/* Supervisor & Accounting Styles */
.supervisor-chat-box {
display: flex;
flex-direction: column;
height: 700px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
overflow: hidden;
}
.chat-presets-bar {
padding: 10px 14px;
background: var(--surface-alt);
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.chat-preset-tag {
font-size: 11px;
color: var(--muted);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.chat-preset-btn {
background: var(--card);
border: 1px solid var(--border);
color: var(--text);
padding: 4px 10px;
border-radius: 14px;
font-size: 12px;
cursor: pointer;
transition: all 0.15s ease;
}
.chat-preset-btn:hover {
background: var(--accent-soft);
border-color: var(--accent);
color: var(--accent);
}
.chat-messages-area {
flex: 1;
overflow-y: auto;
padding: 16px;
display: flex;
flex-direction: column;
gap: 14px;
}
.chat-bubble {
max-width: 82%;
padding: 12px 16px;
border-radius: 8px;
font-size: 13.5px;
line-height: 1.6;
word-break: break-word;
}
.chat-bubble.user {
align-self: flex-end;
background: var(--accent-soft);
border: 1px solid rgba(137, 180, 250, 0.3);
color: var(--text);
}
.chat-bubble.assistant {
align-self: flex-start;
background: var(--card);
border: 1px solid var(--border);
color: var(--text);
}
.chat-bubble-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
margin-bottom: 8px;
padding-bottom: 4px;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
font-size: 11px;
color: var(--muted);
}
.chat-bubble-body {
white-space: pre-wrap;
font-family: inherit;
}
.chat-bubble-body code {
font-family: var(--font-mono);
background: var(--surface);
padding: 2px 5px;
border-radius: 3px;
font-size: 12px;
}
.chat-tool-details {
margin-top: 10px;
padding: 6px 10px;
background: var(--surface-alt);
border-radius: 6px;
border: 1px solid var(--border);
font-size: 12px;
}
.chat-tool-details summary {
cursor: pointer;
color: var(--accent);
font-weight: 500;
}
.chat-tool-list {
margin-top: 6px;
padding-left: 18px;
color: var(--muted);
font-family: var(--font-mono);
font-size: 11px;
}
.chat-input-row {
padding: 12px 14px;
background: var(--surface-alt);
border-top: 1px solid var(--border);
display: flex;
gap: 10px;
align-items: center;
}
.currency-pills {
display: inline-flex;
background: var(--surface-alt);
border-radius: 6px;
padding: 2px;
border: 1px solid var(--border);
}
.currency-pill {
padding: 4px 12px;
font-size: 12px;
font-weight: 600;
cursor: pointer;
border: none;
background: transparent;
color: var(--muted);
border-radius: 4px;
transition: all 0.15s;
}
.currency-pill.active {
background: var(--accent);
color: #11111b;
}
.markdown-pre {
background: var(--surface-alt);
border: 1px solid var(--border);
border-radius: 6px;
padding: 14px;
font-family: var(--font-mono);
font-size: 12.5px;
white-space: pre-wrap;
line-height: 1.55;
max-height: 65vh;
overflow-y: auto;
color: var(--text);
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff