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:
2026-05-28 22:59:12 +03:00
parent 4dae9e8d0d
commit 2b1025cae3
8 changed files with 440 additions and 133 deletions
@@ -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;