using System.Text.Json; using FluentAssertions; using Marathon.Domain.AnomalyDetection; using Marathon.Domain.Entities; using Marathon.Domain.Enums; using Marathon.Domain.ValueObjects; namespace Marathon.Domain.Tests.AnomalyDetection; /// /// Unit tests for . /// All tests use synthetic snapshot timelines to verify the detection algorithm /// without any I/O or database dependencies. /// public sealed class AnomalyDetectorTests { // Default thresholds matching appsettings.json defaults. private const int DefaultGapSeconds = 60; private const decimal DefaultThreshold = 0.30m; private const int DefaultMinSnapshots = 3; private static readonly EventId TestEventId = new("99999999"); private static readonly TimeSpan MoscowOffset = TimeSpan.FromHours(3); private static readonly DateTimeOffset BaseTime = new(2026, 5, 10, 18, 0, 0, MoscowOffset); private static AnomalyDetector DefaultDetector() => new(DefaultGapSeconds, DefaultThreshold, DefaultMinSnapshots); // ── Helper factory methods ──────────────────────────────────────────────── /// Creates a live OddsSnapshot with Match Win bets for both sides. private static OddsSnapshot MakeLiveSnapshot( DateTimeOffset capturedAt, decimal rate1, decimal rate2, decimal? rateDraw = null) { var bets = new List { new(MatchScope.Instance, BetType.Win, Side.Side1, null, new OddsRate(rate1)), new(MatchScope.Instance, BetType.Win, Side.Side2, null, new OddsRate(rate2)), }; if (rateDraw.HasValue) bets.Add(new Bet(MatchScope.Instance, BetType.Draw, Side.Draw, null, new OddsRate(rateDraw.Value))); return new OddsSnapshot(TestEventId, capturedAt, OddsSource.Live, bets); } /// Creates a pre-match OddsSnapshot (should be ignored by detector). private static OddsSnapshot MakePreMatchSnapshot(DateTimeOffset capturedAt) => new(TestEventId, capturedAt, 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(2.5m)), }); // ── Test: empty snapshot list → 0 anomalies ────────────────────────────── [Fact] public void Should_ReturnEmpty_When_SnapshotListIsEmpty() { var sut = DefaultDetector(); var result = sut.Detect(TestEventId, Array.Empty()); result.Should().BeEmpty(); } // ── Test: below minSnapshotCount → 0 anomalies ─────────────────────────── [Fact] public void Should_ReturnEmpty_When_FewerThanMinSnapshotCountLiveSnapshots() { // Only 2 live snapshots; minSnapshotCount = 3. var snapshots = new[] { MakeLiveSnapshot(BaseTime, rate1: 1.5m, rate2: 2.5m), MakeLiveSnapshot(BaseTime.AddSeconds(30), rate1: 1.55m, rate2: 2.45m), }; var sut = DefaultDetector(); var result = sut.Detect(TestEventId, snapshots); result.Should().BeEmpty("fewer than minSnapshotCount=3 live snapshots"); } // ── Test: pre-match-only snapshots → 0 anomalies ───────────────────────── [Fact] public void Should_ReturnEmpty_When_AllSnapshotsArePreMatch() { // 5 pre-match snapshots — should all be ignored. var snapshots = Enumerable.Range(0, 5) .Select(i => MakePreMatchSnapshot(BaseTime.AddSeconds(i * 30))) .ToArray(); var sut = DefaultDetector(); var result = sut.Detect(TestEventId, snapshots); result.Should().BeEmpty("only pre-match snapshots — live filter returns 0"); } // ── Test: no suspension (regular intervals) → 0 anomalies ──────────────── [Fact] public void Should_ReturnEmpty_When_NoSuspensionGapExists() { // 5 snapshots spaced 30 s apart — well below the 60 s gap threshold. var snapshots = Enumerable.Range(0, 5) .Select(i => MakeLiveSnapshot( BaseTime.AddSeconds(i * 30), rate1: 1.5m, rate2: 2.5m)) .ToArray(); var sut = DefaultDetector(); var result = sut.Detect(TestEventId, snapshots); result.Should().BeEmpty("no gap exceeds suspensionGapSeconds=60"); } // ── Test: suspension but odds shift below threshold → 0 anomalies ───────── [Fact] public void Should_ReturnEmpty_When_SuspensionButOddsShiftBelowThreshold() { // Pre-suspension: Side1 slightly favoured. // Post-suspension: Side1 still favoured, tiny shift well below 0.30 threshold. var snapshots = new[] { MakeLiveSnapshot(BaseTime, rate1: 1.5m, rate2: 2.5m), // pre MakeLiveSnapshot(BaseTime.AddSeconds(30), rate1: 1.52m, rate2: 2.48m), // pre (within normal gap) // 90 s gap = suspension MakeLiveSnapshot(BaseTime.AddSeconds(120), rate1: 1.55m, rate2: 2.45m), // post — small shift MakeLiveSnapshot(BaseTime.AddSeconds(150), rate1: 1.56m, rate2: 2.44m), }; var sut = DefaultDetector(); var result = sut.Detect(TestEventId, snapshots); result.Should().BeEmpty("odds shift is far below 0.30 threshold"); } // ── Test: suspension + favourite flipped → 1 anomaly ───────────────────── [Fact] public void Should_DetectOneAnomaly_When_SuspensionWithFavouriteFlip() { // Pre-suspension: Side1 favourite — rate1=1.3, rate2=4.0 // rawP1=1/1.3≈0.769, rawP2=1/4.0=0.25, total≈1.019 // p1_pre≈0.755, p2_pre≈0.245 → favourite = Side1 // // Post-suspension: Side2 favourite — rate1=4.0, rate2=1.3 // rawP1=0.25, rawP2≈0.769, total≈1.019 // p1_post≈0.245, p2_post≈0.755 → favourite = Side2 (changed!) // // flipScore = max(|0.245-0.755|, |0.755-0.245|) ≈ 0.510 ≥ 0.30 ✓ var snapshots = new[] { MakeLiveSnapshot(BaseTime, rate1: 1.3m, rate2: 4.0m), MakeLiveSnapshot(BaseTime.AddSeconds(30), rate1: 1.3m, rate2: 4.0m), // 90 s gap = suspension (> 60 s threshold) MakeLiveSnapshot(BaseTime.AddSeconds(120), rate1: 4.0m, rate2: 1.3m), // flipped MakeLiveSnapshot(BaseTime.AddSeconds(150), rate1: 4.0m, rate2: 1.3m), }; var sut = DefaultDetector(); var result = sut.Detect(TestEventId, snapshots); result.Should().HaveCount(1); result[0].EventId.Should().Be(TestEventId); result[0].Kind.Should().Be(AnomalyKind.SuspensionFlip); result[0].Score.Should().BeGreaterThanOrEqualTo(0.30m); result[0].Score.Should().BeLessThanOrEqualTo(1.0m); } // ── Test: score calculation is accurate ────────────────────────────────── [Fact] public void Should_ComputeCorrectFlipScore_ForKnownInputs() { // Pre: rate1=1.5, rate2=2.5 // rawP1 = 1/1.5 ≈ 0.6667, rawP2 = 1/2.5 = 0.4, total ≈ 1.0667 // p1_pre ≈ 0.6667/1.0667 ≈ 0.625, p2_pre ≈ 0.4/1.0667 ≈ 0.375 // // Post: rate1=2.5, rate2=1.5 // rawP1 = 0.4, rawP2 ≈ 0.6667, total ≈ 1.0667 // p1_post ≈ 0.375, p2_post ≈ 0.625 // // flipScore = max(|0.375-0.625|, |0.625-0.375|) = 0.25 → but wait, with // the normalised values both deltas equal |0.625-0.375|=0.25. Hmm that's < 0.30. // // Use steeper rates to guarantee > 0.30: // Pre: rate1=1.3, rate2=4.0 → rawP1=0.769, rawP2=0.25, total=1.019 → p1≈0.755, p2≈0.245 // Post: rate1=4.0, rate2=1.3 → rawP1=0.25, rawP2=0.769, total=1.019 → p1≈0.245, p2≈0.755 // flipScore = max(|0.245-0.755|, |0.755-0.245|) = 0.510 var snapshots = new[] { MakeLiveSnapshot(BaseTime, rate1: 1.3m, rate2: 4.0m), MakeLiveSnapshot(BaseTime.AddSeconds(30), rate1: 1.3m, rate2: 4.0m), // suspension gap MakeLiveSnapshot(BaseTime.AddSeconds(120), rate1: 4.0m, rate2: 1.3m), MakeLiveSnapshot(BaseTime.AddSeconds(150), rate1: 4.0m, rate2: 1.3m), }; var sut = DefaultDetector(); var result = sut.Detect(TestEventId, snapshots); result.Should().HaveCount(1); // Expected flip score ≈ 0.510 — verify within a reasonable tolerance. result[0].Score.Should().BeGreaterThan(0.45m, "steeper rates produce a large flip"); result[0].Score.Should().BeLessThanOrEqualTo(1.0m); } // ── Test: tennis (no draw) → works correctly ────────────────────────────── [Fact] public void Should_DetectAnomaly_When_TwoWayMarketWithFavouriteFlip() { // Tennis-style: no draw market. Rates flip from 1.3/4.0 to 4.0/1.3. var snapshots = new[] { MakeLiveSnapshot(BaseTime, rate1: 1.3m, rate2: 4.0m, rateDraw: null), MakeLiveSnapshot(BaseTime.AddSeconds(30), rate1: 1.3m, rate2: 4.0m, rateDraw: null), // suspension gap MakeLiveSnapshot(BaseTime.AddSeconds(120), rate1: 4.0m, rate2: 1.3m, rateDraw: null), MakeLiveSnapshot(BaseTime.AddSeconds(150), rate1: 4.0m, rate2: 1.3m, rateDraw: null), }; var sut = DefaultDetector(); var result = sut.Detect(TestEventId, snapshots); result.Should().HaveCount(1, "2-way market flip should be detected"); result[0].Score.Should().BeGreaterThan(0.45m); } // ── Test: multiple suspensions → multiple anomalies ─────────────────────── [Fact] public void Should_DetectMultipleAnomalies_When_MultipleSuspensionsOccur() { // Two separate suspension intervals, each with a clear flip. var snapshots = new[] { MakeLiveSnapshot(BaseTime, rate1: 1.3m, rate2: 4.0m), // period A start MakeLiveSnapshot(BaseTime.AddSeconds(30), rate1: 1.3m, rate2: 4.0m), // Suspension 1 MakeLiveSnapshot(BaseTime.AddSeconds(120), rate1: 4.0m, rate2: 1.3m), // flipped MakeLiveSnapshot(BaseTime.AddSeconds(150), rate1: 4.0m, rate2: 1.3m), // Suspension 2 MakeLiveSnapshot(BaseTime.AddSeconds(240), rate1: 1.3m, rate2: 4.0m), // flipped back MakeLiveSnapshot(BaseTime.AddSeconds(270), rate1: 1.3m, rate2: 4.0m), }; var sut = DefaultDetector(); var result = sut.Detect(TestEventId, snapshots); result.Should().HaveCount(2, "two qualifying suspension intervals produce two anomalies"); } // ── Test: EvidenceJson contains expected fields ─────────────────────────── [Fact] public void Should_IncludeEvidenceJson_WithProbabilityVectorsAndRates() { var snapshots = new[] { MakeLiveSnapshot(BaseTime, rate1: 1.3m, rate2: 4.0m), MakeLiveSnapshot(BaseTime.AddSeconds(30), rate1: 1.3m, rate2: 4.0m), // suspension gap MakeLiveSnapshot(BaseTime.AddSeconds(120), rate1: 4.0m, rate2: 1.3m), MakeLiveSnapshot(BaseTime.AddSeconds(150), rate1: 4.0m, rate2: 1.3m), }; var sut = DefaultDetector(); var result = sut.Detect(TestEventId, snapshots); result.Should().HaveCount(1); var evidenceJson = result[0].EvidenceJson; evidenceJson.Should().NotBeNullOrWhiteSpace(); // Parse and verify key fields. using var doc = JsonDocument.Parse(evidenceJson); var root = doc.RootElement; root.TryGetProperty("suspensionGapSeconds", out _).Should().BeTrue("gap seconds required"); root.TryGetProperty("preSuspension", out var pre).Should().BeTrue(); root.TryGetProperty("postSuspension", out var post).Should().BeTrue(); pre.TryGetProperty("capturedAt", out _).Should().BeTrue(); pre.TryGetProperty("p1", out _).Should().BeTrue(); pre.TryGetProperty("p2", out _).Should().BeTrue(); pre.TryGetProperty("rate1", out _).Should().BeTrue(); pre.TryGetProperty("rate2", out _).Should().BeTrue(); post.TryGetProperty("p1", out _).Should().BeTrue(); post.TryGetProperty("p2", out _).Should().BeTrue(); post.TryGetProperty("rate1", out _).Should().BeTrue(); post.TryGetProperty("rate2", out _).Should().BeTrue(); } // ── Test: determinism — same input produces same output ─────────────────── [Fact] public void Should_BeDeterministic_SameInputProducesSameOutput() { var snapshots = new[] { MakeLiveSnapshot(BaseTime, rate1: 1.3m, rate2: 4.0m), MakeLiveSnapshot(BaseTime.AddSeconds(30), rate1: 1.3m, rate2: 4.0m), MakeLiveSnapshot(BaseTime.AddSeconds(120), rate1: 4.0m, rate2: 1.3m), MakeLiveSnapshot(BaseTime.AddSeconds(150), rate1: 4.0m, rate2: 1.3m), }; var sut = DefaultDetector(); var result1 = sut.Detect(TestEventId, snapshots); var result2 = sut.Detect(TestEventId, snapshots); result1.Should().HaveCount(result2.Count); result1[0].Score.Should().Be(result2[0].Score); result1[0].Kind.Should().Be(result2[0].Kind); result1[0].EventId.Should().Be(result2[0].EventId); } // ── Test: three-way market (with draw) — flip when draw becomes favourite ─ [Fact] public void Should_DetectAnomaly_When_FavouriteChangesFromSide1ToDraw() { // Pre: Side1 is slight favourite with rate 1.6 (draw 3.5, side2 4.0). // Post: Draw becomes favourite with rate 2.2 (side1 2.8, side2 3.5). // This represents an unusual suspension flip where the draw becomes favourite. // We need enough of a flip in p1 vs pDraw. // Pre: raw p1=1/1.6=0.625, pDraw=1/3.5=0.286, p2=1/4.0=0.25, total=1.161 // norm: p1=0.539, pDraw=0.246, p2=0.215 → favourite=Side1 // Post: raw p1=1/1.3=0.769, pDraw=1/2.0=0.5, p2=1/6.0=0.167, total=1.436 // norm: p1=0.535, pDraw=0.348, p2=0.116 → favourite still Side1 // Need to make draw the favourite: // Post: raw p1=1/4.0=0.25, pDraw=1/1.5=0.667, p2=1/6.0=0.167, total=1.083 // norm: p1=0.231, pDraw=0.616, p2=0.154 → favourite=Draw // flipScore = max(|0.231-0.539|, |0.616-0.246|, |0.154-0.215|) // = max(0.308, 0.370, 0.061) = 0.370 ≥ 0.30 ✓ AND favourite changed ✓ var snapshots = new[] { MakeLiveSnapshot(BaseTime, rate1: 1.6m, rate2: 4.0m, rateDraw: 3.5m), MakeLiveSnapshot(BaseTime.AddSeconds(30), rate1: 1.6m, rate2: 4.0m, rateDraw: 3.5m), // suspension gap MakeLiveSnapshot(BaseTime.AddSeconds(120), rate1: 4.0m, rate2: 6.0m, rateDraw: 1.5m), MakeLiveSnapshot(BaseTime.AddSeconds(150), rate1: 4.0m, rate2: 6.0m, rateDraw: 1.5m), }; var sut = DefaultDetector(); var result = sut.Detect(TestEventId, snapshots); result.Should().HaveCount(1, "favourite changed from Side1 to Draw → qualifies"); result[0].Score.Should().BeGreaterThanOrEqualTo(0.30m); // Verify that pDraw is present in the evidence JSON. using var doc = JsonDocument.Parse(result[0].EvidenceJson); var root = doc.RootElement; root.GetProperty("preSuspension").TryGetProperty("pDraw", out _).Should().BeTrue(); root.GetProperty("postSuspension").TryGetProperty("pDraw", out _).Should().BeTrue(); } // ── Test: mixed pre-match and live snapshots — only live are analysed ───── [Fact] public void Should_IgnorePreMatchSnapshots_When_MixedSourcesProvided() { // 3 pre-match snapshots (should be ignored) + 2 live (below minSnapshotCount=3). var snapshots = new[] { MakePreMatchSnapshot(BaseTime), MakePreMatchSnapshot(BaseTime.AddSeconds(30)), MakePreMatchSnapshot(BaseTime.AddSeconds(60)), MakeLiveSnapshot(BaseTime.AddSeconds(90), rate1: 1.3m, rate2: 4.0m), MakeLiveSnapshot(BaseTime.AddSeconds(120), rate1: 4.0m, rate2: 1.3m), // would be a flip if 3+ snapshots }; var sut = DefaultDetector(); var result = sut.Detect(TestEventId, snapshots); result.Should().BeEmpty("only 2 live snapshots after filtering — below minSnapshotCount=3"); } }