feat(phase-7-backend): implement anomaly detection — SuspensionFlip detector, use case, poller, and tests

- AnomalyDetector (pure domain): detects odds-flip pattern from live snapshot
  timelines using implied-probability vectors (p=1/rate, normalised), flip score
  = max(|p_post−p_pre|), gated by both threshold AND favourite-changed test
- SuspensionInterval record: typed pair of (pre, post) OddsSnapshot bracketing a gap
- AnomalyOptions POCO (Application layer): bound to Anomaly:* config section with
  four fields (SuspensionGapSeconds=60, OddsFlipThreshold=0.30, MinSnapshotCount=3,
  DetectionIntervalSeconds=60)
- DetectAnomaliesUseCase: iterates all events, loads last-24h live snapshots, runs
  detector, persists new anomalies with 1-minute dedup window
- AnomalyDetectionPoller: BackgroundService polling every DetectionIntervalSeconds,
  gated by WorkerOptions.AnomalyDetectionEnabled (default true)
- DI wiring: DetectAnomaliesUseCase registered Scoped in ApplicationModule;
  AnomalyOptions bound + AnomalyDetectionPoller hosted in InfrastructureModule
- WorkerOptions.AnomalyDetectionEnabled added; appsettings.json updated
- 13 domain tests + 4 application tests; total 245/245 passing (no regression)
This commit is contained in:
2026-05-05 13:15:50 +03:00
parent d915667da1
commit a6ff368015
14 changed files with 1411 additions and 34 deletions
@@ -1,3 +1,4 @@
using Marathon.Application.Configuration;
using Marathon.Infrastructure.Configuration;
using Marathon.Infrastructure.Persistence;
using Marathon.Infrastructure.Scraping;
@@ -41,9 +42,14 @@ public static class InfrastructureModule
.AddOptions<WorkerOptions>()
.Bind(config.GetSection(WorkerOptions.SectionName));
services
.AddOptions<AnomalyOptions>()
.Bind(config.GetSection(AnomalyOptions.SectionName));
services.AddHostedService<UpcomingEventsPoller>();
services.AddHostedService<LiveOddsPoller>();
services.AddHostedService<ResultsWatchListPoller>();
services.AddHostedService<AnomalyDetectionPoller>();
return services;
}