Add unit tests for Engine and Scoring fixes

This commit is contained in:
Richard
2026-07-07 18:38:23 +02:00
parent 4eb0d99b4e
commit 882226c6c6
3 changed files with 239 additions and 2 deletions
@@ -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<TradeRepository>.Instance);
var watchlistRepo = new WatchlistRepository(db);
var discoveryMock = new Mock<IDiscoveryService>();
var pnlEngineMock = new Mock<IPositionPnLEngine>();
var alertRepoMock = new Mock<IAlertRepository>();
var watchlistRepoMock = new Mock<IWatchlistRepository>();
var marketRepo = new MarketRepository(db);
var providers = new List<IPlatformProvider>();
var analyticsService = new AnalyticsService(
@@ -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<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);
}
}
@@ -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<ITraderRepository>();
var tradeRepoMock = new Mock<ITradeRepository>();
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<CancellationToken>()))
.ReturnsAsync(trader);
// Return some trades that look like a bot or hedger
var trades = new List<Trade>
{
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<CancellationToken>()))
.ReturnsAsync(trades);
var scoringService = new ScoringService(traderRepoMock.Object, tradeRepoMock.Object, NullLogger<ScoringService>.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<ITraderRepository>();
var tradeRepoMock = new Mock<ITradeRepository>();
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<CancellationToken>()))
.ReturnsAsync(trader);
var trades = new List<Trade>
{
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<CancellationToken>()))
.ReturnsAsync(trades);
var scoringService = new ScoringService(traderRepoMock.Object, tradeRepoMock.Object, NullLogger<ScoringService>.Instance);
// Act
await scoringService.CalculateScoreAsync(1);
// Assert
// The heuristic SHOULD overwrite this because AI data is old.
Assert.Equal(StrategyType.Whale, trader.Strategy);
}
}