Files
maraphon-app/src/Marathon.UI/Pages/Anomalies/StrategyCompare.razor
T
alexei.dolgolyov 6e12dd73c3 feat(backtest): strategy comparison (head-to-head)
New /anomalies/compare page runs every saved strategy preset over the same
window and ranks them by ROI — bets, W–L, hit-rate, net, and max drawdown side
by side, with the best ROI flagged. Auto-runs on load with an optional date-range refine.

- CompareStrategiesUseCase fans RunBacktestUseCase over saved presets (re-loads the
  anomaly set per preset — fine for the handful a user keeps; stays bug-for-bug
  identical to a single backtest run).
- StrategyComparisonService.BuildVm (pure) computes per-row hit-rate + a single
  best-by-ROI flag; nav entry + en/ru resx.
- 6 tests: use-case fan-out + BuildVm best/tie/no-bets/hit-rate.
2026-05-29 11:32:01 +03:00

283 lines
12 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
@*
StrategyCompare — head-to-head backtest of every saved strategy preset.
Runs each saved preset (from the Backtest page) over the same window and tables
their ROI / hit-rate / net / drawdown side by side, flagging the best ROI. Same
editorial-quant tone as Backtest.
*@
@page "/anomalies/compare"
@using System.Globalization
@implements IDisposable
@inject IStringLocalizer<SharedResource> L
@inject IStrategyComparisonService Service
@inject NavigationManager Nav
@inject ILogger<StrategyCompare> Logger
<PageTitle>@L["App.Title"] · @L["Nav.Compare"]</PageTitle>
<section class="m-shell">
<header class="m-rise m-rise-1" style="display: grid; gap: var(--m-space-3); max-width: 880px;">
<span class="m-kicker">@L["Compare.Kicker"]</span>
<h1 class="m-display" style="font-size: clamp(2rem, 4vw, 3rem);">@L["Compare.Title"]</h1>
<p style="color: var(--m-c-ink-soft); max-width: 64ch;">@L["Compare.Lede"]</p>
</header>
<hr class="m-rule" />
<article class="m-card m-card--accented m-cmp__form" data-test="compare-form">
<div class="m-cmp__form-grid">
<div class="m-cmp__form-field">
<label class="m-cmp__form-label">@L["Backtest.Field.From"]</label>
<MudDatePicker @bind-Date="_from" DateFormat="yyyy-MM-dd" Clearable="true" Variant="Variant.Outlined" data-test="compare-from" />
</div>
<div class="m-cmp__form-field">
<label class="m-cmp__form-label">@L["Backtest.Field.To"]</label>
<MudDatePicker @bind-Date="_to" DateFormat="yyyy-MM-dd" Clearable="true" Variant="Variant.Outlined" data-test="compare-to" />
<span class="m-cmp__form-hint">@L["Backtest.Field.DateRange.Hint"]</span>
</div>
<div class="m-cmp__form-actions">
<button type="button" class="m-chip m-cmp__run" @onclick="RunAsync" disabled="@_running" data-test="compare-run">
<span class="m-cmp__run-glyph @(_running ? "is-spinning" : null)" aria-hidden="true">▶</span>
<span>@(_running ? L["Backtest.Action.Running"] : L["Compare.Action.Run"])</span>
</button>
</div>
</div>
@if (!string.IsNullOrEmpty(_error))
{
<p class="m-cmp__error" data-test="compare-error">@_error</p>
}
</article>
@if (_loading)
{
<div class="m-list-empty">
<MudProgressCircular Indeterminate="true" Size="Size.Small" />
<span class="m-mono">@L["Common.Loading"]</span>
</div>
}
else if (_vm is null || _vm.Rows.Count == 0)
{
<div class="m-list-empty m-rise m-rise-2" data-test="compare-empty">
<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["Compare.Empty"]
</p>
<MudButton Variant="Variant.Outlined"
StartIcon="@Icons.Material.Outlined.QueryStats"
OnClick='() => Nav.NavigateTo("/anomalies/backtest")'
data-test="compare-empty-cta">
@L["Nav.Backtest"]
</MudButton>
</div>
}
else
{
<hr class="m-rule--double" />
<section class="m-cmp__section m-rise m-rise-2" data-test="compare-result">
<header class="m-cmp__section-head">
<span class="m-kicker">@L["Compare.Section.Results"]</span>
<span class="m-cmp__section-count m-mono">@_vm.Rows.Count</span>
</header>
<div class="m-cmp__table-wrap">
<table class="m-cmp__table" data-test="compare-table">
<thead>
<tr>
<th scope="col">@L["Compare.Column.Strategy"]</th>
<th scope="col" style="text-align: right;">@L["Compare.Column.Bets"]</th>
<th scope="col" style="text-align: right;">@L["Compare.Column.WinLoss"]</th>
<th scope="col" style="text-align: right;">@L["Compare.Column.HitRate"]</th>
<th scope="col" style="text-align: right;">@L["Compare.Column.Net"]</th>
<th scope="col" style="text-align: right;">@L["Compare.Column.Roi"]</th>
<th scope="col" style="text-align: right;">@L["Compare.Column.MaxDrawdown"]</th>
</tr>
</thead>
<tbody>
@foreach (var row in _vm.Rows)
{
<tr class="m-cmp__row @(row.IsBest ? "m-cmp__row--best" : null)" data-test="compare-row" data-strategy-id="@row.StrategyId">
<td style="font-weight: 600;">
@row.Name
@if (row.IsBest)
{
<span class="m-cmp__best-badge" data-test="compare-best">@L["Compare.Best"]</span>
}
</td>
<td class="m-mono" style="text-align: right;">@row.BetsPlaced</td>
<td class="m-mono" style="text-align: right;">
<span style="color: var(--m-c-positive);">@row.Wins</span><span style="color: var(--m-c-anomaly);">@row.Losses</span>
</td>
<td class="m-mono" style="text-align: right;">@FormatPercent(row.HitRatePercent)</td>
<td class="m-mono m-cmp__num m-cmp__num--@Tone(row.NetProfit, row.BetsPlaced)" style="text-align: right;">@FormatSignedDecimal(row.NetProfit, row.BetsPlaced)</td>
<td class="m-mono m-cmp__num m-cmp__num--@RoiTone(row.RoiPercent)" style="text-align: right;">@FormatSignedPercent(row.RoiPercent)</td>
<td class="m-mono" style="text-align: right;">@(row.MaxDrawdown == 0m ? "—" : row.MaxDrawdown.ToString("0.00", CultureInfo.InvariantCulture))</td>
</tr>
}
</tbody>
</table>
</div>
</section>
}
</section>
<style>
.m-cmp__form { display: grid; gap: var(--m-space-4); padding: var(--m-space-5); }
.m-cmp__form-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: var(--m-space-4);
align-items: end;
}
.m-cmp__form-field { display: grid; gap: var(--m-space-2); }
.m-cmp__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-cmp__form-hint { font-size: 0.75rem; color: var(--m-c-ink-soft); }
.m-cmp__form-actions { display: flex; align-items: end; }
.m-cmp__run {
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-cmp__run:not(:disabled):hover { background: var(--m-c-accent); color: var(--m-c-paper); }
.m-cmp__run:disabled { opacity: 0.6; cursor: progress; }
.m-cmp__run-glyph { display: inline-block; font-size: 0.7rem; line-height: 1; }
.m-cmp__run-glyph.is-spinning { animation: m-cmp-spin 1.1s linear infinite; }
@@keyframes m-cmp-spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
@@media (prefers-reduced-motion: reduce) { .m-cmp__run-glyph.is-spinning { animation: none; } }
.m-cmp__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;
}
.m-cmp__section { display: grid; gap: var(--m-space-4); }
.m-cmp__section-head { display: flex; align-items: baseline; justify-content: space-between; gap: var(--m-space-3); }
.m-cmp__section-count { font-size: 0.6875rem; letter-spacing: 0.16em; text-transform: uppercase; color: var(--m-c-ink-soft); }
.m-cmp__table-wrap { background: var(--m-c-paper); border: 1px solid var(--m-c-rule); overflow-x: auto; }
.m-cmp__table { width: 100%; border-collapse: collapse; font-family: var(--m-font-body); }
.m-cmp__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); border-bottom: 1px solid var(--m-c-rule);
color: var(--m-c-ink-soft); background: var(--m-c-paper-2); white-space: nowrap;
}
.m-cmp__table tbody td { padding: var(--m-space-3); border-bottom: 1px solid var(--m-c-rule); vertical-align: middle; font-size: 0.9375rem; }
.m-cmp__table tbody tr:last-child td { border-bottom: 0; }
.m-cmp__row--best { background: rgba(21, 128, 61, 0.06); box-shadow: inset 3px 0 0 0 var(--m-c-positive); }
[data-theme="dark"] .m-cmp__row--best { background: rgba(34, 197, 94, 0.10); }
.m-cmp__num { font-feature-settings: var(--m-num-feature); font-weight: 600; }
.m-cmp__num--positive { color: var(--m-c-positive); }
.m-cmp__num--negative { color: var(--m-c-anomaly); }
.m-cmp__best-badge {
margin-left: 8px; padding: 1px 6px; font-family: var(--m-font-mono); font-size: 0.625rem;
letter-spacing: 0.12em; text-transform: uppercase; color: var(--m-c-positive);
border: 1px solid var(--m-c-positive); border-radius: var(--m-radius-xs);
}
.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 DateTime? _from;
private DateTime? _to;
private StrategyComparisonVm? _vm;
private bool _loading = true;
private bool _running;
private string? _error;
private CancellationTokenSource? _cts;
protected override async Task OnInitializedAsync()
{
await RunCoreAsync();
_loading = false;
}
private async Task RunAsync()
{
if (_running) return;
await RunCoreAsync();
}
private async Task RunCoreAsync()
{
_error = null;
if (_from.HasValue != _to.HasValue)
{
_error = L["Backtest.Field.DateRange.Hint"].Value;
return;
}
_cts?.Cancel();
_cts?.Dispose();
_cts = new CancellationTokenSource();
var ct = _cts.Token;
_running = true;
StateHasChanged();
try
{
var vm = await Service.CompareAsync(_from, _to, ct);
if (!ct.IsCancellationRequested) _vm = vm;
}
catch (OperationCanceledException) { /* superseded */ }
catch (Exception ex)
{
Logger.LogError(ex, "Strategy comparison failed.");
_error = L["Backtest.Error.Generic"].Value;
}
finally
{
_running = false;
StateHasChanged();
}
}
private static string FormatPercent(decimal? v) =>
v is null ? "—" : v.Value.ToString("0.0", CultureInfo.InvariantCulture) + "%";
private static string FormatSignedDecimal(decimal value, int betsPlaced)
{
if (betsPlaced == 0) return "—";
var sign = value > 0m ? "+" : (value < 0m ? "-" : "");
return sign + Math.Abs(value).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 ? "-" : "");
return sign + Math.Abs(v).ToString("0.0", CultureInfo.InvariantCulture) + "%";
}
private static string Tone(decimal value, int betsPlaced)
{
if (betsPlaced == 0) return "neutral";
if (value > 0m) return "positive";
if (value < 0m) return "negative";
return "neutral";
}
private static string RoiTone(decimal? roi) => roi switch
{
null => "neutral",
> 0m => "positive",
< 0m => "negative",
_ => "neutral",
};
public void Dispose()
{
_cts?.Cancel();
_cts?.Dispose();
}
}