68f3229c35
- 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.
105 lines
3.4 KiB
C#
105 lines
3.4 KiB
C#
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>();
|
|
}
|
|
}
|