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:
@@ -41,6 +41,7 @@ public static class ApplicationModule
|
||||
services.AddScoped<RunBacktestUseCase>();
|
||||
services.AddScoped<SaveStrategyUseCase>();
|
||||
services.AddScoped<DeleteStrategyUseCase>();
|
||||
services.AddScoped<CompareStrategiesUseCase>();
|
||||
|
||||
services.AddScoped<OpenPaperBetsUseCase>();
|
||||
services.AddScoped<SettlePaperBetsUseCase>();
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
using Marathon.Application.Abstractions;
|
||||
using Marathon.Application.Storage;
|
||||
using Marathon.Domain.Backtesting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Marathon.Application.UseCases;
|
||||
|
||||
/// <summary>One saved strategy preset paired with its backtest result over a shared window.</summary>
|
||||
public sealed record StrategyComparison(Guid StrategyId, string Name, BacktestResult Result);
|
||||
|
||||
/// <summary>
|
||||
/// Runs every saved strategy preset over the same anomaly window and returns their
|
||||
/// backtest results side by side, so the user can see which staking configuration wins.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Delegates to <see cref="RunBacktestUseCase"/> once per preset — the anomaly set is
|
||||
/// re-loaded per run, which is fine for the handful of presets a user keeps. Keeping the
|
||||
/// composition at the use-case level (rather than re-implementing candidate loading) means
|
||||
/// the comparison stays bug-for-bug identical to a single backtest run.
|
||||
/// </remarks>
|
||||
public sealed class CompareStrategiesUseCase
|
||||
{
|
||||
private readonly ISavedStrategyRepository _strategies;
|
||||
private readonly RunBacktestUseCase _backtest;
|
||||
private readonly ILogger<CompareStrategiesUseCase> _logger;
|
||||
|
||||
public CompareStrategiesUseCase(
|
||||
ISavedStrategyRepository strategies,
|
||||
RunBacktestUseCase backtest,
|
||||
ILogger<CompareStrategiesUseCase> logger)
|
||||
{
|
||||
_strategies = strategies ?? throw new ArgumentNullException(nameof(strategies));
|
||||
_backtest = backtest ?? throw new ArgumentNullException(nameof(backtest));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Backtests each saved preset over <paramref name="dateRange"/> (null = all graded
|
||||
/// anomalies). Returns one row per preset in saved (name-ascending) order; empty when
|
||||
/// the user has saved no strategies.
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<StrategyComparison>> ExecuteAsync(
|
||||
DateRange? dateRange, CancellationToken ct = default)
|
||||
{
|
||||
var presets = await _strategies.ListAsync(ct).ConfigureAwait(false);
|
||||
if (presets.Count == 0)
|
||||
return Array.Empty<StrategyComparison>();
|
||||
|
||||
var rows = new List<StrategyComparison>(presets.Count);
|
||||
foreach (var preset in presets)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
var result = await _backtest.ExecuteAsync(preset.Strategy, dateRange, ct).ConfigureAwait(false);
|
||||
rows.Add(new StrategyComparison(preset.Id, preset.Name, result));
|
||||
}
|
||||
|
||||
_logger.LogInformation("CompareStrategiesUseCase: compared {Count} preset(s)", rows.Count);
|
||||
return rows;
|
||||
}
|
||||
}
|
||||
@@ -51,6 +51,10 @@
|
||||
<MudIcon Icon="@Icons.Material.Outlined.QueryStats" Size="Size.Small" />
|
||||
<span>@L["Nav.Backtest"]</span>
|
||||
</NavLink>
|
||||
<NavLink class="m-nav__link" href="anomalies/compare">
|
||||
<MudIcon Icon="@Icons.Material.Outlined.Leaderboard" Size="Size.Small" />
|
||||
<span>@L["Nav.Compare"]</span>
|
||||
</NavLink>
|
||||
<NavLink class="m-nav__link" href="paper-trading">
|
||||
<MudIcon Icon="@Icons.Material.Outlined.Science" Size="Size.Small" />
|
||||
<span>@L["Nav.PaperTrading"]</span>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -469,6 +469,7 @@
|
||||
<data name="Journal.Confirm.Delete"><value>Delete this bet permanently?</value></data>
|
||||
|
||||
<data name="Nav.Backtest"><value>Backtest</value></data>
|
||||
<data name="Nav.Compare"><value>Compare</value></data>
|
||||
<data name="Nav.PaperTrading"><value>Forward-test</value></data>
|
||||
<data name="Backtest.Kicker"><value>Simulator</value></data>
|
||||
<data name="Backtest.Title"><value>Replay the detector against history</value></data>
|
||||
@@ -553,4 +554,18 @@
|
||||
<data name="Paper.Outcome.Won"><value>Won</value></data>
|
||||
<data name="Paper.Outcome.Lost"><value>Lost</value></data>
|
||||
<data name="Paper.Outcome.Void"><value>Void</value></data>
|
||||
<data name="Compare.Kicker"><value>Strategy lab</value></data>
|
||||
<data name="Compare.Title"><value>Compare strategies</value></data>
|
||||
<data name="Compare.Lede"><value>Run every saved preset over the same window and rank them by ROI — find the staking configuration that actually holds up. Save presets on the Backtest page first.</value></data>
|
||||
<data name="Compare.Action.Run"><value>Compare</value></data>
|
||||
<data name="Compare.Empty"><value>No saved strategies to compare. Save one or more presets on the Backtest page, then return here to rank them head-to-head.</value></data>
|
||||
<data name="Compare.Section.Results"><value>Head to head</value></data>
|
||||
<data name="Compare.Best"><value>Best</value></data>
|
||||
<data name="Compare.Column.Strategy"><value>Strategy</value></data>
|
||||
<data name="Compare.Column.Bets"><value>Bets</value></data>
|
||||
<data name="Compare.Column.WinLoss"><value>W–L</value></data>
|
||||
<data name="Compare.Column.HitRate"><value>Hit %</value></data>
|
||||
<data name="Compare.Column.Net"><value>Net</value></data>
|
||||
<data name="Compare.Column.Roi"><value>ROI</value></data>
|
||||
<data name="Compare.Column.MaxDrawdown"><value>Max DD</value></data>
|
||||
</root>
|
||||
|
||||
@@ -482,6 +482,7 @@
|
||||
<data name="Journal.Confirm.Delete"><value>Удалить эту ставку безвозвратно?</value></data>
|
||||
|
||||
<data name="Nav.Backtest"><value>Бэктест</value></data>
|
||||
<data name="Nav.Compare"><value>Сравнить</value></data>
|
||||
<data name="Nav.PaperTrading"><value>Форвард-тест</value></data>
|
||||
<data name="Backtest.Kicker"><value>Симулятор</value></data>
|
||||
<data name="Backtest.Title"><value>Прогон детектора по истории</value></data>
|
||||
@@ -566,4 +567,18 @@
|
||||
<data name="Paper.Outcome.Won"><value>Выигрыш</value></data>
|
||||
<data name="Paper.Outcome.Lost"><value>Проигрыш</value></data>
|
||||
<data name="Paper.Outcome.Void"><value>Возврат</value></data>
|
||||
<data name="Compare.Kicker"><value>Лаборатория стратегий</value></data>
|
||||
<data name="Compare.Title"><value>Сравнение стратегий</value></data>
|
||||
<data name="Compare.Lede"><value>Прогоните каждый сохранённый пресет на одном окне и ранжируйте по ROI — найдите стейкинг, который реально работает. Сначала сохраните пресеты на странице бэктеста.</value></data>
|
||||
<data name="Compare.Action.Run"><value>Сравнить</value></data>
|
||||
<data name="Compare.Empty"><value>Нет сохранённых стратегий для сравнения. Сохраните один или несколько пресетов на странице бэктеста, затем вернитесь сюда для сравнения.</value></data>
|
||||
<data name="Compare.Section.Results"><value>Лицом к лицу</value></data>
|
||||
<data name="Compare.Best"><value>Лучшая</value></data>
|
||||
<data name="Compare.Column.Strategy"><value>Стратегия</value></data>
|
||||
<data name="Compare.Column.Bets"><value>Ставки</value></data>
|
||||
<data name="Compare.Column.WinLoss"><value>В–П</value></data>
|
||||
<data name="Compare.Column.HitRate"><value>Попад. %</value></data>
|
||||
<data name="Compare.Column.Net"><value>Чистыми</value></data>
|
||||
<data name="Compare.Column.Roi"><value>ROI</value></data>
|
||||
<data name="Compare.Column.MaxDrawdown"><value>Макс. просадка</value></data>
|
||||
</root>
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace Marathon.UI.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Facade over <see cref="Marathon.Application.UseCases.CompareStrategiesUseCase"/> — runs
|
||||
/// every saved preset over the supplied Moscow-day window and shapes the head-to-head table.
|
||||
/// </summary>
|
||||
public interface IStrategyComparisonService
|
||||
{
|
||||
/// <summary>
|
||||
/// Compares all saved presets over [<paramref name="from"/>..<paramref name="to"/>]
|
||||
/// (both null = all graded anomalies). Returns <see cref="StrategyComparisonVm.Empty"/>
|
||||
/// when no presets are saved.
|
||||
/// </summary>
|
||||
Task<StrategyComparisonVm> CompareAsync(DateTime? from, DateTime? to, CancellationToken ct);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
using Marathon.Application.Storage;
|
||||
using Marathon.Application.UseCases;
|
||||
using Marathon.Domain.ValueObjects;
|
||||
|
||||
namespace Marathon.UI.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Page-facing implementation of <see cref="IStrategyComparisonService"/>. Builds the
|
||||
/// inclusive Moscow-day range, delegates to the use case, computes per-row hit rate, and
|
||||
/// marks the single best preset by ROI (among those that actually placed bets).
|
||||
/// </summary>
|
||||
public sealed class StrategyComparisonService : IStrategyComparisonService
|
||||
{
|
||||
private readonly CompareStrategiesUseCase _useCase;
|
||||
|
||||
public StrategyComparisonService(CompareStrategiesUseCase useCase) =>
|
||||
_useCase = useCase ?? throw new ArgumentNullException(nameof(useCase));
|
||||
|
||||
public async Task<StrategyComparisonVm> CompareAsync(DateTime? from, DateTime? to, CancellationToken ct)
|
||||
{
|
||||
var comparisons = await _useCase.ExecuteAsync(ToDateRange(from, to), ct).ConfigureAwait(false);
|
||||
return BuildVm(comparisons);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pure projection of the use case's rows into the view model: per-row hit rate plus a
|
||||
/// single "best" flag on the highest-ROI preset that actually placed bets. Extracted for
|
||||
/// unit testing without the (sealed) use case.
|
||||
/// </summary>
|
||||
public static StrategyComparisonVm BuildVm(IReadOnlyList<StrategyComparison> comparisons)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(comparisons);
|
||||
if (comparisons.Count == 0)
|
||||
return StrategyComparisonVm.Empty;
|
||||
|
||||
// Best = highest ROI among presets that actually placed bets.
|
||||
var bestRoi = comparisons
|
||||
.Where(c => c.Result.BetsPlaced > 0 && c.Result.RoiPercent is not null)
|
||||
.Select(c => c.Result.RoiPercent!.Value)
|
||||
.DefaultIfEmpty(decimal.MinValue)
|
||||
.Max();
|
||||
var bestAssigned = false;
|
||||
|
||||
var rows = new List<StrategyComparisonRowVm>(comparisons.Count);
|
||||
foreach (var c in comparisons)
|
||||
{
|
||||
var r = c.Result;
|
||||
var settled = r.Wins + r.Losses;
|
||||
decimal? hitRate = settled > 0
|
||||
? Math.Round((decimal)r.Wins / settled * 100m, 1, MidpointRounding.AwayFromZero)
|
||||
: null;
|
||||
|
||||
// Single winner even on ties — the first matching row claims "best".
|
||||
var isBest = !bestAssigned
|
||||
&& r.BetsPlaced > 0
|
||||
&& r.RoiPercent is { } roi
|
||||
&& roi == bestRoi;
|
||||
if (isBest) bestAssigned = true;
|
||||
|
||||
rows.Add(new StrategyComparisonRowVm(
|
||||
StrategyId: c.StrategyId,
|
||||
Name: c.Name,
|
||||
BetsPlaced: r.BetsPlaced,
|
||||
Wins: r.Wins,
|
||||
Losses: r.Losses,
|
||||
HitRatePercent: hitRate,
|
||||
NetProfit: r.NetProfit,
|
||||
RoiPercent: r.RoiPercent,
|
||||
MaxDrawdown: r.MaxDrawdown,
|
||||
IsBest: isBest));
|
||||
}
|
||||
|
||||
return new StrategyComparisonVm(rows);
|
||||
}
|
||||
|
||||
// Mirrors BacktestForm.ToDateRange — inclusive Moscow-day range, null when either bound is unset.
|
||||
private static DateRange? ToDateRange(DateTime? from, DateTime? to)
|
||||
{
|
||||
if (from is not { } f || to is not { } t)
|
||||
return null;
|
||||
|
||||
return new DateRange(
|
||||
new DateTimeOffset(f.Date, MoscowTime.Offset),
|
||||
MoscowTime.EndOfMoscowDay(DateOnly.FromDateTime(t.Date)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
namespace Marathon.UI.Services;
|
||||
|
||||
/// <summary>Head-to-head backtest of every saved strategy preset over one window.</summary>
|
||||
public sealed record StrategyComparisonVm(IReadOnlyList<StrategyComparisonRowVm> Rows)
|
||||
{
|
||||
public static StrategyComparisonVm Empty { get; } = new(Array.Empty<StrategyComparisonRowVm>());
|
||||
}
|
||||
|
||||
/// <summary>One preset's summary metrics for the comparison table.</summary>
|
||||
public sealed record StrategyComparisonRowVm(
|
||||
Guid StrategyId,
|
||||
string Name,
|
||||
int BetsPlaced,
|
||||
int Wins,
|
||||
int Losses,
|
||||
decimal? HitRatePercent,
|
||||
decimal NetProfit,
|
||||
decimal? RoiPercent,
|
||||
decimal MaxDrawdown,
|
||||
bool IsBest);
|
||||
@@ -62,6 +62,7 @@ public static class UiServicesExtensions
|
||||
services.AddScoped<IResultsBrowsingService, ResultsBrowsingService>();
|
||||
services.AddScoped<IBetJournalService, BetJournalService>();
|
||||
services.AddScoped<IBacktestService, BacktestService>();
|
||||
services.AddScoped<IStrategyComparisonService, StrategyComparisonService>();
|
||||
services.AddScoped<IDashboardSummaryService, DashboardSummaryService>();
|
||||
services.AddScoped<IPipelineHealthService, PipelineHealthService>();
|
||||
services.AddScoped<IPaperTradingService, PaperTradingService>();
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
using FluentAssertions;
|
||||
using Marathon.Application.Abstractions;
|
||||
using Marathon.Application.UseCases;
|
||||
using Marathon.Domain.Backtesting;
|
||||
using Marathon.Domain.Entities;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NSubstitute;
|
||||
|
||||
namespace Marathon.Application.Tests.UseCases;
|
||||
|
||||
public sealed class CompareStrategiesUseCaseTests
|
||||
{
|
||||
private readonly ISavedStrategyRepository _strategies = Substitute.For<ISavedStrategyRepository>();
|
||||
private readonly IAnomalyRepository _anomalies = Substitute.For<IAnomalyRepository>();
|
||||
private readonly IEventRepository _events = Substitute.For<IEventRepository>();
|
||||
private readonly IResultRepository _results = Substitute.For<IResultRepository>();
|
||||
|
||||
private CompareStrategiesUseCase CreateSut()
|
||||
{
|
||||
var backtest = new RunBacktestUseCase(_anomalies, _events, _results, NullLogger<RunBacktestUseCase>.Instance);
|
||||
return new CompareStrategiesUseCase(_strategies, backtest, NullLogger<CompareStrategiesUseCase>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Empty_When_NoPresets()
|
||||
{
|
||||
_strategies.ListAsync(Arg.Any<CancellationToken>()).Returns(Array.Empty<SavedStrategy>());
|
||||
|
||||
(await CreateSut().ExecuteAsync(null)).Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunsEachPreset_PreservingNameAndOrder()
|
||||
{
|
||||
_strategies.ListAsync(Arg.Any<CancellationToken>()).Returns(new[]
|
||||
{
|
||||
SavedStrategy.Create("Alpha", BacktestStrategy.Default),
|
||||
SavedStrategy.Create("Beta", BacktestStrategy.Default),
|
||||
});
|
||||
// No anomalies → each backtest returns a 0-bet result; events/results are never queried.
|
||||
_anomalies.ListAsync(Arg.Any<CancellationToken>()).Returns(Array.Empty<Anomaly>());
|
||||
|
||||
var rows = await CreateSut().ExecuteAsync(null);
|
||||
|
||||
rows.Select(r => r.Name).Should().ContainInOrder("Alpha", "Beta");
|
||||
rows.Should().OnlyContain(r => r.Result.BetsPlaced == 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
using FluentAssertions;
|
||||
using Marathon.Application.UseCases;
|
||||
using Marathon.Domain.Backtesting;
|
||||
using Marathon.Domain.ValueObjects;
|
||||
using Marathon.UI.Services;
|
||||
|
||||
namespace Marathon.UI.Tests.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Tests the pure <see cref="StrategyComparisonService.BuildVm"/> projection — per-row hit
|
||||
/// rate and the single best-by-ROI flag — without the (sealed) use case.
|
||||
/// </summary>
|
||||
public sealed class StrategyComparisonServiceTests
|
||||
{
|
||||
private static BacktestResult Result(int bets, int wins, int losses, decimal net, decimal? roi, decimal maxDd = 0m) =>
|
||||
new(
|
||||
StartingBankroll: 1000m,
|
||||
FinalBankroll: 1000m + net,
|
||||
NetProfit: net,
|
||||
RoiPercent: roi,
|
||||
TotalStaked: 0m,
|
||||
TotalReturned: 0m,
|
||||
MaxDrawdown: maxDd,
|
||||
MaxDrawdownPercent: null,
|
||||
BetsPlaced: bets,
|
||||
Wins: wins,
|
||||
Losses: losses,
|
||||
Skipped: 0,
|
||||
SkippedByThreshold: 0,
|
||||
SkippedByDataQuality: 0,
|
||||
SkippedByBankroll: 0,
|
||||
MaxWinStreak: 0,
|
||||
MaxLossStreak: 0,
|
||||
Trace: Array.Empty<BacktestTrace>(),
|
||||
EventTitles: new Dictionary<EventId, string>());
|
||||
|
||||
private static StrategyComparison Comp(string name, BacktestResult result) =>
|
||||
new(Guid.NewGuid(), name, result);
|
||||
|
||||
[Fact]
|
||||
public void BuildVm_Empty_When_NoComparisons()
|
||||
{
|
||||
StrategyComparisonService.BuildVm(Array.Empty<StrategyComparison>())
|
||||
.Should().Be(StrategyComparisonVm.Empty);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildVm_MarksHighestRoiBest_AndComputesHitRate()
|
||||
{
|
||||
var comps = new[]
|
||||
{
|
||||
Comp("Flat", Result(bets: 10, wins: 6, losses: 4, net: 20m, roi: 5m)),
|
||||
Comp("Kelly", Result(bets: 10, wins: 7, losses: 3, net: 50m, roi: 12m)),
|
||||
Comp("Percent", Result(bets: 10, wins: 5, losses: 5, net: -10m, roi: -3m)),
|
||||
};
|
||||
|
||||
var vm = StrategyComparisonService.BuildVm(comps);
|
||||
|
||||
vm.Rows.Should().HaveCount(3);
|
||||
vm.Rows.Count(r => r.IsBest).Should().Be(1);
|
||||
vm.Rows.Single(r => r.IsBest).Name.Should().Be("Kelly");
|
||||
vm.Rows.Single(r => r.Name == "Flat").HitRatePercent.Should().Be(60m); // 6/10
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildVm_NoBetsPreset_NotBest_AndNullHitRate()
|
||||
{
|
||||
var vm = StrategyComparisonService.BuildVm(new[]
|
||||
{
|
||||
Comp("Idle", Result(bets: 0, wins: 0, losses: 0, net: 0m, roi: null)),
|
||||
});
|
||||
|
||||
vm.Rows.Should().ContainSingle();
|
||||
vm.Rows[0].IsBest.Should().BeFalse();
|
||||
vm.Rows[0].HitRatePercent.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildVm_Ties_ProduceSingleBest()
|
||||
{
|
||||
var comps = new[]
|
||||
{
|
||||
Comp("A", Result(10, 6, 4, 20m, 5m)),
|
||||
Comp("B", Result(10, 6, 4, 20m, 5m)),
|
||||
};
|
||||
|
||||
StrategyComparisonService.BuildVm(comps).Rows.Count(r => r.IsBest).Should().Be(1);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user