Add unit tests for Engine and Scoring fixes
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user