feat(backtest): strategy comparison (head-to-head)
New /anomalies/compare page runs every saved strategy preset over the same window and ranks them by ROI — bets, W–L, hit-rate, net, and max drawdown side by side, with the best ROI flagged. Auto-runs on load with an optional date-range refine. - CompareStrategiesUseCase fans RunBacktestUseCase over saved presets (re-loads the anomaly set per preset — fine for the handful a user keeps; stays bug-for-bug identical to a single backtest run). - StrategyComparisonService.BuildVm (pure) computes per-row hit-rate + a single best-by-ROI flag; nav entry + en/ru resx. - 6 tests: use-case fan-out + BuildVm best/tie/no-bets/hit-rate.
This commit is contained in:
@@ -0,0 +1,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