115872aad0
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.
116 lines
4.9 KiB
C#
116 lines
4.9 KiB
C#
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, >= 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();
|
|
}
|
|
}
|