e5cd2ab30c
- RunBacktestUseCase gains an ExecuteAsync(strategy, DateRange?, ct) overload that pushes the date filter to SQL via IAnomalyRepository.ListByDateRangeAsync; the existing no-range overload is preserved. +1 use-case test. - BacktestForm carries optional From/To (Moscow dates) with From<=To validation and a ToDateRange() helper; BacktestService threads it through. Backtest page gains two clearable date pickers (empty = all anomalies). - Localization (en+ru) for the backtest date fields and the settings-validation keys (shared resx).
119 lines
5.1 KiB
C#
119 lines
5.1 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// Loads every persisted anomaly paired with its event metadata and result,
|
|
/// constructs <see cref="BacktestCandidate"/> rows, and runs the pure
|
|
/// <see cref="BacktestSimulator"/> with the supplied strategy.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// 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).
|
|
/// </para>
|
|
/// <para>
|
|
/// 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 <see cref="BacktestResult.Skipped"/> counter only reflects
|
|
/// runs the strategy chose not to bet on (below threshold, no edge, etc.).
|
|
/// </para>
|
|
/// </remarks>
|
|
public sealed class RunBacktestUseCase
|
|
{
|
|
private readonly IAnomalyRepository _anomalies;
|
|
private readonly IEventRepository _events;
|
|
private readonly IResultRepository _results;
|
|
private readonly ILogger<RunBacktestUseCase> _logger;
|
|
|
|
public RunBacktestUseCase(
|
|
IAnomalyRepository anomalies,
|
|
IEventRepository events,
|
|
IResultRepository results,
|
|
ILogger<RunBacktestUseCase> 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));
|
|
}
|
|
|
|
/// <summary>Runs the backtest over every graded anomaly (no date filter).</summary>
|
|
public Task<BacktestResult> ExecuteAsync(
|
|
BacktestStrategy strategy,
|
|
CancellationToken ct = default)
|
|
=> ExecuteAsync(strategy, dateRange: null, ct);
|
|
|
|
/// <summary>
|
|
/// Runs the backtest over anomalies detected within <paramref name="dateRange"/>
|
|
/// (inclusive); pass <c>null</c> to include every graded anomaly. The date filter
|
|
/// is pushed to SQL via <see cref="IAnomalyRepository.ListByDateRangeAsync"/>.
|
|
/// </summary>
|
|
public async Task<BacktestResult> 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<BacktestCandidate>());
|
|
}
|
|
|
|
// 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<DomainEventId, string>(eventLookup.Count);
|
|
foreach (var (id, ev) in eventLookup)
|
|
titles[id] = ev.Title;
|
|
|
|
var candidates = new List<BacktestCandidate>(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;
|
|
}
|
|
}
|