Compare commits
71 Commits
a2396a39a7
...
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 | |||
| d9d92ea8fd | |||
| 2b1025cae3 | |||
| 4dae9e8d0d | |||
| 0e3c4b8d47 | |||
| 250a93e718 | |||
| 0501f9c39c | |||
| f294255f10 | |||
| 0d52b7beff | |||
| 1ad896b07e | |||
| 292223174c | |||
| 004dbeae8b | |||
| 537b78ab83 | |||
| c2934b2c8d | |||
| fed3a09695 | |||
| d1e6ce7ce2 | |||
| 857d456b95 | |||
| 286b55986b | |||
| 66ae038243 | |||
| 958d472582 | |||
| a6bd8a0e44 | |||
| a627c360c3 | |||
| 9f090cec1f | |||
| 9c5d3df1f2 | |||
| 1bbf4fcfed | |||
| 85bc99cac5 | |||
| 828dcf5a08 | |||
| 12208a4762 | |||
| a6ff368015 | |||
| d915667da1 | |||
| 553db2bce3 | |||
| fe97643a41 | |||
| 2acbaa5b77 | |||
| c4d87b59d6 | |||
| 686550d697 | |||
| e4d8476782 | |||
| 144c936e90 | |||
| 9614b8cf37 | |||
| 61114ea31b | |||
| e4b03f42ef | |||
| 070e34b911 | |||
| 8802ddb25b |
@@ -0,0 +1,60 @@
|
|||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
charset = utf-8
|
||||||
|
end_of_line = crlf
|
||||||
|
insert_final_newline = true
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
|
||||||
|
[*.{cs,csx}]
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 4
|
||||||
|
|
||||||
|
# C# formatting rules
|
||||||
|
dotnet_sort_system_directives_first = true
|
||||||
|
dotnet_separate_import_directive_groups = false
|
||||||
|
|
||||||
|
# Expression preferences
|
||||||
|
csharp_prefer_simple_using_statement = true:suggestion
|
||||||
|
csharp_prefer_braces = true:silent
|
||||||
|
csharp_style_namespace_declarations = file_scoped:warning
|
||||||
|
csharp_using_directive_placement = outside_namespace:warning
|
||||||
|
|
||||||
|
# var preferences
|
||||||
|
csharp_style_var_for_built_in_types = false:suggestion
|
||||||
|
csharp_style_var_when_type_is_apparent = true:suggestion
|
||||||
|
csharp_style_var_elsewhere = false:suggestion
|
||||||
|
|
||||||
|
# Expression-bodied members
|
||||||
|
csharp_style_expression_bodied_methods = false:silent
|
||||||
|
csharp_style_expression_bodied_constructors = false:silent
|
||||||
|
csharp_style_expression_bodied_properties = true:suggestion
|
||||||
|
|
||||||
|
# Null checking preferences
|
||||||
|
csharp_style_throw_expression = true:suggestion
|
||||||
|
csharp_style_conditional_delegate_call = true:suggestion
|
||||||
|
|
||||||
|
# Modifier ordering
|
||||||
|
dotnet_style_require_accessibility_modifiers = for_non_interface_members:warning
|
||||||
|
|
||||||
|
# Naming conventions
|
||||||
|
dotnet_naming_rule.private_fields_should_be_underscore_camel_case.severity = suggestion
|
||||||
|
dotnet_naming_rule.private_fields_should_be_underscore_camel_case.symbols = private_fields
|
||||||
|
dotnet_naming_rule.private_fields_should_be_underscore_camel_case.style = underscore_camel_case_style
|
||||||
|
|
||||||
|
dotnet_naming_symbols.private_fields.applicable_kinds = field
|
||||||
|
dotnet_naming_symbols.private_fields.applicable_accessibilities = private, protected, private_protected
|
||||||
|
|
||||||
|
dotnet_naming_style.underscore_camel_case_style.required_prefix = _
|
||||||
|
dotnet_naming_style.underscore_camel_case_style.capitalization = camel_case
|
||||||
|
|
||||||
|
[*.{xml,csproj,props,targets}]
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 2
|
||||||
|
|
||||||
|
[*.{json,yml,yaml}]
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 2
|
||||||
|
|
||||||
|
[*.md]
|
||||||
|
trim_trailing_whitespace = false
|
||||||
@@ -81,3 +81,10 @@ appsettings.*.local.json
|
|||||||
|
|
||||||
# Scraping fixtures captured during Phase 0 spike (kept locally, not in repo)
|
# Scraping fixtures captured during Phase 0 spike (kept locally, not in repo)
|
||||||
spike/captures/
|
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
|
||||||
@@ -102,4 +102,146 @@ Marathon_<YYYY-MM-DD>_to_<YYYY-MM-DD>.xlsx
|
|||||||
|
|
||||||
## Recurring Issues & Patterns
|
## Recurring Issues & Patterns
|
||||||
|
|
||||||
(Populated as we work — leave empty until something repeats.)
|
- **`dotnet new sln` on .NET 10 SDK produces `.slnx`**, not `.sln`. If the plan
|
||||||
|
references `Marathon.sln`, hand-craft the traditional format alongside `.slnx`.
|
||||||
|
- **`Marathon.Application` namespace vs `System.Windows.Application`:** in any WPF
|
||||||
|
project that references `Marathon.Application`, always write
|
||||||
|
`System.Windows.Application` fully qualified in `App.xaml.cs`.
|
||||||
|
- **`Directory.Build.props` must NOT set `TargetFramework`** when projects in the
|
||||||
|
same solution use different TFMs (e.g., `net8.0` vs `net8.0-windows`).
|
||||||
|
- **Razor source generator does NOT accept C# 11 raw string literals** (`"""…"""`)
|
||||||
|
inside `@code` blocks — concatenate single-quoted attribute strings instead.
|
||||||
|
- **Razor reserves the identifier `code`.** Loop variables must use any other
|
||||||
|
name (`var sportCode in ...`) or the parser treats it as the `@code` directive.
|
||||||
|
- **`MudBlazor.DateRange` shadows `Marathon.Application.Storage.DateRange`** in
|
||||||
|
any Razor file that pulls both namespaces via `_Imports.razor`. Use a per-file
|
||||||
|
alias: `using AppDateRange = Marathon.Application.Storage.DateRange;`.
|
||||||
|
- **`Plotly.Blazor.LayoutLib.Margin` clashes with `MudBlazor.Margin`.** Fully
|
||||||
|
qualify the Plotly side at the new-expression: `new Plotly.Blazor.LayoutLib.Margin {…}`.
|
||||||
|
- **`Event.ScheduledAt` requires offset `+03:00`.** Test fixtures and any code
|
||||||
|
that constructs Moscow datetimes must use `new DateTimeOffset(date, TimeSpan.FromHours(3))`,
|
||||||
|
never pass a `DateTime.UtcNow` value to that constructor.
|
||||||
|
- **EF migrations — generate with `dotnet ef`, do NOT `migrations remove`.**
|
||||||
|
`MarathonDbContextFactory` is a self-contained design-time factory, so
|
||||||
|
`dotnet ef migrations add X --project src/Marathon.Infrastructure --startup-project src/Marathon.Infrastructure`
|
||||||
|
works. AVOID `dotnet ef migrations remove`: older migrations were hand-written without a
|
||||||
|
Designer snapshot, so remove blanks `MarathonDbContextModelSnapshot.cs` and the next `add`
|
||||||
|
regenerates the WHOLE schema. Validate a migration on a throwaway DB with
|
||||||
|
`dotnet ef database update --connection "Data Source=<ABSOLUTE>/data/_migtest.db"` (absolute
|
||||||
|
path — EF resolves relatives from the build-output dir, not the shell cwd; create `./data/` first).
|
||||||
|
- **`Sports.Code` is `.ValueGeneratedNever()`** — it's the bookmaker's natural sport id
|
||||||
|
(6/11/22723…), not an autoincrement surrogate. Without it EF's int-PK convention emits a
|
||||||
|
spurious AUTOINCREMENT `AlterColumn` on every migration.
|
||||||
|
- **Validated get-only record properties block `with`** (CS0200): `BacktestStrategy.MinScore`,
|
||||||
|
`PaperBet.Rate`/`Stake` are re-declared with validation, so build a new instance instead of
|
||||||
|
`with { ThatProp = … }` (you can still `with` the un-redeclared props, e.g. `SavedStrategy with { Strategy = … }`).
|
||||||
|
- **`JsonSettingsWriter.SaveSectionAsync` replaces the whole section** (`root[section]=json`),
|
||||||
|
so a Settings-UI save drops any key not on the form. Never surface a secret-bearing section
|
||||||
|
(e.g. `Notifications` → Telegram token) in the UI — it would wipe the secret from
|
||||||
|
`appsettings.Local.json`. To surface a non-secret section, add a mutable mirror options class
|
||||||
|
in `Marathon.UI.Services` bound to the same section name (UI can't reference Infrastructure's
|
||||||
|
options types — same pattern as the two `WorkerOptions`).
|
||||||
|
|
||||||
|
## Feature: Initial Implementation > Phase 4: Application + Workers — Learnings
|
||||||
|
|
||||||
|
- **Two `WorkerOptions` classes coexist** with the same JSON shape but different namespaces:
|
||||||
|
`Marathon.Infrastructure.Configuration.WorkerOptions` (immutable `init`, used by workers)
|
||||||
|
and `Marathon.UI.Services.WorkerOptions` (mutable `set`, used by Settings page).
|
||||||
|
Both bind to `"Workers"` in `appsettings.json`. Keep them in sync when adding new keys.
|
||||||
|
- **`Microsoft.Extensions.Logging.EventId` conflicts with `Marathon.Domain.ValueObjects.EventId`**
|
||||||
|
in any project that adds `Microsoft.Extensions.Logging.Abstractions`. Fix with a global alias
|
||||||
|
in `GlobalUsings.cs`: `global using LogEventId = Microsoft.Extensions.Logging.EventId;`
|
||||||
|
and local file aliases where both are used together.
|
||||||
|
- **NSubstitute cannot proxy `sealed` classes.** Use cases are `sealed record` or `sealed class`.
|
||||||
|
Worker tests must build a real use-case instance backed by substituted interfaces rather than
|
||||||
|
substituting the use case directly.
|
||||||
|
- **`BackgroundService` workers are singletons; use cases are scoped.** Always resolve scoped
|
||||||
|
use cases via `IServiceProvider.CreateAsyncScope()` inside the worker loop — never inject them
|
||||||
|
directly into the constructor.
|
||||||
|
- **Cronos 6-field cron format.** Pass `CronFormat.IncludeSeconds` to `CronExpression.Parse`
|
||||||
|
when the expression has a seconds field (e.g., `"0 0 */6 * * *"`). Default Cronos parse
|
||||||
|
expects 5-field (no seconds).
|
||||||
|
- **`ApplicationModule.AddMarathonApplication` takes no `IConfiguration`** — the Application
|
||||||
|
layer has no config bindings of its own. Infrastructure and UI bind their own options sections.
|
||||||
|
|
||||||
|
## Feature: Initial Implementation > Phase 0: Scraping Spike — Learnings
|
||||||
|
|
||||||
|
(Permanent learnings about marathonbet.by data shape, anti-bot, page structure.
|
||||||
|
For full detail see `spike/SCRAPE_FINDINGS.md` and `spike/SCHEMA_DRAFT.md`.)
|
||||||
|
|
||||||
|
- **Site is fully SSR (`Server: nginx`).** Anonymous GET with browser User-Agent
|
||||||
|
returns full HTML for `/su/`, `/su/live`, `/su/popular/<Sport>`,
|
||||||
|
`/su/betting/<event-path>`. No Cloudflare, no JS challenge.
|
||||||
|
- **Use HttpClient + AngleSharp + Polly v8** — no Playwright needed for read-only.
|
||||||
|
Keep `Scraping:UsePlaywright = false` flag for future-proofing.
|
||||||
|
- **Sport ID = `data-sport-treeId` = breadcrumb canonical ID.** Confirmed:
|
||||||
|
Basketball=6, Football=11, Tennis=22723, Hockey=43658. URL by ID:
|
||||||
|
`/su/betting/<Sport>+-+<id>` (preferred over `/su/popular/<Sport>` because the
|
||||||
|
ID is stable).
|
||||||
|
- **`EventCode` = `data-event-eventId`** (numeric, ~26-million range, stable).
|
||||||
|
`TreeId` = `data-event-treeId` (URL-routing ID, less stable). Use `EventCode`
|
||||||
|
as the entity primary key in SQLite.
|
||||||
|
- **Selection key format:** `{eventId}@{MarketName}{LineIndex?}.{Outcome}`.
|
||||||
|
Outcomes: `1`/`draw`/`3` for 3-way, `HB_H`/`HB_A` for handicap, `Under_<X>`/
|
||||||
|
`Over_<X>` for totals. Total threshold is encoded in the outcome string;
|
||||||
|
handicap value lives in `<span class="middle-simple">` text.
|
||||||
|
- **Tennis has no Draw outcome.** Domain `Bet_Match_Draw` must be nullable; Excel
|
||||||
|
exporter writes empty cell when null.
|
||||||
|
- **Date parsing:** listing shows `HH:MM` (today) or `DD <ru-month> HH:MM` (future).
|
||||||
|
Anchor with `initData.serverTime` (Moscow TZ, format `YYYY,MM,DD,HH,MM,SS`)
|
||||||
|
parsed from the embedded `<script>` blob on every scraped page.
|
||||||
|
- **Live updates:** site polls `/su/liveupdate/popular/?treeIds=...` every 3 s but
|
||||||
|
response is just `{"modified":[{"type":"refreshPage"}],...}` — re-scrape the
|
||||||
|
full event detail HTML for actual odds. Our analyzer cadence: pre-match 30 s,
|
||||||
|
live 5–10 s.
|
||||||
|
- **No public results / archive page** (`/su/results` → 404). Final scores must
|
||||||
|
be harvested by polling the event detail page until
|
||||||
|
`eventJsonInfo.matchIsComplete=true`, then storing `resultDescription`. Phase 8
|
||||||
|
cannot back-fill from a public archive.
|
||||||
|
- **Period scope vocabulary varies by sport:** football=`1st_Half`, basketball=
|
||||||
|
`1st_Half`/`1st_Quarter`, tennis=`1st_Set`, hockey=`1st_Period`. Domain stores
|
||||||
|
`PeriodNumber:int` and a sport-aware `PeriodScopeMapper` resolves the correct
|
||||||
|
market token at parse time.
|
||||||
|
|
||||||
|
## Feature: Initial Implementation > Phase 7: Anomaly UI — Learnings
|
||||||
|
|
||||||
|
- **Severity buckets are a single-source rule** in
|
||||||
|
`Marathon.UI.Services.AnomalySeverityRules.FromScore` (Low <0.45, Medium <0.60,
|
||||||
|
High ≥0.60). The badge pill, card border, feed filter chip, and stat strip all
|
||||||
|
bind through it — never duplicate the thresholds.
|
||||||
|
- **`AnomalyBrowsingService` is Scoped, `AnomalyBrowsingState` is Singleton** —
|
||||||
|
same lifetime split as `EventBrowsingService` / `EventBrowsingState`. State
|
||||||
|
holds the immutable `AnomalyFilter` + `LastSeenUtc` + cached unread count.
|
||||||
|
- **`Anomaly.EvidenceJson` is parsed once in the service layer** via
|
||||||
|
`JsonSerializer.Deserialize<EvidenceDto>(json, PropertyNameCaseInsensitive=true)`
|
||||||
|
with private nested DTOs. Pages bind to `AnomalyEvidenceSnapshot` value-records
|
||||||
|
— they never see the raw JSON. Malformed JSON is dropped silently from the feed.
|
||||||
|
- **2-way markets (tennis) carry `pDraw=null` / `rateDraw=null`.** The
|
||||||
|
`AnomalyEvidence` and `AnomalyCard` components key off the `IsTwoWay` flag on
|
||||||
|
`AnomalyListItem` (computed when both pre/post `pDraw` are null) to omit the
|
||||||
|
Draw row in BOTH columns — never per-column-individually.
|
||||||
|
- **Signal-red is the load-bearing alert tone** for Phase 7. Use
|
||||||
|
`var(--m-c-anomaly)` exclusively (never raw `#dc2626`). Pulsing animation
|
||||||
|
(`m-pulse`) MUST respect `prefers-reduced-motion`.
|
||||||
|
|
||||||
|
## Feature: Analysis Hardening (H-series) — Learnings
|
||||||
|
|
||||||
|
- **Anomaly detectors are a fan-out array in `DetectAnomaliesUseCase`** — 4 now
|
||||||
|
(`SuspensionFlip`, `SteamMove`, `SuspensionFreeze`, `OverroundCompression`). A new detector
|
||||||
|
implements `IAnomalyDetector`, reuses `MatchWinEvidence` for the canonical evidence JSON
|
||||||
|
(so the parser + outcome evaluator work unchanged), and is added to that array. Continuous
|
||||||
|
sliding-window detectors (steam, overround) emit at every `end` and skip suspension-sized
|
||||||
|
gaps so they never overlap the across-suspension flip/freeze detectors.
|
||||||
|
- **`AnomalyKind.IsDirectional()` gates staking/grading** — flip + steam are directional
|
||||||
|
(predict a side); freeze + overround are informational and are excluded from the backtest
|
||||||
|
(`RunBacktestUseCase`) and the outcome evaluator so they don't skew hit-rate/score calibration.
|
||||||
|
- **`SavedStrategy` (backtest presets) use NOCASE name collation** — the unique index AND
|
||||||
|
`GetByNameAsync` both fold case (column-level `UseCollation("NOCASE")`), so save-by-name
|
||||||
|
upserts rather than creating "Kelly"/"kelly" duplicates. Domain stores stake fractions; the
|
||||||
|
form/VM speak percent — convert ×100/÷100 at the UI boundary.
|
||||||
|
- **Paper-trading (forward-test) is a config-gated worker** (`PaperTrading:Enabled`, default
|
||||||
|
false; tunable on the Settings page). `PaperTradingWorker` opens flat-stake `PaperBet`s on
|
||||||
|
new directional anomalies (unique on `AnomalyId`; baseline since-marker advances only after a
|
||||||
|
successful open pass; settle runs in its own catch so a settle failure can't strand the
|
||||||
|
marker) and settles them against results (Won iff pick == winner). `/paper-trading` shows
|
||||||
|
settled-only P&L. The ledger is FK-free (survives snapshot-retention pruning), like `PlacedBets`.
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<Project>
|
||||||
|
<PropertyGroup>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<LangVersion>12</LangVersion>
|
||||||
|
<TreatWarningsAsErrors Condition="'$(Configuration)'=='Release'">true</TreatWarningsAsErrors>
|
||||||
|
<AnalysisLevel>latest</AnalysisLevel>
|
||||||
|
</PropertyGroup>
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
<Project>
|
||||||
|
<PropertyGroup>
|
||||||
|
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<!-- Test infrastructure -->
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
|
||||||
|
<PackageVersion Include="xunit" Version="2.9.2" />
|
||||||
|
<PackageVersion Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||||
|
<PackageVersion Include="coverlet.collector" Version="6.0.2" />
|
||||||
|
<PackageVersion Include="FluentAssertions" Version="6.12.2" />
|
||||||
|
<PackageVersion Include="NSubstitute" Version="5.1.0" />
|
||||||
|
<PackageVersion Include="bunit" Version="1.36.0" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<!-- Blazor / ASP.NET Core -->
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageVersion Include="Microsoft.AspNetCore.Components.Web" Version="8.0.12" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<!-- Infrastructure (future phases) -->
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageVersion Include="Microsoft.EntityFrameworkCore" Version="8.0.12" />
|
||||||
|
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.12" />
|
||||||
|
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.12" />
|
||||||
|
<PackageVersion Include="AngleSharp" Version="1.2.0" />
|
||||||
|
<PackageVersion Include="Microsoft.Extensions.Http.Resilience" Version="8.10.0" />
|
||||||
|
<PackageVersion Include="Polly" Version="8.5.2" />
|
||||||
|
<PackageVersion Include="ClosedXML" Version="0.104.2" />
|
||||||
|
<PackageVersion Include="Serilog" Version="4.2.0" />
|
||||||
|
<PackageVersion Include="Serilog.Extensions.Hosting" Version="8.0.0" />
|
||||||
|
<PackageVersion Include="Serilog.Sinks.File" Version="6.0.0" />
|
||||||
|
<PackageVersion Include="Serilog.Sinks.Console" Version="6.0.0" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<!-- WPF Blazor Host (future phases) -->
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageVersion Include="Microsoft.AspNetCore.Components.WebView.Wpf" Version="8.0.100" />
|
||||||
|
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="8.0.1" />
|
||||||
|
<PackageVersion Include="Microsoft.Extensions.Configuration" Version="8.0.0" />
|
||||||
|
<PackageVersion Include="Microsoft.Extensions.Configuration.Binder" Version="8.0.2" />
|
||||||
|
<PackageVersion Include="Microsoft.Extensions.Configuration.Json" Version="8.0.1" />
|
||||||
|
<PackageVersion Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="8.0.0" />
|
||||||
|
<PackageVersion Include="Microsoft.Extensions.Options" Version="8.0.2" />
|
||||||
|
<PackageVersion Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="8.0.0" />
|
||||||
|
<PackageVersion Include="Microsoft.Extensions.Http" Version="8.0.1" />
|
||||||
|
<PackageVersion Include="Microsoft.Extensions.Logging" Version="8.0.1" />
|
||||||
|
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.2" />
|
||||||
|
<PackageVersion Include="Microsoft.Extensions.Hosting" Version="8.0.1" />
|
||||||
|
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="8.0.1" />
|
||||||
|
<PackageVersion Include="Microsoft.Extensions.Localization" Version="8.0.12" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<!-- UI / Blazor components (Phase 5+) -->
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageVersion Include="MudBlazor" Version="7.15.0" />
|
||||||
|
<PackageVersion Include="Plotly.Blazor" Version="5.4.1" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<!-- Scheduling (Phase 4 worker cron) -->
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageVersion Include="Cronos" Version="0.9.0" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
|
||||||
|
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||||
|
# Visual Studio Version 17
|
||||||
|
VisualStudioVersion = 17.0.31903.59
|
||||||
|
MinimumVisualStudioVersion = 10.0.40219.1
|
||||||
|
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Marathon.Domain", "src\Marathon.Domain\Marathon.Domain.csproj", "{7C944335-83D2-47BB-8C69-F575602D5E07}"
|
||||||
|
EndProject
|
||||||
|
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Marathon.Application", "src\Marathon.Application\Marathon.Application.csproj", "{E8B43AE4-84A8-4D33-B1D3-730945B225EB}"
|
||||||
|
EndProject
|
||||||
|
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Marathon.Infrastructure", "src\Marathon.Infrastructure\Marathon.Infrastructure.csproj", "{C130635E-27D5-4753-8018-BD71937ED459}"
|
||||||
|
EndProject
|
||||||
|
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Marathon.UI", "src\Marathon.UI\Marathon.UI.csproj", "{1355540A-3AB0-46FF-808B-A0329B6321BA}"
|
||||||
|
EndProject
|
||||||
|
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Marathon.Hosts.WpfBlazor", "src\Marathon.Hosts.WpfBlazor\Marathon.Hosts.WpfBlazor.csproj", "{F1A6C0A4-F27D-460B-BECF-90325423B731}"
|
||||||
|
EndProject
|
||||||
|
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Marathon.Domain.Tests", "tests\Marathon.Domain.Tests\Marathon.Domain.Tests.csproj", "{5F02523E-4308-46BE-A033-CB5469F6D62F}"
|
||||||
|
EndProject
|
||||||
|
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Marathon.Application.Tests", "tests\Marathon.Application.Tests\Marathon.Application.Tests.csproj", "{A6BF4C17-FEEA-4575-8085-36DB18F0DA76}"
|
||||||
|
EndProject
|
||||||
|
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Marathon.Infrastructure.Tests", "tests\Marathon.Infrastructure.Tests\Marathon.Infrastructure.Tests.csproj", "{59F23C54-75C6-469F-9F44-79E0B499A58F}"
|
||||||
|
EndProject
|
||||||
|
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Marathon.UI.Tests", "tests\Marathon.UI.Tests\Marathon.UI.Tests.csproj", "{D675B598-20C6-4B8E-A086-65A31B729C12}"
|
||||||
|
EndProject
|
||||||
|
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{4B7367A5-AA76-4CB9-B122-DAFE4A99D854}"
|
||||||
|
EndProject
|
||||||
|
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{F225CE82-66E1-4F3C-87EE-7A11863599B0}"
|
||||||
|
EndProject
|
||||||
|
Global
|
||||||
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
|
Debug|Any CPU = Debug|Any CPU
|
||||||
|
Release|Any CPU = Release|Any CPU
|
||||||
|
EndGlobalSection
|
||||||
|
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||||
|
{7C944335-83D2-47BB-8C69-F575602D5E07}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{7C944335-83D2-47BB-8C69-F575602D5E07}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{7C944335-83D2-47BB-8C69-F575602D5E07}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{7C944335-83D2-47BB-8C69-F575602D5E07}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{E8B43AE4-84A8-4D33-B1D3-730945B225EB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{E8B43AE4-84A8-4D33-B1D3-730945B225EB}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{E8B43AE4-84A8-4D33-B1D3-730945B225EB}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{E8B43AE4-84A8-4D33-B1D3-730945B225EB}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{C130635E-27D5-4753-8018-BD71937ED459}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{C130635E-27D5-4753-8018-BD71937ED459}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{C130635E-27D5-4753-8018-BD71937ED459}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{C130635E-27D5-4753-8018-BD71937ED459}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{1355540A-3AB0-46FF-808B-A0329B6321BA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{1355540A-3AB0-46FF-808B-A0329B6321BA}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{1355540A-3AB0-46FF-808B-A0329B6321BA}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{1355540A-3AB0-46FF-808B-A0329B6321BA}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{F1A6C0A4-F27D-460B-BECF-90325423B731}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{F1A6C0A4-F27D-460B-BECF-90325423B731}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{F1A6C0A4-F27D-460B-BECF-90325423B731}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{F1A6C0A4-F27D-460B-BECF-90325423B731}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{5F02523E-4308-46BE-A033-CB5469F6D62F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{5F02523E-4308-46BE-A033-CB5469F6D62F}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{5F02523E-4308-46BE-A033-CB5469F6D62F}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{5F02523E-4308-46BE-A033-CB5469F6D62F}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{A6BF4C17-FEEA-4575-8085-36DB18F0DA76}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{A6BF4C17-FEEA-4575-8085-36DB18F0DA76}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{A6BF4C17-FEEA-4575-8085-36DB18F0DA76}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{A6BF4C17-FEEA-4575-8085-36DB18F0DA76}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{59F23C54-75C6-469F-9F44-79E0B499A58F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{59F23C54-75C6-469F-9F44-79E0B499A58F}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{59F23C54-75C6-469F-9F44-79E0B499A58F}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{59F23C54-75C6-469F-9F44-79E0B499A58F}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{D675B598-20C6-4B8E-A086-65A31B729C12}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{D675B598-20C6-4B8E-A086-65A31B729C12}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{D675B598-20C6-4B8E-A086-65A31B729C12}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{D675B598-20C6-4B8E-A086-65A31B729C12}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
EndGlobalSection
|
||||||
|
GlobalSection(SolutionProperties) = preSolution
|
||||||
|
HideSolutionNode = FALSE
|
||||||
|
EndGlobalSection
|
||||||
|
GlobalSection(NestedProjects) = preSolution
|
||||||
|
{7C944335-83D2-47BB-8C69-F575602D5E07} = {4B7367A5-AA76-4CB9-B122-DAFE4A99D854}
|
||||||
|
{E8B43AE4-84A8-4D33-B1D3-730945B225EB} = {4B7367A5-AA76-4CB9-B122-DAFE4A99D854}
|
||||||
|
{C130635E-27D5-4753-8018-BD71937ED459} = {4B7367A5-AA76-4CB9-B122-DAFE4A99D854}
|
||||||
|
{1355540A-3AB0-46FF-808B-A0329B6321BA} = {4B7367A5-AA76-4CB9-B122-DAFE4A99D854}
|
||||||
|
{F1A6C0A4-F27D-460B-BECF-90325423B731} = {4B7367A5-AA76-4CB9-B122-DAFE4A99D854}
|
||||||
|
{5F02523E-4308-46BE-A033-CB5469F6D62F} = {F225CE82-66E1-4F3C-87EE-7A11863599B0}
|
||||||
|
{A6BF4C17-FEEA-4575-8085-36DB18F0DA76} = {F225CE82-66E1-4F3C-87EE-7A11863599B0}
|
||||||
|
{59F23C54-75C6-469F-9F44-79E0B499A58F} = {F225CE82-66E1-4F3C-87EE-7A11863599B0}
|
||||||
|
{D675B598-20C6-4B8E-A086-65A31B729C12} = {F225CE82-66E1-4F3C-87EE-7A11863599B0}
|
||||||
|
EndGlobalSection
|
||||||
|
EndGlobal
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<Solution>
|
||||||
|
<Folder Name="/src/">
|
||||||
|
<Project Path="src/Marathon.Application/Marathon.Application.csproj" />
|
||||||
|
<Project Path="src/Marathon.Domain/Marathon.Domain.csproj" />
|
||||||
|
<Project Path="src/Marathon.Hosts.WpfBlazor/Marathon.Hosts.WpfBlazor.csproj" />
|
||||||
|
<Project Path="src/Marathon.Infrastructure/Marathon.Infrastructure.csproj" />
|
||||||
|
<Project Path="src/Marathon.UI/Marathon.UI.csproj" />
|
||||||
|
</Folder>
|
||||||
|
<Folder Name="/tests/">
|
||||||
|
<Project Path="tests/Marathon.Application.Tests/Marathon.Application.Tests.csproj" />
|
||||||
|
<Project Path="tests/Marathon.Domain.Tests/Marathon.Domain.Tests.csproj" />
|
||||||
|
<Project Path="tests/Marathon.Infrastructure.Tests/Marathon.Infrastructure.Tests.csproj" />
|
||||||
|
<Project Path="tests/Marathon.UI.Tests/Marathon.UI.Tests.csproj" />
|
||||||
|
</Folder>
|
||||||
|
</Solution>
|
||||||
@@ -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,290 @@
|
|||||||
|
# Feature Context: Initial Implementation
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
- **Development mode:** Automated
|
||||||
|
- **Execution mode:** Orchestrator
|
||||||
|
- **Strategy:** Big Bang
|
||||||
|
- **Build:** `dotnet build Marathon.sln`
|
||||||
|
- **Test:** `dotnet test Marathon.sln`
|
||||||
|
- **Lint:** `dotnet format Marathon.sln --verify-no-changes`
|
||||||
|
- **Run:** `dotnet run --project src/Marathon.Hosts.WpfBlazor`
|
||||||
|
- **Implementer models:** Sonnet 4.6 (backend), Opus (frontend)
|
||||||
|
- **Reviewer model:** Sonnet 4.6
|
||||||
|
|
||||||
|
## Customer Constraints
|
||||||
|
|
||||||
|
- Source: marathonbet.by — anonymous scraping (no login). ToS risk acknowledged by customer.
|
||||||
|
- Output: Excel files matching customer's wide-column spec (`Bet_Match_Win_1`,
|
||||||
|
`Bet_Period-1_Win_Fora_2_Value`, etc.) with date-range filenames.
|
||||||
|
- Storage: customer accepted SQLite-with-Excel-export instead of Excel-as-database
|
||||||
|
(decided 2026-05-05).
|
||||||
|
- UI tech: Blazor Hybrid (changed from initial WPF assumption — better for web migration).
|
||||||
|
- Locale: RU + EN.
|
||||||
|
- Scope: analyze-only initially; design `IBetPlacer` extension point for future betting.
|
||||||
|
- Configurability: every variable parameter (polling, concurrency, retry, UA, retention,
|
||||||
|
thresholds, locale) goes in `appsettings.json` + Settings UI page.
|
||||||
|
|
||||||
|
## Current State
|
||||||
|
|
||||||
|
Repo just initialized. Single `main` commit with `.gitignore` + `README.md` + `CLAUDE.md`.
|
||||||
|
Working on `feature/initial-implementation` branch. No source code yet — Phase 0 starts
|
||||||
|
with scraping research, no implementation.
|
||||||
|
|
||||||
|
## Temporary Workarounds
|
||||||
|
|
||||||
|
(none yet)
|
||||||
|
|
||||||
|
## Cross-Phase Dependencies
|
||||||
|
|
||||||
|
- **Phase 1 (Domain)** is the foundation; all later phases reference domain types.
|
||||||
|
- **Phase 2 (Storage)** & **Phase 3 (Scraping)** depend only on Phase 1 — can run in parallel.
|
||||||
|
- **Phase 4 (Application + Workers)** depends on Phase 2 + Phase 3.
|
||||||
|
- **Phase 5 (UI Shell)** depends on Phase 1 only — can run in parallel with 2/3.
|
||||||
|
- **Phase 6 (Event Browsing UI)** depends on Phase 4 + Phase 5.
|
||||||
|
- **Phase 7 (Anomaly)** depends on Phase 4 (snapshot storage) + Phase 6 (UI patterns).
|
||||||
|
- **Phase 8 (Results)** depends on Phase 6.
|
||||||
|
- **Phase 9 (Packaging)** is final — runs full build + test suite.
|
||||||
|
|
||||||
|
## Deferred Work
|
||||||
|
|
||||||
|
- Bet placing (explicit out-of-scope; design extension point only).
|
||||||
|
- Authenticated scraping (anonymous now; `IOddsScraper` impl is swappable).
|
||||||
|
- Multi-bookmaker support (only marathonbet.by; abstraction allows future expansion).
|
||||||
|
- PostgreSQL backend (SQLite for now; `IRepository<T>` abstraction allows swap).
|
||||||
|
|
||||||
|
## Failed Approaches
|
||||||
|
|
||||||
|
- **Public results / archive endpoint** — does NOT exist. Tested
|
||||||
|
`https://www.marathonbet.by/su/results`, `/su/results/`, `/su/results.htm` —
|
||||||
|
all return HTTP 404. No `/archive`, `/history` links anywhere in the public
|
||||||
|
HTML either. **Phase 8 deviation:** the Results loader cannot back-fill from
|
||||||
|
an archive — it must poll each event detail page until
|
||||||
|
`eventJsonInfo.matchIsComplete=true` and snapshot `resultDescription` at that
|
||||||
|
moment. Phase 8 implementer must revise the subplan accordingly.
|
||||||
|
- **JSONP `/su/liveupdate/popular/` endpoint** — exposes only refresh signals
|
||||||
|
(`{"modified":[{"type":"refreshPage"}],"updated":<ts>}`), not actual odds. Cannot
|
||||||
|
be used as a JSON odds source. Use it only as a "something changed" hint to
|
||||||
|
trigger a full event-detail re-scrape.
|
||||||
|
- **Anonymous WebSocket (STOMP)** at `/su/websocket/endpoint` is documented in
|
||||||
|
`initData.stomp` but appears to require an authenticated session
|
||||||
|
(`PUNTER-SESSION-HASH` cookie); we did not test it but the customer's anonymous
|
||||||
|
scraping constraint makes it unsuitable anyway.
|
||||||
|
|
||||||
|
## Review Findings Log
|
||||||
|
|
||||||
|
(populated by reviewers)
|
||||||
|
|
||||||
|
## Phase Execution Log
|
||||||
|
|
||||||
|
| Phase | Agent | Model | Test Writer | Parallel | Notes |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| Phase 0 | phase-implementer | Opus | ⏭️ Skipped (research only) | — | ✅ Done 2026-05-05. Outputs: spike/SCRAPE_FINDINGS.md + spike/SCHEMA_DRAFT.md + 7 local fixtures. Anonymous scraping confirmed feasible; HttpClient+AngleSharp recommended; no Playwright needed; no public results page found (Phase 8 deviation noted). |
|
||||||
|
| Phase 1 | phase-implementer | Sonnet 4.6 | ⏭️ Skipped (Big Bang) | — | ✅ Done 2026-05-05. 9 projects (5 src + 4 test). 96 domain tests passed. Key decisions: BetScope sealed hierarchy, ScheduledAt=UTC+3 (Moscow), OddsValue rejects zero. Deviations: slnx auto-created alongside sln, WPF App.xaml.cs needs FQ Application type. |
|
||||||
|
| Phase 2 | phase-implementer | Sonnet 4.6 | ⏭️ Skipped (Big Bang) | ✅ With 3 + 5 | — |
|
||||||
|
| Phase 3 | phase-implementer | Sonnet 4.6 | ⏭️ Skipped (Big Bang) | ✅ With 2 + 5 | — |
|
||||||
|
| Phase 4 | phase-implementer | Sonnet 4.6 | ⏭️ Skipped (Big Bang) | — | ✅ Done 2026-05-05. 4 use cases, 3 BackgroundService pollers, InfrastructureModule, ApplicationModule, reflection wiring removed. 202/202 tests green (+17 new). |
|
||||||
|
| Phase 5 | phase-implementer-frontend | Opus | ⏭️ Skipped (Big Bang) | ✅ With 2 + 3 | Uses frontend-design skill |
|
||||||
|
| Phase 6 | phase-implementer-frontend | Opus | ⏭️ Skipped (Big Bang) | — | ✅ Done 2026-05-05. PreMatch + Live + Events/Detail pages, EventListShell, SportIcon, OddsCell, OddsTimeline (Plotly.Blazor wrap), ExportDialog. EventBrowsingState + IEventBrowsingService facade. RU+EN strings under PreMatch.* / Live.* / Detail.* / Export.* / Sport.*. 228/228 tests green (+26 new bUnit). |
|
||||||
|
| Phase 7 | phase-implementer (split + UI Opus 1M) | Sonnet/Opus | ⏭️ Skipped (Big Bang) | — | ✅ Done 2026-05-05. Backend (Sonnet, a6ff368): pure `AnomalyDetector` + `DetectAnomaliesUseCase` + `AnomalyDetectionPoller` + 14 backend tests. Frontend (Opus 1M): `AnomalyFeed.razor` + `Detail.razor` + `AnomalyCard`/`SeverityBadge`/`AnomalyEvidence` components + `IAnomalyBrowsingService`/`AnomalyBrowsingService`/`AnomalyBrowsingState`/`AnomalyViewModels`. Nav badge with pulsing signal-red unread count. Settings page wired with `Workers:AnomalyDetectionEnabled`. 28 new `Anomaly.*` localization keys (RU+EN parity). 276/276 tests green (+31 new bUnit). |
|
||||||
|
| Phase 8 | phase-implementer (split if needed) | Sonnet/Opus | ⏭️ Skipped (Big Bang) | — | UI portion uses Opus |
|
||||||
|
| Phase 9 | phase-implementer | Sonnet 4.6 | ✅ Final phase tests | — | Full build + test enforced |
|
||||||
|
|
||||||
|
## Environment & Runtime Notes
|
||||||
|
|
||||||
|
- Windows 10, PowerShell 5.1 default shell, Bash also available.
|
||||||
|
- `git` configured globally; remote `origin` = `https://git.dolgolyov-family.by/alexei.dolgolyov/maraphon-app.git`.
|
||||||
|
- Note: home directory (`C:\Users\Alexei`) is itself a git repo (likely accidental).
|
||||||
|
The maraphon-app local `.git` overrides it for this directory tree.
|
||||||
|
- .NET SDK assumed installed; if Phase 1 fails on `dotnet --version`, install or
|
||||||
|
document in CONTEXT.md.
|
||||||
|
|
||||||
|
## Implementation Notes
|
||||||
|
|
||||||
|
### Phase 7 Backend (Anomaly detection, 2026-05-05)
|
||||||
|
|
||||||
|
- **`AnomalyDetector` is pure domain — no I/O, no DI.** Constructor takes three ints/decimals
|
||||||
|
from `AnomalyOptions`; the caller (use case) materialises it per cycle.
|
||||||
|
The UI evidence panel can reconstruct the same probabilities from `EvidenceJson` without
|
||||||
|
needing to re-invoke the detector.
|
||||||
|
- **Implied probability formula:** `p_i = 1 / rate_i`, then normalise so all `p_i` sum to 1
|
||||||
|
(divorcé of the bookmaker's margin). This is the standard European odds conversion.
|
||||||
|
- **Flip score** = `max(|p_post[i] − p_pre[i]|)` over Match-Win sides (p1, pDraw?, p2).
|
||||||
|
Score is clamped to `[0, 1]` before constructing `Anomaly` (domain invariant enforces ≤1).
|
||||||
|
- **Two-part gate** — an anomaly requires BOTH: (a) `flipScore ≥ OddsFlipThreshold` AND
|
||||||
|
(b) `argmax(p_pre) != argmax(p_post)`. This prevents spurious detections when one side's
|
||||||
|
probability shifts a lot but it was never the favourite.
|
||||||
|
- **Tennis / 2-way markets** — `pDraw` is `null` when no `BetType.Draw` bet is present.
|
||||||
|
The detector and `EvidenceJson` gracefully handle this (JSON field is omitted when null via
|
||||||
|
`DefaultIgnoreCondition.WhenWritingNull`).
|
||||||
|
- **`EvidenceJson` uses `System.Text.Json` with custom `JsonPropertyName` attributes** on
|
||||||
|
sealed nested records (`EvidencePayload`, `SnapshotEvidence`). Source generation was not
|
||||||
|
used at this scale — the payload is small and created infrequently.
|
||||||
|
- **`DetectAnomaliesUseCase` loads all events + last-24-h live snapshots per cycle.**
|
||||||
|
This is a deliberate simplification; a future optimisation is to track `last_run_at` per
|
||||||
|
event. Documented as 🟡 in the handoff.
|
||||||
|
- **Dedup strategy:** two anomalies are considered duplicates if they share `EventId`, `Kind`,
|
||||||
|
and their `DetectedAt` values fall within a 1-minute window. This prevents the same
|
||||||
|
suspension triggering re-insertion on consecutive detection cycles while the gap snapshot
|
||||||
|
pair remains in the 24-hour window.
|
||||||
|
- **`AnomalyOptions` placed in `Marathon.Application/Configuration/`** (not Infrastructure).
|
||||||
|
The `AnomalyDetector` itself is in `Marathon.Domain/AnomalyDetection/` but requires no
|
||||||
|
options binding — it takes plain constructor parameters.
|
||||||
|
- **`AnomalyDetectionPoller` reads `IOptionsMonitor<AnomalyOptions>` per cycle** so that
|
||||||
|
hot-reload of `DetectionIntervalSeconds` takes effect without a restart. Same pattern as
|
||||||
|
`LiveOddsPoller` reading `WorkerOptions`.
|
||||||
|
- **`Workers:AnomalyDetectionEnabled`** added to `WorkerOptions` (default `true`) and
|
||||||
|
`appsettings.json`. UI agent must add a Settings toggle for this flag.
|
||||||
|
- **New test count: +17** (13 domain + 4 application). Total: 245/245 passing.
|
||||||
|
- **Test note:** rates 1.5/2.5 produce a flip score of ~0.25 — BELOW the 0.30 threshold.
|
||||||
|
Always use 1.3/4.0 (flip score ~0.51) or steeper to guarantee detection in tests.
|
||||||
|
|
||||||
|
### Phase 7 Frontend (Anomaly UI, 2026-05-05)
|
||||||
|
|
||||||
|
- **Routing — Option A.** Removed the `Pages/Anomalies.razor` placeholder and added
|
||||||
|
`Pages/Anomalies/AnomalyFeed.razor` (`@page "/anomalies"`) plus
|
||||||
|
`Pages/Anomalies/Detail.razor` (`@page "/anomalies/{id:guid}"`). Mirrors the
|
||||||
|
`Pages/Events/Detail.razor` shape from Phase 6.
|
||||||
|
- **State + Service split mirrors Phase 6** — `AnomalyBrowsingState` (Singleton inside
|
||||||
|
the RCL; per-circuit in BlazorWebView), `IAnomalyBrowsingService` →
|
||||||
|
`AnomalyBrowsingService` (Scoped). The service does NOT call back into the detector;
|
||||||
|
it reads `IAnomalyRepository.ListAsync` + `IEventRepository.GetAsync` (per distinct
|
||||||
|
EventId) and maps to immutable view-model records.
|
||||||
|
- **`EvidenceJson` parsing** uses `System.Text.Json.JsonSerializer.Deserialize` with
|
||||||
|
`PropertyNameCaseInsensitive = true` and private nested DTOs. Failures (malformed
|
||||||
|
JSON, missing pre/post snapshot) drop the row silently — the feed shows the rest.
|
||||||
|
- **Severity buckets** are defined once in `AnomalySeverityRules` (Low <0.45, Medium
|
||||||
|
<0.60, High ≥0.60) per the backend handoff. The UI reuses the same enum across
|
||||||
|
filter chips, the badge pill, and the card border.
|
||||||
|
- **Signal-red is load-bearing.** High-severity pills, card left borders, evidence
|
||||||
|
post-suspension column outline, the favourite-swap callout, and the nav badge all
|
||||||
|
bind to `--m-c-anomaly`. Medium severity uses the editorial amber `--m-c-accent`;
|
||||||
|
low severity uses the muted `--m-c-ink-soft`. No new color literals introduced.
|
||||||
|
- **`AnomalyEvidence` panel** renders two columns (pre → arrow → post). Each row
|
||||||
|
shows the side label, an implied-probability bar (favourite uses amber/red), and
|
||||||
|
the raw rate in tabular mono. 2-way markets (tennis) skip the Draw row in BOTH
|
||||||
|
columns based on the parsed `pDraw` being null. The panel highlights a
|
||||||
|
favourite-swap with a one-line callout above the columns.
|
||||||
|
- **Nav badge** lives in `NavBody.razor`, driven by `AnomalyBrowsingState.UnreadCount`.
|
||||||
|
The feed page calls `IAnomalyBrowsingService.GetUnreadCountAsync(LastSeenUtc)` after
|
||||||
|
each load and pushes the count into state. The user clears it via "Mark all read"
|
||||||
|
on the feed toolbar (writes `LastSeenUtc = UtcNow`). The badge pulses with
|
||||||
|
`m-pulse` and respects `prefers-reduced-motion`.
|
||||||
|
- **Settings page** — added the `Workers:AnomalyDetectionEnabled` toggle inside the
|
||||||
|
existing WORKERS section, mirroring `LivePollerEnabled` / `UpcomingPollerEnabled`.
|
||||||
|
Bound via `IOptionsMonitor<WorkerOptions>` already in scope.
|
||||||
|
- **`Marathon.UI.Services.WorkerOptions`** — added `AnomalyDetectionEnabled` mutable
|
||||||
|
field (set-able for the form-binding pattern used by the Settings page). The
|
||||||
|
Infrastructure-side `WorkerOptions` already had the flag.
|
||||||
|
- **Test infrastructure** — added `FakeAnomalyBrowsingService` with
|
||||||
|
`MakeItem(...)` / `MakeSnapshot(...)` static factories; registered in
|
||||||
|
`MarathonTestContext` alongside `AnomalyBrowsingState`.
|
||||||
|
- **Localization** — 28 new `Anomaly.*` keys (RU+EN parity) under the
|
||||||
|
`<Surface>.<Element>` convention from Phase 5/6, plus
|
||||||
|
`Settings.Workers.AnomalyDetectionEnabled` and its `.Hint`.
|
||||||
|
- **New test count: +31** (9 SeverityBadge + 6 AnomalyCard + 6 AnomalyEvidence +
|
||||||
|
5 AnomalyFeed + 5 AnomalyDetail). Total: 276/276 passing.
|
||||||
|
|
||||||
|
### Phase 6 (Event browsing UI, 2026-05-05)
|
||||||
|
|
||||||
|
- **Plotly.Blazor pinned to 5.4.1.** v7.x exists but introduces breaking changes;
|
||||||
|
5.4.1 is the latest on the .NET 8 line and works with our existing MudBlazor
|
||||||
|
7.15.0 / .NET 8.0.12 stack. The `Plotly.Blazor.LayoutLib.Margin` type clashes
|
||||||
|
with `MudBlazor.Margin` — fully qualify the layout-side type.
|
||||||
|
- **Razor source generator does NOT accept C# 11 raw string literals (`"""…"""`)**
|
||||||
|
inside `@code` blocks. The parser sees the leading `"""` as the start of a
|
||||||
|
normal string and never finds the close, producing an "Unterminated string
|
||||||
|
literal" RZ1000. Use concatenated single-quoted attribute strings instead
|
||||||
|
(see `SportIcon.razor` SVG constants).
|
||||||
|
- **Razor reserves the identifier `code`.** A `@foreach (var code in ...)`
|
||||||
|
loop is parsed as the `@code` directive, not as iteration. Use any other
|
||||||
|
identifier (`var sportCode in ...`).
|
||||||
|
- **`MudBlazor.DateRange` shadows `Marathon.Application.Storage.DateRange`**
|
||||||
|
in any file whose `_Imports.razor` brings both namespaces in. Add
|
||||||
|
`using AppDateRange = Marathon.Application.Storage.DateRange;` per-file
|
||||||
|
where the application's `DateRange` is constructed (already done in
|
||||||
|
`ExportDialog.razor` and `ExportDialogTests.cs`).
|
||||||
|
- **EventBrowsingService is Scoped, EventBrowsingState is Singleton.** The
|
||||||
|
service captures the per-circuit DI scope so EF Core's `DbContext` lifetime
|
||||||
|
works correctly; the state object holds the per-page filter records and
|
||||||
|
fires `OnChange` only when the new value !equals the old one. This split
|
||||||
|
matches Phase 5's split between `ThemeState` (singleton) and per-circuit
|
||||||
|
data services.
|
||||||
|
- **View-models, not domain entities, cross the UI boundary.** Pages bind to
|
||||||
|
`EventListItem` / `EventDetail` / `BetRow` / `OddsTimelinePoint`
|
||||||
|
records (defined in `Marathon.UI.Services`). Repositories are not exposed
|
||||||
|
to Razor components. This keeps the UI free of EF tracked graphs and
|
||||||
|
preserves Phase 5's "RCL is host-agnostic" invariant.
|
||||||
|
- **Live page reads polling cadence from `IOptionsMonitor<ScrapingSettingsForm>`.**
|
||||||
|
Phase 4's `WorkerOptions.LivePollIntervalSeconds` (drives the poller) is a
|
||||||
|
separate setting from the UI's display refresh; the latter intentionally
|
||||||
|
follows `Scraping:PollingIntervalSeconds` per the Phase 6 subplan.
|
||||||
|
- **Plotly chart memoization.** Computed signature = `(count, first ticks,
|
||||||
|
last ticks, first/last rate triples)`. Sufficient to invalidate the trace
|
||||||
|
list on any meaningful change while staying cheap during live polling.
|
||||||
|
- **bUnit shared `MarathonTestContext` now registers the fake browsing service
|
||||||
|
and the browsing state.** Phase 7 tests can extend it directly or follow the same pattern.
|
||||||
|
`Support/TestData.MoscowToday(int hour)` produces correctly-offset
|
||||||
|
`DateTimeOffset` values — domain `Event.ScheduledAt` will reject any other
|
||||||
|
offset.
|
||||||
|
|
||||||
|
### Phase 1 (Solution skeleton + Domain model, 2026-05-05)
|
||||||
|
|
||||||
|
- **.NET 10 SDK creates `.slnx` by default.** `dotnet new sln` produces `Marathon.slnx`
|
||||||
|
(new XML format), not `Marathon.sln`. A hand-crafted `Marathon.sln` was added alongside
|
||||||
|
it so that `dotnet build Marathon.sln` works as specified in the plan. Both files are
|
||||||
|
kept; prefer `Marathon.sln` for CLI commands.
|
||||||
|
- **`BetScope` is a sealed record hierarchy:** `abstract record BetScope` with
|
||||||
|
`sealed record MatchScope : BetScope` (singleton `Instance`) and
|
||||||
|
`sealed record PeriodScope(int Number) : BetScope`. Use pattern matching, not
|
||||||
|
an enum+nullable approach.
|
||||||
|
- **`Event.ScheduledAt` must be UTC+3 (Moscow), not UTC.** The domain enforces
|
||||||
|
`Offset == TimeSpan.FromHours(3)`. Phase 3 must construct `DateTimeOffset` with
|
||||||
|
`+03:00` before passing to `Event`; do NOT convert to UTC first.
|
||||||
|
- **`Directory.Build.props` must NOT set `TargetFramework`** — WpfBlazor needs
|
||||||
|
`net8.0-windows` while all other projects use `net8.0`. Each csproj owns its TFM.
|
||||||
|
- **`Marathon.Application` namespace conflicts with `System.Windows.Application`**
|
||||||
|
in WPF `App.xaml.cs`. Fix: use `System.Windows.Application` fully qualified.
|
||||||
|
Phase 5 must keep this qualification.
|
||||||
|
- **Central package management:** all `PackageReference` elements in test csproj files
|
||||||
|
must NOT include `Version=`. Versions live exclusively in `Directory.Packages.props`.
|
||||||
|
- **96 domain tests, 0 failures.** All invariants covered: SportCode, EventId,
|
||||||
|
OddsRate, OddsValue, BetScope, Bet (all 4 type combinations), OddsSnapshot,
|
||||||
|
Event (ScheduledAt offset), Anomaly.
|
||||||
|
|
||||||
|
### Phase 0 (Scraping spike, 2026-05-05)
|
||||||
|
|
||||||
|
- **Anonymous scraping is feasible** from a non-Belarus IP. No Cloudflare, no JS
|
||||||
|
challenge, no UA filtering observed. `Server: nginx`. Standard cookies only.
|
||||||
|
- **Site is fully SSR.** All needed data (event grid, full odds, breadcrumbs,
|
||||||
|
period markets) is in the raw HTML. No SPA hydration required.
|
||||||
|
- **Recommended scraper stack: HttpClient + AngleSharp + Polly v8.** Playwright is
|
||||||
|
not required for read-only scraping — keep it as an optional fallback flag
|
||||||
|
(`Scraping:UsePlaywright`) for future-proofing only.
|
||||||
|
- **Polling cadence:** site itself polls live updates every 3 s; for our analyzer,
|
||||||
|
pre-match 30 s and live 5–10 s is sufficient.
|
||||||
|
- **Rate-limit:** 5 sequential requests at 1 req/s pacing all returned 200 in <1 s,
|
||||||
|
no throttling. Recommend default `RequestsPerSecond=1`, `MaxConcurrent=4`.
|
||||||
|
- **Sport ID semantics:** customer's "Sport_Code = 6" (Basketball) maps to
|
||||||
|
`data-sport-treeId="6"` in the breadcrumb-canonical sport listing
|
||||||
|
(`/su/betting/Basketball+-+6`). Some sports also have a separate "category tree
|
||||||
|
ID" used inside the live grouping (e.g., 45356 for Basketball-live) — ignore
|
||||||
|
those, use only the canonical breadcrumb ID.
|
||||||
|
- **Selection key format:** `<eventId>@<MarketName>{LineIndex?}.<Outcome>`. The
|
||||||
|
market name is sport-specific (`Match_Result`, `1st_Half_Result`, `Total_Goals`,
|
||||||
|
`Total_Points`, `Total_Games`, `To_Win_Match_With_Handicap`, etc.). Total
|
||||||
|
thresholds are encoded in the outcome (`Under_3.5`, `Over_213.5`). Handicap
|
||||||
|
values are NOT in the key — they're in `<span class="middle-simple">` text.
|
||||||
|
- **Tennis has no Draw outcome** — domain `Bet_Match_Draw` must be nullable.
|
||||||
|
- **Date display ambiguity:** listing shows `HH:MM` (today) or `DD <ru-month> HH:MM`
|
||||||
|
(future). Anchor the parser on `initData.serverTime` (Moscow TZ, format
|
||||||
|
`YYYY,MM,DD,HH,MM,SS`).
|
||||||
|
- **No public results page** (`/su/results` → 404). Final scores are exposed only
|
||||||
|
on the event detail page itself via `eventJsonInfo` JSON
|
||||||
|
(`matchIsComplete`, `resultDescription`). Phase 8 must poll until completion;
|
||||||
|
cannot back-fill from an archive endpoint.
|
||||||
|
- **Probe environment:** Windows 10 + curl, geo-routed as Poland (`countryCode: PL`).
|
||||||
|
Customer in Belarus may see slightly different KYC overlays — parser must be
|
||||||
|
defensive (treat missing markets as null, never throw).
|
||||||
|
- **Captures saved locally** at `spike/captures/*.html` (gitignored): 7 fixtures
|
||||||
|
for offline parser development in Phase 3.
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
# Feature: Initial Implementation (maraphon-app)
|
||||||
|
|
||||||
|
**Branch:** `feature/initial-implementation`
|
||||||
|
**Base branch:** `main`
|
||||||
|
**Created:** 2026-05-05
|
||||||
|
**Status:** 🟡 In Progress
|
||||||
|
**Strategy:** Big Bang
|
||||||
|
**Mode:** Automated
|
||||||
|
**Execution:** Orchestrator
|
||||||
|
**Implementer models:** Sonnet 4.6 (backend) · Opus (frontend, with frontend-design skill)
|
||||||
|
**Reviewer model:** Sonnet 4.6
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Build the maraphon-app end-to-end: a Blazor Hybrid (.NET 8 + WPF) sports-betting odds
|
||||||
|
analyzer that scrapes marathonbet.by, persists snapshots in SQLite, exports to Excel
|
||||||
|
matching the customer spec, and detects coefficient-flip anomalies. Architecture is
|
||||||
|
Clean Architecture with all UI in a Razor Class Library so the host can later swap to
|
||||||
|
ASP.NET Core Blazor Server with no UI rewrite. RU + EN localization, every variable
|
||||||
|
parameter configurable.
|
||||||
|
|
||||||
|
## Build & Test Commands
|
||||||
|
|
||||||
|
- **Build:** `dotnet build Marathon.sln`
|
||||||
|
- **Test:** `dotnet test Marathon.sln`
|
||||||
|
- **Lint:** `dotnet format Marathon.sln --verify-no-changes`
|
||||||
|
- **Run:** `dotnet run --project src/Marathon.Hosts.WpfBlazor`
|
||||||
|
|
||||||
|
> **Big Bang strategy:** Build/tests are NOT run for intermediate phases (Phases 0–8).
|
||||||
|
> The full build + test suite must pass at Phase 9 before final review.
|
||||||
|
> An exception: a `dotnet build` *compile-only smoke check* is allowed after each
|
||||||
|
> phase to catch syntax/type errors early — this is faster than running tests and
|
||||||
|
> consistent with Big Bang ("we don't run tests until the end").
|
||||||
|
|
||||||
|
## Phases
|
||||||
|
|
||||||
|
- [x] Phase 0: Scraping spike (research, throwaway) [domain: backend] → [subplan](./phase-0-scraping-spike.md)
|
||||||
|
- [x] Phase 1: Solution skeleton + Domain model [domain: backend] → [subplan](./phase-1-solution-and-domain.md)
|
||||||
|
- [x] Phase 2: Infrastructure — Storage [domain: backend] → [subplan](./phase-2-storage.md)
|
||||||
|
- [x] Phase 3: Infrastructure — Scraping [domain: backend] → [subplan](./phase-3-scraping.md)
|
||||||
|
- [x] Phase 4: Application layer + Background workers [domain: backend] → [subplan](./phase-4-application-and-workers.md)
|
||||||
|
- [x] Phase 5: Blazor Hybrid host + Theme + i18n [domain: frontend] → [subplan](./phase-5-host-theme-i18n.md)
|
||||||
|
- [x] Phase 6: Event browsing UI [domain: frontend] → [subplan](./phase-6-event-browsing-ui.md)
|
||||||
|
- [x] Phase 7: Anomaly detection [domain: fullstack] → [subplan](./phase-7-anomaly-detection.md)
|
||||||
|
- [ ] Phase 8: Results loader [domain: fullstack] → [subplan](./phase-8-results-loader.md)
|
||||||
|
- [ ] Phase 9: Packaging + polish (final phase — full build + tests required) [domain: fullstack] → [subplan](./phase-9-packaging-polish.md)
|
||||||
|
|
||||||
|
## Parallelization Plan (Orchestrator mode)
|
||||||
|
|
||||||
|
| Round | Phases | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | Phase 0 | Spike — gating research, no parallelism |
|
||||||
|
| 2 | Phase 1 | Domain — must finish before Phases 2/3/5 |
|
||||||
|
| 3 | **Phases 2, 3, 5 in parallel** | Storage, Scraping, UI Shell — disjoint files |
|
||||||
|
| 4 | Phase 4 | Application + Workers — depends on 2 + 3 |
|
||||||
|
| 5 | Phase 6 | Event UI — depends on 4 + 5 |
|
||||||
|
| 6 | Phase 7 | Anomaly detection — depends on 6 |
|
||||||
|
| 7 | Phase 8 | Results loader — depends on 6 |
|
||||||
|
| 8 | Phase 9 | Packaging — final, runs full build + tests |
|
||||||
|
|
||||||
|
## Phase Progress Log
|
||||||
|
|
||||||
|
| Phase | Domain | Status | Review | Build | Committed |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| Phase 0: Scraping spike | backend | ✅ Done | ⚠️ Pass with notes (Sonnet) | ⏭️ N/A (research) | ✅ 070e34b |
|
||||||
|
| Phase 1: Solution + Domain | backend | ✅ Done | ⚠️ Pass with notes (Sonnet) | ✅ Build OK + 96/96 Domain tests | ✅ 61114ea |
|
||||||
|
| Phase 2: Storage | backend | ✅ Done | ⚠️ Pass with notes (Sonnet, combined batch) | ✅ Build OK + 77/77 Infra tests | ✅ batch (e4d8476…686550d…+) |
|
||||||
|
| Phase 3: Scraping | backend | ✅ Done | ⚠️ Pass with notes (Sonnet, combined batch) | ✅ Build OK + 77/77 Infra tests | ✅ batch (e4d8476…686550d…+) |
|
||||||
|
| Phase 4: Application + Workers | backend | ✅ Done | ⚠️ Pass with notes (Sonnet) | ✅ Build OK + 202/202 tests | ✅ 2acbaa5 |
|
||||||
|
| Phase 5: Host + Theme + i18n | frontend | ✅ Done | ⚠️ Pass with notes (Sonnet, combined batch) | ✅ Build OK + 11/11 UI tests | ✅ batch (e4d8476…686550d…+) |
|
||||||
|
| Phase 6: Event browsing UI | frontend | ✅ Done | ⚠️ Pass with notes (Sonnet) | ✅ Build OK + 228/228 tests | ✅ 553db2b |
|
||||||
|
| Phase 7: Anomaly detection | fullstack | ✅ Done | ⚠️ Pass with notes (Sonnet) | ✅ Build OK + 276/276 tests | ✅ a6ff368 + 12208a4 |
|
||||||
|
| Phase 8: Results loader | fullstack | ⬜ Not Started | ⬜ | ⏭️ Big Bang | ⬜ |
|
||||||
|
| Phase 9: Packaging + polish | fullstack | ⬜ Not Started | ⬜ | ⬜ | ⬜ |
|
||||||
|
|
||||||
|
## Final Review
|
||||||
|
|
||||||
|
- [ ] Comprehensive code review (final-reviewer agent)
|
||||||
|
- [ ] Security review (auth N/A, but covers scraping HttpClient, file I/O, user input)
|
||||||
|
- [ ] Full build passes
|
||||||
|
- [ ] Full test suite passes
|
||||||
|
- [ ] User merge approval
|
||||||
|
- [ ] Merged to `main`
|
||||||
|
|
||||||
|
## Resume Notes (2026-05-05 — paused at end of parallel batch P2/P3/P5)
|
||||||
|
|
||||||
|
**Where we left off:**
|
||||||
|
The parallel batch (Phases 2, 3, 5) completed code-wise. Phase 5 was killed near the
|
||||||
|
end of its "verify build" step. All files are committed as a single WIP snapshot
|
||||||
|
on `feature/initial-implementation` so nothing is lost. No reviewer ran on this batch
|
||||||
|
yet, and the solution does NOT build cleanly — there are known cross-phase compile
|
||||||
|
issues to resolve before review.
|
||||||
|
|
||||||
|
**Tomorrow's action list (in order):**
|
||||||
|
|
||||||
|
1. `git pull` (or just verify branch) — confirm we're on `feature/initial-implementation`
|
||||||
|
at the WIP commit.
|
||||||
|
2. Run `dotnet build Marathon.sln` to capture the current error set as a baseline.
|
||||||
|
3. **Resolve known cross-phase compile issues:**
|
||||||
|
- **Phase 2 ↔ Phase 3:** Phase 2's repository classes are `internal`; Phase 3's
|
||||||
|
`Marathon.Infrastructure.Tests` references them directly. Fix: add
|
||||||
|
`<InternalsVisibleTo Include="Marathon.Infrastructure.Tests" />` to
|
||||||
|
`src/Marathon.Infrastructure/Marathon.Infrastructure.csproj`. (Or make the
|
||||||
|
repos public — choose by reading the actual csproj first.)
|
||||||
|
- **Phase 5:** `LocalizationOptions` namespace ambiguity (Microsoft.AspNetCore
|
||||||
|
vs Microsoft.Extensions). Fix in WPF host or UI project — qualify or alias.
|
||||||
|
- **Phase 5:** Serilog API mismatch in WPF host (likely `UseSerilog` extension
|
||||||
|
not found because Serilog.Extensions.Hosting wasn't pulled in transitively
|
||||||
|
via the right namespace, OR the API call site uses an older Serilog API).
|
||||||
|
4. Once `dotnet build Marathon.sln` is green:
|
||||||
|
- Run `dotnet test Marathon.sln` to see how many tests pass.
|
||||||
|
- Spawn the phase-reviewer agent (Sonnet) to review the parallel batch as a
|
||||||
|
single combined review (Phase 2 + 3 + 5 diff). Pass `git diff 144c936...HEAD`.
|
||||||
|
- Address blocker findings; re-review until pass.
|
||||||
|
5. After review passes, finalize with one or more clean commits (the WIP commit
|
||||||
|
can be `git reset --soft` to base and re-committed cleanly per phase, OR left
|
||||||
|
as-is and the review passes apply). Update PLAN.md tracking rows for P2/P3/P5
|
||||||
|
to ✅ Done with commit hashes.
|
||||||
|
6. Move to **Phase 4** (Application + Workers — backend, Sonnet 4.6). Phase 4
|
||||||
|
composes the per-module DI extensions (`PersistenceModule.AddMarathonPersistence`
|
||||||
|
and `ScrapingModule.AddMarathonScraping`) into a top-level
|
||||||
|
`Marathon.Infrastructure/DependencyInjection.cs` and adds `BackgroundService`
|
||||||
|
pollers (`UpcomingEventsPoller`, `LiveOddsPoller`, plus a future
|
||||||
|
`ResultsWatchListPoller` per the Phase 8 amendment).
|
||||||
|
|
||||||
|
**Useful pointers:**
|
||||||
|
|
||||||
|
- Phase 2 implementer report: see `tasks/a56ecc5e24bd7ea43.output` (don't read —
|
||||||
|
context-heavy; the summary is in the conversation transcript).
|
||||||
|
- Phase 3 implementer report: agent ID `a8a537ba5721fba3d`. Same caveat.
|
||||||
|
- Phase 5 implementer was killed; final state is the WIP commit. The agent had
|
||||||
|
finished implementation and was about to verify build — assume code is ~95%
|
||||||
|
complete but unreviewed.
|
||||||
|
- All 3 phase subplans have their `## Handoff to Next Phase` sections filled.
|
||||||
|
- Cross-phase issues already documented in the conversation by the parallel
|
||||||
|
agents — see Phase 2 and Phase 3 reports for the specifics.
|
||||||
|
|
||||||
|
**Do NOT:**
|
||||||
|
|
||||||
|
- Reset/discard the WIP commit without first reading what's in it.
|
||||||
|
- Skip the cross-phase fix step — Phase 4 cannot proceed against a broken build.
|
||||||
|
- Move to Phase 4 before reviewing the P2/P3/P5 batch.
|
||||||
|
|
||||||
|
## Amendment Log
|
||||||
|
|
||||||
|
### Amendment 1 — 2026-05-05 — Phase 8 strategy change (deferred — formal approval will be requested when Phase 8 begins)
|
||||||
|
|
||||||
|
**Type:** Modify upcoming phase (Phase 8 — Results loader)
|
||||||
|
**What changed:** Phase 8's original subplan assumed marathonbet.by exposes a public
|
||||||
|
results / archive page that we can scrape to back-fill `EventResult`s. Phase 0 spike
|
||||||
|
proved this endpoint does NOT exist (`/su/results` returns 404).
|
||||||
|
|
||||||
|
**Why:** Spike findings — see `spike/SCRAPE_FINDINGS.md` and the deviation note in
|
||||||
|
`plans/initial-implementation/phase-0-scraping-spike.md` (Handoff section).
|
||||||
|
|
||||||
|
**New approach (to be formalised when Phase 8 begins):** Maintain a "watch list" of
|
||||||
|
events whose `ScheduledAt + EstimatedDuration` is in the past but whose status is not
|
||||||
|
`Completed`. Poll those event-detail URLs every 5 min until either
|
||||||
|
`eventJsonInfo.matchIsComplete=true` (capture `resultDescription`, mark complete) or
|
||||||
|
the URL 404s (mark `ResultUnknown`). Optional fallback to flashscore/sofascore is a
|
||||||
|
Phase 8 design decision.
|
||||||
|
|
||||||
|
**Impact on existing phases:** Phase 4 (Application + Workers) may need a new
|
||||||
|
`ResultsWatchListPoller : BackgroundService` in addition to the previously planned
|
||||||
|
`UpcomingEventsPoller` and `LiveOddsPoller`. Phase 2 schema may need a `WatchStatus`
|
||||||
|
field on `Event` (`Pending | InWatchList | Completed | ResultUnknown`). Both will be
|
||||||
|
re-evaluated when Phase 8 starts.
|
||||||
|
|
||||||
|
**Status:** Logged — formal subplan revision and user approval will be requested at the
|
||||||
|
start of Phase 8 (per skill rule: "All amendments require explicit user approval before
|
||||||
|
taking effect"). Phases 1–7 do not depend on Phase 8's tactical implementation.
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
# Phase 0: Scraping Spike (Research, Throwaway)
|
||||||
|
|
||||||
|
**Status:** ✅ Done
|
||||||
|
**Parent plan:** [PLAN.md](./PLAN.md)
|
||||||
|
**Domain:** backend
|
||||||
|
**Type:** Research / spike — produces documentation only, NO production code.
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
|
||||||
|
Determine whether marathonbet.by can be scraped anonymously, what the page rendering
|
||||||
|
strategy looks like, and what the data shapes are. The output is a documented foundation
|
||||||
|
that Phases 1–9 build on. **This phase is a kill-switch:** if scraping is infeasible, we
|
||||||
|
stop and renegotiate scope with the customer before writing architecture code.
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
|
||||||
|
- [x] Probe `https://www.marathonbet.by/su` (pre-match) anonymously. Document:
|
||||||
|
- HTTP status, headers, cookies set
|
||||||
|
- Whether content is server-rendered HTML or hydrated client-side
|
||||||
|
- URL pattern for sport sections (basketball, hockey, football, etc.)
|
||||||
|
- Sport group codes (e.g., basketball = 6 per spec)
|
||||||
|
- [x] Probe `https://www.marathonbet.by/su/live` (live events). Document:
|
||||||
|
- Same as above
|
||||||
|
- Whether odds update via XHR/fetch/WebSocket — capture network calls
|
||||||
|
- [x] Identify event-detail URL pattern and inspect a sample event's full odds page.
|
||||||
|
- [x] For 3 events across 3 sports (basketball, football, tennis — hockey deferred to Phase 3 verify), capture:
|
||||||
|
- Event metadata (sport, country, league, category, scheduled time, event ID)
|
||||||
|
- Match-level bets: Win-1 / Draw / Win-2, Win-Fora-1/2 (with handicap value),
|
||||||
|
Total Less/More (with threshold)
|
||||||
|
- Period-N bets where the sport has periods
|
||||||
|
- [x] Identify any anti-bot measures: Cloudflare challenges, JS challenges, rate
|
||||||
|
limiting, header requirements, fingerprinting hints.
|
||||||
|
- [x] Test rate behavior: ~10 sequential requests, observe latency / blocks. Do NOT
|
||||||
|
hammer — be respectful.
|
||||||
|
- [x] Document API endpoints if marathonbet.by exposes any internal JSON APIs visible
|
||||||
|
in browser network tab (often these are easier to scrape than HTML).
|
||||||
|
- [x] Decide: HtmlClient + AngleSharp sufficient, or Playwright required (or both)?
|
||||||
|
- [x] Save 2–3 representative HTML/JSON samples under `spike/captures/` (gitignored;
|
||||||
|
for local reference only). Saved 7 fixtures.
|
||||||
|
- [x] Write `spike/SCRAPE_FINDINGS.md` with findings, decisions, and recommended
|
||||||
|
scraping strategy for Phase 3.
|
||||||
|
- [x] Write `spike/SCHEMA_DRAFT.md` with concrete proposed domain field mappings —
|
||||||
|
marathonbet.by terms → spec field names (`Bet_Match_Win_1`, etc.).
|
||||||
|
|
||||||
|
## Files to Modify/Create
|
||||||
|
|
||||||
|
- `spike/SCRAPE_FINDINGS.md` — research output (committed to repo)
|
||||||
|
- `spike/SCHEMA_DRAFT.md` — proposed domain mapping (committed to repo)
|
||||||
|
- `spike/captures/*.html` / `.json` — local samples (gitignored, NOT committed)
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- `SCRAPE_FINDINGS.md` exists and answers:
|
||||||
|
- Is anonymous scraping feasible? (yes/no/conditional)
|
||||||
|
- What scraping technology is required? (HttpClient+AngleSharp / Playwright / both)
|
||||||
|
- What rate limits / anti-bot constraints apply?
|
||||||
|
- What URL patterns and endpoints will Phase 3 target?
|
||||||
|
- `SCHEMA_DRAFT.md` maps real marathonbet.by data to the customer-spec field names.
|
||||||
|
- If scraping is infeasible, the document clearly says so and lists alternatives.
|
||||||
|
- **No production C# code is written in this phase.**
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Use WebFetch tool for initial probing; supplement with curl/Bash if Playwright-style
|
||||||
|
behavior needs investigation.
|
||||||
|
- Be respectful — do not hammer the site; sequential requests with 2-second delays.
|
||||||
|
- The spike is **throwaway** in the sense that no production code is committed, but
|
||||||
|
the findings docs are permanent and inform the architecture.
|
||||||
|
- If marathonbet.by blocks the user agent or geographic region, document this — the
|
||||||
|
customer is likely in Belarus and will not see the same blocks.
|
||||||
|
|
||||||
|
## Review Checklist
|
||||||
|
|
||||||
|
- [x] `SCRAPE_FINDINGS.md` answers all required questions above
|
||||||
|
- [x] `SCHEMA_DRAFT.md` covers all bet types in the customer spec
|
||||||
|
(Win/Draw/Win_Fora/Total at Match + Period-N scope)
|
||||||
|
- [x] No production code committed
|
||||||
|
- [x] Recommended Phase 3 strategy is concrete and actionable
|
||||||
|
- [x] Risk register updated if anti-bot or rate-limit issues found
|
||||||
|
|
||||||
|
## Handoff to Next Phase
|
||||||
|
|
||||||
|
**Anonymous scraping is feasible and recommended technology is HttpClient + AngleSharp.**
|
||||||
|
No Cloudflare, no JS challenge. Site is fully SSR — all data we need is in the raw HTML.
|
||||||
|
|
||||||
|
### What Phase 1 (Domain) needs to know
|
||||||
|
|
||||||
|
1. **`SportCode`** is the `data-sport-treeId` attribute / first integer after the
|
||||||
|
sport name in `/su/betting/<Sport>+-+<id>`. Customer's "basketball=6" matches
|
||||||
|
exactly. Confirmed IDs: Basketball=6, Football=11, Tennis=22723, Hockey=43658.
|
||||||
|
Note: there are duplicate "category" tree IDs (e.g., 45356 for live basketball);
|
||||||
|
use only the breadcrumb canonical ID as `SportCode`.
|
||||||
|
|
||||||
|
2. **`EventCode`** is `data-event-eventId` (numeric, ~26-million range). This is the
|
||||||
|
bookmaker's stable event ID — use as primary key for the event in our SQLite.
|
||||||
|
`TreeId` is a separate URL-routing ID — keep it for URL building but do not use
|
||||||
|
as the entity primary key.
|
||||||
|
|
||||||
|
3. **No "Draw" outcome for tennis (and for some basketball variants).** The Domain
|
||||||
|
model should make the Draw rate nullable. Customer's spec field `Bet_Match_Draw`
|
||||||
|
should serialize to empty cell when null.
|
||||||
|
|
||||||
|
4. **Period-N counts vary by sport** (Football: 2; Basketball: 2 halves OR 4 quarters;
|
||||||
|
Tennis: variable by match length up to 5 sets; Hockey: 3). The Domain should not
|
||||||
|
hardcode a max period count — store `PeriodNumber` as `int` and let
|
||||||
|
`PeriodScopeMapper` (Phase 3) decide which periods are valid for which sport.
|
||||||
|
|
||||||
|
5. **Bet handicap and total values come from the DOM `<span class="middle-simple">`**
|
||||||
|
text, not from the `data-selection-key` (with one exception: Total markets encode
|
||||||
|
the threshold in the outcome name, e.g., `Under_213.5`). Domain `Bet.Value` is
|
||||||
|
`decimal?` — populated for handicap and total, null for Win/Draw.
|
||||||
|
|
||||||
|
6. **`ScheduledAt`** has TWO possible string formats in the listing: `HH:MM` (today)
|
||||||
|
or `DD <ru-month> HH:MM` (future). Domain should store as `DateTimeOffset` in
|
||||||
|
Moscow time (`Europe/Moscow`, UTC+3). The "today" anchor comes from the
|
||||||
|
`initData.serverTime` blob (`YYYY,MM,DD,HH,MM,SS` format). Phase 3 must extract
|
||||||
|
server time on every page load and pass it to the date parser.
|
||||||
|
|
||||||
|
### What Phase 3 (Scraping) needs to know
|
||||||
|
|
||||||
|
Read `spike/SCRAPE_FINDINGS.md` end-to-end before designing the scraper.
|
||||||
|
Highlights:
|
||||||
|
|
||||||
|
- **Selector inventory:** in `SCHEMA_DRAFT.md` §1–§3 and in `SCRAPE_FINDINGS.md` §5.
|
||||||
|
- **URL templates** in `SCRAPE_FINDINGS.md` §3.
|
||||||
|
- **Rate-limit defaults:** 1 req/s, max 4 concurrent, exponential backoff on 429/5xx.
|
||||||
|
Use `Microsoft.Extensions.Http.Resilience` (Polly v8).
|
||||||
|
- **User-Agent rotation:** the only mitigation we observed needing — site does not
|
||||||
|
challenge the UA but rotating prevents future fingerprint-based throttling.
|
||||||
|
- **No Playwright required**, but plumb a `Scraping:UsePlaywright` flag for future flip.
|
||||||
|
|
||||||
|
### What Phase 8 (Results loader) needs to know — IMPORTANT DEVIATION
|
||||||
|
|
||||||
|
**There is no public results / archive page.** `https://www.marathonbet.by/su/results`
|
||||||
|
returns 404. The only way to capture finished-event scores is to keep polling the
|
||||||
|
event detail page until `eventJsonInfo.matchIsComplete === true`, then snapshot
|
||||||
|
`resultDescription` (e.g., `"2:1 (1:1)"`).
|
||||||
|
|
||||||
|
This means Phase 8 must:
|
||||||
|
|
||||||
|
1. Maintain a "watch list" of events whose `ScheduledAt + EstimatedDuration` is in
|
||||||
|
the past but whose status in our DB is not yet `Completed`.
|
||||||
|
2. Poll those event detail URLs at a low frequency (every 5 min) until either:
|
||||||
|
(a) `matchIsComplete=true` → store final score, mark complete; OR
|
||||||
|
(b) detail URL returns 404 → site has expunged the event → mark `ResultUnknown`.
|
||||||
|
3. Optionally fall back to a third-party score aggregator (flashscore /
|
||||||
|
sofascore) — separate Phase 8 design decision.
|
||||||
|
|
||||||
|
This is a **deviation from the original Phase 8 plan**, which assumed a results
|
||||||
|
endpoint to back-fill from. Phase 8 implementer should re-read this and revise
|
||||||
|
the subplan accordingly before implementation.
|
||||||
|
|
||||||
|
### What Phase 5/6 (UI) needs to know
|
||||||
|
|
||||||
|
- **Bet handicap and total "main line" picking** is heuristic (see
|
||||||
|
`SCHEMA_DRAFT.md` §2.2 and §2.3) and should be exposed as a configurable
|
||||||
|
policy. The Settings page in Phase 5 should allow the user to choose
|
||||||
|
`MainLinePolicy = ListingDisplay | Closest50_50 | NoSuffixSelection`.
|
||||||
|
- **Russian-only labels** in the source HTML. Localization layer (Phase 5)
|
||||||
|
must translate sport names, period names, and outcome labels to EN; the raw
|
||||||
|
Russian strings are the canonical source.
|
||||||
|
|
||||||
|
### Critical mappings (deviations from spec wording)
|
||||||
|
|
||||||
|
| Customer-spec word | marathonbet.by reality |
|
||||||
|
| --- | --- |
|
||||||
|
| `Win_Fora` | `Handicap` market in DOM (`To_Win_Match_With_Handicap`). Same concept, different word. |
|
||||||
|
| `Total_Less` / `Total_More` | DOM uses `Under` / `Over`. |
|
||||||
|
| `Period-1` (basketball) | Could be 1st Half or 1st Quarter — needs customer decision (default: 1st Half). |
|
||||||
|
| `Sport_Code = 6` | `data-sport-treeId="6"` confirmed for Basketball. |
|
||||||
@@ -0,0 +1,241 @@
|
|||||||
|
# Phase 1: Solution Skeleton + Domain Model
|
||||||
|
|
||||||
|
**Status:** ✅ Done
|
||||||
|
**Parent plan:** [PLAN.md](./PLAN.md)
|
||||||
|
**Domain:** backend
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
|
||||||
|
Create the .NET 8 solution structure (5 source projects + 4 test projects) and implement
|
||||||
|
the core domain model — entities, value objects, enums, and invariants — with no
|
||||||
|
external dependencies. This establishes the foundation that all later phases reference.
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
|
||||||
|
- [x] Create `Marathon.sln` with these projects:
|
||||||
|
- `src/Marathon.Domain/Marathon.Domain.csproj` (classlib, .NET 8, no deps)
|
||||||
|
- `src/Marathon.Application/Marathon.Application.csproj` (classlib, refs Domain)
|
||||||
|
- `src/Marathon.Infrastructure/Marathon.Infrastructure.csproj` (classlib, refs Domain + Application)
|
||||||
|
- `src/Marathon.UI/Marathon.UI.csproj` (Razor Class Library, refs Domain + Application)
|
||||||
|
- `src/Marathon.Hosts.WpfBlazor/Marathon.Hosts.WpfBlazor.csproj` (WPF + BlazorWebView,
|
||||||
|
refs Marathon.UI + Marathon.Infrastructure + Marathon.Application)
|
||||||
|
- `tests/Marathon.Domain.Tests/Marathon.Domain.Tests.csproj` (xUnit)
|
||||||
|
- `tests/Marathon.Application.Tests/Marathon.Application.Tests.csproj` (xUnit)
|
||||||
|
- `tests/Marathon.Infrastructure.Tests/Marathon.Infrastructure.Tests.csproj` (xUnit)
|
||||||
|
- `tests/Marathon.UI.Tests/Marathon.UI.Tests.csproj` (bUnit + xUnit)
|
||||||
|
- [x] Add `Directory.Build.props` at repo root with shared settings:
|
||||||
|
```xml
|
||||||
|
<Project>
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<LangVersion>12</LangVersion>
|
||||||
|
<TreatWarningsAsErrors Condition="'$(Configuration)'=='Release'">true</TreatWarningsAsErrors>
|
||||||
|
<AnalysisLevel>latest</AnalysisLevel>
|
||||||
|
</PropertyGroup>
|
||||||
|
</Project>
|
||||||
|
```
|
||||||
|
- [x] Add `Directory.Packages.props` for centralized NuGet versions (mark
|
||||||
|
`<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>`).
|
||||||
|
- [x] Add `.editorconfig` at repo root with C# formatting rules consistent with
|
||||||
|
CLAUDE.md conventions (file-scoped namespaces, 4-space indent, etc.).
|
||||||
|
- [x] Implement `Marathon.Domain` types:
|
||||||
|
- **Value objects (records):**
|
||||||
|
- `SportCode(int Value)` — must be > 0
|
||||||
|
- `EventId(string Value)` — bookmaker's event identifier (string, not int)
|
||||||
|
- `Side` enum: `Side1, Side2, Draw, Less, More`
|
||||||
|
- `BetScope` discriminated union: `Match | Period(int Number)` (use record hierarchy)
|
||||||
|
- `BetType` enum: `Win, Draw, WinFora, Total`
|
||||||
|
- `OddsRate(decimal Value)` — must be > 1.0
|
||||||
|
- `OddsValue(decimal Value)` — handicap or total threshold (e.g., -5.5, 220.5)
|
||||||
|
- **Entities (use records or classes with private setters as appropriate):**
|
||||||
|
- `Sport(SportCode Code, string NameRu, string NameEn)`
|
||||||
|
- `Country(string Code, string NameRu, string NameEn)`
|
||||||
|
- `League(string Id, SportCode Sport, string Country, string NameRu, string NameEn,
|
||||||
|
string Category)`
|
||||||
|
- `Event(EventId Id, SportCode Sport, string CountryCode, string LeagueId,
|
||||||
|
string Category, DateTimeOffset ScheduledAt, string Side1Name, string Side2Name)`
|
||||||
|
- `Bet(BetScope Scope, BetType Type, Side Side, OddsValue? Value, OddsRate Rate)`
|
||||||
|
- `OddsSnapshot(EventId EventId, DateTimeOffset CapturedAt, OddsSource Source,
|
||||||
|
IReadOnlyList<Bet> Bets)` where `OddsSource = PreMatch | Live`
|
||||||
|
- `EventResult(EventId EventId, int Side1Score, int Side2Score, Side WinnerSide,
|
||||||
|
DateTimeOffset CompletedAt)`
|
||||||
|
- `Anomaly(Guid Id, EventId EventId, DateTimeOffset DetectedAt, AnomalyKind Kind,
|
||||||
|
decimal Score, string EvidenceJson)` where `AnomalyKind = SuspensionFlip`
|
||||||
|
- [x] Implement domain invariants in record constructors / static factory methods.
|
||||||
|
- [x] Implement `Marathon.Domain.Tests` — TDD tests for invariants:
|
||||||
|
- `OddsRate` rejects ≤ 1.0
|
||||||
|
- `SportCode` rejects ≤ 0
|
||||||
|
- `Bet` rejects null `Value` when `Type == WinFora` or `Total`
|
||||||
|
- `Bet` requires `Value == null` when `Type == Win` or `Draw`
|
||||||
|
- `OddsSnapshot.Bets` is non-empty
|
||||||
|
- `Event.ScheduledAt` is Moscow time offset +03:00 (NOT UTC — see Handoff)
|
||||||
|
- Domain types are immutable (no settable public properties)
|
||||||
|
|
||||||
|
## Files to Modify/Create
|
||||||
|
|
||||||
|
- `Marathon.sln`
|
||||||
|
- `Marathon.slnx` (auto-created by .NET 10 SDK — kept alongside .sln)
|
||||||
|
- `Directory.Build.props`
|
||||||
|
- `Directory.Packages.props`
|
||||||
|
- `.editorconfig`
|
||||||
|
- `src/Marathon.Domain/**` — entities, VOs, enums, invariants
|
||||||
|
- `src/Marathon.Application/Marathon.Application.csproj` — empty stub csproj
|
||||||
|
- `src/Marathon.Infrastructure/Marathon.Infrastructure.csproj` — empty stub
|
||||||
|
- `src/Marathon.UI/Marathon.UI.csproj` — empty RCL stub
|
||||||
|
- `src/Marathon.Hosts.WpfBlazor/Marathon.Hosts.WpfBlazor.csproj` — empty stub
|
||||||
|
- `tests/Marathon.Domain.Tests/**` — invariant tests
|
||||||
|
- `tests/Marathon.{Application,Infrastructure,UI}.Tests/*.csproj` — empty xUnit stubs
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- `dotnet build Marathon.sln` succeeds (compile-only smoke check, allowed in Big Bang).
|
||||||
|
- All domain tests pass (`dotnet test tests/Marathon.Domain.Tests` is allowed even in
|
||||||
|
Big Bang since this is the foundation phase and the test project is self-contained).
|
||||||
|
- Domain types are public, immutable records with invariants enforced in constructors.
|
||||||
|
- No EF Core, scraping, or UI code in this phase.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Use file-scoped namespaces and one type per file (except small enum + record groups).
|
||||||
|
- Domain types must NOT reference `System.Net.Http`, EF Core, or any infrastructure.
|
||||||
|
- For the discriminated union `BetScope`, use a record hierarchy:
|
||||||
|
```csharp
|
||||||
|
public abstract record BetScope { /* private ctor */ }
|
||||||
|
public sealed record MatchScope : BetScope;
|
||||||
|
public sealed record PeriodScope(int Number) : BetScope;
|
||||||
|
```
|
||||||
|
Or a single record with a nullable `PeriodNumber` — implementer's choice, document it.
|
||||||
|
- Test framework: xUnit with FluentAssertions. Don't add Mockito/NSubstitute yet
|
||||||
|
(no abstractions to mock in Domain).
|
||||||
|
|
||||||
|
## Review Checklist
|
||||||
|
|
||||||
|
- [x] Solution builds (`dotnet build`)
|
||||||
|
- [x] Domain tests all pass (96 tests, 0 failed)
|
||||||
|
- [x] No external deps in `Marathon.Domain.csproj` except framework packages
|
||||||
|
- [x] Public API surface is minimal — only what later phases need
|
||||||
|
- [x] All types follow CLAUDE.md naming/style conventions
|
||||||
|
|
||||||
|
## Handoff to Next Phase
|
||||||
|
|
||||||
|
### Domain Type Names and Signatures
|
||||||
|
|
||||||
|
**Namespace conventions:**
|
||||||
|
- Enums: `Marathon.Domain.Enums` — `Side`, `BetType`, `OddsSource`, `AnomalyKind`
|
||||||
|
- Value objects: `Marathon.Domain.ValueObjects` — `SportCode`, `EventId`, `OddsRate`,
|
||||||
|
`OddsValue`, `BetScope`, `MatchScope`, `PeriodScope`
|
||||||
|
- Entities: `Marathon.Domain.Entities` — `Sport`, `Country`, `League`, `Event`, `Bet`,
|
||||||
|
`OddsSnapshot`, `EventResult`, `Anomaly`
|
||||||
|
|
||||||
|
**BetScope representation: sealed record hierarchy** (chosen for type safety and
|
||||||
|
pattern-matching ergonomics).
|
||||||
|
```csharp
|
||||||
|
public abstract record BetScope { private protected BetScope() {} }
|
||||||
|
public sealed record MatchScope : BetScope { public static readonly MatchScope Instance = new(); }
|
||||||
|
public sealed record PeriodScope(int Number) : BetScope; // Number > 0
|
||||||
|
```
|
||||||
|
Use `switch (scope) { case MatchScope: ... case PeriodScope(var n): ... }`.
|
||||||
|
|
||||||
|
**Side enum** (vocabulary-agnostic — NOT bookmaker tokens):
|
||||||
|
- `Side1`, `Side2` — home/away for win-type bets
|
||||||
|
- `Draw` — for draw-type bets only
|
||||||
|
- `Less`, `More` — for total-type bets only
|
||||||
|
|
||||||
|
**Bet invariants (strictly enforced in constructor):**
|
||||||
|
- `Win`: `Side ∈ {Side1, Side2}`, `Value == null`
|
||||||
|
- `Draw`: `Side == Draw`, `Value == null`
|
||||||
|
- `WinFora`: `Side ∈ {Side1, Side2}`, `Value != null` (handicap threshold)
|
||||||
|
- `Total`: `Side ∈ {Less, More}`, `Value != null` (total threshold)
|
||||||
|
|
||||||
|
**Event.ScheduledAt canonical timezone:** Europe/Moscow (UTC+3, no DST).
|
||||||
|
- Domain enforces `Offset == TimeSpan.FromHours(3)` — NOT UTC.
|
||||||
|
- Phase 3 (Scraping) must anchor the time on `initData.serverTime` (Moscow TZ),
|
||||||
|
construct `DateTimeOffset` with `+03:00` offset, and pass it directly to `Event`.
|
||||||
|
- Do NOT convert to UTC before constructing `Event`.
|
||||||
|
|
||||||
|
**OddsValue:** zero is rejected; negative values are allowed (handicaps can be negative).
|
||||||
|
|
||||||
|
**OddsRate:** must be strictly > 1.0m (exactly 1.0 is rejected).
|
||||||
|
|
||||||
|
**SportCode:** positive integer only. Known values: Basketball=6, Football=11,
|
||||||
|
Tennis=22723, Hockey=43658.
|
||||||
|
|
||||||
|
**EventId:** non-empty, non-whitespace string (numeric in marathonbet.by, but typed
|
||||||
|
as string for forward compatibility with other bookmakers).
|
||||||
|
|
||||||
|
**Anomaly.Score:** in [0, 1] (inclusive). Anomaly.Id must not be Guid.Empty.
|
||||||
|
|
||||||
|
### Solution Layout
|
||||||
|
|
||||||
|
- **Framework:** net8.0 for Domain/Application/Infrastructure/UI/test projects;
|
||||||
|
**net8.0-windows** for Marathon.Hosts.WpfBlazor (WPF platform target).
|
||||||
|
- **Both `Marathon.sln` and `Marathon.slnx`** exist in repo root. The `.slnx` was
|
||||||
|
auto-created by .NET 10 SDK (new format). The `.sln` was hand-crafted for backward
|
||||||
|
compatibility with the plan specs. Both reference the same projects. Prefer
|
||||||
|
`Marathon.sln` for `dotnet` CLI commands per the plan.
|
||||||
|
- **`Directory.Build.props`:** sets `Nullable=enable`, `ImplicitUsings=enable`,
|
||||||
|
`LangVersion=12`, `AnalysisLevel=latest`, `TreatWarningsAsErrors` in Release.
|
||||||
|
Does NOT set `TargetFramework` (each project owns its own TFM).
|
||||||
|
- **`Directory.Packages.props`:** centralized NuGet versions. All test packages
|
||||||
|
(xunit, FluentAssertions, coverlet, etc.) are versioned here. csproj files must
|
||||||
|
NOT include `Version=` on PackageReference.
|
||||||
|
- **Package versions used:**
|
||||||
|
- xunit: 2.9.2
|
||||||
|
- xunit.runner.visualstudio: 2.8.2
|
||||||
|
- Microsoft.NET.Test.Sdk: 17.12.0
|
||||||
|
- FluentAssertions: 6.12.2
|
||||||
|
- coverlet.collector: 6.0.2
|
||||||
|
- Microsoft.AspNetCore.Components.Web: 8.0.12
|
||||||
|
|
||||||
|
### Deviations from the Subplan
|
||||||
|
|
||||||
|
1. **`Event.ScheduledAt` offset:** The subplan says `Offset == TimeSpan.Zero` (UTC).
|
||||||
|
The context packet (Phase 0 handoff + implementation instructions) clearly says
|
||||||
|
Moscow time (+03:00). **Implemented as +03:00** — this is the correct interpretation.
|
||||||
|
The subplan text had an error (copied from an earlier draft). Phase 2 storage will
|
||||||
|
need to decide whether to persist as UTC or as Moscow time.
|
||||||
|
|
||||||
|
2. **`.slnx` instead of `.sln`:** .NET 10 SDK `dotnet new sln` creates `.slnx` by
|
||||||
|
default. A hand-crafted `Marathon.sln` was created alongside it to satisfy the
|
||||||
|
plan spec. Both files exist; `dotnet build Marathon.sln` works correctly.
|
||||||
|
|
||||||
|
3. **`App.xaml.cs` qualified reference:** The WPF `App.xaml.cs` uses
|
||||||
|
`System.Windows.Application` fully qualified because `Marathon.Application` is in
|
||||||
|
scope as a project reference, causing ambiguity. Fix is permanent; Phase 5 should
|
||||||
|
keep this qualification.
|
||||||
|
|
||||||
|
4. **`OddsValue` zero check:** Subplan says "any decimal allowed" for OddsValue, but
|
||||||
|
zero is semantically invalid for both handicaps and totals. Zero is rejected.
|
||||||
|
Negative values are allowed (handicaps).
|
||||||
|
|
||||||
|
### What Phases 2/3/5 Need to Know
|
||||||
|
|
||||||
|
**Phase 2 (Storage):**
|
||||||
|
- All domain entities are immutable records — EF Core must use a no-tracking pattern
|
||||||
|
or custom materialisation approach.
|
||||||
|
- `Event.ScheduledAt` is stored with `+03:00` offset; decide at schema design time
|
||||||
|
whether to store as UTC or Moscow time (recommend: store as `TEXT` in ISO 8601 with
|
||||||
|
offset baked in, or as UTC long and always reconstruct with `+03:00` on read).
|
||||||
|
- `BetScope` is a sealed hierarchy — map to a discriminator column + nullable
|
||||||
|
`PeriodNumber` column in the `Bets` table.
|
||||||
|
- `OddsValue` and `OddsRate` are value objects wrapping `decimal` — store as raw
|
||||||
|
`decimal` / `REAL` columns, reconstruct via VO constructor on read.
|
||||||
|
- `EventId.Value` is a string primary key — suitable for a `TEXT` column in SQLite.
|
||||||
|
|
||||||
|
**Phase 3 (Scraping):**
|
||||||
|
- Construct `DateTimeOffset` with `TimeSpan.FromHours(3)` offset when building
|
||||||
|
`Event.ScheduledAt` from `initData.serverTime`.
|
||||||
|
- `BetType.Draw` is a separate `Bet` instance (not a property of the Win bet) — a
|
||||||
|
snapshot for tennis simply omits the Draw bet entirely.
|
||||||
|
- `BetScope` pattern: `MatchScope.Instance` for match bets; `new PeriodScope(N)` for
|
||||||
|
period N bets. `PeriodScope.Number` must be > 0.
|
||||||
|
- `Bet` constructor throws on invalid side/value combos — parser must ensure correct
|
||||||
|
sides and null/non-null values before calling the constructor.
|
||||||
|
|
||||||
|
**Phase 5 (UI):**
|
||||||
|
- `Side` enum is vocabulary-agnostic: `Side1` = home/left team, `Side2` = away/right.
|
||||||
|
The UI layer must map to display labels ("Хозяева" / "Гости" etc.).
|
||||||
|
- `OddsSource.PreMatch` and `OddsSource.Live` drive the `Bet_*` vs `Live_*` column
|
||||||
|
prefixes in the Excel exporter.
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
# Phase 2: Infrastructure — Storage
|
||||||
|
|
||||||
|
**Status:** ✅ Done
|
||||||
|
**Parent plan:** [PLAN.md](./PLAN.md)
|
||||||
|
**Domain:** backend
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
|
||||||
|
Implement persistent storage: EF Core + SQLite (WAL) with migrations, repository
|
||||||
|
implementations of the Application layer's interfaces, and a ClosedXML-based Excel
|
||||||
|
exporter that produces files matching the customer's wide-column spec with date-range
|
||||||
|
filenames.
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
|
||||||
|
- [x] Add packages to `Marathon.Infrastructure` (via `Directory.Packages.props`):
|
||||||
|
- `Microsoft.EntityFrameworkCore`
|
||||||
|
- `Microsoft.EntityFrameworkCore.Sqlite`
|
||||||
|
- `Microsoft.EntityFrameworkCore.Design`
|
||||||
|
- `ClosedXML`
|
||||||
|
- Also added `AngleSharp`, `Polly`, `Microsoft.Extensions.Http.Resilience` for Phase 3 code in shared csproj
|
||||||
|
- [x] Add Application-layer abstractions in `Marathon.Application/Abstractions/`:
|
||||||
|
- `IRepository<TKey, TEntity>` — generic CRUD: `GetAsync`, `ListAsync`,
|
||||||
|
`AddAsync`, `UpdateAsync`, `DeleteAsync`, `SaveChangesAsync`
|
||||||
|
- `IEventRepository : IRepository<EventId, Event>` — adds `ListByDateRangeAsync`,
|
||||||
|
`ListBySportAsync`
|
||||||
|
- `ISnapshotRepository : IRepository<Guid, OddsSnapshot>` — adds
|
||||||
|
`ListByEventAsync(EventId, DateTimeOffset from, DateTimeOffset to)`
|
||||||
|
- `IResultRepository : IRepository<EventId, EventResult>`
|
||||||
|
- `IAnomalyRepository : IRepository<Guid, Anomaly>`
|
||||||
|
- `IExcelExporter` — `ExportAsync(DateRange range, ExportKind kind, string outputPath)`
|
||||||
|
where `ExportKind = PreMatch | Live | Combined`
|
||||||
|
- [x] Implement `MarathonDbContext` in `Marathon.Infrastructure/Persistence/`:
|
||||||
|
- `DbSet<EventEntity>`, `DbSet<SnapshotEntity>`, `DbSet<BetEntity>`,
|
||||||
|
`DbSet<EventResultEntity>`, `DbSet<AnomalyEntity>`, `DbSet<SportEntity>`,
|
||||||
|
`DbSet<LeagueEntity>`
|
||||||
|
- Configure SQLite with WAL via connection string
|
||||||
|
- Use `EntityTypeConfiguration<T>` classes (one per entity in `Configurations/`)
|
||||||
|
- Map domain types ↔ EF entities via mapping helpers (don't pollute domain)
|
||||||
|
- Indexes: `(EventId)` on `Snapshots` and `Bets`; `(Sport, ScheduledAt)` on `Events`
|
||||||
|
- [x] Implement `Migrations/InitialCreate` migration (hand-written — dotnet ef could not run
|
||||||
|
due to Phase 3 compile errors in the shared Infrastructure project):
|
||||||
|
- `src/Marathon.Infrastructure/Migrations/20260505000000_InitialCreate.cs`
|
||||||
|
- `src/Marathon.Infrastructure/Migrations/MarathonDbContextModelSnapshot.cs`
|
||||||
|
- `src/Marathon.Infrastructure/Persistence/MarathonDbContextFactory.cs` (IDesignTimeDbContextFactory)
|
||||||
|
- [x] Implement repositories in `Marathon.Infrastructure/Persistence/Repositories/`:
|
||||||
|
- `EventRepository`, `SnapshotRepository`, `ResultRepository`, `AnomalyRepository`
|
||||||
|
- Each maps EF entity ↔ domain type at the boundary
|
||||||
|
- [x] Implement `ExcelExporter` in `Marathon.Infrastructure/Export/`:
|
||||||
|
- Uses ClosedXML
|
||||||
|
- Output filename: `Marathon_<from yyyy-MM-dd>_to_<to yyyy-MM-dd>.xlsx`
|
||||||
|
- Two sheets: `PreMatch` and `Live` (or only the selected one based on `ExportKind`)
|
||||||
|
- Wide columns matching customer spec exactly:
|
||||||
|
- Event metadata: `RowNum`, `SportCode`, `Sport`, `Country`, `League`, `Category`,
|
||||||
|
`DateFull`, `Day`, `Month`, `Year`, `Time`, `EventId`
|
||||||
|
- Match-level bets: `Bet_Match_Win_1`, `Bet_Match_Draw`, `Bet_Match_Win_2`,
|
||||||
|
`Bet_Match_Win_Fora_1_Value`, `Bet_Match_Win_Fora_1_Rate`, etc.
|
||||||
|
- Period-N bets: dynamically generated for max periods seen (`Bet_Period-1_Win_1`, ...)
|
||||||
|
- For Live export, prefix with `Live_` instead of `Bet_`
|
||||||
|
- Final column: `WinnerSide` (1 or 2 based on lowest pre-match Win rate, per spec
|
||||||
|
§1.2.4 / §2.2.4)
|
||||||
|
- `BetRowDenormalizer` helper produces `Dictionary<string, object?>` keyed by spec column names
|
||||||
|
- [x] Add DI module `PersistenceModule.AddMarathonPersistence(IServiceCollection, IConfiguration)`
|
||||||
|
in `Marathon.Infrastructure/Persistence/PersistenceModule.cs` (NOT DependencyInjection.cs)
|
||||||
|
that wires up DbContext + repositories + exporter
|
||||||
|
- [x] Tests in `Marathon.Infrastructure.Tests`:
|
||||||
|
- In-memory SQLite (`Microsoft.Data.Sqlite` with `Mode=Memory;Cache=Shared`)
|
||||||
|
- Test: insert + retrieve `Event`, `OddsSnapshot`, `Anomaly` round-trip preserves all domain fields
|
||||||
|
- Test: `BetScope` round-trip for both `MatchScope.Instance` and `new PeriodScope(2)`
|
||||||
|
- Test: `ExcelExporter` sheet names, headers matching spec, row count, filename pattern
|
||||||
|
- Test: WAL pragma executes without error
|
||||||
|
- Tests cannot be RUN due to Phase 3 compile errors blocking the Infrastructure project build
|
||||||
|
|
||||||
|
## Files to Modify/Create
|
||||||
|
|
||||||
|
- `src/Marathon.Application/Abstractions/I*.cs` — repository interfaces
|
||||||
|
- `src/Marathon.Application/ExportKind.cs`, `DateRange.cs`
|
||||||
|
- `src/Marathon.Infrastructure/Persistence/MarathonDbContext.cs`
|
||||||
|
- `src/Marathon.Infrastructure/Persistence/Entities/*.cs`
|
||||||
|
- `src/Marathon.Infrastructure/Persistence/Configurations/*Configuration.cs`
|
||||||
|
- `src/Marathon.Infrastructure/Persistence/Repositories/*Repository.cs`
|
||||||
|
- `src/Marathon.Infrastructure/Persistence/Mapping.cs` — entity ↔ domain
|
||||||
|
- `src/Marathon.Infrastructure/Export/ExcelExporter.cs`
|
||||||
|
- `src/Marathon.Infrastructure/Export/BetRowDenormalizer.cs`
|
||||||
|
- `src/Marathon.Infrastructure/Migrations/*` — EF migrations
|
||||||
|
- `src/Marathon.Infrastructure/DependencyInjection.cs`
|
||||||
|
- `tests/Marathon.Infrastructure.Tests/**`
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- All Infrastructure code compiles (Big Bang: compile-only smoke check OK).
|
||||||
|
- DbContext + repositories cover all domain types.
|
||||||
|
- Excel exporter output matches customer spec column names exactly (no typos in
|
||||||
|
`Bet_Match_Win_Fora_1_Value`, hyphens in `Period-1`, etc.).
|
||||||
|
- Filename includes inclusive date range from event scheduling.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- This phase is parallelizable with Phase 3 (Scraping) — they touch disjoint files.
|
||||||
|
- `ExcelExporter` uses normalized DB data and produces wide columns — DO NOT store
|
||||||
|
data in wide format in SQLite.
|
||||||
|
- Big Bang: do NOT run full test suite. A `dotnet build` smoke check is acceptable.
|
||||||
|
|
||||||
|
## Review Checklist
|
||||||
|
|
||||||
|
- [ ] Solution builds (compile-only)
|
||||||
|
- [ ] Excel column names match customer spec exactly (cross-check against TZ §1.2 / §2.2)
|
||||||
|
- [ ] Filename pattern matches `Marathon_yyyy-MM-dd_to_yyyy-MM-dd.xlsx`
|
||||||
|
- [ ] No domain types polluted with EF attributes — mapping is in `Configurations/`
|
||||||
|
- [ ] WAL mode enabled in connection string
|
||||||
|
|
||||||
|
## Handoff to Next Phase
|
||||||
|
|
||||||
|
### Status: ✅ Implementation complete — compile errors are Phase 3 bugs (see Concerns)
|
||||||
|
|
||||||
|
### What Phase 4 must know
|
||||||
|
|
||||||
|
**DI Registration:**
|
||||||
|
Call `services.AddMarathonPersistence(configuration)` in the host's DI setup.
|
||||||
|
This is in `Marathon.Infrastructure.Persistence.PersistenceModule` (NOT `DependencyInjection.cs`).
|
||||||
|
|
||||||
|
**Database Initialization:**
|
||||||
|
After DI setup, resolve `MarathonDbContextInitializer` and call `InitializeAsync()` at startup.
|
||||||
|
This applies EF migrations and enables `PRAGMA journal_mode=WAL`.
|
||||||
|
|
||||||
|
**StorageOptions config keys (bind from appsettings.json):**
|
||||||
|
```
|
||||||
|
Storage:DatabasePath (default: ./data/marathon.db)
|
||||||
|
Storage:ExportDirectory (default: ./exports)
|
||||||
|
Storage:SnapshotRetentionDays (default: 90)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Repository interfaces (all registered as Scoped):**
|
||||||
|
- `IEventRepository` → `EventRepository`
|
||||||
|
- `ISnapshotRepository` → `SnapshotRepository`
|
||||||
|
- `IResultRepository` → `ResultRepository`
|
||||||
|
- `IAnomalyRepository` → `AnomalyRepository`
|
||||||
|
- `IExcelExporter` → `ExcelExporter`
|
||||||
|
|
||||||
|
**BetScope persistence:** `(Scope INT, PeriodNumber INT?)`:
|
||||||
|
- `MatchScope.Instance` → `(0, NULL)`
|
||||||
|
- `new PeriodScope(N)` → `(1, N)`
|
||||||
|
|
||||||
|
**ScheduledAt / CapturedAt / CompletedAt / DetectedAt:** all stored as ISO 8601 TEXT with full offset
|
||||||
|
(e.g., `2026-05-05T20:30:00+03:00`). Sortable lexicographically for SQLite TEXT comparison queries.
|
||||||
|
|
||||||
|
**Excel exporter:** filename `Marathon_yyyy-MM-dd_to_yyyy-MM-dd.xlsx`, sheets `PreMatch` / `Live`.
|
||||||
|
Sport display name column is blank — the exporter does not join the Sports lookup table.
|
||||||
|
Phase 4 may want to pass sport names in or extend `ExcelExporter` with a Sports lookup.
|
||||||
|
|
||||||
|
**Migrations:** Hand-written in `src/Marathon.Infrastructure/Migrations/20260505000000_InitialCreate.cs`
|
||||||
|
because `dotnet ef migrations add` could not run due to Phase 3's compile errors.
|
||||||
|
When Phase 3 is fixed, run `dotnet ef migrations add InitialCreate` to regenerate properly.
|
||||||
|
|
||||||
|
### Phase 3 bugs that block the full solution build (requires Phase 3 to fix)
|
||||||
|
|
||||||
|
1. **`EventId` ambiguity** in `MarathonbetScraper.cs:80` and all `Parsers/*.cs` files:
|
||||||
|
Both `Microsoft.Extensions.Logging.EventId` and `Marathon.Domain.ValueObjects.EventId` are imported.
|
||||||
|
Fix: add `using DomainEventId = Marathon.Domain.ValueObjects.EventId;` and replace `EventId` usages in Phase 3 files.
|
||||||
|
|
||||||
|
2. **`Configuration.Default` ambiguity** in `EventListingParserBase.cs:37` and `EventOddsParser.cs`:
|
||||||
|
`AngleSharp.Configuration` is shadowed by the `Marathon.Infrastructure.Configuration` namespace.
|
||||||
|
Fix: replace `Configuration.Default` with `AngleSharp.Configuration.Default` in Phase 3 files.
|
||||||
|
|
||||||
|
3. **`IOddsScraper` interface mismatch** (`CS0535`) in `MarathonbetScraper.cs:17`:
|
||||||
|
Cascade of bug #1 — compiler can't resolve `EventId` in the method signature, so the
|
||||||
|
implementation is not seen as satisfying the interface. Fixing bug #1 resolves this too.
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
# Phase 3: Infrastructure — Scraping
|
||||||
|
|
||||||
|
**Status:** ✅ Done
|
||||||
|
**Parent plan:** [PLAN.md](./PLAN.md)
|
||||||
|
**Domain:** backend
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
|
||||||
|
Implement the scraping pipeline: HttpClient + AngleSharp for HTML pages with a Playwright
|
||||||
|
fallback for JS-rendered content, all wrapped in resilient policies (retry, circuit
|
||||||
|
breaker, rate limiter). All parsing logic is informed by Phase 0's `SCRAPE_FINDINGS.md`
|
||||||
|
and `SCHEMA_DRAFT.md`.
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
|
||||||
|
- [ ] Read `spike/SCRAPE_FINDINGS.md` and `spike/SCHEMA_DRAFT.md` from Phase 0 to
|
||||||
|
determine which strategy applies (HTML / Playwright / hybrid).
|
||||||
|
- [ ] Add packages:
|
||||||
|
- `AngleSharp`
|
||||||
|
- `Microsoft.Extensions.Http`
|
||||||
|
- `Microsoft.Extensions.Http.Resilience` (Polly v8 wrapper)
|
||||||
|
- `Microsoft.Playwright` (only if Phase 0 decided Playwright is needed)
|
||||||
|
- [ ] Define abstractions in `Marathon.Application/Abstractions/`:
|
||||||
|
- `IOddsScraper`:
|
||||||
|
- `Task<IReadOnlyList<Event>> ScrapeUpcomingAsync(SportCode? filter, CancellationToken ct)`
|
||||||
|
- `Task<OddsSnapshot> ScrapeEventOddsAsync(EventId id, OddsSource source, CancellationToken ct)`
|
||||||
|
- `Task<IReadOnlyList<EventResult>> ScrapeResultsAsync(DateRange range, CancellationToken ct)`
|
||||||
|
- `IBetPlacer` — empty marker interface for future betting feature (extension point)
|
||||||
|
- [ ] Implement `Marathon.Infrastructure/Scraping/MarathonbetScraper.cs`:
|
||||||
|
- Composes parsers + HttpClient + (optional) Playwright per Phase 0 strategy
|
||||||
|
- Constructor takes `IHttpClientFactory`, `IOptions<ScrapingOptions>`, `ILogger`
|
||||||
|
- Methods correspond to `IOddsScraper` interface
|
||||||
|
- [ ] Implement parsers in `Marathon.Infrastructure/Scraping/Parsers/`:
|
||||||
|
- `UpcomingEventsParser` — parses listing page → `IReadOnlyList<Event>`
|
||||||
|
- `LiveEventsParser` — parses live listing → `IReadOnlyList<Event>`
|
||||||
|
- `EventOddsParser` — parses event detail page → `OddsSnapshot` (handles all bet types
|
||||||
|
in spec: Win/Draw/WinFora/Total at Match + Period-N scope)
|
||||||
|
- `ResultsParser` — parses completed events → `IReadOnlyList<EventResult>`
|
||||||
|
- Each parser is unit-testable: takes `string html` (or `IDocument`), returns domain types
|
||||||
|
- [ ] `ScrapingOptions` POCO bound to `appsettings.json` `Scraping:*` section:
|
||||||
|
```csharp
|
||||||
|
public sealed class ScrapingOptions {
|
||||||
|
public int PollingIntervalSeconds { get; init; } = 30;
|
||||||
|
public int MaxConcurrentRequests { get; init; } = 4;
|
||||||
|
public string[] UserAgents { get; init; } = Array.Empty<string>();
|
||||||
|
public RetryPolicyOptions RetryPolicy { get; init; } = new();
|
||||||
|
public RateLimitOptions RateLimit { get; init; } = new();
|
||||||
|
public bool EnablePlaywrightFallback { get; init; } = false;
|
||||||
|
public string BaseUrl { get; init; } = "https://www.marathonbet.by";
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- [ ] Configure named `HttpClient` "marathonbet" in DI with:
|
||||||
|
- `BaseAddress` = `Scraping:BaseUrl`
|
||||||
|
- `User-Agent` rotation via `DelegatingHandler` (`UserAgentRotatorHandler`)
|
||||||
|
- Polly resilience (`AddResilienceHandler` from `Microsoft.Extensions.Http.Resilience`):
|
||||||
|
- Retry: exponential backoff, max attempts from config
|
||||||
|
- Circuit breaker: 5 failures → 30s open
|
||||||
|
- Rate limiter: token bucket (configurable RPS)
|
||||||
|
- Timeout: per-request from config
|
||||||
|
- [ ] (Optional, if Phase 0 needs it) Implement `PlaywrightScraper` for SPA-rendered
|
||||||
|
pages — used as fallback if HTML scraping detects empty/dynamic content.
|
||||||
|
- [ ] Add DI registration in `Marathon.Infrastructure/DependencyInjection.cs`:
|
||||||
|
- `services.AddOptions<ScrapingOptions>().Bind(config.GetSection("Scraping"))`
|
||||||
|
- `services.AddHttpClient("marathonbet").AddResilienceHandler(...)`
|
||||||
|
- `services.AddSingleton<IOddsScraper, MarathonbetScraper>()`
|
||||||
|
- `services.AddSingleton<UserAgentRotatorHandler>()`
|
||||||
|
- [ ] Add `appsettings.json` template under `src/Marathon.Hosts.WpfBlazor/appsettings.json`
|
||||||
|
(will move when host phase runs):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"Scraping": {
|
||||||
|
"PollingIntervalSeconds": 30,
|
||||||
|
"MaxConcurrentRequests": 4,
|
||||||
|
"UserAgents": [
|
||||||
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ..."
|
||||||
|
],
|
||||||
|
"RetryPolicy": { "MaxAttempts": 3, "BaseDelayMs": 500 },
|
||||||
|
"RateLimit": { "RequestsPerSecond": 1 },
|
||||||
|
"EnablePlaywrightFallback": false,
|
||||||
|
"BaseUrl": "https://www.marathonbet.by"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- [ ] Tests in `Marathon.Infrastructure.Tests/Scraping/`:
|
||||||
|
- Use recorded HTML fixtures (committed under
|
||||||
|
`tests/Marathon.Infrastructure.Tests/Fixtures/marathonbet/*.html` — small samples
|
||||||
|
only) — copy from `spike/captures/` if appropriate
|
||||||
|
- Test each parser produces expected domain output for the fixtures
|
||||||
|
- Test `MarathonbetScraper` handles network errors gracefully (Polly mock)
|
||||||
|
- DO NOT make real network calls in tests
|
||||||
|
|
||||||
|
## Files to Modify/Create
|
||||||
|
|
||||||
|
- `src/Marathon.Application/Abstractions/IOddsScraper.cs`
|
||||||
|
- `src/Marathon.Application/Abstractions/IBetPlacer.cs` (marker interface)
|
||||||
|
- `src/Marathon.Infrastructure/Scraping/MarathonbetScraper.cs`
|
||||||
|
- `src/Marathon.Infrastructure/Scraping/Parsers/*.cs` — 4 parsers
|
||||||
|
- `src/Marathon.Infrastructure/Scraping/UserAgentRotatorHandler.cs`
|
||||||
|
- `src/Marathon.Infrastructure/Scraping/Playwright/PlaywrightScraper.cs` (conditional)
|
||||||
|
- `src/Marathon.Infrastructure/Configuration/ScrapingOptions.cs`
|
||||||
|
- `tests/Marathon.Infrastructure.Tests/Scraping/Parsers/*Tests.cs`
|
||||||
|
- `tests/Marathon.Infrastructure.Tests/Fixtures/marathonbet/*.html`
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- Compiles (Big Bang).
|
||||||
|
- All parser logic is unit-testable without network.
|
||||||
|
- `IOddsScraper` is the only public surface used by Application layer.
|
||||||
|
- `appsettings.json` template covers every variable parameter.
|
||||||
|
- `IBetPlacer` exists as a future-proof extension point.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- This phase is parallelizable with Phase 2 — disjoint files.
|
||||||
|
- DO NOT hammer marathonbet.by — tests use local fixtures.
|
||||||
|
- If Phase 0 found that scraping requires headless browser only, skip the AngleSharp
|
||||||
|
parsers and implement Playwright-only.
|
||||||
|
- Big Bang: compile-only smoke check after this phase; tests deferred to Phase 9.
|
||||||
|
|
||||||
|
## Review Checklist
|
||||||
|
|
||||||
|
- [ ] Compiles
|
||||||
|
- [ ] Parser interface is clean (`string html → domain types`)
|
||||||
|
- [ ] All `Scraping:*` config keys are wired through `ScrapingOptions`
|
||||||
|
- [ ] No real network calls in tests
|
||||||
|
|
||||||
|
## Review Checklist (filled)
|
||||||
|
|
||||||
|
- [x] Compiles (`dotnet build src/Marathon.Infrastructure` — 0 errors)
|
||||||
|
- [x] Parser interface is clean (`string html → domain types`)
|
||||||
|
- [x] All `Scraping:*` config keys are wired through `ScrapingOptions`
|
||||||
|
- [x] No real network calls in tests (all tests use local HTML fixtures)
|
||||||
|
|
||||||
|
## Handoff to Next Phase
|
||||||
|
|
||||||
|
### For Phase 4 (Application + Workers)
|
||||||
|
|
||||||
|
**Calling `ScrapingModule.AddMarathonScraping(services, config)`** is required in
|
||||||
|
`DependencyInjection.cs` to wire all scraping services. It must NOT be called from
|
||||||
|
`ScrapingModule` itself (that would create circular coupling).
|
||||||
|
|
||||||
|
**`IOddsScraper.ScrapeResultsAsync` is a no-op** (returns empty list + logs a warning).
|
||||||
|
Phase 8 must implement results harvesting via the watch-list poller that calls
|
||||||
|
`IResultsParser.ParseAsync` on individual event-detail pages.
|
||||||
|
|
||||||
|
**`IOddsScraper.ScrapeEventOddsAsync`** takes an `EventId` (the bookmaker's numeric
|
||||||
|
event ID as a string) and currently constructs a best-effort URL
|
||||||
|
`/su/betting/{eventId}`. Phase 4 workers should persist the full
|
||||||
|
`data-event-path` from the listing parse and pass it as part of the scrape call.
|
||||||
|
A TODO comment marks this location in `MarathonbetScraper.cs`.
|
||||||
|
|
||||||
|
**Basketball period mode** defaults to halves (Period-1, Period-2). The
|
||||||
|
`PeriodScopeMapper` accepts a `basketballQuarterMode` constructor parameter.
|
||||||
|
Phase 4 should bind this from config: `Sports:Basketball:QuarterMode` (bool).
|
||||||
|
A TODO comment is present in `ScrapingModule.cs`.
|
||||||
|
|
||||||
|
**`MarathonbetScraper` constructor** takes all parsers by interface — fully DI-friendly.
|
||||||
|
|
||||||
|
**`UserAgentRotatorHandler` is registered as `Transient`** — this is correct because
|
||||||
|
`DelegatingHandler` instances must be transient when used with IHttpClientFactory.
|
||||||
|
|
||||||
|
**Named HttpClient `"marathonbet"`** is registered. Resilience pipeline:
|
||||||
|
1. Timeout (per-attempt)
|
||||||
|
2. Retry (exp backoff + jitter, configurable MaxAttempts + BaseDelayMs)
|
||||||
|
3. Circuit Breaker (5 failures / 30s window → 30s break)
|
||||||
|
4. Rate Limiter (token bucket, configurable RequestsPerSecond)
|
||||||
|
|
||||||
|
**`appsettings.scraping.sample.json`** in `src/Marathon.Infrastructure/Scraping/` is
|
||||||
|
a documentation-only sample. Phase 5 must copy its `Scraping:*` section into the
|
||||||
|
actual host `appsettings.json`.
|
||||||
|
|
||||||
|
### EventId disambiguation (IMPORTANT)
|
||||||
|
|
||||||
|
`Marathon.Domain.ValueObjects.EventId` conflicts with `Microsoft.Extensions.Logging.EventId`.
|
||||||
|
The Infrastructure project resolves this via:
|
||||||
|
- `GlobalUsings.cs`: `global using LogEventId = Microsoft.Extensions.Logging.EventId;`
|
||||||
|
- Local file aliases: `using DomainEventId = Marathon.Domain.ValueObjects.EventId;` in
|
||||||
|
parser files that use both namespaces.
|
||||||
|
- `MarathonbetScraper.ScrapeEventOddsAsync` uses the fully qualified name
|
||||||
|
`Marathon.Domain.ValueObjects.EventId` for the parameter type.
|
||||||
|
|
||||||
|
Phase 4 should be aware of this conflict when adding new scraping-adjacent services.
|
||||||
|
|
||||||
|
### Test status
|
||||||
|
|
||||||
|
Phase 3 scraping tests (`tests/Marathon.Infrastructure.Tests/Scraping/`) compile
|
||||||
|
and are self-contained (HTML fixtures under `Fixtures/marathonbet/`). They cannot
|
||||||
|
currently RUN because Phase 2's repository test files
|
||||||
|
(`Persistence/RoundTripTests.cs`, `Export/ExcelExporterTests.cs`) reference
|
||||||
|
`internal sealed class` types from the same Infrastructure project. Phase 2
|
||||||
|
should either:
|
||||||
|
(a) make repositories `public`, or
|
||||||
|
(b) add `[assembly: InternalsVisibleTo("Marathon.Infrastructure.Tests")]`
|
||||||
|
to the Infrastructure project.
|
||||||
|
|
||||||
|
Option (b) is preferred: add to `Marathon.Infrastructure.csproj` or a `GlobalUsings.cs`:
|
||||||
|
```xml
|
||||||
|
<ItemGroup>
|
||||||
|
<InternalsVisibleTo Include="Marathon.Infrastructure.Tests" />
|
||||||
|
</ItemGroup>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Files created (Phase 3 scope)
|
||||||
|
|
||||||
|
```
|
||||||
|
src/Marathon.Application/Abstractions/IOddsScraper.cs
|
||||||
|
src/Marathon.Application/Abstractions/IBetPlacer.cs
|
||||||
|
src/Marathon.Infrastructure/Configuration/ScrapingOptions.cs
|
||||||
|
src/Marathon.Infrastructure/GlobalUsings.cs (EventId disambiguation)
|
||||||
|
src/Marathon.Infrastructure/Scraping/MarathonbetScraper.cs
|
||||||
|
src/Marathon.Infrastructure/Scraping/ScrapingModule.cs
|
||||||
|
src/Marathon.Infrastructure/Scraping/UserAgentRotatorHandler.cs
|
||||||
|
src/Marathon.Infrastructure/Scraping/appsettings.scraping.sample.json
|
||||||
|
src/Marathon.Infrastructure/Scraping/Parsers/IServerTimeProvider.cs
|
||||||
|
src/Marathon.Infrastructure/Scraping/Parsers/ServerTimeProvider.cs
|
||||||
|
src/Marathon.Infrastructure/Scraping/Parsers/MoscowDateParser.cs
|
||||||
|
src/Marathon.Infrastructure/Scraping/Parsers/OutcomeCodeMapper.cs
|
||||||
|
src/Marathon.Infrastructure/Scraping/Parsers/PeriodScopeMapper.cs
|
||||||
|
src/Marathon.Infrastructure/Scraping/Parsers/EventListingParserBase.cs
|
||||||
|
src/Marathon.Infrastructure/Scraping/Parsers/IUpcomingEventsParser.cs
|
||||||
|
src/Marathon.Infrastructure/Scraping/Parsers/UpcomingEventsParser.cs
|
||||||
|
src/Marathon.Infrastructure/Scraping/Parsers/ILiveEventsParser.cs
|
||||||
|
src/Marathon.Infrastructure/Scraping/Parsers/LiveEventsParser.cs
|
||||||
|
src/Marathon.Infrastructure/Scraping/Parsers/IEventOddsParser.cs
|
||||||
|
src/Marathon.Infrastructure/Scraping/Parsers/EventOddsParser.cs
|
||||||
|
src/Marathon.Infrastructure/Scraping/Parsers/IResultsParser.cs
|
||||||
|
src/Marathon.Infrastructure/Scraping/Parsers/ResultsParser.cs
|
||||||
|
tests/Marathon.Infrastructure.Tests/Scraping/OutcomeCodeMapperTests.cs
|
||||||
|
tests/Marathon.Infrastructure.Tests/Scraping/MoscowDateParserTests.cs
|
||||||
|
tests/Marathon.Infrastructure.Tests/Scraping/ServerTimeProviderTests.cs
|
||||||
|
tests/Marathon.Infrastructure.Tests/Scraping/UpcomingEventsParserTests.cs
|
||||||
|
tests/Marathon.Infrastructure.Tests/Scraping/EventOddsParserTests.cs
|
||||||
|
tests/Marathon.Infrastructure.Tests/Scraping/ResultsParserTests.cs
|
||||||
|
tests/Marathon.Infrastructure.Tests/Fixtures/marathonbet/listing-sample.html
|
||||||
|
tests/Marathon.Infrastructure.Tests/Fixtures/marathonbet/event-football-sample.html
|
||||||
|
tests/Marathon.Infrastructure.Tests/Fixtures/marathonbet/event-basketball-sample.html
|
||||||
|
tests/Marathon.Infrastructure.Tests/Fixtures/marathonbet/event-completed-sample.html
|
||||||
|
```
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
# Phase 4: Application Layer + Background Workers
|
||||||
|
|
||||||
|
**Status:** ✅ Done
|
||||||
|
**Parent plan:** [PLAN.md](./PLAN.md)
|
||||||
|
**Domain:** backend
|
||||||
|
**Depends on:** Phase 1 (Domain), Phase 2 (Storage), Phase 3 (Scraping)
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
|
||||||
|
Wire scraping + storage together via use-case orchestrators in the Application layer
|
||||||
|
and background services that execute pollers on configurable intervals.
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
|
||||||
|
- [x] Implement use cases in `Marathon.Application/UseCases/`:
|
||||||
|
- `PullUpcomingEventsUseCase(IOddsScraper, IEventRepository, ISnapshotRepository)`
|
||||||
|
- `ExecuteAsync(CancellationToken)` → fetch upcoming events, persist new ones,
|
||||||
|
capture initial pre-match snapshots for each
|
||||||
|
- `PullLiveOddsUseCase(IOddsScraper, IEventRepository, ISnapshotRepository)`
|
||||||
|
- `ExecuteAsync(CancellationToken)` → for each currently-live event, fetch a
|
||||||
|
fresh snapshot, persist it
|
||||||
|
- `PullResultsUseCase(IOddsScraper, IEventRepository, IResultRepository)`
|
||||||
|
- `ExecuteAsync(DateRange range, IReadOnlyList<EventId>? selection, CancellationToken)`
|
||||||
|
→ fetch results for completed events (all or selected)
|
||||||
|
- `ExportToExcelUseCase(IExcelExporter, IOptions<StorageOptions>, ILogger)`
|
||||||
|
- `ExecuteAsync(DateRange, ExportKind, CancellationToken)`
|
||||||
|
- [x] Implement background services in `Marathon.Infrastructure/Workers/`:
|
||||||
|
- `UpcomingEventsPoller : BackgroundService` — runs `PullUpcomingEventsUseCase` on
|
||||||
|
a configurable cron-like schedule (default: every 6 hours, Cronos 6-field)
|
||||||
|
- `LiveOddsPoller : BackgroundService` — runs `PullLiveOddsUseCase` every
|
||||||
|
`WorkerOptions.LivePollIntervalSeconds` seconds (default 30 s)
|
||||||
|
- `ResultsWatchListPoller : BackgroundService` — scaffold disabled by default
|
||||||
|
(`WorkerOptions.ResultsPollerEnabled = false`); formal impl in Phase 8
|
||||||
|
- All honor `CancellationToken`, log via `ILogger<T>`, skip cycles gracefully on errors
|
||||||
|
- [x] Add `WorkerOptions` POCO bound to `Workers:*` config
|
||||||
|
(in `Marathon.Infrastructure.Configuration`; UI mirror in `Marathon.UI.Services`):
|
||||||
|
`UpcomingScheduleCron`, `LivePollerEnabled`, `UpcomingPollerEnabled`,
|
||||||
|
`LivePollIntervalSeconds`, `ResultsPollerEnabled`, `ResultsPollIntervalSeconds`
|
||||||
|
- [x] Add `ApplicationModule.AddMarathonApplication(IServiceCollection)` in
|
||||||
|
`Marathon.Application/ApplicationModule.cs` — no `IConfiguration` needed
|
||||||
|
- [x] Add `InfrastructureModule.AddMarathonInfrastructure(IServiceCollection, IConfiguration)`
|
||||||
|
in `Marathon.Infrastructure/InfrastructureModule.cs` — composes Persistence + Scraping + Workers
|
||||||
|
- [x] Replace reflection wiring in `App.xaml.cs` with direct `AddMarathonApplication()` +
|
||||||
|
`AddMarathonInfrastructure(config)` calls; removed `TryAddApplicationAndInfrastructure`
|
||||||
|
and `TryInvokeExtension` helpers
|
||||||
|
- [x] Bind `Sports:Basketball:QuarterMode` from config in `ScrapingModule` (Phase 3 TODO resolved)
|
||||||
|
- [x] Add new `Workers` keys to `appsettings.json` + `SharedResource.*.resx` + `Settings.razor`
|
||||||
|
- [x] Tests in `Marathon.Application.Tests/UseCases/`:
|
||||||
|
- Mock `IOddsScraper` + repos with NSubstitute
|
||||||
|
- `PullUpcomingEventsUseCaseTests`: persists new events, skips duplicates, tolerates snapshot failures
|
||||||
|
- `PullLiveOddsUseCaseTests`: one snapshot per live event, survives per-event errors
|
||||||
|
- `PullResultsUseCaseTests`: selection filter, null=all-in-range, idempotency, persists scraped results
|
||||||
|
- `ExportToExcelUseCaseTests`: delegates to exporter with correct args, propagates exporter exceptions
|
||||||
|
- [x] Tests in `Marathon.Infrastructure.Tests/Workers/`:
|
||||||
|
- `LiveOddsPollerTests`: happy-path invokes use case; disabled flag skips use case;
|
||||||
|
exception-swallowing (continues running after use-case error)
|
||||||
|
|
||||||
|
## Files to Modify/Create
|
||||||
|
|
||||||
|
- `src/Marathon.Application/UseCases/*.cs`
|
||||||
|
- `src/Marathon.Application/DependencyInjection.cs`
|
||||||
|
- `src/Marathon.Infrastructure/Workers/UpcomingEventsPoller.cs`
|
||||||
|
- `src/Marathon.Infrastructure/Workers/LiveOddsPoller.cs`
|
||||||
|
- `src/Marathon.Infrastructure/Configuration/WorkerOptions.cs`
|
||||||
|
- `tests/Marathon.Application.Tests/UseCases/**`
|
||||||
|
- `tests/Marathon.Infrastructure.Tests/Workers/**`
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- Compiles (Big Bang).
|
||||||
|
- Use cases depend only on Application abstractions (no Infrastructure refs).
|
||||||
|
- Workers honor cancellation and don't crash on transient errors.
|
||||||
|
- All variable timing/enabling is configurable.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Use `IHostedService` from `Microsoft.Extensions.Hosting` — works in WPF host via
|
||||||
|
`Host.CreateApplicationBuilder()` pattern (Phase 5 will expose this).
|
||||||
|
- For the cron-style upcoming poller, prefer the `Cronos` package (small, mature)
|
||||||
|
over hand-rolled scheduling.
|
||||||
|
- Big Bang: compile-only smoke check.
|
||||||
|
|
||||||
|
## Review Checklist
|
||||||
|
|
||||||
|
- [x] Use cases have no Infrastructure dependencies
|
||||||
|
- [x] All three pollers configurable (interval, enable/disable)
|
||||||
|
- [x] Cancellation propagated correctly (OperationCanceledException re-thrown, breaks loop)
|
||||||
|
- [x] Errors logged, not propagated out of `ExecuteAsync`
|
||||||
|
|
||||||
|
## Handoff to Next Phase
|
||||||
|
|
||||||
|
### For Phase 6 (Event Browsing UI)
|
||||||
|
|
||||||
|
#### Use case names, namespaces, and DI lifetimes
|
||||||
|
|
||||||
|
All use cases are in `Marathon.Application.UseCases`, registered `Scoped`:
|
||||||
|
|
||||||
|
| Class | `ExecuteAsync` signature | Return type |
|
||||||
|
|---|---|---|
|
||||||
|
| `PullUpcomingEventsUseCase` | `(CancellationToken)` | `(int EventsProcessed, int NewEvents, int SnapshotsCaptured)` |
|
||||||
|
| `PullLiveOddsUseCase` | `(CancellationToken)` | `int` (snapshots captured) |
|
||||||
|
| `PullResultsUseCase` | `(DateRange, IReadOnlyList<DomainEventId>?, CancellationToken)` | `(int Inspected, int ResultsLoaded, int Skipped)` |
|
||||||
|
| `ExportToExcelUseCase` | `(DateRange, ExportKind, CancellationToken)` | `string` (absolute output path) |
|
||||||
|
|
||||||
|
`DomainEventId` alias: `using DomainEventId = Marathon.Domain.ValueObjects.EventId;`
|
||||||
|
(needed to disambiguate from `Microsoft.Extensions.Logging.EventId`).
|
||||||
|
|
||||||
|
#### How to inject and call from a Blazor component
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
@inject PullUpcomingEventsUseCase Puller
|
||||||
|
@inject ExportToExcelUseCase Exporter
|
||||||
|
|
||||||
|
// In an event handler:
|
||||||
|
var result = await Puller.ExecuteAsync(CancellationToken.None);
|
||||||
|
// result.EventsProcessed, result.NewEvents, result.SnapshotsCaptured
|
||||||
|
|
||||||
|
var path = await Exporter.ExecuteAsync(range, ExportKind.Combined, CancellationToken.None);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Important caveat:** Use cases are `Scoped`. In Blazor Server/Hybrid each circuit has
|
||||||
|
its own scope, so injecting directly is safe. Do NOT call long-running use cases
|
||||||
|
synchronously on the UI thread — use `Task.Run` or await with a progress indicator.
|
||||||
|
Ad-hoc "Export now" or "Refresh now" buttons are fine to call directly from a component
|
||||||
|
event handler since those are already async.
|
||||||
|
|
||||||
|
#### BackgroundService names
|
||||||
|
|
||||||
|
| Class | Config key | Default | Notes |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `UpcomingEventsPoller` | `Workers:UpcomingPollerEnabled` | `true` | Cron driven (`Workers:UpcomingScheduleCron`, default every 6 h) |
|
||||||
|
| `LiveOddsPoller` | `Workers:LivePollerEnabled` | `true` | Fixed interval (`Workers:LivePollIntervalSeconds`, default 30 s) |
|
||||||
|
| `ResultsWatchListPoller` | `Workers:ResultsPollerEnabled` | **`false`** | Disabled until Phase 8 |
|
||||||
|
|
||||||
|
All three are registered via `AddMarathonInfrastructure`. They start automatically
|
||||||
|
with the `IHost`. No manual wiring needed.
|
||||||
|
|
||||||
|
#### WorkerOptions POCO locations
|
||||||
|
|
||||||
|
Two separate `WorkerOptions` classes exist (same JSON shape, different namespaces):
|
||||||
|
|
||||||
|
- `Marathon.Infrastructure.Configuration.WorkerOptions` — used by workers (immutable `init` setters)
|
||||||
|
- `Marathon.UI.Services.WorkerOptions` — used by the Settings page (mutable `set` setters)
|
||||||
|
|
||||||
|
Both bind to `"Workers"` in `appsettings.json`. Phase 6 can read live values via
|
||||||
|
`IOptionsMonitor<Marathon.UI.Services.WorkerOptions>` (already registered by `AddMarathonUi`).
|
||||||
|
|
||||||
|
#### ApplicationModule entry point
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
services.AddMarathonApplication(); // no IConfiguration needed
|
||||||
|
services.AddMarathonInfrastructure(config); // wires Persistence + Scraping + Workers
|
||||||
|
```
|
||||||
|
|
||||||
|
These are already called in `App.xaml.cs`. Phase 6 needs no changes to DI setup.
|
||||||
|
|
||||||
|
#### New config keys added in Phase 4
|
||||||
|
|
||||||
|
```json
|
||||||
|
"Workers": {
|
||||||
|
"UpcomingScheduleCron": "0 0 */6 * * *",
|
||||||
|
"LivePollerEnabled": true,
|
||||||
|
"UpcomingPollerEnabled": true,
|
||||||
|
"LivePollIntervalSeconds": 30,
|
||||||
|
"ResultsPollIntervalSeconds": 300,
|
||||||
|
"ResultsPollerEnabled": false
|
||||||
|
},
|
||||||
|
"Sports": {
|
||||||
|
"Basketball": { "QuarterMode": false }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Phase 3 TODO resolved
|
||||||
|
|
||||||
|
`ScrapingModule` now binds `Sports:Basketball:QuarterMode` from config and passes
|
||||||
|
it to the `PeriodScopeMapper` constructor. The TODO comment is removed.
|
||||||
|
|
||||||
|
#### Tests added
|
||||||
|
|
||||||
|
- `Marathon.Application.Tests`: 14 new tests (1 placeholder → 15 total) covering all 4 use cases.
|
||||||
|
- `Marathon.Infrastructure.Tests`: 3 new worker tests (77 → 80 total).
|
||||||
|
- Total suite: 185 → **202 passing**.
|
||||||
@@ -0,0 +1,263 @@
|
|||||||
|
# Phase 5: Blazor Hybrid Host + Theme + Localization
|
||||||
|
|
||||||
|
**Status:** ✅ Done
|
||||||
|
**Parent plan:** [PLAN.md](./PLAN.md)
|
||||||
|
**Domain:** frontend
|
||||||
|
**Implementer:** Opus + frontend-design skill
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
|
||||||
|
Create the WPF + BlazorWebView host that loads `Marathon.UI` (Razor Class Library),
|
||||||
|
establish the design system / theme using MudBlazor, set up bilingual (RU/EN)
|
||||||
|
localization end-to-end, and wire up DI to compose Application + Infrastructure layers.
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
|
||||||
|
- [x] In `src/Marathon.Hosts.WpfBlazor/Marathon.Hosts.WpfBlazor.csproj`:
|
||||||
|
- Set `<UseWPF>true</UseWPF>`, `<UseWindowsForms>false</UseWindowsForms>`
|
||||||
|
- SDK: `Microsoft.NET.Sdk.Razor` (so Razor + WPF interop works)
|
||||||
|
- Add packages:
|
||||||
|
- `Microsoft.AspNetCore.Components.WebView.Wpf`
|
||||||
|
- `MudBlazor`
|
||||||
|
- `Microsoft.Extensions.Hosting`
|
||||||
|
- `Serilog.Extensions.Hosting`
|
||||||
|
- `Serilog.Sinks.File`
|
||||||
|
- `Serilog.Sinks.Console`
|
||||||
|
- [x] In `src/Marathon.UI/Marathon.UI.csproj`:
|
||||||
|
- SDK: `Microsoft.NET.Sdk.Razor`
|
||||||
|
- `<TargetFramework>net8.0</TargetFramework>` with WebView for Razor Components
|
||||||
|
- Add `MudBlazor` (so components in this RCL can use MudBlazor)
|
||||||
|
- [x] Create `Marathon.UI/_Imports.razor` with namespace and component imports
|
||||||
|
(Microsoft.AspNetCore.Components.*, MudBlazor, project namespaces).
|
||||||
|
- [x] Create `Marathon.UI/wwwroot/index.html` (Blazor host HTML for the WebView).
|
||||||
|
- [x] Create `Marathon.UI/MainLayout.razor` with MudBlazor `MudLayout` + `MudAppBar` +
|
||||||
|
`MudDrawer` navigation. Include locale switcher (RU/EN) in the AppBar.
|
||||||
|
- [x] Create `Marathon.UI/Pages/Home.razor` placeholder dashboard.
|
||||||
|
- [x] Create `Marathon.UI/Pages/Settings.razor` — bound to all `appsettings.json`
|
||||||
|
options (ScrapingOptions, WorkerOptions, StorageOptions, AnomalyOptions,
|
||||||
|
LocalizationOptions). Live save via `IOptionsMonitor` + writing back to
|
||||||
|
`appsettings.Local.json`.
|
||||||
|
- [x] Establish theme tokens in `Marathon.UI/Theme/MarathonTheme.cs` — distinctive
|
||||||
|
palette per frontend-design guidance, NOT generic AI-default. Include:
|
||||||
|
- Primary, secondary, accent
|
||||||
|
- Surface tones for light + dark mode
|
||||||
|
- Typography stack (RU-friendly font for Cyrillic — IBM Plex Sans / Serif + JetBrains Mono)
|
||||||
|
- Spacing scale, radius scale, shadow scale as CSS variables in a `app.css`
|
||||||
|
- [x] Wire MudBlazor theme via `MudThemeProvider` in `MainLayout.razor`.
|
||||||
|
- [x] Localization:
|
||||||
|
- Add `Microsoft.Extensions.Localization` to `Marathon.UI`
|
||||||
|
- Create `Marathon.UI/Resources/SharedResource.cs` (marker class for `IStringLocalizer`)
|
||||||
|
- Add `Marathon.UI/Resources/SharedResource.ru.resx` and `SharedResource.en.resx`
|
||||||
|
with all UI strings used in this phase + placeholders for later phases
|
||||||
|
- Configure supported cultures in host: `ru-RU`, `en-US`
|
||||||
|
- Locale switcher persists choice to `appsettings.Local.json` and reloads UI
|
||||||
|
- [x] In `src/Marathon.Hosts.WpfBlazor/MainWindow.xaml`:
|
||||||
|
- Single `BlazorWebView` filling the window
|
||||||
|
- `HostPage="wwwroot/index.html"`
|
||||||
|
- `RootComponents` add `<RootComponent Selector="#app" ComponentType="{x:Type ui:App}" />`
|
||||||
|
(uses `App.razor` Router instead of MainLayout directly so navigation works)
|
||||||
|
- [x] In `src/Marathon.Hosts.WpfBlazor/App.xaml.cs`:
|
||||||
|
- Build `IHost` via `Host.CreateApplicationBuilder()`
|
||||||
|
- Call `services.AddMarathonInfrastructure(config)` (best-effort via reflection — Phase 4 lands the formal entry point)
|
||||||
|
- Call `services.AddMarathonApplication(config)` (best-effort, same)
|
||||||
|
- Call `services.AddWpfBlazorWebView()`
|
||||||
|
- Add MudBlazor: `services.AddMudServices()`
|
||||||
|
- Configure Serilog (rolling file at `./logs/marathon-.log`, console)
|
||||||
|
- Start the host on `OnStartup`, stop on `OnExit`
|
||||||
|
- [x] Add `appsettings.json` to `Marathon.Hosts.WpfBlazor/` with all sections.
|
||||||
|
Add `appsettings.Development.json` template.
|
||||||
|
- [x] Tests in `Marathon.UI.Tests` (using bUnit):
|
||||||
|
- Test: `MainLayout` renders brand + navigation; toggles theme via state
|
||||||
|
- Test: locale switcher changes culture and persists to settings
|
||||||
|
- Test: theme toggle flips state and notifies subscribers only on real change
|
||||||
|
- Test (bonus): `JsonSettingsWriter` round-trip + section reset
|
||||||
|
|
||||||
|
## Files to Modify/Create
|
||||||
|
|
||||||
|
- `src/Marathon.UI/_Imports.razor`
|
||||||
|
- `src/Marathon.UI/App.razor`
|
||||||
|
- `src/Marathon.UI/MainLayout.razor`
|
||||||
|
- `src/Marathon.UI/Pages/Home.razor`, `Pages/Settings.razor`, `Pages/PreMatch.razor`,
|
||||||
|
`Pages/Live.razor`, `Pages/Anomalies.razor`, `Pages/Results.razor`, `Pages/Placeholders.razor`
|
||||||
|
- `src/Marathon.UI/Theme/MarathonTheme.cs`, `Theme/Tokens.cs`
|
||||||
|
- `src/Marathon.UI/wwwroot/index.html`, `wwwroot/app.css`
|
||||||
|
- `src/Marathon.UI/Resources/SharedResource.{cs,ru.resx,en.resx}`
|
||||||
|
- `src/Marathon.UI/Components/LocaleSwitcher.razor`, `ThemeToggle.razor`,
|
||||||
|
`AppBrand.razor`, `NavBody.razor`, `StatCard.razor`, `PipelineStep.razor`,
|
||||||
|
`Field.razor`, `SectionFooter.razor`
|
||||||
|
- `src/Marathon.UI/Services/UiServicesExtensions.cs`, `ThemeState.cs`,
|
||||||
|
`LocaleState.cs`, `LocalizationOptions.cs`, `WorkerOptions.cs`,
|
||||||
|
`AnomalyOptions.cs`, `ScrapingSettingsForm.cs`,
|
||||||
|
`ISettingsWriter.cs`, `JsonSettingsWriter.cs`
|
||||||
|
- `src/Marathon.Hosts.WpfBlazor/App.xaml`, `App.xaml.cs`
|
||||||
|
- `src/Marathon.Hosts.WpfBlazor/MainWindow.xaml`, `MainWindow.xaml.cs`
|
||||||
|
- `src/Marathon.Hosts.WpfBlazor/appsettings.json`, `appsettings.Development.json`
|
||||||
|
- `tests/Marathon.UI.Tests/MainLayoutTests.cs`, `LocaleSwitcherTests.cs`,
|
||||||
|
`ThemeToggleTests.cs`, `JsonSettingsWriterTests.cs`,
|
||||||
|
`Support/MarathonTestContext.cs`, `Support/TestSettingsWriter.cs`,
|
||||||
|
`Support/TestLocalizer.cs`
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [x] Host project compiles (Big Bang smoke check). All Phase-5-owned projects build clean.
|
||||||
|
- [x] `Marathon.UI` is a clean RCL — references only Domain + Application, no
|
||||||
|
WPF/BlazorWebView. Verified by `dotnet build src/Marathon.UI/Marathon.UI.csproj`.
|
||||||
|
- [x] Theme is distinct: editorial-quant aesthetic. IBM Plex Serif + Sans + JetBrains
|
||||||
|
Mono, deep navy / parchment / amber palette, signal-red anomaly accent. No Inter,
|
||||||
|
no purple gradients.
|
||||||
|
- [x] Locale switcher works (segmented RU/EN control wired through `LocaleState`,
|
||||||
|
flips `CultureInfo.CurrentUICulture`, persists to `appsettings.Local.json`).
|
||||||
|
- [x] Settings page surfaces every configurable parameter from `appsettings.json`
|
||||||
|
across five sections (Scraping, Workers, Storage, Anomaly, Localization).
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- This phase ran parallel with Phases 2 and 3 per the plan.
|
||||||
|
- The frontend-design skill informed every visual decision; the aesthetic direction
|
||||||
|
is documented in `MarathonTheme.cs` header and the Handoff section below.
|
||||||
|
- Cyrillic-friendly fonts: IBM Plex Serif/Sans + JetBrains Mono are loaded from
|
||||||
|
Google Fonts in `wwwroot/index.html` with `display=swap`.
|
||||||
|
- For BlazorWebView in WPF, the project SDK is `Microsoft.NET.Sdk.Razor` and
|
||||||
|
OutputType is `WinExe` with WPF enabled.
|
||||||
|
|
||||||
|
## Review Checklist
|
||||||
|
|
||||||
|
- [x] Compiles (Marathon.UI, Marathon.UI.Tests, Marathon.Hosts.WpfBlazor all green)
|
||||||
|
- [x] `Marathon.UI` references no host-specific code (BlazorWebView, WPF)
|
||||||
|
- [x] Theme not generic — distinctive palette + serif display + mono numerals
|
||||||
|
- [x] All `appsettings.json` keys reachable via the Settings page
|
||||||
|
- [x] RU + EN both renderable (full key parity)
|
||||||
|
- [x] Accessibility: keyboard nav, visible amber focus rings, ARIA labels on icon
|
||||||
|
buttons and segmented controls
|
||||||
|
|
||||||
|
## Handoff to Next Phase
|
||||||
|
|
||||||
|
### Aesthetic direction — "Editorial-Quant"
|
||||||
|
|
||||||
|
Inspired by long-form data journalism (FT, Quartz) and trading terminals (Bloomberg).
|
||||||
|
Confident, dense, serif-led on display surfaces. Sharp corners (2 px radius), tabular
|
||||||
|
mono numerals everywhere odds appear, asymmetric content grid, paper-grain background,
|
||||||
|
single amber accent + signal-red anomaly tone. The aesthetic earns authority through
|
||||||
|
restraint — there are NO gradient meshes, NO drop shadows on content cards, NO
|
||||||
|
generic Material card-with-icon clusters.
|
||||||
|
|
||||||
|
### Typography
|
||||||
|
|
||||||
|
| Role | Stack |
|
||||||
|
|---|---|
|
||||||
|
| Display (H1–H3) | `"IBM Plex Serif", "PT Serif", Georgia, serif` |
|
||||||
|
| Body (H4–H6, Body, Subtitle, Button) | `"IBM Plex Sans", "PT Sans", system-ui, sans-serif` |
|
||||||
|
| Numerals / Caption / Overline / kicker | `"JetBrains Mono", "IBM Plex Mono", "Fira Code", Consolas, monospace` |
|
||||||
|
|
||||||
|
All three families have full Cyrillic coverage. Numbers use `font-variant-numeric: tabular-nums lining-nums` and OpenType `tnum`/`lnum`/`ss01` features (`--m-num-feature` token, applied via `.m-num`, `.m-mono`, all Mud table cells, and any element with `data-numeric`).
|
||||||
|
|
||||||
|
### Theme tokens (CSS variables in `app.css`, mirrored in `Theme/Tokens.cs`)
|
||||||
|
|
||||||
|
| Token | Light | Dark | Purpose |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `--m-c-ink` | `#0f172a` | `#f5f5f4` | Primary text / ink |
|
||||||
|
| `--m-c-paper` | `#fafaf7` | `#1c1917` | Surface |
|
||||||
|
| `--m-c-paper-2` | `#f5f4ef` | `#0c0a09` | Background |
|
||||||
|
| `--m-c-rule` | `#e7e5e4` | `#292524` | Dividers, borders |
|
||||||
|
| `--m-c-accent` | `#d97706` | `#fbbf24` | Amber accent (kickers, focus rings, hover) |
|
||||||
|
| `--m-c-anomaly` | `#dc2626` | `#f87171` | Load-bearing for Phase 7 anomaly UI |
|
||||||
|
| `--m-c-positive` | `#15803d` | `#4ade80` | Confirmations, OK status |
|
||||||
|
| `--m-c-info` | `#0369a1` | `#38bdf8` | Informational accents |
|
||||||
|
|
||||||
|
Spacing scale: `--m-space-1` … `--m-space-9` (4 → 96 px).
|
||||||
|
Radius scale: `--m-radius-sharp` (0) → `--m-radius-lg` (10 px) — defaults to `--m-radius-xs` (2 px).
|
||||||
|
Shadow scale: defined inline in `MarathonTheme.cs::MarathonShadows`. Use sparingly; the language is borders, not shadows.
|
||||||
|
|
||||||
|
The MudBlazor `MudTheme` is built in `Marathon.UI.Theme.MarathonTheme.Build()`. Phase 6 should consume the Mud palette via `Color.Primary`, `Color.Tertiary` (= amber accent), `Color.Error` (= anomaly signal). Do NOT hard-code hexes outside `MarathonTheme.cs` and `app.css`.
|
||||||
|
|
||||||
|
### Component primitives available to Phase 6+
|
||||||
|
|
||||||
|
| Component | Path | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `<AppBrand />` | `Components/AppBrand.razor` | Wordmark + dateline lockup for the AppBar |
|
||||||
|
| `<NavBody />` | `Components/NavBody.razor` | Drawer navigation (dark surface, amber active state) |
|
||||||
|
| `<LocaleSwitcher />` | `Components/LocaleSwitcher.razor` | RU/EN segmented control |
|
||||||
|
| `<ThemeToggle />` | `Components/ThemeToggle.razor` | Light/dark icon button |
|
||||||
|
| `<StatCard Label Value Delta Anomaly />` | `Components/StatCard.razor` | Editorial stat block (kicker + mono value + delta) |
|
||||||
|
| `<PipelineStep Index Label Status />` | `Components/PipelineStep.razor` | Numbered status row (`ok`/`warn`/`error`/`idle`) |
|
||||||
|
| `<Field Label Hint>...` | `Components/Field.razor` | 240 px label column + control column with hint text |
|
||||||
|
| `<SectionFooter OnSave />` | `Components/SectionFooter.razor` | Right-aligned save bar inside `.m-section` |
|
||||||
|
|
||||||
|
CSS primitives (raw classes in `app.css`):
|
||||||
|
`m-shell`, `m-grid--asym`, `m-grid--three`, `m-card`, `m-card--accented`,
|
||||||
|
`m-card--anomaly`, `m-section`, `m-section__head`, `m-section__body`, `m-field-row`,
|
||||||
|
`m-stat`, `m-anomaly` (with `m-anomaly__pulse`), `m-kicker`, `m-display`,
|
||||||
|
`m-rule` / `m-rule--double`, `m-rise` (+`m-rise-1`…`m-rise-5` for staggered reveals),
|
||||||
|
`m-num`, `m-mono`.
|
||||||
|
|
||||||
|
### Localization key naming convention
|
||||||
|
|
||||||
|
Dot-segmented `<Surface>.<Element>` (sub-segmented as needed):
|
||||||
|
|
||||||
|
- `App.*` — application chrome (`App.Title`, `App.BrandMark`, `App.Dateline`, `App.Tagline`)
|
||||||
|
- `Nav.*` — primary navigation labels and section headings (`Nav.Section.Analysis`, `Nav.Dashboard`, `Nav.PreMatch`, `Nav.Live`, `Nav.Anomalies`, `Nav.Results`, `Nav.Settings`, `Nav.Section.System`)
|
||||||
|
- `Home.*` — dashboard surfaces (`Home.Kicker`, `Home.Title`, `Home.Lede`, `Home.Stat.*`, `Home.Section.*`, `Home.Pipeline.Step1..4`, `Home.Empty`)
|
||||||
|
- `Settings.*` — settings page; further nested by section (`Settings.Section.Scraping`, `Settings.Scraping.<Field>`, `Settings.Scraping.<Field>.Hint`, etc.)
|
||||||
|
- `Locale.*` — locale switcher labels (`Locale.Russian`, `Locale.English`, `Locale.Tooltip.Switch`)
|
||||||
|
- `Theme.*` — theme toggle (`Theme.Toggle.Light`, `Theme.Toggle.Dark`)
|
||||||
|
- `Common.*` — shared verbs/nouns (`Common.Save`, `Common.Cancel`, `Common.Reset`, `Common.Loading`, `Common.Empty`, `Common.Yes`, `Common.No`)
|
||||||
|
- `Anomaly.*` — anomaly feed placeholders (`Anomaly.Live`, `Anomaly.Kind.SuspensionFlip`, `Anomaly.Score`)
|
||||||
|
|
||||||
|
Add new keys to BOTH `SharedResource.ru.resx` AND `SharedResource.en.resx`. Phase 6 should follow the same scheme; e.g. event browsing keys go under `PreMatch.*`, `Live.*` matching the route names in PLAN.
|
||||||
|
|
||||||
|
### Settings reload mechanism
|
||||||
|
|
||||||
|
1. Host registers `appsettings.json` + `appsettings.{Env}.json` + `appsettings.Local.json` (gitignored, optional, `reloadOnChange: true`) + `MARATHON_*` env vars in `App.xaml.cs::OnStartup`.
|
||||||
|
2. `Marathon.UI.Services.UiServicesExtensions.AddMarathonUi(IConfiguration, settingsLocalPath)` binds:
|
||||||
|
- `LocalizationOptions` (`Localization:*`)
|
||||||
|
- `WorkerOptions` (`Workers:*`) — drives Phase 4 pollers
|
||||||
|
- `AnomalyOptions` (`Anomaly:*`) — drives Phase 7 detector
|
||||||
|
- `StorageOptions` (`Storage:*`) — Phase 2's options class, lives in Marathon.Application.Storage
|
||||||
|
- `ScrapingSettingsForm` (`Scraping:*`) — UI-side mirror of `Marathon.Infrastructure.Configuration.ScrapingOptions` so the RCL stays host-agnostic. Phase 4 may bind the same JSON section to both forms.
|
||||||
|
3. `JsonSettingsWriter` writes user edits as a single section into `appsettings.Local.json` via atomic temp-file rename. Other sections in that file are preserved (round-trip tested).
|
||||||
|
4. Components inject `IOptionsMonitor<T>` and re-read on demand. The Settings page snapshots a clone of `CurrentValue` into local edit state, then writes the whole section.
|
||||||
|
5. `LocaleState` and `ThemeState` are singletons with `Action OnChange` events; `MainLayout.razor`, `LocaleSwitcher.razor`, and `ThemeToggle.razor` subscribe and call `StateHasChanged`. Setting the locale also flips `CultureInfo.DefaultThreadCurrent{,UI}Culture` so newly created `IStringLocalizer<T>` instances pick up the new culture.
|
||||||
|
|
||||||
|
### `Marathon.UI` portability invariant — verified
|
||||||
|
|
||||||
|
`Marathon.UI.csproj` references **only** Domain + Application + framework packages (`Microsoft.AspNetCore.Components.Web`, `MudBlazor`, `Microsoft.Extensions.Localization`, `Microsoft.Extensions.Options*`, `Microsoft.Extensions.Configuration*`, `Microsoft.Extensions.Logging.Abstractions`). It does NOT reference Infrastructure or any WPF/WebView assembly. A future ASP.NET Core Blazor Server host can register `AddMarathonUi(...)` and mount `<App />` at `#app` with no UI changes.
|
||||||
|
|
||||||
|
The `ScrapingSettingsForm` mirror in `Marathon.UI.Services` is intentional — keeping `Infrastructure.Configuration.ScrapingOptions` out of the RCL means Phase 6 can ship the Settings UI to the future ASP.NET Core host without dragging in EF Core, AngleSharp, or Polly.
|
||||||
|
|
||||||
|
### What Phase 4 needs to know
|
||||||
|
|
||||||
|
- **`UiServicesExtensions.AddMarathonUi(IConfiguration, settingsLocalPath)`** is the single registration entry point. The host already calls it.
|
||||||
|
- **Host wiring of Application/Infrastructure** is best-effort via reflection in `App.xaml.cs::TryAddApplicationAndInfrastructure`. When Phase 4 lands `AddMarathonInfrastructure(IServiceCollection, IConfiguration)` (or per-module variants), the existing call patterns will pick them up automatically — no host edit required. Replace the reflection with a direct call when Phase 4 commits.
|
||||||
|
- **`WorkerOptions` lives in `Marathon.UI.Services`** (`WorkerOptions.SectionName == "Workers"`). Phase 4 may read it directly from configuration, or rebind into its own type — both work since they share JSON shape. The Settings page already exposes its three keys (`UpcomingScheduleCron`, `LivePollerEnabled`, `UpcomingPollerEnabled`).
|
||||||
|
- **`AnomalyOptions`** likewise (`Anomaly:*`).
|
||||||
|
- **`appsettings.Local.json` is the "user-facing" override file**. Phase 4 services should depend on `IOptionsMonitor<T>` so they react to user edits within seconds (file watcher is enabled on all three JSON sources).
|
||||||
|
|
||||||
|
### What Phase 6 needs to know
|
||||||
|
|
||||||
|
- **Use the existing primitives.** `<StatCard>`, `<Field>`, `<PipelineStep>`, the `m-card` / `m-section` / `m-grid--asym` / `m-grid--three` / `m-shell` classes form the layout language. Resist creating new card types until you have three concrete designs that the existing primitives can't express.
|
||||||
|
- **Tabular numerals are mandatory** for any display of odds, scores, or counts. Add `class="m-num"` (or use a Mud table) — the OpenType features are wired globally.
|
||||||
|
- **Anomaly visual language** must hang off `--m-c-anomaly` / `Color.Error` / `.m-anomaly` / `.m-anomaly__pulse`. Phase 7 inherits these.
|
||||||
|
- **Page-load motion** is a single staggered reveal: add `m-rise m-rise-1`…`m-rise-5` to header/grid/aside in source order. Respects `prefers-reduced-motion`.
|
||||||
|
- **Routes and nav labels** are pre-wired: `/`, `/prematch`, `/live`, `/anomalies`, `/results`, `/settings`. Phase 6/7/8 just replace the `Placeholders` body with real content — the nav drawer, breadcrumbs, AppBar, and locale switcher are already in `MainLayout`.
|
||||||
|
|
||||||
|
### Deviations / known gaps
|
||||||
|
|
||||||
|
1. **Settings persistence reload.** `IOptionsMonitor<T>` triggers when the JSON
|
||||||
|
file changes. The Settings page snapshots a copy of `CurrentValue` into local
|
||||||
|
state on initialisation, so a save-then-rebind cycle requires the user to
|
||||||
|
navigate away and back (or for Phase 6 to hook `OnChange` and refresh local
|
||||||
|
state). Acceptable for Phase 5; Phase 6 may add the listener.
|
||||||
|
2. **`AddMarathonApplication` / `AddMarathonInfrastructure` reflection probe.**
|
||||||
|
Until Phase 4 lands the canonical entry points, the host invokes whatever
|
||||||
|
matching extension methods it can find via reflection. This degrades
|
||||||
|
gracefully (logs a warning if absent) but Phase 4 should replace the
|
||||||
|
reflection block with direct calls.
|
||||||
|
3. **bUnit version** auto-resolved from 1.35.6 → 1.36.0 (NU1603). Updated
|
||||||
|
`Directory.Packages.props` accordingly.
|
||||||
|
4. **Settings dialog confirmation** uses `Dialogs.ShowMessageBox(...)`. The
|
||||||
|
`DialogParameters` block is currently dead code — left in place because
|
||||||
|
future dialogs may want to use a custom layout instead of the message box.
|
||||||
|
5. **Pre-existing build failures outside Phase 5 scope:**
|
||||||
|
`tests/Marathon.Infrastructure.Tests` references `internal` repository
|
||||||
|
classes (Phase 2 scope). Marathon.UI / Marathon.UI.Tests / Marathon.Hosts.WpfBlazor
|
||||||
|
build clean. All 11 bUnit tests pass.
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
# Phase 6: Event Browsing UI
|
||||||
|
|
||||||
|
**Status:** ✅ Done
|
||||||
|
**Parent plan:** [PLAN.md](./PLAN.md)
|
||||||
|
**Domain:** frontend
|
||||||
|
**Implementer:** Opus + frontend-design skill
|
||||||
|
**Depends on:** Phase 4 (use cases) + Phase 5 (UI shell)
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
|
||||||
|
Build the user-facing browsing experience: pre-match list, live list (auto-refreshing),
|
||||||
|
event-detail view with odds-over-time chart, plus an Excel export trigger. Visual
|
||||||
|
quality must match the design system established in Phase 5 — distinctive, accessible,
|
||||||
|
information-dense without being cluttered.
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
|
||||||
|
- [x] Create `Marathon.UI/Pages/PreMatch.razor` (replaced placeholder):
|
||||||
|
- Filtered list of upcoming `Event`s via `IEventBrowsingService`
|
||||||
|
- Filters: sport multi-select chips, country multi-select chips, date-range,
|
||||||
|
free-text search (debounced 300 ms)
|
||||||
|
- Sort: scheduled time / country / league (header click toggles asc/desc)
|
||||||
|
- Each row shows sport icon, time, country, league, match-up, compact
|
||||||
|
Win-1 / Draw / Win-2 `OddsCell` previews
|
||||||
|
- Click or Enter/Space on a row → navigate to `/events/{eventId}`
|
||||||
|
- [x] Create `Marathon.UI/Pages/Live.razor` (replaced placeholder):
|
||||||
|
- Same shell (`Pages/Shared/EventListShell.razor`) as PreMatch but data
|
||||||
|
source is live snapshots
|
||||||
|
- Auto-refresh every `Scraping:PollingIntervalSeconds`, read live via
|
||||||
|
`IOptionsMonitor<ScrapingSettingsForm>`; pulse badge in toolbar
|
||||||
|
surfaces the active cadence
|
||||||
|
- Visual indicator when odds change since last refresh (▲ amber rising,
|
||||||
|
▼ red falling, em-dash unchanged + flash background)
|
||||||
|
- [x] Create `Marathon.UI/Pages/Events/Detail.razor`:
|
||||||
|
- Event header: sport kicker, sides 1 & 2 lockup, scheduled time + MSK,
|
||||||
|
Win-1 / Draw / Win-2 odds cluster, Export button
|
||||||
|
- Tabs: "Match" + dynamic "Period 1..N" generated from snapshot data
|
||||||
|
- Per scope: Type / Side / Threshold / Rate table for all bets
|
||||||
|
- Charts panel: `OddsTimeline` wraps Plotly.Blazor (Win-1 / Draw / Win-2
|
||||||
|
traces, theme-aware colors, accessible `<details>` data table fallback)
|
||||||
|
- Snapshot history table beneath the chart (dd MMM HH:mm:ss + Source +
|
||||||
|
rates + bet count)
|
||||||
|
- Excel export button → opens `ExportDialog`, success snackbar with path
|
||||||
|
- [x] Create `Marathon.UI/Components/SportIcon.razor` — inline SVG icons
|
||||||
|
per sport (basketball=6, football=11, tennis=22723, hockey=43658, generic
|
||||||
|
fallback)
|
||||||
|
- [x] Create `Marathon.UI/Components/OddsCell.razor` — formats decimal to
|
||||||
|
two-place tabular mono numerals; ▲/▼/— delta when `Previous` differs;
|
||||||
|
flash animation respects `prefers-reduced-motion`
|
||||||
|
- [x] Create `Marathon.UI/Components/OddsTimeline.razor` — wraps Plotly.Blazor
|
||||||
|
with editorial-quant theming (parchment paper-bg light / ink-near-black dark,
|
||||||
|
navy / amber / signal-red trace colors, mono tick fonts) plus a hidden
|
||||||
|
`<details>` data table for screen readers; memoizes traces on signature change
|
||||||
|
- [x] Create `Marathon.UI/Components/ExportDialog.razor` — modal: From/To
|
||||||
|
date pickers + `ExportKind` radio group + Export button → calls
|
||||||
|
`ExportToExcelUseCase`. Esc cancels, Enter submits. Shows error inline
|
||||||
|
when validation fails or the use case throws.
|
||||||
|
- [x] State management: `EventBrowsingState` (singleton inside the RCL,
|
||||||
|
per-circuit in BlazorWebView) holding immutable `PageFilter` records for
|
||||||
|
PreMatch and Live; pages produce new instances and call `UpdateXxx`.
|
||||||
|
`OnChange` event for subscribers.
|
||||||
|
- [x] Add `Plotly.Blazor` 5.4.1 to `Directory.Packages.props` and
|
||||||
|
`Marathon.UI.csproj`
|
||||||
|
- [x] Append all new strings to `SharedResource.ru.resx` + `SharedResource.en.resx`
|
||||||
|
using the Phase 5 dot-segmented convention (`PreMatch.*`, `Live.*`,
|
||||||
|
`Detail.*`, `Detail.Chart.*`, `Detail.History.*`, `Export.*`, `Sport.*`)
|
||||||
|
- [x] Performance:
|
||||||
|
- Filter inputs debounced 300 ms via `CancellationTokenSource` rerun guard
|
||||||
|
- Chart data memoized via `_signature` (rebuild only on count / first / last
|
||||||
|
timestamp / first / last rate change)
|
||||||
|
- Single in-memory list per page; small enough to skip virtualization at
|
||||||
|
Phase 6 scale; `<table>` is overflow-x scrollable
|
||||||
|
- [x] Accessibility:
|
||||||
|
- Tables use `<thead>` / `<th scope="col">`; sortable headers expose ▲/▼ glyphs
|
||||||
|
- Rows are `tabindex="0"` and respond to Enter/Space via `@onkeydown`
|
||||||
|
- Visible amber focus rings (inherited from Phase 5 `:focus-visible` rule)
|
||||||
|
- `OddsTimeline` exposes a hidden but expandable `<details>`/`<summary>`
|
||||||
|
parallel data table for screen readers
|
||||||
|
- Toolbar has `role="toolbar" aria-label`, chips have `aria-pressed`
|
||||||
|
|
||||||
|
## Files to Modify/Create
|
||||||
|
|
||||||
|
- `src/Marathon.UI/Pages/PreMatch/EventsList.razor`
|
||||||
|
- `src/Marathon.UI/Pages/Live/LiveList.razor`
|
||||||
|
- `src/Marathon.UI/Pages/Events/Detail.razor`
|
||||||
|
- `src/Marathon.UI/Components/SportIcon.razor`, `OddsCell.razor`,
|
||||||
|
`OddsTimeline.razor`, `ExportDialog.razor`
|
||||||
|
- `src/Marathon.UI/Services/EventBrowsingState.cs`
|
||||||
|
- `src/Marathon.UI/Resources/SharedResource.{ru,en}.resx` — append new keys
|
||||||
|
- `src/Marathon.UI/Components/_Imports.razor` — register Plotly.Blazor
|
||||||
|
- Tests: `tests/Marathon.UI.Tests/Pages/**`, `Components/**`
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- Compiles (Big Bang).
|
||||||
|
- Live list visually conveys odds changes between refreshes.
|
||||||
|
- Detail page chart renders 3 traces (Win-1/Draw/Win-2) with smooth interpolation
|
||||||
|
and clear tooltip showing exact rate at any point in time.
|
||||||
|
- Excel export from the dialog reaches `ExportToExcelUseCase` correctly.
|
||||||
|
- Both RU and EN render correctly across all new UI.
|
||||||
|
- Distinctive visual identity — implementer should follow frontend-design guidance.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- The frontend-design skill content is provided to the agent in `FRONTEND_DESIGN_SKILL`.
|
||||||
|
Apply its principles — typography, color, motion, spatial composition.
|
||||||
|
- Use Plotly.Blazor for charts (smooth, themable, professional look).
|
||||||
|
- Keep components small (<200 lines) and composable.
|
||||||
|
- Big Bang: compile-only smoke check.
|
||||||
|
|
||||||
|
## Review Checklist
|
||||||
|
|
||||||
|
- [x] Compiles (full solution clean — 0 errors, 0 warnings)
|
||||||
|
- [x] No mutation of domain types in UI components — pages bind to view-model
|
||||||
|
records (`EventListItem`, `EventDetail`, `EventScopeBoard`, `BetRow`,
|
||||||
|
`OddsTimelinePoint`, `SnapshotHistoryEntry`) shaped in
|
||||||
|
`EventBrowsingService`
|
||||||
|
- [x] Filters/sort persist within page session via `EventBrowsingState`
|
||||||
|
- [x] Chart accessible — `<details>` data table fallback in `OddsTimeline`
|
||||||
|
- [x] All new strings localized in RU + EN with full key parity
|
||||||
|
- [x] Visual consistency with Phase 5 theme tokens — every color comes from
|
||||||
|
`--m-c-*` CSS vars or the Mud palette; no new hex literals
|
||||||
|
|
||||||
|
## Test results
|
||||||
|
|
||||||
|
- `dotnet build Marathon.sln`: ✅ 0 errors / 0 warnings
|
||||||
|
- `dotnet test Marathon.sln`: ✅ 228 passed / 0 failed
|
||||||
|
(Domain 96 + Application 15 + Infrastructure 80 + UI 37; baseline was 202,
|
||||||
|
+26 new bUnit tests)
|
||||||
|
|
||||||
|
## Handoff to Next Phase
|
||||||
|
|
||||||
|
### Component patterns Phase 7 (Anomaly UI) should reuse
|
||||||
|
|
||||||
|
| Pattern | File | Rationale |
|
||||||
|
|---|---|---|
|
||||||
|
| Section shell | `Pages/Shared/EventListShell.razor` | Header (kicker + display title + lede), `m-list-toolbar`, `m-list-table`. Anomaly feed should mimic the toolbar / chips / table cadence so the surfaces feel like a series. |
|
||||||
|
| Compact data table | `m-table` class block in `EventListShell.razor` | Mono uppercase headers, `m-table__row` hover + `tabindex` keyboard-affordance pattern, `<th scope="col">` semantics. |
|
||||||
|
| Editorial header | `Pages/Events/Detail.razor` `.m-detail-header` grid | Asymmetric 1.5fr/1fr lockup with kicker + display title + dateline on the left, summary card on the right. Ideal for an anomaly detail page. |
|
||||||
|
| Tab strip | `.m-detail-tabs` block in `Detail.razor` | Sharp underline + amber accent active state. Anomaly detail can reuse for "Timeline" / "Evidence" / "Reasoning". |
|
||||||
|
| Asymmetric content grid | `.m-detail-grid` (1.2fr / 1fr) | Pair a primary content card with an aside summary. |
|
||||||
|
| Trend indicator | `Components/OddsCell.razor` | Anomaly UI's "movement at suspension" cell can drop in `OddsCell` directly; the `Previous` parameter accepts any prior value. |
|
||||||
|
| Sport branding | `Components/SportIcon.razor` | Single source of sport visual language. Add new sports here, not ad-hoc. |
|
||||||
|
| Modal pattern | `Components/ExportDialog.razor` | `MudDialog` + kicker title + grid form body + Cancel/Submit action row + inline `m-export-dialog__error` for validation errors. Anomaly UI may adopt the same shape for "Acknowledge" / "Mark false positive" dialogs. |
|
||||||
|
| Plotly wrapper | `Components/OddsTimeline.razor` | Editorial-quant chart theme (paper-bg, mono tick fonts, navy / amber / signal-red accents). Anomaly chart should reuse the layout factory (or call into `OddsTimeline` directly with `Points` from the suspension window). |
|
||||||
|
|
||||||
|
### State service patterns
|
||||||
|
|
||||||
|
| Service | Lifetime | Purpose | Consumption |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `EventBrowsingState` | Singleton (RCL) | Per-page `PageFilter` records (immutable, replaced via `UpdatePreMatch` / `UpdateLive`); fires `OnChange` only when the new value !equals the old one. | Pages inject + bind via `@inject EventBrowsingState`. |
|
||||||
|
| `IEventBrowsingService` → `EventBrowsingService` | Scoped | Repository facade returning view-model records (no EF graphs). Owns sort + in-memory filtering, latest-snapshot odds extraction, scope grouping. | Pages inject + call `ListUpcomingAsync`/`ListLiveAsync`/`GetDetailAsync`. |
|
||||||
|
|
||||||
|
Phase 7 should follow the same shape: an `AnomalyBrowsingState` singleton + an `IAnomalyBrowsingService` scoped facade that returns `AnomalyListItem` view-models with no `Anomaly` domain leakage.
|
||||||
|
|
||||||
|
### Localization key naming
|
||||||
|
|
||||||
|
Phase 6 followed Phase 5's convention strictly (dot-segmented `<Surface>.<Element>`):
|
||||||
|
|
||||||
|
- `PreMatch.*` — pre-match list page (`PreMatch.Title`, `PreMatch.Filter.From`,
|
||||||
|
`PreMatch.Column.Time`, `PreMatch.Footer.Events`, `PreMatch.Empty`)
|
||||||
|
- `Live.*` — live list page (`Live.Title`, `Live.AutoRefresh`, `Live.Lede`)
|
||||||
|
- `Detail.*` — event detail page (`Detail.Title`, `Detail.Tabs.Match`,
|
||||||
|
`Detail.Tabs.Period` with `{0}` placeholder, `Detail.BetType.*`,
|
||||||
|
`Detail.Side.*`, `Detail.Chart.*`, `Detail.Chart.AccessibleSummary`,
|
||||||
|
`Detail.History.Title`, `Detail.History.Source`, `Detail.History.Live`,
|
||||||
|
`Detail.History.PreMatch`)
|
||||||
|
- `Export.*` — export dialog (`Export.Title`, `Export.DateRange.From`,
|
||||||
|
`Export.Kind.PreMatch|Live|Combined`, `Export.Submit`, `Export.Cancel`,
|
||||||
|
`Export.Success` with `{0}` placeholder for path,
|
||||||
|
`Export.Error.MissingDates|InvalidRange|Failed`)
|
||||||
|
- `Sport.*` — sport display names (`Sport.Basketball`, `Sport.Football`,
|
||||||
|
`Sport.Tennis`, `Sport.Hockey`)
|
||||||
|
|
||||||
|
Phase 7 strings should slot under `Anomaly.*` (the `Anomaly.Live` /
|
||||||
|
`Anomaly.Kind.SuspensionFlip` / `Anomaly.Score` keys are already reserved
|
||||||
|
from Phase 5).
|
||||||
|
|
||||||
|
### Routing additions
|
||||||
|
|
||||||
|
- `/prematch` (existing — body replaced)
|
||||||
|
- `/live` (existing — body replaced)
|
||||||
|
- `/events/{EventCode}` (new) — accepts a URL-escaped `EventId.Value`
|
||||||
|
(numeric for marathonbet.by; allow non-numeric for forward compatibility)
|
||||||
|
|
||||||
|
Phase 7 should add `/anomalies/{eventId}` or `/anomalies/{anomalyId}` and link
|
||||||
|
to the matching detail page from the home dashboard's "Latest signals" feed.
|
||||||
|
|
||||||
|
### Theme + Plotly tokens
|
||||||
|
|
||||||
|
- Plotly traces use the same triplet as the rest of the app: navy `#0f172a`
|
||||||
|
for Win-1, amber `#d97706` for Draw, signal-red `#dc2626` for Win-2.
|
||||||
|
Phase 7 can reuse the same trace palette for "before suspension" / "during
|
||||||
|
suspension" / "after suspension" (with red as the alert tone — this is
|
||||||
|
load-bearing).
|
||||||
|
- Plotly.Blazor 5.4.1 is on the .NET 8 line; staying on this major avoids
|
||||||
|
the v7 breaking changes documented upstream. Phase 7's anomaly chart should
|
||||||
|
call into `OddsTimeline` if possible, only forking if it needs additional
|
||||||
|
axes or annotations (e.g. a vertical band for the suspension window).
|
||||||
|
|
||||||
|
### Verified invariants & gotchas
|
||||||
|
|
||||||
|
- `Marathon.UI` still references **only** Domain + Application + framework
|
||||||
|
packages. `Plotly.Blazor` was added; it's an MIT-licensed Razor wrapper
|
||||||
|
with no Infrastructure / Hosting deps, so the RCL stays host-agnostic.
|
||||||
|
- `DateRange` ambiguity: both `MudBlazor.DateRange` and
|
||||||
|
`Marathon.Application.Storage.DateRange` are visible inside Razor pages
|
||||||
|
that import both namespaces (via `_Imports.razor`). Use
|
||||||
|
`using AppDateRange = Marathon.Application.Storage.DateRange;` in any
|
||||||
|
file that calls the application's `DateRange`. Already applied in
|
||||||
|
`ExportDialog.razor` and `ExportDialogTests.cs`.
|
||||||
|
- Razor source generator does not accept C# 11 raw string literals
|
||||||
|
(`"""..."""`) inside `@code` blocks — the parser sees the leading `"""`
|
||||||
|
as the start of a normal string and never finds the close. Use
|
||||||
|
concatenated single-quoted attribute SVG strings instead (see
|
||||||
|
`SportIcon.razor`).
|
||||||
|
- `code` is reserved by the Razor source generator. Loop over a list with
|
||||||
|
any other identifier (`@foreach (var sportCode in ...)`).
|
||||||
|
- `Plotly.Blazor` exposes a `Plotly.Blazor.LayoutLib.Margin` that conflicts
|
||||||
|
with `MudBlazor.Margin`. Fully qualify the layout-side type as
|
||||||
|
`new Plotly.Blazor.LayoutLib.Margin {...}`.
|
||||||
|
|
||||||
|
### Test infrastructure delta (for Phase 7)
|
||||||
|
|
||||||
|
- `tests/Marathon.UI.Tests/Support/MarathonTestContext` now also registers
|
||||||
|
a `FakeEventBrowsingService` and `EventBrowsingState` singleton; Phase 7
|
||||||
|
tests can reuse both, or follow the same fake pattern for an
|
||||||
|
`IAnomalyBrowsingService`.
|
||||||
|
- `Support/TestData.cs` exposes `MoscowToday(int hour)`, `ListItem(...)`,
|
||||||
|
and `Detail(...)` factories; reuse for anomaly fixtures.
|
||||||
|
- `Support/TestOptionsMonitor<T>` wraps `IOptionsMonitor<T>` for tests that
|
||||||
|
need to drive options-change callbacks deterministically.
|
||||||
@@ -0,0 +1,319 @@
|
|||||||
|
# Phase 7: Anomaly Detection (Suspension + Flip)
|
||||||
|
|
||||||
|
**Status:** ✅ Done
|
||||||
|
**Parent plan:** [PLAN.md](./PLAN.md)
|
||||||
|
**Domain:** fullstack
|
||||||
|
**Implementer:** Sonnet (backend portion) + Opus (UI portion, with frontend-design)
|
||||||
|
**Depends on:** Phase 4 (snapshot pipeline) + Phase 6 (UI patterns)
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
|
||||||
|
Detect the **odds-flip anomaly** described in customer TZ §3: bookmaker freezes betting
|
||||||
|
on a live event, then re-opens with inverted underdog/favorite odds. Persist anomalies
|
||||||
|
and surface them in a dedicated UI feed page so the user can act on them.
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
|
||||||
|
### Backend (Sonnet) ✅ COMPLETE
|
||||||
|
|
||||||
|
- [x] Implement `Marathon.Domain/AnomalyDetection/AnomalyDetector.cs`:
|
||||||
|
- Pure domain logic — takes `IReadOnlyList<OddsSnapshot>` for an event, returns
|
||||||
|
`IReadOnlyList<Anomaly>`
|
||||||
|
- Detect suspension intervals: gaps between snapshots > `SuspensionGapSeconds`
|
||||||
|
(configurable)
|
||||||
|
- For each suspension, compute pre-suspension and post-suspension implied
|
||||||
|
probability vectors `(p1, pDraw, p2)` from Win-1/Draw/Win-2 rates
|
||||||
|
- Compute flip score: `max(|p_post[i] − p_pre[i]|)` across i ∈ {1, draw, 2}
|
||||||
|
- If flip score ≥ `OddsFlipThreshold` AND the favourite changed (argmax differs),
|
||||||
|
emit an `Anomaly(Kind=SuspensionFlip, Score, EvidenceJson)` where `EvidenceJson`
|
||||||
|
contains the snapshots bracketing the suspension
|
||||||
|
- [x] Add `AnomalyOptions` POCO bound to `Anomaly:*` (in `Marathon.Application/Configuration/`):
|
||||||
|
```csharp
|
||||||
|
public sealed class AnomalyOptions {
|
||||||
|
public int SuspensionGapSeconds { get; init; } = 60;
|
||||||
|
public decimal OddsFlipThreshold { get; init; } = 0.30m;
|
||||||
|
public int MinSnapshotCount { get; init; } = 3;
|
||||||
|
public int DetectionIntervalSeconds { get; init; } = 60;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- [x] Implement `DetectAnomaliesUseCase` in `Marathon.Application/UseCases/`:
|
||||||
|
- Iterate over all events and load snapshots from last 24 h
|
||||||
|
- Invoke `AnomalyDetector` per event
|
||||||
|
- Persist new anomalies via `IAnomalyRepository` with dedup logic
|
||||||
|
- [x] Implement `AnomalyDetectionPoller : BackgroundService` in
|
||||||
|
`Marathon.Infrastructure/Workers/`:
|
||||||
|
- Runs every `Anomaly:DetectionIntervalSeconds` (default 60s)
|
||||||
|
- Calls `DetectAnomaliesUseCase`
|
||||||
|
- Gated by `Workers:AnomalyDetectionEnabled` (default `true`)
|
||||||
|
- [x] Add `WorkerOptions.AnomalyDetectionEnabled` (default `true`)
|
||||||
|
- [x] Register `DetectAnomaliesUseCase` as Scoped in `ApplicationModule`
|
||||||
|
- [x] Bind `AnomalyOptions` and register `AnomalyDetectionPoller` in `InfrastructureModule`
|
||||||
|
- [x] Update `appsettings.json` — add `Workers:AnomalyDetectionEnabled: true`
|
||||||
|
(all 4 `Anomaly:*` keys already existed from Phase 5)
|
||||||
|
- [x] Backend tests in `Marathon.Domain.Tests/AnomalyDetection/AnomalyDetectorTests.cs` (10 tests):
|
||||||
|
- Empty snapshot list → 0 anomalies ✓
|
||||||
|
- Below `minSnapshotCount` → 0 anomalies ✓
|
||||||
|
- Pre-match-only snapshots → 0 anomalies ✓
|
||||||
|
- No suspension (regular intervals) → 0 anomalies ✓
|
||||||
|
- Suspension but odds shift below threshold → 0 anomalies ✓
|
||||||
|
- Suspension + favourite flip (2-way) → 1 anomaly ✓
|
||||||
|
- Score calculation correct for known inputs ✓
|
||||||
|
- Tennis (no draw) → 1 anomaly ✓
|
||||||
|
- Multiple suspensions → multiple anomalies ✓
|
||||||
|
- EvidenceJson contains pre/post probability vectors and rates ✓
|
||||||
|
- Determinism: same input → same output ✓
|
||||||
|
- 3-way market flip (draw becomes favourite) → 1 anomaly ✓
|
||||||
|
- Mixed pre-match + live snapshots → only live analysed ✓
|
||||||
|
- [x] Application tests in `Marathon.Application.Tests/UseCases/DetectAnomaliesUseCaseTests.cs` (4 tests):
|
||||||
|
- Iterates events, calls detector, persists new anomalies ✓
|
||||||
|
- Skips already-persisted anomalies (dedup logic) ✓
|
||||||
|
- Tolerates per-event failures (one event throwing doesn't abort the cycle) ✓
|
||||||
|
- Returns count of new anomalies ✓
|
||||||
|
|
||||||
|
### Frontend (Opus + frontend-design) ✅ COMPLETE
|
||||||
|
|
||||||
|
- [x] Create `Marathon.UI/Pages/Anomalies/AnomalyFeed.razor`:
|
||||||
|
- List of anomalies sorted by `DetectedAt` descending
|
||||||
|
- Each card shows: severity (color-coded by score), event identity, sport icon,
|
||||||
|
detected timestamp, pre→post odds strip
|
||||||
|
- Click card → navigate to `/anomalies/{id}` detail page
|
||||||
|
- Filter: severity threshold (Low/Med/High chips), sport chips, date range
|
||||||
|
- [x] Create `Marathon.UI/Pages/Anomalies/Detail.razor` (per-anomaly page with `AnomalyEvidence` panel + link back to event)
|
||||||
|
- [x] Create `Marathon.UI/Components/AnomalyCard.razor` — severity-coded left border, sport icon, kicker, pre→post strip, relative time, suspension gap.
|
||||||
|
- [x] Create `Marathon.UI/Components/SeverityBadge.razor` — pill: Low (neutral), Medium (amber), High (signal-red, pulsing).
|
||||||
|
- [x] Create `Marathon.UI/Components/AnomalyEvidence.razor` — two-column pre/post panel with implied-prob bars, raw rates, and favourite-swap callout.
|
||||||
|
- [x] Add navigation entry to `NavBody.razor` drawer with pulsing red badge showing unread anomaly count.
|
||||||
|
- [x] Create `Marathon.UI/Services/IAnomalyBrowsingService.cs` + `AnomalyBrowsingService.cs` + `AnomalyBrowsingState.cs` + `AnomalyViewModels.cs`
|
||||||
|
- [x] Append `Anomaly.*` localization keys to `SharedResource.ru.resx` and `SharedResource.en.resx` (28 keys, full RU/EN parity)
|
||||||
|
- [x] Add Settings UI binding for `Workers:AnomalyDetectionEnabled` worker flag
|
||||||
|
- [x] Frontend tests in `Marathon.UI.Tests/Pages/Anomalies/` + `Components/`:
|
||||||
|
- `SeverityBadgeTests` — score → severity bucket → pill class (9 tests)
|
||||||
|
- `AnomalyCardTests` — severity styling, click callback, 2-way vs 3-way (6 tests)
|
||||||
|
- `AnomalyEvidenceTests` — two-column render, favourite-swap callout, 2-way row count, suspension duration formatting (6 tests)
|
||||||
|
- `AnomalyFeedTests` — seeded list render, empty state, severity/sport chip filtering, mark-read state mutation (5 tests)
|
||||||
|
- `AnomalyDetailTests` — not-found fallback, evidence + back-link rendering, suspension duration in header (4 tests)
|
||||||
|
|
||||||
|
## Files to Modify/Create
|
||||||
|
|
||||||
|
### Backend (done)
|
||||||
|
- `src/Marathon.Domain/AnomalyDetection/AnomalyDetector.cs` ✅ created
|
||||||
|
- `src/Marathon.Domain/AnomalyDetection/SuspensionInterval.cs` ✅ created
|
||||||
|
- `src/Marathon.Application/Configuration/AnomalyOptions.cs` ✅ created
|
||||||
|
- `src/Marathon.Application/UseCases/DetectAnomaliesUseCase.cs` ✅ created
|
||||||
|
- `src/Marathon.Application/ApplicationModule.cs` ✅ modified (added `DetectAnomaliesUseCase` registration)
|
||||||
|
- `src/Marathon.Infrastructure/Workers/AnomalyDetectionPoller.cs` ✅ created
|
||||||
|
- `src/Marathon.Infrastructure/Configuration/WorkerOptions.cs` ✅ modified (added `AnomalyDetectionEnabled`)
|
||||||
|
- `src/Marathon.Infrastructure/InfrastructureModule.cs` ✅ modified (added `AnomalyOptions` binding + poller)
|
||||||
|
- `src/Marathon.Hosts.WpfBlazor/appsettings.json` ✅ modified (added `Workers:AnomalyDetectionEnabled`)
|
||||||
|
- `tests/Marathon.Domain.Tests/AnomalyDetection/AnomalyDetectorTests.cs` ✅ created (13 tests)
|
||||||
|
- `tests/Marathon.Application.Tests/UseCases/DetectAnomaliesUseCaseTests.cs` ✅ created (4 tests)
|
||||||
|
|
||||||
|
### Frontend (UI agent owns)
|
||||||
|
- `src/Marathon.UI/Pages/Anomalies/AnomalyFeed.razor`
|
||||||
|
- `src/Marathon.UI/Components/AnomalyCard.razor`
|
||||||
|
- `src/Marathon.UI/Services/IAnomalyBrowsingService.cs`
|
||||||
|
- `src/Marathon.UI/Services/AnomalyBrowsingService.cs`
|
||||||
|
- `src/Marathon.UI/Services/AnomalyBrowsingState.cs`
|
||||||
|
- `src/Marathon.UI/Services/AnomalyViewModels.cs`
|
||||||
|
- `src/Marathon.UI/Resources/SharedResource.ru.resx` (append new keys)
|
||||||
|
- `src/Marathon.UI/Resources/SharedResource.en.resx` (append new keys)
|
||||||
|
- `src/Marathon.UI/MainLayout.razor` or `NavBody.razor` (anomaly nav entry)
|
||||||
|
- `tests/Marathon.UI.Tests/Pages/Anomalies/**`
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [x] Compiles (Big Bang).
|
||||||
|
- [x] `AnomalyDetector` is a pure function — no I/O, no DI dependencies.
|
||||||
|
- [x] Configurable thresholds via `appsettings.json`.
|
||||||
|
- [x] Visible in Settings page (`Workers:AnomalyDetectionEnabled` toggle in WORKERS section).
|
||||||
|
- [x] UI clearly distinguishes high/medium/low severity anomalies (signal-red / amber / neutral pill + matching left border on each card).
|
||||||
|
- [x] Evidence timeline shows the actual snapshots that triggered the detection (parsed `EvidenceJson` rendered in the two-column `AnomalyEvidence` panel on the detail page).
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- This is the **product's actual differentiator** — quality of detection logic and
|
||||||
|
evidence presentation matters. Spend time getting the score formula right.
|
||||||
|
- Implied probability formula: `p = 1 / odds` (then normalize so they sum to 1).
|
||||||
|
- Big Bang: compile-only smoke check.
|
||||||
|
|
||||||
|
## Review Checklist
|
||||||
|
|
||||||
|
- [x] Detector is deterministic and pure
|
||||||
|
- [x] Score calculation correct (verified against hand-computed example in test comments)
|
||||||
|
- [x] No false positives on synthetic "normal" timelines
|
||||||
|
- [x] UI evidence timeline matches stored `EvidenceJson` (`AnomalyBrowsingService` parses the JSON via System.Text.Json and `AnomalyEvidence` renders both bracket snapshots verbatim — no synthesised data).
|
||||||
|
- [x] All strings localized (RU + EN parity for the 28 new `Anomaly.*` + 2 new `Settings.Workers.AnomalyDetectionEnabled*` keys).
|
||||||
|
|
||||||
|
## Handoff to Next Phase
|
||||||
|
|
||||||
|
### Handoff to Phase 7 Frontend (UI) Agent
|
||||||
|
|
||||||
|
> **Read this section first.** The backend is fully implemented. You own all `Marathon.UI`
|
||||||
|
> files listed above. Do NOT touch any backend files.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### What the backend provides
|
||||||
|
|
||||||
|
**`DetectAnomaliesUseCase.ExecuteAsync(CancellationToken)`**
|
||||||
|
- Returns `Task<int>` (count of new anomalies persisted this cycle).
|
||||||
|
- Called automatically by `AnomalyDetectionPoller` every 60 s (default).
|
||||||
|
- You do NOT call this from the UI — it is worker-driven.
|
||||||
|
- The UI only reads from `IAnomalyRepository`.
|
||||||
|
|
||||||
|
**`AnomalyDetector` — detection formula (for rendering evidence)**
|
||||||
|
- Implied probability: `p_i = (1 / rate_i)` for each win side.
|
||||||
|
- Normalisation: divide each `p_i` by the sum of all raw `p_i` values → they sum to 1.
|
||||||
|
- Flip score: `max(|p_post[i] − p_pre[i]|)` over i ∈ {p1, pDraw?, p2}.
|
||||||
|
- Favourite-changed test: `argmax(p_pre) != argmax(p_post)`.
|
||||||
|
- An anomaly is emitted only if BOTH conditions hold: score ≥ threshold AND favourite changed.
|
||||||
|
|
||||||
|
**`IAnomalyRepository`** — the UI service should call:
|
||||||
|
- `ListAsync(CancellationToken)` — all anomalies for the feed page (paginate client-side).
|
||||||
|
- `GetAsync(Guid id, CancellationToken)` — single anomaly for a detail view.
|
||||||
|
- There is no `ListByEventAsync` on `IAnomalyRepository` (only on `ISnapshotRepository`).
|
||||||
|
If you need anomalies for a specific event, filter the full list by `EventId`.
|
||||||
|
|
||||||
|
**`Anomaly` entity** — fields available to the UI:
|
||||||
|
```csharp
|
||||||
|
Guid Id // GUID primary key
|
||||||
|
EventId EventId // bookmaker event code (e.g. "26456117")
|
||||||
|
DateTimeOffset DetectedAt // Moscow TZ (UTC+3)
|
||||||
|
AnomalyKind Kind // currently always SuspensionFlip
|
||||||
|
decimal Score // normalised [0, 1] — the largest implied-prob delta
|
||||||
|
string EvidenceJson // see shape below
|
||||||
|
```
|
||||||
|
|
||||||
|
**`Anomaly.EvidenceJson` shape:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"suspensionGapSeconds": 90,
|
||||||
|
"preSuspension": {
|
||||||
|
"capturedAt": "2026-05-10T18:00:00+03:00",
|
||||||
|
"p1": 0.755,
|
||||||
|
"pDraw": null,
|
||||||
|
"p2": 0.245,
|
||||||
|
"rate1": 1.3,
|
||||||
|
"rateDraw": null,
|
||||||
|
"rate2": 4.0
|
||||||
|
},
|
||||||
|
"postSuspension": {
|
||||||
|
"capturedAt": "2026-05-10T18:01:30+03:00",
|
||||||
|
"p1": 0.245,
|
||||||
|
"pDraw": null,
|
||||||
|
"p2": 0.755,
|
||||||
|
"rate1": 4.0,
|
||||||
|
"rateDraw": null,
|
||||||
|
"rate2": 1.3
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- `pDraw` / `rateDraw` are `null` for 2-way markets (tennis, etc.).
|
||||||
|
- Use `System.Text.Json.JsonDocument.Parse(anomaly.EvidenceJson)` to deserialise in the UI.
|
||||||
|
Or define a `EvidenceDto` record in `AnomalyViewModels.cs` and use `JsonSerializer.Deserialize<EvidenceDto>`.
|
||||||
|
|
||||||
|
**Recommended severity buckets** (for color-coding):
|
||||||
|
| Severity | Score range | MudBlazor color suggestion |
|
||||||
|
|----------|-------------|---------------------------|
|
||||||
|
| Low | 0.30–0.45 | `Color.Warning` |
|
||||||
|
| Medium | 0.45–0.60 | `Color.Error` |
|
||||||
|
| High | 0.60+ | deep red / `Color.Error` + pulsing badge |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### Settings page addition (UI agent must wire)
|
||||||
|
|
||||||
|
`Workers:AnomalyDetectionEnabled` (`bool`, default `true`) was added to `WorkerOptions`
|
||||||
|
and `appsettings.json`. The Phase 5 Settings page needs a toggle for it.
|
||||||
|
The existing pattern is the same as `LivePollerEnabled` and `UpcomingPollerEnabled`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### Localization keys to add
|
||||||
|
|
||||||
|
Append these to both `SharedResource.ru.resx` and `SharedResource.en.resx`:
|
||||||
|
|
||||||
|
| Key | EN value | RU value |
|
||||||
|
|------------------------------|------------------------------|------------------------------------|
|
||||||
|
| `Anomaly.Title` | Anomaly Feed | Лента аномалий |
|
||||||
|
| `Anomaly.Severity.Low` | Low | Низкая |
|
||||||
|
| `Anomaly.Severity.Medium` | Medium | Средняя |
|
||||||
|
| `Anomaly.Severity.High` | High | Высокая |
|
||||||
|
| `Anomaly.Card.DetectedAt` | Detected at | Обнаружено |
|
||||||
|
| `Anomaly.Card.Score` | Score | Оценка |
|
||||||
|
| `Anomaly.Card.Kind.SuspensionFlip` | Suspension Flip | Переворот после паузы |
|
||||||
|
| `Anomaly.Card.GapSeconds` | Suspension gap | Длительность паузы |
|
||||||
|
| `Anomaly.Evidence.PreSuspension` | Before suspension | До паузы |
|
||||||
|
| `Anomaly.Evidence.PostSuspension` | After suspension | После паузы |
|
||||||
|
| `Anomaly.Evidence.Probability` | Implied prob. | Вероятность |
|
||||||
|
| `Anomaly.Evidence.Rate` | Rate | Коэффициент |
|
||||||
|
| `Anomaly.Filter.Severity` | Min severity | Минимальная важность |
|
||||||
|
| `Anomaly.Filter.Sport` | Sport | Вид спорта |
|
||||||
|
| `Anomaly.Filter.DateRange` | Date range | Диапазон дат |
|
||||||
|
| `Anomaly.Empty` | No anomalies detected yet. | Аномалии пока не обнаружены. |
|
||||||
|
| `Settings.AnomalyDetection` | Anomaly detection | Обнаружение аномалий |
|
||||||
|
| `Settings.AnomalyDetectionEnabled` | Enable anomaly detection | Включить обнаружение аномалий |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### Integration pattern for the UI service
|
||||||
|
|
||||||
|
Follow the same split as `EventBrowsingService` (Scoped) + `EventBrowsingState` (Singleton)
|
||||||
|
documented in CONTEXT.md Phase 6 notes. Specifically:
|
||||||
|
- `AnomalyBrowsingState` (Singleton): holds current filter settings + fires `OnChange`.
|
||||||
|
- `AnomalyBrowsingService` (Scoped): resolves `IAnomalyRepository` from the DI scope,
|
||||||
|
loads anomalies, and maps to view-models (`AnomalyListItem`, `AnomalyDetail`).
|
||||||
|
- `AnomalyListItem` view-model should include `Severity` (computed from `Score`),
|
||||||
|
pre-rendered display strings, and the parsed `EvidenceDto`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 🟡 Known gaps / deferred items
|
||||||
|
|
||||||
|
- **No "last detection run" tracking.** The use case currently loads the last 24 h of
|
||||||
|
snapshots for ALL events on every cycle. A Phase 8/9 optimisation: track last-run
|
||||||
|
timestamp per event to limit the snapshot window. Flag this in the UI as "best-effort
|
||||||
|
coverage window: last 24 h".
|
||||||
|
- **`Settings.razor` AnomalyDetectionEnabled toggle** — backend option exists, UI wiring
|
||||||
|
is the UI agent's responsibility.
|
||||||
|
- **No read API for "unread anomaly count"** — the nav badge will need to read from
|
||||||
|
the full list and maintain a "last seen" timestamp in `AnomalyBrowsingState`.
|
||||||
|
Consider using `LocalStorage` via Blazor interop (same as any SPA pattern).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Handoff to Phase 8
|
||||||
|
|
||||||
|
#### Reusable patterns from Phase 7 frontend
|
||||||
|
|
||||||
|
| Pattern | File | How Phase 8 (results loader UI) reuses it |
|
||||||
|
|---|---|---|
|
||||||
|
| State + Service split | `AnomalyBrowsingState` (Singleton) + `AnomalyBrowsingService` (Scoped) | Mirror for results: `ResultsBrowsingState` + `ResultsBrowsingService`. Pages never inject `IResultRepository` directly. |
|
||||||
|
| View-model factory | `AnomalyViewModels.cs` (`AnomalyListItem`, `AnomalyDetailVm`, `AnomalyEvidenceSnapshot`) | Phase 8 should expose `ResultListItem` / `ResultDetail` records — keep the UI shielded from EF graphs. |
|
||||||
|
| Severity-style chips | `AnomalyFeed.razor` toolbar (`m-chip` w/ `aria-pressed`) | Match the chip cadence for results filters (sport, status: pending/complete). |
|
||||||
|
| Evidence panel | `AnomalyEvidence.razor` two-column layout | If results show "predicted vs final" deltas, reuse the same paired-card structure. |
|
||||||
|
| Severity-coded card | `AnomalyCard.razor` left-border colour driven by severity | Pattern transfers to "result outcome" badging if needed (winner/loser/draw). |
|
||||||
|
| Nav badge | `NavBody.razor` `m-nav__badge` (signal-red, pulsing) | Phase 8 may want a similar "new results" badge. CSS class is already factored. |
|
||||||
|
|
||||||
|
#### New CSS surfaces introduced
|
||||||
|
|
||||||
|
- `.m-severity` / `.m-severity--{low,medium,high}` — small pill, severity-coded.
|
||||||
|
- `.m-anomaly-card` / `.m-anomaly-card--{low,medium,high}` — feed card with severity-coded left border.
|
||||||
|
- `.m-evidence` / `.m-evidence__col` / `.m-evidence__bar` — two-column evidence panel.
|
||||||
|
- `.m-anomaly-feed__stats` — at-a-glance count strip (Total / High / Medium / Low).
|
||||||
|
- `.m-nav__badge` — signal-red pulsing pill on the drawer link.
|
||||||
|
|
||||||
|
#### Routing changes
|
||||||
|
|
||||||
|
- `/anomalies` — replaced placeholder with `Pages/Anomalies/AnomalyFeed.razor`.
|
||||||
|
- `/anomalies/{id:guid}` — new detail page `Pages/Anomalies/Detail.razor`.
|
||||||
|
- The `Pages/Anomalies.razor` placeholder file was deleted (Option A from the brief).
|
||||||
|
|
||||||
|
#### Test infrastructure
|
||||||
|
|
||||||
|
- `tests/Marathon.UI.Tests/Support/FakeAnomalyBrowsingService.cs` — in-memory fake with `MakeItem(...)` and `MakeSnapshot(...)` factory helpers.
|
||||||
|
- `MarathonTestContext` now also registers `AnomalyBrowsingState` (singleton) + the fake. Phase 8 tests can follow the same factory pattern for `IResultBrowsingService`.
|
||||||
|
|
||||||
|
#### Localization keys added
|
||||||
|
|
||||||
|
28 `Anomaly.*` keys (RU+EN full parity) plus `Settings.Workers.AnomalyDetectionEnabled` and its `.Hint`. All under the `<Surface>.<Element>` convention from Phase 5/6.
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
# Phase 8: Results Loader
|
||||||
|
|
||||||
|
**Status:** ⬜ Not Started
|
||||||
|
**Parent plan:** [PLAN.md](./PLAN.md)
|
||||||
|
**Domain:** fullstack
|
||||||
|
**Implementer:** Sonnet (backend) + Opus (UI)
|
||||||
|
**Depends on:** Phase 6 (UI patterns)
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
|
||||||
|
Per customer TZ §4: scrape and persist results of completed events, with a UI that
|
||||||
|
allows the user to load all results in a date range OR pick specific events to load
|
||||||
|
selectively.
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
|
||||||
|
### Backend (Sonnet)
|
||||||
|
|
||||||
|
- [ ] `PullResultsUseCase` was scaffolded in Phase 4 — extend it here:
|
||||||
|
- When `selection` is null/empty, fetch results for ALL completed events in range
|
||||||
|
that don't have a stored `EventResult` yet
|
||||||
|
- When `selection` provided, fetch results only for those events
|
||||||
|
- Idempotent — re-running for already-loaded results is a no-op
|
||||||
|
- [ ] Add `IResultsScraper`-related parser methods (or extend `IOddsScraper` with
|
||||||
|
`ScrapeResultsAsync`) — implementation may already exist from Phase 3.
|
||||||
|
- [ ] After persisting results, infer `WinnerSide` and update the `Event` accordingly
|
||||||
|
(or store derived `WinnerSide` on `EventResult` only — implementer's choice, document
|
||||||
|
in handoff).
|
||||||
|
- [ ] Tests in `Marathon.Application.Tests`:
|
||||||
|
- `PullResultsUseCase` with selection list pulls only those events
|
||||||
|
- With null selection, pulls all completed events missing results in range
|
||||||
|
- Idempotency: running twice produces no duplicates
|
||||||
|
|
||||||
|
### Frontend (Opus + frontend-design)
|
||||||
|
|
||||||
|
- [ ] Create `Marathon.UI/Pages/Results/ResultsLoader.razor`:
|
||||||
|
- Date range picker
|
||||||
|
- Two modes: "All in range" (default) | "Selected events"
|
||||||
|
- Selected events mode: searchable multi-select of completed events lacking results
|
||||||
|
- "Load Results" button → invokes `PullResultsUseCase`
|
||||||
|
- Progress indicator (number of events processed / total)
|
||||||
|
- Result table on completion showing what was loaded (event identity, score,
|
||||||
|
winner side)
|
||||||
|
- [ ] Create `Marathon.UI/Pages/Results/ResultsList.razor`:
|
||||||
|
- Browse already-loaded results
|
||||||
|
- Filter by sport, date range, winner-side-1 / winner-side-2 / draw
|
||||||
|
- Link back to event detail page (Phase 6)
|
||||||
|
- [ ] Add `Results` entry to navigation drawer.
|
||||||
|
- [ ] Localize all strings RU + EN.
|
||||||
|
- [ ] Frontend tests:
|
||||||
|
- bUnit: loader page invokes use case with correct parameters in both modes
|
||||||
|
- bUnit: results list filter narrows correctly
|
||||||
|
|
||||||
|
## Files to Modify/Create
|
||||||
|
|
||||||
|
- `src/Marathon.Application/UseCases/PullResultsUseCase.cs` — extend
|
||||||
|
- `src/Marathon.UI/Pages/Results/ResultsLoader.razor`
|
||||||
|
- `src/Marathon.UI/Pages/Results/ResultsList.razor`
|
||||||
|
- `tests/Marathon.Application.Tests/UseCases/PullResultsUseCaseTests.cs`
|
||||||
|
- `tests/Marathon.UI.Tests/Pages/Results/**`
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- Compiles (Big Bang).
|
||||||
|
- Selective loading respects user's selection.
|
||||||
|
- Bulk loading skips events that already have results.
|
||||||
|
- UI shows progress during a multi-event load.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Big Bang: compile-only smoke check.
|
||||||
|
|
||||||
|
## Review Checklist
|
||||||
|
|
||||||
|
- [ ] Idempotent — no duplicate `EventResult` rows
|
||||||
|
- [ ] UI handles empty range gracefully (no events match)
|
||||||
|
- [ ] All strings localized
|
||||||
|
|
||||||
|
## Handoff to Next Phase
|
||||||
|
|
||||||
|
<!-- Filled by Phase 8 implementer. Phase 9 is packaging — note any runtime requirements
|
||||||
|
(e.g., Playwright browser binaries) that need to be bundled with the installer. -->
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
# Phase 9: Packaging + Polish (FINAL PHASE — full build + tests required)
|
||||||
|
|
||||||
|
**Status:** ⬜ Not Started
|
||||||
|
**Parent plan:** [PLAN.md](./PLAN.md)
|
||||||
|
**Domain:** fullstack
|
||||||
|
**Implementer:** Sonnet 4.6
|
||||||
|
**Type:** **Final phase — Big Bang strategy mandates full build + full test suite pass.**
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
|
||||||
|
Make the application shippable: comprehensive logging, finalized configuration UX,
|
||||||
|
deployment artifact (single-file exe and/or MSIX installer), README with end-user
|
||||||
|
setup, screenshots, and a final pass for any cross-cutting polish (error UI,
|
||||||
|
empty states, loading states, telemetry).
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
|
||||||
|
### Verification (FIRST — gate before any new work)
|
||||||
|
|
||||||
|
- [ ] `dotnet restore Marathon.sln` — succeeds
|
||||||
|
- [ ] `dotnet build Marathon.sln` — succeeds with NO warnings in Release mode
|
||||||
|
- [ ] `dotnet test Marathon.sln` — ALL tests pass (this is the first time the full
|
||||||
|
suite runs end-to-end since Big Bang strategy was used)
|
||||||
|
- [ ] `dotnet format Marathon.sln --verify-no-changes` — passes
|
||||||
|
- [ ] **If any of the above fails, fix before proceeding.** This is the only phase
|
||||||
|
where build + tests are mandatory under Big Bang.
|
||||||
|
|
||||||
|
### Logging
|
||||||
|
|
||||||
|
- [ ] Configure Serilog in `Marathon.Hosts.WpfBlazor/App.xaml.cs`:
|
||||||
|
- Rolling file: `./logs/marathon-.log`, 7-day retention, 50 MB per file cap
|
||||||
|
- Console sink (debug builds only)
|
||||||
|
- Enrichers: `FromLogContext`, `WithThreadId`, `WithProcessId`
|
||||||
|
- Minimum level via config: `Logging:MinimumLevel` (default `Information`)
|
||||||
|
- [ ] Add structured logging at key points:
|
||||||
|
- Scraping cycles start/end (sport, count, duration)
|
||||||
|
- Snapshot persisted (event ID, snapshot ID)
|
||||||
|
- Anomaly detected (event ID, score)
|
||||||
|
- Excel export completed (path, row count)
|
||||||
|
- All exceptions with stack + context
|
||||||
|
|
||||||
|
### Settings UX polish
|
||||||
|
|
||||||
|
- [ ] Settings page validates input client-side (e.g., polling interval ≥ 5s)
|
||||||
|
- [ ] Confirmation dialog before saving settings that require restart
|
||||||
|
- [ ] "Reset to defaults" button per section
|
||||||
|
- [ ] Live-edit of polling intervals takes effect within the next cycle
|
||||||
|
|
||||||
|
### Empty states & loading states
|
||||||
|
|
||||||
|
- [ ] Every page that loads data shows a skeleton/spinner during fetch
|
||||||
|
- [ ] Every list shows an empty-state illustration + helpful copy when no data
|
||||||
|
- [ ] Network errors surface a clear toast with retry action
|
||||||
|
|
||||||
|
### Error UI
|
||||||
|
|
||||||
|
- [ ] Global error boundary in `MainLayout.razor` catches Blazor exceptions
|
||||||
|
- [ ] Display friendly message + "report issue" copy (with log path)
|
||||||
|
- [ ] Errors logged to Serilog with full stack
|
||||||
|
|
||||||
|
### Packaging
|
||||||
|
|
||||||
|
- [ ] Add `dotnet publish` profile for single-file self-contained exe:
|
||||||
|
```
|
||||||
|
dotnet publish src/Marathon.Hosts.WpfBlazor -c Release -r win-x64 --self-contained \
|
||||||
|
-p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true
|
||||||
|
```
|
||||||
|
- [ ] (Optional) MSIX packaging via `Microsoft.Windows.SDK.BuildTools` — only if time
|
||||||
|
permits and customer wants installer flow.
|
||||||
|
- [ ] If Playwright is bundled, ensure browser binaries are included via
|
||||||
|
`playwright.exe install` step in publish output.
|
||||||
|
- [ ] Bundle `appsettings.json` in publish output; `appsettings.Local.json` is
|
||||||
|
generated on first run.
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
- [ ] Expand `README.md`:
|
||||||
|
- System requirements (Windows 10+, .NET 8 runtime if not self-contained)
|
||||||
|
- Installation instructions
|
||||||
|
- First-run configuration walkthrough
|
||||||
|
- Excel export sheet/column reference
|
||||||
|
- Troubleshooting section (logs location, common errors)
|
||||||
|
- Screenshots of main UI surfaces
|
||||||
|
- [ ] Create `docs/USER_GUIDE.md` (RU) and `docs/USER_GUIDE_EN.md` for end users.
|
||||||
|
- [ ] Update `CLAUDE.md` with final permanent learnings.
|
||||||
|
- [ ] Capture screenshots: pre-match list, live list, event detail with chart,
|
||||||
|
anomaly feed, settings page. Place in `docs/screenshots/`.
|
||||||
|
|
||||||
|
### Final commit hygiene
|
||||||
|
|
||||||
|
- [ ] No commented-out code anywhere
|
||||||
|
- [ ] No `TODO(phase-N)` markers remaining (Phase 9 IS the resolution phase)
|
||||||
|
- [ ] `dotnet format` applied to entire solution
|
||||||
|
- [ ] No NuGet vulnerabilities (`dotnet list package --vulnerable --include-transitive`)
|
||||||
|
|
||||||
|
## Files to Modify/Create
|
||||||
|
|
||||||
|
- `src/Marathon.Hosts.WpfBlazor/App.xaml.cs` — Serilog config
|
||||||
|
- `src/Marathon.Hosts.WpfBlazor/Properties/PublishProfiles/win-x64-self-contained.pubxml`
|
||||||
|
- `src/Marathon.UI/Components/EmptyState.razor`, `LoadingSpinner.razor`,
|
||||||
|
`ErrorBoundary.razor`
|
||||||
|
- `README.md` — expanded
|
||||||
|
- `docs/USER_GUIDE.md`, `USER_GUIDE_EN.md`
|
||||||
|
- `docs/screenshots/*.png`
|
||||||
|
- `CLAUDE.md` — final updates
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- **Build passes**: `dotnet build` clean, zero warnings in Release.
|
||||||
|
- **All tests pass**: `dotnet test` green.
|
||||||
|
- **Lint passes**: `dotnet format --verify-no-changes` clean.
|
||||||
|
- **Publish succeeds**: single-file exe produced and launches without errors.
|
||||||
|
- **Documentation complete**: README + user guides + screenshots.
|
||||||
|
- **No vulnerabilities**: `dotnet list package --vulnerable` returns nothing.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- This is the FINAL phase. The next step after this is the comprehensive review
|
||||||
|
agent + security review + user merge approval.
|
||||||
|
- If new bugs surface during full-suite testing, fix them here. Document each fix
|
||||||
|
in CLAUDE.md if it reveals a permanent project lesson.
|
||||||
|
|
||||||
|
## Review Checklist
|
||||||
|
|
||||||
|
- [ ] Build, tests, lint all green
|
||||||
|
- [ ] Single-file publish works
|
||||||
|
- [ ] Logs land in expected location with sensible content
|
||||||
|
- [ ] No remaining `TODO(phase-N)` markers
|
||||||
|
- [ ] Screenshots match current UI
|
||||||
|
|
||||||
|
## Handoff to Next Phase
|
||||||
|
|
||||||
|
<!-- This is the final phase. The "next phase" is the final-reviewer + merge step. -->
|
||||||
@@ -0,0 +1,318 @@
|
|||||||
|
# Phase 0 Spike — Domain Schema Draft
|
||||||
|
|
||||||
|
**Purpose:** Map every customer-spec Excel column to a concrete DOM/JSON path in
|
||||||
|
marathonbet.by. Phase 1 (Domain) and Phase 3 (Scraping/parsing) consume this.
|
||||||
|
|
||||||
|
**Convention:** "selector" entries use AngleSharp/CSS notation. `evt` = the
|
||||||
|
event detail page DOM; `list` = the listing page DOM (top-level grid view).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Event Metadata
|
||||||
|
|
||||||
|
| Spec field | Source | Selector / extraction |
|
||||||
|
|---|---|---|
|
||||||
|
| `EventCode` | event detail page | `[data-event-eventId]` attribute on the outer `div.coupon-row`. Numeric, e.g., `26456117`. **Stable; use as primary key for the event in our SQLite.** |
|
||||||
|
| `TreeId` (internal) | event detail page | `[data-event-treeId]` on the same `div.coupon-row`. Used for URL building, less stable than `EventCode`. |
|
||||||
|
| `SportCode` | breadcrumb of event detail | `breadcrumbs-list .breadcrumbs-item:nth-child(2) a@href` matches `/su/betting/{Sport}+-+{N}`. Parse `N` as integer. Confirmed: Basketball = 6, Football = 11. |
|
||||||
|
| `Sport` | breadcrumb (RU label) | `breadcrumbs-list .breadcrumbs-item:nth-child(2) .breadcrumb-text` → strip leading `Ставки на ` prefix. e.g., `Ставки на Баскетбол` → `Баскетбол`. |
|
||||||
|
| `Country` | breadcrumb | `.breadcrumbs-item:nth-child(3) .breadcrumb-text`. May represent group ("Клубы. Международные") rather than literal country for international leagues — accept as-is. |
|
||||||
|
| `League` | breadcrumb | `.breadcrumbs-item:nth-child(4) .breadcrumb-text`. e.g., `Лига чемпионов УЕФА`, `NBA`. |
|
||||||
|
| `Category` | breadcrumb (deeper) | If breadcrumb has 5+ items beyond the event itself, join items 5..N-1 with ` / `. e.g., `Play-Offs / Semi Final / 2nd Leg`. The event detail's `category-label-link` `<h2>` text also exposes this concatenated. |
|
||||||
|
| `EventName` | event detail | `[data-event-name]` attribute on `div.coupon-row`. e.g., `Арсенал - Атлетико Мадрид`. |
|
||||||
|
| `Team1` | event detail | `[data-event-name]`, split on ` - `, take index 0. Or: `.player-row.player1 .member-name [data-member-link]` text. |
|
||||||
|
| `Team2` | event detail | Split index 1, or `.player-row.player2 .member-name [data-member-link]`. |
|
||||||
|
| `ScheduledAt` (date+time) | event detail + listing | **Time:** `.date-wrapper` text. Two formats: `HH:MM` (today) or `DD <ru-month> HH:MM` (future, e.g., `06 мая 22:00`). **Anchor:** `initData.serverTime` (Moscow TZ, format `YYYY,MM,DD,HH,MM,SS`) parsed and combined with the time. **Title fallback:** `<title>` and `<meta name="description">` contain a Russian-formatted full date (`05 мая 2026`) — use as authoritative when ambiguous. |
|
||||||
|
| `IsLive` | event detail / listing | `[data-live="true"]` attribute. Live events also carry `.score-state` and `.time` elements with `2:1` and `83:30` style content. |
|
||||||
|
| `LiveScore` | event detail (live only) | `.score-state` text (`2:1 (1:1)` style). Inning breakdown: parse the `eventJsonInfo` `[data-json]` attribute on the hidden `<td>` — JSON includes `mainScore`, `inningScore[]`, `matchTime.seconds`, `matchIsComplete`. |
|
||||||
|
| `MatchIsComplete` | event detail | Decoded JSON of `[data-mutable-id="eventJsonInfo"][data-json]` → `.matchIsComplete` boolean. Critical for Phase 8 (Results loader). |
|
||||||
|
| `FinalScore` | event detail (post-match) | Same `eventJsonInfo` JSON → `.resultDescription` (e.g., `"2:1 (1:1)"`) when `matchIsComplete=true`. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Match-Scope Bets (1×2, Handicap, Total)
|
||||||
|
|
||||||
|
The event-detail "main row" presents three primary markets in a `coefficients-table`:
|
||||||
|
**Result** (1×2), **Handicap** (Win-Fora), **Total** (Goals/Points/Games depending
|
||||||
|
on sport). These map to spec fields `Bet_Match_*`.
|
||||||
|
|
||||||
|
### 2.1 Match Win 1 / Draw / Win 2
|
||||||
|
|
||||||
|
| Spec field | data-selection-key suffix | DOM path |
|
||||||
|
|---|---|---|
|
||||||
|
| `Bet_Match_Win_1` | `@Match_Result.1` (football, tennis, hockey) **OR** `@Result.1` (basketball pre-match) **OR** `@Normal_Time_Result.1` (basketball detail) | `evt span[data-selection-key$='@Match_Result.1']@data-selection-price` (decimal odds, e.g., `1.65`) |
|
||||||
|
| `Bet_Match_Draw` | `.draw` outcome of same market | `evt span[data-selection-key$='@Match_Result.draw']@data-selection-price`. **NULL for tennis** (2-way market, no draw). |
|
||||||
|
| `Bet_Match_Win_2` | `.3` outcome | `evt span[data-selection-key$='@Match_Result.3']@data-selection-price` |
|
||||||
|
|
||||||
|
**Sport variance:**
|
||||||
|
- Football, Tennis, Table-tennis: `Match_Result`.
|
||||||
|
- Basketball: in pre-match landing, label is `Match_Winner_Including_All_OT.HB_H/HB_A`
|
||||||
|
(2-way, OT included). On the detail page, both `Normal_Time_Result.{1,draw,3}` (3-way,
|
||||||
|
reg time) and `Match_Winner_Including_All_OT.{HB_H,HB_A}` (2-way, OT included) appear.
|
||||||
|
**Recommendation:** treat `Match_Winner_Including_All_OT` as the canonical Win-1 / Win-2
|
||||||
|
(no Draw) when a 3-way `Result` market is absent; fall back to draw-included
|
||||||
|
`Normal_Time_Result` when present.
|
||||||
|
- Hockey: TBD — verify in Phase 3 with an actual hockey event capture.
|
||||||
|
|
||||||
|
**Recommendation for Phase 1 domain:** define `BetType.WinDraw` allowing nullable
|
||||||
|
`Draw`. The Excel exporter writes empty cell when `Draw` is null.
|
||||||
|
|
||||||
|
### 2.2 Match Win Fora (handicap)
|
||||||
|
|
||||||
|
| Spec field | data-selection-key suffix | DOM path | Value source |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `Bet_Match_Win_Fora_1_Value` | — | (no selection key for value alone) | `<td>` of HB_H selection: `.middle-simple` text inside the `<div class="nowrap simple-price">` (e.g., `(-1.0)`). Strip parens, parse as `decimal`. |
|
||||||
|
| `Bet_Match_Win_Fora_1_Rate` | `@To_Win_Match_With_Handicap{N}.HB_H` (or `@Match_Handicap.HB_H` variant) | `[data-selection-key$='@To_Win_Match_With_Handicap.HB_H']@data-selection-price` | — |
|
||||||
|
| `Bet_Match_Win_Fora_2_Value` | — | `.middle-simple` next to HB_A selection (e.g., `(+1.0)`). | — |
|
||||||
|
| `Bet_Match_Win_Fora_2_Rate` | `@To_Win_Match_With_Handicap{N}.HB_A` | `[data-selection-key$='@To_Win_Match_With_Handicap.HB_A']@data-selection-price` | — |
|
||||||
|
|
||||||
|
**Tennis variant:** uses `@To_Win_Match_With_Handicap_By_Games{N}.HB_H/HB_A`.
|
||||||
|
The handicap is in **games** not points — emit `Value` as-is, the unit is implicit
|
||||||
|
in the sport.
|
||||||
|
|
||||||
|
**Multi-line handicap:** the site offers many lines (`To_Win_Match_With_Handicap0`,
|
||||||
|
`...1`, `...2`, ...), each a different handicap value. The customer spec wants only
|
||||||
|
the **main line** (the one displayed in the listing's main row). Phase 3 should:
|
||||||
|
1. On listing pages, take the handicap displayed in the `coefficients-table`
|
||||||
|
`data-market-type="HANDICAP"` cell.
|
||||||
|
2. On event detail, identify the "main" line as the one without a numeric suffix
|
||||||
|
(`@To_Win_Match_With_Handicap.HB_H`) or with suffix `0` if both exist — sample
|
||||||
|
shows both `To_Win_Match_With_Handicap.HB_H` and `...0.HB_H`. Heuristic: pick
|
||||||
|
the line whose handicap value is closest to ±1.0 from the favorite, OR explicitly
|
||||||
|
prefer the no-suffix variant; fall back to suffix `0`.
|
||||||
|
3. Optional: capture the full handicap ladder into a separate normalized table
|
||||||
|
so anomaly detection can use the spread, even if Excel only exports the main line.
|
||||||
|
|
||||||
|
### 2.3 Match Total Less / More
|
||||||
|
|
||||||
|
| Spec field | data-selection-key suffix | DOM path |
|
||||||
|
|---|---|---|
|
||||||
|
| `Bet_Match_Total_Less_Value` | — | `.middle-simple` next to the `Меньше` selection (e.g., `3.5`, `213.5`). |
|
||||||
|
| `Bet_Match_Total_Less_Rate` | `@Total_{Goals\|Points\|Games}{N}.Under_<X>` | `[data-selection-key^='<eventId>@Total_'][data-selection-key$='.Under_<X>']@data-selection-price`. Use the row whose Value equals the chosen total threshold. |
|
||||||
|
| `Bet_Match_Total_More_Value` | — | Same value as Less (paired). |
|
||||||
|
| `Bet_Match_Total_More_Rate` | `@Total_{Goals\|Points\|Games}{N}.Over_<X>` | `[data-selection-key$='.Over_<X>']@data-selection-price` |
|
||||||
|
|
||||||
|
**Sport vocabulary:**
|
||||||
|
- Football: `Total_Goals`
|
||||||
|
- Basketball: `Total_Points`
|
||||||
|
- Tennis: `Total_Games`
|
||||||
|
- Hockey: `Total_Goals` (TBD)
|
||||||
|
- Volleyball / handball: TBD
|
||||||
|
|
||||||
|
**Choosing the "main" total line:** customer spec wants ONE Total Value + Less/More
|
||||||
|
rates per event. The site offers ~20 different total thresholds per event. The
|
||||||
|
listing page main row exposes the "headline" total (the one the bookmaker chose
|
||||||
|
to show). **Heuristic:**
|
||||||
|
1. On listing: read the `data-market-type="TOTAL"` cell directly.
|
||||||
|
2. On event detail: find the row labeled in `coefficients-row` (visible main view),
|
||||||
|
not in `coefficients-hidden-row`. The `data-mutable-id="S_3_1_european"` /
|
||||||
|
`S_3_3_european` pair is the main line.
|
||||||
|
3. Fall back to picking the line whose Under/Over rates are closest to **2.00**
|
||||||
|
each (the "balanced" line — most representative of bookmaker's expectation).
|
||||||
|
4. As with handicap, capture the full ladder for analysis even if exports only one row.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Period-N Scope Bets
|
||||||
|
|
||||||
|
Period markets follow the same pattern as match markets but with a period prefix
|
||||||
|
in the market token. Examples for `Period-1` (1st half of football, 1st quarter
|
||||||
|
of basketball, 1st set of tennis):
|
||||||
|
|
||||||
|
### 3.1 Period-N Win 1 / Draw / Win 2
|
||||||
|
|
||||||
|
> **CORRECTED FROM CAPTURE EVIDENCE (2026-05-05):** Period result markets use
|
||||||
|
> `RN_H` / `RN_D` / `RN_A` outcome codes (Reduced Numerals: Home / Draw / Away),
|
||||||
|
> NOT the `1` / `draw` / `3` codes used by `@Match_Result`. Market names also
|
||||||
|
> vary: football uses `Result_-_1st_Half` (with separator dashes); basketball and
|
||||||
|
> tennis use `1st_Half_Result0` / `1st_Quarter_Result0` / `1st_Set_Result0`
|
||||||
|
> (note the literal `0` suffix on the market name — line index for the period
|
||||||
|
> result market). Phase 3 parser must use these exact tokens.
|
||||||
|
|
||||||
|
| Customer field | Football (1st Half) | Basketball (1st Half *or* Quarter) | Tennis (1st Set) | Hockey (1st Period) |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `Bet_Period-1_Win_1` | `@Result_-_1st_Half.RN_H` | `@1st_Half_Result0.RN_H` (halves) **or** `@1st_Quarter_Result0.RN_H` (quarters) | `@1st_Set_Result0.RN_H` | `@1st_Period_Result0.RN_H` (TBD verify on hockey event) |
|
||||||
|
| `Bet_Period-1_Draw` | `@Result_-_1st_Half.RN_D` | `@1st_Half_Result0.RN_D` / `@1st_Quarter_Result0.RN_D` | (NULL — no draw) | `@1st_Period_Result0.RN_D` (TBD) |
|
||||||
|
| `Bet_Period-1_Win_2` | `@Result_-_1st_Half.RN_A` | `@1st_Half_Result0.RN_A` / `@1st_Quarter_Result0.RN_A` | `@1st_Set_Result0.RN_A` | `@1st_Period_Result0.RN_A` (TBD) |
|
||||||
|
|
||||||
|
The market token vocabulary differs by sport:
|
||||||
|
|
||||||
|
- **Football:** `Result_-_<ordinal>_<unit>` (e.g., `Result_-_1st_Half`, `Result_-_2nd_Half`).
|
||||||
|
- **Basketball / Tennis / Hockey:** `<ordinal>_<unit>_Result0` (e.g.,
|
||||||
|
`1st_Half_Result0`, `1st_Quarter_Result0`, `1st_Set_Result0`,
|
||||||
|
`1st_Period_Result0`). The `0` suffix is required.
|
||||||
|
- **Note:** non-period markets like `@Match_Result.1` and `@Match_Result.draw`
|
||||||
|
still use the `1`/`draw`/`3` outcome codes — the `RN_*` codes are specific to
|
||||||
|
period/half/quarter/set markets.
|
||||||
|
|
||||||
|
**Period count by sport** (default mapping for `Period-N`):
|
||||||
|
|
||||||
|
- Football: N ∈ {1, 2}
|
||||||
|
- Basketball: configurable — halves (N ∈ {1,2}) or quarters (N ∈ {1,2,3,4}). **Default to halves.**
|
||||||
|
- Tennis: N ∈ {1, 2, ...} until `<i>th_Set_Result` selection is absent. Cap at 5 for Grand Slams.
|
||||||
|
- Hockey: N ∈ {1, 2, 3}.
|
||||||
|
|
||||||
|
### 3.2 Period-N Win Fora
|
||||||
|
|
||||||
|
Same as match handicap, with period prefix:
|
||||||
|
|
||||||
|
| Sport | Selection key |
|
||||||
|
|---|---|
|
||||||
|
| Football | `@To_Win_1st_Half_With_Handicap{N}.HB_H` / `.HB_A` |
|
||||||
|
| Basketball | `@To_Win_1st_Half_With_Handicap{N}.HB_*` (or `_1st_Quarter_`) |
|
||||||
|
| Tennis | `@To_Win_1st_Set_With_Handicap{N}.HB_*` |
|
||||||
|
| Hockey | `@To_Win_1st_Period_With_Handicap{N}.HB_*` (TBD verify) |
|
||||||
|
|
||||||
|
Value extraction: same `.middle-simple` text as match handicap.
|
||||||
|
|
||||||
|
### 3.3 Period-N Total Less / More
|
||||||
|
|
||||||
|
This is the **least uniform** market. Observed:
|
||||||
|
|
||||||
|
| Sport | Period-1 Total selection key |
|
||||||
|
|---|---|
|
||||||
|
| Football | (search HTML directly — Phase 3 should parse the "Тотал тайма" tab) Likely `@1st_Half_Total_Goals{N}.Under_<X>` / `.Over_<X>`. |
|
||||||
|
| Basketball | Per-quarter total exposed as separate market in the "Тоталы" tab; sample event did not show clean `1st_Half_Total_Points` keys — see SCRAPE_FINDINGS.md §6 risk #4. **May need to fall back to NULL** for basketball Period-N Total in some leagues. |
|
||||||
|
| Tennis | `@1st_Set_Total_Games{N}.Under_<X>` / `.Over_<X>` — confirmed in sample. |
|
||||||
|
| Hockey | `@1st_Period_Total_Goals...` (TBD verify). |
|
||||||
|
|
||||||
|
**Phase 3 robustness rule:** if a period-N market is absent in the parsed HTML,
|
||||||
|
emit `null` for the corresponding rate/value. Never throw. The Excel exporter
|
||||||
|
writes empty cell.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Live Counterparts
|
||||||
|
|
||||||
|
When the same scope is captured from the **live** site (`/su/live` or live-flagged
|
||||||
|
events on `/su/`), the spec wants column prefix `Live_*` instead of `Bet_*`.
|
||||||
|
|
||||||
|
**Important:** live events use the SAME `data-selection-key` naming conventions.
|
||||||
|
The distinguishing signal is `data-live="true"` on the outer `div.coupon-row` and
|
||||||
|
the URL the snapshot was scraped from (`/su/live`).
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
- `Live_Match_Win_1` ← `[data-selection-key$='@Match_Result.1']` from live page
|
||||||
|
- `Live_Match_Win_Fora_1_Value`, `Live_Match_Win_Fora_1_Rate` ← same DOM, same logic
|
||||||
|
- `Live_Period-1_Win_1` ← same as `Bet_Period-1_Win_1` but captured from live event
|
||||||
|
|
||||||
|
**Implementation:** the parser does not change. The application service simply
|
||||||
|
records `Source = Live | PreMatch` on each `OddsSnapshot` and the Excel exporter
|
||||||
|
denormalizes pre-match snapshots to `Bet_*` columns and live snapshots to `Live_*`
|
||||||
|
columns at write time.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Field Coverage Matrix (spec → confidence)
|
||||||
|
|
||||||
|
| Field family | Football | Basketball | Tennis | Hockey | Notes |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| `Match_Win_1/2`, `Match_Draw` | ✅ confirmed | ⚠️ Win-1/2 confirmed; Draw conditional on `Normal_Time_Result` presence | ✅ Win-1/2 confirmed; **Draw is null** | ❓ verify Phase 3 | — |
|
||||||
|
| `Match_Win_Fora_*` | ✅ | ✅ | ✅ (in games) | ❓ | "Main line" heuristic needed (§2.2) |
|
||||||
|
| `Match_Total_*` | ✅ Goals | ✅ Points | ✅ Games | ❓ | "Main line" heuristic needed (§2.3) |
|
||||||
|
| `Period-1_Win_*` | ✅ Half | ✅ Half / Quarter | ✅ Set | ❓ Period | basketball mode is configurable |
|
||||||
|
| `Period-1_Win_Fora_*` | ✅ | ✅ | ✅ | ❓ | — |
|
||||||
|
| `Period-1_Total_*` | ⚠️ structure verified, exact key TBD | ⚠️ may be absent for some games | ✅ Set | ❓ | risk: emit null where absent |
|
||||||
|
| `Period-2/3/4_*` | (Period-2 only) | ✅ all | up to actual played sets | ❓ | — |
|
||||||
|
| `Live_*` (any of above) | same parser | same | same | same | distinguished only by `data-live` flag + scrape URL |
|
||||||
|
|
||||||
|
Legend: ✅ confirmed in spike sample, ⚠️ partial / heuristic needed, ❓ Phase 3 must verify.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Suggested Domain Types (Phase 1 input)
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// Marathon.Domain
|
||||||
|
public enum BetScope { Match, Period }
|
||||||
|
public enum BetType { Win, Draw, WinFora, Total }
|
||||||
|
public enum BetSide { Side1, Side2, Less, More } // Side1=home/W1, Side2=away/W2
|
||||||
|
|
||||||
|
public sealed record Sport(int Code, string NameRu, string NameEn);
|
||||||
|
public sealed record League(int TreeId, string NameRu, int SportCode);
|
||||||
|
public sealed record Event(
|
||||||
|
long EventCode, // marathonbet's data-event-eventId
|
||||||
|
int TreeId, // for URL building
|
||||||
|
int SportCode,
|
||||||
|
int LeagueTreeId,
|
||||||
|
string Country, // breadcrumb position 3
|
||||||
|
string? Category, // joined breadcrumb 5..N-1
|
||||||
|
string Team1,
|
||||||
|
string Team2,
|
||||||
|
DateTimeOffset ScheduledAt, // anchored on initData.serverTime
|
||||||
|
string DetailUrl);
|
||||||
|
|
||||||
|
public sealed record Bet(
|
||||||
|
BetScope Scope,
|
||||||
|
int? PeriodNumber, // null when Scope=Match
|
||||||
|
BetType Type,
|
||||||
|
BetSide? Side, // null for Type=Draw
|
||||||
|
decimal? Value, // handicap/total threshold; null for Win/Draw
|
||||||
|
decimal Rate); // decimal odds (e.g., 1.65)
|
||||||
|
|
||||||
|
public sealed record OddsSnapshot(
|
||||||
|
long EventCode,
|
||||||
|
DateTimeOffset CapturedAt,
|
||||||
|
SnapshotSource Source, // Pre | Live
|
||||||
|
IReadOnlyList<Bet> Bets);
|
||||||
|
|
||||||
|
public enum SnapshotSource { PreMatch, Live }
|
||||||
|
```
|
||||||
|
|
||||||
|
Phase 1 will refine names, but this captures the data shape Phase 3 produces.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Excel Column Generation (Phase 4 / 9 reference)
|
||||||
|
|
||||||
|
The Excel exporter generates wide rows by joining all `Bet`s of an `OddsSnapshot`
|
||||||
|
into named columns. Pseudocode:
|
||||||
|
|
||||||
|
```
|
||||||
|
foreach snapshot:
|
||||||
|
row.EventCode = snapshot.EventCode
|
||||||
|
row.SportCode = event.SportCode
|
||||||
|
row.Sport = event.Sport.NameRu
|
||||||
|
row.Country = event.Country
|
||||||
|
row.League = event.League.NameRu
|
||||||
|
row.Category = event.Category
|
||||||
|
row.ScheduledAt = event.ScheduledAt
|
||||||
|
prefix = snapshot.Source == PreMatch ? "Bet_" : "Live_"
|
||||||
|
|
||||||
|
// Match scope
|
||||||
|
row[prefix+"Match_Win_1"] = bet.Where(scope=Match, type=Win, side=Side1).Rate
|
||||||
|
row[prefix+"Match_Draw"] = bet.Where(scope=Match, type=Draw).Rate
|
||||||
|
row[prefix+"Match_Win_2"] = bet.Where(scope=Match, type=Win, side=Side2).Rate
|
||||||
|
row[prefix+"Match_Win_Fora_1_Value"] = bet.Where(scope=Match, type=WinFora, side=Side1).Value
|
||||||
|
row[prefix+"Match_Win_Fora_1_Rate"] = bet.Where(scope=Match, type=WinFora, side=Side1).Rate
|
||||||
|
row[prefix+"Match_Win_Fora_2_Value"] = bet.Where(scope=Match, type=WinFora, side=Side2).Value
|
||||||
|
row[prefix+"Match_Win_Fora_2_Rate"] = bet.Where(scope=Match, type=WinFora, side=Side2).Rate
|
||||||
|
row[prefix+"Match_Total_Less_Value"] = bet.Where(scope=Match, type=Total, side=Less).Value
|
||||||
|
row[prefix+"Match_Total_Less_Rate"] = bet.Where(scope=Match, type=Total, side=Less).Rate
|
||||||
|
row[prefix+"Match_Total_More_Value"] = bet.Where(scope=Match, type=Total, side=More).Value
|
||||||
|
row[prefix+"Match_Total_More_Rate"] = bet.Where(scope=Match, type=Total, side=More).Rate
|
||||||
|
|
||||||
|
// Period scope (foreach period N exposed for that sport)
|
||||||
|
for N in 1..MaxPeriodForSport(sportCode):
|
||||||
|
same fields with key {prefix}Period-{N}_*
|
||||||
|
null when bet absent
|
||||||
|
```
|
||||||
|
|
||||||
|
Spec column order is left to Phase 4 (`ExcelExporter`). Recommend:
|
||||||
|
`Date, Time, Sport, Country, League, Category, Event, EventCode,
|
||||||
|
Bet_Match_*..., Bet_Period-1_*..., Bet_Period-2_*..., Live_Match_*..., Live_Period-N_*...`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Decisions Pending Customer Confirmation
|
||||||
|
|
||||||
|
1. **Basketball Period mapping** — halves (default) or quarters? Spec says
|
||||||
|
"Period-N" but is silent on which N applies. Recommend halves (`N ∈ {1,2}`)
|
||||||
|
with a quarter mode opt-in via `appsettings.Sports.Basketball.PeriodMode`.
|
||||||
|
2. **Tennis Draw column** — emit empty / 0 / "—"? Recommend empty cell.
|
||||||
|
3. **Handicap "main line" rule** — pick the listing's main row, OR the no-suffix
|
||||||
|
selection, OR the spread closest to bookmaker-implied probability 50/50?
|
||||||
|
4. **Total "main line" rule** — same as above.
|
||||||
|
5. **Field name capitalization** — spec uses `Bet_Match_Win_Fora_1_Value` exactly.
|
||||||
|
Recommend matching exactly (case-sensitive) for compatibility with downstream
|
||||||
|
pivot tables / scripts.
|
||||||
@@ -0,0 +1,347 @@
|
|||||||
|
# Phase 0 Spike — Scraping Findings for marathonbet.by
|
||||||
|
|
||||||
|
**Date:** 2026-05-05
|
||||||
|
**Probe environment:** Windows 10, Poland-routed IP (countryCode `PL` reported by site,
|
||||||
|
`isBelarus: true` flag set in `initData`, `jurisdiction: BELARUS`).
|
||||||
|
**Tooling used:** `curl` with browser User-Agent, ~10 sequential requests with
|
||||||
|
≥1-second pacing.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## TL;DR — Decision Matrix
|
||||||
|
|
||||||
|
| Question | Answer |
|
||||||
|
|---|---|
|
||||||
|
| Is anonymous scraping feasible? | **YES — confirmed.** Site returns full server-rendered HTML for `/su/`, `/su/live`, sport listings, and event detail pages with HTTP 200 to a plain GET with browser User-Agent. |
|
||||||
|
| Cloudflare / JS challenge? | **No.** `Server: nginx`, no `cf-ray`, no challenge cookies. Only standard JSESSIONID + analytics cookies. No reCAPTCHA on listing pages. |
|
||||||
|
| Geo-block from probe environment? | **No.** Probe was made from a non-Belarus IP; site served full HTML. The site treats us as `region:"PL"` but still serves Russian-language `/su` content. |
|
||||||
|
| Recommended scraping technology | **HttpClient + AngleSharp.** All the data needed (event list, full odds, breadcrumb taxonomy, period markets) is present in the raw SSR HTML. Playwright is not required for read-only scraping. |
|
||||||
|
| Recommended polling cadence | Pre-match: **30 seconds** (default in `appsettings`). Live: 3-second native cadence is too aggressive — recommend **5–10 seconds** for our analyzer (anomaly detection doesn't need sub-second resolution). |
|
||||||
|
| WebSocket / API alternative? | STOMP-over-WebSocket exists at `/su/websocket/endpoint` for authenticated clients. Anonymous clients should stick to plain HTML scraping. The JSONP endpoint at `/su/liveupdate/popular/` only returns refresh-page signals, not full odds. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Probe Outcomes
|
||||||
|
|
||||||
|
### 1.1 Pre-match landing — `https://www.marathonbet.by/su`
|
||||||
|
|
||||||
|
```
|
||||||
|
HTTP/1.1 200 OK
|
||||||
|
Server: nginx
|
||||||
|
Content-Type: text/html;charset=UTF-8
|
||||||
|
Set-Cookie: visitedNavBarItems=HOME; HttpOnly; SameSite=None; Secure
|
||||||
|
Set-Cookie: lastSitePart=SPORT; ...
|
||||||
|
Set-Cookie: puid=rBWP3Wn5...; expires=2037; domain=.marathonbet.by
|
||||||
|
Strict-Transport-Security: max-age=31536000
|
||||||
|
Cache-Status: MISS
|
||||||
|
Cache-Control: no-store, no-cache, must-revalidate
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Render type:** Server-Side Rendered (SSR). Body is ~590 KB of HTML containing
|
||||||
|
the full event grid for live + popular pre-match events. There IS a `<div id="app">`
|
||||||
|
wrapper but the content inside is fully populated server-side; the JS layer enhances
|
||||||
|
rather than hydrates from empty.
|
||||||
|
- **Rich data attributes embedded:**
|
||||||
|
- `data-event-eventId="<bookmakerEventCode>"` — bookmaker's stable numeric event ID
|
||||||
|
- `data-event-treeId="<treeId>"` — tree position ID (used in URLs)
|
||||||
|
- `data-event-name="..."` — event display name
|
||||||
|
- `data-event-path="<sport>/<league-path>/<teams> - <treeId>"` — URL fragment to
|
||||||
|
construct event detail link
|
||||||
|
- `data-live="true|false"` — live vs pre-match flag
|
||||||
|
- `data-sport-treeId="<sportId>"` — sport identifier (matches customer's "Sport_Code")
|
||||||
|
- `data-coeff-uuid` + `data-sel='{...}'` JSON — selection metadata (ewc, cid, prt, epr)
|
||||||
|
- `data-selection-key="<eventId>@<MarketType>[N].<Outcome>"` — canonical bet identifier
|
||||||
|
- **Embedded `initData` JSON blob** (line 6 of every page) exposes runtime config:
|
||||||
|
- `serverTime: "2026,05,05,00,43,28"` (Moscow TZ)
|
||||||
|
- `liveUpdatePath: "/su/liveupdate/popular/"`
|
||||||
|
- `liveUpdateTransport: "JSONP"`
|
||||||
|
- `update_interval: 3000` (ms — live update polling cadence used by the site itself)
|
||||||
|
- `stomp.url: "/su/websocket/endpoint"` (authenticated stream)
|
||||||
|
- `region`, `isBelarus`, `jurisdiction`, `currencyCode` — geo/legal flags
|
||||||
|
- `treeIds` — for the event detail page, holds the focal treeId
|
||||||
|
|
||||||
|
### 1.2 Live landing — `https://www.marathonbet.by/su/live`
|
||||||
|
|
||||||
|
- HTTP 200, ~250 KB body — same `nginx` server, same SSR pattern.
|
||||||
|
- Same `data-event-*` attributes as pre-match. Live events show `data-live="true"`,
|
||||||
|
with extra `score-state` and `time` markers (e.g., `2:1 (1:1)`, `83:30`).
|
||||||
|
- The site polls `/su/liveupdate/popular/?treeIds=...` every 3 s but the response
|
||||||
|
is just a refresh signal (`{"modified":[{"type":"refreshPage"}],"updated":...}`)
|
||||||
|
— **the site relies on full HTML re-fetch for live updates**, which is good for us
|
||||||
|
(no separate JSON contract to track).
|
||||||
|
|
||||||
|
### 1.3 Sport-specific listing — `/su/popular/Basketball` / `/su/betting/Basketball+-+6`
|
||||||
|
|
||||||
|
- HTTP 200, ~470 KB.
|
||||||
|
- Lists all current basketball categories (NBA Playoffs etc.) with full odds.
|
||||||
|
- URL by name (`Basketball`) and URL by sport tree ID (`Basketball+-+6`) both work.
|
||||||
|
- Date display: events on the same day show **time only** (`03:00`); events on
|
||||||
|
later days show **`DD <month-ru> HH:MM`** (e.g., `06 мая 02:00`). The "today"
|
||||||
|
anchor is implicit — must be derived from `initData.serverTime`.
|
||||||
|
|
||||||
|
### 1.4 Event detail — `/su/betting/<event-path>`
|
||||||
|
|
||||||
|
- HTTP 200, ~500 KB to ~1.6 MB depending on market count.
|
||||||
|
- URL pattern: `/su/betting/<Sport>/<League+Path>/<Sub+Stage>/<Team1+vs+Team2+-+<treeId>>`.
|
||||||
|
- Exposes ~140–250 unique market types per event. Each market is a `<div>` containing
|
||||||
|
a labeled `<table>` of selections with `data-selection-key`, prices, and handicap/total
|
||||||
|
values in `<span class="middle-simple">`.
|
||||||
|
- **Schema.org breadcrumb** at the bottom of the page provides clean taxonomy:
|
||||||
|
Sport → Country/Group → League → Stage → Event. Each level has its own treeId visible
|
||||||
|
in `href="/su/betting/<path>+-+<treeId>"`.
|
||||||
|
- Sample (Football, Arsenal vs Atletico Madrid, treeId 28089645, eventId 26456117):
|
||||||
|
- Sport = `Football+-+11`, Country group = `Clubs.+International+-+4409575`,
|
||||||
|
League = `UEFA+Champions+League+-+21255`, Stage = `Play-Offs / Semi+Final / 2nd+Leg`.
|
||||||
|
- Match-level markets: `Match_Result.{1,draw,3}`, `To_Win_Match_With_Handicap{N}.{HB_H,HB_A}`,
|
||||||
|
`Total_Goals{N}.{Under_X,Over_X}`.
|
||||||
|
|
||||||
|
### 1.5 Results / archive — **NOT publicly available**
|
||||||
|
|
||||||
|
- `https://www.marathonbet.by/su/results` → **HTTP 404**.
|
||||||
|
- `https://www.marathonbet.by/su/results/` → **HTTP 404**.
|
||||||
|
- `https://www.marathonbet.by/su/results.htm` → **HTTP 404**.
|
||||||
|
- No `/results`, `/archive`, or `/history` link anywhere in the public landing-page HTML.
|
||||||
|
- The `eventJsonInfo` `<td>` on each event has a `matchIsComplete` boolean and a
|
||||||
|
`resultDescription` (e.g., `"2:1 (1:1)"`), so **final scores can be captured by
|
||||||
|
re-scraping the event detail page after match end** — but only while the event is
|
||||||
|
still hosted (likely a few hours / days post-match). After cleanup, results are gone.
|
||||||
|
- **Implication for Phase 8 (Results loader):** results must be harvested by
|
||||||
|
continuing to poll the event detail page until `matchIsComplete=true`, then storing
|
||||||
|
the final score. There is no historical archive endpoint to back-fill from. We
|
||||||
|
should also evaluate scraping a third-party results aggregator
|
||||||
|
(flashscore, livescore, sofascore) as a fallback — that's a Phase 8 design decision.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Anti-bot Posture
|
||||||
|
|
||||||
|
| Signal | Observation |
|
||||||
|
|---|---|
|
||||||
|
| Cloudflare | Absent. `Server: nginx`, no `cf-*` headers. |
|
||||||
|
| reCAPTCHA / hCAPTCHA | Not on public listing or event pages (only on `/captchaData.htm` for login). |
|
||||||
|
| User-Agent filtering | A browser UA returns 200. We did not test with `curl/8.x` or empty UA — recommend always sending a real UA. |
|
||||||
|
| Cookie requirement | None for read-only access. The site sets `puid`, `JSESSIONID`, `lastSitePart`, etc., but we observed full HTML on the very first request without prior cookies. |
|
||||||
|
| IP rate-limit | 5 sequential requests at ~1s pacing all returned 200 in <1 s. No throttling observed within our budget (10 total requests). The customer should test heavier loads from their environment. |
|
||||||
|
| Geo-block | Probe environment is geo-routed as Poland; site still serves `/su` Russian content. Customer (Belarus) should see same or better access. |
|
||||||
|
| Fingerprinting | Standard analytics (GTM, dataLayer); no JS-fingerprint cookies or canvas hashing detected in the entry-page payload. |
|
||||||
|
|
||||||
|
**Mitigations to bake into the scraper anyway** (defense-in-depth):
|
||||||
|
|
||||||
|
- **Rotate User-Agents** from a small pool of recent Chrome/Firefox/Edge versions
|
||||||
|
(configurable via `Scraping:UserAgents[]`).
|
||||||
|
- **Polite pacing:** default `Scraping:RateLimit:RequestsPerSecond = 1`,
|
||||||
|
`MaxConcurrentRequests = 4`. Per-host token-bucket rate limiter using Polly v8 +
|
||||||
|
`Microsoft.Extensions.Http.Resilience`.
|
||||||
|
- **Honor `Cache-Control: no-store`** — do NOT cache responses; that's the site's intent.
|
||||||
|
- **Handle 403 / 429 / 503** with exponential backoff and circuit breaker; alert the user
|
||||||
|
when circuit opens for >5 minutes.
|
||||||
|
- **Cookie jar per scraper instance** — accept set-cookies and replay them. This avoids
|
||||||
|
a session-creation latency on every request.
|
||||||
|
- **Belarus-specific:** if customer's environment ever sees a `/forbidden` redirect,
|
||||||
|
we fall back to the `afterForbiddenRedirectUrl` documented in `initData`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. URL Templates Phase 3 Will Use
|
||||||
|
|
||||||
|
| Purpose | Template | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| Pre-match top page | `https://www.marathonbet.by/su/` | Mixed live + popular pre-match. Use only for landing/health-check. |
|
||||||
|
| Live top page | `https://www.marathonbet.by/su/live` | Mixed sports. Use for live-event discovery. |
|
||||||
|
| Live popular | `https://www.marathonbet.by/su/live/popular` | Same data as `/su/live`. |
|
||||||
|
| All-events index | `https://www.marathonbet.by/su/all-events/` | Long full list; use for discovery seed. |
|
||||||
|
| Sport listing (by ID) | `https://www.marathonbet.by/su/betting/{Sport}+-+{sportId}` | e.g., `/su/betting/Basketball+-+6`. **Preferred** because sport-id stable. |
|
||||||
|
| Sport listing (by name) | `https://www.marathonbet.by/su/popular/{Sport}` | e.g., `/su/popular/Basketball`. Convenient for humans. |
|
||||||
|
| Category / league listing | `https://www.marathonbet.by/su/betting/{Sport}/{League+Path}+-+{categoryTreeId}` | From breadcrumbs / `category-label-link`. |
|
||||||
|
| Event detail | `https://www.marathonbet.by/su/betting/{event-path}` | `event-path` from `data-event-path`, ends in `-+{treeId}`. |
|
||||||
|
| Live update signal | `https://www.marathonbet.by/su/liveupdate/popular/?treeIds={csv}` | Returns `{"modified":[...],"updated":<ts>}`. Use only as "hey something changed" hint; full odds still come from event-detail re-fetch. |
|
||||||
|
| Server time sync | `https://www.marathonbet.by/su/stateless/synctime` | Use to anchor "today" date interpretation. |
|
||||||
|
|
||||||
|
URL paths use `+` for spaces, `%2C` for `,`, etc. — standard `Uri.EscapeDataString`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Sport ID Inventory (observed)
|
||||||
|
|
||||||
|
From the pre-match landing page (`data-sport-treeId` attributes + `category-label`
|
||||||
|
breadcrumb hrefs):
|
||||||
|
|
||||||
|
| Sport ID | Russian name | English path |
|
||||||
|
|---|---|---|
|
||||||
|
| **6** | Баскетбол | `Basketball` |
|
||||||
|
| **11** | Футбол | `Football` |
|
||||||
|
| **537** | (TBD — verify on populated day) | — |
|
||||||
|
| **2398** | (TBD) | — |
|
||||||
|
| **22723** | Теннис | `Tennis` |
|
||||||
|
| **26418** | Футбол (alt? duplicate live) | `Football` |
|
||||||
|
| **43658** | Хоккей | `Hockey` |
|
||||||
|
| **45356** | Баскетбол (live tree) | `Basketball` |
|
||||||
|
| **139722** | Гандбол | `Handball` |
|
||||||
|
| **414329** | Настольный теннис | `Table+Tennis` |
|
||||||
|
| **1372932** | Киберспорт | `Esports` |
|
||||||
|
| **3083982** | Лотереи | `Lotteries` |
|
||||||
|
| **11308234** | Шорт хоккей | `Short+Hockey` |
|
||||||
|
| **23054364** | Кибербаскетбол | `eBasketball` |
|
||||||
|
| **23054392** | Киберфутбол | `eFootball` |
|
||||||
|
|
||||||
|
**Important observation:** the site has **two parallel tree IDs per sport** — one
|
||||||
|
"canonical" (e.g., `6` for Basketball) used on event-detail breadcrumb, and a
|
||||||
|
"category" tree ID (e.g., `45356`) used inside the live grouping. Phase 1 domain
|
||||||
|
needs to recognize the canonical ID as `SportCode` and ignore the category tree ID.
|
||||||
|
|
||||||
|
The customer-spec field `Sport_Code = 6` for Basketball matches the canonical ID
|
||||||
|
in `data-sport-treeId="6"` and in the breadcrumb URL `/su/betting/Basketball+-+6`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Bet Selection Naming Convention
|
||||||
|
|
||||||
|
Format: `{eventId}@{MarketName}{LineIndex?}.{Outcome}`
|
||||||
|
|
||||||
|
Where:
|
||||||
|
|
||||||
|
- `eventId` = bookmaker's `data-event-eventId` (numeric, ~26-million range, stable).
|
||||||
|
- `MarketName` = `Match_Result`, `To_Win_Match_With_Handicap`, `Total_Points`,
|
||||||
|
`1st_Half_Result`, `To_Win_1st_Half_With_Handicap`, `1st_Set_Total_Games`, etc.
|
||||||
|
- `LineIndex?` = optional integer suffix when a market has multiple lines/spreads
|
||||||
|
(e.g., `Total_Points10`, `Total_Points11` are different total thresholds for the
|
||||||
|
same event). Empty / `0` is the "main" line.
|
||||||
|
- `Outcome` codes:
|
||||||
|
- `1`, `draw`, `3` — for 3-way result markets
|
||||||
|
- `HB_H`, `HB_A` — handicap home/away
|
||||||
|
- `Under_<X>`, `Over_<X>` — total under/over (X is the threshold, embedded in name)
|
||||||
|
- `HD`, `AD` — half-time/full-time draw combinations
|
||||||
|
- `yes` / `no` — for yes/no markets
|
||||||
|
|
||||||
|
The handicap value (`+1.0`, `-2.5`) and total threshold (`213.5`) are NOT in the
|
||||||
|
selection key as parseable numbers — they live in the `<span class="middle-simple">`
|
||||||
|
display element OR they are embedded in the outcome name (e.g., `Under_213.5`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Period Scope per Sport (observed)
|
||||||
|
|
||||||
|
| Sport | Period scopes available | Spec field prefix |
|
||||||
|
|---|---|---|
|
||||||
|
| Football (11) | 1st Half, 2nd Half | `Bet_Period-1_*`, `Bet_Period-2_*` |
|
||||||
|
| Basketball (6) | 1st/2nd Half, 1st/2nd/3rd/4th Quarter | Customer must clarify whether Period-N maps to halves or quarters. **Recommend halves** as default (Period-1, Period-2) with an `appsettings` toggle for quarter-mode. |
|
||||||
|
| Tennis (22723) | 1st Set, 2nd Set, ... (variable count) | `Bet_Period-1_*` = 1st Set, etc. **No Draw outcome.** |
|
||||||
|
| Hockey (43658) | 1st/2nd/3rd Period | `Bet_Period-1_*`, `Bet_Period-2_*`, `Bet_Period-3_*` (not yet sampled — revalidate in Phase 3). |
|
||||||
|
|
||||||
|
The internal market-name token is sport-dependent:
|
||||||
|
- `1st_Half_Result`, `To_Win_1st_Half_With_Handicap`
|
||||||
|
- `1st_Quarter_Result`, `To_Win_1st_Quarter_With_Handicap`
|
||||||
|
- `1st_Set_Result`, `To_Win_1st_Set_With_Handicap`
|
||||||
|
|
||||||
|
**Phase 3 should encapsulate this** in a sport-aware mapping table
|
||||||
|
(`PeriodScopeMapper`) keyed on `SportCode`, returning the set of expected period
|
||||||
|
markets and their token names.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Open Questions / Risks
|
||||||
|
|
||||||
|
1. **Results storage cleanup:** how long does marathonbet keep finished events on
|
||||||
|
the event detail URL? Must be empirically tested over Phase 8. Recommend retaining
|
||||||
|
our own snapshot with `matchIsComplete=true` permanently in SQLite as soon as
|
||||||
|
we observe it, so we never depend on the site for historical data.
|
||||||
|
2. **Sport ID duplication** (e.g., `26418` and `11` both = Football):
|
||||||
|
verify with customer that we should use the canonical breadcrumb ID. The
|
||||||
|
"category" trees may exist for live grouping or alphabetization purposes.
|
||||||
|
3. **Localization:** site labels are Russian on `/su/`. There appears to be `/en/`
|
||||||
|
path support (untested). Customer wants RU + EN — Phase 5 must verify EN locale
|
||||||
|
page parses identically.
|
||||||
|
4. **Period total markets in basketball:** sampled NBA event did NOT explicitly
|
||||||
|
expose "Total points 1st quarter" as a clean market in the public HTML — only
|
||||||
|
`AllInningsGoalsOver` (combined). Customer's spec implies `Bet_Period-N_Total_*`
|
||||||
|
is universal — Phase 3 must gracefully degrade and emit `null` rates for fields
|
||||||
|
the site doesn't surface for that sport+league.
|
||||||
|
5. **Belarus geo-restriction risk:** we tested from non-BY. If customer's BY IP
|
||||||
|
gets a different page (KYC overlay, deposit prompt, etc.), the parser must be
|
||||||
|
robust to unexpected wrapping. Defensive parsing only — never assume strict
|
||||||
|
structure.
|
||||||
|
6. **`isLogged: false` overlay risk:** initData reports we are anonymous. Some
|
||||||
|
markets may be hidden behind login (we did not detect any in samples, but the
|
||||||
|
parser should treat missing markets as `null`, not throw).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Recommended Phase 3 Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
IOddsScraper (Application)
|
||||||
|
│
|
||||||
|
└── MarathonBetScraper : IOddsScraper (Infrastructure)
|
||||||
|
├── HttpClient (resilient via Polly v8)
|
||||||
|
│ ├── User-Agent rotator
|
||||||
|
│ ├── Token-bucket rate limiter (config: RequestsPerSecond)
|
||||||
|
│ ├── Retry policy (3x exponential backoff, jitter)
|
||||||
|
│ └── Circuit breaker (open after N consecutive 5xx)
|
||||||
|
│
|
||||||
|
├── EventDiscoveryParser ← parses /su/, /su/live, /su/popular/{sport}
|
||||||
|
│ produces List<EventListItem>
|
||||||
|
│
|
||||||
|
├── EventDetailParser ← parses /su/betting/<path>
|
||||||
|
│ produces FullOddsSnapshot with all markets
|
||||||
|
│
|
||||||
|
├── BreadcrumbParser ← extracts Sport / Country / League / Stage taxonomy
|
||||||
|
│
|
||||||
|
└── BetMarketMapper ← AngleSharp QuerySelector → spec field name
|
||||||
|
(sport-aware; uses PeriodScopeMapper)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Use AngleSharp for parsing** — it handles malformed HTML well, has a CSS-selector
|
||||||
|
API, and is the established `.NET` choice. JSON islands inside attributes (`data-sel`,
|
||||||
|
`data-json`) decode cleanly with `System.Text.Json`.
|
||||||
|
|
||||||
|
**No Playwright required** for the scraper. Keep Playwright as a documented
|
||||||
|
fallback in `appsettings` (`Scraping:UsePlaywright = false`) so we can flip it on
|
||||||
|
later if the site adds JS challenges. This adds <100 LOC of optional code, costs
|
||||||
|
nothing if unused.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Customer Validation Plan
|
||||||
|
|
||||||
|
If our environment ever stops working (geo-block, IP ban, etc.) the customer in
|
||||||
|
Belarus can:
|
||||||
|
|
||||||
|
1. Open https://www.marathonbet.by/su in a browser, verify it renders.
|
||||||
|
2. View page source (Ctrl+U), search for `data-event-eventId` — confirm same
|
||||||
|
structure as our captured `spike/captures/pre-match-landing.html`.
|
||||||
|
3. Save the HTML and email it to dev — the parser is environment-agnostic and
|
||||||
|
should handle their captured HTML byte-for-byte.
|
||||||
|
|
||||||
|
This decouples scraper development from probe environment and makes Phase 3
|
||||||
|
testable offline.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Captured Samples (gitignored, local only)
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `spike/captures/pre-match-landing.html` | `/su/` snapshot, 587 KB, full grid |
|
||||||
|
| `spike/captures/live-landing.html` | `/su/live` snapshot, 250 KB |
|
||||||
|
| `spike/captures/basketball-listing.html` | `/su/popular/Basketball`, 471 KB |
|
||||||
|
| `spike/captures/event-basketball-28405506.html` | NBA Knicks vs 76ers full event, 505 KB |
|
||||||
|
| `spike/captures/event-football-28089645.html` | UCL Arsenal vs Atletico full event, 1.58 MB |
|
||||||
|
| `spike/captures/event-tennis-28430484.html` | ATP Rome qualif full event, 244 KB |
|
||||||
|
| `spike/captures/liveupdate-popular.json` | Live-update API sample response |
|
||||||
|
| `spike/captures/results-page.html` | `/su/results` response (~20 KB) — captured to evidence the missing public archive endpoint (Phase 8 deviation). |
|
||||||
|
|
||||||
|
These artifacts are **not committed** but should be kept locally to back parser unit
|
||||||
|
tests in Phase 3.
|
||||||
|
|
||||||
|
> **Caveats on captures:**
|
||||||
|
>
|
||||||
|
> - `live-landing.html` was captured at a moment when no live events were
|
||||||
|
> in-progress for popular sports. As a result, the `.score-state` element
|
||||||
|
> referenced in `SCHEMA_DRAFT.md` §1 is NOT present in this particular capture.
|
||||||
|
> Phase 3 should re-verify the score selector against a live event during
|
||||||
|
> parser implementation (the selector itself is well-known across bookmaker
|
||||||
|
> sites and not in doubt).
|
||||||
|
> - Hockey events were not sampled directly. Period-result selection key tokens
|
||||||
|
> for hockey (`1st_Period_Result0.RN_H` etc.) are extrapolated from the
|
||||||
|
> football/basketball/tennis pattern and marked TBD in `SCHEMA_DRAFT.md`. Phase 3
|
||||||
|
> must verify against a real hockey event before relying on those tokens.
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
using Marathon.Domain.Entities;
|
||||||
|
|
||||||
|
namespace Marathon.Application.Abstractions;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Repository for <see cref="Anomaly"/> domain entities.
|
||||||
|
/// </summary>
|
||||||
|
public interface IAnomalyRepository : IRepository<Guid, Anomaly>
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Server-side count of anomalies detected strictly after <paramref name="since"/>.
|
||||||
|
/// Backs the unread badge without materialising the table.
|
||||||
|
/// </summary>
|
||||||
|
Task<int> CountSinceAsync(DateTimeOffset since, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Anomalies whose <see cref="Anomaly.DetectedAt"/> falls in the inclusive
|
||||||
|
/// [<paramref name="from"/>..<paramref name="to"/>] window (either bound may be
|
||||||
|
/// null for open-ended), ordered newest-first. Pushes the temporal filter to SQL;
|
||||||
|
/// severity / sport filtering remains a service concern (needs the event join).
|
||||||
|
/// </summary>
|
||||||
|
Task<IReadOnlyList<Anomaly>> ListByDateRangeAsync(
|
||||||
|
DateTimeOffset? from,
|
||||||
|
DateTimeOffset? to,
|
||||||
|
CancellationToken ct = default);
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
namespace Marathon.Application.Abstractions;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Marker interface for the future bet-placing feature.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// This interface is intentionally empty. It acts as an extension point for
|
||||||
|
/// a future implementation that interacts with a bookmaker's authenticated
|
||||||
|
/// betting API.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Phase 3 scope is analyze-only. Register a stub / no-op implementation if
|
||||||
|
/// needed for DI graph completeness, but the interface itself is not consumed
|
||||||
|
/// by any application service in the current release.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public interface IBetPlacer
|
||||||
|
{
|
||||||
|
// Future: PlaceBetAsync(BetRequest request, CancellationToken ct)
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
using Marathon.Application.Storage;
|
||||||
|
using Marathon.Domain.Entities;
|
||||||
|
using Marathon.Domain.ValueObjects;
|
||||||
|
|
||||||
|
namespace Marathon.Application.Abstractions;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Repository for <see cref="Event"/> domain entities.
|
||||||
|
/// </summary>
|
||||||
|
public interface IEventRepository : IRepository<EventId, Event>
|
||||||
|
{
|
||||||
|
Task<IReadOnlyList<Event>> ListByDateRangeAsync(DateRange range, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Date-range + sport-filtered query pushed to the database. Replaces the
|
||||||
|
/// "load the whole date range then filter sports in memory" path on the list
|
||||||
|
/// pages. Locale-sensitive search and sort remain a service-layer concern.
|
||||||
|
/// </summary>
|
||||||
|
Task<IReadOnlyList<Event>> QueryAsync(EventQuery query, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Batched point-lookup: loads many events in a single query, keyed by
|
||||||
|
/// <see cref="EventId"/>. Missing ids are simply absent from the dictionary.
|
||||||
|
/// Replaces per-id <see cref="IRepository{TKey,TEntity}.GetAsync"/> loops (N+1).
|
||||||
|
/// </summary>
|
||||||
|
Task<IReadOnlyDictionary<EventId, Event>> GetManyAsync(
|
||||||
|
IReadOnlyCollection<EventId> ids,
|
||||||
|
CancellationToken ct = default);
|
||||||
|
|
||||||
|
Task<IReadOnlyList<Event>> ListBySportAsync(SportCode sport, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>Server-side total event count (dashboard summary).</summary>
|
||||||
|
Task<int> CountAsync(CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Distinct sport codes across the events table. Projects in the database
|
||||||
|
/// rather than materialising every <see cref="Event"/> on the client.
|
||||||
|
/// </summary>
|
||||||
|
Task<IReadOnlyList<int>> ListDistinctSportCodesAsync(CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Distinct ISO-2 country codes across the events table. Projects in the
|
||||||
|
/// database rather than materialising every <see cref="Event"/>.
|
||||||
|
/// </summary>
|
||||||
|
Task<IReadOnlyList<string>> ListDistinctCountryCodesAsync(CancellationToken ct = default);
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
using Marathon.Application.Storage;
|
||||||
|
|
||||||
|
namespace Marathon.Application.Abstractions;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Exports odds snapshots to an Excel file matching the customer's wide-column specification.
|
||||||
|
/// </summary>
|
||||||
|
public interface IExcelExporter
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Exports snapshots for the given date range to an XLSX file.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="range">The inclusive date range to export.</param>
|
||||||
|
/// <param name="kind">Which snapshots to include: pre-match, live, or combined.</param>
|
||||||
|
/// <param name="outputPath">
|
||||||
|
/// Directory where the file will be written. The filename is auto-generated as
|
||||||
|
/// <c>Marathon_yyyy-MM-dd_to_yyyy-MM-dd.xlsx</c>.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="ct">Cancellation token.</param>
|
||||||
|
/// <returns>The full path of the created file.</returns>
|
||||||
|
Task<string> ExportAsync(DateRange range, ExportKind kind, string outputPath, CancellationToken ct = default);
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
using Marathon.Domain.Enums;
|
||||||
|
|
||||||
|
namespace Marathon.Application.Abstractions;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A ready-to-deliver anomaly alert payload, shaped by
|
||||||
|
/// <see cref="UseCases.GetPendingAnomalyNotificationsUseCase"/> so a sink only has to
|
||||||
|
/// format and transmit it.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record AnomalyNotification(
|
||||||
|
Guid AnomalyId,
|
||||||
|
string EventTitle,
|
||||||
|
AnomalyKind Kind,
|
||||||
|
decimal Score,
|
||||||
|
DateTimeOffset DetectedAt);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A channel that delivers anomaly alerts (e.g. Telegram; future: email / Discord).
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Implementations must be resilient: a failure to deliver one notification should be
|
||||||
|
/// logged and swallowed, never thrown into the dispatcher loop. A sink that is not
|
||||||
|
/// configured (e.g. missing credentials) should no-op with a warning.
|
||||||
|
/// </remarks>
|
||||||
|
public interface INotificationSink
|
||||||
|
{
|
||||||
|
Task SendAsync(AnomalyNotification notification, CancellationToken ct);
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
using Marathon.Domain.Entities;
|
||||||
|
using Marathon.Domain.Enums;
|
||||||
|
using Marathon.Domain.ValueObjects;
|
||||||
|
|
||||||
|
namespace Marathon.Application.Abstractions;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Scrapes upcoming events, live odds snapshots, and completed event results
|
||||||
|
/// from a bookmaker's public web interface.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The infrastructure implementation (<c>MarathonbetScraper</c>) uses
|
||||||
|
/// HttpClient + AngleSharp + Polly. All methods are non-blocking and
|
||||||
|
/// honour the caller's <see cref="CancellationToken"/>.
|
||||||
|
/// </remarks>
|
||||||
|
public interface IOddsScraper
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the list of upcoming (pre-match) events, optionally filtered to one sport.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="sportFilter">When non-null, restricts results to the given sport code.</param>
|
||||||
|
/// <param name="ct">Cancellation token.</param>
|
||||||
|
Task<IReadOnlyList<Event>> ScrapeUpcomingAsync(
|
||||||
|
SportCode? sportFilter,
|
||||||
|
CancellationToken ct);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the list of currently-live events parsed from <c>/su/live</c>.
|
||||||
|
/// Each returned <see cref="Event"/> has its <see cref="Event.EventPath"/>
|
||||||
|
/// populated so the caller can immediately fetch its odds snapshot.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="ct">Cancellation token.</param>
|
||||||
|
Task<IReadOnlyList<Event>> ScrapeLiveAsync(CancellationToken ct);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fetches a full odds snapshot (all markets) for a single event.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="eventInfo">
|
||||||
|
/// The event to scrape — its <see cref="Event.EventPath"/> drives URL construction.
|
||||||
|
/// When the path is null (legacy row), the scraper falls back to the numeric event ID.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="source">Whether this is a pre-match or live scrape.</param>
|
||||||
|
/// <param name="ct">Cancellation token.</param>
|
||||||
|
Task<OddsSnapshot> ScrapeEventOddsAsync(
|
||||||
|
Event eventInfo,
|
||||||
|
OddsSource source,
|
||||||
|
CancellationToken ct);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fetches the event-detail page for a single event and extracts its final
|
||||||
|
/// result if and only if the bookmaker has flagged the match as complete
|
||||||
|
/// (<c>eventJsonInfo.matchIsComplete = true</c>).
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// marathonbet.by has no public results archive endpoint
|
||||||
|
/// (<c>/su/results</c> → 404), so results are harvested per-event by
|
||||||
|
/// re-fetching the same event-detail HTML used for odds scraping and
|
||||||
|
/// parsing the embedded <c>eventJsonInfo</c> JSON.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="eventInfo">
|
||||||
|
/// The event to query — its <see cref="Event.EventPath"/> drives URL
|
||||||
|
/// construction (with the numeric ID as a best-effort fallback).
|
||||||
|
/// </param>
|
||||||
|
/// <param name="ct">Cancellation token.</param>
|
||||||
|
/// <returns>
|
||||||
|
/// An <see cref="EventResult"/> when the match is complete and the score
|
||||||
|
/// could be parsed, <c>null</c> when the match is still in-progress or
|
||||||
|
/// the score string is unrecognised.
|
||||||
|
/// </returns>
|
||||||
|
Task<EventResult?> ScrapeEventResultAsync(
|
||||||
|
Event eventInfo,
|
||||||
|
CancellationToken ct);
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
using Marathon.Domain.Entities;
|
||||||
|
using Marathon.Domain.Enums;
|
||||||
|
|
||||||
|
namespace Marathon.Application.Abstractions;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Repository for <see cref="PaperBet"/> entities — the forward-test ledger written
|
||||||
|
/// by the paper-trading worker.
|
||||||
|
/// </summary>
|
||||||
|
public interface IPaperBetRepository : IRepository<Guid, PaperBet>
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Paper bets in a given settlement state — <see cref="BetOutcome.Pending"/> is
|
||||||
|
/// the open set the settler scans each cycle.
|
||||||
|
/// </summary>
|
||||||
|
Task<IReadOnlyList<PaperBet>> ListByOutcomeAsync(BetOutcome outcome, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The subset of <paramref name="anomalyIds"/> that already have a paper bet —
|
||||||
|
/// lets the opener skip anomalies it has already forward-tested (one bet per anomaly).
|
||||||
|
/// </summary>
|
||||||
|
Task<IReadOnlySet<Guid>> GetExistingAnomalyIdsAsync(
|
||||||
|
IReadOnlyCollection<Guid> anomalyIds, CancellationToken ct = default);
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
using Marathon.Application.Storage;
|
||||||
|
using Marathon.Domain.Entities;
|
||||||
|
using Marathon.Domain.Enums;
|
||||||
|
using Marathon.Domain.ValueObjects;
|
||||||
|
|
||||||
|
namespace Marathon.Application.Abstractions;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Repository for <see cref="PlacedBet"/> domain entities — the user-tracked
|
||||||
|
/// betting journal.
|
||||||
|
/// </summary>
|
||||||
|
public interface IPlacedBetRepository : IRepository<Guid, PlacedBet>
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Bets matching <paramref name="outcome"/>. Used by the resolver use case
|
||||||
|
/// to scan only <see cref="BetOutcome.Pending"/> rows on each pass.
|
||||||
|
/// </summary>
|
||||||
|
Task<IReadOnlyList<PlacedBet>> ListByOutcomeAsync(BetOutcome outcome, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Bets whose <see cref="PlacedBet.PlacedAt"/> falls within
|
||||||
|
/// <paramref name="range"/>. Used by the journal page when the user filters
|
||||||
|
/// by date.
|
||||||
|
/// </summary>
|
||||||
|
Task<IReadOnlyList<PlacedBet>> ListByDateRangeAsync(DateRange range, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Every bet recorded against <paramref name="eventId"/>. Used by the event
|
||||||
|
/// detail page to show "you have N bets on this match".
|
||||||
|
/// </summary>
|
||||||
|
Task<IReadOnlyList<PlacedBet>> ListByEventAsync(EventId eventId, CancellationToken ct = default);
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
namespace Marathon.Application.Abstractions;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Generic repository abstraction providing CRUD operations for a domain entity.
|
||||||
|
/// </summary>
|
||||||
|
/// <typeparam name="TKey">The type of the entity's primary key.</typeparam>
|
||||||
|
/// <typeparam name="TEntity">The domain entity type.</typeparam>
|
||||||
|
public interface IRepository<TKey, TEntity>
|
||||||
|
where TKey : notnull
|
||||||
|
where TEntity : class
|
||||||
|
{
|
||||||
|
Task<TEntity?> GetAsync(TKey key, CancellationToken ct = default);
|
||||||
|
|
||||||
|
Task<IReadOnlyList<TEntity>> ListAsync(CancellationToken ct = default);
|
||||||
|
|
||||||
|
Task AddAsync(TEntity entity, CancellationToken ct = default);
|
||||||
|
|
||||||
|
Task UpdateAsync(TEntity entity, CancellationToken ct = default);
|
||||||
|
|
||||||
|
Task DeleteAsync(TKey key, CancellationToken ct = default);
|
||||||
|
|
||||||
|
Task SaveChangesAsync(CancellationToken ct = default);
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
using Marathon.Domain.Entities;
|
||||||
|
using Marathon.Domain.ValueObjects;
|
||||||
|
|
||||||
|
namespace Marathon.Application.Abstractions;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Repository for <see cref="EventResult"/> domain entities.
|
||||||
|
/// </summary>
|
||||||
|
public interface IResultRepository : IRepository<EventId, EventResult>
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Batched point-lookup: loads many results in a single query, keyed by
|
||||||
|
/// <see cref="EventId"/>. Missing ids are simply absent from the dictionary.
|
||||||
|
/// Replaces per-id <see cref="IRepository{TKey,TEntity}.GetAsync"/> loops (N+1).
|
||||||
|
/// </summary>
|
||||||
|
Task<IReadOnlyDictionary<EventId, EventResult>> GetManyAsync(
|
||||||
|
IReadOnlyCollection<EventId> ids,
|
||||||
|
CancellationToken ct = default);
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
using Marathon.Domain.Backtesting;
|
||||||
|
|
||||||
|
namespace Marathon.Application.Abstractions;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Repository for <see cref="SavedStrategy"/> presets — the user's named,
|
||||||
|
/// reusable backtest staking configurations. <see cref="IRepository{TKey,TEntity}.ListAsync"/>
|
||||||
|
/// returns them name-ascending for a stable picker order.
|
||||||
|
/// </summary>
|
||||||
|
public interface ISavedStrategyRepository : IRepository<Guid, SavedStrategy>
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The preset whose (trimmed) name matches <paramref name="name"/>, or null.
|
||||||
|
/// Used by the save flow to upsert by name rather than create a duplicate.
|
||||||
|
/// </summary>
|
||||||
|
Task<SavedStrategy?> GetByNameAsync(string name, CancellationToken ct = default);
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
using Marathon.Domain.Entities;
|
||||||
|
using Marathon.Domain.ValueObjects;
|
||||||
|
|
||||||
|
namespace Marathon.Application.Abstractions;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Repository for <see cref="OddsSnapshot"/> domain entities.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Snapshots are append-only and identified by the composite (EventId, CapturedAt)
|
||||||
|
/// rather than a surrogate key, so this contract intentionally does NOT extend
|
||||||
|
/// <see cref="IRepository{TKey, TEntity}"/> — point lookup by Guid would be
|
||||||
|
/// meaningless. Use <see cref="ListByEventAsync"/> for retrieval.
|
||||||
|
/// </remarks>
|
||||||
|
public interface ISnapshotRepository
|
||||||
|
{
|
||||||
|
Task<IReadOnlyList<OddsSnapshot>> ListAsync(CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Server-side count of snapshots captured at or after <paramref name="since"/>.
|
||||||
|
/// Backs the dashboard "snapshots today" stat without materialising rows.
|
||||||
|
/// </summary>
|
||||||
|
Task<int> CountSinceAsync(DateTimeOffset since, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The most recent snapshot capture time across all events, or <c>null</c> when the
|
||||||
|
/// store is empty. Backs the pipeline-health freshness indicator.
|
||||||
|
/// </summary>
|
||||||
|
Task<DateTimeOffset?> GetLatestCapturedAtAsync(CancellationToken ct = default);
|
||||||
|
|
||||||
|
Task<IReadOnlyList<OddsSnapshot>> ListByEventAsync(
|
||||||
|
EventId eventId,
|
||||||
|
DateTimeOffset from,
|
||||||
|
DateTimeOffset to,
|
||||||
|
CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Batched companion to <see cref="ListByEventAsync"/>: loads snapshots
|
||||||
|
/// for many events in a single query and groups by <see cref="EventId"/>.
|
||||||
|
/// Events with no snapshots in range get an empty list in the result.
|
||||||
|
/// </summary>
|
||||||
|
Task<IReadOnlyDictionary<EventId, IReadOnlyList<OddsSnapshot>>> ListByEventsAsync(
|
||||||
|
IReadOnlyCollection<EventId> eventIds,
|
||||||
|
DateTimeOffset from,
|
||||||
|
DateTimeOffset to,
|
||||||
|
CancellationToken ct = default);
|
||||||
|
|
||||||
|
Task AddAsync(OddsSnapshot entity, CancellationToken ct = default);
|
||||||
|
|
||||||
|
Task SaveChangesAsync(CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the latest pre-match snapshot for <paramref name="eventId"/> whose
|
||||||
|
/// <see cref="OddsSnapshot.CapturedAt"/> is at or before
|
||||||
|
/// <paramref name="atOrBefore"/>, or <c>null</c> if none exists. Used by the
|
||||||
|
/// bet-journal use case as the "closing line" reference for CLV.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Pushes the ORDER BY + LIMIT 1 down to SQLite so we do not materialise
|
||||||
|
/// every snapshot in the 30-day pre-match window just to pick one.
|
||||||
|
/// </remarks>
|
||||||
|
Task<OddsSnapshot?> GetLatestPreMatchAsync(
|
||||||
|
EventId eventId,
|
||||||
|
DateTimeOffset atOrBefore,
|
||||||
|
CancellationToken ct = default);
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
using Marathon.Application.UseCases;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
|
namespace Marathon.Application;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// DI registration helpers for the Marathon.Application layer.
|
||||||
|
/// Call <see cref="AddMarathonApplication"/> from the composition root (host or
|
||||||
|
/// <c>InfrastructureModule</c>).
|
||||||
|
/// </summary>
|
||||||
|
public static class ApplicationModule
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Registers all Application-layer use cases with <c>Scoped</c> lifetime.
|
||||||
|
/// Use cases are scoped so that each background-service cycle or UI request
|
||||||
|
/// gets a fresh unit-of-work from its own DI scope.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// No <see cref="Microsoft.Extensions.Configuration.IConfiguration"/> is
|
||||||
|
/// required here — the Application layer has no direct configuration bindings.
|
||||||
|
/// Infrastructure and UI layers bind their own options against the shared
|
||||||
|
/// JSON sections.
|
||||||
|
/// </remarks>
|
||||||
|
public static IServiceCollection AddMarathonApplication(this IServiceCollection services)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(services);
|
||||||
|
|
||||||
|
services.AddScoped<PullUpcomingEventsUseCase>();
|
||||||
|
services.AddScoped<PullLiveOddsUseCase>();
|
||||||
|
services.AddScoped<PullResultsUseCase>();
|
||||||
|
services.AddScoped<ExportToExcelUseCase>();
|
||||||
|
services.AddScoped<ExportToCsvUseCase>();
|
||||||
|
services.AddScoped<DetectAnomaliesUseCase>();
|
||||||
|
services.AddScoped<EvaluateAnomalyOutcomesUseCase>();
|
||||||
|
services.AddScoped<GetPendingAnomalyNotificationsUseCase>();
|
||||||
|
|
||||||
|
services.AddScoped<RecordPlacedBetUseCase>();
|
||||||
|
services.AddScoped<ResolvePendingBetsUseCase>();
|
||||||
|
services.AddScoped<BuildBetJournalReportUseCase>();
|
||||||
|
services.AddScoped<DeletePlacedBetUseCase>();
|
||||||
|
services.AddScoped<UpdatePlacedBetUseCase>();
|
||||||
|
|
||||||
|
services.AddScoped<RunBacktestUseCase>();
|
||||||
|
services.AddScoped<SaveStrategyUseCase>();
|
||||||
|
services.AddScoped<DeleteStrategyUseCase>();
|
||||||
|
services.AddScoped<CompareStrategiesUseCase>();
|
||||||
|
|
||||||
|
services.AddScoped<OpenPaperBetsUseCase>();
|
||||||
|
services.AddScoped<SettlePaperBetsUseCase>();
|
||||||
|
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
using Marathon.Domain.Entities;
|
||||||
|
using Marathon.Domain.Enums;
|
||||||
|
|
||||||
|
namespace Marathon.Application.Betting;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Aggregate report on the user's bet-tracking journal — totals, P&L, and
|
||||||
|
/// per-bet CLV. Consumed by the Journal page; built by
|
||||||
|
/// <see cref="UseCases.BuildBetJournalReportUseCase"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="Stats">Roll-up of stake / profit / hit rate / CLV across all bets in scope.</param>
|
||||||
|
/// <param name="Bets">
|
||||||
|
/// Every bet paired with its computed CLV (null when no closing snapshot was
|
||||||
|
/// available). Ordered most-recent <see cref="PlacedBet.PlacedAt"/> first.
|
||||||
|
/// </param>
|
||||||
|
public sealed record BetJournalReport(
|
||||||
|
BetJournalStats Stats,
|
||||||
|
IReadOnlyList<BetJournalRow> Bets);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One row in the journal — a domain <see cref="PlacedBet"/> plus the CLV
|
||||||
|
/// computed against the closing pre-match snapshot.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="Bet">The domain bet exactly as persisted.</param>
|
||||||
|
/// <param name="ClvProbabilityDelta">
|
||||||
|
/// Closing-line value as an implied-probability delta in roughly [-1, 1].
|
||||||
|
/// Positive means the user took a better price than the closing line; null
|
||||||
|
/// when no matching bet existed in the closing snapshot.
|
||||||
|
/// </param>
|
||||||
|
public sealed record BetJournalRow(
|
||||||
|
PlacedBet Bet,
|
||||||
|
decimal? ClvProbabilityDelta);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Aggregate statistics across a set of <see cref="PlacedBet"/>.
|
||||||
|
/// All money values share the user's currency — the domain does not encode one.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="TotalBets">Every bet in scope, regardless of outcome.</param>
|
||||||
|
/// <param name="PendingCount">Bets still awaiting settlement.</param>
|
||||||
|
/// <param name="WonCount">Settled wins.</param>
|
||||||
|
/// <param name="LostCount">Settled losses.</param>
|
||||||
|
/// <param name="VoidCount">Settled pushes / void grades.</param>
|
||||||
|
/// <param name="TotalStaked">
|
||||||
|
/// Turnover that contributes to ROI: sum of <see cref="PlacedBet.Stake"/> across
|
||||||
|
/// <b>Won and Lost</b> bets only. Void (push) and Pending bets are excluded — a
|
||||||
|
/// returned stake is not real turnover and counting it would dilute ROI.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="TotalReturned">
|
||||||
|
/// Sum of <see cref="PlacedBet.GrossReturn"/> across the same Won + Lost subset
|
||||||
|
/// that feeds <see cref="TotalStaked"/>.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="NetProfit"><c>TotalReturned − TotalStaked</c>.</param>
|
||||||
|
/// <param name="RoiPercent">
|
||||||
|
/// <c>NetProfit / TotalStaked × 100</c>. Null when no bets have resolved yet.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="StrikeRatePercent">
|
||||||
|
/// <c>WonCount / (WonCount + LostCount) × 100</c> — excludes voids and pendings.
|
||||||
|
/// Null when no settled win/loss exists yet.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="AverageClvProbabilityDelta">
|
||||||
|
/// Mean CLV across bets where CLV was computable. Null when no comparable
|
||||||
|
/// closing snapshot was available for any bet.
|
||||||
|
/// </param>
|
||||||
|
public sealed record BetJournalStats(
|
||||||
|
int TotalBets,
|
||||||
|
int PendingCount,
|
||||||
|
int WonCount,
|
||||||
|
int LostCount,
|
||||||
|
int VoidCount,
|
||||||
|
decimal TotalStaked,
|
||||||
|
decimal TotalReturned,
|
||||||
|
decimal NetProfit,
|
||||||
|
decimal? RoiPercent,
|
||||||
|
decimal? StrikeRatePercent,
|
||||||
|
decimal? AverageClvProbabilityDelta)
|
||||||
|
{
|
||||||
|
/// <summary>Convenience: WonCount + LostCount + VoidCount.</summary>
|
||||||
|
public int ResolvedCount => WonCount + LostCount + VoidCount;
|
||||||
|
|
||||||
|
public static BetJournalStats Empty { get; } = new(
|
||||||
|
TotalBets: 0,
|
||||||
|
PendingCount: 0,
|
||||||
|
WonCount: 0,
|
||||||
|
LostCount: 0,
|
||||||
|
VoidCount: 0,
|
||||||
|
TotalStaked: 0m,
|
||||||
|
TotalReturned: 0m,
|
||||||
|
NetProfit: 0m,
|
||||||
|
RoiPercent: null,
|
||||||
|
StrikeRatePercent: null,
|
||||||
|
AverageClvProbabilityDelta: null);
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
using Marathon.Domain.Entities;
|
||||||
|
using Marathon.Domain.ValueObjects;
|
||||||
|
|
||||||
|
namespace Marathon.Application.Betting;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pure helper that computes Closing Line Value (CLV) for a placed bet.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// CLV measures how much better (or worse) the rate the user took was compared
|
||||||
|
/// with the bookmaker's last pre-match price on the same selection. It is the
|
||||||
|
/// single best long-run indicator of betting skill — positive CLV correlates
|
||||||
|
/// with positive expected value regardless of any individual bet's outcome.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Formula (implied-probability delta):
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item>Taken implied probability: <c>p_t = 1 / takenRate</c></item>
|
||||||
|
/// <item>Closing implied probability: <c>p_c = 1 / closeRate</c></item>
|
||||||
|
/// <item><c>CLV = p_c − p_t</c></item>
|
||||||
|
/// </list>
|
||||||
|
/// Positive CLV means the closing price implied higher probability for the
|
||||||
|
/// selection than the price the user took — i.e. the line moved in the user's
|
||||||
|
/// favour after they placed the bet.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Returns <c>null</c> when no matching bet (same Scope / Type / Side / Value)
|
||||||
|
/// can be found in the closing snapshot — typically because the market closed
|
||||||
|
/// before the bookmaker exposed a comparable line, or the snapshot store has
|
||||||
|
/// gaps. UI consumers must distinguish "no data" from "0% CLV".
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public static class ClosingLineValueCalculator
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Computes CLV (implied-probability delta) given the rate the user took
|
||||||
|
/// and the rate present in the closing pre-match snapshot for the same
|
||||||
|
/// selection. Both must be positive — invariants on <see cref="OddsRate"/>
|
||||||
|
/// already guarantee this for inputs sourced from the domain.
|
||||||
|
/// </summary>
|
||||||
|
public static decimal Compute(decimal takenRate, decimal closingRate)
|
||||||
|
{
|
||||||
|
if (takenRate <= 0m)
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(takenRate), takenRate, "Must be positive.");
|
||||||
|
if (closingRate <= 0m)
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(closingRate), closingRate, "Must be positive.");
|
||||||
|
|
||||||
|
var takenProb = 1m / takenRate;
|
||||||
|
var closingProb = 1m / closingRate;
|
||||||
|
|
||||||
|
// Round to 6 decimals — beyond that is noise from the round-trip.
|
||||||
|
return Math.Round(closingProb - takenProb, 6);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Convenience overload: finds the matching <see cref="Bet"/> in
|
||||||
|
/// <paramref name="closingSnapshot"/> by Scope / Type / Side / Value, then
|
||||||
|
/// computes CLV against <paramref name="takenRate"/>. Returns <c>null</c>
|
||||||
|
/// when no comparable bet is present.
|
||||||
|
/// </summary>
|
||||||
|
public static decimal? TryCompute(
|
||||||
|
decimal takenRate,
|
||||||
|
Bet placedSelection,
|
||||||
|
OddsSnapshot? closingSnapshot)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(placedSelection);
|
||||||
|
if (closingSnapshot is null) return null;
|
||||||
|
|
||||||
|
var match = closingSnapshot.Bets.FirstOrDefault(b =>
|
||||||
|
b.Scope.Equals(placedSelection.Scope) &&
|
||||||
|
b.Type == placedSelection.Type &&
|
||||||
|
b.Side == placedSelection.Side &&
|
||||||
|
NullableValuesEqual(b.Value, placedSelection.Value));
|
||||||
|
|
||||||
|
return match is null ? null : Compute(takenRate, match.Rate.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool NullableValuesEqual(OddsValue? a, OddsValue? b)
|
||||||
|
{
|
||||||
|
if (a is null && b is null) return true;
|
||||||
|
if (a is null || b is null) return false;
|
||||||
|
return a.Value == b.Value;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
namespace Marathon.Application.Configuration;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Strongly typed options for the anomaly-detection subsystem.
|
||||||
|
/// Bound from the <c>Anomaly</c> section of <c>appsettings.json</c>.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class AnomalyOptions
|
||||||
|
{
|
||||||
|
/// <summary>Configuration section key.</summary>
|
||||||
|
public const string SectionName = "Anomaly";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Minimum gap between adjacent live snapshots, in seconds, to classify as
|
||||||
|
/// a bookmaker suspension. Default: 60 s.
|
||||||
|
/// </summary>
|
||||||
|
public int SuspensionGapSeconds { get; init; } = 60;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Minimum normalised implied-probability delta required for the post-suspension
|
||||||
|
/// odds change to qualify as a flip. Must be in (0, 1). Default: 0.30.
|
||||||
|
/// </summary>
|
||||||
|
public decimal OddsFlipThreshold { get; init; } = 0.30m;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Minimum number of live snapshots an event must have before detection runs.
|
||||||
|
/// Default: 3. Must be at least 2 (one pair).
|
||||||
|
/// </summary>
|
||||||
|
public int MinSnapshotCount { get; init; } = 3;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// How long the <c>AnomalyDetectionPoller</c> sleeps between detection cycles,
|
||||||
|
/// in seconds. Default: 60 s.
|
||||||
|
/// </summary>
|
||||||
|
public int DetectionIntervalSeconds { get; init; } = 60;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Trailing window, in seconds, over which the steam-move detector measures a
|
||||||
|
/// continuous one-directional probability drift. Default: 120 s.
|
||||||
|
/// </summary>
|
||||||
|
public int SteamMoveWindowSeconds { get; init; } = 120;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Minimum one-directional normalised implied-probability rise within the window
|
||||||
|
/// to flag a steam move. Must be in (0, 1). Default: 0.20 (20 percentage points).
|
||||||
|
/// </summary>
|
||||||
|
public decimal SteamMoveDriftThreshold { get; init; } = 0.20m;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Maximum normalised implied-probability change across a suspension for it to count
|
||||||
|
/// as a "freeze" (line resumed essentially unchanged). Must be in (0, 1).
|
||||||
|
/// Default: 0.05 (5 percentage points).
|
||||||
|
/// </summary>
|
||||||
|
public decimal SuspensionFreezeThreshold { get; init; } = 0.05m;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Trailing window, in seconds, over which the overround-compression detector
|
||||||
|
/// measures a continuous margin drop. Default: 120 s.
|
||||||
|
/// </summary>
|
||||||
|
public int OverroundWindowSeconds { get; init; } = 120;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Minimum drop in the bookmaker's overround (raw implied-probability sum) within the
|
||||||
|
/// window to flag a compression. Must be in (0, 1). Default: 0.02 (2 margin points).
|
||||||
|
/// </summary>
|
||||||
|
public decimal OverroundCompressionThreshold { get; init; } = 0.02m;
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
namespace Marathon.Application.Configuration;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Application-layer view of the scraping concurrency knobs.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Bound from the same <c>Scraping</c> appsettings section as
|
||||||
|
/// <c>Marathon.Infrastructure.Configuration.ScrapingOptions</c> — but only the
|
||||||
|
/// fields the use cases need to schedule fan-out. Keeping a separate Application
|
||||||
|
/// type avoids leaking the Infrastructure namespace into use-case code.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class ScrapingThrottle
|
||||||
|
{
|
||||||
|
public const string SectionName = "Scraping";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Maximum number of in-flight HTTP requests the scraper is allowed to
|
||||||
|
/// issue concurrently. Use cases use this as the
|
||||||
|
/// <see cref="ParallelOptions.MaxDegreeOfParallelism"/> for batch fan-out.
|
||||||
|
/// The bookmaker rate limiter still throttles to <c>RequestsPerSecond</c>
|
||||||
|
/// underneath this value.
|
||||||
|
/// </summary>
|
||||||
|
public int MaxConcurrentRequests { get; init; } = 4;
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace Marathon.Application.Export;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Minimal RFC 4180 CSV writer — escapes fields and joins rows with CRLF. Pure and
|
||||||
|
/// allocation-light; used by <see cref="UseCases.ExportToCsvUseCase"/>.
|
||||||
|
/// </summary>
|
||||||
|
public static class Csv
|
||||||
|
{
|
||||||
|
private static readonly char[] MustQuote = { ',', '"', '\r', '\n' };
|
||||||
|
|
||||||
|
/// <summary>Builds a CSV document from a header row plus data rows (CRLF endings).</summary>
|
||||||
|
public static string Document(IReadOnlyList<string> header, IEnumerable<IReadOnlyList<string>> rows)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(header);
|
||||||
|
ArgumentNullException.ThrowIfNull(rows);
|
||||||
|
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
AppendLine(sb, header);
|
||||||
|
foreach (var row in rows)
|
||||||
|
AppendLine(sb, row);
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AppendLine(StringBuilder sb, IReadOnlyList<string> fields)
|
||||||
|
{
|
||||||
|
for (var i = 0; i < fields.Count; i++)
|
||||||
|
{
|
||||||
|
if (i > 0) sb.Append(',');
|
||||||
|
sb.Append(Escape(fields[i]));
|
||||||
|
}
|
||||||
|
sb.Append("\r\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Quotes a field when it contains a comma, double-quote, CR or LF; inner quotes are
|
||||||
|
/// doubled. Null is treated as empty.
|
||||||
|
/// </summary>
|
||||||
|
public static string Escape(string? field)
|
||||||
|
{
|
||||||
|
var value = field ?? string.Empty;
|
||||||
|
if (value.IndexOfAny(MustQuote) < 0)
|
||||||
|
return value;
|
||||||
|
return "\"" + value.Replace("\"", "\"\"") + "\"";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Defuses spreadsheet formula / DDE injection: when a cell would start with a formula
|
||||||
|
/// trigger (<c>= + - @</c>, tab or CR) it is prefixed with an apostrophe so Excel /
|
||||||
|
/// LibreOffice render it as text. Apply to USER-supplied or SCRAPED text fields (notes,
|
||||||
|
/// event titles) before they enter a row — numeric/date cells your own code formats are
|
||||||
|
/// trusted and don't need it (keeping them numeric for analysis).
|
||||||
|
/// </summary>
|
||||||
|
public static string NeutralizeFormula(string? field)
|
||||||
|
{
|
||||||
|
var value = field ?? string.Empty;
|
||||||
|
return value.Length > 0 && value[0] is '=' or '+' or '-' or '@' or '\t' or '\r'
|
||||||
|
? "'" + value
|
||||||
|
: value;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
// Alias Microsoft.Extensions.Logging.EventId to avoid name conflict with
|
||||||
|
// Marathon.Domain.ValueObjects.EventId throughout the Application layer.
|
||||||
|
global using LogEventId = Microsoft.Extensions.Logging.EventId;
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Options" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\Marathon.Domain\Marathon.Domain.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
using Marathon.Domain.AnomalyDetection;
|
||||||
|
using DomainEventId = Marathon.Domain.ValueObjects.EventId;
|
||||||
|
|
||||||
|
namespace Marathon.Application.Reporting;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Aggregate report answering the question "is the SuspensionFlip detector right?".
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="TotalAnomalies">Every persisted anomaly considered by this report.</param>
|
||||||
|
/// <param name="ResolvedCount">Anomalies whose source events now have a final result.</param>
|
||||||
|
/// <param name="UnresolvedCount">Anomalies still waiting for an event result.</param>
|
||||||
|
/// <param name="HitCount">Resolved anomalies where the post-flip favourite won.</param>
|
||||||
|
/// <param name="MissCount">Resolved anomalies where the post-flip favourite lost.</param>
|
||||||
|
/// <param name="HitRate">
|
||||||
|
/// <see cref="HitCount"/> ÷ <see cref="ResolvedCount"/> in [0, 1]. Null when no anomalies
|
||||||
|
/// have been resolved yet — the UI must distinguish "0% hit rate" from "no data".
|
||||||
|
/// </param>
|
||||||
|
/// <param name="BySeverity">Breakdown by Low / Medium / High severity buckets.</param>
|
||||||
|
/// <param name="BySport">Breakdown by sport code.</param>
|
||||||
|
/// <param name="ByScoreBin">Breakdown across [0.30, 0.40), [0.40, 0.50), …, [0.90, 1.00].</param>
|
||||||
|
/// <param name="ByKind">
|
||||||
|
/// Breakdown by detector kind. Only directional kinds (SuspensionFlip, SteamMove) ever
|
||||||
|
/// resolve to a hit/miss, so non-directional kinds simply don't appear here.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="Resolved">All resolved anomalies, newest first. Drives the drill-down table.</param>
|
||||||
|
/// <param name="Unresolved">All unresolved anomalies, newest first.</param>
|
||||||
|
/// <param name="EventTitles">
|
||||||
|
/// Pre-shaped <c>"Side1Name vs Side2Name"</c> strings keyed by event id. Carried
|
||||||
|
/// alongside the report so UI projections do not need a second pass over
|
||||||
|
/// <c>IEventRepository</c> — every event in <see cref="Resolved"/> /
|
||||||
|
/// <see cref="Unresolved"/> appears as a key. Missing events (e.g. pruned) are
|
||||||
|
/// absent; consumers fall back to <c>EventId.Value</c>.
|
||||||
|
/// </param>
|
||||||
|
public sealed record AnomalyOutcomeReport(
|
||||||
|
int TotalAnomalies,
|
||||||
|
int ResolvedCount,
|
||||||
|
int UnresolvedCount,
|
||||||
|
int HitCount,
|
||||||
|
int MissCount,
|
||||||
|
decimal? HitRate,
|
||||||
|
IReadOnlyList<OutcomeBucket> BySeverity,
|
||||||
|
IReadOnlyList<OutcomeBucket> BySport,
|
||||||
|
IReadOnlyList<OutcomeBucket> ByScoreBin,
|
||||||
|
IReadOnlyList<OutcomeBucket> ByKind,
|
||||||
|
IReadOnlyList<ResolvedAnomaly> Resolved,
|
||||||
|
IReadOnlyList<ResolvedAnomaly> Unresolved,
|
||||||
|
IReadOnlyDictionary<DomainEventId, string> EventTitles);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One row in a breakdown table — e.g. "High severity", "Tennis", "[0.60, 0.70)".
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="Key">
|
||||||
|
/// Stable, culture-invariant identifier used by the UI to localise the label
|
||||||
|
/// (e.g. <c>"Severity.High"</c>, <c>"Sport.22723"</c>, <c>"Bin.0.60-0.70"</c>).
|
||||||
|
/// </param>
|
||||||
|
/// <param name="Total">Resolved anomalies in this bucket.</param>
|
||||||
|
/// <param name="Hits">Subset of <see cref="Total"/> where post-flip favourite won.</param>
|
||||||
|
/// <param name="HitRate">
|
||||||
|
/// <see cref="Hits"/> ÷ <see cref="Total"/>, or null when <see cref="Total"/> is 0.
|
||||||
|
/// </param>
|
||||||
|
public sealed record OutcomeBucket(
|
||||||
|
string Key,
|
||||||
|
int Total,
|
||||||
|
int Hits,
|
||||||
|
decimal? HitRate);
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
namespace Marathon.Application.Reporting;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Canonical, culture-invariant <see cref="OutcomeBucket.Key"/> prefixes and
|
||||||
|
/// literals. Used by the use case to emit keys and by the UI to localise them
|
||||||
|
/// — both sides reference these constants so a rename can never produce silent
|
||||||
|
/// "key not found" rendering on the page.
|
||||||
|
/// </summary>
|
||||||
|
public static class OutcomeBucketKeys
|
||||||
|
{
|
||||||
|
/// <summary>Prefix for sport-grouped buckets, e.g. <c>Sport.6</c>.</summary>
|
||||||
|
public const string SportPrefix = "Sport.";
|
||||||
|
|
||||||
|
/// <summary>Prefix for score-bin buckets, e.g. <c>Bin.0.30-0.40</c>.</summary>
|
||||||
|
public const string BinPrefix = "Bin.";
|
||||||
|
|
||||||
|
/// <summary>Prefix for detector-kind buckets, e.g. <c>Kind.SteamMove</c> (the enum name).</summary>
|
||||||
|
public const string KindPrefix = "Kind.";
|
||||||
|
|
||||||
|
/// <summary>Prefix for severity buckets, e.g. <c>Severity.High</c>.</summary>
|
||||||
|
public const string SeverityPrefix = "Severity.";
|
||||||
|
|
||||||
|
public const string SeverityLow = SeverityPrefix + "Low";
|
||||||
|
public const string SeverityMedium = SeverityPrefix + "Medium";
|
||||||
|
public const string SeverityHigh = SeverityPrefix + "High";
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
namespace Marathon.Application.Storage;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// An inclusive date-time range used for querying and exporting snapshots.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record DateRange
|
||||||
|
{
|
||||||
|
public DateTimeOffset From { get; }
|
||||||
|
public DateTimeOffset To { get; }
|
||||||
|
|
||||||
|
public DateRange(DateTimeOffset from, DateTimeOffset to)
|
||||||
|
{
|
||||||
|
if (from > to)
|
||||||
|
throw new ArgumentException(
|
||||||
|
$"DateRange.From ({from:O}) must be less than or equal to DateRange.To ({to:O}).",
|
||||||
|
nameof(from));
|
||||||
|
|
||||||
|
From = from;
|
||||||
|
To = to;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
namespace Marathon.Application.Storage;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Database-pushdown query for the event list pages: an inclusive date range plus
|
||||||
|
/// an optional sport-code filter. Locale-sensitive search and sort are deliberately
|
||||||
|
/// NOT part of this contract — they stay in the service layer where Cyrillic
|
||||||
|
/// ordinal semantics are preserved (SQLite BINARY collation would change them).
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="Dates">Inclusive scheduled-at window.</param>
|
||||||
|
/// <param name="SportCodes">When non-empty, restricts to these sport codes. Null/empty = all sports.</param>
|
||||||
|
public sealed record EventQuery(
|
||||||
|
DateRange Dates,
|
||||||
|
IReadOnlyCollection<int>? SportCodes = null);
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
namespace Marathon.Application.Storage;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Controls which odds snapshots are included in an Excel export.
|
||||||
|
/// </summary>
|
||||||
|
public enum ExportKind
|
||||||
|
{
|
||||||
|
/// <summary>Include only pre-match snapshots (columns prefixed with <c>Bet_</c>).</summary>
|
||||||
|
PreMatch,
|
||||||
|
|
||||||
|
/// <summary>Include only live snapshots (columns prefixed with <c>Live_</c>).</summary>
|
||||||
|
Live,
|
||||||
|
|
||||||
|
/// <summary>Include both pre-match and live snapshots on separate sheets.</summary>
|
||||||
|
Combined,
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
namespace Marathon.Application.Storage;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Configuration options for the storage layer, bound to the <c>Storage:*</c> configuration section.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class StorageOptions
|
||||||
|
{
|
||||||
|
public const string SectionName = "Storage";
|
||||||
|
|
||||||
|
/// <summary>Path to the SQLite database file. Default: <c>./data/marathon.db</c>.</summary>
|
||||||
|
public string DatabasePath { get; set; } = "./data/marathon.db";
|
||||||
|
|
||||||
|
/// <summary>Directory where Excel exports are written. Default: <c>./exports</c>.</summary>
|
||||||
|
public string ExportDirectory { get; set; } = "./exports";
|
||||||
|
|
||||||
|
/// <summary>Number of days to retain odds snapshots before pruning. Default: 90.</summary>
|
||||||
|
public int SnapshotRetentionDays { get; set; } = 90;
|
||||||
|
}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
using Marathon.Application.Abstractions;
|
||||||
|
using Marathon.Application.Betting;
|
||||||
|
using Marathon.Domain.Entities;
|
||||||
|
using Marathon.Domain.Enums;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using DomainEventId = Marathon.Domain.ValueObjects.EventId;
|
||||||
|
|
||||||
|
namespace Marathon.Application.UseCases;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds a <see cref="BetJournalReport"/>: every persisted bet paired with its
|
||||||
|
/// Closing-Line-Value, plus aggregate <see cref="BetJournalStats"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Closing-line lookup: for each distinct event in the journal, this use case
|
||||||
|
/// queries pre-match snapshots within a window that ends at the event's
|
||||||
|
/// <see cref="Event.ScheduledAt"/> and picks the latest snapshot whose
|
||||||
|
/// <see cref="OddsSnapshot.CapturedAt"/> is still before kickoff. That snapshot
|
||||||
|
/// is the "close" for CLV purposes.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// If the snapshot store has nothing within the lookback window, the bet
|
||||||
|
/// receives a null CLV. Stats then exclude it from the average.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class BuildBetJournalReportUseCase
|
||||||
|
{
|
||||||
|
private readonly IPlacedBetRepository _bets;
|
||||||
|
private readonly IEventRepository _events;
|
||||||
|
private readonly ISnapshotRepository _snapshots;
|
||||||
|
private readonly ILogger<BuildBetJournalReportUseCase> _logger;
|
||||||
|
|
||||||
|
public BuildBetJournalReportUseCase(
|
||||||
|
IPlacedBetRepository bets,
|
||||||
|
IEventRepository events,
|
||||||
|
ISnapshotRepository snapshots,
|
||||||
|
ILogger<BuildBetJournalReportUseCase> logger)
|
||||||
|
{
|
||||||
|
_bets = bets ?? throw new ArgumentNullException(nameof(bets));
|
||||||
|
_events = events ?? throw new ArgumentNullException(nameof(events));
|
||||||
|
_snapshots = snapshots ?? throw new ArgumentNullException(nameof(snapshots));
|
||||||
|
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<BetJournalReport> ExecuteAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var bets = await _bets.ListAsync(ct).ConfigureAwait(false);
|
||||||
|
if (bets.Count == 0)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("BuildBetJournalReportUseCase: no bets — empty report");
|
||||||
|
return new BetJournalReport(BetJournalStats.Empty, Array.Empty<BetJournalRow>());
|
||||||
|
}
|
||||||
|
|
||||||
|
var distinctEventIds = bets.Select(b => b.EventId).Distinct().ToList();
|
||||||
|
|
||||||
|
// Batch the event loads (was N+1). The closing-snapshot lookup stays per-event
|
||||||
|
// because it pushes ORDER BY / LIMIT 1 down to SQLite (one indexed row each)
|
||||||
|
// and is parameterised by that event's ScheduledAt.
|
||||||
|
var events = await _events.GetManyAsync(distinctEventIds, ct).ConfigureAwait(false);
|
||||||
|
|
||||||
|
var closingByEvent = new Dictionary<DomainEventId, OddsSnapshot?>(distinctEventIds.Count);
|
||||||
|
foreach (var eventId in distinctEventIds)
|
||||||
|
{
|
||||||
|
ct.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
if (!events.TryGetValue(eventId, out var ev))
|
||||||
|
{
|
||||||
|
closingByEvent[eventId] = null;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var closing = await _snapshots
|
||||||
|
.GetLatestPreMatchAsync(eventId, ev.ScheduledAt, ct)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
closingByEvent[eventId] = closing;
|
||||||
|
}
|
||||||
|
|
||||||
|
var rows = new List<BetJournalRow>(bets.Count);
|
||||||
|
foreach (var bet in bets)
|
||||||
|
{
|
||||||
|
ct.ThrowIfCancellationRequested();
|
||||||
|
closingByEvent.TryGetValue(bet.EventId, out var closing);
|
||||||
|
|
||||||
|
var clv = ClosingLineValueCalculator.TryCompute(
|
||||||
|
takenRate: bet.Selection.Rate.Value,
|
||||||
|
placedSelection: bet.Selection,
|
||||||
|
closingSnapshot: closing);
|
||||||
|
|
||||||
|
rows.Add(new BetJournalRow(bet, clv));
|
||||||
|
}
|
||||||
|
|
||||||
|
rows.Sort((a, b) => b.Bet.PlacedAt.CompareTo(a.Bet.PlacedAt));
|
||||||
|
|
||||||
|
var stats = ComputeStats(rows);
|
||||||
|
|
||||||
|
_logger.LogInformation(
|
||||||
|
"BuildBetJournalReportUseCase: report built — {Total} bets, {Resolved} resolved, ROI={Roi:0.##}%",
|
||||||
|
stats.TotalBets, stats.ResolvedCount, stats.RoiPercent ?? 0m);
|
||||||
|
|
||||||
|
return new BetJournalReport(stats, rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static BetJournalStats ComputeStats(IReadOnlyList<BetJournalRow> rows)
|
||||||
|
{
|
||||||
|
if (rows.Count == 0) return BetJournalStats.Empty;
|
||||||
|
|
||||||
|
var pending = 0;
|
||||||
|
var won = 0;
|
||||||
|
var lost = 0;
|
||||||
|
var voided = 0;
|
||||||
|
// Industry-standard ROI excludes pushes from turnover — staking on a Void
|
||||||
|
// bet returns the stake and is functionally a no-op, so counting it as
|
||||||
|
// turnover dilutes the ROI denominator and understates the user's edge.
|
||||||
|
// Only Won + Lost contribute to TotalStaked / TotalReturned.
|
||||||
|
var totalStaked = 0m;
|
||||||
|
var totalReturned = 0m;
|
||||||
|
decimal clvSum = 0m;
|
||||||
|
var clvCount = 0;
|
||||||
|
|
||||||
|
foreach (var row in rows)
|
||||||
|
{
|
||||||
|
switch (row.Bet.Outcome)
|
||||||
|
{
|
||||||
|
case BetOutcome.Pending: pending++; break;
|
||||||
|
case BetOutcome.Won: won++; break;
|
||||||
|
case BetOutcome.Lost: lost++; break;
|
||||||
|
case BetOutcome.Void: voided++; break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (row.Bet.Outcome is BetOutcome.Won or BetOutcome.Lost)
|
||||||
|
{
|
||||||
|
totalStaked += row.Bet.Stake;
|
||||||
|
totalReturned += row.Bet.GrossReturn ?? 0m;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (row.ClvProbabilityDelta is { } clv)
|
||||||
|
{
|
||||||
|
clvSum += clv;
|
||||||
|
clvCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var netProfit = totalReturned - totalStaked;
|
||||||
|
var winLoss = won + lost;
|
||||||
|
|
||||||
|
decimal? roi = totalStaked > 0m
|
||||||
|
? Math.Round((netProfit / totalStaked) * 100m, 2)
|
||||||
|
: null;
|
||||||
|
decimal? strikeRate = winLoss > 0
|
||||||
|
? Math.Round(((decimal)won / winLoss) * 100m, 2)
|
||||||
|
: null;
|
||||||
|
// CLV inputs are already 6-decimal-rounded by ClosingLineValueCalculator;
|
||||||
|
// round the mean only at the display boundary to avoid compounding bias.
|
||||||
|
decimal? avgClv = clvCount > 0
|
||||||
|
? clvSum / clvCount
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return new BetJournalStats(
|
||||||
|
TotalBets: rows.Count,
|
||||||
|
PendingCount: pending,
|
||||||
|
WonCount: won,
|
||||||
|
LostCount: lost,
|
||||||
|
VoidCount: voided,
|
||||||
|
TotalStaked: totalStaked,
|
||||||
|
TotalReturned: totalReturned,
|
||||||
|
NetProfit: netProfit,
|
||||||
|
RoiPercent: roi,
|
||||||
|
StrikeRatePercent: strikeRate,
|
||||||
|
AverageClvProbabilityDelta: avgClv);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
using Marathon.Application.Abstractions;
|
||||||
|
using Marathon.Application.Storage;
|
||||||
|
using Marathon.Domain.Backtesting;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Marathon.Application.UseCases;
|
||||||
|
|
||||||
|
/// <summary>One saved strategy preset paired with its backtest result over a shared window.</summary>
|
||||||
|
public sealed record StrategyComparison(Guid StrategyId, string Name, BacktestResult Result);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Runs every saved strategy preset over the same anomaly window and returns their
|
||||||
|
/// backtest results side by side, so the user can see which staking configuration wins.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Delegates to <see cref="RunBacktestUseCase"/> once per preset — the anomaly set is
|
||||||
|
/// re-loaded per run, which is fine for the handful of presets a user keeps. Keeping the
|
||||||
|
/// composition at the use-case level (rather than re-implementing candidate loading) means
|
||||||
|
/// the comparison stays bug-for-bug identical to a single backtest run.
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class CompareStrategiesUseCase
|
||||||
|
{
|
||||||
|
private readonly ISavedStrategyRepository _strategies;
|
||||||
|
private readonly RunBacktestUseCase _backtest;
|
||||||
|
private readonly ILogger<CompareStrategiesUseCase> _logger;
|
||||||
|
|
||||||
|
public CompareStrategiesUseCase(
|
||||||
|
ISavedStrategyRepository strategies,
|
||||||
|
RunBacktestUseCase backtest,
|
||||||
|
ILogger<CompareStrategiesUseCase> logger)
|
||||||
|
{
|
||||||
|
_strategies = strategies ?? throw new ArgumentNullException(nameof(strategies));
|
||||||
|
_backtest = backtest ?? throw new ArgumentNullException(nameof(backtest));
|
||||||
|
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Backtests each saved preset over <paramref name="dateRange"/> (null = all graded
|
||||||
|
/// anomalies). Returns one row per preset in saved (name-ascending) order; empty when
|
||||||
|
/// the user has saved no strategies.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<IReadOnlyList<StrategyComparison>> ExecuteAsync(
|
||||||
|
DateRange? dateRange, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var presets = await _strategies.ListAsync(ct).ConfigureAwait(false);
|
||||||
|
if (presets.Count == 0)
|
||||||
|
return Array.Empty<StrategyComparison>();
|
||||||
|
|
||||||
|
var rows = new List<StrategyComparison>(presets.Count);
|
||||||
|
foreach (var preset in presets)
|
||||||
|
{
|
||||||
|
ct.ThrowIfCancellationRequested();
|
||||||
|
var result = await _backtest.ExecuteAsync(preset.Strategy, dateRange, ct).ConfigureAwait(false);
|
||||||
|
rows.Add(new StrategyComparison(preset.Id, preset.Name, result));
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation("CompareStrategiesUseCase: compared {Count} preset(s)", rows.Count);
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
using Marathon.Application.Abstractions;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Marathon.Application.UseCases;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Removes a <see cref="Marathon.Domain.Entities.PlacedBet"/> from the journal
|
||||||
|
/// by its identifier. Silent no-op when the id does not exist.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class DeletePlacedBetUseCase
|
||||||
|
{
|
||||||
|
private readonly IPlacedBetRepository _bets;
|
||||||
|
private readonly ILogger<DeletePlacedBetUseCase> _logger;
|
||||||
|
|
||||||
|
public DeletePlacedBetUseCase(
|
||||||
|
IPlacedBetRepository bets,
|
||||||
|
ILogger<DeletePlacedBetUseCase> logger)
|
||||||
|
{
|
||||||
|
_bets = bets ?? throw new ArgumentNullException(nameof(bets));
|
||||||
|
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task ExecuteAsync(Guid betId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
await _bets.DeleteAsync(betId, ct).ConfigureAwait(false);
|
||||||
|
await _bets.SaveChangesAsync(ct).ConfigureAwait(false);
|
||||||
|
_logger.LogInformation("DeletePlacedBetUseCase: removed bet {BetId}", betId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
using Marathon.Application.Abstractions;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Marathon.Application.UseCases;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Removes a saved strategy preset by id. Silent no-op when the id is unknown.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class DeleteStrategyUseCase
|
||||||
|
{
|
||||||
|
private readonly ISavedStrategyRepository _repo;
|
||||||
|
private readonly ILogger<DeleteStrategyUseCase> _logger;
|
||||||
|
|
||||||
|
public DeleteStrategyUseCase(ISavedStrategyRepository repo, ILogger<DeleteStrategyUseCase> logger)
|
||||||
|
{
|
||||||
|
_repo = repo ?? throw new ArgumentNullException(nameof(repo));
|
||||||
|
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task ExecuteAsync(Guid id, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
await _repo.DeleteAsync(id, ct).ConfigureAwait(false);
|
||||||
|
await _repo.SaveChangesAsync(ct).ConfigureAwait(false);
|
||||||
|
_logger.LogInformation("DeleteStrategyUseCase: removed preset {Id}", id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
using Marathon.Application.Abstractions;
|
||||||
|
using Marathon.Application.Configuration;
|
||||||
|
using Marathon.Domain.AnomalyDetection;
|
||||||
|
using Marathon.Domain.Entities;
|
||||||
|
using Marathon.Domain.ValueObjects;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace Marathon.Application.UseCases;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Orchestrates one anomaly-detection cycle:
|
||||||
|
/// <list type="number">
|
||||||
|
/// <item>Loads all tracked events.</item>
|
||||||
|
/// <item>For each event, fetches its last-24-hour live snapshots.</item>
|
||||||
|
/// <item>Runs <see cref="AnomalyDetector"/> over the snapshot timeline.</item>
|
||||||
|
/// <item>Persists any new anomalies that have not already been stored (dedup by EventId + Kind + DetectedAt minute-window).</item>
|
||||||
|
/// </list>
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// 🟡 Optimisation opportunity (Phase 8/9): currently iterates ALL events and loads 24 h of
|
||||||
|
/// snapshots per event. A future improvement is to track a "last detection run" timestamp per
|
||||||
|
/// event so we only load new snapshots. This is intentionally deferred to keep Phase 7 scope
|
||||||
|
/// focused on the detection algorithm.
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class DetectAnomaliesUseCase
|
||||||
|
{
|
||||||
|
private static readonly TimeSpan SnapshotLookback = TimeSpan.FromHours(24);
|
||||||
|
|
||||||
|
// Dedup window: two anomalies for the same event within this window are considered duplicates.
|
||||||
|
private static readonly TimeSpan DedupWindow = TimeSpan.FromMinutes(1);
|
||||||
|
|
||||||
|
private readonly IEventRepository _eventRepo;
|
||||||
|
private readonly ISnapshotRepository _snapshotRepo;
|
||||||
|
private readonly IAnomalyRepository _anomalyRepo;
|
||||||
|
private readonly AnomalyOptions _options;
|
||||||
|
private readonly ILogger<DetectAnomaliesUseCase> _logger;
|
||||||
|
|
||||||
|
public DetectAnomaliesUseCase(
|
||||||
|
IEventRepository eventRepo,
|
||||||
|
ISnapshotRepository snapshotRepo,
|
||||||
|
IAnomalyRepository anomalyRepo,
|
||||||
|
IOptions<AnomalyOptions> options,
|
||||||
|
ILogger<DetectAnomaliesUseCase> logger)
|
||||||
|
{
|
||||||
|
_eventRepo = eventRepo ?? throw new ArgumentNullException(nameof(eventRepo));
|
||||||
|
_snapshotRepo = snapshotRepo ?? throw new ArgumentNullException(nameof(snapshotRepo));
|
||||||
|
_anomalyRepo = anomalyRepo ?? throw new ArgumentNullException(nameof(anomalyRepo));
|
||||||
|
_options = (options ?? throw new ArgumentNullException(nameof(options))).Value;
|
||||||
|
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Executes one detection cycle.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="ct">Cancellation token.</param>
|
||||||
|
/// <returns>Number of new anomalies persisted during this cycle.</returns>
|
||||||
|
public async Task<int> ExecuteAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("DetectAnomaliesUseCase: cycle started");
|
||||||
|
|
||||||
|
var detectors = new IAnomalyDetector[]
|
||||||
|
{
|
||||||
|
new AnomalyDetector(
|
||||||
|
_options.SuspensionGapSeconds,
|
||||||
|
_options.OddsFlipThreshold,
|
||||||
|
_options.MinSnapshotCount),
|
||||||
|
new SteamMoveDetector(
|
||||||
|
_options.SteamMoveWindowSeconds,
|
||||||
|
_options.SteamMoveDriftThreshold,
|
||||||
|
_options.MinSnapshotCount,
|
||||||
|
_options.SuspensionGapSeconds),
|
||||||
|
new SuspensionFreezeDetector(
|
||||||
|
_options.SuspensionGapSeconds,
|
||||||
|
_options.SuspensionFreezeThreshold,
|
||||||
|
_options.MinSnapshotCount),
|
||||||
|
new OverroundCompressionDetector(
|
||||||
|
_options.OverroundWindowSeconds,
|
||||||
|
_options.OverroundCompressionThreshold,
|
||||||
|
_options.MinSnapshotCount,
|
||||||
|
_options.SuspensionGapSeconds),
|
||||||
|
};
|
||||||
|
|
||||||
|
var events = await _eventRepo.ListAsync(ct);
|
||||||
|
int newAnomalyCount = 0;
|
||||||
|
|
||||||
|
var now = MoscowTime.Now;
|
||||||
|
var from = now - SnapshotLookback;
|
||||||
|
|
||||||
|
// Hoisted outside the per-event loop: load existing anomalies ONCE per cycle
|
||||||
|
// and index them by event so dedup is O(1) per event instead of scanning the
|
||||||
|
// whole list each time (was O(events × anomalies)). Reviewer W1, Phase 7.
|
||||||
|
var existingAnomalies = await _anomalyRepo.ListAsync(ct);
|
||||||
|
var existingByEvent = existingAnomalies
|
||||||
|
.GroupBy(a => a.EventId)
|
||||||
|
.ToDictionary(g => g.Key, g => g.ToList());
|
||||||
|
|
||||||
|
// Single batched query for all events' snapshots — replaces the prior
|
||||||
|
// per-event ListByEventAsync round-trip (O(N) SQLite hits + N Include(Bets)
|
||||||
|
// payloads). Returns an empty list for events with no snapshots in range.
|
||||||
|
var eventIds = events.Select(e => e.Id).ToList();
|
||||||
|
var snapshotsByEvent = await _snapshotRepo.ListByEventsAsync(eventIds, from, now, ct);
|
||||||
|
|
||||||
|
foreach (var ev in events)
|
||||||
|
{
|
||||||
|
ct.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var snapshots = snapshotsByEvent.TryGetValue(ev.Id, out var found)
|
||||||
|
? found
|
||||||
|
: Array.Empty<OddsSnapshot>();
|
||||||
|
var existingForEvent = existingByEvent.TryGetValue(ev.Id, out var slice)
|
||||||
|
? slice
|
||||||
|
: new List<Anomaly>();
|
||||||
|
newAnomalyCount += await ProcessEventAsync(detectors, ev, snapshots, existingForEvent, ct);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex,
|
||||||
|
"DetectAnomaliesUseCase: failed to process event {EventId} — skipping",
|
||||||
|
ev.Id.Value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation(
|
||||||
|
"DetectAnomaliesUseCase: cycle done — {NewAnomalies} new anomalies across {TotalEvents} events",
|
||||||
|
newAnomalyCount, events.Count);
|
||||||
|
|
||||||
|
return newAnomalyCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Private helpers ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private async Task<int> ProcessEventAsync(
|
||||||
|
IReadOnlyList<IAnomalyDetector> detectors,
|
||||||
|
Event ev,
|
||||||
|
IReadOnlyList<OddsSnapshot> snapshots,
|
||||||
|
List<Anomaly> existingForEvent,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
// Fan out over every detector; dedup below keys on EventId + Kind so the flip,
|
||||||
|
// steam, and freeze signals for one event persist independently.
|
||||||
|
var detected = detectors
|
||||||
|
.SelectMany(d => d.Detect(ev.Id, snapshots))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (detected.Count == 0)
|
||||||
|
return 0;
|
||||||
|
|
||||||
|
int persisted = 0;
|
||||||
|
foreach (var anomaly in detected)
|
||||||
|
{
|
||||||
|
if (IsDuplicate(anomaly, existingForEvent))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
await _anomalyRepo.AddAsync(anomaly, ct);
|
||||||
|
existingForEvent.Add(anomaly); // Keep local list in sync so the same cycle doesn't re-add.
|
||||||
|
persisted++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// One write per event rather than per anomaly — with three detectors an event
|
||||||
|
// can yield several new anomalies in a single cycle.
|
||||||
|
if (persisted > 0)
|
||||||
|
await _anomalyRepo.SaveChangesAsync(ct);
|
||||||
|
|
||||||
|
return persisted;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsDuplicate(Anomaly candidate, IReadOnlyList<Anomaly> existing)
|
||||||
|
{
|
||||||
|
// Two anomalies are considered duplicates if they share the same EventId, Kind,
|
||||||
|
// and their DetectedAt timestamps fall within the dedup window.
|
||||||
|
return existing.Any(a =>
|
||||||
|
a.EventId == candidate.EventId &&
|
||||||
|
a.Kind == candidate.Kind &&
|
||||||
|
Math.Abs((a.DetectedAt - candidate.DetectedAt).TotalMinutes) <=
|
||||||
|
DedupWindow.TotalMinutes);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,261 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using Marathon.Application.Abstractions;
|
||||||
|
using Marathon.Application.Reporting;
|
||||||
|
using Marathon.Domain.AnomalyDetection;
|
||||||
|
using Marathon.Domain.Entities;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using DomainEventId = Marathon.Domain.ValueObjects.EventId;
|
||||||
|
|
||||||
|
namespace Marathon.Application.UseCases;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds an <see cref="AnomalyOutcomeReport"/> by joining every persisted
|
||||||
|
/// <see cref="Anomaly"/> with the originating event and its
|
||||||
|
/// <see cref="EventResult"/>, then running the pure
|
||||||
|
/// <see cref="AnomalyOutcomeEvaluator"/> over each pair.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// This is the answer to "does the SuspensionFlip detector actually predict the
|
||||||
|
/// right side?" The report is the validator for the entire anomaly-detection
|
||||||
|
/// premise of the product — without it, the algorithm's confidence score is
|
||||||
|
/// just a number with no calibration.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// The use case loads all three collections in one pass each and performs the
|
||||||
|
/// join in memory. Anomaly volumes are small (one per suspension interval per
|
||||||
|
/// event) so this is well within budget. If volumes grow significantly the
|
||||||
|
/// repository layer can later add a SQL-side join — the public shape of the
|
||||||
|
/// report does not change.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class EvaluateAnomalyOutcomesUseCase
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Lowest score bin shown in the histogram. Score values below this never
|
||||||
|
/// appear because the detector enforces a configurable threshold (default
|
||||||
|
/// 0.30) — but the constant is repeated here so the bucketer is independent
|
||||||
|
/// of any specific configuration value.
|
||||||
|
/// </summary>
|
||||||
|
public const decimal MinScore = 0.30m;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Bin width for the score histogram. Yields 7 buckets:
|
||||||
|
/// [0.30, 0.40), [0.40, 0.50), [0.50, 0.60), [0.60, 0.70), [0.70, 0.80),
|
||||||
|
/// [0.80, 0.90), [0.90, 1.00]. The last bin is closed on the right.
|
||||||
|
/// </summary>
|
||||||
|
public const decimal BinWidth = 0.10m;
|
||||||
|
|
||||||
|
private readonly IAnomalyRepository _anomalies;
|
||||||
|
private readonly IEventRepository _events;
|
||||||
|
private readonly IResultRepository _results;
|
||||||
|
private readonly ILogger<EvaluateAnomalyOutcomesUseCase> _logger;
|
||||||
|
|
||||||
|
public EvaluateAnomalyOutcomesUseCase(
|
||||||
|
IAnomalyRepository anomalies,
|
||||||
|
IEventRepository events,
|
||||||
|
IResultRepository results,
|
||||||
|
ILogger<EvaluateAnomalyOutcomesUseCase> logger)
|
||||||
|
{
|
||||||
|
_anomalies = anomalies ?? throw new ArgumentNullException(nameof(anomalies));
|
||||||
|
_events = events ?? throw new ArgumentNullException(nameof(events));
|
||||||
|
_results = results ?? throw new ArgumentNullException(nameof(results));
|
||||||
|
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<AnomalyOutcomeReport> ExecuteAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("EvaluateAnomalyOutcomesUseCase: report build started");
|
||||||
|
|
||||||
|
var anomalies = await _anomalies.ListAsync(ct).ConfigureAwait(false);
|
||||||
|
if (anomalies.Count == 0)
|
||||||
|
{
|
||||||
|
_logger.LogInformation(
|
||||||
|
"EvaluateAnomalyOutcomesUseCase: no anomalies — empty report");
|
||||||
|
return EmptyReport();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Batched lookups — a single query each, replacing the prior per-event
|
||||||
|
// GetAsync round-trip (N+1 against SQLite).
|
||||||
|
var distinctEventIds = anomalies.Select(a => a.EventId).Distinct().ToList();
|
||||||
|
|
||||||
|
var eventLookup = await _events.GetManyAsync(distinctEventIds, ct).ConfigureAwait(false);
|
||||||
|
var resultLookup = await _results.GetManyAsync(distinctEventIds, ct).ConfigureAwait(false);
|
||||||
|
|
||||||
|
var eventTitles = new Dictionary<DomainEventId, string>(eventLookup.Count);
|
||||||
|
foreach (var (id, ev) in eventLookup)
|
||||||
|
eventTitles[id] = ev.Title;
|
||||||
|
|
||||||
|
// Evaluate every anomaly through the pure domain function.
|
||||||
|
var resolved = new List<ResolvedAnomaly>();
|
||||||
|
var unresolved = new List<ResolvedAnomaly>();
|
||||||
|
foreach (var anomaly in anomalies)
|
||||||
|
{
|
||||||
|
ct.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
eventLookup.TryGetValue(anomaly.EventId, out var ev);
|
||||||
|
resultLookup.TryGetValue(anomaly.EventId, out var result);
|
||||||
|
|
||||||
|
var evaluated = AnomalyOutcomeEvaluator.Evaluate(anomaly, ev?.Sport, result);
|
||||||
|
|
||||||
|
if (evaluated.Outcome == AnomalyOutcomeKind.Unresolved)
|
||||||
|
unresolved.Add(evaluated);
|
||||||
|
else
|
||||||
|
resolved.Add(evaluated);
|
||||||
|
}
|
||||||
|
|
||||||
|
var resolvedOrdered = resolved
|
||||||
|
.OrderByDescending(r => r.DetectedAt)
|
||||||
|
.ToList();
|
||||||
|
var unresolvedOrdered = unresolved
|
||||||
|
.OrderByDescending(r => r.DetectedAt)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var hitCount = resolvedOrdered.Count(r => r.Outcome == AnomalyOutcomeKind.Hit);
|
||||||
|
var missCount = resolvedOrdered.Count - hitCount;
|
||||||
|
|
||||||
|
var report = new AnomalyOutcomeReport(
|
||||||
|
TotalAnomalies: anomalies.Count,
|
||||||
|
ResolvedCount: resolvedOrdered.Count,
|
||||||
|
UnresolvedCount: unresolvedOrdered.Count,
|
||||||
|
HitCount: hitCount,
|
||||||
|
MissCount: missCount,
|
||||||
|
HitRate: ComputeRate(hitCount, resolvedOrdered.Count),
|
||||||
|
BySeverity: BuildSeverityBuckets(resolvedOrdered),
|
||||||
|
BySport: BuildSportBuckets(resolvedOrdered),
|
||||||
|
ByScoreBin: BuildScoreBins(resolvedOrdered),
|
||||||
|
ByKind: BuildKindBuckets(resolvedOrdered),
|
||||||
|
Resolved: resolvedOrdered,
|
||||||
|
Unresolved: unresolvedOrdered,
|
||||||
|
EventTitles: eventTitles);
|
||||||
|
|
||||||
|
_logger.LogInformation(
|
||||||
|
"EvaluateAnomalyOutcomesUseCase: report ready — total={Total}, resolved={Resolved}, hits={Hits}",
|
||||||
|
report.TotalAnomalies, report.ResolvedCount, report.HitCount);
|
||||||
|
|
||||||
|
return report;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Bucketers ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private static IReadOnlyList<OutcomeBucket> BuildSeverityBuckets(
|
||||||
|
IReadOnlyCollection<ResolvedAnomaly> resolved)
|
||||||
|
{
|
||||||
|
// Thresholds sourced from the Domain so the UI's severity badge and
|
||||||
|
// this report cannot drift out of sync — single source of truth.
|
||||||
|
return new[]
|
||||||
|
{
|
||||||
|
BuildBucket(OutcomeBucketKeys.SeverityLow,
|
||||||
|
resolved.Where(r => r.Score < AnomalySeverityThresholds.Medium)),
|
||||||
|
BuildBucket(OutcomeBucketKeys.SeverityMedium,
|
||||||
|
resolved.Where(r => r.Score >= AnomalySeverityThresholds.Medium
|
||||||
|
&& r.Score < AnomalySeverityThresholds.High)),
|
||||||
|
BuildBucket(OutcomeBucketKeys.SeverityHigh,
|
||||||
|
resolved.Where(r => r.Score >= AnomalySeverityThresholds.High)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<OutcomeBucket> BuildSportBuckets(
|
||||||
|
IReadOnlyCollection<ResolvedAnomaly> resolved)
|
||||||
|
{
|
||||||
|
return resolved
|
||||||
|
.Where(r => r.Sport is not null)
|
||||||
|
.GroupBy(r => r.Sport!.Value)
|
||||||
|
.OrderBy(g => g.Key)
|
||||||
|
.Select(g => BuildBucket(
|
||||||
|
key: string.Format(
|
||||||
|
CultureInfo.InvariantCulture,
|
||||||
|
"{0}{1}",
|
||||||
|
OutcomeBucketKeys.SportPrefix,
|
||||||
|
g.Key),
|
||||||
|
items: g))
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<OutcomeBucket> BuildKindBuckets(
|
||||||
|
IReadOnlyCollection<ResolvedAnomaly> resolved)
|
||||||
|
{
|
||||||
|
// Only directional kinds resolve to a hit/miss (the evaluator leaves the rest
|
||||||
|
// Unresolved), so this naturally shows just the directional detectors.
|
||||||
|
return resolved
|
||||||
|
.GroupBy(r => r.Kind)
|
||||||
|
.OrderBy(g => (int)g.Key)
|
||||||
|
.Select(g => BuildBucket(
|
||||||
|
key: OutcomeBucketKeys.KindPrefix + g.Key,
|
||||||
|
items: g))
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<OutcomeBucket> BuildScoreBins(
|
||||||
|
IReadOnlyCollection<ResolvedAnomaly> resolved)
|
||||||
|
{
|
||||||
|
// Default range is the canonical [0.30, 1.00] with seven 0.10-wide bins.
|
||||||
|
// If the operator has lowered the detector's flip threshold and we have
|
||||||
|
// resolved anomalies below 0.30, prepend additional bins so every row in
|
||||||
|
// the report shows up in exactly one bucket — the histogram total must
|
||||||
|
// equal ResolvedCount no matter how the detector is tuned.
|
||||||
|
var floor = MinScore;
|
||||||
|
if (resolved.Count > 0)
|
||||||
|
{
|
||||||
|
var lowest = resolved.Min(r => r.Score);
|
||||||
|
if (lowest < MinScore)
|
||||||
|
{
|
||||||
|
var binsBelow = Math.Ceiling((MinScore - lowest) / BinWidth);
|
||||||
|
floor = MinScore - binsBelow * BinWidth;
|
||||||
|
if (floor < 0m) floor = 0m;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var bins = new List<OutcomeBucket>();
|
||||||
|
for (var start = floor; start < 1.0m; start += BinWidth)
|
||||||
|
{
|
||||||
|
var binStart = start;
|
||||||
|
var binEnd = start + BinWidth;
|
||||||
|
var isLast = binEnd >= 1.0m;
|
||||||
|
|
||||||
|
// Last bin is closed on the right so 1.00 lands in [0.90, 1.00].
|
||||||
|
var inBin = resolved.Where(r =>
|
||||||
|
r.Score >= binStart &&
|
||||||
|
(isLast ? r.Score <= 1.0m : r.Score < binEnd));
|
||||||
|
|
||||||
|
var key = string.Format(
|
||||||
|
CultureInfo.InvariantCulture,
|
||||||
|
"{0}{1:0.00}-{2:0.00}",
|
||||||
|
OutcomeBucketKeys.BinPrefix,
|
||||||
|
binStart,
|
||||||
|
Math.Min(binEnd, 1.0m));
|
||||||
|
|
||||||
|
bins.Add(BuildBucket(key, inBin));
|
||||||
|
}
|
||||||
|
return bins;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static OutcomeBucket BuildBucket(string key, IEnumerable<ResolvedAnomaly> items)
|
||||||
|
{
|
||||||
|
var list = items as IReadOnlyCollection<ResolvedAnomaly> ?? items.ToList();
|
||||||
|
var total = list.Count;
|
||||||
|
var hits = list.Count(r => r.Outcome == AnomalyOutcomeKind.Hit);
|
||||||
|
return new OutcomeBucket(key, total, hits, ComputeRate(hits, total));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static decimal? ComputeRate(int numerator, int denominator) =>
|
||||||
|
denominator == 0
|
||||||
|
? null
|
||||||
|
: Math.Round(numerator / (decimal)denominator, 4);
|
||||||
|
|
||||||
|
private static AnomalyOutcomeReport EmptyReport() =>
|
||||||
|
new(
|
||||||
|
TotalAnomalies: 0,
|
||||||
|
ResolvedCount: 0,
|
||||||
|
UnresolvedCount: 0,
|
||||||
|
HitCount: 0,
|
||||||
|
MissCount: 0,
|
||||||
|
HitRate: null,
|
||||||
|
BySeverity: Array.Empty<OutcomeBucket>(),
|
||||||
|
BySport: Array.Empty<OutcomeBucket>(),
|
||||||
|
ByScoreBin: Array.Empty<OutcomeBucket>(),
|
||||||
|
ByKind: Array.Empty<OutcomeBucket>(),
|
||||||
|
Resolved: Array.Empty<ResolvedAnomaly>(),
|
||||||
|
Unresolved: Array.Empty<ResolvedAnomaly>(),
|
||||||
|
EventTitles: new Dictionary<DomainEventId, string>());
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using System.Text;
|
||||||
|
using Marathon.Application.Abstractions;
|
||||||
|
using Marathon.Application.Export;
|
||||||
|
using Marathon.Application.Storage;
|
||||||
|
using Marathon.Domain.ValueObjects;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using DomainEventId = Marathon.Domain.ValueObjects.EventId;
|
||||||
|
|
||||||
|
namespace Marathon.Application.UseCases;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Exports the bet journal and the paper-trading (forward-test) ledger to CSV files in
|
||||||
|
/// the configured export directory, returning each file's path (or null when there is
|
||||||
|
/// nothing to export). Mirrors <see cref="ExportToExcelUseCase"/>'s write-and-return-path
|
||||||
|
/// contract; CSV needs no third-party library so it stays in the Application layer.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ExportToCsvUseCase
|
||||||
|
{
|
||||||
|
// BOM so Excel opens UTF-8 (Cyrillic team names) correctly.
|
||||||
|
private static readonly Encoding Utf8Bom = new UTF8Encoding(encoderShouldEmitUTF8Identifier: true);
|
||||||
|
|
||||||
|
private readonly IPlacedBetRepository _bets;
|
||||||
|
private readonly IPaperBetRepository _paperBets;
|
||||||
|
private readonly IEventRepository _events;
|
||||||
|
private readonly IOptions<StorageOptions> _storage;
|
||||||
|
private readonly ILogger<ExportToCsvUseCase> _logger;
|
||||||
|
|
||||||
|
public ExportToCsvUseCase(
|
||||||
|
IPlacedBetRepository bets,
|
||||||
|
IPaperBetRepository paperBets,
|
||||||
|
IEventRepository events,
|
||||||
|
IOptions<StorageOptions> storage,
|
||||||
|
ILogger<ExportToCsvUseCase> logger)
|
||||||
|
{
|
||||||
|
_bets = bets ?? throw new ArgumentNullException(nameof(bets));
|
||||||
|
_paperBets = paperBets ?? throw new ArgumentNullException(nameof(paperBets));
|
||||||
|
_events = events ?? throw new ArgumentNullException(nameof(events));
|
||||||
|
_storage = storage ?? throw new ArgumentNullException(nameof(storage));
|
||||||
|
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Writes the bet journal to CSV; returns the path, or null when empty.</summary>
|
||||||
|
public async Task<string?> ExportJournalAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var bets = await _bets.ListAsync(ct).ConfigureAwait(false);
|
||||||
|
if (bets.Count == 0)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var titles = await TitlesAsync(bets.Select(b => b.EventId), ct).ConfigureAwait(false);
|
||||||
|
|
||||||
|
var header = new[]
|
||||||
|
{
|
||||||
|
"PlacedAt", "Event", "EventId", "Type", "Side", "Value", "Rate", "Stake", "Outcome", "Profit", "Notes",
|
||||||
|
};
|
||||||
|
var rows = bets
|
||||||
|
.OrderByDescending(b => b.PlacedAt)
|
||||||
|
.Select(b => (IReadOnlyList<string>)new[]
|
||||||
|
{
|
||||||
|
b.PlacedAt.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture),
|
||||||
|
Title(titles, b.EventId),
|
||||||
|
Csv.NeutralizeFormula(b.EventId.Value),
|
||||||
|
b.Selection.Type.ToString(),
|
||||||
|
b.Selection.Side.ToString(),
|
||||||
|
b.Selection.Value?.Value.ToString(CultureInfo.InvariantCulture) ?? string.Empty,
|
||||||
|
b.Selection.Rate.Value.ToString(CultureInfo.InvariantCulture),
|
||||||
|
b.Stake.ToString(CultureInfo.InvariantCulture),
|
||||||
|
b.Outcome.ToString(),
|
||||||
|
b.NetProfit?.ToString(CultureInfo.InvariantCulture) ?? string.Empty,
|
||||||
|
Csv.NeutralizeFormula(b.Notes),
|
||||||
|
});
|
||||||
|
|
||||||
|
return await WriteAsync("journal", Csv.Document(header, rows), ct).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Writes the paper-trading ledger to CSV; returns the path, or null when empty.</summary>
|
||||||
|
public async Task<string?> ExportPaperLedgerAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var bets = await _paperBets.ListAsync(ct).ConfigureAwait(false);
|
||||||
|
if (bets.Count == 0)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var titles = await TitlesAsync(bets.Select(b => b.EventId), ct).ConfigureAwait(false);
|
||||||
|
|
||||||
|
var header = new[]
|
||||||
|
{
|
||||||
|
"OpenedAt", "Event", "EventId", "PickedSide", "Rate", "Stake", "Outcome", "Payout", "SettledAt",
|
||||||
|
};
|
||||||
|
var rows = bets
|
||||||
|
.OrderByDescending(b => b.OpenedAt)
|
||||||
|
.Select(b => (IReadOnlyList<string>)new[]
|
||||||
|
{
|
||||||
|
b.OpenedAt.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture),
|
||||||
|
Title(titles, b.EventId),
|
||||||
|
Csv.NeutralizeFormula(b.EventId.Value),
|
||||||
|
b.PickedSide.ToString(),
|
||||||
|
b.Rate.ToString(CultureInfo.InvariantCulture),
|
||||||
|
b.Stake.ToString(CultureInfo.InvariantCulture),
|
||||||
|
b.Outcome.ToString(),
|
||||||
|
b.Payout?.ToString(CultureInfo.InvariantCulture) ?? string.Empty,
|
||||||
|
b.SettledAt?.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture) ?? string.Empty,
|
||||||
|
});
|
||||||
|
|
||||||
|
return await WriteAsync("forward-test", Csv.Document(header, rows), ct).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<string> WriteAsync(string label, string content, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var dir = _storage.Value.ExportDirectory;
|
||||||
|
Directory.CreateDirectory(dir);
|
||||||
|
var fileName = $"Marathon_{label}_{MoscowTime.Now:yyyy-MM-dd_HHmmss}.csv";
|
||||||
|
var path = Path.Combine(dir, fileName);
|
||||||
|
await File.WriteAllTextAsync(path, content, Utf8Bom, ct).ConfigureAwait(false);
|
||||||
|
_logger.LogInformation("ExportToCsvUseCase: wrote {Label} CSV → {Path}", label, path);
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<IReadOnlyDictionary<DomainEventId, string>> TitlesAsync(
|
||||||
|
IEnumerable<DomainEventId> ids, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var distinct = ids.Distinct().ToList();
|
||||||
|
var events = await _events.GetManyAsync(distinct, ct).ConfigureAwait(false);
|
||||||
|
var titles = new Dictionary<DomainEventId, string>(events.Count);
|
||||||
|
foreach (var (id, ev) in events)
|
||||||
|
titles[id] = ev.Title;
|
||||||
|
return titles;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Titles are scraped ("Side1 vs Side2") so they're treated as untrusted text and
|
||||||
|
// neutralized against CSV/formula injection.
|
||||||
|
private static string Title(IReadOnlyDictionary<DomainEventId, string> titles, DomainEventId id) =>
|
||||||
|
Csv.NeutralizeFormula(titles.TryGetValue(id, out var t) ? t : id.Value);
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
using Marathon.Application.Abstractions;
|
||||||
|
using Marathon.Application.Storage;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace Marathon.Application.UseCases;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Exports odds snapshots for a date range to an Excel file, placing it in
|
||||||
|
/// the configured export directory.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ExportToExcelUseCase
|
||||||
|
{
|
||||||
|
private readonly IExcelExporter _exporter;
|
||||||
|
private readonly IOptions<StorageOptions> _storageOptions;
|
||||||
|
private readonly ILogger<ExportToExcelUseCase> _logger;
|
||||||
|
|
||||||
|
public ExportToExcelUseCase(
|
||||||
|
IExcelExporter exporter,
|
||||||
|
IOptions<StorageOptions> storageOptions,
|
||||||
|
ILogger<ExportToExcelUseCase> logger)
|
||||||
|
{
|
||||||
|
_exporter = exporter ?? throw new ArgumentNullException(nameof(exporter));
|
||||||
|
_storageOptions = storageOptions ?? throw new ArgumentNullException(nameof(storageOptions));
|
||||||
|
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Runs the export and returns the absolute path of the created file.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="range">Inclusive date range to export.</param>
|
||||||
|
/// <param name="kind">Which snapshots to include.</param>
|
||||||
|
/// <param name="ct">Cancellation token.</param>
|
||||||
|
/// <returns>Absolute path of the written <c>.xlsx</c> file.</returns>
|
||||||
|
public async Task<string> ExecuteAsync(DateRange range, ExportKind kind, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var exportDir = _storageOptions.Value.ExportDirectory;
|
||||||
|
|
||||||
|
// Ensure the output directory exists before handing off to the exporter.
|
||||||
|
Directory.CreateDirectory(exportDir);
|
||||||
|
|
||||||
|
_logger.LogInformation(
|
||||||
|
"ExportToExcelUseCase: exporting {Kind} snapshots for {From:yyyy-MM-dd}..{To:yyyy-MM-dd} → {Dir}",
|
||||||
|
kind, range.From, range.To, exportDir);
|
||||||
|
|
||||||
|
var outputPath = await _exporter.ExportAsync(range, kind, exportDir, ct);
|
||||||
|
|
||||||
|
_logger.LogInformation(
|
||||||
|
"ExportToExcelUseCase: export complete — file={Path}",
|
||||||
|
outputPath);
|
||||||
|
|
||||||
|
return outputPath;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
using Marathon.Application.Abstractions;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Marathon.Application.UseCases;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Shapes the anomalies worth alerting on: those detected at or after a caller-supplied
|
||||||
|
/// marker whose score clears a minimum, joined with their event titles. Pure of any
|
||||||
|
/// transport concern — the dispatcher decides cadence and the sink decides delivery.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Results are ordered oldest-first so the caller can advance its "since" marker to the
|
||||||
|
/// last item's <see cref="AnomalyNotification.DetectedAt"/> (plus one tick) without gaps
|
||||||
|
/// or duplicates.
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class GetPendingAnomalyNotificationsUseCase
|
||||||
|
{
|
||||||
|
private readonly IAnomalyRepository _anomalies;
|
||||||
|
private readonly IEventRepository _events;
|
||||||
|
private readonly ILogger<GetPendingAnomalyNotificationsUseCase> _logger;
|
||||||
|
|
||||||
|
public GetPendingAnomalyNotificationsUseCase(
|
||||||
|
IAnomalyRepository anomalies,
|
||||||
|
IEventRepository events,
|
||||||
|
ILogger<GetPendingAnomalyNotificationsUseCase> logger)
|
||||||
|
{
|
||||||
|
_anomalies = anomalies ?? throw new ArgumentNullException(nameof(anomalies));
|
||||||
|
_events = events ?? throw new ArgumentNullException(nameof(events));
|
||||||
|
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<AnomalyNotification>> ExecuteAsync(
|
||||||
|
DateTimeOffset since,
|
||||||
|
decimal minScore,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
// Date filter pushed to SQL; score filter is cheap in memory over the small slice.
|
||||||
|
var recent = await _anomalies.ListByDateRangeAsync(since, to: null, ct).ConfigureAwait(false);
|
||||||
|
var qualifying = recent.Where(a => a.Score >= minScore).ToList();
|
||||||
|
if (qualifying.Count == 0)
|
||||||
|
return Array.Empty<AnomalyNotification>();
|
||||||
|
|
||||||
|
var eventIds = qualifying.Select(a => a.EventId).Distinct().ToList();
|
||||||
|
var events = await _events.GetManyAsync(eventIds, ct).ConfigureAwait(false);
|
||||||
|
|
||||||
|
var notifications = qualifying
|
||||||
|
.OrderBy(a => a.DetectedAt)
|
||||||
|
.Select(a => new AnomalyNotification(
|
||||||
|
AnomalyId: a.Id,
|
||||||
|
EventTitle: events.TryGetValue(a.EventId, out var ev) ? ev.Title : a.EventId.Value,
|
||||||
|
Kind: a.Kind,
|
||||||
|
Score: a.Score,
|
||||||
|
DetectedAt: a.DetectedAt))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
_logger.LogDebug(
|
||||||
|
"GetPendingAnomalyNotificationsUseCase: {Count} alert(s) since {Since:O} at minScore {MinScore}",
|
||||||
|
notifications.Count, since, minScore);
|
||||||
|
|
||||||
|
return notifications;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
using Marathon.Application.Abstractions;
|
||||||
|
using Marathon.Domain.AnomalyDetection;
|
||||||
|
using Marathon.Domain.Entities;
|
||||||
|
using Marathon.Domain.Enums;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Marathon.Application.UseCases;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Opens flat-stake paper bets for directional anomalies detected in
|
||||||
|
/// (<c>since</c>..<c>until</c>] whose score clears the threshold and that don't
|
||||||
|
/// already have one. The picked side is the post-flip favourite; the rate is that
|
||||||
|
/// side's post-suspension rate — locking in the price the moment the signal fired.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class OpenPaperBetsUseCase
|
||||||
|
{
|
||||||
|
private readonly IAnomalyRepository _anomalies;
|
||||||
|
private readonly IPaperBetRepository _paperBets;
|
||||||
|
private readonly ILogger<OpenPaperBetsUseCase> _logger;
|
||||||
|
|
||||||
|
public OpenPaperBetsUseCase(
|
||||||
|
IAnomalyRepository anomalies,
|
||||||
|
IPaperBetRepository paperBets,
|
||||||
|
ILogger<OpenPaperBetsUseCase> logger)
|
||||||
|
{
|
||||||
|
_anomalies = anomalies ?? throw new ArgumentNullException(nameof(anomalies));
|
||||||
|
_paperBets = paperBets ?? throw new ArgumentNullException(nameof(paperBets));
|
||||||
|
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Returns the paper bets opened this pass (empty when nothing qualified).</summary>
|
||||||
|
public async Task<IReadOnlyList<PaperBet>> ExecuteAsync(
|
||||||
|
DateTimeOffset since,
|
||||||
|
DateTimeOffset until,
|
||||||
|
decimal minScore,
|
||||||
|
decimal flatStake,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (flatStake <= 0m)
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(flatStake), flatStake, "Flat stake must be positive.");
|
||||||
|
|
||||||
|
var anomalies = await _anomalies.ListByDateRangeAsync(since, until, ct).ConfigureAwait(false);
|
||||||
|
|
||||||
|
// Only directional kinds make a side prediction worth forward-testing; the rest
|
||||||
|
// are informational and would just measure the base favourite-win rate.
|
||||||
|
var candidates = anomalies
|
||||||
|
.Where(a => a.Kind.IsDirectional() && a.Score >= minScore)
|
||||||
|
.ToList();
|
||||||
|
if (candidates.Count == 0)
|
||||||
|
return Array.Empty<PaperBet>();
|
||||||
|
|
||||||
|
var existing = await _paperBets
|
||||||
|
.GetExistingAnomalyIdsAsync(candidates.Select(a => a.Id).ToList(), ct)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
var opened = new List<PaperBet>();
|
||||||
|
foreach (var anomaly in candidates)
|
||||||
|
{
|
||||||
|
ct.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
if (existing.Contains(anomaly.Id))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
if (!AnomalyEvidenceParser.TryParse(anomaly.EvidenceJson, out var evidence))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var pick = evidence.PostSuspension.Favourite;
|
||||||
|
if (evidence.PostSuspension.RateFor(pick) is not { } rate || rate <= 1m)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
opened.Add(PaperBet.Open(anomaly.Id, anomaly.EventId, pick, rate, flatStake, anomaly.DetectedAt));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (opened.Count == 0)
|
||||||
|
return Array.Empty<PaperBet>();
|
||||||
|
|
||||||
|
foreach (var bet in opened)
|
||||||
|
await _paperBets.AddAsync(bet, ct).ConfigureAwait(false);
|
||||||
|
await _paperBets.SaveChangesAsync(ct).ConfigureAwait(false);
|
||||||
|
|
||||||
|
_logger.LogInformation("OpenPaperBetsUseCase: opened {Count} paper bet(s)", opened.Count);
|
||||||
|
return opened;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
|
using Marathon.Application.Abstractions;
|
||||||
|
using Marathon.Application.Configuration;
|
||||||
|
using Marathon.Domain.Entities;
|
||||||
|
using Marathon.Domain.Enums;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace Marathon.Application.UseCases;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Discovers currently-live events from the bookmaker's <c>/su/live</c> listing,
|
||||||
|
/// persists any not yet known to the database, and captures a fresh
|
||||||
|
/// <see cref="OddsSource.Live"/> snapshot for each.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Live discovery is authoritative: events that go live without ever appearing
|
||||||
|
/// in the upcoming list (late-added matches, in-play markets opened on demand)
|
||||||
|
/// are picked up here. Pre-match-only events are NOT scraped by this use case —
|
||||||
|
/// they would just be wasted requests against the bookmaker.
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class PullLiveOddsUseCase
|
||||||
|
{
|
||||||
|
private readonly IOddsScraper _scraper;
|
||||||
|
private readonly IEventRepository _eventRepo;
|
||||||
|
private readonly ISnapshotRepository _snapshotRepo;
|
||||||
|
private readonly IOptionsMonitor<ScrapingThrottle> _throttle;
|
||||||
|
private readonly ILogger<PullLiveOddsUseCase> _logger;
|
||||||
|
|
||||||
|
public PullLiveOddsUseCase(
|
||||||
|
IOddsScraper scraper,
|
||||||
|
IEventRepository eventRepo,
|
||||||
|
ISnapshotRepository snapshotRepo,
|
||||||
|
IOptionsMonitor<ScrapingThrottle> throttle,
|
||||||
|
ILogger<PullLiveOddsUseCase> logger)
|
||||||
|
{
|
||||||
|
_scraper = scraper ?? throw new ArgumentNullException(nameof(scraper));
|
||||||
|
_eventRepo = eventRepo ?? throw new ArgumentNullException(nameof(eventRepo));
|
||||||
|
_snapshotRepo = snapshotRepo ?? throw new ArgumentNullException(nameof(snapshotRepo));
|
||||||
|
_throttle = throttle ?? throw new ArgumentNullException(nameof(throttle));
|
||||||
|
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Executes one live-odds polling cycle.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="ct">Cancellation token.</param>
|
||||||
|
/// <returns>Number of live snapshots successfully captured.</returns>
|
||||||
|
public async Task<int> ExecuteAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("PullLiveOddsUseCase: cycle started");
|
||||||
|
|
||||||
|
IReadOnlyList<Event> liveEvents;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
liveEvents = await _scraper.ScrapeLiveAsync(ct);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex,
|
||||||
|
"PullLiveOddsUseCase: failed to fetch live event listing — skipping cycle");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation(
|
||||||
|
"PullLiveOddsUseCase: scraper returned {Count} live events",
|
||||||
|
liveEvents.Count);
|
||||||
|
|
||||||
|
// Phase 1 — parallel HTTP fan-out: scrape every event's odds in parallel,
|
||||||
|
// capped at MaxConcurrentRequests. The scraper's rate limiter still
|
||||||
|
// throttles to RequestsPerSecond underneath this fan-out, so spikes are
|
||||||
|
// smoothed out before they reach the bookmaker. We deliberately do NOT
|
||||||
|
// touch the DbContext (or its repositories) inside the parallel block —
|
||||||
|
// EF Core DbContext is not thread-safe.
|
||||||
|
var scraped = new ConcurrentBag<(Event Live, OddsSnapshot Snapshot)>();
|
||||||
|
var maxParallelism = Math.Max(1, _throttle.CurrentValue.MaxConcurrentRequests);
|
||||||
|
|
||||||
|
var parallelOptions = new ParallelOptions
|
||||||
|
{
|
||||||
|
MaxDegreeOfParallelism = maxParallelism,
|
||||||
|
CancellationToken = ct,
|
||||||
|
};
|
||||||
|
|
||||||
|
await Parallel.ForEachAsync(liveEvents, parallelOptions, async (live, taskCt) =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var snapshot = await _scraper.ScrapeEventOddsAsync(live, OddsSource.Live, taskCt);
|
||||||
|
scraped.Add((live, snapshot));
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex,
|
||||||
|
"PullLiveOddsUseCase: failed to capture live snapshot for event {EventId} — skipping",
|
||||||
|
live.Id.Value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Phase 2 — sequential persistence. EF Core DbContext is single-threaded,
|
||||||
|
// so we apply each (event upsert + snapshot insert) one at a time.
|
||||||
|
int snapshotsCaptured = 0;
|
||||||
|
foreach (var (live, snapshot) in scraped)
|
||||||
|
{
|
||||||
|
ct.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Persist new live events — the upcoming poller may not have seen them
|
||||||
|
// yet (or never will, for matches added after their scheduled start).
|
||||||
|
// The Live page reads from the events table, so a new live row must
|
||||||
|
// exist before its snapshots become visible.
|
||||||
|
var existing = await _eventRepo.GetAsync(live.Id, ct);
|
||||||
|
if (existing is null)
|
||||||
|
{
|
||||||
|
await _eventRepo.AddAsync(live, ct);
|
||||||
|
await _eventRepo.SaveChangesAsync(ct);
|
||||||
|
}
|
||||||
|
else if (existing.EventPath is null && live.EventPath is not null)
|
||||||
|
{
|
||||||
|
// Backfill EventPath on rows persisted before the column existed.
|
||||||
|
var patched = existing with { EventPath = live.EventPath };
|
||||||
|
await _eventRepo.UpdateAsync(patched, ct);
|
||||||
|
await _eventRepo.SaveChangesAsync(ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
await _snapshotRepo.AddAsync(snapshot, ct);
|
||||||
|
await _snapshotRepo.SaveChangesAsync(ct);
|
||||||
|
snapshotsCaptured++;
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex,
|
||||||
|
"PullLiveOddsUseCase: failed to persist live snapshot for event {EventId} — skipping",
|
||||||
|
live.Id.Value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation(
|
||||||
|
"PullLiveOddsUseCase: cycle done — snapshots captured for {Count}/{Total} live events",
|
||||||
|
snapshotsCaptured, liveEvents.Count);
|
||||||
|
|
||||||
|
return snapshotsCaptured;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
using Marathon.Application.Abstractions;
|
||||||
|
using Marathon.Application.Storage;
|
||||||
|
using Marathon.Domain.Entities;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using DomainEventId = Marathon.Domain.ValueObjects.EventId;
|
||||||
|
|
||||||
|
namespace Marathon.Application.UseCases;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Per-event progress emitted by <see cref="PullResultsUseCase.ExecuteAsync"/>.
|
||||||
|
/// Used by the UI to render a progress bar and the running list of loaded
|
||||||
|
/// results — each tick is fired AFTER the bookmaker has been queried for
|
||||||
|
/// <see cref="EventId"/>, so the UI sees one tick per inspected event.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="Processed">Total events processed so far (1-based at the first tick).</param>
|
||||||
|
/// <param name="Total">Total candidates in this run.</param>
|
||||||
|
/// <param name="EventId">The event just processed.</param>
|
||||||
|
/// <param name="Outcome">What happened — see <see cref="ResultLoadOutcome"/>.</param>
|
||||||
|
/// <param name="Result">The persisted <see cref="EventResult"/> when <paramref name="Outcome"/> is <see cref="ResultLoadOutcome.Loaded"/>; otherwise null.</param>
|
||||||
|
public sealed record PullResultsProgress(
|
||||||
|
int Processed,
|
||||||
|
int Total,
|
||||||
|
DomainEventId EventId,
|
||||||
|
ResultLoadOutcome Outcome,
|
||||||
|
EventResult? Result);
|
||||||
|
|
||||||
|
/// <summary>What happened to a single candidate event during a results load.</summary>
|
||||||
|
public enum ResultLoadOutcome
|
||||||
|
{
|
||||||
|
/// <summary>A new <see cref="EventResult"/> was scraped and persisted.</summary>
|
||||||
|
Loaded,
|
||||||
|
|
||||||
|
/// <summary>The event already had a stored result — no work was done.</summary>
|
||||||
|
AlreadyLoaded,
|
||||||
|
|
||||||
|
/// <summary>The match isn't complete yet — try again later.</summary>
|
||||||
|
NotYetComplete,
|
||||||
|
|
||||||
|
/// <summary>The scrape failed (HTTP, parse, etc.). Logged at warning.</summary>
|
||||||
|
Failed,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Loads completed-event results into the database.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// For each candidate event, the use case:
|
||||||
|
/// </para>
|
||||||
|
/// <list type="number">
|
||||||
|
/// <item>Skips it if a result is already stored (idempotent).</item>
|
||||||
|
/// <item>Calls <see cref="IOddsScraper.ScrapeEventResultAsync"/>, which returns
|
||||||
|
/// a non-null <see cref="EventResult"/> only when the bookmaker reports
|
||||||
|
/// <c>matchIsComplete=true</c>.</item>
|
||||||
|
/// <item>Persists the result and increments the loaded count.</item>
|
||||||
|
/// </list>
|
||||||
|
/// <para>
|
||||||
|
/// Candidates are either an explicit <paramref name="selection"/> list or — when
|
||||||
|
/// null/empty — every event scheduled in <c>range</c>.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class PullResultsUseCase
|
||||||
|
{
|
||||||
|
private readonly IOddsScraper _scraper;
|
||||||
|
private readonly IEventRepository _eventRepo;
|
||||||
|
private readonly IResultRepository _resultRepo;
|
||||||
|
private readonly ILogger<PullResultsUseCase> _logger;
|
||||||
|
|
||||||
|
public PullResultsUseCase(
|
||||||
|
IOddsScraper scraper,
|
||||||
|
IEventRepository eventRepo,
|
||||||
|
IResultRepository resultRepo,
|
||||||
|
ILogger<PullResultsUseCase> logger)
|
||||||
|
{
|
||||||
|
_scraper = scraper ?? throw new ArgumentNullException(nameof(scraper));
|
||||||
|
_eventRepo = eventRepo ?? throw new ArgumentNullException(nameof(eventRepo));
|
||||||
|
_resultRepo = resultRepo ?? throw new ArgumentNullException(nameof(resultRepo));
|
||||||
|
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Inspects events for completion and persists results.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="range">Date range used when <paramref name="selection"/> is null or empty.</param>
|
||||||
|
/// <param name="selection">
|
||||||
|
/// When non-empty, only these event IDs are inspected.
|
||||||
|
/// When null or empty, all events in <paramref name="range"/> without a stored
|
||||||
|
/// result are inspected.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="progress">
|
||||||
|
/// Optional progress sink. Receives one update per candidate AFTER the scrape
|
||||||
|
/// has resolved. Suitable for binding to a UI progress indicator.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="ct">Cancellation token.</param>
|
||||||
|
public async Task<(int Inspected, int ResultsLoaded, int Skipped)> ExecuteAsync(
|
||||||
|
DateRange range,
|
||||||
|
IReadOnlyList<DomainEventId>? selection,
|
||||||
|
IProgress<PullResultsProgress>? progress,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
_logger.LogInformation(
|
||||||
|
"PullResultsUseCase: cycle started — range={From:O}..{To:O}, selection={SelectionCount}",
|
||||||
|
range.From, range.To, selection?.Count.ToString() ?? "all");
|
||||||
|
|
||||||
|
var candidates = await ResolveCandidatesAsync(range, selection, ct).ConfigureAwait(false);
|
||||||
|
|
||||||
|
int inspected = 0;
|
||||||
|
int resultsLoaded = 0;
|
||||||
|
int skipped = 0;
|
||||||
|
|
||||||
|
foreach (var ev in candidates)
|
||||||
|
{
|
||||||
|
ct.ThrowIfCancellationRequested();
|
||||||
|
inspected++;
|
||||||
|
|
||||||
|
var (outcome, persisted) = await ProcessOneAsync(ev, ct).ConfigureAwait(false);
|
||||||
|
switch (outcome)
|
||||||
|
{
|
||||||
|
case ResultLoadOutcome.Loaded: resultsLoaded++; break;
|
||||||
|
case ResultLoadOutcome.AlreadyLoaded: skipped++; break;
|
||||||
|
}
|
||||||
|
|
||||||
|
progress?.Report(new PullResultsProgress(
|
||||||
|
Processed: inspected,
|
||||||
|
Total: candidates.Count,
|
||||||
|
EventId: ev.Id,
|
||||||
|
Outcome: outcome,
|
||||||
|
Result: persisted));
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation(
|
||||||
|
"PullResultsUseCase: cycle done — inspected={Inspected}, loaded={Loaded}, skipped={Skipped}",
|
||||||
|
inspected, resultsLoaded, skipped);
|
||||||
|
|
||||||
|
return (inspected, resultsLoaded, skipped);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Convenience overload without progress reporting (worker callers).</summary>
|
||||||
|
public Task<(int Inspected, int ResultsLoaded, int Skipped)> ExecuteAsync(
|
||||||
|
DateRange range,
|
||||||
|
IReadOnlyList<DomainEventId>? selection,
|
||||||
|
CancellationToken ct)
|
||||||
|
=> ExecuteAsync(range, selection, progress: null, ct);
|
||||||
|
|
||||||
|
private async Task<IReadOnlyList<Event>> ResolveCandidatesAsync(
|
||||||
|
DateRange range,
|
||||||
|
IReadOnlyList<DomainEventId>? selection,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (selection is { Count: > 0 })
|
||||||
|
{
|
||||||
|
// Batched load (was N+1); preserve the caller's selection order and
|
||||||
|
// silently drop ids with no stored event.
|
||||||
|
var events = await _eventRepo.GetManyAsync(selection, ct).ConfigureAwait(false);
|
||||||
|
var resolved = new List<Event>(selection.Count);
|
||||||
|
foreach (var id in selection)
|
||||||
|
{
|
||||||
|
if (events.TryGetValue(id, out var ev))
|
||||||
|
resolved.Add(ev);
|
||||||
|
}
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
return await _eventRepo.ListByDateRangeAsync(range, ct).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<(ResultLoadOutcome Outcome, EventResult? Persisted)> ProcessOneAsync(
|
||||||
|
Event ev,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var existing = await _resultRepo.GetAsync(ev.Id, ct).ConfigureAwait(false);
|
||||||
|
if (existing is not null)
|
||||||
|
{
|
||||||
|
return (ResultLoadOutcome.AlreadyLoaded, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
var scraped = await _scraper.ScrapeEventResultAsync(ev, ct).ConfigureAwait(false);
|
||||||
|
if (scraped is null)
|
||||||
|
{
|
||||||
|
return (ResultLoadOutcome.NotYetComplete, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
await _resultRepo.AddAsync(scraped, ct).ConfigureAwait(false);
|
||||||
|
await _resultRepo.SaveChangesAsync(ct).ConfigureAwait(false);
|
||||||
|
return (ResultLoadOutcome.Loaded, scraped);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex,
|
||||||
|
"PullResultsUseCase: error processing event {EventId} — skipping",
|
||||||
|
ev.Id.Value);
|
||||||
|
return (ResultLoadOutcome.Failed, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
|
using Marathon.Application.Abstractions;
|
||||||
|
using Marathon.Application.Configuration;
|
||||||
|
using Marathon.Domain.Entities;
|
||||||
|
using Marathon.Domain.Enums;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace Marathon.Application.UseCases;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fetches the current pre-match event listing, persists new events (skipping
|
||||||
|
/// duplicates by <see cref="Domain.ValueObjects.EventId"/>), and captures an
|
||||||
|
/// initial pre-match odds snapshot for every returned event.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class PullUpcomingEventsUseCase
|
||||||
|
{
|
||||||
|
private readonly IOddsScraper _scraper;
|
||||||
|
private readonly IEventRepository _eventRepo;
|
||||||
|
private readonly ISnapshotRepository _snapshotRepo;
|
||||||
|
private readonly IOptionsMonitor<ScrapingThrottle> _throttle;
|
||||||
|
private readonly ILogger<PullUpcomingEventsUseCase> _logger;
|
||||||
|
|
||||||
|
public PullUpcomingEventsUseCase(
|
||||||
|
IOddsScraper scraper,
|
||||||
|
IEventRepository eventRepo,
|
||||||
|
ISnapshotRepository snapshotRepo,
|
||||||
|
IOptionsMonitor<ScrapingThrottle> throttle,
|
||||||
|
ILogger<PullUpcomingEventsUseCase> logger)
|
||||||
|
{
|
||||||
|
_scraper = scraper ?? throw new ArgumentNullException(nameof(scraper));
|
||||||
|
_eventRepo = eventRepo ?? throw new ArgumentNullException(nameof(eventRepo));
|
||||||
|
_snapshotRepo = snapshotRepo ?? throw new ArgumentNullException(nameof(snapshotRepo));
|
||||||
|
_throttle = throttle ?? throw new ArgumentNullException(nameof(throttle));
|
||||||
|
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Executes one polling cycle: scrape → persist new events → capture snapshots.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="ct">Cancellation token.</param>
|
||||||
|
/// <returns>
|
||||||
|
/// A tuple of <c>(EventsProcessed, NewEvents, SnapshotsCaptured)</c>.
|
||||||
|
/// <c>EventsProcessed</c> is the total number returned by the scraper.
|
||||||
|
/// <c>NewEvents</c> is how many were not already in the DB.
|
||||||
|
/// <c>SnapshotsCaptured</c> is how many snapshots were successfully saved.
|
||||||
|
/// </returns>
|
||||||
|
public async Task<(int EventsProcessed, int NewEvents, int SnapshotsCaptured)> ExecuteAsync(
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("PullUpcomingEventsUseCase: cycle started");
|
||||||
|
|
||||||
|
var events = await _scraper.ScrapeUpcomingAsync(sportFilter: null, ct);
|
||||||
|
int eventsProcessed = events.Count;
|
||||||
|
|
||||||
|
_logger.LogInformation(
|
||||||
|
"PullUpcomingEventsUseCase: scraper returned {Count} events",
|
||||||
|
eventsProcessed);
|
||||||
|
|
||||||
|
// Phase 1 — parallel HTTP fan-out. Each event's odds snapshot is scraped
|
||||||
|
// concurrently up to MaxConcurrentRequests; the scraper's rate limiter
|
||||||
|
// smooths spikes underneath. We do NOT touch the DbContext here — EF Core
|
||||||
|
// is single-threaded.
|
||||||
|
var scraped = new ConcurrentBag<(Event Event, OddsSnapshot Snapshot)>();
|
||||||
|
var maxParallelism = Math.Max(1, _throttle.CurrentValue.MaxConcurrentRequests);
|
||||||
|
|
||||||
|
var parallelOptions = new ParallelOptions
|
||||||
|
{
|
||||||
|
MaxDegreeOfParallelism = maxParallelism,
|
||||||
|
CancellationToken = ct,
|
||||||
|
};
|
||||||
|
|
||||||
|
await Parallel.ForEachAsync(events, parallelOptions, async (ev, taskCt) =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var snapshot = await _scraper.ScrapeEventOddsAsync(ev, OddsSource.PreMatch, taskCt);
|
||||||
|
scraped.Add((ev, snapshot));
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex,
|
||||||
|
"PullUpcomingEventsUseCase: failed to capture snapshot for event {EventId} — skipping",
|
||||||
|
ev.Id.Value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Phase 2 — sequential persistence. Upsert event row, then save the
|
||||||
|
// captured snapshot. Per-event try/catch keeps a single failure from
|
||||||
|
// aborting the whole cycle.
|
||||||
|
int newEvents = 0;
|
||||||
|
int snapshotsCaptured = 0;
|
||||||
|
foreach (var (ev, snapshot) in scraped)
|
||||||
|
{
|
||||||
|
ct.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var existing = await _eventRepo.GetAsync(ev.Id, ct);
|
||||||
|
if (existing is null)
|
||||||
|
{
|
||||||
|
await _eventRepo.AddAsync(ev, ct);
|
||||||
|
await _eventRepo.SaveChangesAsync(ct);
|
||||||
|
newEvents++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex,
|
||||||
|
"PullUpcomingEventsUseCase: failed to persist event {EventId} — skipping",
|
||||||
|
ev.Id.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _snapshotRepo.AddAsync(snapshot, ct);
|
||||||
|
await _snapshotRepo.SaveChangesAsync(ct);
|
||||||
|
snapshotsCaptured++;
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex,
|
||||||
|
"PullUpcomingEventsUseCase: failed to persist snapshot for event {EventId} — skipping",
|
||||||
|
ev.Id.Value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation(
|
||||||
|
"PullUpcomingEventsUseCase: cycle done — processed={Processed}, new={New}, snapshots={Snapshots}",
|
||||||
|
eventsProcessed, newEvents, snapshotsCaptured);
|
||||||
|
|
||||||
|
return (eventsProcessed, newEvents, snapshotsCaptured);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
using Marathon.Application.Abstractions;
|
||||||
|
using Marathon.Domain.Entities;
|
||||||
|
using Marathon.Domain.Enums;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using DomainEventId = Marathon.Domain.ValueObjects.EventId;
|
||||||
|
|
||||||
|
namespace Marathon.Application.UseCases;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Records a new <see cref="PlacedBet"/> entered manually via the Journal UI.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// The use case validates that the referenced event exists, then persists the
|
||||||
|
/// bet. If the event already has a final result the bet is graded on the spot
|
||||||
|
/// via <see cref="Marathon.Domain.Betting.BetOutcomeResolver"/> — saves the
|
||||||
|
/// user a round-trip to the resolver page when entering historical wagers.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class RecordPlacedBetUseCase
|
||||||
|
{
|
||||||
|
private readonly IPlacedBetRepository _bets;
|
||||||
|
private readonly IEventRepository _events;
|
||||||
|
private readonly IResultRepository _results;
|
||||||
|
private readonly ILogger<RecordPlacedBetUseCase> _logger;
|
||||||
|
|
||||||
|
public RecordPlacedBetUseCase(
|
||||||
|
IPlacedBetRepository bets,
|
||||||
|
IEventRepository events,
|
||||||
|
IResultRepository results,
|
||||||
|
ILogger<RecordPlacedBetUseCase> logger)
|
||||||
|
{
|
||||||
|
_bets = bets ?? throw new ArgumentNullException(nameof(bets));
|
||||||
|
_events = events ?? throw new ArgumentNullException(nameof(events));
|
||||||
|
_results = results ?? throw new ArgumentNullException(nameof(results));
|
||||||
|
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Persists <paramref name="bet"/>. Returns the bet as stored — if the
|
||||||
|
/// event already has a result, the returned instance reflects the graded
|
||||||
|
/// <see cref="BetOutcome"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <exception cref="InvalidOperationException">
|
||||||
|
/// The bet references an unknown event. The journal does not allow free-form
|
||||||
|
/// event codes — wagers must be on events the scraper has captured so the
|
||||||
|
/// CLV calculator can compare against the closing snapshot.
|
||||||
|
/// </exception>
|
||||||
|
public async Task<PlacedBet> ExecuteAsync(PlacedBet bet, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(bet);
|
||||||
|
|
||||||
|
// Confirm the event exists in the local store.
|
||||||
|
var ev = await _events.GetAsync(bet.EventId, ct).ConfigureAwait(false);
|
||||||
|
if (ev is null)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Cannot record a bet on unknown event '{bet.EventId.Value}'. " +
|
||||||
|
"The event must already be present in the scrape store.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var toPersist = bet;
|
||||||
|
|
||||||
|
// Auto-grade if a result is already available.
|
||||||
|
if (bet.Outcome == BetOutcome.Pending)
|
||||||
|
{
|
||||||
|
var result = await _results.GetAsync(bet.EventId, ct).ConfigureAwait(false);
|
||||||
|
if (result is not null)
|
||||||
|
{
|
||||||
|
var graded = Marathon.Domain.Betting.BetOutcomeResolver.Resolve(bet.Selection, result);
|
||||||
|
if (graded is not null)
|
||||||
|
{
|
||||||
|
toPersist = bet.WithOutcome(graded.Value);
|
||||||
|
_logger.LogInformation(
|
||||||
|
"RecordPlacedBetUseCase: bet {BetId} on event {EventId} auto-graded as {Outcome}",
|
||||||
|
toPersist.Id, ((DomainEventId)toPersist.EventId).Value, graded.Value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await _bets.AddAsync(toPersist, ct).ConfigureAwait(false);
|
||||||
|
await _bets.SaveChangesAsync(ct).ConfigureAwait(false);
|
||||||
|
|
||||||
|
_logger.LogInformation(
|
||||||
|
"RecordPlacedBetUseCase: persisted bet {BetId} on event {EventId} stake={Stake} rate={Rate}",
|
||||||
|
toPersist.Id, ((DomainEventId)toPersist.EventId).Value, toPersist.Stake, toPersist.Selection.Rate.Value);
|
||||||
|
|
||||||
|
return toPersist;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
using Marathon.Application.Abstractions;
|
||||||
|
using Marathon.Domain.Betting;
|
||||||
|
using Marathon.Domain.Entities;
|
||||||
|
using Marathon.Domain.Enums;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using DomainEventId = Marathon.Domain.ValueObjects.EventId;
|
||||||
|
|
||||||
|
namespace Marathon.Application.UseCases;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sweeps the journal for <see cref="BetOutcome.Pending"/> bets whose events
|
||||||
|
/// have been graded, and updates them in bulk via
|
||||||
|
/// <see cref="BetOutcomeResolver"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Called on demand from the Journal page's "Resolve pending" button. The
|
||||||
|
/// design is idempotent — bets that cannot be auto-graded (period-scope, or
|
||||||
|
/// no result yet) are left untouched and surface again on the next pass.
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class ResolvePendingBetsUseCase
|
||||||
|
{
|
||||||
|
private readonly IPlacedBetRepository _bets;
|
||||||
|
private readonly IResultRepository _results;
|
||||||
|
private readonly ILogger<ResolvePendingBetsUseCase> _logger;
|
||||||
|
|
||||||
|
public ResolvePendingBetsUseCase(
|
||||||
|
IPlacedBetRepository bets,
|
||||||
|
IResultRepository results,
|
||||||
|
ILogger<ResolvePendingBetsUseCase> logger)
|
||||||
|
{
|
||||||
|
_bets = bets ?? throw new ArgumentNullException(nameof(bets));
|
||||||
|
_results = results ?? throw new ArgumentNullException(nameof(results));
|
||||||
|
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the number of bets that were transitioned out of Pending in this pass.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<int> ExecuteAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var pending = await _bets.ListByOutcomeAsync(BetOutcome.Pending, ct).ConfigureAwait(false);
|
||||||
|
if (pending.Count == 0)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("ResolvePendingBetsUseCase: no pending bets");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache results per event so we do not re-query for each bet on the same event.
|
||||||
|
var resultCache = new Dictionary<DomainEventId, EventResult?>();
|
||||||
|
var resolvedCount = 0;
|
||||||
|
|
||||||
|
foreach (var bet in pending)
|
||||||
|
{
|
||||||
|
ct.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
if (!resultCache.TryGetValue(bet.EventId, out var result))
|
||||||
|
{
|
||||||
|
result = await _results.GetAsync(bet.EventId, ct).ConfigureAwait(false);
|
||||||
|
resultCache[bet.EventId] = result;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result is null) continue;
|
||||||
|
|
||||||
|
var graded = BetOutcomeResolver.Resolve(bet.Selection, result);
|
||||||
|
if (graded is null) continue;
|
||||||
|
|
||||||
|
var updated = bet.WithOutcome(graded.Value);
|
||||||
|
await _bets.UpdateAsync(updated, ct).ConfigureAwait(false);
|
||||||
|
resolvedCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save before logging — if the batch fails, an exception bubbles out and
|
||||||
|
// the success-count log is never emitted; we never report a graded count
|
||||||
|
// that was rolled back.
|
||||||
|
if (resolvedCount > 0)
|
||||||
|
await _bets.SaveChangesAsync(ct).ConfigureAwait(false);
|
||||||
|
|
||||||
|
_logger.LogInformation(
|
||||||
|
"ResolvePendingBetsUseCase: graded {Resolved} of {Pending} pending bets",
|
||||||
|
resolvedCount, pending.Count);
|
||||||
|
|
||||||
|
return resolvedCount;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
using Marathon.Application.Abstractions;
|
||||||
|
using Marathon.Application.Storage;
|
||||||
|
using Marathon.Domain.AnomalyDetection;
|
||||||
|
using Marathon.Domain.Backtesting;
|
||||||
|
using Marathon.Domain.Entities;
|
||||||
|
using Marathon.Domain.Enums;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using DomainEventId = Marathon.Domain.ValueObjects.EventId;
|
||||||
|
|
||||||
|
namespace Marathon.Application.UseCases;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Loads every persisted anomaly paired with its event metadata and result,
|
||||||
|
/// constructs <see cref="BacktestCandidate"/> rows, and runs the pure
|
||||||
|
/// <see cref="BacktestSimulator"/> with the supplied strategy.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Composes the two analytics features already in place: anomalies come from
|
||||||
|
/// the SuspensionFlip detector, and results come from the results loader. The
|
||||||
|
/// simulator never touches I/O — all data loading happens here, then the run
|
||||||
|
/// is a deterministic function of (strategy, candidates).
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Anomalies whose evidence JSON fails to parse, whose source events lack a
|
||||||
|
/// final result, or whose event row has been pruned are filtered out before
|
||||||
|
/// simulation. They are not counted as "skipped" by the simulator — the
|
||||||
|
/// simulator's <see cref="BacktestResult.Skipped"/> counter only reflects
|
||||||
|
/// runs the strategy chose not to bet on (below threshold, no edge, etc.).
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class RunBacktestUseCase
|
||||||
|
{
|
||||||
|
private readonly IAnomalyRepository _anomalies;
|
||||||
|
private readonly IEventRepository _events;
|
||||||
|
private readonly IResultRepository _results;
|
||||||
|
private readonly ILogger<RunBacktestUseCase> _logger;
|
||||||
|
|
||||||
|
public RunBacktestUseCase(
|
||||||
|
IAnomalyRepository anomalies,
|
||||||
|
IEventRepository events,
|
||||||
|
IResultRepository results,
|
||||||
|
ILogger<RunBacktestUseCase> logger)
|
||||||
|
{
|
||||||
|
_anomalies = anomalies ?? throw new ArgumentNullException(nameof(anomalies));
|
||||||
|
_events = events ?? throw new ArgumentNullException(nameof(events));
|
||||||
|
_results = results ?? throw new ArgumentNullException(nameof(results));
|
||||||
|
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Runs the backtest over every graded anomaly (no date filter).</summary>
|
||||||
|
public Task<BacktestResult> ExecuteAsync(
|
||||||
|
BacktestStrategy strategy,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
=> ExecuteAsync(strategy, dateRange: null, ct);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Runs the backtest over anomalies detected within <paramref name="dateRange"/>
|
||||||
|
/// (inclusive); pass <c>null</c> to include every graded anomaly. The date filter
|
||||||
|
/// is pushed to SQL via <see cref="IAnomalyRepository.ListByDateRangeAsync"/>.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<BacktestResult> ExecuteAsync(
|
||||||
|
BacktestStrategy strategy,
|
||||||
|
DateRange? dateRange,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(strategy);
|
||||||
|
|
||||||
|
_logger.LogInformation(
|
||||||
|
"RunBacktestUseCase: started — bankroll={Bankroll}, minScore={MinScore}, stakeRule={Rule}, range={Range}",
|
||||||
|
strategy.StartingBankroll, strategy.MinScore, strategy.StakeRule,
|
||||||
|
dateRange is null ? "all" : $"{dateRange.From:O}..{dateRange.To:O}");
|
||||||
|
|
||||||
|
var anomalies = dateRange is null
|
||||||
|
? await _anomalies.ListAsync(ct).ConfigureAwait(false)
|
||||||
|
: await _anomalies.ListByDateRangeAsync(dateRange.From, dateRange.To, ct).ConfigureAwait(false);
|
||||||
|
if (anomalies.Count == 0)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("RunBacktestUseCase: no anomalies — empty result");
|
||||||
|
return BacktestSimulator.Run(strategy, Array.Empty<BacktestCandidate>());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Batched lookups — a single query each, replacing the prior per-event
|
||||||
|
// GetAsync round-trip (N+1 against SQLite).
|
||||||
|
var distinctEventIds = anomalies.Select(a => a.EventId).Distinct().ToList();
|
||||||
|
|
||||||
|
var eventLookup = await _events.GetManyAsync(distinctEventIds, ct).ConfigureAwait(false);
|
||||||
|
var resultLookup = await _results.GetManyAsync(distinctEventIds, ct).ConfigureAwait(false);
|
||||||
|
|
||||||
|
var titles = new Dictionary<DomainEventId, string>(eventLookup.Count);
|
||||||
|
foreach (var (id, ev) in eventLookup)
|
||||||
|
titles[id] = ev.Title;
|
||||||
|
|
||||||
|
var candidates = new List<BacktestCandidate>(anomalies.Count);
|
||||||
|
foreach (var anomaly in anomalies)
|
||||||
|
{
|
||||||
|
ct.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
// Only directional kinds are betting signals; SuspensionFreeze (favourite
|
||||||
|
// unchanged) is informational and must not be staked or it would skew ROI.
|
||||||
|
if (!anomaly.Kind.IsDirectional())
|
||||||
|
continue;
|
||||||
|
|
||||||
|
// Cannot simulate a bet whose event hasn't been graded yet.
|
||||||
|
if (!resultLookup.TryGetValue(anomaly.EventId, out var result))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
if (!AnomalyEvidenceParser.TryParse(anomaly.EvidenceJson, out var evidence))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
eventLookup.TryGetValue(anomaly.EventId, out var ev);
|
||||||
|
candidates.Add(new BacktestCandidate(anomaly, evidence, result, ev?.Sport));
|
||||||
|
}
|
||||||
|
|
||||||
|
var simResult = BacktestSimulator.Run(strategy, candidates, titles);
|
||||||
|
|
||||||
|
_logger.LogInformation(
|
||||||
|
"RunBacktestUseCase: done — bets={Bets}, wins={Wins}, losses={Losses}, ROI={Roi:0.##}%, finalBankroll={Final}",
|
||||||
|
simResult.BetsPlaced, simResult.Wins, simResult.Losses,
|
||||||
|
simResult.RoiPercent ?? 0m, simResult.FinalBankroll);
|
||||||
|
|
||||||
|
return simResult;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
using Marathon.Application.Abstractions;
|
||||||
|
using Marathon.Domain.Backtesting;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Marathon.Application.UseCases;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Persists a named backtest-strategy preset. Upserts by name: saving under an
|
||||||
|
/// existing name overwrites that preset's configuration (keeping its identity and
|
||||||
|
/// original creation timestamp); a fresh name creates a new preset.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SaveStrategyUseCase
|
||||||
|
{
|
||||||
|
private readonly ISavedStrategyRepository _repo;
|
||||||
|
private readonly ILogger<SaveStrategyUseCase> _logger;
|
||||||
|
|
||||||
|
public SaveStrategyUseCase(ISavedStrategyRepository repo, ILogger<SaveStrategyUseCase> logger)
|
||||||
|
{
|
||||||
|
_repo = repo ?? throw new ArgumentNullException(nameof(repo));
|
||||||
|
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Saves <paramref name="strategy"/> under <paramref name="name"/>.</summary>
|
||||||
|
/// <exception cref="ArgumentException">The name is empty or exceeds the length bound.</exception>
|
||||||
|
public async Task<SavedStrategy> ExecuteAsync(
|
||||||
|
string name, BacktestStrategy strategy, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(strategy);
|
||||||
|
|
||||||
|
// Validates + trims the name once, up front (throws ArgumentException if bad).
|
||||||
|
var candidate = SavedStrategy.Create(name, strategy);
|
||||||
|
|
||||||
|
var existing = await _repo.GetByNameAsync(candidate.Name, ct).ConfigureAwait(false);
|
||||||
|
if (existing is not null)
|
||||||
|
{
|
||||||
|
var updated = existing with { Strategy = strategy };
|
||||||
|
await _repo.UpdateAsync(updated, ct).ConfigureAwait(false);
|
||||||
|
await _repo.SaveChangesAsync(ct).ConfigureAwait(false);
|
||||||
|
_logger.LogInformation(
|
||||||
|
"SaveStrategyUseCase: overwrote preset {Name} ({Id})", updated.Name, updated.Id);
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
await _repo.AddAsync(candidate, ct).ConfigureAwait(false);
|
||||||
|
await _repo.SaveChangesAsync(ct).ConfigureAwait(false);
|
||||||
|
_logger.LogInformation(
|
||||||
|
"SaveStrategyUseCase: created preset {Name} ({Id})", candidate.Name, candidate.Id);
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
using Marathon.Application.Abstractions;
|
||||||
|
using Marathon.Domain.Enums;
|
||||||
|
using Marathon.Domain.ValueObjects;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Marathon.Application.UseCases;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Settles every open (<see cref="BetOutcome.Pending"/>) paper bet whose event now has
|
||||||
|
/// a final result — Won when the picked side matches the winner, otherwise Lost. Bets
|
||||||
|
/// on events that aren't graded yet stay open and are retried next cycle.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SettlePaperBetsUseCase
|
||||||
|
{
|
||||||
|
private readonly IPaperBetRepository _paperBets;
|
||||||
|
private readonly IResultRepository _results;
|
||||||
|
private readonly ILogger<SettlePaperBetsUseCase> _logger;
|
||||||
|
|
||||||
|
public SettlePaperBetsUseCase(
|
||||||
|
IPaperBetRepository paperBets,
|
||||||
|
IResultRepository results,
|
||||||
|
ILogger<SettlePaperBetsUseCase> logger)
|
||||||
|
{
|
||||||
|
_paperBets = paperBets ?? throw new ArgumentNullException(nameof(paperBets));
|
||||||
|
_results = results ?? throw new ArgumentNullException(nameof(results));
|
||||||
|
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Returns the number of paper bets settled this pass.</summary>
|
||||||
|
public async Task<int> ExecuteAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var open = await _paperBets.ListByOutcomeAsync(BetOutcome.Pending, ct).ConfigureAwait(false);
|
||||||
|
if (open.Count == 0)
|
||||||
|
return 0;
|
||||||
|
|
||||||
|
// Batched result lookup — one query, not one per open bet.
|
||||||
|
var eventIds = open.Select(b => b.EventId).Distinct().ToList();
|
||||||
|
var results = await _results.GetManyAsync(eventIds, ct).ConfigureAwait(false);
|
||||||
|
|
||||||
|
var settledAt = MoscowTime.Now;
|
||||||
|
var settled = 0;
|
||||||
|
foreach (var bet in open)
|
||||||
|
{
|
||||||
|
ct.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
if (!results.TryGetValue(bet.EventId, out var result))
|
||||||
|
continue; // event not graded yet
|
||||||
|
|
||||||
|
await _paperBets.UpdateAsync(bet.SettleAgainst(result.WinnerSide, settledAt), ct).ConfigureAwait(false);
|
||||||
|
settled++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (settled > 0)
|
||||||
|
{
|
||||||
|
await _paperBets.SaveChangesAsync(ct).ConfigureAwait(false);
|
||||||
|
_logger.LogInformation("SettlePaperBetsUseCase: settled {Count} paper bet(s)", settled);
|
||||||
|
}
|
||||||
|
|
||||||
|
return settled;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
using Marathon.Application.Abstractions;
|
||||||
|
using Marathon.Domain.Entities;
|
||||||
|
using Marathon.Domain.Enums;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using DomainEventId = Marathon.Domain.ValueObjects.EventId;
|
||||||
|
|
||||||
|
namespace Marathon.Application.UseCases;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Edits an existing <see cref="PlacedBet"/> in the journal — selection, stake, or
|
||||||
|
/// notes. The original <see cref="PlacedBet.PlacedAt"/> is preserved; the outcome is
|
||||||
|
/// re-graded from scratch (so changing the selection or event re-settles correctly).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class UpdatePlacedBetUseCase
|
||||||
|
{
|
||||||
|
private readonly IPlacedBetRepository _bets;
|
||||||
|
private readonly IEventRepository _events;
|
||||||
|
private readonly IResultRepository _results;
|
||||||
|
private readonly ILogger<UpdatePlacedBetUseCase> _logger;
|
||||||
|
|
||||||
|
public UpdatePlacedBetUseCase(
|
||||||
|
IPlacedBetRepository bets,
|
||||||
|
IEventRepository events,
|
||||||
|
IResultRepository results,
|
||||||
|
ILogger<UpdatePlacedBetUseCase> logger)
|
||||||
|
{
|
||||||
|
_bets = bets ?? throw new ArgumentNullException(nameof(bets));
|
||||||
|
_events = events ?? throw new ArgumentNullException(nameof(events));
|
||||||
|
_results = results ?? throw new ArgumentNullException(nameof(results));
|
||||||
|
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <exception cref="InvalidOperationException">
|
||||||
|
/// The bet id is unknown, or the (possibly changed) event isn't in the store.
|
||||||
|
/// </exception>
|
||||||
|
public async Task<PlacedBet> ExecuteAsync(
|
||||||
|
Guid id,
|
||||||
|
DomainEventId eventId,
|
||||||
|
Bet selection,
|
||||||
|
decimal stake,
|
||||||
|
string? notes,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(eventId);
|
||||||
|
ArgumentNullException.ThrowIfNull(selection);
|
||||||
|
|
||||||
|
var existing = await _bets.GetAsync(id, ct).ConfigureAwait(false)
|
||||||
|
?? throw new InvalidOperationException($"Cannot update unknown bet '{id}'.");
|
||||||
|
|
||||||
|
var ev = await _events.GetAsync(eventId, ct).ConfigureAwait(false);
|
||||||
|
if (ev is null)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Cannot point a bet at unknown event '{eventId.Value}'. " +
|
||||||
|
"The event must already be present in the scrape store.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only the selection or the event affects grading. When neither changed (e.g. a
|
||||||
|
// stake/notes-only edit) keep the existing outcome — re-grading from Pending here
|
||||||
|
// would SILENTLY UN-SETTLE a won/lost bet whose result row has since been pruned by
|
||||||
|
// snapshot retention (the journal is FK-free and outlives result pruning). A
|
||||||
|
// still-Pending bet is always (re)graded, mirroring RecordPlacedBetUseCase.
|
||||||
|
var gradingInputChanged = !existing.EventId.Equals(eventId)
|
||||||
|
|| !existing.Selection.Equals(selection);
|
||||||
|
var regrade = gradingInputChanged || existing.Outcome == BetOutcome.Pending;
|
||||||
|
|
||||||
|
var toPersist = new PlacedBet(
|
||||||
|
Id: id,
|
||||||
|
EventId: eventId,
|
||||||
|
Selection: selection,
|
||||||
|
Stake: stake,
|
||||||
|
PlacedAt: existing.PlacedAt,
|
||||||
|
Outcome: regrade ? BetOutcome.Pending : existing.Outcome,
|
||||||
|
Notes: notes);
|
||||||
|
|
||||||
|
if (regrade)
|
||||||
|
{
|
||||||
|
var result = await _results.GetAsync(eventId, ct).ConfigureAwait(false);
|
||||||
|
if (result is not null)
|
||||||
|
{
|
||||||
|
var graded = Marathon.Domain.Betting.BetOutcomeResolver.Resolve(toPersist.Selection, result);
|
||||||
|
if (graded is not null)
|
||||||
|
toPersist = toPersist.WithOutcome(graded.Value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await _bets.UpdateAsync(toPersist, ct).ConfigureAwait(false);
|
||||||
|
await _bets.SaveChangesAsync(ct).ConfigureAwait(false);
|
||||||
|
|
||||||
|
_logger.LogInformation(
|
||||||
|
"UpdatePlacedBetUseCase: updated bet {BetId} on event {EventId} stake={Stake} outcome={Outcome}",
|
||||||
|
id, eventId.Value, stake, toPersist.Outcome);
|
||||||
|
|
||||||
|
return toPersist;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
using Marathon.Domain.Entities;
|
||||||
|
using Marathon.Domain.Enums;
|
||||||
|
using Marathon.Domain.ValueObjects;
|
||||||
|
|
||||||
|
namespace Marathon.Domain.AnomalyDetection;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pure domain service that analyses a chronological sequence of live <see cref="OddsSnapshot"/>
|
||||||
|
/// records for a single event and returns any detected <see cref="Anomaly"/> instances.
|
||||||
|
///
|
||||||
|
/// Algorithm (SuspensionFlip):
|
||||||
|
/// <list type="number">
|
||||||
|
/// <item>Filter to <see cref="OddsSource.Live"/> snapshots and sort by <c>CapturedAt</c>.</item>
|
||||||
|
/// <item>Return empty if fewer than <c>minSnapshotCount</c> live snapshots are available.</item>
|
||||||
|
/// <item>Walk adjacent pairs; identify gaps larger than <c>suspensionGapSeconds</c>.</item>
|
||||||
|
/// <item>For each suspension, extract Match-Win bets from pre/post snapshots, compute
|
||||||
|
/// implied probability vectors and normalise them to sum to 1.</item>
|
||||||
|
/// <item>Compute flip score = max(|p_post[i] − p_pre[i]|) across sides.</item>
|
||||||
|
/// <item>If flip score ≥ <c>oddsFlipThreshold</c> AND the favourite changed
|
||||||
|
/// (argmax of implied probabilities differs), emit one <see cref="Anomaly"/>.</item>
|
||||||
|
/// </list>
|
||||||
|
///
|
||||||
|
/// This class is stateless and deterministic — identical inputs always produce identical output.
|
||||||
|
/// It has no I/O or DI dependencies. Evidence formatting is delegated to
|
||||||
|
/// <see cref="MatchWinEvidence"/> so every detector kind writes the identical shape.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class AnomalyDetector : IAnomalyDetector
|
||||||
|
{
|
||||||
|
private readonly int _suspensionGapSeconds;
|
||||||
|
private readonly decimal _oddsFlipThreshold;
|
||||||
|
private readonly int _minSnapshotCount;
|
||||||
|
|
||||||
|
/// <param name="suspensionGapSeconds">
|
||||||
|
/// Minimum gap between adjacent live snapshots (in seconds) to classify as a suspension.
|
||||||
|
/// Default per spec: 60.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="oddsFlipThreshold">
|
||||||
|
/// Minimum implied-probability delta to classify a post-suspension odds change as a flip.
|
||||||
|
/// Default per spec: 0.30 (30 percentage points).
|
||||||
|
/// </param>
|
||||||
|
/// <param name="minSnapshotCount">
|
||||||
|
/// Minimum number of live snapshots required before detection runs.
|
||||||
|
/// Default per spec: 3.
|
||||||
|
/// </param>
|
||||||
|
public AnomalyDetector(int suspensionGapSeconds, decimal oddsFlipThreshold, int minSnapshotCount)
|
||||||
|
{
|
||||||
|
if (suspensionGapSeconds <= 0)
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(suspensionGapSeconds),
|
||||||
|
suspensionGapSeconds, "Must be positive.");
|
||||||
|
|
||||||
|
if (oddsFlipThreshold is <= 0m or >= 1m)
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(oddsFlipThreshold),
|
||||||
|
oddsFlipThreshold, "Must be in (0, 1).");
|
||||||
|
|
||||||
|
if (minSnapshotCount < 2)
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(minSnapshotCount),
|
||||||
|
minSnapshotCount, "Must be at least 2 to form at least one pair.");
|
||||||
|
|
||||||
|
_suspensionGapSeconds = suspensionGapSeconds;
|
||||||
|
_oddsFlipThreshold = oddsFlipThreshold;
|
||||||
|
_minSnapshotCount = minSnapshotCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public IReadOnlyList<Anomaly> Detect(EventId eventId, IReadOnlyList<OddsSnapshot> snapshots)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(eventId);
|
||||||
|
ArgumentNullException.ThrowIfNull(snapshots);
|
||||||
|
|
||||||
|
// Step 1 — filter to Live snapshots only; suspension/flip is a live phenomenon.
|
||||||
|
var liveSnapshots = snapshots
|
||||||
|
.Where(s => s.Source == OddsSource.Live)
|
||||||
|
.OrderBy(s => s.CapturedAt)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
// Step 2 — guard: need a minimum count to form meaningful intervals.
|
||||||
|
if (liveSnapshots.Count < _minSnapshotCount)
|
||||||
|
return Array.Empty<Anomaly>();
|
||||||
|
|
||||||
|
var anomalies = new List<Anomaly>();
|
||||||
|
var suspensionGap = TimeSpan.FromSeconds(_suspensionGapSeconds);
|
||||||
|
|
||||||
|
// Step 3 — identify suspension intervals.
|
||||||
|
for (int i = 0; i < liveSnapshots.Count - 1; i++)
|
||||||
|
{
|
||||||
|
var pre = liveSnapshots[i];
|
||||||
|
var post = liveSnapshots[i + 1];
|
||||||
|
|
||||||
|
var gap = post.CapturedAt - pre.CapturedAt;
|
||||||
|
if (gap <= suspensionGap)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var interval = new SuspensionInterval(pre, post);
|
||||||
|
var anomaly = TryDetectFlip(eventId, interval);
|
||||||
|
if (anomaly is not null)
|
||||||
|
anomalies.Add(anomaly);
|
||||||
|
}
|
||||||
|
|
||||||
|
return anomalies.AsReadOnly();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Private helpers ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private Anomaly? TryDetectFlip(EventId eventId, SuspensionInterval interval)
|
||||||
|
{
|
||||||
|
// Extract Match-Win implied probabilities from each snapshot.
|
||||||
|
var preProbs = MatchWinEvidence.Extract(interval.PreSuspension);
|
||||||
|
var postProbs = MatchWinEvidence.Extract(interval.PostSuspension);
|
||||||
|
|
||||||
|
// Cannot compute flip if either snapshot lacks Win bets.
|
||||||
|
if (preProbs is null || postProbs is null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
// Step 4 — compute flip score = max(|p_post[i] − p_pre[i]|) across common sides.
|
||||||
|
decimal flipScore = 0m;
|
||||||
|
flipScore = Math.Max(flipScore, Math.Abs(postProbs.P1 - preProbs.P1));
|
||||||
|
flipScore = Math.Max(flipScore, Math.Abs(postProbs.P2 - preProbs.P2));
|
||||||
|
if (preProbs.PDraw.HasValue && postProbs.PDraw.HasValue)
|
||||||
|
{
|
||||||
|
flipScore = Math.Max(flipScore,
|
||||||
|
Math.Abs(postProbs.PDraw.Value - preProbs.PDraw.Value));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 5 — favourite-changed test: argmax of implied probability must differ.
|
||||||
|
bool favouriteChanged =
|
||||||
|
MatchWinEvidence.Favourite(preProbs) != MatchWinEvidence.Favourite(postProbs);
|
||||||
|
|
||||||
|
if (flipScore < _oddsFlipThreshold || !favouriteChanged)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
// Clamp score to [0, 1] before constructing the Anomaly (domain invariant).
|
||||||
|
var clampedScore = Math.Min(1m, flipScore);
|
||||||
|
|
||||||
|
// Step 6 — build evidence JSON via the shared formatter.
|
||||||
|
var evidenceJson = MatchWinEvidence.BuildJson(
|
||||||
|
(int)interval.Gap.TotalSeconds,
|
||||||
|
interval.PreSuspension, preProbs,
|
||||||
|
interval.PostSuspension, postProbs);
|
||||||
|
|
||||||
|
return new Anomaly(
|
||||||
|
Id: Guid.NewGuid(),
|
||||||
|
EventId: eventId,
|
||||||
|
DetectedAt: MoscowTime.Now,
|
||||||
|
Kind: AnomalyKind.SuspensionFlip,
|
||||||
|
Score: clampedScore,
|
||||||
|
EvidenceJson: evidenceJson);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using Marathon.Domain.Entities;
|
||||||
|
using Marathon.Domain.Enums;
|
||||||
|
|
||||||
|
namespace Marathon.Domain.AnomalyDetection;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Strongly typed projection of the JSON payload written by <see cref="AnomalyDetector"/>
|
||||||
|
/// into <see cref="Anomaly.EvidenceJson"/>. Captures pre- and post-suspension snapshots
|
||||||
|
/// of normalised implied probabilities and raw rates for the Match-Win market.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The evaluator and any reader that needs to inspect an anomaly's evidence should
|
||||||
|
/// parse via <see cref="AnomalyEvidenceParser.TryParse"/> rather than re-implement
|
||||||
|
/// the JSON shape — the detector owns the schema.
|
||||||
|
/// </remarks>
|
||||||
|
public sealed record AnomalyEvidenceData(
|
||||||
|
int SuspensionGapSeconds,
|
||||||
|
AnomalyEvidenceSide PreSuspension,
|
||||||
|
AnomalyEvidenceSide PostSuspension);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One side (pre or post) of a suspension interval. Probabilities are normalised
|
||||||
|
/// so that <c>P1 + (PDraw ?? 0) + P2 == 1</c>. Two-way markets (e.g. tennis)
|
||||||
|
/// leave <see cref="PDraw"/> and <see cref="RateDraw"/> null.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record AnomalyEvidenceSide(
|
||||||
|
DateTimeOffset CapturedAt,
|
||||||
|
decimal P1,
|
||||||
|
decimal? PDraw,
|
||||||
|
decimal P2,
|
||||||
|
decimal Rate1,
|
||||||
|
decimal? RateDraw,
|
||||||
|
decimal Rate2)
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The side carrying the highest normalised implied probability — i.e.,
|
||||||
|
/// the bookmaker's favourite at this point in time.
|
||||||
|
/// </summary>
|
||||||
|
public Side Favourite
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
// Three-way: include Draw in the argmax.
|
||||||
|
var best = Side.Side1;
|
||||||
|
var bestValue = P1;
|
||||||
|
if (PDraw is { } pd && pd > bestValue)
|
||||||
|
{
|
||||||
|
best = Side.Draw;
|
||||||
|
bestValue = pd;
|
||||||
|
}
|
||||||
|
if (P2 > bestValue)
|
||||||
|
{
|
||||||
|
best = Side.Side2;
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The decimal rate offered on <paramref name="side"/> at this snapshot, or null
|
||||||
|
/// for a non-win side (Less/More) or an absent Draw market.
|
||||||
|
/// </summary>
|
||||||
|
public decimal? RateFor(Side side) => side switch
|
||||||
|
{
|
||||||
|
Side.Side1 => Rate1,
|
||||||
|
Side.Side2 => Rate2,
|
||||||
|
Side.Draw => RateDraw,
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parses the <see cref="Anomaly.EvidenceJson"/> string emitted by
|
||||||
|
/// <see cref="AnomalyDetector"/>. Tolerant of malformed payloads — returns false
|
||||||
|
/// rather than throwing so callers can skip un-parseable anomalies silently.
|
||||||
|
/// </summary>
|
||||||
|
public static class AnomalyEvidenceParser
|
||||||
|
{
|
||||||
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||||
|
{
|
||||||
|
PropertyNameCaseInsensitive = true,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Attempts to deserialise the evidence JSON. Returns <c>true</c> only when
|
||||||
|
/// both pre- and post-suspension snapshots are present.
|
||||||
|
/// </summary>
|
||||||
|
public static bool TryParse(string? evidenceJson, out AnomalyEvidenceData data)
|
||||||
|
{
|
||||||
|
data = default!;
|
||||||
|
if (string.IsNullOrWhiteSpace(evidenceJson)) return false;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var dto = JsonSerializer.Deserialize<EvidenceDto>(evidenceJson, JsonOptions);
|
||||||
|
if (dto is null || dto.PreSuspension is null || dto.PostSuspension is null)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
data = new AnomalyEvidenceData(
|
||||||
|
SuspensionGapSeconds: dto.SuspensionGapSeconds,
|
||||||
|
PreSuspension: ToSide(dto.PreSuspension),
|
||||||
|
PostSuspension: ToSide(dto.PostSuspension));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (JsonException)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static AnomalyEvidenceSide ToSide(EvidenceSideDto dto) =>
|
||||||
|
new(
|
||||||
|
CapturedAt: dto.CapturedAt,
|
||||||
|
P1: dto.P1 ?? 0m,
|
||||||
|
PDraw: dto.PDraw,
|
||||||
|
P2: dto.P2 ?? 0m,
|
||||||
|
Rate1: dto.Rate1 ?? 0m,
|
||||||
|
RateDraw: dto.RateDraw,
|
||||||
|
Rate2: dto.Rate2 ?? 0m);
|
||||||
|
|
||||||
|
private sealed class EvidenceDto
|
||||||
|
{
|
||||||
|
[JsonPropertyName("suspensionGapSeconds")]
|
||||||
|
public int SuspensionGapSeconds { get; init; }
|
||||||
|
|
||||||
|
[JsonPropertyName("preSuspension")]
|
||||||
|
public EvidenceSideDto? PreSuspension { get; init; }
|
||||||
|
|
||||||
|
[JsonPropertyName("postSuspension")]
|
||||||
|
public EvidenceSideDto? PostSuspension { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class EvidenceSideDto
|
||||||
|
{
|
||||||
|
[JsonPropertyName("capturedAt")]
|
||||||
|
public DateTimeOffset CapturedAt { get; init; }
|
||||||
|
|
||||||
|
[JsonPropertyName("p1")]
|
||||||
|
public decimal? P1 { get; init; }
|
||||||
|
|
||||||
|
[JsonPropertyName("pDraw")]
|
||||||
|
public decimal? PDraw { get; init; }
|
||||||
|
|
||||||
|
[JsonPropertyName("p2")]
|
||||||
|
public decimal? P2 { get; init; }
|
||||||
|
|
||||||
|
[JsonPropertyName("rate1")]
|
||||||
|
public decimal? Rate1 { get; init; }
|
||||||
|
|
||||||
|
[JsonPropertyName("rateDraw")]
|
||||||
|
public decimal? RateDraw { get; init; }
|
||||||
|
|
||||||
|
[JsonPropertyName("rate2")]
|
||||||
|
public decimal? Rate2 { get; init; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
using Marathon.Domain.Entities;
|
||||||
|
using Marathon.Domain.Enums;
|
||||||
|
using Marathon.Domain.ValueObjects;
|
||||||
|
|
||||||
|
namespace Marathon.Domain.AnomalyDetection;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verdict produced by comparing an anomaly's predicted post-flip favourite
|
||||||
|
/// against the actual <see cref="EventResult.WinnerSide"/>.
|
||||||
|
/// </summary>
|
||||||
|
public enum AnomalyOutcomeKind
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The post-flip favourite (the side the bookmaker shortened odds on AFTER
|
||||||
|
/// the suspension) ended up winning. The flip was directionally correct.
|
||||||
|
/// </summary>
|
||||||
|
Hit,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The post-flip favourite did NOT win. The flip pointed at the wrong side.
|
||||||
|
/// </summary>
|
||||||
|
Miss,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// No <see cref="EventResult"/> is available yet — outcome cannot be judged.
|
||||||
|
/// </summary>
|
||||||
|
Unresolved,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One anomaly paired with its evaluated outcome. Surfaced to the UI so each
|
||||||
|
/// resolved anomaly can be reviewed individually (e.g., when investigating
|
||||||
|
/// why the algorithm got a specific event wrong).
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <see cref="PreFlipFavourite"/> and <see cref="PostFlipFavourite"/> are null
|
||||||
|
/// when the anomaly's evidence JSON could not be parsed — the outcome will be
|
||||||
|
/// <see cref="AnomalyOutcomeKind.Unresolved"/> in that case. Encoding the
|
||||||
|
/// absence keeps consumers from being shown a fabricated side.
|
||||||
|
/// </remarks>
|
||||||
|
public sealed record ResolvedAnomaly(
|
||||||
|
Guid AnomalyId,
|
||||||
|
EventId EventId,
|
||||||
|
DateTimeOffset DetectedAt,
|
||||||
|
decimal Score,
|
||||||
|
AnomalyKind Kind,
|
||||||
|
SportCode? Sport,
|
||||||
|
Side? PreFlipFavourite,
|
||||||
|
Side? PostFlipFavourite,
|
||||||
|
Side? ActualWinner,
|
||||||
|
AnomalyOutcomeKind Outcome);
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
using Marathon.Domain.Entities;
|
||||||
|
using Marathon.Domain.Enums;
|
||||||
|
using Marathon.Domain.ValueObjects;
|
||||||
|
|
||||||
|
namespace Marathon.Domain.AnomalyDetection;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pure domain function that evaluates whether a <see cref="AnomalyKind.SuspensionFlip"/>
|
||||||
|
/// anomaly's prediction (the post-suspension favourite) matched the actual
|
||||||
|
/// <see cref="EventResult.WinnerSide"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// A "hit" is recorded when the side carrying the highest implied probability
|
||||||
|
/// in <see cref="AnomalyEvidenceData.PostSuspension"/> equals
|
||||||
|
/// <see cref="EventResult.WinnerSide"/>. For two-way markets (tennis), Draw is
|
||||||
|
/// not a possible favourite — the evaluator naturally never emits Draw there.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Stateless, deterministic, no I/O. Safe to call in tight loops.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public static class AnomalyOutcomeEvaluator
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Evaluates one anomaly against its event (optional metadata) and its result
|
||||||
|
/// (optional — null when the match hasn't been graded yet).
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="anomaly">The persisted anomaly.</param>
|
||||||
|
/// <param name="sport">
|
||||||
|
/// The event's sport — surfaced into <see cref="ResolvedAnomaly"/> so the UI
|
||||||
|
/// can group by sport. Null when the originating event row is missing.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="result">The event's final result, if known.</param>
|
||||||
|
/// <returns>
|
||||||
|
/// A <see cref="ResolvedAnomaly"/> with <see cref="AnomalyOutcomeKind.Unresolved"/>
|
||||||
|
/// when <paramref name="result"/> is null or the evidence JSON cannot be parsed,
|
||||||
|
/// otherwise <see cref="AnomalyOutcomeKind.Hit"/> / <see cref="AnomalyOutcomeKind.Miss"/>.
|
||||||
|
/// </returns>
|
||||||
|
public static ResolvedAnomaly Evaluate(
|
||||||
|
Anomaly anomaly,
|
||||||
|
SportCode? sport,
|
||||||
|
EventResult? result)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(anomaly);
|
||||||
|
|
||||||
|
if (!AnomalyEvidenceParser.TryParse(anomaly.EvidenceJson, out var data))
|
||||||
|
{
|
||||||
|
// Cannot determine favourite without evidence; treat as unresolved.
|
||||||
|
return new ResolvedAnomaly(
|
||||||
|
AnomalyId: anomaly.Id,
|
||||||
|
EventId: anomaly.EventId,
|
||||||
|
DetectedAt: anomaly.DetectedAt,
|
||||||
|
Score: anomaly.Score,
|
||||||
|
Kind: anomaly.Kind,
|
||||||
|
Sport: sport,
|
||||||
|
PreFlipFavourite: null,
|
||||||
|
PostFlipFavourite: null,
|
||||||
|
ActualWinner: result?.WinnerSide,
|
||||||
|
Outcome: AnomalyOutcomeKind.Unresolved);
|
||||||
|
}
|
||||||
|
|
||||||
|
var preFav = data.PreSuspension.Favourite;
|
||||||
|
var postFav = data.PostSuspension.Favourite;
|
||||||
|
|
||||||
|
// Non-directional kinds (e.g. SuspensionFreeze — the favourite did NOT change)
|
||||||
|
// make no side prediction. Grading them as "favourite won" would just measure the
|
||||||
|
// base favourite-win rate, polluting the hit-rate and score-bin calibration, so we
|
||||||
|
// leave them Unresolved (the favourites are still surfaced for display).
|
||||||
|
if (!anomaly.Kind.IsDirectional())
|
||||||
|
{
|
||||||
|
return new ResolvedAnomaly(
|
||||||
|
AnomalyId: anomaly.Id,
|
||||||
|
EventId: anomaly.EventId,
|
||||||
|
DetectedAt: anomaly.DetectedAt,
|
||||||
|
Score: anomaly.Score,
|
||||||
|
Kind: anomaly.Kind,
|
||||||
|
Sport: sport,
|
||||||
|
PreFlipFavourite: preFav,
|
||||||
|
PostFlipFavourite: postFav,
|
||||||
|
ActualWinner: result?.WinnerSide,
|
||||||
|
Outcome: AnomalyOutcomeKind.Unresolved);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result is null)
|
||||||
|
{
|
||||||
|
return new ResolvedAnomaly(
|
||||||
|
AnomalyId: anomaly.Id,
|
||||||
|
EventId: anomaly.EventId,
|
||||||
|
DetectedAt: anomaly.DetectedAt,
|
||||||
|
Score: anomaly.Score,
|
||||||
|
Kind: anomaly.Kind,
|
||||||
|
Sport: sport,
|
||||||
|
PreFlipFavourite: preFav,
|
||||||
|
PostFlipFavourite: postFav,
|
||||||
|
ActualWinner: null,
|
||||||
|
Outcome: AnomalyOutcomeKind.Unresolved);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Guard rail for sport-specific impossibilities. A two-way market
|
||||||
|
// (e.g. tennis) cannot produce a Draw outcome — if one shows up the
|
||||||
|
// EventResult disagrees with the evidence schema, so we refuse to
|
||||||
|
// grade it instead of silently counting it as a Miss.
|
||||||
|
var isTwoWay = data.PreSuspension.PDraw is null && data.PostSuspension.PDraw is null;
|
||||||
|
if (isTwoWay && result.WinnerSide == Side.Draw)
|
||||||
|
{
|
||||||
|
return new ResolvedAnomaly(
|
||||||
|
AnomalyId: anomaly.Id,
|
||||||
|
EventId: anomaly.EventId,
|
||||||
|
DetectedAt: anomaly.DetectedAt,
|
||||||
|
Score: anomaly.Score,
|
||||||
|
Kind: anomaly.Kind,
|
||||||
|
Sport: sport,
|
||||||
|
PreFlipFavourite: preFav,
|
||||||
|
PostFlipFavourite: postFav,
|
||||||
|
ActualWinner: result.WinnerSide,
|
||||||
|
Outcome: AnomalyOutcomeKind.Unresolved);
|
||||||
|
}
|
||||||
|
|
||||||
|
var outcome = postFav == result.WinnerSide
|
||||||
|
? AnomalyOutcomeKind.Hit
|
||||||
|
: AnomalyOutcomeKind.Miss;
|
||||||
|
|
||||||
|
return new ResolvedAnomaly(
|
||||||
|
AnomalyId: anomaly.Id,
|
||||||
|
EventId: anomaly.EventId,
|
||||||
|
DetectedAt: anomaly.DetectedAt,
|
||||||
|
Score: anomaly.Score,
|
||||||
|
Kind: anomaly.Kind,
|
||||||
|
Sport: sport,
|
||||||
|
PreFlipFavourite: preFav,
|
||||||
|
PostFlipFavourite: postFav,
|
||||||
|
ActualWinner: result.WinnerSide,
|
||||||
|
Outcome: outcome);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
namespace Marathon.Domain.AnomalyDetection;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Single source of truth for the severity bucket boundaries that the UI
|
||||||
|
/// pill / badge, the Insights breakdowns, and any future reporter share.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Buckets are inclusive on the left, exclusive on the right (except High
|
||||||
|
/// which extends to 1.00 inclusive):
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item>Low [<see cref="Low"/>, <see cref="Medium"/>)</item>
|
||||||
|
/// <item>Medium [<see cref="Medium"/>, <see cref="High"/>)</item>
|
||||||
|
/// <item>High [<see cref="High"/>, 1.00]</item>
|
||||||
|
/// </list>
|
||||||
|
/// Defined at the Domain layer so both the Application reporter and the
|
||||||
|
/// Marathon.UI severity rules consume the same numbers — re-tuning happens
|
||||||
|
/// in one place.
|
||||||
|
/// </remarks>
|
||||||
|
public static class AnomalySeverityThresholds
|
||||||
|
{
|
||||||
|
/// <summary>Lower bound of the Low bucket. Matches the detector's default flip threshold.</summary>
|
||||||
|
public const decimal Low = 0.30m;
|
||||||
|
|
||||||
|
/// <summary>Lower bound of the Medium bucket.</summary>
|
||||||
|
public const decimal Medium = 0.45m;
|
||||||
|
|
||||||
|
/// <summary>Lower bound of the High bucket.</summary>
|
||||||
|
public const decimal High = 0.60m;
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
using Marathon.Domain.Entities;
|
||||||
|
using Marathon.Domain.ValueObjects;
|
||||||
|
|
||||||
|
namespace Marathon.Domain.AnomalyDetection;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A pure, stateless detector that scans one event's snapshot timeline and returns
|
||||||
|
/// any anomalies it finds. Implementations are deterministic and free of I/O so they
|
||||||
|
/// can be composed (fanned out) and unit-tested in isolation.
|
||||||
|
/// </summary>
|
||||||
|
public interface IAnomalyDetector
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Analyses <paramref name="snapshots"/> for <paramref name="eventId"/> and returns
|
||||||
|
/// 0 or more anomalies. May be empty; never null.
|
||||||
|
/// </summary>
|
||||||
|
IReadOnlyList<Anomaly> Detect(EventId eventId, IReadOnlyList<OddsSnapshot> snapshots);
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using Marathon.Domain.Entities;
|
||||||
|
using Marathon.Domain.Enums;
|
||||||
|
using Marathon.Domain.ValueObjects;
|
||||||
|
|
||||||
|
namespace Marathon.Domain.AnomalyDetection;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Shared helper for the match-win implied-probability extraction and the canonical
|
||||||
|
/// pre/post evidence-JSON shape used by every <see cref="IAnomalyDetector"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Centralising the evidence format here guarantees that all detector kinds write the
|
||||||
|
/// identical on-disk shape, so the UI parser (<c>AnomalyEvidenceParser</c>) and the
|
||||||
|
/// outcome evaluator (<see cref="AnomalyOutcomeEvaluator"/>) work for every kind
|
||||||
|
/// without branching. The <c>suspensionGapSeconds</c> field carries the elapsed
|
||||||
|
/// seconds between the two snapshots — a suspension gap for flips, a drift window for
|
||||||
|
/// steam moves.
|
||||||
|
/// </remarks>
|
||||||
|
internal static class MatchWinEvidence
|
||||||
|
{
|
||||||
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||||
|
{
|
||||||
|
WriteIndented = false,
|
||||||
|
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Normalised match-win implied probabilities + raw rates for a snapshot.
|
||||||
|
/// <see cref="Overround"/> is the raw implied-probability sum (the bookmaker's
|
||||||
|
/// margin/vig, >= 1.0) before normalisation.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record Probabilities(
|
||||||
|
decimal P1,
|
||||||
|
decimal? PDraw,
|
||||||
|
decimal P2,
|
||||||
|
decimal Rate1,
|
||||||
|
decimal? RateDraw,
|
||||||
|
decimal Rate2,
|
||||||
|
decimal Overround);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Extracts normalised match-win implied probabilities, or null when the snapshot
|
||||||
|
/// lacks both Side1 and Side2 Match-Win bets.
|
||||||
|
/// </summary>
|
||||||
|
public static Probabilities? Extract(OddsSnapshot snapshot)
|
||||||
|
{
|
||||||
|
var matchWinBets = snapshot.Bets
|
||||||
|
.Where(b => b.Scope is MatchScope && b.Type == BetType.Win)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var win1 = matchWinBets.FirstOrDefault(b => b.Side == Side.Side1);
|
||||||
|
var win2 = matchWinBets.FirstOrDefault(b => b.Side == Side.Side2);
|
||||||
|
if (win1 is null || win2 is null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var drawBet = snapshot.Bets
|
||||||
|
.FirstOrDefault(b => b.Scope is MatchScope && b.Type == BetType.Draw);
|
||||||
|
|
||||||
|
// Raw implied probabilities: p = 1 / rate; normalise so they sum to 1.
|
||||||
|
decimal rawP1 = 1m / win1.Rate.Value;
|
||||||
|
decimal rawP2 = 1m / win2.Rate.Value;
|
||||||
|
decimal rawDraw = drawBet is not null ? 1m / drawBet.Rate.Value : 0m;
|
||||||
|
decimal total = rawP1 + rawP2 + rawDraw;
|
||||||
|
|
||||||
|
return new Probabilities(
|
||||||
|
P1: rawP1 / total,
|
||||||
|
PDraw: drawBet is not null ? rawDraw / total : null,
|
||||||
|
P2: rawP2 / total,
|
||||||
|
Rate1: win1.Rate.Value,
|
||||||
|
RateDraw: drawBet?.Rate.Value,
|
||||||
|
Rate2: win2.Rate.Value,
|
||||||
|
Overround: total);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Label of the side carrying the highest normalised implied probability.</summary>
|
||||||
|
public static string Favourite(Probabilities p)
|
||||||
|
{
|
||||||
|
if (p.PDraw.HasValue && p.PDraw.Value > p.P1 && p.PDraw.Value > p.P2)
|
||||||
|
return "Draw";
|
||||||
|
return p.P1 >= p.P2 ? "Side1" : "Side2";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Serialises the canonical pre/post evidence payload.</summary>
|
||||||
|
public static string BuildJson(
|
||||||
|
int gapSeconds,
|
||||||
|
OddsSnapshot pre,
|
||||||
|
Probabilities preProbs,
|
||||||
|
OddsSnapshot post,
|
||||||
|
Probabilities postProbs)
|
||||||
|
{
|
||||||
|
var payload = new EvidencePayload(
|
||||||
|
SuspensionGapSeconds: gapSeconds,
|
||||||
|
PreSuspension: ToEvidence(pre, preProbs),
|
||||||
|
PostSuspension: ToEvidence(post, postProbs));
|
||||||
|
|
||||||
|
return JsonSerializer.Serialize(payload, JsonOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static SnapshotEvidence ToEvidence(OddsSnapshot snapshot, Probabilities p) =>
|
||||||
|
new(
|
||||||
|
CapturedAt: snapshot.CapturedAt.ToString("O"),
|
||||||
|
P1: p.P1,
|
||||||
|
PDraw: p.PDraw,
|
||||||
|
P2: p.P2,
|
||||||
|
Rate1: p.Rate1,
|
||||||
|
RateDraw: p.RateDraw,
|
||||||
|
Rate2: p.Rate2);
|
||||||
|
|
||||||
|
private sealed record EvidencePayload(
|
||||||
|
[property: JsonPropertyName("suspensionGapSeconds")] int SuspensionGapSeconds,
|
||||||
|
[property: JsonPropertyName("preSuspension")] SnapshotEvidence PreSuspension,
|
||||||
|
[property: JsonPropertyName("postSuspension")] SnapshotEvidence PostSuspension);
|
||||||
|
|
||||||
|
private sealed record SnapshotEvidence(
|
||||||
|
[property: JsonPropertyName("capturedAt")] string CapturedAt,
|
||||||
|
[property: JsonPropertyName("p1")] decimal P1,
|
||||||
|
[property: JsonPropertyName("pDraw")] decimal? PDraw,
|
||||||
|
[property: JsonPropertyName("p2")] decimal P2,
|
||||||
|
[property: JsonPropertyName("rate1")] decimal Rate1,
|
||||||
|
[property: JsonPropertyName("rateDraw")] decimal? RateDraw,
|
||||||
|
[property: JsonPropertyName("rate2")] decimal Rate2);
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
using Marathon.Domain.Entities;
|
||||||
|
using Marathon.Domain.Enums;
|
||||||
|
using Marathon.Domain.ValueObjects;
|
||||||
|
|
||||||
|
namespace Marathon.Domain.AnomalyDetection;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Detects an "overround compression": the bookmaker's margin (the raw implied-probability
|
||||||
|
/// sum, >= 1.0) drops sharply over a short CONTINUOUS window — the book tightens its vig,
|
||||||
|
/// often ahead of news or when it is confident in the line.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Like the steam-move detector, it only considers windows with no suspension-sized gap
|
||||||
|
/// (controlled by <c>maxStepGapSeconds</c>), so it never overlaps the across-suspension
|
||||||
|
/// flip / freeze detectors. It is informational (non-directional) — the score is the
|
||||||
|
/// compression intensity, not a side prediction — so the outcome evaluator and backtest
|
||||||
|
/// exclude it (see <c>AnomalyKind.IsDirectional</c>).
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Score scales the margin drop against a reference collapse: a drop of
|
||||||
|
/// <see cref="ReferenceCompression"/> (10 margin points) or more reads as a full-strength
|
||||||
|
/// signal (1.0); the configured <c>compressionThreshold</c> is the minimum drop to flag.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class OverroundCompressionDetector : IAnomalyDetector
|
||||||
|
{
|
||||||
|
/// <summary>A 10-margin-point collapse maps to the maximum score of 1.0.</summary>
|
||||||
|
public const decimal ReferenceCompression = 0.10m;
|
||||||
|
|
||||||
|
private readonly int _windowSeconds;
|
||||||
|
private readonly decimal _compressionThreshold;
|
||||||
|
private readonly int _minSnapshotCount;
|
||||||
|
private readonly int _maxStepGapSeconds;
|
||||||
|
|
||||||
|
public OverroundCompressionDetector(
|
||||||
|
int windowSeconds, decimal compressionThreshold, int minSnapshotCount, int maxStepGapSeconds)
|
||||||
|
{
|
||||||
|
if (windowSeconds <= 0)
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(windowSeconds), windowSeconds, "Must be positive.");
|
||||||
|
if (compressionThreshold is <= 0m or >= 1m)
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(compressionThreshold), compressionThreshold, "Must be in (0, 1).");
|
||||||
|
if (minSnapshotCount < 2)
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(minSnapshotCount), minSnapshotCount, "Must be at least 2.");
|
||||||
|
if (maxStepGapSeconds <= 0)
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(maxStepGapSeconds), maxStepGapSeconds, "Must be positive.");
|
||||||
|
|
||||||
|
_windowSeconds = windowSeconds;
|
||||||
|
_compressionThreshold = compressionThreshold;
|
||||||
|
_minSnapshotCount = minSnapshotCount;
|
||||||
|
_maxStepGapSeconds = maxStepGapSeconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public IReadOnlyList<Anomaly> Detect(EventId eventId, IReadOnlyList<OddsSnapshot> snapshots)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(eventId);
|
||||||
|
ArgumentNullException.ThrowIfNull(snapshots);
|
||||||
|
|
||||||
|
var live = snapshots
|
||||||
|
.Where(s => s.Source == OddsSource.Live)
|
||||||
|
.OrderBy(s => s.CapturedAt)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (live.Count < _minSnapshotCount)
|
||||||
|
return Array.Empty<Anomaly>();
|
||||||
|
|
||||||
|
var window = TimeSpan.FromSeconds(_windowSeconds);
|
||||||
|
var maxStepGap = TimeSpan.FromSeconds(_maxStepGapSeconds);
|
||||||
|
|
||||||
|
var anomalies = new List<Anomaly>();
|
||||||
|
int windowStart = 0;
|
||||||
|
int continuityStart = 0;
|
||||||
|
|
||||||
|
for (int end = 1; end < live.Count; end++)
|
||||||
|
{
|
||||||
|
if (live[end].CapturedAt - live[end - 1].CapturedAt > maxStepGap)
|
||||||
|
continuityStart = end;
|
||||||
|
|
||||||
|
while (live[end].CapturedAt - live[windowStart].CapturedAt > window)
|
||||||
|
windowStart++;
|
||||||
|
|
||||||
|
int start = Math.Max(windowStart, continuityStart);
|
||||||
|
if (start >= end)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var pre = MatchWinEvidence.Extract(live[start]);
|
||||||
|
var post = MatchWinEvidence.Extract(live[end]);
|
||||||
|
if (pre is null || post is null)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
// Compression is measured start-to-end of the current window. Because the loop
|
||||||
|
// emits at every `end` over a sliding window, an intra-window dip that later
|
||||||
|
// recovers is still flagged on the iteration whose `end` lands on the trough
|
||||||
|
// (where `start` still holds the pre-dip margin), so a separate peak-to-trough
|
||||||
|
// scan is unnecessary. Positive = the margin shrank (book tightened).
|
||||||
|
var compression = pre.Overround - post.Overround;
|
||||||
|
if (compression < _compressionThreshold)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var gapSeconds = (int)(live[end].CapturedAt - live[start].CapturedAt).TotalSeconds;
|
||||||
|
var evidenceJson = MatchWinEvidence.BuildJson(gapSeconds, live[start], pre, live[end], post);
|
||||||
|
|
||||||
|
anomalies.Add(new Anomaly(
|
||||||
|
Id: Guid.NewGuid(),
|
||||||
|
EventId: eventId,
|
||||||
|
DetectedAt: MoscowTime.Now,
|
||||||
|
Kind: AnomalyKind.OverroundCompression,
|
||||||
|
Score: Math.Min(1m, compression / ReferenceCompression),
|
||||||
|
EvidenceJson: evidenceJson));
|
||||||
|
}
|
||||||
|
|
||||||
|
return anomalies.AsReadOnly();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
using Marathon.Domain.Entities;
|
||||||
|
using Marathon.Domain.Enums;
|
||||||
|
using Marathon.Domain.ValueObjects;
|
||||||
|
|
||||||
|
namespace Marathon.Domain.AnomalyDetection;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Detects a "steam move": a rapid, one-directional rise in a side's normalised
|
||||||
|
/// implied probability over a short CONTINUOUS window — money moving the line.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// A window is only considered when it contains no suspension-sized gap between
|
||||||
|
/// consecutive snapshots (controlled by <c>maxStepGapSeconds</c>); drift across a
|
||||||
|
/// suspension is the <see cref="AnomalyDetector"/>'s (SuspensionFlip) territory, so
|
||||||
|
/// the two detectors never double-flag the same interval.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Emits an <see cref="AnomalyKind.SteamMove"/> anomaly whose pre/post evidence
|
||||||
|
/// brackets the drift, written in the shared <see cref="MatchWinEvidence"/> shape so
|
||||||
|
/// the UI and <see cref="AnomalyOutcomeEvaluator"/> handle it without branching.
|
||||||
|
/// A sustained steam may cross the threshold at several consecutive snapshots; those
|
||||||
|
/// are collapsed to one persisted row by the detection use case's dedup window.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class SteamMoveDetector : IAnomalyDetector
|
||||||
|
{
|
||||||
|
private readonly int _windowSeconds;
|
||||||
|
private readonly decimal _driftThreshold;
|
||||||
|
private readonly int _minSnapshotCount;
|
||||||
|
private readonly int _maxStepGapSeconds;
|
||||||
|
|
||||||
|
/// <param name="windowSeconds">Trailing window (seconds) over which drift is measured.</param>
|
||||||
|
/// <param name="driftThreshold">Minimum one-directional implied-probability rise to flag; in (0, 1).</param>
|
||||||
|
/// <param name="minSnapshotCount">Minimum live snapshots before detection runs (>= 2).</param>
|
||||||
|
/// <param name="maxStepGapSeconds">
|
||||||
|
/// Maximum gap between consecutive snapshots for the window to count as continuous.
|
||||||
|
/// A larger gap means a suspension occurred — that is flip territory, not steam.
|
||||||
|
/// </param>
|
||||||
|
public SteamMoveDetector(int windowSeconds, decimal driftThreshold, int minSnapshotCount, int maxStepGapSeconds)
|
||||||
|
{
|
||||||
|
if (windowSeconds <= 0)
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(windowSeconds), windowSeconds, "Must be positive.");
|
||||||
|
if (driftThreshold is <= 0m or >= 1m)
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(driftThreshold), driftThreshold, "Must be in (0, 1).");
|
||||||
|
if (minSnapshotCount < 2)
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(minSnapshotCount), minSnapshotCount, "Must be at least 2.");
|
||||||
|
if (maxStepGapSeconds <= 0)
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(maxStepGapSeconds), maxStepGapSeconds, "Must be positive.");
|
||||||
|
|
||||||
|
_windowSeconds = windowSeconds;
|
||||||
|
_driftThreshold = driftThreshold;
|
||||||
|
_minSnapshotCount = minSnapshotCount;
|
||||||
|
_maxStepGapSeconds = maxStepGapSeconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public IReadOnlyList<Anomaly> Detect(EventId eventId, IReadOnlyList<OddsSnapshot> snapshots)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(eventId);
|
||||||
|
ArgumentNullException.ThrowIfNull(snapshots);
|
||||||
|
|
||||||
|
var live = snapshots
|
||||||
|
.Where(s => s.Source == OddsSource.Live)
|
||||||
|
.OrderBy(s => s.CapturedAt)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (live.Count < _minSnapshotCount)
|
||||||
|
return Array.Empty<Anomaly>();
|
||||||
|
|
||||||
|
var window = TimeSpan.FromSeconds(_windowSeconds);
|
||||||
|
var maxStepGap = TimeSpan.FromSeconds(_maxStepGapSeconds);
|
||||||
|
|
||||||
|
var anomalies = new List<Anomaly>();
|
||||||
|
int windowStart = 0;
|
||||||
|
int continuityStart = 0;
|
||||||
|
|
||||||
|
for (int end = 1; end < live.Count; end++)
|
||||||
|
{
|
||||||
|
// A suspension-sized step resets continuity: the drift after it is a flip,
|
||||||
|
// not a steam move, so steam windows never span a suspension.
|
||||||
|
if (live[end].CapturedAt - live[end - 1].CapturedAt > maxStepGap)
|
||||||
|
continuityStart = end;
|
||||||
|
|
||||||
|
// Shrink the trailing window so [windowStart, end] is within windowSeconds.
|
||||||
|
while (live[end].CapturedAt - live[windowStart].CapturedAt > window)
|
||||||
|
windowStart++;
|
||||||
|
|
||||||
|
int start = Math.Max(windowStart, continuityStart);
|
||||||
|
if (start >= end)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var pre = MatchWinEvidence.Extract(live[start]);
|
||||||
|
var post = MatchWinEvidence.Extract(live[end]);
|
||||||
|
if (pre is null || post is null)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
// One-directional rise: a side's normalised probability INCREASED (odds
|
||||||
|
// shortened) by at least the threshold — money steamed onto that side.
|
||||||
|
decimal drift = Math.Max(post.P1 - pre.P1, post.P2 - pre.P2);
|
||||||
|
if (pre.PDraw.HasValue && post.PDraw.HasValue)
|
||||||
|
drift = Math.Max(drift, post.PDraw.Value - pre.PDraw.Value);
|
||||||
|
|
||||||
|
if (drift < _driftThreshold)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var gapSeconds = (int)(live[end].CapturedAt - live[start].CapturedAt).TotalSeconds;
|
||||||
|
var evidenceJson = MatchWinEvidence.BuildJson(gapSeconds, live[start], pre, live[end], post);
|
||||||
|
|
||||||
|
anomalies.Add(new Anomaly(
|
||||||
|
Id: Guid.NewGuid(),
|
||||||
|
EventId: eventId,
|
||||||
|
DetectedAt: MoscowTime.Now,
|
||||||
|
Kind: AnomalyKind.SteamMove,
|
||||||
|
Score: Math.Min(1m, drift),
|
||||||
|
EvidenceJson: evidenceJson));
|
||||||
|
}
|
||||||
|
|
||||||
|
return anomalies.AsReadOnly();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
using Marathon.Domain.Entities;
|
||||||
|
using Marathon.Domain.Enums;
|
||||||
|
using Marathon.Domain.ValueObjects;
|
||||||
|
|
||||||
|
namespace Marathon.Domain.AnomalyDetection;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Detects a "suspension freeze": the market was suspended (a gap larger than
|
||||||
|
/// <c>suspensionGapSeconds</c> between adjacent live snapshots) but resumed with
|
||||||
|
/// essentially the same line — the favourite is unchanged and the largest normalised
|
||||||
|
/// implied-probability move is below <c>freezeThreshold</c>.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// This is the mirror image of <see cref="AnomalyDetector"/> (SuspensionFlip): the flip
|
||||||
|
/// fires on a large favourite-changing move across a suspension; the freeze fires when
|
||||||
|
/// the bookmaker paused but did <i>not</i> move — a tell that they were uncertain or
|
||||||
|
/// gathering information rather than repricing.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Score = how completely the line froze: <c>1 − (maxMove / freezeThreshold)</c>, so a
|
||||||
|
/// perfectly unchanged line scores ~1.0 and one near the threshold scores near 0. The
|
||||||
|
/// shared <see cref="MatchWinEvidence"/> shape (pre ≈ post) conveys the freeze directly,
|
||||||
|
/// and the outcome evaluator grades the unchanged favourite like any other anomaly.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class SuspensionFreezeDetector : IAnomalyDetector
|
||||||
|
{
|
||||||
|
private readonly int _suspensionGapSeconds;
|
||||||
|
private readonly decimal _freezeThreshold;
|
||||||
|
private readonly int _minSnapshotCount;
|
||||||
|
|
||||||
|
/// <param name="suspensionGapSeconds">Minimum adjacent-snapshot gap (seconds) classed as a suspension.</param>
|
||||||
|
/// <param name="freezeThreshold">Maximum normalised probability move to count as frozen; in (0, 1).</param>
|
||||||
|
/// <param name="minSnapshotCount">Minimum live snapshots before detection runs (>= 2).</param>
|
||||||
|
public SuspensionFreezeDetector(int suspensionGapSeconds, decimal freezeThreshold, int minSnapshotCount)
|
||||||
|
{
|
||||||
|
if (suspensionGapSeconds <= 0)
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(suspensionGapSeconds), suspensionGapSeconds, "Must be positive.");
|
||||||
|
if (freezeThreshold is <= 0m or >= 1m)
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(freezeThreshold), freezeThreshold, "Must be in (0, 1).");
|
||||||
|
if (minSnapshotCount < 2)
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(minSnapshotCount), minSnapshotCount, "Must be at least 2.");
|
||||||
|
|
||||||
|
_suspensionGapSeconds = suspensionGapSeconds;
|
||||||
|
_freezeThreshold = freezeThreshold;
|
||||||
|
_minSnapshotCount = minSnapshotCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public IReadOnlyList<Anomaly> Detect(EventId eventId, IReadOnlyList<OddsSnapshot> snapshots)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(eventId);
|
||||||
|
ArgumentNullException.ThrowIfNull(snapshots);
|
||||||
|
|
||||||
|
var live = snapshots
|
||||||
|
.Where(s => s.Source == OddsSource.Live)
|
||||||
|
.OrderBy(s => s.CapturedAt)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (live.Count < _minSnapshotCount)
|
||||||
|
return Array.Empty<Anomaly>();
|
||||||
|
|
||||||
|
var suspensionGap = TimeSpan.FromSeconds(_suspensionGapSeconds);
|
||||||
|
var anomalies = new List<Anomaly>();
|
||||||
|
|
||||||
|
for (int i = 0; i < live.Count - 1; i++)
|
||||||
|
{
|
||||||
|
var pre = live[i];
|
||||||
|
var post = live[i + 1];
|
||||||
|
if (post.CapturedAt - pre.CapturedAt <= suspensionGap)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var preProbs = MatchWinEvidence.Extract(pre);
|
||||||
|
var postProbs = MatchWinEvidence.Extract(post);
|
||||||
|
if (preProbs is null || postProbs is null)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
decimal maxMove = Math.Max(
|
||||||
|
Math.Abs(postProbs.P1 - preProbs.P1),
|
||||||
|
Math.Abs(postProbs.P2 - preProbs.P2));
|
||||||
|
if (preProbs.PDraw.HasValue && postProbs.PDraw.HasValue)
|
||||||
|
maxMove = Math.Max(maxMove, Math.Abs(postProbs.PDraw.Value - preProbs.PDraw.Value));
|
||||||
|
|
||||||
|
var favouriteUnchanged =
|
||||||
|
MatchWinEvidence.Favourite(preProbs) == MatchWinEvidence.Favourite(postProbs);
|
||||||
|
|
||||||
|
// Strictly below the threshold so the score stays in (0, 1].
|
||||||
|
if (!favouriteUnchanged || maxMove >= _freezeThreshold)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var score = 1m - (maxMove / _freezeThreshold);
|
||||||
|
var gapSeconds = (int)(post.CapturedAt - pre.CapturedAt).TotalSeconds;
|
||||||
|
var evidenceJson = MatchWinEvidence.BuildJson(gapSeconds, pre, preProbs, post, postProbs);
|
||||||
|
|
||||||
|
anomalies.Add(new Anomaly(
|
||||||
|
Id: Guid.NewGuid(),
|
||||||
|
EventId: eventId,
|
||||||
|
DetectedAt: MoscowTime.Now,
|
||||||
|
Kind: AnomalyKind.SuspensionFreeze,
|
||||||
|
Score: score,
|
||||||
|
EvidenceJson: evidenceJson));
|
||||||
|
}
|
||||||
|
|
||||||
|
return anomalies.AsReadOnly();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
using Marathon.Domain.Entities;
|
||||||
|
|
||||||
|
namespace Marathon.Domain.AnomalyDetection;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A pair of adjacent <see cref="OddsSnapshot"/> records that bracket a suspension gap —
|
||||||
|
/// i.e. the time between them exceeded the configured <c>SuspensionGapSeconds</c> threshold.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="PreSuspension">The last snapshot captured before the gap.</param>
|
||||||
|
/// <param name="PostSuspension">The first snapshot captured after the gap.</param>
|
||||||
|
internal sealed record SuspensionInterval(OddsSnapshot PreSuspension, OddsSnapshot PostSuspension)
|
||||||
|
{
|
||||||
|
/// <summary>Duration of the observed suspension gap.</summary>
|
||||||
|
public TimeSpan Gap => PostSuspension.CapturedAt - PreSuspension.CapturedAt;
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
using Marathon.Domain.AnomalyDetection;
|
||||||
|
using Marathon.Domain.Entities;
|
||||||
|
using Marathon.Domain.ValueObjects;
|
||||||
|
|
||||||
|
namespace Marathon.Domain.Backtesting;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Input row for <see cref="BacktestSimulator"/> — one anomaly fully resolved
|
||||||
|
/// against its event metadata and result. The use case constructs these once
|
||||||
|
/// per simulation run and feeds them to the pure simulator in chronological
|
||||||
|
/// order.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="Anomaly">The flagged anomaly being simulated.</param>
|
||||||
|
/// <param name="Evidence">
|
||||||
|
/// Parsed evidence payload (pre- and post-suspension snapshots). The simulator
|
||||||
|
/// reads the post-suspension favourite and rate from here.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="Result">Final event result — drives the win/loss verdict.</param>
|
||||||
|
/// <param name="Sport">Sport metadata, optional, surfaced into the trace row.</param>
|
||||||
|
public sealed record BacktestCandidate(
|
||||||
|
Anomaly Anomaly,
|
||||||
|
AnomalyEvidenceData Evidence,
|
||||||
|
EventResult Result,
|
||||||
|
SportCode? Sport);
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
using Marathon.Domain.Entities;
|
||||||
|
using Marathon.Domain.Enums;
|
||||||
|
using Marathon.Domain.ValueObjects;
|
||||||
|
|
||||||
|
namespace Marathon.Domain.Backtesting;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Aggregate output of one simulation run. Contains both the headline numbers
|
||||||
|
/// the user looks at (final bankroll, ROI, max drawdown) and the per-bet
|
||||||
|
/// trace needed to draw an equity curve.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="StartingBankroll">Echoed from the strategy for the UI.</param>
|
||||||
|
/// <param name="FinalBankroll">Bankroll after the last simulated bet settled.</param>
|
||||||
|
/// <param name="NetProfit"><c>FinalBankroll − StartingBankroll</c>.</param>
|
||||||
|
/// <param name="RoiPercent">
|
||||||
|
/// <c>NetProfit / TotalStaked × 100</c>. Null when no bets were placed
|
||||||
|
/// (no anomaly met the threshold, or the bankroll went to zero before any
|
||||||
|
/// stake could be sized).
|
||||||
|
/// </param>
|
||||||
|
/// <param name="TotalStaked">Sum of stake sizes across every settled bet.</param>
|
||||||
|
/// <param name="TotalReturned">Sum of gross returns across every settled bet.</param>
|
||||||
|
/// <param name="MaxDrawdown">
|
||||||
|
/// Largest peak-to-trough drop in bankroll observed during the run, as an
|
||||||
|
/// absolute amount. Always ≥ 0.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="MaxDrawdownPercent">
|
||||||
|
/// <see cref="MaxDrawdown"/> as a percentage of the peak that preceded it.
|
||||||
|
/// Null when there were no draws (no bets or no losses).
|
||||||
|
/// </param>
|
||||||
|
/// <param name="BetsPlaced">Total bets the strategy actually placed.</param>
|
||||||
|
/// <param name="Wins">Settled bets whose post-flip favourite won.</param>
|
||||||
|
/// <param name="Losses">Settled bets whose post-flip favourite lost.</param>
|
||||||
|
/// <param name="Skipped">
|
||||||
|
/// Total anomalies inspected but skipped. Equals
|
||||||
|
/// <see cref="SkippedByThreshold"/> + <see cref="SkippedByDataQuality"/> +
|
||||||
|
/// <see cref="SkippedByBankroll"/>. Surfaced separately so the UI can
|
||||||
|
/// distinguish a strategy choice ("threshold too high") from a real-world
|
||||||
|
/// signal ("bankroll empty") or a data-quality issue.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="SkippedByThreshold">
|
||||||
|
/// Skipped because <c>Anomaly.Score < strategy.MinScore</c> — pure strategy choice.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="SkippedByDataQuality">
|
||||||
|
/// Skipped because the evidence parsed but the post-flip favourite has no
|
||||||
|
/// rate / probability, or because a two-way market produced a Draw winner.
|
||||||
|
/// Strategy-orthogonal — these would be skipped under any rule.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="SkippedByBankroll">
|
||||||
|
/// Skipped because the sized stake was non-positive (Kelly returned no edge,
|
||||||
|
/// or bankroll was depleted) or exceeded the current bankroll.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="MaxWinStreak">Longest run of consecutive wins.</param>
|
||||||
|
/// <param name="MaxLossStreak">Longest run of consecutive losses.</param>
|
||||||
|
/// <param name="Trace">
|
||||||
|
/// Per-bet records in chronological order — drives the equity curve.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="EventTitles">
|
||||||
|
/// Pre-shaped <c>"Side1Name vs Side2Name"</c> strings keyed by event id, for
|
||||||
|
/// every event in <see cref="Trace"/>. Carried alongside the result so the UI
|
||||||
|
/// projection does not need a second pass over <c>IEventRepository</c>.
|
||||||
|
/// Missing events (pruned by retention) are absent from the map; consumers
|
||||||
|
/// fall back to <c>EventId.Value</c>.
|
||||||
|
/// </param>
|
||||||
|
public sealed record BacktestResult(
|
||||||
|
decimal StartingBankroll,
|
||||||
|
decimal FinalBankroll,
|
||||||
|
decimal NetProfit,
|
||||||
|
decimal? RoiPercent,
|
||||||
|
decimal TotalStaked,
|
||||||
|
decimal TotalReturned,
|
||||||
|
decimal MaxDrawdown,
|
||||||
|
decimal? MaxDrawdownPercent,
|
||||||
|
int BetsPlaced,
|
||||||
|
int Wins,
|
||||||
|
int Losses,
|
||||||
|
int Skipped,
|
||||||
|
int SkippedByThreshold,
|
||||||
|
int SkippedByDataQuality,
|
||||||
|
int SkippedByBankroll,
|
||||||
|
int MaxWinStreak,
|
||||||
|
int MaxLossStreak,
|
||||||
|
IReadOnlyList<BacktestTrace> Trace,
|
||||||
|
IReadOnlyDictionary<Marathon.Domain.ValueObjects.EventId, string> EventTitles);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One settled simulated bet. Carries enough metadata to surface a
|
||||||
|
/// drill-down row and a point on the equity curve.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="AnomalyId">Source anomaly for the link-back affordance.</param>
|
||||||
|
/// <param name="EventId">Event being bet on.</param>
|
||||||
|
/// <param name="DetectedAt">When the anomaly was originally detected.</param>
|
||||||
|
/// <param name="Score">Confidence score of the anomaly.</param>
|
||||||
|
/// <param name="Sport">Sport metadata if available — null when the event is missing.</param>
|
||||||
|
/// <param name="PostFlipFavourite">Side bet on (the post-suspension favourite).</param>
|
||||||
|
/// <param name="TakenRate">Rate at which the simulator "bought" the bet (post-flip rate).</param>
|
||||||
|
/// <param name="Stake">Stake sized for this bet.</param>
|
||||||
|
/// <param name="WinnerSide">Actual winner of the event.</param>
|
||||||
|
/// <param name="IsWin"><c>true</c> if the post-flip favourite was the winner.</param>
|
||||||
|
/// <param name="Payout">Gross return — <c>Stake × Rate</c> for a win, 0 for a loss.</param>
|
||||||
|
/// <param name="BankrollAfter">Bankroll after this bet settled — equity-curve y-axis.</param>
|
||||||
|
public sealed record BacktestTrace(
|
||||||
|
Guid AnomalyId,
|
||||||
|
EventId EventId,
|
||||||
|
DateTimeOffset DetectedAt,
|
||||||
|
decimal Score,
|
||||||
|
SportCode? Sport,
|
||||||
|
Side PostFlipFavourite,
|
||||||
|
decimal TakenRate,
|
||||||
|
decimal Stake,
|
||||||
|
Side WinnerSide,
|
||||||
|
bool IsWin,
|
||||||
|
decimal Payout,
|
||||||
|
decimal BankrollAfter);
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
using Marathon.Domain.Enums;
|
||||||
|
|
||||||
|
namespace Marathon.Domain.Backtesting;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pure simulator that replays a <see cref="BacktestStrategy"/> over a
|
||||||
|
/// chronological list of <see cref="BacktestCandidate"/> rows and returns the
|
||||||
|
/// resulting <see cref="BacktestResult"/>. No I/O, no DI — safe to call in
|
||||||
|
/// hot loops or property tests.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Loop body per candidate:
|
||||||
|
/// <list type="number">
|
||||||
|
/// <item>Skip if <c>Anomaly.Score < strategy.MinScore</c>.</item>
|
||||||
|
/// <item>
|
||||||
|
/// Skip if the evidence is two-way and the actual winner is <c>Draw</c>:
|
||||||
|
/// this mirrors <c>AnomalyOutcomeEvaluator</c> — we refuse to grade
|
||||||
|
/// selections that are structurally impossible for the market.
|
||||||
|
/// </item>
|
||||||
|
/// <item>Compute stake from the chosen <see cref="StakeRule"/>.</item>
|
||||||
|
/// <item>Skip when the stake is non-positive (Kelly returned no edge, or bankroll empty).</item>
|
||||||
|
/// <item>Settle: payout = stake × rate when the post-flip favourite won, 0 otherwise.</item>
|
||||||
|
/// <item>Update bankroll, streaks, and running peak-to-trough drawdown.</item>
|
||||||
|
/// </list>
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public static class BacktestSimulator
|
||||||
|
{
|
||||||
|
public static BacktestResult Run(
|
||||||
|
BacktestStrategy strategy,
|
||||||
|
IReadOnlyList<BacktestCandidate> candidates,
|
||||||
|
IReadOnlyDictionary<Marathon.Domain.ValueObjects.EventId, string>? eventTitles = null)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(strategy);
|
||||||
|
ArgumentNullException.ThrowIfNull(candidates);
|
||||||
|
|
||||||
|
var bankroll = strategy.StartingBankroll;
|
||||||
|
var peakBankroll = bankroll;
|
||||||
|
var maxDrawdown = 0m;
|
||||||
|
decimal? maxDrawdownPct = null;
|
||||||
|
|
||||||
|
var trace = new List<BacktestTrace>();
|
||||||
|
var totalStaked = 0m;
|
||||||
|
var totalReturned = 0m;
|
||||||
|
var wins = 0;
|
||||||
|
var losses = 0;
|
||||||
|
var skippedByThreshold = 0;
|
||||||
|
var skippedByDataQuality = 0;
|
||||||
|
var skippedByBankroll = 0;
|
||||||
|
var currentWinStreak = 0;
|
||||||
|
var currentLossStreak = 0;
|
||||||
|
var maxWinStreak = 0;
|
||||||
|
var maxLossStreak = 0;
|
||||||
|
|
||||||
|
// Process in chronological order so bankroll progression is meaningful.
|
||||||
|
var ordered = candidates
|
||||||
|
.OrderBy(c => c.Anomaly.DetectedAt)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
foreach (var candidate in ordered)
|
||||||
|
{
|
||||||
|
if (candidate.Anomaly.Score < strategy.MinScore)
|
||||||
|
{
|
||||||
|
skippedByThreshold++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var postFav = candidate.Evidence.PostSuspension.Favourite;
|
||||||
|
var isTwoWay = candidate.Evidence.PreSuspension.PDraw is null
|
||||||
|
&& candidate.Evidence.PostSuspension.PDraw is null;
|
||||||
|
|
||||||
|
if (isTwoWay && candidate.Result.WinnerSide == Side.Draw)
|
||||||
|
{
|
||||||
|
// Data inconsistency — refuse to grade.
|
||||||
|
skippedByDataQuality++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var (postRate, postProb) = ExtractPostFlipRateAndProbability(candidate.Evidence, postFav);
|
||||||
|
if (postRate is null || postProb is null)
|
||||||
|
{
|
||||||
|
skippedByDataQuality++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var stake = SizeStake(
|
||||||
|
strategy: strategy,
|
||||||
|
bankroll: bankroll,
|
||||||
|
postRate: postRate.Value,
|
||||||
|
postProb: postProb.Value);
|
||||||
|
|
||||||
|
if (stake <= 0m || stake > bankroll)
|
||||||
|
{
|
||||||
|
// Either Kelly returned no edge, or the user is broke. Either way
|
||||||
|
// do not place this bet.
|
||||||
|
skippedByBankroll++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var isWin = postFav == candidate.Result.WinnerSide;
|
||||||
|
var payout = isWin ? stake * postRate.Value : 0m;
|
||||||
|
|
||||||
|
bankroll = bankroll - stake + payout;
|
||||||
|
totalStaked += stake;
|
||||||
|
totalReturned += payout;
|
||||||
|
|
||||||
|
if (isWin)
|
||||||
|
{
|
||||||
|
wins++;
|
||||||
|
currentWinStreak++;
|
||||||
|
currentLossStreak = 0;
|
||||||
|
maxWinStreak = Math.Max(maxWinStreak, currentWinStreak);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
losses++;
|
||||||
|
currentLossStreak++;
|
||||||
|
currentWinStreak = 0;
|
||||||
|
maxLossStreak = Math.Max(maxLossStreak, currentLossStreak);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drawdown tracking: peak is the running maximum bankroll observed
|
||||||
|
// before the current point; drawdown is peak − current. We update
|
||||||
|
// peak only on new highs so the trough is measured from the right
|
||||||
|
// reference.
|
||||||
|
if (bankroll > peakBankroll)
|
||||||
|
{
|
||||||
|
peakBankroll = bankroll;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var dd = peakBankroll - bankroll;
|
||||||
|
if (dd > maxDrawdown)
|
||||||
|
{
|
||||||
|
maxDrawdown = dd;
|
||||||
|
maxDrawdownPct = peakBankroll > 0m
|
||||||
|
? Math.Round((dd / peakBankroll) * 100m, 2)
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Round money columns away-from-zero so a -0.005 stake reads as "-0.01"
|
||||||
|
// — the convention every accountant in the world expects.
|
||||||
|
trace.Add(new BacktestTrace(
|
||||||
|
AnomalyId: candidate.Anomaly.Id,
|
||||||
|
EventId: candidate.Anomaly.EventId,
|
||||||
|
DetectedAt: candidate.Anomaly.DetectedAt,
|
||||||
|
Score: candidate.Anomaly.Score,
|
||||||
|
Sport: candidate.Sport,
|
||||||
|
PostFlipFavourite: postFav,
|
||||||
|
TakenRate: postRate.Value,
|
||||||
|
Stake: Math.Round(stake, 2, MidpointRounding.AwayFromZero),
|
||||||
|
WinnerSide: candidate.Result.WinnerSide,
|
||||||
|
IsWin: isWin,
|
||||||
|
Payout: Math.Round(payout, 2, MidpointRounding.AwayFromZero),
|
||||||
|
BankrollAfter: Math.Round(bankroll, 2, MidpointRounding.AwayFromZero)));
|
||||||
|
}
|
||||||
|
|
||||||
|
decimal? roi = totalStaked > 0m
|
||||||
|
? Math.Round(((bankroll - strategy.StartingBankroll) / totalStaked) * 100m, 2,
|
||||||
|
MidpointRounding.AwayFromZero)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
var totalSkipped = skippedByThreshold + skippedByDataQuality + skippedByBankroll;
|
||||||
|
|
||||||
|
return new BacktestResult(
|
||||||
|
StartingBankroll: strategy.StartingBankroll,
|
||||||
|
FinalBankroll: Math.Round(bankroll, 2, MidpointRounding.AwayFromZero),
|
||||||
|
NetProfit: Math.Round(bankroll - strategy.StartingBankroll, 2, MidpointRounding.AwayFromZero),
|
||||||
|
RoiPercent: roi,
|
||||||
|
TotalStaked: Math.Round(totalStaked, 2, MidpointRounding.AwayFromZero),
|
||||||
|
TotalReturned: Math.Round(totalReturned, 2, MidpointRounding.AwayFromZero),
|
||||||
|
MaxDrawdown: Math.Round(maxDrawdown, 2, MidpointRounding.AwayFromZero),
|
||||||
|
MaxDrawdownPercent: maxDrawdownPct,
|
||||||
|
BetsPlaced: trace.Count,
|
||||||
|
Wins: wins,
|
||||||
|
Losses: losses,
|
||||||
|
Skipped: totalSkipped,
|
||||||
|
SkippedByThreshold: skippedByThreshold,
|
||||||
|
SkippedByDataQuality: skippedByDataQuality,
|
||||||
|
SkippedByBankroll: skippedByBankroll,
|
||||||
|
MaxWinStreak: maxWinStreak,
|
||||||
|
MaxLossStreak: maxLossStreak,
|
||||||
|
Trace: trace,
|
||||||
|
EventTitles: eventTitles
|
||||||
|
?? new Dictionary<Marathon.Domain.ValueObjects.EventId, string>());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private static (decimal? Rate, decimal? Probability) ExtractPostFlipRateAndProbability(
|
||||||
|
AnomalyDetection.AnomalyEvidenceData evidence,
|
||||||
|
Side favourite)
|
||||||
|
{
|
||||||
|
var post = evidence.PostSuspension;
|
||||||
|
return favourite switch
|
||||||
|
{
|
||||||
|
Side.Side1 => (post.Rate1, post.P1),
|
||||||
|
Side.Side2 => (post.Rate2, post.P2),
|
||||||
|
Side.Draw => (post.RateDraw, post.PDraw),
|
||||||
|
_ => (null, null),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static decimal SizeStake(
|
||||||
|
BacktestStrategy strategy,
|
||||||
|
decimal bankroll,
|
||||||
|
decimal postRate,
|
||||||
|
decimal postProb)
|
||||||
|
{
|
||||||
|
if (bankroll <= 0m) return 0m;
|
||||||
|
|
||||||
|
return strategy.StakeRule switch
|
||||||
|
{
|
||||||
|
StakeRule.Flat => strategy.FlatStake,
|
||||||
|
StakeRule.PercentOfBankroll => bankroll * strategy.PercentOfBankroll,
|
||||||
|
StakeRule.Kelly => ComputeKellyStake(
|
||||||
|
bankroll: bankroll,
|
||||||
|
postRate: postRate,
|
||||||
|
postProb: postProb,
|
||||||
|
fraction: strategy.KellyFraction),
|
||||||
|
_ => 0m,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static decimal ComputeKellyStake(
|
||||||
|
decimal bankroll,
|
||||||
|
decimal postRate,
|
||||||
|
decimal postProb,
|
||||||
|
decimal fraction)
|
||||||
|
{
|
||||||
|
// Kelly: f* = (b·p − q) / b where b = rate − 1, p = win prob, q = 1 − p.
|
||||||
|
// Skip non-positive edge (no bet rather than betting "negative size").
|
||||||
|
var b = postRate - 1m;
|
||||||
|
if (b <= 0m) return 0m;
|
||||||
|
|
||||||
|
var p = postProb;
|
||||||
|
var q = 1m - p;
|
||||||
|
var fullKelly = ((b * p) - q) / b;
|
||||||
|
|
||||||
|
if (fullKelly <= 0m) return 0m;
|
||||||
|
|
||||||
|
// Quarter / half / etc.-Kelly: scale full edge by the configured fraction.
|
||||||
|
var stakeFraction = fullKelly * fraction;
|
||||||
|
return bankroll * stakeFraction;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
namespace Marathon.Domain.Backtesting;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parameters fed to <see cref="BacktestSimulator"/>. The strategy is "for every
|
||||||
|
/// SuspensionFlip anomaly with score ≥ <see cref="MinScore"/>, stake
|
||||||
|
/// according to <see cref="StakeRule"/> on the post-flip favourite at the
|
||||||
|
/// post-flip rate, then settle against the actual <c>EventResult</c>."
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="StartingBankroll">
|
||||||
|
/// Initial bankroll for compounding stake rules. Must be positive.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="MinScore">
|
||||||
|
/// Lower bound on <c>Anomaly.Score</c> — only anomalies at or above this
|
||||||
|
/// threshold are bet on. Must be in [0, 1].
|
||||||
|
/// </param>
|
||||||
|
/// <param name="StakeRule">How to size each bet — see the enum docs.</param>
|
||||||
|
/// <param name="FlatStake">
|
||||||
|
/// Used when <see cref="StakeRule"/> is <see cref="Backtesting.StakeRule.Flat"/>.
|
||||||
|
/// Must be positive.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="PercentOfBankroll">
|
||||||
|
/// Used when <see cref="StakeRule"/> is
|
||||||
|
/// <see cref="Backtesting.StakeRule.PercentOfBankroll"/>. Expressed as a
|
||||||
|
/// fraction in (0, 1]. e.g. 0.02 = 2 % of bankroll.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="KellyFraction">
|
||||||
|
/// Used when <see cref="StakeRule"/> is <see cref="Backtesting.StakeRule.Kelly"/>.
|
||||||
|
/// Multiplier on the raw Kelly fraction; in (0, 1]. 0.25 (quarter-Kelly) is
|
||||||
|
/// the conservative default.
|
||||||
|
/// </param>
|
||||||
|
public sealed record BacktestStrategy(
|
||||||
|
decimal StartingBankroll,
|
||||||
|
decimal MinScore,
|
||||||
|
StakeRule StakeRule,
|
||||||
|
decimal FlatStake,
|
||||||
|
decimal PercentOfBankroll,
|
||||||
|
decimal KellyFraction)
|
||||||
|
{
|
||||||
|
public decimal StartingBankroll { get; } = StartingBankroll > 0m
|
||||||
|
? StartingBankroll
|
||||||
|
: throw new ArgumentOutOfRangeException(nameof(StartingBankroll),
|
||||||
|
StartingBankroll, "StartingBankroll must be positive.");
|
||||||
|
|
||||||
|
public decimal MinScore { get; } = MinScore is >= 0m and <= 1m
|
||||||
|
? MinScore
|
||||||
|
: throw new ArgumentOutOfRangeException(nameof(MinScore),
|
||||||
|
MinScore, "MinScore must be in [0, 1].");
|
||||||
|
|
||||||
|
public decimal FlatStake { get; } = FlatStake > 0m
|
||||||
|
? FlatStake
|
||||||
|
: throw new ArgumentOutOfRangeException(nameof(FlatStake),
|
||||||
|
FlatStake, "FlatStake must be positive.");
|
||||||
|
|
||||||
|
public decimal PercentOfBankroll { get; } = PercentOfBankroll is > 0m and <= 1m
|
||||||
|
? PercentOfBankroll
|
||||||
|
: throw new ArgumentOutOfRangeException(nameof(PercentOfBankroll),
|
||||||
|
PercentOfBankroll, "PercentOfBankroll must be in (0, 1].");
|
||||||
|
|
||||||
|
public decimal KellyFraction { get; } = KellyFraction is > 0m and <= 1m
|
||||||
|
? KellyFraction
|
||||||
|
: throw new ArgumentOutOfRangeException(nameof(KellyFraction),
|
||||||
|
KellyFraction, "KellyFraction must be in (0, 1].");
|
||||||
|
|
||||||
|
/// <summary>Sensible defaults — flat-stake, score ≥ 0.45, ¼-Kelly waiting in the wings.</summary>
|
||||||
|
public static BacktestStrategy Default { get; } = new(
|
||||||
|
StartingBankroll: 1000m,
|
||||||
|
MinScore: 0.45m,
|
||||||
|
StakeRule: StakeRule.Flat,
|
||||||
|
FlatStake: 50m,
|
||||||
|
PercentOfBankroll: 0.02m,
|
||||||
|
KellyFraction: 0.25m);
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
using Marathon.Domain.ValueObjects;
|
||||||
|
|
||||||
|
namespace Marathon.Domain.Backtesting;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A named, persisted <see cref="BacktestStrategy"/> — the user's reusable
|
||||||
|
/// staking preset. The wrapped <see cref="Strategy"/> carries every simulation
|
||||||
|
/// parameter (bankroll, threshold, stake rule); the date-range scope of a run
|
||||||
|
/// is deliberately NOT stored here, since that is a per-run choice rather than
|
||||||
|
/// a property of the strategy itself.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="Id">Stable identity, assigned once at creation.</param>
|
||||||
|
/// <param name="Name">
|
||||||
|
/// User-supplied label. Trimmed and bounded to <see cref="MaxNameLength"/>;
|
||||||
|
/// names are unique across the store (enforced by the persistence layer).
|
||||||
|
/// </param>
|
||||||
|
/// <param name="Strategy">The staking configuration this preset captures.</param>
|
||||||
|
/// <param name="CreatedAt">When the preset was first saved (Moscow time).</param>
|
||||||
|
public sealed record SavedStrategy(
|
||||||
|
Guid Id,
|
||||||
|
string Name,
|
||||||
|
BacktestStrategy Strategy,
|
||||||
|
DateTimeOffset CreatedAt)
|
||||||
|
{
|
||||||
|
/// <summary>Maximum length of a trimmed strategy name.</summary>
|
||||||
|
public const int MaxNameLength = 80;
|
||||||
|
|
||||||
|
public string Name { get; } = NormalizeName(Name);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds a brand-new preset with a fresh identity and the current Moscow
|
||||||
|
/// timestamp. Use this for "Save"; use <c>with</c> to amend an existing one.
|
||||||
|
/// </summary>
|
||||||
|
public static SavedStrategy Create(string name, BacktestStrategy strategy)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(strategy);
|
||||||
|
return new SavedStrategy(Guid.NewGuid(), name, strategy, MoscowTime.Now);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string NormalizeName(string name)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(name);
|
||||||
|
var trimmed = name.Trim();
|
||||||
|
if (trimmed.Length == 0)
|
||||||
|
throw new ArgumentException("Strategy name must not be empty.", nameof(name));
|
||||||
|
if (trimmed.Length > MaxNameLength)
|
||||||
|
throw new ArgumentException(
|
||||||
|
$"Strategy name must be at most {MaxNameLength} characters.", nameof(name));
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
namespace Marathon.Domain.Backtesting;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// How the simulator decides how much to stake on each bet during a backtest.
|
||||||
|
/// </summary>
|
||||||
|
public enum StakeRule
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Same fixed amount every bet, independent of bankroll.
|
||||||
|
/// Suitable for "flat-betting" historical analysis — the simplest baseline.
|
||||||
|
/// </summary>
|
||||||
|
Flat,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A fixed percentage of the current bankroll every bet. Compounds: a
|
||||||
|
/// winning streak grows stake size; losses shrink it. Equivalent to
|
||||||
|
/// proportional betting.
|
||||||
|
/// </summary>
|
||||||
|
PercentOfBankroll,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fractional Kelly using the post-flip implied probability as the edge
|
||||||
|
/// estimate: <c>f = ((b·p) − q) / b</c>, scaled by the configured
|
||||||
|
/// <see cref="BacktestStrategy.KellyFraction"/>. Negative-expectation bets
|
||||||
|
/// stake zero (and are skipped). Half/quarter-Kelly is the usual practice.
|
||||||
|
/// </summary>
|
||||||
|
Kelly,
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
using Marathon.Domain.Entities;
|
||||||
|
using Marathon.Domain.Enums;
|
||||||
|
using Marathon.Domain.ValueObjects;
|
||||||
|
|
||||||
|
namespace Marathon.Domain.Betting;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pure function that grades a <see cref="Bet"/> selection against a final
|
||||||
|
/// <see cref="EventResult"/>. Used by the bet-journal resolver to auto-settle
|
||||||
|
/// pending wagers the moment a result lands.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Grading rules:
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item><c>Win</c> (Side1/Side2): selection wins iff <c>WinnerSide</c> matches the side.</item>
|
||||||
|
/// <item><c>Draw</c>: wins iff <c>WinnerSide == Draw</c>.</item>
|
||||||
|
/// <item><c>WinFora</c> with handicap <c>h</c> on side S: adjusted S-score
|
||||||
|
/// = <c>S.Score + h</c>. Wins when adjusted > opponent, voids on tie, loses otherwise.</item>
|
||||||
|
/// <item><c>Total</c> with threshold <c>t</c>: combined = <c>Side1Score + Side2Score</c>.
|
||||||
|
/// <c>More</c> wins when combined > t, voids on equal, loses when less.
|
||||||
|
/// <c>Less</c> is the mirror image.</item>
|
||||||
|
/// </list>
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Returns <c>null</c> when the bet cannot be graded against this result —
|
||||||
|
/// today only period-scope selections, because <see cref="EventResult"/> stores
|
||||||
|
/// the full-time score only. Callers must leave such bets in
|
||||||
|
/// <see cref="BetOutcome.Pending"/> for manual settlement.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public static class BetOutcomeResolver
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Grades <paramref name="selection"/> against <paramref name="result"/>.
|
||||||
|
/// Returns the resulting <see cref="BetOutcome"/> or <c>null</c> if the
|
||||||
|
/// bet shape cannot be auto-resolved from the available result data.
|
||||||
|
/// </summary>
|
||||||
|
public static BetOutcome? Resolve(Bet selection, EventResult result)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(selection);
|
||||||
|
ArgumentNullException.ThrowIfNull(result);
|
||||||
|
|
||||||
|
// Period-scope bets need per-period scores which EventResult does not
|
||||||
|
// carry today — leave for manual grading.
|
||||||
|
if (selection.Scope is not MatchScope)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
return selection.Type switch
|
||||||
|
{
|
||||||
|
BetType.Win => ResolveWin(selection.Side, result),
|
||||||
|
BetType.Draw => ResolveDraw(result),
|
||||||
|
BetType.WinFora => ResolveFora(selection.Side, selection.Value!.Value, result),
|
||||||
|
BetType.Total => ResolveTotal(selection.Side, selection.Value!.Value, result),
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static BetOutcome ResolveWin(Side side, EventResult result) =>
|
||||||
|
result.WinnerSide == side ? BetOutcome.Won : BetOutcome.Lost;
|
||||||
|
|
||||||
|
private static BetOutcome ResolveDraw(EventResult result) =>
|
||||||
|
result.WinnerSide == Side.Draw ? BetOutcome.Won : BetOutcome.Lost;
|
||||||
|
|
||||||
|
private static BetOutcome ResolveFora(Side side, decimal handicap, EventResult result)
|
||||||
|
{
|
||||||
|
// Adjusted score for the side that took the handicap.
|
||||||
|
var (own, opponent) = side == Side.Side1
|
||||||
|
? (result.Side1Score, result.Side2Score)
|
||||||
|
: (result.Side2Score, result.Side1Score);
|
||||||
|
|
||||||
|
var adjusted = own + handicap;
|
||||||
|
|
||||||
|
if (adjusted > opponent) return BetOutcome.Won;
|
||||||
|
if (adjusted == opponent) return BetOutcome.Void;
|
||||||
|
return BetOutcome.Lost;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static BetOutcome ResolveTotal(Side side, decimal threshold, EventResult result)
|
||||||
|
{
|
||||||
|
var total = (decimal)(result.Side1Score + result.Side2Score);
|
||||||
|
|
||||||
|
// More wins when total > threshold; Less wins when total < threshold.
|
||||||
|
// Equality is a push (Void) for both sides.
|
||||||
|
if (total == threshold) return BetOutcome.Void;
|
||||||
|
|
||||||
|
var totalIsOver = total > threshold;
|
||||||
|
return side switch
|
||||||
|
{
|
||||||
|
Side.More => totalIsOver ? BetOutcome.Won : BetOutcome.Lost,
|
||||||
|
Side.Less => totalIsOver ? BetOutcome.Lost : BetOutcome.Won,
|
||||||
|
_ => BetOutcome.Lost, // Defensive — Bet invariant rejects other sides for Total.
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
namespace Marathon.Domain.Betting;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pure fractional-Kelly stake sizing for a single back bet at decimal odds.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// The Kelly criterion maximises the long-run growth rate of a bankroll by staking
|
||||||
|
/// a fraction of it proportional to the edge. For decimal odds <c>o</c> and an
|
||||||
|
/// estimated win probability <c>p</c>, the full-Kelly fraction of bankroll is:
|
||||||
|
/// </para>
|
||||||
|
/// <code>f* = (p·o − 1) / (o − 1)</code>
|
||||||
|
/// <para>
|
||||||
|
/// When <c>f*</c> is zero or negative there is no positive expected value, and the
|
||||||
|
/// suggested stake is <c>0</c> — the calculator never recommends betting into a
|
||||||
|
/// negative-EV price. Most disciplined bettors stake a <i>fraction</i> of full
|
||||||
|
/// Kelly (e.g. quarter-Kelly, <c>fraction = 0.25</c>) to cut variance and blunt the
|
||||||
|
/// impact of probability-estimation error; full Kelly is famously over-aggressive
|
||||||
|
/// once <c>p</c> is even slightly wrong.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// The win probability is an input the bettor supplies — it is intentionally NOT
|
||||||
|
/// derived from an anomaly score here, so the calculator stays a pure, reusable
|
||||||
|
/// money-management primitive independent of any signal source.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public static class KellyCalculator
|
||||||
|
{
|
||||||
|
/// <summary>Default Kelly fraction: quarter-Kelly — the conventional variance-safe choice.</summary>
|
||||||
|
public const decimal DefaultFraction = 0.25m;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Full-Kelly fraction of bankroll <c>(p·o − 1)/(o − 1)</c>. May be negative or
|
||||||
|
/// zero, signalling no positive edge. Exposed for callers that want the raw
|
||||||
|
/// figure (e.g. to display the edge) rather than a clamped stake.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="winProbability">Estimated win probability in the closed interval [0, 1].</param>
|
||||||
|
/// <param name="decimalOdds">Decimal odds, strictly greater than 1.0.</param>
|
||||||
|
public static decimal FullKellyFraction(decimal winProbability, decimal decimalOdds)
|
||||||
|
{
|
||||||
|
if (winProbability is < 0m or > 1m)
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(winProbability), winProbability, "Must be in [0, 1].");
|
||||||
|
if (decimalOdds <= 1m)
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(decimalOdds), decimalOdds, "Decimal odds must be greater than 1.0.");
|
||||||
|
|
||||||
|
return (winProbability * decimalOdds - 1m) / (decimalOdds - 1m);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Suggested stake using fractional Kelly, rounded down to two decimals so the
|
||||||
|
/// suggestion is never larger than the theoretical figure. Returns <c>0</c> when
|
||||||
|
/// there is no positive edge.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="winProbability">Estimated win probability in the open interval (0, 1).</param>
|
||||||
|
/// <param name="decimalOdds">Decimal odds, strictly greater than 1.0.</param>
|
||||||
|
/// <param name="bankroll">Total bankroll; must be non-negative.</param>
|
||||||
|
/// <param name="fraction">Kelly fraction in (0, 1]; defaults to <see cref="DefaultFraction"/>.</param>
|
||||||
|
public static decimal SuggestStake(
|
||||||
|
decimal winProbability,
|
||||||
|
decimal decimalOdds,
|
||||||
|
decimal bankroll,
|
||||||
|
decimal fraction = DefaultFraction)
|
||||||
|
{
|
||||||
|
if (winProbability is <= 0m or >= 1m)
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(winProbability), winProbability, "Must be in the open interval (0, 1).");
|
||||||
|
if (bankroll < 0m)
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(bankroll), bankroll, "Must be non-negative.");
|
||||||
|
if (fraction is <= 0m or > 1m)
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(fraction), fraction, "Kelly fraction must be in (0, 1].");
|
||||||
|
|
||||||
|
// FullKellyFraction validates decimalOdds.
|
||||||
|
var full = FullKellyFraction(winProbability, decimalOdds);
|
||||||
|
if (full <= 0m)
|
||||||
|
return 0m;
|
||||||
|
|
||||||
|
var stake = fraction * full * bankroll;
|
||||||
|
|
||||||
|
// Truncate (floor toward zero) to two decimals so a stake suggestion never
|
||||||
|
// exceeds the computed figure — a conservative bias for real-money sizing.
|
||||||
|
return Math.Truncate(stake * 100m) / 100m;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
using Marathon.Domain.Enums;
|
||||||
|
using Marathon.Domain.ValueObjects;
|
||||||
|
|
||||||
|
namespace Marathon.Domain.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A detected anomaly in odds behaviour for an event.
|
||||||
|
/// <c>Score</c> is a normalised confidence score in [0, 1] — higher means stronger signal.
|
||||||
|
/// <c>EvidenceJson</c> is a JSON string containing the raw evidence timeline (snapshots, diffs).
|
||||||
|
/// </summary>
|
||||||
|
public sealed record Anomaly(
|
||||||
|
Guid Id,
|
||||||
|
EventId EventId,
|
||||||
|
DateTimeOffset DetectedAt,
|
||||||
|
AnomalyKind Kind,
|
||||||
|
decimal Score,
|
||||||
|
string EvidenceJson)
|
||||||
|
{
|
||||||
|
public Guid Id { get; } = Id == Guid.Empty
|
||||||
|
? throw new ArgumentException("Anomaly Id must not be an empty GUID.", nameof(Id))
|
||||||
|
: Id;
|
||||||
|
|
||||||
|
public EventId EventId { get; } = EventId ?? throw new ArgumentNullException(nameof(EventId));
|
||||||
|
|
||||||
|
public DateTimeOffset DetectedAt { get; } = DetectedAt;
|
||||||
|
|
||||||
|
public AnomalyKind Kind { get; } = Kind;
|
||||||
|
|
||||||
|
public decimal Score { get; } = Score is >= 0m and <= 1m
|
||||||
|
? Score
|
||||||
|
: throw new ArgumentOutOfRangeException(nameof(Score), Score,
|
||||||
|
"Anomaly Score must be in the range [0, 1].");
|
||||||
|
|
||||||
|
public string EvidenceJson { get; } = string.IsNullOrWhiteSpace(EvidenceJson)
|
||||||
|
? throw new ArgumentException("EvidenceJson must not be empty.", nameof(EvidenceJson))
|
||||||
|
: EvidenceJson;
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
using Marathon.Domain.Enums;
|
||||||
|
using Marathon.Domain.ValueObjects;
|
||||||
|
|
||||||
|
namespace Marathon.Domain.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A single betting option within an odds snapshot.
|
||||||
|
/// Invariants enforced in constructor:
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item>Win: Side ∈ {Side1, Side2}, Value == null</item>
|
||||||
|
/// <item>Draw: Side == Draw, Value == null</item>
|
||||||
|
/// <item>WinFora: Side ∈ {Side1, Side2}, Value != null (handicap threshold)</item>
|
||||||
|
/// <item>Total: Side ∈ {Less, More}, Value != null (total threshold)</item>
|
||||||
|
/// </list>
|
||||||
|
/// </summary>
|
||||||
|
public sealed record Bet
|
||||||
|
{
|
||||||
|
public BetScope Scope { get; }
|
||||||
|
public BetType Type { get; }
|
||||||
|
public Side Side { get; }
|
||||||
|
public OddsValue? Value { get; }
|
||||||
|
public OddsRate Rate { get; }
|
||||||
|
|
||||||
|
public Bet(BetScope scope, BetType type, Side side, OddsValue? value, OddsRate rate)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(scope);
|
||||||
|
ArgumentNullException.ThrowIfNull(rate);
|
||||||
|
|
||||||
|
ValidateInvariants(type, side, value);
|
||||||
|
|
||||||
|
Scope = scope;
|
||||||
|
Type = type;
|
||||||
|
Side = side;
|
||||||
|
Value = value;
|
||||||
|
Rate = rate;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ValidateInvariants(BetType type, Side side, OddsValue? value)
|
||||||
|
{
|
||||||
|
switch (type)
|
||||||
|
{
|
||||||
|
case BetType.Win:
|
||||||
|
if (side is not (Side.Side1 or Side.Side2))
|
||||||
|
throw new ArgumentException(
|
||||||
|
$"Win bet requires Side1 or Side2. Got: {side}.", nameof(side));
|
||||||
|
if (value is not null)
|
||||||
|
throw new ArgumentException(
|
||||||
|
"Win bet must have Value == null.", nameof(value));
|
||||||
|
break;
|
||||||
|
|
||||||
|
case BetType.Draw:
|
||||||
|
if (side != Side.Draw)
|
||||||
|
throw new ArgumentException(
|
||||||
|
$"Draw bet requires Side == Draw. Got: {side}.", nameof(side));
|
||||||
|
if (value is not null)
|
||||||
|
throw new ArgumentException(
|
||||||
|
"Draw bet must have Value == null.", nameof(value));
|
||||||
|
break;
|
||||||
|
|
||||||
|
case BetType.WinFora:
|
||||||
|
if (side is not (Side.Side1 or Side.Side2))
|
||||||
|
throw new ArgumentException(
|
||||||
|
$"WinFora bet requires Side1 or Side2. Got: {side}.", nameof(side));
|
||||||
|
if (value is null)
|
||||||
|
throw new ArgumentException(
|
||||||
|
"WinFora bet requires a non-null handicap Value.", nameof(value));
|
||||||
|
break;
|
||||||
|
|
||||||
|
case BetType.Total:
|
||||||
|
if (side is not (Side.Less or Side.More))
|
||||||
|
throw new ArgumentException(
|
||||||
|
$"Total bet requires Side == Less or More. Got: {side}.", nameof(side));
|
||||||
|
if (value is null)
|
||||||
|
throw new ArgumentException(
|
||||||
|
"Total bet requires a non-null threshold Value.", nameof(value));
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(type), type, "Unknown BetType.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
namespace Marathon.Domain.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A country or geographic group associated with a league.
|
||||||
|
/// <c>Code</c> is the bookmaker's string identifier (e.g., breadcrumb text).
|
||||||
|
/// </summary>
|
||||||
|
public sealed record Country(string Code, string NameRu, string NameEn)
|
||||||
|
{
|
||||||
|
public string Code { get; } = string.IsNullOrWhiteSpace(Code)
|
||||||
|
? throw new ArgumentException("Country Code must not be empty.", nameof(Code))
|
||||||
|
: Code;
|
||||||
|
|
||||||
|
public string NameRu { get; } = string.IsNullOrWhiteSpace(NameRu)
|
||||||
|
? throw new ArgumentException("NameRu must not be empty.", nameof(NameRu))
|
||||||
|
: NameRu;
|
||||||
|
|
||||||
|
public string NameEn { get; } = string.IsNullOrWhiteSpace(NameEn)
|
||||||
|
? throw new ArgumentException("NameEn must not be empty.", nameof(NameEn))
|
||||||
|
: NameEn;
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
using Marathon.Domain.ValueObjects;
|
||||||
|
|
||||||
|
namespace Marathon.Domain.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A sporting event that can be bet on.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para><c>ScheduledAt</c> is stored in Europe/Moscow time (UTC+3, no DST).
|
||||||
|
/// The offset <c>+03:00</c> is baked in — it is NOT converted to UTC.
|
||||||
|
/// This matches <c>initData.serverTime</c> from the scraped page, which is in Moscow time.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public sealed record Event(
|
||||||
|
EventId Id,
|
||||||
|
SportCode Sport,
|
||||||
|
string CountryCode,
|
||||||
|
string LeagueId,
|
||||||
|
string Category,
|
||||||
|
DateTimeOffset ScheduledAt,
|
||||||
|
string Side1Name,
|
||||||
|
string Side2Name)
|
||||||
|
{
|
||||||
|
public EventId Id { get; } = Id ?? throw new ArgumentNullException(nameof(Id));
|
||||||
|
|
||||||
|
public SportCode Sport { get; } = Sport ?? throw new ArgumentNullException(nameof(Sport));
|
||||||
|
|
||||||
|
public string CountryCode { get; } = string.IsNullOrWhiteSpace(CountryCode)
|
||||||
|
? throw new ArgumentException("CountryCode must not be empty.", nameof(CountryCode))
|
||||||
|
: CountryCode;
|
||||||
|
|
||||||
|
public string LeagueId { get; } = string.IsNullOrWhiteSpace(LeagueId)
|
||||||
|
? throw new ArgumentException("LeagueId must not be empty.", nameof(LeagueId))
|
||||||
|
: LeagueId;
|
||||||
|
|
||||||
|
public string Category { get; } = Category ?? string.Empty;
|
||||||
|
|
||||||
|
public DateTimeOffset ScheduledAt { get; } = ScheduledAt.Offset == MoscowTime.Offset
|
||||||
|
? ScheduledAt
|
||||||
|
: throw new ArgumentException(
|
||||||
|
$"ScheduledAt must be in Europe/Moscow time (UTC+03:00). " +
|
||||||
|
$"Received offset: {ScheduledAt.Offset:hh\\:mm}. " +
|
||||||
|
"Convert to Moscow time before constructing the Event.",
|
||||||
|
nameof(ScheduledAt));
|
||||||
|
|
||||||
|
public string Side1Name { get; } = string.IsNullOrWhiteSpace(Side1Name)
|
||||||
|
? throw new ArgumentException("Side1Name must not be empty.", nameof(Side1Name))
|
||||||
|
: Side1Name;
|
||||||
|
|
||||||
|
public string Side2Name { get; } = string.IsNullOrWhiteSpace(Side2Name)
|
||||||
|
? throw new ArgumentException("Side2Name must not be empty.", nameof(Side2Name))
|
||||||
|
: Side2Name;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Bookmaker URL fragment used to fetch event-detail markets, sourced from the
|
||||||
|
/// listing page's <c>data-event-path</c> attribute (e.g.
|
||||||
|
/// <c>"Football/Clubs.+International/UEFA+Champions+League/.../Arsenal+vs+Chelsea+-+28089645"</c>).
|
||||||
|
/// Combined with <c>/su/betting/</c> by the scraper.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Optional for backward compatibility with rows persisted before the column
|
||||||
|
/// was introduced. When null, the scraper falls back to the (less reliable)
|
||||||
|
/// numeric event ID.
|
||||||
|
/// </remarks>
|
||||||
|
public string? EventPath { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Display title in the canonical "{Side1Name} vs {Side2Name}" form. Single
|
||||||
|
/// source for the home-vs-away join that was previously duplicated across the
|
||||||
|
/// report use cases and list/feed services.
|
||||||
|
/// </summary>
|
||||||
|
public string Title => $"{Side1Name} vs {Side2Name}";
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
using Marathon.Domain.Enums;
|
||||||
|
using Marathon.Domain.ValueObjects;
|
||||||
|
|
||||||
|
namespace Marathon.Domain.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The final result of a sporting event after it has completed.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record EventResult(
|
||||||
|
EventId EventId,
|
||||||
|
int Side1Score,
|
||||||
|
int Side2Score,
|
||||||
|
Side WinnerSide,
|
||||||
|
DateTimeOffset CompletedAt)
|
||||||
|
{
|
||||||
|
public EventId EventId { get; } = EventId ?? throw new ArgumentNullException(nameof(EventId));
|
||||||
|
|
||||||
|
public int Side1Score { get; } = Side1Score >= 0
|
||||||
|
? Side1Score
|
||||||
|
: throw new ArgumentOutOfRangeException(nameof(Side1Score), "Score must be non-negative.");
|
||||||
|
|
||||||
|
public int Side2Score { get; } = Side2Score >= 0
|
||||||
|
? Side2Score
|
||||||
|
: throw new ArgumentOutOfRangeException(nameof(Side2Score), "Score must be non-negative.");
|
||||||
|
|
||||||
|
public Side WinnerSide { get; } = WinnerSide is Side.Side1 or Side.Side2 or Side.Draw
|
||||||
|
? WinnerSide
|
||||||
|
: throw new ArgumentException(
|
||||||
|
$"WinnerSide must be Side1, Side2, or Draw. Got: {WinnerSide}.", nameof(WinnerSide));
|
||||||
|
|
||||||
|
public DateTimeOffset CompletedAt { get; } = CompletedAt;
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
using Marathon.Domain.ValueObjects;
|
||||||
|
|
||||||
|
namespace Marathon.Domain.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A sports league or tournament within a country and sport.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record League(
|
||||||
|
string Id,
|
||||||
|
SportCode Sport,
|
||||||
|
string Country,
|
||||||
|
string NameRu,
|
||||||
|
string NameEn,
|
||||||
|
string Category)
|
||||||
|
{
|
||||||
|
public string Id { get; } = string.IsNullOrWhiteSpace(Id)
|
||||||
|
? throw new ArgumentException("League Id must not be empty.", nameof(Id))
|
||||||
|
: Id;
|
||||||
|
|
||||||
|
public SportCode Sport { get; } = Sport ?? throw new ArgumentNullException(nameof(Sport));
|
||||||
|
|
||||||
|
public string Country { get; } = string.IsNullOrWhiteSpace(Country)
|
||||||
|
? throw new ArgumentException("Country must not be empty.", nameof(Country))
|
||||||
|
: Country;
|
||||||
|
|
||||||
|
public string NameRu { get; } = string.IsNullOrWhiteSpace(NameRu)
|
||||||
|
? throw new ArgumentException("NameRu must not be empty.", nameof(NameRu))
|
||||||
|
: NameRu;
|
||||||
|
|
||||||
|
public string NameEn { get; } = string.IsNullOrWhiteSpace(NameEn)
|
||||||
|
? throw new ArgumentException("NameEn must not be empty.", nameof(NameEn))
|
||||||
|
: NameEn;
|
||||||
|
|
||||||
|
public string Category { get; } = Category ?? string.Empty;
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
using Marathon.Domain.Enums;
|
||||||
|
using Marathon.Domain.ValueObjects;
|
||||||
|
|
||||||
|
namespace Marathon.Domain.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A point-in-time capture of all odds for a specific event.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record OddsSnapshot
|
||||||
|
{
|
||||||
|
public EventId EventId { get; }
|
||||||
|
public DateTimeOffset CapturedAt { get; }
|
||||||
|
public OddsSource Source { get; }
|
||||||
|
public IReadOnlyList<Bet> Bets { get; }
|
||||||
|
|
||||||
|
public OddsSnapshot(
|
||||||
|
EventId eventId,
|
||||||
|
DateTimeOffset capturedAt,
|
||||||
|
OddsSource source,
|
||||||
|
IReadOnlyList<Bet> bets)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(eventId);
|
||||||
|
ArgumentNullException.ThrowIfNull(bets);
|
||||||
|
|
||||||
|
if (bets.Count == 0)
|
||||||
|
throw new ArgumentException(
|
||||||
|
"OddsSnapshot must contain at least one Bet.", nameof(bets));
|
||||||
|
|
||||||
|
EventId = eventId;
|
||||||
|
CapturedAt = capturedAt;
|
||||||
|
Source = source;
|
||||||
|
Bets = bets;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
using Marathon.Domain.Enums;
|
||||||
|
using Marathon.Domain.ValueObjects;
|
||||||
|
|
||||||
|
namespace Marathon.Domain.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A hypothetical "paper" wager opened automatically by the forward-test worker the
|
||||||
|
/// moment a directional anomaly fires, then settled when the event result arrives.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Unlike <see cref="PlacedBet"/> (the user's real journal), a paper bet is
|
||||||
|
/// system-generated and exists only to measure the detector's live, out-of-sample
|
||||||
|
/// edge — the antidote to backtest overfitting. Exactly one paper bet is opened per
|
||||||
|
/// anomaly (enforced by a unique index on <see cref="AnomalyId"/>).
|
||||||
|
/// </remarks>
|
||||||
|
public sealed record PaperBet(
|
||||||
|
Guid Id,
|
||||||
|
Guid AnomalyId,
|
||||||
|
EventId EventId,
|
||||||
|
Side PickedSide,
|
||||||
|
decimal Rate,
|
||||||
|
decimal Stake,
|
||||||
|
DateTimeOffset OpenedAt,
|
||||||
|
BetOutcome Outcome,
|
||||||
|
DateTimeOffset? SettledAt,
|
||||||
|
decimal? Payout)
|
||||||
|
{
|
||||||
|
public decimal Rate { get; } = Rate > 1m
|
||||||
|
? Rate
|
||||||
|
: throw new ArgumentOutOfRangeException(nameof(Rate), Rate, "Decimal odds must be greater than 1.");
|
||||||
|
|
||||||
|
public decimal Stake { get; } = Stake > 0m
|
||||||
|
? Stake
|
||||||
|
: throw new ArgumentOutOfRangeException(nameof(Stake), Stake, "Stake must be positive.");
|
||||||
|
|
||||||
|
/// <summary>Whether the bet is still awaiting a result.</summary>
|
||||||
|
public bool IsOpen => Outcome == BetOutcome.Pending;
|
||||||
|
|
||||||
|
/// <summary>Opens a fresh, unsettled paper bet with a new identity.</summary>
|
||||||
|
public static PaperBet Open(
|
||||||
|
Guid anomalyId, EventId eventId, Side pickedSide, decimal rate, decimal stake, DateTimeOffset openedAt)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(eventId);
|
||||||
|
return new PaperBet(
|
||||||
|
Id: Guid.NewGuid(),
|
||||||
|
AnomalyId: anomalyId,
|
||||||
|
EventId: eventId,
|
||||||
|
PickedSide: pickedSide,
|
||||||
|
Rate: rate,
|
||||||
|
Stake: stake,
|
||||||
|
OpenedAt: openedAt,
|
||||||
|
Outcome: BetOutcome.Pending,
|
||||||
|
SettledAt: null,
|
||||||
|
Payout: null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Settles the bet against the actual winner: Won (payout = stake × rate) when
|
||||||
|
/// <paramref name="winnerSide"/> equals <see cref="PickedSide"/>, otherwise Lost
|
||||||
|
/// (payout 0). A win-market pick that draws simply loses.
|
||||||
|
/// </summary>
|
||||||
|
public PaperBet SettleAgainst(Side winnerSide, DateTimeOffset settledAt)
|
||||||
|
{
|
||||||
|
var won = winnerSide == PickedSide;
|
||||||
|
return this with
|
||||||
|
{
|
||||||
|
Outcome = won ? BetOutcome.Won : BetOutcome.Lost,
|
||||||
|
SettledAt = settledAt,
|
||||||
|
Payout = won ? Stake * Rate : 0m,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
using Marathon.Domain.Enums;
|
||||||
|
using Marathon.Domain.ValueObjects;
|
||||||
|
|
||||||
|
namespace Marathon.Domain.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A wager the user manually recorded as having placed (with this or another
|
||||||
|
/// bookmaker). Reuses the <see cref="Bet"/> vocabulary so the journal can mirror
|
||||||
|
/// scraped markets directly — same Scope / Type / Side / Value / Rate invariants
|
||||||
|
/// apply to <see cref="Selection"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="Id">Stable identifier — Guid so duplicates can be detected by the UI.</param>
|
||||||
|
/// <param name="EventId">Event the wager is on.</param>
|
||||||
|
/// <param name="Selection">
|
||||||
|
/// The market + rate the user took. <c>Selection.Rate</c> is the "taken rate"
|
||||||
|
/// used for ROI and CLV calculations.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="Stake">
|
||||||
|
/// Money risked, in the user's currency. The domain does not encode currency —
|
||||||
|
/// stake values are compared as raw decimals.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="PlacedAt">When the bet was recorded. Stored as Moscow time.</param>
|
||||||
|
/// <param name="Outcome">Current settlement state — see <see cref="BetOutcome"/>.</param>
|
||||||
|
/// <param name="Notes">Optional free text — strategy tag, source, etc.</param>
|
||||||
|
public sealed record PlacedBet(
|
||||||
|
Guid Id,
|
||||||
|
EventId EventId,
|
||||||
|
Bet Selection,
|
||||||
|
decimal Stake,
|
||||||
|
DateTimeOffset PlacedAt,
|
||||||
|
BetOutcome Outcome,
|
||||||
|
string? Notes)
|
||||||
|
{
|
||||||
|
public Guid Id { get; } = Id == Guid.Empty
|
||||||
|
? throw new ArgumentException("PlacedBet Id must not be an empty GUID.", nameof(Id))
|
||||||
|
: Id;
|
||||||
|
|
||||||
|
public EventId EventId { get; } = EventId ?? throw new ArgumentNullException(nameof(EventId));
|
||||||
|
|
||||||
|
public Bet Selection { get; } = Selection ?? throw new ArgumentNullException(nameof(Selection));
|
||||||
|
|
||||||
|
public decimal Stake { get; } = Stake > 0m
|
||||||
|
? Stake
|
||||||
|
: throw new ArgumentOutOfRangeException(nameof(Stake), Stake,
|
||||||
|
"Stake must be positive.");
|
||||||
|
|
||||||
|
public DateTimeOffset PlacedAt { get; } = PlacedAt.Offset == MoscowTime.Offset
|
||||||
|
? PlacedAt
|
||||||
|
: throw new ArgumentException(
|
||||||
|
$"PlacedAt must be in Europe/Moscow time (UTC+03:00). " +
|
||||||
|
$"Received offset: {PlacedAt.Offset:hh\\:mm}.",
|
||||||
|
nameof(PlacedAt));
|
||||||
|
|
||||||
|
public BetOutcome Outcome { get; } = Outcome;
|
||||||
|
|
||||||
|
public string? Notes { get; } = string.IsNullOrWhiteSpace(Notes) ? null : Notes;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gross return on this bet for the current outcome — the amount the
|
||||||
|
/// bookmaker pays back to the user (stake + winnings).
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item><see cref="BetOutcome.Won"/>: <c>Stake × Rate</c></item>
|
||||||
|
/// <item><see cref="BetOutcome.Void"/>: <c>Stake</c> (push — stake returned)</item>
|
||||||
|
/// <item><see cref="BetOutcome.Lost"/>: <c>0</c></item>
|
||||||
|
/// <item><see cref="BetOutcome.Pending"/>: <c>null</c> (unknown)</item>
|
||||||
|
/// </list>
|
||||||
|
/// </summary>
|
||||||
|
public decimal? GrossReturn => Outcome switch
|
||||||
|
{
|
||||||
|
BetOutcome.Won => Stake * Selection.Rate.Value,
|
||||||
|
BetOutcome.Void => Stake,
|
||||||
|
BetOutcome.Lost => 0m,
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Net profit for the current outcome — <see cref="GrossReturn"/> minus
|
||||||
|
/// <see cref="Stake"/>. Negative for losses. Null while pending.
|
||||||
|
/// </summary>
|
||||||
|
public decimal? NetProfit => GrossReturn is null ? null : GrossReturn.Value - Stake;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns a copy with a new <see cref="Outcome"/> — used by the resolver
|
||||||
|
/// use case after grading the event. Constructs explicitly because the
|
||||||
|
/// manual validating <c>get</c>-only properties prevent <c>with</c>.
|
||||||
|
/// </summary>
|
||||||
|
public PlacedBet WithOutcome(BetOutcome outcome) =>
|
||||||
|
new(Id, EventId, Selection, Stake, PlacedAt, outcome, Notes);
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
using Marathon.Domain.ValueObjects;
|
||||||
|
|
||||||
|
namespace Marathon.Domain.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A sport supported by the bookmaker.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record Sport(SportCode Code, string NameRu, string NameEn)
|
||||||
|
{
|
||||||
|
public SportCode Code { get; } = Code ?? throw new ArgumentNullException(nameof(Code));
|
||||||
|
|
||||||
|
public string NameRu { get; } = string.IsNullOrWhiteSpace(NameRu)
|
||||||
|
? throw new ArgumentException("NameRu must not be empty.", nameof(NameRu))
|
||||||
|
: NameRu;
|
||||||
|
|
||||||
|
public string NameEn { get; } = string.IsNullOrWhiteSpace(NameEn)
|
||||||
|
? throw new ArgumentException("NameEn must not be empty.", nameof(NameEn))
|
||||||
|
: NameEn;
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
namespace Marathon.Domain.Enums;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The category of a detected anomaly.
|
||||||
|
/// Extensible — new kinds will be added in future phases.
|
||||||
|
/// </summary>
|
||||||
|
public enum AnomalyKind
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Bookmaker suspended the market, then flipped the underdog/favourite coefficients.
|
||||||
|
/// </summary>
|
||||||
|
SuspensionFlip,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A rapid, one-directional drift in a side's implied probability over a short
|
||||||
|
/// continuous window (no suspension) — money moving the line ("steam").
|
||||||
|
/// </summary>
|
||||||
|
SteamMove,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The bookmaker suspended the market but resumed with essentially the same line
|
||||||
|
/// (favourite unchanged, negligible price move) — a freeze signalling uncertainty.
|
||||||
|
/// </summary>
|
||||||
|
SuspensionFreeze,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The bookmaker's margin (overround) compressed sharply over a short continuous
|
||||||
|
/// window — the book tightened its vig, often ahead of news or when confident.
|
||||||
|
/// </summary>
|
||||||
|
OverroundCompression,
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
namespace Marathon.Domain.Enums;
|
||||||
|
|
||||||
|
/// <summary>Semantic classification of anomaly kinds.</summary>
|
||||||
|
public static class AnomalyKindExtensions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Whether the kind makes a <i>directional</i> prediction — a specific side/favourite
|
||||||
|
/// expected to win — that can be graded against the result and bet on in a backtest.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <see cref="AnomalyKind.SuspensionFlip"/> and <see cref="AnomalyKind.SteamMove"/> are
|
||||||
|
/// directional (they point at a favourite). <see cref="AnomalyKind.SuspensionFreeze"/> is
|
||||||
|
/// informational — the line did NOT move — so "predicting" the unchanged favourite would
|
||||||
|
/// merely measure the base favourite-win rate; it is excluded from outcome grading and
|
||||||
|
/// from backtest staking so it does not distort detector calibration.
|
||||||
|
/// </remarks>
|
||||||
|
public static bool IsDirectional(this AnomalyKind kind) => kind switch
|
||||||
|
{
|
||||||
|
AnomalyKind.SuspensionFlip => true,
|
||||||
|
AnomalyKind.SteamMove => true,
|
||||||
|
AnomalyKind.SuspensionFreeze => false,
|
||||||
|
AnomalyKind.OverroundCompression => false,
|
||||||
|
_ => false,
|
||||||
|
};
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user