diff --git a/src/Predictalytics.Api/Endpoints/TraderEndpoints.cs b/src/Predictalytics.Api/Endpoints/TraderEndpoints.cs index eb03d3e..70bb2ed 100644 --- a/src/Predictalytics.Api/Endpoints/TraderEndpoints.cs +++ b/src/Predictalytics.Api/Endpoints/TraderEndpoints.cs @@ -35,6 +35,12 @@ public static class TraderEndpoints 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); + return Results.Ok(new { summary }); + }); + group.MapPost("/", async (string platform, string wallet, IAnalyticsService svc, CancellationToken ct) => { var id = await svc.AddTraderAsync(platform, wallet, ct); diff --git a/src/Predictalytics.Api/wwwroot/index.html b/src/Predictalytics.Api/wwwroot/index.html index f63532c..9fe7ead 100644 --- a/src/Predictalytics.Api/wwwroot/index.html +++ b/src/Predictalytics.Api/wwwroot/index.html @@ -188,22 +188,33 @@
-
-
-
Platform
-
+
+
+
+
Platform
+
+
+
+
Platform ID
+
+
+
+
Tier
+
+
+
+
Strategy
+
+
-
-
Platform ID
-
-
-
-
Tier
-
-
-
-
Strategy
-
+
+
+
+ AI Analysis + +
+
Not analyzed yet.
+
diff --git a/src/Predictalytics.Api/wwwroot/js/app.js b/src/Predictalytics.Api/wwwroot/js/app.js index 5cee0b7..1c7f829 100644 --- a/src/Predictalytics.Api/wwwroot/js/app.js +++ b/src/Predictalytics.Api/wwwroot/js/app.js @@ -149,7 +149,12 @@ async function api(endpoint) { // ─── Format Helpers ─── const fmt = { usd: v => { if (v === null || v === undefined) return '$0'; const n = Number(v); return n >= 1000000 ? `$${(n/1000000).toFixed(1)}M` : n >= 1000 ? `$${(n/1000).toFixed(1)}K` : `$${n.toFixed(0)}`; }, - pct: v => { if (v === null || v === undefined) return '0%'; return `${Number(v).toFixed(1)}%`; }, + pct: v => { + if (v === null || v === undefined) return '0%'; + const n = Number(v); + const color = n > 55 ? 'var(--pnl-positive)' : n < 45 ? 'var(--pnl-negative)' : 'var(--text)'; + return `${n.toFixed(1)}%`; + }, num: v => { if (v === null || v === undefined) return '0'; return Number(v).toLocaleString(); }, time: v => { if (!v) return '—'; const d = new Date(v); const now = new Date(); const diff = (now - d) / 1000; if (diff < 60) return `${Math.floor(diff)}s ago`; @@ -207,15 +212,15 @@ async function loadDashboard() { // Top Traders table const tbody = document.getElementById('topTradersBody'); tbody.innerHTML = data.topTraders.map((t, i) => ` - - ${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)} + + ${i + 1} + ${t.platform === 'Polymarket' ? `${t.displayName}` : 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)} `).join(''); @@ -282,7 +287,7 @@ async function loadTraders() { tbody.innerHTML = data.map((t, i) => ` ${i + 1} - ${t.displayName} + ${t.platform === 'Polymarket' ? `${t.displayName}` : t.displayName} ${t.platform} ${Number(t.combinedScore).toFixed(1)} ${fmt.pct(t.winRate)} @@ -350,10 +355,14 @@ async function viewTrader(id) { 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); + document.getElementById('td-ai-summary').textContent = t.aiStrategySummary || 'Not analyzed yet.'; const refreshBtn = document.getElementById('btn-refresh-trader'); refreshBtn.onclick = () => manualUpdateTrader(id); + const aiBtn = document.getElementById('btn-ai-analysis'); + aiBtn.onclick = () => triggerAiAnalysis(id, true); + const tbody = document.getElementById('td-tradesBody'); tbody.innerHTML = t.recentTrades.map(tr => ` @@ -428,3 +437,24 @@ setInterval(() => { const activePage = document.querySelector('.page.active'); if (activePage?.id === 'page-dashboard') loadDashboard(); }, 30000); + +async function triggerAiAnalysis(id, manual) { + const btn = document.getElementById('btn-ai-analysis'); + const oldText = btn.textContent; + btn.textContent = 'Analyzing...'; + btn.disabled = true; + try { + const res = await api(`/api/traders/${id}/ai-analysis?manual=${manual}`, { method: 'POST' }); + if (res && res.summary) { + document.getElementById('td-ai-summary').textContent = res.summary; + } else { + alert('Analysis failed or returned empty.'); + } + } catch (e) { + console.error(e); + alert('Analysis error: ' + e); + } finally { + btn.textContent = oldText; + btn.disabled = false; + } +} diff --git a/src/Predictalytics.Application.Tests/Services/AnalyticsServiceTests.cs b/src/Predictalytics.Application.Tests/Services/AnalyticsServiceTests.cs index 4c0b791..9a5b13e 100644 --- a/src/Predictalytics.Application.Tests/Services/AnalyticsServiceTests.cs +++ b/src/Predictalytics.Application.Tests/Services/AnalyticsServiceTests.cs @@ -52,7 +52,7 @@ public class AnalyticsServiceTests var trader = new Trader { Id = 1, PlatformUserId = "0x1", DisplayName = "Trader 1" }; db.Traders.Add(trader); - var market = new Market { Id = 10, PlatformMarketId = "pm1", Question = "Q?" }; + var market = new Market { Id = 10, PlatformMarketId = 1L, Question = "Q?" }; var outcome = new MarketOutcome { Id = 100, MarketId = 10, Label = "Yes", TokenId = "t100", CurrentPrice = 0.50m }; market.Outcomes.Add(outcome); db.Markets.Add(market); diff --git a/src/Predictalytics.Application.Tests/Services/PositionPnLEngineTests.cs b/src/Predictalytics.Application.Tests/Services/PositionPnLEngineTests.cs index ab3db45..49092a9 100644 --- a/src/Predictalytics.Application.Tests/Services/PositionPnLEngineTests.cs +++ b/src/Predictalytics.Application.Tests/Services/PositionPnLEngineTests.cs @@ -28,7 +28,7 @@ public class PositionPnLEngineTests var pnlEngine = new PositionPnLEngine(db, NullLogger.Instance); var trader = new Trader { Id = 1, PlatformUserId = "0x1", DisplayName = "Trader 1" }; - var market = new Market { Id = 10, PlatformMarketId = "pm1", Question = "Q?" }; + var market = new Market { Id = 10, PlatformMarketId = 1L, Question = "Q?" }; var outcome = new MarketOutcome { Id = 100, MarketId = 10, Label = "Yes", TokenId = "t100", CurrentPrice = 0.60m }; market.Outcomes.Add(outcome); @@ -69,7 +69,7 @@ public class PositionPnLEngineTests var pnlEngine = new PositionPnLEngine(db, NullLogger.Instance); var trader = new Trader { Id = 1, PlatformUserId = "0x1", DisplayName = "Trader 1" }; - var market = new Market { Id = 10, PlatformMarketId = "pm1", Question = "Q?" }; + var market = new Market { Id = 10, PlatformMarketId = 1L, Question = "Q?" }; var outcome = new MarketOutcome { Id = 100, MarketId = 10, Label = "Yes", TokenId = "t100", CurrentPrice = 0.60m }; market.Outcomes.Add(outcome); @@ -113,7 +113,7 @@ public class PositionPnLEngineTests var pnlEngine = new PositionPnLEngine(db, NullLogger.Instance); var trader = new Trader { Id = 1, PlatformUserId = "0x1", DisplayName = "Trader 1" }; - var market = new Market { Id = 10, PlatformMarketId = "pm1", Question = "Q?", IsResolved = true, ResolutionOutcome = "Yes" }; + var market = new Market { Id = 10, PlatformMarketId = 1L, Question = "Q?", IsResolved = true, ResolutionOutcome = "Yes" }; var outcome = new MarketOutcome { Id = 100, MarketId = 10, Label = "Yes", TokenId = "t100", CurrentPrice = 1.00m }; market.Outcomes.Add(outcome); diff --git a/src/Predictalytics.Application/DTOs/TraderDto.cs b/src/Predictalytics.Application/DTOs/TraderDto.cs index 4144674..883faa3 100644 --- a/src/Predictalytics.Application/DTOs/TraderDto.cs +++ b/src/Predictalytics.Application/DTOs/TraderDto.cs @@ -42,5 +42,6 @@ public record TraderDetailDto( bool IsOnWatchlist, DateTime CreatedAt, DateTime? LastPolledAt, + string? AiStrategySummary, IReadOnlyList RecentTrades ); diff --git a/src/Predictalytics.Application/Interfaces/IAiStrategyAnalysisService.cs b/src/Predictalytics.Application/Interfaces/IAiStrategyAnalysisService.cs new file mode 100644 index 0000000..326df42 --- /dev/null +++ b/src/Predictalytics.Application/Interfaces/IAiStrategyAnalysisService.cs @@ -0,0 +1,9 @@ +using System.Threading; +using System.Threading.Tasks; + +namespace Predictalytics.Application.Interfaces; + +public interface IAiStrategyAnalysisService +{ + Task AnalyzeTraderStrategyAsync(int traderId, bool manual, CancellationToken ct = default); +} diff --git a/src/Predictalytics.Application/Interfaces/IOpenRouterApiClient.cs b/src/Predictalytics.Application/Interfaces/IOpenRouterApiClient.cs new file mode 100644 index 0000000..ebf709d --- /dev/null +++ b/src/Predictalytics.Application/Interfaces/IOpenRouterApiClient.cs @@ -0,0 +1,9 @@ +using System.Threading; +using System.Threading.Tasks; + +namespace Predictalytics.Application.Interfaces; + +public interface IOpenRouterApiClient +{ + Task GenerateChatCompletionAsync(string prompt, bool useManualModel = false, CancellationToken ct = default); +} diff --git a/src/Predictalytics.Application/Interfaces/IPlatformStatisticsService.cs b/src/Predictalytics.Application/Interfaces/IPlatformStatisticsService.cs index ef3a5b7..0b3ed78 100644 --- a/src/Predictalytics.Application/Interfaces/IPlatformStatisticsService.cs +++ b/src/Predictalytics.Application/Interfaces/IPlatformStatisticsService.cs @@ -7,6 +7,7 @@ public interface IPlatformStatisticsService void TrackMarketSync(PlatformType platform, int count = 1); void TrackTraderDiscovery(PlatformType platform, int count = 1); void TrackTradeActivity(PlatformType platform, int count = 1); + void TrackDuplicateError(PlatformType platform, int count = 1); Dictionary GetAndResetStats(); } @@ -16,4 +17,5 @@ public class PlatformStats public int MarketsSynced { get; set; } public int TradersDiscovered { get; set; } public int TradesProcessed { get; set; } + public int DuplicateErrors { get; set; } } diff --git a/src/Predictalytics.Application/Interfaces/IRateLimiter.cs b/src/Predictalytics.Application/Interfaces/IRateLimiter.cs index 6d1a24d..ac41e4e 100644 --- a/src/Predictalytics.Application/Interfaces/IRateLimiter.cs +++ b/src/Predictalytics.Application/Interfaces/IRateLimiter.cs @@ -8,11 +8,11 @@ namespace Predictalytics.Application.Interfaces; public interface IRateLimiter { /// Wait until a request can be made to the given platform. - Task WaitAsync(PlatformType platform, CancellationToken ct = default); + Task WaitAsync(PlatformType platform, CancellationToken ct = default, string endpointGroup = "Default"); /// Check if a request can be made immediately. - bool CanMakeRequest(PlatformType platform); + bool CanMakeRequest(PlatformType platform, string endpointGroup = "Default"); /// Report that a 429 Too Many Requests was received. - void ReportRateLimitExceeded(PlatformType platform, TimeSpan? retryAfter = null); + void ReportRateLimitExceeded(PlatformType platform, TimeSpan? retryAfter = null, string endpointGroup = "Default"); } diff --git a/src/Predictalytics.Application/Services/AiStrategyAnalysisService.cs b/src/Predictalytics.Application/Services/AiStrategyAnalysisService.cs new file mode 100644 index 0000000..8ba2d8f --- /dev/null +++ b/src/Predictalytics.Application/Services/AiStrategyAnalysisService.cs @@ -0,0 +1,76 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Predictalytics.Application.Interfaces; +using Predictalytics.Domain.Entities; +using Predictalytics.Domain.Interfaces; + +namespace Predictalytics.Application.Services; + +public class AiStrategyAnalysisService : IAiStrategyAnalysisService +{ + private readonly ITraderRepository _traderRepo; + private readonly ITradeRepository _tradeRepo; + private readonly IOpenRouterApiClient _openRouter; + private readonly ILogger _logger; + + public AiStrategyAnalysisService( + ITraderRepository traderRepo, + ITradeRepository tradeRepo, + IOpenRouterApiClient openRouter, + ILogger logger) + { + _traderRepo = traderRepo; + _tradeRepo = tradeRepo; + _openRouter = openRouter; + _logger = logger; + } + + public async Task AnalyzeTraderStrategyAsync(int traderId, bool manual, CancellationToken ct = default) + { + var trader = await _traderRepo.GetByIdAsync(traderId, ct); + if (trader == null) return "Trader not found."; + + var trades = await _tradeRepo.GetByTraderIdAsync(traderId, 0, 500, ct); + + var prompt = BuildTraderContext(trader, trades); + + _logger.LogInformation("Sending {Model} AI analysis request for trader {TraderName}", manual ? "Manual (Claude)" : "Auto (Gemini)", trader.DisplayName); + + var result = await _openRouter.GenerateChatCompletionAsync(prompt, manual, ct); + + trader.AiStrategySummary = result; + trader.AiStrategyUpdatedAt = DateTime.UtcNow; + + await _traderRepo.UpdateAsync(trader, ct); + + return result; + } + + private string BuildTraderContext(Trader trader, IReadOnlyList trades) + { + var sb = new StringBuilder(); + sb.AppendLine($"Analyze the following prediction market trader."); + sb.AppendLine($"Name: {trader.DisplayName}"); + sb.AppendLine($"Platform: {trader.Platform}"); + sb.AppendLine($"Total PnL: ${trader.TotalPnl:F2}"); + sb.AppendLine($"Win Rate: {trader.WinRate:F2}%"); + sb.AppendLine($"Total Trades: {trader.TotalTrades}"); + sb.AppendLine(); + + sb.AppendLine("Recent Trades:"); + foreach (var trade in trades.OrderByDescending(t => t.ExecutedAt).Take(50)) + { + sb.AppendLine($"- {trade.ExecutedAt:yyyy-MM-dd}: {trade.Side} {trade.Size:F0} shares of '{trade.Outcome}' @ ${trade.Price:F2} (Total: ${trade.Amount:F2})"); + } + + sb.AppendLine(); + sb.AppendLine("Based on these stats and recent trades, provide a concise summary of their strategy (e.g. Value investor, Arbitrageur, News-driven, Degen). Highlight their main strengths and weaknesses. Keep it under 100 words."); + + return sb.ToString(); + } +} diff --git a/src/Predictalytics.Application/Services/AnalyticsService.cs b/src/Predictalytics.Application/Services/AnalyticsService.cs index 007e633..f9e6e2b 100644 --- a/src/Predictalytics.Application/Services/AnalyticsService.cs +++ b/src/Predictalytics.Application/Services/AnalyticsService.cs @@ -191,6 +191,7 @@ public class AnalyticsService : IAnalyticsService 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()); } @@ -204,14 +205,14 @@ public class AnalyticsService : IAnalyticsService if (recentTrades.Count == 0) { // Fallback: trades ingested before DbMarketId backfill - recentTrades = await _tradeRepo.GetByMarketIdAsync(market.PlatformMarketId, 0, 50, ct); + recentTrades = await _tradeRepo.GetByMarketIdAsync(market.ConditionId, 0, 50, ct); } return new MarketDetailDto { Id = market.Id, Platform = market.Platform.ToString(), - PlatformMarketId = market.PlatformMarketId, + PlatformMarketId = market.ConditionId, Question = market.Question, Description = market.Description, Category = market.Category, diff --git a/src/Predictalytics.Application/Services/PlatformStatisticsService.cs b/src/Predictalytics.Application/Services/PlatformStatisticsService.cs index f6683d6..e309ff2 100644 --- a/src/Predictalytics.Application/Services/PlatformStatisticsService.cs +++ b/src/Predictalytics.Application/Services/PlatformStatisticsService.cs @@ -26,6 +26,12 @@ public class PlatformStatisticsService : IPlatformStatisticsService lock (stats) stats.TradesProcessed += count; } + public void TrackDuplicateError(PlatformType platform, int count = 1) + { + var stats = _stats.GetOrAdd(platform, _ => new PlatformStats()); + lock (stats) stats.DuplicateErrors += count; + } + public Dictionary GetAndResetStats() { var result = new Dictionary(); diff --git a/src/Predictalytics.Application/Services/RateLimiterService.cs b/src/Predictalytics.Application/Services/RateLimiterService.cs index 21096ff..dbcecde 100644 --- a/src/Predictalytics.Application/Services/RateLimiterService.cs +++ b/src/Predictalytics.Application/Services/RateLimiterService.cs @@ -9,30 +9,35 @@ namespace Predictalytics.Application.Services; /// public class RateLimiterService : IRateLimiter { - private readonly ConcurrentDictionary _semaphores = new(); - private readonly ConcurrentDictionary _lastRequest = new(); - private readonly ConcurrentDictionary _blockedUntil = new(); + private readonly ConcurrentDictionary _semaphores = new(); + private readonly ConcurrentDictionary _lastRequest = new(); + private readonly ConcurrentDictionary _blockedUntil = new(); - // Minimum delay between requests per platform (milliseconds) - private static readonly Dictionary PlatformDelays = new() + private static readonly Dictionary Delays = new() { - { PlatformType.Polymarket, 200 }, - { PlatformType.Limitless, 500 }, - { PlatformType.Azuro, 1000 }, - { PlatformType.Myriad, 1000 }, - { PlatformType.PredictFun, 1000 }, - { PlatformType.Kalshi, 500 }, - { PlatformType.Stake, 1000 } + { "Polymarket-Gamma", 35 }, // ~28 req/s (Gamma /markets limit is 30/s) + { "Polymarket-Data", 55 }, // ~18 req/s (Data /trades limit is 20/s) + { "Polymarket-Clob", 15 }, // ~66 req/s (Clob /prices limit is 100/s) + { "Polymarket-Default", 0 }, // Bypass worker-level limiting for Polymarket, handled in ApiClient + + { "Limitless-Default", 500 }, + { "Azuro-Default", 1000 }, + { "Myriad-Default", 1000 }, + { "PredictFun-Default", 1000 }, + { "Kalshi-Default", 500 }, + { "Stake-Default", 1000 } }; - public async Task WaitAsync(PlatformType platform, CancellationToken ct = default) + public async Task WaitAsync(PlatformType platform, CancellationToken ct = default, string endpointGroup = "Default") { - var sem = _semaphores.GetOrAdd(platform, _ => new SemaphoreSlim(1, 1)); + var key = $"{platform}-{endpointGroup}"; + if (!Delays.ContainsKey(key)) key = $"{platform}-Default"; + + var sem = _semaphores.GetOrAdd(key, _ => new SemaphoreSlim(1, 1)); await sem.WaitAsync(ct); try { - // 1. Check if we are currently blocked due to a 429 - if (_blockedUntil.TryGetValue(platform, out var blockedUntil)) + if (_blockedUntil.TryGetValue(key, out var blockedUntil)) { var waitTime = blockedUntil - DateTime.UtcNow; if (waitTime > TimeSpan.Zero) @@ -41,32 +46,37 @@ public class RateLimiterService : IRateLimiter } } - // 2. Normal token bucket delay - if (_lastRequest.TryGetValue(platform, out var last)) + if (_lastRequest.TryGetValue(key, out var last)) { - var delayMs = PlatformDelays.GetValueOrDefault(platform, 1000); + var delayMs = Delays.GetValueOrDefault(key, 1000); var elapsed = (DateTime.UtcNow - last).TotalMilliseconds; if (elapsed < delayMs) await Task.Delay((int)(delayMs - elapsed), ct); } - _lastRequest[platform] = DateTime.UtcNow; + _lastRequest[key] = DateTime.UtcNow; } finally { sem.Release(); } } - public bool CanMakeRequest(PlatformType platform) + public bool CanMakeRequest(PlatformType platform, string endpointGroup = "Default") { - if (_blockedUntil.TryGetValue(platform, out var blockedUntil) && blockedUntil > DateTime.UtcNow) + var key = $"{platform}-{endpointGroup}"; + if (!Delays.ContainsKey(key)) key = $"{platform}-Default"; + + if (_blockedUntil.TryGetValue(key, out var blockedUntil) && blockedUntil > DateTime.UtcNow) return false; - if (!_lastRequest.TryGetValue(platform, out var last)) return true; - var delayMs = PlatformDelays.GetValueOrDefault(platform, 1000); + if (!_lastRequest.TryGetValue(key, out var last)) return true; + var delayMs = Delays.GetValueOrDefault(key, 1000); return (DateTime.UtcNow - last).TotalMilliseconds >= delayMs; } - public void ReportRateLimitExceeded(PlatformType platform, TimeSpan? retryAfter = null) + public void ReportRateLimitExceeded(PlatformType platform, TimeSpan? retryAfter = null, string endpointGroup = "Default") { + var key = $"{platform}-{endpointGroup}"; + if (!Delays.ContainsKey(key)) key = $"{platform}-Default"; + var penalty = retryAfter ?? TimeSpan.FromSeconds(30); - _blockedUntil[platform] = DateTime.UtcNow.Add(penalty); + _blockedUntil[key] = DateTime.UtcNow.Add(penalty); } } diff --git a/src/Predictalytics.Domain/Entities/Event.cs b/src/Predictalytics.Domain/Entities/Event.cs new file mode 100644 index 0000000..cb90aac --- /dev/null +++ b/src/Predictalytics.Domain/Entities/Event.cs @@ -0,0 +1,56 @@ +using Predictalytics.Domain.Enums; + +namespace Predictalytics.Domain.Entities; + +/// +/// Represents a prediction market event, which can contain multiple Markets (Questions). +/// +public class Event +{ + public int Id { get; set; } + + /// Platform this event belongs to. + public PlatformType Platform { get; set; } + + /// Platform-specific numeric identifier for the event. + public long PlatformEventId { get; set; } + + /// URL-friendly slug for the event. + public string Slug { get; set; } = string.Empty; + + /// Event title. + public string Title { get; set; } = string.Empty; + + /// Detailed event description. + public string? Description { get; set; } + + /// Event image URL. + public string? ImageUrl { get; set; } + + /// The time when the event starts. + public DateTime? StartDate { get; set; } + + /// The time when the event ends. + public DateTime? EndDate { get; set; } + + /// Comma-separated tags (categories) associated with the event. + public string Tags { get; set; } = string.Empty; + + /// Whether the event is currently active. + public bool IsActive { get; set; } + + /// Whether the event is closed. + public bool IsClosed { get; set; } + + /// When this event was created on the platform. + public DateTime CreatedAt { get; set; } + + /// When this record was first saved to our database. + public DateTime DbCreatedAt { get; set; } = DateTime.UtcNow; + + /// Last time event data was refreshed. + public DateTime? LastUpdatedAt { get; set; } + + // Navigation + public ICollection Markets { get; set; } = new List(); +} diff --git a/src/Predictalytics.Domain/Entities/Market.cs b/src/Predictalytics.Domain/Entities/Market.cs index 14edaa4..245b7fd 100644 --- a/src/Predictalytics.Domain/Entities/Market.cs +++ b/src/Predictalytics.Domain/Entities/Market.cs @@ -12,15 +12,22 @@ public class Market /// Platform this market belongs to. public PlatformType Platform { get; set; } - /// Platform-specific market identifier (conditionId on Polymarket). - public string PlatformMarketId { get; set; } = string.Empty; + /// The Event this market belongs to. + public int EventId { get; set; } + public virtual Event Event { get; set; } + + /// Platform-specific numeric market identifier. + public long PlatformMarketId { get; set; } + + /// Platform-specific blockchain market identifier (conditionId on Polymarket). + public string ConditionId { get; set; } = string.Empty; + + /// Smart contract question ID. + public string QuestionId { get; set; } = string.Empty; /// URL-friendly slug for the market. public string MarketSlug { get; set; } = string.Empty; - /// URL-friendly slug for the parent event. - public string EventSlug { get; set; } = string.Empty; - /// Detailed market description / resolution criteria. public string? Description { get; set; } @@ -36,6 +43,9 @@ public class Market /// Current total volume traded. public decimal Volume { get; set; } + /// Current 24h volume. + public decimal Volume24h { get; set; } + /// Current liquidity. public decimal Liquidity { get; set; } diff --git a/src/Predictalytics.Domain/Entities/Trade.cs b/src/Predictalytics.Domain/Entities/Trade.cs index f590752..aebbea9 100644 --- a/src/Predictalytics.Domain/Entities/Trade.cs +++ b/src/Predictalytics.Domain/Entities/Trade.cs @@ -66,6 +66,19 @@ public class Trade /// Transaction hash (for blockchain-based platforms). public string? TransactionHash { get; set; } + // ── Context Enrichment (AI Strategy Detection) ─────────── + + /// Market price 1 minute before trade execution. + [Column(TypeName = "decimal(18,4)")] + public decimal? PreTradePrice1m { get; set; } + + /// Market price 1 minute after trade execution. + [Column(TypeName = "decimal(18,4)")] + public decimal? PostTradePrice1m { get; set; } + + /// Indicates if high-res price context was fetched. + public bool IsContextEnriched { get; set; } + // ── Transient (not persisted) ────────────────────────────────────────── /// diff --git a/src/Predictalytics.Domain/Entities/Trader.cs b/src/Predictalytics.Domain/Entities/Trader.cs index 82bdc53..44b3a75 100644 --- a/src/Predictalytics.Domain/Entities/Trader.cs +++ b/src/Predictalytics.Domain/Entities/Trader.cs @@ -58,6 +58,12 @@ public class Trader /// Win rate as a percentage (0-100). public decimal WinRate { get; set; } + /// AI-generated strategy summary based on trade history. + public string? AiStrategySummary { get; set; } + + /// When the AI strategy summary was last updated. + public DateTime? AiStrategyUpdatedAt { get; set; } + /// Total number of trades tracked. public int TotalTrades { get; set; } diff --git a/src/Predictalytics.Domain/Interfaces/IMarketRepository.cs b/src/Predictalytics.Domain/Interfaces/IMarketRepository.cs index 0611352..c43d74e 100644 --- a/src/Predictalytics.Domain/Interfaces/IMarketRepository.cs +++ b/src/Predictalytics.Domain/Interfaces/IMarketRepository.cs @@ -10,6 +10,7 @@ public interface IMarketRepository Task> GetOutcomesByTokenIdsAsync(IEnumerable tokenIds, CancellationToken ct = default); Task AddOrUpdateAsync(Market market, CancellationToken ct = default); Task AddOrUpdateRangeAsync(IEnumerable markets, CancellationToken ct = default); + Task AddOrUpdateEventsAsync(IEnumerable events, CancellationToken ct = default); Task> GetActiveAsync(int count = 50, CancellationToken ct = default); Task GetCountAsync(CancellationToken ct = default); Task> GetMarketsDueForTradeUpdateAsync(int cooldownHours, int limit, CancellationToken ct = default); diff --git a/src/Predictalytics.Domain/Interfaces/IPlatformProvider.cs b/src/Predictalytics.Domain/Interfaces/IPlatformProvider.cs index 4c0b864..6092657 100644 --- a/src/Predictalytics.Domain/Interfaces/IPlatformProvider.cs +++ b/src/Predictalytics.Domain/Interfaces/IPlatformProvider.cs @@ -30,8 +30,8 @@ public interface IPlatformProvider /// Fetch market metadata by platform-specific market ID. Task GetMarketAsync(string platformMarketId, CancellationToken ct = default); - /// Fetch a batch of markets with their outcomes for bulk sync. - Task> GetMarketsAsync(int limit = 100, string? cursor = null, bool includeClosed = false, CancellationToken ct = default); + /// Fetch a batch of events with their markets and outcomes for bulk sync. + Task> GetEventsAsync(int limit = 100, string? cursor = null, bool includeClosed = false, CancellationToken ct = default); /// Fetch top holders for a market to discover new traders. Task> GetTopHoldersAsync(string platformMarketId, int limit = 20, CancellationToken ct = default); diff --git a/src/Predictalytics.Domain/Interfaces/ITradeRepository.cs b/src/Predictalytics.Domain/Interfaces/ITradeRepository.cs index b544427..ee8bf27 100644 --- a/src/Predictalytics.Domain/Interfaces/ITradeRepository.cs +++ b/src/Predictalytics.Domain/Interfaces/ITradeRepository.cs @@ -19,4 +19,5 @@ public interface ITradeRepository Task> GetOrphanedTradesAsync(int limit, CancellationToken ct = default); Task> GetKnownPlatformTradeIdsAsync(PlatformType platform, int traderId, IEnumerable platformTradeIds, CancellationToken ct = default); Task UpdateAsync(Trade trade, CancellationToken ct = default); + Task> GetTradesForContextEnrichmentAsync(int limit, CancellationToken ct = default); } diff --git a/src/Predictalytics.Domain/Interfaces/ITraderRepository.cs b/src/Predictalytics.Domain/Interfaces/ITraderRepository.cs index eedde49..7df92cc 100644 --- a/src/Predictalytics.Domain/Interfaces/ITraderRepository.cs +++ b/src/Predictalytics.Domain/Interfaces/ITraderRepository.cs @@ -22,5 +22,8 @@ public interface ITraderRepository /// Get traders that haven't been polled in a long time or have a prolonged API error for cleanup. Task> GetTradersForCleanupAsync(DateTime inactiveSince, DateTime errorSince, int take = 50, CancellationToken ct = default); + /// Get traders ordered by LastPolledAt to ensure round-robin polling of all traders. + Task> GetTradersForPollingAsync(int take, CancellationToken ct = default); + Task> SearchAsync(string query, int take = 20, CancellationToken ct = default); } diff --git a/src/Predictalytics.Domain/ValueObjects/PriorityScore.cs b/src/Predictalytics.Domain/ValueObjects/PriorityScore.cs index 624f4b6..89cd7dc 100644 --- a/src/Predictalytics.Domain/ValueObjects/PriorityScore.cs +++ b/src/Predictalytics.Domain/ValueObjects/PriorityScore.cs @@ -1,4 +1,4 @@ -using Predictalytics.Domain.Enums; +using Predictalytics.Domain.Enums; namespace Predictalytics.Domain.ValueObjects; @@ -23,14 +23,15 @@ public record PriorityScore( /// /// Determine tier based on effective score. + /// To reach Diamond, the score must be exceptionally high (top 1%). /// public TraderTier DetermineTier() => EffectiveScore switch { - >= 90 => TraderTier.Diamond, - >= 75 => TraderTier.Platinum, - >= 60 => TraderTier.Gold, - >= 40 => TraderTier.Silver, - >= 20 => TraderTier.Bronze, + >= 95 => TraderTier.Diamond, + >= 85 => TraderTier.Platinum, + >= 70 => TraderTier.Gold, + >= 50 => TraderTier.Silver, + >= 30 => TraderTier.Bronze, _ => TraderTier.Unknown }; } diff --git a/src/Predictalytics.Infrastructure/Data/AppDbContext.cs b/src/Predictalytics.Infrastructure/Data/AppDbContext.cs index e4977df..12c15bf 100644 --- a/src/Predictalytics.Infrastructure/Data/AppDbContext.cs +++ b/src/Predictalytics.Infrastructure/Data/AppDbContext.cs @@ -7,6 +7,7 @@ public class AppDbContext : DbContext { public DbSet Traders => Set(); public DbSet Trades => Set(); + public DbSet Events => Set(); public DbSet Markets => Set(); public DbSet MarketOutcomes => Set(); public DbSet TraderScores => Set(); @@ -66,19 +67,34 @@ public class AppDbContext : DbContext .OnDelete(DeleteBehavior.SetNull); }); + // Event + mb.Entity(e => + { + e.HasKey(ev => ev.Id); + e.HasIndex(ev => new { ev.Platform, ev.PlatformEventId }).IsUnique(); + e.Property(ev => ev.Slug).HasMaxLength(512); + e.Property(ev => ev.Title).HasMaxLength(1024); + e.Property(ev => ev.Description).HasMaxLength(4096); + e.Property(ev => ev.ImageUrl).HasMaxLength(1024); + e.Property(ev => ev.Tags).HasMaxLength(1024); + e.HasMany(ev => ev.Markets).WithOne(m => m.Event).HasForeignKey(m => m.EventId) + .OnDelete(DeleteBehavior.Cascade); + }); + // Market mb.Entity(e => { e.HasKey(m => m.Id); e.HasIndex(m => new { m.Platform, m.PlatformMarketId }).IsUnique(); - e.Property(m => m.PlatformMarketId).HasMaxLength(256); + e.Property(m => m.ConditionId).HasMaxLength(256); + e.Property(m => m.QuestionId).HasMaxLength(256); e.Property(m => m.MarketSlug).HasMaxLength(512); - e.Property(m => m.EventSlug).HasMaxLength(512); e.Property(m => m.Question).HasMaxLength(1024); e.Property(m => m.Description).HasMaxLength(4096); e.Property(m => m.ImageUrl).HasMaxLength(1024); e.Property(m => m.Category).HasMaxLength(128); e.Property(m => m.Volume).HasPrecision(18, 4); + e.Property(m => m.Volume24h).HasPrecision(18, 4); e.Property(m => m.Liquidity).HasPrecision(18, 4); e.HasMany(m => m.Outcomes).WithOne(o => o.Market).HasForeignKey(o => o.MarketId) .OnDelete(DeleteBehavior.Cascade); diff --git a/src/Predictalytics.Infrastructure/Data/Repositories/MarketRepository.cs b/src/Predictalytics.Infrastructure/Data/Repositories/MarketRepository.cs index 54036dd..e666026 100644 --- a/src/Predictalytics.Infrastructure/Data/Repositories/MarketRepository.cs +++ b/src/Predictalytics.Infrastructure/Data/Repositories/MarketRepository.cs @@ -14,8 +14,8 @@ public class MarketRepository : IMarketRepository public MarketRepository(AppDbContext db) => _db = db; public async Task GetByPlatformIdAsync(PlatformType platform, string platformMarketId, CancellationToken ct = default) - => await _db.Markets.Include(m => m.Outcomes) - .FirstOrDefaultAsync(m => m.Platform == platform && m.PlatformMarketId == platformMarketId, ct); + => await _db.Markets.Include(m => m.Outcomes).Include(m => m.Event) + .FirstOrDefaultAsync(m => m.Platform == platform && m.ConditionId == platformMarketId, ct); public async Task GetOutcomeByTokenIdAsync(string tokenId, CancellationToken ct = default) => await _db.MarketOutcomes.Include(o => o.Market) @@ -34,7 +34,7 @@ public class MarketRepository : IMarketRepository TruncateMarketStrings(market); var existing = await _db.Markets.Include(m => m.Outcomes) - .FirstOrDefaultAsync(m => m.Platform == market.Platform && m.PlatformMarketId == market.PlatformMarketId, ct); + .FirstOrDefaultAsync(m => m.Platform == market.Platform && m.ConditionId == market.ConditionId, ct); if (existing != null) { @@ -42,6 +42,11 @@ public class MarketRepository : IMarketRepository } else { + if (market.Event == null && market.EventId == 0) + { + // Fallback to avoid foreign key exceptions if event is entirely missing + market.Event = new Event { Platform = market.Platform, PlatformEventId = market.PlatformMarketId, Slug = "unknown", Title = "Unknown" }; + } _db.Markets.Add(market); } @@ -55,9 +60,9 @@ public class MarketRepository : IMarketRepository public async Task AddOrUpdateRangeAsync(IEnumerable markets, CancellationToken ct = default) { - // Deduplicate input by PlatformMarketId to avoid processing the same ID twice in one call + // Deduplicate input by ConditionId to avoid processing the same ID twice in one call var marketList = markets - .GroupBy(m => new { m.Platform, m.PlatformMarketId }) + .GroupBy(m => new { m.Platform, m.ConditionId }) .Select(g => g.First()) .ToList(); @@ -72,25 +77,28 @@ public class MarketRepository : IMarketRepository { var currentBatch = marketList.Skip(i).Take(subBatchSize).ToList(); var platform = currentBatch.First().Platform; - var ids = currentBatch.Select(m => m.PlatformMarketId).ToList(); + var ids = currentBatch.Select(m => m.ConditionId).ToList(); - // Fetch all existing markets in this batch at once var existingMarkets = await _db.Markets.Include(m => m.Outcomes) - .Where(m => m.Platform == platform && ids.Contains(m.PlatformMarketId)) + .Where(m => m.Platform == platform && ids.Contains(m.ConditionId)) .ToListAsync(ct); - var existingMap = existingMarkets.ToDictionary(m => m.PlatformMarketId); + var existingMap = existingMarkets.ToDictionary(m => m.ConditionId); foreach (var market in currentBatch) { TruncateMarketStrings(market); - if (existingMap.TryGetValue(market.PlatformMarketId, out var existing)) + if (existingMap.TryGetValue(market.ConditionId, out var existing)) { UpdateMarketFields(existing, market); } else { + if (market.Event == null && market.EventId == 0) + { + market.Event = new Event { Platform = market.Platform, PlatformEventId = market.PlatformMarketId, Slug = "unknown", Title = "Unknown" }; + } _db.Markets.Add(market); } } @@ -104,21 +112,96 @@ public class MarketRepository : IMarketRepository } } + public async Task AddOrUpdateEventsAsync(IEnumerable events, CancellationToken ct = default) + { + var eventList = events.GroupBy(e => new { e.Platform, e.PlatformEventId }).Select(g => g.First()).ToList(); + if (!eventList.Any()) return; + + await _syncSemaphore.WaitAsync(ct); + try + { + const int subBatchSize = 100; + for (int i = 0; i < eventList.Count; i += subBatchSize) + { + var currentBatch = eventList.Skip(i).Take(subBatchSize).ToList(); + var platform = currentBatch.First().Platform; + var eventIds = currentBatch.Select(e => e.PlatformEventId).ToList(); + + var existingEvents = await _db.Events + .Include(e => e.Markets).ThenInclude(m => m.Outcomes) + .Where(e => e.Platform == platform && eventIds.Contains(e.PlatformEventId)) + .ToListAsync(ct); + + var existingEventsMap = existingEvents.ToDictionary(e => e.PlatformEventId); + + foreach (var ev in currentBatch) + { + if (ev.Slug != null && ev.Slug.Length > 512) ev.Slug = ev.Slug[..512]; + if (ev.Title != null && ev.Title.Length > 1024) ev.Title = ev.Title[..1024]; + + if (existingEventsMap.TryGetValue(ev.PlatformEventId, out var existing)) + { + existing.Slug = ev.Slug; + existing.Title = ev.Title; + existing.Description = ev.Description; + existing.ImageUrl = ev.ImageUrl; + existing.Tags = ev.Tags; + existing.StartDate = ev.StartDate; + existing.EndDate = ev.EndDate; + existing.IsActive = ev.IsActive; + existing.IsClosed = ev.IsClosed; + existing.LastUpdatedAt = DateTime.UtcNow; + + // Upsert markets inside event + foreach (var market in ev.Markets) + { + TruncateMarketStrings(market); + var existingMarket = existing.Markets.FirstOrDefault(m => m.ConditionId == market.ConditionId); + if (existingMarket != null) + { + UpdateMarketFields(existingMarket, market); + } + else + { + market.EventId = existing.Id; + market.Event = null; // Prevent EF tracking issue + existing.Markets.Add(market); + } + } + } + else + { + foreach (var m in ev.Markets) TruncateMarketStrings(m); + _db.Events.Add(ev); + } + } + + await _db.SaveChangesAsync(ct); + } + } + finally + { + _syncSemaphore.Release(); + } + } + private void UpdateMarketFields(Market existing, Market updated) { existing.Question = updated.Question; existing.MarketSlug = updated.MarketSlug; - existing.EventSlug = updated.EventSlug; + existing.PlatformMarketId = updated.PlatformMarketId; + existing.QuestionId = updated.QuestionId; existing.Description = updated.Description; existing.ImageUrl = updated.ImageUrl; existing.Category = updated.Category; existing.Volume = updated.Volume; + existing.Volume24h = updated.Volume24h; existing.Liquidity = updated.Liquidity; existing.StartDate = updated.StartDate; existing.EndDate = updated.EndDate; existing.IsResolved = updated.IsResolved; existing.ResolutionOutcome = updated.ResolutionOutcome; - existing.CreatedAt = updated.CreatedAt; // Platform creation date + existing.CreatedAt = updated.CreatedAt; existing.LastUpdatedAt = DateTime.UtcNow; // Upsert outcomes @@ -146,7 +229,6 @@ public class MarketRepository : IMarketRepository market.Question = StringHelper.Truncate(market.Question, 1024) ?? ""; market.Description = StringHelper.Truncate(market.Description, 4096); market.MarketSlug = StringHelper.Truncate(market.MarketSlug, 512) ?? ""; - market.EventSlug = StringHelper.Truncate(market.EventSlug, 512) ?? ""; market.ImageUrl = StringHelper.Truncate(market.ImageUrl, 1024); market.Category = StringHelper.Truncate(market.Category, 128) ?? ""; @@ -192,7 +274,7 @@ public class MarketRepository : IMarketRepository return await _db.Markets.Include(m => m.Outcomes) .Where(m => m.Question.Contains(query) || - m.PlatformMarketId.Contains(query) || + m.ConditionId.Contains(query) || m.Id.ToString() == query) .OrderByDescending(m => m.Volume) .Take(take) diff --git a/src/Predictalytics.Infrastructure/Data/Repositories/TradeRepository.cs b/src/Predictalytics.Infrastructure/Data/Repositories/TradeRepository.cs index 71f0fed..bf0a647 100644 --- a/src/Predictalytics.Infrastructure/Data/Repositories/TradeRepository.cs +++ b/src/Predictalytics.Infrastructure/Data/Repositories/TradeRepository.cs @@ -92,13 +92,13 @@ public class TradeRepository : ITradeRepository public async Task> GetKnownPlatformTradeIdsAsync(PlatformType platform, int traderId, IEnumerable platformTradeIds, CancellationToken ct = default) { var idList = platformTradeIds.ToList(); - if (idList.Count == 0) return new HashSet(); + if (idList.Count == 0) return new HashSet(StringComparer.OrdinalIgnoreCase); var ids = await _db.Trades - .Where(t => t.Platform == platform && t.TraderId == traderId && idList.Contains(t.PlatformTradeId)) + .Where(t => t.Platform == platform && idList.Contains(t.PlatformTradeId)) .Select(t => t.PlatformTradeId) .ToListAsync(ct); - return new HashSet(ids); + return new HashSet(ids, StringComparer.OrdinalIgnoreCase); } public async Task UpdateAsync(Trade trade, CancellationToken ct = default) @@ -111,4 +111,19 @@ public class TradeRepository : ITradeRepository _db.Trades.Update(trade); await _db.SaveChangesAsync(ct); } + + public async Task> GetTradesForContextEnrichmentAsync(int limit, CancellationToken ct = default) + { + return await _db.Trades + .Include(t => t.Trader) + .Include(t => t.Trader.CurrentScore) + .Include(t => t.Trader.WatchlistEntries) + .Where(t => !t.IsContextEnriched + && t.Platform == PlatformType.Polymarket + && t.AssetId != "") + .Where(t => t.Trader.WatchlistEntries.Any() || (t.Trader.CurrentScore != null && t.Trader.CurrentScore.CopytradingScore > 50)) + .OrderByDescending(t => t.ExecutedAt) + .Take(limit) + .ToListAsync(ct); + } } diff --git a/src/Predictalytics.Infrastructure/Data/Repositories/TraderRepository.cs b/src/Predictalytics.Infrastructure/Data/Repositories/TraderRepository.cs index 39df856..a187c28 100644 --- a/src/Predictalytics.Infrastructure/Data/Repositories/TraderRepository.cs +++ b/src/Predictalytics.Infrastructure/Data/Repositories/TraderRepository.cs @@ -100,6 +100,14 @@ public class TraderRepository : ITraderRepository .ToListAsync(ct); } + public async Task> GetTradersForPollingAsync(int take, CancellationToken ct = default) + { + return await _db.Traders + .OrderBy(t => t.LastPolledAt) + .Take(take) + .ToListAsync(ct); + } + public async Task> SearchAsync(string query, int take = 20, CancellationToken ct = default) { if (string.IsNullOrWhiteSpace(query)) return Array.Empty(); diff --git a/src/Predictalytics.Infrastructure/DependencyInjection.cs b/src/Predictalytics.Infrastructure/DependencyInjection.cs index fc1c2fd..4caaabf 100644 --- a/src/Predictalytics.Infrastructure/DependencyInjection.cs +++ b/src/Predictalytics.Infrastructure/DependencyInjection.cs @@ -84,6 +84,8 @@ public static class DependencyInjection services.AddSingleton(); services.AddSingleton(); + services.AddHttpClient(); + services.AddScoped(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/src/Predictalytics.Infrastructure/Migrations/20260703100444_AddTradePriceContext.Designer.cs b/src/Predictalytics.Infrastructure/Migrations/20260703100444_AddTradePriceContext.Designer.cs new file mode 100644 index 0000000..7df3489 --- /dev/null +++ b/src/Predictalytics.Infrastructure/Migrations/20260703100444_AddTradePriceContext.Designer.cs @@ -0,0 +1,750 @@ +// +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("20260703100444_AddTradePriceContext")] + partial class AddTradePriceContext + { + /// + 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.Market", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Category") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("varchar(128)"); + + 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("EventSlug") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("varchar(512)"); + + 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") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("Question") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("varchar(1024)"); + + b.Property("ResolutionOutcome") + .HasColumnType("longtext"); + + b.Property("StartDate") + .HasColumnType("datetime(6)"); + + b.Property("Volume") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.HasKey("Id"); + + b.HasIndex("Platform", "PlatformMarketId") + .IsUnique(); + + b.ToTable("Markets"); + }); + + modelBuilder.Entity("Predictalytics.Domain.Entities.MarketAnalytics", b => + { + b.Property("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("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.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.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/20260703100444_AddTradePriceContext.cs b/src/Predictalytics.Infrastructure/Migrations/20260703100444_AddTradePriceContext.cs new file mode 100644 index 0000000..23e89dd --- /dev/null +++ b/src/Predictalytics.Infrastructure/Migrations/20260703100444_AddTradePriceContext.cs @@ -0,0 +1,49 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Predictalytics.Infrastructure.Migrations +{ + /// + public partial class AddTradePriceContext : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "IsContextEnriched", + table: "Trades", + type: "tinyint(1)", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "PostTradePrice1m", + table: "Trades", + type: "decimal(18,4)", + nullable: true); + + migrationBuilder.AddColumn( + name: "PreTradePrice1m", + table: "Trades", + type: "decimal(18,4)", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "IsContextEnriched", + table: "Trades"); + + migrationBuilder.DropColumn( + name: "PostTradePrice1m", + table: "Trades"); + + migrationBuilder.DropColumn( + name: "PreTradePrice1m", + table: "Trades"); + } + } +} diff --git a/src/Predictalytics.Infrastructure/Migrations/20260703114907_AddEventsAndTags.Designer.cs b/src/Predictalytics.Infrastructure/Migrations/20260703114907_AddEventsAndTags.Designer.cs new file mode 100644 index 0000000..2914227 --- /dev/null +++ b/src/Predictalytics.Infrastructure/Migrations/20260703114907_AddEventsAndTags.Designer.cs @@ -0,0 +1,844 @@ +// +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("20260703114907_AddEventsAndTags")] + partial class AddEventsAndTags + { + /// + 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(128) + .HasColumnType("varchar(128)"); + + 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("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("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/20260703114907_AddEventsAndTags.cs b/src/Predictalytics.Infrastructure/Migrations/20260703114907_AddEventsAndTags.cs new file mode 100644 index 0000000..d68b700 --- /dev/null +++ b/src/Predictalytics.Infrastructure/Migrations/20260703114907_AddEventsAndTags.cs @@ -0,0 +1,167 @@ +using System; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Predictalytics.Infrastructure.Migrations +{ + /// + public partial class AddEventsAndTags : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql("SET FOREIGN_KEY_CHECKS=0; TRUNCATE TABLE TraderPositions; TRUNCATE TABLE Trades; TRUNCATE TABLE MarketOutcomes; TRUNCATE TABLE Markets; SET FOREIGN_KEY_CHECKS=1;"); + + migrationBuilder.DropColumn( + name: "EventSlug", + table: "Markets"); + + migrationBuilder.AlterColumn( + name: "PlatformMarketId", + table: "Markets", + type: "bigint", + nullable: false, + oldClrType: typeof(string), + oldType: "varchar(256)", + oldMaxLength: 256) + .OldAnnotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.AddColumn( + name: "ConditionId", + table: "Markets", + type: "varchar(256)", + maxLength: 256, + nullable: false, + defaultValue: "") + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.AddColumn( + name: "EventId", + table: "Markets", + type: "int", + nullable: false, + defaultValue: 0); + + migrationBuilder.AddColumn( + name: "QuestionId", + table: "Markets", + type: "varchar(256)", + maxLength: 256, + nullable: false, + defaultValue: "") + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.AddColumn( + name: "Volume24h", + table: "Markets", + type: "decimal(18,4)", + precision: 18, + scale: 4, + nullable: false, + defaultValue: 0m); + + migrationBuilder.CreateTable( + name: "Events", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn), + Platform = table.Column(type: "int", nullable: false), + PlatformEventId = table.Column(type: "bigint", nullable: false), + Slug = table.Column(type: "varchar(512)", maxLength: 512, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + Title = table.Column(type: "varchar(1024)", maxLength: 1024, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + Description = table.Column(type: "varchar(4096)", maxLength: 4096, nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"), + ImageUrl = table.Column(type: "varchar(1024)", maxLength: 1024, nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"), + StartDate = table.Column(type: "datetime(6)", nullable: true), + EndDate = table.Column(type: "datetime(6)", nullable: true), + Tags = table.Column(type: "varchar(1024)", maxLength: 1024, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + IsActive = table.Column(type: "tinyint(1)", nullable: false), + IsClosed = table.Column(type: "tinyint(1)", nullable: false), + CreatedAt = table.Column(type: "datetime(6)", nullable: false), + DbCreatedAt = table.Column(type: "datetime(6)", nullable: false), + LastUpdatedAt = table.Column(type: "datetime(6)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_Events", x => x.Id); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateIndex( + name: "IX_Markets_EventId", + table: "Markets", + column: "EventId"); + + migrationBuilder.CreateIndex( + name: "IX_Events_Platform_PlatformEventId", + table: "Events", + columns: new[] { "Platform", "PlatformEventId" }, + unique: true); + + migrationBuilder.AddForeignKey( + name: "FK_Markets_Events_EventId", + table: "Markets", + column: "EventId", + principalTable: "Events", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_Markets_Events_EventId", + table: "Markets"); + + migrationBuilder.DropTable( + name: "Events"); + + migrationBuilder.DropIndex( + name: "IX_Markets_EventId", + table: "Markets"); + + migrationBuilder.DropColumn( + name: "ConditionId", + table: "Markets"); + + migrationBuilder.DropColumn( + name: "EventId", + table: "Markets"); + + migrationBuilder.DropColumn( + name: "QuestionId", + table: "Markets"); + + migrationBuilder.DropColumn( + name: "Volume24h", + table: "Markets"); + + migrationBuilder.AlterColumn( + name: "PlatformMarketId", + table: "Markets", + type: "varchar(256)", + maxLength: 256, + nullable: false, + oldClrType: typeof(long), + oldType: "bigint") + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.AddColumn( + name: "EventSlug", + table: "Markets", + type: "varchar(512)", + maxLength: 512, + nullable: false, + defaultValue: "") + .Annotation("MySql:CharSet", "utf8mb4"); + } + } +} diff --git a/src/Predictalytics.Infrastructure/Migrations/20260704120722_AddAiStrategyFields.Designer.cs b/src/Predictalytics.Infrastructure/Migrations/20260704120722_AddAiStrategyFields.Designer.cs new file mode 100644 index 0000000..f821c67 --- /dev/null +++ b/src/Predictalytics.Infrastructure/Migrations/20260704120722_AddAiStrategyFields.Designer.cs @@ -0,0 +1,850 @@ +// +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("20260704120722_AddAiStrategyFields")] + partial class AddAiStrategyFields + { + /// + 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(128) + .HasColumnType("varchar(128)"); + + 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("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/20260704120722_AddAiStrategyFields.cs b/src/Predictalytics.Infrastructure/Migrations/20260704120722_AddAiStrategyFields.cs new file mode 100644 index 0000000..2cf2de4 --- /dev/null +++ b/src/Predictalytics.Infrastructure/Migrations/20260704120722_AddAiStrategyFields.cs @@ -0,0 +1,40 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Predictalytics.Infrastructure.Migrations +{ + /// + public partial class AddAiStrategyFields : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "AiStrategySummary", + table: "Traders", + type: "longtext", + nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.AddColumn( + name: "AiStrategyUpdatedAt", + table: "Traders", + type: "datetime(6)", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "AiStrategySummary", + table: "Traders"); + + migrationBuilder.DropColumn( + name: "AiStrategyUpdatedAt", + table: "Traders"); + } + } +} diff --git a/src/Predictalytics.Infrastructure/Migrations/AppDbContextModelSnapshot.cs b/src/Predictalytics.Infrastructure/Migrations/AppDbContextModelSnapshot.cs index ec0e69c..df14bf1 100644 --- a/src/Predictalytics.Infrastructure/Migrations/AppDbContextModelSnapshot.cs +++ b/src/Predictalytics.Infrastructure/Migrations/AppDbContextModelSnapshot.cs @@ -67,7 +67,7 @@ namespace Predictalytics.Infrastructure.Migrations b.ToTable("Alerts"); }); - modelBuilder.Entity("Predictalytics.Domain.Entities.Market", b => + modelBuilder.Entity("Predictalytics.Domain.Entities.Event", b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -75,11 +75,6 @@ namespace Predictalytics.Infrastructure.Migrations MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); - b.Property("Category") - .IsRequired() - .HasMaxLength(128) - .HasColumnType("varchar(128)"); - b.Property("CreatedAt") .HasColumnType("datetime(6)"); @@ -93,11 +88,85 @@ namespace Predictalytics.Infrastructure.Migrations b.Property("EndDate") .HasColumnType("datetime(6)"); - b.Property("EventSlug") + 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(128) + .HasColumnType("varchar(128)"); + + 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)"); @@ -123,16 +192,19 @@ namespace Predictalytics.Infrastructure.Migrations b.Property("Platform") .HasColumnType("int"); - b.Property("PlatformMarketId") - .IsRequired() - .HasMaxLength(256) - .HasColumnType("varchar(256)"); + 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"); @@ -143,8 +215,14 @@ namespace Predictalytics.Infrastructure.Migrations .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(); @@ -297,6 +375,9 @@ namespace Predictalytics.Infrastructure.Migrations b.Property("ExecutedAt") .HasColumnType("datetime(6)"); + b.Property("IsContextEnriched") + .HasColumnType("tinyint(1)"); + b.Property("MarketId") .IsRequired() .HasMaxLength(66) @@ -318,6 +399,12 @@ namespace Predictalytics.Infrastructure.Migrations .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)"); @@ -362,6 +449,12 @@ namespace Predictalytics.Infrastructure.Migrations MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + b.Property("AiStrategySummary") + .HasColumnType("longtext"); + + b.Property("AiStrategyUpdatedAt") + .HasColumnType("datetime(6)"); + b.Property("CreatedAt") .HasColumnType("datetime(6)"); @@ -603,6 +696,17 @@ namespace Predictalytics.Infrastructure.Migrations 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") @@ -713,6 +817,11 @@ namespace Predictalytics.Infrastructure.Migrations b.Navigation("Trader"); }); + modelBuilder.Entity("Predictalytics.Domain.Entities.Event", b => + { + b.Navigation("Markets"); + }); + modelBuilder.Entity("Predictalytics.Domain.Entities.Market", b => { b.Navigation("Analytics"); diff --git a/src/Predictalytics.Infrastructure/Providers/Azuro/AzuroProvider.cs b/src/Predictalytics.Infrastructure/Providers/Azuro/AzuroProvider.cs index ea6778b..d05a46f 100644 --- a/src/Predictalytics.Infrastructure/Providers/Azuro/AzuroProvider.cs +++ b/src/Predictalytics.Infrastructure/Providers/Azuro/AzuroProvider.cs @@ -31,8 +31,8 @@ public class AzuroProvider : IPlatformProvider public Task GetMarketAsync(string platformMarketId, CancellationToken ct = default) { using var _ = PlatformLogContext.Push(PlatformName); _logger.LogWarning("Provider not yet implemented"); return Task.FromResult(null); } - public Task> GetMarketsAsync(int limit = 100, string? cursor = null, bool includeClosed = false, CancellationToken ct = default) - { using var _ = PlatformLogContext.Push(PlatformName); return Task.FromResult>(Array.Empty()); } + public Task> GetEventsAsync(int limit = 100, string? cursor = null, bool includeClosed = false, CancellationToken ct = default) + { using var _ = PlatformLogContext.Push(PlatformName); return Task.FromResult>(Array.Empty()); } public Task> GetTopHoldersAsync(string platformMarketId, int limit = 20, CancellationToken ct = default) { using var _ = PlatformLogContext.Push(PlatformName); return Task.FromResult>(Array.Empty()); } diff --git a/src/Predictalytics.Infrastructure/Providers/Limitless/LimitlessApiClient.cs b/src/Predictalytics.Infrastructure/Providers/Limitless/LimitlessApiClient.cs index 5367536..19df354 100644 --- a/src/Predictalytics.Infrastructure/Providers/Limitless/LimitlessApiClient.cs +++ b/src/Predictalytics.Infrastructure/Providers/Limitless/LimitlessApiClient.cs @@ -23,6 +23,7 @@ public class LimitlessApiClient var url = $"markets/active?limit={Math.Min(limit, 25)}"; // Offset is not supported by this endpoint, limit max 25 try { + var response = await _client.GetAsync(url, ct); if (!response.IsSuccessStatusCode) { @@ -45,6 +46,7 @@ public class LimitlessApiClient var url = $"markets/{addressOrSlug}"; try { + var response = await _client.GetAsync(url, ct); if (!response.IsSuccessStatusCode) return null; return await response.Content.ReadFromJsonAsync(cancellationToken: ct); @@ -61,6 +63,7 @@ public class LimitlessApiClient var url = $"portfolio/{walletAddress}/positions"; try { + var response = await _client.GetAsync(url, ct); if (!response.IsSuccessStatusCode) { @@ -82,6 +85,7 @@ public class LimitlessApiClient var url = $"markets/{slug}/events?limit={limit}"; try { + var response = await _client.GetAsync(url, ct); if (!response.IsSuccessStatusCode) return []; var result = await response.Content.ReadFromJsonAsync(cancellationToken: ct); diff --git a/src/Predictalytics.Infrastructure/Providers/Limitless/LimitlessProvider.cs b/src/Predictalytics.Infrastructure/Providers/Limitless/LimitlessProvider.cs index 543c29c..1c929f9 100644 --- a/src/Predictalytics.Infrastructure/Providers/Limitless/LimitlessProvider.cs +++ b/src/Predictalytics.Infrastructure/Providers/Limitless/LimitlessProvider.cs @@ -164,17 +164,33 @@ public class LimitlessProvider : IPlatformProvider return MapLimitlessMarket(raw); } - public async Task> GetMarketsAsync(int limit = 100, string? cursor = null, bool includeClosed = false, CancellationToken ct = default) + public async Task> GetEventsAsync(int limit = 100, string? cursor = null, bool includeClosed = false, CancellationToken ct = default) { using var _ = PlatformLogContext.Push(PlatformName); - int.TryParse(cursor, out var offset); - - // Since Limitless /markets/active doesn't support offset, we only return the first page. - // Returning data for offset > 0 would cause an infinite loop in MarketSyncWorker. - if (offset > 0) return []; + var offset = 0; + if (!string.IsNullOrEmpty(cursor) && int.TryParse(cursor, out var parsed)) + offset = parsed; + _logger.LogInformation("Fetching markets batch (limit={Limit}, offset={Offset})", limit, offset); var raw = await _api.GetActiveMarketsAsync(limit, offset, ct); - return raw.Select(MapLimitlessMarket).ToList(); + _logger.LogInformation("Fetched {Count} markets from Limitless API", raw.Count); + + var events = new List(); + foreach (var r in raw) + { + var m = MapLimitlessMarket(r); + events.Add(new Event + { + Platform = PlatformType.Limitless, + PlatformEventId = m.PlatformMarketId, // Use market ID as Event ID + Slug = "limitless-" + m.ConditionId, + Title = m.Question, + DbCreatedAt = DateTime.UtcNow, + LastUpdatedAt = DateTime.UtcNow, + Markets = new List { m } + }); + } + return events; } public async Task> GetTopHoldersAsync(string platformMarketId, int limit = 20, CancellationToken ct = default) @@ -229,12 +245,13 @@ public class LimitlessProvider : IPlatformProvider private Market MapLimitlessMarket(LimitlessMarketResponse raw) { + var conditionId = raw.Address ?? raw.Slug ?? Guid.NewGuid().ToString(); var market = new Market { Platform = PlatformType.Limitless, - PlatformMarketId = raw.Address ?? raw.Slug ?? "", + ConditionId = conditionId, + PlatformMarketId = GetStableHashCode(conditionId), MarketSlug = raw.Slug ?? "", - EventSlug = "", // Limitless doesn't seem to have a clear Event/Market split in this model Question = raw.Title ?? "", Description = raw.Description ?? "", Category = raw.Categories?.FirstOrDefault() ?? "", @@ -279,4 +296,15 @@ public class LimitlessProvider : IPlatformProvider if (sideStr == "0" || sideStr == "BUY") return TradeSide.Buy; return TradeSide.Sell; } + + private static long GetStableHashCode(string str) + { + unchecked + { + long hash = 23; + foreach (char c in str) + hash = hash * 31 + c; + return hash; + } + } } diff --git a/src/Predictalytics.Infrastructure/Providers/OpenRouter/OpenRouterApiClient.cs b/src/Predictalytics.Infrastructure/Providers/OpenRouter/OpenRouterApiClient.cs new file mode 100644 index 0000000..29bd9cc --- /dev/null +++ b/src/Predictalytics.Infrastructure/Providers/OpenRouter/OpenRouterApiClient.cs @@ -0,0 +1,88 @@ +using System; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using Predictalytics.Application.Interfaces; + +namespace Predictalytics.Infrastructure.Providers.OpenRouter; + +public class OpenRouterApiClient : IOpenRouterApiClient +{ + private readonly HttpClient _httpClient; + private readonly IConfiguration _config; + private readonly ILogger _logger; + + public OpenRouterApiClient(HttpClient httpClient, IConfiguration config, ILogger logger) + { + _httpClient = httpClient; + _config = config; + _logger = logger; + + var baseUrl = _config["OpenRouter:BaseUrl"] ?? "https://openrouter.ai/api/v1"; + var apiKey = _config["OpenRouter:ApiKey"]; + + _httpClient.BaseAddress = new Uri(baseUrl.EndsWith("/") ? baseUrl : baseUrl + "/"); + if (!string.IsNullOrEmpty(apiKey)) + { + _httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", apiKey); + } + // OpenRouter recommends adding a referer and title for ranking + _httpClient.DefaultRequestHeaders.Add("HTTP-Referer", "http://localhost"); + _httpClient.DefaultRequestHeaders.Add("X-Title", "Predictalytics"); + } + + public async Task GenerateChatCompletionAsync(string prompt, bool useManualModel = false, CancellationToken ct = default) + { + var model = useManualModel + ? _config["OpenRouter:ManualAnalysisModel"] ?? "anthropic/claude-3-opus" + : _config["OpenRouter:DefaultModel"] ?? "google/gemini-flash-1.5"; + + var requestBody = new + { + model = model, + messages = new[] + { + new { role = "system", content = "You are an expert crypto and prediction market analyst. You analyze a trader's history and deduce their strategy, strengths, and weaknesses." }, + new { role = "user", content = prompt } + } + }; + + try + { + var response = await _httpClient.PostAsJsonAsync("chat/completions", requestBody, ct); + response.EnsureSuccessStatusCode(); + + var result = await response.Content.ReadFromJsonAsync(cancellationToken: ct); + return result?.Choices?[0]?.Message?.Content ?? "No response generated."; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to generate chat completion from OpenRouter using model {Model}", model); + return $"Error: {ex.Message}"; + } + } + + private class OpenRouterResponse + { + [JsonPropertyName("choices")] + public Choice[]? Choices { get; set; } + } + + private class Choice + { + [JsonPropertyName("message")] + public Message? Message { get; set; } + } + + private class Message + { + [JsonPropertyName("content")] + public string? Content { get; set; } + } +} diff --git a/src/Predictalytics.Infrastructure/Providers/Polymarket/PolymarketApiClient.cs b/src/Predictalytics.Infrastructure/Providers/Polymarket/PolymarketApiClient.cs index 4528470..70705dd 100644 --- a/src/Predictalytics.Infrastructure/Providers/Polymarket/PolymarketApiClient.cs +++ b/src/Predictalytics.Infrastructure/Providers/Polymarket/PolymarketApiClient.cs @@ -43,39 +43,39 @@ public class PolymarketApiClient public async Task> GetTradesAsync(string walletAddress, int limit = 1000, CancellationToken ct = default) { var url = $"/activity?user={walletAddress}&limit={limit}"; - return await ExecuteWithRetryAsync>(_client, url, ct) ?? []; + return await ExecuteWithRetryAsync>(_client, url, "Data", ct) ?? []; } public async Task> GetMarketTradesAsync(string conditionId, int limit = 1000, CancellationToken ct = default) { var url = $"/trades?condition_id={conditionId}&limit={limit}"; - return await ExecuteWithRetryAsync>(_client, url, ct) ?? []; + return await ExecuteWithRetryAsync>(_client, url, "Data", ct) ?? []; } public async Task> GetPositionsAsync(string walletAddress, CancellationToken ct = default) { var url = $"/positions?user={walletAddress}&sizeThreshold=0.1&sortBy=CURRENT&sortOrder=DESC"; - return await ExecuteWithRetryAsync>(_client, url, ct) ?? []; + return await ExecuteWithRetryAsync>(_client, url, "Data", ct) ?? []; } public async Task GetMarketAsync(string conditionId, CancellationToken ct = default) { var url = $"/markets?condition_id={conditionId}"; - var results = await ExecuteWithRetryAsync>(_gammaClient, url, ct); + var results = await ExecuteWithRetryAsync>(_gammaClient, url, "Gamma", ct); return results?.FirstOrDefault(); } /// - /// Fetch a batch of markets from the Gamma API with pagination. + /// Fetch a batch of events (and their nested markets) from the Gamma API with pagination. /// Supports offset-based pagination via the offset parameter. /// - public async Task> GetMarketsAsync(int limit = 1000, int offset = 0, bool includeClosed = false, CancellationToken ct = default) + public async Task> GetEventsAsync(int limit = 100, int offset = 0, bool includeClosed = false, CancellationToken ct = default) { var activeOnly = !includeClosed; - var url = $"/markets?limit={limit}&offset={offset}&active={activeOnly.ToString().ToLower()}&closed={includeClosed.ToString().ToLower()}"; - _logger.LogDebug("Fetching markets: {Url}", url); - var result = await ExecuteWithRetryAsync>(_gammaClient, url, ct); - _logger.LogInformation("Fetched {Count} markets (offset={Offset}, closed={Closed})", result?.Count ?? 0, offset, includeClosed); + var url = $"/events?limit={limit}&offset={offset}&active={activeOnly.ToString().ToLower()}&closed={includeClosed.ToString().ToLower()}"; + _logger.LogDebug("Fetching events: {Url}", url); + var result = await ExecuteWithRetryAsync>(_gammaClient, url, "Gamma", ct); + _logger.LogInformation("Fetched {Count} events (offset={Offset}, closed={Closed})", result?.Count ?? 0, offset, includeClosed); return result ?? []; } @@ -87,7 +87,7 @@ public class PolymarketApiClient { var url = $"/holders?market={conditionId}&limit={limit}"; _logger.LogDebug("Fetching holders: {Url}", url); - var result = await ExecuteWithRetryAsync>(_client, url, ct); + var result = await ExecuteWithRetryAsync>(_client, url, "Data", ct); _logger.LogInformation("Fetched holders for {Market}: {Count} token groups", conditionId.Length > 12 ? conditionId[..12] + "..." : conditionId, result?.Count ?? 0); return result ?? []; @@ -106,13 +106,15 @@ public class PolymarketApiClient { var url = $"/v1/leaderboard?limit={Math.Min(limit, 50)}&time_period={timePeriod}&order_by={orderBy}&category={category}"; _logger.LogDebug("Fetching leaderboard: {Url}", url); - var result = await ExecuteWithRetryAsync>(_client, url, ct); + var result = await ExecuteWithRetryAsync>(_client, url, "Data", ct); _logger.LogInformation("Leaderboard returned {Count} entries", result?.Count ?? 0); return result ?? []; } - private async Task ExecuteWithRetryAsync(HttpClient client, string url, CancellationToken ct, int attempt = 1) + private async Task ExecuteWithRetryAsync(HttpClient client, string url, string endpointGroup, CancellationToken ct, int attempt = 1) { + await _rateLimiter.WaitAsync(PlatformType.Polymarket, ct, endpointGroup); + try { var response = await client.GetAsync(url, ct); @@ -135,15 +137,14 @@ public class PolymarketApiClient waitTime = TimeSpan.FromSeconds(30); } - _logger.LogWarning("⚠️ Rate limit exceeded (429) for Polymarket. Pausing for {WaitTime}s...", (int)waitTime.TotalSeconds); + _logger.LogWarning("⚠️ Rate limit exceeded (429) for Polymarket {Group}. Pausing for {WaitTime}s...", endpointGroup, (int)waitTime.TotalSeconds); - _rateLimiter.ReportRateLimitExceeded(PlatformType.Polymarket, waitTime); + _rateLimiter.ReportRateLimitExceeded(PlatformType.Polymarket, waitTime, endpointGroup); if (attempt < 3) { - await _rateLimiter.WaitAsync(PlatformType.Polymarket, ct); _logger.LogWarning("Retrying {Url} (attempt {NextAttempt})...", url, attempt + 1); - return await ExecuteWithRetryAsync(client, url, ct, attempt + 1); + return await ExecuteWithRetryAsync(client, url, endpointGroup, ct, attempt + 1); } return default; @@ -172,7 +173,7 @@ public class PolymarketApiClient public async Task> GetPricesHistoryAsync(string clobTokenId, string interval = "6h", CancellationToken ct = default) { var url = $"/prices-history?market={clobTokenId}&interval={interval}"; - var result = await ExecuteWithRetryAsync(_clobClient, url, ct); + var result = await ExecuteWithRetryAsync(_clobClient, url, "Clob", ct); return result?.History ?? []; } } diff --git a/src/Predictalytics.Infrastructure/Providers/Polymarket/PolymarketModels.cs b/src/Predictalytics.Infrastructure/Providers/Polymarket/PolymarketModels.cs index 9f3fd67..16207c4 100644 --- a/src/Predictalytics.Infrastructure/Providers/Polymarket/PolymarketModels.cs +++ b/src/Predictalytics.Infrastructure/Providers/Polymarket/PolymarketModels.cs @@ -109,6 +109,7 @@ public class GammaMarketResponse { [JsonPropertyName("id")] public string Id { get; set; } = ""; [JsonPropertyName("conditionId")] public string ConditionId { get; set; } = ""; + [JsonPropertyName("questionID")] public string QuestionId { get; set; } = ""; [JsonPropertyName("question")] public string Question { get; set; } = ""; [JsonPropertyName("slug")] public string Slug { get; set; } = ""; [JsonPropertyName("description")] public string? Description { get; set; } @@ -121,11 +122,16 @@ public class GammaMarketResponse [JsonConverter(typeof(FlexibleDoubleConverter))] public double Volume { get; set; } + [JsonPropertyName("volume24hr")] + [JsonConverter(typeof(FlexibleDoubleConverter))] + public double Volume24hr { get; set; } + [JsonPropertyName("liquidityNum")] [JsonConverter(typeof(FlexibleDoubleConverter))] public double Liquidity { get; set; } - [JsonPropertyName("endDateIso")] public string? EndDate { get; set; } + [JsonPropertyName("endDateIso")] public string? EndDateIso { get; set; } + [JsonPropertyName("endDate")] public string? EndDate { get; set; } [JsonPropertyName("startDate")] public string? StartDate { get; set; } [JsonPropertyName("createdAt")] public string? CreatedAt { get; set; } [JsonPropertyName("closed")] public bool Closed { get; set; } @@ -136,7 +142,7 @@ public class GammaMarketResponse /// JSON string of outcomes, e.g. "[\"Yes\", \"No\"]" [JsonPropertyName("outcomes")] public string? Outcomes { get; set; } - /// JSON string of outcome prices, e.g. "[\"0.55\", \"0.45\"]" + /// JSON string of outcomePrices, e.g. "[\"0.55\", \"0.45\"]" [JsonPropertyName("outcomePrices")] public string? OutcomePrices { get; set; } /// JSON string of CLOB token IDs, e.g. "[\"12345...\", \"67890...\"]" @@ -148,6 +154,22 @@ public class GammaEventResponse [JsonPropertyName("id")] public string Id { get; set; } = ""; [JsonPropertyName("slug")] public string Slug { get; set; } = ""; [JsonPropertyName("title")] public string Title { get; set; } = ""; + [JsonPropertyName("description")] public string? Description { get; set; } + [JsonPropertyName("image")] public string? Image { get; set; } + [JsonPropertyName("startDate")] public string? StartDate { get; set; } + [JsonPropertyName("endDate")] public string? EndDate { get; set; } + [JsonPropertyName("createdAt")] public string? CreatedAt { get; set; } + [JsonPropertyName("active")] public bool Active { get; set; } + [JsonPropertyName("closed")] public bool Closed { get; set; } + [JsonPropertyName("tags")] public List Tags { get; set; } = []; + [JsonPropertyName("markets")] public List Markets { get; set; } = []; +} + +public class GammaTagResponse +{ + [JsonPropertyName("id")] public string Id { get; set; } = ""; + [JsonPropertyName("label")] public string Label { get; set; } = ""; + [JsonPropertyName("slug")] public string Slug { get; set; } = ""; } // ═══════════════════════════════════════════════════════ diff --git a/src/Predictalytics.Infrastructure/Providers/Polymarket/PolymarketProvider.cs b/src/Predictalytics.Infrastructure/Providers/Polymarket/PolymarketProvider.cs index c733120..a76f894 100644 --- a/src/Predictalytics.Infrastructure/Providers/Polymarket/PolymarketProvider.cs +++ b/src/Predictalytics.Infrastructure/Providers/Polymarket/PolymarketProvider.cs @@ -32,17 +32,19 @@ public class PolymarketProvider : IPlatformProvider var mappedTrades = raw.Select(r => { - var wallet = r.User ?? r.ProxyWallet ?? ""; + var wallet = !string.IsNullOrEmpty(r.User) ? r.User : + !string.IsNullOrEmpty(r.ProxyWallet) ? r.ProxyWallet : + platformUserId; var side = MapTradeSide(r); var sideStr = side.ToString().ToUpperInvariant(); - // Compact format: {txHash}_{assetId}_{side} — no wallet in ID to reduce index size. - // Wallet passed transiently via TransientWallet [NotMapped] for MarketHistoryWorker. + // Format: {txHash}_{wallet}_{assetId}_{side} + // Wallet must be included to avoid cross-user collisions in the global IX_Trades_Platform_PlatformTradeId index. return new Trade { Platform = PlatformType.Polymarket, PlatformTradeId = string.IsNullOrEmpty(r.TransactionHash) - ? $"{r.Timestamp}_{r.Asset}_{sideStr}" - : $"{r.TransactionHash}_{r.Asset}_{sideStr}", + ? $"{r.Timestamp}_{wallet}_{r.Asset}_{sideStr}" + : $"{r.TransactionHash.ToLowerInvariant()}_{wallet}_{r.Asset}_{sideStr}", MarketId = r.ConditionId ?? "", AssetId = r.Asset ?? "", Outcome = r.Outcome ?? "", @@ -51,13 +53,13 @@ public class PolymarketProvider : IPlatformProvider Size = (decimal)r.Size, Amount = (decimal)(r.Price * r.Size), ExecutedAt = DateTimeOffset.FromUnixTimeSeconds(r.Timestamp).UtcDateTime, - TransactionHash = r.TransactionHash, + TransactionHash = r.TransactionHash?.ToLowerInvariant(), TraderId = 0, TransientWallet = wallet, }; }).ToList(); - return mappedTrades.GroupBy(t => t.PlatformTradeId).Select(g => g.First()).ToList(); + return mappedTrades.GroupBy(t => t.PlatformTradeId, StringComparer.OrdinalIgnoreCase).Select(g => g.First()).ToList(); } @@ -75,8 +77,8 @@ public class PolymarketProvider : IPlatformProvider { Platform = PlatformType.Polymarket, PlatformTradeId = string.IsNullOrEmpty(r.TransactionHash) - ? $"{r.Timestamp}_{r.Asset}_{sideStr}" - : $"{r.TransactionHash}_{r.Asset}_{sideStr}", + ? $"{r.Timestamp}_{wallet}_{r.Asset}_{sideStr}" + : $"{r.TransactionHash.ToLowerInvariant()}_{wallet}_{r.Asset}_{sideStr}", MarketId = r.ConditionId ?? "", AssetId = r.Asset ?? "", Outcome = r.Outcome ?? "", @@ -127,33 +129,76 @@ public class PolymarketProvider : IPlatformProvider public async Task GetMarketAsync(string platformMarketId, CancellationToken ct = default) { using var _ = PlatformLogContext.Push(PlatformName); - _logger.LogDebug("Fetching market {MarketId}", platformMarketId); var raw = await _api.GetMarketAsync(platformMarketId, ct); - if (raw == null) + if (raw == null || string.IsNullOrEmpty(raw.ConditionId)) return null; + + var parentTags = ""; + if (raw.Events != null && raw.Events.Count > 0) { - _logger.LogWarning("Market {MarketId} not found", platformMarketId); - return null; + var ev = raw.Events[0]; + parentTags = ev.Tags != null ? string.Join(", ", ev.Tags.Select(t => t.Label)) : ""; + } + var market = MapGammaMarket(raw, parentTags); + + // Map the parent Event if available in the Market response + if (raw.Events != null && raw.Events.Count > 0) + { + var rawEv = raw.Events[0]; + long.TryParse(rawEv.Id, out var numericEventId); + market.Event = new Event + { + Platform = PlatformType.Polymarket, + PlatformEventId = numericEventId, + Slug = rawEv.Slug, + Title = rawEv.Title, + Description = rawEv.Description, + ImageUrl = rawEv.Image, + StartDate = DateTime.TryParse(rawEv.StartDate, out var esd) ? esd : null, + EndDate = DateTime.TryParse(rawEv.EndDate, out var eed) ? eed : null, + CreatedAt = DateTime.TryParse(rawEv.CreatedAt, out var ecd) ? ecd : DateTime.UtcNow, + DbCreatedAt = DateTime.UtcNow, + IsActive = rawEv.Active, + IsClosed = rawEv.Closed, + Tags = rawEv.Tags != null && rawEv.Tags.Count > 0 ? string.Join(", ", rawEv.Tags.Select(t => t.Label)) : string.Empty, + LastUpdatedAt = DateTime.UtcNow + }; + } + else + { + // Fallback empty event if missing (should rarely happen for valid Polymarket markets) + market.Event = new Event + { + Platform = PlatformType.Polymarket, + Slug = "unknown-" + market.ConditionId, + Title = "Unknown Event", + DbCreatedAt = DateTime.UtcNow, + LastUpdatedAt = DateTime.UtcNow + }; } _logger.LogInformation("Fetched market: {Question}", raw.Question); - return MapGammaMarket(raw); + return market; } - public async Task> GetMarketsAsync(int limit = 100, string? cursor = null, bool includeClosed = false, CancellationToken ct = default) + public async Task> GetEventsAsync(int limit = 100, string? cursor = null, bool includeClosed = false, CancellationToken ct = default) { using var _ = PlatformLogContext.Push(PlatformName); int offset = 0; if (!string.IsNullOrEmpty(cursor) && int.TryParse(cursor, out var parsed)) offset = parsed; - _logger.LogInformation("Fetching markets batch (limit={Limit}, offset={Offset}, includeClosed={Closed})", limit, offset, includeClosed); - var raw = await _api.GetMarketsAsync(limit, offset, includeClosed, ct); - _logger.LogInformation("Fetched {Count} markets from Gamma API", raw.Count); + _logger.LogInformation("Fetching events batch (limit={Limit}, offset={Offset}, includeClosed={Closed})", limit, offset, includeClosed); + var rawEvents = await _api.GetEventsAsync(limit, offset, includeClosed, ct); + _logger.LogInformation("Fetched {Count} events from Gamma API", rawEvents.Count); - return raw - .Where(m => !string.IsNullOrEmpty(m.ConditionId) && !string.IsNullOrEmpty(m.ClobTokenIds)) - .Select(MapGammaMarket) - .ToList(); + var events = new List(); + foreach (var rawEvent in rawEvents) + { + var ev = MapGammaEvent(rawEvent); + events.Add(ev); + } + + return events; } public async Task> GetTopHoldersAsync(string platformMarketId, int limit = 20, CancellationToken ct = default) @@ -192,31 +237,67 @@ public class PolymarketProvider : IPlatformProvider // ── Private helpers ────────────────────────────────────────── - private Market MapGammaMarket(GammaMarketResponse raw) + private Event MapGammaEvent(GammaEventResponse rawEvent) { - var eventSlug = ""; - if (raw.Events != null && raw.Events.Count > 0 && !string.IsNullOrEmpty(raw.Events[0].Slug)) + long.TryParse(rawEvent.Id, out var numericId); + + var ev = new Event { - eventSlug = raw.Events[0].Slug; + Platform = PlatformType.Polymarket, + PlatformEventId = numericId, + Slug = rawEvent.Slug, + Title = rawEvent.Title, + Description = rawEvent.Description, + ImageUrl = rawEvent.Image, + StartDate = DateTime.TryParse(rawEvent.StartDate, out var sd) ? sd : null, + EndDate = DateTime.TryParse(rawEvent.EndDate, out var ed) ? ed : null, + CreatedAt = DateTime.TryParse(rawEvent.CreatedAt, out var cd) ? cd : DateTime.UtcNow, + DbCreatedAt = DateTime.UtcNow, + IsActive = rawEvent.Active, + IsClosed = rawEvent.Closed, + Tags = rawEvent.Tags != null && rawEvent.Tags.Count > 0 + ? string.Join(", ", rawEvent.Tags.Select(t => t.Label)) + : string.Empty, + LastUpdatedAt = DateTime.UtcNow + }; + + if (rawEvent.Markets != null) + { + foreach (var rawMarket in rawEvent.Markets) + { + if (string.IsNullOrEmpty(rawMarket.ConditionId) || string.IsNullOrEmpty(rawMarket.ClobTokenIds)) continue; + + var market = MapGammaMarket(rawMarket, ev.Tags ?? ""); + ev.Markets.Add(market); + } } + return ev; + } + + private Market MapGammaMarket(GammaMarketResponse raw, string parentTags = "") + { + long.TryParse(raw.Id, out var marketNumericId); + var market = new Market { Platform = PlatformType.Polymarket, - PlatformMarketId = raw.ConditionId, + PlatformMarketId = marketNumericId, + ConditionId = raw.ConditionId, + QuestionId = raw.QuestionId, MarketSlug = raw.Slug, - EventSlug = eventSlug, Description = raw.Description, ImageUrl = raw.Image, Question = raw.Question, - Category = raw.Category, + Category = string.IsNullOrWhiteSpace(raw.Category) ? parentTags : raw.Category, Volume = (decimal)raw.Volume, + Volume24h = (decimal)raw.Volume24hr, Liquidity = (decimal)raw.Liquidity, - StartDate = DateTime.TryParse(raw.StartDate, out var sd) ? sd : null, - EndDate = DateTime.TryParse(raw.EndDate, out var ed) ? ed : null, - CreatedAt = DateTime.TryParse(raw.CreatedAt, out var cd) ? cd : DateTime.UtcNow, + StartDate = DateTime.TryParse(raw.StartDate, out var msd) ? msd : null, + EndDate = DateTime.TryParse(raw.EndDate ?? raw.EndDateIso, out var med) ? med : null, + CreatedAt = DateTime.TryParse(raw.CreatedAt, out var mcd) ? mcd : DateTime.UtcNow, DbCreatedAt = DateTime.UtcNow, - IsResolved = raw.Resolved || raw.Closed, // Prefer resolved flag + IsResolved = raw.Resolved || raw.Closed, ResolutionOutcome = raw.ResolutionOutcome, LastUpdatedAt = DateTime.UtcNow }; diff --git a/src/Predictalytics.WinFormsHost/MainForm.Designer.cs b/src/Predictalytics.WinFormsHost/MainForm.Designer.cs index 97b15ac..f154465 100644 --- a/src/Predictalytics.WinFormsHost/MainForm.Designer.cs +++ b/src/Predictalytics.WinFormsHost/MainForm.Designer.cs @@ -21,6 +21,8 @@ partial class MainForm btn_serverstart = new ToolStripButton(); btn_localWebserver = new ToolStripButton(); statusStrip1 = new StatusStrip(); + label_apiRatelimit = new ToolStripStatusLabel(); + label_buildVersion = new ToolStripStatusLabel(); tabControl1 = new TabControl(); tabPage_terminal = new TabPage(); rtb_terminal = new RichTextBox(); @@ -34,8 +36,7 @@ partial class MainForm developmentToolStripMenuItem = new ToolStripMenuItem(); btn_dbReset = new ToolStripMenuItem(); btn_syncmarkets = new ToolStripMenuItem(); - label_apiRatelimit = new ToolStripStatusLabel(); - label_buildVersion = new ToolStripStatusLabel(); + btn_dbUpdate = new ToolStripMenuItem(); toolStrip1.SuspendLayout(); statusStrip1.SuspendLayout(); tabControl1.SuspendLayout(); @@ -76,6 +77,21 @@ partial class MainForm statusStrip1.Size = new Size(1864, 32); statusStrip1.TabIndex = 1; // + // label_apiRatelimit + // + label_apiRatelimit.Name = "label_apiRatelimit"; + label_apiRatelimit.Size = new Size(1782, 25); + label_apiRatelimit.Spring = true; + label_apiRatelimit.Text = "API: OK"; + label_apiRatelimit.TextAlign = ContentAlignment.MiddleLeft; + // + // label_buildVersion + // + label_buildVersion.Name = "label_buildVersion"; + label_buildVersion.Size = new Size(67, 25); + label_buildVersion.Text = "Build: -"; + label_buildVersion.TextAlign = ContentAlignment.MiddleRight; + // // tabControl1 // tabControl1.Anchor = AnchorStyles.Top | AnchorStyles.Bottom | AnchorStyles.Left | AnchorStyles.Right; @@ -150,18 +166,182 @@ partial class MainForm // btn_logfolder // btn_logfolder.Name = "btn_logfolder"; + btn_logfolder.Size = new Size(261, 34); + btn_logfolder.Text = "Show Logfolder"; + btn_logfolder.Click += btn_logfolder_Click; + // + // btn_openbrowser + toolStrip1 = new ToolStrip(); + btn_serverstart = new ToolStripButton(); + btn_localWebserver = new ToolStripButton(); + statusStrip1 = new StatusStrip(); + label_apiRatelimit = new ToolStripStatusLabel(); + label_dbSize = new ToolStripStatusLabel(); + label_buildVersion = new ToolStripStatusLabel(); + tabControl1 = new TabControl(); + tabPage_terminal = new TabPage(); + rtb_terminal = new RichTextBox(); + tabPage2 = new TabPage(); + pg_settings = new PropertyGrid(); + menuStrip1 = new MenuStrip(); + filesToolStripMenuItem = new ToolStripMenuItem(); + editToolStripMenuItem = new ToolStripMenuItem(); + btn_logfolder = new ToolStripMenuItem(); + btn_openbrowser = new ToolStripMenuItem(); + developmentToolStripMenuItem = new ToolStripMenuItem(); + btn_dbReset = new ToolStripMenuItem(); + btn_syncmarkets = new ToolStripMenuItem(); + btn_dbUpdate = new ToolStripMenuItem(); + toolStrip1.SuspendLayout(); + statusStrip1.SuspendLayout(); + tabControl1.SuspendLayout(); + tabPage_terminal.SuspendLayout(); + tabPage2.SuspendLayout(); + menuStrip1.SuspendLayout(); + SuspendLayout(); + // + // toolStrip1 + // + toolStrip1.ImageScalingSize = new Size(24, 24); + toolStrip1.Items.AddRange(new ToolStripItem[] { btn_serverstart, btn_localWebserver }); + toolStrip1.Location = new Point(0, 33); + toolStrip1.Name = "toolStrip1"; + toolStrip1.Size = new Size(1864, 34); + toolStrip1.TabIndex = 0; + // + // btn_serverstart + // + btn_serverstart.ImageTransparentColor = Color.Magenta; + btn_serverstart.Name = "btn_serverstart"; + btn_serverstart.Size = new Size(127, 29); + btn_serverstart.Text = "▶ Start Server"; + // + // btn_localWebserver + // + btn_localWebserver.ImageTransparentColor = Color.Magenta; + btn_localWebserver.Name = "btn_localWebserver"; + btn_localWebserver.Size = new Size(161, 29); + btn_localWebserver.Text = "▶ Start Webserver"; + // + // statusStrip1 + // + statusStrip1.ImageScalingSize = new Size(24, 24); + statusStrip1.Items.AddRange(new ToolStripItem[] { label_apiRatelimit, label_dbSize, label_buildVersion }); + statusStrip1.Location = new Point(0, 1000); + statusStrip1.Name = "statusStrip1"; + statusStrip1.Size = new Size(1864, 32); + statusStrip1.TabIndex = 1; + // + // label_apiRatelimit + // + label_apiRatelimit.Name = "label_apiRatelimit"; + label_apiRatelimit.Size = new Size(1600, 25); + label_apiRatelimit.Spring = true; + label_apiRatelimit.Text = "API: OK"; + label_apiRatelimit.TextAlign = ContentAlignment.MiddleLeft; + // + // label_dbSize + // + label_dbSize.Name = "label_dbSize"; + label_dbSize.Size = new Size(150, 25); + label_dbSize.Text = "DB Size: -"; + label_dbSize.TextAlign = ContentAlignment.MiddleRight; + // + // label_buildVersion + // + label_buildVersion.Name = "label_buildVersion"; + label_buildVersion.Size = new Size(67, 25); + label_buildVersion.Text = "Build: -"; + label_buildVersion.TextAlign = ContentAlignment.MiddleRight; + // + // tabControl1 + // + tabControl1.Anchor = AnchorStyles.Top | AnchorStyles.Bottom | AnchorStyles.Left | AnchorStyles.Right; + tabControl1.Controls.Add(tabPage_terminal); + tabControl1.Controls.Add(tabPage2); + tabControl1.Location = new Point(0, 61); + tabControl1.Name = "tabControl1"; + tabControl1.SelectedIndex = 0; + tabControl1.Size = new Size(1864, 946); + tabControl1.TabIndex = 2; + // + // tabPage_terminal + // + tabPage_terminal.Controls.Add(rtb_terminal); + tabPage_terminal.Location = new Point(4, 34); + tabPage_terminal.Name = "tabPage_terminal"; + tabPage_terminal.Padding = new Padding(3); + tabPage_terminal.Size = new Size(1856, 908); + tabPage_terminal.TabIndex = 0; + tabPage_terminal.Text = "Terminal"; + tabPage_terminal.UseVisualStyleBackColor = true; + // + // rtb_terminal + // + rtb_terminal.Anchor = AnchorStyles.Top | AnchorStyles.Bottom | AnchorStyles.Left | AnchorStyles.Right; + rtb_terminal.Location = new Point(3, 6); + rtb_terminal.Name = "rtb_terminal"; + rtb_terminal.Size = new Size(1847, 896); + rtb_terminal.TabIndex = 0; + rtb_terminal.Text = ""; + // + // tabPage2 + // + tabPage2.Controls.Add(pg_settings); + tabPage2.Location = new Point(4, 34); + tabPage2.Name = "tabPage2"; + tabPage2.Padding = new Padding(3); + tabPage2.Size = new Size(1856, 908); + tabPage2.TabIndex = 1; + tabPage2.Text = "Settings"; + tabPage2.UseVisualStyleBackColor = true; + // + // pg_settings + // + pg_settings.Location = new Point(3, 6); + pg_settings.Name = "pg_settings"; + pg_settings.Size = new Size(1850, 896); + pg_settings.TabIndex = 0; + // + // menuStrip1 + // + menuStrip1.ImageScalingSize = new Size(24, 24); + menuStrip1.Items.AddRange(new ToolStripItem[] { filesToolStripMenuItem, editToolStripMenuItem, developmentToolStripMenuItem }); + menuStrip1.Location = new Point(0, 0); + menuStrip1.Name = "menuStrip1"; + menuStrip1.Size = new Size(1864, 33); + menuStrip1.TabIndex = 3; + // + // filesToolStripMenuItem + // + filesToolStripMenuItem.Name = "filesToolStripMenuItem"; + filesToolStripMenuItem.Size = new Size(62, 29); + filesToolStripMenuItem.Text = "Files"; + // + // editToolStripMenuItem + // + editToolStripMenuItem.DropDownItems.AddRange(new ToolStripItem[] { btn_logfolder, btn_openbrowser }); + editToolStripMenuItem.Name = "editToolStripMenuItem"; + editToolStripMenuItem.Size = new Size(58, 29); + editToolStripMenuItem.Text = "Edit"; + // + // btn_logfolder + // + btn_logfolder.Name = "btn_logfolder"; + btn_logfolder.Size = new Size(261, 34); btn_logfolder.Text = "Show Logfolder"; btn_logfolder.Click += btn_logfolder_Click; // // btn_openbrowser // btn_openbrowser.Name = "btn_openbrowser"; + btn_openbrowser.Size = new Size(261, 34); btn_openbrowser.Text = "Show Local WebUI"; btn_openbrowser.Click += btn_openbrowser_Click; // // developmentToolStripMenuItem // - developmentToolStripMenuItem.DropDownItems.AddRange(new ToolStripItem[] { btn_dbReset, btn_syncmarkets }); + developmentToolStripMenuItem.DropDownItems.AddRange(new ToolStripItem[] { btn_dbReset, btn_syncmarkets, btn_dbUpdate }); developmentToolStripMenuItem.Name = "developmentToolStripMenuItem"; developmentToolStripMenuItem.Size = new Size(135, 29); developmentToolStripMenuItem.Text = "Development"; @@ -169,28 +349,22 @@ partial class MainForm // btn_dbReset // btn_dbReset.Name = "btn_dbReset"; + btn_dbReset.Size = new Size(270, 34); btn_dbReset.Text = "reset TradesDB"; // // btn_syncmarkets // btn_syncmarkets.Name = "btn_syncmarkets"; - btn_syncmarkets.Text = "Sync Marketsa"; + btn_syncmarkets.Size = new Size(270, 34); + btn_syncmarkets.Text = "Sync Markets"; btn_syncmarkets.Click += syncMarketsaToolStripMenuItem_Click; // - // label_apiRatelimit + // btn_dbUpdate // - label_apiRatelimit.Name = "label_apiRatelimit"; - label_apiRatelimit.Size = new Size(1670, 25); - label_apiRatelimit.Spring = true; - label_apiRatelimit.Text = "API: OK"; - label_apiRatelimit.TextAlign = ContentAlignment.MiddleLeft; - // - // label_buildVersion - // - label_buildVersion.Name = "label_buildVersion"; - label_buildVersion.Size = new Size(179, 25); - label_buildVersion.Text = "Build: -"; - label_buildVersion.TextAlign = ContentAlignment.MiddleRight; + btn_dbUpdate.Name = "btn_dbUpdate"; + btn_dbUpdate.Size = new Size(270, 34); + btn_dbUpdate.Text = "UpdateDB"; + btn_dbUpdate.Click += btn_dbUpdate_Click; // // MainForm // @@ -237,5 +411,7 @@ partial class MainForm private ToolStripMenuItem btn_syncmarkets; private PropertyGrid pg_settings; private ToolStripStatusLabel label_apiRatelimit; + private ToolStripStatusLabel label_dbSize; private ToolStripStatusLabel label_buildVersion; + private ToolStripMenuItem btn_dbUpdate; } diff --git a/src/Predictalytics.WinFormsHost/MainForm.cs b/src/Predictalytics.WinFormsHost/MainForm.cs index 9a8fc45..194acf2 100644 --- a/src/Predictalytics.WinFormsHost/MainForm.cs +++ b/src/Predictalytics.WinFormsHost/MainForm.cs @@ -59,10 +59,16 @@ public partial class MainForm : Form btn_serverstart.Click += Btn_serverstart_Click; btn_localWebserver.Click += Btn_localWebserver_Click; btn_syncmarkets.Click += syncMarketsaToolStripMenuItem_Click; + btn_dbUpdate.Click += btn_dbUpdate_Click; Log.Information("MainForm initialized. Ready."); Log.Information("Press 'Start Server' to begin polling & discovery."); Log.Information("Press 'Start Local Webserver' to launch the WebUI on http://localhost:{Port}", _settings.WebserverPort); + + _ = UpdateDbSizeAsync(); + var dbSizeTimer = new System.Windows.Forms.Timer { Interval = 6 * 60 * 60 * 1000 }; + dbSizeTimer.Tick += async (s, e) => await UpdateDbSizeAsync(); + dbSizeTimer.Start(); } private async void Btn_serverstart_Click(object? sender, EventArgs e) @@ -193,11 +199,67 @@ public partial class MainForm : Form catch (Exception ex) { Log.Error(ex, "Manual market sync failed"); - MessageBox.Show($"Market sync failed: {ex.Message}", "Error", MessageBoxButtons.OK, MessageBoxIcon.Error); + MessageBox.Show($"Error syncing markets: {ex.Message}", "Error", MessageBoxButtons.OK, MessageBoxIcon.Error); } finally { btn_syncmarkets.Enabled = true; } } + + private async void btn_dbUpdate_Click(object sender, EventArgs e) + { + if (_workerRunning) + { + MessageBox.Show("Database update cannot be run while background workers are running.", + "Workers Busy", MessageBoxButtons.OK, MessageBoxIcon.Warning); + return; + } + + try + { + btn_dbUpdate.Enabled = false; + Log.Information("Manual database update triggered..."); + await _webServer!.UpdateDatabaseAsync(); + Log.Information("Database updated successfully."); + MessageBox.Show("Database update completed successfully.", "Success", MessageBoxButtons.OK, MessageBoxIcon.Information); + } + catch (Exception ex) + { + Log.Error(ex, "Manual database update failed"); + MessageBox.Show($"Database update failed: {ex.Message}", "Error", MessageBoxButtons.OK, MessageBoxIcon.Error); + } + finally + { + btn_dbUpdate.Enabled = true; + } + } + + private async Task UpdateDbSizeAsync() + { + try + { + // Build the connection string + var csBuilder = new MySqlConnector.MySqlConnectionStringBuilder(_settings.ConnectionString); + if (string.IsNullOrWhiteSpace(csBuilder.Database)) + return; // Not ready or valid yet + + using var conn = new MySqlConnector.MySqlConnection(_settings.ConnectionString); + await conn.OpenAsync(); + using var cmd = conn.CreateCommand(); + cmd.CommandText = "SELECT SUM(data_length + index_length) / 1024 / 1024 FROM information_schema.tables WHERE table_schema = DATABASE();"; + + var result = await cmd.ExecuteScalarAsync(); + if (result != DBNull.Value && result != null) + { + var sizeMb = Convert.ToDouble(result); + this.Invoke(() => label_dbSize.Text = $"DB Size: {sizeMb:F2} MB"); + } + } + catch (Exception ex) + { + this.Invoke(() => label_dbSize.Text = "DB Size: Error"); + Log.Debug(ex, "Failed to fetch DB size for status bar"); + } + } } diff --git a/src/Predictalytics.WinFormsHost/Program.cs b/src/Predictalytics.WinFormsHost/Program.cs index 6374b8c..5e9c925 100644 --- a/src/Predictalytics.WinFormsHost/Program.cs +++ b/src/Predictalytics.WinFormsHost/Program.cs @@ -31,6 +31,9 @@ internal static class Program .MinimumLevel.Override("Microsoft.Hosting", LogEventLevel.Warning) .Enrich.FromLogContext() + // Suppress duplicate entry EF errors completely from logging + .Filter.ByExcluding(e => e.Exception != null && e.Exception.ToString().Contains("Duplicate entry")) + // ── Console (simple) ── .WriteTo.Console(outputTemplate: simpleTemplate, restrictedToMinimumLevel: LogEventLevel.Warning) diff --git a/src/Predictalytics.WinFormsHost/Services/EmbeddedWebServer.cs b/src/Predictalytics.WinFormsHost/Services/EmbeddedWebServer.cs index 8e6828c..3392a71 100644 --- a/src/Predictalytics.WinFormsHost/Services/EmbeddedWebServer.cs +++ b/src/Predictalytics.WinFormsHost/Services/EmbeddedWebServer.cs @@ -21,6 +21,14 @@ public class EmbeddedWebServer public string? ConnectionString { get; set; } public bool DbConnectionDebug { get; set; } + public async Task UpdateDatabaseAsync() + { + var services = new ServiceCollection(); + Predictalytics.Infrastructure.DependencyInjection.AddPredictalytics(services, new Microsoft.Extensions.Configuration.ConfigurationBuilder().Build(), ConnectionString, DbConnectionDebug); + var provider = services.BuildServiceProvider(); + await Predictalytics.Infrastructure.DependencyInjection.EnsureDatabaseAsync(provider, DbConnectionDebug); + } + public async Task StartWebServerAsync(int port = 5000) { lock (_lock) { if (_app != null) return; } @@ -161,25 +169,25 @@ public class EmbeddedWebServer .First(x => x.Platform == p.Platform); await rateLimiter.WaitAsync(provider.Platform, ct); - var markets = await provider.GetMarketsAsync(batchSize, offset.ToString(), includeClosed, ct); + var events = await provider.GetEventsAsync(batchSize, offset.ToString(), includeClosed, ct); - if (markets == null || markets.Count == 0) + if (events == null || events.Count == 0) { - Log.Warning("[{Platform}] No more markets found at offset {Offset} (includeClosed={Closed}). Ending pass.", provider.PlatformName, offset, includeClosed); + Log.Warning("[{Platform}] No more events found at offset {Offset} (includeClosed={Closed}). Ending pass.", provider.PlatformName, offset, includeClosed); break; } - await marketRepo.AddOrUpdateRangeAsync(markets, ct); + await marketRepo.AddOrUpdateEventsAsync(events, ct); - totalSynced += markets.Count; + totalSynced += events.Count; offset += batchSize; - if (totalSynced % 500 == 0 || markets.Count < batchSize) - Log.Information("[{Platform}] Synced {Total} markets so far (offset={Offset}, includeClosed={Closed})...", provider.PlatformName, totalSynced, offset, includeClosed); + if (totalSynced % 500 == 0 || events.Count < batchSize) + Log.Information("[{Platform}] Synced {Total} events so far (offset={Offset}, includeClosed={Closed})...", provider.PlatformName, totalSynced, offset, includeClosed); - if (markets.Count < batchSize) + if (events.Count < batchSize) { - Log.Warning("[{Platform}] Batch was smaller than limit ({Count}/{Limit}), assuming end of list.", provider.PlatformName, markets.Count, batchSize); + Log.Warning("[{Platform}] Batch was smaller than limit ({Count}/{Limit}), assuming end of list.", provider.PlatformName, events.Count, batchSize); break; } } diff --git a/src/Predictalytics.WinFormsHost/appsettings.json b/src/Predictalytics.WinFormsHost/appsettings.json index b9a1802..e33a6a8 100644 --- a/src/Predictalytics.WinFormsHost/appsettings.json +++ b/src/Predictalytics.WinFormsHost/appsettings.json @@ -11,5 +11,11 @@ "Microsoft.AspNetCore": "Warning", "Microsoft.EntityFrameworkCore": "Warning" } + }, + "OpenRouter": { + "ApiKey": "", + "BaseUrl": "https://openrouter.ai/api/v1", + "DefaultModel": "google/gemini-flash-1.5", + "ManualAnalysisModel": "anthropic/claude-3-opus" } } diff --git a/src/Predictalytics.Worker/DependencyInjection.cs b/src/Predictalytics.Worker/DependencyInjection.cs index e04a224..f866ab8 100644 --- a/src/Predictalytics.Worker/DependencyInjection.cs +++ b/src/Predictalytics.Worker/DependencyInjection.cs @@ -19,6 +19,7 @@ public static class DependencyInjection services.AddHostedService(); services.AddHostedService(); services.AddHostedService(); + services.AddHostedService(); return services; } } diff --git a/src/Predictalytics.Worker/Predictalytics.Worker.csproj b/src/Predictalytics.Worker/Predictalytics.Worker.csproj index 31f5544..b303bce 100644 --- a/src/Predictalytics.Worker/Predictalytics.Worker.csproj +++ b/src/Predictalytics.Worker/Predictalytics.Worker.csproj @@ -7,6 +7,10 @@ + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + diff --git a/src/Predictalytics.Worker/Services/MarketHistoryWorker.cs b/src/Predictalytics.Worker/Services/MarketHistoryWorker.cs index e107e13..06000cb 100644 --- a/src/Predictalytics.Worker/Services/MarketHistoryWorker.cs +++ b/src/Predictalytics.Worker/Services/MarketHistoryWorker.cs @@ -18,7 +18,7 @@ public class MarketHistoryWorker : BackgroundService private readonly ILogger _logger; private const int CooldownHours = 6; - private const int MarketsPerCycle = 10; + private const int MarketsPerCycle = 50; private const int TradesPerFetch = 100; public MarketHistoryWorker(IServiceProvider services, ILogger logger) @@ -64,7 +64,7 @@ public class MarketHistoryWorker : BackgroundService using var platformCtx = PlatformLogContext.Push(provider.PlatformName); await rateLimiter.WaitAsync(market.Platform, stoppingToken); - var trades = await provider.GetMarketTradesAsync(market.PlatformMarketId, TradesPerFetch, stoppingToken); + var trades = await provider.GetMarketTradesAsync(market.ConditionId, TradesPerFetch, stoppingToken); // Extract unique wallet addresses from TransientWallet [NotMapped]. // Providers set this during in-memory mapping; it is NOT stored in the DB. diff --git a/src/Predictalytics.Worker/Services/MarketSyncWorker.cs b/src/Predictalytics.Worker/Services/MarketSyncWorker.cs index 04c299b..99fac0c 100644 --- a/src/Predictalytics.Worker/Services/MarketSyncWorker.cs +++ b/src/Predictalytics.Worker/Services/MarketSyncWorker.cs @@ -66,15 +66,15 @@ public class MarketSyncWorker : BackgroundService .First(x => x.Platform == p.Platform); await rateLimiter.WaitAsync(provider.Platform, stoppingToken); - var markets = await provider.GetMarketsAsync(batchSize, offset.ToString(), includeClosed, stoppingToken); + var events = await provider.GetEventsAsync(batchSize, offset.ToString(), includeClosed, stoppingToken); - if (markets.Count == 0) break; + if (events.Count == 0) break; - await marketRepo.AddOrUpdateRangeAsync(markets, stoppingToken); + await marketRepo.AddOrUpdateEventsAsync(events, stoppingToken); - passSynced += markets.Count; - cycleTotalSynced += markets.Count; - _statsService.TrackMarketSync(provider.Platform, markets.Count); + passSynced += events.Count; + cycleTotalSynced += events.Count; + _statsService.TrackMarketSync(provider.Platform, events.Count); offset += batchSize; if (passSynced % 500 == 0) diff --git a/src/Predictalytics.Worker/Services/PollingWorker.cs b/src/Predictalytics.Worker/Services/PollingWorker.cs index d99fab9..8cad0d0 100644 --- a/src/Predictalytics.Worker/Services/PollingWorker.cs +++ b/src/Predictalytics.Worker/Services/PollingWorker.cs @@ -36,7 +36,7 @@ public class PollingWorker : BackgroundService using (var scope = _services.CreateScope()) { var repo = scope.ServiceProvider.GetRequiredService(); - tradersToProcess = await repo.GetAllAsync(take: 100, ct: stoppingToken); + tradersToProcess = await repo.GetTradersForPollingAsync(take: 100, ct: stoppingToken); } _logger.LogWarning("📊 Polling {Count} traders...", tradersToProcess.Count); @@ -121,7 +121,7 @@ public class PollingWorker : BackgroundService { // Final deduplication of the batch itself var uniqueNewTrades = newTrades - .GroupBy(tr => tr.PlatformTradeId) + .GroupBy(tr => tr.PlatformTradeId, StringComparer.OrdinalIgnoreCase) .Select(g => g.First()) .ToList(); @@ -135,7 +135,7 @@ public class PollingWorker : BackgroundService } catch (Exception ex) when (ex.ToString().Contains("Duplicate entry") || (ex.InnerException?.Message.Contains("Duplicate entry") ?? false)) { - _logger.LogWarning("{Trader}: Skipping batch due to duplicate entries (likely already imported)", trader.DisplayName); + _statsService.TrackDuplicateError(trader.Platform, 1); } } else diff --git a/src/Predictalytics.Worker/Services/ReportingWorker.cs b/src/Predictalytics.Worker/Services/ReportingWorker.cs index d8249c3..fe84fba 100644 --- a/src/Predictalytics.Worker/Services/ReportingWorker.cs +++ b/src/Predictalytics.Worker/Services/ReportingWorker.cs @@ -52,8 +52,9 @@ public class ReportingWorker : BackgroundService foreach (var (platform, stats) in statsMap) { - _logger.LogWarning("[{Platform}] Markets: {M} | New Traders: {T} | Activities: {A}", - platform, stats.MarketsSynced, stats.TradersDiscovered, stats.TradesProcessed); + var dupStr = stats.DuplicateErrors > 0 ? $" | Duplikatfehler: {stats.DuplicateErrors}" : ""; + _logger.LogWarning("[{Platform}] Markets: {M} | New Traders: {T} | Activities: {A}{D}", + platform, stats.MarketsSynced, stats.TradersDiscovered, stats.TradesProcessed, dupStr); } _logger.LogWarning("------------------------------------------------"); diff --git a/src/Predictalytics.Worker/Services/TopHolderDiscoveryWorker.cs b/src/Predictalytics.Worker/Services/TopHolderDiscoveryWorker.cs index 333fa98..1301270 100644 --- a/src/Predictalytics.Worker/Services/TopHolderDiscoveryWorker.cs +++ b/src/Predictalytics.Worker/Services/TopHolderDiscoveryWorker.cs @@ -37,7 +37,7 @@ public class TopHolderDiscoveryWorker : BackgroundService var rateLimiter = scope.ServiceProvider.GetRequiredService(); // Get top active markets by volume - var activeMarkets = await marketRepo.GetActiveAsync(20, stoppingToken); + var activeMarkets = await marketRepo.GetActiveAsync(100, stoppingToken); _logger.LogInformation("👥 Scanning top holders across {Count} active markets", activeMarkets.Count); int totalDiscovered = 0; @@ -54,9 +54,9 @@ public class TopHolderDiscoveryWorker : BackgroundService using var platformCtx = PlatformLogContext.Push(provider.PlatformName); await rateLimiter.WaitAsync(market.Platform, stoppingToken); - var holders = await provider.GetTopHoldersAsync(market.PlatformMarketId, 10, stoppingToken); + var newHolders = await provider.GetTopHoldersAsync(market.ConditionId, 50, stoppingToken); - foreach (var holder in holders) + foreach (var holder in newHolders) { // Skip empty wallet addresses if (string.IsNullOrWhiteSpace(holder.PlatformUserId)) continue; @@ -95,9 +95,9 @@ public class TopHolderDiscoveryWorker : BackgroundService catch (OperationCanceledException) { break; } catch (Exception ex) { _logger.LogError(ex, "TopHolderDiscoveryWorker error"); } - // Run every 15 minutes - _logger.LogInformation("👥 Next top holder scan in 15 minutes."); - await Task.Delay(TimeSpan.FromMinutes(15), stoppingToken); + // Run every 5 minutes + _logger.LogInformation("🚀 Next top holder scan in 5 minutes."); + await Task.Delay(TimeSpan.FromMinutes(5), stoppingToken); } _logger.LogInformation("👥 TopHolderDiscoveryWorker stopped"); diff --git a/src/Predictalytics.Worker/Services/TradeContextEnrichmentWorker.cs b/src/Predictalytics.Worker/Services/TradeContextEnrichmentWorker.cs new file mode 100644 index 0000000..437181a --- /dev/null +++ b/src/Predictalytics.Worker/Services/TradeContextEnrichmentWorker.cs @@ -0,0 +1,124 @@ +using Predictalytics.Domain.Entities; +using Predictalytics.Domain.Interfaces; +using Predictalytics.Infrastructure.Providers.Polymarket; +using Predictalytics.Application.Interfaces; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace Predictalytics.Worker.Services; + +/// +/// Retroactively enriches trades of top/watchlisted traders with high-resolution +/// 1-minute price contexts immediately before and after execution. +/// Avoids burdening the live PollingWorker. +/// +public class TradeContextEnrichmentWorker : BackgroundService +{ + private readonly IServiceProvider _services; + private readonly ILogger _logger; + + public TradeContextEnrichmentWorker(IServiceProvider services, ILogger logger) + { + _services = services; + _logger = logger; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + _logger.LogInformation("🧠 TradeContextEnrichmentWorker started"); + await Task.Delay(10000, stoppingToken); // Wait for app startup + + while (!stoppingToken.IsCancellationRequested) + { + try + { + using var scope = _services.CreateScope(); + var tradeRepo = scope.ServiceProvider.GetRequiredService(); + var polymarketClient = scope.ServiceProvider.GetRequiredService(); + var rateLimiter = scope.ServiceProvider.GetRequiredService(); + + // Fetch a batch of unenriched trades + var unenrichedTrades = await tradeRepo.GetTradesForContextEnrichmentAsync(500, stoppingToken); + + if (unenrichedTrades.Count == 0) + { + // No work to do, sleep longer + await Task.Delay(TimeSpan.FromMinutes(5), stoppingToken); + continue; + } + + // Group by AssetId to minimize API calls (1 call per asset fetches the whole 1m history) + var tradesByAsset = unenrichedTrades.GroupBy(t => t.AssetId).ToList(); + _logger.LogInformation("Enriching {TradeCount} trades across {AssetCount} assets...", unenrichedTrades.Count, tradesByAsset.Count); + + int updatedCount = 0; + + foreach (var group in tradesByAsset) + { + if (stoppingToken.IsCancellationRequested) break; + var assetId = group.Key; + + try + { + // Wait for rate limiter to respect global limits + await rateLimiter.WaitAsync(Predictalytics.Domain.Enums.PlatformType.Polymarket, stoppingToken); + + // Fetch the 10-minute history for the entire market (using "max" since "1m" is invalid for full history) + var history = await polymarketClient.GetPricesHistoryAsync(assetId, "max", stoppingToken); + if (history == null || history.Count == 0) + { + // If history is not available, mark as enriched to prevent infinite loops, + // but prices remain null. + foreach (var trade in group) + { + trade.IsContextEnriched = true; + await tradeRepo.UpdateAsync(trade, stoppingToken); + } + continue; + } + + // Order history chronologically for safe binary search / LINQ + var orderedHistory = history.OrderBy(h => h.Timestamp).ToList(); + + foreach (var trade in group) + { + var tradeTimeUnix = ((DateTimeOffset)trade.ExecutedAt).ToUnixTimeSeconds(); + + // Find the closest point BEFORE the trade (approx 1 min before) + var prePoint = orderedHistory + .LastOrDefault(h => h.Timestamp < tradeTimeUnix); + + // Find the closest point AFTER the trade (approx 1 min after) + var postPoint = orderedHistory + .FirstOrDefault(h => h.Timestamp > tradeTimeUnix); + + trade.PreTradePrice1m = prePoint != null ? (decimal)prePoint.Price : null; + trade.PostTradePrice1m = postPoint != null ? (decimal)postPoint.Price : null; + trade.IsContextEnriched = true; + + await tradeRepo.UpdateAsync(trade, stoppingToken); + updatedCount++; + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to enrich asset {AssetId}", assetId); + // Do NOT mark as enriched on failure, try again later + } + } + + _logger.LogInformation("✅ Enriched {UpdatedCount} trades in this cycle.", updatedCount); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error in TradeContextEnrichmentWorker loop"); + } + + // Sleep briefly before next batch + await Task.Delay(TimeSpan.FromSeconds(30), stoppingToken); + } + + _logger.LogInformation("🧠 TradeContextEnrichmentWorker stopped"); + } +} diff --git a/src/Predictalytics.Worker/Services/TradeHistoryWorker.cs b/src/Predictalytics.Worker/Services/TradeHistoryWorker.cs index 83b5d7d..51cb611 100644 --- a/src/Predictalytics.Worker/Services/TradeHistoryWorker.cs +++ b/src/Predictalytics.Worker/Services/TradeHistoryWorker.cs @@ -151,14 +151,14 @@ public class TradeHistoryWorker : BackgroundService if (newTrades.Count > 0) { - var uniqueNewTrades = newTrades.GroupBy(tr => tr.PlatformTradeId).Select(g => g.First()).ToList(); + var uniqueNewTrades = newTrades.GroupBy(tr => tr.PlatformTradeId, StringComparer.OrdinalIgnoreCase).Select(g => g.First()).ToList(); try { await tradeRepo.AddRangeAsync(uniqueNewTrades, ct); _statsService.TrackTradeActivity(trader.Platform, uniqueNewTrades.Count); trader.TotalTrades += uniqueNewTrades.Count; _logger.LogInformation("{Trader}: {New} new trades imported", trader.DisplayName, uniqueNewTrades.Count); - } catch (Exception ex) when (ex.ToString().Contains("Duplicate entry")) { - _logger.LogWarning("{Trader}: Skipping batch due to duplicates", trader.DisplayName); + } catch (Exception ex) when (ex.ToString().Contains("Duplicate entry") || (ex.InnerException?.Message.Contains("Duplicate entry") ?? false)) { + _statsService.TrackDuplicateError(trader.Platform, 1); } } diff --git a/src/Predictalytics.Worker/Services/TraderAnalyticsWorker.cs b/src/Predictalytics.Worker/Services/TraderAnalyticsWorker.cs index a56adf7..5b95ff1 100644 --- a/src/Predictalytics.Worker/Services/TraderAnalyticsWorker.cs +++ b/src/Predictalytics.Worker/Services/TraderAnalyticsWorker.cs @@ -33,25 +33,26 @@ public class TraderAnalyticsWorker : BackgroundService _logger.LogError(ex, "Error in TraderAnalyticsWorker"); } - _logger.LogInformation("TraderAnalyticsWorker sleeping for 12 hours..."); - await Task.Delay(TimeSpan.FromHours(12), ct); + _logger.LogInformation("TraderAnalyticsWorker sleeping for 2 hours..."); + await Task.Delay(TimeSpan.FromHours(2), ct); } } private async Task RunAnalyticsAsync(CancellationToken ct) { - using var scope = _services.CreateScope(); - var db = scope.ServiceProvider.GetRequiredService(); - var pnlEngine = scope.ServiceProvider.GetRequiredService(); - var cutoff30d = DateTime.UtcNow.AddDays(-30); + List traderIds; - // Find traders active in the last 30 days - var traderIds = await db.Trades - .Where(t => t.ExecutedAt >= cutoff30d) - .Select(t => t.TraderId) - .Distinct() - .ToListAsync(ct); + using (var scope = _services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + // Find traders active in the last 30 days + traderIds = await db.Trades + .Where(t => t.ExecutedAt >= cutoff30d) + .Select(t => t.TraderId) + .Distinct() + .ToListAsync(ct); + } _logger.LogInformation("Found {Count} active traders to analyze", traderIds.Count); @@ -59,6 +60,8 @@ public class TraderAnalyticsWorker : BackgroundService { try { + using var traderScope = _services.CreateScope(); + var pnlEngine = traderScope.ServiceProvider.GetRequiredService(); await pnlEngine.RecalculateTraderPositionsAsync(id, ct); } catch (Exception ex)