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; /// /// Mapping helpers that translate between domain objects and EF Core persistence entities. /// Domain invariants are enforced on the domain side; mapping is purely structural. /// /// /// ScheduledAt / CapturedAt / DetectedAt / CompletedAt / PlacedAt are encoded and /// decoded exclusively through so the write format and /// the repositories' range-predicate format can never drift apart. /// 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)); }