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>
544 lines
34 KiB
C#
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");
|
|
}
|
|
}
|