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