Implement A1, A6, A2: Add TraderPosition entity, PositionPnLEngine (Average-Cost-Method), Market-level WinRate, and integrate with TraderAnalyticsWorker

This commit is contained in:
Richard
2026-07-03 11:16:42 +02:00
parent 5fe7ab63ac
commit e994e1ce72
10 changed files with 1227 additions and 78 deletions
@@ -0,0 +1,14 @@
using System.Threading;
using System.Threading.Tasks;
namespace Predictalytics.Application.Interfaces;
public interface IPositionPnLEngine
{
/// <summary>
/// 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.
/// </summary>
Task RecalculateTraderPositionsAsync(int traderId, CancellationToken ct = default);
}
@@ -66,4 +66,5 @@ public class Trader
public TraderScore? CurrentScore { get; set; } public TraderScore? CurrentScore { get; set; }
public virtual TraderAnalytics? Analytics { get; set; } public virtual TraderAnalytics? Analytics { get; set; }
public ICollection<WatchlistEntry> WatchlistEntries { get; set; } = new List<WatchlistEntry>(); public ICollection<WatchlistEntry> WatchlistEntries { get; set; } = new List<WatchlistEntry>();
public ICollection<TraderPosition> Positions { get; set; } = new List<TraderPosition>();
} }
@@ -0,0 +1,34 @@
using System;
namespace Predictalytics.Domain.Entities;
/// <summary>
/// Represents a trader's position in a specific market outcome.
/// Tracks shares held, average purchase price, and realized profit/loss.
/// </summary>
public class TraderPosition
{
public int Id { get; set; }
/// <summary>Foreign key to the trader.</summary>
public int TraderId { get; set; }
/// <summary>Foreign key to the resolved market outcome.</summary>
public int MarketOutcomeId { get; set; }
/// <summary>Number of shares currently held.</summary>
public decimal SharesHeld { get; set; }
/// <summary>Average purchase cost per share.</summary>
public decimal AvgCost { get; set; }
/// <summary>Realized profit/loss from closed portions of this position.</summary>
public decimal RealizedPnl { get; set; }
/// <summary>When this position was last updated.</summary>
public DateTime LastUpdatedAt { get; set; } = DateTime.UtcNow;
// Navigation properties
public Trader Trader { get; set; } = null!;
public MarketOutcome MarketOutcome { get; set; } = null!;
}
@@ -15,6 +15,7 @@ public class AppDbContext : DbContext
public DbSet<PlatformConfig> PlatformConfigs => Set<PlatformConfig>(); public DbSet<PlatformConfig> PlatformConfigs => Set<PlatformConfig>();
public DbSet<TraderAnalytics> TraderAnalytics => Set<TraderAnalytics>(); public DbSet<TraderAnalytics> TraderAnalytics => Set<TraderAnalytics>();
public DbSet<MarketAnalytics> MarketAnalytics => Set<MarketAnalytics>(); public DbSet<MarketAnalytics> MarketAnalytics => Set<MarketAnalytics>();
public DbSet<TraderPosition> TraderPositions => Set<TraderPosition>();
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { } public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }
@@ -153,5 +154,17 @@ public class AppDbContext : DbContext
e.Property(a => a.BotActivityScore).HasPrecision(8, 4); e.Property(a => a.BotActivityScore).HasPrecision(8, 4);
e.Property(a => a.AverageTradeSize).HasPrecision(18, 4); e.Property(a => a.AverageTradeSize).HasPrecision(18, 4);
}); });
// TraderPosition
mb.Entity<TraderPosition>(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);
});
} }
} }
@@ -1,5 +1,6 @@
using Predictalytics.Application.Interfaces; using Predictalytics.Application.Interfaces;
using Predictalytics.Application.Services; using Predictalytics.Application.Services;
using Predictalytics.Infrastructure.Services;
using Predictalytics.Domain.Interfaces; using Predictalytics.Domain.Interfaces;
using Predictalytics.Infrastructure.Data; using Predictalytics.Infrastructure.Data;
using Predictalytics.Infrastructure.Data.Repositories; using Predictalytics.Infrastructure.Data.Repositories;
@@ -63,6 +64,7 @@ public static class DependencyInjection
services.AddScoped<IAlertRepository, AlertRepository>(); services.AddScoped<IAlertRepository, AlertRepository>();
// Application Services // Application Services
services.AddScoped<IPositionPnLEngine, PositionPnLEngine>();
services.AddScoped<IScoringService, ScoringService>(); services.AddScoped<IScoringService, ScoringService>();
services.AddScoped<IDiscoveryService, DiscoveryService>(); services.AddScoped<IDiscoveryService, DiscoveryService>();
services.AddScoped<IAlertService, AlertService>(); services.AddScoped<IAlertService, AlertService>();
@@ -0,0 +1,701 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Predictalytics.Infrastructure.Data;
#nullable disable
namespace Predictalytics.Infrastructure.Migrations
{
[DbContext(typeof(AppDbContext))]
[Migration("20260703091631_AddTraderPosition")]
partial class AddTraderPosition
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "8.0.11")
.HasAnnotation("Relational:MaxIdentifierLength", 64);
MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder);
modelBuilder.Entity("Predictalytics.Domain.Entities.Alert", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<bool>("IsRead")
.HasColumnType("tinyint(1)");
b.Property<string>("Message")
.IsRequired()
.HasMaxLength(4096)
.HasColumnType("varchar(4096)");
b.Property<int>("Platform")
.HasColumnType("int");
b.Property<int>("Severity")
.HasColumnType("int");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("varchar(512)");
b.Property<int?>("TraderId")
.HasColumnType("int");
b.Property<int>("Type")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("CreatedAt");
b.HasIndex("TraderId");
b.ToTable("Alerts");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.Market", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
b.Property<string>("Category")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("varchar(128)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<DateTime>("DbCreatedAt")
.HasColumnType("datetime(6)");
b.Property<string>("Description")
.HasMaxLength(4096)
.HasColumnType("varchar(4096)");
b.Property<DateTime?>("EndDate")
.HasColumnType("datetime(6)");
b.Property<string>("EventSlug")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("varchar(512)");
b.Property<string>("ImageUrl")
.HasMaxLength(1024)
.HasColumnType("varchar(1024)");
b.Property<bool>("IsResolved")
.HasColumnType("tinyint(1)");
b.Property<DateTime?>("LastTradesUpdatedAt")
.HasColumnType("datetime(6)");
b.Property<DateTime?>("LastUpdatedAt")
.HasColumnType("datetime(6)");
b.Property<decimal>("Liquidity")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<string>("MarketSlug")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("varchar(512)");
b.Property<int>("Platform")
.HasColumnType("int");
b.Property<string>("PlatformMarketId")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("varchar(256)");
b.Property<string>("Question")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("varchar(1024)");
b.Property<string>("ResolutionOutcome")
.HasColumnType("longtext");
b.Property<DateTime?>("StartDate")
.HasColumnType("datetime(6)");
b.Property<decimal>("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<int>("MarketId")
.HasColumnType("int");
b.Property<decimal>("AverageTradeSize")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<decimal>("BotActivityScore")
.HasPrecision(8, 4)
.HasColumnType("decimal(8,4)");
b.Property<DateTime>("LastCalculatedAt")
.HasColumnType("datetime(6)");
b.Property<int>("UniqueTradersCount")
.HasColumnType("int");
b.HasKey("MarketId");
b.ToTable("MarketAnalytics");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.MarketOutcome", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
b.Property<decimal>("CurrentPrice")
.HasPrecision(18, 8)
.HasColumnType("decimal(18,8)");
b.Property<string>("Label")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("varchar(256)");
b.Property<int>("MarketId")
.HasColumnType("int");
b.Property<int>("OutcomeIndex")
.HasColumnType("int");
b.Property<string>("TokenId")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("varchar(256)");
b.HasKey("Id");
b.HasIndex("TokenId");
b.HasIndex("MarketId", "OutcomeIndex")
.IsUnique();
b.ToTable("MarketOutcomes");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.PlatformConfig", b =>
{
b.Property<int>("Id")
.HasColumnType("int");
b.Property<string>("BaseUrl")
.HasMaxLength(1024)
.HasColumnType("varchar(1024)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("varchar(256)");
b.Property<bool>("IsActive")
.HasColumnType("tinyint(1)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("varchar(128)");
b.Property<string>("SettingsJson")
.HasColumnType("longtext");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime(6)");
b.HasKey("Id");
b.ToTable("PlatformConfigs");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.Trade", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<long>("Id"));
b.Property<decimal>("Amount")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<string>("AssetId")
.IsRequired()
.HasMaxLength(80)
.HasColumnType("varchar(80)");
b.Property<int?>("DbMarketId")
.HasColumnType("int");
b.Property<DateTime>("ExecutedAt")
.HasColumnType("datetime(6)");
b.Property<string>("MarketId")
.IsRequired()
.HasMaxLength(66)
.HasColumnType("varchar(66)");
b.Property<int?>("MarketOutcomeId")
.HasColumnType("int");
b.Property<string>("Outcome")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("varchar(128)");
b.Property<int>("Platform")
.HasColumnType("int");
b.Property<string>("PlatformTradeId")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("varchar(256)");
b.Property<decimal>("Price")
.HasPrecision(10, 6)
.HasColumnType("decimal(10,6)");
b.Property<int>("Side")
.HasColumnType("int");
b.Property<decimal>("Size")
.HasPrecision(14, 6)
.HasColumnType("decimal(14,6)");
b.Property<int>("TraderId")
.HasColumnType("int");
b.Property<string>("TransactionHash")
.HasMaxLength(66)
.HasColumnType("varchar(66)");
b.HasKey("Id");
b.HasIndex("AssetId");
b.HasIndex("DbMarketId");
b.HasIndex("ExecutedAt");
b.HasIndex("MarketOutcomeId");
b.HasIndex("TraderId");
b.HasIndex("Platform", "PlatformTradeId")
.IsUnique();
b.ToTable("Trades");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.Trader", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("varchar(256)");
b.Property<bool>("IsAutoDiscovered")
.HasColumnType("tinyint(1)");
b.Property<bool>("IsInitialImportComplete")
.HasColumnType("tinyint(1)");
b.Property<bool>("IsSuspectedBot")
.HasColumnType("tinyint(1)");
b.Property<DateTime?>("LastApiErrorAt")
.HasColumnType("datetime(6)");
b.Property<DateTime?>("LastPolledAt")
.HasColumnType("datetime(6)");
b.Property<DateTime?>("LastTradesUpdatedAt")
.HasColumnType("datetime(6)");
b.Property<int?>("ManualPriorityOverride")
.HasColumnType("int");
b.Property<string>("Notes")
.HasColumnType("longtext");
b.Property<int>("Platform")
.HasColumnType("int");
b.Property<string>("PlatformUserId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("varchar(128)");
b.Property<int>("Strategy")
.HasColumnType("int");
b.Property<int>("Tier")
.HasColumnType("int");
b.Property<decimal>("TotalPnl")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<int>("TotalTrades")
.HasColumnType("int");
b.Property<decimal>("WinRate")
.HasPrecision(8, 4)
.HasColumnType("decimal(8,4)");
b.HasKey("Id");
b.HasIndex("Platform", "PlatformUserId")
.IsUnique();
b.ToTable("Traders");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.TraderAnalytics", b =>
{
b.Property<int>("TraderId")
.HasColumnType("int");
b.Property<DateTime>("LastCalculatedAt")
.HasColumnType("datetime(6)");
b.Property<decimal>("OverallPnL")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<decimal>("OverallWinRate")
.HasPrecision(8, 4)
.HasColumnType("decimal(8,4)");
b.Property<decimal>("PnL24h")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<decimal>("PnL30d")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<decimal>("PnL7d")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<decimal>("WinRate24h")
.HasPrecision(8, 4)
.HasColumnType("decimal(8,4)");
b.Property<decimal>("WinRate30d")
.HasPrecision(8, 4)
.HasColumnType("decimal(8,4)");
b.Property<decimal>("WinRate7d")
.HasPrecision(8, 4)
.HasColumnType("decimal(8,4)");
b.HasKey("TraderId");
b.ToTable("TraderAnalytics");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.TraderPosition", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
b.Property<decimal>("AvgCost")
.HasPrecision(10, 6)
.HasColumnType("decimal(10,6)");
b.Property<DateTime>("LastUpdatedAt")
.HasColumnType("datetime(6)");
b.Property<int>("MarketOutcomeId")
.HasColumnType("int");
b.Property<decimal>("RealizedPnl")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<decimal>("SharesHeld")
.HasPrecision(14, 6)
.HasColumnType("decimal(14,6)");
b.Property<int>("TraderId")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("MarketOutcomeId");
b.HasIndex("TraderId", "MarketOutcomeId")
.IsUnique();
b.ToTable("TraderPositions");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.TraderScore", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
b.Property<decimal>("ActivityScore")
.HasPrecision(8, 4)
.HasColumnType("decimal(8,4)");
b.Property<DateTime>("CalculatedAt")
.HasColumnType("datetime(6)");
b.Property<decimal>("CombinedScore")
.HasPrecision(8, 4)
.HasColumnType("decimal(8,4)");
b.Property<decimal>("QualityScore")
.HasPrecision(8, 4)
.HasColumnType("decimal(8,4)");
b.Property<int>("Rank")
.HasColumnType("int");
b.Property<decimal>("TimingScore")
.HasPrecision(8, 4)
.HasColumnType("decimal(8,4)");
b.Property<int>("TraderId")
.HasColumnType("int");
b.Property<decimal>("VolumeScore")
.HasPrecision(8, 4)
.HasColumnType("decimal(8,4)");
b.HasKey("Id");
b.HasIndex("TraderId")
.IsUnique();
b.ToTable("TraderScores");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.WatchlistEntry", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
b.Property<DateTime>("AddedAt")
.HasColumnType("datetime(6)");
b.Property<bool>("AlertsEnabled")
.HasColumnType("tinyint(1)");
b.Property<string>("Label")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("varchar(256)");
b.Property<string>("Notes")
.HasColumnType("longtext");
b.Property<int>("TraderId")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("TraderId")
.IsUnique();
b.ToTable("WatchlistEntries");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.Alert", b =>
{
b.HasOne("Predictalytics.Domain.Entities.Trader", "Trader")
.WithMany()
.HasForeignKey("TraderId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("Trader");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.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
}
}
}
@@ -0,0 +1,65 @@
using System;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Predictalytics.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddTraderPosition : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "TraderPositions",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
TraderId = table.Column<int>(type: "int", nullable: false),
MarketOutcomeId = table.Column<int>(type: "int", nullable: false),
SharesHeld = table.Column<decimal>(type: "decimal(14,6)", precision: 14, scale: 6, nullable: false),
AvgCost = table.Column<decimal>(type: "decimal(10,6)", precision: 10, scale: 6, nullable: false),
RealizedPnl = table.Column<decimal>(type: "decimal(18,4)", precision: 18, scale: 4, nullable: false),
LastUpdatedAt = table.Column<DateTime>(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);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "TraderPositions");
}
}
}
@@ -447,6 +447,45 @@ namespace Predictalytics.Infrastructure.Migrations
b.ToTable("TraderAnalytics"); b.ToTable("TraderAnalytics");
}); });
modelBuilder.Entity("Predictalytics.Domain.Entities.TraderPosition", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
b.Property<decimal>("AvgCost")
.HasPrecision(10, 6)
.HasColumnType("decimal(10,6)");
b.Property<DateTime>("LastUpdatedAt")
.HasColumnType("datetime(6)");
b.Property<int>("MarketOutcomeId")
.HasColumnType("int");
b.Property<decimal>("RealizedPnl")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<decimal>("SharesHeld")
.HasPrecision(14, 6)
.HasColumnType("decimal(14,6)");
b.Property<int>("TraderId")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("MarketOutcomeId");
b.HasIndex("TraderId", "MarketOutcomeId")
.IsUnique();
b.ToTable("TraderPositions");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.TraderScore", b => modelBuilder.Entity("Predictalytics.Domain.Entities.TraderScore", b =>
{ {
b.Property<int>("Id") b.Property<int>("Id")
@@ -593,6 +632,25 @@ namespace Predictalytics.Infrastructure.Migrations
b.Navigation("Trader"); 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 => modelBuilder.Entity("Predictalytics.Domain.Entities.TraderScore", b =>
{ {
b.HasOne("Predictalytics.Domain.Entities.Trader", "Trader") b.HasOne("Predictalytics.Domain.Entities.Trader", "Trader")
@@ -628,6 +686,8 @@ namespace Predictalytics.Infrastructure.Migrations
b.Navigation("CurrentScore"); b.Navigation("CurrentScore");
b.Navigation("Positions");
b.Navigation("Trades"); b.Navigation("Trades");
b.Navigation("WatchlistEntries"); b.Navigation("WatchlistEntries");
@@ -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<PositionPnLEngine> _logger;
public PositionPnLEngine(AppDbContext db, ILogger<PositionPnLEngine> 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<int, TraderPosition>();
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<int>();
var tradedOutcomes7d = new HashSet<int>();
var tradedOutcomes24h = new HashSet<int>();
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<Trade> trades,
Dictionary<int, TraderPosition> 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;
}
}
@@ -3,7 +3,7 @@ using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Predictalytics.Infrastructure.Data; using Predictalytics.Infrastructure.Data;
using Predictalytics.Domain.Entities; using Predictalytics.Application.Interfaces;
namespace Predictalytics.Worker.Services; namespace Predictalytics.Worker.Services;
@@ -12,10 +12,10 @@ public class TraderAnalyticsWorker : BackgroundService
private readonly IServiceProvider _services; private readonly IServiceProvider _services;
private readonly ILogger<TraderAnalyticsWorker> _logger; private readonly ILogger<TraderAnalyticsWorker> _logger;
public TraderAnalyticsWorker(IServiceProvider services, ILogger<TraderAnalyticsWorker> logger) public TraderAnalyticsWorker(IServiceProvider services, ILogger<TraderAnalyticsWorker> _logger)
{ {
_services = services; _services = services;
_logger = logger; this._logger = _logger;
} }
protected override async Task ExecuteAsync(CancellationToken ct) protected override async Task ExecuteAsync(CancellationToken ct)
@@ -42,6 +42,7 @@ public class TraderAnalyticsWorker : BackgroundService
{ {
using var scope = _services.CreateScope(); using var scope = _services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>(); var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var pnlEngine = scope.ServiceProvider.GetRequiredService<IPositionPnLEngine>();
var cutoff30d = DateTime.UtcNow.AddDays(-30); var cutoff30d = DateTime.UtcNow.AddDays(-30);
@@ -56,83 +57,16 @@ public class TraderAnalyticsWorker : BackgroundService
foreach (var id in traderIds) 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."); _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<Trade> 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<Trade> 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
}
} }