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:
@@ -1,4 +1,5 @@
|
|||||||
using Marathon.Application.Abstractions;
|
using Marathon.Application.Abstractions;
|
||||||
|
using Marathon.Application.Storage;
|
||||||
using Marathon.Domain.AnomalyDetection;
|
using Marathon.Domain.AnomalyDetection;
|
||||||
using Marathon.Domain.Backtesting;
|
using Marathon.Domain.Backtesting;
|
||||||
using Marathon.Domain.Entities;
|
using Marathon.Domain.Entities;
|
||||||
@@ -46,17 +47,32 @@ public sealed class RunBacktestUseCase
|
|||||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
_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,
|
BacktestStrategy strategy,
|
||||||
CancellationToken ct = default)
|
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);
|
ArgumentNullException.ThrowIfNull(strategy);
|
||||||
|
|
||||||
_logger.LogInformation(
|
_logger.LogInformation(
|
||||||
"RunBacktestUseCase: started — bankroll={Bankroll}, minScore={MinScore}, stakeRule={Rule}",
|
"RunBacktestUseCase: started — bankroll={Bankroll}, minScore={MinScore}, stakeRule={Rule}, range={Range}",
|
||||||
strategy.StartingBankroll, strategy.MinScore, strategy.StakeRule);
|
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)
|
if (anomalies.Count == 0)
|
||||||
{
|
{
|
||||||
_logger.LogInformation("RunBacktestUseCase: no anomalies — empty result");
|
_logger.LogInformation("RunBacktestUseCase: no anomalies — empty result");
|
||||||
|
|||||||
@@ -46,6 +46,25 @@
|
|||||||
data-test="backtest-bankroll" />
|
data-test="backtest-bankroll" />
|
||||||
</div>
|
</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">
|
<div class="m-backtest__form-field">
|
||||||
<label class="m-backtest__form-label">@L["Backtest.Field.MinScore"]</label>
|
<label class="m-backtest__form-label">@L["Backtest.Field.MinScore"]</label>
|
||||||
<MudNumericField T="decimal"
|
<MudNumericField T="decimal"
|
||||||
|
|||||||
@@ -116,6 +116,9 @@
|
|||||||
<data name="Settings.Scraping.RateLimitRps"><value>Rate limit (RPS)</value></data>
|
<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.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"><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.RequestTimeoutSeconds"><value>Request timeout (sec)</value></data>
|
||||||
<data name="Settings.Scraping.UsePlaywright"><value>Use Playwright</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.Equity"><value>Equity curve</value></data>
|
||||||
<data name="Backtest.Section.Trace"><value>Trade trace</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.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"><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.MinScore.Hint"><value>Only bet anomalies at or above this confidence.</value></data>
|
||||||
<data name="Backtest.Field.StakeRule"><value>Staking rule</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"><value>Лимит RPS</value></data>
|
||||||
<data name="Settings.Scraping.RateLimitRps.Hint"><value>Запросов в секунду. Рекомендовано 1.</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"><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.RequestTimeoutSeconds"><value>Тайм-аут запроса (сек)</value></data>
|
||||||
<data name="Settings.Scraping.UsePlaywright"><value>Использовать Playwright</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.Equity"><value>Кривая банка</value></data>
|
||||||
<data name="Backtest.Section.Trace"><value>Хронология ставок</value></data>
|
<data name="Backtest.Section.Trace"><value>Хронология ставок</value></data>
|
||||||
<data name="Backtest.Field.Bankroll"><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"><value>Мин. score аномалии</value></data>
|
||||||
<data name="Backtest.Field.MinScore.Hint"><value>Ставим только при уверенности не ниже этого порога.</value></data>
|
<data name="Backtest.Field.MinScore.Hint"><value>Ставим только при уверенности не ниже этого порога.</value></data>
|
||||||
<data name="Backtest.Field.StakeRule"><value>Правило стейкинга</value></data>
|
<data name="Backtest.Field.StakeRule"><value>Правило стейкинга</value></data>
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ public sealed class BacktestService : IBacktestService
|
|||||||
if (!form.IsValid(out var err))
|
if (!form.IsValid(out var err))
|
||||||
throw new ArgumentException(err ?? "Invalid form.", nameof(form));
|
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
|
var rows = result.Trace
|
||||||
.Select(t => new BacktestTraceRow(
|
.Select(t => new BacktestTraceRow(
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
|
using Marathon.Application.Storage;
|
||||||
using Marathon.Domain.Backtesting;
|
using Marathon.Domain.Backtesting;
|
||||||
using Marathon.Domain.Enums;
|
using Marathon.Domain.Enums;
|
||||||
|
using Marathon.Domain.ValueObjects;
|
||||||
|
|
||||||
namespace Marathon.UI.Services;
|
namespace Marathon.UI.Services;
|
||||||
|
|
||||||
@@ -21,10 +23,18 @@ public sealed class BacktestForm
|
|||||||
/// <summary>Bound to the UI as a percentage 0–100; converted to a fraction before sim.</summary>
|
/// <summary>Bound to the UI as a percentage 0–100; converted to a fraction before sim.</summary>
|
||||||
public decimal KellyFractionPercent { get; set; } = 25m;
|
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)
|
public bool IsValid(out string? error)
|
||||||
{
|
{
|
||||||
if (StartingBankroll <= 0m) { error = "Bankroll must be positive."; return false; }
|
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 (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)
|
switch (StakeRule)
|
||||||
{
|
{
|
||||||
case StakeRule.Flat:
|
case StakeRule.Flat:
|
||||||
@@ -52,6 +62,20 @@ public sealed class BacktestForm
|
|||||||
FlatStake: FlatStake,
|
FlatStake: FlatStake,
|
||||||
PercentOfBankroll: PercentOfBankrollPercent / 100m,
|
PercentOfBankroll: PercentOfBankrollPercent / 100m,
|
||||||
KellyFraction: KellyFractionPercent / 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>
|
/// <summary>UI-facing projection of <see cref="BacktestResult"/>.</summary>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using FluentAssertions;
|
using FluentAssertions;
|
||||||
using Marathon.Application.Abstractions;
|
using Marathon.Application.Abstractions;
|
||||||
|
using Marathon.Application.Storage;
|
||||||
using Marathon.Application.UseCases;
|
using Marathon.Application.UseCases;
|
||||||
using Marathon.Domain.Backtesting;
|
using Marathon.Domain.Backtesting;
|
||||||
using Marathon.Domain.Entities;
|
using Marathon.Domain.Entities;
|
||||||
@@ -63,6 +64,27 @@ public sealed class RunBacktestUseCaseTests
|
|||||||
StakeRule: StakeRule.Flat,
|
StakeRule: StakeRule.Flat,
|
||||||
FlatStake: 100m, PercentOfBankroll: 0.02m, KellyFraction: 0.25m);
|
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]
|
[Fact]
|
||||||
public async Task Should_ReturnEmptyResult_When_NoAnomaliesExist()
|
public async Task Should_ReturnEmptyResult_When_NoAnomaliesExist()
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user