feat: Implement copytrading analytics and update WebUI

- Add v4 Tape-based CopytradingEstimator (Clustering, MAE, Tape Fills)
- Add CopytradingBacktestHarness (Walk-Forward testing, Shrinkage, LCB ranking)
- Add EF Core Migrations for FollowerFillPrice and Scoring fields
- Update WebUI: Fix Trader Detail tab layout blowout
- Update WebUI: Redesign Trader Detail menubar
- Update WebUI: Display separate Quality and Copyability metric cards
- Update WebUI: Add 'Highly Copyable' filter to Trader List
This commit is contained in:
Richard
2026-07-06 11:55:38 +02:00
parent c0c598b86a
commit a564c016bb
21 changed files with 1705 additions and 47 deletions
@@ -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>();
@@ -0,0 +1,978 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Predictalytics.Infrastructure.Data;
#nullable disable
namespace Predictalytics.Infrastructure.Migrations
{
[DbContext(typeof(AppDbContext))]
[Migration("20260706081202_AddCopytradingScoreAndTapeFills")]
partial class AddCopytradingScoreAndTapeFills
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "8.0.11")
.HasAnnotation("Relational:MaxIdentifierLength", 64);
MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder);
modelBuilder.Entity("Predictalytics.Domain.Entities.Alert", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<bool>("IsRead")
.HasColumnType("tinyint(1)");
b.Property<string>("Message")
.IsRequired()
.HasMaxLength(4096)
.HasColumnType("varchar(4096)");
b.Property<int>("Platform")
.HasColumnType("int");
b.Property<int>("Severity")
.HasColumnType("int");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("varchar(512)");
b.Property<int?>("TraderId")
.HasColumnType("int");
b.Property<int>("Type")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("CreatedAt");
b.HasIndex("TraderId");
b.ToTable("Alerts");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.Event", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<DateTime>("DbCreatedAt")
.HasColumnType("datetime(6)");
b.Property<string>("Description")
.HasMaxLength(4096)
.HasColumnType("varchar(4096)");
b.Property<DateTime?>("EndDate")
.HasColumnType("datetime(6)");
b.Property<string>("ImageUrl")
.HasMaxLength(1024)
.HasColumnType("varchar(1024)");
b.Property<bool>("IsActive")
.HasColumnType("tinyint(1)");
b.Property<bool>("IsClosed")
.HasColumnType("tinyint(1)");
b.Property<DateTime?>("LastUpdatedAt")
.HasColumnType("datetime(6)");
b.Property<int>("Platform")
.HasColumnType("int");
b.Property<long>("PlatformEventId")
.HasColumnType("bigint");
b.Property<string>("Slug")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("varchar(512)");
b.Property<DateTime?>("StartDate")
.HasColumnType("datetime(6)");
b.Property<string>("Tags")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("varchar(1024)");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("varchar(1024)");
b.HasKey("Id");
b.HasIndex("Platform", "PlatformEventId")
.IsUnique();
b.ToTable("Events");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.Market", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
b.Property<string>("Category")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("varchar(64)");
b.Property<string>("ConditionId")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("varchar(256)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<DateTime>("DbCreatedAt")
.HasColumnType("datetime(6)");
b.Property<string>("Description")
.HasMaxLength(4096)
.HasColumnType("varchar(4096)");
b.Property<DateTime?>("EndDate")
.HasColumnType("datetime(6)");
b.Property<int>("EventId")
.HasColumnType("int");
b.Property<string>("ImageUrl")
.HasMaxLength(1024)
.HasColumnType("varchar(1024)");
b.Property<bool>("IsResolved")
.HasColumnType("tinyint(1)");
b.Property<DateTime?>("LastTradesUpdatedAt")
.HasColumnType("datetime(6)");
b.Property<DateTime?>("LastUpdatedAt")
.HasColumnType("datetime(6)");
b.Property<decimal>("Liquidity")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<string>("MarketSlug")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("varchar(512)");
b.Property<int>("Platform")
.HasColumnType("int");
b.Property<long>("PlatformMarketId")
.HasColumnType("bigint");
b.Property<string>("Question")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("varchar(1024)");
b.Property<string>("QuestionId")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("varchar(256)");
b.Property<string>("ResolutionOutcome")
.HasColumnType("longtext");
b.Property<DateTime?>("StartDate")
.HasColumnType("datetime(6)");
b.Property<string>("Subcategory")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("varchar(128)");
b.Property<decimal>("Volume")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<decimal>("Volume24h")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.HasKey("Id");
b.HasIndex("EventId");
b.HasIndex("Platform", "PlatformMarketId")
.IsUnique();
b.ToTable("Markets");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.MarketAnalytics", b =>
{
b.Property<int>("MarketId")
.HasColumnType("int");
b.Property<decimal>("AverageTradeSize")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<decimal>("BotActivityScore")
.HasPrecision(8, 4)
.HasColumnType("decimal(8,4)");
b.Property<DateTime>("LastCalculatedAt")
.HasColumnType("datetime(6)");
b.Property<int>("UniqueTradersCount")
.HasColumnType("int");
b.HasKey("MarketId");
b.ToTable("MarketAnalytics");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.MarketOutcome", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
b.Property<decimal>("CurrentPrice")
.HasPrecision(18, 8)
.HasColumnType("decimal(18,8)");
b.Property<string>("Label")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("varchar(256)");
b.Property<int>("MarketId")
.HasColumnType("int");
b.Property<int>("OutcomeIndex")
.HasColumnType("int");
b.Property<string>("TokenId")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("varchar(256)");
b.HasKey("Id");
b.HasIndex("TokenId");
b.HasIndex("MarketId", "OutcomeIndex")
.IsUnique();
b.ToTable("MarketOutcomes");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.MarketOutcomePriceSnapshot", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
b.Property<int>("MarketOutcomeId")
.HasColumnType("int");
b.Property<decimal>("Price")
.HasPrecision(10, 6)
.HasColumnType("decimal(10,6)");
b.Property<DateTime>("Timestamp")
.HasColumnType("datetime(6)");
b.HasKey("Id");
b.HasIndex("MarketOutcomeId", "Timestamp");
b.ToTable("MarketOutcomePriceSnapshots");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.PlatformConfig", b =>
{
b.Property<int>("Id")
.HasColumnType("int");
b.Property<string>("BaseUrl")
.HasMaxLength(1024)
.HasColumnType("varchar(1024)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("varchar(256)");
b.Property<bool>("IsActive")
.HasColumnType("tinyint(1)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("varchar(128)");
b.Property<string>("SettingsJson")
.HasColumnType("longtext");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime(6)");
b.HasKey("Id");
b.ToTable("PlatformConfigs");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.Trade", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<long>("Id"));
b.Property<decimal>("Amount")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<string>("AssetId")
.IsRequired()
.HasMaxLength(80)
.HasColumnType("varchar(80)");
b.Property<int?>("DbMarketId")
.HasColumnType("int");
b.Property<DateTime>("ExecutedAt")
.HasColumnType("datetime(6)");
b.Property<bool>("IsContextEnriched")
.HasColumnType("tinyint(1)");
b.Property<string>("MarketId")
.IsRequired()
.HasMaxLength(66)
.HasColumnType("varchar(66)");
b.Property<int?>("MarketOutcomeId")
.HasColumnType("int");
b.Property<string>("Outcome")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("varchar(128)");
b.Property<int>("Platform")
.HasColumnType("int");
b.Property<string>("PlatformTradeId")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("varchar(256)");
b.Property<decimal?>("PostTradePrice1m")
.HasColumnType("decimal(18,4)");
b.Property<decimal?>("PreTradePrice1m")
.HasColumnType("decimal(18,4)");
b.Property<decimal>("Price")
.HasPrecision(18, 6)
.HasColumnType("decimal(18,6)");
b.Property<int>("Side")
.HasColumnType("int");
b.Property<decimal>("Size")
.HasPrecision(14, 6)
.HasColumnType("decimal(14,6)");
b.Property<int>("TraderId")
.HasColumnType("int");
b.Property<string>("TransactionHash")
.HasMaxLength(66)
.HasColumnType("varchar(66)");
b.HasKey("Id");
b.HasIndex("AssetId");
b.HasIndex("DbMarketId");
b.HasIndex("ExecutedAt");
b.HasIndex("MarketOutcomeId");
b.HasIndex("TraderId");
b.HasIndex("Platform", "PlatformTradeId")
.IsUnique();
b.ToTable("Trades");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.TradeContext", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<long>("Id"));
b.Property<string>("EstimatedOrderType")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("varchar(32)");
b.Property<decimal?>("EstimatedSlippage")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<decimal?>("FollowerFillPrice10s")
.HasColumnType("decimal(65,30)");
b.Property<decimal?>("FollowerFillPrice60s")
.HasColumnType("decimal(65,30)");
b.Property<decimal?>("PriceAfter1m")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<decimal?>("PriceBefore1m")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<long>("TradeId")
.HasColumnType("bigint");
b.HasKey("Id");
b.HasIndex("TradeId")
.IsUnique();
b.ToTable("TradeContexts");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.Trader", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
b.Property<string>("AiStrategySummary")
.HasColumnType("longtext");
b.Property<DateTime?>("AiStrategyUpdatedAt")
.HasColumnType("datetime(6)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("varchar(256)");
b.Property<bool>("IsAutoDiscovered")
.HasColumnType("tinyint(1)");
b.Property<bool>("IsInitialImportComplete")
.HasColumnType("tinyint(1)");
b.Property<bool>("IsSuspectedBot")
.HasColumnType("tinyint(1)");
b.Property<DateTime?>("LastAnalyzedAt")
.HasColumnType("datetime(6)");
b.Property<DateTime?>("LastApiErrorAt")
.HasColumnType("datetime(6)");
b.Property<DateTime?>("LastPolledAt")
.HasColumnType("datetime(6)");
b.Property<DateTime?>("LastTradesUpdatedAt")
.HasColumnType("datetime(6)");
b.Property<int?>("ManualPriorityOverride")
.HasColumnType("int");
b.Property<string>("Notes")
.HasColumnType("longtext");
b.Property<int>("Platform")
.HasColumnType("int");
b.Property<string>("PlatformUserId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("varchar(128)");
b.Property<int>("Strategy")
.HasColumnType("int");
b.Property<int>("Tier")
.HasColumnType("int");
b.Property<decimal>("TotalPnl")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<int>("TotalTrades")
.HasColumnType("int");
b.Property<decimal>("WinRate")
.HasPrecision(8, 4)
.HasColumnType("decimal(8,4)");
b.HasKey("Id");
b.HasIndex("Platform", "PlatformUserId")
.IsUnique();
b.ToTable("Traders");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.TraderAnalytics", b =>
{
b.Property<int>("TraderId")
.HasColumnType("int");
b.Property<DateTime>("LastCalculatedAt")
.HasColumnType("datetime(6)");
b.Property<decimal>("OverallPnL")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<decimal>("OverallWinRate")
.HasPrecision(8, 4)
.HasColumnType("decimal(8,4)");
b.Property<decimal>("PnL24h")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<decimal>("PnL30d")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<decimal>("PnL7d")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<decimal>("WinRate24h")
.HasPrecision(8, 4)
.HasColumnType("decimal(8,4)");
b.Property<decimal>("WinRate30d")
.HasPrecision(8, 4)
.HasColumnType("decimal(8,4)");
b.Property<decimal>("WinRate7d")
.HasPrecision(8, 4)
.HasColumnType("decimal(8,4)");
b.HasKey("TraderId");
b.ToTable("TraderAnalytics");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.TraderCategoryPerformance", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
b.Property<string>("Category")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("varchar(64)");
b.Property<string>("Subcategory")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("varchar(128)");
b.Property<decimal>("TotalPnL")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<int>("TotalTrades")
.HasColumnType("int");
b.Property<decimal>("TotalVolume")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<int>("TraderId")
.HasColumnType("int");
b.Property<int>("WinningTrades")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("TraderId", "Category", "Subcategory")
.IsUnique();
b.ToTable("TraderCategoryPerformances");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.TraderPosition", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
b.Property<decimal>("AvgCost")
.HasPrecision(10, 6)
.HasColumnType("decimal(10,6)");
b.Property<DateTime>("LastUpdatedAt")
.HasColumnType("datetime(6)");
b.Property<int>("MarketOutcomeId")
.HasColumnType("int");
b.Property<decimal>("RealizedPnl")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<decimal>("SharesHeld")
.HasPrecision(14, 6)
.HasColumnType("decimal(14,6)");
b.Property<int>("TraderId")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("MarketOutcomeId");
b.HasIndex("TraderId", "MarketOutcomeId")
.IsUnique();
b.ToTable("TraderPositions");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.TraderScore", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
b.Property<decimal>("ActivityScore")
.HasPrecision(8, 4)
.HasColumnType("decimal(8,4)");
b.Property<DateTime>("CalculatedAt")
.HasColumnType("datetime(6)");
b.Property<decimal>("CombinedScore")
.HasPrecision(8, 4)
.HasColumnType("decimal(8,4)");
b.Property<decimal>("CopytradingCopyabilityScore")
.HasColumnType("decimal(65,30)");
b.Property<decimal>("CopytradingQualityScore")
.HasColumnType("decimal(65,30)");
b.Property<decimal>("CopytradingScore")
.HasPrecision(8, 4)
.HasColumnType("decimal(8,4)");
b.Property<decimal>("QualityScore")
.HasPrecision(8, 4)
.HasColumnType("decimal(8,4)");
b.Property<int>("Rank")
.HasColumnType("int");
b.Property<decimal>("TimingScore")
.HasPrecision(8, 4)
.HasColumnType("decimal(8,4)");
b.Property<int>("TraderId")
.HasColumnType("int");
b.Property<decimal>("VolumeScore")
.HasPrecision(8, 4)
.HasColumnType("decimal(8,4)");
b.HasKey("Id");
b.HasIndex("TraderId")
.IsUnique();
b.ToTable("TraderScores");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.WatchlistEntry", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
b.Property<DateTime>("AddedAt")
.HasColumnType("datetime(6)");
b.Property<bool>("AlertsEnabled")
.HasColumnType("tinyint(1)");
b.Property<string>("Label")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("varchar(256)");
b.Property<string>("Notes")
.HasColumnType("longtext");
b.Property<int>("TraderId")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("TraderId")
.IsUnique();
b.ToTable("WatchlistEntries");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.Alert", b =>
{
b.HasOne("Predictalytics.Domain.Entities.Trader", "Trader")
.WithMany()
.HasForeignKey("TraderId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("Trader");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.Market", b =>
{
b.HasOne("Predictalytics.Domain.Entities.Event", "Event")
.WithMany("Markets")
.HasForeignKey("EventId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Event");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.MarketAnalytics", b =>
{
b.HasOne("Predictalytics.Domain.Entities.Market", "Market")
.WithOne("Analytics")
.HasForeignKey("Predictalytics.Domain.Entities.MarketAnalytics", "MarketId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Market");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.MarketOutcome", b =>
{
b.HasOne("Predictalytics.Domain.Entities.Market", "Market")
.WithMany("Outcomes")
.HasForeignKey("MarketId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Market");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.MarketOutcomePriceSnapshot", b =>
{
b.HasOne("Predictalytics.Domain.Entities.MarketOutcome", "MarketOutcome")
.WithMany()
.HasForeignKey("MarketOutcomeId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("MarketOutcome");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.Trade", b =>
{
b.HasOne("Predictalytics.Domain.Entities.Market", "DbMarket")
.WithMany()
.HasForeignKey("DbMarketId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("Predictalytics.Domain.Entities.MarketOutcome", "MarketOutcome")
.WithMany()
.HasForeignKey("MarketOutcomeId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("Predictalytics.Domain.Entities.Trader", "Trader")
.WithMany("Trades")
.HasForeignKey("TraderId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("DbMarket");
b.Navigation("MarketOutcome");
b.Navigation("Trader");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.TradeContext", b =>
{
b.HasOne("Predictalytics.Domain.Entities.Trade", "Trade")
.WithOne("Context")
.HasForeignKey("Predictalytics.Domain.Entities.TradeContext", "TradeId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Trade");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.TraderAnalytics", b =>
{
b.HasOne("Predictalytics.Domain.Entities.Trader", "Trader")
.WithOne("Analytics")
.HasForeignKey("Predictalytics.Domain.Entities.TraderAnalytics", "TraderId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Trader");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.TraderCategoryPerformance", b =>
{
b.HasOne("Predictalytics.Domain.Entities.Trader", "Trader")
.WithMany("CategoryPerformances")
.HasForeignKey("TraderId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Trader");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.TraderPosition", b =>
{
b.HasOne("Predictalytics.Domain.Entities.MarketOutcome", "MarketOutcome")
.WithMany()
.HasForeignKey("MarketOutcomeId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Predictalytics.Domain.Entities.Trader", "Trader")
.WithMany("Positions")
.HasForeignKey("TraderId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("MarketOutcome");
b.Navigation("Trader");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.TraderScore", b =>
{
b.HasOne("Predictalytics.Domain.Entities.Trader", "Trader")
.WithOne("CurrentScore")
.HasForeignKey("Predictalytics.Domain.Entities.TraderScore", "TraderId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Trader");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.WatchlistEntry", b =>
{
b.HasOne("Predictalytics.Domain.Entities.Trader", "Trader")
.WithMany("WatchlistEntries")
.HasForeignKey("TraderId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Trader");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.Event", b =>
{
b.Navigation("Markets");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.Market", b =>
{
b.Navigation("Analytics");
b.Navigation("Outcomes");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.Trade", b =>
{
b.Navigation("Context");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.Trader", b =>
{
b.Navigation("Analytics");
b.Navigation("CategoryPerformances");
b.Navigation("CurrentScore");
b.Navigation("Positions");
b.Navigation("Trades");
b.Navigation("WatchlistEntries");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,60 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Predictalytics.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddCopytradingScoreAndTapeFills : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<decimal>(
name: "CopytradingCopyabilityScore",
table: "TraderScores",
type: "decimal(65,30)",
nullable: false,
defaultValue: 0m);
migrationBuilder.AddColumn<decimal>(
name: "CopytradingQualityScore",
table: "TraderScores",
type: "decimal(65,30)",
nullable: false,
defaultValue: 0m);
migrationBuilder.AddColumn<decimal>(
name: "FollowerFillPrice10s",
table: "TradeContexts",
type: "decimal(65,30)",
nullable: true);
migrationBuilder.AddColumn<decimal>(
name: "FollowerFillPrice60s",
table: "TradeContexts",
type: "decimal(65,30)",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "CopytradingCopyabilityScore",
table: "TraderScores");
migrationBuilder.DropColumn(
name: "CopytradingQualityScore",
table: "TraderScores");
migrationBuilder.DropColumn(
name: "FollowerFillPrice10s",
table: "TradeContexts");
migrationBuilder.DropColumn(
name: "FollowerFillPrice60s",
table: "TradeContexts");
}
}
}
@@ -463,6 +463,12 @@ namespace Predictalytics.Infrastructure.Migrations
.HasPrecision(18, 4)
.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;
}
}