feat: prioritize trader analytics via LastAnalyzedAt and add force-analyze endpoint

This commit is contained in:
Richard
2026-07-05 18:58:33 +02:00
parent c38b4d498f
commit 0b799123db
5 changed files with 45 additions and 17 deletions
@@ -36,6 +36,12 @@ public static class TraderEndpoints
return Results.Ok();
});
group.MapPost("/{id:int}/force-analyze", async (int id, IAnalyticsService svc, CancellationToken ct) =>
{
await svc.ForceAnalyzeTraderAsync(id, ct);
return Results.Ok();
});
group.MapPost("/{id:int}/watchlist", async (int id, WatchlistService svc, CancellationToken ct) =>
{
await svc.AddAsync(id, "Watched via UI", null, ct);
@@ -29,6 +29,9 @@ public interface IAnalyticsService
/// <summary>Manually trigger a trade history sync for a specific trader.</summary>
Task TriggerTradeSyncAsync(int traderId, CancellationToken ct = default);
/// <summary>Manually trigger a deep-dive analysis (PnL recalculation) for a specific trader.</summary>
Task ForceAnalyzeTraderAsync(int traderId, CancellationToken ct = default);
/// <summary>Manually add a trader by platform and wallet address.</summary>
Task<int> AddTraderAsync(string platform, string walletAddress, CancellationToken ct = default);
}
@@ -17,15 +17,22 @@ public class AnalyticsService : IAnalyticsService
private readonly IMarketRepository _marketRepo;
private readonly IDiscoveryService _discovery;
private readonly IEnumerable<IPlatformProvider> _providers;
private readonly IPositionPnLEngine _pnlEngine;
private readonly ILogger<AnalyticsService> _logger;
public AnalyticsService(ITraderRepository traderRepo, ITradeRepository tradeRepo,
IAlertRepository alertRepo, IWatchlistRepository watchlistRepo, IMarketRepository marketRepo,
IDiscoveryService discovery, IEnumerable<IPlatformProvider> providers, ILogger<AnalyticsService> logger)
IDiscoveryService discovery, IEnumerable<IPlatformProvider> providers, IPositionPnLEngine pnlEngine, ILogger<AnalyticsService> logger)
{
_traderRepo = traderRepo; _tradeRepo = tradeRepo;
_alertRepo = alertRepo; _watchlistRepo = watchlistRepo; _marketRepo = marketRepo;
_discovery = discovery; _providers = providers; _logger = logger;
_traderRepo = traderRepo;
_tradeRepo = tradeRepo;
_alertRepo = alertRepo;
_watchlistRepo = watchlistRepo;
_marketRepo = marketRepo;
_discovery = discovery;
_providers = providers;
_pnlEngine = pnlEngine;
_logger = logger;
}
public async Task<DashboardDto> GetDashboardAsync(CancellationToken ct = default)
@@ -419,6 +426,12 @@ public class AnalyticsService : IAnalyticsService
}
}
public async Task ForceAnalyzeTraderAsync(int traderId, CancellationToken ct = default)
{
_logger.LogInformation("Manually forcing deep analysis for trader {TraderId}", traderId);
await _pnlEngine.RecalculateTraderPositionsAsync(traderId, ct);
}
public async Task<int> AddTraderAsync(string platform, string walletAddress, CancellationToken ct = default)
{
if (!Enum.TryParse<PlatformType>(platform, true, out var pType))
@@ -224,11 +224,12 @@ public class PositionPnLEngine : IPositionPnLEngine
// Sync back to Trader record for quick sorting / UI display
trader.TotalPnl = overallPnl;
trader.WinRate = winRateOverall;
trader.LastAnalyzedAt = DateTime.UtcNow;
// Calculate Category Performance
var existingCatPerf = await _db.TraderCategoryPerformances
.Where(tcp => tcp.TraderId == traderId)
.ToDictionaryAsync(tcp => tcp.Category, ct);
.ToDictionaryAsync(tcp => (tcp.Category, tcp.Subcategory), ct);
var newCatPerf = CalculateCategoryPerformances(trades, tempPositions);
@@ -348,11 +349,11 @@ public class PositionPnLEngine : IPositionPnLEngine
return false;
}
private static Dictionary<MarketCategory, TraderCategoryPerformance> CalculateCategoryPerformances(
private static Dictionary<(MarketCategory, string), TraderCategoryPerformance> CalculateCategoryPerformances(
List<Trade> trades,
Dictionary<int, TraderPosition> finalPositions)
{
var result = new Dictionary<MarketCategory, TraderCategoryPerformance>();
var result = new Dictionary<(MarketCategory, string), TraderCategoryPerformance>();
var tradesByMarket = trades
.Where(t => t.MarketOutcome?.Market != null)
@@ -362,11 +363,14 @@ public class PositionPnLEngine : IPositionPnLEngine
{
var market = marketGroup.Key;
var category = market.Category;
var subcat = market.Subcategory ?? "";
if (!result.TryGetValue(category, out var perf))
var key = (category, subcat);
if (!result.TryGetValue(key, out var perf))
{
perf = new TraderCategoryPerformance { Category = category };
result[category] = perf;
perf = new TraderCategoryPerformance { Category = category, Subcategory = subcat };
result[key] = perf;
}
// Add volume
@@ -40,21 +40,23 @@ public class TraderAnalyticsWorker : BackgroundService
private async Task RunAnalyticsAsync(CancellationToken ct)
{
var cutoff30d = DateTime.UtcNow.AddDays(-30);
List<int> traderIds;
using (var scope = _services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
// Find traders active in the last 30 days
traderIds = await db.Trades
.Where(t => t.ExecutedAt >= cutoff30d)
.Select(t => t.TraderId)
.Distinct()
// Find traders who have never been analyzed, or whose last analysis was before their latest trade.
// Prioritize never-analyzed traders.
traderIds = await db.Traders
.Where(t => t.LastAnalyzedAt == null || t.Trades.Any(tr => tr.ExecutedAt > t.LastAnalyzedAt))
.OrderBy(t => t.LastAnalyzedAt == null ? 0 : 1)
.ThenBy(t => t.LastAnalyzedAt)
.Select(t => t.Id)
.Take(500) // Limit batch size to prevent long-running loops without save
.ToListAsync(ct);
}
_logger.LogInformation("Found {Count} active traders to analyze", traderIds.Count);
_logger.LogInformation("Found {Count} active or unanalyzed traders to update", traderIds.Count);
foreach (var id in traderIds)
{