0d52b7beff
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>
73 lines
3.0 KiB
C#
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);
|
|
}
|