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
+8
View File
@@ -0,0 +1,8 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>
@@ -9,8 +9,8 @@ public static class TraderEndpoints
{ {
var group = app.MapGroup("/api/traders").WithTags("Traders"); var group = app.MapGroup("/api/traders").WithTags("Traders");
group.MapGet("/", async (IAnalyticsService svc, int? skip, int? take, string? platform, CancellationToken ct) => group.MapGet("/", async (IAnalyticsService svc, int? skip, int? take, string? platform, bool? highlyCopyable, CancellationToken ct) =>
Results.Ok(await svc.GetTradersAsync(skip ?? 0, take ?? 50, platform, ct))); Results.Ok(await svc.GetTradersAsync(skip ?? 0, take ?? 50, platform, highlyCopyable ?? false, ct)));
group.MapGet("/{id:int}", async (int id, IAnalyticsService svc, CancellationToken ct) => group.MapGet("/{id:int}", async (int id, IAnalyticsService svc, CancellationToken ct) =>
{ {
@@ -437,6 +437,19 @@ body {
.detail-grid { display: grid; grid-template-columns: 300px 1fr; gap: 24px; align-items: start; } .detail-grid { display: grid; grid-template-columns: 300px 1fr; gap: 24px; align-items: start; }
.detail-sidebar { padding: 24px; } .detail-sidebar { padding: 24px; }
.detail-main { min-width: 0; }
.trader-menubar { display: flex; justify-content: space-between; align-items: center; background: var(--bg-surface); padding: 16px 24px; border-radius: 8px; border: 1px solid var(--border); margin-bottom: 24px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); }
.trader-menubar-left { display: flex; align-items: center; gap: 16px; }
.trader-menubar-right { display: flex; gap: 12px; }
.menubar-divider { width: 1px; height: 24px; background: var(--border); }
.btn-outline { background: var(--bg-input); color: var(--text); border: 1px solid var(--border); border-radius: 6px; cursor: pointer; font-weight: 500; transition: all 0.2s; }
.btn-outline:hover { background: var(--border); }
.btn-primary { background: var(--primary); color: #000; border: none; border-radius: 6px; cursor: pointer; font-weight: bold; transition: all 0.2s; }
.btn-primary:hover { filter: brightness(1.1); }
.tabs-nav { display: flex; gap: 12px; margin-bottom: 24px; border-bottom: 1px solid var(--border); padding-bottom: 8px; flex-wrap: wrap; align-items: center; }
.btn-tab { white-space: nowrap; padding: 8px 16px; border-radius: 6px; font-size: 14px; transition: background 0.2s; }
.btn-tab:hover { background: var(--bg-input); }
.btn-tab.active { background: var(--bg-input); color: var(--primary) !important; border-bottom: 2px solid var(--primary) !important; }
.stat-group { margin-bottom: 16px; } .stat-group { margin-bottom: 16px; }
.stat-label { font-size: 11px; font-weight: 700; text-transform: uppercase; color: var(--text-muted); letter-spacing: 0.5px; margin-bottom: 4px; } .stat-label { font-size: 11px; font-weight: 700; text-transform: uppercase; color: var(--text-muted); letter-spacing: 0.5px; margin-bottom: 4px; }
.stat-value { font-size: 15px; font-weight: 600; color: var(--text-primary); } .stat-value { font-size: 15px; font-weight: 600; color: var(--text-primary); }
+24 -17
View File
@@ -127,7 +127,12 @@
<!-- Traders Page --> <!-- Traders Page -->
<section class="page" id="page-traders"> <section class="page" id="page-traders">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:24px;"> <div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:24px;">
<h1 class="page-title" style="margin-bottom:0">Traders</h1> <div style="display:flex; align-items:center; gap:24px;">
<h1 class="page-title" style="margin-bottom:0">Traders</h1>
<label style="display:flex; align-items:center; gap:8px; font-weight:600; cursor:pointer; background:var(--bg-surface); padding:8px 12px; border-radius:6px; border:1px solid var(--border);">
<input type="checkbox" id="chk-highly-copyable" onchange="loadTraders()"> Highly Copyable
</label>
</div>
<div class="card" style="margin-bottom:0; padding:12px 20px; display:flex; gap:12px; align-items:center;"> <div class="card" style="margin-bottom:0; padding:12px 20px; display:flex; gap:12px; align-items:center;">
<span style="font-size:13px; font-weight:600">Add Trader:</span> <span style="font-size:13px; font-weight:600">Add Trader:</span>
<select id="addPlatform" class="platform-select"> <select id="addPlatform" class="platform-select">
@@ -140,7 +145,7 @@
</div> </div>
<div class="card"> <div class="card">
<div class="table-wrap"> <div class="table-wrap">
<table class="data-table"><thead><tr><th>#</th><th>Name</th><th>Platform</th><th>Score</th><th>Win Rate</th><th>PnL</th><th>Tier</th><th>Strategy</th><th>Actions</th></tr></thead> <table class="data-table"><thead><tr><th>#</th><th>Name</th><th>Platform</th><th>Combined Score</th><th>Quality</th><th>Copyability</th><th>Win Rate</th><th>PnL</th><th>Tier</th><th>Strategy</th><th>Actions</th></tr></thead>
<tbody id="allTradersBody"></tbody> <tbody id="allTradersBody"></tbody>
</table> </table>
</div> </div>
@@ -181,20 +186,20 @@
<!-- Trader Detail Page --> <!-- Trader Detail Page -->
<section class="page" id="page-trader-detail"> <section class="page" id="page-trader-detail">
<!-- Trader Detail Menu Bar --> <!-- Trader Detail Menu Bar -->
<div class="detail-header" style="justify-content:space-between; background:var(--bg-surface); padding:16px; border-radius:8px; border:1px solid var(--border); margin-bottom:24px; box-shadow:0 4px 12px rgba(0,0,0,0.1);"> <div class="trader-menubar">
<div style="display:flex; align-items:center; gap:16px"> <div class="trader-menubar-left">
<button class="btn-back" onclick="navigateBack()" style="background:transparent; border:none; color:var(--text-muted); cursor:pointer; font-weight:bold; font-size:16px;">← Back</button> <button class="btn-back" onclick="navigateBack()">← Back</button>
<div style="width:1px; height:24px; background:var(--border);"></div> <div class="menubar-divider"></div>
<h1 class="page-title" id="td-name" style="margin-bottom:0; font-size:24px;">Trader Name</h1> <h1 class="page-title" id="td-name" style="margin-bottom:0;">Trader Name</h1>
</div> </div>
<div style="display:flex; gap:12px;"> <div class="trader-menubar-right">
<button class="btn-sm" id="btn-toggle-watchlist" style="padding:8px 16px; background:var(--bg-input); color:var(--text); border:1px solid var(--border); border-radius:6px; cursor:pointer; font-weight:500; transition:all 0.2s;"> <button class="btn-sm btn-outline" id="btn-toggle-watchlist">
☆ Watchlist ☆ Watchlist
</button> </button>
<button class="btn-sm" id="btn-open-platform" style="padding:8px 16px; background:var(--bg-input); color:var(--text); border:1px solid var(--border); border-radius:6px; cursor:pointer; font-weight:500; transition:all 0.2s; display:none;"> <button class="btn-sm btn-outline" id="btn-open-platform" style="display:none;">
🌐 Open Platform 🌐 Open Platform
</button> </button>
<button class="btn-sm" id="btn-refresh-trader" style="padding:8px 16px; background:var(--primary); color:#000; border:none; border-radius:6px; cursor:pointer; font-weight:bold; transition:all 0.2s;"> <button class="btn-sm btn-primary" id="btn-refresh-trader">
⟱ Sync History ⟱ Sync History
</button> </button>
</div> </div>
@@ -222,10 +227,10 @@
</div> </div>
<div class="detail-main"> <div class="detail-main">
<!-- Tabs Navigation --> <!-- Tabs Navigation -->
<div class="tabs-nav" style="display:flex; gap:16px; margin-bottom:16px; border-bottom:1px solid var(--border); padding-bottom:8px;"> <div class="tabs-nav">
<button class="btn-tab active" data-tab="td-tab-analytics" onclick="switchTraderTab('td-tab-analytics')" style="background:transparent; border:none; color:var(--text); font-weight:bold; cursor:pointer;">Analytics & AI</button> <button class="btn-tab active" data-tab="td-tab-analytics" onclick="switchTraderTab('td-tab-analytics')">Analytics & AI</button>
<button class="btn-tab" data-tab="td-tab-recent" onclick="switchTraderTab('td-tab-recent')" style="background:transparent; border:none; color:var(--text-muted); cursor:pointer;">Recent Trades</button> <button class="btn-tab" data-tab="td-tab-recent" onclick="switchTraderTab('td-tab-recent')">Recent Trades</button>
<button class="btn-tab" data-tab="td-tab-positions" onclick="switchTraderTab('td-tab-positions')" style="background:transparent; border:none; color:var(--text-muted); cursor:pointer;">Positions</button> <button class="btn-tab" data-tab="td-tab-positions" onclick="switchTraderTab('td-tab-positions')">Positions</button>
</div> </div>
<!-- Tab Content: Analytics --> <!-- Tab Content: Analytics -->
@@ -234,10 +239,12 @@
<div class="metric-card"><div class="metric-label">Win Rate</div><div class="metric-value" id="td-winrate">---</div></div> <div class="metric-card"><div class="metric-label">Win Rate</div><div class="metric-value" id="td-winrate">---</div></div>
<div class="metric-card"><div class="metric-label">Total PnL</div><div class="metric-value" id="td-pnl">---</div></div> <div class="metric-card"><div class="metric-label">Total PnL</div><div class="metric-value" id="td-pnl">---</div></div>
<div class="metric-card"><div class="metric-label">Total Trades</div><div class="metric-value" id="td-trades">---</div></div> <div class="metric-card"><div class="metric-label">Total Trades</div><div class="metric-value" id="td-trades">---</div></div>
<div class="metric-card"><div class="metric-label">Quality Edge</div><div class="metric-value" id="td-quality-score">---</div></div>
<div class="metric-card"><div class="metric-label">Copyability</div><div class="metric-value" id="td-copyability-score">---</div></div>
<div class="metric-card accent"> <div class="metric-card accent">
<div class="metric-label">Score</div> <div class="metric-label">Combined Score</div>
<div class="metric-value" id="td-score">---</div> <div class="metric-value" id="td-score">---</div>
<button class="btn-sm" id="btn-force-analyze" style="margin-top:8px; padding:4px 8px; width:100%; background:var(--bg-input);">Recalculate</button> <button class="btn-sm btn-outline" id="btn-force-analyze" style="margin-top:8px; padding:4px 8px; width:100%; border-color:rgba(255,255,255,0.2);">Recalculate</button>
</div> </div>
</div> </div>
+12 -2
View File
@@ -275,9 +275,15 @@ async function loadDashboard() {
async function loadTraders() { async function loadTraders() {
let url = '/api/traders?skip=0&take=100'; let url = '/api/traders?skip=0&take=100';
if (currentPlatform !== 'All') url += `&platform=${currentPlatform}`; if (currentPlatform !== 'All') url += `&platform=${currentPlatform}`;
const hcCheckbox = document.getElementById('chk-highly-copyable');
if (hcCheckbox && hcCheckbox.checked) {
url += `&highlyCopyable=true`;
}
let data = await api(url); let data = await api(url);
const tbody = document.getElementById('allTradersBody'); const tbody = document.getElementById('allTradersBody');
if (!data || !data.length) { tbody.innerHTML = '<tr><td colspan="9"><div class="empty-state"><p>No traders tracked yet.</p></div></td></tr>'; return; } if (!data || !data.length) { tbody.innerHTML = '<tr><td colspan="11"><div class="empty-state"><p>No traders tracked yet.</p></div></td></tr>'; return; }
// Sorting // Sorting
if (currentSort === 'score') data.sort((a, b) => b.combinedScore - a.combinedScore); if (currentSort === 'score') data.sort((a, b) => b.combinedScore - a.combinedScore);
@@ -287,9 +293,11 @@ async function loadTraders() {
tbody.innerHTML = data.map((t, i) => ` tbody.innerHTML = data.map((t, i) => `
<tr> <tr>
<td>${i + 1}</td> <td>${i + 1}</td>
<td><strong><a href="#" onclick="viewTrader(${t.id}); return false;" style="color:var(--primary);text-decoration:none;">${t.displayName}</a></strong></td> <td><strong><a href="#" onclick="viewTrader(${t.id}); return false;" style="color:var(--primary);text-decoration:none;">${t.displayName}</a></strong>${t.isSuspectedBot ? ' 🤖' : ''}</td>
<td>${t.platform}</td> <td>${t.platform}</td>
<td><strong>${Number(t.combinedScore).toFixed(1)}</strong></td> <td><strong>${Number(t.combinedScore).toFixed(1)}</strong></td>
<td>${Number(t.copytradingQualityScore || 0).toFixed(1)}</td>
<td>${Number(t.copytradingCopyabilityScore || 0).toFixed(1)}</td>
<td>${fmt.pct(t.winRate)}</td> <td>${fmt.pct(t.winRate)}</td>
<td>${fmt.pnl(t.totalPnl)}</td> <td>${fmt.pnl(t.totalPnl)}</td>
<td>${fmt.tier(t.tier)}</td> <td>${fmt.tier(t.tier)}</td>
@@ -358,6 +366,8 @@ async function viewTrader(id) {
document.getElementById('td-pnl').innerHTML = fmt.pnl(t.totalPnl); document.getElementById('td-pnl').innerHTML = fmt.pnl(t.totalPnl);
document.getElementById('td-trades').textContent = fmt.num(t.totalTrades); document.getElementById('td-trades').textContent = fmt.num(t.totalTrades);
document.getElementById('td-score').textContent = Number(t.combinedScore).toFixed(1); document.getElementById('td-score').textContent = Number(t.combinedScore).toFixed(1);
document.getElementById('td-quality-score').textContent = Number(t.copytradingQualityScore || 0).toFixed(1);
document.getElementById('td-copyability-score').textContent = Number(t.copytradingCopyabilityScore || 0).toFixed(1);
document.getElementById('td-ai-summary').textContent = t.aiStrategySummary || 'Not analyzed yet.'; document.getElementById('td-ai-summary').textContent = t.aiStrategySummary || 'Not analyzed yet.';
const refreshBtn = document.getElementById('btn-refresh-trader'); const refreshBtn = document.getElementById('btn-refresh-trader');
@@ -11,6 +11,8 @@ public record TraderDto(
string Strategy, string Strategy,
decimal CombinedScore, decimal CombinedScore,
decimal CopytradingScore, decimal CopytradingScore,
decimal CopytradingQualityScore,
decimal CopytradingCopyabilityScore,
decimal WinRate, decimal WinRate,
decimal TotalPnl, decimal TotalPnl,
int TotalTrades, int TotalTrades,
@@ -38,6 +40,8 @@ public record TraderDetailDto(
decimal TimingScore, decimal TimingScore,
decimal CombinedScore, decimal CombinedScore,
decimal CopytradingScore, decimal CopytradingScore,
decimal CopytradingQualityScore,
decimal CopytradingCopyabilityScore,
int Rank, int Rank,
bool IsOnWatchlist, bool IsOnWatchlist,
DateTime CreatedAt, DateTime CreatedAt,
@@ -12,7 +12,7 @@ public interface IAnalyticsService
Task<TraderDeepDiveDto?> GetTraderDeepDiveAsync(int traderId, CancellationToken ct = default); Task<TraderDeepDiveDto?> GetTraderDeepDiveAsync(int traderId, CancellationToken ct = default);
/// <summary>Get trader list with scores.</summary> /// <summary>Get trader list with scores.</summary>
Task<IReadOnlyList<TraderDto>> GetTradersAsync(int skip = 0, int take = 50, string? platform = null, CancellationToken ct = default); Task<IReadOnlyList<TraderDto>> GetTradersAsync(int skip = 0, int take = 50, string? platform = null, bool highlyCopyable = false, CancellationToken ct = default);
/// <summary>Get a trader's details.</summary> /// <summary>Get a trader's details.</summary>
Task<TraderDetailDto?> GetTraderDetailAsync(int traderId, CancellationToken ct = default); Task<TraderDetailDto?> GetTraderDetailAsync(int traderId, CancellationToken ct = default);
@@ -0,0 +1,23 @@
using Predictalytics.Domain.Entities;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace Predictalytics.Application.Interfaces;
public class BacktestResult
{
public int LatencySeconds { get; set; }
public decimal HypotheticalFollowerPnl { get; set; }
public decimal TraderPnl { get; set; }
public decimal AlphaDecayPercentage { get; set; }
}
public interface ICopytradingBacktestHarness
{
/// <summary>
/// Runs a latency sweep (0s, 10s, 30s, 60s) for a given trader over their historical trades
/// to determine exactly how much alpha is lost to the tape execution.
/// </summary>
Task<List<BacktestResult>> RunLatencySweepAsync(Trader trader, IReadOnlyList<Trade> trades, CancellationToken ct = default);
}
@@ -0,0 +1,48 @@
using Predictalytics.Domain.Entities;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace Predictalytics.Application.Interfaces;
public interface ICopytradingEstimator
{
/// <summary>
/// Calculates the dual-axis copytrading score (Quality & Copyability).
/// </summary>
Task<(decimal QualityScore, decimal CopyabilityScore, decimal CombinedScore)> CalculateScoresAsync(
Trader trader,
IReadOnlyList<Trade> trades,
CancellationToken ct = default);
/// <summary>
/// Replays the trades using the historical trade tape to estimate the true alpha decay and Follower Fill Price.
/// Returns the VWAP of the follower fill, capped by the actual volume in the latency window.
/// </summary>
Task<decimal?> EstimateFollowerFillPriceAsync(
Trade traderTrade,
int latencySeconds = 10,
CancellationToken ct = default);
/// <summary>
/// Clusters trades into logical events (positions) based on a time window (e.g., 24h) and market.
/// </summary>
IReadOnlyList<TradeEvent> ClusterTradesIntoEvents(IReadOnlyList<Trade> trades, TimeSpan window);
/// <summary>
/// Calculates Maximum Adverse Excursion (MAE) during the holding period, handling tape sparsity.
/// </summary>
Task<decimal?> CalculateMaeAsync(TradeEvent tradeEvent, CancellationToken ct = default);
}
public class TradeEvent
{
public string MarketId { get; set; } = "";
public string AssetId { get; set; } = "";
public Predictalytics.Domain.Enums.TradeSide Side { get; set; }
public decimal AveragePrice { get; set; }
public decimal TotalSize { get; set; }
public DateTimeOffset EventStartTime { get; set; }
public DateTimeOffset EventEndTime { get; set; }
public List<Trade> ConstituentTrades { get; set; } = new();
}
@@ -145,13 +145,21 @@ public class AnalyticsService : IAnalyticsService
analysis.BotIndicators, analysis.Summary, tradeDtos); analysis.BotIndicators, analysis.Summary, tradeDtos);
} }
public async Task<IReadOnlyList<TraderDto>> GetTradersAsync(int skip = 0, int take = 50, string? platform = null, CancellationToken ct = default) public async Task<IReadOnlyList<TraderDto>> GetTradersAsync(int skip = 0, int take = 50, string? platform = null, bool highlyCopyable = false, CancellationToken ct = default)
{ {
PlatformType? pType = null; PlatformType? pType = null;
if (!string.IsNullOrEmpty(platform) && platform != "All" && Enum.TryParse<PlatformType>(platform, true, out var pt)) if (!string.IsNullOrEmpty(platform) && platform != "All" && Enum.TryParse<PlatformType>(platform, true, out var pt))
pType = pt; pType = pt;
var traders = await _traderRepo.GetAllAsync(platform: pType, skip: skip, take: take, ct: ct); var traders = await _traderRepo.GetAllAsync(platform: pType, skip: 0, take: 5000, ct: ct); // Get more to filter in memory
if (highlyCopyable)
{
traders = traders.Where(t => t.CurrentScore != null && t.CurrentScore.CopytradingScore >= 60).ToList();
}
traders = traders.Skip(skip).Take(take).ToList();
var watchlist = await _watchlistRepo.GetAllAsync(ct); var watchlist = await _watchlistRepo.GetAllAsync(ct);
var wIds = watchlist.Select(w => w.TraderId).ToHashSet(); var wIds = watchlist.Select(w => w.TraderId).ToHashSet();
return traders.Select(t => MapTraderDto(t, wIds)).ToList(); return traders.Select(t => MapTraderDto(t, wIds)).ToList();
@@ -205,7 +213,7 @@ public class AnalyticsService : IAnalyticsService
trader.Notes, trader.Tier.ToString(), trader.Strategy.ToString(), trader.IsSuspectedBot, trader.ManualPriorityOverride, trader.Notes, trader.Tier.ToString(), trader.Strategy.ToString(), trader.IsSuspectedBot, trader.ManualPriorityOverride,
trader.WinRate, trader.TotalPnl, trader.TotalTrades, trader.WinRate, trader.TotalPnl, trader.TotalTrades,
s?.ActivityScore ?? 0, s?.QualityScore ?? 0, s?.VolumeScore ?? 0, s?.TimingScore ?? 0, s?.ActivityScore ?? 0, s?.QualityScore ?? 0, s?.VolumeScore ?? 0, s?.TimingScore ?? 0,
s?.CombinedScore ?? 0, s?.CopytradingScore ?? 0, s?.Rank ?? 0, wl != null, trader.CreatedAt, trader.LastPolledAt, s?.CombinedScore ?? 0, s?.CopytradingScore ?? 0, s?.CopytradingQualityScore ?? 0, s?.CopytradingCopyabilityScore ?? 0, s?.Rank ?? 0, wl != null, trader.CreatedAt, trader.LastPolledAt,
trader.AiStrategySummary, trader.AiStrategySummary,
trades.Select(MapTradeDto).ToList(), trades.Select(MapTradeDto).ToList(),
perfs); perfs);
@@ -443,7 +451,9 @@ public class AnalyticsService : IAnalyticsService
private static TraderDto MapTraderDto(Trader t, HashSet<int> wIds) => new( private static TraderDto MapTraderDto(Trader t, HashSet<int> wIds) => new(
t.Id, t.Platform.ToString(), t.PlatformUserId, t.DisplayName, t.Tier.ToString(), t.Strategy.ToString(), t.Id, t.Platform.ToString(), t.PlatformUserId, t.DisplayName, t.Tier.ToString(), t.Strategy.ToString(),
t.CurrentScore?.CombinedScore ?? 0, t.CurrentScore?.CopytradingScore ?? 0, t.WinRate, t.TotalPnl, t.TotalTrades, t.CurrentScore?.CombinedScore ?? 0, t.CurrentScore?.CopytradingScore ?? 0,
t.CurrentScore?.CopytradingQualityScore ?? 0, t.CurrentScore?.CopytradingCopyabilityScore ?? 0,
t.WinRate, t.TotalPnl, t.TotalTrades,
wIds.Contains(t.Id), t.IsSuspectedBot, t.LastPolledAt); wIds.Contains(t.Id), t.IsSuspectedBot, t.LastPolledAt);
private static TradeDto MapTradeDto(Trade t) => new( private static TradeDto MapTradeDto(Trade t) => new(
@@ -21,6 +21,12 @@ public class TradeContext
/// <summary>The estimated mid-price of the asset roughly 1 minute after the trade execution.</summary> /// <summary>The estimated mid-price of the asset roughly 1 minute after the trade execution.</summary>
public decimal? PriceAfter1m { get; set; } public decimal? PriceAfter1m { get; set; }
/// <summary>The volume-weighted exact follower fill price 10 seconds after the trade execution (excluding the trader's own trades).</summary>
public decimal? FollowerFillPrice10s { get; set; }
/// <summary>The volume-weighted exact follower fill price 60 seconds after the trade execution (excluding the trader's own trades).</summary>
public decimal? FollowerFillPrice60s { get; set; }
/// <summary>Calculated slippage: execution price vs PriceBefore1m.</summary> /// <summary>Calculated slippage: execution price vs PriceBefore1m.</summary>
public decimal? EstimatedSlippage { get; set; } public decimal? EstimatedSlippage { get; set; }
@@ -32,6 +32,12 @@ public class TraderScore
/// <summary>Copytrading suitability score (0-100).</summary> /// <summary>Copytrading suitability score (0-100).</summary>
public decimal CopytradingScore { get; set; } public decimal CopytradingScore { get; set; }
/// <summary>The pure skill/edge dimension of the copytrading score (0-100).</summary>
public decimal CopytradingQualityScore { get; set; }
/// <summary>The copyability dimension (alpha-decay, sizing consistency) of the copytrading score (0-100).</summary>
public decimal CopytradingCopyabilityScore { get; set; }
/// <summary>When this score was last calculated.</summary> /// <summary>When this score was last calculated.</summary>
public DateTime CalculatedAt { get; set; } = DateTime.UtcNow; public DateTime CalculatedAt { get; set; } = DateTime.UtcNow;
@@ -73,6 +73,8 @@ public static class DependencyInjection
services.AddScoped<IDiscoveryService, DiscoveryService>(); services.AddScoped<IDiscoveryService, DiscoveryService>();
services.AddScoped<IAlertService, AlertService>(); services.AddScoped<IAlertService, AlertService>();
services.AddScoped<IAnalyticsService, AnalyticsService>(); services.AddScoped<IAnalyticsService, AnalyticsService>();
services.AddScoped<ICopytradingEstimator, CopytradingEstimator>();
services.AddScoped<ICopytradingBacktestHarness, CopytradingBacktestHarness>();
services.AddScoped<WatchlistService>(); services.AddScoped<WatchlistService>();
services.AddSingleton<IRateLimiter, RateLimiterService>(); services.AddSingleton<IRateLimiter, RateLimiterService>();
services.AddSingleton<IPlatformStatisticsService, PlatformStatisticsService>(); services.AddSingleton<IPlatformStatisticsService, PlatformStatisticsService>();
@@ -0,0 +1,978 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Predictalytics.Infrastructure.Data;
#nullable disable
namespace Predictalytics.Infrastructure.Migrations
{
[DbContext(typeof(AppDbContext))]
[Migration("20260706081202_AddCopytradingScoreAndTapeFills")]
partial class AddCopytradingScoreAndTapeFills
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "8.0.11")
.HasAnnotation("Relational:MaxIdentifierLength", 64);
MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder);
modelBuilder.Entity("Predictalytics.Domain.Entities.Alert", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<bool>("IsRead")
.HasColumnType("tinyint(1)");
b.Property<string>("Message")
.IsRequired()
.HasMaxLength(4096)
.HasColumnType("varchar(4096)");
b.Property<int>("Platform")
.HasColumnType("int");
b.Property<int>("Severity")
.HasColumnType("int");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("varchar(512)");
b.Property<int?>("TraderId")
.HasColumnType("int");
b.Property<int>("Type")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("CreatedAt");
b.HasIndex("TraderId");
b.ToTable("Alerts");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.Event", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<DateTime>("DbCreatedAt")
.HasColumnType("datetime(6)");
b.Property<string>("Description")
.HasMaxLength(4096)
.HasColumnType("varchar(4096)");
b.Property<DateTime?>("EndDate")
.HasColumnType("datetime(6)");
b.Property<string>("ImageUrl")
.HasMaxLength(1024)
.HasColumnType("varchar(1024)");
b.Property<bool>("IsActive")
.HasColumnType("tinyint(1)");
b.Property<bool>("IsClosed")
.HasColumnType("tinyint(1)");
b.Property<DateTime?>("LastUpdatedAt")
.HasColumnType("datetime(6)");
b.Property<int>("Platform")
.HasColumnType("int");
b.Property<long>("PlatformEventId")
.HasColumnType("bigint");
b.Property<string>("Slug")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("varchar(512)");
b.Property<DateTime?>("StartDate")
.HasColumnType("datetime(6)");
b.Property<string>("Tags")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("varchar(1024)");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("varchar(1024)");
b.HasKey("Id");
b.HasIndex("Platform", "PlatformEventId")
.IsUnique();
b.ToTable("Events");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.Market", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
b.Property<string>("Category")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("varchar(64)");
b.Property<string>("ConditionId")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("varchar(256)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<DateTime>("DbCreatedAt")
.HasColumnType("datetime(6)");
b.Property<string>("Description")
.HasMaxLength(4096)
.HasColumnType("varchar(4096)");
b.Property<DateTime?>("EndDate")
.HasColumnType("datetime(6)");
b.Property<int>("EventId")
.HasColumnType("int");
b.Property<string>("ImageUrl")
.HasMaxLength(1024)
.HasColumnType("varchar(1024)");
b.Property<bool>("IsResolved")
.HasColumnType("tinyint(1)");
b.Property<DateTime?>("LastTradesUpdatedAt")
.HasColumnType("datetime(6)");
b.Property<DateTime?>("LastUpdatedAt")
.HasColumnType("datetime(6)");
b.Property<decimal>("Liquidity")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<string>("MarketSlug")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("varchar(512)");
b.Property<int>("Platform")
.HasColumnType("int");
b.Property<long>("PlatformMarketId")
.HasColumnType("bigint");
b.Property<string>("Question")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("varchar(1024)");
b.Property<string>("QuestionId")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("varchar(256)");
b.Property<string>("ResolutionOutcome")
.HasColumnType("longtext");
b.Property<DateTime?>("StartDate")
.HasColumnType("datetime(6)");
b.Property<string>("Subcategory")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("varchar(128)");
b.Property<decimal>("Volume")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<decimal>("Volume24h")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.HasKey("Id");
b.HasIndex("EventId");
b.HasIndex("Platform", "PlatformMarketId")
.IsUnique();
b.ToTable("Markets");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.MarketAnalytics", b =>
{
b.Property<int>("MarketId")
.HasColumnType("int");
b.Property<decimal>("AverageTradeSize")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<decimal>("BotActivityScore")
.HasPrecision(8, 4)
.HasColumnType("decimal(8,4)");
b.Property<DateTime>("LastCalculatedAt")
.HasColumnType("datetime(6)");
b.Property<int>("UniqueTradersCount")
.HasColumnType("int");
b.HasKey("MarketId");
b.ToTable("MarketAnalytics");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.MarketOutcome", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
b.Property<decimal>("CurrentPrice")
.HasPrecision(18, 8)
.HasColumnType("decimal(18,8)");
b.Property<string>("Label")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("varchar(256)");
b.Property<int>("MarketId")
.HasColumnType("int");
b.Property<int>("OutcomeIndex")
.HasColumnType("int");
b.Property<string>("TokenId")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("varchar(256)");
b.HasKey("Id");
b.HasIndex("TokenId");
b.HasIndex("MarketId", "OutcomeIndex")
.IsUnique();
b.ToTable("MarketOutcomes");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.MarketOutcomePriceSnapshot", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
b.Property<int>("MarketOutcomeId")
.HasColumnType("int");
b.Property<decimal>("Price")
.HasPrecision(10, 6)
.HasColumnType("decimal(10,6)");
b.Property<DateTime>("Timestamp")
.HasColumnType("datetime(6)");
b.HasKey("Id");
b.HasIndex("MarketOutcomeId", "Timestamp");
b.ToTable("MarketOutcomePriceSnapshots");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.PlatformConfig", b =>
{
b.Property<int>("Id")
.HasColumnType("int");
b.Property<string>("BaseUrl")
.HasMaxLength(1024)
.HasColumnType("varchar(1024)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("varchar(256)");
b.Property<bool>("IsActive")
.HasColumnType("tinyint(1)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("varchar(128)");
b.Property<string>("SettingsJson")
.HasColumnType("longtext");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime(6)");
b.HasKey("Id");
b.ToTable("PlatformConfigs");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.Trade", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<long>("Id"));
b.Property<decimal>("Amount")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<string>("AssetId")
.IsRequired()
.HasMaxLength(80)
.HasColumnType("varchar(80)");
b.Property<int?>("DbMarketId")
.HasColumnType("int");
b.Property<DateTime>("ExecutedAt")
.HasColumnType("datetime(6)");
b.Property<bool>("IsContextEnriched")
.HasColumnType("tinyint(1)");
b.Property<string>("MarketId")
.IsRequired()
.HasMaxLength(66)
.HasColumnType("varchar(66)");
b.Property<int?>("MarketOutcomeId")
.HasColumnType("int");
b.Property<string>("Outcome")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("varchar(128)");
b.Property<int>("Platform")
.HasColumnType("int");
b.Property<string>("PlatformTradeId")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("varchar(256)");
b.Property<decimal?>("PostTradePrice1m")
.HasColumnType("decimal(18,4)");
b.Property<decimal?>("PreTradePrice1m")
.HasColumnType("decimal(18,4)");
b.Property<decimal>("Price")
.HasPrecision(18, 6)
.HasColumnType("decimal(18,6)");
b.Property<int>("Side")
.HasColumnType("int");
b.Property<decimal>("Size")
.HasPrecision(14, 6)
.HasColumnType("decimal(14,6)");
b.Property<int>("TraderId")
.HasColumnType("int");
b.Property<string>("TransactionHash")
.HasMaxLength(66)
.HasColumnType("varchar(66)");
b.HasKey("Id");
b.HasIndex("AssetId");
b.HasIndex("DbMarketId");
b.HasIndex("ExecutedAt");
b.HasIndex("MarketOutcomeId");
b.HasIndex("TraderId");
b.HasIndex("Platform", "PlatformTradeId")
.IsUnique();
b.ToTable("Trades");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.TradeContext", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<long>("Id"));
b.Property<string>("EstimatedOrderType")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("varchar(32)");
b.Property<decimal?>("EstimatedSlippage")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<decimal?>("FollowerFillPrice10s")
.HasColumnType("decimal(65,30)");
b.Property<decimal?>("FollowerFillPrice60s")
.HasColumnType("decimal(65,30)");
b.Property<decimal?>("PriceAfter1m")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<decimal?>("PriceBefore1m")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<long>("TradeId")
.HasColumnType("bigint");
b.HasKey("Id");
b.HasIndex("TradeId")
.IsUnique();
b.ToTable("TradeContexts");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.Trader", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
b.Property<string>("AiStrategySummary")
.HasColumnType("longtext");
b.Property<DateTime?>("AiStrategyUpdatedAt")
.HasColumnType("datetime(6)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("varchar(256)");
b.Property<bool>("IsAutoDiscovered")
.HasColumnType("tinyint(1)");
b.Property<bool>("IsInitialImportComplete")
.HasColumnType("tinyint(1)");
b.Property<bool>("IsSuspectedBot")
.HasColumnType("tinyint(1)");
b.Property<DateTime?>("LastAnalyzedAt")
.HasColumnType("datetime(6)");
b.Property<DateTime?>("LastApiErrorAt")
.HasColumnType("datetime(6)");
b.Property<DateTime?>("LastPolledAt")
.HasColumnType("datetime(6)");
b.Property<DateTime?>("LastTradesUpdatedAt")
.HasColumnType("datetime(6)");
b.Property<int?>("ManualPriorityOverride")
.HasColumnType("int");
b.Property<string>("Notes")
.HasColumnType("longtext");
b.Property<int>("Platform")
.HasColumnType("int");
b.Property<string>("PlatformUserId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("varchar(128)");
b.Property<int>("Strategy")
.HasColumnType("int");
b.Property<int>("Tier")
.HasColumnType("int");
b.Property<decimal>("TotalPnl")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<int>("TotalTrades")
.HasColumnType("int");
b.Property<decimal>("WinRate")
.HasPrecision(8, 4)
.HasColumnType("decimal(8,4)");
b.HasKey("Id");
b.HasIndex("Platform", "PlatformUserId")
.IsUnique();
b.ToTable("Traders");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.TraderAnalytics", b =>
{
b.Property<int>("TraderId")
.HasColumnType("int");
b.Property<DateTime>("LastCalculatedAt")
.HasColumnType("datetime(6)");
b.Property<decimal>("OverallPnL")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<decimal>("OverallWinRate")
.HasPrecision(8, 4)
.HasColumnType("decimal(8,4)");
b.Property<decimal>("PnL24h")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<decimal>("PnL30d")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<decimal>("PnL7d")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<decimal>("WinRate24h")
.HasPrecision(8, 4)
.HasColumnType("decimal(8,4)");
b.Property<decimal>("WinRate30d")
.HasPrecision(8, 4)
.HasColumnType("decimal(8,4)");
b.Property<decimal>("WinRate7d")
.HasPrecision(8, 4)
.HasColumnType("decimal(8,4)");
b.HasKey("TraderId");
b.ToTable("TraderAnalytics");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.TraderCategoryPerformance", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
b.Property<string>("Category")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("varchar(64)");
b.Property<string>("Subcategory")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("varchar(128)");
b.Property<decimal>("TotalPnL")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<int>("TotalTrades")
.HasColumnType("int");
b.Property<decimal>("TotalVolume")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<int>("TraderId")
.HasColumnType("int");
b.Property<int>("WinningTrades")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("TraderId", "Category", "Subcategory")
.IsUnique();
b.ToTable("TraderCategoryPerformances");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.TraderPosition", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
b.Property<decimal>("AvgCost")
.HasPrecision(10, 6)
.HasColumnType("decimal(10,6)");
b.Property<DateTime>("LastUpdatedAt")
.HasColumnType("datetime(6)");
b.Property<int>("MarketOutcomeId")
.HasColumnType("int");
b.Property<decimal>("RealizedPnl")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<decimal>("SharesHeld")
.HasPrecision(14, 6)
.HasColumnType("decimal(14,6)");
b.Property<int>("TraderId")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("MarketOutcomeId");
b.HasIndex("TraderId", "MarketOutcomeId")
.IsUnique();
b.ToTable("TraderPositions");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.TraderScore", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
b.Property<decimal>("ActivityScore")
.HasPrecision(8, 4)
.HasColumnType("decimal(8,4)");
b.Property<DateTime>("CalculatedAt")
.HasColumnType("datetime(6)");
b.Property<decimal>("CombinedScore")
.HasPrecision(8, 4)
.HasColumnType("decimal(8,4)");
b.Property<decimal>("CopytradingCopyabilityScore")
.HasColumnType("decimal(65,30)");
b.Property<decimal>("CopytradingQualityScore")
.HasColumnType("decimal(65,30)");
b.Property<decimal>("CopytradingScore")
.HasPrecision(8, 4)
.HasColumnType("decimal(8,4)");
b.Property<decimal>("QualityScore")
.HasPrecision(8, 4)
.HasColumnType("decimal(8,4)");
b.Property<int>("Rank")
.HasColumnType("int");
b.Property<decimal>("TimingScore")
.HasPrecision(8, 4)
.HasColumnType("decimal(8,4)");
b.Property<int>("TraderId")
.HasColumnType("int");
b.Property<decimal>("VolumeScore")
.HasPrecision(8, 4)
.HasColumnType("decimal(8,4)");
b.HasKey("Id");
b.HasIndex("TraderId")
.IsUnique();
b.ToTable("TraderScores");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.WatchlistEntry", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
b.Property<DateTime>("AddedAt")
.HasColumnType("datetime(6)");
b.Property<bool>("AlertsEnabled")
.HasColumnType("tinyint(1)");
b.Property<string>("Label")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("varchar(256)");
b.Property<string>("Notes")
.HasColumnType("longtext");
b.Property<int>("TraderId")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("TraderId")
.IsUnique();
b.ToTable("WatchlistEntries");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.Alert", b =>
{
b.HasOne("Predictalytics.Domain.Entities.Trader", "Trader")
.WithMany()
.HasForeignKey("TraderId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("Trader");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.Market", b =>
{
b.HasOne("Predictalytics.Domain.Entities.Event", "Event")
.WithMany("Markets")
.HasForeignKey("EventId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Event");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.MarketAnalytics", b =>
{
b.HasOne("Predictalytics.Domain.Entities.Market", "Market")
.WithOne("Analytics")
.HasForeignKey("Predictalytics.Domain.Entities.MarketAnalytics", "MarketId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Market");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.MarketOutcome", b =>
{
b.HasOne("Predictalytics.Domain.Entities.Market", "Market")
.WithMany("Outcomes")
.HasForeignKey("MarketId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Market");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.MarketOutcomePriceSnapshot", b =>
{
b.HasOne("Predictalytics.Domain.Entities.MarketOutcome", "MarketOutcome")
.WithMany()
.HasForeignKey("MarketOutcomeId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("MarketOutcome");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.Trade", b =>
{
b.HasOne("Predictalytics.Domain.Entities.Market", "DbMarket")
.WithMany()
.HasForeignKey("DbMarketId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("Predictalytics.Domain.Entities.MarketOutcome", "MarketOutcome")
.WithMany()
.HasForeignKey("MarketOutcomeId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("Predictalytics.Domain.Entities.Trader", "Trader")
.WithMany("Trades")
.HasForeignKey("TraderId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("DbMarket");
b.Navigation("MarketOutcome");
b.Navigation("Trader");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.TradeContext", b =>
{
b.HasOne("Predictalytics.Domain.Entities.Trade", "Trade")
.WithOne("Context")
.HasForeignKey("Predictalytics.Domain.Entities.TradeContext", "TradeId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Trade");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.TraderAnalytics", b =>
{
b.HasOne("Predictalytics.Domain.Entities.Trader", "Trader")
.WithOne("Analytics")
.HasForeignKey("Predictalytics.Domain.Entities.TraderAnalytics", "TraderId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Trader");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.TraderCategoryPerformance", b =>
{
b.HasOne("Predictalytics.Domain.Entities.Trader", "Trader")
.WithMany("CategoryPerformances")
.HasForeignKey("TraderId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Trader");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.TraderPosition", b =>
{
b.HasOne("Predictalytics.Domain.Entities.MarketOutcome", "MarketOutcome")
.WithMany()
.HasForeignKey("MarketOutcomeId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Predictalytics.Domain.Entities.Trader", "Trader")
.WithMany("Positions")
.HasForeignKey("TraderId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("MarketOutcome");
b.Navigation("Trader");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.TraderScore", b =>
{
b.HasOne("Predictalytics.Domain.Entities.Trader", "Trader")
.WithOne("CurrentScore")
.HasForeignKey("Predictalytics.Domain.Entities.TraderScore", "TraderId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Trader");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.WatchlistEntry", b =>
{
b.HasOne("Predictalytics.Domain.Entities.Trader", "Trader")
.WithMany("WatchlistEntries")
.HasForeignKey("TraderId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Trader");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.Event", b =>
{
b.Navigation("Markets");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.Market", b =>
{
b.Navigation("Analytics");
b.Navigation("Outcomes");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.Trade", b =>
{
b.Navigation("Context");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.Trader", b =>
{
b.Navigation("Analytics");
b.Navigation("CategoryPerformances");
b.Navigation("CurrentScore");
b.Navigation("Positions");
b.Navigation("Trades");
b.Navigation("WatchlistEntries");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,60 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Predictalytics.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddCopytradingScoreAndTapeFills : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<decimal>(
name: "CopytradingCopyabilityScore",
table: "TraderScores",
type: "decimal(65,30)",
nullable: false,
defaultValue: 0m);
migrationBuilder.AddColumn<decimal>(
name: "CopytradingQualityScore",
table: "TraderScores",
type: "decimal(65,30)",
nullable: false,
defaultValue: 0m);
migrationBuilder.AddColumn<decimal>(
name: "FollowerFillPrice10s",
table: "TradeContexts",
type: "decimal(65,30)",
nullable: true);
migrationBuilder.AddColumn<decimal>(
name: "FollowerFillPrice60s",
table: "TradeContexts",
type: "decimal(65,30)",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "CopytradingCopyabilityScore",
table: "TraderScores");
migrationBuilder.DropColumn(
name: "CopytradingQualityScore",
table: "TraderScores");
migrationBuilder.DropColumn(
name: "FollowerFillPrice10s",
table: "TradeContexts");
migrationBuilder.DropColumn(
name: "FollowerFillPrice60s",
table: "TradeContexts");
}
}
}
@@ -463,6 +463,12 @@ namespace Predictalytics.Infrastructure.Migrations
.HasPrecision(18, 4) .HasPrecision(18, 4)
.HasColumnType("decimal(18,4)"); .HasColumnType("decimal(18,4)");
b.Property<decimal?>("FollowerFillPrice10s")
.HasColumnType("decimal(65,30)");
b.Property<decimal?>("FollowerFillPrice60s")
.HasColumnType("decimal(65,30)");
b.Property<decimal?>("PriceAfter1m") b.Property<decimal?>("PriceAfter1m")
.HasPrecision(18, 4) .HasPrecision(18, 4)
.HasColumnType("decimal(18,4)"); .HasColumnType("decimal(18,4)");
@@ -710,6 +716,12 @@ namespace Predictalytics.Infrastructure.Migrations
.HasPrecision(8, 4) .HasPrecision(8, 4)
.HasColumnType("decimal(8,4)"); .HasColumnType("decimal(8,4)");
b.Property<decimal>("CopytradingCopyabilityScore")
.HasColumnType("decimal(65,30)");
b.Property<decimal>("CopytradingQualityScore")
.HasColumnType("decimal(65,30)");
b.Property<decimal>("CopytradingScore") b.Property<decimal>("CopytradingScore")
.HasPrecision(8, 4) .HasPrecision(8, 4)
.HasColumnType("decimal(8,4)"); .HasColumnType("decimal(8,4)");
@@ -46,9 +46,9 @@ public class PolymarketApiClient
return await ExecuteWithRetryAsync<List<PolymarketTradeResponse>>(_client, url, "Data", ct) ?? []; return await ExecuteWithRetryAsync<List<PolymarketTradeResponse>>(_client, url, "Data", ct) ?? [];
} }
public async Task<List<PolymarketTradeResponse>> GetMarketTradesAsync(string conditionId, int limit = 1000, CancellationToken ct = default) public async Task<List<PolymarketTradeResponse>> GetMarketTradesAsync(string conditionId, int limit = 1000, int offset = 0, CancellationToken ct = default)
{ {
var url = $"/trades?condition_id={conditionId}&limit={limit}"; var url = $"/trades?condition_id={conditionId}&limit={limit}&offset={offset}";
return await ExecuteWithRetryAsync<List<PolymarketTradeResponse>>(_client, url, "Data", ct) ?? []; return await ExecuteWithRetryAsync<List<PolymarketTradeResponse>>(_client, url, "Data", ct) ?? [];
} }
@@ -65,7 +65,7 @@ public class PolymarketProvider : IPlatformProvider
public async Task<IReadOnlyList<Trade>> GetMarketTradesAsync(string platformMarketId, int limit = 1000, CancellationToken ct = default) public async Task<IReadOnlyList<Trade>> GetMarketTradesAsync(string platformMarketId, int limit = 1000, CancellationToken ct = default)
{ {
var raw = await _api.GetMarketTradesAsync(platformMarketId, limit, ct); var raw = await _api.GetMarketTradesAsync(platformMarketId, limit, 0, ct);
_logger.LogInformation("Fetched {Count} trades for Market {Market} (limit={Limit})", raw.Count, platformMarketId, limit); _logger.LogInformation("Fetched {Count} trades for Market {Market} (limit={Limit})", raw.Count, platformMarketId, limit);
var mappedTrades = raw.Select(r => var mappedTrades = raw.Select(r =>
@@ -0,0 +1,100 @@
using Predictalytics.Application.Interfaces;
using Predictalytics.Domain.Entities;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
namespace Predictalytics.Infrastructure.Services;
public class CopytradingBacktestHarness : ICopytradingBacktestHarness
{
private readonly ICopytradingEstimator _estimator;
public CopytradingBacktestHarness(ICopytradingEstimator estimator)
{
_estimator = estimator;
}
public async Task<List<BacktestResult>> RunLatencySweepAsync(Trader trader, IReadOnlyList<Trade> trades, CancellationToken ct = default)
{
var results = new List<BacktestResult>();
int[] latencies = new[] { 0, 10, 30, 60 };
var events = _estimator.ClusterTradesIntoEvents(trades, TimeSpan.FromHours(24));
var buyEvents = events.Where(e => e.Side == Predictalytics.Domain.Enums.TradeSide.Buy).ToList();
foreach (int latency in latencies)
{
decimal followerPnl = 0;
decimal traderPnl = 0;
decimal totalRisked = 0;
foreach (var e in buyEvents)
{
var sampleTrade = e.ConstituentTrades.First();
var market = sampleTrade.DbMarket;
var outcome = sampleTrade.MarketOutcome;
decimal resolutionPrice = 0;
bool isResolved = market?.IsResolved ?? false;
if (isResolved)
{
resolutionPrice = string.Equals(market?.ResolutionOutcome, sampleTrade.Outcome, StringComparison.OrdinalIgnoreCase) ? 1.0m : 0.0m;
}
else
{
resolutionPrice = outcome?.CurrentPrice ?? e.AveragePrice;
}
// Calculate Trader PnL
decimal entryPrice = e.AveragePrice;
decimal size = e.TotalSize;
decimal eventTraderPnl = (resolutionPrice - entryPrice) * size;
traderPnl += eventTraderPnl;
totalRisked += entryPrice * size;
// Calculate Follower PnL
decimal eventFollowerPnl = 0;
foreach (var t in e.ConstituentTrades)
{
// For latency 0, we assume perfect fill (which is unrealistic, but serves as baseline)
decimal followerFill = t.Price;
if (latency > 0)
{
var fill = await _estimator.EstimateFollowerFillPriceAsync(t, latency, ct);
if (fill.HasValue)
{
followerFill = fill.Value;
}
}
eventFollowerPnl += (resolutionPrice - followerFill) * t.Size;
}
followerPnl += eventFollowerPnl;
}
decimal alphaDecay = 0;
if (traderPnl != 0)
{
alphaDecay = (traderPnl - followerPnl) / Math.Abs(traderPnl) * 100m;
}
results.Add(new BacktestResult
{
LatencySeconds = latency,
TraderPnl = traderPnl,
HypotheticalFollowerPnl = followerPnl,
AlphaDecayPercentage = alphaDecay
});
}
return results;
}
}
@@ -0,0 +1,376 @@
using Microsoft.Extensions.Logging;
using Predictalytics.Application.Interfaces;
using Predictalytics.Domain.Entities;
using Predictalytics.Infrastructure.Providers.Polymarket;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
namespace Predictalytics.Infrastructure.Services;
public class CopytradingEstimator : ICopytradingEstimator
{
private readonly ILogger<CopytradingEstimator> _logger;
private readonly PolymarketApiClient _polymarketClient;
private readonly IRateLimiter _rateLimiter;
public CopytradingEstimator(ILogger<CopytradingEstimator> logger, PolymarketApiClient polymarketClient, IRateLimiter rateLimiter)
{
_logger = logger;
_polymarketClient = polymarketClient;
_rateLimiter = rateLimiter;
}
public async Task<(decimal QualityScore, decimal CopyabilityScore, decimal CombinedScore)> CalculateScoresAsync(
Trader trader,
IReadOnlyList<Trade> trades,
CancellationToken ct = default)
{
if (trades.Count == 0) return (0, 0, 0);
// 1. Event Clustering
var events = ClusterTradesIntoEvents(trades, TimeSpan.FromHours(24));
decimal qualityScore = CalculateQualityScore(trader, events);
decimal copyabilityScore = await CalculateCopyabilityScoreAsync(trader, events, ct);
// Minimum Quality Gate: if Quality is below 30, it is not highly copyable regardless of copyability.
// We do not hard-gate to 0, but we scale it down heavily.
// Multiplicative Formula: Both in [0, 100], combined in [0, 100]
decimal normalizedQuality = qualityScore / 100m;
decimal normalizedCopyability = copyabilityScore / 100m;
decimal combinedScore = (normalizedQuality * normalizedCopyability) * 100m;
// Hard-gate: If quality is very low, combined score is strictly 0.
if (qualityScore < 20)
{
combinedScore = 0;
}
return (Math.Clamp(Math.Round(qualityScore, 2), 0, 100),
Math.Clamp(Math.Round(copyabilityScore, 2), 0, 100),
Math.Clamp(Math.Round(combinedScore, 2), 0, 100));
}
private decimal CalculateQualityScore(Trader trader, IReadOnlyList<TradeEvent> events)
{
var buyEvents = events.Where(e => e.Side == Predictalytics.Domain.Enums.TradeSide.Buy).ToList();
if (buyEvents.Count == 0) return 0;
var edges = new List<decimal>();
foreach (var e in buyEvents)
{
var sampleTrade = e.ConstituentTrades.First();
var market = sampleTrade.DbMarket;
var outcome = sampleTrade.MarketOutcome;
decimal resolutionPrice = 0;
bool isResolved = market?.IsResolved ?? false;
if (isResolved)
{
resolutionPrice = string.Equals(market?.ResolutionOutcome, sampleTrade.Outcome, StringComparison.OrdinalIgnoreCase) ? 1.0m : 0.0m;
}
else
{
// Live market
resolutionPrice = outcome?.CurrentPrice ?? e.AveragePrice; // fallback to entry if unknown
}
decimal entryPrice = e.AveragePrice;
decimal size = e.TotalSize;
decimal risked = entryPrice * size;
if (risked > 0)
{
decimal eventPnl = (resolutionPrice - entryPrice) * size;
edges.Add(eventPnl / risked); // Edge for this event (Return on Investment)
}
}
if (edges.Count == 0) return 0;
decimal meanEdge = edges.Average();
decimal stdDev = 0;
if (edges.Count > 1)
{
decimal sumOfSquares = edges.Sum(e => (e - meanEdge) * (e - meanEdge));
stdDev = (decimal)Math.Sqrt((double)(sumOfSquares / (edges.Count - 1)));
}
decimal standardError = stdDev / (decimal)Math.Sqrt(edges.Count);
decimal zScore = 1.645m; // 90% confidence
decimal lcbEdge = meanEdge - (zScore * standardError);
// Convert LCB Edge to a 0-100 score.
// 0% edge = 50. +20% edge = 100. -20% edge = 0.
decimal score = 50 + (lcbEdge * 100m * 2.5m);
return Math.Clamp(Math.Round(score, 2), 0, 100);
}
private Task<decimal> CalculateCopyabilityScoreAsync(Trader trader, IReadOnlyList<TradeEvent> events, CancellationToken ct)
{
if (events.Count == 0) return Task.FromResult(0m);
var alphaDecays = new List<decimal>();
var sizes = new List<decimal>();
foreach (var e in events)
{
decimal eventDecay = 0;
decimal totalTradeSize = 0;
foreach (var t in e.ConstituentTrades)
{
sizes.Add(t.Amount); // USD equivalent sizing
if (t.Context?.FollowerFillPrice10s.HasValue == true && t.Price > 0)
{
decimal slippage = 0;
if (t.Side == Predictalytics.Domain.Enums.TradeSide.Buy)
{
slippage = t.Context.FollowerFillPrice10s.Value - t.Price; // Positive slippage = price went up (we buy higher)
}
else
{
slippage = t.Price - t.Context.FollowerFillPrice10s.Value; // Positive slippage = price went down (we sell lower)
}
// slippage as a percentage of price
decimal decayPct = slippage / t.Price;
eventDecay += decayPct * t.Size;
totalTradeSize += t.Size;
}
}
if (totalTradeSize > 0)
{
alphaDecays.Add(eventDecay / totalTradeSize);
}
}
// 1. Alpha Decay Score
decimal averageDecay = alphaDecays.Count > 0 ? alphaDecays.Average() : 0;
// Convert averageDecay to score.
// If average decay is <= 0%, score is 100.
// If average decay is 10% (0.10), score is 0.
decimal decayScore = 100m - (averageDecay * 1000m); // 0.10 * 1000 = 100
decayScore = Math.Clamp(decayScore, 0, 100);
// 2. Sizing Consistency Score
decimal sizingScore = 50;
if (sizes.Count > 1)
{
decimal meanSize = sizes.Average();
decimal sumOfSquares = sizes.Sum(s => (s - meanSize) * (s - meanSize));
decimal stdDev = (decimal)Math.Sqrt((double)(sumOfSquares / (sizes.Count - 1)));
decimal cv = stdDev / (meanSize == 0 ? 1 : meanSize); // Coefficient of Variation
// CV of 0 = 100 score. CV of 1.0 = 50 score. CV of 2.0 = 0 score.
sizingScore = 100m - (cv * 50m);
sizingScore = Math.Clamp(sizingScore, 0, 100);
}
decimal combined = (decayScore * 0.7m) + (sizingScore * 0.3m);
return Task.FromResult(Math.Clamp(Math.Round(combined, 2), 0, 100));
}
public async Task<decimal?> EstimateFollowerFillPriceAsync(
Trade traderTrade,
int latencySeconds = 10,
CancellationToken ct = default)
{
if (string.IsNullOrEmpty(traderTrade.MarketId)) return null;
await _rateLimiter.WaitAsync(Predictalytics.Domain.Enums.PlatformType.Polymarket, ct);
long tradeUnix = ((DateTimeOffset)traderTrade.ExecutedAt).ToUnixTimeSeconds();
long windowStart = tradeUnix + latencySeconds;
long windowEnd = windowStart + 120; // 2 minutes window to get filled
var tape = new List<PolymarketTradeResponse>();
int limit = 1000;
int offset = 0;
while (true)
{
var page = await _polymarketClient.GetMarketTradesAsync(traderTrade.MarketId, limit, offset, ct);
if (page.Count == 0) break;
var filtered = page.Where(p =>
!string.Equals(p.ProxyWallet, traderTrade.Trader.PlatformUserId, StringComparison.OrdinalIgnoreCase) &&
!string.Equals(p.User, traderTrade.Trader.PlatformUserId, StringComparison.OrdinalIgnoreCase))
.ToList();
tape.AddRange(filtered);
var oldestInPage = page.Last().Timestamp;
if (oldestInPage < tradeUnix)
{
break; // We have gone back far enough
}
offset += limit;
}
var relevantTrades = tape
.Where(t => t.Asset == traderTrade.AssetId && t.Timestamp >= windowStart && t.Timestamp <= windowEnd)
.OrderBy(t => t.Timestamp)
.ToList();
if (relevantTrades.Count == 0) return null;
decimal remainingSize = traderTrade.Size;
decimal totalValue = 0;
decimal totalSizeFilled = 0;
foreach (var t in relevantTrades)
{
decimal sizeAvailable = (decimal)t.Size;
decimal fillSize = Math.Min(remainingSize, sizeAvailable);
totalValue += fillSize * (decimal)t.Price;
totalSizeFilled += fillSize;
remainingSize -= fillSize;
if (remainingSize <= 0) break;
}
if (totalSizeFilled == 0) return null;
return totalValue / totalSizeFilled;
}
public IReadOnlyList<TradeEvent> ClusterTradesIntoEvents(IReadOnlyList<Trade> trades, TimeSpan window)
{
var events = new List<TradeEvent>();
var grouped = trades.GroupBy(t => new { t.MarketId, t.AssetId, t.Side });
foreach (var group in grouped)
{
var ordered = group.OrderBy(t => t.ExecutedAt).ToList();
TradeEvent? currentEvent = null;
foreach (var trade in ordered)
{
if (currentEvent == null || trade.ExecutedAt - currentEvent.EventStartTime > window)
{
if (currentEvent != null) events.Add(currentEvent);
currentEvent = new TradeEvent
{
MarketId = trade.MarketId,
AssetId = trade.AssetId ?? "",
Side = trade.Side,
EventStartTime = trade.ExecutedAt,
EventEndTime = trade.ExecutedAt,
ConstituentTrades = new List<Trade> { trade },
AveragePrice = trade.Price,
TotalSize = trade.Size
};
}
else
{
currentEvent.ConstituentTrades.Add(trade);
currentEvent.EventEndTime = trade.ExecutedAt;
// Update VWAP
decimal newTotalSize = currentEvent.TotalSize + trade.Size;
decimal totalValue = (currentEvent.AveragePrice * currentEvent.TotalSize) + (trade.Price * trade.Size);
currentEvent.AveragePrice = totalValue / newTotalSize;
currentEvent.TotalSize = newTotalSize;
}
}
if (currentEvent != null)
{
events.Add(currentEvent);
}
}
return events.OrderBy(e => e.EventStartTime).ToList();
}
public async Task<decimal?> CalculateMaeAsync(TradeEvent tradeEvent, CancellationToken ct = default)
{
if (string.IsNullOrEmpty(tradeEvent.MarketId)) return null;
await _rateLimiter.WaitAsync(Predictalytics.Domain.Enums.PlatformType.Polymarket, ct);
long startUnix = tradeEvent.EventStartTime.ToUnixTimeSeconds();
// Determine end of holding period. Since we don't have sell trades linked natively,
// we'll just check MAE over the next 7 days or until now, whichever is shorter.
// (A more accurate implementation would need to match buys with sells to find the holding period)
long endUnix = Math.Min(DateTimeOffset.UtcNow.ToUnixTimeSeconds(), startUnix + (7 * 24 * 3600));
var tape = new List<PolymarketTradeResponse>();
int limit = 1000;
int offset = 0;
while (true)
{
var page = await _polymarketClient.GetMarketTradesAsync(tradeEvent.MarketId, limit, offset, ct);
if (page.Count == 0) break;
tape.AddRange(page);
var oldestInPage = page.Last().Timestamp;
if (oldestInPage < startUnix)
{
break;
}
offset += limit;
}
var relevantTrades = tape
.Where(t => t.Asset == tradeEvent.AssetId && t.Timestamp >= startUnix && t.Timestamp <= endUnix)
.ToList();
if (relevantTrades.Count == 0) return null;
decimal mae = 0;
// VWAP of the event is the entry price
decimal entryPrice = tradeEvent.AveragePrice;
foreach (var t in relevantTrades)
{
decimal price = (decimal)t.Price;
decimal unrealizedLoss = 0;
if (tradeEvent.Side == Predictalytics.Domain.Enums.TradeSide.Buy)
{
// Buy -> loss if price drops
if (price < entryPrice)
{
unrealizedLoss = entryPrice - price;
}
}
else
{
// Sell -> loss if price rises
if (price > entryPrice)
{
unrealizedLoss = price - entryPrice;
}
}
if (unrealizedLoss > mae)
{
mae = unrealizedLoss;
}
}
// Return MAE as a percentage of entry price or absolute? Absolute is often easier for 0-1 bounded prices.
return mae;
}
}
@@ -38,8 +38,10 @@ public class TradeContextEnrichmentWorker : BackgroundService
var polymarketClient = scope.ServiceProvider.GetRequiredService<PolymarketApiClient>(); var polymarketClient = scope.ServiceProvider.GetRequiredService<PolymarketApiClient>();
var rateLimiter = scope.ServiceProvider.GetRequiredService<IRateLimiter>(); var rateLimiter = scope.ServiceProvider.GetRequiredService<IRateLimiter>();
var estimator = scope.ServiceProvider.GetRequiredService<ICopytradingEstimator>();
// Fetch a batch of unenriched trades // Fetch a batch of unenriched trades
var unenrichedTrades = await tradeRepo.GetTradesForContextEnrichmentAsync(500, stoppingToken); var unenrichedTrades = await tradeRepo.GetTradesForContextEnrichmentAsync(50, stoppingToken);
if (unenrichedTrades.Count == 0) if (unenrichedTrades.Count == 0)
{ {
@@ -48,7 +50,7 @@ public class TradeContextEnrichmentWorker : BackgroundService
continue; 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(); var tradesByAsset = unenrichedTrades.GroupBy(t => t.AssetId).ToList();
_logger.LogInformation("Enriching {TradeCount} trades across {AssetCount} assets...", unenrichedTrades.Count, tradesByAsset.Count); _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 // Wait for rate limiter to respect global limits
await rateLimiter.WaitAsync(Predictalytics.Domain.Enums.PlatformType.Polymarket, stoppingToken); 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); var history = await polymarketClient.GetPricesHistoryAsync(assetId, "max", stoppingToken);
if (history == null || history.Count == 0) var orderedHistory = history?.OrderBy(h => h.Timestamp).ToList() ?? new List<PriceHistoryEntry>();
{
// 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();
foreach (var trade in group) foreach (var trade in group)
{ {
@@ -89,7 +78,7 @@ public class TradeContextEnrichmentWorker : BackgroundService
var prePoint = orderedHistory var prePoint = orderedHistory
.LastOrDefault(h => h.Timestamp < tradeTimeUnix); .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 var postPoint = orderedHistory
.FirstOrDefault(h => h.Timestamp > tradeTimeUnix); .FirstOrDefault(h => h.Timestamp > tradeTimeUnix);
@@ -98,12 +87,18 @@ public class TradeContextEnrichmentWorker : BackgroundService
trade.PostTradePrice1m = postPoint != null ? (decimal?)postPoint.Price : null; trade.PostTradePrice1m = postPoint != null ? (decimal?)postPoint.Price : null;
trade.IsContextEnriched = true; 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 // Populate new high-res TradeContext
trade.Context = new TradeContext trade.Context = new TradeContext
{ {
TradeId = trade.Id, TradeId = trade.Id,
PriceBefore1m = prePrice, PriceBefore1m = prePrice,
PriceAfter1m = trade.PostTradePrice1m, PriceAfter1m = trade.PostTradePrice1m,
FollowerFillPrice10s = followerFill10s,
FollowerFillPrice60s = followerFill60s,
EstimatedSlippage = prePrice.HasValue ? Math.Abs(trade.Price - prePrice.Value) : null, EstimatedSlippage = prePrice.HasValue ? Math.Abs(trade.Price - prePrice.Value) : null,
EstimatedOrderType = DetermineOrderType(trade, prePrice) EstimatedOrderType = DetermineOrderType(trade, prePrice)
}; };