diff --git a/src/Predictalytics.Application/Services/AnalyticsService.cs b/src/Predictalytics.Application/Services/AnalyticsService.cs index de96137..7a26dde 100644 --- a/src/Predictalytics.Application/Services/AnalyticsService.cs +++ b/src/Predictalytics.Application/Services/AnalyticsService.cs @@ -155,7 +155,7 @@ public class AnalyticsService : IAnalyticsService if (highlyCopyable) { - traders = traders.Where(t => t.CurrentScore != null && t.CurrentScore.CopytradingScore >= 60).ToList(); + traders = traders.Where(t => t.Analytics != null && t.Analytics.CopytradingScore >= 60).ToList(); } traders = traders.Skip(skip).Take(take).ToList(); @@ -201,6 +201,7 @@ public class AnalyticsService : IAnalyticsService var trades = await _tradeRepo.GetByTraderIdAsync(traderId, 0, 50, ct); var wl = await _watchlistRepo.GetByTraderIdAsync(traderId, ct); var s = trader.CurrentScore; + var a = trader.Analytics; var perfs = trader.CategoryPerformances.Select(p => new TraderCategoryPerformanceDto( p.Category.ToString(), p.TotalVolume, @@ -213,7 +214,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?.CopytradingQualityScore ?? 0, s?.CopytradingCopyabilityScore ?? 0, s?.Rank ?? 0, wl != null, trader.CreatedAt, trader.LastPolledAt, + s?.CombinedScore ?? 0, a?.CopytradingScore ?? 0, a?.CopytradingQualityScore ?? 0, a?.CopytradingCopyabilityScore ?? 0, s?.Rank ?? 0, wl != null, trader.CreatedAt, trader.LastPolledAt, trader.AiStrategySummary, trades.Select(MapTradeDto).ToList(), perfs); @@ -451,8 +452,8 @@ 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.CurrentScore?.CopytradingQualityScore ?? 0, t.CurrentScore?.CopytradingCopyabilityScore ?? 0, + t.CurrentScore?.CombinedScore ?? 0, t.Analytics?.CopytradingScore ?? 0, + t.Analytics?.CopytradingQualityScore ?? 0, t.Analytics?.CopytradingCopyabilityScore ?? 0, t.WinRate, t.TotalPnl, t.TotalTrades, wIds.Contains(t.Id), t.IsSuspectedBot, t.LastPolledAt); diff --git a/src/Predictalytics.Application/Services/ScoringService.cs b/src/Predictalytics.Application/Services/ScoringService.cs index 956d4b1..002138b 100644 --- a/src/Predictalytics.Application/Services/ScoringService.cs +++ b/src/Predictalytics.Application/Services/ScoringService.cs @@ -70,22 +70,27 @@ public class ScoringService : IScoringService if (intervals.Average() < 10) botIndicators.Add("Sub-10s trade frequency"); } - trader.IsSuspectedBot = botIndicators.Count > 0; + bool aiControlsStrategy = trader.AiStrategyUpdatedAt.HasValue && trader.AiStrategyUpdatedAt.Value > DateTime.UtcNow.AddDays(-7); - var marketKeys = trades - .Select(t => t.DbMarketId.HasValue ? t.DbMarketId.Value.ToString() : t.MarketId) - .Where(k => !string.IsNullOrEmpty(k)) - .ToList(); - var marketsTraded = marketKeys.Distinct().Count(); - var hedgeGroups = trades - .GroupBy(t => t.DbMarketId.HasValue ? t.DbMarketId.Value.ToString() : t.MarketId) - .Where(g => !string.IsNullOrEmpty(g.Key) && g.Select(t => t.Outcome).Distinct().Count() > 1); - var hedgingRate = marketsTraded > 0 ? (decimal)hedgeGroups.Count() / marketsTraded * 100 : 0; - - trader.Strategy = avgSize > 10000 ? Predictalytics.Domain.Enums.StrategyType.Whale : - hedgingRate > 30 ? Predictalytics.Domain.Enums.StrategyType.Hedger : - trader.IsSuspectedBot ? Predictalytics.Domain.Enums.StrategyType.Bot : - Predictalytics.Domain.Enums.StrategyType.Unknown; + if (!aiControlsStrategy) + { + trader.IsSuspectedBot = botIndicators.Count > 0; + + var marketKeys = trades + .Select(t => t.DbMarketId.HasValue ? t.DbMarketId.Value.ToString() : t.MarketId) + .Where(k => !string.IsNullOrEmpty(k)) + .ToList(); + var marketsTraded = marketKeys.Distinct().Count(); + var hedgeGroups = trades + .GroupBy(t => t.DbMarketId.HasValue ? t.DbMarketId.Value.ToString() : t.MarketId) + .Where(g => !string.IsNullOrEmpty(g.Key) && g.Select(t => t.Outcome).Distinct().Count() > 1); + var hedgingRate = marketsTraded > 0 ? (decimal)hedgeGroups.Count() / marketsTraded * 100 : 0; + + trader.Strategy = avgSize > 10000 ? Predictalytics.Domain.Enums.StrategyType.Whale : + hedgingRate > 30 ? Predictalytics.Domain.Enums.StrategyType.Hedger : + trader.IsSuspectedBot ? Predictalytics.Domain.Enums.StrategyType.Bot : + Predictalytics.Domain.Enums.StrategyType.Unknown; + } var score = new PriorityScore(activityScore, qualityScore, volumeScore, timingScore, combined, trader.ManualPriorityOverride); diff --git a/src/Predictalytics.Domain/Entities/AnalyticsEntities.cs b/src/Predictalytics.Domain/Entities/AnalyticsEntities.cs index 99d51d3..219b4bb 100644 --- a/src/Predictalytics.Domain/Entities/AnalyticsEntities.cs +++ b/src/Predictalytics.Domain/Entities/AnalyticsEntities.cs @@ -25,6 +25,15 @@ public class TraderAnalytics public decimal EstimatedBankroll { get; set; } public decimal CurrentBalance { get; set; } + /// Copytrading suitability score (0-100). Generated by the Estimator. + public decimal CopytradingScore { get; set; } + + /// The pure skill/edge dimension of the copytrading score (0-100). Generated by the Estimator. + public decimal CopytradingQualityScore { get; set; } + + /// The copyability dimension (alpha-decay, sizing consistency) of the copytrading score (0-100). Generated by the Estimator. + public decimal CopytradingCopyabilityScore { get; set; } + // Navigation public virtual Trader Trader { get; set; } = null!; } diff --git a/src/Predictalytics.Domain/Entities/TraderDailySnapshot.cs b/src/Predictalytics.Domain/Entities/TraderDailySnapshot.cs new file mode 100644 index 0000000..65cd9bd --- /dev/null +++ b/src/Predictalytics.Domain/Entities/TraderDailySnapshot.cs @@ -0,0 +1,19 @@ +using System; + +namespace Predictalytics.Domain.Entities; + +/// +/// Daily snapshot of a trader's performance metrics. +/// Used to calculate rolling time windows (e.g. 24h, 7d, 30d PnL). +/// +public class TraderDailySnapshot +{ + public int Id { get; set; } + public int TraderId { get; set; } + public DateTime Date { get; set; } + + public decimal TotalPnl { get; set; } + public decimal CurrentBalance { get; set; } + + public Trader Trader { get; set; } = null!; +} diff --git a/src/Predictalytics.Domain/Entities/TraderPosition.cs b/src/Predictalytics.Domain/Entities/TraderPosition.cs index 5267610..34aaddc 100644 --- a/src/Predictalytics.Domain/Entities/TraderPosition.cs +++ b/src/Predictalytics.Domain/Entities/TraderPosition.cs @@ -28,6 +28,12 @@ public class TraderPosition /// ID of the last trade applied to this position. public long LastAppliedTradeId { get; set; } + /// Timestamp of the last trade applied. Used to detect out-of-order inserts. + public DateTime? LastTradeExecutedAt { get; set; } + + /// True if old trades for this position have been pruned/deleted. Prevents resetting. + public bool IsHistoryPruned { get; set; } + /// When this position was last updated. public DateTime LastUpdatedAt { get; set; } = DateTime.UtcNow; diff --git a/src/Predictalytics.Domain/Entities/TraderScore.cs b/src/Predictalytics.Domain/Entities/TraderScore.cs index de904aa..12d1b10 100644 --- a/src/Predictalytics.Domain/Entities/TraderScore.cs +++ b/src/Predictalytics.Domain/Entities/TraderScore.cs @@ -29,15 +29,6 @@ public class TraderScore /// Overall rank among all tracked traders. public int Rank { get; set; } - /// 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/Data/AppDbContext.cs b/src/Predictalytics.Infrastructure/Data/AppDbContext.cs index 0b2109e..562cb4c 100644 --- a/src/Predictalytics.Infrastructure/Data/AppDbContext.cs +++ b/src/Predictalytics.Infrastructure/Data/AppDbContext.cs @@ -18,6 +18,7 @@ public class AppDbContext : DbContext public DbSet MarketAnalytics => Set(); public DbSet TraderPositions => Set(); public DbSet MarketOutcomePriceSnapshots => Set(); + public DbSet TraderDailySnapshots => Set(); public DbSet TraderCategoryPerformances => Set(); public DbSet TradeContexts => Set(); public DbSet BackgroundJobs => Set(); @@ -119,12 +120,13 @@ public class AppDbContext : DbContext mb.Entity(e => { e.HasKey(s => s.Id); - e.Property(s => s.ActivityScore).HasPrecision(8, 4); - e.Property(s => s.QualityScore).HasPrecision(8, 4); - e.Property(s => s.CombinedScore).HasPrecision(8, 4); - e.Property(s => s.VolumeScore).HasPrecision(8, 4); - e.Property(s => s.TimingScore).HasPrecision(8, 4); - e.Property(s => s.CopytradingScore).HasPrecision(8, 4); + e.HasIndex(s => s.TraderId).IsUnique(); + e.Property(s => s.ActivityScore).HasPrecision(5, 2); + e.Property(s => s.QualityScore).HasPrecision(5, 2); + e.Property(s => s.CombinedScore).HasPrecision(5, 2); + e.Property(s => s.VolumeScore).HasPrecision(5, 2); + e.Property(s => s.TimingScore).HasPrecision(5, 2); + e.HasOne(s => s.Trader).WithOne(t => t.CurrentScore).HasForeignKey(s => s.TraderId).OnDelete(DeleteBehavior.Cascade); }); // WatchlistEntry @@ -213,6 +215,16 @@ public class AppDbContext : DbContext e.HasOne(tp => tp.MarketOutcome).WithMany().HasForeignKey(tp => tp.MarketOutcomeId).OnDelete(DeleteBehavior.Cascade); }); + // TraderDailySnapshot + mb.Entity(e => + { + e.HasKey(s => s.Id); + e.HasIndex(s => new { s.TraderId, s.Date }).IsUnique(); + e.Property(s => s.TotalPnl).HasPrecision(18, 4); + e.Property(s => s.CurrentBalance).HasPrecision(18, 4); + e.HasOne(s => s.Trader).WithMany().HasForeignKey(s => s.TraderId).OnDelete(DeleteBehavior.Cascade); + }); + // MarketOutcomePriceSnapshot mb.Entity(e => { diff --git a/src/Predictalytics.Infrastructure/Data/Repositories/TradeRepository.cs b/src/Predictalytics.Infrastructure/Data/Repositories/TradeRepository.cs index 7a2beea..8b70567 100644 --- a/src/Predictalytics.Infrastructure/Data/Repositories/TradeRepository.cs +++ b/src/Predictalytics.Infrastructure/Data/Repositories/TradeRepository.cs @@ -2,13 +2,20 @@ using Predictalytics.Domain.Entities; using Predictalytics.Domain.Enums; using Predictalytics.Domain.Interfaces; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; namespace Predictalytics.Infrastructure.Data.Repositories; public class TradeRepository : ITradeRepository { private readonly AppDbContext _db; - public TradeRepository(AppDbContext db) => _db = db; + private readonly Microsoft.Extensions.Logging.ILogger _logger; + + public TradeRepository(AppDbContext db, Microsoft.Extensions.Logging.ILogger logger) + { + _db = db; + _logger = logger; + } public async Task GetByPlatformTradeIdAsync(PlatformType platform, string platformTradeId, CancellationToken ct = default) => await _db.Trades.FirstOrDefaultAsync(t => t.Platform == platform && t.PlatformTradeId == platformTradeId, ct); @@ -92,7 +99,8 @@ public class TradeRepository : ITradeRepository parameters.Add(t.IsContextEnriched); } - await _db.Database.ExecuteSqlRawAsync(sb.ToString(), parameters.ToArray(), ct); + var rowsInserted = await _db.Database.ExecuteSqlRawAsync(sb.ToString(), parameters.ToArray(), ct); + _logger.LogInformation("Inserted {RowsInserted} trades into the database.", rowsInserted); } } @@ -141,10 +149,11 @@ public class TradeRepository : ITradeRepository .Include(t => t.Trader) .Include(t => t.Trader.CurrentScore) .Include(t => t.Trader.WatchlistEntries) + .Include(t => t.Trader.Analytics) .Where(t => !t.IsContextEnriched && t.Platform == PlatformType.Polymarket && t.AssetId != "") - .Where(t => t.Trader.WatchlistEntries.Any() || (t.Trader.CurrentScore != null && t.Trader.CurrentScore.CopytradingScore > 50)) + .Where(t => t.Trader.WatchlistEntries.Any() || (t.Trader.Analytics != null && t.Trader.Analytics.CopytradingScore > 50)) .OrderByDescending(t => t.ExecutedAt) .Take(limit) .ToListAsync(ct); diff --git a/src/Predictalytics.Infrastructure/Migrations/20260707161145_AddTraderPositionHistoryGuards.Designer.cs b/src/Predictalytics.Infrastructure/Migrations/20260707161145_AddTraderPositionHistoryGuards.Designer.cs new file mode 100644 index 0000000..1a06451 --- /dev/null +++ b/src/Predictalytics.Infrastructure/Migrations/20260707161145_AddTraderPositionHistoryGuards.Designer.cs @@ -0,0 +1,1057 @@ +// +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("20260707161145_AddTraderPositionHistoryGuards")] + partial class AddTraderPositionHistoryGuards + { + /// + 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.BackgroundJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CompletedAt") + .HasColumnType("datetime(6)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("ErrorMessage") + .HasMaxLength(4096) + .HasColumnType("varchar(4096)"); + + b.Property("JobType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("varchar(64)"); + + b.Property("StartedAt") + .HasColumnType("datetime(6)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("varchar(64)"); + + b.Property("TraderId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("JobType"); + + b.HasIndex("Status"); + + b.HasIndex("TraderId"); + + b.ToTable("BackgroundJobs"); + }); + + 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("ClosedAt") + .HasColumnType("datetime(6)"); + + 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("FeeRateBps") + .HasColumnType("decimal(65,30)"); + + b.Property("ImageUrl") + .HasMaxLength(1024) + .HasColumnType("varchar(1024)"); + + b.Property("IsNegRisk") + .HasColumnType("tinyint(1)"); + + 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("CurrentBalance") + .HasColumnType("decimal(65,30)"); + + b.Property("EstimatedBankroll") + .HasColumnType("decimal(65,30)"); + + 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("IsHistoryPruned") + .HasColumnType("tinyint(1)"); + + b.Property("LastAppliedTradeId") + .HasColumnType("bigint"); + + b.Property("LastTradeExecutedAt") + .HasColumnType("datetime(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.BackgroundJob", 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/20260707161145_AddTraderPositionHistoryGuards.cs b/src/Predictalytics.Infrastructure/Migrations/20260707161145_AddTraderPositionHistoryGuards.cs new file mode 100644 index 0000000..5f7c84d --- /dev/null +++ b/src/Predictalytics.Infrastructure/Migrations/20260707161145_AddTraderPositionHistoryGuards.cs @@ -0,0 +1,43 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Predictalytics.Infrastructure.Migrations +{ + /// + public partial class AddTraderPositionHistoryGuards : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "IsHistoryPruned", + table: "TraderPositions", + type: "tinyint(1)", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "LastTradeExecutedAt", + table: "TraderPositions", + type: "datetime(6)", + nullable: true); + + // Step 2.1: Global Reset to force re-calculation of all positions and fix corrupted PnL data + migrationBuilder.Sql("UPDATE TraderPositions SET LastAppliedTradeId = 0;"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "IsHistoryPruned", + table: "TraderPositions"); + + migrationBuilder.DropColumn( + name: "LastTradeExecutedAt", + table: "TraderPositions"); + } + } +} diff --git a/src/Predictalytics.Infrastructure/Migrations/20260707161722_AddTraderDailySnapshots.Designer.cs b/src/Predictalytics.Infrastructure/Migrations/20260707161722_AddTraderDailySnapshots.Designer.cs new file mode 100644 index 0000000..5a76924 --- /dev/null +++ b/src/Predictalytics.Infrastructure/Migrations/20260707161722_AddTraderDailySnapshots.Designer.cs @@ -0,0 +1,1098 @@ +// +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("20260707161722_AddTraderDailySnapshots")] + partial class AddTraderDailySnapshots + { + /// + 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.BackgroundJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CompletedAt") + .HasColumnType("datetime(6)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("ErrorMessage") + .HasMaxLength(4096) + .HasColumnType("varchar(4096)"); + + b.Property("JobType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("varchar(64)"); + + b.Property("StartedAt") + .HasColumnType("datetime(6)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("varchar(64)"); + + b.Property("TraderId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("JobType"); + + b.HasIndex("Status"); + + b.HasIndex("TraderId"); + + b.ToTable("BackgroundJobs"); + }); + + 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("ClosedAt") + .HasColumnType("datetime(6)"); + + 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("FeeRateBps") + .HasColumnType("decimal(65,30)"); + + b.Property("ImageUrl") + .HasMaxLength(1024) + .HasColumnType("varchar(1024)"); + + b.Property("IsNegRisk") + .HasColumnType("tinyint(1)"); + + 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("CurrentBalance") + .HasColumnType("decimal(65,30)"); + + b.Property("EstimatedBankroll") + .HasColumnType("decimal(65,30)"); + + 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.TraderDailySnapshot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CurrentBalance") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("Date") + .HasColumnType("datetime(6)"); + + b.Property("TotalPnl") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("TraderId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("TraderId", "Date") + .IsUnique(); + + b.ToTable("TraderDailySnapshots"); + }); + + 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("IsHistoryPruned") + .HasColumnType("tinyint(1)"); + + b.Property("LastAppliedTradeId") + .HasColumnType("bigint"); + + b.Property("LastTradeExecutedAt") + .HasColumnType("datetime(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.BackgroundJob", 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.TraderDailySnapshot", b => + { + b.HasOne("Predictalytics.Domain.Entities.Trader", "Trader") + .WithMany() + .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/20260707161722_AddTraderDailySnapshots.cs b/src/Predictalytics.Infrastructure/Migrations/20260707161722_AddTraderDailySnapshots.cs new file mode 100644 index 0000000..4e3e302 --- /dev/null +++ b/src/Predictalytics.Infrastructure/Migrations/20260707161722_AddTraderDailySnapshots.cs @@ -0,0 +1,52 @@ +using System; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Predictalytics.Infrastructure.Migrations +{ + /// + public partial class AddTraderDailySnapshots : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "TraderDailySnapshots", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn), + TraderId = table.Column(type: "int", nullable: false), + Date = table.Column(type: "datetime(6)", nullable: false), + TotalPnl = table.Column(type: "decimal(18,4)", precision: 18, scale: 4, nullable: false), + CurrentBalance = table.Column(type: "decimal(18,4)", precision: 18, scale: 4, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_TraderDailySnapshots", x => x.Id); + table.ForeignKey( + name: "FK_TraderDailySnapshots_Traders_TraderId", + column: x => x.TraderId, + principalTable: "Traders", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateIndex( + name: "IX_TraderDailySnapshots_TraderId_Date", + table: "TraderDailySnapshots", + columns: new[] { "TraderId", "Date" }, + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "TraderDailySnapshots"); + } + } +} diff --git a/src/Predictalytics.Infrastructure/Migrations/AppDbContextModelSnapshot.cs b/src/Predictalytics.Infrastructure/Migrations/AppDbContextModelSnapshot.cs index 16c5b2b..6659991 100644 --- a/src/Predictalytics.Infrastructure/Migrations/AppDbContextModelSnapshot.cs +++ b/src/Predictalytics.Infrastructure/Migrations/AppDbContextModelSnapshot.cs @@ -718,6 +718,36 @@ namespace Predictalytics.Infrastructure.Migrations b.ToTable("TraderCategoryPerformances"); }); + modelBuilder.Entity("Predictalytics.Domain.Entities.TraderDailySnapshot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CurrentBalance") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("Date") + .HasColumnType("datetime(6)"); + + b.Property("TotalPnl") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("TraderId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("TraderId", "Date") + .IsUnique(); + + b.ToTable("TraderDailySnapshots"); + }); + modelBuilder.Entity("Predictalytics.Domain.Entities.TraderPosition", b => { b.Property("Id") @@ -730,9 +760,15 @@ namespace Predictalytics.Infrastructure.Migrations .HasPrecision(10, 6) .HasColumnType("decimal(10,6)"); + b.Property("IsHistoryPruned") + .HasColumnType("tinyint(1)"); + b.Property("LastAppliedTradeId") .HasColumnType("bigint"); + b.Property("LastTradeExecutedAt") + .HasColumnType("datetime(6)"); + b.Property("LastUpdatedAt") .HasColumnType("datetime(6)"); @@ -970,6 +1006,17 @@ namespace Predictalytics.Infrastructure.Migrations b.Navigation("Trader"); }); + modelBuilder.Entity("Predictalytics.Domain.Entities.TraderDailySnapshot", b => + { + b.HasOne("Predictalytics.Domain.Entities.Trader", "Trader") + .WithMany() + .HasForeignKey("TraderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Trader"); + }); + modelBuilder.Entity("Predictalytics.Domain.Entities.TraderPosition", b => { b.HasOne("Predictalytics.Domain.Entities.MarketOutcome", "MarketOutcome") diff --git a/src/Predictalytics.Infrastructure/Services/PositionPnLEngine.cs b/src/Predictalytics.Infrastructure/Services/PositionPnLEngine.cs index b37447f..61a2023 100644 --- a/src/Predictalytics.Infrastructure/Services/PositionPnLEngine.cs +++ b/src/Predictalytics.Infrastructure/Services/PositionPnLEngine.cs @@ -48,6 +48,30 @@ public class PositionPnLEngine : IPositionPnLEngine .Where(tp => tp.TraderId == traderId) .ToDictionaryAsync(tp => tp.MarketOutcomeId, ct); + // Pre-flight check: Detect out-of-order unapplied trades (Bug 3) or manually reset checkpoints (Bug 2a) + foreach (var pos in existingPositions.Values) + { + if (pos.IsHistoryPruned) continue; // Cannot reset if history is pruned + + var outcomeTrades = trades.Where(t => t.MarketOutcomeId == pos.MarketOutcomeId).ToList(); + bool needsReset = pos.LastAppliedTradeId == 0 || + outcomeTrades.Any(t => t.Id > pos.LastAppliedTradeId && pos.LastTradeExecutedAt.HasValue && t.ExecutedAt < pos.LastTradeExecutedAt.Value); + + if (needsReset) + { + if (pos.LastAppliedTradeId > 0) + { + _logger.LogInformation("Out-of-order unapplied trade detected for Trader {TraderId} Outcome {OutcomeId}. Resetting position.", traderId, pos.MarketOutcomeId); + } + + pos.SharesHeld = 0; + pos.AvgCost = 0; + pos.RealizedPnl = 0; + pos.LastAppliedTradeId = 0; + pos.LastTradeExecutedAt = null; + } + } + var analytics = trader.Analytics; if (analytics == null) { @@ -60,10 +84,6 @@ public class PositionPnLEngine : IPositionPnLEngine var cutoff30d = DateTime.UtcNow.AddDays(-30); var cutoff7d = DateTime.UtcNow.AddDays(-7); var cutoff24h = DateTime.UtcNow.AddHours(-24); - - var realizedPnl30d = 0m; - var realizedPnl7d = 0m; - var realizedPnl24h = 0m; decimal currentBalance = analytics.CurrentBalance; decimal estimatedBankroll = analytics.EstimatedBankroll; @@ -97,10 +117,13 @@ public class PositionPnLEngine : IPositionPnLEngine { TraderId = traderId, MarketOutcomeId = outcomeId, + MarketOutcome = trade.MarketOutcome, // Bug 8 Fix: Assign immediately so virtual payout runs SharesHeld = 0, AvgCost = 0, RealizedPnl = 0, - LastAppliedTradeId = 0 + LastAppliedTradeId = 0, + LastTradeExecutedAt = null, + IsHistoryPruned = false }; } pos.LastUpdatedAt = DateTime.UtcNow; @@ -160,6 +183,25 @@ public class PositionPnLEngine : IPositionPnLEngine case TradeSide.Split: case TradeSide.Merge: + var cashEquivalent = Math.Abs(trade.Size) * trade.Price; + if (trade.Size > 0) + { + currentBalance -= cashEquivalent; + var totalCost = (pos.SharesHeld * pos.AvgCost) + cashEquivalent; + var totalShares = pos.SharesHeld + trade.Size; + pos.AvgCost = totalShares > 0 ? totalCost / totalShares : 0; + pos.SharesHeld = totalShares; + } + else if (trade.Size < 0) + { + var absSize = Math.Abs(trade.Size); + currentBalance += cashEquivalent; + var splitSizeToSell = Math.Min(absSize, pos.SharesHeld); + pos.RealizedPnl += splitSizeToSell * (trade.Price - pos.AvgCost); + pos.SharesHeld -= absSize; + if (pos.SharesHeld < 0) pos.SharesHeld = 0; + } + break; case TradeSide.AddLiquidity: case TradeSide.RemoveLiquidity: case TradeSide.Unknown: @@ -174,15 +216,12 @@ public class PositionPnLEngine : IPositionPnLEngine } pos.LastAppliedTradeId = Math.Max(pos.LastAppliedTradeId, trade.Id); + if (pos.LastTradeExecutedAt == null || trade.ExecutedAt > pos.LastTradeExecutedAt.Value) + { + pos.LastTradeExecutedAt = trade.ExecutedAt; + } var realizedPnlDelta = pos.RealizedPnl - previousRealizedPnl; - if (realizedPnlDelta != 0) - { - // This will only accumulate deltas for NEW trades. - if (trade.ExecutedAt >= cutoff30d) realizedPnl30d += realizedPnlDelta; - if (trade.ExecutedAt >= cutoff7d) realizedPnl7d += realizedPnlDelta; - if (trade.ExecutedAt >= cutoff24h) realizedPnl24h += realizedPnlDelta; - } } // Bug 6: Virtual payout for unredeemed winning positions @@ -200,10 +239,6 @@ public class PositionPnLEngine : IPositionPnLEngine pos.RealizedPnl += virtualPnlDelta; pos.SharesHeld = 0; pos.AvgCost = 0; - - if (market.ClosedAt.HasValue && market.ClosedAt.Value >= cutoff30d) realizedPnl30d += virtualPnlDelta; - if (market.ClosedAt.HasValue && market.ClosedAt.Value >= cutoff7d) realizedPnl7d += virtualPnlDelta; - if (market.ClosedAt.HasValue && market.ClosedAt.Value >= cutoff24h) realizedPnl24h += virtualPnlDelta; } } } @@ -211,9 +246,6 @@ public class PositionPnLEngine : IPositionPnLEngine // Persist new / updated positions and calculate total values decimal totalRealizedPnl = 0; decimal totalUnrealizedPnl = 0; - decimal unrealizedPnl30d = 0; - decimal unrealizedPnl7d = 0; - decimal unrealizedPnl24h = 0; foreach (var pos in tempPositions.Values) { @@ -222,10 +254,6 @@ public class PositionPnLEngine : IPositionPnLEngine { var unrealized = pos.SharesHeld * (outcome.CurrentPrice - pos.AvgCost); totalUnrealizedPnl += unrealized; - - if (tradedOutcomes30d.Contains(pos.MarketOutcomeId)) unrealizedPnl30d += unrealized; - if (tradedOutcomes7d.Contains(pos.MarketOutcomeId)) unrealizedPnl7d += unrealized; - if (tradedOutcomes24h.Contains(pos.MarketOutcomeId)) unrealizedPnl24h += unrealized; } totalRealizedPnl += pos.RealizedPnl; @@ -239,27 +267,60 @@ public class PositionPnLEngine : IPositionPnLEngine } } - // Remove positions for outcomes that have no trades anymore - foreach (var outcomeId in existingPositions.Keys) - { - if (!tempPositions.ContainsKey(outcomeId)) - { - _db.TraderPositions.Remove(existingPositions[outcomeId]); - } - } + // Bug 7: Positions are intentionally kept even if their trades are pruned by retention policies. // Update analytics record // (Analytics instance is already retrieved at the top of this method) var overallPnl = totalRealizedPnl + totalUnrealizedPnl; analytics.OverallPnL = overallPnl; - analytics.PnL30d = realizedPnl30d + unrealizedPnl30d; - analytics.PnL7d = realizedPnl7d + unrealizedPnl7d; - analytics.PnL24h = realizedPnl24h + unrealizedPnl24h; - analytics.CurrentBalance = currentBalance; analytics.EstimatedBankroll = estimatedBankroll; + // Bug 1: Calculate time windows based on historical snapshots + var today = DateTime.UtcNow.Date; + + // Save today's snapshot + var todaySnapshot = await _db.TraderDailySnapshots + .FirstOrDefaultAsync(s => s.TraderId == traderId && s.Date == today, ct); + + if (todaySnapshot == null) + { + todaySnapshot = new TraderDailySnapshot + { + TraderId = traderId, + Date = today, + TotalPnl = overallPnl, + CurrentBalance = currentBalance + }; + _db.TraderDailySnapshots.Add(todaySnapshot); + } + else + { + todaySnapshot.TotalPnl = overallPnl; + todaySnapshot.CurrentBalance = currentBalance; + } + + // Fetch historical snapshots + var snapshot24h = await _db.TraderDailySnapshots + .Where(s => s.TraderId == traderId && s.Date <= today.AddDays(-1)) + .OrderByDescending(s => s.Date) + .FirstOrDefaultAsync(ct); + + var snapshot7d = await _db.TraderDailySnapshots + .Where(s => s.TraderId == traderId && s.Date <= today.AddDays(-7)) + .OrderByDescending(s => s.Date) + .FirstOrDefaultAsync(ct); + + var snapshot30d = await _db.TraderDailySnapshots + .Where(s => s.TraderId == traderId && s.Date <= today.AddDays(-30)) + .OrderByDescending(s => s.Date) + .FirstOrDefaultAsync(ct); + + analytics.PnL24h = overallPnl - (snapshot24h?.TotalPnl ?? 0); + analytics.PnL7d = overallPnl - (snapshot7d?.TotalPnl ?? 0); + analytics.PnL30d = overallPnl - (snapshot30d?.TotalPnl ?? 0); + // Calculate Win Rate on Market level var (winRateOverall, winRate30d, winRate7d, winRate24h) = CalculateMarketWinRates(trades, tempPositions, cutoff30d, cutoff7d, cutoff24h); diff --git a/src/Predictalytics.Worker/Services/TradeReconciliationWorker.cs b/src/Predictalytics.Worker/Services/TradeReconciliationWorker.cs index e79d97f..176d177 100644 --- a/src/Predictalytics.Worker/Services/TradeReconciliationWorker.cs +++ b/src/Predictalytics.Worker/Services/TradeReconciliationWorker.cs @@ -102,6 +102,12 @@ public class TradeReconciliationWorker : BackgroundService } // 2. Bulk update orphaned trades + var pairsToReset = await db.Trades + .Where(t => t.MarketOutcomeId == null && t.AssetId != "") + .Join(db.MarketOutcomes, t => t.AssetId, o => o.TokenId, (t, o) => new { t.TraderId, MarketOutcomeId = o.Id }) + .Distinct() + .ToListAsync(ct); + reconciledCount = await Microsoft.EntityFrameworkCore.RelationalDatabaseFacadeExtensions.ExecuteSqlRawAsync(db.Database, @" UPDATE Trades t INNER JOIN MarketOutcomes o ON t.AssetId = o.TokenId @@ -112,21 +118,35 @@ public class TradeReconciliationWorker : BackgroundService WHERE t.MarketOutcomeId IS NULL AND t.AssetId != ''; ", ct); - if (reconciledCount > 0) + if (reconciledCount > 0 && pairsToReset.Any()) { - // Force re-analysis of traders who now have new linked trades that haven't been applied - await Microsoft.EntityFrameworkCore.RelationalDatabaseFacadeExtensions.ExecuteSqlRawAsync(db.Database, @" - UPDATE Traders t - SET LastAnalyzedAt = NULL - WHERE LastAnalyzedAt IS NOT NULL - AND EXISTS ( - SELECT 1 FROM Trades tr - LEFT JOIN TraderPositions tp ON tr.TraderId = tp.TraderId AND tr.MarketOutcomeId = tp.MarketOutcomeId - WHERE tr.TraderId = t.Id - AND tr.MarketOutcomeId IS NOT NULL - AND (tp.Id IS NULL OR tr.Id > tp.LastAppliedTradeId) - ); - ", ct); + // Reset checkpoints for positions that received newly linked older trades + var posIdsToReset = await db.TraderPositions + .Where(tp => pairsToReset.Select(p => p.TraderId).Contains(tp.TraderId) && + pairsToReset.Select(p => p.MarketOutcomeId).Contains(tp.MarketOutcomeId)) + .Select(tp => tp.Id) + .ToListAsync(ct); + + // Need to filter client side since EF core can't translate tuple Contains + var actualPosIdsToReset = (await db.TraderPositions + .Where(tp => posIdsToReset.Contains(tp.Id)) + .ToListAsync(ct)) + .Where(tp => pairsToReset.Any(p => p.TraderId == tp.TraderId && p.MarketOutcomeId == tp.MarketOutcomeId)) + .Select(tp => tp.Id) + .ToList(); + + if (actualPosIdsToReset.Any()) + { + await db.TraderPositions + .Where(tp => actualPosIdsToReset.Contains(tp.Id)) + .ExecuteUpdateAsync(s => s.SetProperty(p => p.LastAppliedTradeId, 0), ct); + } + + // Force re-analysis of traders who now have new linked trades + var traderIds = pairsToReset.Select(p => p.TraderId).Distinct().ToList(); + await db.Traders + .Where(t => traderIds.Contains(t.Id)) + .ExecuteUpdateAsync(s => s.SetProperty(p => p.LastAnalyzedAt, (DateTime?)null), ct); } } catch (Exception ex) diff --git a/src/Predictalytics.Worker/Services/TradeRetentionWorker.cs b/src/Predictalytics.Worker/Services/TradeRetentionWorker.cs index c720e7f..8ab1482 100644 --- a/src/Predictalytics.Worker/Services/TradeRetentionWorker.cs +++ b/src/Predictalytics.Worker/Services/TradeRetentionWorker.cs @@ -71,13 +71,47 @@ public class TradeRetentionWorker : BackgroundService var retentionCutoff = utcNow.Date.AddDays(-retentionDays); var compactionCutoff = utcNow.Date.AddDays(-compactionDays); - // 1. Prune Old Trades (C1 & C2) // Exclude trades if the trader is on any active Watchlist _logger.LogInformation("Pruning trades older than {Cutoff}...", retentionCutoff); + + var deletedTrades = await db.Trades + .Where(t => t.ExecutedAt < retentionCutoff && !t.Trader.WatchlistEntries.Any()) + .Select(t => new { t.TraderId, t.MarketOutcomeId }) + .Distinct() + .ToListAsync(ct); + var deletedCount = await db.Trades .Where(t => t.ExecutedAt < retentionCutoff && !t.Trader.WatchlistEntries.Any()) .ExecuteDeleteAsync(ct); + if (deletedCount > 0 && deletedTrades.Any()) + { + var validDeletedTrades = deletedTrades.Where(d => d.MarketOutcomeId.HasValue).ToList(); + if (validDeletedTrades.Any()) + { + var posIdsToUpdate = await db.TraderPositions + .Where(tp => validDeletedTrades.Select(d => d.TraderId).Contains(tp.TraderId) && + validDeletedTrades.Select(d => d.MarketOutcomeId!.Value).Contains(tp.MarketOutcomeId)) + .Select(tp => tp.Id) + .ToListAsync(ct); + + // Filter on client side due to EF Core limitation with tuple Contains + var actualPosIdsToUpdate = (await db.TraderPositions + .Where(tp => posIdsToUpdate.Contains(tp.Id)) + .ToListAsync(ct)) + .Where(tp => validDeletedTrades.Any(d => d.TraderId == tp.TraderId && d.MarketOutcomeId == tp.MarketOutcomeId)) + .Select(tp => tp.Id) + .ToList(); + + if (actualPosIdsToUpdate.Any()) + { + await db.TraderPositions + .Where(tp => actualPosIdsToUpdate.Contains(tp.Id)) + .ExecuteUpdateAsync(s => s.SetProperty(p => p.IsHistoryPruned, true), ct); + } + } + } + _logger.LogInformation("Pruned {Count} old trades from the database.", deletedCount); // 2. Compact Bot Trades (C3) @@ -100,14 +134,25 @@ public class TradeRetentionWorker : BackgroundService { if (ct.IsCancellationRequested) break; + // Fetch positions to ensure we only compact applied trades and can bump checkpoints + var positions = await db.TraderPositions + .Where(tp => tp.TraderId == traderId) + .ToDictionaryAsync(tp => tp.MarketOutcomeId, ct); + // Load candidate trades to compact (older than compactionCutoff, newer than retentionCutoff) var tradesToCompact = await db.Trades .Where(t => t.TraderId == traderId && t.ExecutedAt >= retentionCutoff && t.ExecutedAt < compactionCutoff && - !t.PlatformTradeId.StartsWith("COMPACT_")) + !t.PlatformTradeId.StartsWith("COMPACT_") && + t.MarketOutcomeId != null) .ToListAsync(ct); + // Filter strictly to trades that are already applied + tradesToCompact = tradesToCompact + .Where(t => positions.TryGetValue(t.MarketOutcomeId!.Value, out var pos) && t.Id <= pos.LastAppliedTradeId) + .ToList(); + if (tradesToCompact.Count == 0) continue; // Group trades by outcome, date, and side to aggregate @@ -160,6 +205,19 @@ public class TradeRetentionWorker : BackgroundService // Add the compacted trade db.Trades.Add(compactedTrade); + + // Save immediately so compactedTrade gets an ID + await db.SaveChangesAsync(ct); + + // Bump the position checkpoint so it doesn't get double counted + if (positions.TryGetValue(outcomeId, out var pos)) + { + pos.LastAppliedTradeId = Math.Max(pos.LastAppliedTradeId, compactedTrade.Id); + // Mark as pruned so we don't accidentally reset and replay (which would lose the exact intraday timestamps) + pos.IsHistoryPruned = true; + db.TraderPositions.Update(pos); + } + compactedTradeCount += list.Count - 1; } diff --git a/src/Predictalytics.Worker/Services/TraderAnalyticsWorker.cs b/src/Predictalytics.Worker/Services/TraderAnalyticsWorker.cs index 855c1d6..b96adfe 100644 --- a/src/Predictalytics.Worker/Services/TraderAnalyticsWorker.cs +++ b/src/Predictalytics.Worker/Services/TraderAnalyticsWorker.cs @@ -63,9 +63,11 @@ public class TraderAnalyticsWorker : BackgroundService { var db = scope.ServiceProvider.GetRequiredService(); // Find traders who have never been analyzed, or whose last analysis was before their latest trade. - // Prioritize never-analyzed traders. + // Bug 5 Fix: Add 30-minute cooldown to prevent CPU looping. + var cooldown = DateTime.UtcNow.AddMinutes(-30); traderIds = await db.Traders - .Where(t => t.LastAnalyzedAt == null || t.Trades.Any(tr => tr.ExecutedAt > t.LastAnalyzedAt)) + .Where(t => t.LastAnalyzedAt == null || + (t.LastAnalyzedAt < cooldown && t.Trades.Any(tr => tr.ExecutedAt > t.LastAnalyzedAt))) .OrderBy(t => t.LastAnalyzedAt == null ? 0 : 1) .ThenBy(t => t.LastAnalyzedAt) .Select(t => t.Id) @@ -89,24 +91,31 @@ public class TraderAnalyticsWorker : BackgroundService // Run CopytradingEstimator var traderRepo = traderScope.ServiceProvider.GetRequiredService(); - var tradeRepo = traderScope.ServiceProvider.GetRequiredService(); + var db = traderScope.ServiceProvider.GetRequiredService(); var estimator = traderScope.ServiceProvider.GetRequiredService(); var trader = await traderRepo.GetByIdAsync(id, ct); if (trader != null) { - var trades = await tradeRepo.GetByTraderIdAsync(id, 0, 1000, ct); + var trades = await db.Trades + .Include(t => t.MarketOutcome) + .Include(t => t.Context) + .Where(t => t.TraderId == id && t.DbMarketId != null) + .OrderByDescending(t => t.ExecutedAt) + .Take(1000) + .ToListAsync(ct); + if (trades.Count > 0) { var estScores = await estimator.CalculateScoresAsync(trader, trades, ct); - var scoreObj = trader.CurrentScore ?? new Predictalytics.Domain.Entities.TraderScore { TraderId = trader.Id }; + var analyticsObj = trader.Analytics ?? new Predictalytics.Domain.Entities.TraderAnalytics { TraderId = trader.Id }; // Persist advanced copyability and quality scores derived from tape replay - scoreObj.CopytradingScore = estScores.CopyabilityScore; - scoreObj.QualityScore = estScores.QualityScore; - scoreObj.CalculatedAt = DateTime.UtcNow; + analyticsObj.CopytradingScore = estScores.CopyabilityScore; + analyticsObj.CopytradingQualityScore = estScores.QualityScore; + analyticsObj.CopytradingCopyabilityScore = estScores.CopyabilityScore; - trader.CurrentScore = scoreObj; + trader.Analytics = analyticsObj; } trader.LastAnalyzedAt = DateTime.UtcNow; await traderRepo.UpdateAsync(trader, ct);