Add unit tests for Engine and Scoring fixes
This commit is contained in:
@@ -33,13 +33,14 @@ public class AnalyticsServiceTests
|
|||||||
// Arrange
|
// Arrange
|
||||||
using var db = CreateDbContext();
|
using var db = CreateDbContext();
|
||||||
var traderRepo = new TraderRepository(db);
|
var traderRepo = new TraderRepository(db);
|
||||||
var tradeRepo = new TradeRepository(db);
|
var tradeRepo = new TradeRepository(db, Microsoft.Extensions.Logging.Abstractions.NullLogger<TradeRepository>.Instance);
|
||||||
var marketRepo = new MarketRepository(db);
|
var watchlistRepo = new WatchlistRepository(db);
|
||||||
|
|
||||||
var discoveryMock = new Mock<IDiscoveryService>();
|
var discoveryMock = new Mock<IDiscoveryService>();
|
||||||
var pnlEngineMock = new Mock<IPositionPnLEngine>();
|
var pnlEngineMock = new Mock<IPositionPnLEngine>();
|
||||||
var alertRepoMock = new Mock<IAlertRepository>();
|
var alertRepoMock = new Mock<IAlertRepository>();
|
||||||
var watchlistRepoMock = new Mock<IWatchlistRepository>();
|
var watchlistRepoMock = new Mock<IWatchlistRepository>();
|
||||||
|
var marketRepo = new MarketRepository(db);
|
||||||
var providers = new List<IPlatformProvider>();
|
var providers = new List<IPlatformProvider>();
|
||||||
|
|
||||||
var analyticsService = new AnalyticsService(
|
var analyticsService = new AnalyticsService(
|
||||||
|
|||||||
@@ -147,4 +147,146 @@ public class PositionPnLEngineTests
|
|||||||
Assert.Equal(0m, pos.SharesHeld);
|
Assert.Equal(0m, pos.SharesHeld);
|
||||||
Assert.Equal(60m, pos.RealizedPnl); // 100 * (1.00 - 0.40) = 60
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user