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>
This commit is contained in:
2026-05-16 18:34:42 +03:00
parent 1ad896b07e
commit 0d52b7beff
17 changed files with 2249 additions and 0 deletions
@@ -410,4 +410,52 @@
<data name="Journal.Resolve.None"><value>No pending bets needed grading.</value></data>
<data name="Journal.Resolve.Done"><value>Graded {0} pending bet(s).</value></data>
<data name="Journal.Confirm.Delete"><value>Delete this bet permanently?</value></data>
<data name="Nav.Backtest"><value>Backtest</value></data>
<data name="Backtest.Kicker"><value>Simulator</value></data>
<data name="Backtest.Title"><value>Replay the detector against history</value></data>
<data name="Backtest.Lede"><value>Run a hypothetical strategy over every anomaly the detector has flagged. Choose a confidence threshold and a staking rule — the simulator settles every bet against the actual event result, compounds bankroll, and reports the headline numbers you need to judge edge.</value></data>
<data name="Backtest.Section.Strategy"><value>Strategy</value></data>
<data name="Backtest.Section.Headline"><value>Result</value></data>
<data name="Backtest.Section.Equity"><value>Equity curve</value></data>
<data name="Backtest.Section.Trace"><value>Trade trace</value></data>
<data name="Backtest.Field.Bankroll"><value>Starting bankroll</value></data>
<data name="Backtest.Field.MinScore"><value>Min anomaly score</value></data>
<data name="Backtest.Field.MinScore.Hint"><value>Only bet anomalies at or above this confidence.</value></data>
<data name="Backtest.Field.StakeRule"><value>Staking rule</value></data>
<data name="Backtest.Field.FlatStake"><value>Flat stake</value></data>
<data name="Backtest.Field.PercentOfBankroll"><value>Percent of bankroll</value></data>
<data name="Backtest.Field.KellyFraction"><value>Kelly fraction</value></data>
<data name="Backtest.Field.KellyFraction.Hint"><value>0.25 (quarter-Kelly) is the conservative default.</value></data>
<data name="Backtest.StakeRule.Flat"><value>Flat</value></data>
<data name="Backtest.StakeRule.PercentOfBankroll"><value>% of bankroll</value></data>
<data name="Backtest.StakeRule.Kelly"><value>Kelly</value></data>
<data name="Backtest.Action.Run"><value>Run simulation</value></data>
<data name="Backtest.Action.Running"><value>Simulating…</value></data>
<data name="Backtest.Stat.FinalBankroll"><value>Final bankroll</value></data>
<data name="Backtest.Stat.NetProfit"><value>Net profit</value></data>
<data name="Backtest.Stat.Roi"><value>ROI</value></data>
<data name="Backtest.Stat.MaxDrawdown"><value>Max drawdown</value></data>
<data name="Backtest.Stat.BetsPlaced"><value>Bets placed</value></data>
<data name="Backtest.Stat.Wins"><value>Wins</value></data>
<data name="Backtest.Stat.Losses"><value>Losses</value></data>
<data name="Backtest.Stat.Skipped"><value>Skipped</value></data>
<data name="Backtest.Stat.MaxWinStreak"><value>Max win streak</value></data>
<data name="Backtest.Stat.MaxLossStreak"><value>Max loss streak</value></data>
<data name="Backtest.Stat.TotalStaked"><value>Total staked</value></data>
<data name="Backtest.Stat.TotalReturned"><value>Total returned</value></data>
<data name="Backtest.Column.DetectedAt"><value>Detected</value></data>
<data name="Backtest.Column.Match"><value>Match</value></data>
<data name="Backtest.Column.Score"><value>Score</value></data>
<data name="Backtest.Column.Pick"><value>Pick</value></data>
<data name="Backtest.Column.Rate"><value>Rate</value></data>
<data name="Backtest.Column.Stake"><value>Stake</value></data>
<data name="Backtest.Column.Payout"><value>Payout</value></data>
<data name="Backtest.Column.Bankroll"><value>Bankroll</value></data>
<data name="Backtest.Column.Outcome"><value>Outcome</value></data>
<data name="Backtest.Outcome.Win"><value>Win</value></data>
<data name="Backtest.Outcome.Loss"><value>Loss</value></data>
<data name="Backtest.Empty.NoData"><value>No graded anomalies to simulate yet. Run the results loader so the detector has outcomes to replay against.</value></data>
<data name="Backtest.Empty.NoBetsPlaced"><value>The strategy placed zero bets — try lowering the score threshold, or switch staking rule.</value></data>
<data name="Backtest.Error.Generic"><value>Simulation failed — check the form values and try again.</value></data>
</root>