Fix Aggregated-tier mutable-bucket vs checkpoint bug

The Aggregated ingest tier bucketed trades by hour incl. the current, still-growing
hour, then upserted via ON DUPLICATE KEY UPDATE (mutable rows). The PnL engine
checkpoints positions by row Id, so a bucket that keeps growing after being applied
had its later growth silently skipped (Id <= LastAppliedTradeId).

Extract the duplicated aggregation logic from PollingWorker + TradeHistoryWorker into
TradeAggregation.AggregateCompletedHours, which only aggregates COMPLETED hours; the
current hour is deferred (re-fetched next cycle) so every persisted aggregate is
immutable. +3 unit tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-07-20 08:57:46 +02:00
co-authored by Claude Opus 4.8
parent 3ed0b4df27
commit be1b90b556
4 changed files with 154 additions and 55 deletions
@@ -0,0 +1,81 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Predictalytics.Application.Services;
using Predictalytics.Domain.Entities;
using Predictalytics.Domain.Enums;
using Xunit;
namespace Predictalytics.Application.Tests.Services;
public class TradeAggregationTests
{
private static Trade Raw(int outcomeId, TradeSide side, decimal price, decimal size, DateTime at)
=> new()
{
MarketOutcomeId = outcomeId, DbMarketId = 10, MarketId = "cond", AssetId = "asset",
Outcome = "Yes", Side = side, Price = price, Size = size, Amount = price * size, ExecutedAt = at
};
[Fact]
public void AggregateCompletedHours_SumsCompletedHourWithVwap()
{
var now = new DateTime(2026, 07, 13, 15, 30, 0, DateTimeKind.Utc);
var completedHour = new DateTime(2026, 07, 13, 10, 0, 0, DateTimeKind.Utc);
var trades = new List<Trade>
{
Raw(100, TradeSide.Buy, 0.40m, 50m, completedHour.AddMinutes(5)),
Raw(100, TradeSide.Buy, 0.60m, 50m, completedHour.AddMinutes(45)),
};
var result = TradeAggregation.AggregateCompletedHours(trades, traderId: 7, nowUtc: now);
var agg = Assert.Single(result);
Assert.Equal("AGG_7_100_Buy_2026071310", agg.PlatformTradeId);
Assert.Equal(100m, agg.Size); // 50 + 50
Assert.Equal(50m, agg.Amount); // 0.40*50 + 0.60*50 = 20 + 30
Assert.Equal(0.50m, agg.Price); // VWAP = 50/100
Assert.Equal(2, agg.AggregatedCount);
}
[Fact]
public void AggregateCompletedHours_DefersCurrentHour()
{
// The bug this guards against: the current, still-growing hour must NOT be aggregated,
// because its aggregate row is mutable-by-key while the PnL engine checkpoints by Id.
var now = new DateTime(2026, 07, 13, 15, 30, 0, DateTimeKind.Utc);
var trades = new List<Trade>
{
// completed hour -> aggregated
Raw(100, TradeSide.Buy, 0.50m, 40m, new DateTime(2026, 07, 13, 14, 10, 0, DateTimeKind.Utc)),
// current hour (15:xx) -> deferred
Raw(100, TradeSide.Buy, 0.50m, 99m, new DateTime(2026, 07, 13, 15, 05, 0, DateTimeKind.Utc)),
};
var result = TradeAggregation.AggregateCompletedHours(trades, traderId: 7, nowUtc: now);
Assert.Single(result); // only the 14:00 bucket
Assert.EndsWith("_2026071314", result[0].PlatformTradeId);
Assert.Equal(40m, result[0].Size); // current-hour 99 shares NOT included
}
[Fact]
public void AggregateCompletedHours_SeparatesOutcomeAndSide()
{
var now = new DateTime(2026, 07, 13, 15, 0, 0, DateTimeKind.Utc);
var h = new DateTime(2026, 07, 13, 12, 0, 0, DateTimeKind.Utc);
var trades = new List<Trade>
{
Raw(100, TradeSide.Buy, 0.50m, 10m, h),
Raw(100, TradeSide.Sell, 0.50m, 10m, h),
Raw(101, TradeSide.Buy, 0.50m, 10m, h),
};
var result = TradeAggregation.AggregateCompletedHours(trades, traderId: 7, nowUtc: now);
Assert.Equal(3, result.Count); // (100,Buy), (100,Sell), (101,Buy) are distinct buckets
}
}