using Marathon.Domain.Entities; using Marathon.Domain.Enums; using Marathon.Domain.ValueObjects; namespace Marathon.Domain.AnomalyDetection; /// /// Detects a "suspension freeze": the market was suspended (a gap larger than /// suspensionGapSeconds between adjacent live snapshots) but resumed with /// essentially the same line — the favourite is unchanged and the largest normalised /// implied-probability move is below freezeThreshold. /// /// /// /// This is the mirror image of (SuspensionFlip): the flip /// fires on a large favourite-changing move across a suspension; the freeze fires when /// the bookmaker paused but did not move — a tell that they were uncertain or /// gathering information rather than repricing. /// /// /// Score = how completely the line froze: 1 − (maxMove / freezeThreshold), so a /// perfectly unchanged line scores ~1.0 and one near the threshold scores near 0. The /// shared shape (pre ≈ post) conveys the freeze directly, /// and the outcome evaluator grades the unchanged favourite like any other anomaly. /// /// public sealed class SuspensionFreezeDetector : IAnomalyDetector { private readonly int _suspensionGapSeconds; private readonly decimal _freezeThreshold; private readonly int _minSnapshotCount; /// Minimum adjacent-snapshot gap (seconds) classed as a suspension. /// Maximum normalised probability move to count as frozen; in (0, 1). /// Minimum live snapshots before detection runs (>= 2). public SuspensionFreezeDetector(int suspensionGapSeconds, decimal freezeThreshold, int minSnapshotCount) { if (suspensionGapSeconds <= 0) throw new ArgumentOutOfRangeException(nameof(suspensionGapSeconds), suspensionGapSeconds, "Must be positive."); if (freezeThreshold is <= 0m or >= 1m) throw new ArgumentOutOfRangeException(nameof(freezeThreshold), freezeThreshold, "Must be in (0, 1)."); if (minSnapshotCount < 2) throw new ArgumentOutOfRangeException(nameof(minSnapshotCount), minSnapshotCount, "Must be at least 2."); _suspensionGapSeconds = suspensionGapSeconds; _freezeThreshold = freezeThreshold; _minSnapshotCount = minSnapshotCount; } /// 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 suspensionGap = TimeSpan.FromSeconds(_suspensionGapSeconds); var anomalies = new List(); for (int i = 0; i < live.Count - 1; i++) { var pre = live[i]; var post = live[i + 1]; if (post.CapturedAt - pre.CapturedAt <= suspensionGap) continue; var preProbs = MatchWinEvidence.Extract(pre); var postProbs = MatchWinEvidence.Extract(post); if (preProbs is null || postProbs is null) continue; decimal maxMove = Math.Max( Math.Abs(postProbs.P1 - preProbs.P1), Math.Abs(postProbs.P2 - preProbs.P2)); if (preProbs.PDraw.HasValue && postProbs.PDraw.HasValue) maxMove = Math.Max(maxMove, Math.Abs(postProbs.PDraw.Value - preProbs.PDraw.Value)); var favouriteUnchanged = MatchWinEvidence.Favourite(preProbs) == MatchWinEvidence.Favourite(postProbs); // Strictly below the threshold so the score stays in (0, 1]. if (!favouriteUnchanged || maxMove >= _freezeThreshold) continue; var score = 1m - (maxMove / _freezeThreshold); var gapSeconds = (int)(post.CapturedAt - pre.CapturedAt).TotalSeconds; var evidenceJson = MatchWinEvidence.BuildJson(gapSeconds, pre, preProbs, post, postProbs); anomalies.Add(new Anomaly( Id: Guid.NewGuid(), EventId: eventId, DetectedAt: MoscowTime.Now, Kind: AnomalyKind.SuspensionFreeze, Score: score, EvidenceJson: evidenceJson)); } return anomalies.AsReadOnly(); } }