From 6e12dd73c36d4d791bc61603966ec986529e1c8e Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Fri, 29 May 2026 11:32:01 +0300 Subject: [PATCH] feat(backtest): strategy comparison (head-to-head) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/Marathon.Application/ApplicationModule.cs | 1 + .../UseCases/CompareStrategiesUseCase.cs | 60 ++++ src/Marathon.UI/Components/NavBody.razor | 4 + .../Pages/Anomalies/StrategyCompare.razor | 282 ++++++++++++++++++ .../Resources/SharedResource.en.resx | 15 + .../Resources/SharedResource.ru.resx | 15 + .../Services/IStrategyComparisonService.cs | 15 + .../Services/StrategyComparisonService.cs | 86 ++++++ .../Services/StrategyComparisonViewModels.cs | 20 ++ .../Services/UiServicesExtensions.cs | 1 + .../UseCases/CompareStrategiesUseCaseTests.cs | 48 +++ .../StrategyComparisonServiceTests.cs | 89 ++++++ 12 files changed, 636 insertions(+) create mode 100644 src/Marathon.Application/UseCases/CompareStrategiesUseCase.cs create mode 100644 src/Marathon.UI/Pages/Anomalies/StrategyCompare.razor create mode 100644 src/Marathon.UI/Services/IStrategyComparisonService.cs create mode 100644 src/Marathon.UI/Services/StrategyComparisonService.cs create mode 100644 src/Marathon.UI/Services/StrategyComparisonViewModels.cs create mode 100644 tests/Marathon.Application.Tests/UseCases/CompareStrategiesUseCaseTests.cs create mode 100644 tests/Marathon.UI.Tests/Services/StrategyComparisonServiceTests.cs diff --git a/src/Marathon.Application/ApplicationModule.cs b/src/Marathon.Application/ApplicationModule.cs index be60de8..9e95b3a 100644 --- a/src/Marathon.Application/ApplicationModule.cs +++ b/src/Marathon.Application/ApplicationModule.cs @@ -41,6 +41,7 @@ public static class ApplicationModule services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/src/Marathon.Application/UseCases/CompareStrategiesUseCase.cs b/src/Marathon.Application/UseCases/CompareStrategiesUseCase.cs new file mode 100644 index 0000000..e94cf98 --- /dev/null +++ b/src/Marathon.Application/UseCases/CompareStrategiesUseCase.cs @@ -0,0 +1,60 @@ +using Marathon.Application.Abstractions; +using Marathon.Application.Storage; +using Marathon.Domain.Backtesting; +using Microsoft.Extensions.Logging; + +namespace Marathon.Application.UseCases; + +/// One saved strategy preset paired with its backtest result over a shared window. +public sealed record StrategyComparison(Guid StrategyId, string Name, BacktestResult Result); + +/// +/// 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. +/// +/// +/// Delegates to 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. +/// +public sealed class CompareStrategiesUseCase +{ + private readonly ISavedStrategyRepository _strategies; + private readonly RunBacktestUseCase _backtest; + private readonly ILogger _logger; + + public CompareStrategiesUseCase( + ISavedStrategyRepository strategies, + RunBacktestUseCase backtest, + ILogger logger) + { + _strategies = strategies ?? throw new ArgumentNullException(nameof(strategies)); + _backtest = backtest ?? throw new ArgumentNullException(nameof(backtest)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + /// Backtests each saved preset over (null = all graded + /// anomalies). Returns one row per preset in saved (name-ascending) order; empty when + /// the user has saved no strategies. + /// + public async Task> ExecuteAsync( + DateRange? dateRange, CancellationToken ct = default) + { + var presets = await _strategies.ListAsync(ct).ConfigureAwait(false); + if (presets.Count == 0) + return Array.Empty(); + + var rows = new List(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; + } +} diff --git a/src/Marathon.UI/Components/NavBody.razor b/src/Marathon.UI/Components/NavBody.razor index b36d83d..c4eee4d 100644 --- a/src/Marathon.UI/Components/NavBody.razor +++ b/src/Marathon.UI/Components/NavBody.razor @@ -51,6 +51,10 @@ @L["Nav.Backtest"] + + + @L["Nav.Compare"] + @L["Nav.PaperTrading"] diff --git a/src/Marathon.UI/Pages/Anomalies/StrategyCompare.razor b/src/Marathon.UI/Pages/Anomalies/StrategyCompare.razor new file mode 100644 index 0000000..31216c4 --- /dev/null +++ b/src/Marathon.UI/Pages/Anomalies/StrategyCompare.razor @@ -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 L +@inject IStrategyComparisonService Service +@inject NavigationManager Nav +@inject ILogger Logger + +@L["App.Title"] · @L["Nav.Compare"] + +
+
+ @L["Compare.Kicker"] +

