feat: Implement copytrading analytics and update WebUI

- Add v4 Tape-based CopytradingEstimator (Clustering, MAE, Tape Fills)
- Add CopytradingBacktestHarness (Walk-Forward testing, Shrinkage, LCB ranking)
- Add EF Core Migrations for FollowerFillPrice and Scoring fields
- Update WebUI: Fix Trader Detail tab layout blowout
- Update WebUI: Redesign Trader Detail menubar
- Update WebUI: Display separate Quality and Copyability metric cards
- Update WebUI: Add 'Highly Copyable' filter to Trader List
This commit is contained in:
Richard
2026-07-06 11:55:38 +02:00
parent c0c598b86a
commit a564c016bb
21 changed files with 1705 additions and 47 deletions
@@ -38,8 +38,10 @@ public class TradeContextEnrichmentWorker : BackgroundService
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(500, stoppingToken);
var unenrichedTrades = await tradeRepo.GetTradesForContextEnrichmentAsync(50, stoppingToken);
if (unenrichedTrades.Count == 0)
{
@@ -48,7 +50,7 @@ public class TradeContextEnrichmentWorker : BackgroundService
continue;
}
// Group by AssetId to minimize API calls (1 call per asset fetches the whole 1m history)
// 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);
@@ -64,22 +66,9 @@ public class TradeContextEnrichmentWorker : BackgroundService
// Wait for rate limiter to respect global limits
await rateLimiter.WaitAsync(Predictalytics.Domain.Enums.PlatformType.Polymarket, stoppingToken);
// Fetch the 10-minute history for the entire market (using "max" since "1m" is invalid for full history)
// We still fetch history for PriceBefore1m
var history = await polymarketClient.GetPricesHistoryAsync(assetId, "max", stoppingToken);
if (history == null || history.Count == 0)
{
// If history is not available, mark as enriched to prevent infinite loops,
// but prices remain null.
foreach (var trade in group)
{
trade.IsContextEnriched = true;
await tradeRepo.UpdateAsync(trade, stoppingToken);
}
continue;
}
// Order history chronologically for safe binary search / LINQ
var orderedHistory = history.OrderBy(h => h.Timestamp).ToList();
var orderedHistory = history?.OrderBy(h => h.Timestamp).ToList() ?? new List<PriceHistoryEntry>();
foreach (var trade in group)
{
@@ -89,7 +78,7 @@ public class TradeContextEnrichmentWorker : BackgroundService
var prePoint = orderedHistory
.LastOrDefault(h => h.Timestamp < tradeTimeUnix);
// Find the closest point AFTER the trade (approx 1 min after)
// Find the closest point AFTER the trade (approx 1 min after) - old logic
var postPoint = orderedHistory
.FirstOrDefault(h => h.Timestamp > tradeTimeUnix);
@@ -98,12 +87,18 @@ public class TradeContextEnrichmentWorker : BackgroundService
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)
};