diff --git a/src/Marathon.Application/Abstractions/ISavedStrategyRepository.cs b/src/Marathon.Application/Abstractions/ISavedStrategyRepository.cs new file mode 100644 index 0000000..82bff29 --- /dev/null +++ b/src/Marathon.Application/Abstractions/ISavedStrategyRepository.cs @@ -0,0 +1,17 @@ +using Marathon.Domain.Backtesting; + +namespace Marathon.Application.Abstractions; + +/// +/// Repository for presets — the user's named, +/// reusable backtest staking configurations. +/// returns them name-ascending for a stable picker order. +/// +public interface ISavedStrategyRepository : IRepository +{ + /// + /// The preset whose (trimmed) name matches , or null. + /// Used by the save flow to upsert by name rather than create a duplicate. + /// + Task GetByNameAsync(string name, CancellationToken ct = default); +} diff --git a/src/Marathon.Application/ApplicationModule.cs b/src/Marathon.Application/ApplicationModule.cs index 90c98ba..3a4edc6 100644 --- a/src/Marathon.Application/ApplicationModule.cs +++ b/src/Marathon.Application/ApplicationModule.cs @@ -39,6 +39,8 @@ public static class ApplicationModule services.AddScoped(); services.AddScoped(); + services.AddScoped(); + services.AddScoped(); return services; } diff --git a/src/Marathon.Application/UseCases/DeleteStrategyUseCase.cs b/src/Marathon.Application/UseCases/DeleteStrategyUseCase.cs new file mode 100644 index 0000000..0f85d56 --- /dev/null +++ b/src/Marathon.Application/UseCases/DeleteStrategyUseCase.cs @@ -0,0 +1,26 @@ +using Marathon.Application.Abstractions; +using Microsoft.Extensions.Logging; + +namespace Marathon.Application.UseCases; + +/// +/// Removes a saved strategy preset by id. Silent no-op when the id is unknown. +/// +public sealed class DeleteStrategyUseCase +{ + private readonly ISavedStrategyRepository _repo; + private readonly ILogger _logger; + + public DeleteStrategyUseCase(ISavedStrategyRepository repo, ILogger 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); + } +} diff --git a/src/Marathon.Application/UseCases/SaveStrategyUseCase.cs b/src/Marathon.Application/UseCases/SaveStrategyUseCase.cs new file mode 100644 index 0000000..ac8e9b0 --- /dev/null +++ b/src/Marathon.Application/UseCases/SaveStrategyUseCase.cs @@ -0,0 +1,50 @@ +using Marathon.Application.Abstractions; +using Marathon.Domain.Backtesting; +using Microsoft.Extensions.Logging; + +namespace Marathon.Application.UseCases; + +/// +/// 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. +/// +public sealed class SaveStrategyUseCase +{ + private readonly ISavedStrategyRepository _repo; + private readonly ILogger _logger; + + public SaveStrategyUseCase(ISavedStrategyRepository repo, ILogger logger) + { + _repo = repo ?? throw new ArgumentNullException(nameof(repo)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// Saves under . + /// The name is empty or exceeds the length bound. + public async Task 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; + } +} diff --git a/src/Marathon.Domain/Backtesting/SavedStrategy.cs b/src/Marathon.Domain/Backtesting/SavedStrategy.cs new file mode 100644 index 0000000..8ccb08b --- /dev/null +++ b/src/Marathon.Domain/Backtesting/SavedStrategy.cs @@ -0,0 +1,51 @@ +using Marathon.Domain.ValueObjects; + +namespace Marathon.Domain.Backtesting; + +/// +/// A named, persisted — the user's reusable +/// staking preset. The wrapped 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. +/// +/// Stable identity, assigned once at creation. +/// +/// User-supplied label. Trimmed and bounded to ; +/// names are unique across the store (enforced by the persistence layer). +/// +/// The staking configuration this preset captures. +/// When the preset was first saved (Moscow time). +public sealed record SavedStrategy( + Guid Id, + string Name, + BacktestStrategy Strategy, + DateTimeOffset CreatedAt) +{ + /// Maximum length of a trimmed strategy name. + public const int MaxNameLength = 80; + + public string Name { get; } = NormalizeName(Name); + + /// + /// Builds a brand-new preset with a fresh identity and the current Moscow + /// timestamp. Use this for "Save"; use with to amend an existing one. + /// + 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; + } +} diff --git a/src/Marathon.Infrastructure/Migrations/20260528225529_AddSavedStrategies.Designer.cs b/src/Marathon.Infrastructure/Migrations/20260528225529_AddSavedStrategies.Designer.cs new file mode 100644 index 0000000..283d1a6 --- /dev/null +++ b/src/Marathon.Infrastructure/Migrations/20260528225529_AddSavedStrategies.Designer.cs @@ -0,0 +1,391 @@ +// +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 + { + /// + 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("Id") + .HasColumnType("TEXT"); + + b.Property("DetectedAt") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("EventCode") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("EvidenceJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Kind") + .HasColumnType("INTEGER"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("PeriodNumber") + .HasColumnType("INTEGER"); + + b.Property("Rate") + .HasColumnType("TEXT"); + + b.Property("Scope") + .HasColumnType("INTEGER"); + + b.Property("Side") + .HasColumnType("INTEGER"); + + b.Property("SnapshotId") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("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("EventCode") + .HasColumnType("TEXT"); + + b.Property("Category") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("CountryCode") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("EventPath") + .HasColumnType("TEXT"); + + b.Property("LeagueId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ScheduledAt") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Side1Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Side2Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("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("EventCode") + .HasColumnType("TEXT"); + + b.Property("CompletedAt") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Side1Score") + .HasColumnType("INTEGER"); + + b.Property("Side2Score") + .HasColumnType("INTEGER"); + + b.Property("WinnerSide") + .HasColumnType("INTEGER"); + + b.HasKey("EventCode"); + + b.ToTable("EventResults", (string)null); + }); + + modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.LeagueEntity", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Category") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("Country") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("NameEn") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("NameRu") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SportCode") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Leagues", (string)null); + }); + + modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.PlacedBetEntity", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("EventCode") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Notes") + .HasColumnType("TEXT"); + + b.Property("Outcome") + .HasColumnType("INTEGER"); + + b.Property("PeriodNumber") + .HasColumnType("INTEGER"); + + b.Property("PlacedAt") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Rate") + .HasColumnType("TEXT"); + + b.Property("Scope") + .HasColumnType("INTEGER"); + + b.Property("Side") + .HasColumnType("INTEGER"); + + b.Property("Stake") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("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("Id") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("FlatStake") + .HasColumnType("TEXT"); + + b.Property("KellyFraction") + .HasColumnType("TEXT"); + + b.Property("MinScore") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PercentOfBankroll") + .HasColumnType("TEXT"); + + b.Property("StakeRule") + .HasColumnType("INTEGER"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CapturedAt") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("EventCode") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("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("Code") + .HasColumnType("INTEGER"); + + b.Property("NameEn") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("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 + } + } +} diff --git a/src/Marathon.Infrastructure/Migrations/20260528225529_AddSavedStrategies.cs b/src/Marathon.Infrastructure/Migrations/20260528225529_AddSavedStrategies.cs new file mode 100644 index 0000000..098d9a5 --- /dev/null +++ b/src/Marathon.Infrastructure/Migrations/20260528225529_AddSavedStrategies.cs @@ -0,0 +1,46 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Marathon.Infrastructure.Migrations +{ + /// + public partial class AddSavedStrategies : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "SavedStrategies", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + Name = table.Column(type: "TEXT", nullable: false, collation: "NOCASE"), + StartingBankroll = table.Column(type: "TEXT", nullable: false), + MinScore = table.Column(type: "TEXT", nullable: false), + StakeRule = table.Column(type: "INTEGER", nullable: false), + FlatStake = table.Column(type: "TEXT", nullable: false), + PercentOfBankroll = table.Column(type: "TEXT", nullable: false), + KellyFraction = table.Column(type: "TEXT", nullable: false), + CreatedAt = table.Column(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); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "SavedStrategies"); + } + } +} diff --git a/src/Marathon.Infrastructure/Migrations/MarathonDbContextModelSnapshot.cs b/src/Marathon.Infrastructure/Migrations/MarathonDbContextModelSnapshot.cs index 68bbcdf..ebf2ca2 100644 --- a/src/Marathon.Infrastructure/Migrations/MarathonDbContextModelSnapshot.cs +++ b/src/Marathon.Infrastructure/Migrations/MarathonDbContextModelSnapshot.cs @@ -1,4 +1,5 @@ -// +// +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("Id").HasColumnType("TEXT"); - b.Property("DetectedAt").IsRequired().HasColumnType("TEXT"); - b.Property("EventCode").IsRequired().HasColumnType("TEXT"); - b.Property("EvidenceJson").IsRequired().HasColumnType("TEXT"); - b.Property("Kind").HasColumnType("INTEGER"); - b.Property("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("Id") + .HasColumnType("TEXT"); - modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.BetEntity", b => - { - b.Property("Id").ValueGeneratedOnAdd().HasColumnType("INTEGER"); - b.Property("PeriodNumber").HasColumnType("INTEGER"); - b.Property("Rate").HasColumnType("TEXT"); - b.Property("Scope").HasColumnType("INTEGER"); - b.Property("Side").HasColumnType("INTEGER"); - b.Property("SnapshotId").HasColumnType("INTEGER"); - b.Property("Type").HasColumnType("INTEGER"); - b.Property("Value").HasColumnType("TEXT"); - b.HasKey("Id"); - b.HasIndex("SnapshotId").HasDatabaseName("IX_Bets_SnapshotId"); - b.ToTable("Bets"); - }); + b.Property("DetectedAt") + .IsRequired() + .HasColumnType("TEXT"); - modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.EventEntity", b => - { - b.Property("EventCode").HasColumnType("TEXT"); - b.Property("Category").IsRequired().HasDefaultValue("").HasColumnType("TEXT"); - b.Property("CountryCode").IsRequired().HasColumnType("TEXT"); - b.Property("EventPath").HasColumnType("TEXT"); - b.Property("LeagueId").IsRequired().HasColumnType("TEXT"); - b.Property("ScheduledAt").IsRequired().HasColumnType("TEXT"); - b.Property("Side1Name").IsRequired().HasColumnType("TEXT"); - b.Property("Side2Name").IsRequired().HasColumnType("TEXT"); - b.Property("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("EventCode") + .IsRequired() + .HasColumnType("TEXT"); - modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.EventResultEntity", b => - { - b.Property("EventCode").HasColumnType("TEXT"); - b.Property("CompletedAt").IsRequired().HasColumnType("TEXT"); - b.Property("Side1Score").HasColumnType("INTEGER"); - b.Property("Side2Score").HasColumnType("INTEGER"); - b.Property("WinnerSide").HasColumnType("INTEGER"); - b.HasKey("EventCode"); - b.ToTable("EventResults"); - }); + b.Property("EvidenceJson") + .IsRequired() + .HasColumnType("TEXT"); - modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.LeagueEntity", b => - { - b.Property("Id").HasColumnType("TEXT"); - b.Property("Category").IsRequired().HasDefaultValue("").HasColumnType("TEXT"); - b.Property("Country").IsRequired().HasColumnType("TEXT"); - b.Property("NameEn").IsRequired().HasColumnType("TEXT"); - b.Property("NameRu").IsRequired().HasColumnType("TEXT"); - b.Property("SportCode").HasColumnType("INTEGER"); - b.HasKey("Id"); - b.ToTable("Leagues"); - }); + b.Property("Kind") + .HasColumnType("INTEGER"); - modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.SnapshotEntity", b => - { - b.Property("Id").ValueGeneratedOnAdd().HasColumnType("INTEGER"); - b.Property("CapturedAt").IsRequired().HasColumnType("TEXT"); - b.Property("EventCode").IsRequired().HasColumnType("TEXT"); - b.Property("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("Score") + .HasColumnType("TEXT"); - modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.SportEntity", b => - { - b.Property("Code").HasColumnType("INTEGER"); - b.Property("NameEn").IsRequired().HasColumnType("TEXT"); - b.Property("NameRu").IsRequired().HasColumnType("TEXT"); - b.HasKey("Code"); - b.ToTable("Sports"); - }); + b.HasKey("Id"); - modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.PlacedBetEntity", b => - { - b.Property("Id").HasColumnType("TEXT"); - b.Property("EventCode").IsRequired().HasColumnType("TEXT"); - b.Property("Scope").HasColumnType("INTEGER"); - b.Property("PeriodNumber").HasColumnType("INTEGER"); - b.Property("Type").HasColumnType("INTEGER"); - b.Property("Side").HasColumnType("INTEGER"); - b.Property("Value").HasColumnType("TEXT"); - b.Property("Rate").HasColumnType("TEXT"); - b.Property("Stake").HasColumnType("TEXT"); - b.Property("PlacedAt").IsRequired().HasColumnType("TEXT"); - b.Property("Outcome").HasColumnType("INTEGER"); - b.Property("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("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("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("Rate") + .HasColumnType("TEXT"); - modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.EventEntity", b => - { - b.Navigation("Anomalies"); - b.Navigation("Result"); - b.Navigation("Snapshots"); - }); + b.Property("Scope") + .HasColumnType("INTEGER"); - modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.SnapshotEntity", b => - { - b.Navigation("Bets"); - }); + b.Property("Side") + .HasColumnType("INTEGER"); + + b.Property("SnapshotId") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("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("EventCode") + .HasColumnType("TEXT"); + + b.Property("Category") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("CountryCode") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("EventPath") + .HasColumnType("TEXT"); + + b.Property("LeagueId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ScheduledAt") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Side1Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Side2Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("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("EventCode") + .HasColumnType("TEXT"); + + b.Property("CompletedAt") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Side1Score") + .HasColumnType("INTEGER"); + + b.Property("Side2Score") + .HasColumnType("INTEGER"); + + b.Property("WinnerSide") + .HasColumnType("INTEGER"); + + b.HasKey("EventCode"); + + b.ToTable("EventResults", (string)null); + }); + + modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.LeagueEntity", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Category") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("Country") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("NameEn") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("NameRu") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SportCode") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Leagues", (string)null); + }); + + modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.PlacedBetEntity", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("EventCode") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Notes") + .HasColumnType("TEXT"); + + b.Property("Outcome") + .HasColumnType("INTEGER"); + + b.Property("PeriodNumber") + .HasColumnType("INTEGER"); + + b.Property("PlacedAt") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Rate") + .HasColumnType("TEXT"); + + b.Property("Scope") + .HasColumnType("INTEGER"); + + b.Property("Side") + .HasColumnType("INTEGER"); + + b.Property("Stake") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("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("Id") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("FlatStake") + .HasColumnType("TEXT"); + + b.Property("KellyFraction") + .HasColumnType("TEXT"); + + b.Property("MinScore") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT") + .UseCollation("NOCASE"); + + b.Property("PercentOfBankroll") + .HasColumnType("TEXT"); + + b.Property("StakeRule") + .HasColumnType("INTEGER"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CapturedAt") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("EventCode") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("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("Code") + .HasColumnType("INTEGER"); + + b.Property("NameEn") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("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 + } } } diff --git a/src/Marathon.Infrastructure/Persistence/Configurations/SavedStrategyConfiguration.cs b/src/Marathon.Infrastructure/Persistence/Configurations/SavedStrategyConfiguration.cs new file mode 100644 index 0000000..8edbfe4 --- /dev/null +++ b/src/Marathon.Infrastructure/Persistence/Configurations/SavedStrategyConfiguration.cs @@ -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 +{ + public void Configure(EntityTypeBuilder 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"); + } +} diff --git a/src/Marathon.Infrastructure/Persistence/Configurations/SportConfiguration.cs b/src/Marathon.Infrastructure/Persistence/Configurations/SportConfiguration.cs index 3730dad..d81bd49 100644 --- a/src/Marathon.Infrastructure/Persistence/Configurations/SportConfiguration.cs +++ b/src/Marathon.Infrastructure/Persistence/Configurations/SportConfiguration.cs @@ -11,7 +11,11 @@ internal sealed class SportConfiguration : IEntityTypeConfiguration 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(); } diff --git a/src/Marathon.Infrastructure/Persistence/Entities/SavedStrategyEntity.cs b/src/Marathon.Infrastructure/Persistence/Entities/SavedStrategyEntity.cs new file mode 100644 index 0000000..0bc6cac --- /dev/null +++ b/src/Marathon.Infrastructure/Persistence/Entities/SavedStrategyEntity.cs @@ -0,0 +1,33 @@ +namespace Marathon.Infrastructure.Persistence.Entities; + +/// +/// EF Core persistence entity for a . +/// Flattens the wrapped BacktestStrategy parameters into columns; decimals +/// are stored as TEXT (invariant round-trip) to match the rest of the schema. +/// +public sealed class SavedStrategyEntity +{ + /// GUID primary key stored as TEXT. + public string Id { get; set; } = default!; + + /// User-supplied label; unique across the store. + public string Name { get; set; } = default!; + + // ─── Flattened BacktestStrategy ────────────────────────────────────────── + public decimal StartingBankroll { get; set; } + public decimal MinScore { get; set; } + + /// StakeRule as int (Flat / PercentOfBankroll / Kelly). + public int StakeRule { get; set; } + + public decimal FlatStake { get; set; } + + /// Fraction in (0, 1] — e.g. 0.02 = 2% of bankroll. + public decimal PercentOfBankroll { get; set; } + + /// Kelly multiplier in (0, 1] — e.g. 0.25 = quarter-Kelly. + public decimal KellyFraction { get; set; } + + /// ISO 8601 timestamp when the preset was first saved (Moscow time). + public string CreatedAt { get; set; } = default!; +} diff --git a/src/Marathon.Infrastructure/Persistence/Mapping.cs b/src/Marathon.Infrastructure/Persistence/Mapping.cs index 36a6145..f360f42 100644 --- a/src/Marathon.Infrastructure/Persistence/Mapping.cs +++ b/src/Marathon.Infrastructure/Persistence/Mapping.cs @@ -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)); } diff --git a/src/Marathon.Infrastructure/Persistence/MarathonDbContext.cs b/src/Marathon.Infrastructure/Persistence/MarathonDbContext.cs index a534577..916b251 100644 --- a/src/Marathon.Infrastructure/Persistence/MarathonDbContext.cs +++ b/src/Marathon.Infrastructure/Persistence/MarathonDbContext.cs @@ -19,6 +19,7 @@ public sealed class MarathonDbContext : DbContext public DbSet Sports => Set(); public DbSet Leagues => Set(); public DbSet PlacedBets => Set(); + public DbSet SavedStrategies => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { diff --git a/src/Marathon.Infrastructure/Persistence/PersistenceModule.cs b/src/Marathon.Infrastructure/Persistence/PersistenceModule.cs index 7700236..79ebd4a 100644 --- a/src/Marathon.Infrastructure/Persistence/PersistenceModule.cs +++ b/src/Marathon.Infrastructure/Persistence/PersistenceModule.cs @@ -54,6 +54,7 @@ public static class PersistenceModule services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); return services; diff --git a/src/Marathon.Infrastructure/Persistence/Repositories/SavedStrategyRepository.cs b/src/Marathon.Infrastructure/Persistence/Repositories/SavedStrategyRepository.cs new file mode 100644 index 0000000..e4bd533 --- /dev/null +++ b/src/Marathon.Infrastructure/Persistence/Repositories/SavedStrategyRepository.cs @@ -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 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 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> 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); +} diff --git a/src/Marathon.UI/Pages/Anomalies/Backtest.razor b/src/Marathon.UI/Pages/Anomalies/Backtest.razor index a4458d2..e7d624c 100644 --- a/src/Marathon.UI/Pages/Anomalies/Backtest.razor +++ b/src/Marathon.UI/Pages/Anomalies/Backtest.razor @@ -35,6 +35,63 @@
+
+
+ @L["Backtest.Presets.Label"] + @if (_strategies.Count > 0) + { + @_strategies.Count + } +
+ + @if (_strategies.Count > 0) + { +
+ @foreach (var preset in _strategies) + { + var local = preset; +
+ + +
+ } +
+ } + else + { +

@L["Backtest.Presets.Empty"]

+ } + +
+ + +
+
+ +
+
@@ -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 _strategies = Array.Empty(); + 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(); + } + } + + 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; diff --git a/src/Marathon.UI/Resources/SharedResource.en.resx b/src/Marathon.UI/Resources/SharedResource.en.resx index 0cf5a23..a7e4fde 100644 --- a/src/Marathon.UI/Resources/SharedResource.en.resx +++ b/src/Marathon.UI/Resources/SharedResource.en.resx @@ -162,6 +162,7 @@ Reset Loading… No data + Delete Yes No @@ -510,4 +511,12 @@ No graded anomalies to simulate yet. Run the results loader so the detector has outcomes to replay against. The strategy placed zero bets — try lowering the score threshold, or switch staking rule. Simulation failed — check the form values and try again. + Saved strategies + Preset name + Save preset + No saved strategies yet — tune the form, name it, and save a reusable preset. + Strategy loaded + Strategy saved + Strategy deleted + Enter a name to save this strategy. diff --git a/src/Marathon.UI/Resources/SharedResource.ru.resx b/src/Marathon.UI/Resources/SharedResource.ru.resx index 52bd77c..b7a4044 100644 --- a/src/Marathon.UI/Resources/SharedResource.ru.resx +++ b/src/Marathon.UI/Resources/SharedResource.ru.resx @@ -174,6 +174,7 @@ Сбросить Загрузка… Нет данных + Удалить Да Нет @@ -523,4 +524,12 @@ Аномалий с результатом ещё нет. Запустите загрузчик результатов, чтобы симулятору было на чём прогоняться. Стратегия не сделала ни одной ставки — снизьте порог score или поменяйте правило стейкинга. Симуляция упала — проверьте параметры формы и повторите. + Сохранённые стратегии + Название пресета + Сохранить пресет + Сохранённых стратегий пока нет — настройте форму, дайте имя и сохраните пресет для повторного использования. + Стратегия загружена + Стратегия сохранена + Стратегия удалена + Введите название, чтобы сохранить стратегию. diff --git a/src/Marathon.UI/Services/BacktestService.cs b/src/Marathon.UI/Services/BacktestService.cs index a641052..89eb3fe 100644 --- a/src/Marathon.UI/Services/BacktestService.cs +++ b/src/Marathon.UI/Services/BacktestService.cs @@ -1,19 +1,32 @@ +using Marathon.Application.Abstractions; using Marathon.Application.UseCases; +using Marathon.Domain.Backtesting; namespace Marathon.UI.Services; /// -/// Page-facing implementation of . 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 . 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. /// 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 RunAsync(BacktestForm form, CancellationToken ct) @@ -58,4 +71,37 @@ public sealed class BacktestService : IBacktestService Trace: rows, EquityCurve: curve); } + + public async Task> ListStrategiesAsync(CancellationToken ct) + { + var presets = await _strategies.ListAsync(ct).ConfigureAwait(false); + return presets.Select(ToVm).ToList(); + } + + public async Task 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); } diff --git a/src/Marathon.UI/Services/BacktestViewModels.cs b/src/Marathon.UI/Services/BacktestViewModels.cs index 5287c19..e5a0d3b 100644 --- a/src/Marathon.UI/Services/BacktestViewModels.cs +++ b/src/Marathon.UI/Services/BacktestViewModels.cs @@ -115,3 +115,35 @@ public sealed record BacktestTraceRow( /// When the bet would have been placed. /// Bankroll after this bet settled. public sealed record EquityPoint(DateTimeOffset DetectedAt, decimal Bankroll); + +/// +/// UI projection of a persisted . +/// 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. +/// +public sealed record SavedStrategyVm( + Guid Id, + string Name, + decimal StartingBankroll, + decimal MinScore, + StakeRule StakeRule, + decimal FlatStake, + decimal PercentOfBankrollPercent, + decimal KellyFractionPercent) +{ + /// + /// Copies this preset's staking parameters onto , leaving + /// the form's / date + /// range untouched (scope is a per-run choice, not part of the preset). + /// + 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; + } +} diff --git a/src/Marathon.UI/Services/IBacktestService.cs b/src/Marathon.UI/Services/IBacktestService.cs index 852e999..1e5cf99 100644 --- a/src/Marathon.UI/Services/IBacktestService.cs +++ b/src/Marathon.UI/Services/IBacktestService.cs @@ -10,4 +10,17 @@ public interface IBacktestService /// Validates the form, runs the simulator, projects for the UI. /// Form fails its own validation. Task RunAsync(BacktestForm form, CancellationToken ct); + + /// Every saved strategy preset, name-ascending. + Task> ListStrategiesAsync(CancellationToken ct); + + /// + /// Saves the current form as a named preset (upsert by name). Validates the + /// form first, exactly like . + /// + /// Form is invalid, or the name is empty/too long. + Task SaveStrategyAsync(string name, BacktestForm form, CancellationToken ct); + + /// Deletes a saved preset by id. No-op when the id is unknown. + Task DeleteStrategyAsync(Guid id, CancellationToken ct); } diff --git a/tests/Marathon.Application.Tests/UseCases/DeleteStrategyUseCaseTests.cs b/tests/Marathon.Application.Tests/UseCases/DeleteStrategyUseCaseTests.cs new file mode 100644 index 0000000..2acc258 --- /dev/null +++ b/tests/Marathon.Application.Tests/UseCases/DeleteStrategyUseCaseTests.cs @@ -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(); + + private DeleteStrategyUseCase CreateSut() => + new(_repo, NullLogger.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()); + await _repo.Received(1).SaveChangesAsync(Arg.Any()); + } +} diff --git a/tests/Marathon.Application.Tests/UseCases/SaveStrategyUseCaseTests.cs b/tests/Marathon.Application.Tests/UseCases/SaveStrategyUseCaseTests.cs new file mode 100644 index 0000000..e6541b1 --- /dev/null +++ b/tests/Marathon.Application.Tests/UseCases/SaveStrategyUseCaseTests.cs @@ -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(); + + private SaveStrategyUseCase CreateSut() => + new(_repo, NullLogger.Instance); + + [Fact] + public async Task Creates_New_When_NameUnused() + { + _repo.GetByNameAsync("Fresh", Arg.Any()).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(s => s.Name == "Fresh"), Arg.Any()); + await _repo.DidNotReceive().UpdateAsync(Arg.Any(), Arg.Any()); + await _repo.Received(1).SaveChangesAsync(Arg.Any()); + } + + [Fact] + public async Task Overwrites_Existing_KeepingIdentityAndCreatedAt() + { + var existing = SavedStrategy.Create("Reused", BacktestStrategy.Default); + _repo.GetByNameAsync("Reused", Arg.Any()).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(s => s.Id == existing.Id && s.Strategy.MinScore == 0.7m), + Arg.Any()); + await _repo.DidNotReceive().AddAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task UpsertLookup_UsesTrimmedName() + { + _repo.GetByNameAsync("Padded", Arg.Any()).Returns((SavedStrategy?)null); + + await CreateSut().ExecuteAsync(" Padded ", BacktestStrategy.Default, CancellationToken.None); + + await _repo.Received(1).GetByNameAsync("Padded", Arg.Any()); + } + + [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(); + await _repo.DidNotReceive().AddAsync(Arg.Any(), Arg.Any()); + } +} diff --git a/tests/Marathon.Domain.Tests/Backtesting/SavedStrategyTests.cs b/tests/Marathon.Domain.Tests/Backtesting/SavedStrategyTests.cs new file mode 100644 index 0000000..06a7e0b --- /dev/null +++ b/tests/Marathon.Domain.Tests/Backtesting/SavedStrategyTests.cs @@ -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(); + } + + [Fact] + public void Constructor_Throws_When_NameExceedsMax() + { + var tooLong = new string('x', SavedStrategy.MaxNameLength + 1); + var act = () => SavedStrategy.Create(tooLong, AnyStrategy()); + act.Should().Throw(); + } + + [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); + } +} diff --git a/tests/Marathon.Infrastructure.Tests/Persistence/SavedStrategyRoundTripTests.cs b/tests/Marathon.Infrastructure.Tests/Persistence/SavedStrategyRoundTripTests.cs new file mode 100644 index 0000000..a3d4b07 --- /dev/null +++ b/tests/Marathon.Infrastructure.Tests/Persistence/SavedStrategyRoundTripTests.cs @@ -0,0 +1,146 @@ +using FluentAssertions; +using Marathon.Domain.Backtesting; +using Marathon.Infrastructure.Persistence.Repositories; +using Microsoft.EntityFrameworkCore; + +namespace Marathon.Infrastructure.Tests.Persistence; + +/// +/// Round-trip + query tests for . Uses the +/// in-memory SQLite fixture so the table + unique name index declared in the +/// configuration are exercised on every test. +/// +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(); + } + + [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(); + } +} diff --git a/tests/Marathon.UI.Tests/Services/BacktestServiceStrategyTests.cs b/tests/Marathon.UI.Tests/Services/BacktestServiceStrategyTests.cs new file mode 100644 index 0000000..5588cdc --- /dev/null +++ b/tests/Marathon.UI.Tests/Services/BacktestServiceStrategyTests.cs @@ -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; + +/// +/// Covers the saved-strategy surface of — chiefly the +/// fraction↔percent conversion (domain stores fractions, the form/VM speak percent) +/// and the form-validation guard. Run-simulation behaviour is covered elsewhere. +/// +public sealed class BacktestServiceStrategyTests +{ + private readonly ISavedStrategyRepository _strategies = Substitute.For(); + private readonly IAnomalyRepository _anomalies = Substitute.For(); + private readonly IEventRepository _events = Substitute.For(); + private readonly IResultRepository _results = Substitute.For(); + + private BacktestService CreateSut() + { + var run = new RunBacktestUseCase(_anomalies, _events, _results, NullLogger.Instance); + var save = new SaveStrategyUseCase(_strategies, NullLogger.Instance); + var delete = new DeleteStrategyUseCase(_strategies, NullLogger.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()).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(), Arg.Any()).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(s => s.Name == "PoB" && s.Strategy.PercentOfBankroll == 0.04m), + Arg.Any()); + } + + [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(); + await _strategies.DidNotReceive().AddAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task DeleteStrategyAsync_DelegatesToRepository() + { + var id = Guid.NewGuid(); + + await CreateSut().DeleteStrategyAsync(id, CancellationToken.None); + + await _strategies.Received(1).DeleteAsync(id, Arg.Any()); + } +}