using FluentAssertions; using Marathon.Domain.AnomalyDetection; using Marathon.Domain.Entities; using Marathon.Domain.Enums; using Marathon.Domain.ValueObjects; namespace Marathon.Domain.Tests.AnomalyDetection; public sealed class OverroundCompressionDetectorTests { private static readonly TimeSpan MoscowOffset = TimeSpan.FromHours(3); private static readonly DateTimeOffset BaseTime = new(2026, 5, 10, 18, 0, 0, MoscowOffset); private static readonly EventId Event = new("26000003"); // window 120s, compression threshold 0.02, min 3 snapshots, continuity break at 60s. private static OverroundCompressionDetector CreateSut() => new(120, 0.02m, 3, 60); private static OddsSnapshot Live(int seconds, decimal r1, decimal r2) => new(Event, BaseTime.AddSeconds(seconds), OddsSource.Live, new List { new(MatchScope.Instance, BetType.Win, Side.Side1, null, new OddsRate(r1)), new(MatchScope.Instance, BetType.Win, Side.Side2, null, new OddsRate(r2)), }); private static OddsSnapshot Live3Way(int seconds, decimal r1, decimal rDraw, decimal r2) => new(Event, BaseTime.AddSeconds(seconds), OddsSource.Live, new List { new(MatchScope.Instance, BetType.Win, Side.Side1, null, new OddsRate(r1)), new(MatchScope.Instance, BetType.Draw, Side.Draw, null, new OddsRate(rDraw)), new(MatchScope.Instance, BetType.Win, Side.Side2, null, new OddsRate(r2)), }); private static decimal Overround2Way(decimal r1, decimal r2) => 1m / r1 + 1m / r2; [Fact] public void Should_FlagCompression_When_MarginTightensContinuously() { // Overround 1/1.8+1/2.0 = 1.056 → 1/1.9+1/2.1 = 1.002, a ~0.054 drop over 60s. var snapshots = new[] { Live(0, 1.80m, 2.00m), Live(30, 1.85m, 2.05m), Live(60, 1.90m, 2.10m), }; var result = CreateSut().Detect(Event, snapshots); result.Should().NotBeEmpty(); result.Should().OnlyContain(a => a.Kind == AnomalyKind.OverroundCompression); result.Max(a => a.Score).Should().BeGreaterThan(0m); } [Fact] public void Should_NotFlag_When_MarginStable() { var snapshots = new[] { Live(0, 1.80m, 2.00m), Live(30, 1.80m, 2.00m), Live(60, 1.80m, 2.00m), }; CreateSut().Detect(Event, snapshots).Should().BeEmpty(); } [Fact] public void Should_NotFlag_When_CompressionSpansSuspensionGap() { // The margin change straddles a 90s gap (> 60s continuity break) — out of scope. var snapshots = new[] { Live(0, 1.80m, 2.00m), Live(30, 1.80m, 2.00m), Live(120, 1.90m, 2.10m), // 90s gap Live(150, 1.90m, 2.10m), }; CreateSut().Detect(Event, snapshots).Should().BeEmpty(); } [Fact] public void Should_NotFlag_When_MarginWidens() { // Overround rising (margin widening) is the opposite signal — never flagged. var snapshots = new[] { Live(0, 1.90m, 2.10m), Live(30, 1.85m, 2.05m), Live(60, 1.80m, 2.00m), }; CreateSut().Detect(Event, snapshots).Should().BeEmpty(); } [Fact] public void Should_ReturnEmpty_When_FewerThanMinSnapshots() { var snapshots = new[] { Live(0, 1.80m, 2.00m), Live(60, 1.90m, 2.10m) }; CreateSut().Detect(Event, snapshots).Should().BeEmpty(); } [Fact] public void Should_Throw_When_ThresholdOutOfRange() { var act = () => new OverroundCompressionDetector(120, 1.0m, 3, 60); act.Should().Throw(); } [Fact] public void Should_SaturateScore_When_CompressionExceedsReference() { // Overround 1.333 (1.50/1.50) → 1.000 (2.00/2.00): a 0.333 drop, well past the // 0.10 ReferenceCompression, so the score must clamp to exactly 1.0. var snapshots = new[] { Live(0, 1.50m, 1.50m), Live(30, 1.75m, 1.75m), Live(60, 2.00m, 2.00m), }; var result = CreateSut().Detect(Event, snapshots); result.Should().NotBeEmpty(); result.Max(a => a.Score).Should().Be(1.0m); } [Fact] public void Should_ScaleScore_ByCompressionMagnitude() { // Overround 1.25 (1.60/1.60) → 1.17647 (1.70/1.70): a ~0.0735 drop, between the // 0.02 flag threshold and the 0.10 reference, so the score is the linear ratio. var snapshots = new[] { Live(0, 1.60m, 1.60m), Live(30, 1.65m, 1.65m), Live(60, 1.70m, 1.70m), }; var expected = Math.Min( 1m, (Overround2Way(1.60m, 1.60m) - Overround2Way(1.70m, 1.70m)) / OverroundCompressionDetector.ReferenceCompression); var result = CreateSut().Detect(Event, snapshots); result.Max(a => a.Score).Should().BeApproximately(expected, 0.0001m); result.Max(a => a.Score).Should().BeGreaterThan(0m).And.BeLessThan(1m); } [Fact] public void Should_Flag_When_CompressionExactlyEqualsThreshold() { // The full-window drop is the largest compression present; setting the threshold to // exactly that value must still flag — the boundary is inclusive (compression >= T). var snapshots = new[] { Live(0, 1.80m, 2.00m), Live(30, 1.85m, 2.05m), Live(60, 1.90m, 2.10m), }; var exact = Overround2Way(1.80m, 2.00m) - Overround2Way(1.90m, 2.10m); new OverroundCompressionDetector(120, exact, 3, 60) .Detect(Event, snapshots).Should().NotBeEmpty(); } [Fact] public void Should_NotFlag_When_ThresholdExceedsCompression() { // Threshold a hair above the largest drop present — nothing qualifies. var snapshots = new[] { Live(0, 1.80m, 2.00m), Live(30, 1.85m, 2.05m), Live(60, 1.90m, 2.10m), }; var aboveMax = (Overround2Way(1.80m, 2.00m) - Overround2Way(1.90m, 2.10m)) + 0.0001m; new OverroundCompressionDetector(120, aboveMax, 3, 60) .Detect(Event, snapshots).Should().BeEmpty(); } [Fact] public void Should_IncludeDrawLeg_When_OnlyDrawMarginTightens() { // Win legs are held constant (zero contribution); only the draw price lengthens // (3.00 → 4.00), dropping the overround by ~0.083. If the overround ignored the // draw leg the compression would be 0 and nothing would flag — so flagging proves // the three-way leg is summed into the margin. var snapshots = new[] { Live3Way(0, 2.00m, 3.00m, 2.00m), Live3Way(30, 2.00m, 3.50m, 2.00m), Live3Way(60, 2.00m, 4.00m, 2.00m), }; var result = CreateSut().Detect(Event, snapshots); result.Should().NotBeEmpty(); result.Should().OnlyContain(a => a.Kind == AnomalyKind.OverroundCompression); } }