D3: Implement IngestMode classification, weekly biopsy, SnapshotOnly bypass, and Aggregated import grouping
This commit is contained in:
@@ -0,0 +1,227 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Predictalytics.Application.Interfaces;
|
||||
using Predictalytics.Domain.Entities;
|
||||
using Predictalytics.Domain.Enums;
|
||||
using Predictalytics.Domain.Interfaces;
|
||||
using Predictalytics.Infrastructure.Data;
|
||||
using Predictalytics.Infrastructure.Data.Repositories;
|
||||
using Predictalytics.Infrastructure.Services;
|
||||
using Predictalytics.Worker.Services;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace Predictalytics.Application.Tests.Services;
|
||||
|
||||
public class IngestModeTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task PollingWorker_BypassesSnapshotOnlyTrader()
|
||||
{
|
||||
using var connection = new SqliteConnection("DataSource=:memory:");
|
||||
connection.Open();
|
||||
var options = new DbContextOptionsBuilder<AppDbContext>()
|
||||
.UseSqlite(connection)
|
||||
.Options;
|
||||
|
||||
using (var setup = new AppDbContext(options))
|
||||
{
|
||||
setup.Database.EnsureCreated();
|
||||
setup.Traders.Add(new Trader
|
||||
{
|
||||
Id = 1,
|
||||
PlatformUserId = "0x1",
|
||||
DisplayName = "HN1",
|
||||
Platform = PlatformType.Polymarket,
|
||||
IngestMode = IngestMode.SnapshotOnly
|
||||
});
|
||||
setup.SaveChanges();
|
||||
}
|
||||
|
||||
var services = new ServiceCollection();
|
||||
services.AddScoped(_ => new AppDbContext(options));
|
||||
services.AddScoped<ITraderRepository, TraderRepository>();
|
||||
|
||||
var mockTradeRepo = new Mock<ITradeRepository>();
|
||||
mockTradeRepo.Setup(r => r.GetKnownPlatformTradeIdsAsync(It.IsAny<PlatformType>(), It.IsAny<int>(), It.IsAny<IEnumerable<string>>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new HashSet<string>());
|
||||
services.AddSingleton(mockTradeRepo.Object);
|
||||
|
||||
services.AddScoped<IMarketRepository, MarketRepository>();
|
||||
services.AddSingleton(typeof(ILogger<>), typeof(NullLogger<>));
|
||||
|
||||
var mockProvider = new Mock<IPlatformProvider>();
|
||||
mockProvider.Setup(p => p.Platform).Returns(PlatformType.Polymarket);
|
||||
mockProvider.Setup(p => p.IsImplemented).Returns(true);
|
||||
mockProvider.Setup(p => p.PlatformName).Returns("Polymarket");
|
||||
|
||||
mockProvider.Setup(p => p.GetTraderTradesAsync(It.IsAny<string>(), It.IsAny<int>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<Trade> { new Trade { PlatformTradeId = "test_tx" } });
|
||||
|
||||
services.AddSingleton<IEnumerable<IPlatformProvider>>(new[] { mockProvider.Object });
|
||||
|
||||
var mockRateLimiter = new Mock<IRateLimiter>();
|
||||
services.AddSingleton(mockRateLimiter.Object);
|
||||
|
||||
var mockStats = new Mock<IPlatformStatisticsService>();
|
||||
services.AddSingleton(mockStats.Object);
|
||||
|
||||
using var provider = services.BuildServiceProvider();
|
||||
|
||||
var worker = new PollingWorker(provider, NullLogger<PollingWorker>.Instance, mockStats.Object);
|
||||
|
||||
var method = typeof(PollingWorker).GetMethod("ExecuteAsync", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic);
|
||||
Assert.NotNull(method);
|
||||
|
||||
using var cts = new CancellationTokenSource();
|
||||
var task = (Task)method!.Invoke(worker, new object[] { cts.Token })!;
|
||||
|
||||
// Wait for worker to start, process, and then cancel
|
||||
await Task.Delay(6000);
|
||||
cts.Cancel();
|
||||
|
||||
try { await task; } catch (Exception) { }
|
||||
|
||||
// Assert: mockProvider GetTraderTradesAsync should NEVER be called since SnapshotOnly is skipped
|
||||
mockProvider.Verify(p => p.GetTraderTradesAsync(It.IsAny<string>(), It.IsAny<int>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AggregatedMode_AggregatesTradesCorrectly()
|
||||
{
|
||||
using var connection = new SqliteConnection("DataSource=:memory:");
|
||||
connection.Open();
|
||||
var options = new DbContextOptionsBuilder<AppDbContext>()
|
||||
.UseSqlite(connection)
|
||||
.Options;
|
||||
|
||||
using (var setup = new AppDbContext(options))
|
||||
{
|
||||
setup.Database.EnsureCreated();
|
||||
setup.Traders.Add(new Trader
|
||||
{
|
||||
Id = 2,
|
||||
PlatformUserId = "0x2",
|
||||
DisplayName = "AggregatedTrader",
|
||||
Platform = PlatformType.Polymarket,
|
||||
IngestMode = IngestMode.Aggregated
|
||||
});
|
||||
|
||||
var ev = new Event { Id = 1, Platform = PlatformType.Polymarket, Slug = "e", Title = "E" };
|
||||
setup.Set<Event>().Add(ev);
|
||||
|
||||
var market = new Market { Id = 10, EventId = 1, PlatformMarketId = 1L, Question = "Q?" };
|
||||
market.Outcomes.Add(new MarketOutcome { Id = 100, MarketId = 10, Label = "Yes", TokenId = "t100", CurrentPrice = 0.45m });
|
||||
setup.Markets.Add(market);
|
||||
|
||||
setup.SaveChanges();
|
||||
}
|
||||
|
||||
var services = new ServiceCollection();
|
||||
services.AddScoped(_ => new AppDbContext(options));
|
||||
services.AddScoped<ITraderRepository, TraderRepository>();
|
||||
|
||||
var mockTradeRepo = new Mock<ITradeRepository>();
|
||||
mockTradeRepo.Setup(r => r.GetKnownPlatformTradeIdsAsync(It.IsAny<PlatformType>(), It.IsAny<int>(), It.IsAny<IEnumerable<string>>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new HashSet<string>());
|
||||
|
||||
// Mock AddRangeAsync to bypass MySql raw query and save trades directly to SQLite in-memory DB
|
||||
mockTradeRepo.Setup(r => r.AddRangeAsync(It.IsAny<IEnumerable<Trade>>(), It.IsAny<CancellationToken>()))
|
||||
.Callback<IEnumerable<Trade>, CancellationToken>((trades, ct) =>
|
||||
{
|
||||
using var db = new AppDbContext(options);
|
||||
foreach (var t in trades)
|
||||
{
|
||||
db.Trades.Add(t);
|
||||
}
|
||||
db.SaveChanges();
|
||||
})
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
services.AddSingleton(mockTradeRepo.Object);
|
||||
|
||||
services.AddScoped<IMarketRepository, MarketRepository>();
|
||||
services.AddSingleton(typeof(ILogger<>), typeof(NullLogger<>));
|
||||
|
||||
var baseTime = new DateTime(2026, 7, 19, 10, 30, 0, DateTimeKind.Utc);
|
||||
|
||||
var mockProvider = new Mock<IPlatformProvider>();
|
||||
mockProvider.Setup(p => p.Platform).Returns(PlatformType.Polymarket);
|
||||
mockProvider.Setup(p => p.IsImplemented).Returns(true);
|
||||
mockProvider.Setup(p => p.PlatformName).Returns("Polymarket");
|
||||
|
||||
// Fetch returns two trades in the same hour
|
||||
mockProvider.Setup(p => p.GetTraderTradesAsync(It.IsAny<string>(), It.IsAny<int>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<Trade>
|
||||
{
|
||||
new Trade
|
||||
{
|
||||
PlatformTradeId = "tx1",
|
||||
MarketId = "10",
|
||||
AssetId = "t100",
|
||||
Side = TradeSide.Buy,
|
||||
Price = 0.40m,
|
||||
Size = 100m,
|
||||
Amount = 40m,
|
||||
ExecutedAt = baseTime
|
||||
},
|
||||
new Trade
|
||||
{
|
||||
PlatformTradeId = "tx2",
|
||||
MarketId = "10",
|
||||
AssetId = "t100",
|
||||
Side = TradeSide.Buy,
|
||||
Price = 0.60m,
|
||||
Size = 100m,
|
||||
Amount = 60m,
|
||||
ExecutedAt = baseTime.AddMinutes(15)
|
||||
}
|
||||
});
|
||||
|
||||
services.AddSingleton<IEnumerable<IPlatformProvider>>(new[] { mockProvider.Object });
|
||||
|
||||
var mockRateLimiter = new Mock<IRateLimiter>();
|
||||
services.AddSingleton(mockRateLimiter.Object);
|
||||
|
||||
var mockStats = new Mock<IPlatformStatisticsService>();
|
||||
services.AddSingleton(mockStats.Object);
|
||||
|
||||
using var provider = services.BuildServiceProvider();
|
||||
|
||||
var worker = new PollingWorker(provider, NullLogger<PollingWorker>.Instance, mockStats.Object);
|
||||
|
||||
var method = typeof(PollingWorker).GetMethod("ExecuteAsync", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic);
|
||||
Assert.NotNull(method);
|
||||
|
||||
using var cts = new CancellationTokenSource();
|
||||
var task = (Task)method!.Invoke(worker, new object[] { cts.Token })!;
|
||||
|
||||
// Wait for worker to start, process, and then cancel
|
||||
await Task.Delay(6000);
|
||||
cts.Cancel();
|
||||
|
||||
try { await task; } catch (Exception) { }
|
||||
|
||||
// Verify that only 1 aggregated trade is saved in the database
|
||||
using (var assertCtx = new AppDbContext(options))
|
||||
{
|
||||
var trades = await assertCtx.Trades.ToListAsync();
|
||||
Assert.Single(trades);
|
||||
var aggTrade = trades[0];
|
||||
Assert.Equal("AGG_2_100_Buy_2026071910", aggTrade.PlatformTradeId);
|
||||
Assert.Equal(200m, aggTrade.Size);
|
||||
Assert.Equal(100m, aggTrade.Amount);
|
||||
Assert.Equal(0.50m, aggTrade.Price); // VWAP: (40 + 60) / (100 + 100) = 0.50
|
||||
Assert.Equal(2, aggTrade.AggregatedCount);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user