66 Commits

Author SHA1 Message Date
alexei.dolgolyov 690d98d194 chore: add vex config + redesign mockups, ignore debug scratch
Commit the shared vex code-search config (.vex.toml) and the UI redesign
mockups under design/. Add the throwaway debugging dumps (_dump*.ps1,
_pages*.txt) to .gitignore so they stay local.
2026-05-29 14:03:12 +03:00
alexei.dolgolyov 42e62c1ed2 fix(journal): stop bet edits silently un-settling graded bets
A stake/notes-only edit re-graded from Pending, which un-settled a won/lost
bet once its result row had been pruned by snapshot retention (the journal is
FK-free and outlives results). Now only re-grade when the selection or event
actually changed, or the bet is still Pending — mirroring RecordPlacedBet.
2026-05-29 13:57:23 +03:00
alexei.dolgolyov 08486667c3 fix(export): neutralize CSV/DDE formula injection in exported text
Exported journal notes and scraped event titles could begin with a formula
trigger (= + - @, tab, CR) that Excel/LibreOffice execute on open.
Csv.NeutralizeFormula apostrophe-prefixes such cells so they render as text;
applied to user notes, raw event ids and scraped titles. Numeric/date cells
the exporter formats itself stay numeric for downstream analysis.
2026-05-29 13:57:18 +03:00
alexei.dolgolyov 88615a95e9 feat(export): CSV export for the bet journal + forward-test ledger
Adds CSV export alongside the Excel snapshot export: two buttons on the Export hub
write the bet journal and the paper-trading ledger to UTF-8 (BOM) .csv files in the
configured export directory and toast the path — mirroring the Excel use case's
write-and-return-path contract. CSV needs no third-party library, so it lives in the
Application layer behind a pure RFC-4180 formatter.

