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:
@@ -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