From 850c47a404f184f5a57bc7c36d8f6f4a40a25fc6 Mon Sep 17 00:00:00 2001 From: Richard Date: Fri, 10 Jul 2026 17:58:10 +0200 Subject: [PATCH] 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 --- src/Predictalytics.Api/ApiConfiguration.cs | 26 +++++-- .../Endpoints/WatchlistEndpoints.cs | 11 +-- src/Predictalytics.Api/wwwroot/index.html | 4 +- src/Predictalytics.Api/wwwroot/js/app.js | 18 +++-- .../Data/Repositories/WatchlistRepository.cs | 5 +- .../MainForm.Designer.cs | 12 +++- src/Predictalytics.WinFormsHost/MainForm.cs | 40 +++++++++++ .../Services/EmbeddedWebServer.cs | 69 ++++++++++++++++--- 8 files changed, 155 insertions(+), 30 deletions(-) diff --git a/src/Predictalytics.Api/ApiConfiguration.cs b/src/Predictalytics.Api/ApiConfiguration.cs index c9da848..6c0174c 100644 --- a/src/Predictalytics.Api/ApiConfiguration.cs +++ b/src/Predictalytics.Api/ApiConfiguration.cs @@ -23,20 +23,34 @@ public static class ApiConfiguration app.UseSwagger(); app.UseSwaggerUI(); 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; + } + + /// + /// 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). + /// + public static void MapPredictalyticsEndpoints(this WebApplication app) + { app.MapDashboardEndpoints(); app.MapTraderEndpoints(); app.MapAlertEndpoints(); app.MapMarketEndpoints(); + app.MapSearchEndpoints(); app.MapJobEndpoints(); app.MapDevEndpoints(); app.MapWatchlistEndpoints(); - - // Health check app.MapGet("/api/health", () => Results.Ok(new { Status = "OK", Timestamp = DateTime.UtcNow })); - - return app; } } diff --git a/src/Predictalytics.Api/Endpoints/WatchlistEndpoints.cs b/src/Predictalytics.Api/Endpoints/WatchlistEndpoints.cs index 45306eb..28ac86c 100644 --- a/src/Predictalytics.Api/Endpoints/WatchlistEndpoints.cs +++ b/src/Predictalytics.Api/Endpoints/WatchlistEndpoints.cs @@ -15,11 +15,12 @@ public static class WatchlistEndpoints return Results.Ok(list.Select(w => new { w.TraderId, - w.Trader.DisplayName, - w.Trader.PlatformUserId, - w.Trader.TotalPnl, - w.Trader.WinRate, - CopytradingScore = w.Trader.CurrentScore?.CombinedScore ?? 0, + DisplayName = w.Trader?.DisplayName ?? "?", + PlatformUserId = w.Trader?.PlatformUserId ?? "", + Platform = w.Trader?.Platform.ToString() ?? "Unknown", + TotalPnl = w.Trader?.TotalPnl ?? 0, + WinRate = w.Trader?.WinRate ?? 0, + CopytradingScore = w.Trader?.Analytics?.CopytradingScore ?? 0, w.Label, w.Notes, CreatedAt = w.AddedAt diff --git a/src/Predictalytics.Api/wwwroot/index.html b/src/Predictalytics.Api/wwwroot/index.html index 76c962b..e7441f2 100644 --- a/src/Predictalytics.Api/wwwroot/index.html +++ b/src/Predictalytics.Api/wwwroot/index.html @@ -7,7 +7,7 @@ Predictalytics - + @@ -472,6 +472,6 @@ - + diff --git a/src/Predictalytics.Api/wwwroot/js/app.js b/src/Predictalytics.Api/wwwroot/js/app.js index 3e0a4e9..d77dd06 100644 --- a/src/Predictalytics.Api/wwwroot/js/app.js +++ b/src/Predictalytics.Api/wwwroot/js/app.js @@ -346,22 +346,26 @@ async function loadTraders() { } async function loadWatchlist() { - const data = await api(`/api/watchlist`); - if (!data) return; - 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 = '⚠ Failed to load watchlist (see browser console / server log).'; + return; + } + tbody.innerHTML = data.map(w => ` -
${w.displayName.substring(0, 2).toUpperCase()}
+
${(w.displayName || '?').substring(0, 2).toUpperCase()}
- ${w.displayName}
- ${w.platformUserId.substring(0, 8)}... + ${w.displayName || '?'}
+ ${(w.platformUserId || '').substring(0, 8)}...
${w.platform ?? 'Unknown'} ${Number(w.copytradingScore || 0).toFixed(1)} - ${fmt.pct(w.winRate)} + ${fmt.pct(w.winRate)} ${fmt.pnl(w.totalPnl)} ${w.label || ''} ${new Date(w.createdAt).toLocaleDateString()} diff --git a/src/Predictalytics.Infrastructure/Data/Repositories/WatchlistRepository.cs b/src/Predictalytics.Infrastructure/Data/Repositories/WatchlistRepository.cs index a551bed..0b93763 100644 --- a/src/Predictalytics.Infrastructure/Data/Repositories/WatchlistRepository.cs +++ b/src/Predictalytics.Infrastructure/Data/Repositories/WatchlistRepository.cs @@ -10,7 +10,10 @@ public class WatchlistRepository : IWatchlistRepository public WatchlistRepository(AppDbContext db) => _db = db; public async Task> 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 GetByTraderIdAsync(int traderId, CancellationToken ct = default) => await _db.WatchlistEntries.FirstOrDefaultAsync(w => w.TraderId == traderId, ct); diff --git a/src/Predictalytics.WinFormsHost/MainForm.Designer.cs b/src/Predictalytics.WinFormsHost/MainForm.Designer.cs index f154465..eb8d899 100644 --- a/src/Predictalytics.WinFormsHost/MainForm.Designer.cs +++ b/src/Predictalytics.WinFormsHost/MainForm.Designer.cs @@ -37,6 +37,7 @@ partial class MainForm btn_dbReset = new ToolStripMenuItem(); btn_syncmarkets = new ToolStripMenuItem(); btn_dbUpdate = new ToolStripMenuItem(); + btn_recalcAll = new ToolStripMenuItem(); toolStrip1.SuspendLayout(); statusStrip1.SuspendLayout(); tabControl1.SuspendLayout(); @@ -192,6 +193,7 @@ partial class MainForm btn_dbReset = new ToolStripMenuItem(); btn_syncmarkets = new ToolStripMenuItem(); btn_dbUpdate = new ToolStripMenuItem(); + btn_recalcAll = new ToolStripMenuItem(); toolStrip1.SuspendLayout(); statusStrip1.SuspendLayout(); tabControl1.SuspendLayout(); @@ -341,7 +343,7 @@ partial class MainForm // // 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.Size = new Size(135, 29); developmentToolStripMenuItem.Text = "Development"; @@ -365,6 +367,13 @@ partial class MainForm btn_dbUpdate.Size = new Size(270, 34); btn_dbUpdate.Text = "UpdateDB"; 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 // @@ -414,4 +423,5 @@ partial class MainForm private ToolStripStatusLabel label_dbSize; private ToolStripStatusLabel label_buildVersion; private ToolStripMenuItem btn_dbUpdate; + private ToolStripMenuItem btn_recalcAll; } diff --git a/src/Predictalytics.WinFormsHost/MainForm.cs b/src/Predictalytics.WinFormsHost/MainForm.cs index 9137c60..f77cbb8 100644 --- a/src/Predictalytics.WinFormsHost/MainForm.cs +++ b/src/Predictalytics.WinFormsHost/MainForm.cs @@ -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() { try diff --git a/src/Predictalytics.WinFormsHost/Services/EmbeddedWebServer.cs b/src/Predictalytics.WinFormsHost/Services/EmbeddedWebServer.cs index 3131380..61ae905 100644 --- a/src/Predictalytics.WinFormsHost/Services/EmbeddedWebServer.cs +++ b/src/Predictalytics.WinFormsHost/Services/EmbeddedWebServer.cs @@ -1,4 +1,6 @@ +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Hosting; +using Predictalytics.Api; using Predictalytics.Api.Endpoints; using Predictalytics.Worker; using Predictalytics.Application.Interfaces; @@ -29,6 +31,59 @@ public class EmbeddedWebServer await Predictalytics.Infrastructure.DependencyInjection.EnsureDatabaseAsync(provider, DbConnectionDebug); } + /// + /// 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. + /// + public async Task 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(); + 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) { lock (_lock) { if (_app != null) return; } @@ -66,19 +121,17 @@ public class EmbeddedWebServer { 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( Path.Combine(wwwrootPath, "index.html"), "text/html")); } - app.MapDashboardEndpoints(); - app.MapTraderEndpoints(); - app.MapMarketEndpoints(); - app.MapAlertEndpoints(); - app.MapSearchEndpoints(); - app.MapJobEndpoints(); - app.MapGet("/api/health", () => Results.Ok(new { Status = "OK", Timestamp = DateTime.UtcNow })); + // Single shared registration — see ApiConfiguration.MapPredictalyticsEndpoints. + // Do NOT map endpoints individually here. + app.MapPredictalyticsEndpoints(); await Predictalytics.Infrastructure.DependencyInjection.EnsureDatabaseAsync(app.Services, DbConnectionDebug);