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:
co-authored by
Claude Fable 5
parent
1787422243
commit
850c47a404
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user