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
@@ -0,0 +1,17 @@
using Marathon.Domain.Backtesting;
namespace Marathon.Application.Abstractions;
/// <summary>
/// Repository for <see cref="SavedStrategy"/> presets — the user's named,
/// reusable backtest staking configurations. <see cref="IRepository{TKey,TEntity}.ListAsync"/>
/// returns them name-ascending for a stable picker order.
/// </summary>
public interface ISavedStrategyRepository : IRepository<Guid, SavedStrategy>
{
/// <summary>
/// The preset whose (trimmed) name matches <paramref name="name"/>, or null.
/// Used by the save flow to upsert by name rather than create a duplicate.
/// </summary>
Task<SavedStrategy?> GetByNameAsync(string name, CancellationToken ct = default);
}
@@ -39,6 +39,8 @@ public static class ApplicationModule
services.AddScoped<DeletePlacedBetUseCase>();
services.AddScoped<RunBacktestUseCase>();
services.AddScoped<SaveStrategyUseCase>();
services.AddScoped<DeleteStrategyUseCase>();
return services;
}
@@ -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;
}
}
@@ -0,0 +1,51 @@
using Marathon.Domain.ValueObjects;
namespace Marathon.Domain.Backtesting;
/// <summary>
/// A named, persisted <see cref="BacktestStrategy"/> — the user's reusable
/// staking preset. The wrapped <see cref="Strategy"/> carries every simulation
/// parameter (bankroll, threshold, stake rule); the date-range scope of a run
/// is deliberately NOT stored here, since that is a per-run choice rather than
/// a property of the strategy itself.
/// </summary>
/// <param name="Id">Stable identity, assigned once at creation.</param>
/// <param name="Name">
/// User-supplied label. Trimmed and bounded to <see cref="MaxNameLength"/>;
/// names are unique across the store (enforced by the persistence layer).
/// </param>
/// <param name="Strategy">The staking configuration this preset captures.</param>
/// <param name="CreatedAt">When the preset was first saved (Moscow time).</param>
public sealed record SavedStrategy(
Guid Id,
string Name,
BacktestStrategy Strategy,
DateTimeOffset CreatedAt)
{
/// <summary>Maximum length of a trimmed strategy name.</summary>
public const int MaxNameLength = 80;
public string Name { get; } = NormalizeName(Name);
/// <summary>
/// Builds a brand-new preset with a fresh identity and the current Moscow
/// timestamp. Use this for "Save"; use <c>with</c> to amend an existing one.
/// </summary>
public static SavedStrategy Create(string name, BacktestStrategy strategy)
{
ArgumentNullException.ThrowIfNull(strategy);
return new SavedStrategy(Guid.NewGuid(), name, strategy, MoscowTime.Now);
}
private static string NormalizeName(string name)
{
ArgumentNullException.ThrowIfNull(name);
var trimmed = name.Trim();
if (trimmed.Length == 0)
throw new ArgumentException("Strategy name must not be empty.", nameof(name));
if (trimmed.Length > MaxNameLength)
throw new ArgumentException(
$"Strategy name must be at most {MaxNameLength} characters.", nameof(name));
return trimmed;
}
}
@@ -0,0 +1,391 @@
// <auto-generated />
using System;
using Marathon.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace Marathon.Infrastructure.Migrations
{
[DbContext(typeof(MarathonDbContext))]
[Migration("20260528225529_AddSavedStrategies")]
partial class AddSavedStrategies
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "8.0.12");
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.AnomalyEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT");
b.Property<string>("DetectedAt")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("EventCode")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("EvidenceJson")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("Kind")
.HasColumnType("INTEGER");
b.Property<decimal>("Score")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("EventCode")
.HasDatabaseName("IX_Anomalies_EventCode");
b.ToTable("Anomalies", (string)null);
});
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.BetEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int?>("PeriodNumber")
.HasColumnType("INTEGER");
b.Property<decimal>("Rate")
.HasColumnType("TEXT");
b.Property<int>("Scope")
.HasColumnType("INTEGER");
b.Property<int>("Side")
.HasColumnType("INTEGER");
b.Property<long>("SnapshotId")
.HasColumnType("INTEGER");
b.Property<int>("Type")
.HasColumnType("INTEGER");
b.Property<decimal?>("Value")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("SnapshotId")
.HasDatabaseName("IX_Bets_SnapshotId");
b.ToTable("Bets", (string)null);
});
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.EventEntity", b =>
{
b.Property<string>("EventCode")
.HasColumnType("TEXT");
b.Property<string>("Category")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("");
b.Property<string>("CountryCode")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("EventPath")
.HasColumnType("TEXT");
b.Property<string>("LeagueId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("ScheduledAt")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Side1Name")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Side2Name")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("SportCode")
.HasColumnType("INTEGER");
b.HasKey("EventCode");
b.HasIndex("ScheduledAt")
.HasDatabaseName("IX_Events_ScheduledAt");
b.HasIndex("SportCode", "ScheduledAt")
.HasDatabaseName("IX_Events_SportCode_ScheduledAt");
b.ToTable("Events", (string)null);
});
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.EventResultEntity", b =>
{
b.Property<string>("EventCode")
.HasColumnType("TEXT");
b.Property<string>("CompletedAt")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("Side1Score")
.HasColumnType("INTEGER");
b.Property<int>("Side2Score")
.HasColumnType("INTEGER");
b.Property<int>("WinnerSide")
.HasColumnType("INTEGER");
b.HasKey("EventCode");
b.ToTable("EventResults", (string)null);
});
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.LeagueEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT");
b.Property<string>("Category")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("");
b.Property<string>("Country")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("NameEn")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("NameRu")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("SportCode")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.ToTable("Leagues", (string)null);
});
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.PlacedBetEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT");
b.Property<string>("EventCode")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Notes")
.HasColumnType("TEXT");
b.Property<int>("Outcome")
.HasColumnType("INTEGER");
b.Property<int?>("PeriodNumber")
.HasColumnType("INTEGER");
b.Property<string>("PlacedAt")
.IsRequired()
.HasColumnType("TEXT");
b.Property<decimal>("Rate")
.HasColumnType("TEXT");
b.Property<int>("Scope")
.HasColumnType("INTEGER");
b.Property<int>("Side")
.HasColumnType("INTEGER");
b.Property<decimal>("Stake")
.HasColumnType("TEXT");
b.Property<int>("Type")
.HasColumnType("INTEGER");
b.Property<decimal?>("Value")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("EventCode")
.HasDatabaseName("IX_PlacedBets_EventCode");
b.HasIndex("Outcome")
.HasDatabaseName("IX_PlacedBets_Outcome");
b.ToTable("PlacedBets", (string)null);
});
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.SavedStrategyEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT");
b.Property<string>("CreatedAt")
.IsRequired()
.HasColumnType("TEXT");
b.Property<decimal>("FlatStake")
.HasColumnType("TEXT");
b.Property<decimal>("KellyFraction")
.HasColumnType("TEXT");
b.Property<decimal>("MinScore")
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT");
b.Property<decimal>("PercentOfBankroll")
.HasColumnType("TEXT");
b.Property<int>("StakeRule")
.HasColumnType("INTEGER");
b.Property<decimal>("StartingBankroll")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Name")
.IsUnique()
.HasDatabaseName("IX_SavedStrategies_Name");
b.ToTable("SavedStrategies", (string)null);
});
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.SnapshotEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("CapturedAt")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("EventCode")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("Source")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("EventCode")
.HasDatabaseName("IX_Snapshots_EventCode");
b.HasIndex("EventCode", "CapturedAt")
.HasDatabaseName("IX_Snapshots_EventCode_CapturedAt");
b.HasIndex("EventCode", "Source", "CapturedAt")
.HasDatabaseName("IX_Snapshots_EventCode_Source_CapturedAt");
b.ToTable("Snapshots", (string)null);
});
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.SportEntity", b =>
{
b.Property<int>("Code")
.HasColumnType("INTEGER");
b.Property<string>("NameEn")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("NameRu")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Code");
b.ToTable("Sports", (string)null);
});
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.AnomalyEntity", b =>
{
b.HasOne("Marathon.Infrastructure.Persistence.Entities.EventEntity", "Event")
.WithMany("Anomalies")
.HasForeignKey("EventCode")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Event");
});
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.BetEntity", b =>
{
b.HasOne("Marathon.Infrastructure.Persistence.Entities.SnapshotEntity", "Snapshot")
.WithMany("Bets")
.HasForeignKey("SnapshotId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Snapshot");
});
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.EventResultEntity", b =>
{
b.HasOne("Marathon.Infrastructure.Persistence.Entities.EventEntity", "Event")
.WithOne("Result")
.HasForeignKey("Marathon.Infrastructure.Persistence.Entities.EventResultEntity", "EventCode")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Event");
});
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.SnapshotEntity", b =>
{
b.HasOne("Marathon.Infrastructure.Persistence.Entities.EventEntity", "Event")
.WithMany("Snapshots")
.HasForeignKey("EventCode")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Event");
});
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.EventEntity", b =>
{
b.Navigation("Anomalies");
b.Navigation("Result");
b.Navigation("Snapshots");
});
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.SnapshotEntity", b =>
{
b.Navigation("Bets");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,46 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Marathon.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddSavedStrategies : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "SavedStrategies",
columns: table => new
{
Id = table.Column<string>(type: "TEXT", nullable: false),
Name = table.Column<string>(type: "TEXT", nullable: false, collation: "NOCASE"),
StartingBankroll = table.Column<decimal>(type: "TEXT", nullable: false),
MinScore = table.Column<decimal>(type: "TEXT", nullable: false),
StakeRule = table.Column<int>(type: "INTEGER", nullable: false),
FlatStake = table.Column<decimal>(type: "TEXT", nullable: false),
PercentOfBankroll = table.Column<decimal>(type: "TEXT", nullable: false),
KellyFraction = table.Column<decimal>(type: "TEXT", nullable: false),
CreatedAt = table.Column<string>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_SavedStrategies", x => x.Id);
});
migrationBuilder.CreateIndex(
name: "IX_SavedStrategies_Name",
table: "SavedStrategies",
column: "Name",
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "SavedStrategies");
}
}
}
@@ -1,4 +1,5 @@
// <auto-generated />
// <auto-generated />
using System;
using Marathon.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
@@ -6,177 +7,383 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace Marathon.Infrastructure.Migrations;
[DbContext(typeof(MarathonDbContext))]
partial class MarathonDbContextModelSnapshot : ModelSnapshot
namespace Marathon.Infrastructure.Migrations
{
protected override void BuildModel(ModelBuilder modelBuilder)
[DbContext(typeof(MarathonDbContext))]
partial class MarathonDbContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "8.0.12");
modelBuilder.HasAnnotation("ProductVersion", "8.0.12");
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.AnomalyEntity", b =>
{
b.Property<string>("Id").HasColumnType("TEXT");
b.Property<string>("DetectedAt").IsRequired().HasColumnType("TEXT");
b.Property<string>("EventCode").IsRequired().HasColumnType("TEXT");
b.Property<string>("EvidenceJson").IsRequired().HasColumnType("TEXT");
b.Property<int>("Kind").HasColumnType("INTEGER");
b.Property<decimal>("Score").HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("EventCode").HasDatabaseName("IX_Anomalies_EventCode");
b.ToTable("Anomalies");
});
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.AnomalyEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT");
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.BetEntity", b =>
{
b.Property<long>("Id").ValueGeneratedOnAdd().HasColumnType("INTEGER");
b.Property<int?>("PeriodNumber").HasColumnType("INTEGER");
b.Property<decimal>("Rate").HasColumnType("TEXT");
b.Property<int>("Scope").HasColumnType("INTEGER");
b.Property<int>("Side").HasColumnType("INTEGER");
b.Property<long>("SnapshotId").HasColumnType("INTEGER");
b.Property<int>("Type").HasColumnType("INTEGER");
b.Property<decimal?>("Value").HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("SnapshotId").HasDatabaseName("IX_Bets_SnapshotId");
b.ToTable("Bets");
});
b.Property<string>("DetectedAt")
.IsRequired()
.HasColumnType("TEXT");
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.EventEntity", b =>
{
b.Property<string>("EventCode").HasColumnType("TEXT");
b.Property<string>("Category").IsRequired().HasDefaultValue("").HasColumnType("TEXT");
b.Property<string>("CountryCode").IsRequired().HasColumnType("TEXT");
b.Property<string>("EventPath").HasColumnType("TEXT");
b.Property<string>("LeagueId").IsRequired().HasColumnType("TEXT");
b.Property<string>("ScheduledAt").IsRequired().HasColumnType("TEXT");
b.Property<string>("Side1Name").IsRequired().HasColumnType("TEXT");
b.Property<string>("Side2Name").IsRequired().HasColumnType("TEXT");
b.Property<int>("SportCode").HasColumnType("INTEGER");
b.HasKey("EventCode");
b.HasIndex(new[] { "SportCode", "ScheduledAt" }).HasDatabaseName("IX_Events_SportCode_ScheduledAt");
b.HasIndex("ScheduledAt").HasDatabaseName("IX_Events_ScheduledAt");
b.ToTable("Events");
});
b.Property<string>("EventCode")
.IsRequired()
.HasColumnType("TEXT");
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.EventResultEntity", b =>
{
b.Property<string>("EventCode").HasColumnType("TEXT");
b.Property<string>("CompletedAt").IsRequired().HasColumnType("TEXT");
b.Property<int>("Side1Score").HasColumnType("INTEGER");
b.Property<int>("Side2Score").HasColumnType("INTEGER");
b.Property<int>("WinnerSide").HasColumnType("INTEGER");
b.HasKey("EventCode");
b.ToTable("EventResults");
});
b.Property<string>("EvidenceJson")
.IsRequired()
.HasColumnType("TEXT");
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.LeagueEntity", b =>
{
b.Property<string>("Id").HasColumnType("TEXT");
b.Property<string>("Category").IsRequired().HasDefaultValue("").HasColumnType("TEXT");
b.Property<string>("Country").IsRequired().HasColumnType("TEXT");
b.Property<string>("NameEn").IsRequired().HasColumnType("TEXT");
b.Property<string>("NameRu").IsRequired().HasColumnType("TEXT");
b.Property<int>("SportCode").HasColumnType("INTEGER");
b.HasKey("Id");
b.ToTable("Leagues");
});
b.Property<int>("Kind")
.HasColumnType("INTEGER");
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.SnapshotEntity", b =>
{
b.Property<long>("Id").ValueGeneratedOnAdd().HasColumnType("INTEGER");
b.Property<string>("CapturedAt").IsRequired().HasColumnType("TEXT");
b.Property<string>("EventCode").IsRequired().HasColumnType("TEXT");
b.Property<int>("Source").HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("EventCode").HasDatabaseName("IX_Snapshots_EventCode");
b.HasIndex("EventCode", "CapturedAt").HasDatabaseName("IX_Snapshots_EventCode_CapturedAt");
b.HasIndex("EventCode", "Source", "CapturedAt").HasDatabaseName("IX_Snapshots_EventCode_Source_CapturedAt");
b.ToTable("Snapshots");
});
b.Property<decimal>("Score")
.HasColumnType("TEXT");
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.SportEntity", b =>
{
b.Property<int>("Code").HasColumnType("INTEGER");
b.Property<string>("NameEn").IsRequired().HasColumnType("TEXT");
b.Property<string>("NameRu").IsRequired().HasColumnType("TEXT");
b.HasKey("Code");
b.ToTable("Sports");
});
b.HasKey("Id");
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.PlacedBetEntity", b =>
{
b.Property<string>("Id").HasColumnType("TEXT");
b.Property<string>("EventCode").IsRequired().HasColumnType("TEXT");
b.Property<int>("Scope").HasColumnType("INTEGER");
b.Property<int?>("PeriodNumber").HasColumnType("INTEGER");
b.Property<int>("Type").HasColumnType("INTEGER");
b.Property<int>("Side").HasColumnType("INTEGER");
b.Property<decimal?>("Value").HasColumnType("TEXT");
b.Property<decimal>("Rate").HasColumnType("TEXT");
b.Property<decimal>("Stake").HasColumnType("TEXT");
b.Property<string>("PlacedAt").IsRequired().HasColumnType("TEXT");
b.Property<int>("Outcome").HasColumnType("INTEGER");
b.Property<string>("Notes").HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("EventCode").HasDatabaseName("IX_PlacedBets_EventCode");
b.HasIndex("Outcome").HasDatabaseName("IX_PlacedBets_Outcome");
b.ToTable("PlacedBets");
});
b.HasIndex("EventCode")
.HasDatabaseName("IX_Anomalies_EventCode");
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.AnomalyEntity", b =>
{
b.HasOne("Marathon.Infrastructure.Persistence.Entities.EventEntity", "Event")
.WithMany("Anomalies")
.HasForeignKey("EventCode")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Event");
});
b.ToTable("Anomalies", (string)null);
});
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.BetEntity", b =>
{
b.HasOne("Marathon.Infrastructure.Persistence.Entities.SnapshotEntity", "Snapshot")
.WithMany("Bets")
.HasForeignKey("SnapshotId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Snapshot");
});
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.BetEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.EventResultEntity", b =>
{
b.HasOne("Marathon.Infrastructure.Persistence.Entities.EventEntity", "Event")
.WithOne("Result")
.HasForeignKey("Marathon.Infrastructure.Persistence.Entities.EventResultEntity", "EventCode")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Event");
});
b.Property<int?>("PeriodNumber")
.HasColumnType("INTEGER");
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.SnapshotEntity", b =>
{
b.HasOne("Marathon.Infrastructure.Persistence.Entities.EventEntity", "Event")
.WithMany("Snapshots")
.HasForeignKey("EventCode")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Event");
});
b.Property<decimal>("Rate")
.HasColumnType("TEXT");
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.EventEntity", b =>
{
b.Navigation("Anomalies");
b.Navigation("Result");
b.Navigation("Snapshots");
});
b.Property<int>("Scope")
.HasColumnType("INTEGER");
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.SnapshotEntity", b =>
{
b.Navigation("Bets");
});
b.Property<int>("Side")
.HasColumnType("INTEGER");
b.Property<long>("SnapshotId")
.HasColumnType("INTEGER");
b.Property<int>("Type")
.HasColumnType("INTEGER");
b.Property<decimal?>("Value")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("SnapshotId")
.HasDatabaseName("IX_Bets_SnapshotId");
b.ToTable("Bets", (string)null);
});
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.EventEntity", b =>
{
b.Property<string>("EventCode")
.HasColumnType("TEXT");
b.Property<string>("Category")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("");
b.Property<string>("CountryCode")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("EventPath")
.HasColumnType("TEXT");
b.Property<string>("LeagueId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("ScheduledAt")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Side1Name")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Side2Name")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("SportCode")
.HasColumnType("INTEGER");
b.HasKey("EventCode");
b.HasIndex("ScheduledAt")
.HasDatabaseName("IX_Events_ScheduledAt");
b.HasIndex("SportCode", "ScheduledAt")
.HasDatabaseName("IX_Events_SportCode_ScheduledAt");
b.ToTable("Events", (string)null);
});
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.EventResultEntity", b =>
{
b.Property<string>("EventCode")
.HasColumnType("TEXT");
b.Property<string>("CompletedAt")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("Side1Score")
.HasColumnType("INTEGER");
b.Property<int>("Side2Score")
.HasColumnType("INTEGER");
b.Property<int>("WinnerSide")
.HasColumnType("INTEGER");
b.HasKey("EventCode");
b.ToTable("EventResults", (string)null);
});
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.LeagueEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT");
b.Property<string>("Category")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("");
b.Property<string>("Country")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("NameEn")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("NameRu")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("SportCode")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.ToTable("Leagues", (string)null);
});
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.PlacedBetEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT");
b.Property<string>("EventCode")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Notes")
.HasColumnType("TEXT");
b.Property<int>("Outcome")
.HasColumnType("INTEGER");
b.Property<int?>("PeriodNumber")
.HasColumnType("INTEGER");
b.Property<string>("PlacedAt")
.IsRequired()
.HasColumnType("TEXT");
b.Property<decimal>("Rate")
.HasColumnType("TEXT");
b.Property<int>("Scope")
.HasColumnType("INTEGER");
b.Property<int>("Side")
.HasColumnType("INTEGER");
b.Property<decimal>("Stake")
.HasColumnType("TEXT");
b.Property<int>("Type")
.HasColumnType("INTEGER");
b.Property<decimal?>("Value")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("EventCode")
.HasDatabaseName("IX_PlacedBets_EventCode");
b.HasIndex("Outcome")
.HasDatabaseName("IX_PlacedBets_Outcome");
b.ToTable("PlacedBets", (string)null);
});
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.SavedStrategyEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT");
b.Property<string>("CreatedAt")
.IsRequired()
.HasColumnType("TEXT");
b.Property<decimal>("FlatStake")
.HasColumnType("TEXT");
b.Property<decimal>("KellyFraction")
.HasColumnType("TEXT");
b.Property<decimal>("MinScore")
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT")
.UseCollation("NOCASE");
b.Property<decimal>("PercentOfBankroll")
.HasColumnType("TEXT");
b.Property<int>("StakeRule")
.HasColumnType("INTEGER");
b.Property<decimal>("StartingBankroll")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Name")
.IsUnique()
.HasDatabaseName("IX_SavedStrategies_Name");
b.ToTable("SavedStrategies", (string)null);
});
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.SnapshotEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("CapturedAt")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("EventCode")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("Source")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("EventCode")
.HasDatabaseName("IX_Snapshots_EventCode");
b.HasIndex("EventCode", "CapturedAt")
.HasDatabaseName("IX_Snapshots_EventCode_CapturedAt");
b.HasIndex("EventCode", "Source", "CapturedAt")
.HasDatabaseName("IX_Snapshots_EventCode_Source_CapturedAt");
b.ToTable("Snapshots", (string)null);
});
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.SportEntity", b =>
{
b.Property<int>("Code")
.HasColumnType("INTEGER");
b.Property<string>("NameEn")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("NameRu")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Code");
b.ToTable("Sports", (string)null);
});
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.AnomalyEntity", b =>
{
b.HasOne("Marathon.Infrastructure.Persistence.Entities.EventEntity", "Event")
.WithMany("Anomalies")
.HasForeignKey("EventCode")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Event");
});
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.BetEntity", b =>
{
b.HasOne("Marathon.Infrastructure.Persistence.Entities.SnapshotEntity", "Snapshot")
.WithMany("Bets")
.HasForeignKey("SnapshotId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Snapshot");
});
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.EventResultEntity", b =>
{
b.HasOne("Marathon.Infrastructure.Persistence.Entities.EventEntity", "Event")
.WithOne("Result")
.HasForeignKey("Marathon.Infrastructure.Persistence.Entities.EventResultEntity", "EventCode")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Event");
});
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.SnapshotEntity", b =>
{
b.HasOne("Marathon.Infrastructure.Persistence.Entities.EventEntity", "Event")
.WithMany("Snapshots")
.HasForeignKey("EventCode")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Event");
});
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.EventEntity", b =>
{
b.Navigation("Anomalies");
b.Navigation("Result");
b.Navigation("Snapshots");
});
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.SnapshotEntity", b =>
{
b.Navigation("Bets");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,35 @@
using Marathon.Infrastructure.Persistence.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Marathon.Infrastructure.Persistence.Configurations;
internal sealed class SavedStrategyConfiguration : IEntityTypeConfiguration<SavedStrategyEntity>
{
public void Configure(EntityTypeBuilder<SavedStrategyEntity> builder)
{
builder.ToTable("SavedStrategies");
builder.HasKey(s => s.Id);
builder.Property(s => s.Id).HasColumnType("TEXT").IsRequired();
// NOCASE so the unique index and the GetByNameAsync lookup both treat names
// case-insensitively (ASCII) — "Kelly" and "kelly" are the same preset, and
// save-by-name overwrites rather than creating a near-duplicate.
builder.Property(s => s.Name).HasColumnType("TEXT").UseCollation("NOCASE").IsRequired();
builder.Property(s => s.StartingBankroll).HasColumnType("TEXT").IsRequired();
builder.Property(s => s.MinScore).HasColumnType("TEXT").IsRequired();
builder.Property(s => s.StakeRule).HasColumnType("INTEGER").IsRequired();
builder.Property(s => s.FlatStake).HasColumnType("TEXT").IsRequired();
builder.Property(s => s.PercentOfBankroll).HasColumnType("TEXT").IsRequired();
builder.Property(s => s.KellyFraction).HasColumnType("TEXT").IsRequired();
builder.Property(s => s.CreatedAt).HasColumnType("TEXT").IsRequired();
// Names are the user-facing identity for save/overwrite, so they must be
// unique — the SaveStrategyUseCase upserts by name and the index backstops
// any race that would otherwise create a duplicate.
builder.HasIndex(s => s.Name)
.IsUnique()
.HasDatabaseName("IX_SavedStrategies_Name");
}
}
@@ -11,7 +11,11 @@ internal sealed class SportConfiguration : IEntityTypeConfiguration<SportEntity>
builder.ToTable("Sports");
builder.HasKey(s => s.Code);
builder.Property(s => s.Code).HasColumnType("INTEGER").IsRequired();
// Code is the bookmaker's canonical sport id (6 = Basketball, 11 = Football,
// 22723 = Tennis, …), a natural key — never an auto-incremented surrogate.
// Without this, EF's int-PK convention treats it as ValueGeneratedOnAdd and
// tries to alter the column to AUTOINCREMENT on the next migration.
builder.Property(s => s.Code).HasColumnType("INTEGER").ValueGeneratedNever().IsRequired();
builder.Property(s => s.NameRu).HasColumnType("TEXT").IsRequired();
builder.Property(s => s.NameEn).HasColumnType("TEXT").IsRequired();
}
@@ -0,0 +1,33 @@
namespace Marathon.Infrastructure.Persistence.Entities;
/// <summary>
/// EF Core persistence entity for a <see cref="Marathon.Domain.Backtesting.SavedStrategy"/>.
/// Flattens the wrapped <c>BacktestStrategy</c> parameters into columns; decimals
/// are stored as TEXT (invariant round-trip) to match the rest of the schema.
/// </summary>
public sealed class SavedStrategyEntity
{
/// <summary>GUID primary key stored as TEXT.</summary>
public string Id { get; set; } = default!;
/// <summary>User-supplied label; unique across the store.</summary>
public string Name { get; set; } = default!;
// ─── Flattened BacktestStrategy ──────────────────────────────────────────
public decimal StartingBankroll { get; set; }
public decimal MinScore { get; set; }
/// <summary>StakeRule as int (Flat / PercentOfBankroll / Kelly).</summary>
public int StakeRule { get; set; }
public decimal FlatStake { get; set; }
/// <summary>Fraction in (0, 1] — e.g. 0.02 = 2% of bankroll.</summary>
public decimal PercentOfBankroll { get; set; }
/// <summary>Kelly multiplier in (0, 1] — e.g. 0.25 = quarter-Kelly.</summary>
public decimal KellyFraction { get; set; }
/// <summary>ISO 8601 timestamp when the preset was first saved (Moscow time).</summary>
public string CreatedAt { get; set; } = default!;
}
@@ -1,3 +1,4 @@
using Marathon.Domain.Backtesting;
using Marathon.Domain.Entities;
using Marathon.Domain.Enums;
using Marathon.Domain.ValueObjects;
@@ -222,4 +223,33 @@ internal static class Mapping
NameRu: entity.NameRu,
NameEn: entity.NameEn,
Category: entity.Category);
// ─── SavedStrategy ─────────────────────────────────────────────────────────
public static SavedStrategyEntity ToEntity(SavedStrategy domain) =>
new()
{
Id = domain.Id.ToString(),
Name = domain.Name,
StartingBankroll = domain.Strategy.StartingBankroll,
MinScore = domain.Strategy.MinScore,
StakeRule = (int)domain.Strategy.StakeRule,
FlatStake = domain.Strategy.FlatStake,
PercentOfBankroll = domain.Strategy.PercentOfBankroll,
KellyFraction = domain.Strategy.KellyFraction,
CreatedAt = SqliteDateText.Key(domain.CreatedAt),
};
public static SavedStrategy ToDomain(SavedStrategyEntity entity) =>
new(
Id: Guid.Parse(entity.Id),
Name: entity.Name,
Strategy: new BacktestStrategy(
StartingBankroll: entity.StartingBankroll,
MinScore: entity.MinScore,
StakeRule: (StakeRule)entity.StakeRule,
FlatStake: entity.FlatStake,
PercentOfBankroll: entity.PercentOfBankroll,
KellyFraction: entity.KellyFraction),
CreatedAt: SqliteDateText.Parse(entity.CreatedAt));
}
@@ -19,6 +19,7 @@ public sealed class MarathonDbContext : DbContext
public DbSet<SportEntity> Sports => Set<SportEntity>();
public DbSet<LeagueEntity> Leagues => Set<LeagueEntity>();
public DbSet<PlacedBetEntity> PlacedBets => Set<PlacedBetEntity>();
public DbSet<SavedStrategyEntity> SavedStrategies => Set<SavedStrategyEntity>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
@@ -54,6 +54,7 @@ public static class PersistenceModule
services.AddScoped<IResultRepository, ResultRepository>();
services.AddScoped<IAnomalyRepository, AnomalyRepository>();
services.AddScoped<IPlacedBetRepository, PlacedBetRepository>();
services.AddScoped<ISavedStrategyRepository, SavedStrategyRepository>();
services.AddScoped<IExcelExporter, ExcelExporter>();
return services;
@@ -0,0 +1,62 @@
using Marathon.Application.Abstractions;
using Marathon.Domain.Backtesting;
using Microsoft.EntityFrameworkCore;
namespace Marathon.Infrastructure.Persistence.Repositories;
internal sealed class SavedStrategyRepository : ISavedStrategyRepository
{
private readonly MarathonDbContext _db;
public SavedStrategyRepository(MarathonDbContext db) => _db = db;
public async Task<SavedStrategy?> GetAsync(Guid key, CancellationToken ct = default)
{
var idStr = key.ToString();
// AsNoTracking so callers can re-map and UpdateAsync without tripping
// EF's "another instance with the same key is already tracked" guard.
var entity = await _db.SavedStrategies.AsNoTracking()
.FirstOrDefaultAsync(s => s.Id == idStr, ct);
return entity is null ? null : Mapping.ToDomain(entity);
}
public async Task<SavedStrategy?> GetByNameAsync(string name, CancellationToken ct = default)
{
var trimmed = (name ?? string.Empty).Trim();
var entity = await _db.SavedStrategies.AsNoTracking()
.FirstOrDefaultAsync(s => s.Name == trimmed, ct);
return entity is null ? null : Mapping.ToDomain(entity);
}
public async Task<IReadOnlyList<SavedStrategy>> ListAsync(CancellationToken ct = default)
{
var entities = await _db.SavedStrategies.AsNoTracking()
.OrderBy(s => s.Name)
.ToListAsync(ct);
return entities.Select(Mapping.ToDomain).ToList().AsReadOnly();
}
public async Task AddAsync(SavedStrategy entity, CancellationToken ct = default)
{
var efEntity = Mapping.ToEntity(entity);
await _db.SavedStrategies.AddAsync(efEntity, ct);
}
public Task UpdateAsync(SavedStrategy entity, CancellationToken ct = default)
{
var efEntity = Mapping.ToEntity(entity);
_db.SavedStrategies.Update(efEntity);
return Task.CompletedTask;
}
public async Task DeleteAsync(Guid key, CancellationToken ct = default)
{
var idStr = key.ToString();
var entity = await _db.SavedStrategies.FirstOrDefaultAsync(s => s.Id == idStr, ct);
if (entity is not null)
_db.SavedStrategies.Remove(entity);
}
public async Task SaveChangesAsync(CancellationToken ct = default) =>
await _db.SaveChangesAsync(ct);
}
@@ -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>
+50 -4
View File
@@ -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 0100 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);
}
@@ -0,0 +1,25 @@
using Marathon.Application.Abstractions;
using Marathon.Application.UseCases;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
namespace Marathon.Application.Tests.UseCases;
public sealed class DeleteStrategyUseCaseTests
{
private readonly ISavedStrategyRepository _repo = Substitute.For<ISavedStrategyRepository>();
private DeleteStrategyUseCase CreateSut() =>
new(_repo, NullLogger<DeleteStrategyUseCase>.Instance);
[Fact]
public async Task Delegates_Delete_Then_SaveChanges()
{
var id = Guid.NewGuid();
await CreateSut().ExecuteAsync(id, CancellationToken.None);
await _repo.Received(1).DeleteAsync(id, Arg.Any<CancellationToken>());
await _repo.Received(1).SaveChangesAsync(Arg.Any<CancellationToken>());
}
}
@@ -0,0 +1,69 @@
using FluentAssertions;
using Marathon.Application.Abstractions;
using Marathon.Application.UseCases;
using Marathon.Domain.Backtesting;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
namespace Marathon.Application.Tests.UseCases;
public sealed class SaveStrategyUseCaseTests
{
private readonly ISavedStrategyRepository _repo = Substitute.For<ISavedStrategyRepository>();
private SaveStrategyUseCase CreateSut() =>
new(_repo, NullLogger<SaveStrategyUseCase>.Instance);
[Fact]
public async Task Creates_New_When_NameUnused()
{
_repo.GetByNameAsync("Fresh", Arg.Any<CancellationToken>()).Returns((SavedStrategy?)null);
var result = await CreateSut().ExecuteAsync("Fresh", BacktestStrategy.Default, CancellationToken.None);
result.Name.Should().Be("Fresh");
await _repo.Received(1).AddAsync(
Arg.Is<SavedStrategy>(s => s.Name == "Fresh"), Arg.Any<CancellationToken>());
await _repo.DidNotReceive().UpdateAsync(Arg.Any<SavedStrategy>(), Arg.Any<CancellationToken>());
await _repo.Received(1).SaveChangesAsync(Arg.Any<CancellationToken>());
}
[Fact]
public async Task Overwrites_Existing_KeepingIdentityAndCreatedAt()
{
var existing = SavedStrategy.Create("Reused", BacktestStrategy.Default);
_repo.GetByNameAsync("Reused", Arg.Any<CancellationToken>()).Returns(existing);
var newStrategy = new BacktestStrategy(1000m, 0.7m, StakeRule.Flat, 50m, 0.02m, 0.25m);
var result = await CreateSut().ExecuteAsync("Reused", newStrategy, CancellationToken.None);
result.Id.Should().Be(existing.Id);
result.CreatedAt.Should().Be(existing.CreatedAt);
result.Strategy.MinScore.Should().Be(0.7m);
await _repo.Received(1).UpdateAsync(
Arg.Is<SavedStrategy>(s => s.Id == existing.Id && s.Strategy.MinScore == 0.7m),
Arg.Any<CancellationToken>());
await _repo.DidNotReceive().AddAsync(Arg.Any<SavedStrategy>(), Arg.Any<CancellationToken>());
}
[Fact]
public async Task UpsertLookup_UsesTrimmedName()
{
_repo.GetByNameAsync("Padded", Arg.Any<CancellationToken>()).Returns((SavedStrategy?)null);
await CreateSut().ExecuteAsync(" Padded ", BacktestStrategy.Default, CancellationToken.None);
await _repo.Received(1).GetByNameAsync("Padded", Arg.Any<CancellationToken>());
}
[Theory]
[InlineData("")]
[InlineData(" ")]
public async Task Throws_When_NameBlank(string blank)
{
var act = async () => await CreateSut().ExecuteAsync(blank, BacktestStrategy.Default, CancellationToken.None);
await act.Should().ThrowAsync<ArgumentException>();
await _repo.DidNotReceive().AddAsync(Arg.Any<SavedStrategy>(), Arg.Any<CancellationToken>());
}
}
@@ -0,0 +1,67 @@
using FluentAssertions;
using Marathon.Domain.Backtesting;
namespace Marathon.Domain.Tests.Backtesting;
public sealed class SavedStrategyTests
{
private static BacktestStrategy AnyStrategy() => BacktestStrategy.Default;
[Fact]
public void Create_AssignsIdentity_AndRecentTimestamp()
{
var s = SavedStrategy.Create("My preset", AnyStrategy());
s.Id.Should().NotBe(Guid.Empty);
s.Name.Should().Be("My preset");
s.Strategy.Should().Be(AnyStrategy());
// Offset-agnostic instant comparison — robust to however MoscowTime is sourced.
s.CreatedAt.Should().BeCloseTo(DateTimeOffset.UtcNow, TimeSpan.FromMinutes(1));
}
[Theory]
[InlineData(" Spaced ", "Spaced")]
[InlineData("\tTabbed\n", "Tabbed")]
public void Constructor_TrimsName(string input, string expected)
{
SavedStrategy.Create(input, AnyStrategy()).Name.Should().Be(expected);
}
[Theory]
[InlineData("")]
[InlineData(" ")]
public void Constructor_Throws_When_NameBlank(string blank)
{
var act = () => SavedStrategy.Create(blank, AnyStrategy());
act.Should().Throw<ArgumentException>();
}
[Fact]
public void Constructor_Throws_When_NameExceedsMax()
{
var tooLong = new string('x', SavedStrategy.MaxNameLength + 1);
var act = () => SavedStrategy.Create(tooLong, AnyStrategy());
act.Should().Throw<ArgumentException>();
}
[Fact]
public void Constructor_Accepts_NameAtMaxLength()
{
var maxName = new string('x', SavedStrategy.MaxNameLength);
SavedStrategy.Create(maxName, AnyStrategy()).Name.Should().HaveLength(SavedStrategy.MaxNameLength);
}
[Fact]
public void With_SwapsStrategy_KeepingIdentityAndName()
{
var s = SavedStrategy.Create("Preset", AnyStrategy());
var tweaked = new BacktestStrategy(1000m, 0.6m, StakeRule.Flat, 50m, 0.02m, 0.25m);
var updated = s with { Strategy = tweaked };
updated.Id.Should().Be(s.Id);
updated.Name.Should().Be("Preset");
updated.CreatedAt.Should().Be(s.CreatedAt);
updated.Strategy.MinScore.Should().Be(0.6m);
}
}
@@ -0,0 +1,146 @@
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>();
}
}
@@ -0,0 +1,92 @@
using FluentAssertions;
using Marathon.Application.Abstractions;
using Marathon.Application.UseCases;
using Marathon.Domain.Backtesting;
using Marathon.UI.Services;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
namespace Marathon.UI.Tests.Services;
/// <summary>
/// Covers the saved-strategy surface of <see cref="BacktestService"/> — chiefly the
/// fraction↔percent conversion (domain stores fractions, the form/VM speak percent)
/// and the form-validation guard. Run-simulation behaviour is covered elsewhere.
/// </summary>
public sealed class BacktestServiceStrategyTests
{
private readonly ISavedStrategyRepository _strategies = Substitute.For<ISavedStrategyRepository>();
private readonly IAnomalyRepository _anomalies = Substitute.For<IAnomalyRepository>();
private readonly IEventRepository _events = Substitute.For<IEventRepository>();
private readonly IResultRepository _results = Substitute.For<IResultRepository>();
private BacktestService CreateSut()
{
var run = new RunBacktestUseCase(_anomalies, _events, _results, NullLogger<RunBacktestUseCase>.Instance);
var save = new SaveStrategyUseCase(_strategies, NullLogger<SaveStrategyUseCase>.Instance);
var delete = new DeleteStrategyUseCase(_strategies, NullLogger<DeleteStrategyUseCase>.Instance);
return new BacktestService(run, save, delete, _strategies);
}
[Fact]
public async Task ListStrategiesAsync_ConvertsStoredFractionsToPercent()
{
var preset = new SavedStrategy(
Guid.NewGuid(),
"Quarter Kelly",
new BacktestStrategy(1000m, 0.45m, StakeRule.Kelly, 50m, PercentOfBankroll: 0.03m, KellyFraction: 0.25m),
DateTimeOffset.UtcNow);
_strategies.ListAsync(Arg.Any<CancellationToken>()).Returns(new[] { preset });
var vms = await CreateSut().ListStrategiesAsync(CancellationToken.None);
vms.Should().ContainSingle();
vms[0].Name.Should().Be("Quarter Kelly");
vms[0].StakeRule.Should().Be(StakeRule.Kelly);
vms[0].PercentOfBankrollPercent.Should().Be(3m); // 0.03 fraction → 3%
vms[0].KellyFractionPercent.Should().Be(25m); // 0.25 fraction → 25%
}
[Fact]
public async Task SaveStrategyAsync_PersistsFormPercents_AsFractions()
{
_strategies.GetByNameAsync(Arg.Any<string>(), Arg.Any<CancellationToken>()).Returns((SavedStrategy?)null);
var form = new BacktestForm
{
StartingBankroll = 1000m,
MinScore = 0.5m,
StakeRule = StakeRule.PercentOfBankroll,
FlatStake = 50m,
PercentOfBankrollPercent = 4m,
KellyFractionPercent = 25m,
};
var vm = await CreateSut().SaveStrategyAsync("PoB", form, CancellationToken.None);
vm.PercentOfBankrollPercent.Should().Be(4m);
await _strategies.Received(1).AddAsync(
Arg.Is<SavedStrategy>(s => s.Name == "PoB" && s.Strategy.PercentOfBankroll == 0.04m),
Arg.Any<CancellationToken>());
}
[Fact]
public async Task SaveStrategyAsync_Throws_And_DoesNotPersist_When_FormInvalid()
{
var badForm = new BacktestForm { StartingBankroll = 0m }; // fails IsValid
var act = async () => await CreateSut().SaveStrategyAsync("X", badForm, CancellationToken.None);
await act.Should().ThrowAsync<ArgumentException>();
await _strategies.DidNotReceive().AddAsync(Arg.Any<SavedStrategy>(), Arg.Any<CancellationToken>());
}
[Fact]
public async Task DeleteStrategyAsync_DelegatesToRepository()
{
var id = Guid.NewGuid();
await CreateSut().DeleteStrategyAsync(id, CancellationToken.None);
await _strategies.Received(1).DeleteAsync(id, Arg.Any<CancellationToken>());
}
}