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:
2026-05-29 11:32:01 +03:00
parent e60b5bf57e
commit 6e12dd73c3
12 changed files with 636 additions and 0 deletions
@@ -41,6 +41,7 @@ public static class ApplicationModule
services.AddScoped<RunBacktestUseCase>(); services.AddScoped<RunBacktestUseCase>();
services.AddScoped<SaveStrategyUseCase>(); services.AddScoped<SaveStrategyUseCase>();
services.AddScoped<DeleteStrategyUseCase>(); services.AddScoped<DeleteStrategyUseCase>();
services.AddScoped<CompareStrategiesUseCase>();
services.AddScoped<OpenPaperBetsUseCase>(); services.AddScoped<OpenPaperBetsUseCase>();
services.AddScoped<SettlePaperBetsUseCase>(); 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;
}
}
+4
View File
@@ -51,6 +51,10 @@
<MudIcon Icon="@Icons.Material.Outlined.QueryStats" Size="Size.Small" /> <MudIcon Icon="@Icons.Material.Outlined.QueryStats" Size="Size.Small" />
<span>@L["Nav.Backtest"]</span> <span>@L["Nav.Backtest"]</span>
</NavLink> </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"> <NavLink class="m-nav__link" href="paper-trading">
<MudIcon Icon="@Icons.Material.Outlined.Science" Size="Size.Small" /> <MudIcon Icon="@Icons.Material.Outlined.Science" Size="Size.Small" />
<span>@L["Nav.PaperTrading"]</span> <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="Journal.Confirm.Delete"><value>Delete this bet permanently?</value></data>
<data name="Nav.Backtest"><value>Backtest</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="Nav.PaperTrading"><value>Forward-test</value></data>
<data name="Backtest.Kicker"><value>Simulator</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.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.Won"><value>Won</value></data>
<data name="Paper.Outcome.Lost"><value>Lost</value></data> <data name="Paper.Outcome.Lost"><value>Lost</value></data>
<data name="Paper.Outcome.Void"><value>Void</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>WL</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> </root>
@@ -482,6 +482,7 @@
<data name="Journal.Confirm.Delete"><value>Удалить эту ставку безвозвратно?</value></data> <data name="Journal.Confirm.Delete"><value>Удалить эту ставку безвозвратно?</value></data>
<data name="Nav.Backtest"><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="Nav.PaperTrading"><value>Форвард-тест</value></data>
<data name="Backtest.Kicker"><value>Симулятор</value></data> <data name="Backtest.Kicker"><value>Симулятор</value></data>
<data name="Backtest.Title"><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.Won"><value>Выигрыш</value></data>
<data name="Paper.Outcome.Lost"><value>Проигрыш</value></data> <data name="Paper.Outcome.Lost"><value>Проигрыш</value></data>
<data name="Paper.Outcome.Void"><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> </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<IResultsBrowsingService, ResultsBrowsingService>();
services.AddScoped<IBetJournalService, BetJournalService>(); services.AddScoped<IBetJournalService, BetJournalService>();
services.AddScoped<IBacktestService, BacktestService>(); services.AddScoped<IBacktestService, BacktestService>();
services.AddScoped<IStrategyComparisonService, StrategyComparisonService>();
services.AddScoped<IDashboardSummaryService, DashboardSummaryService>(); services.AddScoped<IDashboardSummaryService, DashboardSummaryService>();
services.AddScoped<IPipelineHealthService, PipelineHealthService>(); services.AddScoped<IPipelineHealthService, PipelineHealthService>();
services.AddScoped<IPaperTradingService, PaperTradingService>(); 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);
}
}