Frontend portion of Phase 7. Backend (commit a6ff368) had already shipped
the AnomalyDetector, DetectAnomaliesUseCase, AnomalyDetectionPoller, and
all DI wiring. This commit adds the user-facing surfaces.
New surfaces (Option A routing — folder-per-feature):
- Pages/Anomalies/AnomalyFeed.razor (@page /anomalies) — replaces the
Phase 5 placeholder with a severity-coded card stream, filter chips
(severity / sport / date), unread-count summary, 'Mark all read' action.
- Pages/Anomalies/Detail.razor (@page /anomalies/{id:guid}) — m-detail-header
lockup + AnomalyEvidence panel + back link to /events/{eventCode}.
New components:
- AnomalyCard.razor — severity-tinted left border (signal-red on High,
amber on Medium, neutral on Low) + SeverityBadge pill + sport icon +
pre→post tabular-mono rate strip + relative time. Click navigates.
- SeverityBadge.razor — small pill mapping score → bucket per backend
handoff (Low <0.45, Medium <0.60, High ≥0.60).
- AnomalyEvidence.razor — two-column pre/post panel with implied-prob
bars + raw rates; favourite-swap callout when argmax(p_pre) ≠ argmax(p_post);
signal-red 3px left border on the post column. Handles 2-way (no draw).
State + service split mirrors Phase 6's pattern:
- AnomalyViewModels.cs — AnomalyListItem / AnomalyDetailVm / Severity enum
/ AnomalyEvidenceSnapshot record. Severity computed in the view-model
from Score.
- IAnomalyBrowsingService / AnomalyBrowsingService — wraps IAnomalyRepository,
parses Anomaly.EvidenceJson into typed view-models, applies filters
client-side. Methods: ListAsync(filter, ct), GetByIdAsync(id, ct),
GetUnreadCountAsync(since, ct).
- AnomalyBrowsingState — Singleton holding AnomalyFilter (severity threshold,
sport set, date range) + LastSeenUtc + cached UnreadCount. OnChange event.
Nav badge:
- NavBody.razor subscribes to AnomalyBrowsingState.OnChange, renders a
pulsing red m-nav__badge when UnreadCount > 0. Badge resets when the
user clicks 'Mark all read' on the feed toolbar.
Settings toggle:
- Settings.razor — added Workers:AnomalyDetectionEnabled toggle (backend
added the flag). Localized via Settings.Worker.AnomalyDetectionEnabled.
- Marathon.UI.Services.WorkerOptions mirror — added AnomalyDetectionEnabled
(default true).
Localization: +30 RU/EN keys following the dot-segmented convention
(Anomaly.*, Settings.Worker.AnomalyDetectionEnabled). Full key parity verified.
Tests (+31 bUnit, all passing):
- AnomalyFeedTests, AnomalyDetailTests
- AnomalyCardTests, SeverityBadgeTests, AnomalyEvidenceTests
- FakeAnomalyBrowsingService support fake registered in MarathonTestContext.
Routing: deleted the Phase 5 Pages/Anomalies.razor placeholder; new feed
page lives at Pages/Anomalies/AnomalyFeed.razor.
Build: 0 warnings, 0 errors.
Tests: Domain 109 + Application 19 + Infrastructure 80 + UI 68 = 276/276
(baseline 245, +31 new bUnit tests, no regressions).
Phase 7 status: ✅ Done (backend + frontend both complete, awaiting review).
Known deferral: AnomalyBrowsingState.LastSeenUtc is in-memory only; the
unread-count badge resets on app restart. Acceptable for now; Phase 9 may
extend ISettingsWriter or add an ILastSeenStore.
18 KiB
Phase 7: Anomaly Detection (Suspension + Flip)
Status: ✅ Done 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) ✅ COMPLETE
- 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, pre→post odds strip
- Click card → navigate to
/anomalies/{id}detail page - Filter: severity threshold (Low/Med/High chips), sport chips, date range
- List of anomalies sorted by
- Create
Marathon.UI/Pages/Anomalies/Detail.razor(per-anomaly page withAnomalyEvidencepanel + link back to event) - Create
Marathon.UI/Components/AnomalyCard.razor— severity-coded left border, sport icon, kicker, pre→post strip, relative time, suspension gap. - Create
Marathon.UI/Components/SeverityBadge.razor— pill: Low (neutral), Medium (amber), High (signal-red, pulsing). - Create
Marathon.UI/Components/AnomalyEvidence.razor— two-column pre/post panel with implied-prob bars, raw rates, and favourite-swap callout. - Add navigation entry to
NavBody.razordrawer with pulsing red badge showing unread anomaly count. - Create
Marathon.UI/Services/IAnomalyBrowsingService.cs+AnomalyBrowsingService.cs+AnomalyBrowsingState.cs+AnomalyViewModels.cs - Append
Anomaly.*localization keys toSharedResource.ru.resxandSharedResource.en.resx(28 keys, full RU/EN parity) - Add Settings UI binding for
Workers:AnomalyDetectionEnabledworker flag - 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✅ 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 (
Workers:AnomalyDetectionEnabledtoggle in WORKERS section). - UI clearly distinguishes high/medium/low severity anomalies (signal-red / amber / neutral pill + matching left border on each card).
- Evidence timeline shows the actual snapshots that triggered the detection (parsed
EvidenceJsonrendered in the two-columnAnomalyEvidencepanel 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
- 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(AnomalyBrowsingServiceparses the JSON via System.Text.Json andAnomalyEvidencerenders both bracket snapshots verbatim — no synthesised data). - All strings localized (RU + EN parity for the 28 new
Anomaly.*+ 2 newSettings.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.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).
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 withPages/Anomalies/AnomalyFeed.razor./anomalies/{id:guid}— new detail pagePages/Anomalies/Detail.razor.- The
Pages/Anomalies.razorplaceholder file was deleted (Option A from the brief).
Test infrastructure
tests/Marathon.UI.Tests/Support/FakeAnomalyBrowsingService.cs— in-memory fake withMakeItem(...)andMakeSnapshot(...)factory helpers.MarathonTestContextnow also registersAnomalyBrowsingState(singleton) + the fake. Phase 8 tests can follow the same factory pattern forIResultBrowsingService.
Localization keys added
28 Anomaly.* keys (RU+EN full parity) plus Settings.Workers.AnomalyDetectionEnabled and its .Hint. All under the <Surface>.<Element> convention from Phase 5/6.