Files
maraphon-app/src/Marathon.Application/Betting/ClosingLineValueCalculator.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

86 lines
3.4 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.ValueObjects;
namespace Marathon.Application.Betting;
/// <summary>
/// Pure helper that computes Closing Line Value (CLV) for a placed bet.
/// </summary>
/// <remarks>
/// <para>
/// 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.
/// </para>
/// <para>
/// Formula (implied-probability delta):
/// <list type="bullet">
/// <item>Taken implied probability: <c>p_t = 1 / takenRate</c></item>
/// <item>Closing implied probability: <c>p_c = 1 / closeRate</c></item>
/// <item><c>CLV = p_c p_t</c></item>
/// </list>
/// 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.
/// </para>
/// <para>
/// Returns <c>null</c> 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".
/// </para>
/// </remarks>
public static class ClosingLineValueCalculator
{
/// <summary>
/// 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 <see cref="OddsRate"/>
/// already guarantee this for inputs sourced from the domain.
/// </summary>
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);
}
/// <summary>
/// Convenience overload: finds the matching <see cref="Bet"/> in
/// <paramref name="closingSnapshot"/> by Scope / Type / Side / Value, then
/// computes CLV against <paramref name="takenRate"/>. Returns <c>null</c>
/// when no comparable bet is present.
/// </summary>
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;
}
}