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.
This commit is contained in:
@@ -0,0 +1,130 @@
|
||||
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>();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user