Adds a "By detector kind" breakdown to the anomaly outcome report + Insights page,
answering "which directional detector actually wins?". Only directional kinds
(SuspensionFlip, SteamMove) resolve to hit/miss, so the breakdown naturally shows
just those — mirrors the existing by-sport / by-severity bucketers and rendering.
- AnomalyOutcomeReport.ByKind + EvaluateAnomalyOutcomesUseCase.BuildKindBuckets
(keyed OutcomeBucketKeys.KindPrefix + enum name); threaded through the insights VM/service.
- Insights page: new section + BucketRenderKind.Kind label case (resolves the enum name
to the Anomaly.Kind.* resx); en/ru section heading.
- 2 tests: empty-report ByKind + directional-kind grouping.
Adds a forward-test (paper-trading) net-P&L tile to the Home dashboard, shown only
once the worker has opened or settled any paper bet. Reuses the existing
IPaperTradingService aggregation (settled-only net + open count) so there is one
definition of the figure, and makes the H4 forward-test result visible from the
landing page instead of only its own route.
- DashboardSummary gains Paper{OpenCount,SettledCount,NetProfit,RoiPercent}
(default-valued, so existing constructions are unaffected) + HasPaperTrades;
DashboardSummaryService injects IPaperTradingService; Home tile + en/ru resx.
Adds a detector-kind chip row to the anomaly feed (SuspensionFlip / SteamMove /
SuspensionFreeze / OverroundCompression), multi-select like the sport filter — so
with four detectors live you can slice the feed to a single signal type. The kind
set lives on AnomalyFilter and filters in-memory alongside severity/sport, persisted
via AnomalyBrowsingState like the other filters.
- AnomalyFilter.Kinds + AnomalyBrowsingService in-memory Where clause; feed chip
row + ToggleKind/KindLabel; en/ru resx (Anomaly.Filter.Kind).
- 2 tests: kind-filtered subset + no-filter returns all kinds.
New /anomalies/compare page runs every saved strategy preset over the same
window and ranks them by ROI — bets, W–L, hit-rate, net, and max drawdown side
by side, with the best ROI flagged. Auto-runs on load with an optional date-range refine.
- CompareStrategiesUseCase fans RunBacktestUseCase over saved presets (re-loads the
anomaly set per preset — fine for the handful a user keeps; stays bug-for-bug
identical to a single backtest run).
- StrategyComparisonService.BuildVm (pure) computes per-row hit-rate + a single
best-by-ROI flag; nav entry + en/ru resx.
- 6 tests: use-case fan-out + BuildVm best/tie/no-bets/hit-rate.
Surfaces the PaperTradingWorker's config on the Settings page — Enabled toggle,
min-score, flat-stake, and poll interval — so forward-testing can be switched on
and tuned from the UI instead of hand-editing committed appsettings.json.
Uses the established UI-mirror-options pattern (PaperTradingSettingsForm bound to
the "PaperTrading" section, same as the UI WorkerOptions mirror) and writes through
the existing ISettingsWriter to appsettings.Local.json. Notifications is deliberately
NOT surfaced: its section holds the Telegram secret and the section-replace writer
would clobber the token — that section stays Local.json-only by design.
- PaperTradingSettingsForm (no secrets) + DI binding; Settings section + en/ru resx.
Adds the read-only paper-trading page (/paper-trading): settled-only P&L KPIs
(net profit, ROI, hit rate, open count) plus a per-bet ledger table, with a
Forward-test nav entry under Analysis. PaperTradingService batches the
event-title join (no N+1) and folds settled bets into the summary.
Also hardens PaperTradingWorker (review finding): settle now runs in its own
catch so a transient settle failure can't advance the since-marker past an
open window — the window replays until its opens succeed.
- IPaperTradingService / PaperTradingService / PaperTradingVm + PaperBetRowVm.
- en/ru resx (full parity), service registration, nav entry.
- 2 service tests: empty ledger + settled-only aggregation incl. title fallback.
Adds a background forward-test engine that records flat-stake "paper" bets
for directional anomalies as they fire and settles them when results arrive,
measuring the detector's live, out-of-sample edge — the antidote to backtest
overfitting. The results UI is a follow-up.
- Domain: PaperBet entity (Rate>1 / Stake>0 invariants, Open factory,
SettleAgainst — Won pays stake x rate, else Lost) + AnomalyEvidenceSide.RateFor.
- Application: OpenPaperBetsUseCase (directional + score gate, dedups by
AnomalyId, picks the post-flip favourite and its locked-in rate) and
SettlePaperBetsUseCase (Won when pick == winner else Lost; ungraded events
stay open; batched result lookup).
- Infrastructure: PaperBetEntity + config (TEXT decimals, unique AnomalyId index,
Outcome index), repository, mapping, additive AddPaperBets migration, and
PaperTradingWorker (config-gated, baseline since-marker, open+settle per cycle).
- Config: PaperTradingOptions / appsettings PaperTrading (Enabled:false default).
- 25 tests: domain settlement, both use cases, and a real-SQLite round-trip
incl. the unique-AnomalyId double-open backstop.
Persist named backtest-strategy presets so a staking config (bankroll,
min-score, stake rule, flat/percent/Kelly params) can be saved, listed,
loaded back into the form, and deleted. The per-run date range is not
part of a preset.
- Domain: SavedStrategy record (name trimmed + bounded to 80 chars,
Create() factory) wrapping the pure BacktestStrategy.
- Persistence: SavedStrategyEntity + config (TEXT decimals, unique
case-insensitive NOCASE index on Name), repository, mapping, and a
hand-trimmed AddSavedStrategies migration (additive — only the new
table). Case-insensitive names mean save-by-name overwrites instead of
creating near-duplicates.
- Application: SaveStrategyUseCase (upsert by name, keeps Id+CreatedAt) +
DeleteStrategyUseCase.
- UI: presets panel on the Backtest page (load/save/delete) + service
methods; fraction<->percent round-trip; en/ru resx.
- Fix: pin Sports.Code as ValueGeneratedNever — it is the bookmaker's
natural sport id, not an autoincrement surrogate. Corrects long-standing
model-snapshot drift; the snapshot is regenerated to match the DB.
- 25 tests across all four layers: domain validation, real-SQLite
round-trip incl. case-insensitive lookup/uniqueness, the upsert use
case, and the service percent mapping.
- Add a /health ops page: snapshot freshness (last-capture-at, colour-coded), 24h vs
total snapshots + anomalies, events tracked, sports covered, and the four worker
on/off states. Nav entry under System; localized en+ru.
- New IPipelineHealthService + ISnapshotRepository.GetLatestCapturedAtAsync (max
CapturedAt via indexed ORDER BY/LIMIT 1), with a real-SQLite round-trip test.
Review follow-ups:
- (HIGH) Add real-SQLite round-trip tests for the new query methods so the load-bearing
lexical O-format date ordering is verified, not just mocked: Anomaly
ListByDateRange/CountSince, Snapshot CountSince/ListByEvents grouping, Event Query/GetMany.
- (MED) DetectAnomaliesUseCase: one SaveChanges per event instead of per anomaly.
- (LOW) Route PlacedBetRepository + ExcelExporter date bounds through SqliteDateText.
- (LOW) Backtest: reject a one-sided date range (was silently ignored).
- (LOW) Refresh stale comments after the detector fan-out.
Review follow-up (HIGH): the three detectors fed the same evaluator/backtest, but
SuspensionFreeze is non-directional (favourite unchanged) — grading it as "favourite
won" polluted the hit-rate with the base favourite-win rate, and its high frozen-ness
score always cleared the backtest threshold.
- Add AnomalyKind.IsDirectional() (flip + steam = true, freeze = false).
- AnomalyOutcomeEvaluator returns Unresolved for non-directional kinds (favourites
still surfaced for display) so they don't distort calibration.
- RunBacktestUseCase skips non-directional anomalies when building candidates.
- Tests for the classification, the evaluator path, and the backtest skip.
Review follow-ups: advance the dispatcher's "since" marker after each delivered
alert (not once per batch) so a future throwing sink can't re-deliver already-sent
alerts; give the Telegram HttpClient a 15s timeout so a hung connection can't stall
the dispatch loop.
- Add SuspensionFreezeDetector via the IAnomalyDetector seam: a suspension gap with
the favourite unchanged and a negligible (< threshold) price move — the mirror of
the flip. Score = how completely the line froze. Reuses MatchWinEvidence so UI +
evaluator handle it unchanged. 6 tests.
- Add AnomalyKind.SuspensionFreeze + localized card/detail label, SuspensionFreezeThreshold
option, and fan it into DetectAnomaliesUseCase.
- INotificationSink + AnomalyNotification (Application) and a testable
GetPendingAnomalyNotificationsUseCase (date+score filter, event-title join,
oldest-first). 4 use-case tests.
- TelegramNotificationSink posts to the Bot API via HttpClient (no SDK dependency);
no-ops with a warning when unconfigured and never logs the token.
- AnomalyNotificationDispatcher BackgroundService: startup-baselined marker advanced
past the newest sent (gap- and dup-free); idles until Notifications:Enabled.
- Wire options + named client + sink + dispatcher in InfrastructureModule. Add a
secret-free Notifications section + steam-move tuning to appsettings.json
(bot token + chat id go in appsettings.Local.json only).
- Reject a non-absolute / non-http(s) BaseUrl and an implausible (not 5- or
6-field) cron expression before the section is written to disk, mirroring the
existing storage-path validation (snackbar + early return).
- Add a hint to the BaseUrl field. Cron check is a lightweight UI guard; the
worker still does the authoritative Cronos parse at startup.
- RunBacktestUseCase gains an ExecuteAsync(strategy, DateRange?, ct) overload that
pushes the date filter to SQL via IAnomalyRepository.ListByDateRangeAsync; the
existing no-range overload is preserved. +1 use-case test.
- BacktestForm carries optional From/To (Moscow dates) with From<=To validation and
a ToDateRange() helper; BacktestService threads it through. Backtest page gains two
clearable date pickers (empty = all anomalies).
- Localization (en+ru) for the backtest date fields and the settings-validation keys
(shared resx).
- MyBets: add a "Find event" MudAutocomplete over upcoming events (loaded once,
filtered client-side) that fills the Event ID; the manual ID field stays as a
fallback. Backed by IBetJournalService.GetUpcomingEventOptionsAsync.
- Add a "Log bet" CTA on the anomaly detail page that deep-links to
/my-bets?eventId=<code>; the journal prefills the Event ID from the query.
- Render the new SteamMove anomaly kind with a localized label in the card and
detail KindLabel switches (was falling through to the raw enum name).
- Localization (en+ru) for all new strings.
- Introduce IAnomalyDetector; the existing flip detector implements it.
- Extract MatchWinEvidence so every detector writes the identical pre/post
evidence shape — the UI parser and outcome evaluator handle new kinds with no
branching (steam moves get hit-rate calibrated for free).
- Add SteamMoveDetector: flags a rapid one-directional implied-probability rise
over a short CONTINUOUS window (no suspension gap inside it), so it never
double-flags the same interval as the suspension-flip detector.
- DetectAnomaliesUseCase fans out over both detectors; dedup keys on EventId+Kind
so flip and steam signals persist independently. Add AnomalyKind.SteamMove +
SteamMove window/threshold options. 8 detector tests.
- Add an /export hub page that hosts the existing (date-range, not event-specific)
ExportDialog, so export is no longer reachable only by opening an event detail.
- Add an Export entry under the System nav section.
- Add KellyCalculator (Domain/Betting): pure fractional-Kelly stake from win
probability, decimal odds, bankroll, and fraction (default quarter-Kelly).
Returns 0 on non-positive edge; truncates the suggestion down to 2 decimals
so it never exceeds the computed figure. 19 unit tests.
- MyBets: add a page-local stake helper (bankroll + win-probability inputs) that
suggests a quarter-Kelly stake from the form's rate, with an Apply button and a
no-edge message. Win probability is user-supplied, not derived from a signal.
- Localization (en+ru) for the Kelly helper and the export-hub keys (shared resx).
- Add IDashboardSummaryService/DashboardSummaryService: real event/snapshot/
anomaly counts, top-5 signals, and per-stage pipeline health from worker state.
- Home: replace hard-coded zeros + placeholder feed with live data, a clickable
signal feed, and a first-run empty state with a Settings CTA.
- MainLayout: add an appbar capture-status pill (Capturing/Paused) bound to the
poller toggles, refreshed via IOptionsMonitor.OnChange.
- MyBets: success snackbar on bet submit. Backtest: surface a Cancel button
while a run is in flight.
- Add en/ru localization for all new strings; register IOptionsMonitor<WorkerOptions>
in the bUnit test context for layout-rendering tests.
- EventListShell: detach the Elapsed handler before disposing the refresh timer
(both StartTimer and Dispose) to stop a leaked subscription firing on a
torn-down component; log the two previously-silent catches.
- Insights: log the previously-silent report-load catch.
- EventOddsParser: narrow catch(Exception) to catch(ArgumentException) so only
the OddsRate/OddsValue/Bet guard-clause throws are swallowed.
- AnomalyEvidenceData: make the JSON DTOs init-only per the immutability convention.
- Settings: remove a dead DialogParameters block.
- Add IEventRepository/IResultRepository.GetManyAsync to kill N+1 lookups at
6 sites (backtest, outcome eval, both bet-journal paths, anomaly browsing,
results selection); guarded by a Received(1).GetManyAsync test.
- Add EventRepository.QueryAsync to push date+sport filtering to SQL (was
load-whole-range-then-filter); search/sort stay in-memory for Cyrillic order.
- Add AnomalyRepository.CountSinceAsync (unread badge) + ListByDateRangeAsync
(feed date filter); add Event/Snapshot count methods for the dashboard.
- Add composite indexes IX_Snapshots_EventCode_CapturedAt and
_EventCode_Source_CapturedAt via a new migration + model snapshot.
- Introduce SqliteDateText as the single source of the O-format date encoding
shared by Mapping (read/write) and the repositories' range predicates.
- Fix LiveOddsPoller cadence drift (budget sleep against cycle time); make
DetectAnomalies dedup O(1) per event; add Event.Title to dedup the title join.
Tests adapted to the batched GetManyAsync via a TestFixtures bridge.
Adds an interactive backtester that replays the SuspensionFlip detector over
all flagged anomalies under a chosen score threshold and staking rule
(flat / percent-of-bankroll / Kelly), and reports the headline numbers a
user needs to judge edge: final bankroll, ROI, max drawdown (peak-to-trough),
win/loss streaks, plus per-bet equity curve.
Domain (pure):
- StakeRule enum + BacktestStrategy params (with validation).
- BacktestSimulator: deterministic function taking strategy + chronological
candidates → BacktestResult. Implements Kelly with post-flip implied prob
as p (skipping negative-edge bets), peak-to-trough drawdown tracking, and
win/loss streak rollups. Mirrors AnomalyOutcomeEvaluator on the 2-way Draw
guard so tennis data inconsistencies are refused rather than miss-counted.
- Skipped counter split into SkippedByThreshold / SkippedByDataQuality /
SkippedByBankroll so the UI can distinguish "strategy choice" from
"data-quality" from "bankroll empty".
Application:
- RunBacktestUseCase: loads anomalies + events + results, parses evidence,
builds candidates, hands event titles into the simulator so the UI does
zero repository round-trips of its own.
UI:
- Pages/Anomalies/Backtest.razor: hero, strategy form (MudBlazor — conditional
sub-field per staking rule), 4-card KPI strip (final bankroll / net profit
/ ROI / max drawdown), counters row, inline-SVG equity curve, trade-trace
table with per-bet outcome pills and link-back to the source anomaly.
- Nav entry under Analysis. RU + EN i18n.
Tests: +20 (16 simulator math — flat / percent compounding / Kelly +/-
edge / quarter-Kelly / bankroll-exceeded / out-of-order chronology / Draw
favourite / multi-window drawdown / event-title pass-through + 4 use-case
join). All 399 tests pass.
Money rounding switched to MidpointRounding.AwayFromZero throughout the
simulator output for accounting convention.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a manual bet-tracking journal that turns the analyzer into an actual
bet tracker. Users record wagers; the journal auto-grades them when event
results land and computes per-bet Closing-Line-Value against the latest
pre-match snapshot — the strongest long-run indicator of betting skill.
Domain:
- PlacedBet entity (reuses Bet vocabulary for Scope/Type/Side/Value/Rate)
with stake, placed-at, outcome, and notes. Derived GrossReturn / NetProfit.
- BetOutcome enum (Pending / Won / Lost / Void).
- BetOutcomeResolver: pure function grading any Match-scope bet against an
EventResult. Handles 1X2, draws, handicap (incl. push), and totals.
Period-scope bets stay manual since EventResult only carries full-time.
Application:
- IPlacedBetRepository abstraction.
- ClosingLineValueCalculator: pure CLV math (implied-probability delta) +
snapshot-matching predicate by Scope/Type/Side/Value.
- BetJournalReport + BetJournalStats records.
- Four use cases: Record / ResolvePending / BuildReport / Delete.
- New ISnapshotRepository.GetLatestPreMatchAsync pushes the closing-line
pick into a single SQLite query rather than materialising the 30-day
window in memory per event.
- ROI turnover excludes Void stakes — pushes are not real turnover and
including them would dilute the user's edge.
Infrastructure:
- PlacedBetEntity / Configuration / Repository / Mapping helpers.
- 20260516 migration adding the PlacedBets table with EventCode and
Outcome indices. Intentionally NO foreign key to Events — the journal
is user data and must survive snapshot-retention pruning. Covered by an
explicit round-trip test.
UI:
- Pages/MyBets/Journal.razor: hero header, 4-card KPI strip (ROI / strike
rate / avg CLV / net profit, tinted by tone), inline add-bet form with
the same invariants as the Bet record, drill-down table with per-row
outcome pills, CLV percentage-points column, P&L, notes underline, and
inline-confirm delete. RU + EN i18n.
- Nav entry under Analysis.
Tests: +55 across Domain / Application / Infrastructure (resolver math
including handicap push and total push boundaries, PlacedBet invariants
and derived properties, CLV math + null-handling, four use cases under
NSubstitute, EF round-trip including survives-event-deletion). All 379
tests pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a calibration dashboard that joins persisted SuspensionFlip anomalies
with EventResult rows and reports whether the post-flip favourite actually
won — the single metric that says whether the detector is doing its job.
Domain:
- AnomalyEvidenceData + AnomalyEvidenceParser to read the JSON written by
AnomalyDetector without re-implementing the schema.
- AnomalyOutcomeEvaluator: pure function returning Hit / Miss / Unresolved.
Tennis-style two-way markets with a Draw winner are downgraded to
Unresolved rather than silently counted as Miss.
- AnomalySeverityThresholds: shared Low/Medium/High constants so the UI
badge and the report buckets cannot drift.
Application:
- EvaluateAnomalyOutcomesUseCase orchestrates the join + aggregation.
- AnomalyOutcomeReport carries totals, hit rate, three breakdowns
(severity / sport / score bins) and a per-event title lookup so the UI
needs no second pass over IEventRepository.
- Score bins extend below 0.30 automatically when the operator lowers the
detector threshold so the histogram total always equals ResolvedCount.
UI:
- Insights page at /anomalies/insights — hero header, 4-card KPI strip
(hit rate tinted by tone), three breakdown grids with bar visualisation,
drill-down tables for resolved and unresolved anomalies. Honors
prefers-reduced-motion. RU + EN localisation.
- Nav entry under Analysis section + chip button on the Anomaly Feed.
Tests: +42 across Domain + Application (evaluator boundary cases including
tennis two-way and Draw guard, score-bin edges, dynamic floor when
threshold is lowered, event-title pass-through). All 324 tests pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previously LiveEventsParser returned 0 events from /su/live because two
real differences between the live page and the pre-match listing weren't
handled:
1. Live rows omit data-event-path entirely. They expose only
data-event-treeId, and the bookmaker routes live events under
/su/live/<treeId> rather than /su/betting/<...>.
2. The closest data-sport-treeId ancestor on the live page is a
category-tree wrapper (26418=Football-live, 45356=Basketball-live, …)
instead of the canonical breadcrumb sport ID (11/6/22723/43658) the
rest of the app uses. The pre-match listing carries the canonical
ID directly.
Changes:
* EventListingParserBase.ParseRow: data-event-path becomes optional. For
live rows we synthesize EventPath = "live/<treeId>" from
data-event-treeId (validated as digits-only). Pre-match validation is
unchanged.
* New ExtractSportCodeFromLive walks ancestors looking for a sport-tree
ID and maps it through a small live-id → canonical-id table covering
the four scoped sports. Out-of-scope sports (cybersport, volleyball,
table tennis) are intentionally left unmapped — they keep their raw
category ID and the UI renders them via SportLabels as "Sport <N>".
* MarathonbetScraper.ResolveEventDetailPath: dispatches between
/su/live/<treeId> and /su/betting/<...> based on the EventPath prefix.
Removes the duplicated path-building between ScrapeEventOddsAsync and
ScrapeEventResultAsync.
* New regression tests covering all three behaviors against a real
/su/live capture (16 events, 5 sport categories).
Also: rewrites the stale "Disabled until Phase 8" hint copy on the
Settings.Workers.ResultsPollerEnabled flag — Phase 8 shipped, so the
results poller is safe to enable.
Five MEDIUM-tier security findings from the review. None were exploitable in
the single-user desktop threat model, but each is hardening for future hosts
(server / multi-user) and for the case of an upstream compromise.
* EventListingParserBase: scraped data-event-path values are now run through
IsSafeRelativePath before being concatenated into request URLs. Rejects
scheme://host/* patterns, leading slash/backslash (network-path traversal),
".." traversal, control characters (CRLF log forging), and path lengths
greater than 512.
* ScrapingModule.ResolveBaseAddress: configured Scraping:BaseUrl is now
validated through an https-only allow-list (marathonbet.by + subdomains).
Falls back to the default base URL when the value is missing, malformed,
off-host, or carries userinfo / query / fragment. Settings UI may write
arbitrary strings to this key — a typo or worse could otherwise re-point
every subsequent request at an unrelated host.
* JsonSettingsWriter.WriteRootAsync: temp-file write now FlushAsync +
Flush(flushToDisk: true) before the rename so a crash mid-write doesn't
leave a 0-byte appsettings.Local.json. Promote the rename from File.Move
(overwrite=true, not atomic on NTFS for cross-volume cases) to
File.Replace (atomic on NTFS, keeps a .bak copy of the previous file).
Falls back to File.Move when the destination doesn't exist yet.
* StoragePathValidator + Settings.razor wiring: DatabasePath and
ExportDirectory paths from the Settings page are validated before persist.
Rejects empty values, ".." traversal, control characters, and any path
that resolves outside AppContext.BaseDirectory. Adds 4 new RU+EN keys for
the validation messages.
* Logged scraper paths: covered transitively by the EventPath validation
above (the upstream of every logged {Path} value), so no separate
sanitization needed.
Six MEDIUM-tier review items:
* Mapping.cs — DateTimeOffset.Parse now passes CultureInfo.InvariantCulture
+ DateTimeStyles.RoundtripKind so a non-en-US thread culture cannot
corrupt round-tripped ScheduledAt / CapturedAt / DetectedAt / CompletedAt.
Also replaces the magic 0/1 BetScope discriminator with named constants.
* Delete dead Placeholder.cs files in Marathon.Application and
Marathon.Infrastructure — they were stubs from Phase 1 to satisfy
"non-empty project" and have been dead since Phase 2/3.
* EventBrowsingService — drop the bespoke ScopeEqualityComparer; BetScope
is a record hierarchy, .GroupBy uses value equality natively.
* UserAgentRotatorHandler — counter promoted to private static int with
Interlocked.Increment so rotation is round-robin across the process.
HttpClientFactory builds the handler Transient, so the previous instance
field reset to zero on every new client and broke rotation.
* EventOddsParser — added a parallel "selection-key → IElement" index
alongside the existing price index. Handicap extraction (6 call sites
per event detail page) used to do a fresh document.QuerySelector("span[
data-selection-key='...']") for every key — full-document CSS traversal.
Now it's a dictionary lookup, with the pair-emit logic factored into a
shared TryEmitHandicapPair helper.
* New Marathon.Domain.ValueObjects.MoscowTime with Offset, Now, and
EndOfMoscowDay(DateOnly) — replaces ~15 inline TimeSpan.FromHours(3)
literals across Domain/Application/Infrastructure/UI.
* New Marathon.UI.Services.SportLabels.Resolve(IStringLocalizer, int) —
replaces 6 near-identical SportLabel switch bodies in EventListShell,
Events/Detail, Anomalies/AnomalyFeed, Results/ResultsList,
Results/ResultsLoader, and AnomalyCard. Single source of truth for the
6/11/22723/43658 sport-code mapping. Pages keep a one-liner wrapper so
the call sites stay terse.
* Filter changes from the parent (page-state singleton swap) now trigger
LoadAsync via OnParametersSetAsync once the component has rendered.
Previously the reload happened only on first render, so navigating back
to a list page with a different sport/date filter showed the prior data.
* Refresh-timer Elapsed handler is hoisted to a named async-void method
with try/catch around InvokeAsync. An unhandled exception on the timer
thread used to crash WebView2; now it's logged and swallowed.
* Overlapping ticks are skipped via a _loading short-circuit so a slow
Loader doesn't stack up cancelled-superseded loads.
* EventBrowsingService.BuildListAsync issued one snapshot query per event
on every page render — N+1 against SQLite, with each round-trip hauling
the full bet graph via Include(Bets). Replaced with a single
ISnapshotRepository.ListByEventsAsync batch.
* ListKnownSportCodesAsync / ListKnownCountryCodesAsync used to materialise
every Event row to compute Distinct() in memory. Pushed to EF projection
via two new IEventRepository methods (ListDistinctSportCodesAsync,
ListDistinctCountryCodesAsync) implemented as
.Select(...).Distinct().ToListAsync — single SELECT DISTINCT.
The Pull*UseCase implementations issued one HTTP request at a time despite
Scraping:MaxConcurrentRequests=4. With 30–80 live events and ~1s per
fetch, a 5–10s live cadence target was unreachable; cycles overflowed
the configured interval.
* New Marathon.Application.Configuration.ScrapingThrottle bound from the
shared Scraping:* section. Exposes only MaxConcurrentRequests so the
Application layer doesn't pull in the Infrastructure-side ScrapingOptions.
* PullLiveOddsUseCase + PullUpcomingEventsUseCase split into two phases:
- Phase 1 — Parallel.ForEachAsync over the event list with
MaxDegreeOfParallelism = throttle.MaxConcurrentRequests. The scraper's
Polly rate limiter still throttles to RequestsPerSecond underneath
this fan-out, so spikes are smoothed before they hit the bookmaker.
- Phase 2 — sequential foreach over the (Event, Snapshot) tuples
captured in Phase 1, doing event upsert + snapshot insert. EF Core
DbContext is not thread-safe so all DB writes stay on a single thread.
* InfrastructureModule binds ScrapingThrottle alongside AnomalyOptions.
* Failed snapshot scrapes in Phase 1 mean the event row is also NOT
persisted in Phase 2 — previously we'd persist the row even when the
snapshot scrape failed, leaving an orphan event with no odds. Updated
the regression test accordingly.
* Test fixture exposes TestFixtures.Throttle(maxConcurrentRequests=1) for
deterministic sequential test runs.
* One existing NSubstitute setup that chained Arg.Is<>() across two
configurations was rewritten to use a single Arg.Any<>() with inline
branching — chained matchers were leaking and returning wrong results.
DetectAnomaliesUseCase was issuing one ISnapshotRepository.ListByEventAsync
call per event each cycle, with each call rehydrating that event's bets via
Include(s => s.Bets) — O(N) SQLite round-trips and N Include payloads on
every detection cycle.
* Add ISnapshotRepository.ListByEventsAsync(IReadOnlyCollection<EventId>, …)
returning a per-event dictionary; events with no snapshots in range get
Array.Empty<OddsSnapshot>() so the caller doesn't need a presence check.
* Implementation uses a single .Where(s => ids.Contains(s.EventCode))
query and groups in memory.
* DetectAnomaliesUseCase loads the whole batch once before the foreach,
then ProcessEventAsync receives the per-event slice as a parameter.
* Tests updated to stub the new method; per-event-failure test now
exercises an AddAsync throw rather than a snapshot-load throw, since
individual snapshot loads no longer fail per-event.
The component had a Dispose() method that detached ThemeState/LocaleState
event handlers, but it was never invoked because the @implements directive
was missing. Each navigation through the layout leaked two subscriptions
on the singleton state objects.
Other state subscribers (NavBody.razor, AnomalyFeed.razor) already declare
the directive correctly.
Snapshots are append-only and identified by the composite (EventId, CapturedAt),
not a surrogate Guid. The previous implementation inherited
IRepository<Guid, OddsSnapshot> and faked the lookup with (long)key.GetHashCode()
on a long auto-increment PK — collision-prone and non-portable. Nobody called
GetAsync(Guid) / DeleteAsync(Guid) anyway.
* ISnapshotRepository no longer extends IRepository<Guid, OddsSnapshot>; it
exposes only the methods snapshots actually have: ListAsync, ListByEventAsync,
AddAsync, SaveChangesAsync.
* SnapshotRepository drops the broken Get/Update/Delete methods.
Cross-cutting polish that surfaced while Phase 8 was being implemented:
* Invariant-culture formatting on every decimal ToString("0.00" / "0.##")
(OddsCell, OddsTimeline, SeverityBadge, AnomalyEvidence,
Pages/Events/Detail) — previously the comma/dot decimal separator
switched with the locale and broke tabular alignment + tests.
* LocaleState constructor no longer mutates process-wide ambient culture;
apply only happens through Set(...). Stops parallel bUnit test runs
leaking ru-RU into each other's threads.
* MainLayout: drawer-open state now offsets main content / footer / appbar
by the drawer width (248px) so the sidebar no longer overlaps content.
Mobile breakpoint (≤720px) keeps the original full-width layout.
* wwwroot/index.html (Marathon.UI RCL): switched from Plotly CDN to the
bundled "_content/Plotly.Blazor/plotly-2.35.3.min.js" — works offline
and matches the Plotly.Blazor 5.4.1 version pin.
* Marathon.Hosts.WpfBlazor/wwwroot/index.html: host-level page that
BlazorWebView's HostPage attribute resolves to. Same Plotly bundle,
no autostart="false" (BlazorWebView auto-starts).
* Pages/Results/ResultsList.razor — completed-events list with date range,
sport/winner filter, search, footer count.
* Pages/Results/ResultsLoader.razor — driver page with two modes (load all
in range / load selected events), live progress reporting via
IProgress<PullResultsProgress>, summary line, cancellable.
* Replaces the Phase 5 Pages/Results.razor placeholder.
Service layer:
* IResultsBrowsingService + ResultsBrowsingService (Scoped, mirrors the
Event/Anomaly browsing-service pattern). Reads IResultRepository +
IEventRepository, projects to immutable view-model records.
* UiServicesExtensions: registers ResultsBrowsingService; also fixes an
unrelated localization resolver bug (drop ResourcesPath since
SharedResource lives in the Marathon.UI.Resources namespace already).
Localization:
* 41 new Results.* keys (RU+EN parity) covering both pages, filter chips,
loader modes, progress states, and footer copy.
Tests:
* ResultsListTests + ResultsLoaderTests — 22 new bUnit tests covering
filter narrowing, mode switching, progress aggregation, and empty
states.
* FakeResultsBrowsingService support type for tests.
* MarathonTestContext registers the fake; TestData adds factories for
EventResult/EventResultListItem.
Implements Phase 8 Amendment 1: marathonbet.by has no public results archive
endpoint, so results must be harvested per-event by re-fetching the event
detail page until eventJsonInfo.matchIsComplete=true.
Backend changes:
* IOddsScraper:
- ScrapeResultsAsync(DateRange) replaced with ScrapeEventResultAsync(Event)
returning a nullable EventResult — null when match still in progress.
- ScrapeEventOddsAsync now takes the full Event (so EventPath drives URL
construction) instead of bare EventId.
- New ScrapeLiveAsync() for the /su/live listing.
* Domain:
- Event gains EventPath (nullable string) — the data-event-path attribute
captured during scraping; required for reliable URL construction.
* Infrastructure:
- New migration 20260506000000_AddEventPath adds the column.
- EventEntity / EventConfiguration / Mapping / model-snapshot updated.
- MarathonbetScraper: new ScrapeLiveAsync + ScrapeEventResultAsync; URL
builder prefers EventPath, falls back to numeric ID for legacy rows.
- EventListingParserBase extracts data-event-path on every listing row.
* Application:
- PullResultsUseCase: branches on selection vs date-range, emits IProgress<
PullResultsProgress>, returns ResultLoadOutcome (Loaded / AlreadyLoaded /
NotYetComplete / Failed); idempotent (skips events whose result already
exists).
- PullLiveOddsUseCase now drives off the live listing (auto-discovers
events that go live without ever appearing in the upcoming list) and
backfills EventPath on legacy rows.
- PullUpcomingEventsUseCase wires EventPath on persisted events.
* Workers: UpcomingEventsPoller updates persistence path accordingly.
* Tests: 17 net-new tests across Application + Infrastructure + Domain;
all 293 still pass.
Three fixes surfaced when launching the WPF host for the first time:
1. App.xaml.cs — call MarathonDbContextInitializer.InitializeAsync()
between Host.Build() and Host.Start() so EF migrations + WAL pragma
are applied BEFORE BackgroundServices race to query the DB. Without
this, all pollers crashed on 'no such table: Events'.
2. wwwroot/index.html — added <script src='https://cdn.plot.ly/plotly-2.35.2.min.js'>
before blazor.webview.js. Phase 6 reviewer flagged this for Phase 9,
but charts are unrenderable without it; better to ship now.
3. Migrations/20260505000000_InitialCreate.cs — added [DbContext] and
[Migration('20260505000000_InitialCreate')] attributes. Phase 2's
hand-written migration was missing both, so EF saw 'no migrations to
apply' even on a fresh DB. With the attributes, the migration runs
on first launch and creates all tables (Events, Snapshots, Bets,
EventResults, Anomalies, Sports, Leagues).
Verified: clean DB → migration applied → all 7 tables created → pollers
run with empty results (no data yet — UpcomingEventsPoller fires every 6h
by default; first scrape will populate the DB).
Phase 7 reviewer (Sonnet, combined backend + frontend) flagged 3 🟡 warnings;
two real fixes here, one tracking:
W1 — DetectAnomaliesUseCase had an undocumented N+1: _anomalyRepo.ListAsync
was called inside ProcessEventAsync, once per event. Hoisted to ExecuteAsync
before the loop and threaded into ProcessEventAsync as a parameter. The
per-event slice happens in-memory now. O(N_events) DB round-trips → 1.
W2 — AnomalyDetector.ExtractMatchWinProbabilities had a dead expression
'(decimal?)null ?? 0m' that always evaluated to 0m. Simplified to
'drawBet is not null ? rawDraw / total : 0m'. The 0m is never surfaced
anyway (PDraw in the return uses the same null guard), so behaviour is
identical.
W3 — PLAN.md row updated with both Phase 7 commit hashes (a6ff368 backend
+ 12208a4 frontend) and review verdict.
Build 0/0, 276 tests still passing.
- AnomalyDetector (pure domain): detects odds-flip pattern from live snapshot
timelines using implied-probability vectors (p=1/rate, normalised), flip score
= max(|p_post−p_pre|), gated by both threshold AND favourite-changed test
- SuspensionInterval record: typed pair of (pre, post) OddsSnapshot bracketing a gap
- AnomalyOptions POCO (Application layer): bound to Anomaly:* config section with
four fields (SuspensionGapSeconds=60, OddsFlipThreshold=0.30, MinSnapshotCount=3,
DetectionIntervalSeconds=60)
- DetectAnomaliesUseCase: iterates all events, loads last-24h live snapshots, runs
detector, persists new anomalies with 1-minute dedup window
- AnomalyDetectionPoller: BackgroundService polling every DetectionIntervalSeconds,
gated by WorkerOptions.AnomalyDetectionEnabled (default true)
- DI wiring: DetectAnomaliesUseCase registered Scoped in ApplicationModule;
AnomalyOptions bound + AnomalyDetectionPoller hosted in InfrastructureModule
- WorkerOptions.AnomalyDetectionEnabled added; appsettings.json updated
- 13 domain tests + 4 application tests; total 245/245 passing (no regression)
Phase 4 reviewer (Sonnet) flagged two 🟡 warnings; both addressed:
1. PullLiveOddsUseCase: removed dead 'liveEvents' assignment that called
ScrapeUpcomingAsync but never read the result. Replaced misleading
comment block with a single TODO(phase-6/8) note pointing to the
ListLiveAsync(cutoff) follow-up.
2. Marathon.UI.Services.WorkerOptions.UpcomingScheduleCron: changed default
from '0 */5 * * * *' (every 5 min) to '0 0 */6 * * *' (every 6 hours)
to match Marathon.Infrastructure.Configuration.WorkerOptions and the
appsettings.json default.
PLAN.md: Phase 4 row updated (review status, commit hash); top-level
checkbox in Phases list ticked.
Build 0/0, all 202 tests still passing.
Combined-batch reviewer flagged three real blockers + two test-infra
issues across the parallel P2/P3/P5 batch. All resolved:
PHASE 3 — DateTimeOffset UTC-kind constructor (3 sites)
EventListingParserBase.cs:39, EventOddsParser.cs:72, ResultsParser.cs:104
Replaced `new DateTimeOffset(DateTimeOffset.UtcNow.UtcDateTime, MoscowOffset)`
(throws ArgumentException because UtcDateTime has Kind=Utc) with
`DateTimeOffset.UtcNow.ToOffset(MoscowOffset)`.
PHASE 2 — EF string.Compare not translatable (3 sites)
EventRepository.cs:34, SnapshotRepository.cs:46, ExcelExporter.cs:35
Replaced `string.Compare(col, str, StringComparison.Ordinal)` with
`col.CompareTo(str)` so EF Core's SQLite provider can translate the
expression. Semantics unchanged (SQLite default collation = BINARY = ordinal).
PHASE 3 — ServerTimeProvider regex misses JSON-quoted key
Regex `serverTime\s*:\s*"..."` only matched bare-key form. Updated to
`"?serverTime"?\s*:\s*"..."` so the JSON-quoted form (the actual
marathonbet.by production format) is matched.
PHASE 3 — fixture: orphan <td> elements stripped by HTML5 parser
tests/.../Fixtures/marathonbet/event-football-sample.html — wrapped
the <td> blocks in a proper <table><tbody><tr> hierarchy so AngleSharp
preserves them and `td.Closest("td")` succeeds in the parser.
PHASE 2 — InMemoryDbFixture shared state across parallel tests
All fixture instances used `Data Source=marathon_tests` causing xUnit's
parallel-within-class runs to contaminate each other's data. Each fixture
now uses a Guid-suffixed unique data source name.
PLAN.md — P2/P3/P5 rows updated to ✅ Done with batch commit reference.
Test status:
Domain.Tests: 96/96 ✅
Application.Tests: 1/1 ✅
Infrastructure.Tests: 77/77 ✅
UI.Tests: 11/11 ✅
TOTAL: 185/185 ✅
Build: 0 warnings, 0 errors.
Deferred to later phases (per reviewer 🟡 / 🔵 notes):
- SnapshotRepository.GetAsync(Guid) uses lossy GetHashCode workaround;
Phase 4 to fix or remove from interface.
- Excel Sport name column writes string.Empty (need lookup join in Phase 6).
- PeriodScopeMapper football n>2 falls through to "Quarter" token;
guarded by MaxPeriods today, but defensive cleanup at Phase 9.
- Settings.razor duplicate m-rise-5 class on Localization section.
Three minimal fixes to make Marathon.sln build with 0/0:
1. Marathon.Infrastructure.csproj — add InternalsVisibleTo for
Marathon.Infrastructure.Tests so test code can reference internal
repository and exporter classes (Phase 2 issue blocking Phase 3 tests).
2. EventOddsParserTests.cs — add 'using Marathon.Domain.ValueObjects' so
MatchScope/PeriodScope resolve.
3. RoundTripTests.cs — add 'using Microsoft.EntityFrameworkCore' so the
ExecuteSqlRawAsync extension method on DatabaseFacade resolves.
Phase 5's anticipated LocalizationOptions / Serilog issues were already
resolved by its agent before being killed — no changes needed there.
Build status: 0 warnings, 0 errors.
Test status: Domain 96/96, UI 11/11, Infrastructure 42/77 (35 failing —
parser fixture issues + a real DateTimeOffset bug; reviewer will assess).