35 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
177 changed files with 17773 additions and 442 deletions
+4
View File
@@ -84,3 +84,7 @@ 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
+42
View File
@@ -121,6 +121,26 @@ Marathon_<YYYY-MM-DD>_to_<YYYY-MM-DD>.xlsx
- **`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
@@ -203,3 +223,25 @@ For full detail see `spike/SCRAPE_FINDINGS.md` and `spike/SCHEMA_DRAFT.md`.)
- **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`.
+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>
@@ -5,4 +5,22 @@ namespace Marathon.Application.Abstractions;
/// <summary>
/// Repository for <see cref="Anomaly"/> domain entities.
/// </summary>
public interface IAnomalyRepository : IRepository<Guid, Anomaly>;
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);
}
@@ -11,8 +11,27 @@ 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.
@@ -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,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);
}
@@ -6,4 +6,14 @@ namespace Marathon.Application.Abstractions;
/// <summary>
/// Repository for <see cref="EventResult"/> domain entities.
/// </summary>
public interface IResultRepository : IRepository<EventId, EventResult>;
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);
}
@@ -16,6 +16,18 @@ 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,
@@ -36,4 +48,19 @@ public interface ISnapshotRepository
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);
}
@@ -29,7 +29,24 @@ public static class ApplicationModule
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;
}
}
@@ -32,4 +32,35 @@ public sealed class AnomalyOptions
/// 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;
}
+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;
}
}
@@ -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,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,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);
}
}
@@ -14,7 +14,7 @@ namespace Marathon.Application.UseCases;
/// <item>Loads all tracked events.</item>
/// <item>For each event, fetches its last-24-hour live snapshots.</item>
/// <item>Runs <see cref="AnomalyDetector"/> over the snapshot timeline.</item>
/// <item>Persists any new anomalies that have not already been stored (dedup by EventId + DetectedAt minute-window).</item>
/// <item>Persists any new anomalies that have not already been stored (dedup by EventId + Kind + DetectedAt minute-window).</item>
/// </list>
/// </summary>
/// <remarks>
@@ -30,10 +30,10 @@ public sealed class DetectAnomaliesUseCase
// 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 IEventRepository _eventRepo;
private readonly ISnapshotRepository _snapshotRepo;
private readonly IAnomalyRepository _anomalyRepo;
private readonly AnomalyOptions _options;
private readonly IAnomalyRepository _anomalyRepo;
private readonly AnomalyOptions _options;
private readonly ILogger<DetectAnomaliesUseCase> _logger;
public DetectAnomaliesUseCase(
@@ -43,11 +43,11 @@ public sealed class DetectAnomaliesUseCase
IOptions<AnomalyOptions> options,
ILogger<DetectAnomaliesUseCase> logger)
{
_eventRepo = eventRepo ?? throw new ArgumentNullException(nameof(eventRepo));
_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));
_anomalyRepo = anomalyRepo ?? throw new ArgumentNullException(nameof(anomalyRepo));
_options = (options ?? throw new ArgumentNullException(nameof(options))).Value;
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <summary>
@@ -59,21 +59,41 @@ public sealed class DetectAnomaliesUseCase
{
_logger.LogInformation("DetectAnomaliesUseCase: cycle started");
var detector = new AnomalyDetector(
_options.SuspensionGapSeconds,
_options.OddsFlipThreshold,
_options.MinSnapshotCount);
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 now = MoscowTime.Now;
var from = now - SnapshotLookback;
// Hoisted outside the per-event loop: load existing anomalies ONCE per cycle
// and slice per-event in the loop. Previously this was reloaded per event
// (O(N_events) round-trips). Reviewer W1, Phase 7.
// 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)
@@ -90,7 +110,10 @@ public sealed class DetectAnomaliesUseCase
var snapshots = snapshotsByEvent.TryGetValue(ev.Id, out var found)
? found
: Array.Empty<OddsSnapshot>();
newAnomalyCount += await ProcessEventAsync(detector, ev, snapshots, existingAnomalies, ct);
var existingForEvent = existingByEvent.TryGetValue(ev.Id, out var slice)
? slice
: new List<Anomaly>();
newAnomalyCount += await ProcessEventAsync(detectors, ev, snapshots, existingForEvent, ct);
}
catch (OperationCanceledException)
{
@@ -114,22 +137,21 @@ public sealed class DetectAnomaliesUseCase
// ── Private helpers ───────────────────────────────────────────────────────
private async Task<int> ProcessEventAsync(
AnomalyDetector detector,
IReadOnlyList<IAnomalyDetector> detectors,
Event ev,
IReadOnlyList<OddsSnapshot> snapshots,
IReadOnlyList<Anomaly> existingAnomalies,
List<Anomaly> existingForEvent,
CancellationToken ct)
{
var detected = detector.Detect(ev.Id, snapshots);
// 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;
// Slice the cycle-wide existing-anomaly list to just this event for dedup.
var existingForEvent = existingAnomalies
.Where(a => a.EventId == ev.Id)
.ToList();
int persisted = 0;
foreach (var anomaly in detected)
{
@@ -137,11 +159,15 @@ public sealed class DetectAnomaliesUseCase
continue;
await _anomalyRepo.AddAsync(anomaly, ct);
await _anomalyRepo.SaveChangesAsync(ct);
existingForEvent.Add(anomaly); // Keep local list in sync so the same cycle doesn't re-add.
persisted++;
}
// 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;
}
@@ -151,7 +177,7 @@ public sealed class DetectAnomaliesUseCase
// and their DetectedAt timestamps fall within the dedup window.
return existing.Any(a =>
a.EventId == candidate.EventId &&
a.Kind == candidate.Kind &&
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,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;
}
}
@@ -72,10 +72,10 @@ public sealed class PullResultsUseCase
IResultRepository resultRepo,
ILogger<PullResultsUseCase> logger)
{
_scraper = scraper ?? throw new ArgumentNullException(nameof(scraper));
_eventRepo = eventRepo ?? throw new ArgumentNullException(nameof(eventRepo));
_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));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <summary>
@@ -149,12 +149,13 @@ public sealed class PullResultsUseCase
{
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)
{
ct.ThrowIfCancellationRequested();
var ev = await _eventRepo.GetAsync(id, ct).ConfigureAwait(false);
if (ev is not null)
if (events.TryGetValue(id, out var ev))
resolved.Add(ev);
}
return resolved;
@@ -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;
}
}
@@ -1,5 +1,3 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using Marathon.Domain.Entities;
using Marathon.Domain.Enums;
using Marathon.Domain.ValueObjects;
@@ -23,20 +21,15 @@ namespace Marathon.Domain.AnomalyDetection;
/// </list>
///
/// This class is stateless and deterministic — identical inputs always produce identical output.
/// It has no I/O or DI dependencies.
/// 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
public sealed class AnomalyDetector : IAnomalyDetector
{
private readonly int _suspensionGapSeconds;
private readonly decimal _oddsFlipThreshold;
private readonly int _minSnapshotCount;
private static readonly JsonSerializerOptions JsonOptions = new()
{
WriteIndented = false,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
};
/// <param name="suspensionGapSeconds">
/// Minimum gap between adjacent live snapshots (in seconds) to classify as a suspension.
/// Default per spec: 60.
@@ -68,16 +61,7 @@ public sealed class AnomalyDetector
_minSnapshotCount = minSnapshotCount;
}
/// <summary>
/// Analyses <paramref name="snapshots"/> for the given <paramref name="eventId"/> and
/// returns 0 or more anomalies detected in this timeline.
/// </summary>
/// <param name="eventId">The event being analysed.</param>
/// <param name="snapshots">All snapshots for this event (any source, any order).</param>
/// <returns>
/// An <see cref="IReadOnlyList{T}"/> of <see cref="Anomaly"/> records, one per qualifying
/// suspension interval. May be empty.
/// </returns>
/// <inheritdoc />
public IReadOnlyList<Anomaly> Detect(EventId eventId, IReadOnlyList<OddsSnapshot> snapshots)
{
ArgumentNullException.ThrowIfNull(eventId);
@@ -119,9 +103,9 @@ public sealed class AnomalyDetector
private Anomaly? TryDetectFlip(EventId eventId, SuspensionInterval interval)
{
// Extract Match-Win bets from each snapshot.
var preProbs = ExtractMatchWinProbabilities(interval.PreSuspension);
var postProbs = ExtractMatchWinProbabilities(interval.PostSuspension);
// 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)
@@ -129,10 +113,8 @@ public sealed class AnomalyDetector
// 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));
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,
@@ -140,7 +122,8 @@ public sealed class AnomalyDetector
}
// Step 5 — favourite-changed test: argmax of implied probability must differ.
bool favouriteChanged = DetermineFavourite(preProbs) != DetermineFavourite(postProbs);
bool favouriteChanged =
MatchWinEvidence.Favourite(preProbs) != MatchWinEvidence.Favourite(postProbs);
if (flipScore < _oddsFlipThreshold || !favouriteChanged)
return null;
@@ -148,8 +131,11 @@ public sealed class AnomalyDetector
// Clamp score to [0, 1] before constructing the Anomaly (domain invariant).
var clampedScore = Math.Min(1m, flipScore);
// Step 6 — build evidence JSON.
var evidenceJson = BuildEvidenceJson(interval, preProbs, postProbs);
// 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(),
@@ -159,100 +145,4 @@ public sealed class AnomalyDetector
Score: clampedScore,
EvidenceJson: evidenceJson);
}
private static MatchWinProbabilities? ExtractMatchWinProbabilities(OddsSnapshot snapshot)
{
// Find Match-scope Win bets.
var matchWinBets = snapshot.Bets
.Where(b => b.Scope is MatchScope && b.Type == BetType.Win)
.ToList();
var win1 = matchWinBets.FirstOrDefault(b => b.Side == Side.Side1);
var win2 = matchWinBets.FirstOrDefault(b => b.Side == Side.Side2);
if (win1 is null || win2 is null)
return null; // Not enough data.
// Find optional Draw bet (MatchScope, BetType.Draw).
var drawBet = snapshot.Bets
.FirstOrDefault(b => b.Scope is MatchScope && b.Type == BetType.Draw);
// Raw implied probabilities: p = 1 / rate.
decimal rawP1 = 1m / win1.Rate.Value;
decimal rawP2 = 1m / win2.Rate.Value;
decimal rawDraw = drawBet is not null ? 1m / drawBet.Rate.Value : 0m;
decimal total = rawP1 + rawP2 + rawDraw;
// Normalise so they sum to 1.
decimal p1 = rawP1 / total;
decimal p2 = rawP2 / total;
decimal pDraw = drawBet is not null ? rawDraw / total : 0m;
return new MatchWinProbabilities(
P1: p1,
PDraw: drawBet is not null ? pDraw : null,
P2: p2,
Rate1: win1.Rate.Value,
RateDraw: drawBet?.Rate.Value,
Rate2: win2.Rate.Value);
}
private static string DetermineFavourite(MatchWinProbabilities probs)
{
// The favourite is the side with the highest normalised implied probability.
if (probs.PDraw.HasValue && probs.PDraw.Value > probs.P1 && probs.PDraw.Value > probs.P2)
return "Draw";
return probs.P1 >= probs.P2 ? "Side1" : "Side2";
}
private string BuildEvidenceJson(
SuspensionInterval interval,
MatchWinProbabilities preProbs,
MatchWinProbabilities postProbs)
{
var payload = new EvidencePayload(
SuspensionGapSeconds: (int)interval.Gap.TotalSeconds,
PreSuspension: new SnapshotEvidence(
CapturedAt: interval.PreSuspension.CapturedAt.ToString("O"),
P1: preProbs.P1,
PDraw: preProbs.PDraw,
P2: preProbs.P2,
Rate1: preProbs.Rate1,
RateDraw: preProbs.RateDraw,
Rate2: preProbs.Rate2),
PostSuspension: new SnapshotEvidence(
CapturedAt: interval.PostSuspension.CapturedAt.ToString("O"),
P1: postProbs.P1,
PDraw: postProbs.PDraw,
P2: postProbs.P2,
Rate1: postProbs.Rate1,
RateDraw: postProbs.RateDraw,
Rate2: postProbs.Rate2));
return JsonSerializer.Serialize(payload, JsonOptions);
}
// ── Nested types ─────────────────────────────────────────────────────────
private sealed record MatchWinProbabilities(
decimal P1,
decimal? PDraw,
decimal P2,
decimal Rate1,
decimal? RateDraw,
decimal Rate2);
private sealed record EvidencePayload(
[property: JsonPropertyName("suspensionGapSeconds")] int SuspensionGapSeconds,
[property: JsonPropertyName("preSuspension")] SnapshotEvidence PreSuspension,
[property: JsonPropertyName("postSuspension")] SnapshotEvidence PostSuspension);
private sealed record SnapshotEvidence(
[property: JsonPropertyName("capturedAt")] string CapturedAt,
[property: JsonPropertyName("p1")] decimal P1,
[property: JsonPropertyName("pDraw")] decimal? PDraw,
[property: JsonPropertyName("p2")] decimal P2,
[property: JsonPropertyName("rate1")] decimal Rate1,
[property: JsonPropertyName("rateDraw")] decimal? RateDraw,
[property: JsonPropertyName("rate2")] decimal Rate2);
}
@@ -0,0 +1,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,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;
}
}
+7
View File
@@ -63,4 +63,11 @@ public sealed record Event(
/// 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}";
}
+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);
}
+18
View File
@@ -10,4 +10,22 @@ public enum AnomalyKind
/// 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,
};
}
+24
View File
@@ -0,0 +1,24 @@
namespace Marathon.Domain.Enums;
/// <summary>
/// Settlement status of a user-tracked <see cref="Marathon.Domain.Entities.PlacedBet"/>.
/// </summary>
public enum BetOutcome
{
/// <summary>
/// The event has not been graded yet, or the bet has not been auto-resolved
/// yet. Default state for a freshly recorded bet.
/// </summary>
Pending,
/// <summary>The selection won — stake returned plus winnings.</summary>
Won,
/// <summary>The selection lost — stake is forfeit.</summary>
Lost,
/// <summary>
/// Handicap/total push or event abandoned — stake returned, no profit/loss.
/// </summary>
Void,
}
+17 -1
View File
@@ -41,7 +41,23 @@
"SuspensionGapSeconds": 60,
"OddsFlipThreshold": 0.30,
"MinSnapshotCount": 3,
"DetectionIntervalSeconds": 60
"DetectionIntervalSeconds": 60,
"SteamMoveWindowSeconds": 120,
"SteamMoveDriftThreshold": 0.20,
"SuspensionFreezeThreshold": 0.05,
"OverroundWindowSeconds": 120,
"OverroundCompressionThreshold": 0.02
},
"Notifications": {
"Enabled": false,
"MinScore": 0.45,
"PollIntervalSeconds": 60
},
"PaperTrading": {
"Enabled": false,
"MinScore": 0.55,
"FlatStake": 10,
"PollIntervalSeconds": 60
},
"Localization": {
"DefaultCulture": "ru-RU"
@@ -0,0 +1,30 @@
namespace Marathon.Infrastructure.Configuration;
/// <summary>
/// Options for outbound anomaly notifications, bound from the <c>Notifications</c>
/// config section.
/// </summary>
/// <remarks>
/// Disabled by default. <see cref="TelegramBotToken"/> and <see cref="TelegramChatId"/>
/// are secrets — set them ONLY in <c>appsettings.Local.json</c> (gitignored) or an
/// environment variable, never in the committed <c>appsettings.json</c>.
/// </remarks>
public sealed class NotificationOptions
{
public const string SectionName = "Notifications";
/// <summary>Master switch — when false, the dispatcher idles and nothing is sent.</summary>
public bool Enabled { get; init; }
/// <summary>Telegram bot token (secret — Local.json / env only).</summary>
public string? TelegramBotToken { get; init; }
/// <summary>Telegram chat id to deliver alerts to (secret — Local.json / env only).</summary>
public string? TelegramChatId { get; init; }
/// <summary>Minimum anomaly score to alert on. Default: 0.45 (Medium severity).</summary>
public decimal MinScore { get; init; } = 0.45m;
/// <summary>Seconds between dispatcher polls. Default: 60.</summary>
public int PollIntervalSeconds { get; init; } = 60;
}
@@ -0,0 +1,22 @@
namespace Marathon.Infrastructure.Configuration;
/// <summary>
/// Options for the forward-test (paper-trading) worker, bound to the
/// <c>PaperTrading</c> configuration section.
/// </summary>
public sealed class PaperTradingOptions
{
public const string SectionName = "PaperTrading";
/// <summary>Master switch. When false the worker idles (cheap re-check). Default false.</summary>
public bool Enabled { get; init; }
/// <summary>Minimum anomaly score required to open a paper bet. Default 0.55.</summary>
public decimal MinScore { get; init; } = 0.55m;
/// <summary>Flat stake placed on every paper bet (currency-agnostic units). Default 10.</summary>
public decimal FlatStake { get; init; } = 10m;
/// <summary>Seconds between open/settle cycles. Floored at 5. Default 60.</summary>
public int PollIntervalSeconds { get; init; } = 60;
}
@@ -25,9 +25,10 @@ internal sealed class ExcelExporter : IExcelExporter
string outputPath,
CancellationToken ct = default)
{
// Load all snapshots in the date range with their bets eagerly
var fromStr = range.From.ToString("O");
var toStr = range.To.ToString("O");
// Load all snapshots in the date range with their bets eagerly. Bounds use the
// shared SqliteDateText encoding so they match the persisted CapturedAt keys.
var fromStr = SqliteDateText.Key(range.From);
var toStr = SqliteDateText.Key(range.To);
var snapshotEntities = await _db.Snapshots.AsNoTracking()
.Include(s => s.Bets)
@@ -1,5 +1,7 @@
using Marathon.Application.Abstractions;
using Marathon.Application.Configuration;
using Marathon.Infrastructure.Configuration;
using Marathon.Infrastructure.Notifications;
using Marathon.Infrastructure.Persistence;
using Marathon.Infrastructure.Scraping;
using Marathon.Infrastructure.Workers;
@@ -50,11 +52,30 @@ public static class InfrastructureModule
.AddOptions<ScrapingThrottle>()
.Bind(config.GetSection(ScrapingThrottle.SectionName));
services
.AddOptions<NotificationOptions>()
.Bind(config.GetSection(NotificationOptions.SectionName));
services
.AddOptions<PaperTradingOptions>()
.Bind(config.GetSection(PaperTradingOptions.SectionName));
services.AddHostedService<UpcomingEventsPoller>();
services.AddHostedService<LiveOddsPoller>();
services.AddHostedService<ResultsWatchListPoller>();
services.AddHostedService<AnomalyDetectionPoller>();
// Outbound anomaly notifications (Telegram). Sink + dispatcher are always
// registered; the dispatcher idles until Notifications:Enabled is true and
// the sink no-ops until a bot token + chat id are configured.
services.AddHttpClient(TelegramNotificationSink.HttpClientName, client =>
client.Timeout = TimeSpan.FromSeconds(15));
services.AddSingleton<INotificationSink, TelegramNotificationSink>();
services.AddHostedService<AnomalyNotificationDispatcher>();
// Forward-test (paper-trading) engine. Idles until PaperTrading:Enabled is true.
services.AddHostedService<PaperTradingWorker>();
return services;
}
}
@@ -0,0 +1,57 @@
using Marathon.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Marathon.Infrastructure.Migrations;
/// <inheritdoc />
[DbContext(typeof(MarathonDbContext))]
[Migration("20260516000000_AddPlacedBets")]
public partial class AddPlacedBets : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "PlacedBets",
columns: table => new
{
Id = table.Column<string>(type: "TEXT", nullable: false),
EventCode = table.Column<string>(type: "TEXT", nullable: false),
Scope = table.Column<int>(type: "INTEGER", nullable: false),
PeriodNumber = table.Column<int>(type: "INTEGER", nullable: true),
Type = table.Column<int>(type: "INTEGER", nullable: false),
Side = table.Column<int>(type: "INTEGER", nullable: false),
Value = table.Column<decimal>(type: "TEXT", nullable: true),
Rate = table.Column<decimal>(type: "TEXT", nullable: false),
Stake = table.Column<decimal>(type: "TEXT", nullable: false),
PlacedAt = table.Column<string>(type: "TEXT", nullable: false),
Outcome = table.Column<int>(type: "INTEGER", nullable: false),
Notes = table.Column<string>(type: "TEXT", nullable: true),
},
constraints: table =>
{
table.PrimaryKey("PK_PlacedBets", x => x.Id);
// No foreign key to Events — the journal is user data and must
// survive snapshot retention pruning the source event row.
});
migrationBuilder.CreateIndex(
name: "IX_PlacedBets_EventCode",
table: "PlacedBets",
column: "EventCode");
migrationBuilder.CreateIndex(
name: "IX_PlacedBets_Outcome",
table: "PlacedBets",
column: "Outcome");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(name: "PlacedBets");
}
}
@@ -0,0 +1,43 @@
using Marathon.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Marathon.Infrastructure.Migrations;
/// <inheritdoc />
[DbContext(typeof(MarathonDbContext))]
[Migration("20260528000000_AddSnapshotCapturedAtIndexes")]
public partial class AddSnapshotCapturedAtIndexes : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
// Composite index for the dominant read shape: filter by EventCode + a
// CapturedAt range, frequently with ORDER BY CapturedAt. Lets SQLite serve
// both the predicate and the ordering from the index rather than scanning.
migrationBuilder.CreateIndex(
name: "IX_Snapshots_EventCode_CapturedAt",
table: "Snapshots",
columns: new[] { "EventCode", "CapturedAt" });
// Covers GetLatestPreMatchAsync: EventCode + Source filter, ORDER BY CapturedAt DESC.
migrationBuilder.CreateIndex(
name: "IX_Snapshots_EventCode_Source_CapturedAt",
table: "Snapshots",
columns: new[] { "EventCode", "Source", "CapturedAt" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_Snapshots_EventCode_Source_CapturedAt",
table: "Snapshots");
migrationBuilder.DropIndex(
name: "IX_Snapshots_EventCode_CapturedAt",
table: "Snapshots");
}
}
@@ -0,0 +1,391 @@
// <auto-generated />
using System;
using Marathon.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace Marathon.Infrastructure.Migrations
{
[DbContext(typeof(MarathonDbContext))]
[Migration("20260528225529_AddSavedStrategies")]
partial class AddSavedStrategies
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "8.0.12");
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.AnomalyEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT");
b.Property<string>("DetectedAt")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("EventCode")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("EvidenceJson")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("Kind")
.HasColumnType("INTEGER");
b.Property<decimal>("Score")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("EventCode")
.HasDatabaseName("IX_Anomalies_EventCode");
b.ToTable("Anomalies", (string)null);
});
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.BetEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int?>("PeriodNumber")
.HasColumnType("INTEGER");
b.Property<decimal>("Rate")
.HasColumnType("TEXT");
b.Property<int>("Scope")
.HasColumnType("INTEGER");
b.Property<int>("Side")
.HasColumnType("INTEGER");
b.Property<long>("SnapshotId")
.HasColumnType("INTEGER");
b.Property<int>("Type")
.HasColumnType("INTEGER");
b.Property<decimal?>("Value")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("SnapshotId")
.HasDatabaseName("IX_Bets_SnapshotId");
b.ToTable("Bets", (string)null);
});
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.EventEntity", b =>
{
b.Property<string>("EventCode")
.HasColumnType("TEXT");
b.Property<string>("Category")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("");
b.Property<string>("CountryCode")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("EventPath")
.HasColumnType("TEXT");
b.Property<string>("LeagueId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("ScheduledAt")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Side1Name")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Side2Name")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("SportCode")
.HasColumnType("INTEGER");
b.HasKey("EventCode");
b.HasIndex("ScheduledAt")
.HasDatabaseName("IX_Events_ScheduledAt");
b.HasIndex("SportCode", "ScheduledAt")
.HasDatabaseName("IX_Events_SportCode_ScheduledAt");
b.ToTable("Events", (string)null);
});
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.EventResultEntity", b =>
{
b.Property<string>("EventCode")
.HasColumnType("TEXT");
b.Property<string>("CompletedAt")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("Side1Score")
.HasColumnType("INTEGER");
b.Property<int>("Side2Score")
.HasColumnType("INTEGER");
b.Property<int>("WinnerSide")
.HasColumnType("INTEGER");
b.HasKey("EventCode");
b.ToTable("EventResults", (string)null);
});
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.LeagueEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT");
b.Property<string>("Category")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("");
b.Property<string>("Country")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("NameEn")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("NameRu")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("SportCode")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.ToTable("Leagues", (string)null);
});
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.PlacedBetEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT");
b.Property<string>("EventCode")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Notes")
.HasColumnType("TEXT");
b.Property<int>("Outcome")
.HasColumnType("INTEGER");
b.Property<int?>("PeriodNumber")
.HasColumnType("INTEGER");
b.Property<string>("PlacedAt")
.IsRequired()
.HasColumnType("TEXT");
b.Property<decimal>("Rate")
.HasColumnType("TEXT");
b.Property<int>("Scope")
.HasColumnType("INTEGER");
b.Property<int>("Side")
.HasColumnType("INTEGER");
b.Property<decimal>("Stake")
.HasColumnType("TEXT");
b.Property<int>("Type")
.HasColumnType("INTEGER");
b.Property<decimal?>("Value")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("EventCode")
.HasDatabaseName("IX_PlacedBets_EventCode");
b.HasIndex("Outcome")
.HasDatabaseName("IX_PlacedBets_Outcome");
b.ToTable("PlacedBets", (string)null);
});
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.SavedStrategyEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT");
b.Property<string>("CreatedAt")
.IsRequired()
.HasColumnType("TEXT");
b.Property<decimal>("FlatStake")
.HasColumnType("TEXT");
b.Property<decimal>("KellyFraction")
.HasColumnType("TEXT");
b.Property<decimal>("MinScore")
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT");
b.Property<decimal>("PercentOfBankroll")
.HasColumnType("TEXT");
b.Property<int>("StakeRule")
.HasColumnType("INTEGER");
b.Property<decimal>("StartingBankroll")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Name")
.IsUnique()
.HasDatabaseName("IX_SavedStrategies_Name");
b.ToTable("SavedStrategies", (string)null);
});
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.SnapshotEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("CapturedAt")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("EventCode")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("Source")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("EventCode")
.HasDatabaseName("IX_Snapshots_EventCode");
b.HasIndex("EventCode", "CapturedAt")
.HasDatabaseName("IX_Snapshots_EventCode_CapturedAt");
b.HasIndex("EventCode", "Source", "CapturedAt")
.HasDatabaseName("IX_Snapshots_EventCode_Source_CapturedAt");
b.ToTable("Snapshots", (string)null);
});
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.SportEntity", b =>
{
b.Property<int>("Code")
.HasColumnType("INTEGER");
b.Property<string>("NameEn")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("NameRu")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Code");
b.ToTable("Sports", (string)null);
});
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.AnomalyEntity", b =>
{
b.HasOne("Marathon.Infrastructure.Persistence.Entities.EventEntity", "Event")
.WithMany("Anomalies")
.HasForeignKey("EventCode")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Event");
});
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.BetEntity", b =>
{
b.HasOne("Marathon.Infrastructure.Persistence.Entities.SnapshotEntity", "Snapshot")
.WithMany("Bets")
.HasForeignKey("SnapshotId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Snapshot");
});
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.EventResultEntity", b =>
{
b.HasOne("Marathon.Infrastructure.Persistence.Entities.EventEntity", "Event")
.WithOne("Result")
.HasForeignKey("Marathon.Infrastructure.Persistence.Entities.EventResultEntity", "EventCode")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Event");
});
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.SnapshotEntity", b =>
{
b.HasOne("Marathon.Infrastructure.Persistence.Entities.EventEntity", "Event")
.WithMany("Snapshots")
.HasForeignKey("EventCode")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Event");
});
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.EventEntity", b =>
{
b.Navigation("Anomalies");
b.Navigation("Result");
b.Navigation("Snapshots");
});
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.SnapshotEntity", b =>
{
b.Navigation("Bets");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,46 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Marathon.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddSavedStrategies : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "SavedStrategies",
columns: table => new
{
Id = table.Column<string>(type: "TEXT", nullable: false),
Name = table.Column<string>(type: "TEXT", nullable: false, collation: "NOCASE"),
StartingBankroll = table.Column<decimal>(type: "TEXT", nullable: false),
MinScore = table.Column<decimal>(type: "TEXT", nullable: false),
StakeRule = table.Column<int>(type: "INTEGER", nullable: false),
FlatStake = table.Column<decimal>(type: "TEXT", nullable: false),
PercentOfBankroll = table.Column<decimal>(type: "TEXT", nullable: false),
KellyFraction = table.Column<decimal>(type: "TEXT", nullable: false),
CreatedAt = table.Column<string>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_SavedStrategies", x => x.Id);
});
migrationBuilder.CreateIndex(
name: "IX_SavedStrategies_Name",
table: "SavedStrategies",
column: "Name",
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "SavedStrategies");
}
}
}
@@ -0,0 +1,439 @@
// <auto-generated />
using System;
using Marathon.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace Marathon.Infrastructure.Migrations
{
[DbContext(typeof(MarathonDbContext))]
[Migration("20260528232145_AddPaperBets")]
partial class AddPaperBets
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "8.0.12");
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.AnomalyEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT");
b.Property<string>("DetectedAt")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("EventCode")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("EvidenceJson")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("Kind")
.HasColumnType("INTEGER");
b.Property<decimal>("Score")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("EventCode")
.HasDatabaseName("IX_Anomalies_EventCode");
b.ToTable("Anomalies", (string)null);
});
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.BetEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int?>("PeriodNumber")
.HasColumnType("INTEGER");
b.Property<decimal>("Rate")
.HasColumnType("TEXT");
b.Property<int>("Scope")
.HasColumnType("INTEGER");
b.Property<int>("Side")
.HasColumnType("INTEGER");
b.Property<long>("SnapshotId")
.HasColumnType("INTEGER");
b.Property<int>("Type")
.HasColumnType("INTEGER");
b.Property<decimal?>("Value")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("SnapshotId")
.HasDatabaseName("IX_Bets_SnapshotId");
b.ToTable("Bets", (string)null);
});
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.EventEntity", b =>
{
b.Property<string>("EventCode")
.HasColumnType("TEXT");
b.Property<string>("Category")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("");
b.Property<string>("CountryCode")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("EventPath")
.HasColumnType("TEXT");
b.Property<string>("LeagueId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("ScheduledAt")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Side1Name")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Side2Name")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("SportCode")
.HasColumnType("INTEGER");
b.HasKey("EventCode");
b.HasIndex("ScheduledAt")
.HasDatabaseName("IX_Events_ScheduledAt");
b.HasIndex("SportCode", "ScheduledAt")
.HasDatabaseName("IX_Events_SportCode_ScheduledAt");
b.ToTable("Events", (string)null);
});
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.EventResultEntity", b =>
{
b.Property<string>("EventCode")
.HasColumnType("TEXT");
b.Property<string>("CompletedAt")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("Side1Score")
.HasColumnType("INTEGER");
b.Property<int>("Side2Score")
.HasColumnType("INTEGER");
b.Property<int>("WinnerSide")
.HasColumnType("INTEGER");
b.HasKey("EventCode");
b.ToTable("EventResults", (string)null);
});
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.LeagueEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT");
b.Property<string>("Category")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("");
b.Property<string>("Country")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("NameEn")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("NameRu")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("SportCode")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.ToTable("Leagues", (string)null);
});
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.PaperBetEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT");
b.Property<string>("AnomalyId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("EventCode")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("OpenedAt")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("Outcome")
.HasColumnType("INTEGER");
b.Property<decimal?>("Payout")
.HasColumnType("TEXT");
b.Property<int>("PickedSide")
.HasColumnType("INTEGER");
b.Property<decimal>("Rate")
.HasColumnType("TEXT");
b.Property<string>("SettledAt")
.HasColumnType("TEXT");
b.Property<decimal>("Stake")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("AnomalyId")
.IsUnique()
.HasDatabaseName("IX_PaperBets_AnomalyId");
b.HasIndex("Outcome")
.HasDatabaseName("IX_PaperBets_Outcome");
b.ToTable("PaperBets", (string)null);
});
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.PlacedBetEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT");
b.Property<string>("EventCode")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Notes")
.HasColumnType("TEXT");
b.Property<int>("Outcome")
.HasColumnType("INTEGER");
b.Property<int?>("PeriodNumber")
.HasColumnType("INTEGER");
b.Property<string>("PlacedAt")
.IsRequired()
.HasColumnType("TEXT");
b.Property<decimal>("Rate")
.HasColumnType("TEXT");
b.Property<int>("Scope")
.HasColumnType("INTEGER");
b.Property<int>("Side")
.HasColumnType("INTEGER");
b.Property<decimal>("Stake")
.HasColumnType("TEXT");
b.Property<int>("Type")
.HasColumnType("INTEGER");
b.Property<decimal?>("Value")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("EventCode")
.HasDatabaseName("IX_PlacedBets_EventCode");
b.HasIndex("Outcome")
.HasDatabaseName("IX_PlacedBets_Outcome");
b.ToTable("PlacedBets", (string)null);
});
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.SavedStrategyEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT");
b.Property<string>("CreatedAt")
.IsRequired()
.HasColumnType("TEXT");
b.Property<decimal>("FlatStake")
.HasColumnType("TEXT");
b.Property<decimal>("KellyFraction")
.HasColumnType("TEXT");
b.Property<decimal>("MinScore")
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT")
.UseCollation("NOCASE");
b.Property<decimal>("PercentOfBankroll")
.HasColumnType("TEXT");
b.Property<int>("StakeRule")
.HasColumnType("INTEGER");
b.Property<decimal>("StartingBankroll")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Name")
.IsUnique()
.HasDatabaseName("IX_SavedStrategies_Name");
b.ToTable("SavedStrategies", (string)null);
});
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.SnapshotEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("CapturedAt")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("EventCode")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("Source")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("EventCode")
.HasDatabaseName("IX_Snapshots_EventCode");
b.HasIndex("EventCode", "CapturedAt")
.HasDatabaseName("IX_Snapshots_EventCode_CapturedAt");
b.HasIndex("EventCode", "Source", "CapturedAt")
.HasDatabaseName("IX_Snapshots_EventCode_Source_CapturedAt");
b.ToTable("Snapshots", (string)null);
});
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.SportEntity", b =>
{
b.Property<int>("Code")
.HasColumnType("INTEGER");
b.Property<string>("NameEn")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("NameRu")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Code");
b.ToTable("Sports", (string)null);
});
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.AnomalyEntity", b =>
{
b.HasOne("Marathon.Infrastructure.Persistence.Entities.EventEntity", "Event")
.WithMany("Anomalies")
.HasForeignKey("EventCode")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Event");
});
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.BetEntity", b =>
{
b.HasOne("Marathon.Infrastructure.Persistence.Entities.SnapshotEntity", "Snapshot")
.WithMany("Bets")
.HasForeignKey("SnapshotId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Snapshot");
});
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.EventResultEntity", b =>
{
b.HasOne("Marathon.Infrastructure.Persistence.Entities.EventEntity", "Event")
.WithOne("Result")
.HasForeignKey("Marathon.Infrastructure.Persistence.Entities.EventResultEntity", "EventCode")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Event");
});
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.SnapshotEntity", b =>
{
b.HasOne("Marathon.Infrastructure.Persistence.Entities.EventEntity", "Event")
.WithMany("Snapshots")
.HasForeignKey("EventCode")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Event");
});
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.EventEntity", b =>
{
b.Navigation("Anomalies");
b.Navigation("Result");
b.Navigation("Snapshots");
});
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.SnapshotEntity", b =>
{
b.Navigation("Bets");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,52 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Marathon.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddPaperBets : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "PaperBets",
columns: table => new
{
Id = table.Column<string>(type: "TEXT", nullable: false),
AnomalyId = table.Column<string>(type: "TEXT", nullable: false),
EventCode = table.Column<string>(type: "TEXT", nullable: false),
PickedSide = table.Column<int>(type: "INTEGER", nullable: false),
Rate = table.Column<decimal>(type: "TEXT", nullable: false),
Stake = table.Column<decimal>(type: "TEXT", nullable: false),
OpenedAt = table.Column<string>(type: "TEXT", nullable: false),
Outcome = table.Column<int>(type: "INTEGER", nullable: false),
SettledAt = table.Column<string>(type: "TEXT", nullable: true),
Payout = table.Column<decimal>(type: "TEXT", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_PaperBets", x => x.Id);
});
migrationBuilder.CreateIndex(
name: "IX_PaperBets_AnomalyId",
table: "PaperBets",
column: "AnomalyId",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_PaperBets_Outcome",
table: "PaperBets",
column: "Outcome");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "PaperBets");
}
}
}
@@ -1,4 +1,5 @@
// <auto-generated />
// <auto-generated />
using System;
using Marathon.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
@@ -6,155 +7,430 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace Marathon.Infrastructure.Migrations;
[DbContext(typeof(MarathonDbContext))]
partial class MarathonDbContextModelSnapshot : ModelSnapshot
namespace Marathon.Infrastructure.Migrations
{
protected override void BuildModel(ModelBuilder modelBuilder)
[DbContext(typeof(MarathonDbContext))]
partial class MarathonDbContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "8.0.12");
modelBuilder.HasAnnotation("ProductVersion", "8.0.12");
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.AnomalyEntity", b =>
{
b.Property<string>("Id").HasColumnType("TEXT");
b.Property<string>("DetectedAt").IsRequired().HasColumnType("TEXT");
b.Property<string>("EventCode").IsRequired().HasColumnType("TEXT");
b.Property<string>("EvidenceJson").IsRequired().HasColumnType("TEXT");
b.Property<int>("Kind").HasColumnType("INTEGER");
b.Property<decimal>("Score").HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("EventCode").HasDatabaseName("IX_Anomalies_EventCode");
b.ToTable("Anomalies");
});
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.AnomalyEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT");
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.BetEntity", b =>
{
b.Property<long>("Id").ValueGeneratedOnAdd().HasColumnType("INTEGER");
b.Property<int?>("PeriodNumber").HasColumnType("INTEGER");
b.Property<decimal>("Rate").HasColumnType("TEXT");
b.Property<int>("Scope").HasColumnType("INTEGER");
b.Property<int>("Side").HasColumnType("INTEGER");
b.Property<long>("SnapshotId").HasColumnType("INTEGER");
b.Property<int>("Type").HasColumnType("INTEGER");
b.Property<decimal?>("Value").HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("SnapshotId").HasDatabaseName("IX_Bets_SnapshotId");
b.ToTable("Bets");
});
b.Property<string>("DetectedAt")
.IsRequired()
.HasColumnType("TEXT");
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.EventEntity", b =>
{
b.Property<string>("EventCode").HasColumnType("TEXT");
b.Property<string>("Category").IsRequired().HasDefaultValue("").HasColumnType("TEXT");
b.Property<string>("CountryCode").IsRequired().HasColumnType("TEXT");
b.Property<string>("EventPath").HasColumnType("TEXT");
b.Property<string>("LeagueId").IsRequired().HasColumnType("TEXT");
b.Property<string>("ScheduledAt").IsRequired().HasColumnType("TEXT");
b.Property<string>("Side1Name").IsRequired().HasColumnType("TEXT");
b.Property<string>("Side2Name").IsRequired().HasColumnType("TEXT");
b.Property<int>("SportCode").HasColumnType("INTEGER");
b.HasKey("EventCode");
b.HasIndex(new[] { "SportCode", "ScheduledAt" }).HasDatabaseName("IX_Events_SportCode_ScheduledAt");
b.HasIndex("ScheduledAt").HasDatabaseName("IX_Events_ScheduledAt");
b.ToTable("Events");
});
b.Property<string>("EventCode")
.IsRequired()
.HasColumnType("TEXT");
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.EventResultEntity", b =>
{
b.Property<string>("EventCode").HasColumnType("TEXT");
b.Property<string>("CompletedAt").IsRequired().HasColumnType("TEXT");
b.Property<int>("Side1Score").HasColumnType("INTEGER");
b.Property<int>("Side2Score").HasColumnType("INTEGER");
b.Property<int>("WinnerSide").HasColumnType("INTEGER");
b.HasKey("EventCode");
b.ToTable("EventResults");
});
b.Property<string>("EvidenceJson")
.IsRequired()
.HasColumnType("TEXT");
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.LeagueEntity", b =>
{
b.Property<string>("Id").HasColumnType("TEXT");
b.Property<string>("Category").IsRequired().HasDefaultValue("").HasColumnType("TEXT");
b.Property<string>("Country").IsRequired().HasColumnType("TEXT");
b.Property<string>("NameEn").IsRequired().HasColumnType("TEXT");
b.Property<string>("NameRu").IsRequired().HasColumnType("TEXT");
b.Property<int>("SportCode").HasColumnType("INTEGER");
b.HasKey("Id");
b.ToTable("Leagues");
});
b.Property<int>("Kind")
.HasColumnType("INTEGER");
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.SnapshotEntity", b =>
{
b.Property<long>("Id").ValueGeneratedOnAdd().HasColumnType("INTEGER");
b.Property<string>("CapturedAt").IsRequired().HasColumnType("TEXT");
b.Property<string>("EventCode").IsRequired().HasColumnType("TEXT");
b.Property<int>("Source").HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("EventCode").HasDatabaseName("IX_Snapshots_EventCode");
b.ToTable("Snapshots");
});
b.Property<decimal>("Score")
.HasColumnType("TEXT");
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.SportEntity", b =>
{
b.Property<int>("Code").HasColumnType("INTEGER");
b.Property<string>("NameEn").IsRequired().HasColumnType("TEXT");
b.Property<string>("NameRu").IsRequired().HasColumnType("TEXT");
b.HasKey("Code");
b.ToTable("Sports");
});
b.HasKey("Id");
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.AnomalyEntity", b =>
{
b.HasOne("Marathon.Infrastructure.Persistence.Entities.EventEntity", "Event")
.WithMany("Anomalies")
.HasForeignKey("EventCode")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Event");
});
b.HasIndex("EventCode")
.HasDatabaseName("IX_Anomalies_EventCode");
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.BetEntity", b =>
{
b.HasOne("Marathon.Infrastructure.Persistence.Entities.SnapshotEntity", "Snapshot")
.WithMany("Bets")
.HasForeignKey("SnapshotId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Snapshot");
});
b.ToTable("Anomalies", (string)null);
});
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.EventResultEntity", b =>
{
b.HasOne("Marathon.Infrastructure.Persistence.Entities.EventEntity", "Event")
.WithOne("Result")
.HasForeignKey("Marathon.Infrastructure.Persistence.Entities.EventResultEntity", "EventCode")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Event");
});
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.BetEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.SnapshotEntity", b =>
{
b.HasOne("Marathon.Infrastructure.Persistence.Entities.EventEntity", "Event")
.WithMany("Snapshots")
.HasForeignKey("EventCode")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Event");
});
b.Property<int?>("PeriodNumber")
.HasColumnType("INTEGER");
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.EventEntity", b =>
{
b.Navigation("Anomalies");
b.Navigation("Result");
b.Navigation("Snapshots");
});
b.Property<decimal>("Rate")
.HasColumnType("TEXT");
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.SnapshotEntity", b =>
{
b.Navigation("Bets");
});
b.Property<int>("Scope")
.HasColumnType("INTEGER");
b.Property<int>("Side")
.HasColumnType("INTEGER");
b.Property<long>("SnapshotId")
.HasColumnType("INTEGER");
b.Property<int>("Type")
.HasColumnType("INTEGER");
b.Property<decimal?>("Value")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("SnapshotId")
.HasDatabaseName("IX_Bets_SnapshotId");
b.ToTable("Bets", (string)null);
});
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.EventEntity", b =>
{
b.Property<string>("EventCode")
.HasColumnType("TEXT");
b.Property<string>("Category")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("");
b.Property<string>("CountryCode")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("EventPath")
.HasColumnType("TEXT");
b.Property<string>("LeagueId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("ScheduledAt")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Side1Name")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Side2Name")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("SportCode")
.HasColumnType("INTEGER");
b.HasKey("EventCode");
b.HasIndex("ScheduledAt")
.HasDatabaseName("IX_Events_ScheduledAt");
b.HasIndex("SportCode", "ScheduledAt")
.HasDatabaseName("IX_Events_SportCode_ScheduledAt");
b.ToTable("Events", (string)null);
});
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.EventResultEntity", b =>
{
b.Property<string>("EventCode")
.HasColumnType("TEXT");
b.Property<string>("CompletedAt")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("Side1Score")
.HasColumnType("INTEGER");
b.Property<int>("Side2Score")
.HasColumnType("INTEGER");
b.Property<int>("WinnerSide")
.HasColumnType("INTEGER");
b.HasKey("EventCode");
b.ToTable("EventResults", (string)null);
});
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.LeagueEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT");
b.Property<string>("Category")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("");
b.Property<string>("Country")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("NameEn")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("NameRu")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("SportCode")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.ToTable("Leagues", (string)null);
});
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.PaperBetEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT");
b.Property<string>("AnomalyId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("EventCode")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("OpenedAt")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("Outcome")
.HasColumnType("INTEGER");
b.Property<decimal?>("Payout")
.HasColumnType("TEXT");
b.Property<int>("PickedSide")
.HasColumnType("INTEGER");
b.Property<decimal>("Rate")
.HasColumnType("TEXT");
b.Property<string>("SettledAt")
.HasColumnType("TEXT");
b.Property<decimal>("Stake")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("AnomalyId")
.IsUnique()
.HasDatabaseName("IX_PaperBets_AnomalyId");
b.HasIndex("Outcome")
.HasDatabaseName("IX_PaperBets_Outcome");
b.ToTable("PaperBets", (string)null);
});
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.PlacedBetEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT");
b.Property<string>("EventCode")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Notes")
.HasColumnType("TEXT");
b.Property<int>("Outcome")
.HasColumnType("INTEGER");
b.Property<int?>("PeriodNumber")
.HasColumnType("INTEGER");
b.Property<string>("PlacedAt")
.IsRequired()
.HasColumnType("TEXT");
b.Property<decimal>("Rate")
.HasColumnType("TEXT");
b.Property<int>("Scope")
.HasColumnType("INTEGER");
b.Property<int>("Side")
.HasColumnType("INTEGER");
b.Property<decimal>("Stake")
.HasColumnType("TEXT");
b.Property<int>("Type")
.HasColumnType("INTEGER");
b.Property<decimal?>("Value")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("EventCode")
.HasDatabaseName("IX_PlacedBets_EventCode");
b.HasIndex("Outcome")
.HasDatabaseName("IX_PlacedBets_Outcome");
b.ToTable("PlacedBets", (string)null);
});
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.SavedStrategyEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT");
b.Property<string>("CreatedAt")
.IsRequired()
.HasColumnType("TEXT");
b.Property<decimal>("FlatStake")
.HasColumnType("TEXT");
b.Property<decimal>("KellyFraction")
.HasColumnType("TEXT");
b.Property<decimal>("MinScore")
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT")
.UseCollation("NOCASE");
b.Property<decimal>("PercentOfBankroll")
.HasColumnType("TEXT");
b.Property<int>("StakeRule")
.HasColumnType("INTEGER");
b.Property<decimal>("StartingBankroll")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Name")
.IsUnique()
.HasDatabaseName("IX_SavedStrategies_Name");
b.ToTable("SavedStrategies", (string)null);
});
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.SnapshotEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("CapturedAt")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("EventCode")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("Source")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("EventCode")
.HasDatabaseName("IX_Snapshots_EventCode");
b.HasIndex("EventCode", "CapturedAt")
.HasDatabaseName("IX_Snapshots_EventCode_CapturedAt");
b.HasIndex("EventCode", "Source", "CapturedAt")
.HasDatabaseName("IX_Snapshots_EventCode_Source_CapturedAt");
b.ToTable("Snapshots", (string)null);
});
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.SportEntity", b =>
{
b.Property<int>("Code")
.HasColumnType("INTEGER");
b.Property<string>("NameEn")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("NameRu")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Code");
b.ToTable("Sports", (string)null);
});
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.AnomalyEntity", b =>
{
b.HasOne("Marathon.Infrastructure.Persistence.Entities.EventEntity", "Event")
.WithMany("Anomalies")
.HasForeignKey("EventCode")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Event");
});
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.BetEntity", b =>
{
b.HasOne("Marathon.Infrastructure.Persistence.Entities.SnapshotEntity", "Snapshot")
.WithMany("Bets")
.HasForeignKey("SnapshotId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Snapshot");
});
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.EventResultEntity", b =>
{
b.HasOne("Marathon.Infrastructure.Persistence.Entities.EventEntity", "Event")
.WithOne("Result")
.HasForeignKey("Marathon.Infrastructure.Persistence.Entities.EventResultEntity", "EventCode")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Event");
});
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.SnapshotEntity", b =>
{
b.HasOne("Marathon.Infrastructure.Persistence.Entities.EventEntity", "Event")
.WithMany("Snapshots")
.HasForeignKey("EventCode")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Event");
});
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.EventEntity", b =>
{
b.Navigation("Anomalies");
b.Navigation("Result");
b.Navigation("Snapshots");
});
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.SnapshotEntity", b =>
{
b.Navigation("Bets");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,79 @@
using System.Net.Http.Json;
using Marathon.Application.Abstractions;
using Marathon.Infrastructure.Configuration;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace Marathon.Infrastructure.Notifications;
/// <summary>
/// Delivers anomaly alerts to a Telegram chat via the Bot API <c>sendMessage</c>
/// endpoint, using a plain <see cref="HttpClient"/> (no third-party SDK dependency).
/// </summary>
/// <remarks>
/// No-ops with a warning when the bot token or chat id is not configured, so a
/// half-configured deployment degrades gracefully rather than throwing. The bot token
/// is never logged (it sits in the request URL only).
/// </remarks>
internal sealed class TelegramNotificationSink : INotificationSink
{
public const string HttpClientName = "telegram";
private readonly IHttpClientFactory _factory;
private readonly IOptionsMonitor<NotificationOptions> _opts;
private readonly ILogger<TelegramNotificationSink> _logger;
public TelegramNotificationSink(
IHttpClientFactory factory,
IOptionsMonitor<NotificationOptions> opts,
ILogger<TelegramNotificationSink> logger)
{
_factory = factory ?? throw new ArgumentNullException(nameof(factory));
_opts = opts ?? throw new ArgumentNullException(nameof(opts));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task SendAsync(AnomalyNotification notification, CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(notification);
var opts = _opts.CurrentValue;
if (string.IsNullOrWhiteSpace(opts.TelegramBotToken) || string.IsNullOrWhiteSpace(opts.TelegramChatId))
{
_logger.LogWarning(
"TelegramNotificationSink: bot token / chat id not configured — skipping notification {AnomalyId}.",
notification.AnomalyId);
return;
}
var payload = new
{
chat_id = opts.TelegramChatId,
text = FormatMessage(notification),
disable_web_page_preview = true,
};
var client = _factory.CreateClient(HttpClientName);
var requestUri = $"https://api.telegram.org/bot{opts.TelegramBotToken}/sendMessage";
try
{
using var response = await client.PostAsJsonAsync(requestUri, payload, ct).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
// Never log the URL/token — only the status.
_logger.LogWarning(
"TelegramNotificationSink: send failed for {AnomalyId} with status {Status}.",
notification.AnomalyId, (int)response.StatusCode);
}
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_logger.LogWarning(ex,
"TelegramNotificationSink: send threw for {AnomalyId}.", notification.AnomalyId);
}
}
private static string FormatMessage(AnomalyNotification n) =>
$"⚠ {n.Kind}\n{n.EventTitle}\nScore {n.Score:0.00} · {n.DetectedAt:yyyy-MM-dd HH:mm} MSK";
}
@@ -0,0 +1,38 @@
using Marathon.Infrastructure.Persistence.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Marathon.Infrastructure.Persistence.Configurations;
internal sealed class PaperBetConfiguration : IEntityTypeConfiguration<PaperBetEntity>
{
public void Configure(EntityTypeBuilder<PaperBetEntity> builder)
{
builder.ToTable("PaperBets");
builder.HasKey(b => b.Id);
builder.Property(b => b.Id).HasColumnType("TEXT").IsRequired();
builder.Property(b => b.AnomalyId).HasColumnType("TEXT").IsRequired();
builder.Property(b => b.EventCode).HasColumnType("TEXT").IsRequired();
builder.Property(b => b.PickedSide).HasColumnType("INTEGER").IsRequired();
builder.Property(b => b.Rate).HasColumnType("TEXT").IsRequired();
builder.Property(b => b.Stake).HasColumnType("TEXT").IsRequired();
builder.Property(b => b.OpenedAt).HasColumnType("TEXT").IsRequired();
builder.Property(b => b.Outcome).HasColumnType("INTEGER").IsRequired();
builder.Property(b => b.SettledAt).HasColumnType("TEXT");
builder.Property(b => b.Payout).HasColumnType("TEXT");
// One paper bet per anomaly — the opener skips existing ids, and this index is
// the hard backstop against a double-open race.
builder.HasIndex(b => b.AnomalyId)
.IsUnique()
.HasDatabaseName("IX_PaperBets_AnomalyId");
// The settler scans the open (Pending) set every cycle.
builder.HasIndex(b => b.Outcome).HasDatabaseName("IX_PaperBets_Outcome");
// No FK to Events/Anomalies — the ledger is analysis data and must survive
// snapshot-retention pruning of the source rows (same rationale as PlacedBets).
}
}
@@ -0,0 +1,35 @@
using Marathon.Infrastructure.Persistence.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Marathon.Infrastructure.Persistence.Configurations;
internal sealed class PlacedBetConfiguration : IEntityTypeConfiguration<PlacedBetEntity>
{
public void Configure(EntityTypeBuilder<PlacedBetEntity> builder)
{
builder.ToTable("PlacedBets");
builder.HasKey(b => b.Id);
builder.Property(b => b.Id).HasColumnType("TEXT").IsRequired();
builder.Property(b => b.EventCode).HasColumnType("TEXT").IsRequired();
builder.Property(b => b.Scope).HasColumnType("INTEGER").IsRequired();
builder.Property(b => b.PeriodNumber).HasColumnType("INTEGER");
builder.Property(b => b.Type).HasColumnType("INTEGER").IsRequired();
builder.Property(b => b.Side).HasColumnType("INTEGER").IsRequired();
builder.Property(b => b.Value).HasColumnType("TEXT");
builder.Property(b => b.Rate).HasColumnType("TEXT").IsRequired();
builder.Property(b => b.Stake).HasColumnType("TEXT").IsRequired();
builder.Property(b => b.PlacedAt).HasColumnType("TEXT").IsRequired();
builder.Property(b => b.Outcome).HasColumnType("INTEGER").IsRequired();
builder.Property(b => b.Notes).HasColumnType("TEXT");
// EventCode is intentionally NOT a foreign key — the journal is the
// user's data and must survive snapshot retention pruning the source
// event row. Existence is checked once at insert time by the use case.
builder.HasIndex(b => b.EventCode).HasDatabaseName("IX_PlacedBets_EventCode");
builder.HasIndex(b => b.Outcome).HasDatabaseName("IX_PlacedBets_Outcome");
}
}
@@ -0,0 +1,35 @@
using Marathon.Infrastructure.Persistence.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Marathon.Infrastructure.Persistence.Configurations;
internal sealed class SavedStrategyConfiguration : IEntityTypeConfiguration<SavedStrategyEntity>
{
public void Configure(EntityTypeBuilder<SavedStrategyEntity> builder)
{
builder.ToTable("SavedStrategies");
builder.HasKey(s => s.Id);
builder.Property(s => s.Id).HasColumnType("TEXT").IsRequired();
// NOCASE so the unique index and the GetByNameAsync lookup both treat names
// case-insensitively (ASCII) — "Kelly" and "kelly" are the same preset, and
// save-by-name overwrites rather than creating a near-duplicate.
builder.Property(s => s.Name).HasColumnType("TEXT").UseCollation("NOCASE").IsRequired();
builder.Property(s => s.StartingBankroll).HasColumnType("TEXT").IsRequired();
builder.Property(s => s.MinScore).HasColumnType("TEXT").IsRequired();
builder.Property(s => s.StakeRule).HasColumnType("INTEGER").IsRequired();
builder.Property(s => s.FlatStake).HasColumnType("TEXT").IsRequired();
builder.Property(s => s.PercentOfBankroll).HasColumnType("TEXT").IsRequired();
builder.Property(s => s.KellyFraction).HasColumnType("TEXT").IsRequired();
builder.Property(s => s.CreatedAt).HasColumnType("TEXT").IsRequired();
// Names are the user-facing identity for save/overwrite, so they must be
// unique — the SaveStrategyUseCase upserts by name and the index backstops
// any race that would otherwise create a duplicate.
builder.HasIndex(s => s.Name)
.IsUnique()
.HasDatabaseName("IX_SavedStrategies_Name");
}
}
@@ -18,6 +18,17 @@ internal sealed class SnapshotConfiguration : IEntityTypeConfiguration<SnapshotE
builder.HasIndex(s => s.EventCode).HasDatabaseName("IX_Snapshots_EventCode");
// Snapshots is the largest table (live cadence 510s, 90-day retention) and
// every hot read filters EventCode + CapturedAt range, often with an ORDER BY
// CapturedAt. These composite indexes let SQLite satisfy the filter and the
// ordering from the index instead of scanning + sorting the table.
builder.HasIndex(s => new { s.EventCode, s.CapturedAt })
.HasDatabaseName("IX_Snapshots_EventCode_CapturedAt");
// Covers GetLatestPreMatchAsync: EventCode + Source filter, ORDER BY CapturedAt DESC.
builder.HasIndex(s => new { s.EventCode, s.Source, s.CapturedAt })
.HasDatabaseName("IX_Snapshots_EventCode_Source_CapturedAt");
builder.HasMany(s => s.Bets)
.WithOne(b => b.Snapshot)
.HasForeignKey(b => b.SnapshotId)
@@ -11,7 +11,11 @@ internal sealed class SportConfiguration : IEntityTypeConfiguration<SportEntity>
builder.ToTable("Sports");
builder.HasKey(s => s.Code);
builder.Property(s => s.Code).HasColumnType("INTEGER").IsRequired();
// Code is the bookmaker's canonical sport id (6 = Basketball, 11 = Football,
// 22723 = Tennis, …), a natural key — never an auto-incremented surrogate.
// Without this, EF's int-PK convention treats it as ValueGeneratedOnAdd and
// tries to alter the column to AUTOINCREMENT on the next migration.
builder.Property(s => s.Code).HasColumnType("INTEGER").ValueGeneratedNever().IsRequired();
builder.Property(s => s.NameRu).HasColumnType("TEXT").IsRequired();
builder.Property(s => s.NameEn).HasColumnType("TEXT").IsRequired();
}
@@ -0,0 +1,39 @@
namespace Marathon.Infrastructure.Persistence.Entities;
/// <summary>
/// EF Core persistence entity for a <see cref="Marathon.Domain.Entities.PaperBet"/> —
/// the system-generated forward-test ledger. Decimals are stored as TEXT (invariant
/// round-trip) to match the rest of the schema.
/// </summary>
public sealed class PaperBetEntity
{
/// <summary>GUID primary key stored as TEXT.</summary>
public string Id { get; set; } = default!;
/// <summary>The anomaly that triggered this paper bet (unique — one bet per anomaly).</summary>
public string AnomalyId { get; set; } = default!;
/// <summary>The event the bet is on.</summary>
public string EventCode { get; set; } = default!;
/// <summary>Picked Side as int (Side1 / Side2 / Draw).</summary>
public int PickedSide { get; set; }
/// <summary>Decimal odds locked in at the moment the signal fired.</summary>
public decimal Rate { get; set; }
/// <summary>Flat stake.</summary>
public decimal Stake { get; set; }
/// <summary>ISO 8601 timestamp of the originating anomaly's detection (Moscow time).</summary>
public string OpenedAt { get; set; } = default!;
/// <summary>BetOutcome as int (Pending = open / Won / Lost / Void).</summary>
public int Outcome { get; set; }
/// <summary>ISO 8601 settlement timestamp, or null while open.</summary>
public string? SettledAt { get; set; }
/// <summary>Realised payout once settled (stake × rate on a win, 0 on a loss), else null.</summary>
public decimal? Payout { get; set; }
}
@@ -0,0 +1,47 @@
namespace Marathon.Infrastructure.Persistence.Entities;
/// <summary>
/// EF Core persistence entity for a user-tracked <see cref="Marathon.Domain.Entities.PlacedBet"/>.
/// Flattens the embedded <c>Bet</c> selection (Scope / Type / Side / Value / Rate)
/// into columns so SQLite can index by event and outcome cheaply.
/// </summary>
public sealed class PlacedBetEntity
{
/// <summary>GUID primary key stored as TEXT.</summary>
public string Id { get; set; } = default!;
/// <summary>Foreign key to <see cref="EventEntity.EventCode"/>.</summary>
public string EventCode { get; set; } = default!;
// ─── Embedded Bet selection ──────────────────────────────────────────────
/// <summary>Scope discriminator: 0 = Match, 1 = Period.</summary>
public int Scope { get; set; }
/// <summary>Period number when <see cref="Scope"/> = 1; null otherwise.</summary>
public int? PeriodNumber { get; set; }
/// <summary>BetType as int (Win / Draw / WinFora / Total).</summary>
public int Type { get; set; }
/// <summary>Side as int (Side1 / Side2 / Draw / Less / More).</summary>
public int Side { get; set; }
/// <summary>Handicap or total threshold; null for Win / Draw markets.</summary>
public decimal? Value { get; set; }
/// <summary>Decimal odds the user took.</summary>
public decimal Rate { get; set; }
// ─── Wager fields ────────────────────────────────────────────────────────
/// <summary>Stake in the user's currency.</summary>
public decimal Stake { get; set; }
/// <summary>ISO 8601 timestamp when the bet was recorded (Moscow time).</summary>
public string PlacedAt { get; set; } = default!;
/// <summary>BetOutcome as int (Pending / Won / Lost / Void).</summary>
public int Outcome { get; set; }
/// <summary>Optional free-text note from the user.</summary>
public string? Notes { get; set; }
}
@@ -0,0 +1,33 @@
namespace Marathon.Infrastructure.Persistence.Entities;
/// <summary>
/// EF Core persistence entity for a <see cref="Marathon.Domain.Backtesting.SavedStrategy"/>.
/// Flattens the wrapped <c>BacktestStrategy</c> parameters into columns; decimals
/// are stored as TEXT (invariant round-trip) to match the rest of the schema.
/// </summary>
public sealed class SavedStrategyEntity
{
/// <summary>GUID primary key stored as TEXT.</summary>
public string Id { get; set; } = default!;
/// <summary>User-supplied label; unique across the store.</summary>
public string Name { get; set; } = default!;
// ─── Flattened BacktestStrategy ──────────────────────────────────────────
public decimal StartingBankroll { get; set; }
public decimal MinScore { get; set; }
/// <summary>StakeRule as int (Flat / PercentOfBankroll / Kelly).</summary>
public int StakeRule { get; set; }
public decimal FlatStake { get; set; }
/// <summary>Fraction in (0, 1] — e.g. 0.02 = 2% of bankroll.</summary>
public decimal PercentOfBankroll { get; set; }
/// <summary>Kelly multiplier in (0, 1] — e.g. 0.25 = quarter-Kelly.</summary>
public decimal KellyFraction { get; set; }
/// <summary>ISO 8601 timestamp when the preset was first saved (Moscow time).</summary>
public string CreatedAt { get; set; } = default!;
}
@@ -1,4 +1,4 @@
using System.Globalization;
using Marathon.Domain.Backtesting;
using Marathon.Domain.Entities;
using Marathon.Domain.Enums;
using Marathon.Domain.ValueObjects;
@@ -10,16 +10,15 @@ namespace Marathon.Infrastructure.Persistence;
/// Mapping helpers that translate between domain objects and EF Core persistence entities.
/// Domain invariants are enforced on the domain side; mapping is purely structural.
/// </summary>
/// <remarks>
/// ScheduledAt / CapturedAt / DetectedAt / CompletedAt / PlacedAt are encoded and
/// decoded exclusively through <see cref="SqliteDateText"/> so the write format and
/// the repositories' range-predicate format can never drift apart.
/// </remarks>
internal static class Mapping
{
// ScheduledAt / CapturedAt / DetectedAt / CompletedAt are written via
// DateTimeOffset.ToString("O") — round-trip ISO 8601. Parse with the
// invariant culture and RoundtripKind so a non-en-US thread culture
// (or a future locale change) cannot corrupt the round-trip.
private const DateTimeStyles RoundtripStyles = DateTimeStyles.RoundtripKind;
// ─── Bet scope discriminator constants ────────────────────────────────────
private const int ScopeMatch = 0;
private const int ScopeMatch = 0;
private const int ScopePeriod = 1;
// ─── Event ───────────────────────────────────────────────────────────────
@@ -31,7 +30,7 @@ internal static class Mapping
CountryCode = domain.CountryCode,
LeagueId = domain.LeagueId,
Category = domain.Category,
ScheduledAt = domain.ScheduledAt.ToString("O"),
ScheduledAt = SqliteDateText.Key(domain.ScheduledAt),
Side1Name = domain.Side1Name,
Side2Name = domain.Side2Name,
EventPath = domain.EventPath,
@@ -44,7 +43,7 @@ internal static class Mapping
CountryCode: entity.CountryCode,
LeagueId: entity.LeagueId,
Category: entity.Category,
ScheduledAt: DateTimeOffset.Parse(entity.ScheduledAt, CultureInfo.InvariantCulture, RoundtripStyles),
ScheduledAt: SqliteDateText.Parse(entity.ScheduledAt),
Side1Name: entity.Side1Name,
Side2Name: entity.Side2Name)
{
@@ -57,7 +56,7 @@ internal static class Mapping
new()
{
EventCode = domain.EventId.Value,
CapturedAt = domain.CapturedAt.ToString("O"),
CapturedAt = SqliteDateText.Key(domain.CapturedAt),
Source = (int)domain.Source,
Bets = domain.Bets.Select(ToEntity).ToList(),
};
@@ -65,7 +64,7 @@ internal static class Mapping
public static OddsSnapshot ToDomain(SnapshotEntity entity) =>
new(
eventId: new EventId(entity.EventCode),
capturedAt: DateTimeOffset.Parse(entity.CapturedAt, CultureInfo.InvariantCulture, RoundtripStyles),
capturedAt: SqliteDateText.Parse(entity.CapturedAt),
source: (OddsSource)entity.Source,
bets: entity.Bets.Select(ToDomain).ToList().AsReadOnly());
@@ -86,7 +85,7 @@ internal static class Mapping
{
var scope = entity.Scope switch
{
ScopeMatch => (BetScope)MatchScope.Instance,
ScopeMatch => (BetScope)MatchScope.Instance,
ScopePeriod => new PeriodScope(entity.PeriodNumber!.Value),
_ => throw new InvalidOperationException(
$"Unknown BetScope discriminator: {entity.Scope}"),
@@ -109,7 +108,7 @@ internal static class Mapping
Side1Score = domain.Side1Score,
Side2Score = domain.Side2Score,
WinnerSide = (int)domain.WinnerSide,
CompletedAt = domain.CompletedAt.ToString("O"),
CompletedAt = SqliteDateText.Key(domain.CompletedAt),
};
public static EventResult ToDomain(EventResultEntity entity) =>
@@ -118,7 +117,7 @@ internal static class Mapping
Side1Score: entity.Side1Score,
Side2Score: entity.Side2Score,
WinnerSide: (Side)entity.WinnerSide,
CompletedAt: DateTimeOffset.Parse(entity.CompletedAt, CultureInfo.InvariantCulture, RoundtripStyles));
CompletedAt: SqliteDateText.Parse(entity.CompletedAt));
// ─── Anomaly ──────────────────────────────────────────────────────────────
@@ -127,7 +126,7 @@ internal static class Mapping
{
Id = domain.Id.ToString(),
EventCode = domain.EventId.Value,
DetectedAt = domain.DetectedAt.ToString("O"),
DetectedAt = SqliteDateText.Key(domain.DetectedAt),
Kind = (int)domain.Kind,
Score = domain.Score,
EvidenceJson = domain.EvidenceJson,
@@ -137,7 +136,7 @@ internal static class Mapping
new(
Id: Guid.Parse(entity.Id),
EventId: new EventId(entity.EventCode),
DetectedAt: DateTimeOffset.Parse(entity.DetectedAt, CultureInfo.InvariantCulture, RoundtripStyles),
DetectedAt: SqliteDateText.Parse(entity.DetectedAt),
Kind: (AnomalyKind)entity.Kind,
Score: entity.Score,
EvidenceJson: entity.EvidenceJson);
@@ -158,6 +157,51 @@ internal static class Mapping
NameRu: entity.NameRu,
NameEn: entity.NameEn);
// ─── PlacedBet ────────────────────────────────────────────────────────────
public static PlacedBetEntity ToEntity(PlacedBet domain) =>
new()
{
Id = domain.Id.ToString(),
EventCode = domain.EventId.Value,
Scope = domain.Selection.Scope is MatchScope ? ScopeMatch : ScopePeriod,
PeriodNumber = domain.Selection.Scope is PeriodScope ps ? ps.Number : null,
Type = (int)domain.Selection.Type,
Side = (int)domain.Selection.Side,
Value = domain.Selection.Value?.Value,
Rate = domain.Selection.Rate.Value,
Stake = domain.Stake,
PlacedAt = SqliteDateText.Key(domain.PlacedAt),
Outcome = (int)domain.Outcome,
Notes = domain.Notes,
};
public static PlacedBet ToDomain(PlacedBetEntity entity)
{
var scope = entity.Scope switch
{
ScopeMatch => (BetScope)MatchScope.Instance,
ScopePeriod => new PeriodScope(entity.PeriodNumber!.Value),
_ => throw new InvalidOperationException(
$"Unknown BetScope discriminator: {entity.Scope}"),
};
var value = entity.Value.HasValue ? new OddsValue(entity.Value.Value) : null;
var rate = new OddsRate(entity.Rate);
var type = (BetType)entity.Type;
var side = (Side)entity.Side;
var selection = new Bet(scope, type, side, value, rate);
return new PlacedBet(
Id: Guid.Parse(entity.Id),
EventId: new EventId(entity.EventCode),
Selection: selection,
Stake: entity.Stake,
PlacedAt: SqliteDateText.Parse(entity.PlacedAt),
Outcome: (BetOutcome)entity.Outcome,
Notes: entity.Notes);
}
// ─── League ───────────────────────────────────────────────────────────────
public static LeagueEntity ToEntity(League domain) =>
@@ -179,4 +223,63 @@ internal static class Mapping
NameRu: entity.NameRu,
NameEn: entity.NameEn,
Category: entity.Category);
// ─── SavedStrategy ─────────────────────────────────────────────────────────
public static SavedStrategyEntity ToEntity(SavedStrategy domain) =>
new()
{
Id = domain.Id.ToString(),
Name = domain.Name,
StartingBankroll = domain.Strategy.StartingBankroll,
MinScore = domain.Strategy.MinScore,
StakeRule = (int)domain.Strategy.StakeRule,
FlatStake = domain.Strategy.FlatStake,
PercentOfBankroll = domain.Strategy.PercentOfBankroll,
KellyFraction = domain.Strategy.KellyFraction,
CreatedAt = SqliteDateText.Key(domain.CreatedAt),
};
public static SavedStrategy ToDomain(SavedStrategyEntity entity) =>
new(
Id: Guid.Parse(entity.Id),
Name: entity.Name,
Strategy: new BacktestStrategy(
StartingBankroll: entity.StartingBankroll,
MinScore: entity.MinScore,
StakeRule: (StakeRule)entity.StakeRule,
FlatStake: entity.FlatStake,
PercentOfBankroll: entity.PercentOfBankroll,
KellyFraction: entity.KellyFraction),
CreatedAt: SqliteDateText.Parse(entity.CreatedAt));
// ─── PaperBet ──────────────────────────────────────────────────────────────
public static PaperBetEntity ToEntity(PaperBet domain) =>
new()
{
Id = domain.Id.ToString(),
AnomalyId = domain.AnomalyId.ToString(),
EventCode = domain.EventId.Value,
PickedSide = (int)domain.PickedSide,
Rate = domain.Rate,
Stake = domain.Stake,
OpenedAt = SqliteDateText.Key(domain.OpenedAt),
Outcome = (int)domain.Outcome,
SettledAt = domain.SettledAt is { } s ? SqliteDateText.Key(s) : null,
Payout = domain.Payout,
};
public static PaperBet ToDomain(PaperBetEntity entity) =>
new(
Id: Guid.Parse(entity.Id),
AnomalyId: Guid.Parse(entity.AnomalyId),
EventId: new EventId(entity.EventCode),
PickedSide: (Side)entity.PickedSide,
Rate: entity.Rate,
Stake: entity.Stake,
OpenedAt: SqliteDateText.Parse(entity.OpenedAt),
Outcome: (BetOutcome)entity.Outcome,
SettledAt: entity.SettledAt is { } s ? SqliteDateText.Parse(s) : null,
Payout: entity.Payout);
}
@@ -18,6 +18,9 @@ public sealed class MarathonDbContext : DbContext
public DbSet<AnomalyEntity> Anomalies => Set<AnomalyEntity>();
public DbSet<SportEntity> Sports => Set<SportEntity>();
public DbSet<LeagueEntity> Leagues => Set<LeagueEntity>();
public DbSet<PlacedBetEntity> PlacedBets => Set<PlacedBetEntity>();
public DbSet<SavedStrategyEntity> SavedStrategies => Set<SavedStrategyEntity>();
public DbSet<PaperBetEntity> PaperBets => Set<PaperBetEntity>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
@@ -53,6 +53,9 @@ public static class PersistenceModule
services.AddScoped<ISnapshotRepository, SnapshotRepository>();
services.AddScoped<IResultRepository, ResultRepository>();
services.AddScoped<IAnomalyRepository, AnomalyRepository>();
services.AddScoped<IPlacedBetRepository, PlacedBetRepository>();
services.AddScoped<ISavedStrategyRepository, SavedStrategyRepository>();
services.AddScoped<IPaperBetRepository, PaperBetRepository>();
services.AddScoped<IExcelExporter, ExcelExporter>();
return services;
@@ -23,6 +23,44 @@ internal sealed class AnomalyRepository : IAnomalyRepository
return entities.Select(Mapping.ToDomain).ToList().AsReadOnly();
}
public async Task<int> CountSinceAsync(DateTimeOffset since, CancellationToken ct = default)
{
// Server-side COUNT(*) — the unread-badge hot path must not materialise the
// whole table (with EvidenceJson) just to count. DetectedAt is stored as the
// O-format TEXT key (see SqliteDateText); ">" matches the prior in-memory
// GetUnreadCountAsync semantics (strictly newer than the last-seen marker).
var sinceStr = SqliteDateText.Key(since);
return await _db.Anomalies.AsNoTracking()
.Where(a => a.DetectedAt.CompareTo(sinceStr) > 0)
.CountAsync(ct);
}
public async Task<IReadOnlyList<Anomaly>> ListByDateRangeAsync(
DateTimeOffset? from,
DateTimeOffset? to,
CancellationToken ct = default)
{
var q = _db.Anomalies.AsNoTracking();
if (from is { } f)
{
var fromStr = SqliteDateText.Key(f);
q = q.Where(a => a.DetectedAt.CompareTo(fromStr) >= 0);
}
if (to is { } t)
{
var toStr = SqliteDateText.Key(t);
q = q.Where(a => a.DetectedAt.CompareTo(toStr) <= 0);
}
var entities = await q
.OrderByDescending(a => a.DetectedAt)
.ToListAsync(ct);
return entities.Select(Mapping.ToDomain).ToList().AsReadOnly();
}
public async Task AddAsync(Anomaly entity, CancellationToken ct = default)
{
var efEntity = Mapping.ToEntity(entity);
@@ -26,9 +26,10 @@ internal sealed class EventRepository : IEventRepository
public async Task<IReadOnlyList<Event>> ListByDateRangeAsync(DateRange range, CancellationToken ct = default)
{
// ScheduledAt is stored as ISO 8601 TEXT; SQLite TEXT comparison sorts correctly for ISO 8601.
var fromStr = range.From.ToString("O");
var toStr = range.To.ToString("O");
// ScheduledAt is stored as ISO 8601 TEXT (see SqliteDateText); SQLite TEXT
// comparison sorts chronologically for the fixed-offset O format.
var fromStr = SqliteDateText.Key(range.From);
var toStr = SqliteDateText.Key(range.To);
// EF Core SQLite cannot translate string.Compare(...) with StringComparison; it can
// translate the relational operators on string columns (which use BINARY/ordinal
@@ -41,6 +42,57 @@ internal sealed class EventRepository : IEventRepository
return entities.Select(Mapping.ToDomain).ToList().AsReadOnly();
}
public async Task<IReadOnlyList<Event>> QueryAsync(EventQuery query, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(query);
var fromStr = SqliteDateText.Key(query.Dates.From);
var toStr = SqliteDateText.Key(query.Dates.To);
// Date range + sport filter pushed to SQL so a multi-sport page no longer
// materialises every event in the window. The composite
// IX_Events_SportCode_ScheduledAt index covers this predicate. Case-sensitive
// search / country filtering and locale-aware sorting stay in the service
// layer where Cyrillic ordinal semantics are preserved.
var q = _db.Events.AsNoTracking()
.Where(e => e.ScheduledAt.CompareTo(fromStr) >= 0
&& e.ScheduledAt.CompareTo(toStr) <= 0);
if (query.SportCodes is { Count: > 0 } sports)
{
var sportArray = sports.Distinct().ToArray();
q = q.Where(e => sportArray.Contains(e.SportCode));
}
var entities = await q.ToListAsync(ct);
return entities.Select(Mapping.ToDomain).ToList().AsReadOnly();
}
public async Task<IReadOnlyDictionary<EventId, Event>> GetManyAsync(
IReadOnlyCollection<EventId> ids,
CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(ids);
var result = new Dictionary<EventId, Event>(ids.Count);
if (ids.Count == 0)
return result;
var codes = ids.Select(e => e.Value).Distinct().ToArray();
var entities = await _db.Events.AsNoTracking()
.Where(e => codes.Contains(e.EventCode))
.ToListAsync(ct);
foreach (var entity in entities)
{
var domain = Mapping.ToDomain(entity);
result[domain.Id] = domain;
}
return result;
}
public async Task<IReadOnlyList<Event>> ListBySportAsync(SportCode sport, CancellationToken ct = default)
{
var entities = await _db.Events.AsNoTracking()
@@ -50,6 +102,9 @@ internal sealed class EventRepository : IEventRepository
return entities.Select(Mapping.ToDomain).ToList().AsReadOnly();
}
public Task<int> CountAsync(CancellationToken ct = default) =>
_db.Events.AsNoTracking().CountAsync(ct);
public async Task<IReadOnlyList<int>> ListDistinctSportCodesAsync(CancellationToken ct = default)
{
var codes = await _db.Events.AsNoTracking()
@@ -0,0 +1,77 @@
using Marathon.Application.Abstractions;
using Marathon.Domain.Entities;
using Marathon.Domain.Enums;
using Microsoft.EntityFrameworkCore;
namespace Marathon.Infrastructure.Persistence.Repositories;
internal sealed class PaperBetRepository : IPaperBetRepository
{
private readonly MarathonDbContext _db;
public PaperBetRepository(MarathonDbContext db) => _db = db;
public async Task<PaperBet?> GetAsync(Guid key, CancellationToken ct = default)
{
var idStr = key.ToString();
var entity = await _db.PaperBets.AsNoTracking()
.FirstOrDefaultAsync(b => b.Id == idStr, ct);
return entity is null ? null : Mapping.ToDomain(entity);
}
public async Task<IReadOnlyList<PaperBet>> ListAsync(CancellationToken ct = default)
{
var entities = await _db.PaperBets.AsNoTracking()
.OrderByDescending(b => b.OpenedAt)
.ToListAsync(ct);
return entities.Select(Mapping.ToDomain).ToList().AsReadOnly();
}
public async Task<IReadOnlyList<PaperBet>> ListByOutcomeAsync(BetOutcome outcome, CancellationToken ct = default)
{
var outcomeInt = (int)outcome;
var entities = await _db.PaperBets.AsNoTracking()
.Where(b => b.Outcome == outcomeInt)
.ToListAsync(ct);
return entities.Select(Mapping.ToDomain).ToList().AsReadOnly();
}
public async Task<IReadOnlySet<Guid>> GetExistingAnomalyIdsAsync(
IReadOnlyCollection<Guid> anomalyIds, CancellationToken ct = default)
{
if (anomalyIds.Count == 0)
return new HashSet<Guid>();
var idStrings = anomalyIds.Select(id => id.ToString()).ToList();
var existing = await _db.PaperBets.AsNoTracking()
.Where(b => idStrings.Contains(b.AnomalyId))
.Select(b => b.AnomalyId)
.ToListAsync(ct);
return existing.Select(Guid.Parse).ToHashSet();
}
public async Task AddAsync(PaperBet entity, CancellationToken ct = default)
{
var efEntity = Mapping.ToEntity(entity);
await _db.PaperBets.AddAsync(efEntity, ct);
}
public Task UpdateAsync(PaperBet entity, CancellationToken ct = default)
{
var efEntity = Mapping.ToEntity(entity);
_db.PaperBets.Update(efEntity);
return Task.CompletedTask;
}
public async Task DeleteAsync(Guid key, CancellationToken ct = default)
{
var idStr = key.ToString();
var entity = await _db.PaperBets.FirstOrDefaultAsync(b => b.Id == idStr, ct);
if (entity is not null)
_db.PaperBets.Remove(entity);
}
public async Task SaveChangesAsync(CancellationToken ct = default) =>
await _db.SaveChangesAsync(ct);
}
@@ -0,0 +1,87 @@
using Marathon.Application.Abstractions;
using Marathon.Application.Storage;
using Marathon.Domain.Entities;
using Marathon.Domain.Enums;
using Marathon.Domain.ValueObjects;
using Microsoft.EntityFrameworkCore;
namespace Marathon.Infrastructure.Persistence.Repositories;
internal sealed class PlacedBetRepository : IPlacedBetRepository
{
private readonly MarathonDbContext _db;
public PlacedBetRepository(MarathonDbContext db) => _db = db;
public async Task<PlacedBet?> GetAsync(Guid key, CancellationToken ct = default)
{
var idStr = key.ToString();
// AsNoTracking so callers can re-map and UpdateAsync without tripping
// EF's "another instance with the same key is already tracked" guard.
var entity = await _db.PlacedBets.AsNoTracking()
.FirstOrDefaultAsync(b => b.Id == idStr, ct);
return entity is null ? null : Mapping.ToDomain(entity);
}
public async Task<IReadOnlyList<PlacedBet>> ListAsync(CancellationToken ct = default)
{
var entities = await _db.PlacedBets.AsNoTracking().ToListAsync(ct);
return entities.Select(Mapping.ToDomain).ToList().AsReadOnly();
}
public async Task<IReadOnlyList<PlacedBet>> ListByOutcomeAsync(BetOutcome outcome, CancellationToken ct = default)
{
var outcomeInt = (int)outcome;
var entities = await _db.PlacedBets.AsNoTracking()
.Where(b => b.Outcome == outcomeInt)
.ToListAsync(ct);
return entities.Select(Mapping.ToDomain).ToList().AsReadOnly();
}
public async Task<IReadOnlyList<PlacedBet>> ListByDateRangeAsync(DateRange range, CancellationToken ct = default)
{
// PlacedAt is stored via SqliteDateText (O-format TEXT) — same lexical-equals-
// chronological ordering used across the repositories.
var fromStr = SqliteDateText.Key(range.From);
var toStr = SqliteDateText.Key(range.To);
var entities = await _db.PlacedBets.AsNoTracking()
.Where(b => b.PlacedAt.CompareTo(fromStr) >= 0
&& b.PlacedAt.CompareTo(toStr) <= 0)
.ToListAsync(ct);
return entities.Select(Mapping.ToDomain).ToList().AsReadOnly();
}
public async Task<IReadOnlyList<PlacedBet>> ListByEventAsync(EventId eventId, CancellationToken ct = default)
{
var entities = await _db.PlacedBets.AsNoTracking()
.Where(b => b.EventCode == eventId.Value)
.ToListAsync(ct);
return entities.Select(Mapping.ToDomain).ToList().AsReadOnly();
}
public async Task AddAsync(PlacedBet entity, CancellationToken ct = default)
{
var efEntity = Mapping.ToEntity(entity);
await _db.PlacedBets.AddAsync(efEntity, ct);
}
public Task UpdateAsync(PlacedBet entity, CancellationToken ct = default)
{
var efEntity = Mapping.ToEntity(entity);
_db.PlacedBets.Update(efEntity);
return Task.CompletedTask;
}
public async Task DeleteAsync(Guid key, CancellationToken ct = default)
{
var idStr = key.ToString();
var entity = await _db.PlacedBets.FirstOrDefaultAsync(b => b.Id == idStr, ct);
if (entity is not null)
_db.PlacedBets.Remove(entity);
}
public async Task SaveChangesAsync(CancellationToken ct = default) =>
await _db.SaveChangesAsync(ct);
}
@@ -23,6 +23,31 @@ internal sealed class ResultRepository : IResultRepository
return entities.Select(Mapping.ToDomain).ToList().AsReadOnly();
}
public async Task<IReadOnlyDictionary<EventId, EventResult>> GetManyAsync(
IReadOnlyCollection<EventId> ids,
CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(ids);
var result = new Dictionary<EventId, EventResult>(ids.Count);
if (ids.Count == 0)
return result;
var codes = ids.Select(e => e.Value).Distinct().ToArray();
var entities = await _db.EventResults.AsNoTracking()
.Where(r => codes.Contains(r.EventCode))
.ToListAsync(ct);
foreach (var entity in entities)
{
var domain = Mapping.ToDomain(entity);
result[domain.EventId] = domain;
}
return result;
}
public async Task AddAsync(EventResult entity, CancellationToken ct = default)
{
var efEntity = Mapping.ToEntity(entity);
@@ -0,0 +1,62 @@
using Marathon.Application.Abstractions;
using Marathon.Domain.Backtesting;
using Microsoft.EntityFrameworkCore;
namespace Marathon.Infrastructure.Persistence.Repositories;
internal sealed class SavedStrategyRepository : ISavedStrategyRepository
{
private readonly MarathonDbContext _db;
public SavedStrategyRepository(MarathonDbContext db) => _db = db;
public async Task<SavedStrategy?> GetAsync(Guid key, CancellationToken ct = default)
{
var idStr = key.ToString();
// AsNoTracking so callers can re-map and UpdateAsync without tripping
// EF's "another instance with the same key is already tracked" guard.
var entity = await _db.SavedStrategies.AsNoTracking()
.FirstOrDefaultAsync(s => s.Id == idStr, ct);
return entity is null ? null : Mapping.ToDomain(entity);
}
public async Task<SavedStrategy?> GetByNameAsync(string name, CancellationToken ct = default)
{
var trimmed = (name ?? string.Empty).Trim();
var entity = await _db.SavedStrategies.AsNoTracking()
.FirstOrDefaultAsync(s => s.Name == trimmed, ct);
return entity is null ? null : Mapping.ToDomain(entity);
}
public async Task<IReadOnlyList<SavedStrategy>> ListAsync(CancellationToken ct = default)
{
var entities = await _db.SavedStrategies.AsNoTracking()
.OrderBy(s => s.Name)
.ToListAsync(ct);
return entities.Select(Mapping.ToDomain).ToList().AsReadOnly();
}
public async Task AddAsync(SavedStrategy entity, CancellationToken ct = default)
{
var efEntity = Mapping.ToEntity(entity);
await _db.SavedStrategies.AddAsync(efEntity, ct);
}
public Task UpdateAsync(SavedStrategy entity, CancellationToken ct = default)
{
var efEntity = Mapping.ToEntity(entity);
_db.SavedStrategies.Update(efEntity);
return Task.CompletedTask;
}
public async Task DeleteAsync(Guid key, CancellationToken ct = default)
{
var idStr = key.ToString();
var entity = await _db.SavedStrategies.FirstOrDefaultAsync(s => s.Id == idStr, ct);
if (entity is not null)
_db.SavedStrategies.Remove(entity);
}
public async Task SaveChangesAsync(CancellationToken ct = default) =>
await _db.SaveChangesAsync(ct);
}
@@ -19,14 +19,34 @@ internal sealed class SnapshotRepository : ISnapshotRepository
return entities.Select(Mapping.ToDomain).ToList().AsReadOnly();
}
public Task<int> CountSinceAsync(DateTimeOffset since, CancellationToken ct = default)
{
var sinceStr = SqliteDateText.Key(since);
return _db.Snapshots.AsNoTracking()
.Where(s => s.CapturedAt.CompareTo(sinceStr) >= 0)
.CountAsync(ct);
}
public async Task<DateTimeOffset?> GetLatestCapturedAtAsync(CancellationToken ct = default)
{
// O-format TEXT sorts lexically == chronologically (see SqliteDateText), so the
// max CapturedAt is the most recent capture. ORDER BY + LIMIT 1 pushed to SQLite.
var latest = await _db.Snapshots.AsNoTracking()
.OrderByDescending(s => s.CapturedAt)
.Select(s => s.CapturedAt)
.FirstOrDefaultAsync(ct);
return latest is null ? null : SqliteDateText.Parse(latest);
}
public async Task<IReadOnlyList<OddsSnapshot>> ListByEventAsync(
EventId eventId,
DateTimeOffset from,
DateTimeOffset to,
CancellationToken ct = default)
{
var fromStr = from.ToString("O");
var toStr = to.ToString("O");
var fromStr = SqliteDateText.Key(from);
var toStr = SqliteDateText.Key(to);
var entities = await _db.Snapshots.AsNoTracking()
.Include(s => s.Bets)
@@ -51,8 +71,8 @@ internal sealed class SnapshotRepository : ISnapshotRepository
return result;
var ids = eventIds.Select(e => e.Value).Distinct().ToArray();
var fromStr = from.ToString("O");
var toStr = to.ToString("O");
var fromStr = SqliteDateText.Key(from);
var toStr = SqliteDateText.Key(to);
var entities = await _db.Snapshots.AsNoTracking()
.Include(s => s.Bets)
@@ -83,4 +103,26 @@ internal sealed class SnapshotRepository : ISnapshotRepository
public async Task SaveChangesAsync(CancellationToken ct = default) =>
await _db.SaveChangesAsync(ct);
public async Task<OddsSnapshot?> GetLatestPreMatchAsync(
EventId eventId,
DateTimeOffset atOrBefore,
CancellationToken ct = default)
{
// OddsSource enum: PreMatch == 0. Inlined as an int constant to keep the
// expression EF-translatable (the IL would otherwise carry a cast).
const int preMatchSource = (int)Marathon.Domain.Enums.OddsSource.PreMatch;
var toStr = SqliteDateText.Key(atOrBefore);
var entity = await _db.Snapshots.AsNoTracking()
.Include(s => s.Bets)
.Where(s => s.EventCode == eventId.Value
&& s.Source == preMatchSource
&& s.CapturedAt.CompareTo(toStr) <= 0)
.OrderByDescending(s => s.CapturedAt)
.FirstOrDefaultAsync(ct);
return entity is null ? null : Mapping.ToDomain(entity);
}
}
@@ -0,0 +1,42 @@
using System.Globalization;
namespace Marathon.Infrastructure.Persistence;
/// <summary>
/// Single source of truth for how <see cref="DateTimeOffset"/> values are encoded
/// as the TEXT used by both the write path (<see cref="Mapping"/>) and the
/// date-range predicates / ORDER BY clauses in the repositories.
/// </summary>
/// <remarks>
/// <para>
/// Dates are stored as round-trip ISO-8601 (<c>"O"</c> format) TEXT. SQLite TEXT
/// columns use BINARY (ordinal) collation by default, so the relational operators
/// (<c>&gt;=</c>, <c>&lt;=</c>) and <c>ORDER BY</c> on these strings sort
/// <b>chronologically</b> — but ONLY because every persisted timestamp carries the
/// same Moscow <c>+03:00</c> offset (see the project invariant in CLAUDE.md). Two
/// instants written with different offsets would sort lexically, not
/// chronologically, and silently corrupt range filtering.
/// </para>
/// <para>
/// Centralising the format here means the write encoding and the query-bound
/// encoding can never drift apart, and the offset invariant is documented in one
/// authoritative place. If a future change normalises storage to UTC or a native
/// DATETIME column, this is the only call site that must change.
/// </para>
/// </remarks>
internal static class SqliteDateText
{
// Parse with the invariant culture + RoundtripKind so a non-en-US thread
// culture (or a future locale change) cannot corrupt the round-trip.
private const DateTimeStyles RoundtripStyles = DateTimeStyles.RoundtripKind;
/// <summary>
/// Encodes a <see cref="DateTimeOffset"/> as the TEXT key used for storage and
/// for the bounds of range/ordering predicates.
/// </summary>
public static string Key(DateTimeOffset value) => value.ToString("O");
/// <summary>Decodes a stored TEXT key back into a <see cref="DateTimeOffset"/>.</summary>
public static DateTimeOffset Parse(string text) =>
DateTimeOffset.Parse(text, CultureInfo.InvariantCulture, RoundtripStyles);
}
@@ -6,9 +6,8 @@ using Marathon.Domain.Entities;
using Marathon.Domain.Enums;
using Marathon.Domain.ValueObjects;
using Microsoft.Extensions.Logging;
using DomainEventId = Marathon.Domain.ValueObjects.EventId;
using AngleSharpConfig = AngleSharp.Configuration;
using DomainEventId = Marathon.Domain.ValueObjects.EventId;
namespace Marathon.Infrastructure.Scraping.Parsers;
@@ -110,7 +109,7 @@ public sealed partial class EventOddsParser : IEventOddsParser
// no longer rescans the document with QuerySelector
// for every key — that was an O(N) cost paid 6× per
// period).
var priceIndex = BuildSelectionPriceIndex(selections);
var priceIndex = BuildSelectionPriceIndex(selections);
var elementIndex = BuildSelectionElementIndex(selections);
var bets = new List<Bet>();
@@ -187,19 +186,19 @@ public sealed partial class EventOddsParser : IEventOddsParser
// Try each market variant; first match wins
foreach (var market in MatchResultMarkets)
{
var win1Key = $"{eventId}@{market}.1";
var drawKey = $"{eventId}@{market}.draw";
var win2Key = $"{eventId}@{market}.3";
var win1Key = $"{eventId}@{market}.1";
var drawKey = $"{eventId}@{market}.draw";
var win2Key = $"{eventId}@{market}.3";
// Basketball 2-way OT market uses HB_H / HB_A
var hbhKey = $"{eventId}@{market}.HB_H";
var hbaKey = $"{eventId}@{market}.HB_A";
var hbhKey = $"{eventId}@{market}.HB_H";
var hbaKey = $"{eventId}@{market}.HB_A";
var hasWin1 = idx.TryGetValue(win1Key, out var rate1);
var hasDraw = idx.TryGetValue(drawKey, out var rateDraw);
var hasWin2 = idx.TryGetValue(win2Key, out var rate2);
var hasHbh = idx.TryGetValue(hbhKey, out var rateHbh);
var hasHba = idx.TryGetValue(hbaKey, out var rateHba);
var hasWin1 = idx.TryGetValue(win1Key, out var rate1);
var hasDraw = idx.TryGetValue(drawKey, out var rateDraw);
var hasWin2 = idx.TryGetValue(win2Key, out var rate2);
var hasHbh = idx.TryGetValue(hbhKey, out var rateHbh);
var hasHba = idx.TryGetValue(hbaKey, out var rateHba);
if (hasWin1 || hasDraw || hasWin2 || hasHbh || hasHba)
{
@@ -517,8 +516,11 @@ public sealed partial class EventOddsParser : IEventOddsParser
value.HasValue ? new OddsValue(value.Value) : null,
new OddsRate(rate)));
}
catch (Exception ex)
catch (ArgumentException ex)
{
// OddsValue / OddsRate / Bet guard clauses throw ArgumentException and its
// derivatives (ArgumentNullException, ArgumentOutOfRangeException). Catch
// only those — anything else is a real bug that must not be swallowed here.
_logger.LogDebug(ex,
"Skipping bet ({Type}, {Side}, value={Value}, rate={Rate}) — invariant violation.",
type, side, value, rate);
@@ -0,0 +1,106 @@
using Marathon.Application.Abstractions;
using Marathon.Application.UseCases;
using Marathon.Domain.ValueObjects;
using Marathon.Infrastructure.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace Marathon.Infrastructure.Workers;
/// <summary>
/// Polls for newly detected anomalies above a configured score and pushes each to the
/// registered <see cref="INotificationSink"/>. Idle (cheap re-check) while
/// <see cref="NotificationOptions.Enabled"/> is false.
/// </summary>
/// <remarks>
/// The "since" marker is baselined to startup time so pre-existing anomalies are not
/// re-announced on every restart, and is advanced past the newest dispatched item
/// (plus one tick) each cycle — gap-free and duplicate-free. The scoped use case is
/// resolved per cycle (EF Core DbContext lifetime); the sink is a singleton.
/// </remarks>
internal sealed class AnomalyNotificationDispatcher : BackgroundService
{
private readonly IServiceProvider _services;
private readonly INotificationSink _sink;
private readonly IOptionsMonitor<NotificationOptions> _opts;
private readonly ILogger<AnomalyNotificationDispatcher> _logger;
private DateTimeOffset _since;
public AnomalyNotificationDispatcher(
IServiceProvider services,
INotificationSink sink,
IOptionsMonitor<NotificationOptions> opts,
ILogger<AnomalyNotificationDispatcher> logger)
{
_services = services ?? throw new ArgumentNullException(nameof(services));
_sink = sink ?? throw new ArgumentNullException(nameof(sink));
_opts = opts ?? throw new ArgumentNullException(nameof(opts));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
// Baseline: only alert on anomalies detected after this service started.
_since = MoscowTime.Now;
_logger.LogInformation("AnomalyNotificationDispatcher: started");
while (!stoppingToken.IsCancellationRequested)
{
var opts = _opts.CurrentValue;
if (!opts.Enabled)
{
await Task.Delay(TimeSpan.FromSeconds(10), stoppingToken);
continue;
}
try
{
await using var scope = _services.CreateAsyncScope();
var useCase = scope.ServiceProvider.GetRequiredService<GetPendingAnomalyNotificationsUseCase>();
var pending = await useCase.ExecuteAsync(_since, opts.MinScore, stoppingToken);
var dispatched = 0;
foreach (var notification in pending)
{
stoppingToken.ThrowIfCancellationRequested();
await _sink.SendAsync(notification, stoppingToken);
// Advance the marker per delivered item (pending is oldest-first) so that
// if a future sink ever threw mid-batch, the already-sent alerts are not
// re-delivered on the next cycle — only the unsent tail is retried.
_since = notification.DetectedAt.AddTicks(1);
dispatched++;
}
if (dispatched > 0)
{
_logger.LogInformation(
"AnomalyNotificationDispatcher: dispatched {Count} alert(s)", dispatched);
}
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
catch (Exception ex)
{
_logger.LogError(ex,
"AnomalyNotificationDispatcher: cycle failed — will retry after interval");
}
var interval = TimeSpan.FromSeconds(Math.Max(5, opts.PollIntervalSeconds));
try
{
await Task.Delay(interval, stoppingToken);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
}
_logger.LogInformation("AnomalyNotificationDispatcher: stopping");
}
}
@@ -28,8 +28,8 @@ internal sealed class LiveOddsPoller : BackgroundService
ILogger<LiveOddsPoller> logger)
{
_services = services ?? throw new ArgumentNullException(nameof(services));
_opts = opts ?? throw new ArgumentNullException(nameof(opts));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_opts = opts ?? throw new ArgumentNullException(nameof(opts));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
@@ -47,6 +47,8 @@ internal sealed class LiveOddsPoller : BackgroundService
continue;
}
var cycleStart = DateTime.UtcNow;
try
{
await using var scope = _services.CreateAsyncScope();
@@ -69,9 +71,17 @@ internal sealed class LiveOddsPoller : BackgroundService
var interval = TimeSpan.FromSeconds(
Math.Max(1, _opts.CurrentValue.LivePollIntervalSeconds));
// Budget the sleep against the time the cycle already consumed so the
// effective cadence tracks the configured interval instead of
// (interval + scrapeDuration). If a cycle overran the interval, loop
// immediately rather than sleeping a full extra interval.
var remaining = interval - (DateTime.UtcNow - cycleStart);
if (remaining <= TimeSpan.Zero)
continue;
try
{
await Task.Delay(interval, stoppingToken);
await Task.Delay(remaining, stoppingToken);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
@@ -0,0 +1,105 @@
using Marathon.Application.UseCases;
using Marathon.Domain.ValueObjects;
using Marathon.Infrastructure.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace Marathon.Infrastructure.Workers;
/// <summary>
/// Forward-test engine: each cycle opens flat-stake paper bets for newly detected
/// directional anomalies, then settles any open bets whose events have been graded.
/// Idle (cheap re-check) while <see cref="PaperTradingOptions.Enabled"/> is false.
/// </summary>
/// <remarks>
/// The "since" marker is baselined to startup so pre-existing anomalies are not
/// retro-traded, and advances to each cycle's upper bound only after the open pass
/// succeeds. A unique index on <c>PaperBets.AnomalyId</c> backstops any double-open.
/// Scoped use cases are resolved per cycle (EF Core DbContext lifetime).
/// </remarks>
internal sealed class PaperTradingWorker : BackgroundService
{
private readonly IServiceProvider _services;
private readonly IOptionsMonitor<PaperTradingOptions> _opts;
private readonly ILogger<PaperTradingWorker> _logger;
private DateTimeOffset _since;
public PaperTradingWorker(
IServiceProvider services,
IOptionsMonitor<PaperTradingOptions> opts,
ILogger<PaperTradingWorker> logger)
{
_services = services ?? throw new ArgumentNullException(nameof(services));
_opts = opts ?? throw new ArgumentNullException(nameof(opts));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
// Baseline: only forward-test anomalies detected after this worker started.
_since = MoscowTime.Now;
_logger.LogInformation("PaperTradingWorker: started");
while (!stoppingToken.IsCancellationRequested)
{
var opts = _opts.CurrentValue;
if (!opts.Enabled)
{
await DelayQuietly(TimeSpan.FromSeconds(10), stoppingToken);
continue;
}
try
{
var until = MoscowTime.Now;
await using var scope = _services.CreateAsyncScope();
var open = scope.ServiceProvider.GetRequiredService<OpenPaperBetsUseCase>();
await open.ExecuteAsync(_since, until, opts.MinScore, opts.FlatStake, stoppingToken);
// Advance only after a successful open pass, so an open failure replays the window.
_since = until;
// Settle in its own catch: it rescans every Pending bet each cycle (idempotent),
// so a transient settle failure must NOT strand the marker — otherwise the window
// just opened above would be lost to a settle-only error. Shutdown cancellation is
// excluded so it propagates to the outer break.
try
{
var settle = scope.ServiceProvider.GetRequiredService<SettlePaperBetsUseCase>();
await settle.ExecuteAsync(stoppingToken);
}
catch (Exception ex) when (!stoppingToken.IsCancellationRequested)
{
_logger.LogError(ex, "PaperTradingWorker: settle failed — open bets retried next cycle");
}
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
catch (Exception ex)
{
_logger.LogError(ex, "PaperTradingWorker: open cycle failed — will retry after interval");
}
await DelayQuietly(TimeSpan.FromSeconds(Math.Max(5, opts.PollIntervalSeconds)), stoppingToken);
}
_logger.LogInformation("PaperTradingWorker: stopping");
}
private static async Task DelayQuietly(TimeSpan delay, CancellationToken ct)
{
try
{
await Task.Delay(delay, ct);
}
catch (OperationCanceledException)
{
// Shutting down — swallow so ExecuteAsync's loop check exits cleanly.
}
}
}
+5 -2
View File
@@ -211,8 +211,11 @@
private string KindLabel(AnomalyKind kind) => kind switch
{
AnomalyKind.SuspensionFlip => L["Anomaly.Kind.SuspensionFlip"],
_ => kind.ToString(),
AnomalyKind.SuspensionFlip => L["Anomaly.Kind.SuspensionFlip"],
AnomalyKind.SteamMove => L["Anomaly.Kind.SteamMove"],
AnomalyKind.SuspensionFreeze => L["Anomaly.Kind.SuspensionFreeze"],
AnomalyKind.OverroundCompression => L["Anomaly.Kind.OverroundCompression"],
_ => kind.ToString(),
};
private string SportLabel(int code) => SportLabels.Resolve(L, code);
+28
View File
@@ -39,8 +39,36 @@
<MudIcon Icon="@Icons.Material.Outlined.Done" Size="Size.Small" />
<span>@L["Nav.Results"]</span>
</NavLink>
<NavLink class="m-nav__link" href="anomalies/insights">
<MudIcon Icon="@Icons.Material.Outlined.Insights" Size="Size.Small" />
<span>@L["Nav.Insights"]</span>
</NavLink>
<NavLink class="m-nav__link" href="my-bets">
<MudIcon Icon="@Icons.Material.Outlined.Receipt" Size="Size.Small" />
<span>@L["Nav.MyBets"]</span>
</NavLink>
<NavLink class="m-nav__link" href="anomalies/backtest">
<MudIcon Icon="@Icons.Material.Outlined.QueryStats" Size="Size.Small" />
<span>@L["Nav.Backtest"]</span>
</NavLink>
<NavLink class="m-nav__link" href="anomalies/compare">
<MudIcon Icon="@Icons.Material.Outlined.Leaderboard" Size="Size.Small" />
<span>@L["Nav.Compare"]</span>
</NavLink>
<NavLink class="m-nav__link" href="paper-trading">
<MudIcon Icon="@Icons.Material.Outlined.Science" Size="Size.Small" />
<span>@L["Nav.PaperTrading"]</span>
</NavLink>
<div class="m-nav__group" style="margin-top: var(--m-space-5);">@L["Nav.Section.System"]</div>
<NavLink class="m-nav__link" href="export">
<MudIcon Icon="@Icons.Material.Outlined.FileDownload" Size="Size.Small" />
<span>@L["Nav.Export"]</span>
</NavLink>
<NavLink class="m-nav__link" href="health">
<MudIcon Icon="@Icons.Material.Outlined.MonitorHeart" Size="Size.Small" />
<span>@L["Nav.Health"]</span>
</NavLink>
<NavLink class="m-nav__link" href="settings">
<MudIcon Icon="@Icons.Material.Outlined.Tune" Size="Size.Small" />
<span>@L["Nav.Settings"]</span>
+17
View File
@@ -3,6 +3,7 @@
@inject ThemeState ThemeState
@inject LocaleState LocaleState
@inject IStringLocalizer<SharedResource> L
@inject IOptionsMonitor<WorkerOptions> Workers
<MudThemeProvider IsDarkMode="@ThemeState.IsDark" Theme="@_theme" />
<MudPopoverProvider />
@@ -24,6 +25,12 @@
<div class="m-appbar__spacer"></div>
<div class="m-appbar__tools m-rise m-rise-2">
<span class="m-capture-pill" data-test="capture-pill"
aria-label="@L["Scraping.Aria"]" title="@L["Scraping.Aria"]"
style="display:inline-flex; align-items:center; gap:7px; font-family:var(--m-font-mono); font-size:0.6875rem; text-transform:uppercase; letter-spacing:0.12em; color:@(Capturing ? "var(--m-c-positive)" : "var(--m-c-ink-soft)");">
<span style="width:8px; height:8px; border-radius:50%; background:@(Capturing ? "var(--m-c-positive)" : "var(--m-c-ink-soft)");"></span>
@(Capturing ? L["Scraping.On"] : L["Scraping.Off"])
</span>
<LocaleSwitcher />
<ThemeToggle />
</div>
@@ -123,11 +130,20 @@
@code {
private bool _drawerOpen = true;
private MudBlazor.MudTheme _theme = Theme.MarathonTheme.Build();
private IDisposable? _workerOptionsListener;
// "Capturing" when any of the primary pollers is enabled in config.
private bool Capturing =>
Workers.CurrentValue.LivePollerEnabled
|| Workers.CurrentValue.UpcomingPollerEnabled
|| Workers.CurrentValue.AnomalyDetectionEnabled;
protected override void OnInitialized()
{
ThemeState.OnChange += StateHasChanged;
LocaleState.OnChange += StateHasChanged;
// Reflect Settings toggles live without requiring a navigation.
_workerOptionsListener = Workers.OnChange(_ => InvokeAsync(StateHasChanged));
}
private void ToggleDrawer() => _drawerOpen = !_drawerOpen;
@@ -136,5 +152,6 @@
{
ThemeState.OnChange -= StateHasChanged;
LocaleState.OnChange -= StateHasChanged;
_workerOptionsListener?.Dispose();
}
}

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