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:
Richard
2026-07-01 19:53:29 +02:00
commit afb251acfc
107 changed files with 9613 additions and 0 deletions
@@ -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");
}
}