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; using Microsoft.Extensions.Configuration; using Microsoft.EntityFrameworkCore; namespace Predictalytics.Worker.Services; /// /// 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. /// public class TradeHistoryWorker : BackgroundService { private static DateTime _lastDbError = DateTime.MinValue; private readonly IServiceProvider _services; private readonly IPlatformStatisticsService _statsService; private readonly ILogger _logger; private const int CooldownHours = 12; private const int TradesPerFetch = 1000; private const int TradersPerCycle = 100; public TradeHistoryWorker(IServiceProvider services, ILogger 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); using (var scope = _services.CreateScope()) { var jobRepo = scope.ServiceProvider.GetRequiredService(); await jobRepo.ResetHungJobsAsync(stoppingToken); } await Task.Delay(15000, stoppingToken); while (!stoppingToken.IsCancellationRequested) { IReadOnlyList tradersToProcess = new List(); try { Domain.Entities.BackgroundJob? activeJob = null; using (var scope = _services.CreateScope()) { var jobRepo = scope.ServiceProvider.GetRequiredService(); activeJob = await jobRepo.GetNextPendingJobAsync(Predictalytics.Domain.Enums.JobType.DeepResync, stoppingToken); if (activeJob == null) { activeJob = await jobRepo.GetNextPendingJobAsync(Predictalytics.Domain.Enums.JobType.HistorySync, stoppingToken); } var repo = scope.ServiceProvider.GetRequiredService(); if (activeJob != null && activeJob.TraderId.HasValue) { var t = await repo.GetByIdAsync(activeJob.TraderId.Value, stoppingToken); if (t != null) { tradersToProcess = new[] { t }; activeJob.Status = Predictalytics.Domain.Enums.JobStatus.InProgress; activeJob.StartedAt = DateTime.UtcNow; await jobRepo.UpdateAsync(activeJob, stoppingToken); } } else { 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(); var marketFetchCache = new System.Collections.Concurrent.ConcurrentDictionary>(); await Parallel.ForEachAsync(tradersToProcess, new ParallelOptions { MaxDegreeOfParallelism = 5, CancellationToken = stoppingToken }, async (t, ct) => { try { 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, ct); if (trader == null) return; var provider = providers.FirstOrDefault(p => p.Platform == trader.Platform && p.IsImplemented); if (provider == null) return; var config = scope.ServiceProvider.GetRequiredService(); bool isEnabled = config.GetValue($"PlatformSettings:{provider.PlatformName}:EnableCrawling", provider.Platform == PlatformType.Polymarket); if (!isEnabled) { trader.LastPolledAt = DateTime.UtcNow; await traderRepo.UpdateAsync(trader, ct); return; } using var platformCtx = PlatformLogContext.Push(provider.PlatformName); await rateLimiter.WaitAsync(trader.Platform, ct); bool isDeepResync = activeJob != null && activeJob.JobType == Predictalytics.Domain.Enums.JobType.DeepResync; bool isInitial = !trader.IsInitialImportComplete || isDeepResync; bool isWeeklyBiopsy = trader.IngestMode == IngestMode.SnapshotOnly && (!trader.LastTradesUpdatedAt.HasValue || (DateTime.UtcNow - trader.LastTradesUpdatedAt.Value).TotalDays >= 7); bool skipTradeFetch = trader.IngestMode == IngestMode.SnapshotOnly && !isWeeklyBiopsy && !isDeepResync; _logger.LogInformation("{Trader}: Starting {Type} sync (Mode: {Mode})", trader.DisplayName, isDeepResync ? "DEEP RESYNC" : (isInitial ? "INITIAL FULL" : "INCREMENTAL"), trader.IngestMode); if (isDeepResync) { var db = scope.ServiceProvider.GetRequiredService(); await Microsoft.EntityFrameworkCore.RelationalDatabaseFacadeExtensions.ExecuteSqlRawAsync(db.Database, "DELETE FROM Trades WHERE TraderId = {0} AND PlatformTradeId LIKE 'COMPACT_%'", t.Id); await Microsoft.EntityFrameworkCore.RelationalDatabaseFacadeExtensions.ExecuteSqlRawAsync(db.Database, "DELETE FROM Trades WHERE TraderId = {0} AND PlatformTradeId LIKE 'AGG_%'", t.Id); await Microsoft.EntityFrameworkCore.RelationalDatabaseFacadeExtensions.ExecuteSqlRawAsync(db.Database, "DELETE FROM TraderPositions WHERE TraderId = {0}", t.Id); } IReadOnlyList fetchedTrades = new List(); if (!skipTradeFetch) { if (isDeepResync || isWeeklyBiopsy) { fetchedTrades = await provider.GetTradesPagedAsync(trader.PlatformUserId, 500, ct); } else { fetchedTrades = await provider.GetTraderTradesAsync(trader.PlatformUserId, TradesPerFetch, ct); } var validTrades = fetchedTrades.Where(tr => !string.IsNullOrWhiteSpace(tr.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, ct); } // Classification (IngestMode) var last500 = validTrades.OrderByDescending(t => t.ExecutedAt).Take(500).ToList(); if (last500.Count >= 50) { var minDate = last500.Min(x => x.ExecutedAt); var maxDate = last500.Max(x => x.ExecutedAt); var days = (maxDate - minDate).TotalDays; if (days > 0.01) { var tradesPerDay = last500.Count / days; var newMode = trader.IngestMode; if (tradesPerDay > 5000) newMode = IngestMode.SnapshotOnly; else if (tradesPerDay > 100 && trader.IngestMode == IngestMode.Full) newMode = IngestMode.Aggregated; else if (trader.IngestMode == IngestMode.SnapshotOnly && tradesPerDay < 2500) newMode = IngestMode.Aggregated; else if (trader.IngestMode == IngestMode.Aggregated && tradesPerDay < 50 && days >= 7) newMode = IngestMode.Full; if (newMode != trader.IngestMode) { _logger.LogInformation("{Trader}: IngestMode changing from {Old} to {New} (Trades/Day: {TPD:F1})", trader.DisplayName, trader.IngestMode, newMode, tradesPerDay); trader.IngestMode = newMode; await traderRepo.UpdateAsync(trader, ct); } } } // fetchedTrades are kept for weekly biopsy resolving, we will clear newTrades afterwards. } var newTrades = new List(); if (fetchedTrades.Count > 0) { var validTrades = fetchedTrades.Where(tr => !string.IsNullOrWhiteSpace(tr.PlatformTradeId)).ToList(); var fetchedTradeIds = validTrades.Select(tr => tr.PlatformTradeId).ToList(); var knownTradeIds = await tradeRepo.GetKnownPlatformTradeIdsAsync(trader.Platform, trader.Id, fetchedTradeIds, 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); } } foreach (var trade in validTrades) { if (knownTradeIds.Contains(trade.PlatformTradeId)) { 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 (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); } if (isWeeklyBiopsy && newTrades.Count > 0) { var db = scope.ServiceProvider.GetRequiredService(); var positions = await db.TraderPositions .Include(p => p.MarketOutcome).ThenInclude(o => o.Market) .Where(p => p.TraderId == trader.Id) .ToListAsync(ct); var computedTraits = Predictalytics.Application.Services.TraderTraitCalculator.Compute(trader, newTrades, positions); db.TraderTraits.RemoveRange(db.TraderTraits.Where(tt => tt.TraderId == trader.Id)); foreach (var (traitName, value) in computedTraits) { db.TraderTraits.Add(new Predictalytics.Domain.Entities.TraderTrait { TraderId = trader.Id, Trait = traitName, Value = value }); } await db.SaveChangesAsync(ct); newTrades.Clear(); // DO NOT persist trades! } if (trader.IngestMode == IngestMode.SnapshotOnly) { // "SnapshotOnly (Tier C): Stรผndlich: GetTraderPositionsAsync -> TraderPositions upserten" if (!trader.LastTradesUpdatedAt.HasValue || (DateTime.UtcNow - trader.LastTradesUpdatedAt.Value).TotalHours >= 1) { try { var positions = await provider.GetTraderPositionsAsync(trader.PlatformUserId, ct); decimal totalRealizedPnl = 0; decimal totalUnrealizedPnl = 0; if (positions != null && positions.Count > 0) { var db = scope.ServiceProvider.GetRequiredService(); var existingPos = db.TraderPositions.Where(tp => tp.TraderId == trader.Id).ToList(); foreach(var info in positions) { int? marketOutcomeId = null; if (!string.IsNullOrEmpty(info.AssetId)) { var outcome = await marketRepo.GetOutcomeByTokenIdAsync(info.AssetId, ct); if (outcome != null) { marketOutcomeId = outcome.Id; } } if (!marketOutcomeId.HasValue) continue; var ex = existingPos.FirstOrDefault(ep => ep.MarketOutcomeId == marketOutcomeId.Value); if (ex != null) { ex.SharesHeld = info.Size; ex.AvgCost = info.AveragePrice; ex.RealizedPnl = info.RealizedPnl; // Mapped cashPnl! } else { ex = new Predictalytics.Domain.Entities.TraderPosition { TraderId = trader.Id, MarketOutcomeId = marketOutcomeId.Value, SharesHeld = info.Size, AvgCost = info.AveragePrice, RealizedPnl = info.RealizedPnl }; db.TraderPositions.Add(ex); } // Calculate unrealized PnL: pos.SharesHeld * (currentPrice - pos.AvgCost) if (!string.IsNullOrEmpty(info.AssetId)) { var outcomeObj = await marketRepo.GetOutcomeByTokenIdAsync(info.AssetId, ct); if (outcomeObj != null) { var unrealized = ex.SharesHeld * (outcomeObj.CurrentPrice - ex.AvgCost); totalUnrealizedPnl += unrealized; } } totalRealizedPnl += ex.RealizedPnl; } await db.SaveChangesAsync(ct); } // Overall PnL decimal overallPnl = totalRealizedPnl + totalUnrealizedPnl; // Let's query Leaderboard PnLs decimal pnl30d = 0; decimal pnl7d = 0; decimal pnl24h = 0; var polyApi = scope.ServiceProvider.GetService(); if (polyApi != null) { try { var leaderboardAll = await polyApi.GetLeaderboardAsync(limit: 50, timePeriod: "ALL", ct: ct); var entryAll = leaderboardAll.FirstOrDefault(e => string.Equals(e.ProxyWallet, trader.PlatformUserId, StringComparison.OrdinalIgnoreCase) || string.Equals(e.UserName, trader.PlatformUserId, StringComparison.OrdinalIgnoreCase)); if (entryAll != null) { overallPnl = (decimal)entryAll.Pnl; } var leaderboard30d = await polyApi.GetLeaderboardAsync(limit: 50, timePeriod: "30D", ct: ct); var entry30d = leaderboard30d.FirstOrDefault(e => string.Equals(e.ProxyWallet, trader.PlatformUserId, StringComparison.OrdinalIgnoreCase) || string.Equals(e.UserName, trader.PlatformUserId, StringComparison.OrdinalIgnoreCase)); if (entry30d != null) { pnl30d = (decimal)entry30d.Pnl; } var leaderboard7d = await polyApi.GetLeaderboardAsync(limit: 50, timePeriod: "7D", ct: ct); var entry7d = leaderboard7d.FirstOrDefault(e => string.Equals(e.ProxyWallet, trader.PlatformUserId, StringComparison.OrdinalIgnoreCase) || string.Equals(e.UserName, trader.PlatformUserId, StringComparison.OrdinalIgnoreCase)); if (entry7d != null) { pnl7d = (decimal)entry7d.Pnl; } var leaderboard24h = await polyApi.GetLeaderboardAsync(limit: 50, timePeriod: "24H", ct: ct); var entry24h = leaderboard24h.FirstOrDefault(e => string.Equals(e.ProxyWallet, trader.PlatformUserId, StringComparison.OrdinalIgnoreCase) || string.Equals(e.UserName, trader.PlatformUserId, StringComparison.OrdinalIgnoreCase)); if (entry24h != null) { pnl24h = (decimal)entry24h.Pnl; } } catch (Exception ex) { _logger.LogWarning(ex, "Failed to fetch leaderboard PnLs for SnapshotOnly trader {TraderId}", trader.Id); } } var dbCtx = scope.ServiceProvider.GetRequiredService(); var analyticsObj = await dbCtx.TraderAnalytics.FirstOrDefaultAsync(a => a.TraderId == trader.Id, ct); if (analyticsObj == null) { analyticsObj = new Predictalytics.Domain.Entities.TraderAnalytics { TraderId = trader.Id }; dbCtx.TraderAnalytics.Add(analyticsObj); } analyticsObj.OverallPnL = overallPnl; analyticsObj.PnL30d = pnl30d; analyticsObj.PnL7d = pnl7d; analyticsObj.PnL24h = pnl24h; analyticsObj.LastCalculatedAt = DateTime.UtcNow; trader.TotalPnl = overallPnl; trader.LastAnalyzedAt = DateTime.UtcNow; // Save Daily Snapshot (Equity curve) var today = DateTime.UtcNow.Date; var snapshot = await dbCtx.TraderDailySnapshots.FirstOrDefaultAsync(s => s.TraderId == trader.Id && s.Date == today, ct); if (snapshot == null) { dbCtx.TraderDailySnapshots.Add(new Predictalytics.Domain.Entities.TraderDailySnapshot { TraderId = trader.Id, Date = today, TotalPnl = overallPnl, CurrentBalance = analyticsObj.CurrentBalance }); } else { snapshot.TotalPnl = overallPnl; } await dbCtx.SaveChangesAsync(ct); } catch (Exception ex) { _logger.LogWarning(ex, "Failed to fetch positions / leaderboard for SnapshotOnly trader {TraderId}", trader.Id); } } } if (newTrades.Count > 0) { 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); // Fable 5 recommendation: fetch /book directly after recent trades to get market overview var recentTrades = uniqueNewTrades.Where(t => (DateTime.UtcNow - t.ExecutedAt).TotalMinutes < 5).ToList(); foreach (var rt in recentTrades.GroupBy(t => new { t.MarketOutcomeId, t.AssetId })) { if (rt.Key.MarketOutcomeId.HasValue && rt.Key.MarketOutcomeId.Value > 0 && !string.IsNullOrEmpty(rt.Key.AssetId) && provider is Predictalytics.Infrastructure.Providers.Polymarket.PolymarketProvider polyProv) { try { var polyApi = scope.ServiceProvider.GetService(); if (polyApi != null) { var book = await polyApi.GetOrderBookAsync(rt.Key.AssetId, ct); if (book != null && book.Bids.Count > 0 && book.Asks.Count > 0) { decimal topBid = decimal.Parse(book.Bids[0].Price, System.Globalization.CultureInfo.InvariantCulture); decimal topAsk = decimal.Parse(book.Asks[0].Price, System.Globalization.CultureInfo.InvariantCulture); decimal midPrice = (topBid + topAsk) / 2m; var snapshot = new Domain.Entities.MarketOutcomePriceSnapshot { MarketOutcomeId = rt.Key.MarketOutcomeId.Value, Timestamp = DateTime.UtcNow, Price = midPrice }; await marketRepo.SavePriceSnapshotsAsync(rt.Key.MarketOutcomeId.Value, new[] { snapshot }, ct); } } } catch (Exception ex) { _logger.LogWarning(ex, "Failed to fetch /book for Outcome {OutcomeId}", rt.Key.MarketOutcomeId.Value); } } } } catch (Exception ex) when (ex.ToString().Contains("Duplicate entry") || (ex.InnerException?.Message.Contains("Duplicate entry") ?? false)) { _statsService.TrackDuplicateError(trader.Platform, 1); } } trader.IsInitialImportComplete = true; trader.LastTradesUpdatedAt = DateTime.UtcNow; trader.LastPolledAt = DateTime.UtcNow; if (isDeepResync) trader.LastAnalyzedAt = null; await traderRepo.UpdateAsync(trader, ct); if (activeJob != null && activeJob.TraderId == trader.Id) { using var jobScope = _services.CreateScope(); var updateJobRepo = jobScope.ServiceProvider.GetRequiredService(); activeJob.Status = Predictalytics.Domain.Enums.JobStatus.Completed; activeJob.CompletedAt = DateTime.UtcNow; await updateJobRepo.UpdateAsync(activeJob, ct); } } catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { /* App shutting down */ } catch (Exception ex) { _logger.LogError(ex, "Error syncing trader {TraderId}", t.Id); if (activeJob != null && activeJob.TraderId == t.Id) { try { using var jobScope = _services.CreateScope(); var updateJobRepo = jobScope.ServiceProvider.GetRequiredService(); activeJob.Status = Predictalytics.Domain.Enums.JobStatus.Failed; activeJob.CompletedAt = DateTime.UtcNow; activeJob.ErrorMessage = ex.Message; await updateJobRepo.UpdateAsync(activeJob, CancellationToken.None); } catch { /* Ignore secondary errors */ } } } }); } 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"); } if (tradersToProcess == null || tradersToProcess.Count == 0) { await Task.Delay(TimeSpan.FromMinutes(2), stoppingToken); } } _logger.LogInformation("๐Ÿ“œ TradeHistoryWorker stopped"); } }