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>
114 lines
5.1 KiB
C#
114 lines
5.1 KiB
C#
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 < 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);
|