using Marathon.Application.Abstractions; using Marathon.Application.Storage; using Marathon.Domain.AnomalyDetection; using Marathon.Domain.Backtesting; using Marathon.Domain.Entities; using Microsoft.Extensions.Logging; using DomainEventId = Marathon.Domain.ValueObjects.EventId; namespace Marathon.Application.UseCases; /// /// Loads every persisted anomaly paired with its event metadata and result, /// constructs rows, and runs the pure /// with the supplied strategy. /// /// /// /// Composes the two analytics features already in place: anomalies come from /// the SuspensionFlip detector, and results come from the results loader. The /// simulator never touches I/O — all data loading happens here, then the run /// is a deterministic function of (strategy, candidates). /// /// /// Anomalies whose evidence JSON fails to parse, whose source events lack a /// final result, or whose event row has been pruned are filtered out before /// simulation. They are not counted as "skipped" by the simulator — the /// simulator's counter only reflects /// runs the strategy chose not to bet on (below threshold, no edge, etc.). /// /// public sealed class RunBacktestUseCase { private readonly IAnomalyRepository _anomalies; private readonly IEventRepository _events; private readonly IResultRepository _results; private readonly ILogger _logger; public RunBacktestUseCase( IAnomalyRepository anomalies, IEventRepository events, IResultRepository results, ILogger logger) { _anomalies = anomalies ?? throw new ArgumentNullException(nameof(anomalies)); _events = events ?? throw new ArgumentNullException(nameof(events)); _results = results ?? throw new ArgumentNullException(nameof(results)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } /// Runs the backtest over every graded anomaly (no date filter). public Task ExecuteAsync( BacktestStrategy strategy, CancellationToken ct = default) => ExecuteAsync(strategy, dateRange: null, ct); /// /// Runs the backtest over anomalies detected within /// (inclusive); pass null to include every graded anomaly. The date filter /// is pushed to SQL via . /// public async Task ExecuteAsync( BacktestStrategy strategy, DateRange? dateRange, CancellationToken ct) { ArgumentNullException.ThrowIfNull(strategy); _logger.LogInformation( "RunBacktestUseCase: started — bankroll={Bankroll}, minScore={MinScore}, stakeRule={Rule}, range={Range}", strategy.StartingBankroll, strategy.MinScore, strategy.StakeRule, dateRange is null ? "all" : $"{dateRange.From:O}..{dateRange.To:O}"); var anomalies = dateRange is null ? await _anomalies.ListAsync(ct).ConfigureAwait(false) : await _anomalies.ListByDateRangeAsync(dateRange.From, dateRange.To, ct).ConfigureAwait(false); if (anomalies.Count == 0) { _logger.LogInformation("RunBacktestUseCase: no anomalies — empty result"); return BacktestSimulator.Run(strategy, Array.Empty()); } // Batched lookups — a single query each, replacing the prior per-event // GetAsync round-trip (N+1 against SQLite). var distinctEventIds = anomalies.Select(a => a.EventId).Distinct().ToList(); var eventLookup = await _events.GetManyAsync(distinctEventIds, ct).ConfigureAwait(false); var resultLookup = await _results.GetManyAsync(distinctEventIds, ct).ConfigureAwait(false); var titles = new Dictionary(eventLookup.Count); foreach (var (id, ev) in eventLookup) titles[id] = ev.Title; var candidates = new List(anomalies.Count); foreach (var anomaly in anomalies) { ct.ThrowIfCancellationRequested(); // Cannot simulate a bet whose event hasn't been graded yet. if (!resultLookup.TryGetValue(anomaly.EventId, out var result)) continue; if (!AnomalyEvidenceParser.TryParse(anomaly.EvidenceJson, out var evidence)) continue; eventLookup.TryGetValue(anomaly.EventId, out var ev); candidates.Add(new BacktestCandidate(anomaly, evidence, result, ev?.Sport)); } var simResult = BacktestSimulator.Run(strategy, candidates, titles); _logger.LogInformation( "RunBacktestUseCase: done — bets={Bets}, wins={Wins}, losses={Losses}, ROI={Roi:0.##}%, finalBankroll={Final}", simResult.BetsPlaced, simResult.Wins, simResult.Losses, simResult.RoiPercent ?? 0m, simResult.FinalBankroll); return simResult; } }