Files
maraphon-app/src/Marathon.Infrastructure/Persistence/Mapping.cs
T
alexei.dolgolyov 2a0ea7b3a6 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.
2026-05-29 02:13:16 +03:00

256 lines
11 KiB
C#

using Marathon.Domain.Backtesting;
using Marathon.Domain.Entities;
using Marathon.Domain.Enums;
using Marathon.Domain.ValueObjects;
using Marathon.Infrastructure.Persistence.Entities;
namespace Marathon.Infrastructure.Persistence;
/// <summary>
/// Mapping helpers that translate between domain objects and EF Core persistence entities.
/// Domain invariants are enforced on the domain side; mapping is purely structural.
/// </summary>
/// <remarks>
/// ScheduledAt / CapturedAt / DetectedAt / CompletedAt / PlacedAt are encoded and
/// decoded exclusively through <see cref="SqliteDateText"/> so the write format and
/// the repositories' range-predicate format can never drift apart.
/// </remarks>
internal static class Mapping
{
// ─── Bet scope discriminator constants ────────────────────────────────────
private const int ScopeMatch = 0;
private const int ScopePeriod = 1;
// ─── Event ───────────────────────────────────────────────────────────────
public static EventEntity ToEntity(Event domain) =>
new()
{
EventCode = domain.Id.Value,
SportCode = domain.Sport.Value,
CountryCode = domain.CountryCode,
LeagueId = domain.LeagueId,
Category = domain.Category,
ScheduledAt = SqliteDateText.Key(domain.ScheduledAt),
Side1Name = domain.Side1Name,
Side2Name = domain.Side2Name,
EventPath = domain.EventPath,
};
public static Event ToDomain(EventEntity entity) =>
new(
Id: new EventId(entity.EventCode),
Sport: new SportCode(entity.SportCode),
CountryCode: entity.CountryCode,
LeagueId: entity.LeagueId,
Category: entity.Category,
ScheduledAt: SqliteDateText.Parse(entity.ScheduledAt),
Side1Name: entity.Side1Name,
Side2Name: entity.Side2Name)
{
EventPath = entity.EventPath,
};
// ─── OddsSnapshot ─────────────────────────────────────────────────────────
public static SnapshotEntity ToEntity(OddsSnapshot domain) =>
new()
{
EventCode = domain.EventId.Value,
CapturedAt = SqliteDateText.Key(domain.CapturedAt),
Source = (int)domain.Source,
Bets = domain.Bets.Select(ToEntity).ToList(),
};
public static OddsSnapshot ToDomain(SnapshotEntity entity) =>
new(
eventId: new EventId(entity.EventCode),
capturedAt: SqliteDateText.Parse(entity.CapturedAt),
source: (OddsSource)entity.Source,
bets: entity.Bets.Select(ToDomain).ToList().AsReadOnly());
// ─── Bet ──────────────────────────────────────────────────────────────────
public static BetEntity ToEntity(Bet domain) =>
new()
{
Scope = domain.Scope is MatchScope ? ScopeMatch : ScopePeriod,
PeriodNumber = domain.Scope is PeriodScope ps ? ps.Number : null,
Type = (int)domain.Type,
Side = (int)domain.Side,
Value = domain.Value?.Value,
Rate = domain.Rate.Value,
};
public static Bet ToDomain(BetEntity entity)
{
var scope = entity.Scope switch
{
ScopeMatch => (BetScope)MatchScope.Instance,
ScopePeriod => new PeriodScope(entity.PeriodNumber!.Value),
_ => throw new InvalidOperationException(
$"Unknown BetScope discriminator: {entity.Scope}"),
};
var value = entity.Value.HasValue ? new OddsValue(entity.Value.Value) : null;
var rate = new OddsRate(entity.Rate);
var type = (BetType)entity.Type;
var side = (Side)entity.Side;
return new Bet(scope, type, side, value, rate);
}
// ─── EventResult ──────────────────────────────────────────────────────────
public static EventResultEntity ToEntity(EventResult domain) =>
new()
{
EventCode = domain.EventId.Value,
Side1Score = domain.Side1Score,
Side2Score = domain.Side2Score,
WinnerSide = (int)domain.WinnerSide,
CompletedAt = SqliteDateText.Key(domain.CompletedAt),
};
public static EventResult ToDomain(EventResultEntity entity) =>
new(
EventId: new EventId(entity.EventCode),
Side1Score: entity.Side1Score,
Side2Score: entity.Side2Score,
WinnerSide: (Side)entity.WinnerSide,
CompletedAt: SqliteDateText.Parse(entity.CompletedAt));
// ─── Anomaly ──────────────────────────────────────────────────────────────
public static AnomalyEntity ToEntity(Anomaly domain) =>
new()
{
Id = domain.Id.ToString(),
EventCode = domain.EventId.Value,
DetectedAt = SqliteDateText.Key(domain.DetectedAt),
Kind = (int)domain.Kind,
Score = domain.Score,
EvidenceJson = domain.EvidenceJson,
};
public static Anomaly ToDomain(AnomalyEntity entity) =>
new(
Id: Guid.Parse(entity.Id),
EventId: new EventId(entity.EventCode),
DetectedAt: SqliteDateText.Parse(entity.DetectedAt),
Kind: (AnomalyKind)entity.Kind,
Score: entity.Score,
EvidenceJson: entity.EvidenceJson);
// ─── Sport ────────────────────────────────────────────────────────────────
public static SportEntity ToEntity(Sport domain) =>
new()
{
Code = domain.Code.Value,
NameRu = domain.NameRu,
NameEn = domain.NameEn,
};
public static Sport ToDomain(SportEntity entity) =>
new(
Code: new SportCode(entity.Code),
NameRu: entity.NameRu,
NameEn: entity.NameEn);
// ─── PlacedBet ────────────────────────────────────────────────────────────
public static PlacedBetEntity ToEntity(PlacedBet domain) =>
new()
{
Id = domain.Id.ToString(),
EventCode = domain.EventId.Value,
Scope = domain.Selection.Scope is MatchScope ? ScopeMatch : ScopePeriod,
PeriodNumber = domain.Selection.Scope is PeriodScope ps ? ps.Number : null,
Type = (int)domain.Selection.Type,
Side = (int)domain.Selection.Side,
Value = domain.Selection.Value?.Value,
Rate = domain.Selection.Rate.Value,
Stake = domain.Stake,
PlacedAt = SqliteDateText.Key(domain.PlacedAt),
Outcome = (int)domain.Outcome,
Notes = domain.Notes,
};
public static PlacedBet ToDomain(PlacedBetEntity entity)
{
var scope = entity.Scope switch
{
ScopeMatch => (BetScope)MatchScope.Instance,
ScopePeriod => new PeriodScope(entity.PeriodNumber!.Value),
_ => throw new InvalidOperationException(
$"Unknown BetScope discriminator: {entity.Scope}"),
};
var value = entity.Value.HasValue ? new OddsValue(entity.Value.Value) : null;
var rate = new OddsRate(entity.Rate);
var type = (BetType)entity.Type;
var side = (Side)entity.Side;
var selection = new Bet(scope, type, side, value, rate);
return new PlacedBet(
Id: Guid.Parse(entity.Id),
EventId: new EventId(entity.EventCode),
Selection: selection,
Stake: entity.Stake,
PlacedAt: SqliteDateText.Parse(entity.PlacedAt),
Outcome: (BetOutcome)entity.Outcome,
Notes: entity.Notes);
}
// ─── League ───────────────────────────────────────────────────────────────
public static LeagueEntity ToEntity(League domain) =>
new()
{
Id = domain.Id,
SportCode = domain.Sport.Value,
Country = domain.Country,
NameRu = domain.NameRu,
NameEn = domain.NameEn,
Category = domain.Category,
};
public static League ToDomain(LeagueEntity entity) =>
new(
Id: entity.Id,
Sport: new SportCode(entity.SportCode),
Country: entity.Country,
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));
}