Files
Predictalytics/src/Predictalytics.Infrastructure/Data/Repositories/TraderRepository.cs
T
Richard 16431f38a5 feat: implement Part D and E from FIXPLAN
- D1/D2/D2c: Added TraderTraits entity, TraderTraitCalculator, Market Return Metrics (MedianWin, AvgWin, etc.), and trait filters
- D3: Implemented HF-Trader Tiering via IngestMode (Full, Aggregated, SnapshotOnly) and updated TradeHistoryWorker to respect tiers
- E1-E5: Added MasterStatus to Trader, TraderWindowMetrics for rolling analytics, Fingerprint metrics (PriceBandProfile, P50/P90), Copyability aggregates (Volume, Drift, Edge)
- E6: Implemented GET /api/traders/{id}/profile and GET /api/traders/correlation
- Replaced FIXPLAN-2026-07-09.md with FIXPLAN-TODO.md and FIXPLAN-DONE.md
- Cleaned up API docs and plan to use generic terms (removed hardcoded PolyTrader references)
- Added respective EF Core Migrations
2026-07-14 09:04:31 +02:00

171 lines
7.5 KiB
C#

using Predictalytics.Domain.Entities;
using Predictalytics.Domain.Enums;
using Predictalytics.Domain.Interfaces;
using Microsoft.EntityFrameworkCore;
using System.Text;
namespace Predictalytics.Infrastructure.Data.Repositories;
public class TraderRepository : ITraderRepository
{
private readonly AppDbContext _db;
public TraderRepository(AppDbContext db) => _db = db;
public async Task<Trader?> GetByIdAsync(int id, CancellationToken ct = default)
{
return await _db.Traders
.Include(t => t.CurrentScore)
.Include(t => t.Analytics)
.Include(t => t.CategoryPerformances)
.Include(t => t.Traits)
.FirstOrDefaultAsync(t => t.Id == id, ct);
}
public async Task<Trader?> GetByPlatformIdAsync(PlatformType platform, string platformUserId, CancellationToken ct = default)
=> await _db.Traders.Include(t => t.CurrentScore).Include(t => t.CategoryPerformances)
.FirstOrDefaultAsync(t => t.Platform == platform && t.PlatformUserId == platformUserId, ct);
public async Task<IReadOnlyList<Trader>> GetAllAsync(PlatformType? platform = null, int skip = 0, int take = 50, CancellationToken ct = default)
{
var q = _db.Traders
.Include(t => t.CurrentScore)
.Include(t => t.Analytics)
.Include(t => t.Traits)
.AsQueryable();
if (platform.HasValue) q = q.Where(t => t.Platform == platform.Value);
// Sort by CombinedScore, then by PnL as fallback
return await q.OrderByDescending(t => t.CurrentScore != null ? t.CurrentScore.CombinedScore : 0)
.ThenByDescending(t => t.TotalPnl)
.Skip(skip).Take(take).ToListAsync(ct);
}
public async Task<IReadOnlyList<Trader>> GetWatchlistedAsync(CancellationToken ct = default)
=> await _db.Traders.Include(t => t.CurrentScore).Include(t => t.WatchlistEntries)
.Where(t => t.WatchlistEntries.Any()).ToListAsync(ct);
public async Task<IReadOnlyList<Trader>> GetTopByScoreAsync(int count = 20, CancellationToken ct = default)
=> await _db.Traders.Include(t => t.CurrentScore).Include(t => t.Analytics)
.OrderByDescending(t => t.CurrentScore!.CombinedScore).Take(count).ToListAsync(ct);
public async Task<IReadOnlyList<Trader>> GetTopByPnLAsync(int count = 5, DateTime? since = null, CancellationToken ct = default)
{
var q = _db.Traders.Include(t => t.CurrentScore).Include(t => t.Analytics).AsQueryable();
// If 'since' is 7 days ago, try to use PnL7d from Analytics
if (since.HasValue && (DateTime.UtcNow - since.Value).TotalDays >= 6.9)
{
return await q.OrderByDescending(t => t.Analytics != null ? t.Analytics.PnL7d : t.TotalPnl)
.Take(count).ToListAsync(ct);
}
return await q.OrderByDescending(t => t.TotalPnl).Take(count).ToListAsync(ct);
}
public async Task<int> GetCountAsync(PlatformType? platform = null, CancellationToken ct = default)
{
var q = _db.Traders.AsQueryable();
if (platform.HasValue) q = q.Where(t => t.Platform == platform.Value);
return await q.CountAsync(ct);
}
public async Task AddAsync(Trader trader, CancellationToken ct = default)
{ _db.Traders.Add(trader); await _db.SaveChangesAsync(ct); }
public async Task UpdateAsync(Trader trader, CancellationToken ct = default)
{
_db.Traders.Update(trader);
await _db.SaveChangesAsync(ct);
}
public async Task UpdateRanksAsync(IEnumerable<(int TraderId, int Rank)> ranks, CancellationToken ct = default)
{
// Batch update ranks using raw SQL to avoid N+1 and loading entities
var sql = new StringBuilder();
sql.AppendLine("UPDATE TraderScores SET Rank = CASE TraderId");
var ids = new List<int>();
foreach (var r in ranks)
{
sql.AppendLine($"WHEN {r.TraderId} THEN {r.Rank}");
ids.Add(r.TraderId);
}
sql.AppendLine("ELSE Rank END WHERE TraderId IN (" + string.Join(",", ids) + ");");
if (ids.Count > 0)
{
await _db.Database.ExecuteSqlRawAsync(sql.ToString(), ct);
}
}
public async Task DeleteAsync(int id, CancellationToken ct = default)
{
var t = await _db.Traders.FindAsync(new object[] { id }, ct);
if (t != null) { _db.Traders.Remove(t); await _db.SaveChangesAsync(ct); }
}
public async Task<IReadOnlyList<Trader>> GetTradersDueForTradeUpdateAsync(int cooldownHours = 12, int take = 20, CancellationToken ct = default)
{
var normalCutoff = DateTime.UtcNow.AddHours(-cooldownHours);
var priorityCutoff = DateTime.UtcNow.AddHours(-1); // Sync priority traders more often, but not continuously
return await _db.Traders
.Include(t => t.WatchlistEntries)
.Where(t => t.LastTradesUpdatedAt == null ||
(!t.IsInitialImportComplete) ||
((!t.IsAutoDiscovered || t.WatchlistEntries.Any()) && t.LastTradesUpdatedAt < priorityCutoff) ||
(t.IsAutoDiscovered && t.LastTradesUpdatedAt < normalCutoff))
.OrderBy(t => t.IsAutoDiscovered) // Manual first (false = 0)
.ThenByDescending(t => t.WatchlistEntries.Any()) // Watchlisted next (true = 1)
.ThenBy(t => t.IsInitialImportComplete) // New ones next (false = 0)
.ThenBy(t => t.LastTradesUpdatedAt ?? DateTime.MinValue) // Oldest first
.Take(take)
.ToListAsync(ct);
}
public async Task<IReadOnlyList<Trader>> GetTradersForCleanupAsync(DateTime inactiveSince, DateTime errorSince, int take = 50, CancellationToken ct = default)
{
return await _db.Traders
.Where(t => t.IsAutoDiscovered && !t.WatchlistEntries.Any())
.Where(t => (t.LastPolledAt != null && t.LastPolledAt < inactiveSince) ||
(t.LastApiErrorAt != null && t.LastApiErrorAt < errorSince))
.OrderBy(t => t.LastApiErrorAt ?? DateTime.MaxValue) // Prioritize errors first
.Take(take)
.ToListAsync(ct);
}
public async Task<IReadOnlyList<Trader>> GetTradersForPollingAsync(int take, CancellationToken ct = default)
{
return await _db.Traders
.OrderBy(t => t.LastPolledAt)
.Take(take)
.ToListAsync(ct);
}
public async Task<IReadOnlyList<Trader>> SearchAsync(string query, int take = 20, CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(query)) return Array.Empty<Trader>();
return await _db.Traders
.Include(t => t.CurrentScore)
.Include(t => t.Analytics)
.Where(t => t.DisplayName.Contains(query) ||
t.PlatformUserId.Contains(query) ||
t.Id.ToString() == query)
.OrderByDescending(t => t.CurrentScore != null ? t.CurrentScore.CombinedScore : 0)
.ThenByDescending(t => t.TotalPnl)
.Take(take)
.ToListAsync(ct);
}
public async Task<IReadOnlyList<TraderPosition>> GetPositionsAsync(int traderId, CancellationToken ct = default)
{
return await _db.TraderPositions
.Include(p => p.MarketOutcome)
.ThenInclude(o => o.Market)
.Where(p => p.TraderId == traderId && (p.SharesHeld > 0 || p.RealizedPnl != 0))
.OrderByDescending(p => p.LastTradeExecutedAt ?? DateTime.MinValue)
.ToListAsync(ct);
}
}