Fix Estimator scoring and PnL engine cashflow

This commit is contained in:
Richard
2026-07-07 18:30:20 +02:00
parent 8d056653f9
commit 4eb0d99b4e
17 changed files with 2594 additions and 97 deletions
@@ -18,6 +18,7 @@ public class AppDbContext : DbContext
public DbSet<MarketAnalytics> MarketAnalytics => Set<MarketAnalytics>();
public DbSet<TraderPosition> TraderPositions => Set<TraderPosition>();
public DbSet<MarketOutcomePriceSnapshot> MarketOutcomePriceSnapshots => Set<MarketOutcomePriceSnapshot>();
public DbSet<TraderDailySnapshot> TraderDailySnapshots => Set<TraderDailySnapshot>();
public DbSet<TraderCategoryPerformance> TraderCategoryPerformances => Set<TraderCategoryPerformance>();
public DbSet<TradeContext> TradeContexts => Set<TradeContext>();
public DbSet<BackgroundJob> BackgroundJobs => Set<BackgroundJob>();
@@ -119,12 +120,13 @@ public class AppDbContext : DbContext
mb.Entity<TraderScore>(e =>
{
e.HasKey(s => s.Id);
e.Property(s => s.ActivityScore).HasPrecision(8, 4);
e.Property(s => s.QualityScore).HasPrecision(8, 4);
e.Property(s => s.CombinedScore).HasPrecision(8, 4);
e.Property(s => s.VolumeScore).HasPrecision(8, 4);
e.Property(s => s.TimingScore).HasPrecision(8, 4);
e.Property(s => s.CopytradingScore).HasPrecision(8, 4);
e.HasIndex(s => s.TraderId).IsUnique();
e.Property(s => s.ActivityScore).HasPrecision(5, 2);
e.Property(s => s.QualityScore).HasPrecision(5, 2);
e.Property(s => s.CombinedScore).HasPrecision(5, 2);
e.Property(s => s.VolumeScore).HasPrecision(5, 2);
e.Property(s => s.TimingScore).HasPrecision(5, 2);
e.HasOne(s => s.Trader).WithOne(t => t.CurrentScore).HasForeignKey<TraderScore>(s => s.TraderId).OnDelete(DeleteBehavior.Cascade);
});
// WatchlistEntry
@@ -213,6 +215,16 @@ public class AppDbContext : DbContext
e.HasOne(tp => tp.MarketOutcome).WithMany().HasForeignKey(tp => tp.MarketOutcomeId).OnDelete(DeleteBehavior.Cascade);
});
// TraderDailySnapshot
mb.Entity<TraderDailySnapshot>(e =>
{
e.HasKey(s => s.Id);
e.HasIndex(s => new { s.TraderId, s.Date }).IsUnique();
e.Property(s => s.TotalPnl).HasPrecision(18, 4);
e.Property(s => s.CurrentBalance).HasPrecision(18, 4);
e.HasOne(s => s.Trader).WithMany().HasForeignKey(s => s.TraderId).OnDelete(DeleteBehavior.Cascade);
});
// MarketOutcomePriceSnapshot
mb.Entity<MarketOutcomePriceSnapshot>(e =>
{
@@ -2,13 +2,20 @@ using Predictalytics.Domain.Entities;
using Predictalytics.Domain.Enums;
using Predictalytics.Domain.Interfaces;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace Predictalytics.Infrastructure.Data.Repositories;
public class TradeRepository : ITradeRepository
{
private readonly AppDbContext _db;
public TradeRepository(AppDbContext db) => _db = db;
private readonly Microsoft.Extensions.Logging.ILogger<TradeRepository> _logger;
public TradeRepository(AppDbContext db, Microsoft.Extensions.Logging.ILogger<TradeRepository> logger)
{
_db = db;
_logger = logger;
}
public async Task<Trade?> GetByPlatformTradeIdAsync(PlatformType platform, string platformTradeId, CancellationToken ct = default)
=> await _db.Trades.FirstOrDefaultAsync(t => t.Platform == platform && t.PlatformTradeId == platformTradeId, ct);
@@ -92,7 +99,8 @@ public class TradeRepository : ITradeRepository
parameters.Add(t.IsContextEnriched);
}
await _db.Database.ExecuteSqlRawAsync(sb.ToString(), parameters.ToArray(), ct);
var rowsInserted = await _db.Database.ExecuteSqlRawAsync(sb.ToString(), parameters.ToArray(), ct);
_logger.LogInformation("Inserted {RowsInserted} trades into the database.", rowsInserted);
}
}
@@ -141,10 +149,11 @@ public class TradeRepository : ITradeRepository
.Include(t => t.Trader)
.Include(t => t.Trader.CurrentScore)
.Include(t => t.Trader.WatchlistEntries)
.Include(t => t.Trader.Analytics)
.Where(t => !t.IsContextEnriched
&& t.Platform == PlatformType.Polymarket
&& t.AssetId != "")
.Where(t => t.Trader.WatchlistEntries.Any() || (t.Trader.CurrentScore != null && t.Trader.CurrentScore.CopytradingScore > 50))
.Where(t => t.Trader.WatchlistEntries.Any() || (t.Trader.Analytics != null && t.Trader.Analytics.CopytradingScore > 50))
.OrderByDescending(t => t.ExecutedAt)
.Take(limit)
.ToListAsync(ct);
@@ -0,0 +1,43 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Predictalytics.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddTraderPositionHistoryGuards : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "IsHistoryPruned",
table: "TraderPositions",
type: "tinyint(1)",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<DateTime>(
name: "LastTradeExecutedAt",
table: "TraderPositions",
type: "datetime(6)",
nullable: true);
// Step 2.1: Global Reset to force re-calculation of all positions and fix corrupted PnL data
migrationBuilder.Sql("UPDATE TraderPositions SET LastAppliedTradeId = 0;");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "IsHistoryPruned",
table: "TraderPositions");
migrationBuilder.DropColumn(
name: "LastTradeExecutedAt",
table: "TraderPositions");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,52 @@
using System;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Predictalytics.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddTraderDailySnapshots : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "TraderDailySnapshots",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
TraderId = table.Column<int>(type: "int", nullable: false),
Date = table.Column<DateTime>(type: "datetime(6)", nullable: false),
TotalPnl = table.Column<decimal>(type: "decimal(18,4)", precision: 18, scale: 4, nullable: false),
CurrentBalance = table.Column<decimal>(type: "decimal(18,4)", precision: 18, scale: 4, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_TraderDailySnapshots", x => x.Id);
table.ForeignKey(
name: "FK_TraderDailySnapshots_Traders_TraderId",
column: x => x.TraderId,
principalTable: "Traders",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateIndex(
name: "IX_TraderDailySnapshots_TraderId_Date",
table: "TraderDailySnapshots",
columns: new[] { "TraderId", "Date" },
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "TraderDailySnapshots");
}
}
}
@@ -718,6 +718,36 @@ namespace Predictalytics.Infrastructure.Migrations
b.ToTable("TraderCategoryPerformances");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.TraderDailySnapshot", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
b.Property<decimal>("CurrentBalance")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<DateTime>("Date")
.HasColumnType("datetime(6)");
b.Property<decimal>("TotalPnl")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<int>("TraderId")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("TraderId", "Date")
.IsUnique();
b.ToTable("TraderDailySnapshots");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.TraderPosition", b =>
{
b.Property<int>("Id")
@@ -730,9 +760,15 @@ namespace Predictalytics.Infrastructure.Migrations
.HasPrecision(10, 6)
.HasColumnType("decimal(10,6)");
b.Property<bool>("IsHistoryPruned")
.HasColumnType("tinyint(1)");
b.Property<long>("LastAppliedTradeId")
.HasColumnType("bigint");
b.Property<DateTime?>("LastTradeExecutedAt")
.HasColumnType("datetime(6)");
b.Property<DateTime>("LastUpdatedAt")
.HasColumnType("datetime(6)");
@@ -970,6 +1006,17 @@ namespace Predictalytics.Infrastructure.Migrations
b.Navigation("Trader");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.TraderDailySnapshot", b =>
{
b.HasOne("Predictalytics.Domain.Entities.Trader", "Trader")
.WithMany()
.HasForeignKey("TraderId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Trader");
});
modelBuilder.Entity("Predictalytics.Domain.Entities.TraderPosition", b =>
{
b.HasOne("Predictalytics.Domain.Entities.MarketOutcome", "MarketOutcome")
@@ -48,6 +48,30 @@ public class PositionPnLEngine : IPositionPnLEngine
.Where(tp => tp.TraderId == traderId)
.ToDictionaryAsync(tp => tp.MarketOutcomeId, ct);
// Pre-flight check: Detect out-of-order unapplied trades (Bug 3) or manually reset checkpoints (Bug 2a)
foreach (var pos in existingPositions.Values)
{
if (pos.IsHistoryPruned) continue; // Cannot reset if history is pruned
var outcomeTrades = trades.Where(t => t.MarketOutcomeId == pos.MarketOutcomeId).ToList();
bool needsReset = pos.LastAppliedTradeId == 0 ||
outcomeTrades.Any(t => t.Id > pos.LastAppliedTradeId && pos.LastTradeExecutedAt.HasValue && t.ExecutedAt < pos.LastTradeExecutedAt.Value);
if (needsReset)
{
if (pos.LastAppliedTradeId > 0)
{
_logger.LogInformation("Out-of-order unapplied trade detected for Trader {TraderId} Outcome {OutcomeId}. Resetting position.", traderId, pos.MarketOutcomeId);
}
pos.SharesHeld = 0;
pos.AvgCost = 0;
pos.RealizedPnl = 0;
pos.LastAppliedTradeId = 0;
pos.LastTradeExecutedAt = null;
}
}
var analytics = trader.Analytics;
if (analytics == null)
{
@@ -60,10 +84,6 @@ public class PositionPnLEngine : IPositionPnLEngine
var cutoff30d = DateTime.UtcNow.AddDays(-30);
var cutoff7d = DateTime.UtcNow.AddDays(-7);
var cutoff24h = DateTime.UtcNow.AddHours(-24);
var realizedPnl30d = 0m;
var realizedPnl7d = 0m;
var realizedPnl24h = 0m;
decimal currentBalance = analytics.CurrentBalance;
decimal estimatedBankroll = analytics.EstimatedBankroll;
@@ -97,10 +117,13 @@ public class PositionPnLEngine : IPositionPnLEngine
{
TraderId = traderId,
MarketOutcomeId = outcomeId,
MarketOutcome = trade.MarketOutcome, // Bug 8 Fix: Assign immediately so virtual payout runs
SharesHeld = 0,
AvgCost = 0,
RealizedPnl = 0,
LastAppliedTradeId = 0
LastAppliedTradeId = 0,
LastTradeExecutedAt = null,
IsHistoryPruned = false
};
}
pos.LastUpdatedAt = DateTime.UtcNow;
@@ -160,6 +183,25 @@ public class PositionPnLEngine : IPositionPnLEngine
case TradeSide.Split:
case TradeSide.Merge:
var cashEquivalent = Math.Abs(trade.Size) * trade.Price;
if (trade.Size > 0)
{
currentBalance -= cashEquivalent;
var totalCost = (pos.SharesHeld * pos.AvgCost) + cashEquivalent;
var totalShares = pos.SharesHeld + trade.Size;
pos.AvgCost = totalShares > 0 ? totalCost / totalShares : 0;
pos.SharesHeld = totalShares;
}
else if (trade.Size < 0)
{
var absSize = Math.Abs(trade.Size);
currentBalance += cashEquivalent;
var splitSizeToSell = Math.Min(absSize, pos.SharesHeld);
pos.RealizedPnl += splitSizeToSell * (trade.Price - pos.AvgCost);
pos.SharesHeld -= absSize;
if (pos.SharesHeld < 0) pos.SharesHeld = 0;
}
break;
case TradeSide.AddLiquidity:
case TradeSide.RemoveLiquidity:
case TradeSide.Unknown:
@@ -174,15 +216,12 @@ public class PositionPnLEngine : IPositionPnLEngine
}
pos.LastAppliedTradeId = Math.Max(pos.LastAppliedTradeId, trade.Id);
if (pos.LastTradeExecutedAt == null || trade.ExecutedAt > pos.LastTradeExecutedAt.Value)
{
pos.LastTradeExecutedAt = trade.ExecutedAt;
}
var realizedPnlDelta = pos.RealizedPnl - previousRealizedPnl;
if (realizedPnlDelta != 0)
{
// This will only accumulate deltas for NEW trades.
if (trade.ExecutedAt >= cutoff30d) realizedPnl30d += realizedPnlDelta;
if (trade.ExecutedAt >= cutoff7d) realizedPnl7d += realizedPnlDelta;
if (trade.ExecutedAt >= cutoff24h) realizedPnl24h += realizedPnlDelta;
}
}
// Bug 6: Virtual payout for unredeemed winning positions
@@ -200,10 +239,6 @@ public class PositionPnLEngine : IPositionPnLEngine
pos.RealizedPnl += virtualPnlDelta;
pos.SharesHeld = 0;
pos.AvgCost = 0;
if (market.ClosedAt.HasValue && market.ClosedAt.Value >= cutoff30d) realizedPnl30d += virtualPnlDelta;
if (market.ClosedAt.HasValue && market.ClosedAt.Value >= cutoff7d) realizedPnl7d += virtualPnlDelta;
if (market.ClosedAt.HasValue && market.ClosedAt.Value >= cutoff24h) realizedPnl24h += virtualPnlDelta;
}
}
}
@@ -211,9 +246,6 @@ public class PositionPnLEngine : IPositionPnLEngine
// Persist new / updated positions and calculate total values
decimal totalRealizedPnl = 0;
decimal totalUnrealizedPnl = 0;
decimal unrealizedPnl30d = 0;
decimal unrealizedPnl7d = 0;
decimal unrealizedPnl24h = 0;
foreach (var pos in tempPositions.Values)
{
@@ -222,10 +254,6 @@ public class PositionPnLEngine : IPositionPnLEngine
{
var unrealized = pos.SharesHeld * (outcome.CurrentPrice - pos.AvgCost);
totalUnrealizedPnl += unrealized;
if (tradedOutcomes30d.Contains(pos.MarketOutcomeId)) unrealizedPnl30d += unrealized;
if (tradedOutcomes7d.Contains(pos.MarketOutcomeId)) unrealizedPnl7d += unrealized;
if (tradedOutcomes24h.Contains(pos.MarketOutcomeId)) unrealizedPnl24h += unrealized;
}
totalRealizedPnl += pos.RealizedPnl;
@@ -239,27 +267,60 @@ public class PositionPnLEngine : IPositionPnLEngine
}
}
// Remove positions for outcomes that have no trades anymore
foreach (var outcomeId in existingPositions.Keys)
{
if (!tempPositions.ContainsKey(outcomeId))
{
_db.TraderPositions.Remove(existingPositions[outcomeId]);
}
}
// Bug 7: Positions are intentionally kept even if their trades are pruned by retention policies.
// Update analytics record
// (Analytics instance is already retrieved at the top of this method)
var overallPnl = totalRealizedPnl + totalUnrealizedPnl;
analytics.OverallPnL = overallPnl;
analytics.PnL30d = realizedPnl30d + unrealizedPnl30d;
analytics.PnL7d = realizedPnl7d + unrealizedPnl7d;
analytics.PnL24h = realizedPnl24h + unrealizedPnl24h;
analytics.CurrentBalance = currentBalance;
analytics.EstimatedBankroll = estimatedBankroll;
// Bug 1: Calculate time windows based on historical snapshots
var today = DateTime.UtcNow.Date;
// Save today's snapshot
var todaySnapshot = await _db.TraderDailySnapshots
.FirstOrDefaultAsync(s => s.TraderId == traderId && s.Date == today, ct);
if (todaySnapshot == null)
{
todaySnapshot = new TraderDailySnapshot
{
TraderId = traderId,
Date = today,
TotalPnl = overallPnl,
CurrentBalance = currentBalance
};
_db.TraderDailySnapshots.Add(todaySnapshot);
}
else
{
todaySnapshot.TotalPnl = overallPnl;
todaySnapshot.CurrentBalance = currentBalance;
}
// Fetch historical snapshots
var snapshot24h = await _db.TraderDailySnapshots
.Where(s => s.TraderId == traderId && s.Date <= today.AddDays(-1))
.OrderByDescending(s => s.Date)
.FirstOrDefaultAsync(ct);
var snapshot7d = await _db.TraderDailySnapshots
.Where(s => s.TraderId == traderId && s.Date <= today.AddDays(-7))
.OrderByDescending(s => s.Date)
.FirstOrDefaultAsync(ct);
var snapshot30d = await _db.TraderDailySnapshots
.Where(s => s.TraderId == traderId && s.Date <= today.AddDays(-30))
.OrderByDescending(s => s.Date)
.FirstOrDefaultAsync(ct);
analytics.PnL24h = overallPnl - (snapshot24h?.TotalPnl ?? 0);
analytics.PnL7d = overallPnl - (snapshot7d?.TotalPnl ?? 0);
analytics.PnL30d = overallPnl - (snapshot30d?.TotalPnl ?? 0);
// Calculate Win Rate on Market level
var (winRateOverall, winRate30d, winRate7d, winRate24h) = CalculateMarketWinRates(trades, tempPositions, cutoff30d, cutoff7d, cutoff24h);