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
+38
View File
@@ -101,6 +101,44 @@ with scraping research, no implementation.
## Implementation Notes
### Phase 7 Backend (Anomaly detection, 2026-05-05)
- **`AnomalyDetector` is pure domain — no I/O, no DI.** Constructor takes three ints/decimals
from `AnomalyOptions`; the caller (use case) materialises it per cycle.
The UI evidence panel can reconstruct the same probabilities from `EvidenceJson` without
needing to re-invoke the detector.
- **Implied probability formula:** `p_i = 1 / rate_i`, then normalise so all `p_i` sum to 1
(divorcé of the bookmaker's margin). This is the standard European odds conversion.
- **Flip score** = `max(|p_post[i] p_pre[i]|)` over Match-Win sides (p1, pDraw?, p2).
Score is clamped to `[0, 1]` before constructing `Anomaly` (domain invariant enforces ≤1).
- **Two-part gate** — an anomaly requires BOTH: (a) `flipScore ≥ OddsFlipThreshold` AND
(b) `argmax(p_pre) != argmax(p_post)`. This prevents spurious detections when one side's
probability shifts a lot but it was never the favourite.
- **Tennis / 2-way markets** — `pDraw` is `null` when no `BetType.Draw` bet is present.
The detector and `EvidenceJson` gracefully handle this (JSON field is omitted when null via
`DefaultIgnoreCondition.WhenWritingNull`).
- **`EvidenceJson` uses `System.Text.Json` with custom `JsonPropertyName` attributes** on
sealed nested records (`EvidencePayload`, `SnapshotEvidence`). Source generation was not
used at this scale — the payload is small and created infrequently.
- **`DetectAnomaliesUseCase` loads all events + last-24-h live snapshots per cycle.**
This is a deliberate simplification; a future optimisation is to track `last_run_at` per
event. Documented as 🟡 in the handoff.
- **Dedup strategy:** two anomalies are considered duplicates if they share `EventId`, `Kind`,
and their `DetectedAt` values fall within a 1-minute window. This prevents the same
suspension triggering re-insertion on consecutive detection cycles while the gap snapshot
pair remains in the 24-hour window.
- **`AnomalyOptions` placed in `Marathon.Application/Configuration/`** (not Infrastructure).
The `AnomalyDetector` itself is in `Marathon.Domain/AnomalyDetection/` but requires no
options binding — it takes plain constructor parameters.
- **`AnomalyDetectionPoller` reads `IOptionsMonitor<AnomalyOptions>` per cycle** so that
hot-reload of `DetectionIntervalSeconds` takes effect without a restart. Same pattern as
`LiveOddsPoller` reading `WorkerOptions`.
- **`Workers:AnomalyDetectionEnabled`** added to `WorkerOptions` (default `true`) and
`appsettings.json`. UI agent must add a Settings toggle for this flag.
- **New test count: +17** (13 domain + 4 application). Total: 245/245 passing.
- **Test note:** rates 1.5/2.5 produce a flip score of ~0.25 — BELOW the 0.30 threshold.
Always use 1.3/4.0 (flip score ~0.51) or steeper to guarantee detection in tests.
### Phase 6 (Event browsing UI, 2026-05-05)
- **Plotly.Blazor pinned to 5.4.1.** v7.x exists but introduces breaking changes;