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:
@@ -32,4 +32,16 @@ public sealed class AnomalyOptions
|
||||
/// in seconds. Default: 60 s.
|
||||
/// </summary>
|
||||
public int DetectionIntervalSeconds { get; init; } = 60;
|
||||
|
||||
/// <summary>
|
||||
/// Trailing window, in seconds, over which the steam-move detector measures a
|
||||
/// continuous one-directional probability drift. Default: 120 s.
|
||||
/// </summary>
|
||||
public int SteamMoveWindowSeconds { get; init; } = 120;
|
||||
|
||||
/// <summary>
|
||||
/// Minimum one-directional normalised implied-probability rise within the window
|
||||
/// to flag a steam move. Must be in (0, 1). Default: 0.20 (20 percentage points).
|
||||
/// </summary>
|
||||
public decimal SteamMoveDriftThreshold { get; init; } = 0.20m;
|
||||
}
|
||||
|
||||
@@ -59,10 +59,18 @@ public sealed class DetectAnomaliesUseCase
|
||||
{
|
||||
_logger.LogInformation("DetectAnomaliesUseCase: cycle started");
|
||||
|
||||
var detector = new AnomalyDetector(
|
||||
_options.SuspensionGapSeconds,
|
||||
_options.OddsFlipThreshold,
|
||||
_options.MinSnapshotCount);
|
||||
var detectors = new IAnomalyDetector[]
|
||||
{
|
||||
new AnomalyDetector(
|
||||
_options.SuspensionGapSeconds,
|
||||
_options.OddsFlipThreshold,
|
||||
_options.MinSnapshotCount),
|
||||
new SteamMoveDetector(
|
||||
_options.SteamMoveWindowSeconds,
|
||||
_options.SteamMoveDriftThreshold,
|
||||
_options.MinSnapshotCount,
|
||||
_options.SuspensionGapSeconds),
|
||||
};
|
||||
|
||||
var events = await _eventRepo.ListAsync(ct);
|
||||
int newAnomalyCount = 0;
|
||||
@@ -96,7 +104,7 @@ public sealed class DetectAnomaliesUseCase
|
||||
var existingForEvent = existingByEvent.TryGetValue(ev.Id, out var slice)
|
||||
? slice
|
||||
: new List<Anomaly>();
|
||||
newAnomalyCount += await ProcessEventAsync(detector, ev, snapshots, existingForEvent, ct);
|
||||
newAnomalyCount += await ProcessEventAsync(detectors, ev, snapshots, existingForEvent, ct);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
@@ -120,13 +128,17 @@ public sealed class DetectAnomaliesUseCase
|
||||
// ── Private helpers ───────────────────────────────────────────────────────
|
||||
|
||||
private async Task<int> ProcessEventAsync(
|
||||
AnomalyDetector detector,
|
||||
IReadOnlyList<IAnomalyDetector> detectors,
|
||||
Event ev,
|
||||
IReadOnlyList<OddsSnapshot> snapshots,
|
||||
List<Anomaly> existingForEvent,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var detected = detector.Detect(ev.Id, snapshots);
|
||||
// Fan out over every detector kind; dedup below keys on EventId + Kind so the
|
||||
// flip and steam signals for one event persist independently.
|
||||
var detected = detectors
|
||||
.SelectMany(d => d.Detect(ev.Id, snapshots))
|
||||
.ToList();
|
||||
|
||||
if (detected.Count == 0)
|
||||
return 0;
|
||||
|
||||
Reference in New Issue
Block a user