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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 0–100 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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user