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).
This commit is contained in:
@@ -0,0 +1,82 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
@page "/my-bets"
|
||||
@using Marathon.Application.Betting
|
||||
@using Marathon.Domain.Betting
|
||||
@implements IDisposable
|
||||
@inject IStringLocalizer<SharedResource> L
|
||||
@inject IBetJournalService Service
|
||||
@@ -184,6 +185,50 @@
|
||||
data-test="journal-add-stake" />
|
||||
</div>
|
||||
|
||||
<div class="m-journal__form-field m-journal__form-field--wide" data-test="journal-kelly">
|
||||
<label class="m-journal__form-label">@L["Journal.Kelly.Title"]</label>
|
||||
<div style="display:flex; flex-wrap:wrap; gap:var(--m-space-3); align-items:flex-end;">
|
||||
<div style="flex:1 1 150px;">
|
||||
<span class="m-journal__form-hint">@L["Journal.Kelly.Bankroll"]</span>
|
||||
<MudNumericField T="decimal?"
|
||||
@bind-Value="_kellyBankroll"
|
||||
Min="0m" Step="50m"
|
||||
Variant="Variant.Outlined"
|
||||
data-test="journal-kelly-bankroll" />
|
||||
</div>
|
||||
<div style="flex:1 1 150px;">
|
||||
<span class="m-journal__form-hint">@L["Journal.Kelly.Probability"]</span>
|
||||
<MudNumericField T="decimal?"
|
||||
@bind-Value="_kellyProbabilityPercent"
|
||||
Min="0m" Max="100m" Step="1m"
|
||||
Variant="Variant.Outlined"
|
||||
data-test="journal-kelly-prob" />
|
||||
</div>
|
||||
</div>
|
||||
@if (KellySuggestion is { } suggestion)
|
||||
{
|
||||
@if (suggestion > 0m)
|
||||
{
|
||||
<div style="margin-top:var(--m-space-2); display:flex; align-items:center; gap:var(--m-space-3); flex-wrap:wrap;">
|
||||
<span class="m-journal__form-hint" data-test="journal-kelly-suggestion">
|
||||
@string.Format(CultureInfo.CurrentCulture, L["Journal.Kelly.Suggestion"].Value, suggestion)
|
||||
</span>
|
||||
<button type="button" class="m-chip" @onclick="ApplyKellyStake" data-test="journal-kelly-apply">
|
||||
@L["Journal.Kelly.Apply"]
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="m-journal__form-hint" data-test="journal-kelly-noedge">@L["Journal.Kelly.NoEdge"]</span>
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="m-journal__form-hint">@L["Journal.Kelly.Hint"]</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="m-journal__form-field m-journal__form-field--wide">
|
||||
<label class="m-journal__form-label">@L["Journal.Field.Notes"]</label>
|
||||
<MudTextField T="string"
|
||||
@@ -644,6 +689,33 @@
|
||||
private string? _formError;
|
||||
private Guid? _pendingDeleteId;
|
||||
private AddBetForm _form = new();
|
||||
|
||||
// Kelly stake-helper state — page-local (not persisted with the bet). Bankroll
|
||||
// intentionally survives form resets so it carries across successive entries.
|
||||
private decimal? _kellyBankroll;
|
||||
private decimal? _kellyProbabilityPercent;
|
||||
|
||||
/// <summary>
|
||||
/// Quarter-Kelly suggested stake from the entered bankroll + win probability and
|
||||
/// the form's current rate. Null when inputs are incomplete/invalid; 0 when the
|
||||
/// price carries no positive edge.
|
||||
/// </summary>
|
||||
private decimal? KellySuggestion
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_kellyBankroll is not { } bankroll || bankroll <= 0m) return null;
|
||||
if (_kellyProbabilityPercent is not { } pct || pct <= 0m || pct >= 100m) return null;
|
||||
if (_form.Rate <= 1m) return null;
|
||||
return KellyCalculator.SuggestStake(pct / 100m, _form.Rate, bankroll, KellyCalculator.DefaultFraction);
|
||||
}
|
||||
}
|
||||
|
||||
private void ApplyKellyStake()
|
||||
{
|
||||
if (KellySuggestion is { } s && s > 0m)
|
||||
_form.Stake = s;
|
||||
}
|
||||
private CancellationTokenSource? _loadCts;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
|
||||
@@ -64,6 +64,7 @@
|
||||
<data name="Nav.Anomalies"><value>Anomalies</value></data>
|
||||
<data name="Nav.Results"><value>Results</value></data>
|
||||
<data name="Nav.Settings"><value>Settings</value></data>
|
||||
<data name="Nav.Export"><value>Export</value></data>
|
||||
|
||||
<data name="Home.Kicker"><value>Briefing</value></data>
|
||||
<data name="Home.Title"><value>Hunting odds-flip anomalies</value></data>
|
||||
@@ -262,6 +263,9 @@
|
||||
<data name="Export.Submit"><value>Export</value></data>
|
||||
<data name="Export.Cancel"><value>Cancel</value></data>
|
||||
<data name="Export.Success"><value>Export saved to {0}</value></data>
|
||||
<data name="Export.Hub.Lede"><value>Export captured odds snapshots to an Excel workbook for any date range — no need to open a specific event first.</value></data>
|
||||
<data name="Export.Hub.Action"><value>Configure export</value></data>
|
||||
<data name="Export.Hub.FilenameHint"><value>Saved as Marathon_<from>_to_<to>.xlsx in the configured export directory.</value></data>
|
||||
<data name="Export.Error.MissingDates"><value>Pick a start and end date.</value></data>
|
||||
<data name="Export.Error.InvalidRange"><value>End date must be on or after the start date.</value></data>
|
||||
<data name="Export.Error.Failed"><value>Export failed.</value></data>
|
||||
@@ -416,6 +420,13 @@
|
||||
<data name="Journal.Empty.NotApplicable"><value>—</value></data>
|
||||
<data name="Journal.Error.Generic"><value>Failed to save bet — check the event ID and try again.</value></data>
|
||||
<data name="Journal.Submitted"><value>Bet recorded.</value></data>
|
||||
<data name="Journal.Kelly.Title"><value>Stake helper (¼-Kelly)</value></data>
|
||||
<data name="Journal.Kelly.Bankroll"><value>Bankroll</value></data>
|
||||
<data name="Journal.Kelly.Probability"><value>Win probability (%)</value></data>
|
||||
<data name="Journal.Kelly.Suggestion"><value>Suggested stake: {0:0.00}</value></data>
|
||||
<data name="Journal.Kelly.Apply"><value>Apply</value></data>
|
||||
<data name="Journal.Kelly.NoEdge"><value>No positive edge at this price.</value></data>
|
||||
<data name="Journal.Kelly.Hint"><value>Enter bankroll + win probability for a ¼-Kelly stake.</value></data>
|
||||
<data name="Journal.Resolve.None"><value>No pending bets needed grading.</value></data>
|
||||
<data name="Journal.Resolve.Done"><value>Graded {0} pending bet(s).</value></data>
|
||||
<data name="Journal.Confirm.Delete"><value>Delete this bet permanently?</value></data>
|
||||
|
||||
@@ -66,6 +66,7 @@
|
||||
<data name="Nav.Anomalies"><value>Аномалии</value></data>
|
||||
<data name="Nav.Results"><value>Результаты</value></data>
|
||||
<data name="Nav.Settings"><value>Настройки</value></data>
|
||||
<data name="Nav.Export"><value>Экспорт</value></data>
|
||||
|
||||
<!-- Home / Dashboard -->
|
||||
<data name="Home.Kicker"><value>Сводка</value></data>
|
||||
@@ -275,6 +276,9 @@
|
||||
<data name="Export.Submit"><value>Экспорт</value></data>
|
||||
<data name="Export.Cancel"><value>Отмена</value></data>
|
||||
<data name="Export.Success"><value>Файл сохранён в {0}</value></data>
|
||||
<data name="Export.Hub.Lede"><value>Экспорт собранных снимков коэффициентов в книгу Excel за любой диапазон дат — без необходимости открывать конкретное событие.</value></data>
|
||||
<data name="Export.Hub.Action"><value>Настроить экспорт</value></data>
|
||||
<data name="Export.Hub.FilenameHint"><value>Сохраняется как Marathon_<от>_to_<до>.xlsx в указанной папке экспорта.</value></data>
|
||||
<data name="Export.Error.MissingDates"><value>Выберите даты начала и конца.</value></data>
|
||||
<data name="Export.Error.InvalidRange"><value>Дата конца должна быть не раньше даты начала.</value></data>
|
||||
<data name="Export.Error.Failed"><value>Экспорт не удался.</value></data>
|
||||
@@ -429,6 +433,13 @@
|
||||
<data name="Journal.Empty.NotApplicable"><value>—</value></data>
|
||||
<data name="Journal.Error.Generic"><value>Не удалось сохранить ставку — проверьте ID события и повторите.</value></data>
|
||||
<data name="Journal.Submitted"><value>Ставка записана.</value></data>
|
||||
<data name="Journal.Kelly.Title"><value>Калькулятор ставки (¼-Келли)</value></data>
|
||||
<data name="Journal.Kelly.Bankroll"><value>Банкролл</value></data>
|
||||
<data name="Journal.Kelly.Probability"><value>Вероятность выигрыша (%)</value></data>
|
||||
<data name="Journal.Kelly.Suggestion"><value>Рекомендуемая ставка: {0:0.00}</value></data>
|
||||
<data name="Journal.Kelly.Apply"><value>Применить</value></data>
|
||||
<data name="Journal.Kelly.NoEdge"><value>Нет преимущества при этом коэффициенте.</value></data>
|
||||
<data name="Journal.Kelly.Hint"><value>Введите банкролл и вероятность для ставки по ¼-Келли.</value></data>
|
||||
<data name="Journal.Resolve.None"><value>Ожидающих ставок к расчёту нет.</value></data>
|
||||
<data name="Journal.Resolve.Done"><value>Рассчитано ожидающих: {0}.</value></data>
|
||||
<data name="Journal.Confirm.Delete"><value>Удалить эту ставку безвозвратно?</value></data>
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
using FluentAssertions;
|
||||
using Marathon.Domain.Betting;
|
||||
|
||||
namespace Marathon.Domain.Tests.Betting;
|
||||
|
||||
public sealed class KellyCalculatorTests
|
||||
{
|
||||
// ── SuggestStake: edge handling ───────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Should_ReturnZeroStake_When_NoPositiveEdge()
|
||||
{
|
||||
// p·o = 0.50 × 1.90 = 0.95 < 1 → negative edge.
|
||||
var stake = KellyCalculator.SuggestStake(
|
||||
winProbability: 0.50m, decimalOdds: 1.90m, bankroll: 1000m);
|
||||
|
||||
stake.Should().Be(0m);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Should_ReturnZeroStake_When_ExactlyBreakeven()
|
||||
{
|
||||
// p·o = 0.50 × 2.00 = 1 → zero edge.
|
||||
var stake = KellyCalculator.SuggestStake(
|
||||
winProbability: 0.50m, decimalOdds: 2.00m, bankroll: 1000m);
|
||||
|
||||
stake.Should().Be(0m);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Should_SizeQuarterKellyStake_When_PositiveEdge()
|
||||
{
|
||||
// full = (0.55×2.10 − 1)/(2.10 − 1) = 0.155/1.10 = 0.140909…
|
||||
// quarter = 0.0352272… × 1000 = 35.2272… → truncated to 35.22.
|
||||
var stake = KellyCalculator.SuggestStake(
|
||||
winProbability: 0.55m, decimalOdds: 2.10m, bankroll: 1000m, fraction: 0.25m);
|
||||
|
||||
stake.Should().Be(35.22m);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Should_UseFullKelly_When_FractionIsOne()
|
||||
{
|
||||
// full = (0.60×2.00 − 1)/(2.00 − 1) = 0.20 → 0.20 × 1000 = 200.
|
||||
var stake = KellyCalculator.SuggestStake(
|
||||
winProbability: 0.60m, decimalOdds: 2.00m, bankroll: 1000m, fraction: 1.0m);
|
||||
|
||||
stake.Should().Be(200m);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Should_ScaleStake_Proportionally_WithBankroll()
|
||||
{
|
||||
var small = KellyCalculator.SuggestStake(0.60m, 2.00m, bankroll: 500m, fraction: 1.0m);
|
||||
var large = KellyCalculator.SuggestStake(0.60m, 2.00m, bankroll: 1000m, fraction: 1.0m);
|
||||
|
||||
small.Should().Be(100m);
|
||||
large.Should().Be(200m);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Should_NeverExceedComputedFigure_When_Truncating()
|
||||
{
|
||||
// Raw quarter-Kelly stake is 35.2272…; the suggestion must floor, not round up.
|
||||
var stake = KellyCalculator.SuggestStake(0.55m, 2.10m, bankroll: 1000m, fraction: 0.25m);
|
||||
|
||||
stake.Should().BeLessThanOrEqualTo(0.25m * KellyCalculator.FullKellyFraction(0.55m, 2.10m) * 1000m);
|
||||
}
|
||||
|
||||
// ── FullKellyFraction ─────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void FullKellyFraction_Should_ComputeEdgeFraction()
|
||||
{
|
||||
KellyCalculator.FullKellyFraction(0.55m, 2.10m)
|
||||
.Should().BeApproximately(0.140909m, 0.000001m);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FullKellyFraction_Should_BeNegative_When_NoEdge()
|
||||
{
|
||||
KellyCalculator.FullKellyFraction(0.40m, 2.00m).Should().Be(-0.20m);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FullKellyFraction_Should_BeZero_When_Breakeven()
|
||||
{
|
||||
KellyCalculator.FullKellyFraction(0.50m, 2.00m).Should().Be(0m);
|
||||
}
|
||||
|
||||
// ── Guard clauses ─────────────────────────────────────────────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData(0.0)]
|
||||
[InlineData(1.0)]
|
||||
[InlineData(-0.1)]
|
||||
[InlineData(1.1)]
|
||||
public void SuggestStake_Should_Throw_When_ProbabilityOutOfOpenInterval(double probability)
|
||||
{
|
||||
var act = () => KellyCalculator.SuggestStake((decimal)probability, 2.0m, 1000m);
|
||||
|
||||
act.Should().Throw<ArgumentOutOfRangeException>();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(1.0)]
|
||||
[InlineData(0.5)]
|
||||
public void SuggestStake_Should_Throw_When_OddsNotGreaterThanOne(double odds)
|
||||
{
|
||||
var act = () => KellyCalculator.SuggestStake(0.55m, (decimal)odds, 1000m);
|
||||
|
||||
act.Should().Throw<ArgumentOutOfRangeException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SuggestStake_Should_Throw_When_BankrollNegative()
|
||||
{
|
||||
var act = () => KellyCalculator.SuggestStake(0.55m, 2.10m, bankroll: -1m);
|
||||
|
||||
act.Should().Throw<ArgumentOutOfRangeException>();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0.0)]
|
||||
[InlineData(-0.25)]
|
||||
[InlineData(1.5)]
|
||||
public void SuggestStake_Should_Throw_When_FractionOutOfRange(double fraction)
|
||||
{
|
||||
var act = () => KellyCalculator.SuggestStake(0.55m, 2.10m, 1000m, (decimal)fraction);
|
||||
|
||||
act.Should().Throw<ArgumentOutOfRangeException>();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user