Files
maraphon-app/src/Marathon.Application/UseCases/RunBacktestUseCase.cs
T
alexei.dolgolyov 0d52b7beff feat(backtest): historical strategy backtester
Adds an interactive backtester that replays the SuspensionFlip detector over
all flagged anomalies under a chosen score threshold and staking rule
(flat / percent-of-bankroll / Kelly), and reports the headline numbers a
user needs to judge edge: final bankroll, ROI, max drawdown (peak-to-trough),
win/loss streaks, plus per-bet equity curve.

Domain (pure):
- StakeRule enum + BacktestStrategy params (with validation).
- BacktestSimulator: deterministic function taking strategy + chronological
  candidates → BacktestResult. Implements Kelly with post-flip implied prob
  as p (skipping negative-edge bets), peak-to-trough drawdown tracking, and
  win/loss streak rollups. Mirrors AnomalyOutcomeEvaluator on the 2-way Draw
  guard so tennis data inconsistencies are refused rather than miss-counted.
- Skipped counter split into SkippedByThreshold / SkippedByDataQuality /
  SkippedByBankroll so the UI can distinguish "strategy choice" from
  "data-quality" from "bankroll empty".

Application:
- RunBacktestUseCase: loads anomalies + events + results, parses evidence,
  builds candidates, hands event titles into the simulator so the UI does
  zero repository round-trips of its own.

UI:
- Pages/Anomalies/Backtest.razor: hero, strategy form (MudBlazor — conditional
  sub-field per staking rule), 4-card KPI strip (final bankroll / net profit
  / ROI / max drawdown), counters row, inline-SVG equity curve, trade-trace
  table with per-bet outcome pills and link-back to the source anomaly.
- Nav entry under Analysis. RU + EN i18n.

Tests: +20 (16 simulator math — flat / percent compounding / Kelly +/-
edge / quarter-Kelly / bankroll-exceeded / out-of-order chronology / Draw
favourite / multi-window drawdown / event-title pass-through + 4 use-case
join). All 399 tests pass.

Money rounding switched to MidpointRounding.AwayFromZero throughout the
simulator output for accounting convention.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 18:34:42 +03:00

116 lines
4.8 KiB
C#

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;
/// <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));
}
public async Task<BacktestResult> 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<BacktestCandidate>());
}
// 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<DomainEventId, Event>(distinctEventIds.Count);
var resultLookup = new Dictionary<DomainEventId, EventResult>(distinctEventIds.Count);
var titles = new Dictionary<DomainEventId, string>(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<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;
}
}