Enhance UI, add AI integration, improve logging and database stats

This commit is contained in:
Richard
2026-07-04 21:11:31 +02:00
parent 7a44914d9d
commit d102af2965
57 changed files with 4025 additions and 229 deletions
@@ -35,6 +35,12 @@ public static class TraderEndpoints
return Results.Ok(); 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) => group.MapPost("/", async (string platform, string wallet, IAnalyticsService svc, CancellationToken ct) =>
{ {
var id = await svc.AddTraderAsync(platform, wallet, ct); var id = await svc.AddTraderAsync(platform, wallet, ct);
+12 -1
View File
@@ -188,7 +188,8 @@
<button class="btn-sm" id="btn-refresh-trader" style="padding:8px 16px; background:var(--bg-input)">Sync History</button> <button class="btn-sm" id="btn-refresh-trader" style="padding:8px 16px; background:var(--bg-input)">Sync History</button>
</div> </div>
<div class="detail-grid"> <div class="detail-grid">
<div class="card detail-sidebar"> <div class="detail-sidebar">
<div class="card">
<div class="stat-group"> <div class="stat-group">
<div class="stat-label">Platform</div> <div class="stat-label">Platform</div>
<div class="stat-value" id="td-platform"></div> <div class="stat-value" id="td-platform"></div>
@@ -206,6 +207,16 @@
<div class="stat-value" id="td-strategy"></div> <div class="stat-value" id="td-strategy"></div>
</div> </div>
</div> </div>
<div class="card" style="margin-top: 16px;">
<div class="stat-group">
<div class="stat-label" style="display:flex; justify-content:space-between; align-items:center;">
AI Analysis
<button class="btn-sm" id="btn-ai-analysis" style="padding:4px 8px; font-size:12px; background:var(--bg-input);">Run Analysis</button>
</div>
<div class="stat-value small" id="td-ai-summary" style="margin-top: 8px; font-weight:normal; line-height:1.4;">Not analyzed yet.</div>
</div>
</div>
</div>
<div class="detail-main"> <div class="detail-main">
<div class="metrics-grid"> <div class="metrics-grid">
<div class="metric-card"><div class="metric-label">Win Rate</div><div class="metric-value" id="td-winrate"></div></div> <div class="metric-card"><div class="metric-label">Win Rate</div><div class="metric-value" id="td-winrate"></div></div>
+41 -11
View File
@@ -149,7 +149,12 @@ async function api(endpoint) {
// ─── Format Helpers ─── // ─── Format Helpers ───
const fmt = { 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)}`; }, 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 `<span style="color:${color};font-weight:${n>55||n<45?'600':'normal'};">${n.toFixed(1)}%</span>`;
},
num: v => { if (v === null || v === undefined) return '0'; return Number(v).toLocaleString(); }, 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; 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`; if (diff < 60) return `${Math.floor(diff)}s ago`;
@@ -207,15 +212,15 @@ async function loadDashboard() {
// Top Traders table // Top Traders table
const tbody = document.getElementById('topTradersBody'); const tbody = document.getElementById('topTradersBody');
tbody.innerHTML = data.topTraders.map((t, i) => ` tbody.innerHTML = data.topTraders.map((t, i) => `
<tr onclick="viewTrader(${t.id})" style="cursor:pointer"> <tr>
<td>${i + 1}</td> <td onclick="viewTrader(${t.id})" style="cursor:pointer">${i + 1}</td>
<td><strong>${t.displayName}</strong></td> <td><strong>${t.platform === 'Polymarket' ? `<a href="https://polymarket.com/profile/${t.platformUserId}" target="_blank" style="color:var(--text);text-decoration:underline;">${t.displayName}</a>` : t.displayName}</strong></td>
<td>${t.platform}</td> <td onclick="viewTrader(${t.id})" style="cursor:pointer">${t.platform}</td>
<td><strong>${Number(t.combinedScore).toFixed(1)}</strong></td> <td onclick="viewTrader(${t.id})" style="cursor:pointer"><strong>${Number(t.combinedScore).toFixed(1)}</strong></td>
<td>${fmt.pct(t.winRate)}</td> <td onclick="viewTrader(${t.id})" style="cursor:pointer">${fmt.pct(t.winRate)}</td>
<td>${fmt.pnl(t.totalPnl)}</td> <td onclick="viewTrader(${t.id})" style="cursor:pointer">${fmt.pnl(t.totalPnl)}</td>
<td>${fmt.tier(t.tier)}</td> <td onclick="viewTrader(${t.id})" style="cursor:pointer">${fmt.tier(t.tier)}</td>
<td>${fmt.num(t.totalTrades)}</td> <td onclick="viewTrader(${t.id})" style="cursor:pointer">${fmt.num(t.totalTrades)}</td>
</tr> </tr>
`).join(''); `).join('');
@@ -282,7 +287,7 @@ async function loadTraders() {
tbody.innerHTML = data.map((t, i) => ` tbody.innerHTML = data.map((t, i) => `
<tr> <tr>
<td>${i + 1}</td> <td>${i + 1}</td>
<td><strong>${t.displayName}</strong></td> <td><strong>${t.platform === 'Polymarket' ? `<a href="https://polymarket.com/profile/${t.platformUserId}" target="_blank" style="color:var(--text);text-decoration:underline;">${t.displayName}</a>` : t.displayName}</strong></td>
<td>${t.platform}</td> <td>${t.platform}</td>
<td><strong>${Number(t.combinedScore).toFixed(1)}</strong></td> <td><strong>${Number(t.combinedScore).toFixed(1)}</strong></td>
<td>${fmt.pct(t.winRate)}</td> <td>${fmt.pct(t.winRate)}</td>
@@ -350,10 +355,14 @@ async function viewTrader(id) {
document.getElementById('td-pnl').innerHTML = fmt.pnl(t.totalPnl); document.getElementById('td-pnl').innerHTML = fmt.pnl(t.totalPnl);
document.getElementById('td-trades').textContent = fmt.num(t.totalTrades); document.getElementById('td-trades').textContent = fmt.num(t.totalTrades);
document.getElementById('td-score').textContent = Number(t.combinedScore).toFixed(1); 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'); const refreshBtn = document.getElementById('btn-refresh-trader');
refreshBtn.onclick = () => manualUpdateTrader(id); refreshBtn.onclick = () => manualUpdateTrader(id);
const aiBtn = document.getElementById('btn-ai-analysis');
aiBtn.onclick = () => triggerAiAnalysis(id, true);
const tbody = document.getElementById('td-tradesBody'); const tbody = document.getElementById('td-tradesBody');
tbody.innerHTML = t.recentTrades.map(tr => ` tbody.innerHTML = t.recentTrades.map(tr => `
<tr> <tr>
@@ -428,3 +437,24 @@ setInterval(() => {
const activePage = document.querySelector('.page.active'); const activePage = document.querySelector('.page.active');
if (activePage?.id === 'page-dashboard') loadDashboard(); if (activePage?.id === 'page-dashboard') loadDashboard();
}, 30000); }, 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;
}
}
@@ -52,7 +52,7 @@ public class AnalyticsServiceTests
var trader = new Trader { Id = 1, PlatformUserId = "0x1", DisplayName = "Trader 1" }; var trader = new Trader { Id = 1, PlatformUserId = "0x1", DisplayName = "Trader 1" };
db.Traders.Add(trader); 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 }; var outcome = new MarketOutcome { Id = 100, MarketId = 10, Label = "Yes", TokenId = "t100", CurrentPrice = 0.50m };
market.Outcomes.Add(outcome); market.Outcomes.Add(outcome);
db.Markets.Add(market); db.Markets.Add(market);
@@ -28,7 +28,7 @@ public class PositionPnLEngineTests
var pnlEngine = new PositionPnLEngine(db, NullLogger<PositionPnLEngine>.Instance); var pnlEngine = new PositionPnLEngine(db, NullLogger<PositionPnLEngine>.Instance);
var trader = new Trader { Id = 1, PlatformUserId = "0x1", DisplayName = "Trader 1" }; 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 }; var outcome = new MarketOutcome { Id = 100, MarketId = 10, Label = "Yes", TokenId = "t100", CurrentPrice = 0.60m };
market.Outcomes.Add(outcome); market.Outcomes.Add(outcome);
@@ -69,7 +69,7 @@ public class PositionPnLEngineTests
var pnlEngine = new PositionPnLEngine(db, NullLogger<PositionPnLEngine>.Instance); var pnlEngine = new PositionPnLEngine(db, NullLogger<PositionPnLEngine>.Instance);
var trader = new Trader { Id = 1, PlatformUserId = "0x1", DisplayName = "Trader 1" }; 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 }; var outcome = new MarketOutcome { Id = 100, MarketId = 10, Label = "Yes", TokenId = "t100", CurrentPrice = 0.60m };
market.Outcomes.Add(outcome); market.Outcomes.Add(outcome);
@@ -113,7 +113,7 @@ public class PositionPnLEngineTests
var pnlEngine = new PositionPnLEngine(db, NullLogger<PositionPnLEngine>.Instance); var pnlEngine = new PositionPnLEngine(db, NullLogger<PositionPnLEngine>.Instance);
var trader = new Trader { Id = 1, PlatformUserId = "0x1", DisplayName = "Trader 1" }; 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 }; var outcome = new MarketOutcome { Id = 100, MarketId = 10, Label = "Yes", TokenId = "t100", CurrentPrice = 1.00m };
market.Outcomes.Add(outcome); market.Outcomes.Add(outcome);
@@ -42,5 +42,6 @@ public record TraderDetailDto(
bool IsOnWatchlist, bool IsOnWatchlist,
DateTime CreatedAt, DateTime CreatedAt,
DateTime? LastPolledAt, DateTime? LastPolledAt,
string? AiStrategySummary,
IReadOnlyList<TradeDto> RecentTrades IReadOnlyList<TradeDto> RecentTrades
); );
@@ -0,0 +1,9 @@
using System.Threading;
using System.Threading.Tasks;
namespace Predictalytics.Application.Interfaces;
public interface IAiStrategyAnalysisService
{
Task<string> AnalyzeTraderStrategyAsync(int traderId, bool manual, CancellationToken ct = default);
}
@@ -0,0 +1,9 @@
using System.Threading;
using System.Threading.Tasks;
namespace Predictalytics.Application.Interfaces;
public interface IOpenRouterApiClient
{
Task<string> GenerateChatCompletionAsync(string prompt, bool useManualModel = false, CancellationToken ct = default);
}
@@ -7,6 +7,7 @@ public interface IPlatformStatisticsService
void TrackMarketSync(PlatformType platform, int count = 1); void TrackMarketSync(PlatformType platform, int count = 1);
void TrackTraderDiscovery(PlatformType platform, int count = 1); void TrackTraderDiscovery(PlatformType platform, int count = 1);
void TrackTradeActivity(PlatformType platform, int count = 1); void TrackTradeActivity(PlatformType platform, int count = 1);
void TrackDuplicateError(PlatformType platform, int count = 1);
Dictionary<PlatformType, PlatformStats> GetAndResetStats(); Dictionary<PlatformType, PlatformStats> GetAndResetStats();
} }
@@ -16,4 +17,5 @@ public class PlatformStats
public int MarketsSynced { get; set; } public int MarketsSynced { get; set; }
public int TradersDiscovered { get; set; } public int TradersDiscovered { get; set; }
public int TradesProcessed { get; set; } public int TradesProcessed { get; set; }
public int DuplicateErrors { get; set; }
} }
@@ -8,11 +8,11 @@ namespace Predictalytics.Application.Interfaces;
public interface IRateLimiter public interface IRateLimiter
{ {
/// <summary>Wait until a request can be made to the given platform.</summary> /// <summary>Wait until a request can be made to the given platform.</summary>
Task WaitAsync(PlatformType platform, CancellationToken ct = default); Task WaitAsync(PlatformType platform, CancellationToken ct = default, string endpointGroup = "Default");
/// <summary>Check if a request can be made immediately.</summary> /// <summary>Check if a request can be made immediately.</summary>
bool CanMakeRequest(PlatformType platform); bool CanMakeRequest(PlatformType platform, string endpointGroup = "Default");
/// <summary>Report that a 429 Too Many Requests was received.</summary> /// <summary>Report that a 429 Too Many Requests was received.</summary>
void ReportRateLimitExceeded(PlatformType platform, TimeSpan? retryAfter = null); void ReportRateLimitExceeded(PlatformType platform, TimeSpan? retryAfter = null, string endpointGroup = "Default");
} }
@@ -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<AiStrategyAnalysisService> _logger;
public AiStrategyAnalysisService(
ITraderRepository traderRepo,
ITradeRepository tradeRepo,
IOpenRouterApiClient openRouter,
ILogger<AiStrategyAnalysisService> logger)
{
_traderRepo = traderRepo;
_tradeRepo = tradeRepo;
_openRouter = openRouter;
_logger = logger;
}
public async Task<string> 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<Trade> 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();
}
}
@@ -191,6 +191,7 @@ public class AnalyticsService : IAnalyticsService
trader.WinRate, trader.TotalPnl, trader.TotalTrades, trader.WinRate, trader.TotalPnl, trader.TotalTrades,
s?.ActivityScore ?? 0, s?.QualityScore ?? 0, s?.VolumeScore ?? 0, s?.TimingScore ?? 0, 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, s?.CombinedScore ?? 0, s?.CopytradingScore ?? 0, s?.Rank ?? 0, wl != null, trader.CreatedAt, trader.LastPolledAt,
trader.AiStrategySummary,
trades.Select(MapTradeDto).ToList()); trades.Select(MapTradeDto).ToList());
} }
@@ -204,14 +205,14 @@ public class AnalyticsService : IAnalyticsService
if (recentTrades.Count == 0) if (recentTrades.Count == 0)
{ {
// Fallback: trades ingested before DbMarketId backfill // 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 return new MarketDetailDto
{ {
Id = market.Id, Id = market.Id,
Platform = market.Platform.ToString(), Platform = market.Platform.ToString(),
PlatformMarketId = market.PlatformMarketId, PlatformMarketId = market.ConditionId,
Question = market.Question, Question = market.Question,
Description = market.Description, Description = market.Description,
Category = market.Category, Category = market.Category,
@@ -26,6 +26,12 @@ public class PlatformStatisticsService : IPlatformStatisticsService
lock (stats) stats.TradesProcessed += count; 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<PlatformType, PlatformStats> GetAndResetStats() public Dictionary<PlatformType, PlatformStats> GetAndResetStats()
{ {
var result = new Dictionary<PlatformType, PlatformStats>(); var result = new Dictionary<PlatformType, PlatformStats>();
@@ -9,30 +9,35 @@ namespace Predictalytics.Application.Services;
/// </summary> /// </summary>
public class RateLimiterService : IRateLimiter public class RateLimiterService : IRateLimiter
{ {
private readonly ConcurrentDictionary<PlatformType, SemaphoreSlim> _semaphores = new(); private readonly ConcurrentDictionary<string, SemaphoreSlim> _semaphores = new();
private readonly ConcurrentDictionary<PlatformType, DateTime> _lastRequest = new(); private readonly ConcurrentDictionary<string, DateTime> _lastRequest = new();
private readonly ConcurrentDictionary<PlatformType, DateTime> _blockedUntil = new(); private readonly ConcurrentDictionary<string, DateTime> _blockedUntil = new();
// Minimum delay between requests per platform (milliseconds) private static readonly Dictionary<string, int> Delays = new()
private static readonly Dictionary<PlatformType, int> PlatformDelays = new()
{ {
{ PlatformType.Polymarket, 200 }, { "Polymarket-Gamma", 35 }, // ~28 req/s (Gamma /markets limit is 30/s)
{ PlatformType.Limitless, 500 }, { "Polymarket-Data", 55 }, // ~18 req/s (Data /trades limit is 20/s)
{ PlatformType.Azuro, 1000 }, { "Polymarket-Clob", 15 }, // ~66 req/s (Clob /prices limit is 100/s)
{ PlatformType.Myriad, 1000 }, { "Polymarket-Default", 0 }, // Bypass worker-level limiting for Polymarket, handled in ApiClient
{ PlatformType.PredictFun, 1000 },
{ PlatformType.Kalshi, 500 }, { "Limitless-Default", 500 },
{ PlatformType.Stake, 1000 } { "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); await sem.WaitAsync(ct);
try try
{ {
// 1. Check if we are currently blocked due to a 429 if (_blockedUntil.TryGetValue(key, out var blockedUntil))
if (_blockedUntil.TryGetValue(platform, out var blockedUntil))
{ {
var waitTime = blockedUntil - DateTime.UtcNow; var waitTime = blockedUntil - DateTime.UtcNow;
if (waitTime > TimeSpan.Zero) if (waitTime > TimeSpan.Zero)
@@ -41,32 +46,37 @@ public class RateLimiterService : IRateLimiter
} }
} }
// 2. Normal token bucket delay if (_lastRequest.TryGetValue(key, out var last))
if (_lastRequest.TryGetValue(platform, out var last))
{ {
var delayMs = PlatformDelays.GetValueOrDefault(platform, 1000); var delayMs = Delays.GetValueOrDefault(key, 1000);
var elapsed = (DateTime.UtcNow - last).TotalMilliseconds; var elapsed = (DateTime.UtcNow - last).TotalMilliseconds;
if (elapsed < delayMs) if (elapsed < delayMs)
await Task.Delay((int)(delayMs - elapsed), ct); await Task.Delay((int)(delayMs - elapsed), ct);
} }
_lastRequest[platform] = DateTime.UtcNow; _lastRequest[key] = DateTime.UtcNow;
} }
finally { sem.Release(); } 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; return false;
if (!_lastRequest.TryGetValue(platform, out var last)) return true; if (!_lastRequest.TryGetValue(key, out var last)) return true;
var delayMs = PlatformDelays.GetValueOrDefault(platform, 1000); var delayMs = Delays.GetValueOrDefault(key, 1000);
return (DateTime.UtcNow - last).TotalMilliseconds >= delayMs; 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); var penalty = retryAfter ?? TimeSpan.FromSeconds(30);
_blockedUntil[platform] = DateTime.UtcNow.Add(penalty); _blockedUntil[key] = DateTime.UtcNow.Add(penalty);
} }
} }
@@ -0,0 +1,56 @@
using Predictalytics.Domain.Enums;
namespace Predictalytics.Domain.Entities;
/// <summary>
/// Represents a prediction market event, which can contain multiple Markets (Questions).
/// </summary>
public class Event
{
public int Id { get; set; }
/// <summary>Platform this event belongs to.</summary>
public PlatformType Platform { get; set; }
/// <summary>Platform-specific numeric identifier for the event.</summary>
public long PlatformEventId { get; set; }
/// <summary>URL-friendly slug for the event.</summary>
public string Slug { get; set; } = string.Empty;
/// <summary>Event title.</summary>
public string Title { get; set; } = string.Empty;
/// <summary>Detailed event description.</summary>
public string? Description { get; set; }
/// <summary>Event image URL.</summary>
public string? ImageUrl { get; set; }
/// <summary>The time when the event starts.</summary>
public DateTime? StartDate { get; set; }
/// <summary>The time when the event ends.</summary>
public DateTime? EndDate { get; set; }
/// <summary>Comma-separated tags (categories) associated with the event.</summary>
public string Tags { get; set; } = string.Empty;
/// <summary>Whether the event is currently active.</summary>
public bool IsActive { get; set; }
/// <summary>Whether the event is closed.</summary>
public bool IsClosed { get; set; }
/// <summary>When this event was created on the platform.</summary>
public DateTime CreatedAt { get; set; }
/// <summary>When this record was first saved to our database.</summary>
public DateTime DbCreatedAt { get; set; } = DateTime.UtcNow;
/// <summary>Last time event data was refreshed.</summary>
public DateTime? LastUpdatedAt { get; set; }
// Navigation
public ICollection<Market> Markets { get; set; } = new List<Market>();
}
+15 -5
View File
@@ -12,15 +12,22 @@ public class Market
/// <summary>Platform this market belongs to.</summary> /// <summary>Platform this market belongs to.</summary>
public PlatformType Platform { get; set; } public PlatformType Platform { get; set; }
/// <summary>Platform-specific market identifier (conditionId on Polymarket).</summary> /// <summary>The Event this market belongs to.</summary>
public string PlatformMarketId { get; set; } = string.Empty; public int EventId { get; set; }
public virtual Event Event { get; set; }
/// <summary>Platform-specific numeric market identifier.</summary>
public long PlatformMarketId { get; set; }
/// <summary>Platform-specific blockchain market identifier (conditionId on Polymarket).</summary>
public string ConditionId { get; set; } = string.Empty;
/// <summary>Smart contract question ID.</summary>
public string QuestionId { get; set; } = string.Empty;
/// <summary>URL-friendly slug for the market.</summary> /// <summary>URL-friendly slug for the market.</summary>
public string MarketSlug { get; set; } = string.Empty; public string MarketSlug { get; set; } = string.Empty;
/// <summary>URL-friendly slug for the parent event.</summary>
public string EventSlug { get; set; } = string.Empty;
/// <summary>Detailed market description / resolution criteria.</summary> /// <summary>Detailed market description / resolution criteria.</summary>
public string? Description { get; set; } public string? Description { get; set; }
@@ -36,6 +43,9 @@ public class Market
/// <summary>Current total volume traded.</summary> /// <summary>Current total volume traded.</summary>
public decimal Volume { get; set; } public decimal Volume { get; set; }
/// <summary>Current 24h volume.</summary>
public decimal Volume24h { get; set; }
/// <summary>Current liquidity.</summary> /// <summary>Current liquidity.</summary>
public decimal Liquidity { get; set; } public decimal Liquidity { get; set; }
@@ -66,6 +66,19 @@ public class Trade
/// <summary>Transaction hash (for blockchain-based platforms).</summary> /// <summary>Transaction hash (for blockchain-based platforms).</summary>
public string? TransactionHash { get; set; } public string? TransactionHash { get; set; }
// ── Context Enrichment (AI Strategy Detection) ───────────
/// <summary>Market price 1 minute before trade execution.</summary>
[Column(TypeName = "decimal(18,4)")]
public decimal? PreTradePrice1m { get; set; }
/// <summary>Market price 1 minute after trade execution.</summary>
[Column(TypeName = "decimal(18,4)")]
public decimal? PostTradePrice1m { get; set; }
/// <summary>Indicates if high-res price context was fetched.</summary>
public bool IsContextEnriched { get; set; }
// ── Transient (not persisted) ────────────────────────────────────────── // ── Transient (not persisted) ──────────────────────────────────────────
/// <summary> /// <summary>
@@ -58,6 +58,12 @@ public class Trader
/// <summary>Win rate as a percentage (0-100).</summary> /// <summary>Win rate as a percentage (0-100).</summary>
public decimal WinRate { get; set; } public decimal WinRate { get; set; }
/// <summary>AI-generated strategy summary based on trade history.</summary>
public string? AiStrategySummary { get; set; }
/// <summary>When the AI strategy summary was last updated.</summary>
public DateTime? AiStrategyUpdatedAt { get; set; }
/// <summary>Total number of trades tracked.</summary> /// <summary>Total number of trades tracked.</summary>
public int TotalTrades { get; set; } public int TotalTrades { get; set; }
@@ -10,6 +10,7 @@ public interface IMarketRepository
Task<IReadOnlyList<MarketOutcome>> GetOutcomesByTokenIdsAsync(IEnumerable<string> tokenIds, CancellationToken ct = default); Task<IReadOnlyList<MarketOutcome>> GetOutcomesByTokenIdsAsync(IEnumerable<string> tokenIds, CancellationToken ct = default);
Task AddOrUpdateAsync(Market market, CancellationToken ct = default); Task AddOrUpdateAsync(Market market, CancellationToken ct = default);
Task AddOrUpdateRangeAsync(IEnumerable<Market> markets, CancellationToken ct = default); Task AddOrUpdateRangeAsync(IEnumerable<Market> markets, CancellationToken ct = default);
Task AddOrUpdateEventsAsync(IEnumerable<Event> events, CancellationToken ct = default);
Task<IReadOnlyList<Market>> GetActiveAsync(int count = 50, CancellationToken ct = default); Task<IReadOnlyList<Market>> GetActiveAsync(int count = 50, CancellationToken ct = default);
Task<int> GetCountAsync(CancellationToken ct = default); Task<int> GetCountAsync(CancellationToken ct = default);
Task<IReadOnlyList<Market>> GetMarketsDueForTradeUpdateAsync(int cooldownHours, int limit, CancellationToken ct = default); Task<IReadOnlyList<Market>> GetMarketsDueForTradeUpdateAsync(int cooldownHours, int limit, CancellationToken ct = default);
@@ -30,8 +30,8 @@ public interface IPlatformProvider
/// <summary>Fetch market metadata by platform-specific market ID.</summary> /// <summary>Fetch market metadata by platform-specific market ID.</summary>
Task<Market?> GetMarketAsync(string platformMarketId, CancellationToken ct = default); Task<Market?> GetMarketAsync(string platformMarketId, CancellationToken ct = default);
/// <summary>Fetch a batch of markets with their outcomes for bulk sync.</summary> /// <summary>Fetch a batch of events with their markets and outcomes for bulk sync.</summary>
Task<IReadOnlyList<Market>> GetMarketsAsync(int limit = 100, string? cursor = null, bool includeClosed = false, CancellationToken ct = default); Task<IReadOnlyList<Event>> GetEventsAsync(int limit = 100, string? cursor = null, bool includeClosed = false, CancellationToken ct = default);
/// <summary>Fetch top holders for a market to discover new traders.</summary> /// <summary>Fetch top holders for a market to discover new traders.</summary>
Task<IReadOnlyList<DiscoveredTrader>> GetTopHoldersAsync(string platformMarketId, int limit = 20, CancellationToken ct = default); Task<IReadOnlyList<DiscoveredTrader>> GetTopHoldersAsync(string platformMarketId, int limit = 20, CancellationToken ct = default);
@@ -19,4 +19,5 @@ public interface ITradeRepository
Task<IReadOnlyList<Trade>> GetOrphanedTradesAsync(int limit, CancellationToken ct = default); Task<IReadOnlyList<Trade>> GetOrphanedTradesAsync(int limit, CancellationToken ct = default);
Task<HashSet<string>> GetKnownPlatformTradeIdsAsync(PlatformType platform, int traderId, IEnumerable<string> platformTradeIds, CancellationToken ct = default); Task<HashSet<string>> GetKnownPlatformTradeIdsAsync(PlatformType platform, int traderId, IEnumerable<string> platformTradeIds, CancellationToken ct = default);
Task UpdateAsync(Trade trade, CancellationToken ct = default); Task UpdateAsync(Trade trade, CancellationToken ct = default);
Task<IReadOnlyList<Trade>> GetTradesForContextEnrichmentAsync(int limit, CancellationToken ct = default);
} }
@@ -22,5 +22,8 @@ public interface ITraderRepository
/// <summary>Get traders that haven't been polled in a long time or have a prolonged API error for cleanup.</summary> /// <summary>Get traders that haven't been polled in a long time or have a prolonged API error for cleanup.</summary>
Task<IReadOnlyList<Trader>> GetTradersForCleanupAsync(DateTime inactiveSince, DateTime errorSince, int take = 50, CancellationToken ct = default); Task<IReadOnlyList<Trader>> GetTradersForCleanupAsync(DateTime inactiveSince, DateTime errorSince, int take = 50, CancellationToken ct = default);
/// <summary>Get traders ordered by LastPolledAt to ensure round-robin polling of all traders.</summary>
Task<IReadOnlyList<Trader>> GetTradersForPollingAsync(int take, CancellationToken ct = default);
Task<IReadOnlyList<Trader>> SearchAsync(string query, int take = 20, CancellationToken ct = default); Task<IReadOnlyList<Trader>> SearchAsync(string query, int take = 20, CancellationToken ct = default);
} }
@@ -1,4 +1,4 @@
using Predictalytics.Domain.Enums; using Predictalytics.Domain.Enums;
namespace Predictalytics.Domain.ValueObjects; namespace Predictalytics.Domain.ValueObjects;
@@ -23,14 +23,15 @@ public record PriorityScore(
/// <summary> /// <summary>
/// Determine tier based on effective score. /// Determine tier based on effective score.
/// To reach Diamond, the score must be exceptionally high (top 1%).
/// </summary> /// </summary>
public TraderTier DetermineTier() => EffectiveScore switch public TraderTier DetermineTier() => EffectiveScore switch
{ {
>= 90 => TraderTier.Diamond, >= 95 => TraderTier.Diamond,
>= 75 => TraderTier.Platinum, >= 85 => TraderTier.Platinum,
>= 60 => TraderTier.Gold, >= 70 => TraderTier.Gold,
>= 40 => TraderTier.Silver, >= 50 => TraderTier.Silver,
>= 20 => TraderTier.Bronze, >= 30 => TraderTier.Bronze,
_ => TraderTier.Unknown _ => TraderTier.Unknown
}; };
} }
@@ -7,6 +7,7 @@ public class AppDbContext : DbContext
{ {
public DbSet<Trader> Traders => Set<Trader>(); public DbSet<Trader> Traders => Set<Trader>();
public DbSet<Trade> Trades => Set<Trade>(); public DbSet<Trade> Trades => Set<Trade>();
public DbSet<Event> Events => Set<Event>();
public DbSet<Market> Markets => Set<Market>(); public DbSet<Market> Markets => Set<Market>();
public DbSet<MarketOutcome> MarketOutcomes => Set<MarketOutcome>(); public DbSet<MarketOutcome> MarketOutcomes => Set<MarketOutcome>();
public DbSet<TraderScore> TraderScores => Set<TraderScore>(); public DbSet<TraderScore> TraderScores => Set<TraderScore>();
@@ -66,19 +67,34 @@ public class AppDbContext : DbContext
.OnDelete(DeleteBehavior.SetNull); .OnDelete(DeleteBehavior.SetNull);
}); });
// Event
mb.Entity<Event>(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 // Market
mb.Entity<Market>(e => mb.Entity<Market>(e =>
{ {
e.HasKey(m => m.Id); e.HasKey(m => m.Id);
e.HasIndex(m => new { m.Platform, m.PlatformMarketId }).IsUnique(); 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.MarketSlug).HasMaxLength(512);
e.Property(m => m.EventSlug).HasMaxLength(512);
e.Property(m => m.Question).HasMaxLength(1024); e.Property(m => m.Question).HasMaxLength(1024);
e.Property(m => m.Description).HasMaxLength(4096); e.Property(m => m.Description).HasMaxLength(4096);
e.Property(m => m.ImageUrl).HasMaxLength(1024); e.Property(m => m.ImageUrl).HasMaxLength(1024);
e.Property(m => m.Category).HasMaxLength(128); e.Property(m => m.Category).HasMaxLength(128);
e.Property(m => m.Volume).HasPrecision(18, 4); 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.Property(m => m.Liquidity).HasPrecision(18, 4);
e.HasMany(m => m.Outcomes).WithOne(o => o.Market).HasForeignKey(o => o.MarketId) e.HasMany(m => m.Outcomes).WithOne(o => o.Market).HasForeignKey(o => o.MarketId)
.OnDelete(DeleteBehavior.Cascade); .OnDelete(DeleteBehavior.Cascade);
@@ -14,8 +14,8 @@ public class MarketRepository : IMarketRepository
public MarketRepository(AppDbContext db) => _db = db; public MarketRepository(AppDbContext db) => _db = db;
public async Task<Market?> GetByPlatformIdAsync(PlatformType platform, string platformMarketId, CancellationToken ct = default) public async Task<Market?> GetByPlatformIdAsync(PlatformType platform, string platformMarketId, CancellationToken ct = default)
=> await _db.Markets.Include(m => m.Outcomes) => await _db.Markets.Include(m => m.Outcomes).Include(m => m.Event)
.FirstOrDefaultAsync(m => m.Platform == platform && m.PlatformMarketId == platformMarketId, ct); .FirstOrDefaultAsync(m => m.Platform == platform && m.ConditionId == platformMarketId, ct);
public async Task<MarketOutcome?> GetOutcomeByTokenIdAsync(string tokenId, CancellationToken ct = default) public async Task<MarketOutcome?> GetOutcomeByTokenIdAsync(string tokenId, CancellationToken ct = default)
=> await _db.MarketOutcomes.Include(o => o.Market) => await _db.MarketOutcomes.Include(o => o.Market)
@@ -34,7 +34,7 @@ public class MarketRepository : IMarketRepository
TruncateMarketStrings(market); TruncateMarketStrings(market);
var existing = await _db.Markets.Include(m => m.Outcomes) 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) if (existing != null)
{ {
@@ -42,6 +42,11 @@ public class MarketRepository : IMarketRepository
} }
else 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); _db.Markets.Add(market);
} }
@@ -55,9 +60,9 @@ public class MarketRepository : IMarketRepository
public async Task AddOrUpdateRangeAsync(IEnumerable<Market> markets, CancellationToken ct = default) public async Task AddOrUpdateRangeAsync(IEnumerable<Market> 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 var marketList = markets
.GroupBy(m => new { m.Platform, m.PlatformMarketId }) .GroupBy(m => new { m.Platform, m.ConditionId })
.Select(g => g.First()) .Select(g => g.First())
.ToList(); .ToList();
@@ -72,25 +77,28 @@ public class MarketRepository : IMarketRepository
{ {
var currentBatch = marketList.Skip(i).Take(subBatchSize).ToList(); var currentBatch = marketList.Skip(i).Take(subBatchSize).ToList();
var platform = currentBatch.First().Platform; 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) 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); .ToListAsync(ct);
var existingMap = existingMarkets.ToDictionary(m => m.PlatformMarketId); var existingMap = existingMarkets.ToDictionary(m => m.ConditionId);
foreach (var market in currentBatch) foreach (var market in currentBatch)
{ {
TruncateMarketStrings(market); TruncateMarketStrings(market);
if (existingMap.TryGetValue(market.PlatformMarketId, out var existing)) if (existingMap.TryGetValue(market.ConditionId, out var existing))
{ {
UpdateMarketFields(existing, market); UpdateMarketFields(existing, market);
} }
else 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); _db.Markets.Add(market);
} }
} }
@@ -104,21 +112,96 @@ public class MarketRepository : IMarketRepository
} }
} }
public async Task AddOrUpdateEventsAsync(IEnumerable<Event> 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) private void UpdateMarketFields(Market existing, Market updated)
{ {
existing.Question = updated.Question; existing.Question = updated.Question;
existing.MarketSlug = updated.MarketSlug; existing.MarketSlug = updated.MarketSlug;
existing.EventSlug = updated.EventSlug; existing.PlatformMarketId = updated.PlatformMarketId;
existing.QuestionId = updated.QuestionId;
existing.Description = updated.Description; existing.Description = updated.Description;
existing.ImageUrl = updated.ImageUrl; existing.ImageUrl = updated.ImageUrl;
existing.Category = updated.Category; existing.Category = updated.Category;
existing.Volume = updated.Volume; existing.Volume = updated.Volume;
existing.Volume24h = updated.Volume24h;
existing.Liquidity = updated.Liquidity; existing.Liquidity = updated.Liquidity;
existing.StartDate = updated.StartDate; existing.StartDate = updated.StartDate;
existing.EndDate = updated.EndDate; existing.EndDate = updated.EndDate;
existing.IsResolved = updated.IsResolved; existing.IsResolved = updated.IsResolved;
existing.ResolutionOutcome = updated.ResolutionOutcome; existing.ResolutionOutcome = updated.ResolutionOutcome;
existing.CreatedAt = updated.CreatedAt; // Platform creation date existing.CreatedAt = updated.CreatedAt;
existing.LastUpdatedAt = DateTime.UtcNow; existing.LastUpdatedAt = DateTime.UtcNow;
// Upsert outcomes // Upsert outcomes
@@ -146,7 +229,6 @@ public class MarketRepository : IMarketRepository
market.Question = StringHelper.Truncate(market.Question, 1024) ?? ""; market.Question = StringHelper.Truncate(market.Question, 1024) ?? "";
market.Description = StringHelper.Truncate(market.Description, 4096); market.Description = StringHelper.Truncate(market.Description, 4096);
market.MarketSlug = StringHelper.Truncate(market.MarketSlug, 512) ?? ""; market.MarketSlug = StringHelper.Truncate(market.MarketSlug, 512) ?? "";
market.EventSlug = StringHelper.Truncate(market.EventSlug, 512) ?? "";
market.ImageUrl = StringHelper.Truncate(market.ImageUrl, 1024); market.ImageUrl = StringHelper.Truncate(market.ImageUrl, 1024);
market.Category = StringHelper.Truncate(market.Category, 128) ?? ""; market.Category = StringHelper.Truncate(market.Category, 128) ?? "";
@@ -192,7 +274,7 @@ public class MarketRepository : IMarketRepository
return await _db.Markets.Include(m => m.Outcomes) return await _db.Markets.Include(m => m.Outcomes)
.Where(m => m.Question.Contains(query) || .Where(m => m.Question.Contains(query) ||
m.PlatformMarketId.Contains(query) || m.ConditionId.Contains(query) ||
m.Id.ToString() == query) m.Id.ToString() == query)
.OrderByDescending(m => m.Volume) .OrderByDescending(m => m.Volume)
.Take(take) .Take(take)
@@ -92,13 +92,13 @@ public class TradeRepository : ITradeRepository
public async Task<HashSet<string>> GetKnownPlatformTradeIdsAsync(PlatformType platform, int traderId, IEnumerable<string> platformTradeIds, CancellationToken ct = default) public async Task<HashSet<string>> GetKnownPlatformTradeIdsAsync(PlatformType platform, int traderId, IEnumerable<string> platformTradeIds, CancellationToken ct = default)
{ {
var idList = platformTradeIds.ToList(); var idList = platformTradeIds.ToList();
if (idList.Count == 0) return new HashSet<string>(); if (idList.Count == 0) return new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var ids = await _db.Trades 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) .Select(t => t.PlatformTradeId)
.ToListAsync(ct); .ToListAsync(ct);
return new HashSet<string>(ids); return new HashSet<string>(ids, StringComparer.OrdinalIgnoreCase);
} }
public async Task UpdateAsync(Trade trade, CancellationToken ct = default) public async Task UpdateAsync(Trade trade, CancellationToken ct = default)
@@ -111,4 +111,19 @@ public class TradeRepository : ITradeRepository
_db.Trades.Update(trade); _db.Trades.Update(trade);
await _db.SaveChangesAsync(ct); await _db.SaveChangesAsync(ct);
} }
public async Task<IReadOnlyList<Trade>> 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);
}
} }
@@ -100,6 +100,14 @@ public class TraderRepository : ITraderRepository
.ToListAsync(ct); .ToListAsync(ct);
} }
public async Task<IReadOnlyList<Trader>> GetTradersForPollingAsync(int take, CancellationToken ct = default)
{
return await _db.Traders
.OrderBy(t => t.LastPolledAt)
.Take(take)
.ToListAsync(ct);
}
public async Task<IReadOnlyList<Trader>> SearchAsync(string query, int take = 20, CancellationToken ct = default) public async Task<IReadOnlyList<Trader>> SearchAsync(string query, int take = 20, CancellationToken ct = default)
{ {
if (string.IsNullOrWhiteSpace(query)) return Array.Empty<Trader>(); if (string.IsNullOrWhiteSpace(query)) return Array.Empty<Trader>();
@@ -84,6 +84,8 @@ public static class DependencyInjection
services.AddSingleton<PolymarketApiClient>(); services.AddSingleton<PolymarketApiClient>();
services.AddSingleton<LimitlessApiClient>(); services.AddSingleton<LimitlessApiClient>();
services.AddHttpClient<Predictalytics.Application.Interfaces.IOpenRouterApiClient, Predictalytics.Infrastructure.Providers.OpenRouter.OpenRouterApiClient>();
services.AddScoped<Predictalytics.Application.Interfaces.IAiStrategyAnalysisService, Predictalytics.Application.Services.AiStrategyAnalysisService>();
services.AddSingleton<IPlatformProvider, PolymarketProvider>(); services.AddSingleton<IPlatformProvider, PolymarketProvider>();
services.AddSingleton<IPlatformProvider, LimitlessProvider>(); services.AddSingleton<IPlatformProvider, LimitlessProvider>();
services.AddSingleton<IPlatformProvider, AzuroProvider>(); services.AddSingleton<IPlatformProvider, AzuroProvider>();
@@ -0,0 +1,750 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Predictalytics.Infrastructure.Data;
#nullable disable
namespace Predictalytics.Infrastructure.Migrations
{
[DbContext(typeof(AppDbContext))]
[Migration("20260703100444_AddTradePriceContext")]
partial class AddTradePriceContext
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "8.0.11")
.HasAnnotation("Relational:MaxIdentifierLength", 64);
MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder);
modelBuilder.Entity("Predictalytics.Domain.Entities.Alert", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<bool>("IsRead")
.HasColumnType("tinyint(1)");
b.Property<string>("Message")
.IsRequired()
.HasMaxLength(4096)
.HasColumnType("varchar(4096)");
b.Property<int>("Platform")
.HasColumnType("int");
b.Property<int>("Severity")
.HasColumnType("int");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("varchar(512)");
b.Property<int?>("TraderId")
.HasColumnType("int");
b.Property<int>("Type")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("CreatedAt");
b.HasIndex("TraderId");
b.ToTable("Alerts");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.Market", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
b.Property<string>("Category")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("varchar(128)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<DateTime>("DbCreatedAt")
.HasColumnType("datetime(6)");
b.Property<string>("Description")
.HasMaxLength(4096)
.HasColumnType("varchar(4096)");
b.Property<DateTime?>("EndDate")
.HasColumnType("datetime(6)");
b.Property<string>("EventSlug")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("varchar(512)");
b.Property<string>("ImageUrl")
.HasMaxLength(1024)
.HasColumnType("varchar(1024)");
b.Property<bool>("IsResolved")
.HasColumnType("tinyint(1)");
b.Property<DateTime?>("LastTradesUpdatedAt")
.HasColumnType("datetime(6)");
b.Property<DateTime?>("LastUpdatedAt")
.HasColumnType("datetime(6)");
b.Property<decimal>("Liquidity")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<string>("MarketSlug")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("varchar(512)");
b.Property<int>("Platform")
.HasColumnType("int");
b.Property<string>("PlatformMarketId")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("varchar(256)");
b.Property<string>("Question")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("varchar(1024)");
b.Property<string>("ResolutionOutcome")
.HasColumnType("longtext");
b.Property<DateTime?>("StartDate")
.HasColumnType("datetime(6)");
b.Property<decimal>("Volume")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.HasKey("Id");
b.HasIndex("Platform", "PlatformMarketId")
.IsUnique();
b.ToTable("Markets");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.MarketAnalytics", b =>
{
b.Property<int>("MarketId")
.HasColumnType("int");
b.Property<decimal>("AverageTradeSize")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<decimal>("BotActivityScore")
.HasPrecision(8, 4)
.HasColumnType("decimal(8,4)");
b.Property<DateTime>("LastCalculatedAt")
.HasColumnType("datetime(6)");
b.Property<int>("UniqueTradersCount")
.HasColumnType("int");
b.HasKey("MarketId");
b.ToTable("MarketAnalytics");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.MarketOutcome", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
b.Property<decimal>("CurrentPrice")
.HasPrecision(18, 8)
.HasColumnType("decimal(18,8)");
b.Property<string>("Label")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("varchar(256)");
b.Property<int>("MarketId")
.HasColumnType("int");
b.Property<int>("OutcomeIndex")
.HasColumnType("int");
b.Property<string>("TokenId")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("varchar(256)");
b.HasKey("Id");
b.HasIndex("TokenId");
b.HasIndex("MarketId", "OutcomeIndex")
.IsUnique();
b.ToTable("MarketOutcomes");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.MarketOutcomePriceSnapshot", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
b.Property<int>("MarketOutcomeId")
.HasColumnType("int");
b.Property<decimal>("Price")
.HasPrecision(10, 6)
.HasColumnType("decimal(10,6)");
b.Property<DateTime>("Timestamp")
.HasColumnType("datetime(6)");
b.HasKey("Id");
b.HasIndex("MarketOutcomeId", "Timestamp");
b.ToTable("MarketOutcomePriceSnapshots");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.PlatformConfig", b =>
{
b.Property<int>("Id")
.HasColumnType("int");
b.Property<string>("BaseUrl")
.HasMaxLength(1024)
.HasColumnType("varchar(1024)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("varchar(256)");
b.Property<bool>("IsActive")
.HasColumnType("tinyint(1)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("varchar(128)");
b.Property<string>("SettingsJson")
.HasColumnType("longtext");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime(6)");
b.HasKey("Id");
b.ToTable("PlatformConfigs");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.Trade", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<long>("Id"));
b.Property<decimal>("Amount")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<string>("AssetId")
.IsRequired()
.HasMaxLength(80)
.HasColumnType("varchar(80)");
b.Property<int?>("DbMarketId")
.HasColumnType("int");
b.Property<DateTime>("ExecutedAt")
.HasColumnType("datetime(6)");
b.Property<bool>("IsContextEnriched")
.HasColumnType("tinyint(1)");
b.Property<string>("MarketId")
.IsRequired()
.HasMaxLength(66)
.HasColumnType("varchar(66)");
b.Property<int?>("MarketOutcomeId")
.HasColumnType("int");
b.Property<string>("Outcome")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("varchar(128)");
b.Property<int>("Platform")
.HasColumnType("int");
b.Property<string>("PlatformTradeId")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("varchar(256)");
b.Property<decimal?>("PostTradePrice1m")
.HasColumnType("decimal(18,4)");
b.Property<decimal?>("PreTradePrice1m")
.HasColumnType("decimal(18,4)");
b.Property<decimal>("Price")
.HasPrecision(10, 6)
.HasColumnType("decimal(10,6)");
b.Property<int>("Side")
.HasColumnType("int");
b.Property<decimal>("Size")
.HasPrecision(14, 6)
.HasColumnType("decimal(14,6)");
b.Property<int>("TraderId")
.HasColumnType("int");
b.Property<string>("TransactionHash")
.HasMaxLength(66)
.HasColumnType("varchar(66)");
b.HasKey("Id");
b.HasIndex("AssetId");
b.HasIndex("DbMarketId");
b.HasIndex("ExecutedAt");
b.HasIndex("MarketOutcomeId");
b.HasIndex("TraderId");
b.HasIndex("Platform", "PlatformTradeId")
.IsUnique();
b.ToTable("Trades");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.Trader", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("varchar(256)");
b.Property<bool>("IsAutoDiscovered")
.HasColumnType("tinyint(1)");
b.Property<bool>("IsInitialImportComplete")
.HasColumnType("tinyint(1)");
b.Property<bool>("IsSuspectedBot")
.HasColumnType("tinyint(1)");
b.Property<DateTime?>("LastApiErrorAt")
.HasColumnType("datetime(6)");
b.Property<DateTime?>("LastPolledAt")
.HasColumnType("datetime(6)");
b.Property<DateTime?>("LastTradesUpdatedAt")
.HasColumnType("datetime(6)");
b.Property<int?>("ManualPriorityOverride")
.HasColumnType("int");
b.Property<string>("Notes")
.HasColumnType("longtext");
b.Property<int>("Platform")
.HasColumnType("int");
b.Property<string>("PlatformUserId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("varchar(128)");
b.Property<int>("Strategy")
.HasColumnType("int");
b.Property<int>("Tier")
.HasColumnType("int");
b.Property<decimal>("TotalPnl")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<int>("TotalTrades")
.HasColumnType("int");
b.Property<decimal>("WinRate")
.HasPrecision(8, 4)
.HasColumnType("decimal(8,4)");
b.HasKey("Id");
b.HasIndex("Platform", "PlatformUserId")
.IsUnique();
b.ToTable("Traders");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.TraderAnalytics", b =>
{
b.Property<int>("TraderId")
.HasColumnType("int");
b.Property<DateTime>("LastCalculatedAt")
.HasColumnType("datetime(6)");
b.Property<decimal>("OverallPnL")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<decimal>("OverallWinRate")
.HasPrecision(8, 4)
.HasColumnType("decimal(8,4)");
b.Property<decimal>("PnL24h")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<decimal>("PnL30d")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<decimal>("PnL7d")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<decimal>("WinRate24h")
.HasPrecision(8, 4)
.HasColumnType("decimal(8,4)");
b.Property<decimal>("WinRate30d")
.HasPrecision(8, 4)
.HasColumnType("decimal(8,4)");
b.Property<decimal>("WinRate7d")
.HasPrecision(8, 4)
.HasColumnType("decimal(8,4)");
b.HasKey("TraderId");
b.ToTable("TraderAnalytics");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.TraderPosition", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
b.Property<decimal>("AvgCost")
.HasPrecision(10, 6)
.HasColumnType("decimal(10,6)");
b.Property<DateTime>("LastUpdatedAt")
.HasColumnType("datetime(6)");
b.Property<int>("MarketOutcomeId")
.HasColumnType("int");
b.Property<decimal>("RealizedPnl")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<decimal>("SharesHeld")
.HasPrecision(14, 6)
.HasColumnType("decimal(14,6)");
b.Property<int>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
b.Property<decimal>("ActivityScore")
.HasPrecision(8, 4)
.HasColumnType("decimal(8,4)");
b.Property<DateTime>("CalculatedAt")
.HasColumnType("datetime(6)");
b.Property<decimal>("CombinedScore")
.HasPrecision(8, 4)
.HasColumnType("decimal(8,4)");
b.Property<decimal>("CopytradingScore")
.HasPrecision(8, 4)
.HasColumnType("decimal(8,4)");
b.Property<decimal>("QualityScore")
.HasPrecision(8, 4)
.HasColumnType("decimal(8,4)");
b.Property<int>("Rank")
.HasColumnType("int");
b.Property<decimal>("TimingScore")
.HasPrecision(8, 4)
.HasColumnType("decimal(8,4)");
b.Property<int>("TraderId")
.HasColumnType("int");
b.Property<decimal>("VolumeScore")
.HasPrecision(8, 4)
.HasColumnType("decimal(8,4)");
b.HasKey("Id");
b.HasIndex("TraderId")
.IsUnique();
b.ToTable("TraderScores");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.WatchlistEntry", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
b.Property<DateTime>("AddedAt")
.HasColumnType("datetime(6)");
b.Property<bool>("AlertsEnabled")
.HasColumnType("tinyint(1)");
b.Property<string>("Label")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("varchar(256)");
b.Property<string>("Notes")
.HasColumnType("longtext");
b.Property<int>("TraderId")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("TraderId")
.IsUnique();
b.ToTable("WatchlistEntries");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.Alert", b =>
{
b.HasOne("Predictalytics.Domain.Entities.Trader", "Trader")
.WithMany()
.HasForeignKey("TraderId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("Trader");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.MarketAnalytics", b =>
{
b.HasOne("Predictalytics.Domain.Entities.Market", "Market")
.WithOne("Analytics")
.HasForeignKey("Predictalytics.Domain.Entities.MarketAnalytics", "MarketId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Market");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.MarketOutcome", b =>
{
b.HasOne("Predictalytics.Domain.Entities.Market", "Market")
.WithMany("Outcomes")
.HasForeignKey("MarketId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Market");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.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
}
}
}
@@ -0,0 +1,49 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Predictalytics.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddTradePriceContext : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "IsContextEnriched",
table: "Trades",
type: "tinyint(1)",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<decimal>(
name: "PostTradePrice1m",
table: "Trades",
type: "decimal(18,4)",
nullable: true);
migrationBuilder.AddColumn<decimal>(
name: "PreTradePrice1m",
table: "Trades",
type: "decimal(18,4)",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "IsContextEnriched",
table: "Trades");
migrationBuilder.DropColumn(
name: "PostTradePrice1m",
table: "Trades");
migrationBuilder.DropColumn(
name: "PreTradePrice1m",
table: "Trades");
}
}
}
@@ -0,0 +1,844 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Predictalytics.Infrastructure.Data;
#nullable disable
namespace Predictalytics.Infrastructure.Migrations
{
[DbContext(typeof(AppDbContext))]
[Migration("20260703114907_AddEventsAndTags")]
partial class AddEventsAndTags
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "8.0.11")
.HasAnnotation("Relational:MaxIdentifierLength", 64);
MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder);
modelBuilder.Entity("Predictalytics.Domain.Entities.Alert", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<bool>("IsRead")
.HasColumnType("tinyint(1)");
b.Property<string>("Message")
.IsRequired()
.HasMaxLength(4096)
.HasColumnType("varchar(4096)");
b.Property<int>("Platform")
.HasColumnType("int");
b.Property<int>("Severity")
.HasColumnType("int");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("varchar(512)");
b.Property<int?>("TraderId")
.HasColumnType("int");
b.Property<int>("Type")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("CreatedAt");
b.HasIndex("TraderId");
b.ToTable("Alerts");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.Event", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<DateTime>("DbCreatedAt")
.HasColumnType("datetime(6)");
b.Property<string>("Description")
.HasMaxLength(4096)
.HasColumnType("varchar(4096)");
b.Property<DateTime?>("EndDate")
.HasColumnType("datetime(6)");
b.Property<string>("ImageUrl")
.HasMaxLength(1024)
.HasColumnType("varchar(1024)");
b.Property<bool>("IsActive")
.HasColumnType("tinyint(1)");
b.Property<bool>("IsClosed")
.HasColumnType("tinyint(1)");
b.Property<DateTime?>("LastUpdatedAt")
.HasColumnType("datetime(6)");
b.Property<int>("Platform")
.HasColumnType("int");
b.Property<long>("PlatformEventId")
.HasColumnType("bigint");
b.Property<string>("Slug")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("varchar(512)");
b.Property<DateTime?>("StartDate")
.HasColumnType("datetime(6)");
b.Property<string>("Tags")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("varchar(1024)");
b.Property<string>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
b.Property<string>("Category")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("varchar(128)");
b.Property<string>("ConditionId")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("varchar(256)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<DateTime>("DbCreatedAt")
.HasColumnType("datetime(6)");
b.Property<string>("Description")
.HasMaxLength(4096)
.HasColumnType("varchar(4096)");
b.Property<DateTime?>("EndDate")
.HasColumnType("datetime(6)");
b.Property<int>("EventId")
.HasColumnType("int");
b.Property<string>("ImageUrl")
.HasMaxLength(1024)
.HasColumnType("varchar(1024)");
b.Property<bool>("IsResolved")
.HasColumnType("tinyint(1)");
b.Property<DateTime?>("LastTradesUpdatedAt")
.HasColumnType("datetime(6)");
b.Property<DateTime?>("LastUpdatedAt")
.HasColumnType("datetime(6)");
b.Property<decimal>("Liquidity")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<string>("MarketSlug")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("varchar(512)");
b.Property<int>("Platform")
.HasColumnType("int");
b.Property<long>("PlatformMarketId")
.HasColumnType("bigint");
b.Property<string>("Question")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("varchar(1024)");
b.Property<string>("QuestionId")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("varchar(256)");
b.Property<string>("ResolutionOutcome")
.HasColumnType("longtext");
b.Property<DateTime?>("StartDate")
.HasColumnType("datetime(6)");
b.Property<decimal>("Volume")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<decimal>("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<int>("MarketId")
.HasColumnType("int");
b.Property<decimal>("AverageTradeSize")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<decimal>("BotActivityScore")
.HasPrecision(8, 4)
.HasColumnType("decimal(8,4)");
b.Property<DateTime>("LastCalculatedAt")
.HasColumnType("datetime(6)");
b.Property<int>("UniqueTradersCount")
.HasColumnType("int");
b.HasKey("MarketId");
b.ToTable("MarketAnalytics");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.MarketOutcome", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
b.Property<decimal>("CurrentPrice")
.HasPrecision(18, 8)
.HasColumnType("decimal(18,8)");
b.Property<string>("Label")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("varchar(256)");
b.Property<int>("MarketId")
.HasColumnType("int");
b.Property<int>("OutcomeIndex")
.HasColumnType("int");
b.Property<string>("TokenId")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("varchar(256)");
b.HasKey("Id");
b.HasIndex("TokenId");
b.HasIndex("MarketId", "OutcomeIndex")
.IsUnique();
b.ToTable("MarketOutcomes");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.MarketOutcomePriceSnapshot", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
b.Property<int>("MarketOutcomeId")
.HasColumnType("int");
b.Property<decimal>("Price")
.HasPrecision(10, 6)
.HasColumnType("decimal(10,6)");
b.Property<DateTime>("Timestamp")
.HasColumnType("datetime(6)");
b.HasKey("Id");
b.HasIndex("MarketOutcomeId", "Timestamp");
b.ToTable("MarketOutcomePriceSnapshots");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.PlatformConfig", b =>
{
b.Property<int>("Id")
.HasColumnType("int");
b.Property<string>("BaseUrl")
.HasMaxLength(1024)
.HasColumnType("varchar(1024)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("varchar(256)");
b.Property<bool>("IsActive")
.HasColumnType("tinyint(1)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("varchar(128)");
b.Property<string>("SettingsJson")
.HasColumnType("longtext");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime(6)");
b.HasKey("Id");
b.ToTable("PlatformConfigs");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.Trade", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<long>("Id"));
b.Property<decimal>("Amount")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<string>("AssetId")
.IsRequired()
.HasMaxLength(80)
.HasColumnType("varchar(80)");
b.Property<int?>("DbMarketId")
.HasColumnType("int");
b.Property<DateTime>("ExecutedAt")
.HasColumnType("datetime(6)");
b.Property<bool>("IsContextEnriched")
.HasColumnType("tinyint(1)");
b.Property<string>("MarketId")
.IsRequired()
.HasMaxLength(66)
.HasColumnType("varchar(66)");
b.Property<int?>("MarketOutcomeId")
.HasColumnType("int");
b.Property<string>("Outcome")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("varchar(128)");
b.Property<int>("Platform")
.HasColumnType("int");
b.Property<string>("PlatformTradeId")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("varchar(256)");
b.Property<decimal?>("PostTradePrice1m")
.HasColumnType("decimal(18,4)");
b.Property<decimal?>("PreTradePrice1m")
.HasColumnType("decimal(18,4)");
b.Property<decimal>("Price")
.HasPrecision(10, 6)
.HasColumnType("decimal(10,6)");
b.Property<int>("Side")
.HasColumnType("int");
b.Property<decimal>("Size")
.HasPrecision(14, 6)
.HasColumnType("decimal(14,6)");
b.Property<int>("TraderId")
.HasColumnType("int");
b.Property<string>("TransactionHash")
.HasMaxLength(66)
.HasColumnType("varchar(66)");
b.HasKey("Id");
b.HasIndex("AssetId");
b.HasIndex("DbMarketId");
b.HasIndex("ExecutedAt");
b.HasIndex("MarketOutcomeId");
b.HasIndex("TraderId");
b.HasIndex("Platform", "PlatformTradeId")
.IsUnique();
b.ToTable("Trades");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.Trader", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("varchar(256)");
b.Property<bool>("IsAutoDiscovered")
.HasColumnType("tinyint(1)");
b.Property<bool>("IsInitialImportComplete")
.HasColumnType("tinyint(1)");
b.Property<bool>("IsSuspectedBot")
.HasColumnType("tinyint(1)");
b.Property<DateTime?>("LastApiErrorAt")
.HasColumnType("datetime(6)");
b.Property<DateTime?>("LastPolledAt")
.HasColumnType("datetime(6)");
b.Property<DateTime?>("LastTradesUpdatedAt")
.HasColumnType("datetime(6)");
b.Property<int?>("ManualPriorityOverride")
.HasColumnType("int");
b.Property<string>("Notes")
.HasColumnType("longtext");
b.Property<int>("Platform")
.HasColumnType("int");
b.Property<string>("PlatformUserId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("varchar(128)");
b.Property<int>("Strategy")
.HasColumnType("int");
b.Property<int>("Tier")
.HasColumnType("int");
b.Property<decimal>("TotalPnl")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<int>("TotalTrades")
.HasColumnType("int");
b.Property<decimal>("WinRate")
.HasPrecision(8, 4)
.HasColumnType("decimal(8,4)");
b.HasKey("Id");
b.HasIndex("Platform", "PlatformUserId")
.IsUnique();
b.ToTable("Traders");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.TraderAnalytics", b =>
{
b.Property<int>("TraderId")
.HasColumnType("int");
b.Property<DateTime>("LastCalculatedAt")
.HasColumnType("datetime(6)");
b.Property<decimal>("OverallPnL")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<decimal>("OverallWinRate")
.HasPrecision(8, 4)
.HasColumnType("decimal(8,4)");
b.Property<decimal>("PnL24h")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<decimal>("PnL30d")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<decimal>("PnL7d")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<decimal>("WinRate24h")
.HasPrecision(8, 4)
.HasColumnType("decimal(8,4)");
b.Property<decimal>("WinRate30d")
.HasPrecision(8, 4)
.HasColumnType("decimal(8,4)");
b.Property<decimal>("WinRate7d")
.HasPrecision(8, 4)
.HasColumnType("decimal(8,4)");
b.HasKey("TraderId");
b.ToTable("TraderAnalytics");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.TraderPosition", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
b.Property<decimal>("AvgCost")
.HasPrecision(10, 6)
.HasColumnType("decimal(10,6)");
b.Property<DateTime>("LastUpdatedAt")
.HasColumnType("datetime(6)");
b.Property<int>("MarketOutcomeId")
.HasColumnType("int");
b.Property<decimal>("RealizedPnl")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<decimal>("SharesHeld")
.HasPrecision(14, 6)
.HasColumnType("decimal(14,6)");
b.Property<int>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
b.Property<decimal>("ActivityScore")
.HasPrecision(8, 4)
.HasColumnType("decimal(8,4)");
b.Property<DateTime>("CalculatedAt")
.HasColumnType("datetime(6)");
b.Property<decimal>("CombinedScore")
.HasPrecision(8, 4)
.HasColumnType("decimal(8,4)");
b.Property<decimal>("CopytradingScore")
.HasPrecision(8, 4)
.HasColumnType("decimal(8,4)");
b.Property<decimal>("QualityScore")
.HasPrecision(8, 4)
.HasColumnType("decimal(8,4)");
b.Property<int>("Rank")
.HasColumnType("int");
b.Property<decimal>("TimingScore")
.HasPrecision(8, 4)
.HasColumnType("decimal(8,4)");
b.Property<int>("TraderId")
.HasColumnType("int");
b.Property<decimal>("VolumeScore")
.HasPrecision(8, 4)
.HasColumnType("decimal(8,4)");
b.HasKey("Id");
b.HasIndex("TraderId")
.IsUnique();
b.ToTable("TraderScores");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.WatchlistEntry", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
b.Property<DateTime>("AddedAt")
.HasColumnType("datetime(6)");
b.Property<bool>("AlertsEnabled")
.HasColumnType("tinyint(1)");
b.Property<string>("Label")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("varchar(256)");
b.Property<string>("Notes")
.HasColumnType("longtext");
b.Property<int>("TraderId")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("TraderId")
.IsUnique();
b.ToTable("WatchlistEntries");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.Alert", b =>
{
b.HasOne("Predictalytics.Domain.Entities.Trader", "Trader")
.WithMany()
.HasForeignKey("TraderId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("Trader");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.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
}
}
}
@@ -0,0 +1,167 @@
using System;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Predictalytics.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddEventsAndTags : Migration
{
/// <inheritdoc />
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<long>(
name: "PlatformMarketId",
table: "Markets",
type: "bigint",
nullable: false,
oldClrType: typeof(string),
oldType: "varchar(256)",
oldMaxLength: 256)
.OldAnnotation("MySql:CharSet", "utf8mb4");
migrationBuilder.AddColumn<string>(
name: "ConditionId",
table: "Markets",
type: "varchar(256)",
maxLength: 256,
nullable: false,
defaultValue: "")
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.AddColumn<int>(
name: "EventId",
table: "Markets",
type: "int",
nullable: false,
defaultValue: 0);
migrationBuilder.AddColumn<string>(
name: "QuestionId",
table: "Markets",
type: "varchar(256)",
maxLength: 256,
nullable: false,
defaultValue: "")
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.AddColumn<decimal>(
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<int>(type: "int", nullable: false)
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
Platform = table.Column<int>(type: "int", nullable: false),
PlatformEventId = table.Column<long>(type: "bigint", nullable: false),
Slug = table.Column<string>(type: "varchar(512)", maxLength: 512, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
Title = table.Column<string>(type: "varchar(1024)", maxLength: 1024, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
Description = table.Column<string>(type: "varchar(4096)", maxLength: 4096, nullable: true)
.Annotation("MySql:CharSet", "utf8mb4"),
ImageUrl = table.Column<string>(type: "varchar(1024)", maxLength: 1024, nullable: true)
.Annotation("MySql:CharSet", "utf8mb4"),
StartDate = table.Column<DateTime>(type: "datetime(6)", nullable: true),
EndDate = table.Column<DateTime>(type: "datetime(6)", nullable: true),
Tags = table.Column<string>(type: "varchar(1024)", maxLength: 1024, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
IsActive = table.Column<bool>(type: "tinyint(1)", nullable: false),
IsClosed = table.Column<bool>(type: "tinyint(1)", nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
DbCreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
LastUpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true)
},
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);
}
/// <inheritdoc />
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<string>(
name: "PlatformMarketId",
table: "Markets",
type: "varchar(256)",
maxLength: 256,
nullable: false,
oldClrType: typeof(long),
oldType: "bigint")
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.AddColumn<string>(
name: "EventSlug",
table: "Markets",
type: "varchar(512)",
maxLength: 512,
nullable: false,
defaultValue: "")
.Annotation("MySql:CharSet", "utf8mb4");
}
}
}
@@ -0,0 +1,850 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Predictalytics.Infrastructure.Data;
#nullable disable
namespace Predictalytics.Infrastructure.Migrations
{
[DbContext(typeof(AppDbContext))]
[Migration("20260704120722_AddAiStrategyFields")]
partial class AddAiStrategyFields
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "8.0.11")
.HasAnnotation("Relational:MaxIdentifierLength", 64);
MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder);
modelBuilder.Entity("Predictalytics.Domain.Entities.Alert", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<bool>("IsRead")
.HasColumnType("tinyint(1)");
b.Property<string>("Message")
.IsRequired()
.HasMaxLength(4096)
.HasColumnType("varchar(4096)");
b.Property<int>("Platform")
.HasColumnType("int");
b.Property<int>("Severity")
.HasColumnType("int");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("varchar(512)");
b.Property<int?>("TraderId")
.HasColumnType("int");
b.Property<int>("Type")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("CreatedAt");
b.HasIndex("TraderId");
b.ToTable("Alerts");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.Event", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<DateTime>("DbCreatedAt")
.HasColumnType("datetime(6)");
b.Property<string>("Description")
.HasMaxLength(4096)
.HasColumnType("varchar(4096)");
b.Property<DateTime?>("EndDate")
.HasColumnType("datetime(6)");
b.Property<string>("ImageUrl")
.HasMaxLength(1024)
.HasColumnType("varchar(1024)");
b.Property<bool>("IsActive")
.HasColumnType("tinyint(1)");
b.Property<bool>("IsClosed")
.HasColumnType("tinyint(1)");
b.Property<DateTime?>("LastUpdatedAt")
.HasColumnType("datetime(6)");
b.Property<int>("Platform")
.HasColumnType("int");
b.Property<long>("PlatformEventId")
.HasColumnType("bigint");
b.Property<string>("Slug")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("varchar(512)");
b.Property<DateTime?>("StartDate")
.HasColumnType("datetime(6)");
b.Property<string>("Tags")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("varchar(1024)");
b.Property<string>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
b.Property<string>("Category")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("varchar(128)");
b.Property<string>("ConditionId")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("varchar(256)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<DateTime>("DbCreatedAt")
.HasColumnType("datetime(6)");
b.Property<string>("Description")
.HasMaxLength(4096)
.HasColumnType("varchar(4096)");
b.Property<DateTime?>("EndDate")
.HasColumnType("datetime(6)");
b.Property<int>("EventId")
.HasColumnType("int");
b.Property<string>("ImageUrl")
.HasMaxLength(1024)
.HasColumnType("varchar(1024)");
b.Property<bool>("IsResolved")
.HasColumnType("tinyint(1)");
b.Property<DateTime?>("LastTradesUpdatedAt")
.HasColumnType("datetime(6)");
b.Property<DateTime?>("LastUpdatedAt")
.HasColumnType("datetime(6)");
b.Property<decimal>("Liquidity")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<string>("MarketSlug")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("varchar(512)");
b.Property<int>("Platform")
.HasColumnType("int");
b.Property<long>("PlatformMarketId")
.HasColumnType("bigint");
b.Property<string>("Question")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("varchar(1024)");
b.Property<string>("QuestionId")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("varchar(256)");
b.Property<string>("ResolutionOutcome")
.HasColumnType("longtext");
b.Property<DateTime?>("StartDate")
.HasColumnType("datetime(6)");
b.Property<decimal>("Volume")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<decimal>("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<int>("MarketId")
.HasColumnType("int");
b.Property<decimal>("AverageTradeSize")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<decimal>("BotActivityScore")
.HasPrecision(8, 4)
.HasColumnType("decimal(8,4)");
b.Property<DateTime>("LastCalculatedAt")
.HasColumnType("datetime(6)");
b.Property<int>("UniqueTradersCount")
.HasColumnType("int");
b.HasKey("MarketId");
b.ToTable("MarketAnalytics");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.MarketOutcome", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
b.Property<decimal>("CurrentPrice")
.HasPrecision(18, 8)
.HasColumnType("decimal(18,8)");
b.Property<string>("Label")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("varchar(256)");
b.Property<int>("MarketId")
.HasColumnType("int");
b.Property<int>("OutcomeIndex")
.HasColumnType("int");
b.Property<string>("TokenId")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("varchar(256)");
b.HasKey("Id");
b.HasIndex("TokenId");
b.HasIndex("MarketId", "OutcomeIndex")
.IsUnique();
b.ToTable("MarketOutcomes");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.MarketOutcomePriceSnapshot", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
b.Property<int>("MarketOutcomeId")
.HasColumnType("int");
b.Property<decimal>("Price")
.HasPrecision(10, 6)
.HasColumnType("decimal(10,6)");
b.Property<DateTime>("Timestamp")
.HasColumnType("datetime(6)");
b.HasKey("Id");
b.HasIndex("MarketOutcomeId", "Timestamp");
b.ToTable("MarketOutcomePriceSnapshots");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.PlatformConfig", b =>
{
b.Property<int>("Id")
.HasColumnType("int");
b.Property<string>("BaseUrl")
.HasMaxLength(1024)
.HasColumnType("varchar(1024)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("varchar(256)");
b.Property<bool>("IsActive")
.HasColumnType("tinyint(1)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("varchar(128)");
b.Property<string>("SettingsJson")
.HasColumnType("longtext");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime(6)");
b.HasKey("Id");
b.ToTable("PlatformConfigs");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.Trade", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<long>("Id"));
b.Property<decimal>("Amount")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<string>("AssetId")
.IsRequired()
.HasMaxLength(80)
.HasColumnType("varchar(80)");
b.Property<int?>("DbMarketId")
.HasColumnType("int");
b.Property<DateTime>("ExecutedAt")
.HasColumnType("datetime(6)");
b.Property<bool>("IsContextEnriched")
.HasColumnType("tinyint(1)");
b.Property<string>("MarketId")
.IsRequired()
.HasMaxLength(66)
.HasColumnType("varchar(66)");
b.Property<int?>("MarketOutcomeId")
.HasColumnType("int");
b.Property<string>("Outcome")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("varchar(128)");
b.Property<int>("Platform")
.HasColumnType("int");
b.Property<string>("PlatformTradeId")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("varchar(256)");
b.Property<decimal?>("PostTradePrice1m")
.HasColumnType("decimal(18,4)");
b.Property<decimal?>("PreTradePrice1m")
.HasColumnType("decimal(18,4)");
b.Property<decimal>("Price")
.HasPrecision(10, 6)
.HasColumnType("decimal(10,6)");
b.Property<int>("Side")
.HasColumnType("int");
b.Property<decimal>("Size")
.HasPrecision(14, 6)
.HasColumnType("decimal(14,6)");
b.Property<int>("TraderId")
.HasColumnType("int");
b.Property<string>("TransactionHash")
.HasMaxLength(66)
.HasColumnType("varchar(66)");
b.HasKey("Id");
b.HasIndex("AssetId");
b.HasIndex("DbMarketId");
b.HasIndex("ExecutedAt");
b.HasIndex("MarketOutcomeId");
b.HasIndex("TraderId");
b.HasIndex("Platform", "PlatformTradeId")
.IsUnique();
b.ToTable("Trades");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.Trader", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
b.Property<string>("AiStrategySummary")
.HasColumnType("longtext");
b.Property<DateTime?>("AiStrategyUpdatedAt")
.HasColumnType("datetime(6)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("varchar(256)");
b.Property<bool>("IsAutoDiscovered")
.HasColumnType("tinyint(1)");
b.Property<bool>("IsInitialImportComplete")
.HasColumnType("tinyint(1)");
b.Property<bool>("IsSuspectedBot")
.HasColumnType("tinyint(1)");
b.Property<DateTime?>("LastApiErrorAt")
.HasColumnType("datetime(6)");
b.Property<DateTime?>("LastPolledAt")
.HasColumnType("datetime(6)");
b.Property<DateTime?>("LastTradesUpdatedAt")
.HasColumnType("datetime(6)");
b.Property<int?>("ManualPriorityOverride")
.HasColumnType("int");
b.Property<string>("Notes")
.HasColumnType("longtext");
b.Property<int>("Platform")
.HasColumnType("int");
b.Property<string>("PlatformUserId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("varchar(128)");
b.Property<int>("Strategy")
.HasColumnType("int");
b.Property<int>("Tier")
.HasColumnType("int");
b.Property<decimal>("TotalPnl")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<int>("TotalTrades")
.HasColumnType("int");
b.Property<decimal>("WinRate")
.HasPrecision(8, 4)
.HasColumnType("decimal(8,4)");
b.HasKey("Id");
b.HasIndex("Platform", "PlatformUserId")
.IsUnique();
b.ToTable("Traders");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.TraderAnalytics", b =>
{
b.Property<int>("TraderId")
.HasColumnType("int");
b.Property<DateTime>("LastCalculatedAt")
.HasColumnType("datetime(6)");
b.Property<decimal>("OverallPnL")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<decimal>("OverallWinRate")
.HasPrecision(8, 4)
.HasColumnType("decimal(8,4)");
b.Property<decimal>("PnL24h")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<decimal>("PnL30d")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<decimal>("PnL7d")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<decimal>("WinRate24h")
.HasPrecision(8, 4)
.HasColumnType("decimal(8,4)");
b.Property<decimal>("WinRate30d")
.HasPrecision(8, 4)
.HasColumnType("decimal(8,4)");
b.Property<decimal>("WinRate7d")
.HasPrecision(8, 4)
.HasColumnType("decimal(8,4)");
b.HasKey("TraderId");
b.ToTable("TraderAnalytics");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.TraderPosition", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
b.Property<decimal>("AvgCost")
.HasPrecision(10, 6)
.HasColumnType("decimal(10,6)");
b.Property<DateTime>("LastUpdatedAt")
.HasColumnType("datetime(6)");
b.Property<int>("MarketOutcomeId")
.HasColumnType("int");
b.Property<decimal>("RealizedPnl")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<decimal>("SharesHeld")
.HasPrecision(14, 6)
.HasColumnType("decimal(14,6)");
b.Property<int>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
b.Property<decimal>("ActivityScore")
.HasPrecision(8, 4)
.HasColumnType("decimal(8,4)");
b.Property<DateTime>("CalculatedAt")
.HasColumnType("datetime(6)");
b.Property<decimal>("CombinedScore")
.HasPrecision(8, 4)
.HasColumnType("decimal(8,4)");
b.Property<decimal>("CopytradingScore")
.HasPrecision(8, 4)
.HasColumnType("decimal(8,4)");
b.Property<decimal>("QualityScore")
.HasPrecision(8, 4)
.HasColumnType("decimal(8,4)");
b.Property<int>("Rank")
.HasColumnType("int");
b.Property<decimal>("TimingScore")
.HasPrecision(8, 4)
.HasColumnType("decimal(8,4)");
b.Property<int>("TraderId")
.HasColumnType("int");
b.Property<decimal>("VolumeScore")
.HasPrecision(8, 4)
.HasColumnType("decimal(8,4)");
b.HasKey("Id");
b.HasIndex("TraderId")
.IsUnique();
b.ToTable("TraderScores");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.WatchlistEntry", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
b.Property<DateTime>("AddedAt")
.HasColumnType("datetime(6)");
b.Property<bool>("AlertsEnabled")
.HasColumnType("tinyint(1)");
b.Property<string>("Label")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("varchar(256)");
b.Property<string>("Notes")
.HasColumnType("longtext");
b.Property<int>("TraderId")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("TraderId")
.IsUnique();
b.ToTable("WatchlistEntries");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.Alert", b =>
{
b.HasOne("Predictalytics.Domain.Entities.Trader", "Trader")
.WithMany()
.HasForeignKey("TraderId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("Trader");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.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
}
}
}
@@ -0,0 +1,40 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Predictalytics.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddAiStrategyFields : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "AiStrategySummary",
table: "Traders",
type: "longtext",
nullable: true)
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.AddColumn<DateTime>(
name: "AiStrategyUpdatedAt",
table: "Traders",
type: "datetime(6)",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "AiStrategySummary",
table: "Traders");
migrationBuilder.DropColumn(
name: "AiStrategyUpdatedAt",
table: "Traders");
}
}
}
@@ -67,7 +67,7 @@ namespace Predictalytics.Infrastructure.Migrations
b.ToTable("Alerts"); b.ToTable("Alerts");
}); });
modelBuilder.Entity("Predictalytics.Domain.Entities.Market", b => modelBuilder.Entity("Predictalytics.Domain.Entities.Event", b =>
{ {
b.Property<int>("Id") b.Property<int>("Id")
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
@@ -75,11 +75,6 @@ namespace Predictalytics.Infrastructure.Migrations
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id")); MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
b.Property<string>("Category")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("varchar(128)");
b.Property<DateTime>("CreatedAt") b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)"); .HasColumnType("datetime(6)");
@@ -93,11 +88,85 @@ namespace Predictalytics.Infrastructure.Migrations
b.Property<DateTime?>("EndDate") b.Property<DateTime?>("EndDate")
.HasColumnType("datetime(6)"); .HasColumnType("datetime(6)");
b.Property<string>("EventSlug") b.Property<string>("ImageUrl")
.HasMaxLength(1024)
.HasColumnType("varchar(1024)");
b.Property<bool>("IsActive")
.HasColumnType("tinyint(1)");
b.Property<bool>("IsClosed")
.HasColumnType("tinyint(1)");
b.Property<DateTime?>("LastUpdatedAt")
.HasColumnType("datetime(6)");
b.Property<int>("Platform")
.HasColumnType("int");
b.Property<long>("PlatformEventId")
.HasColumnType("bigint");
b.Property<string>("Slug")
.IsRequired() .IsRequired()
.HasMaxLength(512) .HasMaxLength(512)
.HasColumnType("varchar(512)"); .HasColumnType("varchar(512)");
b.Property<DateTime?>("StartDate")
.HasColumnType("datetime(6)");
b.Property<string>("Tags")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("varchar(1024)");
b.Property<string>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
b.Property<string>("Category")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("varchar(128)");
b.Property<string>("ConditionId")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("varchar(256)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<DateTime>("DbCreatedAt")
.HasColumnType("datetime(6)");
b.Property<string>("Description")
.HasMaxLength(4096)
.HasColumnType("varchar(4096)");
b.Property<DateTime?>("EndDate")
.HasColumnType("datetime(6)");
b.Property<int>("EventId")
.HasColumnType("int");
b.Property<string>("ImageUrl") b.Property<string>("ImageUrl")
.HasMaxLength(1024) .HasMaxLength(1024)
.HasColumnType("varchar(1024)"); .HasColumnType("varchar(1024)");
@@ -123,16 +192,19 @@ namespace Predictalytics.Infrastructure.Migrations
b.Property<int>("Platform") b.Property<int>("Platform")
.HasColumnType("int"); .HasColumnType("int");
b.Property<string>("PlatformMarketId") b.Property<long>("PlatformMarketId")
.IsRequired() .HasColumnType("bigint");
.HasMaxLength(256)
.HasColumnType("varchar(256)");
b.Property<string>("Question") b.Property<string>("Question")
.IsRequired() .IsRequired()
.HasMaxLength(1024) .HasMaxLength(1024)
.HasColumnType("varchar(1024)"); .HasColumnType("varchar(1024)");
b.Property<string>("QuestionId")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("varchar(256)");
b.Property<string>("ResolutionOutcome") b.Property<string>("ResolutionOutcome")
.HasColumnType("longtext"); .HasColumnType("longtext");
@@ -143,8 +215,14 @@ namespace Predictalytics.Infrastructure.Migrations
.HasPrecision(18, 4) .HasPrecision(18, 4)
.HasColumnType("decimal(18,4)"); .HasColumnType("decimal(18,4)");
b.Property<decimal>("Volume24h")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("EventId");
b.HasIndex("Platform", "PlatformMarketId") b.HasIndex("Platform", "PlatformMarketId")
.IsUnique(); .IsUnique();
@@ -297,6 +375,9 @@ namespace Predictalytics.Infrastructure.Migrations
b.Property<DateTime>("ExecutedAt") b.Property<DateTime>("ExecutedAt")
.HasColumnType("datetime(6)"); .HasColumnType("datetime(6)");
b.Property<bool>("IsContextEnriched")
.HasColumnType("tinyint(1)");
b.Property<string>("MarketId") b.Property<string>("MarketId")
.IsRequired() .IsRequired()
.HasMaxLength(66) .HasMaxLength(66)
@@ -318,6 +399,12 @@ namespace Predictalytics.Infrastructure.Migrations
.HasMaxLength(256) .HasMaxLength(256)
.HasColumnType("varchar(256)"); .HasColumnType("varchar(256)");
b.Property<decimal?>("PostTradePrice1m")
.HasColumnType("decimal(18,4)");
b.Property<decimal?>("PreTradePrice1m")
.HasColumnType("decimal(18,4)");
b.Property<decimal>("Price") b.Property<decimal>("Price")
.HasPrecision(10, 6) .HasPrecision(10, 6)
.HasColumnType("decimal(10,6)"); .HasColumnType("decimal(10,6)");
@@ -362,6 +449,12 @@ namespace Predictalytics.Infrastructure.Migrations
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id")); MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
b.Property<string>("AiStrategySummary")
.HasColumnType("longtext");
b.Property<DateTime?>("AiStrategyUpdatedAt")
.HasColumnType("datetime(6)");
b.Property<DateTime>("CreatedAt") b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)"); .HasColumnType("datetime(6)");
@@ -603,6 +696,17 @@ namespace Predictalytics.Infrastructure.Migrations
b.Navigation("Trader"); 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 => modelBuilder.Entity("Predictalytics.Domain.Entities.MarketAnalytics", b =>
{ {
b.HasOne("Predictalytics.Domain.Entities.Market", "Market") b.HasOne("Predictalytics.Domain.Entities.Market", "Market")
@@ -713,6 +817,11 @@ namespace Predictalytics.Infrastructure.Migrations
b.Navigation("Trader"); b.Navigation("Trader");
}); });
modelBuilder.Entity("Predictalytics.Domain.Entities.Event", b =>
{
b.Navigation("Markets");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.Market", b => modelBuilder.Entity("Predictalytics.Domain.Entities.Market", b =>
{ {
b.Navigation("Analytics"); b.Navigation("Analytics");
@@ -31,8 +31,8 @@ public class AzuroProvider : IPlatformProvider
public Task<Market?> GetMarketAsync(string platformMarketId, CancellationToken ct = default) public Task<Market?> GetMarketAsync(string platformMarketId, CancellationToken ct = default)
{ using var _ = PlatformLogContext.Push(PlatformName); _logger.LogWarning("Provider not yet implemented"); return Task.FromResult<Market?>(null); } { using var _ = PlatformLogContext.Push(PlatformName); _logger.LogWarning("Provider not yet implemented"); return Task.FromResult<Market?>(null); }
public Task<IReadOnlyList<Market>> GetMarketsAsync(int limit = 100, string? cursor = null, bool includeClosed = false, CancellationToken ct = default) public Task<IReadOnlyList<Event>> GetEventsAsync(int limit = 100, string? cursor = null, bool includeClosed = false, CancellationToken ct = default)
{ using var _ = PlatformLogContext.Push(PlatformName); return Task.FromResult<IReadOnlyList<Market>>(Array.Empty<Market>()); } { using var _ = PlatformLogContext.Push(PlatformName); return Task.FromResult<IReadOnlyList<Event>>(Array.Empty<Event>()); }
public Task<IReadOnlyList<DiscoveredTrader>> GetTopHoldersAsync(string platformMarketId, int limit = 20, CancellationToken ct = default) public Task<IReadOnlyList<DiscoveredTrader>> GetTopHoldersAsync(string platformMarketId, int limit = 20, CancellationToken ct = default)
{ using var _ = PlatformLogContext.Push(PlatformName); return Task.FromResult<IReadOnlyList<DiscoveredTrader>>(Array.Empty<DiscoveredTrader>()); } { using var _ = PlatformLogContext.Push(PlatformName); return Task.FromResult<IReadOnlyList<DiscoveredTrader>>(Array.Empty<DiscoveredTrader>()); }
@@ -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 var url = $"markets/active?limit={Math.Min(limit, 25)}"; // Offset is not supported by this endpoint, limit max 25
try try
{ {
var response = await _client.GetAsync(url, ct); var response = await _client.GetAsync(url, ct);
if (!response.IsSuccessStatusCode) if (!response.IsSuccessStatusCode)
{ {
@@ -45,6 +46,7 @@ public class LimitlessApiClient
var url = $"markets/{addressOrSlug}"; var url = $"markets/{addressOrSlug}";
try try
{ {
var response = await _client.GetAsync(url, ct); var response = await _client.GetAsync(url, ct);
if (!response.IsSuccessStatusCode) return null; if (!response.IsSuccessStatusCode) return null;
return await response.Content.ReadFromJsonAsync<LimitlessMarketResponse>(cancellationToken: ct); return await response.Content.ReadFromJsonAsync<LimitlessMarketResponse>(cancellationToken: ct);
@@ -61,6 +63,7 @@ public class LimitlessApiClient
var url = $"portfolio/{walletAddress}/positions"; var url = $"portfolio/{walletAddress}/positions";
try try
{ {
var response = await _client.GetAsync(url, ct); var response = await _client.GetAsync(url, ct);
if (!response.IsSuccessStatusCode) if (!response.IsSuccessStatusCode)
{ {
@@ -82,6 +85,7 @@ public class LimitlessApiClient
var url = $"markets/{slug}/events?limit={limit}"; var url = $"markets/{slug}/events?limit={limit}";
try try
{ {
var response = await _client.GetAsync(url, ct); var response = await _client.GetAsync(url, ct);
if (!response.IsSuccessStatusCode) return []; if (!response.IsSuccessStatusCode) return [];
var result = await response.Content.ReadFromJsonAsync<LimitlessEventsResponse>(cancellationToken: ct); var result = await response.Content.ReadFromJsonAsync<LimitlessEventsResponse>(cancellationToken: ct);
@@ -164,17 +164,33 @@ public class LimitlessProvider : IPlatformProvider
return MapLimitlessMarket(raw); return MapLimitlessMarket(raw);
} }
public async Task<IReadOnlyList<Market>> GetMarketsAsync(int limit = 100, string? cursor = null, bool includeClosed = false, CancellationToken ct = default) public async Task<IReadOnlyList<Event>> GetEventsAsync(int limit = 100, string? cursor = null, bool includeClosed = false, CancellationToken ct = default)
{ {
using var _ = PlatformLogContext.Push(PlatformName); using var _ = PlatformLogContext.Push(PlatformName);
int.TryParse(cursor, out var offset); var offset = 0;
if (!string.IsNullOrEmpty(cursor) && int.TryParse(cursor, out var parsed))
// Since Limitless /markets/active doesn't support offset, we only return the first page. offset = parsed;
// Returning data for offset > 0 would cause an infinite loop in MarketSyncWorker.
if (offset > 0) return [];
_logger.LogInformation("Fetching markets batch (limit={Limit}, offset={Offset})", limit, offset);
var raw = await _api.GetActiveMarketsAsync(limit, offset, ct); 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<Event>();
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<Market> { m }
});
}
return events;
} }
public async Task<IReadOnlyList<DiscoveredTrader>> GetTopHoldersAsync(string platformMarketId, int limit = 20, CancellationToken ct = default) public async Task<IReadOnlyList<DiscoveredTrader>> GetTopHoldersAsync(string platformMarketId, int limit = 20, CancellationToken ct = default)
@@ -229,12 +245,13 @@ public class LimitlessProvider : IPlatformProvider
private Market MapLimitlessMarket(LimitlessMarketResponse raw) private Market MapLimitlessMarket(LimitlessMarketResponse raw)
{ {
var conditionId = raw.Address ?? raw.Slug ?? Guid.NewGuid().ToString();
var market = new Market var market = new Market
{ {
Platform = PlatformType.Limitless, Platform = PlatformType.Limitless,
PlatformMarketId = raw.Address ?? raw.Slug ?? "", ConditionId = conditionId,
PlatformMarketId = GetStableHashCode(conditionId),
MarketSlug = raw.Slug ?? "", MarketSlug = raw.Slug ?? "",
EventSlug = "", // Limitless doesn't seem to have a clear Event/Market split in this model
Question = raw.Title ?? "", Question = raw.Title ?? "",
Description = raw.Description ?? "", Description = raw.Description ?? "",
Category = raw.Categories?.FirstOrDefault() ?? "", Category = raw.Categories?.FirstOrDefault() ?? "",
@@ -279,4 +296,15 @@ public class LimitlessProvider : IPlatformProvider
if (sideStr == "0" || sideStr == "BUY") return TradeSide.Buy; if (sideStr == "0" || sideStr == "BUY") return TradeSide.Buy;
return TradeSide.Sell; return TradeSide.Sell;
} }
private static long GetStableHashCode(string str)
{
unchecked
{
long hash = 23;
foreach (char c in str)
hash = hash * 31 + c;
return hash;
}
}
} }
@@ -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<OpenRouterApiClient> _logger;
public OpenRouterApiClient(HttpClient httpClient, IConfiguration config, ILogger<OpenRouterApiClient> 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<string> 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<OpenRouterResponse>(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; }
}
}
@@ -43,39 +43,39 @@ public class PolymarketApiClient
public async Task<List<PolymarketTradeResponse>> GetTradesAsync(string walletAddress, int limit = 1000, CancellationToken ct = default) public async Task<List<PolymarketTradeResponse>> GetTradesAsync(string walletAddress, int limit = 1000, CancellationToken ct = default)
{ {
var url = $"/activity?user={walletAddress}&limit={limit}"; var url = $"/activity?user={walletAddress}&limit={limit}";
return await ExecuteWithRetryAsync<List<PolymarketTradeResponse>>(_client, url, ct) ?? []; return await ExecuteWithRetryAsync<List<PolymarketTradeResponse>>(_client, url, "Data", ct) ?? [];
} }
public async Task<List<PolymarketTradeResponse>> GetMarketTradesAsync(string conditionId, int limit = 1000, CancellationToken ct = default) public async Task<List<PolymarketTradeResponse>> GetMarketTradesAsync(string conditionId, int limit = 1000, CancellationToken ct = default)
{ {
var url = $"/trades?condition_id={conditionId}&limit={limit}"; var url = $"/trades?condition_id={conditionId}&limit={limit}";
return await ExecuteWithRetryAsync<List<PolymarketTradeResponse>>(_client, url, ct) ?? []; return await ExecuteWithRetryAsync<List<PolymarketTradeResponse>>(_client, url, "Data", ct) ?? [];
} }
public async Task<List<PolymarketPositionResponse>> GetPositionsAsync(string walletAddress, CancellationToken ct = default) public async Task<List<PolymarketPositionResponse>> GetPositionsAsync(string walletAddress, CancellationToken ct = default)
{ {
var url = $"/positions?user={walletAddress}&sizeThreshold=0.1&sortBy=CURRENT&sortOrder=DESC"; var url = $"/positions?user={walletAddress}&sizeThreshold=0.1&sortBy=CURRENT&sortOrder=DESC";
return await ExecuteWithRetryAsync<List<PolymarketPositionResponse>>(_client, url, ct) ?? []; return await ExecuteWithRetryAsync<List<PolymarketPositionResponse>>(_client, url, "Data", ct) ?? [];
} }
public async Task<GammaMarketResponse?> GetMarketAsync(string conditionId, CancellationToken ct = default) public async Task<GammaMarketResponse?> GetMarketAsync(string conditionId, CancellationToken ct = default)
{ {
var url = $"/markets?condition_id={conditionId}"; var url = $"/markets?condition_id={conditionId}";
var results = await ExecuteWithRetryAsync<List<GammaMarketResponse>>(_gammaClient, url, ct); var results = await ExecuteWithRetryAsync<List<GammaMarketResponse>>(_gammaClient, url, "Gamma", ct);
return results?.FirstOrDefault(); return results?.FirstOrDefault();
} }
/// <summary> /// <summary>
/// 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. /// Supports offset-based pagination via the offset parameter.
/// </summary> /// </summary>
public async Task<List<GammaMarketResponse>> GetMarketsAsync(int limit = 1000, int offset = 0, bool includeClosed = false, CancellationToken ct = default) public async Task<List<GammaEventResponse>> GetEventsAsync(int limit = 100, int offset = 0, bool includeClosed = false, CancellationToken ct = default)
{ {
var activeOnly = !includeClosed; var activeOnly = !includeClosed;
var url = $"/markets?limit={limit}&offset={offset}&active={activeOnly.ToString().ToLower()}&closed={includeClosed.ToString().ToLower()}"; var url = $"/events?limit={limit}&offset={offset}&active={activeOnly.ToString().ToLower()}&closed={includeClosed.ToString().ToLower()}";
_logger.LogDebug("Fetching markets: {Url}", url); _logger.LogDebug("Fetching events: {Url}", url);
var result = await ExecuteWithRetryAsync<List<GammaMarketResponse>>(_gammaClient, url, ct); var result = await ExecuteWithRetryAsync<List<GammaEventResponse>>(_gammaClient, url, "Gamma", ct);
_logger.LogInformation("Fetched {Count} markets (offset={Offset}, closed={Closed})", result?.Count ?? 0, offset, includeClosed); _logger.LogInformation("Fetched {Count} events (offset={Offset}, closed={Closed})", result?.Count ?? 0, offset, includeClosed);
return result ?? []; return result ?? [];
} }
@@ -87,7 +87,7 @@ public class PolymarketApiClient
{ {
var url = $"/holders?market={conditionId}&limit={limit}"; var url = $"/holders?market={conditionId}&limit={limit}";
_logger.LogDebug("Fetching holders: {Url}", url); _logger.LogDebug("Fetching holders: {Url}", url);
var result = await ExecuteWithRetryAsync<List<HoldersResponse>>(_client, url, ct); var result = await ExecuteWithRetryAsync<List<HoldersResponse>>(_client, url, "Data", ct);
_logger.LogInformation("Fetched holders for {Market}: {Count} token groups", _logger.LogInformation("Fetched holders for {Market}: {Count} token groups",
conditionId.Length > 12 ? conditionId[..12] + "..." : conditionId, result?.Count ?? 0); conditionId.Length > 12 ? conditionId[..12] + "..." : conditionId, result?.Count ?? 0);
return result ?? []; 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}"; var url = $"/v1/leaderboard?limit={Math.Min(limit, 50)}&time_period={timePeriod}&order_by={orderBy}&category={category}";
_logger.LogDebug("Fetching leaderboard: {Url}", url); _logger.LogDebug("Fetching leaderboard: {Url}", url);
var result = await ExecuteWithRetryAsync<List<LeaderboardEntry>>(_client, url, ct); var result = await ExecuteWithRetryAsync<List<LeaderboardEntry>>(_client, url, "Data", ct);
_logger.LogInformation("Leaderboard returned {Count} entries", result?.Count ?? 0); _logger.LogInformation("Leaderboard returned {Count} entries", result?.Count ?? 0);
return result ?? []; return result ?? [];
} }
private async Task<T?> ExecuteWithRetryAsync<T>(HttpClient client, string url, CancellationToken ct, int attempt = 1) private async Task<T?> ExecuteWithRetryAsync<T>(HttpClient client, string url, string endpointGroup, CancellationToken ct, int attempt = 1)
{ {
await _rateLimiter.WaitAsync(PlatformType.Polymarket, ct, endpointGroup);
try try
{ {
var response = await client.GetAsync(url, ct); var response = await client.GetAsync(url, ct);
@@ -135,15 +137,14 @@ public class PolymarketApiClient
waitTime = TimeSpan.FromSeconds(30); 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) if (attempt < 3)
{ {
await _rateLimiter.WaitAsync(PlatformType.Polymarket, ct);
_logger.LogWarning("Retrying {Url} (attempt {NextAttempt})...", url, attempt + 1); _logger.LogWarning("Retrying {Url} (attempt {NextAttempt})...", url, attempt + 1);
return await ExecuteWithRetryAsync<T>(client, url, ct, attempt + 1); return await ExecuteWithRetryAsync<T>(client, url, endpointGroup, ct, attempt + 1);
} }
return default; return default;
@@ -172,7 +173,7 @@ public class PolymarketApiClient
public async Task<List<PriceHistoryEntry>> GetPricesHistoryAsync(string clobTokenId, string interval = "6h", CancellationToken ct = default) public async Task<List<PriceHistoryEntry>> GetPricesHistoryAsync(string clobTokenId, string interval = "6h", CancellationToken ct = default)
{ {
var url = $"/prices-history?market={clobTokenId}&interval={interval}"; var url = $"/prices-history?market={clobTokenId}&interval={interval}";
var result = await ExecuteWithRetryAsync<PolymarketPriceHistoryResponse>(_clobClient, url, ct); var result = await ExecuteWithRetryAsync<PolymarketPriceHistoryResponse>(_clobClient, url, "Clob", ct);
return result?.History ?? []; return result?.History ?? [];
} }
} }
@@ -109,6 +109,7 @@ public class GammaMarketResponse
{ {
[JsonPropertyName("id")] public string Id { get; set; } = ""; [JsonPropertyName("id")] public string Id { get; set; } = "";
[JsonPropertyName("conditionId")] public string ConditionId { get; set; } = ""; [JsonPropertyName("conditionId")] public string ConditionId { get; set; } = "";
[JsonPropertyName("questionID")] public string QuestionId { get; set; } = "";
[JsonPropertyName("question")] public string Question { get; set; } = ""; [JsonPropertyName("question")] public string Question { get; set; } = "";
[JsonPropertyName("slug")] public string Slug { get; set; } = ""; [JsonPropertyName("slug")] public string Slug { get; set; } = "";
[JsonPropertyName("description")] public string? Description { get; set; } [JsonPropertyName("description")] public string? Description { get; set; }
@@ -121,11 +122,16 @@ public class GammaMarketResponse
[JsonConverter(typeof(FlexibleDoubleConverter))] [JsonConverter(typeof(FlexibleDoubleConverter))]
public double Volume { get; set; } public double Volume { get; set; }
[JsonPropertyName("volume24hr")]
[JsonConverter(typeof(FlexibleDoubleConverter))]
public double Volume24hr { get; set; }
[JsonPropertyName("liquidityNum")] [JsonPropertyName("liquidityNum")]
[JsonConverter(typeof(FlexibleDoubleConverter))] [JsonConverter(typeof(FlexibleDoubleConverter))]
public double Liquidity { get; set; } 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("startDate")] public string? StartDate { get; set; }
[JsonPropertyName("createdAt")] public string? CreatedAt { get; set; } [JsonPropertyName("createdAt")] public string? CreatedAt { get; set; }
[JsonPropertyName("closed")] public bool Closed { get; set; } [JsonPropertyName("closed")] public bool Closed { get; set; }
@@ -136,7 +142,7 @@ public class GammaMarketResponse
/// <summary>JSON string of outcomes, e.g. "[\"Yes\", \"No\"]"</summary> /// <summary>JSON string of outcomes, e.g. "[\"Yes\", \"No\"]"</summary>
[JsonPropertyName("outcomes")] public string? Outcomes { get; set; } [JsonPropertyName("outcomes")] public string? Outcomes { get; set; }
/// <summary>JSON string of outcome prices, e.g. "[\"0.55\", \"0.45\"]"</summary> /// <summary>JSON string of outcomePrices, e.g. "[\"0.55\", \"0.45\"]"</summary>
[JsonPropertyName("outcomePrices")] public string? OutcomePrices { get; set; } [JsonPropertyName("outcomePrices")] public string? OutcomePrices { get; set; }
/// <summary>JSON string of CLOB token IDs, e.g. "[\"12345...\", \"67890...\"]"</summary> /// <summary>JSON string of CLOB token IDs, e.g. "[\"12345...\", \"67890...\"]"</summary>
@@ -148,6 +154,22 @@ public class GammaEventResponse
[JsonPropertyName("id")] public string Id { get; set; } = ""; [JsonPropertyName("id")] public string Id { get; set; } = "";
[JsonPropertyName("slug")] public string Slug { get; set; } = ""; [JsonPropertyName("slug")] public string Slug { get; set; } = "";
[JsonPropertyName("title")] public string Title { 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<GammaTagResponse> Tags { get; set; } = [];
[JsonPropertyName("markets")] public List<GammaMarketResponse> 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; } = "";
} }
// ═══════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════
@@ -32,17 +32,19 @@ public class PolymarketProvider : IPlatformProvider
var mappedTrades = raw.Select(r => 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 side = MapTradeSide(r);
var sideStr = side.ToString().ToUpperInvariant(); var sideStr = side.ToString().ToUpperInvariant();
// Compact format: {txHash}_{assetId}_{side} — no wallet in ID to reduce index size. // Format: {txHash}_{wallet}_{assetId}_{side}
// Wallet passed transiently via TransientWallet [NotMapped] for MarketHistoryWorker. // Wallet must be included to avoid cross-user collisions in the global IX_Trades_Platform_PlatformTradeId index.
return new Trade return new Trade
{ {
Platform = PlatformType.Polymarket, Platform = PlatformType.Polymarket,
PlatformTradeId = string.IsNullOrEmpty(r.TransactionHash) PlatformTradeId = string.IsNullOrEmpty(r.TransactionHash)
? $"{r.Timestamp}_{r.Asset}_{sideStr}" ? $"{r.Timestamp}_{wallet}_{r.Asset}_{sideStr}"
: $"{r.TransactionHash}_{r.Asset}_{sideStr}", : $"{r.TransactionHash.ToLowerInvariant()}_{wallet}_{r.Asset}_{sideStr}",
MarketId = r.ConditionId ?? "", MarketId = r.ConditionId ?? "",
AssetId = r.Asset ?? "", AssetId = r.Asset ?? "",
Outcome = r.Outcome ?? "", Outcome = r.Outcome ?? "",
@@ -51,13 +53,13 @@ public class PolymarketProvider : IPlatformProvider
Size = (decimal)r.Size, Size = (decimal)r.Size,
Amount = (decimal)(r.Price * r.Size), Amount = (decimal)(r.Price * r.Size),
ExecutedAt = DateTimeOffset.FromUnixTimeSeconds(r.Timestamp).UtcDateTime, ExecutedAt = DateTimeOffset.FromUnixTimeSeconds(r.Timestamp).UtcDateTime,
TransactionHash = r.TransactionHash, TransactionHash = r.TransactionHash?.ToLowerInvariant(),
TraderId = 0, TraderId = 0,
TransientWallet = wallet, TransientWallet = wallet,
}; };
}).ToList(); }).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, Platform = PlatformType.Polymarket,
PlatformTradeId = string.IsNullOrEmpty(r.TransactionHash) PlatformTradeId = string.IsNullOrEmpty(r.TransactionHash)
? $"{r.Timestamp}_{r.Asset}_{sideStr}" ? $"{r.Timestamp}_{wallet}_{r.Asset}_{sideStr}"
: $"{r.TransactionHash}_{r.Asset}_{sideStr}", : $"{r.TransactionHash.ToLowerInvariant()}_{wallet}_{r.Asset}_{sideStr}",
MarketId = r.ConditionId ?? "", MarketId = r.ConditionId ?? "",
AssetId = r.Asset ?? "", AssetId = r.Asset ?? "",
Outcome = r.Outcome ?? "", Outcome = r.Outcome ?? "",
@@ -127,33 +129,76 @@ public class PolymarketProvider : IPlatformProvider
public async Task<Market?> GetMarketAsync(string platformMarketId, CancellationToken ct = default) public async Task<Market?> GetMarketAsync(string platformMarketId, CancellationToken ct = default)
{ {
using var _ = PlatformLogContext.Push(PlatformName); using var _ = PlatformLogContext.Push(PlatformName);
_logger.LogDebug("Fetching market {MarketId}", platformMarketId);
var raw = await _api.GetMarketAsync(platformMarketId, ct); 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); var ev = raw.Events[0];
return null; 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); _logger.LogInformation("Fetched market: {Question}", raw.Question);
return MapGammaMarket(raw); return market;
} }
public async Task<IReadOnlyList<Market>> GetMarketsAsync(int limit = 100, string? cursor = null, bool includeClosed = false, CancellationToken ct = default) public async Task<IReadOnlyList<Event>> GetEventsAsync(int limit = 100, string? cursor = null, bool includeClosed = false, CancellationToken ct = default)
{ {
using var _ = PlatformLogContext.Push(PlatformName); using var _ = PlatformLogContext.Push(PlatformName);
int offset = 0; int offset = 0;
if (!string.IsNullOrEmpty(cursor) && int.TryParse(cursor, out var parsed)) if (!string.IsNullOrEmpty(cursor) && int.TryParse(cursor, out var parsed))
offset = parsed; offset = parsed;
_logger.LogInformation("Fetching markets batch (limit={Limit}, offset={Offset}, includeClosed={Closed})", limit, offset, includeClosed); _logger.LogInformation("Fetching events batch (limit={Limit}, offset={Offset}, includeClosed={Closed})", limit, offset, includeClosed);
var raw = await _api.GetMarketsAsync(limit, offset, includeClosed, ct); var rawEvents = await _api.GetEventsAsync(limit, offset, includeClosed, ct);
_logger.LogInformation("Fetched {Count} markets from Gamma API", raw.Count); _logger.LogInformation("Fetched {Count} events from Gamma API", rawEvents.Count);
return raw var events = new List<Event>();
.Where(m => !string.IsNullOrEmpty(m.ConditionId) && !string.IsNullOrEmpty(m.ClobTokenIds)) foreach (var rawEvent in rawEvents)
.Select(MapGammaMarket) {
.ToList(); var ev = MapGammaEvent(rawEvent);
events.Add(ev);
}
return events;
} }
public async Task<IReadOnlyList<DiscoveredTrader>> GetTopHoldersAsync(string platformMarketId, int limit = 20, CancellationToken ct = default) public async Task<IReadOnlyList<DiscoveredTrader>> GetTopHoldersAsync(string platformMarketId, int limit = 20, CancellationToken ct = default)
@@ -192,31 +237,67 @@ public class PolymarketProvider : IPlatformProvider
// ── Private helpers ────────────────────────────────────────── // ── Private helpers ──────────────────────────────────────────
private Market MapGammaMarket(GammaMarketResponse raw) private Event MapGammaEvent(GammaEventResponse rawEvent)
{ {
var eventSlug = ""; long.TryParse(rawEvent.Id, out var numericId);
if (raw.Events != null && raw.Events.Count > 0 && !string.IsNullOrEmpty(raw.Events[0].Slug))
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 var market = new Market
{ {
Platform = PlatformType.Polymarket, Platform = PlatformType.Polymarket,
PlatformMarketId = raw.ConditionId, PlatformMarketId = marketNumericId,
ConditionId = raw.ConditionId,
QuestionId = raw.QuestionId,
MarketSlug = raw.Slug, MarketSlug = raw.Slug,
EventSlug = eventSlug,
Description = raw.Description, Description = raw.Description,
ImageUrl = raw.Image, ImageUrl = raw.Image,
Question = raw.Question, Question = raw.Question,
Category = raw.Category, Category = string.IsNullOrWhiteSpace(raw.Category) ? parentTags : raw.Category,
Volume = (decimal)raw.Volume, Volume = (decimal)raw.Volume,
Volume24h = (decimal)raw.Volume24hr,
Liquidity = (decimal)raw.Liquidity, Liquidity = (decimal)raw.Liquidity,
StartDate = DateTime.TryParse(raw.StartDate, out var sd) ? sd : null, StartDate = DateTime.TryParse(raw.StartDate, out var msd) ? msd : null,
EndDate = DateTime.TryParse(raw.EndDate, out var ed) ? ed : null, EndDate = DateTime.TryParse(raw.EndDate ?? raw.EndDateIso, out var med) ? med : null,
CreatedAt = DateTime.TryParse(raw.CreatedAt, out var cd) ? cd : DateTime.UtcNow, CreatedAt = DateTime.TryParse(raw.CreatedAt, out var mcd) ? mcd : DateTime.UtcNow,
DbCreatedAt = DateTime.UtcNow, DbCreatedAt = DateTime.UtcNow,
IsResolved = raw.Resolved || raw.Closed, // Prefer resolved flag IsResolved = raw.Resolved || raw.Closed,
ResolutionOutcome = raw.ResolutionOutcome, ResolutionOutcome = raw.ResolutionOutcome,
LastUpdatedAt = DateTime.UtcNow LastUpdatedAt = DateTime.UtcNow
}; };
+193 -17
View File
@@ -21,6 +21,8 @@ partial class MainForm
btn_serverstart = new ToolStripButton(); btn_serverstart = new ToolStripButton();
btn_localWebserver = new ToolStripButton(); btn_localWebserver = new ToolStripButton();
statusStrip1 = new StatusStrip(); statusStrip1 = new StatusStrip();
label_apiRatelimit = new ToolStripStatusLabel();
label_buildVersion = new ToolStripStatusLabel();
tabControl1 = new TabControl(); tabControl1 = new TabControl();
tabPage_terminal = new TabPage(); tabPage_terminal = new TabPage();
rtb_terminal = new RichTextBox(); rtb_terminal = new RichTextBox();
@@ -34,8 +36,7 @@ partial class MainForm
developmentToolStripMenuItem = new ToolStripMenuItem(); developmentToolStripMenuItem = new ToolStripMenuItem();
btn_dbReset = new ToolStripMenuItem(); btn_dbReset = new ToolStripMenuItem();
btn_syncmarkets = new ToolStripMenuItem(); btn_syncmarkets = new ToolStripMenuItem();
label_apiRatelimit = new ToolStripStatusLabel(); btn_dbUpdate = new ToolStripMenuItem();
label_buildVersion = new ToolStripStatusLabel();
toolStrip1.SuspendLayout(); toolStrip1.SuspendLayout();
statusStrip1.SuspendLayout(); statusStrip1.SuspendLayout();
tabControl1.SuspendLayout(); tabControl1.SuspendLayout();
@@ -76,6 +77,21 @@ partial class MainForm
statusStrip1.Size = new Size(1864, 32); statusStrip1.Size = new Size(1864, 32);
statusStrip1.TabIndex = 1; 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
// //
tabControl1.Anchor = AnchorStyles.Top | AnchorStyles.Bottom | AnchorStyles.Left | AnchorStyles.Right; tabControl1.Anchor = AnchorStyles.Top | AnchorStyles.Bottom | AnchorStyles.Left | AnchorStyles.Right;
@@ -150,18 +166,182 @@ partial class MainForm
// btn_logfolder // btn_logfolder
// //
btn_logfolder.Name = "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.Text = "Show Logfolder";
btn_logfolder.Click += btn_logfolder_Click; btn_logfolder.Click += btn_logfolder_Click;
// //
// btn_openbrowser // btn_openbrowser
// //
btn_openbrowser.Name = "btn_openbrowser"; btn_openbrowser.Name = "btn_openbrowser";
btn_openbrowser.Size = new Size(261, 34);
btn_openbrowser.Text = "Show Local WebUI"; btn_openbrowser.Text = "Show Local WebUI";
btn_openbrowser.Click += btn_openbrowser_Click; btn_openbrowser.Click += btn_openbrowser_Click;
// //
// developmentToolStripMenuItem // 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.Name = "developmentToolStripMenuItem";
developmentToolStripMenuItem.Size = new Size(135, 29); developmentToolStripMenuItem.Size = new Size(135, 29);
developmentToolStripMenuItem.Text = "Development"; developmentToolStripMenuItem.Text = "Development";
@@ -169,28 +349,22 @@ partial class MainForm
// btn_dbReset // btn_dbReset
// //
btn_dbReset.Name = "btn_dbReset"; btn_dbReset.Name = "btn_dbReset";
btn_dbReset.Size = new Size(270, 34);
btn_dbReset.Text = "reset TradesDB"; btn_dbReset.Text = "reset TradesDB";
// //
// btn_syncmarkets // btn_syncmarkets
// //
btn_syncmarkets.Name = "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; btn_syncmarkets.Click += syncMarketsaToolStripMenuItem_Click;
// //
// label_apiRatelimit // btn_dbUpdate
// //
label_apiRatelimit.Name = "label_apiRatelimit"; btn_dbUpdate.Name = "btn_dbUpdate";
label_apiRatelimit.Size = new Size(1670, 25); btn_dbUpdate.Size = new Size(270, 34);
label_apiRatelimit.Spring = true; btn_dbUpdate.Text = "UpdateDB";
label_apiRatelimit.Text = "API: OK"; btn_dbUpdate.Click += btn_dbUpdate_Click;
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;
// //
// MainForm // MainForm
// //
@@ -237,5 +411,7 @@ partial class MainForm
private ToolStripMenuItem btn_syncmarkets; private ToolStripMenuItem btn_syncmarkets;
private PropertyGrid pg_settings; private PropertyGrid pg_settings;
private ToolStripStatusLabel label_apiRatelimit; private ToolStripStatusLabel label_apiRatelimit;
private ToolStripStatusLabel label_dbSize;
private ToolStripStatusLabel label_buildVersion; private ToolStripStatusLabel label_buildVersion;
private ToolStripMenuItem btn_dbUpdate;
} }
+63 -1
View File
@@ -59,10 +59,16 @@ public partial class MainForm : Form
btn_serverstart.Click += Btn_serverstart_Click; btn_serverstart.Click += Btn_serverstart_Click;
btn_localWebserver.Click += Btn_localWebserver_Click; btn_localWebserver.Click += Btn_localWebserver_Click;
btn_syncmarkets.Click += syncMarketsaToolStripMenuItem_Click; btn_syncmarkets.Click += syncMarketsaToolStripMenuItem_Click;
btn_dbUpdate.Click += btn_dbUpdate_Click;
Log.Information("MainForm initialized. Ready."); Log.Information("MainForm initialized. Ready.");
Log.Information("Press 'Start Server' to begin polling & discovery."); 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); 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) private async void Btn_serverstart_Click(object? sender, EventArgs e)
@@ -193,11 +199,67 @@ public partial class MainForm : Form
catch (Exception ex) catch (Exception ex)
{ {
Log.Error(ex, "Manual market sync failed"); 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 finally
{ {
btn_syncmarkets.Enabled = true; 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");
}
}
} }
@@ -31,6 +31,9 @@ internal static class Program
.MinimumLevel.Override("Microsoft.Hosting", LogEventLevel.Warning) .MinimumLevel.Override("Microsoft.Hosting", LogEventLevel.Warning)
.Enrich.FromLogContext() .Enrich.FromLogContext()
// Suppress duplicate entry EF errors completely from logging
.Filter.ByExcluding(e => e.Exception != null && e.Exception.ToString().Contains("Duplicate entry"))
// ── Console (simple) ── // ── Console (simple) ──
.WriteTo.Console(outputTemplate: simpleTemplate, restrictedToMinimumLevel: LogEventLevel.Warning) .WriteTo.Console(outputTemplate: simpleTemplate, restrictedToMinimumLevel: LogEventLevel.Warning)
@@ -21,6 +21,14 @@ public class EmbeddedWebServer
public string? ConnectionString { get; set; } public string? ConnectionString { get; set; }
public bool DbConnectionDebug { 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) public async Task StartWebServerAsync(int port = 5000)
{ {
lock (_lock) { if (_app != null) return; } lock (_lock) { if (_app != null) return; }
@@ -161,25 +169,25 @@ public class EmbeddedWebServer
.First(x => x.Platform == p.Platform); .First(x => x.Platform == p.Platform);
await rateLimiter.WaitAsync(provider.Platform, ct); 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; break;
} }
await marketRepo.AddOrUpdateRangeAsync(markets, ct); await marketRepo.AddOrUpdateEventsAsync(events, ct);
totalSynced += markets.Count; totalSynced += events.Count;
offset += batchSize; offset += batchSize;
if (totalSynced % 500 == 0 || markets.Count < batchSize) if (totalSynced % 500 == 0 || events.Count < batchSize)
Log.Information("[{Platform}] Synced {Total} markets so far (offset={Offset}, includeClosed={Closed})...", provider.PlatformName, totalSynced, offset, includeClosed); 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; break;
} }
} }
@@ -11,5 +11,11 @@
"Microsoft.AspNetCore": "Warning", "Microsoft.AspNetCore": "Warning",
"Microsoft.EntityFrameworkCore": "Warning" "Microsoft.EntityFrameworkCore": "Warning"
} }
},
"OpenRouter": {
"ApiKey": "",
"BaseUrl": "https://openrouter.ai/api/v1",
"DefaultModel": "google/gemini-flash-1.5",
"ManualAnalysisModel": "anthropic/claude-3-opus"
} }
} }
@@ -19,6 +19,7 @@ public static class DependencyInjection
services.AddHostedService<TraderAnalyticsWorker>(); services.AddHostedService<TraderAnalyticsWorker>();
services.AddHostedService<ScoringAndAlertsWorker>(); services.AddHostedService<ScoringAndAlertsWorker>();
services.AddHostedService<TradeRetentionWorker>(); services.AddHostedService<TradeRetentionWorker>();
services.AddHostedService<TradeContextEnrichmentWorker>();
return services; return services;
} }
} }
@@ -7,6 +7,10 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.11">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="8.0.1" /> <PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="8.0.1" />
</ItemGroup> </ItemGroup>
@@ -18,7 +18,7 @@ public class MarketHistoryWorker : BackgroundService
private readonly ILogger<MarketHistoryWorker> _logger; private readonly ILogger<MarketHistoryWorker> _logger;
private const int CooldownHours = 6; private const int CooldownHours = 6;
private const int MarketsPerCycle = 10; private const int MarketsPerCycle = 50;
private const int TradesPerFetch = 100; private const int TradesPerFetch = 100;
public MarketHistoryWorker(IServiceProvider services, ILogger<MarketHistoryWorker> logger) public MarketHistoryWorker(IServiceProvider services, ILogger<MarketHistoryWorker> logger)
@@ -64,7 +64,7 @@ public class MarketHistoryWorker : BackgroundService
using var platformCtx = PlatformLogContext.Push(provider.PlatformName); using var platformCtx = PlatformLogContext.Push(provider.PlatformName);
await rateLimiter.WaitAsync(market.Platform, stoppingToken); 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]. // Extract unique wallet addresses from TransientWallet [NotMapped].
// Providers set this during in-memory mapping; it is NOT stored in the DB. // Providers set this during in-memory mapping; it is NOT stored in the DB.
@@ -66,15 +66,15 @@ public class MarketSyncWorker : BackgroundService
.First(x => x.Platform == p.Platform); .First(x => x.Platform == p.Platform);
await rateLimiter.WaitAsync(provider.Platform, stoppingToken); 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; passSynced += events.Count;
cycleTotalSynced += markets.Count; cycleTotalSynced += events.Count;
_statsService.TrackMarketSync(provider.Platform, markets.Count); _statsService.TrackMarketSync(provider.Platform, events.Count);
offset += batchSize; offset += batchSize;
if (passSynced % 500 == 0) if (passSynced % 500 == 0)
@@ -36,7 +36,7 @@ public class PollingWorker : BackgroundService
using (var scope = _services.CreateScope()) using (var scope = _services.CreateScope())
{ {
var repo = scope.ServiceProvider.GetRequiredService<ITraderRepository>(); var repo = scope.ServiceProvider.GetRequiredService<ITraderRepository>();
tradersToProcess = await repo.GetAllAsync(take: 100, ct: stoppingToken); tradersToProcess = await repo.GetTradersForPollingAsync(take: 100, ct: stoppingToken);
} }
_logger.LogWarning("📊 Polling {Count} traders...", tradersToProcess.Count); _logger.LogWarning("📊 Polling {Count} traders...", tradersToProcess.Count);
@@ -121,7 +121,7 @@ public class PollingWorker : BackgroundService
{ {
// Final deduplication of the batch itself // Final deduplication of the batch itself
var uniqueNewTrades = newTrades var uniqueNewTrades = newTrades
.GroupBy(tr => tr.PlatformTradeId) .GroupBy(tr => tr.PlatformTradeId, StringComparer.OrdinalIgnoreCase)
.Select(g => g.First()) .Select(g => g.First())
.ToList(); .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)) 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 else
@@ -52,8 +52,9 @@ public class ReportingWorker : BackgroundService
foreach (var (platform, stats) in statsMap) foreach (var (platform, stats) in statsMap)
{ {
_logger.LogWarning("[{Platform}] Markets: {M} | New Traders: {T} | Activities: {A}", var dupStr = stats.DuplicateErrors > 0 ? $" | Duplikatfehler: {stats.DuplicateErrors}" : "";
platform, stats.MarketsSynced, stats.TradersDiscovered, stats.TradesProcessed); _logger.LogWarning("[{Platform}] Markets: {M} | New Traders: {T} | Activities: {A}{D}",
platform, stats.MarketsSynced, stats.TradersDiscovered, stats.TradesProcessed, dupStr);
} }
_logger.LogWarning("------------------------------------------------"); _logger.LogWarning("------------------------------------------------");
@@ -37,7 +37,7 @@ public class TopHolderDiscoveryWorker : BackgroundService
var rateLimiter = scope.ServiceProvider.GetRequiredService<IRateLimiter>(); var rateLimiter = scope.ServiceProvider.GetRequiredService<IRateLimiter>();
// Get top active markets by volume // 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); _logger.LogInformation("👥 Scanning top holders across {Count} active markets", activeMarkets.Count);
int totalDiscovered = 0; int totalDiscovered = 0;
@@ -54,9 +54,9 @@ public class TopHolderDiscoveryWorker : BackgroundService
using var platformCtx = PlatformLogContext.Push(provider.PlatformName); using var platformCtx = PlatformLogContext.Push(provider.PlatformName);
await rateLimiter.WaitAsync(market.Platform, stoppingToken); 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 // Skip empty wallet addresses
if (string.IsNullOrWhiteSpace(holder.PlatformUserId)) continue; if (string.IsNullOrWhiteSpace(holder.PlatformUserId)) continue;
@@ -95,9 +95,9 @@ public class TopHolderDiscoveryWorker : BackgroundService
catch (OperationCanceledException) { break; } catch (OperationCanceledException) { break; }
catch (Exception ex) { _logger.LogError(ex, "TopHolderDiscoveryWorker error"); } catch (Exception ex) { _logger.LogError(ex, "TopHolderDiscoveryWorker error"); }
// Run every 15 minutes // Run every 5 minutes
_logger.LogInformation("👥 Next top holder scan in 15 minutes."); _logger.LogInformation("🚀 Next top holder scan in 5 minutes.");
await Task.Delay(TimeSpan.FromMinutes(15), stoppingToken); await Task.Delay(TimeSpan.FromMinutes(5), stoppingToken);
} }
_logger.LogInformation("👥 TopHolderDiscoveryWorker stopped"); _logger.LogInformation("👥 TopHolderDiscoveryWorker stopped");
@@ -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;
/// <summary>
/// Retroactively enriches trades of top/watchlisted traders with high-resolution
/// 1-minute price contexts immediately before and after execution.
/// Avoids burdening the live PollingWorker.
/// </summary>
public class TradeContextEnrichmentWorker : BackgroundService
{
private readonly IServiceProvider _services;
private readonly ILogger<TradeContextEnrichmentWorker> _logger;
public TradeContextEnrichmentWorker(IServiceProvider services, ILogger<TradeContextEnrichmentWorker> 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<ITradeRepository>();
var polymarketClient = scope.ServiceProvider.GetRequiredService<PolymarketApiClient>();
var rateLimiter = scope.ServiceProvider.GetRequiredService<IRateLimiter>();
// 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");
}
}
@@ -151,14 +151,14 @@ public class TradeHistoryWorker : BackgroundService
if (newTrades.Count > 0) 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 { try {
await tradeRepo.AddRangeAsync(uniqueNewTrades, ct); await tradeRepo.AddRangeAsync(uniqueNewTrades, ct);
_statsService.TrackTradeActivity(trader.Platform, uniqueNewTrades.Count); _statsService.TrackTradeActivity(trader.Platform, uniqueNewTrades.Count);
trader.TotalTrades += uniqueNewTrades.Count; trader.TotalTrades += uniqueNewTrades.Count;
_logger.LogInformation("{Trader}: {New} new trades imported", trader.DisplayName, uniqueNewTrades.Count); _logger.LogInformation("{Trader}: {New} new trades imported", trader.DisplayName, uniqueNewTrades.Count);
} catch (Exception ex) when (ex.ToString().Contains("Duplicate entry")) { } catch (Exception ex) when (ex.ToString().Contains("Duplicate entry") || (ex.InnerException?.Message.Contains("Duplicate entry") ?? false)) {
_logger.LogWarning("{Trader}: Skipping batch due to duplicates", trader.DisplayName); _statsService.TrackDuplicateError(trader.Platform, 1);
} }
} }
@@ -33,25 +33,26 @@ public class TraderAnalyticsWorker : BackgroundService
_logger.LogError(ex, "Error in TraderAnalyticsWorker"); _logger.LogError(ex, "Error in TraderAnalyticsWorker");
} }
_logger.LogInformation("TraderAnalyticsWorker sleeping for 12 hours..."); _logger.LogInformation("TraderAnalyticsWorker sleeping for 2 hours...");
await Task.Delay(TimeSpan.FromHours(12), ct); await Task.Delay(TimeSpan.FromHours(2), ct);
} }
} }
private async Task RunAnalyticsAsync(CancellationToken ct) private async Task RunAnalyticsAsync(CancellationToken ct)
{ {
using var scope = _services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var pnlEngine = scope.ServiceProvider.GetRequiredService<IPositionPnLEngine>();
var cutoff30d = DateTime.UtcNow.AddDays(-30); var cutoff30d = DateTime.UtcNow.AddDays(-30);
List<int> traderIds;
using (var scope = _services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
// Find traders active in the last 30 days // Find traders active in the last 30 days
var traderIds = await db.Trades traderIds = await db.Trades
.Where(t => t.ExecutedAt >= cutoff30d) .Where(t => t.ExecutedAt >= cutoff30d)
.Select(t => t.TraderId) .Select(t => t.TraderId)
.Distinct() .Distinct()
.ToListAsync(ct); .ToListAsync(ct);
}
_logger.LogInformation("Found {Count} active traders to analyze", traderIds.Count); _logger.LogInformation("Found {Count} active traders to analyze", traderIds.Count);
@@ -59,6 +60,8 @@ public class TraderAnalyticsWorker : BackgroundService
{ {
try try
{ {
using var traderScope = _services.CreateScope();
var pnlEngine = traderScope.ServiceProvider.GetRequiredService<IPositionPnLEngine>();
await pnlEngine.RecalculateTraderPositionsAsync(id, ct); await pnlEngine.RecalculateTraderPositionsAsync(id, ct);
} }
catch (Exception ex) catch (Exception ex)