6e12dd73c3
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.
283 lines
12 KiB
Plaintext
283 lines
12 KiB
Plaintext
@*
|
||
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();
|
||
}
|
||
}
|