2b1025cae3
- 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.
131 lines
4.8 KiB
C#
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>();
|
|
}
|
|
}
|