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