feat(anomaly): IAnomalyDetector seam + steam-move detector
- 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.
This commit is contained in:
@@ -32,4 +32,16 @@ public sealed class AnomalyOptions
|
||||
/// in seconds. Default: 60 s.
|
||||
/// </summary>
|
||||
public int DetectionIntervalSeconds { get; init; } = 60;
|
||||
|
||||
/// <summary>
|
||||
/// Trailing window, in seconds, over which the steam-move detector measures a
|
||||
/// continuous one-directional probability drift. Default: 120 s.
|
||||
/// </summary>
|
||||
public int SteamMoveWindowSeconds { get; init; } = 120;
|
||||
|
||||
/// <summary>
|
||||
/// Minimum one-directional normalised implied-probability rise within the window
|
||||
/// to flag a steam move. Must be in (0, 1). Default: 0.20 (20 percentage points).
|
||||
/// </summary>
|
||||
public decimal SteamMoveDriftThreshold { get; init; } = 0.20m;
|
||||
}
|
||||
|
||||
@@ -59,10 +59,18 @@ public sealed class DetectAnomaliesUseCase
|
||||
{
|
||||
_logger.LogInformation("DetectAnomaliesUseCase: cycle started");
|
||||
|
||||
var detector = new AnomalyDetector(
|
||||
var detectors = new IAnomalyDetector[]
|
||||
{
|
||||
new AnomalyDetector(
|
||||
_options.SuspensionGapSeconds,
|
||||
_options.OddsFlipThreshold,
|
||||
_options.MinSnapshotCount);
|
||||
_options.MinSnapshotCount),
|
||||
new SteamMoveDetector(
|
||||
_options.SteamMoveWindowSeconds,
|
||||
_options.SteamMoveDriftThreshold,
|
||||
_options.MinSnapshotCount,
|
||||
_options.SuspensionGapSeconds),
|
||||
};
|
||||
|
||||
var events = await _eventRepo.ListAsync(ct);
|
||||
int newAnomalyCount = 0;
|
||||
@@ -96,7 +104,7 @@ public sealed class DetectAnomaliesUseCase
|
||||
var existingForEvent = existingByEvent.TryGetValue(ev.Id, out var slice)
|
||||
? slice
|
||||
: new List<Anomaly>();
|
||||
newAnomalyCount += await ProcessEventAsync(detector, ev, snapshots, existingForEvent, ct);
|
||||
newAnomalyCount += await ProcessEventAsync(detectors, ev, snapshots, existingForEvent, ct);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
@@ -120,13 +128,17 @@ public sealed class DetectAnomaliesUseCase
|
||||
// ── Private helpers ───────────────────────────────────────────────────────
|
||||
|
||||
private async Task<int> ProcessEventAsync(
|
||||
AnomalyDetector detector,
|
||||
IReadOnlyList<IAnomalyDetector> detectors,
|
||||
Event ev,
|
||||
IReadOnlyList<OddsSnapshot> snapshots,
|
||||
List<Anomaly> existingForEvent,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var detected = detector.Detect(ev.Id, snapshots);
|
||||
// Fan out over every detector kind; dedup below keys on EventId + Kind so the
|
||||
// flip and steam signals for one event persist independently.
|
||||
var detected = detectors
|
||||
.SelectMany(d => d.Detect(ev.Id, snapshots))
|
||||
.ToList();
|
||||
|
||||
if (detected.Count == 0)
|
||||
return 0;
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Marathon.Domain.Entities;
|
||||
using Marathon.Domain.Enums;
|
||||
using Marathon.Domain.ValueObjects;
|
||||
@@ -23,20 +21,15 @@ namespace Marathon.Domain.AnomalyDetection;
|
||||
/// </list>
|
||||
///
|
||||
/// This class is stateless and deterministic — identical inputs always produce identical output.
|
||||
/// It has no I/O or DI dependencies.
|
||||
/// It has no I/O or DI dependencies. Evidence formatting is delegated to
|
||||
/// <see cref="MatchWinEvidence"/> so every detector kind writes the identical shape.
|
||||
/// </summary>
|
||||
public sealed class AnomalyDetector
|
||||
public sealed class AnomalyDetector : IAnomalyDetector
|
||||
{
|
||||
private readonly int _suspensionGapSeconds;
|
||||
private readonly decimal _oddsFlipThreshold;
|
||||
private readonly int _minSnapshotCount;
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
WriteIndented = false,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
};
|
||||
|
||||
/// <param name="suspensionGapSeconds">
|
||||
/// Minimum gap between adjacent live snapshots (in seconds) to classify as a suspension.
|
||||
/// Default per spec: 60.
|
||||
@@ -68,16 +61,7 @@ public sealed class AnomalyDetector
|
||||
_minSnapshotCount = minSnapshotCount;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Analyses <paramref name="snapshots"/> for the given <paramref name="eventId"/> and
|
||||
/// returns 0 or more anomalies detected in this timeline.
|
||||
/// </summary>
|
||||
/// <param name="eventId">The event being analysed.</param>
|
||||
/// <param name="snapshots">All snapshots for this event (any source, any order).</param>
|
||||
/// <returns>
|
||||
/// An <see cref="IReadOnlyList{T}"/> of <see cref="Anomaly"/> records, one per qualifying
|
||||
/// suspension interval. May be empty.
|
||||
/// </returns>
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<Anomaly> Detect(EventId eventId, IReadOnlyList<OddsSnapshot> snapshots)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(eventId);
|
||||
@@ -119,9 +103,9 @@ public sealed class AnomalyDetector
|
||||
|
||||
private Anomaly? TryDetectFlip(EventId eventId, SuspensionInterval interval)
|
||||
{
|
||||
// Extract Match-Win bets from each snapshot.
|
||||
var preProbs = ExtractMatchWinProbabilities(interval.PreSuspension);
|
||||
var postProbs = ExtractMatchWinProbabilities(interval.PostSuspension);
|
||||
// Extract Match-Win implied probabilities from each snapshot.
|
||||
var preProbs = MatchWinEvidence.Extract(interval.PreSuspension);
|
||||
var postProbs = MatchWinEvidence.Extract(interval.PostSuspension);
|
||||
|
||||
// Cannot compute flip if either snapshot lacks Win bets.
|
||||
if (preProbs is null || postProbs is null)
|
||||
@@ -129,10 +113,8 @@ public sealed class AnomalyDetector
|
||||
|
||||
// Step 4 — compute flip score = max(|p_post[i] − p_pre[i]|) across common sides.
|
||||
decimal flipScore = 0m;
|
||||
flipScore = Math.Max(flipScore,
|
||||
Math.Abs(postProbs.P1 - preProbs.P1));
|
||||
flipScore = Math.Max(flipScore,
|
||||
Math.Abs(postProbs.P2 - preProbs.P2));
|
||||
flipScore = Math.Max(flipScore, Math.Abs(postProbs.P1 - preProbs.P1));
|
||||
flipScore = Math.Max(flipScore, Math.Abs(postProbs.P2 - preProbs.P2));
|
||||
if (preProbs.PDraw.HasValue && postProbs.PDraw.HasValue)
|
||||
{
|
||||
flipScore = Math.Max(flipScore,
|
||||
@@ -140,7 +122,8 @@ public sealed class AnomalyDetector
|
||||
}
|
||||
|
||||
// Step 5 — favourite-changed test: argmax of implied probability must differ.
|
||||
bool favouriteChanged = DetermineFavourite(preProbs) != DetermineFavourite(postProbs);
|
||||
bool favouriteChanged =
|
||||
MatchWinEvidence.Favourite(preProbs) != MatchWinEvidence.Favourite(postProbs);
|
||||
|
||||
if (flipScore < _oddsFlipThreshold || !favouriteChanged)
|
||||
return null;
|
||||
@@ -148,8 +131,11 @@ public sealed class AnomalyDetector
|
||||
// Clamp score to [0, 1] before constructing the Anomaly (domain invariant).
|
||||
var clampedScore = Math.Min(1m, flipScore);
|
||||
|
||||
// Step 6 — build evidence JSON.
|
||||
var evidenceJson = BuildEvidenceJson(interval, preProbs, postProbs);
|
||||
// Step 6 — build evidence JSON via the shared formatter.
|
||||
var evidenceJson = MatchWinEvidence.BuildJson(
|
||||
(int)interval.Gap.TotalSeconds,
|
||||
interval.PreSuspension, preProbs,
|
||||
interval.PostSuspension, postProbs);
|
||||
|
||||
return new Anomaly(
|
||||
Id: Guid.NewGuid(),
|
||||
@@ -159,100 +145,4 @@ public sealed class AnomalyDetector
|
||||
Score: clampedScore,
|
||||
EvidenceJson: evidenceJson);
|
||||
}
|
||||
|
||||
private static MatchWinProbabilities? ExtractMatchWinProbabilities(OddsSnapshot snapshot)
|
||||
{
|
||||
// Find Match-scope Win bets.
|
||||
var matchWinBets = snapshot.Bets
|
||||
.Where(b => b.Scope is MatchScope && b.Type == BetType.Win)
|
||||
.ToList();
|
||||
|
||||
var win1 = matchWinBets.FirstOrDefault(b => b.Side == Side.Side1);
|
||||
var win2 = matchWinBets.FirstOrDefault(b => b.Side == Side.Side2);
|
||||
|
||||
if (win1 is null || win2 is null)
|
||||
return null; // Not enough data.
|
||||
|
||||
// Find optional Draw bet (MatchScope, BetType.Draw).
|
||||
var drawBet = snapshot.Bets
|
||||
.FirstOrDefault(b => b.Scope is MatchScope && b.Type == BetType.Draw);
|
||||
|
||||
// Raw implied probabilities: p = 1 / rate.
|
||||
decimal rawP1 = 1m / win1.Rate.Value;
|
||||
decimal rawP2 = 1m / win2.Rate.Value;
|
||||
decimal rawDraw = drawBet is not null ? 1m / drawBet.Rate.Value : 0m;
|
||||
decimal total = rawP1 + rawP2 + rawDraw;
|
||||
|
||||
// Normalise so they sum to 1.
|
||||
decimal p1 = rawP1 / total;
|
||||
decimal p2 = rawP2 / total;
|
||||
decimal pDraw = drawBet is not null ? rawDraw / total : 0m;
|
||||
|
||||
return new MatchWinProbabilities(
|
||||
P1: p1,
|
||||
PDraw: drawBet is not null ? pDraw : null,
|
||||
P2: p2,
|
||||
Rate1: win1.Rate.Value,
|
||||
RateDraw: drawBet?.Rate.Value,
|
||||
Rate2: win2.Rate.Value);
|
||||
}
|
||||
|
||||
private static string DetermineFavourite(MatchWinProbabilities probs)
|
||||
{
|
||||
// The favourite is the side with the highest normalised implied probability.
|
||||
if (probs.PDraw.HasValue && probs.PDraw.Value > probs.P1 && probs.PDraw.Value > probs.P2)
|
||||
return "Draw";
|
||||
return probs.P1 >= probs.P2 ? "Side1" : "Side2";
|
||||
}
|
||||
|
||||
private string BuildEvidenceJson(
|
||||
SuspensionInterval interval,
|
||||
MatchWinProbabilities preProbs,
|
||||
MatchWinProbabilities postProbs)
|
||||
{
|
||||
var payload = new EvidencePayload(
|
||||
SuspensionGapSeconds: (int)interval.Gap.TotalSeconds,
|
||||
PreSuspension: new SnapshotEvidence(
|
||||
CapturedAt: interval.PreSuspension.CapturedAt.ToString("O"),
|
||||
P1: preProbs.P1,
|
||||
PDraw: preProbs.PDraw,
|
||||
P2: preProbs.P2,
|
||||
Rate1: preProbs.Rate1,
|
||||
RateDraw: preProbs.RateDraw,
|
||||
Rate2: preProbs.Rate2),
|
||||
PostSuspension: new SnapshotEvidence(
|
||||
CapturedAt: interval.PostSuspension.CapturedAt.ToString("O"),
|
||||
P1: postProbs.P1,
|
||||
PDraw: postProbs.PDraw,
|
||||
P2: postProbs.P2,
|
||||
Rate1: postProbs.Rate1,
|
||||
RateDraw: postProbs.RateDraw,
|
||||
Rate2: postProbs.Rate2));
|
||||
|
||||
return JsonSerializer.Serialize(payload, JsonOptions);
|
||||
}
|
||||
|
||||
// ── Nested types ─────────────────────────────────────────────────────────
|
||||
|
||||
private sealed record MatchWinProbabilities(
|
||||
decimal P1,
|
||||
decimal? PDraw,
|
||||
decimal P2,
|
||||
decimal Rate1,
|
||||
decimal? RateDraw,
|
||||
decimal Rate2);
|
||||
|
||||
private sealed record EvidencePayload(
|
||||
[property: JsonPropertyName("suspensionGapSeconds")] int SuspensionGapSeconds,
|
||||
[property: JsonPropertyName("preSuspension")] SnapshotEvidence PreSuspension,
|
||||
[property: JsonPropertyName("postSuspension")] SnapshotEvidence PostSuspension);
|
||||
|
||||
private sealed record SnapshotEvidence(
|
||||
[property: JsonPropertyName("capturedAt")] string CapturedAt,
|
||||
[property: JsonPropertyName("p1")] decimal P1,
|
||||
[property: JsonPropertyName("pDraw")] decimal? PDraw,
|
||||
[property: JsonPropertyName("p2")] decimal P2,
|
||||
[property: JsonPropertyName("rate1")] decimal Rate1,
|
||||
[property: JsonPropertyName("rateDraw")] decimal? RateDraw,
|
||||
[property: JsonPropertyName("rate2")] decimal Rate2);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
using Marathon.Domain.Entities;
|
||||
using Marathon.Domain.ValueObjects;
|
||||
|
||||
namespace Marathon.Domain.AnomalyDetection;
|
||||
|
||||
/// <summary>
|
||||
/// A pure, stateless detector that scans one event's snapshot timeline and returns
|
||||
/// any anomalies it finds. Implementations are deterministic and free of I/O so they
|
||||
/// can be composed (fanned out) and unit-tested in isolation.
|
||||
/// </summary>
|
||||
public interface IAnomalyDetector
|
||||
{
|
||||
/// <summary>
|
||||
/// Analyses <paramref name="snapshots"/> for <paramref name="eventId"/> and returns
|
||||
/// 0 or more anomalies. May be empty; never null.
|
||||
/// </summary>
|
||||
IReadOnlyList<Anomaly> Detect(EventId eventId, IReadOnlyList<OddsSnapshot> snapshots);
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Marathon.Domain.Entities;
|
||||
using Marathon.Domain.Enums;
|
||||
using Marathon.Domain.ValueObjects;
|
||||
|
||||
namespace Marathon.Domain.AnomalyDetection;
|
||||
|
||||
/// <summary>
|
||||
/// Shared helper for the match-win implied-probability extraction and the canonical
|
||||
/// pre/post evidence-JSON shape used by every <see cref="IAnomalyDetector"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Centralising the evidence format here guarantees that all detector kinds write the
|
||||
/// identical on-disk shape, so the UI parser (<c>AnomalyEvidenceParser</c>) and the
|
||||
/// outcome evaluator (<see cref="AnomalyOutcomeEvaluator"/>) work for every kind
|
||||
/// without branching. The <c>suspensionGapSeconds</c> field carries the elapsed
|
||||
/// seconds between the two snapshots — a suspension gap for flips, a drift window for
|
||||
/// steam moves.
|
||||
/// </remarks>
|
||||
internal static class MatchWinEvidence
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
WriteIndented = false,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
};
|
||||
|
||||
/// <summary>Normalised match-win implied probabilities + raw rates for a snapshot.</summary>
|
||||
public sealed record Probabilities(
|
||||
decimal P1,
|
||||
decimal? PDraw,
|
||||
decimal P2,
|
||||
decimal Rate1,
|
||||
decimal? RateDraw,
|
||||
decimal Rate2);
|
||||
|
||||
/// <summary>
|
||||
/// Extracts normalised match-win implied probabilities, or null when the snapshot
|
||||
/// lacks both Side1 and Side2 Match-Win bets.
|
||||
/// </summary>
|
||||
public static Probabilities? Extract(OddsSnapshot snapshot)
|
||||
{
|
||||
var matchWinBets = snapshot.Bets
|
||||
.Where(b => b.Scope is MatchScope && b.Type == BetType.Win)
|
||||
.ToList();
|
||||
|
||||
var win1 = matchWinBets.FirstOrDefault(b => b.Side == Side.Side1);
|
||||
var win2 = matchWinBets.FirstOrDefault(b => b.Side == Side.Side2);
|
||||
if (win1 is null || win2 is null)
|
||||
return null;
|
||||
|
||||
var drawBet = snapshot.Bets
|
||||
.FirstOrDefault(b => b.Scope is MatchScope && b.Type == BetType.Draw);
|
||||
|
||||
// Raw implied probabilities: p = 1 / rate; normalise so they sum to 1.
|
||||
decimal rawP1 = 1m / win1.Rate.Value;
|
||||
decimal rawP2 = 1m / win2.Rate.Value;
|
||||
decimal rawDraw = drawBet is not null ? 1m / drawBet.Rate.Value : 0m;
|
||||
decimal total = rawP1 + rawP2 + rawDraw;
|
||||
|
||||
return new Probabilities(
|
||||
P1: rawP1 / total,
|
||||
PDraw: drawBet is not null ? rawDraw / total : null,
|
||||
P2: rawP2 / total,
|
||||
Rate1: win1.Rate.Value,
|
||||
RateDraw: drawBet?.Rate.Value,
|
||||
Rate2: win2.Rate.Value);
|
||||
}
|
||||
|
||||
/// <summary>Label of the side carrying the highest normalised implied probability.</summary>
|
||||
public static string Favourite(Probabilities p)
|
||||
{
|
||||
if (p.PDraw.HasValue && p.PDraw.Value > p.P1 && p.PDraw.Value > p.P2)
|
||||
return "Draw";
|
||||
return p.P1 >= p.P2 ? "Side1" : "Side2";
|
||||
}
|
||||
|
||||
/// <summary>Serialises the canonical pre/post evidence payload.</summary>
|
||||
public static string BuildJson(
|
||||
int gapSeconds,
|
||||
OddsSnapshot pre,
|
||||
Probabilities preProbs,
|
||||
OddsSnapshot post,
|
||||
Probabilities postProbs)
|
||||
{
|
||||
var payload = new EvidencePayload(
|
||||
SuspensionGapSeconds: gapSeconds,
|
||||
PreSuspension: ToEvidence(pre, preProbs),
|
||||
PostSuspension: ToEvidence(post, postProbs));
|
||||
|
||||
return JsonSerializer.Serialize(payload, JsonOptions);
|
||||
}
|
||||
|
||||
private static SnapshotEvidence ToEvidence(OddsSnapshot snapshot, Probabilities p) =>
|
||||
new(
|
||||
CapturedAt: snapshot.CapturedAt.ToString("O"),
|
||||
P1: p.P1,
|
||||
PDraw: p.PDraw,
|
||||
P2: p.P2,
|
||||
Rate1: p.Rate1,
|
||||
RateDraw: p.RateDraw,
|
||||
Rate2: p.Rate2);
|
||||
|
||||
private sealed record EvidencePayload(
|
||||
[property: JsonPropertyName("suspensionGapSeconds")] int SuspensionGapSeconds,
|
||||
[property: JsonPropertyName("preSuspension")] SnapshotEvidence PreSuspension,
|
||||
[property: JsonPropertyName("postSuspension")] SnapshotEvidence PostSuspension);
|
||||
|
||||
private sealed record SnapshotEvidence(
|
||||
[property: JsonPropertyName("capturedAt")] string CapturedAt,
|
||||
[property: JsonPropertyName("p1")] decimal P1,
|
||||
[property: JsonPropertyName("pDraw")] decimal? PDraw,
|
||||
[property: JsonPropertyName("p2")] decimal P2,
|
||||
[property: JsonPropertyName("rate1")] decimal Rate1,
|
||||
[property: JsonPropertyName("rateDraw")] decimal? RateDraw,
|
||||
[property: JsonPropertyName("rate2")] decimal Rate2);
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -10,4 +10,10 @@ public enum AnomalyKind
|
||||
/// Bookmaker suspended the market, then flipped the underdog/favourite coefficients.
|
||||
/// </summary>
|
||||
SuspensionFlip,
|
||||
|
||||
/// <summary>
|
||||
/// A rapid, one-directional drift in a side's implied probability over a short
|
||||
/// continuous window (no suspension) — money moving the line ("steam").
|
||||
/// </summary>
|
||||
SteamMove,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
using FluentAssertions;
|
||||
using Marathon.Domain.AnomalyDetection;
|
||||
using Marathon.Domain.Entities;
|
||||
using Marathon.Domain.Enums;
|
||||
using Marathon.Domain.ValueObjects;
|
||||
|
||||
namespace Marathon.Domain.Tests.AnomalyDetection;
|
||||
|
||||
public sealed class SteamMoveDetectorTests
|
||||
{
|
||||
private static readonly TimeSpan MoscowOffset = TimeSpan.FromHours(3);
|
||||
private static readonly DateTimeOffset BaseTime = new(2026, 5, 10, 18, 0, 0, MoscowOffset);
|
||||
private static readonly EventId Event = new("26000001");
|
||||
|
||||
// window 120s, drift threshold 0.20, min 3 snapshots, continuity break at 60s.
|
||||
private static SteamMoveDetector CreateSut() => new(120, 0.20m, 3, 60);
|
||||
|
||||
private static OddsSnapshot Live(int seconds, decimal r1, decimal r2) =>
|
||||
new(Event, BaseTime.AddSeconds(seconds), OddsSource.Live,
|
||||
new List<Bet>
|
||||
{
|
||||
new(MatchScope.Instance, BetType.Win, Side.Side1, null, new OddsRate(r1)),
|
||||
new(MatchScope.Instance, BetType.Win, Side.Side2, null, new OddsRate(r2)),
|
||||
});
|
||||
|
||||
[Fact]
|
||||
public void Should_FlagSteamMove_When_OneSideShortensContinuously()
|
||||
{
|
||||
// Side2 shortens (3.0 → 1.6) over 90s in continuous 30s steps: its normalised
|
||||
// implied probability rises ~0.33 → ~0.61, a ~0.28 drift > 0.20 threshold.
|
||||
var snapshots = new[]
|
||||
{
|
||||
Live(0, 1.5m, 3.0m),
|
||||
Live(30, 1.7m, 2.3m),
|
||||
Live(60, 2.1m, 1.9m),
|
||||
Live(90, 2.5m, 1.6m),
|
||||
};
|
||||
|
||||
var result = CreateSut().Detect(Event, snapshots);
|
||||
|
||||
result.Should().NotBeEmpty();
|
||||
result.Should().OnlyContain(a => a.Kind == AnomalyKind.SteamMove);
|
||||
result.Max(a => a.Score).Should().BeGreaterThanOrEqualTo(0.20m);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Should_NotFlag_When_DriftBelowThreshold()
|
||||
{
|
||||
// Gentle drift: Side2 0.333 → ~0.38, well under the 0.20 threshold.
|
||||
var snapshots = new[]
|
||||
{
|
||||
Live(0, 1.5m, 3.0m),
|
||||
Live(30, 1.55m, 2.85m),
|
||||
Live(60, 1.6m, 2.7m),
|
||||
};
|
||||
|
||||
CreateSut().Detect(Event, snapshots).Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Should_NotFlag_When_DriftSpansSuspensionGap()
|
||||
{
|
||||
// The big move happens across a 90s gap (> 60s continuity break) — that is the
|
||||
// SuspensionFlip detector's territory, so steam must not double-flag it.
|
||||
var snapshots = new[]
|
||||
{
|
||||
Live(0, 1.3m, 4.0m),
|
||||
Live(30, 1.3m, 4.0m),
|
||||
Live(120, 4.0m, 1.3m), // 90s gap
|
||||
Live(150, 4.0m, 1.3m),
|
||||
};
|
||||
|
||||
CreateSut().Detect(Event, snapshots).Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Should_ReturnEmpty_When_FewerThanMinSnapshots()
|
||||
{
|
||||
var snapshots = new[] { Live(0, 1.5m, 3.0m), Live(30, 2.5m, 1.6m) };
|
||||
|
||||
CreateSut().Detect(Event, snapshots).Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Should_IgnorePreMatchSnapshots()
|
||||
{
|
||||
var snapshots = new[]
|
||||
{
|
||||
new OddsSnapshot(Event, BaseTime, OddsSource.PreMatch,
|
||||
new List<Bet> { new(MatchScope.Instance, BetType.Win, Side.Side1, null, new OddsRate(1.5m)),
|
||||
new(MatchScope.Instance, BetType.Win, Side.Side2, null, new OddsRate(3.0m)) }),
|
||||
new OddsSnapshot(Event, BaseTime.AddSeconds(30), OddsSource.PreMatch,
|
||||
new List<Bet> { new(MatchScope.Instance, BetType.Win, Side.Side1, null, new OddsRate(2.5m)),
|
||||
new(MatchScope.Instance, BetType.Win, Side.Side2, null, new OddsRate(1.6m)) }),
|
||||
new OddsSnapshot(Event, BaseTime.AddSeconds(60), OddsSource.PreMatch,
|
||||
new List<Bet> { new(MatchScope.Instance, BetType.Win, Side.Side1, null, new OddsRate(2.6m)),
|
||||
new(MatchScope.Instance, BetType.Win, Side.Side2, null, new OddsRate(1.55m)) }),
|
||||
};
|
||||
|
||||
CreateSut().Detect(Event, snapshots).Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Should_EmitParseableEvidence_For_DetectedSteamMove()
|
||||
{
|
||||
var snapshots = new[]
|
||||
{
|
||||
Live(0, 1.5m, 3.0m),
|
||||
Live(30, 1.9m, 2.0m),
|
||||
Live(60, 2.5m, 1.6m),
|
||||
};
|
||||
|
||||
var anomaly = CreateSut().Detect(Event, snapshots).First();
|
||||
|
||||
AnomalyEvidenceParser.TryParse(anomaly.EvidenceJson, out var data).Should().BeTrue();
|
||||
data.PreSuspension.Should().NotBeNull();
|
||||
data.PostSuspension.Should().NotBeNull();
|
||||
// Post favourite is the steamed (shortened) side — drives the outcome evaluator.
|
||||
data.PostSuspension.Favourite.Should().Be(Side.Side2);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0)]
|
||||
[InlineData(-30)]
|
||||
public void Should_Throw_When_ConstructedWithInvalidWindow(int windowSeconds)
|
||||
{
|
||||
var act = () => new SteamMoveDetector(windowSeconds, 0.20m, 3, 60);
|
||||
act.Should().Throw<ArgumentOutOfRangeException>();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user