Initial commit: Predictalytics solution

Clean Architecture .NET 8 solution (Domain/Application/Infrastructure/Api/Worker/WinFormsHost)
for analyzing Polymarket traders for copytrading/strategy-replication candidates.

Includes EF Core InitialBaseline migration and DB secrets removed from source/config
in preparation for version control.
This commit is contained in:
Richard
2026-07-01 19:53:29 +02:00
commit afb251acfc
107 changed files with 9613 additions and 0 deletions
@@ -0,0 +1,60 @@
using Predictalytics.Application.Interfaces;
using Predictalytics.Domain.Enums;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace Predictalytics.Worker.Services;
/// <summary>
/// Background service that periodically discovers new notable traders on platforms.
/// </summary>
public class DiscoveryWorker : BackgroundService
{
private readonly IServiceProvider _services;
private readonly IPlatformStatisticsService _statsService;
private readonly ILogger<DiscoveryWorker> _logger;
public DiscoveryWorker(IServiceProvider services, ILogger<DiscoveryWorker> logger, IPlatformStatisticsService statsService)
{ _services = services; _logger = logger; _statsService = statsService; }
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("🔍 DiscoveryWorker started");
await Task.Delay(10000, stoppingToken); // Delay to let other services initialize
while (!stoppingToken.IsCancellationRequested)
{
try
{
using var scope = _services.CreateScope();
var discovery = scope.ServiceProvider.GetRequiredService<IDiscoveryService>();
// Run discovery for all implemented platforms
foreach (var platform in new[] { PlatformType.Polymarket, PlatformType.Limitless })
{
try
{
if (stoppingToken.IsCancellationRequested) break;
_logger.LogWarning("[{Platform}] Starting discovery scan...", platform);
var discovered = await discovery.RunDiscoveryAsync(platform, stoppingToken);
_statsService.TrackTraderDiscovery(platform, discovered.Count);
_logger.LogWarning("[{Platform}] Discovery found {Count} traders", platform, discovered.Count);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error in discovery for platform {Platform}", platform);
}
}
}
catch (OperationCanceledException) { break; }
catch (Exception ex) { _logger.LogError(ex, "DiscoveryWorker error"); }
// Run discovery every 5 minutes
await Task.Delay(TimeSpan.FromMinutes(5), stoppingToken);
}
_logger.LogInformation("🔍 DiscoveryWorker stopped");
}
}
@@ -0,0 +1,128 @@
using Predictalytics.Application.Interfaces;
using Predictalytics.Domain.Enums;
using Predictalytics.Domain.Interfaces;
using Predictalytics.Infrastructure.Logging;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace Predictalytics.Worker.Services;
/// <summary>
/// Background service that periodically fetches recent trades for active markets
/// to discover new unknown traders. Updates markets starting with the oldest refreshed.
/// </summary>
public class MarketHistoryWorker : BackgroundService
{
private readonly IServiceProvider _services;
private readonly ILogger<MarketHistoryWorker> _logger;
private const int CooldownHours = 6;
private const int MarketsPerCycle = 10;
private const int TradesPerFetch = 100;
public MarketHistoryWorker(IServiceProvider services, ILogger<MarketHistoryWorker> logger)
{ _services = services; _logger = logger; }
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("🕵️ MarketHistoryWorker started (cooldown: {Hours}h)", CooldownHours);
await Task.Delay(25000, stoppingToken);
while (!stoppingToken.IsCancellationRequested)
{
try
{
using var scope = _services.CreateScope();
var marketRepo = scope.ServiceProvider.GetRequiredService<IMarketRepository>();
var traderRepo = scope.ServiceProvider.GetRequiredService<ITraderRepository>();
var providers = scope.ServiceProvider.GetRequiredService<IEnumerable<IPlatformProvider>>();
var rateLimiter = scope.ServiceProvider.GetRequiredService<IRateLimiter>();
var markets = await marketRepo.GetMarketsDueForTradeUpdateAsync(CooldownHours, MarketsPerCycle, stoppingToken);
if (markets.Count == 0)
{
_logger.LogDebug("🕵️ No markets due for trade history update. Sleeping.");
}
else
{
_logger.LogInformation("🕵️ Processing {Count} markets for trade history sync", markets.Count);
}
int totalDiscovered = 0;
foreach (var market in markets)
{
if (stoppingToken.IsCancellationRequested) break;
var provider = providers.FirstOrDefault(p => p.Platform == market.Platform && p.IsImplemented);
if (provider == null) continue;
try
{
using var platformCtx = PlatformLogContext.Push(provider.PlatformName);
await rateLimiter.WaitAsync(market.Platform, stoppingToken);
var trades = await provider.GetMarketTradesAsync(market.PlatformMarketId, TradesPerFetch, stoppingToken);
// Extract unique wallet addresses from TransientWallet [NotMapped].
// Providers set this during in-memory mapping; it is NOT stored in the DB.
var wallets = trades
.Select(t => t.TransientWallet)
.Where(w => !string.IsNullOrWhiteSpace(w))
.Select(w => w!)
.Distinct()
.ToList();
foreach (var wallet in wallets)
{
var existing = await traderRepo.GetByPlatformIdAsync(
market.Platform, wallet, stoppingToken);
if (existing == null)
{
var trader = new Domain.Entities.Trader
{
Platform = market.Platform,
PlatformUserId = wallet,
DisplayName = wallet.Length > 8 ? wallet[..8] + "..." : wallet, // Will be updated later
IsAutoDiscovered = true,
CreatedAt = DateTime.UtcNow
};
await traderRepo.AddAsync(trader, stoppingToken);
totalDiscovered++;
_logger.LogInformation("[{Platform}] Discovered trader ({Wallet}) from market trades: {Market}",
provider.PlatformName, wallet[..10] + "...",
market.Question.Length > 60 ? market.Question[..60] + "..." : market.Question);
}
}
// Update timestamps
market.LastTradesUpdatedAt = DateTime.UtcNow;
await marketRepo.UpdateAsync(market, stoppingToken);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error syncing trade history for market {Market} on {Platform}",
market.PlatformMarketId, market.Platform);
}
}
if (totalDiscovered > 0)
{
_logger.LogInformation("✅ Market history cycle complete. {Count} new traders discovered.", totalDiscovered);
}
}
catch (OperationCanceledException) { break; }
catch (Exception ex) { _logger.LogError(ex, "MarketHistoryWorker error"); }
// Run every 2 minutes
await Task.Delay(TimeSpan.FromMinutes(2), stoppingToken);
}
_logger.LogInformation("🕵️ MarketHistoryWorker stopped");
}
}
@@ -0,0 +1,111 @@
using Predictalytics.Application.Interfaces;
using Predictalytics.Domain.Enums;
using Predictalytics.Domain.Interfaces;
using Predictalytics.Infrastructure.Logging;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace Predictalytics.Worker.Services;
/// <summary>
/// Background service that periodically syncs market master data (including outcomes/token IDs)
/// from the Gamma API. This builds the lookup table needed to resolve trades to markets.
/// </summary>
public class MarketSyncWorker : BackgroundService
{
private static DateTime _lastDbError = DateTime.MinValue;
private readonly IServiceProvider _services;
private readonly IPlatformStatisticsService _statsService;
private readonly ILogger<MarketSyncWorker> _logger;
public MarketSyncWorker(IServiceProvider services, ILogger<MarketSyncWorker> logger, IPlatformStatisticsService statsService)
{ _services = services; _logger = logger; _statsService = statsService; }
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("🏛️ MarketSyncWorker started");
await Task.Delay(5000, stoppingToken); // Let DB initialize
while (!stoppingToken.IsCancellationRequested)
{
try
{
var rateLimiter = _services.GetRequiredService<IRateLimiter>();
var platformProviders = _services.GetRequiredService<IEnumerable<IPlatformProvider>>();
foreach (var p in platformProviders.Where(x => x.IsImplemented))
{
try
{
if (stoppingToken.IsCancellationRequested) break;
using var platformCtx = PlatformLogContext.Push(p.PlatformName);
int cycleTotalSynced = 0;
foreach (var includeClosed in new[] { false, true })
{
_logger.LogWarning("[{Platform}] Syncing markets (includeClosed={Closed})...", p.PlatformName, includeClosed);
int passSynced = 0;
int offset = 0;
const int batchSize = 1000;
while (!stoppingToken.IsCancellationRequested)
{
// Fresh scope per batch
using var scope = _services.CreateScope();
var marketRepo = scope.ServiceProvider.GetRequiredService<IMarketRepository>();
var provider = scope.ServiceProvider.GetRequiredService<IEnumerable<IPlatformProvider>>()
.First(x => x.Platform == p.Platform);
await rateLimiter.WaitAsync(provider.Platform, stoppingToken);
var markets = await provider.GetMarketsAsync(batchSize, offset.ToString(), includeClosed, stoppingToken);
if (markets.Count == 0) break;
await marketRepo.AddOrUpdateRangeAsync(markets, stoppingToken);
passSynced += markets.Count;
cycleTotalSynced += markets.Count;
_statsService.TrackMarketSync(provider.Platform, markets.Count);
offset += batchSize;
if (passSynced % 500 == 0)
_logger.LogWarning("[{Platform}] Synced {Total} markets so far (includeClosed={Closed})...", p.PlatformName, passSynced, includeClosed);
}
}
// Need a temporary scope for stats
using (var scope = _services.CreateScope())
{
var marketRepo = scope.ServiceProvider.GetRequiredService<IMarketRepository>();
var dbCount = await marketRepo.GetCountAsync(stoppingToken);
_logger.LogWarning("✅ [{Platform}] Market sync complete. {Synced} synced this cycle, {Total} total in DB",
p.PlatformName, cycleTotalSynced, dbCount);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error syncing markets for platform {Platform}", p.PlatformName);
}
}
}
catch (OperationCanceledException) { break; }
catch (Exception ex) when (ex.ToString().Contains("MySqlException") || ex.ToString().Contains("Connection"))
{
if (DateTime.UtcNow - _lastDbError > TimeSpan.FromMinutes(10))
{
_logger.LogWarning("⚠️ Database connection lost in MarketSyncWorker. Retrying in 30m. (Error: {Message})", ex.Message);
_lastDbError = DateTime.UtcNow;
}
}
catch (Exception ex) { _logger.LogError(ex, "MarketSyncWorker error"); }
// Run every 30 minutes
_logger.LogInformation("🏛️ Next market sync in 30 minutes.");
await Task.Delay(TimeSpan.FromMinutes(30), stoppingToken);
}
_logger.LogInformation("🏛️ MarketSyncWorker stopped");
}
}
@@ -0,0 +1,183 @@
using Predictalytics.Application.Interfaces;
using Predictalytics.Domain.Enums;
using Predictalytics.Domain.Interfaces;
using Predictalytics.Infrastructure.Logging;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace Predictalytics.Worker.Services;
/// <summary>
/// Background service that periodically polls tracked traders for new trades.
/// Properly deduplicates trades using the Platform+PlatformTradeId unique index.
/// Resolves trades to MarketOutcomes via AssetId → TokenId mapping.
/// </summary>
public class PollingWorker : BackgroundService
{
private static DateTime _lastDbError = DateTime.MinValue;
private readonly IServiceProvider _services;
private readonly IPlatformStatisticsService _statsService;
private readonly ILogger<PollingWorker> _logger;
public PollingWorker(IServiceProvider services, ILogger<PollingWorker> logger, IPlatformStatisticsService statsService)
{ _services = services; _logger = logger; _statsService = statsService; }
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogWarning("📡 PollingWorker started");
await Task.Delay(3000, stoppingToken);
while (!stoppingToken.IsCancellationRequested)
{
try
{
IReadOnlyList<Domain.Entities.Trader> tradersToProcess;
using (var scope = _services.CreateScope())
{
var repo = scope.ServiceProvider.GetRequiredService<ITraderRepository>();
tradersToProcess = await repo.GetAllAsync(take: 100, ct: stoppingToken);
}
_logger.LogWarning("📊 Polling {Count} traders...", tradersToProcess.Count);
foreach (var t in tradersToProcess)
{
if (stoppingToken.IsCancellationRequested) break;
using var scope = _services.CreateScope();
var traderRepo = scope.ServiceProvider.GetRequiredService<ITraderRepository>();
var tradeRepo = scope.ServiceProvider.GetRequiredService<ITradeRepository>();
var marketRepo = scope.ServiceProvider.GetRequiredService<IMarketRepository>();
var providers = scope.ServiceProvider.GetRequiredService<IEnumerable<IPlatformProvider>>();
var rateLimiter = scope.ServiceProvider.GetRequiredService<IRateLimiter>();
var trader = await traderRepo.GetByIdAsync(t.Id, stoppingToken);
if (trader == null) continue;
var provider = providers.FirstOrDefault(p => p.Platform == trader.Platform && p.IsImplemented);
if (provider == null) continue;
try
{
using var platformCtx = PlatformLogContext.Push(provider.PlatformName);
await rateLimiter.WaitAsync(trader.Platform, stoppingToken);
var trades = await provider.GetTraderTradesAsync(trader.PlatformUserId, 100, stoppingToken);
// ── Filter: skip trades with empty PlatformTradeId ──
var validTrades = trades
.Where(t => !string.IsNullOrWhiteSpace(t.PlatformTradeId))
.ToList();
if (validTrades.Count < trades.Count)
{
_logger.LogWarning("{Trader}: Skipped {Count} trades with empty PlatformTradeId",
trader.DisplayName, trades.Count - validTrades.Count);
}
// ── Deduplicate: check each trade against DB ──
var newTrades = new List<Domain.Entities.Trade>();
var knownTradeIds = await tradeRepo.GetKnownPlatformTradeIdsAsync(trader.Platform, trader.Id, stoppingToken);
foreach (var trade in validTrades)
{
if (!knownTradeIds.Contains(trade.PlatformTradeId))
{
trade.TraderId = trader.Id;
// Resolve MarketOutcome via AssetId → TokenId
if (!string.IsNullOrEmpty(trade.AssetId))
{
var outcome = await marketRepo.GetOutcomeByTokenIdAsync(trade.AssetId, stoppingToken);
// If not found locally, fetch the market from provider
if (outcome == null && !string.IsNullOrEmpty(trade.MarketId))
{
var newMarket = await provider.GetMarketAsync(trade.MarketId, stoppingToken);
if (newMarket != null)
{
await marketRepo.AddOrUpdateAsync(newMarket, stoppingToken);
outcome = await marketRepo.GetOutcomeByTokenIdAsync(trade.AssetId, stoppingToken);
}
}
if (outcome != null)
{
trade.MarketOutcomeId = outcome.Id;
trade.Outcome = outcome.Label;
// Attempt to resolve DbMarketId via outcome's parent market
if (outcome.Market != null)
trade.DbMarketId = outcome.Market.Id;
}
}
newTrades.Add(trade);
}
}
// ── Persist new trades ──
if (newTrades.Count > 0)
{
// Final deduplication of the batch itself
var uniqueNewTrades = newTrades
.GroupBy(tr => tr.PlatformTradeId)
.Select(g => g.First())
.ToList();
try
{
await tradeRepo.AddRangeAsync(uniqueNewTrades, stoppingToken);
_statsService.TrackTradeActivity(trader.Platform, uniqueNewTrades.Count);
trader.TotalTrades += uniqueNewTrades.Count;
_logger.LogInformation("{Trader}: {New} new trades (of {Total} fetched)",
trader.DisplayName, uniqueNewTrades.Count, validTrades.Count);
}
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);
}
}
else
{
_logger.LogDebug("{Trader}: No new trades (all {Count} already known)",
trader.DisplayName, validTrades.Count);
}
trader.LastPolledAt = DateTime.UtcNow;
await traderRepo.UpdateAsync(trader, stoppingToken);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error polling trader {Trader} on {Platform}",
trader.DisplayName, trader.Platform);
}
}
// Recalculate scores and evaluate alerts
using (var scope = _services.CreateScope())
{
var scoringService = scope.ServiceProvider.GetRequiredService<IScoringService>();
var alertService = scope.ServiceProvider.GetRequiredService<IAlertService>();
await scoringService.RecalculateAllScoresAsync(stoppingToken);
await alertService.EvaluateAlertsAsync(stoppingToken);
}
_logger.LogWarning("✅ Polling cycle complete. Next in 60s.");
}
catch (OperationCanceledException) { break; }
catch (Exception ex) when (ex.ToString().Contains("MySqlException") || ex.ToString().Contains("Connection"))
{
if (DateTime.UtcNow - _lastDbError > TimeSpan.FromMinutes(10))
{
_logger.LogWarning("⚠️ Database connection lost in PollingWorker. Retrying in 60s. (Error: {Message})", ex.Message);
_lastDbError = DateTime.UtcNow;
}
}
catch (Exception ex) { _logger.LogError(ex, "PollingWorker error"); }
await Task.Delay(TimeSpan.FromSeconds(60), stoppingToken);
}
_logger.LogInformation("📡 PollingWorker stopped");
}
}
@@ -0,0 +1,61 @@
using Predictalytics.Application.Interfaces;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace Predictalytics.Worker.Services;
/// <summary>
/// Background worker that reports platform statistics every 5 minutes.
/// </summary>
public class ReportingWorker : BackgroundService
{
private readonly IPlatformStatisticsService _statsService;
private readonly ILogger<ReportingWorker> _logger;
private const int IntervalMinutes = 5;
public ReportingWorker(IPlatformStatisticsService statsService, ILogger<ReportingWorker> logger)
{
_statsService = statsService;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
// Initial delay to align with the first 5-minute mark
await Task.Delay(TimeSpan.FromMinutes(IntervalMinutes), stoppingToken);
while (!stoppingToken.IsCancellationRequested)
{
try
{
ReportStats();
}
catch (Exception ex)
{
_logger.LogError(ex, "Error in ReportingWorker cycle");
}
await Task.Delay(TimeSpan.FromMinutes(IntervalMinutes), stoppingToken);
}
}
private void ReportStats()
{
var statsMap = _statsService.GetAndResetStats();
_logger.LogWarning("--- 📊 Platform Activity Report (Last {Min}m) ---", IntervalMinutes);
if (statsMap.Count == 0)
{
_logger.LogWarning("No activity detected across platforms.");
}
foreach (var (platform, stats) in statsMap)
{
_logger.LogWarning("[{Platform}] Markets: {M} | New Traders: {T} | Activities: {A}",
platform, stats.MarketsSynced, stats.TradersDiscovered, stats.TradesProcessed);
}
_logger.LogWarning("------------------------------------------------");
}
}
@@ -0,0 +1,105 @@
using Predictalytics.Application.Interfaces;
using Predictalytics.Domain.Enums;
using Predictalytics.Domain.Interfaces;
using Predictalytics.Infrastructure.Logging;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace Predictalytics.Worker.Services;
/// <summary>
/// Background service that discovers new traders by scanning the top holders
/// of active markets. Complements the leaderboard-based discovery by finding
/// traders who hold significant positions in currently traded markets.
/// </summary>
public class TopHolderDiscoveryWorker : BackgroundService
{
private readonly IServiceProvider _services;
private readonly ILogger<TopHolderDiscoveryWorker> _logger;
public TopHolderDiscoveryWorker(IServiceProvider services, ILogger<TopHolderDiscoveryWorker> logger)
{ _services = services; _logger = logger; }
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("👥 TopHolderDiscoveryWorker started");
await Task.Delay(20000, stoppingToken); // Let market sync populate markets first
while (!stoppingToken.IsCancellationRequested)
{
try
{
using var scope = _services.CreateScope();
var marketRepo = scope.ServiceProvider.GetRequiredService<IMarketRepository>();
var traderRepo = scope.ServiceProvider.GetRequiredService<ITraderRepository>();
var providers = scope.ServiceProvider.GetRequiredService<IEnumerable<IPlatformProvider>>();
var rateLimiter = scope.ServiceProvider.GetRequiredService<IRateLimiter>();
// Get top active markets by volume
var activeMarkets = await marketRepo.GetActiveAsync(20, stoppingToken);
_logger.LogInformation("👥 Scanning top holders across {Count} active markets", activeMarkets.Count);
int totalDiscovered = 0;
foreach (var market in activeMarkets)
{
if (stoppingToken.IsCancellationRequested) break;
var provider = providers.FirstOrDefault(p => p.Platform == market.Platform && p.IsImplemented);
if (provider == null) continue;
try
{
using var platformCtx = PlatformLogContext.Push(provider.PlatformName);
await rateLimiter.WaitAsync(market.Platform, stoppingToken);
var holders = await provider.GetTopHoldersAsync(market.PlatformMarketId, 10, stoppingToken);
foreach (var holder in holders)
{
// Skip empty wallet addresses
if (string.IsNullOrWhiteSpace(holder.PlatformUserId)) continue;
var existing = await traderRepo.GetByPlatformIdAsync(
market.Platform, holder.PlatformUserId, stoppingToken);
if (existing == null)
{
var trader = new Domain.Entities.Trader
{
Platform = market.Platform,
PlatformUserId = holder.PlatformUserId,
DisplayName = holder.DisplayName,
IsAutoDiscovered = true,
CreatedAt = DateTime.UtcNow
};
await traderRepo.AddAsync(trader, stoppingToken);
totalDiscovered++;
_logger.LogInformation("[{Platform}] Discovered trader {Name} ({Wallet}) from market: {Market}",
provider.PlatformName, holder.DisplayName, holder.PlatformUserId[..10] + "...",
market.Question.Length > 60 ? market.Question[..60] + "..." : market.Question);
}
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error scanning holders for market {Market}", market.PlatformMarketId);
}
}
_logger.LogInformation("✅ TopHolderDiscovery complete: {Count} new traders discovered", totalDiscovered);
}
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);
}
_logger.LogInformation("👥 TopHolderDiscoveryWorker stopped");
}
}
@@ -0,0 +1,190 @@
using Predictalytics.Application.Interfaces;
using Predictalytics.Domain.Enums;
using Predictalytics.Domain.Interfaces;
using Predictalytics.Infrastructure.Logging;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace Predictalytics.Worker.Services;
/// <summary>
/// Background service that loads and updates trade history for all tracked traders.
/// Respects a 6-hour cooldown per trader to avoid excessive API calls.
/// Links trades to MarketOutcomes via AssetId → TokenId mapping.
/// </summary>
public class TradeHistoryWorker : BackgroundService
{
private static DateTime _lastDbError = DateTime.MinValue;
private readonly IServiceProvider _services;
private readonly IPlatformStatisticsService _statsService;
private readonly ILogger<TradeHistoryWorker> _logger;
private const int CooldownHours = 12;
private const int TradesPerFetch = 1000;
private const int TradersPerCycle = 100;
public TradeHistoryWorker(IServiceProvider services, ILogger<TradeHistoryWorker> logger, IPlatformStatisticsService statsService)
{ _services = services; _logger = logger; _statsService = statsService; }
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("📜 TradeHistoryWorker started (update cooldown: {Hours}h)", CooldownHours);
await Task.Delay(15000, stoppingToken);
while (!stoppingToken.IsCancellationRequested)
{
try
{
IReadOnlyList<Domain.Entities.Trader> tradersToProcess;
using (var scope = _services.CreateScope())
{
var repo = scope.ServiceProvider.GetRequiredService<ITraderRepository>();
tradersToProcess = await repo.GetTradersDueForTradeUpdateAsync(CooldownHours, TradersPerCycle, stoppingToken);
}
if (tradersToProcess.Count == 0)
{
_logger.LogDebug("📜 No traders due for sync. Sleeping.");
}
else
{
_logger.LogInformation("📜 Processing {Count} traders for trade sync (Initial/Update)", tradersToProcess.Count);
}
var outcomeCache = new System.Collections.Concurrent.ConcurrentDictionary<string, Domain.Entities.MarketOutcome>();
var marketFetchCache = new System.Collections.Concurrent.ConcurrentDictionary<string, Task<Domain.Entities.Market?>>();
await Parallel.ForEachAsync(tradersToProcess, new ParallelOptions
{
MaxDegreeOfParallelism = 5,
CancellationToken = stoppingToken
}, async (t, ct) =>
{
try
{
using var scope = _services.CreateScope();
var traderRepo = scope.ServiceProvider.GetRequiredService<ITraderRepository>();
var tradeRepo = scope.ServiceProvider.GetRequiredService<ITradeRepository>();
var marketRepo = scope.ServiceProvider.GetRequiredService<IMarketRepository>();
var providers = scope.ServiceProvider.GetRequiredService<IEnumerable<IPlatformProvider>>();
var rateLimiter = scope.ServiceProvider.GetRequiredService<IRateLimiter>();
var trader = await traderRepo.GetByIdAsync(t.Id, ct);
if (trader == null) return;
var provider = providers.FirstOrDefault(p => p.Platform == trader.Platform && p.IsImplemented);
if (provider == null) return;
using var platformCtx = PlatformLogContext.Push(provider.PlatformName);
await rateLimiter.WaitAsync(trader.Platform, ct);
bool isInitial = !trader.IsInitialImportComplete;
_logger.LogInformation("{Trader}: Starting {Type} sync", trader.DisplayName, isInitial ? "INITIAL FULL" : "INCREMENTAL");
var fetchedTrades = await provider.GetTraderTradesAsync(trader.PlatformUserId, TradesPerFetch, ct);
var validTrades = fetchedTrades.Where(tr => !string.IsNullOrWhiteSpace(tr.PlatformTradeId)).ToList();
var knownTradeIds = await tradeRepo.GetKnownPlatformTradeIdsAsync(trader.Platform, trader.Id, ct);
// Collect all unique AssetIds we might need to resolve
var assetIdsToResolve = validTrades
.Where(tr => !knownTradeIds.Contains(tr.PlatformTradeId) || isInitial)
.Select(tr => tr.AssetId)
.Where(id => !string.IsNullOrEmpty(id))
.Distinct()
.ToList();
// Pre-fill local cache with bulk query
var missingAssetIds = assetIdsToResolve.Where(id => !outcomeCache.ContainsKey(id!)).ToList();
if (missingAssetIds.Count > 0)
{
var resolvedOutcomes = await marketRepo.GetOutcomesByTokenIdsAsync(missingAssetIds!, ct);
foreach (var o in resolvedOutcomes)
{
outcomeCache.TryAdd(o.TokenId, o);
}
}
var newTrades = new List<Domain.Entities.Trade>();
foreach (var trade in validTrades)
{
if (knownTradeIds.Contains(trade.PlatformTradeId))
{
if (!isInitial) break;
continue;
}
trade.TraderId = trader.Id;
if (!string.IsNullOrEmpty(trade.AssetId))
{
if (outcomeCache.TryGetValue(trade.AssetId, out var outcome))
{
trade.MarketOutcomeId = outcome.Id;
trade.Outcome = outcome.Label;
if (outcome.Market != null)
trade.DbMarketId = outcome.Market.Id;
}
else if (!string.IsNullOrEmpty(trade.MarketId))
{
// Fallback for missing outcomes: try to fetch market
var marketTask = marketFetchCache.GetOrAdd(trade.MarketId, _ => provider.GetMarketAsync(trade.MarketId, ct));
var newMarket = await marketTask;
if (newMarket != null)
{
await marketRepo.AddOrUpdateAsync(newMarket, ct);
var newOutcome = await marketRepo.GetOutcomeByTokenIdAsync(trade.AssetId, ct);
if (newOutcome != null)
{
outcomeCache.TryAdd(trade.AssetId, newOutcome);
trade.MarketOutcomeId = newOutcome.Id;
trade.Outcome = newOutcome.Label;
if (newOutcome.Market != null)
trade.DbMarketId = newOutcome.Market.Id;
}
}
}
}
newTrades.Add(trade);
}
if (newTrades.Count > 0)
{
var uniqueNewTrades = newTrades.GroupBy(tr => tr.PlatformTradeId).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);
}
}
trader.IsInitialImportComplete = true;
trader.LastTradesUpdatedAt = DateTime.UtcNow;
trader.LastPolledAt = DateTime.UtcNow;
await traderRepo.UpdateAsync(trader, ct);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { /* App shutting down */ }
catch (Exception ex) { _logger.LogError(ex, "Error syncing trader {TraderId}", t.Id); }
});
}
catch (OperationCanceledException) { break; }
catch (Exception ex) when (ex.ToString().Contains("MySqlException") || ex.ToString().Contains("Connection"))
{
if (DateTime.UtcNow - _lastDbError > TimeSpan.FromMinutes(10))
{
_logger.LogWarning("⚠️ Database connection lost in TradeHistoryWorker. Retrying in 2m. (Error: {Message})", ex.Message);
_lastDbError = DateTime.UtcNow;
}
}
catch (Exception ex) { _logger.LogError(ex, "TradeHistoryWorker error"); }
await Task.Delay(TimeSpan.FromMinutes(2), stoppingToken);
}
_logger.LogInformation("📜 TradeHistoryWorker stopped");
}
}
@@ -0,0 +1,97 @@
using Predictalytics.Domain.Interfaces;
using Predictalytics.Infrastructure.Logging;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace Predictalytics.Worker.Services;
/// <summary>
/// Background service that retroactively resolves missing MarketOutcomeIds for trades.
/// Scans for trades with NULL MarketOutcomeId and attempts to link them via AssetId.
/// </summary>
public class TradeReconciliationWorker : BackgroundService
{
private readonly IServiceProvider _services;
private readonly ILogger<TradeReconciliationWorker> _logger;
private const int BatchSize = 250;
private const int IntervalMinutes = 15;
public TradeReconciliationWorker(IServiceProvider services, ILogger<TradeReconciliationWorker> logger)
{
_services = services;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("🛠️ TradeReconciliationWorker started (batch: {Batch}, interval: {Min}m)", BatchSize, IntervalMinutes);
// Initial delay to let other workers settle
await Task.Delay(TimeSpan.FromSeconds(30), stoppingToken);
while (!stoppingToken.IsCancellationRequested)
{
try
{
int reconciledCount = await ReconcileBatchAsync(stoppingToken);
if (reconciledCount > 0)
{
_logger.LogInformation("✅ Reconciled {Count} orphaned trades", reconciledCount);
}
else
{
_logger.LogDebug("🛠️ No orphaned trades found for reconciliation");
}
}
catch (OperationCanceledException) { break; }
catch (Exception ex)
{
_logger.LogError(ex, "Error in TradeReconciliationWorker cycle");
}
await Task.Delay(TimeSpan.FromMinutes(IntervalMinutes), stoppingToken);
}
_logger.LogInformation("🛠️ TradeReconciliationWorker stopped");
}
private async Task<int> ReconcileBatchAsync(CancellationToken ct)
{
using var scope = _services.CreateScope();
var tradeRepo = scope.ServiceProvider.GetRequiredService<ITradeRepository>();
var marketRepo = scope.ServiceProvider.GetRequiredService<IMarketRepository>();
var orphanedTrades = await tradeRepo.GetOrphanedTradesAsync(BatchSize, ct);
if (orphanedTrades.Count == 0) return 0;
int count = 0;
foreach (var trade in orphanedTrades)
{
if (ct.IsCancellationRequested) break;
try
{
var outcome = await marketRepo.GetOutcomeByTokenIdAsync(trade.AssetId, ct);
if (outcome != null)
{
trade.MarketOutcomeId = outcome.Id;
trade.Outcome = outcome.Label;
if (outcome.Market != null)
trade.DbMarketId = outcome.Market.Id;
await tradeRepo.UpdateAsync(trade, ct);
count++;
}
}
catch (Exception ex)
{
_logger.LogWarning("Failed to reconcile trade {TradeId}: {Msg}", trade.Id, ex.Message);
}
}
return count;
}
}
@@ -0,0 +1,138 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.DependencyInjection;
using Predictalytics.Infrastructure.Data;
using Predictalytics.Domain.Entities;
namespace Predictalytics.Worker.Services;
public class TraderAnalyticsWorker : BackgroundService
{
private readonly IServiceProvider _services;
private readonly ILogger<TraderAnalyticsWorker> _logger;
public TraderAnalyticsWorker(IServiceProvider services, ILogger<TraderAnalyticsWorker> logger)
{
_services = services;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken ct)
{
_logger.LogInformation("TraderAnalyticsWorker starting...");
while (!ct.IsCancellationRequested)
{
try
{
await RunAnalyticsAsync(ct);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error in TraderAnalyticsWorker");
}
_logger.LogInformation("TraderAnalyticsWorker sleeping for 12 hours...");
await Task.Delay(TimeSpan.FromHours(12), ct);
}
}
private async Task RunAnalyticsAsync(CancellationToken ct)
{
using var scope = _services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var cutoff30d = DateTime.UtcNow.AddDays(-30);
// Find traders active in the last 30 days
var traderIds = await db.Trades
.Where(t => t.ExecutedAt >= cutoff30d)
.Select(t => t.TraderId)
.Distinct()
.ToListAsync(ct);
_logger.LogInformation("Found {Count} active traders to analyze", traderIds.Count);
foreach (var id in traderIds)
{
await UpdateTraderAnalyticsAsync(db, id, ct);
}
await db.SaveChangesAsync(ct);
_logger.LogInformation("Trader analytics update complete.");
}
private async Task UpdateTraderAnalyticsAsync(AppDbContext db, int traderId, CancellationToken ct)
{
var trades = await db.Trades.Where(t => t.TraderId == traderId).ToListAsync(ct);
if (!trades.Any()) return;
var analytics = await db.TraderAnalytics.FirstOrDefaultAsync(a => a.TraderId == traderId, ct);
if (analytics == null)
{
analytics = new TraderAnalytics { TraderId = traderId };
db.TraderAnalytics.Add(analytics);
}
analytics.LastCalculatedAt = DateTime.UtcNow;
// Simplified PnL calculation: Sum of Sells - Sum of Buys
// This is not perfect but a good starting point as requested.
// In a real scenario, we'd account for current market value of holdings.
analytics.OverallPnL = CalculatePnL(trades, null);
analytics.OverallWinRate = CalculateWinRate(trades, null);
analytics.PnL30d = CalculatePnL(trades, DateTime.UtcNow.AddDays(-30));
analytics.WinRate30d = CalculateWinRate(trades, DateTime.UtcNow.AddDays(-30));
analytics.PnL7d = CalculatePnL(trades, DateTime.UtcNow.AddDays(-7));
analytics.WinRate7d = CalculateWinRate(trades, DateTime.UtcNow.AddDays(-7));
analytics.PnL24h = CalculatePnL(trades, DateTime.UtcNow.AddHours(-24));
analytics.WinRate24h = CalculateWinRate(trades, DateTime.UtcNow.AddHours(-24));
// Update the trader record too for easy sorting
var trader = await db.Traders.FindAsync(new object[] { traderId }, ct);
if (trader != null)
{
trader.TotalPnl = analytics.OverallPnL;
trader.WinRate = analytics.OverallWinRate;
}
}
private decimal CalculatePnL(List<Trade> trades, DateTime? since)
{
var filtered = since.HasValue ? trades.Where(t => t.ExecutedAt >= since.Value) : trades;
// Very simplified: Sells - Buys
// Note: Real PnL should consider if the market resolved in their favor.
// For now, we use the raw trade amounts.
decimal pnl = 0;
foreach (var t in filtered)
{
if (t.Side == Predictalytics.Domain.Enums.TradeSide.Buy) pnl -= t.Amount;
else pnl += t.Amount;
}
return pnl;
}
private decimal CalculateWinRate(List<Trade> trades, DateTime? since)
{
var filtered = since.HasValue ? trades.Where(t => t.ExecutedAt >= since.Value).ToList() : trades;
if (!filtered.Any()) return 0;
// Simplified: A "win" is a Sell at a higher price than the average Buy price?
// Actually, without proper position tracking, this is hard.
// Let's assume a "win" is any trade that closed a position in profit.
// For now, let's just return a placeholder or implement a basic logic.
// Since we don't have resolution data easily linked here, we'll return 0 or a dummy.
// Wait, if MarketOutcome is resolved and they held that outcome, it's a win.
// Let's just use 0 for now to avoid misleading data, or
// if we have MarketOutcomeId and it's resolved, we can check.
return 0; // Placeholder until more complex logic is added
}
}
@@ -0,0 +1,103 @@
using Predictalytics.Domain.Interfaces;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace Predictalytics.Worker.Services;
public class TraderCleanupWorker : BackgroundService
{
private readonly IServiceProvider _services;
private readonly ILogger<TraderCleanupWorker> _logger;
private readonly TimeSpan _checkInterval = TimeSpan.FromHours(12);
public TraderCleanupWorker(IServiceProvider services, ILogger<TraderCleanupWorker> logger)
{
_services = services;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
try
{
await DoCleanupWorkAsync(stoppingToken);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error occurred executing TraderCleanupWorker.");
}
await Task.Delay(_checkInterval, stoppingToken);
}
}
private async Task DoCleanupWorkAsync(CancellationToken ct)
{
using var scope = _services.CreateScope();
var traderRepo = scope.ServiceProvider.GetRequiredService<ITraderRepository>();
var platformProviders = scope.ServiceProvider.GetRequiredService<IEnumerable<IPlatformProvider>>();
// Inactive since 1 year ago, or API error since 48 hours ago
var inactiveSince = DateTime.UtcNow.AddYears(-1);
var errorSince = DateTime.UtcNow.AddHours(-48);
var tradersToCleanup = await traderRepo.GetTradersForCleanupAsync(inactiveSince, errorSince, 50, ct);
if (tradersToCleanup.Count == 0) return;
_logger.LogInformation("Found {Count} traders for cleanup verification", tradersToCleanup.Count);
foreach (var trader in tradersToCleanup)
{
var provider = platformProviders.FirstOrDefault(p => p.Platform == trader.Platform);
if (provider == null || !provider.IsImplemented) continue;
_logger.LogInformation("Verifying trader {PlatformUserId} for cleanup...", trader.PlatformUserId);
try
{
var recentTrades = await provider.GetTraderTradesAsync(trader.PlatformUserId, 1, ct);
if (recentTrades.Count > 0)
{
// Trader is still active and API is working
trader.LastApiErrorAt = null;
trader.LastPolledAt = DateTime.UtcNow;
await traderRepo.UpdateAsync(trader, ct);
_logger.LogInformation("Trader {PlatformUserId} is still active. Resetting error state.", trader.PlatformUserId);
}
else
{
// Check if positions API also returns nothing/error
var positions = await provider.GetTraderPositionsAsync(trader.PlatformUserId, ct);
if (positions.Count > 0)
{
trader.LastApiErrorAt = null;
trader.LastPolledAt = DateTime.UtcNow;
await traderRepo.UpdateAsync(trader, ct);
_logger.LogInformation("Trader {PlatformUserId} has open positions. Resetting error state.", trader.PlatformUserId);
}
else
{
// Truly inactive or deleted
_logger.LogWarning("Trader {PlatformUserId} verified as inactive/deleted. Deleting from database.", trader.PlatformUserId);
await traderRepo.DeleteAsync(trader.Id, ct);
}
}
}
catch (Exception ex)
{
// If API throws an exception again, it might be a confirmed 404
// We assume persistent error means deleted account
_logger.LogWarning(ex, "API error fetching {PlatformUserId} during cleanup. Deleting trader.", trader.PlatformUserId);
await traderRepo.DeleteAsync(trader.Id, ct);
}
// Small delay to prevent API spam
await Task.Delay(1000, ct);
}
}
}