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.
14 KiB
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
Releasebuilds - File-scoped namespaces
- One public type per file (except small DTOs/records grouped in a feature folder)
- Domain entities: prefer
recordfor immutable data; class with private setters when identity matters - No mutation of domain objects after construction — return new instances
- Repositories return
IReadOnlyList<T>, notList<T>orIEnumerable<T>(clarity on enumeration cost) - Tests follow
Given_When_ThenorShould_<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(defaultru-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 slnon .NET 10 SDK produces.slnx, not.sln. If the plan referencesMarathon.sln, hand-craft the traditional format alongside.slnx.Marathon.Applicationnamespace vsSystem.Windows.Application: in any WPF project that referencesMarathon.Application, always writeSystem.Windows.Applicationfully qualified inApp.xaml.cs.Directory.Build.propsmust NOT setTargetFrameworkwhen projects in the same solution use different TFMs (e.g.,net8.0vsnet8.0-windows).- Razor source generator does NOT accept C# 11 raw string literals (
"""…""") inside@codeblocks — 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@codedirective. MudBlazor.DateRangeshadowsMarathon.Application.Storage.DateRangein any Razor file that pulls both namespaces via_Imports.razor. Use a per-file alias:using AppDateRange = Marathon.Application.Storage.DateRange;.Plotly.Blazor.LayoutLib.Marginclashes withMudBlazor.Margin. Fully qualify the Plotly side at the new-expression:new Plotly.Blazor.LayoutLib.Margin {…}.Event.ScheduledAtrequires offset+03:00. Test fixtures and any code that constructs Moscow datetimes must usenew DateTimeOffset(date, TimeSpan.FromHours(3)), never pass aDateTime.UtcNowvalue to that constructor.- EF migrations — generate with
dotnet ef, do NOTmigrations remove.MarathonDbContextFactoryis a self-contained design-time factory, sodotnet ef migrations add X --project src/Marathon.Infrastructure --startup-project src/Marathon.Infrastructureworks. AVOIDdotnet ef migrations remove: older migrations were hand-written without a Designer snapshot, so remove blanksMarathonDbContextModelSnapshot.csand the nextaddregenerates the WHOLE schema. Validate a migration on a throwaway DB withdotnet 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.Codeis.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 AUTOINCREMENTAlterColumnon every migration.- Validated get-only record properties block
with(CS0200):BacktestStrategy.MinScore,PaperBet.Rate/Stakeare re-declared with validation, so build a new instance instead ofwith { ThatProp = … }(you can stillwiththe un-redeclared props, e.g.SavedStrategy with { Strategy = … }). JsonSettingsWriter.SaveSectionAsyncreplaces 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 fromappsettings.Local.json. To surface a non-secret section, add a mutable mirror options class inMarathon.UI.Servicesbound to the same section name (UI can't reference Infrastructure's options types — same pattern as the twoWorkerOptions).
Feature: Initial Implementation > Phase 4: Application + Workers — Learnings
- Two
WorkerOptionsclasses coexist with the same JSON shape but different namespaces:Marathon.Infrastructure.Configuration.WorkerOptions(immutableinit, used by workers) andMarathon.UI.Services.WorkerOptions(mutableset, used by Settings page). Both bind to"Workers"inappsettings.json. Keep them in sync when adding new keys. Microsoft.Extensions.Logging.EventIdconflicts withMarathon.Domain.ValueObjects.EventIdin any project that addsMicrosoft.Extensions.Logging.Abstractions. Fix with a global alias inGlobalUsings.cs:global using LogEventId = Microsoft.Extensions.Logging.EventId;and local file aliases where both are used together.- NSubstitute cannot proxy
sealedclasses. Use cases aresealed recordorsealed class. Worker tests must build a real use-case instance backed by substituted interfaces rather than substituting the use case directly. BackgroundServiceworkers are singletons; use cases are scoped. Always resolve scoped use cases viaIServiceProvider.CreateAsyncScope()inside the worker loop — never inject them directly into the constructor.- Cronos 6-field cron format. Pass
CronFormat.IncludeSecondstoCronExpression.Parsewhen the expression has a seconds field (e.g.,"0 0 */6 * * *"). Default Cronos parse expects 5-field (no seconds). ApplicationModule.AddMarathonApplicationtakes noIConfiguration— 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 = falseflag 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). UseEventCodeas the entity primary key in SQLite.- Selection key format:
{eventId}@{MarketName}{LineIndex?}.{Outcome}. Outcomes:1/draw/3for 3-way,HB_H/HB_Afor 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_Drawmust be nullable; Excel exporter writes empty cell when null. - Date parsing: listing shows
HH:MM(today) orDD <ru-month> HH:MM(future). Anchor withinitData.serverTime(Moscow TZ, formatYYYY,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 untileventJsonInfo.matchIsComplete=true, then storingresultDescription. 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 storesPeriodNumber:intand a sport-awarePeriodScopeMapperresolves 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. AnomalyBrowsingServiceis Scoped,AnomalyBrowsingStateis Singleton — same lifetime split asEventBrowsingService/EventBrowsingState. State holds the immutableAnomalyFilter+LastSeenUtc+ cached unread count.Anomaly.EvidenceJsonis parsed once in the service layer viaJsonSerializer.Deserialize<EvidenceDto>(json, PropertyNameCaseInsensitive=true)with private nested DTOs. Pages bind toAnomalyEvidenceSnapshotvalue-records — they never see the raw JSON. Malformed JSON is dropped silently from the feed.- 2-way markets (tennis) carry
pDraw=null/rateDraw=null. TheAnomalyEvidenceandAnomalyCardcomponents key off theIsTwoWayflag onAnomalyListItem(computed when both pre/postpDraware 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 respectprefers-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 implementsIAnomalyDetector, reusesMatchWinEvidencefor 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 everyendand 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 ANDGetByNameAsyncboth fold case (column-levelUseCollation("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).PaperTradingWorkeropens flat-stakePaperBets on new directional anomalies (unique onAnomalyId; 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-tradingshows settled-only P&L. The ledger is FK-free (survives snapshot-retention pruning), likePlacedBets.