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();
+ }
+}