@L["Compare.Title"]

+

@L["Compare.Lede"]

+
+ +
+ +
+
+
+ + +
+
+ + + @L["Backtest.Field.DateRange.Hint"] +
+
+ +
+
+ @if (!string.IsNullOrEmpty(_error)) + { +

@_error

+ } +
+ + @if (_loading) + { +
+ + @L["Common.Loading"] +
+ } + else if (_vm is null || _vm.Rows.Count == 0) + { +
+ + @L["Common.Empty"] + +

+ @L["Compare.Empty"] +

+ + @L["Nav.Backtest"] + +
+ } + else + { +
+
+
+ @L["Compare.Section.Results"] + @_vm.Rows.Count +
+ +
+ + + + + + + + + + + + + + @foreach (var row in _vm.Rows) + { + + + + + + + + + + } + +
@L["Compare.Column.Strategy"]@L["Compare.Column.Bets"]@L["Compare.Column.WinLoss"]@L["Compare.Column.HitRate"]@L["Compare.Column.Net"]@L["Compare.Column.Roi"]@L["Compare.Column.MaxDrawdown"]
+ @row.Name + @if (row.IsBest) + { + @L["Compare.Best"] + } + @row.BetsPlaced + @row.Wins@row.Losses + @FormatPercent(row.HitRatePercent)@FormatSignedDecimal(row.NetProfit, row.BetsPlaced)@FormatSignedPercent(row.RoiPercent)@(row.MaxDrawdown == 0m ? "—" : row.MaxDrawdown.ToString("0.00", CultureInfo.InvariantCulture))
+
+
+ } +
+ + + +@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(); + } +} diff --git a/src/Marathon.UI/Resources/SharedResource.en.resx b/src/Marathon.UI/Resources/SharedResource.en.resx index 1299e8e..3b0800f 100644 --- a/src/Marathon.UI/Resources/SharedResource.en.resx +++ b/src/Marathon.UI/Resources/SharedResource.en.resx @@ -469,6 +469,7 @@ Delete this bet permanently? Backtest + Compare Forward-test Simulator Replay the detector against history @@ -553,4 +554,18 @@ Won Lost Void + Strategy lab + Compare strategies + 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. + Compare + No saved strategies to compare. Save one or more presets on the Backtest page, then return here to rank them head-to-head. + Head to head + Best + Strategy + Bets + W–L + Hit % + Net + ROI + Max DD diff --git a/src/Marathon.UI/Resources/SharedResource.ru.resx b/src/Marathon.UI/Resources/SharedResource.ru.resx index 3bc0177..ee6c3b8 100644 --- a/src/Marathon.UI/Resources/SharedResource.ru.resx +++ b/src/Marathon.UI/Resources/SharedResource.ru.resx @@ -482,6 +482,7 @@ Удалить эту ставку безвозвратно? Бэктест + Сравнить Форвард-тест Симулятор Прогон детектора по истории @@ -566,4 +567,18 @@ Выигрыш Проигрыш Возврат + Лаборатория стратегий + Сравнение стратегий + Прогоните каждый сохранённый пресет на одном окне и ранжируйте по ROI — найдите стейкинг, который реально работает. Сначала сохраните пресеты на странице бэктеста. + Сравнить + Нет сохранённых стратегий для сравнения. Сохраните один или несколько пресетов на странице бэктеста, затем вернитесь сюда для сравнения. + Лицом к лицу + Лучшая + Стратегия + Ставки + В–П + Попад. % + Чистыми + ROI + Макс. просадка diff --git a/src/Marathon.UI/Services/IStrategyComparisonService.cs b/src/Marathon.UI/Services/IStrategyComparisonService.cs new file mode 100644 index 0000000..c6e239f --- /dev/null +++ b/src/Marathon.UI/Services/IStrategyComparisonService.cs @@ -0,0 +1,15 @@ +namespace Marathon.UI.Services; + +/// +/// Facade over — runs +/// every saved preset over the supplied Moscow-day window and shapes the head-to-head table. +/// +public interface IStrategyComparisonService +{ + /// + /// Compares all saved presets over [..] + /// (both null = all graded anomalies). Returns + /// when no presets are saved. + /// + Task CompareAsync(DateTime? from, DateTime? to, CancellationToken ct); +} diff --git a/src/Marathon.UI/Services/StrategyComparisonService.cs b/src/Marathon.UI/Services/StrategyComparisonService.cs new file mode 100644 index 0000000..29be307 --- /dev/null +++ b/src/Marathon.UI/Services/StrategyComparisonService.cs @@ -0,0 +1,86 @@ +using Marathon.Application.Storage; +using Marathon.Application.UseCases; +using Marathon.Domain.ValueObjects; + +namespace Marathon.UI.Services; + +/// +/// Page-facing implementation of . 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). +/// +public sealed class StrategyComparisonService : IStrategyComparisonService +{ + private readonly CompareStrategiesUseCase _useCase; + + public StrategyComparisonService(CompareStrategiesUseCase useCase) => + _useCase = useCase ?? throw new ArgumentNullException(nameof(useCase)); + + public async Task CompareAsync(DateTime? from, DateTime? to, CancellationToken ct) + { + var comparisons = await _useCase.ExecuteAsync(ToDateRange(from, to), ct).ConfigureAwait(false); + return BuildVm(comparisons); + } + + /// + /// 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. + /// + public static StrategyComparisonVm BuildVm(IReadOnlyList 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(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))); + } +} diff --git a/src/Marathon.UI/Services/StrategyComparisonViewModels.cs b/src/Marathon.UI/Services/StrategyComparisonViewModels.cs new file mode 100644 index 0000000..d17c33e --- /dev/null +++ b/src/Marathon.UI/Services/StrategyComparisonViewModels.cs @@ -0,0 +1,20 @@ +namespace Marathon.UI.Services; + +/// Head-to-head backtest of every saved strategy preset over one window. +public sealed record StrategyComparisonVm(IReadOnlyList Rows) +{ + public static StrategyComparisonVm Empty { get; } = new(Array.Empty()); +} + +/// One preset's summary metrics for the comparison table. +public sealed record StrategyComparisonRowVm( + Guid StrategyId, + string Name, + int BetsPlaced, + int Wins, + int Losses, + decimal? HitRatePercent, + decimal NetProfit, + decimal? RoiPercent, + decimal MaxDrawdown, + bool IsBest); diff --git a/src/Marathon.UI/Services/UiServicesExtensions.cs b/src/Marathon.UI/Services/UiServicesExtensions.cs index 7b643c4..3907444 100644 --- a/src/Marathon.UI/Services/UiServicesExtensions.cs +++ b/src/Marathon.UI/Services/UiServicesExtensions.cs @@ -62,6 +62,7 @@ public static class UiServicesExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/tests/Marathon.Application.Tests/UseCases/CompareStrategiesUseCaseTests.cs b/tests/Marathon.Application.Tests/UseCases/CompareStrategiesUseCaseTests.cs new file mode 100644 index 0000000..c139fa2 --- /dev/null +++ b/tests/Marathon.Application.Tests/UseCases/CompareStrategiesUseCaseTests.cs @@ -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(); + private readonly IAnomalyRepository _anomalies = Substitute.For(); + private readonly IEventRepository _events = Substitute.For(); + private readonly IResultRepository _results = Substitute.For(); + + private CompareStrategiesUseCase CreateSut() + { + var backtest = new RunBacktestUseCase(_anomalies, _events, _results, NullLogger.Instance); + return new CompareStrategiesUseCase(_strategies, backtest, NullLogger.Instance); + } + + [Fact] + public async Task Empty_When_NoPresets() + { + _strategies.ListAsync(Arg.Any()).Returns(Array.Empty()); + + (await CreateSut().ExecuteAsync(null)).Should().BeEmpty(); + } + + [Fact] + public async Task RunsEachPreset_PreservingNameAndOrder() + { + _strategies.ListAsync(Arg.Any()).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()).Returns(Array.Empty()); + + var rows = await CreateSut().ExecuteAsync(null); + + rows.Select(r => r.Name).Should().ContainInOrder("Alpha", "Beta"); + rows.Should().OnlyContain(r => r.Result.BetsPlaced == 0); + } +} diff --git a/tests/Marathon.UI.Tests/Services/StrategyComparisonServiceTests.cs b/tests/Marathon.UI.Tests/Services/StrategyComparisonServiceTests.cs new file mode 100644 index 0000000..c07ff6e --- /dev/null +++ b/tests/Marathon.UI.Tests/Services/StrategyComparisonServiceTests.cs @@ -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; + +/// +/// Tests the pure projection — per-row hit +/// rate and the single best-by-ROI flag — without the (sealed) use case. +/// +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(), + EventTitles: new Dictionary()); + + private static StrategyComparison Comp(string name, BacktestResult result) => + new(Guid.NewGuid(), name, result); + + [Fact] + public void BuildVm_Empty_When_NoComparisons() + { + StrategyComparisonService.BuildVm(Array.Empty()) + .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); + } +}