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;
///
/// 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.
///
public class PollingWorker : BackgroundService
{
private static DateTime _lastDbError = DateTime.MinValue;
private readonly IServiceProvider _services;
private readonly IPlatformStatisticsService _statsService;
private readonly ILogger _logger;
public PollingWorker(IServiceProvider services, ILogger 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 tradersToProcess;
using (var scope = _services.CreateScope())
{
var repo = scope.ServiceProvider.GetRequiredService();
tradersToProcess = await repo.GetTradersForPollingAsync(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();
var tradeRepo = scope.ServiceProvider.GetRequiredService();
var marketRepo = scope.ServiceProvider.GetRequiredService();
var providers = scope.ServiceProvider.GetRequiredService>();
var rateLimiter = scope.ServiceProvider.GetRequiredService();
var trader = await traderRepo.GetByIdAsync(t.Id, stoppingToken);
if (trader == null || trader.IngestMode == IngestMode.SnapshotOnly) 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);
var validTrades = trades
.Where(t => !string.IsNullOrWhiteSpace(t.PlatformTradeId))
.ToList();
var updatedName = validTrades.FirstOrDefault(t => !string.IsNullOrEmpty(t.TransientDisplayName))?.TransientDisplayName;
if (!string.IsNullOrEmpty(updatedName) && !string.Equals(trader.DisplayName, updatedName, StringComparison.OrdinalIgnoreCase))
{
trader.DisplayName = updatedName;
await traderRepo.UpdateAsync(trader, stoppingToken);
}
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();
var fetchedTradeIds = validTrades.Select(tr => tr.PlatformTradeId).ToList();
var knownTradeIds = await tradeRepo.GetKnownPlatformTradeIdsAsync(trader.Platform, trader.Id, fetchedTradeIds, 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);
}
}
if (trader.IngestMode == IngestMode.Aggregated && newTrades.Count > 0)
{
// Only completed hours are aggregated+persisted; the current (growing)
// hour is deferred so aggregate rows stay immutable for the checkpoint
// engine. See TradeAggregation.AggregateCompletedHours.
newTrades = Predictalytics.Application.Services.TradeAggregation
.AggregateCompletedHours(newTrades, trader.Id, DateTime.UtcNow);
}
// ── Persist new trades ──
if (newTrades.Count > 0)
{
// Final deduplication of the batch itself
var uniqueNewTrades = newTrades
.GroupBy(tr => tr.PlatformTradeId, StringComparer.OrdinalIgnoreCase)
.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))
{
_statsService.TrackDuplicateError(trader.Platform, 1);
}
}
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 (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
_logger.LogInformation("Cancellation requested during polling, stopping batch.");
break;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error polling trader {Trader} on {Platform}",
trader.DisplayName, trader.Platform);
}
}
_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");
}
}