diff --git a/Spike/Spike.csproj b/Spike/Spike.csproj new file mode 100644 index 0000000..64e34a8 --- /dev/null +++ b/Spike/Spike.csproj @@ -0,0 +1,8 @@ + + + Exe + net8.0 + enable + enable + + diff --git a/src/Predictalytics.Api/Endpoints/TraderEndpoints.cs b/src/Predictalytics.Api/Endpoints/TraderEndpoints.cs index 68d9360..6dbc75c 100644 --- a/src/Predictalytics.Api/Endpoints/TraderEndpoints.cs +++ b/src/Predictalytics.Api/Endpoints/TraderEndpoints.cs @@ -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) => { diff --git a/src/Predictalytics.Api/wwwroot/css/style.css b/src/Predictalytics.Api/wwwroot/css/style.css index f0253f9..e4eeb33 100644 --- a/src/Predictalytics.Api/wwwroot/css/style.css +++ b/src/Predictalytics.Api/wwwroot/css/style.css @@ -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); } diff --git a/src/Predictalytics.Api/wwwroot/index.html b/src/Predictalytics.Api/wwwroot/index.html index 5d5417b..75e69d1 100644 --- a/src/Predictalytics.Api/wwwroot/index.html +++ b/src/Predictalytics.Api/wwwroot/index.html @@ -127,7 +127,12 @@ - Traders + + Traders + + Highly Copyable + + Add Trader: @@ -140,7 +145,7 @@ - #NamePlatformScoreWin RatePnLTierStrategyActions + #NamePlatformCombined ScoreQualityCopyabilityWin RatePnLTierStrategyActions @@ -181,20 +186,20 @@ - - - ← Back - - Trader Name + + + ← Back + + Trader Name - - + + ☆ Watchlist - + 🌐 Open Platform - + ⟱ Sync History @@ -222,10 +227,10 @@ - - Analytics & AI - Recent Trades - Positions + + Analytics & AI + Recent Trades + Positions @@ -234,10 +239,12 @@ Win Rate--- Total PnL--- Total Trades--- + Quality Edge--- + Copyability--- - Score + Combined Score --- - Recalculate + Recalculate diff --git a/src/Predictalytics.Api/wwwroot/js/app.js b/src/Predictalytics.Api/wwwroot/js/app.js index 1b84e39..5583ecf 100644 --- a/src/Predictalytics.Api/wwwroot/js/app.js +++ b/src/Predictalytics.Api/wwwroot/js/app.js @@ -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 = 'No traders tracked yet.'; return; } + if (!data || !data.length) { tbody.innerHTML = 'No traders tracked yet.'; 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) => ` ${i + 1} - ${t.displayName} + ${t.displayName}${t.isSuspectedBot ? ' 🤖' : ''} ${t.platform} ${Number(t.combinedScore).toFixed(1)} + ${Number(t.copytradingQualityScore || 0).toFixed(1)} + ${Number(t.copytradingCopyabilityScore || 0).toFixed(1)} ${fmt.pct(t.winRate)} ${fmt.pnl(t.totalPnl)} ${fmt.tier(t.tier)} @@ -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'); diff --git a/src/Predictalytics.Application/DTOs/TraderDto.cs b/src/Predictalytics.Application/DTOs/TraderDto.cs index 05bd9de..9e2a044 100644 --- a/src/Predictalytics.Application/DTOs/TraderDto.cs +++ b/src/Predictalytics.Application/DTOs/TraderDto.cs @@ -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, diff --git a/src/Predictalytics.Application/Interfaces/IAnalyticsService.cs b/src/Predictalytics.Application/Interfaces/IAnalyticsService.cs index 975456e..284ab3b 100644 --- a/src/Predictalytics.Application/Interfaces/IAnalyticsService.cs +++ b/src/Predictalytics.Application/Interfaces/IAnalyticsService.cs @@ -12,7 +12,7 @@ public interface IAnalyticsService Task GetTraderDeepDiveAsync(int traderId, CancellationToken ct = default); /// Get trader list with scores. - Task> GetTradersAsync(int skip = 0, int take = 50, string? platform = null, CancellationToken ct = default); + Task> GetTradersAsync(int skip = 0, int take = 50, string? platform = null, bool highlyCopyable = false, CancellationToken ct = default); /// Get a trader's details. Task GetTraderDetailAsync(int traderId, CancellationToken ct = default); diff --git a/src/Predictalytics.Application/Interfaces/ICopytradingBacktestHarness.cs b/src/Predictalytics.Application/Interfaces/ICopytradingBacktestHarness.cs new file mode 100644 index 0000000..b9ad007 --- /dev/null +++ b/src/Predictalytics.Application/Interfaces/ICopytradingBacktestHarness.cs @@ -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 +{ + /// + /// 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. + /// + Task> RunLatencySweepAsync(Trader trader, IReadOnlyList trades, CancellationToken ct = default); +} diff --git a/src/Predictalytics.Application/Interfaces/ICopytradingEstimator.cs b/src/Predictalytics.Application/Interfaces/ICopytradingEstimator.cs new file mode 100644 index 0000000..de9eee4 --- /dev/null +++ b/src/Predictalytics.Application/Interfaces/ICopytradingEstimator.cs @@ -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 +{ + /// + /// Calculates the dual-axis copytrading score (Quality & Copyability). + /// + Task<(decimal QualityScore, decimal CopyabilityScore, decimal CombinedScore)> CalculateScoresAsync( + Trader trader, + IReadOnlyList trades, + CancellationToken ct = default); + + /// + /// 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. + /// + Task EstimateFollowerFillPriceAsync( + Trade traderTrade, + int latencySeconds = 10, + CancellationToken ct = default); + + /// + /// Clusters trades into logical events (positions) based on a time window (e.g., 24h) and market. + /// + IReadOnlyList ClusterTradesIntoEvents(IReadOnlyList trades, TimeSpan window); + + /// + /// Calculates Maximum Adverse Excursion (MAE) during the holding period, handling tape sparsity. + /// + Task 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 ConstituentTrades { get; set; } = new(); +} diff --git a/src/Predictalytics.Application/Services/AnalyticsService.cs b/src/Predictalytics.Application/Services/AnalyticsService.cs index 1fc7f00..de96137 100644 --- a/src/Predictalytics.Application/Services/AnalyticsService.cs +++ b/src/Predictalytics.Application/Services/AnalyticsService.cs @@ -145,13 +145,21 @@ public class AnalyticsService : IAnalyticsService analysis.BotIndicators, analysis.Summary, tradeDtos); } - public async Task> GetTradersAsync(int skip = 0, int take = 50, string? platform = null, CancellationToken ct = default) + public async Task> 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(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 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( diff --git a/src/Predictalytics.Domain/Entities/TradeContext.cs b/src/Predictalytics.Domain/Entities/TradeContext.cs index bc99f15..9632119 100644 --- a/src/Predictalytics.Domain/Entities/TradeContext.cs +++ b/src/Predictalytics.Domain/Entities/TradeContext.cs @@ -21,6 +21,12 @@ public class TradeContext /// The estimated mid-price of the asset roughly 1 minute after the trade execution. public decimal? PriceAfter1m { get; set; } + /// The volume-weighted exact follower fill price 10 seconds after the trade execution (excluding the trader's own trades). + public decimal? FollowerFillPrice10s { get; set; } + + /// The volume-weighted exact follower fill price 60 seconds after the trade execution (excluding the trader's own trades). + public decimal? FollowerFillPrice60s { get; set; } + /// Calculated slippage: execution price vs PriceBefore1m. public decimal? EstimatedSlippage { get; set; } diff --git a/src/Predictalytics.Domain/Entities/TraderScore.cs b/src/Predictalytics.Domain/Entities/TraderScore.cs index 2510599..de904aa 100644 --- a/src/Predictalytics.Domain/Entities/TraderScore.cs +++ b/src/Predictalytics.Domain/Entities/TraderScore.cs @@ -32,6 +32,12 @@ public class TraderScore /// Copytrading suitability score (0-100). public decimal CopytradingScore { get; set; } + /// The pure skill/edge dimension of the copytrading score (0-100). + public decimal CopytradingQualityScore { get; set; } + + /// The copyability dimension (alpha-decay, sizing consistency) of the copytrading score (0-100). + public decimal CopytradingCopyabilityScore { get; set; } + /// When this score was last calculated. public DateTime CalculatedAt { get; set; } = DateTime.UtcNow; diff --git a/src/Predictalytics.Infrastructure/DependencyInjection.cs b/src/Predictalytics.Infrastructure/DependencyInjection.cs index b84a5d6..b66c9dc 100644 --- a/src/Predictalytics.Infrastructure/DependencyInjection.cs +++ b/src/Predictalytics.Infrastructure/DependencyInjection.cs @@ -73,6 +73,8 @@ public static class DependencyInjection services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddSingleton(); services.AddSingleton(); diff --git a/src/Predictalytics.Infrastructure/Migrations/20260706081202_AddCopytradingScoreAndTapeFills.Designer.cs b/src/Predictalytics.Infrastructure/Migrations/20260706081202_AddCopytradingScoreAndTapeFills.Designer.cs new file mode 100644 index 0000000..ee68f8c --- /dev/null +++ b/src/Predictalytics.Infrastructure/Migrations/20260706081202_AddCopytradingScoreAndTapeFills.Designer.cs @@ -0,0 +1,978 @@ +// +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 + { + /// + 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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("IsRead") + .HasColumnType("tinyint(1)"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("varchar(4096)"); + + b.Property("Platform") + .HasColumnType("int"); + + b.Property("Severity") + .HasColumnType("int"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("varchar(512)"); + + b.Property("TraderId") + .HasColumnType("int"); + + b.Property("Type") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("TraderId"); + + b.ToTable("Alerts"); + }); + + modelBuilder.Entity("Predictalytics.Domain.Entities.Event", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("DbCreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Description") + .HasMaxLength(4096) + .HasColumnType("varchar(4096)"); + + b.Property("EndDate") + .HasColumnType("datetime(6)"); + + b.Property("ImageUrl") + .HasMaxLength(1024) + .HasColumnType("varchar(1024)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("IsClosed") + .HasColumnType("tinyint(1)"); + + b.Property("LastUpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Platform") + .HasColumnType("int"); + + b.Property("PlatformEventId") + .HasColumnType("bigint"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("varchar(512)"); + + b.Property("StartDate") + .HasColumnType("datetime(6)"); + + b.Property("Tags") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("varchar(1024)"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Category") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("varchar(64)"); + + b.Property("ConditionId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("DbCreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Description") + .HasMaxLength(4096) + .HasColumnType("varchar(4096)"); + + b.Property("EndDate") + .HasColumnType("datetime(6)"); + + b.Property("EventId") + .HasColumnType("int"); + + b.Property("ImageUrl") + .HasMaxLength(1024) + .HasColumnType("varchar(1024)"); + + b.Property("IsResolved") + .HasColumnType("tinyint(1)"); + + b.Property("LastTradesUpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("LastUpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Liquidity") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("MarketSlug") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("varchar(512)"); + + b.Property("Platform") + .HasColumnType("int"); + + b.Property("PlatformMarketId") + .HasColumnType("bigint"); + + b.Property("Question") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("varchar(1024)"); + + b.Property("QuestionId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("ResolutionOutcome") + .HasColumnType("longtext"); + + b.Property("StartDate") + .HasColumnType("datetime(6)"); + + b.Property("Subcategory") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("varchar(128)"); + + b.Property("Volume") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("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("MarketId") + .HasColumnType("int"); + + b.Property("AverageTradeSize") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("BotActivityScore") + .HasPrecision(8, 4) + .HasColumnType("decimal(8,4)"); + + b.Property("LastCalculatedAt") + .HasColumnType("datetime(6)"); + + b.Property("UniqueTradersCount") + .HasColumnType("int"); + + b.HasKey("MarketId"); + + b.ToTable("MarketAnalytics"); + }); + + modelBuilder.Entity("Predictalytics.Domain.Entities.MarketOutcome", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CurrentPrice") + .HasPrecision(18, 8) + .HasColumnType("decimal(18,8)"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("MarketId") + .HasColumnType("int"); + + b.Property("OutcomeIndex") + .HasColumnType("int"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("MarketOutcomeId") + .HasColumnType("int"); + + b.Property("Price") + .HasPrecision(10, 6) + .HasColumnType("decimal(10,6)"); + + b.Property("Timestamp") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("MarketOutcomeId", "Timestamp"); + + b.ToTable("MarketOutcomePriceSnapshots"); + }); + + modelBuilder.Entity("Predictalytics.Domain.Entities.PlatformConfig", b => + { + b.Property("Id") + .HasColumnType("int"); + + b.Property("BaseUrl") + .HasMaxLength(1024) + .HasColumnType("varchar(1024)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("varchar(128)"); + + b.Property("SettingsJson") + .HasColumnType("longtext"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.ToTable("PlatformConfigs"); + }); + + modelBuilder.Entity("Predictalytics.Domain.Entities.Trade", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Amount") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("AssetId") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("varchar(80)"); + + b.Property("DbMarketId") + .HasColumnType("int"); + + b.Property("ExecutedAt") + .HasColumnType("datetime(6)"); + + b.Property("IsContextEnriched") + .HasColumnType("tinyint(1)"); + + b.Property("MarketId") + .IsRequired() + .HasMaxLength(66) + .HasColumnType("varchar(66)"); + + b.Property("MarketOutcomeId") + .HasColumnType("int"); + + b.Property("Outcome") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("varchar(128)"); + + b.Property("Platform") + .HasColumnType("int"); + + b.Property("PlatformTradeId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("PostTradePrice1m") + .HasColumnType("decimal(18,4)"); + + b.Property("PreTradePrice1m") + .HasColumnType("decimal(18,4)"); + + b.Property("Price") + .HasPrecision(18, 6) + .HasColumnType("decimal(18,6)"); + + b.Property("Side") + .HasColumnType("int"); + + b.Property("Size") + .HasPrecision(14, 6) + .HasColumnType("decimal(14,6)"); + + b.Property("TraderId") + .HasColumnType("int"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("EstimatedOrderType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("varchar(32)"); + + b.Property("EstimatedSlippage") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("FollowerFillPrice10s") + .HasColumnType("decimal(65,30)"); + + b.Property("FollowerFillPrice60s") + .HasColumnType("decimal(65,30)"); + + b.Property("PriceAfter1m") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("PriceBefore1m") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("TradeId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("TradeId") + .IsUnique(); + + b.ToTable("TradeContexts"); + }); + + modelBuilder.Entity("Predictalytics.Domain.Entities.Trader", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("AiStrategySummary") + .HasColumnType("longtext"); + + b.Property("AiStrategyUpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("IsAutoDiscovered") + .HasColumnType("tinyint(1)"); + + b.Property("IsInitialImportComplete") + .HasColumnType("tinyint(1)"); + + b.Property("IsSuspectedBot") + .HasColumnType("tinyint(1)"); + + b.Property("LastAnalyzedAt") + .HasColumnType("datetime(6)"); + + b.Property("LastApiErrorAt") + .HasColumnType("datetime(6)"); + + b.Property("LastPolledAt") + .HasColumnType("datetime(6)"); + + b.Property("LastTradesUpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("ManualPriorityOverride") + .HasColumnType("int"); + + b.Property("Notes") + .HasColumnType("longtext"); + + b.Property("Platform") + .HasColumnType("int"); + + b.Property("PlatformUserId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("varchar(128)"); + + b.Property("Strategy") + .HasColumnType("int"); + + b.Property("Tier") + .HasColumnType("int"); + + b.Property("TotalPnl") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("TotalTrades") + .HasColumnType("int"); + + b.Property("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("TraderId") + .HasColumnType("int"); + + b.Property("LastCalculatedAt") + .HasColumnType("datetime(6)"); + + b.Property("OverallPnL") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("OverallWinRate") + .HasPrecision(8, 4) + .HasColumnType("decimal(8,4)"); + + b.Property("PnL24h") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("PnL30d") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("PnL7d") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("WinRate24h") + .HasPrecision(8, 4) + .HasColumnType("decimal(8,4)"); + + b.Property("WinRate30d") + .HasPrecision(8, 4) + .HasColumnType("decimal(8,4)"); + + b.Property("WinRate7d") + .HasPrecision(8, 4) + .HasColumnType("decimal(8,4)"); + + b.HasKey("TraderId"); + + b.ToTable("TraderAnalytics"); + }); + + modelBuilder.Entity("Predictalytics.Domain.Entities.TraderCategoryPerformance", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Category") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("varchar(64)"); + + b.Property("Subcategory") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("varchar(128)"); + + b.Property("TotalPnL") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("TotalTrades") + .HasColumnType("int"); + + b.Property("TotalVolume") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("TraderId") + .HasColumnType("int"); + + b.Property("WinningTrades") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("TraderId", "Category", "Subcategory") + .IsUnique(); + + b.ToTable("TraderCategoryPerformances"); + }); + + modelBuilder.Entity("Predictalytics.Domain.Entities.TraderPosition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("AvgCost") + .HasPrecision(10, 6) + .HasColumnType("decimal(10,6)"); + + b.Property("LastUpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("MarketOutcomeId") + .HasColumnType("int"); + + b.Property("RealizedPnl") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("SharesHeld") + .HasPrecision(14, 6) + .HasColumnType("decimal(14,6)"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("ActivityScore") + .HasPrecision(8, 4) + .HasColumnType("decimal(8,4)"); + + b.Property("CalculatedAt") + .HasColumnType("datetime(6)"); + + b.Property("CombinedScore") + .HasPrecision(8, 4) + .HasColumnType("decimal(8,4)"); + + b.Property("CopytradingCopyabilityScore") + .HasColumnType("decimal(65,30)"); + + b.Property("CopytradingQualityScore") + .HasColumnType("decimal(65,30)"); + + b.Property("CopytradingScore") + .HasPrecision(8, 4) + .HasColumnType("decimal(8,4)"); + + b.Property("QualityScore") + .HasPrecision(8, 4) + .HasColumnType("decimal(8,4)"); + + b.Property("Rank") + .HasColumnType("int"); + + b.Property("TimingScore") + .HasPrecision(8, 4) + .HasColumnType("decimal(8,4)"); + + b.Property("TraderId") + .HasColumnType("int"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("AddedAt") + .HasColumnType("datetime(6)"); + + b.Property("AlertsEnabled") + .HasColumnType("tinyint(1)"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("Notes") + .HasColumnType("longtext"); + + b.Property("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 + } + } +} diff --git a/src/Predictalytics.Infrastructure/Migrations/20260706081202_AddCopytradingScoreAndTapeFills.cs b/src/Predictalytics.Infrastructure/Migrations/20260706081202_AddCopytradingScoreAndTapeFills.cs new file mode 100644 index 0000000..7de5963 --- /dev/null +++ b/src/Predictalytics.Infrastructure/Migrations/20260706081202_AddCopytradingScoreAndTapeFills.cs @@ -0,0 +1,60 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Predictalytics.Infrastructure.Migrations +{ + /// + public partial class AddCopytradingScoreAndTapeFills : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "CopytradingCopyabilityScore", + table: "TraderScores", + type: "decimal(65,30)", + nullable: false, + defaultValue: 0m); + + migrationBuilder.AddColumn( + name: "CopytradingQualityScore", + table: "TraderScores", + type: "decimal(65,30)", + nullable: false, + defaultValue: 0m); + + migrationBuilder.AddColumn( + name: "FollowerFillPrice10s", + table: "TradeContexts", + type: "decimal(65,30)", + nullable: true); + + migrationBuilder.AddColumn( + name: "FollowerFillPrice60s", + table: "TradeContexts", + type: "decimal(65,30)", + nullable: true); + } + + /// + 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"); + } + } +} diff --git a/src/Predictalytics.Infrastructure/Migrations/AppDbContextModelSnapshot.cs b/src/Predictalytics.Infrastructure/Migrations/AppDbContextModelSnapshot.cs index ff72cfb..4b4f327 100644 --- a/src/Predictalytics.Infrastructure/Migrations/AppDbContextModelSnapshot.cs +++ b/src/Predictalytics.Infrastructure/Migrations/AppDbContextModelSnapshot.cs @@ -463,6 +463,12 @@ namespace Predictalytics.Infrastructure.Migrations .HasPrecision(18, 4) .HasColumnType("decimal(18,4)"); + b.Property("FollowerFillPrice10s") + .HasColumnType("decimal(65,30)"); + + b.Property("FollowerFillPrice60s") + .HasColumnType("decimal(65,30)"); + b.Property("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("CopytradingCopyabilityScore") + .HasColumnType("decimal(65,30)"); + + b.Property("CopytradingQualityScore") + .HasColumnType("decimal(65,30)"); + b.Property("CopytradingScore") .HasPrecision(8, 4) .HasColumnType("decimal(8,4)"); diff --git a/src/Predictalytics.Infrastructure/Providers/Polymarket/PolymarketApiClient.cs b/src/Predictalytics.Infrastructure/Providers/Polymarket/PolymarketApiClient.cs index 70705dd..5be708c 100644 --- a/src/Predictalytics.Infrastructure/Providers/Polymarket/PolymarketApiClient.cs +++ b/src/Predictalytics.Infrastructure/Providers/Polymarket/PolymarketApiClient.cs @@ -46,9 +46,9 @@ public class PolymarketApiClient return await ExecuteWithRetryAsync>(_client, url, "Data", ct) ?? []; } - public async Task> GetMarketTradesAsync(string conditionId, int limit = 1000, CancellationToken ct = default) + public async Task> 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>(_client, url, "Data", ct) ?? []; } diff --git a/src/Predictalytics.Infrastructure/Providers/Polymarket/PolymarketProvider.cs b/src/Predictalytics.Infrastructure/Providers/Polymarket/PolymarketProvider.cs index 2cc58c3..76764f7 100644 --- a/src/Predictalytics.Infrastructure/Providers/Polymarket/PolymarketProvider.cs +++ b/src/Predictalytics.Infrastructure/Providers/Polymarket/PolymarketProvider.cs @@ -65,7 +65,7 @@ public class PolymarketProvider : IPlatformProvider public async Task> 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 => diff --git a/src/Predictalytics.Infrastructure/Services/CopytradingBacktestHarness.cs b/src/Predictalytics.Infrastructure/Services/CopytradingBacktestHarness.cs new file mode 100644 index 0000000..9290986 --- /dev/null +++ b/src/Predictalytics.Infrastructure/Services/CopytradingBacktestHarness.cs @@ -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> RunLatencySweepAsync(Trader trader, IReadOnlyList trades, CancellationToken ct = default) + { + var results = new List(); + 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; + } +} diff --git a/src/Predictalytics.Infrastructure/Services/CopytradingEstimator.cs b/src/Predictalytics.Infrastructure/Services/CopytradingEstimator.cs new file mode 100644 index 0000000..0b78d9b --- /dev/null +++ b/src/Predictalytics.Infrastructure/Services/CopytradingEstimator.cs @@ -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 _logger; + private readonly PolymarketApiClient _polymarketClient; + private readonly IRateLimiter _rateLimiter; + + public CopytradingEstimator(ILogger logger, PolymarketApiClient polymarketClient, IRateLimiter rateLimiter) + { + _logger = logger; + _polymarketClient = polymarketClient; + _rateLimiter = rateLimiter; + } + + public async Task<(decimal QualityScore, decimal CopyabilityScore, decimal CombinedScore)> CalculateScoresAsync( + Trader trader, + IReadOnlyList 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 events) + { + var buyEvents = events.Where(e => e.Side == Predictalytics.Domain.Enums.TradeSide.Buy).ToList(); + if (buyEvents.Count == 0) return 0; + + var edges = new List(); + + 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 CalculateCopyabilityScoreAsync(Trader trader, IReadOnlyList events, CancellationToken ct) + { + if (events.Count == 0) return Task.FromResult(0m); + + var alphaDecays = new List(); + var sizes = new List(); + + 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 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(); + 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 ClusterTradesIntoEvents(IReadOnlyList trades, TimeSpan window) + { + var events = new List(); + + 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 }, + 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 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(); + 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; + } +} diff --git a/src/Predictalytics.Worker/Services/TradeContextEnrichmentWorker.cs b/src/Predictalytics.Worker/Services/TradeContextEnrichmentWorker.cs index 6a11a5f..b07b3d4 100644 --- a/src/Predictalytics.Worker/Services/TradeContextEnrichmentWorker.cs +++ b/src/Predictalytics.Worker/Services/TradeContextEnrichmentWorker.cs @@ -38,8 +38,10 @@ public class TradeContextEnrichmentWorker : BackgroundService var polymarketClient = scope.ServiceProvider.GetRequiredService(); var rateLimiter = scope.ServiceProvider.GetRequiredService(); + var estimator = scope.ServiceProvider.GetRequiredService(); + // 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(); 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) };
No traders tracked yet.