diff --git a/src/Marathon.Application/UseCases/RunBacktestUseCase.cs b/src/Marathon.Application/UseCases/RunBacktestUseCase.cs index 75247de..4f3e216 100644 --- a/src/Marathon.Application/UseCases/RunBacktestUseCase.cs +++ b/src/Marathon.Application/UseCases/RunBacktestUseCase.cs @@ -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 ExecuteAsync( + /// Runs the backtest over every graded anomaly (no date filter). + public Task ExecuteAsync( BacktestStrategy strategy, CancellationToken ct = default) + => ExecuteAsync(strategy, dateRange: null, ct); + + /// + /// Runs the backtest over anomalies detected within + /// (inclusive); pass null to include every graded anomaly. The date filter + /// is pushed to SQL via . + /// + public async Task 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"); diff --git a/src/Marathon.UI/Pages/Anomalies/Backtest.razor b/src/Marathon.UI/Pages/Anomalies/Backtest.razor index 9b81c55..a4458d2 100644 --- a/src/Marathon.UI/Pages/Anomalies/Backtest.razor +++ b/src/Marathon.UI/Pages/Anomalies/Backtest.razor @@ -46,6 +46,25 @@ data-test="backtest-bankroll" /> +
+ + +
+ +
+ + + @L["Backtest.Field.DateRange.Hint"] +
+
Rate limit (RPS) Requests per second. 1 is recommended. Base URL + Must be an absolute http(s) URL, e.g. https://www.marathonbet.by + Base URL must be an absolute http(s) address. + Schedule must be a 5- or 6-field cron expression. Request timeout (sec) Use Playwright @@ -445,6 +448,9 @@ Equity curve Trade trace Starting bankroll + From date + To date + Leave both empty to backtest every graded anomaly. Min anomaly score Only bet anomalies at or above this confidence. Staking rule diff --git a/src/Marathon.UI/Resources/SharedResource.ru.resx b/src/Marathon.UI/Resources/SharedResource.ru.resx index 4663c71..ff46fe1 100644 --- a/src/Marathon.UI/Resources/SharedResource.ru.resx +++ b/src/Marathon.UI/Resources/SharedResource.ru.resx @@ -121,6 +121,9 @@ Лимит RPS Запросов в секунду. Рекомендовано 1. Базовый URL + Должен быть абсолютный http(s)-адрес, например https://www.marathonbet.by + Базовый URL должен быть абсолютным http(s)-адресом. + Расписание должно быть cron-выражением из 5 или 6 полей. Тайм-аут запроса (сек) Использовать Playwright @@ -458,6 +461,9 @@ Кривая банка Хронология ставок Стартовый банк + Дата с + Дата по + Оставьте оба поля пустыми, чтобы прогнать все оценённые аномалии. Мин. score аномалии Ставим только при уверенности не ниже этого порога. Правило стейкинга diff --git a/src/Marathon.UI/Services/BacktestService.cs b/src/Marathon.UI/Services/BacktestService.cs index ff13f71..a641052 100644 --- a/src/Marathon.UI/Services/BacktestService.cs +++ b/src/Marathon.UI/Services/BacktestService.cs @@ -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( diff --git a/src/Marathon.UI/Services/BacktestViewModels.cs b/src/Marathon.UI/Services/BacktestViewModels.cs index 057f8a7..2f35cc7 100644 --- a/src/Marathon.UI/Services/BacktestViewModels.cs +++ b/src/Marathon.UI/Services/BacktestViewModels.cs @@ -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 /// Bound to the UI as a percentage 0–100; converted to a fraction before sim. public decimal KellyFractionPercent { get; set; } = 25m; + /// Optional inclusive date-range filter (Moscow dates). Both null = all anomalies. + public DateTime? From { get; set; } + + /// Optional inclusive date-range filter (Moscow dates). Both null = all anomalies. + 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); + + /// + /// The inclusive Moscow-day date range, or null when either bound is unset + /// (meaning: run over every graded anomaly). + /// + 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))); + } } /// UI-facing projection of . diff --git a/tests/Marathon.Application.Tests/UseCases/RunBacktestUseCaseTests.cs b/tests/Marathon.Application.Tests/UseCases/RunBacktestUseCaseTests.cs index e1b37c9..280efd8 100644 --- a/tests/Marathon.Application.Tests/UseCases/RunBacktestUseCaseTests.cs +++ b/tests/Marathon.Application.Tests/UseCases/RunBacktestUseCaseTests.cs @@ -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(), Arg.Any(), Arg.Any()) + .Returns(new[] { MakeAnomaly(id) }.ToList().AsReadOnly()); + _events.GetAsync(id, Arg.Any()).Returns(MakeEvent(id)); + _results.GetAsync(id, Arg.Any()) + .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(), Arg.Any(), Arg.Any()); + await _anomalies.DidNotReceive().ListAsync(Arg.Any()); + result.BetsPlaced.Should().BeGreaterThan(0, "the in-range graded anomaly produces a bet"); + } + [Fact] public async Task Should_ReturnEmptyResult_When_NoAnomaliesExist() {