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
+4
View File
@@ -47,6 +47,10 @@
<MudIcon Icon="@Icons.Material.Outlined.Receipt" Size="Size.Small" />
<span>@L["Nav.MyBets"]</span>
</NavLink>
<NavLink class="m-nav__link" href="anomalies/backtest">
<MudIcon Icon="@Icons.Material.Outlined.QueryStats" Size="Size.Small" />
<span>@L["Nav.Backtest"]</span>
</NavLink>
<div class="m-nav__group" style="margin-top: var(--m-space-5);">@L["Nav.Section.System"]</div>
<NavLink class="m-nav__link" href="settings">
@@ -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();
}
}
@@ -410,4 +410,52 @@
<data name="Journal.Resolve.None"><value>No pending bets needed grading.</value></data>
<data name="Journal.Resolve.Done"><value>Graded {0} pending bet(s).</value></data>
<data name="Journal.Confirm.Delete"><value>Delete this bet permanently?</value></data>
<data name="Nav.Backtest"><value>Backtest</value></data>
<data name="Backtest.Kicker"><value>Simulator</value></data>
<data name="Backtest.Title"><value>Replay the detector against history</value></data>
<data name="Backtest.Lede"><value>Run a hypothetical strategy over every anomaly the detector has flagged. Choose a confidence threshold and a staking rule — the simulator settles every bet against the actual event result, compounds bankroll, and reports the headline numbers you need to judge edge.</value></data>
<data name="Backtest.Section.Strategy"><value>Strategy</value></data>
<data name="Backtest.Section.Headline"><value>Result</value></data>
<data name="Backtest.Section.Equity"><value>Equity curve</value></data>
<data name="Backtest.Section.Trace"><value>Trade trace</value></data>
<data name="Backtest.Field.Bankroll"><value>Starting bankroll</value></data>
<data name="Backtest.Field.MinScore"><value>Min anomaly score</value></data>
<data name="Backtest.Field.MinScore.Hint"><value>Only bet anomalies at or above this confidence.</value></data>
<data name="Backtest.Field.StakeRule"><value>Staking rule</value></data>
<data name="Backtest.Field.FlatStake"><value>Flat stake</value></data>
<data name="Backtest.Field.PercentOfBankroll"><value>Percent of bankroll</value></data>
<data name="Backtest.Field.KellyFraction"><value>Kelly fraction</value></data>
<data name="Backtest.Field.KellyFraction.Hint"><value>0.25 (quarter-Kelly) is the conservative default.</value></data>
<data name="Backtest.StakeRule.Flat"><value>Flat</value></data>
<data name="Backtest.StakeRule.PercentOfBankroll"><value>% of bankroll</value></data>
<data name="Backtest.StakeRule.Kelly"><value>Kelly</value></data>
<data name="Backtest.Action.Run"><value>Run simulation</value></data>
<data name="Backtest.Action.Running"><value>Simulating…</value></data>
<data name="Backtest.Stat.FinalBankroll"><value>Final bankroll</value></data>
<data name="Backtest.Stat.NetProfit"><value>Net profit</value></data>
<data name="Backtest.Stat.Roi"><value>ROI</value></data>
<data name="Backtest.Stat.MaxDrawdown"><value>Max drawdown</value></data>
<data name="Backtest.Stat.BetsPlaced"><value>Bets placed</value></data>
<data name="Backtest.Stat.Wins"><value>Wins</value></data>
<data name="Backtest.Stat.Losses"><value>Losses</value></data>
<data name="Backtest.Stat.Skipped"><value>Skipped</value></data>
<data name="Backtest.Stat.MaxWinStreak"><value>Max win streak</value></data>
<data name="Backtest.Stat.MaxLossStreak"><value>Max loss streak</value></data>
<data name="Backtest.Stat.TotalStaked"><value>Total staked</value></data>
<data name="Backtest.Stat.TotalReturned"><value>Total returned</value></data>
<data name="Backtest.Column.DetectedAt"><value>Detected</value></data>
<data name="Backtest.Column.Match"><value>Match</value></data>
<data name="Backtest.Column.Score"><value>Score</value></data>
<data name="Backtest.Column.Pick"><value>Pick</value></data>
<data name="Backtest.Column.Rate"><value>Rate</value></data>
<data name="Backtest.Column.Stake"><value>Stake</value></data>
<data name="Backtest.Column.Payout"><value>Payout</value></data>
<data name="Backtest.Column.Bankroll"><value>Bankroll</value></data>
<data name="Backtest.Column.Outcome"><value>Outcome</value></data>
<data name="Backtest.Outcome.Win"><value>Win</value></data>
<data name="Backtest.Outcome.Loss"><value>Loss</value></data>
<data name="Backtest.Empty.NoData"><value>No graded anomalies to simulate yet. Run the results loader so the detector has outcomes to replay against.</value></data>
<data name="Backtest.Empty.NoBetsPlaced"><value>The strategy placed zero bets — try lowering the score threshold, or switch staking rule.</value></data>
<data name="Backtest.Error.Generic"><value>Simulation failed — check the form values and try again.</value></data>
</root>
@@ -423,4 +423,52 @@
<data name="Journal.Resolve.None"><value>Ожидающих ставок к расчёту нет.</value></data>
<data name="Journal.Resolve.Done"><value>Рассчитано ожидающих: {0}.</value></data>
<data name="Journal.Confirm.Delete"><value>Удалить эту ставку безвозвратно?</value></data>
<data name="Nav.Backtest"><value>Бэктест</value></data>
<data name="Backtest.Kicker"><value>Симулятор</value></data>
<data name="Backtest.Title"><value>Прогон детектора по истории</value></data>
<data name="Backtest.Lede"><value>Запустите гипотетическую стратегию на всех зафиксированных аномалиях. Выберите порог уверенности и правило стейкинга — симулятор разыграет каждую ставку против реального исхода, нарастит банк и покажет ключевые метрики для оценки преимущества.</value></data>
<data name="Backtest.Section.Strategy"><value>Стратегия</value></data>
<data name="Backtest.Section.Headline"><value>Результат</value></data>
<data name="Backtest.Section.Equity"><value>Кривая банка</value></data>
<data name="Backtest.Section.Trace"><value>Хронология ставок</value></data>
<data name="Backtest.Field.Bankroll"><value>Стартовый банк</value></data>
<data name="Backtest.Field.MinScore"><value>Мин. score аномалии</value></data>
<data name="Backtest.Field.MinScore.Hint"><value>Ставим только при уверенности не ниже этого порога.</value></data>
<data name="Backtest.Field.StakeRule"><value>Правило стейкинга</value></data>
<data name="Backtest.Field.FlatStake"><value>Фикс. ставка</value></data>
<data name="Backtest.Field.PercentOfBankroll"><value>Процент от банка</value></data>
<data name="Backtest.Field.KellyFraction"><value>Доля Келли</value></data>
<data name="Backtest.Field.KellyFraction.Hint"><value>0,25 (четверть-Келли) — консервативный дефолт.</value></data>
<data name="Backtest.StakeRule.Flat"><value>Фиксированная</value></data>
<data name="Backtest.StakeRule.PercentOfBankroll"><value>% от банка</value></data>
<data name="Backtest.StakeRule.Kelly"><value>Келли</value></data>
<data name="Backtest.Action.Run"><value>Запустить</value></data>
<data name="Backtest.Action.Running"><value>Симуляция…</value></data>
<data name="Backtest.Stat.FinalBankroll"><value>Итоговый банк</value></data>
<data name="Backtest.Stat.NetProfit"><value>Чистая прибыль</value></data>
<data name="Backtest.Stat.Roi"><value>ROI</value></data>
<data name="Backtest.Stat.MaxDrawdown"><value>Макс. просадка</value></data>
<data name="Backtest.Stat.BetsPlaced"><value>Поставлено</value></data>
<data name="Backtest.Stat.Wins"><value>Победы</value></data>
<data name="Backtest.Stat.Losses"><value>Поражения</value></data>
<data name="Backtest.Stat.Skipped"><value>Пропущено</value></data>
<data name="Backtest.Stat.MaxWinStreak"><value>Макс. серия побед</value></data>
<data name="Backtest.Stat.MaxLossStreak"><value>Макс. серия пораж.</value></data>
<data name="Backtest.Stat.TotalStaked"><value>Всего поставлено</value></data>
<data name="Backtest.Stat.TotalReturned"><value>Всего возвращено</value></data>
<data name="Backtest.Column.DetectedAt"><value>Замечено</value></data>
<data name="Backtest.Column.Match"><value>Матч</value></data>
<data name="Backtest.Column.Score"><value>Score</value></data>
<data name="Backtest.Column.Pick"><value>Выбор</value></data>
<data name="Backtest.Column.Rate"><value>Кэф</value></data>
<data name="Backtest.Column.Stake"><value>Ставка</value></data>
<data name="Backtest.Column.Payout"><value>Выплата</value></data>
<data name="Backtest.Column.Bankroll"><value>Банк</value></data>
<data name="Backtest.Column.Outcome"><value>Исход</value></data>
<data name="Backtest.Outcome.Win"><value>Победа</value></data>
<data name="Backtest.Outcome.Loss"><value>Проигрыш</value></data>
<data name="Backtest.Empty.NoData"><value>Аномалий с результатом ещё нет. Запустите загрузчик результатов, чтобы симулятору было на чём прогоняться.</value></data>
<data name="Backtest.Empty.NoBetsPlaced"><value>Стратегия не сделала ни одной ставки — снизьте порог score или поменяйте правило стейкинга.</value></data>
<data name="Backtest.Error.Generic"><value>Симуляция упала — проверьте параметры формы и повторите.</value></data>
</root>
@@ -0,0 +1,61 @@
using Marathon.Application.UseCases;
namespace Marathon.UI.Services;
/// <summary>
/// Page-facing implementation of <see cref="IBacktestService"/>. The use case
/// hands back per-event titles inside the result so the service does no
/// repository I/O of its own.
/// </summary>
public sealed class BacktestService : IBacktestService
{
private readonly RunBacktestUseCase _useCase;
public BacktestService(RunBacktestUseCase useCase)
{
_useCase = useCase ?? throw new ArgumentNullException(nameof(useCase));
}
public async Task<BacktestVm> RunAsync(BacktestForm form, CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(form);
if (!form.IsValid(out var err))
throw new ArgumentException(err ?? "Invalid form.", nameof(form));
var result = await _useCase.ExecuteAsync(form.ToStrategy(), ct).ConfigureAwait(false);
var rows = result.Trace
.Select(t => new BacktestTraceRow(
Trace: t,
EventTitle: result.EventTitles.TryGetValue(t.EventId, out var title)
? title
: t.EventId.Value))
.ToList();
var curve = result.Trace
.Select(t => new EquityPoint(t.DetectedAt, t.BankrollAfter))
.ToList();
return new BacktestVm(
StartingBankroll: result.StartingBankroll,
FinalBankroll: result.FinalBankroll,
NetProfit: result.NetProfit,
RoiPercent: result.RoiPercent,
TotalStaked: result.TotalStaked,
TotalReturned: result.TotalReturned,
MaxDrawdown: result.MaxDrawdown,
MaxDrawdownPercent: result.MaxDrawdownPercent,
BetsPlaced: result.BetsPlaced,
Wins: result.Wins,
Losses: result.Losses,
Skipped: result.Skipped,
SkippedByThreshold: result.SkippedByThreshold,
SkippedByDataQuality: result.SkippedByDataQuality,
SkippedByBankroll: result.SkippedByBankroll,
MaxWinStreak: result.MaxWinStreak,
MaxLossStreak: result.MaxLossStreak,
Trace: rows,
EquityCurve: curve);
}
}
@@ -0,0 +1,89 @@
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);
@@ -0,0 +1,13 @@
namespace Marathon.UI.Services;
/// <summary>
/// Browsing facade in front of <see cref="Marathon.Application.UseCases.RunBacktestUseCase"/>.
/// The Backtest page binds to this — view-model shaping and event-title
/// joining live here so the page stays declarative.
/// </summary>
public interface IBacktestService
{
/// <summary>Validates the form, runs the simulator, projects for the UI.</summary>
/// <exception cref="ArgumentException">Form fails its own validation.</exception>
Task<BacktestVm> RunAsync(BacktestForm form, CancellationToken ct);
}
@@ -60,6 +60,7 @@ public static class UiServicesExtensions
services.AddScoped<IAnomalyInsightsService, AnomalyInsightsService>();
services.AddScoped<IResultsBrowsingService, ResultsBrowsingService>();
services.AddScoped<IBetJournalService, BetJournalService>();
services.AddScoped<IBacktestService, BacktestService>();
// Settings writer — file path is host-resolved.
services.AddSingleton<ISettingsWriter>(_ => new JsonSettingsWriter(settingsLocalPath));