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

14 KiB
Raw Blame History

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, 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
  • Add AnomalyOptions POCO bound to Anomaly:* (in Marathon.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 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
  • Implement AnomalyDetectionPoller : BackgroundService in Marathon.Infrastructure/Workers/:
    • Runs every Anomaly:DetectionIntervalSeconds (default 60s)
    • Calls DetectAnomaliesUseCase
    • Gated by Workers:AnomalyDetectionEnabled (default true)
  • Add WorkerOptions.AnomalyDetectionEnabled (default true)
  • Register DetectAnomaliesUseCase as Scoped in ApplicationModule
  • Bind AnomalyOptions and register AnomalyDetectionPoller in InfrastructureModule
  • Update appsettings.json — add Workers:AnomalyDetectionEnabled: true (all 4 Anomaly:* 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 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

  • Compiles (Big Bang).
  • AnomalyDetector is 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.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:

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 / 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).