feat(anomaly): overround-compression detector
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.
This commit is contained in:
@@ -51,4 +51,16 @@ public sealed class AnomalyOptions
|
|||||||
/// Default: 0.05 (5 percentage points).
|
/// Default: 0.05 (5 percentage points).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public decimal SuspensionFreezeThreshold { get; init; } = 0.05m;
|
public decimal SuspensionFreezeThreshold { get; init; } = 0.05m;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Trailing window, in seconds, over which the overround-compression detector
|
||||||
|
/// measures a continuous margin drop. Default: 120 s.
|
||||||
|
/// </summary>
|
||||||
|
public int OverroundWindowSeconds { get; init; } = 120;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Minimum drop in the bookmaker's overround (raw implied-probability sum) within the
|
||||||
|
/// window to flag a compression. Must be in (0, 1). Default: 0.02 (2 margin points).
|
||||||
|
/// </summary>
|
||||||
|
public decimal OverroundCompressionThreshold { get; init; } = 0.02m;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,6 +74,11 @@ public sealed class DetectAnomaliesUseCase
|
|||||||
_options.SuspensionGapSeconds,
|
_options.SuspensionGapSeconds,
|
||||||
_options.SuspensionFreezeThreshold,
|
_options.SuspensionFreezeThreshold,
|
||||||
_options.MinSnapshotCount),
|
_options.MinSnapshotCount),
|
||||||
|
new OverroundCompressionDetector(
|
||||||
|
_options.OverroundWindowSeconds,
|
||||||
|
_options.OverroundCompressionThreshold,
|
||||||
|
_options.MinSnapshotCount,
|
||||||
|
_options.SuspensionGapSeconds),
|
||||||
};
|
};
|
||||||
|
|
||||||
var events = await _eventRepo.ListAsync(ct);
|
var events = await _eventRepo.ListAsync(ct);
|
||||||
|
|||||||
@@ -26,14 +26,19 @@ internal static class MatchWinEvidence
|
|||||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// <summary>Normalised match-win implied probabilities + raw rates for a snapshot.</summary>
|
/// <summary>
|
||||||
|
/// Normalised match-win implied probabilities + raw rates for a snapshot.
|
||||||
|
/// <see cref="Overround"/> is the raw implied-probability sum (the bookmaker's
|
||||||
|
/// margin/vig, >= 1.0) before normalisation.
|
||||||
|
/// </summary>
|
||||||
public sealed record Probabilities(
|
public sealed record Probabilities(
|
||||||
decimal P1,
|
decimal P1,
|
||||||
decimal? PDraw,
|
decimal? PDraw,
|
||||||
decimal P2,
|
decimal P2,
|
||||||
decimal Rate1,
|
decimal Rate1,
|
||||||
decimal? RateDraw,
|
decimal? RateDraw,
|
||||||
decimal Rate2);
|
decimal Rate2,
|
||||||
|
decimal Overround);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Extracts normalised match-win implied probabilities, or null when the snapshot
|
/// Extracts normalised match-win implied probabilities, or null when the snapshot
|
||||||
@@ -65,7 +70,8 @@ internal static class MatchWinEvidence
|
|||||||
P2: rawP2 / total,
|
P2: rawP2 / total,
|
||||||
Rate1: win1.Rate.Value,
|
Rate1: win1.Rate.Value,
|
||||||
RateDraw: drawBet?.Rate.Value,
|
RateDraw: drawBet?.Rate.Value,
|
||||||
Rate2: win2.Rate.Value);
|
Rate2: win2.Rate.Value,
|
||||||
|
Overround: total);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Label of the side carrying the highest normalised implied probability.</summary>
|
/// <summary>Label of the side carrying the highest normalised implied probability.</summary>
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
using Marathon.Domain.Entities;
|
||||||
|
using Marathon.Domain.Enums;
|
||||||
|
using Marathon.Domain.ValueObjects;
|
||||||
|
|
||||||
|
namespace Marathon.Domain.AnomalyDetection;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Detects an "overround compression": the bookmaker's margin (the raw implied-probability
|
||||||
|
/// sum, >= 1.0) drops sharply over a short CONTINUOUS window — the book tightens its vig,
|
||||||
|
/// often ahead of news or when it is confident in the line.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Like the steam-move detector, it only considers windows with no suspension-sized gap
|
||||||
|
/// (controlled by <c>maxStepGapSeconds</c>), so it never overlaps the across-suspension
|
||||||
|
/// flip / freeze detectors. It is informational (non-directional) — the score is the
|
||||||
|
/// compression intensity, not a side prediction — so the outcome evaluator and backtest
|
||||||
|
/// exclude it (see <c>AnomalyKind.IsDirectional</c>).
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Score scales the margin drop against a reference collapse: a drop of
|
||||||
|
/// <see cref="ReferenceCompression"/> (10 margin points) or more reads as a full-strength
|
||||||
|
/// signal (1.0); the configured <c>compressionThreshold</c> is the minimum drop to flag.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class OverroundCompressionDetector : IAnomalyDetector
|
||||||
|
{
|
||||||
|
/// <summary>A 10-margin-point collapse maps to the maximum score of 1.0.</summary>
|
||||||
|
public const decimal ReferenceCompression = 0.10m;
|
||||||
|
|
||||||
|
private readonly int _windowSeconds;
|
||||||
|
private readonly decimal _compressionThreshold;
|
||||||
|
private readonly int _minSnapshotCount;
|
||||||
|
private readonly int _maxStepGapSeconds;
|
||||||
|
|
||||||
|
public OverroundCompressionDetector(
|
||||||
|
int windowSeconds, decimal compressionThreshold, int minSnapshotCount, int maxStepGapSeconds)
|
||||||
|
{
|
||||||
|
if (windowSeconds <= 0)
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(windowSeconds), windowSeconds, "Must be positive.");
|
||||||
|
if (compressionThreshold is <= 0m or >= 1m)
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(compressionThreshold), compressionThreshold, "Must be in (0, 1).");
|
||||||
|
if (minSnapshotCount < 2)
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(minSnapshotCount), minSnapshotCount, "Must be at least 2.");
|
||||||
|
if (maxStepGapSeconds <= 0)
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(maxStepGapSeconds), maxStepGapSeconds, "Must be positive.");
|
||||||
|
|
||||||
|
_windowSeconds = windowSeconds;
|
||||||
|
_compressionThreshold = compressionThreshold;
|
||||||
|
_minSnapshotCount = minSnapshotCount;
|
||||||
|
_maxStepGapSeconds = maxStepGapSeconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public IReadOnlyList<Anomaly> Detect(EventId eventId, IReadOnlyList<OddsSnapshot> snapshots)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(eventId);
|
||||||
|
ArgumentNullException.ThrowIfNull(snapshots);
|
||||||
|
|
||||||
|
var live = snapshots
|
||||||
|
.Where(s => s.Source == OddsSource.Live)
|
||||||
|
.OrderBy(s => s.CapturedAt)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (live.Count < _minSnapshotCount)
|
||||||
|
return Array.Empty<Anomaly>();
|
||||||
|
|
||||||
|
var window = TimeSpan.FromSeconds(_windowSeconds);
|
||||||
|
var maxStepGap = TimeSpan.FromSeconds(_maxStepGapSeconds);
|
||||||
|
|
||||||
|
var anomalies = new List<Anomaly>();
|
||||||
|
int windowStart = 0;
|
||||||
|
int continuityStart = 0;
|
||||||
|
|
||||||
|
for (int end = 1; end < live.Count; end++)
|
||||||
|
{
|
||||||
|
if (live[end].CapturedAt - live[end - 1].CapturedAt > maxStepGap)
|
||||||
|
continuityStart = end;
|
||||||
|
|
||||||
|
while (live[end].CapturedAt - live[windowStart].CapturedAt > window)
|
||||||
|
windowStart++;
|
||||||
|
|
||||||
|
int start = Math.Max(windowStart, continuityStart);
|
||||||
|
if (start >= end)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var pre = MatchWinEvidence.Extract(live[start]);
|
||||||
|
var post = MatchWinEvidence.Extract(live[end]);
|
||||||
|
if (pre is null || post is null)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
// Compression is measured start-to-end of the current window. Because the loop
|
||||||
|
// emits at every `end` over a sliding window, an intra-window dip that later
|
||||||
|
// recovers is still flagged on the iteration whose `end` lands on the trough
|
||||||
|
// (where `start` still holds the pre-dip margin), so a separate peak-to-trough
|
||||||
|
// scan is unnecessary. Positive = the margin shrank (book tightened).
|
||||||
|
var compression = pre.Overround - post.Overround;
|
||||||
|
if (compression < _compressionThreshold)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var gapSeconds = (int)(live[end].CapturedAt - live[start].CapturedAt).TotalSeconds;
|
||||||
|
var evidenceJson = MatchWinEvidence.BuildJson(gapSeconds, live[start], pre, live[end], post);
|
||||||
|
|
||||||
|
anomalies.Add(new Anomaly(
|
||||||
|
Id: Guid.NewGuid(),
|
||||||
|
EventId: eventId,
|
||||||
|
DetectedAt: MoscowTime.Now,
|
||||||
|
Kind: AnomalyKind.OverroundCompression,
|
||||||
|
Score: Math.Min(1m, compression / ReferenceCompression),
|
||||||
|
EvidenceJson: evidenceJson));
|
||||||
|
}
|
||||||
|
|
||||||
|
return anomalies.AsReadOnly();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,4 +22,10 @@ public enum AnomalyKind
|
|||||||
/// (favourite unchanged, negligible price move) — a freeze signalling uncertainty.
|
/// (favourite unchanged, negligible price move) — a freeze signalling uncertainty.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
SuspensionFreeze,
|
SuspensionFreeze,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The bookmaker's margin (overround) compressed sharply over a short continuous
|
||||||
|
/// window — the book tightened its vig, often ahead of news or when confident.
|
||||||
|
/// </summary>
|
||||||
|
OverroundCompression,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ public static class AnomalyKindExtensions
|
|||||||
AnomalyKind.SuspensionFlip => true,
|
AnomalyKind.SuspensionFlip => true,
|
||||||
AnomalyKind.SteamMove => true,
|
AnomalyKind.SteamMove => true,
|
||||||
AnomalyKind.SuspensionFreeze => false,
|
AnomalyKind.SuspensionFreeze => false,
|
||||||
|
AnomalyKind.OverroundCompression => false,
|
||||||
_ => false,
|
_ => false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,7 +44,9 @@
|
|||||||
"DetectionIntervalSeconds": 60,
|
"DetectionIntervalSeconds": 60,
|
||||||
"SteamMoveWindowSeconds": 120,
|
"SteamMoveWindowSeconds": 120,
|
||||||
"SteamMoveDriftThreshold": 0.20,
|
"SteamMoveDriftThreshold": 0.20,
|
||||||
"SuspensionFreezeThreshold": 0.05
|
"SuspensionFreezeThreshold": 0.05,
|
||||||
|
"OverroundWindowSeconds": 120,
|
||||||
|
"OverroundCompressionThreshold": 0.02
|
||||||
},
|
},
|
||||||
"Notifications": {
|
"Notifications": {
|
||||||
"Enabled": false,
|
"Enabled": false,
|
||||||
|
|||||||
@@ -211,10 +211,11 @@
|
|||||||
|
|
||||||
private string KindLabel(AnomalyKind kind) => kind switch
|
private string KindLabel(AnomalyKind kind) => kind switch
|
||||||
{
|
{
|
||||||
AnomalyKind.SuspensionFlip => L["Anomaly.Kind.SuspensionFlip"],
|
AnomalyKind.SuspensionFlip => L["Anomaly.Kind.SuspensionFlip"],
|
||||||
AnomalyKind.SteamMove => L["Anomaly.Kind.SteamMove"],
|
AnomalyKind.SteamMove => L["Anomaly.Kind.SteamMove"],
|
||||||
AnomalyKind.SuspensionFreeze => L["Anomaly.Kind.SuspensionFreeze"],
|
AnomalyKind.SuspensionFreeze => L["Anomaly.Kind.SuspensionFreeze"],
|
||||||
_ => kind.ToString(),
|
AnomalyKind.OverroundCompression => L["Anomaly.Kind.OverroundCompression"],
|
||||||
|
_ => kind.ToString(),
|
||||||
};
|
};
|
||||||
|
|
||||||
private string SportLabel(int code) => SportLabels.Resolve(L, code);
|
private string SportLabel(int code) => SportLabels.Resolve(L, code);
|
||||||
|
|||||||
@@ -105,10 +105,11 @@
|
|||||||
|
|
||||||
private string KindLabel(AnomalyKind kind) => kind switch
|
private string KindLabel(AnomalyKind kind) => kind switch
|
||||||
{
|
{
|
||||||
AnomalyKind.SuspensionFlip => L["Anomaly.Kind.SuspensionFlip"],
|
AnomalyKind.SuspensionFlip => L["Anomaly.Kind.SuspensionFlip"],
|
||||||
AnomalyKind.SteamMove => L["Anomaly.Kind.SteamMove"],
|
AnomalyKind.SteamMove => L["Anomaly.Kind.SteamMove"],
|
||||||
AnomalyKind.SuspensionFreeze => L["Anomaly.Kind.SuspensionFreeze"],
|
AnomalyKind.SuspensionFreeze => L["Anomaly.Kind.SuspensionFreeze"],
|
||||||
_ => kind.ToString(),
|
AnomalyKind.OverroundCompression => L["Anomaly.Kind.OverroundCompression"],
|
||||||
|
_ => kind.ToString(),
|
||||||
};
|
};
|
||||||
|
|
||||||
private static string FormatGap(int seconds)
|
private static string FormatGap(int seconds)
|
||||||
|
|||||||
@@ -169,6 +169,7 @@
|
|||||||
<data name="Anomaly.Kind.SuspensionFlip"><value>Suspension flip</value></data>
|
<data name="Anomaly.Kind.SuspensionFlip"><value>Suspension flip</value></data>
|
||||||
<data name="Anomaly.Kind.SteamMove"><value>Steam move</value></data>
|
<data name="Anomaly.Kind.SteamMove"><value>Steam move</value></data>
|
||||||
<data name="Anomaly.Kind.SuspensionFreeze"><value>Suspension freeze</value></data>
|
<data name="Anomaly.Kind.SuspensionFreeze"><value>Suspension freeze</value></data>
|
||||||
|
<data name="Anomaly.Kind.OverroundCompression"><value>Margin compression</value></data>
|
||||||
<data name="Anomaly.Score"><value>Confidence</value></data>
|
<data name="Anomaly.Score"><value>Confidence</value></data>
|
||||||
|
|
||||||
<!-- Phase 7 — Anomaly feed UI -->
|
<!-- Phase 7 — Anomaly feed UI -->
|
||||||
|
|||||||
@@ -182,6 +182,7 @@
|
|||||||
<data name="Anomaly.Kind.SuspensionFlip"><value>Разворот после заморозки</value></data>
|
<data name="Anomaly.Kind.SuspensionFlip"><value>Разворот после заморозки</value></data>
|
||||||
<data name="Anomaly.Kind.SteamMove"><value>Движение линии</value></data>
|
<data name="Anomaly.Kind.SteamMove"><value>Движение линии</value></data>
|
||||||
<data name="Anomaly.Kind.SuspensionFreeze"><value>Заморозка линии</value></data>
|
<data name="Anomaly.Kind.SuspensionFreeze"><value>Заморозка линии</value></data>
|
||||||
|
<data name="Anomaly.Kind.OverroundCompression"><value>Сжатие маржи</value></data>
|
||||||
<data name="Anomaly.Score"><value>Уверенность</value></data>
|
<data name="Anomaly.Score"><value>Уверенность</value></data>
|
||||||
|
|
||||||
<!-- Phase 7 — Лента аномалий -->
|
<!-- Phase 7 — Лента аномалий -->
|
||||||
|
|||||||
@@ -0,0 +1,204 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ public sealed class AnomalyKindExtensionsTests
|
|||||||
[InlineData(AnomalyKind.SuspensionFlip, true)]
|
[InlineData(AnomalyKind.SuspensionFlip, true)]
|
||||||
[InlineData(AnomalyKind.SteamMove, true)]
|
[InlineData(AnomalyKind.SteamMove, true)]
|
||||||
[InlineData(AnomalyKind.SuspensionFreeze, false)]
|
[InlineData(AnomalyKind.SuspensionFreeze, false)]
|
||||||
|
[InlineData(AnomalyKind.OverroundCompression, false)]
|
||||||
public void IsDirectional_Should_ClassifyKinds(AnomalyKind kind, bool expected)
|
public void IsDirectional_Should_ClassifyKinds(AnomalyKind kind, bool expected)
|
||||||
{
|
{
|
||||||
kind.IsDirectional().Should().Be(expected);
|
kind.IsDirectional().Should().Be(expected);
|
||||||
|
|||||||
Reference in New Issue
Block a user