Files
maraphon-app/src/Marathon.UI/Services/BacktestViewModels.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

90 lines
3.3 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 0100; converted to a fraction before sim.</summary>
public decimal PercentOfBankrollPercent { get; set; } = 2m;
/// <summary>Bound to the UI as a percentage 0100; 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);