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; /// /// Tests the orchestration: anomaly + event + result join + parse + delegate to /// the simulator. The simulator's own correctness is covered in /// Marathon.Domain.Tests. /// 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(); private readonly IEventRepository _events = Substitute.For(); private readonly IResultRepository _results = Substitute.For(); 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.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()) .Returns(Array.Empty().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()) .Returns(new[] { MakeAnomaly(id, score: 0.55m) }.ToList().AsReadOnly()); _events.GetAsync(id, Arg.Any()).Returns(MakeEvent(id)); _results.GetAsync(id, Arg.Any()) .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()) .Returns(new[] { MakeAnomaly(graded), MakeAnomaly(ungraded) }.ToList().AsReadOnly()); _events.GetAsync(graded, Arg.Any()).Returns(MakeEvent(graded)); _events.GetAsync(ungraded, Arg.Any()).Returns(MakeEvent(ungraded)); _results.GetAsync(graded, Arg.Any()) .Returns(new EventResult(graded, 0, 2, Side.Side2, DateTimeOffset.UtcNow)); _results.GetAsync(ungraded, Arg.Any()) .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()) .Returns(new[] { MakeAnomaly(id, evidence: "{not json") }.ToList().AsReadOnly()); _events.GetAsync(id, Arg.Any()).Returns(MakeEvent(id)); _results.GetAsync(id, Arg.Any()) .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"); } }