feat(anomaly): suspension-freeze detector
- Add SuspensionFreezeDetector via the IAnomalyDetector seam: a suspension gap with the favourite unchanged and a negligible (< threshold) price move — the mirror of the flip. Score = how completely the line froze. Reuses MatchWinEvidence so UI + evaluator handle it unchanged. 6 tests. - Add AnomalyKind.SuspensionFreeze + localized card/detail label, SuspensionFreezeThreshold option, and fan it into DetectAnomaliesUseCase.
This commit is contained in:
@@ -44,4 +44,11 @@ public sealed class AnomalyOptions
|
|||||||
/// to flag a steam move. Must be in (0, 1). Default: 0.20 (20 percentage points).
|
/// to flag a steam move. Must be in (0, 1). Default: 0.20 (20 percentage points).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public decimal SteamMoveDriftThreshold { get; init; } = 0.20m;
|
public decimal SteamMoveDriftThreshold { get; init; } = 0.20m;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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).
|
||||||
|
/// </summary>
|
||||||
|
public decimal SuspensionFreezeThreshold { get; init; } = 0.05m;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,6 +70,10 @@ public sealed class DetectAnomaliesUseCase
|
|||||||
_options.SteamMoveDriftThreshold,
|
_options.SteamMoveDriftThreshold,
|
||||||
_options.MinSnapshotCount,
|
_options.MinSnapshotCount,
|
||||||
_options.SuspensionGapSeconds),
|
_options.SuspensionGapSeconds),
|
||||||
|
new SuspensionFreezeDetector(
|
||||||
|
_options.SuspensionGapSeconds,
|
||||||
|
_options.SuspensionFreezeThreshold,
|
||||||
|
_options.MinSnapshotCount),
|
||||||
};
|
};
|
||||||
|
|
||||||
var events = await _eventRepo.ListAsync(ct);
|
var events = await _eventRepo.ListAsync(ct);
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
using Marathon.Domain.Entities;
|
||||||
|
using Marathon.Domain.Enums;
|
||||||
|
using Marathon.Domain.ValueObjects;
|
||||||
|
|
||||||
|
namespace Marathon.Domain.AnomalyDetection;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Detects a "suspension freeze": the market was suspended (a gap larger than
|
||||||
|
/// <c>suspensionGapSeconds</c> between adjacent live snapshots) but resumed with
|
||||||
|
/// essentially the same line — the favourite is unchanged and the largest normalised
|
||||||
|
/// implied-probability move is below <c>freezeThreshold</c>.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// This is the mirror image of <see cref="AnomalyDetector"/> (SuspensionFlip): the flip
|
||||||
|
/// fires on a large favourite-changing move across a suspension; the freeze fires when
|
||||||
|
/// the bookmaker paused but did <i>not</i> move — a tell that they were uncertain or
|
||||||
|
/// gathering information rather than repricing.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Score = how completely the line froze: <c>1 − (maxMove / freezeThreshold)</c>, so a
|
||||||
|
/// perfectly unchanged line scores ~1.0 and one near the threshold scores near 0. The
|
||||||
|
/// shared <see cref="MatchWinEvidence"/> shape (pre ≈ post) conveys the freeze directly,
|
||||||
|
/// and the outcome evaluator grades the unchanged favourite like any other anomaly.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class SuspensionFreezeDetector : IAnomalyDetector
|
||||||
|
{
|
||||||
|
private readonly int _suspensionGapSeconds;
|
||||||
|
private readonly decimal _freezeThreshold;
|
||||||
|
private readonly int _minSnapshotCount;
|
||||||
|
|
||||||
|
/// <param name="suspensionGapSeconds">Minimum adjacent-snapshot gap (seconds) classed as a suspension.</param>
|
||||||
|
/// <param name="freezeThreshold">Maximum normalised probability move to count as frozen; in (0, 1).</param>
|
||||||
|
/// <param name="minSnapshotCount">Minimum live snapshots before detection runs (>= 2).</param>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <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 suspensionGap = TimeSpan.FromSeconds(_suspensionGapSeconds);
|
||||||
|
var anomalies = new List<Anomaly>();
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,4 +16,10 @@ public enum AnomalyKind
|
|||||||
/// continuous window (no suspension) — money moving the line ("steam").
|
/// continuous window (no suspension) — money moving the line ("steam").
|
||||||
/// </summary>
|
/// </summary>
|
||||||
SteamMove,
|
SteamMove,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The bookmaker suspended the market but resumed with essentially the same line
|
||||||
|
/// (favourite unchanged, negligible price move) — a freeze signalling uncertainty.
|
||||||
|
/// </summary>
|
||||||
|
SuspensionFreeze,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,7 +43,8 @@
|
|||||||
"MinSnapshotCount": 3,
|
"MinSnapshotCount": 3,
|
||||||
"DetectionIntervalSeconds": 60,
|
"DetectionIntervalSeconds": 60,
|
||||||
"SteamMoveWindowSeconds": 120,
|
"SteamMoveWindowSeconds": 120,
|
||||||
"SteamMoveDriftThreshold": 0.20
|
"SteamMoveDriftThreshold": 0.20,
|
||||||
|
"SuspensionFreezeThreshold": 0.05
|
||||||
},
|
},
|
||||||
"Notifications": {
|
"Notifications": {
|
||||||
"Enabled": false,
|
"Enabled": false,
|
||||||
|
|||||||
@@ -211,9 +211,10 @@
|
|||||||
|
|
||||||
private string KindLabel(AnomalyKind kind) => kind switch
|
private string KindLabel(AnomalyKind kind) => kind switch
|
||||||
{
|
{
|
||||||
AnomalyKind.SuspensionFlip => L["Anomaly.Kind.SuspensionFlip"],
|
AnomalyKind.SuspensionFlip => L["Anomaly.Kind.SuspensionFlip"],
|
||||||
AnomalyKind.SteamMove => L["Anomaly.Kind.SteamMove"],
|
AnomalyKind.SteamMove => L["Anomaly.Kind.SteamMove"],
|
||||||
_ => kind.ToString(),
|
AnomalyKind.SuspensionFreeze => L["Anomaly.Kind.SuspensionFreeze"],
|
||||||
|
_ => kind.ToString(),
|
||||||
};
|
};
|
||||||
|
|
||||||
private string SportLabel(int code) => SportLabels.Resolve(L, code);
|
private string SportLabel(int code) => SportLabels.Resolve(L, code);
|
||||||
|
|||||||
@@ -105,9 +105,10 @@
|
|||||||
|
|
||||||
private string KindLabel(AnomalyKind kind) => kind switch
|
private string KindLabel(AnomalyKind kind) => kind switch
|
||||||
{
|
{
|
||||||
AnomalyKind.SuspensionFlip => L["Anomaly.Kind.SuspensionFlip"],
|
AnomalyKind.SuspensionFlip => L["Anomaly.Kind.SuspensionFlip"],
|
||||||
AnomalyKind.SteamMove => L["Anomaly.Kind.SteamMove"],
|
AnomalyKind.SteamMove => L["Anomaly.Kind.SteamMove"],
|
||||||
_ => kind.ToString(),
|
AnomalyKind.SuspensionFreeze => L["Anomaly.Kind.SuspensionFreeze"],
|
||||||
|
_ => kind.ToString(),
|
||||||
};
|
};
|
||||||
|
|
||||||
private static string FormatGap(int seconds)
|
private static string FormatGap(int seconds)
|
||||||
|
|||||||
@@ -167,6 +167,7 @@
|
|||||||
<data name="Anomaly.Live"><value>Anomaly</value></data>
|
<data name="Anomaly.Live"><value>Anomaly</value></data>
|
||||||
<data name="Anomaly.Kind.SuspensionFlip"><value>Suspension flip</value></data>
|
<data name="Anomaly.Kind.SuspensionFlip"><value>Suspension flip</value></data>
|
||||||
<data name="Anomaly.Kind.SteamMove"><value>Steam move</value></data>
|
<data name="Anomaly.Kind.SteamMove"><value>Steam move</value></data>
|
||||||
|
<data name="Anomaly.Kind.SuspensionFreeze"><value>Suspension freeze</value></data>
|
||||||
<data name="Anomaly.Score"><value>Confidence</value></data>
|
<data name="Anomaly.Score"><value>Confidence</value></data>
|
||||||
|
|
||||||
<!-- Phase 7 — Anomaly feed UI -->
|
<!-- Phase 7 — Anomaly feed UI -->
|
||||||
|
|||||||
@@ -180,6 +180,7 @@
|
|||||||
<data name="Anomaly.Live"><value>Аномалия</value></data>
|
<data name="Anomaly.Live"><value>Аномалия</value></data>
|
||||||
<data name="Anomaly.Kind.SuspensionFlip"><value>Разворот после заморозки</value></data>
|
<data name="Anomaly.Kind.SuspensionFlip"><value>Разворот после заморозки</value></data>
|
||||||
<data name="Anomaly.Kind.SteamMove"><value>Движение линии</value></data>
|
<data name="Anomaly.Kind.SteamMove"><value>Движение линии</value></data>
|
||||||
|
<data name="Anomaly.Kind.SuspensionFreeze"><value>Заморозка линии</value></data>
|
||||||
<data name="Anomaly.Score"><value>Уверенность</value></data>
|
<data name="Anomaly.Score"><value>Уверенность</value></data>
|
||||||
|
|
||||||
<!-- Phase 7 — Лента аномалий -->
|
<!-- Phase 7 — Лента аномалий -->
|
||||||
|
|||||||
@@ -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<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_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<ArgumentOutOfRangeException>();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user