21 Commits

Author SHA1 Message Date
alexei.dolgolyov 42e62c1ed2 fix(journal): stop bet edits silently un-settling graded bets
A stake/notes-only edit re-graded from Pending, which un-settled a won/lost
bet once its result row had been pruned by snapshot retention (the journal is
FK-free and outlives results). Now only re-grade when the selection or event
actually changed, or the bet is still Pending — mirroring RecordPlacedBet.
2026-05-29 13:57:23 +03:00
alexei.dolgolyov 08486667c3 fix(export): neutralize CSV/DDE formula injection in exported text
Exported journal notes and scraped event titles could begin with a formula
trigger (= + - @, tab, CR) that Excel/LibreOffice execute on open.
Csv.NeutralizeFormula apostrophe-prefixes such cells so they render as text;
applied to user notes, raw event ids and scraped titles. Numeric/date cells
the exporter formats itself stay numeric for downstream analysis.
2026-05-29 13:57:18 +03:00
alexei.dolgolyov 88615a95e9 feat(export): CSV export for the bet journal + forward-test ledger
Adds CSV export alongside the Excel snapshot export: two buttons on the Export hub
write the bet journal and the paper-trading ledger to UTF-8 (BOM) .csv files in the
configured export directory and toast the path — mirroring the Excel use case's
write-and-return-path contract. CSV needs no third-party library, so it lives in the
Application layer behind a pure RFC-4180 formatter.

- Csv formatter (RFC 4180 escaping) + ExportToCsvUseCase (journal + ledger, batched
  title join, returns null when there's nothing to export); registered; Export hub
  buttons + en/ru resx.
- 11 tests: formatter escaping/Document + use-case empty-state + real-file write to a temp dir.
2026-05-29 13:44:35 +03:00
alexei.dolgolyov 1092e2a2c5 feat(journal): edit a placed bet
Adds an edit flow to the bet journal — an Edit button per row repurposes the inline
entry form (edit mode), saving via a new UpdatePlacedBetUseCase that preserves the
original placed-at and re-grades the outcome against the result (so a changed
selection/event re-settles). A cancel affordance + an editing banner make the mode
obvious; the submit button switches to "Save changes".

- UpdatePlacedBetUseCase (loads existing for PlacedAt, validates the event, re-grades)
  + IBetJournalService.UpdateAsync + service impl; Journal page edit-mode state +
  per-row Edit button; en/ru resx.
- 3 tests: unknown-bet, unknown-event, and preserve-PlacedAt + re-grade.
2026-05-29 13:38:38 +03:00
alexei.dolgolyov 67f2ae130c feat(insights): hit-rate breakdown by detector kind
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.
2026-05-29 11:52:25 +03:00
alexei.dolgolyov 6e12dd73c3 feat(backtest): strategy comparison (head-to-head)
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.
2026-05-29 11:32:01 +03:00
alexei.dolgolyov f622dadf95 feat(paper-trading): forward-test ledger engine
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.
2026-05-29 02:25:54 +03:00
alexei.dolgolyov 2a0ea7b3a6 feat(backtest): saved strategy presets (strategy editor v1)
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.
2026-05-29 02:13:16 +03:00
alexei.dolgolyov c9eee9f907 fix(anomaly): exclude non-directional kinds from grading and backtest
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.
2026-05-29 01:25:16 +03:00
alexei.dolgolyov 005d4e794a feat(notifications): config-gated Telegram anomaly alerts
- 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).
2026-05-29 00:59:57 +03:00
alexei.dolgolyov e5cd2ab30c feat(backtest): optional date-range window
- 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).
2026-05-29 00:50:43 +03:00
alexei.dolgolyov f294255f10 perf: batch repository reads, index snapshots, centralize date encoding
- 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.
2026-05-28 22:34:08 +03:00
alexei.dolgolyov 0d52b7beff feat(backtest): historical strategy backtester
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>
2026-05-16 18:34:42 +03:00
alexei.dolgolyov 1ad896b07e feat(my-bets): personal bet journal with CLV tracking
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>
2026-05-16 17:45:42 +03:00
alexei.dolgolyov 292223174c feat(insights): anomaly outcome validator — hit-rate calibration page
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>
2026-05-16 13:53:31 +03:00
alexei.dolgolyov 286b55986b perf(scraping): parallel HTTP fan-out, sequential DB persist (HIGH)
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.
2026-05-09 15:27:06 +03:00
alexei.dolgolyov 66ae038243 perf(detect-anomalies): batch snapshot loads into a single query (HIGH)
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.
2026-05-09 15:17:49 +03:00
alexei.dolgolyov 9c5d3df1f2 feat(phase-8-backend): per-event results harvesting + EventPath plumbing
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.
2026-05-09 15:10:27 +03:00
alexei.dolgolyov a6ff368015 feat(phase-7-backend): implement anomaly detection — SuspensionFlip detector, use case, poller, and tests
- 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)
2026-05-05 13:15:50 +03:00
alexei.dolgolyov 2acbaa5b77 feat(phase-4): application layer + background workers — 202/202 tests green
Use cases (Marathon.Application/UseCases/):
- PullUpcomingEventsUseCase: scrape + persist new events + capture pre-match snapshots
- PullLiveOddsUseCase: refresh live snapshots for all stored events
- PullResultsUseCase: Phase 4 scaffold; delegates to ScrapeResultsAsync (Phase 3 no-op);
  Phase 8 will replace with watch-list polling
