Compare commits
30 Commits
d9d92ea8fd
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| def878f773 | |||
| 6f0d74b56e | |||
| 1e4dddbbad | |||
| 5d79911c12 | |||
| 0683e348ba | |||
| 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 |
@@ -84,3 +84,7 @@ spike/captures/
|
||||
|
||||
# Claude Code per-session task metadata (local only)
|
||||
.claude/
|
||||
|
||||
# Throwaway debugging scratch (PowerShell dumps, raw page captures)
|
||||
_dump*.ps1
|
||||
_pages*.txt
|
||||
|
||||
@@ -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
|
||||
that constructs Moscow datetimes must use `new DateTimeOffset(date, TimeSpan.FromHours(3))`,
|
||||
never pass a `DateTime.UtcNow` value to that constructor.
|
||||
- **EF migrations — generate with `dotnet ef`, do NOT `migrations remove`.**
|
||||
`MarathonDbContextFactory` is a self-contained design-time factory, so
|
||||
`dotnet ef migrations add X --project src/Marathon.Infrastructure --startup-project src/Marathon.Infrastructure`
|
||||
works. AVOID `dotnet ef migrations remove`: older migrations were hand-written without a
|
||||
Designer snapshot, so remove blanks `MarathonDbContextModelSnapshot.cs` and the next `add`
|
||||
regenerates the WHOLE schema. Validate a migration on a throwaway DB with
|
||||
`dotnet ef database update --connection "Data Source=<ABSOLUTE>/data/_migtest.db"` (absolute
|
||||
path — EF resolves relatives from the build-output dir, not the shell cwd; create `./data/` first).
|
||||
- **`Sports.Code` is `.ValueGeneratedNever()`** — it's the bookmaker's natural sport id
|
||||
(6/11/22723…), not an autoincrement surrogate. Without it EF's int-PK convention emits a
|
||||
spurious AUTOINCREMENT `AlterColumn` on every migration.
|
||||
- **Validated get-only record properties block `with`** (CS0200): `BacktestStrategy.MinScore`,
|
||||
`PaperBet.Rate`/`Stake` are re-declared with validation, so build a new instance instead of
|
||||
`with { ThatProp = … }` (you can still `with` the un-redeclared props, e.g. `SavedStrategy with { Strategy = … }`).
|
||||
- **`JsonSettingsWriter.SaveSectionAsync` replaces the whole section** (`root[section]=json`),
|
||||
so a Settings-UI save drops any key not on the form. Never surface a secret-bearing section
|
||||
(e.g. `Notifications` → Telegram token) in the UI — it would wipe the secret from
|
||||
`appsettings.Local.json`. To surface a non-secret section, add a mutable mirror options class
|
||||
in `Marathon.UI.Services` bound to the same section name (UI can't reference Infrastructure's
|
||||
options types — same pattern as the two `WorkerOptions`).
|
||||
|
||||
## Feature: Initial Implementation > Phase 4: Application + Workers — Learnings
|
||||
|
||||
@@ -203,3 +223,25 @@ For full detail see `spike/SCRAPE_FINDINGS.md` and `spike/SCHEMA_DRAFT.md`.)
|
||||
- **Signal-red is the load-bearing alert tone** for Phase 7. Use
|
||||
`var(--m-c-anomaly)` exclusively (never raw `#dc2626`). Pulsing animation
|
||||
(`m-pulse`) MUST respect `prefers-reduced-motion`.
|
||||
|
||||
## Feature: Analysis Hardening (H-series) — Learnings
|
||||
|
||||
- **Anomaly detectors are a fan-out array in `DetectAnomaliesUseCase`** — 4 now
|
||||
(`SuspensionFlip`, `SteamMove`, `SuspensionFreeze`, `OverroundCompression`). A new detector
|
||||
implements `IAnomalyDetector`, reuses `MatchWinEvidence` for the canonical evidence JSON
|
||||
(so the parser + outcome evaluator work unchanged), and is added to that array. Continuous
|
||||
sliding-window detectors (steam, overround) emit at every `end` and skip suspension-sized
|
||||
gaps so they never overlap the across-suspension flip/freeze detectors.
|
||||
- **`AnomalyKind.IsDirectional()` gates staking/grading** — flip + steam are directional
|
||||
(predict a side); freeze + overround are informational and are excluded from the backtest
|
||||
(`RunBacktestUseCase`) and the outcome evaluator so they don't skew hit-rate/score calibration.
|
||||
- **`SavedStrategy` (backtest presets) use NOCASE name collation** — the unique index AND
|
||||
`GetByNameAsync` both fold case (column-level `UseCollation("NOCASE")`), so save-by-name
|
||||
upserts rather than creating "Kelly"/"kelly" duplicates. Domain stores stake fractions; the
|
||||
form/VM speak percent — convert ×100/÷100 at the UI boundary.
|
||||
- **Paper-trading (forward-test) is a config-gated worker** (`PaperTrading:Enabled`, default
|
||||
false; tunable on the Settings page). `PaperTradingWorker` opens flat-stake `PaperBet`s on
|
||||
new directional anomalies (unique on `AnomalyId`; baseline since-marker advances only after a
|
||||
successful open pass; settle runs in its own catch so a settle failure can't strand the
|
||||
marker) and settles them against results (Won iff pick == winner). `/paper-trading` shows
|
||||
settled-only P&L. The ledger is FK-free (survives snapshot-retention pruning), like `PlacedBets`.
|
||||
|
||||
@@ -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>
|
||||
@@ -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,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);
|
||||
}
|
||||
@@ -22,6 +22,12 @@ public interface ISnapshotRepository
|
||||
/// </summary>
|
||||
Task<int> CountSinceAsync(DateTimeOffset since, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// The most recent snapshot capture time across all events, or <c>null</c> when the
|
||||
/// store is empty. Backs the pipeline-health freshness indicator.
|
||||
/// </summary>
|
||||
Task<DateTimeOffset?> GetLatestCapturedAtAsync(CancellationToken ct = default);
|
||||
|
||||
Task<IReadOnlyList<OddsSnapshot>> ListByEventAsync(
|
||||
EventId eventId,
|
||||
DateTimeOffset from,
|
||||
|
||||
@@ -29,15 +29,24 @@ public static class ApplicationModule
|
||||
services.AddScoped<PullLiveOddsUseCase>();
|
||||
services.AddScoped<PullResultsUseCase>();
|
||||
services.AddScoped<ExportToExcelUseCase>();
|
||||
services.AddScoped<ExportToCsvUseCase>();
|
||||
services.AddScoped<DetectAnomaliesUseCase>();
|
||||
services.AddScoped<EvaluateAnomalyOutcomesUseCase>();
|
||||
services.AddScoped<GetPendingAnomalyNotificationsUseCase>();
|
||||
|
||||
services.AddScoped<RecordPlacedBetUseCase>();
|
||||
services.AddScoped<ResolvePendingBetsUseCase>();
|
||||
services.AddScoped<BuildBetJournalReportUseCase>();
|
||||
services.AddScoped<DeletePlacedBetUseCase>();
|
||||
services.AddScoped<UpdatePlacedBetUseCase>();
|
||||
|
||||
services.AddScoped<RunBacktestUseCase>();
|
||||
services.AddScoped<SaveStrategyUseCase>();
|
||||
services.AddScoped<DeleteStrategyUseCase>();
|
||||
services.AddScoped<CompareStrategiesUseCase>();
|
||||
|
||||
services.AddScoped<OpenPaperBetsUseCase>();
|
||||
services.AddScoped<SettlePaperBetsUseCase>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
@@ -44,4 +44,23 @@ public sealed class AnomalyOptions
|
||||
/// 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;
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,10 @@ namespace Marathon.Application.Reporting;
|
||||
/// <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">
|
||||
@@ -37,6 +41,7 @@ public sealed record AnomalyOutcomeReport(
|
||||
IReadOnlyList<OutcomeBucket> BySeverity,
|
||||
IReadOnlyList<OutcomeBucket> BySport,
|
||||
IReadOnlyList<OutcomeBucket> ByScoreBin,
|
||||
IReadOnlyList<OutcomeBucket> ByKind,
|
||||
IReadOnlyList<ResolvedAnomaly> Resolved,
|
||||
IReadOnlyList<ResolvedAnomaly> Unresolved,
|
||||
IReadOnlyDictionary<DomainEventId, string> EventTitles);
|
||||
|
||||
@@ -14,6 +14,9 @@ public static class OutcomeBucketKeys
|
||||
/// <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.";
|
||||
|
||||
|
||||
@@ -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,26 @@
|
||||
using Marathon.Application.Abstractions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Marathon.Application.UseCases;
|
||||
|
||||
/// <summary>
|
||||
/// Removes a saved strategy preset by id. Silent no-op when the id is unknown.
|
||||
/// </summary>
|
||||
public sealed class DeleteStrategyUseCase
|
||||
{
|
||||
private readonly ISavedStrategyRepository _repo;
|
||||
private readonly ILogger<DeleteStrategyUseCase> _logger;
|
||||
|
||||
public DeleteStrategyUseCase(ISavedStrategyRepository repo, ILogger<DeleteStrategyUseCase> logger)
|
||||
{
|
||||
_repo = repo ?? throw new ArgumentNullException(nameof(repo));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task ExecuteAsync(Guid id, CancellationToken ct = default)
|
||||
{
|
||||
await _repo.DeleteAsync(id, ct).ConfigureAwait(false);
|
||||
await _repo.SaveChangesAsync(ct).ConfigureAwait(false);
|
||||
_logger.LogInformation("DeleteStrategyUseCase: removed preset {Id}", id);
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,7 @@ namespace Marathon.Application.UseCases;
|
||||
/// <item>Loads all tracked events.</item>
|
||||
/// <item>For each event, fetches its last-24-hour live snapshots.</item>
|
||||
/// <item>Runs <see cref="AnomalyDetector"/> over the snapshot timeline.</item>
|
||||
/// <item>Persists any new anomalies that have not already been stored (dedup by EventId + DetectedAt minute-window).</item>
|
||||
/// <item>Persists any new anomalies that have not already been stored (dedup by EventId + Kind + DetectedAt minute-window).</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
@@ -70,6 +70,15 @@ public sealed class DetectAnomaliesUseCase
|
||||
_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);
|
||||
@@ -134,8 +143,8 @@ public sealed class DetectAnomaliesUseCase
|
||||
List<Anomaly> existingForEvent,
|
||||
CancellationToken ct)
|
||||
{
|
||||
// Fan out over every detector kind; dedup below keys on EventId + Kind so the
|
||||
// flip and steam signals for one event persist independently.
|
||||
// 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();
|
||||
@@ -150,11 +159,15 @@ public sealed class DetectAnomaliesUseCase
|
||||
continue;
|
||||
|
||||
await _anomalyRepo.AddAsync(anomaly, ct);
|
||||
await _anomalyRepo.SaveChangesAsync(ct);
|
||||
existingForEvent.Add(anomaly); // Keep local list in sync so the same cycle doesn't re-add.
|
||||
persisted++;
|
||||
}
|
||||
|
||||
// One write per event rather than per anomaly — with three detectors an event
|
||||
// can yield several new anomalies in a single cycle.
|
||||
if (persisted > 0)
|
||||
await _anomalyRepo.SaveChangesAsync(ct);
|
||||
|
||||
return persisted;
|
||||
}
|
||||
|
||||
|
||||
@@ -124,6 +124,7 @@ public sealed class EvaluateAnomalyOutcomesUseCase
|
||||
BySeverity: BuildSeverityBuckets(resolvedOrdered),
|
||||
BySport: BuildSportBuckets(resolvedOrdered),
|
||||
ByScoreBin: BuildScoreBins(resolvedOrdered),
|
||||
ByKind: BuildKindBuckets(resolvedOrdered),
|
||||
Resolved: resolvedOrdered,
|
||||
Unresolved: unresolvedOrdered,
|
||||
EventTitles: eventTitles);
|
||||
@@ -171,6 +172,20 @@ public sealed class EvaluateAnomalyOutcomesUseCase
|
||||
.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)
|
||||
{
|
||||
@@ -239,6 +254,7 @@ public sealed class EvaluateAnomalyOutcomesUseCase
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
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;
|
||||
|
||||
@@ -46,17 +48,32 @@ public sealed class RunBacktestUseCase
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<BacktestResult> ExecuteAsync(
|
||||
/// <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}",
|
||||
strategy.StartingBankroll, strategy.MinScore, strategy.StakeRule);
|
||||
"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 = await _anomalies.ListAsync(ct).ConfigureAwait(false);
|
||||
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");
|
||||
@@ -79,6 +96,11 @@ public sealed class RunBacktestUseCase
|
||||
{
|
||||
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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -57,6 +57,18 @@ public sealed record AnomalyEvidenceSide(
|
||||
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>
|
||||
|
||||
@@ -63,6 +63,25 @@ public static class AnomalyOutcomeEvaluator
|
||||
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(
|
||||
|
||||
@@ -26,14 +26,19 @@ internal static class MatchWinEvidence
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
};
|
||||
|
||||
/// <summary>Normalised match-win implied probabilities + raw rates for a snapshot.</summary>
|
||||
/// <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 Rate2,
|
||||
decimal Overround);
|
||||
|
||||
/// <summary>
|
||||
/// Extracts normalised match-win implied probabilities, or null when the snapshot
|
||||
@@ -65,7 +70,8 @@ internal static class MatchWinEvidence
|
||||
P2: rawP2 / total,
|
||||
Rate1: win1.Rate.Value,
|
||||
RateDraw: drawBet?.Rate.Value,
|
||||
Rate2: win2.Rate.Value);
|
||||
Rate2: win2.Rate.Value,
|
||||
Overround: total);
|
||||
}
|
||||
|
||||
/// <summary>Label of the side carrying the highest normalised implied probability.</summary>
|
||||
|
||||
@@ -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,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,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,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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -16,4 +16,16 @@ public enum AnomalyKind
|
||||
/// 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,
|
||||
};
|
||||
}
|
||||
@@ -7,6 +7,9 @@ namespace Marathon.Domain.ValueObjects;
|
||||
/// </summary>
|
||||
public sealed record EventId
|
||||
{
|
||||
/// <summary>Sane upper bound — real bookmaker ids are short (≈8 chars).</summary>
|
||||
private const int MaxLength = 128;
|
||||
|
||||
public string Value { get; }
|
||||
|
||||
public EventId(string value)
|
||||
@@ -14,6 +17,25 @@ public sealed record EventId
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
throw new ArgumentException("EventId must not be empty or whitespace.", nameof(value));
|
||||
|
||||
// Defense-in-depth. The id is deliberately string-typed for forward-compat with other
|
||||
// bookmakers' id formats, so we do NOT pin a charset whitelist. We only reject the few
|
||||
// characters that would be dangerous if any current or future consumer ever builds a
|
||||
// file path, filename, or log line from the id — path separators, parent-dir traversal
|
||||
// ("..") and control/newline characters — plus a length cap against pathological input.
|
||||
if (value.Length > MaxLength)
|
||||
throw new ArgumentException($"EventId must be at most {MaxLength} characters.", nameof(value));
|
||||
|
||||
if (value.Contains('/') || value.Contains('\\') || value.Contains(".."))
|
||||
throw new ArgumentException(
|
||||
"EventId must not contain path separators or '..'.", nameof(value));
|
||||
|
||||
foreach (var ch in value)
|
||||
{
|
||||
if (char.IsControl(ch))
|
||||
throw new ArgumentException(
|
||||
"EventId must not contain control characters.", nameof(value));
|
||||
}
|
||||
|
||||
Value = value;
|
||||
}
|
||||
|
||||
|
||||
@@ -41,7 +41,23 @@
|
||||
"SuspensionGapSeconds": 60,
|
||||
"OddsFlipThreshold": 0.30,
|
||||
"MinSnapshotCount": 3,
|
||||
"DetectionIntervalSeconds": 60
|
||||
"DetectionIntervalSeconds": 60,
|
||||
"SteamMoveWindowSeconds": 120,
|
||||
"SteamMoveDriftThreshold": 0.20,
|
||||
"SuspensionFreezeThreshold": 0.05,
|
||||
"OverroundWindowSeconds": 120,
|
||||
"OverroundCompressionThreshold": 0.02
|
||||
},
|
||||
"Notifications": {
|
||||
"Enabled": false,
|
||||
"MinScore": 0.45,
|
||||
"PollIntervalSeconds": 60
|
||||
},
|
||||
"PaperTrading": {
|
||||
"Enabled": false,
|
||||
"MinScore": 0.55,
|
||||
"FlatStake": 10,
|
||||
"PollIntervalSeconds": 60
|
||||
},
|
||||
"Localization": {
|
||||
"DefaultCulture": "ru-RU"
|
||||
|
||||
@@ -8,17 +8,17 @@
|
||||
|
||||
<!-- Prevent flash of unthemed content -->
|
||||
<style>
|
||||
html, body { background: #f5f4ef; margin: 0; }
|
||||
html, body { background: #f3f1e9; margin: 0; }
|
||||
@media (prefers-color-scheme: dark) {
|
||||
html, body { background: #0c0a09; }
|
||||
html, body { background: #141310; }
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- Fonts: IBM Plex Sans / Serif / Mono + JetBrains Mono. Full Cyrillic coverage. -->
|
||||
<!-- Fonts: Oswald (display) + Manrope (body) + JetBrains Mono (numerals). Full Cyrillic coverage. -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@300;400;500;600;700&family=IBM+Plex+Serif:wght@300;400;500;600&family=IBM+Plex+Mono:wght@400;500&family=JetBrains+Mono:wght@400;500;600&display=swap"
|
||||
href="https://fonts.googleapis.com/css2?family=Oswald:wght@400;500;600;700&family=Manrope:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500;600;700;800&display=swap"
|
||||
rel="stylesheet" />
|
||||
|
||||
<link href="_content/MudBlazor/MudBlazor.min.css" rel="stylesheet" />
|
||||
@@ -26,13 +26,13 @@
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<div style="padding: 64px; font-family: 'IBM Plex Serif', Georgia, serif; color: #475569;">
|
||||
<span style="font-family: 'JetBrains Mono', monospace; font-size: 11px; letter-spacing: 0.18em; text-transform: uppercase; color: #d97706;">Booting</span>
|
||||
<div style="font-size: 32px; font-weight: 300; margin-top: 8px;">Marathon Odds Lab</div>
|
||||
<div style="padding: 64px; font-family: 'Manrope', system-ui, sans-serif; color: #6b6757;">
|
||||
<span style="font-family: 'JetBrains Mono', monospace; font-size: 11px; font-weight: 700; letter-spacing: 0.16em; text-transform: uppercase; color: #0a0a0a; background: #c6f400; border: 2px solid #0a0a0a; border-radius: 8px; padding: 4px 9px;">Booting</span>
|
||||
<div style="font-family: 'Oswald', sans-serif; font-size: 42px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.01em; margin-top: 14px; color: #0a0a0a;">Marathon Odds Lab</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="blazor-error-ui" style="display:none; position: fixed; bottom: 0; left: 0; right: 0; z-index: 1000; padding: 12px 24px; background: #dc2626; color: #fafaf7; font-family: 'IBM Plex Sans', sans-serif;">
|
||||
<div id="blazor-error-ui" style="display:none; position: fixed; bottom: 0; left: 0; right: 0; z-index: 1000; padding: 12px 24px; background: #ff3b30; color: #fffef8; font-family: 'Manrope', system-ui, sans-serif;">
|
||||
<span>An unhandled error has occurred.</span>
|
||||
<a href="" class="reload" style="color: #fff; text-decoration: underline; margin-left: 12px;">Reload</a>
|
||||
<a class="dismiss" style="float: right; cursor: pointer; padding: 0 8px;">×</a>
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
namespace Marathon.Infrastructure.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Options for outbound anomaly notifications, bound from the <c>Notifications</c>
|
||||
/// config section.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Disabled by default. <see cref="TelegramBotToken"/> and <see cref="TelegramChatId"/>
|
||||
/// are secrets — set them ONLY in <c>appsettings.Local.json</c> (gitignored) or an
|
||||
/// environment variable, never in the committed <c>appsettings.json</c>.
|
||||
/// </remarks>
|
||||
public sealed class NotificationOptions
|
||||
{
|
||||
public const string SectionName = "Notifications";
|
||||
|
||||
/// <summary>Master switch — when false, the dispatcher idles and nothing is sent.</summary>
|
||||
public bool Enabled { get; init; }
|
||||
|
||||
/// <summary>Telegram bot token (secret — Local.json / env only).</summary>
|
||||
public string? TelegramBotToken { get; init; }
|
||||
|
||||
/// <summary>Telegram chat id to deliver alerts to (secret — Local.json / env only).</summary>
|
||||
public string? TelegramChatId { get; init; }
|
||||
|
||||
/// <summary>Minimum anomaly score to alert on. Default: 0.45 (Medium severity).</summary>
|
||||
public decimal MinScore { get; init; } = 0.45m;
|
||||
|
||||
/// <summary>Seconds between dispatcher polls. Default: 60.</summary>
|
||||
public int PollIntervalSeconds { get; init; } = 60;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace Marathon.Infrastructure.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Options for the forward-test (paper-trading) worker, bound to the
|
||||
/// <c>PaperTrading</c> configuration section.
|
||||
/// </summary>
|
||||
public sealed class PaperTradingOptions
|
||||
{
|
||||
public const string SectionName = "PaperTrading";
|
||||
|
||||
/// <summary>Master switch. When false the worker idles (cheap re-check). Default false.</summary>
|
||||
public bool Enabled { get; init; }
|
||||
|
||||
/// <summary>Minimum anomaly score required to open a paper bet. Default 0.55.</summary>
|
||||
public decimal MinScore { get; init; } = 0.55m;
|
||||
|
||||
/// <summary>Flat stake placed on every paper bet (currency-agnostic units). Default 10.</summary>
|
||||
public decimal FlatStake { get; init; } = 10m;
|
||||
|
||||
/// <summary>Seconds between open/settle cycles. Floored at 5. Default 60.</summary>
|
||||
public int PollIntervalSeconds { get; init; } = 60;
|
||||
}
|
||||
@@ -25,9 +25,10 @@ internal sealed class ExcelExporter : IExcelExporter
|
||||
string outputPath,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
// Load all snapshots in the date range with their bets eagerly
|
||||
var fromStr = range.From.ToString("O");
|
||||
var toStr = range.To.ToString("O");
|
||||
// Load all snapshots in the date range with their bets eagerly. Bounds use the
|
||||
// shared SqliteDateText encoding so they match the persisted CapturedAt keys.
|
||||
var fromStr = SqliteDateText.Key(range.From);
|
||||
var toStr = SqliteDateText.Key(range.To);
|
||||
|
||||
var snapshotEntities = await _db.Snapshots.AsNoTracking()
|
||||
.Include(s => s.Bets)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
using Marathon.Application.Abstractions;
|
||||
using Marathon.Application.Configuration;
|
||||
using Marathon.Infrastructure.Configuration;
|
||||
using Marathon.Infrastructure.Notifications;
|
||||
using Marathon.Infrastructure.Persistence;
|
||||
using Marathon.Infrastructure.Scraping;
|
||||
using Marathon.Infrastructure.Workers;
|
||||
@@ -50,11 +52,30 @@ public static class InfrastructureModule
|
||||
.AddOptions<ScrapingThrottle>()
|
||||
.Bind(config.GetSection(ScrapingThrottle.SectionName));
|
||||
|
||||
services
|
||||
.AddOptions<NotificationOptions>()
|
||||
.Bind(config.GetSection(NotificationOptions.SectionName));
|
||||
|
||||
services
|
||||
.AddOptions<PaperTradingOptions>()
|
||||
.Bind(config.GetSection(PaperTradingOptions.SectionName));
|
||||
|
||||
services.AddHostedService<UpcomingEventsPoller>();
|
||||
services.AddHostedService<LiveOddsPoller>();
|
||||
services.AddHostedService<ResultsWatchListPoller>();
|
||||
services.AddHostedService<AnomalyDetectionPoller>();
|
||||
|
||||
// Outbound anomaly notifications (Telegram). Sink + dispatcher are always
|
||||
// registered; the dispatcher idles until Notifications:Enabled is true and
|
||||
// the sink no-ops until a bot token + chat id are configured.
|
||||
services.AddHttpClient(TelegramNotificationSink.HttpClientName, client =>
|
||||
client.Timeout = TimeSpan.FromSeconds(15));
|
||||
services.AddSingleton<INotificationSink, TelegramNotificationSink>();
|
||||
services.AddHostedService<AnomalyNotificationDispatcher>();
|
||||
|
||||
// Forward-test (paper-trading) engine. Idles until PaperTrading:Enabled is true.
|
||||
services.AddHostedService<PaperTradingWorker>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
|
||||
+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 Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
@@ -6,177 +7,430 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Marathon.Infrastructure.Migrations;
|
||||
|
||||
[DbContext(typeof(MarathonDbContext))]
|
||||
partial class MarathonDbContextModelSnapshot : ModelSnapshot
|
||||
namespace Marathon.Infrastructure.Migrations
|
||||
{
|
||||
protected override void BuildModel(ModelBuilder modelBuilder)
|
||||
[DbContext(typeof(MarathonDbContext))]
|
||||
partial class MarathonDbContextModelSnapshot : ModelSnapshot
|
||||
{
|
||||
protected override void BuildModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "8.0.12");
|
||||
modelBuilder.HasAnnotation("ProductVersion", "8.0.12");
|
||||
|
||||
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.AnomalyEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id").HasColumnType("TEXT");
|
||||
b.Property<string>("DetectedAt").IsRequired().HasColumnType("TEXT");
|
||||
b.Property<string>("EventCode").IsRequired().HasColumnType("TEXT");
|
||||
b.Property<string>("EvidenceJson").IsRequired().HasColumnType("TEXT");
|
||||
b.Property<int>("Kind").HasColumnType("INTEGER");
|
||||
b.Property<decimal>("Score").HasColumnType("TEXT");
|
||||
b.HasKey("Id");
|
||||
b.HasIndex("EventCode").HasDatabaseName("IX_Anomalies_EventCode");
|
||||
b.ToTable("Anomalies");
|
||||
});
|
||||
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.AnomalyEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.BetEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id").ValueGeneratedOnAdd().HasColumnType("INTEGER");
|
||||
b.Property<int?>("PeriodNumber").HasColumnType("INTEGER");
|
||||
b.Property<decimal>("Rate").HasColumnType("TEXT");
|
||||
b.Property<int>("Scope").HasColumnType("INTEGER");
|
||||
b.Property<int>("Side").HasColumnType("INTEGER");
|
||||
b.Property<long>("SnapshotId").HasColumnType("INTEGER");
|
||||
b.Property<int>("Type").HasColumnType("INTEGER");
|
||||
b.Property<decimal?>("Value").HasColumnType("TEXT");
|
||||
b.HasKey("Id");
|
||||
b.HasIndex("SnapshotId").HasDatabaseName("IX_Bets_SnapshotId");
|
||||
b.ToTable("Bets");
|
||||
});
|
||||
b.Property<string>("DetectedAt")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.EventEntity", b =>
|
||||
{
|
||||
b.Property<string>("EventCode").HasColumnType("TEXT");
|
||||
b.Property<string>("Category").IsRequired().HasDefaultValue("").HasColumnType("TEXT");
|
||||
b.Property<string>("CountryCode").IsRequired().HasColumnType("TEXT");
|
||||
b.Property<string>("EventPath").HasColumnType("TEXT");
|
||||
b.Property<string>("LeagueId").IsRequired().HasColumnType("TEXT");
|
||||
b.Property<string>("ScheduledAt").IsRequired().HasColumnType("TEXT");
|
||||
b.Property<string>("Side1Name").IsRequired().HasColumnType("TEXT");
|
||||
b.Property<string>("Side2Name").IsRequired().HasColumnType("TEXT");
|
||||
b.Property<int>("SportCode").HasColumnType("INTEGER");
|
||||
b.HasKey("EventCode");
|
||||
b.HasIndex(new[] { "SportCode", "ScheduledAt" }).HasDatabaseName("IX_Events_SportCode_ScheduledAt");
|
||||
b.HasIndex("ScheduledAt").HasDatabaseName("IX_Events_ScheduledAt");
|
||||
b.ToTable("Events");
|
||||
});
|
||||
b.Property<string>("EventCode")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.EventResultEntity", b =>
|
||||
{
|
||||
b.Property<string>("EventCode").HasColumnType("TEXT");
|
||||
b.Property<string>("CompletedAt").IsRequired().HasColumnType("TEXT");
|
||||
b.Property<int>("Side1Score").HasColumnType("INTEGER");
|
||||
b.Property<int>("Side2Score").HasColumnType("INTEGER");
|
||||
b.Property<int>("WinnerSide").HasColumnType("INTEGER");
|
||||
b.HasKey("EventCode");
|
||||
b.ToTable("EventResults");
|
||||
});
|
||||
b.Property<string>("EvidenceJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.LeagueEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id").HasColumnType("TEXT");
|
||||
b.Property<string>("Category").IsRequired().HasDefaultValue("").HasColumnType("TEXT");
|
||||
b.Property<string>("Country").IsRequired().HasColumnType("TEXT");
|
||||
b.Property<string>("NameEn").IsRequired().HasColumnType("TEXT");
|
||||
b.Property<string>("NameRu").IsRequired().HasColumnType("TEXT");
|
||||
b.Property<int>("SportCode").HasColumnType("INTEGER");
|
||||
b.HasKey("Id");
|
||||
b.ToTable("Leagues");
|
||||
});
|
||||
b.Property<int>("Kind")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.SnapshotEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id").ValueGeneratedOnAdd().HasColumnType("INTEGER");
|
||||
b.Property<string>("CapturedAt").IsRequired().HasColumnType("TEXT");
|
||||
b.Property<string>("EventCode").IsRequired().HasColumnType("TEXT");
|
||||
b.Property<int>("Source").HasColumnType("INTEGER");
|
||||
b.HasKey("Id");
|
||||
b.HasIndex("EventCode").HasDatabaseName("IX_Snapshots_EventCode");
|
||||
b.HasIndex("EventCode", "CapturedAt").HasDatabaseName("IX_Snapshots_EventCode_CapturedAt");
|
||||
b.HasIndex("EventCode", "Source", "CapturedAt").HasDatabaseName("IX_Snapshots_EventCode_Source_CapturedAt");
|
||||
b.ToTable("Snapshots");
|
||||
});
|
||||
b.Property<decimal>("Score")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.SportEntity", b =>
|
||||
{
|
||||
b.Property<int>("Code").HasColumnType("INTEGER");
|
||||
b.Property<string>("NameEn").IsRequired().HasColumnType("TEXT");
|
||||
b.Property<string>("NameRu").IsRequired().HasColumnType("TEXT");
|
||||
b.HasKey("Code");
|
||||
b.ToTable("Sports");
|
||||
});
|
||||
b.HasKey("Id");
|
||||
|
||||
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.PlacedBetEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id").HasColumnType("TEXT");
|
||||
b.Property<string>("EventCode").IsRequired().HasColumnType("TEXT");
|
||||
b.Property<int>("Scope").HasColumnType("INTEGER");
|
||||
b.Property<int?>("PeriodNumber").HasColumnType("INTEGER");
|
||||
b.Property<int>("Type").HasColumnType("INTEGER");
|
||||
b.Property<int>("Side").HasColumnType("INTEGER");
|
||||
b.Property<decimal?>("Value").HasColumnType("TEXT");
|
||||
b.Property<decimal>("Rate").HasColumnType("TEXT");
|
||||
b.Property<decimal>("Stake").HasColumnType("TEXT");
|
||||
b.Property<string>("PlacedAt").IsRequired().HasColumnType("TEXT");
|
||||
b.Property<int>("Outcome").HasColumnType("INTEGER");
|
||||
b.Property<string>("Notes").HasColumnType("TEXT");
|
||||
b.HasKey("Id");
|
||||
b.HasIndex("EventCode").HasDatabaseName("IX_PlacedBets_EventCode");
|
||||
b.HasIndex("Outcome").HasDatabaseName("IX_PlacedBets_Outcome");
|
||||
b.ToTable("PlacedBets");
|
||||
});
|
||||
b.HasIndex("EventCode")
|
||||
.HasDatabaseName("IX_Anomalies_EventCode");
|
||||
|
||||
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.AnomalyEntity", b =>
|
||||
{
|
||||
b.HasOne("Marathon.Infrastructure.Persistence.Entities.EventEntity", "Event")
|
||||
.WithMany("Anomalies")
|
||||
.HasForeignKey("EventCode")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
b.Navigation("Event");
|
||||
});
|
||||
b.ToTable("Anomalies", (string)null);
|
||||
});
|
||||
|
||||
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.BetEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
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");
|
||||
});
|
||||
b.Property<int?>("PeriodNumber")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.SnapshotEntity", b =>
|
||||
{
|
||||
b.HasOne("Marathon.Infrastructure.Persistence.Entities.EventEntity", "Event")
|
||||
.WithMany("Snapshots")
|
||||
.HasForeignKey("EventCode")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
b.Navigation("Event");
|
||||
});
|
||||
b.Property<decimal>("Rate")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.EventEntity", b =>
|
||||
{
|
||||
b.Navigation("Anomalies");
|
||||
b.Navigation("Result");
|
||||
b.Navigation("Snapshots");
|
||||
});
|
||||
b.Property<int>("Scope")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.SnapshotEntity", b =>
|
||||
{
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 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");
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,11 @@ internal sealed class SportConfiguration : IEntityTypeConfiguration<SportEntity>
|
||||
builder.ToTable("Sports");
|
||||
|
||||
builder.HasKey(s => s.Code);
|
||||
builder.Property(s => s.Code).HasColumnType("INTEGER").IsRequired();
|
||||
// Code is the bookmaker's canonical sport id (6 = Basketball, 11 = Football,
|
||||
// 22723 = Tennis, …), a natural key — never an auto-incremented surrogate.
|
||||
// Without this, EF's int-PK convention treats it as ValueGeneratedOnAdd and
|
||||
// tries to alter the column to AUTOINCREMENT on the next migration.
|
||||
builder.Property(s => s.Code).HasColumnType("INTEGER").ValueGeneratedNever().IsRequired();
|
||||
builder.Property(s => s.NameRu).HasColumnType("TEXT").IsRequired();
|
||||
builder.Property(s => s.NameEn).HasColumnType("TEXT").IsRequired();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
namespace Marathon.Infrastructure.Persistence.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// EF Core persistence entity for a <see cref="Marathon.Domain.Entities.PaperBet"/> —
|
||||
/// the system-generated forward-test ledger. Decimals are stored as TEXT (invariant
|
||||
/// round-trip) to match the rest of the schema.
|
||||
/// </summary>
|
||||
public sealed class PaperBetEntity
|
||||
{
|
||||
/// <summary>GUID primary key stored as TEXT.</summary>
|
||||
public string Id { get; set; } = default!;
|
||||
|
||||
/// <summary>The anomaly that triggered this paper bet (unique — one bet per anomaly).</summary>
|
||||
public string AnomalyId { get; set; } = default!;
|
||||
|
||||
/// <summary>The event the bet is on.</summary>
|
||||
public string EventCode { get; set; } = default!;
|
||||
|
||||
/// <summary>Picked Side as int (Side1 / Side2 / Draw).</summary>
|
||||
public int PickedSide { get; set; }
|
||||
|
||||
/// <summary>Decimal odds locked in at the moment the signal fired.</summary>
|
||||
public decimal Rate { get; set; }
|
||||
|
||||
/// <summary>Flat stake.</summary>
|
||||
public decimal Stake { get; set; }
|
||||
|
||||
/// <summary>ISO 8601 timestamp of the originating anomaly's detection (Moscow time).</summary>
|
||||
public string OpenedAt { get; set; } = default!;
|
||||
|
||||
/// <summary>BetOutcome as int (Pending = open / Won / Lost / Void).</summary>
|
||||
public int Outcome { get; set; }
|
||||
|
||||
/// <summary>ISO 8601 settlement timestamp, or null while open.</summary>
|
||||
public string? SettledAt { get; set; }
|
||||
|
||||
/// <summary>Realised payout once settled (stake × rate on a win, 0 on a loss), else null.</summary>
|
||||
public decimal? Payout { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,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,3 +1,4 @@
|
||||
using Marathon.Domain.Backtesting;
|
||||
using Marathon.Domain.Entities;
|
||||
using Marathon.Domain.Enums;
|
||||
using Marathon.Domain.ValueObjects;
|
||||
@@ -222,4 +223,63 @@ internal static class Mapping
|
||||
NameRu: entity.NameRu,
|
||||
NameEn: entity.NameEn,
|
||||
Category: entity.Category);
|
||||
|
||||
// ─── SavedStrategy ─────────────────────────────────────────────────────────
|
||||
|
||||
public static SavedStrategyEntity ToEntity(SavedStrategy domain) =>
|
||||
new()
|
||||
{
|
||||
Id = domain.Id.ToString(),
|
||||
Name = domain.Name,
|
||||
StartingBankroll = domain.Strategy.StartingBankroll,
|
||||
MinScore = domain.Strategy.MinScore,
|
||||
StakeRule = (int)domain.Strategy.StakeRule,
|
||||
FlatStake = domain.Strategy.FlatStake,
|
||||
PercentOfBankroll = domain.Strategy.PercentOfBankroll,
|
||||
KellyFraction = domain.Strategy.KellyFraction,
|
||||
CreatedAt = SqliteDateText.Key(domain.CreatedAt),
|
||||
};
|
||||
|
||||
public static SavedStrategy ToDomain(SavedStrategyEntity entity) =>
|
||||
new(
|
||||
Id: Guid.Parse(entity.Id),
|
||||
Name: entity.Name,
|
||||
Strategy: new BacktestStrategy(
|
||||
StartingBankroll: entity.StartingBankroll,
|
||||
MinScore: entity.MinScore,
|
||||
StakeRule: (StakeRule)entity.StakeRule,
|
||||
FlatStake: entity.FlatStake,
|
||||
PercentOfBankroll: entity.PercentOfBankroll,
|
||||
KellyFraction: entity.KellyFraction),
|
||||
CreatedAt: SqliteDateText.Parse(entity.CreatedAt));
|
||||
|
||||
// ─── PaperBet ──────────────────────────────────────────────────────────────
|
||||
|
||||
public static PaperBetEntity ToEntity(PaperBet domain) =>
|
||||
new()
|
||||
{
|
||||
Id = domain.Id.ToString(),
|
||||
AnomalyId = domain.AnomalyId.ToString(),
|
||||
EventCode = domain.EventId.Value,
|
||||
PickedSide = (int)domain.PickedSide,
|
||||
Rate = domain.Rate,
|
||||
Stake = domain.Stake,
|
||||
OpenedAt = SqliteDateText.Key(domain.OpenedAt),
|
||||
Outcome = (int)domain.Outcome,
|
||||
SettledAt = domain.SettledAt is { } s ? SqliteDateText.Key(s) : null,
|
||||
Payout = domain.Payout,
|
||||
};
|
||||
|
||||
public static PaperBet ToDomain(PaperBetEntity entity) =>
|
||||
new(
|
||||
Id: Guid.Parse(entity.Id),
|
||||
AnomalyId: Guid.Parse(entity.AnomalyId),
|
||||
EventId: new EventId(entity.EventCode),
|
||||
PickedSide: (Side)entity.PickedSide,
|
||||
Rate: entity.Rate,
|
||||
Stake: entity.Stake,
|
||||
OpenedAt: SqliteDateText.Parse(entity.OpenedAt),
|
||||
Outcome: (BetOutcome)entity.Outcome,
|
||||
SettledAt: entity.SettledAt is { } s ? SqliteDateText.Parse(s) : null,
|
||||
Payout: entity.Payout);
|
||||
}
|
||||
|
||||
@@ -19,6 +19,8 @@ public sealed class MarathonDbContext : DbContext
|
||||
public DbSet<SportEntity> Sports => Set<SportEntity>();
|
||||
public DbSet<LeagueEntity> Leagues => Set<LeagueEntity>();
|
||||
public DbSet<PlacedBetEntity> PlacedBets => Set<PlacedBetEntity>();
|
||||
public DbSet<SavedStrategyEntity> SavedStrategies => Set<SavedStrategyEntity>();
|
||||
public DbSet<PaperBetEntity> PaperBets => Set<PaperBetEntity>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
|
||||
@@ -54,6 +54,8 @@ public static class PersistenceModule
|
||||
services.AddScoped<IResultRepository, ResultRepository>();
|
||||
services.AddScoped<IAnomalyRepository, AnomalyRepository>();
|
||||
services.AddScoped<IPlacedBetRepository, PlacedBetRepository>();
|
||||
services.AddScoped<ISavedStrategyRepository, SavedStrategyRepository>();
|
||||
services.AddScoped<IPaperBetRepository, PaperBetRepository>();
|
||||
services.AddScoped<IExcelExporter, ExcelExporter>();
|
||||
|
||||
return services;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -40,10 +40,10 @@ internal sealed class PlacedBetRepository : IPlacedBetRepository
|
||||
|
||||
public async Task<IReadOnlyList<PlacedBet>> ListByDateRangeAsync(DateRange range, CancellationToken ct = default)
|
||||
{
|
||||
// PlacedAt is stored as ISO 8601 TEXT — same lexical-equals-chronological ordering
|
||||
// trick used in EventRepository.ListByDateRangeAsync.
|
||||
var fromStr = range.From.ToString("O");
|
||||
var toStr = range.To.ToString("O");
|
||||
// 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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -27,6 +27,18 @@ internal sealed class SnapshotRepository : ISnapshotRepository
|
||||
.CountAsync(ct);
|
||||
}
|
||||
|
||||
public async Task<DateTimeOffset?> GetLatestCapturedAtAsync(CancellationToken ct = default)
|
||||
{
|
||||
// O-format TEXT sorts lexically == chronologically (see SqliteDateText), so the
|
||||
// max CapturedAt is the most recent capture. ORDER BY + LIMIT 1 pushed to SQLite.
|
||||
var latest = await _db.Snapshots.AsNoTracking()
|
||||
.OrderByDescending(s => s.CapturedAt)
|
||||
.Select(s => s.CapturedAt)
|
||||
.FirstOrDefaultAsync(ct);
|
||||
|
||||
return latest is null ? null : SqliteDateText.Parse(latest);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<OddsSnapshot>> ListByEventAsync(
|
||||
EventId eventId,
|
||||
DateTimeOffset from,
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -77,7 +77,7 @@
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.m-anomaly-card--high { border-left-color: var(--m-c-anomaly); }
|
||||
.m-anomaly-card--medium { border-left-color: var(--m-c-accent); }
|
||||
.m-anomaly-card--medium { border-left-color: var(--m-c-warning); }
|
||||
.m-anomaly-card--low { border-left-color: var(--m-c-ink-soft); }
|
||||
|
||||
.m-anomaly-card__head {
|
||||
@@ -132,7 +132,7 @@
|
||||
color: var(--m-c-ink-soft);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.m-anomaly-card__rate-arrow { color: var(--m-c-accent); font-size: 0.875rem; }
|
||||
.m-anomaly-card__rate-arrow { color: var(--m-c-ink-soft); font-size: 0.875rem; }
|
||||
.m-anomaly-card__rate-post {
|
||||
color: var(--m-c-ink);
|
||||
font-weight: 600;
|
||||
@@ -211,9 +211,11 @@
|
||||
|
||||
private string KindLabel(AnomalyKind kind) => kind switch
|
||||
{
|
||||
AnomalyKind.SuspensionFlip => L["Anomaly.Kind.SuspensionFlip"],
|
||||
AnomalyKind.SteamMove => L["Anomaly.Kind.SteamMove"],
|
||||
_ => kind.ToString(),
|
||||
AnomalyKind.SuspensionFlip => L["Anomaly.Kind.SuspensionFlip"],
|
||||
AnomalyKind.SteamMove => L["Anomaly.Kind.SteamMove"],
|
||||
AnomalyKind.SuspensionFreeze => L["Anomaly.Kind.SuspensionFreeze"],
|
||||
AnomalyKind.OverroundCompression => L["Anomaly.Kind.OverroundCompression"],
|
||||
_ => kind.ToString(),
|
||||
};
|
||||
|
||||
private string SportLabel(int code) => SportLabels.Resolve(L, code);
|
||||
|
||||
@@ -143,7 +143,7 @@
|
||||
justify-content: center;
|
||||
font-family: var(--m-font-display);
|
||||
font-size: 1.75rem;
|
||||
color: var(--m-c-accent);
|
||||
color: var(--m-c-info);
|
||||
}
|
||||
.m-evidence__row {
|
||||
display: grid;
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
@implements IDisposable
|
||||
@using Microsoft.Extensions.DependencyInjection
|
||||
@inject IStringLocalizer<SharedResource> L
|
||||
@inject AnomalyBrowsingState AnomalyState
|
||||
@inject IServiceScopeFactory ScopeFactory
|
||||
@inject ILogger<NavBody> Logger
|
||||
|
||||
<nav class="m-nav" aria-label="primary">
|
||||
<div style="padding: var(--m-space-5) var(--m-space-4) var(--m-space-3); border-bottom: 1px solid rgba(231,229,228,0.10);">
|
||||
<div style="font-family: var(--m-font-display); font-size: 1.25rem; color: #fafaf7;">
|
||||
<span style="color: var(--m-c-accent);">M</span>arathon
|
||||
<div style="padding: var(--m-space-5) var(--m-space-4) var(--m-space-4); border-bottom: 2px solid var(--m-c-ink);">
|
||||
<div style="font-family: var(--m-font-display); font-weight: 700; font-size: 1.5rem; text-transform: uppercase; letter-spacing: 0.01em; color: var(--m-c-ink);">
|
||||
<span style="background: var(--m-c-accent); color: var(--m-c-on-accent); padding: 0 5px; border-radius: 5px;">M</span>arathon
|
||||
</div>
|
||||
<div style="font-family: var(--m-font-mono); font-size: 0.6875rem; letter-spacing: 0.18em; text-transform: uppercase; color: rgba(231,229,228,0.55); margin-top: 4px;">
|
||||
<div style="font-family: var(--m-font-mono); font-size: 0.625rem; font-weight: 700; letter-spacing: 0.16em; text-transform: uppercase; color: var(--m-c-ink-soft); margin-top: 4px;">
|
||||
Odds Lab · v0.1
|
||||
</div>
|
||||
</div>
|
||||
@@ -51,12 +54,24 @@
|
||||
<MudIcon Icon="@Icons.Material.Outlined.QueryStats" Size="Size.Small" />
|
||||
<span>@L["Nav.Backtest"]</span>
|
||||
</NavLink>
|
||||
<NavLink class="m-nav__link" href="anomalies/compare">
|
||||
<MudIcon Icon="@Icons.Material.Outlined.Leaderboard" Size="Size.Small" />
|
||||
<span>@L["Nav.Compare"]</span>
|
||||
</NavLink>
|
||||
<NavLink class="m-nav__link" href="paper-trading">
|
||||
<MudIcon Icon="@Icons.Material.Outlined.Science" Size="Size.Small" />
|
||||
<span>@L["Nav.PaperTrading"]</span>
|
||||
</NavLink>
|
||||
|
||||
<div class="m-nav__group" style="margin-top: var(--m-space-5);">@L["Nav.Section.System"]</div>
|
||||
<NavLink class="m-nav__link" href="export">
|
||||
<MudIcon Icon="@Icons.Material.Outlined.FileDownload" Size="Size.Small" />
|
||||
<span>@L["Nav.Export"]</span>
|
||||
</NavLink>
|
||||
<NavLink class="m-nav__link" href="health">
|
||||
<MudIcon Icon="@Icons.Material.Outlined.MonitorHeart" Size="Size.Small" />
|
||||
<span>@L["Nav.Health"]</span>
|
||||
</NavLink>
|
||||
<NavLink class="m-nav__link" href="settings">
|
||||
<MudIcon Icon="@Icons.Material.Outlined.Tune" Size="Size.Small" />
|
||||
<span>@L["Nav.Settings"]</span>
|
||||
@@ -66,18 +81,20 @@
|
||||
<style>
|
||||
.m-nav__badge {
|
||||
margin-left: auto;
|
||||
min-width: 18px;
|
||||
box-sizing: border-box;
|
||||
min-width: 20px;
|
||||
padding: 0 6px;
|
||||
background: var(--m-c-anomaly);
|
||||
color: #ffffff;
|
||||
font-family: var(--m-font-mono);
|
||||
font-size: 0.625rem;
|
||||
font-weight: 600;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.05em;
|
||||
line-height: 18px;
|
||||
height: 18px;
|
||||
line-height: 16px;
|
||||
height: 20px;
|
||||
text-align: center;
|
||||
border-radius: var(--m-radius-xs);
|
||||
border: 2px solid var(--m-c-ink);
|
||||
border-radius: var(--m-radius-sm);
|
||||
animation: m-pulse 1.6s ease-in-out infinite;
|
||||
}
|
||||
@@media (prefers-reduced-motion: reduce) {
|
||||
@@ -86,9 +103,56 @@
|
||||
</style>
|
||||
|
||||
@code {
|
||||
// Keep the unread badge live without a visit to the feed. A short poll is fine:
|
||||
// the count is a cheap server-side COUNT(*) over an indexed timestamp.
|
||||
private static readonly TimeSpan PollInterval = TimeSpan.FromSeconds(15);
|
||||
private readonly CancellationTokenSource _cts = new();
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
AnomalyState.OnChange += OnAnomalyStateChanged;
|
||||
_ = PollUnreadAsync(_cts.Token);
|
||||
}
|
||||
|
||||
private async Task PollUnreadAsync(CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Refresh once immediately so the badge is correct at startup too,
|
||||
// then keep it fresh on the timer.
|
||||
await RefreshUnreadAsync(ct).ConfigureAwait(false);
|
||||
|
||||
using var timer = new PeriodicTimer(PollInterval);
|
||||
while (await timer.WaitForNextTickAsync(ct).ConfigureAwait(false))
|
||||
{
|
||||
await RefreshUnreadAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Component disposed — stop polling.
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RefreshUnreadAsync(CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Fresh DI scope per tick so we never touch a page's scoped DbContext
|
||||
// concurrently from this background loop.
|
||||
await using var scope = ScopeFactory.CreateAsyncScope();
|
||||
var anomalies = scope.ServiceProvider.GetRequiredService<IAnomalyBrowsingService>();
|
||||
var count = await anomalies.GetUnreadCountAsync(AnomalyState.LastSeenUtc, ct).ConfigureAwait(false);
|
||||
AnomalyState.SetUnreadCount(count); // no-op (and no re-render) when unchanged
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw; // bubble cancellation so the poll loop exits
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogDebug(ex, "NavBody: failed to refresh unread anomaly count; retrying next tick.");
|
||||
}
|
||||
}
|
||||
|
||||
private void OnAnomalyStateChanged() => InvokeAsync(StateHasChanged);
|
||||
@@ -96,5 +160,7 @@
|
||||
public void Dispose()
|
||||
{
|
||||
AnomalyState.OnChange -= OnAnomalyStateChanged;
|
||||
_cts.Cancel();
|
||||
_cts.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,7 +60,7 @@
|
||||
color: var(--m-c-ink-soft);
|
||||
transition: color 220ms ease;
|
||||
}
|
||||
.m-odds.is-up .m-odds__delta { color: var(--m-c-accent); }
|
||||
.m-odds.is-up .m-odds__delta { color: var(--m-c-positive); }
|
||||
.m-odds.is-down .m-odds__delta { color: var(--m-c-anomaly); }
|
||||
.m-odds.is-flat .m-odds__delta { color: var(--m-c-ink-soft); }
|
||||
|
||||
|
||||
@@ -152,9 +152,9 @@
|
||||
var times = points.Select(p => (object)p.At.UtcDateTime).ToList();
|
||||
return new List<ITrace>
|
||||
{
|
||||
BuildSeries("Win 1", "#0f172a", points.Select(p => (object?)p.Win1Rate).ToList(), times),
|
||||
BuildSeries("Draw", "#d97706", points.Select(p => (object?)p.DrawRate).ToList(), times),
|
||||
BuildSeries("Win 2", "#dc2626", points.Select(p => (object?)p.Win2Rate).ToList(), times),
|
||||
BuildSeries("Win 1", "#244bff", points.Select(p => (object?)p.Win1Rate).ToList(), times),
|
||||
BuildSeries("Draw", "#ff8a00", points.Select(p => (object?)p.DrawRate).ToList(), times),
|
||||
BuildSeries("Win 2", "#ff3b30", points.Select(p => (object?)p.Win2Rate).ToList(), times),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -178,13 +178,13 @@
|
||||
{
|
||||
AutoSize = true,
|
||||
Margin = new Plotly.Blazor.LayoutLib.Margin { L = 56, R = 24, T = 24, B = 48 },
|
||||
PaperBgColor = dark ? "#1c1917" : "#fafaf7",
|
||||
PlotBgColor = dark ? "#0c0a09" : "#fafaf7",
|
||||
PaperBgColor = dark ? "#1e1c15" : "#fffef8",
|
||||
PlotBgColor = dark ? "#141310" : "#fffef8",
|
||||
Font = new Plotly.Blazor.LayoutLib.Font
|
||||
{
|
||||
Family = "JetBrains Mono, IBM Plex Mono, Consolas, monospace",
|
||||
Size = 11,
|
||||
Color = dark ? "#f5f5f4" : "#0f172a",
|
||||
Color = dark ? "#f5f3ea" : "#0a0a0a",
|
||||
},
|
||||
XAxis = new List<XAxis>
|
||||
{
|
||||
@@ -192,14 +192,14 @@
|
||||
{
|
||||
Title = new Plotly.Blazor.LayoutLib.XAxisLib.Title { Text = string.Empty },
|
||||
ShowGrid = true,
|
||||
GridColor = dark ? "#292524" : "#e7e5e4",
|
||||
GridColor = dark ? "#2a2820" : "#e7e3d6",
|
||||
ZeroLine = false,
|
||||
LineColor = dark ? "#292524" : "#e7e5e4",
|
||||
LineColor = dark ? "#2a2820" : "#e7e3d6",
|
||||
TickFont = new Plotly.Blazor.LayoutLib.XAxisLib.TickFont
|
||||
{
|
||||
Family = "JetBrains Mono, IBM Plex Mono, Consolas, monospace",
|
||||
Size = 10,
|
||||
Color = dark ? "#a8a29e" : "#475569",
|
||||
Color = dark ? "#9c9784" : "#6b6757",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -209,14 +209,14 @@
|
||||
{
|
||||
Title = new Plotly.Blazor.LayoutLib.YAxisLib.Title { Text = string.Empty },
|
||||
ShowGrid = true,
|
||||
GridColor = dark ? "#292524" : "#e7e5e4",
|
||||
GridColor = dark ? "#2a2820" : "#e7e3d6",
|
||||
ZeroLine = false,
|
||||
LineColor = dark ? "#292524" : "#e7e5e4",
|
||||
LineColor = dark ? "#2a2820" : "#e7e3d6",
|
||||
TickFont = new Plotly.Blazor.LayoutLib.YAxisLib.TickFont
|
||||
{
|
||||
Family = "JetBrains Mono, IBM Plex Mono, Consolas, monospace",
|
||||
Size = 10,
|
||||
Color = dark ? "#a8a29e" : "#475569",
|
||||
Color = dark ? "#9c9784" : "#6b6757",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -232,7 +232,7 @@
|
||||
{
|
||||
Family = "JetBrains Mono, IBM Plex Mono, Consolas, monospace",
|
||||
Size = 11,
|
||||
Color = dark ? "#e7e5e4" : "#1e293b",
|
||||
Color = dark ? "#d8d4c6" : "#26241e",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
SeverityBadge — small uppercase pill encoding an anomaly's severity bucket.
|
||||
|
||||
The High variant is signal-red (`--m-c-anomaly`) and pulses to draw the eye
|
||||
on the feed page. Medium uses the editorial amber accent. Low is a muted
|
||||
on the feed page. Medium uses an amber warning tone. Low is a muted
|
||||
neutral so it does not compete with higher severities.
|
||||
|
||||
The component is presentational only — callers compute the severity (via
|
||||
@@ -56,8 +56,8 @@
|
||||
background: color-mix(in srgb, var(--m-c-ink-soft) 8%, transparent);
|
||||
}
|
||||
.m-severity--medium {
|
||||
color: var(--m-c-accent);
|
||||
background: color-mix(in srgb, var(--m-c-accent) 12%, transparent);
|
||||
color: var(--m-c-warning);
|
||||
background: color-mix(in srgb, var(--m-c-warning) 12%, transparent);
|
||||
}
|
||||
.m-severity--high {
|
||||
color: var(--m-c-anomaly);
|
||||
|
||||
@@ -20,10 +20,10 @@
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.m-sport svg { width: 100%; height: 100%; display: block; }
|
||||
.m-sport[data-sport="6"] { color: #d97706; }
|
||||
.m-sport[data-sport="11"] { color: #15803d; }
|
||||
.m-sport[data-sport="22723"] { color: #0369a1; }
|
||||
.m-sport[data-sport="43658"] { color: #6d28d9; }
|
||||
.m-sport[data-sport="6"] { color: #ff8a00; } /* basketball — amber */
|
||||
.m-sport[data-sport="11"] { color: #1f9e3d; } /* football — green */
|
||||
.m-sport[data-sport="22723"] { color: #0d9488; } /* tennis — teal */
|
||||
.m-sport[data-sport="43658"] { color: #244bff; } /* hockey — electric blue */
|
||||
[data-theme="dark"] .m-sport { filter: brightness(1.1); }
|
||||
</style>
|
||||
|
||||
|
||||
@@ -27,8 +27,8 @@
|
||||
<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>
|
||||
style="display:inline-flex; align-items:center; gap:7px; font-family:var(--m-font-mono); font-size:0.6875rem; font-weight:700; text-transform:uppercase; letter-spacing:0.1em; padding:6px 10px; border-radius:8px; border:2px solid @(Capturing ? "#0a0a0a" : "rgba(255,254,248,0.4)"); background:@(Capturing ? "var(--m-c-accent)" : "transparent"); color:@(Capturing ? "var(--m-c-on-accent)" : "rgba(255,254,248,0.72)");">
|
||||
<span style="width:8px; height:8px; border-radius:50%; background:@(Capturing ? "#0a0a0a" : "rgba(255,254,248,0.72)");"></span>
|
||||
@(Capturing ? L["Scraping.On"] : L["Scraping.Off"])
|
||||
</span>
|
||||
<LocaleSwitcher />
|
||||
@@ -43,7 +43,7 @@
|
||||
ClipMode="DrawerClipMode.Always"
|
||||
Elevation="0"
|
||||
Width="248px"
|
||||
Color="Color.Dark">
|
||||
Color="Color.Surface">
|
||||
<NavBody />
|
||||
</MudDrawer>
|
||||
|
||||
@@ -58,7 +58,7 @@
|
||||
<footer class="m-footer">
|
||||
<span class="m-kicker">Marathon Odds Lab</span>
|
||||
<span style="font-family: var(--m-font-mono); font-size: 0.6875rem; color: var(--m-c-ink-soft); letter-spacing: 0.16em; text-transform: uppercase;">
|
||||
Phase 5 · Editorial-Quant · v0.1
|
||||
Velocity · v0.1
|
||||
</span>
|
||||
</footer>
|
||||
</div>
|
||||
@@ -76,8 +76,9 @@
|
||||
align-items: center;
|
||||
gap: var(--m-space-3);
|
||||
padding: 0 clamp(var(--m-space-3), 2vw, var(--m-space-5));
|
||||
border-bottom: 1px solid var(--m-c-rule);
|
||||
background: var(--m-c-paper);
|
||||
border-bottom: 2px solid var(--m-c-accent);
|
||||
background: #0a0a0a;
|
||||
color: #fffef8;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
@@ -116,15 +117,12 @@
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 clamp(var(--m-space-3), 2vw, var(--m-space-5));
|
||||
border-top: 1px solid var(--m-c-rule);
|
||||
border-top: 2px solid var(--m-c-ink);
|
||||
background: var(--m-c-paper);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .m-appbar,
|
||||
[data-theme="dark"] .m-footer {
|
||||
background: var(--m-c-paper-2);
|
||||
border-color: var(--m-c-rule);
|
||||
}
|
||||
/* The appbar is a constant black bar in both themes (set above); only the
|
||||
footer follows the paper/charcoal surface token. */
|
||||
</style>
|
||||
|
||||
@code {
|
||||
|
||||
@@ -82,6 +82,40 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="m-list-toolbar__row m-list-toolbar__chips">
|
||||
<span class="m-list-toolbar__label">@L["Anomaly.Filter.Kind"]</span>
|
||||
@foreach (var kind in _kindOptions)
|
||||
{
|
||||
var localKind = kind;
|
||||
var active = _filter.Kinds is { Count: > 0 } ks && ks.Contains(localKind);
|
||||
<button type="button"
|
||||
class="m-chip @(active ? "is-active" : null)"
|
||||
aria-pressed="@active"
|
||||
data-test="kind-chip"
|
||||
data-kind="@localKind"
|
||||
@onclick="() => ToggleKind(localKind)">
|
||||
@KindLabel(localKind)
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="m-list-toolbar__row m-list-toolbar__chips">
|
||||
<span class="m-list-toolbar__label">@L["Anomaly.Filter.Sort"]</span>
|
||||
@foreach (var sort in _sortOptions)
|
||||
{
|
||||
var localSort = sort;
|
||||
var active = _filter.Sort == localSort;
|
||||
<button type="button"
|
||||
class="m-chip @(active ? "is-active" : null)"
|
||||
aria-pressed="@active"
|
||||
data-test="sort-chip"
|
||||
data-sort="@localSort"
|
||||
@onclick="() => SetSort(localSort)">
|
||||
@SortLabel(localSort)
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="m-list-toolbar__row">
|
||||
<div class="m-list-toolbar__group">
|
||||
<label class="m-list-toolbar__label">@L["Anomaly.Filter.From"]</label>
|
||||
@@ -166,7 +200,7 @@
|
||||
font-feature-settings: var(--m-num-feature);
|
||||
}
|
||||
.m-anomaly-feed__stat--high dd { color: var(--m-c-anomaly); }
|
||||
.m-anomaly-feed__stat--medium dd { color: var(--m-c-accent); }
|
||||
.m-anomaly-feed__stat--medium dd { color: var(--m-c-warning); }
|
||||
|
||||
.m-anomaly-feed__list {
|
||||
display: grid;
|
||||
@@ -178,6 +212,12 @@
|
||||
private static readonly AnomalySeverity[] _severityOptions =
|
||||
{ AnomalySeverity.Low, AnomalySeverity.Medium, AnomalySeverity.High };
|
||||
|
||||
private static readonly AnomalyKind[] _kindOptions =
|
||||
{ AnomalyKind.SuspensionFlip, AnomalyKind.SteamMove, AnomalyKind.SuspensionFreeze, AnomalyKind.OverroundCompression };
|
||||
|
||||
private static readonly AnomalySort[] _sortOptions =
|
||||
{ AnomalySort.Newest, AnomalySort.HighestScore, AnomalySort.LongestGap };
|
||||
|
||||
private List<AnomalyListItem> _items = new();
|
||||
private IReadOnlyList<int> _availableSports = Array.Empty<int>();
|
||||
private bool _loading = true;
|
||||
@@ -251,6 +291,32 @@
|
||||
return UpdateFilter(_filter with { SportCodes = existing.Count == 0 ? null : existing });
|
||||
}
|
||||
|
||||
private Task ToggleKind(AnomalyKind kind)
|
||||
{
|
||||
var existing = _filter.Kinds?.ToList() ?? new List<AnomalyKind>();
|
||||
if (!existing.Remove(kind)) existing.Add(kind);
|
||||
return UpdateFilter(_filter with { Kinds = existing.Count == 0 ? null : existing });
|
||||
}
|
||||
|
||||
private string KindLabel(AnomalyKind kind) => kind switch
|
||||
{
|
||||
AnomalyKind.SuspensionFlip => L["Anomaly.Kind.SuspensionFlip"],
|
||||
AnomalyKind.SteamMove => L["Anomaly.Kind.SteamMove"],
|
||||
AnomalyKind.SuspensionFreeze => L["Anomaly.Kind.SuspensionFreeze"],
|
||||
AnomalyKind.OverroundCompression => L["Anomaly.Kind.OverroundCompression"],
|
||||
_ => kind.ToString(),
|
||||
};
|
||||
|
||||
private Task SetSort(AnomalySort sort) => UpdateFilter(_filter with { Sort = sort });
|
||||
|
||||
private string SortLabel(AnomalySort sort) => sort switch
|
||||
{
|
||||
AnomalySort.Newest => L["Anomaly.Sort.Newest"],
|
||||
AnomalySort.HighestScore => L["Anomaly.Sort.Score"],
|
||||
AnomalySort.LongestGap => L["Anomaly.Sort.Gap"],
|
||||
_ => sort.ToString(),
|
||||
};
|
||||
|
||||
private async Task OnFromChanged(ChangeEventArgs e)
|
||||
{
|
||||
if (DateTimeOffset.TryParse(e.Value?.ToString(), out var v))
|
||||
|
||||
@@ -35,6 +35,63 @@
|
||||
</header>
|
||||
|
||||
<article class="m-card m-card--accented m-backtest__form-card">
|
||||
<div class="m-backtest__presets" data-test="backtest-presets">
|
||||
<div class="m-backtest__presets-head">
|
||||
<span class="m-backtest__form-label">@L["Backtest.Presets.Label"]</span>
|
||||
@if (_strategies.Count > 0)
|
||||
{
|
||||
<span class="m-backtest__section-count m-mono">@_strategies.Count</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (_strategies.Count > 0)
|
||||
{
|
||||
<div class="m-backtest__presets-list">
|
||||
@foreach (var preset in _strategies)
|
||||
{
|
||||
var local = preset;
|
||||
<div class="m-backtest__preset" data-test="backtest-preset" data-strategy-id="@local.Id">
|
||||
<button type="button"
|
||||
class="m-backtest__preset-load"
|
||||
@onclick="() => LoadStrategy(local)"
|
||||
data-test="backtest-preset-load">
|
||||
<span class="m-backtest__preset-name">@local.Name</span>
|
||||
<span class="m-backtest__preset-meta m-mono">@PresetSummary(local)</span>
|
||||
</button>
|
||||
<button type="button"
|
||||
class="m-backtest__preset-del"
|
||||
@onclick="() => DeleteStrategyAsync(local)"
|
||||
title="@L["Common.Delete"]"
|
||||
aria-label="@L["Common.Delete"]"
|
||||
data-test="backtest-preset-delete">×</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<p class="m-backtest__presets-empty">@L["Backtest.Presets.Empty"]</p>
|
||||
}
|
||||
|
||||
<div class="m-backtest__presets-save">
|
||||
<MudTextField @bind-Value="_strategyName"
|
||||
Label="@L["Backtest.Presets.NameLabel"]"
|
||||
Variant="Variant.Outlined"
|
||||
Margin="Margin.Dense"
|
||||
MaxLength="80"
|
||||
data-test="backtest-preset-name" />
|
||||
<button type="button"
|
||||
class="m-chip m-backtest__preset-save-btn"
|
||||
@onclick="SaveStrategyAsync"
|
||||
disabled="@_savingStrategy"
|
||||
data-test="backtest-preset-save">
|
||||
<span>@L["Backtest.Presets.Save"]</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr class="m-rule" />
|
||||
|
||||
<div class="m-backtest__form-grid">
|
||||
<div class="m-backtest__form-field">
|
||||
<label class="m-backtest__form-label">@L["Backtest.Field.Bankroll"]</label>
|
||||
@@ -46,6 +103,25 @@
|
||||
data-test="backtest-bankroll" />
|
||||
</div>
|
||||
|
||||
<div class="m-backtest__form-field">
|
||||
<label class="m-backtest__form-label">@L["Backtest.Field.From"]</label>
|
||||
<MudDatePicker @bind-Date="_form.From"
|
||||
DateFormat="yyyy-MM-dd"
|
||||
Clearable="true"
|
||||
Variant="Variant.Outlined"
|
||||
data-test="backtest-from" />
|
||||
</div>
|
||||
|
||||
<div class="m-backtest__form-field">
|
||||
<label class="m-backtest__form-label">@L["Backtest.Field.To"]</label>
|
||||
<MudDatePicker @bind-Date="_form.To"
|
||||
DateFormat="yyyy-MM-dd"
|
||||
Clearable="true"
|
||||
Variant="Variant.Outlined"
|
||||
data-test="backtest-to" />
|
||||
<span class="m-backtest__form-hint">@L["Backtest.Field.DateRange.Hint"]</span>
|
||||
</div>
|
||||
|
||||
<div class="m-backtest__form-field">
|
||||
<label class="m-backtest__form-label">@L["Backtest.Field.MinScore"]</label>
|
||||
<MudNumericField T="decimal"
|
||||
@@ -376,8 +452,8 @@
|
||||
.m-backtest__submit {
|
||||
gap: var(--m-space-2);
|
||||
padding: 8px 16px;
|
||||
border-color: var(--m-c-accent);
|
||||
color: var(--m-c-accent);
|
||||
border-color: var(--m-c-info);
|
||||
color: var(--m-c-info);
|
||||
font-family: var(--m-font-mono);
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
@@ -402,6 +478,87 @@
|
||||
.m-backtest__submit-glyph.is-spinning { animation: none; }
|
||||
}
|
||||
|
||||
/* ---- Saved-strategy presets ---- */
|
||||
.m-backtest__presets { display: grid; gap: var(--m-space-3); }
|
||||
.m-backtest__presets-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: var(--m-space-3);
|
||||
}
|
||||
.m-backtest__presets-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--m-space-2);
|
||||
}
|
||||
.m-backtest__preset {
|
||||
display: inline-flex;
|
||||
align-items: stretch;
|
||||
border: 1px solid var(--m-c-rule);
|
||||
background: var(--m-c-paper);
|
||||
transition: border-color 120ms ease, background 120ms ease;
|
||||
}
|
||||
.m-backtest__preset:hover { border-color: var(--m-c-accent); background: var(--m-c-paper-2); }
|
||||
.m-backtest__preset-load {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
padding: 6px 10px;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
color: inherit;
|
||||
}
|
||||
.m-backtest__preset-name {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--m-c-ink);
|
||||
}
|
||||
.m-backtest__preset-meta {
|
||||
font-size: 0.6875rem;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--m-c-ink-soft);
|
||||
}
|
||||
.m-backtest__preset-del {
|
||||
align-self: stretch;
|
||||
padding: 0 10px;
|
||||
border: 0;
|
||||
border-left: 1px solid var(--m-c-rule);
|
||||
background: transparent;
|
||||
color: var(--m-c-ink-soft);
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
line-height: 1;
|
||||
transition: color 120ms ease, background 120ms ease;
|
||||
}
|
||||
.m-backtest__preset-del:hover { color: var(--m-c-anomaly); background: rgba(220, 38, 38, 0.08); }
|
||||
.m-backtest__presets-empty {
|
||||
margin: 0;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--m-c-ink-soft);
|
||||
}
|
||||
.m-backtest__presets-save {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--m-space-3);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.m-backtest__presets-save .mud-input-control { flex: 1 1 220px; min-width: 200px; }
|
||||
.m-backtest__preset-save-btn {
|
||||
border-color: var(--m-c-ink-soft);
|
||||
color: var(--m-c-ink);
|
||||
font-family: var(--m-font-mono);
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.14em;
|
||||
padding: 8px 16px;
|
||||
}
|
||||
.m-backtest__preset-save-btn:not(:disabled):hover { border-color: var(--m-c-info); color: var(--m-c-info); }
|
||||
.m-backtest__preset-save-btn:disabled { opacity: 0.6; cursor: progress; }
|
||||
@@media (prefers-reduced-motion: reduce) {
|
||||
.m-backtest__preset, .m-backtest__preset-del, .m-backtest__preset-save-btn { transition: none; }
|
||||
}
|
||||
|
||||
/* ---- KPI strip ---- */
|
||||
.m-backtest__kpis {
|
||||
display: grid;
|
||||
@@ -591,7 +748,7 @@
|
||||
transition: color 120ms ease, border-color 120ms ease;
|
||||
}
|
||||
.m-backtest__open:hover {
|
||||
color: var(--m-c-accent);
|
||||
color: var(--m-c-info);
|
||||
border-bottom-color: var(--m-c-ink);
|
||||
}
|
||||
|
||||
@@ -617,6 +774,89 @@
|
||||
private string? _formError;
|
||||
private CancellationTokenSource? _runCts;
|
||||
|
||||
private IReadOnlyList<SavedStrategyVm> _strategies = Array.Empty<SavedStrategyVm>();
|
||||
private string _strategyName = string.Empty;
|
||||
private bool _savingStrategy;
|
||||
|
||||
protected override async Task OnInitializedAsync() => await ReloadStrategiesAsync();
|
||||
|
||||
private async Task ReloadStrategiesAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
_strategies = await Service.ListStrategiesAsync(CancellationToken.None);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "Failed to load saved strategies.");
|
||||
_strategies = Array.Empty<SavedStrategyVm>();
|
||||
}
|
||||
}
|
||||
|
||||
private void LoadStrategy(SavedStrategyVm preset)
|
||||
{
|
||||
preset.ApplyTo(_form);
|
||||
_strategyName = preset.Name;
|
||||
_formError = null;
|
||||
Snackbar.Add(L["Backtest.Presets.Loaded"].Value, Severity.Info);
|
||||
}
|
||||
|
||||
private async Task SaveStrategyAsync()
|
||||
{
|
||||
if (_savingStrategy) return;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(_strategyName))
|
||||
{
|
||||
_formError = L["Backtest.Presets.NameRequired"].Value;
|
||||
StateHasChanged();
|
||||
return;
|
||||
}
|
||||
|
||||
_savingStrategy = true;
|
||||
_formError = null;
|
||||
try
|
||||
{
|
||||
await Service.SaveStrategyAsync(_strategyName, _form, CancellationToken.None);
|
||||
await ReloadStrategiesAsync();
|
||||
Snackbar.Add(L["Backtest.Presets.Saved"].Value, Severity.Success);
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
_formError = ex.Message;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "Failed to save strategy preset.");
|
||||
Snackbar.Add(L["Backtest.Error.Generic"].Value, Severity.Error);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_savingStrategy = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DeleteStrategyAsync(SavedStrategyVm preset)
|
||||
{
|
||||
try
|
||||
{
|
||||
await Service.DeleteStrategyAsync(preset.Id, CancellationToken.None);
|
||||
await ReloadStrategiesAsync();
|
||||
Snackbar.Add(L["Backtest.Presets.Deleted"].Value, Severity.Info);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "Failed to delete strategy preset.");
|
||||
Snackbar.Add(L["Backtest.Error.Generic"].Value, Severity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private string PresetSummary(SavedStrategyVm s) => string.Format(
|
||||
CultureInfo.InvariantCulture,
|
||||
"{0} · ≥{1:0.00} · {2}",
|
||||
s.StartingBankroll.ToString("0", CultureInfo.InvariantCulture),
|
||||
s.MinScore,
|
||||
StakeRuleLabel(s.StakeRule));
|
||||
|
||||
private async Task RunAsync()
|
||||
{
|
||||
if (_running) return;
|
||||
|
||||
@@ -105,9 +105,11 @@
|
||||
|
||||
private string KindLabel(AnomalyKind kind) => kind switch
|
||||
{
|
||||
AnomalyKind.SuspensionFlip => L["Anomaly.Kind.SuspensionFlip"],
|
||||
AnomalyKind.SteamMove => L["Anomaly.Kind.SteamMove"],
|
||||
_ => kind.ToString(),
|
||||
AnomalyKind.SuspensionFlip => L["Anomaly.Kind.SuspensionFlip"],
|
||||
AnomalyKind.SteamMove => L["Anomaly.Kind.SteamMove"],
|
||||
AnomalyKind.SuspensionFreeze => L["Anomaly.Kind.SuspensionFreeze"],
|
||||
AnomalyKind.OverroundCompression => L["Anomaly.Kind.OverroundCompression"],
|
||||
_ => kind.ToString(),
|
||||
};
|
||||
|
||||
private static string FormatGap(int seconds)
|
||||
|
||||
@@ -106,6 +106,16 @@
|
||||
|
||||
<hr class="m-rule--double" />
|
||||
|
||||
@* ---------- By detector kind ---------- *@
|
||||
<section class="m-insights__section m-rise m-rise-3" data-test="insights-by-kind">
|
||||
<header class="m-insights__section-head">
|
||||
<span class="m-kicker">@L["Insights.Section.ByKind"]</span>
|
||||
</header>
|
||||
@RenderBucketTable(vm.ByKind, BucketRenderKind.Kind)
|
||||
</section>
|
||||
|
||||
<hr class="m-rule--double" />
|
||||
|
||||
@* ---------- By sport ---------- *@
|
||||
<section class="m-insights__section m-rise m-rise-3" data-test="insights-by-sport">
|
||||
<header class="m-insights__section-head">
|
||||
@@ -607,7 +617,7 @@
|
||||
transition: color 120ms ease, border-color 120ms ease;
|
||||
}
|
||||
.m-insights__open:hover {
|
||||
color: var(--m-c-accent);
|
||||
color: var(--m-c-info);
|
||||
border-bottom-color: var(--m-c-ink);
|
||||
}
|
||||
|
||||
@@ -630,6 +640,7 @@
|
||||
Severity,
|
||||
Sport,
|
||||
Score,
|
||||
Kind,
|
||||
}
|
||||
|
||||
private AnomalyInsightsVm? _vm;
|
||||
@@ -826,6 +837,23 @@
|
||||
}
|
||||
break;
|
||||
}
|
||||
case BucketRenderKind.Kind:
|
||||
{
|
||||
var trimmed = key.StartsWith(OutcomeBucketKeys.KindPrefix, StringComparison.Ordinal)
|
||||
? key.Substring(OutcomeBucketKeys.KindPrefix.Length)
|
||||
: key;
|
||||
var locKey = trimmed switch
|
||||
{
|
||||
nameof(AnomalyKind.SuspensionFlip) => "Anomaly.Kind.SuspensionFlip",
|
||||
nameof(AnomalyKind.SteamMove) => "Anomaly.Kind.SteamMove",
|
||||
nameof(AnomalyKind.SuspensionFreeze) => "Anomaly.Kind.SuspensionFreeze",
|
||||
nameof(AnomalyKind.OverroundCompression) => "Anomaly.Kind.OverroundCompression",
|
||||
_ => null,
|
||||
};
|
||||
if (locKey is null) builder.AddContent(0, trimmed);
|
||||
else builder.AddContent(0, L[locKey]);
|
||||
break;
|
||||
}
|
||||
case BucketRenderKind.Score:
|
||||
default:
|
||||
{
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
@*
|
||||
PaperTrading — the forward-test ledger.
|
||||
|
||||
Read-only view of the paper bets the PaperTradingWorker opens on live directional
|
||||
signals and settles as results arrive. Settled-only P&L KPIs + a per-bet table.
|
||||
Same editorial-quant tone as Backtest / Insights.
|
||||
*@
|
||||
|
||||
@page "/paper-trading"
|
||||
@using System.Globalization
|
||||
@using Marathon.Domain.Enums
|
||||
@inject IStringLocalizer<SharedResource> L
|
||||
@inject IPaperTradingService Service
|
||||
@inject NavigationManager Nav
|
||||
|
||||
<PageTitle>@L["App.Title"] · @L["Nav.PaperTrading"]</PageTitle>
|
||||
|
||||
<section class="m-shell">
|
||||
<header class="m-rise m-rise-1" style="display: grid; gap: var(--m-space-3); max-width: 880px;">
|
||||
<span class="m-kicker">@L["Paper.Kicker"]</span>
|
||||
<h1 class="m-display" style="font-size: clamp(2rem, 4vw, 3rem);">@L["Paper.Title"]</h1>
|
||||
<p style="color: var(--m-c-ink-soft); max-width: 64ch;">@L["Paper.Lede"]</p>
|
||||
</header>
|
||||
|
||||
@if (_loading)
|
||||
{
|
||||
<div class="m-list-empty">
|
||||
<MudProgressCircular Indeterminate="true" Size="Size.Small" />
|
||||
<span class="m-mono">@L["Common.Loading"]</span>
|
||||
</div>
|
||||
}
|
||||
else if (_vm is null || (_vm.OpenCount == 0 && _vm.SettledCount == 0))
|
||||
{
|
||||
<div class="m-list-empty m-rise m-rise-2" data-test="paper-empty">
|
||||
<span class="m-kicker" style="border-color: var(--m-c-ink-soft); color: var(--m-c-ink-soft);">
|
||||
@L["Common.Empty"]
|
||||
</span>
|
||||
<p style="color: var(--m-c-ink-soft); margin-top: var(--m-space-3); max-width: 56ch;">
|
||||
@L["Paper.Empty"]
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
var vm = _vm;
|
||||
<hr class="m-rule" />
|
||||
|
||||
<section class="m-paper__section m-rise m-rise-2" data-test="paper-kpis">
|
||||
<header class="m-paper__section-head">
|
||||
<span class="m-kicker">@L["Paper.Section.Summary"]</span>
|
||||
</header>
|
||||
<div class="m-paper__kpis">
|
||||
<article class="m-paper__kpi m-paper__kpi--@ProfitTone(vm)" data-test="paper-kpi-net">
|
||||
<span class="m-paper__kpi-label">@L["Paper.Stat.NetProfit"]</span>
|
||||
<span class="m-paper__kpi-value">@FormatSignedDecimal(vm.NetProfit, vm.SettledCount)</span>
|
||||
</article>
|
||||
<article class="m-paper__kpi m-paper__kpi--@RoiTone(vm.RoiPercent)" data-test="paper-kpi-roi">
|
||||
<span class="m-paper__kpi-label">@L["Paper.Stat.Roi"]</span>
|
||||
<span class="m-paper__kpi-value">@FormatSignedPercent(vm.RoiPercent)</span>
|
||||
</article>
|
||||
<article class="m-paper__kpi m-paper__kpi--neutral" data-test="paper-kpi-hit">
|
||||
<span class="m-paper__kpi-label">@L["Paper.Stat.HitRate"]</span>
|
||||
<span class="m-paper__kpi-value">@FormatPercent(vm.HitRatePercent)</span>
|
||||
</article>
|
||||
<article class="m-paper__kpi m-paper__kpi--neutral" data-test="paper-kpi-open">
|
||||
<span class="m-paper__kpi-label">@L["Paper.Stat.Open"]</span>
|
||||
<span class="m-paper__kpi-value">@vm.OpenCount</span>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div class="m-paper__counts m-mono" data-test="paper-counts">
|
||||
<span><span class="m-paper__counts-label">@L["Paper.Stat.Settled"]</span> <strong>@vm.SettledCount</strong></span>
|
||||
<span aria-hidden="true">·</span>
|
||||
<span><span class="m-paper__counts-label">@L["Paper.Stat.Wins"]</span> <strong style="color: var(--m-c-positive);">@vm.Wins</strong></span>
|
||||
<span aria-hidden="true">·</span>
|
||||
<span><span class="m-paper__counts-label">@L["Paper.Stat.Losses"]</span> <strong style="color: var(--m-c-anomaly);">@vm.Losses</strong></span>
|
||||
<span aria-hidden="true">·</span>
|
||||
<span><span class="m-paper__counts-label">@L["Paper.Stat.Staked"]</span> <strong>@vm.TotalStaked.ToString("0.00", CultureInfo.InvariantCulture)</strong></span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<hr class="m-rule--double" />
|
||||
|
||||
<section class="m-paper__section m-rise m-rise-3" data-test="paper-table-section">
|
||||
<header class="m-paper__section-head">
|
||||
<span class="m-kicker">@L["Paper.Section.Ledger"]</span>
|
||||
<span class="m-paper__section-count m-mono">@vm.Bets.Count</span>
|
||||
</header>
|
||||
|
||||
<div class="m-paper__table-wrap">
|
||||
<table class="m-paper__table" data-test="paper-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">@L["Paper.Column.OpenedAt"]</th>
|
||||
<th scope="col">@L["Paper.Column.Match"]</th>
|
||||
<th scope="col">@L["Paper.Column.Pick"]</th>
|
||||
<th scope="col" style="text-align: right;">@L["Paper.Column.Rate"]</th>
|
||||
<th scope="col" style="text-align: right;">@L["Paper.Column.Stake"]</th>
|
||||
<th scope="col">@L["Paper.Column.Status"]</th>
|
||||
<th scope="col" style="text-align: right;">@L["Paper.Column.Payout"]</th>
|
||||
<th scope="col"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var row in vm.Bets)
|
||||
{
|
||||
var local = row;
|
||||
<tr class="m-paper__row m-paper__row--@OutcomeClass(local.Outcome)"
|
||||
data-test="paper-row"
|
||||
data-anomaly-id="@local.AnomalyId">
|
||||
<td class="m-mono">@local.OpenedAt.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture)</td>
|
||||
<td style="font-weight: 500;">@local.EventTitle</td>
|
||||
<td style="font-weight: 600;">@SideLabel(local.PickedSide)</td>
|
||||
<td class="m-mono" style="text-align: right;">@local.Rate.ToString("0.00", CultureInfo.InvariantCulture)</td>
|
||||
<td class="m-mono" style="text-align: right;">@local.Stake.ToString("0.00", CultureInfo.InvariantCulture)</td>
|
||||
<td>
|
||||
<span class="m-paper__status m-paper__status--@OutcomeClass(local.Outcome)">
|
||||
@OutcomeLabel(local.Outcome)
|
||||
</span>
|
||||
</td>
|
||||
<td class="m-mono" style="text-align: right;">
|
||||
@(local.Payout is { } p ? p.ToString("0.00", CultureInfo.InvariantCulture) : "—")
|
||||
</td>
|
||||
<td>
|
||||
<a href="@($"/anomalies/{local.AnomalyId}")"
|
||||
class="m-paper__open"
|
||||
data-test="paper-open"
|
||||
@onclick="@(e => OpenAnomaly(e, local.AnomalyId))"
|
||||
@onclick:preventDefault>
|
||||
@L["Insights.Action.OpenAnomaly"] <span aria-hidden="true">→</span>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.m-paper__section { display: grid; gap: var(--m-space-4); }
|
||||
.m-paper__section-head {
|
||||
display: flex; align-items: baseline; justify-content: space-between; gap: var(--m-space-3);
|
||||
}
|
||||
.m-paper__section-count {
|
||||
font-size: 0.6875rem; letter-spacing: 0.16em; text-transform: uppercase; color: var(--m-c-ink-soft);
|
||||
}
|
||||
|
||||
.m-paper__kpis {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: var(--m-space-4);
|
||||
}
|
||||
.m-paper__kpi {
|
||||
background: var(--m-c-paper);
|
||||
border: 1px solid var(--m-c-rule);
|
||||
border-left: 3px solid var(--m-c-rule);
|
||||
padding: var(--m-space-4) var(--m-space-5);
|
||||
display: flex; flex-direction: column; gap: var(--m-space-2);
|
||||
}
|
||||
.m-paper__kpi--positive { border-left-color: var(--m-c-positive); }
|
||||
.m-paper__kpi--negative { border-left-color: var(--m-c-anomaly); }
|
||||
.m-paper__kpi--neutral { border-left-color: var(--m-c-accent); }
|
||||
.m-paper__kpi-label {
|
||||
font-family: var(--m-font-mono); font-size: 0.6875rem; letter-spacing: 0.16em;
|
||||
text-transform: uppercase; color: var(--m-c-ink-soft);
|
||||
}
|
||||
.m-paper__kpi-value {
|
||||
font-family: var(--m-font-mono); font-feature-settings: var(--m-num-feature);
|
||||
font-size: clamp(1.6rem, 3vw, 2.2rem); font-weight: 500; line-height: 1;
|
||||
letter-spacing: -0.02em; color: var(--m-c-ink);
|
||||
}
|
||||
.m-paper__kpi--positive .m-paper__kpi-value { color: var(--m-c-positive); }
|
||||
.m-paper__kpi--negative .m-paper__kpi-value { color: var(--m-c-anomaly); }
|
||||
|
||||
.m-paper__counts {
|
||||
display: flex; gap: var(--m-space-3); flex-wrap: wrap; align-items: baseline;
|
||||
padding: var(--m-space-2) 0; font-size: 0.8125rem; color: var(--m-c-ink-soft);
|
||||
font-feature-settings: var(--m-num-feature);
|
||||
}
|
||||
.m-paper__counts strong { color: var(--m-c-ink); font-weight: 600; }
|
||||
.m-paper__counts-label { text-transform: uppercase; letter-spacing: 0.12em; font-size: 0.6875rem; }
|
||||
|
||||
.m-paper__table-wrap {
|
||||
background: var(--m-c-paper); border: 1px solid var(--m-c-rule); overflow-x: auto;
|
||||
}
|
||||
.m-paper__table { width: 100%; border-collapse: collapse; font-family: var(--m-font-body); }
|
||||
.m-paper__table thead th {
|
||||
font-family: var(--m-font-mono); font-size: 0.6875rem; letter-spacing: 0.14em;
|
||||
text-transform: uppercase; text-align: left; padding: var(--m-space-3);
|
||||
border-bottom: 1px solid var(--m-c-rule); color: var(--m-c-ink-soft);
|
||||
background: var(--m-c-paper-2); white-space: nowrap;
|
||||
}
|
||||
.m-paper__table tbody td {
|
||||
padding: var(--m-space-3); border-bottom: 1px solid var(--m-c-rule);
|
||||
vertical-align: middle; font-size: 0.9375rem;
|
||||
}
|
||||
.m-paper__table tbody tr:last-child td { border-bottom: 0; }
|
||||
.m-paper__row { transition: background 120ms ease; }
|
||||
.m-paper__row:hover { background: var(--m-c-paper-2); }
|
||||
.m-paper__row--won { box-shadow: inset 2px 0 0 0 var(--m-c-positive); }
|
||||
.m-paper__row--lost { box-shadow: inset 2px 0 0 0 var(--m-c-anomaly); }
|
||||
.m-paper__row--open { box-shadow: inset 2px 0 0 0 var(--m-c-ink-soft); }
|
||||
@@media (prefers-reduced-motion: reduce) { .m-paper__row { transition: none; } }
|
||||
|
||||
.m-paper__status {
|
||||
display: inline-flex; align-items: center; padding: 2px 8px;
|
||||
font-family: var(--m-font-mono); font-size: 0.6875rem; letter-spacing: 0.14em;
|
||||
text-transform: uppercase; border: 1px solid currentColor; border-radius: var(--m-radius-xs);
|
||||
}
|
||||
.m-paper__status--won { color: var(--m-c-positive); background: rgba(21, 128, 61, 0.10); }
|
||||
.m-paper__status--lost { color: var(--m-c-anomaly); background: rgba(220, 38, 38, 0.10); }
|
||||
.m-paper__status--open { color: var(--m-c-ink-soft); }
|
||||
|
||||
.m-paper__open {
|
||||
display: inline-flex; align-items: center; gap: 6px; font-family: var(--m-font-mono);
|
||||
font-size: 0.75rem; letter-spacing: 0.12em; text-transform: uppercase; text-decoration: none;
|
||||
color: var(--m-c-ink); border-bottom: 1px solid var(--m-c-accent); padding-bottom: 1px;
|
||||
transition: color 120ms ease, border-color 120ms ease;
|
||||
}
|
||||
.m-paper__open:hover { color: var(--m-c-info); border-bottom-color: var(--m-c-ink); }
|
||||
|
||||
.m-list-empty {
|
||||
display: grid; place-content: center; gap: var(--m-space-3); padding: var(--m-space-7);
|
||||
text-align: center; background: var(--m-c-paper); border: 1px solid var(--m-c-rule);
|
||||
}
|
||||
</style>
|
||||
|
||||
@code {
|
||||
private PaperTradingVm? _vm;
|
||||
private bool _loading = true;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
_vm = await Service.GetAsync(CancellationToken.None);
|
||||
}
|
||||
catch
|
||||
{
|
||||
_vm = null;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void OpenAnomaly(MouseEventArgs e, Guid anomalyId) =>
|
||||
Nav.NavigateTo("/anomalies/" + anomalyId.ToString());
|
||||
|
||||
private string SideLabel(Side side) => side switch
|
||||
{
|
||||
Side.Side1 => L["Journal.Side.Side1"],
|
||||
Side.Side2 => L["Journal.Side.Side2"],
|
||||
Side.Draw => L["Journal.Side.Draw"],
|
||||
_ => side.ToString(),
|
||||
};
|
||||
|
||||
private string OutcomeLabel(BetOutcome outcome) => outcome switch
|
||||
{
|
||||
BetOutcome.Pending => L["Paper.Outcome.Open"],
|
||||
BetOutcome.Won => L["Paper.Outcome.Won"],
|
||||
BetOutcome.Lost => L["Paper.Outcome.Lost"],
|
||||
BetOutcome.Void => L["Paper.Outcome.Void"],
|
||||
_ => outcome.ToString(),
|
||||
};
|
||||
|
||||
private static string OutcomeClass(BetOutcome outcome) => outcome switch
|
||||
{
|
||||
BetOutcome.Won => "won",
|
||||
BetOutcome.Lost => "lost",
|
||||
_ => "open",
|
||||
};
|
||||
|
||||
private static string FormatSignedDecimal(decimal value, int settledCount)
|
||||
{
|
||||
if (settledCount == 0) return "—";
|
||||
var sign = value > 0m ? "+" : (value < 0m ? "-" : "");
|
||||
return sign + Math.Abs(value).ToString("0.00", CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
private static string FormatSignedPercent(decimal? value)
|
||||
{
|
||||
if (value is null) return "—";
|
||||
var v = value.Value;
|
||||
var sign = v > 0m ? "+" : (v < 0m ? "-" : "");
|
||||
return sign + Math.Abs(v).ToString("0.0", CultureInfo.InvariantCulture) + "%";
|
||||
}
|
||||
|
||||
private static string FormatPercent(decimal? value) =>
|
||||
value is null ? "—" : value.Value.ToString("0.0", CultureInfo.InvariantCulture) + "%";
|
||||
|
||||
private static string ProfitTone(PaperTradingVm vm)
|
||||
{
|
||||
if (vm.SettledCount == 0) return "neutral";
|
||||
if (vm.NetProfit > 0m) return "positive";
|
||||
if (vm.NetProfit < 0m) return "negative";
|
||||
return "neutral";
|
||||
}
|
||||
|
||||
private static string RoiTone(decimal? roi) => roi switch
|
||||
{
|
||||
null => "neutral",
|
||||
> 0m => "positive",
|
||||
< 0m => "negative",
|
||||
_ => "neutral",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
@*
|
||||
StrategyCompare — head-to-head backtest of every saved strategy preset.
|
||||
|
||||
Runs each saved preset (from the Backtest page) over the same window and tables
|
||||
their ROI / hit-rate / net / drawdown side by side, flagging the best ROI. Same
|
||||
editorial-quant tone as Backtest.
|
||||
*@
|
||||
|
||||
@page "/anomalies/compare"
|
||||
@using System.Globalization
|
||||
@implements IDisposable
|
||||
@inject IStringLocalizer<SharedResource> L
|
||||
@inject IStrategyComparisonService Service
|
||||
@inject NavigationManager Nav
|
||||
@inject ILogger<StrategyCompare> Logger
|
||||
|
||||
<PageTitle>@L["App.Title"] · @L["Nav.Compare"]</PageTitle>
|
||||
|
||||
<section class="m-shell">
|
||||
<header class="m-rise m-rise-1" style="display: grid; gap: var(--m-space-3); max-width: 880px;">
|
||||
<span class="m-kicker">@L["Compare.Kicker"]</span>
|
||||
<h1 class="m-display" style="font-size: clamp(2rem, 4vw, 3rem);">@L["Compare.Title"]</h1>
|
||||
<p style="color: var(--m-c-ink-soft); max-width: 64ch;">@L["Compare.Lede"]</p>
|
||||
</header>
|
||||
|
||||
<hr class="m-rule" />
|
||||
|
||||
<article class="m-card m-card--accented m-cmp__form" data-test="compare-form">
|
||||
<div class="m-cmp__form-grid">
|
||||
<div class="m-cmp__form-field">
|
||||
<label class="m-cmp__form-label">@L["Backtest.Field.From"]</label>
|
||||
<MudDatePicker @bind-Date="_from" DateFormat="yyyy-MM-dd" Clearable="true" Variant="Variant.Outlined" data-test="compare-from" />
|
||||
</div>
|
||||
<div class="m-cmp__form-field">
|
||||
<label class="m-cmp__form-label">@L["Backtest.Field.To"]</label>
|
||||
<MudDatePicker @bind-Date="_to" DateFormat="yyyy-MM-dd" Clearable="true" Variant="Variant.Outlined" data-test="compare-to" />
|
||||
<span class="m-cmp__form-hint">@L["Backtest.Field.DateRange.Hint"]</span>
|
||||
</div>
|
||||
<div class="m-cmp__form-actions">
|
||||
<button type="button" class="m-chip m-cmp__run" @onclick="RunAsync" disabled="@_running" data-test="compare-run">
|
||||
<span class="m-cmp__run-glyph @(_running ? "is-spinning" : null)" aria-hidden="true">▶</span>
|
||||
<span>@(_running ? L["Backtest.Action.Running"] : L["Compare.Action.Run"])</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@if (!string.IsNullOrEmpty(_error))
|
||||
{
|
||||
<p class="m-cmp__error" data-test="compare-error">@_error</p>
|
||||
}
|
||||
</article>
|
||||
|
||||
@if (_loading)
|
||||
{
|
||||
<div class="m-list-empty">
|
||||
<MudProgressCircular Indeterminate="true" Size="Size.Small" />
|
||||
<span class="m-mono">@L["Common.Loading"]</span>
|
||||
</div>
|
||||
}
|
||||
else if (_vm is null || _vm.Rows.Count == 0)
|
||||
{
|
||||
<div class="m-list-empty m-rise m-rise-2" data-test="compare-empty">
|
||||
<span class="m-kicker" style="border-color: var(--m-c-ink-soft); color: var(--m-c-ink-soft);">
|
||||
@L["Common.Empty"]
|
||||
</span>
|
||||
<p style="color: var(--m-c-ink-soft); margin-top: var(--m-space-3); max-width: 56ch;">
|
||||
@L["Compare.Empty"]
|
||||
</p>
|
||||
<MudButton Variant="Variant.Outlined"
|
||||
StartIcon="@Icons.Material.Outlined.QueryStats"
|
||||
OnClick='() => Nav.NavigateTo("/anomalies/backtest")'
|
||||
data-test="compare-empty-cta">
|
||||
@L["Nav.Backtest"]
|
||||
</MudButton>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<hr class="m-rule--double" />
|
||||
<section class="m-cmp__section m-rise m-rise-2" data-test="compare-result">
|
||||
<header class="m-cmp__section-head">
|
||||
<span class="m-kicker">@L["Compare.Section.Results"]</span>
|
||||
<span class="m-cmp__section-count m-mono">@_vm.Rows.Count</span>
|
||||
</header>
|
||||
|
||||
<div class="m-cmp__table-wrap">
|
||||
<table class="m-cmp__table" data-test="compare-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">@L["Compare.Column.Strategy"]</th>
|
||||
<th scope="col" style="text-align: right;">@L["Compare.Column.Bets"]</th>
|
||||
<th scope="col" style="text-align: right;">@L["Compare.Column.WinLoss"]</th>
|
||||
<th scope="col" style="text-align: right;">@L["Compare.Column.HitRate"]</th>
|
||||
<th scope="col" style="text-align: right;">@L["Compare.Column.Net"]</th>
|
||||
<th scope="col" style="text-align: right;">@L["Compare.Column.Roi"]</th>
|
||||
<th scope="col" style="text-align: right;">@L["Compare.Column.MaxDrawdown"]</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var row in _vm.Rows)
|
||||
{
|
||||
<tr class="m-cmp__row @(row.IsBest ? "m-cmp__row--best" : null)" data-test="compare-row" data-strategy-id="@row.StrategyId">
|
||||
<td style="font-weight: 600;">
|
||||
@row.Name
|
||||
@if (row.IsBest)
|
||||
{
|
||||
<span class="m-cmp__best-badge" data-test="compare-best">@L["Compare.Best"]</span>
|
||||
}
|
||||
</td>
|
||||
<td class="m-mono" style="text-align: right;">@row.BetsPlaced</td>
|
||||
<td class="m-mono" style="text-align: right;">
|
||||
<span style="color: var(--m-c-positive);">@row.Wins</span>–<span style="color: var(--m-c-anomaly);">@row.Losses</span>
|
||||
</td>
|
||||
<td class="m-mono" style="text-align: right;">@FormatPercent(row.HitRatePercent)</td>
|
||||
<td class="m-mono m-cmp__num m-cmp__num--@Tone(row.NetProfit, row.BetsPlaced)" style="text-align: right;">@FormatSignedDecimal(row.NetProfit, row.BetsPlaced)</td>
|
||||
<td class="m-mono m-cmp__num m-cmp__num--@RoiTone(row.RoiPercent)" style="text-align: right;">@FormatSignedPercent(row.RoiPercent)</td>
|
||||
<td class="m-mono" style="text-align: right;">@(row.MaxDrawdown == 0m ? "—" : row.MaxDrawdown.ToString("0.00", CultureInfo.InvariantCulture))</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.m-cmp__form { display: grid; gap: var(--m-space-4); padding: var(--m-space-5); }
|
||||
.m-cmp__form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: var(--m-space-4);
|
||||
align-items: end;
|
||||
}
|
||||
.m-cmp__form-field { display: grid; gap: var(--m-space-2); }
|
||||
.m-cmp__form-label {
|
||||
font-family: var(--m-font-mono); font-size: 0.6875rem; letter-spacing: 0.14em;
|
||||
text-transform: uppercase; color: var(--m-c-ink-soft);
|
||||
}
|
||||
.m-cmp__form-hint { font-size: 0.75rem; color: var(--m-c-ink-soft); }
|
||||
.m-cmp__form-actions { display: flex; align-items: end; }
|
||||
.m-cmp__run {
|
||||
gap: var(--m-space-2); padding: 8px 16px; border-color: var(--m-c-info); color: var(--m-c-info);
|
||||
font-family: var(--m-font-mono); font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.14em;
|
||||
}
|
||||
.m-cmp__run:not(:disabled):hover { background: var(--m-c-accent); color: var(--m-c-paper); }
|
||||
.m-cmp__run:disabled { opacity: 0.6; cursor: progress; }
|
||||
.m-cmp__run-glyph { display: inline-block; font-size: 0.7rem; line-height: 1; }
|
||||
.m-cmp__run-glyph.is-spinning { animation: m-cmp-spin 1.1s linear infinite; }
|
||||
@@keyframes m-cmp-spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
|
||||
@@media (prefers-reduced-motion: reduce) { .m-cmp__run-glyph.is-spinning { animation: none; } }
|
||||
.m-cmp__error {
|
||||
margin: 0; padding: var(--m-space-3) var(--m-space-4); border: 1px solid var(--m-c-anomaly);
|
||||
border-left-width: 3px; background: rgba(220, 38, 38, 0.06); color: var(--m-c-anomaly);
|
||||
font-family: var(--m-font-mono); font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.m-cmp__section { display: grid; gap: var(--m-space-4); }
|
||||
.m-cmp__section-head { display: flex; align-items: baseline; justify-content: space-between; gap: var(--m-space-3); }
|
||||
.m-cmp__section-count { font-size: 0.6875rem; letter-spacing: 0.16em; text-transform: uppercase; color: var(--m-c-ink-soft); }
|
||||
|
||||
.m-cmp__table-wrap { background: var(--m-c-paper); border: 1px solid var(--m-c-rule); overflow-x: auto; }
|
||||
.m-cmp__table { width: 100%; border-collapse: collapse; font-family: var(--m-font-body); }
|
||||
.m-cmp__table thead th {
|
||||
font-family: var(--m-font-mono); font-size: 0.6875rem; letter-spacing: 0.14em; text-transform: uppercase;
|
||||
text-align: left; padding: var(--m-space-3); border-bottom: 1px solid var(--m-c-rule);
|
||||
color: var(--m-c-ink-soft); background: var(--m-c-paper-2); white-space: nowrap;
|
||||
}
|
||||
.m-cmp__table tbody td { padding: var(--m-space-3); border-bottom: 1px solid var(--m-c-rule); vertical-align: middle; font-size: 0.9375rem; }
|
||||
.m-cmp__table tbody tr:last-child td { border-bottom: 0; }
|
||||
.m-cmp__row--best { background: rgba(21, 128, 61, 0.06); box-shadow: inset 3px 0 0 0 var(--m-c-positive); }
|
||||
[data-theme="dark"] .m-cmp__row--best { background: rgba(34, 197, 94, 0.10); }
|
||||
.m-cmp__num { font-feature-settings: var(--m-num-feature); font-weight: 600; }
|
||||
.m-cmp__num--positive { color: var(--m-c-positive); }
|
||||
.m-cmp__num--negative { color: var(--m-c-anomaly); }
|
||||
.m-cmp__best-badge {
|
||||
margin-left: 8px; padding: 1px 6px; font-family: var(--m-font-mono); font-size: 0.625rem;
|
||||
letter-spacing: 0.12em; text-transform: uppercase; color: var(--m-c-positive);
|
||||
border: 1px solid var(--m-c-positive); border-radius: var(--m-radius-xs);
|
||||
}
|
||||
|
||||
.m-list-empty {
|
||||
display: grid; place-content: center; gap: var(--m-space-3); padding: var(--m-space-7);
|
||||
text-align: center; background: var(--m-c-paper); border: 1px solid var(--m-c-rule);
|
||||
}
|
||||
</style>
|
||||
|
||||
@code {
|
||||
private DateTime? _from;
|
||||
private DateTime? _to;
|
||||
private StrategyComparisonVm? _vm;
|
||||
private bool _loading = true;
|
||||
private bool _running;
|
||||
private string? _error;
|
||||
private CancellationTokenSource? _cts;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await RunCoreAsync();
|
||||
_loading = false;
|
||||
}
|
||||
|
||||
private async Task RunAsync()
|
||||
{
|
||||
if (_running) return;
|
||||
await RunCoreAsync();
|
||||
}
|
||||
|
||||
private async Task RunCoreAsync()
|
||||
{
|
||||
_error = null;
|
||||
|
||||
if (_from.HasValue != _to.HasValue)
|
||||
{
|
||||
_error = L["Backtest.Field.DateRange.Hint"].Value;
|
||||
return;
|
||||
}
|
||||
|
||||
_cts?.Cancel();
|
||||
_cts?.Dispose();
|
||||
_cts = new CancellationTokenSource();
|
||||
var ct = _cts.Token;
|
||||
|
||||
_running = true;
|
||||
StateHasChanged();
|
||||
try
|
||||
{
|
||||
var vm = await Service.CompareAsync(_from, _to, ct);
|
||||
if (!ct.IsCancellationRequested) _vm = vm;
|
||||
}
|
||||
catch (OperationCanceledException) { /* superseded */ }
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "Strategy comparison failed.");
|
||||
_error = L["Backtest.Error.Generic"].Value;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_running = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private static string FormatPercent(decimal? v) =>
|
||||
v is null ? "—" : v.Value.ToString("0.0", CultureInfo.InvariantCulture) + "%";
|
||||
|
||||
private static string FormatSignedDecimal(decimal value, int betsPlaced)
|
||||
{
|
||||
if (betsPlaced == 0) return "—";
|
||||
var sign = value > 0m ? "+" : (value < 0m ? "-" : "");
|
||||
return sign + Math.Abs(value).ToString("0.00", CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
private static string FormatSignedPercent(decimal? value)
|
||||
{
|
||||
if (value is null) return "—";
|
||||
var v = value.Value;
|
||||
var sign = v > 0m ? "+" : (v < 0m ? "-" : "");
|
||||
return sign + Math.Abs(v).ToString("0.0", CultureInfo.InvariantCulture) + "%";
|
||||
}
|
||||
|
||||
private static string Tone(decimal value, int betsPlaced)
|
||||
{
|
||||
if (betsPlaced == 0) return "neutral";
|
||||
if (value > 0m) return "positive";
|
||||
if (value < 0m) return "negative";
|
||||
return "neutral";
|
||||
}
|
||||
|
||||
private static string RoiTone(decimal? roi) => roi switch
|
||||
{
|
||||
null => "neutral",
|
||||
> 0m => "positive",
|
||||
< 0m => "negative",
|
||||
_ => "neutral",
|
||||
};
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_cts?.Cancel();
|
||||
_cts?.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,8 @@
|
||||
@inject IStringLocalizer<SharedResource> L
|
||||
@inject IDialogService Dialog
|
||||
@inject ISnackbar Snackbar
|
||||
@inject Marathon.Application.UseCases.ExportToCsvUseCase CsvExport
|
||||
@inject ILogger<ExportHub> Logger
|
||||
|
||||
<PageTitle>@L["App.Title"] · @L["Export.Title"]</PageTitle>
|
||||
|
||||
@@ -30,6 +32,20 @@
|
||||
@L["Export.Hub.FilenameHint"]
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<hr class="m-rule" />
|
||||
|
||||
<div class="m-rise m-rise-3" style="display: grid; gap: var(--m-space-3);">
|
||||
<span class="m-kicker" style="border-color: var(--m-c-ink-soft); color: var(--m-c-ink-soft);">@L["Export.Csv.Section"]</span>
|
||||
<div style="display: flex; gap: var(--m-space-3); flex-wrap: wrap;">
|
||||
<button type="button" class="m-chip" @onclick="ExportJournalCsvAsync" disabled="@_busy" data-test="export-journal-csv">
|
||||
@L["Export.Csv.Journal"]
|
||||
</button>
|
||||
<button type="button" class="m-chip" @onclick="ExportLedgerCsvAsync" disabled="@_busy" data-test="export-ledger-csv">
|
||||
@L["Export.Csv.Ledger"]
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@code {
|
||||
@@ -55,4 +71,35 @@
|
||||
Snackbar.Add(string.Format(CultureInfo.CurrentCulture, L["Export.Success"].Value, path), Severity.Success);
|
||||
}
|
||||
}
|
||||
|
||||
private bool _busy;
|
||||
|
||||
private Task ExportJournalCsvAsync() => RunCsvAsync(CsvExport.ExportJournalAsync);
|
||||
|
||||
private Task ExportLedgerCsvAsync() => RunCsvAsync(CsvExport.ExportPaperLedgerAsync);
|
||||
|
||||
private async Task RunCsvAsync(Func<CancellationToken, Task<string?>> export)
|
||||
{
|
||||
if (_busy) return;
|
||||
_busy = true;
|
||||
StateHasChanged();
|
||||
try
|
||||
{
|
||||
var path = await export(CancellationToken.None);
|
||||
if (path is null)
|
||||
Snackbar.Add(L["Export.Csv.Empty"].Value, Severity.Info);
|
||||
else
|
||||
Snackbar.Add(string.Format(CultureInfo.CurrentCulture, L["Export.Success"].Value, path), Severity.Success);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "ExportHub: CSV export failed.");
|
||||
Snackbar.Add(L["Export.Csv.Error"].Value, Severity.Error);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_busy = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
@page "/health"
|
||||
@inject IStringLocalizer<SharedResource> L
|
||||
@inject IPipelineHealthService HealthService
|
||||
@inject ILogger<Health> Logger
|
||||
|
||||
<PageTitle>@L["App.Title"] · @L["Nav.Health"]</PageTitle>
|
||||
|
||||
<section class="m-shell">
|
||||
<header class="m-rise m-rise-1" style="display: grid; gap: var(--m-space-3); max-width: 880px;">
|
||||
<span class="m-kicker">@L["Health.Kicker"]</span>
|
||||
<h1 class="m-display" style="font-size: clamp(2rem, 4vw, 3rem);">@L["Health.Title"]</h1>
|
||||
<p style="color: var(--m-c-ink-soft); max-width: 60ch;">@L["Health.Lede"]</p>
|
||||
</header>
|
||||
|
||||
<hr class="m-rule--double" />
|
||||
|
||||
<div class="m-rise m-rise-2" data-test="health-freshness"
|
||||
style="display: inline-flex; align-items: center; gap: 10px; font-family: var(--m-font-mono); font-size: 0.8125rem; text-transform: uppercase; letter-spacing: 0.12em;">
|
||||
<span style="width: 10px; height: 10px; border-radius: 50%; background: @FreshnessColor;"></span>
|
||||
<span style="color: var(--m-c-ink-soft);">@L["Health.LastCapture"]:</span>
|
||||
<span style="color: @FreshnessColor;">@FreshnessText</span>
|
||||
</div>
|
||||
|
||||
<div class="m-grid--three m-rise m-rise-2" style="margin-top: var(--m-space-5);">
|
||||
<StatCard Label="@L["Health.Stat.Snapshots"]"
|
||||
Value="@_health.SnapshotsLast24h.ToString("N0")"
|
||||
Delta="@string.Format(CultureInfo.CurrentCulture, L["Health.Total"].Value, _health.SnapshotsTotal)" />
|
||||
<StatCard Label="@L["Health.Stat.Anomalies"]"
|
||||
Value="@_health.AnomaliesLast24h.ToString("N0")"
|
||||
Delta="@string.Format(CultureInfo.CurrentCulture, L["Health.Total"].Value, _health.AnomaliesTotal)"
|
||||
Anomaly="true" />
|
||||
<StatCard Label="@L["Health.Stat.Events"]" Value="@_health.EventsTracked.ToString("N0")" />
|
||||
<StatCard Label="@L["Health.Stat.Sports"]" Value="@_health.SportsCovered.ToString()" />
|
||||
</div>
|
||||
|
||||
<aside class="m-card m-card--accented m-rise m-rise-3" style="margin-top: var(--m-space-6);">
|
||||
<span class="m-kicker">@L["Health.Workers"]</span>
|
||||
<ol style="list-style: none; padding: 0; margin: var(--m-space-4) 0 0; display: grid; gap: var(--m-space-3);">
|
||||
<PipelineStep Index="01" Label="@L["Health.Worker.Schedule"]" Status="@WorkerStatus(_health.UpcomingPollerEnabled)" />
|
||||
<PipelineStep Index="02" Label="@L["Health.Worker.Live"]" Status="@WorkerStatus(_health.LivePollerEnabled)" />
|
||||
<PipelineStep Index="03" Label="@L["Health.Worker.Detection"]" Status="@WorkerStatus(_health.AnomalyDetectionEnabled)" />
|
||||
<PipelineStep Index="04" Label="@L["Health.Worker.Results"]" Status="@WorkerStatus(_health.ResultsPollerEnabled)" />
|
||||
</ol>
|
||||
</aside>
|
||||
|
||||
@if (!_health.HasData)
|
||||
{
|
||||
<p class="m-rise m-rise-3" data-test="health-empty"
|
||||
style="margin-top: var(--m-space-5); color: var(--m-c-ink-soft);">
|
||||
@L["Health.Empty"]
|
||||
</p>
|
||||
}
|
||||
</section>
|
||||
|
||||
@code {
|
||||
private PipelineHealth _health = PipelineHealth.Empty;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
_health = await HealthService.GetAsync(CancellationToken.None);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "Health: failed to load pipeline health.");
|
||||
}
|
||||
}
|
||||
|
||||
private static string WorkerStatus(bool enabled) => enabled ? "ok" : "idle";
|
||||
|
||||
private double? MinutesSinceLastCapture =>
|
||||
_health.LastSnapshotAt is { } at ? (MoscowTime.Now - at).TotalMinutes : null;
|
||||
|
||||
private string FreshnessText
|
||||
{
|
||||
get
|
||||
{
|
||||
if (MinutesSinceLastCapture is not { } mins)
|
||||
return L["Health.LastCapture.Never"];
|
||||
return string.Format(CultureInfo.CurrentCulture, L["Health.MinutesAgo"].Value, (int)Math.Max(0, mins));
|
||||
}
|
||||
}
|
||||
|
||||
// Green when fresh (<15 min), amber when slowing (<60 min), signal-red when stale.
|
||||
private string FreshnessColor => MinutesSinceLastCapture switch
|
||||
{
|
||||
null => "var(--m-c-ink-soft)",
|
||||
< 15 => "var(--m-c-positive)",
|
||||
< 60 => "var(--m-c-accent)",
|
||||
_ => "var(--m-c-anomaly)",
|
||||
};
|
||||
}
|
||||
@@ -24,6 +24,10 @@
|
||||
Delta="@AnomaliesDelta"
|
||||
Anomaly="true" />
|
||||
<StatCard Label="@L["Home.Stat.Coverage"]" Value="@_summary.SportsCovered.ToString()" />
|
||||
@if (_summary.HasPaperTrades)
|
||||
{
|
||||
<StatCard Label="@L["Home.Stat.ForwardPnl"]" Value="@PaperNet" Delta="@PaperDelta" />
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="m-grid--asym m-rise m-rise-3" style="margin-top: var(--m-space-6);">
|
||||
@@ -39,7 +43,7 @@
|
||||
{
|
||||
@* First-run: nothing captured yet. Make the next step unmissable. *@
|
||||
<div data-test="home-empty" style="display: grid; gap: var(--m-space-4); padding: var(--m-space-4) 0;">
|
||||
<span class="m-mono" style="font-size: 0.6875rem; text-transform: uppercase; letter-spacing: 0.16em; color: var(--m-c-accent);">
|
||||
<span class="m-mono" style="font-size: 0.6875rem; text-transform: uppercase; letter-spacing: 0.16em; color: var(--m-c-info);">
|
||||
@L["Home.Empty.Heading"]
|
||||
</span>
|
||||
<p style="color: var(--m-c-ink-soft); max-width: 48ch; margin: 0;">
|
||||
@@ -47,7 +51,7 @@
|
||||
</p>
|
||||
<div>
|
||||
<a href="/settings" data-test="home-empty-cta"
|
||||
style="display: inline-flex; align-items: center; gap: 8px; padding: 10px 18px; border: 1px solid var(--m-c-accent); color: var(--m-c-accent); font-family: var(--m-font-mono); font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.12em; text-decoration: none;">
|
||||
style="display: inline-flex; align-items: center; gap: 8px; padding: 10px 18px; background: var(--m-c-accent); color: var(--m-c-on-accent); border: 2px solid var(--m-c-ink); border-radius: var(--m-radius-md); box-shadow: var(--m-shadow-hard-sm); font-family: var(--m-font-mono); font-weight: 700; font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.12em; text-decoration: none;">
|
||||
@L["Home.Empty.Cta"] →
|
||||
</a>
|
||||
</div>
|
||||
@@ -58,37 +62,42 @@
|
||||
@* Capturing, but the detector hasn't flagged anything yet. *@
|
||||
<div data-test="home-no-signals" style="display: grid; gap: var(--m-space-3); padding: var(--m-space-3) 0; border-top: 1px solid var(--m-c-rule);">
|
||||
<p style="color: var(--m-c-ink-soft); margin: 0;">@L["Home.NoSignals"]</p>
|
||||
<a href="/anomalies" style="font-family: var(--m-font-mono); font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.12em; color: var(--m-c-accent); text-decoration: none;">
|
||||
<a href="/anomalies" style="font-family: var(--m-font-mono); font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.12em; color: var(--m-c-info); text-decoration: none;">
|
||||
@L["Home.ViewAll"] →
|
||||
</a>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div style="display: grid; gap: var(--m-space-4);">
|
||||
<div class="m-signal-feed" data-test="home-signals">
|
||||
@foreach (var signal in _summary.LatestSignals)
|
||||
{
|
||||
<a href="@($"/anomalies/{signal.Id}")" data-test="home-signal"
|
||||
style="display: grid; grid-template-columns: 80px 1fr auto; gap: var(--m-space-4); padding: var(--m-space-3) 0; border-top: 1px solid var(--m-c-rule); text-decoration: none; color: inherit;">
|
||||
<div class="m-mono" style="font-size: 0.75rem; color: var(--m-c-ink-soft); text-transform: uppercase; letter-spacing: 0.1em;">
|
||||
@FormatSignalTime(signal.DetectedAt)
|
||||
</div>
|
||||
<div>
|
||||
<div style="font-weight: 500;">@signal.EventTitle</div>
|
||||
<div style="color: var(--m-c-ink-soft); font-size: 0.8125rem;">
|
||||
@SportLabel(signal.Sport.Value) · @SeverityLabel(signal.Severity)
|
||||
<a href="@($"/anomalies/{signal.Id}")" class="m-signal" data-test="home-signal">
|
||||
<SportIcon Code="@signal.Sport.Value" Label="@SportLabel(signal.Sport.Value)" ClassName="m-signal__icon" />
|
||||
<div class="m-signal__mid">
|
||||
<div class="m-signal__meta m-mono">
|
||||
@FormatSignalTime(signal.DetectedAt) · @SportLabel(signal.Sport.Value) · @signal.CountryCode
|
||||
</div>
|
||||
<div class="m-signal__teams">@signal.EventTitle</div>
|
||||
<div class="m-signal__mkts">
|
||||
@Chip("1", signal.PreWin1Rate, signal.PostWin1Rate)
|
||||
@if (!signal.IsTwoWay)
|
||||
{
|
||||
@Chip("X", signal.PreDrawRate, signal.PostDrawRate)
|
||||
}
|
||||
@Chip("2", signal.PreWin2Rate, signal.PostWin2Rate)
|
||||
</div>
|
||||
</div>
|
||||
<span class="m-anomaly">
|
||||
<span class="m-anomaly__pulse"></span>
|
||||
@signal.Score.ToString("0.00", CultureInfo.InvariantCulture)
|
||||
</span>
|
||||
<div class="m-signal__right">
|
||||
<SeverityBadge Severity="signal.Severity" ShowScore="false" ShowDot="false" />
|
||||
<span class="m-signal__score" data-numeric>@signal.Score.ToString("0.00", CultureInfo.InvariantCulture)</span>
|
||||
</div>
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div style="margin-top: var(--m-space-5); padding-top: var(--m-space-3); border-top: 1px solid var(--m-c-rule);">
|
||||
<a href="/anomalies" style="font-family: var(--m-font-mono); font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.12em; color: var(--m-c-accent); text-decoration: none;">
|
||||
<a href="/anomalies" style="font-family: var(--m-font-mono); font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.12em; color: var(--m-c-info); text-decoration: none;">
|
||||
@L["Home.ViewAll"] →
|
||||
</a>
|
||||
</div>
|
||||
@@ -107,6 +116,75 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.m-signal-feed { display: flex; flex-direction: column; }
|
||||
.m-signal {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
gap: var(--m-space-4);
|
||||
align-items: center;
|
||||
padding: var(--m-space-3) 0;
|
||||
border-top: 1px solid var(--m-c-rule);
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
transition: transform 120ms ease;
|
||||
}
|
||||
.m-signal:hover { transform: translateX(2px); }
|
||||
.m-signal:focus-visible { outline: 2px solid var(--m-c-info); outline-offset: 2px; }
|
||||
.m-signal__icon { --m-sport-size: 28px; margin-top: 2px; }
|
||||
.m-signal__mid { min-width: 0; display: grid; gap: 5px; }
|
||||
.m-signal__meta {
|
||||
font-size: 0.6875rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--m-c-ink-soft);
|
||||
}
|
||||
.m-signal__teams {
|
||||
font-family: var(--m-font-display);
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.01em;
|
||||
font-size: 1.0625rem;
|
||||
line-height: 1.1;
|
||||
color: var(--m-c-ink);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.m-signal__mkts { display: flex; flex-wrap: wrap; gap: var(--m-space-2); margin-top: 2px; }
|
||||
.m-signal__mkt {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 6px;
|
||||
padding: 3px 8px;
|
||||
border: 2px solid var(--m-c-ink);
|
||||
border-radius: var(--m-radius-sm);
|
||||
background: var(--m-c-paper);
|
||||
color: var(--m-c-ink);
|
||||
font-family: var(--m-font-mono);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.m-signal__mkt-k { font-weight: 700; opacity: 0.55; }
|
||||
.m-signal__mkt-pre { opacity: 0.55; text-decoration: line-through; }
|
||||
.m-signal__mkt-arrow { opacity: 0.55; }
|
||||
.m-signal__mkt-post { font-weight: 700; }
|
||||
.m-signal__mkt--up { background: var(--m-c-accent); color: var(--m-c-on-accent); border-color: var(--m-c-ink); }
|
||||
.m-signal__mkt--dn { background: color-mix(in srgb, var(--m-c-anomaly) 14%, var(--m-c-paper)); }
|
||||
.m-signal__mkt--dn .m-signal__mkt-post { color: var(--m-c-anomaly); }
|
||||
.m-signal__right { display: flex; flex-direction: column; align-items: flex-end; gap: var(--m-space-2); }
|
||||
.m-signal__score {
|
||||
font-family: var(--m-font-display);
|
||||
font-weight: 700;
|
||||
font-size: 1.75rem;
|
||||
line-height: 1;
|
||||
color: var(--m-c-ink);
|
||||
}
|
||||
@@media (max-width: 560px) {
|
||||
.m-signal { grid-template-columns: auto minmax(0, 1fr); }
|
||||
.m-signal__right { grid-column: 1 / -1; flex-direction: row; align-items: center; justify-content: space-between; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@code {
|
||||
private DashboardSummary _summary = DashboardSummary.Empty;
|
||||
|
||||
@@ -114,6 +192,22 @@
|
||||
? string.Format(CultureInfo.CurrentCulture, L["Home.Stat.NewToday"], _summary.AnomaliesToday)
|
||||
: null;
|
||||
|
||||
// Forward-test headline: settled-only net P&L, with open-bet count as the delta line.
|
||||
private string PaperNet
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_summary.PaperSettledCount == 0) return "—";
|
||||
var v = _summary.PaperNetProfit;
|
||||
var sign = v > 0m ? "+" : (v < 0m ? "-" : "");
|
||||
return sign + Math.Abs(v).ToString("0.00", CultureInfo.InvariantCulture);
|
||||
}
|
||||
}
|
||||
|
||||
private string? PaperDelta => _summary.PaperOpenCount > 0
|
||||
? string.Format(CultureInfo.CurrentCulture, L["Home.Stat.ForwardOpen"], _summary.PaperOpenCount)
|
||||
: null;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
try
|
||||
@@ -137,10 +231,40 @@
|
||||
|
||||
private string SportLabel(int code) => SportLabels.Resolve(L, code);
|
||||
|
||||
private string SeverityLabel(AnomalySeverity severity) => severity switch
|
||||
// Direction of an odds move for the market chip colour: up = drifted out, dn = shortened.
|
||||
private static string ChipDir(decimal? pre, decimal? post) =>
|
||||
pre is { } p && post is { } q && p != q ? (q > p ? "up" : "dn") : string.Empty;
|
||||
|
||||
private static string FormatRate(decimal? r) =>
|
||||
r is { } v ? v.ToString("0.00", CultureInfo.InvariantCulture) : "—";
|
||||
|
||||
// A single 1 / X / 2 market chip: label · struck pre · → · post (coloured by direction).
|
||||
private RenderFragment Chip(string label, decimal? pre, decimal? post) => builder =>
|
||||
{
|
||||
AnomalySeverity.High => L["Anomaly.Severity.High"],
|
||||
AnomalySeverity.Medium => L["Anomaly.Severity.Medium"],
|
||||
_ => L["Anomaly.Severity.Low"],
|
||||
var dir = ChipDir(pre, post);
|
||||
builder.OpenElement(0, "span");
|
||||
builder.AddAttribute(1, "class", dir.Length > 0 ? $"m-signal__mkt m-signal__mkt--{dir}" : "m-signal__mkt");
|
||||
|
||||
builder.OpenElement(2, "span");
|
||||
builder.AddAttribute(3, "class", "m-signal__mkt-k");
|
||||
builder.AddContent(4, label);
|
||||
builder.CloseElement();
|
||||
|
||||
builder.OpenElement(5, "span");
|
||||
builder.AddAttribute(6, "class", "m-signal__mkt-pre");
|
||||
builder.AddContent(7, FormatRate(pre));
|
||||
builder.CloseElement();
|
||||
|
||||
builder.OpenElement(8, "span");
|
||||
builder.AddAttribute(9, "class", "m-signal__mkt-arrow");
|
||||
builder.AddContent(10, "→");
|
||||
builder.CloseElement();
|
||||
|
||||
builder.OpenElement(11, "span");
|
||||
builder.AddAttribute(12, "class", "m-signal__mkt-post");
|
||||
builder.AddContent(13, FormatRate(post));
|
||||
builder.CloseElement();
|
||||
|
||||
builder.CloseElement();
|
||||
};
|
||||
}
|
||||
|
||||
@@ -259,14 +259,28 @@
|
||||
<p class="m-journal__form-error" data-test="journal-add-error">@_formError</p>
|
||||
}
|
||||
|
||||
@if (_editingId is not null)
|
||||
{
|
||||
<p class="m-journal__form-hint" data-test="journal-editing-banner">@L["Journal.Editing"]</p>
|
||||
}
|
||||
|
||||
<div class="m-journal__form-actions">
|
||||
@if (_editingId is not null)
|
||||
{
|
||||
<button type="button"
|
||||
class="m-chip m-journal__chip"
|
||||
@onclick="CancelEdit"
|
||||
data-test="journal-edit-cancel">
|
||||
@L["Journal.Action.Cancel"]
|
||||
</button>
|
||||
}
|
||||
<button type="button"
|
||||
class="m-chip m-journal__submit"
|
||||
@onclick="SubmitAsync"
|
||||
disabled="@_submitting"
|
||||
data-test="journal-add-submit">
|
||||
<span class="m-journal__chip-glyph @(_submitting ? "is-spinning" : null)" aria-hidden="true">+</span>
|
||||
<span>@L["Journal.Action.Submit"]</span>
|
||||
<span class="m-journal__chip-glyph @(_submitting ? "is-spinning" : null)" aria-hidden="true">@(_editingId is null ? "+" : "✓")</span>
|
||||
<span>@(_editingId is null ? L["Journal.Action.Submit"] : L["Journal.Action.SaveEdit"])</span>
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
@@ -359,6 +373,14 @@
|
||||
}
|
||||
else
|
||||
{
|
||||
<button type="button"
|
||||
class="m-chip m-journal__chip m-journal__chip--ghost"
|
||||
@onclick="@(() => BeginEdit(row))"
|
||||
data-test="@($"journal-edit-{row.Id}")"
|
||||
aria-label="@L["Journal.Action.EditBet"]">
|
||||
<span aria-hidden="true">✎</span>
|
||||
<span>@L["Journal.Action.EditBet"]</span>
|
||||
</button>
|
||||
<button type="button"
|
||||
class="m-chip m-journal__chip m-journal__chip--ghost"
|
||||
@onclick="@(() => RequestDelete(row.Id))"
|
||||
@@ -557,8 +579,8 @@
|
||||
gap: var(--m-space-3);
|
||||
}
|
||||
.m-journal__submit {
|
||||
border-color: var(--m-c-accent);
|
||||
color: var(--m-c-accent);
|
||||
border-color: var(--m-c-info);
|
||||
color: var(--m-c-info);
|
||||
}
|
||||
.m-journal__submit:not(:disabled):hover {
|
||||
background: var(--m-c-accent);
|
||||
@@ -702,6 +724,7 @@
|
||||
private bool _resolving;
|
||||
private string? _formError;
|
||||
private Guid? _pendingDeleteId;
|
||||
private Guid? _editingId;
|
||||
private AddBetForm _form = new();
|
||||
|
||||
// Kelly stake-helper state — page-local (not persisted with the bet). Bankroll
|
||||
@@ -851,10 +874,19 @@
|
||||
var ct = _loadCts?.Token ?? CancellationToken.None;
|
||||
try
|
||||
{
|
||||
await Service.AddAsync(_form, ct);
|
||||
if (_editingId is { } editId)
|
||||
{
|
||||
await Service.UpdateAsync(editId, _form, ct);
|
||||
Snackbar.Add(L["Journal.Edited"].Value, Severity.Success);
|
||||
}
|
||||
else
|
||||
{
|
||||
await Service.AddAsync(_form, ct);
|
||||
Snackbar.Add(L["Journal.Submitted"].Value, Severity.Success);
|
||||
}
|
||||
_editingId = null;
|
||||
_form = new AddBetForm();
|
||||
_formError = null;
|
||||
Snackbar.Add(L["Journal.Submitted"].Value, Severity.Success);
|
||||
await LoadAsync();
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
@@ -891,6 +923,31 @@
|
||||
}
|
||||
}
|
||||
|
||||
private void BeginEdit(BetJournalRowVm row)
|
||||
{
|
||||
var b = row.Bet;
|
||||
_editingId = row.Id;
|
||||
_pendingDeleteId = null;
|
||||
_form = new AddBetForm
|
||||
{
|
||||
EventId = b.EventId.Value,
|
||||
Type = b.Selection.Type,
|
||||
Side = b.Selection.Side,
|
||||
Value = b.Selection.Value?.Value,
|
||||
Rate = b.Selection.Rate.Value,
|
||||
Stake = b.Stake,
|
||||
Notes = b.Notes,
|
||||
};
|
||||
_formError = null;
|
||||
}
|
||||
|
||||
private void CancelEdit()
|
||||
{
|
||||
_editingId = null;
|
||||
_form = new AddBetForm();
|
||||
_formError = null;
|
||||
}
|
||||
|
||||
private void RequestDelete(Guid id)
|
||||
{
|
||||
_pendingDeleteId = id;
|
||||
|
||||
@@ -156,11 +156,11 @@
|
||||
border-radius: var(--m-radius-xs);
|
||||
border: 1px solid var(--m-c-rule);
|
||||
}
|
||||
.m-result-winner--side1 { background: rgba(34,197,94,0.10); color: #15803d; border-color: rgba(34,197,94,0.30); }
|
||||
.m-result-winner--side2 { background: rgba(59,130,246,0.10); color: #1d4ed8; border-color: rgba(59,130,246,0.30); }
|
||||
.m-result-winner--side1 { background: rgba(31,158,61,0.10); color: var(--m-c-positive); border-color: rgba(31,158,61,0.32); }
|
||||
.m-result-winner--side2 { background: rgba(36,75,255,0.10); color: var(--m-c-info); border-color: rgba(36,75,255,0.32); }
|
||||
.m-result-winner--draw { background: rgba(120,113,108,0.10); color: var(--m-c-ink-soft); }
|
||||
[data-theme="dark"] .m-result-winner--side1 { color: #4ade80; background: rgba(34,197,94,0.15); }
|
||||
[data-theme="dark"] .m-result-winner--side2 { color: #93c5fd; background: rgba(59,130,246,0.15); }
|
||||
[data-theme="dark"] .m-result-winner--side1 { background: rgba(74,222,128,0.16); }
|
||||
[data-theme="dark"] .m-result-winner--side2 { background: rgba(111,139,255,0.16); }
|
||||
</style>
|
||||
|
||||
@code {
|
||||
|
||||
@@ -198,8 +198,8 @@
|
||||
border-radius: var(--m-radius-xs);
|
||||
border: 1px solid var(--m-c-rule);
|
||||
}
|
||||
.m-result-winner--side1 { background: rgba(34,197,94,0.10); color: #15803d; border-color: rgba(34,197,94,0.30); }
|
||||
.m-result-winner--side2 { background: rgba(59,130,246,0.10); color: #1d4ed8; border-color: rgba(59,130,246,0.30); }
|
||||
.m-result-winner--side1 { background: rgba(31,158,61,0.10); color: var(--m-c-positive); border-color: rgba(31,158,61,0.32); }
|
||||
.m-result-winner--side2 { background: rgba(36,75,255,0.10); color: var(--m-c-info); border-color: rgba(36,75,255,0.32); }
|
||||
.m-result-winner--draw { background: rgba(120,113,108,0.10); color: var(--m-c-ink-soft); }
|
||||
</style>
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
@inject IOptionsMonitor<WorkerOptions> WorkerOpts
|
||||
@inject IOptionsMonitor<StorageOptions> StorageOpts
|
||||
@inject IOptionsMonitor<AnomalyOptions> AnomalyOpts
|
||||
@inject IOptionsMonitor<PaperTradingSettingsForm> PaperTradingOpts
|
||||
@inject IOptionsMonitor<Marathon.UI.Services.LocalizationOptions> LocaleOpts
|
||||
@inject ISettingsWriter Writer
|
||||
@inject IDialogService Dialogs
|
||||
@@ -49,7 +50,7 @@
|
||||
<Field Label="@L["Settings.Scraping.RetryBaseDelayMs"]">
|
||||
<MudNumericField T="int" @bind-Value="_scraping.RetryPolicy.BaseDelayMs" Min="100" Max="60000" Variant="Variant.Outlined" />
|
||||
</Field>
|
||||
<Field Label="@L["Settings.Scraping.BaseUrl"]">
|
||||
<Field Label="@L["Settings.Scraping.BaseUrl"]" Hint="@L["Settings.Scraping.BaseUrl.Hint"]">
|
||||
<MudTextField T="string" @bind-Value="_scraping.BaseUrl" Variant="Variant.Outlined" />
|
||||
</Field>
|
||||
<Field Label="@L["Settings.Scraping.RequestTimeoutSeconds"]">
|
||||
@@ -157,6 +158,33 @@
|
||||
</div>
|
||||
</article>
|
||||
|
||||
@* FORWARD-TEST (PAPER TRADING) *@
|
||||
<article class="m-section m-rise m-rise-5">
|
||||
<header class="m-section__head">
|
||||
<h2>@L["Settings.Section.PaperTrading"]</h2>
|
||||
<MudButton Variant="Variant.Text" Size="Size.Small"
|
||||
OnClick="@(() => ResetSectionAsync(PaperTradingSettingsForm.SectionName))">
|
||||
@L["Settings.Action.Reset"]
|
||||
</MudButton>
|
||||
</header>
|
||||
<div class="m-section__body">
|
||||
<Field Label="@L["Settings.PaperTrading.Enabled"]" Hint="@L["Settings.PaperTrading.Enabled.Hint"]">
|
||||
<MudSwitch T="bool" @bind-Value="_paperTrading.Enabled" Color="Color.Primary" />
|
||||
</Field>
|
||||
<Field Label="@L["Settings.PaperTrading.MinScore"]" Hint="@L["Settings.PaperTrading.MinScore.Hint"]">
|
||||
<MudNumericField T="decimal" @bind-Value="_paperTrading.MinScore" Min="0m" Max="1m" Step="0.05m" Variant="Variant.Outlined" />
|
||||
</Field>
|
||||
<Field Label="@L["Settings.PaperTrading.FlatStake"]" Hint="@L["Settings.PaperTrading.FlatStake.Hint"]">
|
||||
<MudNumericField T="decimal" @bind-Value="_paperTrading.FlatStake" Min="0.01m" Step="5m" Variant="Variant.Outlined" />
|
||||
</Field>
|
||||
<Field Label="@L["Settings.PaperTrading.PollIntervalSeconds"]">
|
||||
<MudNumericField T="int" @bind-Value="_paperTrading.PollIntervalSeconds" Min="5" Max="3600" Variant="Variant.Outlined" />
|
||||
</Field>
|
||||
|
||||
<SectionFooter OnSave="@(() => SaveSectionAsync(PaperTradingSettingsForm.SectionName, _paperTrading))" />
|
||||
</div>
|
||||
</article>
|
||||
|
||||
@* LOCALIZATION *@
|
||||
<article class="m-section m-rise m-rise-5">
|
||||
<header class="m-section__head">
|
||||
@@ -184,6 +212,7 @@
|
||||
private WorkerOptions _workers = new();
|
||||
private StorageOptions _storage = new();
|
||||
private AnomalyOptions _anomaly = new();
|
||||
private PaperTradingSettingsForm _paperTrading = new();
|
||||
private LocalizationOptions _locale = new();
|
||||
private string _userAgentsRaw = string.Empty;
|
||||
|
||||
@@ -218,6 +247,14 @@
|
||||
DetectionIntervalSeconds = AnomalyOpts.CurrentValue.DetectionIntervalSeconds,
|
||||
};
|
||||
|
||||
_paperTrading = new PaperTradingSettingsForm
|
||||
{
|
||||
Enabled = PaperTradingOpts.CurrentValue.Enabled,
|
||||
MinScore = PaperTradingOpts.CurrentValue.MinScore,
|
||||
FlatStake = PaperTradingOpts.CurrentValue.FlatStake,
|
||||
PollIntervalSeconds = PaperTradingOpts.CurrentValue.PollIntervalSeconds,
|
||||
};
|
||||
|
||||
_locale = new LocalizationOptions { DefaultCulture = LocaleOpts.CurrentValue.DefaultCulture };
|
||||
}
|
||||
|
||||
@@ -242,6 +279,20 @@
|
||||
}
|
||||
}
|
||||
|
||||
if (payload is ScrapingSettingsForm scraping
|
||||
&& !(Uri.TryCreate(scraping.BaseUrl, UriKind.Absolute, out var baseUri)
|
||||
&& (baseUri.Scheme == Uri.UriSchemeHttp || baseUri.Scheme == Uri.UriSchemeHttps)))
|
||||
{
|
||||
Snackbar.Add(L["Settings.Scraping.BaseUrl.Invalid"], Severity.Error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (payload is WorkerOptions workers && !IsPlausibleCron(workers.UpcomingScheduleCron))
|
||||
{
|
||||
Snackbar.Add(L["Settings.Workers.Cron.Invalid"], Severity.Error);
|
||||
return;
|
||||
}
|
||||
|
||||
var confirmed = await ConfirmAsync();
|
||||
if (!confirmed)
|
||||
{
|
||||
@@ -260,6 +311,15 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Lightweight 5- or 6-field cron sanity check — avoids a Cronos dependency in the
|
||||
// UI layer; the worker still does the authoritative parse at startup.
|
||||
private static bool IsPlausibleCron(string? expression)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(expression)) return false;
|
||||
var fields = expression.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||
return fields.Length is 5 or 6;
|
||||
}
|
||||
|
||||
private async Task ResetSectionAsync(string section)
|
||||
{
|
||||
var confirmed = await ConfirmAsync();
|
||||
|
||||
@@ -516,7 +516,7 @@
|
||||
{
|
||||
if (_filter.SortKey != key) return;
|
||||
builder.OpenElement(0, "span");
|
||||
builder.AddAttribute(1, "style", "margin-left: 6px; color: var(--m-c-accent);");
|
||||
builder.AddAttribute(1, "style", "margin-left: 6px; color: var(--m-c-info);");
|
||||
builder.AddContent(2, _filter.SortDescending ? "▼" : "▲");
|
||||
builder.CloseElement();
|
||||
};
|
||||
|
||||
@@ -65,6 +65,7 @@
|
||||
<data name="Nav.Results"><value>Results</value></data>
|
||||
<data name="Nav.Settings"><value>Settings</value></data>
|
||||
<data name="Nav.Export"><value>Export</value></data>
|
||||
<data name="Nav.Health"><value>Health</value></data>
|
||||
|
||||
<data name="Home.Kicker"><value>Briefing</value></data>
|
||||
<data name="Home.Title"><value>Hunting odds-flip anomalies</value></data>
|
||||
@@ -73,6 +74,8 @@
|
||||
<data name="Home.Stat.Snapshots"><value>Snapshots today</value></data>
|
||||
<data name="Home.Stat.Anomalies"><value>Anomalies flagged</value></data>
|
||||
<data name="Home.Stat.Coverage"><value>Sports covered</value></data>
|
||||
<data name="Home.Stat.ForwardPnl"><value>Forward-test P&L</value></data>
|
||||
<data name="Home.Stat.ForwardOpen"><value>{0} open</value></data>
|
||||
<data name="Home.Section.Latest"><value>Latest signals</value></data>
|
||||
<data name="Home.Section.Pipeline"><value>Capture pipeline</value></data>
|
||||
<data name="Home.Pipeline.Step1"><value>Schedule capture (`/su`)</value></data>
|
||||
@@ -96,6 +99,14 @@
|
||||
<data name="Settings.Section.Workers"><value>Background workers</value></data>
|
||||
<data name="Settings.Section.Storage"><value>Storage</value></data>
|
||||
<data name="Settings.Section.Anomaly"><value>Anomaly detector</value></data>
|
||||
<data name="Settings.Section.PaperTrading"><value>Forward-test (paper trading)</value></data>
|
||||
<data name="Settings.PaperTrading.Enabled"><value>Enable forward-test worker</value></data>
|
||||
<data name="Settings.PaperTrading.Enabled.Hint"><value>Opens a flat-stake paper bet on each live directional signal and settles it when the result lands.</value></data>
|
||||
<data name="Settings.PaperTrading.MinScore"><value>Min score</value></data>
|
||||
<data name="Settings.PaperTrading.MinScore.Hint"><value>Only anomalies at or above this score are paper-traded.</value></data>
|
||||
<data name="Settings.PaperTrading.FlatStake"><value>Flat stake</value></data>
|
||||
<data name="Settings.PaperTrading.FlatStake.Hint"><value>Stake placed on every paper bet.</value></data>
|
||||
<data name="Settings.PaperTrading.PollIntervalSeconds"><value>Poll interval (sec)</value></data>
|
||||
<data name="Settings.Section.Localization"><value>Localization</value></data>
|
||||
<data name="Settings.Action.Reset"><value>Reset section</value></data>
|
||||
<data name="Settings.Action.Save"><value>Save</value></data>
|
||||
@@ -116,6 +127,9 @@
|
||||
<data name="Settings.Scraping.RateLimitRps"><value>Rate limit (RPS)</value></data>
|
||||
<data name="Settings.Scraping.RateLimitRps.Hint"><value>Requests per second. 1 is recommended.</value></data>
|
||||
<data name="Settings.Scraping.BaseUrl"><value>Base URL</value></data>
|
||||
<data name="Settings.Scraping.BaseUrl.Hint"><value>Must be an absolute http(s) URL, e.g. https://www.marathonbet.by</value></data>
|
||||
<data name="Settings.Scraping.BaseUrl.Invalid"><value>Base URL must be an absolute http(s) address.</value></data>
|
||||
<data name="Settings.Workers.Cron.Invalid"><value>Schedule must be a 5- or 6-field cron expression.</value></data>
|
||||
<data name="Settings.Scraping.RequestTimeoutSeconds"><value>Request timeout (sec)</value></data>
|
||||
<data name="Settings.Scraping.UsePlaywright"><value>Use Playwright</value></data>
|
||||
|
||||
@@ -158,12 +172,15 @@
|
||||
<data name="Common.Reset"><value>Reset</value></data>
|
||||
<data name="Common.Loading"><value>Loading…</value></data>
|
||||
<data name="Common.Empty"><value>No data</value></data>
|
||||
<data name="Common.Delete"><value>Delete</value></data>
|
||||
<data name="Common.Yes"><value>Yes</value></data>
|
||||
<data name="Common.No"><value>No</value></data>
|
||||
|
||||
<data name="Anomaly.Live"><value>Anomaly</value></data>
|
||||
<data name="Anomaly.Kind.SuspensionFlip"><value>Suspension flip</value></data>
|
||||
<data name="Anomaly.Kind.SteamMove"><value>Steam move</value></data>
|
||||
<data name="Anomaly.Kind.SuspensionFreeze"><value>Suspension freeze</value></data>
|
||||
<data name="Anomaly.Kind.OverroundCompression"><value>Margin compression</value></data>
|
||||
<data name="Anomaly.Score"><value>Confidence</value></data>
|
||||
|
||||
<!-- Phase 7 — Anomaly feed UI -->
|
||||
@@ -175,6 +192,11 @@
|
||||
<data name="Anomaly.Filter.AnySeverity"><value>Any</value></data>
|
||||
<data name="Anomaly.Filter.Severity"><value>Min severity</value></data>
|
||||
<data name="Anomaly.Filter.Sport"><value>Sport</value></data>
|
||||
<data name="Anomaly.Filter.Kind"><value>Kind</value></data>
|
||||
<data name="Anomaly.Filter.Sort"><value>Sort</value></data>
|
||||
<data name="Anomaly.Sort.Newest"><value>Newest</value></data>
|
||||
<data name="Anomaly.Sort.Score"><value>Top score</value></data>
|
||||
<data name="Anomaly.Sort.Gap"><value>Longest gap</value></data>
|
||||
<data name="Anomaly.Filter.From"><value>Detected from</value></data>
|
||||
<data name="Anomaly.Filter.To"><value>Detected to</value></data>
|
||||
<data name="Anomaly.Filter.DateRange"><value>Date range</value></data>
|
||||
@@ -268,6 +290,28 @@
|
||||
<data name="Export.Hub.Lede"><value>Export captured odds snapshots to an Excel workbook for any date range — no need to open a specific event first.</value></data>
|
||||
<data name="Export.Hub.Action"><value>Configure export</value></data>
|
||||
<data name="Export.Hub.FilenameHint"><value>Saved as Marathon_<from>_to_<to>.xlsx in the configured export directory.</value></data>
|
||||
<data name="Export.Csv.Section"><value>Journal & ledger (CSV)</value></data>
|
||||
<data name="Export.Csv.Journal"><value>Export bet journal</value></data>
|
||||
<data name="Export.Csv.Ledger"><value>Export forward-test ledger</value></data>
|
||||
<data name="Export.Csv.Empty"><value>Nothing to export yet.</value></data>
|
||||
<data name="Export.Csv.Error"><value>CSV export failed — see the log for details.</value></data>
|
||||
<data name="Health.Kicker"><value>Operations</value></data>
|
||||
<data name="Health.Title"><value>Pipeline health</value></data>
|
||||
<data name="Health.Lede"><value>Capture freshness, recent volumes, and worker status at a glance.</value></data>
|
||||
<data name="Health.LastCapture"><value>Last capture</value></data>
|
||||
<data name="Health.LastCapture.Never"><value>no captures yet</value></data>
|
||||
<data name="Health.MinutesAgo"><value>{0} min ago</value></data>
|
||||
<data name="Health.Stat.Snapshots"><value>Snapshots (24h)</value></data>
|
||||
<data name="Health.Stat.Anomalies"><value>Anomalies (24h)</value></data>
|
||||
<data name="Health.Stat.Events"><value>Events tracked</value></data>
|
||||
<data name="Health.Stat.Sports"><value>Sports covered</value></data>
|
||||
<data name="Health.Total"><value>{0} total</value></data>
|
||||
<data name="Health.Workers"><value>Workers</value></data>
|
||||
<data name="Health.Worker.Schedule"><value>Schedule poller</value></data>
|
||||
<data name="Health.Worker.Live"><value>Live poller</value></data>
|
||||
<data name="Health.Worker.Detection"><value>Anomaly detection</value></data>
|
||||
<data name="Health.Worker.Results"><value>Results poller</value></data>
|
||||
<data name="Health.Empty"><value>No data captured yet — enable the pollers in Settings.</value></data>
|
||||
<data name="Export.Error.MissingDates"><value>Pick a start and end date.</value></data>
|
||||
<data name="Export.Error.InvalidRange"><value>End date must be on or after the start date.</value></data>
|
||||
<data name="Export.Error.Failed"><value>Export failed.</value></data>
|
||||
@@ -335,6 +379,7 @@
|
||||
<data name="Insights.Stat.Total"><value>Total anomalies</value></data>
|
||||
<data name="Insights.Section.BySeverity"><value>By severity</value></data>
|
||||
<data name="Insights.Section.BySport"><value>By sport</value></data>
|
||||
<data name="Insights.Section.ByKind"><value>By detector kind</value></data>
|
||||
<data name="Insights.Section.ByScore"><value>By confidence score</value></data>
|
||||
<data name="Insights.Section.Resolved"><value>Resolved anomalies</value></data>
|
||||
<data name="Insights.Section.Unresolved"><value>Awaiting results</value></data>
|
||||
@@ -384,6 +429,10 @@
|
||||
<data name="Journal.Action.Refresh"><value>Refresh</value></data>
|
||||
<data name="Journal.Action.Resolve"><value>Resolve pending</value></data>
|
||||
<data name="Journal.Action.Submit"><value>Record bet</value></data>
|
||||
<data name="Journal.Action.SaveEdit"><value>Save changes</value></data>
|
||||
<data name="Journal.Action.EditBet"><value>Edit</value></data>
|
||||
<data name="Journal.Edited"><value>Bet updated.</value></data>
|
||||
<data name="Journal.Editing"><value>Editing an existing bet — save to apply your changes, or cancel.</value></data>
|
||||
<data name="Journal.Action.Delete"><value>Delete</value></data>
|
||||
<data name="Journal.Action.Confirm"><value>Confirm</value></data>
|
||||
<data name="Journal.Action.Cancel"><value>Cancel</value></data>
|
||||
@@ -437,6 +486,8 @@
|
||||
<data name="Journal.Confirm.Delete"><value>Delete this bet permanently?</value></data>
|
||||
|
||||
<data name="Nav.Backtest"><value>Backtest</value></data>
|
||||
<data name="Nav.Compare"><value>Compare</value></data>
|
||||
<data name="Nav.PaperTrading"><value>Forward-test</value></data>
|
||||
<data name="Backtest.Kicker"><value>Simulator</value></data>
|
||||
<data name="Backtest.Title"><value>Replay the detector against history</value></data>
|
||||
<data name="Backtest.Lede"><value>Run a hypothetical strategy over every anomaly the detector has flagged. Choose a confidence threshold and a staking rule — the simulator settles every bet against the actual event result, compounds bankroll, and reports the headline numbers you need to judge edge.</value></data>
|
||||
@@ -445,6 +496,9 @@
|
||||
<data name="Backtest.Section.Equity"><value>Equity curve</value></data>
|
||||
<data name="Backtest.Section.Trace"><value>Trade trace</value></data>
|
||||
<data name="Backtest.Field.Bankroll"><value>Starting bankroll</value></data>
|
||||
<data name="Backtest.Field.From"><value>From date</value></data>
|
||||
<data name="Backtest.Field.To"><value>To date</value></data>
|
||||
<data name="Backtest.Field.DateRange.Hint"><value>Leave both empty to backtest every graded anomaly.</value></data>
|
||||
<data name="Backtest.Field.MinScore"><value>Min anomaly score</value></data>
|
||||
<data name="Backtest.Field.MinScore.Hint"><value>Only bet anomalies at or above this confidence.</value></data>
|
||||
<data name="Backtest.Field.StakeRule"><value>Staking rule</value></data>
|
||||
@@ -484,4 +538,51 @@
|
||||
<data name="Backtest.Empty.NoData"><value>No graded anomalies to simulate yet. Run the results loader so the detector has outcomes to replay against.</value></data>
|
||||
<data name="Backtest.Empty.NoBetsPlaced"><value>The strategy placed zero bets — try lowering the score threshold, or switch staking rule.</value></data>
|
||||
<data name="Backtest.Error.Generic"><value>Simulation failed — check the form values and try again.</value></data>
|
||||
<data name="Backtest.Presets.Label"><value>Saved strategies</value></data>
|
||||
<data name="Backtest.Presets.NameLabel"><value>Preset name</value></data>
|
||||
<data name="Backtest.Presets.Save"><value>Save preset</value></data>
|
||||
<data name="Backtest.Presets.Empty"><value>No saved strategies yet — tune the form, name it, and save a reusable preset.</value></data>
|
||||
<data name="Backtest.Presets.Loaded"><value>Strategy loaded</value></data>
|
||||
<data name="Backtest.Presets.Saved"><value>Strategy saved</value></data>
|
||||
<data name="Backtest.Presets.Deleted"><value>Strategy deleted</value></data>
|
||||
<data name="Backtest.Presets.NameRequired"><value>Enter a name to save this strategy.</value></data>
|
||||
<data name="Paper.Kicker"><value>Forward-test</value></data>
|
||||
<data name="Paper.Title"><value>Paper trading</value></data>
|
||||
<data name="Paper.Lede"><value>Out-of-sample proof: the worker opens a flat-stake paper bet on every live directional signal and settles it when the result lands — the antidote to backtest overfitting. Enable it under PaperTrading in settings.</value></data>
|
||||
<data name="Paper.Empty"><value>No paper bets yet. The forward-test worker is off by default — enable PaperTrading and bets will accrue here as new directional anomalies fire.</value></data>
|
||||
<data name="Paper.Section.Summary"><value>Settled P&L</value></data>
|
||||
<data name="Paper.Section.Ledger"><value>Ledger</value></data>
|
||||
<data name="Paper.Stat.NetProfit"><value>Net profit</value></data>
|
||||
<data name="Paper.Stat.Roi"><value>ROI</value></data>
|
||||
<data name="Paper.Stat.HitRate"><value>Hit rate</value></data>
|
||||
<data name="Paper.Stat.Open"><value>Open</value></data>
|
||||
<data name="Paper.Stat.Settled"><value>Settled</value></data>
|
||||
<data name="Paper.Stat.Wins"><value>Wins</value></data>
|
||||
<data name="Paper.Stat.Losses"><value>Losses</value></data>
|
||||
<data name="Paper.Stat.Staked"><value>Staked</value></data>
|
||||
<data name="Paper.Column.OpenedAt"><value>Opened</value></data>
|
||||
<data name="Paper.Column.Match"><value>Match</value></data>
|
||||
<data name="Paper.Column.Pick"><value>Pick</value></data>
|
||||
<data name="Paper.Column.Rate"><value>Rate</value></data>
|
||||
<data name="Paper.Column.Stake"><value>Stake</value></data>
|
||||
<data name="Paper.Column.Status"><value>Status</value></data>
|
||||
<data name="Paper.Column.Payout"><value>Payout</value></data>
|
||||
<data name="Paper.Outcome.Open"><value>Open</value></data>
|
||||
<data name="Paper.Outcome.Won"><value>Won</value></data>
|
||||
<data name="Paper.Outcome.Lost"><value>Lost</value></data>
|
||||
<data name="Paper.Outcome.Void"><value>Void</value></data>
|
||||
<data name="Compare.Kicker"><value>Strategy lab</value></data>
|
||||
<data name="Compare.Title"><value>Compare strategies</value></data>
|
||||
<data name="Compare.Lede"><value>Run every saved preset over the same window and rank them by ROI — find the staking configuration that actually holds up. Save presets on the Backtest page first.</value></data>
|
||||
<data name="Compare.Action.Run"><value>Compare</value></data>
|
||||
<data name="Compare.Empty"><value>No saved strategies to compare. Save one or more presets on the Backtest page, then return here to rank them head-to-head.</value></data>
|
||||
<data name="Compare.Section.Results"><value>Head to head</value></data>
|
||||
<data name="Compare.Best"><value>Best</value></data>
|
||||
<data name="Compare.Column.Strategy"><value>Strategy</value></data>
|
||||
<data name="Compare.Column.Bets"><value>Bets</value></data>
|
||||
<data name="Compare.Column.WinLoss"><value>W–L</value></data>
|
||||
<data name="Compare.Column.HitRate"><value>Hit %</value></data>
|
||||
<data name="Compare.Column.Net"><value>Net</value></data>
|
||||
<data name="Compare.Column.Roi"><value>ROI</value></data>
|
||||
<data name="Compare.Column.MaxDrawdown"><value>Max DD</value></data>
|
||||
</root>
|
||||
|
||||
@@ -67,6 +67,7 @@
|
||||
<data name="Nav.Results"><value>Результаты</value></data>
|
||||
<data name="Nav.Settings"><value>Настройки</value></data>
|
||||
<data name="Nav.Export"><value>Экспорт</value></data>
|
||||
<data name="Nav.Health"><value>Состояние</value></data>
|
||||
|
||||
<!-- Home / Dashboard -->
|
||||
<data name="Home.Kicker"><value>Сводка</value></data>
|
||||
@@ -76,6 +77,8 @@
|
||||
<data name="Home.Stat.Snapshots"><value>Снимков сегодня</value></data>
|
||||
<data name="Home.Stat.Anomalies"><value>Аномалий найдено</value></data>
|
||||
<data name="Home.Stat.Coverage"><value>Видов спорта</value></data>
|
||||
<data name="Home.Stat.ForwardPnl"><value>P&L форвард-теста</value></data>
|
||||
<data name="Home.Stat.ForwardOpen"><value>{0} открытых</value></data>
|
||||
<data name="Home.Section.Latest"><value>Свежий поток</value></data>
|
||||
<data name="Home.Section.Pipeline"><value>Конвейер сбора</value></data>
|
||||
<data name="Home.Pipeline.Step1"><value>Сбор расписания (`/su`)</value></data>
|
||||
@@ -100,6 +103,14 @@
|
||||
<data name="Settings.Section.Workers"><value>Фоновые задачи</value></data>
|
||||
<data name="Settings.Section.Storage"><value>Хранилище</value></data>
|
||||
<data name="Settings.Section.Anomaly"><value>Детектор аномалий</value></data>
|
||||
<data name="Settings.Section.PaperTrading"><value>Форвард-тест (бумажная торговля)</value></data>
|
||||
<data name="Settings.PaperTrading.Enabled"><value>Включить воркер форвард-теста</value></data>
|
||||
<data name="Settings.PaperTrading.Enabled.Hint"><value>Открывает ставку фиксированным стейком на каждый живой направленный сигнал и рассчитывает её по результату.</value></data>
|
||||
<data name="Settings.PaperTrading.MinScore"><value>Мин. score</value></data>
|
||||
<data name="Settings.PaperTrading.MinScore.Hint"><value>В форвард-тест попадают только аномалии со score не ниже указанного.</value></data>
|
||||
<data name="Settings.PaperTrading.FlatStake"><value>Фикс. стейк</value></data>
|
||||
<data name="Settings.PaperTrading.FlatStake.Hint"><value>Стейк на каждую бумажную ставку.</value></data>
|
||||
<data name="Settings.PaperTrading.PollIntervalSeconds"><value>Интервал опроса (сек)</value></data>
|
||||
<data name="Settings.Section.Localization"><value>Локализация</value></data>
|
||||
<data name="Settings.Action.Reset"><value>Сбросить раздел</value></data>
|
||||
<data name="Settings.Action.Save"><value>Сохранить</value></data>
|
||||
@@ -121,6 +132,9 @@
|
||||
<data name="Settings.Scraping.RateLimitRps"><value>Лимит RPS</value></data>
|
||||
<data name="Settings.Scraping.RateLimitRps.Hint"><value>Запросов в секунду. Рекомендовано 1.</value></data>
|
||||
<data name="Settings.Scraping.BaseUrl"><value>Базовый URL</value></data>
|
||||
<data name="Settings.Scraping.BaseUrl.Hint"><value>Должен быть абсолютный http(s)-адрес, например https://www.marathonbet.by</value></data>
|
||||
<data name="Settings.Scraping.BaseUrl.Invalid"><value>Базовый URL должен быть абсолютным http(s)-адресом.</value></data>
|
||||
<data name="Settings.Workers.Cron.Invalid"><value>Расписание должно быть cron-выражением из 5 или 6 полей.</value></data>
|
||||
<data name="Settings.Scraping.RequestTimeoutSeconds"><value>Тайм-аут запроса (сек)</value></data>
|
||||
<data name="Settings.Scraping.UsePlaywright"><value>Использовать Playwright</value></data>
|
||||
|
||||
@@ -170,6 +184,7 @@
|
||||
<data name="Common.Reset"><value>Сбросить</value></data>
|
||||
<data name="Common.Loading"><value>Загрузка…</value></data>
|
||||
<data name="Common.Empty"><value>Нет данных</value></data>
|
||||
<data name="Common.Delete"><value>Удалить</value></data>
|
||||
<data name="Common.Yes"><value>Да</value></data>
|
||||
<data name="Common.No"><value>Нет</value></data>
|
||||
|
||||
@@ -177,6 +192,8 @@
|
||||
<data name="Anomaly.Live"><value>Аномалия</value></data>
|
||||
<data name="Anomaly.Kind.SuspensionFlip"><value>Разворот после заморозки</value></data>
|
||||
<data name="Anomaly.Kind.SteamMove"><value>Движение линии</value></data>
|
||||
<data name="Anomaly.Kind.SuspensionFreeze"><value>Заморозка линии</value></data>
|
||||
<data name="Anomaly.Kind.OverroundCompression"><value>Сжатие маржи</value></data>
|
||||
<data name="Anomaly.Score"><value>Уверенность</value></data>
|
||||
|
||||
<!-- Phase 7 — Лента аномалий -->
|
||||
@@ -188,6 +205,11 @@
|
||||
<data name="Anomaly.Filter.AnySeverity"><value>Любая</value></data>
|
||||
<data name="Anomaly.Filter.Severity"><value>Мин. важность</value></data>
|
||||
<data name="Anomaly.Filter.Sport"><value>Вид спорта</value></data>
|
||||
<data name="Anomaly.Filter.Kind"><value>Тип</value></data>
|
||||
<data name="Anomaly.Filter.Sort"><value>Сортировка</value></data>
|
||||
<data name="Anomaly.Sort.Newest"><value>Новые</value></data>
|
||||
<data name="Anomaly.Sort.Score"><value>По score</value></data>
|
||||
<data name="Anomaly.Sort.Gap"><value>По паузе</value></data>
|
||||
<data name="Anomaly.Filter.From"><value>Обнаружено с</value></data>
|
||||
<data name="Anomaly.Filter.To"><value>Обнаружено по</value></data>
|
||||
<data name="Anomaly.Filter.DateRange"><value>Диапазон дат</value></data>
|
||||
@@ -281,6 +303,28 @@
|
||||
<data name="Export.Hub.Lede"><value>Экспорт собранных снимков коэффициентов в книгу Excel за любой диапазон дат — без необходимости открывать конкретное событие.</value></data>
|
||||
<data name="Export.Hub.Action"><value>Настроить экспорт</value></data>
|
||||
<data name="Export.Hub.FilenameHint"><value>Сохраняется как Marathon_<от>_to_<до>.xlsx в указанной папке экспорта.</value></data>
|
||||
<data name="Export.Csv.Section"><value>Журнал и реестр (CSV)</value></data>
|
||||
<data name="Export.Csv.Journal"><value>Экспорт журнала ставок</value></data>
|
||||
<data name="Export.Csv.Ledger"><value>Экспорт реестра форвард-теста</value></data>
|
||||
<data name="Export.Csv.Empty"><value>Пока нечего экспортировать.</value></data>
|
||||
<data name="Export.Csv.Error"><value>Экспорт CSV не удался — подробности в логе.</value></data>
|
||||
<data name="Health.Kicker"><value>Операции</value></data>
|
||||
<data name="Health.Title"><value>Состояние конвейера</value></data>
|
||||
<data name="Health.Lede"><value>Свежесть сбора, недавние объёмы и статус воркеров на одном экране.</value></data>
|
||||
<data name="Health.LastCapture"><value>Последний снимок</value></data>
|
||||
<data name="Health.LastCapture.Never"><value>снимков пока нет</value></data>
|
||||
<data name="Health.MinutesAgo"><value>{0} мин назад</value></data>
|
||||
<data name="Health.Stat.Snapshots"><value>Снимков (24ч)</value></data>
|
||||
<data name="Health.Stat.Anomalies"><value>Аномалий (24ч)</value></data>
|
||||
<data name="Health.Stat.Events"><value>Событий в работе</value></data>
|
||||
<data name="Health.Stat.Sports"><value>Видов спорта</value></data>
|
||||
<data name="Health.Total"><value>всего {0}</value></data>
|
||||
<data name="Health.Workers"><value>Воркеры</value></data>
|
||||
<data name="Health.Worker.Schedule"><value>Сбор расписания</value></data>
|
||||
<data name="Health.Worker.Live"><value>Сбор лайва</value></data>
|
||||
<data name="Health.Worker.Detection"><value>Детектор аномалий</value></data>
|
||||
<data name="Health.Worker.Results"><value>Сбор результатов</value></data>
|
||||
<data name="Health.Empty"><value>Данные ещё не собраны — включите сборщики в «Настройках».</value></data>
|
||||
<data name="Export.Error.MissingDates"><value>Выберите даты начала и конца.</value></data>
|
||||
<data name="Export.Error.InvalidRange"><value>Дата конца должна быть не раньше даты начала.</value></data>
|
||||
<data name="Export.Error.Failed"><value>Экспорт не удался.</value></data>
|
||||
@@ -348,6 +392,7 @@
|
||||
<data name="Insights.Stat.Total"><value>Всего аномалий</value></data>
|
||||
<data name="Insights.Section.BySeverity"><value>По уровню</value></data>
|
||||
<data name="Insights.Section.BySport"><value>По виду спорта</value></data>
|
||||
<data name="Insights.Section.ByKind"><value>По типу детектора</value></data>
|
||||
<data name="Insights.Section.ByScore"><value>По уверенности</value></data>
|
||||
<data name="Insights.Section.Resolved"><value>Подтверждённые аномалии</value></data>
|
||||
<data name="Insights.Section.Unresolved"><value>Ожидают итога</value></data>
|
||||
@@ -397,6 +442,10 @@
|
||||
<data name="Journal.Action.Refresh"><value>Обновить</value></data>
|
||||
<data name="Journal.Action.Resolve"><value>Рассчитать ожидающие</value></data>
|
||||
<data name="Journal.Action.Submit"><value>Записать</value></data>
|
||||
<data name="Journal.Action.SaveEdit"><value>Сохранить изменения</value></data>
|
||||
<data name="Journal.Action.EditBet"><value>Изменить</value></data>
|
||||
<data name="Journal.Edited"><value>Ставка обновлена.</value></data>
|
||||
<data name="Journal.Editing"><value>Редактирование ставки — сохраните изменения или отмените.</value></data>
|
||||
<data name="Journal.Action.Delete"><value>Удалить</value></data>
|
||||
<data name="Journal.Action.Confirm"><value>Подтвердить</value></data>
|
||||
<data name="Journal.Action.Cancel"><value>Отмена</value></data>
|
||||
@@ -450,6 +499,8 @@
|
||||
<data name="Journal.Confirm.Delete"><value>Удалить эту ставку безвозвратно?</value></data>
|
||||
|
||||
<data name="Nav.Backtest"><value>Бэктест</value></data>
|
||||
<data name="Nav.Compare"><value>Сравнить</value></data>
|
||||
<data name="Nav.PaperTrading"><value>Форвард-тест</value></data>
|
||||
<data name="Backtest.Kicker"><value>Симулятор</value></data>
|
||||
<data name="Backtest.Title"><value>Прогон детектора по истории</value></data>
|
||||
<data name="Backtest.Lede"><value>Запустите гипотетическую стратегию на всех зафиксированных аномалиях. Выберите порог уверенности и правило стейкинга — симулятор разыграет каждую ставку против реального исхода, нарастит банк и покажет ключевые метрики для оценки преимущества.</value></data>
|
||||
@@ -458,6 +509,9 @@
|
||||
<data name="Backtest.Section.Equity"><value>Кривая банка</value></data>
|
||||
<data name="Backtest.Section.Trace"><value>Хронология ставок</value></data>
|
||||
<data name="Backtest.Field.Bankroll"><value>Стартовый банк</value></data>
|
||||
<data name="Backtest.Field.From"><value>Дата с</value></data>
|
||||
<data name="Backtest.Field.To"><value>Дата по</value></data>
|
||||
<data name="Backtest.Field.DateRange.Hint"><value>Оставьте оба поля пустыми, чтобы прогнать все оценённые аномалии.</value></data>
|
||||
<data name="Backtest.Field.MinScore"><value>Мин. score аномалии</value></data>
|
||||
<data name="Backtest.Field.MinScore.Hint"><value>Ставим только при уверенности не ниже этого порога.</value></data>
|
||||
<data name="Backtest.Field.StakeRule"><value>Правило стейкинга</value></data>
|
||||
@@ -497,4 +551,51 @@
|
||||
<data name="Backtest.Empty.NoData"><value>Аномалий с результатом ещё нет. Запустите загрузчик результатов, чтобы симулятору было на чём прогоняться.</value></data>
|
||||
<data name="Backtest.Empty.NoBetsPlaced"><value>Стратегия не сделала ни одной ставки — снизьте порог score или поменяйте правило стейкинга.</value></data>
|
||||
<data name="Backtest.Error.Generic"><value>Симуляция упала — проверьте параметры формы и повторите.</value></data>
|
||||
<data name="Backtest.Presets.Label"><value>Сохранённые стратегии</value></data>
|
||||
<data name="Backtest.Presets.NameLabel"><value>Название пресета</value></data>
|
||||
<data name="Backtest.Presets.Save"><value>Сохранить пресет</value></data>
|
||||
<data name="Backtest.Presets.Empty"><value>Сохранённых стратегий пока нет — настройте форму, дайте имя и сохраните пресет для повторного использования.</value></data>
|
||||
<data name="Backtest.Presets.Loaded"><value>Стратегия загружена</value></data>
|
||||
<data name="Backtest.Presets.Saved"><value>Стратегия сохранена</value></data>
|
||||
<data name="Backtest.Presets.Deleted"><value>Стратегия удалена</value></data>
|
||||
<data name="Backtest.Presets.NameRequired"><value>Введите название, чтобы сохранить стратегию.</value></data>
|
||||
<data name="Paper.Kicker"><value>Форвард-тест</value></data>
|
||||
<data name="Paper.Title"><value>Бумажная торговля</value></data>
|
||||
<data name="Paper.Lede"><value>Проверка вне выборки: воркер открывает ставку фиксированным стейком на каждый живой направленный сигнал и рассчитывает её, когда приходит результат — противоядие от переобучения на бэктесте. Включается в разделе PaperTrading настроек.</value></data>
|
||||
<data name="Paper.Empty"><value>Бумажных ставок пока нет. Воркер форвард-теста по умолчанию выключен — включите PaperTrading, и ставки начнут накапливаться здесь по мере появления новых направленных аномалий.</value></data>
|
||||
<data name="Paper.Section.Summary"><value>Рассчитанный P&L</value></data>
|
||||
<data name="Paper.Section.Ledger"><value>Журнал</value></data>
|
||||
<data name="Paper.Stat.NetProfit"><value>Чистая прибыль</value></data>
|
||||
<data name="Paper.Stat.Roi"><value>ROI</value></data>
|
||||
<data name="Paper.Stat.HitRate"><value>Доля попаданий</value></data>
|
||||
<data name="Paper.Stat.Open"><value>Открыто</value></data>
|
||||
<data name="Paper.Stat.Settled"><value>Рассчитано</value></data>
|
||||
<data name="Paper.Stat.Wins"><value>Победы</value></data>
|
||||
<data name="Paper.Stat.Losses"><value>Поражения</value></data>
|
||||
<data name="Paper.Stat.Staked"><value>Поставлено</value></data>
|
||||
<data name="Paper.Column.OpenedAt"><value>Открыта</value></data>
|
||||
<data name="Paper.Column.Match"><value>Матч</value></data>
|
||||
<data name="Paper.Column.Pick"><value>Выбор</value></data>
|
||||
<data name="Paper.Column.Rate"><value>Кэф</value></data>
|
||||
<data name="Paper.Column.Stake"><value>Стейк</value></data>
|
||||
<data name="Paper.Column.Status"><value>Статус</value></data>
|
||||
<data name="Paper.Column.Payout"><value>Выплата</value></data>
|
||||
<data name="Paper.Outcome.Open"><value>Открыта</value></data>
|
||||
<data name="Paper.Outcome.Won"><value>Выигрыш</value></data>
|
||||
<data name="Paper.Outcome.Lost"><value>Проигрыш</value></data>
|
||||
<data name="Paper.Outcome.Void"><value>Возврат</value></data>
|
||||
<data name="Compare.Kicker"><value>Лаборатория стратегий</value></data>
|
||||
<data name="Compare.Title"><value>Сравнение стратегий</value></data>
|
||||
<data name="Compare.Lede"><value>Прогоните каждый сохранённый пресет на одном окне и ранжируйте по ROI — найдите стейкинг, который реально работает. Сначала сохраните пресеты на странице бэктеста.</value></data>
|
||||
<data name="Compare.Action.Run"><value>Сравнить</value></data>
|
||||
<data name="Compare.Empty"><value>Нет сохранённых стратегий для сравнения. Сохраните один или несколько пресетов на странице бэктеста, затем вернитесь сюда для сравнения.</value></data>
|
||||
<data name="Compare.Section.Results"><value>Лицом к лицу</value></data>
|
||||
<data name="Compare.Best"><value>Лучшая</value></data>
|
||||
<data name="Compare.Column.Strategy"><value>Стратегия</value></data>
|
||||
<data name="Compare.Column.Bets"><value>Ставки</value></data>
|
||||
<data name="Compare.Column.WinLoss"><value>В–П</value></data>
|
||||
<data name="Compare.Column.HitRate"><value>Попад. %</value></data>
|
||||
<data name="Compare.Column.Net"><value>Чистыми</value></data>
|
||||
<data name="Compare.Column.Roi"><value>ROI</value></data>
|
||||
<data name="Compare.Column.MaxDrawdown"><value>Макс. просадка</value></data>
|
||||
</root>
|
||||
|
||||
@@ -59,9 +59,22 @@ public sealed class AnomalyBrowsingService : IAnomalyBrowsingService
|
||||
filtered = filtered.Where(i => sports.Contains(i.Sport.Value));
|
||||
}
|
||||
|
||||
return filtered
|
||||
.OrderByDescending(static i => i.DetectedAt)
|
||||
.ToList();
|
||||
if (filter.Kinds is { Count: > 0 } kinds)
|
||||
{
|
||||
filtered = filtered.Where(i => kinds.Contains(i.Kind));
|
||||
}
|
||||
|
||||
// DetectedAt is the tiebreak for the score/gap sorts so order stays stable.
|
||||
var sorted = filter.Sort switch
|
||||
{
|
||||
AnomalySort.HighestScore =>
|
||||
filtered.OrderByDescending(i => i.Score).ThenByDescending(i => i.DetectedAt),
|
||||
AnomalySort.LongestGap =>
|
||||
filtered.OrderByDescending(i => i.SuspensionGapSeconds).ThenByDescending(i => i.DetectedAt),
|
||||
_ => filtered.OrderByDescending(i => i.DetectedAt),
|
||||
};
|
||||
|
||||
return sorted.ToList();
|
||||
}
|
||||
|
||||
public async Task<AnomalyDetailVm?> GetByIdAsync(Guid id, CancellationToken ct)
|
||||
@@ -124,14 +137,16 @@ public sealed class AnomalyBrowsingService : IAnomalyBrowsingService
|
||||
|
||||
var severity = AnomalySeverityRules.FromScore(anomaly.Score);
|
||||
|
||||
events.TryGetValue(anomaly.EventId, out var ev);
|
||||
// Skip orphan anomalies whose event has been pruned — degrade gracefully rather
|
||||
// than throwing on a sentinel SportCode(0). Anomalies have an FK to events so this
|
||||
// is defensive; the feed already drops rows whose evidence won't parse.
|
||||
if (!events.TryGetValue(anomaly.EventId, out var ev) || ev is null)
|
||||
return false;
|
||||
|
||||
var sport = ev?.Sport ?? new SportCode(0);
|
||||
var country = ev?.CountryCode ?? string.Empty;
|
||||
var league = ev?.LeagueId ?? string.Empty;
|
||||
var title = ev is not null
|
||||
? ev.Title
|
||||
: anomaly.EventId.Value;
|
||||
var sport = ev.Sport;
|
||||
var country = ev.CountryCode;
|
||||
var league = ev.LeagueId;
|
||||
var title = ev.Title;
|
||||
|
||||
var preSnap = ToSnapshot(dto.PreSuspension);
|
||||
var postSnap = ToSnapshot(dto.PostSuspension);
|
||||
|
||||
@@ -40,6 +40,7 @@ public sealed class AnomalyInsightsService : IAnomalyInsightsService
|
||||
BySeverity: report.BySeverity,
|
||||
BySport: report.BySport,
|
||||
ByScoreBin: report.ByScoreBin,
|
||||
ByKind: report.ByKind,
|
||||
Resolved: resolvedRows,
|
||||
Unresolved: unresolvedRows);
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ public sealed record AnomalyInsightsVm(
|
||||
IReadOnlyList<OutcomeBucket> BySeverity,
|
||||
IReadOnlyList<OutcomeBucket> BySport,
|
||||
IReadOnlyList<OutcomeBucket> ByScoreBin,
|
||||
IReadOnlyList<OutcomeBucket> ByKind,
|
||||
IReadOnlyList<ResolvedAnomalyRow> Resolved,
|
||||
IReadOnlyList<ResolvedAnomalyRow> Unresolved);
|
||||
|
||||
|
||||
@@ -16,15 +16,30 @@ public enum AnomalySeverity
|
||||
High,
|
||||
}
|
||||
|
||||
/// <summary>Sort order for the anomaly feed.</summary>
|
||||
public enum AnomalySort
|
||||
{
|
||||
/// <summary>Most recently detected first (default).</summary>
|
||||
Newest,
|
||||
|
||||
/// <summary>Highest confidence score first.</summary>
|
||||
HighestScore,
|
||||
|
||||
/// <summary>Longest suspension gap first.</summary>
|
||||
LongestGap,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Filter state passed from a page to <see cref="IAnomalyBrowsingService"/>.
|
||||
/// All fields optional — empty filter returns the full feed.
|
||||
/// All fields optional — empty filter returns the full feed newest-first.
|
||||
/// </summary>
|
||||
public sealed record AnomalyFilter(
|
||||
AnomalySeverity? MinSeverity = null,
|
||||
IReadOnlyCollection<int>? SportCodes = null,
|
||||
DateTimeOffset? From = null,
|
||||
DateTimeOffset? To = null);
|
||||
DateTimeOffset? To = null,
|
||||
IReadOnlyCollection<AnomalyKind>? Kinds = null,
|
||||
AnomalySort Sort = AnomalySort.Newest);
|
||||
|
||||
/// <summary>
|
||||
/// Compact anomaly row used by the feed page. Designed to render without any
|
||||
|
||||
@@ -1,19 +1,32 @@
|
||||
using Marathon.Application.Abstractions;
|
||||
using Marathon.Application.UseCases;
|
||||
using Marathon.Domain.Backtesting;
|
||||
|
||||
namespace Marathon.UI.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Page-facing implementation of <see cref="IBacktestService"/>. The use case
|
||||
/// hands back per-event titles inside the result so the service does no
|
||||
/// repository I/O of its own.
|
||||
/// Page-facing implementation of <see cref="IBacktestService"/>. The run use case
|
||||
/// hands back per-event titles inside the result so the service does no repository
|
||||
/// I/O for runs; saved-strategy reads go straight to the repository, writes through
|
||||
/// the save/delete use cases.
|
||||
/// </summary>
|
||||
public sealed class BacktestService : IBacktestService
|
||||
{
|
||||
private readonly RunBacktestUseCase _useCase;
|
||||
private readonly SaveStrategyUseCase _saveStrategy;
|
||||
private readonly DeleteStrategyUseCase _deleteStrategy;
|
||||
private readonly ISavedStrategyRepository _strategies;
|
||||
|
||||
public BacktestService(RunBacktestUseCase useCase)
|
||||
public BacktestService(
|
||||
RunBacktestUseCase useCase,
|
||||
SaveStrategyUseCase saveStrategy,
|
||||
DeleteStrategyUseCase deleteStrategy,
|
||||
ISavedStrategyRepository strategies)
|
||||
{
|
||||
_useCase = useCase ?? throw new ArgumentNullException(nameof(useCase));
|
||||
_saveStrategy = saveStrategy ?? throw new ArgumentNullException(nameof(saveStrategy));
|
||||
_deleteStrategy = deleteStrategy ?? throw new ArgumentNullException(nameof(deleteStrategy));
|
||||
_strategies = strategies ?? throw new ArgumentNullException(nameof(strategies));
|
||||
}
|
||||
|
||||
public async Task<BacktestVm> RunAsync(BacktestForm form, CancellationToken ct)
|
||||
@@ -23,7 +36,7 @@ public sealed class BacktestService : IBacktestService
|
||||
if (!form.IsValid(out var err))
|
||||
throw new ArgumentException(err ?? "Invalid form.", nameof(form));
|
||||
|
||||
var result = await _useCase.ExecuteAsync(form.ToStrategy(), ct).ConfigureAwait(false);
|
||||
var result = await _useCase.ExecuteAsync(form.ToStrategy(), form.ToDateRange(), ct).ConfigureAwait(false);
|
||||
|
||||
var rows = result.Trace
|
||||
.Select(t => new BacktestTraceRow(
|
||||
@@ -58,4 +71,37 @@ public sealed class BacktestService : IBacktestService
|
||||
Trace: rows,
|
||||
EquityCurve: curve);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<SavedStrategyVm>> ListStrategiesAsync(CancellationToken ct)
|
||||
{
|
||||
var presets = await _strategies.ListAsync(ct).ConfigureAwait(false);
|
||||
return presets.Select(ToVm).ToList();
|
||||
}
|
||||
|
||||
public async Task<SavedStrategyVm> SaveStrategyAsync(string name, BacktestForm form, CancellationToken ct)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(form);
|
||||
|
||||
// Reuse the form's own validation so an invalid preset can never be persisted.
|
||||
if (!form.IsValid(out var err))
|
||||
throw new ArgumentException(err ?? "Invalid form.", nameof(form));
|
||||
|
||||
var saved = await _saveStrategy.ExecuteAsync(name, form.ToStrategy(), ct).ConfigureAwait(false);
|
||||
return ToVm(saved);
|
||||
}
|
||||
|
||||
public Task DeleteStrategyAsync(Guid id, CancellationToken ct) =>
|
||||
_deleteStrategy.ExecuteAsync(id, ct);
|
||||
|
||||
private static SavedStrategyVm ToVm(SavedStrategy s) =>
|
||||
new(
|
||||
Id: s.Id,
|
||||
Name: s.Name,
|
||||
StartingBankroll: s.Strategy.StartingBankroll,
|
||||
MinScore: s.Strategy.MinScore,
|
||||
StakeRule: s.Strategy.StakeRule,
|
||||
FlatStake: s.Strategy.FlatStake,
|
||||
// Domain stores fractions; the form/VM speak percentages.
|
||||
PercentOfBankrollPercent: s.Strategy.PercentOfBankroll * 100m,
|
||||
KellyFractionPercent: s.Strategy.KellyFraction * 100m);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
using Marathon.Application.Storage;
|
||||
using Marathon.Domain.Backtesting;
|
||||
using Marathon.Domain.Enums;
|
||||
using Marathon.Domain.ValueObjects;
|
||||
|
||||
namespace Marathon.UI.Services;
|
||||
|
||||
@@ -21,10 +23,22 @@ public sealed class BacktestForm
|
||||
/// <summary>Bound to the UI as a percentage 0–100; converted to a fraction before sim.</summary>
|
||||
public decimal KellyFractionPercent { get; set; } = 25m;
|
||||
|
||||
/// <summary>Optional inclusive date-range filter (Moscow dates). Both null = all anomalies.</summary>
|
||||
public DateTime? From { get; set; }
|
||||
|
||||
/// <summary>Optional inclusive date-range filter (Moscow dates). Both null = all anomalies.</summary>
|
||||
public DateTime? To { get; set; }
|
||||
|
||||
public bool IsValid(out string? error)
|
||||
{
|
||||
if (StartingBankroll <= 0m) { error = "Bankroll must be positive."; return false; }
|
||||
if (MinScore is < 0m or > 1m) { error = "Min score must be in [0, 1]."; return false; }
|
||||
// A one-sided range would be silently ignored (ToDateRange needs both bounds), so
|
||||
// require both-or-neither and give the user explicit feedback.
|
||||
if (From.HasValue != To.HasValue)
|
||||
{ error = "Set both From and To dates, or leave both empty."; return false; }
|
||||
if (From is { } f && To is { } t && f.Date > t.Date)
|
||||
{ error = "From date must be on or before To date."; return false; }
|
||||
switch (StakeRule)
|
||||
{
|
||||
case StakeRule.Flat:
|
||||
@@ -52,6 +66,20 @@ public sealed class BacktestForm
|
||||
FlatStake: FlatStake,
|
||||
PercentOfBankroll: PercentOfBankrollPercent / 100m,
|
||||
KellyFraction: KellyFractionPercent / 100m);
|
||||
|
||||
/// <summary>
|
||||
/// The inclusive Moscow-day date range, or null when either bound is unset
|
||||
/// (meaning: run over every graded anomaly).
|
||||
/// </summary>
|
||||
public DateRange? ToDateRange()
|
||||
{
|
||||
if (From is not { } from || To is not { } to)
|
||||
return null;
|
||||
|
||||
return new DateRange(
|
||||
new DateTimeOffset(from.Date, MoscowTime.Offset),
|
||||
MoscowTime.EndOfMoscowDay(DateOnly.FromDateTime(to.Date)));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>UI-facing projection of <see cref="BacktestResult"/>.</summary>
|
||||
@@ -87,3 +115,35 @@ public sealed record BacktestTraceRow(
|
||||
/// <param name="DetectedAt">When the bet would have been placed.</param>
|
||||
/// <param name="Bankroll">Bankroll after this bet settled.</param>
|
||||
public sealed record EquityPoint(DateTimeOffset DetectedAt, decimal Bankroll);
|
||||
|
||||
/// <summary>
|
||||
/// UI projection of a persisted <see cref="Marathon.Domain.Backtesting.SavedStrategy"/>.
|
||||
/// Percent fields are pre-scaled to 0–100 to match the form bindings; the per-run
|
||||
/// date range is intentionally not part of a preset.
|
||||
/// </summary>
|
||||
public sealed record SavedStrategyVm(
|
||||
Guid Id,
|
||||
string Name,
|
||||
decimal StartingBankroll,
|
||||
decimal MinScore,
|
||||
StakeRule StakeRule,
|
||||
decimal FlatStake,
|
||||
decimal PercentOfBankrollPercent,
|
||||
decimal KellyFractionPercent)
|
||||
{
|
||||
/// <summary>
|
||||
/// Copies this preset's staking parameters onto <paramref name="form"/>, leaving
|
||||
/// the form's <see cref="BacktestForm.From"/>/<see cref="BacktestForm.To"/> date
|
||||
/// range untouched (scope is a per-run choice, not part of the preset).
|
||||
/// </summary>
|
||||
public void ApplyTo(BacktestForm form)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(form);
|
||||
form.StartingBankroll = StartingBankroll;
|
||||
form.MinScore = MinScore;
|
||||
form.StakeRule = StakeRule;
|
||||
form.FlatStake = FlatStake;
|
||||
form.PercentOfBankrollPercent = PercentOfBankrollPercent;
|
||||
form.KellyFractionPercent = KellyFractionPercent;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ public sealed class BetJournalService : IBetJournalService
|
||||
private readonly RecordPlacedBetUseCase _record;
|
||||
private readonly ResolvePendingBetsUseCase _resolve;
|
||||
private readonly DeletePlacedBetUseCase _delete;
|
||||
private readonly UpdatePlacedBetUseCase _update;
|
||||
private readonly IEventRepository _events;
|
||||
|
||||
public BetJournalService(
|
||||
@@ -26,12 +27,14 @@ public sealed class BetJournalService : IBetJournalService
|
||||
RecordPlacedBetUseCase record,
|
||||
ResolvePendingBetsUseCase resolve,
|
||||
DeletePlacedBetUseCase delete,
|
||||
UpdatePlacedBetUseCase update,
|
||||
IEventRepository events)
|
||||
{
|
||||
_build = build ?? throw new ArgumentNullException(nameof(build));
|
||||
_record = record ?? throw new ArgumentNullException(nameof(record));
|
||||
_resolve = resolve ?? throw new ArgumentNullException(nameof(resolve));
|
||||
_delete = delete ?? throw new ArgumentNullException(nameof(delete));
|
||||
_update = update ?? throw new ArgumentNullException(nameof(update));
|
||||
_events = events ?? throw new ArgumentNullException(nameof(events));
|
||||
}
|
||||
|
||||
@@ -92,6 +95,29 @@ public sealed class BetJournalService : IBetJournalService
|
||||
return stored.Id;
|
||||
}
|
||||
|
||||
public async Task UpdateAsync(Guid betId, AddBetForm form, CancellationToken ct)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(form);
|
||||
|
||||
if (!form.IsValid(out var error))
|
||||
throw new ArgumentException(error ?? "Invalid form.", nameof(form));
|
||||
|
||||
var selection = new Bet(
|
||||
scope: MatchScope.Instance,
|
||||
type: form.Type,
|
||||
side: form.Side,
|
||||
value: form.Value is { } v ? new OddsValue(v) : null,
|
||||
rate: new OddsRate(form.Rate));
|
||||
|
||||
await _update.ExecuteAsync(
|
||||
betId,
|
||||
new DomainEventId(form.EventId.Trim()),
|
||||
selection,
|
||||
form.Stake,
|
||||
form.Notes,
|
||||
ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public Task DeleteAsync(Guid betId, CancellationToken ct) =>
|
||||
_delete.ExecuteAsync(betId, ct);
|
||||
|
||||
|
||||
@@ -52,6 +52,9 @@ public sealed class AddBetForm
|
||||
/// <summary>Upper sanity cap on a single wager.</summary>
|
||||
public const decimal MaxStake = 10_000_000m;
|
||||
|
||||
/// <summary>Upper bound on the free-text note so a paste can't bloat the row unbounded.</summary>
|
||||
public const int MaxNotesLength = 2000;
|
||||
|
||||
public bool IsValid(out string? error)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(EventId)) { error = "EventId is required."; return false; }
|
||||
@@ -59,6 +62,7 @@ public sealed class AddBetForm
|
||||
if (Stake > MaxStake) { error = $"Stake must be at most {MaxStake:N0}."; return false; }
|
||||
if (Rate < 1.01m) { error = "Rate must be at least 1.01."; return false; }
|
||||
if (Rate > MaxRate) { error = $"Rate must be at most {MaxRate:N0}."; return false; }
|
||||
if (Notes is { Length: > MaxNotesLength }) { error = $"Notes must be at most {MaxNotesLength:N0} characters."; return false; }
|
||||
|
||||
// Mirror Bet invariants — surface a friendly message instead of throwing
|
||||
// ArgumentException deep in the use case.
|
||||
|
||||
@@ -18,6 +18,7 @@ public sealed class DashboardSummaryService : IDashboardSummaryService
|
||||
private readonly ISnapshotRepository _snapshots;
|
||||
private readonly IAnomalyRepository _anomalies;
|
||||
private readonly IAnomalyBrowsingService _anomalyBrowsing;
|
||||
private readonly IPaperTradingService _paperTrading;
|
||||
private readonly IOptionsMonitor<WorkerOptions> _workers;
|
||||
|
||||
public DashboardSummaryService(
|
||||
@@ -25,12 +26,14 @@ public sealed class DashboardSummaryService : IDashboardSummaryService
|
||||
ISnapshotRepository snapshots,
|
||||
IAnomalyRepository anomalies,
|
||||
IAnomalyBrowsingService anomalyBrowsing,
|
||||
IPaperTradingService paperTrading,
|
||||
IOptionsMonitor<WorkerOptions> workers)
|
||||
{
|
||||
_events = events ?? throw new ArgumentNullException(nameof(events));
|
||||
_snapshots = snapshots ?? throw new ArgumentNullException(nameof(snapshots));
|
||||
_anomalies = anomalies ?? throw new ArgumentNullException(nameof(anomalies));
|
||||
_anomalyBrowsing = anomalyBrowsing ?? throw new ArgumentNullException(nameof(anomalyBrowsing));
|
||||
_paperTrading = paperTrading ?? throw new ArgumentNullException(nameof(paperTrading));
|
||||
_workers = workers ?? throw new ArgumentNullException(nameof(workers));
|
||||
}
|
||||
|
||||
@@ -54,6 +57,9 @@ public sealed class DashboardSummaryService : IDashboardSummaryService
|
||||
|
||||
var w = _workers.CurrentValue;
|
||||
|
||||
// Reuse the forward-test aggregation (settled-only P&L) for the headline tile.
|
||||
var paper = await _paperTrading.GetAsync(ct).ConfigureAwait(false);
|
||||
|
||||
return new DashboardSummary(
|
||||
EventsTracked: eventsTracked,
|
||||
SnapshotsToday: snapshotsToday,
|
||||
@@ -64,7 +70,11 @@ public sealed class DashboardSummaryService : IDashboardSummaryService
|
||||
ScheduleStatus: StageStatus(w.UpcomingPollerEnabled, eventsTracked > 0),
|
||||
SnapshotStatus: StageStatus(w.LivePollerEnabled, snapshotsToday > 0),
|
||||
DetectorStatus: StageStatus(w.AnomalyDetectionEnabled, anomaliesTotal > 0),
|
||||
ExportStatus: eventsTracked > 0 ? "ok" : "idle");
|
||||
ExportStatus: eventsTracked > 0 ? "ok" : "idle",
|
||||
PaperOpenCount: paper.OpenCount,
|
||||
PaperSettledCount: paper.SettledCount,
|
||||
PaperNetProfit: paper.NetProfit,
|
||||
PaperRoiPercent: paper.RoiPercent);
|
||||
}
|
||||
|
||||
// Maps a worker stage to the PipelineStep token: disabled → idle, enabled but
|
||||
|
||||
@@ -10,4 +10,17 @@ public interface IBacktestService
|
||||
/// <summary>Validates the form, runs the simulator, projects for the UI.</summary>
|
||||
/// <exception cref="ArgumentException">Form fails its own validation.</exception>
|
||||
Task<BacktestVm> RunAsync(BacktestForm form, CancellationToken ct);
|
||||
|
||||
/// <summary>Every saved strategy preset, name-ascending.</summary>
|
||||
Task<IReadOnlyList<SavedStrategyVm>> ListStrategiesAsync(CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Saves the current form as a named preset (upsert by name). Validates the
|
||||
/// form first, exactly like <see cref="RunAsync"/>.
|
||||
/// </summary>
|
||||
/// <exception cref="ArgumentException">Form is invalid, or the name is empty/too long.</exception>
|
||||
Task<SavedStrategyVm> SaveStrategyAsync(string name, BacktestForm form, CancellationToken ct);
|
||||
|
||||
/// <summary>Deletes a saved preset by id. No-op when the id is unknown.</summary>
|
||||
Task DeleteStrategyAsync(Guid id, CancellationToken ct);
|
||||
}
|
||||
|
||||
@@ -18,6 +18,14 @@ public interface IBetJournalService
|
||||
/// <exception cref="ArgumentException">Form fails its own validation.</exception>
|
||||
Task<Guid> AddAsync(AddBetForm form, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Updates an existing bet's selection / stake / notes, preserving its original
|
||||
/// placed-at and re-grading against the event result.
|
||||
/// </summary>
|
||||
/// <exception cref="ArgumentException">Form fails its own validation.</exception>
|
||||
/// <exception cref="InvalidOperationException">The bet id or its event is unknown.</exception>
|
||||
Task UpdateAsync(Guid betId, AddBetForm form, CancellationToken ct);
|
||||
|
||||
/// <summary>Removes a bet by id. No-op when the id is unknown.</summary>
|
||||
Task DeleteAsync(Guid betId, CancellationToken ct);
|
||||
|
||||
|
||||
@@ -25,8 +25,15 @@ public sealed record DashboardSummary(
|
||||
string ScheduleStatus,
|
||||
string SnapshotStatus,
|
||||
string DetectorStatus,
|
||||
string ExportStatus)
|
||||
string ExportStatus,
|
||||
int PaperOpenCount = 0,
|
||||
int PaperSettledCount = 0,
|
||||
decimal PaperNetProfit = 0m,
|
||||
decimal? PaperRoiPercent = null)
|
||||
{
|
||||
/// <summary>True once the forward-test worker has opened or settled any paper bet.</summary>
|
||||
public bool HasPaperTrades => PaperOpenCount > 0 || PaperSettledCount > 0;
|
||||
|
||||
/// <summary>True once anything has been captured — gates the welcome/empty state.</summary>
|
||||
public bool HasAnyData => EventsTracked > 0 || AnomaliesTotal > 0;
|
||||
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace Marathon.UI.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Read-facing facade over the paper-trading (forward-test) ledger. Joins event
|
||||
/// titles and computes the running settled-only P&L summary for the page.
|
||||
/// </summary>
|
||||
public interface IPaperTradingService
|
||||
{
|
||||
/// <summary>The full ledger plus aggregate KPIs, newest bet first.</summary>
|
||||
Task<PaperTradingVm> GetAsync(CancellationToken ct);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
namespace Marathon.UI.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Read-only operational health of the capture/detection pipeline, for the ops page:
|
||||
/// data freshness, recent vs total volumes, and which workers are enabled.
|
||||
/// </summary>
|
||||
public interface IPipelineHealthService
|
||||
{
|
||||
Task<PipelineHealth> GetAsync(CancellationToken ct);
|
||||
}
|
||||
|
||||
/// <summary>Snapshot of pipeline health. <see cref="LastSnapshotAt"/> drives the freshness pill.</summary>
|
||||
public sealed record PipelineHealth(
|
||||
int EventsTracked,
|
||||
int SportsCovered,
|
||||
int SnapshotsLast24h,
|
||||
int SnapshotsTotal,
|
||||
int AnomaliesLast24h,
|
||||
int AnomaliesTotal,
|
||||
DateTimeOffset? LastSnapshotAt,
|
||||
bool UpcomingPollerEnabled,
|
||||
bool LivePollerEnabled,
|
||||
bool ResultsPollerEnabled,
|
||||
bool AnomalyDetectionEnabled)
|
||||
{
|
||||
/// <summary>True once anything has been captured — gates the empty state.</summary>
|
||||
public bool HasData => SnapshotsTotal > 0 || EventsTracked > 0;
|
||||
|
||||
/// <summary>Empty snapshot used as the initial render state before data loads.</summary>
|
||||
public static PipelineHealth Empty { get; } = new(
|
||||
EventsTracked: 0, SportsCovered: 0,
|
||||
SnapshotsLast24h: 0, SnapshotsTotal: 0,
|
||||
AnomaliesLast24h: 0, AnomaliesTotal: 0,
|
||||
LastSnapshotAt: null,
|
||||
UpcomingPollerEnabled: false, LivePollerEnabled: false,
|
||||
ResultsPollerEnabled: false, AnomalyDetectionEnabled: false);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace Marathon.UI.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Facade over <see cref="Marathon.Application.UseCases.CompareStrategiesUseCase"/> — runs
|
||||
/// every saved preset over the supplied Moscow-day window and shapes the head-to-head table.
|
||||
/// </summary>
|
||||
public interface IStrategyComparisonService
|
||||
{
|
||||
/// <summary>
|
||||
/// Compares all saved presets over [<paramref name="from"/>..<paramref name="to"/>]
|
||||
/// (both null = all graded anomalies). Returns <see cref="StrategyComparisonVm.Empty"/>
|
||||
/// when no presets are saved.
|
||||
/// </summary>
|
||||
Task<StrategyComparisonVm> CompareAsync(DateTime? from, DateTime? to, CancellationToken ct);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user