Files
maraphon-app/src/Marathon.Domain/AnomalyDetection/SuspensionFreezeDetector.cs
T
alexei.dolgolyov 68f3229c35 feat(anomaly): suspension-freeze detector
- Add SuspensionFreezeDetector via the IAnomalyDetector seam: a suspension gap with
  the favourite unchanged and a negligible (< threshold) price move — the mirror of
  the flip. Score = how completely the line froze. Reuses MatchWinEvidence so UI +
  evaluator handle it unchanged. 6 tests.
- Add AnomalyKind.SuspensionFreeze + localized card/detail label, SuspensionFreezeThreshold
  option, and fan it into DetectAnomaliesUseCase.
2026-05-29 01:03:47 +03:00

108 lines
4.6 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using Marathon.Domain.Entities;
using Marathon.Domain.Enums;
using Marathon.Domain.ValueObjects;
namespace Marathon.Domain.AnomalyDetection;
/// <summary>
/// Detects a "suspension freeze": the market was suspended (a gap larger than
/// <c>suspensionGapSeconds</c> between adjacent live snapshots) but resumed with
/// essentially the same line — the favourite is unchanged and the largest normalised
/// implied-probability move is below <c>freezeThreshold</c>.
/// </summary>
/// <remarks>
/// <para>
/// This is the mirror image of <see cref="AnomalyDetector"/> (SuspensionFlip): the flip
/// fires on a large favourite-changing move across a suspension; the freeze fires when
/// the bookmaker paused but did <i>not</i> move — a tell that they were uncertain or
/// gathering information rather than repricing.
/// </para>
/// <para>
/// Score = how completely the line froze: <c>1 (maxMove / freezeThreshold)</c>, so a
/// perfectly unchanged line scores ~1.0 and one near the threshold scores near 0. The
/// shared <see cref="MatchWinEvidence"/> shape (pre ≈ post) conveys the freeze directly,
/// and the outcome evaluator grades the unchanged favourite like any other anomaly.
/// </para>
/// </remarks>
public sealed class SuspensionFreezeDetector : IAnomalyDetector
{
private readonly int _suspensionGapSeconds;
private readonly decimal _freezeThreshold;
private readonly int _minSnapshotCount;
/// <param name="suspensionGapSeconds">Minimum adjacent-snapshot gap (seconds) classed as a suspension.</param>
/// <param name="freezeThreshold">Maximum normalised probability move to count as frozen; in (0, 1).</param>
/// <param name="minSnapshotCount">Minimum live snapshots before detection runs (>= 2).</param>
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;
}
/// <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 suspensionGap = TimeSpan.FromSeconds(_suspensionGapSeconds);
var anomalies = new List<Anomaly>();
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();
}
}