feat: Jobs infrastructure, endpoints, UI and worker prioritization

This commit is contained in:
Richard
2026-07-06 19:58:23 +02:00
parent 33a52ed173
commit fb703b6c81
9 changed files with 321 additions and 26 deletions
@@ -30,6 +30,7 @@ public static class ApiConfiguration
app.MapTraderEndpoints();
app.MapAlertEndpoints();
app.MapMarketEndpoints();
app.MapJobEndpoints();
// Health check
app.MapGet("/api/health", () => Results.Ok(new { Status = "OK", Timestamp = DateTime.UtcNow }));
+19 -3
View File
@@ -447,9 +447,25 @@ body {
.btn-primary { background: var(--primary); color: #000; border: none; border-radius: 6px; cursor: pointer; font-weight: bold; transition: all 0.2s; }
.btn-primary:hover { filter: brightness(1.1); }
.tabs-nav { display: flex; gap: 12px; margin-bottom: 24px; border-bottom: 1px solid var(--border); padding-bottom: 8px; flex-wrap: wrap; align-items: center; }
.btn-tab { white-space: nowrap; padding: 8px 16px; border-radius: 6px; font-size: 14px; transition: background 0.2s; }
.btn-tab:hover { background: var(--bg-input); }
.btn-tab.active { background: var(--bg-input); color: var(--primary) !important; border-bottom: 2px solid var(--primary) !important; }
.btn-tab {
white-space: nowrap;
padding: 8px 16px;
border-radius: 6px;
font-size: 14px;
font-weight: 600;
transition: all 0.2s;
background: transparent;
color: var(--text-muted);
border: none;
cursor: pointer;
}
.btn-tab:hover { background: var(--bg-input); color: var(--text-primary); }
.btn-tab.active {
background: var(--bg-input);
color: var(--primary) !important;
border-bottom: 2px solid var(--primary) !important;
border-radius: 6px 6px 0 0;
}
.stat-group { margin-bottom: 16px; }
.stat-label { font-size: 11px; font-weight: 700; text-transform: uppercase; color: var(--text-muted); letter-spacing: 0.5px; margin-bottom: 4px; }
.stat-value { font-size: 15px; font-weight: 600; color: var(--text-primary); }
+62 -4
View File
@@ -35,6 +35,10 @@
<span>Alerts</span>
<span class="badge" id="alertBadge" style="display:none">0</span>
</a>
<a href="#" class="nav-item" data-page="jobs">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="7" width="20" height="14" rx="2" ry="2"/><path d="M16 21V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16"/></svg>
<span>Jobs</span>
</a>
</nav>
<div class="sidebar-footer">
<a href="/swagger" target="_blank" class="nav-item">
@@ -126,9 +130,30 @@
<!-- Traders Page -->
<section class="page" id="page-traders">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:24px;">
<div style="display:flex; align-items:center; gap:24px;">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:24px; flex-wrap:wrap; gap:12px;">
<div style="display:flex; align-items:center; gap:16px; flex-wrap:wrap;">
<h1 class="page-title" style="margin-bottom:0">Traders</h1>
<select id="tradersSort" class="platform-select" onchange="loadTraders()">
<option value="score">Sort: Combined Score</option>
<option value="winrate">Sort: Win Rate</option>
<option value="copyability">Sort: Copyability</option>
<option value="pnl">Sort: Total PnL</option>
<option value="name">Sort: Name</option>
</select>
<select id="filterWinrate" class="platform-select" onchange="loadTraders()">
<option value="all">Win Rate: All</option>
<option value="gt50">> 50%</option>
<option value="gt60">> 60%</option>
</select>
<select id="filterCopyability" class="platform-select" onchange="loadTraders()">
<option value="all">Copyability: All</option>
<option value="gt50">> 50%</option>
<option value="gt80">> 80%</option>
</select>
<label style="display:flex; align-items:center; gap:8px; font-weight:600; cursor:pointer; background:var(--bg-surface); padding:8px 12px; border-radius:6px; border:1px solid var(--border);">
<input type="checkbox" id="chk-highly-copyable" onchange="loadTraders()"> Highly Copyable
</label>
@@ -139,7 +164,7 @@
<option value="Polymarket">Polymarket</option>
<option value="Limitless">Limitless</option>
</select>
<input type="text" id="addWallet" placeholder="Wallet Address" class="platform-select" style="width:300px">
<input type="text" id="addWallet" placeholder="Wallet Address" class="platform-select" style="width:200px">
<button class="btn-sm" onclick="manualAddTrader()" style="padding:6px 16px; background:var(--accent); color:white; border:none">Add</button>
</div>
</div>
@@ -158,6 +183,33 @@
<!-- Alerts Page -->
<section class="page" id="page-alerts"><h1 class="page-title">Alerts</h1><div class="card" id="alertsList"></div></section>
<!-- Jobs Page -->
<section class="page" id="page-jobs">
<h1 class="page-title">Background Jobs</h1>
<div class="card">
<div class="table-wrap">
<table class="data-table">
<thead>
<tr>
<th>Job ID</th>
<th>Job Type</th>
<th>Status</th>
<th>Trader</th>
<th>Created At</th>
<th>Started At</th>
<th>Completed At</th>
<th>Error Message</th>
<th>Action</th>
</tr>
</thead>
<tbody id="jobsBody">
<tr><td colspan="9" style="text-align:center;">Loading jobs...</td></tr>
</tbody>
</table>
</div>
</div>
</section>
<!-- Search Results Page -->
<section class="page" id="page-search">
<h1 class="page-title" id="search-title">Search Results</h1>
@@ -309,7 +361,13 @@
<div class="stat-label">Platform</div>
<div class="stat-value" id="md-platform"></div>
</div>
<div class="stat-group">
<ul class="nav-links">
<li><a href="#" onclick="showPage('page-dashboard')" class="active" id="nav-dashboard">Dashboard</a></li>
<li><a href="#" onclick="showPage('page-traders')" id="nav-traders">Traders</a></li>
<li><a href="#" onclick="showPage('page-markets')" id="nav-markets">Markets</a></li>
<li><a href="#" onclick="showPage('page-alerts')" id="nav-alerts">Alerts</a></li>
<li><a href="#" onclick="showPage('page-jobs')" id="nav-jobs">Jobs</a></li>
</ul> <div class="stat-group">
<div class="stat-label">Category</div>
<div class="stat-value" id="md-category"></div>
</div>
+68 -1
View File
@@ -26,6 +26,7 @@ document.querySelectorAll('.nav-item[data-page]').forEach(item => {
if (page === 'traders') loadTraders();
if (page === 'alerts') loadAlerts();
if (page === 'markets') loadMarkets();
if (page === 'jobs') loadJobs();
});
});
@@ -285,10 +286,25 @@ async function loadTraders() {
const tbody = document.getElementById('allTradersBody');
if (!data || !data.length) { tbody.innerHTML = '<tr><td colspan="11"><div class="empty-state"><p>No traders tracked yet.</p></div></td></tr>'; return; }
const sortSelect = document.getElementById('tradersSort');
if (sortSelect) currentSort = sortSelect.value;
const filterWinrate = document.getElementById('filterWinrate')?.value || 'all';
const filterCopyability = document.getElementById('filterCopyability')?.value || 'all';
// Filtering
if (filterWinrate === 'gt50') data = data.filter(t => t.winRate > 50);
if (filterWinrate === 'gt60') data = data.filter(t => t.winRate > 60);
if (filterCopyability === 'gt50') data = data.filter(t => t.copytradingCopyabilityScore > 50);
if (filterCopyability === 'gt80') data = data.filter(t => t.copytradingCopyabilityScore > 80);
// Sorting
if (currentSort === 'score') data.sort((a, b) => b.combinedScore - a.combinedScore);
else if (currentSort === 'name') data.sort((a, b) => a.displayName.localeCompare(b.displayName));
else if (currentSort === 'pnl') data.sort((a, b) => b.totalPnl - a.totalPnl);
else if (currentSort === 'winrate') data.sort((a, b) => b.winRate - a.winRate);
else if (currentSort === 'copyability') data.sort((a, b) => (b.copytradingCopyabilityScore || 0) - (a.copytradingCopyabilityScore || 0));
tbody.innerHTML = data.map((t, i) => `
<tr>
@@ -302,7 +318,13 @@ async function loadTraders() {
<td>${fmt.pnl(t.totalPnl)}</td>
<td>${fmt.tier(t.tier)}</td>
<td>${t.strategy}</td>
<td><button class="btn-sm" onclick="viewTrader(${t.id})">Details</button></td>
<td>
<div style="display:flex; gap:4px;">
<button class="btn-sm" onclick="viewTrader(${t.id})">Details</button>
<button class="btn-sm" onclick="queueHistorySync(${t.id})" style="background:var(--bg-input); border:1px solid var(--border); color:var(--text)">Sync</button>
<button class="btn-sm" onclick="queueTraderAnalysis(${t.id})" style="background:var(--bg-input); border:1px solid var(--border); color:var(--text)">Analyze</button>
</div>
</td>
</tr>
`).join('');
}
@@ -531,3 +553,48 @@ async function triggerAiAnalysis(id, manual) {
btn.disabled = false;
}
}
// ─── Jobs Page ───
async function loadJobs() {
const data = await api('/api/jobs');
const tbody = document.getElementById('jobsBody');
if (!data || !data.length) {
tbody.innerHTML = '<tr><td colspan="9"><div class="empty-state"><p>No jobs found.</p></div></td></tr>';
return;
}
tbody.innerHTML = data.map(j => `
<tr>
<td>${j.id}</td>
<td><strong>${j.jobType}</strong></td>
<td>${j.status === 'Completed' ? '<span style="color:var(--pnl-positive)">Completed</span>' : j.status === 'Failed' ? '<span style="color:var(--pnl-negative)">Failed</span>' : j.status}</td>
<td><a href="#" onclick="viewTrader(${j.traderId}); return false;" style="color:var(--primary);text-decoration:none;">${j.traderName || j.traderId}</a></td>
<td>${fmt.time(j.createdAt)}</td>
<td>${j.startedAt ? fmt.time(j.startedAt) : '—'}</td>
<td>${j.completedAt ? fmt.time(j.completedAt) : '—'}</td>
<td style="color:var(--pnl-negative); max-width:200px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;" title="${j.errorMessage || ''}">${j.errorMessage || '—'}</td>
<td><button class="btn-sm" onclick="viewTrader(${j.traderId})">View Trader</button></td>
</tr>
`).join('');
}
async function queueHistorySync(id) {
const res = await fetch(`/api/jobs/sync/${id}`, { method: 'POST' });
if (res.ok) {
alert('History Sync job queued successfully.');
} else {
alert('Failed to queue history sync.');
}
}
async function queueTraderAnalysis(id) {
const res = await fetch(`/api/jobs/analyze/${id}`, { method: 'POST' });
if (res.ok) {
alert('Trader Analysis job queued successfully.');
} else {
alert('Failed to queue trader analysis.');
}
}
// Initialize Dashboard
loadDashboard();
@@ -20,6 +20,7 @@ public class AppDbContext : DbContext
public DbSet<MarketOutcomePriceSnapshot> MarketOutcomePriceSnapshots => Set<MarketOutcomePriceSnapshot>();
public DbSet<TraderCategoryPerformance> TraderCategoryPerformances => Set<TraderCategoryPerformance>();
public DbSet<TradeContext> TradeContexts => Set<TradeContext>();
public DbSet<BackgroundJob> BackgroundJobs => Set<BackgroundJob>();
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }
@@ -220,5 +221,17 @@ public class AppDbContext : DbContext
e.Property(ps => ps.Price).HasPrecision(10, 6);
e.HasOne(ps => ps.MarketOutcome).WithMany().HasForeignKey(ps => ps.MarketOutcomeId).OnDelete(DeleteBehavior.Cascade);
});
// BackgroundJob
mb.Entity<BackgroundJob>(e =>
{
e.HasKey(j => j.Id);
e.HasIndex(j => j.Status);
e.HasIndex(j => j.JobType);
e.Property(j => j.JobType).HasConversion<string>().HasMaxLength(64);
e.Property(j => j.Status).HasConversion<string>().HasMaxLength(64);
e.Property(j => j.ErrorMessage).HasMaxLength(4096);
e.HasOne(j => j.Trader).WithMany().HasForeignKey(j => j.TraderId).OnDelete(DeleteBehavior.SetNull);
});
}
}
@@ -66,6 +66,7 @@ public static class DependencyInjection
services.AddScoped<IMarketRepository, MarketRepository>();
services.AddScoped<IWatchlistRepository, WatchlistRepository>();
services.AddScoped<IAlertRepository, AlertRepository>();
services.AddScoped<IJobRepository, JobRepository>();
// Application Services
services.AddScoped<IPositionPnLEngine, PositionPnLEngine>();
@@ -67,6 +67,51 @@ namespace Predictalytics.Infrastructure.Migrations
b.ToTable("Alerts");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.BackgroundJob", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
b.Property<DateTime?>("CompletedAt")
.HasColumnType("datetime(6)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<string>("ErrorMessage")
.HasMaxLength(4096)
.HasColumnType("varchar(4096)");
b.Property<string>("JobType")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("varchar(64)");
b.Property<DateTime?>("StartedAt")
.HasColumnType("datetime(6)");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("varchar(64)");
b.Property<int?>("TraderId")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("JobType");
b.HasIndex("Status");
b.HasIndex("TraderId");
b.ToTable("BackgroundJobs");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.Event", b =>
{
b.Property<int>("Id")
@@ -795,6 +840,16 @@ namespace Predictalytics.Infrastructure.Migrations
b.Navigation("Trader");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.BackgroundJob", b =>
{
b.HasOne("Predictalytics.Domain.Entities.Trader", "Trader")
.WithMany()
.HasForeignKey("TraderId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("Trader");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.Market", b =>
{
b.HasOne("Predictalytics.Domain.Entities.Event", "Event")
@@ -36,20 +36,40 @@ public class TradeHistoryWorker : BackgroundService
{
try
{
IReadOnlyList<Domain.Entities.Trader> tradersToProcess;
IReadOnlyList<Domain.Entities.Trader> tradersToProcess = new List<Domain.Entities.Trader>();
Domain.Entities.BackgroundJob? activeJob = null;
using (var scope = _services.CreateScope())
{
var jobRepo = scope.ServiceProvider.GetRequiredService<IJobRepository>();
activeJob = await jobRepo.GetNextPendingJobAsync(Predictalytics.Domain.Enums.JobType.HistorySync, stoppingToken);
var repo = scope.ServiceProvider.GetRequiredService<ITraderRepository>();
if (activeJob != null && activeJob.TraderId.HasValue)
{
var t = await repo.GetByIdAsync(activeJob.TraderId.Value, stoppingToken);
if (t != null)
{
tradersToProcess = new[] { t };
activeJob.Status = Predictalytics.Domain.Enums.JobStatus.InProgress;
activeJob.StartedAt = DateTime.UtcNow;
await jobRepo.UpdateAsync(activeJob, stoppingToken);
}
}
else
{
tradersToProcess = await repo.GetTradersDueForTradeUpdateAsync(CooldownHours, TradersPerCycle, stoppingToken);
}
}
if (tradersToProcess.Count == 0)
{
_logger.LogDebug("📜 No traders due for sync. Sleeping.");
_logger.LogDebug("🔄 No traders due for sync. Sleeping.");
}
else
{
_logger.LogInformation("📜 Processing {Count} traders for trade sync (Initial/Update)", tradersToProcess.Count);
_logger.LogInformation("🔄 Processing {Count} traders for trade sync (Initial/Update)", tradersToProcess.Count);
}
var outcomeCache = new System.Collections.Concurrent.ConcurrentDictionary<string, Domain.Entities.MarketOutcome>();
@@ -166,9 +186,32 @@ public class TradeHistoryWorker : BackgroundService
trader.LastTradesUpdatedAt = DateTime.UtcNow;
trader.LastPolledAt = DateTime.UtcNow;
await traderRepo.UpdateAsync(trader, ct);
if (activeJob != null && activeJob.TraderId == trader.Id)
{
using var jobScope = _services.CreateScope();
var updateJobRepo = jobScope.ServiceProvider.GetRequiredService<IJobRepository>();
activeJob.Status = Predictalytics.Domain.Enums.JobStatus.Completed;
activeJob.CompletedAt = DateTime.UtcNow;
await updateJobRepo.UpdateAsync(activeJob, ct);
}
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { /* App shutting down */ }
catch (Exception ex) { _logger.LogError(ex, "Error syncing trader {TraderId}", t.Id); }
catch (Exception ex)
{
_logger.LogError(ex, "Error syncing trader {TraderId}", t.Id);
if (activeJob != null && activeJob.TraderId == t.Id)
{
try {
using var jobScope = _services.CreateScope();
var updateJobRepo = jobScope.ServiceProvider.GetRequiredService<IJobRepository>();
activeJob.Status = Predictalytics.Domain.Enums.JobStatus.Failed;
activeJob.CompletedAt = DateTime.UtcNow;
activeJob.ErrorMessage = ex.Message;
await updateJobRepo.UpdateAsync(activeJob, CancellationToken.None);
} catch { /* Ignore secondary errors */ }
}
}
});
}
catch (OperationCanceledException) { break; }
@@ -4,6 +4,7 @@ using Microsoft.Extensions.Logging;
using Microsoft.Extensions.DependencyInjection;
using Predictalytics.Infrastructure.Data;
using Predictalytics.Application.Interfaces;
using Predictalytics.Domain.Interfaces;
namespace Predictalytics.Worker.Services;
@@ -40,9 +41,22 @@ public class TraderAnalyticsWorker : BackgroundService
private async Task RunAnalyticsAsync(CancellationToken ct)
{
List<int> traderIds;
List<int> traderIds = new List<int>();
Domain.Entities.BackgroundJob? activeJob = null;
using (var scope = _services.CreateScope())
{
var jobRepo = scope.ServiceProvider.GetRequiredService<IJobRepository>();
activeJob = await jobRepo.GetNextPendingJobAsync(Predictalytics.Domain.Enums.JobType.TraderAnalysis, ct);
if (activeJob != null && activeJob.TraderId.HasValue)
{
traderIds.Add(activeJob.TraderId.Value);
activeJob.Status = Predictalytics.Domain.Enums.JobStatus.InProgress;
activeJob.StartedAt = DateTime.UtcNow;
await jobRepo.UpdateAsync(activeJob, ct);
}
else
{
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
// Find traders who have never been analyzed, or whose last analysis was before their latest trade.
@@ -55,8 +69,12 @@ public class TraderAnalyticsWorker : BackgroundService
.Take(500) // Limit batch size to prevent long-running loops without save
.ToListAsync(ct);
}
}
if (traderIds.Count > 0)
{
_logger.LogInformation("Found {Count} active or unanalyzed traders to update", traderIds.Count);
}
foreach (var id in traderIds)
{
@@ -65,13 +83,36 @@ public class TraderAnalyticsWorker : BackgroundService
using var traderScope = _services.CreateScope();
var pnlEngine = traderScope.ServiceProvider.GetRequiredService<IPositionPnLEngine>();
await pnlEngine.RecalculateTraderPositionsAsync(id, ct);
if (activeJob != null && activeJob.TraderId == id)
{
using var jobScope = _services.CreateScope();
var updateJobRepo = jobScope.ServiceProvider.GetRequiredService<IJobRepository>();
activeJob.Status = Predictalytics.Domain.Enums.JobStatus.Completed;
activeJob.CompletedAt = DateTime.UtcNow;
await updateJobRepo.UpdateAsync(activeJob, ct);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error recalculating positions/PnL for trader {TraderId}", id);
if (activeJob != null && activeJob.TraderId == id)
{
try {
using var jobScope = _services.CreateScope();
var updateJobRepo = jobScope.ServiceProvider.GetRequiredService<IJobRepository>();
activeJob.Status = Predictalytics.Domain.Enums.JobStatus.Failed;
activeJob.CompletedAt = DateTime.UtcNow;
activeJob.ErrorMessage = ex.Message;
await updateJobRepo.UpdateAsync(activeJob, CancellationToken.None);
} catch { /* Ignore secondary errors */ }
}
}
}
if (traderIds.Count > 0)
{
_logger.LogInformation("Trader analytics update complete.");
}
}
}