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