2a0ea7b3a6
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.
147 lines
5.0 KiB
C#
147 lines
5.0 KiB
C#
using FluentAssertions;
|
|
using Marathon.Domain.Backtesting;
|
|
using Marathon.Infrastructure.Persistence.Repositories;
|
|
using Microsoft.EntityFrameworkCore;
|
|
|
|
namespace Marathon.Infrastructure.Tests.Persistence;
|
|
|
|
/// <summary>
|
|
/// Round-trip + query tests for <see cref="SavedStrategyRepository"/>. Uses the
|
|
/// in-memory SQLite fixture so the table + unique name index declared in the
|
|
/// configuration are exercised on every test.
|
|
/// </summary>
|
|
public sealed class SavedStrategyRoundTripTests : IDisposable
|
|
{
|
|
private readonly InMemoryDbFixture _fixture;
|
|
private readonly SavedStrategyRepository _repo;
|
|
|
|
public SavedStrategyRoundTripTests()
|
|
{
|
|
_fixture = new InMemoryDbFixture();
|
|
_repo = new SavedStrategyRepository(_fixture.DbContext);
|
|
}
|
|
|
|
public void Dispose() => _fixture.Dispose();
|
|
|
|
private static BacktestStrategy Strategy(
|
|
decimal minScore = 0.45m, StakeRule rule = StakeRule.Kelly) =>
|
|
new(
|
|
StartingBankroll: 2000m,
|
|
MinScore: minScore,
|
|
StakeRule: rule,
|
|
FlatStake: 75m,
|
|
PercentOfBankroll: 0.03m,
|
|
KellyFraction: 0.5m);
|
|
|
|
[Fact]
|
|
public async Task RoundTrip_PreservesAllFields()
|
|
{
|
|
var saved = SavedStrategy.Create("Quarter Kelly", Strategy());
|
|
|
|
await _repo.AddAsync(saved);
|
|
await _repo.SaveChangesAsync();
|
|
_fixture.DbContext.ChangeTracker.Clear();
|
|
|
|
var got = await _repo.GetAsync(saved.Id);
|
|
|
|
got.Should().NotBeNull();
|
|
got!.Id.Should().Be(saved.Id);
|
|
got.Name.Should().Be("Quarter Kelly");
|
|
got.Strategy.StartingBankroll.Should().Be(2000m);
|
|
got.Strategy.MinScore.Should().Be(0.45m);
|
|
got.Strategy.StakeRule.Should().Be(StakeRule.Kelly);
|
|
got.Strategy.FlatStake.Should().Be(75m);
|
|
got.Strategy.PercentOfBankroll.Should().Be(0.03m);
|
|
got.Strategy.KellyFraction.Should().Be(0.5m);
|
|
got.CreatedAt.Should().Be(saved.CreatedAt);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetByNameAsync_MatchesTrimmed_AndReturnsNullWhenMissing()
|
|
{
|
|
var saved = SavedStrategy.Create("Aggro", Strategy());
|
|
await _repo.AddAsync(saved);
|
|
await _repo.SaveChangesAsync();
|
|
_fixture.DbContext.ChangeTracker.Clear();
|
|
|
|
(await _repo.GetByNameAsync(" Aggro "))!.Id.Should().Be(saved.Id);
|
|
(await _repo.GetByNameAsync("nope")).Should().BeNull();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Name_IsCaseInsensitive_ForLookupAndUniqueness()
|
|
{
|
|
await _repo.AddAsync(SavedStrategy.Create("Kelly", Strategy()));
|
|
await _repo.SaveChangesAsync();
|
|
_fixture.DbContext.ChangeTracker.Clear();
|
|
|
|
// Lookup folds case (NOCASE column collation).
|
|
(await _repo.GetByNameAsync("kelly")).Should().NotBeNull();
|
|
|
|
// And a case-variant is rejected as a duplicate by the unique index.
|
|
await _repo.AddAsync(SavedStrategy.Create("KELLY", Strategy()));
|
|
var act = async () => await _repo.SaveChangesAsync();
|
|
await act.Should().ThrowAsync<DbUpdateException>();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ListAsync_OrdersByName()
|
|
{
|
|
await _repo.AddAsync(SavedStrategy.Create("Zeta", Strategy()));
|
|
await _repo.AddAsync(SavedStrategy.Create("Alpha", Strategy()));
|
|
await _repo.AddAsync(SavedStrategy.Create("Mu", Strategy()));
|
|
await _repo.SaveChangesAsync();
|
|
_fixture.DbContext.ChangeTracker.Clear();
|
|
|
|
var list = await _repo.ListAsync();
|
|
|
|
list.Select(s => s.Name).Should().ContainInOrder("Alpha", "Mu", "Zeta");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task UpdateAsync_PersistsStrategyChange()
|
|
{
|
|
var saved = SavedStrategy.Create("Tweak me", Strategy(minScore: 0.45m, rule: StakeRule.Kelly));
|
|
await _repo.AddAsync(saved);
|
|
await _repo.SaveChangesAsync();
|
|
_fixture.DbContext.ChangeTracker.Clear();
|
|
|
|
var updated = saved with { Strategy = Strategy(minScore: 0.7m, rule: StakeRule.Flat) };
|
|
await _repo.UpdateAsync(updated);
|
|
await _repo.SaveChangesAsync();
|
|
_fixture.DbContext.ChangeTracker.Clear();
|
|
|
|
var got = await _repo.GetAsync(saved.Id);
|
|
got!.Strategy.MinScore.Should().Be(0.7m);
|
|
got.Strategy.StakeRule.Should().Be(StakeRule.Flat);
|
|
got.Name.Should().Be("Tweak me");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task DeleteAsync_Removes()
|
|
{
|
|
var saved = SavedStrategy.Create("Trash", Strategy());
|
|
await _repo.AddAsync(saved);
|
|
await _repo.SaveChangesAsync();
|
|
_fixture.DbContext.ChangeTracker.Clear();
|
|
|
|
await _repo.DeleteAsync(saved.Id);
|
|
await _repo.SaveChangesAsync();
|
|
|
|
(await _repo.GetAsync(saved.Id)).Should().BeNull();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task UniqueNameIndex_RejectsDuplicateName()
|
|
{
|
|
await _repo.AddAsync(SavedStrategy.Create("dupe", Strategy()));
|
|
await _repo.SaveChangesAsync();
|
|
_fixture.DbContext.ChangeTracker.Clear();
|
|
|
|
await _repo.AddAsync(SavedStrategy.Create("dupe", Strategy()));
|
|
var act = async () => await _repo.SaveChangesAsync();
|
|
|
|
await act.Should().ThrowAsync<DbUpdateException>();
|
|
}
|
|
}
|