Fix 10 critical bugs in Engine, Retention, Reconciliation and API

This commit is contained in:
Richard
2026-07-09 19:37:02 +02:00
parent 21da1e867f
commit 44f48284a2
7 changed files with 121 additions and 31 deletions
@@ -45,6 +45,8 @@ public class PositionPnLEngine : IPositionPnLEngine
// Fetch existing positions for this trader to update or replace them
var existingPositions = await _db.TraderPositions
.Include(tp => tp.MarketOutcome)
.ThenInclude(o => o!.Market)
.Where(tp => tp.TraderId == traderId)
.ToDictionaryAsync(tp => tp.MarketOutcomeId, ct);
@@ -135,6 +137,12 @@ public class PositionPnLEngine : IPositionPnLEngine
continue;
}
// Bug 3: Pruned positions must not re-apply orphaned trades that were skipped before the checkpoint
if (pos.IsHistoryPruned && pos.LastTradeExecutedAt.HasValue && trade.ExecutedAt <= pos.LastTradeExecutedAt.Value)
{
continue;
}
var previousRealizedPnl = pos.RealizedPnl;
// Apply trade side booking rules
@@ -183,8 +191,8 @@ public class PositionPnLEngine : IPositionPnLEngine
case TradeSide.Split:
case TradeSide.Merge:
var cashEquivalent = Math.Abs(trade.Size) * trade.Price;
if (trade.Size > 0)
var cashEquivalent = trade.Size * trade.Price;
if (trade.Side == TradeSide.Split)
{
currentBalance -= cashEquivalent;
var totalCost = (pos.SharesHeld * pos.AvgCost) + cashEquivalent;
@@ -192,13 +200,12 @@ public class PositionPnLEngine : IPositionPnLEngine
pos.AvgCost = totalShares > 0 ? totalCost / totalShares : 0;
pos.SharesHeld = totalShares;
}
else if (trade.Size < 0)
else if (trade.Side == TradeSide.Merge)
{
var absSize = Math.Abs(trade.Size);
currentBalance += cashEquivalent;
var splitSizeToSell = Math.Min(absSize, pos.SharesHeld);
var splitSizeToSell = Math.Min(trade.Size, pos.SharesHeld);
pos.RealizedPnl += splitSizeToSell * (trade.Price - pos.AvgCost);
pos.SharesHeld -= absSize;
pos.SharesHeld -= trade.Size;
if (pos.SharesHeld < 0) pos.SharesHeld = 0;
}
break;
@@ -224,8 +231,13 @@ public class PositionPnLEngine : IPositionPnLEngine
var realizedPnlDelta = pos.RealizedPnl - previousRealizedPnl;
}
// Bug 1: Include positions without trades in this batch
var allPositions = tempPositions.Values
.Concat(existingPositions.Values.Where(ep => !tempPositions.ContainsKey(ep.MarketOutcomeId)))
.ToList();
// Bug 6: Virtual payout for unredeemed winning positions
foreach (var pos in tempPositions.Values)
foreach (var pos in allPositions)
{
if (pos.SharesHeld > 0 && pos.MarketOutcome?.Market != null)
{
@@ -247,9 +259,9 @@ public class PositionPnLEngine : IPositionPnLEngine
decimal totalRealizedPnl = 0;
decimal totalUnrealizedPnl = 0;
foreach (var pos in tempPositions.Values)
foreach (var pos in allPositions)
{
var outcome = trades.FirstOrDefault(t => t.MarketOutcomeId == pos.MarketOutcomeId)?.MarketOutcome;
var outcome = trades.FirstOrDefault(t => t.MarketOutcomeId == pos.MarketOutcomeId)?.MarketOutcome ?? pos.MarketOutcome;
if (pos.SharesHeld > 0 && outcome != null)
{
var unrealized = pos.SharesHeld * (outcome.CurrentPrice - pos.AvgCost);
@@ -257,13 +269,17 @@ public class PositionPnLEngine : IPositionPnLEngine
}
totalRealizedPnl += pos.RealizedPnl;
if (pos.Id == 0)
// Only update DB for positions that were modified or newly created
if (tempPositions.ContainsKey(pos.MarketOutcomeId))
{
_db.TraderPositions.Add(pos);
}
else
{
_db.TraderPositions.Update(pos);
if (pos.Id == 0)
{
_db.TraderPositions.Add(pos);
}
else
{
_db.TraderPositions.Update(pos);
}
}
}
@@ -317,9 +333,31 @@ public class PositionPnLEngine : IPositionPnLEngine
.OrderByDescending(s => s.Date)
.FirstOrDefaultAsync(ct);
analytics.PnL24h = overallPnl - (snapshot24h?.TotalPnl ?? 0);
analytics.PnL7d = overallPnl - (snapshot7d?.TotalPnl ?? 0);
analytics.PnL30d = overallPnl - (snapshot30d?.TotalPnl ?? 0);
var oldestSnapshot = await _db.TraderDailySnapshots
.Where(s => s.TraderId == traderId)
.OrderBy(s => s.Date)
.FirstOrDefaultAsync(ct);
var firstTrade = await _db.Trades
.Where(t => t.TraderId == traderId)
.OrderBy(t => t.ExecutedAt)
.FirstOrDefaultAsync(ct);
decimal GetFallback(DateTime cutoff)
{
if (firstTrade != null && firstTrade.ExecutedAt < cutoff)
{
// The trader traded before the window, but we have no snapshot.
// Fallback to oldest snapshot if it exists, otherwise overallPnl (so that PnL24h=0)
return oldestSnapshot?.TotalPnl ?? overallPnl;
}
// Trader started trading inside the window, so baseline is 0.
return 0;
}
analytics.PnL24h = overallPnl - (snapshot24h?.TotalPnl ?? GetFallback(today.AddDays(-1)));
analytics.PnL7d = overallPnl - (snapshot7d?.TotalPnl ?? GetFallback(today.AddDays(-7)));
analytics.PnL30d = overallPnl - (snapshot30d?.TotalPnl ?? GetFallback(today.AddDays(-30)));
// Count Trades30d
analytics.Trades30d = trades.Count(t => t.ExecutedAt >= cutoff30d);
@@ -336,7 +374,10 @@ public class PositionPnLEngine : IPositionPnLEngine
// Sync back to Trader record for quick sorting / UI display
trader.TotalPnl = overallPnl;
trader.WinRate = winRateOverall;
trader.LastAnalyzedAt = DateTime.UtcNow;
if (trades.Count > 0 || trader.LastTradesUpdatedAt != null)
{
trader.LastAnalyzedAt = DateTime.UtcNow;
}
// Calculate Category Performance
var existingCatPerf = await _db.TraderCategoryPerformances