diff --git a/src/Marathon.Domain/Betting/KellyCalculator.cs b/src/Marathon.Domain/Betting/KellyCalculator.cs new file mode 100644 index 0000000..8805583 --- /dev/null +++ b/src/Marathon.Domain/Betting/KellyCalculator.cs @@ -0,0 +1,82 @@ +namespace Marathon.Domain.Betting; + +/// +/// Pure fractional-Kelly stake sizing for a single back bet at decimal odds. +/// +/// +/// +/// 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 o and an +/// estimated win probability p, the full-Kelly fraction of bankroll is: +/// +/// f* = (p·o − 1) / (o − 1) +/// +/// When f* is zero or negative there is no positive expected value, and the +/// suggested stake is 0 — the calculator never recommends betting into a +/// negative-EV price. Most disciplined bettors stake a fraction of full +/// Kelly (e.g. quarter-Kelly, fraction = 0.25) to cut variance and blunt the +/// impact of probability-estimation error; full Kelly is famously over-aggressive +/// once p is even slightly wrong. +/// +/// +/// 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. +/// +/// +public static class KellyCalculator +{ + /// Default Kelly fraction: quarter-Kelly — the conventional variance-safe choice. + public const decimal DefaultFraction = 0.25m; + + /// + /// Full-Kelly fraction of bankroll (p·o − 1)/(o − 1). 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. + /// + /// Estimated win probability in the closed interval [0, 1]. + /// Decimal odds, strictly greater than 1.0. + 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); + } + + /// + /// Suggested stake using fractional Kelly, rounded down to two decimals so the + /// suggestion is never larger than the theoretical figure. Returns 0 when + /// there is no positive edge. + /// + /// Estimated win probability in the open interval (0, 1). + /// Decimal odds, strictly greater than 1.0. + /// Total bankroll; must be non-negative. + /// Kelly fraction in (0, 1]; defaults to . + 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; + } +} diff --git a/src/Marathon.UI/Pages/MyBets/Journal.razor b/src/Marathon.UI/Pages/MyBets/Journal.razor index fee5a80..af6eac6 100644 --- a/src/Marathon.UI/Pages/MyBets/Journal.razor +++ b/src/Marathon.UI/Pages/MyBets/Journal.razor @@ -9,6 +9,7 @@ @page "/my-bets" @using Marathon.Application.Betting +@using Marathon.Domain.Betting @implements IDisposable @inject IStringLocalizer L @inject IBetJournalService Service @@ -184,6 +185,50 @@ data-test="journal-add-stake" /> +
+ +
+
+ @L["Journal.Kelly.Bankroll"] + +
+
+ @L["Journal.Kelly.Probability"] + +
+
+ @if (KellySuggestion is { } suggestion) + { + @if (suggestion > 0m) + { +
+ + @string.Format(CultureInfo.CurrentCulture, L["Journal.Kelly.Suggestion"].Value, suggestion) + + +
+ } + else + { + @L["Journal.Kelly.NoEdge"] + } + } + else + { + @L["Journal.Kelly.Hint"] + } +
+
+ /// 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. + /// + 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() diff --git a/src/Marathon.UI/Resources/SharedResource.en.resx b/src/Marathon.UI/Resources/SharedResource.en.resx index 16edb89..5a70b3e 100644 --- a/src/Marathon.UI/Resources/SharedResource.en.resx +++ b/src/Marathon.UI/Resources/SharedResource.en.resx @@ -64,6 +64,7 @@ Anomalies Results Settings + Export Briefing Hunting odds-flip anomalies @@ -262,6 +263,9 @@ Export Cancel Export saved to {0} + Export captured odds snapshots to an Excel workbook for any date range — no need to open a specific event first. + Configure export + Saved as Marathon_<from>_to_<to>.xlsx in the configured export directory. Pick a start and end date. End date must be on or after the start date. Export failed. @@ -416,6 +420,13 @@ Failed to save bet — check the event ID and try again. Bet recorded. + Stake helper (¼-Kelly) + Bankroll + Win probability (%) + Suggested stake: {0:0.00} + Apply + No positive edge at this price. + Enter bankroll + win probability for a ¼-Kelly stake. No pending bets needed grading. Graded {0} pending bet(s). Delete this bet permanently? diff --git a/src/Marathon.UI/Resources/SharedResource.ru.resx b/src/Marathon.UI/Resources/SharedResource.ru.resx index 19bf4a7..6d7763d 100644 --- a/src/Marathon.UI/Resources/SharedResource.ru.resx +++ b/src/Marathon.UI/Resources/SharedResource.ru.resx @@ -66,6 +66,7 @@ Аномалии Результаты Настройки + Экспорт Сводка @@ -275,6 +276,9 @@ Экспорт Отмена Файл сохранён в {0} + Экспорт собранных снимков коэффициентов в книгу Excel за любой диапазон дат — без необходимости открывать конкретное событие. + Настроить экспорт + Сохраняется как Marathon_<от>_to_<до>.xlsx в указанной папке экспорта. Выберите даты начала и конца. Дата конца должна быть не раньше даты начала. Экспорт не удался. @@ -429,6 +433,13 @@ Не удалось сохранить ставку — проверьте ID события и повторите. Ставка записана. + Калькулятор ставки (¼-Келли) + Банкролл + Вероятность выигрыша (%) + Рекомендуемая ставка: {0:0.00} + Применить + Нет преимущества при этом коэффициенте. + Введите банкролл и вероятность для ставки по ¼-Келли. Ожидающих ставок к расчёту нет. Рассчитано ожидающих: {0}. Удалить эту ставку безвозвратно? diff --git a/tests/Marathon.Domain.Tests/Betting/KellyCalculatorTests.cs b/tests/Marathon.Domain.Tests/Betting/KellyCalculatorTests.cs new file mode 100644 index 0000000..ae77595 --- /dev/null +++ b/tests/Marathon.Domain.Tests/Betting/KellyCalculatorTests.cs @@ -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(); + } + + [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(); + } + + [Fact] + public void SuggestStake_Should_Throw_When_BankrollNegative() + { + var act = () => KellyCalculator.SuggestStake(0.55m, 2.10m, bankroll: -1m); + + act.Should().Throw(); + } + + [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(); + } +}