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,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;
|
||||
}
|
||||
}
|
||||
+391
@@ -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,11 +7,11 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Marathon.Infrastructure.Migrations;
|
||||
|
||||
[DbContext(typeof(MarathonDbContext))]
|
||||
partial class MarathonDbContextModelSnapshot : ModelSnapshot
|
||||
namespace Marathon.Infrastructure.Migrations
|
||||
{
|
||||
[DbContext(typeof(MarathonDbContext))]
|
||||
partial class MarathonDbContextModelSnapshot : ModelSnapshot
|
||||
{
|
||||
protected override void BuildModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
@@ -18,112 +19,311 @@ partial class MarathonDbContextModelSnapshot : ModelSnapshot
|
||||
|
||||
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.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");
|
||||
|
||||
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.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.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().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.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(new[] { "SportCode", "ScheduledAt" }).HasDatabaseName("IX_Events_SportCode_ScheduledAt");
|
||||
b.HasIndex("ScheduledAt").HasDatabaseName("IX_Events_ScheduledAt");
|
||||
b.ToTable("Events");
|
||||
|
||||
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.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.ToTable("EventResults", (string)null);
|
||||
});
|
||||
|
||||
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<string>("Id")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
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<string>("Category")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("");
|
||||
|
||||
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.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<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.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");
|
||||
|
||||
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 =>
|
||||
@@ -133,6 +333,7 @@ partial class MarathonDbContextModelSnapshot : ModelSnapshot
|
||||
.HasForeignKey("EventCode")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Event");
|
||||
});
|
||||
|
||||
@@ -143,6 +344,7 @@ partial class MarathonDbContextModelSnapshot : ModelSnapshot
|
||||
.HasForeignKey("SnapshotId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Snapshot");
|
||||
});
|
||||
|
||||
@@ -153,6 +355,7 @@ partial class MarathonDbContextModelSnapshot : ModelSnapshot
|
||||
.HasForeignKey("Marathon.Infrastructure.Persistence.Entities.EventResultEntity", "EventCode")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Event");
|
||||
});
|
||||
|
||||
@@ -163,13 +366,16 @@ partial class MarathonDbContextModelSnapshot : ModelSnapshot
|
||||
.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");
|
||||
});
|
||||
|
||||
@@ -179,4 +385,5 @@ partial class MarathonDbContextModelSnapshot : ModelSnapshot
|
||||
});
|
||||
#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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user