31 Commits

Author SHA1 Message Date
alexei.dolgolyov 004dbeae8b fix(scraping): live page lacks data-event-path and uses category sport IDs
Previously LiveEventsParser returned 0 events from /su/live because two
real differences between the live page and the pre-match listing weren't
handled:

1. Live rows omit data-event-path entirely. They expose only
   data-event-treeId, and the bookmaker routes live events under
   /su/live/<treeId> rather than /su/betting/<...>.

2. The closest data-sport-treeId ancestor on the live page is a
   category-tree wrapper (26418=Football-live, 45356=Basketball-live, …)
   instead of the canonical breadcrumb sport ID (11/6/22723/43658) the
   rest of the app uses. The pre-match listing carries the canonical
   ID directly.

Changes:

* EventListingParserBase.ParseRow: data-event-path becomes optional. For
  live rows we synthesize EventPath = "live/<treeId>" from
  data-event-treeId (validated as digits-only). Pre-match validation is
  unchanged.

* New ExtractSportCodeFromLive walks ancestors looking for a sport-tree
  ID and maps it through a small live-id → canonical-id table covering
  the four scoped sports. Out-of-scope sports (cybersport, volleyball,
  table tennis) are intentionally left unmapped — they keep their raw
  category ID and the UI renders them via SportLabels as "Sport <N>".

* MarathonbetScraper.ResolveEventDetailPath: dispatches between
  /su/live/<treeId> and /su/betting/<...> based on the EventPath prefix.
  Removes the duplicated path-building between ScrapeEventOddsAsync and
  ScrapeEventResultAsync.

* New regression tests covering all three behaviors against a real
  /su/live capture (16 events, 5 sport categories).

Also: rewrites the stale "Disabled until Phase 8" hint copy on the
Settings.Workers.ResultsPollerEnabled flag — Phase 8 shipped, so the
results poller is safe to enable.
2026-05-09 16:07:03 +03:00
alexei.dolgolyov 537b78ab83 fix(security): validate scraped paths, BaseUrl, settings paths + atomic write
Five MEDIUM-tier security findings from the review. None were exploitable in
the single-user desktop threat model, but each is hardening for future hosts
(server / multi-user) and for the case of an upstream compromise.

* EventListingParserBase: scraped data-event-path values are now run through
  IsSafeRelativePath before being concatenated into request URLs. Rejects
  scheme://host/* patterns, leading slash/backslash (network-path traversal),
  ".." traversal, control characters (CRLF log forging), and path lengths
  greater than 512.

* ScrapingModule.ResolveBaseAddress: configured Scraping:BaseUrl is now
  validated through an https-only allow-list (marathonbet.by + subdomains).
  Falls back to the default base URL when the value is missing, malformed,
  off-host, or carries userinfo / query / fragment. Settings UI may write
  arbitrary strings to this key — a typo or worse could otherwise re-point
  every subsequent request at an unrelated host.

* JsonSettingsWriter.WriteRootAsync: temp-file write now FlushAsync +
  Flush(flushToDisk: true) before the rename so a crash mid-write doesn't
  leave a 0-byte appsettings.Local.json. Promote the rename from File.Move
  (overwrite=true, not atomic on NTFS for cross-volume cases) to
  File.Replace (atomic on NTFS, keeps a .bak copy of the previous file).
  Falls back to File.Move when the destination doesn't exist yet.

* StoragePathValidator + Settings.razor wiring: DatabasePath and
  ExportDirectory paths from the Settings page are validated before persist.
  Rejects empty values, ".." traversal, control characters, and any path
  that resolves outside AppContext.BaseDirectory. Adds 4 new RU+EN keys for
  the validation messages.

* Logged scraper paths: covered transitively by the EventPath validation
  above (the upstream of every logged {Path} value), so no separate
  sanitization needed.
2026-05-09 15:50:52 +03:00
alexei.dolgolyov c2934b2c8d chore(med): mapping culture-safe parse, dead-code, scope comparer, UA rotator, parser cache
Six MEDIUM-tier review items:

* Mapping.cs — DateTimeOffset.Parse now passes CultureInfo.InvariantCulture
  + DateTimeStyles.RoundtripKind so a non-en-US thread culture cannot
  corrupt round-tripped ScheduledAt / CapturedAt / DetectedAt / CompletedAt.
  Also replaces the magic 0/1 BetScope discriminator with named constants.

* Delete dead Placeholder.cs files in Marathon.Application and
  Marathon.Infrastructure — they were stubs from Phase 1 to satisfy
  "non-empty project" and have been dead since Phase 2/3.

* EventBrowsingService — drop the bespoke ScopeEqualityComparer; BetScope
  is a record hierarchy, .GroupBy uses value equality natively.

* UserAgentRotatorHandler — counter promoted to private static int with
  Interlocked.Increment so rotation is round-robin across the process.
  HttpClientFactory builds the handler Transient, so the previous instance
  field reset to zero on every new client and broke rotation.

* EventOddsParser — added a parallel "selection-key → IElement" index
  alongside the existing price index. Handicap extraction (6 call sites
  per event detail page) used to do a fresh document.QuerySelector("span[
  data-selection-key='...']") for every key — full-document CSS traversal.
  Now it's a dictionary lookup, with the pair-emit logic factored into a
  shared TryEmitHandicapPair helper.
2026-05-09 15:45:18 +03:00
alexei.dolgolyov fed3a09695 refactor: hoist Moscow offset + sport labels into shared helpers (HIGH)
* 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.
2026-05-09 15:40:35 +03:00
alexei.dolgolyov d1e6ce7ce2 fix(ui): EventListShell — reload on Filter swap + harden refresh-timer (HIGH+MED)
* Filter changes from the parent (page-state singleton swap) now trigger
  LoadAsync via OnParametersSetAsync once the component has rendered.
  Previously the reload happened only on first render, so navigating back
  to a list page with a different sport/date filter showed the prior data.
* Refresh-timer Elapsed handler is hoisted to a named async-void method
  with try/catch around InvokeAsync. An unhandled exception on the timer
  thread used to crash WebView2; now it's logged and swallowed.
* Overlapping ticks are skipped via a _loading short-circuit so a slow
  Loader doesn't stack up cancelled-superseded loads.
2026-05-09 15:31:48 +03:00
alexei.dolgolyov 857d456b95 perf(ui): batch event-list snapshot loads + push distinct dimensions to DB (HIGH)
* EventBrowsingService.BuildListAsync issued one snapshot query per event
  on every page render — N+1 against SQLite, with each round-trip hauling
  the full bet graph via Include(Bets). Replaced with a single
  ISnapshotRepository.ListByEventsAsync batch.
* ListKnownSportCodesAsync / ListKnownCountryCodesAsync used to materialise
  every Event row to compute Distinct() in memory. Pushed to EF projection
  via two new IEventRepository methods (ListDistinctSportCodesAsync,
  ListDistinctCountryCodesAsync) implemented as
  .Select(...).Distinct().ToListAsync — single SELECT DISTINCT.
2026-05-09 15:29:05 +03:00
alexei.dolgolyov 286b55986b perf(scraping): parallel HTTP fan-out, sequential DB persist (HIGH)
The Pull*UseCase implementations issued one HTTP request at a time despite
Scraping:MaxConcurrentRequests=4. With 30–80 live events and ~1s per
fetch, a 5–10s live cadence target was unreachable; cycles overflowed
the configured interval.

* New Marathon.Application.Configuration.ScrapingThrottle bound from the
  shared Scraping:* section. Exposes only MaxConcurrentRequests so the
  Application layer doesn't pull in the Infrastructure-side ScrapingOptions.
* PullLiveOddsUseCase + PullUpcomingEventsUseCase split into two phases:
  - Phase 1 — Parallel.ForEachAsync over the event list with
    MaxDegreeOfParallelism = throttle.MaxConcurrentRequests. The scraper's
    Polly rate limiter still throttles to RequestsPerSecond underneath
    this fan-out, so spikes are smoothed before they hit the bookmaker.
  - Phase 2 — sequential foreach over the (Event, Snapshot) tuples
    captured in Phase 1, doing event upsert + snapshot insert. EF Core
    DbContext is not thread-safe so all DB writes stay on a single thread.
* InfrastructureModule binds ScrapingThrottle alongside AnomalyOptions.
* Failed snapshot scrapes in Phase 1 mean the event row is also NOT
  persisted in Phase 2 — previously we'd persist the row even when the
  snapshot scrape failed, leaving an orphan event with no odds. Updated
  the regression test accordingly.
* Test fixture exposes TestFixtures.Throttle(maxConcurrentRequests=1) for
  deterministic sequential test runs.
* One existing NSubstitute setup that chained Arg.Is<>() across two
  configurations was rewritten to use a single Arg.Any<>() with inline
  branching — chained matchers were leaking and returning wrong results.
2026-05-09 15:27:06 +03:00
alexei.dolgolyov 66ae038243 perf(detect-anomalies): batch snapshot loads into a single query (HIGH)
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.
2026-05-09 15:17:49 +03:00
alexei.dolgolyov 958d472582 fix(ui): MainLayout — declare @implements IDisposable so Dispose actually runs (HIGH)
The component had a Dispose() method that detached ThemeState/LocaleState
event handlers, but it was never invoked because the @implements directive
was missing. Each navigation through the layout leaked two subscriptions
on the singleton state objects.

Other state subscribers (NavBody.razor, AnomalyFeed.razor) already declare
the directive correctly.
2026-05-09 15:14:34 +03:00
alexei.dolgolyov a6bd8a0e44 fix(persistence): drop broken Guid lookup on ISnapshotRepository (CRITICAL)
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.
2026-05-09 15:13:22 +03:00
alexei.dolgolyov a627c360c3 fix(ui): invariant decimals + LocaleState test isolation + drawer offset + bundled Plotly
Cross-cutting polish that surfaced while Phase 8 was being implemented:

* Invariant-culture formatting on every decimal ToString("0.00" / "0.##")
  (OddsCell, OddsTimeline, SeverityBadge, AnomalyEvidence,
  Pages/Events/Detail) — previously the comma/dot decimal separator
  switched with the locale and broke tabular alignment + tests.

* LocaleState constructor no longer mutates process-wide ambient culture;
  apply only happens through Set(...). Stops parallel bUnit test runs
  leaking ru-RU into each other's threads.

* MainLayout: drawer-open state now offsets main content / footer / appbar
  by the drawer width (248px) so the sidebar no longer overlaps content.
  Mobile breakpoint (≤720px) keeps the original full-width layout.

* wwwroot/index.html (Marathon.UI RCL): switched from Plotly CDN to the
  bundled "_content/Plotly.Blazor/plotly-2.35.3.min.js" — works offline
  and matches the Plotly.Blazor 5.4.1 version pin.

* Marathon.Hosts.WpfBlazor/wwwroot/index.html: host-level page that
  BlazorWebView's HostPage attribute resolves to. Same Plotly bundle,
  no autostart="false" (BlazorWebView auto-starts).
2026-05-09 15:11:13 +03:00
alexei.dolgolyov 9f090cec1f feat(phase-8-frontend): results loader UI + browsing list + 41 localization keys
* Pages/Results/ResultsList.razor — completed-events list with date range,
  sport/winner filter, search, footer count.
* Pages/Results/ResultsLoader.razor — driver page with two modes (load all
  in range / load selected events), live progress reporting via
  IProgress<PullResultsProgress>, summary line, cancellable.
* Replaces the Phase 5 Pages/Results.razor placeholder.

Service layer:
* IResultsBrowsingService + ResultsBrowsingService (Scoped, mirrors the
  Event/Anomaly browsing-service pattern). Reads IResultRepository +
  IEventRepository, projects to immutable view-model records.
* UiServicesExtensions: registers ResultsBrowsingService; also fixes an
  unrelated localization resolver bug (drop ResourcesPath since
  SharedResource lives in the Marathon.UI.Resources namespace already).

Localization:
* 41 new Results.* keys (RU+EN parity) covering both pages, filter chips,
  loader modes, progress states, and footer copy.

Tests:
* ResultsListTests + ResultsLoaderTests — 22 new bUnit tests covering
  filter narrowing, mode switching, progress aggregation, and empty
  states.
* FakeResultsBrowsingService support type for tests.
* MarathonTestContext registers the fake; TestData adds factories for
  EventResult/EventResultListItem.
2026-05-09 15:10:49 +03:00
alexei.dolgolyov 9c5d3df1f2 feat(phase-8-backend): per-event results harvesting + EventPath plumbing
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.
2026-05-09 15:10:27 +03:00
alexei.dolgolyov 1bbf4fcfed chore: gitignore Claude Code per-session task metadata (.claude/) 2026-05-09 15:09:37 +03:00
alexei.dolgolyov 85bc99cac5 fix(host): wire DB migration init + Plotly CDN + attribute fix on hand-written migration
Three fixes surfaced when launching the WPF host for the first time:

1. App.xaml.cs — call MarathonDbContextInitializer.InitializeAsync()
   between Host.Build() and Host.Start() so EF migrations + WAL pragma
   are applied BEFORE BackgroundServices race to query the DB. Without
   this, all pollers crashed on 'no such table: Events'.

2. wwwroot/index.html — added <script src='https://cdn.plot.ly/plotly-2.35.2.min.js'>
   before blazor.webview.js. Phase 6 reviewer flagged this for Phase 9,
   but charts are unrenderable without it; better to ship now.

3. Migrations/20260505000000_InitialCreate.cs — added [DbContext] and
   [Migration('20260505000000_InitialCreate')] attributes. Phase 2's
   hand-written migration was missing both, so EF saw 'no migrations to
   apply' even on a fresh DB. With the attributes, the migration runs
   on first launch and creates all tables (Events, Snapshots, Bets,
   EventResults, Anomalies, Sports, Leagues).

Verified: clean DB → migration applied → all 7 tables created → pollers
run with empty results (no data yet — UpcomingEventsPoller fires every 6h
by default; first scrape will populate the DB).
2026-05-05 13:55:59 +03:00
alexei.dolgolyov 828dcf5a08 fix(phase-7): close review notes — hoist anomaly dedup query, drop dead expr
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.
2026-05-05 13:46:34 +03:00
alexei.dolgolyov 12208a4762 feat(phase-7-frontend): anomaly feed UI + nav badge + Settings toggle (+31 bUnit tests)
Frontend portion of Phase 7. Backend (commit a6ff368) had already shipped
the AnomalyDetector, DetectAnomaliesUseCase, AnomalyDetectionPoller, and
all DI wiring. This commit adds the user-facing surfaces.

New surfaces (Option A routing — folder-per-feature):
- Pages/Anomalies/AnomalyFeed.razor (@page /anomalies) — replaces the
  Phase 5 placeholder with a severity-coded card stream, filter chips
  (severity / sport / date), unread-count summary, 'Mark all read' action.
- Pages/Anomalies/Detail.razor (@page /anomalies/{id:guid}) — m-detail-header
  lockup + AnomalyEvidence panel + back link to /events/{eventCode}.

New components:
- AnomalyCard.razor — severity-tinted left border (signal-red on High,
  amber on Medium, neutral on Low) + SeverityBadge pill + sport icon +
  pre→post tabular-mono rate strip + relative time. Click navigates.
- SeverityBadge.razor — small pill mapping score → bucket per backend
  handoff (Low <0.45, Medium <0.60, High ≥0.60).
- AnomalyEvidence.razor — two-column pre/post panel with implied-prob
  bars + raw rates; favourite-swap callout when argmax(p_pre) ≠ argmax(p_post);
  signal-red 3px left border on the post column. Handles 2-way (no draw).

State + service split mirrors Phase 6's pattern:
- AnomalyViewModels.cs — AnomalyListItem / AnomalyDetailVm / Severity enum
  / AnomalyEvidenceSnapshot record. Severity computed in the view-model
  from Score.
- IAnomalyBrowsingService / AnomalyBrowsingService — wraps IAnomalyRepository,
  parses Anomaly.EvidenceJson into typed view-models, applies filters
  client-side. Methods: ListAsync(filter, ct), GetByIdAsync(id, ct),
  GetUnreadCountAsync(since, ct).
- AnomalyBrowsingState — Singleton holding AnomalyFilter (severity threshold,
  sport set, date range) + LastSeenUtc + cached UnreadCount. OnChange event.

Nav badge:
- NavBody.razor subscribes to AnomalyBrowsingState.OnChange, renders a
  pulsing red m-nav__badge when UnreadCount > 0. Badge resets when the
  user clicks 'Mark all read' on the feed toolbar.

Settings toggle:
- Settings.razor — added Workers:AnomalyDetectionEnabled toggle (backend
  added the flag). Localized via Settings.Worker.AnomalyDetectionEnabled.
- Marathon.UI.Services.WorkerOptions mirror — added AnomalyDetectionEnabled
  (default true).

Localization: +30 RU/EN keys following the dot-segmented convention
(Anomaly.*, Settings.Worker.AnomalyDetectionEnabled). Full key parity verified.

Tests (+31 bUnit, all passing):
- AnomalyFeedTests, AnomalyDetailTests
- AnomalyCardTests, SeverityBadgeTests, AnomalyEvidenceTests
- FakeAnomalyBrowsingService support fake registered in MarathonTestContext.

Routing: deleted the Phase 5 Pages/Anomalies.razor placeholder; new feed
page lives at Pages/Anomalies/AnomalyFeed.razor.

Build: 0 warnings, 0 errors.
Tests: Domain 109 + Application 19 + Infrastructure 80 + UI 68 = 276/276
(baseline 245, +31 new bUnit tests, no regressions).

Phase 7 status:  Done (backend + frontend both complete, awaiting review).

Known deferral: AnomalyBrowsingState.LastSeenUtc is in-memory only; the
unread-count badge resets on app restart. Acceptable for now; Phase 9 may
extend ISettingsWriter or add an ILastSeenStore.
2026-05-05 13:39:39 +03:00
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
alexei.dolgolyov d915667da1 docs(phase-6): close review tracking — pass with notes (Sonnet)
Phase 6 reviewer flagged 5 🟡 warnings (none blocking):
- OddsCell decimal format mask is invariant-safe but lacks explicit culture
- SportIcon hex tints (4 colors, 2 new) bypass --m-c-* tokens
- OddsTimeline Plotly hardcodes hex (justified — Plotly API requires hex)
- N+1 snapshot query in EventBrowsingService.BuildListAsync
- Test naming uses Verb_outcome_qualifier instead of Should_<exp>_When_<cond>

All deferred to Phase 9 (polish/optimization). PLAN.md row updated.
2026-05-05 13:03:31 +03:00
alexei.dolgolyov 553db2bce3 feat(phase-6): event browsing UI — pre-match/live lists, detail page, +26 bUnit tests
Replaces PreMatch/Live placeholder pages with a shared EventListShell
(filter chips, date range, sortable virtualized-friendly table, debounced
search, live auto-refresh with odds-movement indicators) and adds a new
/events/{eventCode} detail page (asymmetric header lockup, dynamic
Match/Period tabs, Plotly.Blazor odds-over-time chart with accessible
data-table fallback, snapshot history, Excel export modal).

New primitives matching Phase 5's editorial-quant system:
- SportIcon: inline SVGs per sport (basketball=6, football=11,
  tennis=22723, hockey=43658, generic fallback)
- OddsCell: tabular mono with ▲/▼/— delta + flash on change
  (prefers-reduced-motion honored)
- OddsTimeline: Plotly.Blazor wrapper with theme-aware colors and
  <details>/<summary> data-table screen-reader fallback
- ExportDialog: From/To pickers + ExportKind radio + Esc/Enter
  keyboard, surfaces use-case errors inline
- EventListShell: shared section shell for PreMatch/Live cadence

State + service split keeps the RCL host-agnostic:
- IEventBrowsingService / EventBrowsingService — wraps repos, returns
  view-model records (EventListItem, EventDetail, EventScopeBoard,
  BetRow, OddsTimelinePoint, SnapshotHistoryEntry); pages never see
  EF or domain entities directly.
- EventBrowsingState — singleton (per-circuit in BlazorWebView) holding
  immutable PageFilter records for PreMatch and Live.

Plotly.Blazor 5.4.1 added (latest .NET 8 line; 7.x has breaking changes).
+59 RU/EN localization keys following the Phase 5 dot-segmented convention.

Tests: +26 bUnit tests (PreMatch/Live/Detail pages, OddsCell/SportIcon/
ExportDialog components, EventBrowsingState). Total 228/228 passing
(Domain 96 + Application 15 + Infrastructure 80 + UI 37; baseline 202).
Build clean (0/0).

PLAN.md: P2/P3/P5 top-level checkboxes ticked; P6 row marked Done.
2026-05-05 12:58:03 +03:00
alexei.dolgolyov fe97643a41 fix(phase-4): close review notes — drop dead var, sync UI cron default
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.
2026-05-05 12:35:01 +03:00
alexei.dolgolyov 2acbaa5b77 feat(phase-4): application layer + background workers — 202/202 tests green
Use cases (Marathon.Application/UseCases/):
- PullUpcomingEventsUseCase: scrape + persist new events + capture pre-match snapshots
- PullLiveOddsUseCase: refresh live snapshots for all stored events
- PullResultsUseCase: Phase 4 scaffold; delegates to ScrapeResultsAsync (Phase 3 no-op);
  Phase 8 will replace with watch-list polling
- ExportToExcelUseCase: resolves export dir from StorageOptions, delegates to IExcelExporter

ApplicationModule.AddMarathonApplication(IServiceCollection) — no IConfiguration needed.

Background workers (Marathon.Infrastructure/Workers/):
- UpcomingEventsPoller: Cronos 6-field cron schedule (default every 6 h)
- LiveOddsPoller: fixed interval (WorkerOptions.LivePollIntervalSeconds, default 30 s)
- ResultsWatchListPoller: scaffold, disabled by default (WorkerOptions.ResultsPollerEnabled=false)
All three: exception-swallowing, cancellation-aware, scoped DI via CreateAsyncScope().

InfrastructureModule.AddMarathonInfrastructure(IServiceCollection, IConfiguration):
- Composes AddMarathonPersistence + AddMarathonScraping + WorkerOptions + 3 hosted services

App.xaml.cs: replace reflection-based TryAddApplicationAndInfrastructure with direct
AddMarathonApplication() + AddMarathonInfrastructure(config) calls.

Resolved Phase 3 TODO: bind Sports:Basketball:QuarterMode from config in ScrapingModule.

appsettings.json: add Workers.LivePollIntervalSeconds, ResultsPollIntervalSeconds,
ResultsPollerEnabled; add Sports.Basketball.QuarterMode.

Settings.razor + WorkerOptions (UI) + SharedResource.*.resx: surface new Workers fields.

Tests: +14 Application use-case tests, +3 Infrastructure worker tests (185 → 202 total).
2026-05-05 12:28:15 +03:00
alexei.dolgolyov c4d87b59d6 fix(initial-implementation): close P2/P3/P5 review blockers — 185/185 tests green
Combined-batch reviewer flagged three real blockers + two test-infra
issues across the parallel P2/P3/P5 batch. All resolved:

PHASE 3 — DateTimeOffset UTC-kind constructor (3 sites)
  EventListingParserBase.cs:39, EventOddsParser.cs:72, ResultsParser.cs:104
  Replaced `new DateTimeOffset(DateTimeOffset.UtcNow.UtcDateTime, MoscowOffset)`
  (throws ArgumentException because UtcDateTime has Kind=Utc) with
  `DateTimeOffset.UtcNow.ToOffset(MoscowOffset)`.

PHASE 2 — EF string.Compare not translatable (3 sites)
  EventRepository.cs:34, SnapshotRepository.cs:46, ExcelExporter.cs:35
  Replaced `string.Compare(col, str, StringComparison.Ordinal)` with
  `col.CompareTo(str)` so EF Core's SQLite provider can translate the
  expression. Semantics unchanged (SQLite default collation = BINARY = ordinal).

PHASE 3 — ServerTimeProvider regex misses JSON-quoted key
  Regex `serverTime\s*:\s*"..."` only matched bare-key form. Updated to
  `"?serverTime"?\s*:\s*"..."` so the JSON-quoted form (the actual
  marathonbet.by production format) is matched.

PHASE 3 — fixture: orphan <td> elements stripped by HTML5 parser
  tests/.../Fixtures/marathonbet/event-football-sample.html — wrapped
  the <td> blocks in a proper <table><tbody><tr> hierarchy so AngleSharp
  preserves them and `td.Closest("td")` succeeds in the parser.

PHASE 2 — InMemoryDbFixture shared state across parallel tests
  All fixture instances used `Data Source=marathon_tests` causing xUnit's
  parallel-within-class runs to contaminate each other's data. Each fixture
  now uses a Guid-suffixed unique data source name.

PLAN.md — P2/P3/P5 rows updated to  Done with batch commit reference.

Test status:
  Domain.Tests:         96/96 
  Application.Tests:     1/1  
  Infrastructure.Tests: 77/77 
  UI.Tests:             11/11 
  TOTAL:               185/185 
Build: 0 warnings, 0 errors.

Deferred to later phases (per reviewer 🟡 / 🔵 notes):
- SnapshotRepository.GetAsync(Guid) uses lossy GetHashCode workaround;
  Phase 4 to fix or remove from interface.
- Excel Sport name column writes string.Empty (need lookup join in Phase 6).
- PeriodScopeMapper football n>2 falls through to "Quarter" token;
  guarded by MaxPeriods today, but defensive cleanup at Phase 9.
- Settings.razor duplicate m-rise-5 class on Localization section.
2026-05-05 12:09:44 +03:00
alexei.dolgolyov 686550d697 fix(initial-implementation): resolve P2/P3 cross-phase build issues
Three minimal fixes to make Marathon.sln build with 0/0:

1. Marathon.Infrastructure.csproj — add InternalsVisibleTo for
   Marathon.Infrastructure.Tests so test code can reference internal
   repository and exporter classes (Phase 2 issue blocking Phase 3 tests).
2. EventOddsParserTests.cs — add 'using Marathon.Domain.ValueObjects' so
   MatchScope/PeriodScope resolve.
3. RoundTripTests.cs — add 'using Microsoft.EntityFrameworkCore' so the
   ExecuteSqlRawAsync extension method on DatabaseFacade resolves.

Phase 5's anticipated LocalizationOptions / Serilog issues were already
resolved by its agent before being killed — no changes needed there.

Build status: 0 warnings, 0 errors.
Test status: Domain 96/96, UI 11/11, Infrastructure 42/77 (35 failing —
parser fixture issues + a real DateTimeOffset bug; reviewer will assess).
2026-05-05 11:35:42 +03:00
alexei.dolgolyov e4d8476782 WIP(initial-implementation): parallel batch P2/P3/P5 — code complete, unreviewed
Snapshot of the parallel batch (Phases 2 + 3 + 5) at session pause. Solution does
NOT build cleanly yet — known cross-phase compile issues remain to be resolved
before review. See plans/initial-implementation/PLAN.md "Resume Notes" section
for the exact tomorrow-morning action list.

Phase 2 (Storage):
- Repository interfaces in Marathon.Application/Abstractions
- DateRange, ExportKind, StorageOptions in Marathon.Application/Storage
- EF Core 8 + SQLite (WAL) persistence: 7 entities + configurations + 4 repos
- Hand-written InitialCreate migration (dotnet ef blocked by parallel work)
- ClosedXML ExcelExporter with exact customer-spec wide columns
- PersistenceModule.AddMarathonPersistence DI extension
- Round-trip + export tests (cannot run yet — see cross-phase issues)

Phase 3 (Scraping):
- IOddsScraper, IBetPlacer in Marathon.Application/Abstractions
- ScrapingOptions in Marathon.Infrastructure/Configuration
- MarathonbetScraper with 4 parsers (Upcoming, Live, EventOdds, Results)
- Helpers: ServerTimeProvider, PeriodScopeMapper, OutcomeCodeMapper, MoscowDateParser
- UserAgentRotatorHandler + Polly v8 resilience pipeline
- ScrapingModule.AddMarathonScraping DI extension
- GlobalUsings.cs aliases for EventId / Configuration disambiguation
- Parser tests with trimmed HTML fixtures
- ScrapeResultsAsync interim no-op (Phase 8 will replace via watch-list polling)

Phase 5 (UI shell — killed mid-final-verify, assumed ~95%):
- Marathon.UI populated: MainLayout, App.razor, Pages (Home, Settings),
  Components, Theme (MarathonTheme.cs + Tokens.cs + app.css), Resources
  (SharedResource.{cs,ru.resx,en.resx}), Services (ISettingsWriter), wwwroot
- WPF host: App.xaml(.cs), MainWindow.xaml(.cs), Marathon.Hosts.WpfBlazor.csproj
  with Microsoft.AspNetCore.Components.WebView.Wpf + MudBlazor + Serilog
- appsettings.json + appsettings.Development.json with all sections wired
- bUnit tests: MainLayoutTests, LocaleSwitcherTests, ThemeToggleTests,
  JsonSettingsWriterTests + Support helpers

Cross-phase issues to resolve at next session:
1. Phase 2 repository classes are 'internal' — Phase 3's tests can't reference
   them. Fix: add InternalsVisibleTo to Marathon.Infrastructure.csproj.
2. Phase 5: LocalizationOptions namespace ambiguity (AspNetCore vs Extensions).
3. Phase 5: WpfBlazor Serilog API mismatch.

Reviewer has NOT run on this batch. Move to Phase 4 only after build is green
and a combined parallel-batch reviewer passes.
2026-05-05 01:56:53 +03:00
alexei.dolgolyov 144c936e90 chore(packages): pre-stage MudBlazor, Localization, Cronos, Configuration for parallel phases 2/3/5 2026-05-05 01:28:37 +03:00
alexei.dolgolyov 9614b8cf37 chore(initial-implementation): remove RCL boilerplate, close phase 1 tracking
Per Phase 1 reviewer notes — strips Component1.razor, ExampleJsInterop, and
the wwwroot template assets generated by 'dotnet new razorclasslib'. Phase 5
will populate Marathon.UI from scratch with the real layout, components, and
wwwroot/index.html for BlazorWebView. Build still green (0/0).
2026-05-05 01:26:55 +03:00
alexei.dolgolyov 61114ea31b feat: implement Phase 1 — solution skeleton and domain model
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.
2026-05-05 01:20:28 +03:00
alexei.dolgolyov e4b03f42ef docs(initial-implementation): close phase 0 tracking, log phase 8 amendment 2026-05-05 01:04:57 +03:00
alexei.dolgolyov 070e34b911 feat(initial-implementation): phase 0 - scraping spike findings
Anonymous scraping confirmed feasible for marathonbet.by — site is fully SSR
(nginx), no Cloudflare or JS challenge. HttpClient + AngleSharp + Polly v8 is
sufficient; Playwright not required (kept as a future-flag).

Spike outputs:
- spike/SCRAPE_FINDINGS.md  — page rendering, URL templates, anti-bot, rate
  limits, recommended scraping strategy for Phase 3.
- spike/SCHEMA_DRAFT.md     — customer-spec field → DOM selector mapping for
  Match + Period-N scope across football/basketball/tennis (hockey TBD).

Phase 1+ handoff captured in subplan + CLAUDE.md. Critical Phase 8 finding:
no public results endpoint at /su/results — phase 8 must switch to polling
event-detail until eventJsonInfo.matchIsComplete=true (deviation flagged).

Reviewer notes addressed:
- Period market outcome codes corrected to RN_H/RN_D/RN_A (not 1/draw/3) and
  market name vocabulary clarified per-sport in SCHEMA_DRAFT §3.1.
- results-page.html capture added to file list with caveat about live-landing
  score-state and unsampled hockey selectors.
2026-05-05 01:04:03 +03:00
alexei.dolgolyov 8802ddb25b docs(initial-implementation): add feature plan and 10 phase subplans 2026-05-05 00:39:27 +03:00
249 changed files with 43372 additions and 1 deletions
+60
View File
@@ -0,0 +1,60 @@
root = true
[*]
charset = utf-8
end_of_line = crlf
insert_final_newline = true
trim_trailing_whitespace = true
[*.{cs,csx}]
indent_style = space
indent_size = 4
# C# formatting rules
dotnet_sort_system_directives_first = true
dotnet_separate_import_directive_groups = false
# Expression preferences
csharp_prefer_simple_using_statement = true:suggestion
csharp_prefer_braces = true:silent
csharp_style_namespace_declarations = file_scoped:warning
csharp_using_directive_placement = outside_namespace:warning
# var preferences
csharp_style_var_for_built_in_types = false:suggestion
csharp_style_var_when_type_is_apparent = true:suggestion
csharp_style_var_elsewhere = false:suggestion
# Expression-bodied members
csharp_style_expression_bodied_methods = false:silent
csharp_style_expression_bodied_constructors = false:silent
csharp_style_expression_bodied_properties = true:suggestion
# Null checking preferences
csharp_style_throw_expression = true:suggestion
csharp_style_conditional_delegate_call = true:suggestion
# Modifier ordering
dotnet_style_require_accessibility_modifiers = for_non_interface_members:warning
# Naming conventions
dotnet_naming_rule.private_fields_should_be_underscore_camel_case.severity = suggestion
dotnet_naming_rule.private_fields_should_be_underscore_camel_case.symbols = private_fields
dotnet_naming_rule.private_fields_should_be_underscore_camel_case.style = underscore_camel_case_style
dotnet_naming_symbols.private_fields.applicable_kinds = field
dotnet_naming_symbols.private_fields.applicable_accessibilities = private, protected, private_protected
dotnet_naming_style.underscore_camel_case_style.required_prefix = _
dotnet_naming_style.underscore_camel_case_style.capitalization = camel_case
[*.{xml,csproj,props,targets}]
indent_style = space
indent_size = 2
[*.{json,yml,yaml}]
indent_style = space
indent_size = 2
[*.md]
trim_trailing_whitespace = false
+3
View File
@@ -81,3 +81,6 @@ appsettings.*.local.json
# Scraping fixtures captured during Phase 0 spike (kept locally, not in repo)
spike/captures/
# Claude Code per-session task metadata (local only)
.claude/
+101 -1
View File
@@ -102,4 +102,104 @@ Marathon_<YYYY-MM-DD>_to_<YYYY-MM-DD>.xlsx
## Recurring Issues & Patterns
(Populated as we work — leave empty until something repeats.)
- **`dotnet new sln` on .NET 10 SDK produces `.slnx`**, not `.sln`. If the plan
references `Marathon.sln`, hand-craft the traditional format alongside `.slnx`.
- **`Marathon.Application` namespace vs `System.Windows.Application`:** in any WPF
project that references `Marathon.Application`, always write
`System.Windows.Application` fully qualified in `App.xaml.cs`.
- **`Directory.Build.props` must NOT set `TargetFramework`** when projects in the
same solution use different TFMs (e.g., `net8.0` vs `net8.0-windows`).
- **Razor source generator does NOT accept C# 11 raw string literals** (`"""…"""`)
inside `@code` blocks — concatenate single-quoted attribute strings instead.
- **Razor reserves the identifier `code`.** Loop variables must use any other
name (`var sportCode in ...`) or the parser treats it as the `@code` directive.
- **`MudBlazor.DateRange` shadows `Marathon.Application.Storage.DateRange`** in
any Razor file that pulls both namespaces via `_Imports.razor`. Use a per-file
alias: `using AppDateRange = Marathon.Application.Storage.DateRange;`.
- **`Plotly.Blazor.LayoutLib.Margin` clashes with `MudBlazor.Margin`.** Fully
qualify the Plotly side at the new-expression: `new Plotly.Blazor.LayoutLib.Margin {…}`.
- **`Event.ScheduledAt` requires offset `+03:00`.** Test fixtures and any code
that constructs Moscow datetimes must use `new DateTimeOffset(date, TimeSpan.FromHours(3))`,
never pass a `DateTime.UtcNow` value to that constructor.
## Feature: Initial Implementation > Phase 4: Application + Workers — Learnings
- **Two `WorkerOptions` classes coexist** with the same JSON shape but different namespaces:
`Marathon.Infrastructure.Configuration.WorkerOptions` (immutable `init`, used by workers)
and `Marathon.UI.Services.WorkerOptions` (mutable `set`, used by Settings page).
Both bind to `"Workers"` in `appsettings.json`. Keep them in sync when adding new keys.
- **`Microsoft.Extensions.Logging.EventId` conflicts with `Marathon.Domain.ValueObjects.EventId`**
in any project that adds `Microsoft.Extensions.Logging.Abstractions`. Fix with a global alias
in `GlobalUsings.cs`: `global using LogEventId = Microsoft.Extensions.Logging.EventId;`
and local file aliases where both are used together.
- **NSubstitute cannot proxy `sealed` classes.** Use cases are `sealed record` or `sealed class`.
Worker tests must build a real use-case instance backed by substituted interfaces rather than
substituting the use case directly.
- **`BackgroundService` workers are singletons; use cases are scoped.** Always resolve scoped
use cases via `IServiceProvider.CreateAsyncScope()` inside the worker loop — never inject them
directly into the constructor.
- **Cronos 6-field cron format.** Pass `CronFormat.IncludeSeconds` to `CronExpression.Parse`
when the expression has a seconds field (e.g., `"0 0 */6 * * *"`). Default Cronos parse
expects 5-field (no seconds).
- **`ApplicationModule.AddMarathonApplication` takes no `IConfiguration`** — the Application
layer has no config bindings of its own. Infrastructure and UI bind their own options sections.
## Feature: Initial Implementation > Phase 0: Scraping Spike — Learnings
(Permanent learnings about marathonbet.by data shape, anti-bot, page structure.
For full detail see `spike/SCRAPE_FINDINGS.md` and `spike/SCHEMA_DRAFT.md`.)
- **Site is fully SSR (`Server: nginx`).** Anonymous GET with browser User-Agent
returns full HTML for `/su/`, `/su/live`, `/su/popular/<Sport>`,
`/su/betting/<event-path>`. No Cloudflare, no JS challenge.
- **Use HttpClient + AngleSharp + Polly v8** — no Playwright needed for read-only.
Keep `Scraping:UsePlaywright = false` flag for future-proofing.
- **Sport ID = `data-sport-treeId` = breadcrumb canonical ID.** Confirmed:
Basketball=6, Football=11, Tennis=22723, Hockey=43658. URL by ID:
`/su/betting/<Sport>+-+<id>` (preferred over `/su/popular/<Sport>` because the
ID is stable).
- **`EventCode` = `data-event-eventId`** (numeric, ~26-million range, stable).
`TreeId` = `data-event-treeId` (URL-routing ID, less stable). Use `EventCode`
as the entity primary key in SQLite.
- **Selection key format:** `{eventId}@{MarketName}{LineIndex?}.{Outcome}`.
Outcomes: `1`/`draw`/`3` for 3-way, `HB_H`/`HB_A` for handicap, `Under_<X>`/
`Over_<X>` for totals. Total threshold is encoded in the outcome string;
handicap value lives in `<span class="middle-simple">` text.
- **Tennis has no Draw outcome.** Domain `Bet_Match_Draw` must be nullable; Excel
exporter writes empty cell when null.
- **Date parsing:** listing shows `HH:MM` (today) or `DD <ru-month> HH:MM` (future).
Anchor with `initData.serverTime` (Moscow TZ, format `YYYY,MM,DD,HH,MM,SS`)
parsed from the embedded `<script>` blob on every scraped page.
- **Live updates:** site polls `/su/liveupdate/popular/?treeIds=...` every 3 s but
response is just `{"modified":[{"type":"refreshPage"}],...}` — re-scrape the
full event detail HTML for actual odds. Our analyzer cadence: pre-match 30 s,
live 510 s.
- **No public results / archive page** (`/su/results` → 404). Final scores must
be harvested by polling the event detail page until
`eventJsonInfo.matchIsComplete=true`, then storing `resultDescription`. Phase 8
cannot back-fill from a public archive.
- **Period scope vocabulary varies by sport:** football=`1st_Half`, basketball=
`1st_Half`/`1st_Quarter`, tennis=`1st_Set`, hockey=`1st_Period`. Domain stores
`PeriodNumber:int` and a sport-aware `PeriodScopeMapper` resolves the correct
market token at parse time.
## Feature: Initial Implementation > Phase 7: Anomaly UI — Learnings
- **Severity buckets are a single-source rule** in
`Marathon.UI.Services.AnomalySeverityRules.FromScore` (Low <0.45, Medium <0.60,
High ≥0.60). The badge pill, card border, feed filter chip, and stat strip all
bind through it — never duplicate the thresholds.
- **`AnomalyBrowsingService` is Scoped, `AnomalyBrowsingState` is Singleton** —
same lifetime split as `EventBrowsingService` / `EventBrowsingState`. State
holds the immutable `AnomalyFilter` + `LastSeenUtc` + cached unread count.
- **`Anomaly.EvidenceJson` is parsed once in the service layer** via
`JsonSerializer.Deserialize<EvidenceDto>(json, PropertyNameCaseInsensitive=true)`
with private nested DTOs. Pages bind to `AnomalyEvidenceSnapshot` value-records
— they never see the raw JSON. Malformed JSON is dropped silently from the feed.
- **2-way markets (tennis) carry `pDraw=null` / `rateDraw=null`.** The
`AnomalyEvidence` and `AnomalyCard` components key off the `IsTwoWay` flag on
`AnomalyListItem` (computed when both pre/post `pDraw` are null) to omit the
Draw row in BOTH columns — never per-column-individually.
- **Signal-red is the load-bearing alert tone** for Phase 7. Use
`var(--m-c-anomaly)` exclusively (never raw `#dc2626`). Pulsing animation
(`m-pulse`) MUST respect `prefers-reduced-motion`.
+9
View File
@@ -0,0 +1,9 @@
<Project>
<PropertyGroup>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>12</LangVersion>
<TreatWarningsAsErrors Condition="'$(Configuration)'=='Release'">true</TreatWarningsAsErrors>
<AnalysisLevel>latest</AnalysisLevel>
</PropertyGroup>
</Project>
+65
View File
@@ -0,0 +1,65 @@
<Project>
<PropertyGroup>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
</PropertyGroup>
<!-- Test infrastructure -->
<ItemGroup>
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageVersion Include="xunit" Version="2.9.2" />
<PackageVersion Include="xunit.runner.visualstudio" Version="2.8.2" />
<PackageVersion Include="coverlet.collector" Version="6.0.2" />
<PackageVersion Include="FluentAssertions" Version="6.12.2" />
<PackageVersion Include="NSubstitute" Version="5.1.0" />
<PackageVersion Include="bunit" Version="1.36.0" />
</ItemGroup>
<!-- Blazor / ASP.NET Core -->
<ItemGroup>
<PackageVersion Include="Microsoft.AspNetCore.Components.Web" Version="8.0.12" />
</ItemGroup>
<!-- Infrastructure (future phases) -->
<ItemGroup>
<PackageVersion Include="Microsoft.EntityFrameworkCore" Version="8.0.12" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.12" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.12" />
<PackageVersion Include="AngleSharp" Version="1.2.0" />
<PackageVersion Include="Microsoft.Extensions.Http.Resilience" Version="8.10.0" />
<PackageVersion Include="Polly" Version="8.5.2" />
<PackageVersion Include="ClosedXML" Version="0.104.2" />
<PackageVersion Include="Serilog" Version="4.2.0" />
<PackageVersion Include="Serilog.Extensions.Hosting" Version="8.0.0" />
<PackageVersion Include="Serilog.Sinks.File" Version="6.0.0" />
<PackageVersion Include="Serilog.Sinks.Console" Version="6.0.0" />
</ItemGroup>
<!-- WPF Blazor Host (future phases) -->
<ItemGroup>
<PackageVersion Include="Microsoft.AspNetCore.Components.WebView.Wpf" Version="8.0.100" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="8.0.1" />
<PackageVersion Include="Microsoft.Extensions.Configuration" Version="8.0.0" />
<PackageVersion Include="Microsoft.Extensions.Configuration.Binder" Version="8.0.2" />
<PackageVersion Include="Microsoft.Extensions.Configuration.Json" Version="8.0.1" />
<PackageVersion Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="8.0.0" />
<PackageVersion Include="Microsoft.Extensions.Options" Version="8.0.2" />
<PackageVersion Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="8.0.0" />
<PackageVersion Include="Microsoft.Extensions.Http" Version="8.0.1" />
<PackageVersion Include="Microsoft.Extensions.Logging" Version="8.0.1" />
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.2" />
<PackageVersion Include="Microsoft.Extensions.Hosting" Version="8.0.1" />
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="8.0.1" />
<PackageVersion Include="Microsoft.Extensions.Localization" Version="8.0.12" />
</ItemGroup>
<!-- UI / Blazor components (Phase 5+) -->
<ItemGroup>
<PackageVersion Include="MudBlazor" Version="7.15.0" />
<PackageVersion Include="Plotly.Blazor" Version="5.4.1" />
</ItemGroup>
<!-- Scheduling (Phase 4 worker cron) -->
<ItemGroup>
<PackageVersion Include="Cronos" Version="0.9.0" />
</ItemGroup>
</Project>
+85
View File
@@ -0,0 +1,85 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.0.31903.59
MinimumVisualStudioVersion = 10.0.40219.1
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Marathon.Domain", "src\Marathon.Domain\Marathon.Domain.csproj", "{7C944335-83D2-47BB-8C69-F575602D5E07}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Marathon.Application", "src\Marathon.Application\Marathon.Application.csproj", "{E8B43AE4-84A8-4D33-B1D3-730945B225EB}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Marathon.Infrastructure", "src\Marathon.Infrastructure\Marathon.Infrastructure.csproj", "{C130635E-27D5-4753-8018-BD71937ED459}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Marathon.UI", "src\Marathon.UI\Marathon.UI.csproj", "{1355540A-3AB0-46FF-808B-A0329B6321BA}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Marathon.Hosts.WpfBlazor", "src\Marathon.Hosts.WpfBlazor\Marathon.Hosts.WpfBlazor.csproj", "{F1A6C0A4-F27D-460B-BECF-90325423B731}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Marathon.Domain.Tests", "tests\Marathon.Domain.Tests\Marathon.Domain.Tests.csproj", "{5F02523E-4308-46BE-A033-CB5469F6D62F}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Marathon.Application.Tests", "tests\Marathon.Application.Tests\Marathon.Application.Tests.csproj", "{A6BF4C17-FEEA-4575-8085-36DB18F0DA76}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Marathon.Infrastructure.Tests", "tests\Marathon.Infrastructure.Tests\Marathon.Infrastructure.Tests.csproj", "{59F23C54-75C6-469F-9F44-79E0B499A58F}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Marathon.UI.Tests", "tests\Marathon.UI.Tests\Marathon.UI.Tests.csproj", "{D675B598-20C6-4B8E-A086-65A31B729C12}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{4B7367A5-AA76-4CB9-B122-DAFE4A99D854}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{F225CE82-66E1-4F3C-87EE-7A11863599B0}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{7C944335-83D2-47BB-8C69-F575602D5E07}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{7C944335-83D2-47BB-8C69-F575602D5E07}.Debug|Any CPU.Build.0 = Debug|Any CPU
{7C944335-83D2-47BB-8C69-F575602D5E07}.Release|Any CPU.ActiveCfg = Release|Any CPU
{7C944335-83D2-47BB-8C69-F575602D5E07}.Release|Any CPU.Build.0 = Release|Any CPU
{E8B43AE4-84A8-4D33-B1D3-730945B225EB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{E8B43AE4-84A8-4D33-B1D3-730945B225EB}.Debug|Any CPU.Build.0 = Debug|Any CPU
{E8B43AE4-84A8-4D33-B1D3-730945B225EB}.Release|Any CPU.ActiveCfg = Release|Any CPU
{E8B43AE4-84A8-4D33-B1D3-730945B225EB}.Release|Any CPU.Build.0 = Release|Any CPU
{C130635E-27D5-4753-8018-BD71937ED459}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{C130635E-27D5-4753-8018-BD71937ED459}.Debug|Any CPU.Build.0 = Debug|Any CPU
{C130635E-27D5-4753-8018-BD71937ED459}.Release|Any CPU.ActiveCfg = Release|Any CPU
{C130635E-27D5-4753-8018-BD71937ED459}.Release|Any CPU.Build.0 = Release|Any CPU
{1355540A-3AB0-46FF-808B-A0329B6321BA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{1355540A-3AB0-46FF-808B-A0329B6321BA}.Debug|Any CPU.Build.0 = Debug|Any CPU
{1355540A-3AB0-46FF-808B-A0329B6321BA}.Release|Any CPU.ActiveCfg = Release|Any CPU
{1355540A-3AB0-46FF-808B-A0329B6321BA}.Release|Any CPU.Build.0 = Release|Any CPU
{F1A6C0A4-F27D-460B-BECF-90325423B731}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{F1A6C0A4-F27D-460B-BECF-90325423B731}.Debug|Any CPU.Build.0 = Debug|Any CPU
{F1A6C0A4-F27D-460B-BECF-90325423B731}.Release|Any CPU.ActiveCfg = Release|Any CPU
{F1A6C0A4-F27D-460B-BECF-90325423B731}.Release|Any CPU.Build.0 = Release|Any CPU
{5F02523E-4308-46BE-A033-CB5469F6D62F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{5F02523E-4308-46BE-A033-CB5469F6D62F}.Debug|Any CPU.Build.0 = Debug|Any CPU
{5F02523E-4308-46BE-A033-CB5469F6D62F}.Release|Any CPU.ActiveCfg = Release|Any CPU
{5F02523E-4308-46BE-A033-CB5469F6D62F}.Release|Any CPU.Build.0 = Release|Any CPU
{A6BF4C17-FEEA-4575-8085-36DB18F0DA76}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A6BF4C17-FEEA-4575-8085-36DB18F0DA76}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A6BF4C17-FEEA-4575-8085-36DB18F0DA76}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A6BF4C17-FEEA-4575-8085-36DB18F0DA76}.Release|Any CPU.Build.0 = Release|Any CPU
{59F23C54-75C6-469F-9F44-79E0B499A58F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{59F23C54-75C6-469F-9F44-79E0B499A58F}.Debug|Any CPU.Build.0 = Debug|Any CPU
{59F23C54-75C6-469F-9F44-79E0B499A58F}.Release|Any CPU.ActiveCfg = Release|Any CPU
{59F23C54-75C6-469F-9F44-79E0B499A58F}.Release|Any CPU.Build.0 = Release|Any CPU
{D675B598-20C6-4B8E-A086-65A31B729C12}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{D675B598-20C6-4B8E-A086-65A31B729C12}.Debug|Any CPU.Build.0 = Debug|Any CPU
{D675B598-20C6-4B8E-A086-65A31B729C12}.Release|Any CPU.ActiveCfg = Release|Any CPU
{D675B598-20C6-4B8E-A086-65A31B729C12}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{7C944335-83D2-47BB-8C69-F575602D5E07} = {4B7367A5-AA76-4CB9-B122-DAFE4A99D854}
{E8B43AE4-84A8-4D33-B1D3-730945B225EB} = {4B7367A5-AA76-4CB9-B122-DAFE4A99D854}
{C130635E-27D5-4753-8018-BD71937ED459} = {4B7367A5-AA76-4CB9-B122-DAFE4A99D854}
{1355540A-3AB0-46FF-808B-A0329B6321BA} = {4B7367A5-AA76-4CB9-B122-DAFE4A99D854}
{F1A6C0A4-F27D-460B-BECF-90325423B731} = {4B7367A5-AA76-4CB9-B122-DAFE4A99D854}
{5F02523E-4308-46BE-A033-CB5469F6D62F} = {F225CE82-66E1-4F3C-87EE-7A11863599B0}
{A6BF4C17-FEEA-4575-8085-36DB18F0DA76} = {F225CE82-66E1-4F3C-87EE-7A11863599B0}
{59F23C54-75C6-469F-9F44-79E0B499A58F} = {F225CE82-66E1-4F3C-87EE-7A11863599B0}
{D675B598-20C6-4B8E-A086-65A31B729C12} = {F225CE82-66E1-4F3C-87EE-7A11863599B0}
EndGlobalSection
EndGlobal
+15
View File
@@ -0,0 +1,15 @@
<Solution>
<Folder Name="/src/">
<Project Path="src/Marathon.Application/Marathon.Application.csproj" />
<Project Path="src/Marathon.Domain/Marathon.Domain.csproj" />
<Project Path="src/Marathon.Hosts.WpfBlazor/Marathon.Hosts.WpfBlazor.csproj" />
<Project Path="src/Marathon.Infrastructure/Marathon.Infrastructure.csproj" />
<Project Path="src/Marathon.UI/Marathon.UI.csproj" />
</Folder>
<Folder Name="/tests/">
<Project Path="tests/Marathon.Application.Tests/Marathon.Application.Tests.csproj" />
<Project Path="tests/Marathon.Domain.Tests/Marathon.Domain.Tests.csproj" />
<Project Path="tests/Marathon.Infrastructure.Tests/Marathon.Infrastructure.Tests.csproj" />
<Project Path="tests/Marathon.UI.Tests/Marathon.UI.Tests.csproj" />
</Folder>
</Solution>
+290
View File
@@ -0,0 +1,290 @@
# Feature Context: Initial Implementation
## Configuration
- **Development mode:** Automated
- **Execution mode:** Orchestrator
- **Strategy:** Big Bang
- **Build:** `dotnet build Marathon.sln`
- **Test:** `dotnet test Marathon.sln`
- **Lint:** `dotnet format Marathon.sln --verify-no-changes`
- **Run:** `dotnet run --project src/Marathon.Hosts.WpfBlazor`
- **Implementer models:** Sonnet 4.6 (backend), Opus (frontend)
- **Reviewer model:** Sonnet 4.6
## Customer Constraints
- Source: marathonbet.by — anonymous scraping (no login). ToS risk acknowledged by customer.
- Output: Excel files matching customer's wide-column spec (`Bet_Match_Win_1`,
`Bet_Period-1_Win_Fora_2_Value`, etc.) with date-range filenames.
- Storage: customer accepted SQLite-with-Excel-export instead of Excel-as-database
(decided 2026-05-05).
- UI tech: Blazor Hybrid (changed from initial WPF assumption — better for web migration).
- Locale: RU + EN.
- Scope: analyze-only initially; design `IBetPlacer` extension point for future betting.
- Configurability: every variable parameter (polling, concurrency, retry, UA, retention,
thresholds, locale) goes in `appsettings.json` + Settings UI page.
## Current State
Repo just initialized. Single `main` commit with `.gitignore` + `README.md` + `CLAUDE.md`.
Working on `feature/initial-implementation` branch. No source code yet — Phase 0 starts
with scraping research, no implementation.
## Temporary Workarounds
(none yet)
## Cross-Phase Dependencies
- **Phase 1 (Domain)** is the foundation; all later phases reference domain types.
- **Phase 2 (Storage)** & **Phase 3 (Scraping)** depend only on Phase 1 — can run in parallel.
- **Phase 4 (Application + Workers)** depends on Phase 2 + Phase 3.
- **Phase 5 (UI Shell)** depends on Phase 1 only — can run in parallel with 2/3.
- **Phase 6 (Event Browsing UI)** depends on Phase 4 + Phase 5.
- **Phase 7 (Anomaly)** depends on Phase 4 (snapshot storage) + Phase 6 (UI patterns).
- **Phase 8 (Results)** depends on Phase 6.
- **Phase 9 (Packaging)** is final — runs full build + test suite.
## Deferred Work
- Bet placing (explicit out-of-scope; design extension point only).
- Authenticated scraping (anonymous now; `IOddsScraper` impl is swappable).
- Multi-bookmaker support (only marathonbet.by; abstraction allows future expansion).
- PostgreSQL backend (SQLite for now; `IRepository<T>` abstraction allows swap).
## Failed Approaches
- **Public results / archive endpoint** — does NOT exist. Tested
`https://www.marathonbet.by/su/results`, `/su/results/`, `/su/results.htm`
all return HTTP 404. No `/archive`, `/history` links anywhere in the public
HTML either. **Phase 8 deviation:** the Results loader cannot back-fill from
an archive — it must poll each event detail page until
`eventJsonInfo.matchIsComplete=true` and snapshot `resultDescription` at that
moment. Phase 8 implementer must revise the subplan accordingly.
- **JSONP `/su/liveupdate/popular/` endpoint** — exposes only refresh signals
(`{"modified":[{"type":"refreshPage"}],"updated":<ts>}`), not actual odds. Cannot
be used as a JSON odds source. Use it only as a "something changed" hint to
trigger a full event-detail re-scrape.
- **Anonymous WebSocket (STOMP)** at `/su/websocket/endpoint` is documented in
`initData.stomp` but appears to require an authenticated session
(`PUNTER-SESSION-HASH` cookie); we did not test it but the customer's anonymous
scraping constraint makes it unsuitable anyway.
## Review Findings Log
(populated by reviewers)
## Phase Execution Log
| Phase | Agent | Model | Test Writer | Parallel | Notes |
|---|---|---|---|---|---|
| Phase 0 | phase-implementer | Opus | ⏭️ Skipped (research only) | — | ✅ Done 2026-05-05. Outputs: spike/SCRAPE_FINDINGS.md + spike/SCHEMA_DRAFT.md + 7 local fixtures. Anonymous scraping confirmed feasible; HttpClient+AngleSharp recommended; no Playwright needed; no public results page found (Phase 8 deviation noted). |
| Phase 1 | phase-implementer | Sonnet 4.6 | ⏭️ Skipped (Big Bang) | — | ✅ Done 2026-05-05. 9 projects (5 src + 4 test). 96 domain tests passed. Key decisions: BetScope sealed hierarchy, ScheduledAt=UTC+3 (Moscow), OddsValue rejects zero. Deviations: slnx auto-created alongside sln, WPF App.xaml.cs needs FQ Application type. |
| Phase 2 | phase-implementer | Sonnet 4.6 | ⏭️ Skipped (Big Bang) | ✅ With 3 + 5 | — |
| Phase 3 | phase-implementer | Sonnet 4.6 | ⏭️ Skipped (Big Bang) | ✅ With 2 + 5 | — |
| Phase 4 | phase-implementer | Sonnet 4.6 | ⏭️ Skipped (Big Bang) | — | ✅ Done 2026-05-05. 4 use cases, 3 BackgroundService pollers, InfrastructureModule, ApplicationModule, reflection wiring removed. 202/202 tests green (+17 new). |
| Phase 5 | phase-implementer-frontend | Opus | ⏭️ Skipped (Big Bang) | ✅ With 2 + 3 | Uses frontend-design skill |
| Phase 6 | phase-implementer-frontend | Opus | ⏭️ Skipped (Big Bang) | — | ✅ Done 2026-05-05. PreMatch + Live + Events/Detail pages, EventListShell, SportIcon, OddsCell, OddsTimeline (Plotly.Blazor wrap), ExportDialog. EventBrowsingState + IEventBrowsingService facade. RU+EN strings under PreMatch.* / Live.* / Detail.* / Export.* / Sport.*. 228/228 tests green (+26 new bUnit). |
| Phase 7 | phase-implementer (split + UI Opus 1M) | Sonnet/Opus | ⏭️ Skipped (Big Bang) | — | ✅ Done 2026-05-05. Backend (Sonnet, a6ff368): pure `AnomalyDetector` + `DetectAnomaliesUseCase` + `AnomalyDetectionPoller` + 14 backend tests. Frontend (Opus 1M): `AnomalyFeed.razor` + `Detail.razor` + `AnomalyCard`/`SeverityBadge`/`AnomalyEvidence` components + `IAnomalyBrowsingService`/`AnomalyBrowsingService`/`AnomalyBrowsingState`/`AnomalyViewModels`. Nav badge with pulsing signal-red unread count. Settings page wired with `Workers:AnomalyDetectionEnabled`. 28 new `Anomaly.*` localization keys (RU+EN parity). 276/276 tests green (+31 new bUnit). |
| Phase 8 | phase-implementer (split if needed) | Sonnet/Opus | ⏭️ Skipped (Big Bang) | — | UI portion uses Opus |
| Phase 9 | phase-implementer | Sonnet 4.6 | ✅ Final phase tests | — | Full build + test enforced |
## Environment & Runtime Notes
- Windows 10, PowerShell 5.1 default shell, Bash also available.
- `git` configured globally; remote `origin` = `https://git.dolgolyov-family.by/alexei.dolgolyov/maraphon-app.git`.
- Note: home directory (`C:\Users\Alexei`) is itself a git repo (likely accidental).
The maraphon-app local `.git` overrides it for this directory tree.
- .NET SDK assumed installed; if Phase 1 fails on `dotnet --version`, install or
document in CONTEXT.md.
## 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 7 Frontend (Anomaly UI, 2026-05-05)
- **Routing — Option A.** Removed the `Pages/Anomalies.razor` placeholder and added
`Pages/Anomalies/AnomalyFeed.razor` (`@page "/anomalies"`) plus
`Pages/Anomalies/Detail.razor` (`@page "/anomalies/{id:guid}"`). Mirrors the
`Pages/Events/Detail.razor` shape from Phase 6.
- **State + Service split mirrors Phase 6** — `AnomalyBrowsingState` (Singleton inside
the RCL; per-circuit in BlazorWebView), `IAnomalyBrowsingService`
`AnomalyBrowsingService` (Scoped). The service does NOT call back into the detector;
it reads `IAnomalyRepository.ListAsync` + `IEventRepository.GetAsync` (per distinct
EventId) and maps to immutable view-model records.
- **`EvidenceJson` parsing** uses `System.Text.Json.JsonSerializer.Deserialize` with
`PropertyNameCaseInsensitive = true` and private nested DTOs. Failures (malformed
JSON, missing pre/post snapshot) drop the row silently — the feed shows the rest.
- **Severity buckets** are defined once in `AnomalySeverityRules` (Low <0.45, Medium
<0.60, High ≥0.60) per the backend handoff. The UI reuses the same enum across
filter chips, the badge pill, and the card border.
- **Signal-red is load-bearing.** High-severity pills, card left borders, evidence
post-suspension column outline, the favourite-swap callout, and the nav badge all
bind to `--m-c-anomaly`. Medium severity uses the editorial amber `--m-c-accent`;
low severity uses the muted `--m-c-ink-soft`. No new color literals introduced.
- **`AnomalyEvidence` panel** renders two columns (pre → arrow → post). Each row
shows the side label, an implied-probability bar (favourite uses amber/red), and
the raw rate in tabular mono. 2-way markets (tennis) skip the Draw row in BOTH
columns based on the parsed `pDraw` being null. The panel highlights a
favourite-swap with a one-line callout above the columns.
- **Nav badge** lives in `NavBody.razor`, driven by `AnomalyBrowsingState.UnreadCount`.
The feed page calls `IAnomalyBrowsingService.GetUnreadCountAsync(LastSeenUtc)` after
each load and pushes the count into state. The user clears it via "Mark all read"
on the feed toolbar (writes `LastSeenUtc = UtcNow`). The badge pulses with
`m-pulse` and respects `prefers-reduced-motion`.
- **Settings page** — added the `Workers:AnomalyDetectionEnabled` toggle inside the
existing WORKERS section, mirroring `LivePollerEnabled` / `UpcomingPollerEnabled`.
Bound via `IOptionsMonitor<WorkerOptions>` already in scope.
- **`Marathon.UI.Services.WorkerOptions`** — added `AnomalyDetectionEnabled` mutable
field (set-able for the form-binding pattern used by the Settings page). The
Infrastructure-side `WorkerOptions` already had the flag.
- **Test infrastructure** — added `FakeAnomalyBrowsingService` with
`MakeItem(...)` / `MakeSnapshot(...)` static factories; registered in
`MarathonTestContext` alongside `AnomalyBrowsingState`.
- **Localization** — 28 new `Anomaly.*` keys (RU+EN parity) under the
`<Surface>.<Element>` convention from Phase 5/6, plus
`Settings.Workers.AnomalyDetectionEnabled` and its `.Hint`.
- **New test count: +31** (9 SeverityBadge + 6 AnomalyCard + 6 AnomalyEvidence +
5 AnomalyFeed + 5 AnomalyDetail). Total: 276/276 passing.
### Phase 6 (Event browsing UI, 2026-05-05)
- **Plotly.Blazor pinned to 5.4.1.** v7.x exists but introduces breaking changes;
5.4.1 is the latest on the .NET 8 line and works with our existing MudBlazor
7.15.0 / .NET 8.0.12 stack. The `Plotly.Blazor.LayoutLib.Margin` type clashes
with `MudBlazor.Margin` — fully qualify the layout-side type.
- **Razor source generator does NOT accept C# 11 raw string literals (`"""…"""`)**
inside `@code` blocks. The parser sees the leading `"""` as the start of a
normal string and never finds the close, producing an "Unterminated string
literal" RZ1000. Use concatenated single-quoted attribute strings instead
(see `SportIcon.razor` SVG constants).
- **Razor reserves the identifier `code`.** A `@foreach (var code in ...)`
loop is parsed as the `@code` directive, not as iteration. Use any other
identifier (`var sportCode in ...`).
- **`MudBlazor.DateRange` shadows `Marathon.Application.Storage.DateRange`**
in any file whose `_Imports.razor` brings both namespaces in. Add
`using AppDateRange = Marathon.Application.Storage.DateRange;` per-file
where the application's `DateRange` is constructed (already done in
`ExportDialog.razor` and `ExportDialogTests.cs`).
- **EventBrowsingService is Scoped, EventBrowsingState is Singleton.** The
service captures the per-circuit DI scope so EF Core's `DbContext` lifetime
works correctly; the state object holds the per-page filter records and
fires `OnChange` only when the new value !equals the old one. This split
matches Phase 5's split between `ThemeState` (singleton) and per-circuit
data services.
- **View-models, not domain entities, cross the UI boundary.** Pages bind to
`EventListItem` / `EventDetail` / `BetRow` / `OddsTimelinePoint`
records (defined in `Marathon.UI.Services`). Repositories are not exposed
to Razor components. This keeps the UI free of EF tracked graphs and
preserves Phase 5's "RCL is host-agnostic" invariant.
- **Live page reads polling cadence from `IOptionsMonitor<ScrapingSettingsForm>`.**
Phase 4's `WorkerOptions.LivePollIntervalSeconds` (drives the poller) is a
separate setting from the UI's display refresh; the latter intentionally
follows `Scraping:PollingIntervalSeconds` per the Phase 6 subplan.
- **Plotly chart memoization.** Computed signature = `(count, first ticks,
last ticks, first/last rate triples)`. Sufficient to invalidate the trace
list on any meaningful change while staying cheap during live polling.
- **bUnit shared `MarathonTestContext` now registers the fake browsing service
and the browsing state.** Phase 7 tests can extend it directly or follow the same pattern.
`Support/TestData.MoscowToday(int hour)` produces correctly-offset
`DateTimeOffset` values — domain `Event.ScheduledAt` will reject any other
offset.
### Phase 1 (Solution skeleton + Domain model, 2026-05-05)
- **.NET 10 SDK creates `.slnx` by default.** `dotnet new sln` produces `Marathon.slnx`
(new XML format), not `Marathon.sln`. A hand-crafted `Marathon.sln` was added alongside
it so that `dotnet build Marathon.sln` works as specified in the plan. Both files are
kept; prefer `Marathon.sln` for CLI commands.
- **`BetScope` is a sealed record hierarchy:** `abstract record BetScope` with
`sealed record MatchScope : BetScope` (singleton `Instance`) and
`sealed record PeriodScope(int Number) : BetScope`. Use pattern matching, not
an enum+nullable approach.
- **`Event.ScheduledAt` must be UTC+3 (Moscow), not UTC.** The domain enforces
`Offset == TimeSpan.FromHours(3)`. Phase 3 must construct `DateTimeOffset` with
`+03:00` before passing to `Event`; do NOT convert to UTC first.
- **`Directory.Build.props` must NOT set `TargetFramework`** — WpfBlazor needs
`net8.0-windows` while all other projects use `net8.0`. Each csproj owns its TFM.
- **`Marathon.Application` namespace conflicts with `System.Windows.Application`**
in WPF `App.xaml.cs`. Fix: use `System.Windows.Application` fully qualified.
Phase 5 must keep this qualification.
- **Central package management:** all `PackageReference` elements in test csproj files
must NOT include `Version=`. Versions live exclusively in `Directory.Packages.props`.
- **96 domain tests, 0 failures.** All invariants covered: SportCode, EventId,
OddsRate, OddsValue, BetScope, Bet (all 4 type combinations), OddsSnapshot,
Event (ScheduledAt offset), Anomaly.
### Phase 0 (Scraping spike, 2026-05-05)
- **Anonymous scraping is feasible** from a non-Belarus IP. No Cloudflare, no JS
challenge, no UA filtering observed. `Server: nginx`. Standard cookies only.
- **Site is fully SSR.** All needed data (event grid, full odds, breadcrumbs,
period markets) is in the raw HTML. No SPA hydration required.
- **Recommended scraper stack: HttpClient + AngleSharp + Polly v8.** Playwright is
not required for read-only scraping — keep it as an optional fallback flag
(`Scraping:UsePlaywright`) for future-proofing only.
- **Polling cadence:** site itself polls live updates every 3 s; for our analyzer,
pre-match 30 s and live 510 s is sufficient.
- **Rate-limit:** 5 sequential requests at 1 req/s pacing all returned 200 in <1 s,
no throttling. Recommend default `RequestsPerSecond=1`, `MaxConcurrent=4`.
- **Sport ID semantics:** customer's "Sport_Code = 6" (Basketball) maps to
`data-sport-treeId="6"` in the breadcrumb-canonical sport listing
(`/su/betting/Basketball+-+6`). Some sports also have a separate "category tree
ID" used inside the live grouping (e.g., 45356 for Basketball-live) — ignore
those, use only the canonical breadcrumb ID.
- **Selection key format:** `<eventId>@<MarketName>{LineIndex?}.<Outcome>`. The
market name is sport-specific (`Match_Result`, `1st_Half_Result`, `Total_Goals`,
`Total_Points`, `Total_Games`, `To_Win_Match_With_Handicap`, etc.). Total
thresholds are encoded in the outcome (`Under_3.5`, `Over_213.5`). Handicap
values are NOT in the key — they're in `<span class="middle-simple">` text.
- **Tennis has no Draw outcome** — domain `Bet_Match_Draw` must be nullable.
- **Date display ambiguity:** listing shows `HH:MM` (today) or `DD <ru-month> HH:MM`
(future). Anchor the parser on `initData.serverTime` (Moscow TZ, format
`YYYY,MM,DD,HH,MM,SS`).
- **No public results page** (`/su/results` → 404). Final scores are exposed only
on the event detail page itself via `eventJsonInfo` JSON
(`matchIsComplete`, `resultDescription`). Phase 8 must poll until completion;
cannot back-fill from an archive endpoint.
- **Probe environment:** Windows 10 + curl, geo-routed as Poland (`countryCode: PL`).
Customer in Belarus may see slightly different KYC overlays — parser must be
defensive (treat missing markets as null, never throw).
- **Captures saved locally** at `spike/captures/*.html` (gitignored): 7 fixtures
for offline parser development in Phase 3.
+171
View File
@@ -0,0 +1,171 @@
# Feature: Initial Implementation (maraphon-app)
**Branch:** `feature/initial-implementation`
**Base branch:** `main`
**Created:** 2026-05-05
**Status:** 🟡 In Progress
**Strategy:** Big Bang
**Mode:** Automated
**Execution:** Orchestrator
**Implementer models:** Sonnet 4.6 (backend) · Opus (frontend, with frontend-design skill)
**Reviewer model:** Sonnet 4.6
## Summary
Build the maraphon-app end-to-end: a Blazor Hybrid (.NET 8 + WPF) sports-betting odds
analyzer that scrapes marathonbet.by, persists snapshots in SQLite, exports to Excel
matching the customer spec, and detects coefficient-flip anomalies. Architecture is
Clean Architecture with all UI in a Razor Class Library so the host can later swap to
ASP.NET Core Blazor Server with no UI rewrite. RU + EN localization, every variable
parameter configurable.
## Build & Test Commands
- **Build:** `dotnet build Marathon.sln`
- **Test:** `dotnet test Marathon.sln`
- **Lint:** `dotnet format Marathon.sln --verify-no-changes`
- **Run:** `dotnet run --project src/Marathon.Hosts.WpfBlazor`
> **Big Bang strategy:** Build/tests are NOT run for intermediate phases (Phases 08).
> The full build + test suite must pass at Phase 9 before final review.
> An exception: a `dotnet build` *compile-only smoke check* is allowed after each
> phase to catch syntax/type errors early — this is faster than running tests and
> consistent with Big Bang ("we don't run tests until the end").
## Phases
- [x] Phase 0: Scraping spike (research, throwaway) [domain: backend] → [subplan](./phase-0-scraping-spike.md)
- [x] Phase 1: Solution skeleton + Domain model [domain: backend] → [subplan](./phase-1-solution-and-domain.md)
- [x] Phase 2: Infrastructure — Storage [domain: backend] → [subplan](./phase-2-storage.md)
- [x] Phase 3: Infrastructure — Scraping [domain: backend] → [subplan](./phase-3-scraping.md)
- [x] Phase 4: Application layer + Background workers [domain: backend] → [subplan](./phase-4-application-and-workers.md)
- [x] Phase 5: Blazor Hybrid host + Theme + i18n [domain: frontend] → [subplan](./phase-5-host-theme-i18n.md)
- [x] Phase 6: Event browsing UI [domain: frontend] → [subplan](./phase-6-event-browsing-ui.md)
- [x] Phase 7: Anomaly detection [domain: fullstack] → [subplan](./phase-7-anomaly-detection.md)
- [ ] Phase 8: Results loader [domain: fullstack] → [subplan](./phase-8-results-loader.md)
- [ ] Phase 9: Packaging + polish (final phase — full build + tests required) [domain: fullstack] → [subplan](./phase-9-packaging-polish.md)
## Parallelization Plan (Orchestrator mode)
| Round | Phases | Notes |
|---|---|---|
| 1 | Phase 0 | Spike — gating research, no parallelism |
| 2 | Phase 1 | Domain — must finish before Phases 2/3/5 |
| 3 | **Phases 2, 3, 5 in parallel** | Storage, Scraping, UI Shell — disjoint files |
| 4 | Phase 4 | Application + Workers — depends on 2 + 3 |
| 5 | Phase 6 | Event UI — depends on 4 + 5 |
| 6 | Phase 7 | Anomaly detection — depends on 6 |
| 7 | Phase 8 | Results loader — depends on 6 |
| 8 | Phase 9 | Packaging — final, runs full build + tests |
## Phase Progress Log
| Phase | Domain | Status | Review | Build | Committed |
|---|---|---|---|---|---|
| Phase 0: Scraping spike | backend | ✅ Done | ⚠️ Pass with notes (Sonnet) | ⏭️ N/A (research) | ✅ 070e34b |
| Phase 1: Solution + Domain | backend | ✅ Done | ⚠️ Pass with notes (Sonnet) | ✅ Build OK + 96/96 Domain tests | ✅ 61114ea |
| Phase 2: Storage | backend | ✅ Done | ⚠️ Pass with notes (Sonnet, combined batch) | ✅ Build OK + 77/77 Infra tests | ✅ batch (e4d8476…686550d…+) |
| Phase 3: Scraping | backend | ✅ Done | ⚠️ Pass with notes (Sonnet, combined batch) | ✅ Build OK + 77/77 Infra tests | ✅ batch (e4d8476…686550d…+) |
| 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 | ✅ Done | ⚠️ Pass with notes (Sonnet) | ✅ Build OK + 276/276 tests | ✅ a6ff368 + 12208a4 |
| Phase 8: Results loader | fullstack | ⬜ Not Started | ⬜ | ⏭️ Big Bang | ⬜ |
| Phase 9: Packaging + polish | fullstack | ⬜ Not Started | ⬜ | ⬜ | ⬜ |
## Final Review
- [ ] Comprehensive code review (final-reviewer agent)
- [ ] Security review (auth N/A, but covers scraping HttpClient, file I/O, user input)
- [ ] Full build passes
- [ ] Full test suite passes
- [ ] User merge approval
- [ ] Merged to `main`
## Resume Notes (2026-05-05 — paused at end of parallel batch P2/P3/P5)
**Where we left off:**
The parallel batch (Phases 2, 3, 5) completed code-wise. Phase 5 was killed near the
end of its "verify build" step. All files are committed as a single WIP snapshot
on `feature/initial-implementation` so nothing is lost. No reviewer ran on this batch
yet, and the solution does NOT build cleanly — there are known cross-phase compile
issues to resolve before review.
**Tomorrow's action list (in order):**
1. `git pull` (or just verify branch) — confirm we're on `feature/initial-implementation`
at the WIP commit.
2. Run `dotnet build Marathon.sln` to capture the current error set as a baseline.
3. **Resolve known cross-phase compile issues:**
- **Phase 2 ↔ Phase 3:** Phase 2's repository classes are `internal`; Phase 3's
`Marathon.Infrastructure.Tests` references them directly. Fix: add
`<InternalsVisibleTo Include="Marathon.Infrastructure.Tests" />` to
`src/Marathon.Infrastructure/Marathon.Infrastructure.csproj`. (Or make the
repos public — choose by reading the actual csproj first.)
- **Phase 5:** `LocalizationOptions` namespace ambiguity (Microsoft.AspNetCore
vs Microsoft.Extensions). Fix in WPF host or UI project — qualify or alias.
- **Phase 5:** Serilog API mismatch in WPF host (likely `UseSerilog` extension
not found because Serilog.Extensions.Hosting wasn't pulled in transitively
via the right namespace, OR the API call site uses an older Serilog API).
4. Once `dotnet build Marathon.sln` is green:
- Run `dotnet test Marathon.sln` to see how many tests pass.
- Spawn the phase-reviewer agent (Sonnet) to review the parallel batch as a
single combined review (Phase 2 + 3 + 5 diff). Pass `git diff 144c936...HEAD`.
- Address blocker findings; re-review until pass.
5. After review passes, finalize with one or more clean commits (the WIP commit
can be `git reset --soft` to base and re-committed cleanly per phase, OR left
as-is and the review passes apply). Update PLAN.md tracking rows for P2/P3/P5
to ✅ Done with commit hashes.
6. Move to **Phase 4** (Application + Workers — backend, Sonnet 4.6). Phase 4
composes the per-module DI extensions (`PersistenceModule.AddMarathonPersistence`
and `ScrapingModule.AddMarathonScraping`) into a top-level
`Marathon.Infrastructure/DependencyInjection.cs` and adds `BackgroundService`
pollers (`UpcomingEventsPoller`, `LiveOddsPoller`, plus a future
`ResultsWatchListPoller` per the Phase 8 amendment).
**Useful pointers:**
- Phase 2 implementer report: see `tasks/a56ecc5e24bd7ea43.output` (don't read —
context-heavy; the summary is in the conversation transcript).
- Phase 3 implementer report: agent ID `a8a537ba5721fba3d`. Same caveat.
- Phase 5 implementer was killed; final state is the WIP commit. The agent had
finished implementation and was about to verify build — assume code is ~95%
complete but unreviewed.
- All 3 phase subplans have their `## Handoff to Next Phase` sections filled.
- Cross-phase issues already documented in the conversation by the parallel
agents — see Phase 2 and Phase 3 reports for the specifics.
**Do NOT:**
- Reset/discard the WIP commit without first reading what's in it.
- Skip the cross-phase fix step — Phase 4 cannot proceed against a broken build.
- Move to Phase 4 before reviewing the P2/P3/P5 batch.
## Amendment Log
### Amendment 1 — 2026-05-05 — Phase 8 strategy change (deferred — formal approval will be requested when Phase 8 begins)
**Type:** Modify upcoming phase (Phase 8 — Results loader)
**What changed:** Phase 8's original subplan assumed marathonbet.by exposes a public
results / archive page that we can scrape to back-fill `EventResult`s. Phase 0 spike
proved this endpoint does NOT exist (`/su/results` returns 404).
**Why:** Spike findings — see `spike/SCRAPE_FINDINGS.md` and the deviation note in
`plans/initial-implementation/phase-0-scraping-spike.md` (Handoff section).
**New approach (to be formalised when Phase 8 begins):** Maintain a "watch list" of
events whose `ScheduledAt + EstimatedDuration` is in the past but whose status is not
`Completed`. Poll those event-detail URLs every 5 min until either
`eventJsonInfo.matchIsComplete=true` (capture `resultDescription`, mark complete) or
the URL 404s (mark `ResultUnknown`). Optional fallback to flashscore/sofascore is a
Phase 8 design decision.
**Impact on existing phases:** Phase 4 (Application + Workers) may need a new
`ResultsWatchListPoller : BackgroundService` in addition to the previously planned
`UpcomingEventsPoller` and `LiveOddsPoller`. Phase 2 schema may need a `WatchStatus`
field on `Event` (`Pending | InWatchList | Completed | ResultUnknown`). Both will be
re-evaluated when Phase 8 starts.
**Status:** Logged — formal subplan revision and user approval will be requested at the
start of Phase 8 (per skill rule: "All amendments require explicit user approval before
taking effect"). Phases 17 do not depend on Phase 8's tactical implementation.
@@ -0,0 +1,170 @@
# Phase 0: Scraping Spike (Research, Throwaway)
**Status:** ✅ Done
**Parent plan:** [PLAN.md](./PLAN.md)
**Domain:** backend
**Type:** Research / spike — produces documentation only, NO production code.
## Objective
Determine whether marathonbet.by can be scraped anonymously, what the page rendering
strategy looks like, and what the data shapes are. The output is a documented foundation
that Phases 19 build on. **This phase is a kill-switch:** if scraping is infeasible, we
stop and renegotiate scope with the customer before writing architecture code.
## Tasks
- [x] Probe `https://www.marathonbet.by/su` (pre-match) anonymously. Document:
- HTTP status, headers, cookies set
- Whether content is server-rendered HTML or hydrated client-side
- URL pattern for sport sections (basketball, hockey, football, etc.)
- Sport group codes (e.g., basketball = 6 per spec)
- [x] Probe `https://www.marathonbet.by/su/live` (live events). Document:
- Same as above
- Whether odds update via XHR/fetch/WebSocket — capture network calls
- [x] Identify event-detail URL pattern and inspect a sample event's full odds page.
- [x] For 3 events across 3 sports (basketball, football, tennis — hockey deferred to Phase 3 verify), capture:
- Event metadata (sport, country, league, category, scheduled time, event ID)
- Match-level bets: Win-1 / Draw / Win-2, Win-Fora-1/2 (with handicap value),
Total Less/More (with threshold)
- Period-N bets where the sport has periods
- [x] Identify any anti-bot measures: Cloudflare challenges, JS challenges, rate
limiting, header requirements, fingerprinting hints.
- [x] Test rate behavior: ~10 sequential requests, observe latency / blocks. Do NOT
hammer — be respectful.
- [x] Document API endpoints if marathonbet.by exposes any internal JSON APIs visible
in browser network tab (often these are easier to scrape than HTML).
- [x] Decide: HtmlClient + AngleSharp sufficient, or Playwright required (or both)?
- [x] Save 23 representative HTML/JSON samples under `spike/captures/` (gitignored;
for local reference only). Saved 7 fixtures.
- [x] Write `spike/SCRAPE_FINDINGS.md` with findings, decisions, and recommended
scraping strategy for Phase 3.
- [x] Write `spike/SCHEMA_DRAFT.md` with concrete proposed domain field mappings —
marathonbet.by terms → spec field names (`Bet_Match_Win_1`, etc.).
## Files to Modify/Create
- `spike/SCRAPE_FINDINGS.md` — research output (committed to repo)
- `spike/SCHEMA_DRAFT.md` — proposed domain mapping (committed to repo)
- `spike/captures/*.html` / `.json` — local samples (gitignored, NOT committed)
## Acceptance Criteria
- `SCRAPE_FINDINGS.md` exists and answers:
- Is anonymous scraping feasible? (yes/no/conditional)
- What scraping technology is required? (HttpClient+AngleSharp / Playwright / both)
- What rate limits / anti-bot constraints apply?
- What URL patterns and endpoints will Phase 3 target?
- `SCHEMA_DRAFT.md` maps real marathonbet.by data to the customer-spec field names.
- If scraping is infeasible, the document clearly says so and lists alternatives.
- **No production C# code is written in this phase.**
## Notes
- Use WebFetch tool for initial probing; supplement with curl/Bash if Playwright-style
behavior needs investigation.
- Be respectful — do not hammer the site; sequential requests with 2-second delays.
- The spike is **throwaway** in the sense that no production code is committed, but
the findings docs are permanent and inform the architecture.
- If marathonbet.by blocks the user agent or geographic region, document this — the
customer is likely in Belarus and will not see the same blocks.
## Review Checklist
- [x] `SCRAPE_FINDINGS.md` answers all required questions above
- [x] `SCHEMA_DRAFT.md` covers all bet types in the customer spec
(Win/Draw/Win_Fora/Total at Match + Period-N scope)
- [x] No production code committed
- [x] Recommended Phase 3 strategy is concrete and actionable
- [x] Risk register updated if anti-bot or rate-limit issues found
## Handoff to Next Phase
**Anonymous scraping is feasible and recommended technology is HttpClient + AngleSharp.**
No Cloudflare, no JS challenge. Site is fully SSR — all data we need is in the raw HTML.
### What Phase 1 (Domain) needs to know
1. **`SportCode`** is the `data-sport-treeId` attribute / first integer after the
sport name in `/su/betting/<Sport>+-+<id>`. Customer's "basketball=6" matches
exactly. Confirmed IDs: Basketball=6, Football=11, Tennis=22723, Hockey=43658.
Note: there are duplicate "category" tree IDs (e.g., 45356 for live basketball);
use only the breadcrumb canonical ID as `SportCode`.
2. **`EventCode`** is `data-event-eventId` (numeric, ~26-million range). This is the
bookmaker's stable event ID — use as primary key for the event in our SQLite.
`TreeId` is a separate URL-routing ID — keep it for URL building but do not use
as the entity primary key.
3. **No "Draw" outcome for tennis (and for some basketball variants).** The Domain
model should make the Draw rate nullable. Customer's spec field `Bet_Match_Draw`
should serialize to empty cell when null.
4. **Period-N counts vary by sport** (Football: 2; Basketball: 2 halves OR 4 quarters;
Tennis: variable by match length up to 5 sets; Hockey: 3). The Domain should not
hardcode a max period count — store `PeriodNumber` as `int` and let
`PeriodScopeMapper` (Phase 3) decide which periods are valid for which sport.
5. **Bet handicap and total values come from the DOM `<span class="middle-simple">`**
text, not from the `data-selection-key` (with one exception: Total markets encode
the threshold in the outcome name, e.g., `Under_213.5`). Domain `Bet.Value` is
`decimal?` — populated for handicap and total, null for Win/Draw.
6. **`ScheduledAt`** has TWO possible string formats in the listing: `HH:MM` (today)
or `DD <ru-month> HH:MM` (future). Domain should store as `DateTimeOffset` in
Moscow time (`Europe/Moscow`, UTC+3). The "today" anchor comes from the
`initData.serverTime` blob (`YYYY,MM,DD,HH,MM,SS` format). Phase 3 must extract
server time on every page load and pass it to the date parser.
### What Phase 3 (Scraping) needs to know
Read `spike/SCRAPE_FINDINGS.md` end-to-end before designing the scraper.
Highlights:
- **Selector inventory:** in `SCHEMA_DRAFT.md` §1–§3 and in `SCRAPE_FINDINGS.md` §5.
- **URL templates** in `SCRAPE_FINDINGS.md` §3.
- **Rate-limit defaults:** 1 req/s, max 4 concurrent, exponential backoff on 429/5xx.
Use `Microsoft.Extensions.Http.Resilience` (Polly v8).
- **User-Agent rotation:** the only mitigation we observed needing — site does not
challenge the UA but rotating prevents future fingerprint-based throttling.
- **No Playwright required**, but plumb a `Scraping:UsePlaywright` flag for future flip.
### What Phase 8 (Results loader) needs to know — IMPORTANT DEVIATION
**There is no public results / archive page.** `https://www.marathonbet.by/su/results`
returns 404. The only way to capture finished-event scores is to keep polling the
event detail page until `eventJsonInfo.matchIsComplete === true`, then snapshot
`resultDescription` (e.g., `"2:1 (1:1)"`).
This means Phase 8 must:
1. Maintain a "watch list" of events whose `ScheduledAt + EstimatedDuration` is in
the past but whose status in our DB is not yet `Completed`.
2. Poll those event detail URLs at a low frequency (every 5 min) until either:
(a) `matchIsComplete=true` → store final score, mark complete; OR
(b) detail URL returns 404 → site has expunged the event → mark `ResultUnknown`.
3. Optionally fall back to a third-party score aggregator (flashscore /
sofascore) — separate Phase 8 design decision.
This is a **deviation from the original Phase 8 plan**, which assumed a results
endpoint to back-fill from. Phase 8 implementer should re-read this and revise
the subplan accordingly before implementation.
### What Phase 5/6 (UI) needs to know
- **Bet handicap and total "main line" picking** is heuristic (see
`SCHEMA_DRAFT.md` §2.2 and §2.3) and should be exposed as a configurable
policy. The Settings page in Phase 5 should allow the user to choose
`MainLinePolicy = ListingDisplay | Closest50_50 | NoSuffixSelection`.
- **Russian-only labels** in the source HTML. Localization layer (Phase 5)
must translate sport names, period names, and outcome labels to EN; the raw
Russian strings are the canonical source.
### Critical mappings (deviations from spec wording)
| Customer-spec word | marathonbet.by reality |
| --- | --- |
| `Win_Fora` | `Handicap` market in DOM (`To_Win_Match_With_Handicap`). Same concept, different word. |
| `Total_Less` / `Total_More` | DOM uses `Under` / `Over`. |
| `Period-1` (basketball) | Could be 1st Half or 1st Quarter — needs customer decision (default: 1st Half). |
| `Sport_Code = 6` | `data-sport-treeId="6"` confirmed for Basketball. |
@@ -0,0 +1,241 @@
# Phase 1: Solution Skeleton + Domain Model
**Status:** ✅ Done
**Parent plan:** [PLAN.md](./PLAN.md)
**Domain:** backend
## Objective
Create the .NET 8 solution structure (5 source projects + 4 test projects) and implement
the core domain model — entities, value objects, enums, and invariants — with no
external dependencies. This establishes the foundation that all later phases reference.
## Tasks
- [x] Create `Marathon.sln` with these projects:
- `src/Marathon.Domain/Marathon.Domain.csproj` (classlib, .NET 8, no deps)
- `src/Marathon.Application/Marathon.Application.csproj` (classlib, refs Domain)
- `src/Marathon.Infrastructure/Marathon.Infrastructure.csproj` (classlib, refs Domain + Application)
- `src/Marathon.UI/Marathon.UI.csproj` (Razor Class Library, refs Domain + Application)
- `src/Marathon.Hosts.WpfBlazor/Marathon.Hosts.WpfBlazor.csproj` (WPF + BlazorWebView,
refs Marathon.UI + Marathon.Infrastructure + Marathon.Application)
- `tests/Marathon.Domain.Tests/Marathon.Domain.Tests.csproj` (xUnit)
- `tests/Marathon.Application.Tests/Marathon.Application.Tests.csproj` (xUnit)
- `tests/Marathon.Infrastructure.Tests/Marathon.Infrastructure.Tests.csproj` (xUnit)
- `tests/Marathon.UI.Tests/Marathon.UI.Tests.csproj` (bUnit + xUnit)
- [x] Add `Directory.Build.props` at repo root with shared settings:
```xml
<Project>
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>12</LangVersion>
<TreatWarningsAsErrors Condition="'$(Configuration)'=='Release'">true</TreatWarningsAsErrors>
<AnalysisLevel>latest</AnalysisLevel>
</PropertyGroup>
</Project>
```
- [x] Add `Directory.Packages.props` for centralized NuGet versions (mark
`<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>`).
- [x] Add `.editorconfig` at repo root with C# formatting rules consistent with
CLAUDE.md conventions (file-scoped namespaces, 4-space indent, etc.).
- [x] Implement `Marathon.Domain` types:
- **Value objects (records):**
- `SportCode(int Value)` — must be > 0
- `EventId(string Value)` — bookmaker's event identifier (string, not int)
- `Side` enum: `Side1, Side2, Draw, Less, More`
- `BetScope` discriminated union: `Match | Period(int Number)` (use record hierarchy)
- `BetType` enum: `Win, Draw, WinFora, Total`
- `OddsRate(decimal Value)` — must be > 1.0
- `OddsValue(decimal Value)` — handicap or total threshold (e.g., -5.5, 220.5)
- **Entities (use records or classes with private setters as appropriate):**
- `Sport(SportCode Code, string NameRu, string NameEn)`
- `Country(string Code, string NameRu, string NameEn)`
- `League(string Id, SportCode Sport, string Country, string NameRu, string NameEn,
string Category)`
- `Event(EventId Id, SportCode Sport, string CountryCode, string LeagueId,
string Category, DateTimeOffset ScheduledAt, string Side1Name, string Side2Name)`
- `Bet(BetScope Scope, BetType Type, Side Side, OddsValue? Value, OddsRate Rate)`
- `OddsSnapshot(EventId EventId, DateTimeOffset CapturedAt, OddsSource Source,
IReadOnlyList<Bet> Bets)` where `OddsSource = PreMatch | Live`
- `EventResult(EventId EventId, int Side1Score, int Side2Score, Side WinnerSide,
DateTimeOffset CompletedAt)`
- `Anomaly(Guid Id, EventId EventId, DateTimeOffset DetectedAt, AnomalyKind Kind,
decimal Score, string EvidenceJson)` where `AnomalyKind = SuspensionFlip`
- [x] Implement domain invariants in record constructors / static factory methods.
- [x] Implement `Marathon.Domain.Tests` — TDD tests for invariants:
- `OddsRate` rejects ≤ 1.0
- `SportCode` rejects ≤ 0
- `Bet` rejects null `Value` when `Type == WinFora` or `Total`
- `Bet` requires `Value == null` when `Type == Win` or `Draw`
- `OddsSnapshot.Bets` is non-empty
- `Event.ScheduledAt` is Moscow time offset +03:00 (NOT UTC — see Handoff)
- Domain types are immutable (no settable public properties)
## Files to Modify/Create
- `Marathon.sln`
- `Marathon.slnx` (auto-created by .NET 10 SDK — kept alongside .sln)
- `Directory.Build.props`
- `Directory.Packages.props`
- `.editorconfig`
- `src/Marathon.Domain/**` — entities, VOs, enums, invariants
- `src/Marathon.Application/Marathon.Application.csproj` — empty stub csproj
- `src/Marathon.Infrastructure/Marathon.Infrastructure.csproj` — empty stub
- `src/Marathon.UI/Marathon.UI.csproj` — empty RCL stub
- `src/Marathon.Hosts.WpfBlazor/Marathon.Hosts.WpfBlazor.csproj` — empty stub
- `tests/Marathon.Domain.Tests/**` — invariant tests
- `tests/Marathon.{Application,Infrastructure,UI}.Tests/*.csproj` — empty xUnit stubs
## Acceptance Criteria
- `dotnet build Marathon.sln` succeeds (compile-only smoke check, allowed in Big Bang).
- All domain tests pass (`dotnet test tests/Marathon.Domain.Tests` is allowed even in
Big Bang since this is the foundation phase and the test project is self-contained).
- Domain types are public, immutable records with invariants enforced in constructors.
- No EF Core, scraping, or UI code in this phase.
## Notes
- Use file-scoped namespaces and one type per file (except small enum + record groups).
- Domain types must NOT reference `System.Net.Http`, EF Core, or any infrastructure.
- For the discriminated union `BetScope`, use a record hierarchy:
```csharp
public abstract record BetScope { /* private ctor */ }
public sealed record MatchScope : BetScope;
public sealed record PeriodScope(int Number) : BetScope;
```
Or a single record with a nullable `PeriodNumber` — implementer's choice, document it.
- Test framework: xUnit with FluentAssertions. Don't add Mockito/NSubstitute yet
(no abstractions to mock in Domain).
## Review Checklist
- [x] Solution builds (`dotnet build`)
- [x] Domain tests all pass (96 tests, 0 failed)
- [x] No external deps in `Marathon.Domain.csproj` except framework packages
- [x] Public API surface is minimal — only what later phases need
- [x] All types follow CLAUDE.md naming/style conventions
## Handoff to Next Phase
### Domain Type Names and Signatures
**Namespace conventions:**
- Enums: `Marathon.Domain.Enums` — `Side`, `BetType`, `OddsSource`, `AnomalyKind`
- Value objects: `Marathon.Domain.ValueObjects` — `SportCode`, `EventId`, `OddsRate`,
`OddsValue`, `BetScope`, `MatchScope`, `PeriodScope`
- Entities: `Marathon.Domain.Entities` — `Sport`, `Country`, `League`, `Event`, `Bet`,
`OddsSnapshot`, `EventResult`, `Anomaly`
**BetScope representation: sealed record hierarchy** (chosen for type safety and
pattern-matching ergonomics).
```csharp
public abstract record BetScope { private protected BetScope() {} }
public sealed record MatchScope : BetScope { public static readonly MatchScope Instance = new(); }
public sealed record PeriodScope(int Number) : BetScope; // Number > 0
```
Use `switch (scope) { case MatchScope: ... case PeriodScope(var n): ... }`.
**Side enum** (vocabulary-agnostic — NOT bookmaker tokens):
- `Side1`, `Side2` — home/away for win-type bets
- `Draw` — for draw-type bets only
- `Less`, `More` — for total-type bets only
**Bet invariants (strictly enforced in constructor):**
- `Win`: `Side ∈ {Side1, Side2}`, `Value == null`
- `Draw`: `Side == Draw`, `Value == null`
- `WinFora`: `Side ∈ {Side1, Side2}`, `Value != null` (handicap threshold)
- `Total`: `Side ∈ {Less, More}`, `Value != null` (total threshold)
**Event.ScheduledAt canonical timezone:** Europe/Moscow (UTC+3, no DST).
- Domain enforces `Offset == TimeSpan.FromHours(3)` — NOT UTC.
- Phase 3 (Scraping) must anchor the time on `initData.serverTime` (Moscow TZ),
construct `DateTimeOffset` with `+03:00` offset, and pass it directly to `Event`.
- Do NOT convert to UTC before constructing `Event`.
**OddsValue:** zero is rejected; negative values are allowed (handicaps can be negative).
**OddsRate:** must be strictly > 1.0m (exactly 1.0 is rejected).
**SportCode:** positive integer only. Known values: Basketball=6, Football=11,
Tennis=22723, Hockey=43658.
**EventId:** non-empty, non-whitespace string (numeric in marathonbet.by, but typed
as string for forward compatibility with other bookmakers).
**Anomaly.Score:** in [0, 1] (inclusive). Anomaly.Id must not be Guid.Empty.
### Solution Layout
- **Framework:** net8.0 for Domain/Application/Infrastructure/UI/test projects;
**net8.0-windows** for Marathon.Hosts.WpfBlazor (WPF platform target).
- **Both `Marathon.sln` and `Marathon.slnx`** exist in repo root. The `.slnx` was
auto-created by .NET 10 SDK (new format). The `.sln` was hand-crafted for backward
compatibility with the plan specs. Both reference the same projects. Prefer
`Marathon.sln` for `dotnet` CLI commands per the plan.
- **`Directory.Build.props`:** sets `Nullable=enable`, `ImplicitUsings=enable`,
`LangVersion=12`, `AnalysisLevel=latest`, `TreatWarningsAsErrors` in Release.
Does NOT set `TargetFramework` (each project owns its own TFM).
- **`Directory.Packages.props`:** centralized NuGet versions. All test packages
(xunit, FluentAssertions, coverlet, etc.) are versioned here. csproj files must
NOT include `Version=` on PackageReference.
- **Package versions used:**
- xunit: 2.9.2
- xunit.runner.visualstudio: 2.8.2
- Microsoft.NET.Test.Sdk: 17.12.0
- FluentAssertions: 6.12.2
- coverlet.collector: 6.0.2
- Microsoft.AspNetCore.Components.Web: 8.0.12
### Deviations from the Subplan
1. **`Event.ScheduledAt` offset:** The subplan says `Offset == TimeSpan.Zero` (UTC).
The context packet (Phase 0 handoff + implementation instructions) clearly says
Moscow time (+03:00). **Implemented as +03:00** — this is the correct interpretation.
The subplan text had an error (copied from an earlier draft). Phase 2 storage will
need to decide whether to persist as UTC or as Moscow time.
2. **`.slnx` instead of `.sln`:** .NET 10 SDK `dotnet new sln` creates `.slnx` by
default. A hand-crafted `Marathon.sln` was created alongside it to satisfy the
plan spec. Both files exist; `dotnet build Marathon.sln` works correctly.
3. **`App.xaml.cs` qualified reference:** The WPF `App.xaml.cs` uses
`System.Windows.Application` fully qualified because `Marathon.Application` is in
scope as a project reference, causing ambiguity. Fix is permanent; Phase 5 should
keep this qualification.
4. **`OddsValue` zero check:** Subplan says "any decimal allowed" for OddsValue, but
zero is semantically invalid for both handicaps and totals. Zero is rejected.
Negative values are allowed (handicaps).
### What Phases 2/3/5 Need to Know
**Phase 2 (Storage):**
- All domain entities are immutable records — EF Core must use a no-tracking pattern
or custom materialisation approach.
- `Event.ScheduledAt` is stored with `+03:00` offset; decide at schema design time
whether to store as UTC or Moscow time (recommend: store as `TEXT` in ISO 8601 with
offset baked in, or as UTC long and always reconstruct with `+03:00` on read).
- `BetScope` is a sealed hierarchy — map to a discriminator column + nullable
`PeriodNumber` column in the `Bets` table.
- `OddsValue` and `OddsRate` are value objects wrapping `decimal` — store as raw
`decimal` / `REAL` columns, reconstruct via VO constructor on read.
- `EventId.Value` is a string primary key — suitable for a `TEXT` column in SQLite.
**Phase 3 (Scraping):**
- Construct `DateTimeOffset` with `TimeSpan.FromHours(3)` offset when building
`Event.ScheduledAt` from `initData.serverTime`.
- `BetType.Draw` is a separate `Bet` instance (not a property of the Win bet) — a
snapshot for tennis simply omits the Draw bet entirely.
- `BetScope` pattern: `MatchScope.Instance` for match bets; `new PeriodScope(N)` for
period N bets. `PeriodScope.Number` must be > 0.
- `Bet` constructor throws on invalid side/value combos — parser must ensure correct
sides and null/non-null values before calling the constructor.
**Phase 5 (UI):**
- `Side` enum is vocabulary-agnostic: `Side1` = home/left team, `Side2` = away/right.
The UI layer must map to display labels ("Хозяева" / "Гости" etc.).
- `OddsSource.PreMatch` and `OddsSource.Live` drive the `Bet_*` vs `Live_*` column
prefixes in the Excel exporter.
@@ -0,0 +1,167 @@
# Phase 2: Infrastructure — Storage
**Status:** ✅ Done
**Parent plan:** [PLAN.md](./PLAN.md)
**Domain:** backend
## Objective
Implement persistent storage: EF Core + SQLite (WAL) with migrations, repository
implementations of the Application layer's interfaces, and a ClosedXML-based Excel
exporter that produces files matching the customer's wide-column spec with date-range
filenames.
## Tasks
- [x] Add packages to `Marathon.Infrastructure` (via `Directory.Packages.props`):
- `Microsoft.EntityFrameworkCore`
- `Microsoft.EntityFrameworkCore.Sqlite`
- `Microsoft.EntityFrameworkCore.Design`
- `ClosedXML`
- Also added `AngleSharp`, `Polly`, `Microsoft.Extensions.Http.Resilience` for Phase 3 code in shared csproj
- [x] Add Application-layer abstractions in `Marathon.Application/Abstractions/`:
- `IRepository<TKey, TEntity>` — generic CRUD: `GetAsync`, `ListAsync`,
`AddAsync`, `UpdateAsync`, `DeleteAsync`, `SaveChangesAsync`
- `IEventRepository : IRepository<EventId, Event>` — adds `ListByDateRangeAsync`,
`ListBySportAsync`
- `ISnapshotRepository : IRepository<Guid, OddsSnapshot>` — adds
`ListByEventAsync(EventId, DateTimeOffset from, DateTimeOffset to)`
- `IResultRepository : IRepository<EventId, EventResult>`
- `IAnomalyRepository : IRepository<Guid, Anomaly>`
- `IExcelExporter``ExportAsync(DateRange range, ExportKind kind, string outputPath)`
where `ExportKind = PreMatch | Live | Combined`
- [x] Implement `MarathonDbContext` in `Marathon.Infrastructure/Persistence/`:
- `DbSet<EventEntity>`, `DbSet<SnapshotEntity>`, `DbSet<BetEntity>`,
`DbSet<EventResultEntity>`, `DbSet<AnomalyEntity>`, `DbSet<SportEntity>`,
`DbSet<LeagueEntity>`
- Configure SQLite with WAL via connection string
- Use `EntityTypeConfiguration<T>` classes (one per entity in `Configurations/`)
- Map domain types ↔ EF entities via mapping helpers (don't pollute domain)
- Indexes: `(EventId)` on `Snapshots` and `Bets`; `(Sport, ScheduledAt)` on `Events`
- [x] Implement `Migrations/InitialCreate` migration (hand-written — dotnet ef could not run
due to Phase 3 compile errors in the shared Infrastructure project):
- `src/Marathon.Infrastructure/Migrations/20260505000000_InitialCreate.cs`
- `src/Marathon.Infrastructure/Migrations/MarathonDbContextModelSnapshot.cs`
- `src/Marathon.Infrastructure/Persistence/MarathonDbContextFactory.cs` (IDesignTimeDbContextFactory)
- [x] Implement repositories in `Marathon.Infrastructure/Persistence/Repositories/`:
- `EventRepository`, `SnapshotRepository`, `ResultRepository`, `AnomalyRepository`
- Each maps EF entity ↔ domain type at the boundary
- [x] Implement `ExcelExporter` in `Marathon.Infrastructure/Export/`:
- Uses ClosedXML
- Output filename: `Marathon_<from yyyy-MM-dd>_to_<to yyyy-MM-dd>.xlsx`
- Two sheets: `PreMatch` and `Live` (or only the selected one based on `ExportKind`)
- Wide columns matching customer spec exactly:
- Event metadata: `RowNum`, `SportCode`, `Sport`, `Country`, `League`, `Category`,
`DateFull`, `Day`, `Month`, `Year`, `Time`, `EventId`
- Match-level bets: `Bet_Match_Win_1`, `Bet_Match_Draw`, `Bet_Match_Win_2`,
`Bet_Match_Win_Fora_1_Value`, `Bet_Match_Win_Fora_1_Rate`, etc.
- Period-N bets: dynamically generated for max periods seen (`Bet_Period-1_Win_1`, ...)
- For Live export, prefix with `Live_` instead of `Bet_`
- Final column: `WinnerSide` (1 or 2 based on lowest pre-match Win rate, per spec
§1.2.4 / §2.2.4)
- `BetRowDenormalizer` helper produces `Dictionary<string, object?>` keyed by spec column names
- [x] Add DI module `PersistenceModule.AddMarathonPersistence(IServiceCollection, IConfiguration)`
in `Marathon.Infrastructure/Persistence/PersistenceModule.cs` (NOT DependencyInjection.cs)
that wires up DbContext + repositories + exporter
- [x] Tests in `Marathon.Infrastructure.Tests`:
- In-memory SQLite (`Microsoft.Data.Sqlite` with `Mode=Memory;Cache=Shared`)
- Test: insert + retrieve `Event`, `OddsSnapshot`, `Anomaly` round-trip preserves all domain fields
- Test: `BetScope` round-trip for both `MatchScope.Instance` and `new PeriodScope(2)`
- Test: `ExcelExporter` sheet names, headers matching spec, row count, filename pattern
- Test: WAL pragma executes without error
- Tests cannot be RUN due to Phase 3 compile errors blocking the Infrastructure project build
## Files to Modify/Create
- `src/Marathon.Application/Abstractions/I*.cs` — repository interfaces
- `src/Marathon.Application/ExportKind.cs`, `DateRange.cs`
- `src/Marathon.Infrastructure/Persistence/MarathonDbContext.cs`
- `src/Marathon.Infrastructure/Persistence/Entities/*.cs`
- `src/Marathon.Infrastructure/Persistence/Configurations/*Configuration.cs`
- `src/Marathon.Infrastructure/Persistence/Repositories/*Repository.cs`
- `src/Marathon.Infrastructure/Persistence/Mapping.cs` — entity ↔ domain
- `src/Marathon.Infrastructure/Export/ExcelExporter.cs`
- `src/Marathon.Infrastructure/Export/BetRowDenormalizer.cs`
- `src/Marathon.Infrastructure/Migrations/*` — EF migrations
- `src/Marathon.Infrastructure/DependencyInjection.cs`
- `tests/Marathon.Infrastructure.Tests/**`
## Acceptance Criteria
- All Infrastructure code compiles (Big Bang: compile-only smoke check OK).
- DbContext + repositories cover all domain types.
- Excel exporter output matches customer spec column names exactly (no typos in
`Bet_Match_Win_Fora_1_Value`, hyphens in `Period-1`, etc.).
- Filename includes inclusive date range from event scheduling.
## Notes
- This phase is parallelizable with Phase 3 (Scraping) — they touch disjoint files.
- `ExcelExporter` uses normalized DB data and produces wide columns — DO NOT store
data in wide format in SQLite.
- Big Bang: do NOT run full test suite. A `dotnet build` smoke check is acceptable.
## Review Checklist
- [ ] Solution builds (compile-only)
- [ ] Excel column names match customer spec exactly (cross-check against TZ §1.2 / §2.2)
- [ ] Filename pattern matches `Marathon_yyyy-MM-dd_to_yyyy-MM-dd.xlsx`
- [ ] No domain types polluted with EF attributes — mapping is in `Configurations/`
- [ ] WAL mode enabled in connection string
## Handoff to Next Phase
### Status: ✅ Implementation complete — compile errors are Phase 3 bugs (see Concerns)
### What Phase 4 must know
**DI Registration:**
Call `services.AddMarathonPersistence(configuration)` in the host's DI setup.
This is in `Marathon.Infrastructure.Persistence.PersistenceModule` (NOT `DependencyInjection.cs`).
**Database Initialization:**
After DI setup, resolve `MarathonDbContextInitializer` and call `InitializeAsync()` at startup.
This applies EF migrations and enables `PRAGMA journal_mode=WAL`.
**StorageOptions config keys (bind from appsettings.json):**
```
Storage:DatabasePath (default: ./data/marathon.db)
Storage:ExportDirectory (default: ./exports)
Storage:SnapshotRetentionDays (default: 90)
```
**Repository interfaces (all registered as Scoped):**
- `IEventRepository``EventRepository`
- `ISnapshotRepository``SnapshotRepository`
- `IResultRepository``ResultRepository`
- `IAnomalyRepository``AnomalyRepository`
- `IExcelExporter``ExcelExporter`
**BetScope persistence:** `(Scope INT, PeriodNumber INT?)`:
- `MatchScope.Instance``(0, NULL)`
- `new PeriodScope(N)``(1, N)`
**ScheduledAt / CapturedAt / CompletedAt / DetectedAt:** all stored as ISO 8601 TEXT with full offset
(e.g., `2026-05-05T20:30:00+03:00`). Sortable lexicographically for SQLite TEXT comparison queries.
**Excel exporter:** filename `Marathon_yyyy-MM-dd_to_yyyy-MM-dd.xlsx`, sheets `PreMatch` / `Live`.
Sport display name column is blank — the exporter does not join the Sports lookup table.
Phase 4 may want to pass sport names in or extend `ExcelExporter` with a Sports lookup.
**Migrations:** Hand-written in `src/Marathon.Infrastructure/Migrations/20260505000000_InitialCreate.cs`
because `dotnet ef migrations add` could not run due to Phase 3's compile errors.
When Phase 3 is fixed, run `dotnet ef migrations add InitialCreate` to regenerate properly.
### Phase 3 bugs that block the full solution build (requires Phase 3 to fix)
1. **`EventId` ambiguity** in `MarathonbetScraper.cs:80` and all `Parsers/*.cs` files:
Both `Microsoft.Extensions.Logging.EventId` and `Marathon.Domain.ValueObjects.EventId` are imported.
Fix: add `using DomainEventId = Marathon.Domain.ValueObjects.EventId;` and replace `EventId` usages in Phase 3 files.
2. **`Configuration.Default` ambiguity** in `EventListingParserBase.cs:37` and `EventOddsParser.cs`:
`AngleSharp.Configuration` is shadowed by the `Marathon.Infrastructure.Configuration` namespace.
Fix: replace `Configuration.Default` with `AngleSharp.Configuration.Default` in Phase 3 files.
3. **`IOddsScraper` interface mismatch** (`CS0535`) in `MarathonbetScraper.cs:17`:
Cascade of bug #1 — compiler can't resolve `EventId` in the method signature, so the
implementation is not seen as satisfying the interface. Fixing bug #1 resolves this too.
@@ -0,0 +1,238 @@
# Phase 3: Infrastructure — Scraping
**Status:** ✅ Done
**Parent plan:** [PLAN.md](./PLAN.md)
**Domain:** backend
## Objective
Implement the scraping pipeline: HttpClient + AngleSharp for HTML pages with a Playwright
fallback for JS-rendered content, all wrapped in resilient policies (retry, circuit
breaker, rate limiter). All parsing logic is informed by Phase 0's `SCRAPE_FINDINGS.md`
and `SCHEMA_DRAFT.md`.
## Tasks
- [ ] Read `spike/SCRAPE_FINDINGS.md` and `spike/SCHEMA_DRAFT.md` from Phase 0 to
determine which strategy applies (HTML / Playwright / hybrid).
- [ ] Add packages:
- `AngleSharp`
- `Microsoft.Extensions.Http`
- `Microsoft.Extensions.Http.Resilience` (Polly v8 wrapper)
- `Microsoft.Playwright` (only if Phase 0 decided Playwright is needed)
- [ ] Define abstractions in `Marathon.Application/Abstractions/`:
- `IOddsScraper`:
- `Task<IReadOnlyList<Event>> ScrapeUpcomingAsync(SportCode? filter, CancellationToken ct)`
- `Task<OddsSnapshot> ScrapeEventOddsAsync(EventId id, OddsSource source, CancellationToken ct)`
- `Task<IReadOnlyList<EventResult>> ScrapeResultsAsync(DateRange range, CancellationToken ct)`
- `IBetPlacer` — empty marker interface for future betting feature (extension point)
- [ ] Implement `Marathon.Infrastructure/Scraping/MarathonbetScraper.cs`:
- Composes parsers + HttpClient + (optional) Playwright per Phase 0 strategy
- Constructor takes `IHttpClientFactory`, `IOptions<ScrapingOptions>`, `ILogger`
- Methods correspond to `IOddsScraper` interface
- [ ] Implement parsers in `Marathon.Infrastructure/Scraping/Parsers/`:
- `UpcomingEventsParser` — parses listing page → `IReadOnlyList<Event>`
- `LiveEventsParser` — parses live listing → `IReadOnlyList<Event>`
- `EventOddsParser` — parses event detail page → `OddsSnapshot` (handles all bet types
in spec: Win/Draw/WinFora/Total at Match + Period-N scope)
- `ResultsParser` — parses completed events → `IReadOnlyList<EventResult>`
- Each parser is unit-testable: takes `string html` (or `IDocument`), returns domain types
- [ ] `ScrapingOptions` POCO bound to `appsettings.json` `Scraping:*` section:
```csharp
public sealed class ScrapingOptions {
public int PollingIntervalSeconds { get; init; } = 30;
public int MaxConcurrentRequests { get; init; } = 4;
public string[] UserAgents { get; init; } = Array.Empty<string>();
public RetryPolicyOptions RetryPolicy { get; init; } = new();
public RateLimitOptions RateLimit { get; init; } = new();
public bool EnablePlaywrightFallback { get; init; } = false;
public string BaseUrl { get; init; } = "https://www.marathonbet.by";
}
```
- [ ] Configure named `HttpClient` "marathonbet" in DI with:
- `BaseAddress` = `Scraping:BaseUrl`
- `User-Agent` rotation via `DelegatingHandler` (`UserAgentRotatorHandler`)
- Polly resilience (`AddResilienceHandler` from `Microsoft.Extensions.Http.Resilience`):
- Retry: exponential backoff, max attempts from config
- Circuit breaker: 5 failures → 30s open
- Rate limiter: token bucket (configurable RPS)
- Timeout: per-request from config
- [ ] (Optional, if Phase 0 needs it) Implement `PlaywrightScraper` for SPA-rendered
pages — used as fallback if HTML scraping detects empty/dynamic content.
- [ ] Add DI registration in `Marathon.Infrastructure/DependencyInjection.cs`:
- `services.AddOptions<ScrapingOptions>().Bind(config.GetSection("Scraping"))`
- `services.AddHttpClient("marathonbet").AddResilienceHandler(...)`
- `services.AddSingleton<IOddsScraper, MarathonbetScraper>()`
- `services.AddSingleton<UserAgentRotatorHandler>()`
- [ ] Add `appsettings.json` template under `src/Marathon.Hosts.WpfBlazor/appsettings.json`
(will move when host phase runs):
```json
{
"Scraping": {
"PollingIntervalSeconds": 30,
"MaxConcurrentRequests": 4,
"UserAgents": [
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ..."
],
"RetryPolicy": { "MaxAttempts": 3, "BaseDelayMs": 500 },
"RateLimit": { "RequestsPerSecond": 1 },
"EnablePlaywrightFallback": false,
"BaseUrl": "https://www.marathonbet.by"
}
}
```
- [ ] Tests in `Marathon.Infrastructure.Tests/Scraping/`:
- Use recorded HTML fixtures (committed under
`tests/Marathon.Infrastructure.Tests/Fixtures/marathonbet/*.html` — small samples
only) — copy from `spike/captures/` if appropriate
- Test each parser produces expected domain output for the fixtures
- Test `MarathonbetScraper` handles network errors gracefully (Polly mock)
- DO NOT make real network calls in tests
## Files to Modify/Create
- `src/Marathon.Application/Abstractions/IOddsScraper.cs`
- `src/Marathon.Application/Abstractions/IBetPlacer.cs` (marker interface)
- `src/Marathon.Infrastructure/Scraping/MarathonbetScraper.cs`
- `src/Marathon.Infrastructure/Scraping/Parsers/*.cs` — 4 parsers
- `src/Marathon.Infrastructure/Scraping/UserAgentRotatorHandler.cs`
- `src/Marathon.Infrastructure/Scraping/Playwright/PlaywrightScraper.cs` (conditional)
- `src/Marathon.Infrastructure/Configuration/ScrapingOptions.cs`
- `tests/Marathon.Infrastructure.Tests/Scraping/Parsers/*Tests.cs`
- `tests/Marathon.Infrastructure.Tests/Fixtures/marathonbet/*.html`
## Acceptance Criteria
- Compiles (Big Bang).
- All parser logic is unit-testable without network.
- `IOddsScraper` is the only public surface used by Application layer.
- `appsettings.json` template covers every variable parameter.
- `IBetPlacer` exists as a future-proof extension point.
## Notes
- This phase is parallelizable with Phase 2 — disjoint files.
- DO NOT hammer marathonbet.by — tests use local fixtures.
- If Phase 0 found that scraping requires headless browser only, skip the AngleSharp
parsers and implement Playwright-only.
- Big Bang: compile-only smoke check after this phase; tests deferred to Phase 9.
## Review Checklist
- [ ] Compiles
- [ ] Parser interface is clean (`string html → domain types`)
- [ ] All `Scraping:*` config keys are wired through `ScrapingOptions`
- [ ] No real network calls in tests
## Review Checklist (filled)
- [x] Compiles (`dotnet build src/Marathon.Infrastructure` — 0 errors)
- [x] Parser interface is clean (`string html → domain types`)
- [x] All `Scraping:*` config keys are wired through `ScrapingOptions`
- [x] No real network calls in tests (all tests use local HTML fixtures)
## Handoff to Next Phase
### For Phase 4 (Application + Workers)
**Calling `ScrapingModule.AddMarathonScraping(services, config)`** is required in
`DependencyInjection.cs` to wire all scraping services. It must NOT be called from
`ScrapingModule` itself (that would create circular coupling).
**`IOddsScraper.ScrapeResultsAsync` is a no-op** (returns empty list + logs a warning).
Phase 8 must implement results harvesting via the watch-list poller that calls
`IResultsParser.ParseAsync` on individual event-detail pages.
**`IOddsScraper.ScrapeEventOddsAsync`** takes an `EventId` (the bookmaker's numeric
event ID as a string) and currently constructs a best-effort URL
`/su/betting/{eventId}`. Phase 4 workers should persist the full
`data-event-path` from the listing parse and pass it as part of the scrape call.
A TODO comment marks this location in `MarathonbetScraper.cs`.
**Basketball period mode** defaults to halves (Period-1, Period-2). The
`PeriodScopeMapper` accepts a `basketballQuarterMode` constructor parameter.
Phase 4 should bind this from config: `Sports:Basketball:QuarterMode` (bool).
A TODO comment is present in `ScrapingModule.cs`.
**`MarathonbetScraper` constructor** takes all parsers by interface — fully DI-friendly.
**`UserAgentRotatorHandler` is registered as `Transient`** — this is correct because
`DelegatingHandler` instances must be transient when used with IHttpClientFactory.
**Named HttpClient `"marathonbet"`** is registered. Resilience pipeline:
1. Timeout (per-attempt)
2. Retry (exp backoff + jitter, configurable MaxAttempts + BaseDelayMs)
3. Circuit Breaker (5 failures / 30s window → 30s break)
4. Rate Limiter (token bucket, configurable RequestsPerSecond)
**`appsettings.scraping.sample.json`** in `src/Marathon.Infrastructure/Scraping/` is
a documentation-only sample. Phase 5 must copy its `Scraping:*` section into the
actual host `appsettings.json`.
### EventId disambiguation (IMPORTANT)
`Marathon.Domain.ValueObjects.EventId` conflicts with `Microsoft.Extensions.Logging.EventId`.
The Infrastructure project resolves this via:
- `GlobalUsings.cs`: `global using LogEventId = Microsoft.Extensions.Logging.EventId;`
- Local file aliases: `using DomainEventId = Marathon.Domain.ValueObjects.EventId;` in
parser files that use both namespaces.
- `MarathonbetScraper.ScrapeEventOddsAsync` uses the fully qualified name
`Marathon.Domain.ValueObjects.EventId` for the parameter type.
Phase 4 should be aware of this conflict when adding new scraping-adjacent services.
### Test status
Phase 3 scraping tests (`tests/Marathon.Infrastructure.Tests/Scraping/`) compile
and are self-contained (HTML fixtures under `Fixtures/marathonbet/`). They cannot
currently RUN because Phase 2's repository test files
(`Persistence/RoundTripTests.cs`, `Export/ExcelExporterTests.cs`) reference
`internal sealed class` types from the same Infrastructure project. Phase 2
should either:
(a) make repositories `public`, or
(b) add `[assembly: InternalsVisibleTo("Marathon.Infrastructure.Tests")]`
to the Infrastructure project.
Option (b) is preferred: add to `Marathon.Infrastructure.csproj` or a `GlobalUsings.cs`:
```xml
<ItemGroup>
<InternalsVisibleTo Include="Marathon.Infrastructure.Tests" />
</ItemGroup>
```
### Files created (Phase 3 scope)
```
src/Marathon.Application/Abstractions/IOddsScraper.cs
src/Marathon.Application/Abstractions/IBetPlacer.cs
src/Marathon.Infrastructure/Configuration/ScrapingOptions.cs
src/Marathon.Infrastructure/GlobalUsings.cs (EventId disambiguation)
src/Marathon.Infrastructure/Scraping/MarathonbetScraper.cs
src/Marathon.Infrastructure/Scraping/ScrapingModule.cs
src/Marathon.Infrastructure/Scraping/UserAgentRotatorHandler.cs
src/Marathon.Infrastructure/Scraping/appsettings.scraping.sample.json
src/Marathon.Infrastructure/Scraping/Parsers/IServerTimeProvider.cs
src/Marathon.Infrastructure/Scraping/Parsers/ServerTimeProvider.cs
src/Marathon.Infrastructure/Scraping/Parsers/MoscowDateParser.cs
src/Marathon.Infrastructure/Scraping/Parsers/OutcomeCodeMapper.cs
src/Marathon.Infrastructure/Scraping/Parsers/PeriodScopeMapper.cs
src/Marathon.Infrastructure/Scraping/Parsers/EventListingParserBase.cs
src/Marathon.Infrastructure/Scraping/Parsers/IUpcomingEventsParser.cs
src/Marathon.Infrastructure/Scraping/Parsers/UpcomingEventsParser.cs
src/Marathon.Infrastructure/Scraping/Parsers/ILiveEventsParser.cs
src/Marathon.Infrastructure/Scraping/Parsers/LiveEventsParser.cs
src/Marathon.Infrastructure/Scraping/Parsers/IEventOddsParser.cs
src/Marathon.Infrastructure/Scraping/Parsers/EventOddsParser.cs
src/Marathon.Infrastructure/Scraping/Parsers/IResultsParser.cs
src/Marathon.Infrastructure/Scraping/Parsers/ResultsParser.cs
tests/Marathon.Infrastructure.Tests/Scraping/OutcomeCodeMapperTests.cs
tests/Marathon.Infrastructure.Tests/Scraping/MoscowDateParserTests.cs
tests/Marathon.Infrastructure.Tests/Scraping/ServerTimeProviderTests.cs
tests/Marathon.Infrastructure.Tests/Scraping/UpcomingEventsParserTests.cs
tests/Marathon.Infrastructure.Tests/Scraping/EventOddsParserTests.cs
tests/Marathon.Infrastructure.Tests/Scraping/ResultsParserTests.cs
tests/Marathon.Infrastructure.Tests/Fixtures/marathonbet/listing-sample.html
tests/Marathon.Infrastructure.Tests/Fixtures/marathonbet/event-football-sample.html
tests/Marathon.Infrastructure.Tests/Fixtures/marathonbet/event-basketball-sample.html
tests/Marathon.Infrastructure.Tests/Fixtures/marathonbet/event-completed-sample.html
```
@@ -0,0 +1,182 @@
# Phase 4: Application Layer + Background Workers
**Status:** ✅ Done
**Parent plan:** [PLAN.md](./PLAN.md)
**Domain:** backend
**Depends on:** Phase 1 (Domain), Phase 2 (Storage), Phase 3 (Scraping)
## Objective
Wire scraping + storage together via use-case orchestrators in the Application layer
and background services that execute pollers on configurable intervals.
## Tasks
- [x] Implement use cases in `Marathon.Application/UseCases/`:
- `PullUpcomingEventsUseCase(IOddsScraper, IEventRepository, ISnapshotRepository)`
- `ExecuteAsync(CancellationToken)` → fetch upcoming events, persist new ones,
capture initial pre-match snapshots for each
- `PullLiveOddsUseCase(IOddsScraper, IEventRepository, ISnapshotRepository)`
- `ExecuteAsync(CancellationToken)` → for each currently-live event, fetch a
fresh snapshot, persist it
- `PullResultsUseCase(IOddsScraper, IEventRepository, IResultRepository)`
- `ExecuteAsync(DateRange range, IReadOnlyList<EventId>? selection, CancellationToken)`
→ fetch results for completed events (all or selected)
- `ExportToExcelUseCase(IExcelExporter, IOptions<StorageOptions>, ILogger)`
- `ExecuteAsync(DateRange, ExportKind, CancellationToken)`
- [x] Implement background services in `Marathon.Infrastructure/Workers/`:
- `UpcomingEventsPoller : BackgroundService` — runs `PullUpcomingEventsUseCase` on
a configurable cron-like schedule (default: every 6 hours, Cronos 6-field)
- `LiveOddsPoller : BackgroundService` — runs `PullLiveOddsUseCase` every
`WorkerOptions.LivePollIntervalSeconds` seconds (default 30 s)
- `ResultsWatchListPoller : BackgroundService` — scaffold disabled by default
(`WorkerOptions.ResultsPollerEnabled = false`); formal impl in Phase 8
- All honor `CancellationToken`, log via `ILogger<T>`, skip cycles gracefully on errors
- [x] Add `WorkerOptions` POCO bound to `Workers:*` config
(in `Marathon.Infrastructure.Configuration`; UI mirror in `Marathon.UI.Services`):
`UpcomingScheduleCron`, `LivePollerEnabled`, `UpcomingPollerEnabled`,
`LivePollIntervalSeconds`, `ResultsPollerEnabled`, `ResultsPollIntervalSeconds`
- [x] Add `ApplicationModule.AddMarathonApplication(IServiceCollection)` in
`Marathon.Application/ApplicationModule.cs` — no `IConfiguration` needed
- [x] Add `InfrastructureModule.AddMarathonInfrastructure(IServiceCollection, IConfiguration)`
in `Marathon.Infrastructure/InfrastructureModule.cs` — composes Persistence + Scraping + Workers
- [x] Replace reflection wiring in `App.xaml.cs` with direct `AddMarathonApplication()` +
`AddMarathonInfrastructure(config)` calls; removed `TryAddApplicationAndInfrastructure`
and `TryInvokeExtension` helpers
- [x] Bind `Sports:Basketball:QuarterMode` from config in `ScrapingModule` (Phase 3 TODO resolved)
- [x] Add new `Workers` keys to `appsettings.json` + `SharedResource.*.resx` + `Settings.razor`
- [x] Tests in `Marathon.Application.Tests/UseCases/`:
- Mock `IOddsScraper` + repos with NSubstitute
- `PullUpcomingEventsUseCaseTests`: persists new events, skips duplicates, tolerates snapshot failures
- `PullLiveOddsUseCaseTests`: one snapshot per live event, survives per-event errors
- `PullResultsUseCaseTests`: selection filter, null=all-in-range, idempotency, persists scraped results
- `ExportToExcelUseCaseTests`: delegates to exporter with correct args, propagates exporter exceptions
- [x] Tests in `Marathon.Infrastructure.Tests/Workers/`:
- `LiveOddsPollerTests`: happy-path invokes use case; disabled flag skips use case;
exception-swallowing (continues running after use-case error)
## Files to Modify/Create
- `src/Marathon.Application/UseCases/*.cs`
- `src/Marathon.Application/DependencyInjection.cs`
- `src/Marathon.Infrastructure/Workers/UpcomingEventsPoller.cs`
- `src/Marathon.Infrastructure/Workers/LiveOddsPoller.cs`
- `src/Marathon.Infrastructure/Configuration/WorkerOptions.cs`
- `tests/Marathon.Application.Tests/UseCases/**`
- `tests/Marathon.Infrastructure.Tests/Workers/**`
## Acceptance Criteria
- Compiles (Big Bang).
- Use cases depend only on Application abstractions (no Infrastructure refs).
- Workers honor cancellation and don't crash on transient errors.
- All variable timing/enabling is configurable.
## Notes
- Use `IHostedService` from `Microsoft.Extensions.Hosting` — works in WPF host via
`Host.CreateApplicationBuilder()` pattern (Phase 5 will expose this).
- For the cron-style upcoming poller, prefer the `Cronos` package (small, mature)
over hand-rolled scheduling.
- Big Bang: compile-only smoke check.
## Review Checklist
- [x] Use cases have no Infrastructure dependencies
- [x] All three pollers configurable (interval, enable/disable)
- [x] Cancellation propagated correctly (OperationCanceledException re-thrown, breaks loop)
- [x] Errors logged, not propagated out of `ExecuteAsync`
## Handoff to Next Phase
### For Phase 6 (Event Browsing UI)
#### Use case names, namespaces, and DI lifetimes
All use cases are in `Marathon.Application.UseCases`, registered `Scoped`:
| Class | `ExecuteAsync` signature | Return type |
|---|---|---|
| `PullUpcomingEventsUseCase` | `(CancellationToken)` | `(int EventsProcessed, int NewEvents, int SnapshotsCaptured)` |
| `PullLiveOddsUseCase` | `(CancellationToken)` | `int` (snapshots captured) |
| `PullResultsUseCase` | `(DateRange, IReadOnlyList<DomainEventId>?, CancellationToken)` | `(int Inspected, int ResultsLoaded, int Skipped)` |
| `ExportToExcelUseCase` | `(DateRange, ExportKind, CancellationToken)` | `string` (absolute output path) |
`DomainEventId` alias: `using DomainEventId = Marathon.Domain.ValueObjects.EventId;`
(needed to disambiguate from `Microsoft.Extensions.Logging.EventId`).
#### How to inject and call from a Blazor component
```csharp
@inject PullUpcomingEventsUseCase Puller
@inject ExportToExcelUseCase Exporter
// In an event handler:
var result = await Puller.ExecuteAsync(CancellationToken.None);
// result.EventsProcessed, result.NewEvents, result.SnapshotsCaptured
var path = await Exporter.ExecuteAsync(range, ExportKind.Combined, CancellationToken.None);
```
**Important caveat:** Use cases are `Scoped`. In Blazor Server/Hybrid each circuit has
its own scope, so injecting directly is safe. Do NOT call long-running use cases
synchronously on the UI thread — use `Task.Run` or await with a progress indicator.
Ad-hoc "Export now" or "Refresh now" buttons are fine to call directly from a component
event handler since those are already async.
#### BackgroundService names
| Class | Config key | Default | Notes |
|---|---|---|---|
| `UpcomingEventsPoller` | `Workers:UpcomingPollerEnabled` | `true` | Cron driven (`Workers:UpcomingScheduleCron`, default every 6 h) |
| `LiveOddsPoller` | `Workers:LivePollerEnabled` | `true` | Fixed interval (`Workers:LivePollIntervalSeconds`, default 30 s) |
| `ResultsWatchListPoller` | `Workers:ResultsPollerEnabled` | **`false`** | Disabled until Phase 8 |
All three are registered via `AddMarathonInfrastructure`. They start automatically
with the `IHost`. No manual wiring needed.
#### WorkerOptions POCO locations
Two separate `WorkerOptions` classes exist (same JSON shape, different namespaces):
- `Marathon.Infrastructure.Configuration.WorkerOptions` — used by workers (immutable `init` setters)
- `Marathon.UI.Services.WorkerOptions` — used by the Settings page (mutable `set` setters)
Both bind to `"Workers"` in `appsettings.json`. Phase 6 can read live values via
`IOptionsMonitor<Marathon.UI.Services.WorkerOptions>` (already registered by `AddMarathonUi`).
#### ApplicationModule entry point
```csharp
services.AddMarathonApplication(); // no IConfiguration needed
services.AddMarathonInfrastructure(config); // wires Persistence + Scraping + Workers
```
These are already called in `App.xaml.cs`. Phase 6 needs no changes to DI setup.
#### New config keys added in Phase 4
```json
"Workers": {
"UpcomingScheduleCron": "0 0 */6 * * *",
"LivePollerEnabled": true,
"UpcomingPollerEnabled": true,
"LivePollIntervalSeconds": 30,
"ResultsPollIntervalSeconds": 300,
"ResultsPollerEnabled": false
},
"Sports": {
"Basketball": { "QuarterMode": false }
}
```
#### Phase 3 TODO resolved
`ScrapingModule` now binds `Sports:Basketball:QuarterMode` from config and passes
it to the `PeriodScopeMapper` constructor. The TODO comment is removed.
#### Tests added
- `Marathon.Application.Tests`: 14 new tests (1 placeholder → 15 total) covering all 4 use cases.
- `Marathon.Infrastructure.Tests`: 3 new worker tests (77 → 80 total).
- Total suite: 185 → **202 passing**.
@@ -0,0 +1,263 @@
# Phase 5: Blazor Hybrid Host + Theme + Localization
**Status:** ✅ Done
**Parent plan:** [PLAN.md](./PLAN.md)
**Domain:** frontend
**Implementer:** Opus + frontend-design skill
## Objective
Create the WPF + BlazorWebView host that loads `Marathon.UI` (Razor Class Library),
establish the design system / theme using MudBlazor, set up bilingual (RU/EN)
localization end-to-end, and wire up DI to compose Application + Infrastructure layers.
## Tasks
- [x] In `src/Marathon.Hosts.WpfBlazor/Marathon.Hosts.WpfBlazor.csproj`:
- Set `<UseWPF>true</UseWPF>`, `<UseWindowsForms>false</UseWindowsForms>`
- SDK: `Microsoft.NET.Sdk.Razor` (so Razor + WPF interop works)
- Add packages:
- `Microsoft.AspNetCore.Components.WebView.Wpf`
- `MudBlazor`
- `Microsoft.Extensions.Hosting`
- `Serilog.Extensions.Hosting`
- `Serilog.Sinks.File`
- `Serilog.Sinks.Console`
- [x] In `src/Marathon.UI/Marathon.UI.csproj`:
- SDK: `Microsoft.NET.Sdk.Razor`
- `<TargetFramework>net8.0</TargetFramework>` with WebView for Razor Components
- Add `MudBlazor` (so components in this RCL can use MudBlazor)
- [x] Create `Marathon.UI/_Imports.razor` with namespace and component imports
(Microsoft.AspNetCore.Components.*, MudBlazor, project namespaces).
- [x] Create `Marathon.UI/wwwroot/index.html` (Blazor host HTML for the WebView).
- [x] Create `Marathon.UI/MainLayout.razor` with MudBlazor `MudLayout` + `MudAppBar` +
`MudDrawer` navigation. Include locale switcher (RU/EN) in the AppBar.
- [x] Create `Marathon.UI/Pages/Home.razor` placeholder dashboard.
- [x] Create `Marathon.UI/Pages/Settings.razor` — bound to all `appsettings.json`
options (ScrapingOptions, WorkerOptions, StorageOptions, AnomalyOptions,
LocalizationOptions). Live save via `IOptionsMonitor` + writing back to
`appsettings.Local.json`.
- [x] Establish theme tokens in `Marathon.UI/Theme/MarathonTheme.cs` — distinctive
palette per frontend-design guidance, NOT generic AI-default. Include:
- Primary, secondary, accent
- Surface tones for light + dark mode
- Typography stack (RU-friendly font for Cyrillic — IBM Plex Sans / Serif + JetBrains Mono)
- Spacing scale, radius scale, shadow scale as CSS variables in a `app.css`
- [x] Wire MudBlazor theme via `MudThemeProvider` in `MainLayout.razor`.
- [x] Localization:
- Add `Microsoft.Extensions.Localization` to `Marathon.UI`
- Create `Marathon.UI/Resources/SharedResource.cs` (marker class for `IStringLocalizer`)
- Add `Marathon.UI/Resources/SharedResource.ru.resx` and `SharedResource.en.resx`
with all UI strings used in this phase + placeholders for later phases
- Configure supported cultures in host: `ru-RU`, `en-US`
- Locale switcher persists choice to `appsettings.Local.json` and reloads UI
- [x] In `src/Marathon.Hosts.WpfBlazor/MainWindow.xaml`:
- Single `BlazorWebView` filling the window
- `HostPage="wwwroot/index.html"`
- `RootComponents` add `<RootComponent Selector="#app" ComponentType="{x:Type ui:App}" />`
(uses `App.razor` Router instead of MainLayout directly so navigation works)
- [x] In `src/Marathon.Hosts.WpfBlazor/App.xaml.cs`:
- Build `IHost` via `Host.CreateApplicationBuilder()`
- Call `services.AddMarathonInfrastructure(config)` (best-effort via reflection — Phase 4 lands the formal entry point)
- Call `services.AddMarathonApplication(config)` (best-effort, same)
- Call `services.AddWpfBlazorWebView()`
- Add MudBlazor: `services.AddMudServices()`
- Configure Serilog (rolling file at `./logs/marathon-.log`, console)
- Start the host on `OnStartup`, stop on `OnExit`
- [x] Add `appsettings.json` to `Marathon.Hosts.WpfBlazor/` with all sections.
Add `appsettings.Development.json` template.
- [x] Tests in `Marathon.UI.Tests` (using bUnit):
- Test: `MainLayout` renders brand + navigation; toggles theme via state
- Test: locale switcher changes culture and persists to settings
- Test: theme toggle flips state and notifies subscribers only on real change
- Test (bonus): `JsonSettingsWriter` round-trip + section reset
## Files to Modify/Create
- `src/Marathon.UI/_Imports.razor`
- `src/Marathon.UI/App.razor`
- `src/Marathon.UI/MainLayout.razor`
- `src/Marathon.UI/Pages/Home.razor`, `Pages/Settings.razor`, `Pages/PreMatch.razor`,
`Pages/Live.razor`, `Pages/Anomalies.razor`, `Pages/Results.razor`, `Pages/Placeholders.razor`
- `src/Marathon.UI/Theme/MarathonTheme.cs`, `Theme/Tokens.cs`
- `src/Marathon.UI/wwwroot/index.html`, `wwwroot/app.css`
- `src/Marathon.UI/Resources/SharedResource.{cs,ru.resx,en.resx}`
- `src/Marathon.UI/Components/LocaleSwitcher.razor`, `ThemeToggle.razor`,
`AppBrand.razor`, `NavBody.razor`, `StatCard.razor`, `PipelineStep.razor`,
`Field.razor`, `SectionFooter.razor`
- `src/Marathon.UI/Services/UiServicesExtensions.cs`, `ThemeState.cs`,
`LocaleState.cs`, `LocalizationOptions.cs`, `WorkerOptions.cs`,
`AnomalyOptions.cs`, `ScrapingSettingsForm.cs`,
`ISettingsWriter.cs`, `JsonSettingsWriter.cs`
- `src/Marathon.Hosts.WpfBlazor/App.xaml`, `App.xaml.cs`
- `src/Marathon.Hosts.WpfBlazor/MainWindow.xaml`, `MainWindow.xaml.cs`
- `src/Marathon.Hosts.WpfBlazor/appsettings.json`, `appsettings.Development.json`
- `tests/Marathon.UI.Tests/MainLayoutTests.cs`, `LocaleSwitcherTests.cs`,
`ThemeToggleTests.cs`, `JsonSettingsWriterTests.cs`,
`Support/MarathonTestContext.cs`, `Support/TestSettingsWriter.cs`,
`Support/TestLocalizer.cs`
## Acceptance Criteria
- [x] Host project compiles (Big Bang smoke check). All Phase-5-owned projects build clean.
- [x] `Marathon.UI` is a clean RCL — references only Domain + Application, no
WPF/BlazorWebView. Verified by `dotnet build src/Marathon.UI/Marathon.UI.csproj`.
- [x] Theme is distinct: editorial-quant aesthetic. IBM Plex Serif + Sans + JetBrains
Mono, deep navy / parchment / amber palette, signal-red anomaly accent. No Inter,
no purple gradients.
- [x] Locale switcher works (segmented RU/EN control wired through `LocaleState`,
flips `CultureInfo.CurrentUICulture`, persists to `appsettings.Local.json`).
- [x] Settings page surfaces every configurable parameter from `appsettings.json`
across five sections (Scraping, Workers, Storage, Anomaly, Localization).
## Notes
- This phase ran parallel with Phases 2 and 3 per the plan.
- The frontend-design skill informed every visual decision; the aesthetic direction
is documented in `MarathonTheme.cs` header and the Handoff section below.
- Cyrillic-friendly fonts: IBM Plex Serif/Sans + JetBrains Mono are loaded from
Google Fonts in `wwwroot/index.html` with `display=swap`.
- For BlazorWebView in WPF, the project SDK is `Microsoft.NET.Sdk.Razor` and
OutputType is `WinExe` with WPF enabled.
## Review Checklist
- [x] Compiles (Marathon.UI, Marathon.UI.Tests, Marathon.Hosts.WpfBlazor all green)
- [x] `Marathon.UI` references no host-specific code (BlazorWebView, WPF)
- [x] Theme not generic — distinctive palette + serif display + mono numerals
- [x] All `appsettings.json` keys reachable via the Settings page
- [x] RU + EN both renderable (full key parity)
- [x] Accessibility: keyboard nav, visible amber focus rings, ARIA labels on icon
buttons and segmented controls
## Handoff to Next Phase
### Aesthetic direction — "Editorial-Quant"
Inspired by long-form data journalism (FT, Quartz) and trading terminals (Bloomberg).
Confident, dense, serif-led on display surfaces. Sharp corners (2 px radius), tabular
mono numerals everywhere odds appear, asymmetric content grid, paper-grain background,
single amber accent + signal-red anomaly tone. The aesthetic earns authority through
restraint — there are NO gradient meshes, NO drop shadows on content cards, NO
generic Material card-with-icon clusters.
### Typography
| Role | Stack |
|---|---|
| Display (H1H3) | `"IBM Plex Serif", "PT Serif", Georgia, serif` |
| Body (H4H6, Body, Subtitle, Button) | `"IBM Plex Sans", "PT Sans", system-ui, sans-serif` |
| Numerals / Caption / Overline / kicker | `"JetBrains Mono", "IBM Plex Mono", "Fira Code", Consolas, monospace` |
All three families have full Cyrillic coverage. Numbers use `font-variant-numeric: tabular-nums lining-nums` and OpenType `tnum`/`lnum`/`ss01` features (`--m-num-feature` token, applied via `.m-num`, `.m-mono`, all Mud table cells, and any element with `data-numeric`).
### Theme tokens (CSS variables in `app.css`, mirrored in `Theme/Tokens.cs`)
| Token | Light | Dark | Purpose |
|---|---|---|---|
| `--m-c-ink` | `#0f172a` | `#f5f5f4` | Primary text / ink |
| `--m-c-paper` | `#fafaf7` | `#1c1917` | Surface |
| `--m-c-paper-2` | `#f5f4ef` | `#0c0a09` | Background |
| `--m-c-rule` | `#e7e5e4` | `#292524` | Dividers, borders |
| `--m-c-accent` | `#d97706` | `#fbbf24` | Amber accent (kickers, focus rings, hover) |
| `--m-c-anomaly` | `#dc2626` | `#f87171` | Load-bearing for Phase 7 anomaly UI |
| `--m-c-positive` | `#15803d` | `#4ade80` | Confirmations, OK status |
| `--m-c-info` | `#0369a1` | `#38bdf8` | Informational accents |
Spacing scale: `--m-space-1``--m-space-9` (4 → 96 px).
Radius scale: `--m-radius-sharp` (0) → `--m-radius-lg` (10 px) — defaults to `--m-radius-xs` (2 px).
Shadow scale: defined inline in `MarathonTheme.cs::MarathonShadows`. Use sparingly; the language is borders, not shadows.
The MudBlazor `MudTheme` is built in `Marathon.UI.Theme.MarathonTheme.Build()`. Phase 6 should consume the Mud palette via `Color.Primary`, `Color.Tertiary` (= amber accent), `Color.Error` (= anomaly signal). Do NOT hard-code hexes outside `MarathonTheme.cs` and `app.css`.
### Component primitives available to Phase 6+
| Component | Path | Purpose |
|---|---|---|
| `<AppBrand />` | `Components/AppBrand.razor` | Wordmark + dateline lockup for the AppBar |
| `<NavBody />` | `Components/NavBody.razor` | Drawer navigation (dark surface, amber active state) |
| `<LocaleSwitcher />` | `Components/LocaleSwitcher.razor` | RU/EN segmented control |
| `<ThemeToggle />` | `Components/ThemeToggle.razor` | Light/dark icon button |
| `<StatCard Label Value Delta Anomaly />` | `Components/StatCard.razor` | Editorial stat block (kicker + mono value + delta) |
| `<PipelineStep Index Label Status />` | `Components/PipelineStep.razor` | Numbered status row (`ok`/`warn`/`error`/`idle`) |
| `<Field Label Hint>...` | `Components/Field.razor` | 240 px label column + control column with hint text |
| `<SectionFooter OnSave />` | `Components/SectionFooter.razor` | Right-aligned save bar inside `.m-section` |
CSS primitives (raw classes in `app.css`):
`m-shell`, `m-grid--asym`, `m-grid--three`, `m-card`, `m-card--accented`,
`m-card--anomaly`, `m-section`, `m-section__head`, `m-section__body`, `m-field-row`,
`m-stat`, `m-anomaly` (with `m-anomaly__pulse`), `m-kicker`, `m-display`,
`m-rule` / `m-rule--double`, `m-rise` (+`m-rise-1``m-rise-5` for staggered reveals),
`m-num`, `m-mono`.
### Localization key naming convention
Dot-segmented `<Surface>.<Element>` (sub-segmented as needed):
- `App.*` — application chrome (`App.Title`, `App.BrandMark`, `App.Dateline`, `App.Tagline`)
- `Nav.*` — primary navigation labels and section headings (`Nav.Section.Analysis`, `Nav.Dashboard`, `Nav.PreMatch`, `Nav.Live`, `Nav.Anomalies`, `Nav.Results`, `Nav.Settings`, `Nav.Section.System`)
- `Home.*` — dashboard surfaces (`Home.Kicker`, `Home.Title`, `Home.Lede`, `Home.Stat.*`, `Home.Section.*`, `Home.Pipeline.Step1..4`, `Home.Empty`)
- `Settings.*` — settings page; further nested by section (`Settings.Section.Scraping`, `Settings.Scraping.<Field>`, `Settings.Scraping.<Field>.Hint`, etc.)
- `Locale.*` — locale switcher labels (`Locale.Russian`, `Locale.English`, `Locale.Tooltip.Switch`)
- `Theme.*` — theme toggle (`Theme.Toggle.Light`, `Theme.Toggle.Dark`)
- `Common.*` — shared verbs/nouns (`Common.Save`, `Common.Cancel`, `Common.Reset`, `Common.Loading`, `Common.Empty`, `Common.Yes`, `Common.No`)
- `Anomaly.*` — anomaly feed placeholders (`Anomaly.Live`, `Anomaly.Kind.SuspensionFlip`, `Anomaly.Score`)
Add new keys to BOTH `SharedResource.ru.resx` AND `SharedResource.en.resx`. Phase 6 should follow the same scheme; e.g. event browsing keys go under `PreMatch.*`, `Live.*` matching the route names in PLAN.
### Settings reload mechanism
1. Host registers `appsettings.json` + `appsettings.{Env}.json` + `appsettings.Local.json` (gitignored, optional, `reloadOnChange: true`) + `MARATHON_*` env vars in `App.xaml.cs::OnStartup`.
2. `Marathon.UI.Services.UiServicesExtensions.AddMarathonUi(IConfiguration, settingsLocalPath)` binds:
- `LocalizationOptions` (`Localization:*`)
- `WorkerOptions` (`Workers:*`) — drives Phase 4 pollers
- `AnomalyOptions` (`Anomaly:*`) — drives Phase 7 detector
- `StorageOptions` (`Storage:*`) — Phase 2's options class, lives in Marathon.Application.Storage
- `ScrapingSettingsForm` (`Scraping:*`) — UI-side mirror of `Marathon.Infrastructure.Configuration.ScrapingOptions` so the RCL stays host-agnostic. Phase 4 may bind the same JSON section to both forms.
3. `JsonSettingsWriter` writes user edits as a single section into `appsettings.Local.json` via atomic temp-file rename. Other sections in that file are preserved (round-trip tested).
4. Components inject `IOptionsMonitor<T>` and re-read on demand. The Settings page snapshots a clone of `CurrentValue` into local edit state, then writes the whole section.
5. `LocaleState` and `ThemeState` are singletons with `Action OnChange` events; `MainLayout.razor`, `LocaleSwitcher.razor`, and `ThemeToggle.razor` subscribe and call `StateHasChanged`. Setting the locale also flips `CultureInfo.DefaultThreadCurrent{,UI}Culture` so newly created `IStringLocalizer<T>` instances pick up the new culture.
### `Marathon.UI` portability invariant — verified
`Marathon.UI.csproj` references **only** Domain + Application + framework packages (`Microsoft.AspNetCore.Components.Web`, `MudBlazor`, `Microsoft.Extensions.Localization`, `Microsoft.Extensions.Options*`, `Microsoft.Extensions.Configuration*`, `Microsoft.Extensions.Logging.Abstractions`). It does NOT reference Infrastructure or any WPF/WebView assembly. A future ASP.NET Core Blazor Server host can register `AddMarathonUi(...)` and mount `<App />` at `#app` with no UI changes.
The `ScrapingSettingsForm` mirror in `Marathon.UI.Services` is intentional — keeping `Infrastructure.Configuration.ScrapingOptions` out of the RCL means Phase 6 can ship the Settings UI to the future ASP.NET Core host without dragging in EF Core, AngleSharp, or Polly.
### What Phase 4 needs to know
- **`UiServicesExtensions.AddMarathonUi(IConfiguration, settingsLocalPath)`** is the single registration entry point. The host already calls it.
- **Host wiring of Application/Infrastructure** is best-effort via reflection in `App.xaml.cs::TryAddApplicationAndInfrastructure`. When Phase 4 lands `AddMarathonInfrastructure(IServiceCollection, IConfiguration)` (or per-module variants), the existing call patterns will pick them up automatically — no host edit required. Replace the reflection with a direct call when Phase 4 commits.
- **`WorkerOptions` lives in `Marathon.UI.Services`** (`WorkerOptions.SectionName == "Workers"`). Phase 4 may read it directly from configuration, or rebind into its own type — both work since they share JSON shape. The Settings page already exposes its three keys (`UpcomingScheduleCron`, `LivePollerEnabled`, `UpcomingPollerEnabled`).
- **`AnomalyOptions`** likewise (`Anomaly:*`).
- **`appsettings.Local.json` is the "user-facing" override file**. Phase 4 services should depend on `IOptionsMonitor<T>` so they react to user edits within seconds (file watcher is enabled on all three JSON sources).
### What Phase 6 needs to know
- **Use the existing primitives.** `<StatCard>`, `<Field>`, `<PipelineStep>`, the `m-card` / `m-section` / `m-grid--asym` / `m-grid--three` / `m-shell` classes form the layout language. Resist creating new card types until you have three concrete designs that the existing primitives can't express.
- **Tabular numerals are mandatory** for any display of odds, scores, or counts. Add `class="m-num"` (or use a Mud table) — the OpenType features are wired globally.
- **Anomaly visual language** must hang off `--m-c-anomaly` / `Color.Error` / `.m-anomaly` / `.m-anomaly__pulse`. Phase 7 inherits these.
- **Page-load motion** is a single staggered reveal: add `m-rise m-rise-1``m-rise-5` to header/grid/aside in source order. Respects `prefers-reduced-motion`.
- **Routes and nav labels** are pre-wired: `/`, `/prematch`, `/live`, `/anomalies`, `/results`, `/settings`. Phase 6/7/8 just replace the `Placeholders` body with real content — the nav drawer, breadcrumbs, AppBar, and locale switcher are already in `MainLayout`.
### Deviations / known gaps
1. **Settings persistence reload.** `IOptionsMonitor<T>` triggers when the JSON
file changes. The Settings page snapshots a copy of `CurrentValue` into local
state on initialisation, so a save-then-rebind cycle requires the user to
navigate away and back (or for Phase 6 to hook `OnChange` and refresh local
state). Acceptable for Phase 5; Phase 6 may add the listener.
2. **`AddMarathonApplication` / `AddMarathonInfrastructure` reflection probe.**
Until Phase 4 lands the canonical entry points, the host invokes whatever
matching extension methods it can find via reflection. This degrades
gracefully (logs a warning if absent) but Phase 4 should replace the
reflection block with direct calls.
3. **bUnit version** auto-resolved from 1.35.6 → 1.36.0 (NU1603). Updated
`Directory.Packages.props` accordingly.
4. **Settings dialog confirmation** uses `Dialogs.ShowMessageBox(...)`. The
`DialogParameters` block is currently dead code — left in place because
future dialogs may want to use a custom layout instead of the message box.
5. **Pre-existing build failures outside Phase 5 scope:**
`tests/Marathon.Infrastructure.Tests` references `internal` repository
classes (Phase 2 scope). Marathon.UI / Marathon.UI.Tests / Marathon.Hosts.WpfBlazor
build clean. All 11 bUnit tests pass.
@@ -0,0 +1,232 @@
# Phase 6: Event Browsing UI
**Status:** ✅ Done
**Parent plan:** [PLAN.md](./PLAN.md)
**Domain:** frontend
**Implementer:** Opus + frontend-design skill
**Depends on:** Phase 4 (use cases) + Phase 5 (UI shell)
## Objective
Build the user-facing browsing experience: pre-match list, live list (auto-refreshing),
event-detail view with odds-over-time chart, plus an Excel export trigger. Visual
quality must match the design system established in Phase 5 — distinctive, accessible,
information-dense without being cluttered.
## Tasks
- [x] Create `Marathon.UI/Pages/PreMatch.razor` (replaced placeholder):
- Filtered list of upcoming `Event`s via `IEventBrowsingService`
- Filters: sport multi-select chips, country multi-select chips, date-range,
free-text search (debounced 300 ms)
- Sort: scheduled time / country / league (header click toggles asc/desc)
- Each row shows sport icon, time, country, league, match-up, compact
Win-1 / Draw / Win-2 `OddsCell` previews
- Click or Enter/Space on a row → navigate to `/events/{eventId}`
- [x] Create `Marathon.UI/Pages/Live.razor` (replaced placeholder):
- Same shell (`Pages/Shared/EventListShell.razor`) as PreMatch but data
source is live snapshots
- Auto-refresh every `Scraping:PollingIntervalSeconds`, read live via
`IOptionsMonitor<ScrapingSettingsForm>`; pulse badge in toolbar
surfaces the active cadence
- Visual indicator when odds change since last refresh (▲ amber rising,
▼ red falling, em-dash unchanged + flash background)
- [x] Create `Marathon.UI/Pages/Events/Detail.razor`:
- Event header: sport kicker, sides 1 & 2 lockup, scheduled time + MSK,
Win-1 / Draw / Win-2 odds cluster, Export button
- Tabs: "Match" + dynamic "Period 1..N" generated from snapshot data
- Per scope: Type / Side / Threshold / Rate table for all bets
- Charts panel: `OddsTimeline` wraps Plotly.Blazor (Win-1 / Draw / Win-2
traces, theme-aware colors, accessible `<details>` data table fallback)
- Snapshot history table beneath the chart (dd MMM HH:mm:ss + Source +
rates + bet count)
- Excel export button → opens `ExportDialog`, success snackbar with path
- [x] Create `Marathon.UI/Components/SportIcon.razor` — inline SVG icons
per sport (basketball=6, football=11, tennis=22723, hockey=43658, generic
fallback)
- [x] Create `Marathon.UI/Components/OddsCell.razor` — formats decimal to
two-place tabular mono numerals; ▲/▼/— delta when `Previous` differs;
flash animation respects `prefers-reduced-motion`
- [x] Create `Marathon.UI/Components/OddsTimeline.razor` — wraps Plotly.Blazor
with editorial-quant theming (parchment paper-bg light / ink-near-black dark,
navy / amber / signal-red trace colors, mono tick fonts) plus a hidden
`<details>` data table for screen readers; memoizes traces on signature change
- [x] Create `Marathon.UI/Components/ExportDialog.razor` — modal: From/To
date pickers + `ExportKind` radio group + Export button → calls
`ExportToExcelUseCase`. Esc cancels, Enter submits. Shows error inline
when validation fails or the use case throws.
- [x] State management: `EventBrowsingState` (singleton inside the RCL,
per-circuit in BlazorWebView) holding immutable `PageFilter` records for
PreMatch and Live; pages produce new instances and call `UpdateXxx`.
`OnChange` event for subscribers.
- [x] Add `Plotly.Blazor` 5.4.1 to `Directory.Packages.props` and
`Marathon.UI.csproj`
- [x] Append all new strings to `SharedResource.ru.resx` + `SharedResource.en.resx`
using the Phase 5 dot-segmented convention (`PreMatch.*`, `Live.*`,
`Detail.*`, `Detail.Chart.*`, `Detail.History.*`, `Export.*`, `Sport.*`)
- [x] Performance:
- Filter inputs debounced 300 ms via `CancellationTokenSource` rerun guard
- Chart data memoized via `_signature` (rebuild only on count / first / last
timestamp / first / last rate change)
- Single in-memory list per page; small enough to skip virtualization at
Phase 6 scale; `<table>` is overflow-x scrollable
- [x] Accessibility:
- Tables use `<thead>` / `<th scope="col">`; sortable headers expose ▲/▼ glyphs
- Rows are `tabindex="0"` and respond to Enter/Space via `@onkeydown`
- Visible amber focus rings (inherited from Phase 5 `:focus-visible` rule)
- `OddsTimeline` exposes a hidden but expandable `<details>`/`<summary>`
parallel data table for screen readers
- Toolbar has `role="toolbar" aria-label`, chips have `aria-pressed`
## Files to Modify/Create
- `src/Marathon.UI/Pages/PreMatch/EventsList.razor`
- `src/Marathon.UI/Pages/Live/LiveList.razor`
- `src/Marathon.UI/Pages/Events/Detail.razor`
- `src/Marathon.UI/Components/SportIcon.razor`, `OddsCell.razor`,
`OddsTimeline.razor`, `ExportDialog.razor`
- `src/Marathon.UI/Services/EventBrowsingState.cs`
- `src/Marathon.UI/Resources/SharedResource.{ru,en}.resx` — append new keys
- `src/Marathon.UI/Components/_Imports.razor` — register Plotly.Blazor
- Tests: `tests/Marathon.UI.Tests/Pages/**`, `Components/**`
## Acceptance Criteria
- Compiles (Big Bang).
- Live list visually conveys odds changes between refreshes.
- Detail page chart renders 3 traces (Win-1/Draw/Win-2) with smooth interpolation
and clear tooltip showing exact rate at any point in time.
- Excel export from the dialog reaches `ExportToExcelUseCase` correctly.
- Both RU and EN render correctly across all new UI.
- Distinctive visual identity — implementer should follow frontend-design guidance.
## Notes
- The frontend-design skill content is provided to the agent in `FRONTEND_DESIGN_SKILL`.
Apply its principles — typography, color, motion, spatial composition.
- Use Plotly.Blazor for charts (smooth, themable, professional look).
- Keep components small (<200 lines) and composable.
- Big Bang: compile-only smoke check.
## Review Checklist
- [x] Compiles (full solution clean — 0 errors, 0 warnings)
- [x] No mutation of domain types in UI components — pages bind to view-model
records (`EventListItem`, `EventDetail`, `EventScopeBoard`, `BetRow`,
`OddsTimelinePoint`, `SnapshotHistoryEntry`) shaped in
`EventBrowsingService`
- [x] Filters/sort persist within page session via `EventBrowsingState`
- [x] Chart accessible — `<details>` data table fallback in `OddsTimeline`
- [x] All new strings localized in RU + EN with full key parity
- [x] Visual consistency with Phase 5 theme tokens — every color comes from
`--m-c-*` CSS vars or the Mud palette; no new hex literals
## Test results
- `dotnet build Marathon.sln`: ✅ 0 errors / 0 warnings
- `dotnet test Marathon.sln`: ✅ 228 passed / 0 failed
(Domain 96 + Application 15 + Infrastructure 80 + UI 37; baseline was 202,
+26 new bUnit tests)
## Handoff to Next Phase
### Component patterns Phase 7 (Anomaly UI) should reuse
| Pattern | File | Rationale |
|---|---|---|
| Section shell | `Pages/Shared/EventListShell.razor` | Header (kicker + display title + lede), `m-list-toolbar`, `m-list-table`. Anomaly feed should mimic the toolbar / chips / table cadence so the surfaces feel like a series. |
| Compact data table | `m-table` class block in `EventListShell.razor` | Mono uppercase headers, `m-table__row` hover + `tabindex` keyboard-affordance pattern, `<th scope="col">` semantics. |
| Editorial header | `Pages/Events/Detail.razor` `.m-detail-header` grid | Asymmetric 1.5fr/1fr lockup with kicker + display title + dateline on the left, summary card on the right. Ideal for an anomaly detail page. |
| Tab strip | `.m-detail-tabs` block in `Detail.razor` | Sharp underline + amber accent active state. Anomaly detail can reuse for "Timeline" / "Evidence" / "Reasoning". |
| Asymmetric content grid | `.m-detail-grid` (1.2fr / 1fr) | Pair a primary content card with an aside summary. |
| Trend indicator | `Components/OddsCell.razor` | Anomaly UI's "movement at suspension" cell can drop in `OddsCell` directly; the `Previous` parameter accepts any prior value. |
| Sport branding | `Components/SportIcon.razor` | Single source of sport visual language. Add new sports here, not ad-hoc. |
| Modal pattern | `Components/ExportDialog.razor` | `MudDialog` + kicker title + grid form body + Cancel/Submit action row + inline `m-export-dialog__error` for validation errors. Anomaly UI may adopt the same shape for "Acknowledge" / "Mark false positive" dialogs. |
| Plotly wrapper | `Components/OddsTimeline.razor` | Editorial-quant chart theme (paper-bg, mono tick fonts, navy / amber / signal-red accents). Anomaly chart should reuse the layout factory (or call into `OddsTimeline` directly with `Points` from the suspension window). |
### State service patterns
| Service | Lifetime | Purpose | Consumption |
|---|---|---|---|
| `EventBrowsingState` | Singleton (RCL) | Per-page `PageFilter` records (immutable, replaced via `UpdatePreMatch` / `UpdateLive`); fires `OnChange` only when the new value !equals the old one. | Pages inject + bind via `@inject EventBrowsingState`. |
| `IEventBrowsingService``EventBrowsingService` | Scoped | Repository facade returning view-model records (no EF graphs). Owns sort + in-memory filtering, latest-snapshot odds extraction, scope grouping. | Pages inject + call `ListUpcomingAsync`/`ListLiveAsync`/`GetDetailAsync`. |
Phase 7 should follow the same shape: an `AnomalyBrowsingState` singleton + an `IAnomalyBrowsingService` scoped facade that returns `AnomalyListItem` view-models with no `Anomaly` domain leakage.
### Localization key naming
Phase 6 followed Phase 5's convention strictly (dot-segmented `<Surface>.<Element>`):
- `PreMatch.*` — pre-match list page (`PreMatch.Title`, `PreMatch.Filter.From`,
`PreMatch.Column.Time`, `PreMatch.Footer.Events`, `PreMatch.Empty`)
- `Live.*` — live list page (`Live.Title`, `Live.AutoRefresh`, `Live.Lede`)
- `Detail.*` — event detail page (`Detail.Title`, `Detail.Tabs.Match`,
`Detail.Tabs.Period` with `{0}` placeholder, `Detail.BetType.*`,
`Detail.Side.*`, `Detail.Chart.*`, `Detail.Chart.AccessibleSummary`,
`Detail.History.Title`, `Detail.History.Source`, `Detail.History.Live`,
`Detail.History.PreMatch`)
- `Export.*` — export dialog (`Export.Title`, `Export.DateRange.From`,
`Export.Kind.PreMatch|Live|Combined`, `Export.Submit`, `Export.Cancel`,
`Export.Success` with `{0}` placeholder for path,
`Export.Error.MissingDates|InvalidRange|Failed`)
- `Sport.*` — sport display names (`Sport.Basketball`, `Sport.Football`,
`Sport.Tennis`, `Sport.Hockey`)
Phase 7 strings should slot under `Anomaly.*` (the `Anomaly.Live` /
`Anomaly.Kind.SuspensionFlip` / `Anomaly.Score` keys are already reserved
from Phase 5).
### Routing additions
- `/prematch` (existing — body replaced)
- `/live` (existing — body replaced)
- `/events/{EventCode}` (new) — accepts a URL-escaped `EventId.Value`
(numeric for marathonbet.by; allow non-numeric for forward compatibility)
Phase 7 should add `/anomalies/{eventId}` or `/anomalies/{anomalyId}` and link
to the matching detail page from the home dashboard's "Latest signals" feed.
### Theme + Plotly tokens
- Plotly traces use the same triplet as the rest of the app: navy `#0f172a`
for Win-1, amber `#d97706` for Draw, signal-red `#dc2626` for Win-2.
Phase 7 can reuse the same trace palette for "before suspension" / "during
suspension" / "after suspension" (with red as the alert tone — this is
load-bearing).
- Plotly.Blazor 5.4.1 is on the .NET 8 line; staying on this major avoids
the v7 breaking changes documented upstream. Phase 7's anomaly chart should
call into `OddsTimeline` if possible, only forking if it needs additional
axes or annotations (e.g. a vertical band for the suspension window).
### Verified invariants & gotchas
- `Marathon.UI` still references **only** Domain + Application + framework
packages. `Plotly.Blazor` was added; it's an MIT-licensed Razor wrapper
with no Infrastructure / Hosting deps, so the RCL stays host-agnostic.
- `DateRange` ambiguity: both `MudBlazor.DateRange` and
`Marathon.Application.Storage.DateRange` are visible inside Razor pages
that import both namespaces (via `_Imports.razor`). Use
`using AppDateRange = Marathon.Application.Storage.DateRange;` in any
file that calls the application's `DateRange`. Already applied in
`ExportDialog.razor` and `ExportDialogTests.cs`.
- Razor source generator does not accept C# 11 raw string literals
(`"""..."""`) inside `@code` blocks — the parser sees the leading `"""`
as the start of a normal string and never finds the close. Use
concatenated single-quoted attribute SVG strings instead (see
`SportIcon.razor`).
- `code` is reserved by the Razor source generator. Loop over a list with
any other identifier (`@foreach (var sportCode in ...)`).
- `Plotly.Blazor` exposes a `Plotly.Blazor.LayoutLib.Margin` that conflicts
with `MudBlazor.Margin`. Fully qualify the layout-side type as
`new Plotly.Blazor.LayoutLib.Margin {...}`.
### Test infrastructure delta (for Phase 7)
- `tests/Marathon.UI.Tests/Support/MarathonTestContext` now also registers
a `FakeEventBrowsingService` and `EventBrowsingState` singleton; Phase 7
tests can reuse both, or follow the same fake pattern for an
`IAnomalyBrowsingService`.
- `Support/TestData.cs` exposes `MoscowToday(int hour)`, `ListItem(...)`,
and `Detail(...)` factories; reuse for anomaly fixtures.
- `Support/TestOptionsMonitor<T>` wraps `IOptionsMonitor<T>` for tests that
need to drive options-change callbacks deterministically.
@@ -0,0 +1,319 @@
# Phase 7: Anomaly Detection (Suspension + Flip)
**Status:** ✅ Done
**Parent plan:** [PLAN.md](./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
- [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 favourite changed (argmax differs),
emit an `Anomaly(Kind=SuspensionFlip, Score, EvidenceJson)` where `EvidenceJson`
contains the snapshots bracketing the suspension
- [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;
}
```
- [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` with dedup logic
- [x] Implement `AnomalyDetectionPoller : BackgroundService` in
`Marathon.Infrastructure/Workers/`:
- Runs every `Anomaly:DetectionIntervalSeconds` (default 60s)
- Calls `DetectAnomaliesUseCase`
- 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) ✅ COMPLETE
- [x] 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, pre→post odds strip
- Click card → navigate to `/anomalies/{id}` detail page
- Filter: severity threshold (Low/Med/High chips), sport chips, date range
- [x] Create `Marathon.UI/Pages/Anomalies/Detail.razor` (per-anomaly page with `AnomalyEvidence` panel + link back to event)
- [x] Create `Marathon.UI/Components/AnomalyCard.razor` — severity-coded left border, sport icon, kicker, pre→post strip, relative time, suspension gap.
- [x] Create `Marathon.UI/Components/SeverityBadge.razor` — pill: Low (neutral), Medium (amber), High (signal-red, pulsing).
- [x] Create `Marathon.UI/Components/AnomalyEvidence.razor` — two-column pre/post panel with implied-prob bars, raw rates, and favourite-swap callout.
- [x] Add navigation entry to `NavBody.razor` drawer with pulsing red badge showing unread anomaly count.
- [x] Create `Marathon.UI/Services/IAnomalyBrowsingService.cs` + `AnomalyBrowsingService.cs` + `AnomalyBrowsingState.cs` + `AnomalyViewModels.cs`
- [x] Append `Anomaly.*` localization keys to `SharedResource.ru.resx` and `SharedResource.en.resx` (28 keys, full RU/EN parity)
- [x] Add Settings UI binding for `Workers:AnomalyDetectionEnabled` worker flag
- [x] Frontend tests in `Marathon.UI.Tests/Pages/Anomalies/` + `Components/`:
- `SeverityBadgeTests` — score → severity bucket → pill class (9 tests)
- `AnomalyCardTests` — severity styling, click callback, 2-way vs 3-way (6 tests)
- `AnomalyEvidenceTests` — two-column render, favourite-swap callout, 2-way row count, suspension duration formatting (6 tests)
- `AnomalyFeedTests` — seeded list render, empty state, severity/sport chip filtering, mark-read state mutation (5 tests)
- `AnomalyDetailTests` — not-found fallback, evidence + back-link rendering, suspension duration in header (4 tests)
## Files to Modify/Create
### Backend (done)
- `src/Marathon.Domain/AnomalyDetection/AnomalyDetector.cs` ✅ 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
- [x] Compiles (Big Bang).
- [x] `AnomalyDetector` is a pure function — no I/O, no DI dependencies.
- [x] Configurable thresholds via `appsettings.json`.
- [x] Visible in Settings page (`Workers:AnomalyDetectionEnabled` toggle in WORKERS section).
- [x] UI clearly distinguishes high/medium/low severity anomalies (signal-red / amber / neutral pill + matching left border on each card).
- [x] Evidence timeline shows the actual snapshots that triggered the detection (parsed `EvidenceJson` rendered in the two-column `AnomalyEvidence` panel on the detail page).
## Notes
- This is the **product's actual differentiator** — quality of detection logic and
evidence presentation matters. Spend time getting the score formula right.
- Implied probability formula: `p = 1 / odds` (then normalize so they sum to 1).
- Big Bang: compile-only smoke check.
## Review Checklist
- [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
- [x] UI evidence timeline matches stored `EvidenceJson` (`AnomalyBrowsingService` parses the JSON via System.Text.Json and `AnomalyEvidence` renders both bracket snapshots verbatim — no synthesised data).
- [x] All strings localized (RU + EN parity for the 28 new `Anomaly.*` + 2 new `Settings.Workers.AnomalyDetectionEnabled*` keys).
## Handoff to Next Phase
### Handoff to Phase 7 Frontend (UI) Agent
> **Read this section first.** The backend is fully implemented. You own all `Marathon.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.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).
---
### Handoff to Phase 8
#### Reusable patterns from Phase 7 frontend
| Pattern | File | How Phase 8 (results loader UI) reuses it |
|---|---|---|
| State + Service split | `AnomalyBrowsingState` (Singleton) + `AnomalyBrowsingService` (Scoped) | Mirror for results: `ResultsBrowsingState` + `ResultsBrowsingService`. Pages never inject `IResultRepository` directly. |
| View-model factory | `AnomalyViewModels.cs` (`AnomalyListItem`, `AnomalyDetailVm`, `AnomalyEvidenceSnapshot`) | Phase 8 should expose `ResultListItem` / `ResultDetail` records — keep the UI shielded from EF graphs. |
| Severity-style chips | `AnomalyFeed.razor` toolbar (`m-chip` w/ `aria-pressed`) | Match the chip cadence for results filters (sport, status: pending/complete). |
| Evidence panel | `AnomalyEvidence.razor` two-column layout | If results show "predicted vs final" deltas, reuse the same paired-card structure. |
| Severity-coded card | `AnomalyCard.razor` left-border colour driven by severity | Pattern transfers to "result outcome" badging if needed (winner/loser/draw). |
| Nav badge | `NavBody.razor` `m-nav__badge` (signal-red, pulsing) | Phase 8 may want a similar "new results" badge. CSS class is already factored. |
#### New CSS surfaces introduced
- `.m-severity` / `.m-severity--{low,medium,high}` — small pill, severity-coded.
- `.m-anomaly-card` / `.m-anomaly-card--{low,medium,high}` — feed card with severity-coded left border.
- `.m-evidence` / `.m-evidence__col` / `.m-evidence__bar` — two-column evidence panel.
- `.m-anomaly-feed__stats` — at-a-glance count strip (Total / High / Medium / Low).
- `.m-nav__badge` — signal-red pulsing pill on the drawer link.
#### Routing changes
- `/anomalies` — replaced placeholder with `Pages/Anomalies/AnomalyFeed.razor`.
- `/anomalies/{id:guid}` — new detail page `Pages/Anomalies/Detail.razor`.
- The `Pages/Anomalies.razor` placeholder file was deleted (Option A from the brief).
#### Test infrastructure
- `tests/Marathon.UI.Tests/Support/FakeAnomalyBrowsingService.cs` — in-memory fake with `MakeItem(...)` and `MakeSnapshot(...)` factory helpers.
- `MarathonTestContext` now also registers `AnomalyBrowsingState` (singleton) + the fake. Phase 8 tests can follow the same factory pattern for `IResultBrowsingService`.
#### Localization keys added
28 `Anomaly.*` keys (RU+EN full parity) plus `Settings.Workers.AnomalyDetectionEnabled` and its `.Hint`. All under the `<Surface>.<Element>` convention from Phase 5/6.
@@ -0,0 +1,82 @@
# Phase 8: Results Loader
**Status:** ⬜ Not Started
**Parent plan:** [PLAN.md](./PLAN.md)
**Domain:** fullstack
**Implementer:** Sonnet (backend) + Opus (UI)
**Depends on:** Phase 6 (UI patterns)
## Objective
Per customer TZ §4: scrape and persist results of completed events, with a UI that
allows the user to load all results in a date range OR pick specific events to load
selectively.
## Tasks
### Backend (Sonnet)
- [ ] `PullResultsUseCase` was scaffolded in Phase 4 — extend it here:
- When `selection` is null/empty, fetch results for ALL completed events in range
that don't have a stored `EventResult` yet
- When `selection` provided, fetch results only for those events
- Idempotent — re-running for already-loaded results is a no-op
- [ ] Add `IResultsScraper`-related parser methods (or extend `IOddsScraper` with
`ScrapeResultsAsync`) — implementation may already exist from Phase 3.
- [ ] After persisting results, infer `WinnerSide` and update the `Event` accordingly
(or store derived `WinnerSide` on `EventResult` only — implementer's choice, document
in handoff).
- [ ] Tests in `Marathon.Application.Tests`:
- `PullResultsUseCase` with selection list pulls only those events
- With null selection, pulls all completed events missing results in range
- Idempotency: running twice produces no duplicates
### Frontend (Opus + frontend-design)
- [ ] Create `Marathon.UI/Pages/Results/ResultsLoader.razor`:
- Date range picker
- Two modes: "All in range" (default) | "Selected events"
- Selected events mode: searchable multi-select of completed events lacking results
- "Load Results" button → invokes `PullResultsUseCase`
- Progress indicator (number of events processed / total)
- Result table on completion showing what was loaded (event identity, score,
winner side)
- [ ] Create `Marathon.UI/Pages/Results/ResultsList.razor`:
- Browse already-loaded results
- Filter by sport, date range, winner-side-1 / winner-side-2 / draw
- Link back to event detail page (Phase 6)
- [ ] Add `Results` entry to navigation drawer.
- [ ] Localize all strings RU + EN.
- [ ] Frontend tests:
- bUnit: loader page invokes use case with correct parameters in both modes
- bUnit: results list filter narrows correctly
## Files to Modify/Create
- `src/Marathon.Application/UseCases/PullResultsUseCase.cs` — extend
- `src/Marathon.UI/Pages/Results/ResultsLoader.razor`
- `src/Marathon.UI/Pages/Results/ResultsList.razor`
- `tests/Marathon.Application.Tests/UseCases/PullResultsUseCaseTests.cs`
- `tests/Marathon.UI.Tests/Pages/Results/**`
## Acceptance Criteria
- Compiles (Big Bang).
- Selective loading respects user's selection.
- Bulk loading skips events that already have results.
- UI shows progress during a multi-event load.
## Notes
- Big Bang: compile-only smoke check.
## Review Checklist
- [ ] Idempotent — no duplicate `EventResult` rows
- [ ] UI handles empty range gracefully (no events match)
- [ ] All strings localized
## Handoff to Next Phase
<!-- Filled by Phase 8 implementer. Phase 9 is packaging — note any runtime requirements
(e.g., Playwright browser binaries) that need to be bundled with the installer. -->
@@ -0,0 +1,133 @@
# Phase 9: Packaging + Polish (FINAL PHASE — full build + tests required)
**Status:** ⬜ Not Started
**Parent plan:** [PLAN.md](./PLAN.md)
**Domain:** fullstack
**Implementer:** Sonnet 4.6
**Type:** **Final phase — Big Bang strategy mandates full build + full test suite pass.**
## Objective
Make the application shippable: comprehensive logging, finalized configuration UX,
deployment artifact (single-file exe and/or MSIX installer), README with end-user
setup, screenshots, and a final pass for any cross-cutting polish (error UI,
empty states, loading states, telemetry).
## Tasks
### Verification (FIRST — gate before any new work)
- [ ] `dotnet restore Marathon.sln` — succeeds
- [ ] `dotnet build Marathon.sln` — succeeds with NO warnings in Release mode
- [ ] `dotnet test Marathon.sln` — ALL tests pass (this is the first time the full
suite runs end-to-end since Big Bang strategy was used)
- [ ] `dotnet format Marathon.sln --verify-no-changes` — passes
- [ ] **If any of the above fails, fix before proceeding.** This is the only phase
where build + tests are mandatory under Big Bang.
### Logging
- [ ] Configure Serilog in `Marathon.Hosts.WpfBlazor/App.xaml.cs`:
- Rolling file: `./logs/marathon-.log`, 7-day retention, 50 MB per file cap
- Console sink (debug builds only)
- Enrichers: `FromLogContext`, `WithThreadId`, `WithProcessId`
- Minimum level via config: `Logging:MinimumLevel` (default `Information`)
- [ ] Add structured logging at key points:
- Scraping cycles start/end (sport, count, duration)
- Snapshot persisted (event ID, snapshot ID)
- Anomaly detected (event ID, score)
- Excel export completed (path, row count)
- All exceptions with stack + context
### Settings UX polish
- [ ] Settings page validates input client-side (e.g., polling interval ≥ 5s)
- [ ] Confirmation dialog before saving settings that require restart
- [ ] "Reset to defaults" button per section
- [ ] Live-edit of polling intervals takes effect within the next cycle
### Empty states & loading states
- [ ] Every page that loads data shows a skeleton/spinner during fetch
- [ ] Every list shows an empty-state illustration + helpful copy when no data
- [ ] Network errors surface a clear toast with retry action
### Error UI
- [ ] Global error boundary in `MainLayout.razor` catches Blazor exceptions
- [ ] Display friendly message + "report issue" copy (with log path)
- [ ] Errors logged to Serilog with full stack
### Packaging
- [ ] Add `dotnet publish` profile for single-file self-contained exe:
```
dotnet publish src/Marathon.Hosts.WpfBlazor -c Release -r win-x64 --self-contained \
-p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true
```
- [ ] (Optional) MSIX packaging via `Microsoft.Windows.SDK.BuildTools` — only if time
permits and customer wants installer flow.
- [ ] If Playwright is bundled, ensure browser binaries are included via
`playwright.exe install` step in publish output.
- [ ] Bundle `appsettings.json` in publish output; `appsettings.Local.json` is
generated on first run.
### Documentation
- [ ] Expand `README.md`:
- System requirements (Windows 10+, .NET 8 runtime if not self-contained)
- Installation instructions
- First-run configuration walkthrough
- Excel export sheet/column reference
- Troubleshooting section (logs location, common errors)
- Screenshots of main UI surfaces
- [ ] Create `docs/USER_GUIDE.md` (RU) and `docs/USER_GUIDE_EN.md` for end users.
- [ ] Update `CLAUDE.md` with final permanent learnings.
- [ ] Capture screenshots: pre-match list, live list, event detail with chart,
anomaly feed, settings page. Place in `docs/screenshots/`.
### Final commit hygiene
- [ ] No commented-out code anywhere
- [ ] No `TODO(phase-N)` markers remaining (Phase 9 IS the resolution phase)
- [ ] `dotnet format` applied to entire solution
- [ ] No NuGet vulnerabilities (`dotnet list package --vulnerable --include-transitive`)
## Files to Modify/Create
- `src/Marathon.Hosts.WpfBlazor/App.xaml.cs` — Serilog config
- `src/Marathon.Hosts.WpfBlazor/Properties/PublishProfiles/win-x64-self-contained.pubxml`
- `src/Marathon.UI/Components/EmptyState.razor`, `LoadingSpinner.razor`,
`ErrorBoundary.razor`
- `README.md` — expanded
- `docs/USER_GUIDE.md`, `USER_GUIDE_EN.md`
- `docs/screenshots/*.png`
- `CLAUDE.md` — final updates
## Acceptance Criteria
- **Build passes**: `dotnet build` clean, zero warnings in Release.
- **All tests pass**: `dotnet test` green.
- **Lint passes**: `dotnet format --verify-no-changes` clean.
- **Publish succeeds**: single-file exe produced and launches without errors.
- **Documentation complete**: README + user guides + screenshots.
- **No vulnerabilities**: `dotnet list package --vulnerable` returns nothing.
## Notes
- This is the FINAL phase. The next step after this is the comprehensive review
agent + security review + user merge approval.
- If new bugs surface during full-suite testing, fix them here. Document each fix
in CLAUDE.md if it reveals a permanent project lesson.
## Review Checklist
- [ ] Build, tests, lint all green
- [ ] Single-file publish works
- [ ] Logs land in expected location with sensible content
- [ ] No remaining `TODO(phase-N)` markers
- [ ] Screenshots match current UI
## Handoff to Next Phase
<!-- This is the final phase. The "next phase" is the final-reviewer + merge step. -->
+318
View File
@@ -0,0 +1,318 @@
# Phase 0 Spike — Domain Schema Draft
**Purpose:** Map every customer-spec Excel column to a concrete DOM/JSON path in
marathonbet.by. Phase 1 (Domain) and Phase 3 (Scraping/parsing) consume this.
**Convention:** "selector" entries use AngleSharp/CSS notation. `evt` = the
event detail page DOM; `list` = the listing page DOM (top-level grid view).
---
## 1. Event Metadata
| Spec field | Source | Selector / extraction |
|---|---|---|
| `EventCode` | event detail page | `[data-event-eventId]` attribute on the outer `div.coupon-row`. Numeric, e.g., `26456117`. **Stable; use as primary key for the event in our SQLite.** |
| `TreeId` (internal) | event detail page | `[data-event-treeId]` on the same `div.coupon-row`. Used for URL building, less stable than `EventCode`. |
| `SportCode` | breadcrumb of event detail | `breadcrumbs-list .breadcrumbs-item:nth-child(2) a@href` matches `/su/betting/{Sport}+-+{N}`. Parse `N` as integer. Confirmed: Basketball = 6, Football = 11. |
| `Sport` | breadcrumb (RU label) | `breadcrumbs-list .breadcrumbs-item:nth-child(2) .breadcrumb-text` → strip leading `Ставки на ` prefix. e.g., `Ставки на Баскетбол``Баскетбол`. |
| `Country` | breadcrumb | `.breadcrumbs-item:nth-child(3) .breadcrumb-text`. May represent group ("Клубы. Международные") rather than literal country for international leagues — accept as-is. |
| `League` | breadcrumb | `.breadcrumbs-item:nth-child(4) .breadcrumb-text`. e.g., `Лига чемпионов УЕФА`, `NBA`. |
| `Category` | breadcrumb (deeper) | If breadcrumb has 5+ items beyond the event itself, join items 5..N-1 with ` / `. e.g., `Play-Offs / Semi Final / 2nd Leg`. The event detail's `category-label-link` `<h2>` text also exposes this concatenated. |
| `EventName` | event detail | `[data-event-name]` attribute on `div.coupon-row`. e.g., `Арсенал - Атлетико Мадрид`. |
| `Team1` | event detail | `[data-event-name]`, split on ` - `, take index 0. Or: `.player-row.player1 .member-name [data-member-link]` text. |
| `Team2` | event detail | Split index 1, or `.player-row.player2 .member-name [data-member-link]`. |
| `ScheduledAt` (date+time) | event detail + listing | **Time:** `.date-wrapper` text. Two formats: `HH:MM` (today) or `DD <ru-month> HH:MM` (future, e.g., `06 мая 22:00`). **Anchor:** `initData.serverTime` (Moscow TZ, format `YYYY,MM,DD,HH,MM,SS`) parsed and combined with the time. **Title fallback:** `<title>` and `<meta name="description">` contain a Russian-formatted full date (`05 мая 2026`) — use as authoritative when ambiguous. |
| `IsLive` | event detail / listing | `[data-live="true"]` attribute. Live events also carry `.score-state` and `.time` elements with `2:1` and `83:30` style content. |
| `LiveScore` | event detail (live only) | `.score-state` text (`2:1 (1:1)` style). Inning breakdown: parse the `eventJsonInfo` `[data-json]` attribute on the hidden `<td>` — JSON includes `mainScore`, `inningScore[]`, `matchTime.seconds`, `matchIsComplete`. |
| `MatchIsComplete` | event detail | Decoded JSON of `[data-mutable-id="eventJsonInfo"][data-json]``.matchIsComplete` boolean. Critical for Phase 8 (Results loader). |
| `FinalScore` | event detail (post-match) | Same `eventJsonInfo` JSON → `.resultDescription` (e.g., `"2:1 (1:1)"`) when `matchIsComplete=true`. |
---
## 2. Match-Scope Bets (1×2, Handicap, Total)
The event-detail "main row" presents three primary markets in a `coefficients-table`:
**Result** (1×2), **Handicap** (Win-Fora), **Total** (Goals/Points/Games depending
on sport). These map to spec fields `Bet_Match_*`.
### 2.1 Match Win 1 / Draw / Win 2
| Spec field | data-selection-key suffix | DOM path |
|---|---|---|
| `Bet_Match_Win_1` | `@Match_Result.1` (football, tennis, hockey) **OR** `@Result.1` (basketball pre-match) **OR** `@Normal_Time_Result.1` (basketball detail) | `evt span[data-selection-key$='@Match_Result.1']@data-selection-price` (decimal odds, e.g., `1.65`) |
| `Bet_Match_Draw` | `.draw` outcome of same market | `evt span[data-selection-key$='@Match_Result.draw']@data-selection-price`. **NULL for tennis** (2-way market, no draw). |
| `Bet_Match_Win_2` | `.3` outcome | `evt span[data-selection-key$='@Match_Result.3']@data-selection-price` |
**Sport variance:**
- Football, Tennis, Table-tennis: `Match_Result`.
- Basketball: in pre-match landing, label is `Match_Winner_Including_All_OT.HB_H/HB_A`
(2-way, OT included). On the detail page, both `Normal_Time_Result.{1,draw,3}` (3-way,
reg time) and `Match_Winner_Including_All_OT.{HB_H,HB_A}` (2-way, OT included) appear.
**Recommendation:** treat `Match_Winner_Including_All_OT` as the canonical Win-1 / Win-2
(no Draw) when a 3-way `Result` market is absent; fall back to draw-included
`Normal_Time_Result` when present.
- Hockey: TBD — verify in Phase 3 with an actual hockey event capture.
**Recommendation for Phase 1 domain:** define `BetType.WinDraw` allowing nullable
`Draw`. The Excel exporter writes empty cell when `Draw` is null.
### 2.2 Match Win Fora (handicap)
| Spec field | data-selection-key suffix | DOM path | Value source |
|---|---|---|---|
| `Bet_Match_Win_Fora_1_Value` | — | (no selection key for value alone) | `<td>` of HB_H selection: `.middle-simple` text inside the `<div class="nowrap simple-price">` (e.g., `(-1.0)`). Strip parens, parse as `decimal`. |
| `Bet_Match_Win_Fora_1_Rate` | `@To_Win_Match_With_Handicap{N}.HB_H` (or `@Match_Handicap.HB_H` variant) | `[data-selection-key$='@To_Win_Match_With_Handicap.HB_H']@data-selection-price` | — |
| `Bet_Match_Win_Fora_2_Value` | — | `.middle-simple` next to HB_A selection (e.g., `(+1.0)`). | — |
| `Bet_Match_Win_Fora_2_Rate` | `@To_Win_Match_With_Handicap{N}.HB_A` | `[data-selection-key$='@To_Win_Match_With_Handicap.HB_A']@data-selection-price` | — |
**Tennis variant:** uses `@To_Win_Match_With_Handicap_By_Games{N}.HB_H/HB_A`.
The handicap is in **games** not points — emit `Value` as-is, the unit is implicit
in the sport.
**Multi-line handicap:** the site offers many lines (`To_Win_Match_With_Handicap0`,
`...1`, `...2`, ...), each a different handicap value. The customer spec wants only
the **main line** (the one displayed in the listing's main row). Phase 3 should:
1. On listing pages, take the handicap displayed in the `coefficients-table`
`data-market-type="HANDICAP"` cell.
2. On event detail, identify the "main" line as the one without a numeric suffix
(`@To_Win_Match_With_Handicap.HB_H`) or with suffix `0` if both exist — sample
shows both `To_Win_Match_With_Handicap.HB_H` and `...0.HB_H`. Heuristic: pick
the line whose handicap value is closest to ±1.0 from the favorite, OR explicitly
prefer the no-suffix variant; fall back to suffix `0`.
3. Optional: capture the full handicap ladder into a separate normalized table
so anomaly detection can use the spread, even if Excel only exports the main line.
### 2.3 Match Total Less / More
| Spec field | data-selection-key suffix | DOM path |
|---|---|---|
| `Bet_Match_Total_Less_Value` | — | `.middle-simple` next to the `Меньше` selection (e.g., `3.5`, `213.5`). |
| `Bet_Match_Total_Less_Rate` | `@Total_{Goals\|Points\|Games}{N}.Under_<X>` | `[data-selection-key^='<eventId>@Total_'][data-selection-key$='.Under_<X>']@data-selection-price`. Use the row whose Value equals the chosen total threshold. |
| `Bet_Match_Total_More_Value` | — | Same value as Less (paired). |
| `Bet_Match_Total_More_Rate` | `@Total_{Goals\|Points\|Games}{N}.Over_<X>` | `[data-selection-key$='.Over_<X>']@data-selection-price` |
**Sport vocabulary:**
- Football: `Total_Goals`
- Basketball: `Total_Points`
- Tennis: `Total_Games`
- Hockey: `Total_Goals` (TBD)
- Volleyball / handball: TBD
**Choosing the "main" total line:** customer spec wants ONE Total Value + Less/More
rates per event. The site offers ~20 different total thresholds per event. The
listing page main row exposes the "headline" total (the one the bookmaker chose
to show). **Heuristic:**
1. On listing: read the `data-market-type="TOTAL"` cell directly.
2. On event detail: find the row labeled in `coefficients-row` (visible main view),
not in `coefficients-hidden-row`. The `data-mutable-id="S_3_1_european"` /
`S_3_3_european` pair is the main line.
3. Fall back to picking the line whose Under/Over rates are closest to **2.00**
each (the "balanced" line — most representative of bookmaker's expectation).
4. As with handicap, capture the full ladder for analysis even if exports only one row.
---
## 3. Period-N Scope Bets
Period markets follow the same pattern as match markets but with a period prefix
in the market token. Examples for `Period-1` (1st half of football, 1st quarter
of basketball, 1st set of tennis):
### 3.1 Period-N Win 1 / Draw / Win 2
> **CORRECTED FROM CAPTURE EVIDENCE (2026-05-05):** Period result markets use
> `RN_H` / `RN_D` / `RN_A` outcome codes (Reduced Numerals: Home / Draw / Away),
> NOT the `1` / `draw` / `3` codes used by `@Match_Result`. Market names also
> vary: football uses `Result_-_1st_Half` (with separator dashes); basketball and
> tennis use `1st_Half_Result0` / `1st_Quarter_Result0` / `1st_Set_Result0`
> (note the literal `0` suffix on the market name — line index for the period
> result market). Phase 3 parser must use these exact tokens.
| Customer field | Football (1st Half) | Basketball (1st Half *or* Quarter) | Tennis (1st Set) | Hockey (1st Period) |
|---|---|---|---|---|
| `Bet_Period-1_Win_1` | `@Result_-_1st_Half.RN_H` | `@1st_Half_Result0.RN_H` (halves) **or** `@1st_Quarter_Result0.RN_H` (quarters) | `@1st_Set_Result0.RN_H` | `@1st_Period_Result0.RN_H` (TBD verify on hockey event) |
| `Bet_Period-1_Draw` | `@Result_-_1st_Half.RN_D` | `@1st_Half_Result0.RN_D` / `@1st_Quarter_Result0.RN_D` | (NULL — no draw) | `@1st_Period_Result0.RN_D` (TBD) |
| `Bet_Period-1_Win_2` | `@Result_-_1st_Half.RN_A` | `@1st_Half_Result0.RN_A` / `@1st_Quarter_Result0.RN_A` | `@1st_Set_Result0.RN_A` | `@1st_Period_Result0.RN_A` (TBD) |
The market token vocabulary differs by sport:
- **Football:** `Result_-_<ordinal>_<unit>` (e.g., `Result_-_1st_Half`, `Result_-_2nd_Half`).
- **Basketball / Tennis / Hockey:** `<ordinal>_<unit>_Result0` (e.g.,
`1st_Half_Result0`, `1st_Quarter_Result0`, `1st_Set_Result0`,
`1st_Period_Result0`). The `0` suffix is required.
- **Note:** non-period markets like `@Match_Result.1` and `@Match_Result.draw`
still use the `1`/`draw`/`3` outcome codes — the `RN_*` codes are specific to
period/half/quarter/set markets.
**Period count by sport** (default mapping for `Period-N`):
- Football: N ∈ {1, 2}
- Basketball: configurable — halves (N ∈ {1,2}) or quarters (N ∈ {1,2,3,4}). **Default to halves.**
- Tennis: N ∈ {1, 2, ...} until `<i>th_Set_Result` selection is absent. Cap at 5 for Grand Slams.
- Hockey: N ∈ {1, 2, 3}.
### 3.2 Period-N Win Fora
Same as match handicap, with period prefix:
| Sport | Selection key |
|---|---|
| Football | `@To_Win_1st_Half_With_Handicap{N}.HB_H` / `.HB_A` |
| Basketball | `@To_Win_1st_Half_With_Handicap{N}.HB_*` (or `_1st_Quarter_`) |
| Tennis | `@To_Win_1st_Set_With_Handicap{N}.HB_*` |
| Hockey | `@To_Win_1st_Period_With_Handicap{N}.HB_*` (TBD verify) |
Value extraction: same `.middle-simple` text as match handicap.
### 3.3 Period-N Total Less / More
This is the **least uniform** market. Observed:
| Sport | Period-1 Total selection key |
|---|---|
| Football | (search HTML directly — Phase 3 should parse the "Тотал тайма" tab) Likely `@1st_Half_Total_Goals{N}.Under_<X>` / `.Over_<X>`. |
| Basketball | Per-quarter total exposed as separate market in the "Тоталы" tab; sample event did not show clean `1st_Half_Total_Points` keys — see SCRAPE_FINDINGS.md §6 risk #4. **May need to fall back to NULL** for basketball Period-N Total in some leagues. |
| Tennis | `@1st_Set_Total_Games{N}.Under_<X>` / `.Over_<X>` — confirmed in sample. |
| Hockey | `@1st_Period_Total_Goals...` (TBD verify). |
**Phase 3 robustness rule:** if a period-N market is absent in the parsed HTML,
emit `null` for the corresponding rate/value. Never throw. The Excel exporter
writes empty cell.
---
## 4. Live Counterparts
When the same scope is captured from the **live** site (`/su/live` or live-flagged
events on `/su/`), the spec wants column prefix `Live_*` instead of `Bet_*`.
**Important:** live events use the SAME `data-selection-key` naming conventions.
The distinguishing signal is `data-live="true"` on the outer `div.coupon-row` and
the URL the snapshot was scraped from (`/su/live`).
Examples:
- `Live_Match_Win_1``[data-selection-key$='@Match_Result.1']` from live page
- `Live_Match_Win_Fora_1_Value`, `Live_Match_Win_Fora_1_Rate` ← same DOM, same logic
- `Live_Period-1_Win_1` ← same as `Bet_Period-1_Win_1` but captured from live event
**Implementation:** the parser does not change. The application service simply
records `Source = Live | PreMatch` on each `OddsSnapshot` and the Excel exporter
denormalizes pre-match snapshots to `Bet_*` columns and live snapshots to `Live_*`
columns at write time.
---
## 5. Field Coverage Matrix (spec → confidence)
| Field family | Football | Basketball | Tennis | Hockey | Notes |
|---|---|---|---|---|---|
| `Match_Win_1/2`, `Match_Draw` | ✅ confirmed | ⚠️ Win-1/2 confirmed; Draw conditional on `Normal_Time_Result` presence | ✅ Win-1/2 confirmed; **Draw is null** | ❓ verify Phase 3 | — |
| `Match_Win_Fora_*` | ✅ | ✅ | ✅ (in games) | ❓ | "Main line" heuristic needed (§2.2) |
| `Match_Total_*` | ✅ Goals | ✅ Points | ✅ Games | ❓ | "Main line" heuristic needed (§2.3) |
| `Period-1_Win_*` | ✅ Half | ✅ Half / Quarter | ✅ Set | ❓ Period | basketball mode is configurable |
| `Period-1_Win_Fora_*` | ✅ | ✅ | ✅ | ❓ | — |
| `Period-1_Total_*` | ⚠️ structure verified, exact key TBD | ⚠️ may be absent for some games | ✅ Set | ❓ | risk: emit null where absent |
| `Period-2/3/4_*` | (Period-2 only) | ✅ all | up to actual played sets | ❓ | — |
| `Live_*` (any of above) | same parser | same | same | same | distinguished only by `data-live` flag + scrape URL |
Legend: ✅ confirmed in spike sample, ⚠️ partial / heuristic needed, ❓ Phase 3 must verify.
---
## 6. Suggested Domain Types (Phase 1 input)
```csharp
// Marathon.Domain
public enum BetScope { Match, Period }
public enum BetType { Win, Draw, WinFora, Total }
public enum BetSide { Side1, Side2, Less, More } // Side1=home/W1, Side2=away/W2
public sealed record Sport(int Code, string NameRu, string NameEn);
public sealed record League(int TreeId, string NameRu, int SportCode);
public sealed record Event(
long EventCode, // marathonbet's data-event-eventId
int TreeId, // for URL building
int SportCode,
int LeagueTreeId,
string Country, // breadcrumb position 3
string? Category, // joined breadcrumb 5..N-1
string Team1,
string Team2,
DateTimeOffset ScheduledAt, // anchored on initData.serverTime
string DetailUrl);
public sealed record Bet(
BetScope Scope,
int? PeriodNumber, // null when Scope=Match
BetType Type,
BetSide? Side, // null for Type=Draw
decimal? Value, // handicap/total threshold; null for Win/Draw
decimal Rate); // decimal odds (e.g., 1.65)
public sealed record OddsSnapshot(
long EventCode,
DateTimeOffset CapturedAt,
SnapshotSource Source, // Pre | Live
IReadOnlyList<Bet> Bets);
public enum SnapshotSource { PreMatch, Live }
```
Phase 1 will refine names, but this captures the data shape Phase 3 produces.
---
## 7. Excel Column Generation (Phase 4 / 9 reference)
The Excel exporter generates wide rows by joining all `Bet`s of an `OddsSnapshot`
into named columns. Pseudocode:
```
foreach snapshot:
row.EventCode = snapshot.EventCode
row.SportCode = event.SportCode
row.Sport = event.Sport.NameRu
row.Country = event.Country
row.League = event.League.NameRu
row.Category = event.Category
row.ScheduledAt = event.ScheduledAt
prefix = snapshot.Source == PreMatch ? "Bet_" : "Live_"
// Match scope
row[prefix+"Match_Win_1"] = bet.Where(scope=Match, type=Win, side=Side1).Rate
row[prefix+"Match_Draw"] = bet.Where(scope=Match, type=Draw).Rate
row[prefix+"Match_Win_2"] = bet.Where(scope=Match, type=Win, side=Side2).Rate
row[prefix+"Match_Win_Fora_1_Value"] = bet.Where(scope=Match, type=WinFora, side=Side1).Value
row[prefix+"Match_Win_Fora_1_Rate"] = bet.Where(scope=Match, type=WinFora, side=Side1).Rate
row[prefix+"Match_Win_Fora_2_Value"] = bet.Where(scope=Match, type=WinFora, side=Side2).Value
row[prefix+"Match_Win_Fora_2_Rate"] = bet.Where(scope=Match, type=WinFora, side=Side2).Rate
row[prefix+"Match_Total_Less_Value"] = bet.Where(scope=Match, type=Total, side=Less).Value
row[prefix+"Match_Total_Less_Rate"] = bet.Where(scope=Match, type=Total, side=Less).Rate
row[prefix+"Match_Total_More_Value"] = bet.Where(scope=Match, type=Total, side=More).Value
row[prefix+"Match_Total_More_Rate"] = bet.Where(scope=Match, type=Total, side=More).Rate
// Period scope (foreach period N exposed for that sport)
for N in 1..MaxPeriodForSport(sportCode):
same fields with key {prefix}Period-{N}_*
null when bet absent
```
Spec column order is left to Phase 4 (`ExcelExporter`). Recommend:
`Date, Time, Sport, Country, League, Category, Event, EventCode,
Bet_Match_*..., Bet_Period-1_*..., Bet_Period-2_*..., Live_Match_*..., Live_Period-N_*...`
---
## 8. Decisions Pending Customer Confirmation
1. **Basketball Period mapping** — halves (default) or quarters? Spec says
"Period-N" but is silent on which N applies. Recommend halves (`N ∈ {1,2}`)
with a quarter mode opt-in via `appsettings.Sports.Basketball.PeriodMode`.
2. **Tennis Draw column** — emit empty / 0 / "—"? Recommend empty cell.
3. **Handicap "main line" rule** — pick the listing's main row, OR the no-suffix
selection, OR the spread closest to bookmaker-implied probability 50/50?
4. **Total "main line" rule** — same as above.
5. **Field name capitalization** — spec uses `Bet_Match_Win_Fora_1_Value` exactly.
Recommend matching exactly (case-sensitive) for compatibility with downstream
pivot tables / scripts.
+347
View File
@@ -0,0 +1,347 @@
# Phase 0 Spike — Scraping Findings for marathonbet.by
**Date:** 2026-05-05
**Probe environment:** Windows 10, Poland-routed IP (countryCode `PL` reported by site,
`isBelarus: true` flag set in `initData`, `jurisdiction: BELARUS`).
**Tooling used:** `curl` with browser User-Agent, ~10 sequential requests with
≥1-second pacing.
---
## TL;DR — Decision Matrix
| Question | Answer |
|---|---|
| Is anonymous scraping feasible? | **YES — confirmed.** Site returns full server-rendered HTML for `/su/`, `/su/live`, sport listings, and event detail pages with HTTP 200 to a plain GET with browser User-Agent. |
| Cloudflare / JS challenge? | **No.** `Server: nginx`, no `cf-ray`, no challenge cookies. Only standard JSESSIONID + analytics cookies. No reCAPTCHA on listing pages. |
| Geo-block from probe environment? | **No.** Probe was made from a non-Belarus IP; site served full HTML. The site treats us as `region:"PL"` but still serves Russian-language `/su` content. |
| Recommended scraping technology | **HttpClient + AngleSharp.** All the data needed (event list, full odds, breadcrumb taxonomy, period markets) is present in the raw SSR HTML. Playwright is not required for read-only scraping. |
| Recommended polling cadence | Pre-match: **30 seconds** (default in `appsettings`). Live: 3-second native cadence is too aggressive — recommend **510 seconds** for our analyzer (anomaly detection doesn't need sub-second resolution). |
| WebSocket / API alternative? | STOMP-over-WebSocket exists at `/su/websocket/endpoint` for authenticated clients. Anonymous clients should stick to plain HTML scraping. The JSONP endpoint at `/su/liveupdate/popular/` only returns refresh-page signals, not full odds. |
---
## 1. Probe Outcomes
### 1.1 Pre-match landing — `https://www.marathonbet.by/su`
```
HTTP/1.1 200 OK
Server: nginx
Content-Type: text/html;charset=UTF-8
Set-Cookie: visitedNavBarItems=HOME; HttpOnly; SameSite=None; Secure
Set-Cookie: lastSitePart=SPORT; ...
Set-Cookie: puid=rBWP3Wn5...; expires=2037; domain=.marathonbet.by
Strict-Transport-Security: max-age=31536000
Cache-Status: MISS
Cache-Control: no-store, no-cache, must-revalidate
```
- **Render type:** Server-Side Rendered (SSR). Body is ~590 KB of HTML containing
the full event grid for live + popular pre-match events. There IS a `<div id="app">`
wrapper but the content inside is fully populated server-side; the JS layer enhances
rather than hydrates from empty.
- **Rich data attributes embedded:**
- `data-event-eventId="<bookmakerEventCode>"` — bookmaker's stable numeric event ID
- `data-event-treeId="<treeId>"` — tree position ID (used in URLs)
- `data-event-name="..."` — event display name
- `data-event-path="<sport>/<league-path>/<teams> - <treeId>"` — URL fragment to
construct event detail link
- `data-live="true|false"` — live vs pre-match flag
- `data-sport-treeId="<sportId>"` — sport identifier (matches customer's "Sport_Code")
- `data-coeff-uuid` + `data-sel='{...}'` JSON — selection metadata (ewc, cid, prt, epr)
- `data-selection-key="<eventId>@<MarketType>[N].<Outcome>"` — canonical bet identifier
- **Embedded `initData` JSON blob** (line 6 of every page) exposes runtime config:
- `serverTime: "2026,05,05,00,43,28"` (Moscow TZ)
- `liveUpdatePath: "/su/liveupdate/popular/"`
- `liveUpdateTransport: "JSONP"`
- `update_interval: 3000` (ms — live update polling cadence used by the site itself)
- `stomp.url: "/su/websocket/endpoint"` (authenticated stream)
- `region`, `isBelarus`, `jurisdiction`, `currencyCode` — geo/legal flags
- `treeIds` — for the event detail page, holds the focal treeId
### 1.2 Live landing — `https://www.marathonbet.by/su/live`
- HTTP 200, ~250 KB body — same `nginx` server, same SSR pattern.
- Same `data-event-*` attributes as pre-match. Live events show `data-live="true"`,
with extra `score-state` and `time` markers (e.g., `2:1 (1:1)`, `83:30`).
- The site polls `/su/liveupdate/popular/?treeIds=...` every 3 s but the response
is just a refresh signal (`{"modified":[{"type":"refreshPage"}],"updated":...}`)
**the site relies on full HTML re-fetch for live updates**, which is good for us
(no separate JSON contract to track).
### 1.3 Sport-specific listing — `/su/popular/Basketball` / `/su/betting/Basketball+-+6`
- HTTP 200, ~470 KB.
- Lists all current basketball categories (NBA Playoffs etc.) with full odds.
- URL by name (`Basketball`) and URL by sport tree ID (`Basketball+-+6`) both work.
- Date display: events on the same day show **time only** (`03:00`); events on
later days show **`DD <month-ru> HH:MM`** (e.g., `06 мая 02:00`). The "today"
anchor is implicit — must be derived from `initData.serverTime`.
### 1.4 Event detail — `/su/betting/<event-path>`
- HTTP 200, ~500 KB to ~1.6 MB depending on market count.
- URL pattern: `/su/betting/<Sport>/<League+Path>/<Sub+Stage>/<Team1+vs+Team2+-+<treeId>>`.
- Exposes ~140250 unique market types per event. Each market is a `<div>` containing
a labeled `<table>` of selections with `data-selection-key`, prices, and handicap/total
values in `<span class="middle-simple">`.
- **Schema.org breadcrumb** at the bottom of the page provides clean taxonomy:
Sport → Country/Group → League → Stage → Event. Each level has its own treeId visible
in `href="/su/betting/<path>+-+<treeId>"`.
- Sample (Football, Arsenal vs Atletico Madrid, treeId 28089645, eventId 26456117):
- Sport = `Football+-+11`, Country group = `Clubs.+International+-+4409575`,
League = `UEFA+Champions+League+-+21255`, Stage = `Play-Offs / Semi+Final / 2nd+Leg`.
- Match-level markets: `Match_Result.{1,draw,3}`, `To_Win_Match_With_Handicap{N}.{HB_H,HB_A}`,
`Total_Goals{N}.{Under_X,Over_X}`.
### 1.5 Results / archive — **NOT publicly available**
- `https://www.marathonbet.by/su/results`**HTTP 404**.
- `https://www.marathonbet.by/su/results/`**HTTP 404**.
- `https://www.marathonbet.by/su/results.htm`**HTTP 404**.
- No `/results`, `/archive`, or `/history` link anywhere in the public landing-page HTML.
- The `eventJsonInfo` `<td>` on each event has a `matchIsComplete` boolean and a
`resultDescription` (e.g., `"2:1 (1:1)"`), so **final scores can be captured by
re-scraping the event detail page after match end** — but only while the event is
still hosted (likely a few hours / days post-match). After cleanup, results are gone.
- **Implication for Phase 8 (Results loader):** results must be harvested by
continuing to poll the event detail page until `matchIsComplete=true`, then storing
the final score. There is no historical archive endpoint to back-fill from. We
should also evaluate scraping a third-party results aggregator
(flashscore, livescore, sofascore) as a fallback — that's a Phase 8 design decision.
---
## 2. Anti-bot Posture
| Signal | Observation |
|---|---|
| Cloudflare | Absent. `Server: nginx`, no `cf-*` headers. |
| reCAPTCHA / hCAPTCHA | Not on public listing or event pages (only on `/captchaData.htm` for login). |
| User-Agent filtering | A browser UA returns 200. We did not test with `curl/8.x` or empty UA — recommend always sending a real UA. |
| Cookie requirement | None for read-only access. The site sets `puid`, `JSESSIONID`, `lastSitePart`, etc., but we observed full HTML on the very first request without prior cookies. |
| IP rate-limit | 5 sequential requests at ~1s pacing all returned 200 in <1 s. No throttling observed within our budget (10 total requests). The customer should test heavier loads from their environment. |
| Geo-block | Probe environment is geo-routed as Poland; site still serves `/su` Russian content. Customer (Belarus) should see same or better access. |
| Fingerprinting | Standard analytics (GTM, dataLayer); no JS-fingerprint cookies or canvas hashing detected in the entry-page payload. |
**Mitigations to bake into the scraper anyway** (defense-in-depth):
- **Rotate User-Agents** from a small pool of recent Chrome/Firefox/Edge versions
(configurable via `Scraping:UserAgents[]`).
- **Polite pacing:** default `Scraping:RateLimit:RequestsPerSecond = 1`,
`MaxConcurrentRequests = 4`. Per-host token-bucket rate limiter using Polly v8 +
`Microsoft.Extensions.Http.Resilience`.
- **Honor `Cache-Control: no-store`** — do NOT cache responses; that's the site's intent.
- **Handle 403 / 429 / 503** with exponential backoff and circuit breaker; alert the user
when circuit opens for >5 minutes.
- **Cookie jar per scraper instance** — accept set-cookies and replay them. This avoids
a session-creation latency on every request.
- **Belarus-specific:** if customer's environment ever sees a `/forbidden` redirect,
we fall back to the `afterForbiddenRedirectUrl` documented in `initData`.
---
## 3. URL Templates Phase 3 Will Use
| Purpose | Template | Notes |
|---|---|---|
| Pre-match top page | `https://www.marathonbet.by/su/` | Mixed live + popular pre-match. Use only for landing/health-check. |
| Live top page | `https://www.marathonbet.by/su/live` | Mixed sports. Use for live-event discovery. |
| Live popular | `https://www.marathonbet.by/su/live/popular` | Same data as `/su/live`. |
| All-events index | `https://www.marathonbet.by/su/all-events/` | Long full list; use for discovery seed. |
| Sport listing (by ID) | `https://www.marathonbet.by/su/betting/{Sport}+-+{sportId}` | e.g., `/su/betting/Basketball+-+6`. **Preferred** because sport-id stable. |
| Sport listing (by name) | `https://www.marathonbet.by/su/popular/{Sport}` | e.g., `/su/popular/Basketball`. Convenient for humans. |
| Category / league listing | `https://www.marathonbet.by/su/betting/{Sport}/{League+Path}+-+{categoryTreeId}` | From breadcrumbs / `category-label-link`. |
| Event detail | `https://www.marathonbet.by/su/betting/{event-path}` | `event-path` from `data-event-path`, ends in `-+{treeId}`. |
| Live update signal | `https://www.marathonbet.by/su/liveupdate/popular/?treeIds={csv}` | Returns `{"modified":[...],"updated":<ts>}`. Use only as "hey something changed" hint; full odds still come from event-detail re-fetch. |
| Server time sync | `https://www.marathonbet.by/su/stateless/synctime` | Use to anchor "today" date interpretation. |
URL paths use `+` for spaces, `%2C` for `,`, etc. — standard `Uri.EscapeDataString`.
---
## 4. Sport ID Inventory (observed)
From the pre-match landing page (`data-sport-treeId` attributes + `category-label`
breadcrumb hrefs):
| Sport ID | Russian name | English path |
|---|---|---|
| **6** | Баскетбол | `Basketball` |
| **11** | Футбол | `Football` |
| **537** | (TBD — verify on populated day) | — |
| **2398** | (TBD) | — |
| **22723** | Теннис | `Tennis` |
| **26418** | Футбол (alt? duplicate live) | `Football` |
| **43658** | Хоккей | `Hockey` |
| **45356** | Баскетбол (live tree) | `Basketball` |
| **139722** | Гандбол | `Handball` |
| **414329** | Настольный теннис | `Table+Tennis` |
| **1372932** | Киберспорт | `Esports` |
| **3083982** | Лотереи | `Lotteries` |
| **11308234** | Шорт хоккей | `Short+Hockey` |
| **23054364** | Кибербаскетбол | `eBasketball` |
| **23054392** | Киберфутбол | `eFootball` |
**Important observation:** the site has **two parallel tree IDs per sport** — one
"canonical" (e.g., `6` for Basketball) used on event-detail breadcrumb, and a
"category" tree ID (e.g., `45356`) used inside the live grouping. Phase 1 domain
needs to recognize the canonical ID as `SportCode` and ignore the category tree ID.
The customer-spec field `Sport_Code = 6` for Basketball matches the canonical ID
in `data-sport-treeId="6"` and in the breadcrumb URL `/su/betting/Basketball+-+6`.
---
## 5. Bet Selection Naming Convention
Format: `{eventId}@{MarketName}{LineIndex?}.{Outcome}`
Where:
- `eventId` = bookmaker's `data-event-eventId` (numeric, ~26-million range, stable).
- `MarketName` = `Match_Result`, `To_Win_Match_With_Handicap`, `Total_Points`,
`1st_Half_Result`, `To_Win_1st_Half_With_Handicap`, `1st_Set_Total_Games`, etc.
- `LineIndex?` = optional integer suffix when a market has multiple lines/spreads
(e.g., `Total_Points10`, `Total_Points11` are different total thresholds for the
same event). Empty / `0` is the "main" line.
- `Outcome` codes:
- `1`, `draw`, `3` — for 3-way result markets
- `HB_H`, `HB_A` — handicap home/away
- `Under_<X>`, `Over_<X>` — total under/over (X is the threshold, embedded in name)
- `HD`, `AD` — half-time/full-time draw combinations
- `yes` / `no` — for yes/no markets
The handicap value (`+1.0`, `-2.5`) and total threshold (`213.5`) are NOT in the
selection key as parseable numbers — they live in the `<span class="middle-simple">`
display element OR they are embedded in the outcome name (e.g., `Under_213.5`).
---
## 6. Period Scope per Sport (observed)
| Sport | Period scopes available | Spec field prefix |
|---|---|---|
| Football (11) | 1st Half, 2nd Half | `Bet_Period-1_*`, `Bet_Period-2_*` |
| Basketball (6) | 1st/2nd Half, 1st/2nd/3rd/4th Quarter | Customer must clarify whether Period-N maps to halves or quarters. **Recommend halves** as default (Period-1, Period-2) with an `appsettings` toggle for quarter-mode. |
| Tennis (22723) | 1st Set, 2nd Set, ... (variable count) | `Bet_Period-1_*` = 1st Set, etc. **No Draw outcome.** |
| Hockey (43658) | 1st/2nd/3rd Period | `Bet_Period-1_*`, `Bet_Period-2_*`, `Bet_Period-3_*` (not yet sampled — revalidate in Phase 3). |
The internal market-name token is sport-dependent:
- `1st_Half_Result`, `To_Win_1st_Half_With_Handicap`
- `1st_Quarter_Result`, `To_Win_1st_Quarter_With_Handicap`
- `1st_Set_Result`, `To_Win_1st_Set_With_Handicap`
**Phase 3 should encapsulate this** in a sport-aware mapping table
(`PeriodScopeMapper`) keyed on `SportCode`, returning the set of expected period
markets and their token names.
---
## 7. Open Questions / Risks
1. **Results storage cleanup:** how long does marathonbet keep finished events on
the event detail URL? Must be empirically tested over Phase 8. Recommend retaining
our own snapshot with `matchIsComplete=true` permanently in SQLite as soon as
we observe it, so we never depend on the site for historical data.
2. **Sport ID duplication** (e.g., `26418` and `11` both = Football):
verify with customer that we should use the canonical breadcrumb ID. The
"category" trees may exist for live grouping or alphabetization purposes.
3. **Localization:** site labels are Russian on `/su/`. There appears to be `/en/`
path support (untested). Customer wants RU + EN — Phase 5 must verify EN locale
page parses identically.
4. **Period total markets in basketball:** sampled NBA event did NOT explicitly
expose "Total points 1st quarter" as a clean market in the public HTML — only
`AllInningsGoalsOver` (combined). Customer's spec implies `Bet_Period-N_Total_*`
is universal — Phase 3 must gracefully degrade and emit `null` rates for fields
the site doesn't surface for that sport+league.
5. **Belarus geo-restriction risk:** we tested from non-BY. If customer's BY IP
gets a different page (KYC overlay, deposit prompt, etc.), the parser must be
robust to unexpected wrapping. Defensive parsing only — never assume strict
structure.
6. **`isLogged: false` overlay risk:** initData reports we are anonymous. Some
markets may be hidden behind login (we did not detect any in samples, but the
parser should treat missing markets as `null`, not throw).
---
## 8. Recommended Phase 3 Architecture
```
IOddsScraper (Application)
└── MarathonBetScraper : IOddsScraper (Infrastructure)
├── HttpClient (resilient via Polly v8)
│ ├── User-Agent rotator
│ ├── Token-bucket rate limiter (config: RequestsPerSecond)
│ ├── Retry policy (3x exponential backoff, jitter)
│ └── Circuit breaker (open after N consecutive 5xx)
├── EventDiscoveryParser ← parses /su/, /su/live, /su/popular/{sport}
│ produces List<EventListItem>
├── EventDetailParser ← parses /su/betting/<path>
│ produces FullOddsSnapshot with all markets
├── BreadcrumbParser ← extracts Sport / Country / League / Stage taxonomy
└── BetMarketMapper ← AngleSharp QuerySelector → spec field name
(sport-aware; uses PeriodScopeMapper)
```
**Use AngleSharp for parsing** — it handles malformed HTML well, has a CSS-selector
API, and is the established `.NET` choice. JSON islands inside attributes (`data-sel`,
`data-json`) decode cleanly with `System.Text.Json`.
**No Playwright required** for the scraper. Keep Playwright as a documented
fallback in `appsettings` (`Scraping:UsePlaywright = false`) so we can flip it on
later if the site adds JS challenges. This adds <100 LOC of optional code, costs
nothing if unused.
---
## 9. Customer Validation Plan
If our environment ever stops working (geo-block, IP ban, etc.) the customer in
Belarus can:
1. Open https://www.marathonbet.by/su in a browser, verify it renders.
2. View page source (Ctrl+U), search for `data-event-eventId` — confirm same
structure as our captured `spike/captures/pre-match-landing.html`.
3. Save the HTML and email it to dev — the parser is environment-agnostic and
should handle their captured HTML byte-for-byte.
This decouples scraper development from probe environment and makes Phase 3
testable offline.
---
## 10. Captured Samples (gitignored, local only)
| File | Purpose |
|---|---|
| `spike/captures/pre-match-landing.html` | `/su/` snapshot, 587 KB, full grid |
| `spike/captures/live-landing.html` | `/su/live` snapshot, 250 KB |
| `spike/captures/basketball-listing.html` | `/su/popular/Basketball`, 471 KB |
| `spike/captures/event-basketball-28405506.html` | NBA Knicks vs 76ers full event, 505 KB |
| `spike/captures/event-football-28089645.html` | UCL Arsenal vs Atletico full event, 1.58 MB |
| `spike/captures/event-tennis-28430484.html` | ATP Rome qualif full event, 244 KB |
| `spike/captures/liveupdate-popular.json` | Live-update API sample response |
| `spike/captures/results-page.html` | `/su/results` response (~20 KB) — captured to evidence the missing public archive endpoint (Phase 8 deviation). |
These artifacts are **not committed** but should be kept locally to back parser unit
tests in Phase 3.
> **Caveats on captures:**
>
> - `live-landing.html` was captured at a moment when no live events were
> in-progress for popular sports. As a result, the `.score-state` element
> referenced in `SCHEMA_DRAFT.md` §1 is NOT present in this particular capture.
> Phase 3 should re-verify the score selector against a live event during
> parser implementation (the selector itself is well-known across bookmaker
> sites and not in doubt).
> - Hockey events were not sampled directly. Period-result selection key tokens
> for hockey (`1st_Period_Result0.RN_H` etc.) are extrapolated from the
> football/basketball/tennis pattern and marked TBD in `SCHEMA_DRAFT.md`. Phase 3
> must verify against a real hockey event before relying on those tokens.
@@ -0,0 +1,8 @@
using Marathon.Domain.Entities;
namespace Marathon.Application.Abstractions;
/// <summary>
/// Repository for <see cref="Anomaly"/> domain entities.
/// </summary>
public interface IAnomalyRepository : IRepository<Guid, Anomaly>;
@@ -0,0 +1,21 @@
namespace Marathon.Application.Abstractions;
/// <summary>
/// Marker interface for the future bet-placing feature.
/// </summary>
/// <remarks>
/// <para>
/// This interface is intentionally empty. It acts as an extension point for
/// a future implementation that interacts with a bookmaker's authenticated
/// betting API.
/// </para>
/// <para>
/// Phase 3 scope is analyze-only. Register a stub / no-op implementation if
/// needed for DI graph completeness, but the interface itself is not consumed
/// by any application service in the current release.
/// </para>
/// </remarks>
public interface IBetPlacer
{
// Future: PlaceBetAsync(BetRequest request, CancellationToken ct)
}
@@ -0,0 +1,27 @@
using Marathon.Application.Storage;
using Marathon.Domain.Entities;
using Marathon.Domain.ValueObjects;
namespace Marathon.Application.Abstractions;
/// <summary>
/// Repository for <see cref="Event"/> domain entities.
/// </summary>
public interface IEventRepository : IRepository<EventId, Event>
{
Task<IReadOnlyList<Event>> ListByDateRangeAsync(DateRange range, CancellationToken ct = default);
Task<IReadOnlyList<Event>> ListBySportAsync(SportCode sport, CancellationToken ct = default);
/// <summary>
/// Distinct sport codes across the events table. Projects in the database
/// rather than materialising every <see cref="Event"/> on the client.
/// </summary>
Task<IReadOnlyList<int>> ListDistinctSportCodesAsync(CancellationToken ct = default);
/// <summary>
/// Distinct ISO-2 country codes across the events table. Projects in the
/// database rather than materialising every <see cref="Event"/>.
/// </summary>
Task<IReadOnlyList<string>> ListDistinctCountryCodesAsync(CancellationToken ct = default);
}
@@ -0,0 +1,22 @@
using Marathon.Application.Storage;
namespace Marathon.Application.Abstractions;
/// <summary>
/// Exports odds snapshots to an Excel file matching the customer's wide-column specification.
/// </summary>
public interface IExcelExporter
{
/// <summary>
/// Exports snapshots for the given date range to an XLSX file.
/// </summary>
/// <param name="range">The inclusive date range to export.</param>
/// <param name="kind">Which snapshots to include: pre-match, live, or combined.</param>
/// <param name="outputPath">
/// Directory where the file will be written. The filename is auto-generated as
/// <c>Marathon_yyyy-MM-dd_to_yyyy-MM-dd.xlsx</c>.
/// </param>
/// <param name="ct">Cancellation token.</param>
/// <returns>The full path of the created file.</returns>
Task<string> ExportAsync(DateRange range, ExportKind kind, string outputPath, CancellationToken ct = default);
}
@@ -0,0 +1,75 @@
using Marathon.Domain.Entities;
using Marathon.Domain.Enums;
using Marathon.Domain.ValueObjects;
namespace Marathon.Application.Abstractions;
/// <summary>
/// Scrapes upcoming events, live odds snapshots, and completed event results
/// from a bookmaker's public web interface.
/// </summary>
/// <remarks>
/// The infrastructure implementation (<c>MarathonbetScraper</c>) uses
/// HttpClient + AngleSharp + Polly. All methods are non-blocking and
/// honour the caller's <see cref="CancellationToken"/>.
/// </remarks>
public interface IOddsScraper
{
/// <summary>
/// Returns the list of upcoming (pre-match) events, optionally filtered to one sport.
/// </summary>
/// <param name="sportFilter">When non-null, restricts results to the given sport code.</param>
/// <param name="ct">Cancellation token.</param>
Task<IReadOnlyList<Event>> ScrapeUpcomingAsync(
SportCode? sportFilter,
CancellationToken ct);
/// <summary>
/// Returns the list of currently-live events parsed from <c>/su/live</c>.
/// Each returned <see cref="Event"/> has its <see cref="Event.EventPath"/>
/// populated so the caller can immediately fetch its odds snapshot.
/// </summary>
/// <param name="ct">Cancellation token.</param>
Task<IReadOnlyList<Event>> ScrapeLiveAsync(CancellationToken ct);
/// <summary>
/// Fetches a full odds snapshot (all markets) for a single event.
/// </summary>
/// <param name="eventInfo">
/// The event to scrape — its <see cref="Event.EventPath"/> drives URL construction.
/// When the path is null (legacy row), the scraper falls back to the numeric event ID.
/// </param>
/// <param name="source">Whether this is a pre-match or live scrape.</param>
/// <param name="ct">Cancellation token.</param>
Task<OddsSnapshot> ScrapeEventOddsAsync(
Event eventInfo,
OddsSource source,
CancellationToken ct);
/// <summary>
/// Fetches the event-detail page for a single event and extracts its final
/// result if and only if the bookmaker has flagged the match as complete
/// (<c>eventJsonInfo.matchIsComplete = true</c>).
/// </summary>
/// <remarks>
/// <para>
/// marathonbet.by has no public results archive endpoint
/// (<c>/su/results</c> → 404), so results are harvested per-event by
/// re-fetching the same event-detail HTML used for odds scraping and
/// parsing the embedded <c>eventJsonInfo</c> JSON.
/// </para>
/// </remarks>
/// <param name="eventInfo">
/// The event to query — its <see cref="Event.EventPath"/> drives URL
/// construction (with the numeric ID as a best-effort fallback).
/// </param>
/// <param name="ct">Cancellation token.</param>
/// <returns>
/// An <see cref="EventResult"/> when the match is complete and the score
/// could be parsed, <c>null</c> when the match is still in-progress or
/// the score string is unrecognised.
/// </returns>
Task<EventResult?> ScrapeEventResultAsync(
Event eventInfo,
CancellationToken ct);
}
@@ -0,0 +1,23 @@
namespace Marathon.Application.Abstractions;
/// <summary>
/// Generic repository abstraction providing CRUD operations for a domain entity.
/// </summary>
/// <typeparam name="TKey">The type of the entity's primary key.</typeparam>
/// <typeparam name="TEntity">The domain entity type.</typeparam>
public interface IRepository<TKey, TEntity>
where TKey : notnull
where TEntity : class
{
Task<TEntity?> GetAsync(TKey key, CancellationToken ct = default);
Task<IReadOnlyList<TEntity>> ListAsync(CancellationToken ct = default);
Task AddAsync(TEntity entity, CancellationToken ct = default);
Task UpdateAsync(TEntity entity, CancellationToken ct = default);
Task DeleteAsync(TKey key, CancellationToken ct = default);
Task SaveChangesAsync(CancellationToken ct = default);
}
@@ -0,0 +1,9 @@
using Marathon.Domain.Entities;
using Marathon.Domain.ValueObjects;
namespace Marathon.Application.Abstractions;
/// <summary>
/// Repository for <see cref="EventResult"/> domain entities.
/// </summary>
public interface IResultRepository : IRepository<EventId, EventResult>;
@@ -0,0 +1,39 @@
using Marathon.Domain.Entities;
using Marathon.Domain.ValueObjects;
namespace Marathon.Application.Abstractions;
/// <summary>
/// Repository for <see cref="OddsSnapshot"/> domain entities.
/// </summary>
/// <remarks>
/// Snapshots are append-only and identified by the composite (EventId, CapturedAt)
/// rather than a surrogate key, so this contract intentionally does NOT extend
/// <see cref="IRepository{TKey, TEntity}"/> — point lookup by Guid would be
/// meaningless. Use <see cref="ListByEventAsync"/> for retrieval.
/// </remarks>
public interface ISnapshotRepository
{
Task<IReadOnlyList<OddsSnapshot>> ListAsync(CancellationToken ct = default);
Task<IReadOnlyList<OddsSnapshot>> ListByEventAsync(
EventId eventId,
DateTimeOffset from,
DateTimeOffset to,
CancellationToken ct = default);
/// <summary>
/// Batched companion to <see cref="ListByEventAsync"/>: loads snapshots
/// for many events in a single query and groups by <see cref="EventId"/>.
/// Events with no snapshots in range get an empty list in the result.
/// </summary>
Task<IReadOnlyDictionary<EventId, IReadOnlyList<OddsSnapshot>>> ListByEventsAsync(
IReadOnlyCollection<EventId> eventIds,
DateTimeOffset from,
DateTimeOffset to,
CancellationToken ct = default);
Task AddAsync(OddsSnapshot entity, CancellationToken ct = default);
Task SaveChangesAsync(CancellationToken ct = default);
}
@@ -0,0 +1,36 @@
using Marathon.Application.UseCases;
using Microsoft.Extensions.DependencyInjection;
namespace Marathon.Application;
/// <summary>
/// DI registration helpers for the Marathon.Application layer.
/// Call <see cref="AddMarathonApplication"/> from the composition root (host or
/// <c>InfrastructureModule</c>).
/// </summary>
public static class ApplicationModule
{
/// <summary>
/// Registers all Application-layer use cases with <c>Scoped</c> lifetime.
/// Use cases are scoped so that each background-service cycle or UI request
/// gets a fresh unit-of-work from its own DI scope.
/// </summary>
/// <remarks>
/// No <see cref="Microsoft.Extensions.Configuration.IConfiguration"/> is
/// required here — the Application layer has no direct configuration bindings.
/// Infrastructure and UI layers bind their own options against the shared
/// JSON sections.
/// </remarks>
public static IServiceCollection AddMarathonApplication(this IServiceCollection services)
{
ArgumentNullException.ThrowIfNull(services);
services.AddScoped<PullUpcomingEventsUseCase>();
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,26 @@
namespace Marathon.Application.Configuration;
/// <summary>
/// Application-layer view of the scraping concurrency knobs.
/// </summary>
/// <remarks>
/// <para>
/// Bound from the same <c>Scraping</c> appsettings section as
/// <c>Marathon.Infrastructure.Configuration.ScrapingOptions</c> — but only the
/// fields the use cases need to schedule fan-out. Keeping a separate Application
/// type avoids leaking the Infrastructure namespace into use-case code.
/// </para>
/// </remarks>
public sealed class ScrapingThrottle
{
public const string SectionName = "Scraping";
/// <summary>
/// Maximum number of in-flight HTTP requests the scraper is allowed to
/// issue concurrently. Use cases use this as the
/// <see cref="ParallelOptions.MaxDegreeOfParallelism"/> for batch fan-out.
/// The bookmaker rate limiter still throttles to <c>RequestsPerSecond</c>
/// underneath this value.
/// </summary>
public int MaxConcurrentRequests { get; init; } = 4;
}
+3
View File
@@ -0,0 +1,3 @@
// Alias Microsoft.Extensions.Logging.EventId to avoid name conflict with
// Marathon.Domain.ValueObjects.EventId throughout the Application layer.
global using LogEventId = Microsoft.Extensions.Logging.EventId;
@@ -0,0 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Options" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Marathon.Domain\Marathon.Domain.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,21 @@
namespace Marathon.Application.Storage;
/// <summary>
/// An inclusive date-time range used for querying and exporting snapshots.
/// </summary>
public sealed record DateRange
{
public DateTimeOffset From { get; }
public DateTimeOffset To { get; }
public DateRange(DateTimeOffset from, DateTimeOffset to)
{
if (from > to)
throw new ArgumentException(
$"DateRange.From ({from:O}) must be less than or equal to DateRange.To ({to:O}).",
nameof(from));
From = from;
To = to;
}
}
@@ -0,0 +1,16 @@
namespace Marathon.Application.Storage;
/// <summary>
/// Controls which odds snapshots are included in an Excel export.
/// </summary>
public enum ExportKind
{
/// <summary>Include only pre-match snapshots (columns prefixed with <c>Bet_</c>).</summary>
PreMatch,
/// <summary>Include only live snapshots (columns prefixed with <c>Live_</c>).</summary>
Live,
/// <summary>Include both pre-match and live snapshots on separate sheets.</summary>
Combined,
}
@@ -0,0 +1,18 @@
namespace Marathon.Application.Storage;
/// <summary>
/// Configuration options for the storage layer, bound to the <c>Storage:*</c> configuration section.
/// </summary>
public sealed class StorageOptions
{
public const string SectionName = "Storage";
/// <summary>Path to the SQLite database file. Default: <c>./data/marathon.db</c>.</summary>
public string DatabasePath { get; set; } = "./data/marathon.db";
/// <summary>Directory where Excel exports are written. Default: <c>./exports</c>.</summary>
public string ExportDirectory { get; set; } = "./exports";
/// <summary>Number of days to retain odds snapshots before pruning. Default: 90.</summary>
public int SnapshotRetentionDays { get; set; } = 90;
}
@@ -0,0 +1,158 @@
using Marathon.Application.Abstractions;
using Marathon.Application.Configuration;
using Marathon.Domain.AnomalyDetection;
using Marathon.Domain.Entities;
using Marathon.Domain.ValueObjects;
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 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 = MoscowTime.Now;
var from = now - SnapshotLookback;
// Hoisted outside the per-event loop: load existing anomalies ONCE per cycle
// and slice per-event in the loop. Previously this was reloaded per event
// (O(N_events) round-trips). Reviewer W1, Phase 7.
var existingAnomalies = await _anomalyRepo.ListAsync(ct);
// Single batched query for all events' snapshots — replaces the prior
// per-event ListByEventAsync round-trip (O(N) SQLite hits + N Include(Bets)
// payloads). Returns an empty list for events with no snapshots in range.
var eventIds = events.Select(e => e.Id).ToList();
var snapshotsByEvent = await _snapshotRepo.ListByEventsAsync(eventIds, from, now, ct);
foreach (var ev in events)
{
ct.ThrowIfCancellationRequested();
try
{
var snapshots = snapshotsByEvent.TryGetValue(ev.Id, out var found)
? found
: Array.Empty<OddsSnapshot>();
newAnomalyCount += await ProcessEventAsync(detector, ev, snapshots, existingAnomalies, 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,
IReadOnlyList<OddsSnapshot> snapshots,
IReadOnlyList<Anomaly> existingAnomalies,
CancellationToken ct)
{
var detected = detector.Detect(ev.Id, snapshots);
if (detected.Count == 0)
return 0;
// Slice the cycle-wide existing-anomaly list to just this event for dedup.
var existingForEvent = existingAnomalies
.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,54 @@
using Marathon.Application.Abstractions;
using Marathon.Application.Storage;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace Marathon.Application.UseCases;
/// <summary>
/// Exports odds snapshots for a date range to an Excel file, placing it in
/// the configured export directory.
/// </summary>
public sealed class ExportToExcelUseCase
{
private readonly IExcelExporter _exporter;
private readonly IOptions<StorageOptions> _storageOptions;
private readonly ILogger<ExportToExcelUseCase> _logger;
public ExportToExcelUseCase(
IExcelExporter exporter,
IOptions<StorageOptions> storageOptions,
ILogger<ExportToExcelUseCase> logger)
{
_exporter = exporter ?? throw new ArgumentNullException(nameof(exporter));
_storageOptions = storageOptions ?? throw new ArgumentNullException(nameof(storageOptions));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <summary>
/// Runs the export and returns the absolute path of the created file.
/// </summary>
/// <param name="range">Inclusive date range to export.</param>
/// <param name="kind">Which snapshots to include.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Absolute path of the written <c>.xlsx</c> file.</returns>
public async Task<string> ExecuteAsync(DateRange range, ExportKind kind, CancellationToken ct)
{
var exportDir = _storageOptions.Value.ExportDirectory;
// Ensure the output directory exists before handing off to the exporter.
Directory.CreateDirectory(exportDir);
_logger.LogInformation(
"ExportToExcelUseCase: exporting {Kind} snapshots for {From:yyyy-MM-dd}..{To:yyyy-MM-dd} → {Dir}",
kind, range.From, range.To, exportDir);
var outputPath = await _exporter.ExportAsync(range, kind, exportDir, ct);
_logger.LogInformation(
"ExportToExcelUseCase: export complete — file={Path}",
outputPath);
return outputPath;
}
}
@@ -0,0 +1,156 @@
using System.Collections.Concurrent;
using Marathon.Application.Abstractions;
using Marathon.Application.Configuration;
using Marathon.Domain.Entities;
using Marathon.Domain.Enums;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace Marathon.Application.UseCases;
/// <summary>
/// Discovers currently-live events from the bookmaker's <c>/su/live</c> listing,
/// persists any not yet known to the database, and captures a fresh
/// <see cref="OddsSource.Live"/> snapshot for each.
/// </summary>
/// <remarks>
/// Live discovery is authoritative: events that go live without ever appearing
/// in the upcoming list (late-added matches, in-play markets opened on demand)
/// are picked up here. Pre-match-only events are NOT scraped by this use case —
/// they would just be wasted requests against the bookmaker.
/// </remarks>
public sealed class PullLiveOddsUseCase
{
private readonly IOddsScraper _scraper;
private readonly IEventRepository _eventRepo;
private readonly ISnapshotRepository _snapshotRepo;
private readonly IOptionsMonitor<ScrapingThrottle> _throttle;
private readonly ILogger<PullLiveOddsUseCase> _logger;
public PullLiveOddsUseCase(
IOddsScraper scraper,
IEventRepository eventRepo,
ISnapshotRepository snapshotRepo,
IOptionsMonitor<ScrapingThrottle> throttle,
ILogger<PullLiveOddsUseCase> logger)
{
_scraper = scraper ?? throw new ArgumentNullException(nameof(scraper));
_eventRepo = eventRepo ?? throw new ArgumentNullException(nameof(eventRepo));
_snapshotRepo = snapshotRepo ?? throw new ArgumentNullException(nameof(snapshotRepo));
_throttle = throttle ?? throw new ArgumentNullException(nameof(throttle));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <summary>
/// Executes one live-odds polling cycle.
/// </summary>
/// <param name="ct">Cancellation token.</param>
/// <returns>Number of live snapshots successfully captured.</returns>
public async Task<int> ExecuteAsync(CancellationToken ct)
{
_logger.LogInformation("PullLiveOddsUseCase: cycle started");
IReadOnlyList<Event> liveEvents;
try
{
liveEvents = await _scraper.ScrapeLiveAsync(ct);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
_logger.LogError(ex,
"PullLiveOddsUseCase: failed to fetch live event listing — skipping cycle");
return 0;
}
_logger.LogInformation(
"PullLiveOddsUseCase: scraper returned {Count} live events",
liveEvents.Count);
// Phase 1 — parallel HTTP fan-out: scrape every event's odds in parallel,
// capped at MaxConcurrentRequests. The scraper's rate limiter still
// throttles to RequestsPerSecond underneath this fan-out, so spikes are
// smoothed out before they reach the bookmaker. We deliberately do NOT
// touch the DbContext (or its repositories) inside the parallel block —
// EF Core DbContext is not thread-safe.
var scraped = new ConcurrentBag<(Event Live, OddsSnapshot Snapshot)>();
var maxParallelism = Math.Max(1, _throttle.CurrentValue.MaxConcurrentRequests);
var parallelOptions = new ParallelOptions
{
MaxDegreeOfParallelism = maxParallelism,
CancellationToken = ct,
};
await Parallel.ForEachAsync(liveEvents, parallelOptions, async (live, taskCt) =>
{
try
{
var snapshot = await _scraper.ScrapeEventOddsAsync(live, OddsSource.Live, taskCt);
scraped.Add((live, snapshot));
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
_logger.LogWarning(ex,
"PullLiveOddsUseCase: failed to capture live snapshot for event {EventId} — skipping",
live.Id.Value);
}
});
// Phase 2 — sequential persistence. EF Core DbContext is single-threaded,
// so we apply each (event upsert + snapshot insert) one at a time.
int snapshotsCaptured = 0;
foreach (var (live, snapshot) in scraped)
{
ct.ThrowIfCancellationRequested();
try
{
// Persist new live events — the upcoming poller may not have seen them
// yet (or never will, for matches added after their scheduled start).
// The Live page reads from the events table, so a new live row must
// exist before its snapshots become visible.
var existing = await _eventRepo.GetAsync(live.Id, ct);
if (existing is null)
{
await _eventRepo.AddAsync(live, ct);
await _eventRepo.SaveChangesAsync(ct);
}
else if (existing.EventPath is null && live.EventPath is not null)
{
// Backfill EventPath on rows persisted before the column existed.
var patched = existing with { EventPath = live.EventPath };
await _eventRepo.UpdateAsync(patched, ct);
await _eventRepo.SaveChangesAsync(ct);
}
await _snapshotRepo.AddAsync(snapshot, ct);
await _snapshotRepo.SaveChangesAsync(ct);
snapshotsCaptured++;
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
_logger.LogWarning(ex,
"PullLiveOddsUseCase: failed to persist live snapshot for event {EventId} — skipping",
live.Id.Value);
}
}
_logger.LogInformation(
"PullLiveOddsUseCase: cycle done — snapshots captured for {Count}/{Total} live events",
snapshotsCaptured, liveEvents.Count);
return snapshotsCaptured;
}
}
@@ -0,0 +1,200 @@
using Marathon.Application.Abstractions;
using Marathon.Application.Storage;
using Marathon.Domain.Entities;
using Microsoft.Extensions.Logging;
using DomainEventId = Marathon.Domain.ValueObjects.EventId;
namespace Marathon.Application.UseCases;
/// <summary>
/// Per-event progress emitted by <see cref="PullResultsUseCase.ExecuteAsync"/>.
/// Used by the UI to render a progress bar and the running list of loaded
/// results — each tick is fired AFTER the bookmaker has been queried for
/// <see cref="EventId"/>, so the UI sees one tick per inspected event.
/// </summary>
/// <param name="Processed">Total events processed so far (1-based at the first tick).</param>
/// <param name="Total">Total candidates in this run.</param>
/// <param name="EventId">The event just processed.</param>
/// <param name="Outcome">What happened — see <see cref="ResultLoadOutcome"/>.</param>
/// <param name="Result">The persisted <see cref="EventResult"/> when <paramref name="Outcome"/> is <see cref="ResultLoadOutcome.Loaded"/>; otherwise null.</param>
public sealed record PullResultsProgress(
int Processed,
int Total,
DomainEventId EventId,
ResultLoadOutcome Outcome,
EventResult? Result);
/// <summary>What happened to a single candidate event during a results load.</summary>
public enum ResultLoadOutcome
{
/// <summary>A new <see cref="EventResult"/> was scraped and persisted.</summary>
Loaded,
/// <summary>The event already had a stored result — no work was done.</summary>
AlreadyLoaded,
/// <summary>The match isn't complete yet — try again later.</summary>
NotYetComplete,
/// <summary>The scrape failed (HTTP, parse, etc.). Logged at warning.</summary>
Failed,
}
/// <summary>
/// Loads completed-event results into the database.
/// </summary>
/// <remarks>
/// <para>
/// For each candidate event, the use case:
/// </para>
/// <list type="number">
/// <item>Skips it if a result is already stored (idempotent).</item>
/// <item>Calls <see cref="IOddsScraper.ScrapeEventResultAsync"/>, which returns
/// a non-null <see cref="EventResult"/> only when the bookmaker reports
/// <c>matchIsComplete=true</c>.</item>
/// <item>Persists the result and increments the loaded count.</item>
/// </list>
/// <para>
/// Candidates are either an explicit <paramref name="selection"/> list or — when
/// null/empty — every event scheduled in <c>range</c>.
/// </para>
/// </remarks>
public sealed class PullResultsUseCase
{
private readonly IOddsScraper _scraper;
private readonly IEventRepository _eventRepo;
private readonly IResultRepository _resultRepo;
private readonly ILogger<PullResultsUseCase> _logger;
public PullResultsUseCase(
IOddsScraper scraper,
IEventRepository eventRepo,
IResultRepository resultRepo,
ILogger<PullResultsUseCase> logger)
{
_scraper = scraper ?? throw new ArgumentNullException(nameof(scraper));
_eventRepo = eventRepo ?? throw new ArgumentNullException(nameof(eventRepo));
_resultRepo = resultRepo ?? throw new ArgumentNullException(nameof(resultRepo));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <summary>
/// Inspects events for completion and persists results.
/// </summary>
/// <param name="range">Date range used when <paramref name="selection"/> is null or empty.</param>
/// <param name="selection">
/// When non-empty, only these event IDs are inspected.
/// When null or empty, all events in <paramref name="range"/> without a stored
/// result are inspected.
/// </param>
/// <param name="progress">
/// Optional progress sink. Receives one update per candidate AFTER the scrape
/// has resolved. Suitable for binding to a UI progress indicator.
/// </param>
/// <param name="ct">Cancellation token.</param>
public async Task<(int Inspected, int ResultsLoaded, int Skipped)> ExecuteAsync(
DateRange range,
IReadOnlyList<DomainEventId>? selection,
IProgress<PullResultsProgress>? progress,
CancellationToken ct)
{
_logger.LogInformation(
"PullResultsUseCase: cycle started — range={From:O}..{To:O}, selection={SelectionCount}",
range.From, range.To, selection?.Count.ToString() ?? "all");
var candidates = await ResolveCandidatesAsync(range, selection, ct).ConfigureAwait(false);
int inspected = 0;
int resultsLoaded = 0;
int skipped = 0;
foreach (var ev in candidates)
{
ct.ThrowIfCancellationRequested();
inspected++;
var (outcome, persisted) = await ProcessOneAsync(ev, ct).ConfigureAwait(false);
switch (outcome)
{
case ResultLoadOutcome.Loaded: resultsLoaded++; break;
case ResultLoadOutcome.AlreadyLoaded: skipped++; break;
}
progress?.Report(new PullResultsProgress(
Processed: inspected,
Total: candidates.Count,
EventId: ev.Id,
Outcome: outcome,
Result: persisted));
}
_logger.LogInformation(
"PullResultsUseCase: cycle done — inspected={Inspected}, loaded={Loaded}, skipped={Skipped}",
inspected, resultsLoaded, skipped);
return (inspected, resultsLoaded, skipped);
}
/// <summary>Convenience overload without progress reporting (worker callers).</summary>
public Task<(int Inspected, int ResultsLoaded, int Skipped)> ExecuteAsync(
DateRange range,
IReadOnlyList<DomainEventId>? selection,
CancellationToken ct)
=> ExecuteAsync(range, selection, progress: null, ct);
private async Task<IReadOnlyList<Event>> ResolveCandidatesAsync(
DateRange range,
IReadOnlyList<DomainEventId>? selection,
CancellationToken ct)
{
if (selection is { Count: > 0 })
{
var resolved = new List<Event>(selection.Count);
foreach (var id in selection)
{
ct.ThrowIfCancellationRequested();
var ev = await _eventRepo.GetAsync(id, ct).ConfigureAwait(false);
if (ev is not null)
resolved.Add(ev);
}
return resolved;
}
return await _eventRepo.ListByDateRangeAsync(range, ct).ConfigureAwait(false);
}
private async Task<(ResultLoadOutcome Outcome, EventResult? Persisted)> ProcessOneAsync(
Event ev,
CancellationToken ct)
{
try
{
var existing = await _resultRepo.GetAsync(ev.Id, ct).ConfigureAwait(false);
if (existing is not null)
{
return (ResultLoadOutcome.AlreadyLoaded, null);
}
var scraped = await _scraper.ScrapeEventResultAsync(ev, ct).ConfigureAwait(false);
if (scraped is null)
{
return (ResultLoadOutcome.NotYetComplete, null);
}
await _resultRepo.AddAsync(scraped, ct).ConfigureAwait(false);
await _resultRepo.SaveChangesAsync(ct).ConfigureAwait(false);
return (ResultLoadOutcome.Loaded, scraped);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
_logger.LogWarning(ex,
"PullResultsUseCase: error processing event {EventId} — skipping",
ev.Id.Value);
return (ResultLoadOutcome.Failed, null);
}
}
}
@@ -0,0 +1,146 @@
using System.Collections.Concurrent;
using Marathon.Application.Abstractions;
using Marathon.Application.Configuration;
using Marathon.Domain.Entities;
using Marathon.Domain.Enums;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace Marathon.Application.UseCases;
/// <summary>
/// Fetches the current pre-match event listing, persists new events (skipping
/// duplicates by <see cref="Domain.ValueObjects.EventId"/>), and captures an
/// initial pre-match odds snapshot for every returned event.
/// </summary>
public sealed class PullUpcomingEventsUseCase
{
private readonly IOddsScraper _scraper;
private readonly IEventRepository _eventRepo;
private readonly ISnapshotRepository _snapshotRepo;
private readonly IOptionsMonitor<ScrapingThrottle> _throttle;
private readonly ILogger<PullUpcomingEventsUseCase> _logger;
public PullUpcomingEventsUseCase(
IOddsScraper scraper,
IEventRepository eventRepo,
ISnapshotRepository snapshotRepo,
IOptionsMonitor<ScrapingThrottle> throttle,
ILogger<PullUpcomingEventsUseCase> logger)
{
_scraper = scraper ?? throw new ArgumentNullException(nameof(scraper));
_eventRepo = eventRepo ?? throw new ArgumentNullException(nameof(eventRepo));
_snapshotRepo = snapshotRepo ?? throw new ArgumentNullException(nameof(snapshotRepo));
_throttle = throttle ?? throw new ArgumentNullException(nameof(throttle));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <summary>
/// Executes one polling cycle: scrape → persist new events → capture snapshots.
/// </summary>
/// <param name="ct">Cancellation token.</param>
/// <returns>
/// A tuple of <c>(EventsProcessed, NewEvents, SnapshotsCaptured)</c>.
/// <c>EventsProcessed</c> is the total number returned by the scraper.
/// <c>NewEvents</c> is how many were not already in the DB.
/// <c>SnapshotsCaptured</c> is how many snapshots were successfully saved.
/// </returns>
public async Task<(int EventsProcessed, int NewEvents, int SnapshotsCaptured)> ExecuteAsync(
CancellationToken ct)
{
_logger.LogInformation("PullUpcomingEventsUseCase: cycle started");
var events = await _scraper.ScrapeUpcomingAsync(sportFilter: null, ct);
int eventsProcessed = events.Count;
_logger.LogInformation(
"PullUpcomingEventsUseCase: scraper returned {Count} events",
eventsProcessed);
// Phase 1 — parallel HTTP fan-out. Each event's odds snapshot is scraped
// concurrently up to MaxConcurrentRequests; the scraper's rate limiter
// smooths spikes underneath. We do NOT touch the DbContext here — EF Core
// is single-threaded.
var scraped = new ConcurrentBag<(Event Event, OddsSnapshot Snapshot)>();
var maxParallelism = Math.Max(1, _throttle.CurrentValue.MaxConcurrentRequests);
var parallelOptions = new ParallelOptions
{
MaxDegreeOfParallelism = maxParallelism,
CancellationToken = ct,
};
await Parallel.ForEachAsync(events, parallelOptions, async (ev, taskCt) =>
{
try
{
var snapshot = await _scraper.ScrapeEventOddsAsync(ev, OddsSource.PreMatch, taskCt);
scraped.Add((ev, snapshot));
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
_logger.LogWarning(ex,
"PullUpcomingEventsUseCase: failed to capture snapshot for event {EventId} — skipping",
ev.Id.Value);
}
});
// Phase 2 — sequential persistence. Upsert event row, then save the
// captured snapshot. Per-event try/catch keeps a single failure from
// aborting the whole cycle.
int newEvents = 0;
int snapshotsCaptured = 0;
foreach (var (ev, snapshot) in scraped)
{
ct.ThrowIfCancellationRequested();
try
{
var existing = await _eventRepo.GetAsync(ev.Id, ct);
if (existing is null)
{
await _eventRepo.AddAsync(ev, ct);
await _eventRepo.SaveChangesAsync(ct);
newEvents++;
}
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
_logger.LogWarning(ex,
"PullUpcomingEventsUseCase: failed to persist event {EventId} — skipping",
ev.Id.Value);
}
try
{
await _snapshotRepo.AddAsync(snapshot, ct);
await _snapshotRepo.SaveChangesAsync(ct);
snapshotsCaptured++;
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
_logger.LogWarning(ex,
"PullUpcomingEventsUseCase: failed to persist snapshot for event {EventId} — skipping",
ev.Id.Value);
}
}
_logger.LogInformation(
"PullUpcomingEventsUseCase: cycle done — processed={Processed}, new={New}, snapshots={Snapshots}",
eventsProcessed, newEvents, snapshotsCaptured);
return (eventsProcessed, newEvents, snapshotsCaptured);
}
}
@@ -0,0 +1,258 @@
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 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: MoscowTime.Now,
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 : 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;
}
+37
View File
@@ -0,0 +1,37 @@
using Marathon.Domain.Enums;
using Marathon.Domain.ValueObjects;
namespace Marathon.Domain.Entities;
/// <summary>
/// A detected anomaly in odds behaviour for an event.
/// <c>Score</c> is a normalised confidence score in [0, 1] — higher means stronger signal.
/// <c>EvidenceJson</c> is a JSON string containing the raw evidence timeline (snapshots, diffs).
/// </summary>
public sealed record Anomaly(
Guid Id,
EventId EventId,
DateTimeOffset DetectedAt,
AnomalyKind Kind,
decimal Score,
string EvidenceJson)
{
public Guid Id { get; } = Id == Guid.Empty
? throw new ArgumentException("Anomaly Id must not be an empty GUID.", nameof(Id))
: Id;
public EventId EventId { get; } = EventId ?? throw new ArgumentNullException(nameof(EventId));
public DateTimeOffset DetectedAt { get; } = DetectedAt;
public AnomalyKind Kind { get; } = Kind;
public decimal Score { get; } = Score is >= 0m and <= 1m
? Score
: throw new ArgumentOutOfRangeException(nameof(Score), Score,
"Anomaly Score must be in the range [0, 1].");
public string EvidenceJson { get; } = string.IsNullOrWhiteSpace(EvidenceJson)
? throw new ArgumentException("EvidenceJson must not be empty.", nameof(EvidenceJson))
: EvidenceJson;
}
+82
View File
@@ -0,0 +1,82 @@
using Marathon.Domain.Enums;
using Marathon.Domain.ValueObjects;
namespace Marathon.Domain.Entities;
/// <summary>
/// A single betting option within an odds snapshot.
/// Invariants enforced in constructor:
/// <list type="bullet">
/// <item>Win: Side ∈ {Side1, Side2}, Value == null</item>
/// <item>Draw: Side == Draw, Value == null</item>
/// <item>WinFora: Side ∈ {Side1, Side2}, Value != null (handicap threshold)</item>
/// <item>Total: Side ∈ {Less, More}, Value != null (total threshold)</item>
/// </list>
/// </summary>
public sealed record Bet
{
public BetScope Scope { get; }
public BetType Type { get; }
public Side Side { get; }
public OddsValue? Value { get; }
public OddsRate Rate { get; }
public Bet(BetScope scope, BetType type, Side side, OddsValue? value, OddsRate rate)
{
ArgumentNullException.ThrowIfNull(scope);
ArgumentNullException.ThrowIfNull(rate);
ValidateInvariants(type, side, value);
Scope = scope;
Type = type;
Side = side;
Value = value;
Rate = rate;
}
private static void ValidateInvariants(BetType type, Side side, OddsValue? value)
{
switch (type)
{
case BetType.Win:
if (side is not (Side.Side1 or Side.Side2))
throw new ArgumentException(
$"Win bet requires Side1 or Side2. Got: {side}.", nameof(side));
if (value is not null)
throw new ArgumentException(
"Win bet must have Value == null.", nameof(value));
break;
case BetType.Draw:
if (side != Side.Draw)
throw new ArgumentException(
$"Draw bet requires Side == Draw. Got: {side}.", nameof(side));
if (value is not null)
throw new ArgumentException(
"Draw bet must have Value == null.", nameof(value));
break;
case BetType.WinFora:
if (side is not (Side.Side1 or Side.Side2))
throw new ArgumentException(
$"WinFora bet requires Side1 or Side2. Got: {side}.", nameof(side));
if (value is null)
throw new ArgumentException(
"WinFora bet requires a non-null handicap Value.", nameof(value));
break;
case BetType.Total:
if (side is not (Side.Less or Side.More))
throw new ArgumentException(
$"Total bet requires Side == Less or More. Got: {side}.", nameof(side));
if (value is null)
throw new ArgumentException(
"Total bet requires a non-null threshold Value.", nameof(value));
break;
default:
throw new ArgumentOutOfRangeException(nameof(type), type, "Unknown BetType.");
}
}
}
+20
View File
@@ -0,0 +1,20 @@
namespace Marathon.Domain.Entities;
/// <summary>
/// A country or geographic group associated with a league.
/// <c>Code</c> is the bookmaker's string identifier (e.g., breadcrumb text).
/// </summary>
public sealed record Country(string Code, string NameRu, string NameEn)
{
public string Code { get; } = string.IsNullOrWhiteSpace(Code)
? throw new ArgumentException("Country Code must not be empty.", nameof(Code))
: Code;
public string NameRu { get; } = string.IsNullOrWhiteSpace(NameRu)
? throw new ArgumentException("NameRu must not be empty.", nameof(NameRu))
: NameRu;
public string NameEn { get; } = string.IsNullOrWhiteSpace(NameEn)
? throw new ArgumentException("NameEn must not be empty.", nameof(NameEn))
: NameEn;
}
+66
View File
@@ -0,0 +1,66 @@
using Marathon.Domain.ValueObjects;
namespace Marathon.Domain.Entities;
/// <summary>
/// A sporting event that can be bet on.
/// </summary>
/// <remarks>
/// <para><c>ScheduledAt</c> is stored in Europe/Moscow time (UTC+3, no DST).
/// The offset <c>+03:00</c> is baked in — it is NOT converted to UTC.
/// This matches <c>initData.serverTime</c> from the scraped page, which is in Moscow time.
/// </para>
/// </remarks>
public sealed record Event(
EventId Id,
SportCode Sport,
string CountryCode,
string LeagueId,
string Category,
DateTimeOffset ScheduledAt,
string Side1Name,
string Side2Name)
{
public EventId Id { get; } = Id ?? throw new ArgumentNullException(nameof(Id));
public SportCode Sport { get; } = Sport ?? throw new ArgumentNullException(nameof(Sport));
public string CountryCode { get; } = string.IsNullOrWhiteSpace(CountryCode)
? throw new ArgumentException("CountryCode must not be empty.", nameof(CountryCode))
: CountryCode;
public string LeagueId { get; } = string.IsNullOrWhiteSpace(LeagueId)
? throw new ArgumentException("LeagueId must not be empty.", nameof(LeagueId))
: LeagueId;
public string Category { get; } = Category ?? string.Empty;
public DateTimeOffset ScheduledAt { get; } = ScheduledAt.Offset == MoscowTime.Offset
? ScheduledAt
: throw new ArgumentException(
$"ScheduledAt must be in Europe/Moscow time (UTC+03:00). " +
$"Received offset: {ScheduledAt.Offset:hh\\:mm}. " +
"Convert to Moscow time before constructing the Event.",
nameof(ScheduledAt));
public string Side1Name { get; } = string.IsNullOrWhiteSpace(Side1Name)
? throw new ArgumentException("Side1Name must not be empty.", nameof(Side1Name))
: Side1Name;
public string Side2Name { get; } = string.IsNullOrWhiteSpace(Side2Name)
? throw new ArgumentException("Side2Name must not be empty.", nameof(Side2Name))
: Side2Name;
/// <summary>
/// Bookmaker URL fragment used to fetch event-detail markets, sourced from the
/// listing page's <c>data-event-path</c> attribute (e.g.
/// <c>"Football/Clubs.+International/UEFA+Champions+League/.../Arsenal+vs+Chelsea+-+28089645"</c>).
/// Combined with <c>/su/betting/</c> by the scraper.
/// </summary>
/// <remarks>
/// Optional for backward compatibility with rows persisted before the column
/// was introduced. When null, the scraper falls back to the (less reliable)
/// numeric event ID.
/// </remarks>
public string? EventPath { get; init; }
}
@@ -0,0 +1,32 @@
using Marathon.Domain.Enums;
using Marathon.Domain.ValueObjects;
namespace Marathon.Domain.Entities;
/// <summary>
/// The final result of a sporting event after it has completed.
/// </summary>
public sealed record EventResult(
EventId EventId,
int Side1Score,
int Side2Score,
Side WinnerSide,
DateTimeOffset CompletedAt)
{
public EventId EventId { get; } = EventId ?? throw new ArgumentNullException(nameof(EventId));
public int Side1Score { get; } = Side1Score >= 0
? Side1Score
: throw new ArgumentOutOfRangeException(nameof(Side1Score), "Score must be non-negative.");
public int Side2Score { get; } = Side2Score >= 0
? Side2Score
: throw new ArgumentOutOfRangeException(nameof(Side2Score), "Score must be non-negative.");
public Side WinnerSide { get; } = WinnerSide is Side.Side1 or Side.Side2 or Side.Draw
? WinnerSide
: throw new ArgumentException(
$"WinnerSide must be Side1, Side2, or Draw. Got: {WinnerSide}.", nameof(WinnerSide));
public DateTimeOffset CompletedAt { get; } = CompletedAt;
}
+35
View File
@@ -0,0 +1,35 @@
using Marathon.Domain.ValueObjects;
namespace Marathon.Domain.Entities;
/// <summary>
/// A sports league or tournament within a country and sport.
/// </summary>
public sealed record League(
string Id,
SportCode Sport,
string Country,
string NameRu,
string NameEn,
string Category)
{
public string Id { get; } = string.IsNullOrWhiteSpace(Id)
? throw new ArgumentException("League Id must not be empty.", nameof(Id))
: Id;
public SportCode Sport { get; } = Sport ?? throw new ArgumentNullException(nameof(Sport));
public string Country { get; } = string.IsNullOrWhiteSpace(Country)
? throw new ArgumentException("Country must not be empty.", nameof(Country))
: Country;
public string NameRu { get; } = string.IsNullOrWhiteSpace(NameRu)
? throw new ArgumentException("NameRu must not be empty.", nameof(NameRu))
: NameRu;
public string NameEn { get; } = string.IsNullOrWhiteSpace(NameEn)
? throw new ArgumentException("NameEn must not be empty.", nameof(NameEn))
: NameEn;
public string Category { get; } = Category ?? string.Empty;
}
@@ -0,0 +1,34 @@
using Marathon.Domain.Enums;
using Marathon.Domain.ValueObjects;
namespace Marathon.Domain.Entities;
/// <summary>
/// A point-in-time capture of all odds for a specific event.
/// </summary>
public sealed record OddsSnapshot
{
public EventId EventId { get; }
public DateTimeOffset CapturedAt { get; }
public OddsSource Source { get; }
public IReadOnlyList<Bet> Bets { get; }
public OddsSnapshot(
EventId eventId,
DateTimeOffset capturedAt,
OddsSource source,
IReadOnlyList<Bet> bets)
{
ArgumentNullException.ThrowIfNull(eventId);
ArgumentNullException.ThrowIfNull(bets);
if (bets.Count == 0)
throw new ArgumentException(
"OddsSnapshot must contain at least one Bet.", nameof(bets));
EventId = eventId;
CapturedAt = capturedAt;
Source = source;
Bets = bets;
}
}
+19
View File
@@ -0,0 +1,19 @@
using Marathon.Domain.ValueObjects;
namespace Marathon.Domain.Entities;
/// <summary>
/// A sport supported by the bookmaker.
/// </summary>
public sealed record Sport(SportCode Code, string NameRu, string NameEn)
{
public SportCode Code { get; } = Code ?? throw new ArgumentNullException(nameof(Code));
public string NameRu { get; } = string.IsNullOrWhiteSpace(NameRu)
? throw new ArgumentException("NameRu must not be empty.", nameof(NameRu))
: NameRu;
public string NameEn { get; } = string.IsNullOrWhiteSpace(NameEn)
? throw new ArgumentException("NameEn must not be empty.", nameof(NameEn))
: NameEn;
}
+13
View File
@@ -0,0 +1,13 @@
namespace Marathon.Domain.Enums;
/// <summary>
/// The category of a detected anomaly.
/// Extensible — new kinds will be added in future phases.
/// </summary>
public enum AnomalyKind
{
/// <summary>
/// Bookmaker suspended the market, then flipped the underdog/favourite coefficients.
/// </summary>
SuspensionFlip,
}
+16
View File
@@ -0,0 +1,16 @@
namespace Marathon.Domain.Enums;
/// <summary>
/// The type of a bet.
/// Win — match or period winner (Side1 or Side2; no draw).
/// Draw — match or period draw (Side = Draw).
/// WinFora — handicap / fora bet (Side1 or Side2; Value = handicap threshold).
/// Total — total goals/points/games bet (Side = Less or More; Value = threshold).
/// </summary>
public enum BetType
{
Win,
Draw,
WinFora,
Total,
}
+10
View File
@@ -0,0 +1,10 @@
namespace Marathon.Domain.Enums;
/// <summary>
/// Indicates whether an odds snapshot was captured from the pre-match or live section.
/// </summary>
public enum OddsSource
{
PreMatch,
Live,
}
+16
View File
@@ -0,0 +1,16 @@
namespace Marathon.Domain.Enums;
/// <summary>
/// Vocabulary-agnostic representation of a bet side.
/// Side1/Side2 map to home/away for win-type bets.
/// Draw applies only to win-type markets where a draw is possible.
/// Less/More apply to total-type bets.
/// </summary>
public enum Side
{
Side1,
Side2,
Draw,
Less,
More,
}
@@ -0,0 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
</PropertyGroup>
</Project>
@@ -0,0 +1,28 @@
namespace Marathon.Domain.ValueObjects;
/// <summary>
/// Discriminated union representing the scope of a bet: the full match or a specific period.
/// Use pattern matching to distinguish:
/// switch (scope) { case MatchScope: ... case PeriodScope(var n): ... }
/// </summary>
public abstract record BetScope
{
// Private constructor prevents external derivation outside this assembly.
private protected BetScope() { }
}
/// <summary>Bet applies to the entire match.</summary>
public sealed record MatchScope : BetScope
{
/// <summary>Singleton instance — MatchScope carries no additional data.</summary>
public static readonly MatchScope Instance = new();
}
/// <summary>Bet applies to a specific period (1-based). No max enforced — sport-dependent.</summary>
public sealed record PeriodScope(int Number) : BetScope
{
public int Number { get; } = Number > 0
? Number
: throw new ArgumentOutOfRangeException(nameof(Number), Number,
"Period number must be greater than zero.");
}
@@ -0,0 +1,21 @@
namespace Marathon.Domain.ValueObjects;
/// <summary>
/// The bookmaker's stable event identifier — corresponds to <c>data-event-eventId</c>.
/// Modelled as a string for forward compatibility with non-numeric IDs from other bookmakers.
/// For marathonbet.by this is a numeric string in the ~26-million range (e.g., "26456117").
/// </summary>
public sealed record EventId
{
public string Value { get; }
public EventId(string value)
{
if (string.IsNullOrWhiteSpace(value))
throw new ArgumentException("EventId must not be empty or whitespace.", nameof(value));
Value = value;
}
public override string ToString() => Value;
}
@@ -0,0 +1,32 @@
namespace Marathon.Domain.ValueObjects;
/// <summary>
/// Single source of truth for the Moscow timezone offset.
/// </summary>
/// <remarks>
/// <para>
/// marathonbet.by serves all timestamps in Moscow time (UTC+3) and the domain
/// invariant on <see cref="Marathon.Domain.Entities.Event.ScheduledAt"/>
/// rejects any other offset. Code that constructs <see cref="DateTimeOffset"/>
/// values for events, results, snapshots, or test fixtures MUST use this
/// constant rather than re-deriving <c>TimeSpan.FromHours(3)</c>.
/// </para>
/// </remarks>
public static class MoscowTime
{
/// <summary>The Moscow time offset (UTC+3).</summary>
public static readonly TimeSpan Offset = TimeSpan.FromHours(3);
/// <summary>Current Moscow time.</summary>
public static DateTimeOffset Now => DateTimeOffset.UtcNow.ToOffset(Offset);
/// <summary>
/// Returns the inclusive end-of-day for the given Moscow date — i.e.,
/// the moment one second before the next day starts. Used by date-range
/// filters where the user picks "to: 2026-05-09" meaning "through the
/// rest of that day."
/// </summary>
public static DateTimeOffset EndOfMoscowDay(DateOnly date) =>
new DateTimeOffset(date.ToDateTime(TimeOnly.MinValue), Offset)
.AddDays(1).AddSeconds(-1);
}
@@ -0,0 +1,21 @@
namespace Marathon.Domain.ValueObjects;
/// <summary>
/// Decimal odds for a bet (e.g., 1.65, 2.10).
/// Must be strictly greater than 1.0 — odds of 1.0 or less are mathematically invalid.
/// </summary>
public sealed record OddsRate
{
public decimal Value { get; }
public OddsRate(decimal value)
{
if (value <= 1.0m)
throw new ArgumentOutOfRangeException(nameof(value), value,
"OddsRate must be greater than 1.0. Odds of 1.0 or less are invalid.");
Value = value;
}
public override string ToString() => Value.ToString("G");
}
@@ -0,0 +1,23 @@
namespace Marathon.Domain.ValueObjects;
/// <summary>
/// The threshold value for a handicap (fora) or total bet.
/// Handicaps can be negative (e.g., -1.5 for the favourite).
/// Totals are positive and non-zero (e.g., 3.5, 213.5).
/// Zero is excluded as it has no meaningful betting interpretation.
/// </summary>
public sealed record OddsValue
{
public decimal Value { get; }
public OddsValue(decimal value)
{
if (value == 0m)
throw new ArgumentOutOfRangeException(nameof(value), value,
"OddsValue must not be zero. Use a non-zero handicap or total threshold.");
Value = value;
}
public override string ToString() => Value.ToString("G");
}
@@ -0,0 +1,21 @@
namespace Marathon.Domain.ValueObjects;
/// <summary>
/// Canonical sport identifier — corresponds to <c>data-sport-treeId</c> in marathonbet.by breadcrumbs.
/// Known values: Basketball=6, Football=11, Tennis=22723, Hockey=43658.
/// </summary>
public sealed record SportCode
{
public int Value { get; }
public SportCode(int value)
{
if (value <= 0)
throw new ArgumentOutOfRangeException(nameof(value), value,
"SportCode must be greater than zero.");
Value = value;
}
public override string ToString() => Value.ToString();
}
+8
View File
@@ -0,0 +1,8 @@
<Application x:Class="Marathon.Hosts.WpfBlazor.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:Marathon.Hosts.WpfBlazor"
ShutdownMode="OnMainWindowClose">
<Application.Resources>
</Application.Resources>
</Application>
+152
View File
@@ -0,0 +1,152 @@
using System.Globalization;
using System.IO;
using System.Windows;
using Marathon.Application;
using Marathon.Infrastructure;
using Marathon.UI.Services;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Options;
using Serilog;
namespace Marathon.Hosts.WpfBlazor;
/// <summary>
/// WPF application entry-point. Builds an <see cref="IHost"/> with Serilog,
/// configuration (appsettings.json + Local + env vars), and the Marathon UI
/// service collection. Composes Application + Infrastructure modules
/// optionally — those module entry points may not yet exist while parallel
/// Phase 2/3/4 work merges.
/// </summary>
public partial class App : System.Windows.Application
{
public IHost? Host { get; private set; }
/// <summary>
/// Absolute path to the Local override settings file. Resolved from the
/// host's content root (the directory containing <c>appsettings.json</c>).
/// </summary>
public static string SettingsLocalFileName => "appsettings.Local.json";
protected override void OnStartup(StartupEventArgs e)
{
base.OnStartup(e);
// Bootstrap default culture (ru-RU) before any DI / hosting / rendering
// begins. This ensures background-service threads spawned by Host.Start()
// and the BlazorWebView dispatcher inherit ru-RU even before the
// configured DefaultCulture is read from settings. The configured value
// is re-applied below; system locale (e.g., en-US) never wins.
var bootstrap = CultureInfo.GetCultureInfo(LocaleState.Russian);
CultureInfo.DefaultThreadCurrentCulture = bootstrap;
CultureInfo.DefaultThreadCurrentUICulture = bootstrap;
CultureInfo.CurrentCulture = bootstrap;
CultureInfo.CurrentUICulture = bootstrap;
var contentRoot = AppContext.BaseDirectory;
var localSettingsPath = Path.Combine(contentRoot, SettingsLocalFileName);
var builder = Microsoft.Extensions.Hosting.Host.CreateApplicationBuilder();
builder.Environment.ContentRootPath = contentRoot;
builder.Configuration
.SetBasePath(contentRoot)
.AddJsonFile("appsettings.json", optional: false, reloadOnChange: true)
.AddJsonFile($"appsettings.{builder.Environment.EnvironmentName}.json", optional: true, reloadOnChange: true)
.AddJsonFile(SettingsLocalFileName, optional: true, reloadOnChange: true)
.AddEnvironmentVariables(prefix: "MARATHON_");
// Serilog — structured rolling-file + console.
// Minimum level honours the "Serilog:MinimumLevel:Default" key when
// present in configuration; otherwise defaults to Information.
var logsDir = Path.Combine(contentRoot, "logs");
Directory.CreateDirectory(logsDir);
var minimumLevel = ParseMinimumLevel(builder.Configuration["Serilog:MinimumLevel:Default"]);
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Is(minimumLevel)
.Enrich.FromLogContext()
.WriteTo.Console()
.WriteTo.File(
path: Path.Combine(logsDir, "marathon-.log"),
rollingInterval: RollingInterval.Day,
retainedFileCountLimit: 14,
shared: true)
.CreateLogger();
builder.Services.AddSerilog();
// Marathon.UI services (Mud, localization, options, theme/locale state, settings writer).
builder.Services.AddMarathonUi(builder.Configuration, localSettingsPath);
// Blazor WebView root services.
builder.Services.AddWpfBlazorWebView();
#if DEBUG
builder.Services.AddBlazorWebViewDeveloperTools();
#endif
// Application use cases + Infrastructure (persistence, scraping, workers).
builder.Services.AddMarathonApplication();
builder.Services.AddMarathonInfrastructure(builder.Configuration);
// MainWindow needs the IServiceProvider for BlazorWebView.Services binding.
builder.Services.AddSingleton<MainWindow>();
Host = builder.Build();
// Apply EF migrations + WAL pragma BEFORE Host.Start() so the BackgroundServices
// (LiveOddsPoller, AnomalyDetectionPoller, etc.) don't race the DB schema creation.
// Resolved in a scope because MarathonDbContextInitializer is Scoped (DbContext lifetime).
using (var initScope = Host.Services.CreateScope())
{
var initializer = initScope.ServiceProvider
.GetRequiredService<Marathon.Infrastructure.Persistence.MarathonDbContextInitializer>();
initializer.InitializeAsync().GetAwaiter().GetResult();
}
// Apply default culture from configuration BEFORE Host.Start() so the
// BackgroundServices (LiveOddsPoller, AnomalyDetectionPoller, ...) and
// any threads they spawn inherit the configured locale via
// CultureInfo.DefaultThreadCurrent{,UI}Culture rather than the system
// default (which would surface as English on en-US Windows installs).
var localeOptions = Host.Services.GetRequiredService<IOptions<LocalizationOptions>>().Value;
var locale = Host.Services.GetRequiredService<LocaleState>();
try
{
locale.Set(localeOptions.DefaultCulture);
}
catch (CultureNotFoundException)
{
locale.Set(LocaleState.Russian);
}
Host.Start();
var window = Host.Services.GetRequiredService<MainWindow>();
window.Show();
}
private static Serilog.Events.LogEventLevel ParseMinimumLevel(string? raw) =>
Enum.TryParse<Serilog.Events.LogEventLevel>(raw, ignoreCase: true, out var level)
? level
: Serilog.Events.LogEventLevel.Information;
protected override void OnExit(ExitEventArgs e)
{
try
{
Host?.StopAsync(TimeSpan.FromSeconds(5)).GetAwaiter().GetResult();
}
catch (Exception ex)
{
Log.Error(ex, "Host shutdown failed");
}
finally
{
Host?.Dispose();
Log.CloseAndFlush();
base.OnExit(e);
}
}
}
@@ -0,0 +1,10 @@
using System.Windows;
[assembly:ThemeInfo(
ResourceDictionaryLocation.None, //where theme specific resource dictionaries are located
//(used if a resource is not found in the page,
// or application resource dictionaries)
ResourceDictionaryLocation.SourceAssembly //where the generic resource dictionary is located
//(used if a resource is not found in the page,
// app, or any theme specific resource dictionaries)
)]
@@ -0,0 +1,22 @@
<Window x:Class="Marathon.Hosts.WpfBlazor.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:wv="clr-namespace:Microsoft.AspNetCore.Components.WebView.Wpf;assembly=Microsoft.AspNetCore.Components.WebView.Wpf"
xmlns:ui="clr-namespace:Marathon.UI;assembly=Marathon.UI"
xmlns:b="clr-namespace:Microsoft.AspNetCore.Components.Web;assembly=Microsoft.AspNetCore.Components.Web"
Title="Marathon Odds Lab"
Height="900"
Width="1440"
MinHeight="640"
MinWidth="960"
Background="#0c0a09"
WindowStartupLocation="CenterScreen">
<Grid>
<wv:BlazorWebView x:Name="BlazorWebView"
HostPage="wwwroot/index.html">
<wv:BlazorWebView.RootComponents>
<wv:RootComponent Selector="#app" ComponentType="{x:Type ui:App}" />
</wv:BlazorWebView.RootComponents>
</wv:BlazorWebView>
</Grid>
</Window>
@@ -0,0 +1,18 @@
using System.Windows;
namespace Marathon.Hosts.WpfBlazor;
/// <summary>
/// Hosts the BlazorWebView that renders <see cref="Marathon.UI.App"/>.
/// All UI lives in the Razor Class Library — this window is intentionally
/// thin so a future ASP.NET Core Blazor Server host can swap in trivially.
/// </summary>
public partial class MainWindow : Window
{
public MainWindow(IServiceProvider services)
{
ArgumentNullException.ThrowIfNull(services);
InitializeComponent();
BlazorWebView.Services = services;
}
}
@@ -0,0 +1,47 @@
<Project Sdk="Microsoft.NET.Sdk.Razor">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net8.0-windows</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UseWPF>true</UseWPF>
<UseWindowsForms>false</UseWindowsForms>
<RootNamespace>Marathon.Hosts.WpfBlazor</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Components.WebView.Wpf" />
<PackageReference Include="MudBlazor" />
<PackageReference Include="Microsoft.Extensions.Hosting" />
<PackageReference Include="Microsoft.Extensions.Configuration" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" />
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" />
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
<PackageReference Include="Microsoft.Extensions.Logging" />
<PackageReference Include="Microsoft.Extensions.Localization" />
<PackageReference Include="Microsoft.Extensions.Options" />
<PackageReference Include="Serilog" />
<PackageReference Include="Serilog.Extensions.Hosting" />
<PackageReference Include="Serilog.Sinks.File" />
<PackageReference Include="Serilog.Sinks.Console" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Marathon.UI\Marathon.UI.csproj" />
<ProjectReference Include="..\Marathon.Application\Marathon.Application.csproj" />
<ProjectReference Include="..\Marathon.Infrastructure\Marathon.Infrastructure.csproj" />
</ItemGroup>
<ItemGroup>
<None Update="appsettings.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="appsettings.Development.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<DependentUpon>appsettings.json</DependentUpon>
</None>
</ItemGroup>
</Project>
@@ -0,0 +1,12 @@
{
"Serilog": {
"MinimumLevel": {
"Default": "Debug",
"Override": {
"Microsoft": "Information",
"Microsoft.AspNetCore": "Information",
"System": "Information"
}
}
}
}
@@ -0,0 +1,59 @@
{
"Scraping": {
"PollingIntervalSeconds": 30,
"MaxConcurrentRequests": 4,
"UserAgents": [
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Safari/605.1.15",
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"
],
"RetryPolicy": {
"MaxAttempts": 3,
"BaseDelayMs": 500
},
"RateLimit": {
"RequestsPerSecond": 1
},
"UsePlaywright": false,
"BaseUrl": "https://www.marathonbet.by",
"RequestTimeoutSeconds": 30
},
"Workers": {
"UpcomingScheduleCron": "0 0 */6 * * *",
"LivePollerEnabled": true,
"UpcomingPollerEnabled": true,
"LivePollIntervalSeconds": 30,
"ResultsPollIntervalSeconds": 300,
"ResultsPollerEnabled": false,
"AnomalyDetectionEnabled": true
},
"Sports": {
"Basketball": {
"QuarterMode": false
}
},
"Storage": {
"DatabasePath": "./data/marathon.db",
"ExportDirectory": "./exports",
"SnapshotRetentionDays": 90
},
"Anomaly": {
"SuspensionGapSeconds": 60,
"OddsFlipThreshold": 0.30,
"MinSnapshotCount": 3,
"DetectionIntervalSeconds": 60
},
"Localization": {
"DefaultCulture": "ru-RU"
},
"Serilog": {
"MinimumLevel": {
"Default": "Information",
"Override": {
"Microsoft": "Warning",
"Microsoft.Hosting.Lifetime": "Information",
"System": "Warning"
}
}
}
}
@@ -0,0 +1,48 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<title>Marathon — Odds Lab</title>
<base href="/" />
<!-- Prevent flash of unthemed content -->
<style>
html, body { background: #f5f4ef; margin: 0; }
@media (prefers-color-scheme: dark) {
html, body { background: #0c0a09; }
}
</style>
<!-- Fonts: IBM Plex Sans / Serif / Mono + JetBrains Mono. Full Cyrillic coverage. -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@300;400;500;600;700&family=IBM+Plex+Serif:wght@300;400;500;600&family=IBM+Plex+Mono:wght@400;500&family=JetBrains+Mono:wght@400;500;600&display=swap"
rel="stylesheet" />
<link href="_content/MudBlazor/MudBlazor.min.css" rel="stylesheet" />
<link href="_content/Marathon.UI/app.css" rel="stylesheet" />
</head>
<body>
<div id="app">
<div style="padding: 64px; font-family: 'IBM Plex Serif', Georgia, serif; color: #475569;">
<span style="font-family: 'JetBrains Mono', monospace; font-size: 11px; letter-spacing: 0.18em; text-transform: uppercase; color: #d97706;">Booting</span>
<div style="font-size: 32px; font-weight: 300; margin-top: 8px;">Marathon Odds Lab</div>
</div>
</div>
<div id="blazor-error-ui" style="display:none; position: fixed; bottom: 0; left: 0; right: 0; z-index: 1000; padding: 12px 24px; background: #dc2626; color: #fafaf7; font-family: 'IBM Plex Sans', sans-serif;">
<span>An unhandled error has occurred.</span>
<a href="" class="reload" style="color: #fff; text-decoration: underline; margin-left: 12px;">Reload</a>
<a class="dismiss" style="float: right; cursor: pointer; padding: 0 8px;">×</a>
</div>
<!-- Plotly.js for OddsTimeline charts (Phase 6). Bundled by Plotly.Blazor 5.4.1. -->
<script src="_content/Plotly.Blazor/plotly-2.35.3.min.js" charset="utf-8"></script>
<!-- Blazor must auto-start in BlazorWebView; no autostart="false". -->
<script src="_framework/blazor.webview.js"></script>
<script src="_content/MudBlazor/MudBlazor.min.js"></script>
</body>
</html>
@@ -0,0 +1,57 @@
namespace Marathon.Infrastructure.Configuration;
/// <summary>
/// Strongly typed options for the scraping pipeline.
/// Bound from the <c>Scraping</c> section of <c>appsettings.json</c>.
/// </summary>
public sealed class ScrapingOptions
{
/// <summary>How often pre-match event listings are refreshed, in seconds.</summary>
public int PollingIntervalSeconds { get; init; } = 30;
/// <summary>Maximum number of concurrent HTTP requests to the bookmaker.</summary>
public int MaxConcurrentRequests { get; init; } = 4;
/// <summary>
/// Pool of browser User-Agent strings to rotate per request.
/// If empty, the default HttpClient UA is used.
/// </summary>
public string[] UserAgents { get; init; } = Array.Empty<string>();
/// <summary>Retry policy configuration.</summary>
public RetryPolicyOptions RetryPolicy { get; init; } = new();
/// <summary>Token-bucket rate limiting configuration.</summary>
public RateLimitOptions RateLimit { get; init; } = new();
/// <summary>
/// Reserved flag for Playwright-based scraping fallback.
/// Default <c>false</c> — HttpClient + AngleSharp is used exclusively.
/// Flip to <c>true</c> when the site starts serving JS challenges.
/// Playwright integration is NOT implemented in Phase 3.
/// </summary>
public bool UsePlaywright { get; init; } = false;
/// <summary>Base URL of the bookmaker site.</summary>
public string BaseUrl { get; init; } = "https://www.marathonbet.by";
/// <summary>Per-request HTTP timeout, in seconds.</summary>
public int RequestTimeoutSeconds { get; init; } = 30;
}
/// <summary>Options for the Polly retry policy.</summary>
public sealed class RetryPolicyOptions
{
/// <summary>Maximum number of retry attempts (not counting the initial call).</summary>
public int MaxAttempts { get; init; } = 3;
/// <summary>Base delay for exponential back-off, in milliseconds.</summary>
public int BaseDelayMs { get; init; } = 500;
}
/// <summary>Options for the per-host rate limiter.</summary>
public sealed class RateLimitOptions
{
/// <summary>Maximum sustained request rate per second.</summary>
public int RequestsPerSecond { get; init; } = 1;
}
@@ -0,0 +1,50 @@
namespace Marathon.Infrastructure.Configuration;
/// <summary>
/// Strongly typed options for the background worker pollers.
/// Bound from the <c>Workers</c> section of <c>appsettings.json</c>.
/// </summary>
public sealed class WorkerOptions
{
public const string SectionName = "Workers";
/// <summary>
/// Cron expression (6-field with seconds: s m h d M dow) controlling when the
/// upcoming-events poller fires. Default: every 6 hours.
/// </summary>
public string UpcomingScheduleCron { get; init; } = "0 0 */6 * * *";
/// <summary>Whether the live odds poller should run at startup.</summary>
public bool LivePollerEnabled { get; init; } = true;
/// <summary>Whether the upcoming/pre-match poller should run at startup.</summary>
public bool UpcomingPollerEnabled { get; init; } = true;
/// <summary>
/// How long the live odds poller sleeps between polling cycles, in seconds.
/// Default: 30 s (matches <c>Scraping:PollingIntervalSeconds</c> but is
/// independently configurable here).
/// </summary>
public int LivePollIntervalSeconds { get; init; } = 30;
/// <summary>
/// How long the results watch-list poller sleeps between cycles, in seconds.
/// Default: 300 s (5 minutes).
/// </summary>
public int ResultsPollIntervalSeconds { get; init; } = 300;
/// <summary>
/// Whether the results watch-list poller is enabled.
/// Default: <c>false</c> — the poller infrastructure ships in Phase 4 but
/// the formal watch-list implementation lands in Phase 8.
/// 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;
}
@@ -0,0 +1,129 @@
using Marathon.Domain.Entities;
using Marathon.Domain.Enums;
using Marathon.Domain.ValueObjects;
namespace Marathon.Infrastructure.Export;
/// <summary>
/// Converts a list of <see cref="Bet"/> objects for one event into a flat dictionary
/// keyed by customer-spec column names in canonical order.
/// The prefix is either <c>Bet_</c> (pre-match) or <c>Live_</c> (live snapshots).
/// </summary>
internal static class BetRowDenormalizer
{
/// <summary>
/// Produces the column key dictionary for a single snapshot's bets.
/// </summary>
/// <param name="bets">All bets in one <see cref="OddsSnapshot"/>.</param>
/// <param name="prefix"><c>"Bet_"</c> or <c>"Live_"</c>.</param>
/// <param name="maxPeriods">
/// Maximum period number to generate columns for; columns are generated for periods
/// 1 through <paramref name="maxPeriods"/> even if some bets are absent (null cells).
/// </param>
/// <returns>
/// Ordered dictionary (insertion order preserved) from column name to nullable value.
/// </returns>
public static Dictionary<string, object?> Denormalize(
IReadOnlyList<Bet> bets,
string prefix,
int maxPeriods)
{
var row = new Dictionary<string, object?>(StringComparer.Ordinal);
// Match-scope bets
AppendMatchBets(row, bets, prefix);
// Period-scope bets
for (var n = 1; n <= maxPeriods; n++)
AppendPeriodBets(row, bets, prefix, n);
return row;
}
/// <summary>
/// Returns the maximum period number found across all bets in a set of snapshots.
/// Returns 0 if no period-scoped bets exist.
/// </summary>
public static int MaxPeriods(IEnumerable<IReadOnlyList<Bet>> allBetLists)
{
var max = 0;
foreach (var bets in allBetLists)
{
foreach (var bet in bets)
{
if (bet.Scope is PeriodScope ps && ps.Number > max)
max = ps.Number;
}
}
return max;
}
private static void AppendMatchBets(Dictionary<string, object?> row, IReadOnlyList<Bet> bets, string prefix)
{
row[$"{prefix}Match_Win_1"] = FindRate(bets, MatchScope.Instance, BetType.Win, Side.Side1);
row[$"{prefix}Match_Draw"] = FindRate(bets, MatchScope.Instance, BetType.Draw, Side.Draw);
row[$"{prefix}Match_Win_2"] = FindRate(bets, MatchScope.Instance, BetType.Win, Side.Side2);
row[$"{prefix}Match_Win_Fora_1_Value"] = FindValue(bets, MatchScope.Instance, BetType.WinFora, Side.Side1);
row[$"{prefix}Match_Win_Fora_1_Rate"] = FindRate(bets, MatchScope.Instance, BetType.WinFora, Side.Side1);
row[$"{prefix}Match_Win_Fora_2_Value"] = FindValue(bets, MatchScope.Instance, BetType.WinFora, Side.Side2);
row[$"{prefix}Match_Win_Fora_2_Rate"] = FindRate(bets, MatchScope.Instance, BetType.WinFora, Side.Side2);
row[$"{prefix}Match_Total_Less_Value"] = FindValue(bets, MatchScope.Instance, BetType.Total, Side.Less);
row[$"{prefix}Match_Total_Less_Rate"] = FindRate(bets, MatchScope.Instance, BetType.Total, Side.Less);
row[$"{prefix}Match_Total_More_Value"] = FindValue(bets, MatchScope.Instance, BetType.Total, Side.More);
row[$"{prefix}Match_Total_More_Rate"] = FindRate(bets, MatchScope.Instance, BetType.Total, Side.More);
}
private static void AppendPeriodBets(
Dictionary<string, object?> row,
IReadOnlyList<Bet> bets,
string prefix,
int periodNumber)
{
var p = $"Period-{periodNumber}";
row[$"{prefix}{p}_Win_1"] = FindRatePeriod(bets, periodNumber, BetType.Win, Side.Side1);
row[$"{prefix}{p}_Draw"] = FindRatePeriod(bets, periodNumber, BetType.Draw, Side.Draw);
row[$"{prefix}{p}_Win_2"] = FindRatePeriod(bets, periodNumber, BetType.Win, Side.Side2);
row[$"{prefix}{p}_Win_Fora_1_Value"] = FindValuePeriod(bets, periodNumber, BetType.WinFora, Side.Side1);
row[$"{prefix}{p}_Win_Fora_1_Rate"] = FindRatePeriod(bets, periodNumber, BetType.WinFora, Side.Side1);
row[$"{prefix}{p}_Win_Fora_2_Value"] = FindValuePeriod(bets, periodNumber, BetType.WinFora, Side.Side2);
row[$"{prefix}{p}_Win_Fora_2_Rate"] = FindRatePeriod(bets, periodNumber, BetType.WinFora, Side.Side2);
row[$"{prefix}{p}_Total_Less_Value"] = FindValuePeriod(bets, periodNumber, BetType.Total, Side.Less);
row[$"{prefix}{p}_Total_Less_Rate"] = FindRatePeriod(bets, periodNumber, BetType.Total, Side.Less);
row[$"{prefix}{p}_Total_More_Value"] = FindValuePeriod(bets, periodNumber, BetType.Total, Side.More);
row[$"{prefix}{p}_Total_More_Rate"] = FindRatePeriod(bets, periodNumber, BetType.Total, Side.More);
}
// ── Match-scope finders ──────────────────────────────────────────────────
private static object? FindRate(IReadOnlyList<Bet> bets, BetScope scope, BetType type, Side side)
{
var bet = bets.FirstOrDefault(b => ScopeEquals(b.Scope, scope) && b.Type == type && b.Side == side);
return bet is null ? null : (object?)bet.Rate.Value;
}
private static object? FindValue(IReadOnlyList<Bet> bets, BetScope scope, BetType type, Side side)
{
var bet = bets.FirstOrDefault(b => ScopeEquals(b.Scope, scope) && b.Type == type && b.Side == side);
return bet?.Value?.Value;
}
// ── Period-scope finders ─────────────────────────────────────────────────
private static object? FindRatePeriod(IReadOnlyList<Bet> bets, int period, BetType type, Side side)
{
var bet = bets.FirstOrDefault(b =>
b.Scope is PeriodScope ps && ps.Number == period && b.Type == type && b.Side == side);
return bet is null ? null : (object?)bet.Rate.Value;
}
private static object? FindValuePeriod(IReadOnlyList<Bet> bets, int period, BetType type, Side side)
{
var bet = bets.FirstOrDefault(b =>
b.Scope is PeriodScope ps && ps.Number == period && b.Type == type && b.Side == side);
return bet?.Value?.Value;
}
private static bool ScopeEquals(BetScope a, BetScope b) =>
(a is MatchScope && b is MatchScope) ||
(a is PeriodScope pa && b is PeriodScope pb && pa.Number == pb.Number);
}
@@ -0,0 +1,239 @@
using System.Globalization;
using ClosedXML.Excel;
using Marathon.Application.Abstractions;
using Marathon.Application.Storage;
using Marathon.Domain.Entities;
using Marathon.Domain.Enums;
using Microsoft.EntityFrameworkCore;
using Marathon.Infrastructure.Persistence;
namespace Marathon.Infrastructure.Export;
/// <summary>
/// Exports odds snapshots to an Excel file matching the customer's wide-column specification.
/// </summary>
internal sealed class ExcelExporter : IExcelExporter
{
private readonly MarathonDbContext _db;
public ExcelExporter(MarathonDbContext db) => _db = db;
/// <inheritdoc/>
public async Task<string> ExportAsync(
DateRange range,
ExportKind kind,
string outputPath,
CancellationToken ct = default)
{
// Load all snapshots in the date range with their bets eagerly
var fromStr = range.From.ToString("O");
var toStr = range.To.ToString("O");
var snapshotEntities = await _db.Snapshots.AsNoTracking()
.Include(s => s.Bets)
.Include(s => s.Event)
.Where(s => s.CapturedAt.CompareTo(fromStr) >= 0
&& s.CapturedAt.CompareTo(toStr) <= 0)
.ToListAsync(ct);
// Convert to domain objects for processing
var allSnapshots = snapshotEntities
.Select(e => (
Snapshot: Mapping.ToDomain(e),
Event: Mapping.ToDomain(e.Event)))
.ToList();
// Determine max periods across all relevant snapshots
var relevantBetLists = allSnapshots
.Where(x => IsRelevant(x.Snapshot.Source, kind))
.Select(x => x.Snapshot.Bets)
.ToList();
var maxPeriods = BetRowDenormalizer.MaxPeriods(relevantBetLists);
// Build filename
var fileName = string.Format(
CultureInfo.InvariantCulture,
"Marathon_{0:yyyy-MM-dd}_to_{1:yyyy-MM-dd}.xlsx",
range.From,
range.To);
Directory.CreateDirectory(outputPath);
var fullPath = Path.Combine(outputPath, fileName);
using var workbook = new XLWorkbook();
if (kind == ExportKind.PreMatch || kind == ExportKind.Combined)
{
var preMatchData = allSnapshots
.Where(x => x.Snapshot.Source == OddsSource.PreMatch)
.ToList();
WriteSheet(workbook, "PreMatch", preMatchData, "Bet_", maxPeriods);
}
if (kind == ExportKind.Live || kind == ExportKind.Combined)
{
var liveData = allSnapshots
.Where(x => x.Snapshot.Source == OddsSource.Live)
.ToList();
WriteSheet(workbook, "Live", liveData, "Live_", maxPeriods);
}
workbook.SaveAs(fullPath);
return fullPath;
}
private static bool IsRelevant(OddsSource source, ExportKind kind) =>
kind switch
{
ExportKind.PreMatch => source == OddsSource.PreMatch,
ExportKind.Live => source == OddsSource.Live,
ExportKind.Combined => true,
_ => false,
};
private static void WriteSheet(
IXLWorkbook workbook,
string sheetName,
IReadOnlyList<(OddsSnapshot Snapshot, Event Event)> rows,
string prefix,
int maxPeriods)
{
var sheet = workbook.Worksheets.Add(sheetName);
// Build header columns in canonical order
var headers = BuildHeaders(prefix, maxPeriods);
// Write header row
for (var col = 0; col < headers.Count; col++)
sheet.Cell(1, col + 1).Value = headers[col];
// Write data rows
for (var i = 0; i < rows.Count; i++)
{
var (snapshot, evt) = rows[i];
var rowNum = i + 2; // 1-indexed, row 1 is header
var scheduledAt = evt.ScheduledAt;
var betDict = BetRowDenormalizer.Denormalize(snapshot.Bets, prefix, maxPeriods);
// Compute WinnerSide: 1 if Win_1 rate < Win_2 rate, else 2, else blank
object? winnerSide = ComputeWinnerSide(betDict, prefix);
// Write metadata columns
sheet.Cell(rowNum, 1).Value = i + 1; // RowNum (1-based)
sheet.Cell(rowNum, 2).Value = evt.Sport.Value;
sheet.Cell(rowNum, 3).Value = string.Empty; // Sport name — not available without lookup table join
sheet.Cell(rowNum, 4).Value = evt.CountryCode;
sheet.Cell(rowNum, 5).Value = evt.LeagueId;
sheet.Cell(rowNum, 6).Value = evt.Category;
sheet.Cell(rowNum, 7).Value = scheduledAt.ToString("dd.MM.yyyy HH:mm", CultureInfo.InvariantCulture);
sheet.Cell(rowNum, 8).Value = scheduledAt.Day;
sheet.Cell(rowNum, 9).Value = scheduledAt.Month;
sheet.Cell(rowNum, 10).Value = scheduledAt.Year;
sheet.Cell(rowNum, 11).Value = scheduledAt.ToString("HH:mm", CultureInfo.InvariantCulture);
sheet.Cell(rowNum, 12).Value = evt.Id.Value;
// Write bet columns in the order they appear in headers (starting at col 13)
for (var col = MetadataColumnCount; col < headers.Count - 1; col++)
{
var key = headers[col];
if (betDict.TryGetValue(key, out var cellValue) && cellValue is not null)
SetCellValue(sheet.Cell(rowNum, col + 1), cellValue);
}
// WinnerSide — last column
if (winnerSide is not null)
SetCellValue(sheet.Cell(rowNum, headers.Count), winnerSide);
}
}
private const int MetadataColumnCount = 12; // RowNum, SportCode, Sport, Country, League, Category, DateFull, Day, Month, Year, Time, EventId
private static List<string> BuildHeaders(string prefix, int maxPeriods)
{
var headers = new List<string>
{
"RowNum", "SportCode", "Sport", "Country", "League", "Category",
"DateFull", "Day", "Month", "Year", "Time", "EventId",
// Match-level bet columns
$"{prefix}Match_Win_1",
$"{prefix}Match_Draw",
$"{prefix}Match_Win_2",
$"{prefix}Match_Win_Fora_1_Value",
$"{prefix}Match_Win_Fora_1_Rate",
$"{prefix}Match_Win_Fora_2_Value",
$"{prefix}Match_Win_Fora_2_Rate",
$"{prefix}Match_Total_Less_Value",
$"{prefix}Match_Total_Less_Rate",
$"{prefix}Match_Total_More_Value",
$"{prefix}Match_Total_More_Rate",
};
for (var n = 1; n <= maxPeriods; n++)
{
var p = $"Period-{n}";
headers.Add($"{prefix}{p}_Win_1");
headers.Add($"{prefix}{p}_Draw");
headers.Add($"{prefix}{p}_Win_2");
headers.Add($"{prefix}{p}_Win_Fora_1_Value");
headers.Add($"{prefix}{p}_Win_Fora_1_Rate");
headers.Add($"{prefix}{p}_Win_Fora_2_Value");
headers.Add($"{prefix}{p}_Win_Fora_2_Rate");
headers.Add($"{prefix}{p}_Total_Less_Value");
headers.Add($"{prefix}{p}_Total_Less_Rate");
headers.Add($"{prefix}{p}_Total_More_Value");
headers.Add($"{prefix}{p}_Total_More_Rate");
}
headers.Add("WinnerSide");
return headers;
}
/// <summary>
/// Sets a cell's value from a boxed primitive. Handles decimal, int, and string.
/// Empty cell on null (caller already guards).
/// </summary>
private static void SetCellValue(IXLCell cell, object value)
{
switch (value)
{
case decimal d:
cell.Value = (double)d;
break;
case int i:
cell.Value = i;
break;
case long l:
cell.Value = (double)l;
break;
case string s:
cell.Value = s;
break;
default:
cell.Value = value.ToString() ?? string.Empty;
break;
}
}
private static object? ComputeWinnerSide(Dictionary<string, object?> betDict, string prefix)
{
var win1Key = $"{prefix}Match_Win_1";
var win2Key = $"{prefix}Match_Win_2";
if (!betDict.TryGetValue(win1Key, out var win1Raw) || win1Raw is null)
return null;
if (!betDict.TryGetValue(win2Key, out var win2Raw) || win2Raw is null)
return null;
var win1 = Convert.ToDecimal(win1Raw, CultureInfo.InvariantCulture);
var win2 = Convert.ToDecimal(win2Raw, CultureInfo.InvariantCulture);
if (win1 == win2)
return null;
// Lower rate = bookmaker's favourite
return win1 < win2 ? (object?)1 : 2;
}
}
@@ -0,0 +1,4 @@
// Alias Microsoft.Extensions.Logging.EventId to avoid name conflict with
// Marathon.Domain.ValueObjects.EventId. Files that need the logging EventId
// can use LogEventId explicitly.
global using LogEventId = Microsoft.Extensions.Logging.EventId;
@@ -0,0 +1,60 @@
using Marathon.Application.Configuration;
using Marathon.Infrastructure.Configuration;
using Marathon.Infrastructure.Persistence;
using Marathon.Infrastructure.Scraping;
using Marathon.Infrastructure.Workers;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
namespace Marathon.Infrastructure;
/// <summary>
/// Top-level DI composition entry-point for all Infrastructure sub-modules
/// (Persistence, Scraping, Workers).
/// </summary>
/// <remarks>
/// Call <see cref="AddMarathonInfrastructure"/> once from the host's DI
/// setup. This replaces the previous reflection-based wiring in
/// <c>App.xaml.cs::TryAddApplicationAndInfrastructure</c>.
/// </remarks>
public static class InfrastructureModule
{
/// <summary>
/// Registers the complete Infrastructure layer:
/// <list type="bullet">
/// <item>EF Core / SQLite persistence (<see cref="PersistenceModule.AddMarathonPersistence"/>).</item>
/// <item>HttpClient + AngleSharp + Polly scraping (<see cref="ScrapingModule.AddMarathonScraping"/>).</item>
/// <item><see cref="WorkerOptions"/> bound to the <c>Workers</c> config section.</item>
/// <item>Three <see cref="Microsoft.Extensions.Hosting.BackgroundService"/> pollers.</item>
/// </list>
/// </summary>
public static IServiceCollection AddMarathonInfrastructure(
this IServiceCollection services,
IConfiguration config)
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(config);
services.AddMarathonPersistence(config);
services.AddMarathonScraping(config);
services
.AddOptions<WorkerOptions>()
.Bind(config.GetSection(WorkerOptions.SectionName));
services
.AddOptions<AnomalyOptions>()
.Bind(config.GetSection(AnomalyOptions.SectionName));
services
.AddOptions<ScrapingThrottle>()
.Bind(config.GetSection(ScrapingThrottle.SectionName));
services.AddHostedService<UpcomingEventsPoller>();
services.AddHostedService<LiveOddsPoller>();
services.AddHostedService<ResultsWatchListPoller>();
services.AddHostedService<AnomalyDetectionPoller>();
return services;
}
}
@@ -0,0 +1,34 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="AngleSharp" />
<PackageReference Include="ClosedXML" />
<PackageReference Include="Microsoft.EntityFrameworkCore" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" />
<PackageReference Include="Microsoft.Extensions.Http" />
<PackageReference Include="Microsoft.Extensions.Http.Resilience" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" />
<PackageReference Include="Polly" />
<PackageReference Include="Cronos" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Marathon.Domain\Marathon.Domain.csproj" />
<ProjectReference Include="..\Marathon.Application\Marathon.Application.csproj" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="Marathon.Infrastructure.Tests" />
</ItemGroup>
</Project>
@@ -0,0 +1,191 @@
using Marathon.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Marathon.Infrastructure.Migrations;
/// <inheritdoc />
[DbContext(typeof(MarathonDbContext))]
[Migration("20260505000000_InitialCreate")]
public partial class InitialCreate : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Events",
columns: table => new
{
EventCode = table.Column<string>(type: "TEXT", nullable: false),
SportCode = table.Column<int>(type: "INTEGER", nullable: false),
CountryCode = table.Column<string>(type: "TEXT", nullable: false),
LeagueId = table.Column<string>(type: "TEXT", nullable: false),
Category = table.Column<string>(type: "TEXT", nullable: false, defaultValue: ""),
ScheduledAt = table.Column<string>(type: "TEXT", nullable: false),
Side1Name = table.Column<string>(type: "TEXT", nullable: false),
Side2Name = table.Column<string>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Events", x => x.EventCode);
});
migrationBuilder.CreateTable(
name: "Leagues",
columns: table => new
{
Id = table.Column<string>(type: "TEXT", nullable: false),
SportCode = table.Column<int>(type: "INTEGER", nullable: false),
Country = table.Column<string>(type: "TEXT", nullable: false),
NameRu = table.Column<string>(type: "TEXT", nullable: false),
NameEn = table.Column<string>(type: "TEXT", nullable: false),
Category = table.Column<string>(type: "TEXT", nullable: false, defaultValue: "")
},
constraints: table =>
{
table.PrimaryKey("PK_Leagues", x => x.Id);
});
migrationBuilder.CreateTable(
name: "Sports",
columns: table => new
{
Code = table.Column<int>(type: "INTEGER", nullable: false),
NameRu = table.Column<string>(type: "TEXT", nullable: false),
NameEn = table.Column<string>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Sports", x => x.Code);
});
migrationBuilder.CreateTable(
name: "Anomalies",
columns: table => new
{
Id = table.Column<string>(type: "TEXT", nullable: false),
EventCode = table.Column<string>(type: "TEXT", nullable: false),
DetectedAt = table.Column<string>(type: "TEXT", nullable: false),
Kind = table.Column<int>(type: "INTEGER", nullable: false),
Score = table.Column<decimal>(type: "TEXT", nullable: false),
EvidenceJson = table.Column<string>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Anomalies", x => x.Id);
table.ForeignKey(
name: "FK_Anomalies_Events_EventCode",
column: x => x.EventCode,
principalTable: "Events",
principalColumn: "EventCode",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "EventResults",
columns: table => new
{
EventCode = table.Column<string>(type: "TEXT", nullable: false),
Side1Score = table.Column<int>(type: "INTEGER", nullable: false),
Side2Score = table.Column<int>(type: "INTEGER", nullable: false),
WinnerSide = table.Column<int>(type: "INTEGER", nullable: false),
CompletedAt = table.Column<string>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_EventResults", x => x.EventCode);
table.ForeignKey(
name: "FK_EventResults_Events_EventCode",
column: x => x.EventCode,
principalTable: "Events",
principalColumn: "EventCode",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "Snapshots",
columns: table => new
{
Id = table.Column<long>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
EventCode = table.Column<string>(type: "TEXT", nullable: false),
CapturedAt = table.Column<string>(type: "TEXT", nullable: false),
Source = table.Column<int>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Snapshots", x => x.Id);
table.ForeignKey(
name: "FK_Snapshots_Events_EventCode",
column: x => x.EventCode,
principalTable: "Events",
principalColumn: "EventCode",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "Bets",
columns: table => new
{
Id = table.Column<long>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
SnapshotId = table.Column<long>(type: "INTEGER", nullable: false),
Scope = table.Column<int>(type: "INTEGER", nullable: false),
PeriodNumber = table.Column<int>(type: "INTEGER", nullable: true),
Type = table.Column<int>(type: "INTEGER", nullable: false),
Side = table.Column<int>(type: "INTEGER", nullable: false),
Value = table.Column<decimal>(type: "TEXT", nullable: true),
Rate = table.Column<decimal>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Bets", x => x.Id);
table.ForeignKey(
name: "FK_Bets_Snapshots_SnapshotId",
column: x => x.SnapshotId,
principalTable: "Snapshots",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
// Indexes
migrationBuilder.CreateIndex(
name: "IX_Events_SportCode_ScheduledAt",
table: "Events",
columns: new[] { "SportCode", "ScheduledAt" });
migrationBuilder.CreateIndex(
name: "IX_Events_ScheduledAt",
table: "Events",
column: "ScheduledAt");
migrationBuilder.CreateIndex(
name: "IX_Snapshots_EventCode",
table: "Snapshots",
column: "EventCode");
migrationBuilder.CreateIndex(
name: "IX_Bets_SnapshotId",
table: "Bets",
column: "SnapshotId");
migrationBuilder.CreateIndex(
name: "IX_Anomalies_EventCode",
table: "Anomalies",
column: "EventCode");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(name: "Bets");
migrationBuilder.DropTable(name: "Snapshots");
migrationBuilder.DropTable(name: "EventResults");
migrationBuilder.DropTable(name: "Anomalies");
migrationBuilder.DropTable(name: "Events");
migrationBuilder.DropTable(name: "Leagues");
migrationBuilder.DropTable(name: "Sports");
}
}
@@ -0,0 +1,31 @@
using Marathon.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Marathon.Infrastructure.Migrations;
/// <inheritdoc />
[DbContext(typeof(MarathonDbContext))]
[Migration("20260506000000_AddEventPath")]
public partial class AddEventPath : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "EventPath",
table: "Events",
type: "TEXT",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "EventPath",
table: "Events");
}
}
@@ -0,0 +1,160 @@
// <auto-generated />
using Marathon.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace Marathon.Infrastructure.Migrations;
[DbContext(typeof(MarathonDbContext))]
partial class MarathonDbContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "8.0.12");
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.AnomalyEntity", b =>
{
b.Property<string>("Id").HasColumnType("TEXT");
b.Property<string>("DetectedAt").IsRequired().HasColumnType("TEXT");
b.Property<string>("EventCode").IsRequired().HasColumnType("TEXT");
b.Property<string>("EvidenceJson").IsRequired().HasColumnType("TEXT");
b.Property<int>("Kind").HasColumnType("INTEGER");
b.Property<decimal>("Score").HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("EventCode").HasDatabaseName("IX_Anomalies_EventCode");
b.ToTable("Anomalies");
});
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.BetEntity", b =>
{
b.Property<long>("Id").ValueGeneratedOnAdd().HasColumnType("INTEGER");
b.Property<int?>("PeriodNumber").HasColumnType("INTEGER");
b.Property<decimal>("Rate").HasColumnType("TEXT");
b.Property<int>("Scope").HasColumnType("INTEGER");
b.Property<int>("Side").HasColumnType("INTEGER");
b.Property<long>("SnapshotId").HasColumnType("INTEGER");
b.Property<int>("Type").HasColumnType("INTEGER");
b.Property<decimal?>("Value").HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("SnapshotId").HasDatabaseName("IX_Bets_SnapshotId");
b.ToTable("Bets");
});
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.EventEntity", b =>
{
b.Property<string>("EventCode").HasColumnType("TEXT");
b.Property<string>("Category").IsRequired().HasDefaultValue("").HasColumnType("TEXT");
b.Property<string>("CountryCode").IsRequired().HasColumnType("TEXT");
b.Property<string>("EventPath").HasColumnType("TEXT");
b.Property<string>("LeagueId").IsRequired().HasColumnType("TEXT");
b.Property<string>("ScheduledAt").IsRequired().HasColumnType("TEXT");
b.Property<string>("Side1Name").IsRequired().HasColumnType("TEXT");
b.Property<string>("Side2Name").IsRequired().HasColumnType("TEXT");
b.Property<int>("SportCode").HasColumnType("INTEGER");
b.HasKey("EventCode");
b.HasIndex(new[] { "SportCode", "ScheduledAt" }).HasDatabaseName("IX_Events_SportCode_ScheduledAt");
b.HasIndex("ScheduledAt").HasDatabaseName("IX_Events_ScheduledAt");
b.ToTable("Events");
});
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.EventResultEntity", b =>
{
b.Property<string>("EventCode").HasColumnType("TEXT");
b.Property<string>("CompletedAt").IsRequired().HasColumnType("TEXT");
b.Property<int>("Side1Score").HasColumnType("INTEGER");
b.Property<int>("Side2Score").HasColumnType("INTEGER");
b.Property<int>("WinnerSide").HasColumnType("INTEGER");
b.HasKey("EventCode");
b.ToTable("EventResults");
});
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.LeagueEntity", b =>
{
b.Property<string>("Id").HasColumnType("TEXT");
b.Property<string>("Category").IsRequired().HasDefaultValue("").HasColumnType("TEXT");
b.Property<string>("Country").IsRequired().HasColumnType("TEXT");
b.Property<string>("NameEn").IsRequired().HasColumnType("TEXT");
b.Property<string>("NameRu").IsRequired().HasColumnType("TEXT");
b.Property<int>("SportCode").HasColumnType("INTEGER");
b.HasKey("Id");
b.ToTable("Leagues");
});
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.SnapshotEntity", b =>
{
b.Property<long>("Id").ValueGeneratedOnAdd().HasColumnType("INTEGER");
b.Property<string>("CapturedAt").IsRequired().HasColumnType("TEXT");
b.Property<string>("EventCode").IsRequired().HasColumnType("TEXT");
b.Property<int>("Source").HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("EventCode").HasDatabaseName("IX_Snapshots_EventCode");
b.ToTable("Snapshots");
});
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.SportEntity", b =>
{
b.Property<int>("Code").HasColumnType("INTEGER");
b.Property<string>("NameEn").IsRequired().HasColumnType("TEXT");
b.Property<string>("NameRu").IsRequired().HasColumnType("TEXT");
b.HasKey("Code");
b.ToTable("Sports");
});
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.AnomalyEntity", b =>
{
b.HasOne("Marathon.Infrastructure.Persistence.Entities.EventEntity", "Event")
.WithMany("Anomalies")
.HasForeignKey("EventCode")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Event");
});
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.BetEntity", b =>
{
b.HasOne("Marathon.Infrastructure.Persistence.Entities.SnapshotEntity", "Snapshot")
.WithMany("Bets")
.HasForeignKey("SnapshotId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Snapshot");
});
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.EventResultEntity", b =>
{
b.HasOne("Marathon.Infrastructure.Persistence.Entities.EventEntity", "Event")
.WithOne("Result")
.HasForeignKey("Marathon.Infrastructure.Persistence.Entities.EventResultEntity", "EventCode")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Event");
});
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.SnapshotEntity", b =>
{
b.HasOne("Marathon.Infrastructure.Persistence.Entities.EventEntity", "Event")
.WithMany("Snapshots")
.HasForeignKey("EventCode")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Event");
});
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.EventEntity", b =>
{
b.Navigation("Anomalies");
b.Navigation("Result");
b.Navigation("Snapshots");
});
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.SnapshotEntity", b =>
{
b.Navigation("Bets");
});
#pragma warning restore 612, 618
}
}
@@ -0,0 +1,23 @@
using Marathon.Infrastructure.Persistence.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Marathon.Infrastructure.Persistence.Configurations;
internal sealed class AnomalyConfiguration : IEntityTypeConfiguration<AnomalyEntity>
{
public void Configure(EntityTypeBuilder<AnomalyEntity> builder)
{
builder.ToTable("Anomalies");
builder.HasKey(a => a.Id);
builder.Property(a => a.Id).HasColumnType("TEXT").IsRequired();
builder.Property(a => a.EventCode).HasColumnType("TEXT").IsRequired();
builder.Property(a => a.DetectedAt).HasColumnType("TEXT").IsRequired();
builder.Property(a => a.Kind).HasColumnType("INTEGER").IsRequired();
builder.Property(a => a.Score).HasColumnType("TEXT").IsRequired();
builder.Property(a => a.EvidenceJson).HasColumnType("TEXT").IsRequired();
builder.HasIndex(a => a.EventCode).HasDatabaseName("IX_Anomalies_EventCode");
}
}
@@ -0,0 +1,25 @@
using Marathon.Infrastructure.Persistence.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Marathon.Infrastructure.Persistence.Configurations;
internal sealed class BetConfiguration : IEntityTypeConfiguration<BetEntity>
{
public void Configure(EntityTypeBuilder<BetEntity> builder)
{
builder.ToTable("Bets");
builder.HasKey(b => b.Id);
builder.Property(b => b.Id).HasColumnType("INTEGER").ValueGeneratedOnAdd();
builder.Property(b => b.SnapshotId).HasColumnType("INTEGER").IsRequired();
builder.Property(b => b.Scope).HasColumnType("INTEGER").IsRequired();
builder.Property(b => b.PeriodNumber).HasColumnType("INTEGER");
builder.Property(b => b.Type).HasColumnType("INTEGER").IsRequired();
builder.Property(b => b.Side).HasColumnType("INTEGER").IsRequired();
builder.Property(b => b.Value).HasColumnType("TEXT");
builder.Property(b => b.Rate).HasColumnType("TEXT").IsRequired();
builder.HasIndex(b => b.SnapshotId).HasDatabaseName("IX_Bets_SnapshotId");
}
}
@@ -0,0 +1,43 @@
using Marathon.Infrastructure.Persistence.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Marathon.Infrastructure.Persistence.Configurations;
internal sealed class EventConfiguration : IEntityTypeConfiguration<EventEntity>
{
public void Configure(EntityTypeBuilder<EventEntity> builder)
{
builder.ToTable("Events");
builder.HasKey(e => e.EventCode);
builder.Property(e => e.EventCode).HasColumnType("TEXT").IsRequired();
builder.Property(e => e.SportCode).HasColumnType("INTEGER").IsRequired();
builder.Property(e => e.CountryCode).HasColumnType("TEXT").IsRequired();
builder.Property(e => e.LeagueId).HasColumnType("TEXT").IsRequired();
builder.Property(e => e.Category).HasColumnType("TEXT").HasDefaultValue(string.Empty);
builder.Property(e => e.ScheduledAt).HasColumnType("TEXT").IsRequired();
builder.Property(e => e.Side1Name).HasColumnType("TEXT").IsRequired();
builder.Property(e => e.Side2Name).HasColumnType("TEXT").IsRequired();
builder.Property(e => e.EventPath).HasColumnType("TEXT");
// Index for date-range queries and sport filtering
builder.HasIndex(e => new { e.SportCode, e.ScheduledAt }).HasDatabaseName("IX_Events_SportCode_ScheduledAt");
builder.HasIndex(e => e.ScheduledAt).HasDatabaseName("IX_Events_ScheduledAt");
builder.HasMany(e => e.Snapshots)
.WithOne(s => s.Event)
.HasForeignKey(s => s.EventCode)
.OnDelete(DeleteBehavior.Cascade);
builder.HasOne(e => e.Result)
.WithOne(r => r.Event)
.HasForeignKey<EventResultEntity>(r => r.EventCode)
.OnDelete(DeleteBehavior.Cascade);
builder.HasMany(e => e.Anomalies)
.WithOne(a => a.Event)
.HasForeignKey(a => a.EventCode)
.OnDelete(DeleteBehavior.Cascade);
}
}
@@ -0,0 +1,20 @@
using Marathon.Infrastructure.Persistence.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Marathon.Infrastructure.Persistence.Configurations;
internal sealed class EventResultConfiguration : IEntityTypeConfiguration<EventResultEntity>
{
public void Configure(EntityTypeBuilder<EventResultEntity> builder)
{
builder.ToTable("EventResults");
builder.HasKey(r => r.EventCode);
builder.Property(r => r.EventCode).HasColumnType("TEXT").IsRequired();
builder.Property(r => r.Side1Score).HasColumnType("INTEGER").IsRequired();
builder.Property(r => r.Side2Score).HasColumnType("INTEGER").IsRequired();
builder.Property(r => r.WinnerSide).HasColumnType("INTEGER").IsRequired();
builder.Property(r => r.CompletedAt).HasColumnType("TEXT").IsRequired();
}
}
@@ -0,0 +1,21 @@
using Marathon.Infrastructure.Persistence.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Marathon.Infrastructure.Persistence.Configurations;
internal sealed class LeagueConfiguration : IEntityTypeConfiguration<LeagueEntity>
{
public void Configure(EntityTypeBuilder<LeagueEntity> builder)
{
builder.ToTable("Leagues");
builder.HasKey(l => l.Id);
builder.Property(l => l.Id).HasColumnType("TEXT").IsRequired();
builder.Property(l => l.SportCode).HasColumnType("INTEGER").IsRequired();
builder.Property(l => l.Country).HasColumnType("TEXT").IsRequired();
builder.Property(l => l.NameRu).HasColumnType("TEXT").IsRequired();
builder.Property(l => l.NameEn).HasColumnType("TEXT").IsRequired();
builder.Property(l => l.Category).HasColumnType("TEXT").HasDefaultValue(string.Empty);
}
}
@@ -0,0 +1,26 @@
using Marathon.Infrastructure.Persistence.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Marathon.Infrastructure.Persistence.Configurations;
internal sealed class SnapshotConfiguration : IEntityTypeConfiguration<SnapshotEntity>
{
public void Configure(EntityTypeBuilder<SnapshotEntity> builder)
{
builder.ToTable("Snapshots");
builder.HasKey(s => s.Id);
builder.Property(s => s.Id).HasColumnType("INTEGER").ValueGeneratedOnAdd();
builder.Property(s => s.EventCode).HasColumnType("TEXT").IsRequired();
builder.Property(s => s.CapturedAt).HasColumnType("TEXT").IsRequired();
builder.Property(s => s.Source).HasColumnType("INTEGER").IsRequired();
builder.HasIndex(s => s.EventCode).HasDatabaseName("IX_Snapshots_EventCode");
builder.HasMany(s => s.Bets)
.WithOne(b => b.Snapshot)
.HasForeignKey(b => b.SnapshotId)
.OnDelete(DeleteBehavior.Cascade);
}
}
@@ -0,0 +1,18 @@
using Marathon.Infrastructure.Persistence.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Marathon.Infrastructure.Persistence.Configurations;
internal sealed class SportConfiguration : IEntityTypeConfiguration<SportEntity>
{
public void Configure(EntityTypeBuilder<SportEntity> builder)
{
builder.ToTable("Sports");
builder.HasKey(s => s.Code);
builder.Property(s => s.Code).HasColumnType("INTEGER").IsRequired();
builder.Property(s => s.NameRu).HasColumnType("TEXT").IsRequired();
builder.Property(s => s.NameEn).HasColumnType("TEXT").IsRequired();
}
}
@@ -0,0 +1,28 @@
namespace Marathon.Infrastructure.Persistence.Entities;
/// <summary>
/// EF Core persistence entity for a detected odds anomaly.
/// </summary>
public sealed class AnomalyEntity
{
/// <summary>GUID primary key stored as TEXT.</summary>
public string Id { get; set; } = default!;
/// <summary>Foreign key to <see cref="EventEntity.EventCode"/>.</summary>
public string EventCode { get; set; } = default!;
/// <summary>ISO 8601 timestamp when the anomaly was detected.</summary>
public string DetectedAt { get; set; } = default!;
/// <summary>Anomaly kind as int (AnomalyKind enum value).</summary>
public int Kind { get; set; }
/// <summary>Normalised confidence score in [0, 1].</summary>
public decimal Score { get; set; }
/// <summary>JSON string containing the raw evidence timeline.</summary>
public string EvidenceJson { get; set; } = default!;
// Navigation property
public EventEntity Event { get; set; } = default!;
}
@@ -0,0 +1,37 @@
namespace Marathon.Infrastructure.Persistence.Entities;
/// <summary>
/// EF Core persistence entity for a single bet within an odds snapshot.
/// BetScope is stored as (Scope, PeriodNumber):
/// MatchScope → Scope=0, PeriodNumber=NULL
/// PeriodScope → Scope=1, PeriodNumber=N
/// </summary>
public sealed class BetEntity
{
/// <summary>Auto-incremented surrogate key.</summary>
public long Id { get; set; }
/// <summary>Foreign key to <see cref="SnapshotEntity.Id"/>.</summary>
public long SnapshotId { get; set; }
/// <summary>Scope discriminator: 0 = Match, 1 = Period.</summary>
public int Scope { get; set; }
/// <summary>Period number (1-based); null when Scope = Match.</summary>
public int? PeriodNumber { get; set; }
/// <summary>Bet type as int (BetType enum value).</summary>
public int Type { get; set; }
/// <summary>Bet side as int (Side enum value).</summary>
public int Side { get; set; }
/// <summary>Handicap or total threshold; null for Win/Draw bet types.</summary>
public decimal? Value { get; set; }
/// <summary>Decimal odds rate (must be > 1.0 in domain).</summary>
public decimal Rate { get; set; }
// Navigation property
public SnapshotEntity Snapshot { get; set; } = default!;
}
@@ -0,0 +1,44 @@
namespace Marathon.Infrastructure.Persistence.Entities;
/// <summary>
/// EF Core persistence entity for a sporting event.
/// ScheduledAt is stored as ISO 8601 TEXT including the +03:00 offset (Moscow time).
/// </summary>
public sealed class EventEntity
{
/// <summary>Bookmaker's stable event identifier (TEXT primary key, e.g. "26456117").</summary>
public string EventCode { get; set; } = default!;
/// <summary>Sport identifier corresponding to <c>data-sport-treeId</c>.</summary>
public int SportCode { get; set; }
/// <summary>Country breadcrumb text.</summary>
public string CountryCode { get; set; } = default!;
/// <summary>League identifier.</summary>
public string LeagueId { get; set; } = default!;
/// <summary>Optional category text (deeper breadcrumb items joined with " / ").</summary>
public string Category { get; set; } = string.Empty;
/// <summary>ISO 8601 timestamp with +03:00 offset (e.g. "2026-05-05T20:30:00+03:00").</summary>
public string ScheduledAt { get; set; } = default!;
/// <summary>Name of the first participant (home side).</summary>
public string Side1Name { get; set; } = default!;
/// <summary>Name of the second participant (away side).</summary>
public string Side2Name { get; set; } = default!;
/// <summary>
/// Optional bookmaker URL fragment used to construct the event-detail page URL.
/// Sourced from <c>data-event-path</c> at scrape time. Nullable so older rows
/// (persisted before this column existed) round-trip without a backfill.
/// </summary>
public string? EventPath { get; set; }
// Navigation properties
public ICollection<SnapshotEntity> Snapshots { get; set; } = [];
public EventResultEntity? Result { get; set; }
public ICollection<AnomalyEntity> Anomalies { get; set; } = [];
}
@@ -0,0 +1,26 @@
namespace Marathon.Infrastructure.Persistence.Entities;
/// <summary>
/// EF Core persistence entity for the final result of an event.
/// Has a 1-to-1 relationship with <see cref="EventEntity"/> (shared primary key).
/// </summary>
public sealed class EventResultEntity
{
/// <summary>Primary key — same value as <see cref="EventEntity.EventCode"/>.</summary>
public string EventCode { get; set; } = default!;
/// <summary>Score for the first side (home).</summary>
public int Side1Score { get; set; }
/// <summary>Score for the second side (away).</summary>
public int Side2Score { get; set; }
/// <summary>Winner side as int (Side enum value: Side1=0, Side2=1, Draw=2).</summary>
public int WinnerSide { get; set; }
/// <summary>ISO 8601 timestamp when the event completed.</summary>
public string CompletedAt { get; set; } = default!;
// Navigation property
public EventEntity Event { get; set; } = default!;
}
@@ -0,0 +1,25 @@
namespace Marathon.Infrastructure.Persistence.Entities;
/// <summary>
/// EF Core persistence entity for a league / tournament lookup record.
/// </summary>
public sealed class LeagueEntity
{
/// <summary>League identifier (primary key).</summary>
public string Id { get; set; } = default!;
/// <summary>Sport code this league belongs to.</summary>
public int SportCode { get; set; }
/// <summary>Country or region this league belongs to.</summary>
public string Country { get; set; } = default!;
/// <summary>Russian display name.</summary>
public string NameRu { get; set; } = default!;
/// <summary>English display name.</summary>
public string NameEn { get; set; } = default!;
/// <summary>Optional category (deeper classification).</summary>
public string Category { get; set; } = string.Empty;
}
@@ -0,0 +1,23 @@
namespace Marathon.Infrastructure.Persistence.Entities;
/// <summary>
/// EF Core persistence entity for an odds snapshot captured at a point in time.
/// </summary>
public sealed class SnapshotEntity
{
/// <summary>Auto-incremented surrogate key.</summary>
public long Id { get; set; }
/// <summary>Foreign key to <see cref="EventEntity.EventCode"/>.</summary>
public string EventCode { get; set; } = default!;
/// <summary>ISO 8601 timestamp when this snapshot was captured.</summary>
public string CapturedAt { get; set; } = default!;
/// <summary>Source of the snapshot: 0 = PreMatch, 1 = Live.</summary>
public int Source { get; set; }
// Navigation properties
public EventEntity Event { get; set; } = default!;
public ICollection<BetEntity> Bets { get; set; } = [];
}
@@ -0,0 +1,16 @@
namespace Marathon.Infrastructure.Persistence.Entities;
/// <summary>
/// EF Core persistence entity for a sport lookup record.
/// </summary>
public sealed class SportEntity
{
/// <summary>Sport code (data-sport-treeId from breadcrumbs).</summary>
public int Code { get; set; }
/// <summary>Russian display name.</summary>
public string NameRu { get; set; } = default!;
/// <summary>English display name.</summary>
public string NameEn { get; set; } = default!;
}
@@ -0,0 +1,182 @@
using System.Globalization;
using Marathon.Domain.Entities;
using Marathon.Domain.Enums;
using Marathon.Domain.ValueObjects;
using Marathon.Infrastructure.Persistence.Entities;
namespace Marathon.Infrastructure.Persistence;
/// <summary>
/// Mapping helpers that translate between domain objects and EF Core persistence entities.
/// Domain invariants are enforced on the domain side; mapping is purely structural.
/// </summary>
internal static class Mapping
{
// ScheduledAt / CapturedAt / DetectedAt / CompletedAt are written via
// DateTimeOffset.ToString("O") — round-trip ISO 8601. Parse with the
// invariant culture and RoundtripKind so a non-en-US thread culture
// (or a future locale change) cannot corrupt the round-trip.
private const DateTimeStyles RoundtripStyles = DateTimeStyles.RoundtripKind;
// ─── Bet scope discriminator constants ────────────────────────────────────
private const int ScopeMatch = 0;
private const int ScopePeriod = 1;
// ─── Event ───────────────────────────────────────────────────────────────
public static EventEntity ToEntity(Event domain) =>
new()
{
EventCode = domain.Id.Value,
SportCode = domain.Sport.Value,
CountryCode = domain.CountryCode,
LeagueId = domain.LeagueId,
Category = domain.Category,
ScheduledAt = domain.ScheduledAt.ToString("O"),
Side1Name = domain.Side1Name,
Side2Name = domain.Side2Name,
EventPath = domain.EventPath,
};
public static Event ToDomain(EventEntity entity) =>
new(
Id: new EventId(entity.EventCode),
Sport: new SportCode(entity.SportCode),
CountryCode: entity.CountryCode,
LeagueId: entity.LeagueId,
Category: entity.Category,
ScheduledAt: DateTimeOffset.Parse(entity.ScheduledAt, CultureInfo.InvariantCulture, RoundtripStyles),
Side1Name: entity.Side1Name,
Side2Name: entity.Side2Name)
{
EventPath = entity.EventPath,
};
// ─── OddsSnapshot ─────────────────────────────────────────────────────────
public static SnapshotEntity ToEntity(OddsSnapshot domain) =>
new()
{
EventCode = domain.EventId.Value,
CapturedAt = domain.CapturedAt.ToString("O"),
Source = (int)domain.Source,
Bets = domain.Bets.Select(ToEntity).ToList(),
};
public static OddsSnapshot ToDomain(SnapshotEntity entity) =>
new(
eventId: new EventId(entity.EventCode),
capturedAt: DateTimeOffset.Parse(entity.CapturedAt, CultureInfo.InvariantCulture, RoundtripStyles),
source: (OddsSource)entity.Source,
bets: entity.Bets.Select(ToDomain).ToList().AsReadOnly());
// ─── Bet ──────────────────────────────────────────────────────────────────
public static BetEntity ToEntity(Bet domain) =>
new()
{
Scope = domain.Scope is MatchScope ? ScopeMatch : ScopePeriod,
PeriodNumber = domain.Scope is PeriodScope ps ? ps.Number : null,
Type = (int)domain.Type,
Side = (int)domain.Side,
Value = domain.Value?.Value,
Rate = domain.Rate.Value,
};
public static Bet ToDomain(BetEntity entity)
{
var scope = entity.Scope switch
{
ScopeMatch => (BetScope)MatchScope.Instance,
ScopePeriod => new PeriodScope(entity.PeriodNumber!.Value),
_ => throw new InvalidOperationException(
$"Unknown BetScope discriminator: {entity.Scope}"),
};
var value = entity.Value.HasValue ? new OddsValue(entity.Value.Value) : null;
var rate = new OddsRate(entity.Rate);
var type = (BetType)entity.Type;
var side = (Side)entity.Side;
return new Bet(scope, type, side, value, rate);
}
// ─── EventResult ──────────────────────────────────────────────────────────
public static EventResultEntity ToEntity(EventResult domain) =>
new()
{
EventCode = domain.EventId.Value,
Side1Score = domain.Side1Score,
Side2Score = domain.Side2Score,
WinnerSide = (int)domain.WinnerSide,
CompletedAt = domain.CompletedAt.ToString("O"),
};
public static EventResult ToDomain(EventResultEntity entity) =>
new(
EventId: new EventId(entity.EventCode),
Side1Score: entity.Side1Score,
Side2Score: entity.Side2Score,
WinnerSide: (Side)entity.WinnerSide,
CompletedAt: DateTimeOffset.Parse(entity.CompletedAt, CultureInfo.InvariantCulture, RoundtripStyles));
// ─── Anomaly ──────────────────────────────────────────────────────────────
public static AnomalyEntity ToEntity(Anomaly domain) =>
new()
{
Id = domain.Id.ToString(),
EventCode = domain.EventId.Value,
DetectedAt = domain.DetectedAt.ToString("O"),
Kind = (int)domain.Kind,
Score = domain.Score,
EvidenceJson = domain.EvidenceJson,
};
public static Anomaly ToDomain(AnomalyEntity entity) =>
new(
Id: Guid.Parse(entity.Id),
EventId: new EventId(entity.EventCode),
DetectedAt: DateTimeOffset.Parse(entity.DetectedAt, CultureInfo.InvariantCulture, RoundtripStyles),
Kind: (AnomalyKind)entity.Kind,
Score: entity.Score,
EvidenceJson: entity.EvidenceJson);
// ─── Sport ────────────────────────────────────────────────────────────────
public static SportEntity ToEntity(Sport domain) =>
new()
{
Code = domain.Code.Value,
NameRu = domain.NameRu,
NameEn = domain.NameEn,
};
public static Sport ToDomain(SportEntity entity) =>
new(
Code: new SportCode(entity.Code),
NameRu: entity.NameRu,
NameEn: entity.NameEn);
// ─── League ───────────────────────────────────────────────────────────────
public static LeagueEntity ToEntity(League domain) =>
new()
{
Id = domain.Id,
SportCode = domain.Sport.Value,
Country = domain.Country,
NameRu = domain.NameRu,
NameEn = domain.NameEn,
Category = domain.Category,
};
public static League ToDomain(LeagueEntity entity) =>
new(
Id: entity.Id,
Sport: new SportCode(entity.SportCode),
Country: entity.Country,
NameRu: entity.NameRu,
NameEn: entity.NameEn,
Category: entity.Category);
}
@@ -0,0 +1,27 @@
using Marathon.Infrastructure.Persistence.Entities;
using Microsoft.EntityFrameworkCore;
namespace Marathon.Infrastructure.Persistence;
/// <summary>
/// EF Core database context for the Marathon application.
/// Uses SQLite with WAL journal mode for safe concurrent reads alongside writes.
/// </summary>
public sealed class MarathonDbContext : DbContext
{
public MarathonDbContext(DbContextOptions<MarathonDbContext> options) : base(options) { }
public DbSet<EventEntity> Events => Set<EventEntity>();
public DbSet<SnapshotEntity> Snapshots => Set<SnapshotEntity>();
public DbSet<BetEntity> Bets => Set<BetEntity>();
public DbSet<EventResultEntity> EventResults => Set<EventResultEntity>();
public DbSet<AnomalyEntity> Anomalies => Set<AnomalyEntity>();
public DbSet<SportEntity> Sports => Set<SportEntity>();
public DbSet<LeagueEntity> Leagues => Set<LeagueEntity>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.ApplyConfigurationsFromAssembly(typeof(MarathonDbContext).Assembly);
base.OnModelCreating(modelBuilder);
}
}
@@ -0,0 +1,20 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
namespace Marathon.Infrastructure.Persistence;
/// <summary>
/// Design-time factory used by <c>dotnet ef migrations add</c>.
/// The host project is not required because this factory is self-contained.
/// </summary>
public sealed class MarathonDbContextFactory : IDesignTimeDbContextFactory<MarathonDbContext>
{
public MarathonDbContext CreateDbContext(string[] args)
{
var options = new DbContextOptionsBuilder<MarathonDbContext>()
.UseSqlite("Data Source=./data/design.db")
.Options;
return new MarathonDbContext(options);
}
}
@@ -0,0 +1,23 @@
using Microsoft.EntityFrameworkCore;
namespace Marathon.Infrastructure.Persistence;
/// <summary>
/// Applies one-time database initialization: runs pending migrations and enables WAL journal mode.
/// Should be resolved from the DI container during application startup.
/// </summary>
public sealed class MarathonDbContextInitializer
{
private readonly MarathonDbContext _db;
public MarathonDbContextInitializer(MarathonDbContext db) => _db = db;
/// <summary>
/// Applies pending EF migrations and enables WAL mode on the SQLite database.
/// </summary>
public async Task InitializeAsync(CancellationToken ct = default)
{
await _db.Database.MigrateAsync(ct);
await _db.Database.ExecuteSqlRawAsync("PRAGMA journal_mode=WAL;", ct);
}
}

Some files were not shown because too many files have changed in this diff Show More