Files
maraphon-app/src/Marathon.Application/Betting/BetJournalReport.cs
T
alexei.dolgolyov 1ad896b07e feat(my-bets): personal bet journal with CLV tracking
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) <noreply@anthropic.com>
2026-05-16 17:45:42 +03:00

93 lines
3.5 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using Marathon.Domain.Entities;
using Marathon.Domain.Enums;
namespace Marathon.Application.Betting;
/// <summary>
/// Aggregate report on the user's bet-tracking journal — totals, P&amp;L, and
/// per-bet CLV. Consumed by the Journal page; built by
/// <see cref="UseCases.BuildBetJournalReportUseCase"/>.
/// </summary>
/// <param name="Stats">Roll-up of stake / profit / hit rate / CLV across all bets in scope.</param>
/// <param name="Bets">
/// Every bet paired with its computed CLV (null when no closing snapshot was
/// available). Ordered most-recent <see cref="PlacedBet.PlacedAt"/> first.
/// </param>
public sealed record BetJournalReport(
BetJournalStats Stats,
IReadOnlyList<BetJournalRow> Bets);
/// <summary>
/// One row in the journal — a domain <see cref="PlacedBet"/> plus the CLV
/// computed against the closing pre-match snapshot.
/// </summary>
/// <param name="Bet">The domain bet exactly as persisted.</param>
/// <param name="ClvProbabilityDelta">
/// 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.
/// </param>
public sealed record BetJournalRow(
PlacedBet Bet,
decimal? ClvProbabilityDelta);
/// <summary>
/// Aggregate statistics across a set of <see cref="PlacedBet"/>.
/// All money values share the user's currency — the domain does not encode one.
/// </summary>
/// <param name="TotalBets">Every bet in scope, regardless of outcome.</param>
/// <param name="PendingCount">Bets still awaiting settlement.</param>
/// <param name="WonCount">Settled wins.</param>
/// <param name="LostCount">Settled losses.</param>
/// <param name="VoidCount">Settled pushes / void grades.</param>
/// <param name="TotalStaked">
/// Turnover that contributes to ROI: sum of <see cref="PlacedBet.Stake"/> across
/// <b>Won and Lost</b> bets only. Void (push) and Pending bets are excluded — a
/// returned stake is not real turnover and counting it would dilute ROI.
/// </param>
/// <param name="TotalReturned">
/// Sum of <see cref="PlacedBet.GrossReturn"/> across the same Won + Lost subset
/// that feeds <see cref="TotalStaked"/>.
/// </param>
/// <param name="NetProfit"><c>TotalReturned TotalStaked</c>.</param>
/// <param name="RoiPercent">
/// <c>NetProfit / TotalStaked × 100</c>. Null when no bets have resolved yet.
/// </param>
/// <param name="StrikeRatePercent">
/// <c>WonCount / (WonCount + LostCount) × 100</c> — excludes voids and pendings.
/// Null when no settled win/loss exists yet.
/// </param>
/// <param name="AverageClvProbabilityDelta">
/// Mean CLV across bets where CLV was computable. Null when no comparable
/// closing snapshot was available for any bet.
/// </param>
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)
{
/// <summary>Convenience: WonCount + LostCount + VoidCount.</summary>
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);
}