- Csv formatter (RFC 4180 escaping) + ExportToCsvUseCase (journal + ledger, batched
  title join, returns null when there's nothing to export); registered; Export hub
  buttons + en/ru resx.
- 11 tests: formatter escaping/Document + use-case empty-state + real-file write to a temp dir.
2026-05-29 13:44:35 +03:00
alexei.dolgolyov 1092e2a2c5 feat(journal): edit a placed bet
Adds an edit flow to the bet journal — an Edit button per row repurposes the inline
entry form (edit mode), saving via a new UpdatePlacedBetUseCase that preserves the
original placed-at and re-grades the outcome against the result (so a changed
selection/event re-settles). A cancel affordance + an editing banner make the mode
obvious; the submit button switches to "Save changes".

- UpdatePlacedBetUseCase (loads existing for PlacedAt, validates the event, re-grades)
  + IBetJournalService.UpdateAsync + service impl; Journal page edit-mode state +
  per-row Edit button; en/ru resx.
- 3 tests: unknown-bet, unknown-event, and preserve-PlacedAt + re-grade.
2026-05-29 13:38:38 +03:00
alexei.dolgolyov 41148a87a6 fix(anomalies): skip orphan-event rows in the feed instead of crashing
AnomalyBrowsingService.TryProject fell back to `new SportCode(0)` when an anomaly's
event was missing — but SportCode throws for 0, which would blow up the whole
feed/dashboard for that row. Anomalies have an FK to events so it was dead in
practice, but an orphaned row now degrades gracefully (skipped, like a row with
unparseable evidence). Closes the flagged latent crash.

- TryProject returns false when the event lookup misses; +1 test.
2026-05-29 13:32:19 +03:00
alexei.dolgolyov 36178e6d1b feat(anomalies): sort the feed (newest / top score / longest gap)
Adds a sort chip row to the anomaly feed — newest (default), highest score, or
longest suspension gap — replacing the fixed newest-first order. DetectedAt is the
tiebreak so order stays stable.

- AnomalySort enum + AnomalyFilter.Sort (default Newest, so existing constructions
  are unaffected); AnomalyBrowsingService applies it; feed sort chips +
  SetSort/SortLabel; en/ru resx.
- 2 tests: default newest-first + highest-score ordering.
2026-05-29 11:55:46 +03:00
alexei.dolgolyov 67f2ae130c feat(insights): hit-rate breakdown by detector kind
Adds a "By detector kind" breakdown to the anomaly outcome report + Insights page,
answering "which directional detector actually wins?". Only directional kinds
(SuspensionFlip, SteamMove) resolve to hit/miss, so the breakdown naturally shows
just those — mirrors the existing by-sport / by-severity bucketers and rendering.

- AnomalyOutcomeReport.ByKind + EvaluateAnomalyOutcomesUseCase.BuildKindBuckets
  (keyed OutcomeBucketKeys.KindPrefix + enum name); threaded through the insights VM/service.
- Insights page: new section + BucketRenderKind.Kind label case (resolves the enum name
  to the Anomaly.Kind.* resx); en/ru section heading.
- 2 tests: empty-report ByKind + directional-kind grouping.
2026-05-29 11:52:25 +03:00
alexei.dolgolyov f512a08772 feat(home): surface forward-test P&L on the dashboard
Adds a forward-test (paper-trading) net-P&L tile to the Home dashboard, shown only
once the worker has opened or settled any paper bet. Reuses the existing
IPaperTradingService aggregation (settled-only net + open count) so there is one
definition of the figure, and makes the H4 forward-test result visible from the
landing page instead of only its own route.

- DashboardSummary gains Paper{OpenCount,SettledCount,NetProfit,RoiPercent}
  (default-valued, so existing constructions are unaffected) + HasPaperTrades;
  DashboardSummaryService injects IPaperTradingService; Home tile + en/ru resx.
2026-05-29 11:43:58 +03:00
alexei.dolgolyov 34cc72fd2d feat(anomalies): filter the feed by detector kind
Adds a detector-kind chip row to the anomaly feed (SuspensionFlip / SteamMove /
SuspensionFreeze / OverroundCompression), multi-select like the sport filter — so
with four detectors live you can slice the feed to a single signal type. The kind
set lives on AnomalyFilter and filters in-memory alongside severity/sport, persisted
via AnomalyBrowsingState like the other filters.

- AnomalyFilter.Kinds + AnomalyBrowsingService in-memory Where clause; feed chip
  row + ToggleKind/KindLabel; en/ru resx (Anomaly.Filter.Kind).
- 2 tests: kind-filtered subset + no-filter returns all kinds.
2026-05-29 11:38:06 +03:00
alexei.dolgolyov 6e12dd73c3 feat(backtest): strategy comparison (head-to-head)
New /anomalies/compare page runs every saved strategy preset over the same
window and ranks them by ROI — bets, W–L, hit-rate, net, and max drawdown side
by side, with the best ROI flagged. Auto-runs on load with an optional date-range refine.

- CompareStrategiesUseCase fans RunBacktestUseCase over saved presets (re-loads the
  anomaly set per preset — fine for the handful a user keeps; stays bug-for-bug
  identical to a single backtest run).
- StrategyComparisonService.BuildVm (pure) computes per-row hit-rate + a single
  best-by-ROI flag; nav entry + en/ru resx.
- 6 tests: use-case fan-out + BuildVm best/tie/no-bets/hit-rate.
2026-05-29 11:32:01 +03:00
alexei.dolgolyov e60b5bf57e docs: capture H-series learnings in project memory
Recurring Issues & Patterns: EF migration generation via dotnet ef + the
migrations-remove snapshot hazard, Sports.Code ValueGeneratedNever, validated
get-only record props blocking `with` (CS0200), and the JsonSettingsWriter
section-replace secret-clobbering gotcha + UI-mirror-options pattern.

New "Analysis Hardening (H-series)" learnings: the 4-detector fan-out +
IsDirectional() gate, SavedStrategy NOCASE name collation, and the config-gated
paper-trading worker.
2026-05-29 11:18:52 +03:00
alexei.dolgolyov 76306ef59b feat(settings): forward-test (paper-trading) settings section
Surfaces the PaperTradingWorker's config on the Settings page — Enabled toggle,
min-score, flat-stake, and poll interval — so forward-testing can be switched on
and tuned from the UI instead of hand-editing committed appsettings.json.

Uses the established UI-mirror-options pattern (PaperTradingSettingsForm bound to
the "PaperTrading" section, same as the UI WorkerOptions mirror) and writes through
the existing ISettingsWriter to appsettings.Local.json. Notifications is deliberately
NOT surfaced: its section holds the Telegram secret and the section-replace writer
would clobber the token — that section stays Local.json-only by design.

- PaperTradingSettingsForm (no secrets) + DI binding; Settings section + en/ru resx.
2026-05-29 02:38:20 +03:00
alexei.dolgolyov 39aef449f7 feat(paper-trading): forward-test results page + worker hardening
Adds the read-only paper-trading page (/paper-trading): settled-only P&L KPIs
(net profit, ROI, hit rate, open count) plus a per-bet ledger table, with a
Forward-test nav entry under Analysis. PaperTradingService batches the
event-title join (no N+1) and folds settled bets into the summary.

Also hardens PaperTradingWorker (review finding): settle now runs in its own
catch so a transient settle failure can't advance the since-marker past an
open window — the window replays until its opens succeed.

- IPaperTradingService / PaperTradingService / PaperTradingVm + PaperBetRowVm.
- en/ru resx (full parity), service registration, nav entry.
- 2 service tests: empty ledger + settled-only aggregation incl. title fallback.
2026-05-29 02:33:42 +03:00
alexei.dolgolyov f622dadf95 feat(paper-trading): forward-test ledger engine
Adds a background forward-test engine that records flat-stake "paper" bets
for directional anomalies as they fire and settles them when results arrive,
measuring the detector's live, out-of-sample edge — the antidote to backtest
overfitting. The results UI is a follow-up.

- Domain: PaperBet entity (Rate>1 / Stake>0 invariants, Open factory,
  SettleAgainst — Won pays stake x rate, else Lost) + AnomalyEvidenceSide.RateFor.
- Application: OpenPaperBetsUseCase (directional + score gate, dedups by
  AnomalyId, picks the post-flip favourite and its locked-in rate) and
  SettlePaperBetsUseCase (Won when pick == winner else Lost; ungraded events
  stay open; batched result lookup).
- Infrastructure: PaperBetEntity + config (TEXT decimals, unique AnomalyId index,
  Outcome index), repository, mapping, additive AddPaperBets migration, and
  PaperTradingWorker (config-gated, baseline since-marker, open+settle per cycle).
- Config: PaperTradingOptions / appsettings PaperTrading (Enabled:false default).
- 25 tests: domain settlement, both use cases, and a real-SQLite round-trip
  incl. the unique-AnomalyId double-open backstop.
2026-05-29 02:25:54 +03:00
alexei.dolgolyov 2a0ea7b3a6 feat(backtest): saved strategy presets (strategy editor v1)
Persist named backtest-strategy presets so a staking config (bankroll,
min-score, stake rule, flat/percent/Kelly params) can be saved, listed,
loaded back into the form, and deleted. The per-run date range is not
part of a preset.

- Domain: SavedStrategy record (name trimmed + bounded to 80 chars,
  Create() factory) wrapping the pure BacktestStrategy.
- Persistence: SavedStrategyEntity + config (TEXT decimals, unique
  case-insensitive NOCASE index on Name), repository, mapping, and a
  hand-trimmed AddSavedStrategies migration (additive — only the new
  table). Case-insensitive names mean save-by-name overwrites instead of
  creating near-duplicates.
- Application: SaveStrategyUseCase (upsert by name, keeps Id+CreatedAt) +
  DeleteStrategyUseCase.
- UI: presets panel on the Backtest page (load/save/delete) + service
  methods; fraction<->percent round-trip; en/ru resx.
- Fix: pin Sports.Code as ValueGeneratedNever — it is the bookmaker's
  natural sport id, not an autoincrement surrogate. Corrects long-standing
  model-snapshot drift; the snapshot is regenerated to match the DB.
- 25 tests across all four layers: domain validation, real-SQLite
  round-trip incl. case-insensitive lookup/uniqueness, the upsert use
  case, and the service percent mapping.
2026-05-29 02:13:16 +03:00
alexei.dolgolyov 115872aad0 feat(anomaly): overround-compression detector
Adds the 4th IAnomalyDetector: flags a sharp drop in the bookmaker's
overround (raw implied-probability sum / margin) over a continuous live
window. Informational/non-directional — excluded from outcome grading
and backtest staking via AnomalyKind.IsDirectional().

- OverroundCompressionDetector: sliding-window scan mirroring SteamMove,
  score = min(1, compression / 0.10 reference), suspension-gap aware.
- MatchWinEvidence.Probabilities gains Overround (pre-normalisation
  margin); evidence JSON shape unchanged.
- Wired into DetectAnomaliesUseCase fan-out; AnomalyOptions + appsettings
  (window 120s, threshold 0.02); en/ru resx + KindLabel arms.
- 11 detector tests incl. score saturation/scaling, inclusive threshold
  boundary, and a three-way (draw-leg) overround case.
2026-05-29 01:46:56 +03:00
alexei.dolgolyov 5eb3dec24b feat(ops): pipeline-health dashboard
- Add a /health ops page: snapshot freshness (last-capture-at, colour-coded), 24h vs
  total snapshots + anomalies, events tracked, sports covered, and the four worker
  on/off states. Nav entry under System; localized en+ru.
- New IPipelineHealthService + ISnapshotRepository.GetLatestCapturedAtAsync (max
  CapturedAt via indexed ORDER BY/LIMIT 1), with a real-SQLite round-trip test.
2026-05-29 01:32:41 +03:00
alexei.dolgolyov b67030ae7f test+chore: real-SQLite query coverage, batch detect writes, finish date centralization
Review follow-ups:
- (HIGH) Add real-SQLite round-trip tests for the new query methods so the load-bearing
  lexical O-format date ordering is verified, not just mocked: Anomaly
  ListByDateRange/CountSince, Snapshot CountSince/ListByEvents grouping, Event Query/GetMany.
- (MED) DetectAnomaliesUseCase: one SaveChanges per event instead of per anomaly.
- (LOW) Route PlacedBetRepository + ExcelExporter date bounds through SqliteDateText.
- (LOW) Backtest: reject a one-sided date range (was silently ignored).
- (LOW) Refresh stale comments after the detector fan-out.
2026-05-29 01:25:25 +03:00
alexei.dolgolyov c9eee9f907 fix(anomaly): exclude non-directional kinds from grading and backtest
Review follow-up (HIGH): the three detectors fed the same evaluator/backtest, but
SuspensionFreeze is non-directional (favourite unchanged) — grading it as "favourite
won" polluted the hit-rate with the base favourite-win rate, and its high frozen-ness
score always cleared the backtest threshold.

- Add AnomalyKind.IsDirectional() (flip + steam = true, freeze = false).
- AnomalyOutcomeEvaluator returns Unresolved for non-directional kinds (favourites
  still surfaced for display) so they don't distort calibration.
- RunBacktestUseCase skips non-directional anomalies when building candidates.
- Tests for the classification, the evaluator path, and the backtest skip.
2026-05-29 01:25:16 +03:00
alexei.dolgolyov e307a54bec harden(notifications): per-item marker advance + Telegram client timeout
Review follow-ups: advance the dispatcher's "since" marker after each delivered
alert (not once per batch) so a future throwing sink can't re-deliver already-sent
alerts; give the Telegram HttpClient a 15s timeout so a hung connection can't stall
the dispatch loop.
2026-05-29 01:08:02 +03:00
alexei.dolgolyov 68f3229c35 feat(anomaly): suspension-freeze detector
- Add SuspensionFreezeDetector via the IAnomalyDetector seam: a suspension gap with
  the favourite unchanged and a negligible (< threshold) price move — the mirror of
  the flip. Score = how completely the line froze. Reuses MatchWinEvidence so UI +
  evaluator handle it unchanged. 6 tests.
- Add AnomalyKind.SuspensionFreeze + localized card/detail label, SuspensionFreezeThreshold
  option, and fan it into DetectAnomaliesUseCase.
2026-05-29 01:03:47 +03:00
alexei.dolgolyov 005d4e794a feat(notifications): config-gated Telegram anomaly alerts
- INotificationSink + AnomalyNotification (Application) and a testable
  GetPendingAnomalyNotificationsUseCase (date+score filter, event-title join,
  oldest-first). 4 use-case tests.
- TelegramNotificationSink posts to the Bot API via HttpClient (no SDK dependency);
  no-ops with a warning when unconfigured and never logs the token.
- AnomalyNotificationDispatcher BackgroundService: startup-baselined marker advanced
  past the newest sent (gap- and dup-free); idles until Notifications:Enabled.
- Wire options + named client + sink + dispatcher in InfrastructureModule. Add a
  secret-free Notifications section + steam-move tuning to appsettings.json
  (bot token + chat id go in appsettings.Local.json only).
2026-05-29 00:59:57 +03:00
alexei.dolgolyov 2e53dff853 feat(settings): validate BaseUrl + cron on save, add BaseUrl hint
- Reject a non-absolute / non-http(s) BaseUrl and an implausible (not 5- or
  6-field) cron expression before the section is written to disk, mirroring the
  existing storage-path validation (snackbar + early return).
- Add a hint to the BaseUrl field. Cron check is a lightweight UI guard; the
  worker still does the authoritative Cronos parse at startup.
2026-05-29 00:50:49 +03:00
alexei.dolgolyov e5cd2ab30c feat(backtest): optional date-range window
- RunBacktestUseCase gains an ExecuteAsync(strategy, DateRange?, ct) overload that
  pushes the date filter to SQL via IAnomalyRepository.ListByDateRangeAsync; the
  existing no-range overload is preserved. +1 use-case test.
- BacktestForm carries optional From/To (Moscow dates) with From<=To validation and
  a ToDateRange() helper; BacktestService threads it through. Backtest page gains two
  clearable date pickers (empty = all anomalies).
- Localization (en+ru) for the backtest date fields and the settings-validation keys
  (shared resx).
2026-05-29 00:50:43 +03:00
alexei.dolgolyov d9d92ea8fd feat(ui): event autocomplete + log-bet deep link, steam-move label
- MyBets: add a "Find event" MudAutocomplete over upcoming events (loaded once,
  filtered client-side) that fills the Event ID; the manual ID field stays as a
  fallback. Backed by IBetJournalService.GetUpcomingEventOptionsAsync.
- Add a "Log bet" CTA on the anomaly detail page that deep-links to
  /my-bets?eventId=<code>; the journal prefills the Event ID from the query.
- Render the new SteamMove anomaly kind with a localized label in the card and
  detail KindLabel switches (was falling through to the raw enum name).
- Localization (en+ru) for all new strings.
2026-05-28 23:08:56 +03:00
alexei.dolgolyov 2b1025cae3 feat(anomaly): IAnomalyDetector seam + steam-move detector
- Introduce IAnomalyDetector; the existing flip detector implements it.
- Extract MatchWinEvidence so every detector writes the identical pre/post
  evidence shape — the UI parser and outcome evaluator handle new kinds with no
  branching (steam moves get hit-rate calibrated for free).
- Add SteamMoveDetector: flags a rapid one-directional implied-probability rise
  over a short CONTINUOUS window (no suspension gap inside it), so it never
  double-flags the same interval as the suspension-flip detector.
- DetectAnomaliesUseCase fans out over both detectors; dedup keys on EventId+Kind
  so flip and steam signals persist independently. Add AnomalyKind.SteamMove +
  SteamMove window/threshold options. 8 detector tests.
2026-05-28 22:59:12 +03:00
alexei.dolgolyov 4dae9e8d0d feat(ui): promote Excel export to a top-level destination
- Add an /export hub page that hosts the existing (date-range, not event-specific)
  ExportDialog, so export is no longer reachable only by opening an event detail.
- Add an Export entry under the System nav section.
2026-05-28 22:46:38 +03:00
alexei.dolgolyov 0e3c4b8d47 feat: Kelly criterion stake sizing (domain + MyBets helper)
- Add KellyCalculator (Domain/Betting): pure fractional-Kelly stake from win
  probability, decimal odds, bankroll, and fraction (default quarter-Kelly).
  Returns 0 on non-positive edge; truncates the suggestion down to 2 decimals
  so it never exceeds the computed figure. 19 unit tests.
- MyBets: add a page-local stake helper (bankroll + win-probability inputs) that
  suggests a quarter-Kelly stake from the form's rate, with an Apply button and a
  no-edge message. Win probability is user-supplied, not derived from a signal.
- Localization (en+ru) for the Kelly helper and the export-hub keys (shared resx).
2026-05-28 22:46:33 +03:00
alexei.dolgolyov 250a93e718 feat(ui): live dashboard, capture-status pill, bet/backtest UX
- Add IDashboardSummaryService/DashboardSummaryService: real event/snapshot/
  anomaly counts, top-5 signals, and per-stage pipeline health from worker state.
- Home: replace hard-coded zeros + placeholder feed with live data, a clickable
  signal feed, and a first-run empty state with a Settings CTA.
- MainLayout: add an appbar capture-status pill (Capturing/Paused) bound to the
  poller toggles, refreshed via IOptionsMonitor.OnChange.
- MyBets: success snackbar on bet submit. Backtest: surface a Cancel button
  while a run is in flight.
- Add en/ru localization for all new strings; register IOptionsMonitor<WorkerOptions>
  in the bUnit test context for layout-rendering tests.
2026-05-28 22:34:28 +03:00
alexei.dolgolyov 0501f9c39c refactor: log silenced UI errors, fix timer leak, narrow exception catch
- EventListShell: detach the Elapsed handler before disposing the refresh timer
  (both StartTimer and Dispose) to stop a leaked subscription firing on a
  torn-down component; log the two previously-silent catches.
- Insights: log the previously-silent report-load catch.
- EventOddsParser: narrow catch(Exception) to catch(ArgumentException) so only
  the OddsRate/OddsValue/Bet guard-clause throws are swallowed.
- AnomalyEvidenceData: make the JSON DTOs init-only per the immutability convention.
- Settings: remove a dead DialogParameters block.
2026-05-28 22:34:17 +03:00
alexei.dolgolyov f294255f10 perf: batch repository reads, index snapshots, centralize date encoding
- Add IEventRepository/IResultRepository.GetManyAsync to kill N+1 lookups at
  6 sites (backtest, outcome eval, both bet-journal paths, anomaly browsing,
  results selection); guarded by a Received(1).GetManyAsync test.
- Add EventRepository.QueryAsync to push date+sport filtering to SQL (was
  load-whole-range-then-filter); search/sort stay in-memory for Cyrillic order.
- Add AnomalyRepository.CountSinceAsync (unread badge) + ListByDateRangeAsync
  (feed date filter); add Event/Snapshot count methods for the dashboard.
- Add composite indexes IX_Snapshots_EventCode_CapturedAt and
  _EventCode_Source_CapturedAt via a new migration + model snapshot.
- Introduce SqliteDateText as the single source of the O-format date encoding
  shared by Mapping (read/write) and the repositories' range predicates.
- Fix LiveOddsPoller cadence drift (budget sleep against cycle time); make
  DetectAnomalies dedup O(1) per event; add Event.Title to dedup the title join.

Tests adapted to the batched GetManyAsync via a TestFixtures bridge.
2026-05-28 22:34:08 +03:00
alexei.dolgolyov 0d52b7beff feat(backtest): historical strategy backtester
Adds an interactive backtester that replays the SuspensionFlip detector over
all flagged anomalies under a chosen score threshold and staking rule
(flat / percent-of-bankroll / Kelly), and reports the headline numbers a
user needs to judge edge: final bankroll, ROI, max drawdown (peak-to-trough),
win/loss streaks, plus per-bet equity curve.

Domain (pure):
- StakeRule enum + BacktestStrategy params (with validation).
- BacktestSimulator: deterministic function taking strategy + chronological
  candidates → BacktestResult. Implements Kelly with post-flip implied prob
  as p (skipping negative-edge bets), peak-to-trough drawdown tracking, and
  win/loss streak rollups. Mirrors AnomalyOutcomeEvaluator on the 2-way Draw
  guard so tennis data inconsistencies are refused rather than miss-counted.
- Skipped counter split into SkippedByThreshold / SkippedByDataQuality /
  SkippedByBankroll so the UI can distinguish "strategy choice" from
  "data-quality" from "bankroll empty".

Application:
- RunBacktestUseCase: loads anomalies + events + results, parses evidence,
  builds candidates, hands event titles into the simulator so the UI does
  zero repository round-trips of its own.

UI:
- Pages/Anomalies/Backtest.razor: hero, strategy form (MudBlazor — conditional
  sub-field per staking rule), 4-card KPI strip (final bankroll / net profit
  / ROI / max drawdown), counters row, inline-SVG equity curve, trade-trace
  table with per-bet outcome pills and link-back to the source anomaly.
- Nav entry under Analysis. RU + EN i18n.

Tests: +20 (16 simulator math — flat / percent compounding / Kelly +/-
edge / quarter-Kelly / bankroll-exceeded / out-of-order chronology / Draw
favourite / multi-window drawdown / event-title pass-through + 4 use-case
join). All 399 tests pass.

Money rounding switched to MidpointRounding.AwayFromZero throughout the
simulator output for accounting convention.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 18:34:42 +03:00
alexei.dolgolyov 1ad896b07e feat(my-bets): personal bet journal with CLV tracking
Adds a manual bet-tracking journal that turns the analyzer into an actual
bet tracker. Users record wagers; the journal auto-grades them when event
results land and computes per-bet Closing-Line-Value against the latest
pre-match snapshot — the strongest long-run indicator of betting skill.

Domain:
- PlacedBet entity (reuses Bet vocabulary for Scope/Type/Side/Value/Rate)
  with stake, placed-at, outcome, and notes. Derived GrossReturn / NetProfit.
- BetOutcome enum (Pending / Won / Lost / Void).
- BetOutcomeResolver: pure function grading any Match-scope bet against an
  EventResult. Handles 1X2, draws, handicap (incl. push), and totals.
  Period-scope bets stay manual since EventResult only carries full-time.

Application:
- IPlacedBetRepository abstraction.
- ClosingLineValueCalculator: pure CLV math (implied-probability delta) +
  snapshot-matching predicate by Scope/Type/Side/Value.
- BetJournalReport + BetJournalStats records.
- Four use cases: Record / ResolvePending / BuildReport / Delete.
- New ISnapshotRepository.GetLatestPreMatchAsync pushes the closing-line
  pick into a single SQLite query rather than materialising the 30-day
  window in memory per event.
- ROI turnover excludes Void stakes — pushes are not real turnover and
  including them would dilute the user's edge.

Infrastructure:
- PlacedBetEntity / Configuration / Repository / Mapping helpers.
- 20260516 migration adding the PlacedBets table with EventCode and
  Outcome indices. Intentionally NO foreign key to Events — the journal
  is user data and must survive snapshot-retention pruning. Covered by an
  explicit round-trip test.

UI:
- Pages/MyBets/Journal.razor: hero header, 4-card KPI strip (ROI / strike
  rate / avg CLV / net profit, tinted by tone), inline add-bet form with
  the same invariants as the Bet record, drill-down table with per-row
  outcome pills, CLV percentage-points column, P&L, notes underline, and
  inline-confirm delete. RU + EN i18n.
- Nav entry under Analysis.

Tests: +55 across Domain / Application / Infrastructure (resolver math
including handicap push and total push boundaries, PlacedBet invariants
and derived properties, CLV math + null-handling, four use cases under
NSubstitute, EF round-trip including survives-event-deletion). All 379
tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 17:45:42 +03:00
alexei.dolgolyov 292223174c feat(insights): anomaly outcome validator — hit-rate calibration page
Adds a calibration dashboard that joins persisted SuspensionFlip anomalies
with EventResult rows and reports whether the post-flip favourite actually
won — the single metric that says whether the detector is doing its job.

Domain:
- AnomalyEvidenceData + AnomalyEvidenceParser to read the JSON written by
  AnomalyDetector without re-implementing the schema.
- AnomalyOutcomeEvaluator: pure function returning Hit / Miss / Unresolved.
  Tennis-style two-way markets with a Draw winner are downgraded to
  Unresolved rather than silently counted as Miss.
- AnomalySeverityThresholds: shared Low/Medium/High constants so the UI
  badge and the report buckets cannot drift.

Application:
- EvaluateAnomalyOutcomesUseCase orchestrates the join + aggregation.
- AnomalyOutcomeReport carries totals, hit rate, three breakdowns
  (severity / sport / score bins) and a per-event title lookup so the UI
  needs no second pass over IEventRepository.
- Score bins extend below 0.30 automatically when the operator lowers the
  detector threshold so the histogram total always equals ResolvedCount.

UI:
- Insights page at /anomalies/insights — hero header, 4-card KPI strip
  (hit rate tinted by tone), three breakdown grids with bar visualisation,
  drill-down tables for resolved and unresolved anomalies. Honors
  prefers-reduced-motion. RU + EN localisation.
- Nav entry under Analysis section + chip button on the Anomaly Feed.

Tests: +42 across Domain + Application (evaluator boundary cases including
tennis two-way and Draw guard, score-bin edges, dynamic floor when
threshold is lowered, event-title pass-through). All 324 tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 13:53:31 +03:00
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
379 changed files with 60703 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
+7
View File
@@ -81,3 +81,10 @@ 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/
# Throwaway debugging scratch (PowerShell dumps, raw page captures)
_dump*.ps1
_pages*.txt
+57
View File
@@ -0,0 +1,57 @@
# vex configuration — https://github.com/tenatarika/vex
#
# Place this file in your project root as .vex.toml
# Glob patterns to exclude from indexing (gitignore syntax, on top of .gitignore)
# exclude = [
# "vendor/**",
# "node_modules/**",
# "*.generated.go",
# "dist/**",
# ]
# Default output format: "text", "json", or "compact"
# format = "text"
# Enable semantic embeddings by default (slower indexing, enables meaning-based search)
semantic = true
# Automatically run `vex update` before search if the index is stale
auto_update = true
# Embedder used for semantic indexing. Known IDs: minilm-l6-v2 (default).
# Changing the embedder requires a full reindex.
# embedder = "minilm-l6-v2"
# Cache directory override. Defaults to the platform cache location.
# macOS: ~/Library/Caches/vex
# Linux: $XDG_CACHE_HOME/vex (fallback: ~/.cache/vex)
# Windows: %LOCALAPPDATA%\vex (fallback: %USERPROFILE%\AppData\Local\vex)
# Accepts absolute paths, "~/..." or paths relative to this file (e.g. "./.vex/cache").
# Can also be overridden per-invocation with --cache-dir or $VEX_CACHE_DIR.
# cache_dir = "./.vex/cache"
# Store the index inside the project as `<project>/.vex_cache/`. Useful when
# the cache should travel with the project (e.g. on a moved or renamed
# directory). vex writes a `.gitignore` inside it so contents are not
# committed. Overridden by `cache_dir`, `--cache-dir`, or $VEX_CACHE_DIR.
# local_cache = false
# Thread count for parallel indexing (index/update/watch).
# * unset — 80% of available cores, rounded up (default, leaves headroom)
# * 0 — use all cores (explicit opt-in to max throughput)
# * N — exactly N workers
# Overridable per-invocation with `-j/--jobs` or $VEX_JOBS.
# jobs = 4
# Build the persistent call-graph section. Disabling falls back to live-scan
# for `vex callers`/`vex callees` (slower per-query, but saves indexing
# time on large monorepos). The opt-out is persisted in the manifest so
# `vex update` does not silently re-add the section.
# Per-invocation override: `vex index --no-call-graph`.
# call_graph = true
# Build the BM25 channel. Disabling drops the third RRF channel and keeps
# only structural (+ semantic). Same persistence rules as `call_graph`.
# Per-invocation override: `vex index --no-bm25`.
# bm25 = true
+143 -1
View File
@@ -102,4 +102,146 @@ 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.
- **EF migrations — generate with `dotnet ef`, do NOT `migrations remove`.**
`MarathonDbContextFactory` is a self-contained design-time factory, so
`dotnet ef migrations add X --project src/Marathon.Infrastructure --startup-project src/Marathon.Infrastructure`
works. AVOID `dotnet ef migrations remove`: older migrations were hand-written without a
Designer snapshot, so remove blanks `MarathonDbContextModelSnapshot.cs` and the next `add`
regenerates the WHOLE schema. Validate a migration on a throwaway DB with
`dotnet ef database update --connection "Data Source=<ABSOLUTE>/data/_migtest.db"` (absolute
path — EF resolves relatives from the build-output dir, not the shell cwd; create `./data/` first).
- **`Sports.Code` is `.ValueGeneratedNever()`** — it's the bookmaker's natural sport id
(6/11/22723…), not an autoincrement surrogate. Without it EF's int-PK convention emits a
spurious AUTOINCREMENT `AlterColumn` on every migration.
- **Validated get-only record properties block `with`** (CS0200): `BacktestStrategy.MinScore`,
`PaperBet.Rate`/`Stake` are re-declared with validation, so build a new instance instead of
`with { ThatProp = … }` (you can still `with` the un-redeclared props, e.g. `SavedStrategy with { Strategy = … }`).
- **`JsonSettingsWriter.SaveSectionAsync` replaces the whole section** (`root[section]=json`),
so a Settings-UI save drops any key not on the form. Never surface a secret-bearing section
(e.g. `Notifications` → Telegram token) in the UI — it would wipe the secret from
`appsettings.Local.json`. To surface a non-secret section, add a mutable mirror options class
in `Marathon.UI.Services` bound to the same section name (UI can't reference Infrastructure's
options types — same pattern as the two `WorkerOptions`).
## 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`.
## Feature: Analysis Hardening (H-series) — Learnings
- **Anomaly detectors are a fan-out array in `DetectAnomaliesUseCase`** — 4 now
(`SuspensionFlip`, `SteamMove`, `SuspensionFreeze`, `OverroundCompression`). A new detector
implements `IAnomalyDetector`, reuses `MatchWinEvidence` for the canonical evidence JSON
(so the parser + outcome evaluator work unchanged), and is added to that array. Continuous
sliding-window detectors (steam, overround) emit at every `end` and skip suspension-sized
gaps so they never overlap the across-suspension flip/freeze detectors.
- **`AnomalyKind.IsDirectional()` gates staking/grading** — flip + steam are directional
(predict a side); freeze + overround are informational and are excluded from the backtest
(`RunBacktestUseCase`) and the outcome evaluator so they don't skew hit-rate/score calibration.
- **`SavedStrategy` (backtest presets) use NOCASE name collation** — the unique index AND
`GetByNameAsync` both fold case (column-level `UseCollation("NOCASE")`), so save-by-name
upserts rather than creating "Kelly"/"kelly" duplicates. Domain stores stake fractions; the
form/VM speak percent — convert ×100/÷100 at the UI boundary.
- **Paper-trading (forward-test) is a config-gated worker** (`PaperTrading:Enabled`, default
false; tunable on the Settings page). `PaperTradingWorker` opens flat-stake `PaperBet`s on
new directional anomalies (unique on `AnomalyId`; baseline since-marker advances only after a
successful open pass; settle runs in its own catch so a settle failure can't strand the
marker) and settles them against results (Won iff pick == winner). `/paper-trading` shows
settled-only P&L. The ledger is FK-free (survives snapshot-retention pruning), like `PlacedBets`.
+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>
+7
View File
@@ -0,0 +1,7 @@
<!doctype html>
<meta charset="utf-8">
<title>Marathon — Redesign Directions</title>
<meta http-equiv="refresh" content="0; url=redesign-mockups.html">
<body style="background:#05060a;color:#aeb8c8;font-family:monospace;padding:40px">
<a href="redesign-mockups.html" style="color:#34e07f">Open Marathon redesign mockups →</a>
</body>
+641
View File
@@ -0,0 +1,641 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Marathon — Redesign Directions</title>
<!-- Distinct type per direction: Archivo+JetBrains Mono / Anton+DM Sans+Space Mono / Outfit+Manrope+IBM Plex Mono -->
<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=Anton&family=Archivo:wght@400;500;600;700;800;900&family=DM+Sans:opsz,wght@9..40,400;9..40,500;9..40,700&family=IBM+Plex+Mono:wght@400;500;600&family=JetBrains+Mono:wght@400;500;700;800&family=Manrope:wght@400;500;600;700;800&family=Outfit:wght@300;400;500;600;700;800&family=Space+Mono:wght@400;700&display=swap" rel="stylesheet" />
<style>
/* ============================================================
META CHROME (not part of any design — the direction switcher)
============================================================ */
*,*::before,*::after{ box-sizing:border-box; }
html,body{ margin:0; padding:0; }
body{ background:#05060a; min-height:100vh; font-family:"JetBrains Mono",monospace; }
a{ text-decoration:none; color:inherit; }
button{ font:inherit; cursor:pointer; border:0; background:none; color:inherit; }
svg{ display:block; }
.ico{ width:18px; height:18px; flex:none; }
.switch{
position:fixed; inset:0 0 auto 0; height:54px; z-index:9999;
display:flex; align-items:center; gap:18px;
padding:0 18px;
background:rgba(8,10,16,.86);
backdrop-filter:blur(14px);
border-bottom:1px solid rgba(255,255,255,.08);
}
.switch__label{
font:600 11px/1 "JetBrains Mono",monospace; letter-spacing:.22em; text-transform:uppercase;
color:#5b6678; white-space:nowrap;
}
.switch__label b{ color:#cdd6e4; font-weight:700; }
.switch__tabs{ display:flex; gap:8px; }
.tab{
display:flex; flex-direction:column; gap:2px;
padding:7px 14px; border-radius:9px;
border:1px solid rgba(255,255,255,.10);
background:rgba(255,255,255,.03);
transition:border-color .18s, background .18s, transform .18s;
}
.tab:hover{ transform:translateY(-1px); border-color:rgba(255,255,255,.24); }
.tab__name{ font:700 12.5px/1 "JetBrains Mono",monospace; letter-spacing:.04em; color:#aeb8c8; }
.tab__tag{ font:500 9.5px/1 "JetBrains Mono",monospace; letter-spacing:.14em; text-transform:uppercase; color:#5b6678; }
.tab[data-for="noir"].is-on{ background:rgba(43,213,118,.12); border-color:rgba(43,213,118,.5); }
.tab[data-for="noir"].is-on .tab__name{ color:#34e07f; }
.tab[data-for="velocity"].is-on{ background:rgba(198,244,0,.16); border-color:rgba(198,244,0,.6); }
.tab[data-for="velocity"].is-on .tab__name{ color:#dcff4a; }
.tab[data-for="aurora"].is-on{ background:rgba(139,124,255,.18); border-color:rgba(139,124,255,.6); }
.tab[data-for="aurora"].is-on .tab__name{ color:#b3a8ff; }
.switch__hint{ margin-left:auto; font:500 10px/1 "JetBrains Mono",monospace; letter-spacing:.16em; text-transform:uppercase; color:#3f4859; }
.stage{ display:none; min-height:100vh; padding-top:54px; position:relative; }
.stage.is-active{ display:block; }
.stage .fx{ position:absolute; inset:54px 0 0 0; pointer-events:none; z-index:0; overflow:hidden; }
.mount{ position:relative; z-index:1; }
@media (prefers-reduced-motion: reduce){
*{ animation-duration:.001ms !important; animation-iteration-count:1 !important; }
}
/* ============================================================
SHARED STRUCTURE (styled differently under each .d-* scope)
============================================================ */
.app{ display:grid; grid-template-rows:auto 1fr; min-height:calc(100vh - 54px); }
.body{ display:grid; grid-template-columns:248px minmax(0,1fr); min-height:0; }
.main{ min-width:0; overflow:auto; }
.bar{ display:flex; align-items:center; gap:14px; padding:0 22px; height:62px; }
.bar__spacer{ flex:1; }
.bar__tools{ display:flex; align-items:center; gap:14px; }
.brand{ display:flex; align-items:baseline; gap:10px; }
.locale{ display:inline-flex; overflow:hidden; }
.nav{ display:flex; flex-direction:column; min-height:0; overflow:auto; }
.nav-link{ display:flex; align-items:center; gap:12px; }
.nav-link .lbl{ flex:1; }
.badge{ display:inline-grid; place-items:center; }
.stats{ display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); }
.stat{ display:flex; flex-direction:column; }
.grid2{ display:grid; grid-template-columns:minmax(0,1.55fr) minmax(0,1fr); }
@media (max-width:1080px){ .grid2{ grid-template-columns:1fr; } .stats{ grid-template-columns:repeat(2,1fr); } }
.feed{ display:flex; flex-direction:column; }
.signal{ display:grid; }
.sig-mkts{ display:flex; flex-wrap:wrap; }
.mkt{ display:inline-flex; align-items:baseline; }
.pipe{ list-style:none; margin:0; padding:0; display:flex; flex-direction:column; }
.step{ display:flex; align-items:center; }
.sporticon{ display:inline-grid; place-items:center; flex:none; font-weight:700; }
@keyframes rise{ from{ opacity:0; transform:translateY(10px);} to{ opacity:1; transform:none;} }
@keyframes pulse{ 0%,100%{ opacity:.35; transform:scale(1);} 50%{ opacity:1; transform:scale(1.25);} }
/* ════════════════════════════════════════════════════════════
DIRECTION 1 — TERMINAL NOIR (dark trading terminal)
Archivo (heavy numerals) + JetBrains Mono. Neon-on-black, grid.
════════════════════════════════════════════════════════════ */
.d-noir{
--bg:#070a0f; --bg2:#0b0f16; --card:#0d1219; --card2:#10161f;
--line:#1b2330; --line2:#28303d;
--ink:#e7eef6; --mut:#828d9e; --dim:#5a6475;
--grn:#2bd576; --red:#ff5a6e; --cyan:#36e0ff; --amber:#ffb02e; --signal:#ff4d5e;
background:var(--bg); color:var(--ink);
font-family:"JetBrains Mono",monospace;
}
.d-noir .fx{
background:
radial-gradient(900px 480px at 78% -6%, rgba(54,224,255,.07), transparent 60%),
radial-gradient(760px 520px at 4% 108%, rgba(43,213,118,.06), transparent 60%),
linear-gradient(transparent 95%, rgba(255,255,255,.022) 95%),
linear-gradient(90deg, transparent 95%, rgba(255,255,255,.022) 95%);
background-size:auto,auto,38px 38px,38px 38px;
}
.d-noir .bar{ background:linear-gradient(180deg,#0c1118,#0a0e14); border-bottom:1px solid var(--line); }
.d-noir .bar__menu{ color:var(--mut); display:grid; place-items:center; width:34px; height:34px; border:1px solid var(--line); border-radius:6px; }
.d-noir .bar__menu:hover{ color:var(--ink); border-color:var(--line2); }
.d-noir .brand__mark{ font:800 19px/1 "Archivo",sans-serif; letter-spacing:-.01em; }
.d-noir .brand__mark::first-letter{ color:var(--grn); }
.d-noir .brand__sub{ font:500 10px/1 "JetBrains Mono"; letter-spacing:.2em; text-transform:uppercase; color:var(--dim); border-left:1px solid var(--line2); padding-left:10px; }
.d-noir .capture{ display:inline-flex; align-items:center; gap:8px; font:600 10.5px/1 "JetBrains Mono"; letter-spacing:.16em; text-transform:uppercase; color:var(--grn); padding:6px 10px; border:1px solid rgba(43,213,118,.35); border-radius:6px; background:rgba(43,213,118,.07); }
.d-noir .capture__dot{ width:7px; height:7px; border-radius:50%; background:var(--grn); box-shadow:0 0 10px var(--grn); animation:pulse 1.6s ease-in-out infinite; }
.d-noir .locale{ border:1px solid var(--line2); border-radius:6px; }
.d-noir .locale__btn{ padding:6px 11px; font:600 10.5px/1 "JetBrains Mono"; letter-spacing:.12em; color:var(--dim); }
.d-noir .locale__btn+.locale__btn{ border-left:1px solid var(--line2); }
.d-noir .locale__btn.is-active{ background:var(--grn); color:#04130b; }
.d-noir .theme{ width:34px; height:34px; display:grid; place-items:center; border:1px solid var(--line); border-radius:6px; color:var(--mut); }
.d-noir .theme:hover{ color:var(--cyan); border-color:var(--line2); }
.d-noir .nav{ background:var(--bg2); border-right:1px solid var(--line); padding:14px 12px; gap:2px; }
.d-noir .nav__brandblock{ padding:8px 10px 16px; }
.d-noir .nav__group{ font:600 10px/1 "JetBrains Mono"; letter-spacing:.24em; text-transform:uppercase; color:var(--dim); padding:18px 10px 8px; }
.d-noir .nav-link{ padding:9px 11px; border-radius:7px; color:var(--mut); font:500 13px/1 "JetBrains Mono"; border-left:2px solid transparent; transition:background .14s,color .14s; }
.d-noir .nav-link svg{ color:var(--dim); }
.d-noir .nav-link:hover{ background:rgba(255,255,255,.04); color:var(--ink); }
.d-noir .nav-link.is-active{ background:rgba(43,213,118,.10); color:#eafff3; border-left-color:var(--grn); }
.d-noir .nav-link.is-active svg{ color:var(--grn); }
.d-noir .badge{ min-width:18px; height:18px; padding:0 5px; border-radius:5px; background:var(--signal); color:#fff; font:700 10px/18px "JetBrains Mono"; box-shadow:0 0 12px rgba(255,77,94,.6); }
.d-noir .main{ padding:30px 34px 46px; }
.d-noir .hero{ max-width:760px; animation:rise .5s both; }
.d-noir .kicker{ font:600 10.5px/1 "JetBrains Mono"; letter-spacing:.26em; text-transform:uppercase; color:var(--cyan); }
.d-noir .title{ font:800 clamp(30px,4.4vw,50px)/1.02 "Archivo",sans-serif; letter-spacing:-.02em; margin:14px 0 12px; }
.d-noir .lede{ color:var(--mut); font:400 14px/1.6 "JetBrains Mono"; max-width:62ch; margin:0; }
.d-noir .stats{ gap:14px; margin:30px 0; animation:rise .5s .06s both; }
.d-noir .stat{ background:linear-gradient(180deg,var(--card),var(--card2)); border:1px solid var(--line); border-radius:10px; padding:16px 18px; gap:10px; position:relative; overflow:hidden; }
.d-noir .stat::after{ content:""; position:absolute; left:0; top:0; bottom:0; width:3px; background:var(--cyan); opacity:.5; }
.d-noir .stat--alert::after{ background:var(--signal); opacity:.9; box-shadow:0 0 18px var(--signal); }
.d-noir .stat__label{ font:600 10px/1 "JetBrains Mono"; letter-spacing:.18em; text-transform:uppercase; color:var(--dim); }
.d-noir .stat__value{ font:800 34px/1 "Archivo",sans-serif; letter-spacing:-.02em; font-variant-numeric:tabular-nums; }
.d-noir .stat--alert .stat__value{ color:var(--signal); }
.d-noir .stat__delta{ font:500 11px/1 "JetBrains Mono"; color:var(--grn); }
.d-noir .stat__delta.dn{ color:var(--red); }
.d-noir .stat__delta.flat{ color:var(--dim); }
.d-noir .grid2{ gap:22px; animation:rise .5s .12s both; }
.d-noir .side{ display:flex; flex-direction:column; gap:22px; }
.d-noir .panel{ background:linear-gradient(180deg,var(--card),var(--card2)); border:1px solid var(--line); border-radius:12px; overflow:hidden; }
.d-noir .panel__head{ display:flex; align-items:center; justify-content:space-between; padding:15px 18px; border-bottom:1px solid var(--line); }
.d-noir .panel__more{ font:600 10.5px/1 "JetBrains Mono"; letter-spacing:.14em; text-transform:uppercase; color:var(--cyan); }
.d-noir .panel__more:hover{ text-decoration:underline; }
.d-noir .signal{ grid-template-columns:54px 1fr auto; gap:14px; align-items:center; padding:14px 18px; border-bottom:1px solid var(--line); transition:background .14s; }
.d-noir .signal:last-child{ border-bottom:0; }
.d-noir .signal:hover{ background:rgba(54,224,255,.04); }
.d-noir .signal__time{ font:500 11px/1.4 "JetBrains Mono"; color:var(--dim); }
.d-noir .sporticon{ width:26px; height:26px; border-radius:7px; font:800 11px/1 "Archivo"; }
.d-noir .si-football{ background:rgba(43,213,118,.16); color:var(--grn); }
.d-noir .si-basketball{ background:rgba(255,176,46,.16); color:var(--amber); }
.d-noir .si-tennis{ background:rgba(54,224,255,.16); color:var(--cyan); }
.d-noir .si-hockey{ background:rgba(139,156,255,.18); color:#9db0ff; }
.d-noir .sig-mid{ display:flex; align-items:center; gap:11px; min-width:0; }
.d-noir .sig-teams{ font:500 14px/1.2 "Archivo",sans-serif; color:var(--ink); }
.d-noir .sig-sub{ font:500 10.5px/1 "JetBrains Mono"; letter-spacing:.06em; color:var(--dim); margin-top:4px; }
.d-noir .sig-mkts{ gap:7px; margin-top:9px; }
.d-noir .mkt{ gap:6px; padding:4px 8px; border:1px solid var(--line); border-radius:6px; background:var(--bg); font-family:"JetBrains Mono"; }
.d-noir .mkt__k{ font:700 10px/1; color:var(--dim); }
.d-noir .mkt__pre{ font-size:11.5px; color:var(--mut); text-decoration:line-through; text-decoration-color:var(--line2); }
.d-noir .mkt__arr{ color:var(--dim); font-size:11px; }
.d-noir .mkt__post{ font-weight:700; font-size:12.5px; }
.d-noir .mkt.up .mkt__post{ color:var(--grn); }
.d-noir .mkt.dn .mkt__post{ color:var(--red); }
.d-noir .sig-right{ display:flex; flex-direction:column; align-items:flex-end; gap:8px; }
.d-noir .sev{ font:700 9.5px/1 "JetBrains Mono"; letter-spacing:.14em; text-transform:uppercase; padding:4px 8px; border-radius:5px; border:1px solid currentColor; }
.d-noir .sev--high{ color:var(--signal); background:rgba(255,77,94,.10); }
.d-noir .sev--medium{ color:var(--amber); background:rgba(255,176,46,.10); }
.d-noir .sev--low{ color:var(--dim); background:rgba(255,255,255,.03); }
.d-noir .score{ display:flex; align-items:center; gap:8px; }
.d-noir .score__bar{ width:60px; height:5px; border-radius:3px; background:var(--line2); overflow:hidden; }
.d-noir .score__fill{ height:100%; background:linear-gradient(90deg,var(--amber),var(--signal)); }
.d-noir .score__n{ font:800 13px/1 "Archivo"; font-variant-numeric:tabular-nums; }
.d-noir .pipe{ padding:8px 10px; }
.d-noir .step{ gap:13px; padding:11px 8px; border-bottom:1px dashed var(--line); }
.d-noir .step:last-child{ border-bottom:0; }
.d-noir .step__idx{ font:800 12px/1 "Archivo"; color:var(--dim); width:24px; }
.d-noir .step__lbl{ flex:1; font:500 13px/1.2 "JetBrains Mono"; }
.d-noir .step__lbl small{ display:block; color:var(--dim); font-size:10.5px; margin-top:3px; letter-spacing:.06em; }
.d-noir .dot{ width:8px; height:8px; border-radius:50%; }
.d-noir .dot--ok{ background:var(--grn); box-shadow:0 0 9px var(--grn); }
.d-noir .dot--run{ background:var(--cyan); box-shadow:0 0 9px var(--cyan); animation:pulse 1.4s infinite; }
.d-noir .dot--idle{ background:var(--line2); }
.d-noir .concept{ padding:18px; }
.d-noir .concept h4{ margin:0 0 6px; font:800 16px/1.1 "Archivo"; }
.d-noir .concept p{ margin:0 0 12px; font:400 12px/1.6 "JetBrains Mono"; color:var(--mut); }
.d-noir .concept .meta{ font:500 10px/1.5 "JetBrains Mono"; letter-spacing:.1em; text-transform:uppercase; color:var(--cyan); }
/* ════════════════════════════════════════════════════════════
DIRECTION 2 — VELOCITY (neo-brutalist sportsbook)
Anton (slammed caps) + DM Sans + Space Mono. Acid lime, hard shadows.
════════════════════════════════════════════════════════════ */
.d-velocity{
--paper:#f3f1e9; --paper2:#fffef8; --ink:#0a0a0a; --ink2:#26241e;
--lime:#c6f400; --blue:#244bff; --red:#ff3b30; --amber:#ff8a00;
--shadow:6px 6px 0 var(--ink);
background:var(--paper); color:var(--ink);
font-family:"DM Sans",sans-serif;
}
.d-velocity .fx{
background:
repeating-linear-gradient(135deg, transparent 0 22px, rgba(10,10,10,.025) 22px 24px);
}
.d-velocity .bar{ background:var(--ink); color:var(--paper2); height:64px; border-bottom:3px solid var(--ink); }
.d-velocity .bar__menu{ width:38px; height:38px; display:grid; place-items:center; border:2px solid var(--paper2); border-radius:9px; color:var(--paper2); }
.d-velocity .bar__menu:hover{ background:var(--lime); color:var(--ink); border-color:var(--lime); }
.d-velocity .brand__mark{ font:400 26px/1 "Anton",sans-serif; letter-spacing:.02em; text-transform:uppercase; color:var(--paper2); }
.d-velocity .brand__mark::first-letter{ color:var(--lime); }
.d-velocity .brand__sub{ font:700 10px/1 "Space Mono"; letter-spacing:.18em; text-transform:uppercase; color:var(--lime); }
.d-velocity .capture{ display:inline-flex; align-items:center; gap:8px; font:700 10.5px/1 "Space Mono"; letter-spacing:.1em; text-transform:uppercase; color:var(--ink); background:var(--lime); padding:8px 12px; border-radius:8px; }
.d-velocity .capture__dot{ width:8px; height:8px; border-radius:50%; background:var(--ink); animation:pulse 1.3s infinite; }
.d-velocity .locale{ border:2px solid var(--paper2); border-radius:8px; }
.d-velocity .locale__btn{ padding:7px 11px; font:700 10.5px/1 "Space Mono"; color:var(--paper2); }
.d-velocity .locale__btn+.locale__btn{ border-left:2px solid var(--paper2); }
.d-velocity .locale__btn.is-active{ background:var(--lime); color:var(--ink); }
.d-velocity .theme{ width:38px; height:38px; display:grid; place-items:center; border:2px solid var(--paper2); border-radius:9px; color:var(--paper2); }
.d-velocity .theme:hover{ background:var(--lime); color:var(--ink); border-color:var(--lime); }
.d-velocity .nav{ background:var(--paper2); border-right:3px solid var(--ink); padding:18px 14px; gap:6px; }
.d-velocity .nav__group{ font:700 10px/1 "Space Mono"; letter-spacing:.18em; text-transform:uppercase; color:#9a958a; padding:16px 8px 8px; }
.d-velocity .nav-link{ padding:11px 12px; border:2px solid transparent; border-radius:10px; font:700 13.5px/1 "DM Sans"; color:var(--ink2); }
.d-velocity .nav-link:hover{ border-color:var(--ink); transform:translate(-1px,-1px); box-shadow:3px 3px 0 var(--ink); }
.d-velocity .nav-link.is-active{ background:var(--lime); border-color:var(--ink); box-shadow:var(--shadow); }
.d-velocity .badge{ min-width:20px; height:20px; padding:0 6px; border-radius:6px; background:var(--red); color:#fff; font:700 11px/20px "Space Mono"; border:2px solid var(--ink); }
.d-velocity .main{ padding:30px 34px 50px; }
.d-velocity .hero{ max-width:820px; animation:rise .45s both; }
.d-velocity .kicker{ display:inline-block; font:700 11px/1 "Space Mono"; letter-spacing:.18em; text-transform:uppercase; color:var(--ink); background:var(--lime); padding:6px 10px; border:2px solid var(--ink); border-radius:6px; transform:rotate(-1.5deg); }
.d-velocity .title{ font:400 clamp(44px,7vw,82px)/.92 "Anton",sans-serif; letter-spacing:.005em; text-transform:uppercase; margin:18px 0 14px; }
.d-velocity .title em{ font-style:normal; color:var(--blue); -webkit-text-stroke:2px var(--ink); }
.d-velocity .lede{ color:var(--ink2); font:500 15.5px/1.55 "DM Sans"; max-width:60ch; margin:0; }
.d-velocity .stats{ gap:18px; margin:34px 0; animation:rise .45s .05s both; }
.d-velocity .stat{ background:var(--paper2); border:3px solid var(--ink); border-radius:14px; box-shadow:var(--shadow); padding:18px 20px; gap:8px; position:relative; }
.d-velocity .stat__label{ font:700 10.5px/1 "Space Mono"; letter-spacing:.12em; text-transform:uppercase; color:var(--ink2); }
.d-velocity .stat__value{ font:400 46px/1 "Anton",sans-serif; letter-spacing:.01em; }
.d-velocity .stat__value::after{ content:""; display:block; width:46px; height:6px; background:var(--lime); margin-top:6px; }
.d-velocity .stat--alert{ background:var(--ink); color:var(--paper2); }
.d-velocity .stat--alert .stat__label{ color:var(--lime); }
.d-velocity .stat--alert .stat__value::after{ background:var(--red); }
.d-velocity .stat__delta{ font:700 12px/1 "Space Mono"; color:var(--ink2); }
.d-velocity .stat--alert .stat__delta{ color:var(--red); }
.d-velocity .grid2{ gap:24px; animation:rise .45s .1s both; }
.d-velocity .side{ display:flex; flex-direction:column; gap:24px; }
.d-velocity .panel{ background:var(--paper2); border:3px solid var(--ink); border-radius:16px; box-shadow:var(--shadow); overflow:hidden; }
.d-velocity .panel__head{ display:flex; align-items:center; justify-content:space-between; padding:16px 20px; border-bottom:3px solid var(--ink); background:var(--lime); }
.d-velocity .panel__head .kicker{ transform:none; background:var(--ink); color:var(--lime); }
.d-velocity .panel__more{ font:700 11px/1 "Space Mono"; letter-spacing:.1em; text-transform:uppercase; color:var(--ink); }
.d-velocity .panel__more:hover{ text-decoration:underline; }
.d-velocity .signal{ grid-template-columns:auto 1fr auto; gap:16px; align-items:center; padding:16px 20px; border-bottom:2px solid var(--ink); transition:background .12s; }
.d-velocity .signal:last-child{ border-bottom:0; }
.d-velocity .signal:hover{ background:#faf7e8; }
.d-velocity .sporticon{ width:46px; height:46px; border-radius:11px; border:2px solid var(--ink); font:400 17px/1 "Anton"; }
.d-velocity .si-football{ background:var(--lime); }
.d-velocity .si-basketball{ background:var(--amber); color:#fff; }
.d-velocity .si-tennis{ background:#34d6c0; }
.d-velocity .si-hockey{ background:var(--blue); color:#fff; }
.d-velocity .sig-time{ font:700 11px/1 "Space Mono"; color:#8c887d; margin-bottom:5px; }
.d-velocity .sig-teams{ font:400 21px/1 "Anton",sans-serif; text-transform:uppercase; letter-spacing:.01em; }
.d-velocity .sig-mkts{ gap:9px; margin-top:11px; }
.d-velocity .mkt{ gap:6px; padding:5px 9px; border:2px solid var(--ink); border-radius:7px; font-family:"Space Mono"; background:var(--paper); }
.d-velocity .mkt__k{ font:700 11px/1; color:#8c887d; }
.d-velocity .mkt__pre{ font-size:12px; color:#8c887d; text-decoration:line-through; }
.d-velocity .mkt__arr{ color:var(--ink); }
.d-velocity .mkt__post{ font-weight:700; font-size:14px; }
.d-velocity .mkt.up{ background:var(--lime); }
.d-velocity .mkt.dn{ background:#ffe2df; }
.d-velocity .mkt.dn .mkt__post{ color:var(--red); }
.d-velocity .sig-right{ display:flex; flex-direction:column; align-items:flex-end; gap:9px; }
.d-velocity .sev{ font:700 10px/1 "Space Mono"; letter-spacing:.1em; text-transform:uppercase; padding:6px 10px; border:2px solid var(--ink); border-radius:7px; }
.d-velocity .sev--high{ background:var(--red); color:#fff; }
.d-velocity .sev--medium{ background:var(--amber); color:var(--ink); }
.d-velocity .sev--low{ background:var(--paper); color:var(--ink2); }
.d-velocity .score__n{ font:400 30px/1 "Anton"; }
.d-velocity .score__bar{ display:none; }
.d-velocity .pipe{ padding:10px 14px; }
.d-velocity .step{ gap:14px; padding:13px 8px; border-bottom:2px dashed var(--ink); }
.d-velocity .step:last-child{ border-bottom:0; }
.d-velocity .step__idx{ font:400 24px/1 "Anton"; color:var(--ink); width:34px; }
.d-velocity .step__lbl{ flex:1; font:700 14px/1.2 "DM Sans"; }
.d-velocity .step__lbl small{ display:block; font:700 11px/1 "Space Mono"; color:#8c887d; margin-top:4px; letter-spacing:.05em; }
.d-velocity .dot{ width:13px; height:13px; border:2px solid var(--ink); border-radius:4px; }
.d-velocity .dot--ok{ background:var(--lime); }
.d-velocity .dot--run{ background:var(--blue); animation:pulse 1.3s infinite; }
.d-velocity .dot--idle{ background:var(--paper); }
.d-velocity .concept{ padding:20px; }
.d-velocity .concept h4{ margin:0 0 8px; font:400 26px/.95 "Anton"; text-transform:uppercase; }
.d-velocity .concept p{ margin:0 0 12px; font:500 13px/1.55 "DM Sans"; color:var(--ink2); }
.d-velocity .concept .meta{ font:700 10.5px/1.5 "Space Mono"; letter-spacing:.08em; text-transform:uppercase; color:var(--ink); background:var(--lime); display:inline-block; padding:4px 8px; border:2px solid var(--ink); border-radius:6px; }
/* ════════════════════════════════════════════════════════════
DIRECTION 3 — AURORA (refined premium fintech, dark glass)
Outfit + Manrope + IBM Plex Mono. Indigo glass, aurora glow.
════════════════════════════════════════════════════════════ */
.d-aurora{
--bg:#0a0d1a; --glass:rgba(255,255,255,.045); --glass2:rgba(255,255,255,.07);
--line:rgba(255,255,255,.09); --line2:rgba(255,255,255,.16);
--ink:#eef1fb; --mut:#9aa3c0; --dim:#6b7494;
--violet:#8b7cff; --teal:#3dd6c4; --coral:#ff7a8a; --gold:#ffce6b; --grn:#5fe0a0;
background:var(--bg); color:var(--ink);
font-family:"Manrope",sans-serif;
}
.d-aurora .fx::before,.d-aurora .fx::after{ content:""; position:absolute; border-radius:50%; filter:blur(90px); opacity:.5; }
.d-aurora .fx::before{ width:620px; height:620px; left:-120px; top:-140px; background:radial-gradient(circle,#5a47d6,transparent 70%); animation:drift1 22s ease-in-out infinite alternate; }
.d-aurora .fx::after{ width:560px; height:560px; right:-100px; bottom:-160px; background:radial-gradient(circle,#1f8f88,transparent 70%); animation:drift2 26s ease-in-out infinite alternate; }
@keyframes drift1{ to{ transform:translate(80px,60px) scale(1.1);} }
@keyframes drift2{ to{ transform:translate(-70px,-50px) scale(1.15);} }
.d-aurora .fx .glow3{ position:absolute; width:420px; height:420px; left:46%; top:30%; border-radius:50%; filter:blur(100px); opacity:.32; background:radial-gradient(circle,#b06bff,transparent 70%); }
.d-aurora .bar{ background:rgba(12,16,30,.6); backdrop-filter:blur(16px); border-bottom:1px solid var(--line); height:64px; }
.d-aurora .bar__menu{ width:36px; height:36px; display:grid; place-items:center; border:1px solid var(--line); border-radius:11px; color:var(--mut); background:var(--glass); }
.d-aurora .bar__menu:hover{ color:var(--ink); border-color:var(--line2); }
.d-aurora .brand__mark{ font:600 20px/1 "Outfit",sans-serif; letter-spacing:-.01em; }
.d-aurora .brand__mark::first-letter{ color:var(--violet); }
.d-aurora .brand__sub{ font:500 10px/1 "IBM Plex Mono"; letter-spacing:.2em; text-transform:uppercase; color:var(--dim); }
.d-aurora .capture{ display:inline-flex; align-items:center; gap:8px; font:500 10.5px/1 "IBM Plex Mono"; letter-spacing:.12em; text-transform:uppercase; color:var(--grn); padding:7px 12px; border-radius:20px; background:rgba(95,224,160,.10); border:1px solid rgba(95,224,160,.3); }
.d-aurora .capture__dot{ width:7px; height:7px; border-radius:50%; background:var(--grn); box-shadow:0 0 10px var(--grn); animation:pulse 1.7s infinite; }
.d-aurora .locale{ border:1px solid var(--line); border-radius:20px; background:var(--glass); }
.d-aurora .locale__btn{ padding:7px 13px; font:500 10.5px/1 "IBM Plex Mono"; letter-spacing:.1em; color:var(--dim); border-radius:20px; }
.d-aurora .locale__btn.is-active{ background:linear-gradient(120deg,var(--violet),#6d8bff); color:#fff; }
.d-aurora .theme{ width:36px; height:36px; display:grid; place-items:center; border:1px solid var(--line); border-radius:11px; color:var(--mut); background:var(--glass); }
.d-aurora .theme:hover{ color:var(--violet); border-color:var(--line2); }
.d-aurora .nav{ background:rgba(12,16,30,.4); backdrop-filter:blur(10px); border-right:1px solid var(--line); padding:18px 14px; gap:3px; }
.d-aurora .nav__group{ font:500 10px/1 "IBM Plex Mono"; letter-spacing:.2em; text-transform:uppercase; color:var(--dim); padding:18px 12px 9px; }
.d-aurora .nav-link{ padding:10px 13px; border-radius:12px; font:600 13.5px/1 "Manrope"; color:var(--mut); position:relative; transition:background .16s,color .16s; }
.d-aurora .nav-link svg{ color:var(--dim); }
.d-aurora .nav-link:hover{ background:var(--glass); color:var(--ink); }
.d-aurora .nav-link.is-active{ background:linear-gradient(120deg,rgba(139,124,255,.22),rgba(109,139,255,.10)); color:#fff; border:1px solid rgba(139,124,255,.32); }
.d-aurora .nav-link.is-active svg{ color:var(--violet); }
.d-aurora .badge{ min-width:19px; height:19px; padding:0 6px; border-radius:10px; background:linear-gradient(120deg,var(--coral),#ff5d8f); color:#fff; font:700 10.5px/19px "IBM Plex Mono"; }
.d-aurora .main{ padding:32px 38px 50px; }
.d-aurora .hero{ max-width:780px; animation:rise .55s both; }
.d-aurora .kicker{ font:500 11px/1 "IBM Plex Mono"; letter-spacing:.24em; text-transform:uppercase; background:linear-gradient(120deg,var(--violet),var(--teal)); -webkit-background-clip:text; background-clip:text; color:transparent; }
.d-aurora .title{ font:600 clamp(32px,4.6vw,54px)/1.04 "Outfit",sans-serif; letter-spacing:-.02em; margin:14px 0 14px; }
.d-aurora .title em{ font-style:normal; background:linear-gradient(120deg,#b9aaff,var(--teal)); -webkit-background-clip:text; background-clip:text; color:transparent; }
.d-aurora .lede{ color:var(--mut); font:400 15.5px/1.65 "Manrope"; max-width:62ch; margin:0; }
.d-aurora .stats{ gap:18px; margin:32px 0; animation:rise .55s .07s both; }
.d-aurora .stat{ background:var(--glass); border:1px solid var(--line); border-radius:18px; padding:20px; gap:11px; backdrop-filter:blur(12px); position:relative; overflow:hidden; box-shadow:0 8px 30px rgba(0,0,0,.25); }
.d-aurora .stat::before{ content:""; position:absolute; inset:0 0 auto 0; height:1px; background:linear-gradient(90deg,transparent,var(--line2),transparent); }
.d-aurora .stat__label{ font:500 10.5px/1 "IBM Plex Mono"; letter-spacing:.14em; text-transform:uppercase; color:var(--dim); }
.d-aurora .stat__value{ font:600 38px/1 "Outfit",sans-serif; letter-spacing:-.02em; font-variant-numeric:tabular-nums; }
.d-aurora .stat--alert .stat__value{ background:linear-gradient(120deg,var(--coral),var(--gold)); -webkit-background-clip:text; background-clip:text; color:transparent; }
.d-aurora .stat__delta{ display:inline-flex; align-items:center; gap:5px; align-self:flex-start; font:600 11px/1 "IBM Plex Mono"; color:var(--grn); background:rgba(95,224,160,.10); padding:4px 8px; border-radius:8px; }
.d-aurora .stat__delta.dn{ color:var(--coral); background:rgba(255,122,138,.10); }
.d-aurora .stat__delta.flat{ color:var(--dim); background:var(--glass2); }
.d-aurora .grid2{ gap:24px; animation:rise .55s .14s both; }
.d-aurora .side{ display:flex; flex-direction:column; gap:24px; }
.d-aurora .panel{ background:var(--glass); border:1px solid var(--line); border-radius:20px; overflow:hidden; backdrop-filter:blur(12px); box-shadow:0 10px 36px rgba(0,0,0,.28); }
.d-aurora .panel__head{ display:flex; align-items:center; justify-content:space-between; padding:18px 22px; border-bottom:1px solid var(--line); }
.d-aurora .panel__more{ font:500 11px/1 "IBM Plex Mono"; letter-spacing:.1em; text-transform:uppercase; color:var(--violet); }
.d-aurora .panel__more:hover{ color:#b9aaff; }
.d-aurora .signal{ grid-template-columns:auto 1fr auto; gap:16px; align-items:center; padding:16px 22px; border-bottom:1px solid var(--line); transition:background .16s; }
.d-aurora .signal:last-child{ border-bottom:0; }
.d-aurora .signal:hover{ background:var(--glass2); }
.d-aurora .sporticon{ width:42px; height:42px; border-radius:13px; font:600 15px/1 "Outfit"; border:1px solid var(--line2); }
.d-aurora .si-football{ background:linear-gradient(135deg,rgba(95,224,160,.22),rgba(95,224,160,.06)); color:var(--grn); }
.d-aurora .si-basketball{ background:linear-gradient(135deg,rgba(255,206,107,.22),rgba(255,206,107,.06)); color:var(--gold); }
.d-aurora .si-tennis{ background:linear-gradient(135deg,rgba(61,214,196,.22),rgba(61,214,196,.06)); color:var(--teal); }
.d-aurora .si-hockey{ background:linear-gradient(135deg,rgba(139,124,255,.26),rgba(139,124,255,.06)); color:#b9aaff; }
.d-aurora .sig-time{ font:500 11px/1 "IBM Plex Mono"; color:var(--dim); margin-bottom:5px; }
.d-aurora .sig-teams{ font:600 16px/1.15 "Outfit",sans-serif; color:var(--ink); }
.d-aurora .sig-mkts{ gap:8px; margin-top:11px; }
.d-aurora .mkt{ gap:6px; padding:5px 10px; border:1px solid var(--line); border-radius:10px; background:var(--glass); font-family:"IBM Plex Mono"; }
.d-aurora .mkt__k{ font:600 10px/1; color:var(--dim); }
.d-aurora .mkt__pre{ font-size:11.5px; color:var(--dim); text-decoration:line-through; text-decoration-color:var(--line2); }
.d-aurora .mkt__arr{ color:var(--dim); }
.d-aurora .mkt__post{ font-weight:600; font-size:12.5px; }
.d-aurora .mkt.up .mkt__post{ color:var(--grn); }
.d-aurora .mkt.dn .mkt__post{ color:var(--coral); }
.d-aurora .sig-right{ display:flex; flex-direction:column; align-items:flex-end; gap:9px; }
.d-aurora .sev{ font:600 9.5px/1 "IBM Plex Mono"; letter-spacing:.12em; text-transform:uppercase; padding:5px 11px; border-radius:20px; }
.d-aurora .sev--high{ color:#fff; background:linear-gradient(120deg,var(--coral),#ff5d8f); }
.d-aurora .sev--medium{ color:#3a2c08; background:linear-gradient(120deg,var(--gold),#ffb24d); }
.d-aurora .sev--low{ color:var(--mut); background:var(--glass2); border:1px solid var(--line); }
.d-aurora .score{ display:flex; align-items:center; gap:9px; }
.d-aurora .score__bar{ width:54px; height:5px; border-radius:4px; background:var(--glass2); overflow:hidden; }
.d-aurora .score__fill{ height:100%; border-radius:4px; background:linear-gradient(90deg,var(--violet),var(--coral)); }
.d-aurora .score__n{ font:600 15px/1 "Outfit"; font-variant-numeric:tabular-nums; }
.d-aurora .pipe{ padding:10px 14px; }
.d-aurora .step{ gap:14px; padding:13px 10px; border-bottom:1px solid var(--line); }
.d-aurora .step:last-child{ border-bottom:0; }
.d-aurora .step__idx{ font:600 14px/1 "Outfit"; color:var(--dim); width:26px; }
.d-aurora .step__lbl{ flex:1; font:600 13.5px/1.2 "Manrope"; }
.d-aurora .step__lbl small{ display:block; font:400 11px/1 "IBM Plex Mono"; color:var(--dim); margin-top:4px; letter-spacing:.04em; }
.d-aurora .dot{ width:9px; height:9px; border-radius:50%; }
.d-aurora .dot--ok{ background:var(--grn); box-shadow:0 0 10px var(--grn); }
.d-aurora .dot--run{ background:var(--teal); box-shadow:0 0 10px var(--teal); animation:pulse 1.5s infinite; }
.d-aurora .dot--idle{ background:var(--line2); }
.d-aurora .concept{ padding:22px; }
.d-aurora .concept h4{ margin:0 0 8px; font:600 19px/1.1 "Outfit"; }
.d-aurora .concept p{ margin:0 0 14px; font:400 13px/1.65 "Manrope"; color:var(--mut); }
.d-aurora .concept .meta{ font:500 10px/1.5 "IBM Plex Mono"; letter-spacing:.1em; text-transform:uppercase; background:linear-gradient(120deg,var(--violet),var(--teal)); -webkit-background-clip:text; background-clip:text; color:transparent; }
</style>
</head>
<body data-active="noir">
<!-- icon sprite -->
<svg width="0" height="0" style="position:absolute" aria-hidden="true">
<symbol id="i-menu" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M4 7h16M4 12h16M4 17h16"/></symbol>
<symbol id="i-moon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M20 14.5A8 8 0 1 1 9.5 4a6.5 6.5 0 0 0 10.5 10.5z"/></symbol>
<symbol id="i-grid" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"><rect x="3.5" y="3.5" width="7" height="7" rx="1.2"/><rect x="13.5" y="3.5" width="7" height="7" rx="1.2"/><rect x="3.5" y="13.5" width="7" height="7" rx="1.2"/><rect x="13.5" y="13.5" width="7" height="7" rx="1.2"/></symbol>
<symbol id="i-clock" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="8.5"/><path d="M12 7.5V12l3 2"/></symbol>
<symbol id="i-bolt" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"><path d="M13 3 5 13h6l-1 8 8-10h-6z"/></symbol>
<symbol id="i-warn" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M12 4 3 19h18z"/><path d="M12 10v4M12 16.5v.4"/></symbol>
<symbol id="i-check" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="8.5"/><path d="m8.5 12 2.5 2.5 4.5-5"/></symbol>
<symbol id="i-insight" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18h6M10 21h4"/><path d="M12 3a6 6 0 0 0-3.5 10.9c.5.4.5 1 .5 1.6h6c0-.6 0-1.2.5-1.6A6 6 0 0 0 12 3z"/></symbol>
<symbol id="i-receipt" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M6 3.5h12v17l-2.2-1.4-2 1.4-1.8-1.4-1.8 1.4-2-1.4L6 20.5z"/><path d="M9 8h6M9 12h6"/></symbol>
<symbol id="i-stats" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M5 19V10M10 19V5M15 19v-6M20 19v-9"/></symbol>
<symbol id="i-gear" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"><path d="M5 8h7M16 8h3"/><circle cx="14" cy="8" r="2.2"/><path d="M5 16h3M12 16h7"/><circle cx="10" cy="16" r="2.2"/></symbol>
</svg>
<!-- ===== direction switcher ===== -->
<div class="switch">
<span class="switch__label"><b>Marathon</b> · Redesign</span>
<div class="switch__tabs">
<button class="tab is-on" data-for="noir"><span class="tab__name">Terminal Noir</span><span class="tab__tag">Dark · Quant</span></button>
<button class="tab" data-for="velocity"><span class="tab__name">Velocity</span><span class="tab__tag">Brutalist · Sport</span></button>
<button class="tab" data-for="aurora"><span class="tab__name">Aurora</span><span class="tab__tag">Premium · Glass</span></button>
</div>
<span class="switch__hint">Press 1 · 2 · 3 to switch</span>
</div>
<!-- ===== stages ===== -->
<section class="stage d-noir is-active" data-stage="noir"><div class="fx"></div><div class="mount"></div></section>
<section class="stage d-velocity" data-stage="velocity"><div class="fx"></div><div class="mount"></div></section>
<section class="stage d-aurora" data-stage="aurora"><div class="fx"><span class="glow3"></span></div><div class="mount"></div></section>
<script>
const NAV = [
["Analysis", null],
["Dashboard","i-grid",true], ["Pre-Match","i-clock"], ["Live","i-bolt"],
["Anomalies","i-warn",false,3], ["Results","i-check"], ["Insights","i-insight"],
["My Bets","i-receipt"], ["Backtest","i-stats"],
["System", null],
["Settings","i-gear"],
];
const STATS = [
["Events tracked","1,284","+38 today","up"],
["Snapshots today","38,902","+5.1K live","up"],
["Anomalies","47","+6 today","alert"],
["Sports covered","4","all active","flat"],
];
const SIGNALS = [
{t:"14:32", sport:"Football", ic:"football", mono:"F", teams:"Динамо Минск — БАТЭ", league:"BLR · Vysshaya Liga", sev:"high", score:0.82, gap:"72s",
mkts:[["1","1.85","2.40","up"],["X","3.40","3.05","dn"],["2","4.20","2.95","dn"]]},
{t:"13:58", sport:"Basketball", ic:"basketball", mono:"B", teams:"ЦСКА — Зенит", league:"VTB United", sev:"medium", score:0.57, gap:"48s",
mkts:[["1","1.62","1.95","up"],["2","2.30","1.88","dn"]]},
{t:"12:10", sport:"Tennis", ic:"tennis", mono:"T", teams:"Medvedev — Sinner", league:"ATP Masters 1000", sev:"low", score:0.41, gap:"35s",
mkts:[["1","1.40","1.55","up"],["2","2.95","2.55","dn"]]},
{t:"11:25", sport:"Hockey", ic:"hockey", mono:"H", teams:"Динамо Мн — Спартак", league:"KHL", sev:"high", score:0.74, gap:"65s",
mkts:[["1","2.10","2.85","up"],["X","3.80","3.40","dn"],["2","3.05","2.40","dn"]]},
];
const PIPE = [
["01","Schedule scan","Every 6h","ok"],
["02","Capture snapshots","30s pre · 5s live","ok"],
["03","Detect anomalies","Running now","run"],
["04","Export workbook","Manual","idle"],
];
const SEV_LABEL = {high:"High", medium:"Medium", low:"Low"};
const META = {
noir: {name:"Terminal Noir", pick:"Pick this if you read Marathon as a precision instrument — dense, fast, every number first. Closest in spirit to the current build, taken fully dark and neon-lit.", fonts:"Type · Archivo + JetBrains Mono"},
velocity: {name:"Velocity", pick:"Pick this if you want energy and impact — loud, confident, unmistakably about sport. Hard edges, acid lime, slammed headlines.", fonts:"Type · Anton + DM Sans + Space Mono"},
aurora: {name:"Aurora", pick:"Pick this if you want a calm, premium product feel — soft glass, drifting aurora light, refined gradients. Modern fintech polish.", fonts:"Type · Outfit + Manrope"},
};
function navHTML(){
return NAV.map(item=>{
if(item[1]===null) return `<div class="nav__group">${item[0]}</div>`;
const [label,icon,active,badge]=item;
return `<a class="nav-link${active?" is-active":""}">
<svg class="ico"><use href="#${icon}"/></svg>
<span class="lbl">${label}</span>
${badge?`<span class="badge">${badge}</span>`:""}
</a>`;
}).join("");
}
function statsHTML(){
return STATS.map(([label,val,delta,kind])=>`
<div class="stat${kind==="alert"?" stat--alert":""}">
<span class="stat__label">${label}</span>
<span class="stat__value">${val}</span>
<span class="stat__delta ${kind}">${delta}</span>
</div>`).join("");
}
function mktsHTML(mkts){
return mkts.map(([k,pre,post,dir])=>`
<span class="mkt ${dir}">
<span class="mkt__k">${k}</span>
<span class="mkt__pre">${pre}</span>
<span class="mkt__arr">→</span>
<span class="mkt__post">${post}</span>
</span>`).join("");
}
function signalsHTML(){
return SIGNALS.map(s=>`
<div class="signal">
<span class="sporticon si-${s.ic}">${s.mono}</span>
<div class="sig-mid">
<div style="min-width:0">
<div class="sig-time">${s.t} · ${s.sport} · ${s.league}</div>
<div class="sig-teams">${s.teams}</div>
<div class="sig-mkts">${mktsHTML(s.mkts)}</div>
</div>
</div>
<div class="sig-right">
<span class="sev sev--${s.sev}">${SEV_LABEL[s.sev]}</span>
<span class="score">
<span class="score__bar"><span class="score__fill" style="width:${Math.round(s.score*100)}%"></span></span>
<span class="score__n">${s.score.toFixed(2)}</span>
</span>
</div>
</div>`).join("");
}
function pipeHTML(){
return PIPE.map(([idx,lbl,sub,st])=>`
<li class="step">
<span class="step__idx">${idx}</span>
<span class="step__lbl">${lbl}<small>${sub}</small></span>
<span class="dot dot--${st}"></span>
</li>`).join("");
}
function dashboardHTML(key){
const m = META[key];
return `
<div class="app">
<header class="bar">
<button class="bar__menu" aria-label="Menu"><svg class="ico"><use href="#i-menu"/></svg></button>
<div class="brand">
<span class="brand__mark">Marathon</span>
<span class="brand__sub">Odds Lab · v0.1</span>
</div>
<div class="bar__spacer"></div>
<div class="bar__tools">
<span class="capture"><span class="capture__dot"></span>Capturing</span>
<div class="locale"><button class="locale__btn is-active">RU</button><button class="locale__btn">EN</button></div>
<button class="theme" aria-label="Theme"><svg class="ico"><use href="#i-moon"/></svg></button>
</div>
</header>
<div class="body">
<nav class="nav">${navHTML()}</nav>
<main class="main">
<section class="hero">
<span class="kicker">Odds Intelligence</span>
<h1 class="title">Suspension-flip <em>radar</em></h1>
<p class="lede">Live watch on frozen markets that reopen inverted — the moment a bookmaker swaps underdog and favourite. Sorted by confidence, newest first.</p>
</section>
<div class="stats">${statsHTML()}</div>
<div class="grid2">
<section class="panel">
<header class="panel__head"><span class="kicker">Latest signals</span><a class="panel__more">View all →</a></header>
<div class="feed">${signalsHTML()}</div>
</section>
<aside class="side">
<section class="panel">
<header class="panel__head"><span class="kicker">Pipeline</span></header>
<ol class="pipe">${pipeHTML()}</ol>
</section>
<section class="panel concept">
<h4>${m.name}</h4>
<p>${m.pick}</p>
<span class="meta">${m.fonts}</span>
</section>
</aside>
</div>
</main>
</div>
</div>`;
}
// mount each stage
document.querySelectorAll(".stage").forEach(st=>{
st.querySelector(".mount").innerHTML = dashboardHTML(st.dataset.stage);
});
// switcher
const stages = document.querySelectorAll(".stage");
const tabs = document.querySelectorAll(".tab");
function show(key){
document.body.dataset.active = key;
stages.forEach(s=>s.classList.toggle("is-active", s.dataset.stage===key));
tabs.forEach(t=>t.classList.toggle("is-on", t.dataset.for===key));
// re-trigger entrance animation
const active = document.querySelector(".stage.is-active .main");
if(active){ active.style.animation="none"; void active.offsetWidth; active.style.animation=""; }
window.scrollTo(0,0);
}
tabs.forEach(t=>t.addEventListener("click",()=>show(t.dataset.for)));
addEventListener("keydown",e=>{
if(e.key==="1") show("noir");
if(e.key==="2") show("velocity");
if(e.key==="3") show("aurora");
});
</script>
</body>
</html>
+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,26 @@
using Marathon.Domain.Entities;
namespace Marathon.Application.Abstractions;
/// <summary>
/// Repository for <see cref="Anomaly"/> domain entities.
/// </summary>
public interface IAnomalyRepository : IRepository<Guid, Anomaly>
{
/// <summary>
/// Server-side count of anomalies detected strictly after <paramref name="since"/>.
/// Backs the unread badge without materialising the table.
/// </summary>
Task<int> CountSinceAsync(DateTimeOffset since, CancellationToken ct = default);
/// <summary>
/// Anomalies whose <see cref="Anomaly.DetectedAt"/> falls in the inclusive
/// [<paramref name="from"/>..<paramref name="to"/>] window (either bound may be
/// null for open-ended), ordered newest-first. Pushes the temporal filter to SQL;
/// severity / sport filtering remains a service concern (needs the event join).
/// </summary>
Task<IReadOnlyList<Anomaly>> ListByDateRangeAsync(
DateTimeOffset? from,
DateTimeOffset? to,
CancellationToken ct = default);
}
@@ -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,46 @@
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);
/// <summary>
/// Date-range + sport-filtered query pushed to the database. Replaces the
/// "load the whole date range then filter sports in memory" path on the list
/// pages. Locale-sensitive search and sort remain a service-layer concern.
/// </summary>
Task<IReadOnlyList<Event>> QueryAsync(EventQuery query, CancellationToken ct = default);
/// <summary>
/// Batched point-lookup: loads many events in a single query, keyed by
/// <see cref="EventId"/>. Missing ids are simply absent from the dictionary.
/// Replaces per-id <see cref="IRepository{TKey,TEntity}.GetAsync"/> loops (N+1).
/// </summary>
Task<IReadOnlyDictionary<EventId, Event>> GetManyAsync(
IReadOnlyCollection<EventId> ids,
CancellationToken ct = default);
Task<IReadOnlyList<Event>> ListBySportAsync(SportCode sport, CancellationToken ct = default);
/// <summary>Server-side total event count (dashboard summary).</summary>
Task<int> CountAsync(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,28 @@
using Marathon.Domain.Enums;
namespace Marathon.Application.Abstractions;
/// <summary>
/// A ready-to-deliver anomaly alert payload, shaped by
/// <see cref="UseCases.GetPendingAnomalyNotificationsUseCase"/> so a sink only has to
/// format and transmit it.
/// </summary>
public sealed record AnomalyNotification(
Guid AnomalyId,
string EventTitle,
AnomalyKind Kind,
decimal Score,
DateTimeOffset DetectedAt);
/// <summary>
/// A channel that delivers anomaly alerts (e.g. Telegram; future: email / Discord).
/// </summary>
/// <remarks>
/// Implementations must be resilient: a failure to deliver one notification should be
/// logged and swallowed, never thrown into the dispatcher loop. A sink that is not
/// configured (e.g. missing credentials) should no-op with a warning.
/// </remarks>
public interface INotificationSink
{
Task SendAsync(AnomalyNotification notification, CancellationToken ct);
}
@@ -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,24 @@
using Marathon.Domain.Entities;
using Marathon.Domain.Enums;
namespace Marathon.Application.Abstractions;
/// <summary>
/// Repository for <see cref="PaperBet"/> entities — the forward-test ledger written
/// by the paper-trading worker.
/// </summary>
public interface IPaperBetRepository : IRepository<Guid, PaperBet>
{
/// <summary>
/// Paper bets in a given settlement state — <see cref="BetOutcome.Pending"/> is
/// the open set the settler scans each cycle.
/// </summary>
Task<IReadOnlyList<PaperBet>> ListByOutcomeAsync(BetOutcome outcome, CancellationToken ct = default);
/// <summary>
/// The subset of <paramref name="anomalyIds"/> that already have a paper bet —
/// lets the opener skip anomalies it has already forward-tested (one bet per anomaly).
/// </summary>
Task<IReadOnlySet<Guid>> GetExistingAnomalyIdsAsync(
IReadOnlyCollection<Guid> anomalyIds, CancellationToken ct = default);
}
@@ -0,0 +1,32 @@
using Marathon.Application.Storage;
using Marathon.Domain.Entities;
using Marathon.Domain.Enums;
using Marathon.Domain.ValueObjects;
namespace Marathon.Application.Abstractions;
/// <summary>
/// Repository for <see cref="PlacedBet"/> domain entities — the user-tracked
/// betting journal.
/// </summary>
public interface IPlacedBetRepository : IRepository<Guid, PlacedBet>
{
/// <summary>
/// Bets matching <paramref name="outcome"/>. Used by the resolver use case
/// to scan only <see cref="BetOutcome.Pending"/> rows on each pass.
/// </summary>
Task<IReadOnlyList<PlacedBet>> ListByOutcomeAsync(BetOutcome outcome, CancellationToken ct = default);
/// <summary>
/// Bets whose <see cref="PlacedBet.PlacedAt"/> falls within
/// <paramref name="range"/>. Used by the journal page when the user filters
/// by date.
/// </summary>
Task<IReadOnlyList<PlacedBet>> ListByDateRangeAsync(DateRange range, CancellationToken ct = default);
/// <summary>
/// Every bet recorded against <paramref name="eventId"/>. Used by the event
/// detail page to show "you have N bets on this match".
/// </summary>
Task<IReadOnlyList<PlacedBet>> ListByEventAsync(EventId eventId, CancellationToken ct = default);
}
@@ -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,19 @@
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>
{
/// <summary>
/// Batched point-lookup: loads many results in a single query, keyed by
/// <see cref="EventId"/>. Missing ids are simply absent from the dictionary.
/// Replaces per-id <see cref="IRepository{TKey,TEntity}.GetAsync"/> loops (N+1).
/// </summary>
Task<IReadOnlyDictionary<EventId, EventResult>> GetManyAsync(
IReadOnlyCollection<EventId> ids,
CancellationToken ct = default);
}
@@ -0,0 +1,17 @@
using Marathon.Domain.Backtesting;
namespace Marathon.Application.Abstractions;
/// <summary>
/// Repository for <see cref="SavedStrategy"/> presets — the user's named,
/// reusable backtest staking configurations. <see cref="IRepository{TKey,TEntity}.ListAsync"/>
/// returns them name-ascending for a stable picker order.
/// </summary>
public interface ISavedStrategyRepository : IRepository<Guid, SavedStrategy>
{
/// <summary>
/// The preset whose (trimmed) name matches <paramref name="name"/>, or null.
/// Used by the save flow to upsert by name rather than create a duplicate.
/// </summary>
Task<SavedStrategy?> GetByNameAsync(string name, CancellationToken ct = default);
}
@@ -0,0 +1,66 @@
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);
/// <summary>
/// Server-side count of snapshots captured at or after <paramref name="since"/>.
/// Backs the dashboard "snapshots today" stat without materialising rows.
/// </summary>
Task<int> CountSinceAsync(DateTimeOffset since, CancellationToken ct = default);
/// <summary>
/// The most recent snapshot capture time across all events, or <c>null</c> when the
/// store is empty. Backs the pipeline-health freshness indicator.
/// </summary>
Task<DateTimeOffset?> GetLatestCapturedAtAsync(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);
/// <summary>
/// Returns the latest pre-match snapshot for <paramref name="eventId"/> whose
/// <see cref="OddsSnapshot.CapturedAt"/> is at or before
/// <paramref name="atOrBefore"/>, or <c>null</c> if none exists. Used by the
/// bet-journal use case as the "closing line" reference for CLV.
/// </summary>
/// <remarks>
/// Pushes the ORDER BY + LIMIT 1 down to SQLite so we do not materialise
/// every snapshot in the 30-day pre-match window just to pick one.
/// </remarks>
Task<OddsSnapshot?> GetLatestPreMatchAsync(
EventId eventId,
DateTimeOffset atOrBefore,
CancellationToken ct = default);
}
@@ -0,0 +1,53 @@
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<ExportToCsvUseCase>();
services.AddScoped<DetectAnomaliesUseCase>();
services.AddScoped<EvaluateAnomalyOutcomesUseCase>();
services.AddScoped<GetPendingAnomalyNotificationsUseCase>();
services.AddScoped<RecordPlacedBetUseCase>();
services.AddScoped<ResolvePendingBetsUseCase>();
services.AddScoped<BuildBetJournalReportUseCase>();
services.AddScoped<DeletePlacedBetUseCase>();
services.AddScoped<UpdatePlacedBetUseCase>();
services.AddScoped<RunBacktestUseCase>();
services.AddScoped<SaveStrategyUseCase>();
services.AddScoped<DeleteStrategyUseCase>();
services.AddScoped<CompareStrategiesUseCase>();
services.AddScoped<OpenPaperBetsUseCase>();
services.AddScoped<SettlePaperBetsUseCase>();
return services;
}
}
@@ -0,0 +1,92 @@
using Marathon.Domain.Entities;
using Marathon.Domain.Enums;
namespace Marathon.Application.Betting;
/// <summary>
/// Aggregate report on the user's bet-tracking journal — totals, P&amp;L, and
/// per-bet CLV. Consumed by the Journal page; built by
/// <see cref="UseCases.BuildBetJournalReportUseCase"/>.
/// </summary>
/// <param name="Stats">Roll-up of stake / profit / hit rate / CLV across all bets in scope.</param>
/// <param name="Bets">
/// Every bet paired with its computed CLV (null when no closing snapshot was
/// available). Ordered most-recent <see cref="PlacedBet.PlacedAt"/> first.
/// </param>
public sealed record BetJournalReport(
BetJournalStats Stats,
IReadOnlyList<BetJournalRow> Bets);
/// <summary>
/// One row in the journal — a domain <see cref="PlacedBet"/> plus the CLV
/// computed against the closing pre-match snapshot.
/// </summary>
/// <param name="Bet">The domain bet exactly as persisted.</param>
/// <param name="ClvProbabilityDelta">
/// Closing-line value as an implied-probability delta in roughly [-1, 1].
/// Positive means the user took a better price than the closing line; null
/// when no matching bet existed in the closing snapshot.
/// </param>
public sealed record BetJournalRow(
PlacedBet Bet,
decimal? ClvProbabilityDelta);
/// <summary>
/// Aggregate statistics across a set of <see cref="PlacedBet"/>.
/// All money values share the user's currency — the domain does not encode one.
/// </summary>
/// <param name="TotalBets">Every bet in scope, regardless of outcome.</param>
/// <param name="PendingCount">Bets still awaiting settlement.</param>
/// <param name="WonCount">Settled wins.</param>
/// <param name="LostCount">Settled losses.</param>
/// <param name="VoidCount">Settled pushes / void grades.</param>
/// <param name="TotalStaked">
/// Turnover that contributes to ROI: sum of <see cref="PlacedBet.Stake"/> across
/// <b>Won and Lost</b> bets only. Void (push) and Pending bets are excluded — a
/// returned stake is not real turnover and counting it would dilute ROI.
/// </param>
/// <param name="TotalReturned">
/// Sum of <see cref="PlacedBet.GrossReturn"/> across the same Won + Lost subset
/// that feeds <see cref="TotalStaked"/>.
/// </param>
/// <param name="NetProfit"><c>TotalReturned TotalStaked</c>.</param>
/// <param name="RoiPercent">
/// <c>NetProfit / TotalStaked × 100</c>. Null when no bets have resolved yet.
/// </param>
/// <param name="StrikeRatePercent">
/// <c>WonCount / (WonCount + LostCount) × 100</c> — excludes voids and pendings.
/// Null when no settled win/loss exists yet.
/// </param>
/// <param name="AverageClvProbabilityDelta">
/// Mean CLV across bets where CLV was computable. Null when no comparable
/// closing snapshot was available for any bet.
/// </param>
public sealed record BetJournalStats(
int TotalBets,
int PendingCount,
int WonCount,
int LostCount,
int VoidCount,
decimal TotalStaked,
decimal TotalReturned,
decimal NetProfit,
decimal? RoiPercent,
decimal? StrikeRatePercent,
decimal? AverageClvProbabilityDelta)
{
/// <summary>Convenience: WonCount + LostCount + VoidCount.</summary>
public int ResolvedCount => WonCount + LostCount + VoidCount;
public static BetJournalStats Empty { get; } = new(
TotalBets: 0,
PendingCount: 0,
WonCount: 0,
LostCount: 0,
VoidCount: 0,
TotalStaked: 0m,
TotalReturned: 0m,
NetProfit: 0m,
RoiPercent: null,
StrikeRatePercent: null,
AverageClvProbabilityDelta: null);
}
@@ -0,0 +1,85 @@
using Marathon.Domain.Entities;
using Marathon.Domain.ValueObjects;
namespace Marathon.Application.Betting;
/// <summary>
/// Pure helper that computes Closing Line Value (CLV) for a placed bet.
/// </summary>
/// <remarks>
/// <para>
/// CLV measures how much better (or worse) the rate the user took was compared
/// with the bookmaker's last pre-match price on the same selection. It is the
/// single best long-run indicator of betting skill — positive CLV correlates
/// with positive expected value regardless of any individual bet's outcome.
/// </para>
/// <para>
/// Formula (implied-probability delta):
/// <list type="bullet">
/// <item>Taken implied probability: <c>p_t = 1 / takenRate</c></item>
/// <item>Closing implied probability: <c>p_c = 1 / closeRate</c></item>
/// <item><c>CLV = p_c p_t</c></item>
/// </list>
/// Positive CLV means the closing price implied higher probability for the
/// selection than the price the user took — i.e. the line moved in the user's
/// favour after they placed the bet.
/// </para>
/// <para>
/// Returns <c>null</c> when no matching bet (same Scope / Type / Side / Value)
/// can be found in the closing snapshot — typically because the market closed
/// before the bookmaker exposed a comparable line, or the snapshot store has
/// gaps. UI consumers must distinguish "no data" from "0% CLV".
/// </para>
/// </remarks>
public static class ClosingLineValueCalculator
{
/// <summary>
/// Computes CLV (implied-probability delta) given the rate the user took
/// and the rate present in the closing pre-match snapshot for the same
/// selection. Both must be positive — invariants on <see cref="OddsRate"/>
/// already guarantee this for inputs sourced from the domain.
/// </summary>
public static decimal Compute(decimal takenRate, decimal closingRate)
{
if (takenRate <= 0m)
throw new ArgumentOutOfRangeException(nameof(takenRate), takenRate, "Must be positive.");
if (closingRate <= 0m)
throw new ArgumentOutOfRangeException(nameof(closingRate), closingRate, "Must be positive.");
var takenProb = 1m / takenRate;
var closingProb = 1m / closingRate;
// Round to 6 decimals — beyond that is noise from the round-trip.
return Math.Round(closingProb - takenProb, 6);
}
/// <summary>
/// Convenience overload: finds the matching <see cref="Bet"/> in
/// <paramref name="closingSnapshot"/> by Scope / Type / Side / Value, then
/// computes CLV against <paramref name="takenRate"/>. Returns <c>null</c>
/// when no comparable bet is present.
/// </summary>
public static decimal? TryCompute(
decimal takenRate,
Bet placedSelection,
OddsSnapshot? closingSnapshot)
{
ArgumentNullException.ThrowIfNull(placedSelection);
if (closingSnapshot is null) return null;
var match = closingSnapshot.Bets.FirstOrDefault(b =>
b.Scope.Equals(placedSelection.Scope) &&
b.Type == placedSelection.Type &&
b.Side == placedSelection.Side &&
NullableValuesEqual(b.Value, placedSelection.Value));
return match is null ? null : Compute(takenRate, match.Rate.Value);
}
private static bool NullableValuesEqual(OddsValue? a, OddsValue? b)
{
if (a is null && b is null) return true;
if (a is null || b is null) return false;
return a.Value == b.Value;
}
}
@@ -0,0 +1,66 @@
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;
/// <summary>
/// Trailing window, in seconds, over which the steam-move detector measures a
/// continuous one-directional probability drift. Default: 120 s.
/// </summary>
public int SteamMoveWindowSeconds { get; init; } = 120;
/// <summary>
/// Minimum one-directional normalised implied-probability rise within the window
/// to flag a steam move. Must be in (0, 1). Default: 0.20 (20 percentage points).
/// </summary>
public decimal SteamMoveDriftThreshold { get; init; } = 0.20m;
/// <summary>
/// Maximum normalised implied-probability change across a suspension for it to count
/// as a "freeze" (line resumed essentially unchanged). Must be in (0, 1).
/// Default: 0.05 (5 percentage points).
/// </summary>
public decimal SuspensionFreezeThreshold { get; init; } = 0.05m;
/// <summary>
/// Trailing window, in seconds, over which the overround-compression detector
/// measures a continuous margin drop. Default: 120 s.
/// </summary>
public int OverroundWindowSeconds { get; init; } = 120;
/// <summary>
/// Minimum drop in the bookmaker's overround (raw implied-probability sum) within the
/// window to flag a compression. Must be in (0, 1). Default: 0.02 (2 margin points).
/// </summary>
public decimal OverroundCompressionThreshold { get; init; } = 0.02m;
}
@@ -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;
}
+62
View File
@@ -0,0 +1,62 @@
using System.Text;
namespace Marathon.Application.Export;
/// <summary>
/// Minimal RFC 4180 CSV writer — escapes fields and joins rows with CRLF. Pure and
/// allocation-light; used by <see cref="UseCases.ExportToCsvUseCase"/>.
/// </summary>
public static class Csv
{
private static readonly char[] MustQuote = { ',', '"', '\r', '\n' };
/// <summary>Builds a CSV document from a header row plus data rows (CRLF endings).</summary>
public static string Document(IReadOnlyList<string> header, IEnumerable<IReadOnlyList<string>> rows)
{
ArgumentNullException.ThrowIfNull(header);
ArgumentNullException.ThrowIfNull(rows);
var sb = new StringBuilder();
AppendLine(sb, header);
foreach (var row in rows)
AppendLine(sb, row);
return sb.ToString();
}
private static void AppendLine(StringBuilder sb, IReadOnlyList<string> fields)
{
for (var i = 0; i < fields.Count; i++)
{
if (i > 0) sb.Append(',');
sb.Append(Escape(fields[i]));
}
sb.Append("\r\n");
}
/// <summary>
/// Quotes a field when it contains a comma, double-quote, CR or LF; inner quotes are
/// doubled. Null is treated as empty.
/// </summary>
public static string Escape(string? field)
{
var value = field ?? string.Empty;
if (value.IndexOfAny(MustQuote) < 0)
return value;
return "\"" + value.Replace("\"", "\"\"") + "\"";
}
/// <summary>
/// Defuses spreadsheet formula / DDE injection: when a cell would start with a formula
/// trigger (<c>= + - @</c>, tab or CR) it is prefixed with an apostrophe so Excel /
/// LibreOffice render it as text. Apply to USER-supplied or SCRAPED text fields (notes,
/// event titles) before they enter a row — numeric/date cells your own code formats are
/// trusted and don't need it (keeping them numeric for analysis).
/// </summary>
public static string NeutralizeFormula(string? field)
{
var value = field ?? string.Empty;
return value.Length > 0 && value[0] is '=' or '+' or '-' or '@' or '\t' or '\r'
? "'" + value
: value;
}
}
+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,65 @@
using Marathon.Domain.AnomalyDetection;
using DomainEventId = Marathon.Domain.ValueObjects.EventId;
namespace Marathon.Application.Reporting;
/// <summary>
/// Aggregate report answering the question "is the SuspensionFlip detector right?".
/// </summary>
/// <param name="TotalAnomalies">Every persisted anomaly considered by this report.</param>
/// <param name="ResolvedCount">Anomalies whose source events now have a final result.</param>
/// <param name="UnresolvedCount">Anomalies still waiting for an event result.</param>
/// <param name="HitCount">Resolved anomalies where the post-flip favourite won.</param>
/// <param name="MissCount">Resolved anomalies where the post-flip favourite lost.</param>
/// <param name="HitRate">
/// <see cref="HitCount"/> ÷ <see cref="ResolvedCount"/> in [0, 1]. Null when no anomalies
/// have been resolved yet — the UI must distinguish "0% hit rate" from "no data".
/// </param>
/// <param name="BySeverity">Breakdown by Low / Medium / High severity buckets.</param>
/// <param name="BySport">Breakdown by sport code.</param>
/// <param name="ByScoreBin">Breakdown across [0.30, 0.40), [0.40, 0.50), …, [0.90, 1.00].</param>
/// <param name="ByKind">
/// Breakdown by detector kind. Only directional kinds (SuspensionFlip, SteamMove) ever
/// resolve to a hit/miss, so non-directional kinds simply don't appear here.
/// </param>
/// <param name="Resolved">All resolved anomalies, newest first. Drives the drill-down table.</param>
/// <param name="Unresolved">All unresolved anomalies, newest first.</param>
/// <param name="EventTitles">
/// Pre-shaped <c>"Side1Name vs Side2Name"</c> strings keyed by event id. Carried
/// alongside the report so UI projections do not need a second pass over
/// <c>IEventRepository</c> — every event in <see cref="Resolved"/> /
/// <see cref="Unresolved"/> appears as a key. Missing events (e.g. pruned) are
/// absent; consumers fall back to <c>EventId.Value</c>.
/// </param>
public sealed record AnomalyOutcomeReport(
int TotalAnomalies,
int ResolvedCount,
int UnresolvedCount,
int HitCount,
int MissCount,
decimal? HitRate,
IReadOnlyList<OutcomeBucket> BySeverity,
IReadOnlyList<OutcomeBucket> BySport,
IReadOnlyList<OutcomeBucket> ByScoreBin,
IReadOnlyList<OutcomeBucket> ByKind,
IReadOnlyList<ResolvedAnomaly> Resolved,
IReadOnlyList<ResolvedAnomaly> Unresolved,
IReadOnlyDictionary<DomainEventId, string> EventTitles);
/// <summary>
/// One row in a breakdown table — e.g. "High severity", "Tennis", "[0.60, 0.70)".
/// </summary>
/// <param name="Key">
/// Stable, culture-invariant identifier used by the UI to localise the label
/// (e.g. <c>"Severity.High"</c>, <c>"Sport.22723"</c>, <c>"Bin.0.60-0.70"</c>).
/// </param>
/// <param name="Total">Resolved anomalies in this bucket.</param>
/// <param name="Hits">Subset of <see cref="Total"/> where post-flip favourite won.</param>
/// <param name="HitRate">
/// <see cref="Hits"/> ÷ <see cref="Total"/>, or null when <see cref="Total"/> is 0.
/// </param>
public sealed record OutcomeBucket(
string Key,
int Total,
int Hits,
decimal? HitRate);
@@ -0,0 +1,26 @@
namespace Marathon.Application.Reporting;
/// <summary>
/// Canonical, culture-invariant <see cref="OutcomeBucket.Key"/> prefixes and
/// literals. Used by the use case to emit keys and by the UI to localise them
/// — both sides reference these constants so a rename can never produce silent
/// "key not found" rendering on the page.
/// </summary>
public static class OutcomeBucketKeys
{
/// <summary>Prefix for sport-grouped buckets, e.g. <c>Sport.6</c>.</summary>
public const string SportPrefix = "Sport.";
/// <summary>Prefix for score-bin buckets, e.g. <c>Bin.0.30-0.40</c>.</summary>
public const string BinPrefix = "Bin.";
/// <summary>Prefix for detector-kind buckets, e.g. <c>Kind.SteamMove</c> (the enum name).</summary>
public const string KindPrefix = "Kind.";
/// <summary>Prefix for severity buckets, e.g. <c>Severity.High</c>.</summary>
public const string SeverityPrefix = "Severity.";
public const string SeverityLow = SeverityPrefix + "Low";
public const string SeverityMedium = SeverityPrefix + "Medium";
public const string SeverityHigh = SeverityPrefix + "High";
}
@@ -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,13 @@
namespace Marathon.Application.Storage;
/// <summary>
/// Database-pushdown query for the event list pages: an inclusive date range plus
/// an optional sport-code filter. Locale-sensitive search and sort are deliberately
/// NOT part of this contract — they stay in the service layer where Cyrillic
/// ordinal semantics are preserved (SQLite BINARY collation would change them).
/// </summary>
/// <param name="Dates">Inclusive scheduled-at window.</param>
/// <param name="SportCodes">When non-empty, restricts to these sport codes. Null/empty = all sports.</param>
public sealed record EventQuery(
DateRange Dates,
IReadOnlyCollection<int>? SportCodes = null);
@@ -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,173 @@
using Marathon.Application.Abstractions;
using Marathon.Application.Betting;
using Marathon.Domain.Entities;
using Marathon.Domain.Enums;
using Microsoft.Extensions.Logging;
using DomainEventId = Marathon.Domain.ValueObjects.EventId;
namespace Marathon.Application.UseCases;
/// <summary>
/// Builds a <see cref="BetJournalReport"/>: every persisted bet paired with its
/// Closing-Line-Value, plus aggregate <see cref="BetJournalStats"/>.
/// </summary>
/// <remarks>
/// <para>
/// Closing-line lookup: for each distinct event in the journal, this use case
/// queries pre-match snapshots within a window that ends at the event's
/// <see cref="Event.ScheduledAt"/> and picks the latest snapshot whose
/// <see cref="OddsSnapshot.CapturedAt"/> is still before kickoff. That snapshot
/// is the "close" for CLV purposes.
/// </para>
/// <para>
/// If the snapshot store has nothing within the lookback window, the bet
/// receives a null CLV. Stats then exclude it from the average.
/// </para>
/// </remarks>
public sealed class BuildBetJournalReportUseCase
{
private readonly IPlacedBetRepository _bets;
private readonly IEventRepository _events;
private readonly ISnapshotRepository _snapshots;
private readonly ILogger<BuildBetJournalReportUseCase> _logger;
public BuildBetJournalReportUseCase(
IPlacedBetRepository bets,
IEventRepository events,
ISnapshotRepository snapshots,
ILogger<BuildBetJournalReportUseCase> logger)
{
_bets = bets ?? throw new ArgumentNullException(nameof(bets));
_events = events ?? throw new ArgumentNullException(nameof(events));
_snapshots = snapshots ?? throw new ArgumentNullException(nameof(snapshots));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<BetJournalReport> ExecuteAsync(CancellationToken ct = default)
{
var bets = await _bets.ListAsync(ct).ConfigureAwait(false);
if (bets.Count == 0)
{
_logger.LogInformation("BuildBetJournalReportUseCase: no bets — empty report");
return new BetJournalReport(BetJournalStats.Empty, Array.Empty<BetJournalRow>());
}
var distinctEventIds = bets.Select(b => b.EventId).Distinct().ToList();
// Batch the event loads (was N+1). The closing-snapshot lookup stays per-event
// because it pushes ORDER BY / LIMIT 1 down to SQLite (one indexed row each)
// and is parameterised by that event's ScheduledAt.
var events = await _events.GetManyAsync(distinctEventIds, ct).ConfigureAwait(false);
var closingByEvent = new Dictionary<DomainEventId, OddsSnapshot?>(distinctEventIds.Count);
foreach (var eventId in distinctEventIds)
{
ct.ThrowIfCancellationRequested();
if (!events.TryGetValue(eventId, out var ev))
{
closingByEvent[eventId] = null;
continue;
}
var closing = await _snapshots
.GetLatestPreMatchAsync(eventId, ev.ScheduledAt, ct)
.ConfigureAwait(false);
closingByEvent[eventId] = closing;
}
var rows = new List<BetJournalRow>(bets.Count);
foreach (var bet in bets)
{
ct.ThrowIfCancellationRequested();
closingByEvent.TryGetValue(bet.EventId, out var closing);
var clv = ClosingLineValueCalculator.TryCompute(
takenRate: bet.Selection.Rate.Value,
placedSelection: bet.Selection,
closingSnapshot: closing);
rows.Add(new BetJournalRow(bet, clv));
}
rows.Sort((a, b) => b.Bet.PlacedAt.CompareTo(a.Bet.PlacedAt));
var stats = ComputeStats(rows);
_logger.LogInformation(
"BuildBetJournalReportUseCase: report built — {Total} bets, {Resolved} resolved, ROI={Roi:0.##}%",
stats.TotalBets, stats.ResolvedCount, stats.RoiPercent ?? 0m);
return new BetJournalReport(stats, rows);
}
private static BetJournalStats ComputeStats(IReadOnlyList<BetJournalRow> rows)
{
if (rows.Count == 0) return BetJournalStats.Empty;
var pending = 0;
var won = 0;
var lost = 0;
var voided = 0;
// Industry-standard ROI excludes pushes from turnover — staking on a Void
// bet returns the stake and is functionally a no-op, so counting it as
// turnover dilutes the ROI denominator and understates the user's edge.
// Only Won + Lost contribute to TotalStaked / TotalReturned.
var totalStaked = 0m;
var totalReturned = 0m;
decimal clvSum = 0m;
var clvCount = 0;
foreach (var row in rows)
{
switch (row.Bet.Outcome)
{
case BetOutcome.Pending: pending++; break;
case BetOutcome.Won: won++; break;
case BetOutcome.Lost: lost++; break;
case BetOutcome.Void: voided++; break;
}
if (row.Bet.Outcome is BetOutcome.Won or BetOutcome.Lost)
{
totalStaked += row.Bet.Stake;
totalReturned += row.Bet.GrossReturn ?? 0m;
}
if (row.ClvProbabilityDelta is { } clv)
{
clvSum += clv;
clvCount++;
}
}
var netProfit = totalReturned - totalStaked;
var winLoss = won + lost;
decimal? roi = totalStaked > 0m
? Math.Round((netProfit / totalStaked) * 100m, 2)
: null;
decimal? strikeRate = winLoss > 0
? Math.Round(((decimal)won / winLoss) * 100m, 2)
: null;
// CLV inputs are already 6-decimal-rounded by ClosingLineValueCalculator;
// round the mean only at the display boundary to avoid compounding bias.
decimal? avgClv = clvCount > 0
? clvSum / clvCount
: null;
return new BetJournalStats(
TotalBets: rows.Count,
PendingCount: pending,
WonCount: won,
LostCount: lost,
VoidCount: voided,
TotalStaked: totalStaked,
TotalReturned: totalReturned,
NetProfit: netProfit,
RoiPercent: roi,
StrikeRatePercent: strikeRate,
AverageClvProbabilityDelta: avgClv);
}
}
@@ -0,0 +1,60 @@
using Marathon.Application.Abstractions;
using Marathon.Application.Storage;
using Marathon.Domain.Backtesting;
using Microsoft.Extensions.Logging;
namespace Marathon.Application.UseCases;
/// <summary>One saved strategy preset paired with its backtest result over a shared window.</summary>
public sealed record StrategyComparison(Guid StrategyId, string Name, BacktestResult Result);
/// <summary>
/// Runs every saved strategy preset over the same anomaly window and returns their
/// backtest results side by side, so the user can see which staking configuration wins.
/// </summary>
/// <remarks>
/// Delegates to <see cref="RunBacktestUseCase"/> once per preset — the anomaly set is
/// re-loaded per run, which is fine for the handful of presets a user keeps. Keeping the
/// composition at the use-case level (rather than re-implementing candidate loading) means
/// the comparison stays bug-for-bug identical to a single backtest run.
/// </remarks>
public sealed class CompareStrategiesUseCase
{
private readonly ISavedStrategyRepository _strategies;
private readonly RunBacktestUseCase _backtest;
private readonly ILogger<CompareStrategiesUseCase> _logger;
public CompareStrategiesUseCase(
ISavedStrategyRepository strategies,
RunBacktestUseCase backtest,
ILogger<CompareStrategiesUseCase> logger)
{
_strategies = strategies ?? throw new ArgumentNullException(nameof(strategies));
_backtest = backtest ?? throw new ArgumentNullException(nameof(backtest));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <summary>
/// Backtests each saved preset over <paramref name="dateRange"/> (null = all graded
/// anomalies). Returns one row per preset in saved (name-ascending) order; empty when
/// the user has saved no strategies.
/// </summary>
public async Task<IReadOnlyList<StrategyComparison>> ExecuteAsync(
DateRange? dateRange, CancellationToken ct = default)
{
var presets = await _strategies.ListAsync(ct).ConfigureAwait(false);
if (presets.Count == 0)
return Array.Empty<StrategyComparison>();
var rows = new List<StrategyComparison>(presets.Count);
foreach (var preset in presets)
{
ct.ThrowIfCancellationRequested();
var result = await _backtest.ExecuteAsync(preset.Strategy, dateRange, ct).ConfigureAwait(false);
rows.Add(new StrategyComparison(preset.Id, preset.Name, result));
}
_logger.LogInformation("CompareStrategiesUseCase: compared {Count} preset(s)", rows.Count);
return rows;
}
}
@@ -0,0 +1,29 @@
using Marathon.Application.Abstractions;
using Microsoft.Extensions.Logging;
namespace Marathon.Application.UseCases;
/// <summary>
/// Removes a <see cref="Marathon.Domain.Entities.PlacedBet"/> from the journal
/// by its identifier. Silent no-op when the id does not exist.
/// </summary>
public sealed class DeletePlacedBetUseCase
{
private readonly IPlacedBetRepository _bets;
private readonly ILogger<DeletePlacedBetUseCase> _logger;
public DeletePlacedBetUseCase(
IPlacedBetRepository bets,
ILogger<DeletePlacedBetUseCase> logger)
{
_bets = bets ?? throw new ArgumentNullException(nameof(bets));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task ExecuteAsync(Guid betId, CancellationToken ct = default)
{
await _bets.DeleteAsync(betId, ct).ConfigureAwait(false);
await _bets.SaveChangesAsync(ct).ConfigureAwait(false);
_logger.LogInformation("DeletePlacedBetUseCase: removed bet {BetId}", betId);
}
}
@@ -0,0 +1,26 @@
using Marathon.Application.Abstractions;
using Microsoft.Extensions.Logging;
namespace Marathon.Application.UseCases;
/// <summary>
/// Removes a saved strategy preset by id. Silent no-op when the id is unknown.
/// </summary>
public sealed class DeleteStrategyUseCase
{
private readonly ISavedStrategyRepository _repo;
private readonly ILogger<DeleteStrategyUseCase> _logger;
public DeleteStrategyUseCase(ISavedStrategyRepository repo, ILogger<DeleteStrategyUseCase> logger)
{
_repo = repo ?? throw new ArgumentNullException(nameof(repo));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task ExecuteAsync(Guid id, CancellationToken ct = default)
{
await _repo.DeleteAsync(id, ct).ConfigureAwait(false);
await _repo.SaveChangesAsync(ct).ConfigureAwait(false);
_logger.LogInformation("DeleteStrategyUseCase: removed preset {Id}", id);
}
}
@@ -0,0 +1,184 @@
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 + Kind + 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 detectors = new IAnomalyDetector[]
{
new AnomalyDetector(
_options.SuspensionGapSeconds,
_options.OddsFlipThreshold,
_options.MinSnapshotCount),
new SteamMoveDetector(
_options.SteamMoveWindowSeconds,
_options.SteamMoveDriftThreshold,
_options.MinSnapshotCount,
_options.SuspensionGapSeconds),
new SuspensionFreezeDetector(
_options.SuspensionGapSeconds,
_options.SuspensionFreezeThreshold,
_options.MinSnapshotCount),
new OverroundCompressionDetector(
_options.OverroundWindowSeconds,
_options.OverroundCompressionThreshold,
_options.MinSnapshotCount,
_options.SuspensionGapSeconds),
};
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 index them by event so dedup is O(1) per event instead of scanning the
// whole list each time (was O(events × anomalies)). Reviewer W1, Phase 7.
var existingAnomalies = await _anomalyRepo.ListAsync(ct);
var existingByEvent = existingAnomalies
.GroupBy(a => a.EventId)
.ToDictionary(g => g.Key, g => g.ToList());
// 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>();
var existingForEvent = existingByEvent.TryGetValue(ev.Id, out var slice)
? slice
: new List<Anomaly>();
newAnomalyCount += await ProcessEventAsync(detectors, ev, snapshots, existingForEvent, 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(
IReadOnlyList<IAnomalyDetector> detectors,
Event ev,
IReadOnlyList<OddsSnapshot> snapshots,
List<Anomaly> existingForEvent,
CancellationToken ct)
{
// Fan out over every detector; dedup below keys on EventId + Kind so the flip,
// steam, and freeze signals for one event persist independently.
var detected = detectors
.SelectMany(d => d.Detect(ev.Id, snapshots))
.ToList();
if (detected.Count == 0)
return 0;
int persisted = 0;
foreach (var anomaly in detected)
{
if (IsDuplicate(anomaly, existingForEvent))
continue;
await _anomalyRepo.AddAsync(anomaly, ct);
existingForEvent.Add(anomaly); // Keep local list in sync so the same cycle doesn't re-add.
persisted++;
}
// One write per event rather than per anomaly — with three detectors an event
// can yield several new anomalies in a single cycle.
if (persisted > 0)
await _anomalyRepo.SaveChangesAsync(ct);
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,261 @@
using System.Globalization;
using Marathon.Application.Abstractions;
using Marathon.Application.Reporting;
using Marathon.Domain.AnomalyDetection;
using Marathon.Domain.Entities;
using Microsoft.Extensions.Logging;
using DomainEventId = Marathon.Domain.ValueObjects.EventId;
namespace Marathon.Application.UseCases;
/// <summary>
/// Builds an <see cref="AnomalyOutcomeReport"/> by joining every persisted
/// <see cref="Anomaly"/> with the originating event and its
/// <see cref="EventResult"/>, then running the pure
/// <see cref="AnomalyOutcomeEvaluator"/> over each pair.
/// </summary>
/// <remarks>
/// <para>
/// This is the answer to "does the SuspensionFlip detector actually predict the
/// right side?" The report is the validator for the entire anomaly-detection
/// premise of the product — without it, the algorithm's confidence score is
/// just a number with no calibration.
/// </para>
/// <para>
/// The use case loads all three collections in one pass each and performs the
/// join in memory. Anomaly volumes are small (one per suspension interval per
/// event) so this is well within budget. If volumes grow significantly the
/// repository layer can later add a SQL-side join — the public shape of the
/// report does not change.
/// </para>
/// </remarks>
public sealed class EvaluateAnomalyOutcomesUseCase
{
/// <summary>
/// Lowest score bin shown in the histogram. Score values below this never
/// appear because the detector enforces a configurable threshold (default
/// 0.30) — but the constant is repeated here so the bucketer is independent
/// of any specific configuration value.
/// </summary>
public const decimal MinScore = 0.30m;
/// <summary>
/// Bin width for the score histogram. Yields 7 buckets:
/// [0.30, 0.40), [0.40, 0.50), [0.50, 0.60), [0.60, 0.70), [0.70, 0.80),
/// [0.80, 0.90), [0.90, 1.00]. The last bin is closed on the right.
/// </summary>
public const decimal BinWidth = 0.10m;
private readonly IAnomalyRepository _anomalies;
private readonly IEventRepository _events;
private readonly IResultRepository _results;
private readonly ILogger<EvaluateAnomalyOutcomesUseCase> _logger;
public EvaluateAnomalyOutcomesUseCase(
IAnomalyRepository anomalies,
IEventRepository events,
IResultRepository results,
ILogger<EvaluateAnomalyOutcomesUseCase> logger)
{
_anomalies = anomalies ?? throw new ArgumentNullException(nameof(anomalies));
_events = events ?? throw new ArgumentNullException(nameof(events));
_results = results ?? throw new ArgumentNullException(nameof(results));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<AnomalyOutcomeReport> ExecuteAsync(CancellationToken ct)
{
_logger.LogInformation("EvaluateAnomalyOutcomesUseCase: report build started");
var anomalies = await _anomalies.ListAsync(ct).ConfigureAwait(false);
if (anomalies.Count == 0)
{
_logger.LogInformation(
"EvaluateAnomalyOutcomesUseCase: no anomalies — empty report");
return EmptyReport();
}
// Batched lookups — a single query each, replacing the prior per-event
// GetAsync round-trip (N+1 against SQLite).
var distinctEventIds = anomalies.Select(a => a.EventId).Distinct().ToList();
var eventLookup = await _events.GetManyAsync(distinctEventIds, ct).ConfigureAwait(false);
var resultLookup = await _results.GetManyAsync(distinctEventIds, ct).ConfigureAwait(false);
var eventTitles = new Dictionary<DomainEventId, string>(eventLookup.Count);
foreach (var (id, ev) in eventLookup)
eventTitles[id] = ev.Title;
// Evaluate every anomaly through the pure domain function.
var resolved = new List<ResolvedAnomaly>();
var unresolved = new List<ResolvedAnomaly>();
foreach (var anomaly in anomalies)
{
ct.ThrowIfCancellationRequested();
eventLookup.TryGetValue(anomaly.EventId, out var ev);
resultLookup.TryGetValue(anomaly.EventId, out var result);
var evaluated = AnomalyOutcomeEvaluator.Evaluate(anomaly, ev?.Sport, result);
if (evaluated.Outcome == AnomalyOutcomeKind.Unresolved)
unresolved.Add(evaluated);
else
resolved.Add(evaluated);
}
var resolvedOrdered = resolved
.OrderByDescending(r => r.DetectedAt)
.ToList();
var unresolvedOrdered = unresolved
.OrderByDescending(r => r.DetectedAt)
.ToList();
var hitCount = resolvedOrdered.Count(r => r.Outcome == AnomalyOutcomeKind.Hit);
var missCount = resolvedOrdered.Count - hitCount;
var report = new AnomalyOutcomeReport(
TotalAnomalies: anomalies.Count,
ResolvedCount: resolvedOrdered.Count,
UnresolvedCount: unresolvedOrdered.Count,
HitCount: hitCount,
MissCount: missCount,
HitRate: ComputeRate(hitCount, resolvedOrdered.Count),
BySeverity: BuildSeverityBuckets(resolvedOrdered),
BySport: BuildSportBuckets(resolvedOrdered),
ByScoreBin: BuildScoreBins(resolvedOrdered),
ByKind: BuildKindBuckets(resolvedOrdered),
Resolved: resolvedOrdered,
Unresolved: unresolvedOrdered,
EventTitles: eventTitles);
_logger.LogInformation(
"EvaluateAnomalyOutcomesUseCase: report ready — total={Total}, resolved={Resolved}, hits={Hits}",
report.TotalAnomalies, report.ResolvedCount, report.HitCount);
return report;
}
// ── Bucketers ────────────────────────────────────────────────────────────
private static IReadOnlyList<OutcomeBucket> BuildSeverityBuckets(
IReadOnlyCollection<ResolvedAnomaly> resolved)
{
// Thresholds sourced from the Domain so the UI's severity badge and
// this report cannot drift out of sync — single source of truth.
return new[]
{
BuildBucket(OutcomeBucketKeys.SeverityLow,
resolved.Where(r => r.Score < AnomalySeverityThresholds.Medium)),
BuildBucket(OutcomeBucketKeys.SeverityMedium,
resolved.Where(r => r.Score >= AnomalySeverityThresholds.Medium
&& r.Score < AnomalySeverityThresholds.High)),
BuildBucket(OutcomeBucketKeys.SeverityHigh,
resolved.Where(r => r.Score >= AnomalySeverityThresholds.High)),
};
}
private static IReadOnlyList<OutcomeBucket> BuildSportBuckets(
IReadOnlyCollection<ResolvedAnomaly> resolved)
{
return resolved
.Where(r => r.Sport is not null)
.GroupBy(r => r.Sport!.Value)
.OrderBy(g => g.Key)
.Select(g => BuildBucket(
key: string.Format(
CultureInfo.InvariantCulture,
"{0}{1}",
OutcomeBucketKeys.SportPrefix,
g.Key),
items: g))
.ToList();
}
private static IReadOnlyList<OutcomeBucket> BuildKindBuckets(
IReadOnlyCollection<ResolvedAnomaly> resolved)
{
// Only directional kinds resolve to a hit/miss (the evaluator leaves the rest
// Unresolved), so this naturally shows just the directional detectors.
return resolved
.GroupBy(r => r.Kind)
.OrderBy(g => (int)g.Key)
.Select(g => BuildBucket(
key: OutcomeBucketKeys.KindPrefix + g.Key,
items: g))
.ToList();
}
private static IReadOnlyList<OutcomeBucket> BuildScoreBins(
IReadOnlyCollection<ResolvedAnomaly> resolved)
{
// Default range is the canonical [0.30, 1.00] with seven 0.10-wide bins.
// If the operator has lowered the detector's flip threshold and we have
// resolved anomalies below 0.30, prepend additional bins so every row in
// the report shows up in exactly one bucket — the histogram total must
// equal ResolvedCount no matter how the detector is tuned.
var floor = MinScore;
if (resolved.Count > 0)
{
var lowest = resolved.Min(r => r.Score);
if (lowest < MinScore)
{
var binsBelow = Math.Ceiling((MinScore - lowest) / BinWidth);
floor = MinScore - binsBelow * BinWidth;
if (floor < 0m) floor = 0m;
}
}
var bins = new List<OutcomeBucket>();
for (var start = floor; start < 1.0m; start += BinWidth)
{
var binStart = start;
var binEnd = start + BinWidth;
var isLast = binEnd >= 1.0m;
// Last bin is closed on the right so 1.00 lands in [0.90, 1.00].
var inBin = resolved.Where(r =>
r.Score >= binStart &&
(isLast ? r.Score <= 1.0m : r.Score < binEnd));
var key = string.Format(
CultureInfo.InvariantCulture,
"{0}{1:0.00}-{2:0.00}",
OutcomeBucketKeys.BinPrefix,
binStart,
Math.Min(binEnd, 1.0m));
bins.Add(BuildBucket(key, inBin));
}
return bins;
}
private static OutcomeBucket BuildBucket(string key, IEnumerable<ResolvedAnomaly> items)
{
var list = items as IReadOnlyCollection<ResolvedAnomaly> ?? items.ToList();
var total = list.Count;
var hits = list.Count(r => r.Outcome == AnomalyOutcomeKind.Hit);
return new OutcomeBucket(key, total, hits, ComputeRate(hits, total));
}
private static decimal? ComputeRate(int numerator, int denominator) =>
denominator == 0
? null
: Math.Round(numerator / (decimal)denominator, 4);
private static AnomalyOutcomeReport EmptyReport() =>
new(
TotalAnomalies: 0,
ResolvedCount: 0,
UnresolvedCount: 0,
HitCount: 0,
MissCount: 0,
HitRate: null,
BySeverity: Array.Empty<OutcomeBucket>(),
BySport: Array.Empty<OutcomeBucket>(),
ByScoreBin: Array.Empty<OutcomeBucket>(),
ByKind: Array.Empty<OutcomeBucket>(),
Resolved: Array.Empty<ResolvedAnomaly>(),
Unresolved: Array.Empty<ResolvedAnomaly>(),
EventTitles: new Dictionary<DomainEventId, string>());
}
@@ -0,0 +1,134 @@
using System.Globalization;
using System.Text;
using Marathon.Application.Abstractions;
using Marathon.Application.Export;
using Marathon.Application.Storage;
using Marathon.Domain.ValueObjects;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using DomainEventId = Marathon.Domain.ValueObjects.EventId;
namespace Marathon.Application.UseCases;
/// <summary>
/// Exports the bet journal and the paper-trading (forward-test) ledger to CSV files in
/// the configured export directory, returning each file's path (or null when there is
/// nothing to export). Mirrors <see cref="ExportToExcelUseCase"/>'s write-and-return-path
/// contract; CSV needs no third-party library so it stays in the Application layer.
/// </summary>
public sealed class ExportToCsvUseCase
{
// BOM so Excel opens UTF-8 (Cyrillic team names) correctly.
private static readonly Encoding Utf8Bom = new UTF8Encoding(encoderShouldEmitUTF8Identifier: true);
private readonly IPlacedBetRepository _bets;
private readonly IPaperBetRepository _paperBets;
private readonly IEventRepository _events;
private readonly IOptions<StorageOptions> _storage;
private readonly ILogger<ExportToCsvUseCase> _logger;
public ExportToCsvUseCase(
IPlacedBetRepository bets,
IPaperBetRepository paperBets,
IEventRepository events,
IOptions<StorageOptions> storage,
ILogger<ExportToCsvUseCase> logger)
{
_bets = bets ?? throw new ArgumentNullException(nameof(bets));
_paperBets = paperBets ?? throw new ArgumentNullException(nameof(paperBets));
_events = events ?? throw new ArgumentNullException(nameof(events));
_storage = storage ?? throw new ArgumentNullException(nameof(storage));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <summary>Writes the bet journal to CSV; returns the path, or null when empty.</summary>
public async Task<string?> ExportJournalAsync(CancellationToken ct = default)
{
var bets = await _bets.ListAsync(ct).ConfigureAwait(false);
if (bets.Count == 0)
return null;
var titles = await TitlesAsync(bets.Select(b => b.EventId), ct).ConfigureAwait(false);
var header = new[]
{
"PlacedAt", "Event", "EventId", "Type", "Side", "Value", "Rate", "Stake", "Outcome", "Profit", "Notes",
};
var rows = bets
.OrderByDescending(b => b.PlacedAt)
.Select(b => (IReadOnlyList<string>)new[]
{
b.PlacedAt.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture),
Title(titles, b.EventId),
Csv.NeutralizeFormula(b.EventId.Value),
b.Selection.Type.ToString(),
b.Selection.Side.ToString(),
b.Selection.Value?.Value.ToString(CultureInfo.InvariantCulture) ?? string.Empty,
b.Selection.Rate.Value.ToString(CultureInfo.InvariantCulture),
b.Stake.ToString(CultureInfo.InvariantCulture),
b.Outcome.ToString(),
b.NetProfit?.ToString(CultureInfo.InvariantCulture) ?? string.Empty,
Csv.NeutralizeFormula(b.Notes),
});
return await WriteAsync("journal", Csv.Document(header, rows), ct).ConfigureAwait(false);
}
/// <summary>Writes the paper-trading ledger to CSV; returns the path, or null when empty.</summary>
public async Task<string?> ExportPaperLedgerAsync(CancellationToken ct = default)
{
var bets = await _paperBets.ListAsync(ct).ConfigureAwait(false);
if (bets.Count == 0)
return null;
var titles = await TitlesAsync(bets.Select(b => b.EventId), ct).ConfigureAwait(false);
var header = new[]
{
"OpenedAt", "Event", "EventId", "PickedSide", "Rate", "Stake", "Outcome", "Payout", "SettledAt",
};
var rows = bets
.OrderByDescending(b => b.OpenedAt)
.Select(b => (IReadOnlyList<string>)new[]
{
b.OpenedAt.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture),
Title(titles, b.EventId),
Csv.NeutralizeFormula(b.EventId.Value),
b.PickedSide.ToString(),
b.Rate.ToString(CultureInfo.InvariantCulture),
b.Stake.ToString(CultureInfo.InvariantCulture),
b.Outcome.ToString(),
b.Payout?.ToString(CultureInfo.InvariantCulture) ?? string.Empty,
b.SettledAt?.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture) ?? string.Empty,
});
return await WriteAsync("forward-test", Csv.Document(header, rows), ct).ConfigureAwait(false);
}
private async Task<string> WriteAsync(string label, string content, CancellationToken ct)
{
var dir = _storage.Value.ExportDirectory;
Directory.CreateDirectory(dir);
var fileName = $"Marathon_{label}_{MoscowTime.Now:yyyy-MM-dd_HHmmss}.csv";
var path = Path.Combine(dir, fileName);
await File.WriteAllTextAsync(path, content, Utf8Bom, ct).ConfigureAwait(false);
_logger.LogInformation("ExportToCsvUseCase: wrote {Label} CSV → {Path}", label, path);
return path;
}
private async Task<IReadOnlyDictionary<DomainEventId, string>> TitlesAsync(
IEnumerable<DomainEventId> ids, CancellationToken ct)
{
var distinct = ids.Distinct().ToList();
var events = await _events.GetManyAsync(distinct, ct).ConfigureAwait(false);
var titles = new Dictionary<DomainEventId, string>(events.Count);
foreach (var (id, ev) in events)
titles[id] = ev.Title;
return titles;
}
// Titles are scraped ("Side1 vs Side2") so they're treated as untrusted text and
// neutralized against CSV/formula injection.
private static string Title(IReadOnlyDictionary<DomainEventId, string> titles, DomainEventId id) =>
Csv.NeutralizeFormula(titles.TryGetValue(id, out var t) ? t : id.Value);
}
@@ -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,62 @@
using Marathon.Application.Abstractions;
using Microsoft.Extensions.Logging;
namespace Marathon.Application.UseCases;
/// <summary>
/// Shapes the anomalies worth alerting on: those detected at or after a caller-supplied
/// marker whose score clears a minimum, joined with their event titles. Pure of any
/// transport concern — the dispatcher decides cadence and the sink decides delivery.
/// </summary>
/// <remarks>
/// Results are ordered oldest-first so the caller can advance its "since" marker to the
/// last item's <see cref="AnomalyNotification.DetectedAt"/> (plus one tick) without gaps
/// or duplicates.
/// </remarks>
public sealed class GetPendingAnomalyNotificationsUseCase
{
private readonly IAnomalyRepository _anomalies;
private readonly IEventRepository _events;
private readonly ILogger<GetPendingAnomalyNotificationsUseCase> _logger;
public GetPendingAnomalyNotificationsUseCase(
IAnomalyRepository anomalies,
IEventRepository events,
ILogger<GetPendingAnomalyNotificationsUseCase> logger)
{
_anomalies = anomalies ?? throw new ArgumentNullException(nameof(anomalies));
_events = events ?? throw new ArgumentNullException(nameof(events));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<IReadOnlyList<AnomalyNotification>> ExecuteAsync(
DateTimeOffset since,
decimal minScore,
CancellationToken ct)
{
// Date filter pushed to SQL; score filter is cheap in memory over the small slice.
var recent = await _anomalies.ListByDateRangeAsync(since, to: null, ct).ConfigureAwait(false);
var qualifying = recent.Where(a => a.Score >= minScore).ToList();
if (qualifying.Count == 0)
return Array.Empty<AnomalyNotification>();
var eventIds = qualifying.Select(a => a.EventId).Distinct().ToList();
var events = await _events.GetManyAsync(eventIds, ct).ConfigureAwait(false);
var notifications = qualifying
.OrderBy(a => a.DetectedAt)
.Select(a => new AnomalyNotification(
AnomalyId: a.Id,
EventTitle: events.TryGetValue(a.EventId, out var ev) ? ev.Title : a.EventId.Value,
Kind: a.Kind,
Score: a.Score,
DetectedAt: a.DetectedAt))
.ToList();
_logger.LogDebug(
"GetPendingAnomalyNotificationsUseCase: {Count} alert(s) since {Since:O} at minScore {MinScore}",
notifications.Count, since, minScore);
return notifications;
}
}
@@ -0,0 +1,84 @@
using Marathon.Application.Abstractions;
using Marathon.Domain.AnomalyDetection;
using Marathon.Domain.Entities;
using Marathon.Domain.Enums;
using Microsoft.Extensions.Logging;
namespace Marathon.Application.UseCases;
/// <summary>
/// Opens flat-stake paper bets for directional anomalies detected in
/// (<c>since</c>..<c>until</c>] whose score clears the threshold and that don't
/// already have one. The picked side is the post-flip favourite; the rate is that
/// side's post-suspension rate — locking in the price the moment the signal fired.
/// </summary>
public sealed class OpenPaperBetsUseCase
{
private readonly IAnomalyRepository _anomalies;
private readonly IPaperBetRepository _paperBets;
private readonly ILogger<OpenPaperBetsUseCase> _logger;
public OpenPaperBetsUseCase(
IAnomalyRepository anomalies,
IPaperBetRepository paperBets,
ILogger<OpenPaperBetsUseCase> logger)
{
_anomalies = anomalies ?? throw new ArgumentNullException(nameof(anomalies));
_paperBets = paperBets ?? throw new ArgumentNullException(nameof(paperBets));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <summary>Returns the paper bets opened this pass (empty when nothing qualified).</summary>
public async Task<IReadOnlyList<PaperBet>> ExecuteAsync(
DateTimeOffset since,
DateTimeOffset until,
decimal minScore,
decimal flatStake,
CancellationToken ct = default)
{
if (flatStake <= 0m)
throw new ArgumentOutOfRangeException(nameof(flatStake), flatStake, "Flat stake must be positive.");
var anomalies = await _anomalies.ListByDateRangeAsync(since, until, ct).ConfigureAwait(false);
// Only directional kinds make a side prediction worth forward-testing; the rest
// are informational and would just measure the base favourite-win rate.
var candidates = anomalies
.Where(a => a.Kind.IsDirectional() && a.Score >= minScore)
.ToList();
if (candidates.Count == 0)
return Array.Empty<PaperBet>();
var existing = await _paperBets
.GetExistingAnomalyIdsAsync(candidates.Select(a => a.Id).ToList(), ct)
.ConfigureAwait(false);
var opened = new List<PaperBet>();
foreach (var anomaly in candidates)
{
ct.ThrowIfCancellationRequested();
if (existing.Contains(anomaly.Id))
continue;
if (!AnomalyEvidenceParser.TryParse(anomaly.EvidenceJson, out var evidence))
continue;
var pick = evidence.PostSuspension.Favourite;
if (evidence.PostSuspension.RateFor(pick) is not { } rate || rate <= 1m)
continue;
opened.Add(PaperBet.Open(anomaly.Id, anomaly.EventId, pick, rate, flatStake, anomaly.DetectedAt));
}
if (opened.Count == 0)
return Array.Empty<PaperBet>();
foreach (var bet in opened)
await _paperBets.AddAsync(bet, ct).ConfigureAwait(false);
await _paperBets.SaveChangesAsync(ct).ConfigureAwait(false);
_logger.LogInformation("OpenPaperBetsUseCase: opened {Count} paper bet(s)", opened.Count);
return opened;
}
}
@@ -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,201 @@
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 })
{
// Batched load (was N+1); preserve the caller's selection order and
// silently drop ids with no stored event.
var events = await _eventRepo.GetManyAsync(selection, ct).ConfigureAwait(false);
var resolved = new List<Event>(selection.Count);
foreach (var id in selection)
{
if (events.TryGetValue(id, out var ev))
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,90 @@
using Marathon.Application.Abstractions;
using Marathon.Domain.Entities;
using Marathon.Domain.Enums;
using Microsoft.Extensions.Logging;
using DomainEventId = Marathon.Domain.ValueObjects.EventId;
namespace Marathon.Application.UseCases;
/// <summary>
/// Records a new <see cref="PlacedBet"/> entered manually via the Journal UI.
/// </summary>
/// <remarks>
/// <para>
/// The use case validates that the referenced event exists, then persists the
/// bet. If the event already has a final result the bet is graded on the spot
/// via <see cref="Marathon.Domain.Betting.BetOutcomeResolver"/> — saves the
/// user a round-trip to the resolver page when entering historical wagers.
/// </para>
/// </remarks>
public sealed class RecordPlacedBetUseCase
{
private readonly IPlacedBetRepository _bets;
private readonly IEventRepository _events;
private readonly IResultRepository _results;
private readonly ILogger<RecordPlacedBetUseCase> _logger;
public RecordPlacedBetUseCase(
IPlacedBetRepository bets,
IEventRepository events,
IResultRepository results,
ILogger<RecordPlacedBetUseCase> logger)
{
_bets = bets ?? throw new ArgumentNullException(nameof(bets));
_events = events ?? throw new ArgumentNullException(nameof(events));
_results = results ?? throw new ArgumentNullException(nameof(results));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <summary>
/// Persists <paramref name="bet"/>. Returns the bet as stored — if the
/// event already has a result, the returned instance reflects the graded
/// <see cref="BetOutcome"/>.
/// </summary>
/// <exception cref="InvalidOperationException">
/// The bet references an unknown event. The journal does not allow free-form
/// event codes — wagers must be on events the scraper has captured so the
/// CLV calculator can compare against the closing snapshot.
/// </exception>
public async Task<PlacedBet> ExecuteAsync(PlacedBet bet, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(bet);
// Confirm the event exists in the local store.
var ev = await _events.GetAsync(bet.EventId, ct).ConfigureAwait(false);
if (ev is null)
{
throw new InvalidOperationException(
$"Cannot record a bet on unknown event '{bet.EventId.Value}'. " +
"The event must already be present in the scrape store.");
}
var toPersist = bet;
// Auto-grade if a result is already available.
if (bet.Outcome == BetOutcome.Pending)
{
var result = await _results.GetAsync(bet.EventId, ct).ConfigureAwait(false);
if (result is not null)
{
var graded = Marathon.Domain.Betting.BetOutcomeResolver.Resolve(bet.Selection, result);
if (graded is not null)
{
toPersist = bet.WithOutcome(graded.Value);
_logger.LogInformation(
"RecordPlacedBetUseCase: bet {BetId} on event {EventId} auto-graded as {Outcome}",
toPersist.Id, ((DomainEventId)toPersist.EventId).Value, graded.Value);
}
}
}
await _bets.AddAsync(toPersist, ct).ConfigureAwait(false);
await _bets.SaveChangesAsync(ct).ConfigureAwait(false);
_logger.LogInformation(
"RecordPlacedBetUseCase: persisted bet {BetId} on event {EventId} stake={Stake} rate={Rate}",
toPersist.Id, ((DomainEventId)toPersist.EventId).Value, toPersist.Stake, toPersist.Selection.Rate.Value);
return toPersist;
}
}
@@ -0,0 +1,84 @@
using Marathon.Application.Abstractions;
using Marathon.Domain.Betting;
using Marathon.Domain.Entities;
using Marathon.Domain.Enums;
using Microsoft.Extensions.Logging;
using DomainEventId = Marathon.Domain.ValueObjects.EventId;
namespace Marathon.Application.UseCases;
/// <summary>
/// Sweeps the journal for <see cref="BetOutcome.Pending"/> bets whose events
/// have been graded, and updates them in bulk via
/// <see cref="BetOutcomeResolver"/>.
/// </summary>
/// <remarks>
/// Called on demand from the Journal page's "Resolve pending" button. The
/// design is idempotent — bets that cannot be auto-graded (period-scope, or
/// no result yet) are left untouched and surface again on the next pass.
/// </remarks>
public sealed class ResolvePendingBetsUseCase
{
private readonly IPlacedBetRepository _bets;
private readonly IResultRepository _results;
private readonly ILogger<ResolvePendingBetsUseCase> _logger;
public ResolvePendingBetsUseCase(
IPlacedBetRepository bets,
IResultRepository results,
ILogger<ResolvePendingBetsUseCase> logger)
{
_bets = bets ?? throw new ArgumentNullException(nameof(bets));
_results = results ?? throw new ArgumentNullException(nameof(results));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <summary>
/// Returns the number of bets that were transitioned out of Pending in this pass.
/// </summary>
public async Task<int> ExecuteAsync(CancellationToken ct = default)
{
var pending = await _bets.ListByOutcomeAsync(BetOutcome.Pending, ct).ConfigureAwait(false);
if (pending.Count == 0)
{
_logger.LogInformation("ResolvePendingBetsUseCase: no pending bets");
return 0;
}
// Cache results per event so we do not re-query for each bet on the same event.
var resultCache = new Dictionary<DomainEventId, EventResult?>();
var resolvedCount = 0;
foreach (var bet in pending)
{
ct.ThrowIfCancellationRequested();
if (!resultCache.TryGetValue(bet.EventId, out var result))
{
result = await _results.GetAsync(bet.EventId, ct).ConfigureAwait(false);
resultCache[bet.EventId] = result;
}
if (result is null) continue;
var graded = BetOutcomeResolver.Resolve(bet.Selection, result);
if (graded is null) continue;
var updated = bet.WithOutcome(graded.Value);
await _bets.UpdateAsync(updated, ct).ConfigureAwait(false);
resolvedCount++;
}
// Save before logging — if the batch fails, an exception bubbles out and
// the success-count log is never emitted; we never report a graded count
// that was rolled back.
if (resolvedCount > 0)
await _bets.SaveChangesAsync(ct).ConfigureAwait(false);
_logger.LogInformation(
"ResolvePendingBetsUseCase: graded {Resolved} of {Pending} pending bets",
resolvedCount, pending.Count);
return resolvedCount;
}
}
@@ -0,0 +1,124 @@
using Marathon.Application.Abstractions;
using Marathon.Application.Storage;
using Marathon.Domain.AnomalyDetection;
using Marathon.Domain.Backtesting;
using Marathon.Domain.Entities;
using Marathon.Domain.Enums;
using Microsoft.Extensions.Logging;
using DomainEventId = Marathon.Domain.ValueObjects.EventId;
namespace Marathon.Application.UseCases;
/// <summary>
/// Loads every persisted anomaly paired with its event metadata and result,
/// constructs <see cref="BacktestCandidate"/> rows, and runs the pure
/// <see cref="BacktestSimulator"/> with the supplied strategy.
/// </summary>
/// <remarks>
/// <para>
/// Composes the two analytics features already in place: anomalies come from
/// the SuspensionFlip detector, and results come from the results loader. The
/// simulator never touches I/O — all data loading happens here, then the run
/// is a deterministic function of (strategy, candidates).
/// </para>
/// <para>
/// Anomalies whose evidence JSON fails to parse, whose source events lack a
/// final result, or whose event row has been pruned are filtered out before
/// simulation. They are not counted as "skipped" by the simulator — the
/// simulator's <see cref="BacktestResult.Skipped"/> counter only reflects
/// runs the strategy chose not to bet on (below threshold, no edge, etc.).
/// </para>
/// </remarks>
public sealed class RunBacktestUseCase
{
private readonly IAnomalyRepository _anomalies;
private readonly IEventRepository _events;
private readonly IResultRepository _results;
private readonly ILogger<RunBacktestUseCase> _logger;
public RunBacktestUseCase(
IAnomalyRepository anomalies,
IEventRepository events,
IResultRepository results,
ILogger<RunBacktestUseCase> logger)
{
_anomalies = anomalies ?? throw new ArgumentNullException(nameof(anomalies));
_events = events ?? throw new ArgumentNullException(nameof(events));
_results = results ?? throw new ArgumentNullException(nameof(results));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <summary>Runs the backtest over every graded anomaly (no date filter).</summary>
public Task<BacktestResult> ExecuteAsync(
BacktestStrategy strategy,
CancellationToken ct = default)
=> ExecuteAsync(strategy, dateRange: null, ct);
/// <summary>
/// Runs the backtest over anomalies detected within <paramref name="dateRange"/>
/// (inclusive); pass <c>null</c> to include every graded anomaly. The date filter
/// is pushed to SQL via <see cref="IAnomalyRepository.ListByDateRangeAsync"/>.
/// </summary>
public async Task<BacktestResult> ExecuteAsync(
BacktestStrategy strategy,
DateRange? dateRange,
CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(strategy);
_logger.LogInformation(
"RunBacktestUseCase: started — bankroll={Bankroll}, minScore={MinScore}, stakeRule={Rule}, range={Range}",
strategy.StartingBankroll, strategy.MinScore, strategy.StakeRule,
dateRange is null ? "all" : $"{dateRange.From:O}..{dateRange.To:O}");
var anomalies = dateRange is null
? await _anomalies.ListAsync(ct).ConfigureAwait(false)
: await _anomalies.ListByDateRangeAsync(dateRange.From, dateRange.To, ct).ConfigureAwait(false);
if (anomalies.Count == 0)
{
_logger.LogInformation("RunBacktestUseCase: no anomalies — empty result");
return BacktestSimulator.Run(strategy, Array.Empty<BacktestCandidate>());
}
// Batched lookups — a single query each, replacing the prior per-event
// GetAsync round-trip (N+1 against SQLite).
var distinctEventIds = anomalies.Select(a => a.EventId).Distinct().ToList();
var eventLookup = await _events.GetManyAsync(distinctEventIds, ct).ConfigureAwait(false);
var resultLookup = await _results.GetManyAsync(distinctEventIds, ct).ConfigureAwait(false);
var titles = new Dictionary<DomainEventId, string>(eventLookup.Count);
foreach (var (id, ev) in eventLookup)
titles[id] = ev.Title;
var candidates = new List<BacktestCandidate>(anomalies.Count);
foreach (var anomaly in anomalies)
{
ct.ThrowIfCancellationRequested();
// Only directional kinds are betting signals; SuspensionFreeze (favourite
// unchanged) is informational and must not be staked or it would skew ROI.
if (!anomaly.Kind.IsDirectional())
continue;
// Cannot simulate a bet whose event hasn't been graded yet.
if (!resultLookup.TryGetValue(anomaly.EventId, out var result))
continue;
if (!AnomalyEvidenceParser.TryParse(anomaly.EvidenceJson, out var evidence))
continue;
eventLookup.TryGetValue(anomaly.EventId, out var ev);
candidates.Add(new BacktestCandidate(anomaly, evidence, result, ev?.Sport));
}
var simResult = BacktestSimulator.Run(strategy, candidates, titles);
_logger.LogInformation(
"RunBacktestUseCase: done — bets={Bets}, wins={Wins}, losses={Losses}, ROI={Roi:0.##}%, finalBankroll={Final}",
simResult.BetsPlaced, simResult.Wins, simResult.Losses,
simResult.RoiPercent ?? 0m, simResult.FinalBankroll);
return simResult;
}
}
@@ -0,0 +1,50 @@
using Marathon.Application.Abstractions;
using Marathon.Domain.Backtesting;
using Microsoft.Extensions.Logging;
namespace Marathon.Application.UseCases;
/// <summary>
/// Persists a named backtest-strategy preset. Upserts by name: saving under an
/// existing name overwrites that preset's configuration (keeping its identity and
/// original creation timestamp); a fresh name creates a new preset.
/// </summary>
public sealed class SaveStrategyUseCase
{
private readonly ISavedStrategyRepository _repo;
private readonly ILogger<SaveStrategyUseCase> _logger;
public SaveStrategyUseCase(ISavedStrategyRepository repo, ILogger<SaveStrategyUseCase> logger)
{
_repo = repo ?? throw new ArgumentNullException(nameof(repo));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <summary>Saves <paramref name="strategy"/> under <paramref name="name"/>.</summary>
/// <exception cref="ArgumentException">The name is empty or exceeds the length bound.</exception>
public async Task<SavedStrategy> ExecuteAsync(
string name, BacktestStrategy strategy, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(strategy);
// Validates + trims the name once, up front (throws ArgumentException if bad).
var candidate = SavedStrategy.Create(name, strategy);
var existing = await _repo.GetByNameAsync(candidate.Name, ct).ConfigureAwait(false);
if (existing is not null)
{
var updated = existing with { Strategy = strategy };
await _repo.UpdateAsync(updated, ct).ConfigureAwait(false);
await _repo.SaveChangesAsync(ct).ConfigureAwait(false);
_logger.LogInformation(
"SaveStrategyUseCase: overwrote preset {Name} ({Id})", updated.Name, updated.Id);
return updated;
}
await _repo.AddAsync(candidate, ct).ConfigureAwait(false);
await _repo.SaveChangesAsync(ct).ConfigureAwait(false);
_logger.LogInformation(
"SaveStrategyUseCase: created preset {Name} ({Id})", candidate.Name, candidate.Id);
return candidate;
}
}
@@ -0,0 +1,61 @@
using Marathon.Application.Abstractions;
using Marathon.Domain.Enums;
using Marathon.Domain.ValueObjects;
using Microsoft.Extensions.Logging;
namespace Marathon.Application.UseCases;
/// <summary>
/// Settles every open (<see cref="BetOutcome.Pending"/>) paper bet whose event now has
/// a final result — Won when the picked side matches the winner, otherwise Lost. Bets
/// on events that aren't graded yet stay open and are retried next cycle.
/// </summary>
public sealed class SettlePaperBetsUseCase
{
private readonly IPaperBetRepository _paperBets;
private readonly IResultRepository _results;
private readonly ILogger<SettlePaperBetsUseCase> _logger;
public SettlePaperBetsUseCase(
IPaperBetRepository paperBets,
IResultRepository results,
ILogger<SettlePaperBetsUseCase> logger)
{
_paperBets = paperBets ?? throw new ArgumentNullException(nameof(paperBets));
_results = results ?? throw new ArgumentNullException(nameof(results));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <summary>Returns the number of paper bets settled this pass.</summary>
public async Task<int> ExecuteAsync(CancellationToken ct = default)
{
var open = await _paperBets.ListByOutcomeAsync(BetOutcome.Pending, ct).ConfigureAwait(false);
if (open.Count == 0)
return 0;
// Batched result lookup — one query, not one per open bet.
var eventIds = open.Select(b => b.EventId).Distinct().ToList();
var results = await _results.GetManyAsync(eventIds, ct).ConfigureAwait(false);
var settledAt = MoscowTime.Now;
var settled = 0;
foreach (var bet in open)
{
ct.ThrowIfCancellationRequested();
if (!results.TryGetValue(bet.EventId, out var result))
continue; // event not graded yet
await _paperBets.UpdateAsync(bet.SettleAgainst(result.WinnerSide, settledAt), ct).ConfigureAwait(false);
settled++;
}
if (settled > 0)
{
await _paperBets.SaveChangesAsync(ct).ConfigureAwait(false);
_logger.LogInformation("SettlePaperBetsUseCase: settled {Count} paper bet(s)", settled);
}
return settled;
}
}
@@ -0,0 +1,96 @@
using Marathon.Application.Abstractions;
using Marathon.Domain.Entities;
using Marathon.Domain.Enums;
using Microsoft.Extensions.Logging;
using DomainEventId = Marathon.Domain.ValueObjects.EventId;
namespace Marathon.Application.UseCases;
/// <summary>
/// Edits an existing <see cref="PlacedBet"/> in the journal — selection, stake, or
/// notes. The original <see cref="PlacedBet.PlacedAt"/> is preserved; the outcome is
/// re-graded from scratch (so changing the selection or event re-settles correctly).
/// </summary>
public sealed class UpdatePlacedBetUseCase
{
private readonly IPlacedBetRepository _bets;
private readonly IEventRepository _events;
private readonly IResultRepository _results;
private readonly ILogger<UpdatePlacedBetUseCase> _logger;
public UpdatePlacedBetUseCase(
IPlacedBetRepository bets,
IEventRepository events,
IResultRepository results,
ILogger<UpdatePlacedBetUseCase> logger)
{
_bets = bets ?? throw new ArgumentNullException(nameof(bets));
_events = events ?? throw new ArgumentNullException(nameof(events));
_results = results ?? throw new ArgumentNullException(nameof(results));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <exception cref="InvalidOperationException">
/// The bet id is unknown, or the (possibly changed) event isn't in the store.
/// </exception>
public async Task<PlacedBet> ExecuteAsync(
Guid id,
DomainEventId eventId,
Bet selection,
decimal stake,
string? notes,
CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(eventId);
ArgumentNullException.ThrowIfNull(selection);
var existing = await _bets.GetAsync(id, ct).ConfigureAwait(false)
?? throw new InvalidOperationException($"Cannot update unknown bet '{id}'.");
var ev = await _events.GetAsync(eventId, ct).ConfigureAwait(false);
if (ev is null)
{
throw new InvalidOperationException(
$"Cannot point a bet at unknown event '{eventId.Value}'. " +
"The event must already be present in the scrape store.");
}
// Only the selection or the event affects grading. When neither changed (e.g. a
// stake/notes-only edit) keep the existing outcome — re-grading from Pending here
// would SILENTLY UN-SETTLE a won/lost bet whose result row has since been pruned by
// snapshot retention (the journal is FK-free and outlives result pruning). A
// still-Pending bet is always (re)graded, mirroring RecordPlacedBetUseCase.
var gradingInputChanged = !existing.EventId.Equals(eventId)
|| !existing.Selection.Equals(selection);
var regrade = gradingInputChanged || existing.Outcome == BetOutcome.Pending;
var toPersist = new PlacedBet(
Id: id,
EventId: eventId,
Selection: selection,
Stake: stake,
PlacedAt: existing.PlacedAt,
Outcome: regrade ? BetOutcome.Pending : existing.Outcome,
Notes: notes);
if (regrade)
{
var result = await _results.GetAsync(eventId, ct).ConfigureAwait(false);
if (result is not null)
{
var graded = Marathon.Domain.Betting.BetOutcomeResolver.Resolve(toPersist.Selection, result);
if (graded is not null)
toPersist = toPersist.WithOutcome(graded.Value);
}
}
await _bets.UpdateAsync(toPersist, ct).ConfigureAwait(false);
await _bets.SaveChangesAsync(ct).ConfigureAwait(false);
_logger.LogInformation(
"UpdatePlacedBetUseCase: updated bet {BetId} on event {EventId} stake={Stake} outcome={Outcome}",
id, eventId.Value, stake, toPersist.Outcome);
return toPersist;
}
}
@@ -0,0 +1,148 @@
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. Evidence formatting is delegated to
/// <see cref="MatchWinEvidence"/> so every detector kind writes the identical shape.
/// </summary>
public sealed class AnomalyDetector : IAnomalyDetector
{
private readonly int _suspensionGapSeconds;
private readonly decimal _oddsFlipThreshold;
private readonly int _minSnapshotCount;
/// <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;
}
/// <inheritdoc />
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 implied probabilities from each snapshot.
var preProbs = MatchWinEvidence.Extract(interval.PreSuspension);
var postProbs = MatchWinEvidence.Extract(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 =
MatchWinEvidence.Favourite(preProbs) != MatchWinEvidence.Favourite(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 via the shared formatter.
var evidenceJson = MatchWinEvidence.BuildJson(
(int)interval.Gap.TotalSeconds,
interval.PreSuspension, preProbs,
interval.PostSuspension, postProbs);
return new Anomaly(
Id: Guid.NewGuid(),
EventId: eventId,
DetectedAt: MoscowTime.Now,
Kind: AnomalyKind.SuspensionFlip,
Score: clampedScore,
EvidenceJson: evidenceJson);
}
}
@@ -0,0 +1,158 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using Marathon.Domain.Entities;
using Marathon.Domain.Enums;
namespace Marathon.Domain.AnomalyDetection;
/// <summary>
/// Strongly typed projection of the JSON payload written by <see cref="AnomalyDetector"/>
/// into <see cref="Anomaly.EvidenceJson"/>. Captures pre- and post-suspension snapshots
/// of normalised implied probabilities and raw rates for the Match-Win market.
/// </summary>
/// <remarks>
/// The evaluator and any reader that needs to inspect an anomaly's evidence should
/// parse via <see cref="AnomalyEvidenceParser.TryParse"/> rather than re-implement
/// the JSON shape — the detector owns the schema.
/// </remarks>
public sealed record AnomalyEvidenceData(
int SuspensionGapSeconds,
AnomalyEvidenceSide PreSuspension,
AnomalyEvidenceSide PostSuspension);
/// <summary>
/// One side (pre or post) of a suspension interval. Probabilities are normalised
/// so that <c>P1 + (PDraw ?? 0) + P2 == 1</c>. Two-way markets (e.g. tennis)
/// leave <see cref="PDraw"/> and <see cref="RateDraw"/> null.
/// </summary>
public sealed record AnomalyEvidenceSide(
DateTimeOffset CapturedAt,
decimal P1,
decimal? PDraw,
decimal P2,
decimal Rate1,
decimal? RateDraw,
decimal Rate2)
{
/// <summary>
/// The side carrying the highest normalised implied probability — i.e.,
/// the bookmaker's favourite at this point in time.
/// </summary>
public Side Favourite
{
get
{
// Three-way: include Draw in the argmax.
var best = Side.Side1;
var bestValue = P1;
if (PDraw is { } pd && pd > bestValue)
{
best = Side.Draw;
bestValue = pd;
}
if (P2 > bestValue)
{
best = Side.Side2;
}
return best;
}
}
/// <summary>
/// The decimal rate offered on <paramref name="side"/> at this snapshot, or null
/// for a non-win side (Less/More) or an absent Draw market.
/// </summary>
public decimal? RateFor(Side side) => side switch
{
Side.Side1 => Rate1,
Side.Side2 => Rate2,
Side.Draw => RateDraw,
_ => null,
};
}
/// <summary>
/// Parses the <see cref="Anomaly.EvidenceJson"/> string emitted by
/// <see cref="AnomalyDetector"/>. Tolerant of malformed payloads — returns false
/// rather than throwing so callers can skip un-parseable anomalies silently.
/// </summary>
public static class AnomalyEvidenceParser
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true,
};
/// <summary>
/// Attempts to deserialise the evidence JSON. Returns <c>true</c> only when
/// both pre- and post-suspension snapshots are present.
/// </summary>
public static bool TryParse(string? evidenceJson, out AnomalyEvidenceData data)
{
data = default!;
if (string.IsNullOrWhiteSpace(evidenceJson)) return false;
try
{
var dto = JsonSerializer.Deserialize<EvidenceDto>(evidenceJson, JsonOptions);
if (dto is null || dto.PreSuspension is null || dto.PostSuspension is null)
return false;
data = new AnomalyEvidenceData(
SuspensionGapSeconds: dto.SuspensionGapSeconds,
PreSuspension: ToSide(dto.PreSuspension),
PostSuspension: ToSide(dto.PostSuspension));
return true;
}
catch (JsonException)
{
return false;
}
}
private static AnomalyEvidenceSide ToSide(EvidenceSideDto dto) =>
new(
CapturedAt: dto.CapturedAt,
P1: dto.P1 ?? 0m,
PDraw: dto.PDraw,
P2: dto.P2 ?? 0m,
Rate1: dto.Rate1 ?? 0m,
RateDraw: dto.RateDraw,
Rate2: dto.Rate2 ?? 0m);
private sealed class EvidenceDto
{
[JsonPropertyName("suspensionGapSeconds")]
public int SuspensionGapSeconds { get; init; }
[JsonPropertyName("preSuspension")]
public EvidenceSideDto? PreSuspension { get; init; }
[JsonPropertyName("postSuspension")]
public EvidenceSideDto? PostSuspension { get; init; }
}
private sealed class EvidenceSideDto
{
[JsonPropertyName("capturedAt")]
public DateTimeOffset CapturedAt { get; init; }
[JsonPropertyName("p1")]
public decimal? P1 { get; init; }
[JsonPropertyName("pDraw")]
public decimal? PDraw { get; init; }
[JsonPropertyName("p2")]
public decimal? P2 { get; init; }
[JsonPropertyName("rate1")]
public decimal? Rate1 { get; init; }
[JsonPropertyName("rateDraw")]
public decimal? RateDraw { get; init; }
[JsonPropertyName("rate2")]
public decimal? Rate2 { get; init; }
}
}
@@ -0,0 +1,51 @@
using Marathon.Domain.Entities;
using Marathon.Domain.Enums;
using Marathon.Domain.ValueObjects;
namespace Marathon.Domain.AnomalyDetection;
/// <summary>
/// Verdict produced by comparing an anomaly's predicted post-flip favourite
/// against the actual <see cref="EventResult.WinnerSide"/>.
/// </summary>
public enum AnomalyOutcomeKind
{
/// <summary>
/// The post-flip favourite (the side the bookmaker shortened odds on AFTER
/// the suspension) ended up winning. The flip was directionally correct.
/// </summary>
Hit,
/// <summary>
/// The post-flip favourite did NOT win. The flip pointed at the wrong side.
/// </summary>
Miss,
/// <summary>
/// No <see cref="EventResult"/> is available yet — outcome cannot be judged.
/// </summary>
Unresolved,
}
/// <summary>
/// One anomaly paired with its evaluated outcome. Surfaced to the UI so each
/// resolved anomaly can be reviewed individually (e.g., when investigating
/// why the algorithm got a specific event wrong).
/// </summary>
/// <remarks>
/// <see cref="PreFlipFavourite"/> and <see cref="PostFlipFavourite"/> are null
/// when the anomaly's evidence JSON could not be parsed — the outcome will be
/// <see cref="AnomalyOutcomeKind.Unresolved"/> in that case. Encoding the
/// absence keeps consumers from being shown a fabricated side.
/// </remarks>
public sealed record ResolvedAnomaly(
Guid AnomalyId,
EventId EventId,
DateTimeOffset DetectedAt,
decimal Score,
AnomalyKind Kind,
SportCode? Sport,
Side? PreFlipFavourite,
Side? PostFlipFavourite,
Side? ActualWinner,
AnomalyOutcomeKind Outcome);
@@ -0,0 +1,136 @@
using Marathon.Domain.Entities;
using Marathon.Domain.Enums;
using Marathon.Domain.ValueObjects;
namespace Marathon.Domain.AnomalyDetection;
/// <summary>
/// Pure domain function that evaluates whether a <see cref="AnomalyKind.SuspensionFlip"/>
/// anomaly's prediction (the post-suspension favourite) matched the actual
/// <see cref="EventResult.WinnerSide"/>.
/// </summary>
/// <remarks>
/// <para>
/// A "hit" is recorded when the side carrying the highest implied probability
/// in <see cref="AnomalyEvidenceData.PostSuspension"/> equals
/// <see cref="EventResult.WinnerSide"/>. For two-way markets (tennis), Draw is
/// not a possible favourite — the evaluator naturally never emits Draw there.
/// </para>
/// <para>
/// Stateless, deterministic, no I/O. Safe to call in tight loops.
/// </para>
/// </remarks>
public static class AnomalyOutcomeEvaluator
{
/// <summary>
/// Evaluates one anomaly against its event (optional metadata) and its result
/// (optional — null when the match hasn't been graded yet).
/// </summary>
/// <param name="anomaly">The persisted anomaly.</param>
/// <param name="sport">
/// The event's sport — surfaced into <see cref="ResolvedAnomaly"/> so the UI
/// can group by sport. Null when the originating event row is missing.
/// </param>
/// <param name="result">The event's final result, if known.</param>
/// <returns>
/// A <see cref="ResolvedAnomaly"/> with <see cref="AnomalyOutcomeKind.Unresolved"/>
/// when <paramref name="result"/> is null or the evidence JSON cannot be parsed,
/// otherwise <see cref="AnomalyOutcomeKind.Hit"/> / <see cref="AnomalyOutcomeKind.Miss"/>.
/// </returns>
public static ResolvedAnomaly Evaluate(
Anomaly anomaly,
SportCode? sport,
EventResult? result)
{
ArgumentNullException.ThrowIfNull(anomaly);
if (!AnomalyEvidenceParser.TryParse(anomaly.EvidenceJson, out var data))
{
// Cannot determine favourite without evidence; treat as unresolved.
return new ResolvedAnomaly(
AnomalyId: anomaly.Id,
EventId: anomaly.EventId,
DetectedAt: anomaly.DetectedAt,
Score: anomaly.Score,
Kind: anomaly.Kind,
Sport: sport,
PreFlipFavourite: null,
PostFlipFavourite: null,
ActualWinner: result?.WinnerSide,
Outcome: AnomalyOutcomeKind.Unresolved);
}
var preFav = data.PreSuspension.Favourite;
var postFav = data.PostSuspension.Favourite;
// Non-directional kinds (e.g. SuspensionFreeze — the favourite did NOT change)
// make no side prediction. Grading them as "favourite won" would just measure the
// base favourite-win rate, polluting the hit-rate and score-bin calibration, so we
// leave them Unresolved (the favourites are still surfaced for display).
if (!anomaly.Kind.IsDirectional())
{
return new ResolvedAnomaly(
AnomalyId: anomaly.Id,
EventId: anomaly.EventId,
DetectedAt: anomaly.DetectedAt,
Score: anomaly.Score,
Kind: anomaly.Kind,
Sport: sport,
PreFlipFavourite: preFav,
PostFlipFavourite: postFav,
ActualWinner: result?.WinnerSide,
Outcome: AnomalyOutcomeKind.Unresolved);
}
if (result is null)
{
return new ResolvedAnomaly(
AnomalyId: anomaly.Id,
EventId: anomaly.EventId,
DetectedAt: anomaly.DetectedAt,
Score: anomaly.Score,
Kind: anomaly.Kind,
Sport: sport,
PreFlipFavourite: preFav,
PostFlipFavourite: postFav,
ActualWinner: null,
Outcome: AnomalyOutcomeKind.Unresolved);
}
// Guard rail for sport-specific impossibilities. A two-way market
// (e.g. tennis) cannot produce a Draw outcome — if one shows up the
// EventResult disagrees with the evidence schema, so we refuse to
// grade it instead of silently counting it as a Miss.
var isTwoWay = data.PreSuspension.PDraw is null && data.PostSuspension.PDraw is null;
if (isTwoWay && result.WinnerSide == Side.Draw)
{
return new ResolvedAnomaly(
AnomalyId: anomaly.Id,
EventId: anomaly.EventId,
DetectedAt: anomaly.DetectedAt,
Score: anomaly.Score,
Kind: anomaly.Kind,
Sport: sport,
PreFlipFavourite: preFav,
PostFlipFavourite: postFav,
ActualWinner: result.WinnerSide,
Outcome: AnomalyOutcomeKind.Unresolved);
}
var outcome = postFav == result.WinnerSide
? AnomalyOutcomeKind.Hit
: AnomalyOutcomeKind.Miss;
return new ResolvedAnomaly(
AnomalyId: anomaly.Id,
EventId: anomaly.EventId,
DetectedAt: anomaly.DetectedAt,
Score: anomaly.Score,
Kind: anomaly.Kind,
Sport: sport,
PreFlipFavourite: preFav,
PostFlipFavourite: postFav,
ActualWinner: result.WinnerSide,
Outcome: outcome);
}
}
@@ -0,0 +1,29 @@
namespace Marathon.Domain.AnomalyDetection;
/// <summary>
/// Single source of truth for the severity bucket boundaries that the UI
/// pill / badge, the Insights breakdowns, and any future reporter share.
/// </summary>
/// <remarks>
/// Buckets are inclusive on the left, exclusive on the right (except High
/// which extends to 1.00 inclusive):
/// <list type="bullet">
/// <item>Low [<see cref="Low"/>, <see cref="Medium"/>)</item>
/// <item>Medium [<see cref="Medium"/>, <see cref="High"/>)</item>
/// <item>High [<see cref="High"/>, 1.00]</item>
/// </list>
/// Defined at the Domain layer so both the Application reporter and the
/// Marathon.UI severity rules consume the same numbers — re-tuning happens
/// in one place.
/// </remarks>
public static class AnomalySeverityThresholds
{
/// <summary>Lower bound of the Low bucket. Matches the detector's default flip threshold.</summary>
public const decimal Low = 0.30m;
/// <summary>Lower bound of the Medium bucket.</summary>
public const decimal Medium = 0.45m;
/// <summary>Lower bound of the High bucket.</summary>
public const decimal High = 0.60m;
}
@@ -0,0 +1,18 @@
using Marathon.Domain.Entities;
using Marathon.Domain.ValueObjects;
namespace Marathon.Domain.AnomalyDetection;
/// <summary>
/// A pure, stateless detector that scans one event's snapshot timeline and returns
/// any anomalies it finds. Implementations are deterministic and free of I/O so they
/// can be composed (fanned out) and unit-tested in isolation.
/// </summary>
public interface IAnomalyDetector
{
/// <summary>
/// Analyses <paramref name="snapshots"/> for <paramref name="eventId"/> and returns
/// 0 or more anomalies. May be empty; never null.
/// </summary>
IReadOnlyList<Anomaly> Detect(EventId eventId, IReadOnlyList<OddsSnapshot> snapshots);
}
@@ -0,0 +1,124 @@
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>
/// Shared helper for the match-win implied-probability extraction and the canonical
/// pre/post evidence-JSON shape used by every <see cref="IAnomalyDetector"/>.
/// </summary>
/// <remarks>
/// Centralising the evidence format here guarantees that all detector kinds write the
/// identical on-disk shape, so the UI parser (<c>AnomalyEvidenceParser</c>) and the
/// outcome evaluator (<see cref="AnomalyOutcomeEvaluator"/>) work for every kind
/// without branching. The <c>suspensionGapSeconds</c> field carries the elapsed
/// seconds between the two snapshots — a suspension gap for flips, a drift window for
/// steam moves.
/// </remarks>
internal static class MatchWinEvidence
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
WriteIndented = false,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
};
/// <summary>
/// Normalised match-win implied probabilities + raw rates for a snapshot.
/// <see cref="Overround"/> is the raw implied-probability sum (the bookmaker's
/// margin/vig, &gt;= 1.0) before normalisation.
/// </summary>
public sealed record Probabilities(
decimal P1,
decimal? PDraw,
decimal P2,
decimal Rate1,
decimal? RateDraw,
decimal Rate2,
decimal Overround);
/// <summary>
/// Extracts normalised match-win implied probabilities, or null when the snapshot
/// lacks both Side1 and Side2 Match-Win bets.
/// </summary>
public static Probabilities? Extract(OddsSnapshot snapshot)
{
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;
var drawBet = snapshot.Bets
.FirstOrDefault(b => b.Scope is MatchScope && b.Type == BetType.Draw);
// Raw implied probabilities: p = 1 / rate; normalise so they sum to 1.
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;
return new Probabilities(
P1: rawP1 / total,
PDraw: drawBet is not null ? rawDraw / total : null,
P2: rawP2 / total,
Rate1: win1.Rate.Value,
RateDraw: drawBet?.Rate.Value,
Rate2: win2.Rate.Value,
Overround: total);
}
/// <summary>Label of the side carrying the highest normalised implied probability.</summary>
public static string Favourite(Probabilities p)
{
if (p.PDraw.HasValue && p.PDraw.Value > p.P1 && p.PDraw.Value > p.P2)
return "Draw";
return p.P1 >= p.P2 ? "Side1" : "Side2";
}
/// <summary>Serialises the canonical pre/post evidence payload.</summary>
public static string BuildJson(
int gapSeconds,
OddsSnapshot pre,
Probabilities preProbs,
OddsSnapshot post,
Probabilities postProbs)
{
var payload = new EvidencePayload(
SuspensionGapSeconds: gapSeconds,
PreSuspension: ToEvidence(pre, preProbs),
PostSuspension: ToEvidence(post, postProbs));
return JsonSerializer.Serialize(payload, JsonOptions);
}
private static SnapshotEvidence ToEvidence(OddsSnapshot snapshot, Probabilities p) =>
new(
CapturedAt: snapshot.CapturedAt.ToString("O"),
P1: p.P1,
PDraw: p.PDraw,
P2: p.P2,
Rate1: p.Rate1,
RateDraw: p.RateDraw,
Rate2: p.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,115 @@
using Marathon.Domain.Entities;
using Marathon.Domain.Enums;
using Marathon.Domain.ValueObjects;
namespace Marathon.Domain.AnomalyDetection;
/// <summary>
/// Detects an "overround compression": the bookmaker's margin (the raw implied-probability
/// sum, &gt;= 1.0) drops sharply over a short CONTINUOUS window — the book tightens its vig,
/// often ahead of news or when it is confident in the line.
/// </summary>
/// <remarks>
/// <para>
/// Like the steam-move detector, it only considers windows with no suspension-sized gap
/// (controlled by <c>maxStepGapSeconds</c>), so it never overlaps the across-suspension
/// flip / freeze detectors. It is informational (non-directional) — the score is the
/// compression intensity, not a side prediction — so the outcome evaluator and backtest
/// exclude it (see <c>AnomalyKind.IsDirectional</c>).
/// </para>
/// <para>
/// Score scales the margin drop against a reference collapse: a drop of
/// <see cref="ReferenceCompression"/> (10 margin points) or more reads as a full-strength
/// signal (1.0); the configured <c>compressionThreshold</c> is the minimum drop to flag.
/// </para>
/// </remarks>
public sealed class OverroundCompressionDetector : IAnomalyDetector
{
/// <summary>A 10-margin-point collapse maps to the maximum score of 1.0.</summary>
public const decimal ReferenceCompression = 0.10m;
private readonly int _windowSeconds;
private readonly decimal _compressionThreshold;
private readonly int _minSnapshotCount;
private readonly int _maxStepGapSeconds;
public OverroundCompressionDetector(
int windowSeconds, decimal compressionThreshold, int minSnapshotCount, int maxStepGapSeconds)
{
if (windowSeconds <= 0)
throw new ArgumentOutOfRangeException(nameof(windowSeconds), windowSeconds, "Must be positive.");
if (compressionThreshold is <= 0m or >= 1m)
throw new ArgumentOutOfRangeException(nameof(compressionThreshold), compressionThreshold, "Must be in (0, 1).");
if (minSnapshotCount < 2)
throw new ArgumentOutOfRangeException(nameof(minSnapshotCount), minSnapshotCount, "Must be at least 2.");
if (maxStepGapSeconds <= 0)
throw new ArgumentOutOfRangeException(nameof(maxStepGapSeconds), maxStepGapSeconds, "Must be positive.");
_windowSeconds = windowSeconds;
_compressionThreshold = compressionThreshold;
_minSnapshotCount = minSnapshotCount;
_maxStepGapSeconds = maxStepGapSeconds;
}
/// <inheritdoc />
public IReadOnlyList<Anomaly> Detect(EventId eventId, IReadOnlyList<OddsSnapshot> snapshots)
{
ArgumentNullException.ThrowIfNull(eventId);
ArgumentNullException.ThrowIfNull(snapshots);
var live = snapshots
.Where(s => s.Source == OddsSource.Live)
.OrderBy(s => s.CapturedAt)
.ToList();
if (live.Count < _minSnapshotCount)
return Array.Empty<Anomaly>();
var window = TimeSpan.FromSeconds(_windowSeconds);
var maxStepGap = TimeSpan.FromSeconds(_maxStepGapSeconds);
var anomalies = new List<Anomaly>();
int windowStart = 0;
int continuityStart = 0;
for (int end = 1; end < live.Count; end++)
{
if (live[end].CapturedAt - live[end - 1].CapturedAt > maxStepGap)
continuityStart = end;
while (live[end].CapturedAt - live[windowStart].CapturedAt > window)
windowStart++;
int start = Math.Max(windowStart, continuityStart);
if (start >= end)
continue;
var pre = MatchWinEvidence.Extract(live[start]);
var post = MatchWinEvidence.Extract(live[end]);
if (pre is null || post is null)
continue;
// Compression is measured start-to-end of the current window. Because the loop
// emits at every `end` over a sliding window, an intra-window dip that later
// recovers is still flagged on the iteration whose `end` lands on the trough
// (where `start` still holds the pre-dip margin), so a separate peak-to-trough
// scan is unnecessary. Positive = the margin shrank (book tightened).
var compression = pre.Overround - post.Overround;
if (compression < _compressionThreshold)
continue;
var gapSeconds = (int)(live[end].CapturedAt - live[start].CapturedAt).TotalSeconds;
var evidenceJson = MatchWinEvidence.BuildJson(gapSeconds, live[start], pre, live[end], post);
anomalies.Add(new Anomaly(
Id: Guid.NewGuid(),
EventId: eventId,
DetectedAt: MoscowTime.Now,
Kind: AnomalyKind.OverroundCompression,
Score: Math.Min(1m, compression / ReferenceCompression),
EvidenceJson: evidenceJson));
}
return anomalies.AsReadOnly();
}
}
@@ -0,0 +1,121 @@
using Marathon.Domain.Entities;
using Marathon.Domain.Enums;
using Marathon.Domain.ValueObjects;
namespace Marathon.Domain.AnomalyDetection;
/// <summary>
/// Detects a "steam move": a rapid, one-directional rise in a side's normalised
/// implied probability over a short CONTINUOUS window — money moving the line.
/// </summary>
/// <remarks>
/// <para>
/// A window is only considered when it contains no suspension-sized gap between
/// consecutive snapshots (controlled by <c>maxStepGapSeconds</c>); drift across a
/// suspension is the <see cref="AnomalyDetector"/>'s (SuspensionFlip) territory, so
/// the two detectors never double-flag the same interval.
/// </para>
/// <para>
/// Emits an <see cref="AnomalyKind.SteamMove"/> anomaly whose pre/post evidence
/// brackets the drift, written in the shared <see cref="MatchWinEvidence"/> shape so
/// the UI and <see cref="AnomalyOutcomeEvaluator"/> handle it without branching.
/// A sustained steam may cross the threshold at several consecutive snapshots; those
/// are collapsed to one persisted row by the detection use case's dedup window.
/// </para>
/// </remarks>
public sealed class SteamMoveDetector : IAnomalyDetector
{
private readonly int _windowSeconds;
private readonly decimal _driftThreshold;
private readonly int _minSnapshotCount;
private readonly int _maxStepGapSeconds;
/// <param name="windowSeconds">Trailing window (seconds) over which drift is measured.</param>
/// <param name="driftThreshold">Minimum one-directional implied-probability rise to flag; in (0, 1).</param>
/// <param name="minSnapshotCount">Minimum live snapshots before detection runs (>= 2).</param>
/// <param name="maxStepGapSeconds">
/// Maximum gap between consecutive snapshots for the window to count as continuous.
/// A larger gap means a suspension occurred — that is flip territory, not steam.
/// </param>
public SteamMoveDetector(int windowSeconds, decimal driftThreshold, int minSnapshotCount, int maxStepGapSeconds)
{
if (windowSeconds <= 0)
throw new ArgumentOutOfRangeException(nameof(windowSeconds), windowSeconds, "Must be positive.");
if (driftThreshold is <= 0m or >= 1m)
throw new ArgumentOutOfRangeException(nameof(driftThreshold), driftThreshold, "Must be in (0, 1).");
if (minSnapshotCount < 2)
throw new ArgumentOutOfRangeException(nameof(minSnapshotCount), minSnapshotCount, "Must be at least 2.");
if (maxStepGapSeconds <= 0)
throw new ArgumentOutOfRangeException(nameof(maxStepGapSeconds), maxStepGapSeconds, "Must be positive.");
_windowSeconds = windowSeconds;
_driftThreshold = driftThreshold;
_minSnapshotCount = minSnapshotCount;
_maxStepGapSeconds = maxStepGapSeconds;
}
/// <inheritdoc />
public IReadOnlyList<Anomaly> Detect(EventId eventId, IReadOnlyList<OddsSnapshot> snapshots)
{
ArgumentNullException.ThrowIfNull(eventId);
ArgumentNullException.ThrowIfNull(snapshots);
var live = snapshots
.Where(s => s.Source == OddsSource.Live)
.OrderBy(s => s.CapturedAt)
.ToList();
if (live.Count < _minSnapshotCount)
return Array.Empty<Anomaly>();
var window = TimeSpan.FromSeconds(_windowSeconds);
var maxStepGap = TimeSpan.FromSeconds(_maxStepGapSeconds);
var anomalies = new List<Anomaly>();
int windowStart = 0;
int continuityStart = 0;
for (int end = 1; end < live.Count; end++)
{
// A suspension-sized step resets continuity: the drift after it is a flip,
// not a steam move, so steam windows never span a suspension.
if (live[end].CapturedAt - live[end - 1].CapturedAt > maxStepGap)
continuityStart = end;
// Shrink the trailing window so [windowStart, end] is within windowSeconds.
while (live[end].CapturedAt - live[windowStart].CapturedAt > window)
windowStart++;
int start = Math.Max(windowStart, continuityStart);
if (start >= end)
continue;
var pre = MatchWinEvidence.Extract(live[start]);
var post = MatchWinEvidence.Extract(live[end]);
if (pre is null || post is null)
continue;
// One-directional rise: a side's normalised probability INCREASED (odds
// shortened) by at least the threshold — money steamed onto that side.
decimal drift = Math.Max(post.P1 - pre.P1, post.P2 - pre.P2);
if (pre.PDraw.HasValue && post.PDraw.HasValue)
drift = Math.Max(drift, post.PDraw.Value - pre.PDraw.Value);
if (drift < _driftThreshold)
continue;
var gapSeconds = (int)(live[end].CapturedAt - live[start].CapturedAt).TotalSeconds;
var evidenceJson = MatchWinEvidence.BuildJson(gapSeconds, live[start], pre, live[end], post);
anomalies.Add(new Anomaly(
Id: Guid.NewGuid(),
EventId: eventId,
DetectedAt: MoscowTime.Now,
Kind: AnomalyKind.SteamMove,
Score: Math.Min(1m, drift),
EvidenceJson: evidenceJson));
}
return anomalies.AsReadOnly();
}
}
@@ -0,0 +1,107 @@
using Marathon.Domain.Entities;
using Marathon.Domain.Enums;
using Marathon.Domain.ValueObjects;
namespace Marathon.Domain.AnomalyDetection;
/// <summary>
/// Detects a "suspension freeze": the market was suspended (a gap larger than
/// <c>suspensionGapSeconds</c> between adjacent live snapshots) but resumed with
/// essentially the same line — the favourite is unchanged and the largest normalised
/// implied-probability move is below <c>freezeThreshold</c>.
/// </summary>
/// <remarks>
/// <para>
/// This is the mirror image of <see cref="AnomalyDetector"/> (SuspensionFlip): the flip
/// fires on a large favourite-changing move across a suspension; the freeze fires when
/// the bookmaker paused but did <i>not</i> move — a tell that they were uncertain or
/// gathering information rather than repricing.
/// </para>
/// <para>
/// Score = how completely the line froze: <c>1 (maxMove / freezeThreshold)</c>, so a
/// perfectly unchanged line scores ~1.0 and one near the threshold scores near 0. The
/// shared <see cref="MatchWinEvidence"/> shape (pre ≈ post) conveys the freeze directly,
/// and the outcome evaluator grades the unchanged favourite like any other anomaly.
/// </para>
/// </remarks>
public sealed class SuspensionFreezeDetector : IAnomalyDetector
{
private readonly int _suspensionGapSeconds;
private readonly decimal _freezeThreshold;
private readonly int _minSnapshotCount;
/// <param name="suspensionGapSeconds">Minimum adjacent-snapshot gap (seconds) classed as a suspension.</param>
/// <param name="freezeThreshold">Maximum normalised probability move to count as frozen; in (0, 1).</param>
/// <param name="minSnapshotCount">Minimum live snapshots before detection runs (>= 2).</param>
public SuspensionFreezeDetector(int suspensionGapSeconds, decimal freezeThreshold, int minSnapshotCount)
{
if (suspensionGapSeconds <= 0)
throw new ArgumentOutOfRangeException(nameof(suspensionGapSeconds), suspensionGapSeconds, "Must be positive.");
if (freezeThreshold is <= 0m or >= 1m)
throw new ArgumentOutOfRangeException(nameof(freezeThreshold), freezeThreshold, "Must be in (0, 1).");
if (minSnapshotCount < 2)
throw new ArgumentOutOfRangeException(nameof(minSnapshotCount), minSnapshotCount, "Must be at least 2.");
_suspensionGapSeconds = suspensionGapSeconds;
_freezeThreshold = freezeThreshold;
_minSnapshotCount = minSnapshotCount;
}
/// <inheritdoc />
public IReadOnlyList<Anomaly> Detect(EventId eventId, IReadOnlyList<OddsSnapshot> snapshots)
{
ArgumentNullException.ThrowIfNull(eventId);
ArgumentNullException.ThrowIfNull(snapshots);
var live = snapshots
.Where(s => s.Source == OddsSource.Live)
.OrderBy(s => s.CapturedAt)
.ToList();
if (live.Count < _minSnapshotCount)
return Array.Empty<Anomaly>();
var suspensionGap = TimeSpan.FromSeconds(_suspensionGapSeconds);
var anomalies = new List<Anomaly>();
for (int i = 0; i < live.Count - 1; i++)
{
var pre = live[i];
var post = live[i + 1];
if (post.CapturedAt - pre.CapturedAt <= suspensionGap)
continue;
var preProbs = MatchWinEvidence.Extract(pre);
var postProbs = MatchWinEvidence.Extract(post);
if (preProbs is null || postProbs is null)
continue;
decimal maxMove = Math.Max(
Math.Abs(postProbs.P1 - preProbs.P1),
Math.Abs(postProbs.P2 - preProbs.P2));
if (preProbs.PDraw.HasValue && postProbs.PDraw.HasValue)
maxMove = Math.Max(maxMove, Math.Abs(postProbs.PDraw.Value - preProbs.PDraw.Value));
var favouriteUnchanged =
MatchWinEvidence.Favourite(preProbs) == MatchWinEvidence.Favourite(postProbs);
// Strictly below the threshold so the score stays in (0, 1].
if (!favouriteUnchanged || maxMove >= _freezeThreshold)
continue;
var score = 1m - (maxMove / _freezeThreshold);
var gapSeconds = (int)(post.CapturedAt - pre.CapturedAt).TotalSeconds;
var evidenceJson = MatchWinEvidence.BuildJson(gapSeconds, pre, preProbs, post, postProbs);
anomalies.Add(new Anomaly(
Id: Guid.NewGuid(),
EventId: eventId,
DetectedAt: MoscowTime.Now,
Kind: AnomalyKind.SuspensionFreeze,
Score: score,
EvidenceJson: evidenceJson));
}
return anomalies.AsReadOnly();
}
}
@@ -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;
}
@@ -0,0 +1,24 @@
using Marathon.Domain.AnomalyDetection;
using Marathon.Domain.Entities;
using Marathon.Domain.ValueObjects;
namespace Marathon.Domain.Backtesting;
/// <summary>
/// Input row for <see cref="BacktestSimulator"/> — one anomaly fully resolved
/// against its event metadata and result. The use case constructs these once
/// per simulation run and feeds them to the pure simulator in chronological
/// order.
/// </summary>
/// <param name="Anomaly">The flagged anomaly being simulated.</param>
/// <param name="Evidence">
/// Parsed evidence payload (pre- and post-suspension snapshots). The simulator
/// reads the post-suspension favourite and rate from here.
/// </param>
/// <param name="Result">Final event result — drives the win/loss verdict.</param>
/// <param name="Sport">Sport metadata, optional, surfaced into the trace row.</param>
public sealed record BacktestCandidate(
Anomaly Anomaly,
AnomalyEvidenceData Evidence,
EventResult Result,
SportCode? Sport);
@@ -0,0 +1,113 @@
using Marathon.Domain.Entities;
using Marathon.Domain.Enums;
using Marathon.Domain.ValueObjects;
namespace Marathon.Domain.Backtesting;
/// <summary>
/// Aggregate output of one simulation run. Contains both the headline numbers
/// the user looks at (final bankroll, ROI, max drawdown) and the per-bet
/// trace needed to draw an equity curve.
/// </summary>
/// <param name="StartingBankroll">Echoed from the strategy for the UI.</param>
/// <param name="FinalBankroll">Bankroll after the last simulated bet settled.</param>
/// <param name="NetProfit"><c>FinalBankroll StartingBankroll</c>.</param>
/// <param name="RoiPercent">
/// <c>NetProfit / TotalStaked × 100</c>. Null when no bets were placed
/// (no anomaly met the threshold, or the bankroll went to zero before any
/// stake could be sized).
/// </param>
/// <param name="TotalStaked">Sum of stake sizes across every settled bet.</param>
/// <param name="TotalReturned">Sum of gross returns across every settled bet.</param>
/// <param name="MaxDrawdown">
/// Largest peak-to-trough drop in bankroll observed during the run, as an
/// absolute amount. Always ≥ 0.
/// </param>
/// <param name="MaxDrawdownPercent">
/// <see cref="MaxDrawdown"/> as a percentage of the peak that preceded it.
/// Null when there were no draws (no bets or no losses).
/// </param>
/// <param name="BetsPlaced">Total bets the strategy actually placed.</param>
/// <param name="Wins">Settled bets whose post-flip favourite won.</param>
/// <param name="Losses">Settled bets whose post-flip favourite lost.</param>
/// <param name="Skipped">
/// Total anomalies inspected but skipped. Equals
/// <see cref="SkippedByThreshold"/> + <see cref="SkippedByDataQuality"/> +
/// <see cref="SkippedByBankroll"/>. Surfaced separately so the UI can
/// distinguish a strategy choice ("threshold too high") from a real-world
/// signal ("bankroll empty") or a data-quality issue.
/// </param>
/// <param name="SkippedByThreshold">
/// Skipped because <c>Anomaly.Score &lt; strategy.MinScore</c> — pure strategy choice.
/// </param>
/// <param name="SkippedByDataQuality">
/// Skipped because the evidence parsed but the post-flip favourite has no
/// rate / probability, or because a two-way market produced a Draw winner.
/// Strategy-orthogonal — these would be skipped under any rule.
/// </param>
/// <param name="SkippedByBankroll">
/// Skipped because the sized stake was non-positive (Kelly returned no edge,
/// or bankroll was depleted) or exceeded the current bankroll.
/// </param>
/// <param name="MaxWinStreak">Longest run of consecutive wins.</param>
/// <param name="MaxLossStreak">Longest run of consecutive losses.</param>
/// <param name="Trace">
/// Per-bet records in chronological order — drives the equity curve.
/// </param>
/// <param name="EventTitles">
/// Pre-shaped <c>"Side1Name vs Side2Name"</c> strings keyed by event id, for
/// every event in <see cref="Trace"/>. Carried alongside the result so the UI
/// projection does not need a second pass over <c>IEventRepository</c>.
/// Missing events (pruned by retention) are absent from the map; consumers
/// fall back to <c>EventId.Value</c>.
/// </param>
public sealed record BacktestResult(
decimal StartingBankroll,
decimal FinalBankroll,
decimal NetProfit,
decimal? RoiPercent,
decimal TotalStaked,
decimal TotalReturned,
decimal MaxDrawdown,
decimal? MaxDrawdownPercent,
int BetsPlaced,
int Wins,
int Losses,
int Skipped,
int SkippedByThreshold,
int SkippedByDataQuality,
int SkippedByBankroll,
int MaxWinStreak,
int MaxLossStreak,
IReadOnlyList<BacktestTrace> Trace,
IReadOnlyDictionary<Marathon.Domain.ValueObjects.EventId, string> EventTitles);
/// <summary>
/// One settled simulated bet. Carries enough metadata to surface a
/// drill-down row and a point on the equity curve.
/// </summary>
/// <param name="AnomalyId">Source anomaly for the link-back affordance.</param>
/// <param name="EventId">Event being bet on.</param>
/// <param name="DetectedAt">When the anomaly was originally detected.</param>
/// <param name="Score">Confidence score of the anomaly.</param>
/// <param name="Sport">Sport metadata if available — null when the event is missing.</param>
/// <param name="PostFlipFavourite">Side bet on (the post-suspension favourite).</param>
/// <param name="TakenRate">Rate at which the simulator "bought" the bet (post-flip rate).</param>
/// <param name="Stake">Stake sized for this bet.</param>
/// <param name="WinnerSide">Actual winner of the event.</param>
/// <param name="IsWin"><c>true</c> if the post-flip favourite was the winner.</param>
/// <param name="Payout">Gross return — <c>Stake × Rate</c> for a win, 0 for a loss.</param>
/// <param name="BankrollAfter">Bankroll after this bet settled — equity-curve y-axis.</param>
public sealed record BacktestTrace(
Guid AnomalyId,
EventId EventId,
DateTimeOffset DetectedAt,
decimal Score,
SportCode? Sport,
Side PostFlipFavourite,
decimal TakenRate,
decimal Stake,
Side WinnerSide,
bool IsWin,
decimal Payout,
decimal BankrollAfter);
@@ -0,0 +1,248 @@
using Marathon.Domain.Enums;
namespace Marathon.Domain.Backtesting;
/// <summary>
/// Pure simulator that replays a <see cref="BacktestStrategy"/> over a
/// chronological list of <see cref="BacktestCandidate"/> rows and returns the
/// resulting <see cref="BacktestResult"/>. No I/O, no DI — safe to call in
/// hot loops or property tests.
/// </summary>
/// <remarks>
/// <para>
/// Loop body per candidate:
/// <list type="number">
/// <item>Skip if <c>Anomaly.Score &lt; strategy.MinScore</c>.</item>
/// <item>
/// Skip if the evidence is two-way and the actual winner is <c>Draw</c>:
/// this mirrors <c>AnomalyOutcomeEvaluator</c> — we refuse to grade
/// selections that are structurally impossible for the market.
/// </item>
/// <item>Compute stake from the chosen <see cref="StakeRule"/>.</item>
/// <item>Skip when the stake is non-positive (Kelly returned no edge, or bankroll empty).</item>
/// <item>Settle: payout = stake × rate when the post-flip favourite won, 0 otherwise.</item>
/// <item>Update bankroll, streaks, and running peak-to-trough drawdown.</item>
/// </list>
/// </para>
/// </remarks>
public static class BacktestSimulator
{
public static BacktestResult Run(
BacktestStrategy strategy,
IReadOnlyList<BacktestCandidate> candidates,
IReadOnlyDictionary<Marathon.Domain.ValueObjects.EventId, string>? eventTitles = null)
{
ArgumentNullException.ThrowIfNull(strategy);
ArgumentNullException.ThrowIfNull(candidates);
var bankroll = strategy.StartingBankroll;
var peakBankroll = bankroll;
var maxDrawdown = 0m;
decimal? maxDrawdownPct = null;
var trace = new List<BacktestTrace>();
var totalStaked = 0m;
var totalReturned = 0m;
var wins = 0;
var losses = 0;
var skippedByThreshold = 0;
var skippedByDataQuality = 0;
var skippedByBankroll = 0;
var currentWinStreak = 0;
var currentLossStreak = 0;
var maxWinStreak = 0;
var maxLossStreak = 0;
// Process in chronological order so bankroll progression is meaningful.
var ordered = candidates
.OrderBy(c => c.Anomaly.DetectedAt)
.ToList();
foreach (var candidate in ordered)
{
if (candidate.Anomaly.Score < strategy.MinScore)
{
skippedByThreshold++;
continue;
}
var postFav = candidate.Evidence.PostSuspension.Favourite;
var isTwoWay = candidate.Evidence.PreSuspension.PDraw is null
&& candidate.Evidence.PostSuspension.PDraw is null;
if (isTwoWay && candidate.Result.WinnerSide == Side.Draw)
{
// Data inconsistency — refuse to grade.
skippedByDataQuality++;
continue;
}
var (postRate, postProb) = ExtractPostFlipRateAndProbability(candidate.Evidence, postFav);
if (postRate is null || postProb is null)
{
skippedByDataQuality++;
continue;
}
var stake = SizeStake(
strategy: strategy,
bankroll: bankroll,
postRate: postRate.Value,
postProb: postProb.Value);
if (stake <= 0m || stake > bankroll)
{
// Either Kelly returned no edge, or the user is broke. Either way
// do not place this bet.
skippedByBankroll++;
continue;
}
var isWin = postFav == candidate.Result.WinnerSide;
var payout = isWin ? stake * postRate.Value : 0m;
bankroll = bankroll - stake + payout;
totalStaked += stake;
totalReturned += payout;
if (isWin)
{
wins++;
currentWinStreak++;
currentLossStreak = 0;
maxWinStreak = Math.Max(maxWinStreak, currentWinStreak);
}
else
{
losses++;
currentLossStreak++;
currentWinStreak = 0;
maxLossStreak = Math.Max(maxLossStreak, currentLossStreak);
}
// Drawdown tracking: peak is the running maximum bankroll observed
// before the current point; drawdown is peak current. We update
// peak only on new highs so the trough is measured from the right
// reference.
if (bankroll > peakBankroll)
{
peakBankroll = bankroll;
}
else
{
var dd = peakBankroll - bankroll;
if (dd > maxDrawdown)
{
maxDrawdown = dd;
maxDrawdownPct = peakBankroll > 0m
? Math.Round((dd / peakBankroll) * 100m, 2)
: null;
}
}
// Round money columns away-from-zero so a -0.005 stake reads as "-0.01"
// — the convention every accountant in the world expects.
trace.Add(new BacktestTrace(
AnomalyId: candidate.Anomaly.Id,
EventId: candidate.Anomaly.EventId,
DetectedAt: candidate.Anomaly.DetectedAt,
Score: candidate.Anomaly.Score,
Sport: candidate.Sport,
PostFlipFavourite: postFav,
TakenRate: postRate.Value,
Stake: Math.Round(stake, 2, MidpointRounding.AwayFromZero),
WinnerSide: candidate.Result.WinnerSide,
IsWin: isWin,
Payout: Math.Round(payout, 2, MidpointRounding.AwayFromZero),
BankrollAfter: Math.Round(bankroll, 2, MidpointRounding.AwayFromZero)));
}
decimal? roi = totalStaked > 0m
? Math.Round(((bankroll - strategy.StartingBankroll) / totalStaked) * 100m, 2,
MidpointRounding.AwayFromZero)
: null;
var totalSkipped = skippedByThreshold + skippedByDataQuality + skippedByBankroll;
return new BacktestResult(
StartingBankroll: strategy.StartingBankroll,
FinalBankroll: Math.Round(bankroll, 2, MidpointRounding.AwayFromZero),
NetProfit: Math.Round(bankroll - strategy.StartingBankroll, 2, MidpointRounding.AwayFromZero),
RoiPercent: roi,
TotalStaked: Math.Round(totalStaked, 2, MidpointRounding.AwayFromZero),
TotalReturned: Math.Round(totalReturned, 2, MidpointRounding.AwayFromZero),
MaxDrawdown: Math.Round(maxDrawdown, 2, MidpointRounding.AwayFromZero),
MaxDrawdownPercent: maxDrawdownPct,
BetsPlaced: trace.Count,
Wins: wins,
Losses: losses,
Skipped: totalSkipped,
SkippedByThreshold: skippedByThreshold,
SkippedByDataQuality: skippedByDataQuality,
SkippedByBankroll: skippedByBankroll,
MaxWinStreak: maxWinStreak,
MaxLossStreak: maxLossStreak,
Trace: trace,
EventTitles: eventTitles
?? new Dictionary<Marathon.Domain.ValueObjects.EventId, string>());
}
// ── Helpers ──────────────────────────────────────────────────────────────
private static (decimal? Rate, decimal? Probability) ExtractPostFlipRateAndProbability(
AnomalyDetection.AnomalyEvidenceData evidence,
Side favourite)
{
var post = evidence.PostSuspension;
return favourite switch
{
Side.Side1 => (post.Rate1, post.P1),
Side.Side2 => (post.Rate2, post.P2),
Side.Draw => (post.RateDraw, post.PDraw),
_ => (null, null),
};
}
private static decimal SizeStake(
BacktestStrategy strategy,
decimal bankroll,
decimal postRate,
decimal postProb)
{
if (bankroll <= 0m) return 0m;
return strategy.StakeRule switch
{
StakeRule.Flat => strategy.FlatStake,
StakeRule.PercentOfBankroll => bankroll * strategy.PercentOfBankroll,
StakeRule.Kelly => ComputeKellyStake(
bankroll: bankroll,
postRate: postRate,
postProb: postProb,
fraction: strategy.KellyFraction),
_ => 0m,
};
}
private static decimal ComputeKellyStake(
decimal bankroll,
decimal postRate,
decimal postProb,
decimal fraction)
{
// Kelly: f* = (b·p q) / b where b = rate 1, p = win prob, q = 1 p.
// Skip non-positive edge (no bet rather than betting "negative size").
var b = postRate - 1m;
if (b <= 0m) return 0m;
var p = postProb;
var q = 1m - p;
var fullKelly = ((b * p) - q) / b;
if (fullKelly <= 0m) return 0m;
// Quarter / half / etc.-Kelly: scale full edge by the configured fraction.
var stakeFraction = fullKelly * fraction;
return bankroll * stakeFraction;
}
}
@@ -0,0 +1,72 @@
namespace Marathon.Domain.Backtesting;
/// <summary>
/// Parameters fed to <see cref="BacktestSimulator"/>. The strategy is "for every
/// SuspensionFlip anomaly with score ≥ <see cref="MinScore"/>, stake
/// according to <see cref="StakeRule"/> on the post-flip favourite at the
/// post-flip rate, then settle against the actual <c>EventResult</c>."
/// </summary>
/// <param name="StartingBankroll">
/// Initial bankroll for compounding stake rules. Must be positive.
/// </param>
/// <param name="MinScore">
/// Lower bound on <c>Anomaly.Score</c> — only anomalies at or above this
/// threshold are bet on. Must be in [0, 1].
/// </param>
/// <param name="StakeRule">How to size each bet — see the enum docs.</param>
/// <param name="FlatStake">
/// Used when <see cref="StakeRule"/> is <see cref="Backtesting.StakeRule.Flat"/>.
/// Must be positive.
/// </param>
/// <param name="PercentOfBankroll">
/// Used when <see cref="StakeRule"/> is
/// <see cref="Backtesting.StakeRule.PercentOfBankroll"/>. Expressed as a
/// fraction in (0, 1]. e.g. 0.02 = 2 % of bankroll.
/// </param>
/// <param name="KellyFraction">
/// Used when <see cref="StakeRule"/> is <see cref="Backtesting.StakeRule.Kelly"/>.
/// Multiplier on the raw Kelly fraction; in (0, 1]. 0.25 (quarter-Kelly) is
/// the conservative default.
/// </param>
public sealed record BacktestStrategy(
decimal StartingBankroll,
decimal MinScore,
StakeRule StakeRule,
decimal FlatStake,
decimal PercentOfBankroll,
decimal KellyFraction)
{
public decimal StartingBankroll { get; } = StartingBankroll > 0m
? StartingBankroll
: throw new ArgumentOutOfRangeException(nameof(StartingBankroll),
StartingBankroll, "StartingBankroll must be positive.");
public decimal MinScore { get; } = MinScore is >= 0m and <= 1m
? MinScore
: throw new ArgumentOutOfRangeException(nameof(MinScore),
MinScore, "MinScore must be in [0, 1].");
public decimal FlatStake { get; } = FlatStake > 0m
? FlatStake
: throw new ArgumentOutOfRangeException(nameof(FlatStake),
FlatStake, "FlatStake must be positive.");
public decimal PercentOfBankroll { get; } = PercentOfBankroll is > 0m and <= 1m
? PercentOfBankroll
: throw new ArgumentOutOfRangeException(nameof(PercentOfBankroll),
PercentOfBankroll, "PercentOfBankroll must be in (0, 1].");
public decimal KellyFraction { get; } = KellyFraction is > 0m and <= 1m
? KellyFraction
: throw new ArgumentOutOfRangeException(nameof(KellyFraction),
KellyFraction, "KellyFraction must be in (0, 1].");
/// <summary>Sensible defaults — flat-stake, score ≥ 0.45, ¼-Kelly waiting in the wings.</summary>
public static BacktestStrategy Default { get; } = new(
StartingBankroll: 1000m,
MinScore: 0.45m,
StakeRule: StakeRule.Flat,
FlatStake: 50m,
PercentOfBankroll: 0.02m,
KellyFraction: 0.25m);
}
@@ -0,0 +1,51 @@
using Marathon.Domain.ValueObjects;
namespace Marathon.Domain.Backtesting;
/// <summary>
/// A named, persisted <see cref="BacktestStrategy"/> — the user's reusable
/// staking preset. The wrapped <see cref="Strategy"/> carries every simulation
/// parameter (bankroll, threshold, stake rule); the date-range scope of a run
/// is deliberately NOT stored here, since that is a per-run choice rather than
/// a property of the strategy itself.
/// </summary>
/// <param name="Id">Stable identity, assigned once at creation.</param>
/// <param name="Name">
/// User-supplied label. Trimmed and bounded to <see cref="MaxNameLength"/>;
/// names are unique across the store (enforced by the persistence layer).
/// </param>
/// <param name="Strategy">The staking configuration this preset captures.</param>
/// <param name="CreatedAt">When the preset was first saved (Moscow time).</param>
public sealed record SavedStrategy(
Guid Id,
string Name,
BacktestStrategy Strategy,
DateTimeOffset CreatedAt)
{
/// <summary>Maximum length of a trimmed strategy name.</summary>
public const int MaxNameLength = 80;
public string Name { get; } = NormalizeName(Name);
/// <summary>
/// Builds a brand-new preset with a fresh identity and the current Moscow
/// timestamp. Use this for "Save"; use <c>with</c> to amend an existing one.
/// </summary>
public static SavedStrategy Create(string name, BacktestStrategy strategy)
{
ArgumentNullException.ThrowIfNull(strategy);
return new SavedStrategy(Guid.NewGuid(), name, strategy, MoscowTime.Now);
}
private static string NormalizeName(string name)
{
ArgumentNullException.ThrowIfNull(name);
var trimmed = name.Trim();
if (trimmed.Length == 0)
throw new ArgumentException("Strategy name must not be empty.", nameof(name));
if (trimmed.Length > MaxNameLength)
throw new ArgumentException(
$"Strategy name must be at most {MaxNameLength} characters.", nameof(name));
return trimmed;
}
}
@@ -0,0 +1,28 @@
namespace Marathon.Domain.Backtesting;
/// <summary>
/// How the simulator decides how much to stake on each bet during a backtest.
/// </summary>
public enum StakeRule
{
/// <summary>
/// Same fixed amount every bet, independent of bankroll.
/// Suitable for "flat-betting" historical analysis — the simplest baseline.
/// </summary>
Flat,
/// <summary>
/// A fixed percentage of the current bankroll every bet. Compounds: a
/// winning streak grows stake size; losses shrink it. Equivalent to
/// proportional betting.
/// </summary>
PercentOfBankroll,
/// <summary>
/// Fractional Kelly using the post-flip implied probability as the edge
/// estimate: <c>f = ((b·p) q) / b</c>, scaled by the configured
/// <see cref="BacktestStrategy.KellyFraction"/>. Negative-expectation bets
/// stake zero (and are skipped). Half/quarter-Kelly is the usual practice.
/// </summary>
Kelly,
}
@@ -0,0 +1,95 @@
using Marathon.Domain.Entities;
using Marathon.Domain.Enums;
using Marathon.Domain.ValueObjects;
namespace Marathon.Domain.Betting;
/// <summary>
/// Pure function that grades a <see cref="Bet"/> selection against a final
/// <see cref="EventResult"/>. Used by the bet-journal resolver to auto-settle
/// pending wagers the moment a result lands.
/// </summary>
/// <remarks>
/// <para>
/// Grading rules:
/// <list type="bullet">
/// <item><c>Win</c> (Side1/Side2): selection wins iff <c>WinnerSide</c> matches the side.</item>
/// <item><c>Draw</c>: wins iff <c>WinnerSide == Draw</c>.</item>
/// <item><c>WinFora</c> with handicap <c>h</c> on side S: adjusted S-score
/// = <c>S.Score + h</c>. Wins when adjusted > opponent, voids on tie, loses otherwise.</item>
/// <item><c>Total</c> with threshold <c>t</c>: combined = <c>Side1Score + Side2Score</c>.
/// <c>More</c> wins when combined > t, voids on equal, loses when less.
/// <c>Less</c> is the mirror image.</item>
/// </list>
/// </para>
/// <para>
/// Returns <c>null</c> when the bet cannot be graded against this result —
/// today only period-scope selections, because <see cref="EventResult"/> stores
/// the full-time score only. Callers must leave such bets in
/// <see cref="BetOutcome.Pending"/> for manual settlement.
/// </para>
/// </remarks>
public static class BetOutcomeResolver
{
/// <summary>
/// Grades <paramref name="selection"/> against <paramref name="result"/>.
/// Returns the resulting <see cref="BetOutcome"/> or <c>null</c> if the
/// bet shape cannot be auto-resolved from the available result data.
/// </summary>
public static BetOutcome? Resolve(Bet selection, EventResult result)
{
ArgumentNullException.ThrowIfNull(selection);
ArgumentNullException.ThrowIfNull(result);
// Period-scope bets need per-period scores which EventResult does not
// carry today — leave for manual grading.
if (selection.Scope is not MatchScope)
return null;
return selection.Type switch
{
BetType.Win => ResolveWin(selection.Side, result),
BetType.Draw => ResolveDraw(result),
BetType.WinFora => ResolveFora(selection.Side, selection.Value!.Value, result),
BetType.Total => ResolveTotal(selection.Side, selection.Value!.Value, result),
_ => null,
};
}
private static BetOutcome ResolveWin(Side side, EventResult result) =>
result.WinnerSide == side ? BetOutcome.Won : BetOutcome.Lost;
private static BetOutcome ResolveDraw(EventResult result) =>
result.WinnerSide == Side.Draw ? BetOutcome.Won : BetOutcome.Lost;
private static BetOutcome ResolveFora(Side side, decimal handicap, EventResult result)
{
// Adjusted score for the side that took the handicap.
var (own, opponent) = side == Side.Side1
? (result.Side1Score, result.Side2Score)
: (result.Side2Score, result.Side1Score);
var adjusted = own + handicap;
if (adjusted > opponent) return BetOutcome.Won;
if (adjusted == opponent) return BetOutcome.Void;
return BetOutcome.Lost;
}
private static BetOutcome ResolveTotal(Side side, decimal threshold, EventResult result)
{
var total = (decimal)(result.Side1Score + result.Side2Score);
// More wins when total > threshold; Less wins when total < threshold.
// Equality is a push (Void) for both sides.
if (total == threshold) return BetOutcome.Void;
var totalIsOver = total > threshold;
return side switch
{
Side.More => totalIsOver ? BetOutcome.Won : BetOutcome.Lost,
Side.Less => totalIsOver ? BetOutcome.Lost : BetOutcome.Won,
_ => BetOutcome.Lost, // Defensive — Bet invariant rejects other sides for Total.
};
}
}
@@ -0,0 +1,82 @@
namespace Marathon.Domain.Betting;
/// <summary>
/// Pure fractional-Kelly stake sizing for a single back bet at decimal odds.
/// </summary>
/// <remarks>
/// <para>
/// The Kelly criterion maximises the long-run growth rate of a bankroll by staking
/// a fraction of it proportional to the edge. For decimal odds <c>o</c> and an
/// estimated win probability <c>p</c>, the full-Kelly fraction of bankroll is:
/// </para>
/// <code>f* = (p·o 1) / (o 1)</code>
/// <para>
/// When <c>f*</c> is zero or negative there is no positive expected value, and the
/// suggested stake is <c>0</c> — the calculator never recommends betting into a
/// negative-EV price. Most disciplined bettors stake a <i>fraction</i> of full
/// Kelly (e.g. quarter-Kelly, <c>fraction = 0.25</c>) to cut variance and blunt the
/// impact of probability-estimation error; full Kelly is famously over-aggressive
/// once <c>p</c> is even slightly wrong.
/// </para>
/// <para>
/// The win probability is an input the bettor supplies — it is intentionally NOT
/// derived from an anomaly score here, so the calculator stays a pure, reusable
/// money-management primitive independent of any signal source.
/// </para>
/// </remarks>
public static class KellyCalculator
{
/// <summary>Default Kelly fraction: quarter-Kelly — the conventional variance-safe choice.</summary>
public const decimal DefaultFraction = 0.25m;
/// <summary>
/// Full-Kelly fraction of bankroll <c>(p·o 1)/(o 1)</c>. May be negative or
/// zero, signalling no positive edge. Exposed for callers that want the raw
/// figure (e.g. to display the edge) rather than a clamped stake.
/// </summary>
/// <param name="winProbability">Estimated win probability in the closed interval [0, 1].</param>
/// <param name="decimalOdds">Decimal odds, strictly greater than 1.0.</param>
public static decimal FullKellyFraction(decimal winProbability, decimal decimalOdds)
{
if (winProbability is < 0m or > 1m)
throw new ArgumentOutOfRangeException(nameof(winProbability), winProbability, "Must be in [0, 1].");
if (decimalOdds <= 1m)
throw new ArgumentOutOfRangeException(nameof(decimalOdds), decimalOdds, "Decimal odds must be greater than 1.0.");
return (winProbability * decimalOdds - 1m) / (decimalOdds - 1m);
}
/// <summary>
/// Suggested stake using fractional Kelly, rounded down to two decimals so the
/// suggestion is never larger than the theoretical figure. Returns <c>0</c> when
/// there is no positive edge.
/// </summary>
/// <param name="winProbability">Estimated win probability in the open interval (0, 1).</param>
/// <param name="decimalOdds">Decimal odds, strictly greater than 1.0.</param>
/// <param name="bankroll">Total bankroll; must be non-negative.</param>
/// <param name="fraction">Kelly fraction in (0, 1]; defaults to <see cref="DefaultFraction"/>.</param>
public static decimal SuggestStake(
decimal winProbability,
decimal decimalOdds,
decimal bankroll,
decimal fraction = DefaultFraction)
{
if (winProbability is <= 0m or >= 1m)
throw new ArgumentOutOfRangeException(nameof(winProbability), winProbability, "Must be in the open interval (0, 1).");
if (bankroll < 0m)
throw new ArgumentOutOfRangeException(nameof(bankroll), bankroll, "Must be non-negative.");
if (fraction is <= 0m or > 1m)
throw new ArgumentOutOfRangeException(nameof(fraction), fraction, "Kelly fraction must be in (0, 1].");
// FullKellyFraction validates decimalOdds.
var full = FullKellyFraction(winProbability, decimalOdds);
if (full <= 0m)
return 0m;
var stake = fraction * full * bankroll;
// Truncate (floor toward zero) to two decimals so a stake suggestion never
// exceeds the computed figure — a conservative bias for real-money sizing.
return Math.Truncate(stake * 100m) / 100m;
}
}
+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;
}
+73
View File
@@ -0,0 +1,73 @@
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; }
/// <summary>
/// Display title in the canonical "{Side1Name} vs {Side2Name}" form. Single
/// source for the home-vs-away join that was previously duplicated across the
/// report use cases and list/feed services.
/// </summary>
public string Title => $"{Side1Name} vs {Side2Name}";
}
@@ -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;
}
}
+72
View File
@@ -0,0 +1,72 @@
using Marathon.Domain.Enums;
using Marathon.Domain.ValueObjects;
namespace Marathon.Domain.Entities;
/// <summary>
/// A hypothetical "paper" wager opened automatically by the forward-test worker the
/// moment a directional anomaly fires, then settled when the event result arrives.
/// </summary>
/// <remarks>
/// Unlike <see cref="PlacedBet"/> (the user's real journal), a paper bet is
/// system-generated and exists only to measure the detector's live, out-of-sample
/// edge — the antidote to backtest overfitting. Exactly one paper bet is opened per
/// anomaly (enforced by a unique index on <see cref="AnomalyId"/>).
/// </remarks>
public sealed record PaperBet(
Guid Id,
Guid AnomalyId,
EventId EventId,
Side PickedSide,
decimal Rate,
decimal Stake,
DateTimeOffset OpenedAt,
BetOutcome Outcome,
DateTimeOffset? SettledAt,
decimal? Payout)
{
public decimal Rate { get; } = Rate > 1m
? Rate
: throw new ArgumentOutOfRangeException(nameof(Rate), Rate, "Decimal odds must be greater than 1.");
public decimal Stake { get; } = Stake > 0m
? Stake
: throw new ArgumentOutOfRangeException(nameof(Stake), Stake, "Stake must be positive.");
/// <summary>Whether the bet is still awaiting a result.</summary>
public bool IsOpen => Outcome == BetOutcome.Pending;
/// <summary>Opens a fresh, unsettled paper bet with a new identity.</summary>
public static PaperBet Open(
Guid anomalyId, EventId eventId, Side pickedSide, decimal rate, decimal stake, DateTimeOffset openedAt)
{
ArgumentNullException.ThrowIfNull(eventId);
return new PaperBet(
Id: Guid.NewGuid(),
AnomalyId: anomalyId,
EventId: eventId,
PickedSide: pickedSide,
Rate: rate,
Stake: stake,
OpenedAt: openedAt,
Outcome: BetOutcome.Pending,
SettledAt: null,
Payout: null);
}
/// <summary>
/// Settles the bet against the actual winner: Won (payout = stake × rate) when
/// <paramref name="winnerSide"/> equals <see cref="PickedSide"/>, otherwise Lost
/// (payout 0). A win-market pick that draws simply loses.
/// </summary>
public PaperBet SettleAgainst(Side winnerSide, DateTimeOffset settledAt)
{
var won = winnerSide == PickedSide;
return this with
{
Outcome = won ? BetOutcome.Won : BetOutcome.Lost,
SettledAt = settledAt,
Payout = won ? Stake * Rate : 0m,
};
}
}
+89
View File
@@ -0,0 +1,89 @@
using Marathon.Domain.Enums;
using Marathon.Domain.ValueObjects;
namespace Marathon.Domain.Entities;
/// <summary>
/// A wager the user manually recorded as having placed (with this or another
/// bookmaker). Reuses the <see cref="Bet"/> vocabulary so the journal can mirror
/// scraped markets directly — same Scope / Type / Side / Value / Rate invariants
/// apply to <see cref="Selection"/>.
/// </summary>
/// <param name="Id">Stable identifier — Guid so duplicates can be detected by the UI.</param>
/// <param name="EventId">Event the wager is on.</param>
/// <param name="Selection">
/// The market + rate the user took. <c>Selection.Rate</c> is the "taken rate"
/// used for ROI and CLV calculations.
/// </param>
/// <param name="Stake">
/// Money risked, in the user's currency. The domain does not encode currency —
/// stake values are compared as raw decimals.
/// </param>
/// <param name="PlacedAt">When the bet was recorded. Stored as Moscow time.</param>
/// <param name="Outcome">Current settlement state — see <see cref="BetOutcome"/>.</param>
/// <param name="Notes">Optional free text — strategy tag, source, etc.</param>
public sealed record PlacedBet(
Guid Id,
EventId EventId,
Bet Selection,
decimal Stake,
DateTimeOffset PlacedAt,
BetOutcome Outcome,
string? Notes)
{
public Guid Id { get; } = Id == Guid.Empty
? throw new ArgumentException("PlacedBet Id must not be an empty GUID.", nameof(Id))
: Id;
public EventId EventId { get; } = EventId ?? throw new ArgumentNullException(nameof(EventId));
public Bet Selection { get; } = Selection ?? throw new ArgumentNullException(nameof(Selection));
public decimal Stake { get; } = Stake > 0m
? Stake
: throw new ArgumentOutOfRangeException(nameof(Stake), Stake,
"Stake must be positive.");
public DateTimeOffset PlacedAt { get; } = PlacedAt.Offset == MoscowTime.Offset
? PlacedAt
: throw new ArgumentException(
$"PlacedAt must be in Europe/Moscow time (UTC+03:00). " +
$"Received offset: {PlacedAt.Offset:hh\\:mm}.",
nameof(PlacedAt));
public BetOutcome Outcome { get; } = Outcome;
public string? Notes { get; } = string.IsNullOrWhiteSpace(Notes) ? null : Notes;
/// <summary>
/// Gross return on this bet for the current outcome — the amount the
/// bookmaker pays back to the user (stake + winnings).
/// <list type="bullet">
/// <item><see cref="BetOutcome.Won"/>: <c>Stake × Rate</c></item>
/// <item><see cref="BetOutcome.Void"/>: <c>Stake</c> (push — stake returned)</item>
/// <item><see cref="BetOutcome.Lost"/>: <c>0</c></item>
/// <item><see cref="BetOutcome.Pending"/>: <c>null</c> (unknown)</item>
/// </list>
/// </summary>
public decimal? GrossReturn => Outcome switch
{
BetOutcome.Won => Stake * Selection.Rate.Value,
BetOutcome.Void => Stake,
BetOutcome.Lost => 0m,
_ => null,
};
/// <summary>
/// Net profit for the current outcome — <see cref="GrossReturn"/> minus
/// <see cref="Stake"/>. Negative for losses. Null while pending.
/// </summary>
public decimal? NetProfit => GrossReturn is null ? null : GrossReturn.Value - Stake;
/// <summary>
/// Returns a copy with a new <see cref="Outcome"/> — used by the resolver
/// use case after grading the event. Constructs explicitly because the
/// manual validating <c>get</c>-only properties prevent <c>with</c>.
/// </summary>
public PlacedBet WithOutcome(BetOutcome outcome) =>
new(Id, EventId, Selection, Stake, PlacedAt, outcome, Notes);
}
+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;
}
+31
View File
@@ -0,0 +1,31 @@
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,
/// <summary>
/// A rapid, one-directional drift in a side's implied probability over a short
/// continuous window (no suspension) — money moving the line ("steam").
/// </summary>
SteamMove,
/// <summary>
/// The bookmaker suspended the market but resumed with essentially the same line
/// (favourite unchanged, negligible price move) — a freeze signalling uncertainty.
/// </summary>
SuspensionFreeze,
/// <summary>
/// The bookmaker's margin (overround) compressed sharply over a short continuous
/// window — the book tightened its vig, often ahead of news or when confident.
/// </summary>
OverroundCompression,
}
@@ -0,0 +1,25 @@
namespace Marathon.Domain.Enums;
/// <summary>Semantic classification of anomaly kinds.</summary>
public static class AnomalyKindExtensions
{
/// <summary>
/// Whether the kind makes a <i>directional</i> prediction — a specific side/favourite
/// expected to win — that can be graded against the result and bet on in a backtest.
/// </summary>
/// <remarks>
/// <see cref="AnomalyKind.SuspensionFlip"/> and <see cref="AnomalyKind.SteamMove"/> are
/// directional (they point at a favourite). <see cref="AnomalyKind.SuspensionFreeze"/> is
/// informational — the line did NOT move — so "predicting" the unchanged favourite would
/// merely measure the base favourite-win rate; it is excluded from outcome grading and
/// from backtest staking so it does not distort detector calibration.
/// </remarks>
public static bool IsDirectional(this AnomalyKind kind) => kind switch
{
AnomalyKind.SuspensionFlip => true,
AnomalyKind.SteamMove => true,
AnomalyKind.SuspensionFreeze => false,
AnomalyKind.OverroundCompression => false,
_ => false,
};
}

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