Fix 10 critical bugs in Engine, Retention, Reconciliation and API
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user