Files
maraphon-app/src/Marathon.Domain/Backtesting/BacktestStrategy.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

73 lines
3.0 KiB
C#

namespace Marathon.Domain.Backtesting;
/// <summary>
/// Parameters fed to <see cref="BacktestSimulator"/>. The strategy is "for every
/// SuspensionFlip anomaly with score ≥ <see cref="MinScore"/>, stake
/// according to <see cref="StakeRule"/> on the post-flip favourite at the
/// post-flip rate, then settle against the actual <c>EventResult</c>."
/// </summary>
/// <param name="StartingBankroll">
/// Initial bankroll for compounding stake rules. Must be positive.
/// </param>
/// <param name="MinScore">
/// Lower bound on <c>Anomaly.Score</c> — only anomalies at or above this
/// threshold are bet on. Must be in [0, 1].
/// </param>
/// <param name="StakeRule">How to size each bet — see the enum docs.</param>
/// <param name="FlatStake">
/// Used when <see cref="StakeRule"/> is <see cref="Backtesting.StakeRule.Flat"/>.
/// Must be positive.
/// </param>
/// <param name="PercentOfBankroll">
/// Used when <see cref="StakeRule"/> is
/// <see cref="Backtesting.StakeRule.PercentOfBankroll"/>. Expressed as a
/// fraction in (0, 1]. e.g. 0.02 = 2 % of bankroll.
/// </param>
/// <param name="KellyFraction">
/// Used when <see cref="StakeRule"/> is <see cref="Backtesting.StakeRule.Kelly"/>.
/// Multiplier on the raw Kelly fraction; in (0, 1]. 0.25 (quarter-Kelly) is
/// the conservative default.
/// </param>
public sealed record BacktestStrategy(
decimal StartingBankroll,
decimal MinScore,
StakeRule StakeRule,
decimal FlatStake,
decimal PercentOfBankroll,
decimal KellyFraction)
{
public decimal StartingBankroll { get; } = StartingBankroll > 0m
? StartingBankroll
: throw new ArgumentOutOfRangeException(nameof(StartingBankroll),
StartingBankroll, "StartingBankroll must be positive.");
public decimal MinScore { get; } = MinScore is >= 0m and <= 1m
? MinScore
: throw new ArgumentOutOfRangeException(nameof(MinScore),
MinScore, "MinScore must be in [0, 1].");
public decimal FlatStake { get; } = FlatStake > 0m
? FlatStake
: throw new ArgumentOutOfRangeException(nameof(FlatStake),
FlatStake, "FlatStake must be positive.");
public decimal PercentOfBankroll { get; } = PercentOfBankroll is > 0m and <= 1m
? PercentOfBankroll
: throw new ArgumentOutOfRangeException(nameof(PercentOfBankroll),
PercentOfBankroll, "PercentOfBankroll must be in (0, 1].");
public decimal KellyFraction { get; } = KellyFraction is > 0m and <= 1m
? KellyFraction
: throw new ArgumentOutOfRangeException(nameof(KellyFraction),
KellyFraction, "KellyFraction must be in (0, 1].");
/// <summary>Sensible defaults — flat-stake, score ≥ 0.45, ¼-Kelly waiting in the wings.</summary>
public static BacktestStrategy Default { get; } = new(
StartingBankroll: 1000m,
MinScore: 0.45m,
StakeRule: StakeRule.Flat,
FlatStake: 50m,
PercentOfBankroll: 0.02m,
KellyFraction: 0.25m);
}