Files
maraphon-app/src/Marathon.Domain/Betting/KellyCalculator.cs
T
alexei.dolgolyov 0e3c4b8d47 feat: Kelly criterion stake sizing (domain + MyBets helper)
- Add KellyCalculator (Domain/Betting): pure fractional-Kelly stake from win
  probability, decimal odds, bankroll, and fraction (default quarter-Kelly).
  Returns 0 on non-positive edge; truncates the suggestion down to 2 decimals
  so it never exceeds the computed figure. 19 unit tests.
- MyBets: add a page-local stake helper (bankroll + win-probability inputs) that
  suggests a quarter-Kelly stake from the form's rate, with an Apply button and a
  no-edge message. Win probability is user-supplied, not derived from a signal.
- Localization (en+ru) for the Kelly helper and the export-hub keys (shared resx).
2026-05-28 22:46:33 +03:00

83 lines
4.0 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.
namespace Marathon.Domain.Betting;
/// <summary>
/// Pure fractional-Kelly stake sizing for a single back bet at decimal odds.
/// </summary>
/// <remarks>
/// <para>
/// The Kelly criterion maximises the long-run growth rate of a bankroll by staking
/// a fraction of it proportional to the edge. For decimal odds <c>o</c> and an
/// estimated win probability <c>p</c>, the full-Kelly fraction of bankroll is:
/// </para>
/// <code>f* = (p·o 1) / (o 1)</code>
/// <para>
/// When <c>f*</c> is zero or negative there is no positive expected value, and the
/// suggested stake is <c>0</c> — the calculator never recommends betting into a
/// negative-EV price. Most disciplined bettors stake a <i>fraction</i> of full
/// Kelly (e.g. quarter-Kelly, <c>fraction = 0.25</c>) to cut variance and blunt the
/// impact of probability-estimation error; full Kelly is famously over-aggressive
/// once <c>p</c> is even slightly wrong.
/// </para>
/// <para>
/// The win probability is an input the bettor supplies — it is intentionally NOT
/// derived from an anomaly score here, so the calculator stays a pure, reusable
/// money-management primitive independent of any signal source.
/// </para>
/// </remarks>
public static class KellyCalculator
{
/// <summary>Default Kelly fraction: quarter-Kelly — the conventional variance-safe choice.</summary>
public const decimal DefaultFraction = 0.25m;
/// <summary>
/// Full-Kelly fraction of bankroll <c>(p·o 1)/(o 1)</c>. May be negative or
/// zero, signalling no positive edge. Exposed for callers that want the raw
/// figure (e.g. to display the edge) rather than a clamped stake.
/// </summary>
/// <param name="winProbability">Estimated win probability in the closed interval [0, 1].</param>
/// <param name="decimalOdds">Decimal odds, strictly greater than 1.0.</param>
public static decimal FullKellyFraction(decimal winProbability, decimal decimalOdds)
{
if (winProbability is < 0m or > 1m)
throw new ArgumentOutOfRangeException(nameof(winProbability), winProbability, "Must be in [0, 1].");
if (decimalOdds <= 1m)
throw new ArgumentOutOfRangeException(nameof(decimalOdds), decimalOdds, "Decimal odds must be greater than 1.0.");
return (winProbability * decimalOdds - 1m) / (decimalOdds - 1m);
}
/// <summary>
/// Suggested stake using fractional Kelly, rounded down to two decimals so the
/// suggestion is never larger than the theoretical figure. Returns <c>0</c> when
/// there is no positive edge.
/// </summary>
/// <param name="winProbability">Estimated win probability in the open interval (0, 1).</param>
/// <param name="decimalOdds">Decimal odds, strictly greater than 1.0.</param>
/// <param name="bankroll">Total bankroll; must be non-negative.</param>
/// <param name="fraction">Kelly fraction in (0, 1]; defaults to <see cref="DefaultFraction"/>.</param>
public static decimal SuggestStake(
decimal winProbability,
decimal decimalOdds,
decimal bankroll,
decimal fraction = DefaultFraction)
{
if (winProbability is <= 0m or >= 1m)
throw new ArgumentOutOfRangeException(nameof(winProbability), winProbability, "Must be in the open interval (0, 1).");
if (bankroll < 0m)
throw new ArgumentOutOfRangeException(nameof(bankroll), bankroll, "Must be non-negative.");
if (fraction is <= 0m or > 1m)
throw new ArgumentOutOfRangeException(nameof(fraction), fraction, "Kelly fraction must be in (0, 1].");
// FullKellyFraction validates decimalOdds.
var full = FullKellyFraction(winProbability, decimalOdds);
if (full <= 0m)
return 0m;
var stake = fraction * full * bankroll;
// Truncate (floor toward zero) to two decimals so a stake suggestion never
// exceeds the computed figure — a conservative bias for real-money sizing.
return Math.Truncate(stake * 100m) / 100m;
}
}