c9eee9f907
Review follow-up (HIGH): the three detectors fed the same evaluator/backtest, but SuspensionFreeze is non-directional (favourite unchanged) — grading it as "favourite won" polluted the hit-rate with the base favourite-win rate, and its high frozen-ness score always cleared the backtest threshold. - Add AnomalyKind.IsDirectional() (flip + steam = true, freeze = false). - AnomalyOutcomeEvaluator returns Unresolved for non-directional kinds (favourites still surfaced for display) so they don't distort calibration. - RunBacktestUseCase skips non-directional anomalies when building candidates. - Tests for the classification, the evaluator path, and the backtest skip.
179 lines
7.6 KiB
C#
179 lines
7.6 KiB
C#
using FluentAssertions;
|
|
using Marathon.Application.Abstractions;
|
|
using Marathon.Application.Storage;
|
|
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_LoadByDateRange_When_RangeProvided()
|
|
{
|
|
var id = new EventId("77777777");
|
|
var range = new DateRange(BaseTime.AddDays(-1), BaseTime.AddDays(1));
|
|
|
|
_anomalies.ListByDateRangeAsync(
|
|
Arg.Any<DateTimeOffset?>(), Arg.Any<DateTimeOffset?>(), Arg.Any<CancellationToken>())
|
|
.Returns(new[] { MakeAnomaly(id) }.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(), range, CancellationToken.None);
|
|
|
|
await _anomalies.Received(1).ListByDateRangeAsync(
|
|
Arg.Any<DateTimeOffset?>(), Arg.Any<DateTimeOffset?>(), Arg.Any<CancellationToken>());
|
|
await _anomalies.DidNotReceive().ListAsync(Arg.Any<CancellationToken>());
|
|
result.BetsPlaced.Should().BeGreaterThan(0, "the in-range graded anomaly produces a bet");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Should_SkipNonDirectionalAnomalies_When_BuildingCandidates()
|
|
{
|
|
// A SuspensionFreeze anomaly (favourite unchanged) must not be staked.
|
|
var id = new EventId("88888888");
|
|
var freeze = new Anomaly(
|
|
Guid.NewGuid(), id, BaseTime, AnomalyKind.SuspensionFreeze, 0.9m, FlipEvidence);
|
|
|
|
_anomalies.ListAsync(Arg.Any<CancellationToken>())
|
|
.Returns(new[] { freeze }.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, "SuspensionFreeze is non-directional and must not be staked");
|
|
}
|
|
|
|
[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");
|
|
}
|
|
}
|