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