- Add SuspensionFreezeDetector via the IAnomalyDetector seam: a suspension gap with
the favourite unchanged and a negligible (< threshold) price move — the mirror of
the flip. Score = how completely the line froze. Reuses MatchWinEvidence so UI +
evaluator handle it unchanged. 6 tests.
- Add AnomalyKind.SuspensionFreeze + localized card/detail label, SuspensionFreezeThreshold
option, and fan it into DetectAnomaliesUseCase.
- Introduce IAnomalyDetector; the existing flip detector implements it.
- Extract MatchWinEvidence so every detector writes the identical pre/post
evidence shape — the UI parser and outcome evaluator handle new kinds with no
branching (steam moves get hit-rate calibrated for free).
- Add SteamMoveDetector: flags a rapid one-directional implied-probability rise
over a short CONTINUOUS window (no suspension gap inside it), so it never
double-flags the same interval as the suspension-flip detector.
- DetectAnomaliesUseCase fans out over both detectors; dedup keys on EventId+Kind
so flip and steam signals persist independently. Add AnomalyKind.SteamMove +
SteamMove window/threshold options. 8 detector tests.
- Add IEventRepository/IResultRepository.GetManyAsync to kill N+1 lookups at
6 sites (backtest, outcome eval, both bet-journal paths, anomaly browsing,
results selection); guarded by a Received(1).GetManyAsync test.
- Add EventRepository.QueryAsync to push date+sport filtering to SQL (was
load-whole-range-then-filter); search/sort stay in-memory for Cyrillic order.
- Add AnomalyRepository.CountSinceAsync (unread badge) + ListByDateRangeAsync
(feed date filter); add Event/Snapshot count methods for the dashboard.
- Add composite indexes IX_Snapshots_EventCode_CapturedAt and
_EventCode_Source_CapturedAt via a new migration + model snapshot.
- Introduce SqliteDateText as the single source of the O-format date encoding
shared by Mapping (read/write) and the repositories' range predicates.
- Fix LiveOddsPoller cadence drift (budget sleep against cycle time); make
DetectAnomalies dedup O(1) per event; add Event.Title to dedup the title join.
Tests adapted to the batched GetManyAsync via a TestFixtures bridge.
* New Marathon.Domain.ValueObjects.MoscowTime with Offset, Now, and
EndOfMoscowDay(DateOnly) — replaces ~15 inline TimeSpan.FromHours(3)
literals across Domain/Application/Infrastructure/UI.
* New Marathon.UI.Services.SportLabels.Resolve(IStringLocalizer, int) —
replaces 6 near-identical SportLabel switch bodies in EventListShell,
Events/Detail, Anomalies/AnomalyFeed, Results/ResultsList,
Results/ResultsLoader, and AnomalyCard. Single source of truth for the
6/11/22723/43658 sport-code mapping. Pages keep a one-liner wrapper so
the call sites stay terse.
DetectAnomaliesUseCase was issuing one ISnapshotRepository.ListByEventAsync
call per event each cycle, with each call rehydrating that event's bets via
Include(s => s.Bets) — O(N) SQLite round-trips and N Include payloads on
every detection cycle.
* Add ISnapshotRepository.ListByEventsAsync(IReadOnlyCollection<EventId>, …)
returning a per-event dictionary; events with no snapshots in range get
Array.Empty<OddsSnapshot>() so the caller doesn't need a presence check.
* Implementation uses a single .Where(s => ids.Contains(s.EventCode))
query and groups in memory.
* DetectAnomaliesUseCase loads the whole batch once before the foreach,
then ProcessEventAsync receives the per-event slice as a parameter.
* Tests updated to stub the new method; per-event-failure test now
exercises an AddAsync throw rather than a snapshot-load throw, since
individual snapshot loads no longer fail per-event.
Phase 7 reviewer (Sonnet, combined backend + frontend) flagged 3 🟡 warnings;
two real fixes here, one tracking:
W1 — DetectAnomaliesUseCase had an undocumented N+1: _anomalyRepo.ListAsync
was called inside ProcessEventAsync, once per event. Hoisted to ExecuteAsync
before the loop and threaded into ProcessEventAsync as a parameter. The
per-event slice happens in-memory now. O(N_events) DB round-trips → 1.
W2 — AnomalyDetector.ExtractMatchWinProbabilities had a dead expression
'(decimal?)null ?? 0m' that always evaluated to 0m. Simplified to
'drawBet is not null ? rawDraw / total : 0m'. The 0m is never surfaced
anyway (PDraw in the return uses the same null guard), so behaviour is
identical.
W3 — PLAN.md row updated with both Phase 7 commit hashes (a6ff368 backend
+ 12208a4 frontend) and review verdict.
Build 0/0, 276 tests still passing.
- 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)