Files
maraphon-app/CLAUDE.md
T
alexei.dolgolyov e60b5bf57e docs: capture H-series learnings in project memory
Recurring Issues & Patterns: EF migration generation via dotnet ef + the
migrations-remove snapshot hazard, Sports.Code ValueGeneratedNever, validated
get-only record props blocking `with` (CS0200), and the JsonSettingsWriter
section-replace secret-clobbering gotcha + UI-mirror-options pattern.

New "Analysis Hardening (H-series)" learnings: the 4-detector fan-out +
IsDirectional() gate, SavedStrategy NOCASE name collation, and the config-gated
paper-trading worker.
2026-05-29 11:18:52 +03:00

248 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# CLAUDE.md — maraphon-app
> Project memory for Claude Code sessions on this repository. Keep entries concise.
> Per-feature learnings are appended below by the feature-planner workflow.
## Project Overview
**maraphon-app** is a sports betting odds analyzer for marathonbet.by. It scrapes
pre-match (`/su`) and live (`/su/live`) events, persists odds snapshots over time, and
detects anomalies — especially the **odds-flip** pattern (bookmaker freezes bets then
inverts underdog/favorite coefficients).
## Architecture (Clean Architecture, 5 projects + tests)
```
Marathon.Domain ← entities, value objects, no external deps
Marathon.Application ← use cases + abstractions (IOddsScraper, IRepository, ...)
Marathon.Infrastructure ← EF Core (SQLite), scraping (AngleSharp/Playwright), Excel, Polly
Marathon.UI ← Razor Class Library (all Blazor components — host-agnostic)
Marathon.Hosts.WpfBlazor ← WPF + BlazorWebView host (replaceable for ASP.NET Core later)
```
**Key portability invariant:** All UI lives in `Marathon.UI` (Razor Class Library). The
host project (`Marathon.Hosts.WpfBlazor`) is the *only* thing that changes when migrating
to a web app — drop in an ASP.NET Core Blazor Server host that references the same RCL.
## Tech stack
- **.NET 8 LTS**, C# 12
- **EF Core 8** + SQLite (WAL mode)
- **AngleSharp** (HTML), **Playwright for .NET** (SPA fallback)
- **Polly v8** (`Microsoft.Extensions.Http.Resilience`)
- **MudBlazor** components, **Plotly.Blazor** charts
- **Serilog** logging (rolling file + console)
- **xUnit + FluentAssertions + NSubstitute**, in-memory SQLite for repo tests
## Build & test
| Command | Purpose |
|---|---|
| `dotnet build Marathon.sln` | Build all projects |
| `dotnet test Marathon.sln` | Run all tests |
| `dotnet format Marathon.sln --verify-no-changes` | Lint |
| `dotnet run --project src/Marathon.Hosts.WpfBlazor` | Run desktop app |
## Coding conventions
- Nullable reference types: **enabled** (`<Nullable>enable</Nullable>`)
- Implicit usings: enabled
- Treat warnings as errors in `Release` builds
- File-scoped namespaces
- One public type per file (except small DTOs/records grouped in a feature folder)
- Domain entities: prefer `record` for immutable data; class with private setters when
identity matters
- No mutation of domain objects after construction — return new instances
- Repositories return `IReadOnlyList<T>`, not `List<T>` or `IEnumerable<T>` (clarity on
enumeration cost)
- Tests follow `Given_When_Then` or `Should_<expected>_When_<condition>` naming
## Configuration
Every variable parameter is configurable via `appsettings.json` and overridable via
`appsettings.Local.json` (gitignored) or environment variables:
- `Scraping:PollingIntervalSeconds` (default 30)
- `Scraping:MaxConcurrentRequests` (default 4)
- `Scraping:UserAgents[]` (rotated per request)
- `Scraping:RetryPolicy:*` (Polly settings)
- `Scraping:RateLimit:RequestsPerSecond` (default 1)
- `Storage:DatabasePath` (default `./data/marathon.db`)
- `Storage:ExportDirectory` (default `./exports`)
- `Storage:SnapshotRetentionDays` (default 90)
- `Anomaly:SuspensionGapSeconds` (default 60)
- `Anomaly:OddsFlipThreshold` (default 0.30 — implied probability delta)
- `Localization:DefaultCulture` (default `ru-RU`)
A future Settings page in the UI binds to these.
## Domain model summary
- `Sport(Code, Name)` — e.g., `(6, "Баскетбол")`
- `Event(Id, SportCode, CountryCode, LeagueId, CategoryId, ScheduledAt, EventCodeFromBookmaker)`
- `OddsSnapshot(EventId, CapturedAt, Source: Pre|Live, Bets: List<Bet>)`
- `Bet(Scope: Match|Period[N], Type: Win|Draw|WinFora|Total, Side: 1|2|Less|More, Value?, Rate)`
- `EventResult(EventId, FinalScore, WinnerSide)`
- `Anomaly(EventId, DetectedAt, Kind: SuspensionFlip, Score, EvidenceTimeline)`
## Excel export schema (compliance with customer spec)
Customer TZ requires wide-table layout with columns like `Bet_Match_Win_1`,
`Bet_Period-1_Win_Fora_2_Value`, etc.
**Internal storage is normalized** (one row per Bet in `OddsSnapshots`). The Excel
exporter denormalizes to the wide format on demand. Filename pattern:
```
Marathon_<YYYY-MM-DD>_to_<YYYY-MM-DD>.xlsx
```
## Recurring Issues & Patterns
- **`dotnet new sln` on .NET 10 SDK produces `.slnx`**, not `.sln`. If the plan
references `Marathon.sln`, hand-craft the traditional format alongside `.slnx`.
- **`Marathon.Application` namespace vs `System.Windows.Application`:** in any WPF
project that references `Marathon.Application`, always write
`System.Windows.Application` fully qualified in `App.xaml.cs`.
- **`Directory.Build.props` must NOT set `TargetFramework`** when projects in the
same solution use different TFMs (e.g., `net8.0` vs `net8.0-windows`).
- **Razor source generator does NOT accept C# 11 raw string literals** (`"""…"""`)
inside `@code` blocks — concatenate single-quoted attribute strings instead.
- **Razor reserves the identifier `code`.** Loop variables must use any other
name (`var sportCode in ...`) or the parser treats it as the `@code` directive.
- **`MudBlazor.DateRange` shadows `Marathon.Application.Storage.DateRange`** in
any Razor file that pulls both namespaces via `_Imports.razor`. Use a per-file
alias: `using AppDateRange = Marathon.Application.Storage.DateRange;`.
- **`Plotly.Blazor.LayoutLib.Margin` clashes with `MudBlazor.Margin`.** Fully
qualify the Plotly side at the new-expression: `new Plotly.Blazor.LayoutLib.Margin {…}`.
- **`Event.ScheduledAt` requires offset `+03:00`.** Test fixtures and any code
that constructs Moscow datetimes must use `new DateTimeOffset(date, TimeSpan.FromHours(3))`,
never pass a `DateTime.UtcNow` value to that constructor.
- **EF migrations — generate with `dotnet ef`, do NOT `migrations remove`.**
`MarathonDbContextFactory` is a self-contained design-time factory, so
`dotnet ef migrations add X --project src/Marathon.Infrastructure --startup-project src/Marathon.Infrastructure`
works. AVOID `dotnet ef migrations remove`: older migrations were hand-written without a
Designer snapshot, so remove blanks `MarathonDbContextModelSnapshot.cs` and the next `add`
regenerates the WHOLE schema. Validate a migration on a throwaway DB with
`dotnet ef database update --connection "Data Source=<ABSOLUTE>/data/_migtest.db"` (absolute
path — EF resolves relatives from the build-output dir, not the shell cwd; create `./data/` first).
- **`Sports.Code` is `.ValueGeneratedNever()`** — it's the bookmaker's natural sport id
(6/11/22723…), not an autoincrement surrogate. Without it EF's int-PK convention emits a
spurious AUTOINCREMENT `AlterColumn` on every migration.
- **Validated get-only record properties block `with`** (CS0200): `BacktestStrategy.MinScore`,
`PaperBet.Rate`/`Stake` are re-declared with validation, so build a new instance instead of
`with { ThatProp = … }` (you can still `with` the un-redeclared props, e.g. `SavedStrategy with { Strategy = … }`).
- **`JsonSettingsWriter.SaveSectionAsync` replaces the whole section** (`root[section]=json`),
so a Settings-UI save drops any key not on the form. Never surface a secret-bearing section
(e.g. `Notifications` → Telegram token) in the UI — it would wipe the secret from
`appsettings.Local.json`. To surface a non-secret section, add a mutable mirror options class
in `Marathon.UI.Services` bound to the same section name (UI can't reference Infrastructure's
options types — same pattern as the two `WorkerOptions`).
## Feature: Initial Implementation > Phase 4: Application + Workers — Learnings
- **Two `WorkerOptions` classes coexist** with the same JSON shape but different namespaces:
`Marathon.Infrastructure.Configuration.WorkerOptions` (immutable `init`, used by workers)
and `Marathon.UI.Services.WorkerOptions` (mutable `set`, used by Settings page).
Both bind to `"Workers"` in `appsettings.json`. Keep them in sync when adding new keys.
- **`Microsoft.Extensions.Logging.EventId` conflicts with `Marathon.Domain.ValueObjects.EventId`**
in any project that adds `Microsoft.Extensions.Logging.Abstractions`. Fix with a global alias
in `GlobalUsings.cs`: `global using LogEventId = Microsoft.Extensions.Logging.EventId;`
and local file aliases where both are used together.
- **NSubstitute cannot proxy `sealed` classes.** Use cases are `sealed record` or `sealed class`.
Worker tests must build a real use-case instance backed by substituted interfaces rather than
substituting the use case directly.
- **`BackgroundService` workers are singletons; use cases are scoped.** Always resolve scoped
use cases via `IServiceProvider.CreateAsyncScope()` inside the worker loop — never inject them
directly into the constructor.
- **Cronos 6-field cron format.** Pass `CronFormat.IncludeSeconds` to `CronExpression.Parse`
when the expression has a seconds field (e.g., `"0 0 */6 * * *"`). Default Cronos parse
expects 5-field (no seconds).
- **`ApplicationModule.AddMarathonApplication` takes no `IConfiguration`** — the Application
layer has no config bindings of its own. Infrastructure and UI bind their own options sections.
## Feature: Initial Implementation > Phase 0: Scraping Spike — Learnings
(Permanent learnings about marathonbet.by data shape, anti-bot, page structure.
For full detail see `spike/SCRAPE_FINDINGS.md` and `spike/SCHEMA_DRAFT.md`.)
- **Site is fully SSR (`Server: nginx`).** Anonymous GET with browser User-Agent
returns full HTML for `/su/`, `/su/live`, `/su/popular/<Sport>`,
`/su/betting/<event-path>`. No Cloudflare, no JS challenge.
- **Use HttpClient + AngleSharp + Polly v8** — no Playwright needed for read-only.
Keep `Scraping:UsePlaywright = false` flag for future-proofing.
- **Sport ID = `data-sport-treeId` = breadcrumb canonical ID.** Confirmed:
Basketball=6, Football=11, Tennis=22723, Hockey=43658. URL by ID:
`/su/betting/<Sport>+-+<id>` (preferred over `/su/popular/<Sport>` because the
ID is stable).
- **`EventCode` = `data-event-eventId`** (numeric, ~26-million range, stable).
`TreeId` = `data-event-treeId` (URL-routing ID, less stable). Use `EventCode`
as the entity primary key in SQLite.
- **Selection key format:** `{eventId}@{MarketName}{LineIndex?}.{Outcome}`.
Outcomes: `1`/`draw`/`3` for 3-way, `HB_H`/`HB_A` for handicap, `Under_<X>`/
`Over_<X>` for totals. Total threshold is encoded in the outcome string;
handicap value lives in `<span class="middle-simple">` text.
- **Tennis has no Draw outcome.** Domain `Bet_Match_Draw` must be nullable; Excel
exporter writes empty cell when null.
- **Date parsing:** listing shows `HH:MM` (today) or `DD <ru-month> HH:MM` (future).
Anchor with `initData.serverTime` (Moscow TZ, format `YYYY,MM,DD,HH,MM,SS`)
parsed from the embedded `<script>` blob on every scraped page.
- **Live updates:** site polls `/su/liveupdate/popular/?treeIds=...` every 3 s but
response is just `{"modified":[{"type":"refreshPage"}],...}` — re-scrape the
full event detail HTML for actual odds. Our analyzer cadence: pre-match 30 s,
live 510 s.
- **No public results / archive page** (`/su/results` → 404). Final scores must
be harvested by polling the event detail page until
`eventJsonInfo.matchIsComplete=true`, then storing `resultDescription`. Phase 8
cannot back-fill from a public archive.
- **Period scope vocabulary varies by sport:** football=`1st_Half`, basketball=
`1st_Half`/`1st_Quarter`, tennis=`1st_Set`, hockey=`1st_Period`. Domain stores
`PeriodNumber:int` and a sport-aware `PeriodScopeMapper` resolves the correct
market token at parse time.
## Feature: Initial Implementation > Phase 7: Anomaly UI — Learnings
- **Severity buckets are a single-source rule** in
`Marathon.UI.Services.AnomalySeverityRules.FromScore` (Low <0.45, Medium <0.60,
High ≥0.60). The badge pill, card border, feed filter chip, and stat strip all
bind through it — never duplicate the thresholds.
- **`AnomalyBrowsingService` is Scoped, `AnomalyBrowsingState` is Singleton** —
same lifetime split as `EventBrowsingService` / `EventBrowsingState`. State
holds the immutable `AnomalyFilter` + `LastSeenUtc` + cached unread count.
- **`Anomaly.EvidenceJson` is parsed once in the service layer** via
`JsonSerializer.Deserialize<EvidenceDto>(json, PropertyNameCaseInsensitive=true)`
with private nested DTOs. Pages bind to `AnomalyEvidenceSnapshot` value-records
— they never see the raw JSON. Malformed JSON is dropped silently from the feed.
- **2-way markets (tennis) carry `pDraw=null` / `rateDraw=null`.** The
`AnomalyEvidence` and `AnomalyCard` components key off the `IsTwoWay` flag on
`AnomalyListItem` (computed when both pre/post `pDraw` are null) to omit the
Draw row in BOTH columns — never per-column-individually.
- **Signal-red is the load-bearing alert tone** for Phase 7. Use
`var(--m-c-anomaly)` exclusively (never raw `#dc2626`). Pulsing animation
(`m-pulse`) MUST respect `prefers-reduced-motion`.
## Feature: Analysis Hardening (H-series) — Learnings
- **Anomaly detectors are a fan-out array in `DetectAnomaliesUseCase`** — 4 now
(`SuspensionFlip`, `SteamMove`, `SuspensionFreeze`, `OverroundCompression`). A new detector
implements `IAnomalyDetector`, reuses `MatchWinEvidence` for the canonical evidence JSON
(so the parser + outcome evaluator work unchanged), and is added to that array. Continuous
sliding-window detectors (steam, overround) emit at every `end` and skip suspension-sized
gaps so they never overlap the across-suspension flip/freeze detectors.
- **`AnomalyKind.IsDirectional()` gates staking/grading** — flip + steam are directional
(predict a side); freeze + overround are informational and are excluded from the backtest
(`RunBacktestUseCase`) and the outcome evaluator so they don't skew hit-rate/score calibration.
- **`SavedStrategy` (backtest presets) use NOCASE name collation** — the unique index AND
`GetByNameAsync` both fold case (column-level `UseCollation("NOCASE")`), so save-by-name
upserts rather than creating "Kelly"/"kelly" duplicates. Domain stores stake fractions; the
form/VM speak percent — convert ×100/÷100 at the UI boundary.
- **Paper-trading (forward-test) is a config-gated worker** (`PaperTrading:Enabled`, default
false; tunable on the Settings page). `PaperTradingWorker` opens flat-stake `PaperBet`s on
new directional anomalies (unique on `AnomalyId`; baseline since-marker advances only after a
successful open pass; settle runs in its own catch so a settle failure can't strand the
marker) and settles them against results (Won iff pick == winner). `/paper-trading` shows
settled-only P&L. The ledger is FK-free (survives snapshot-retention pruning), like `PlacedBets`.