Files
maraphon-app/tests/Marathon.Domain.Tests/AnomalyDetection/SteamMoveDetectorTests.cs
T
alexei.dolgolyov 2b1025cae3 feat(anomaly): IAnomalyDetector seam + steam-move detector
- Introduce IAnomalyDetector; the existing flip detector implements it.
- Extract MatchWinEvidence so every detector writes the identical pre/post
  evidence shape — the UI parser and outcome evaluator handle new kinds with no
  branching (steam moves get hit-rate calibrated for free).
- Add SteamMoveDetector: flags a rapid one-directional implied-probability rise
  over a short CONTINUOUS window (no suspension gap inside it), so it never
  double-flags the same interval as the suspension-flip detector.
- DetectAnomaliesUseCase fans out over both detectors; dedup keys on EventId+Kind
  so flip and steam signals persist independently. Add AnomalyKind.SteamMove +
  SteamMove window/threshold options. 8 detector tests.
2026-05-28 22:59:12 +03:00

131 lines
4.8 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 SteamMoveDetectorTests
{
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("26000001");
// window 120s, drift threshold 0.20, min 3 snapshots, continuity break at 60s.
private static SteamMoveDetector CreateSut() => new(120, 0.20m, 3, 60);
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_FlagSteamMove_When_OneSideShortensContinuously()
{
// Side2 shortens (3.0 → 1.6) over 90s in continuous 30s steps: its normalised
// implied probability rises ~0.33 → ~0.61, a ~0.28 drift > 0.20 threshold.
var snapshots = new[]
{
Live(0, 1.5m, 3.0m),
Live(30, 1.7m, 2.3m),
Live(60, 2.1m, 1.9m),
Live(90, 2.5m, 1.6m),
};
var result = CreateSut().Detect(Event, snapshots);
result.Should().NotBeEmpty();
result.Should().OnlyContain(a => a.Kind == AnomalyKind.SteamMove);
result.Max(a => a.Score).Should().BeGreaterThanOrEqualTo(0.20m);
}
[Fact]
public void Should_NotFlag_When_DriftBelowThreshold()
{
// Gentle drift: Side2 0.333 → ~0.38, well under the 0.20 threshold.
var snapshots = new[]
{
Live(0, 1.5m, 3.0m),
Live(30, 1.55m, 2.85m),
Live(60, 1.6m, 2.7m),
};
CreateSut().Detect(Event, snapshots).Should().BeEmpty();
}
[Fact]
public void Should_NotFlag_When_DriftSpansSuspensionGap()
{
// The big move happens across a 90s gap (> 60s continuity break) — that is the
// SuspensionFlip detector's territory, so steam must not double-flag it.
var snapshots = new[]
{
Live(0, 1.3m, 4.0m),
Live(30, 1.3m, 4.0m),
Live(120, 4.0m, 1.3m), // 90s gap
Live(150, 4.0m, 1.3m),
};
CreateSut().Detect(Event, snapshots).Should().BeEmpty();
}
[Fact]
public void Should_ReturnEmpty_When_FewerThanMinSnapshots()
{
var snapshots = new[] { Live(0, 1.5m, 3.0m), Live(30, 2.5m, 1.6m) };
CreateSut().Detect(Event, snapshots).Should().BeEmpty();
}
[Fact]
public void Should_IgnorePreMatchSnapshots()
{
var snapshots = new[]
{
new OddsSnapshot(Event, BaseTime, OddsSource.PreMatch,
new List<Bet> { new(MatchScope.Instance, BetType.Win, Side.Side1, null, new OddsRate(1.5m)),
new(MatchScope.Instance, BetType.Win, Side.Side2, null, new OddsRate(3.0m)) }),
new OddsSnapshot(Event, BaseTime.AddSeconds(30), OddsSource.PreMatch,
new List<Bet> { new(MatchScope.Instance, BetType.Win, Side.Side1, null, new OddsRate(2.5m)),
new(MatchScope.Instance, BetType.Win, Side.Side2, null, new OddsRate(1.6m)) }),
new OddsSnapshot(Event, BaseTime.AddSeconds(60), OddsSource.PreMatch,
new List<Bet> { new(MatchScope.Instance, BetType.Win, Side.Side1, null, new OddsRate(2.6m)),
new(MatchScope.Instance, BetType.Win, Side.Side2, null, new OddsRate(1.55m)) }),
};
CreateSut().Detect(Event, snapshots).Should().BeEmpty();
}
[Fact]
public void Should_EmitParseableEvidence_For_DetectedSteamMove()
{
var snapshots = new[]
{
Live(0, 1.5m, 3.0m),
Live(30, 1.9m, 2.0m),
Live(60, 2.5m, 1.6m),
};
var anomaly = CreateSut().Detect(Event, snapshots).First();
AnomalyEvidenceParser.TryParse(anomaly.EvidenceJson, out var data).Should().BeTrue();
data.PreSuspension.Should().NotBeNull();
data.PostSuspension.Should().NotBeNull();
// Post favourite is the steamed (shortened) side — drives the outcome evaluator.
data.PostSuspension.Favourite.Should().Be(Side.Side2);
}
[Theory]
[InlineData(0)]
[InlineData(-30)]
public void Should_Throw_When_ConstructedWithInvalidWindow(int windowSeconds)
{
var act = () => new SteamMoveDetector(windowSeconds, 0.20m, 3, 60);
act.Should().Throw<ArgumentOutOfRangeException>();
}
}