From 2b1025cae3e4ca122c09485c1a701f5770ff8aca Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Thu, 28 May 2026 22:59:12 +0300 Subject: [PATCH] feat(anomaly): IAnomalyDetector seam + steam-move detector MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- .../Configuration/AnomalyOptions.cs | 12 ++ .../UseCases/DetectAnomaliesUseCase.cs | 26 +++- .../AnomalyDetection/AnomalyDetector.cs | 142 ++---------------- .../AnomalyDetection/IAnomalyDetector.cs | 18 +++ .../AnomalyDetection/MatchWinEvidence.cs | 118 +++++++++++++++ .../AnomalyDetection/SteamMoveDetector.cs | 121 +++++++++++++++ src/Marathon.Domain/Enums/AnomalyKind.cs | 6 + .../SteamMoveDetectorTests.cs | 130 ++++++++++++++++ 8 files changed, 440 insertions(+), 133 deletions(-) create mode 100644 src/Marathon.Domain/AnomalyDetection/IAnomalyDetector.cs create mode 100644 src/Marathon.Domain/AnomalyDetection/MatchWinEvidence.cs create mode 100644 src/Marathon.Domain/AnomalyDetection/SteamMoveDetector.cs create mode 100644 tests/Marathon.Domain.Tests/AnomalyDetection/SteamMoveDetectorTests.cs diff --git a/src/Marathon.Application/Configuration/AnomalyOptions.cs b/src/Marathon.Application/Configuration/AnomalyOptions.cs index f964e03..81452c7 100644 --- a/src/Marathon.Application/Configuration/AnomalyOptions.cs +++ b/src/Marathon.Application/Configuration/AnomalyOptions.cs @@ -32,4 +32,16 @@ public sealed class AnomalyOptions /// in seconds. Default: 60 s. /// public int DetectionIntervalSeconds { get; init; } = 60; + + /// + /// Trailing window, in seconds, over which the steam-move detector measures a + /// continuous one-directional probability drift. Default: 120 s. + /// + public int SteamMoveWindowSeconds { get; init; } = 120; + + /// + /// 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). + /// + public decimal SteamMoveDriftThreshold { get; init; } = 0.20m; } diff --git a/src/Marathon.Application/UseCases/DetectAnomaliesUseCase.cs b/src/Marathon.Application/UseCases/DetectAnomaliesUseCase.cs index 28465c1..e558e3f 100644 --- a/src/Marathon.Application/UseCases/DetectAnomaliesUseCase.cs +++ b/src/Marathon.Application/UseCases/DetectAnomaliesUseCase.cs @@ -59,10 +59,18 @@ public sealed class DetectAnomaliesUseCase { _logger.LogInformation("DetectAnomaliesUseCase: cycle started"); - var detector = new AnomalyDetector( - _options.SuspensionGapSeconds, - _options.OddsFlipThreshold, - _options.MinSnapshotCount); + var detectors = new IAnomalyDetector[] + { + new AnomalyDetector( + _options.SuspensionGapSeconds, + _options.OddsFlipThreshold, + _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(); - 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 ProcessEventAsync( - AnomalyDetector detector, + IReadOnlyList detectors, Event ev, IReadOnlyList snapshots, List 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; diff --git a/src/Marathon.Domain/AnomalyDetection/AnomalyDetector.cs b/src/Marathon.Domain/AnomalyDetection/AnomalyDetector.cs index e570584..bc5ba74 100644 --- a/src/Marathon.Domain/AnomalyDetection/AnomalyDetector.cs +++ b/src/Marathon.Domain/AnomalyDetection/AnomalyDetector.cs @@ -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; /// /// /// 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 +/// so every detector kind writes the identical shape. /// -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, - }; - /// /// 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; } - /// - /// Analyses for the given and - /// returns 0 or more anomalies detected in this timeline. - /// - /// The event being analysed. - /// All snapshots for this event (any source, any order). - /// - /// An of records, one per qualifying - /// suspension interval. May be empty. - /// + /// public IReadOnlyList Detect(EventId eventId, IReadOnlyList 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); } diff --git a/src/Marathon.Domain/AnomalyDetection/IAnomalyDetector.cs b/src/Marathon.Domain/AnomalyDetection/IAnomalyDetector.cs new file mode 100644 index 0000000..0eb397c --- /dev/null +++ b/src/Marathon.Domain/AnomalyDetection/IAnomalyDetector.cs @@ -0,0 +1,18 @@ +using Marathon.Domain.Entities; +using Marathon.Domain.ValueObjects; + +namespace Marathon.Domain.AnomalyDetection; + +/// +/// 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. +/// +public interface IAnomalyDetector +{ + /// + /// Analyses for and returns + /// 0 or more anomalies. May be empty; never null. + /// + IReadOnlyList Detect(EventId eventId, IReadOnlyList snapshots); +} diff --git a/src/Marathon.Domain/AnomalyDetection/MatchWinEvidence.cs b/src/Marathon.Domain/AnomalyDetection/MatchWinEvidence.cs new file mode 100644 index 0000000..b86bf25 --- /dev/null +++ b/src/Marathon.Domain/AnomalyDetection/MatchWinEvidence.cs @@ -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; + +/// +/// Shared helper for the match-win implied-probability extraction and the canonical +/// pre/post evidence-JSON shape used by every . +/// +/// +/// Centralising the evidence format here guarantees that all detector kinds write the +/// identical on-disk shape, so the UI parser (AnomalyEvidenceParser) and the +/// outcome evaluator () work for every kind +/// without branching. The suspensionGapSeconds field carries the elapsed +/// seconds between the two snapshots — a suspension gap for flips, a drift window for +/// steam moves. +/// +internal static class MatchWinEvidence +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + WriteIndented = false, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + }; + + /// Normalised match-win implied probabilities + raw rates for a snapshot. + public sealed record Probabilities( + decimal P1, + decimal? PDraw, + decimal P2, + decimal Rate1, + decimal? RateDraw, + decimal Rate2); + + /// + /// Extracts normalised match-win implied probabilities, or null when the snapshot + /// lacks both Side1 and Side2 Match-Win bets. + /// + 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); + } + + /// Label of the side carrying the highest normalised implied probability. + 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"; + } + + /// Serialises the canonical pre/post evidence payload. + 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); +} diff --git a/src/Marathon.Domain/AnomalyDetection/SteamMoveDetector.cs b/src/Marathon.Domain/AnomalyDetection/SteamMoveDetector.cs new file mode 100644 index 0000000..6bfe53b --- /dev/null +++ b/src/Marathon.Domain/AnomalyDetection/SteamMoveDetector.cs @@ -0,0 +1,121 @@ +using Marathon.Domain.Entities; +using Marathon.Domain.Enums; +using Marathon.Domain.ValueObjects; + +namespace Marathon.Domain.AnomalyDetection; + +/// +/// 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. +/// +/// +/// +/// A window is only considered when it contains no suspension-sized gap between +/// consecutive snapshots (controlled by maxStepGapSeconds); drift across a +/// suspension is the 's (SuspensionFlip) territory, so +/// the two detectors never double-flag the same interval. +/// +/// +/// Emits an anomaly whose pre/post evidence +/// brackets the drift, written in the shared shape so +/// the UI and 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. +/// +/// +public sealed class SteamMoveDetector : IAnomalyDetector +{ + private readonly int _windowSeconds; + private readonly decimal _driftThreshold; + private readonly int _minSnapshotCount; + private readonly int _maxStepGapSeconds; + + /// Trailing window (seconds) over which drift is measured. + /// Minimum one-directional implied-probability rise to flag; in (0, 1). + /// Minimum live snapshots before detection runs (>= 2). + /// + /// 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. + /// + 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; + } + + /// + 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++) + { + // 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(); + } +} diff --git a/src/Marathon.Domain/Enums/AnomalyKind.cs b/src/Marathon.Domain/Enums/AnomalyKind.cs index 9cd80f7..228975e 100644 --- a/src/Marathon.Domain/Enums/AnomalyKind.cs +++ b/src/Marathon.Domain/Enums/AnomalyKind.cs @@ -10,4 +10,10 @@ public enum AnomalyKind /// Bookmaker suspended the market, then flipped the underdog/favourite coefficients. /// SuspensionFlip, + + /// + /// A rapid, one-directional drift in a side's implied probability over a short + /// continuous window (no suspension) — money moving the line ("steam"). + /// + SteamMove, } diff --git a/tests/Marathon.Domain.Tests/AnomalyDetection/SteamMoveDetectorTests.cs b/tests/Marathon.Domain.Tests/AnomalyDetection/SteamMoveDetectorTests.cs new file mode 100644 index 0000000..9d586bf --- /dev/null +++ b/tests/Marathon.Domain.Tests/AnomalyDetection/SteamMoveDetectorTests.cs @@ -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 + { + 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 { 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 { 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 { 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(); + } +}