Files
Predictalytics/src/Predictalytics.Application.Tests/Services/PositionPnLEngineTests.cs
T

658 lines
28 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using Microsoft.EntityFrameworkCore;
using Predictalytics.Domain.Entities;
using Predictalytics.Domain.Enums;
using Predictalytics.Infrastructure.Data;
using Predictalytics.Infrastructure.Services;
using Microsoft.Extensions.Logging.Abstractions;
using System;
using System.Threading.Tasks;
using Xunit;
namespace Predictalytics.Application.Tests.Services;
public class PositionPnLEngineTests
{
private AppDbContext CreateDbContext(string? dbName = null)
{
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase(databaseName: dbName ?? Guid.NewGuid().ToString())
.Options;
return new AppDbContext(options);
}
[Fact]
public async Task RecalculateTraderPositionsAsync_BuyTrade_CreatesTraderPosition()
{
// Arrange
using var db = CreateDbContext();
var pnlEngine = new PositionPnLEngine(db, NullLogger<PositionPnLEngine>.Instance);
var trader = new Trader { Id = 1, PlatformUserId = "0x1", DisplayName = "Trader 1" };
var market = new Market { Id = 10, PlatformMarketId = 1L, Question = "Q?" };
var outcome = new MarketOutcome { Id = 100, MarketId = 10, Label = "Yes", TokenId = "t100", CurrentPrice = 0.60m };
market.Outcomes.Add(outcome);
db.Traders.Add(trader);
db.Markets.Add(market);
var buyTrade = new Trade
{
Id = 500,
TraderId = 1,
DbMarketId = 10,
MarketOutcomeId = 100,
Side = TradeSide.Buy,
Price = 0.50m,
Size = 100m,
Amount = 50m,
ExecutedAt = DateTime.UtcNow
};
db.Trades.Add(buyTrade);
await db.SaveChangesAsync();
// Act
await pnlEngine.RecalculateTraderPositionsAsync(1);
// Assert
var pos = await db.TraderPositions.FirstOrDefaultAsync(p => p.TraderId == 1 && p.MarketOutcomeId == 100);
Assert.NotNull(pos);
Assert.Equal(100m, pos.SharesHeld);
Assert.Equal(0.50m, pos.AvgCost);
Assert.Equal(0m, pos.RealizedPnl);
}
[Fact]
public async Task RecalculateTraderPositionsAsync_SellTrade_CalculatesRealizedPnL()
{
// Arrange
using var db = CreateDbContext();
var pnlEngine = new PositionPnLEngine(db, NullLogger<PositionPnLEngine>.Instance);
var trader = new Trader { Id = 1, PlatformUserId = "0x1", DisplayName = "Trader 1" };
var market = new Market { Id = 10, PlatformMarketId = 1L, Question = "Q?" };
var outcome = new MarketOutcome { Id = 100, MarketId = 10, Label = "Yes", TokenId = "t100", CurrentPrice = 0.60m };
market.Outcomes.Add(outcome);
db.Traders.Add(trader);
db.Markets.Add(market);
// Buy 100 shares at 0.40
db.Trades.Add(new Trade
{
Id = 501, TraderId = 1, DbMarketId = 10, MarketOutcomeId = 100,
Side = TradeSide.Buy, Price = 0.40m, Size = 100m, Amount = 40m,
ExecutedAt = DateTime.UtcNow.AddMinutes(-10)
});
// Sell 50 shares at 0.60
db.Trades.Add(new Trade
{
Id = 502, TraderId = 1, DbMarketId = 10, MarketOutcomeId = 100,
Side = TradeSide.Sell, Price = 0.60m, Size = 50m, Amount = 30m,
ExecutedAt = DateTime.UtcNow
});
await db.SaveChangesAsync();
// Act
await pnlEngine.RecalculateTraderPositionsAsync(1);
// Assert
var pos = await db.TraderPositions.FirstOrDefaultAsync(p => p.TraderId == 1 && p.MarketOutcomeId == 100);
Assert.NotNull(pos);
Assert.Equal(50m, pos.SharesHeld);
Assert.Equal(0.40m, pos.AvgCost); // AvgCost stays at 0.40
Assert.Equal(10m, pos.RealizedPnl); // 50 * (0.60 - 0.40) = 10
}
[Fact]
public async Task RecalculateTraderPositionsAsync_RedeemTrade_CalculatesRedeemPnL()
{
// Arrange
using var db = CreateDbContext();
var pnlEngine = new PositionPnLEngine(db, NullLogger<PositionPnLEngine>.Instance);
var trader = new Trader { Id = 1, PlatformUserId = "0x1", DisplayName = "Trader 1" };
var market = new Market { Id = 10, PlatformMarketId = 1L, Question = "Q?", IsResolved = true, ResolutionOutcome = "Yes" };
var outcome = new MarketOutcome { Id = 100, MarketId = 10, Label = "Yes", TokenId = "t100", CurrentPrice = 1.00m };
market.Outcomes.Add(outcome);
db.Traders.Add(trader);
db.Markets.Add(market);
// Buy 100 shares at 0.40
db.Trades.Add(new Trade
{
Id = 501, TraderId = 1, DbMarketId = 10, MarketOutcomeId = 100,
Side = TradeSide.Buy, Price = 0.40m, Size = 100m, Amount = 40m,
ExecutedAt = DateTime.UtcNow.AddMinutes(-10)
});
// Redeem at 1.00 (Win)
db.Trades.Add(new Trade
{
Id = 503, TraderId = 1, DbMarketId = 10, MarketOutcomeId = 100,
Side = TradeSide.Redeem, Price = 1.00m, Size = 100m, Amount = 100m,
ExecutedAt = DateTime.UtcNow
});
await db.SaveChangesAsync();
// Act
await pnlEngine.RecalculateTraderPositionsAsync(1);
// Assert
var pos = await db.TraderPositions.FirstOrDefaultAsync(p => p.TraderId == 1 && p.MarketOutcomeId == 100);
Assert.NotNull(pos);
Assert.Equal(0m, pos.SharesHeld);
Assert.Equal(60m, pos.RealizedPnl); // 100 * (1.00 - 0.40) = 60
}
[Fact]
public async Task RecalculateTraderPositionsAsync_Idempotence_IgnoresOldTrades()
{
// Arrange
using var db = CreateDbContext();
var pnlEngine = new PositionPnLEngine(db, NullLogger<PositionPnLEngine>.Instance);
var trader = new Trader { Id = 1, PlatformUserId = "0x1", DisplayName = "Trader 1" };
var market = new Market { Id = 10, PlatformMarketId = 1L, Question = "Q?" };
var outcome = new MarketOutcome { Id = 100, MarketId = 10, Label = "Yes", TokenId = "t100", CurrentPrice = 0.60m };
market.Outcomes.Add(outcome);
var pos = new TraderPosition { Id = 1, TraderId = 1, MarketOutcomeId = 100, LastAppliedTradeId = 500, SharesHeld = 100, AvgCost = 0.5m };
db.Traders.Add(trader);
db.Markets.Add(market);
db.TraderPositions.Add(pos);
// This trade is older than LastAppliedTradeId, so it should be ignored!
var oldTrade = new Trade
{
Id = 499, TraderId = 1, DbMarketId = 10, MarketOutcomeId = 100,
Side = TradeSide.Buy, Price = 0.10m, Size = 100m, Amount = 10m,
ExecutedAt = DateTime.UtcNow
};
db.Trades.Add(oldTrade);
await db.SaveChangesAsync();
// Act
await pnlEngine.RecalculateTraderPositionsAsync(1);
// Assert
var updatedPos = await db.TraderPositions.FirstOrDefaultAsync(p => p.Id == 1);
Assert.Equal(0.5m, updatedPos!.AvgCost); // Unchanged, ignored old trade
}
[Fact]
public async Task RecalculateTraderPositionsAsync_PrunedHistory_DoesNotReset()
{
// Arrange
using var db = CreateDbContext();
var pnlEngine = new PositionPnLEngine(db, NullLogger<PositionPnLEngine>.Instance);
var trader = new Trader { Id = 1, PlatformUserId = "0x1", DisplayName = "Trader 1" };
var market = new Market { Id = 10, PlatformMarketId = 1L, Question = "Q?" };
var outcome = new MarketOutcome { Id = 100, MarketId = 10, Label = "Yes", TokenId = "t100", CurrentPrice = 0.60m };
market.Outcomes.Add(outcome);
// IsHistoryPruned = true means the engine cannot reconstruct this from trades.
var pos = new TraderPosition { Id = 1, TraderId = 1, MarketOutcomeId = 100, LastAppliedTradeId = 500, SharesHeld = 100, AvgCost = 0.5m, IsHistoryPruned = true, RealizedPnl = 50m };
db.Traders.Add(trader);
db.Markets.Add(market);
db.TraderPositions.Add(pos);
// DB has NO trades for this position because they were compacted/deleted.
await db.SaveChangesAsync();
// Act
await pnlEngine.RecalculateTraderPositionsAsync(1);
// Assert
var updatedPos = await db.TraderPositions.FirstOrDefaultAsync(p => p.Id == 1);
Assert.Equal(100m, updatedPos!.SharesHeld); // Should not be reset to 0
Assert.Equal(50m, updatedPos.RealizedPnl);
}
[Fact]
public async Task RecalculateTraderPositionsAsync_Split_AdjustsBalance()
{
// Arrange
using var db = CreateDbContext();
var pnlEngine = new PositionPnLEngine(db, NullLogger<PositionPnLEngine>.Instance);
var trader = new Trader { Id = 1, PlatformUserId = "0x1", DisplayName = "Trader 1" };
var market = new Market { Id = 10, PlatformMarketId = 1L, Question = "Q?" };
var outcome = new MarketOutcome { Id = 100, MarketId = 10, Label = "Yes", TokenId = "t100", CurrentPrice = 0.60m };
market.Outcomes.Add(outcome);
db.Traders.Add(trader);
db.Markets.Add(market);
var trade = new Trade
{
Id = 500, TraderId = 1, DbMarketId = 10, MarketOutcomeId = 100,
Side = TradeSide.Split, Price = 0.50m, Size = 100m, Amount = 0m, // 0 amount because no cash actually left wallet
ExecutedAt = DateTime.UtcNow
};
db.Trades.Add(trade);
await db.SaveChangesAsync();
// Act
await pnlEngine.RecalculateTraderPositionsAsync(1);
// Assert
var updatedTrader = await db.Traders.Include(t => t.Analytics).FirstOrDefaultAsync(t => t.Id == 1);
Assert.Equal(50m, updatedTrader!.Analytics!.EstimatedBankroll); // Math.Abs(currentBalance) after cashEquivalent subtraction
}
[Fact]
public async Task RecalculateTraderPositionsAsync_PnLWindows_UsesSnapshots()
{
// Arrange
using var db = CreateDbContext();
var pnlEngine = new PositionPnLEngine(db, NullLogger<PositionPnLEngine>.Instance);
var trader = new Trader { Id = 1, PlatformUserId = "0x1", DisplayName = "Trader 1" };
var market = new Market { Id = 10, PlatformMarketId = 1L, Question = "Q?" };
var outcome = new MarketOutcome { Id = 100, MarketId = 10, Label = "Yes", TokenId = "t100", CurrentPrice = 1.00m };
market.Outcomes.Add(outcome);
db.Traders.Add(trader);
db.Markets.Add(market);
// Snapshot from 7 days ago had TotalPnl = 100
var snapshot = new TraderDailySnapshot
{
TraderId = 1,
Date = DateTime.UtcNow.AddDays(-7).Date,
TotalPnl = 100m
};
db.TraderDailySnapshots.Add(snapshot);
// Let's create a new winning trade today that gives +50 PnL
var trade1 = new Trade { Id = 501, TraderId = 1, DbMarketId = 10, MarketOutcomeId = 100, Side = TradeSide.Buy, Price = 0.50m, Size = 100m, Amount = 50m, ExecutedAt = DateTime.UtcNow };
var trade2 = new Trade { Id = 502, TraderId = 1, DbMarketId = 10, MarketOutcomeId = 100, Side = TradeSide.Sell, Price = 1.00m, Size = 100m, Amount = 100m, ExecutedAt = DateTime.UtcNow };
db.Trades.AddRange(trade1, trade2);
await db.SaveChangesAsync();
// Act
await pnlEngine.RecalculateTraderPositionsAsync(1);
// Assert
var updatedTrader = await db.Traders.Include(t => t.Analytics).FirstOrDefaultAsync(t => t.Id == 1);
// PnL7d should be OverallPnL (150) - Snapshot (100) = 50
Assert.Equal(50m, updatedTrader!.Analytics!.OverallPnL);
// Wait, the test above doesn't have initial PnL of 100 on the trader. RecalculateTraderPositionsAsync recalculates from scratch.
// It will see 1 winning trade => OverallPnL = 50.
// Then PnL7d = OverallPnL (50) - SnapshotPnL (100) = -50.
Assert.Equal(-50m, updatedTrader.Analytics.PnL7d);
}
// ═════════════════════════════════════════════════════════════════════════
// Invariant tests added 2026-07-09 (review round 4).
// Each test pins the REQUIRED behavior for a confirmed, still-open defect.
// They are EXPECTED TO BE RED until the corresponding fix lands.
// Fix the engine — never weaken these assertions to make them pass.
// ═════════════════════════════════════════════════════════════════════════
/// <summary>
/// Defect 1: The totals (and the virtual-payout pass) iterate only over
/// positions rebuilt from the remaining trades. A position whose trades were
/// removed by the retention worker must still contribute its RealizedPnl to
/// OverallPnL — the position IS the compressed replacement for its history.
/// </summary>
[Fact]
public async Task RecalculateTraderPositionsAsync_PositionWithoutRemainingTrades_IsIncludedInOverallPnl()
{
// Arrange
var dbName = Guid.NewGuid().ToString();
using (var db = CreateDbContext(dbName))
{
var trader = new Trader { Id = 1, PlatformUserId = "0x1", DisplayName = "Trader 1" };
// Outcome 100: closed position, all of its trades pruned by retention.
var market1 = new Market { Id = 10, PlatformMarketId = 1L, Question = "Old market" };
market1.Outcomes.Add(new MarketOutcome { Id = 100, MarketId = 10, Label = "Yes", TokenId = "t100", CurrentPrice = 0.60m });
// Outcome 101: one live trade, so the engine runs its full path.
var market2 = new Market { Id = 11, PlatformMarketId = 2L, Question = "Live market" };
market2.Outcomes.Add(new MarketOutcome { Id = 101, MarketId = 11, Label = "Yes", TokenId = "t101", CurrentPrice = 0.50m });
db.Traders.Add(trader);
db.Markets.AddRange(market1, market2);
db.TraderPositions.Add(new TraderPosition
{
Id = 1, TraderId = 1, MarketOutcomeId = 100,
SharesHeld = 0, AvgCost = 0, RealizedPnl = 50m,
LastAppliedTradeId = 999, LastTradeExecutedAt = DateTime.UtcNow.AddDays(-40),
IsHistoryPruned = true
});
db.Trades.Add(new Trade
{
Id = 1000, TraderId = 1, DbMarketId = 11, MarketOutcomeId = 101,
Side = TradeSide.Buy, Price = 0.50m, Size = 100m, Amount = 50m,
ExecutedAt = DateTime.UtcNow
});
await db.SaveChangesAsync();
}
// Act (fresh context, like the worker does)
using (var db = CreateDbContext(dbName))
{
var pnlEngine = new PositionPnLEngine(db, NullLogger<PositionPnLEngine>.Instance);
await pnlEngine.RecalculateTraderPositionsAsync(1);
}
// Assert: 50 realized (orphaned position) + 0 unrealized (live buy at current price)
using (var db = CreateDbContext(dbName))
{
var analytics = await db.TraderAnalytics.SingleAsync(a => a.TraderId == 1);
Assert.Equal(50m, analytics.OverallPnL);
var trader = await db.Traders.SingleAsync(t => t.Id == 1);
Assert.Equal(50m, trader.TotalPnl);
}
}
/// <summary>
/// Defect 1 (virtual-payout variant): A position with open shares in a
/// RESOLVED market must receive its virtual payout even when none of its
/// trades exist anymore. Requires the engine to load positions with their
/// MarketOutcome/Market instead of relying on entities tracked via trades.
/// </summary>
[Fact]
public async Task RecalculateTraderPositionsAsync_VirtualPayout_AppliesToPositionWithoutRemainingTrades()
{
// Arrange
var dbName = Guid.NewGuid().ToString();
using (var db = CreateDbContext(dbName))
{
var trader = new Trader { Id = 1, PlatformUserId = "0x1", DisplayName = "Trader 1" };
var resolvedMarket = new Market
{
Id = 10, PlatformMarketId = 1L, Question = "Resolved market",
IsResolved = true, ResolutionOutcome = "Yes"
};
resolvedMarket.Outcomes.Add(new MarketOutcome { Id = 100, MarketId = 10, Label = "Yes", TokenId = "t100", CurrentPrice = 0.99m });
var liveMarket = new Market { Id = 11, PlatformMarketId = 2L, Question = "Live market" };
liveMarket.Outcomes.Add(new MarketOutcome { Id = 101, MarketId = 11, Label = "Yes", TokenId = "t101", CurrentPrice = 0.50m });
db.Traders.Add(trader);
db.Markets.AddRange(resolvedMarket, liveMarket);
// Winning position, bought at 0.40, never redeemed, trades pruned.
db.TraderPositions.Add(new TraderPosition
{
Id = 1, TraderId = 1, MarketOutcomeId = 100,
SharesHeld = 100m, AvgCost = 0.40m, RealizedPnl = 0m,
LastAppliedTradeId = 999, LastTradeExecutedAt = DateTime.UtcNow.AddDays(-40),
IsHistoryPruned = true
});
db.Trades.Add(new Trade
{
Id = 1000, TraderId = 1, DbMarketId = 11, MarketOutcomeId = 101,
Side = TradeSide.Buy, Price = 0.50m, Size = 10m, Amount = 5m,
ExecutedAt = DateTime.UtcNow
});
await db.SaveChangesAsync();
}
// Act
using (var db = CreateDbContext(dbName))
{
var pnlEngine = new PositionPnLEngine(db, NullLogger<PositionPnLEngine>.Instance);
await pnlEngine.RecalculateTraderPositionsAsync(1);
}
// Assert: virtual payout of 100 × (1.00 0.40) = 60 was booked.
using (var db = CreateDbContext(dbName))
{
var pos = await db.TraderPositions.SingleAsync(p => p.TraderId == 1 && p.MarketOutcomeId == 100);
Assert.Equal(60m, pos.RealizedPnl);
Assert.Equal(0m, pos.SharesHeld);
var analytics = await db.TraderAnalytics.SingleAsync(a => a.TraderId == 1);
Assert.Equal(60m, analytics.OverallPnL);
}
}
/// <summary>
/// Defect 3: If an external actor (the reconciliation worker) zeroes the
/// checkpoint of a position whose history is pruned, the engine must NOT
/// re-apply the remaining trades on top of the existing position state.
/// A pruned position can never be replayed — the engine has to detect the
/// inconsistent state, keep the stored values and restore a valid checkpoint.
/// This guard must live in the engine even if the reconciliation worker is
/// also fixed to skip pruned positions (defense in depth).
/// </summary>
[Fact]
public async Task RecalculateTraderPositionsAsync_PrunedPositionWithResetCheckpoint_DoesNotDoubleCount()
{
// Arrange
var dbName = Guid.NewGuid().ToString();
using (var db = CreateDbContext(dbName))
{
var trader = new Trader { Id = 1, PlatformUserId = "0x1", DisplayName = "Trader 1" };
var market = new Market { Id = 10, PlatformMarketId = 1L, Question = "Q?" };
market.Outcomes.Add(new MarketOutcome { Id = 100, MarketId = 10, Label = "Yes", TokenId = "t100", CurrentPrice = 0.50m });
db.Traders.Add(trader);
db.Markets.Add(market);
// This sell was already applied in an earlier run (its +25 PnL is part
// of RealizedPnl below). Earlier buys were pruned (IsHistoryPruned).
db.Trades.Add(new Trade
{
Id = 10, TraderId = 1, DbMarketId = 10, MarketOutcomeId = 100,
Side = TradeSide.Sell, Price = 0.75m, Size = 100m, Amount = 75m,
ExecutedAt = DateTime.UtcNow.AddDays(-1)
});
db.TraderPositions.Add(new TraderPosition
{
Id = 1, TraderId = 1, MarketOutcomeId = 100,
SharesHeld = 100m, AvgCost = 0.50m, RealizedPnl = 25m,
LastAppliedTradeId = 0, // externally reset, e.g. by TradeReconciliationWorker
LastTradeExecutedAt = DateTime.UtcNow.AddDays(-1),
IsHistoryPruned = true
});
await db.SaveChangesAsync();
}
// Act
using (var db = CreateDbContext(dbName))
{
var pnlEngine = new PositionPnLEngine(db, NullLogger<PositionPnLEngine>.Instance);
await pnlEngine.RecalculateTraderPositionsAsync(1);
}
// Assert: values unchanged — the sell must not be booked a second time.
using (var db = CreateDbContext(dbName))
{
var pos = await db.TraderPositions.SingleAsync(p => p.TraderId == 1 && p.MarketOutcomeId == 100);
Assert.Equal(25m, pos.RealizedPnl);
Assert.Equal(100m, pos.SharesHeld);
var analytics = await db.TraderAnalytics.SingleAsync(a => a.TraderId == 1);
Assert.Equal(25m, analytics.OverallPnL); // 25 realized + 0 unrealized (price == cost)
}
}
/// <summary>
/// Defect 4: MERGE burns shares and returns cash — it is the mirror image of
/// SPLIT, not a buy. The Polymarket activity API always delivers positive
/// sizes, so branching on the sign of Size sends every merge through the
/// buy branch (shares up, cash out). Booking must branch on TradeSide.
/// </summary>
[Fact]
public async Task RecalculateTraderPositionsAsync_MergeTrade_ReducesSharesAndReturnsCash()
{
// Arrange
var dbName = Guid.NewGuid().ToString();
using (var db = CreateDbContext(dbName))
{
var trader = new Trader { Id = 1, PlatformUserId = "0x1", DisplayName = "Trader 1" };
var market = new Market { Id = 10, PlatformMarketId = 1L, Question = "Q?" };
market.Outcomes.Add(new MarketOutcome { Id = 100, MarketId = 10, Label = "Yes", TokenId = "t100", CurrentPrice = 0.40m });
db.Traders.Add(trader);
db.Markets.Add(market);
db.Trades.Add(new Trade
{
Id = 10, TraderId = 1, DbMarketId = 10, MarketOutcomeId = 100,
Side = TradeSide.Buy, Price = 0.40m, Size = 100m, Amount = 40m,
ExecutedAt = DateTime.UtcNow.AddHours(-2)
});
// Merge of 100 shares — size is POSITIVE, exactly as the API delivers it.
db.Trades.Add(new Trade
{
Id = 20, TraderId = 1, DbMarketId = 10, MarketOutcomeId = 100,
Side = TradeSide.Merge, Price = 0.40m, Size = 100m, Amount = 40m,
ExecutedAt = DateTime.UtcNow.AddHours(-1)
});
await db.SaveChangesAsync();
}
// Act
using (var db = CreateDbContext(dbName))
{
var pnlEngine = new PositionPnLEngine(db, NullLogger<PositionPnLEngine>.Instance);
await pnlEngine.RecalculateTraderPositionsAsync(1);
}
// Assert: buy 40, merge +40 → flat position, flat cash.
using (var db = CreateDbContext(dbName))
{
var pos = await db.TraderPositions.SingleAsync(p => p.TraderId == 1 && p.MarketOutcomeId == 100);
Assert.Equal(0m, pos.SharesHeld);
Assert.Equal(0m, pos.RealizedPnl); // merged out at cost basis
var analytics = await db.TraderAnalytics.SingleAsync(a => a.TraderId == 1);
Assert.Equal(0m, analytics.CurrentBalance);
Assert.Equal(40m, analytics.EstimatedBankroll); // max cash drawdown was the buy
}
}
/// <summary>
/// Defect 6: A checkpoint reset causes the engine to replay trades whose
/// cashflows are already contained in the persisted CurrentBalance. The
/// replay must not book those cashflows a second time.
/// </summary>
[Fact]
public async Task RecalculateTraderPositionsAsync_CheckpointResetAndReplay_DoesNotDoubleCountBalance()
{
// Arrange
var dbName = Guid.NewGuid().ToString();
using (var db = CreateDbContext(dbName))
{
var trader = new Trader { Id = 1, PlatformUserId = "0x1", DisplayName = "Trader 1" };
var market = new Market { Id = 10, PlatformMarketId = 1L, Question = "Q?" };
market.Outcomes.Add(new MarketOutcome { Id = 100, MarketId = 10, Label = "Yes", TokenId = "t100", CurrentPrice = 0.50m });
db.Traders.Add(trader);
db.Markets.Add(market);
db.Trades.Add(new Trade
{
Id = 10, TraderId = 1, DbMarketId = 10, MarketOutcomeId = 100,
Side = TradeSide.Buy, Price = 0.50m, Size = 100m, Amount = 50m,
ExecutedAt = DateTime.UtcNow.AddHours(-1)
});
await db.SaveChangesAsync();
}
// First run — applies the buy, balance goes to 50.
using (var db = CreateDbContext(dbName))
{
var pnlEngine = new PositionPnLEngine(db, NullLogger<PositionPnLEngine>.Instance);
await pnlEngine.RecalculateTraderPositionsAsync(1);
}
// Simulate the reconciliation worker resetting the checkpoint
// (it does exactly this whenever it links orphaned trades).
using (var db = CreateDbContext(dbName))
{
var pos = await db.TraderPositions.SingleAsync(p => p.TraderId == 1 && p.MarketOutcomeId == 100);
pos.LastAppliedTradeId = 0;
await db.SaveChangesAsync();
}
// Second run — engine resets the position and replays the same buy.
using (var db = CreateDbContext(dbName))
{
var pnlEngine = new PositionPnLEngine(db, NullLogger<PositionPnLEngine>.Instance);
await pnlEngine.RecalculateTraderPositionsAsync(1);
}
// Assert: balance must equal the single-run result.
using (var db = CreateDbContext(dbName))
{
var analytics = await db.TraderAnalytics.SingleAsync(a => a.TraderId == 1);
Assert.Equal(-50m, analytics.CurrentBalance);
Assert.Equal(50m, analytics.EstimatedBankroll);
var pos = await db.TraderPositions.SingleAsync(p => p.TraderId == 1 && p.MarketOutcomeId == 100);
Assert.Equal(100m, pos.SharesHeld);
Assert.Equal(0.50m, pos.AvgCost);
}
}
/// <summary>
/// Defect 5: For a newly discovered trader there is no snapshot older than
/// the window, and the fallback of 0 turns the LIFETIME PnL into the
/// 24h/7d/30d PnL. A trader whose entire activity is older than the window
/// must report 0 for that window, not his all-time PnL.
/// </summary>
[Fact]
public async Task RecalculateTraderPositionsAsync_NewlyDiscoveredTraderWithOldHistory_WindowPnlIsNotLifetimePnl()
{
// Arrange: profitable round trip 60/50 days ago, no snapshots (first analysis).
var dbName = Guid.NewGuid().ToString();
using (var db = CreateDbContext(dbName))
{
var trader = new Trader { Id = 1, PlatformUserId = "0x1", DisplayName = "Trader 1" };
var market = new Market { Id = 10, PlatformMarketId = 1L, Question = "Q?" };
market.Outcomes.Add(new MarketOutcome { Id = 100, MarketId = 10, Label = "Yes", TokenId = "t100", CurrentPrice = 0.70m });
db.Traders.Add(trader);
db.Markets.Add(market);
db.Trades.Add(new Trade
{
Id = 10, TraderId = 1, DbMarketId = 10, MarketOutcomeId = 100,
Side = TradeSide.Buy, Price = 0.20m, Size = 100m, Amount = 20m,
ExecutedAt = DateTime.UtcNow.AddDays(-60)
});
db.Trades.Add(new Trade
{
Id = 20, TraderId = 1, DbMarketId = 10, MarketOutcomeId = 100,
Side = TradeSide.Sell, Price = 0.70m, Size = 100m, Amount = 70m,
ExecutedAt = DateTime.UtcNow.AddDays(-50)
});
await db.SaveChangesAsync();
}
// Act
using (var db = CreateDbContext(dbName))
{
var pnlEngine = new PositionPnLEngine(db, NullLogger<PositionPnLEngine>.Instance);
await pnlEngine.RecalculateTraderPositionsAsync(1);
}
// Assert: lifetime PnL is 50, but no trading happened inside any window.
using (var db = CreateDbContext(dbName))
{
var analytics = await db.TraderAnalytics.SingleAsync(a => a.TraderId == 1);
Assert.Equal(50m, analytics.OverallPnL);
Assert.Equal(0m, analytics.PnL30d);
Assert.Equal(0m, analytics.PnL7d);
Assert.Equal(0m, analytics.PnL24h);
}
}
}