feat(backtest): saved strategy presets (strategy editor v1)

Persist named backtest-strategy presets so a staking config (bankroll,
min-score, stake rule, flat/percent/Kelly params) can be saved, listed,
loaded back into the form, and deleted. The per-run date range is not
part of a preset.

- Domain: SavedStrategy record (name trimmed + bounded to 80 chars,
  Create() factory) wrapping the pure BacktestStrategy.
- Persistence: SavedStrategyEntity + config (TEXT decimals, unique
  case-insensitive NOCASE index on Name), repository, mapping, and a
  hand-trimmed AddSavedStrategies migration (additive — only the new
  table). Case-insensitive names mean save-by-name overwrites instead of
  creating near-duplicates.
- Application: SaveStrategyUseCase (upsert by name, keeps Id+CreatedAt) +
  DeleteStrategyUseCase.
- UI: presets panel on the Backtest page (load/save/delete) + service
  methods; fraction<->percent round-trip; en/ru resx.
- Fix: pin Sports.Code as ValueGeneratedNever — it is the bookmaker's
  natural sport id, not an autoincrement surrogate. Corrects long-standing
  model-snapshot drift; the snapshot is regenerated to match the DB.
- 25 tests across all four layers: domain validation, real-SQLite
  round-trip incl. case-insensitive lookup/uniqueness, the upsert use
  case, and the service percent mapping.
