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; } }