diff --git a/src/Marathon.Application/Configuration/AnomalyOptions.cs b/src/Marathon.Application/Configuration/AnomalyOptions.cs index 81452c7..4c39c7b 100644 --- a/src/Marathon.Application/Configuration/AnomalyOptions.cs +++ b/src/Marathon.Application/Configuration/AnomalyOptions.cs @@ -44,4 +44,11 @@ public sealed class AnomalyOptions /// to flag a steam move. Must be in (0, 1). Default: 0.20 (20 percentage points). /// public decimal SteamMoveDriftThreshold { get; init; } = 0.20m; + + /// + /// Maximum normalised implied-probability change across a suspension for it to count + /// as a "freeze" (line resumed essentially unchanged). Must be in (0, 1). + /// Default: 0.05 (5 percentage points). + /// + public decimal SuspensionFreezeThreshold { get; init; } = 0.05m; } diff --git a/src/Marathon.Application/UseCases/DetectAnomaliesUseCase.cs b/src/Marathon.Application/UseCases/DetectAnomaliesUseCase.cs index e558e3f..6450eff 100644 --- a/src/Marathon.Application/UseCases/DetectAnomaliesUseCase.cs +++ b/src/Marathon.Application/UseCases/DetectAnomaliesUseCase.cs @@ -70,6 +70,10 @@ public sealed class DetectAnomaliesUseCase _options.SteamMoveDriftThreshold, _options.MinSnapshotCount, _options.SuspensionGapSeconds), + new SuspensionFreezeDetector( + _options.SuspensionGapSeconds, + _options.SuspensionFreezeThreshold, + _options.MinSnapshotCount), }; var events = await _eventRepo.ListAsync(ct); diff --git a/src/Marathon.Domain/AnomalyDetection/SuspensionFreezeDetector.cs b/src/Marathon.Domain/AnomalyDetection/SuspensionFreezeDetector.cs new file mode 100644 index 0000000..c2924cb --- /dev/null +++ b/src/Marathon.Domain/AnomalyDetection/SuspensionFreezeDetector.cs @@ -0,0 +1,107 @@ +using Marathon.Domain.Entities; +using Marathon.Domain.Enums; +using Marathon.Domain.ValueObjects; + +namespace Marathon.Domain.AnomalyDetection; + +/// +/// Detects a "suspension freeze": the market was suspended (a gap larger than +/// suspensionGapSeconds between adjacent live snapshots) but resumed with +/// essentially the same line — the favourite is unchanged and the largest normalised +/// implied-probability move is below freezeThreshold. +/// +/// +/// +/// This is the mirror image of (SuspensionFlip): the flip +/// fires on a large favourite-changing move across a suspension; the freeze fires when +/// the bookmaker paused but did not move — a tell that they were uncertain or +/// gathering information rather than repricing. +/// +/// +/// Score = how completely the line froze: 1 − (maxMove / freezeThreshold), so a +/// perfectly unchanged line scores ~1.0 and one near the threshold scores near 0. The +/// shared shape (pre ≈ post) conveys the freeze directly, +/// and the outcome evaluator grades the unchanged favourite like any other anomaly. +/// +/// +public sealed class SuspensionFreezeDetector : IAnomalyDetector +{ + private readonly int _suspensionGapSeconds; + private readonly decimal _freezeThreshold; + private readonly int _minSnapshotCount; + + /// Minimum adjacent-snapshot gap (seconds) classed as a suspension. + /// Maximum normalised probability move to count as frozen; in (0, 1). + /// Minimum live snapshots before detection runs (>= 2). + public SuspensionFreezeDetector(int suspensionGapSeconds, decimal freezeThreshold, int minSnapshotCount) + { + if (suspensionGapSeconds <= 0) + throw new ArgumentOutOfRangeException(nameof(suspensionGapSeconds), suspensionGapSeconds, "Must be positive."); + if (freezeThreshold is <= 0m or >= 1m) + throw new ArgumentOutOfRangeException(nameof(freezeThreshold), freezeThreshold, "Must be in (0, 1)."); + if (minSnapshotCount < 2) + throw new ArgumentOutOfRangeException(nameof(minSnapshotCount), minSnapshotCount, "Must be at least 2."); + + _suspensionGapSeconds = suspensionGapSeconds; + _freezeThreshold = freezeThreshold; + _minSnapshotCount = minSnapshotCount; + } + + /// + 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 suspensionGap = TimeSpan.FromSeconds(_suspensionGapSeconds); + var anomalies = new List(); + + for (int i = 0; i < live.Count - 1; i++) + { + var pre = live[i]; + var post = live[i + 1]; + if (post.CapturedAt - pre.CapturedAt <= suspensionGap) + continue; + + var preProbs = MatchWinEvidence.Extract(pre); + var postProbs = MatchWinEvidence.Extract(post); + if (preProbs is null || postProbs is null) + continue; + + decimal maxMove = Math.Max( + Math.Abs(postProbs.P1 - preProbs.P1), + Math.Abs(postProbs.P2 - preProbs.P2)); + if (preProbs.PDraw.HasValue && postProbs.PDraw.HasValue) + maxMove = Math.Max(maxMove, Math.Abs(postProbs.PDraw.Value - preProbs.PDraw.Value)); + + var favouriteUnchanged = + MatchWinEvidence.Favourite(preProbs) == MatchWinEvidence.Favourite(postProbs); + + // Strictly below the threshold so the score stays in (0, 1]. + if (!favouriteUnchanged || maxMove >= _freezeThreshold) + continue; + + var score = 1m - (maxMove / _freezeThreshold); + var gapSeconds = (int)(post.CapturedAt - pre.CapturedAt).TotalSeconds; + var evidenceJson = MatchWinEvidence.BuildJson(gapSeconds, pre, preProbs, post, postProbs); + + anomalies.Add(new Anomaly( + Id: Guid.NewGuid(), + EventId: eventId, + DetectedAt: MoscowTime.Now, + Kind: AnomalyKind.SuspensionFreeze, + Score: score, + EvidenceJson: evidenceJson)); + } + + return anomalies.AsReadOnly(); + } +} diff --git a/src/Marathon.Domain/Enums/AnomalyKind.cs b/src/Marathon.Domain/Enums/AnomalyKind.cs index 228975e..e2ab0d3 100644 --- a/src/Marathon.Domain/Enums/AnomalyKind.cs +++ b/src/Marathon.Domain/Enums/AnomalyKind.cs @@ -16,4 +16,10 @@ public enum AnomalyKind /// continuous window (no suspension) — money moving the line ("steam"). /// SteamMove, + + /// + /// The bookmaker suspended the market but resumed with essentially the same line + /// (favourite unchanged, negligible price move) — a freeze signalling uncertainty. + /// + SuspensionFreeze, } diff --git a/src/Marathon.Hosts.WpfBlazor/appsettings.json b/src/Marathon.Hosts.WpfBlazor/appsettings.json index ee5ae74..2f67d90 100644 --- a/src/Marathon.Hosts.WpfBlazor/appsettings.json +++ b/src/Marathon.Hosts.WpfBlazor/appsettings.json @@ -43,7 +43,8 @@ "MinSnapshotCount": 3, "DetectionIntervalSeconds": 60, "SteamMoveWindowSeconds": 120, - "SteamMoveDriftThreshold": 0.20 + "SteamMoveDriftThreshold": 0.20, + "SuspensionFreezeThreshold": 0.05 }, "Notifications": { "Enabled": false, diff --git a/src/Marathon.UI/Components/AnomalyCard.razor b/src/Marathon.UI/Components/AnomalyCard.razor index 81a6128..d71db86 100644 --- a/src/Marathon.UI/Components/AnomalyCard.razor +++ b/src/Marathon.UI/Components/AnomalyCard.razor @@ -211,9 +211,10 @@ private string KindLabel(AnomalyKind kind) => kind switch { - AnomalyKind.SuspensionFlip => L["Anomaly.Kind.SuspensionFlip"], - AnomalyKind.SteamMove => L["Anomaly.Kind.SteamMove"], - _ => kind.ToString(), + AnomalyKind.SuspensionFlip => L["Anomaly.Kind.SuspensionFlip"], + AnomalyKind.SteamMove => L["Anomaly.Kind.SteamMove"], + AnomalyKind.SuspensionFreeze => L["Anomaly.Kind.SuspensionFreeze"], + _ => kind.ToString(), }; private string SportLabel(int code) => SportLabels.Resolve(L, code); diff --git a/src/Marathon.UI/Pages/Anomalies/Detail.razor b/src/Marathon.UI/Pages/Anomalies/Detail.razor index 1597bb0..4321c49 100644 --- a/src/Marathon.UI/Pages/Anomalies/Detail.razor +++ b/src/Marathon.UI/Pages/Anomalies/Detail.razor @@ -105,9 +105,10 @@ private string KindLabel(AnomalyKind kind) => kind switch { - AnomalyKind.SuspensionFlip => L["Anomaly.Kind.SuspensionFlip"], - AnomalyKind.SteamMove => L["Anomaly.Kind.SteamMove"], - _ => kind.ToString(), + AnomalyKind.SuspensionFlip => L["Anomaly.Kind.SuspensionFlip"], + AnomalyKind.SteamMove => L["Anomaly.Kind.SteamMove"], + AnomalyKind.SuspensionFreeze => L["Anomaly.Kind.SuspensionFreeze"], + _ => kind.ToString(), }; private static string FormatGap(int seconds) diff --git a/src/Marathon.UI/Resources/SharedResource.en.resx b/src/Marathon.UI/Resources/SharedResource.en.resx index 217f345..7e335f4 100644 --- a/src/Marathon.UI/Resources/SharedResource.en.resx +++ b/src/Marathon.UI/Resources/SharedResource.en.resx @@ -167,6 +167,7 @@ Anomaly Suspension flip Steam move + Suspension freeze Confidence diff --git a/src/Marathon.UI/Resources/SharedResource.ru.resx b/src/Marathon.UI/Resources/SharedResource.ru.resx index ff46fe1..d49dfb0 100644 --- a/src/Marathon.UI/Resources/SharedResource.ru.resx +++ b/src/Marathon.UI/Resources/SharedResource.ru.resx @@ -180,6 +180,7 @@ Аномалия Разворот после заморозки Движение линии + Заморозка линии Уверенность diff --git a/tests/Marathon.Domain.Tests/AnomalyDetection/SuspensionFreezeDetectorTests.cs b/tests/Marathon.Domain.Tests/AnomalyDetection/SuspensionFreezeDetectorTests.cs new file mode 100644 index 0000000..13aed71 --- /dev/null +++ b/tests/Marathon.Domain.Tests/AnomalyDetection/SuspensionFreezeDetectorTests.cs @@ -0,0 +1,104 @@ +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 SuspensionFreezeDetectorTests +{ + 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("26000002"); + + // suspension gap 60s, freeze threshold 0.05, min 3 snapshots. + private static SuspensionFreezeDetector CreateSut() => new(60, 0.05m, 3); + + 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_FlagFreeze_When_SuspensionResumesUnchanged() + { + // 90s gap between the 2nd and 3rd snapshot; line resumes identical. + var snapshots = new[] + { + Live(0, 1.5m, 3.0m), + Live(30, 1.5m, 3.0m), + Live(120, 1.5m, 3.0m), // 90s gap, unchanged + Live(150, 1.5m, 3.0m), + }; + + var result = CreateSut().Detect(Event, snapshots); + + result.Should().ContainSingle(); + result[0].Kind.Should().Be(AnomalyKind.SuspensionFreeze); + result[0].Score.Should().Be(1.0m, "an unchanged line is a complete freeze"); + } + + [Fact] + public void Should_NotFlag_When_FlipAcrossSuspension() + { + // Favourite changes across the gap — that is the SuspensionFlip detector's job. + var snapshots = new[] + { + Live(0, 1.3m, 4.0m), + Live(30, 1.3m, 4.0m), + Live(120, 4.0m, 1.3m), // 90s gap, flipped + Live(150, 4.0m, 1.3m), + }; + + CreateSut().Detect(Event, snapshots).Should().BeEmpty(); + } + + [Fact] + public void Should_NotFlag_When_NoSuspensionGap() + { + // Continuous 30s steps — no gap exceeds the 60s suspension threshold. + var snapshots = new[] + { + Live(0, 1.5m, 3.0m), + Live(30, 1.5m, 3.0m), + Live(60, 1.5m, 3.0m), + }; + + CreateSut().Detect(Event, snapshots).Should().BeEmpty(); + } + + [Fact] + public void Should_ReturnEmpty_When_FewerThanMinSnapshots() + { + var snapshots = new[] { Live(0, 1.5m, 3.0m), Live(120, 1.5m, 3.0m) }; + + CreateSut().Detect(Event, snapshots).Should().BeEmpty(); + } + + [Fact] + public void Should_EmitParseableEvidence_For_DetectedFreeze() + { + var snapshots = new[] + { + Live(0, 1.5m, 3.0m), + Live(30, 1.5m, 3.0m), + Live(120, 1.5m, 3.0m), + }; + + var anomaly = CreateSut().Detect(Event, snapshots).First(); + + AnomalyEvidenceParser.TryParse(anomaly.EvidenceJson, out var data).Should().BeTrue(); + data.PreSuspension.Favourite.Should().Be(data.PostSuspension.Favourite, "the favourite is unchanged in a freeze"); + } + + [Fact] + public void Should_Throw_When_FreezeThresholdOutOfRange() + { + var act = () => new SuspensionFreezeDetector(60, 0m, 3); + act.Should().Throw(); + } +}