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>
90 lines
3.3 KiB
C#
90 lines
3.3 KiB
C#
using Marathon.Domain.Backtesting;
|
||
using Marathon.Domain.Enums;
|
||
|
||
namespace Marathon.UI.Services;
|
||
|
||
/// <summary>
|
||
/// Form bound by the Backtest page. Loose-typed so MudBlazor fields can bind
|
||
/// raw numerics; the service translates this into a domain
|
||
/// <see cref="BacktestStrategy"/> after validation.
|
||
/// </summary>
|
||
public sealed class BacktestForm
|
||
{
|
||
public decimal StartingBankroll { get; set; } = 1000m;
|
||
public decimal MinScore { get; set; } = 0.45m;
|
||
public StakeRule StakeRule { get; set; } = StakeRule.Flat;
|
||
public decimal FlatStake { get; set; } = 50m;
|
||
|
||
/// <summary>Bound to the UI as a percentage 0–100; converted to a fraction before sim.</summary>
|
||
public decimal PercentOfBankrollPercent { get; set; } = 2m;
|
||
|
||
/// <summary>Bound to the UI as a percentage 0–100; converted to a fraction before sim.</summary>
|
||
public decimal KellyFractionPercent { get; set; } = 25m;
|
||
|
||
public bool IsValid(out string? error)
|
||
{
|
||
if (StartingBankroll <= 0m) { error = "Bankroll must be positive."; return false; }
|
||
if (MinScore is < 0m or > 1m) { error = "Min score must be in [0, 1]."; return false; }
|
||
switch (StakeRule)
|
||
{
|
||
case StakeRule.Flat:
|
||
if (FlatStake <= 0m) { error = "Flat stake must be positive."; return false; }
|
||
if (FlatStake > StartingBankroll) { error = "Flat stake exceeds starting bankroll."; return false; }
|
||
break;
|
||
case StakeRule.PercentOfBankroll:
|
||
if (PercentOfBankrollPercent is <= 0m or > 100m)
|
||
{ error = "Percent of bankroll must be in (0, 100]."; return false; }
|
||
break;
|
||
case StakeRule.Kelly:
|
||
if (KellyFractionPercent is <= 0m or > 100m)
|
||
{ error = "Kelly fraction must be in (0, 100]."; return false; }
|
||
break;
|
||
}
|
||
error = null;
|
||
return true;
|
||
}
|
||
|
||
public BacktestStrategy ToStrategy() =>
|
||
new(
|
||
StartingBankroll: StartingBankroll,
|
||
MinScore: MinScore,
|
||
StakeRule: StakeRule,
|
||
FlatStake: FlatStake,
|
||
PercentOfBankroll: PercentOfBankrollPercent / 100m,
|
||
KellyFraction: KellyFractionPercent / 100m);
|
||
}
|
||
|
||
/// <summary>UI-facing projection of <see cref="BacktestResult"/>.</summary>
|
||
public sealed record BacktestVm(
|
||
decimal StartingBankroll,
|
||
decimal FinalBankroll,
|
||
decimal NetProfit,
|
||
decimal? RoiPercent,
|
||
decimal TotalStaked,
|
||
decimal TotalReturned,
|
||
decimal MaxDrawdown,
|
||
decimal? MaxDrawdownPercent,
|
||
int BetsPlaced,
|
||
int Wins,
|
||
int Losses,
|
||
int Skipped,
|
||
int SkippedByThreshold,
|
||
int SkippedByDataQuality,
|
||
int SkippedByBankroll,
|
||
int MaxWinStreak,
|
||
int MaxLossStreak,
|
||
IReadOnlyList<BacktestTraceRow> Trace,
|
||
IReadOnlyList<EquityPoint> EquityCurve);
|
||
|
||
/// <summary>
|
||
/// Trace row plus pre-shaped event title for the link-back affordance.
|
||
/// </summary>
|
||
public sealed record BacktestTraceRow(
|
||
BacktestTrace Trace,
|
||
string EventTitle);
|
||
|
||
/// <summary>One point on the equity curve — bankroll over time.</summary>
|
||
/// <param name="DetectedAt">When the bet would have been placed.</param>
|
||
/// <param name="Bankroll">Bankroll after this bet settled.</param>
|
||
public sealed record EquityPoint(DateTimeOffset DetectedAt, decimal Bankroll);
|