feat(backtest): optional date-range window

- RunBacktestUseCase gains an ExecuteAsync(strategy, DateRange?, ct) overload that
  pushes the date filter to SQL via IAnomalyRepository.ListByDateRangeAsync; the
  existing no-range overload is preserved. +1 use-case test.
- BacktestForm carries optional From/To (Moscow dates) with From<=To validation and
  a ToDateRange() helper; BacktestService threads it through. Backtest page gains two
  clearable date pickers (empty = all anomalies).
- Localization (en+ru) for the backtest date fields and the settings-validation keys
  (shared resx).
This commit is contained in:
2026-05-29 00:50:43 +03:00
parent d9d92ea8fd
commit e5cd2ab30c
7 changed files with 98 additions and 5 deletions
@@ -1,4 +1,5 @@
using Marathon.Application.Abstractions;
using Marathon.Application.Storage;
using Marathon.Domain.AnomalyDetection;
using Marathon.Domain.Backtesting;
using Marathon.Domain.Entities;
@@ -46,17 +47,32 @@ public sealed class RunBacktestUseCase
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<BacktestResult> ExecuteAsync(
/// <summary>Runs the backtest over every graded anomaly (no date filter).</summary>
public Task<BacktestResult> ExecuteAsync(
BacktestStrategy strategy,
CancellationToken ct = default)
=> ExecuteAsync(strategy, dateRange: null, ct);
/// <summary>
/// Runs the backtest over anomalies detected within <paramref name="dateRange"/>
/// (inclusive); pass <c>null</c> to include every graded anomaly. The date filter
/// is pushed to SQL via <see cref="IAnomalyRepository.ListByDateRangeAsync"/>.
/// </summary>
public async Task<BacktestResult> ExecuteAsync(
BacktestStrategy strategy,
DateRange? dateRange,
CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(strategy);
_logger.LogInformation(
"RunBacktestUseCase: started — bankroll={Bankroll}, minScore={MinScore}, stakeRule={Rule}",
strategy.StartingBankroll, strategy.MinScore, strategy.StakeRule);
"RunBacktestUseCase: started — bankroll={Bankroll}, minScore={MinScore}, stakeRule={Rule}, range={Range}",
strategy.StartingBankroll, strategy.MinScore, strategy.StakeRule,
dateRange is null ? "all" : $"{dateRange.From:O}..{dateRange.To:O}");
var anomalies = await _anomalies.ListAsync(ct).ConfigureAwait(false);
var anomalies = dateRange is null
? await _anomalies.ListAsync(ct).ConfigureAwait(false)
: await _anomalies.ListByDateRangeAsync(dateRange.From, dateRange.To, ct).ConfigureAwait(false);
if (anomalies.Count == 0)
{
_logger.LogInformation("RunBacktestUseCase: no anomalies — empty result");
@@ -46,6 +46,25 @@
data-test="backtest-bankroll" />
</div>
<div class="m-backtest__form-field">
<label class="m-backtest__form-label">@L["Backtest.Field.From"]</label>
<MudDatePicker @bind-Date="_form.From"
DateFormat="yyyy-MM-dd"
Clearable="true"
Variant="Variant.Outlined"
data-test="backtest-from" />
</div>
<div class="m-backtest__form-field">
<label class="m-backtest__form-label">@L["Backtest.Field.To"]</label>
<MudDatePicker @bind-Date="_form.To"
DateFormat="yyyy-MM-dd"
Clearable="true"
Variant="Variant.Outlined"
data-test="backtest-to" />
<span class="m-backtest__form-hint">@L["Backtest.Field.DateRange.Hint"]</span>
</div>
<div class="m-backtest__form-field">
<label class="m-backtest__form-label">@L["Backtest.Field.MinScore"]</label>
<MudNumericField T="decimal"
@@ -116,6 +116,9 @@
<data name="Settings.Scraping.RateLimitRps"><value>Rate limit (RPS)</value></data>
<data name="Settings.Scraping.RateLimitRps.Hint"><value>Requests per second. 1 is recommended.</value></data>
<data name="Settings.Scraping.BaseUrl"><value>Base URL</value></data>
<data name="Settings.Scraping.BaseUrl.Hint"><value>Must be an absolute http(s) URL, e.g. https://www.marathonbet.by</value></data>
<data name="Settings.Scraping.BaseUrl.Invalid"><value>Base URL must be an absolute http(s) address.</value></data>
<data name="Settings.Workers.Cron.Invalid"><value>Schedule must be a 5- or 6-field cron expression.</value></data>
<data name="Settings.Scraping.RequestTimeoutSeconds"><value>Request timeout (sec)</value></data>
<data name="Settings.Scraping.UsePlaywright"><value>Use Playwright</value></data>
@@ -445,6 +448,9 @@
<data name="Backtest.Section.Equity"><value>Equity curve</value></data>
<data name="Backtest.Section.Trace"><value>Trade trace</value></data>
<data name="Backtest.Field.Bankroll"><value>Starting bankroll</value></data>
<data name="Backtest.Field.From"><value>From date</value></data>
<data name="Backtest.Field.To"><value>To date</value></data>
<data name="Backtest.Field.DateRange.Hint"><value>Leave both empty to backtest every graded anomaly.</value></data>
<data name="Backtest.Field.MinScore"><value>Min anomaly score</value></data>
<data name="Backtest.Field.MinScore.Hint"><value>Only bet anomalies at or above this confidence.</value></data>
<data name="Backtest.Field.StakeRule"><value>Staking rule</value></data>
@@ -121,6 +121,9 @@
<data name="Settings.Scraping.RateLimitRps"><value>Лимит RPS</value></data>
<data name="Settings.Scraping.RateLimitRps.Hint"><value>Запросов в секунду. Рекомендовано 1.</value></data>
<data name="Settings.Scraping.BaseUrl"><value>Базовый URL</value></data>
<data name="Settings.Scraping.BaseUrl.Hint"><value>Должен быть абсолютный http(s)-адрес, например https://www.marathonbet.by</value></data>
<data name="Settings.Scraping.BaseUrl.Invalid"><value>Базовый URL должен быть абсолютным http(s)-адресом.</value></data>
<data name="Settings.Workers.Cron.Invalid"><value>Расписание должно быть cron-выражением из 5 или 6 полей.</value></data>
<data name="Settings.Scraping.RequestTimeoutSeconds"><value>Тайм-аут запроса (сек)</value></data>
<data name="Settings.Scraping.UsePlaywright"><value>Использовать Playwright</value></data>
@@ -458,6 +461,9 @@
<data name="Backtest.Section.Equity"><value>Кривая банка</value></data>
<data name="Backtest.Section.Trace"><value>Хронология ставок</value></data>
<data name="Backtest.Field.Bankroll"><value>Стартовый банк</value></data>
<data name="Backtest.Field.From"><value>Дата с</value></data>
<data name="Backtest.Field.To"><value>Дата по</value></data>
<data name="Backtest.Field.DateRange.Hint"><value>Оставьте оба поля пустыми, чтобы прогнать все оценённые аномалии.</value></data>
<data name="Backtest.Field.MinScore"><value>Мин. score аномалии</value></data>
<data name="Backtest.Field.MinScore.Hint"><value>Ставим только при уверенности не ниже этого порога.</value></data>
<data name="Backtest.Field.StakeRule"><value>Правило стейкинга</value></data>
+1 -1
View File
@@ -23,7 +23,7 @@ public sealed class BacktestService : IBacktestService
if (!form.IsValid(out var err))
throw new ArgumentException(err ?? "Invalid form.", nameof(form));
var result = await _useCase.ExecuteAsync(form.ToStrategy(), ct).ConfigureAwait(false);
var result = await _useCase.ExecuteAsync(form.ToStrategy(), form.ToDateRange(), ct).ConfigureAwait(false);
var rows = result.Trace
.Select(t => new BacktestTraceRow(
@@ -1,5 +1,7 @@
using Marathon.Application.Storage;
using Marathon.Domain.Backtesting;
using Marathon.Domain.Enums;
using Marathon.Domain.ValueObjects;
namespace Marathon.UI.Services;
@@ -21,10 +23,18 @@ public sealed class BacktestForm
/// <summary>Bound to the UI as a percentage 0100; converted to a fraction before sim.</summary>
public decimal KellyFractionPercent { get; set; } = 25m;
/// <summary>Optional inclusive date-range filter (Moscow dates). Both null = all anomalies.</summary>
public DateTime? From { get; set; }
/// <summary>Optional inclusive date-range filter (Moscow dates). Both null = all anomalies.</summary>
public DateTime? To { get; set; }
public bool IsValid(out string? error)
{
if (StartingBankroll <= 0m) { error = "Bankroll must be positive."; return false; }
if (MinScore is < 0m or > 1m) { error = "Min score must be in [0, 1]."; return false; }
if (From is { } f && To is { } t && f.Date > t.Date)
{ error = "From date must be on or before To date."; return false; }
switch (StakeRule)
{
case StakeRule.Flat:
@@ -52,6 +62,20 @@ public sealed class BacktestForm
FlatStake: FlatStake,
PercentOfBankroll: PercentOfBankrollPercent / 100m,
KellyFraction: KellyFractionPercent / 100m);
/// <summary>
/// The inclusive Moscow-day date range, or null when either bound is unset
/// (meaning: run over every graded anomaly).
/// </summary>
public DateRange? ToDateRange()
{
if (From is not { } from || To is not { } to)
return null;
return new DateRange(
new DateTimeOffset(from.Date, MoscowTime.Offset),
MoscowTime.EndOfMoscowDay(DateOnly.FromDateTime(to.Date)));
}
}
/// <summary>UI-facing projection of <see cref="BacktestResult"/>.</summary>
@@ -1,5 +1,6 @@
using FluentAssertions;
using Marathon.Application.Abstractions;
using Marathon.Application.Storage;
using Marathon.Application.UseCases;
using Marathon.Domain.Backtesting;
using Marathon.Domain.Entities;
@@ -63,6 +64,27 @@ public sealed class RunBacktestUseCaseTests
StakeRule: StakeRule.Flat,
FlatStake: 100m, PercentOfBankroll: 0.02m, KellyFraction: 0.25m);
[Fact]
public async Task Should_LoadByDateRange_When_RangeProvided()
{
var id = new EventId("77777777");
var range = new DateRange(BaseTime.AddDays(-1), BaseTime.AddDays(1));
_anomalies.ListByDateRangeAsync(
Arg.Any<DateTimeOffset?>(), Arg.Any<DateTimeOffset?>(), Arg.Any<CancellationToken>())
.Returns(new[] { MakeAnomaly(id) }.ToList().AsReadOnly());
_events.GetAsync(id, Arg.Any<CancellationToken>()).Returns(MakeEvent(id));
_results.GetAsync(id, Arg.Any<CancellationToken>())
.Returns(new EventResult(id, 0, 2, Side.Side2, DateTimeOffset.UtcNow));
var result = await CreateSut().ExecuteAsync(DefaultStrategy(), range, CancellationToken.None);
await _anomalies.Received(1).ListByDateRangeAsync(
Arg.Any<DateTimeOffset?>(), Arg.Any<DateTimeOffset?>(), Arg.Any<CancellationToken>());
await _anomalies.DidNotReceive().ListAsync(Arg.Any<CancellationToken>());
result.BetsPlaced.Should().BeGreaterThan(0, "the in-range graded anomaly produces a bet");
}
[Fact]
public async Task Should_ReturnEmptyResult_When_NoAnomaliesExist()
{