Files
maraphon-app/src/Marathon.Domain/Backtesting/BacktestResult.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

114 lines
5.1 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.Entities;
using Marathon.Domain.Enums;
using Marathon.Domain.ValueObjects;
namespace Marathon.Domain.Backtesting;
/// <summary>
/// Aggregate output of one simulation run. Contains both the headline numbers
/// the user looks at (final bankroll, ROI, max drawdown) and the per-bet
/// trace needed to draw an equity curve.
/// </summary>
/// <param name="StartingBankroll">Echoed from the strategy for the UI.</param>
/// <param name="FinalBankroll">Bankroll after the last simulated bet settled.</param>
/// <param name="NetProfit"><c>FinalBankroll StartingBankroll</c>.</param>
/// <param name="RoiPercent">
/// <c>NetProfit / TotalStaked × 100</c>. Null when no bets were placed
/// (no anomaly met the threshold, or the bankroll went to zero before any
/// stake could be sized).
/// </param>
/// <param name="TotalStaked">Sum of stake sizes across every settled bet.</param>
/// <param name="TotalReturned">Sum of gross returns across every settled bet.</param>
/// <param name="MaxDrawdown">
/// Largest peak-to-trough drop in bankroll observed during the run, as an
/// absolute amount. Always ≥ 0.
/// </param>
/// <param name="MaxDrawdownPercent">
/// <see cref="MaxDrawdown"/> as a percentage of the peak that preceded it.
/// Null when there were no draws (no bets or no losses).
/// </param>
/// <param name="BetsPlaced">Total bets the strategy actually placed.</param>
/// <param name="Wins">Settled bets whose post-flip favourite won.</param>
/// <param name="Losses">Settled bets whose post-flip favourite lost.</param>
/// <param name="Skipped">
/// Total anomalies inspected but skipped. Equals
/// <see cref="SkippedByThreshold"/> + <see cref="SkippedByDataQuality"/> +
/// <see cref="SkippedByBankroll"/>. Surfaced separately so the UI can
/// distinguish a strategy choice ("threshold too high") from a real-world
/// signal ("bankroll empty") or a data-quality issue.
/// </param>
/// <param name="SkippedByThreshold">
/// Skipped because <c>Anomaly.Score &lt; strategy.MinScore</c> — pure strategy choice.
/// </param>
/// <param name="SkippedByDataQuality">
/// Skipped because the evidence parsed but the post-flip favourite has no
/// rate / probability, or because a two-way market produced a Draw winner.
/// Strategy-orthogonal — these would be skipped under any rule.
/// </param>
/// <param name="SkippedByBankroll">
/// Skipped because the sized stake was non-positive (Kelly returned no edge,
/// or bankroll was depleted) or exceeded the current bankroll.
/// </param>
/// <param name="MaxWinStreak">Longest run of consecutive wins.</param>
/// <param name="MaxLossStreak">Longest run of consecutive losses.</param>
/// <param name="Trace">
/// Per-bet records in chronological order — drives the equity curve.
/// </param>
/// <param name="EventTitles">
/// Pre-shaped <c>"Side1Name vs Side2Name"</c> strings keyed by event id, for
/// every event in <see cref="Trace"/>. Carried alongside the result so the UI
/// projection does not need a second pass over <c>IEventRepository</c>.
/// Missing events (pruned by retention) are absent from the map; consumers
/// fall back to <c>EventId.Value</c>.
/// </param>
public sealed record BacktestResult(
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<BacktestTrace> Trace,
IReadOnlyDictionary<Marathon.Domain.ValueObjects.EventId, string> EventTitles);
/// <summary>
/// One settled simulated bet. Carries enough metadata to surface a
/// drill-down row and a point on the equity curve.
/// </summary>
/// <param name="AnomalyId">Source anomaly for the link-back affordance.</param>
/// <param name="EventId">Event being bet on.</param>
/// <param name="DetectedAt">When the anomaly was originally detected.</param>
/// <param name="Score">Confidence score of the anomaly.</param>
/// <param name="Sport">Sport metadata if available — null when the event is missing.</param>
/// <param name="PostFlipFavourite">Side bet on (the post-suspension favourite).</param>
/// <param name="TakenRate">Rate at which the simulator "bought" the bet (post-flip rate).</param>
/// <param name="Stake">Stake sized for this bet.</param>
/// <param name="WinnerSide">Actual winner of the event.</param>
/// <param name="IsWin"><c>true</c> if the post-flip favourite was the winner.</param>
/// <param name="Payout">Gross return — <c>Stake × Rate</c> for a win, 0 for a loss.</param>
/// <param name="BankrollAfter">Bankroll after this bet settled — equity-curve y-axis.</param>
public sealed record BacktestTrace(
Guid AnomalyId,
EventId EventId,
DateTimeOffset DetectedAt,
decimal Score,
SportCode? Sport,
Side PostFlipFavourite,
decimal TakenRate,
decimal Stake,
Side WinnerSide,
bool IsWin,
decimal Payout,
decimal BankrollAfter);