diff --git a/src/Predictalytics.Application/Interfaces/IPositionPnLEngine.cs b/src/Predictalytics.Application/Interfaces/IPositionPnLEngine.cs new file mode 100644 index 0000000..a3b5598 --- /dev/null +++ b/src/Predictalytics.Application/Interfaces/IPositionPnLEngine.cs @@ -0,0 +1,14 @@ +using System.Threading; +using System.Threading.Tasks; + +namespace Predictalytics.Application.Interfaces; + +public interface IPositionPnLEngine +{ + /// + /// Recalculates all positions, average costs, and PnL values for a trader by re-playing their trade history. + /// Updates the TraderPosition records in the database, calculates realized/unrealized PnL, + /// and updates the Trader's TotalPnl and WinRate. + /// + Task RecalculateTraderPositionsAsync(int traderId, CancellationToken ct = default); +} diff --git a/src/Predictalytics.Domain/Entities/Trader.cs b/src/Predictalytics.Domain/Entities/Trader.cs index 177dadf..82bdc53 100644 --- a/src/Predictalytics.Domain/Entities/Trader.cs +++ b/src/Predictalytics.Domain/Entities/Trader.cs @@ -66,4 +66,5 @@ public class Trader public TraderScore? CurrentScore { get; set; } public virtual TraderAnalytics? Analytics { get; set; } public ICollection WatchlistEntries { get; set; } = new List(); + public ICollection Positions { get; set; } = new List(); } diff --git a/src/Predictalytics.Domain/Entities/TraderPosition.cs b/src/Predictalytics.Domain/Entities/TraderPosition.cs new file mode 100644 index 0000000..a9bd068 --- /dev/null +++ b/src/Predictalytics.Domain/Entities/TraderPosition.cs @@ -0,0 +1,34 @@ +using System; + +namespace Predictalytics.Domain.Entities; + +/// +/// Represents a trader's position in a specific market outcome. +/// Tracks shares held, average purchase price, and realized profit/loss. +/// +public class TraderPosition +{ + public int Id { get; set; } + + /// Foreign key to the trader. + public int TraderId { get; set; } + + /// Foreign key to the resolved market outcome. + public int MarketOutcomeId { get; set; } + + /// Number of shares currently held. + public decimal SharesHeld { get; set; } + + /// Average purchase cost per share. + public decimal AvgCost { get; set; } + + /// Realized profit/loss from closed portions of this position. + public decimal RealizedPnl { get; set; } + + /// When this position was last updated. + public DateTime LastUpdatedAt { get; set; } = DateTime.UtcNow; + + // Navigation properties + public Trader Trader { get; set; } = null!; + public MarketOutcome MarketOutcome { get; set; } = null!; +} diff --git a/src/Predictalytics.Infrastructure/Data/AppDbContext.cs b/src/Predictalytics.Infrastructure/Data/AppDbContext.cs index c34c8ef..dc63756 100644 --- a/src/Predictalytics.Infrastructure/Data/AppDbContext.cs +++ b/src/Predictalytics.Infrastructure/Data/AppDbContext.cs @@ -15,6 +15,7 @@ public class AppDbContext : DbContext public DbSet PlatformConfigs => Set(); public DbSet TraderAnalytics => Set(); public DbSet MarketAnalytics => Set(); + public DbSet TraderPositions => Set(); public AppDbContext(DbContextOptions options) : base(options) { } @@ -153,5 +154,17 @@ public class AppDbContext : DbContext e.Property(a => a.BotActivityScore).HasPrecision(8, 4); e.Property(a => a.AverageTradeSize).HasPrecision(18, 4); }); + + // TraderPosition + mb.Entity(e => + { + e.HasKey(tp => tp.Id); + e.HasIndex(tp => new { tp.TraderId, tp.MarketOutcomeId }).IsUnique(); + e.Property(tp => tp.SharesHeld).HasPrecision(14, 6); + e.Property(tp => tp.AvgCost).HasPrecision(10, 6); + e.Property(tp => tp.RealizedPnl).HasPrecision(18, 4); + e.HasOne(tp => tp.Trader).WithMany(t => t.Positions).HasForeignKey(tp => tp.TraderId).OnDelete(DeleteBehavior.Cascade); + e.HasOne(tp => tp.MarketOutcome).WithMany().HasForeignKey(tp => tp.MarketOutcomeId).OnDelete(DeleteBehavior.Cascade); + }); } } diff --git a/src/Predictalytics.Infrastructure/DependencyInjection.cs b/src/Predictalytics.Infrastructure/DependencyInjection.cs index ba3156b..fc1c2fd 100644 --- a/src/Predictalytics.Infrastructure/DependencyInjection.cs +++ b/src/Predictalytics.Infrastructure/DependencyInjection.cs @@ -1,5 +1,6 @@ using Predictalytics.Application.Interfaces; using Predictalytics.Application.Services; +using Predictalytics.Infrastructure.Services; using Predictalytics.Domain.Interfaces; using Predictalytics.Infrastructure.Data; using Predictalytics.Infrastructure.Data.Repositories; @@ -63,6 +64,7 @@ public static class DependencyInjection services.AddScoped(); // Application Services + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/src/Predictalytics.Infrastructure/Migrations/20260703091631_AddTraderPosition.Designer.cs b/src/Predictalytics.Infrastructure/Migrations/20260703091631_AddTraderPosition.Designer.cs new file mode 100644 index 0000000..0d00e01 --- /dev/null +++ b/src/Predictalytics.Infrastructure/Migrations/20260703091631_AddTraderPosition.Designer.cs @@ -0,0 +1,701 @@ +// +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("20260703091631_AddTraderPosition")] + partial class AddTraderPosition + { + /// + 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.Market", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Category") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("varchar(128)"); + + 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("EventSlug") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("varchar(512)"); + + 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") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("Question") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("varchar(1024)"); + + b.Property("ResolutionOutcome") + .HasColumnType("longtext"); + + b.Property("StartDate") + .HasColumnType("datetime(6)"); + + b.Property("Volume") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.HasKey("Id"); + + 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.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("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("Price") + .HasPrecision(10, 6) + .HasColumnType("decimal(10,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.Trader", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + 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("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.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("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.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.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.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.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.Market", b => + { + b.Navigation("Analytics"); + + b.Navigation("Outcomes"); + }); + + modelBuilder.Entity("Predictalytics.Domain.Entities.Trader", b => + { + b.Navigation("Analytics"); + + b.Navigation("CurrentScore"); + + b.Navigation("Positions"); + + b.Navigation("Trades"); + + b.Navigation("WatchlistEntries"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Predictalytics.Infrastructure/Migrations/20260703091631_AddTraderPosition.cs b/src/Predictalytics.Infrastructure/Migrations/20260703091631_AddTraderPosition.cs new file mode 100644 index 0000000..89ef44c --- /dev/null +++ b/src/Predictalytics.Infrastructure/Migrations/20260703091631_AddTraderPosition.cs @@ -0,0 +1,65 @@ +using System; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Predictalytics.Infrastructure.Migrations +{ + /// + public partial class AddTraderPosition : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "TraderPositions", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn), + TraderId = table.Column(type: "int", nullable: false), + MarketOutcomeId = table.Column(type: "int", nullable: false), + SharesHeld = table.Column(type: "decimal(14,6)", precision: 14, scale: 6, nullable: false), + AvgCost = table.Column(type: "decimal(10,6)", precision: 10, scale: 6, nullable: false), + RealizedPnl = table.Column(type: "decimal(18,4)", precision: 18, scale: 4, nullable: false), + LastUpdatedAt = table.Column(type: "datetime(6)", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_TraderPositions", x => x.Id); + table.ForeignKey( + name: "FK_TraderPositions_MarketOutcomes_MarketOutcomeId", + column: x => x.MarketOutcomeId, + principalTable: "MarketOutcomes", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_TraderPositions_Traders_TraderId", + column: x => x.TraderId, + principalTable: "Traders", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateIndex( + name: "IX_TraderPositions_MarketOutcomeId", + table: "TraderPositions", + column: "MarketOutcomeId"); + + migrationBuilder.CreateIndex( + name: "IX_TraderPositions_TraderId_MarketOutcomeId", + table: "TraderPositions", + columns: new[] { "TraderId", "MarketOutcomeId" }, + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "TraderPositions"); + } + } +} diff --git a/src/Predictalytics.Infrastructure/Migrations/AppDbContextModelSnapshot.cs b/src/Predictalytics.Infrastructure/Migrations/AppDbContextModelSnapshot.cs index 2579e8b..438f793 100644 --- a/src/Predictalytics.Infrastructure/Migrations/AppDbContextModelSnapshot.cs +++ b/src/Predictalytics.Infrastructure/Migrations/AppDbContextModelSnapshot.cs @@ -447,6 +447,45 @@ namespace Predictalytics.Infrastructure.Migrations b.ToTable("TraderAnalytics"); }); + 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") @@ -593,6 +632,25 @@ namespace Predictalytics.Infrastructure.Migrations 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") @@ -628,6 +686,8 @@ namespace Predictalytics.Infrastructure.Migrations b.Navigation("CurrentScore"); + b.Navigation("Positions"); + b.Navigation("Trades"); b.Navigation("WatchlistEntries"); diff --git a/src/Predictalytics.Infrastructure/Services/PositionPnLEngine.cs b/src/Predictalytics.Infrastructure/Services/PositionPnLEngine.cs new file mode 100644 index 0000000..730f9a0 --- /dev/null +++ b/src/Predictalytics.Infrastructure/Services/PositionPnLEngine.cs @@ -0,0 +1,325 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Predictalytics.Application.Interfaces; +using Predictalytics.Domain.Entities; +using Predictalytics.Domain.Enums; +using Predictalytics.Infrastructure.Data; + +namespace Predictalytics.Infrastructure.Services; + +public class PositionPnLEngine : IPositionPnLEngine +{ + private readonly AppDbContext _db; + private readonly ILogger _logger; + + public PositionPnLEngine(AppDbContext db, ILogger logger) + { + _db = db; + _logger = logger; + } + + public async Task RecalculateTraderPositionsAsync(int traderId, CancellationToken ct = default) + { + var trader = await _db.Traders + .Include(t => t.Analytics) + .FirstOrDefaultAsync(t => t.Id == traderId, ct); + + if (trader == null) + { + _logger.LogWarning("RecalculateTraderPositions: Trader {TraderId} not found.", traderId); + return; + } + + // Fetch all trades for this trader, sorted chronologically, including outcomes and markets + var trades = await _db.Trades + .Include(t => t.MarketOutcome) + .ThenInclude(o => o!.Market) + .Where(t => t.TraderId == traderId) + .OrderBy(t => t.ExecutedAt) + .ToListAsync(ct); + + // Fetch existing positions for this trader to update or replace them + var existingPositions = await _db.TraderPositions + .Where(tp => tp.TraderId == traderId) + .ToDictionaryAsync(tp => tp.MarketOutcomeId, ct); + + var tempPositions = new Dictionary(); + + 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; + + // Tracks outcomes traded within time frames + var tradedOutcomes30d = new HashSet(); + var tradedOutcomes7d = new HashSet(); + var tradedOutcomes24h = new HashSet(); + + foreach (var trade in trades) + { + if (trade.MarketOutcomeId == null || trade.MarketOutcome == null) + continue; + + var outcomeId = trade.MarketOutcomeId.Value; + + // Track if trade is within windows + if (trade.ExecutedAt >= cutoff30d) tradedOutcomes30d.Add(outcomeId); + if (trade.ExecutedAt >= cutoff7d) tradedOutcomes7d.Add(outcomeId); + if (trade.ExecutedAt >= cutoff24h) tradedOutcomes24h.Add(outcomeId); + + if (!tempPositions.TryGetValue(outcomeId, out var pos)) + { + if (existingPositions.TryGetValue(outcomeId, out var existing)) + { + pos = existing; + } + else + { + pos = new TraderPosition + { + TraderId = traderId, + MarketOutcomeId = outcomeId, + SharesHeld = 0, + AvgCost = 0, + RealizedPnl = 0 + }; + } + pos.LastUpdatedAt = DateTime.UtcNow; + tempPositions[outcomeId] = pos; + } + + var previousRealizedPnl = pos.RealizedPnl; + + // Apply trade side booking rules + switch (trade.Side) + { + case TradeSide.Buy: + if (pos.SharesHeld == 0) + { + pos.AvgCost = trade.Price; + pos.SharesHeld = trade.Size; + } + else + { + // Weighted average cost + var totalCost = (pos.SharesHeld * pos.AvgCost) + (trade.Size * trade.Price); + var totalShares = pos.SharesHeld + trade.Size; + pos.AvgCost = totalShares > 0 ? totalCost / totalShares : 0; + pos.SharesHeld = totalShares; + } + break; + + case TradeSide.Sell: + var sizeToSell = Math.Min(trade.Size, pos.SharesHeld); + pos.RealizedPnl += sizeToSell * (trade.Price - pos.AvgCost); + pos.SharesHeld -= trade.Size; + if (pos.SharesHeld < 0) + { + pos.SharesHeld = 0; // clamp to 0 + } + break; + + case TradeSide.Redeem: + var market = trade.MarketOutcome.Market; + var isResolved = market?.IsResolved ?? false; + var resolutionOutcome = market?.ResolutionOutcome; + var isWinner = isResolved && IsWinningOutcome(trade.MarketOutcome, resolutionOutcome); + + var payout = isWinner ? 1.00m : 0.00m; + pos.RealizedPnl += pos.SharesHeld * (payout - pos.AvgCost); + pos.SharesHeld = 0; + pos.AvgCost = 0; + break; + + case TradeSide.Split: + case TradeSide.Merge: + case TradeSide.AddLiquidity: + case TradeSide.RemoveLiquidity: + case TradeSide.Unknown: + default: + // Ignored for PnL + break; + } + + var realizedPnlDelta = pos.RealizedPnl - previousRealizedPnl; + if (realizedPnlDelta != 0) + { + if (trade.ExecutedAt >= cutoff30d) realizedPnl30d += realizedPnlDelta; + if (trade.ExecutedAt >= cutoff7d) realizedPnl7d += realizedPnlDelta; + if (trade.ExecutedAt >= cutoff24h) realizedPnl24h += realizedPnlDelta; + } + } + + // 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) + { + var outcome = trades.FirstOrDefault(t => t.MarketOutcomeId == pos.MarketOutcomeId)?.MarketOutcome; + if (pos.SharesHeld > 0 && outcome != null) + { + 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; + + if (pos.Id == 0) + { + _db.TraderPositions.Add(pos); + } + else + { + _db.TraderPositions.Update(pos); + } + } + + // Remove positions for outcomes that have no trades anymore + foreach (var outcomeId in existingPositions.Keys) + { + if (!tempPositions.ContainsKey(outcomeId)) + { + _db.TraderPositions.Remove(existingPositions[outcomeId]); + } + } + + // Update analytics record + var analytics = trader.Analytics; + if (analytics == null) + { + analytics = new TraderAnalytics { TraderId = traderId }; + _db.TraderAnalytics.Add(analytics); + } + + var overallPnl = totalRealizedPnl + totalUnrealizedPnl; + analytics.OverallPnL = overallPnl; + analytics.PnL30d = realizedPnl30d + unrealizedPnl30d; + analytics.PnL7d = realizedPnl7d + unrealizedPnl7d; + analytics.PnL24h = realizedPnl24h + unrealizedPnl24h; + + // Calculate Win Rate on Market level + var (winRateOverall, winRate30d, winRate7d, winRate24h) = CalculateMarketWinRates(trades, tempPositions, cutoff30d, cutoff7d, cutoff24h); + + analytics.OverallWinRate = winRateOverall; + analytics.WinRate30d = winRate30d; + analytics.WinRate7d = winRate7d; + analytics.WinRate24h = winRate24h; + analytics.LastCalculatedAt = DateTime.UtcNow; + + // Sync back to Trader record for quick sorting / UI display + trader.TotalPnl = overallPnl; + trader.WinRate = winRateOverall; + + // Save changes to database + await _db.SaveChangesAsync(ct); + + _logger.LogInformation("Recalculated positions for trader {TraderName} (Id={TraderId}): RealizedPnL={Realized:F4}, UnrealizedPnL={Unrealized:F4}, Total={Total:F4}, WinRate={WinRate:F2}%", + trader.DisplayName, traderId, totalRealizedPnl, totalUnrealizedPnl, overallPnl, winRateOverall); + } + + private static (decimal Overall, decimal WinRate30d, decimal WinRate7d, decimal WinRate24h) CalculateMarketWinRates( + List trades, + Dictionary finalPositions, + DateTime cutoff30d, + DateTime cutoff7d, + DateTime cutoff24h) + { + // Group trades by Market + var tradesByMarket = trades + .Where(t => t.DbMarketId.HasValue || !string.IsNullOrEmpty(t.MarketId)) + .GroupBy(t => t.DbMarketId.HasValue ? t.DbMarketId.Value.ToString() : t.MarketId); + + int closedMarketsOverall = 0, winsOverall = 0; + int closedMarkets30d = 0, wins30d = 0; + int closedMarkets7d = 0, wins7d = 0; + int closedMarkets24h = 0, wins24h = 0; + + foreach (var marketGroup in tradesByMarket) + { + var outcomeIds = marketGroup + .Where(t => t.MarketOutcomeId.HasValue) + .Select(t => t.MarketOutcomeId!.Value) + .Distinct() + .ToList(); + + var isClosed = outcomeIds.All(oid => !finalPositions.TryGetValue(oid, out var pos) || pos.SharesHeld == 0); + if (!isClosed) + { + var firstTradeWithMarket = marketGroup.FirstOrDefault(t => t.MarketOutcome?.Market != null); + if (firstTradeWithMarket?.MarketOutcome?.Market?.IsResolved == true) + { + isClosed = true; + } + } + + if (isClosed) + { + decimal marketPnl = 0; + foreach (var oid in outcomeIds) + { + if (finalPositions.TryGetValue(oid, out var pos)) + { + marketPnl += pos.RealizedPnl; + } + } + + var lastTradeTime = marketGroup.Max(t => t.ExecutedAt); + var isWin = marketPnl > 0; + + closedMarketsOverall++; + if (isWin) winsOverall++; + + if (lastTradeTime >= cutoff30d) + { + closedMarkets30d++; + if (isWin) wins30d++; + } + if (lastTradeTime >= cutoff7d) + { + closedMarkets7d++; + if (isWin) wins7d++; + } + if (lastTradeTime >= cutoff24h) + { + closedMarkets24h++; + if (isWin) wins24h++; + } + } + } + + var winRateOverall = closedMarketsOverall > 0 ? (decimal)winsOverall / closedMarketsOverall * 100m : 0m; + var winRate30d = closedMarkets30d > 0 ? (decimal)wins30d / closedMarkets30d * 100m : 0m; + var winRate7d = closedMarkets7d > 0 ? (decimal)wins7d / closedMarkets7d * 100m : 0m; + var winRate24h = closedMarkets24h > 0 ? (decimal)wins24h / closedMarkets24h * 100m : 0m; + + return (winRateOverall, winRate30d, winRate7d, winRate24h); + } + + private static bool IsWinningOutcome(MarketOutcome outcome, string? resolutionOutcome) + { + if (string.IsNullOrWhiteSpace(resolutionOutcome)) return false; + + if (string.Equals(outcome.Label, resolutionOutcome, StringComparison.OrdinalIgnoreCase)) + return true; + + if (outcome.Label.EndsWith(" - " + resolutionOutcome, StringComparison.OrdinalIgnoreCase)) + return true; + + return false; + } +} diff --git a/src/Predictalytics.Worker/Services/TraderAnalyticsWorker.cs b/src/Predictalytics.Worker/Services/TraderAnalyticsWorker.cs index 749c916..a56adf7 100644 --- a/src/Predictalytics.Worker/Services/TraderAnalyticsWorker.cs +++ b/src/Predictalytics.Worker/Services/TraderAnalyticsWorker.cs @@ -3,7 +3,7 @@ using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Microsoft.Extensions.DependencyInjection; using Predictalytics.Infrastructure.Data; -using Predictalytics.Domain.Entities; +using Predictalytics.Application.Interfaces; namespace Predictalytics.Worker.Services; @@ -12,10 +12,10 @@ public class TraderAnalyticsWorker : BackgroundService private readonly IServiceProvider _services; private readonly ILogger _logger; - public TraderAnalyticsWorker(IServiceProvider services, ILogger logger) + public TraderAnalyticsWorker(IServiceProvider services, ILogger _logger) { _services = services; - _logger = logger; + this._logger = _logger; } protected override async Task ExecuteAsync(CancellationToken ct) @@ -42,6 +42,7 @@ public class TraderAnalyticsWorker : BackgroundService { using var scope = _services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); + var pnlEngine = scope.ServiceProvider.GetRequiredService(); var cutoff30d = DateTime.UtcNow.AddDays(-30); @@ -56,83 +57,16 @@ public class TraderAnalyticsWorker : BackgroundService foreach (var id in traderIds) { - await UpdateTraderAnalyticsAsync(db, id, ct); + try + { + await pnlEngine.RecalculateTraderPositionsAsync(id, ct); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error recalculating positions/PnL for trader {TraderId}", id); + } } - await db.SaveChangesAsync(ct); _logger.LogInformation("Trader analytics update complete."); } - - private async Task UpdateTraderAnalyticsAsync(AppDbContext db, int traderId, CancellationToken ct) - { - var trades = await db.Trades.Where(t => t.TraderId == traderId).ToListAsync(ct); - if (!trades.Any()) return; - - var analytics = await db.TraderAnalytics.FirstOrDefaultAsync(a => a.TraderId == traderId, ct); - if (analytics == null) - { - analytics = new TraderAnalytics { TraderId = traderId }; - db.TraderAnalytics.Add(analytics); - } - - analytics.LastCalculatedAt = DateTime.UtcNow; - - // Simplified PnL calculation: Sum of Sells - Sum of Buys - // This is not perfect but a good starting point as requested. - // In a real scenario, we'd account for current market value of holdings. - - analytics.OverallPnL = CalculatePnL(trades, null); - analytics.OverallWinRate = CalculateWinRate(trades, null); - - analytics.PnL30d = CalculatePnL(trades, DateTime.UtcNow.AddDays(-30)); - analytics.WinRate30d = CalculateWinRate(trades, DateTime.UtcNow.AddDays(-30)); - - analytics.PnL7d = CalculatePnL(trades, DateTime.UtcNow.AddDays(-7)); - analytics.WinRate7d = CalculateWinRate(trades, DateTime.UtcNow.AddDays(-7)); - - analytics.PnL24h = CalculatePnL(trades, DateTime.UtcNow.AddHours(-24)); - analytics.WinRate24h = CalculateWinRate(trades, DateTime.UtcNow.AddHours(-24)); - - // Update the trader record too for easy sorting - var trader = await db.Traders.FindAsync(new object[] { traderId }, ct); - if (trader != null) - { - trader.TotalPnl = analytics.OverallPnL; - trader.WinRate = analytics.OverallWinRate; - } - } - - private decimal CalculatePnL(List trades, DateTime? since) - { - var filtered = since.HasValue ? trades.Where(t => t.ExecutedAt >= since.Value) : trades; - - // Very simplified: Sells - Buys - // Note: Real PnL should consider if the market resolved in their favor. - // For now, we use the raw trade amounts. - decimal pnl = 0; - foreach (var t in filtered) - { - if (t.Side == Predictalytics.Domain.Enums.TradeSide.Buy) pnl -= t.Amount; - else pnl += t.Amount; - } - return pnl; - } - - private decimal CalculateWinRate(List trades, DateTime? since) - { - var filtered = since.HasValue ? trades.Where(t => t.ExecutedAt >= since.Value).ToList() : trades; - if (!filtered.Any()) return 0; - - // Simplified: A "win" is a Sell at a higher price than the average Buy price? - // Actually, without proper position tracking, this is hard. - // Let's assume a "win" is any trade that closed a position in profit. - // For now, let's just return a placeholder or implement a basic logic. - // Since we don't have resolution data easily linked here, we'll return 0 or a dummy. - // Wait, if MarketOutcome is resolved and they held that outcome, it's a win. - - // Let's just use 0 for now to avoid misleading data, or - // if we have MarketOutcomeId and it's resolved, we can check. - - return 0; // Placeholder until more complex logic is added - } }