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:
2026-05-28 22:59:12 +03:00
parent 4dae9e8d0d
commit 2b1025cae3
8 changed files with 440 additions and 133 deletions
@@ -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(
_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<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();
}
}
+6
View File
@@ -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>();
}
}