68f3229c35
- 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.
108 lines
4.6 KiB
C#
108 lines
4.6 KiB
C#
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();
|
||
}
|
||
}
|