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.
Snapshots are append-only and identified by the composite (EventId, CapturedAt),
not a surrogate Guid. The previous implementation inherited
IRepository<Guid, OddsSnapshot> and faked the lookup with (long)key.GetHashCode()
on a long auto-increment PK — collision-prone and non-portable. Nobody called
GetAsync(Guid) / DeleteAsync(Guid) anyway.
* ISnapshotRepository no longer extends IRepository<Guid, OddsSnapshot>; it
exposes only the methods snapshots actually have: ListAsync, ListByEventAsync,
AddAsync, SaveChangesAsync.
* SnapshotRepository drops the broken Get/Update/Delete methods.
Implements Phase 8 Amendment 1: marathonbet.by has no public results archive
endpoint, so results must be harvested per-event by re-fetching the event
detail page until eventJsonInfo.matchIsComplete=true.
Backend changes:
* IOddsScraper:
- ScrapeResultsAsync(DateRange) replaced with ScrapeEventResultAsync(Event)
returning a nullable EventResult — null when match still in progress.
- ScrapeEventOddsAsync now takes the full Event (so EventPath drives URL
construction) instead of bare EventId.
- New ScrapeLiveAsync() for the /su/live listing.
* Domain:
- Event gains EventPath (nullable string) — the data-event-path attribute
captured during scraping; required for reliable URL construction.
* Infrastructure:
- New migration 20260506000000_AddEventPath adds the column.
- EventEntity / EventConfiguration / Mapping / model-snapshot updated.
- MarathonbetScraper: new ScrapeLiveAsync + ScrapeEventResultAsync; URL
builder prefers EventPath, falls back to numeric ID for legacy rows.
- EventListingParserBase extracts data-event-path on every listing row.
* Application:
- PullResultsUseCase: branches on selection vs date-range, emits IProgress<
PullResultsProgress>, returns ResultLoadOutcome (Loaded / AlreadyLoaded /
NotYetComplete / Failed); idempotent (skips events whose result already
exists).
- PullLiveOddsUseCase now drives off the live listing (auto-discovers
events that go live without ever appearing in the upcoming list) and
backfills EventPath on legacy rows.
- PullUpcomingEventsUseCase wires EventPath on persisted events.
* Workers: UpcomingEventsPoller updates persistence path accordingly.
* Tests: 17 net-new tests across Application + Infrastructure + Domain;
all 293 still pass.
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)
Phase 4 reviewer (Sonnet) flagged two 🟡 warnings; both addressed:
1. PullLiveOddsUseCase: removed dead 'liveEvents' assignment that called
ScrapeUpcomingAsync but never read the result. Replaced misleading
comment block with a single TODO(phase-6/8) note pointing to the
ListLiveAsync(cutoff) follow-up.
2. Marathon.UI.Services.WorkerOptions.UpcomingScheduleCron: changed default
from '0 */5 * * * *' (every 5 min) to '0 0 */6 * * *' (every 6 hours)
to match Marathon.Infrastructure.Configuration.WorkerOptions and the
appsettings.json default.
PLAN.md: Phase 4 row updated (review status, commit hash); top-level
checkbox in Phases list ticked.
Build 0/0, all 202 tests still passing.
Creates the 9-project .NET 8 solution (5 src + 4 test) with Marathon.Domain
fully implemented: value objects (SportCode, EventId, OddsRate, OddsValue,
BetScope hierarchy), enums (Side, BetType, OddsSource, AnomalyKind), and
entities (Sport, Country, League, Event, Bet, OddsSnapshot, EventResult,
Anomaly) with all invariants enforced in constructors. 96 domain tests pass
(FluentAssertions + xUnit). Directory.Build.props and Directory.Packages.props
centralise build settings and NuGet versions. Both Marathon.sln and Marathon.slnx
are committed; dotnet build Marathon.sln succeeds with 0 warnings/errors.