Files
maraphon-app/tests/Marathon.Application.Tests/UseCases/RunBacktestUseCaseTests.cs
T
alexei.dolgolyov f294255f10 perf: batch repository reads, index snapshots, centralize date encoding
- Add IEventRepository/IResultRepository.GetManyAsync to kill N+1 lookups at
  6 sites (backtest, outcome eval, both bet-journal paths, anomaly browsing,
  results selection); guarded by a Received(1).GetManyAsync test.
- Add EventRepository.QueryAsync to push date+sport filtering to SQL (was
  load-whole-range-then-filter); search/sort stay in-memory for Cyrillic order.
- Add AnomalyRepository.CountSinceAsync (unread badge) + ListByDateRangeAsync
  (feed date filter); add Event/Snapshot count methods for the dashboard.
- Add composite indexes IX_Snapshots_EventCode_CapturedAt and
  _EventCode_Source_CapturedAt via a new migration + model snapshot.
- Introduce SqliteDateText as the single source of the O-format date encoding
  shared by Mapping (read/write) and the repositories' range predicates.
- Fix LiveOddsPoller cadence drift (budget sleep against cycle time); make
  DetectAnomalies dedup O(1) per event; add Event.Title to dedup the title join.

Tests adapted to the batched GetManyAsync via a TestFixtures bridge.
2026-05-28 22:34:08 +03:00

138 lines
5.6 KiB
C#

using FluentAssertions;
using Marathon.Application.Abstractions;
using Marathon.Application.UseCases;
using Marathon.Domain.Backtesting;
using Marathon.Domain.Entities;
using Marathon.Domain.Enums;
using Marathon.Domain.ValueObjects;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
namespace Marathon.Application.Tests.UseCases;
/// <summary>
/// Tests the orchestration: anomaly + event + result join + parse + delegate to
/// the simulator. The simulator's own correctness is covered in
/// Marathon.Domain.Tests.
/// </summary>
public sealed class RunBacktestUseCaseTests
{
private static readonly TimeSpan MoscowOffset = TimeSpan.FromHours(3);
private static readonly DateTimeOffset BaseTime =
new(2026, 5, 10, 18, 0, 0, MoscowOffset);
private readonly IAnomalyRepository _anomalies = Substitute.For<IAnomalyRepository>();
private readonly IEventRepository _events = Substitute.For<IEventRepository>();
private readonly IResultRepository _results = Substitute.For<IResultRepository>();
public RunBacktestUseCaseTests()
{
// Use case batches event/result loads via GetManyAsync; route through per-id stubs.
TestFixtures.BridgeGetMany(_events);
TestFixtures.BridgeGetMany(_results);
}
private RunBacktestUseCase CreateSut() =>
new(_anomalies, _events, _results, NullLogger<RunBacktestUseCase>.Instance);
private const string FlipEvidence = """
{
"suspensionGapSeconds": 90,
"preSuspension": {
"capturedAt": "2026-05-10T18:00:00+03:00",
"p1": 0.55, "pDraw": 0.20, "p2": 0.25,
"rate1": 1.8, "rateDraw": 4.5, "rate2": 4.0
},
"postSuspension": {
"capturedAt": "2026-05-10T18:02:30+03:00",
"p1": 0.25, "pDraw": 0.20, "p2": 0.55,
"rate1": 4.0, "rateDraw": 4.5, "rate2": 1.8
}
}
""";
private static Anomaly MakeAnomaly(EventId eventId, decimal score = 0.55m, string? evidence = null) =>
new(Guid.NewGuid(), eventId, BaseTime, AnomalyKind.SuspensionFlip,
score, evidence ?? FlipEvidence);
private static Event MakeEvent(EventId id) =>
new(id, new SportCode(11), "BY", "L1", "Cat", BaseTime, "Team A", "Team B");
private static BacktestStrategy DefaultStrategy(decimal minScore = 0.30m) =>
new(StartingBankroll: 1000m, MinScore: minScore,
StakeRule: StakeRule.Flat,
FlatStake: 100m, PercentOfBankroll: 0.02m, KellyFraction: 0.25m);
[Fact]
public async Task Should_ReturnEmptyResult_When_NoAnomaliesExist()
{
_anomalies.ListAsync(Arg.Any<CancellationToken>())
.Returns(Array.Empty<Anomaly>().ToList().AsReadOnly());
var result = await CreateSut().ExecuteAsync(DefaultStrategy(), CancellationToken.None);
result.BetsPlaced.Should().Be(0);
result.FinalBankroll.Should().Be(1000m);
}
[Fact]
public async Task Should_SimulateBet_When_AnomalyHasResult()
{
// Anomaly with result, Side2 (post-flip favourite) wins → +100.
var id = new EventId("event-1");
_anomalies.ListAsync(Arg.Any<CancellationToken>())
.Returns(new[] { MakeAnomaly(id, score: 0.55m) }.ToList().AsReadOnly());
_events.GetAsync(id, Arg.Any<CancellationToken>()).Returns(MakeEvent(id));
_results.GetAsync(id, Arg.Any<CancellationToken>())
.Returns(new EventResult(id, 0, 2, Side.Side2, DateTimeOffset.UtcNow));
var result = await CreateSut().ExecuteAsync(DefaultStrategy(), CancellationToken.None);
result.BetsPlaced.Should().Be(1);
result.Wins.Should().Be(1);
result.NetProfit.Should().Be(80m,
"stake 100 at rate 1.8 — win pays 180, profit 80");
}
[Fact]
public async Task Should_FilterOut_AnomaliesWithoutResults()
{
var graded = new EventId("graded");
var ungraded = new EventId("ungraded");
_anomalies.ListAsync(Arg.Any<CancellationToken>())
.Returns(new[] { MakeAnomaly(graded), MakeAnomaly(ungraded) }.ToList().AsReadOnly());
_events.GetAsync(graded, Arg.Any<CancellationToken>()).Returns(MakeEvent(graded));
_events.GetAsync(ungraded, Arg.Any<CancellationToken>()).Returns(MakeEvent(ungraded));
_results.GetAsync(graded, Arg.Any<CancellationToken>())
.Returns(new EventResult(graded, 0, 2, Side.Side2, DateTimeOffset.UtcNow));
_results.GetAsync(ungraded, Arg.Any<CancellationToken>())
.Returns((EventResult?)null);
var result = await CreateSut().ExecuteAsync(DefaultStrategy(), CancellationToken.None);
// Only the graded anomaly should reach the simulator — the ungraded one is filtered out
// before the simulator, so it does NOT appear in result.Skipped.
(result.BetsPlaced + result.Skipped).Should().Be(1);
}
[Fact]
public async Task Should_FilterOut_AnomaliesWithMalformedEvidence()
{
var id = new EventId("bad-evidence");
_anomalies.ListAsync(Arg.Any<CancellationToken>())
.Returns(new[] { MakeAnomaly(id, evidence: "{not json") }.ToList().AsReadOnly());
_events.GetAsync(id, Arg.Any<CancellationToken>()).Returns(MakeEvent(id));
_results.GetAsync(id, Arg.Any<CancellationToken>())
.Returns(new EventResult(id, 0, 2, Side.Side2, DateTimeOffset.UtcNow));
var result = await CreateSut().ExecuteAsync(DefaultStrategy(), CancellationToken.None);
result.BetsPlaced.Should().Be(0);
result.Skipped.Should().Be(0,
"malformed evidence is filtered before the simulator — not counted as a strategy skip");
}
}