Files
maraphon-app/tests/Marathon.Domain.Tests/AnomalyDetection/SuspensionFreezeDetectorTests.cs
T
alexei.dolgolyov 68f3229c35 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.
2026-05-29 01:03:47 +03:00

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