feat: Jobs infrastructure, endpoints, UI and worker prioritization
This commit is contained in:
@@ -30,6 +30,7 @@ public static class ApiConfiguration
|
|||||||
app.MapTraderEndpoints();
|
app.MapTraderEndpoints();
|
||||||
app.MapAlertEndpoints();
|
app.MapAlertEndpoints();
|
||||||
app.MapMarketEndpoints();
|
app.MapMarketEndpoints();
|
||||||
|
app.MapJobEndpoints();
|
||||||
|
|
||||||
// Health check
|
// 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 }));
|
||||||
|
|||||||
@@ -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 { 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); }
|
.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; }
|
.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 {
|
||||||
.btn-tab:hover { background: var(--bg-input); }
|
white-space: nowrap;
|
||||||
.btn-tab.active { background: var(--bg-input); color: var(--primary) !important; border-bottom: 2px solid var(--primary) !important; }
|
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-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-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); }
|
.stat-value { font-size: 15px; font-weight: 600; color: var(--text-primary); }
|
||||||
|
|||||||
@@ -35,6 +35,10 @@
|
|||||||
<span>Alerts</span>
|
<span>Alerts</span>
|
||||||
<span class="badge" id="alertBadge" style="display:none">0</span>
|
<span class="badge" id="alertBadge" style="display:none">0</span>
|
||||||
</a>
|
</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>
|
</nav>
|
||||||
<div class="sidebar-footer">
|
<div class="sidebar-footer">
|
||||||
<a href="/swagger" target="_blank" class="nav-item">
|
<a href="/swagger" target="_blank" class="nav-item">
|
||||||
@@ -126,9 +130,30 @@
|
|||||||
|
|
||||||
<!-- Traders Page -->
|
<!-- Traders Page -->
|
||||||
<section class="page" id="page-traders">
|
<section class="page" id="page-traders">
|
||||||
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom: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:24px;">
|
<div style="display:flex; align-items:center; gap:16px; flex-wrap:wrap;">
|
||||||
<h1 class="page-title" style="margin-bottom:0">Traders</h1>
|
<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);">
|
<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
|
<input type="checkbox" id="chk-highly-copyable" onchange="loadTraders()"> Highly Copyable
|
||||||
</label>
|
</label>
|
||||||
@@ -139,7 +164,7 @@
|
|||||||
<option value="Polymarket">Polymarket</option>
|
<option value="Polymarket">Polymarket</option>
|
||||||
<option value="Limitless">Limitless</option>
|
<option value="Limitless">Limitless</option>
|
||||||
</select>
|
</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>
|
<button class="btn-sm" onclick="manualAddTrader()" style="padding:6px 16px; background:var(--accent); color:white; border:none">Add</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -158,6 +183,33 @@
|
|||||||
<!-- Alerts Page -->
|
<!-- Alerts Page -->
|
||||||
<section class="page" id="page-alerts"><h1 class="page-title">Alerts</h1><div class="card" id="alertsList"></div></section>
|
<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 -->
|
<!-- Search Results Page -->
|
||||||
<section class="page" id="page-search">
|
<section class="page" id="page-search">
|
||||||
<h1 class="page-title" id="search-title">Search Results</h1>
|
<h1 class="page-title" id="search-title">Search Results</h1>
|
||||||
@@ -309,7 +361,13 @@
|
|||||||
<div class="stat-label">Platform</div>
|
<div class="stat-label">Platform</div>
|
||||||
<div class="stat-value" id="md-platform">—</div>
|
<div class="stat-value" id="md-platform">—</div>
|
||||||
</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-label">Category</div>
|
||||||
<div class="stat-value" id="md-category">—</div>
|
<div class="stat-value" id="md-category">—</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ document.querySelectorAll('.nav-item[data-page]').forEach(item => {
|
|||||||
if (page === 'traders') loadTraders();
|
if (page === 'traders') loadTraders();
|
||||||
if (page === 'alerts') loadAlerts();
|
if (page === 'alerts') loadAlerts();
|
||||||
if (page === 'markets') loadMarkets();
|
if (page === 'markets') loadMarkets();
|
||||||
|
if (page === 'jobs') loadJobs();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -285,10 +286,25 @@ async function loadTraders() {
|
|||||||
const tbody = document.getElementById('allTradersBody');
|
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; }
|
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
|
// Sorting
|
||||||
if (currentSort === 'score') data.sort((a, b) => b.combinedScore - a.combinedScore);
|
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 === '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 === '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) => `
|
tbody.innerHTML = data.map((t, i) => `
|
||||||
<tr>
|
<tr>
|
||||||
@@ -302,7 +318,13 @@ async function loadTraders() {
|
|||||||
<td>${fmt.pnl(t.totalPnl)}</td>
|
<td>${fmt.pnl(t.totalPnl)}</td>
|
||||||
<td>${fmt.tier(t.tier)}</td>
|
<td>${fmt.tier(t.tier)}</td>
|
||||||
<td>${t.strategy}</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>
|
</tr>
|
||||||
`).join('');
|
`).join('');
|
||||||
}
|
}
|
||||||
@@ -531,3 +553,48 @@ async function triggerAiAnalysis(id, manual) {
|
|||||||
btn.disabled = false;
|
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<MarketOutcomePriceSnapshot> MarketOutcomePriceSnapshots => Set<MarketOutcomePriceSnapshot>();
|
||||||
public DbSet<TraderCategoryPerformance> TraderCategoryPerformances => Set<TraderCategoryPerformance>();
|
public DbSet<TraderCategoryPerformance> TraderCategoryPerformances => Set<TraderCategoryPerformance>();
|
||||||
public DbSet<TradeContext> TradeContexts => Set<TradeContext>();
|
public DbSet<TradeContext> TradeContexts => Set<TradeContext>();
|
||||||
|
public DbSet<BackgroundJob> BackgroundJobs => Set<BackgroundJob>();
|
||||||
|
|
||||||
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }
|
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }
|
||||||
|
|
||||||
@@ -220,5 +221,17 @@ public class AppDbContext : DbContext
|
|||||||
e.Property(ps => ps.Price).HasPrecision(10, 6);
|
e.Property(ps => ps.Price).HasPrecision(10, 6);
|
||||||
e.HasOne(ps => ps.MarketOutcome).WithMany().HasForeignKey(ps => ps.MarketOutcomeId).OnDelete(DeleteBehavior.Cascade);
|
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<IMarketRepository, MarketRepository>();
|
||||||
services.AddScoped<IWatchlistRepository, WatchlistRepository>();
|
services.AddScoped<IWatchlistRepository, WatchlistRepository>();
|
||||||
services.AddScoped<IAlertRepository, AlertRepository>();
|
services.AddScoped<IAlertRepository, AlertRepository>();
|
||||||
|
services.AddScoped<IJobRepository, JobRepository>();
|
||||||
|
|
||||||
// Application Services
|
// Application Services
|
||||||
services.AddScoped<IPositionPnLEngine, PositionPnLEngine>();
|
services.AddScoped<IPositionPnLEngine, PositionPnLEngine>();
|
||||||
|
|||||||
@@ -67,6 +67,51 @@ namespace Predictalytics.Infrastructure.Migrations
|
|||||||
b.ToTable("Alerts");
|
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 =>
|
modelBuilder.Entity("Predictalytics.Domain.Entities.Event", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("Id")
|
b.Property<int>("Id")
|
||||||
@@ -795,6 +840,16 @@ namespace Predictalytics.Infrastructure.Migrations
|
|||||||
b.Navigation("Trader");
|
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 =>
|
modelBuilder.Entity("Predictalytics.Domain.Entities.Market", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("Predictalytics.Domain.Entities.Event", "Event")
|
b.HasOne("Predictalytics.Domain.Entities.Event", "Event")
|
||||||
|
|||||||
@@ -36,20 +36,40 @@ public class TradeHistoryWorker : BackgroundService
|
|||||||
{
|
{
|
||||||
try
|
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())
|
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>();
|
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);
|
tradersToProcess = await repo.GetTradersDueForTradeUpdateAsync(CooldownHours, TradersPerCycle, stoppingToken);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (tradersToProcess.Count == 0)
|
if (tradersToProcess.Count == 0)
|
||||||
{
|
{
|
||||||
_logger.LogDebug("📜 No traders due for sync. Sleeping.");
|
_logger.LogDebug("🔄 No traders due for sync. Sleeping.");
|
||||||
}
|
}
|
||||||
else
|
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>();
|
var outcomeCache = new System.Collections.Concurrent.ConcurrentDictionary<string, Domain.Entities.MarketOutcome>();
|
||||||
@@ -166,9 +186,32 @@ public class TradeHistoryWorker : BackgroundService
|
|||||||
trader.LastTradesUpdatedAt = DateTime.UtcNow;
|
trader.LastTradesUpdatedAt = DateTime.UtcNow;
|
||||||
trader.LastPolledAt = DateTime.UtcNow;
|
trader.LastPolledAt = DateTime.UtcNow;
|
||||||
await traderRepo.UpdateAsync(trader, ct);
|
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 (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; }
|
catch (OperationCanceledException) { break; }
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ using Microsoft.Extensions.Logging;
|
|||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Predictalytics.Infrastructure.Data;
|
using Predictalytics.Infrastructure.Data;
|
||||||
using Predictalytics.Application.Interfaces;
|
using Predictalytics.Application.Interfaces;
|
||||||
|
using Predictalytics.Domain.Interfaces;
|
||||||
|
|
||||||
namespace Predictalytics.Worker.Services;
|
namespace Predictalytics.Worker.Services;
|
||||||
|
|
||||||
@@ -40,9 +41,22 @@ public class TraderAnalyticsWorker : BackgroundService
|
|||||||
|
|
||||||
private async Task RunAnalyticsAsync(CancellationToken ct)
|
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())
|
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>();
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||||
// Find traders who have never been analyzed, or whose last analysis was before their latest trade.
|
// 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
|
.Take(500) // Limit batch size to prevent long-running loops without save
|
||||||
.ToListAsync(ct);
|
.ToListAsync(ct);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (traderIds.Count > 0)
|
||||||
|
{
|
||||||
_logger.LogInformation("Found {Count} active or unanalyzed traders to update", traderIds.Count);
|
_logger.LogInformation("Found {Count} active or unanalyzed traders to update", traderIds.Count);
|
||||||
|
}
|
||||||
|
|
||||||
foreach (var id in traderIds)
|
foreach (var id in traderIds)
|
||||||
{
|
{
|
||||||
@@ -65,13 +83,36 @@ public class TraderAnalyticsWorker : BackgroundService
|
|||||||
using var traderScope = _services.CreateScope();
|
using var traderScope = _services.CreateScope();
|
||||||
var pnlEngine = traderScope.ServiceProvider.GetRequiredService<IPositionPnLEngine>();
|
var pnlEngine = traderScope.ServiceProvider.GetRequiredService<IPositionPnLEngine>();
|
||||||
await pnlEngine.RecalculateTraderPositionsAsync(id, ct);
|
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)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogError(ex, "Error recalculating positions/PnL for trader {TraderId}", id);
|
_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.");
|
_logger.LogInformation("Trader analytics update complete.");
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user