Compare commits
35 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 690d98d194 | |||
| 42e62c1ed2 | |||
| 08486667c3 | |||
| 88615a95e9 | |||
| 1092e2a2c5 | |||
| 41148a87a6 | |||
| 36178e6d1b | |||
| 67f2ae130c | |||
| f512a08772 | |||
| 34cc72fd2d | |||
| 6e12dd73c3 | |||
| e60b5bf57e | |||
| 76306ef59b | |||
| 39aef449f7 | |||
| f622dadf95 | |||
| 2a0ea7b3a6 | |||
| 115872aad0 | |||
| 5eb3dec24b | |||
| b67030ae7f | |||
| c9eee9f907 | |||
| e307a54bec | |||
| 68f3229c35 | |||
| 005d4e794a | |||
| 2e53dff853 | |||
| e5cd2ab30c | |||
| d9d92ea8fd | |||
| 2b1025cae3 | |||
| 4dae9e8d0d | |||
| 0e3c4b8d47 | |||
| 250a93e718 | |||
| 0501f9c39c | |||
| f294255f10 | |||
| 0d52b7beff | |||
| 1ad896b07e | |||
| 292223174c |
@@ -84,3 +84,7 @@ spike/captures/
|
|||||||
|
|
||||||
# Claude Code per-session task metadata (local only)
|
# Claude Code per-session task metadata (local only)
|
||||||
.claude/
|
.claude/
|
||||||
|
|
||||||
|
# Throwaway debugging scratch (PowerShell dumps, raw page captures)
|
||||||
|
_dump*.ps1
|
||||||
|
_pages*.txt
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -121,6 +121,26 @@ Marathon_<YYYY-MM-DD>_to_<YYYY-MM-DD>.xlsx
|
|||||||
- **`Event.ScheduledAt` requires offset `+03:00`.** Test fixtures and any code
|
- **`Event.ScheduledAt` requires offset `+03:00`.** Test fixtures and any code
|
||||||
that constructs Moscow datetimes must use `new DateTimeOffset(date, TimeSpan.FromHours(3))`,
|
that constructs Moscow datetimes must use `new DateTimeOffset(date, TimeSpan.FromHours(3))`,
|
||||||
never pass a `DateTime.UtcNow` value to that constructor.
|
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
|
## 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
|
- **Signal-red is the load-bearing alert tone** for Phase 7. Use
|
||||||
`var(--m-c-anomaly)` exclusively (never raw `#dc2626`). Pulsing animation
|
`var(--m-c-anomaly)` exclusively (never raw `#dc2626`). Pulsing animation
|
||||||
(`m-pulse`) MUST respect `prefers-reduced-motion`.
|
(`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`.
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
/// <summary>
|
||||||
/// Repository for <see cref="Anomaly"/> domain entities.
|
/// Repository for <see cref="Anomaly"/> domain entities.
|
||||||
/// </summary>
|
/// </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);
|
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);
|
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>
|
/// <summary>
|
||||||
/// Distinct sport codes across the events table. Projects in the database
|
/// Distinct sport codes across the events table. Projects in the database
|
||||||
/// rather than materialising every <see cref="Event"/> on the client.
|
/// 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>
|
/// <summary>
|
||||||
/// Repository for <see cref="EventResult"/> domain entities.
|
/// Repository for <see cref="EventResult"/> domain entities.
|
||||||
/// </summary>
|
/// </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);
|
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(
|
Task<IReadOnlyList<OddsSnapshot>> ListByEventAsync(
|
||||||
EventId eventId,
|
EventId eventId,
|
||||||
DateTimeOffset from,
|
DateTimeOffset from,
|
||||||
@@ -36,4 +48,19 @@ public interface ISnapshotRepository
|
|||||||
Task AddAsync(OddsSnapshot entity, CancellationToken ct = default);
|
Task AddAsync(OddsSnapshot entity, CancellationToken ct = default);
|
||||||
|
|
||||||
Task SaveChangesAsync(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<PullLiveOddsUseCase>();
|
||||||
services.AddScoped<PullResultsUseCase>();
|
services.AddScoped<PullResultsUseCase>();
|
||||||
services.AddScoped<ExportToExcelUseCase>();
|
services.AddScoped<ExportToExcelUseCase>();
|
||||||
|
services.AddScoped<ExportToCsvUseCase>();
|
||||||
services.AddScoped<DetectAnomaliesUseCase>();
|
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;
|
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&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.
|
/// in seconds. Default: 60 s.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public int DetectionIntervalSeconds { get; init; } = 60;
|
public int DetectionIntervalSeconds { get; init; } = 60;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Trailing window, in seconds, over which the steam-move detector measures a
|
||||||
|
/// continuous one-directional probability drift. Default: 120 s.
|
||||||
|
/// </summary>
|
||||||
|
public int SteamMoveWindowSeconds { get; init; } = 120;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Minimum one-directional normalised implied-probability rise within the window
|
||||||
|
/// to flag a steam move. Must be in (0, 1). Default: 0.20 (20 percentage points).
|
||||||
|
/// </summary>
|
||||||
|
public decimal SteamMoveDriftThreshold { get; init; } = 0.20m;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Maximum normalised implied-probability change across a suspension for it to count
|
||||||
|
/// as a "freeze" (line resumed essentially unchanged). Must be in (0, 1).
|
||||||
|
/// Default: 0.05 (5 percentage points).
|
||||||
|
/// </summary>
|
||||||
|
public decimal SuspensionFreezeThreshold { get; init; } = 0.05m;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Trailing window, in seconds, over which the overround-compression detector
|
||||||
|
/// measures a continuous margin drop. Default: 120 s.
|
||||||
|
/// </summary>
|
||||||
|
public int OverroundWindowSeconds { get; init; } = 120;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Minimum drop in the bookmaker's overround (raw implied-probability sum) within the
|
||||||
|
/// window to flag a compression. Must be in (0, 1). Default: 0.02 (2 margin points).
|
||||||
|
/// </summary>
|
||||||
|
public decimal OverroundCompressionThreshold { get; init; } = 0.02m;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,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>Loads all tracked events.</item>
|
||||||
/// <item>For each event, fetches its last-24-hour live snapshots.</item>
|
/// <item>For each event, fetches its last-24-hour live snapshots.</item>
|
||||||
/// <item>Runs <see cref="AnomalyDetector"/> over the snapshot timeline.</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>
|
/// </list>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
@@ -30,10 +30,10 @@ public sealed class DetectAnomaliesUseCase
|
|||||||
// Dedup window: two anomalies for the same event within this window are considered duplicates.
|
// Dedup window: two anomalies for the same event within this window are considered duplicates.
|
||||||
private static readonly TimeSpan DedupWindow = TimeSpan.FromMinutes(1);
|
private static readonly TimeSpan DedupWindow = TimeSpan.FromMinutes(1);
|
||||||
|
|
||||||
private readonly IEventRepository _eventRepo;
|
private readonly IEventRepository _eventRepo;
|
||||||
private readonly ISnapshotRepository _snapshotRepo;
|
private readonly ISnapshotRepository _snapshotRepo;
|
||||||
private readonly IAnomalyRepository _anomalyRepo;
|
private readonly IAnomalyRepository _anomalyRepo;
|
||||||
private readonly AnomalyOptions _options;
|
private readonly AnomalyOptions _options;
|
||||||
private readonly ILogger<DetectAnomaliesUseCase> _logger;
|
private readonly ILogger<DetectAnomaliesUseCase> _logger;
|
||||||
|
|
||||||
public DetectAnomaliesUseCase(
|
public DetectAnomaliesUseCase(
|
||||||
@@ -43,11 +43,11 @@ public sealed class DetectAnomaliesUseCase
|
|||||||
IOptions<AnomalyOptions> options,
|
IOptions<AnomalyOptions> options,
|
||||||
ILogger<DetectAnomaliesUseCase> logger)
|
ILogger<DetectAnomaliesUseCase> logger)
|
||||||
{
|
{
|
||||||
_eventRepo = eventRepo ?? throw new ArgumentNullException(nameof(eventRepo));
|
_eventRepo = eventRepo ?? throw new ArgumentNullException(nameof(eventRepo));
|
||||||
_snapshotRepo = snapshotRepo ?? throw new ArgumentNullException(nameof(snapshotRepo));
|
_snapshotRepo = snapshotRepo ?? throw new ArgumentNullException(nameof(snapshotRepo));
|
||||||
_anomalyRepo = anomalyRepo ?? throw new ArgumentNullException(nameof(anomalyRepo));
|
_anomalyRepo = anomalyRepo ?? throw new ArgumentNullException(nameof(anomalyRepo));
|
||||||
_options = (options ?? throw new ArgumentNullException(nameof(options))).Value;
|
_options = (options ?? throw new ArgumentNullException(nameof(options))).Value;
|
||||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -59,21 +59,41 @@ public sealed class DetectAnomaliesUseCase
|
|||||||
{
|
{
|
||||||
_logger.LogInformation("DetectAnomaliesUseCase: cycle started");
|
_logger.LogInformation("DetectAnomaliesUseCase: cycle started");
|
||||||
|
|
||||||
var detector = new AnomalyDetector(
|
var detectors = new IAnomalyDetector[]
|
||||||
_options.SuspensionGapSeconds,
|
{
|
||||||
_options.OddsFlipThreshold,
|
new AnomalyDetector(
|
||||||
_options.MinSnapshotCount);
|
_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);
|
var events = await _eventRepo.ListAsync(ct);
|
||||||
int newAnomalyCount = 0;
|
int newAnomalyCount = 0;
|
||||||
|
|
||||||
var now = MoscowTime.Now;
|
var now = MoscowTime.Now;
|
||||||
var from = now - SnapshotLookback;
|
var from = now - SnapshotLookback;
|
||||||
|
|
||||||
// Hoisted outside the per-event loop: load existing anomalies ONCE per cycle
|
// 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
|
// and index them by event so dedup is O(1) per event instead of scanning the
|
||||||
// (O(N_events) round-trips). Reviewer W1, Phase 7.
|
// whole list each time (was O(events × anomalies)). Reviewer W1, Phase 7.
|
||||||
var existingAnomalies = await _anomalyRepo.ListAsync(ct);
|
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
|
// Single batched query for all events' snapshots — replaces the prior
|
||||||
// per-event ListByEventAsync round-trip (O(N) SQLite hits + N Include(Bets)
|
// 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)
|
var snapshots = snapshotsByEvent.TryGetValue(ev.Id, out var found)
|
||||||
? found
|
? found
|
||||||
: Array.Empty<OddsSnapshot>();
|
: 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)
|
catch (OperationCanceledException)
|
||||||
{
|
{
|
||||||
@@ -114,22 +137,21 @@ public sealed class DetectAnomaliesUseCase
|
|||||||
// ── Private helpers ───────────────────────────────────────────────────────
|
// ── Private helpers ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
private async Task<int> ProcessEventAsync(
|
private async Task<int> ProcessEventAsync(
|
||||||
AnomalyDetector detector,
|
IReadOnlyList<IAnomalyDetector> detectors,
|
||||||
Event ev,
|
Event ev,
|
||||||
IReadOnlyList<OddsSnapshot> snapshots,
|
IReadOnlyList<OddsSnapshot> snapshots,
|
||||||
IReadOnlyList<Anomaly> existingAnomalies,
|
List<Anomaly> existingForEvent,
|
||||||
CancellationToken ct)
|
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)
|
if (detected.Count == 0)
|
||||||
return 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;
|
int persisted = 0;
|
||||||
foreach (var anomaly in detected)
|
foreach (var anomaly in detected)
|
||||||
{
|
{
|
||||||
@@ -137,11 +159,15 @@ public sealed class DetectAnomaliesUseCase
|
|||||||
continue;
|
continue;
|
||||||
|
|
||||||
await _anomalyRepo.AddAsync(anomaly, ct);
|
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.
|
existingForEvent.Add(anomaly); // Keep local list in sync so the same cycle doesn't re-add.
|
||||||
persisted++;
|
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;
|
return persisted;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,7 +177,7 @@ public sealed class DetectAnomaliesUseCase
|
|||||||
// and their DetectedAt timestamps fall within the dedup window.
|
// and their DetectedAt timestamps fall within the dedup window.
|
||||||
return existing.Any(a =>
|
return existing.Any(a =>
|
||||||
a.EventId == candidate.EventId &&
|
a.EventId == candidate.EventId &&
|
||||||
a.Kind == candidate.Kind &&
|
a.Kind == candidate.Kind &&
|
||||||
Math.Abs((a.DetectedAt - candidate.DetectedAt).TotalMinutes) <=
|
Math.Abs((a.DetectedAt - candidate.DetectedAt).TotalMinutes) <=
|
||||||
DedupWindow.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,
|
IResultRepository resultRepo,
|
||||||
ILogger<PullResultsUseCase> logger)
|
ILogger<PullResultsUseCase> logger)
|
||||||
{
|
{
|
||||||
_scraper = scraper ?? throw new ArgumentNullException(nameof(scraper));
|
_scraper = scraper ?? throw new ArgumentNullException(nameof(scraper));
|
||||||
_eventRepo = eventRepo ?? throw new ArgumentNullException(nameof(eventRepo));
|
_eventRepo = eventRepo ?? throw new ArgumentNullException(nameof(eventRepo));
|
||||||
_resultRepo = resultRepo ?? throw new ArgumentNullException(nameof(resultRepo));
|
_resultRepo = resultRepo ?? throw new ArgumentNullException(nameof(resultRepo));
|
||||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -149,12 +149,13 @@ public sealed class PullResultsUseCase
|
|||||||
{
|
{
|
||||||
if (selection is { Count: > 0 })
|
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);
|
var resolved = new List<Event>(selection.Count);
|
||||||
foreach (var id in selection)
|
foreach (var id in selection)
|
||||||
{
|
{
|
||||||
ct.ThrowIfCancellationRequested();
|
if (events.TryGetValue(id, out var ev))
|
||||||
var ev = await _eventRepo.GetAsync(id, ct).ConfigureAwait(false);
|
|
||||||
if (ev is not null)
|
|
||||||
resolved.Add(ev);
|
resolved.Add(ev);
|
||||||
}
|
}
|
||||||
return resolved;
|
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.Entities;
|
||||||
using Marathon.Domain.Enums;
|
using Marathon.Domain.Enums;
|
||||||
using Marathon.Domain.ValueObjects;
|
using Marathon.Domain.ValueObjects;
|
||||||
@@ -23,20 +21,15 @@ namespace Marathon.Domain.AnomalyDetection;
|
|||||||
/// </list>
|
/// </list>
|
||||||
///
|
///
|
||||||
/// This class is stateless and deterministic — identical inputs always produce identical output.
|
/// 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>
|
/// </summary>
|
||||||
public sealed class AnomalyDetector
|
public sealed class AnomalyDetector : IAnomalyDetector
|
||||||
{
|
{
|
||||||
private readonly int _suspensionGapSeconds;
|
private readonly int _suspensionGapSeconds;
|
||||||
private readonly decimal _oddsFlipThreshold;
|
private readonly decimal _oddsFlipThreshold;
|
||||||
private readonly int _minSnapshotCount;
|
private readonly int _minSnapshotCount;
|
||||||
|
|
||||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
|
||||||
{
|
|
||||||
WriteIndented = false,
|
|
||||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
|
||||||
};
|
|
||||||
|
|
||||||
/// <param name="suspensionGapSeconds">
|
/// <param name="suspensionGapSeconds">
|
||||||
/// Minimum gap between adjacent live snapshots (in seconds) to classify as a suspension.
|
/// Minimum gap between adjacent live snapshots (in seconds) to classify as a suspension.
|
||||||
/// Default per spec: 60.
|
/// Default per spec: 60.
|
||||||
@@ -68,16 +61,7 @@ public sealed class AnomalyDetector
|
|||||||
_minSnapshotCount = minSnapshotCount;
|
_minSnapshotCount = minSnapshotCount;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <inheritdoc />
|
||||||
/// Analyses <paramref name="snapshots"/> for the given <paramref name="eventId"/> and
|
|
||||||
/// returns 0 or more anomalies detected in this timeline.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="eventId">The event being analysed.</param>
|
|
||||||
/// <param name="snapshots">All snapshots for this event (any source, any order).</param>
|
|
||||||
/// <returns>
|
|
||||||
/// An <see cref="IReadOnlyList{T}"/> of <see cref="Anomaly"/> records, one per qualifying
|
|
||||||
/// suspension interval. May be empty.
|
|
||||||
/// </returns>
|
|
||||||
public IReadOnlyList<Anomaly> Detect(EventId eventId, IReadOnlyList<OddsSnapshot> snapshots)
|
public IReadOnlyList<Anomaly> Detect(EventId eventId, IReadOnlyList<OddsSnapshot> snapshots)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(eventId);
|
ArgumentNullException.ThrowIfNull(eventId);
|
||||||
@@ -119,9 +103,9 @@ public sealed class AnomalyDetector
|
|||||||
|
|
||||||
private Anomaly? TryDetectFlip(EventId eventId, SuspensionInterval interval)
|
private Anomaly? TryDetectFlip(EventId eventId, SuspensionInterval interval)
|
||||||
{
|
{
|
||||||
// Extract Match-Win bets from each snapshot.
|
// Extract Match-Win implied probabilities from each snapshot.
|
||||||
var preProbs = ExtractMatchWinProbabilities(interval.PreSuspension);
|
var preProbs = MatchWinEvidence.Extract(interval.PreSuspension);
|
||||||
var postProbs = ExtractMatchWinProbabilities(interval.PostSuspension);
|
var postProbs = MatchWinEvidence.Extract(interval.PostSuspension);
|
||||||
|
|
||||||
// Cannot compute flip if either snapshot lacks Win bets.
|
// Cannot compute flip if either snapshot lacks Win bets.
|
||||||
if (preProbs is null || postProbs is null)
|
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.
|
// Step 4 — compute flip score = max(|p_post[i] − p_pre[i]|) across common sides.
|
||||||
decimal flipScore = 0m;
|
decimal flipScore = 0m;
|
||||||
flipScore = Math.Max(flipScore,
|
flipScore = Math.Max(flipScore, Math.Abs(postProbs.P1 - preProbs.P1));
|
||||||
Math.Abs(postProbs.P1 - preProbs.P1));
|
flipScore = Math.Max(flipScore, Math.Abs(postProbs.P2 - preProbs.P2));
|
||||||
flipScore = Math.Max(flipScore,
|
|
||||||
Math.Abs(postProbs.P2 - preProbs.P2));
|
|
||||||
if (preProbs.PDraw.HasValue && postProbs.PDraw.HasValue)
|
if (preProbs.PDraw.HasValue && postProbs.PDraw.HasValue)
|
||||||
{
|
{
|
||||||
flipScore = Math.Max(flipScore,
|
flipScore = Math.Max(flipScore,
|
||||||
@@ -140,7 +122,8 @@ public sealed class AnomalyDetector
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Step 5 — favourite-changed test: argmax of implied probability must differ.
|
// 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)
|
if (flipScore < _oddsFlipThreshold || !favouriteChanged)
|
||||||
return null;
|
return null;
|
||||||
@@ -148,8 +131,11 @@ public sealed class AnomalyDetector
|
|||||||
// Clamp score to [0, 1] before constructing the Anomaly (domain invariant).
|
// Clamp score to [0, 1] before constructing the Anomaly (domain invariant).
|
||||||
var clampedScore = Math.Min(1m, flipScore);
|
var clampedScore = Math.Min(1m, flipScore);
|
||||||
|
|
||||||
// Step 6 — build evidence JSON.
|
// Step 6 — build evidence JSON via the shared formatter.
|
||||||
var evidenceJson = BuildEvidenceJson(interval, preProbs, postProbs);
|
var evidenceJson = MatchWinEvidence.BuildJson(
|
||||||
|
(int)interval.Gap.TotalSeconds,
|
||||||
|
interval.PreSuspension, preProbs,
|
||||||
|
interval.PostSuspension, postProbs);
|
||||||
|
|
||||||
return new Anomaly(
|
return new Anomaly(
|
||||||
Id: Guid.NewGuid(),
|
Id: Guid.NewGuid(),
|
||||||
@@ -159,100 +145,4 @@ public sealed class AnomalyDetector
|
|||||||
Score: clampedScore,
|
Score: clampedScore,
|
||||||
EvidenceJson: evidenceJson);
|
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, >= 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, >= 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 < 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 < 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -63,4 +63,11 @@ public sealed record Event(
|
|||||||
/// numeric event ID.
|
/// numeric event ID.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public string? EventPath { get; init; }
|
public string? EventPath { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Display title in the canonical "{Side1Name} vs {Side2Name}" form. Single
|
||||||
|
/// source for the home-vs-away join that was previously duplicated across the
|
||||||
|
/// report use cases and list/feed services.
|
||||||
|
/// </summary>
|
||||||
|
public string Title => $"{Side1Name} vs {Side2Name}";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -10,4 +10,22 @@ public enum AnomalyKind
|
|||||||
/// Bookmaker suspended the market, then flipped the underdog/favourite coefficients.
|
/// Bookmaker suspended the market, then flipped the underdog/favourite coefficients.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
SuspensionFlip,
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -41,7 +41,23 @@
|
|||||||
"SuspensionGapSeconds": 60,
|
"SuspensionGapSeconds": 60,
|
||||||
"OddsFlipThreshold": 0.30,
|
"OddsFlipThreshold": 0.30,
|
||||||
"MinSnapshotCount": 3,
|
"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": {
|
"Localization": {
|
||||||
"DefaultCulture": "ru-RU"
|
"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,
|
string outputPath,
|
||||||
CancellationToken ct = default)
|
CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
// Load all snapshots in the date range with their bets eagerly
|
// Load all snapshots in the date range with their bets eagerly. Bounds use the
|
||||||
var fromStr = range.From.ToString("O");
|
// shared SqliteDateText encoding so they match the persisted CapturedAt keys.
|
||||||
var toStr = range.To.ToString("O");
|
var fromStr = SqliteDateText.Key(range.From);
|
||||||
|
var toStr = SqliteDateText.Key(range.To);
|
||||||
|
|
||||||
var snapshotEntities = await _db.Snapshots.AsNoTracking()
|
var snapshotEntities = await _db.Snapshots.AsNoTracking()
|
||||||
.Include(s => s.Bets)
|
.Include(s => s.Bets)
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
|
using Marathon.Application.Abstractions;
|
||||||
using Marathon.Application.Configuration;
|
using Marathon.Application.Configuration;
|
||||||
using Marathon.Infrastructure.Configuration;
|
using Marathon.Infrastructure.Configuration;
|
||||||
|
using Marathon.Infrastructure.Notifications;
|
||||||
using Marathon.Infrastructure.Persistence;
|
using Marathon.Infrastructure.Persistence;
|
||||||
using Marathon.Infrastructure.Scraping;
|
using Marathon.Infrastructure.Scraping;
|
||||||
using Marathon.Infrastructure.Workers;
|
using Marathon.Infrastructure.Workers;
|
||||||
@@ -50,11 +52,30 @@ public static class InfrastructureModule
|
|||||||
.AddOptions<ScrapingThrottle>()
|
.AddOptions<ScrapingThrottle>()
|
||||||
.Bind(config.GetSection(ScrapingThrottle.SectionName));
|
.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<UpcomingEventsPoller>();
|
||||||
services.AddHostedService<LiveOddsPoller>();
|
services.AddHostedService<LiveOddsPoller>();
|
||||||
services.AddHostedService<ResultsWatchListPoller>();
|
services.AddHostedService<ResultsWatchListPoller>();
|
||||||
services.AddHostedService<AnomalyDetectionPoller>();
|
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;
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
+391
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+439
@@ -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 Marathon.Infrastructure.Persistence;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
@@ -6,155 +7,430 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
|||||||
|
|
||||||
#nullable disable
|
#nullable disable
|
||||||
|
|
||||||
namespace Marathon.Infrastructure.Migrations;
|
namespace Marathon.Infrastructure.Migrations
|
||||||
|
|
||||||
[DbContext(typeof(MarathonDbContext))]
|
|
||||||
partial class MarathonDbContextModelSnapshot : ModelSnapshot
|
|
||||||
{
|
{
|
||||||
protected override void BuildModel(ModelBuilder modelBuilder)
|
[DbContext(typeof(MarathonDbContext))]
|
||||||
|
partial class MarathonDbContextModelSnapshot : ModelSnapshot
|
||||||
{
|
{
|
||||||
|
protected override void BuildModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
#pragma warning disable 612, 618
|
#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 =>
|
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.AnomalyEntity", b =>
|
||||||
{
|
{
|
||||||
b.Property<string>("Id").HasColumnType("TEXT");
|
b.Property<string>("Id")
|
||||||
b.Property<string>("DetectedAt").IsRequired().HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
b.Property<string>("EventCode").IsRequired().HasColumnType("TEXT");
|
|
||||||
b.Property<string>("EvidenceJson").IsRequired().HasColumnType("TEXT");
|
|
||||||
b.Property<int>("Kind").HasColumnType("INTEGER");
|
|
||||||
b.Property<decimal>("Score").HasColumnType("TEXT");
|
|
||||||
b.HasKey("Id");
|
|
||||||
b.HasIndex("EventCode").HasDatabaseName("IX_Anomalies_EventCode");
|
|
||||||
b.ToTable("Anomalies");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.BetEntity", b =>
|
b.Property<string>("DetectedAt")
|
||||||
{
|
.IsRequired()
|
||||||
b.Property<long>("Id").ValueGeneratedOnAdd().HasColumnType("INTEGER");
|
.HasColumnType("TEXT");
|
||||||
b.Property<int?>("PeriodNumber").HasColumnType("INTEGER");
|
|
||||||
b.Property<decimal>("Rate").HasColumnType("TEXT");
|
|
||||||
b.Property<int>("Scope").HasColumnType("INTEGER");
|
|
||||||
b.Property<int>("Side").HasColumnType("INTEGER");
|
|
||||||
b.Property<long>("SnapshotId").HasColumnType("INTEGER");
|
|
||||||
b.Property<int>("Type").HasColumnType("INTEGER");
|
|
||||||
b.Property<decimal?>("Value").HasColumnType("TEXT");
|
|
||||||
b.HasKey("Id");
|
|
||||||
b.HasIndex("SnapshotId").HasDatabaseName("IX_Bets_SnapshotId");
|
|
||||||
b.ToTable("Bets");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.EventEntity", b =>
|
b.Property<string>("EventCode")
|
||||||
{
|
.IsRequired()
|
||||||
b.Property<string>("EventCode").HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
b.Property<string>("Category").IsRequired().HasDefaultValue("").HasColumnType("TEXT");
|
|
||||||
b.Property<string>("CountryCode").IsRequired().HasColumnType("TEXT");
|
|
||||||
b.Property<string>("EventPath").HasColumnType("TEXT");
|
|
||||||
b.Property<string>("LeagueId").IsRequired().HasColumnType("TEXT");
|
|
||||||
b.Property<string>("ScheduledAt").IsRequired().HasColumnType("TEXT");
|
|
||||||
b.Property<string>("Side1Name").IsRequired().HasColumnType("TEXT");
|
|
||||||
b.Property<string>("Side2Name").IsRequired().HasColumnType("TEXT");
|
|
||||||
b.Property<int>("SportCode").HasColumnType("INTEGER");
|
|
||||||
b.HasKey("EventCode");
|
|
||||||
b.HasIndex(new[] { "SportCode", "ScheduledAt" }).HasDatabaseName("IX_Events_SportCode_ScheduledAt");
|
|
||||||
b.HasIndex("ScheduledAt").HasDatabaseName("IX_Events_ScheduledAt");
|
|
||||||
b.ToTable("Events");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.EventResultEntity", b =>
|
b.Property<string>("EvidenceJson")
|
||||||
{
|
.IsRequired()
|
||||||
b.Property<string>("EventCode").HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
b.Property<string>("CompletedAt").IsRequired().HasColumnType("TEXT");
|
|
||||||
b.Property<int>("Side1Score").HasColumnType("INTEGER");
|
|
||||||
b.Property<int>("Side2Score").HasColumnType("INTEGER");
|
|
||||||
b.Property<int>("WinnerSide").HasColumnType("INTEGER");
|
|
||||||
b.HasKey("EventCode");
|
|
||||||
b.ToTable("EventResults");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.LeagueEntity", b =>
|
b.Property<int>("Kind")
|
||||||
{
|
.HasColumnType("INTEGER");
|
||||||
b.Property<string>("Id").HasColumnType("TEXT");
|
|
||||||
b.Property<string>("Category").IsRequired().HasDefaultValue("").HasColumnType("TEXT");
|
|
||||||
b.Property<string>("Country").IsRequired().HasColumnType("TEXT");
|
|
||||||
b.Property<string>("NameEn").IsRequired().HasColumnType("TEXT");
|
|
||||||
b.Property<string>("NameRu").IsRequired().HasColumnType("TEXT");
|
|
||||||
b.Property<int>("SportCode").HasColumnType("INTEGER");
|
|
||||||
b.HasKey("Id");
|
|
||||||
b.ToTable("Leagues");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.SnapshotEntity", b =>
|
b.Property<decimal>("Score")
|
||||||
{
|
.HasColumnType("TEXT");
|
||||||
b.Property<long>("Id").ValueGeneratedOnAdd().HasColumnType("INTEGER");
|
|
||||||
b.Property<string>("CapturedAt").IsRequired().HasColumnType("TEXT");
|
|
||||||
b.Property<string>("EventCode").IsRequired().HasColumnType("TEXT");
|
|
||||||
b.Property<int>("Source").HasColumnType("INTEGER");
|
|
||||||
b.HasKey("Id");
|
|
||||||
b.HasIndex("EventCode").HasDatabaseName("IX_Snapshots_EventCode");
|
|
||||||
b.ToTable("Snapshots");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.SportEntity", b =>
|
b.HasKey("Id");
|
||||||
{
|
|
||||||
b.Property<int>("Code").HasColumnType("INTEGER");
|
|
||||||
b.Property<string>("NameEn").IsRequired().HasColumnType("TEXT");
|
|
||||||
b.Property<string>("NameRu").IsRequired().HasColumnType("TEXT");
|
|
||||||
b.HasKey("Code");
|
|
||||||
b.ToTable("Sports");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.AnomalyEntity", b =>
|
b.HasIndex("EventCode")
|
||||||
{
|
.HasDatabaseName("IX_Anomalies_EventCode");
|
||||||
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.ToTable("Anomalies", (string)null);
|
||||||
{
|
});
|
||||||
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 =>
|
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.BetEntity", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("Marathon.Infrastructure.Persistence.Entities.EventEntity", "Event")
|
b.Property<long>("Id")
|
||||||
.WithOne("Result")
|
.ValueGeneratedOnAdd()
|
||||||
.HasForeignKey("Marathon.Infrastructure.Persistence.Entities.EventResultEntity", "EventCode")
|
.HasColumnType("INTEGER");
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
|
||||||
.IsRequired();
|
|
||||||
b.Navigation("Event");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.SnapshotEntity", b =>
|
b.Property<int?>("PeriodNumber")
|
||||||
{
|
.HasColumnType("INTEGER");
|
||||||
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.Property<decimal>("Rate")
|
||||||
{
|
.HasColumnType("TEXT");
|
||||||
b.Navigation("Anomalies");
|
|
||||||
b.Navigation("Result");
|
|
||||||
b.Navigation("Snapshots");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.SnapshotEntity", b =>
|
b.Property<int>("Scope")
|
||||||
{
|
.HasColumnType("INTEGER");
|
||||||
b.Navigation("Bets");
|
|
||||||
});
|
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
|
#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");
|
builder.HasIndex(s => s.EventCode).HasDatabaseName("IX_Snapshots_EventCode");
|
||||||
|
|
||||||
|
// Snapshots is the largest table (live cadence 5–10s, 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)
|
builder.HasMany(s => s.Bets)
|
||||||
.WithOne(b => b.Snapshot)
|
.WithOne(b => b.Snapshot)
|
||||||
.HasForeignKey(b => b.SnapshotId)
|
.HasForeignKey(b => b.SnapshotId)
|
||||||
|
|||||||
@@ -11,7 +11,11 @@ internal sealed class SportConfiguration : IEntityTypeConfiguration<SportEntity>
|
|||||||
builder.ToTable("Sports");
|
builder.ToTable("Sports");
|
||||||
|
|
||||||
builder.HasKey(s => s.Code);
|
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.NameRu).HasColumnType("TEXT").IsRequired();
|
||||||
builder.Property(s => s.NameEn).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.Entities;
|
||||||
using Marathon.Domain.Enums;
|
using Marathon.Domain.Enums;
|
||||||
using Marathon.Domain.ValueObjects;
|
using Marathon.Domain.ValueObjects;
|
||||||
@@ -10,16 +10,15 @@ namespace Marathon.Infrastructure.Persistence;
|
|||||||
/// Mapping helpers that translate between domain objects and EF Core persistence entities.
|
/// Mapping helpers that translate between domain objects and EF Core persistence entities.
|
||||||
/// Domain invariants are enforced on the domain side; mapping is purely structural.
|
/// Domain invariants are enforced on the domain side; mapping is purely structural.
|
||||||
/// </summary>
|
/// </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
|
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 ────────────────────────────────────
|
// ─── Bet scope discriminator constants ────────────────────────────────────
|
||||||
private const int ScopeMatch = 0;
|
private const int ScopeMatch = 0;
|
||||||
private const int ScopePeriod = 1;
|
private const int ScopePeriod = 1;
|
||||||
// ─── Event ───────────────────────────────────────────────────────────────
|
// ─── Event ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -31,7 +30,7 @@ internal static class Mapping
|
|||||||
CountryCode = domain.CountryCode,
|
CountryCode = domain.CountryCode,
|
||||||
LeagueId = domain.LeagueId,
|
LeagueId = domain.LeagueId,
|
||||||
Category = domain.Category,
|
Category = domain.Category,
|
||||||
ScheduledAt = domain.ScheduledAt.ToString("O"),
|
ScheduledAt = SqliteDateText.Key(domain.ScheduledAt),
|
||||||
Side1Name = domain.Side1Name,
|
Side1Name = domain.Side1Name,
|
||||||
Side2Name = domain.Side2Name,
|
Side2Name = domain.Side2Name,
|
||||||
EventPath = domain.EventPath,
|
EventPath = domain.EventPath,
|
||||||
@@ -44,7 +43,7 @@ internal static class Mapping
|
|||||||
CountryCode: entity.CountryCode,
|
CountryCode: entity.CountryCode,
|
||||||
LeagueId: entity.LeagueId,
|
LeagueId: entity.LeagueId,
|
||||||
Category: entity.Category,
|
Category: entity.Category,
|
||||||
ScheduledAt: DateTimeOffset.Parse(entity.ScheduledAt, CultureInfo.InvariantCulture, RoundtripStyles),
|
ScheduledAt: SqliteDateText.Parse(entity.ScheduledAt),
|
||||||
Side1Name: entity.Side1Name,
|
Side1Name: entity.Side1Name,
|
||||||
Side2Name: entity.Side2Name)
|
Side2Name: entity.Side2Name)
|
||||||
{
|
{
|
||||||
@@ -57,7 +56,7 @@ internal static class Mapping
|
|||||||
new()
|
new()
|
||||||
{
|
{
|
||||||
EventCode = domain.EventId.Value,
|
EventCode = domain.EventId.Value,
|
||||||
CapturedAt = domain.CapturedAt.ToString("O"),
|
CapturedAt = SqliteDateText.Key(domain.CapturedAt),
|
||||||
Source = (int)domain.Source,
|
Source = (int)domain.Source,
|
||||||
Bets = domain.Bets.Select(ToEntity).ToList(),
|
Bets = domain.Bets.Select(ToEntity).ToList(),
|
||||||
};
|
};
|
||||||
@@ -65,7 +64,7 @@ internal static class Mapping
|
|||||||
public static OddsSnapshot ToDomain(SnapshotEntity entity) =>
|
public static OddsSnapshot ToDomain(SnapshotEntity entity) =>
|
||||||
new(
|
new(
|
||||||
eventId: new EventId(entity.EventCode),
|
eventId: new EventId(entity.EventCode),
|
||||||
capturedAt: DateTimeOffset.Parse(entity.CapturedAt, CultureInfo.InvariantCulture, RoundtripStyles),
|
capturedAt: SqliteDateText.Parse(entity.CapturedAt),
|
||||||
source: (OddsSource)entity.Source,
|
source: (OddsSource)entity.Source,
|
||||||
bets: entity.Bets.Select(ToDomain).ToList().AsReadOnly());
|
bets: entity.Bets.Select(ToDomain).ToList().AsReadOnly());
|
||||||
|
|
||||||
@@ -86,7 +85,7 @@ internal static class Mapping
|
|||||||
{
|
{
|
||||||
var scope = entity.Scope switch
|
var scope = entity.Scope switch
|
||||||
{
|
{
|
||||||
ScopeMatch => (BetScope)MatchScope.Instance,
|
ScopeMatch => (BetScope)MatchScope.Instance,
|
||||||
ScopePeriod => new PeriodScope(entity.PeriodNumber!.Value),
|
ScopePeriod => new PeriodScope(entity.PeriodNumber!.Value),
|
||||||
_ => throw new InvalidOperationException(
|
_ => throw new InvalidOperationException(
|
||||||
$"Unknown BetScope discriminator: {entity.Scope}"),
|
$"Unknown BetScope discriminator: {entity.Scope}"),
|
||||||
@@ -109,7 +108,7 @@ internal static class Mapping
|
|||||||
Side1Score = domain.Side1Score,
|
Side1Score = domain.Side1Score,
|
||||||
Side2Score = domain.Side2Score,
|
Side2Score = domain.Side2Score,
|
||||||
WinnerSide = (int)domain.WinnerSide,
|
WinnerSide = (int)domain.WinnerSide,
|
||||||
CompletedAt = domain.CompletedAt.ToString("O"),
|
CompletedAt = SqliteDateText.Key(domain.CompletedAt),
|
||||||
};
|
};
|
||||||
|
|
||||||
public static EventResult ToDomain(EventResultEntity entity) =>
|
public static EventResult ToDomain(EventResultEntity entity) =>
|
||||||
@@ -118,7 +117,7 @@ internal static class Mapping
|
|||||||
Side1Score: entity.Side1Score,
|
Side1Score: entity.Side1Score,
|
||||||
Side2Score: entity.Side2Score,
|
Side2Score: entity.Side2Score,
|
||||||
WinnerSide: (Side)entity.WinnerSide,
|
WinnerSide: (Side)entity.WinnerSide,
|
||||||
CompletedAt: DateTimeOffset.Parse(entity.CompletedAt, CultureInfo.InvariantCulture, RoundtripStyles));
|
CompletedAt: SqliteDateText.Parse(entity.CompletedAt));
|
||||||
|
|
||||||
// ─── Anomaly ──────────────────────────────────────────────────────────────
|
// ─── Anomaly ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -127,7 +126,7 @@ internal static class Mapping
|
|||||||
{
|
{
|
||||||
Id = domain.Id.ToString(),
|
Id = domain.Id.ToString(),
|
||||||
EventCode = domain.EventId.Value,
|
EventCode = domain.EventId.Value,
|
||||||
DetectedAt = domain.DetectedAt.ToString("O"),
|
DetectedAt = SqliteDateText.Key(domain.DetectedAt),
|
||||||
Kind = (int)domain.Kind,
|
Kind = (int)domain.Kind,
|
||||||
Score = domain.Score,
|
Score = domain.Score,
|
||||||
EvidenceJson = domain.EvidenceJson,
|
EvidenceJson = domain.EvidenceJson,
|
||||||
@@ -137,7 +136,7 @@ internal static class Mapping
|
|||||||
new(
|
new(
|
||||||
Id: Guid.Parse(entity.Id),
|
Id: Guid.Parse(entity.Id),
|
||||||
EventId: new EventId(entity.EventCode),
|
EventId: new EventId(entity.EventCode),
|
||||||
DetectedAt: DateTimeOffset.Parse(entity.DetectedAt, CultureInfo.InvariantCulture, RoundtripStyles),
|
DetectedAt: SqliteDateText.Parse(entity.DetectedAt),
|
||||||
Kind: (AnomalyKind)entity.Kind,
|
Kind: (AnomalyKind)entity.Kind,
|
||||||
Score: entity.Score,
|
Score: entity.Score,
|
||||||
EvidenceJson: entity.EvidenceJson);
|
EvidenceJson: entity.EvidenceJson);
|
||||||
@@ -158,6 +157,51 @@ internal static class Mapping
|
|||||||
NameRu: entity.NameRu,
|
NameRu: entity.NameRu,
|
||||||
NameEn: entity.NameEn);
|
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 ───────────────────────────────────────────────────────────────
|
// ─── League ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
public static LeagueEntity ToEntity(League domain) =>
|
public static LeagueEntity ToEntity(League domain) =>
|
||||||
@@ -179,4 +223,63 @@ internal static class Mapping
|
|||||||
NameRu: entity.NameRu,
|
NameRu: entity.NameRu,
|
||||||
NameEn: entity.NameEn,
|
NameEn: entity.NameEn,
|
||||||
Category: entity.Category);
|
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<AnomalyEntity> Anomalies => Set<AnomalyEntity>();
|
||||||
public DbSet<SportEntity> Sports => Set<SportEntity>();
|
public DbSet<SportEntity> Sports => Set<SportEntity>();
|
||||||
public DbSet<LeagueEntity> Leagues => Set<LeagueEntity>();
|
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)
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -53,6 +53,9 @@ public static class PersistenceModule
|
|||||||
services.AddScoped<ISnapshotRepository, SnapshotRepository>();
|
services.AddScoped<ISnapshotRepository, SnapshotRepository>();
|
||||||
services.AddScoped<IResultRepository, ResultRepository>();
|
services.AddScoped<IResultRepository, ResultRepository>();
|
||||||
services.AddScoped<IAnomalyRepository, AnomalyRepository>();
|
services.AddScoped<IAnomalyRepository, AnomalyRepository>();
|
||||||
|
services.AddScoped<IPlacedBetRepository, PlacedBetRepository>();
|
||||||
|
services.AddScoped<ISavedStrategyRepository, SavedStrategyRepository>();
|
||||||
|
services.AddScoped<IPaperBetRepository, PaperBetRepository>();
|
||||||
services.AddScoped<IExcelExporter, ExcelExporter>();
|
services.AddScoped<IExcelExporter, ExcelExporter>();
|
||||||
|
|
||||||
return services;
|
return services;
|
||||||
|
|||||||
@@ -23,6 +23,44 @@ internal sealed class AnomalyRepository : IAnomalyRepository
|
|||||||
return entities.Select(Mapping.ToDomain).ToList().AsReadOnly();
|
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)
|
public async Task AddAsync(Anomaly entity, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var efEntity = Mapping.ToEntity(entity);
|
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)
|
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.
|
// ScheduledAt is stored as ISO 8601 TEXT (see SqliteDateText); SQLite TEXT
|
||||||
var fromStr = range.From.ToString("O");
|
// comparison sorts chronologically for the fixed-offset O format.
|
||||||
var toStr = range.To.ToString("O");
|
var fromStr = SqliteDateText.Key(range.From);
|
||||||
|
var toStr = SqliteDateText.Key(range.To);
|
||||||
|
|
||||||
// EF Core SQLite cannot translate string.Compare(...) with StringComparison; it can
|
// EF Core SQLite cannot translate string.Compare(...) with StringComparison; it can
|
||||||
// translate the relational operators on string columns (which use BINARY/ordinal
|
// 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();
|
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)
|
public async Task<IReadOnlyList<Event>> ListBySportAsync(SportCode sport, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var entities = await _db.Events.AsNoTracking()
|
var entities = await _db.Events.AsNoTracking()
|
||||||
@@ -50,6 +102,9 @@ internal sealed class EventRepository : IEventRepository
|
|||||||
return entities.Select(Mapping.ToDomain).ToList().AsReadOnly();
|
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)
|
public async Task<IReadOnlyList<int>> ListDistinctSportCodesAsync(CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var codes = await _db.Events.AsNoTracking()
|
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();
|
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)
|
public async Task AddAsync(EventResult entity, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var efEntity = Mapping.ToEntity(entity);
|
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();
|
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(
|
public async Task<IReadOnlyList<OddsSnapshot>> ListByEventAsync(
|
||||||
EventId eventId,
|
EventId eventId,
|
||||||
DateTimeOffset from,
|
DateTimeOffset from,
|
||||||
DateTimeOffset to,
|
DateTimeOffset to,
|
||||||
CancellationToken ct = default)
|
CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var fromStr = from.ToString("O");
|
var fromStr = SqliteDateText.Key(from);
|
||||||
var toStr = to.ToString("O");
|
var toStr = SqliteDateText.Key(to);
|
||||||
|
|
||||||
var entities = await _db.Snapshots.AsNoTracking()
|
var entities = await _db.Snapshots.AsNoTracking()
|
||||||
.Include(s => s.Bets)
|
.Include(s => s.Bets)
|
||||||
@@ -51,8 +71,8 @@ internal sealed class SnapshotRepository : ISnapshotRepository
|
|||||||
return result;
|
return result;
|
||||||
|
|
||||||
var ids = eventIds.Select(e => e.Value).Distinct().ToArray();
|
var ids = eventIds.Select(e => e.Value).Distinct().ToArray();
|
||||||
var fromStr = from.ToString("O");
|
var fromStr = SqliteDateText.Key(from);
|
||||||
var toStr = to.ToString("O");
|
var toStr = SqliteDateText.Key(to);
|
||||||
|
|
||||||
var entities = await _db.Snapshots.AsNoTracking()
|
var entities = await _db.Snapshots.AsNoTracking()
|
||||||
.Include(s => s.Bets)
|
.Include(s => s.Bets)
|
||||||
@@ -83,4 +103,26 @@ internal sealed class SnapshotRepository : ISnapshotRepository
|
|||||||
|
|
||||||
public async Task SaveChangesAsync(CancellationToken ct = default) =>
|
public async Task SaveChangesAsync(CancellationToken ct = default) =>
|
||||||
await _db.SaveChangesAsync(ct);
|
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>>=</c>, <c><=</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.Enums;
|
||||||
using Marathon.Domain.ValueObjects;
|
using Marathon.Domain.ValueObjects;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
using DomainEventId = Marathon.Domain.ValueObjects.EventId;
|
|
||||||
using AngleSharpConfig = AngleSharp.Configuration;
|
using AngleSharpConfig = AngleSharp.Configuration;
|
||||||
|
using DomainEventId = Marathon.Domain.ValueObjects.EventId;
|
||||||
|
|
||||||
namespace Marathon.Infrastructure.Scraping.Parsers;
|
namespace Marathon.Infrastructure.Scraping.Parsers;
|
||||||
|
|
||||||
@@ -110,7 +109,7 @@ public sealed partial class EventOddsParser : IEventOddsParser
|
|||||||
// no longer rescans the document with QuerySelector
|
// no longer rescans the document with QuerySelector
|
||||||
// for every key — that was an O(N) cost paid 6× per
|
// for every key — that was an O(N) cost paid 6× per
|
||||||
// period).
|
// period).
|
||||||
var priceIndex = BuildSelectionPriceIndex(selections);
|
var priceIndex = BuildSelectionPriceIndex(selections);
|
||||||
var elementIndex = BuildSelectionElementIndex(selections);
|
var elementIndex = BuildSelectionElementIndex(selections);
|
||||||
|
|
||||||
var bets = new List<Bet>();
|
var bets = new List<Bet>();
|
||||||
@@ -187,19 +186,19 @@ public sealed partial class EventOddsParser : IEventOddsParser
|
|||||||
// Try each market variant; first match wins
|
// Try each market variant; first match wins
|
||||||
foreach (var market in MatchResultMarkets)
|
foreach (var market in MatchResultMarkets)
|
||||||
{
|
{
|
||||||
var win1Key = $"{eventId}@{market}.1";
|
var win1Key = $"{eventId}@{market}.1";
|
||||||
var drawKey = $"{eventId}@{market}.draw";
|
var drawKey = $"{eventId}@{market}.draw";
|
||||||
var win2Key = $"{eventId}@{market}.3";
|
var win2Key = $"{eventId}@{market}.3";
|
||||||
|
|
||||||
// Basketball 2-way OT market uses HB_H / HB_A
|
// Basketball 2-way OT market uses HB_H / HB_A
|
||||||
var hbhKey = $"{eventId}@{market}.HB_H";
|
var hbhKey = $"{eventId}@{market}.HB_H";
|
||||||
var hbaKey = $"{eventId}@{market}.HB_A";
|
var hbaKey = $"{eventId}@{market}.HB_A";
|
||||||
|
|
||||||
var hasWin1 = idx.TryGetValue(win1Key, out var rate1);
|
var hasWin1 = idx.TryGetValue(win1Key, out var rate1);
|
||||||
var hasDraw = idx.TryGetValue(drawKey, out var rateDraw);
|
var hasDraw = idx.TryGetValue(drawKey, out var rateDraw);
|
||||||
var hasWin2 = idx.TryGetValue(win2Key, out var rate2);
|
var hasWin2 = idx.TryGetValue(win2Key, out var rate2);
|
||||||
var hasHbh = idx.TryGetValue(hbhKey, out var rateHbh);
|
var hasHbh = idx.TryGetValue(hbhKey, out var rateHbh);
|
||||||
var hasHba = idx.TryGetValue(hbaKey, out var rateHba);
|
var hasHba = idx.TryGetValue(hbaKey, out var rateHba);
|
||||||
|
|
||||||
if (hasWin1 || hasDraw || hasWin2 || hasHbh || hasHba)
|
if (hasWin1 || hasDraw || hasWin2 || hasHbh || hasHba)
|
||||||
{
|
{
|
||||||
@@ -517,8 +516,11 @@ public sealed partial class EventOddsParser : IEventOddsParser
|
|||||||
value.HasValue ? new OddsValue(value.Value) : null,
|
value.HasValue ? new OddsValue(value.Value) : null,
|
||||||
new OddsRate(rate)));
|
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,
|
_logger.LogDebug(ex,
|
||||||
"Skipping bet ({Type}, {Side}, value={Value}, rate={Rate}) — invariant violation.",
|
"Skipping bet ({Type}, {Side}, value={Value}, rate={Rate}) — invariant violation.",
|
||||||
type, side, value, rate);
|
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)
|
ILogger<LiveOddsPoller> logger)
|
||||||
{
|
{
|
||||||
_services = services ?? throw new ArgumentNullException(nameof(services));
|
_services = services ?? throw new ArgumentNullException(nameof(services));
|
||||||
_opts = opts ?? throw new ArgumentNullException(nameof(opts));
|
_opts = opts ?? throw new ArgumentNullException(nameof(opts));
|
||||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
@@ -47,6 +47,8 @@ internal sealed class LiveOddsPoller : BackgroundService
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var cycleStart = DateTime.UtcNow;
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await using var scope = _services.CreateAsyncScope();
|
await using var scope = _services.CreateAsyncScope();
|
||||||
@@ -69,9 +71,17 @@ internal sealed class LiveOddsPoller : BackgroundService
|
|||||||
var interval = TimeSpan.FromSeconds(
|
var interval = TimeSpan.FromSeconds(
|
||||||
Math.Max(1, _opts.CurrentValue.LivePollIntervalSeconds));
|
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
|
try
|
||||||
{
|
{
|
||||||
await Task.Delay(interval, stoppingToken);
|
await Task.Delay(remaining, stoppingToken);
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
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.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -211,8 +211,11 @@
|
|||||||
|
|
||||||
private string KindLabel(AnomalyKind kind) => kind switch
|
private string KindLabel(AnomalyKind kind) => kind switch
|
||||||
{
|
{
|
||||||
AnomalyKind.SuspensionFlip => L["Anomaly.Kind.SuspensionFlip"],
|
AnomalyKind.SuspensionFlip => L["Anomaly.Kind.SuspensionFlip"],
|
||||||
_ => kind.ToString(),
|
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);
|
private string SportLabel(int code) => SportLabels.Resolve(L, code);
|
||||||
|
|||||||
@@ -39,8 +39,36 @@
|
|||||||
<MudIcon Icon="@Icons.Material.Outlined.Done" Size="Size.Small" />
|
<MudIcon Icon="@Icons.Material.Outlined.Done" Size="Size.Small" />
|
||||||
<span>@L["Nav.Results"]</span>
|
<span>@L["Nav.Results"]</span>
|
||||||
</NavLink>
|
</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>
|
<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">
|
<NavLink class="m-nav__link" href="settings">
|
||||||
<MudIcon Icon="@Icons.Material.Outlined.Tune" Size="Size.Small" />
|
<MudIcon Icon="@Icons.Material.Outlined.Tune" Size="Size.Small" />
|
||||||
<span>@L["Nav.Settings"]</span>
|
<span>@L["Nav.Settings"]</span>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
@inject ThemeState ThemeState
|
@inject ThemeState ThemeState
|
||||||
@inject LocaleState LocaleState
|
@inject LocaleState LocaleState
|
||||||
@inject IStringLocalizer<SharedResource> L
|
@inject IStringLocalizer<SharedResource> L
|
||||||
|
@inject IOptionsMonitor<WorkerOptions> Workers
|
||||||
|
|
||||||
<MudThemeProvider IsDarkMode="@ThemeState.IsDark" Theme="@_theme" />
|
<MudThemeProvider IsDarkMode="@ThemeState.IsDark" Theme="@_theme" />
|
||||||
<MudPopoverProvider />
|
<MudPopoverProvider />
|
||||||
@@ -24,6 +25,12 @@
|
|||||||
<div class="m-appbar__spacer"></div>
|
<div class="m-appbar__spacer"></div>
|
||||||
|
|
||||||
<div class="m-appbar__tools m-rise m-rise-2">
|
<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 />
|
<LocaleSwitcher />
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
</div>
|
</div>
|
||||||
@@ -123,11 +130,20 @@
|
|||||||
@code {
|
@code {
|
||||||
private bool _drawerOpen = true;
|
private bool _drawerOpen = true;
|
||||||
private MudBlazor.MudTheme _theme = Theme.MarathonTheme.Build();
|
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()
|
protected override void OnInitialized()
|
||||||
{
|
{
|
||||||
ThemeState.OnChange += StateHasChanged;
|
ThemeState.OnChange += StateHasChanged;
|
||||||
LocaleState.OnChange += StateHasChanged;
|
LocaleState.OnChange += StateHasChanged;
|
||||||
|
// Reflect Settings toggles live without requiring a navigation.
|
||||||
|
_workerOptionsListener = Workers.OnChange(_ => InvokeAsync(StateHasChanged));
|
||||||
}
|
}
|
||||||
|
|
||||||
private void ToggleDrawer() => _drawerOpen = !_drawerOpen;
|
private void ToggleDrawer() => _drawerOpen = !_drawerOpen;
|
||||||
@@ -136,5 +152,6 @@
|
|||||||
{
|
{
|
||||||
ThemeState.OnChange -= StateHasChanged;
|
ThemeState.OnChange -= StateHasChanged;
|
||||||
LocaleState.OnChange -= StateHasChanged;
|
LocaleState.OnChange -= StateHasChanged;
|
||||||
|
_workerOptionsListener?.Dispose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user