2 Commits

Author SHA1 Message Date
alexei.dolgolyov 2e53dff853 feat(settings): validate BaseUrl + cron on save, add BaseUrl hint
- Reject a non-absolute / non-http(s) BaseUrl and an implausible (not 5- or
  6-field) cron expression before the section is written to disk, mirroring the
  existing storage-path validation (snackbar + early return).
- Add a hint to the BaseUrl field. Cron check is a lightweight UI guard; the
  worker still does the authoritative Cronos parse at startup.
2026-05-29 00:50:49 +03:00
alexei.dolgolyov e5cd2ab30c 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).
2026-05-29 00:50:43 +03:00
8 changed files with 122 additions and 6 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"
+24 -1
View File
@@ -49,7 +49,7 @@
<Field Label="@L["Settings.Scraping.RetryBaseDelayMs"]">
<MudNumericField T="int" @bind-Value="_scraping.RetryPolicy.BaseDelayMs" Min="100" Max="60000" Variant="Variant.Outlined" />
</Field>
<Field Label="@L["Settings.Scraping.BaseUrl"]">
<Field Label="@L["Settings.Scraping.BaseUrl"]" Hint="@L["Settings.Scraping.BaseUrl.Hint"]">
<MudTextField T="string" @bind-Value="_scraping.BaseUrl" Variant="Variant.Outlined" />
</Field>
<Field Label="@L["Settings.Scraping.RequestTimeoutSeconds"]">
@@ -242,6 +242,20 @@
}
}
if (payload is ScrapingSettingsForm scraping
&& !(Uri.TryCreate(scraping.BaseUrl, UriKind.Absolute, out var baseUri)
&& (baseUri.Scheme == Uri.UriSchemeHttp || baseUri.Scheme == Uri.UriSchemeHttps)))
{
Snackbar.Add(L["Settings.Scraping.BaseUrl.Invalid"], Severity.Error);
return;
}
if (payload is WorkerOptions workers && !IsPlausibleCron(workers.UpcomingScheduleCron))
{
Snackbar.Add(L["Settings.Workers.Cron.Invalid"], Severity.Error);
return;
}
var confirmed = await ConfirmAsync();
if (!confirmed)
{
@@ -260,6 +274,15 @@
}
}
// Lightweight 5- or 6-field cron sanity check — avoids a Cronos dependency in the
// UI layer; the worker still does the authoritative parse at startup.
private static bool IsPlausibleCron(string? expression)
{
if (string.IsNullOrWhiteSpace(expression)) return false;
var fields = expression.Split(' ', StringSplitOptions.RemoveEmptyEntries);
return fields.Length is 5 or 6;
}
private async Task ResetSectionAsync(string section)
{
var confirmed = await ConfirmAsync();
@@ -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()
{