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