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>
This commit is contained in:
2026-05-16 18:34:42 +03:00
parent 1ad896b07e
commit 0d52b7beff
17 changed files with 2249 additions and 0 deletions
@@ -0,0 +1,867 @@
@*
Backtest — historical strategy replayer.
Picks a confidence threshold and a staking rule, runs the simulator over
every graded anomaly, and reports the P&L story: equity curve, KPI strip,
per-bet trade trace. Same editorial-quant tone as Insights / Journal —
accent kicker (not anomaly-red), staged m-rise reveal, m-card form,
inline SVG equity curve, mono table.
*@
@page "/anomalies/backtest"
@using Marathon.Domain.Backtesting
@implements IDisposable
@inject IStringLocalizer<SharedResource> L
@inject IBacktestService Service
@inject NavigationManager Nav
@inject ISnackbar Snackbar
@inject ILogger<Backtest> Logger
<PageTitle>@L["App.Title"] · @L["Nav.Backtest"]</PageTitle>
<section class="m-shell">
<header class="m-rise m-rise-1 m-backtest__header" data-test="backtest-header">
<div class="m-backtest__header-text">
<span class="m-kicker">@L["Backtest.Kicker"]</span>
<h1 class="m-display" style="font-size: clamp(2rem, 4vw, 3rem);">@L["Backtest.Title"]</h1>
<p style="color: var(--m-c-ink-soft); max-width: 64ch;">@L["Backtest.Lede"]</p>
</div>
</header>
@* ---------- Strategy form ---------- *@
<section class="m-backtest__section m-rise m-rise-2" data-test="backtest-form-section">
<header class="m-backtest__section-head">
<span class="m-kicker">@L["Backtest.Section.Strategy"]</span>
</header>
<article class="m-card m-card--accented m-backtest__form-card">
<div class="m-backtest__form-grid">
<div class="m-backtest__form-field">
<label class="m-backtest__form-label">@L["Backtest.Field.Bankroll"]</label>
<MudNumericField T="decimal"
@bind-Value="_form.StartingBankroll"
Min="1m"
Step="100m"
Variant="Variant.Outlined"
data-test="backtest-bankroll" />
</div>
<div class="m-backtest__form-field">
<label class="m-backtest__form-label">@L["Backtest.Field.MinScore"]</label>
<MudNumericField T="decimal"
@bind-Value="_form.MinScore"
Min="0m"
Max="1m"
Step="0.05m"
Variant="Variant.Outlined"
data-test="backtest-min-score" />
<span class="m-backtest__form-hint">@L["Backtest.Field.MinScore.Hint"]</span>
</div>
<div class="m-backtest__form-field">
<label class="m-backtest__form-label">@L["Backtest.Field.StakeRule"]</label>
<MudSelect T="StakeRule"
Value="_form.StakeRule"
ValueChanged="OnStakeRuleChanged"
Variant="Variant.Outlined"
data-test="backtest-stake-rule">
@foreach (var rule in _stakeRules)
{
<MudSelectItem T="StakeRule" Value="@rule">@StakeRuleLabel(rule)</MudSelectItem>
}
</MudSelect>
</div>
@switch (_form.StakeRule)
{
case StakeRule.Flat:
<div class="m-backtest__form-field" data-test="backtest-flat-stake-field">
<label class="m-backtest__form-label">@L["Backtest.Field.FlatStake"]</label>
<MudNumericField T="decimal"
@bind-Value="_form.FlatStake"
Min="0.01m"
Step="10m"
Variant="Variant.Outlined"
data-test="backtest-flat-stake" />
</div>
break;
case StakeRule.PercentOfBankroll:
<div class="m-backtest__form-field" data-test="backtest-percent-field">
<label class="m-backtest__form-label">@L["Backtest.Field.PercentOfBankroll"]</label>
<MudNumericField T="decimal"
@bind-Value="_form.PercentOfBankrollPercent"
Min="0.01m"
Max="100m"
Step="0.5m"
Variant="Variant.Outlined"
data-test="backtest-percent" />
</div>
break;
case StakeRule.Kelly:
<div class="m-backtest__form-field" data-test="backtest-kelly-field">
<label class="m-backtest__form-label">@L["Backtest.Field.KellyFraction"]</label>
<MudNumericField T="decimal"
@bind-Value="_form.KellyFractionPercent"
Min="1m"
Max="100m"
Step="5m"
Variant="Variant.Outlined"
data-test="backtest-kelly" />
<span class="m-backtest__form-hint">@L["Backtest.Field.KellyFraction.Hint"]</span>
</div>
break;
}
</div>
@if (!string.IsNullOrEmpty(_formError))
{
<p class="m-backtest__form-error" data-test="backtest-form-error">@_formError</p>
}
<div class="m-backtest__form-actions">
<button type="button"
class="m-chip m-backtest__submit"
@onclick="RunAsync"
disabled="@_running"
data-test="backtest-run">
<span class="m-backtest__submit-glyph @(_running ? "is-spinning" : null)" aria-hidden="true">▶</span>
<span>@(_running ? L["Backtest.Action.Running"] : L["Backtest.Action.Run"])</span>
</button>
</div>
</article>
</section>
@if (_vm is { } vm)
{
<hr class="m-rule--double" />
@* ---------- Result headline ---------- *@
<section class="m-backtest__section m-rise m-rise-3" data-test="backtest-result-section">
<header class="m-backtest__section-head">
<span class="m-kicker">@L["Backtest.Section.Headline"]</span>
</header>
<div class="m-backtest__kpis" data-test="backtest-kpis">
<article class="m-backtest__kpi m-backtest__kpi--@BankrollTone(vm)" data-test="backtest-kpi-final">
<span class="m-backtest__kpi-label">@L["Backtest.Stat.FinalBankroll"]</span>
<span class="m-backtest__kpi-value">@FormatDecimal(vm.FinalBankroll)</span>
</article>
<article class="m-backtest__kpi m-backtest__kpi--@ProfitTone(vm)" data-test="backtest-kpi-profit">
<span class="m-backtest__kpi-label">@L["Backtest.Stat.NetProfit"]</span>
<span class="m-backtest__kpi-value">@FormatSignedDecimal(vm.NetProfit, vm.BetsPlaced)</span>
</article>
<article class="m-backtest__kpi m-backtest__kpi--@RoiTone(vm.RoiPercent)" data-test="backtest-kpi-roi">
<span class="m-backtest__kpi-label">@L["Backtest.Stat.Roi"]</span>
<span class="m-backtest__kpi-value">@FormatSignedPercent(vm.RoiPercent)</span>
</article>
<article class="m-backtest__kpi m-backtest__kpi--drawdown" data-test="backtest-kpi-drawdown">
<span class="m-backtest__kpi-label">@L["Backtest.Stat.MaxDrawdown"]</span>
@if (vm.MaxDrawdown == 0m && vm.MaxDrawdownPercent is null)
{
<span class="m-backtest__kpi-value" style="color: var(--m-c-ink-soft);">—</span>
}
else
{
<span class="m-backtest__kpi-value">@FormatSignedDecimal(-vm.MaxDrawdown, 1)</span>
<span class="m-backtest__kpi-sub">@FormatSignedPercent(vm.MaxDrawdownPercent is null ? null : -vm.MaxDrawdownPercent.Value)</span>
}
</article>
</div>
<div class="m-backtest__counts m-mono" data-test="backtest-counts">
<span><span class="m-backtest__counts-label">@L["Backtest.Stat.BetsPlaced"]</span> <strong>@vm.BetsPlaced</strong></span>
<span aria-hidden="true">·</span>
<span><span class="m-backtest__counts-label">@L["Backtest.Stat.Wins"]</span> <strong style="color: var(--m-c-positive);">@vm.Wins</strong></span>
<span aria-hidden="true">·</span>
<span><span class="m-backtest__counts-label">@L["Backtest.Stat.Losses"]</span> <strong style="color: var(--m-c-anomaly);">@vm.Losses</strong></span>
<span aria-hidden="true">·</span>
<span><span class="m-backtest__counts-label">@L["Backtest.Stat.Skipped"]</span> <strong>@vm.Skipped</strong></span>
<span aria-hidden="true">·</span>
<span><span class="m-backtest__counts-label">@L["Backtest.Stat.MaxWinStreak"]</span> <strong>@vm.MaxWinStreak</strong></span>
<span aria-hidden="true">·</span>
<span><span class="m-backtest__counts-label">@L["Backtest.Stat.MaxLossStreak"]</span> <strong>@vm.MaxLossStreak</strong></span>
</div>
</section>
@if (vm.BetsPlaced == 0 && vm.Trace.Count == 0 && vm.Skipped == 0)
{
<div class="m-list-empty m-rise m-rise-4" data-test="backtest-empty-no-data">
<span class="m-kicker" style="border-color: var(--m-c-ink-soft); color: var(--m-c-ink-soft);">
@L["Common.Empty"]
</span>
<p style="color: var(--m-c-ink-soft); margin-top: var(--m-space-3); max-width: 56ch;">
@L["Backtest.Empty.NoData"]
</p>
</div>
}
else if (vm.BetsPlaced == 0)
{
<div class="m-list-empty m-rise m-rise-4" data-test="backtest-empty-no-bets">
<span class="m-kicker" style="border-color: var(--m-c-ink-soft); color: var(--m-c-ink-soft);">
@L["Common.Empty"]
</span>
<p style="color: var(--m-c-ink-soft); margin-top: var(--m-space-3); max-width: 56ch;">
@L["Backtest.Empty.NoBetsPlaced"]
</p>
</div>
}
else
{
<hr class="m-rule--double" />
@* ---------- Equity curve ---------- *@
<section class="m-backtest__section m-rise m-rise-4" data-test="backtest-equity-section">
<header class="m-backtest__section-head">
<span class="m-kicker">@L["Backtest.Section.Equity"]</span>
</header>
<article class="m-backtest__equity">
@if (vm.EquityCurve.Count == 0)
{
<div class="m-list-empty" data-test="backtest-equity-empty">
<p style="color: var(--m-c-ink-soft); max-width: 50ch;">@L["Backtest.Empty.NoBetsPlaced"]</p>
</div>
}
else
{
@RenderEquityCurve(vm)
}
</article>
</section>
<hr class="m-rule--double" />
@* ---------- Trade trace ---------- *@
<section class="m-backtest__section m-rise m-rise-5" data-test="backtest-trace-section">
<header class="m-backtest__section-head">
<span class="m-kicker">@L["Backtest.Section.Trace"]</span>
<span class="m-backtest__section-count m-mono">@vm.Trace.Count</span>
</header>
<div class="m-backtest__table-wrap">
<table class="m-backtest__table" data-test="backtest-trace-table">
<thead>
<tr>
<th scope="col">@L["Backtest.Column.DetectedAt"]</th>
<th scope="col">@L["Backtest.Column.Match"]</th>
<th scope="col" style="text-align: right;">@L["Backtest.Column.Score"]</th>
<th scope="col">@L["Backtest.Column.Pick"]</th>
<th scope="col" style="text-align: right;">@L["Backtest.Column.Rate"]</th>
<th scope="col" style="text-align: right;">@L["Backtest.Column.Stake"]</th>
<th scope="col" style="text-align: right;">@L["Backtest.Column.Payout"]</th>
<th scope="col" style="text-align: right;">@L["Backtest.Column.Bankroll"]</th>
<th scope="col">@L["Backtest.Column.Outcome"]</th>
<th scope="col"></th>
</tr>
</thead>
<tbody>
@foreach (var row in vm.Trace)
{
var local = row;
var trace = local.Trace;
<tr class="m-backtest__row m-backtest__row--@(trace.IsWin ? "win" : "loss")"
data-test="backtest-trace-row"
data-anomaly-id="@trace.AnomalyId">
<td class="m-mono">@trace.DetectedAt.ToLocalTime().ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture)</td>
<td style="font-weight: 500;">@local.EventTitle</td>
<td class="m-mono" style="text-align: right; font-weight: 600;">@trace.Score.ToString("0.00", CultureInfo.InvariantCulture)</td>
<td style="font-weight: 600;">@SideLabel(trace.PostFlipFavourite)</td>
<td class="m-mono" style="text-align: right;">@trace.TakenRate.ToString("0.00", CultureInfo.InvariantCulture)</td>
<td class="m-mono" style="text-align: right;">@trace.Stake.ToString("0.00", CultureInfo.InvariantCulture)</td>
<td class="m-mono m-backtest__payout m-backtest__payout--@(trace.IsWin ? "win" : "loss")" style="text-align: right;">
@trace.Payout.ToString("0.00", CultureInfo.InvariantCulture)
</td>
<td class="m-mono" style="text-align: right; font-weight: 600;">@trace.BankrollAfter.ToString("0.00", CultureInfo.InvariantCulture)</td>
<td>
<span class="m-backtest__verdict m-backtest__verdict--@(trace.IsWin ? "win" : "loss")">
@(trace.IsWin ? L["Backtest.Outcome.Win"] : L["Backtest.Outcome.Loss"])
</span>
</td>
<td>
<a href="@($"/anomalies/{trace.AnomalyId}")"
class="m-backtest__open"
data-test="backtest-trace-open"
@onclick="@(e => OpenAnomaly(e, trace.AnomalyId))"
@onclick:preventDefault>
@L["Insights.Action.OpenAnomaly"]
<span aria-hidden="true">→</span>
</a>
</td>
</tr>
}
</tbody>
</table>
</div>
</section>
}
}
</section>
<style>
/* ---- Header ---- */
.m-backtest__header {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: var(--m-space-3);
max-width: 880px;
}
.m-backtest__header-text { display: grid; gap: var(--m-space-3); }
/* ---- Sections ---- */
.m-backtest__section { display: grid; gap: var(--m-space-4); }
.m-backtest__section-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var(--m-space-3);
}
.m-backtest__section-count {
font-size: 0.6875rem;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--m-c-ink-soft);
}
/* ---- Form ---- */
.m-backtest__form-card {
display: grid;
gap: var(--m-space-4);
padding: var(--m-space-5);
}
.m-backtest__form-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: var(--m-space-4);
}
.m-backtest__form-field { display: grid; gap: var(--m-space-2); }
.m-backtest__form-label {
font-family: var(--m-font-mono);
font-size: 0.6875rem;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--m-c-ink-soft);
}
.m-backtest__form-hint {
font-size: 0.75rem;
color: var(--m-c-ink-soft);
}
.m-backtest__form-error {
margin: 0;
padding: var(--m-space-3) var(--m-space-4);
border: 1px solid var(--m-c-anomaly);
border-left-width: 3px;
background: rgba(220, 38, 38, 0.06);
color: var(--m-c-anomaly);
font-family: var(--m-font-mono);
font-size: 0.8125rem;
line-height: 1.5;
}
[data-theme="dark"] .m-backtest__form-error {
background: rgba(248, 113, 113, 0.10);
}
.m-backtest__form-actions {
display: flex;
justify-content: flex-end;
gap: var(--m-space-3);
}
.m-backtest__submit {
gap: var(--m-space-2);
padding: 8px 16px;
border-color: var(--m-c-accent);
color: var(--m-c-accent);
font-family: var(--m-font-mono);
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.14em;
}
.m-backtest__submit:not(:disabled):hover {
background: var(--m-c-accent);
color: var(--m-c-paper);
}
.m-backtest__submit:disabled { opacity: 0.6; cursor: progress; }
.m-backtest__submit-glyph {
display: inline-block;
font-size: 0.7rem;
line-height: 1;
}
.m-backtest__submit-glyph.is-spinning { animation: m-backtest-spin 1.1s linear infinite; }
@@keyframes m-backtest-spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
@@media (prefers-reduced-motion: reduce) {
.m-backtest__submit-glyph.is-spinning { animation: none; }
}
/* ---- KPI strip ---- */
.m-backtest__kpis {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: var(--m-space-4);
}
.m-backtest__kpi {
background: var(--m-c-paper);
border: 1px solid var(--m-c-rule);
border-left: 3px solid var(--m-c-rule);
padding: var(--m-space-4) var(--m-space-5);
display: flex;
flex-direction: column;
gap: var(--m-space-2);
position: relative;
}
.m-backtest__kpi--positive { border-left-color: var(--m-c-positive); }
.m-backtest__kpi--negative { border-left-color: var(--m-c-anomaly); }
.m-backtest__kpi--neutral { border-left-color: var(--m-c-accent); }
.m-backtest__kpi--drawdown { border-left-color: var(--m-c-anomaly); }
.m-backtest__kpi-label {
font-family: var(--m-font-mono);
font-size: 0.6875rem;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--m-c-ink-soft);
}
.m-backtest__kpi-value {
font-family: var(--m-font-mono);
font-feature-settings: var(--m-num-feature);
font-size: clamp(1.85rem, 3.4vw, 2.5rem);
font-weight: 500;
line-height: 1;
letter-spacing: -0.02em;
color: var(--m-c-ink);
}
.m-backtest__kpi--positive .m-backtest__kpi-value { color: var(--m-c-positive); }
.m-backtest__kpi--negative .m-backtest__kpi-value { color: var(--m-c-anomaly); }
.m-backtest__kpi--drawdown .m-backtest__kpi-value { color: var(--m-c-anomaly); }
.m-backtest__kpi-sub {
font-family: var(--m-font-mono);
font-feature-settings: var(--m-num-feature);
font-size: 0.8125rem;
color: var(--m-c-anomaly);
letter-spacing: 0.02em;
}
/* ---- Counts row ---- */
.m-backtest__counts {
display: flex;
gap: var(--m-space-3);
flex-wrap: wrap;
align-items: baseline;
padding: var(--m-space-2) 0;
font-size: 0.8125rem;
color: var(--m-c-ink-soft);
font-feature-settings: var(--m-num-feature);
}
.m-backtest__counts strong {
color: var(--m-c-ink);
font-weight: 600;
}
.m-backtest__counts-label {
text-transform: uppercase;
letter-spacing: 0.12em;
font-size: 0.6875rem;
}
/* ---- Equity curve ---- */
.m-backtest__equity {
background: var(--m-c-paper);
border: 1px solid var(--m-c-rule);
padding: var(--m-space-4) var(--m-space-5);
position: relative;
}
.m-backtest__equity-svg {
display: block;
width: 100%;
height: 200px;
}
.m-backtest__equity-baseline {
stroke: var(--m-c-rule);
stroke-width: 1;
stroke-dasharray: 3 4;
fill: none;
}
.m-backtest__equity-path {
fill: none;
stroke-width: 2;
stroke-linejoin: round;
stroke-linecap: round;
}
.m-backtest__equity-path--positive { stroke: var(--m-c-positive); }
.m-backtest__equity-path--negative { stroke: var(--m-c-anomaly); }
.m-backtest__equity-tick {
font-family: var(--m-font-mono);
font-feature-settings: var(--m-num-feature);
font-size: 10px;
fill: var(--m-c-ink-soft);
}
.m-backtest__equity-tick--anchor { fill: var(--m-c-ink); font-weight: 600; }
/* ---- Trace table ---- */
.m-backtest__table-wrap {
background: var(--m-c-paper);
border: 1px solid var(--m-c-rule);
overflow-x: auto;
}
.m-backtest__table {
width: 100%;
border-collapse: collapse;
font-family: var(--m-font-body);
}
.m-backtest__table thead th {
font-family: var(--m-font-mono);
font-size: 0.6875rem;
letter-spacing: 0.14em;
text-transform: uppercase;
text-align: left;
padding: var(--m-space-3) var(--m-space-3);
border-bottom: 1px solid var(--m-c-rule);
color: var(--m-c-ink-soft);
background: var(--m-c-paper-2);
white-space: nowrap;
}
.m-backtest__table tbody td {
padding: var(--m-space-3) var(--m-space-3);
border-bottom: 1px solid var(--m-c-rule);
vertical-align: middle;
font-size: 0.9375rem;
}
.m-backtest__table tbody tr:last-child td { border-bottom: 0; }
.m-backtest__row { transition: background 120ms ease; }
.m-backtest__row:hover { background: var(--m-c-paper-2); }
.m-backtest__row--win { box-shadow: inset 2px 0 0 0 var(--m-c-positive); }
.m-backtest__row--loss { box-shadow: inset 2px 0 0 0 var(--m-c-anomaly); }
@@media (prefers-reduced-motion: reduce) {
.m-backtest__row { transition: none; }
}
.m-backtest__payout { font-feature-settings: var(--m-num-feature); font-weight: 600; }
.m-backtest__payout--win { color: var(--m-c-positive); }
.m-backtest__payout--loss { color: var(--m-c-anomaly); }
.m-backtest__verdict {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 2px 8px;
font-family: var(--m-font-mono);
font-size: 0.6875rem;
letter-spacing: 0.14em;
text-transform: uppercase;
border: 1px solid currentColor;
border-radius: var(--m-radius-xs);
background: rgba(0, 0, 0, 0);
}
.m-backtest__verdict--win {
color: var(--m-c-positive);
background: rgba(21, 128, 61, 0.10);
}
.m-backtest__verdict--loss {
color: var(--m-c-anomaly);
background: rgba(220, 38, 38, 0.10);
}
[data-theme="dark"] .m-backtest__verdict--win {
color: var(--m-c-positive);
background: rgba(34, 197, 94, 0.15);
}
[data-theme="dark"] .m-backtest__verdict--loss {
color: var(--m-c-anomaly);
background: rgba(248, 113, 113, 0.15);
}
.m-backtest__open {
display: inline-flex;
align-items: center;
gap: 6px;
font-family: var(--m-font-mono);
font-size: 0.75rem;
letter-spacing: 0.12em;
text-transform: uppercase;
text-decoration: none;
color: var(--m-c-ink);
border-bottom: 1px solid var(--m-c-accent);
padding-bottom: 1px;
transition: color 120ms ease, border-color 120ms ease;
}
.m-backtest__open:hover {
color: var(--m-c-accent);
border-bottom-color: var(--m-c-ink);
}
/* ---- Empty-state ---- */
.m-list-empty {
display: grid;
place-content: center;
gap: var(--m-space-3);
padding: var(--m-space-7);
text-align: center;
background: var(--m-c-paper);
border: 1px solid var(--m-c-rule);
}
</style>
@code {
private static readonly StakeRule[] _stakeRules =
{ StakeRule.Flat, StakeRule.PercentOfBankroll, StakeRule.Kelly };
private BacktestForm _form = new();
private BacktestVm? _vm;
private bool _running;
private string? _formError;
private CancellationTokenSource? _runCts;
private async Task RunAsync()
{
if (_running) return;
_formError = null;
if (!_form.IsValid(out var err))
{
_formError = err;
StateHasChanged();
return;
}
_runCts?.Cancel();
_runCts = new CancellationTokenSource();
var ct = _runCts.Token;
_running = true;
StateHasChanged();
try
{
var result = await Service.RunAsync(_form, ct);
if (ct.IsCancellationRequested) return;
_vm = result;
}
catch (OperationCanceledException) { /* superseded */ }
catch (ArgumentException ex)
{
_formError = ex.Message;
}
catch (Exception ex)
{
Logger.LogError(ex, "Backtest simulation failed.");
Snackbar.Add(L["Backtest.Error.Generic"].Value, Severity.Error);
}
finally
{
_running = false;
StateHasChanged();
}
}
private void OnStakeRuleChanged(StakeRule next)
{
_form.StakeRule = next;
_formError = null;
}
private void OpenAnomaly(MouseEventArgs e, Guid anomalyId)
{
Nav.NavigateTo("/anomalies/" + anomalyId.ToString());
}
// ---- Equity curve rendering --------------------------------------------
private RenderFragment RenderEquityCurve(BacktestVm vm) => builder =>
{
var points = vm.EquityCurve;
var pointCount = points.Count;
// Y-axis bounds: include starting bankroll + min/max bankroll, 5% padding.
decimal minB = vm.StartingBankroll;
decimal maxB = vm.StartingBankroll;
foreach (var p in points)
{
if (p.Bankroll < minB) minB = p.Bankroll;
if (p.Bankroll > maxB) maxB = p.Bankroll;
}
var rawRange = maxB - minB;
if (rawRange <= 0m) rawRange = Math.Max(1m, Math.Abs(vm.StartingBankroll) * 0.1m);
var pad = rawRange * 0.05m;
var yMin = minB - pad;
var yMax = maxB + pad;
var yRange = yMax - yMin;
if (yRange <= 0m) yRange = 1m;
// SVG canvas (viewBox 0..1000 x 0..200).
const int vbW = 1000;
const int vbH = 200;
const int padL = 56;
const int padR = 16;
const int padT = 12;
const int padB = 22;
var plotW = vbW - padL - padR;
var plotH = vbH - padT - padB;
double XAt(int i)
{
if (pointCount <= 1) return padL + plotW / 2.0;
return padL + (plotW * (double)i) / (pointCount - 1);
}
double YAt(decimal bankroll)
{
var t = (double)((bankroll - yMin) / yRange);
// Flip — SVG y grows downward.
return padT + (1.0 - t) * plotH;
}
// Baseline (StartingBankroll) y.
var baselineY = YAt(vm.StartingBankroll);
// Build polyline points string.
var sb = new System.Text.StringBuilder();
for (var i = 0; i < pointCount; i++)
{
if (i > 0) sb.Append(' ');
sb.Append(XAt(i).ToString("0.##", CultureInfo.InvariantCulture));
sb.Append(',');
sb.Append(YAt(points[i].Bankroll).ToString("0.##", CultureInfo.InvariantCulture));
}
var pathTone = vm.FinalBankroll >= vm.StartingBankroll ? "positive" : "negative";
builder.OpenElement(0, "svg");
builder.AddAttribute(1, "class", "m-backtest__equity-svg");
builder.AddAttribute(2, "viewBox", "0 0 " + vbW.ToString(CultureInfo.InvariantCulture) + " " + vbH.ToString(CultureInfo.InvariantCulture));
builder.AddAttribute(3, "preserveAspectRatio", "none");
builder.AddAttribute(4, "role", "img");
builder.AddAttribute(5, "aria-label", L["Backtest.Section.Equity"].Value);
builder.AddAttribute(6, "data-test", "backtest-equity-svg");
// Baseline (dotted horizontal at starting bankroll).
builder.OpenElement(10, "line");
builder.AddAttribute(11, "class", "m-backtest__equity-baseline");
builder.AddAttribute(12, "x1", padL.ToString(CultureInfo.InvariantCulture));
builder.AddAttribute(13, "y1", baselineY.ToString("0.##", CultureInfo.InvariantCulture));
builder.AddAttribute(14, "x2", (vbW - padR).ToString(CultureInfo.InvariantCulture));
builder.AddAttribute(15, "y2", baselineY.ToString("0.##", CultureInfo.InvariantCulture));
builder.CloseElement();
// Polyline.
builder.OpenElement(20, "polyline");
builder.AddAttribute(21, "class", "m-backtest__equity-path m-backtest__equity-path--" + pathTone);
builder.AddAttribute(22, "points", sb.ToString());
builder.CloseElement();
// Y-axis ticks: yMax (top), starting bankroll (middle), yMin (bottom).
var topLabel = FormatTickValue(yMax);
var midLabel = FormatTickValue(vm.StartingBankroll);
var botLabel = FormatTickValue(yMin);
// Top tick
builder.OpenElement(30, "text");
builder.AddAttribute(31, "class", "m-backtest__equity-tick");
builder.AddAttribute(32, "x", (padL - 6).ToString(CultureInfo.InvariantCulture));
builder.AddAttribute(33, "y", (padT + 8).ToString(CultureInfo.InvariantCulture));
builder.AddAttribute(34, "text-anchor", "end");
builder.AddContent(35, topLabel);
builder.CloseElement();
// Mid tick (starting bankroll anchor)
builder.OpenElement(40, "text");
builder.AddAttribute(41, "class", "m-backtest__equity-tick m-backtest__equity-tick--anchor");
builder.AddAttribute(42, "x", (padL - 6).ToString(CultureInfo.InvariantCulture));
builder.AddAttribute(43, "y", (baselineY + 3).ToString("0.##", CultureInfo.InvariantCulture));
builder.AddAttribute(44, "text-anchor", "end");
builder.AddContent(45, midLabel);
builder.CloseElement();
// Bottom tick
builder.OpenElement(50, "text");
builder.AddAttribute(51, "class", "m-backtest__equity-tick");
builder.AddAttribute(52, "x", (padL - 6).ToString(CultureInfo.InvariantCulture));
builder.AddAttribute(53, "y", (vbH - padB + 12).ToString(CultureInfo.InvariantCulture));
builder.AddAttribute(54, "text-anchor", "end");
builder.AddContent(55, botLabel);
builder.CloseElement();
// Final-value end label (on far right at end point).
if (pointCount > 0)
{
var endY = YAt(points[pointCount - 1].Bankroll);
builder.OpenElement(60, "text");
builder.AddAttribute(61, "class", "m-backtest__equity-tick m-backtest__equity-tick--anchor");
builder.AddAttribute(62, "x", (vbW - padR - 4).ToString(CultureInfo.InvariantCulture));
builder.AddAttribute(63, "y", (endY - 6).ToString("0.##", CultureInfo.InvariantCulture));
builder.AddAttribute(64, "text-anchor", "end");
builder.AddContent(65, FormatTickValue(points[pointCount - 1].Bankroll));
builder.CloseElement();
}
builder.CloseElement(); // svg
};
// ---- Formatting / labels -----------------------------------------------
private string StakeRuleLabel(StakeRule rule) => rule switch
{
StakeRule.Flat => L["Backtest.StakeRule.Flat"],
StakeRule.PercentOfBankroll => L["Backtest.StakeRule.PercentOfBankroll"],
StakeRule.Kelly => L["Backtest.StakeRule.Kelly"],
_ => rule.ToString(),
};
private string SideLabel(Side side) => side switch
{
Side.Side1 => L["Journal.Side.Side1"],
Side.Side2 => L["Journal.Side.Side2"],
Side.Draw => L["Journal.Side.Draw"],
Side.Less => L["Journal.Side.Less"],
Side.More => L["Journal.Side.More"],
_ => side.ToString(),
};
private static string FormatDecimal(decimal value) =>
value.ToString("0.00", CultureInfo.InvariantCulture);
private static string FormatSignedDecimal(decimal value, int betsPlaced)
{
if (betsPlaced == 0) return "—";
var sign = value > 0m ? "+" : (value < 0m ? "-" : "");
var abs = Math.Abs(value);
return sign + abs.ToString("0.00", CultureInfo.InvariantCulture);
}
private static string FormatSignedPercent(decimal? value)
{
if (value is null) return "—";
var v = value.Value;
var sign = v > 0m ? "+" : (v < 0m ? "-" : "");
var abs = Math.Abs(v);
return sign + abs.ToString("0.0", CultureInfo.InvariantCulture) + "%";
}
private static string FormatTickValue(decimal value) =>
value.ToString("0", CultureInfo.InvariantCulture);
private static string BankrollTone(BacktestVm vm)
{
if (vm.BetsPlaced == 0) return "neutral";
if (vm.FinalBankroll > vm.StartingBankroll) return "positive";
if (vm.FinalBankroll < vm.StartingBankroll) return "negative";
return "neutral";
}
private static string ProfitTone(BacktestVm vm)
{
if (vm.BetsPlaced == 0) return "neutral";
if (vm.NetProfit > 0m) return "positive";
if (vm.NetProfit < 0m) return "negative";
return "neutral";
}
private static string RoiTone(decimal? roi) => roi switch
{
null => "neutral",
> 0m => "positive",
< 0m => "negative",
_ => "neutral",
};
public void Dispose()
{
_runCts?.Cancel();
_runCts?.Dispose();
}
}