- 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)
14 KiB
Phase 7: Anomaly Detection (Suspension + Flip)
Status: 🔨 Backend Done — Awaiting Frontend (Opus) Parent plan: 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
- Implement
Marathon.Domain/AnomalyDetection/AnomalyDetector.cs:- Pure domain logic — takes
IReadOnlyList<OddsSnapshot>for an event, returnsIReadOnlyList<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 ≥
OddsFlipThresholdAND the favourite changed (argmax differs), emit anAnomaly(Kind=SuspensionFlip, Score, EvidenceJson)whereEvidenceJsoncontains the snapshots bracketing the suspension
- Pure domain logic — takes
- Add
AnomalyOptionsPOCO bound toAnomaly:*(inMarathon.Application/Configuration/):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; } - Implement
DetectAnomaliesUseCaseinMarathon.Application/UseCases/:- Iterate over all events and load snapshots from last 24 h
- Invoke
AnomalyDetectorper event - Persist new anomalies via
IAnomalyRepositorywith dedup logic
- Implement
AnomalyDetectionPoller : BackgroundServiceinMarathon.Infrastructure/Workers/:- Runs every
Anomaly:DetectionIntervalSeconds(default 60s) - Calls
DetectAnomaliesUseCase - Gated by
Workers:AnomalyDetectionEnabled(defaulttrue)
- Runs every
- Add
WorkerOptions.AnomalyDetectionEnabled(defaulttrue) - Register
DetectAnomaliesUseCaseas Scoped inApplicationModule - Bind
AnomalyOptionsand registerAnomalyDetectionPollerinInfrastructureModule - Update
appsettings.json— addWorkers:AnomalyDetectionEnabled: true(all 4Anomaly:*keys already existed from Phase 5) - 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 ✓
- 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
DetectedAtdescending - 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
- List of anomalies sorted by
- Create
Marathon.UI/Components/AnomalyCard.razor— visually distinctive, attention-grabbing without being garish; follows frontend-design guidance for information hierarchy. - Add navigation entry to
MainLayoutdrawer with notification badge showing unread anomaly count. - Create
Marathon.UI/Services/IAnomalyBrowsingService.cs+AnomalyBrowsingService.csAnomalyBrowsingState.cs+AnomalyViewModels.cs
- Append localization keys to
SharedResource.ru.resxandSharedResource.en.resx - Add Settings UI binding for
AnomalyDetectionEnabledworker 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✅ createdsrc/Marathon.Domain/AnomalyDetection/SuspensionInterval.cs✅ createdsrc/Marathon.Application/Configuration/AnomalyOptions.cs✅ createdsrc/Marathon.Application/UseCases/DetectAnomaliesUseCase.cs✅ createdsrc/Marathon.Application/ApplicationModule.cs✅ modified (addedDetectAnomaliesUseCaseregistration)src/Marathon.Infrastructure/Workers/AnomalyDetectionPoller.cs✅ createdsrc/Marathon.Infrastructure/Configuration/WorkerOptions.cs✅ modified (addedAnomalyDetectionEnabled)src/Marathon.Infrastructure/InfrastructureModule.cs✅ modified (addedAnomalyOptionsbinding + poller)src/Marathon.Hosts.WpfBlazor/appsettings.json✅ modified (addedWorkers: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.razorsrc/Marathon.UI/Components/AnomalyCard.razorsrc/Marathon.UI/Services/IAnomalyBrowsingService.cssrc/Marathon.UI/Services/AnomalyBrowsingService.cssrc/Marathon.UI/Services/AnomalyBrowsingState.cssrc/Marathon.UI/Services/AnomalyViewModels.cssrc/Marathon.UI/Resources/SharedResource.ru.resx(append new keys)src/Marathon.UI/Resources/SharedResource.en.resx(append new keys)src/Marathon.UI/MainLayout.razororNavBody.razor(anomaly nav entry)tests/Marathon.UI.Tests/Pages/Anomalies/**
Acceptance Criteria
- Compiles (Big Bang).
AnomalyDetectoris a pure function — no I/O, no DI dependencies.- 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
- Detector is deterministic and pure
- Score calculation correct (verified against hand-computed example in test comments)
- 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.UIfiles 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
AnomalyDetectionPollerevery 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_iby the sum of all rawp_ivalues → 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
ListByEventAsynconIAnomalyRepository(only onISnapshotRepository). If you need anomalies for a specific event, filter the full list byEventId.
Anomaly entity — fields available to the UI:
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:
{
"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/rateDrawarenullfor 2-way markets (tennis, etc.).- Use
System.Text.Json.JsonDocument.Parse(anomaly.EvidenceJson)to deserialise in the UI. Or define aEvidenceDtorecord inAnomalyViewModels.csand useJsonSerializer.Deserialize<EvidenceDto>.
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 + firesOnChange.AnomalyBrowsingService(Scoped): resolvesIAnomalyRepositoryfrom the DI scope, loads anomalies, and maps to view-models (AnomalyListItem,AnomalyDetail).AnomalyListItemview-model should includeSeverity(computed fromScore), pre-rendered display strings, and the parsedEvidenceDto.
🟡 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.razorAnomalyDetectionEnabled 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 usingLocalStoragevia Blazor interop (same as any SPA pattern).