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:
@@ -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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user