From 1ad896b07e4acad8300e66021b61724397f7a19c Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Sat, 16 May 2026 17:45:42 +0300 Subject: [PATCH] feat(my-bets): personal bet journal with CLV tracking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a manual bet-tracking journal that turns the analyzer into an actual bet tracker. Users record wagers; the journal auto-grades them when event results land and computes per-bet Closing-Line-Value against the latest pre-match snapshot — the strongest long-run indicator of betting skill. Domain: - PlacedBet entity (reuses Bet vocabulary for Scope/Type/Side/Value/Rate) with stake, placed-at, outcome, and notes. Derived GrossReturn / NetProfit. - BetOutcome enum (Pending / Won / Lost / Void). - BetOutcomeResolver: pure function grading any Match-scope bet against an EventResult. Handles 1X2, draws, handicap (incl. push), and totals. Period-scope bets stay manual since EventResult only carries full-time. Application: - IPlacedBetRepository abstraction. - ClosingLineValueCalculator: pure CLV math (implied-probability delta) + snapshot-matching predicate by Scope/Type/Side/Value. - BetJournalReport + BetJournalStats records. - Four use cases: Record / ResolvePending / BuildReport / Delete. - New ISnapshotRepository.GetLatestPreMatchAsync pushes the closing-line pick into a single SQLite query rather than materialising the 30-day window in memory per event. - ROI turnover excludes Void stakes — pushes are not real turnover and including them would dilute the user's edge. Infrastructure: - PlacedBetEntity / Configuration / Repository / Mapping helpers. - 20260516 migration adding the PlacedBets table with EventCode and Outcome indices. Intentionally NO foreign key to Events — the journal is user data and must survive snapshot-retention pruning. Covered by an explicit round-trip test. UI: - Pages/MyBets/Journal.razor: hero header, 4-card KPI strip (ROI / strike rate / avg CLV / net profit, tinted by tone), inline add-bet form with the same invariants as the Bet record, drill-down table with per-row outcome pills, CLV percentage-points column, P&L, notes underline, and inline-confirm delete. RU + EN i18n. - Nav entry under Analysis. Tests: +55 across Domain / Application / Infrastructure (resolver math including handicap push and total push boundaries, PlacedBet invariants and derived properties, CLV math + null-handling, four use cases under NSubstitute, EF round-trip including survives-event-deletion). All 379 tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Abstractions/IPlacedBetRepository.cs | 32 + .../Abstractions/ISnapshotRepository.cs | 15 + src/Marathon.Application/ApplicationModule.cs | 5 + .../Betting/BetJournalReport.cs | 92 ++ .../Betting/ClosingLineValueCalculator.cs | 85 ++ .../UseCases/BuildBetJournalReportUseCase.cs | 172 ++++ .../UseCases/DeletePlacedBetUseCase.cs | 29 + .../UseCases/RecordPlacedBetUseCase.cs | 90 ++ .../UseCases/ResolvePendingBetsUseCase.cs | 84 ++ .../Betting/BetOutcomeResolver.cs | 95 ++ src/Marathon.Domain/Entities/PlacedBet.cs | 89 ++ src/Marathon.Domain/Enums/BetOutcome.cs | 24 + .../20260516000000_AddPlacedBets.cs | 57 ++ .../MarathonDbContextModelSnapshot.cs | 20 + .../Configurations/PlacedBetConfiguration.cs | 35 + .../Persistence/Entities/PlacedBetEntity.cs | 47 + .../Persistence/Mapping.cs | 45 + .../Persistence/MarathonDbContext.cs | 1 + .../Persistence/PersistenceModule.cs | 1 + .../Repositories/PlacedBetRepository.cs | 87 ++ .../Repositories/SnapshotRepository.cs | 22 + src/Marathon.UI/Components/NavBody.razor | 4 + src/Marathon.UI/Pages/MyBets/Journal.razor | 931 ++++++++++++++++++ .../Resources/SharedResource.en.resx | 63 ++ .../Resources/SharedResource.ru.resx | 63 ++ src/Marathon.UI/Services/BetJournalService.cs | 98 ++ .../Services/BetJournalViewModels.cs | 86 ++ .../Services/IBetJournalService.cs | 29 + .../Services/UiServicesExtensions.cs | 1 + .../ClosingLineValueCalculatorTests.cs | 96 ++ .../BuildBetJournalReportUseCaseTests.cs | 204 ++++ .../UseCases/RecordPlacedBetUseCaseTests.cs | 75 ++ .../ResolvePendingBetsUseCaseTests.cs | 91 ++ .../Betting/BetOutcomeResolverTests.cs | 127 +++ .../Entities/PlacedBetTests.cs | 120 +++ .../Persistence/PlacedBetRoundTripTests.cs | 200 ++++ 36 files changed, 3315 insertions(+) create mode 100644 src/Marathon.Application/Abstractions/IPlacedBetRepository.cs create mode 100644 src/Marathon.Application/Betting/BetJournalReport.cs create mode 100644 src/Marathon.Application/Betting/ClosingLineValueCalculator.cs create mode 100644 src/Marathon.Application/UseCases/BuildBetJournalReportUseCase.cs create mode 100644 src/Marathon.Application/UseCases/DeletePlacedBetUseCase.cs create mode 100644 src/Marathon.Application/UseCases/RecordPlacedBetUseCase.cs create mode 100644 src/Marathon.Application/UseCases/ResolvePendingBetsUseCase.cs create mode 100644 src/Marathon.Domain/Betting/BetOutcomeResolver.cs create mode 100644 src/Marathon.Domain/Entities/PlacedBet.cs create mode 100644 src/Marathon.Domain/Enums/BetOutcome.cs create mode 100644 src/Marathon.Infrastructure/Migrations/20260516000000_AddPlacedBets.cs create mode 100644 src/Marathon.Infrastructure/Persistence/Configurations/PlacedBetConfiguration.cs create mode 100644 src/Marathon.Infrastructure/Persistence/Entities/PlacedBetEntity.cs create mode 100644 src/Marathon.Infrastructure/Persistence/Repositories/PlacedBetRepository.cs create mode 100644 src/Marathon.UI/Pages/MyBets/Journal.razor create mode 100644 src/Marathon.UI/Services/BetJournalService.cs create mode 100644 src/Marathon.UI/Services/BetJournalViewModels.cs create mode 100644 src/Marathon.UI/Services/IBetJournalService.cs create mode 100644 tests/Marathon.Application.Tests/Betting/ClosingLineValueCalculatorTests.cs create mode 100644 tests/Marathon.Application.Tests/UseCases/BuildBetJournalReportUseCaseTests.cs create mode 100644 tests/Marathon.Application.Tests/UseCases/RecordPlacedBetUseCaseTests.cs create mode 100644 tests/Marathon.Application.Tests/UseCases/ResolvePendingBetsUseCaseTests.cs create mode 100644 tests/Marathon.Domain.Tests/Betting/BetOutcomeResolverTests.cs create mode 100644 tests/Marathon.Domain.Tests/Entities/PlacedBetTests.cs create mode 100644 tests/Marathon.Infrastructure.Tests/Persistence/PlacedBetRoundTripTests.cs diff --git a/src/Marathon.Application/Abstractions/IPlacedBetRepository.cs b/src/Marathon.Application/Abstractions/IPlacedBetRepository.cs new file mode 100644 index 0000000..5c499d1 --- /dev/null +++ b/src/Marathon.Application/Abstractions/IPlacedBetRepository.cs @@ -0,0 +1,32 @@ +using Marathon.Application.Storage; +using Marathon.Domain.Entities; +using Marathon.Domain.Enums; +using Marathon.Domain.ValueObjects; + +namespace Marathon.Application.Abstractions; + +/// +/// Repository for domain entities — the user-tracked +/// betting journal. +/// +public interface IPlacedBetRepository : IRepository +{ + /// + /// Bets matching . Used by the resolver use case + /// to scan only rows on each pass. + /// + Task> ListByOutcomeAsync(BetOutcome outcome, CancellationToken ct = default); + + /// + /// Bets whose falls within + /// . Used by the journal page when the user filters + /// by date. + /// + Task> ListByDateRangeAsync(DateRange range, CancellationToken ct = default); + + /// + /// Every bet recorded against . Used by the event + /// detail page to show "you have N bets on this match". + /// + Task> ListByEventAsync(EventId eventId, CancellationToken ct = default); +} diff --git a/src/Marathon.Application/Abstractions/ISnapshotRepository.cs b/src/Marathon.Application/Abstractions/ISnapshotRepository.cs index 7bc8440..55bca2a 100644 --- a/src/Marathon.Application/Abstractions/ISnapshotRepository.cs +++ b/src/Marathon.Application/Abstractions/ISnapshotRepository.cs @@ -36,4 +36,19 @@ public interface ISnapshotRepository Task AddAsync(OddsSnapshot entity, CancellationToken ct = default); Task SaveChangesAsync(CancellationToken ct = default); + + /// + /// Returns the latest pre-match snapshot for whose + /// is at or before + /// , or null if none exists. Used by the + /// bet-journal use case as the "closing line" reference for CLV. + /// + /// + /// Pushes the ORDER BY + LIMIT 1 down to SQLite so we do not materialise + /// every snapshot in the 30-day pre-match window just to pick one. + /// + Task GetLatestPreMatchAsync( + EventId eventId, + DateTimeOffset atOrBefore, + CancellationToken ct = default); } diff --git a/src/Marathon.Application/ApplicationModule.cs b/src/Marathon.Application/ApplicationModule.cs index d266989..14313e9 100644 --- a/src/Marathon.Application/ApplicationModule.cs +++ b/src/Marathon.Application/ApplicationModule.cs @@ -32,6 +32,11 @@ public static class ApplicationModule services.AddScoped(); services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + return services; } } diff --git a/src/Marathon.Application/Betting/BetJournalReport.cs b/src/Marathon.Application/Betting/BetJournalReport.cs new file mode 100644 index 0000000..8eafcf7 --- /dev/null +++ b/src/Marathon.Application/Betting/BetJournalReport.cs @@ -0,0 +1,92 @@ +using Marathon.Domain.Entities; +using Marathon.Domain.Enums; + +namespace Marathon.Application.Betting; + +/// +/// Aggregate report on the user's bet-tracking journal — totals, P&L, and +/// per-bet CLV. Consumed by the Journal page; built by +/// . +/// +/// Roll-up of stake / profit / hit rate / CLV across all bets in scope. +/// +/// Every bet paired with its computed CLV (null when no closing snapshot was +/// available). Ordered most-recent first. +/// +public sealed record BetJournalReport( + BetJournalStats Stats, + IReadOnlyList Bets); + +/// +/// One row in the journal — a domain plus the CLV +/// computed against the closing pre-match snapshot. +/// +/// The domain bet exactly as persisted. +/// +/// Closing-line value as an implied-probability delta in roughly [-1, 1]. +/// Positive means the user took a better price than the closing line; null +/// when no matching bet existed in the closing snapshot. +/// +public sealed record BetJournalRow( + PlacedBet Bet, + decimal? ClvProbabilityDelta); + +/// +/// Aggregate statistics across a set of . +/// All money values share the user's currency — the domain does not encode one. +/// +/// Every bet in scope, regardless of outcome. +/// Bets still awaiting settlement. +/// Settled wins. +/// Settled losses. +/// Settled pushes / void grades. +/// +/// Turnover that contributes to ROI: sum of across +/// Won and Lost bets only. Void (push) and Pending bets are excluded — a +/// returned stake is not real turnover and counting it would dilute ROI. +/// +/// +/// Sum of across the same Won + Lost subset +/// that feeds . +/// +/// TotalReturned − TotalStaked. +/// +/// NetProfit / TotalStaked × 100. Null when no bets have resolved yet. +/// +/// +/// WonCount / (WonCount + LostCount) × 100 — excludes voids and pendings. +/// Null when no settled win/loss exists yet. +/// +/// +/// Mean CLV across bets where CLV was computable. Null when no comparable +/// closing snapshot was available for any bet. +/// +public sealed record BetJournalStats( + int TotalBets, + int PendingCount, + int WonCount, + int LostCount, + int VoidCount, + decimal TotalStaked, + decimal TotalReturned, + decimal NetProfit, + decimal? RoiPercent, + decimal? StrikeRatePercent, + decimal? AverageClvProbabilityDelta) +{ + /// Convenience: WonCount + LostCount + VoidCount. + public int ResolvedCount => WonCount + LostCount + VoidCount; + + public static BetJournalStats Empty { get; } = new( + TotalBets: 0, + PendingCount: 0, + WonCount: 0, + LostCount: 0, + VoidCount: 0, + TotalStaked: 0m, + TotalReturned: 0m, + NetProfit: 0m, + RoiPercent: null, + StrikeRatePercent: null, + AverageClvProbabilityDelta: null); +} diff --git a/src/Marathon.Application/Betting/ClosingLineValueCalculator.cs b/src/Marathon.Application/Betting/ClosingLineValueCalculator.cs new file mode 100644 index 0000000..647f34f --- /dev/null +++ b/src/Marathon.Application/Betting/ClosingLineValueCalculator.cs @@ -0,0 +1,85 @@ +using Marathon.Domain.Entities; +using Marathon.Domain.ValueObjects; + +namespace Marathon.Application.Betting; + +/// +/// Pure helper that computes Closing Line Value (CLV) for a placed bet. +/// +/// +/// +/// CLV measures how much better (or worse) the rate the user took was compared +/// with the bookmaker's last pre-match price on the same selection. It is the +/// single best long-run indicator of betting skill — positive CLV correlates +/// with positive expected value regardless of any individual bet's outcome. +/// +/// +/// Formula (implied-probability delta): +/// +/// Taken implied probability: p_t = 1 / takenRate +/// Closing implied probability: p_c = 1 / closeRate +/// CLV = p_c − p_t +/// +/// Positive CLV means the closing price implied higher probability for the +/// selection than the price the user took — i.e. the line moved in the user's +/// favour after they placed the bet. +/// +/// +/// Returns null when no matching bet (same Scope / Type / Side / Value) +/// can be found in the closing snapshot — typically because the market closed +/// before the bookmaker exposed a comparable line, or the snapshot store has +/// gaps. UI consumers must distinguish "no data" from "0% CLV". +/// +/// +public static class ClosingLineValueCalculator +{ + /// + /// Computes CLV (implied-probability delta) given the rate the user took + /// and the rate present in the closing pre-match snapshot for the same + /// selection. Both must be positive — invariants on + /// already guarantee this for inputs sourced from the domain. + /// + public static decimal Compute(decimal takenRate, decimal closingRate) + { + if (takenRate <= 0m) + throw new ArgumentOutOfRangeException(nameof(takenRate), takenRate, "Must be positive."); + if (closingRate <= 0m) + throw new ArgumentOutOfRangeException(nameof(closingRate), closingRate, "Must be positive."); + + var takenProb = 1m / takenRate; + var closingProb = 1m / closingRate; + + // Round to 6 decimals — beyond that is noise from the round-trip. + return Math.Round(closingProb - takenProb, 6); + } + + /// + /// Convenience overload: finds the matching in + /// by Scope / Type / Side / Value, then + /// computes CLV against . Returns null + /// when no comparable bet is present. + /// + public static decimal? TryCompute( + decimal takenRate, + Bet placedSelection, + OddsSnapshot? closingSnapshot) + { + ArgumentNullException.ThrowIfNull(placedSelection); + if (closingSnapshot is null) return null; + + var match = closingSnapshot.Bets.FirstOrDefault(b => + b.Scope.Equals(placedSelection.Scope) && + b.Type == placedSelection.Type && + b.Side == placedSelection.Side && + NullableValuesEqual(b.Value, placedSelection.Value)); + + return match is null ? null : Compute(takenRate, match.Rate.Value); + } + + private static bool NullableValuesEqual(OddsValue? a, OddsValue? b) + { + if (a is null && b is null) return true; + if (a is null || b is null) return false; + return a.Value == b.Value; + } +} diff --git a/src/Marathon.Application/UseCases/BuildBetJournalReportUseCase.cs b/src/Marathon.Application/UseCases/BuildBetJournalReportUseCase.cs new file mode 100644 index 0000000..0b74886 --- /dev/null +++ b/src/Marathon.Application/UseCases/BuildBetJournalReportUseCase.cs @@ -0,0 +1,172 @@ +using Marathon.Application.Abstractions; +using Marathon.Application.Betting; +using Marathon.Domain.Entities; +using Marathon.Domain.Enums; +using Microsoft.Extensions.Logging; +using DomainEventId = Marathon.Domain.ValueObjects.EventId; + +namespace Marathon.Application.UseCases; + +/// +/// Builds a : every persisted bet paired with its +/// Closing-Line-Value, plus aggregate . +/// +/// +/// +/// Closing-line lookup: for each distinct event in the journal, this use case +/// queries pre-match snapshots within a window that ends at the event's +/// and picks the latest snapshot whose +/// is still before kickoff. That snapshot +/// is the "close" for CLV purposes. +/// +/// +/// If the snapshot store has nothing within the lookback window, the bet +/// receives a null CLV. Stats then exclude it from the average. +/// +/// +public sealed class BuildBetJournalReportUseCase +{ + private readonly IPlacedBetRepository _bets; + private readonly IEventRepository _events; + private readonly ISnapshotRepository _snapshots; + private readonly ILogger _logger; + + public BuildBetJournalReportUseCase( + IPlacedBetRepository bets, + IEventRepository events, + ISnapshotRepository snapshots, + ILogger logger) + { + _bets = bets ?? throw new ArgumentNullException(nameof(bets)); + _events = events ?? throw new ArgumentNullException(nameof(events)); + _snapshots = snapshots ?? throw new ArgumentNullException(nameof(snapshots)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task ExecuteAsync(CancellationToken ct = default) + { + var bets = await _bets.ListAsync(ct).ConfigureAwait(false); + if (bets.Count == 0) + { + _logger.LogInformation("BuildBetJournalReportUseCase: no bets — empty report"); + return new BetJournalReport(BetJournalStats.Empty, Array.Empty()); + } + + var distinctEventIds = bets.Select(b => b.EventId).Distinct().ToList(); + + // Resolve closing snapshot per event using a single-row repo call — + // pushes the ORDER BY / LIMIT 1 down to SQLite rather than materialising + // every snapshot in a 30-day window. + var closingByEvent = new Dictionary(distinctEventIds.Count); + foreach (var eventId in distinctEventIds) + { + ct.ThrowIfCancellationRequested(); + + var ev = await _events.GetAsync(eventId, ct).ConfigureAwait(false); + if (ev is null) + { + closingByEvent[eventId] = null; + continue; + } + + var closing = await _snapshots + .GetLatestPreMatchAsync(eventId, ev.ScheduledAt, ct) + .ConfigureAwait(false); + + closingByEvent[eventId] = closing; + } + + var rows = new List(bets.Count); + foreach (var bet in bets) + { + ct.ThrowIfCancellationRequested(); + closingByEvent.TryGetValue(bet.EventId, out var closing); + + var clv = ClosingLineValueCalculator.TryCompute( + takenRate: bet.Selection.Rate.Value, + placedSelection: bet.Selection, + closingSnapshot: closing); + + rows.Add(new BetJournalRow(bet, clv)); + } + + rows.Sort((a, b) => b.Bet.PlacedAt.CompareTo(a.Bet.PlacedAt)); + + var stats = ComputeStats(rows); + + _logger.LogInformation( + "BuildBetJournalReportUseCase: report built — {Total} bets, {Resolved} resolved, ROI={Roi:0.##}%", + stats.TotalBets, stats.ResolvedCount, stats.RoiPercent ?? 0m); + + return new BetJournalReport(stats, rows); + } + + private static BetJournalStats ComputeStats(IReadOnlyList rows) + { + if (rows.Count == 0) return BetJournalStats.Empty; + + var pending = 0; + var won = 0; + var lost = 0; + var voided = 0; + // Industry-standard ROI excludes pushes from turnover — staking on a Void + // bet returns the stake and is functionally a no-op, so counting it as + // turnover dilutes the ROI denominator and understates the user's edge. + // Only Won + Lost contribute to TotalStaked / TotalReturned. + var totalStaked = 0m; + var totalReturned = 0m; + decimal clvSum = 0m; + var clvCount = 0; + + foreach (var row in rows) + { + switch (row.Bet.Outcome) + { + case BetOutcome.Pending: pending++; break; + case BetOutcome.Won: won++; break; + case BetOutcome.Lost: lost++; break; + case BetOutcome.Void: voided++; break; + } + + if (row.Bet.Outcome is BetOutcome.Won or BetOutcome.Lost) + { + totalStaked += row.Bet.Stake; + totalReturned += row.Bet.GrossReturn ?? 0m; + } + + if (row.ClvProbabilityDelta is { } clv) + { + clvSum += clv; + clvCount++; + } + } + + var netProfit = totalReturned - totalStaked; + var winLoss = won + lost; + + decimal? roi = totalStaked > 0m + ? Math.Round((netProfit / totalStaked) * 100m, 2) + : null; + decimal? strikeRate = winLoss > 0 + ? Math.Round(((decimal)won / winLoss) * 100m, 2) + : null; + // CLV inputs are already 6-decimal-rounded by ClosingLineValueCalculator; + // round the mean only at the display boundary to avoid compounding bias. + decimal? avgClv = clvCount > 0 + ? clvSum / clvCount + : null; + + return new BetJournalStats( + TotalBets: rows.Count, + PendingCount: pending, + WonCount: won, + LostCount: lost, + VoidCount: voided, + TotalStaked: totalStaked, + TotalReturned: totalReturned, + NetProfit: netProfit, + RoiPercent: roi, + StrikeRatePercent: strikeRate, + AverageClvProbabilityDelta: avgClv); + } +} diff --git a/src/Marathon.Application/UseCases/DeletePlacedBetUseCase.cs b/src/Marathon.Application/UseCases/DeletePlacedBetUseCase.cs new file mode 100644 index 0000000..52a7c12 --- /dev/null +++ b/src/Marathon.Application/UseCases/DeletePlacedBetUseCase.cs @@ -0,0 +1,29 @@ +using Marathon.Application.Abstractions; +using Microsoft.Extensions.Logging; + +namespace Marathon.Application.UseCases; + +/// +/// Removes a from the journal +/// by its identifier. Silent no-op when the id does not exist. +/// +public sealed class DeletePlacedBetUseCase +{ + private readonly IPlacedBetRepository _bets; + private readonly ILogger _logger; + + public DeletePlacedBetUseCase( + IPlacedBetRepository bets, + ILogger logger) + { + _bets = bets ?? throw new ArgumentNullException(nameof(bets)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task ExecuteAsync(Guid betId, CancellationToken ct = default) + { + await _bets.DeleteAsync(betId, ct).ConfigureAwait(false); + await _bets.SaveChangesAsync(ct).ConfigureAwait(false); + _logger.LogInformation("DeletePlacedBetUseCase: removed bet {BetId}", betId); + } +} diff --git a/src/Marathon.Application/UseCases/RecordPlacedBetUseCase.cs b/src/Marathon.Application/UseCases/RecordPlacedBetUseCase.cs new file mode 100644 index 0000000..fcf4324 --- /dev/null +++ b/src/Marathon.Application/UseCases/RecordPlacedBetUseCase.cs @@ -0,0 +1,90 @@ +using Marathon.Application.Abstractions; +using Marathon.Domain.Entities; +using Marathon.Domain.Enums; +using Microsoft.Extensions.Logging; +using DomainEventId = Marathon.Domain.ValueObjects.EventId; + +namespace Marathon.Application.UseCases; + +/// +/// Records a new entered manually via the Journal UI. +/// +/// +/// +/// The use case validates that the referenced event exists, then persists the +/// bet. If the event already has a final result the bet is graded on the spot +/// via — saves the +/// user a round-trip to the resolver page when entering historical wagers. +/// +/// +public sealed class RecordPlacedBetUseCase +{ + private readonly IPlacedBetRepository _bets; + private readonly IEventRepository _events; + private readonly IResultRepository _results; + private readonly ILogger _logger; + + public RecordPlacedBetUseCase( + IPlacedBetRepository bets, + IEventRepository events, + IResultRepository results, + ILogger logger) + { + _bets = bets ?? throw new ArgumentNullException(nameof(bets)); + _events = events ?? throw new ArgumentNullException(nameof(events)); + _results = results ?? throw new ArgumentNullException(nameof(results)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + /// Persists . Returns the bet as stored — if the + /// event already has a result, the returned instance reflects the graded + /// . + /// + /// + /// The bet references an unknown event. The journal does not allow free-form + /// event codes — wagers must be on events the scraper has captured so the + /// CLV calculator can compare against the closing snapshot. + /// + public async Task ExecuteAsync(PlacedBet bet, CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(bet); + + // Confirm the event exists in the local store. + var ev = await _events.GetAsync(bet.EventId, ct).ConfigureAwait(false); + if (ev is null) + { + throw new InvalidOperationException( + $"Cannot record a bet on unknown event '{bet.EventId.Value}'. " + + "The event must already be present in the scrape store."); + } + + var toPersist = bet; + + // Auto-grade if a result is already available. + if (bet.Outcome == BetOutcome.Pending) + { + var result = await _results.GetAsync(bet.EventId, ct).ConfigureAwait(false); + if (result is not null) + { + var graded = Marathon.Domain.Betting.BetOutcomeResolver.Resolve(bet.Selection, result); + if (graded is not null) + { + toPersist = bet.WithOutcome(graded.Value); + _logger.LogInformation( + "RecordPlacedBetUseCase: bet {BetId} on event {EventId} auto-graded as {Outcome}", + toPersist.Id, ((DomainEventId)toPersist.EventId).Value, graded.Value); + } + } + } + + await _bets.AddAsync(toPersist, ct).ConfigureAwait(false); + await _bets.SaveChangesAsync(ct).ConfigureAwait(false); + + _logger.LogInformation( + "RecordPlacedBetUseCase: persisted bet {BetId} on event {EventId} stake={Stake} rate={Rate}", + toPersist.Id, ((DomainEventId)toPersist.EventId).Value, toPersist.Stake, toPersist.Selection.Rate.Value); + + return toPersist; + } +} diff --git a/src/Marathon.Application/UseCases/ResolvePendingBetsUseCase.cs b/src/Marathon.Application/UseCases/ResolvePendingBetsUseCase.cs new file mode 100644 index 0000000..39dc904 --- /dev/null +++ b/src/Marathon.Application/UseCases/ResolvePendingBetsUseCase.cs @@ -0,0 +1,84 @@ +using Marathon.Application.Abstractions; +using Marathon.Domain.Betting; +using Marathon.Domain.Entities; +using Marathon.Domain.Enums; +using Microsoft.Extensions.Logging; +using DomainEventId = Marathon.Domain.ValueObjects.EventId; + +namespace Marathon.Application.UseCases; + +/// +/// Sweeps the journal for bets whose events +/// have been graded, and updates them in bulk via +/// . +/// +/// +/// Called on demand from the Journal page's "Resolve pending" button. The +/// design is idempotent — bets that cannot be auto-graded (period-scope, or +/// no result yet) are left untouched and surface again on the next pass. +/// +public sealed class ResolvePendingBetsUseCase +{ + private readonly IPlacedBetRepository _bets; + private readonly IResultRepository _results; + private readonly ILogger _logger; + + public ResolvePendingBetsUseCase( + IPlacedBetRepository bets, + IResultRepository results, + ILogger logger) + { + _bets = bets ?? throw new ArgumentNullException(nameof(bets)); + _results = results ?? throw new ArgumentNullException(nameof(results)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + /// Returns the number of bets that were transitioned out of Pending in this pass. + /// + public async Task ExecuteAsync(CancellationToken ct = default) + { + var pending = await _bets.ListByOutcomeAsync(BetOutcome.Pending, ct).ConfigureAwait(false); + if (pending.Count == 0) + { + _logger.LogInformation("ResolvePendingBetsUseCase: no pending bets"); + return 0; + } + + // Cache results per event so we do not re-query for each bet on the same event. + var resultCache = new Dictionary(); + var resolvedCount = 0; + + foreach (var bet in pending) + { + ct.ThrowIfCancellationRequested(); + + if (!resultCache.TryGetValue(bet.EventId, out var result)) + { + result = await _results.GetAsync(bet.EventId, ct).ConfigureAwait(false); + resultCache[bet.EventId] = result; + } + + if (result is null) continue; + + var graded = BetOutcomeResolver.Resolve(bet.Selection, result); + if (graded is null) continue; + + var updated = bet.WithOutcome(graded.Value); + await _bets.UpdateAsync(updated, ct).ConfigureAwait(false); + resolvedCount++; + } + + // Save before logging — if the batch fails, an exception bubbles out and + // the success-count log is never emitted; we never report a graded count + // that was rolled back. + if (resolvedCount > 0) + await _bets.SaveChangesAsync(ct).ConfigureAwait(false); + + _logger.LogInformation( + "ResolvePendingBetsUseCase: graded {Resolved} of {Pending} pending bets", + resolvedCount, pending.Count); + + return resolvedCount; + } +} diff --git a/src/Marathon.Domain/Betting/BetOutcomeResolver.cs b/src/Marathon.Domain/Betting/BetOutcomeResolver.cs new file mode 100644 index 0000000..d95f0f8 --- /dev/null +++ b/src/Marathon.Domain/Betting/BetOutcomeResolver.cs @@ -0,0 +1,95 @@ +using Marathon.Domain.Entities; +using Marathon.Domain.Enums; +using Marathon.Domain.ValueObjects; + +namespace Marathon.Domain.Betting; + +/// +/// Pure function that grades a selection against a final +/// . Used by the bet-journal resolver to auto-settle +/// pending wagers the moment a result lands. +/// +/// +/// +/// Grading rules: +/// +/// Win (Side1/Side2): selection wins iff WinnerSide matches the side. +/// Draw: wins iff WinnerSide == Draw. +/// WinFora with handicap h on side S: adjusted S-score +/// = S.Score + h. Wins when adjusted > opponent, voids on tie, loses otherwise. +/// Total with threshold t: combined = Side1Score + Side2Score. +/// More wins when combined > t, voids on equal, loses when less. +/// Less is the mirror image. +/// +/// +/// +/// Returns null when the bet cannot be graded against this result — +/// today only period-scope selections, because stores +/// the full-time score only. Callers must leave such bets in +/// for manual settlement. +/// +/// +public static class BetOutcomeResolver +{ + /// + /// Grades against . + /// Returns the resulting or null if the + /// bet shape cannot be auto-resolved from the available result data. + /// + public static BetOutcome? Resolve(Bet selection, EventResult result) + { + ArgumentNullException.ThrowIfNull(selection); + ArgumentNullException.ThrowIfNull(result); + + // Period-scope bets need per-period scores which EventResult does not + // carry today — leave for manual grading. + if (selection.Scope is not MatchScope) + return null; + + return selection.Type switch + { + BetType.Win => ResolveWin(selection.Side, result), + BetType.Draw => ResolveDraw(result), + BetType.WinFora => ResolveFora(selection.Side, selection.Value!.Value, result), + BetType.Total => ResolveTotal(selection.Side, selection.Value!.Value, result), + _ => null, + }; + } + + private static BetOutcome ResolveWin(Side side, EventResult result) => + result.WinnerSide == side ? BetOutcome.Won : BetOutcome.Lost; + + private static BetOutcome ResolveDraw(EventResult result) => + result.WinnerSide == Side.Draw ? BetOutcome.Won : BetOutcome.Lost; + + private static BetOutcome ResolveFora(Side side, decimal handicap, EventResult result) + { + // Adjusted score for the side that took the handicap. + var (own, opponent) = side == Side.Side1 + ? (result.Side1Score, result.Side2Score) + : (result.Side2Score, result.Side1Score); + + var adjusted = own + handicap; + + if (adjusted > opponent) return BetOutcome.Won; + if (adjusted == opponent) return BetOutcome.Void; + return BetOutcome.Lost; + } + + private static BetOutcome ResolveTotal(Side side, decimal threshold, EventResult result) + { + var total = (decimal)(result.Side1Score + result.Side2Score); + + // More wins when total > threshold; Less wins when total < threshold. + // Equality is a push (Void) for both sides. + if (total == threshold) return BetOutcome.Void; + + var totalIsOver = total > threshold; + return side switch + { + Side.More => totalIsOver ? BetOutcome.Won : BetOutcome.Lost, + Side.Less => totalIsOver ? BetOutcome.Lost : BetOutcome.Won, + _ => BetOutcome.Lost, // Defensive — Bet invariant rejects other sides for Total. + }; + } +} diff --git a/src/Marathon.Domain/Entities/PlacedBet.cs b/src/Marathon.Domain/Entities/PlacedBet.cs new file mode 100644 index 0000000..a89b4d7 --- /dev/null +++ b/src/Marathon.Domain/Entities/PlacedBet.cs @@ -0,0 +1,89 @@ +using Marathon.Domain.Enums; +using Marathon.Domain.ValueObjects; + +namespace Marathon.Domain.Entities; + +/// +/// A wager the user manually recorded as having placed (with this or another +/// bookmaker). Reuses the vocabulary so the journal can mirror +/// scraped markets directly — same Scope / Type / Side / Value / Rate invariants +/// apply to . +/// +/// Stable identifier — Guid so duplicates can be detected by the UI. +/// Event the wager is on. +/// +/// The market + rate the user took. Selection.Rate is the "taken rate" +/// used for ROI and CLV calculations. +/// +/// +/// Money risked, in the user's currency. The domain does not encode currency — +/// stake values are compared as raw decimals. +/// +/// When the bet was recorded. Stored as Moscow time. +/// Current settlement state — see . +/// Optional free text — strategy tag, source, etc. +public sealed record PlacedBet( + Guid Id, + EventId EventId, + Bet Selection, + decimal Stake, + DateTimeOffset PlacedAt, + BetOutcome Outcome, + string? Notes) +{ + public Guid Id { get; } = Id == Guid.Empty + ? throw new ArgumentException("PlacedBet Id must not be an empty GUID.", nameof(Id)) + : Id; + + public EventId EventId { get; } = EventId ?? throw new ArgumentNullException(nameof(EventId)); + + public Bet Selection { get; } = Selection ?? throw new ArgumentNullException(nameof(Selection)); + + public decimal Stake { get; } = Stake > 0m + ? Stake + : throw new ArgumentOutOfRangeException(nameof(Stake), Stake, + "Stake must be positive."); + + public DateTimeOffset PlacedAt { get; } = PlacedAt.Offset == MoscowTime.Offset + ? PlacedAt + : throw new ArgumentException( + $"PlacedAt must be in Europe/Moscow time (UTC+03:00). " + + $"Received offset: {PlacedAt.Offset:hh\\:mm}.", + nameof(PlacedAt)); + + public BetOutcome Outcome { get; } = Outcome; + + public string? Notes { get; } = string.IsNullOrWhiteSpace(Notes) ? null : Notes; + + /// + /// Gross return on this bet for the current outcome — the amount the + /// bookmaker pays back to the user (stake + winnings). + /// + /// : Stake × Rate + /// : Stake (push — stake returned) + /// : 0 + /// : null (unknown) + /// + /// + public decimal? GrossReturn => Outcome switch + { + BetOutcome.Won => Stake * Selection.Rate.Value, + BetOutcome.Void => Stake, + BetOutcome.Lost => 0m, + _ => null, + }; + + /// + /// Net profit for the current outcome — minus + /// . Negative for losses. Null while pending. + /// + public decimal? NetProfit => GrossReturn is null ? null : GrossReturn.Value - Stake; + + /// + /// Returns a copy with a new — used by the resolver + /// use case after grading the event. Constructs explicitly because the + /// manual validating get-only properties prevent with. + /// + public PlacedBet WithOutcome(BetOutcome outcome) => + new(Id, EventId, Selection, Stake, PlacedAt, outcome, Notes); +} diff --git a/src/Marathon.Domain/Enums/BetOutcome.cs b/src/Marathon.Domain/Enums/BetOutcome.cs new file mode 100644 index 0000000..fa73d3d --- /dev/null +++ b/src/Marathon.Domain/Enums/BetOutcome.cs @@ -0,0 +1,24 @@ +namespace Marathon.Domain.Enums; + +/// +/// Settlement status of a user-tracked . +/// +public enum BetOutcome +{ + /// + /// The event has not been graded yet, or the bet has not been auto-resolved + /// yet. Default state for a freshly recorded bet. + /// + Pending, + + /// The selection won — stake returned plus winnings. + Won, + + /// The selection lost — stake is forfeit. + Lost, + + /// + /// Handicap/total push or event abandoned — stake returned, no profit/loss. + /// + Void, +} diff --git a/src/Marathon.Infrastructure/Migrations/20260516000000_AddPlacedBets.cs b/src/Marathon.Infrastructure/Migrations/20260516000000_AddPlacedBets.cs new file mode 100644 index 0000000..921c716 --- /dev/null +++ b/src/Marathon.Infrastructure/Migrations/20260516000000_AddPlacedBets.cs @@ -0,0 +1,57 @@ +using Marathon.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Marathon.Infrastructure.Migrations; + +/// +[DbContext(typeof(MarathonDbContext))] +[Migration("20260516000000_AddPlacedBets")] +public partial class AddPlacedBets : Migration +{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "PlacedBets", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + EventCode = table.Column(type: "TEXT", nullable: false), + Scope = table.Column(type: "INTEGER", nullable: false), + PeriodNumber = table.Column(type: "INTEGER", nullable: true), + Type = table.Column(type: "INTEGER", nullable: false), + Side = table.Column(type: "INTEGER", nullable: false), + Value = table.Column(type: "TEXT", nullable: true), + Rate = table.Column(type: "TEXT", nullable: false), + Stake = table.Column(type: "TEXT", nullable: false), + PlacedAt = table.Column(type: "TEXT", nullable: false), + Outcome = table.Column(type: "INTEGER", nullable: false), + Notes = table.Column(type: "TEXT", nullable: true), + }, + constraints: table => + { + table.PrimaryKey("PK_PlacedBets", x => x.Id); + // No foreign key to Events — the journal is user data and must + // survive snapshot retention pruning the source event row. + }); + + migrationBuilder.CreateIndex( + name: "IX_PlacedBets_EventCode", + table: "PlacedBets", + column: "EventCode"); + + migrationBuilder.CreateIndex( + name: "IX_PlacedBets_Outcome", + table: "PlacedBets", + column: "Outcome"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable(name: "PlacedBets"); + } +} diff --git a/src/Marathon.Infrastructure/Migrations/MarathonDbContextModelSnapshot.cs b/src/Marathon.Infrastructure/Migrations/MarathonDbContextModelSnapshot.cs index 9adf55d..dc4e7af 100644 --- a/src/Marathon.Infrastructure/Migrations/MarathonDbContextModelSnapshot.cs +++ b/src/Marathon.Infrastructure/Migrations/MarathonDbContextModelSnapshot.cs @@ -104,6 +104,26 @@ partial class MarathonDbContextModelSnapshot : ModelSnapshot b.ToTable("Sports"); }); + 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"); + }); + modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.AnomalyEntity", b => { b.HasOne("Marathon.Infrastructure.Persistence.Entities.EventEntity", "Event") diff --git a/src/Marathon.Infrastructure/Persistence/Configurations/PlacedBetConfiguration.cs b/src/Marathon.Infrastructure/Persistence/Configurations/PlacedBetConfiguration.cs new file mode 100644 index 0000000..2af3321 --- /dev/null +++ b/src/Marathon.Infrastructure/Persistence/Configurations/PlacedBetConfiguration.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 PlacedBetConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("PlacedBets"); + + builder.HasKey(b => b.Id); + builder.Property(b => b.Id).HasColumnType("TEXT").IsRequired(); + builder.Property(b => b.EventCode).HasColumnType("TEXT").IsRequired(); + + builder.Property(b => b.Scope).HasColumnType("INTEGER").IsRequired(); + builder.Property(b => b.PeriodNumber).HasColumnType("INTEGER"); + builder.Property(b => b.Type).HasColumnType("INTEGER").IsRequired(); + builder.Property(b => b.Side).HasColumnType("INTEGER").IsRequired(); + builder.Property(b => b.Value).HasColumnType("TEXT"); + builder.Property(b => b.Rate).HasColumnType("TEXT").IsRequired(); + + builder.Property(b => b.Stake).HasColumnType("TEXT").IsRequired(); + builder.Property(b => b.PlacedAt).HasColumnType("TEXT").IsRequired(); + builder.Property(b => b.Outcome).HasColumnType("INTEGER").IsRequired(); + builder.Property(b => b.Notes).HasColumnType("TEXT"); + + // EventCode is intentionally NOT a foreign key — the journal is the + // user's data and must survive snapshot retention pruning the source + // event row. Existence is checked once at insert time by the use case. + builder.HasIndex(b => b.EventCode).HasDatabaseName("IX_PlacedBets_EventCode"); + builder.HasIndex(b => b.Outcome).HasDatabaseName("IX_PlacedBets_Outcome"); + } +} diff --git a/src/Marathon.Infrastructure/Persistence/Entities/PlacedBetEntity.cs b/src/Marathon.Infrastructure/Persistence/Entities/PlacedBetEntity.cs new file mode 100644 index 0000000..f24b04c --- /dev/null +++ b/src/Marathon.Infrastructure/Persistence/Entities/PlacedBetEntity.cs @@ -0,0 +1,47 @@ +namespace Marathon.Infrastructure.Persistence.Entities; + +/// +/// EF Core persistence entity for a user-tracked . +/// Flattens the embedded Bet selection (Scope / Type / Side / Value / Rate) +/// into columns so SQLite can index by event and outcome cheaply. +/// +public sealed class PlacedBetEntity +{ + /// GUID primary key stored as TEXT. + public string Id { get; set; } = default!; + + /// Foreign key to . + public string EventCode { get; set; } = default!; + + // ─── Embedded Bet selection ────────────────────────────────────────────── + /// Scope discriminator: 0 = Match, 1 = Period. + public int Scope { get; set; } + + /// Period number when = 1; null otherwise. + public int? PeriodNumber { get; set; } + + /// BetType as int (Win / Draw / WinFora / Total). + public int Type { get; set; } + + /// Side as int (Side1 / Side2 / Draw / Less / More). + public int Side { get; set; } + + /// Handicap or total threshold; null for Win / Draw markets. + public decimal? Value { get; set; } + + /// Decimal odds the user took. + public decimal Rate { get; set; } + + // ─── Wager fields ──────────────────────────────────────────────────────── + /// Stake in the user's currency. + public decimal Stake { get; set; } + + /// ISO 8601 timestamp when the bet was recorded (Moscow time). + public string PlacedAt { get; set; } = default!; + + /// BetOutcome as int (Pending / Won / Lost / Void). + public int Outcome { get; set; } + + /// Optional free-text note from the user. + public string? Notes { get; set; } +} diff --git a/src/Marathon.Infrastructure/Persistence/Mapping.cs b/src/Marathon.Infrastructure/Persistence/Mapping.cs index e830131..b119452 100644 --- a/src/Marathon.Infrastructure/Persistence/Mapping.cs +++ b/src/Marathon.Infrastructure/Persistence/Mapping.cs @@ -158,6 +158,51 @@ internal static class Mapping 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 = domain.PlacedAt.ToString("O"), + 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: DateTimeOffset.Parse(entity.PlacedAt, CultureInfo.InvariantCulture, RoundtripStyles), + Outcome: (BetOutcome)entity.Outcome, + Notes: entity.Notes); + } + // ─── League ─────────────────────────────────────────────────────────────── public static LeagueEntity ToEntity(League domain) => diff --git a/src/Marathon.Infrastructure/Persistence/MarathonDbContext.cs b/src/Marathon.Infrastructure/Persistence/MarathonDbContext.cs index c178015..a534577 100644 --- a/src/Marathon.Infrastructure/Persistence/MarathonDbContext.cs +++ b/src/Marathon.Infrastructure/Persistence/MarathonDbContext.cs @@ -18,6 +18,7 @@ public sealed class MarathonDbContext : DbContext public DbSet Anomalies => Set(); public DbSet Sports => Set(); public DbSet Leagues => Set(); + public DbSet PlacedBets => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { diff --git a/src/Marathon.Infrastructure/Persistence/PersistenceModule.cs b/src/Marathon.Infrastructure/Persistence/PersistenceModule.cs index 4eb0200..7700236 100644 --- a/src/Marathon.Infrastructure/Persistence/PersistenceModule.cs +++ b/src/Marathon.Infrastructure/Persistence/PersistenceModule.cs @@ -53,6 +53,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/PlacedBetRepository.cs b/src/Marathon.Infrastructure/Persistence/Repositories/PlacedBetRepository.cs new file mode 100644 index 0000000..d81491d --- /dev/null +++ b/src/Marathon.Infrastructure/Persistence/Repositories/PlacedBetRepository.cs @@ -0,0 +1,87 @@ +using Marathon.Application.Abstractions; +using Marathon.Application.Storage; +using Marathon.Domain.Entities; +using Marathon.Domain.Enums; +using Marathon.Domain.ValueObjects; +using Microsoft.EntityFrameworkCore; + +namespace Marathon.Infrastructure.Persistence.Repositories; + +internal sealed class PlacedBetRepository : IPlacedBetRepository +{ + private readonly MarathonDbContext _db; + + public PlacedBetRepository(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.PlacedBets.AsNoTracking() + .FirstOrDefaultAsync(b => b.Id == idStr, ct); + return entity is null ? null : Mapping.ToDomain(entity); + } + + public async Task> ListAsync(CancellationToken ct = default) + { + var entities = await _db.PlacedBets.AsNoTracking().ToListAsync(ct); + return entities.Select(Mapping.ToDomain).ToList().AsReadOnly(); + } + + public async Task> ListByOutcomeAsync(BetOutcome outcome, CancellationToken ct = default) + { + var outcomeInt = (int)outcome; + var entities = await _db.PlacedBets.AsNoTracking() + .Where(b => b.Outcome == outcomeInt) + .ToListAsync(ct); + return entities.Select(Mapping.ToDomain).ToList().AsReadOnly(); + } + + public async Task> ListByDateRangeAsync(DateRange range, CancellationToken ct = default) + { + // PlacedAt is stored as ISO 8601 TEXT — same lexical-equals-chronological ordering + // trick used in EventRepository.ListByDateRangeAsync. + var fromStr = range.From.ToString("O"); + var toStr = range.To.ToString("O"); + + var entities = await _db.PlacedBets.AsNoTracking() + .Where(b => b.PlacedAt.CompareTo(fromStr) >= 0 + && b.PlacedAt.CompareTo(toStr) <= 0) + .ToListAsync(ct); + + return entities.Select(Mapping.ToDomain).ToList().AsReadOnly(); + } + + public async Task> ListByEventAsync(EventId eventId, CancellationToken ct = default) + { + var entities = await _db.PlacedBets.AsNoTracking() + .Where(b => b.EventCode == eventId.Value) + .ToListAsync(ct); + return entities.Select(Mapping.ToDomain).ToList().AsReadOnly(); + } + + public async Task AddAsync(PlacedBet entity, CancellationToken ct = default) + { + var efEntity = Mapping.ToEntity(entity); + await _db.PlacedBets.AddAsync(efEntity, ct); + } + + public Task UpdateAsync(PlacedBet entity, CancellationToken ct = default) + { + var efEntity = Mapping.ToEntity(entity); + _db.PlacedBets.Update(efEntity); + return Task.CompletedTask; + } + + public async Task DeleteAsync(Guid key, CancellationToken ct = default) + { + var idStr = key.ToString(); + var entity = await _db.PlacedBets.FirstOrDefaultAsync(b => b.Id == idStr, ct); + if (entity is not null) + _db.PlacedBets.Remove(entity); + } + + public async Task SaveChangesAsync(CancellationToken ct = default) => + await _db.SaveChangesAsync(ct); +} diff --git a/src/Marathon.Infrastructure/Persistence/Repositories/SnapshotRepository.cs b/src/Marathon.Infrastructure/Persistence/Repositories/SnapshotRepository.cs index 8654737..1374850 100644 --- a/src/Marathon.Infrastructure/Persistence/Repositories/SnapshotRepository.cs +++ b/src/Marathon.Infrastructure/Persistence/Repositories/SnapshotRepository.cs @@ -83,4 +83,26 @@ internal sealed class SnapshotRepository : ISnapshotRepository public async Task SaveChangesAsync(CancellationToken ct = default) => await _db.SaveChangesAsync(ct); + + public async Task GetLatestPreMatchAsync( + EventId eventId, + DateTimeOffset atOrBefore, + CancellationToken ct = default) + { + // OddsSource enum: PreMatch == 0. Inlined as an int constant to keep the + // expression EF-translatable (the IL would otherwise carry a cast). + const int preMatchSource = (int)Marathon.Domain.Enums.OddsSource.PreMatch; + + var toStr = atOrBefore.ToString("O"); + + var entity = await _db.Snapshots.AsNoTracking() + .Include(s => s.Bets) + .Where(s => s.EventCode == eventId.Value + && s.Source == preMatchSource + && s.CapturedAt.CompareTo(toStr) <= 0) + .OrderByDescending(s => s.CapturedAt) + .FirstOrDefaultAsync(ct); + + return entity is null ? null : Mapping.ToDomain(entity); + } } diff --git a/src/Marathon.UI/Components/NavBody.razor b/src/Marathon.UI/Components/NavBody.razor index 6c31d2f..f3eb6ff 100644 --- a/src/Marathon.UI/Components/NavBody.razor +++ b/src/Marathon.UI/Components/NavBody.razor @@ -43,6 +43,10 @@ @L["Nav.Insights"] + + + @L["Nav.MyBets"] + diff --git a/src/Marathon.UI/Pages/MyBets/Journal.razor b/src/Marathon.UI/Pages/MyBets/Journal.razor new file mode 100644 index 0000000..1cb1c08 --- /dev/null +++ b/src/Marathon.UI/Pages/MyBets/Journal.razor @@ -0,0 +1,931 @@ +@* + Journal — the user's personal bet tracker. + + Loads a precomputed BetJournalVm and exposes it as the editorial-quant + ledger that mirrors Insights / AnomalyFeed: a hero header in the accent + tone (positive product surface, not anomaly-red), a KPI strip, a compact + record-a-bet form, and a list of every wager with P&L, CLV, and outcome. +*@ + +@page "/my-bets" +@using Marathon.Application.Betting +@implements IDisposable +@inject IStringLocalizer L +@inject IBetJournalService Service +@inject ISnackbar Snackbar +@inject ILogger Logger + +@L["App.Title"] · @L["Nav.MyBets"] + +
+
+
+ @L["Journal.Kicker"] +

