# 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` for an event, returns `IReadOnlyList` - 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` (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`. **Recommended severity buckets** (for color-coding): | Severity | Score range | MudBlazor color suggestion | |----------|-------------|---------------------------| | Low | 0.30–0.45 | `Color.Warning` | | Medium | 0.45–0.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).