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:
Richard
2026-07-01 19:53:29 +02:00
commit afb251acfc
107 changed files with 9613 additions and 0 deletions
@@ -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.001.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];
}
}
@@ -0,0 +1,136 @@
using Predictalytics.Application.Interfaces;
using Predictalytics.Application.Services;
using Predictalytics.Domain.Interfaces;
using Predictalytics.Infrastructure.Data;
using Predictalytics.Infrastructure.Data.Repositories;
using Predictalytics.Infrastructure.Providers.Azuro;
using Predictalytics.Infrastructure.Providers.Limitless;
using Predictalytics.Infrastructure.Providers.Polymarket;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
namespace Predictalytics.Infrastructure;
public static class DependencyInjection
{
public static IServiceCollection AddPredictalytics(this IServiceCollection services, IConfiguration configuration, string? connectionStringOverride = null, bool dbDebug = false)
{
if (dbDebug) Serilog.Log.Warning(">>> INFRASTRUCTURE: AddPredictalytics STARTING");
// MySQL / EF Core
var connectionString = connectionStringOverride;
if (string.IsNullOrWhiteSpace(connectionString))
{
connectionString = configuration.GetConnectionString("DefaultConnection")
?? "Server=localhost;Database=Predictalytics_dev;User=root;Password=;";
}
// Use MySqlConnectionStringBuilder to ensure valid format and parse components
var csBuilder = new MySqlConnector.MySqlConnectionStringBuilder(connectionString);
// Final safety check
if (string.IsNullOrWhiteSpace(csBuilder.Database))
{
Serilog.Log.Error("❌ INVALID CONNECTION STRING: Database name is empty! (Input length: {Length})", connectionString.Length);
throw new InvalidOperationException("The connection string is missing a valid 'Database' parameter.");
}
var maskedCs = csBuilder.ConnectionString.Replace(csBuilder.Password, "****");
if (dbDebug)
{
Serilog.Log.Warning("🗄️ Initializing database connection: {ConnectionString}", maskedCs);
Serilog.Log.Warning("🗄️ Target Server: {Server}, Database: {Database}", csBuilder.Server, csBuilder.Database);
}
// Explicitly register the connection string so we can use it elsewhere if needed
services.AddSingleton(csBuilder.ConnectionString);
services.AddDbContext<AppDbContext>(options =>
{
// Log exactly what is being used at the moment of configuration
if (dbDebug) Serilog.Log.Warning("🛠️ EF: Configuring AppDbContext. Target DB: '{Database}'", csBuilder.Database);
options.UseMySql(csBuilder.ConnectionString, new MySqlServerVersion(new Version(8, 0, 31)),
mysql => mysql.EnableRetryOnFailure(3, TimeSpan.FromSeconds(10), null));
});
// Repositories
services.AddScoped<ITraderRepository, TraderRepository>();
services.AddScoped<ITradeRepository, TradeRepository>();
services.AddScoped<IMarketRepository, MarketRepository>();
services.AddScoped<IWatchlistRepository, WatchlistRepository>();
services.AddScoped<IAlertRepository, AlertRepository>();
// Application Services
services.AddScoped<IScoringService, ScoringService>();
services.AddScoped<IDiscoveryService, DiscoveryService>();
services.AddScoped<IAlertService, AlertService>();
services.AddScoped<IAnalyticsService, AnalyticsService>();
services.AddScoped<WatchlistService>();
services.AddSingleton<IRateLimiter, RateLimiterService>();
services.AddSingleton<IPlatformStatisticsService, PlatformStatisticsService>();
// Platform Providers
services.AddHttpClient();
services.AddHttpClient("LimitlessApi", c =>
{
c.BaseAddress = new Uri("https://api.limitless.exchange/");
c.DefaultRequestHeaders.Add("Accept", "application/json");
c.Timeout = TimeSpan.FromSeconds(60);
});
services.AddSingleton<PolymarketApiClient>();
services.AddSingleton<LimitlessApiClient>();
services.AddSingleton<IPlatformProvider, PolymarketProvider>();
services.AddSingleton<IPlatformProvider, LimitlessProvider>();
services.AddSingleton<IPlatformProvider, AzuroProvider>();
return services;
}
/// <summary>
/// Applies pending EF Core migrations and seeds default platform rows.
/// Requires the target database to already be "stamped" with the InitialBaseline
/// migration in __EFMigrationsHistory (see UMSETZUNGSPLAN.md, section B1) if it was
/// previously created via the old EnsureCreated + manual ALTER approach.
/// </summary>
public static async Task EnsureDatabaseAsync(IServiceProvider services, bool dbDebug = false)
{
using var scope = services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
try
{
if (dbDebug)
{
var maskedConnStr = System.Text.RegularExpressions.Regex.Replace(
db.Database.GetDbConnection().ConnectionString ?? "NULL", "Password=[^;]+", "Password=****");
Serilog.Log.Warning("🔍 DEBUG: Applying EF Core migrations. ConnectionString: {CS}", maskedConnStr);
}
await db.Database.MigrateAsync();
if (dbDebug) Serilog.Log.Warning("✅ DEBUG: Migrations applied successfully.");
// Seed default platform rows (idempotent)
var conn = db.Database.GetDbConnection();
if (conn.State != System.Data.ConnectionState.Open) await conn.OpenAsync();
using var seedPlatform = conn.CreateCommand();
seedPlatform.CommandText = @"
INSERT IGNORE INTO `PlatformConfigs` (`Id`, `Name`, `DisplayName`, `IsActive`, `CreatedAt`, `UpdatedAt`) VALUES
(0, 'Unknown', 'Unknown Platform', 1, UTC_TIMESTAMP(), UTC_TIMESTAMP()),
(1, 'Polymarket', 'Polymarket', 1, UTC_TIMESTAMP(), UTC_TIMESTAMP()),
(2, 'Azuro', 'Azuro', 1, UTC_TIMESTAMP(), UTC_TIMESTAMP()),
(3, 'Limitless', 'Limitless', 1, UTC_TIMESTAMP(), UTC_TIMESTAMP());";
await seedPlatform.ExecuteNonQueryAsync();
await conn.CloseAsync();
}
catch (Exception ex)
{
Serilog.Log.Warning("⚠️ Could not connect to database or apply migrations: {Message}. Background workers will retry connection automatically.", ex.Message);
}
}
}
@@ -0,0 +1,19 @@
using Serilog.Context;
namespace Predictalytics.Infrastructure.Logging;
/// <summary>
/// Helper to push platform context into Serilog LogContext for platform-filtered file sinks.
/// Usage: using (PlatformLogContext.Push("Polymarket")) { ... }
/// </summary>
public static class PlatformLogContext
{
/// <summary>
/// Pushes the platform name to the Serilog LogContext.
/// Dispose the returned IDisposable to remove it.
/// </summary>
public static IDisposable Push(string platformName)
{
return LogContext.PushProperty("Platform", platformName);
}
}
@@ -0,0 +1,27 @@
using Serilog.Core;
using Serilog.Events;
namespace Predictalytics.Infrastructure.Logging;
/// <summary>
/// Custom Serilog sink that delegates log writes to a provided action.
/// The action is responsible for marshaling to the correct thread (e.g. UI thread).
/// </summary>
public class RichTextBoxSink : ILogEventSink
{
private readonly Action<string, LogEventLevel> _writeAction;
public RichTextBoxSink(Action<string, LogEventLevel> writeAction)
{
_writeAction = writeAction;
}
public void Emit(LogEvent logEvent)
{
var message = $"[{logEvent.Timestamp:HH:mm:ss}] [{logEvent.Level.ToString()[..3].ToUpper()}] {logEvent.RenderMessage()}";
if (logEvent.Exception != null)
message += $"\n ⚠ {logEvent.Exception.Message}";
_writeAction(message + "\n", logEvent.Level);
}
}
@@ -0,0 +1,641 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Predictalytics.Infrastructure.Data;
#nullable disable
namespace Predictalytics.Infrastructure.Migrations
{
[DbContext(typeof(AppDbContext))]
[Migration("20260701102311_InitialBaseline")]
partial class InitialBaseline
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "8.0.11")
.HasAnnotation("Relational:MaxIdentifierLength", 64);
MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder);
modelBuilder.Entity("Predictalytics.Domain.Entities.Alert", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<bool>("IsRead")
.HasColumnType("tinyint(1)");
b.Property<string>("Message")
.IsRequired()
.HasMaxLength(4096)
.HasColumnType("varchar(4096)");
b.Property<int>("Platform")
.HasColumnType("int");
b.Property<int>("Severity")
.HasColumnType("int");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("varchar(512)");
b.Property<int?>("TraderId")
.HasColumnType("int");
b.Property<int>("Type")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("CreatedAt");
b.HasIndex("TraderId");
b.ToTable("Alerts");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.Market", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
b.Property<string>("Category")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("varchar(128)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<DateTime>("DbCreatedAt")
.HasColumnType("datetime(6)");
b.Property<string>("Description")
.HasMaxLength(4096)
.HasColumnType("varchar(4096)");
b.Property<DateTime?>("EndDate")
.HasColumnType("datetime(6)");
b.Property<string>("EventSlug")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("varchar(512)");
b.Property<string>("ImageUrl")
.HasMaxLength(1024)
.HasColumnType("varchar(1024)");
b.Property<bool>("IsResolved")
.HasColumnType("tinyint(1)");
b.Property<DateTime?>("LastTradesUpdatedAt")
.HasColumnType("datetime(6)");
b.Property<DateTime?>("LastUpdatedAt")
.HasColumnType("datetime(6)");
b.Property<decimal>("Liquidity")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<string>("MarketSlug")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("varchar(512)");
b.Property<int>("Platform")
.HasColumnType("int");
b.Property<string>("PlatformMarketId")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("varchar(256)");
b.Property<string>("Question")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("varchar(1024)");
b.Property<string>("ResolutionOutcome")
.HasColumnType("longtext");
b.Property<DateTime?>("StartDate")
.HasColumnType("datetime(6)");
b.Property<decimal>("Volume")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.HasKey("Id");
b.HasIndex("Platform", "PlatformMarketId")
.IsUnique();
b.ToTable("Markets");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.MarketAnalytics", b =>
{
b.Property<int>("MarketId")
.HasColumnType("int");
b.Property<decimal>("AverageTradeSize")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<decimal>("BotActivityScore")
.HasPrecision(8, 4)
.HasColumnType("decimal(8,4)");
b.Property<DateTime>("LastCalculatedAt")
.HasColumnType("datetime(6)");
b.Property<int>("UniqueTradersCount")
.HasColumnType("int");
b.HasKey("MarketId");
b.ToTable("MarketAnalytics");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.MarketOutcome", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
b.Property<decimal>("CurrentPrice")
.HasPrecision(18, 8)
.HasColumnType("decimal(18,8)");
b.Property<string>("Label")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("varchar(256)");
b.Property<int>("MarketId")
.HasColumnType("int");
b.Property<int>("OutcomeIndex")
.HasColumnType("int");
b.Property<string>("TokenId")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("varchar(256)");
b.HasKey("Id");
b.HasIndex("TokenId");
b.HasIndex("MarketId", "OutcomeIndex")
.IsUnique();
b.ToTable("MarketOutcomes");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.PlatformConfig", b =>
{
b.Property<int>("Id")
.HasColumnType("int");
b.Property<string>("BaseUrl")
.HasMaxLength(1024)
.HasColumnType("varchar(1024)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("varchar(256)");
b.Property<bool>("IsActive")
.HasColumnType("tinyint(1)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("varchar(128)");
b.Property<string>("SettingsJson")
.HasColumnType("longtext");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime(6)");
b.HasKey("Id");
b.ToTable("PlatformConfigs");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.Trade", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<long>("Id"));
b.Property<decimal>("Amount")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<string>("AssetId")
.IsRequired()
.HasMaxLength(80)
.HasColumnType("varchar(80)");
b.Property<int?>("DbMarketId")
.HasColumnType("int");
b.Property<DateTime>("ExecutedAt")
.HasColumnType("datetime(6)");
b.Property<string>("MarketId")
.IsRequired()
.HasMaxLength(66)
.HasColumnType("varchar(66)");
b.Property<int?>("MarketOutcomeId")
.HasColumnType("int");
b.Property<string>("Outcome")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("varchar(128)");
b.Property<int>("Platform")
.HasColumnType("int");
b.Property<string>("PlatformTradeId")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("varchar(256)");
b.Property<decimal>("Price")
.HasPrecision(10, 6)
.HasColumnType("decimal(10,6)");
b.Property<int>("Side")
.HasColumnType("int");
b.Property<decimal>("Size")
.HasPrecision(14, 6)
.HasColumnType("decimal(14,6)");
b.Property<int>("TraderId")
.HasColumnType("int");
b.Property<string>("TransactionHash")
.HasMaxLength(66)
.HasColumnType("varchar(66)");
b.HasKey("Id");
b.HasIndex("AssetId");
b.HasIndex("DbMarketId");
b.HasIndex("ExecutedAt");
b.HasIndex("MarketOutcomeId");
b.HasIndex("TraderId");
b.HasIndex("Platform", "PlatformTradeId")
.IsUnique();
b.ToTable("Trades");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.Trader", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("varchar(256)");
b.Property<bool>("IsAutoDiscovered")
.HasColumnType("tinyint(1)");
b.Property<bool>("IsInitialImportComplete")
.HasColumnType("tinyint(1)");
b.Property<bool>("IsSuspectedBot")
.HasColumnType("tinyint(1)");
b.Property<DateTime?>("LastApiErrorAt")
.HasColumnType("datetime(6)");
b.Property<DateTime?>("LastPolledAt")
.HasColumnType("datetime(6)");
b.Property<DateTime?>("LastTradesUpdatedAt")
.HasColumnType("datetime(6)");
b.Property<int?>("ManualPriorityOverride")
.HasColumnType("int");
b.Property<string>("Notes")
.HasColumnType("longtext");
b.Property<int>("Platform")
.HasColumnType("int");
b.Property<string>("PlatformUserId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("varchar(128)");
b.Property<int>("Strategy")
.HasColumnType("int");
b.Property<int>("Tier")
.HasColumnType("int");
b.Property<decimal>("TotalPnl")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<int>("TotalTrades")
.HasColumnType("int");
b.Property<decimal>("WinRate")
.HasPrecision(8, 4)
.HasColumnType("decimal(8,4)");
b.HasKey("Id");
b.HasIndex("Platform", "PlatformUserId")
.IsUnique();
b.ToTable("Traders");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.TraderAnalytics", b =>
{
b.Property<int>("TraderId")
.HasColumnType("int");
b.Property<DateTime>("LastCalculatedAt")
.HasColumnType("datetime(6)");
b.Property<decimal>("OverallPnL")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<decimal>("OverallWinRate")
.HasPrecision(8, 4)
.HasColumnType("decimal(8,4)");
b.Property<decimal>("PnL24h")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<decimal>("PnL30d")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<decimal>("PnL7d")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<decimal>("WinRate24h")
.HasPrecision(8, 4)
.HasColumnType("decimal(8,4)");
b.Property<decimal>("WinRate30d")
.HasPrecision(8, 4)
.HasColumnType("decimal(8,4)");
b.Property<decimal>("WinRate7d")
.HasPrecision(8, 4)
.HasColumnType("decimal(8,4)");
b.HasKey("TraderId");
b.ToTable("TraderAnalytics");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.TraderScore", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
b.Property<decimal>("ActivityScore")
.HasPrecision(8, 4)
.HasColumnType("decimal(8,4)");
b.Property<DateTime>("CalculatedAt")
.HasColumnType("datetime(6)");
b.Property<decimal>("CombinedScore")
.HasPrecision(8, 4)
.HasColumnType("decimal(8,4)");
b.Property<decimal>("QualityScore")
.HasPrecision(8, 4)
.HasColumnType("decimal(8,4)");
b.Property<int>("Rank")
.HasColumnType("int");
b.Property<decimal>("TimingScore")
.HasPrecision(8, 4)
.HasColumnType("decimal(8,4)");
b.Property<int>("TraderId")
.HasColumnType("int");
b.Property<decimal>("VolumeScore")
.HasPrecision(8, 4)
.HasColumnType("decimal(8,4)");
b.HasKey("Id");
b.HasIndex("TraderId")
.IsUnique();
b.ToTable("TraderScores");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.WatchlistEntry", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
b.Property<DateTime>("AddedAt")
.HasColumnType("datetime(6)");
b.Property<bool>("AlertsEnabled")
.HasColumnType("tinyint(1)");
b.Property<string>("Label")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("varchar(256)");
b.Property<string>("Notes")
.HasColumnType("longtext");
b.Property<int>("TraderId")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("TraderId")
.IsUnique();
b.ToTable("WatchlistEntries");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.Alert", b =>
{
b.HasOne("Predictalytics.Domain.Entities.Trader", "Trader")
.WithMany()
.HasForeignKey("TraderId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("Trader");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.MarketAnalytics", b =>
{
b.HasOne("Predictalytics.Domain.Entities.Market", "Market")
.WithOne("Analytics")
.HasForeignKey("Predictalytics.Domain.Entities.MarketAnalytics", "MarketId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Market");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.MarketOutcome", b =>
{
b.HasOne("Predictalytics.Domain.Entities.Market", "Market")
.WithMany("Outcomes")
.HasForeignKey("MarketId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Market");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.Trade", b =>
{
b.HasOne("Predictalytics.Domain.Entities.Market", "DbMarket")
.WithMany()
.HasForeignKey("DbMarketId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("Predictalytics.Domain.Entities.MarketOutcome", "MarketOutcome")
.WithMany()
.HasForeignKey("MarketOutcomeId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("Predictalytics.Domain.Entities.Trader", "Trader")
.WithMany("Trades")
.HasForeignKey("TraderId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("DbMarket");
b.Navigation("MarketOutcome");
b.Navigation("Trader");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.TraderAnalytics", b =>
{
b.HasOne("Predictalytics.Domain.Entities.Trader", "Trader")
.WithOne("Analytics")
.HasForeignKey("Predictalytics.Domain.Entities.TraderAnalytics", "TraderId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Trader");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.TraderScore", b =>
{
b.HasOne("Predictalytics.Domain.Entities.Trader", "Trader")
.WithOne("CurrentScore")
.HasForeignKey("Predictalytics.Domain.Entities.TraderScore", "TraderId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Trader");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.WatchlistEntry", b =>
{
b.HasOne("Predictalytics.Domain.Entities.Trader", "Trader")
.WithMany("WatchlistEntries")
.HasForeignKey("TraderId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Trader");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.Market", b =>
{
b.Navigation("Analytics");
b.Navigation("Outcomes");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.Trader", b =>
{
b.Navigation("Analytics");
b.Navigation("CurrentScore");
b.Navigation("Trades");
b.Navigation("WatchlistEntries");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,431 @@
using System;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Predictalytics.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class InitialBaseline : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterDatabase()
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateTable(
name: "Markets",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
Platform = table.Column<int>(type: "int", nullable: false),
PlatformMarketId = table.Column<string>(type: "varchar(256)", maxLength: 256, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
MarketSlug = table.Column<string>(type: "varchar(512)", maxLength: 512, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
EventSlug = table.Column<string>(type: "varchar(512)", maxLength: 512, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
Description = table.Column<string>(type: "varchar(4096)", maxLength: 4096, nullable: true)
.Annotation("MySql:CharSet", "utf8mb4"),
ImageUrl = table.Column<string>(type: "varchar(1024)", maxLength: 1024, nullable: true)
.Annotation("MySql:CharSet", "utf8mb4"),
Question = table.Column<string>(type: "varchar(1024)", maxLength: 1024, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
Category = table.Column<string>(type: "varchar(128)", maxLength: 128, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
Volume = table.Column<decimal>(type: "decimal(18,4)", precision: 18, scale: 4, nullable: false),
Liquidity = table.Column<decimal>(type: "decimal(18,4)", precision: 18, scale: 4, nullable: false),
StartDate = table.Column<DateTime>(type: "datetime(6)", nullable: true),
EndDate = table.Column<DateTime>(type: "datetime(6)", nullable: true),
IsResolved = table.Column<bool>(type: "tinyint(1)", nullable: false),
ResolutionOutcome = table.Column<string>(type: "longtext", nullable: true)
.Annotation("MySql:CharSet", "utf8mb4"),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
DbCreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
LastUpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
LastTradesUpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Markets", x => x.Id);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateTable(
name: "PlatformConfigs",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false),
Name = table.Column<string>(type: "varchar(128)", maxLength: 128, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
DisplayName = table.Column<string>(type: "varchar(256)", maxLength: 256, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
IsActive = table.Column<bool>(type: "tinyint(1)", nullable: false),
BaseUrl = table.Column<string>(type: "varchar(1024)", maxLength: 1024, nullable: true)
.Annotation("MySql:CharSet", "utf8mb4"),
SettingsJson = table.Column<string>(type: "longtext", nullable: true)
.Annotation("MySql:CharSet", "utf8mb4"),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_PlatformConfigs", x => x.Id);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateTable(
name: "Traders",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
Platform = table.Column<int>(type: "int", nullable: false),
PlatformUserId = table.Column<string>(type: "varchar(128)", maxLength: 128, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
DisplayName = table.Column<string>(type: "varchar(256)", maxLength: 256, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
Notes = table.Column<string>(type: "longtext", nullable: true)
.Annotation("MySql:CharSet", "utf8mb4"),
IsAutoDiscovered = table.Column<bool>(type: "tinyint(1)", nullable: false),
Tier = table.Column<int>(type: "int", nullable: false),
Strategy = table.Column<int>(type: "int", nullable: false),
IsSuspectedBot = table.Column<bool>(type: "tinyint(1)", nullable: false),
ManualPriorityOverride = table.Column<int>(type: "int", nullable: true),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
LastPolledAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
LastTradesUpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
IsInitialImportComplete = table.Column<bool>(type: "tinyint(1)", nullable: false),
LastApiErrorAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
TotalPnl = table.Column<decimal>(type: "decimal(18,4)", precision: 18, scale: 4, nullable: false),
WinRate = table.Column<decimal>(type: "decimal(8,4)", precision: 8, scale: 4, nullable: false),
TotalTrades = table.Column<int>(type: "int", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Traders", x => x.Id);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateTable(
name: "MarketAnalytics",
columns: table => new
{
MarketId = table.Column<int>(type: "int", nullable: false),
LastCalculatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
BotActivityScore = table.Column<decimal>(type: "decimal(8,4)", precision: 8, scale: 4, nullable: false),
UniqueTradersCount = table.Column<int>(type: "int", nullable: false),
AverageTradeSize = table.Column<decimal>(type: "decimal(18,4)", precision: 18, scale: 4, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_MarketAnalytics", x => x.MarketId);
table.ForeignKey(
name: "FK_MarketAnalytics_Markets_MarketId",
column: x => x.MarketId,
principalTable: "Markets",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateTable(
name: "MarketOutcomes",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
MarketId = table.Column<int>(type: "int", nullable: false),
Label = table.Column<string>(type: "varchar(256)", maxLength: 256, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
OutcomeIndex = table.Column<int>(type: "int", nullable: false),
TokenId = table.Column<string>(type: "varchar(256)", maxLength: 256, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
CurrentPrice = table.Column<decimal>(type: "decimal(18,8)", precision: 18, scale: 8, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_MarketOutcomes", x => x.Id);
table.ForeignKey(
name: "FK_MarketOutcomes_Markets_MarketId",
column: x => x.MarketId,
principalTable: "Markets",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateTable(
name: "Alerts",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
Type = table.Column<int>(type: "int", nullable: false),
Platform = table.Column<int>(type: "int", nullable: false),
TraderId = table.Column<int>(type: "int", nullable: true),
Title = table.Column<string>(type: "varchar(512)", maxLength: 512, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
Message = table.Column<string>(type: "varchar(4096)", maxLength: 4096, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
Severity = table.Column<int>(type: "int", nullable: false),
IsRead = table.Column<bool>(type: "tinyint(1)", nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Alerts", x => x.Id);
table.ForeignKey(
name: "FK_Alerts_Traders_TraderId",
column: x => x.TraderId,
principalTable: "Traders",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateTable(
name: "TraderAnalytics",
columns: table => new
{
TraderId = table.Column<int>(type: "int", nullable: false),
LastCalculatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
OverallPnL = table.Column<decimal>(type: "decimal(18,4)", precision: 18, scale: 4, nullable: false),
OverallWinRate = table.Column<decimal>(type: "decimal(8,4)", precision: 8, scale: 4, nullable: false),
PnL30d = table.Column<decimal>(type: "decimal(18,4)", precision: 18, scale: 4, nullable: false),
WinRate30d = table.Column<decimal>(type: "decimal(8,4)", precision: 8, scale: 4, nullable: false),
PnL7d = table.Column<decimal>(type: "decimal(18,4)", precision: 18, scale: 4, nullable: false),
WinRate7d = table.Column<decimal>(type: "decimal(8,4)", precision: 8, scale: 4, nullable: false),
PnL24h = table.Column<decimal>(type: "decimal(18,4)", precision: 18, scale: 4, nullable: false),
WinRate24h = table.Column<decimal>(type: "decimal(8,4)", precision: 8, scale: 4, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_TraderAnalytics", x => x.TraderId);
table.ForeignKey(
name: "FK_TraderAnalytics_Traders_TraderId",
column: x => x.TraderId,
principalTable: "Traders",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateTable(
name: "TraderScores",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
TraderId = table.Column<int>(type: "int", nullable: false),
ActivityScore = table.Column<decimal>(type: "decimal(8,4)", precision: 8, scale: 4, nullable: false),
QualityScore = table.Column<decimal>(type: "decimal(8,4)", precision: 8, scale: 4, nullable: false),
CombinedScore = table.Column<decimal>(type: "decimal(8,4)", precision: 8, scale: 4, nullable: false),
VolumeScore = table.Column<decimal>(type: "decimal(8,4)", precision: 8, scale: 4, nullable: false),
TimingScore = table.Column<decimal>(type: "decimal(8,4)", precision: 8, scale: 4, nullable: false),
Rank = table.Column<int>(type: "int", nullable: false),
CalculatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_TraderScores", x => x.Id);
table.ForeignKey(
name: "FK_TraderScores_Traders_TraderId",
column: x => x.TraderId,
principalTable: "Traders",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateTable(
name: "WatchlistEntries",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
TraderId = table.Column<int>(type: "int", nullable: false),
Label = table.Column<string>(type: "varchar(256)", maxLength: 256, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
Notes = table.Column<string>(type: "longtext", nullable: true)
.Annotation("MySql:CharSet", "utf8mb4"),
AlertsEnabled = table.Column<bool>(type: "tinyint(1)", nullable: false),
AddedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_WatchlistEntries", x => x.Id);
table.ForeignKey(
name: "FK_WatchlistEntries_Traders_TraderId",
column: x => x.TraderId,
principalTable: "Traders",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateTable(
name: "Trades",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
TraderId = table.Column<int>(type: "int", nullable: false),
Platform = table.Column<int>(type: "int", nullable: false),
PlatformTradeId = table.Column<string>(type: "varchar(256)", maxLength: 256, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
MarketId = table.Column<string>(type: "varchar(66)", maxLength: 66, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
DbMarketId = table.Column<int>(type: "int", nullable: true),
AssetId = table.Column<string>(type: "varchar(80)", maxLength: 80, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
MarketOutcomeId = table.Column<int>(type: "int", nullable: true),
Outcome = table.Column<string>(type: "varchar(128)", maxLength: 128, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
Side = table.Column<int>(type: "int", nullable: false),
Price = table.Column<decimal>(type: "decimal(10,6)", precision: 10, scale: 6, nullable: false),
Size = table.Column<decimal>(type: "decimal(14,6)", precision: 14, scale: 6, nullable: false),
Amount = table.Column<decimal>(type: "decimal(18,4)", precision: 18, scale: 4, nullable: false),
ExecutedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
TransactionHash = table.Column<string>(type: "varchar(66)", maxLength: 66, nullable: true)
.Annotation("MySql:CharSet", "utf8mb4")
},
constraints: table =>
{
table.PrimaryKey("PK_Trades", x => x.Id);
table.ForeignKey(
name: "FK_Trades_MarketOutcomes_MarketOutcomeId",
column: x => x.MarketOutcomeId,
principalTable: "MarketOutcomes",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
table.ForeignKey(
name: "FK_Trades_Markets_DbMarketId",
column: x => x.DbMarketId,
principalTable: "Markets",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
table.ForeignKey(
name: "FK_Trades_Traders_TraderId",
column: x => x.TraderId,
principalTable: "Traders",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateIndex(
name: "IX_Alerts_CreatedAt",
table: "Alerts",
column: "CreatedAt");
migrationBuilder.CreateIndex(
name: "IX_Alerts_TraderId",
table: "Alerts",
column: "TraderId");
migrationBuilder.CreateIndex(
name: "IX_MarketOutcomes_MarketId_OutcomeIndex",
table: "MarketOutcomes",
columns: new[] { "MarketId", "OutcomeIndex" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_MarketOutcomes_TokenId",
table: "MarketOutcomes",
column: "TokenId");
migrationBuilder.CreateIndex(
name: "IX_Markets_Platform_PlatformMarketId",
table: "Markets",
columns: new[] { "Platform", "PlatformMarketId" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_Traders_Platform_PlatformUserId",
table: "Traders",
columns: new[] { "Platform", "PlatformUserId" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_TraderScores_TraderId",
table: "TraderScores",
column: "TraderId",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_Trades_AssetId",
table: "Trades",
column: "AssetId");
migrationBuilder.CreateIndex(
name: "IX_Trades_DbMarketId",
table: "Trades",
column: "DbMarketId");
migrationBuilder.CreateIndex(
name: "IX_Trades_ExecutedAt",
table: "Trades",
column: "ExecutedAt");
migrationBuilder.CreateIndex(
name: "IX_Trades_MarketOutcomeId",
table: "Trades",
column: "MarketOutcomeId");
migrationBuilder.CreateIndex(
name: "IX_Trades_Platform_PlatformTradeId",
table: "Trades",
columns: new[] { "Platform", "PlatformTradeId" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_Trades_TraderId",
table: "Trades",
column: "TraderId");
migrationBuilder.CreateIndex(
name: "IX_WatchlistEntries_TraderId",
table: "WatchlistEntries",
column: "TraderId",
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "Alerts");
migrationBuilder.DropTable(
name: "MarketAnalytics");
migrationBuilder.DropTable(
name: "PlatformConfigs");
migrationBuilder.DropTable(
name: "TraderAnalytics");
migrationBuilder.DropTable(
name: "TraderScores");
migrationBuilder.DropTable(
name: "Trades");
migrationBuilder.DropTable(
name: "WatchlistEntries");
migrationBuilder.DropTable(
name: "MarketOutcomes");
migrationBuilder.DropTable(
name: "Traders");
migrationBuilder.DropTable(
name: "Markets");
}
}
}
@@ -0,0 +1,638 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Predictalytics.Infrastructure.Data;
#nullable disable
namespace Predictalytics.Infrastructure.Migrations
{
[DbContext(typeof(AppDbContext))]
partial class AppDbContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "8.0.11")
.HasAnnotation("Relational:MaxIdentifierLength", 64);
MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder);
modelBuilder.Entity("Predictalytics.Domain.Entities.Alert", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<bool>("IsRead")
.HasColumnType("tinyint(1)");
b.Property<string>("Message")
.IsRequired()
.HasMaxLength(4096)
.HasColumnType("varchar(4096)");
b.Property<int>("Platform")
.HasColumnType("int");
b.Property<int>("Severity")
.HasColumnType("int");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("varchar(512)");
b.Property<int?>("TraderId")
.HasColumnType("int");
b.Property<int>("Type")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("CreatedAt");
b.HasIndex("TraderId");
b.ToTable("Alerts");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.Market", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
b.Property<string>("Category")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("varchar(128)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<DateTime>("DbCreatedAt")
.HasColumnType("datetime(6)");
b.Property<string>("Description")
.HasMaxLength(4096)
.HasColumnType("varchar(4096)");
b.Property<DateTime?>("EndDate")
.HasColumnType("datetime(6)");
b.Property<string>("EventSlug")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("varchar(512)");
b.Property<string>("ImageUrl")
.HasMaxLength(1024)
.HasColumnType("varchar(1024)");
b.Property<bool>("IsResolved")
.HasColumnType("tinyint(1)");
b.Property<DateTime?>("LastTradesUpdatedAt")
.HasColumnType("datetime(6)");
b.Property<DateTime?>("LastUpdatedAt")
.HasColumnType("datetime(6)");
b.Property<decimal>("Liquidity")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<string>("MarketSlug")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("varchar(512)");
b.Property<int>("Platform")
.HasColumnType("int");
b.Property<string>("PlatformMarketId")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("varchar(256)");
b.Property<string>("Question")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("varchar(1024)");
b.Property<string>("ResolutionOutcome")
.HasColumnType("longtext");
b.Property<DateTime?>("StartDate")
.HasColumnType("datetime(6)");
b.Property<decimal>("Volume")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.HasKey("Id");
b.HasIndex("Platform", "PlatformMarketId")
.IsUnique();
b.ToTable("Markets");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.MarketAnalytics", b =>
{
b.Property<int>("MarketId")
.HasColumnType("int");
b.Property<decimal>("AverageTradeSize")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<decimal>("BotActivityScore")
.HasPrecision(8, 4)
.HasColumnType("decimal(8,4)");
b.Property<DateTime>("LastCalculatedAt")
.HasColumnType("datetime(6)");
b.Property<int>("UniqueTradersCount")
.HasColumnType("int");
b.HasKey("MarketId");
b.ToTable("MarketAnalytics");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.MarketOutcome", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
b.Property<decimal>("CurrentPrice")
.HasPrecision(18, 8)
.HasColumnType("decimal(18,8)");
b.Property<string>("Label")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("varchar(256)");
b.Property<int>("MarketId")
.HasColumnType("int");
b.Property<int>("OutcomeIndex")
.HasColumnType("int");
b.Property<string>("TokenId")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("varchar(256)");
b.HasKey("Id");
b.HasIndex("TokenId");
b.HasIndex("MarketId", "OutcomeIndex")
.IsUnique();
b.ToTable("MarketOutcomes");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.PlatformConfig", b =>
{
b.Property<int>("Id")
.HasColumnType("int");
b.Property<string>("BaseUrl")
.HasMaxLength(1024)
.HasColumnType("varchar(1024)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("varchar(256)");
b.Property<bool>("IsActive")
.HasColumnType("tinyint(1)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("varchar(128)");
b.Property<string>("SettingsJson")
.HasColumnType("longtext");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime(6)");
b.HasKey("Id");
b.ToTable("PlatformConfigs");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.Trade", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<long>("Id"));
b.Property<decimal>("Amount")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<string>("AssetId")
.IsRequired()
.HasMaxLength(80)
.HasColumnType("varchar(80)");
b.Property<int?>("DbMarketId")
.HasColumnType("int");
b.Property<DateTime>("ExecutedAt")
.HasColumnType("datetime(6)");
b.Property<string>("MarketId")
.IsRequired()
.HasMaxLength(66)
.HasColumnType("varchar(66)");
b.Property<int?>("MarketOutcomeId")
.HasColumnType("int");
b.Property<string>("Outcome")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("varchar(128)");
b.Property<int>("Platform")
.HasColumnType("int");
b.Property<string>("PlatformTradeId")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("varchar(256)");
b.Property<decimal>("Price")
.HasPrecision(10, 6)
.HasColumnType("decimal(10,6)");
b.Property<int>("Side")
.HasColumnType("int");
b.Property<decimal>("Size")
.HasPrecision(14, 6)
.HasColumnType("decimal(14,6)");
b.Property<int>("TraderId")
.HasColumnType("int");
b.Property<string>("TransactionHash")
.HasMaxLength(66)
.HasColumnType("varchar(66)");
b.HasKey("Id");
b.HasIndex("AssetId");
b.HasIndex("DbMarketId");
b.HasIndex("ExecutedAt");
b.HasIndex("MarketOutcomeId");
b.HasIndex("TraderId");
b.HasIndex("Platform", "PlatformTradeId")
.IsUnique();
b.ToTable("Trades");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.Trader", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("varchar(256)");
b.Property<bool>("IsAutoDiscovered")
.HasColumnType("tinyint(1)");
b.Property<bool>("IsInitialImportComplete")
.HasColumnType("tinyint(1)");
b.Property<bool>("IsSuspectedBot")
.HasColumnType("tinyint(1)");
b.Property<DateTime?>("LastApiErrorAt")
.HasColumnType("datetime(6)");
b.Property<DateTime?>("LastPolledAt")
.HasColumnType("datetime(6)");
b.Property<DateTime?>("LastTradesUpdatedAt")
.HasColumnType("datetime(6)");
b.Property<int?>("ManualPriorityOverride")
.HasColumnType("int");
b.Property<string>("Notes")
.HasColumnType("longtext");
b.Property<int>("Platform")
.HasColumnType("int");
b.Property<string>("PlatformUserId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("varchar(128)");
b.Property<int>("Strategy")
.HasColumnType("int");
b.Property<int>("Tier")
.HasColumnType("int");
b.Property<decimal>("TotalPnl")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<int>("TotalTrades")
.HasColumnType("int");
b.Property<decimal>("WinRate")
.HasPrecision(8, 4)
.HasColumnType("decimal(8,4)");
b.HasKey("Id");
b.HasIndex("Platform", "PlatformUserId")
.IsUnique();
b.ToTable("Traders");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.TraderAnalytics", b =>
{
b.Property<int>("TraderId")
.HasColumnType("int");
b.Property<DateTime>("LastCalculatedAt")
.HasColumnType("datetime(6)");
b.Property<decimal>("OverallPnL")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<decimal>("OverallWinRate")
.HasPrecision(8, 4)
.HasColumnType("decimal(8,4)");
b.Property<decimal>("PnL24h")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<decimal>("PnL30d")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<decimal>("PnL7d")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<decimal>("WinRate24h")
.HasPrecision(8, 4)
.HasColumnType("decimal(8,4)");
b.Property<decimal>("WinRate30d")
.HasPrecision(8, 4)
.HasColumnType("decimal(8,4)");
b.Property<decimal>("WinRate7d")
.HasPrecision(8, 4)
.HasColumnType("decimal(8,4)");
b.HasKey("TraderId");
b.ToTable("TraderAnalytics");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.TraderScore", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
b.Property<decimal>("ActivityScore")
.HasPrecision(8, 4)
.HasColumnType("decimal(8,4)");
b.Property<DateTime>("CalculatedAt")
.HasColumnType("datetime(6)");
b.Property<decimal>("CombinedScore")
.HasPrecision(8, 4)
.HasColumnType("decimal(8,4)");
b.Property<decimal>("QualityScore")
.HasPrecision(8, 4)
.HasColumnType("decimal(8,4)");
b.Property<int>("Rank")
.HasColumnType("int");
b.Property<decimal>("TimingScore")
.HasPrecision(8, 4)
.HasColumnType("decimal(8,4)");
b.Property<int>("TraderId")
.HasColumnType("int");
b.Property<decimal>("VolumeScore")
.HasPrecision(8, 4)
.HasColumnType("decimal(8,4)");
b.HasKey("Id");
b.HasIndex("TraderId")
.IsUnique();
b.ToTable("TraderScores");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.WatchlistEntry", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
b.Property<DateTime>("AddedAt")
.HasColumnType("datetime(6)");
b.Property<bool>("AlertsEnabled")
.HasColumnType("tinyint(1)");
b.Property<string>("Label")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("varchar(256)");
b.Property<string>("Notes")
.HasColumnType("longtext");
b.Property<int>("TraderId")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("TraderId")
.IsUnique();
b.ToTable("WatchlistEntries");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.Alert", b =>
{
b.HasOne("Predictalytics.Domain.Entities.Trader", "Trader")
.WithMany()
.HasForeignKey("TraderId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("Trader");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.MarketAnalytics", b =>
{
b.HasOne("Predictalytics.Domain.Entities.Market", "Market")
.WithOne("Analytics")
.HasForeignKey("Predictalytics.Domain.Entities.MarketAnalytics", "MarketId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Market");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.MarketOutcome", b =>
{
b.HasOne("Predictalytics.Domain.Entities.Market", "Market")
.WithMany("Outcomes")
.HasForeignKey("MarketId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Market");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.Trade", b =>
{
b.HasOne("Predictalytics.Domain.Entities.Market", "DbMarket")
.WithMany()
.HasForeignKey("DbMarketId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("Predictalytics.Domain.Entities.MarketOutcome", "MarketOutcome")
.WithMany()
.HasForeignKey("MarketOutcomeId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("Predictalytics.Domain.Entities.Trader", "Trader")
.WithMany("Trades")
.HasForeignKey("TraderId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("DbMarket");
b.Navigation("MarketOutcome");
b.Navigation("Trader");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.TraderAnalytics", b =>
{
b.HasOne("Predictalytics.Domain.Entities.Trader", "Trader")
.WithOne("Analytics")
.HasForeignKey("Predictalytics.Domain.Entities.TraderAnalytics", "TraderId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Trader");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.TraderScore", b =>
{
b.HasOne("Predictalytics.Domain.Entities.Trader", "Trader")
.WithOne("CurrentScore")
.HasForeignKey("Predictalytics.Domain.Entities.TraderScore", "TraderId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Trader");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.WatchlistEntry", b =>
{
b.HasOne("Predictalytics.Domain.Entities.Trader", "Trader")
.WithMany("WatchlistEntries")
.HasForeignKey("TraderId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Trader");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.Market", b =>
{
b.Navigation("Analytics");
b.Navigation("Outcomes");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.Trader", b =>
{
b.Navigation("Analytics");
b.Navigation("CurrentScore");
b.Navigation("Trades");
b.Navigation("WatchlistEntries");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,31 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<RootNamespace>Predictalytics.Infrastructure</RootNamespace>
<SatelliteResourceLanguages>en</SatelliteResourceLanguages>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.11" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="8.0.11" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.11">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="8.0.2" />
<PackageReference Include="Serilog" Version="4.2.0" />
<PackageReference Include="Serilog.Extensions.Logging" Version="8.0.0" />
<PackageReference Include="Serilog.Sinks.File" Version="6.0.0" />
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
<PackageReference Include="Serilog.Formatting.Compact" Version="3.0.0" />
<PackageReference Include="Microsoft.Extensions.Http" Version="8.0.1" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="8.0.1" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Predictalytics.Domain\Predictalytics.Domain.csproj" />
<ProjectReference Include="..\Predictalytics.Application\Predictalytics.Application.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,42 @@
using Predictalytics.Domain.Entities;
using Predictalytics.Domain.Enums;
using Predictalytics.Domain.Interfaces;
using Predictalytics.Infrastructure.Logging;
using Microsoft.Extensions.Logging;
namespace Predictalytics.Infrastructure.Providers.Azuro;
/// <summary>
/// Placeholder provider for Azuro prediction market.
/// All log entries include Platform=Azuro for log-file routing.
/// </summary>
public class AzuroProvider : IPlatformProvider
{
private readonly ILogger<AzuroProvider> _logger;
public AzuroProvider(ILogger<AzuroProvider> logger) => _logger = logger;
public PlatformType Platform => PlatformType.Azuro;
public string PlatformName => "Azuro";
public bool IsImplemented => false;
public Task<IReadOnlyList<Trade>> GetTraderTradesAsync(string platformUserId, int limit = 50, CancellationToken ct = default)
{ using var _ = PlatformLogContext.Push(PlatformName); _logger.LogWarning("Provider not yet implemented"); return Task.FromResult<IReadOnlyList<Trade>>(Array.Empty<Trade>()); }
public Task<IReadOnlyList<TraderPositionInfo>> GetTraderPositionsAsync(string platformUserId, CancellationToken ct = default)
{ using var _ = PlatformLogContext.Push(PlatformName); _logger.LogWarning("Provider not yet implemented"); return Task.FromResult<IReadOnlyList<TraderPositionInfo>>(Array.Empty<TraderPositionInfo>()); }
public Task<IReadOnlyList<DiscoveredTrader>> DiscoverTradersAsync(int limit = 20, CancellationToken ct = default)
{ using var _ = PlatformLogContext.Push(PlatformName); _logger.LogWarning("Provider not yet implemented"); return Task.FromResult<IReadOnlyList<DiscoveredTrader>>(Array.Empty<DiscoveredTrader>()); }
public Task<Market?> GetMarketAsync(string platformMarketId, CancellationToken ct = default)
{ using var _ = PlatformLogContext.Push(PlatformName); _logger.LogWarning("Provider not yet implemented"); return Task.FromResult<Market?>(null); }
public Task<IReadOnlyList<Market>> GetMarketsAsync(int limit = 100, string? cursor = null, bool includeClosed = false, CancellationToken ct = default)
{ using var _ = PlatformLogContext.Push(PlatformName); return Task.FromResult<IReadOnlyList<Market>>(Array.Empty<Market>()); }
public Task<IReadOnlyList<DiscoveredTrader>> GetTopHoldersAsync(string platformMarketId, int limit = 20, CancellationToken ct = default)
{ using var _ = PlatformLogContext.Push(PlatformName); return Task.FromResult<IReadOnlyList<DiscoveredTrader>>(Array.Empty<DiscoveredTrader>()); }
public Task<IReadOnlyList<Trade>> GetMarketTradesAsync(string platformMarketId, int limit = 50, CancellationToken ct = default)
{ using var _ = PlatformLogContext.Push(PlatformName); return Task.FromResult<IReadOnlyList<Trade>>(Array.Empty<Trade>()); }
}
@@ -0,0 +1,96 @@
using System.Net.Http.Json;
using Microsoft.Extensions.Logging;
namespace Predictalytics.Infrastructure.Providers.Limitless;
/// <summary>
/// HTTP client for Limitless API.
/// Base URL: https://api.limitless.exchange
/// </summary>
public class LimitlessApiClient
{
private readonly HttpClient _client;
private readonly ILogger<LimitlessApiClient> _logger;
public LimitlessApiClient(IHttpClientFactory httpFactory, ILogger<LimitlessApiClient> logger)
{
_client = httpFactory.CreateClient("LimitlessApi");
_logger = logger;
}
public async Task<List<LimitlessMarketResponse>> GetActiveMarketsAsync(int limit = 100, int offset = 0, CancellationToken ct = default)
{
var url = $"markets/active?limit={Math.Min(limit, 25)}"; // Offset is not supported by this endpoint, limit max 25
try
{
var response = await _client.GetAsync(url, ct);
if (!response.IsSuccessStatusCode)
{
var error = await response.Content.ReadAsStringAsync(ct);
_logger.LogError("Limitless API 400/Error for {Url}: {Error}", url, error);
return [];
}
var result = await response.Content.ReadFromJsonAsync<LimitlessActiveMarketsResponse>(cancellationToken: ct);
return result?.Data ?? [];
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to fetch active markets from Limitless via {Url}", url);
return [];
}
}
public async Task<LimitlessMarketResponse?> GetMarketAsync(string addressOrSlug, CancellationToken ct = default)
{
var url = $"markets/{addressOrSlug}";
try
{
var response = await _client.GetAsync(url, ct);
if (!response.IsSuccessStatusCode) return null;
return await response.Content.ReadFromJsonAsync<LimitlessMarketResponse>(cancellationToken: ct);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to fetch Limitless market: {Url}", url);
return null;
}
}
public async Task<LimitlessPortfolioResponse?> GetPositionsAsync(string walletAddress, CancellationToken ct = default)
{
var url = $"portfolio/{walletAddress}/positions";
try
{
var response = await _client.GetAsync(url, ct);
if (!response.IsSuccessStatusCode)
{
var error = await response.Content.ReadAsStringAsync(ct);
_logger.LogError("Limitless API Error for {Url}: {Error}", url, error);
return null;
}
return await response.Content.ReadFromJsonAsync<LimitlessPortfolioResponse>(cancellationToken: ct);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to fetch Limitless positions for {Url}", url);
return null;
}
}
public async Task<List<LimitlessEventResponse>> GetMarketEventsAsync(string slug, int limit = 50, CancellationToken ct = default)
{
var url = $"markets/{slug}/events?limit={limit}";
try
{
var response = await _client.GetAsync(url, ct);
if (!response.IsSuccessStatusCode) return [];
var result = await response.Content.ReadFromJsonAsync<LimitlessEventsResponse>(cancellationToken: ct);
return result?.Events ?? [];
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to fetch events for Limitless market {Url}", url);
return [];
}
}
}
@@ -0,0 +1,171 @@
using System.Text.Json.Serialization;
namespace Predictalytics.Infrastructure.Providers.Limitless;
public class LimitlessActiveMarketsResponse
{
[JsonPropertyName("data")]
public List<LimitlessMarketResponse>? Data { get; set; }
[JsonPropertyName("totalMarketsCount")]
public int TotalMarketsCount { get; set; }
}
public class LimitlessEventsResponse
{
[JsonPropertyName("events")]
public List<LimitlessEventResponse>? Events { get; set; }
}
public class LimitlessPortfolioResponse
{
[JsonPropertyName("clob")]
public List<LimitlessClobItem>? Clob { get; set; }
[JsonPropertyName("amm")]
public List<LimitlessClobItem>? Amm { get; set; }
[JsonPropertyName("group")]
public List<LimitlessClobItem>? Group { get; set; }
}
public class LimitlessClobItem
{
[JsonPropertyName("market")]
public LimitlessMarketResponse? Market { get; set; }
[JsonPropertyName("positions")]
public LimitlessPositionsContainer? Positions { get; set; }
}
public class LimitlessPositionsContainer
{
[JsonPropertyName("yes")]
public LimitlessPositionDetails? Yes { get; set; }
[JsonPropertyName("no")]
public LimitlessPositionDetails? No { get; set; }
}
public class LimitlessPositionDetails
{
[JsonPropertyName("size")]
public string? Size { get; set; } // API uses strings for numbers here
[JsonPropertyName("fillPrice")]
public string? FillPrice { get; set; }
[JsonPropertyName("marketValue")]
public string? MarketValue { get; set; }
[JsonPropertyName("realisedPnl")]
public string? RealisedPnl { get; set; }
[JsonPropertyName("unrealizedPnl")]
public string? UnrealizedPnl { get; set; }
[JsonPropertyName("latestTrade")]
public LimitlessTradeInfo? LatestTrade { get; set; }
}
public class LimitlessMarketResponse
{
[JsonPropertyName("address")]
public string? Address { get; set; }
[JsonPropertyName("slug")]
public string? Slug { get; set; }
[JsonPropertyName("title")]
public string? Title { get; set; }
[JsonPropertyName("description")]
public string? Description { get; set; }
[JsonPropertyName("categories")]
public List<string>? Categories { get; set; }
[JsonPropertyName("imageUrl")]
public string? ImageUrl { get; set; }
[JsonPropertyName("expirationDate")]
public string? ExpirationDate { get; set; }
[JsonPropertyName("expirationTimestamp")]
public long? ExpirationTimestamp { get; set; }
[JsonPropertyName("active")]
public bool Active { get; set; }
[JsonPropertyName("closed")]
public bool Closed { get; set; }
[JsonPropertyName("volumeFormatted")]
public string? VolumeFormatted { get; set; }
[JsonPropertyName("liquidity")]
public double? Liquidity { get; set; }
[JsonPropertyName("prices")]
public List<double>? Prices { get; set; }
[JsonPropertyName("tokens")]
public LimitlessTokens? Tokens { get; set; }
}
public class LimitlessTokens
{
[JsonPropertyName("yes")]
public string? Yes { get; set; }
[JsonPropertyName("no")]
public string? No { get; set; }
}
public class LimitlessTradeInfo
{
[JsonPropertyName("timestamp")]
public long Timestamp { get; set; }
[JsonPropertyName("price")]
public string? Price { get; set; } // Might be string in this context
[JsonPropertyName("size")]
public string? Size { get; set; }
[JsonPropertyName("side")]
public object? Side { get; set; }
[JsonPropertyName("transactionHash")]
public string? TransactionHash { get; set; }
}
public class LimitlessEventResponse
{
[JsonPropertyName("txHash")]
public string? TxHash { get; set; }
[JsonPropertyName("side")]
public object? Side { get; set; }
[JsonPropertyName("price")]
public double? Price { get; set; }
[JsonPropertyName("size")]
public double? Size { get; set; }
[JsonPropertyName("createdAt")]
public string? CreatedAt { get; set; }
[JsonPropertyName("profile")]
public LimitlessProfile? Profile { get; set; }
[JsonPropertyName("asset")]
public string? Asset { get; set; }
}
public class LimitlessProfile
{
[JsonPropertyName("account")]
public string? Account { get; set; }
}
@@ -0,0 +1,277 @@
using Predictalytics.Domain.Entities;
using Predictalytics.Domain.Enums;
using Predictalytics.Domain.Interfaces;
using Predictalytics.Infrastructure.Logging;
using Microsoft.Extensions.Logging;
namespace Predictalytics.Infrastructure.Providers.Limitless;
/// <summary>
/// Placeholder provider for Limitless prediction market.
/// All log entries include Platform=Limitless for log-file routing.
/// </summary>
public class LimitlessProvider : IPlatformProvider
{
private readonly LimitlessApiClient _api;
private readonly ILogger<LimitlessProvider> _logger;
public PlatformType Platform => PlatformType.Limitless;
public string PlatformName => "Limitless";
public bool IsImplemented => true;
public LimitlessProvider(LimitlessApiClient api, ILogger<LimitlessProvider> logger)
{
_api = api;
_logger = logger;
}
public async Task<IReadOnlyList<Trade>> GetTraderTradesAsync(string platformUserId, int limit = 50, CancellationToken ct = default)
{
using var _ = PlatformLogContext.Push(PlatformName);
_logger.LogDebug("Fetching latest trade activity via positions for {Wallet}", platformUserId);
var portfolio = await _api.GetPositionsAsync(platformUserId, ct);
if (portfolio == null) return [];
var items = (portfolio.Clob ?? []).Concat(portfolio.Amm ?? []).Concat(portfolio.Group ?? []);
var trades = new List<Trade>();
foreach (var item in items)
{
if (item.Positions == null || item.Market == null) continue;
// Check Yes and No positions for latest trades
var posList = new[] {
(Details: item.Positions.Yes, Outcome: "Yes", TokenId: item.Market.Tokens?.Yes),
(Details: item.Positions.No, Outcome: "No", TokenId: item.Market.Tokens?.No)
};
foreach (var pos in posList)
{
if (pos.Details?.LatestTrade == null) continue;
var lt = pos.Details.LatestTrade;
decimal.TryParse(lt.Price, System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out var price);
decimal.TryParse(lt.Size, System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out var size);
trades.Add(new Trade
{
Platform = PlatformType.Limitless,
// Compact format: {txHash}_{assetId} — wallet via TransientWallet
PlatformTradeId = lt.TransactionHash != null
? $"{lt.TransactionHash}_{pos.TokenId}"
: $"{lt.Timestamp}_{pos.TokenId}",
MarketId = item.Market.Address ?? item.Market.Slug ?? "",
AssetId = pos.TokenId ?? "",
Outcome = pos.Outcome,
Side = ParseSide(lt.Side),
Price = price,
Size = size,
Amount = price * size,
ExecutedAt = (lt.Timestamp > 0 && lt.Timestamp < 253402300799)
? DateTimeOffset.FromUnixTimeSeconds(lt.Timestamp).UtcDateTime
: DateTime.UtcNow,
TransactionHash = lt.TransactionHash,
TransientWallet = platformUserId,
});
}
}
return trades.OrderByDescending(t => t.ExecutedAt).Take(limit).ToList();
}
public async Task<IReadOnlyList<TraderPositionInfo>> GetTraderPositionsAsync(string platformUserId, CancellationToken ct = default)
{
using var _ = PlatformLogContext.Push(PlatformName);
var portfolio = await _api.GetPositionsAsync(platformUserId, ct);
if (portfolio == null) return [];
var items = (portfolio.Clob ?? []).Concat(portfolio.Amm ?? []).Concat(portfolio.Group ?? []);
var result = new List<TraderPositionInfo>();
foreach (var item in items)
{
if (item.Positions == null || item.Market == null) continue;
var posList = new[] {
(Details: item.Positions.Yes, Outcome: "Yes", TokenId: item.Market.Tokens?.Yes),
(Details: item.Positions.No, Outcome: "No", TokenId: item.Market.Tokens?.No)
};
foreach (var pos in posList)
{
if (pos.Details == null) continue;
decimal.TryParse(pos.Details.Size, System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out var size);
if (size == 0) continue;
decimal.TryParse(pos.Details.FillPrice, System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out var avgPrice);
decimal.TryParse(pos.Details.MarketValue, System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out var val);
decimal.TryParse(pos.Details.UnrealizedPnl, System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out var pnl);
result.Add(new TraderPositionInfo(
platformUserId,
item.Market.Address ?? item.Market.Slug ?? "",
item.Market.Title ?? "",
pos.Outcome,
size,
avgPrice,
val,
0 // PercentPnl not directly available as decimal in this view
));
}
}
return result;
}
public async Task<IReadOnlyList<DiscoveredTrader>> DiscoverTradersAsync(int limit = 20, CancellationToken ct = default)
{
using var _ = PlatformLogContext.Push(PlatformName);
_logger.LogInformation("Trader discovery for Limitless via active markets...");
var markets = await _api.GetActiveMarketsAsync(10, 0, ct);
var traders = new List<DiscoveredTrader>();
foreach (var m in markets.Take(5))
{
if (ct.IsCancellationRequested) break;
var events = await _api.GetMarketEventsAsync(m.Slug ?? m.Address ?? "", 20, ct);
foreach (var e in events.Where(ev => ev.Profile?.Account != null))
{
traders.Add(new DiscoveredTrader(
e.Profile!.Account!,
e.Profile.Account![..10] + "...",
(decimal)(e.Price * e.Size ?? 0),
1, 0
));
}
}
return traders.GroupBy(t => t.PlatformUserId)
.Select(g => g.First())
.Take(limit)
.ToList();
}
public async Task<Market?> GetMarketAsync(string platformMarketId, CancellationToken ct = default)
{
using var _ = PlatformLogContext.Push(PlatformName);
var raw = await _api.GetMarketAsync(platformMarketId, ct);
if (raw == null) return null;
return MapLimitlessMarket(raw);
}
public async Task<IReadOnlyList<Market>> GetMarketsAsync(int limit = 100, string? cursor = null, bool includeClosed = false, CancellationToken ct = default)
{
using var _ = PlatformLogContext.Push(PlatformName);
int.TryParse(cursor, out var offset);
// Since Limitless /markets/active doesn't support offset, we only return the first page.
// Returning data for offset > 0 would cause an infinite loop in MarketSyncWorker.
if (offset > 0) return [];
var raw = await _api.GetActiveMarketsAsync(limit, offset, ct);
return raw.Select(MapLimitlessMarket).ToList();
}
public async Task<IReadOnlyList<DiscoveredTrader>> GetTopHoldersAsync(string platformMarketId, int limit = 20, CancellationToken ct = default)
{
var events = await _api.GetMarketEventsAsync(platformMarketId, limit * 2, ct);
return events
.Where(e => e.Profile?.Account != null)
.GroupBy(e => e.Profile!.Account)
.Select(g => new DiscoveredTrader(g.Key!, g.Key![..10] + "...", 0, g.Count(), 0))
.Take(limit)
.ToList();
}
public async Task<IReadOnlyList<Trade>> GetMarketTradesAsync(string platformMarketId, int limit = 50, CancellationToken ct = default)
{
var events = await _api.GetMarketEventsAsync(platformMarketId, limit, ct);
return events
.Where(e => e.Profile?.Account != null)
.Select(e =>
{
var wallet = e.Profile!.Account!;
var asset = e.Asset ?? "";
var side = e.Side ?? "";
// Compact format: {txHash}_{assetId} — wallet via TransientWallet
var tradeId = e.TxHash != null
? $"{e.TxHash}_{asset}"
: $"{e.CreatedAt}_{asset}_{side}";
return new Trade
{
Platform = PlatformType.Limitless,
PlatformTradeId = tradeId,
MarketId = platformMarketId,
AssetId = asset,
Outcome = "",
Side = ParseSide(side),
Price = (decimal)(e.Price ?? 0),
Size = (decimal)(e.Size ?? 0),
Amount = (decimal)(e.Price * e.Size ?? 0),
ExecutedAt = DateTime.TryParse(e.CreatedAt, out var dt) ? dt : DateTime.UtcNow,
TransactionHash = e.TxHash,
TransientWallet = wallet,
};
})
.ToList();
}
private Market MapLimitlessMarket(LimitlessMarketResponse raw)
{
var market = new Market
{
Platform = PlatformType.Limitless,
PlatformMarketId = raw.Address ?? raw.Slug ?? "",
MarketSlug = raw.Slug ?? "",
EventSlug = "", // Limitless doesn't seem to have a clear Event/Market split in this model
Question = raw.Title ?? "",
Description = raw.Description ?? "",
Category = raw.Categories?.FirstOrDefault() ?? "",
ImageUrl = raw.ImageUrl ?? "",
Volume = decimal.TryParse(raw.VolumeFormatted?.Replace(" USDC", ""), out var vol) ? vol : 0,
Liquidity = (decimal)(raw.Liquidity ?? 0),
EndDate = (raw.ExpirationTimestamp.HasValue && raw.ExpirationTimestamp.Value > 0 && raw.ExpirationTimestamp.Value < 253402300799)
? DateTimeOffset.FromUnixTimeSeconds(raw.ExpirationTimestamp.Value).UtcDateTime
: (DateTime.TryParse(raw.ExpirationDate, out var ed) ? ed : null),
IsResolved = raw.Closed,
LastUpdatedAt = DateTime.UtcNow
};
// Map Outcomes from tokens object and prices array
if (raw.Tokens != null)
{
// Yes Outcome
market.Outcomes.Add(new MarketOutcome
{
Label = "Yes",
OutcomeIndex = 0,
TokenId = raw.Tokens.Yes ?? "",
CurrentPrice = (decimal)(raw.Prices != null && raw.Prices.Count > 0 ? raw.Prices[0] : 0)
});
// No Outcome
market.Outcomes.Add(new MarketOutcome
{
Label = "No",
OutcomeIndex = 1,
TokenId = raw.Tokens.No ?? "",
CurrentPrice = (decimal)(raw.Prices != null && raw.Prices.Count > 1 ? raw.Prices[1] : 0)
});
}
return market;
}
private TradeSide ParseSide(object? sideObj)
{
var sideStr = sideObj?.ToString()?.ToUpper();
if (sideStr == "0" || sideStr == "BUY") return TradeSide.Buy;
return TradeSide.Sell;
}
}
@@ -0,0 +1,164 @@
using System.Net.Http.Json;
using Microsoft.Extensions.Logging;
using Predictalytics.Application.Interfaces;
using Predictalytics.Domain.Enums;
namespace Predictalytics.Infrastructure.Providers.Polymarket;
/// <summary>
/// HTTP client for Polymarket Data API.
/// All endpoints use https://data-api.polymarket.com
/// </summary>
public class PolymarketApiClient
{
private readonly HttpClient _client;
private readonly HttpClient _gammaClient;
private readonly IRateLimiter _rateLimiter;
private readonly ILogger<PolymarketApiClient> _logger;
private const string DataApiBase = "https://data-api.polymarket.com";
private const string GammaApiBase = "https://gamma-api.polymarket.com";
public PolymarketApiClient(IHttpClientFactory httpFactory, IRateLimiter rateLimiter, ILogger<PolymarketApiClient> logger)
{
_client = httpFactory.CreateClient("PolymarketData");
_client.BaseAddress = new Uri(DataApiBase);
_client.DefaultRequestHeaders.Add("Accept", "application/json");
_gammaClient = httpFactory.CreateClient("PolymarketGamma");
_gammaClient.BaseAddress = new Uri(GammaApiBase);
_gammaClient.DefaultRequestHeaders.Add("Accept", "application/json");
_rateLimiter = rateLimiter;
_logger = logger;
}
public async Task<List<PolymarketTradeResponse>> GetTradesAsync(string walletAddress, int limit = 1000, CancellationToken ct = default)
{
var url = $"/activity?user={walletAddress}&limit={limit}";
return await ExecuteWithRetryAsync<List<PolymarketTradeResponse>>(_client, url, ct) ?? [];
}
public async Task<List<PolymarketTradeResponse>> GetMarketTradesAsync(string conditionId, int limit = 1000, CancellationToken ct = default)
{
var url = $"/trades?condition_id={conditionId}&limit={limit}";
return await ExecuteWithRetryAsync<List<PolymarketTradeResponse>>(_client, url, ct) ?? [];
}
public async Task<List<PolymarketPositionResponse>> GetPositionsAsync(string walletAddress, CancellationToken ct = default)
{
var url = $"/positions?user={walletAddress}&sizeThreshold=0.1&sortBy=CURRENT&sortOrder=DESC";
return await ExecuteWithRetryAsync<List<PolymarketPositionResponse>>(_client, url, ct) ?? [];
}
public async Task<GammaMarketResponse?> GetMarketAsync(string conditionId, CancellationToken ct = default)
{
var url = $"/markets?condition_id={conditionId}";
var results = await ExecuteWithRetryAsync<List<GammaMarketResponse>>(_gammaClient, url, ct);
return results?.FirstOrDefault();
}
/// <summary>
/// Fetch a batch of markets from the Gamma API with pagination.
/// Supports offset-based pagination via the offset parameter.
/// </summary>
public async Task<List<GammaMarketResponse>> GetMarketsAsync(int limit = 1000, int offset = 0, bool includeClosed = false, CancellationToken ct = default)
{
var activeOnly = !includeClosed;
var url = $"/markets?limit={limit}&offset={offset}&active={activeOnly.ToString().ToLower()}&closed={includeClosed.ToString().ToLower()}";
_logger.LogDebug("Fetching markets: {Url}", url);
var result = await ExecuteWithRetryAsync<List<GammaMarketResponse>>(_gammaClient, url, ct);
_logger.LogInformation("Fetched {Count} markets (offset={Offset}, closed={Closed})", result?.Count ?? 0, offset, includeClosed);
return result ?? [];
}
/// <summary>
/// Fetch top holders for a specific market (conditionId) from the Data API.
/// Returns holders grouped by token (outcome).
/// </summary>
public async Task<List<HoldersResponse>> GetHoldersAsync(string conditionId, int limit = 20, CancellationToken ct = default)
{
var url = $"/holders?market={conditionId}&limit={limit}";
_logger.LogDebug("Fetching holders: {Url}", url);
var result = await ExecuteWithRetryAsync<List<HoldersResponse>>(_client, url, ct);
_logger.LogInformation("Fetched holders for {Market}: {Count} token groups",
conditionId.Length > 12 ? conditionId[..12] + "..." : conditionId, result?.Count ?? 0);
return result ?? [];
}
/// <summary>
/// Get leaderboard from the official Polymarket Data API v1.
/// Endpoint: GET https://data-api.polymarket.com/v1/leaderboard
/// </summary>
public async Task<List<LeaderboardEntry>> GetLeaderboardAsync(
int limit = 50,
string timePeriod = "ALL",
string orderBy = "PNL",
string category = "OVERALL",
CancellationToken ct = default)
{
var url = $"/v1/leaderboard?limit={Math.Min(limit, 50)}&time_period={timePeriod}&order_by={orderBy}&category={category}";
_logger.LogDebug("Fetching leaderboard: {Url}", url);
var result = await ExecuteWithRetryAsync<List<LeaderboardEntry>>(_client, url, ct);
_logger.LogInformation("Leaderboard returned {Count} entries", result?.Count ?? 0);
return result ?? [];
}
private async Task<T?> ExecuteWithRetryAsync<T>(HttpClient client, string url, CancellationToken ct, int attempt = 1)
{
try
{
var response = await client.GetAsync(url, ct);
if (response.StatusCode == System.Net.HttpStatusCode.TooManyRequests)
{
TimeSpan? retryAfter = null;
if (response.Headers.RetryAfter != null)
{
retryAfter = response.Headers.RetryAfter.Delta ??
(response.Headers.RetryAfter.Date.HasValue
? response.Headers.RetryAfter.Date.Value - DateTimeOffset.UtcNow
: null);
}
var waitTime = retryAfter ?? TimeSpan.FromSeconds(30);
if (waitTime.TotalSeconds < 5)
{
_logger.LogWarning("Got 429 but Retry-After was {RawWait}s. Enforcing 30s minimum.", waitTime.TotalSeconds);
waitTime = TimeSpan.FromSeconds(30);
}
_logger.LogWarning("⚠️ Rate limit exceeded (429) for Polymarket. Pausing for {WaitTime}s...", (int)waitTime.TotalSeconds);
_rateLimiter.ReportRateLimitExceeded(PlatformType.Polymarket, waitTime);
if (attempt < 3)
{
await _rateLimiter.WaitAsync(PlatformType.Polymarket, ct);
_logger.LogWarning("Retrying {Url} (attempt {NextAttempt})...", url, attempt + 1);
return await ExecuteWithRetryAsync<T>(client, url, ct, attempt + 1);
}
return default;
}
if ((int)response.StatusCode == 422)
{
_logger.LogInformation("End of data reached (422) for {Url}. Stopping pagination.", url);
return default;
}
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<T>(cancellationToken: ct);
}
catch (Exception ex)
{
if (ex is HttpRequestException hex && hex.StatusCode == System.Net.HttpStatusCode.TooManyRequests)
{
_logger.LogCritical("Unhandled 429 in PolymarketApiClient for {Url}. This should have been caught by the status code check.", url);
}
_logger.LogError(ex, "Failed to fetch from {Url} (attempt {Attempt})", url, attempt);
return default;
}
}
}
@@ -0,0 +1,199 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Predictalytics.Infrastructure.Providers.Polymarket;
/// <summary>
/// Converter that handles JSON values that may be either a number or a string.
/// Polymarket API is inconsistent — some fields are numbers in one endpoint and strings in another.
/// </summary>
public class FlexibleDoubleConverter : JsonConverter<double>
{
public override double Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
return reader.TokenType switch
{
JsonTokenType.Number => reader.GetDouble(),
JsonTokenType.String => double.TryParse(reader.GetString(), System.Globalization.NumberStyles.Any,
System.Globalization.CultureInfo.InvariantCulture, out var v) ? v : 0,
JsonTokenType.Null => 0,
_ => 0
};
}
public override void Write(Utf8JsonWriter writer, double value, JsonSerializerOptions options)
=> writer.WriteNumberValue(value);
}
public class FlexibleLongConverter : JsonConverter<long>
{
public override long Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
return reader.TokenType switch
{
JsonTokenType.Number => reader.GetInt64(),
JsonTokenType.String => long.TryParse(reader.GetString(), out var v) ? v : 0,
_ => 0
};
}
public override void Write(Utf8JsonWriter writer, long value, JsonSerializerOptions options)
=> writer.WriteNumberValue(value);
}
// ═══════════════════════════════════════════════════════
// Polymarket Data API response models
// ═══════════════════════════════════════════════════════
public class PolymarketTradeResponse
{
[JsonPropertyName("conditionId")] public string ConditionId { get; set; } = "";
[JsonPropertyName("asset")] public string Asset { get; set; } = "";
[JsonPropertyName("side")] public string Side { get; set; } = "";
[JsonPropertyName("action")] public string Action { get; set; } = "";
[JsonPropertyName("type")] public string Type { get; set; } = "";
[JsonPropertyName("user")] public string? User { get; set; }
[JsonPropertyName("proxyWallet")] public string? ProxyWallet { get; set; }
[JsonPropertyName("size")]
[JsonConverter(typeof(FlexibleDoubleConverter))]
public double Size { get; set; }
[JsonPropertyName("price")]
[JsonConverter(typeof(FlexibleDoubleConverter))]
public double Price { get; set; }
[JsonPropertyName("outcome")] public string Outcome { get; set; } = "";
[JsonPropertyName("timestamp")]
[JsonConverter(typeof(FlexibleLongConverter))]
public long Timestamp { get; set; }
[JsonPropertyName("transactionHash")] public string? TransactionHash { get; set; }
}
public class PolymarketPositionResponse
{
[JsonPropertyName("asset_id")] public string AssetId { get; set; } = "";
[JsonPropertyName("market")] public string Market { get; set; } = "";
[JsonPropertyName("outcome")] public string Outcome { get; set; } = "";
[JsonPropertyName("size")]
[JsonConverter(typeof(FlexibleDoubleConverter))]
public double Size { get; set; }
[JsonPropertyName("avgPrice")]
[JsonConverter(typeof(FlexibleDoubleConverter))]
public double AvgPrice { get; set; }
[JsonPropertyName("currentValue")]
[JsonConverter(typeof(FlexibleDoubleConverter))]
public double CurrentValue { get; set; }
[JsonPropertyName("cashPnl")]
[JsonConverter(typeof(FlexibleDoubleConverter))]
public double CashPnl { get; set; }
[JsonPropertyName("percentPnl")]
[JsonConverter(typeof(FlexibleDoubleConverter))]
public double PercentPnl { get; set; }
[JsonPropertyName("question")] public string Question { get; set; } = "";
}
// ═══════════════════════════════════════════════════════
// Gamma API — Market metadata (full market response)
// ═══════════════════════════════════════════════════════
public class GammaMarketResponse
{
[JsonPropertyName("id")] public string Id { get; set; } = "";
[JsonPropertyName("conditionId")] public string ConditionId { get; set; } = "";
[JsonPropertyName("question")] public string Question { get; set; } = "";
[JsonPropertyName("slug")] public string Slug { get; set; } = "";
[JsonPropertyName("description")] public string? Description { get; set; }
[JsonPropertyName("image")] public string? Image { get; set; }
[JsonPropertyName("category")] public string Category { get; set; } = "";
[JsonPropertyName("groupItemTitle")] public string? GroupItemTitle { get; set; }
[JsonPropertyName("events")] public List<GammaEventResponse>? Events { get; set; }
[JsonPropertyName("volumeNum")]
[JsonConverter(typeof(FlexibleDoubleConverter))]
public double Volume { get; set; }
[JsonPropertyName("liquidityNum")]
[JsonConverter(typeof(FlexibleDoubleConverter))]
public double Liquidity { get; set; }
[JsonPropertyName("endDateIso")] public string? EndDate { get; set; }
[JsonPropertyName("startDate")] public string? StartDate { get; set; }
[JsonPropertyName("createdAt")] public string? CreatedAt { get; set; }
[JsonPropertyName("closed")] public bool Closed { get; set; }
[JsonPropertyName("active")] public bool Active { get; set; }
[JsonPropertyName("resolved")] public bool Resolved { get; set; }
[JsonPropertyName("resolution_outcome")] public string? ResolutionOutcome { get; set; }
/// <summary>JSON string of outcomes, e.g. "[\"Yes\", \"No\"]"</summary>
[JsonPropertyName("outcomes")] public string? Outcomes { get; set; }
/// <summary>JSON string of outcome prices, e.g. "[\"0.55\", \"0.45\"]"</summary>
[JsonPropertyName("outcomePrices")] public string? OutcomePrices { get; set; }
/// <summary>JSON string of CLOB token IDs, e.g. "[\"12345...\", \"67890...\"]"</summary>
[JsonPropertyName("clobTokenIds")] public string? ClobTokenIds { get; set; }
}
public class GammaEventResponse
{
[JsonPropertyName("id")] public string Id { get; set; } = "";
[JsonPropertyName("slug")] public string Slug { get; set; } = "";
[JsonPropertyName("title")] public string Title { get; set; } = "";
}
// ═══════════════════════════════════════════════════════
// Data API — Holders response
// ═══════════════════════════════════════════════════════
public class HoldersResponse
{
[JsonPropertyName("token")] public string Token { get; set; } = "";
[JsonPropertyName("holders")] public List<HolderEntry> Holders { get; set; } = [];
}
public class HolderEntry
{
[JsonPropertyName("proxyWallet")] public string ProxyWallet { get; set; } = "";
[JsonPropertyName("name")] public string Name { get; set; } = "";
[JsonPropertyName("pseudonym")] public string Pseudonym { get; set; } = "";
[JsonPropertyName("amount")]
[JsonConverter(typeof(FlexibleDoubleConverter))]
public double Amount { get; set; }
[JsonPropertyName("outcomeIndex")] public int OutcomeIndex { get; set; }
[JsonPropertyName("profileImage")] public string? ProfileImage { get; set; }
[JsonPropertyName("verified")] public bool Verified { get; set; }
}
// ═══════════════════════════════════════════════════════
// Polymarket Data API v1 Leaderboard response
// ═══════════════════════════════════════════════════════
public class LeaderboardEntry
{
[JsonPropertyName("rank")] public string Rank { get; set; } = "";
[JsonPropertyName("proxyWallet")] public string ProxyWallet { get; set; } = "";
[JsonPropertyName("userName")] public string UserName { get; set; } = "";
[JsonPropertyName("vol")]
[JsonConverter(typeof(FlexibleDoubleConverter))]
public double Vol { get; set; }
[JsonPropertyName("pnl")]
[JsonConverter(typeof(FlexibleDoubleConverter))]
public double Pnl { get; set; }
[JsonPropertyName("profileImage")] public string? ProfileImage { get; set; }
[JsonPropertyName("xUsername")] public string? XUsername { get; set; }
[JsonPropertyName("verifiedBadge")] public bool VerifiedBadge { get; set; }
}
@@ -0,0 +1,298 @@
using System.Text.Json;
using Predictalytics.Domain.Entities;
using Predictalytics.Domain.Enums;
using Predictalytics.Domain.Interfaces;
using Predictalytics.Infrastructure.Logging;
using Microsoft.Extensions.Logging;
namespace Predictalytics.Infrastructure.Providers.Polymarket;
/// <summary>
/// Full implementation of IPlatformProvider for Polymarket.
/// All log entries include Platform=Polymarket for log-file routing.
/// </summary>
public class PolymarketProvider : IPlatformProvider
{
private readonly PolymarketApiClient _api;
private readonly ILogger<PolymarketProvider> _logger;
public PlatformType Platform => PlatformType.Polymarket;
public string PlatformName => "Polymarket";
public bool IsImplemented => true;
public PolymarketProvider(PolymarketApiClient api, ILogger<PolymarketProvider> logger)
{ _api = api; _logger = logger; }
public async Task<IReadOnlyList<Trade>> GetTraderTradesAsync(string platformUserId, int limit = 1000, CancellationToken ct = default)
{
using var _ = PlatformLogContext.Push(PlatformName);
_logger.LogDebug("Fetching trades for {Wallet} (limit={Limit})", platformUserId, limit);
var raw = await _api.GetTradesAsync(platformUserId, limit, ct);
_logger.LogInformation("Fetched {Count} trades for {Wallet}", raw.Count, platformUserId);
var mappedTrades = raw.Select(r =>
{
var wallet = r.User ?? r.ProxyWallet ?? "";
var side = MapTradeSide(r);
var sideStr = side.ToString().ToUpperInvariant();
// Compact format: {txHash}_{assetId}_{side} — no wallet in ID to reduce index size.
// Wallet passed transiently via TransientWallet [NotMapped] for MarketHistoryWorker.
return new Trade
{
Platform = PlatformType.Polymarket,
PlatformTradeId = string.IsNullOrEmpty(r.TransactionHash)
? $"{r.Timestamp}_{r.Asset}_{sideStr}"
: $"{r.TransactionHash}_{r.Asset}_{sideStr}",
MarketId = r.ConditionId ?? "",
AssetId = r.Asset ?? "",
Outcome = r.Outcome ?? "",
Side = side,
Price = (decimal)r.Price,
Size = (decimal)r.Size,
Amount = (decimal)(r.Price * r.Size),
ExecutedAt = DateTimeOffset.FromUnixTimeSeconds(r.Timestamp).UtcDateTime,
TransactionHash = r.TransactionHash,
TraderId = 0,
TransientWallet = wallet,
};
}).ToList();
return mappedTrades.GroupBy(t => t.PlatformTradeId).Select(g => g.First()).ToList();
}
public async Task<IReadOnlyList<Trade>> GetMarketTradesAsync(string platformMarketId, int limit = 1000, CancellationToken ct = default)
{
var raw = await _api.GetMarketTradesAsync(platformMarketId, limit, ct);
_logger.LogInformation("Fetched {Count} trades for Market {Market} (limit={Limit})", raw.Count, platformMarketId, limit);
var mappedTrades = raw.Select(r =>
{
var wallet = !string.IsNullOrEmpty(r.User) ? r.User : (r.ProxyWallet ?? "");
var side = MapTradeSide(r);
var sideStr = side.ToString().ToUpperInvariant();
return new Trade
{
Platform = PlatformType.Polymarket,
PlatformTradeId = string.IsNullOrEmpty(r.TransactionHash)
? $"{r.Timestamp}_{r.Asset}_{sideStr}"
: $"{r.TransactionHash}_{r.Asset}_{sideStr}",
MarketId = r.ConditionId ?? "",
AssetId = r.Asset ?? "",
Outcome = r.Outcome ?? "",
Side = side,
Price = (decimal)r.Price,
Size = (decimal)r.Size,
Amount = (decimal)(r.Price * r.Size),
ExecutedAt = DateTimeOffset.FromUnixTimeSeconds(r.Timestamp).UtcDateTime,
TransactionHash = r.TransactionHash,
TraderId = 0,
TransientWallet = wallet,
};
}).ToList();
return mappedTrades.GroupBy(t => t.PlatformTradeId).Select(g => g.First()).ToList();
}
public async Task<IReadOnlyList<TraderPositionInfo>> GetTraderPositionsAsync(string platformUserId, CancellationToken ct = default)
{
using var _ = PlatformLogContext.Push(PlatformName);
_logger.LogDebug("Fetching positions for {Wallet}", platformUserId);
var raw = await _api.GetPositionsAsync(platformUserId, ct);
_logger.LogInformation("Fetched {Count} positions for {Wallet}", raw.Count, platformUserId);
return raw.Select(r => new TraderPositionInfo(
platformUserId, r.Market, r.Question, r.Outcome,
(decimal)r.Size, (decimal)r.AvgPrice,
(decimal)r.CurrentValue, (decimal)r.PercentPnl
)).ToList();
}
public async Task<IReadOnlyList<DiscoveredTrader>> DiscoverTradersAsync(int limit = 50, CancellationToken ct = default)
{
using var _ = PlatformLogContext.Push(PlatformName);
_logger.LogInformation("Running trader discovery via v1/leaderboard (limit={Limit})...", limit);
var leaderboard = await _api.GetLeaderboardAsync(limit, ct: ct);
_logger.LogInformation("Discovery returned {Count} traders from leaderboard", leaderboard.Count);
return leaderboard.Select(e => new DiscoveredTrader(
e.ProxyWallet,
string.IsNullOrEmpty(e.UserName) ? e.ProxyWallet[..10] + "..." : e.UserName,
(decimal)e.Vol,
0, // trade count not in leaderboard API
0 // win rate computed later from trades
)).ToList();
}
public async Task<Market?> GetMarketAsync(string platformMarketId, CancellationToken ct = default)
{
using var _ = PlatformLogContext.Push(PlatformName);
_logger.LogDebug("Fetching market {MarketId}", platformMarketId);
var raw = await _api.GetMarketAsync(platformMarketId, ct);
if (raw == null)
{
_logger.LogWarning("Market {MarketId} not found", platformMarketId);
return null;
}
_logger.LogInformation("Fetched market: {Question}", raw.Question);
return MapGammaMarket(raw);
}
public async Task<IReadOnlyList<Market>> GetMarketsAsync(int limit = 100, string? cursor = null, bool includeClosed = false, CancellationToken ct = default)
{
using var _ = PlatformLogContext.Push(PlatformName);
int offset = 0;
if (!string.IsNullOrEmpty(cursor) && int.TryParse(cursor, out var parsed))
offset = parsed;
_logger.LogInformation("Fetching markets batch (limit={Limit}, offset={Offset}, includeClosed={Closed})", limit, offset, includeClosed);
var raw = await _api.GetMarketsAsync(limit, offset, includeClosed, ct);
_logger.LogInformation("Fetched {Count} markets from Gamma API", raw.Count);
return raw
.Where(m => !string.IsNullOrEmpty(m.ConditionId) && !string.IsNullOrEmpty(m.ClobTokenIds))
.Select(MapGammaMarket)
.ToList();
}
public async Task<IReadOnlyList<DiscoveredTrader>> GetTopHoldersAsync(string platformMarketId, int limit = 20, CancellationToken ct = default)
{
using var _ = PlatformLogContext.Push(PlatformName);
_logger.LogInformation("Fetching top holders for market {MarketId}", platformMarketId[..12] + "...");
var holdersGroups = await _api.GetHoldersAsync(platformMarketId, limit, ct);
// Flatten all holders across token groups, deduplicate by wallet
var uniqueHolders = holdersGroups
.SelectMany(g => g.Holders)
.GroupBy(h => h.ProxyWallet)
.Select(g =>
{
var first = g.First();
var totalAmount = g.Sum(h => h.Amount);
var displayName = !string.IsNullOrEmpty(first.Name) ? first.Name
: !string.IsNullOrEmpty(first.Pseudonym) ? first.Pseudonym
: first.ProxyWallet[..10] + "...";
return new DiscoveredTrader(
first.ProxyWallet,
displayName,
(decimal)totalAmount,
0, 0
);
})
.OrderByDescending(d => d.Volume24h)
.ToList();
_logger.LogInformation("Discovered {Count} unique holders from market {MarketId}",
uniqueHolders.Count, platformMarketId[..12] + "...");
return uniqueHolders;
}
// ── Private helpers ──────────────────────────────────────────
private Market MapGammaMarket(GammaMarketResponse raw)
{
var eventSlug = "";
if (raw.Events != null && raw.Events.Count > 0 && !string.IsNullOrEmpty(raw.Events[0].Slug))
{
eventSlug = raw.Events[0].Slug;
}
var market = new Market
{
Platform = PlatformType.Polymarket,
PlatformMarketId = raw.ConditionId,
MarketSlug = raw.Slug,
EventSlug = eventSlug,
Description = raw.Description,
ImageUrl = raw.Image,
Question = raw.Question,
Category = raw.Category,
Volume = (decimal)raw.Volume,
Liquidity = (decimal)raw.Liquidity,
StartDate = DateTime.TryParse(raw.StartDate, out var sd) ? sd : null,
EndDate = DateTime.TryParse(raw.EndDate, out var ed) ? ed : null,
CreatedAt = DateTime.TryParse(raw.CreatedAt, out var cd) ? cd : DateTime.UtcNow,
DbCreatedAt = DateTime.UtcNow,
IsResolved = raw.Resolved || raw.Closed, // Prefer resolved flag
ResolutionOutcome = raw.ResolutionOutcome,
LastUpdatedAt = DateTime.UtcNow
};
// Parse outcomes, prices, and token IDs from JSON strings
var outcomeLabels = ParseJsonStringArray(raw.Outcomes);
var outcomePrices = ParseJsonStringArray(raw.OutcomePrices);
var tokenIds = ParseJsonStringArray(raw.ClobTokenIds);
for (int i = 0; i < outcomeLabels.Count; i++)
{
decimal price = 0;
if (i < outcomePrices.Count)
decimal.TryParse(outcomePrices[i], System.Globalization.NumberStyles.Any,
System.Globalization.CultureInfo.InvariantCulture, out price);
string tokenId = i < tokenIds.Count ? tokenIds[i] : "";
var label = outcomeLabels[i];
if ((label.Equals("Yes", StringComparison.OrdinalIgnoreCase) || label.Equals("No", StringComparison.OrdinalIgnoreCase))
&& !string.IsNullOrEmpty(raw.GroupItemTitle))
{
label = $"{raw.GroupItemTitle} - {label}";
}
market.Outcomes.Add(new MarketOutcome
{
Label = label,
OutcomeIndex = i,
TokenId = tokenId,
CurrentPrice = price
});
}
return market;
}
private static List<string> ParseJsonStringArray(string? json)
{
if (string.IsNullOrEmpty(json)) return [];
try
{
return JsonSerializer.Deserialize<List<string>>(json) ?? [];
}
catch
{
return [];
}
}
private static TradeSide MapTradeSide(PolymarketTradeResponse r)
{
// Check Action/Type field first for special operations
var typeOrAction = !string.IsNullOrEmpty(r.Action) ? r.Action
: !string.IsNullOrEmpty(r.Type) ? r.Type : "";
if (!string.IsNullOrEmpty(typeOrAction))
{
if (typeOrAction.Equals("SPLIT", StringComparison.OrdinalIgnoreCase)) return TradeSide.Split;
if (typeOrAction.Equals("MERGE", StringComparison.OrdinalIgnoreCase)) return TradeSide.Merge;
if (typeOrAction.Equals("REDEEM", StringComparison.OrdinalIgnoreCase)) return TradeSide.Redeem;
if (typeOrAction.Equals("ADD_LIQUIDITY", StringComparison.OrdinalIgnoreCase)) return TradeSide.AddLiquidity;
if (typeOrAction.Equals("REMOVE_LIQUIDITY", StringComparison.OrdinalIgnoreCase)) return TradeSide.RemoveLiquidity;
// Type field can also contain BUY/SELL directly
if (typeOrAction.Equals("BUY", StringComparison.OrdinalIgnoreCase)) return TradeSide.Buy;
if (typeOrAction.Equals("SELL", StringComparison.OrdinalIgnoreCase)) return TradeSide.Sell;
}
// Side field (explicit buy/sell direction)
if (!string.IsNullOrEmpty(r.Side))
{
if (r.Side.Equals("BUY", StringComparison.OrdinalIgnoreCase)) return TradeSide.Buy;
if (r.Side.Equals("SELL", StringComparison.OrdinalIgnoreCase)) return TradeSide.Sell;
}
return TradeSide.Unknown;
}
}