From 115872aad057cceb0b590e932e5b2ff9e49bddf2 Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Fri, 29 May 2026 01:46:56 +0300 Subject: [PATCH] feat(anomaly): overround-compression detector MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the 4th IAnomalyDetector: flags a sharp drop in the bookmaker's overround (raw implied-probability sum / margin) over a continuous live window. Informational/non-directional — excluded from outcome grading and backtest staking via AnomalyKind.IsDirectional(). - OverroundCompressionDetector: sliding-window scan mirroring SteamMove, score = min(1, compression / 0.10 reference), suspension-gap aware. - MatchWinEvidence.Probabilities gains Overround (pre-normalisation margin); evidence JSON shape unchanged. - Wired into DetectAnomaliesUseCase fan-out; AnomalyOptions + appsettings (window 120s, threshold 0.02); en/ru resx + KindLabel arms. - 11 detector tests incl. score saturation/scaling, inclusive threshold boundary, and a three-way (draw-leg) overround case. --- .../Configuration/AnomalyOptions.cs | 12 ++ .../UseCases/DetectAnomaliesUseCase.cs | 5 + .../AnomalyDetection/MatchWinEvidence.cs | 12 +- .../OverroundCompressionDetector.cs | 115 ++++++++++ src/Marathon.Domain/Enums/AnomalyKind.cs | 6 + .../Enums/AnomalyKindExtensions.cs | 1 + src/Marathon.Hosts.WpfBlazor/appsettings.json | 4 +- src/Marathon.UI/Components/AnomalyCard.razor | 9 +- src/Marathon.UI/Pages/Anomalies/Detail.razor | 9 +- .../Resources/SharedResource.en.resx | 1 + .../Resources/SharedResource.ru.resx | 1 + .../OverroundCompressionDetectorTests.cs | 204 ++++++++++++++++++ .../Enums/AnomalyKindExtensionsTests.cs | 1 + 13 files changed, 368 insertions(+), 12 deletions(-) create mode 100644 src/Marathon.Domain/AnomalyDetection/OverroundCompressionDetector.cs create mode 100644 tests/Marathon.Domain.Tests/AnomalyDetection/OverroundCompressionDetectorTests.cs diff --git a/src/Marathon.Application/Configuration/AnomalyOptions.cs b/src/Marathon.Application/Configuration/AnomalyOptions.cs index 4c39c7b..22221ad 100644 --- a/src/Marathon.Application/Configuration/AnomalyOptions.cs +++ b/src/Marathon.Application/Configuration/AnomalyOptions.cs @@ -51,4 +51,16 @@ public sealed class AnomalyOptions /// Default: 0.05 (5 percentage points). /// public decimal SuspensionFreezeThreshold { get; init; } = 0.05m; + + /// + /// Trailing window, in seconds, over which the overround-compression detector + /// measures a continuous margin drop. Default: 120 s. + /// + public int OverroundWindowSeconds { get; init; } = 120; + + /// + /// Minimum drop in the bookmaker's overround (raw implied-probability sum) within the + /// window to flag a compression. Must be in (0, 1). Default: 0.02 (2 margin points). + /// + public decimal OverroundCompressionThreshold { get; init; } = 0.02m; } diff --git a/src/Marathon.Application/UseCases/DetectAnomaliesUseCase.cs b/src/Marathon.Application/UseCases/DetectAnomaliesUseCase.cs index 3a8d74f..c8641c2 100644 --- a/src/Marathon.Application/UseCases/DetectAnomaliesUseCase.cs +++ b/src/Marathon.Application/UseCases/DetectAnomaliesUseCase.cs @@ -74,6 +74,11 @@ public sealed class DetectAnomaliesUseCase _options.SuspensionGapSeconds, _options.SuspensionFreezeThreshold, _options.MinSnapshotCount), + new OverroundCompressionDetector( + _options.OverroundWindowSeconds, + _options.OverroundCompressionThreshold, + _options.MinSnapshotCount, + _options.SuspensionGapSeconds), }; var events = await _eventRepo.ListAsync(ct); diff --git a/src/Marathon.Domain/AnomalyDetection/MatchWinEvidence.cs b/src/Marathon.Domain/AnomalyDetection/MatchWinEvidence.cs index b86bf25..d81d736 100644 --- a/src/Marathon.Domain/AnomalyDetection/MatchWinEvidence.cs +++ b/src/Marathon.Domain/AnomalyDetection/MatchWinEvidence.cs @@ -26,14 +26,19 @@ internal static class MatchWinEvidence DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, }; - /// Normalised match-win implied probabilities + raw rates for a snapshot. + /// + /// Normalised match-win implied probabilities + raw rates for a snapshot. + /// is the raw implied-probability sum (the bookmaker's + /// margin/vig, >= 1.0) before normalisation. + /// public sealed record Probabilities( decimal P1, decimal? PDraw, decimal P2, decimal Rate1, decimal? RateDraw, - decimal Rate2); + decimal Rate2, + decimal Overround); /// /// Extracts normalised match-win implied probabilities, or null when the snapshot @@ -65,7 +70,8 @@ internal static class MatchWinEvidence P2: rawP2 / total, Rate1: win1.Rate.Value, RateDraw: drawBet?.Rate.Value, - Rate2: win2.Rate.Value); + Rate2: win2.Rate.Value, + Overround: total); } /// Label of the side carrying the highest normalised implied probability. diff --git a/src/Marathon.Domain/AnomalyDetection/OverroundCompressionDetector.cs b/src/Marathon.Domain/AnomalyDetection/OverroundCompressionDetector.cs new file mode 100644 index 0000000..94d699a --- /dev/null +++ b/src/Marathon.Domain/AnomalyDetection/OverroundCompressionDetector.cs @@ -0,0 +1,115 @@ +using Marathon.Domain.Entities; +using Marathon.Domain.Enums; +using Marathon.Domain.ValueObjects; + +namespace Marathon.Domain.AnomalyDetection; + +/// +/// Detects an "overround compression": the bookmaker's margin (the raw implied-probability +/// sum, >= 1.0) drops sharply over a short CONTINUOUS window — the book tightens its vig, +/// often ahead of news or when it is confident in the line. +/// +/// +/// +/// Like the steam-move detector, it only considers windows with no suspension-sized gap +/// (controlled by maxStepGapSeconds), so it never overlaps the across-suspension +/// flip / freeze detectors. It is informational (non-directional) — the score is the +/// compression intensity, not a side prediction — so the outcome evaluator and backtest +/// exclude it (see AnomalyKind.IsDirectional). +/// +/// +/// Score scales the margin drop against a reference collapse: a drop of +/// (10 margin points) or more reads as a full-strength +/// signal (1.0); the configured compressionThreshold is the minimum drop to flag. +/// +/// +public sealed class OverroundCompressionDetector : IAnomalyDetector +{ + /// A 10-margin-point collapse maps to the maximum score of 1.0. + public const decimal ReferenceCompression = 0.10m; + + private readonly int _windowSeconds; + private readonly decimal _compressionThreshold; + private readonly int _minSnapshotCount; + private readonly int _maxStepGapSeconds; + + public OverroundCompressionDetector( + int windowSeconds, decimal compressionThreshold, int minSnapshotCount, int maxStepGapSeconds) + { + if (windowSeconds <= 0) + throw new ArgumentOutOfRangeException(nameof(windowSeconds), windowSeconds, "Must be positive."); + if (compressionThreshold is <= 0m or >= 1m) + throw new ArgumentOutOfRangeException(nameof(compressionThreshold), compressionThreshold, "Must be in (0, 1)."); + if (minSnapshotCount < 2) + throw new ArgumentOutOfRangeException(nameof(minSnapshotCount), minSnapshotCount, "Must be at least 2."); + if (maxStepGapSeconds <= 0) + throw new ArgumentOutOfRangeException(nameof(maxStepGapSeconds), maxStepGapSeconds, "Must be positive."); + + _windowSeconds = windowSeconds; + _compressionThreshold = compressionThreshold; + _minSnapshotCount = minSnapshotCount; + _maxStepGapSeconds = maxStepGapSeconds; + } + + /// + public IReadOnlyList Detect(EventId eventId, IReadOnlyList snapshots) + { + ArgumentNullException.ThrowIfNull(eventId); + ArgumentNullException.ThrowIfNull(snapshots); + + var live = snapshots + .Where(s => s.Source == OddsSource.Live) + .OrderBy(s => s.CapturedAt) + .ToList(); + + if (live.Count < _minSnapshotCount) + return Array.Empty(); + + var window = TimeSpan.FromSeconds(_windowSeconds); + var maxStepGap = TimeSpan.FromSeconds(_maxStepGapSeconds); + + var anomalies = new List(); + int windowStart = 0; + int continuityStart = 0; + + for (int end = 1; end < live.Count; end++) + { + if (live[end].CapturedAt - live[end - 1].CapturedAt > maxStepGap) + continuityStart = end; + + while (live[end].CapturedAt - live[windowStart].CapturedAt > window) + windowStart++; + + int start = Math.Max(windowStart, continuityStart); + if (start >= end) + continue; + + var pre = MatchWinEvidence.Extract(live[start]); + var post = MatchWinEvidence.Extract(live[end]); + if (pre is null || post is null) + continue; + + // Compression is measured start-to-end of the current window. Because the loop + // emits at every `end` over a sliding window, an intra-window dip that later + // recovers is still flagged on the iteration whose `end` lands on the trough + // (where `start` still holds the pre-dip margin), so a separate peak-to-trough + // scan is unnecessary. Positive = the margin shrank (book tightened). + var compression = pre.Overround - post.Overround; + if (compression < _compressionThreshold) + continue; + + var gapSeconds = (int)(live[end].CapturedAt - live[start].CapturedAt).TotalSeconds; + var evidenceJson = MatchWinEvidence.BuildJson(gapSeconds, live[start], pre, live[end], post); + + anomalies.Add(new Anomaly( + Id: Guid.NewGuid(), + EventId: eventId, + DetectedAt: MoscowTime.Now, + Kind: AnomalyKind.OverroundCompression, + Score: Math.Min(1m, compression / ReferenceCompression), + EvidenceJson: evidenceJson)); + } + + return anomalies.AsReadOnly(); + } +} diff --git a/src/Marathon.Domain/Enums/AnomalyKind.cs b/src/Marathon.Domain/Enums/AnomalyKind.cs index e2ab0d3..e9842fa 100644 --- a/src/Marathon.Domain/Enums/AnomalyKind.cs +++ b/src/Marathon.Domain/Enums/AnomalyKind.cs @@ -22,4 +22,10 @@ public enum AnomalyKind /// (favourite unchanged, negligible price move) — a freeze signalling uncertainty. /// SuspensionFreeze, + + /// + /// The bookmaker's margin (overround) compressed sharply over a short continuous + /// window — the book tightened its vig, often ahead of news or when confident. + /// + OverroundCompression, } diff --git a/src/Marathon.Domain/Enums/AnomalyKindExtensions.cs b/src/Marathon.Domain/Enums/AnomalyKindExtensions.cs index 830bca6..00a20c7 100644 --- a/src/Marathon.Domain/Enums/AnomalyKindExtensions.cs +++ b/src/Marathon.Domain/Enums/AnomalyKindExtensions.cs @@ -19,6 +19,7 @@ public static class AnomalyKindExtensions AnomalyKind.SuspensionFlip => true, AnomalyKind.SteamMove => true, AnomalyKind.SuspensionFreeze => false, + AnomalyKind.OverroundCompression => false, _ => false, }; } diff --git a/src/Marathon.Hosts.WpfBlazor/appsettings.json b/src/Marathon.Hosts.WpfBlazor/appsettings.json index 2f67d90..c9e4209 100644 --- a/src/Marathon.Hosts.WpfBlazor/appsettings.json +++ b/src/Marathon.Hosts.WpfBlazor/appsettings.json @@ -44,7 +44,9 @@ "DetectionIntervalSeconds": 60, "SteamMoveWindowSeconds": 120, "SteamMoveDriftThreshold": 0.20, - "SuspensionFreezeThreshold": 0.05 + "SuspensionFreezeThreshold": 0.05, + "OverroundWindowSeconds": 120, + "OverroundCompressionThreshold": 0.02 }, "Notifications": { "Enabled": false, diff --git a/src/Marathon.UI/Components/AnomalyCard.razor b/src/Marathon.UI/Components/AnomalyCard.razor index d71db86..db21f8b 100644 --- a/src/Marathon.UI/Components/AnomalyCard.razor +++ b/src/Marathon.UI/Components/AnomalyCard.razor @@ -211,10 +211,11 @@ private string KindLabel(AnomalyKind kind) => kind switch { - AnomalyKind.SuspensionFlip => L["Anomaly.Kind.SuspensionFlip"], - AnomalyKind.SteamMove => L["Anomaly.Kind.SteamMove"], - AnomalyKind.SuspensionFreeze => L["Anomaly.Kind.SuspensionFreeze"], - _ => kind.ToString(), + AnomalyKind.SuspensionFlip => L["Anomaly.Kind.SuspensionFlip"], + AnomalyKind.SteamMove => L["Anomaly.Kind.SteamMove"], + AnomalyKind.SuspensionFreeze => L["Anomaly.Kind.SuspensionFreeze"], + AnomalyKind.OverroundCompression => L["Anomaly.Kind.OverroundCompression"], + _ => kind.ToString(), }; private string SportLabel(int code) => SportLabels.Resolve(L, code); diff --git a/src/Marathon.UI/Pages/Anomalies/Detail.razor b/src/Marathon.UI/Pages/Anomalies/Detail.razor index 4321c49..380b56f 100644 --- a/src/Marathon.UI/Pages/Anomalies/Detail.razor +++ b/src/Marathon.UI/Pages/Anomalies/Detail.razor @@ -105,10 +105,11 @@ private string KindLabel(AnomalyKind kind) => kind switch { - AnomalyKind.SuspensionFlip => L["Anomaly.Kind.SuspensionFlip"], - AnomalyKind.SteamMove => L["Anomaly.Kind.SteamMove"], - AnomalyKind.SuspensionFreeze => L["Anomaly.Kind.SuspensionFreeze"], - _ => kind.ToString(), + AnomalyKind.SuspensionFlip => L["Anomaly.Kind.SuspensionFlip"], + AnomalyKind.SteamMove => L["Anomaly.Kind.SteamMove"], + AnomalyKind.SuspensionFreeze => L["Anomaly.Kind.SuspensionFreeze"], + AnomalyKind.OverroundCompression => L["Anomaly.Kind.OverroundCompression"], + _ => kind.ToString(), }; private static string FormatGap(int seconds) diff --git a/src/Marathon.UI/Resources/SharedResource.en.resx b/src/Marathon.UI/Resources/SharedResource.en.resx index 84c7255..0cf5a23 100644 --- a/src/Marathon.UI/Resources/SharedResource.en.resx +++ b/src/Marathon.UI/Resources/SharedResource.en.resx @@ -169,6 +169,7 @@ Suspension flip Steam move Suspension freeze + Margin compression Confidence diff --git a/src/Marathon.UI/Resources/SharedResource.ru.resx b/src/Marathon.UI/Resources/SharedResource.ru.resx index 10ee367..52bd77c 100644 --- a/src/Marathon.UI/Resources/SharedResource.ru.resx +++ b/src/Marathon.UI/Resources/SharedResource.ru.resx @@ -182,6 +182,7 @@ Разворот после заморозки Движение линии Заморозка линии + Сжатие маржи Уверенность diff --git a/tests/Marathon.Domain.Tests/AnomalyDetection/OverroundCompressionDetectorTests.cs b/tests/Marathon.Domain.Tests/AnomalyDetection/OverroundCompressionDetectorTests.cs new file mode 100644 index 0000000..caaafd5 --- /dev/null +++ b/tests/Marathon.Domain.Tests/AnomalyDetection/OverroundCompressionDetectorTests.cs @@ -0,0 +1,204 @@ +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); + } +} diff --git a/tests/Marathon.Domain.Tests/Enums/AnomalyKindExtensionsTests.cs b/tests/Marathon.Domain.Tests/Enums/AnomalyKindExtensionsTests.cs index c2777f8..6a2e64c 100644 --- a/tests/Marathon.Domain.Tests/Enums/AnomalyKindExtensionsTests.cs +++ b/tests/Marathon.Domain.Tests/Enums/AnomalyKindExtensionsTests.cs @@ -9,6 +9,7 @@ public sealed class AnomalyKindExtensionsTests [InlineData(AnomalyKind.SuspensionFlip, true)] [InlineData(AnomalyKind.SteamMove, true)] [InlineData(AnomalyKind.SuspensionFreeze, false)] + [InlineData(AnomalyKind.OverroundCompression, false)] public void IsDirectional_Should_ClassifyKinds(AnomalyKind kind, bool expected) { kind.IsDirectional().Should().Be(expected);