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:
2026-05-28 22:46:33 +03:00
parent 250a93e718
commit 0e3c4b8d47
5 changed files with 309 additions and 0 deletions
@@ -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_&lt;from&gt;_to_&lt;to&gt;.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_&lt;от&gt;_to_&lt;до&gt;.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>