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:
@@ -0,0 +1,72 @@
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user