Fix embedded-server endpoint drift, add Recalculate All Traders button

- Shared ApiConfiguration.MapPredictalyticsEndpoints() used by both the
  standalone API and the embedded WinForms Kestrel server: the embedded
  host was missing /api/watchlist and /api/dev (empty watchlist page),
  the standalone host was missing /api/search.
- New Development menu action "Recalculate All Traders": backfills
  ResolutionOutcome from snapped outcome prices, wipes derived analytics
  (snapshots, category stats, rebuildable positions), zeroes aggregates
  and marks every trader for re-analysis. Raw trades untouched.
- Static files now served with Cache-Control: no-cache in both hosts,
  plus ?v= cache-buster on app.js/style.css (stale browser JS masked
  earlier UI fixes).
- Watchlist hardened: endpoint null-safe, real Analytics.CopytradingScore,
  Platform field; repository includes Trader.Analytics; page shows a
  visible error row instead of staying silently blank.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-07-10 17:58:10 +02:00
co-authored by Claude Fable 5
parent 1787422243
commit 850c47a404
8 changed files with 155 additions and 30 deletions
+20 -6
View File
@@ -23,20 +23,34 @@ public static class ApiConfiguration
app.UseSwagger(); app.UseSwagger();
app.UseSwaggerUI(); app.UseSwaggerUI();
app.UseDefaultFiles(); app.UseDefaultFiles();
app.UseStaticFiles(); app.UseStaticFiles(new StaticFileOptions
{
// Internal dashboard: always revalidate so UI fixes reach the browser immediately.
OnPrepareResponse = ctx => ctx.Context.Response.Headers.CacheControl = "no-cache"
});
// Map endpoints app.MapPredictalyticsEndpoints();
return app;
}
/// <summary>
/// Single source of truth for ALL API endpoint registrations.
/// Both the standalone API and the embedded WinForms Kestrel server MUST use
/// this method — never register endpoints individually in either host, or the
/// two servers drift apart (missing /api/watchlist in the embedded host was
/// exactly this class of bug).
/// </summary>
public static void MapPredictalyticsEndpoints(this WebApplication app)
{
app.MapDashboardEndpoints(); app.MapDashboardEndpoints();
app.MapTraderEndpoints(); app.MapTraderEndpoints();
app.MapAlertEndpoints(); app.MapAlertEndpoints();
app.MapMarketEndpoints(); app.MapMarketEndpoints();
app.MapSearchEndpoints();
app.MapJobEndpoints(); app.MapJobEndpoints();
app.MapDevEndpoints(); app.MapDevEndpoints();
app.MapWatchlistEndpoints(); app.MapWatchlistEndpoints();
// Health check
app.MapGet("/api/health", () => Results.Ok(new { Status = "OK", Timestamp = DateTime.UtcNow })); app.MapGet("/api/health", () => Results.Ok(new { Status = "OK", Timestamp = DateTime.UtcNow }));
return app;
} }
} }
@@ -15,11 +15,12 @@ public static class WatchlistEndpoints
return Results.Ok(list.Select(w => new return Results.Ok(list.Select(w => new
{ {
w.TraderId, w.TraderId,
w.Trader.DisplayName, DisplayName = w.Trader?.DisplayName ?? "?",
w.Trader.PlatformUserId, PlatformUserId = w.Trader?.PlatformUserId ?? "",
w.Trader.TotalPnl, Platform = w.Trader?.Platform.ToString() ?? "Unknown",
w.Trader.WinRate, TotalPnl = w.Trader?.TotalPnl ?? 0,
CopytradingScore = w.Trader.CurrentScore?.CombinedScore ?? 0, WinRate = w.Trader?.WinRate ?? 0,
CopytradingScore = w.Trader?.Analytics?.CopytradingScore ?? 0,
w.Label, w.Label,
w.Notes, w.Notes,
CreatedAt = w.AddedAt CreatedAt = w.AddedAt
+2 -2
View File
@@ -7,7 +7,7 @@
<title>Predictalytics</title> <title>Predictalytics</title>
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
<link rel="stylesheet" href="css/style.css"> <link rel="stylesheet" href="css/style.css?v=20260710">
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.4/dist/chart.umd.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.4/dist/chart.umd.min.js"></script>
</head> </head>
<body> <body>
@@ -472,6 +472,6 @@
</section> </section>
</div> </div>
</main> </main>
<script src="js/app.js"></script> <script src="js/app.js?v=20260710"></script>
</body> </body>
</html> </html>
+11 -7
View File
@@ -346,22 +346,26 @@ async function loadTraders() {
} }
async function loadWatchlist() { async function loadWatchlist() {
const data = await api(`/api/watchlist`);
if (!data) return;
const tbody = document.getElementById('watchlistBody'); const tbody = document.getElementById('watchlistBody');
const data = await api(`/api/watchlist`);
if (!Array.isArray(data)) {
// api() returns null on HTTP errors — show it instead of a silently empty page.
tbody.innerHTML = '<tr><td colspan="8">⚠ Failed to load watchlist (see browser console / server log).</td></tr>';
return;
}
tbody.innerHTML = data.map(w => ` tbody.innerHTML = data.map(w => `
<tr> <tr>
<td class="trader-name"> <td class="trader-name">
<div class="avatar">${w.displayName.substring(0, 2).toUpperCase()}</div> <div class="avatar">${(w.displayName || '?').substring(0, 2).toUpperCase()}</div>
<div> <div>
<strong>${w.displayName}</strong><br> <strong>${w.displayName || '?'}</strong><br>
<span style="font-size:12px; color:var(--text-secondary)">${w.platformUserId.substring(0, 8)}...</span> <span style="font-size:12px; color:var(--text-secondary)">${(w.platformUserId || '').substring(0, 8)}...</span>
</div> </div>
</td> </td>
<td>${w.platform ?? 'Unknown'}</td> <td>${w.platform ?? 'Unknown'}</td>
<td>${Number(w.copytradingScore || 0).toFixed(1)}</td> <td>${Number(w.copytradingScore || 0).toFixed(1)}</td>
<td class="${w.winRate > 0.5 ? 'text-green' : 'text-red'}">${fmt.pct(w.winRate)}</td> <td>${fmt.pct(w.winRate)}</td>
<td class="${w.totalPnl >= 0 ? 'text-green' : 'text-red'}">${fmt.pnl(w.totalPnl)}</td> <td class="${w.totalPnl >= 0 ? 'text-green' : 'text-red'}">${fmt.pnl(w.totalPnl)}</td>
<td>${w.label || ''}</td> <td>${w.label || ''}</td>
<td>${new Date(w.createdAt).toLocaleDateString()}</td> <td>${new Date(w.createdAt).toLocaleDateString()}</td>
@@ -10,7 +10,10 @@ public class WatchlistRepository : IWatchlistRepository
public WatchlistRepository(AppDbContext db) => _db = db; public WatchlistRepository(AppDbContext db) => _db = db;
public async Task<IReadOnlyList<WatchlistEntry>> GetAllAsync(CancellationToken ct = default) public async Task<IReadOnlyList<WatchlistEntry>> GetAllAsync(CancellationToken ct = default)
=> await _db.WatchlistEntries.Include(w => w.Trader).ToListAsync(ct); => await _db.WatchlistEntries
.Include(w => w.Trader)
.ThenInclude(t => t.Analytics)
.ToListAsync(ct);
public async Task<WatchlistEntry?> GetByTraderIdAsync(int traderId, CancellationToken ct = default) public async Task<WatchlistEntry?> GetByTraderIdAsync(int traderId, CancellationToken ct = default)
=> await _db.WatchlistEntries.FirstOrDefaultAsync(w => w.TraderId == traderId, ct); => await _db.WatchlistEntries.FirstOrDefaultAsync(w => w.TraderId == traderId, ct);
+11 -1
View File
@@ -37,6 +37,7 @@ partial class MainForm
btn_dbReset = new ToolStripMenuItem(); btn_dbReset = new ToolStripMenuItem();
btn_syncmarkets = new ToolStripMenuItem(); btn_syncmarkets = new ToolStripMenuItem();
btn_dbUpdate = new ToolStripMenuItem(); btn_dbUpdate = new ToolStripMenuItem();
btn_recalcAll = new ToolStripMenuItem();
toolStrip1.SuspendLayout(); toolStrip1.SuspendLayout();
statusStrip1.SuspendLayout(); statusStrip1.SuspendLayout();
tabControl1.SuspendLayout(); tabControl1.SuspendLayout();
@@ -192,6 +193,7 @@ partial class MainForm
btn_dbReset = new ToolStripMenuItem(); btn_dbReset = new ToolStripMenuItem();
btn_syncmarkets = new ToolStripMenuItem(); btn_syncmarkets = new ToolStripMenuItem();
btn_dbUpdate = new ToolStripMenuItem(); btn_dbUpdate = new ToolStripMenuItem();
btn_recalcAll = new ToolStripMenuItem();
toolStrip1.SuspendLayout(); toolStrip1.SuspendLayout();
statusStrip1.SuspendLayout(); statusStrip1.SuspendLayout();
tabControl1.SuspendLayout(); tabControl1.SuspendLayout();
@@ -341,7 +343,7 @@ partial class MainForm
// //
// developmentToolStripMenuItem // developmentToolStripMenuItem
// //
developmentToolStripMenuItem.DropDownItems.AddRange(new ToolStripItem[] { btn_dbReset, btn_syncmarkets, btn_dbUpdate }); developmentToolStripMenuItem.DropDownItems.AddRange(new ToolStripItem[] { btn_dbReset, btn_syncmarkets, btn_dbUpdate, btn_recalcAll });
developmentToolStripMenuItem.Name = "developmentToolStripMenuItem"; developmentToolStripMenuItem.Name = "developmentToolStripMenuItem";
developmentToolStripMenuItem.Size = new Size(135, 29); developmentToolStripMenuItem.Size = new Size(135, 29);
developmentToolStripMenuItem.Text = "Development"; developmentToolStripMenuItem.Text = "Development";
@@ -365,6 +367,13 @@ partial class MainForm
btn_dbUpdate.Size = new Size(270, 34); btn_dbUpdate.Size = new Size(270, 34);
btn_dbUpdate.Text = "UpdateDB"; btn_dbUpdate.Text = "UpdateDB";
btn_dbUpdate.Click += btn_dbUpdate_Click; btn_dbUpdate.Click += btn_dbUpdate_Click;
//
// btn_recalcAll
//
btn_recalcAll.Name = "btn_recalcAll";
btn_recalcAll.Size = new Size(270, 34);
btn_recalcAll.Text = "Recalculate All Traders";
btn_recalcAll.Click += btn_recalcAll_Click;
// //
// MainForm // MainForm
// //
@@ -414,4 +423,5 @@ partial class MainForm
private ToolStripStatusLabel label_dbSize; private ToolStripStatusLabel label_dbSize;
private ToolStripStatusLabel label_buildVersion; private ToolStripStatusLabel label_buildVersion;
private ToolStripMenuItem btn_dbUpdate; private ToolStripMenuItem btn_dbUpdate;
private ToolStripMenuItem btn_recalcAll;
} }
@@ -233,6 +233,46 @@ public partial class MainForm : Form
} }
} }
private async void btn_recalcAll_Click(object? sender, EventArgs e)
{
if (_workerRunning)
{
MessageBox.Show("Recalculation cannot be started while background workers are running. Stop the server first.",
"Workers Busy", MessageBoxButtons.OK, MessageBoxIcon.Warning);
return;
}
var confirm = MessageBox.Show(
"This deletes all DERIVED analytics data (positions, daily snapshots, category stats) " +
"and marks every trader for full recalculation.\n\n" +
"Raw trades and markets are NOT touched.\n\n" +
"After this, start the server: the analytics worker rebuilds every trader with the current " +
"engine (runs in the background, can take several hours for large trader counts).\n\nContinue?",
"Recalculate All Traders", MessageBoxButtons.YesNo, MessageBoxIcon.Warning);
if (confirm != DialogResult.Yes) return;
try
{
btn_recalcAll.Enabled = false;
Log.Information("Manual full recalculation reset triggered...");
using var cts = new CancellationTokenSource();
var summary = await _webServer!.RunRecalculateAllTradersAsync(cts.Token);
MessageBox.Show($"Reset complete:\n\n{summary}\n\nNow start the server to rebuild the analytics.",
"Recalculate All Traders", MessageBoxButtons.OK, MessageBoxIcon.Information);
}
catch (Exception ex)
{
Log.Error(ex, "Full recalculation reset failed");
MessageBox.Show($"Recalculation reset failed: {ex.Message}", "Error", MessageBoxButtons.OK, MessageBoxIcon.Error);
}
finally
{
btn_recalcAll.Enabled = true;
}
}
private async Task UpdateDbSizeAsync() private async Task UpdateDbSizeAsync()
{ {
try try
@@ -1,4 +1,6 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Hosting;
using Predictalytics.Api;
using Predictalytics.Api.Endpoints; using Predictalytics.Api.Endpoints;
using Predictalytics.Worker; using Predictalytics.Worker;
using Predictalytics.Application.Interfaces; using Predictalytics.Application.Interfaces;
@@ -29,6 +31,59 @@ public class EmbeddedWebServer
await Predictalytics.Infrastructure.DependencyInjection.EnsureDatabaseAsync(provider, DbConnectionDebug); await Predictalytics.Infrastructure.DependencyInjection.EnsureDatabaseAsync(provider, DbConnectionDebug);
} }
/// <summary>
/// One-time data repair: derives ResolutionOutcome from snapped outcome prices,
/// wipes all DERIVED analytics data (rebuildable positions, daily snapshots,
/// category stats, analytics aggregates) and marks every trader for re-analysis.
/// Raw trades are NOT touched. The analytics worker rebuilds everything with the
/// current engine on the next server run. Run only while workers are stopped.
/// </summary>
public async Task<string> RunRecalculateAllTradersAsync(CancellationToken ct)
{
Log.Warning("🔄 Full trader recalculation reset requested...");
var services = new ServiceCollection();
Predictalytics.Infrastructure.DependencyInjection.AddPredictalytics(services, new Microsoft.Extensions.Configuration.ConfigurationBuilder().Build(), ConnectionString, DbConnectionDebug);
using var provider = services.BuildServiceProvider();
using var scope = provider.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<Predictalytics.Infrastructure.Data.AppDbContext>();
db.Database.SetCommandTimeout(600);
// 1. Winner-detection backfill: markets synced before the A8 mapper fix have
// ResolutionOutcome = NULL; derive it from the snapped outcome prices.
var backfilled = await db.Database.ExecuteSqlRawAsync(@"
UPDATE Markets m
JOIN MarketOutcomes o ON o.MarketId = m.Id AND o.CurrentPrice >= 0.98
SET m.ResolutionOutcome = o.Label
WHERE m.IsResolved = 1 AND (m.ResolutionOutcome IS NULL OR m.ResolutionOutcome = '');", ct);
Log.Information("Backfilled ResolutionOutcome for {Count} markets", backfilled);
// 2. Wipe derived data computed by earlier engine versions.
var snapshots = await db.Database.ExecuteSqlRawAsync("DELETE FROM TraderDailySnapshots;", ct);
var catPerf = await db.Database.ExecuteSqlRawAsync("DELETE FROM TraderCategoryPerformances;", ct);
// Positions with pruned history are kept: their RealizedPnl is the only
// remaining record of trades already deleted by retention (Deep Resync replaces them).
var positions = await db.Database.ExecuteSqlRawAsync("DELETE FROM TraderPositions WHERE IsHistoryPruned = 0;", ct);
var analytics = await db.Database.ExecuteSqlRawAsync(@"
UPDATE TraderAnalytics SET
OverallPnL = 0, PnL30d = 0, PnL7d = 0, PnL24h = 0,
OverallWinRate = 0, WinRate30d = 0, WinRate7d = 0, WinRate24h = 0,
CurrentBalance = 0, EstimatedBankroll = 0, Trades30d = 0,
CopytradingScore = 0, CopytradingQualityScore = 0, CopytradingCopyabilityScore = 0;", ct);
// 3. Trigger re-analysis of every trader on the next worker run.
var traders = await db.Database.ExecuteSqlRawAsync("UPDATE Traders SET LastAnalyzedAt = NULL;", ct);
var summary = $"ResolutionOutcome backfilled: {backfilled} markets\n" +
$"Daily snapshots deleted: {snapshots}\n" +
$"Category stats deleted: {catPerf}\n" +
$"Positions reset: {positions}\n" +
$"Analytics zeroed: {analytics}\n" +
$"Traders queued for re-analysis: {traders}";
Log.Warning("🔄 Recalculation reset complete. {Summary}", summary.Replace('\n', ' '));
return summary;
}
public async Task StartWebServerAsync(int port = 5000) public async Task StartWebServerAsync(int port = 5000)
{ {
lock (_lock) { if (_app != null) return; } lock (_lock) { if (_app != null) return; }
@@ -66,19 +121,17 @@ public class EmbeddedWebServer
{ {
app.UseStaticFiles(new StaticFileOptions app.UseStaticFiles(new StaticFileOptions
{ {
FileProvider = new Microsoft.Extensions.FileProviders.PhysicalFileProvider(wwwrootPath) FileProvider = new Microsoft.Extensions.FileProviders.PhysicalFileProvider(wwwrootPath),
// Internal dashboard: always revalidate so UI fixes reach the browser immediately.
OnPrepareResponse = ctx => ctx.Context.Response.Headers.CacheControl = "no-cache"
}); });
app.MapGet("/", () => Results.File( app.MapGet("/", () => Results.File(
Path.Combine(wwwrootPath, "index.html"), "text/html")); Path.Combine(wwwrootPath, "index.html"), "text/html"));
} }
app.MapDashboardEndpoints(); // Single shared registration — see ApiConfiguration.MapPredictalyticsEndpoints.
app.MapTraderEndpoints(); // Do NOT map endpoints individually here.
app.MapMarketEndpoints(); app.MapPredictalyticsEndpoints();
app.MapAlertEndpoints();
app.MapSearchEndpoints();
app.MapJobEndpoints();
app.MapGet("/api/health", () => Results.Ok(new { Status = "OK", Timestamp = DateTime.UtcNow }));
await Predictalytics.Infrastructure.DependencyInjection.EnsureDatabaseAsync(app.Services, DbConnectionDebug); await Predictalytics.Infrastructure.DependencyInjection.EnsureDatabaseAsync(app.Services, DbConnectionDebug);