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;