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,183 @@
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 periodically polls tracked traders for new trades.
/// Properly deduplicates trades using the Platform+PlatformTradeId unique index.
/// Resolves trades to MarketOutcomes via AssetId → TokenId mapping.
/// </summary>
public class PollingWorker : BackgroundService
{
private static DateTime _lastDbError = DateTime.MinValue;
private readonly IServiceProvider _services;
private readonly IPlatformStatisticsService _statsService;
private readonly ILogger<PollingWorker> _logger;
public PollingWorker(IServiceProvider services, ILogger<PollingWorker> logger, IPlatformStatisticsService statsService)
{ _services = services; _logger = logger; _statsService = statsService; }
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogWarning("📡 PollingWorker started");
await Task.Delay(3000, stoppingToken);
while (!stoppingToken.IsCancellationRequested)
{
try
{
IReadOnlyList<Domain.Entities.Trader> tradersToProcess;
using (var scope = _services.CreateScope())
{
var repo = scope.ServiceProvider.GetRequiredService<ITraderRepository>();
tradersToProcess = await repo.GetAllAsync(take: 100, ct: stoppingToken);
}
_logger.LogWarning("📊 Polling {Count} traders...", tradersToProcess.Count);
foreach (var t in tradersToProcess)
{
if (stoppingToken.IsCancellationRequested) break;
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, stoppingToken);
if (trader == null) continue;
var provider = providers.FirstOrDefault(p => p.Platform == trader.Platform && p.IsImplemented);
if (provider == null) continue;
try
{
using var platformCtx = PlatformLogContext.Push(provider.PlatformName);
await rateLimiter.WaitAsync(trader.Platform, stoppingToken);
var trades = await provider.GetTraderTradesAsync(trader.PlatformUserId, 100, stoppingToken);
// ── Filter: skip trades with empty PlatformTradeId ──
var validTrades = trades
.Where(t => !string.IsNullOrWhiteSpace(t.PlatformTradeId))
.ToList();
if (validTrades.Count < trades.Count)
{
_logger.LogWarning("{Trader}: Skipped {Count} trades with empty PlatformTradeId",
trader.DisplayName, trades.Count - validTrades.Count);
}
// ── Deduplicate: check each trade against DB ──
var newTrades = new List<Domain.Entities.Trade>();
var knownTradeIds = await tradeRepo.GetKnownPlatformTradeIdsAsync(trader.Platform, trader.Id, stoppingToken);
foreach (var trade in validTrades)
{
if (!knownTradeIds.Contains(trade.PlatformTradeId))
{
trade.TraderId = trader.Id;
// Resolve MarketOutcome via AssetId → TokenId
if (!string.IsNullOrEmpty(trade.AssetId))
{
var outcome = await marketRepo.GetOutcomeByTokenIdAsync(trade.AssetId, stoppingToken);
// If not found locally, fetch the market from provider
if (outcome == null && !string.IsNullOrEmpty(trade.MarketId))
{
var newMarket = await provider.GetMarketAsync(trade.MarketId, stoppingToken);
if (newMarket != null)
{
await marketRepo.AddOrUpdateAsync(newMarket, stoppingToken);
outcome = await marketRepo.GetOutcomeByTokenIdAsync(trade.AssetId, stoppingToken);
}
}
if (outcome != null)
{
trade.MarketOutcomeId = outcome.Id;
trade.Outcome = outcome.Label;
// Attempt to resolve DbMarketId via outcome's parent market
if (outcome.Market != null)
trade.DbMarketId = outcome.Market.Id;
}
}
newTrades.Add(trade);
}
}
// ── Persist new trades ──
if (newTrades.Count > 0)
{
// Final deduplication of the batch itself
var uniqueNewTrades = newTrades
.GroupBy(tr => tr.PlatformTradeId)
.Select(g => g.First())
.ToList();
try
{
await tradeRepo.AddRangeAsync(uniqueNewTrades, stoppingToken);
_statsService.TrackTradeActivity(trader.Platform, uniqueNewTrades.Count);
trader.TotalTrades += uniqueNewTrades.Count;
_logger.LogInformation("{Trader}: {New} new trades (of {Total} fetched)",
trader.DisplayName, uniqueNewTrades.Count, validTrades.Count);
}
catch (Exception ex) when (ex.ToString().Contains("Duplicate entry") || (ex.InnerException?.Message.Contains("Duplicate entry") ?? false))
{
_logger.LogWarning("{Trader}: Skipping batch due to duplicate entries (likely already imported)", trader.DisplayName);
}
}
else
{
_logger.LogDebug("{Trader}: No new trades (all {Count} already known)",
trader.DisplayName, validTrades.Count);
}
trader.LastPolledAt = DateTime.UtcNow;
await traderRepo.UpdateAsync(trader, stoppingToken);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error polling trader {Trader} on {Platform}",
trader.DisplayName, trader.Platform);
}
}
// Recalculate scores and evaluate alerts
using (var scope = _services.CreateScope())
{
var scoringService = scope.ServiceProvider.GetRequiredService<IScoringService>();
var alertService = scope.ServiceProvider.GetRequiredService<IAlertService>();
await scoringService.RecalculateAllScoresAsync(stoppingToken);
await alertService.EvaluateAlertsAsync(stoppingToken);
}
_logger.LogWarning("✅ Polling cycle complete. Next in 60s.");
}
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 PollingWorker. Retrying in 60s. (Error: {Message})", ex.Message);
_lastDbError = DateTime.UtcNow;
}
}
catch (Exception ex) { _logger.LogError(ex, "PollingWorker error"); }
await Task.Delay(TimeSpan.FromSeconds(60), stoppingToken);
}
_logger.LogInformation("📡 PollingWorker stopped");
}
}