@L["Journal.Title"]

+

@L["Journal.Lede"]

+
+
+ + +
+
+ + @if (_loading && _vm is null) + { +
+ + @L["Common.Loading"] +
+ } + else if (_errored && _vm is null) + { +
+ + @L["Common.Empty"] + +

+ @L["Journal.Empty.None"] +

+
+ } + else if (_vm is { } vm) + { + @* ---------- KPI strip ---------- *@ +
+
+ @L["Journal.Stat.Roi"] + @FormatSignedPercent(vm.Stats.RoiPercent) + @L["Journal.Stat.Roi.Hint"] +
+ +
+ @L["Journal.Stat.StrikeRate"] + @FormatPercent(vm.Stats.StrikeRatePercent) + @L["Journal.Stat.StrikeRate.Hint"] +
+ +
+ @L["Journal.Stat.AvgClv"] + @FormatClvPoints(vm.Stats.AverageClvProbabilityDelta) + @L["Journal.Stat.AvgClv.Hint"] +
+ +
+ @L["Journal.Stat.NetProfit"] + @FormatSignedDecimal(vm.Stats.NetProfit, vm.Stats.ResolvedCount) + @L["Journal.Stat.NetProfit.Hint"] +
+
+ +
+ @L["Journal.Stat.TotalBets"] @vm.Stats.TotalBets + + @L["Journal.Stat.Pending"] @vm.Stats.PendingCount + + @L["Journal.Stat.Won"] @vm.Stats.WonCount + + @L["Journal.Stat.Lost"] @vm.Stats.LostCount + + @L["Journal.Stat.Void"] @vm.Stats.VoidCount +
+ +
+ + @* ---------- Record-a-bet form ---------- *@ +
+
+ @L["Journal.Section.Add"] +
+ +
+
+
+ + + @L["Journal.Field.EventId.Hint"] +
+ +
+ + + @foreach (var betType in _betTypes) + { + @BetTypeLabel(betType) + } + +
+ +
+ + + @foreach (var side in SidesFor(_form.Type)) + { + @SideLabel(side) + } + +
+ + @if (_form.Type is BetType.WinFora or BetType.Total) + { +
+ + + @L["Journal.Field.Value.Hint"] +
+ } + +
+ + +
+ +
+ + +
+ +
+ + +
+
+ + @if (!string.IsNullOrEmpty(_formError)) + { +

@_formError

+ } + +
+ +
+
+
+ +
+ + @* ---------- Bets list ---------- *@ +
+
+ @L["Journal.Section.List"] + @vm.Bets.Count +
+ + @if (vm.Bets.Count == 0) + { +
+ + @L["Common.Empty"] + +

+ @L["Journal.Empty.None"] +

+
+ } + else + { +
+ + + + + + + + + + + + + + + + @foreach (var bet in vm.Bets) + { + var row = bet; + + + + + + + + + + + + } + +
@L["Journal.Column.PlacedAt"]@L["Journal.Column.Match"]@L["Journal.Column.Selection"]@L["Journal.Column.Stake"]@L["Journal.Column.Rate"]@L["Journal.Column.Profit"]@L["Journal.Column.Clv"]@L["Journal.Column.Outcome"]
@row.Bet.PlacedAt.ToLocalTime().ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture)@row.EventTitle +
@SelectionLabel(row.Bet.Selection)
+ @if (!string.IsNullOrWhiteSpace(row.Bet.Notes)) + { +
@row.Bet.Notes
+ } +
@row.Bet.Stake.ToString("0.00", CultureInfo.InvariantCulture)@row.Bet.Selection.Rate.Value.ToString("0.00", CultureInfo.InvariantCulture) + @FormatProfit(row.Bet.NetProfit) + + @FormatClvPoints(row.ClvProbabilityDelta) + + + @OutcomeLabel(row.Bet.Outcome) + + + @if (_pendingDeleteId == row.Id) + { + + @L["Journal.Confirm.Delete"] + + + + } + else + { + + } +
+
+ } +
+ } +
+ + + +@code { + private static readonly BetType[] _betTypes = + { BetType.Win, BetType.Draw, BetType.WinFora, BetType.Total }; + + private BetJournalVm? _vm; + private bool _loading = true; + private bool _errored; + private bool _submitting; + private bool _resolving; + private string? _formError; + private Guid? _pendingDeleteId; + private AddBetForm _form = new(); + private CancellationTokenSource? _loadCts; + + protected override async Task OnInitializedAsync() + { + await LoadAsync(); + } + + private async Task LoadAsync() + { + _loadCts?.Cancel(); + _loadCts = new CancellationTokenSource(); + var ct = _loadCts.Token; + + _loading = true; + _errored = false; + StateHasChanged(); + + try + { + var report = await Service.GetReportAsync(ct); + if (ct.IsCancellationRequested) return; + _vm = report; + _pendingDeleteId = null; + } + catch (OperationCanceledException) { /* superseded */ } + catch (Exception ex) + { + Logger.LogError(ex, "Failed to load bet journal report."); + _errored = true; + _vm = null; + } + finally + { + _loading = false; + StateHasChanged(); + } + } + + private async Task ResolvePendingAsync() + { + if (_resolving) return; + _resolving = true; + StateHasChanged(); + var ct = _loadCts?.Token ?? CancellationToken.None; + try + { + var graded = await Service.ResolvePendingAsync(ct); + var msg = graded == 0 + ? L["Journal.Resolve.None"].Value + : string.Format(CultureInfo.CurrentCulture, L["Journal.Resolve.Done"].Value, graded); + Snackbar.Add(msg, graded == 0 ? Severity.Info : Severity.Success); + } + catch (OperationCanceledException) { /* superseded */ } + catch (Exception ex) + { + Logger.LogError(ex, "Failed to resolve pending bets."); + Snackbar.Add(L["Journal.Error.Generic"].Value, Severity.Error); + } + finally + { + _resolving = false; + await LoadAsync(); + } + } + + private async Task SubmitAsync() + { + if (_submitting) return; + _formError = null; + + if (!_form.IsValid(out var err)) + { + _formError = err; + StateHasChanged(); + return; + } + + _submitting = true; + StateHasChanged(); + var ct = _loadCts?.Token ?? CancellationToken.None; + try + { + await Service.AddAsync(_form, ct); + _form = new AddBetForm(); + _formError = null; + await LoadAsync(); + } + catch (ArgumentException ex) + { + _formError = ex.Message; + } + catch (InvalidOperationException ex) + { + _formError = ex.Message; + } + catch (Exception ex) + { + Logger.LogError(ex, "Failed to record bet."); + _formError = L["Journal.Error.Generic"].Value; + } + finally + { + _submitting = false; + StateHasChanged(); + } + } + + private void OnTypeChanged(BetType next) + { + _form.Type = next; + var valid = SidesFor(next); + if (!valid.Contains(_form.Side)) + { + _form.Side = valid[0]; + } + if (next is not (BetType.WinFora or BetType.Total)) + { + _form.Value = null; + } + } + + private void RequestDelete(Guid id) + { + _pendingDeleteId = id; + } + + private void CancelDelete() + { + _pendingDeleteId = null; + } + + private async Task ConfirmDeleteAsync(Guid id) + { + var ct = _loadCts?.Token ?? CancellationToken.None; + try + { + await Service.DeleteAsync(id, ct); + } + catch (OperationCanceledException) { /* superseded */ } + catch (Exception ex) + { + Logger.LogError(ex, "Failed to delete bet {BetId}.", id); + Snackbar.Add(L["Journal.Error.Generic"].Value, Severity.Error); + } + finally + { + _pendingDeleteId = null; + await LoadAsync(); + } + } + + // ---- Formatting / labels ------------------------------------------------ + + private static IReadOnlyList SidesFor(BetType type) => type switch + { + BetType.Win => new[] { Side.Side1, Side.Side2 }, + BetType.WinFora => new[] { Side.Side1, Side.Side2 }, + BetType.Draw => new[] { Side.Draw }, + BetType.Total => new[] { Side.Less, Side.More }, + _ => new[] { Side.Side1 }, + }; + + private string BetTypeLabel(BetType type) => type switch + { + BetType.Win => L["Journal.BetType.Win"], + BetType.Draw => L["Journal.BetType.Draw"], + BetType.WinFora => L["Journal.BetType.WinFora"], + BetType.Total => L["Journal.BetType.Total"], + _ => type.ToString(), + }; + + private string SideLabel(Side side) => side switch + { + Side.Side1 => L["Journal.Side.Side1"], + Side.Side2 => L["Journal.Side.Side2"], + Side.Draw => L["Journal.Side.Draw"], + Side.Less => L["Journal.Side.Less"], + Side.More => L["Journal.Side.More"], + _ => side.ToString(), + }; + + private string OutcomeLabel(BetOutcome o) => o switch + { + BetOutcome.Won => L["Journal.Outcome.Won"], + BetOutcome.Lost => L["Journal.Outcome.Lost"], + BetOutcome.Void => L["Journal.Outcome.Void"], + BetOutcome.Pending => L["Journal.Outcome.Pending"], + _ => L["Journal.Outcome.Pending"], + }; + + private static string OutcomeCss(BetOutcome o) => o switch + { + BetOutcome.Won => "won", + BetOutcome.Lost => "lost", + BetOutcome.Void => "void", + BetOutcome.Pending => "pending", + _ => "pending", + }; + + private string SelectionLabel(Bet selection) + { + var typeText = BetTypeLabel(selection.Type); + var sideText = SideLabel(selection.Side); + var rate = selection.Rate.Value.ToString("0.00", CultureInfo.InvariantCulture); + if (selection.Value is { } v) + { + var threshold = v.Value.ToString("0.##", CultureInfo.InvariantCulture); + return typeText + " " + sideText + " " + threshold + " @ " + rate; + } + return typeText + " " + sideText + " @ " + rate; + } + + private static string FormatSignedPercent(decimal? value) + { + if (value is null) return "—"; + var v = value.Value; + var sign = v > 0m ? "+" : (v < 0m ? "-" : ""); + var abs = Math.Abs(v); + return sign + abs.ToString("0.0", CultureInfo.InvariantCulture) + "%"; + } + + private static string FormatPercent(decimal? value) + { + if (value is null) return "—"; + // Show one decimal so strike-rate 66.67% does not collapse to 67% — + // the user wants to see "50.5%" rather than be lied to. + return value.Value.ToString("0.0", CultureInfo.InvariantCulture) + "%"; + } + + private static string FormatClvPoints(decimal? probabilityDelta) + { + if (probabilityDelta is null) return "—"; + var pts = probabilityDelta.Value * 100m; + var sign = pts > 0m ? "+" : (pts < 0m ? "-" : ""); + var abs = Math.Abs(pts); + return sign + abs.ToString("0.0", CultureInfo.InvariantCulture) + " pp"; + } + + private static string FormatSignedDecimal(decimal value, int resolvedCount) + { + if (resolvedCount == 0) return "—"; + var sign = value > 0m ? "+" : (value < 0m ? "-" : ""); + var abs = Math.Abs(value); + return sign + abs.ToString("0.00", CultureInfo.InvariantCulture); + } + + private static string FormatProfit(decimal? value) + { + if (value is null) return "—"; + var v = value.Value; + var sign = v > 0m ? "+" : (v < 0m ? "-" : ""); + var abs = Math.Abs(v); + return sign + abs.ToString("0.00", CultureInfo.InvariantCulture); + } + + private static string SignedTone(decimal? value) => value switch + { + null => "neutral", + > 0m => "positive", + < 0m => "negative", + _ => "neutral", + }; + + private static string ProfitTone(decimal? value) => value switch + { + null => "neutral", + > 0m => "positive", + < 0m => "negative", + _ => "neutral", + }; + + private static string ClvTone(decimal? value) => value switch + { + null => "neutral", + > 0m => "positive", + < 0m => "negative", + _ => "neutral", + }; + + public void Dispose() + { + _loadCts?.Cancel(); + _loadCts?.Dispose(); + } +} diff --git a/src/Marathon.UI/Resources/SharedResource.en.resx b/src/Marathon.UI/Resources/SharedResource.en.resx index 67b4899..172ab34 100644 --- a/src/Marathon.UI/Resources/SharedResource.en.resx +++ b/src/Marathon.UI/Resources/SharedResource.en.resx @@ -347,4 +347,67 @@ Refresh Open + + My bets + Journal + Your bets and CLV + Every wager you've recorded, graded against final results and scored against the closing line. Positive CLV is the leading indicator that says you're consistently beating the market. + ROI + Net profit ÷ total staked. + Strike rate + Wins ÷ (wins + losses). + Avg CLV + Mean closing-line implied-probability gain. + Net profit + Returns minus stakes (resolved bets). + Total bets + Pending + Won + Lost + Void + Record a bet + Bet journal + Refresh + Resolve pending + Record bet + Delete + Confirm + Cancel + Event ID + Numeric ID from the event detail URL. + Bet type + Side + Threshold + Handicap or total line (e.g. -1.5, 2.5). + Taken rate + Stake + Notes + Strategy tag, bookmaker, or anything you want to remember… + Win + Draw + Handicap + Total + Side 1 + Side 2 + Draw + Under + Over + Pending + Won + Lost + Void + Placed + Match + Selection + Stake + Rate + P&L + CLV + Outcome + No bets recorded yet. Use the form above to log a wager — once the event finishes the journal will auto-grade it and compute closing-line value against the latest pre-match snapshot. + + Failed to save bet — check the event ID and try again. + No pending bets needed grading. + Graded {0} pending bet(s). + Delete this bet permanently? diff --git a/src/Marathon.UI/Resources/SharedResource.ru.resx b/src/Marathon.UI/Resources/SharedResource.ru.resx index f489384..38e4371 100644 --- a/src/Marathon.UI/Resources/SharedResource.ru.resx +++ b/src/Marathon.UI/Resources/SharedResource.ru.resx @@ -360,4 +360,67 @@ Обновить Открыть + + Мои ставки + Журнал + Ваши ставки и CLV + Каждая зафиксированная ставка с автоматическим расчётом результата и оценкой против линии закрытия. Положительный CLV — главный долгосрочный индикатор того, что вы стабильно обыгрываете рынок. + ROI + Чистая прибыль ÷ сумма ставок. + Strike rate + Победы ÷ (победы + поражения). + Средний CLV + Средний прирост вероятности к линии закрытия. + Чистая прибыль + Возвраты минус ставки (учтены сыгравшие). + Всего ставок + В ожидании + Победа + Проигрыш + Возврат + Записать ставку + Журнал ставок + Обновить + Рассчитать ожидающие + Записать + Удалить + Подтвердить + Отмена + ID события + Числовой ID из URL детальной страницы. + Тип ставки + Сторона + Порог + Гандикап или тотал (например −1.5, 2.5). + Кэф на момент ставки + Сумма ставки + Заметки + Тег стратегии, букмекер или что угодно для памяти… + Победа + Ничья + Фора + Тотал + Сторона 1 + Сторона 2 + Ничья + Меньше + Больше + Ожидает + Победа + Проигрыш + Возврат + Размещено + Матч + Выбор + Ставка + Кэф + P&L + CLV + Итог + Ставок пока нет. Запишите свою ставку через форму выше — после окончания матча журнал авто-проставит результат и посчитает CLV против последнего пре-матч снимка. + + Не удалось сохранить ставку — проверьте ID события и повторите. + Ожидающих ставок к расчёту нет. + Рассчитано ожидающих: {0}. + Удалить эту ставку безвозвратно? diff --git a/src/Marathon.UI/Services/BetJournalService.cs b/src/Marathon.UI/Services/BetJournalService.cs new file mode 100644 index 0000000..ec32798 --- /dev/null +++ b/src/Marathon.UI/Services/BetJournalService.cs @@ -0,0 +1,98 @@ +using Marathon.Application.Abstractions; +using Marathon.Application.UseCases; +using Marathon.Domain.Entities; +using Marathon.Domain.Enums; +using Marathon.Domain.ValueObjects; +using DomainEventId = Marathon.Domain.ValueObjects.EventId; + +namespace Marathon.UI.Services; + +/// +/// Page-facing implementation of . Composes the +/// four bet-journal use cases and joins event titles for the table rows. +/// +public sealed class BetJournalService : IBetJournalService +{ + private readonly BuildBetJournalReportUseCase _build; + private readonly RecordPlacedBetUseCase _record; + private readonly ResolvePendingBetsUseCase _resolve; + private readonly DeletePlacedBetUseCase _delete; + private readonly IEventRepository _events; + + public BetJournalService( + BuildBetJournalReportUseCase build, + RecordPlacedBetUseCase record, + ResolvePendingBetsUseCase resolve, + DeletePlacedBetUseCase delete, + IEventRepository events) + { + _build = build ?? throw new ArgumentNullException(nameof(build)); + _record = record ?? throw new ArgumentNullException(nameof(record)); + _resolve = resolve ?? throw new ArgumentNullException(nameof(resolve)); + _delete = delete ?? throw new ArgumentNullException(nameof(delete)); + _events = events ?? throw new ArgumentNullException(nameof(events)); + } + + public async Task GetReportAsync(CancellationToken ct) + { + var report = await _build.ExecuteAsync(ct).ConfigureAwait(false); + + if (report.Bets.Count == 0) + return new BetJournalVm(report.Stats, Array.Empty()); + + // Resolve event titles in one pass — distinct ids only. + var distinctIds = report.Bets.Select(r => r.Bet.EventId).Distinct().ToList(); + var titles = new Dictionary(distinctIds.Count); + foreach (var id in distinctIds) + { + ct.ThrowIfCancellationRequested(); + var ev = await _events.GetAsync(id, ct).ConfigureAwait(false); + titles[id] = ev is not null + ? string.Concat(ev.Side1Name, " vs ", ev.Side2Name) + : id.Value; + } + + var rows = report.Bets + .Select(r => new BetJournalRowVm( + Id: r.Bet.Id, + EventTitle: titles.TryGetValue(r.Bet.EventId, out var t) ? t : r.Bet.EventId.Value, + Bet: r.Bet, + ClvProbabilityDelta: r.ClvProbabilityDelta)) + .ToList(); + + return new BetJournalVm(report.Stats, rows); + } + + public async Task AddAsync(AddBetForm form, CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(form); + + if (!form.IsValid(out var error)) + throw new ArgumentException(error ?? "Invalid form.", nameof(form)); + + var selection = new Bet( + scope: MatchScope.Instance, + type: form.Type, + side: form.Side, + value: form.Value is { } v ? new OddsValue(v) : null, + rate: new OddsRate(form.Rate)); + + var bet = new PlacedBet( + Id: Guid.NewGuid(), + EventId: new DomainEventId(form.EventId.Trim()), + Selection: selection, + Stake: form.Stake, + PlacedAt: MoscowTime.Now, + Outcome: BetOutcome.Pending, + Notes: form.Notes); + + var stored = await _record.ExecuteAsync(bet, ct).ConfigureAwait(false); + return stored.Id; + } + + public Task DeleteAsync(Guid betId, CancellationToken ct) => + _delete.ExecuteAsync(betId, ct); + + public Task ResolvePendingAsync(CancellationToken ct) => + _resolve.ExecuteAsync(ct); +} diff --git a/src/Marathon.UI/Services/BetJournalViewModels.cs b/src/Marathon.UI/Services/BetJournalViewModels.cs new file mode 100644 index 0000000..1e623f3 --- /dev/null +++ b/src/Marathon.UI/Services/BetJournalViewModels.cs @@ -0,0 +1,86 @@ +using Marathon.Application.Betting; +using Marathon.Domain.Entities; +using Marathon.Domain.Enums; + +namespace Marathon.UI.Services; + +/// +/// Page-facing projection of . Adds the +/// pre-shaped event title per row so the Journal page never has to round-trip +/// back to . +/// +public sealed record BetJournalVm( + BetJournalStats Stats, + IReadOnlyList Bets); + +/// Row-level view model for the journal table. +public sealed record BetJournalRowVm( + Guid Id, + string EventTitle, + PlacedBet Bet, + decimal? ClvProbabilityDelta); + +/// +/// Data the Add-Bet form posts. Loose-typed so the form can bind raw inputs; +/// applies the same invariants as +/// / and surfaces validation errors +/// as exceptions. +/// +public sealed class AddBetForm +{ + public string EventId { get; set; } = string.Empty; + + /// Bet type enum value — defaults to Win. + public BetType Type { get; set; } = BetType.Win; + + /// Side enum value — Win/Side1 default. + public Side Side { get; set; } = Side.Side1; + + /// Handicap / total threshold; required for WinFora and Total. + public decimal? Value { get; set; } + + /// The decimal odds the user took at placement. + public decimal Rate { get; set; } = 1.90m; + + public decimal Stake { get; set; } = 100m; + + public string? Notes { get; set; } + + /// Upper sanity caps so a typo cannot torch the KPI strip. + public const decimal MaxRate = 1000m; + + /// Upper sanity cap on a single wager. + public const decimal MaxStake = 10_000_000m; + + public bool IsValid(out string? error) + { + if (string.IsNullOrWhiteSpace(EventId)) { error = "EventId is required."; return false; } + if (Stake <= 0m) { error = "Stake must be positive."; return false; } + if (Stake > MaxStake) { error = $"Stake must be at most {MaxStake:N0}."; return false; } + if (Rate < 1.01m) { error = "Rate must be at least 1.01."; return false; } + if (Rate > MaxRate) { error = $"Rate must be at most {MaxRate:N0}."; return false; } + + // Mirror Bet invariants — surface a friendly message instead of throwing + // ArgumentException deep in the use case. + switch (Type) + { + case BetType.Win: + if (Side is not (Side.Side1 or Side.Side2)) { error = "Win bet requires Side1 or Side2."; return false; } + break; + case BetType.Draw: + if (Side != Side.Draw) { error = "Draw bet requires Side = Draw."; return false; } + break; + case BetType.WinFora: + if (Side is not (Side.Side1 or Side.Side2)) { error = "Handicap bet requires Side1 or Side2."; return false; } + if (Value is null or 0m) { error = "Handicap bet needs a non-zero threshold."; return false; } + break; + case BetType.Total: + if (Side is not (Side.Less or Side.More)) { error = "Total bet requires Less or More."; return false; } + if (Value is null or 0m) { error = "Total bet needs a non-zero threshold."; return false; } + break; + } + + error = null; + return true; + } +} diff --git a/src/Marathon.UI/Services/IBetJournalService.cs b/src/Marathon.UI/Services/IBetJournalService.cs new file mode 100644 index 0000000..4e1c5b9 --- /dev/null +++ b/src/Marathon.UI/Services/IBetJournalService.cs @@ -0,0 +1,29 @@ +namespace Marathon.UI.Services; + +/// +/// Browsing facade over the bet-journal use cases. The Journal page binds to +/// this — never the use cases directly — so view-model shaping, event-title +/// joining, and validation surface in one place. +/// +public interface IBetJournalService +{ + /// Builds the full report and projects it for the UI. + Task GetReportAsync(CancellationToken ct); + + /// + /// Validates and persists a manually entered bet. Returns the newly stored + /// row's id. Throws when the form + /// validates but references an unknown event. + /// + /// Form fails its own validation. + Task AddAsync(AddBetForm form, CancellationToken ct); + + /// Removes a bet by id. No-op when the id is unknown. + Task DeleteAsync(Guid betId, CancellationToken ct); + + /// + /// Sweeps pending bets and grades the ones whose events are now resolved. + /// Returns the count graded in this pass. + /// + Task ResolvePendingAsync(CancellationToken ct); +} diff --git a/src/Marathon.UI/Services/UiServicesExtensions.cs b/src/Marathon.UI/Services/UiServicesExtensions.cs index 6ddb3e1..e0b4fb8 100644 --- a/src/Marathon.UI/Services/UiServicesExtensions.cs +++ b/src/Marathon.UI/Services/UiServicesExtensions.cs @@ -59,6 +59,7 @@ public static class UiServicesExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); // Settings writer — file path is host-resolved. services.AddSingleton(_ => new JsonSettingsWriter(settingsLocalPath)); diff --git a/tests/Marathon.Application.Tests/Betting/ClosingLineValueCalculatorTests.cs b/tests/Marathon.Application.Tests/Betting/ClosingLineValueCalculatorTests.cs new file mode 100644 index 0000000..8b00d1e --- /dev/null +++ b/tests/Marathon.Application.Tests/Betting/ClosingLineValueCalculatorTests.cs @@ -0,0 +1,96 @@ +using FluentAssertions; +using Marathon.Application.Betting; +using Marathon.Domain.Entities; +using Marathon.Domain.Enums; +using Marathon.Domain.ValueObjects; + +namespace Marathon.Application.Tests.Betting; + +/// +/// Unit tests for covering the math +/// itself and the snapshot-matching path used by the report use case. +/// +public sealed class ClosingLineValueCalculatorTests +{ + private static readonly EventId EventId = new("11111111"); + + [Fact] + public void Compute_Should_ReturnPositive_When_TakenRate_BeatsClose() + { + // Taken 2.20 (implied 0.4545); closed 2.00 (implied 0.5000) → CLV = +0.0455 + var clv = ClosingLineValueCalculator.Compute(takenRate: 2.20m, closingRate: 2.00m); + + clv.Should().BeGreaterThan(0m); + clv.Should().BeApproximately(0.04545m, 0.00001m); + } + + [Fact] + public void Compute_Should_ReturnNegative_When_TakenRate_WorseThanClose() + { + // Taken 1.80 (0.5556); closed 2.00 (0.5000) → CLV = -0.0556 + var clv = ClosingLineValueCalculator.Compute(takenRate: 1.80m, closingRate: 2.00m); + + clv.Should().BeLessThan(0m); + clv.Should().BeApproximately(-0.05556m, 0.00001m); + } + + [Fact] + public void Compute_Should_ReturnZero_When_RatesMatch() + { + ClosingLineValueCalculator.Compute(2.00m, 2.00m).Should().Be(0m); + } + + [Theory] + [InlineData(0)] + [InlineData(-1)] + public void Compute_Should_Throw_When_AnyRateIsZeroOrNegative(decimal rate) + { + ((Action)(() => ClosingLineValueCalculator.Compute(rate, 2m))) + .Should().Throw(); + ((Action)(() => ClosingLineValueCalculator.Compute(2m, rate))) + .Should().Throw(); + } + + [Fact] + public void TryCompute_Should_ReturnNull_When_NoMatchingBetInSnapshot() + { + var taken = new Bet(MatchScope.Instance, BetType.Win, Side.Side1, + value: null, new OddsRate(2.20m)); + + // Snapshot contains only Side2 — no match for Side1 Win. + var snapshot = new OddsSnapshot(EventId, DateTimeOffset.UtcNow, OddsSource.PreMatch, + new[] { new Bet(MatchScope.Instance, BetType.Win, Side.Side2, null, new OddsRate(1.70m)) }); + + var clv = ClosingLineValueCalculator.TryCompute(2.20m, taken, snapshot); + + clv.Should().BeNull(); + } + + [Fact] + public void TryCompute_Should_ReturnNull_When_SnapshotIsNull() + { + var taken = new Bet(MatchScope.Instance, BetType.Win, Side.Side1, null, new OddsRate(2m)); + ClosingLineValueCalculator.TryCompute(2m, taken, closingSnapshot: null).Should().BeNull(); + } + + [Fact] + public void TryCompute_Should_MatchOnScopeTypeSideAndValue() + { + // Two handicap markets with different thresholds — pick the right one. + var taken = new Bet(MatchScope.Instance, BetType.WinFora, Side.Side1, + new OddsValue(-1.5m), new OddsRate(2.20m)); + + var snapshot = new OddsSnapshot(EventId, DateTimeOffset.UtcNow, OddsSource.PreMatch, + new[] + { + new Bet(MatchScope.Instance, BetType.WinFora, Side.Side1, + new OddsValue(-2.5m), new OddsRate(3.50m)), // wrong threshold + new Bet(MatchScope.Instance, BetType.WinFora, Side.Side1, + new OddsValue(-1.5m), new OddsRate(2.00m)), // match + }); + + var clv = ClosingLineValueCalculator.TryCompute(2.20m, taken, snapshot); + + clv.Should().BeApproximately(0.04545m, 0.00001m); + } +} diff --git a/tests/Marathon.Application.Tests/UseCases/BuildBetJournalReportUseCaseTests.cs b/tests/Marathon.Application.Tests/UseCases/BuildBetJournalReportUseCaseTests.cs new file mode 100644 index 0000000..e3f622e --- /dev/null +++ b/tests/Marathon.Application.Tests/UseCases/BuildBetJournalReportUseCaseTests.cs @@ -0,0 +1,204 @@ +using FluentAssertions; +using Marathon.Application.Abstractions; +using Marathon.Application.UseCases; +using Marathon.Domain.Entities; +using Marathon.Domain.Enums; +using Marathon.Domain.ValueObjects; +using Microsoft.Extensions.Logging.Abstractions; +using NSubstitute; + +namespace Marathon.Application.Tests.UseCases; + +public sealed class BuildBetJournalReportUseCaseTests +{ + private static readonly TimeSpan MoscowOffset = TimeSpan.FromHours(3); + private static readonly DateTimeOffset Placed = new(2026, 5, 16, 12, 0, 0, MoscowOffset); + private static readonly DateTimeOffset Kickoff = new(2026, 5, 16, 18, 0, 0, MoscowOffset); + + private readonly IPlacedBetRepository _bets = Substitute.For(); + private readonly IEventRepository _events = Substitute.For(); + private readonly ISnapshotRepository _snapshots = Substitute.For(); + + private BuildBetJournalReportUseCase CreateSut() => + new(_bets, _events, _snapshots, NullLogger.Instance); + + private static PlacedBet MakeBet( + EventId id, + BetOutcome outcome, + Side side = Side.Side1, + decimal stake = 100m, + decimal rate = 2.10m) => + new( + Guid.NewGuid(), id, + new Bet(MatchScope.Instance, BetType.Win, side, null, new OddsRate(rate)), + stake, Placed, outcome, null); + + private static Event MakeEvent(EventId id, DateTimeOffset scheduledAt) => + new(id, new SportCode(11), "BY", "L1", "Cat", scheduledAt, "Team A", "Team B"); + + private static OddsSnapshot MakeSnapshot(EventId id, DateTimeOffset at, decimal rateSide1) => + new(id, at, OddsSource.PreMatch, + new[] + { + new Bet(MatchScope.Instance, BetType.Win, Side.Side1, null, new OddsRate(rateSide1)), + new Bet(MatchScope.Instance, BetType.Win, Side.Side2, null, new OddsRate(2.00m)), + }); + + [Fact] + public async Task Should_ReturnEmptyReport_When_NoBets() + { + _bets.ListAsync(Arg.Any()) + .Returns(Array.Empty().ToList().AsReadOnly()); + + var report = await CreateSut().ExecuteAsync(CancellationToken.None); + + report.Bets.Should().BeEmpty(); + report.Stats.TotalBets.Should().Be(0); + report.Stats.RoiPercent.Should().BeNull(); + } + + [Fact] + public async Task Should_AggregateStats_AcrossMixedOutcomes() + { + var id1 = new EventId("e-1"); + var id2 = new EventId("e-2"); + var id3 = new EventId("e-3"); + + _bets.ListAsync(Arg.Any()) + .Returns(new[] + { + MakeBet(id1, BetOutcome.Won, stake: 100m, rate: 2.00m), // gross 200, +100 + MakeBet(id2, BetOutcome.Lost, stake: 100m, rate: 2.00m), // gross 0, -100 + MakeBet(id3, BetOutcome.Pending), + }.ToList().AsReadOnly()); + + // Wire events so the report can compute CLV (we don't need actual CLV here — leave snapshots empty). + foreach (var id in new[] { id1, id2, id3 }) + { + _events.GetAsync(id, Arg.Any()).Returns(MakeEvent(id, Kickoff)); + _snapshots.GetLatestPreMatchAsync(id, Arg.Any(), Arg.Any()) + .Returns((OddsSnapshot?)null); + } + + var report = await CreateSut().ExecuteAsync(CancellationToken.None); + + report.Stats.TotalBets.Should().Be(3); + report.Stats.PendingCount.Should().Be(1); + report.Stats.WonCount.Should().Be(1); + report.Stats.LostCount.Should().Be(1); + report.Stats.TotalStaked.Should().Be(200m, "pending bets are excluded from totals"); + report.Stats.TotalReturned.Should().Be(200m); + report.Stats.NetProfit.Should().Be(0m); + report.Stats.RoiPercent.Should().Be(0m); + report.Stats.StrikeRatePercent.Should().Be(50m); + } + + [Fact] + public async Task Should_ComputeClv_AgainstClosingSnapshot() + { + var id = new EventId("clv-event"); + var bet = MakeBet(id, BetOutcome.Won, rate: 2.20m, stake: 100m); + + _bets.ListAsync(Arg.Any()) + .Returns(new[] { bet }.ToList().AsReadOnly()); + + _events.GetAsync(id, Arg.Any()).Returns(MakeEvent(id, Kickoff)); + + // Closing snapshot returned by the dedicated repo method. + _snapshots.GetLatestPreMatchAsync(id, Kickoff, Arg.Any()) + .Returns(MakeSnapshot(id, Kickoff.AddMinutes(-5), rateSide1: 2.00m)); + + var report = await CreateSut().ExecuteAsync(CancellationToken.None); + + report.Bets.Should().HaveCount(1); + report.Bets[0].ClvProbabilityDelta.Should().NotBeNull(); + // taken 2.20 vs closing 2.00 → +0.04545 + report.Bets[0].ClvProbabilityDelta!.Value.Should().BeApproximately(0.04545m, 0.00001m); + report.Stats.AverageClvProbabilityDelta.Should().NotBeNull(); + } + + [Fact] + public async Task Should_LeaveClvNull_When_NoClosingSnapshotAvailable() + { + var id = new EventId("no-close"); + _bets.ListAsync(Arg.Any()) + .Returns(new[] { MakeBet(id, BetOutcome.Won) }.ToList().AsReadOnly()); + _events.GetAsync(id, Arg.Any()).Returns(MakeEvent(id, Kickoff)); + _snapshots.GetLatestPreMatchAsync(id, Kickoff, Arg.Any()) + .Returns((OddsSnapshot?)null); + + var report = await CreateSut().ExecuteAsync(CancellationToken.None); + + report.Bets[0].ClvProbabilityDelta.Should().BeNull(); + report.Stats.AverageClvProbabilityDelta.Should().BeNull( + "no rows had a computable CLV — average is undefined"); + } + + [Fact] + public async Task Should_ExcludeVoidStakes_FromRoiTurnover() + { + // 1 Won (+100), 1 Lost (-100), 1 Void (stake returned). Industry-standard + // ROI excludes pushes from turnover, so total staked = 200, returned 200, + // net 0, ROI 0%. If voids were included turnover would be 300 → ROI ≈ 0% + // numerator but inflated denominator semantics. + var ids = Enumerable.Range(1, 3) + .Select(i => new EventId($"void-{i}")).ToArray(); + + _bets.ListAsync(Arg.Any()) + .Returns(new[] + { + MakeBet(ids[0], BetOutcome.Won, stake: 100m, rate: 2.00m), + MakeBet(ids[1], BetOutcome.Lost, stake: 100m, rate: 2.00m), + MakeBet(ids[2], BetOutcome.Void, stake: 100m, rate: 2.00m), + }.ToList().AsReadOnly()); + + foreach (var id in ids) + { + _events.GetAsync(id, Arg.Any()).Returns(MakeEvent(id, Kickoff)); + _snapshots.GetLatestPreMatchAsync(id, Arg.Any(), Arg.Any()) + .Returns((OddsSnapshot?)null); + } + + var report = await CreateSut().ExecuteAsync(CancellationToken.None); + + report.Stats.VoidCount.Should().Be(1); + report.Stats.TotalStaked.Should().Be(200m, + "void bets are pushes — the stake was returned and should not count as turnover"); + report.Stats.TotalReturned.Should().Be(200m); + report.Stats.NetProfit.Should().Be(0m); + report.Stats.RoiPercent.Should().Be(0m); + } + + [Fact] + public async Task Should_OrderBets_NewestPlacedFirst() + { + var ids = Enumerable.Range(0, 3).Select(i => new EventId($"ord-{i}")).ToArray(); + var older = new DateTimeOffset(2026, 5, 10, 12, 0, 0, MoscowOffset); + var newer = new DateTimeOffset(2026, 5, 16, 12, 0, 0, MoscowOffset); + + // Bet 0 is the middle one, bet 1 oldest, bet 2 newest. + var b0 = new PlacedBet(Guid.NewGuid(), ids[0], + new Bet(MatchScope.Instance, BetType.Win, Side.Side1, null, new OddsRate(2m)), + 100m, older.AddDays(1), BetOutcome.Won, null); + var b1 = new PlacedBet(Guid.NewGuid(), ids[1], + new Bet(MatchScope.Instance, BetType.Win, Side.Side1, null, new OddsRate(2m)), + 100m, older, BetOutcome.Lost, null); + var b2 = new PlacedBet(Guid.NewGuid(), ids[2], + new Bet(MatchScope.Instance, BetType.Win, Side.Side1, null, new OddsRate(2m)), + 100m, newer, BetOutcome.Pending, null); + + _bets.ListAsync(Arg.Any()) + .Returns(new[] { b0, b1, b2 }.ToList().AsReadOnly()); + + foreach (var id in ids) + { + _events.GetAsync(id, Arg.Any()).Returns(MakeEvent(id, Kickoff)); + _snapshots.GetLatestPreMatchAsync(id, Arg.Any(), Arg.Any()) + .Returns((OddsSnapshot?)null); + } + + var report = await CreateSut().ExecuteAsync(CancellationToken.None); + + report.Bets.Select(r => r.Bet.Id).Should().ContainInOrder(b2.Id, b0.Id, b1.Id); + } +} diff --git a/tests/Marathon.Application.Tests/UseCases/RecordPlacedBetUseCaseTests.cs b/tests/Marathon.Application.Tests/UseCases/RecordPlacedBetUseCaseTests.cs new file mode 100644 index 0000000..82e6806 --- /dev/null +++ b/tests/Marathon.Application.Tests/UseCases/RecordPlacedBetUseCaseTests.cs @@ -0,0 +1,75 @@ +using FluentAssertions; +using Marathon.Application.Abstractions; +using Marathon.Application.UseCases; +using Marathon.Domain.Entities; +using Marathon.Domain.Enums; +using Marathon.Domain.ValueObjects; +using Microsoft.Extensions.Logging.Abstractions; +using NSubstitute; + +namespace Marathon.Application.Tests.UseCases; + +public sealed class RecordPlacedBetUseCaseTests +{ + private static readonly TimeSpan MoscowOffset = TimeSpan.FromHours(3); + + private readonly IPlacedBetRepository _bets = Substitute.For(); + private readonly IEventRepository _events = Substitute.For(); + private readonly IResultRepository _results = Substitute.For(); + + private RecordPlacedBetUseCase CreateSut() => + new(_bets, _events, _results, NullLogger.Instance); + + private static PlacedBet MakePending(EventId id, Side side = Side.Side1) => + new( + Id: Guid.NewGuid(), + EventId: id, + Selection: new Bet(MatchScope.Instance, BetType.Win, side, null, new OddsRate(2.10m)), + Stake: 100m, + PlacedAt: new DateTimeOffset(2026, 5, 16, 12, 0, 0, MoscowOffset), + Outcome: BetOutcome.Pending, + Notes: null); + + [Fact] + public async Task Should_Throw_When_EventDoesNotExist() + { + var id = new EventId("missing"); + _events.GetAsync(id, Arg.Any()).Returns((Event?)null); + + var act = async () => await CreateSut().ExecuteAsync(MakePending(id), CancellationToken.None); + + await act.Should().ThrowAsync().WithMessage("*unknown event*"); + await _bets.DidNotReceive().AddAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task Should_PersistPending_When_NoResultYet() + { + var id = new EventId("event001"); + _events.GetAsync(id, Arg.Any()).Returns(TestFixtures.MakeEvent(id.Value)); + _results.GetAsync(id, Arg.Any()).Returns((EventResult?)null); + + var bet = MakePending(id); + + var stored = await CreateSut().ExecuteAsync(bet, CancellationToken.None); + + stored.Outcome.Should().Be(BetOutcome.Pending, "no result yet — should remain pending"); + await _bets.Received(1).AddAsync(Arg.Any(), Arg.Any()); + await _bets.Received(1).SaveChangesAsync(Arg.Any()); + } + + [Fact] + public async Task Should_AutoGrade_When_ResultAlreadyAvailable() + { + var id = new EventId("event002"); + _events.GetAsync(id, Arg.Any()).Returns(TestFixtures.MakeEvent(id.Value)); + _results.GetAsync(id, Arg.Any()) + .Returns(new EventResult(id, 2, 1, Side.Side1, DateTimeOffset.UtcNow)); + + var bet = MakePending(id, side: Side.Side1); + + var stored = await CreateSut().ExecuteAsync(bet, CancellationToken.None); + + stored.Outcome.Should().Be(BetOutcome.Won, "Side1 was selected and Side1 won"); + } +} diff --git a/tests/Marathon.Application.Tests/UseCases/ResolvePendingBetsUseCaseTests.cs b/tests/Marathon.Application.Tests/UseCases/ResolvePendingBetsUseCaseTests.cs new file mode 100644 index 0000000..cf99d92 --- /dev/null +++ b/tests/Marathon.Application.Tests/UseCases/ResolvePendingBetsUseCaseTests.cs @@ -0,0 +1,91 @@ +using FluentAssertions; +using Marathon.Application.Abstractions; +using Marathon.Application.UseCases; +using Marathon.Domain.Entities; +using Marathon.Domain.Enums; +using Marathon.Domain.ValueObjects; +using Microsoft.Extensions.Logging.Abstractions; +using NSubstitute; + +namespace Marathon.Application.Tests.UseCases; + +public sealed class ResolvePendingBetsUseCaseTests +{ + private static readonly TimeSpan MoscowOffset = TimeSpan.FromHours(3); + private static readonly DateTimeOffset Placed = + new(2026, 5, 16, 12, 0, 0, MoscowOffset); + + private readonly IPlacedBetRepository _bets = Substitute.For(); + private readonly IResultRepository _results = Substitute.For(); + + private ResolvePendingBetsUseCase CreateSut() => + new(_bets, _results, NullLogger.Instance); + + private static PlacedBet MakePending(EventId id, Side side = Side.Side1) => + new( + Guid.NewGuid(), id, + new Bet(MatchScope.Instance, BetType.Win, side, null, new OddsRate(2.10m)), + 100m, Placed, BetOutcome.Pending, null); + + [Fact] + public async Task Should_ReturnZero_When_NoPendingBets() + { + _bets.ListByOutcomeAsync(BetOutcome.Pending, Arg.Any()) + .Returns(Array.Empty().ToList().AsReadOnly()); + + var count = await CreateSut().ExecuteAsync(CancellationToken.None); + + count.Should().Be(0); + await _bets.DidNotReceive().UpdateAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task Should_GradeBetsWithResults_AndLeaveOthersAlone() + { + var idGraded = new EventId("event-1"); + var idUngraded = new EventId("event-2"); + + _bets.ListByOutcomeAsync(BetOutcome.Pending, Arg.Any()) + .Returns(new[] + { + MakePending(idGraded, side: Side.Side1), + MakePending(idUngraded, side: Side.Side2), + }.ToList().AsReadOnly()); + + _results.GetAsync(idGraded, Arg.Any()) + .Returns(new EventResult(idGraded, 2, 1, Side.Side1, DateTimeOffset.UtcNow)); + _results.GetAsync(idUngraded, Arg.Any()) + .Returns((EventResult?)null); + + var count = await CreateSut().ExecuteAsync(CancellationToken.None); + + count.Should().Be(1, "only event-1 has a result"); + await _bets.Received(1).UpdateAsync( + Arg.Is(b => b.EventId == idGraded && b.Outcome == BetOutcome.Won), + Arg.Any()); + await _bets.DidNotReceive().UpdateAsync( + Arg.Is(b => b.EventId == idUngraded), + Arg.Any()); + } + + [Fact] + public async Task Should_CacheResultLookups_PerEvent() + { + // Two pending bets on the same event — only one result fetch should fire. + var id = new EventId("event-shared"); + + _bets.ListByOutcomeAsync(BetOutcome.Pending, Arg.Any()) + .Returns(new[] + { + MakePending(id, side: Side.Side1), + MakePending(id, side: Side.Side2), + }.ToList().AsReadOnly()); + + _results.GetAsync(id, Arg.Any()) + .Returns(new EventResult(id, 2, 1, Side.Side1, DateTimeOffset.UtcNow)); + + await CreateSut().ExecuteAsync(CancellationToken.None); + + await _results.Received(1).GetAsync(id, Arg.Any()); + } +} diff --git a/tests/Marathon.Domain.Tests/Betting/BetOutcomeResolverTests.cs b/tests/Marathon.Domain.Tests/Betting/BetOutcomeResolverTests.cs new file mode 100644 index 0000000..750fb2c --- /dev/null +++ b/tests/Marathon.Domain.Tests/Betting/BetOutcomeResolverTests.cs @@ -0,0 +1,127 @@ +using FluentAssertions; +using Marathon.Domain.Betting; +using Marathon.Domain.Entities; +using Marathon.Domain.Enums; +using Marathon.Domain.ValueObjects; + +namespace Marathon.Domain.Tests.Betting; + +/// +/// Unit tests for across every bet type + +/// every important boundary (handicap push, total push, period-scope null). +/// +public sealed class BetOutcomeResolverTests +{ + private static readonly EventId EventId = new("12345678"); + + // ── Win bets ───────────────────────────────────────────────────────────── + + [Theory] + [InlineData(Side.Side1, Side.Side1, BetOutcome.Won)] + [InlineData(Side.Side1, Side.Side2, BetOutcome.Lost)] + [InlineData(Side.Side1, Side.Draw, BetOutcome.Lost)] + [InlineData(Side.Side2, Side.Side2, BetOutcome.Won)] + [InlineData(Side.Side2, Side.Side1, BetOutcome.Lost)] + public void Should_GradeWinBet(Side selectionSide, Side winner, BetOutcome expected) + { + var bet = MakeBet(BetType.Win, selectionSide); + var result = MakeResult(winner); + + BetOutcomeResolver.Resolve(bet, result).Should().Be(expected); + } + + // ── Draw bets ──────────────────────────────────────────────────────────── + + [Theory] + [InlineData(Side.Draw, BetOutcome.Won)] + [InlineData(Side.Side1, BetOutcome.Lost)] + [InlineData(Side.Side2, BetOutcome.Lost)] + public void Should_GradeDrawBet(Side winner, BetOutcome expected) + { + var bet = MakeBet(BetType.Draw, Side.Draw); + var result = MakeResult(winner, 1, 1); + + BetOutcomeResolver.Resolve(bet, result).Should().Be(expected); + } + + // ── Handicap (WinFora) ─────────────────────────────────────────────────── + + [Theory] + // Side1 with +1.5 handicap; final 0-2 → adjusted 1.5 vs 2 → loss + [InlineData(Side.Side1, 1.5, 0, 2, BetOutcome.Lost)] + // Side1 with +1.5; final 1-2 → adjusted 2.5 vs 2 → win + [InlineData(Side.Side1, 1.5, 1, 2, BetOutcome.Won)] + // Side1 with -1.5; final 3-1 → adjusted 1.5 vs 1 → win + [InlineData(Side.Side1, -1.5, 3, 1, BetOutcome.Won)] + // Whole-number handicap that ties: Side1 +1, final 1-2 → 2 vs 2 → push (Void) + [InlineData(Side.Side1, 1, 1, 2, BetOutcome.Void)] + // Side2 with -1; final 0-2 → adjusted Side2 1 vs Side1 0 → win + [InlineData(Side.Side2, -1, 0, 2, BetOutcome.Won)] + public void Should_GradeHandicapBet( + Side side, double handicap, int s1, int s2, BetOutcome expected) + { + var bet = new Bet(MatchScope.Instance, BetType.WinFora, side, + new OddsValue((decimal)handicap), new OddsRate(1.85m)); + var result = new EventResult(EventId, s1, s2, + DeriveWinner(s1, s2), DateTimeOffset.UtcNow); + + BetOutcomeResolver.Resolve(bet, result).Should().Be(expected); + } + + // ── Totals ─────────────────────────────────────────────────────────────── + + [Theory] + // Over 2.5; total 3 → win + [InlineData(Side.More, 2.5, 1, 2, BetOutcome.Won)] + // Over 2.5; total 2 → loss + [InlineData(Side.More, 2.5, 0, 2, BetOutcome.Lost)] + // Over 3.0; total 3 → push (Void) + [InlineData(Side.More, 3.0, 1, 2, BetOutcome.Void)] + // Under 2.5; total 2 → win + [InlineData(Side.Less, 2.5, 1, 1, BetOutcome.Won)] + // Under 2.5; total 3 → loss + [InlineData(Side.Less, 2.5, 1, 2, BetOutcome.Lost)] + public void Should_GradeTotalBet( + Side side, double threshold, int s1, int s2, BetOutcome expected) + { + var bet = new Bet(MatchScope.Instance, BetType.Total, side, + new OddsValue((decimal)threshold), new OddsRate(1.85m)); + var result = new EventResult(EventId, s1, s2, + DeriveWinner(s1, s2), DateTimeOffset.UtcNow); + + BetOutcomeResolver.Resolve(bet, result).Should().Be(expected); + } + + // ── Period-scope guard ─────────────────────────────────────────────────── + + [Fact] + public void Should_ReturnNull_When_BetScopeIsPeriod() + { + var bet = new Bet(new PeriodScope(1), BetType.Win, Side.Side1, + value: null, new OddsRate(2.10m)); + var result = MakeResult(Side.Side1); + + BetOutcomeResolver.Resolve(bet, result).Should().BeNull( + "period scope cannot be graded from full-time score alone"); + } + + [Fact] + public void Should_Throw_When_BetOrResultIsNull() + { + ((Action)(() => BetOutcomeResolver.Resolve(null!, MakeResult(Side.Side1)))) + .Should().Throw(); + ((Action)(() => BetOutcomeResolver.Resolve(MakeBet(BetType.Win, Side.Side1), null!))) + .Should().Throw(); + } + + // ── Helpers ────────────────────────────────────────────────────────────── + + private static Bet MakeBet(BetType type, Side side) => + new(MatchScope.Instance, type, side, value: null, new OddsRate(2.00m)); + + private static EventResult MakeResult(Side winner, int s1 = 1, int s2 = 0) => + new(EventId, s1, s2, winner, DateTimeOffset.UtcNow); + + private static Side DeriveWinner(int s1, int s2) => + s1 == s2 ? Side.Draw : (s1 > s2 ? Side.Side1 : Side.Side2); +} diff --git a/tests/Marathon.Domain.Tests/Entities/PlacedBetTests.cs b/tests/Marathon.Domain.Tests/Entities/PlacedBetTests.cs new file mode 100644 index 0000000..7398028 --- /dev/null +++ b/tests/Marathon.Domain.Tests/Entities/PlacedBetTests.cs @@ -0,0 +1,120 @@ +using FluentAssertions; +using Marathon.Domain.Entities; +using Marathon.Domain.Enums; +using Marathon.Domain.ValueObjects; + +namespace Marathon.Domain.Tests.Entities; + +/// +/// Invariants and derived-property tests for . +/// +public sealed class PlacedBetTests +{ + private static readonly TimeSpan MoscowOffset = TimeSpan.FromHours(3); + private static readonly DateTimeOffset MoscowMoment = + new(2026, 5, 16, 12, 0, 0, MoscowOffset); + + private static PlacedBet Make(BetOutcome outcome, decimal rate = 2.10m, decimal stake = 100m) => + new( + Id: Guid.NewGuid(), + EventId: new EventId("12345678"), + Selection: new Bet(MatchScope.Instance, BetType.Win, Side.Side1, + value: null, new OddsRate(rate)), + Stake: stake, + PlacedAt: MoscowMoment, + Outcome: outcome, + Notes: null); + + [Fact] + public void Should_ComputeWonReturn_As_StakeTimesRate() + { + var bet = Make(BetOutcome.Won, rate: 2.10m, stake: 100m); + + bet.GrossReturn.Should().Be(210m); + bet.NetProfit.Should().Be(110m); + } + + [Fact] + public void Should_ComputeLossReturn_As_Zero() + { + var bet = Make(BetOutcome.Lost, stake: 50m); + + bet.GrossReturn.Should().Be(0m); + bet.NetProfit.Should().Be(-50m); + } + + [Fact] + public void Should_ReturnStake_When_Outcome_IsVoid() + { + var bet = Make(BetOutcome.Void, stake: 75m); + + bet.GrossReturn.Should().Be(75m); + bet.NetProfit.Should().Be(0m); + } + + [Fact] + public void Should_ReturnNullProfit_When_OutcomeIsPending() + { + var bet = Make(BetOutcome.Pending); + + bet.GrossReturn.Should().BeNull(); + bet.NetProfit.Should().BeNull(); + } + + [Fact] + public void WithOutcome_Should_ReturnNewInstance_With_GradedOutcome() + { + var pending = Make(BetOutcome.Pending); + var graded = pending.WithOutcome(BetOutcome.Won); + + graded.Should().NotBeSameAs(pending); + graded.Outcome.Should().Be(BetOutcome.Won); + graded.Id.Should().Be(pending.Id); + graded.Stake.Should().Be(pending.Stake); + } + + [Fact] + public void Should_Throw_When_StakeIsZeroOrNegative() + { + var act = () => new PlacedBet( + Id: Guid.NewGuid(), + EventId: new EventId("11111111"), + Selection: new Bet(MatchScope.Instance, BetType.Win, Side.Side1, null, new OddsRate(2m)), + Stake: 0m, + PlacedAt: MoscowMoment, + Outcome: BetOutcome.Pending, + Notes: null); + + act.Should().Throw().WithMessage("*Stake*"); + } + + [Fact] + public void Should_Throw_When_PlacedAt_IsNotMoscowOffset() + { + var act = () => new PlacedBet( + Id: Guid.NewGuid(), + EventId: new EventId("11111111"), + Selection: new Bet(MatchScope.Instance, BetType.Win, Side.Side1, null, new OddsRate(2m)), + Stake: 100m, + PlacedAt: DateTimeOffset.UtcNow, // UTC offset, not Moscow + Outcome: BetOutcome.Pending, + Notes: null); + + act.Should().Throw().WithMessage("*Moscow*"); + } + + [Fact] + public void Should_NormaliseWhitespace_Notes_To_Null() + { + var bet = new PlacedBet( + Id: Guid.NewGuid(), + EventId: new EventId("11111111"), + Selection: new Bet(MatchScope.Instance, BetType.Win, Side.Side1, null, new OddsRate(2m)), + Stake: 100m, + PlacedAt: MoscowMoment, + Outcome: BetOutcome.Pending, + Notes: " "); + + bet.Notes.Should().BeNull(); + } +} diff --git a/tests/Marathon.Infrastructure.Tests/Persistence/PlacedBetRoundTripTests.cs b/tests/Marathon.Infrastructure.Tests/Persistence/PlacedBetRoundTripTests.cs new file mode 100644 index 0000000..fd2e1d1 --- /dev/null +++ b/tests/Marathon.Infrastructure.Tests/Persistence/PlacedBetRoundTripTests.cs @@ -0,0 +1,200 @@ +using FluentAssertions; +using Marathon.Application.Storage; +using Marathon.Domain.Entities; +using Marathon.Domain.Enums; +using Marathon.Domain.ValueObjects; +using Marathon.Infrastructure.Persistence; +using Marathon.Infrastructure.Persistence.Repositories; + +namespace Marathon.Infrastructure.Tests.Persistence; + +/// +/// Round-trip + query tests for . Uses the +/// in-memory SQLite fixture so the schema + indices declared in the migration +/// are exercised on every test. +/// +public sealed class PlacedBetRoundTripTests : IDisposable +{ + private static readonly TimeSpan MoscowOffset = TimeSpan.FromHours(3); + private static readonly DateTimeOffset Placed = + new(2026, 5, 16, 12, 0, 0, MoscowOffset); + + private readonly InMemoryDbFixture _fixture; + private readonly PlacedBetRepository _repo; + + public PlacedBetRoundTripTests() + { + _fixture = new InMemoryDbFixture(); + _repo = new PlacedBetRepository(_fixture.DbContext); + } + + public void Dispose() => _fixture.Dispose(); + + private static PlacedBet MakeBet( + Guid? id = null, + string eventCode = "12345678", + BetType type = BetType.Win, + Side side = Side.Side1, + decimal? value = null, + decimal rate = 2.10m, + decimal stake = 100m, + DateTimeOffset? placedAt = null, + BetOutcome outcome = BetOutcome.Pending, + string? notes = null) => + new( + Id: id ?? Guid.NewGuid(), + EventId: new EventId(eventCode), + Selection: new Bet(MatchScope.Instance, type, side, + value is { } v ? new OddsValue(v) : null, new OddsRate(rate)), + Stake: stake, + PlacedAt: placedAt ?? Placed, + Outcome: outcome, + Notes: notes); + + [Fact] + public async Task PlacedBet_RoundTrip_PreservesAllFields() + { + var bet = MakeBet( + type: BetType.WinFora, + side: Side.Side2, + value: -1.5m, + rate: 2.30m, + stake: 250m, + notes: "test note"); + + await _repo.AddAsync(bet); + await _repo.SaveChangesAsync(); + _fixture.DbContext.ChangeTracker.Clear(); + + var retrieved = await _repo.GetAsync(bet.Id); + + retrieved.Should().NotBeNull(); + retrieved!.Id.Should().Be(bet.Id); + retrieved.EventId.Value.Should().Be("12345678"); + retrieved.Selection.Type.Should().Be(BetType.WinFora); + retrieved.Selection.Side.Should().Be(Side.Side2); + retrieved.Selection.Value!.Value.Should().Be(-1.5m); + retrieved.Selection.Rate.Value.Should().Be(2.30m); + retrieved.Stake.Should().Be(250m); + retrieved.PlacedAt.Should().Be(Placed); + retrieved.PlacedAt.Offset.Should().Be(MoscowOffset); + retrieved.Outcome.Should().Be(BetOutcome.Pending); + retrieved.Notes.Should().Be("test note"); + } + + [Fact] + public async Task ListByOutcomeAsync_ReturnsOnlyMatching() + { + await _repo.AddAsync(MakeBet(outcome: BetOutcome.Pending, eventCode: "1")); + await _repo.AddAsync(MakeBet(outcome: BetOutcome.Won, eventCode: "2")); + await _repo.AddAsync(MakeBet(outcome: BetOutcome.Pending, eventCode: "3")); + await _repo.SaveChangesAsync(); + _fixture.DbContext.ChangeTracker.Clear(); + + var pending = await _repo.ListByOutcomeAsync(BetOutcome.Pending); + + pending.Should().HaveCount(2); + pending.Should().OnlyContain(b => b.Outcome == BetOutcome.Pending); + } + + [Fact] + public async Task ListByEventAsync_ReturnsEveryBet_OnTheEvent() + { + await _repo.AddAsync(MakeBet(eventCode: "evt-A", side: Side.Side1)); + await _repo.AddAsync(MakeBet(eventCode: "evt-A", side: Side.Side2)); + await _repo.AddAsync(MakeBet(eventCode: "evt-B")); + await _repo.SaveChangesAsync(); + _fixture.DbContext.ChangeTracker.Clear(); + + var onA = await _repo.ListByEventAsync(new EventId("evt-A")); + + onA.Should().HaveCount(2); + onA.Should().OnlyContain(b => b.EventId.Value == "evt-A"); + } + + [Fact] + public async Task ListByDateRangeAsync_FiltersByPlacedAt() + { + var older = new DateTimeOffset(2026, 5, 1, 12, 0, 0, MoscowOffset); + var newer = new DateTimeOffset(2026, 5, 20, 12, 0, 0, MoscowOffset); + + await _repo.AddAsync(MakeBet(placedAt: older, eventCode: "old")); + await _repo.AddAsync(MakeBet(placedAt: newer, eventCode: "new")); + await _repo.SaveChangesAsync(); + _fixture.DbContext.ChangeTracker.Clear(); + + var range = new DateRange( + from: new DateTimeOffset(2026, 5, 10, 0, 0, 0, MoscowOffset), + to: new DateTimeOffset(2026, 5, 31, 0, 0, 0, MoscowOffset)); + var inRange = await _repo.ListByDateRangeAsync(range); + + inRange.Should().HaveCount(1); + inRange[0].EventId.Value.Should().Be("new"); + } + + [Fact] + public async Task UpdateAsync_PersistsOutcomeChange() + { + var bet = MakeBet(outcome: BetOutcome.Pending); + await _repo.AddAsync(bet); + await _repo.SaveChangesAsync(); + _fixture.DbContext.ChangeTracker.Clear(); + + var graded = bet.WithOutcome(BetOutcome.Won); + await _repo.UpdateAsync(graded); + await _repo.SaveChangesAsync(); + _fixture.DbContext.ChangeTracker.Clear(); + + var reloaded = await _repo.GetAsync(bet.Id); + reloaded!.Outcome.Should().Be(BetOutcome.Won); + } + + [Fact] + public async Task PlacedBet_Survives_Event_Deletion() + { + // Documents the explicit "no foreign key" design choice — the journal + // is user data and must survive snapshot retention pruning the source + // event row. + var eventRepo = new EventRepository(_fixture.DbContext); + var evt = new Event( + Id: new EventId("evt-prune"), + Sport: new SportCode(11), + CountryCode: "England", + LeagueId: "league", + Category: string.Empty, + ScheduledAt: new DateTimeOffset(2026, 5, 16, 20, 0, 0, MoscowOffset), + Side1Name: "Home", + Side2Name: "Away"); + await eventRepo.AddAsync(evt); + await eventRepo.SaveChangesAsync(); + + var bet = MakeBet(eventCode: "evt-prune"); + await _repo.AddAsync(bet); + await _repo.SaveChangesAsync(); + _fixture.DbContext.ChangeTracker.Clear(); + + // Delete the event — the bet should remain untouched. + await eventRepo.DeleteAsync(new EventId("evt-prune")); + await eventRepo.SaveChangesAsync(); + _fixture.DbContext.ChangeTracker.Clear(); + + var stillThere = await _repo.GetAsync(bet.Id); + + stillThere.Should().NotBeNull(); + stillThere!.EventId.Value.Should().Be("evt-prune"); + } + + [Fact] + public async Task DeleteAsync_RemovesBet() + { + var bet = MakeBet(); + await _repo.AddAsync(bet); + await _repo.SaveChangesAsync(); + _fixture.DbContext.ChangeTracker.Clear(); + + await _repo.DeleteAsync(bet.Id); + await _repo.SaveChangesAsync(); + + (await _repo.GetAsync(bet.Id)).Should().BeNull(); + } +}