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() .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.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.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.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.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.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.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.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. // ═════════════════════════════════════════════════════════════════════════ /// /// 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. /// [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.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); } } /// /// 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. /// [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.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); } } /// /// 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). /// [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.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) } } /// /// 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. /// [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.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 } } /// /// 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. /// [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.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.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); } } /// /// 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. /// [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.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); } } }