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
- }
}