feat(anomaly): overround-compression detector

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.
This commit is contained in:
2026-05-29 01:46:56 +03:00
parent 5eb3dec24b
commit 115872aad0
13 changed files with 368 additions and 12 deletions
@@ -51,4 +51,16 @@ public sealed class AnomalyOptions
/// Default: 0.05 (5 percentage points).
/// </summary>
public decimal SuspensionFreezeThreshold { get; init; } = 0.05m;
/// <summary>
/// Trailing window, in seconds, over which the overround-compression detector
/// measures a continuous margin drop. Default: 120 s.
/// </summary>
public int OverroundWindowSeconds { get; init; } = 120;
/// <summary>
/// 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).
/// </summary>
public decimal OverroundCompressionThreshold { get; init; } = 0.02m;
}
@@ -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);
@@ -26,14 +26,19 @@ internal static class MatchWinEvidence
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
};
/// <summary>Normalised match-win implied probabilities + raw rates for a snapshot.</summary>
/// <summary>
/// Normalised match-win implied probabilities + raw rates for a snapshot.
/// <see cref="Overround"/> is the raw implied-probability sum (the bookmaker's
/// margin/vig, &gt;= 1.0) before normalisation.
/// </summary>
public sealed record Probabilities(
decimal P1,
decimal? PDraw,
decimal P2,
decimal Rate1,
decimal? RateDraw,
decimal Rate2);
decimal Rate2,
decimal Overround);
/// <summary>
/// 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);
}
/// <summary>Label of the side carrying the highest normalised implied probability.</summary>
@@ -0,0 +1,115 @@
using Marathon.Domain.Entities;
using Marathon.Domain.Enums;
using Marathon.Domain.ValueObjects;
namespace Marathon.Domain.AnomalyDetection;
/// <summary>
/// Detects an "overround compression": the bookmaker's margin (the raw implied-probability
/// sum, &gt;= 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.
/// </summary>
/// <remarks>
/// <para>
/// Like the steam-move detector, it only considers windows with no suspension-sized gap
/// (controlled by <c>maxStepGapSeconds</c>), 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 <c>AnomalyKind.IsDirectional</c>).
/// </para>
/// <para>
/// Score scales the margin drop against a reference collapse: a drop of
/// <see cref="ReferenceCompression"/> (10 margin points) or more reads as a full-strength
/// signal (1.0); the configured <c>compressionThreshold</c> is the minimum drop to flag.
/// </para>
/// </remarks>
public sealed class OverroundCompressionDetector : IAnomalyDetector
{
/// <summary>A 10-margin-point collapse maps to the maximum score of 1.0.</summary>
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;
}
/// <inheritdoc />
public IReadOnlyList<Anomaly> Detect(EventId eventId, IReadOnlyList<OddsSnapshot> 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<Anomaly>();
var window = TimeSpan.FromSeconds(_windowSeconds);
var maxStepGap = TimeSpan.FromSeconds(_maxStepGapSeconds);
var anomalies = new List<Anomaly>();
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();
}
}
+6
View File
@@ -22,4 +22,10 @@ public enum AnomalyKind
/// (favourite unchanged, negligible price move) — a freeze signalling uncertainty.
/// </summary>
SuspensionFreeze,
/// <summary>
/// The bookmaker's margin (overround) compressed sharply over a short continuous
/// window — the book tightened its vig, often ahead of news or when confident.
/// </summary>
OverroundCompression,
}
@@ -19,6 +19,7 @@ public static class AnomalyKindExtensions
AnomalyKind.SuspensionFlip => true,
AnomalyKind.SteamMove => true,
AnomalyKind.SuspensionFreeze => false,
AnomalyKind.OverroundCompression => false,
_ => false,
};
}
@@ -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,
+5 -4
View File
@@ -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);
+5 -4
View File
@@ -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)
@@ -169,6 +169,7 @@
<data name="Anomaly.Kind.SuspensionFlip"><value>Suspension flip</value></data>
<data name="Anomaly.Kind.SteamMove"><value>Steam move</value></data>
<data name="Anomaly.Kind.SuspensionFreeze"><value>Suspension freeze</value></data>
<data name="Anomaly.Kind.OverroundCompression"><value>Margin compression</value></data>
<data name="Anomaly.Score"><value>Confidence</value></data>
<!-- Phase 7 — Anomaly feed UI -->
@@ -182,6 +182,7 @@
<data name="Anomaly.Kind.SuspensionFlip"><value>Разворот после заморозки</value></data>
<data name="Anomaly.Kind.SteamMove"><value>Движение линии</value></data>
<data name="Anomaly.Kind.SuspensionFreeze"><value>Заморозка линии</value></data>
<data name="Anomaly.Kind.OverroundCompression"><value>Сжатие маржи</value></data>
<data name="Anomaly.Score"><value>Уверенность</value></data>
<!-- Phase 7 — Лента аномалий -->