2b1025cae3
- Introduce IAnomalyDetector; the existing flip detector implements it. - Extract MatchWinEvidence so every detector writes the identical pre/post evidence shape — the UI parser and outcome evaluator handle new kinds with no branching (steam moves get hit-rate calibrated for free). - Add SteamMoveDetector: flags a rapid one-directional implied-probability rise over a short CONTINUOUS window (no suspension gap inside it), so it never double-flags the same interval as the suspension-flip detector. - DetectAnomaliesUseCase fans out over both detectors; dedup keys on EventId+Kind so flip and steam signals persist independently. Add AnomalyKind.SteamMove + SteamMove window/threshold options. 8 detector tests.
122 lines
5.3 KiB
C#
122 lines
5.3 KiB
C#
using Marathon.Domain.Entities;
|
|
using Marathon.Domain.Enums;
|
|
using Marathon.Domain.ValueObjects;
|
|
|
|
namespace Marathon.Domain.AnomalyDetection;
|
|
|
|
/// <summary>
|
|
/// Detects a "steam move": a rapid, one-directional rise in a side's normalised
|
|
/// implied probability over a short CONTINUOUS window — money moving the line.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// A window is only considered when it contains no suspension-sized gap between
|
|
/// consecutive snapshots (controlled by <c>maxStepGapSeconds</c>); drift across a
|
|
/// suspension is the <see cref="AnomalyDetector"/>'s (SuspensionFlip) territory, so
|
|
/// the two detectors never double-flag the same interval.
|
|
/// </para>
|
|
/// <para>
|
|
/// Emits an <see cref="AnomalyKind.SteamMove"/> anomaly whose pre/post evidence
|
|
/// brackets the drift, written in the shared <see cref="MatchWinEvidence"/> shape so
|
|
/// the UI and <see cref="AnomalyOutcomeEvaluator"/> handle it without branching.
|
|
/// A sustained steam may cross the threshold at several consecutive snapshots; those
|
|
/// are collapsed to one persisted row by the detection use case's dedup window.
|
|
/// </para>
|
|
/// </remarks>
|
|
public sealed class SteamMoveDetector : IAnomalyDetector
|
|
{
|
|
private readonly int _windowSeconds;
|
|
private readonly decimal _driftThreshold;
|
|
private readonly int _minSnapshotCount;
|
|
private readonly int _maxStepGapSeconds;
|
|
|
|
/// <param name="windowSeconds">Trailing window (seconds) over which drift is measured.</param>
|
|
/// <param name="driftThreshold">Minimum one-directional implied-probability rise to flag; in (0, 1).</param>
|
|
/// <param name="minSnapshotCount">Minimum live snapshots before detection runs (>= 2).</param>
|
|
/// <param name="maxStepGapSeconds">
|
|
/// Maximum gap between consecutive snapshots for the window to count as continuous.
|
|
/// A larger gap means a suspension occurred — that is flip territory, not steam.
|
|
/// </param>
|
|
public SteamMoveDetector(int windowSeconds, decimal driftThreshold, int minSnapshotCount, int maxStepGapSeconds)
|
|
{
|
|
if (windowSeconds <= 0)
|
|
throw new ArgumentOutOfRangeException(nameof(windowSeconds), windowSeconds, "Must be positive.");
|
|
if (driftThreshold is <= 0m or >= 1m)
|
|
throw new ArgumentOutOfRangeException(nameof(driftThreshold), driftThreshold, "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;
|
|
_driftThreshold = driftThreshold;
|
|
_minSnapshotCount = minSnapshotCount;
|
|
_maxStepGapSeconds = maxStepGapSeconds;
|
|
}
|
|
|
|
/// <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 window = TimeSpan.FromSeconds(_windowSeconds);
|
|
var maxStepGap = TimeSpan.FromSeconds(_maxStepGapSeconds);
|
|
|
|
var anomalies = new List<Anomaly>();
|
|
int windowStart = 0;
|
|
int continuityStart = 0;
|
|
|
|
for (int end = 1; end < live.Count; end++)
|
|
{
|
|
// A suspension-sized step resets continuity: the drift after it is a flip,
|
|
// not a steam move, so steam windows never span a suspension.
|
|
if (live[end].CapturedAt - live[end - 1].CapturedAt > maxStepGap)
|
|
continuityStart = end;
|
|
|
|
// Shrink the trailing window so [windowStart, end] is within windowSeconds.
|
|
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;
|
|
|
|
// One-directional rise: a side's normalised probability INCREASED (odds
|
|
// shortened) by at least the threshold — money steamed onto that side.
|
|
decimal drift = Math.Max(post.P1 - pre.P1, post.P2 - pre.P2);
|
|
if (pre.PDraw.HasValue && post.PDraw.HasValue)
|
|
drift = Math.Max(drift, post.PDraw.Value - pre.PDraw.Value);
|
|
|
|
if (drift < _driftThreshold)
|
|
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.SteamMove,
|
|
Score: Math.Min(1m, drift),
|
|
EvidenceJson: evidenceJson));
|
|
}
|
|
|
|
return anomalies.AsReadOnly();
|
|
}
|
|
}
|