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:
@@ -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 =>
|
||||
{
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user