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:
@@ -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");
|
||||
|
||||
group.MapGet("/", async (IAnalyticsService svc, int? skip, int? take, string? platform, CancellationToken ct) =>
|
||||
Results.Ok(await svc.GetTradersAsync(skip ?? 0, take ?? 50, platform, 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, highlyCopyable ?? false, 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-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-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); }
|
||||
|
||||
@@ -127,7 +127,12 @@
|
||||
<!-- Traders Page -->
|
||||
<section class="page" id="page-traders">
|
||||
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:24px;">
|
||||
<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;">
|
||||
<span style="font-size:13px; font-weight:600">Add Trader:</span>
|
||||
<select id="addPlatform" class="platform-select">
|
||||
@@ -140,7 +145,7 @@
|
||||
</div>
|
||||
<div class="card">
|
||||
<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>
|
||||
</table>
|
||||
</div>
|
||||
@@ -181,20 +186,20 @@
|
||||
<!-- Trader Detail Page -->
|
||||
<section class="page" id="page-trader-detail">
|
||||
<!-- 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 style="display:flex; align-items:center; gap:16px">
|
||||
<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>
|
||||
<div style="width:1px; height:24px; background:var(--border);"></div>
|
||||
<h1 class="page-title" id="td-name" style="margin-bottom:0; font-size:24px;">Trader Name</h1>
|
||||
<div class="trader-menubar">
|
||||
<div class="trader-menubar-left">
|
||||
<button class="btn-back" onclick="navigateBack()">← Back</button>
|
||||
<div class="menubar-divider"></div>
|
||||
<h1 class="page-title" id="td-name" style="margin-bottom:0;">Trader Name</h1>
|
||||
</div>
|
||||
<div style="display:flex; gap:12px;">
|
||||
<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;">
|
||||
<div class="trader-menubar-right">
|
||||
<button class="btn-sm btn-outline" id="btn-toggle-watchlist">
|
||||
☆ Watchlist
|
||||
</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
|
||||
</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
|
||||
</button>
|
||||
</div>
|
||||
@@ -222,10 +227,10 @@
|
||||
</div>
|
||||
<div class="detail-main">
|
||||
<!-- Tabs Navigation -->
|
||||
<div class="tabs-nav" style="display:flex; gap:16px; margin-bottom:16px; border-bottom:1px solid var(--border); padding-bottom:8px;">
|
||||
<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" 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-positions" onclick="switchTraderTab('td-tab-positions')" style="background:transparent; border:none; color:var(--text-muted); cursor:pointer;">Positions</button>
|
||||
<div class="tabs-nav">
|
||||
<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')">Recent Trades</button>
|
||||
<button class="btn-tab" data-tab="td-tab-positions" onclick="switchTraderTab('td-tab-positions')">Positions</button>
|
||||
</div>
|
||||
|
||||
<!-- 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">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">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-label">Score</div>
|
||||
<div class="metric-label">Combined 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>
|
||||
|
||||
|
||||
@@ -275,9 +275,15 @@ async function loadDashboard() {
|
||||
async function loadTraders() {
|
||||
let url = '/api/traders?skip=0&take=100';
|
||||
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);
|
||||
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
|
||||
if (currentSort === 'score') data.sort((a, b) => b.combinedScore - a.combinedScore);
|
||||
@@ -287,9 +293,11 @@ async function loadTraders() {
|
||||
tbody.innerHTML = data.map((t, i) => `
|
||||
<tr>
|
||||
<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><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.pnl(t.totalPnl)}</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-trades').textContent = fmt.num(t.totalTrades);
|
||||
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.';
|
||||
|
||||
const refreshBtn = document.getElementById('btn-refresh-trader');
|
||||
|
||||
@@ -11,6 +11,8 @@ public record TraderDto(
|
||||
string Strategy,
|
||||
decimal CombinedScore,
|
||||
decimal CopytradingScore,
|
||||
decimal CopytradingQualityScore,
|
||||
decimal CopytradingCopyabilityScore,
|
||||
decimal WinRate,
|
||||
decimal TotalPnl,
|
||||
int TotalTrades,
|
||||
@@ -38,6 +40,8 @@ public record TraderDetailDto(
|
||||
decimal TimingScore,
|
||||
decimal CombinedScore,
|
||||
decimal CopytradingScore,
|
||||
decimal CopytradingQualityScore,
|
||||
decimal CopytradingCopyabilityScore,
|
||||
int Rank,
|
||||
bool IsOnWatchlist,
|
||||
DateTime CreatedAt,
|
||||
|
||||
@@ -12,7 +12,7 @@ public interface IAnalyticsService
|
||||
Task<TraderDeepDiveDto?> GetTraderDeepDiveAsync(int traderId, CancellationToken ct = default);
|
||||
|
||||
/// <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>
|
||||
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);
|
||||
}
|
||||
|
||||
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;
|
||||
if (!string.IsNullOrEmpty(platform) && platform != "All" && Enum.TryParse<PlatformType>(platform, true, out var 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 wIds = watchlist.Select(w => w.TraderId).ToHashSet();
|
||||
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.WinRate, trader.TotalPnl, trader.TotalTrades,
|
||||
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,
|
||||
trades.Select(MapTradeDto).ToList(),
|
||||
perfs);
|
||||
@@ -443,7 +451,9 @@ public class AnalyticsService : IAnalyticsService
|
||||
|
||||
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.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);
|
||||
|
||||
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>
|
||||
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>
|
||||
public decimal? EstimatedSlippage { get; set; }
|
||||
|
||||
|
||||
@@ -32,6 +32,12 @@ public class TraderScore
|
||||
/// <summary>Copytrading suitability score (0-100).</summary>
|
||||
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>
|
||||
public DateTime CalculatedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
|
||||
@@ -73,6 +73,8 @@ public static class DependencyInjection
|
||||
services.AddScoped<IDiscoveryService, DiscoveryService>();
|
||||
services.AddScoped<IAlertService, AlertService>();
|
||||
services.AddScoped<IAnalyticsService, AnalyticsService>();
|
||||
services.AddScoped<ICopytradingEstimator, CopytradingEstimator>();
|
||||
services.AddScoped<ICopytradingBacktestHarness, CopytradingBacktestHarness>();
|
||||
services.AddScoped<WatchlistService>();
|
||||
services.AddSingleton<IRateLimiter, RateLimiterService>();
|
||||
services.AddSingleton<IPlatformStatisticsService, PlatformStatisticsService>();
|
||||
|
||||
+978
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+60
@@ -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)
|
||||
.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)");
|
||||
@@ -710,6 +716,12 @@ namespace Predictalytics.Infrastructure.Migrations
|
||||
.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)");
|
||||
|
||||
@@ -46,9 +46,9 @@ public class PolymarketApiClient
|
||||
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) ?? [];
|
||||
}
|
||||
|
||||
|
||||
@@ -65,7 +65,7 @@ public class PolymarketProvider : IPlatformProvider
|
||||
|
||||
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);
|
||||
|
||||
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 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)
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user