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();
});
group.MapPost("/{id:int}/ai-analysis", async (int id, bool manual, IAiStrategyAnalysisService aiSvc, CancellationToken ct) =>
{
var summary = await aiSvc.AnalyzeTraderStrategyAsync(id, manual, ct);
return Results.Ok(new { summary });
});
group.MapPost("/", async (string platform, string wallet, IAnalyticsService svc, CancellationToken ct) =>
{
var id = await svc.AddTraderAsync(platform, wallet, ct);
+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>
</div>
<div class="detail-grid">
<div class="card detail-sidebar">
<div class="detail-sidebar">
<div class="card">
<div class="stat-group">
<div class="stat-label">Platform</div>
<div class="stat-value" id="td-platform"></div>
@@ -206,6 +207,16 @@
<div class="stat-value" id="td-strategy"></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="metrics-grid">
<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 ───
const fmt = {
usd: v => { if (v === null || v === undefined) return '$0'; const n = Number(v); return n >= 1000000 ? `$${(n/1000000).toFixed(1)}M` : n >= 1000 ? `$${(n/1000).toFixed(1)}K` : `$${n.toFixed(0)}`; },
pct: v => { if (v === null || v === undefined) return '0%'; return `${Number(v).toFixed(1)}%`; },
pct: v => {
if (v === null || v === undefined) return '0%';
const n = Number(v);
const color = n > 55 ? 'var(--pnl-positive)' : n < 45 ? 'var(--pnl-negative)' : 'var(--text)';
return `<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(); },
time: v => { if (!v) return '—'; const d = new Date(v); const now = new Date(); const diff = (now - d) / 1000;
if (diff < 60) return `${Math.floor(diff)}s ago`;
@@ -207,15 +212,15 @@ async function loadDashboard() {
// Top Traders table
const tbody = document.getElementById('topTradersBody');
tbody.innerHTML = data.topTraders.map((t, i) => `
<tr onclick="viewTrader(${t.id})" style="cursor:pointer">
<td>${i + 1}</td>
<td><strong>${t.displayName}</strong></td>
<td>${t.platform}</td>
<td><strong>${Number(t.combinedScore).toFixed(1)}</strong></td>
<td>${fmt.pct(t.winRate)}</td>
<td>${fmt.pnl(t.totalPnl)}</td>
<td>${fmt.tier(t.tier)}</td>
<td>${fmt.num(t.totalTrades)}</td>
<tr>
<td onclick="viewTrader(${t.id})" style="cursor:pointer">${i + 1}</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 onclick="viewTrader(${t.id})" style="cursor:pointer">${t.platform}</td>
<td onclick="viewTrader(${t.id})" style="cursor:pointer"><strong>${Number(t.combinedScore).toFixed(1)}</strong></td>
<td onclick="viewTrader(${t.id})" style="cursor:pointer">${fmt.pct(t.winRate)}</td>
<td onclick="viewTrader(${t.id})" style="cursor:pointer">${fmt.pnl(t.totalPnl)}</td>
<td onclick="viewTrader(${t.id})" style="cursor:pointer">${fmt.tier(t.tier)}</td>
<td onclick="viewTrader(${t.id})" style="cursor:pointer">${fmt.num(t.totalTrades)}</td>
</tr>
`).join('');
@@ -282,7 +287,7 @@ async function loadTraders() {
tbody.innerHTML = data.map((t, i) => `
<tr>
<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><strong>${Number(t.combinedScore).toFixed(1)}</strong></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-trades').textContent = fmt.num(t.totalTrades);
document.getElementById('td-score').textContent = Number(t.combinedScore).toFixed(1);
document.getElementById('td-ai-summary').textContent = t.aiStrategySummary || 'Not analyzed yet.';
const refreshBtn = document.getElementById('btn-refresh-trader');
refreshBtn.onclick = () => manualUpdateTrader(id);
const aiBtn = document.getElementById('btn-ai-analysis');
aiBtn.onclick = () => triggerAiAnalysis(id, true);
const tbody = document.getElementById('td-tradesBody');
tbody.innerHTML = t.recentTrades.map(tr => `
<tr>
@@ -428,3 +437,24 @@ setInterval(() => {
const activePage = document.querySelector('.page.active');
if (activePage?.id === 'page-dashboard') loadDashboard();
}, 30000);
async function triggerAiAnalysis(id, manual) {
const btn = document.getElementById('btn-ai-analysis');
const oldText = btn.textContent;
btn.textContent = 'Analyzing...';
btn.disabled = true;
try {
const res = await api(`/api/traders/${id}/ai-analysis?manual=${manual}`, { method: 'POST' });
if (res && res.summary) {
document.getElementById('td-ai-summary').textContent = res.summary;
} else {
alert('Analysis failed or returned empty.');
}
} catch (e) {
console.error(e);
alert('Analysis error: ' + e);
} finally {
btn.textContent = oldText;
btn.disabled = false;
}
}
@@ -52,7 +52,7 @@ public class AnalyticsServiceTests
var trader = new Trader { Id = 1, PlatformUserId = "0x1", DisplayName = "Trader 1" };
db.Traders.Add(trader);
var market = new Market { Id = 10, PlatformMarketId = "pm1", Question = "Q?" };
var market = new Market { Id = 10, PlatformMarketId = 1L, Question = "Q?" };
var outcome = new MarketOutcome { Id = 100, MarketId = 10, Label = "Yes", TokenId = "t100", CurrentPrice = 0.50m };
market.Outcomes.Add(outcome);
db.Markets.Add(market);
@@ -28,7 +28,7 @@ public class PositionPnLEngineTests
var pnlEngine = new PositionPnLEngine(db, NullLogger<PositionPnLEngine>.Instance);
var trader = new Trader { Id = 1, PlatformUserId = "0x1", DisplayName = "Trader 1" };
var market = new Market { Id = 10, PlatformMarketId = "pm1", Question = "Q?" };
var market = new Market { Id = 10, PlatformMarketId = 1L, Question = "Q?" };
var outcome = new MarketOutcome { Id = 100, MarketId = 10, Label = "Yes", TokenId = "t100", CurrentPrice = 0.60m };
market.Outcomes.Add(outcome);
@@ -69,7 +69,7 @@ public class PositionPnLEngineTests
var pnlEngine = new PositionPnLEngine(db, NullLogger<PositionPnLEngine>.Instance);
var trader = new Trader { Id = 1, PlatformUserId = "0x1", DisplayName = "Trader 1" };
var market = new Market { Id = 10, PlatformMarketId = "pm1", Question = "Q?" };
var market = new Market { Id = 10, PlatformMarketId = 1L, Question = "Q?" };
var outcome = new MarketOutcome { Id = 100, MarketId = 10, Label = "Yes", TokenId = "t100", CurrentPrice = 0.60m };
market.Outcomes.Add(outcome);
@@ -113,7 +113,7 @@ public class PositionPnLEngineTests
var pnlEngine = new PositionPnLEngine(db, NullLogger<PositionPnLEngine>.Instance);
var trader = new Trader { Id = 1, PlatformUserId = "0x1", DisplayName = "Trader 1" };
var market = new Market { Id = 10, PlatformMarketId = "pm1", Question = "Q?", IsResolved = true, ResolutionOutcome = "Yes" };
var market = new Market { Id = 10, PlatformMarketId = 1L, Question = "Q?", IsResolved = true, ResolutionOutcome = "Yes" };
var outcome = new MarketOutcome { Id = 100, MarketId = 10, Label = "Yes", TokenId = "t100", CurrentPrice = 1.00m };
market.Outcomes.Add(outcome);
@@ -42,5 +42,6 @@ public record TraderDetailDto(
bool IsOnWatchlist,
DateTime CreatedAt,
DateTime? LastPolledAt,
string? AiStrategySummary,
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 TrackTraderDiscovery(PlatformType platform, int count = 1);
void TrackTradeActivity(PlatformType platform, int count = 1);
void TrackDuplicateError(PlatformType platform, int count = 1);
Dictionary<PlatformType, PlatformStats> GetAndResetStats();
}
@@ -16,4 +17,5 @@ public class PlatformStats
public int MarketsSynced { get; set; }
public int TradersDiscovered { get; set; }
public int TradesProcessed { get; set; }
public int DuplicateErrors { get; set; }
}
@@ -8,11 +8,11 @@ namespace Predictalytics.Application.Interfaces;
public interface IRateLimiter
{
/// <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>
bool CanMakeRequest(PlatformType platform);
bool CanMakeRequest(PlatformType platform, string endpointGroup = "Default");
/// <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,
s?.ActivityScore ?? 0, s?.QualityScore ?? 0, s?.VolumeScore ?? 0, s?.TimingScore ?? 0,
s?.CombinedScore ?? 0, s?.CopytradingScore ?? 0, s?.Rank ?? 0, wl != null, trader.CreatedAt, trader.LastPolledAt,
trader.AiStrategySummary,
trades.Select(MapTradeDto).ToList());
}
@@ -204,14 +205,14 @@ public class AnalyticsService : IAnalyticsService
if (recentTrades.Count == 0)
{
// Fallback: trades ingested before DbMarketId backfill
recentTrades = await _tradeRepo.GetByMarketIdAsync(market.PlatformMarketId, 0, 50, ct);
recentTrades = await _tradeRepo.GetByMarketIdAsync(market.ConditionId, 0, 50, ct);
}
return new MarketDetailDto
{
Id = market.Id,
Platform = market.Platform.ToString(),
PlatformMarketId = market.PlatformMarketId,
PlatformMarketId = market.ConditionId,
Question = market.Question,
Description = market.Description,
Category = market.Category,
@@ -26,6 +26,12 @@ public class PlatformStatisticsService : IPlatformStatisticsService
lock (stats) stats.TradesProcessed += count;
}
public void TrackDuplicateError(PlatformType platform, int count = 1)
{
var stats = _stats.GetOrAdd(platform, _ => new PlatformStats());
lock (stats) stats.DuplicateErrors += count;
}
public Dictionary<PlatformType, PlatformStats> GetAndResetStats()
{
var result = new Dictionary<PlatformType, PlatformStats>();
@@ -9,30 +9,35 @@ namespace Predictalytics.Application.Services;
/// </summary>
public class RateLimiterService : IRateLimiter
{
private readonly ConcurrentDictionary<PlatformType, SemaphoreSlim> _semaphores = new();
private readonly ConcurrentDictionary<PlatformType, DateTime> _lastRequest = new();
private readonly ConcurrentDictionary<PlatformType, DateTime> _blockedUntil = new();
private readonly ConcurrentDictionary<string, SemaphoreSlim> _semaphores = new();
private readonly ConcurrentDictionary<string, DateTime> _lastRequest = new();
private readonly ConcurrentDictionary<string, DateTime> _blockedUntil = new();
// Minimum delay between requests per platform (milliseconds)
private static readonly Dictionary<PlatformType, int> PlatformDelays = new()
private static readonly Dictionary<string, int> Delays = new()
{
{ PlatformType.Polymarket, 200 },
{ PlatformType.Limitless, 500 },
{ PlatformType.Azuro, 1000 },
{ PlatformType.Myriad, 1000 },
{ PlatformType.PredictFun, 1000 },
{ PlatformType.Kalshi, 500 },
{ PlatformType.Stake, 1000 }
{ "Polymarket-Gamma", 35 }, // ~28 req/s (Gamma /markets limit is 30/s)
{ "Polymarket-Data", 55 }, // ~18 req/s (Data /trades limit is 20/s)
{ "Polymarket-Clob", 15 }, // ~66 req/s (Clob /prices limit is 100/s)
{ "Polymarket-Default", 0 }, // Bypass worker-level limiting for Polymarket, handled in ApiClient
{ "Limitless-Default", 500 },
{ "Azuro-Default", 1000 },
{ "Myriad-Default", 1000 },
{ "PredictFun-Default", 1000 },
{ "Kalshi-Default", 500 },
{ "Stake-Default", 1000 }
};
public async Task WaitAsync(PlatformType platform, CancellationToken ct = default)
public async Task WaitAsync(PlatformType platform, CancellationToken ct = default, string endpointGroup = "Default")
{
var sem = _semaphores.GetOrAdd(platform, _ => new SemaphoreSlim(1, 1));
var key = $"{platform}-{endpointGroup}";
if (!Delays.ContainsKey(key)) key = $"{platform}-Default";
var sem = _semaphores.GetOrAdd(key, _ => new SemaphoreSlim(1, 1));
await sem.WaitAsync(ct);
try
{
// 1. Check if we are currently blocked due to a 429
if (_blockedUntil.TryGetValue(platform, out var blockedUntil))
if (_blockedUntil.TryGetValue(key, out var blockedUntil))
{
var waitTime = blockedUntil - DateTime.UtcNow;
if (waitTime > TimeSpan.Zero)
@@ -41,32 +46,37 @@ public class RateLimiterService : IRateLimiter
}
}
// 2. Normal token bucket delay
if (_lastRequest.TryGetValue(platform, out var last))
if (_lastRequest.TryGetValue(key, out var last))
{
var delayMs = PlatformDelays.GetValueOrDefault(platform, 1000);
var delayMs = Delays.GetValueOrDefault(key, 1000);
var elapsed = (DateTime.UtcNow - last).TotalMilliseconds;
if (elapsed < delayMs)
await Task.Delay((int)(delayMs - elapsed), ct);
}
_lastRequest[platform] = DateTime.UtcNow;
_lastRequest[key] = DateTime.UtcNow;
}
finally { sem.Release(); }
}
public bool CanMakeRequest(PlatformType platform)
public bool CanMakeRequest(PlatformType platform, string endpointGroup = "Default")
{
if (_blockedUntil.TryGetValue(platform, out var blockedUntil) && blockedUntil > DateTime.UtcNow)
var key = $"{platform}-{endpointGroup}";
if (!Delays.ContainsKey(key)) key = $"{platform}-Default";
if (_blockedUntil.TryGetValue(key, out var blockedUntil) && blockedUntil > DateTime.UtcNow)
return false;
if (!_lastRequest.TryGetValue(platform, out var last)) return true;
var delayMs = PlatformDelays.GetValueOrDefault(platform, 1000);
if (!_lastRequest.TryGetValue(key, out var last)) return true;
var delayMs = Delays.GetValueOrDefault(key, 1000);
return (DateTime.UtcNow - last).TotalMilliseconds >= delayMs;
}
public void ReportRateLimitExceeded(PlatformType platform, TimeSpan? retryAfter = null)
public void ReportRateLimitExceeded(PlatformType platform, TimeSpan? retryAfter = null, string endpointGroup = "Default")
{
var key = $"{platform}-{endpointGroup}";
if (!Delays.ContainsKey(key)) key = $"{platform}-Default";
var penalty = retryAfter ?? TimeSpan.FromSeconds(30);
_blockedUntil[platform] = DateTime.UtcNow.Add(penalty);
_blockedUntil[key] = DateTime.UtcNow.Add(penalty);
}
}
@@ -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>
public PlatformType Platform { get; set; }
/// <summary>Platform-specific market identifier (conditionId on Polymarket).</summary>
public string PlatformMarketId { get; set; } = string.Empty;
/// <summary>The Event this market belongs to.</summary>
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>
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>
public string? Description { get; set; }
@@ -36,6 +43,9 @@ public class Market
/// <summary>Current total volume traded.</summary>
public decimal Volume { get; set; }
/// <summary>Current 24h volume.</summary>
public decimal Volume24h { get; set; }
/// <summary>Current liquidity.</summary>
public decimal Liquidity { get; set; }
@@ -66,6 +66,19 @@ public class Trade
/// <summary>Transaction hash (for blockchain-based platforms).</summary>
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) ──────────────────────────────────────────
/// <summary>
@@ -58,6 +58,12 @@ public class Trader
/// <summary>Win rate as a percentage (0-100).</summary>
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>
public int TotalTrades { get; set; }
@@ -10,6 +10,7 @@ public interface IMarketRepository
Task<IReadOnlyList<MarketOutcome>> GetOutcomesByTokenIdsAsync(IEnumerable<string> tokenIds, CancellationToken ct = default);
Task AddOrUpdateAsync(Market market, 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<int> GetCountAsync(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>
Task<Market?> GetMarketAsync(string platformMarketId, CancellationToken ct = default);
/// <summary>Fetch a batch of markets with their outcomes for bulk sync.</summary>
Task<IReadOnlyList<Market>> GetMarketsAsync(int limit = 100, string? cursor = null, bool includeClosed = false, CancellationToken ct = default);
/// <summary>Fetch a batch of events with their markets and outcomes for bulk sync.</summary>
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>
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<HashSet<string>> GetKnownPlatformTradeIdsAsync(PlatformType platform, int traderId, IEnumerable<string> platformTradeIds, 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>
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);
}
@@ -1,4 +1,4 @@
using Predictalytics.Domain.Enums;
using Predictalytics.Domain.Enums;
namespace Predictalytics.Domain.ValueObjects;
@@ -23,14 +23,15 @@ public record PriorityScore(
/// <summary>
/// Determine tier based on effective score.
/// To reach Diamond, the score must be exceptionally high (top 1%).
/// </summary>
public TraderTier DetermineTier() => EffectiveScore switch
{
>= 90 => TraderTier.Diamond,
>= 75 => TraderTier.Platinum,
>= 60 => TraderTier.Gold,
>= 40 => TraderTier.Silver,
>= 20 => TraderTier.Bronze,
>= 95 => TraderTier.Diamond,
>= 85 => TraderTier.Platinum,
>= 70 => TraderTier.Gold,
>= 50 => TraderTier.Silver,
>= 30 => TraderTier.Bronze,
_ => TraderTier.Unknown
};
}
@@ -7,6 +7,7 @@ public class AppDbContext : DbContext
{
public DbSet<Trader> Traders => Set<Trader>();
public DbSet<Trade> Trades => Set<Trade>();
public DbSet<Event> Events => Set<Event>();
public DbSet<Market> Markets => Set<Market>();
public DbSet<MarketOutcome> MarketOutcomes => Set<MarketOutcome>();
public DbSet<TraderScore> TraderScores => Set<TraderScore>();
@@ -66,19 +67,34 @@ public class AppDbContext : DbContext
.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
mb.Entity<Market>(e =>
{
e.HasKey(m => m.Id);
e.HasIndex(m => new { m.Platform, m.PlatformMarketId }).IsUnique();
e.Property(m => m.PlatformMarketId).HasMaxLength(256);
e.Property(m => m.ConditionId).HasMaxLength(256);
e.Property(m => m.QuestionId).HasMaxLength(256);
e.Property(m => m.MarketSlug).HasMaxLength(512);
e.Property(m => m.EventSlug).HasMaxLength(512);
e.Property(m => m.Question).HasMaxLength(1024);
e.Property(m => m.Description).HasMaxLength(4096);
e.Property(m => m.ImageUrl).HasMaxLength(1024);
e.Property(m => m.Category).HasMaxLength(128);
e.Property(m => m.Volume).HasPrecision(18, 4);
e.Property(m => m.Volume24h).HasPrecision(18, 4);
e.Property(m => m.Liquidity).HasPrecision(18, 4);
e.HasMany(m => m.Outcomes).WithOne(o => o.Market).HasForeignKey(o => o.MarketId)
.OnDelete(DeleteBehavior.Cascade);
@@ -14,8 +14,8 @@ public class MarketRepository : IMarketRepository
public MarketRepository(AppDbContext db) => _db = db;
public async Task<Market?> GetByPlatformIdAsync(PlatformType platform, string platformMarketId, CancellationToken ct = default)
=> await _db.Markets.Include(m => m.Outcomes)
.FirstOrDefaultAsync(m => m.Platform == platform && m.PlatformMarketId == platformMarketId, ct);
=> await _db.Markets.Include(m => m.Outcomes).Include(m => m.Event)
.FirstOrDefaultAsync(m => m.Platform == platform && m.ConditionId == platformMarketId, ct);
public async Task<MarketOutcome?> GetOutcomeByTokenIdAsync(string tokenId, CancellationToken ct = default)
=> await _db.MarketOutcomes.Include(o => o.Market)
@@ -34,7 +34,7 @@ public class MarketRepository : IMarketRepository
TruncateMarketStrings(market);
var existing = await _db.Markets.Include(m => m.Outcomes)
.FirstOrDefaultAsync(m => m.Platform == market.Platform && m.PlatformMarketId == market.PlatformMarketId, ct);
.FirstOrDefaultAsync(m => m.Platform == market.Platform && m.ConditionId == market.ConditionId, ct);
if (existing != null)
{
@@ -42,6 +42,11 @@ public class MarketRepository : IMarketRepository
}
else
{
if (market.Event == null && market.EventId == 0)
{
// Fallback to avoid foreign key exceptions if event is entirely missing
market.Event = new Event { Platform = market.Platform, PlatformEventId = market.PlatformMarketId, Slug = "unknown", Title = "Unknown" };
}
_db.Markets.Add(market);
}
@@ -55,9 +60,9 @@ public class MarketRepository : IMarketRepository
public async Task AddOrUpdateRangeAsync(IEnumerable<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
.GroupBy(m => new { m.Platform, m.PlatformMarketId })
.GroupBy(m => new { m.Platform, m.ConditionId })
.Select(g => g.First())
.ToList();
@@ -72,25 +77,28 @@ public class MarketRepository : IMarketRepository
{
var currentBatch = marketList.Skip(i).Take(subBatchSize).ToList();
var platform = currentBatch.First().Platform;
var ids = currentBatch.Select(m => m.PlatformMarketId).ToList();
var ids = currentBatch.Select(m => m.ConditionId).ToList();
// Fetch all existing markets in this batch at once
var existingMarkets = await _db.Markets.Include(m => m.Outcomes)
.Where(m => m.Platform == platform && ids.Contains(m.PlatformMarketId))
.Where(m => m.Platform == platform && ids.Contains(m.ConditionId))
.ToListAsync(ct);
var existingMap = existingMarkets.ToDictionary(m => m.PlatformMarketId);
var existingMap = existingMarkets.ToDictionary(m => m.ConditionId);
foreach (var market in currentBatch)
{
TruncateMarketStrings(market);
if (existingMap.TryGetValue(market.PlatformMarketId, out var existing))
if (existingMap.TryGetValue(market.ConditionId, out var existing))
{
UpdateMarketFields(existing, market);
}
else
{
if (market.Event == null && market.EventId == 0)
{
market.Event = new Event { Platform = market.Platform, PlatformEventId = market.PlatformMarketId, Slug = "unknown", Title = "Unknown" };
}
_db.Markets.Add(market);
}
}
@@ -104,21 +112,96 @@ public class MarketRepository : IMarketRepository
}
}
public async Task AddOrUpdateEventsAsync(IEnumerable<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)
{
existing.Question = updated.Question;
existing.MarketSlug = updated.MarketSlug;
existing.EventSlug = updated.EventSlug;
existing.PlatformMarketId = updated.PlatformMarketId;
existing.QuestionId = updated.QuestionId;
existing.Description = updated.Description;
existing.ImageUrl = updated.ImageUrl;
existing.Category = updated.Category;
existing.Volume = updated.Volume;
existing.Volume24h = updated.Volume24h;
existing.Liquidity = updated.Liquidity;
existing.StartDate = updated.StartDate;
existing.EndDate = updated.EndDate;
existing.IsResolved = updated.IsResolved;
existing.ResolutionOutcome = updated.ResolutionOutcome;
existing.CreatedAt = updated.CreatedAt; // Platform creation date
existing.CreatedAt = updated.CreatedAt;
existing.LastUpdatedAt = DateTime.UtcNow;
// Upsert outcomes
@@ -146,7 +229,6 @@ public class MarketRepository : IMarketRepository
market.Question = StringHelper.Truncate(market.Question, 1024) ?? "";
market.Description = StringHelper.Truncate(market.Description, 4096);
market.MarketSlug = StringHelper.Truncate(market.MarketSlug, 512) ?? "";
market.EventSlug = StringHelper.Truncate(market.EventSlug, 512) ?? "";
market.ImageUrl = StringHelper.Truncate(market.ImageUrl, 1024);
market.Category = StringHelper.Truncate(market.Category, 128) ?? "";
@@ -192,7 +274,7 @@ public class MarketRepository : IMarketRepository
return await _db.Markets.Include(m => m.Outcomes)
.Where(m => m.Question.Contains(query) ||
m.PlatformMarketId.Contains(query) ||
m.ConditionId.Contains(query) ||
m.Id.ToString() == query)
.OrderByDescending(m => m.Volume)
.Take(take)
@@ -92,13 +92,13 @@ public class TradeRepository : ITradeRepository
public async Task<HashSet<string>> GetKnownPlatformTradeIdsAsync(PlatformType platform, int traderId, IEnumerable<string> platformTradeIds, CancellationToken ct = default)
{
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
.Where(t => t.Platform == platform && t.TraderId == traderId && idList.Contains(t.PlatformTradeId))
.Where(t => t.Platform == platform && idList.Contains(t.PlatformTradeId))
.Select(t => t.PlatformTradeId)
.ToListAsync(ct);
return new HashSet<string>(ids);
return new HashSet<string>(ids, StringComparer.OrdinalIgnoreCase);
}
public async Task UpdateAsync(Trade trade, CancellationToken ct = default)
@@ -111,4 +111,19 @@ public class TradeRepository : ITradeRepository
_db.Trades.Update(trade);
await _db.SaveChangesAsync(ct);
}
public async Task<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);
}
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)
{
if (string.IsNullOrWhiteSpace(query)) return Array.Empty<Trader>();
@@ -84,6 +84,8 @@ public static class DependencyInjection
services.AddSingleton<PolymarketApiClient>();
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, LimitlessProvider>();
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");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.Market", b =>
modelBuilder.Entity("Predictalytics.Domain.Entities.Event", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
@@ -75,11 +75,6 @@ namespace Predictalytics.Infrastructure.Migrations
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
b.Property<string>("Category")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("varchar(128)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
@@ -93,11 +88,85 @@ namespace Predictalytics.Infrastructure.Migrations
b.Property<DateTime?>("EndDate")
.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()
.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)");
@@ -123,16 +192,19 @@ namespace Predictalytics.Infrastructure.Migrations
b.Property<int>("Platform")
.HasColumnType("int");
b.Property<string>("PlatformMarketId")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("varchar(256)");
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");
@@ -143,8 +215,14 @@ namespace Predictalytics.Infrastructure.Migrations
.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();
@@ -297,6 +375,9 @@ namespace Predictalytics.Infrastructure.Migrations
b.Property<DateTime>("ExecutedAt")
.HasColumnType("datetime(6)");
b.Property<bool>("IsContextEnriched")
.HasColumnType("tinyint(1)");
b.Property<string>("MarketId")
.IsRequired()
.HasMaxLength(66)
@@ -318,6 +399,12 @@ namespace Predictalytics.Infrastructure.Migrations
.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)");
@@ -362,6 +449,12 @@ namespace Predictalytics.Infrastructure.Migrations
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)");
@@ -603,6 +696,17 @@ namespace Predictalytics.Infrastructure.Migrations
b.Navigation("Trader");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.Market", b =>
{
b.HasOne("Predictalytics.Domain.Entities.Event", "Event")
.WithMany("Markets")
.HasForeignKey("EventId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Event");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.MarketAnalytics", b =>
{
b.HasOne("Predictalytics.Domain.Entities.Market", "Market")
@@ -713,6 +817,11 @@ namespace Predictalytics.Infrastructure.Migrations
b.Navigation("Trader");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.Event", b =>
{
b.Navigation("Markets");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.Market", b =>
{
b.Navigation("Analytics");
@@ -31,8 +31,8 @@ public class AzuroProvider : IPlatformProvider
public Task<Market?> GetMarketAsync(string platformMarketId, CancellationToken ct = default)
{ using var _ = PlatformLogContext.Push(PlatformName); _logger.LogWarning("Provider not yet implemented"); return Task.FromResult<Market?>(null); }
public Task<IReadOnlyList<Market>> GetMarketsAsync(int limit = 100, string? cursor = null, bool includeClosed = false, CancellationToken ct = default)
{ using var _ = PlatformLogContext.Push(PlatformName); return Task.FromResult<IReadOnlyList<Market>>(Array.Empty<Market>()); }
public Task<IReadOnlyList<Event>> GetEventsAsync(int limit = 100, string? cursor = null, bool includeClosed = false, CancellationToken ct = default)
{ 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)
{ 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
try
{
var response = await _client.GetAsync(url, ct);
if (!response.IsSuccessStatusCode)
{
@@ -45,6 +46,7 @@ public class LimitlessApiClient
var url = $"markets/{addressOrSlug}";
try
{
var response = await _client.GetAsync(url, ct);
if (!response.IsSuccessStatusCode) return null;
return await response.Content.ReadFromJsonAsync<LimitlessMarketResponse>(cancellationToken: ct);
@@ -61,6 +63,7 @@ public class LimitlessApiClient
var url = $"portfolio/{walletAddress}/positions";
try
{
var response = await _client.GetAsync(url, ct);
if (!response.IsSuccessStatusCode)
{
@@ -82,6 +85,7 @@ public class LimitlessApiClient
var url = $"markets/{slug}/events?limit={limit}";
try
{
var response = await _client.GetAsync(url, ct);
if (!response.IsSuccessStatusCode) return [];
var result = await response.Content.ReadFromJsonAsync<LimitlessEventsResponse>(cancellationToken: ct);
@@ -164,17 +164,33 @@ public class LimitlessProvider : IPlatformProvider
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);
int.TryParse(cursor, out var offset);
// Since Limitless /markets/active doesn't support offset, we only return the first page.
// Returning data for offset > 0 would cause an infinite loop in MarketSyncWorker.
if (offset > 0) return [];
var offset = 0;
if (!string.IsNullOrEmpty(cursor) && int.TryParse(cursor, out var parsed))
offset = parsed;
_logger.LogInformation("Fetching markets batch (limit={Limit}, offset={Offset})", limit, offset);
var raw = await _api.GetActiveMarketsAsync(limit, offset, ct);
return raw.Select(MapLimitlessMarket).ToList();
_logger.LogInformation("Fetched {Count} markets from Limitless API", raw.Count);
var events = new List<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)
@@ -229,12 +245,13 @@ public class LimitlessProvider : IPlatformProvider
private Market MapLimitlessMarket(LimitlessMarketResponse raw)
{
var conditionId = raw.Address ?? raw.Slug ?? Guid.NewGuid().ToString();
var market = new Market
{
Platform = PlatformType.Limitless,
PlatformMarketId = raw.Address ?? raw.Slug ?? "",
ConditionId = conditionId,
PlatformMarketId = GetStableHashCode(conditionId),
MarketSlug = raw.Slug ?? "",
EventSlug = "", // Limitless doesn't seem to have a clear Event/Market split in this model
Question = raw.Title ?? "",
Description = raw.Description ?? "",
Category = raw.Categories?.FirstOrDefault() ?? "",
@@ -279,4 +296,15 @@ public class LimitlessProvider : IPlatformProvider
if (sideStr == "0" || sideStr == "BUY") return TradeSide.Buy;
return TradeSide.Sell;
}
private static long GetStableHashCode(string str)
{
unchecked
{
long hash = 23;
foreach (char c in str)
hash = hash * 31 + c;
return hash;
}
}
}
@@ -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)
{
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)
{
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)
{
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)
{
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();
}
/// <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.
/// </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 url = $"/markets?limit={limit}&offset={offset}&active={activeOnly.ToString().ToLower()}&closed={includeClosed.ToString().ToLower()}";
_logger.LogDebug("Fetching markets: {Url}", url);
var result = await ExecuteWithRetryAsync<List<GammaMarketResponse>>(_gammaClient, url, ct);
_logger.LogInformation("Fetched {Count} markets (offset={Offset}, closed={Closed})", result?.Count ?? 0, offset, includeClosed);
var url = $"/events?limit={limit}&offset={offset}&active={activeOnly.ToString().ToLower()}&closed={includeClosed.ToString().ToLower()}";
_logger.LogDebug("Fetching events: {Url}", url);
var result = await ExecuteWithRetryAsync<List<GammaEventResponse>>(_gammaClient, url, "Gamma", ct);
_logger.LogInformation("Fetched {Count} events (offset={Offset}, closed={Closed})", result?.Count ?? 0, offset, includeClosed);
return result ?? [];
}
@@ -87,7 +87,7 @@ public class PolymarketApiClient
{
var url = $"/holders?market={conditionId}&limit={limit}";
_logger.LogDebug("Fetching holders: {Url}", url);
var result = await ExecuteWithRetryAsync<List<HoldersResponse>>(_client, url, ct);
var result = await ExecuteWithRetryAsync<List<HoldersResponse>>(_client, url, "Data", ct);
_logger.LogInformation("Fetched holders for {Market}: {Count} token groups",
conditionId.Length > 12 ? conditionId[..12] + "..." : conditionId, result?.Count ?? 0);
return result ?? [];
@@ -106,13 +106,15 @@ public class PolymarketApiClient
{
var url = $"/v1/leaderboard?limit={Math.Min(limit, 50)}&time_period={timePeriod}&order_by={orderBy}&category={category}";
_logger.LogDebug("Fetching leaderboard: {Url}", url);
var result = await ExecuteWithRetryAsync<List<LeaderboardEntry>>(_client, url, ct);
var result = await ExecuteWithRetryAsync<List<LeaderboardEntry>>(_client, url, "Data", ct);
_logger.LogInformation("Leaderboard returned {Count} entries", result?.Count ?? 0);
return result ?? [];
}
private async Task<T?> ExecuteWithRetryAsync<T>(HttpClient client, string url, CancellationToken ct, int attempt = 1)
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
{
var response = await client.GetAsync(url, ct);
@@ -135,15 +137,14 @@ public class PolymarketApiClient
waitTime = TimeSpan.FromSeconds(30);
}
_logger.LogWarning("⚠️ Rate limit exceeded (429) for Polymarket. Pausing for {WaitTime}s...", (int)waitTime.TotalSeconds);
_logger.LogWarning("⚠️ Rate limit exceeded (429) for Polymarket {Group}. Pausing for {WaitTime}s...", endpointGroup, (int)waitTime.TotalSeconds);
_rateLimiter.ReportRateLimitExceeded(PlatformType.Polymarket, waitTime);
_rateLimiter.ReportRateLimitExceeded(PlatformType.Polymarket, waitTime, endpointGroup);
if (attempt < 3)
{
await _rateLimiter.WaitAsync(PlatformType.Polymarket, ct);
_logger.LogWarning("Retrying {Url} (attempt {NextAttempt})...", url, attempt + 1);
return await ExecuteWithRetryAsync<T>(client, url, ct, attempt + 1);
return await ExecuteWithRetryAsync<T>(client, url, endpointGroup, ct, attempt + 1);
}
return default;
@@ -172,7 +173,7 @@ public class PolymarketApiClient
public async Task<List<PriceHistoryEntry>> GetPricesHistoryAsync(string clobTokenId, string interval = "6h", CancellationToken ct = default)
{
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 ?? [];
}
}
@@ -109,6 +109,7 @@ public class GammaMarketResponse
{
[JsonPropertyName("id")] public string Id { get; set; } = "";
[JsonPropertyName("conditionId")] public string ConditionId { get; set; } = "";
[JsonPropertyName("questionID")] public string QuestionId { get; set; } = "";
[JsonPropertyName("question")] public string Question { get; set; } = "";
[JsonPropertyName("slug")] public string Slug { get; set; } = "";
[JsonPropertyName("description")] public string? Description { get; set; }
@@ -121,11 +122,16 @@ public class GammaMarketResponse
[JsonConverter(typeof(FlexibleDoubleConverter))]
public double Volume { get; set; }
[JsonPropertyName("volume24hr")]
[JsonConverter(typeof(FlexibleDoubleConverter))]
public double Volume24hr { get; set; }
[JsonPropertyName("liquidityNum")]
[JsonConverter(typeof(FlexibleDoubleConverter))]
public double Liquidity { get; set; }
[JsonPropertyName("endDateIso")] public string? EndDate { get; set; }
[JsonPropertyName("endDateIso")] public string? EndDateIso { get; set; }
[JsonPropertyName("endDate")] public string? EndDate { get; set; }
[JsonPropertyName("startDate")] public string? StartDate { get; set; }
[JsonPropertyName("createdAt")] public string? CreatedAt { get; set; }
[JsonPropertyName("closed")] public bool Closed { get; set; }
@@ -136,7 +142,7 @@ public class GammaMarketResponse
/// <summary>JSON string of outcomes, e.g. "[\"Yes\", \"No\"]"</summary>
[JsonPropertyName("outcomes")] public string? Outcomes { get; set; }
/// <summary>JSON string of outcome prices, e.g. "[\"0.55\", \"0.45\"]"</summary>
/// <summary>JSON string of outcomePrices, e.g. "[\"0.55\", \"0.45\"]"</summary>
[JsonPropertyName("outcomePrices")] public string? OutcomePrices { get; set; }
/// <summary>JSON string of CLOB token IDs, e.g. "[\"12345...\", \"67890...\"]"</summary>
@@ -148,6 +154,22 @@ public class GammaEventResponse
[JsonPropertyName("id")] public string Id { get; set; } = "";
[JsonPropertyName("slug")] public string Slug { get; set; } = "";
[JsonPropertyName("title")] public string Title { get; set; } = "";
[JsonPropertyName("description")] public string? Description { get; set; }
[JsonPropertyName("image")] public string? Image { get; set; }
[JsonPropertyName("startDate")] public string? StartDate { get; set; }
[JsonPropertyName("endDate")] public string? EndDate { get; set; }
[JsonPropertyName("createdAt")] public string? CreatedAt { get; set; }
[JsonPropertyName("active")] public bool Active { get; set; }
[JsonPropertyName("closed")] public bool Closed { get; set; }
[JsonPropertyName("tags")] public List<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 wallet = r.User ?? r.ProxyWallet ?? "";
var wallet = !string.IsNullOrEmpty(r.User) ? r.User :
!string.IsNullOrEmpty(r.ProxyWallet) ? r.ProxyWallet :
platformUserId;
var side = MapTradeSide(r);
var sideStr = side.ToString().ToUpperInvariant();
// Compact format: {txHash}_{assetId}_{side} — no wallet in ID to reduce index size.
// Wallet passed transiently via TransientWallet [NotMapped] for MarketHistoryWorker.
// Format: {txHash}_{wallet}_{assetId}_{side}
// Wallet must be included to avoid cross-user collisions in the global IX_Trades_Platform_PlatformTradeId index.
return new Trade
{
Platform = PlatformType.Polymarket,
PlatformTradeId = string.IsNullOrEmpty(r.TransactionHash)
? $"{r.Timestamp}_{r.Asset}_{sideStr}"
: $"{r.TransactionHash}_{r.Asset}_{sideStr}",
? $"{r.Timestamp}_{wallet}_{r.Asset}_{sideStr}"
: $"{r.TransactionHash.ToLowerInvariant()}_{wallet}_{r.Asset}_{sideStr}",
MarketId = r.ConditionId ?? "",
AssetId = r.Asset ?? "",
Outcome = r.Outcome ?? "",
@@ -51,13 +53,13 @@ public class PolymarketProvider : IPlatformProvider
Size = (decimal)r.Size,
Amount = (decimal)(r.Price * r.Size),
ExecutedAt = DateTimeOffset.FromUnixTimeSeconds(r.Timestamp).UtcDateTime,
TransactionHash = r.TransactionHash,
TransactionHash = r.TransactionHash?.ToLowerInvariant(),
TraderId = 0,
TransientWallet = wallet,
};
}).ToList();
return mappedTrades.GroupBy(t => t.PlatformTradeId).Select(g => g.First()).ToList();
return mappedTrades.GroupBy(t => t.PlatformTradeId, StringComparer.OrdinalIgnoreCase).Select(g => g.First()).ToList();
}
@@ -75,8 +77,8 @@ public class PolymarketProvider : IPlatformProvider
{
Platform = PlatformType.Polymarket,
PlatformTradeId = string.IsNullOrEmpty(r.TransactionHash)
? $"{r.Timestamp}_{r.Asset}_{sideStr}"
: $"{r.TransactionHash}_{r.Asset}_{sideStr}",
? $"{r.Timestamp}_{wallet}_{r.Asset}_{sideStr}"
: $"{r.TransactionHash.ToLowerInvariant()}_{wallet}_{r.Asset}_{sideStr}",
MarketId = r.ConditionId ?? "",
AssetId = r.Asset ?? "",
Outcome = r.Outcome ?? "",
@@ -127,33 +129,76 @@ public class PolymarketProvider : IPlatformProvider
public async Task<Market?> GetMarketAsync(string platformMarketId, CancellationToken ct = default)
{
using var _ = PlatformLogContext.Push(PlatformName);
_logger.LogDebug("Fetching market {MarketId}", platformMarketId);
var raw = await _api.GetMarketAsync(platformMarketId, ct);
if (raw == null)
if (raw == null || string.IsNullOrEmpty(raw.ConditionId)) return null;
var parentTags = "";
if (raw.Events != null && raw.Events.Count > 0)
{
_logger.LogWarning("Market {MarketId} not found", platformMarketId);
return null;
var ev = raw.Events[0];
parentTags = ev.Tags != null ? string.Join(", ", ev.Tags.Select(t => t.Label)) : "";
}
var market = MapGammaMarket(raw, parentTags);
// Map the parent Event if available in the Market response
if (raw.Events != null && raw.Events.Count > 0)
{
var rawEv = raw.Events[0];
long.TryParse(rawEv.Id, out var numericEventId);
market.Event = new Event
{
Platform = PlatformType.Polymarket,
PlatformEventId = numericEventId,
Slug = rawEv.Slug,
Title = rawEv.Title,
Description = rawEv.Description,
ImageUrl = rawEv.Image,
StartDate = DateTime.TryParse(rawEv.StartDate, out var esd) ? esd : null,
EndDate = DateTime.TryParse(rawEv.EndDate, out var eed) ? eed : null,
CreatedAt = DateTime.TryParse(rawEv.CreatedAt, out var ecd) ? ecd : DateTime.UtcNow,
DbCreatedAt = DateTime.UtcNow,
IsActive = rawEv.Active,
IsClosed = rawEv.Closed,
Tags = rawEv.Tags != null && rawEv.Tags.Count > 0 ? string.Join(", ", rawEv.Tags.Select(t => t.Label)) : string.Empty,
LastUpdatedAt = DateTime.UtcNow
};
}
else
{
// Fallback empty event if missing (should rarely happen for valid Polymarket markets)
market.Event = new Event
{
Platform = PlatformType.Polymarket,
Slug = "unknown-" + market.ConditionId,
Title = "Unknown Event",
DbCreatedAt = DateTime.UtcNow,
LastUpdatedAt = DateTime.UtcNow
};
}
_logger.LogInformation("Fetched market: {Question}", raw.Question);
return MapGammaMarket(raw);
return market;
}
public async Task<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);
int offset = 0;
if (!string.IsNullOrEmpty(cursor) && int.TryParse(cursor, out var parsed))
offset = parsed;
_logger.LogInformation("Fetching markets batch (limit={Limit}, offset={Offset}, includeClosed={Closed})", limit, offset, includeClosed);
var raw = await _api.GetMarketsAsync(limit, offset, includeClosed, ct);
_logger.LogInformation("Fetched {Count} markets from Gamma API", raw.Count);
_logger.LogInformation("Fetching events batch (limit={Limit}, offset={Offset}, includeClosed={Closed})", limit, offset, includeClosed);
var rawEvents = await _api.GetEventsAsync(limit, offset, includeClosed, ct);
_logger.LogInformation("Fetched {Count} events from Gamma API", rawEvents.Count);
return raw
.Where(m => !string.IsNullOrEmpty(m.ConditionId) && !string.IsNullOrEmpty(m.ClobTokenIds))
.Select(MapGammaMarket)
.ToList();
var events = new List<Event>();
foreach (var rawEvent in rawEvents)
{
var ev = MapGammaEvent(rawEvent);
events.Add(ev);
}
return events;
}
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 Market MapGammaMarket(GammaMarketResponse raw)
private Event MapGammaEvent(GammaEventResponse rawEvent)
{
var eventSlug = "";
if (raw.Events != null && raw.Events.Count > 0 && !string.IsNullOrEmpty(raw.Events[0].Slug))
long.TryParse(rawEvent.Id, out var numericId);
var ev = new Event
{
eventSlug = raw.Events[0].Slug;
Platform = PlatformType.Polymarket,
PlatformEventId = numericId,
Slug = rawEvent.Slug,
Title = rawEvent.Title,
Description = rawEvent.Description,
ImageUrl = rawEvent.Image,
StartDate = DateTime.TryParse(rawEvent.StartDate, out var sd) ? sd : null,
EndDate = DateTime.TryParse(rawEvent.EndDate, out var ed) ? ed : null,
CreatedAt = DateTime.TryParse(rawEvent.CreatedAt, out var cd) ? cd : DateTime.UtcNow,
DbCreatedAt = DateTime.UtcNow,
IsActive = rawEvent.Active,
IsClosed = rawEvent.Closed,
Tags = rawEvent.Tags != null && rawEvent.Tags.Count > 0
? string.Join(", ", rawEvent.Tags.Select(t => t.Label))
: string.Empty,
LastUpdatedAt = DateTime.UtcNow
};
if (rawEvent.Markets != null)
{
foreach (var rawMarket in rawEvent.Markets)
{
if (string.IsNullOrEmpty(rawMarket.ConditionId) || string.IsNullOrEmpty(rawMarket.ClobTokenIds)) continue;
var market = MapGammaMarket(rawMarket, ev.Tags ?? "");
ev.Markets.Add(market);
}
}
return ev;
}
private Market MapGammaMarket(GammaMarketResponse raw, string parentTags = "")
{
long.TryParse(raw.Id, out var marketNumericId);
var market = new Market
{
Platform = PlatformType.Polymarket,
PlatformMarketId = raw.ConditionId,
PlatformMarketId = marketNumericId,
ConditionId = raw.ConditionId,
QuestionId = raw.QuestionId,
MarketSlug = raw.Slug,
EventSlug = eventSlug,
Description = raw.Description,
ImageUrl = raw.Image,
Question = raw.Question,
Category = raw.Category,
Category = string.IsNullOrWhiteSpace(raw.Category) ? parentTags : raw.Category,
Volume = (decimal)raw.Volume,
Volume24h = (decimal)raw.Volume24hr,
Liquidity = (decimal)raw.Liquidity,
StartDate = DateTime.TryParse(raw.StartDate, out var sd) ? sd : null,
EndDate = DateTime.TryParse(raw.EndDate, out var ed) ? ed : null,
CreatedAt = DateTime.TryParse(raw.CreatedAt, out var cd) ? cd : DateTime.UtcNow,
StartDate = DateTime.TryParse(raw.StartDate, out var msd) ? msd : null,
EndDate = DateTime.TryParse(raw.EndDate ?? raw.EndDateIso, out var med) ? med : null,
CreatedAt = DateTime.TryParse(raw.CreatedAt, out var mcd) ? mcd : DateTime.UtcNow,
DbCreatedAt = DateTime.UtcNow,
IsResolved = raw.Resolved || raw.Closed, // Prefer resolved flag
IsResolved = raw.Resolved || raw.Closed,
ResolutionOutcome = raw.ResolutionOutcome,
LastUpdatedAt = DateTime.UtcNow
};
+193 -17
View File
@@ -21,6 +21,8 @@ partial class MainForm
btn_serverstart = new ToolStripButton();
btn_localWebserver = new ToolStripButton();
statusStrip1 = new StatusStrip();
label_apiRatelimit = new ToolStripStatusLabel();
label_buildVersion = new ToolStripStatusLabel();
tabControl1 = new TabControl();
tabPage_terminal = new TabPage();
rtb_terminal = new RichTextBox();
@@ -34,8 +36,7 @@ partial class MainForm
developmentToolStripMenuItem = new ToolStripMenuItem();
btn_dbReset = new ToolStripMenuItem();
btn_syncmarkets = new ToolStripMenuItem();
label_apiRatelimit = new ToolStripStatusLabel();
label_buildVersion = new ToolStripStatusLabel();
btn_dbUpdate = new ToolStripMenuItem();
toolStrip1.SuspendLayout();
statusStrip1.SuspendLayout();
tabControl1.SuspendLayout();
@@ -76,6 +77,21 @@ partial class MainForm
statusStrip1.Size = new Size(1864, 32);
statusStrip1.TabIndex = 1;
//
// label_apiRatelimit
//
label_apiRatelimit.Name = "label_apiRatelimit";
label_apiRatelimit.Size = new Size(1782, 25);
label_apiRatelimit.Spring = true;
label_apiRatelimit.Text = "API: OK";
label_apiRatelimit.TextAlign = ContentAlignment.MiddleLeft;
//
// label_buildVersion
//
label_buildVersion.Name = "label_buildVersion";
label_buildVersion.Size = new Size(67, 25);
label_buildVersion.Text = "Build: -";
label_buildVersion.TextAlign = ContentAlignment.MiddleRight;
//
// tabControl1
//
tabControl1.Anchor = AnchorStyles.Top | AnchorStyles.Bottom | AnchorStyles.Left | AnchorStyles.Right;
@@ -150,18 +166,182 @@ partial class MainForm
// btn_logfolder
//
btn_logfolder.Name = "btn_logfolder";
btn_logfolder.Size = new Size(261, 34);
btn_logfolder.Text = "Show Logfolder";
btn_logfolder.Click += btn_logfolder_Click;
//
// btn_openbrowser
toolStrip1 = new ToolStrip();
btn_serverstart = new ToolStripButton();
btn_localWebserver = new ToolStripButton();
statusStrip1 = new StatusStrip();
label_apiRatelimit = new ToolStripStatusLabel();
label_dbSize = new ToolStripStatusLabel();
label_buildVersion = new ToolStripStatusLabel();
tabControl1 = new TabControl();
tabPage_terminal = new TabPage();
rtb_terminal = new RichTextBox();
tabPage2 = new TabPage();
pg_settings = new PropertyGrid();
menuStrip1 = new MenuStrip();
filesToolStripMenuItem = new ToolStripMenuItem();
editToolStripMenuItem = new ToolStripMenuItem();
btn_logfolder = new ToolStripMenuItem();
btn_openbrowser = new ToolStripMenuItem();
developmentToolStripMenuItem = new ToolStripMenuItem();
btn_dbReset = new ToolStripMenuItem();
btn_syncmarkets = new ToolStripMenuItem();
btn_dbUpdate = new ToolStripMenuItem();
toolStrip1.SuspendLayout();
statusStrip1.SuspendLayout();
tabControl1.SuspendLayout();
tabPage_terminal.SuspendLayout();
tabPage2.SuspendLayout();
menuStrip1.SuspendLayout();
SuspendLayout();
//
// toolStrip1
//
toolStrip1.ImageScalingSize = new Size(24, 24);
toolStrip1.Items.AddRange(new ToolStripItem[] { btn_serverstart, btn_localWebserver });
toolStrip1.Location = new Point(0, 33);
toolStrip1.Name = "toolStrip1";
toolStrip1.Size = new Size(1864, 34);
toolStrip1.TabIndex = 0;
//
// btn_serverstart
//
btn_serverstart.ImageTransparentColor = Color.Magenta;
btn_serverstart.Name = "btn_serverstart";
btn_serverstart.Size = new Size(127, 29);
btn_serverstart.Text = "▶ Start Server";
//
// btn_localWebserver
//
btn_localWebserver.ImageTransparentColor = Color.Magenta;
btn_localWebserver.Name = "btn_localWebserver";
btn_localWebserver.Size = new Size(161, 29);
btn_localWebserver.Text = "▶ Start Webserver";
//
// statusStrip1
//
statusStrip1.ImageScalingSize = new Size(24, 24);
statusStrip1.Items.AddRange(new ToolStripItem[] { label_apiRatelimit, label_dbSize, label_buildVersion });
statusStrip1.Location = new Point(0, 1000);
statusStrip1.Name = "statusStrip1";
statusStrip1.Size = new Size(1864, 32);
statusStrip1.TabIndex = 1;
//
// label_apiRatelimit
//
label_apiRatelimit.Name = "label_apiRatelimit";
label_apiRatelimit.Size = new Size(1600, 25);
label_apiRatelimit.Spring = true;
label_apiRatelimit.Text = "API: OK";
label_apiRatelimit.TextAlign = ContentAlignment.MiddleLeft;
//
// label_dbSize
//
label_dbSize.Name = "label_dbSize";
label_dbSize.Size = new Size(150, 25);
label_dbSize.Text = "DB Size: -";
label_dbSize.TextAlign = ContentAlignment.MiddleRight;
//
// label_buildVersion
//
label_buildVersion.Name = "label_buildVersion";
label_buildVersion.Size = new Size(67, 25);
label_buildVersion.Text = "Build: -";
label_buildVersion.TextAlign = ContentAlignment.MiddleRight;
//
// tabControl1
//
tabControl1.Anchor = AnchorStyles.Top | AnchorStyles.Bottom | AnchorStyles.Left | AnchorStyles.Right;
tabControl1.Controls.Add(tabPage_terminal);
tabControl1.Controls.Add(tabPage2);
tabControl1.Location = new Point(0, 61);
tabControl1.Name = "tabControl1";
tabControl1.SelectedIndex = 0;
tabControl1.Size = new Size(1864, 946);
tabControl1.TabIndex = 2;
//
// tabPage_terminal
//
tabPage_terminal.Controls.Add(rtb_terminal);
tabPage_terminal.Location = new Point(4, 34);
tabPage_terminal.Name = "tabPage_terminal";
tabPage_terminal.Padding = new Padding(3);
tabPage_terminal.Size = new Size(1856, 908);
tabPage_terminal.TabIndex = 0;
tabPage_terminal.Text = "Terminal";
tabPage_terminal.UseVisualStyleBackColor = true;
//
// rtb_terminal
//
rtb_terminal.Anchor = AnchorStyles.Top | AnchorStyles.Bottom | AnchorStyles.Left | AnchorStyles.Right;
rtb_terminal.Location = new Point(3, 6);
rtb_terminal.Name = "rtb_terminal";
rtb_terminal.Size = new Size(1847, 896);
rtb_terminal.TabIndex = 0;
rtb_terminal.Text = "";
//
// tabPage2
//
tabPage2.Controls.Add(pg_settings);
tabPage2.Location = new Point(4, 34);
tabPage2.Name = "tabPage2";
tabPage2.Padding = new Padding(3);
tabPage2.Size = new Size(1856, 908);
tabPage2.TabIndex = 1;
tabPage2.Text = "Settings";
tabPage2.UseVisualStyleBackColor = true;
//
// pg_settings
//
pg_settings.Location = new Point(3, 6);
pg_settings.Name = "pg_settings";
pg_settings.Size = new Size(1850, 896);
pg_settings.TabIndex = 0;
//
// menuStrip1
//
menuStrip1.ImageScalingSize = new Size(24, 24);
menuStrip1.Items.AddRange(new ToolStripItem[] { filesToolStripMenuItem, editToolStripMenuItem, developmentToolStripMenuItem });
menuStrip1.Location = new Point(0, 0);
menuStrip1.Name = "menuStrip1";
menuStrip1.Size = new Size(1864, 33);
menuStrip1.TabIndex = 3;
//
// filesToolStripMenuItem
//
filesToolStripMenuItem.Name = "filesToolStripMenuItem";
filesToolStripMenuItem.Size = new Size(62, 29);
filesToolStripMenuItem.Text = "Files";
//
// editToolStripMenuItem
//
editToolStripMenuItem.DropDownItems.AddRange(new ToolStripItem[] { btn_logfolder, btn_openbrowser });
editToolStripMenuItem.Name = "editToolStripMenuItem";
editToolStripMenuItem.Size = new Size(58, 29);
editToolStripMenuItem.Text = "Edit";
//
// btn_logfolder
//
btn_logfolder.Name = "btn_logfolder";
btn_logfolder.Size = new Size(261, 34);
btn_logfolder.Text = "Show Logfolder";
btn_logfolder.Click += btn_logfolder_Click;
//
// btn_openbrowser
//
btn_openbrowser.Name = "btn_openbrowser";
btn_openbrowser.Size = new Size(261, 34);
btn_openbrowser.Text = "Show Local WebUI";
btn_openbrowser.Click += btn_openbrowser_Click;
//
// developmentToolStripMenuItem
//
developmentToolStripMenuItem.DropDownItems.AddRange(new ToolStripItem[] { btn_dbReset, btn_syncmarkets });
developmentToolStripMenuItem.DropDownItems.AddRange(new ToolStripItem[] { btn_dbReset, btn_syncmarkets, btn_dbUpdate });
developmentToolStripMenuItem.Name = "developmentToolStripMenuItem";
developmentToolStripMenuItem.Size = new Size(135, 29);
developmentToolStripMenuItem.Text = "Development";
@@ -169,28 +349,22 @@ partial class MainForm
// btn_dbReset
//
btn_dbReset.Name = "btn_dbReset";
btn_dbReset.Size = new Size(270, 34);
btn_dbReset.Text = "reset TradesDB";
//
// btn_syncmarkets
//
btn_syncmarkets.Name = "btn_syncmarkets";
btn_syncmarkets.Text = "Sync Marketsa";
btn_syncmarkets.Size = new Size(270, 34);
btn_syncmarkets.Text = "Sync Markets";
btn_syncmarkets.Click += syncMarketsaToolStripMenuItem_Click;
//
// label_apiRatelimit
// btn_dbUpdate
//
label_apiRatelimit.Name = "label_apiRatelimit";
label_apiRatelimit.Size = new Size(1670, 25);
label_apiRatelimit.Spring = true;
label_apiRatelimit.Text = "API: OK";
label_apiRatelimit.TextAlign = ContentAlignment.MiddleLeft;
//
// label_buildVersion
//
label_buildVersion.Name = "label_buildVersion";
label_buildVersion.Size = new Size(179, 25);
label_buildVersion.Text = "Build: -";
label_buildVersion.TextAlign = ContentAlignment.MiddleRight;
btn_dbUpdate.Name = "btn_dbUpdate";
btn_dbUpdate.Size = new Size(270, 34);
btn_dbUpdate.Text = "UpdateDB";
btn_dbUpdate.Click += btn_dbUpdate_Click;
//
// MainForm
//
@@ -237,5 +411,7 @@ partial class MainForm
private ToolStripMenuItem btn_syncmarkets;
private PropertyGrid pg_settings;
private ToolStripStatusLabel label_apiRatelimit;
private ToolStripStatusLabel label_dbSize;
private ToolStripStatusLabel label_buildVersion;
private ToolStripMenuItem btn_dbUpdate;
}
+63 -1
View File
@@ -59,10 +59,16 @@ public partial class MainForm : Form
btn_serverstart.Click += Btn_serverstart_Click;
btn_localWebserver.Click += Btn_localWebserver_Click;
btn_syncmarkets.Click += syncMarketsaToolStripMenuItem_Click;
btn_dbUpdate.Click += btn_dbUpdate_Click;
Log.Information("MainForm initialized. Ready.");
Log.Information("Press 'Start Server' to begin polling & discovery.");
Log.Information("Press 'Start Local Webserver' to launch the WebUI on http://localhost:{Port}", _settings.WebserverPort);
_ = UpdateDbSizeAsync();
var dbSizeTimer = new System.Windows.Forms.Timer { Interval = 6 * 60 * 60 * 1000 };
dbSizeTimer.Tick += async (s, e) => await UpdateDbSizeAsync();
dbSizeTimer.Start();
}
private async void Btn_serverstart_Click(object? sender, EventArgs e)
@@ -193,11 +199,67 @@ public partial class MainForm : Form
catch (Exception ex)
{
Log.Error(ex, "Manual market sync failed");
MessageBox.Show($"Market sync failed: {ex.Message}", "Error", MessageBoxButtons.OK, MessageBoxIcon.Error);
MessageBox.Show($"Error syncing markets: {ex.Message}", "Error", MessageBoxButtons.OK, MessageBoxIcon.Error);
}
finally
{
btn_syncmarkets.Enabled = true;
}
}
private async void btn_dbUpdate_Click(object sender, EventArgs e)
{
if (_workerRunning)
{
MessageBox.Show("Database update cannot be run while background workers are running.",
"Workers Busy", MessageBoxButtons.OK, MessageBoxIcon.Warning);
return;
}
try
{
btn_dbUpdate.Enabled = false;
Log.Information("Manual database update triggered...");
await _webServer!.UpdateDatabaseAsync();
Log.Information("Database updated successfully.");
MessageBox.Show("Database update completed successfully.", "Success", MessageBoxButtons.OK, MessageBoxIcon.Information);
}
catch (Exception ex)
{
Log.Error(ex, "Manual database update failed");
MessageBox.Show($"Database update failed: {ex.Message}", "Error", MessageBoxButtons.OK, MessageBoxIcon.Error);
}
finally
{
btn_dbUpdate.Enabled = true;
}
}
private async Task UpdateDbSizeAsync()
{
try
{
// Build the connection string
var csBuilder = new MySqlConnector.MySqlConnectionStringBuilder(_settings.ConnectionString);
if (string.IsNullOrWhiteSpace(csBuilder.Database))
return; // Not ready or valid yet
using var conn = new MySqlConnector.MySqlConnection(_settings.ConnectionString);
await conn.OpenAsync();
using var cmd = conn.CreateCommand();
cmd.CommandText = "SELECT SUM(data_length + index_length) / 1024 / 1024 FROM information_schema.tables WHERE table_schema = DATABASE();";
var result = await cmd.ExecuteScalarAsync();
if (result != DBNull.Value && result != null)
{
var sizeMb = Convert.ToDouble(result);
this.Invoke(() => label_dbSize.Text = $"DB Size: {sizeMb:F2} MB");
}
}
catch (Exception ex)
{
this.Invoke(() => label_dbSize.Text = "DB Size: Error");
Log.Debug(ex, "Failed to fetch DB size for status bar");
}
}
}
@@ -31,6 +31,9 @@ internal static class Program
.MinimumLevel.Override("Microsoft.Hosting", LogEventLevel.Warning)
.Enrich.FromLogContext()
// Suppress duplicate entry EF errors completely from logging
.Filter.ByExcluding(e => e.Exception != null && e.Exception.ToString().Contains("Duplicate entry"))
// ── Console (simple) ──
.WriteTo.Console(outputTemplate: simpleTemplate, restrictedToMinimumLevel: LogEventLevel.Warning)
@@ -21,6 +21,14 @@ public class EmbeddedWebServer
public string? ConnectionString { get; set; }
public bool DbConnectionDebug { get; set; }
public async Task UpdateDatabaseAsync()
{
var services = new ServiceCollection();
Predictalytics.Infrastructure.DependencyInjection.AddPredictalytics(services, new Microsoft.Extensions.Configuration.ConfigurationBuilder().Build(), ConnectionString, DbConnectionDebug);
var provider = services.BuildServiceProvider();
await Predictalytics.Infrastructure.DependencyInjection.EnsureDatabaseAsync(provider, DbConnectionDebug);
}
public async Task StartWebServerAsync(int port = 5000)
{
lock (_lock) { if (_app != null) return; }
@@ -161,25 +169,25 @@ public class EmbeddedWebServer
.First(x => x.Platform == p.Platform);
await rateLimiter.WaitAsync(provider.Platform, ct);
var markets = await provider.GetMarketsAsync(batchSize, offset.ToString(), includeClosed, ct);
var events = await provider.GetEventsAsync(batchSize, offset.ToString(), includeClosed, ct);
if (markets == null || markets.Count == 0)
if (events == null || events.Count == 0)
{
Log.Warning("[{Platform}] No more markets found at offset {Offset} (includeClosed={Closed}). Ending pass.", provider.PlatformName, offset, includeClosed);
Log.Warning("[{Platform}] No more events found at offset {Offset} (includeClosed={Closed}). Ending pass.", provider.PlatformName, offset, includeClosed);
break;
}
await marketRepo.AddOrUpdateRangeAsync(markets, ct);
await marketRepo.AddOrUpdateEventsAsync(events, ct);
totalSynced += markets.Count;
totalSynced += events.Count;
offset += batchSize;
if (totalSynced % 500 == 0 || markets.Count < batchSize)
Log.Information("[{Platform}] Synced {Total} markets so far (offset={Offset}, includeClosed={Closed})...", provider.PlatformName, totalSynced, offset, includeClosed);
if (totalSynced % 500 == 0 || events.Count < batchSize)
Log.Information("[{Platform}] Synced {Total} events so far (offset={Offset}, includeClosed={Closed})...", provider.PlatformName, totalSynced, offset, includeClosed);
if (markets.Count < batchSize)
if (events.Count < batchSize)
{
Log.Warning("[{Platform}] Batch was smaller than limit ({Count}/{Limit}), assuming end of list.", provider.PlatformName, markets.Count, batchSize);
Log.Warning("[{Platform}] Batch was smaller than limit ({Count}/{Limit}), assuming end of list.", provider.PlatformName, events.Count, batchSize);
break;
}
}
@@ -11,5 +11,11 @@
"Microsoft.AspNetCore": "Warning",
"Microsoft.EntityFrameworkCore": "Warning"
}
},
"OpenRouter": {
"ApiKey": "",
"BaseUrl": "https://openrouter.ai/api/v1",
"DefaultModel": "google/gemini-flash-1.5",
"ManualAnalysisModel": "anthropic/claude-3-opus"
}
}
@@ -19,6 +19,7 @@ public static class DependencyInjection
services.AddHostedService<TraderAnalyticsWorker>();
services.AddHostedService<ScoringAndAlertsWorker>();
services.AddHostedService<TradeRetentionWorker>();
services.AddHostedService<TradeContextEnrichmentWorker>();
return services;
}
}
@@ -7,6 +7,10 @@
</PropertyGroup>
<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" />
</ItemGroup>
@@ -18,7 +18,7 @@ public class MarketHistoryWorker : BackgroundService
private readonly ILogger<MarketHistoryWorker> _logger;
private const int CooldownHours = 6;
private const int MarketsPerCycle = 10;
private const int MarketsPerCycle = 50;
private const int TradesPerFetch = 100;
public MarketHistoryWorker(IServiceProvider services, ILogger<MarketHistoryWorker> logger)
@@ -64,7 +64,7 @@ public class MarketHistoryWorker : BackgroundService
using var platformCtx = PlatformLogContext.Push(provider.PlatformName);
await rateLimiter.WaitAsync(market.Platform, stoppingToken);
var trades = await provider.GetMarketTradesAsync(market.PlatformMarketId, TradesPerFetch, stoppingToken);
var trades = await provider.GetMarketTradesAsync(market.ConditionId, TradesPerFetch, stoppingToken);
// Extract unique wallet addresses from TransientWallet [NotMapped].
// Providers set this during in-memory mapping; it is NOT stored in the DB.
@@ -66,15 +66,15 @@ public class MarketSyncWorker : BackgroundService
.First(x => x.Platform == p.Platform);
await rateLimiter.WaitAsync(provider.Platform, stoppingToken);
var markets = await provider.GetMarketsAsync(batchSize, offset.ToString(), includeClosed, stoppingToken);
var events = await provider.GetEventsAsync(batchSize, offset.ToString(), includeClosed, stoppingToken);
if (markets.Count == 0) break;
if (events.Count == 0) break;
await marketRepo.AddOrUpdateRangeAsync(markets, stoppingToken);
await marketRepo.AddOrUpdateEventsAsync(events, stoppingToken);
passSynced += markets.Count;
cycleTotalSynced += markets.Count;
_statsService.TrackMarketSync(provider.Platform, markets.Count);
passSynced += events.Count;
cycleTotalSynced += events.Count;
_statsService.TrackMarketSync(provider.Platform, events.Count);
offset += batchSize;
if (passSynced % 500 == 0)
@@ -36,7 +36,7 @@ public class PollingWorker : BackgroundService
using (var scope = _services.CreateScope())
{
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);
@@ -121,7 +121,7 @@ public class PollingWorker : BackgroundService
{
// Final deduplication of the batch itself
var uniqueNewTrades = newTrades
.GroupBy(tr => tr.PlatformTradeId)
.GroupBy(tr => tr.PlatformTradeId, StringComparer.OrdinalIgnoreCase)
.Select(g => g.First())
.ToList();
@@ -135,7 +135,7 @@ public class PollingWorker : BackgroundService
}
catch (Exception ex) when (ex.ToString().Contains("Duplicate entry") || (ex.InnerException?.Message.Contains("Duplicate entry") ?? false))
{
_logger.LogWarning("{Trader}: Skipping batch due to duplicate entries (likely already imported)", trader.DisplayName);
_statsService.TrackDuplicateError(trader.Platform, 1);
}
}
else
@@ -52,8 +52,9 @@ public class ReportingWorker : BackgroundService
foreach (var (platform, stats) in statsMap)
{
_logger.LogWarning("[{Platform}] Markets: {M} | New Traders: {T} | Activities: {A}",
platform, stats.MarketsSynced, stats.TradersDiscovered, stats.TradesProcessed);
var dupStr = stats.DuplicateErrors > 0 ? $" | Duplikatfehler: {stats.DuplicateErrors}" : "";
_logger.LogWarning("[{Platform}] Markets: {M} | New Traders: {T} | Activities: {A}{D}",
platform, stats.MarketsSynced, stats.TradersDiscovered, stats.TradesProcessed, dupStr);
}
_logger.LogWarning("------------------------------------------------");
@@ -37,7 +37,7 @@ public class TopHolderDiscoveryWorker : BackgroundService
var rateLimiter = scope.ServiceProvider.GetRequiredService<IRateLimiter>();
// Get top active markets by volume
var activeMarkets = await marketRepo.GetActiveAsync(20, stoppingToken);
var activeMarkets = await marketRepo.GetActiveAsync(100, stoppingToken);
_logger.LogInformation("👥 Scanning top holders across {Count} active markets", activeMarkets.Count);
int totalDiscovered = 0;
@@ -54,9 +54,9 @@ public class TopHolderDiscoveryWorker : BackgroundService
using var platformCtx = PlatformLogContext.Push(provider.PlatformName);
await rateLimiter.WaitAsync(market.Platform, stoppingToken);
var holders = await provider.GetTopHoldersAsync(market.PlatformMarketId, 10, stoppingToken);
var newHolders = await provider.GetTopHoldersAsync(market.ConditionId, 50, stoppingToken);
foreach (var holder in holders)
foreach (var holder in newHolders)
{
// Skip empty wallet addresses
if (string.IsNullOrWhiteSpace(holder.PlatformUserId)) continue;
@@ -95,9 +95,9 @@ public class TopHolderDiscoveryWorker : BackgroundService
catch (OperationCanceledException) { break; }
catch (Exception ex) { _logger.LogError(ex, "TopHolderDiscoveryWorker error"); }
// Run every 15 minutes
_logger.LogInformation("👥 Next top holder scan in 15 minutes.");
await Task.Delay(TimeSpan.FromMinutes(15), stoppingToken);
// Run every 5 minutes
_logger.LogInformation("🚀 Next top holder scan in 5 minutes.");
await Task.Delay(TimeSpan.FromMinutes(5), stoppingToken);
}
_logger.LogInformation("👥 TopHolderDiscoveryWorker stopped");
@@ -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)
{
var uniqueNewTrades = newTrades.GroupBy(tr => tr.PlatformTradeId).Select(g => g.First()).ToList();
var uniqueNewTrades = newTrades.GroupBy(tr => tr.PlatformTradeId, StringComparer.OrdinalIgnoreCase).Select(g => g.First()).ToList();
try {
await tradeRepo.AddRangeAsync(uniqueNewTrades, ct);
_statsService.TrackTradeActivity(trader.Platform, uniqueNewTrades.Count);
trader.TotalTrades += uniqueNewTrades.Count;
_logger.LogInformation("{Trader}: {New} new trades imported", trader.DisplayName, uniqueNewTrades.Count);
} catch (Exception ex) when (ex.ToString().Contains("Duplicate entry")) {
_logger.LogWarning("{Trader}: Skipping batch due to duplicates", trader.DisplayName);
} catch (Exception ex) when (ex.ToString().Contains("Duplicate entry") || (ex.InnerException?.Message.Contains("Duplicate entry") ?? false)) {
_statsService.TrackDuplicateError(trader.Platform, 1);
}
}
@@ -33,25 +33,26 @@ public class TraderAnalyticsWorker : BackgroundService
_logger.LogError(ex, "Error in TraderAnalyticsWorker");
}
_logger.LogInformation("TraderAnalyticsWorker sleeping for 12 hours...");
await Task.Delay(TimeSpan.FromHours(12), ct);
_logger.LogInformation("TraderAnalyticsWorker sleeping for 2 hours...");
await Task.Delay(TimeSpan.FromHours(2), ct);
}
}
private async Task RunAnalyticsAsync(CancellationToken ct)
{
using var scope = _services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var pnlEngine = scope.ServiceProvider.GetRequiredService<IPositionPnLEngine>();
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
var traderIds = await db.Trades
traderIds = await db.Trades
.Where(t => t.ExecutedAt >= cutoff30d)
.Select(t => t.TraderId)
.Distinct()
.ToListAsync(ct);
}
_logger.LogInformation("Found {Count} active traders to analyze", traderIds.Count);
@@ -59,6 +60,8 @@ public class TraderAnalyticsWorker : BackgroundService
{
try
{
using var traderScope = _services.CreateScope();
var pnlEngine = traderScope.ServiceProvider.GetRequiredService<IPositionPnLEngine>();
await pnlEngine.RecalculateTraderPositionsAsync(id, ct);
}
catch (Exception ex)