2a0ea7b3a6
Persist named backtest-strategy presets so a staking config (bankroll, min-score, stake rule, flat/percent/Kelly params) can be saved, listed, loaded back into the form, and deleted. The per-run date range is not part of a preset. - Domain: SavedStrategy record (name trimmed + bounded to 80 chars, Create() factory) wrapping the pure BacktestStrategy. - Persistence: SavedStrategyEntity + config (TEXT decimals, unique case-insensitive NOCASE index on Name), repository, mapping, and a hand-trimmed AddSavedStrategies migration (additive — only the new table). Case-insensitive names mean save-by-name overwrites instead of creating near-duplicates. - Application: SaveStrategyUseCase (upsert by name, keeps Id+CreatedAt) + DeleteStrategyUseCase. - UI: presets panel on the Backtest page (load/save/delete) + service methods; fraction<->percent round-trip; en/ru resx. - Fix: pin Sports.Code as ValueGeneratedNever — it is the bookmaker's natural sport id, not an autoincrement surrogate. Corrects long-standing model-snapshot drift; the snapshot is regenerated to match the DB. - 25 tests across all four layers: domain validation, real-SQLite round-trip incl. case-insensitive lookup/uniqueness, the upsert use case, and the service percent mapping.
256 lines
11 KiB
C#
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));
|
|
}
|