115872aad0
Adds the 4th IAnomalyDetector: flags a sharp drop in the bookmaker's overround (raw implied-probability sum / margin) over a continuous live window. Informational/non-directional — excluded from outcome grading and backtest staking via AnomalyKind.IsDirectional(). - OverroundCompressionDetector: sliding-window scan mirroring SteamMove, score = min(1, compression / 0.10 reference), suspension-gap aware. - MatchWinEvidence.Probabilities gains Overround (pre-normalisation margin); evidence JSON shape unchanged. - Wired into DetectAnomaliesUseCase fan-out; AnomalyOptions + appsettings (window 120s, threshold 0.02); en/ru resx + KindLabel arms. - 11 detector tests incl. score saturation/scaling, inclusive threshold boundary, and a three-way (draw-leg) overround case.
205 lines
7.1 KiB
C#
205 lines
7.1 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 OverroundCompressionDetectorTests
|
|
{
|
|
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("26000003");
|
|
|
|
// window 120s, compression threshold 0.02, min 3 snapshots, continuity break at 60s.
|
|
private static OverroundCompressionDetector CreateSut() => new(120, 0.02m, 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)),
|
|
});
|
|
|
|
private static OddsSnapshot Live3Way(int seconds, decimal r1, decimal rDraw, 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.Draw, Side.Draw, null, new OddsRate(rDraw)),
|
|
new(MatchScope.Instance, BetType.Win, Side.Side2, null, new OddsRate(r2)),
|
|
});
|
|
|
|
private static decimal Overround2Way(decimal r1, decimal r2) => 1m / r1 + 1m / r2;
|
|
|
|
[Fact]
|
|
public void Should_FlagCompression_When_MarginTightensContinuously()
|
|
{
|
|
// Overround 1/1.8+1/2.0 = 1.056 → 1/1.9+1/2.1 = 1.002, a ~0.054 drop over 60s.
|
|
var snapshots = new[]
|
|
{
|
|
Live(0, 1.80m, 2.00m),
|
|
Live(30, 1.85m, 2.05m),
|
|
Live(60, 1.90m, 2.10m),
|
|
};
|
|
|
|
var result = CreateSut().Detect(Event, snapshots);
|
|
|
|
result.Should().NotBeEmpty();
|
|
result.Should().OnlyContain(a => a.Kind == AnomalyKind.OverroundCompression);
|
|
result.Max(a => a.Score).Should().BeGreaterThan(0m);
|
|
}
|
|
|
|
[Fact]
|
|
public void Should_NotFlag_When_MarginStable()
|
|
{
|
|
var snapshots = new[]
|
|
{
|
|
Live(0, 1.80m, 2.00m),
|
|
Live(30, 1.80m, 2.00m),
|
|
Live(60, 1.80m, 2.00m),
|
|
};
|
|
|
|
CreateSut().Detect(Event, snapshots).Should().BeEmpty();
|
|
}
|
|
|
|
[Fact]
|
|
public void Should_NotFlag_When_CompressionSpansSuspensionGap()
|
|
{
|
|
// The margin change straddles a 90s gap (> 60s continuity break) — out of scope.
|
|
var snapshots = new[]
|
|
{
|
|
Live(0, 1.80m, 2.00m),
|
|
Live(30, 1.80m, 2.00m),
|
|
Live(120, 1.90m, 2.10m), // 90s gap
|
|
Live(150, 1.90m, 2.10m),
|
|
};
|
|
|
|
CreateSut().Detect(Event, snapshots).Should().BeEmpty();
|
|
}
|
|
|
|
[Fact]
|
|
public void Should_NotFlag_When_MarginWidens()
|
|
{
|
|
// Overround rising (margin widening) is the opposite signal — never flagged.
|
|
var snapshots = new[]
|
|
{
|
|
Live(0, 1.90m, 2.10m),
|
|
Live(30, 1.85m, 2.05m),
|
|
Live(60, 1.80m, 2.00m),
|
|
};
|
|
|
|
CreateSut().Detect(Event, snapshots).Should().BeEmpty();
|
|
}
|
|
|
|
[Fact]
|
|
public void Should_ReturnEmpty_When_FewerThanMinSnapshots()
|
|
{
|
|
var snapshots = new[] { Live(0, 1.80m, 2.00m), Live(60, 1.90m, 2.10m) };
|
|
|
|
CreateSut().Detect(Event, snapshots).Should().BeEmpty();
|
|
}
|
|
|
|
[Fact]
|
|
public void Should_Throw_When_ThresholdOutOfRange()
|
|
{
|
|
var act = () => new OverroundCompressionDetector(120, 1.0m, 3, 60);
|
|
act.Should().Throw<ArgumentOutOfRangeException>();
|
|
}
|
|
|
|
[Fact]
|
|
public void Should_SaturateScore_When_CompressionExceedsReference()
|
|
{
|
|
// Overround 1.333 (1.50/1.50) → 1.000 (2.00/2.00): a 0.333 drop, well past the
|
|
// 0.10 ReferenceCompression, so the score must clamp to exactly 1.0.
|
|
var snapshots = new[]
|
|
{
|
|
Live(0, 1.50m, 1.50m),
|
|
Live(30, 1.75m, 1.75m),
|
|
Live(60, 2.00m, 2.00m),
|
|
};
|
|
|
|
var result = CreateSut().Detect(Event, snapshots);
|
|
|
|
result.Should().NotBeEmpty();
|
|
result.Max(a => a.Score).Should().Be(1.0m);
|
|
}
|
|
|
|
[Fact]
|
|
public void Should_ScaleScore_ByCompressionMagnitude()
|
|
{
|
|
// Overround 1.25 (1.60/1.60) → 1.17647 (1.70/1.70): a ~0.0735 drop, between the
|
|
// 0.02 flag threshold and the 0.10 reference, so the score is the linear ratio.
|
|
var snapshots = new[]
|
|
{
|
|
Live(0, 1.60m, 1.60m),
|
|
Live(30, 1.65m, 1.65m),
|
|
Live(60, 1.70m, 1.70m),
|
|
};
|
|
var expected = Math.Min(
|
|
1m,
|
|
(Overround2Way(1.60m, 1.60m) - Overround2Way(1.70m, 1.70m))
|
|
/ OverroundCompressionDetector.ReferenceCompression);
|
|
|
|
var result = CreateSut().Detect(Event, snapshots);
|
|
|
|
result.Max(a => a.Score).Should().BeApproximately(expected, 0.0001m);
|
|
result.Max(a => a.Score).Should().BeGreaterThan(0m).And.BeLessThan(1m);
|
|
}
|
|
|
|
[Fact]
|
|
public void Should_Flag_When_CompressionExactlyEqualsThreshold()
|
|
{
|
|
// The full-window drop is the largest compression present; setting the threshold to
|
|
// exactly that value must still flag — the boundary is inclusive (compression >= T).
|
|
var snapshots = new[]
|
|
{
|
|
Live(0, 1.80m, 2.00m),
|
|
Live(30, 1.85m, 2.05m),
|
|
Live(60, 1.90m, 2.10m),
|
|
};
|
|
var exact = Overround2Way(1.80m, 2.00m) - Overround2Way(1.90m, 2.10m);
|
|
|
|
new OverroundCompressionDetector(120, exact, 3, 60)
|
|
.Detect(Event, snapshots).Should().NotBeEmpty();
|
|
}
|
|
|
|
[Fact]
|
|
public void Should_NotFlag_When_ThresholdExceedsCompression()
|
|
{
|
|
// Threshold a hair above the largest drop present — nothing qualifies.
|
|
var snapshots = new[]
|
|
{
|
|
Live(0, 1.80m, 2.00m),
|
|
Live(30, 1.85m, 2.05m),
|
|
Live(60, 1.90m, 2.10m),
|
|
};
|
|
var aboveMax = (Overround2Way(1.80m, 2.00m) - Overround2Way(1.90m, 2.10m)) + 0.0001m;
|
|
|
|
new OverroundCompressionDetector(120, aboveMax, 3, 60)
|
|
.Detect(Event, snapshots).Should().BeEmpty();
|
|
}
|
|
|
|
[Fact]
|
|
public void Should_IncludeDrawLeg_When_OnlyDrawMarginTightens()
|
|
{
|
|
// Win legs are held constant (zero contribution); only the draw price lengthens
|
|
// (3.00 → 4.00), dropping the overround by ~0.083. If the overround ignored the
|
|
// draw leg the compression would be 0 and nothing would flag — so flagging proves
|
|
// the three-way leg is summed into the margin.
|
|
var snapshots = new[]
|
|
{
|
|
Live3Way(0, 2.00m, 3.00m, 2.00m),
|
|
Live3Way(30, 2.00m, 3.50m, 2.00m),
|
|
Live3Way(60, 2.00m, 4.00m, 2.00m),
|
|
};
|
|
|
|
var result = CreateSut().Detect(Event, snapshots);
|
|
|
|
result.Should().NotBeEmpty();
|
|
result.Should().OnlyContain(a => a.Kind == AnomalyKind.OverroundCompression);
|
|
}
|
|
}
|