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:
@@ -0,0 +1,26 @@
|
||||
using Marathon.Application.Abstractions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Marathon.Application.UseCases;
|
||||
|
||||
/// <summary>
|
||||
/// Removes a saved strategy preset by id. Silent no-op when the id is unknown.
|
||||
/// </summary>
|
||||
public sealed class DeleteStrategyUseCase
|
||||
{
|
||||
private readonly ISavedStrategyRepository _repo;
|
||||
private readonly ILogger<DeleteStrategyUseCase> _logger;
|
||||
|
||||
public DeleteStrategyUseCase(ISavedStrategyRepository repo, ILogger<DeleteStrategyUseCase> logger)
|
||||
{
|
||||
_repo = repo ?? throw new ArgumentNullException(nameof(repo));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task ExecuteAsync(Guid id, CancellationToken ct = default)
|
||||
{
|
||||
await _repo.DeleteAsync(id, ct).ConfigureAwait(false);
|
||||
await _repo.SaveChangesAsync(ct).ConfigureAwait(false);
|
||||
_logger.LogInformation("DeleteStrategyUseCase: removed preset {Id}", id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
using Marathon.Application.Abstractions;
|
||||
using Marathon.Domain.Backtesting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Marathon.Application.UseCases;
|
||||
|
||||
/// <summary>
|
||||
/// Persists a named backtest-strategy preset. Upserts by name: saving under an
|
||||
/// existing name overwrites that preset's configuration (keeping its identity and
|
||||
/// original creation timestamp); a fresh name creates a new preset.
|
||||
/// </summary>
|
||||
public sealed class SaveStrategyUseCase
|
||||
{
|
||||
private readonly ISavedStrategyRepository _repo;
|
||||
private readonly ILogger<SaveStrategyUseCase> _logger;
|
||||
|
||||
public SaveStrategyUseCase(ISavedStrategyRepository repo, ILogger<SaveStrategyUseCase> logger)
|
||||
{
|
||||
_repo = repo ?? throw new ArgumentNullException(nameof(repo));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <summary>Saves <paramref name="strategy"/> under <paramref name="name"/>.</summary>
|
||||
/// <exception cref="ArgumentException">The name is empty or exceeds the length bound.</exception>
|
||||
public async Task<SavedStrategy> ExecuteAsync(
|
||||
string name, BacktestStrategy strategy, CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(strategy);
|
||||
|
||||
// Validates + trims the name once, up front (throws ArgumentException if bad).
|
||||
var candidate = SavedStrategy.Create(name, strategy);
|
||||
|
||||
var existing = await _repo.GetByNameAsync(candidate.Name, ct).ConfigureAwait(false);
|
||||
if (existing is not null)
|
||||
{
|
||||
var updated = existing with { Strategy = strategy };
|
||||
await _repo.UpdateAsync(updated, ct).ConfigureAwait(false);
|
||||
await _repo.SaveChangesAsync(ct).ConfigureAwait(false);
|
||||
_logger.LogInformation(
|
||||
"SaveStrategyUseCase: overwrote preset {Name} ({Id})", updated.Name, updated.Id);
|
||||
return updated;
|
||||
}
|
||||
|
||||
await _repo.AddAsync(candidate, ct).ConfigureAwait(false);
|
||||
await _repo.SaveChangesAsync(ct).ConfigureAwait(false);
|
||||
_logger.LogInformation(
|
||||
"SaveStrategyUseCase: created preset {Name} ({Id})", candidate.Name, candidate.Id);
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user