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