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