using Marathon.Application.Abstractions; 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)); } public async Task ExecuteAsync( BacktestStrategy strategy, CancellationToken ct = default) { ArgumentNullException.ThrowIfNull(strategy); _logger.LogInformation( "RunBacktestUseCase: started — bankroll={Bankroll}, minScore={MinScore}, stakeRule={Rule}", strategy.StartingBankroll, strategy.MinScore, strategy.StakeRule); var anomalies = await _anomalies.ListAsync(ct).ConfigureAwait(false); if (anomalies.Count == 0) { _logger.LogInformation("RunBacktestUseCase: no anomalies — empty result"); return BacktestSimulator.Run(strategy, Array.Empty()); } // Distinct event lookups — minimises repo calls. // TODO (perf, future): batch via IEventRepository.GetManyAsync / // IResultRepository.GetManyAsync once those exist — currently shared // with EvaluateAnomalyOutcomesUseCase, acceptable at expected volumes. var distinctEventIds = anomalies.Select(a => a.EventId).Distinct().ToList(); var eventLookup = new Dictionary(distinctEventIds.Count); var resultLookup = new Dictionary(distinctEventIds.Count); var titles = new Dictionary(distinctEventIds.Count); foreach (var id in distinctEventIds) { ct.ThrowIfCancellationRequested(); var ev = await _events.GetAsync(id, ct).ConfigureAwait(false); if (ev is not null) { eventLookup[id] = ev; titles[id] = string.Concat(ev.Side1Name, " vs ", ev.Side2Name); } var res = await _results.GetAsync(id, ct).ConfigureAwait(false); if (res is not null) resultLookup[id] = res; } 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; } }