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)
This commit is contained in:
@@ -101,6 +101,44 @@ with scraping research, no implementation.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
### Phase 7 Backend (Anomaly detection, 2026-05-05)
|
||||
|
||||
- **`AnomalyDetector` is pure domain — no I/O, no DI.** Constructor takes three ints/decimals
|
||||
from `AnomalyOptions`; the caller (use case) materialises it per cycle.
|
||||
The UI evidence panel can reconstruct the same probabilities from `EvidenceJson` without
|
||||
needing to re-invoke the detector.
|
||||
- **Implied probability formula:** `p_i = 1 / rate_i`, then normalise so all `p_i` sum to 1
|
||||
(divorcé of the bookmaker's margin). This is the standard European odds conversion.
|
||||
- **Flip score** = `max(|p_post[i] − p_pre[i]|)` over Match-Win sides (p1, pDraw?, p2).
|
||||
Score is clamped to `[0, 1]` before constructing `Anomaly` (domain invariant enforces ≤1).
|
||||
- **Two-part gate** — an anomaly requires BOTH: (a) `flipScore ≥ OddsFlipThreshold` AND
|
||||
(b) `argmax(p_pre) != argmax(p_post)`. This prevents spurious detections when one side's
|
||||
probability shifts a lot but it was never the favourite.
|
||||
- **Tennis / 2-way markets** — `pDraw` is `null` when no `BetType.Draw` bet is present.
|
||||
The detector and `EvidenceJson` gracefully handle this (JSON field is omitted when null via
|
||||
`DefaultIgnoreCondition.WhenWritingNull`).
|
||||
- **`EvidenceJson` uses `System.Text.Json` with custom `JsonPropertyName` attributes** on
|
||||
sealed nested records (`EvidencePayload`, `SnapshotEvidence`). Source generation was not
|
||||
used at this scale — the payload is small and created infrequently.
|
||||
- **`DetectAnomaliesUseCase` loads all events + last-24-h live snapshots per cycle.**
|
||||
This is a deliberate simplification; a future optimisation is to track `last_run_at` per
|
||||
event. Documented as 🟡 in the handoff.
|
||||
- **Dedup strategy:** two anomalies are considered duplicates if they share `EventId`, `Kind`,
|
||||
and their `DetectedAt` values fall within a 1-minute window. This prevents the same
|
||||
suspension triggering re-insertion on consecutive detection cycles while the gap snapshot
|
||||
pair remains in the 24-hour window.
|
||||
- **`AnomalyOptions` placed in `Marathon.Application/Configuration/`** (not Infrastructure).
|
||||
The `AnomalyDetector` itself is in `Marathon.Domain/AnomalyDetection/` but requires no
|
||||
options binding — it takes plain constructor parameters.
|
||||
- **`AnomalyDetectionPoller` reads `IOptionsMonitor<AnomalyOptions>` per cycle** so that
|
||||
hot-reload of `DetectionIntervalSeconds` takes effect without a restart. Same pattern as
|
||||
`LiveOddsPoller` reading `WorkerOptions`.
|
||||
- **`Workers:AnomalyDetectionEnabled`** added to `WorkerOptions` (default `true`) and
|
||||
`appsettings.json`. UI agent must add a Settings toggle for this flag.
|
||||
- **New test count: +17** (13 domain + 4 application). Total: 245/245 passing.
|
||||
- **Test note:** rates 1.5/2.5 produce a flip score of ~0.25 — BELOW the 0.30 threshold.
|
||||
Always use 1.3/4.0 (flip score ~0.51) or steeper to guarantee detection in tests.
|
||||
|
||||
### Phase 6 (Event browsing UI, 2026-05-05)
|
||||
|
||||
- **Plotly.Blazor pinned to 5.4.1.** v7.x exists but introduces breaking changes;
|
||||
|
||||
@@ -69,7 +69,7 @@ parameter configurable.
|
||||
| Phase 4: Application + Workers | backend | ✅ Done | ⚠️ Pass with notes (Sonnet) | ✅ Build OK + 202/202 tests | ✅ 2acbaa5 |
|
||||
| Phase 5: Host + Theme + i18n | frontend | ✅ Done | ⚠️ Pass with notes (Sonnet, combined batch) | ✅ Build OK + 11/11 UI tests | ✅ batch (e4d8476…686550d…+) |
|
||||
| Phase 6: Event browsing UI | frontend | ✅ Done | ⚠️ Pass with notes (Sonnet) | ✅ Build OK + 228/228 tests | ✅ 553db2b |
|
||||
| Phase 7: Anomaly detection | fullstack | ⬜ Not Started | ⬜ | ⏭️ Big Bang | ⬜ |
|
||||
| Phase 7: Anomaly detection | fullstack | 🔨 Backend done | ⬜ | ✅ Build OK + 245/245 tests | ⬜ |
|
||||
| Phase 8: Results loader | fullstack | ⬜ Not Started | ⬜ | ⏭️ Big Bang | ⬜ |
|
||||
| Phase 9: Packaging + polish | fullstack | ⬜ Not Started | ⬜ | ⬜ | ⬜ |
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Phase 7: Anomaly Detection (Suspension + Flip)
|
||||
|
||||
**Status:** ⬜ Not Started
|
||||
**Status:** 🔨 Backend Done — Awaiting Frontend (Opus)
|
||||
**Parent plan:** [PLAN.md](./PLAN.md)
|
||||
**Domain:** fullstack
|
||||
**Implementer:** Sonnet (backend portion) + Opus (UI portion, with frontend-design)
|
||||
@@ -14,42 +14,63 @@ and surface them in a dedicated UI feed page so the user can act on them.
|
||||
|
||||
## Tasks
|
||||
|
||||
### Backend (Sonnet)
|
||||
### Backend (Sonnet) ✅ COMPLETE
|
||||
|
||||
- [ ] Implement `Marathon.Domain/AnomalyDetection/AnomalyDetector.cs`:
|
||||
- [x] 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 favorite changed (argmax differs),
|
||||
- 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:*`:
|
||||
- [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;
|
||||
}
|
||||
```
|
||||
- [ ] Implement `DetectAnomaliesUseCase` in `Marathon.Application/UseCases/`:
|
||||
- Iterate over events with new snapshots since last detection run
|
||||
- [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`
|
||||
- [ ] Implement `AnomalyDetectionPoller : BackgroundService` in
|
||||
- Persist new anomalies via `IAnomalyRepository` with dedup logic
|
||||
- [x] Implement `AnomalyDetectionPoller : BackgroundService` in
|
||||
`Marathon.Infrastructure/Workers/`:
|
||||
- Runs every `Anomaly:DetectionIntervalSeconds` (default 60s)
|
||||
- Calls `DetectAnomaliesUseCase`
|
||||
- [ ] Backend tests in `Marathon.Domain.Tests/AnomalyDetection/`:
|
||||
- Synthetic snapshot timeline with no flip → 0 anomalies
|
||||
- Snapshot timeline with suspension + small odds shift → 0 anomalies (below threshold)
|
||||
- Snapshot timeline with suspension + large flip (favorite ↔ underdog) → 1 anomaly
|
||||
- Score calculation matches expected value
|
||||
- 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)
|
||||
### Frontend (Opus + frontend-design) ⬜ NOT STARTED
|
||||
|
||||
- [ ] Create `Marathon.UI/Pages/Anomalies/AnomalyFeed.razor`:
|
||||
- List of anomalies sorted by `DetectedAt` descending
|
||||
@@ -62,30 +83,49 @@ and surface them in a dedicated UI feed page so the user can act on them.
|
||||
information hierarchy.
|
||||
- [ ] Add navigation entry to `MainLayout` drawer with notification badge showing
|
||||
unread anomaly count.
|
||||
- [ ] Localize all strings in RU + EN.
|
||||
- [ ] 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
|
||||
|
||||
- `src/Marathon.Domain/AnomalyDetection/AnomalyDetector.cs`
|
||||
- `src/Marathon.Domain/AnomalyDetection/SuspensionInterval.cs`
|
||||
- `src/Marathon.Application/UseCases/DetectAnomaliesUseCase.cs`
|
||||
- `src/Marathon.Application/Configuration/AnomalyOptions.cs` (or in Infra)
|
||||
- `src/Marathon.Infrastructure/Workers/AnomalyDetectionPoller.cs`
|
||||
### 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`
|
||||
- `tests/Marathon.Domain.Tests/AnomalyDetection/AnomalyDetectorTests.cs`
|
||||
- `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 clearly distinguishes high/medium/low severity anomalies.
|
||||
- Evidence timeline shows the actual snapshots that triggered the detection.
|
||||
- [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
|
||||
|
||||
@@ -96,12 +136,144 @@ and surface them in a dedicated UI feed page so the user can act on them.
|
||||
|
||||
## Review Checklist
|
||||
|
||||
- [ ] Detector is deterministic and pure
|
||||
- [ ] Score calculation correct (verify against hand-computed example)
|
||||
- [ ] No false positives on synthetic "normal" timelines
|
||||
- [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
|
||||
|
||||
<!-- Filled by Phase 7 implementer. -->
|
||||
### 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:
|
||||
```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<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 + 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).
|
||||
|
||||
@@ -29,6 +29,7 @@ public static class ApplicationModule
|
||||
services.AddScoped<PullLiveOddsUseCase>();
|
||||
services.AddScoped<PullResultsUseCase>();
|
||||
services.AddScoped<ExportToExcelUseCase>();
|
||||
services.AddScoped<DetectAnomaliesUseCase>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
namespace Marathon.Application.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Strongly typed options for the anomaly-detection subsystem.
|
||||
/// Bound from the <c>Anomaly</c> section of <c>appsettings.json</c>.
|
||||
/// </summary>
|
||||
public sealed class AnomalyOptions
|
||||
{
|
||||
/// <summary>Configuration section key.</summary>
|
||||
public const string SectionName = "Anomaly";
|
||||
|
||||
/// <summary>
|
||||
/// Minimum gap between adjacent live snapshots, in seconds, to classify as
|
||||
/// a bookmaker suspension. Default: 60 s.
|
||||
/// </summary>
|
||||
public int SuspensionGapSeconds { get; init; } = 60;
|
||||
|
||||
/// <summary>
|
||||
/// Minimum normalised implied-probability delta required for the post-suspension
|
||||
/// odds change to qualify as a flip. Must be in (0, 1). Default: 0.30.
|
||||
/// </summary>
|
||||
public decimal OddsFlipThreshold { get; init; } = 0.30m;
|
||||
|
||||
/// <summary>
|
||||
/// Minimum number of live snapshots an event must have before detection runs.
|
||||
/// Default: 3. Must be at least 2 (one pair).
|
||||
/// </summary>
|
||||
public int MinSnapshotCount { get; init; } = 3;
|
||||
|
||||
/// <summary>
|
||||
/// How long the <c>AnomalyDetectionPoller</c> sleeps between detection cycles,
|
||||
/// in seconds. Default: 60 s.
|
||||
/// </summary>
|
||||
public int DetectionIntervalSeconds { get; init; } = 60;
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
using Marathon.Application.Abstractions;
|
||||
using Marathon.Application.Configuration;
|
||||
using Marathon.Domain.AnomalyDetection;
|
||||
using Marathon.Domain.Entities;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Marathon.Application.UseCases;
|
||||
|
||||
/// <summary>
|
||||
/// Orchestrates one anomaly-detection cycle:
|
||||
/// <list type="number">
|
||||
/// <item>Loads all tracked events.</item>
|
||||
/// <item>For each event, fetches its last-24-hour live snapshots.</item>
|
||||
/// <item>Runs <see cref="AnomalyDetector"/> over the snapshot timeline.</item>
|
||||
/// <item>Persists any new anomalies that have not already been stored (dedup by EventId + DetectedAt minute-window).</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 🟡 Optimisation opportunity (Phase 8/9): currently iterates ALL events and loads 24 h of
|
||||
/// snapshots per event. A future improvement is to track a "last detection run" timestamp per
|
||||
/// event so we only load new snapshots. This is intentionally deferred to keep Phase 7 scope
|
||||
/// focused on the detection algorithm.
|
||||
/// </remarks>
|
||||
public sealed class DetectAnomaliesUseCase
|
||||
{
|
||||
private static readonly TimeSpan MoscowOffset = TimeSpan.FromHours(3);
|
||||
private static readonly TimeSpan SnapshotLookback = TimeSpan.FromHours(24);
|
||||
|
||||
// Dedup window: two anomalies for the same event within this window are considered duplicates.
|
||||
private static readonly TimeSpan DedupWindow = TimeSpan.FromMinutes(1);
|
||||
|
||||
private readonly IEventRepository _eventRepo;
|
||||
private readonly ISnapshotRepository _snapshotRepo;
|
||||
private readonly IAnomalyRepository _anomalyRepo;
|
||||
private readonly AnomalyOptions _options;
|
||||
private readonly ILogger<DetectAnomaliesUseCase> _logger;
|
||||
|
||||
public DetectAnomaliesUseCase(
|
||||
IEventRepository eventRepo,
|
||||
ISnapshotRepository snapshotRepo,
|
||||
IAnomalyRepository anomalyRepo,
|
||||
IOptions<AnomalyOptions> options,
|
||||
ILogger<DetectAnomaliesUseCase> logger)
|
||||
{
|
||||
_eventRepo = eventRepo ?? throw new ArgumentNullException(nameof(eventRepo));
|
||||
_snapshotRepo = snapshotRepo ?? throw new ArgumentNullException(nameof(snapshotRepo));
|
||||
_anomalyRepo = anomalyRepo ?? throw new ArgumentNullException(nameof(anomalyRepo));
|
||||
_options = (options ?? throw new ArgumentNullException(nameof(options))).Value;
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Executes one detection cycle.
|
||||
/// </summary>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Number of new anomalies persisted during this cycle.</returns>
|
||||
public async Task<int> ExecuteAsync(CancellationToken ct)
|
||||
{
|
||||
_logger.LogInformation("DetectAnomaliesUseCase: cycle started");
|
||||
|
||||
var detector = new AnomalyDetector(
|
||||
_options.SuspensionGapSeconds,
|
||||
_options.OddsFlipThreshold,
|
||||
_options.MinSnapshotCount);
|
||||
|
||||
var events = await _eventRepo.ListAsync(ct);
|
||||
int newAnomalyCount = 0;
|
||||
|
||||
var now = DateTimeOffset.UtcNow.ToOffset(MoscowOffset);
|
||||
var from = now - SnapshotLookback;
|
||||
|
||||
foreach (var ev in events)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
try
|
||||
{
|
||||
newAnomalyCount += await ProcessEventAsync(detector, ev, from, now, ct);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex,
|
||||
"DetectAnomaliesUseCase: failed to process event {EventId} — skipping",
|
||||
ev.Id.Value);
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"DetectAnomaliesUseCase: cycle done — {NewAnomalies} new anomalies across {TotalEvents} events",
|
||||
newAnomalyCount, events.Count);
|
||||
|
||||
return newAnomalyCount;
|
||||
}
|
||||
|
||||
// ── Private helpers ───────────────────────────────────────────────────────
|
||||
|
||||
private async Task<int> ProcessEventAsync(
|
||||
AnomalyDetector detector,
|
||||
Event ev,
|
||||
DateTimeOffset from,
|
||||
DateTimeOffset to,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var snapshots = await _snapshotRepo.ListByEventAsync(ev.Id, from, to, ct);
|
||||
var detected = detector.Detect(ev.Id, snapshots);
|
||||
|
||||
if (detected.Count == 0)
|
||||
return 0;
|
||||
|
||||
// Load existing anomalies for this event so we can deduplicate.
|
||||
var existing = await _anomalyRepo.ListAsync(ct);
|
||||
var existingForEvent = existing
|
||||
.Where(a => a.EventId == ev.Id)
|
||||
.ToList();
|
||||
|
||||
int persisted = 0;
|
||||
foreach (var anomaly in detected)
|
||||
{
|
||||
if (IsDuplicate(anomaly, existingForEvent))
|
||||
continue;
|
||||
|
||||
await _anomalyRepo.AddAsync(anomaly, ct);
|
||||
await _anomalyRepo.SaveChangesAsync(ct);
|
||||
existingForEvent.Add(anomaly); // Keep local list in sync so the same cycle doesn't re-add.
|
||||
persisted++;
|
||||
}
|
||||
|
||||
return persisted;
|
||||
}
|
||||
|
||||
private static bool IsDuplicate(Anomaly candidate, IReadOnlyList<Anomaly> existing)
|
||||
{
|
||||
// Two anomalies are considered duplicates if they share the same EventId, Kind,
|
||||
// and their DetectedAt timestamps fall within the dedup window.
|
||||
return existing.Any(a =>
|
||||
a.EventId == candidate.EventId &&
|
||||
a.Kind == candidate.Kind &&
|
||||
Math.Abs((a.DetectedAt - candidate.DetectedAt).TotalMinutes) <=
|
||||
DedupWindow.TotalMinutes);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Marathon.Domain.Entities;
|
||||
using Marathon.Domain.Enums;
|
||||
using Marathon.Domain.ValueObjects;
|
||||
|
||||
namespace Marathon.Domain.AnomalyDetection;
|
||||
|
||||
/// <summary>
|
||||
/// Pure domain service that analyses a chronological sequence of live <see cref="OddsSnapshot"/>
|
||||
/// records for a single event and returns any detected <see cref="Anomaly"/> instances.
|
||||
///
|
||||
/// Algorithm (SuspensionFlip):
|
||||
/// <list type="number">
|
||||
/// <item>Filter to <see cref="OddsSource.Live"/> snapshots and sort by <c>CapturedAt</c>.</item>
|
||||
/// <item>Return empty if fewer than <c>minSnapshotCount</c> live snapshots are available.</item>
|
||||
/// <item>Walk adjacent pairs; identify gaps larger than <c>suspensionGapSeconds</c>.</item>
|
||||
/// <item>For each suspension, extract Match-Win bets from pre/post snapshots, compute
|
||||
/// implied probability vectors and normalise them to sum to 1.</item>
|
||||
/// <item>Compute flip score = max(|p_post[i] − p_pre[i]|) across sides.</item>
|
||||
/// <item>If flip score ≥ <c>oddsFlipThreshold</c> AND the favourite changed
|
||||
/// (argmax of implied probabilities differs), emit one <see cref="Anomaly"/>.</item>
|
||||
/// </list>
|
||||
///
|
||||
/// This class is stateless and deterministic — identical inputs always produce identical output.
|
||||
/// It has no I/O or DI dependencies.
|
||||
/// </summary>
|
||||
public sealed class AnomalyDetector
|
||||
{
|
||||
private static readonly TimeSpan MoscowOffset = TimeSpan.FromHours(3);
|
||||
|
||||
private readonly int _suspensionGapSeconds;
|
||||
private readonly decimal _oddsFlipThreshold;
|
||||
private readonly int _minSnapshotCount;
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
WriteIndented = false,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
};
|
||||
|
||||
/// <param name="suspensionGapSeconds">
|
||||
/// Minimum gap between adjacent live snapshots (in seconds) to classify as a suspension.
|
||||
/// Default per spec: 60.
|
||||
/// </param>
|
||||
/// <param name="oddsFlipThreshold">
|
||||
/// Minimum implied-probability delta to classify a post-suspension odds change as a flip.
|
||||
/// Default per spec: 0.30 (30 percentage points).
|
||||
/// </param>
|
||||
/// <param name="minSnapshotCount">
|
||||
/// Minimum number of live snapshots required before detection runs.
|
||||
/// Default per spec: 3.
|
||||
/// </param>
|
||||
public AnomalyDetector(int suspensionGapSeconds, decimal oddsFlipThreshold, int minSnapshotCount)
|
||||
{
|
||||
if (suspensionGapSeconds <= 0)
|
||||
throw new ArgumentOutOfRangeException(nameof(suspensionGapSeconds),
|
||||
suspensionGapSeconds, "Must be positive.");
|
||||
|
||||
if (oddsFlipThreshold is <= 0m or >= 1m)
|
||||
throw new ArgumentOutOfRangeException(nameof(oddsFlipThreshold),
|
||||
oddsFlipThreshold, "Must be in (0, 1).");
|
||||
|
||||
if (minSnapshotCount < 2)
|
||||
throw new ArgumentOutOfRangeException(nameof(minSnapshotCount),
|
||||
minSnapshotCount, "Must be at least 2 to form at least one pair.");
|
||||
|
||||
_suspensionGapSeconds = suspensionGapSeconds;
|
||||
_oddsFlipThreshold = oddsFlipThreshold;
|
||||
_minSnapshotCount = minSnapshotCount;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Analyses <paramref name="snapshots"/> for the given <paramref name="eventId"/> and
|
||||
/// returns 0 or more anomalies detected in this timeline.
|
||||
/// </summary>
|
||||
/// <param name="eventId">The event being analysed.</param>
|
||||
/// <param name="snapshots">All snapshots for this event (any source, any order).</param>
|
||||
/// <returns>
|
||||
/// An <see cref="IReadOnlyList{T}"/> of <see cref="Anomaly"/> records, one per qualifying
|
||||
/// suspension interval. May be empty.
|
||||
/// </returns>
|
||||
public IReadOnlyList<Anomaly> Detect(EventId eventId, IReadOnlyList<OddsSnapshot> snapshots)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(eventId);
|
||||
ArgumentNullException.ThrowIfNull(snapshots);
|
||||
|
||||
// Step 1 — filter to Live snapshots only; suspension/flip is a live phenomenon.
|
||||
var liveSnapshots = snapshots
|
||||
.Where(s => s.Source == OddsSource.Live)
|
||||
.OrderBy(s => s.CapturedAt)
|
||||
.ToList();
|
||||
|
||||
// Step 2 — guard: need a minimum count to form meaningful intervals.
|
||||
if (liveSnapshots.Count < _minSnapshotCount)
|
||||
return Array.Empty<Anomaly>();
|
||||
|
||||
var anomalies = new List<Anomaly>();
|
||||
var suspensionGap = TimeSpan.FromSeconds(_suspensionGapSeconds);
|
||||
|
||||
// Step 3 — identify suspension intervals.
|
||||
for (int i = 0; i < liveSnapshots.Count - 1; i++)
|
||||
{
|
||||
var pre = liveSnapshots[i];
|
||||
var post = liveSnapshots[i + 1];
|
||||
|
||||
var gap = post.CapturedAt - pre.CapturedAt;
|
||||
if (gap <= suspensionGap)
|
||||
continue;
|
||||
|
||||
var interval = new SuspensionInterval(pre, post);
|
||||
var anomaly = TryDetectFlip(eventId, interval);
|
||||
if (anomaly is not null)
|
||||
anomalies.Add(anomaly);
|
||||
}
|
||||
|
||||
return anomalies.AsReadOnly();
|
||||
}
|
||||
|
||||
// ── Private helpers ──────────────────────────────────────────────────────
|
||||
|
||||
private Anomaly? TryDetectFlip(EventId eventId, SuspensionInterval interval)
|
||||
{
|
||||
// Extract Match-Win bets from each snapshot.
|
||||
var preProbs = ExtractMatchWinProbabilities(interval.PreSuspension);
|
||||
var postProbs = ExtractMatchWinProbabilities(interval.PostSuspension);
|
||||
|
||||
// Cannot compute flip if either snapshot lacks Win bets.
|
||||
if (preProbs is null || postProbs is null)
|
||||
return null;
|
||||
|
||||
// Step 4 — compute flip score = max(|p_post[i] − p_pre[i]|) across common sides.
|
||||
decimal flipScore = 0m;
|
||||
flipScore = Math.Max(flipScore,
|
||||
Math.Abs(postProbs.P1 - preProbs.P1));
|
||||
flipScore = Math.Max(flipScore,
|
||||
Math.Abs(postProbs.P2 - preProbs.P2));
|
||||
if (preProbs.PDraw.HasValue && postProbs.PDraw.HasValue)
|
||||
{
|
||||
flipScore = Math.Max(flipScore,
|
||||
Math.Abs(postProbs.PDraw.Value - preProbs.PDraw.Value));
|
||||
}
|
||||
|
||||
// Step 5 — favourite-changed test: argmax of implied probability must differ.
|
||||
bool favouriteChanged = DetermineFavourite(preProbs) != DetermineFavourite(postProbs);
|
||||
|
||||
if (flipScore < _oddsFlipThreshold || !favouriteChanged)
|
||||
return null;
|
||||
|
||||
// Clamp score to [0, 1] before constructing the Anomaly (domain invariant).
|
||||
var clampedScore = Math.Min(1m, flipScore);
|
||||
|
||||
// Step 6 — build evidence JSON.
|
||||
var evidenceJson = BuildEvidenceJson(interval, preProbs, postProbs);
|
||||
|
||||
return new Anomaly(
|
||||
Id: Guid.NewGuid(),
|
||||
EventId: eventId,
|
||||
DetectedAt: DateTimeOffset.UtcNow.ToOffset(MoscowOffset),
|
||||
Kind: AnomalyKind.SuspensionFlip,
|
||||
Score: clampedScore,
|
||||
EvidenceJson: evidenceJson);
|
||||
}
|
||||
|
||||
private static MatchWinProbabilities? ExtractMatchWinProbabilities(OddsSnapshot snapshot)
|
||||
{
|
||||
// Find Match-scope Win bets.
|
||||
var matchWinBets = snapshot.Bets
|
||||
.Where(b => b.Scope is MatchScope && b.Type == BetType.Win)
|
||||
.ToList();
|
||||
|
||||
var win1 = matchWinBets.FirstOrDefault(b => b.Side == Side.Side1);
|
||||
var win2 = matchWinBets.FirstOrDefault(b => b.Side == Side.Side2);
|
||||
|
||||
if (win1 is null || win2 is null)
|
||||
return null; // Not enough data.
|
||||
|
||||
// Find optional Draw bet (MatchScope, BetType.Draw).
|
||||
var drawBet = snapshot.Bets
|
||||
.FirstOrDefault(b => b.Scope is MatchScope && b.Type == BetType.Draw);
|
||||
|
||||
// Raw implied probabilities: p = 1 / rate.
|
||||
decimal rawP1 = 1m / win1.Rate.Value;
|
||||
decimal rawP2 = 1m / win2.Rate.Value;
|
||||
decimal rawDraw = drawBet is not null ? 1m / drawBet.Rate.Value : 0m;
|
||||
decimal total = rawP1 + rawP2 + rawDraw;
|
||||
|
||||
// Normalise so they sum to 1.
|
||||
decimal p1 = rawP1 / total;
|
||||
decimal p2 = rawP2 / total;
|
||||
decimal pDraw = drawBet is not null ? rawDraw / total : (decimal?)null ?? 0m;
|
||||
|
||||
return new MatchWinProbabilities(
|
||||
P1: p1,
|
||||
PDraw: drawBet is not null ? pDraw : null,
|
||||
P2: p2,
|
||||
Rate1: win1.Rate.Value,
|
||||
RateDraw: drawBet?.Rate.Value,
|
||||
Rate2: win2.Rate.Value);
|
||||
}
|
||||
|
||||
private static string DetermineFavourite(MatchWinProbabilities probs)
|
||||
{
|
||||
// The favourite is the side with the highest normalised implied probability.
|
||||
if (probs.PDraw.HasValue && probs.PDraw.Value > probs.P1 && probs.PDraw.Value > probs.P2)
|
||||
return "Draw";
|
||||
return probs.P1 >= probs.P2 ? "Side1" : "Side2";
|
||||
}
|
||||
|
||||
private string BuildEvidenceJson(
|
||||
SuspensionInterval interval,
|
||||
MatchWinProbabilities preProbs,
|
||||
MatchWinProbabilities postProbs)
|
||||
{
|
||||
var payload = new EvidencePayload(
|
||||
SuspensionGapSeconds: (int)interval.Gap.TotalSeconds,
|
||||
PreSuspension: new SnapshotEvidence(
|
||||
CapturedAt: interval.PreSuspension.CapturedAt.ToString("O"),
|
||||
P1: preProbs.P1,
|
||||
PDraw: preProbs.PDraw,
|
||||
P2: preProbs.P2,
|
||||
Rate1: preProbs.Rate1,
|
||||
RateDraw: preProbs.RateDraw,
|
||||
Rate2: preProbs.Rate2),
|
||||
PostSuspension: new SnapshotEvidence(
|
||||
CapturedAt: interval.PostSuspension.CapturedAt.ToString("O"),
|
||||
P1: postProbs.P1,
|
||||
PDraw: postProbs.PDraw,
|
||||
P2: postProbs.P2,
|
||||
Rate1: postProbs.Rate1,
|
||||
RateDraw: postProbs.RateDraw,
|
||||
Rate2: postProbs.Rate2));
|
||||
|
||||
return JsonSerializer.Serialize(payload, JsonOptions);
|
||||
}
|
||||
|
||||
// ── Nested types ─────────────────────────────────────────────────────────
|
||||
|
||||
private sealed record MatchWinProbabilities(
|
||||
decimal P1,
|
||||
decimal? PDraw,
|
||||
decimal P2,
|
||||
decimal Rate1,
|
||||
decimal? RateDraw,
|
||||
decimal Rate2);
|
||||
|
||||
private sealed record EvidencePayload(
|
||||
[property: JsonPropertyName("suspensionGapSeconds")] int SuspensionGapSeconds,
|
||||
[property: JsonPropertyName("preSuspension")] SnapshotEvidence PreSuspension,
|
||||
[property: JsonPropertyName("postSuspension")] SnapshotEvidence PostSuspension);
|
||||
|
||||
private sealed record SnapshotEvidence(
|
||||
[property: JsonPropertyName("capturedAt")] string CapturedAt,
|
||||
[property: JsonPropertyName("p1")] decimal P1,
|
||||
[property: JsonPropertyName("pDraw")] decimal? PDraw,
|
||||
[property: JsonPropertyName("p2")] decimal P2,
|
||||
[property: JsonPropertyName("rate1")] decimal Rate1,
|
||||
[property: JsonPropertyName("rateDraw")] decimal? RateDraw,
|
||||
[property: JsonPropertyName("rate2")] decimal Rate2);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
using Marathon.Domain.Entities;
|
||||
|
||||
namespace Marathon.Domain.AnomalyDetection;
|
||||
|
||||
/// <summary>
|
||||
/// A pair of adjacent <see cref="OddsSnapshot"/> records that bracket a suspension gap —
|
||||
/// i.e. the time between them exceeded the configured <c>SuspensionGapSeconds</c> threshold.
|
||||
/// </summary>
|
||||
/// <param name="PreSuspension">The last snapshot captured before the gap.</param>
|
||||
/// <param name="PostSuspension">The first snapshot captured after the gap.</param>
|
||||
internal sealed record SuspensionInterval(OddsSnapshot PreSuspension, OddsSnapshot PostSuspension)
|
||||
{
|
||||
/// <summary>Duration of the observed suspension gap.</summary>
|
||||
public TimeSpan Gap => PostSuspension.CapturedAt - PreSuspension.CapturedAt;
|
||||
}
|
||||
@@ -24,7 +24,8 @@
|
||||
"UpcomingPollerEnabled": true,
|
||||
"LivePollIntervalSeconds": 30,
|
||||
"ResultsPollIntervalSeconds": 300,
|
||||
"ResultsPollerEnabled": false
|
||||
"ResultsPollerEnabled": false,
|
||||
"AnomalyDetectionEnabled": true
|
||||
},
|
||||
"Sports": {
|
||||
"Basketball": {
|
||||
|
||||
@@ -40,4 +40,11 @@ public sealed class WorkerOptions
|
||||
/// Flip to <c>true</c> only after Phase 8 is complete.
|
||||
/// </summary>
|
||||
public bool ResultsPollerEnabled { get; init; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the anomaly-detection poller should run.
|
||||
/// Default: <c>true</c> — this is the product's primary differentiator and
|
||||
/// should be enabled by default.
|
||||
/// </summary>
|
||||
public bool AnomalyDetectionEnabled { get; init; } = true;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using Marathon.Application.Configuration;
|
||||
using Marathon.Infrastructure.Configuration;
|
||||
using Marathon.Infrastructure.Persistence;
|
||||
using Marathon.Infrastructure.Scraping;
|
||||
@@ -41,9 +42,14 @@ public static class InfrastructureModule
|
||||
.AddOptions<WorkerOptions>()
|
||||
.Bind(config.GetSection(WorkerOptions.SectionName));
|
||||
|
||||
services
|
||||
.AddOptions<AnomalyOptions>()
|
||||
.Bind(config.GetSection(AnomalyOptions.SectionName));
|
||||
|
||||
services.AddHostedService<UpcomingEventsPoller>();
|
||||
services.AddHostedService<LiveOddsPoller>();
|
||||
services.AddHostedService<ResultsWatchListPoller>();
|
||||
services.AddHostedService<AnomalyDetectionPoller>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
using Marathon.Application.Configuration;
|
||||
using Marathon.Application.UseCases;
|
||||
using Marathon.Infrastructure.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Marathon.Infrastructure.Workers;
|
||||
|
||||
/// <summary>
|
||||
/// Continuously runs the anomaly-detection cycle on a fixed interval controlled by
|
||||
/// <see cref="AnomalyOptions.DetectionIntervalSeconds"/> (default: 60 s).
|
||||
/// Can be disabled at runtime via <see cref="WorkerOptions.AnomalyDetectionEnabled"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Registered as a <see cref="BackgroundService"/> (singleton lifetime).
|
||||
/// <see cref="DetectAnomaliesUseCase"/> is resolved in a fresh <see cref="IServiceScope"/>
|
||||
/// per cycle so that EF Core's scoped <c>DbContext</c> is correctly managed.
|
||||
/// </remarks>
|
||||
internal sealed class AnomalyDetectionPoller : BackgroundService
|
||||
{
|
||||
private readonly IServiceProvider _services;
|
||||
private readonly IOptionsMonitor<WorkerOptions> _workerOpts;
|
||||
private readonly IOptionsMonitor<AnomalyOptions> _anomalyOpts;
|
||||
private readonly ILogger<AnomalyDetectionPoller> _logger;
|
||||
|
||||
public AnomalyDetectionPoller(
|
||||
IServiceProvider services,
|
||||
IOptionsMonitor<WorkerOptions> workerOpts,
|
||||
IOptionsMonitor<AnomalyOptions> anomalyOpts,
|
||||
ILogger<AnomalyDetectionPoller> logger)
|
||||
{
|
||||
_services = services ?? throw new ArgumentNullException(nameof(services));
|
||||
_workerOpts = workerOpts ?? throw new ArgumentNullException(nameof(workerOpts));
|
||||
_anomalyOpts = anomalyOpts ?? throw new ArgumentNullException(nameof(anomalyOpts));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
_logger.LogInformation("AnomalyDetectionPoller: started");
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
if (!_workerOpts.CurrentValue.AnomalyDetectionEnabled)
|
||||
{
|
||||
_logger.LogDebug("AnomalyDetectionPoller: disabled — sleeping 10 s before re-check");
|
||||
await Task.Delay(TimeSpan.FromSeconds(10), stoppingToken);
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await using var scope = _services.CreateAsyncScope();
|
||||
var useCase = scope.ServiceProvider.GetRequiredService<DetectAnomaliesUseCase>();
|
||||
var newAnomalies = await useCase.ExecuteAsync(stoppingToken);
|
||||
|
||||
_logger.LogInformation(
|
||||
"AnomalyDetectionPoller: cycle complete — newAnomalies={NewAnomalies}",
|
||||
newAnomalies);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex,
|
||||
"AnomalyDetectionPoller: unhandled exception during cycle — will retry after interval");
|
||||
}
|
||||
|
||||
var interval = TimeSpan.FromSeconds(
|
||||
Math.Max(1, _anomalyOpts.CurrentValue.DetectionIntervalSeconds));
|
||||
|
||||
try
|
||||
{
|
||||
await Task.Delay(interval, stoppingToken);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("AnomalyDetectionPoller: stopping");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
using FluentAssertions;
|
||||
using Marathon.Application.Abstractions;
|
||||
using Marathon.Application.Configuration;
|
||||
using Marathon.Application.UseCases;
|
||||
using Marathon.Domain.Entities;
|
||||
using Marathon.Domain.Enums;
|
||||
using Marathon.Domain.ValueObjects;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using NSubstitute;
|
||||
using NSubstitute.ExceptionExtensions;
|
||||
|
||||
namespace Marathon.Application.Tests.UseCases;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="DetectAnomaliesUseCase"/> using NSubstitute mocks.
|
||||
/// </summary>
|
||||
public sealed class DetectAnomaliesUseCaseTests
|
||||
{
|
||||
private readonly IEventRepository _eventRepo = Substitute.For<IEventRepository>();
|
||||
private readonly ISnapshotRepository _snapshotRepo = Substitute.For<ISnapshotRepository>();
|
||||
private readonly IAnomalyRepository _anomalyRepo = Substitute.For<IAnomalyRepository>();
|
||||
|
||||
private static readonly TimeSpan MoscowOffset = TimeSpan.FromHours(3);
|
||||
private static readonly DateTimeOffset BaseTime =
|
||||
new(2026, 5, 10, 18, 0, 0, MoscowOffset);
|
||||
|
||||
/// <summary>Default options matching appsettings.json.</summary>
|
||||
private static IOptions<AnomalyOptions> DefaultOptions(
|
||||
int gapSeconds = 60,
|
||||
decimal threshold = 0.30m,
|
||||
int minSnapshots = 3) =>
|
||||
Options.Create(new AnomalyOptions
|
||||
{
|
||||
SuspensionGapSeconds = gapSeconds,
|
||||
OddsFlipThreshold = threshold,
|
||||
MinSnapshotCount = minSnapshots,
|
||||
DetectionIntervalSeconds = 60,
|
||||
});
|
||||
|
||||
private DetectAnomaliesUseCase CreateSut(IOptions<AnomalyOptions>? opts = null) =>
|
||||
new(_eventRepo, _snapshotRepo, _anomalyRepo,
|
||||
opts ?? DefaultOptions(),
|
||||
NullLogger<DetectAnomaliesUseCase>.Instance);
|
||||
|
||||
// ── Helper: build a snapshot timeline with a clear flip ───────────────────
|
||||
|
||||
private static IReadOnlyList<OddsSnapshot> BuildFlipTimeline(EventId eventId)
|
||||
{
|
||||
// 4 live snapshots: first two normal, then 90s gap (>60s threshold),
|
||||
// then two with flipped odds. Rates 1.3/4.0 → 4.0/1.3 produce a large flip score.
|
||||
static OddsSnapshot MakeSnap(EventId id, DateTimeOffset at, decimal r1, decimal r2) =>
|
||||
new(id, at, OddsSource.Live,
|
||||
new List<Bet>
|
||||
{
|
||||
new(MatchScope.Instance, BetType.Win, Side.Side1, null, new OddsRate(r1)),
|
||||
new(MatchScope.Instance, BetType.Win, Side.Side2, null, new OddsRate(r2)),
|
||||
});
|
||||
|
||||
return new[]
|
||||
{
|
||||
MakeSnap(eventId, BaseTime, 1.3m, 4.0m),
|
||||
MakeSnap(eventId, BaseTime.AddSeconds(30), 1.3m, 4.0m),
|
||||
MakeSnap(eventId, BaseTime.AddSeconds(120), 4.0m, 1.3m), // flipped — 90 s gap
|
||||
MakeSnap(eventId, BaseTime.AddSeconds(150), 4.0m, 1.3m),
|
||||
};
|
||||
}
|
||||
|
||||
// ── Test 1: Iterates events, detects, and persists new anomalies ──────────
|
||||
|
||||
[Fact]
|
||||
public async Task Should_PersistNewAnomalies_When_FlipDetectedForEvent()
|
||||
{
|
||||
// Arrange
|
||||
var eventId = new EventId("11111111");
|
||||
var ev = TestFixtures.MakeEvent(eventId.Value);
|
||||
|
||||
_eventRepo.ListAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(new[] { ev }.ToList().AsReadOnly());
|
||||
|
||||
_snapshotRepo
|
||||
.ListByEventAsync(eventId, Arg.Any<DateTimeOffset>(), Arg.Any<DateTimeOffset>(),
|
||||
Arg.Any<CancellationToken>())
|
||||
.Returns(BuildFlipTimeline(eventId));
|
||||
|
||||
// No existing anomalies → dedup will not filter anything.
|
||||
_anomalyRepo.ListAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(Array.Empty<Anomaly>().ToList().AsReadOnly());
|
||||
|
||||
var sut = CreateSut();
|
||||
|
||||
// Act
|
||||
var count = await sut.ExecuteAsync(CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
count.Should().Be(1, "one flip was detected");
|
||||
await _anomalyRepo.Received(1).AddAsync(Arg.Any<Anomaly>(), Arg.Any<CancellationToken>());
|
||||
await _anomalyRepo.Received(1).SaveChangesAsync(Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
// ── Test 2: Deduplication — already-persisted anomaly is not re-added ─────
|
||||
|
||||
[Fact]
|
||||
public async Task Should_SkipAlreadyPersistedAnomalies_When_DuplicateDetected()
|
||||
{
|
||||
// Arrange
|
||||
var eventId = new EventId("22222222");
|
||||
var ev = TestFixtures.MakeEvent(eventId.Value);
|
||||
|
||||
_eventRepo.ListAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(new[] { ev }.ToList().AsReadOnly());
|
||||
|
||||
_snapshotRepo
|
||||
.ListByEventAsync(eventId, Arg.Any<DateTimeOffset>(), Arg.Any<DateTimeOffset>(),
|
||||
Arg.Any<CancellationToken>())
|
||||
.Returns(BuildFlipTimeline(eventId));
|
||||
|
||||
// Existing anomaly with same EventId, Kind=SuspensionFlip, and DetectedAt ≈ now (within dedup window).
|
||||
var existingAnomaly = new Anomaly(
|
||||
Id: Guid.NewGuid(),
|
||||
EventId: eventId,
|
||||
DetectedAt: DateTimeOffset.UtcNow.ToOffset(TimeSpan.FromHours(3)),
|
||||
Kind: AnomalyKind.SuspensionFlip,
|
||||
Score: 0.5m,
|
||||
EvidenceJson: "{\"dummy\":true}");
|
||||
|
||||
_anomalyRepo.ListAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(new[] { existingAnomaly }.ToList().AsReadOnly());
|
||||
|
||||
var sut = CreateSut();
|
||||
|
||||
// Act
|
||||
var count = await sut.ExecuteAsync(CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
count.Should().Be(0, "anomaly already exists — dedup prevents re-adding");
|
||||
await _anomalyRepo.DidNotReceive().AddAsync(Arg.Any<Anomaly>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
// ── Test 3: Per-event failure does not abort the cycle ────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Should_ContinueAfterPerEventFailure_And_ReturnPartialCount()
|
||||
{
|
||||
// Arrange: two events — first throws on snapshot load, second has a detectable flip.
|
||||
var ev1Id = new EventId("33333333");
|
||||
var ev2Id = new EventId("44444444");
|
||||
var ev1 = TestFixtures.MakeEvent(ev1Id.Value);
|
||||
var ev2 = TestFixtures.MakeEvent(ev2Id.Value);
|
||||
|
||||
_eventRepo.ListAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(new[] { ev1, ev2 }.ToList().AsReadOnly());
|
||||
|
||||
// Event 1 — snapshot load throws.
|
||||
_snapshotRepo
|
||||
.ListByEventAsync(ev1Id, Arg.Any<DateTimeOffset>(), Arg.Any<DateTimeOffset>(),
|
||||
Arg.Any<CancellationToken>())
|
||||
.ThrowsAsync(new InvalidOperationException("DB error for event 1"));
|
||||
|
||||
// Event 2 — clean flip timeline.
|
||||
_snapshotRepo
|
||||
.ListByEventAsync(ev2Id, Arg.Any<DateTimeOffset>(), Arg.Any<DateTimeOffset>(),
|
||||
Arg.Any<CancellationToken>())
|
||||
.Returns(BuildFlipTimeline(ev2Id));
|
||||
|
||||
_anomalyRepo.ListAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(Array.Empty<Anomaly>().ToList().AsReadOnly());
|
||||
|
||||
var sut = CreateSut();
|
||||
|
||||
// Act — must not throw despite event 1 failing.
|
||||
var act = async () => await sut.ExecuteAsync(CancellationToken.None);
|
||||
await act.Should().NotThrowAsync();
|
||||
|
||||
var count = await sut.ExecuteAsync(CancellationToken.None);
|
||||
|
||||
// Assert: event 2 succeeded — at least 1 anomaly persisted.
|
||||
count.Should().BeGreaterThan(0, "event 2 should succeed despite event 1 failure");
|
||||
}
|
||||
|
||||
// ── Test 4: Returns count of new anomalies across multiple events ──────────
|
||||
|
||||
[Fact]
|
||||
public async Task Should_ReturnTotalNewAnomalyCount_When_MultipleEventsHaveFlips()
|
||||
{
|
||||
// Arrange: two events, both with a flip.
|
||||
var ev1Id = new EventId("55555555");
|
||||
var ev2Id = new EventId("66666666");
|
||||
var ev1 = TestFixtures.MakeEvent(ev1Id.Value);
|
||||
var ev2 = TestFixtures.MakeEvent(ev2Id.Value);
|
||||
|
||||
_eventRepo.ListAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(new[] { ev1, ev2 }.ToList().AsReadOnly());
|
||||
|
||||
_snapshotRepo
|
||||
.ListByEventAsync(ev1Id, Arg.Any<DateTimeOffset>(), Arg.Any<DateTimeOffset>(),
|
||||
Arg.Any<CancellationToken>())
|
||||
.Returns(BuildFlipTimeline(ev1Id));
|
||||
|
||||
_snapshotRepo
|
||||
.ListByEventAsync(ev2Id, Arg.Any<DateTimeOffset>(), Arg.Any<DateTimeOffset>(),
|
||||
Arg.Any<CancellationToken>())
|
||||
.Returns(BuildFlipTimeline(ev2Id));
|
||||
|
||||
_anomalyRepo.ListAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(Array.Empty<Anomaly>().ToList().AsReadOnly());
|
||||
|
||||
var sut = CreateSut();
|
||||
|
||||
// Act
|
||||
var count = await sut.ExecuteAsync(CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
count.Should().Be(2, "two events, one flip each → 2 new anomalies");
|
||||
await _anomalyRepo.Received(2).AddAsync(Arg.Any<Anomaly>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,391 @@
|
||||
using System.Text.Json;
|
||||
using FluentAssertions;
|
||||
using Marathon.Domain.AnomalyDetection;
|
||||
using Marathon.Domain.Entities;
|
||||
using Marathon.Domain.Enums;
|
||||
using Marathon.Domain.ValueObjects;
|
||||
|
||||
namespace Marathon.Domain.Tests.AnomalyDetection;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="AnomalyDetector"/>.
|
||||
/// All tests use synthetic snapshot timelines to verify the detection algorithm
|
||||
/// without any I/O or database dependencies.
|
||||
/// </summary>
|
||||
public sealed class AnomalyDetectorTests
|
||||
{
|
||||
// Default thresholds matching appsettings.json defaults.
|
||||
private const int DefaultGapSeconds = 60;
|
||||
private const decimal DefaultThreshold = 0.30m;
|
||||
private const int DefaultMinSnapshots = 3;
|
||||
|
||||
private static readonly EventId TestEventId = new("99999999");
|
||||
private static readonly TimeSpan MoscowOffset = TimeSpan.FromHours(3);
|
||||
private static readonly DateTimeOffset BaseTime =
|
||||
new(2026, 5, 10, 18, 0, 0, MoscowOffset);
|
||||
|
||||
private static AnomalyDetector DefaultDetector() =>
|
||||
new(DefaultGapSeconds, DefaultThreshold, DefaultMinSnapshots);
|
||||
|
||||
// ── Helper factory methods ────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Creates a live OddsSnapshot with Match Win bets for both sides.</summary>
|
||||
private static OddsSnapshot MakeLiveSnapshot(
|
||||
DateTimeOffset capturedAt,
|
||||
decimal rate1,
|
||||
decimal rate2,
|
||||
decimal? rateDraw = null)
|
||||
{
|
||||
var bets = new List<Bet>
|
||||
{
|
||||
new(MatchScope.Instance, BetType.Win, Side.Side1, null, new OddsRate(rate1)),
|
||||
new(MatchScope.Instance, BetType.Win, Side.Side2, null, new OddsRate(rate2)),
|
||||
};
|
||||
|
||||
if (rateDraw.HasValue)
|
||||
bets.Add(new Bet(MatchScope.Instance, BetType.Draw, Side.Draw, null, new OddsRate(rateDraw.Value)));
|
||||
|
||||
return new OddsSnapshot(TestEventId, capturedAt, OddsSource.Live, bets);
|
||||
}
|
||||
|
||||
/// <summary>Creates a pre-match OddsSnapshot (should be ignored by detector).</summary>
|
||||
private static OddsSnapshot MakePreMatchSnapshot(DateTimeOffset capturedAt) =>
|
||||
new(TestEventId, capturedAt, OddsSource.PreMatch,
|
||||
new List<Bet>
|
||||
{
|
||||
new(MatchScope.Instance, BetType.Win, Side.Side1, null, new OddsRate(1.5m)),
|
||||
new(MatchScope.Instance, BetType.Win, Side.Side2, null, new OddsRate(2.5m)),
|
||||
});
|
||||
|
||||
// ── Test: empty snapshot list → 0 anomalies ──────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Should_ReturnEmpty_When_SnapshotListIsEmpty()
|
||||
{
|
||||
var sut = DefaultDetector();
|
||||
|
||||
var result = sut.Detect(TestEventId, Array.Empty<OddsSnapshot>());
|
||||
|
||||
result.Should().BeEmpty();
|
||||
}
|
||||
|
||||
// ── Test: below minSnapshotCount → 0 anomalies ───────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Should_ReturnEmpty_When_FewerThanMinSnapshotCountLiveSnapshots()
|
||||
{
|
||||
// Only 2 live snapshots; minSnapshotCount = 3.
|
||||
var snapshots = new[]
|
||||
{
|
||||
MakeLiveSnapshot(BaseTime, rate1: 1.5m, rate2: 2.5m),
|
||||
MakeLiveSnapshot(BaseTime.AddSeconds(30), rate1: 1.55m, rate2: 2.45m),
|
||||
};
|
||||
|
||||
var sut = DefaultDetector();
|
||||
var result = sut.Detect(TestEventId, snapshots);
|
||||
|
||||
result.Should().BeEmpty("fewer than minSnapshotCount=3 live snapshots");
|
||||
}
|
||||
|
||||
// ── Test: pre-match-only snapshots → 0 anomalies ─────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Should_ReturnEmpty_When_AllSnapshotsArePreMatch()
|
||||
{
|
||||
// 5 pre-match snapshots — should all be ignored.
|
||||
var snapshots = Enumerable.Range(0, 5)
|
||||
.Select(i => MakePreMatchSnapshot(BaseTime.AddSeconds(i * 30)))
|
||||
.ToArray();
|
||||
|
||||
var sut = DefaultDetector();
|
||||
var result = sut.Detect(TestEventId, snapshots);
|
||||
|
||||
result.Should().BeEmpty("only pre-match snapshots — live filter returns 0");
|
||||
}
|
||||
|
||||
// ── Test: no suspension (regular intervals) → 0 anomalies ────────────────
|
||||
|
||||
[Fact]
|
||||
public void Should_ReturnEmpty_When_NoSuspensionGapExists()
|
||||
{
|
||||
// 5 snapshots spaced 30 s apart — well below the 60 s gap threshold.
|
||||
var snapshots = Enumerable.Range(0, 5)
|
||||
.Select(i => MakeLiveSnapshot(
|
||||
BaseTime.AddSeconds(i * 30),
|
||||
rate1: 1.5m, rate2: 2.5m))
|
||||
.ToArray();
|
||||
|
||||
var sut = DefaultDetector();
|
||||
var result = sut.Detect(TestEventId, snapshots);
|
||||
|
||||
result.Should().BeEmpty("no gap exceeds suspensionGapSeconds=60");
|
||||
}
|
||||
|
||||
// ── Test: suspension but odds shift below threshold → 0 anomalies ─────────
|
||||
|
||||
[Fact]
|
||||
public void Should_ReturnEmpty_When_SuspensionButOddsShiftBelowThreshold()
|
||||
{
|
||||
// Pre-suspension: Side1 slightly favoured.
|
||||
// Post-suspension: Side1 still favoured, tiny shift well below 0.30 threshold.
|
||||
var snapshots = new[]
|
||||
{
|
||||
MakeLiveSnapshot(BaseTime, rate1: 1.5m, rate2: 2.5m), // pre
|
||||
MakeLiveSnapshot(BaseTime.AddSeconds(30), rate1: 1.52m, rate2: 2.48m), // pre (within normal gap)
|
||||
// 90 s gap = suspension
|
||||
MakeLiveSnapshot(BaseTime.AddSeconds(120), rate1: 1.55m, rate2: 2.45m), // post — small shift
|
||||
MakeLiveSnapshot(BaseTime.AddSeconds(150), rate1: 1.56m, rate2: 2.44m),
|
||||
};
|
||||
|
||||
var sut = DefaultDetector();
|
||||
var result = sut.Detect(TestEventId, snapshots);
|
||||
|
||||
result.Should().BeEmpty("odds shift is far below 0.30 threshold");
|
||||
}
|
||||
|
||||
// ── Test: suspension + favourite flipped → 1 anomaly ─────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Should_DetectOneAnomaly_When_SuspensionWithFavouriteFlip()
|
||||
{
|
||||
// Pre-suspension: Side1 favourite — rate1=1.3, rate2=4.0
|
||||
// rawP1=1/1.3≈0.769, rawP2=1/4.0=0.25, total≈1.019
|
||||
// p1_pre≈0.755, p2_pre≈0.245 → favourite = Side1
|
||||
//
|
||||
// Post-suspension: Side2 favourite — rate1=4.0, rate2=1.3
|
||||
// rawP1=0.25, rawP2≈0.769, total≈1.019
|
||||
// p1_post≈0.245, p2_post≈0.755 → favourite = Side2 (changed!)
|
||||
//
|
||||
// flipScore = max(|0.245-0.755|, |0.755-0.245|) ≈ 0.510 ≥ 0.30 ✓
|
||||
var snapshots = new[]
|
||||
{
|
||||
MakeLiveSnapshot(BaseTime, rate1: 1.3m, rate2: 4.0m),
|
||||
MakeLiveSnapshot(BaseTime.AddSeconds(30), rate1: 1.3m, rate2: 4.0m),
|
||||
// 90 s gap = suspension (> 60 s threshold)
|
||||
MakeLiveSnapshot(BaseTime.AddSeconds(120), rate1: 4.0m, rate2: 1.3m), // flipped
|
||||
MakeLiveSnapshot(BaseTime.AddSeconds(150), rate1: 4.0m, rate2: 1.3m),
|
||||
};
|
||||
|
||||
var sut = DefaultDetector();
|
||||
var result = sut.Detect(TestEventId, snapshots);
|
||||
|
||||
result.Should().HaveCount(1);
|
||||
result[0].EventId.Should().Be(TestEventId);
|
||||
result[0].Kind.Should().Be(AnomalyKind.SuspensionFlip);
|
||||
result[0].Score.Should().BeGreaterThanOrEqualTo(0.30m);
|
||||
result[0].Score.Should().BeLessThanOrEqualTo(1.0m);
|
||||
}
|
||||
|
||||
// ── Test: score calculation is accurate ──────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Should_ComputeCorrectFlipScore_ForKnownInputs()
|
||||
{
|
||||
// Pre: rate1=1.5, rate2=2.5
|
||||
// rawP1 = 1/1.5 ≈ 0.6667, rawP2 = 1/2.5 = 0.4, total ≈ 1.0667
|
||||
// p1_pre ≈ 0.6667/1.0667 ≈ 0.625, p2_pre ≈ 0.4/1.0667 ≈ 0.375
|
||||
//
|
||||
// Post: rate1=2.5, rate2=1.5
|
||||
// rawP1 = 0.4, rawP2 ≈ 0.6667, total ≈ 1.0667
|
||||
// p1_post ≈ 0.375, p2_post ≈ 0.625
|
||||
//
|
||||
// flipScore = max(|0.375-0.625|, |0.625-0.375|) = 0.25 → but wait, with
|
||||
// the normalised values both deltas equal |0.625-0.375|=0.25. Hmm that's < 0.30.
|
||||
//
|
||||
// Use steeper rates to guarantee > 0.30:
|
||||
// Pre: rate1=1.3, rate2=4.0 → rawP1=0.769, rawP2=0.25, total=1.019 → p1≈0.755, p2≈0.245
|
||||
// Post: rate1=4.0, rate2=1.3 → rawP1=0.25, rawP2=0.769, total=1.019 → p1≈0.245, p2≈0.755
|
||||
// flipScore = max(|0.245-0.755|, |0.755-0.245|) = 0.510
|
||||
|
||||
var snapshots = new[]
|
||||
{
|
||||
MakeLiveSnapshot(BaseTime, rate1: 1.3m, rate2: 4.0m),
|
||||
MakeLiveSnapshot(BaseTime.AddSeconds(30), rate1: 1.3m, rate2: 4.0m),
|
||||
// suspension gap
|
||||
MakeLiveSnapshot(BaseTime.AddSeconds(120), rate1: 4.0m, rate2: 1.3m),
|
||||
MakeLiveSnapshot(BaseTime.AddSeconds(150), rate1: 4.0m, rate2: 1.3m),
|
||||
};
|
||||
|
||||
var sut = DefaultDetector();
|
||||
var result = sut.Detect(TestEventId, snapshots);
|
||||
|
||||
result.Should().HaveCount(1);
|
||||
// Expected flip score ≈ 0.510 — verify within a reasonable tolerance.
|
||||
result[0].Score.Should().BeGreaterThan(0.45m, "steeper rates produce a large flip");
|
||||
result[0].Score.Should().BeLessThanOrEqualTo(1.0m);
|
||||
}
|
||||
|
||||
// ── Test: tennis (no draw) → works correctly ──────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Should_DetectAnomaly_When_TwoWayMarketWithFavouriteFlip()
|
||||
{
|
||||
// Tennis-style: no draw market. Rates flip from 1.3/4.0 to 4.0/1.3.
|
||||
var snapshots = new[]
|
||||
{
|
||||
MakeLiveSnapshot(BaseTime, rate1: 1.3m, rate2: 4.0m, rateDraw: null),
|
||||
MakeLiveSnapshot(BaseTime.AddSeconds(30), rate1: 1.3m, rate2: 4.0m, rateDraw: null),
|
||||
// suspension gap
|
||||
MakeLiveSnapshot(BaseTime.AddSeconds(120), rate1: 4.0m, rate2: 1.3m, rateDraw: null),
|
||||
MakeLiveSnapshot(BaseTime.AddSeconds(150), rate1: 4.0m, rate2: 1.3m, rateDraw: null),
|
||||
};
|
||||
|
||||
var sut = DefaultDetector();
|
||||
var result = sut.Detect(TestEventId, snapshots);
|
||||
|
||||
result.Should().HaveCount(1, "2-way market flip should be detected");
|
||||
result[0].Score.Should().BeGreaterThan(0.45m);
|
||||
}
|
||||
|
||||
// ── Test: multiple suspensions → multiple anomalies ───────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Should_DetectMultipleAnomalies_When_MultipleSuspensionsOccur()
|
||||
{
|
||||
// Two separate suspension intervals, each with a clear flip.
|
||||
var snapshots = new[]
|
||||
{
|
||||
MakeLiveSnapshot(BaseTime, rate1: 1.3m, rate2: 4.0m), // period A start
|
||||
MakeLiveSnapshot(BaseTime.AddSeconds(30), rate1: 1.3m, rate2: 4.0m),
|
||||
// Suspension 1
|
||||
MakeLiveSnapshot(BaseTime.AddSeconds(120), rate1: 4.0m, rate2: 1.3m), // flipped
|
||||
MakeLiveSnapshot(BaseTime.AddSeconds(150), rate1: 4.0m, rate2: 1.3m),
|
||||
// Suspension 2
|
||||
MakeLiveSnapshot(BaseTime.AddSeconds(240), rate1: 1.3m, rate2: 4.0m), // flipped back
|
||||
MakeLiveSnapshot(BaseTime.AddSeconds(270), rate1: 1.3m, rate2: 4.0m),
|
||||
};
|
||||
|
||||
var sut = DefaultDetector();
|
||||
var result = sut.Detect(TestEventId, snapshots);
|
||||
|
||||
result.Should().HaveCount(2, "two qualifying suspension intervals produce two anomalies");
|
||||
}
|
||||
|
||||
// ── Test: EvidenceJson contains expected fields ───────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Should_IncludeEvidenceJson_WithProbabilityVectorsAndRates()
|
||||
{
|
||||
var snapshots = new[]
|
||||
{
|
||||
MakeLiveSnapshot(BaseTime, rate1: 1.3m, rate2: 4.0m),
|
||||
MakeLiveSnapshot(BaseTime.AddSeconds(30), rate1: 1.3m, rate2: 4.0m),
|
||||
// suspension gap
|
||||
MakeLiveSnapshot(BaseTime.AddSeconds(120), rate1: 4.0m, rate2: 1.3m),
|
||||
MakeLiveSnapshot(BaseTime.AddSeconds(150), rate1: 4.0m, rate2: 1.3m),
|
||||
};
|
||||
|
||||
var sut = DefaultDetector();
|
||||
var result = sut.Detect(TestEventId, snapshots);
|
||||
|
||||
result.Should().HaveCount(1);
|
||||
|
||||
var evidenceJson = result[0].EvidenceJson;
|
||||
evidenceJson.Should().NotBeNullOrWhiteSpace();
|
||||
|
||||
// Parse and verify key fields.
|
||||
using var doc = JsonDocument.Parse(evidenceJson);
|
||||
var root = doc.RootElement;
|
||||
|
||||
root.TryGetProperty("suspensionGapSeconds", out _).Should().BeTrue("gap seconds required");
|
||||
root.TryGetProperty("preSuspension", out var pre).Should().BeTrue();
|
||||
root.TryGetProperty("postSuspension", out var post).Should().BeTrue();
|
||||
|
||||
pre.TryGetProperty("capturedAt", out _).Should().BeTrue();
|
||||
pre.TryGetProperty("p1", out _).Should().BeTrue();
|
||||
pre.TryGetProperty("p2", out _).Should().BeTrue();
|
||||
pre.TryGetProperty("rate1", out _).Should().BeTrue();
|
||||
pre.TryGetProperty("rate2", out _).Should().BeTrue();
|
||||
|
||||
post.TryGetProperty("p1", out _).Should().BeTrue();
|
||||
post.TryGetProperty("p2", out _).Should().BeTrue();
|
||||
post.TryGetProperty("rate1", out _).Should().BeTrue();
|
||||
post.TryGetProperty("rate2", out _).Should().BeTrue();
|
||||
}
|
||||
|
||||
// ── Test: determinism — same input produces same output ───────────────────
|
||||
|
||||
[Fact]
|
||||
public void Should_BeDeterministic_SameInputProducesSameOutput()
|
||||
{
|
||||
var snapshots = new[]
|
||||
{
|
||||
MakeLiveSnapshot(BaseTime, rate1: 1.3m, rate2: 4.0m),
|
||||
MakeLiveSnapshot(BaseTime.AddSeconds(30), rate1: 1.3m, rate2: 4.0m),
|
||||
MakeLiveSnapshot(BaseTime.AddSeconds(120), rate1: 4.0m, rate2: 1.3m),
|
||||
MakeLiveSnapshot(BaseTime.AddSeconds(150), rate1: 4.0m, rate2: 1.3m),
|
||||
};
|
||||
|
||||
var sut = DefaultDetector();
|
||||
|
||||
var result1 = sut.Detect(TestEventId, snapshots);
|
||||
var result2 = sut.Detect(TestEventId, snapshots);
|
||||
|
||||
result1.Should().HaveCount(result2.Count);
|
||||
result1[0].Score.Should().Be(result2[0].Score);
|
||||
result1[0].Kind.Should().Be(result2[0].Kind);
|
||||
result1[0].EventId.Should().Be(result2[0].EventId);
|
||||
}
|
||||
|
||||
// ── Test: three-way market (with draw) — flip when draw becomes favourite ─
|
||||
|
||||
[Fact]
|
||||
public void Should_DetectAnomaly_When_FavouriteChangesFromSide1ToDraw()
|
||||
{
|
||||
// Pre: Side1 is slight favourite with rate 1.6 (draw 3.5, side2 4.0).
|
||||
// Post: Draw becomes favourite with rate 2.2 (side1 2.8, side2 3.5).
|
||||
// This represents an unusual suspension flip where the draw becomes favourite.
|
||||
// We need enough of a flip in p1 vs pDraw.
|
||||
// Pre: raw p1=1/1.6=0.625, pDraw=1/3.5=0.286, p2=1/4.0=0.25, total=1.161
|
||||
// norm: p1=0.539, pDraw=0.246, p2=0.215 → favourite=Side1
|
||||
// Post: raw p1=1/1.3=0.769, pDraw=1/2.0=0.5, p2=1/6.0=0.167, total=1.436
|
||||
// norm: p1=0.535, pDraw=0.348, p2=0.116 → favourite still Side1
|
||||
// Need to make draw the favourite:
|
||||
// Post: raw p1=1/4.0=0.25, pDraw=1/1.5=0.667, p2=1/6.0=0.167, total=1.083
|
||||
// norm: p1=0.231, pDraw=0.616, p2=0.154 → favourite=Draw
|
||||
// flipScore = max(|0.231-0.539|, |0.616-0.246|, |0.154-0.215|)
|
||||
// = max(0.308, 0.370, 0.061) = 0.370 ≥ 0.30 ✓ AND favourite changed ✓
|
||||
|
||||
var snapshots = new[]
|
||||
{
|
||||
MakeLiveSnapshot(BaseTime, rate1: 1.6m, rate2: 4.0m, rateDraw: 3.5m),
|
||||
MakeLiveSnapshot(BaseTime.AddSeconds(30), rate1: 1.6m, rate2: 4.0m, rateDraw: 3.5m),
|
||||
// suspension gap
|
||||
MakeLiveSnapshot(BaseTime.AddSeconds(120), rate1: 4.0m, rate2: 6.0m, rateDraw: 1.5m),
|
||||
MakeLiveSnapshot(BaseTime.AddSeconds(150), rate1: 4.0m, rate2: 6.0m, rateDraw: 1.5m),
|
||||
};
|
||||
|
||||
var sut = DefaultDetector();
|
||||
var result = sut.Detect(TestEventId, snapshots);
|
||||
|
||||
result.Should().HaveCount(1, "favourite changed from Side1 to Draw → qualifies");
|
||||
result[0].Score.Should().BeGreaterThanOrEqualTo(0.30m);
|
||||
|
||||
// Verify that pDraw is present in the evidence JSON.
|
||||
using var doc = JsonDocument.Parse(result[0].EvidenceJson);
|
||||
var root = doc.RootElement;
|
||||
root.GetProperty("preSuspension").TryGetProperty("pDraw", out _).Should().BeTrue();
|
||||
root.GetProperty("postSuspension").TryGetProperty("pDraw", out _).Should().BeTrue();
|
||||
}
|
||||
|
||||
// ── Test: mixed pre-match and live snapshots — only live are analysed ─────
|
||||
|
||||
[Fact]
|
||||
public void Should_IgnorePreMatchSnapshots_When_MixedSourcesProvided()
|
||||
{
|
||||
// 3 pre-match snapshots (should be ignored) + 2 live (below minSnapshotCount=3).
|
||||
var snapshots = new[]
|
||||
{
|
||||
MakePreMatchSnapshot(BaseTime),
|
||||
MakePreMatchSnapshot(BaseTime.AddSeconds(30)),
|
||||
MakePreMatchSnapshot(BaseTime.AddSeconds(60)),
|
||||
MakeLiveSnapshot(BaseTime.AddSeconds(90), rate1: 1.3m, rate2: 4.0m),
|
||||
MakeLiveSnapshot(BaseTime.AddSeconds(120), rate1: 4.0m, rate2: 1.3m), // would be a flip if 3+ snapshots
|
||||
};
|
||||
|
||||
var sut = DefaultDetector();
|
||||
var result = sut.Detect(TestEventId, snapshots);
|
||||
|
||||
result.Should().BeEmpty("only 2 live snapshots after filtering — below minSnapshotCount=3");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user