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
@@ -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>();
}
}