diff --git a/src/Predictalytics.Api/wwwroot/js/app.js b/src/Predictalytics.Api/wwwroot/js/app.js
index 1c7f829..2558155 100644
--- a/src/Predictalytics.Api/wwwroot/js/app.js
+++ b/src/Predictalytics.Api/wwwroot/js/app.js
@@ -213,12 +213,12 @@ async function loadDashboard() {
const tbody = document.getElementById('topTradersBody');
tbody.innerHTML = data.topTraders.map((t, i) => `
- | ${i + 1} |
- ${t.platform === 'Polymarket' ? `${t.displayName}` : t.displayName} |
- ${t.platform} |
- ${Number(t.combinedScore).toFixed(1)} |
- ${fmt.pct(t.winRate)} |
- ${fmt.pnl(t.totalPnl)} |
+ ${i + 1} |
+ ${t.displayName} |
+ ${t.platform} |
+ ${Number(t.combinedScore).toFixed(1)} |
+ ${fmt.pct(t.winRate)} |
+ ${fmt.pnl(t.totalPnl)} |
${fmt.tier(t.tier)} |
${fmt.num(t.totalTrades)} |
@@ -231,7 +231,7 @@ async function loadDashboard() {
${fmt.time(t.executedAt)} |
${t.traderName} |
- ${t.dbMarketId ? `Market #${t.dbMarketId}` : (t.marketId ? t.marketId.substring(0, 12) + '...' : '—')}
+ ${t.marketName || (t.marketId.length > 20 ? t.marketId.substring(0,20)+'...' : t.marketId)}
|
${fmt.side(t.side)} |
${Number(t.price).toFixed(2)} |
@@ -287,7 +287,7 @@ async function loadTraders() {
tbody.innerHTML = data.map((t, i) => `
| ${i + 1} |
- ${t.platform === 'Polymarket' ? `${t.displayName}` : t.displayName} |
+ ${t.displayName} |
${t.platform} |
${Number(t.combinedScore).toFixed(1)} |
${fmt.pct(t.winRate)} |
@@ -351,7 +351,7 @@ async function viewTrader(id) {
document.getElementById('td-platformId').textContent = t.platformUserId;
document.getElementById('td-tier').innerHTML = fmt.tier(t.tier);
document.getElementById('td-strategy').textContent = t.strategy;
- document.getElementById('td-winrate').textContent = fmt.pct(t.winRate);
+ document.getElementById('td-winrate').innerHTML = fmt.pct(t.winRate);
document.getElementById('td-pnl').innerHTML = fmt.pnl(t.totalPnl);
document.getElementById('td-trades').textContent = fmt.num(t.totalTrades);
document.getElementById('td-score').textContent = Number(t.combinedScore).toFixed(1);
@@ -363,13 +363,45 @@ async function viewTrader(id) {
const aiBtn = document.getElementById('btn-ai-analysis');
aiBtn.onclick = () => triggerAiAnalysis(id, true);
+ const wlBtn = document.getElementById('btn-toggle-watchlist');
+ wlBtn.textContent = t.isOnWatchlist ? '★ Watchlist (Remove)' : '☆ Watchlist (Add)';
+ wlBtn.onclick = async () => {
+ const method = t.isOnWatchlist ? 'DELETE' : 'POST';
+ await api(`/api/traders/${id}/watchlist`, { method });
+ viewTrader(id); // Reload to update UI
+ };
+
+ const openBtn = document.getElementById('btn-open-platform');
+ if (t.platform === 'Polymarket') {
+ openBtn.style.display = 'inline-block';
+ openBtn.textContent = 'Open on Polymarket';
+ openBtn.onclick = () => window.open(`https://polymarket.com/profile/${t.platformUserId}`, '_blank');
+ } else {
+ openBtn.style.display = 'none';
+ }
+
+ const catBody = document.getElementById('td-categoryBody');
+ if (t.categoryPerformances && t.categoryPerformances.length > 0) {
+ catBody.innerHTML = t.categoryPerformances.map(c => `
+
+ | ${c.category} |
+ ${fmt.pct(c.winRate)} |
+ ${fmt.pnl(c.totalPnL)} |
+ ${fmt.usd(c.totalVolume)} |
+ ${fmt.num(c.totalTrades)} |
+
+ `).join('');
+ } else {
+ catBody.innerHTML = '
| No category data available |
';
+ }
+
const tbody = document.getElementById('td-tradesBody');
tbody.innerHTML = t.recentTrades.map(tr => `
| ${fmt.time(tr.executedAt)} |
-
- ${tr.dbMarketId ? `Market #${tr.dbMarketId}` : (tr.marketId ? tr.marketId.substring(0, 16) + '...' : '—')}
- |
+
+ ${tr.marketName || (tr.marketId.length > 20 ? tr.marketId.substring(0,20)+'...' : tr.marketId)}
+ |
${fmt.side(tr.side)} |
${Number(tr.price).toFixed(2)} |
${fmt.num(tr.size)} |
diff --git a/src/Predictalytics.Application.Tests/Services/AnalyticsServiceTests.cs b/src/Predictalytics.Application.Tests/Services/AnalyticsServiceTests.cs
index 9a5b13e..53dc668 100644
--- a/src/Predictalytics.Application.Tests/Services/AnalyticsServiceTests.cs
+++ b/src/Predictalytics.Application.Tests/Services/AnalyticsServiceTests.cs
@@ -97,8 +97,14 @@ public class AnalyticsServiceTests
private class MockDiscoveryService : IDiscoveryService
{
- public Task ImportTraderAsync(PlatformType platform, string platformUserId, string displayName, CancellationToken ct) => Task.FromResult(0);
+ public Task ImportTraderAsync(PlatformType platform, string platformUserId, string displayName, bool isAutoDiscovered = true, CancellationToken ct = default)
+ {
+ return Task.FromResult(1);
+ }
public Task ScanTopHoldersAsync(CancellationToken ct) => Task.CompletedTask;
- public Task> RunDiscoveryAsync(PlatformType platform, CancellationToken ct) => Task.FromResult>(new List());
+ public Task> RunDiscoveryAsync(PlatformType platform, CancellationToken ct = default)
+ {
+ return Task.FromResult>(new List());
+ }
}
}
diff --git a/src/Predictalytics.Application/DTOs/MarketDetailDto.cs b/src/Predictalytics.Application/DTOs/MarketDetailDto.cs
index 39db956..4e918ac 100644
--- a/src/Predictalytics.Application/DTOs/MarketDetailDto.cs
+++ b/src/Predictalytics.Application/DTOs/MarketDetailDto.cs
@@ -8,6 +8,7 @@ public class MarketDetailDto
public string Question { get; set; } = "";
public string? Description { get; set; }
public string Category { get; set; } = "";
+ public string Subcategory { get; set; } = "";
public double Volume { get; set; }
public double Liquidity { get; set; }
public DateTime? EndDate { get; set; }
diff --git a/src/Predictalytics.Application/DTOs/TradeDto.cs b/src/Predictalytics.Application/DTOs/TradeDto.cs
index 96ebb52..c118c77 100644
--- a/src/Predictalytics.Application/DTOs/TradeDto.cs
+++ b/src/Predictalytics.Application/DTOs/TradeDto.cs
@@ -9,6 +9,7 @@ public record TradeDto(
string Platform,
int? DbMarketId,
string MarketId,
+ string MarketName,
string Outcome,
string Side,
decimal Price,
diff --git a/src/Predictalytics.Application/DTOs/TraderDto.cs b/src/Predictalytics.Application/DTOs/TraderDto.cs
index 883faa3..05bd9de 100644
--- a/src/Predictalytics.Application/DTOs/TraderDto.cs
+++ b/src/Predictalytics.Application/DTOs/TraderDto.cs
@@ -43,5 +43,15 @@ public record TraderDetailDto(
DateTime CreatedAt,
DateTime? LastPolledAt,
string? AiStrategySummary,
- IReadOnlyList RecentTrades
+ IReadOnlyList RecentTrades,
+ IReadOnlyList CategoryPerformances
+);
+
+public record TraderCategoryPerformanceDto(
+ string Category,
+ decimal TotalVolume,
+ decimal TotalPnL,
+ int TotalTrades,
+ int WinningTrades,
+ decimal WinRate
);
diff --git a/src/Predictalytics.Application/Interfaces/IDiscoveryService.cs b/src/Predictalytics.Application/Interfaces/IDiscoveryService.cs
index bb6a31b..ada406b 100644
--- a/src/Predictalytics.Application/Interfaces/IDiscoveryService.cs
+++ b/src/Predictalytics.Application/Interfaces/IDiscoveryService.cs
@@ -1,4 +1,4 @@
-using Predictalytics.Domain.Enums;
+using Predictalytics.Domain.Enums;
using Predictalytics.Domain.Interfaces;
namespace Predictalytics.Application.Interfaces;
@@ -9,5 +9,5 @@ public interface IDiscoveryService
Task> RunDiscoveryAsync(PlatformType platform, CancellationToken ct = default);
/// Import a discovered trader into the tracking system.
- Task ImportTraderAsync(PlatformType platform, string platformUserId, string displayName, CancellationToken ct = default);
+ Task ImportTraderAsync(PlatformType platform, string platformUserId, string displayName, bool isAutoDiscovered = true, CancellationToken ct = default);
}
diff --git a/src/Predictalytics.Application/Services/AnalyticsService.cs b/src/Predictalytics.Application/Services/AnalyticsService.cs
index f9e6e2b..e4e413e 100644
--- a/src/Predictalytics.Application/Services/AnalyticsService.cs
+++ b/src/Predictalytics.Application/Services/AnalyticsService.cs
@@ -186,13 +186,22 @@ public class AnalyticsService : IAnalyticsService
var trades = await _tradeRepo.GetByTraderIdAsync(traderId, 0, 50, ct);
var wl = await _watchlistRepo.GetByTraderIdAsync(traderId, ct);
var s = trader.CurrentScore;
+ var perfs = trader.CategoryPerformances.Select(p => new TraderCategoryPerformanceDto(
+ p.Category.ToString(),
+ p.TotalVolume,
+ p.TotalPnL,
+ p.TotalTrades,
+ p.WinningTrades,
+ p.WinRate)).ToList();
+
return new TraderDetailDto(trader.Id, trader.Platform.ToString(), trader.PlatformUserId, trader.DisplayName,
trader.Notes, trader.Tier.ToString(), trader.Strategy.ToString(), trader.IsSuspectedBot, trader.ManualPriorityOverride,
trader.WinRate, trader.TotalPnl, trader.TotalTrades,
s?.ActivityScore ?? 0, s?.QualityScore ?? 0, s?.VolumeScore ?? 0, s?.TimingScore ?? 0,
s?.CombinedScore ?? 0, s?.CopytradingScore ?? 0, s?.Rank ?? 0, wl != null, trader.CreatedAt, trader.LastPolledAt,
trader.AiStrategySummary,
- trades.Select(MapTradeDto).ToList());
+ trades.Select(MapTradeDto).ToList(),
+ perfs);
}
public async Task GetMarketDetailAsync(int marketId, CancellationToken ct = default)
@@ -215,7 +224,8 @@ public class AnalyticsService : IAnalyticsService
PlatformMarketId = market.ConditionId,
Question = market.Question,
Description = market.Description,
- Category = market.Category,
+ Category = market.Category.ToString(),
+ Subcategory = market.Subcategory,
Volume = (double)market.Volume,
Liquidity = (double)market.Liquidity,
EndDate = market.EndDate,
@@ -415,7 +425,7 @@ public class AnalyticsService : IAnalyticsService
throw new ArgumentException($"Invalid platform: {platform}");
_logger.LogInformation("Manually adding trader {Wallet} for platform {Platform}", walletAddress, platform);
- return await _discovery.ImportTraderAsync(pType, walletAddress, walletAddress[..Math.Min(10, walletAddress.Length)] + "...", ct);
+ return await _discovery.ImportTraderAsync(pType, walletAddress, walletAddress[..Math.Min(10, walletAddress.Length)] + "...", false, ct);
}
private static TraderDto MapTraderDto(Trader t, HashSet wIds) => new(
@@ -424,6 +434,6 @@ public class AnalyticsService : IAnalyticsService
wIds.Contains(t.Id), t.IsSuspectedBot, t.LastPolledAt);
private static TradeDto MapTradeDto(Trade t) => new(
- t.Id, t.TraderId, t.Trader?.DisplayName ?? "—", t.Platform.ToString(),
- t.DbMarketId, t.MarketId, t.Outcome, t.Side.ToString(), t.Price, t.Size, t.Amount, t.ExecutedAt);
+ t.Id, t.TraderId, t.Trader?.DisplayName ?? "?", t.Platform.ToString(),
+ t.DbMarketId, t.MarketId, t.DbMarket?.Question ?? t.MarketId, t.Outcome, t.Side.ToString(), t.Price, t.Size, t.Amount, t.ExecutedAt);
}
diff --git a/src/Predictalytics.Application/Services/DiscoveryService.cs b/src/Predictalytics.Application/Services/DiscoveryService.cs
index 5e3daa0..b21f1b3 100644
--- a/src/Predictalytics.Application/Services/DiscoveryService.cs
+++ b/src/Predictalytics.Application/Services/DiscoveryService.cs
@@ -48,7 +48,7 @@ public class DiscoveryService : IDiscoveryService
var existing = await _traderRepo.GetByPlatformIdAsync(platform, d.PlatformUserId, ct);
if (existing == null)
{
- await ImportTraderAsync(platform, d.PlatformUserId, d.DisplayName, ct);
+ await ImportTraderAsync(platform, d.PlatformUserId, d.DisplayName, true, ct);
newCount++;
}
}
@@ -59,7 +59,7 @@ public class DiscoveryService : IDiscoveryService
return discovered;
}
- public async Task ImportTraderAsync(PlatformType platform, string platformUserId, string displayName, CancellationToken ct = default)
+ public async Task ImportTraderAsync(PlatformType platform, string platformUserId, string displayName, bool isAutoDiscovered = true, CancellationToken ct = default)
{
var existing = await _traderRepo.GetByPlatformIdAsync(platform, platformUserId, ct);
if (existing != null) return existing.Id;
@@ -69,7 +69,7 @@ public class DiscoveryService : IDiscoveryService
Platform = platform,
PlatformUserId = platformUserId,
DisplayName = string.IsNullOrEmpty(displayName) ? platformUserId[..8] + "..." : displayName,
- IsAutoDiscovered = true,
+ IsAutoDiscovered = isAutoDiscovered,
CreatedAt = DateTime.UtcNow
};
diff --git a/src/Predictalytics.Application/Services/WatchlistService.cs b/src/Predictalytics.Application/Services/WatchlistService.cs
index 283800c..129e1d1 100644
--- a/src/Predictalytics.Application/Services/WatchlistService.cs
+++ b/src/Predictalytics.Application/Services/WatchlistService.cs
@@ -1,4 +1,4 @@
-using Predictalytics.Domain.Entities;
+using Predictalytics.Domain.Entities;
using Predictalytics.Domain.Interfaces;
using Microsoft.Extensions.Logging;
@@ -29,4 +29,14 @@ public class WatchlistService
await _repo.RemoveAsync(id, ct);
_logger.LogInformation("Removed watchlist entry {Id}", id);
}
+
+ public async Task RemoveByTraderIdAsync(int traderId, CancellationToken ct = default)
+ {
+ var existing = await _repo.GetByTraderIdAsync(traderId, ct);
+ if (existing != null)
+ {
+ await _repo.RemoveAsync(existing.Id, ct);
+ _logger.LogInformation("Removed trader {TraderId} from watchlist", traderId);
+ }
+ }
}
diff --git a/src/Predictalytics.Domain/Entities/Market.cs b/src/Predictalytics.Domain/Entities/Market.cs
index 245b7fd..75f7c6d 100644
--- a/src/Predictalytics.Domain/Entities/Market.cs
+++ b/src/Predictalytics.Domain/Entities/Market.cs
@@ -38,7 +38,10 @@ public class Market
public string Question { get; set; } = string.Empty;
/// Category / tag (e.g. "Politics", "Crypto", "Sports").
- public string Category { get; set; } = string.Empty;
+ public MarketCategory Category { get; set; }
+
+ /// Subcategory (e.g. "Basketball", "Elections", "Bitcoin").
+ public string Subcategory { get; set; } = string.Empty;
/// Current total volume traded.
public decimal Volume { get; set; }
diff --git a/src/Predictalytics.Domain/Entities/Trader.cs b/src/Predictalytics.Domain/Entities/Trader.cs
index 44b3a75..d8ce5e3 100644
--- a/src/Predictalytics.Domain/Entities/Trader.cs
+++ b/src/Predictalytics.Domain/Entities/Trader.cs
@@ -73,4 +73,5 @@ public class Trader
public virtual TraderAnalytics? Analytics { get; set; }
public ICollection WatchlistEntries { get; set; } = new List();
public ICollection Positions { get; set; } = new List();
+ public ICollection CategoryPerformances { get; set; } = new List();
}
diff --git a/src/Predictalytics.Domain/Entities/TraderCategoryPerformance.cs b/src/Predictalytics.Domain/Entities/TraderCategoryPerformance.cs
new file mode 100644
index 0000000..73b6116
--- /dev/null
+++ b/src/Predictalytics.Domain/Entities/TraderCategoryPerformance.cs
@@ -0,0 +1,28 @@
+using Predictalytics.Domain.Enums;
+
+namespace Predictalytics.Domain.Entities;
+
+public class TraderCategoryPerformance
+{
+ public int Id { get; set; }
+
+ public int TraderId { get; set; }
+ public Trader Trader { get; set; } = null!;
+
+ public MarketCategory Category { get; set; }
+
+ /// Total volume traded in this category (USD).
+ public decimal TotalVolume { get; set; }
+
+ /// Total Profit/Loss in this category (USD).
+ public decimal TotalPnL { get; set; }
+
+ /// Number of trades in this category.
+ public int TotalTrades { get; set; }
+
+ /// Number of profitable trades in this category.
+ public int WinningTrades { get; set; }
+
+ /// Calculated win rate for this category (0.0 - 1.0).
+ public decimal WinRate => TotalTrades > 0 ? (decimal)WinningTrades / TotalTrades : 0;
+}
diff --git a/src/Predictalytics.Domain/Enums/MarketCategory.cs b/src/Predictalytics.Domain/Enums/MarketCategory.cs
new file mode 100644
index 0000000..c6aa7c5
--- /dev/null
+++ b/src/Predictalytics.Domain/Enums/MarketCategory.cs
@@ -0,0 +1,13 @@
+namespace Predictalytics.Domain.Enums;
+
+public enum MarketCategory
+{
+ Other = 0,
+ Politics = 1,
+ Crypto = 2,
+ Sports = 3,
+ PopCulture = 4,
+ Science = 5,
+ GlobalNews = 6,
+ Economy = 7
+}
diff --git a/src/Predictalytics.Infrastructure/Data/AppDbContext.cs b/src/Predictalytics.Infrastructure/Data/AppDbContext.cs
index 12c15bf..34d8de8 100644
--- a/src/Predictalytics.Infrastructure/Data/AppDbContext.cs
+++ b/src/Predictalytics.Infrastructure/Data/AppDbContext.cs
@@ -18,6 +18,7 @@ public class AppDbContext : DbContext
public DbSet MarketAnalytics => Set();
public DbSet TraderPositions => Set();
public DbSet MarketOutcomePriceSnapshots => Set();
+ public DbSet TraderCategoryPerformances => Set();
public AppDbContext(DbContextOptions options) : base(options) { }
@@ -54,7 +55,7 @@ public class AppDbContext : DbContext
// Outcome: labels can be long (e.g. anime titles or sports match descriptions)
e.Property(t => t.Outcome).HasMaxLength(128);
// Price: 0.00–1.00 on prediction markets, 6 decimals sufficient
- e.Property(t => t.Price).HasPrecision(10, 6);
+ e.Property(t => t.Price).HasPrecision(18, 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);
@@ -92,7 +93,8 @@ public class AppDbContext : DbContext
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.Category).HasConversion().HasMaxLength(64);
+ e.Property(m => m.Subcategory).HasMaxLength(128);
e.Property(m => m.Volume).HasPrecision(18, 4);
e.Property(m => m.Volume24h).HasPrecision(18, 4);
e.Property(m => m.Liquidity).HasPrecision(18, 4);
@@ -142,6 +144,17 @@ public class AppDbContext : DbContext
e.HasOne(a => a.Trader).WithMany().HasForeignKey(a => a.TraderId).OnDelete(DeleteBehavior.SetNull);
});
+ // TraderCategoryPerformance
+ mb.Entity(e =>
+ {
+ e.HasKey(tcp => tcp.Id);
+ e.HasOne(tcp => tcp.Trader).WithMany().HasForeignKey(tcp => tcp.TraderId).OnDelete(DeleteBehavior.Cascade);
+ e.Property(tcp => tcp.Category).HasConversion().HasMaxLength(64);
+ e.Property(tcp => tcp.TotalVolume).HasPrecision(18, 4);
+ e.Property(tcp => tcp.TotalPnL).HasPrecision(18, 4);
+ e.HasIndex(tcp => new { tcp.TraderId, tcp.Category }).IsUnique();
+ });
+
// PlatformConfig
mb.Entity(e =>
{
diff --git a/src/Predictalytics.Infrastructure/Data/Repositories/MarketRepository.cs b/src/Predictalytics.Infrastructure/Data/Repositories/MarketRepository.cs
index e666026..7f3f5b5 100644
--- a/src/Predictalytics.Infrastructure/Data/Repositories/MarketRepository.cs
+++ b/src/Predictalytics.Infrastructure/Data/Repositories/MarketRepository.cs
@@ -192,8 +192,8 @@ public class MarketRepository : IMarketRepository
existing.PlatformMarketId = updated.PlatformMarketId;
existing.QuestionId = updated.QuestionId;
existing.Description = updated.Description;
- existing.ImageUrl = updated.ImageUrl;
existing.Category = updated.Category;
+ existing.Subcategory = updated.Subcategory;
existing.Volume = updated.Volume;
existing.Volume24h = updated.Volume24h;
existing.Liquidity = updated.Liquidity;
@@ -230,7 +230,7 @@ public class MarketRepository : IMarketRepository
market.Description = StringHelper.Truncate(market.Description, 4096);
market.MarketSlug = StringHelper.Truncate(market.MarketSlug, 512) ?? "";
market.ImageUrl = StringHelper.Truncate(market.ImageUrl, 1024);
- market.Category = StringHelper.Truncate(market.Category, 128) ?? "";
+ market.Subcategory = StringHelper.Truncate(market.Subcategory, 128) ?? "";
foreach (var o in market.Outcomes)
{
diff --git a/src/Predictalytics.Infrastructure/Data/Repositories/TradeRepository.cs b/src/Predictalytics.Infrastructure/Data/Repositories/TradeRepository.cs
index bf0a647..0ac62a2 100644
--- a/src/Predictalytics.Infrastructure/Data/Repositories/TradeRepository.cs
+++ b/src/Predictalytics.Infrastructure/Data/Repositories/TradeRepository.cs
@@ -14,27 +14,27 @@ public class TradeRepository : ITradeRepository
=> await _db.Trades.FirstOrDefaultAsync(t => t.Platform == platform && t.PlatformTradeId == platformTradeId, ct);
public async Task> GetByTraderIdAsync(int traderId, int skip = 0, int take = 50, CancellationToken ct = default)
- => await _db.Trades.Include(t => t.Trader).Where(t => t.TraderId == traderId)
+ => await _db.Trades.Include(t => t.Trader).Include(t => t.DbMarket).Where(t => t.TraderId == traderId)
.OrderByDescending(t => t.ExecutedAt).Skip(skip).Take(take).ToListAsync(ct);
public async Task> GetByDbMarketIdAsync(int dbMarketId, int skip = 0, int take = 50, CancellationToken ct = default)
- => await _db.Trades.Include(t => t.Trader).Where(t => t.DbMarketId == dbMarketId)
+ => await _db.Trades.Include(t => t.Trader).Include(t => t.DbMarket).Where(t => t.DbMarketId == dbMarketId)
.OrderByDescending(t => t.ExecutedAt).Skip(skip).Take(take).ToListAsync(ct);
public async Task> GetByMarketIdAsync(string platformMarketId, int skip = 0, int take = 50, CancellationToken ct = default)
- => await _db.Trades.Include(t => t.Trader).Where(t => t.MarketId == platformMarketId)
+ => await _db.Trades.Include(t => t.Trader).Include(t => t.DbMarket).Where(t => t.MarketId == platformMarketId)
.OrderByDescending(t => t.ExecutedAt).Skip(skip).Take(take).ToListAsync(ct);
public async Task> GetRecentAsync(int count = 50, PlatformType? platform = null, CancellationToken ct = default)
{
- var q = _db.Trades.Include(t => t.Trader).AsQueryable();
+ var q = _db.Trades.Include(t => t.Trader).Include(t => t.DbMarket).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> GetLargestAsync(int count = 5, DateTime? since = null, CancellationToken ct = default)
{
- var q = _db.Trades.Include(t => t.Trader).AsQueryable();
+ var q = _db.Trades.Include(t => t.Trader).Include(t => t.DbMarket).AsQueryable();
if (since.HasValue) q = q.Where(t => t.ExecutedAt >= since.Value);
return await q.OrderByDescending(t => t.Amount).Take(count).ToListAsync(ct);
}
diff --git a/src/Predictalytics.Infrastructure/Data/Repositories/TraderRepository.cs b/src/Predictalytics.Infrastructure/Data/Repositories/TraderRepository.cs
index a187c28..afa82f0 100644
--- a/src/Predictalytics.Infrastructure/Data/Repositories/TraderRepository.cs
+++ b/src/Predictalytics.Infrastructure/Data/Repositories/TraderRepository.cs
@@ -11,10 +11,10 @@ public class TraderRepository : ITraderRepository
public TraderRepository(AppDbContext db) => _db = db;
public async Task GetByIdAsync(int id, CancellationToken ct = default)
- => await _db.Traders.Include(t => t.CurrentScore).FirstOrDefaultAsync(t => t.Id == id, ct);
+ => await _db.Traders.Include(t => t.CurrentScore).Include(t => t.CategoryPerformances).FirstOrDefaultAsync(t => t.Id == id, ct);
public async Task GetByPlatformIdAsync(PlatformType platform, string platformUserId, CancellationToken ct = default)
- => await _db.Traders.Include(t => t.CurrentScore)
+ => await _db.Traders.Include(t => t.CurrentScore).Include(t => t.CategoryPerformances)
.FirstOrDefaultAsync(t => t.Platform == platform && t.PlatformUserId == platformUserId, ct);
public async Task> GetAllAsync(PlatformType? platform = null, int skip = 0, int take = 50, CancellationToken ct = default)
@@ -75,15 +75,18 @@ public class TraderRepository : ITraderRepository
public async Task> 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);
+ var normalCutoff = DateTime.UtcNow.AddHours(-cooldownHours);
+ var priorityCutoff = DateTime.UtcNow.AddHours(-1); // Sync priority traders more often, but not continuously
return await _db.Traders
- .Where(t => !t.IsInitialImportComplete || t.LastTradesUpdatedAt == null || t.LastTradesUpdatedAt < cutoff)
- .OrderBy(t => t.IsInitialImportComplete) // false (0) comes before true (1)
+ .Include(t => t.WatchlistEntries)
+ .Where(t => t.LastTradesUpdatedAt == null ||
+ (!t.IsInitialImportComplete) ||
+ ((!t.IsAutoDiscovered || t.WatchlistEntries.Any()) && t.LastTradesUpdatedAt < priorityCutoff) ||
+ (t.IsAutoDiscovered && t.LastTradesUpdatedAt < normalCutoff))
+ .OrderBy(t => t.IsAutoDiscovered) // Manual first (false = 0)
+ .ThenByDescending(t => t.WatchlistEntries.Any()) // Watchlisted next (true = 1)
+ .ThenBy(t => t.IsInitialImportComplete) // New ones next (false = 0)
.ThenBy(t => t.LastTradesUpdatedAt ?? DateTime.MinValue) // Oldest first
.Take(take)
.ToListAsync(ct);
diff --git a/src/Predictalytics.Infrastructure/Helpers/MarketCategoryMapper.cs b/src/Predictalytics.Infrastructure/Helpers/MarketCategoryMapper.cs
new file mode 100644
index 0000000..891316a
--- /dev/null
+++ b/src/Predictalytics.Infrastructure/Helpers/MarketCategoryMapper.cs
@@ -0,0 +1,46 @@
+using Predictalytics.Domain.Enums;
+
+namespace Predictalytics.Infrastructure.Helpers;
+
+public static class MarketCategoryMapper
+{
+ public static (MarketCategory Category, string Subcategory) Map(string rawCategory, string tags)
+ {
+ var searchString = $"{rawCategory} {tags}".ToLowerInvariant();
+
+ if (searchString.Contains("politic") || searchString.Contains("election") || searchString.Contains("trump") || searchString.Contains("biden"))
+ return (MarketCategory.Politics, GetSubcategory(rawCategory, tags, "Elections"));
+
+ if (searchString.Contains("crypto") || searchString.Contains("bitcoin") || searchString.Contains("eth") || searchString.Contains("solana"))
+ return (MarketCategory.Crypto, GetSubcategory(rawCategory, tags, "Crypto"));
+
+ if (searchString.Contains("sport") || searchString.Contains("nfl") || searchString.Contains("nba") || searchString.Contains("soccer") || searchString.Contains("tennis"))
+ return (MarketCategory.Sports, GetSubcategory(rawCategory, tags, "Sports"));
+
+ if (searchString.Contains("pop") || searchString.Contains("culture") || searchString.Contains("movie") || searchString.Contains("oscars") || searchString.Contains("music"))
+ return (MarketCategory.PopCulture, GetSubcategory(rawCategory, tags, "Pop Culture"));
+
+ if (searchString.Contains("science") || searchString.Contains("space") || searchString.Contains("weather") || searchString.Contains("climate"))
+ return (MarketCategory.Science, GetSubcategory(rawCategory, tags, "Science"));
+
+ if (searchString.Contains("news") || searchString.Contains("global") || searchString.Contains("world"))
+ return (MarketCategory.GlobalNews, GetSubcategory(rawCategory, tags, "Global News"));
+
+ if (searchString.Contains("economy") || searchString.Contains("finance") || searchString.Contains("business") || searchString.Contains("fed"))
+ return (MarketCategory.Economy, GetSubcategory(rawCategory, tags, "Economy"));
+
+ return (MarketCategory.Other, GetSubcategory(rawCategory, tags, "Other"));
+ }
+
+ private static string GetSubcategory(string rawCategory, string tags, string fallback)
+ {
+ if (!string.IsNullOrWhiteSpace(rawCategory) && !rawCategory.Equals("OVERALL", StringComparison.OrdinalIgnoreCase))
+ return rawCategory;
+
+ var firstTag = tags.Split(',', StringSplitOptions.RemoveEmptyEntries).FirstOrDefault()?.Trim();
+ if (!string.IsNullOrWhiteSpace(firstTag))
+ return firstTag;
+
+ return fallback;
+ }
+}
diff --git a/src/Predictalytics.Infrastructure/Migrations/20260705114852_AddMarketCategoryAndSubcategory.Designer.cs b/src/Predictalytics.Infrastructure/Migrations/20260705114852_AddMarketCategoryAndSubcategory.Designer.cs
new file mode 100644
index 0000000..6eeb0c7
--- /dev/null
+++ b/src/Predictalytics.Infrastructure/Migrations/20260705114852_AddMarketCategoryAndSubcategory.Designer.cs
@@ -0,0 +1,855 @@
+//
+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("20260705114852_AddMarketCategoryAndSubcategory")]
+ partial class AddMarketCategoryAndSubcategory
+ {
+ ///
+ 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("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id"));
+
+ b.Property("CreatedAt")
+ .HasColumnType("datetime(6)");
+
+ b.Property("IsRead")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("Message")
+ .IsRequired()
+ .HasMaxLength(4096)
+ .HasColumnType("varchar(4096)");
+
+ b.Property("Platform")
+ .HasColumnType("int");
+
+ b.Property("Severity")
+ .HasColumnType("int");
+
+ b.Property("Title")
+ .IsRequired()
+ .HasMaxLength(512)
+ .HasColumnType("varchar(512)");
+
+ b.Property("TraderId")
+ .HasColumnType("int");
+
+ b.Property("Type")
+ .HasColumnType("int");
+
+ b.HasKey("Id");
+
+ b.HasIndex("CreatedAt");
+
+ b.HasIndex("TraderId");
+
+ b.ToTable("Alerts");
+ });
+
+ modelBuilder.Entity("Predictalytics.Domain.Entities.Event", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id"));
+
+ b.Property("CreatedAt")
+ .HasColumnType("datetime(6)");
+
+ b.Property("DbCreatedAt")
+ .HasColumnType("datetime(6)");
+
+ b.Property("Description")
+ .HasMaxLength(4096)
+ .HasColumnType("varchar(4096)");
+
+ b.Property("EndDate")
+ .HasColumnType("datetime(6)");
+
+ b.Property("ImageUrl")
+ .HasMaxLength(1024)
+ .HasColumnType("varchar(1024)");
+
+ b.Property("IsActive")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("IsClosed")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("LastUpdatedAt")
+ .HasColumnType("datetime(6)");
+
+ b.Property("Platform")
+ .HasColumnType("int");
+
+ b.Property("PlatformEventId")
+ .HasColumnType("bigint");
+
+ b.Property("Slug")
+ .IsRequired()
+ .HasMaxLength(512)
+ .HasColumnType("varchar(512)");
+
+ b.Property("StartDate")
+ .HasColumnType("datetime(6)");
+
+ b.Property("Tags")
+ .IsRequired()
+ .HasMaxLength(1024)
+ .HasColumnType("varchar(1024)");
+
+ b.Property("Title")
+ .IsRequired()
+ .HasMaxLength(1024)
+ .HasColumnType("varchar(1024)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Platform", "PlatformEventId")
+ .IsUnique();
+
+ b.ToTable("Events");
+ });
+
+ modelBuilder.Entity("Predictalytics.Domain.Entities.Market", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id"));
+
+ b.Property("Category")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("varchar(64)");
+
+ b.Property("ConditionId")
+ .IsRequired()
+ .HasMaxLength(256)
+ .HasColumnType("varchar(256)");
+
+ b.Property("CreatedAt")
+ .HasColumnType("datetime(6)");
+
+ b.Property("DbCreatedAt")
+ .HasColumnType("datetime(6)");
+
+ b.Property("Description")
+ .HasMaxLength(4096)
+ .HasColumnType("varchar(4096)");
+
+ b.Property("EndDate")
+ .HasColumnType("datetime(6)");
+
+ b.Property("EventId")
+ .HasColumnType("int");
+
+ b.Property("ImageUrl")
+ .HasMaxLength(1024)
+ .HasColumnType("varchar(1024)");
+
+ b.Property("IsResolved")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("LastTradesUpdatedAt")
+ .HasColumnType("datetime(6)");
+
+ b.Property("LastUpdatedAt")
+ .HasColumnType("datetime(6)");
+
+ b.Property("Liquidity")
+ .HasPrecision(18, 4)
+ .HasColumnType("decimal(18,4)");
+
+ b.Property("MarketSlug")
+ .IsRequired()
+ .HasMaxLength(512)
+ .HasColumnType("varchar(512)");
+
+ b.Property("Platform")
+ .HasColumnType("int");
+
+ b.Property("PlatformMarketId")
+ .HasColumnType("bigint");
+
+ b.Property("Question")
+ .IsRequired()
+ .HasMaxLength(1024)
+ .HasColumnType("varchar(1024)");
+
+ b.Property("QuestionId")
+ .IsRequired()
+ .HasMaxLength(256)
+ .HasColumnType("varchar(256)");
+
+ b.Property("ResolutionOutcome")
+ .HasColumnType("longtext");
+
+ b.Property("StartDate")
+ .HasColumnType("datetime(6)");
+
+ b.Property("Subcategory")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("varchar(128)");
+
+ b.Property("Volume")
+ .HasPrecision(18, 4)
+ .HasColumnType("decimal(18,4)");
+
+ b.Property("Volume24h")
+ .HasPrecision(18, 4)
+ .HasColumnType("decimal(18,4)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("EventId");
+
+ b.HasIndex("Platform", "PlatformMarketId")
+ .IsUnique();
+
+ b.ToTable("Markets");
+ });
+
+ modelBuilder.Entity("Predictalytics.Domain.Entities.MarketAnalytics", b =>
+ {
+ b.Property("MarketId")
+ .HasColumnType("int");
+
+ b.Property("AverageTradeSize")
+ .HasPrecision(18, 4)
+ .HasColumnType("decimal(18,4)");
+
+ b.Property("BotActivityScore")
+ .HasPrecision(8, 4)
+ .HasColumnType("decimal(8,4)");
+
+ b.Property("LastCalculatedAt")
+ .HasColumnType("datetime(6)");
+
+ b.Property("UniqueTradersCount")
+ .HasColumnType("int");
+
+ b.HasKey("MarketId");
+
+ b.ToTable("MarketAnalytics");
+ });
+
+ modelBuilder.Entity("Predictalytics.Domain.Entities.MarketOutcome", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id"));
+
+ b.Property("CurrentPrice")
+ .HasPrecision(18, 8)
+ .HasColumnType("decimal(18,8)");
+
+ b.Property("Label")
+ .IsRequired()
+ .HasMaxLength(256)
+ .HasColumnType("varchar(256)");
+
+ b.Property("MarketId")
+ .HasColumnType("int");
+
+ b.Property("OutcomeIndex")
+ .HasColumnType("int");
+
+ b.Property("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.MarketOutcomePriceSnapshot", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id"));
+
+ b.Property("MarketOutcomeId")
+ .HasColumnType("int");
+
+ b.Property("Price")
+ .HasPrecision(10, 6)
+ .HasColumnType("decimal(10,6)");
+
+ b.Property("Timestamp")
+ .HasColumnType("datetime(6)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("MarketOutcomeId", "Timestamp");
+
+ b.ToTable("MarketOutcomePriceSnapshots");
+ });
+
+ modelBuilder.Entity("Predictalytics.Domain.Entities.PlatformConfig", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("int");
+
+ b.Property("BaseUrl")
+ .HasMaxLength(1024)
+ .HasColumnType("varchar(1024)");
+
+ b.Property("CreatedAt")
+ .HasColumnType("datetime(6)");
+
+ b.Property("DisplayName")
+ .IsRequired()
+ .HasMaxLength(256)
+ .HasColumnType("varchar(256)");
+
+ b.Property("IsActive")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("varchar(128)");
+
+ b.Property("SettingsJson")
+ .HasColumnType("longtext");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("datetime(6)");
+
+ b.HasKey("Id");
+
+ b.ToTable("PlatformConfigs");
+ });
+
+ modelBuilder.Entity("Predictalytics.Domain.Entities.Trade", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint");
+
+ MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id"));
+
+ b.Property("Amount")
+ .HasPrecision(18, 4)
+ .HasColumnType("decimal(18,4)");
+
+ b.Property("AssetId")
+ .IsRequired()
+ .HasMaxLength(80)
+ .HasColumnType("varchar(80)");
+
+ b.Property("DbMarketId")
+ .HasColumnType("int");
+
+ b.Property("ExecutedAt")
+ .HasColumnType("datetime(6)");
+
+ b.Property("IsContextEnriched")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("MarketId")
+ .IsRequired()
+ .HasMaxLength(66)
+ .HasColumnType("varchar(66)");
+
+ b.Property("MarketOutcomeId")
+ .HasColumnType("int");
+
+ b.Property("Outcome")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("varchar(128)");
+
+ b.Property("Platform")
+ .HasColumnType("int");
+
+ b.Property("PlatformTradeId")
+ .IsRequired()
+ .HasMaxLength(256)
+ .HasColumnType("varchar(256)");
+
+ b.Property("PostTradePrice1m")
+ .HasColumnType("decimal(18,4)");
+
+ b.Property("PreTradePrice1m")
+ .HasColumnType("decimal(18,4)");
+
+ b.Property("Price")
+ .HasPrecision(10, 6)
+ .HasColumnType("decimal(10,6)");
+
+ b.Property("Side")
+ .HasColumnType("int");
+
+ b.Property("Size")
+ .HasPrecision(14, 6)
+ .HasColumnType("decimal(14,6)");
+
+ b.Property("TraderId")
+ .HasColumnType("int");
+
+ b.Property("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("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id"));
+
+ b.Property("AiStrategySummary")
+ .HasColumnType("longtext");
+
+ b.Property("AiStrategyUpdatedAt")
+ .HasColumnType("datetime(6)");
+
+ b.Property("CreatedAt")
+ .HasColumnType("datetime(6)");
+
+ b.Property("DisplayName")
+ .IsRequired()
+ .HasMaxLength(256)
+ .HasColumnType("varchar(256)");
+
+ b.Property("IsAutoDiscovered")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("IsInitialImportComplete")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("IsSuspectedBot")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("LastApiErrorAt")
+ .HasColumnType("datetime(6)");
+
+ b.Property("LastPolledAt")
+ .HasColumnType("datetime(6)");
+
+ b.Property("LastTradesUpdatedAt")
+ .HasColumnType("datetime(6)");
+
+ b.Property("ManualPriorityOverride")
+ .HasColumnType("int");
+
+ b.Property("Notes")
+ .HasColumnType("longtext");
+
+ b.Property("Platform")
+ .HasColumnType("int");
+
+ b.Property("PlatformUserId")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("varchar(128)");
+
+ b.Property("Strategy")
+ .HasColumnType("int");
+
+ b.Property("Tier")
+ .HasColumnType("int");
+
+ b.Property("TotalPnl")
+ .HasPrecision(18, 4)
+ .HasColumnType("decimal(18,4)");
+
+ b.Property("TotalTrades")
+ .HasColumnType("int");
+
+ b.Property("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("TraderId")
+ .HasColumnType("int");
+
+ b.Property("LastCalculatedAt")
+ .HasColumnType("datetime(6)");
+
+ b.Property("OverallPnL")
+ .HasPrecision(18, 4)
+ .HasColumnType("decimal(18,4)");
+
+ b.Property("OverallWinRate")
+ .HasPrecision(8, 4)
+ .HasColumnType("decimal(8,4)");
+
+ b.Property("PnL24h")
+ .HasPrecision(18, 4)
+ .HasColumnType("decimal(18,4)");
+
+ b.Property("PnL30d")
+ .HasPrecision(18, 4)
+ .HasColumnType("decimal(18,4)");
+
+ b.Property("PnL7d")
+ .HasPrecision(18, 4)
+ .HasColumnType("decimal(18,4)");
+
+ b.Property("WinRate24h")
+ .HasPrecision(8, 4)
+ .HasColumnType("decimal(8,4)");
+
+ b.Property("WinRate30d")
+ .HasPrecision(8, 4)
+ .HasColumnType("decimal(8,4)");
+
+ b.Property("WinRate7d")
+ .HasPrecision(8, 4)
+ .HasColumnType("decimal(8,4)");
+
+ b.HasKey("TraderId");
+
+ b.ToTable("TraderAnalytics");
+ });
+
+ modelBuilder.Entity("Predictalytics.Domain.Entities.TraderPosition", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id"));
+
+ b.Property("AvgCost")
+ .HasPrecision(10, 6)
+ .HasColumnType("decimal(10,6)");
+
+ b.Property("LastUpdatedAt")
+ .HasColumnType("datetime(6)");
+
+ b.Property("MarketOutcomeId")
+ .HasColumnType("int");
+
+ b.Property("RealizedPnl")
+ .HasPrecision(18, 4)
+ .HasColumnType("decimal(18,4)");
+
+ b.Property("SharesHeld")
+ .HasPrecision(14, 6)
+ .HasColumnType("decimal(14,6)");
+
+ b.Property("TraderId")
+ .HasColumnType("int");
+
+ b.HasKey("Id");
+
+ b.HasIndex("MarketOutcomeId");
+
+ b.HasIndex("TraderId", "MarketOutcomeId")
+ .IsUnique();
+
+ b.ToTable("TraderPositions");
+ });
+
+ modelBuilder.Entity("Predictalytics.Domain.Entities.TraderScore", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id"));
+
+ b.Property("ActivityScore")
+ .HasPrecision(8, 4)
+ .HasColumnType("decimal(8,4)");
+
+ b.Property("CalculatedAt")
+ .HasColumnType("datetime(6)");
+
+ b.Property("CombinedScore")
+ .HasPrecision(8, 4)
+ .HasColumnType("decimal(8,4)");
+
+ b.Property("CopytradingScore")
+ .HasPrecision(8, 4)
+ .HasColumnType("decimal(8,4)");
+
+ b.Property("QualityScore")
+ .HasPrecision(8, 4)
+ .HasColumnType("decimal(8,4)");
+
+ b.Property("Rank")
+ .HasColumnType("int");
+
+ b.Property("TimingScore")
+ .HasPrecision(8, 4)
+ .HasColumnType("decimal(8,4)");
+
+ b.Property("TraderId")
+ .HasColumnType("int");
+
+ b.Property("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("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id"));
+
+ b.Property("AddedAt")
+ .HasColumnType("datetime(6)");
+
+ b.Property("AlertsEnabled")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("Label")
+ .IsRequired()
+ .HasMaxLength(256)
+ .HasColumnType("varchar(256)");
+
+ b.Property("Notes")
+ .HasColumnType("longtext");
+
+ b.Property("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.Market", b =>
+ {
+ b.HasOne("Predictalytics.Domain.Entities.Event", "Event")
+ .WithMany("Markets")
+ .HasForeignKey("EventId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Event");
+ });
+
+ 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.MarketOutcomePriceSnapshot", b =>
+ {
+ b.HasOne("Predictalytics.Domain.Entities.MarketOutcome", "MarketOutcome")
+ .WithMany()
+ .HasForeignKey("MarketOutcomeId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("MarketOutcome");
+ });
+
+ 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.TraderPosition", b =>
+ {
+ b.HasOne("Predictalytics.Domain.Entities.MarketOutcome", "MarketOutcome")
+ .WithMany()
+ .HasForeignKey("MarketOutcomeId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("Predictalytics.Domain.Entities.Trader", "Trader")
+ .WithMany("Positions")
+ .HasForeignKey("TraderId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("MarketOutcome");
+
+ 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.Event", b =>
+ {
+ b.Navigation("Markets");
+ });
+
+ 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("Positions");
+
+ b.Navigation("Trades");
+
+ b.Navigation("WatchlistEntries");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/src/Predictalytics.Infrastructure/Migrations/20260705114852_AddMarketCategoryAndSubcategory.cs b/src/Predictalytics.Infrastructure/Migrations/20260705114852_AddMarketCategoryAndSubcategory.cs
new file mode 100644
index 0000000..2efb011
--- /dev/null
+++ b/src/Predictalytics.Infrastructure/Migrations/20260705114852_AddMarketCategoryAndSubcategory.cs
@@ -0,0 +1,58 @@
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace Predictalytics.Infrastructure.Migrations
+{
+ ///
+ public partial class AddMarketCategoryAndSubcategory : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.AddColumn(
+ name: "Subcategory",
+ table: "Markets",
+ type: "varchar(128)",
+ maxLength: 128,
+ nullable: false,
+ defaultValue: "")
+ .Annotation("MySql:CharSet", "utf8mb4");
+
+ migrationBuilder.Sql("UPDATE Markets SET Subcategory = Category;");
+ migrationBuilder.Sql("UPDATE Markets SET Category = 'Other';");
+
+ migrationBuilder.AlterColumn(
+ name: "Category",
+ table: "Markets",
+ type: "varchar(64)",
+ maxLength: 64,
+ nullable: false,
+ oldClrType: typeof(string),
+ oldType: "varchar(128)",
+ oldMaxLength: 128)
+ .Annotation("MySql:CharSet", "utf8mb4")
+ .OldAnnotation("MySql:CharSet", "utf8mb4");
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropColumn(
+ name: "Subcategory",
+ table: "Markets");
+
+ migrationBuilder.AlterColumn(
+ name: "Category",
+ table: "Markets",
+ type: "varchar(128)",
+ maxLength: 128,
+ nullable: false,
+ oldClrType: typeof(string),
+ oldType: "varchar(64)",
+ oldMaxLength: 64)
+ .Annotation("MySql:CharSet", "utf8mb4")
+ .OldAnnotation("MySql:CharSet", "utf8mb4");
+ }
+ }
+}
diff --git a/src/Predictalytics.Infrastructure/Migrations/20260705115634_AddTraderCategoryPerformance.Designer.cs b/src/Predictalytics.Infrastructure/Migrations/20260705115634_AddTraderCategoryPerformance.Designer.cs
new file mode 100644
index 0000000..0b3e8ae
--- /dev/null
+++ b/src/Predictalytics.Infrastructure/Migrations/20260705115634_AddTraderCategoryPerformance.Designer.cs
@@ -0,0 +1,904 @@
+//
+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("20260705115634_AddTraderCategoryPerformance")]
+ partial class AddTraderCategoryPerformance
+ {
+ ///
+ 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("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id"));
+
+ b.Property("CreatedAt")
+ .HasColumnType("datetime(6)");
+
+ b.Property("IsRead")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("Message")
+ .IsRequired()
+ .HasMaxLength(4096)
+ .HasColumnType("varchar(4096)");
+
+ b.Property("Platform")
+ .HasColumnType("int");
+
+ b.Property("Severity")
+ .HasColumnType("int");
+
+ b.Property("Title")
+ .IsRequired()
+ .HasMaxLength(512)
+ .HasColumnType("varchar(512)");
+
+ b.Property("TraderId")
+ .HasColumnType("int");
+
+ b.Property("Type")
+ .HasColumnType("int");
+
+ b.HasKey("Id");
+
+ b.HasIndex("CreatedAt");
+
+ b.HasIndex("TraderId");
+
+ b.ToTable("Alerts");
+ });
+
+ modelBuilder.Entity("Predictalytics.Domain.Entities.Event", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id"));
+
+ b.Property("CreatedAt")
+ .HasColumnType("datetime(6)");
+
+ b.Property("DbCreatedAt")
+ .HasColumnType("datetime(6)");
+
+ b.Property("Description")
+ .HasMaxLength(4096)
+ .HasColumnType("varchar(4096)");
+
+ b.Property("EndDate")
+ .HasColumnType("datetime(6)");
+
+ b.Property("ImageUrl")
+ .HasMaxLength(1024)
+ .HasColumnType("varchar(1024)");
+
+ b.Property("IsActive")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("IsClosed")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("LastUpdatedAt")
+ .HasColumnType("datetime(6)");
+
+ b.Property("Platform")
+ .HasColumnType("int");
+
+ b.Property("PlatformEventId")
+ .HasColumnType("bigint");
+
+ b.Property("Slug")
+ .IsRequired()
+ .HasMaxLength(512)
+ .HasColumnType("varchar(512)");
+
+ b.Property("StartDate")
+ .HasColumnType("datetime(6)");
+
+ b.Property("Tags")
+ .IsRequired()
+ .HasMaxLength(1024)
+ .HasColumnType("varchar(1024)");
+
+ b.Property("Title")
+ .IsRequired()
+ .HasMaxLength(1024)
+ .HasColumnType("varchar(1024)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Platform", "PlatformEventId")
+ .IsUnique();
+
+ b.ToTable("Events");
+ });
+
+ modelBuilder.Entity("Predictalytics.Domain.Entities.Market", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id"));
+
+ b.Property("Category")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("varchar(64)");
+
+ b.Property("ConditionId")
+ .IsRequired()
+ .HasMaxLength(256)
+ .HasColumnType("varchar(256)");
+
+ b.Property("CreatedAt")
+ .HasColumnType("datetime(6)");
+
+ b.Property("DbCreatedAt")
+ .HasColumnType("datetime(6)");
+
+ b.Property("Description")
+ .HasMaxLength(4096)
+ .HasColumnType("varchar(4096)");
+
+ b.Property("EndDate")
+ .HasColumnType("datetime(6)");
+
+ b.Property("EventId")
+ .HasColumnType("int");
+
+ b.Property("ImageUrl")
+ .HasMaxLength(1024)
+ .HasColumnType("varchar(1024)");
+
+ b.Property("IsResolved")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("LastTradesUpdatedAt")
+ .HasColumnType("datetime(6)");
+
+ b.Property("LastUpdatedAt")
+ .HasColumnType("datetime(6)");
+
+ b.Property("Liquidity")
+ .HasPrecision(18, 4)
+ .HasColumnType("decimal(18,4)");
+
+ b.Property("MarketSlug")
+ .IsRequired()
+ .HasMaxLength(512)
+ .HasColumnType("varchar(512)");
+
+ b.Property("Platform")
+ .HasColumnType("int");
+
+ b.Property("PlatformMarketId")
+ .HasColumnType("bigint");
+
+ b.Property("Question")
+ .IsRequired()
+ .HasMaxLength(1024)
+ .HasColumnType("varchar(1024)");
+
+ b.Property("QuestionId")
+ .IsRequired()
+ .HasMaxLength(256)
+ .HasColumnType("varchar(256)");
+
+ b.Property("ResolutionOutcome")
+ .HasColumnType("longtext");
+
+ b.Property("StartDate")
+ .HasColumnType("datetime(6)");
+
+ b.Property("Subcategory")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("varchar(128)");
+
+ b.Property("Volume")
+ .HasPrecision(18, 4)
+ .HasColumnType("decimal(18,4)");
+
+ b.Property("Volume24h")
+ .HasPrecision(18, 4)
+ .HasColumnType("decimal(18,4)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("EventId");
+
+ b.HasIndex("Platform", "PlatformMarketId")
+ .IsUnique();
+
+ b.ToTable("Markets");
+ });
+
+ modelBuilder.Entity("Predictalytics.Domain.Entities.MarketAnalytics", b =>
+ {
+ b.Property("MarketId")
+ .HasColumnType("int");
+
+ b.Property