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:
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user