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,60 @@
|
||||
using Predictalytics.Application.Interfaces;
|
||||
using Predictalytics.Domain.Enums;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Predictalytics.Worker.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Background service that periodically discovers new notable traders on platforms.
|
||||
/// </summary>
|
||||
public class DiscoveryWorker : BackgroundService
|
||||
{
|
||||
private readonly IServiceProvider _services;
|
||||
private readonly IPlatformStatisticsService _statsService;
|
||||
private readonly ILogger<DiscoveryWorker> _logger;
|
||||
|
||||
public DiscoveryWorker(IServiceProvider services, ILogger<DiscoveryWorker> logger, IPlatformStatisticsService statsService)
|
||||
{ _services = services; _logger = logger; _statsService = statsService; }
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
_logger.LogInformation("🔍 DiscoveryWorker started");
|
||||
await Task.Delay(10000, stoppingToken); // Delay to let other services initialize
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var scope = _services.CreateScope();
|
||||
var discovery = scope.ServiceProvider.GetRequiredService<IDiscoveryService>();
|
||||
|
||||
// Run discovery for all implemented platforms
|
||||
foreach (var platform in new[] { PlatformType.Polymarket, PlatformType.Limitless })
|
||||
{
|
||||
try
|
||||
{
|
||||
if (stoppingToken.IsCancellationRequested) break;
|
||||
|
||||
_logger.LogWarning("[{Platform}] Starting discovery scan...", platform);
|
||||
var discovered = await discovery.RunDiscoveryAsync(platform, stoppingToken);
|
||||
_statsService.TrackTraderDiscovery(platform, discovered.Count);
|
||||
_logger.LogWarning("[{Platform}] Discovery found {Count} traders", platform, discovered.Count);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error in discovery for platform {Platform}", platform);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) { break; }
|
||||
catch (Exception ex) { _logger.LogError(ex, "DiscoveryWorker error"); }
|
||||
|
||||
// Run discovery every 5 minutes
|
||||
await Task.Delay(TimeSpan.FromMinutes(5), stoppingToken);
|
||||
}
|
||||
|
||||
_logger.LogInformation("🔍 DiscoveryWorker stopped");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
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 fetches recent trades for active markets
|
||||
/// to discover new unknown traders. Updates markets starting with the oldest refreshed.
|
||||
/// </summary>
|
||||
public class MarketHistoryWorker : BackgroundService
|
||||
{
|
||||
private readonly IServiceProvider _services;
|
||||
private readonly ILogger<MarketHistoryWorker> _logger;
|
||||
|
||||
private const int CooldownHours = 6;
|
||||
private const int MarketsPerCycle = 10;
|
||||
private const int TradesPerFetch = 100;
|
||||
|
||||
public MarketHistoryWorker(IServiceProvider services, ILogger<MarketHistoryWorker> logger)
|
||||
{ _services = services; _logger = logger; }
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
_logger.LogInformation("🕵️ MarketHistoryWorker started (cooldown: {Hours}h)", CooldownHours);
|
||||
await Task.Delay(25000, stoppingToken);
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var scope = _services.CreateScope();
|
||||
var marketRepo = scope.ServiceProvider.GetRequiredService<IMarketRepository>();
|
||||
var traderRepo = scope.ServiceProvider.GetRequiredService<ITraderRepository>();
|
||||
var providers = scope.ServiceProvider.GetRequiredService<IEnumerable<IPlatformProvider>>();
|
||||
var rateLimiter = scope.ServiceProvider.GetRequiredService<IRateLimiter>();
|
||||
|
||||
var markets = await marketRepo.GetMarketsDueForTradeUpdateAsync(CooldownHours, MarketsPerCycle, stoppingToken);
|
||||
|
||||
if (markets.Count == 0)
|
||||
{
|
||||
_logger.LogDebug("🕵️ No markets due for trade history update. Sleeping.");
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogInformation("🕵️ Processing {Count} markets for trade history sync", markets.Count);
|
||||
}
|
||||
|
||||
int totalDiscovered = 0;
|
||||
|
||||
foreach (var market in markets)
|
||||
{
|
||||
if (stoppingToken.IsCancellationRequested) break;
|
||||
|
||||
var provider = providers.FirstOrDefault(p => p.Platform == market.Platform && p.IsImplemented);
|
||||
if (provider == null) continue;
|
||||
|
||||
try
|
||||
{
|
||||
using var platformCtx = PlatformLogContext.Push(provider.PlatformName);
|
||||
await rateLimiter.WaitAsync(market.Platform, stoppingToken);
|
||||
|
||||
var trades = await provider.GetMarketTradesAsync(market.PlatformMarketId, TradesPerFetch, stoppingToken);
|
||||
|
||||
// Extract unique wallet addresses from TransientWallet [NotMapped].
|
||||
// Providers set this during in-memory mapping; it is NOT stored in the DB.
|
||||
var wallets = trades
|
||||
.Select(t => t.TransientWallet)
|
||||
.Where(w => !string.IsNullOrWhiteSpace(w))
|
||||
.Select(w => w!)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
foreach (var wallet in wallets)
|
||||
{
|
||||
var existing = await traderRepo.GetByPlatformIdAsync(
|
||||
market.Platform, wallet, stoppingToken);
|
||||
|
||||
if (existing == null)
|
||||
{
|
||||
var trader = new Domain.Entities.Trader
|
||||
{
|
||||
Platform = market.Platform,
|
||||
PlatformUserId = wallet,
|
||||
DisplayName = wallet.Length > 8 ? wallet[..8] + "..." : wallet, // Will be updated later
|
||||
IsAutoDiscovered = true,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
await traderRepo.AddAsync(trader, stoppingToken);
|
||||
totalDiscovered++;
|
||||
|
||||
_logger.LogInformation("[{Platform}] Discovered trader ({Wallet}) from market trades: {Market}",
|
||||
provider.PlatformName, wallet[..10] + "...",
|
||||
market.Question.Length > 60 ? market.Question[..60] + "..." : market.Question);
|
||||
}
|
||||
}
|
||||
|
||||
// Update timestamps
|
||||
market.LastTradesUpdatedAt = DateTime.UtcNow;
|
||||
await marketRepo.UpdateAsync(market, stoppingToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error syncing trade history for market {Market} on {Platform}",
|
||||
market.PlatformMarketId, market.Platform);
|
||||
}
|
||||
}
|
||||
|
||||
if (totalDiscovered > 0)
|
||||
{
|
||||
_logger.LogInformation("✅ Market history cycle complete. {Count} new traders discovered.", totalDiscovered);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) { break; }
|
||||
catch (Exception ex) { _logger.LogError(ex, "MarketHistoryWorker error"); }
|
||||
|
||||
// Run every 2 minutes
|
||||
await Task.Delay(TimeSpan.FromMinutes(2), stoppingToken);
|
||||
}
|
||||
|
||||
_logger.LogInformation("🕵️ MarketHistoryWorker stopped");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
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 syncs market master data (including outcomes/token IDs)
|
||||
/// from the Gamma API. This builds the lookup table needed to resolve trades to markets.
|
||||
/// </summary>
|
||||
public class MarketSyncWorker : BackgroundService
|
||||
{
|
||||
private static DateTime _lastDbError = DateTime.MinValue;
|
||||
private readonly IServiceProvider _services;
|
||||
private readonly IPlatformStatisticsService _statsService;
|
||||
private readonly ILogger<MarketSyncWorker> _logger;
|
||||
|
||||
public MarketSyncWorker(IServiceProvider services, ILogger<MarketSyncWorker> logger, IPlatformStatisticsService statsService)
|
||||
{ _services = services; _logger = logger; _statsService = statsService; }
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
_logger.LogInformation("🏛️ MarketSyncWorker started");
|
||||
await Task.Delay(5000, stoppingToken); // Let DB initialize
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
var rateLimiter = _services.GetRequiredService<IRateLimiter>();
|
||||
var platformProviders = _services.GetRequiredService<IEnumerable<IPlatformProvider>>();
|
||||
|
||||
foreach (var p in platformProviders.Where(x => x.IsImplemented))
|
||||
{
|
||||
try
|
||||
{
|
||||
if (stoppingToken.IsCancellationRequested) break;
|
||||
|
||||
using var platformCtx = PlatformLogContext.Push(p.PlatformName);
|
||||
int cycleTotalSynced = 0;
|
||||
foreach (var includeClosed in new[] { false, true })
|
||||
{
|
||||
_logger.LogWarning("[{Platform}] Syncing markets (includeClosed={Closed})...", p.PlatformName, includeClosed);
|
||||
|
||||
int passSynced = 0;
|
||||
int offset = 0;
|
||||
const int batchSize = 1000;
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
// Fresh scope per batch
|
||||
using var scope = _services.CreateScope();
|
||||
var marketRepo = scope.ServiceProvider.GetRequiredService<IMarketRepository>();
|
||||
var provider = scope.ServiceProvider.GetRequiredService<IEnumerable<IPlatformProvider>>()
|
||||
.First(x => x.Platform == p.Platform);
|
||||
|
||||
await rateLimiter.WaitAsync(provider.Platform, stoppingToken);
|
||||
var markets = await provider.GetMarketsAsync(batchSize, offset.ToString(), includeClosed, stoppingToken);
|
||||
|
||||
if (markets.Count == 0) break;
|
||||
|
||||
await marketRepo.AddOrUpdateRangeAsync(markets, stoppingToken);
|
||||
|
||||
passSynced += markets.Count;
|
||||
cycleTotalSynced += markets.Count;
|
||||
_statsService.TrackMarketSync(provider.Platform, markets.Count);
|
||||
offset += batchSize;
|
||||
|
||||
if (passSynced % 500 == 0)
|
||||
_logger.LogWarning("[{Platform}] Synced {Total} markets so far (includeClosed={Closed})...", p.PlatformName, passSynced, includeClosed);
|
||||
}
|
||||
}
|
||||
|
||||
// Need a temporary scope for stats
|
||||
using (var scope = _services.CreateScope())
|
||||
{
|
||||
var marketRepo = scope.ServiceProvider.GetRequiredService<IMarketRepository>();
|
||||
var dbCount = await marketRepo.GetCountAsync(stoppingToken);
|
||||
_logger.LogWarning("✅ [{Platform}] Market sync complete. {Synced} synced this cycle, {Total} total in DB",
|
||||
p.PlatformName, cycleTotalSynced, dbCount);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error syncing markets for platform {Platform}", p.PlatformName);
|
||||
}
|
||||
}
|
||||
}
|
||||
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 MarketSyncWorker. Retrying in 30m. (Error: {Message})", ex.Message);
|
||||
_lastDbError = DateTime.UtcNow;
|
||||
}
|
||||
}
|
||||
catch (Exception ex) { _logger.LogError(ex, "MarketSyncWorker error"); }
|
||||
|
||||
// Run every 30 minutes
|
||||
_logger.LogInformation("🏛️ Next market sync in 30 minutes.");
|
||||
await Task.Delay(TimeSpan.FromMinutes(30), stoppingToken);
|
||||
}
|
||||
|
||||
_logger.LogInformation("🏛️ MarketSyncWorker stopped");
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
using Predictalytics.Application.Interfaces;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Predictalytics.Worker.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Background worker that reports platform statistics every 5 minutes.
|
||||
/// </summary>
|
||||
public class ReportingWorker : BackgroundService
|
||||
{
|
||||
private readonly IPlatformStatisticsService _statsService;
|
||||
private readonly ILogger<ReportingWorker> _logger;
|
||||
private const int IntervalMinutes = 5;
|
||||
|
||||
public ReportingWorker(IPlatformStatisticsService statsService, ILogger<ReportingWorker> logger)
|
||||
{
|
||||
_statsService = statsService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
// Initial delay to align with the first 5-minute mark
|
||||
await Task.Delay(TimeSpan.FromMinutes(IntervalMinutes), stoppingToken);
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
ReportStats();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error in ReportingWorker cycle");
|
||||
}
|
||||
|
||||
await Task.Delay(TimeSpan.FromMinutes(IntervalMinutes), stoppingToken);
|
||||
}
|
||||
}
|
||||
|
||||
private void ReportStats()
|
||||
{
|
||||
var statsMap = _statsService.GetAndResetStats();
|
||||
|
||||
_logger.LogWarning("--- 📊 Platform Activity Report (Last {Min}m) ---", IntervalMinutes);
|
||||
|
||||
if (statsMap.Count == 0)
|
||||
{
|
||||
_logger.LogWarning("No activity detected across platforms.");
|
||||
}
|
||||
|
||||
foreach (var (platform, stats) in statsMap)
|
||||
{
|
||||
_logger.LogWarning("[{Platform}] Markets: {M} | New Traders: {T} | Activities: {A}",
|
||||
platform, stats.MarketsSynced, stats.TradersDiscovered, stats.TradesProcessed);
|
||||
}
|
||||
|
||||
_logger.LogWarning("------------------------------------------------");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
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 discovers new traders by scanning the top holders
|
||||
/// of active markets. Complements the leaderboard-based discovery by finding
|
||||
/// traders who hold significant positions in currently traded markets.
|
||||
/// </summary>
|
||||
public class TopHolderDiscoveryWorker : BackgroundService
|
||||
{
|
||||
private readonly IServiceProvider _services;
|
||||
private readonly ILogger<TopHolderDiscoveryWorker> _logger;
|
||||
|
||||
public TopHolderDiscoveryWorker(IServiceProvider services, ILogger<TopHolderDiscoveryWorker> logger)
|
||||
{ _services = services; _logger = logger; }
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
_logger.LogInformation("👥 TopHolderDiscoveryWorker started");
|
||||
await Task.Delay(20000, stoppingToken); // Let market sync populate markets first
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var scope = _services.CreateScope();
|
||||
var marketRepo = scope.ServiceProvider.GetRequiredService<IMarketRepository>();
|
||||
var traderRepo = scope.ServiceProvider.GetRequiredService<ITraderRepository>();
|
||||
var providers = scope.ServiceProvider.GetRequiredService<IEnumerable<IPlatformProvider>>();
|
||||
var rateLimiter = scope.ServiceProvider.GetRequiredService<IRateLimiter>();
|
||||
|
||||
// Get top active markets by volume
|
||||
var activeMarkets = await marketRepo.GetActiveAsync(20, stoppingToken);
|
||||
_logger.LogInformation("👥 Scanning top holders across {Count} active markets", activeMarkets.Count);
|
||||
|
||||
int totalDiscovered = 0;
|
||||
|
||||
foreach (var market in activeMarkets)
|
||||
{
|
||||
if (stoppingToken.IsCancellationRequested) break;
|
||||
|
||||
var provider = providers.FirstOrDefault(p => p.Platform == market.Platform && p.IsImplemented);
|
||||
if (provider == null) continue;
|
||||
|
||||
try
|
||||
{
|
||||
using var platformCtx = PlatformLogContext.Push(provider.PlatformName);
|
||||
await rateLimiter.WaitAsync(market.Platform, stoppingToken);
|
||||
|
||||
var holders = await provider.GetTopHoldersAsync(market.PlatformMarketId, 10, stoppingToken);
|
||||
|
||||
foreach (var holder in holders)
|
||||
{
|
||||
// Skip empty wallet addresses
|
||||
if (string.IsNullOrWhiteSpace(holder.PlatformUserId)) continue;
|
||||
|
||||
var existing = await traderRepo.GetByPlatformIdAsync(
|
||||
market.Platform, holder.PlatformUserId, stoppingToken);
|
||||
|
||||
if (existing == null)
|
||||
{
|
||||
var trader = new Domain.Entities.Trader
|
||||
{
|
||||
Platform = market.Platform,
|
||||
PlatformUserId = holder.PlatformUserId,
|
||||
DisplayName = holder.DisplayName,
|
||||
IsAutoDiscovered = true,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
await traderRepo.AddAsync(trader, stoppingToken);
|
||||
totalDiscovered++;
|
||||
|
||||
_logger.LogInformation("[{Platform}] Discovered trader {Name} ({Wallet}) from market: {Market}",
|
||||
provider.PlatformName, holder.DisplayName, holder.PlatformUserId[..10] + "...",
|
||||
market.Question.Length > 60 ? market.Question[..60] + "..." : market.Question);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error scanning holders for market {Market}", market.PlatformMarketId);
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("✅ TopHolderDiscovery complete: {Count} new traders discovered", totalDiscovered);
|
||||
}
|
||||
catch (OperationCanceledException) { break; }
|
||||
catch (Exception ex) { _logger.LogError(ex, "TopHolderDiscoveryWorker error"); }
|
||||
|
||||
// Run every 15 minutes
|
||||
_logger.LogInformation("👥 Next top holder scan in 15 minutes.");
|
||||
await Task.Delay(TimeSpan.FromMinutes(15), stoppingToken);
|
||||
}
|
||||
|
||||
_logger.LogInformation("👥 TopHolderDiscoveryWorker stopped");
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
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 retroactively resolves missing MarketOutcomeIds for trades.
|
||||
/// Scans for trades with NULL MarketOutcomeId and attempts to link them via AssetId.
|
||||
/// </summary>
|
||||
public class TradeReconciliationWorker : BackgroundService
|
||||
{
|
||||
private readonly IServiceProvider _services;
|
||||
private readonly ILogger<TradeReconciliationWorker> _logger;
|
||||
|
||||
private const int BatchSize = 250;
|
||||
private const int IntervalMinutes = 15;
|
||||
|
||||
public TradeReconciliationWorker(IServiceProvider services, ILogger<TradeReconciliationWorker> logger)
|
||||
{
|
||||
_services = services;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
_logger.LogInformation("🛠️ TradeReconciliationWorker started (batch: {Batch}, interval: {Min}m)", BatchSize, IntervalMinutes);
|
||||
|
||||
// Initial delay to let other workers settle
|
||||
await Task.Delay(TimeSpan.FromSeconds(30), stoppingToken);
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
int reconciledCount = await ReconcileBatchAsync(stoppingToken);
|
||||
|
||||
if (reconciledCount > 0)
|
||||
{
|
||||
_logger.LogInformation("✅ Reconciled {Count} orphaned trades", reconciledCount);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogDebug("🛠️ No orphaned trades found for reconciliation");
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) { break; }
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error in TradeReconciliationWorker cycle");
|
||||
}
|
||||
|
||||
await Task.Delay(TimeSpan.FromMinutes(IntervalMinutes), stoppingToken);
|
||||
}
|
||||
|
||||
_logger.LogInformation("🛠️ TradeReconciliationWorker stopped");
|
||||
}
|
||||
|
||||
private async Task<int> ReconcileBatchAsync(CancellationToken ct)
|
||||
{
|
||||
using var scope = _services.CreateScope();
|
||||
var tradeRepo = scope.ServiceProvider.GetRequiredService<ITradeRepository>();
|
||||
var marketRepo = scope.ServiceProvider.GetRequiredService<IMarketRepository>();
|
||||
|
||||
var orphanedTrades = await tradeRepo.GetOrphanedTradesAsync(BatchSize, ct);
|
||||
if (orphanedTrades.Count == 0) return 0;
|
||||
|
||||
int count = 0;
|
||||
foreach (var trade in orphanedTrades)
|
||||
{
|
||||
if (ct.IsCancellationRequested) break;
|
||||
|
||||
try
|
||||
{
|
||||
var outcome = await marketRepo.GetOutcomeByTokenIdAsync(trade.AssetId, ct);
|
||||
if (outcome != null)
|
||||
{
|
||||
trade.MarketOutcomeId = outcome.Id;
|
||||
trade.Outcome = outcome.Label;
|
||||
if (outcome.Market != null)
|
||||
trade.DbMarketId = outcome.Market.Id;
|
||||
|
||||
await tradeRepo.UpdateAsync(trade, ct);
|
||||
count++;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning("Failed to reconcile trade {TradeId}: {Msg}", trade.Id, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Predictalytics.Infrastructure.Data;
|
||||
using Predictalytics.Domain.Entities;
|
||||
|
||||
namespace Predictalytics.Worker.Services;
|
||||
|
||||
public class TraderAnalyticsWorker : BackgroundService
|
||||
{
|
||||
private readonly IServiceProvider _services;
|
||||
private readonly ILogger<TraderAnalyticsWorker> _logger;
|
||||
|
||||
public TraderAnalyticsWorker(IServiceProvider services, ILogger<TraderAnalyticsWorker> logger)
|
||||
{
|
||||
_services = services;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken ct)
|
||||
{
|
||||
_logger.LogInformation("TraderAnalyticsWorker starting...");
|
||||
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
await RunAnalyticsAsync(ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error in TraderAnalyticsWorker");
|
||||
}
|
||||
|
||||
_logger.LogInformation("TraderAnalyticsWorker sleeping for 12 hours...");
|
||||
await Task.Delay(TimeSpan.FromHours(12), ct);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RunAnalyticsAsync(CancellationToken ct)
|
||||
{
|
||||
using var scope = _services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
|
||||
var cutoff30d = DateTime.UtcNow.AddDays(-30);
|
||||
|
||||
// Find traders active in the last 30 days
|
||||
var traderIds = await db.Trades
|
||||
.Where(t => t.ExecutedAt >= cutoff30d)
|
||||
.Select(t => t.TraderId)
|
||||
.Distinct()
|
||||
.ToListAsync(ct);
|
||||
|
||||
_logger.LogInformation("Found {Count} active traders to analyze", traderIds.Count);
|
||||
|
||||
foreach (var id in traderIds)
|
||||
{
|
||||
await UpdateTraderAnalyticsAsync(db, id, ct);
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync(ct);
|
||||
_logger.LogInformation("Trader analytics update complete.");
|
||||
}
|
||||
|
||||
private async Task UpdateTraderAnalyticsAsync(AppDbContext db, int traderId, CancellationToken ct)
|
||||
{
|
||||
var trades = await db.Trades.Where(t => t.TraderId == traderId).ToListAsync(ct);
|
||||
if (!trades.Any()) return;
|
||||
|
||||
var analytics = await db.TraderAnalytics.FirstOrDefaultAsync(a => a.TraderId == traderId, ct);
|
||||
if (analytics == null)
|
||||
{
|
||||
analytics = new TraderAnalytics { TraderId = traderId };
|
||||
db.TraderAnalytics.Add(analytics);
|
||||
}
|
||||
|
||||
analytics.LastCalculatedAt = DateTime.UtcNow;
|
||||
|
||||
// Simplified PnL calculation: Sum of Sells - Sum of Buys
|
||||
// This is not perfect but a good starting point as requested.
|
||||
// In a real scenario, we'd account for current market value of holdings.
|
||||
|
||||
analytics.OverallPnL = CalculatePnL(trades, null);
|
||||
analytics.OverallWinRate = CalculateWinRate(trades, null);
|
||||
|
||||
analytics.PnL30d = CalculatePnL(trades, DateTime.UtcNow.AddDays(-30));
|
||||
analytics.WinRate30d = CalculateWinRate(trades, DateTime.UtcNow.AddDays(-30));
|
||||
|
||||
analytics.PnL7d = CalculatePnL(trades, DateTime.UtcNow.AddDays(-7));
|
||||
analytics.WinRate7d = CalculateWinRate(trades, DateTime.UtcNow.AddDays(-7));
|
||||
|
||||
analytics.PnL24h = CalculatePnL(trades, DateTime.UtcNow.AddHours(-24));
|
||||
analytics.WinRate24h = CalculateWinRate(trades, DateTime.UtcNow.AddHours(-24));
|
||||
|
||||
// Update the trader record too for easy sorting
|
||||
var trader = await db.Traders.FindAsync(new object[] { traderId }, ct);
|
||||
if (trader != null)
|
||||
{
|
||||
trader.TotalPnl = analytics.OverallPnL;
|
||||
trader.WinRate = analytics.OverallWinRate;
|
||||
}
|
||||
}
|
||||
|
||||
private decimal CalculatePnL(List<Trade> trades, DateTime? since)
|
||||
{
|
||||
var filtered = since.HasValue ? trades.Where(t => t.ExecutedAt >= since.Value) : trades;
|
||||
|
||||
// Very simplified: Sells - Buys
|
||||
// Note: Real PnL should consider if the market resolved in their favor.
|
||||
// For now, we use the raw trade amounts.
|
||||
decimal pnl = 0;
|
||||
foreach (var t in filtered)
|
||||
{
|
||||
if (t.Side == Predictalytics.Domain.Enums.TradeSide.Buy) pnl -= t.Amount;
|
||||
else pnl += t.Amount;
|
||||
}
|
||||
return pnl;
|
||||
}
|
||||
|
||||
private decimal CalculateWinRate(List<Trade> trades, DateTime? since)
|
||||
{
|
||||
var filtered = since.HasValue ? trades.Where(t => t.ExecutedAt >= since.Value).ToList() : trades;
|
||||
if (!filtered.Any()) return 0;
|
||||
|
||||
// Simplified: A "win" is a Sell at a higher price than the average Buy price?
|
||||
// Actually, without proper position tracking, this is hard.
|
||||
// Let's assume a "win" is any trade that closed a position in profit.
|
||||
// For now, let's just return a placeholder or implement a basic logic.
|
||||
// Since we don't have resolution data easily linked here, we'll return 0 or a dummy.
|
||||
// Wait, if MarketOutcome is resolved and they held that outcome, it's a win.
|
||||
|
||||
// Let's just use 0 for now to avoid misleading data, or
|
||||
// if we have MarketOutcomeId and it's resolved, we can check.
|
||||
|
||||
return 0; // Placeholder until more complex logic is added
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
using Predictalytics.Domain.Interfaces;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Predictalytics.Worker.Services;
|
||||
|
||||
public class TraderCleanupWorker : BackgroundService
|
||||
{
|
||||
private readonly IServiceProvider _services;
|
||||
private readonly ILogger<TraderCleanupWorker> _logger;
|
||||
private readonly TimeSpan _checkInterval = TimeSpan.FromHours(12);
|
||||
|
||||
public TraderCleanupWorker(IServiceProvider services, ILogger<TraderCleanupWorker> logger)
|
||||
{
|
||||
_services = services;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
await DoCleanupWorkAsync(stoppingToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error occurred executing TraderCleanupWorker.");
|
||||
}
|
||||
|
||||
await Task.Delay(_checkInterval, stoppingToken);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DoCleanupWorkAsync(CancellationToken ct)
|
||||
{
|
||||
using var scope = _services.CreateScope();
|
||||
var traderRepo = scope.ServiceProvider.GetRequiredService<ITraderRepository>();
|
||||
var platformProviders = scope.ServiceProvider.GetRequiredService<IEnumerable<IPlatformProvider>>();
|
||||
|
||||
// Inactive since 1 year ago, or API error since 48 hours ago
|
||||
var inactiveSince = DateTime.UtcNow.AddYears(-1);
|
||||
var errorSince = DateTime.UtcNow.AddHours(-48);
|
||||
|
||||
var tradersToCleanup = await traderRepo.GetTradersForCleanupAsync(inactiveSince, errorSince, 50, ct);
|
||||
|
||||
if (tradersToCleanup.Count == 0) return;
|
||||
|
||||
_logger.LogInformation("Found {Count} traders for cleanup verification", tradersToCleanup.Count);
|
||||
|
||||
foreach (var trader in tradersToCleanup)
|
||||
{
|
||||
var provider = platformProviders.FirstOrDefault(p => p.Platform == trader.Platform);
|
||||
if (provider == null || !provider.IsImplemented) continue;
|
||||
|
||||
_logger.LogInformation("Verifying trader {PlatformUserId} for cleanup...", trader.PlatformUserId);
|
||||
|
||||
try
|
||||
{
|
||||
var recentTrades = await provider.GetTraderTradesAsync(trader.PlatformUserId, 1, ct);
|
||||
|
||||
if (recentTrades.Count > 0)
|
||||
{
|
||||
// Trader is still active and API is working
|
||||
trader.LastApiErrorAt = null;
|
||||
trader.LastPolledAt = DateTime.UtcNow;
|
||||
await traderRepo.UpdateAsync(trader, ct);
|
||||
_logger.LogInformation("Trader {PlatformUserId} is still active. Resetting error state.", trader.PlatformUserId);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Check if positions API also returns nothing/error
|
||||
var positions = await provider.GetTraderPositionsAsync(trader.PlatformUserId, ct);
|
||||
if (positions.Count > 0)
|
||||
{
|
||||
trader.LastApiErrorAt = null;
|
||||
trader.LastPolledAt = DateTime.UtcNow;
|
||||
await traderRepo.UpdateAsync(trader, ct);
|
||||
_logger.LogInformation("Trader {PlatformUserId} has open positions. Resetting error state.", trader.PlatformUserId);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Truly inactive or deleted
|
||||
_logger.LogWarning("Trader {PlatformUserId} verified as inactive/deleted. Deleting from database.", trader.PlatformUserId);
|
||||
await traderRepo.DeleteAsync(trader.Id, ct);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// If API throws an exception again, it might be a confirmed 404
|
||||
// We assume persistent error means deleted account
|
||||
_logger.LogWarning(ex, "API error fetching {PlatformUserId} during cleanup. Deleting trader.", trader.PlatformUserId);
|
||||
await traderRepo.DeleteAsync(trader.Id, ct);
|
||||
}
|
||||
|
||||
// Small delay to prevent API spam
|
||||
await Task.Delay(1000, ct);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user