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.
This commit is contained in:
@@ -0,0 +1,282 @@
|
||||
@*
|
||||
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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user