Files
Predictalytics/src/Predictalytics.Worker/Services/TradeContextEnrichmentWorker.cs
T

152 lines
7.1 KiB
C#

using Predictalytics.Domain.Entities;
using Predictalytics.Domain.Interfaces;
using Predictalytics.Infrastructure.Providers.Polymarket;
using Predictalytics.Application.Interfaces;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace Predictalytics.Worker.Services;
/// <summary>
/// Retroactively enriches trades of top/watchlisted traders with high-resolution
/// 1-minute price contexts immediately before and after execution.
/// Avoids burdening the live PollingWorker.
/// </summary>
public class TradeContextEnrichmentWorker : BackgroundService
{
private readonly IServiceProvider _services;
private readonly ILogger<TradeContextEnrichmentWorker> _logger;
public TradeContextEnrichmentWorker(IServiceProvider services, ILogger<TradeContextEnrichmentWorker> logger)
{
_services = services;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("🧠 TradeContextEnrichmentWorker started");
await Task.Delay(10000, stoppingToken); // Wait for app startup
while (!stoppingToken.IsCancellationRequested)
{
try
{
using var scope = _services.CreateScope();
var tradeRepo = scope.ServiceProvider.GetRequiredService<ITradeRepository>();
var polymarketClient = scope.ServiceProvider.GetRequiredService<PolymarketApiClient>();
var rateLimiter = scope.ServiceProvider.GetRequiredService<IRateLimiter>();
var estimator = scope.ServiceProvider.GetRequiredService<ICopytradingEstimator>();
// Fetch a batch of unenriched trades
var unenrichedTrades = await tradeRepo.GetTradesForContextEnrichmentAsync(50, stoppingToken);
if (unenrichedTrades.Count == 0)
{
// No work to do, sleep longer
await Task.Delay(TimeSpan.FromMinutes(5), stoppingToken);
continue;
}
// Group by AssetId to minimize API calls for the history endpoint (if we still use it)
var tradesByAsset = unenrichedTrades.GroupBy(t => t.AssetId).ToList();
_logger.LogInformation("Enriching {TradeCount} trades across {AssetCount} assets...", unenrichedTrades.Count, tradesByAsset.Count);
int updatedCount = 0;
foreach (var group in tradesByAsset)
{
if (stoppingToken.IsCancellationRequested) break;
var assetId = group.Key;
try
{
// Wait for rate limiter to respect global limits
await rateLimiter.WaitAsync(Predictalytics.Domain.Enums.PlatformType.Polymarket, stoppingToken);
// We still fetch history for PriceBefore1m
var history = await polymarketClient.GetPricesHistoryAsync(assetId, "max", stoppingToken);
var orderedHistory = history?.OrderBy(h => h.Timestamp).ToList() ?? new List<PriceHistoryEntry>();
foreach (var trade in group)
{
var tradeTimeUnix = ((DateTimeOffset)trade.ExecutedAt).ToUnixTimeSeconds();
// Find the closest point BEFORE the trade (approx 1 min before)
var prePoint = orderedHistory
.LastOrDefault(h => h.Timestamp < tradeTimeUnix);
// Find the closest point AFTER the trade (approx 1 min after) - old logic
var postPoint = orderedHistory
.FirstOrDefault(h => h.Timestamp > tradeTimeUnix);
var prePrice = prePoint != null ? (decimal?)prePoint.Price : null;
trade.PreTradePrice1m = prePrice;
trade.PostTradePrice1m = postPoint != null ? (decimal?)postPoint.Price : null;
trade.IsContextEnriched = true;
// NEW: Calculate exact follower fill prices from Trade Tape
var followerFill10s = await estimator.EstimateFollowerFillPriceAsync(trade, 10, stoppingToken);
var followerFill60s = await estimator.EstimateFollowerFillPriceAsync(trade, 60, stoppingToken);
// Populate new high-res TradeContext
trade.Context = new TradeContext
{
TradeId = trade.Id,
PriceBefore1m = prePrice,
PriceAfter1m = trade.PostTradePrice1m,
FollowerFillPrice10s = followerFill10s,
FollowerFillPrice60s = followerFill60s,
EstimatedSlippage = prePrice.HasValue ? Math.Abs(trade.Price - prePrice.Value) : null,
EstimatedOrderType = DetermineOrderType(trade, prePrice)
};
await tradeRepo.UpdateAsync(trade, stoppingToken);
updatedCount++;
}
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
_logger.LogInformation("Cancellation requested during enrichment, stopping batch.");
break;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to enrich asset {AssetId}", assetId);
// Do NOT mark as enriched on failure, try again later
}
}
_logger.LogInformation("✅ Enriched {UpdatedCount} trades in this cycle.", updatedCount);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error in TradeContextEnrichmentWorker loop");
}
// Sleep briefly before next batch
await Task.Delay(TimeSpan.FromSeconds(30), stoppingToken);
}
_logger.LogInformation("🧠 TradeContextEnrichmentWorker stopped");
}
private static Predictalytics.Domain.Enums.OrderType DetermineOrderType(Trade trade, decimal? priceBefore)
{
if (priceBefore == null) return Predictalytics.Domain.Enums.OrderType.Unknown;
if (trade.Side == Predictalytics.Domain.Enums.TradeSide.Buy)
{
return trade.Price <= priceBefore.Value ? Predictalytics.Domain.Enums.OrderType.Maker : Predictalytics.Domain.Enums.OrderType.Taker;
}
else if (trade.Side == Predictalytics.Domain.Enums.TradeSide.Sell)
{
return trade.Price >= priceBefore.Value ? Predictalytics.Domain.Enums.OrderType.Maker : Predictalytics.Domain.Enums.OrderType.Taker;
}
return Predictalytics.Domain.Enums.OrderType.Unknown;
}
}