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:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user