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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user