Files
Predictalytics/src/Predictalytics.Worker/Services/TradeHistoryWorker.cs
T
RichardandClaude Opus 4.8 be1b90b556 Fix Aggregated-tier mutable-bucket vs checkpoint bug
The Aggregated ingest tier bucketed trades by hour incl. the current, still-growing
hour, then upserted via ON DUPLICATE KEY UPDATE (mutable rows). The PnL engine
checkpoints positions by row Id, so a bucket that keeps growing after being applied
had its later growth silently skipped (Id <= LastAppliedTradeId).

Extract the duplicated aggregation logic from PollingWorker + TradeHistoryWorker into
TradeAggregation.AggregateCompletedHours, which only aggregates COMPLETED hours; the
current hour is deferred (re-fetched next cycle) so every persisted aggregate is
immutable. +3 unit tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 08:57:46 +02:00

544 lines
34 KiB
C#

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;
/// <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);
using (var scope = _services.CreateScope())
{
var jobRepo = scope.ServiceProvider.GetRequiredService<IJobRepository>();
await jobRepo.ResetHungJobsAsync(stoppingToken);
}
await Task.Delay(15000, stoppingToken);
while (!stoppingToken.IsCancellationRequested)
{
IReadOnlyList<Domain.Entities.Trader> tradersToProcess = new List<Domain.Entities.Trader>();
try
{
Domain.Entities.BackgroundJob? activeJob = null;
using (var scope = _services.CreateScope())
{
var jobRepo = scope.ServiceProvider.GetRequiredService<IJobRepository>();
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<ITraderRepository>();
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<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;
var config = scope.ServiceProvider.GetRequiredService<Microsoft.Extensions.Configuration.IConfiguration>();
bool isEnabled = config.GetValue<bool>($"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<Predictalytics.Infrastructure.Data.AppDbContext>();
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<Domain.Entities.Trade> fetchedTrades = new List<Domain.Entities.Trade>();
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<Domain.Entities.Trade>();
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<Predictalytics.Infrastructure.Data.AppDbContext>();
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<Predictalytics.Infrastructure.Data.AppDbContext>();
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<Predictalytics.Infrastructure.Providers.Polymarket.PolymarketApiClient>();
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<Predictalytics.Infrastructure.Data.AppDbContext>();
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<Predictalytics.Infrastructure.Providers.Polymarket.PolymarketApiClient>();
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<IJobRepository>();
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<IJobRepository>();
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");
}
}