This commit is contained in:
2026-05-29 02:13:16 +03:00
parent 115872aad0
commit 2a0ea7b3a6
26 changed files with 1845 additions and 160 deletions
@@ -35,6 +35,63 @@
</header>
<article class="m-card m-card--accented m-backtest__form-card">
<div class="m-backtest__presets" data-test="backtest-presets">
<div class="m-backtest__presets-head">
<span class="m-backtest__form-label">@L["Backtest.Presets.Label"]</span>
@if (_strategies.Count > 0)
{
<span class="m-backtest__section-count m-mono">@_strategies.Count</span>
}
</div>
@if (_strategies.Count > 0)
{
<div class="m-backtest__presets-list">
@foreach (var preset in _strategies)
{
var local = preset;
<div class="m-backtest__preset" data-test="backtest-preset" data-strategy-id="@local.Id">
<button type="button"
class="m-backtest__preset-load"
@onclick="() => LoadStrategy(local)"
data-test="backtest-preset-load">
<span class="m-backtest__preset-name">@local.Name</span>
<span class="m-backtest__preset-meta m-mono">@PresetSummary(local)</span>
</button>
<button type="button"
class="m-backtest__preset-del"
@onclick="() => DeleteStrategyAsync(local)"
title="@L["Common.Delete"]"
aria-label="@L["Common.Delete"]"
data-test="backtest-preset-delete">×</button>
</div>
}
</div>
}
else
{
<p class="m-backtest__presets-empty">@L["Backtest.Presets.Empty"]</p>
}
<div class="m-backtest__presets-save">
<MudTextField @bind-Value="_strategyName"
Label="@L["Backtest.Presets.NameLabel"]"
Variant="Variant.Outlined"
Margin="Margin.Dense"
MaxLength="80"
data-test="backtest-preset-name" />
<button type="button"
class="m-chip m-backtest__preset-save-btn"
@onclick="SaveStrategyAsync"
disabled="@_savingStrategy"
data-test="backtest-preset-save">
<span>@L["Backtest.Presets.Save"]</span>
</button>
</div>
</div>
<hr class="m-rule" />
<div class="m-backtest__form-grid">
<div class="m-backtest__form-field">
<label class="m-backtest__form-label">@L["Backtest.Field.Bankroll"]</label>
@@ -421,6 +478,87 @@
.m-backtest__submit-glyph.is-spinning { animation: none; }
}
/* ---- Saved-strategy presets ---- */
.m-backtest__presets { display: grid; gap: var(--m-space-3); }
.m-backtest__presets-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var(--m-space-3);
}
.m-backtest__presets-list {
display: flex;
flex-wrap: wrap;
gap: var(--m-space-2);
}
.m-backtest__preset {
display: inline-flex;
align-items: stretch;
border: 1px solid var(--m-c-rule);
background: var(--m-c-paper);
transition: border-color 120ms ease, background 120ms ease;
}
.m-backtest__preset:hover { border-color: var(--m-c-accent); background: var(--m-c-paper-2); }
.m-backtest__preset-load {
display: grid;
gap: 2px;
padding: 6px 10px;
background: transparent;
border: 0;
cursor: pointer;
text-align: left;
color: inherit;
}
.m-backtest__preset-name {
font-size: 0.8125rem;
font-weight: 600;
color: var(--m-c-ink);
}
.m-backtest__preset-meta {
font-size: 0.6875rem;
letter-spacing: 0.04em;
color: var(--m-c-ink-soft);
}
.m-backtest__preset-del {
align-self: stretch;
padding: 0 10px;
border: 0;
border-left: 1px solid var(--m-c-rule);
background: transparent;
color: var(--m-c-ink-soft);
cursor: pointer;
font-size: 1rem;
line-height: 1;
transition: color 120ms ease, background 120ms ease;
}
.m-backtest__preset-del:hover { color: var(--m-c-anomaly); background: rgba(220, 38, 38, 0.08); }
.m-backtest__presets-empty {
margin: 0;
font-size: 0.8125rem;
color: var(--m-c-ink-soft);
}
.m-backtest__presets-save {
display: flex;
align-items: center;
gap: var(--m-space-3);
flex-wrap: wrap;
}
.m-backtest__presets-save .mud-input-control { flex: 1 1 220px; min-width: 200px; }
.m-backtest__preset-save-btn {
border-color: var(--m-c-ink-soft);
color: var(--m-c-ink);
font-family: var(--m-font-mono);
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.14em;
padding: 8px 16px;
}
.m-backtest__preset-save-btn:not(:disabled):hover { border-color: var(--m-c-accent); color: var(--m-c-accent); }
.m-backtest__preset-save-btn:disabled { opacity: 0.6; cursor: progress; }
@@media (prefers-reduced-motion: reduce) {
.m-backtest__preset, .m-backtest__preset-del, .m-backtest__preset-save-btn { transition: none; }
}
/* ---- KPI strip ---- */
.m-backtest__kpis {
display: grid;
@@ -636,6 +774,89 @@
private string? _formError;
private CancellationTokenSource? _runCts;
private IReadOnlyList<SavedStrategyVm> _strategies = Array.Empty<SavedStrategyVm>();
private string _strategyName = string.Empty;
private bool _savingStrategy;
protected override async Task OnInitializedAsync() => await ReloadStrategiesAsync();
private async Task ReloadStrategiesAsync()
{
try
{
_strategies = await Service.ListStrategiesAsync(CancellationToken.None);
}
catch (Exception ex)
{
Logger.LogError(ex, "Failed to load saved strategies.");
_strategies = Array.Empty<SavedStrategyVm>();
}
}
private void LoadStrategy(SavedStrategyVm preset)
{
preset.ApplyTo(_form);
_strategyName = preset.Name;
_formError = null;
Snackbar.Add(L["Backtest.Presets.Loaded"].Value, Severity.Info);
}
private async Task SaveStrategyAsync()
{
if (_savingStrategy) return;
if (string.IsNullOrWhiteSpace(_strategyName))
{
_formError = L["Backtest.Presets.NameRequired"].Value;
StateHasChanged();
return;
}
_savingStrategy = true;
_formError = null;
try
{
await Service.SaveStrategyAsync(_strategyName, _form, CancellationToken.None);
await ReloadStrategiesAsync();
Snackbar.Add(L["Backtest.Presets.Saved"].Value, Severity.Success);
}
catch (ArgumentException ex)
{
_formError = ex.Message;
}
catch (Exception ex)
{
Logger.LogError(ex, "Failed to save strategy preset.");
Snackbar.Add(L["Backtest.Error.Generic"].Value, Severity.Error);
}
finally
{
_savingStrategy = false;
}
}
private async Task DeleteStrategyAsync(SavedStrategyVm preset)
{
try
{
await Service.DeleteStrategyAsync(preset.Id, CancellationToken.None);
await ReloadStrategiesAsync();
Snackbar.Add(L["Backtest.Presets.Deleted"].Value, Severity.Info);
}
catch (Exception ex)
{
Logger.LogError(ex, "Failed to delete strategy preset.");
Snackbar.Add(L["Backtest.Error.Generic"].Value, Severity.Error);
}
}
private string PresetSummary(SavedStrategyVm s) => string.Format(
CultureInfo.InvariantCulture,
"{0} · ≥{1:0.00} · {2}",
s.StartingBankroll.ToString("0", CultureInfo.InvariantCulture),
s.MinScore,
StakeRuleLabel(s.StakeRule));
private async Task RunAsync()
{
if (_running) return;
@@ -162,6 +162,7 @@
<data name="Common.Reset"><value>Reset</value></data>
<data name="Common.Loading"><value>Loading…</value></data>
<data name="Common.Empty"><value>No data</value></data>
<data name="Common.Delete"><value>Delete</value></data>
<data name="Common.Yes"><value>Yes</value></data>
<data name="Common.No"><value>No</value></data>
@@ -510,4 +511,12 @@
<data name="Backtest.Empty.NoData"><value>No graded anomalies to simulate yet. Run the results loader so the detector has outcomes to replay against.</value></data>
<data name="Backtest.Empty.NoBetsPlaced"><value>The strategy placed zero bets — try lowering the score threshold, or switch staking rule.</value></data>
<data name="Backtest.Error.Generic"><value>Simulation failed — check the form values and try again.</value></data>
<data name="Backtest.Presets.Label"><value>Saved strategies</value></data>
<data name="Backtest.Presets.NameLabel"><value>Preset name</value></data>
<data name="Backtest.Presets.Save"><value>Save preset</value></data>
<data name="Backtest.Presets.Empty"><value>No saved strategies yet — tune the form, name it, and save a reusable preset.</value></data>
<data name="Backtest.Presets.Loaded"><value>Strategy loaded</value></data>
<data name="Backtest.Presets.Saved"><value>Strategy saved</value></data>
<data name="Backtest.Presets.Deleted"><value>Strategy deleted</value></data>
<data name="Backtest.Presets.NameRequired"><value>Enter a name to save this strategy.</value></data>
</root>
@@ -174,6 +174,7 @@
<data name="Common.Reset"><value>Сбросить</value></data>
<data name="Common.Loading"><value>Загрузка…</value></data>
<data name="Common.Empty"><value>Нет данных</value></data>
<data name="Common.Delete"><value>Удалить</value></data>
<data name="Common.Yes"><value>Да</value></data>
<data name="Common.No"><value>Нет</value></data>
@@ -523,4 +524,12 @@
<data name="Backtest.Empty.NoData"><value>Аномалий с результатом ещё нет. Запустите загрузчик результатов, чтобы симулятору было на чём прогоняться.</value></data>
<data name="Backtest.Empty.NoBetsPlaced"><value>Стратегия не сделала ни одной ставки — снизьте порог score или поменяйте правило стейкинга.</value></data>
<data name="Backtest.Error.Generic"><value>Симуляция упала — проверьте параметры формы и повторите.</value></data>
<data name="Backtest.Presets.Label"><value>Сохранённые стратегии</value></data>
<data name="Backtest.Presets.NameLabel"><value>Название пресета</value></data>
<data name="Backtest.Presets.Save"><value>Сохранить пресет</value></data>
<data name="Backtest.Presets.Empty"><value>Сохранённых стратегий пока нет — настройте форму, дайте имя и сохраните пресет для повторного использования.</value></data>
<data name="Backtest.Presets.Loaded"><value>Стратегия загружена</value></data>
<data name="Backtest.Presets.Saved"><value>Стратегия сохранена</value></data>
<data name="Backtest.Presets.Deleted"><value>Стратегия удалена</value></data>
<data name="Backtest.Presets.NameRequired"><value>Введите название, чтобы сохранить стратегию.</value></data>
</root>
+50 -4
View File
@@ -1,19 +1,32 @@
using Marathon.Application.Abstractions;
using Marathon.Application.UseCases;
using Marathon.Domain.Backtesting;
namespace Marathon.UI.Services;
/// <summary>
/// Page-facing implementation of <see cref="IBacktestService"/>. The use case
/// hands back per-event titles inside the result so the service does no
/// repository I/O of its own.
/// Page-facing implementation of <see cref="IBacktestService"/>. The run use case
/// hands back per-event titles inside the result so the service does no repository
/// I/O for runs; saved-strategy reads go straight to the repository, writes through
/// the save/delete use cases.
/// </summary>
public sealed class BacktestService : IBacktestService
{
private readonly RunBacktestUseCase _useCase;
private readonly SaveStrategyUseCase _saveStrategy;
private readonly DeleteStrategyUseCase _deleteStrategy;
private readonly ISavedStrategyRepository _strategies;
public BacktestService(RunBacktestUseCase useCase)
public BacktestService(
RunBacktestUseCase useCase,
SaveStrategyUseCase saveStrategy,
DeleteStrategyUseCase deleteStrategy,
ISavedStrategyRepository strategies)
{
_useCase = useCase ?? throw new ArgumentNullException(nameof(useCase));
_saveStrategy = saveStrategy ?? throw new ArgumentNullException(nameof(saveStrategy));
_deleteStrategy = deleteStrategy ?? throw new ArgumentNullException(nameof(deleteStrategy));
_strategies = strategies ?? throw new ArgumentNullException(nameof(strategies));
}
public async Task<BacktestVm> RunAsync(BacktestForm form, CancellationToken ct)
@@ -58,4 +71,37 @@ public sealed class BacktestService : IBacktestService
Trace: rows,
EquityCurve: curve);
}
public async Task<IReadOnlyList<SavedStrategyVm>> ListStrategiesAsync(CancellationToken ct)
{
var presets = await _strategies.ListAsync(ct).ConfigureAwait(false);
return presets.Select(ToVm).ToList();
}
public async Task<SavedStrategyVm> SaveStrategyAsync(string name, BacktestForm form, CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(form);
// Reuse the form's own validation so an invalid preset can never be persisted.
if (!form.IsValid(out var err))
throw new ArgumentException(err ?? "Invalid form.", nameof(form));
var saved = await _saveStrategy.ExecuteAsync(name, form.ToStrategy(), ct).ConfigureAwait(false);
return ToVm(saved);
}
public Task DeleteStrategyAsync(Guid id, CancellationToken ct) =>
_deleteStrategy.ExecuteAsync(id, ct);
private static SavedStrategyVm ToVm(SavedStrategy s) =>
new(
Id: s.Id,
Name: s.Name,
StartingBankroll: s.Strategy.StartingBankroll,
MinScore: s.Strategy.MinScore,
StakeRule: s.Strategy.StakeRule,
FlatStake: s.Strategy.FlatStake,
// Domain stores fractions; the form/VM speak percentages.
PercentOfBankrollPercent: s.Strategy.PercentOfBankroll * 100m,
KellyFractionPercent: s.Strategy.KellyFraction * 100m);
}
@@ -115,3 +115,35 @@ public sealed record BacktestTraceRow(
/// <param name="DetectedAt">When the bet would have been placed.</param>
/// <param name="Bankroll">Bankroll after this bet settled.</param>
public sealed record EquityPoint(DateTimeOffset DetectedAt, decimal Bankroll);
/// <summary>
/// UI projection of a persisted <see cref="Marathon.Domain.Backtesting.SavedStrategy"/>.
/// Percent fields are pre-scaled to 0100 to match the form bindings; the per-run
/// date range is intentionally not part of a preset.
/// </summary>
public sealed record SavedStrategyVm(
Guid Id,
string Name,
decimal StartingBankroll,
decimal MinScore,
StakeRule StakeRule,
decimal FlatStake,
decimal PercentOfBankrollPercent,
decimal KellyFractionPercent)
{
/// <summary>
/// Copies this preset's staking parameters onto <paramref name="form"/>, leaving
/// the form's <see cref="BacktestForm.From"/>/<see cref="BacktestForm.To"/> date
/// range untouched (scope is a per-run choice, not part of the preset).
/// </summary>
public void ApplyTo(BacktestForm form)
{
ArgumentNullException.ThrowIfNull(form);
form.StartingBankroll = StartingBankroll;
form.MinScore = MinScore;
form.StakeRule = StakeRule;
form.FlatStake = FlatStake;
form.PercentOfBankrollPercent = PercentOfBankrollPercent;
form.KellyFractionPercent = KellyFractionPercent;
}
}
@@ -10,4 +10,17 @@ public interface IBacktestService
/// <summary>Validates the form, runs the simulator, projects for the UI.</summary>
/// <exception cref="ArgumentException">Form fails its own validation.</exception>
Task<BacktestVm> RunAsync(BacktestForm form, CancellationToken ct);
/// <summary>Every saved strategy preset, name-ascending.</summary>
Task<IReadOnlyList<SavedStrategyVm>> ListStrategiesAsync(CancellationToken ct);
/// <summary>
/// Saves the current form as a named preset (upsert by name). Validates the
/// form first, exactly like <see cref="RunAsync"/>.
/// </summary>
/// <exception cref="ArgumentException">Form is invalid, or the name is empty/too long.</exception>
Task<SavedStrategyVm> SaveStrategyAsync(string name, BacktestForm form, CancellationToken ct);
/// <summary>Deletes a saved preset by id. No-op when the id is unknown.</summary>
Task DeleteStrategyAsync(Guid id, CancellationToken ct);
}