Files
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

134 lines
4.4 KiB
C#
Raw Permalink 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.
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>();
}
}