Initial commit: Predictalytics solution
Clean Architecture .NET 8 solution (Domain/Application/Infrastructure/Api/Worker/WinFormsHost) for analyzing Polymarket traders for copytrading/strategy-replication candidates. Includes EF Core InitialBaseline migration and DB secrets removed from source/config in preparation for version control.
This commit is contained in:
@@ -0,0 +1,157 @@
|
||||
using Predictalytics.Domain.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Predictalytics.Infrastructure.Data;
|
||||
|
||||
public class AppDbContext : DbContext
|
||||
{
|
||||
public DbSet<Trader> Traders => Set<Trader>();
|
||||
public DbSet<Trade> Trades => Set<Trade>();
|
||||
public DbSet<Market> Markets => Set<Market>();
|
||||
public DbSet<MarketOutcome> MarketOutcomes => Set<MarketOutcome>();
|
||||
public DbSet<TraderScore> TraderScores => Set<TraderScore>();
|
||||
public DbSet<WatchlistEntry> WatchlistEntries => Set<WatchlistEntry>();
|
||||
public DbSet<Alert> Alerts => Set<Alert>();
|
||||
public DbSet<PlatformConfig> PlatformConfigs => Set<PlatformConfig>();
|
||||
public DbSet<TraderAnalytics> TraderAnalytics => Set<TraderAnalytics>();
|
||||
public DbSet<MarketAnalytics> MarketAnalytics => Set<MarketAnalytics>();
|
||||
|
||||
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder mb)
|
||||
{
|
||||
// Trader
|
||||
mb.Entity<Trader>(e =>
|
||||
{
|
||||
e.HasKey(t => t.Id);
|
||||
e.HasIndex(t => new { t.Platform, t.PlatformUserId }).IsUnique();
|
||||
e.Property(t => t.PlatformUserId).HasMaxLength(128);
|
||||
e.Property(t => t.DisplayName).HasMaxLength(256);
|
||||
e.Property(t => t.TotalPnl).HasPrecision(18, 4);
|
||||
e.Property(t => t.WinRate).HasPrecision(8, 4);
|
||||
e.HasOne(t => t.CurrentScore).WithOne(s => s.Trader)
|
||||
.HasForeignKey<TraderScore>(s => s.TraderId).OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
// Trade
|
||||
mb.Entity<Trade>(e =>
|
||||
{
|
||||
e.HasKey(t => t.Id);
|
||||
e.HasIndex(t => new { t.Platform, t.PlatformTradeId }).IsUnique();
|
||||
e.HasIndex(t => t.TraderId);
|
||||
e.HasIndex(t => t.ExecutedAt);
|
||||
e.HasIndex(t => t.AssetId);
|
||||
e.HasIndex(t => t.DbMarketId);
|
||||
// PlatformTradeId: legacy data up to 256 chars; new trades use shorter format
|
||||
e.Property(t => t.PlatformTradeId).HasMaxLength(256);
|
||||
// MarketId: Polymarket ConditionId is always 66 hex chars
|
||||
e.Property(t => t.MarketId).HasMaxLength(66);
|
||||
// AssetId: clobTokenId; Polymarket uses decimal strings up to 78 chars
|
||||
e.Property(t => t.AssetId).HasMaxLength(80);
|
||||
// Outcome: labels can be long (e.g. anime titles or sports match descriptions)
|
||||
e.Property(t => t.Outcome).HasMaxLength(128);
|
||||
// Price: 0.00–1.00 on prediction markets, 6 decimals sufficient
|
||||
e.Property(t => t.Price).HasPrecision(10, 6);
|
||||
// Size: number of shares, needs more integer digits
|
||||
e.Property(t => t.Size).HasPrecision(14, 6);
|
||||
e.Property(t => t.Amount).HasPrecision(18, 4);
|
||||
// TransactionHash: 0x + 64 hex = 66 chars
|
||||
e.Property(t => t.TransactionHash).HasMaxLength(66);
|
||||
e.HasOne(t => t.Trader).WithMany(tr => tr.Trades).HasForeignKey(t => t.TraderId);
|
||||
e.HasOne(t => t.MarketOutcome).WithMany().HasForeignKey(t => t.MarketOutcomeId)
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
e.HasOne(t => t.DbMarket).WithMany().HasForeignKey(t => t.DbMarketId)
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
});
|
||||
|
||||
// Market
|
||||
mb.Entity<Market>(e =>
|
||||
{
|
||||
e.HasKey(m => m.Id);
|
||||
e.HasIndex(m => new { m.Platform, m.PlatformMarketId }).IsUnique();
|
||||
e.Property(m => m.PlatformMarketId).HasMaxLength(256);
|
||||
e.Property(m => m.MarketSlug).HasMaxLength(512);
|
||||
e.Property(m => m.EventSlug).HasMaxLength(512);
|
||||
e.Property(m => m.Question).HasMaxLength(1024);
|
||||
e.Property(m => m.Description).HasMaxLength(4096);
|
||||
e.Property(m => m.ImageUrl).HasMaxLength(1024);
|
||||
e.Property(m => m.Category).HasMaxLength(128);
|
||||
e.Property(m => m.Volume).HasPrecision(18, 4);
|
||||
e.Property(m => m.Liquidity).HasPrecision(18, 4);
|
||||
e.HasMany(m => m.Outcomes).WithOne(o => o.Market).HasForeignKey(o => o.MarketId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
// MarketOutcome
|
||||
mb.Entity<MarketOutcome>(e =>
|
||||
{
|
||||
e.HasKey(o => o.Id);
|
||||
e.HasIndex(o => o.TokenId);
|
||||
e.HasIndex(o => new { o.MarketId, o.OutcomeIndex }).IsUnique();
|
||||
e.Property(o => o.Label).HasMaxLength(256);
|
||||
e.Property(o => o.TokenId).HasMaxLength(256);
|
||||
e.Property(o => o.CurrentPrice).HasPrecision(18, 8);
|
||||
});
|
||||
|
||||
// TraderScore
|
||||
mb.Entity<TraderScore>(e =>
|
||||
{
|
||||
e.HasKey(s => s.Id);
|
||||
e.Property(s => s.ActivityScore).HasPrecision(8, 4);
|
||||
e.Property(s => s.QualityScore).HasPrecision(8, 4);
|
||||
e.Property(s => s.CombinedScore).HasPrecision(8, 4);
|
||||
e.Property(s => s.VolumeScore).HasPrecision(8, 4);
|
||||
e.Property(s => s.TimingScore).HasPrecision(8, 4);
|
||||
});
|
||||
|
||||
// WatchlistEntry
|
||||
mb.Entity<WatchlistEntry>(e =>
|
||||
{
|
||||
e.HasKey(w => w.Id);
|
||||
e.HasIndex(w => w.TraderId).IsUnique();
|
||||
e.Property(w => w.Label).HasMaxLength(256);
|
||||
e.HasOne(w => w.Trader).WithMany(t => t.WatchlistEntries).HasForeignKey(w => w.TraderId);
|
||||
});
|
||||
|
||||
// Alert
|
||||
mb.Entity<Alert>(e =>
|
||||
{
|
||||
e.HasKey(a => a.Id);
|
||||
e.HasIndex(a => a.CreatedAt);
|
||||
e.Property(a => a.Title).HasMaxLength(512);
|
||||
e.Property(a => a.Message).HasMaxLength(4096);
|
||||
e.HasOne(a => a.Trader).WithMany().HasForeignKey(a => a.TraderId).OnDelete(DeleteBehavior.SetNull);
|
||||
});
|
||||
|
||||
// PlatformConfig
|
||||
mb.Entity<PlatformConfig>(e =>
|
||||
{
|
||||
e.HasKey(p => p.Id);
|
||||
e.Property(p => p.Name).HasMaxLength(128);
|
||||
e.Property(p => p.DisplayName).HasMaxLength(256);
|
||||
e.Property(p => p.BaseUrl).HasMaxLength(1024);
|
||||
});
|
||||
|
||||
// TraderAnalytics
|
||||
mb.Entity<TraderAnalytics>(e =>
|
||||
{
|
||||
e.HasKey(a => a.TraderId);
|
||||
e.Property(a => a.OverallPnL).HasPrecision(18, 4);
|
||||
e.Property(a => a.OverallWinRate).HasPrecision(8, 4);
|
||||
e.Property(a => a.PnL30d).HasPrecision(18, 4);
|
||||
e.Property(a => a.WinRate30d).HasPrecision(8, 4);
|
||||
e.Property(a => a.PnL7d).HasPrecision(18, 4);
|
||||
e.Property(a => a.WinRate7d).HasPrecision(8, 4);
|
||||
e.Property(a => a.PnL24h).HasPrecision(18, 4);
|
||||
e.Property(a => a.WinRate24h).HasPrecision(8, 4);
|
||||
});
|
||||
|
||||
// MarketAnalytics
|
||||
mb.Entity<MarketAnalytics>(e =>
|
||||
{
|
||||
e.HasKey(a => a.MarketId);
|
||||
e.Property(a => a.BotActivityScore).HasPrecision(8, 4);
|
||||
e.Property(a => a.AverageTradeSize).HasPrecision(18, 4);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Design;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using System.IO;
|
||||
|
||||
namespace Predictalytics.Infrastructure.Data;
|
||||
|
||||
public class AppDbContextFactory : IDesignTimeDbContextFactory<AppDbContext>
|
||||
{
|
||||
public AppDbContext CreateDbContext(string[] args)
|
||||
{
|
||||
var optionsBuilder = new DbContextOptionsBuilder<AppDbContext>();
|
||||
// Fallback for local migrations
|
||||
var connectionString = "Server=localhost;Database=Predictalytics;User=root;Password=;";
|
||||
|
||||
optionsBuilder.UseMySql(connectionString, new MySqlServerVersion(new Version(8, 0, 31)));
|
||||
|
||||
return new AppDbContext(optionsBuilder.Options);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
using Predictalytics.Domain.Entities;
|
||||
using Predictalytics.Domain.Interfaces;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Predictalytics.Infrastructure.Data.Repositories;
|
||||
|
||||
public class AlertRepository : IAlertRepository
|
||||
{
|
||||
private readonly AppDbContext _db;
|
||||
public AlertRepository(AppDbContext db) => _db = db;
|
||||
|
||||
public async Task<IReadOnlyList<Alert>> GetRecentAsync(int count = 50, bool unreadOnly = false, CancellationToken ct = default)
|
||||
{
|
||||
var q = _db.Alerts.AsQueryable();
|
||||
if (unreadOnly) q = q.Where(a => !a.IsRead);
|
||||
return await q.OrderByDescending(a => a.CreatedAt).Take(count).ToListAsync(ct);
|
||||
}
|
||||
|
||||
public async Task AddAsync(Alert alert, CancellationToken ct = default)
|
||||
{ _db.Alerts.Add(alert); await _db.SaveChangesAsync(ct); }
|
||||
|
||||
public async Task MarkAsReadAsync(int id, CancellationToken ct = default)
|
||||
{
|
||||
var a = await _db.Alerts.FindAsync(new object[] { id }, ct);
|
||||
if (a != null) { a.IsRead = true; await _db.SaveChangesAsync(ct); }
|
||||
}
|
||||
|
||||
public async Task<int> GetUnreadCountAsync(CancellationToken ct = default)
|
||||
=> await _db.Alerts.CountAsync(a => !a.IsRead, ct);
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
using Predictalytics.Domain.Entities;
|
||||
using Predictalytics.Domain.Enums;
|
||||
using Predictalytics.Domain.Interfaces;
|
||||
using System.Threading;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Predictalytics.Infrastructure.Data.Repositories;
|
||||
|
||||
public class MarketRepository : IMarketRepository
|
||||
{
|
||||
private readonly AppDbContext _db;
|
||||
private static readonly SemaphoreSlim _syncSemaphore = new(1, 1);
|
||||
|
||||
public MarketRepository(AppDbContext db) => _db = db;
|
||||
|
||||
public async Task<Market?> GetByPlatformIdAsync(PlatformType platform, string platformMarketId, CancellationToken ct = default)
|
||||
=> await _db.Markets.Include(m => m.Outcomes)
|
||||
.FirstOrDefaultAsync(m => m.Platform == platform && m.PlatformMarketId == platformMarketId, ct);
|
||||
|
||||
public async Task<MarketOutcome?> GetOutcomeByTokenIdAsync(string tokenId, CancellationToken ct = default)
|
||||
=> await _db.MarketOutcomes.Include(o => o.Market)
|
||||
.FirstOrDefaultAsync(o => o.TokenId == tokenId, ct);
|
||||
|
||||
public async Task<IReadOnlyList<MarketOutcome>> GetOutcomesByTokenIdsAsync(IEnumerable<string> tokenIds, CancellationToken ct = default)
|
||||
=> await _db.MarketOutcomes.Include(o => o.Market)
|
||||
.Where(o => tokenIds.Contains(o.TokenId))
|
||||
.ToListAsync(ct);
|
||||
|
||||
public async Task AddOrUpdateAsync(Market market, CancellationToken ct = default)
|
||||
{
|
||||
TruncateMarketStrings(market);
|
||||
|
||||
var existing = await _db.Markets.Include(m => m.Outcomes)
|
||||
.FirstOrDefaultAsync(m => m.Platform == market.Platform && m.PlatformMarketId == market.PlatformMarketId, ct);
|
||||
|
||||
if (existing != null)
|
||||
{
|
||||
UpdateMarketFields(existing, market);
|
||||
}
|
||||
else
|
||||
{
|
||||
_db.Markets.Add(market);
|
||||
}
|
||||
|
||||
await _db.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
public async Task AddOrUpdateRangeAsync(IEnumerable<Market> markets, CancellationToken ct = default)
|
||||
{
|
||||
// Deduplicate input by PlatformMarketId to avoid processing the same ID twice in one call
|
||||
var marketList = markets
|
||||
.GroupBy(m => new { m.Platform, m.PlatformMarketId })
|
||||
.Select(g => g.First())
|
||||
.ToList();
|
||||
|
||||
if (!marketList.Any()) return;
|
||||
|
||||
await _syncSemaphore.WaitAsync(ct);
|
||||
try
|
||||
{
|
||||
// Process in sub-batches to avoid too large SQL queries
|
||||
const int subBatchSize = 500;
|
||||
for (int i = 0; i < marketList.Count; i += subBatchSize)
|
||||
{
|
||||
var currentBatch = marketList.Skip(i).Take(subBatchSize).ToList();
|
||||
var platform = currentBatch.First().Platform;
|
||||
var ids = currentBatch.Select(m => m.PlatformMarketId).ToList();
|
||||
|
||||
// Fetch all existing markets in this batch at once
|
||||
var existingMarkets = await _db.Markets.Include(m => m.Outcomes)
|
||||
.Where(m => m.Platform == platform && ids.Contains(m.PlatformMarketId))
|
||||
.ToListAsync(ct);
|
||||
|
||||
var existingMap = existingMarkets.ToDictionary(m => m.PlatformMarketId);
|
||||
|
||||
foreach (var market in currentBatch)
|
||||
{
|
||||
TruncateMarketStrings(market);
|
||||
|
||||
if (existingMap.TryGetValue(market.PlatformMarketId, out var existing))
|
||||
{
|
||||
UpdateMarketFields(existing, market);
|
||||
}
|
||||
else
|
||||
{
|
||||
_db.Markets.Add(market);
|
||||
}
|
||||
}
|
||||
|
||||
await _db.SaveChangesAsync(ct);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_syncSemaphore.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateMarketFields(Market existing, Market updated)
|
||||
{
|
||||
existing.Question = updated.Question;
|
||||
existing.MarketSlug = updated.MarketSlug;
|
||||
existing.EventSlug = updated.EventSlug;
|
||||
existing.Description = updated.Description;
|
||||
existing.ImageUrl = updated.ImageUrl;
|
||||
existing.Category = updated.Category;
|
||||
existing.Volume = updated.Volume;
|
||||
existing.Liquidity = updated.Liquidity;
|
||||
existing.StartDate = updated.StartDate;
|
||||
existing.EndDate = updated.EndDate;
|
||||
existing.IsResolved = updated.IsResolved;
|
||||
existing.ResolutionOutcome = updated.ResolutionOutcome;
|
||||
existing.CreatedAt = updated.CreatedAt; // Platform creation date
|
||||
existing.LastUpdatedAt = DateTime.UtcNow;
|
||||
|
||||
// Upsert outcomes
|
||||
foreach (var newOutcome in updated.Outcomes)
|
||||
{
|
||||
var existingOutcome = existing.Outcomes
|
||||
.FirstOrDefault(o => o.OutcomeIndex == newOutcome.OutcomeIndex);
|
||||
|
||||
if (existingOutcome != null)
|
||||
{
|
||||
existingOutcome.Label = newOutcome.Label;
|
||||
existingOutcome.TokenId = newOutcome.TokenId;
|
||||
existingOutcome.CurrentPrice = newOutcome.CurrentPrice;
|
||||
}
|
||||
else
|
||||
{
|
||||
newOutcome.MarketId = existing.Id;
|
||||
existing.Outcomes.Add(newOutcome);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void TruncateMarketStrings(Market market)
|
||||
{
|
||||
market.Question = StringHelper.Truncate(market.Question, 1024) ?? "";
|
||||
market.Description = StringHelper.Truncate(market.Description, 4096);
|
||||
market.MarketSlug = StringHelper.Truncate(market.MarketSlug, 512) ?? "";
|
||||
market.EventSlug = StringHelper.Truncate(market.EventSlug, 512) ?? "";
|
||||
market.ImageUrl = StringHelper.Truncate(market.ImageUrl, 1024);
|
||||
market.Category = StringHelper.Truncate(market.Category, 128) ?? "";
|
||||
|
||||
foreach (var o in market.Outcomes)
|
||||
{
|
||||
o.Label = StringHelper.Truncate(o.Label, 256) ?? "";
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<Market>> GetActiveAsync(int count = 50, CancellationToken ct = default)
|
||||
=> await _db.Markets.Include(m => m.Outcomes)
|
||||
.Where(m => !m.IsResolved)
|
||||
.OrderByDescending(m => m.Volume)
|
||||
.Take(count)
|
||||
.ToListAsync(ct);
|
||||
|
||||
public async Task<int> GetCountAsync(CancellationToken ct = default)
|
||||
=> await _db.Markets.CountAsync(ct);
|
||||
|
||||
public async Task<IReadOnlyList<Market>> GetMarketsDueForTradeUpdateAsync(int cooldownHours, int limit, CancellationToken ct = default)
|
||||
{
|
||||
var cutoff = DateTime.UtcNow.AddHours(-cooldownHours);
|
||||
return await _db.Markets
|
||||
.Where(m => !m.IsResolved && (m.LastTradesUpdatedAt == null || m.LastTradesUpdatedAt < cutoff))
|
||||
.OrderBy(m => m.LastTradesUpdatedAt ?? DateTime.MinValue)
|
||||
.Take(limit)
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
|
||||
public async Task UpdateAsync(Market market, CancellationToken ct = default)
|
||||
{
|
||||
TruncateMarketStrings(market);
|
||||
_db.Markets.Update(market);
|
||||
await _db.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
public async Task<Market?> GetByIdAsync(int id, CancellationToken ct = default)
|
||||
=> await _db.Markets.Include(m => m.Outcomes).FirstOrDefaultAsync(m => m.Id == id, ct);
|
||||
|
||||
public async Task<IReadOnlyList<Market>> SearchAsync(string query, int take = 20, CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(query)) return Array.Empty<Market>();
|
||||
|
||||
return await _db.Markets.Include(m => m.Outcomes)
|
||||
.Where(m => m.Question.Contains(query) ||
|
||||
m.PlatformMarketId.Contains(query) ||
|
||||
m.Id.ToString() == query)
|
||||
.OrderByDescending(m => m.Volume)
|
||||
.Take(take)
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
using Predictalytics.Domain.Entities;
|
||||
using Predictalytics.Domain.Enums;
|
||||
using Predictalytics.Domain.Interfaces;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Predictalytics.Infrastructure.Data.Repositories;
|
||||
|
||||
public class TradeRepository : ITradeRepository
|
||||
{
|
||||
private readonly AppDbContext _db;
|
||||
public TradeRepository(AppDbContext db) => _db = db;
|
||||
|
||||
public async Task<Trade?> GetByPlatformTradeIdAsync(PlatformType platform, string platformTradeId, CancellationToken ct = default)
|
||||
=> await _db.Trades.FirstOrDefaultAsync(t => t.Platform == platform && t.PlatformTradeId == platformTradeId, ct);
|
||||
|
||||
public async Task<IReadOnlyList<Trade>> GetByTraderIdAsync(int traderId, int skip = 0, int take = 50, CancellationToken ct = default)
|
||||
=> await _db.Trades.Include(t => t.Trader).Where(t => t.TraderId == traderId)
|
||||
.OrderByDescending(t => t.ExecutedAt).Skip(skip).Take(take).ToListAsync(ct);
|
||||
|
||||
public async Task<IReadOnlyList<Trade>> GetByDbMarketIdAsync(int dbMarketId, int skip = 0, int take = 50, CancellationToken ct = default)
|
||||
=> await _db.Trades.Include(t => t.Trader).Where(t => t.DbMarketId == dbMarketId)
|
||||
.OrderByDescending(t => t.ExecutedAt).Skip(skip).Take(take).ToListAsync(ct);
|
||||
|
||||
public async Task<IReadOnlyList<Trade>> GetByMarketIdAsync(string platformMarketId, int skip = 0, int take = 50, CancellationToken ct = default)
|
||||
=> await _db.Trades.Include(t => t.Trader).Where(t => t.MarketId == platformMarketId)
|
||||
.OrderByDescending(t => t.ExecutedAt).Skip(skip).Take(take).ToListAsync(ct);
|
||||
|
||||
public async Task<IReadOnlyList<Trade>> GetRecentAsync(int count = 50, PlatformType? platform = null, CancellationToken ct = default)
|
||||
{
|
||||
var q = _db.Trades.Include(t => t.Trader).AsQueryable();
|
||||
if (platform.HasValue) q = q.Where(t => t.Platform == platform.Value);
|
||||
return await q.OrderByDescending(t => t.ExecutedAt).Take(count).ToListAsync(ct);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<Trade>> GetLargestAsync(int count = 5, DateTime? since = null, CancellationToken ct = default)
|
||||
{
|
||||
var q = _db.Trades.Include(t => t.Trader).AsQueryable();
|
||||
if (since.HasValue) q = q.Where(t => t.ExecutedAt >= since.Value);
|
||||
return await q.OrderByDescending(t => t.Amount).Take(count).ToListAsync(ct);
|
||||
}
|
||||
|
||||
public async Task<int> GetCountAsync(int? traderId = null, CancellationToken ct = default)
|
||||
{
|
||||
var q = _db.Trades.AsQueryable();
|
||||
if (traderId.HasValue) q = q.Where(t => t.TraderId == traderId.Value);
|
||||
return await q.CountAsync(ct);
|
||||
}
|
||||
|
||||
public async Task AddRangeAsync(IEnumerable<Trade> trades, CancellationToken ct = default)
|
||||
{
|
||||
foreach (var t in trades)
|
||||
{
|
||||
t.Outcome = StringHelper.Truncate(t.Outcome, 128) ?? "";
|
||||
t.PlatformTradeId = StringHelper.Truncate(t.PlatformTradeId, 256) ?? "";
|
||||
t.MarketId = StringHelper.Truncate(t.MarketId, 66) ?? "";
|
||||
t.AssetId = StringHelper.Truncate(t.AssetId, 80) ?? "";
|
||||
if (t.TransactionHash != null)
|
||||
t.TransactionHash = StringHelper.Truncate(t.TransactionHash, 66);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_db.Trades.AddRange(trades);
|
||||
await _db.SaveChangesAsync(ct);
|
||||
}
|
||||
catch
|
||||
{
|
||||
foreach (var t in trades)
|
||||
{
|
||||
try { _db.Entry(t).State = EntityState.Detached; } catch { }
|
||||
}
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<decimal> GetTotalVolumeAsync(DateTime? since = null, CancellationToken ct = default)
|
||||
{
|
||||
var q = _db.Trades.AsQueryable();
|
||||
if (since.HasValue) q = q.Where(t => t.ExecutedAt >= since.Value);
|
||||
return await q.SumAsync(t => t.Amount, ct);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<Trade>> GetOrphanedTradesAsync(int limit, CancellationToken ct = default)
|
||||
{
|
||||
return await _db.Trades
|
||||
.Where(t => t.MarketOutcomeId == null && !string.IsNullOrEmpty(t.AssetId))
|
||||
.OrderByDescending(t => t.ExecutedAt)
|
||||
.Take(limit)
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
|
||||
public async Task<HashSet<string>> GetKnownPlatformTradeIdsAsync(PlatformType platform, int traderId, CancellationToken ct = default)
|
||||
{
|
||||
var ids = await _db.Trades
|
||||
.Where(t => t.Platform == platform && t.TraderId == traderId)
|
||||
.Select(t => t.PlatformTradeId)
|
||||
.ToListAsync(ct);
|
||||
return new HashSet<string>(ids);
|
||||
}
|
||||
|
||||
public async Task UpdateAsync(Trade trade, CancellationToken ct = default)
|
||||
{
|
||||
trade.Outcome = StringHelper.Truncate(trade.Outcome, 128) ?? "";
|
||||
trade.PlatformTradeId = StringHelper.Truncate(trade.PlatformTradeId, 256) ?? "";
|
||||
trade.MarketId = StringHelper.Truncate(trade.MarketId, 66) ?? "";
|
||||
trade.AssetId = StringHelper.Truncate(trade.AssetId, 80) ?? "";
|
||||
|
||||
_db.Trades.Update(trade);
|
||||
await _db.SaveChangesAsync(ct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
using Predictalytics.Domain.Entities;
|
||||
using Predictalytics.Domain.Enums;
|
||||
using Predictalytics.Domain.Interfaces;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
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)
|
||||
=> await _db.Traders.Include(t => t.CurrentScore).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)
|
||||
.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)
|
||||
.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 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)
|
||||
{
|
||||
// Prioritize:
|
||||
// 1. Traders needing initial import (IsInitialImportComplete == false)
|
||||
// 2. Traders where LastTradesUpdatedAt < cutoff (cooldownHours)
|
||||
|
||||
var cutoff = DateTime.UtcNow.AddHours(-cooldownHours);
|
||||
|
||||
return await _db.Traders
|
||||
.Where(t => !t.IsInitialImportComplete || t.LastTradesUpdatedAt == null || t.LastTradesUpdatedAt < cutoff)
|
||||
.OrderBy(t => t.IsInitialImportComplete) // false (0) comes before true (1)
|
||||
.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.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>> 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using Predictalytics.Domain.Entities;
|
||||
using Predictalytics.Domain.Interfaces;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Predictalytics.Infrastructure.Data.Repositories;
|
||||
|
||||
public class WatchlistRepository : IWatchlistRepository
|
||||
{
|
||||
private readonly AppDbContext _db;
|
||||
public WatchlistRepository(AppDbContext db) => _db = db;
|
||||
|
||||
public async Task<IReadOnlyList<WatchlistEntry>> GetAllAsync(CancellationToken ct = default)
|
||||
=> await _db.WatchlistEntries.Include(w => w.Trader).ToListAsync(ct);
|
||||
|
||||
public async Task<WatchlistEntry?> GetByTraderIdAsync(int traderId, CancellationToken ct = default)
|
||||
=> await _db.WatchlistEntries.FirstOrDefaultAsync(w => w.TraderId == traderId, ct);
|
||||
|
||||
public async Task AddAsync(WatchlistEntry entry, CancellationToken ct = default)
|
||||
{ _db.WatchlistEntries.Add(entry); await _db.SaveChangesAsync(ct); }
|
||||
|
||||
public async Task RemoveAsync(int id, CancellationToken ct = default)
|
||||
{
|
||||
var e = await _db.WatchlistEntries.FindAsync(new object[] { id }, ct);
|
||||
if (e != null) { _db.WatchlistEntries.Remove(e); await _db.SaveChangesAsync(ct); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace Predictalytics.Infrastructure.Data;
|
||||
|
||||
public static class StringHelper
|
||||
{
|
||||
public static string? Truncate(string? value, int maxLength)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value)) return value;
|
||||
return value.Length <= maxLength ? value : value[..maxLength];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user