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