diff --git a/src/Predictalytics.Api/Endpoints/TraderEndpoints.cs b/src/Predictalytics.Api/Endpoints/TraderEndpoints.cs index 70bb2ed..12f76bb 100644 --- a/src/Predictalytics.Api/Endpoints/TraderEndpoints.cs +++ b/src/Predictalytics.Api/Endpoints/TraderEndpoints.cs @@ -1,4 +1,5 @@ using Predictalytics.Application.Interfaces; +using Predictalytics.Application.Services; namespace Predictalytics.Api.Endpoints; @@ -35,6 +36,18 @@ public static class TraderEndpoints return Results.Ok(); }); + group.MapPost("/{id:int}/watchlist", async (int id, WatchlistService svc, CancellationToken ct) => + { + await svc.AddAsync(id, "Watched via UI", null, ct); + return Results.Ok(); + }); + + group.MapDelete("/{id:int}/watchlist", async (int id, WatchlistService svc, CancellationToken ct) => + { + await svc.RemoveByTraderIdAsync(id, ct); + return Results.Ok(); + }); + group.MapPost("/{id:int}/ai-analysis", async (int id, bool manual, IAiStrategyAnalysisService aiSvc, CancellationToken ct) => { var summary = await aiSvc.AnalyzeTraderStrategyAsync(id, manual, ct); diff --git a/src/Predictalytics.Api/appsettings.json b/src/Predictalytics.Api/appsettings.json index 6477613..ee82e03 100644 --- a/src/Predictalytics.Api/appsettings.json +++ b/src/Predictalytics.Api/appsettings.json @@ -6,5 +6,9 @@ "LogLevel": { "Default": "Information" } + }, + "OpenRouter": { + "BaseUrl": "https://openrouter.ai/api/v1", + "ApiKey": "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" } } diff --git a/src/Predictalytics.Api/wwwroot/index.html b/src/Predictalytics.Api/wwwroot/index.html index 9fe7ead..84a5894 100644 --- a/src/Predictalytics.Api/wwwroot/index.html +++ b/src/Predictalytics.Api/wwwroot/index.html @@ -184,8 +184,12 @@

Trader Name

+ +
+
+ +
-
@@ -224,6 +228,15 @@
Total Trades
Score
+
+

Category Specialization

+
+ + + +
CategoryWin RatePnLVolumeTrades
+
+

