# Phase 7: Anomaly Detection (Suspension + Flip) **Status:** ✅ Done **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) ✅ COMPLETE - [x] 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, pre→post odds strip - Click card → navigate to `/anomalies/{id}` detail page - Filter: severity threshold (Low/Med/High chips), sport chips, date range - [x] Create `Marathon.UI/Pages/Anomalies/Detail.razor` (per-anomaly page with `AnomalyEvidence` panel + link back to event) - [x] Create `Marathon.UI/Components/AnomalyCard.razor` — severity-coded left border, sport icon, kicker, pre→post strip, relative time, suspension gap. - [x] Create `Marathon.UI/Components/SeverityBadge.razor` — pill: Low (neutral), Medium (amber), High (signal-red, pulsing). - [x] Create `Marathon.UI/Components/AnomalyEvidence.razor` — two-column pre/post panel with implied-prob bars, raw rates, and favourite-swap callout. - [x] Add navigation entry to `NavBody.razor` drawer with pulsing red badge showing unread anomaly count. - [x] Create `Marathon.UI/Services/IAnomalyBrowsingService.cs` + `AnomalyBrowsingService.cs` + `AnomalyBrowsingState.cs` + `AnomalyViewModels.cs` - [x] Append `Anomaly.*` localization keys to `SharedResource.ru.resx` and `SharedResource.en.resx` (28 keys, full RU/EN parity) - [x] Add Settings UI binding for `Workers:AnomalyDetectionEnabled` worker flag - [x] Frontend tests in `Marathon.UI.Tests/Pages/Anomalies/` + `Components/`: - `SeverityBadgeTests` — score → severity bucket → pill class (9 tests) - `AnomalyCardTests` — severity styling, click callback, 2-way vs 3-way (6 tests) - `AnomalyEvidenceTests` — two-column render, favourite-swap callout, 2-way row count, suspension duration formatting (6 tests) - `AnomalyFeedTests` — seeded list render, empty state, severity/sport chip filtering, mark-read state mutation (5 tests) - `AnomalyDetailTests` — not-found fallback, evidence + back-link rendering, suspension duration in header (4 tests) ## 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`. - [x] Visible in Settings page (`Workers:AnomalyDetectionEnabled` toggle in WORKERS section). - [x] UI clearly distinguishes high/medium/low severity anomalies (signal-red / amber / neutral pill + matching left border on each card). - [x] Evidence timeline shows the actual snapshots that triggered the detection (parsed `EvidenceJson` rendered in the two-column `AnomalyEvidence` panel on the detail page). ## 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 - [x] UI evidence timeline matches stored `EvidenceJson` (`AnomalyBrowsingService` parses the JSON via System.Text.Json and `AnomalyEvidence` renders both bracket snapshots verbatim — no synthesised data). - [x] All strings localized (RU + EN parity for the 28 new `Anomaly.*` + 2 new `Settings.Workers.AnomalyDetectionEnabled*` keys). ## 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). --- ### Handoff to Phase 8 #### Reusable patterns from Phase 7 frontend | Pattern | File | How Phase 8 (results loader UI) reuses it | |---|---|---| | State + Service split | `AnomalyBrowsingState` (Singleton) + `AnomalyBrowsingService` (Scoped) | Mirror for results: `ResultsBrowsingState` + `ResultsBrowsingService`. Pages never inject `IResultRepository` directly. | | View-model factory | `AnomalyViewModels.cs` (`AnomalyListItem`, `AnomalyDetailVm`, `AnomalyEvidenceSnapshot`) | Phase 8 should expose `ResultListItem` / `ResultDetail` records — keep the UI shielded from EF graphs. | | Severity-style chips | `AnomalyFeed.razor` toolbar (`m-chip` w/ `aria-pressed`) | Match the chip cadence for results filters (sport, status: pending/complete). | | Evidence panel | `AnomalyEvidence.razor` two-column layout | If results show "predicted vs final" deltas, reuse the same paired-card structure. | | Severity-coded card | `AnomalyCard.razor` left-border colour driven by severity | Pattern transfers to "result outcome" badging if needed (winner/loser/draw). | | Nav badge | `NavBody.razor` `m-nav__badge` (signal-red, pulsing) | Phase 8 may want a similar "new results" badge. CSS class is already factored. | #### New CSS surfaces introduced - `.m-severity` / `.m-severity--{low,medium,high}` — small pill, severity-coded. - `.m-anomaly-card` / `.m-anomaly-card--{low,medium,high}` — feed card with severity-coded left border. - `.m-evidence` / `.m-evidence__col` / `.m-evidence__bar` — two-column evidence panel. - `.m-anomaly-feed__stats` — at-a-glance count strip (Total / High / Medium / Low). - `.m-nav__badge` — signal-red pulsing pill on the drawer link. #### Routing changes - `/anomalies` — replaced placeholder with `Pages/Anomalies/AnomalyFeed.razor`. - `/anomalies/{id:guid}` — new detail page `Pages/Anomalies/Detail.razor`. - The `Pages/Anomalies.razor` placeholder file was deleted (Option A from the brief). #### Test infrastructure - `tests/Marathon.UI.Tests/Support/FakeAnomalyBrowsingService.cs` — in-memory fake with `MakeItem(...)` and `MakeSnapshot(...)` factory helpers. - `MarathonTestContext` now also registers `AnomalyBrowsingState` (singleton) + the fake. Phase 8 tests can follow the same factory pattern for `IResultBrowsingService`. #### Localization keys added 28 `Anomaly.*` keys (RU+EN full parity) plus `Settings.Workers.AnomalyDetectionEnabled` and its `.Hint`. All under the `.` convention from Phase 5/6.