Files
maraphon-app/tests/Marathon.UI.Tests/Services/StrategyComparisonServiceTests.cs
T
alexei.dolgolyov 6e12dd73c3 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.
2026-05-29 11:32:01 +03:00

90 lines
2.9 KiB
C#

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);
}
}