diff --git a/src/Predictalytics.Application.Tests/Services/AnalyticsServiceTests.cs b/src/Predictalytics.Application.Tests/Services/AnalyticsServiceTests.cs index 3d960d0..6a3fbc4 100644 --- a/src/Predictalytics.Application.Tests/Services/AnalyticsServiceTests.cs +++ b/src/Predictalytics.Application.Tests/Services/AnalyticsServiceTests.cs @@ -33,13 +33,14 @@ public class AnalyticsServiceTests // Arrange using var db = CreateDbContext(); var traderRepo = new TraderRepository(db); - var tradeRepo = new TradeRepository(db); - var marketRepo = new MarketRepository(db); + var tradeRepo = new TradeRepository(db, Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); + var watchlistRepo = new WatchlistRepository(db); var discoveryMock = new Mock(); var pnlEngineMock = new Mock(); var alertRepoMock = new Mock(); var watchlistRepoMock = new Mock(); + var marketRepo = new MarketRepository(db); var providers = new List(); var analyticsService = new AnalyticsService( diff --git a/src/Predictalytics.Application.Tests/Services/PositionPnLEngineTests.cs b/src/Predictalytics.Application.Tests/Services/PositionPnLEngineTests.cs index 49092a9..ac6ac76 100644 --- a/src/Predictalytics.Application.Tests/Services/PositionPnLEngineTests.cs +++ b/src/Predictalytics.Application.Tests/Services/PositionPnLEngineTests.cs @@ -147,4 +147,146 @@ public class PositionPnLEngineTests 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); + } } diff --git a/src/Predictalytics.Application.Tests/Services/ScoringServiceTests.cs b/src/Predictalytics.Application.Tests/Services/ScoringServiceTests.cs new file mode 100644 index 0000000..86a29ea --- /dev/null +++ b/src/Predictalytics.Application.Tests/Services/ScoringServiceTests.cs @@ -0,0 +1,94 @@ +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Predictalytics.Application.Services; +using Predictalytics.Domain.Entities; +using Predictalytics.Domain.Enums; +using Predictalytics.Domain.Interfaces; +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Xunit; + +namespace Predictalytics.Application.Tests.Services; + +public class ScoringServiceTests +{ + [Fact] + public async Task CalculateScoreAsync_RecentAiStrategy_DoesNotOverwriteStrategy() + { + // Arrange + var traderRepoMock = new Mock(); + var tradeRepoMock = new Mock(); + + var trader = new Trader + { + Id = 1, + DisplayName = "Test", + Strategy = StrategyType.Scalper, // AI set this + AiStrategyUpdatedAt = DateTime.UtcNow.AddDays(-2), // 2 days ago (recent) + IsSuspectedBot = false + }; + + traderRepoMock.Setup(x => x.GetByIdAsync(1, It.IsAny())) + .ReturnsAsync(trader); + + // Return some trades that look like a bot or hedger + var trades = new List + { + new Trade { Id = 1, Amount = 15000, ExecutedAt = DateTime.UtcNow }, // Large size > 10000 => Whale heuristic + new Trade { Id = 2, Amount = 15000, ExecutedAt = DateTime.UtcNow.AddSeconds(2) } + }; + + tradeRepoMock.Setup(x => x.GetByTraderIdAsync(1, 0, 200, It.IsAny())) + .ReturnsAsync(trades); + + var scoringService = new ScoringService(traderRepoMock.Object, tradeRepoMock.Object, NullLogger.Instance); + + // Act + await scoringService.CalculateScoreAsync(1); + + // Assert + // The heuristic would normally classify this as Whale because avgSize > 10000. + // However, because AiStrategyUpdatedAt is within 7 days, it should NOT overwrite it! + Assert.Equal(StrategyType.Scalper, trader.Strategy); + } + + [Fact] + public async Task CalculateScoreAsync_OldAiStrategy_OverwritesStrategy() + { + // Arrange + var traderRepoMock = new Mock(); + var tradeRepoMock = new Mock(); + + var trader = new Trader + { + Id = 1, + DisplayName = "Test", + Strategy = StrategyType.Unknown, + AiStrategyUpdatedAt = DateTime.UtcNow.AddDays(-10), // 10 days ago (old) + IsSuspectedBot = false + }; + + traderRepoMock.Setup(x => x.GetByIdAsync(1, It.IsAny())) + .ReturnsAsync(trader); + + var trades = new List + { + new Trade { Id = 1, Amount = 15000, ExecutedAt = DateTime.UtcNow }, // Large size > 10000 => Whale heuristic + new Trade { Id = 2, Amount = 15000, ExecutedAt = DateTime.UtcNow.AddSeconds(2) } + }; + + tradeRepoMock.Setup(x => x.GetByTraderIdAsync(1, 0, 200, It.IsAny())) + .ReturnsAsync(trades); + + var scoringService = new ScoringService(traderRepoMock.Object, tradeRepoMock.Object, NullLogger.Instance); + + // Act + await scoringService.CalculateScoreAsync(1); + + // Assert + // The heuristic SHOULD overwrite this because AI data is old. + Assert.Equal(StrategyType.Whale, trader.Strategy); + } +}