Recent Trades

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("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(18, 6) + .HasColumnType("decimal(18,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.TraderCategoryPerformance", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Category") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("varchar(64)"); + + b.Property("TotalPnL") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("TotalTrades") + .HasColumnType("int"); + + b.Property("TotalVolume") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("TraderId") + .HasColumnType("int"); + + b.Property("WinningTrades") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("TraderId", "Category") + .IsUnique(); + + b.ToTable("TraderCategoryPerformances"); + }); + + 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.TraderCategoryPerformance", b => + { + b.HasOne("Predictalytics.Domain.Entities.Trader", "Trader") + .WithMany() + .HasForeignKey("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/20260705115634_AddTraderCategoryPerformance.cs b/src/Predictalytics.Infrastructure/Migrations/20260705115634_AddTraderCategoryPerformance.cs new file mode 100644 index 0000000..21cebcc --- /dev/null +++ b/src/Predictalytics.Infrastructure/Migrations/20260705115634_AddTraderCategoryPerformance.cs @@ -0,0 +1,78 @@ +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Predictalytics.Infrastructure.Migrations +{ + /// + public partial class AddTraderCategoryPerformance : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "Price", + table: "Trades", + type: "decimal(18,6)", + precision: 18, + scale: 6, + nullable: false, + oldClrType: typeof(decimal), + oldType: "decimal(10,6)", + oldPrecision: 10, + oldScale: 6); + + migrationBuilder.CreateTable( + name: "TraderCategoryPerformances", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn), + TraderId = table.Column(type: "int", nullable: false), + Category = table.Column(type: "varchar(64)", maxLength: 64, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + TotalVolume = table.Column(type: "decimal(18,4)", precision: 18, scale: 4, nullable: false), + TotalPnL = table.Column(type: "decimal(18,4)", precision: 18, scale: 4, nullable: false), + TotalTrades = table.Column(type: "int", nullable: false), + WinningTrades = table.Column(type: "int", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_TraderCategoryPerformances", x => x.Id); + table.ForeignKey( + name: "FK_TraderCategoryPerformances_Traders_TraderId", + column: x => x.TraderId, + principalTable: "Traders", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateIndex( + name: "IX_TraderCategoryPerformances_TraderId_Category", + table: "TraderCategoryPerformances", + columns: new[] { "TraderId", "Category" }, + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "TraderCategoryPerformances"); + + migrationBuilder.AlterColumn( + name: "Price", + table: "Trades", + type: "decimal(10,6)", + precision: 10, + scale: 6, + nullable: false, + oldClrType: typeof(decimal), + oldType: "decimal(18,6)", + oldPrecision: 18, + oldScale: 6); + } + } +} diff --git a/src/Predictalytics.Infrastructure/Migrations/AppDbContextModelSnapshot.cs b/src/Predictalytics.Infrastructure/Migrations/AppDbContextModelSnapshot.cs index df14bf1..57b1686 100644 --- a/src/Predictalytics.Infrastructure/Migrations/AppDbContextModelSnapshot.cs +++ b/src/Predictalytics.Infrastructure/Migrations/AppDbContextModelSnapshot.cs @@ -143,8 +143,8 @@ namespace Predictalytics.Infrastructure.Migrations b.Property("Category") .IsRequired() - .HasMaxLength(128) - .HasColumnType("varchar(128)"); + .HasMaxLength(64) + .HasColumnType("varchar(64)"); b.Property("ConditionId") .IsRequired() @@ -211,6 +211,11 @@ namespace Predictalytics.Infrastructure.Migrations 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)"); @@ -406,8 +411,8 @@ namespace Predictalytics.Infrastructure.Migrations .HasColumnType("decimal(18,4)"); b.Property("Price") - .HasPrecision(10, 6) - .HasColumnType("decimal(10,6)"); + .HasPrecision(18, 6) + .HasColumnType("decimal(18,6)"); b.Property("Side") .HasColumnType("int"); @@ -565,6 +570,44 @@ namespace Predictalytics.Infrastructure.Migrations b.ToTable("TraderAnalytics"); }); + modelBuilder.Entity("Predictalytics.Domain.Entities.TraderCategoryPerformance", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Category") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("varchar(64)"); + + b.Property("TotalPnL") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("TotalTrades") + .HasColumnType("int"); + + b.Property("TotalVolume") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("TraderId") + .HasColumnType("int"); + + b.Property("WinningTrades") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("TraderId", "Category") + .IsUnique(); + + b.ToTable("TraderCategoryPerformances"); + }); + modelBuilder.Entity("Predictalytics.Domain.Entities.TraderPosition", b => { b.Property("Id") @@ -776,6 +819,17 @@ namespace Predictalytics.Infrastructure.Migrations b.Navigation("Trader"); }); + modelBuilder.Entity("Predictalytics.Domain.Entities.TraderCategoryPerformance", b => + { + b.HasOne("Predictalytics.Domain.Entities.Trader", "Trader") + .WithMany() + .HasForeignKey("TraderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Trader"); + }); + modelBuilder.Entity("Predictalytics.Domain.Entities.TraderPosition", b => { b.HasOne("Predictalytics.Domain.Entities.MarketOutcome", "MarketOutcome") diff --git a/src/Predictalytics.Infrastructure/Providers/Limitless/LimitlessProvider.cs b/src/Predictalytics.Infrastructure/Providers/Limitless/LimitlessProvider.cs index 1c929f9..24e8eb0 100644 --- a/src/Predictalytics.Infrastructure/Providers/Limitless/LimitlessProvider.cs +++ b/src/Predictalytics.Infrastructure/Providers/Limitless/LimitlessProvider.cs @@ -246,6 +246,9 @@ public class LimitlessProvider : IPlatformProvider private Market MapLimitlessMarket(LimitlessMarketResponse raw) { var conditionId = raw.Address ?? raw.Slug ?? Guid.NewGuid().ToString(); + var firstCat = raw.Categories?.FirstOrDefault() ?? ""; + var catMap = Predictalytics.Infrastructure.Helpers.MarketCategoryMapper.Map(firstCat, string.Join(",", raw.Categories ?? [])); + var market = new Market { Platform = PlatformType.Limitless, @@ -254,7 +257,8 @@ public class LimitlessProvider : IPlatformProvider MarketSlug = raw.Slug ?? "", Question = raw.Title ?? "", Description = raw.Description ?? "", - Category = raw.Categories?.FirstOrDefault() ?? "", + Category = catMap.Category, + Subcategory = catMap.Subcategory, ImageUrl = raw.ImageUrl ?? "", Volume = decimal.TryParse(raw.VolumeFormatted?.Replace(" USDC", ""), out var vol) ? vol : 0, Liquidity = (decimal)(raw.Liquidity ?? 0), diff --git a/src/Predictalytics.Infrastructure/Providers/Polymarket/PolymarketProvider.cs b/src/Predictalytics.Infrastructure/Providers/Polymarket/PolymarketProvider.cs index a76f894..2cc58c3 100644 --- a/src/Predictalytics.Infrastructure/Providers/Polymarket/PolymarketProvider.cs +++ b/src/Predictalytics.Infrastructure/Providers/Polymarket/PolymarketProvider.cs @@ -279,6 +279,8 @@ public class PolymarketProvider : IPlatformProvider { long.TryParse(raw.Id, out var marketNumericId); + var catMap = Predictalytics.Infrastructure.Helpers.MarketCategoryMapper.Map(raw.Category ?? "", parentTags); + var market = new Market { Platform = PlatformType.Polymarket, @@ -289,7 +291,8 @@ public class PolymarketProvider : IPlatformProvider Description = raw.Description, ImageUrl = raw.Image, Question = raw.Question, - Category = string.IsNullOrWhiteSpace(raw.Category) ? parentTags : raw.Category, + Category = catMap.Category, + Subcategory = catMap.Subcategory, Volume = (decimal)raw.Volume, Volume24h = (decimal)raw.Volume24hr, Liquidity = (decimal)raw.Liquidity, diff --git a/src/Predictalytics.Infrastructure/Services/PositionPnLEngine.cs b/src/Predictalytics.Infrastructure/Services/PositionPnLEngine.cs index 730f9a0..5671a2f 100644 --- a/src/Predictalytics.Infrastructure/Services/PositionPnLEngine.cs +++ b/src/Predictalytics.Infrastructure/Services/PositionPnLEngine.cs @@ -225,6 +225,31 @@ public class PositionPnLEngine : IPositionPnLEngine trader.TotalPnl = overallPnl; trader.WinRate = winRateOverall; + // Calculate Category Performance + var existingCatPerf = await _db.TraderCategoryPerformances + .Where(tcp => tcp.TraderId == traderId) + .ToDictionaryAsync(tcp => tcp.Category, ct); + + var newCatPerf = CalculateCategoryPerformances(trades, tempPositions); + + foreach (var kvp in newCatPerf) + { + if (existingCatPerf.TryGetValue(kvp.Key, out var existing)) + { + existing.TotalVolume = kvp.Value.TotalVolume; + existing.TotalPnL = kvp.Value.TotalPnL; + existing.TotalTrades = kvp.Value.TotalTrades; + existing.WinningTrades = kvp.Value.WinningTrades; + _db.TraderCategoryPerformances.Update(existing); + } + else + { + var newEntity = kvp.Value; + newEntity.TraderId = traderId; + _db.TraderCategoryPerformances.Add(newEntity); + } + } + // Save changes to database await _db.SaveChangesAsync(ct); @@ -322,4 +347,64 @@ public class PositionPnLEngine : IPositionPnLEngine return false; } + + private static Dictionary CalculateCategoryPerformances( + List trades, + Dictionary finalPositions) + { + var result = new Dictionary(); + + var tradesByMarket = trades + .Where(t => t.MarketOutcome?.Market != null) + .GroupBy(t => t.MarketOutcome!.Market!); + + foreach (var marketGroup in tradesByMarket) + { + var market = marketGroup.Key; + var category = market.Category; + + if (!result.TryGetValue(category, out var perf)) + { + perf = new TraderCategoryPerformance { Category = category }; + result[category] = perf; + } + + // Add volume + perf.TotalVolume += marketGroup.Sum(t => t.Amount); + + // Determine if market is closed for this trader + var outcomeIds = marketGroup + .Where(t => t.MarketOutcomeId.HasValue) + .Select(t => t.MarketOutcomeId!.Value) + .Distinct() + .ToList(); + + var isClosed = outcomeIds.All(oid => !finalPositions.TryGetValue(oid, out var pos) || pos.SharesHeld == 0); + if (!isClosed && market.IsResolved) + { + isClosed = true; + } + + if (isClosed) + { + decimal marketPnl = 0; + foreach (var oid in outcomeIds) + { + if (finalPositions.TryGetValue(oid, out var pos)) + { + marketPnl += pos.RealizedPnl; + } + } + + perf.TotalPnL += marketPnl; + perf.TotalTrades += 1; + if (marketPnl > 0) + { + perf.WinningTrades += 1; + } + } + } + + return result; + } } diff --git a/src/Predictalytics.WinFormsHost/MainForm.cs b/src/Predictalytics.WinFormsHost/MainForm.cs index 194acf2..fd7e6f0 100644 --- a/src/Predictalytics.WinFormsHost/MainForm.cs +++ b/src/Predictalytics.WinFormsHost/MainForm.cs @@ -175,7 +175,7 @@ public partial class MainForm : Form } } - private async void syncMarketsaToolStripMenuItem_Click(object sender, EventArgs e) + private async void syncMarketsaToolStripMenuItem_Click(object? sender, EventArgs e) { if (_workerRunning) { @@ -207,7 +207,7 @@ public partial class MainForm : Form } } - private async void btn_dbUpdate_Click(object sender, EventArgs e) + private async void btn_dbUpdate_Click(object? sender, EventArgs e) { if (_workerRunning) {