- ExportToExcelUseCase: resolves export dir from StorageOptions, delegates to IExcelExporter

ApplicationModule.AddMarathonApplication(IServiceCollection) — no IConfiguration needed.

Background workers (Marathon.Infrastructure/Workers/):
- UpcomingEventsPoller: Cronos 6-field cron schedule (default every 6 h)
- LiveOddsPoller: fixed interval (WorkerOptions.LivePollIntervalSeconds, default 30 s)
- ResultsWatchListPoller: scaffold, disabled by default (WorkerOptions.ResultsPollerEnabled=false)
All three: exception-swallowing, cancellation-aware, scoped DI via CreateAsyncScope().

InfrastructureModule.AddMarathonInfrastructure(IServiceCollection, IConfiguration):
- Composes AddMarathonPersistence + AddMarathonScraping + WorkerOptions + 3 hosted services

App.xaml.cs: replace reflection-based TryAddApplicationAndInfrastructure with direct
AddMarathonApplication() + AddMarathonInfrastructure(config) calls.

Resolved Phase 3 TODO: bind Sports:Basketball:QuarterMode from config in ScrapingModule.

appsettings.json: add Workers.LivePollIntervalSeconds, ResultsPollIntervalSeconds,
ResultsPollerEnabled; add Sports.Basketball.QuarterMode.

Settings.razor + WorkerOptions (UI) + SharedResource.*.resx: surface new Workers fields.

Tests: +14 Application use-case tests, +3 Infrastructure worker tests (185 → 202 total).
2026-05-05 12:28:15 +03:00
alexei.dolgolyov 61114ea31b feat: implement Phase 1 — solution skeleton and domain model
Creates the 9-project .NET 8 solution (5 src + 4 test) with Marathon.Domain
fully implemented: value objects (SportCode, EventId, OddsRate, OddsValue,
BetScope hierarchy), enums (Side, BetType, OddsSource, AnomalyKind), and
entities (Sport, Country, League, Event, Bet, OddsSnapshot, EventResult,
Anomaly) with all invariants enforced in constructors. 96 domain tests pass
(FluentAssertions + xUnit). Directory.Build.props and Directory.Packages.props
centralise build settings and NuGet versions. Both Marathon.sln and Marathon.slnx
are committed; dotnet build Marathon.sln succeeds with 0 warnings/errors.
2026-05-05 01:20:28 +03:00