D3: Implement IngestMode classification, weekly biopsy, SnapshotOnly bypass, and Aggregated import grouping

This commit is contained in:
Richard
2026-07-19 17:23:19 +02:00
parent 6fd7e563f4
commit 3ed0b4df27
7 changed files with 401 additions and 17 deletions
@@ -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);
}
}
}