Files
maraphon-app/plans/initial-implementation/phase-7-anomaly-detection.md
T
alexei.dolgolyov a6ff368015 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)
2026-05-05 13:15:50 +03:00

280 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Phase 7: Anomaly Detection (Suspension + Flip)
**Status:** 🔨 Backend Done — Awaiting Frontend (Opus)
**Parent plan:** [PLAN.md](./PLAN.md)
**Domain:** fullstack
**Implementer:** Sonnet (backend portion) + Opus (UI portion, with frontend-design)
**Depends on:** Phase 4 (snapshot pipeline) + Phase 6 (UI patterns)
## Objective
Detect the **odds-flip anomaly** described in customer TZ §3: bookmaker freezes betting
on a live event, then re-opens with inverted underdog/favorite odds. Persist anomalies
and surface them in a dedicated UI feed page so the user can act on them.
## Tasks
### Backend (Sonnet) ✅ COMPLETE
- [x] Implement `Marathon.Domain/AnomalyDetection/AnomalyDetector.cs`:
- Pure domain logic — takes `IReadOnlyList<OddsSnapshot>` for an event, returns
`IReadOnlyList<Anomaly>`
- Detect suspension intervals: gaps between snapshots > `SuspensionGapSeconds`
(configurable)
- For each suspension, compute pre-suspension and post-suspension implied
probability vectors `(p1, pDraw, p2)` from Win-1/Draw/Win-2 rates
- Compute flip score: `max(|p_post[i] p_pre[i]|)` across i ∈ {1, draw, 2}
- If flip score ≥ `OddsFlipThreshold` AND the favourite changed (argmax differs),
emit an `Anomaly(Kind=SuspensionFlip, Score, EvidenceJson)` where `EvidenceJson`
contains the snapshots bracketing the suspension
- [x] Add `AnomalyOptions` POCO bound to `Anomaly:*` (in `Marathon.Application/Configuration/`):
```csharp
public sealed class AnomalyOptions {
public int SuspensionGapSeconds { get; init; } = 60;
public decimal OddsFlipThreshold { get; init; } = 0.30m;
public int MinSnapshotCount { get; init; } = 3;
public int DetectionIntervalSeconds { get; init; } = 60;
}
```
- [x] Implement `DetectAnomaliesUseCase` in `Marathon.Application/UseCases/`:
- Iterate over all events and load snapshots from last 24 h
- Invoke `AnomalyDetector` per event
- Persist new anomalies via `IAnomalyRepository` with dedup logic
- [x] Implement `AnomalyDetectionPoller : BackgroundService` in
`Marathon.Infrastructure/Workers/`:
- Runs every `Anomaly:DetectionIntervalSeconds` (default 60s)
- Calls `DetectAnomaliesUseCase`
- Gated by `Workers:AnomalyDetectionEnabled` (default `true`)
- [x] Add `WorkerOptions.AnomalyDetectionEnabled` (default `true`)
- [x] Register `DetectAnomaliesUseCase` as Scoped in `ApplicationModule`
- [x] Bind `AnomalyOptions` and register `AnomalyDetectionPoller` in `InfrastructureModule`
- [x] Update `appsettings.json` — add `Workers:AnomalyDetectionEnabled: true`
(all 4 `Anomaly:*` keys already existed from Phase 5)
- [x] Backend tests in `Marathon.Domain.Tests/AnomalyDetection/AnomalyDetectorTests.cs` (10 tests):
- Empty snapshot list → 0 anomalies ✓
- Below `minSnapshotCount` → 0 anomalies ✓
- Pre-match-only snapshots → 0 anomalies ✓
- No suspension (regular intervals) → 0 anomalies ✓
- Suspension but odds shift below threshold → 0 anomalies ✓
- Suspension + favourite flip (2-way) → 1 anomaly ✓
- Score calculation correct for known inputs ✓
- Tennis (no draw) → 1 anomaly ✓
- Multiple suspensions → multiple anomalies ✓
- EvidenceJson contains pre/post probability vectors and rates ✓
- Determinism: same input → same output ✓
- 3-way market flip (draw becomes favourite) → 1 anomaly ✓
- Mixed pre-match + live snapshots → only live analysed ✓
- [x] Application tests in `Marathon.Application.Tests/UseCases/DetectAnomaliesUseCaseTests.cs` (4 tests):
- Iterates events, calls detector, persists new anomalies ✓
- Skips already-persisted anomalies (dedup logic) ✓
- Tolerates per-event failures (one event throwing doesn't abort the cycle) ✓
- Returns count of new anomalies ✓
### Frontend (Opus + frontend-design) ⬜ NOT STARTED
- [ ] Create `Marathon.UI/Pages/Anomalies/AnomalyFeed.razor`:
- List of anomalies sorted by `DetectedAt` descending
- Each card shows: severity (color-coded by score), event identity, sport icon,
detected timestamp, mini sparkline of pre/post odds
- Click card → expand to show evidence timeline (snapshots before/after suspension)
- Filter: severity threshold, sport, date range
- [ ] Create `Marathon.UI/Components/AnomalyCard.razor` — visually distinctive,
attention-grabbing without being garish; follows frontend-design guidance for
information hierarchy.
- [ ] Add navigation entry to `MainLayout` drawer with notification badge showing
unread anomaly count.
- [ ] Create `Marathon.UI/Services/IAnomalyBrowsingService.cs` + `AnomalyBrowsingService.cs`
+ `AnomalyBrowsingState.cs` + `AnomalyViewModels.cs`
- [ ] Append localization keys to `SharedResource.ru.resx` and `SharedResource.en.resx`
- [ ] Add Settings UI binding for `AnomalyDetectionEnabled` worker flag (see handoff)
- [ ] Frontend tests in `Marathon.UI.Tests/Pages/Anomalies/`:
- bUnit: anomaly card renders evidence timeline
- bUnit: filter narrows the list correctly
## Files to Modify/Create
### Backend (done)
- `src/Marathon.Domain/AnomalyDetection/AnomalyDetector.cs` ✅ created
- `src/Marathon.Domain/AnomalyDetection/SuspensionInterval.cs` ✅ created
- `src/Marathon.Application/Configuration/AnomalyOptions.cs` ✅ created
- `src/Marathon.Application/UseCases/DetectAnomaliesUseCase.cs` ✅ created
- `src/Marathon.Application/ApplicationModule.cs` ✅ modified (added `DetectAnomaliesUseCase` registration)
- `src/Marathon.Infrastructure/Workers/AnomalyDetectionPoller.cs` ✅ created
- `src/Marathon.Infrastructure/Configuration/WorkerOptions.cs` ✅ modified (added `AnomalyDetectionEnabled`)
- `src/Marathon.Infrastructure/InfrastructureModule.cs` ✅ modified (added `AnomalyOptions` binding + poller)
- `src/Marathon.Hosts.WpfBlazor/appsettings.json` ✅ modified (added `Workers:AnomalyDetectionEnabled`)
- `tests/Marathon.Domain.Tests/AnomalyDetection/AnomalyDetectorTests.cs` ✅ created (13 tests)
- `tests/Marathon.Application.Tests/UseCases/DetectAnomaliesUseCaseTests.cs` ✅ created (4 tests)
### Frontend (UI agent owns)
- `src/Marathon.UI/Pages/Anomalies/AnomalyFeed.razor`
- `src/Marathon.UI/Components/AnomalyCard.razor`
- `src/Marathon.UI/Services/IAnomalyBrowsingService.cs`
- `src/Marathon.UI/Services/AnomalyBrowsingService.cs`
- `src/Marathon.UI/Services/AnomalyBrowsingState.cs`
- `src/Marathon.UI/Services/AnomalyViewModels.cs`
- `src/Marathon.UI/Resources/SharedResource.ru.resx` (append new keys)
- `src/Marathon.UI/Resources/SharedResource.en.resx` (append new keys)
- `src/Marathon.UI/MainLayout.razor` or `NavBody.razor` (anomaly nav entry)
- `tests/Marathon.UI.Tests/Pages/Anomalies/**`
## Acceptance Criteria
- [x] Compiles (Big Bang).
- [x] `AnomalyDetector` is a pure function — no I/O, no DI dependencies.
- [x] Configurable thresholds via `appsettings.json`.
- [ ] Visible in Settings page (UI agent must wire `AnomalyDetectionEnabled`).
- [ ] UI clearly distinguishes high/medium/low severity anomalies.
- [ ] Evidence timeline shows the actual snapshots that triggered the detection.
## Notes
- This is the **product's actual differentiator** — quality of detection logic and
evidence presentation matters. Spend time getting the score formula right.
- Implied probability formula: `p = 1 / odds` (then normalize so they sum to 1).
- Big Bang: compile-only smoke check.
## Review Checklist
- [x] Detector is deterministic and pure
- [x] Score calculation correct (verified against hand-computed example in test comments)
- [x] No false positives on synthetic "normal" timelines
- [ ] UI evidence timeline matches stored `EvidenceJson`
- [ ] All strings localized
## Handoff to Next Phase
### Handoff to Phase 7 Frontend (UI) Agent
> **Read this section first.** The backend is fully implemented. You own all `Marathon.UI`
> files listed above. Do NOT touch any backend files.
---
#### What the backend provides
**`DetectAnomaliesUseCase.ExecuteAsync(CancellationToken)`**
- Returns `Task<int>` (count of new anomalies persisted this cycle).
- Called automatically by `AnomalyDetectionPoller` every 60 s (default).
- You do NOT call this from the UI — it is worker-driven.
- The UI only reads from `IAnomalyRepository`.
**`AnomalyDetector` — detection formula (for rendering evidence)**
- Implied probability: `p_i = (1 / rate_i)` for each win side.
- Normalisation: divide each `p_i` by the sum of all raw `p_i` values → they sum to 1.
- Flip score: `max(|p_post[i] p_pre[i]|)` over i ∈ {p1, pDraw?, p2}.
- Favourite-changed test: `argmax(p_pre) != argmax(p_post)`.
- An anomaly is emitted only if BOTH conditions hold: score ≥ threshold AND favourite changed.
**`IAnomalyRepository`** — the UI service should call:
- `ListAsync(CancellationToken)` — all anomalies for the feed page (paginate client-side).
- `GetAsync(Guid id, CancellationToken)` — single anomaly for a detail view.
- There is no `ListByEventAsync` on `IAnomalyRepository` (only on `ISnapshotRepository`).
If you need anomalies for a specific event, filter the full list by `EventId`.
**`Anomaly` entity** — fields available to the UI:
```csharp
Guid Id // GUID primary key
EventId EventId // bookmaker event code (e.g. "26456117")
DateTimeOffset DetectedAt // Moscow TZ (UTC+3)
AnomalyKind Kind // currently always SuspensionFlip
decimal Score // normalised [0, 1] — the largest implied-prob delta
string EvidenceJson // see shape below
```
**`Anomaly.EvidenceJson` shape:**
```json
{
"suspensionGapSeconds": 90,
"preSuspension": {
"capturedAt": "2026-05-10T18:00:00+03:00",
"p1": 0.755,
"pDraw": null,
"p2": 0.245,
"rate1": 1.3,
"rateDraw": null,
"rate2": 4.0
},
"postSuspension": {
"capturedAt": "2026-05-10T18:01:30+03:00",
"p1": 0.245,
"pDraw": null,
"p2": 0.755,
"rate1": 4.0,
"rateDraw": null,
"rate2": 1.3
}
}
```
- `pDraw` / `rateDraw` are `null` for 2-way markets (tennis, etc.).
- Use `System.Text.Json.JsonDocument.Parse(anomaly.EvidenceJson)` to deserialise in the UI.
Or define a `EvidenceDto` record in `AnomalyViewModels.cs` and use `JsonSerializer.Deserialize<EvidenceDto>`.
**Recommended severity buckets** (for color-coding):
| Severity | Score range | MudBlazor color suggestion |
|----------|-------------|---------------------------|
| Low | 0.300.45 | `Color.Warning` |
| Medium | 0.450.60 | `Color.Error` |
| High | 0.60+ | deep red / `Color.Error` + pulsing badge |
---
#### Settings page addition (UI agent must wire)
`Workers:AnomalyDetectionEnabled` (`bool`, default `true`) was added to `WorkerOptions`
and `appsettings.json`. The Phase 5 Settings page needs a toggle for it.
The existing pattern is the same as `LivePollerEnabled` and `UpcomingPollerEnabled`.
---
#### Localization keys to add
Append these to both `SharedResource.ru.resx` and `SharedResource.en.resx`:
| Key | EN value | RU value |
|------------------------------|------------------------------|------------------------------------|
| `Anomaly.Title` | Anomaly Feed | Лента аномалий |
| `Anomaly.Severity.Low` | Low | Низкая |
| `Anomaly.Severity.Medium` | Medium | Средняя |
| `Anomaly.Severity.High` | High | Высокая |
| `Anomaly.Card.DetectedAt` | Detected at | Обнаружено |
| `Anomaly.Card.Score` | Score | Оценка |
| `Anomaly.Card.Kind.SuspensionFlip` | Suspension Flip | Переворот после паузы |
| `Anomaly.Card.GapSeconds` | Suspension gap | Длительность паузы |
| `Anomaly.Evidence.PreSuspension` | Before suspension | До паузы |
| `Anomaly.Evidence.PostSuspension` | After suspension | После паузы |
| `Anomaly.Evidence.Probability` | Implied prob. | Вероятность |
| `Anomaly.Evidence.Rate` | Rate | Коэффициент |
| `Anomaly.Filter.Severity` | Min severity | Минимальная важность |
| `Anomaly.Filter.Sport` | Sport | Вид спорта |
| `Anomaly.Filter.DateRange` | Date range | Диапазон дат |
| `Anomaly.Empty` | No anomalies detected yet. | Аномалии пока не обнаружены. |
| `Settings.AnomalyDetection` | Anomaly detection | Обнаружение аномалий |
| `Settings.AnomalyDetectionEnabled` | Enable anomaly detection | Включить обнаружение аномалий |
---
#### Integration pattern for the UI service
Follow the same split as `EventBrowsingService` (Scoped) + `EventBrowsingState` (Singleton)
documented in CONTEXT.md Phase 6 notes. Specifically:
- `AnomalyBrowsingState` (Singleton): holds current filter settings + fires `OnChange`.
- `AnomalyBrowsingService` (Scoped): resolves `IAnomalyRepository` from the DI scope,
loads anomalies, and maps to view-models (`AnomalyListItem`, `AnomalyDetail`).
- `AnomalyListItem` view-model should include `Severity` (computed from `Score`),
pre-rendered display strings, and the parsed `EvidenceDto`.
---
#### 🟡 Known gaps / deferred items
- **No "last detection run" tracking.** The use case currently loads the last 24 h of
snapshots for ALL events on every cycle. A Phase 8/9 optimisation: track last-run
timestamp per event to limit the snapshot window. Flag this in the UI as "best-effort
coverage window: last 24 h".
- **`Settings.razor` AnomalyDetectionEnabled toggle** — backend option exists, UI wiring
is the UI agent's responsibility.
- **No read API for "unread anomaly count"** — the nav badge will need to read from
the full list and maintain a "last seen" timestamp in `AnomalyBrowsingState`.
Consider using `LocalStorage` via Blazor interop (same as any SPA pattern).