12 Commits

Author SHA1 Message Date
alexei.dolgolyov 2e53dff853 feat(settings): validate BaseUrl + cron on save, add BaseUrl hint
- 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.
2026-05-29 00:50:49 +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 d9d92ea8fd feat(ui): event autocomplete + log-bet deep link, steam-move label
- 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.
2026-05-28 23:08:56 +03:00
alexei.dolgolyov 2b1025cae3 feat(anomaly): IAnomalyDetector seam + steam-move detector
- 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.
2026-05-28 22:59:12 +03:00
alexei.dolgolyov 4dae9e8d0d feat(ui): promote Excel export to a top-level destination
- 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.
2026-05-28 22:46:38 +03:00
alexei.dolgolyov 0e3c4b8d47 feat: Kelly criterion stake sizing (domain + MyBets helper)
- 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).
2026-05-28 22:46:33 +03:00
alexei.dolgolyov 250a93e718 feat(ui): live dashboard, capture-status pill, bet/backtest UX
- 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.
2026-05-28 22:34:28 +03:00
alexei.dolgolyov 0501f9c39c refactor: log silenced UI errors, fix timer leak, narrow exception catch
- 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.
2026-05-28 22:34:17 +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
103 changed files with 9774 additions and 291 deletions
@@ -5,4 +5,22 @@ namespace Marathon.Application.Abstractions;
/// <summary>
/// Repository for <see cref="Anomaly"/> domain entities.
/// </summary>
public interface IAnomalyRepository : IRepository<Guid, Anomaly>;
public interface IAnomalyRepository : IRepository<Guid, Anomaly>
{
/// <summary>
/// Server-side count of anomalies detected strictly after <paramref name="since"/>.
/// Backs the unread badge without materialising the table.
/// </summary>
Task<int> CountSinceAsync(DateTimeOffset since, CancellationToken ct = default);
/// <summary>
/// Anomalies whose <see cref="Anomaly.DetectedAt"/> falls in the inclusive
/// [<paramref name="from"/>..<paramref name="to"/>] window (either bound may be
/// null for open-ended), ordered newest-first. Pushes the temporal filter to SQL;
/// severity / sport filtering remains a service concern (needs the event join).
/// </summary>
Task<IReadOnlyList<Anomaly>> ListByDateRangeAsync(
DateTimeOffset? from,
DateTimeOffset? to,
CancellationToken ct = default);
}
@@ -11,8 +11,27 @@ public interface IEventRepository : IRepository<EventId, Event>
{
Task<IReadOnlyList<Event>> ListByDateRangeAsync(DateRange range, CancellationToken ct = default);
/// <summary>
/// Date-range + sport-filtered query pushed to the database. Replaces the
/// "load the whole date range then filter sports in memory" path on the list
/// pages. Locale-sensitive search and sort remain a service-layer concern.
/// </summary>
Task<IReadOnlyList<Event>> QueryAsync(EventQuery query, CancellationToken ct = default);
/// <summary>
/// Batched point-lookup: loads many events in a single query, keyed by
/// <see cref="EventId"/>. Missing ids are simply absent from the dictionary.
/// Replaces per-id <see cref="IRepository{TKey,TEntity}.GetAsync"/> loops (N+1).
/// </summary>
Task<IReadOnlyDictionary<EventId, Event>> GetManyAsync(
IReadOnlyCollection<EventId> ids,
CancellationToken ct = default);
Task<IReadOnlyList<Event>> ListBySportAsync(SportCode sport, CancellationToken ct = default);
/// <summary>Server-side total event count (dashboard summary).</summary>
Task<int> CountAsync(CancellationToken ct = default);
/// <summary>
/// Distinct sport codes across the events table. Projects in the database
/// rather than materialising every <see cref="Event"/> on the client.
@@ -0,0 +1,32 @@
using Marathon.Application.Storage;
using Marathon.Domain.Entities;
using Marathon.Domain.Enums;
using Marathon.Domain.ValueObjects;
namespace Marathon.Application.Abstractions;
/// <summary>
/// Repository for <see cref="PlacedBet"/> domain entities — the user-tracked
/// betting journal.
/// </summary>
public interface IPlacedBetRepository : IRepository<Guid, PlacedBet>
{
/// <summary>
/// Bets matching <paramref name="outcome"/>. Used by the resolver use case
/// to scan only <see cref="BetOutcome.Pending"/> rows on each pass.
/// </summary>
Task<IReadOnlyList<PlacedBet>> ListByOutcomeAsync(BetOutcome outcome, CancellationToken ct = default);
/// <summary>
/// Bets whose <see cref="PlacedBet.PlacedAt"/> falls within
/// <paramref name="range"/>. Used by the journal page when the user filters
/// by date.
/// </summary>
Task<IReadOnlyList<PlacedBet>> ListByDateRangeAsync(DateRange range, CancellationToken ct = default);
/// <summary>
/// Every bet recorded against <paramref name="eventId"/>. Used by the event
/// detail page to show "you have N bets on this match".
/// </summary>
Task<IReadOnlyList<PlacedBet>> ListByEventAsync(EventId eventId, CancellationToken ct = default);
}
@@ -6,4 +6,14 @@ namespace Marathon.Application.Abstractions;
/// <summary>
/// Repository for <see cref="EventResult"/> domain entities.
/// </summary>
public interface IResultRepository : IRepository<EventId, EventResult>;
public interface IResultRepository : IRepository<EventId, EventResult>
{
/// <summary>
/// Batched point-lookup: loads many results in a single query, keyed by
/// <see cref="EventId"/>. Missing ids are simply absent from the dictionary.
/// Replaces per-id <see cref="IRepository{TKey,TEntity}.GetAsync"/> loops (N+1).
/// </summary>
Task<IReadOnlyDictionary<EventId, EventResult>> GetManyAsync(
IReadOnlyCollection<EventId> ids,
CancellationToken ct = default);
}
@@ -16,6 +16,12 @@ public interface ISnapshotRepository
{
Task<IReadOnlyList<OddsSnapshot>> ListAsync(CancellationToken ct = default);
/// <summary>
/// Server-side count of snapshots captured at or after <paramref name="since"/>.
/// Backs the dashboard "snapshots today" stat without materialising rows.
/// </summary>
Task<int> CountSinceAsync(DateTimeOffset since, CancellationToken ct = default);
Task<IReadOnlyList<OddsSnapshot>> ListByEventAsync(
EventId eventId,
DateTimeOffset from,
@@ -36,4 +42,19 @@ public interface ISnapshotRepository
Task AddAsync(OddsSnapshot entity, CancellationToken ct = default);
Task SaveChangesAsync(CancellationToken ct = default);
/// <summary>
/// Returns the latest pre-match snapshot for <paramref name="eventId"/> whose
/// <see cref="OddsSnapshot.CapturedAt"/> is at or before
/// <paramref name="atOrBefore"/>, or <c>null</c> if none exists. Used by the
/// bet-journal use case as the "closing line" reference for CLV.
/// </summary>
/// <remarks>
/// Pushes the ORDER BY + LIMIT 1 down to SQLite so we do not materialise
/// every snapshot in the 30-day pre-match window just to pick one.
/// </remarks>
Task<OddsSnapshot?> GetLatestPreMatchAsync(
EventId eventId,
DateTimeOffset atOrBefore,
CancellationToken ct = default);
}
@@ -30,6 +30,14 @@ public static class ApplicationModule
services.AddScoped<PullResultsUseCase>();
services.AddScoped<ExportToExcelUseCase>();
services.AddScoped<DetectAnomaliesUseCase>();
services.AddScoped<EvaluateAnomalyOutcomesUseCase>();
services.AddScoped<RecordPlacedBetUseCase>();
services.AddScoped<ResolvePendingBetsUseCase>();
services.AddScoped<BuildBetJournalReportUseCase>();
services.AddScoped<DeletePlacedBetUseCase>();
services.AddScoped<RunBacktestUseCase>();
return services;
}
@@ -0,0 +1,92 @@
using Marathon.Domain.Entities;
using Marathon.Domain.Enums;
namespace Marathon.Application.Betting;
/// <summary>
/// Aggregate report on the user's bet-tracking journal — totals, P&amp;L, and
/// per-bet CLV. Consumed by the Journal page; built by
/// <see cref="UseCases.BuildBetJournalReportUseCase"/>.
/// </summary>
/// <param name="Stats">Roll-up of stake / profit / hit rate / CLV across all bets in scope.</param>
/// <param name="Bets">
/// Every bet paired with its computed CLV (null when no closing snapshot was
/// available). Ordered most-recent <see cref="PlacedBet.PlacedAt"/> first.
/// </param>
public sealed record BetJournalReport(
BetJournalStats Stats,
IReadOnlyList<BetJournalRow> Bets);
/// <summary>
/// One row in the journal — a domain <see cref="PlacedBet"/> plus the CLV
/// computed against the closing pre-match snapshot.
/// </summary>
/// <param name="Bet">The domain bet exactly as persisted.</param>
/// <param name="ClvProbabilityDelta">
/// Closing-line value as an implied-probability delta in roughly [-1, 1].
/// Positive means the user took a better price than the closing line; null
/// when no matching bet existed in the closing snapshot.
/// </param>
public sealed record BetJournalRow(
PlacedBet Bet,
decimal? ClvProbabilityDelta);
/// <summary>
/// Aggregate statistics across a set of <see cref="PlacedBet"/>.
/// All money values share the user's currency — the domain does not encode one.
/// </summary>
/// <param name="TotalBets">Every bet in scope, regardless of outcome.</param>
/// <param name="PendingCount">Bets still awaiting settlement.</param>
/// <param name="WonCount">Settled wins.</param>
/// <param name="LostCount">Settled losses.</param>
/// <param name="VoidCount">Settled pushes / void grades.</param>
/// <param name="TotalStaked">
/// Turnover that contributes to ROI: sum of <see cref="PlacedBet.Stake"/> across
/// <b>Won and Lost</b> bets only. Void (push) and Pending bets are excluded — a
/// returned stake is not real turnover and counting it would dilute ROI.
/// </param>
/// <param name="TotalReturned">
/// Sum of <see cref="PlacedBet.GrossReturn"/> across the same Won + Lost subset
/// that feeds <see cref="TotalStaked"/>.
/// </param>
/// <param name="NetProfit"><c>TotalReturned TotalStaked</c>.</param>
/// <param name="RoiPercent">
/// <c>NetProfit / TotalStaked × 100</c>. Null when no bets have resolved yet.
/// </param>
/// <param name="StrikeRatePercent">
/// <c>WonCount / (WonCount + LostCount) × 100</c> — excludes voids and pendings.
/// Null when no settled win/loss exists yet.
/// </param>
/// <param name="AverageClvProbabilityDelta">
/// Mean CLV across bets where CLV was computable. Null when no comparable
/// closing snapshot was available for any bet.
/// </param>
public sealed record BetJournalStats(
int TotalBets,
int PendingCount,
int WonCount,
int LostCount,
int VoidCount,
decimal TotalStaked,
decimal TotalReturned,
decimal NetProfit,
decimal? RoiPercent,
decimal? StrikeRatePercent,
decimal? AverageClvProbabilityDelta)
{
/// <summary>Convenience: WonCount + LostCount + VoidCount.</summary>
public int ResolvedCount => WonCount + LostCount + VoidCount;
public static BetJournalStats Empty { get; } = new(
TotalBets: 0,
PendingCount: 0,
WonCount: 0,
LostCount: 0,
VoidCount: 0,
TotalStaked: 0m,
TotalReturned: 0m,
NetProfit: 0m,
RoiPercent: null,
StrikeRatePercent: null,
AverageClvProbabilityDelta: null);
}
@@ -0,0 +1,85 @@
using Marathon.Domain.Entities;
using Marathon.Domain.ValueObjects;
namespace Marathon.Application.Betting;
/// <summary>
/// Pure helper that computes Closing Line Value (CLV) for a placed bet.
/// </summary>
/// <remarks>
/// <para>
/// CLV measures how much better (or worse) the rate the user took was compared
/// with the bookmaker's last pre-match price on the same selection. It is the
/// single best long-run indicator of betting skill — positive CLV correlates
/// with positive expected value regardless of any individual bet's outcome.
/// </para>
/// <para>
/// Formula (implied-probability delta):
/// <list type="bullet">
/// <item>Taken implied probability: <c>p_t = 1 / takenRate</c></item>
/// <item>Closing implied probability: <c>p_c = 1 / closeRate</c></item>
/// <item><c>CLV = p_c p_t</c></item>
/// </list>
/// Positive CLV means the closing price implied higher probability for the
/// selection than the price the user took — i.e. the line moved in the user's
/// favour after they placed the bet.
/// </para>
/// <para>
/// Returns <c>null</c> when no matching bet (same Scope / Type / Side / Value)
/// can be found in the closing snapshot — typically because the market closed
/// before the bookmaker exposed a comparable line, or the snapshot store has
/// gaps. UI consumers must distinguish "no data" from "0% CLV".
/// </para>
/// </remarks>
public static class ClosingLineValueCalculator
{
/// <summary>
/// Computes CLV (implied-probability delta) given the rate the user took
/// and the rate present in the closing pre-match snapshot for the same
/// selection. Both must be positive — invariants on <see cref="OddsRate"/>
/// already guarantee this for inputs sourced from the domain.
/// </summary>
public static decimal Compute(decimal takenRate, decimal closingRate)
{
if (takenRate <= 0m)
throw new ArgumentOutOfRangeException(nameof(takenRate), takenRate, "Must be positive.");
if (closingRate <= 0m)
throw new ArgumentOutOfRangeException(nameof(closingRate), closingRate, "Must be positive.");
var takenProb = 1m / takenRate;
var closingProb = 1m / closingRate;
// Round to 6 decimals — beyond that is noise from the round-trip.
return Math.Round(closingProb - takenProb, 6);
}
/// <summary>
/// Convenience overload: finds the matching <see cref="Bet"/> in
/// <paramref name="closingSnapshot"/> by Scope / Type / Side / Value, then
/// computes CLV against <paramref name="takenRate"/>. Returns <c>null</c>
/// when no comparable bet is present.
/// </summary>
public static decimal? TryCompute(
decimal takenRate,
Bet placedSelection,
OddsSnapshot? closingSnapshot)
{
ArgumentNullException.ThrowIfNull(placedSelection);
if (closingSnapshot is null) return null;
var match = closingSnapshot.Bets.FirstOrDefault(b =>
b.Scope.Equals(placedSelection.Scope) &&
b.Type == placedSelection.Type &&
b.Side == placedSelection.Side &&
NullableValuesEqual(b.Value, placedSelection.Value));
return match is null ? null : Compute(takenRate, match.Rate.Value);
}
private static bool NullableValuesEqual(OddsValue? a, OddsValue? b)
{
if (a is null && b is null) return true;
if (a is null || b is null) return false;
return a.Value == b.Value;
}
}
@@ -32,4 +32,16 @@ public sealed class AnomalyOptions
/// in seconds. Default: 60 s.
/// </summary>
public int DetectionIntervalSeconds { get; init; } = 60;
/// <summary>
/// Trailing window, in seconds, over which the steam-move detector measures a
/// continuous one-directional probability drift. Default: 120 s.
/// </summary>
public int SteamMoveWindowSeconds { get; init; } = 120;
/// <summary>
/// Minimum one-directional normalised implied-probability rise within the window
/// to flag a steam move. Must be in (0, 1). Default: 0.20 (20 percentage points).
/// </summary>
public decimal SteamMoveDriftThreshold { get; init; } = 0.20m;
}
@@ -0,0 +1,60 @@
using Marathon.Domain.AnomalyDetection;
using DomainEventId = Marathon.Domain.ValueObjects.EventId;
namespace Marathon.Application.Reporting;
/// <summary>
/// Aggregate report answering the question "is the SuspensionFlip detector right?".
/// </summary>
/// <param name="TotalAnomalies">Every persisted anomaly considered by this report.</param>
/// <param name="ResolvedCount">Anomalies whose source events now have a final result.</param>
/// <param name="UnresolvedCount">Anomalies still waiting for an event result.</param>
/// <param name="HitCount">Resolved anomalies where the post-flip favourite won.</param>
/// <param name="MissCount">Resolved anomalies where the post-flip favourite lost.</param>
/// <param name="HitRate">
/// <see cref="HitCount"/> ÷ <see cref="ResolvedCount"/> in [0, 1]. Null when no anomalies
/// have been resolved yet — the UI must distinguish "0% hit rate" from "no data".
/// </param>
/// <param name="BySeverity">Breakdown by Low / Medium / High severity buckets.</param>
/// <param name="BySport">Breakdown by sport code.</param>
/// <param name="ByScoreBin">Breakdown across [0.30, 0.40), [0.40, 0.50), …, [0.90, 1.00].</param>
/// <param name="Resolved">All resolved anomalies, newest first. Drives the drill-down table.</param>
/// <param name="Unresolved">All unresolved anomalies, newest first.</param>
/// <param name="EventTitles">
/// Pre-shaped <c>"Side1Name vs Side2Name"</c> strings keyed by event id. Carried
/// alongside the report so UI projections do not need a second pass over
/// <c>IEventRepository</c> — every event in <see cref="Resolved"/> /
/// <see cref="Unresolved"/> appears as a key. Missing events (e.g. pruned) are
/// absent; consumers fall back to <c>EventId.Value</c>.
/// </param>
public sealed record AnomalyOutcomeReport(
int TotalAnomalies,
int ResolvedCount,
int UnresolvedCount,
int HitCount,
int MissCount,
decimal? HitRate,
IReadOnlyList<OutcomeBucket> BySeverity,
IReadOnlyList<OutcomeBucket> BySport,
IReadOnlyList<OutcomeBucket> ByScoreBin,
IReadOnlyList<ResolvedAnomaly> Resolved,
IReadOnlyList<ResolvedAnomaly> Unresolved,
IReadOnlyDictionary<DomainEventId, string> EventTitles);
/// <summary>
/// One row in a breakdown table — e.g. "High severity", "Tennis", "[0.60, 0.70)".
/// </summary>
/// <param name="Key">
/// Stable, culture-invariant identifier used by the UI to localise the label
/// (e.g. <c>"Severity.High"</c>, <c>"Sport.22723"</c>, <c>"Bin.0.60-0.70"</c>).
/// </param>
/// <param name="Total">Resolved anomalies in this bucket.</param>
/// <param name="Hits">Subset of <see cref="Total"/> where post-flip favourite won.</param>
/// <param name="HitRate">
/// <see cref="Hits"/> ÷ <see cref="Total"/>, or null when <see cref="Total"/> is 0.
/// </param>
public sealed record OutcomeBucket(
string Key,
int Total,
int Hits,
decimal? HitRate);
@@ -0,0 +1,23 @@
namespace Marathon.Application.Reporting;
/// <summary>
/// Canonical, culture-invariant <see cref="OutcomeBucket.Key"/> prefixes and
/// literals. Used by the use case to emit keys and by the UI to localise them
/// — both sides reference these constants so a rename can never produce silent
/// "key not found" rendering on the page.
/// </summary>
public static class OutcomeBucketKeys
{
/// <summary>Prefix for sport-grouped buckets, e.g. <c>Sport.6</c>.</summary>
public const string SportPrefix = "Sport.";
/// <summary>Prefix for score-bin buckets, e.g. <c>Bin.0.30-0.40</c>.</summary>
public const string BinPrefix = "Bin.";
/// <summary>Prefix for severity buckets, e.g. <c>Severity.High</c>.</summary>
public const string SeverityPrefix = "Severity.";
public const string SeverityLow = SeverityPrefix + "Low";
public const string SeverityMedium = SeverityPrefix + "Medium";
public const string SeverityHigh = SeverityPrefix + "High";
}
@@ -0,0 +1,13 @@
namespace Marathon.Application.Storage;
/// <summary>
/// Database-pushdown query for the event list pages: an inclusive date range plus
/// an optional sport-code filter. Locale-sensitive search and sort are deliberately
/// NOT part of this contract — they stay in the service layer where Cyrillic
/// ordinal semantics are preserved (SQLite BINARY collation would change them).
/// </summary>
/// <param name="Dates">Inclusive scheduled-at window.</param>
/// <param name="SportCodes">When non-empty, restricts to these sport codes. Null/empty = all sports.</param>
public sealed record EventQuery(
DateRange Dates,
IReadOnlyCollection<int>? SportCodes = null);
@@ -0,0 +1,173 @@
using Marathon.Application.Abstractions;
using Marathon.Application.Betting;
using Marathon.Domain.Entities;
using Marathon.Domain.Enums;
using Microsoft.Extensions.Logging;
using DomainEventId = Marathon.Domain.ValueObjects.EventId;
namespace Marathon.Application.UseCases;
/// <summary>
/// Builds a <see cref="BetJournalReport"/>: every persisted bet paired with its
/// Closing-Line-Value, plus aggregate <see cref="BetJournalStats"/>.
/// </summary>
/// <remarks>
/// <para>
/// Closing-line lookup: for each distinct event in the journal, this use case
/// queries pre-match snapshots within a window that ends at the event's
/// <see cref="Event.ScheduledAt"/> and picks the latest snapshot whose
/// <see cref="OddsSnapshot.CapturedAt"/> is still before kickoff. That snapshot
/// is the "close" for CLV purposes.
/// </para>
/// <para>
/// If the snapshot store has nothing within the lookback window, the bet
/// receives a null CLV. Stats then exclude it from the average.
/// </para>
/// </remarks>
public sealed class BuildBetJournalReportUseCase
{
private readonly IPlacedBetRepository _bets;
private readonly IEventRepository _events;
private readonly ISnapshotRepository _snapshots;
private readonly ILogger<BuildBetJournalReportUseCase> _logger;
public BuildBetJournalReportUseCase(
IPlacedBetRepository bets,
IEventRepository events,
ISnapshotRepository snapshots,
ILogger<BuildBetJournalReportUseCase> logger)
{
_bets = bets ?? throw new ArgumentNullException(nameof(bets));
_events = events ?? throw new ArgumentNullException(nameof(events));
_snapshots = snapshots ?? throw new ArgumentNullException(nameof(snapshots));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<BetJournalReport> ExecuteAsync(CancellationToken ct = default)
{
var bets = await _bets.ListAsync(ct).ConfigureAwait(false);
if (bets.Count == 0)
{
_logger.LogInformation("BuildBetJournalReportUseCase: no bets — empty report");
return new BetJournalReport(BetJournalStats.Empty, Array.Empty<BetJournalRow>());
}
var distinctEventIds = bets.Select(b => b.EventId).Distinct().ToList();
// Batch the event loads (was N+1). The closing-snapshot lookup stays per-event
// because it pushes ORDER BY / LIMIT 1 down to SQLite (one indexed row each)
// and is parameterised by that event's ScheduledAt.
var events = await _events.GetManyAsync(distinctEventIds, ct).ConfigureAwait(false);
var closingByEvent = new Dictionary<DomainEventId, OddsSnapshot?>(distinctEventIds.Count);
foreach (var eventId in distinctEventIds)
{
ct.ThrowIfCancellationRequested();
if (!events.TryGetValue(eventId, out var ev))
{
closingByEvent[eventId] = null;
continue;
}
var closing = await _snapshots
.GetLatestPreMatchAsync(eventId, ev.ScheduledAt, ct)
.ConfigureAwait(false);
closingByEvent[eventId] = closing;
}
var rows = new List<BetJournalRow>(bets.Count);
foreach (var bet in bets)
{
ct.ThrowIfCancellationRequested();
closingByEvent.TryGetValue(bet.EventId, out var closing);
var clv = ClosingLineValueCalculator.TryCompute(
takenRate: bet.Selection.Rate.Value,
placedSelection: bet.Selection,
closingSnapshot: closing);
rows.Add(new BetJournalRow(bet, clv));
}
rows.Sort((a, b) => b.Bet.PlacedAt.CompareTo(a.Bet.PlacedAt));
var stats = ComputeStats(rows);
_logger.LogInformation(
"BuildBetJournalReportUseCase: report built — {Total} bets, {Resolved} resolved, ROI={Roi:0.##}%",
stats.TotalBets, stats.ResolvedCount, stats.RoiPercent ?? 0m);
return new BetJournalReport(stats, rows);
}
private static BetJournalStats ComputeStats(IReadOnlyList<BetJournalRow> rows)
{
if (rows.Count == 0) return BetJournalStats.Empty;
var pending = 0;
var won = 0;
var lost = 0;
var voided = 0;
// Industry-standard ROI excludes pushes from turnover — staking on a Void
// bet returns the stake and is functionally a no-op, so counting it as
// turnover dilutes the ROI denominator and understates the user's edge.
// Only Won + Lost contribute to TotalStaked / TotalReturned.
var totalStaked = 0m;
var totalReturned = 0m;
decimal clvSum = 0m;
var clvCount = 0;
foreach (var row in rows)
{
switch (row.Bet.Outcome)
{
case BetOutcome.Pending: pending++; break;
case BetOutcome.Won: won++; break;
case BetOutcome.Lost: lost++; break;
case BetOutcome.Void: voided++; break;
}
if (row.Bet.Outcome is BetOutcome.Won or BetOutcome.Lost)
{
totalStaked += row.Bet.Stake;
totalReturned += row.Bet.GrossReturn ?? 0m;
}
if (row.ClvProbabilityDelta is { } clv)
{
clvSum += clv;
clvCount++;
}
}
var netProfit = totalReturned - totalStaked;
var winLoss = won + lost;
decimal? roi = totalStaked > 0m
? Math.Round((netProfit / totalStaked) * 100m, 2)
: null;
decimal? strikeRate = winLoss > 0
? Math.Round(((decimal)won / winLoss) * 100m, 2)
: null;
// CLV inputs are already 6-decimal-rounded by ClosingLineValueCalculator;
// round the mean only at the display boundary to avoid compounding bias.
decimal? avgClv = clvCount > 0
? clvSum / clvCount
: null;
return new BetJournalStats(
TotalBets: rows.Count,
PendingCount: pending,
WonCount: won,
LostCount: lost,
VoidCount: voided,
TotalStaked: totalStaked,
TotalReturned: totalReturned,
NetProfit: netProfit,
RoiPercent: roi,
StrikeRatePercent: strikeRate,
AverageClvProbabilityDelta: avgClv);
}
}
@@ -0,0 +1,29 @@
using Marathon.Application.Abstractions;
using Microsoft.Extensions.Logging;
namespace Marathon.Application.UseCases;
/// <summary>
/// Removes a <see cref="Marathon.Domain.Entities.PlacedBet"/> from the journal
/// by its identifier. Silent no-op when the id does not exist.
/// </summary>
public sealed class DeletePlacedBetUseCase
{
private readonly IPlacedBetRepository _bets;
private readonly ILogger<DeletePlacedBetUseCase> _logger;
public DeletePlacedBetUseCase(
IPlacedBetRepository bets,
ILogger<DeletePlacedBetUseCase> logger)
{
_bets = bets ?? throw new ArgumentNullException(nameof(bets));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task ExecuteAsync(Guid betId, CancellationToken ct = default)
{
await _bets.DeleteAsync(betId, ct).ConfigureAwait(false);
await _bets.SaveChangesAsync(ct).ConfigureAwait(false);
_logger.LogInformation("DeletePlacedBetUseCase: removed bet {BetId}", betId);
}
}
@@ -30,10 +30,10 @@ public sealed class DetectAnomaliesUseCase
// Dedup window: two anomalies for the same event within this window are considered duplicates.
private static readonly TimeSpan DedupWindow = TimeSpan.FromMinutes(1);
private readonly IEventRepository _eventRepo;
private readonly IEventRepository _eventRepo;
private readonly ISnapshotRepository _snapshotRepo;
private readonly IAnomalyRepository _anomalyRepo;
private readonly AnomalyOptions _options;
private readonly IAnomalyRepository _anomalyRepo;
private readonly AnomalyOptions _options;
private readonly ILogger<DetectAnomaliesUseCase> _logger;
public DetectAnomaliesUseCase(
@@ -43,11 +43,11 @@ public sealed class DetectAnomaliesUseCase
IOptions<AnomalyOptions> options,
ILogger<DetectAnomaliesUseCase> logger)
{
_eventRepo = eventRepo ?? throw new ArgumentNullException(nameof(eventRepo));
_eventRepo = eventRepo ?? throw new ArgumentNullException(nameof(eventRepo));
_snapshotRepo = snapshotRepo ?? throw new ArgumentNullException(nameof(snapshotRepo));
_anomalyRepo = anomalyRepo ?? throw new ArgumentNullException(nameof(anomalyRepo));
_options = (options ?? throw new ArgumentNullException(nameof(options))).Value;
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_anomalyRepo = anomalyRepo ?? throw new ArgumentNullException(nameof(anomalyRepo));
_options = (options ?? throw new ArgumentNullException(nameof(options))).Value;
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <summary>
@@ -59,21 +59,32 @@ public sealed class DetectAnomaliesUseCase
{
_logger.LogInformation("DetectAnomaliesUseCase: cycle started");
var detector = new AnomalyDetector(
_options.SuspensionGapSeconds,
_options.OddsFlipThreshold,
_options.MinSnapshotCount);
var detectors = new IAnomalyDetector[]
{
new AnomalyDetector(
_options.SuspensionGapSeconds,
_options.OddsFlipThreshold,
_options.MinSnapshotCount),
new SteamMoveDetector(
_options.SteamMoveWindowSeconds,
_options.SteamMoveDriftThreshold,
_options.MinSnapshotCount,
_options.SuspensionGapSeconds),
};
var events = await _eventRepo.ListAsync(ct);
int newAnomalyCount = 0;
var now = MoscowTime.Now;
var now = MoscowTime.Now;
var from = now - SnapshotLookback;
// Hoisted outside the per-event loop: load existing anomalies ONCE per cycle
// and slice per-event in the loop. Previously this was reloaded per event
// (O(N_events) round-trips). Reviewer W1, Phase 7.
// and index them by event so dedup is O(1) per event instead of scanning the
// whole list each time (was O(events × anomalies)). Reviewer W1, Phase 7.
var existingAnomalies = await _anomalyRepo.ListAsync(ct);
var existingByEvent = existingAnomalies
.GroupBy(a => a.EventId)
.ToDictionary(g => g.Key, g => g.ToList());
// Single batched query for all events' snapshots — replaces the prior
// per-event ListByEventAsync round-trip (O(N) SQLite hits + N Include(Bets)
@@ -90,7 +101,10 @@ public sealed class DetectAnomaliesUseCase
var snapshots = snapshotsByEvent.TryGetValue(ev.Id, out var found)
? found
: Array.Empty<OddsSnapshot>();
newAnomalyCount += await ProcessEventAsync(detector, ev, snapshots, existingAnomalies, ct);
var existingForEvent = existingByEvent.TryGetValue(ev.Id, out var slice)
? slice
: new List<Anomaly>();
newAnomalyCount += await ProcessEventAsync(detectors, ev, snapshots, existingForEvent, ct);
}
catch (OperationCanceledException)
{
@@ -114,22 +128,21 @@ public sealed class DetectAnomaliesUseCase
// ── Private helpers ───────────────────────────────────────────────────────
private async Task<int> ProcessEventAsync(
AnomalyDetector detector,
IReadOnlyList<IAnomalyDetector> detectors,
Event ev,
IReadOnlyList<OddsSnapshot> snapshots,
IReadOnlyList<Anomaly> existingAnomalies,
List<Anomaly> existingForEvent,
CancellationToken ct)
{
var detected = detector.Detect(ev.Id, snapshots);
// Fan out over every detector kind; dedup below keys on EventId + Kind so the
// flip and steam signals for one event persist independently.
var detected = detectors
.SelectMany(d => d.Detect(ev.Id, snapshots))
.ToList();
if (detected.Count == 0)
return 0;
// Slice the cycle-wide existing-anomaly list to just this event for dedup.
var existingForEvent = existingAnomalies
.Where(a => a.EventId == ev.Id)
.ToList();
int persisted = 0;
foreach (var anomaly in detected)
{
@@ -151,7 +164,7 @@ public sealed class DetectAnomaliesUseCase
// and their DetectedAt timestamps fall within the dedup window.
return existing.Any(a =>
a.EventId == candidate.EventId &&
a.Kind == candidate.Kind &&
a.Kind == candidate.Kind &&
Math.Abs((a.DetectedAt - candidate.DetectedAt).TotalMinutes) <=
DedupWindow.TotalMinutes);
}
@@ -0,0 +1,245 @@
using System.Globalization;
using Marathon.Application.Abstractions;
using Marathon.Application.Reporting;
using Marathon.Domain.AnomalyDetection;
using Marathon.Domain.Entities;
using Microsoft.Extensions.Logging;
using DomainEventId = Marathon.Domain.ValueObjects.EventId;
namespace Marathon.Application.UseCases;
/// <summary>
/// Builds an <see cref="AnomalyOutcomeReport"/> by joining every persisted
/// <see cref="Anomaly"/> with the originating event and its
/// <see cref="EventResult"/>, then running the pure
/// <see cref="AnomalyOutcomeEvaluator"/> over each pair.
/// </summary>
/// <remarks>
/// <para>
/// This is the answer to "does the SuspensionFlip detector actually predict the
/// right side?" The report is the validator for the entire anomaly-detection
/// premise of the product — without it, the algorithm's confidence score is
/// just a number with no calibration.
/// </para>
/// <para>
/// The use case loads all three collections in one pass each and performs the
/// join in memory. Anomaly volumes are small (one per suspension interval per
/// event) so this is well within budget. If volumes grow significantly the
/// repository layer can later add a SQL-side join — the public shape of the
/// report does not change.
/// </para>
/// </remarks>
public sealed class EvaluateAnomalyOutcomesUseCase
{
/// <summary>
/// Lowest score bin shown in the histogram. Score values below this never
/// appear because the detector enforces a configurable threshold (default
/// 0.30) — but the constant is repeated here so the bucketer is independent
/// of any specific configuration value.
/// </summary>
public const decimal MinScore = 0.30m;
/// <summary>
/// Bin width for the score histogram. Yields 7 buckets:
/// [0.30, 0.40), [0.40, 0.50), [0.50, 0.60), [0.60, 0.70), [0.70, 0.80),
/// [0.80, 0.90), [0.90, 1.00]. The last bin is closed on the right.
/// </summary>
public const decimal BinWidth = 0.10m;
private readonly IAnomalyRepository _anomalies;
private readonly IEventRepository _events;
private readonly IResultRepository _results;
private readonly ILogger<EvaluateAnomalyOutcomesUseCase> _logger;
public EvaluateAnomalyOutcomesUseCase(
IAnomalyRepository anomalies,
IEventRepository events,
IResultRepository results,
ILogger<EvaluateAnomalyOutcomesUseCase> logger)
{
_anomalies = anomalies ?? throw new ArgumentNullException(nameof(anomalies));
_events = events ?? throw new ArgumentNullException(nameof(events));
_results = results ?? throw new ArgumentNullException(nameof(results));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<AnomalyOutcomeReport> ExecuteAsync(CancellationToken ct)
{
_logger.LogInformation("EvaluateAnomalyOutcomesUseCase: report build started");
var anomalies = await _anomalies.ListAsync(ct).ConfigureAwait(false);
if (anomalies.Count == 0)
{
_logger.LogInformation(
"EvaluateAnomalyOutcomesUseCase: no anomalies — empty report");
return EmptyReport();
}
// Batched lookups — a single query each, replacing the prior per-event
// GetAsync round-trip (N+1 against SQLite).
var distinctEventIds = anomalies.Select(a => a.EventId).Distinct().ToList();
var eventLookup = await _events.GetManyAsync(distinctEventIds, ct).ConfigureAwait(false);
var resultLookup = await _results.GetManyAsync(distinctEventIds, ct).ConfigureAwait(false);
var eventTitles = new Dictionary<DomainEventId, string>(eventLookup.Count);
foreach (var (id, ev) in eventLookup)
eventTitles[id] = ev.Title;
// Evaluate every anomaly through the pure domain function.
var resolved = new List<ResolvedAnomaly>();
var unresolved = new List<ResolvedAnomaly>();
foreach (var anomaly in anomalies)
{
ct.ThrowIfCancellationRequested();
eventLookup.TryGetValue(anomaly.EventId, out var ev);
resultLookup.TryGetValue(anomaly.EventId, out var result);
var evaluated = AnomalyOutcomeEvaluator.Evaluate(anomaly, ev?.Sport, result);
if (evaluated.Outcome == AnomalyOutcomeKind.Unresolved)
unresolved.Add(evaluated);
else
resolved.Add(evaluated);
}
var resolvedOrdered = resolved
.OrderByDescending(r => r.DetectedAt)
.ToList();
var unresolvedOrdered = unresolved
.OrderByDescending(r => r.DetectedAt)
.ToList();
var hitCount = resolvedOrdered.Count(r => r.Outcome == AnomalyOutcomeKind.Hit);
var missCount = resolvedOrdered.Count - hitCount;
var report = new AnomalyOutcomeReport(
TotalAnomalies: anomalies.Count,
ResolvedCount: resolvedOrdered.Count,
UnresolvedCount: unresolvedOrdered.Count,
HitCount: hitCount,
MissCount: missCount,
HitRate: ComputeRate(hitCount, resolvedOrdered.Count),
BySeverity: BuildSeverityBuckets(resolvedOrdered),
BySport: BuildSportBuckets(resolvedOrdered),
ByScoreBin: BuildScoreBins(resolvedOrdered),
Resolved: resolvedOrdered,
Unresolved: unresolvedOrdered,
EventTitles: eventTitles);
_logger.LogInformation(
"EvaluateAnomalyOutcomesUseCase: report ready — total={Total}, resolved={Resolved}, hits={Hits}",
report.TotalAnomalies, report.ResolvedCount, report.HitCount);
return report;
}
// ── Bucketers ────────────────────────────────────────────────────────────
private static IReadOnlyList<OutcomeBucket> BuildSeverityBuckets(
IReadOnlyCollection<ResolvedAnomaly> resolved)
{
// Thresholds sourced from the Domain so the UI's severity badge and
// this report cannot drift out of sync — single source of truth.
return new[]
{
BuildBucket(OutcomeBucketKeys.SeverityLow,
resolved.Where(r => r.Score < AnomalySeverityThresholds.Medium)),
BuildBucket(OutcomeBucketKeys.SeverityMedium,
resolved.Where(r => r.Score >= AnomalySeverityThresholds.Medium
&& r.Score < AnomalySeverityThresholds.High)),
BuildBucket(OutcomeBucketKeys.SeverityHigh,
resolved.Where(r => r.Score >= AnomalySeverityThresholds.High)),
};
}
private static IReadOnlyList<OutcomeBucket> BuildSportBuckets(
IReadOnlyCollection<ResolvedAnomaly> resolved)
{
return resolved
.Where(r => r.Sport is not null)
.GroupBy(r => r.Sport!.Value)
.OrderBy(g => g.Key)
.Select(g => BuildBucket(
key: string.Format(
CultureInfo.InvariantCulture,
"{0}{1}",
OutcomeBucketKeys.SportPrefix,
g.Key),
items: g))
.ToList();
}
private static IReadOnlyList<OutcomeBucket> BuildScoreBins(
IReadOnlyCollection<ResolvedAnomaly> resolved)
{
// Default range is the canonical [0.30, 1.00] with seven 0.10-wide bins.
// If the operator has lowered the detector's flip threshold and we have
// resolved anomalies below 0.30, prepend additional bins so every row in
// the report shows up in exactly one bucket — the histogram total must
// equal ResolvedCount no matter how the detector is tuned.
var floor = MinScore;
if (resolved.Count > 0)
{
var lowest = resolved.Min(r => r.Score);
if (lowest < MinScore)
{
var binsBelow = Math.Ceiling((MinScore - lowest) / BinWidth);
floor = MinScore - binsBelow * BinWidth;
if (floor < 0m) floor = 0m;
}
}
var bins = new List<OutcomeBucket>();
for (var start = floor; start < 1.0m; start += BinWidth)
{
var binStart = start;
var binEnd = start + BinWidth;
var isLast = binEnd >= 1.0m;
// Last bin is closed on the right so 1.00 lands in [0.90, 1.00].
var inBin = resolved.Where(r =>
r.Score >= binStart &&
(isLast ? r.Score <= 1.0m : r.Score < binEnd));
var key = string.Format(
CultureInfo.InvariantCulture,
"{0}{1:0.00}-{2:0.00}",
OutcomeBucketKeys.BinPrefix,
binStart,
Math.Min(binEnd, 1.0m));
bins.Add(BuildBucket(key, inBin));
}
return bins;
}
private static OutcomeBucket BuildBucket(string key, IEnumerable<ResolvedAnomaly> items)
{
var list = items as IReadOnlyCollection<ResolvedAnomaly> ?? items.ToList();
var total = list.Count;
var hits = list.Count(r => r.Outcome == AnomalyOutcomeKind.Hit);
return new OutcomeBucket(key, total, hits, ComputeRate(hits, total));
}
private static decimal? ComputeRate(int numerator, int denominator) =>
denominator == 0
? null
: Math.Round(numerator / (decimal)denominator, 4);
private static AnomalyOutcomeReport EmptyReport() =>
new(
TotalAnomalies: 0,
ResolvedCount: 0,
UnresolvedCount: 0,
HitCount: 0,
MissCount: 0,
HitRate: null,
BySeverity: Array.Empty<OutcomeBucket>(),
BySport: Array.Empty<OutcomeBucket>(),
ByScoreBin: Array.Empty<OutcomeBucket>(),
Resolved: Array.Empty<ResolvedAnomaly>(),
Unresolved: Array.Empty<ResolvedAnomaly>(),
EventTitles: new Dictionary<DomainEventId, string>());
}
@@ -72,10 +72,10 @@ public sealed class PullResultsUseCase
IResultRepository resultRepo,
ILogger<PullResultsUseCase> logger)
{
_scraper = scraper ?? throw new ArgumentNullException(nameof(scraper));
_eventRepo = eventRepo ?? throw new ArgumentNullException(nameof(eventRepo));
_scraper = scraper ?? throw new ArgumentNullException(nameof(scraper));
_eventRepo = eventRepo ?? throw new ArgumentNullException(nameof(eventRepo));
_resultRepo = resultRepo ?? throw new ArgumentNullException(nameof(resultRepo));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <summary>
@@ -149,12 +149,13 @@ public sealed class PullResultsUseCase
{
if (selection is { Count: > 0 })
{
// Batched load (was N+1); preserve the caller's selection order and
// silently drop ids with no stored event.
var events = await _eventRepo.GetManyAsync(selection, ct).ConfigureAwait(false);
var resolved = new List<Event>(selection.Count);
foreach (var id in selection)
{
ct.ThrowIfCancellationRequested();
var ev = await _eventRepo.GetAsync(id, ct).ConfigureAwait(false);
if (ev is not null)
if (events.TryGetValue(id, out var ev))
resolved.Add(ev);
}
return resolved;
@@ -0,0 +1,90 @@
using Marathon.Application.Abstractions;
using Marathon.Domain.Entities;
using Marathon.Domain.Enums;
using Microsoft.Extensions.Logging;
using DomainEventId = Marathon.Domain.ValueObjects.EventId;
namespace Marathon.Application.UseCases;
/// <summary>
/// Records a new <see cref="PlacedBet"/> entered manually via the Journal UI.
/// </summary>
/// <remarks>
/// <para>
/// The use case validates that the referenced event exists, then persists the
/// bet. If the event already has a final result the bet is graded on the spot
/// via <see cref="Marathon.Domain.Betting.BetOutcomeResolver"/> — saves the
/// user a round-trip to the resolver page when entering historical wagers.
/// </para>
/// </remarks>
public sealed class RecordPlacedBetUseCase
{
private readonly IPlacedBetRepository _bets;
private readonly IEventRepository _events;
private readonly IResultRepository _results;
private readonly ILogger<RecordPlacedBetUseCase> _logger;
public RecordPlacedBetUseCase(
IPlacedBetRepository bets,
IEventRepository events,
IResultRepository results,
ILogger<RecordPlacedBetUseCase> logger)
{
_bets = bets ?? throw new ArgumentNullException(nameof(bets));
_events = events ?? throw new ArgumentNullException(nameof(events));
_results = results ?? throw new ArgumentNullException(nameof(results));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <summary>
/// Persists <paramref name="bet"/>. Returns the bet as stored — if the
/// event already has a result, the returned instance reflects the graded
/// <see cref="BetOutcome"/>.
/// </summary>
/// <exception cref="InvalidOperationException">
/// The bet references an unknown event. The journal does not allow free-form
/// event codes — wagers must be on events the scraper has captured so the
/// CLV calculator can compare against the closing snapshot.
/// </exception>
public async Task<PlacedBet> ExecuteAsync(PlacedBet bet, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(bet);
// Confirm the event exists in the local store.
var ev = await _events.GetAsync(bet.EventId, ct).ConfigureAwait(false);
if (ev is null)
{
throw new InvalidOperationException(
$"Cannot record a bet on unknown event '{bet.EventId.Value}'. " +
"The event must already be present in the scrape store.");
}
var toPersist = bet;
// Auto-grade if a result is already available.
if (bet.Outcome == BetOutcome.Pending)
{
var result = await _results.GetAsync(bet.EventId, ct).ConfigureAwait(false);
if (result is not null)
{
var graded = Marathon.Domain.Betting.BetOutcomeResolver.Resolve(bet.Selection, result);
if (graded is not null)
{
toPersist = bet.WithOutcome(graded.Value);
_logger.LogInformation(
"RecordPlacedBetUseCase: bet {BetId} on event {EventId} auto-graded as {Outcome}",
toPersist.Id, ((DomainEventId)toPersist.EventId).Value, graded.Value);
}
}
}
await _bets.AddAsync(toPersist, ct).ConfigureAwait(false);
await _bets.SaveChangesAsync(ct).ConfigureAwait(false);
_logger.LogInformation(
"RecordPlacedBetUseCase: persisted bet {BetId} on event {EventId} stake={Stake} rate={Rate}",
toPersist.Id, ((DomainEventId)toPersist.EventId).Value, toPersist.Stake, toPersist.Selection.Rate.Value);
return toPersist;
}
}
@@ -0,0 +1,84 @@
using Marathon.Application.Abstractions;
using Marathon.Domain.Betting;
using Marathon.Domain.Entities;
using Marathon.Domain.Enums;
using Microsoft.Extensions.Logging;
using DomainEventId = Marathon.Domain.ValueObjects.EventId;
namespace Marathon.Application.UseCases;
/// <summary>
/// Sweeps the journal for <see cref="BetOutcome.Pending"/> bets whose events
/// have been graded, and updates them in bulk via
/// <see cref="BetOutcomeResolver"/>.
/// </summary>
/// <remarks>
/// Called on demand from the Journal page's "Resolve pending" button. The
/// design is idempotent — bets that cannot be auto-graded (period-scope, or
/// no result yet) are left untouched and surface again on the next pass.
/// </remarks>
public sealed class ResolvePendingBetsUseCase
{
private readonly IPlacedBetRepository _bets;
private readonly IResultRepository _results;
private readonly ILogger<ResolvePendingBetsUseCase> _logger;
public ResolvePendingBetsUseCase(
IPlacedBetRepository bets,
IResultRepository results,
ILogger<ResolvePendingBetsUseCase> logger)
{
_bets = bets ?? throw new ArgumentNullException(nameof(bets));
_results = results ?? throw new ArgumentNullException(nameof(results));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <summary>
/// Returns the number of bets that were transitioned out of Pending in this pass.
/// </summary>
public async Task<int> ExecuteAsync(CancellationToken ct = default)
{
var pending = await _bets.ListByOutcomeAsync(BetOutcome.Pending, ct).ConfigureAwait(false);
if (pending.Count == 0)
{
_logger.LogInformation("ResolvePendingBetsUseCase: no pending bets");
return 0;
}
// Cache results per event so we do not re-query for each bet on the same event.
var resultCache = new Dictionary<DomainEventId, EventResult?>();
var resolvedCount = 0;
foreach (var bet in pending)
{
ct.ThrowIfCancellationRequested();
if (!resultCache.TryGetValue(bet.EventId, out var result))
{
result = await _results.GetAsync(bet.EventId, ct).ConfigureAwait(false);
resultCache[bet.EventId] = result;
}
if (result is null) continue;
var graded = BetOutcomeResolver.Resolve(bet.Selection, result);
if (graded is null) continue;
var updated = bet.WithOutcome(graded.Value);
await _bets.UpdateAsync(updated, ct).ConfigureAwait(false);
resolvedCount++;
}
// Save before logging — if the batch fails, an exception bubbles out and
// the success-count log is never emitted; we never report a graded count
// that was rolled back.
if (resolvedCount > 0)
await _bets.SaveChangesAsync(ct).ConfigureAwait(false);
_logger.LogInformation(
"ResolvePendingBetsUseCase: graded {Resolved} of {Pending} pending bets",
resolvedCount, pending.Count);
return resolvedCount;
}
}
@@ -0,0 +1,118 @@
using Marathon.Application.Abstractions;
using Marathon.Application.Storage;
using Marathon.Domain.AnomalyDetection;
using Marathon.Domain.Backtesting;
using Marathon.Domain.Entities;
using Microsoft.Extensions.Logging;
using DomainEventId = Marathon.Domain.ValueObjects.EventId;
namespace Marathon.Application.UseCases;
/// <summary>
/// Loads every persisted anomaly paired with its event metadata and result,
/// constructs <see cref="BacktestCandidate"/> rows, and runs the pure
/// <see cref="BacktestSimulator"/> with the supplied strategy.
/// </summary>
/// <remarks>
/// <para>
/// Composes the two analytics features already in place: anomalies come from
/// the SuspensionFlip detector, and results come from the results loader. The
/// simulator never touches I/O — all data loading happens here, then the run
/// is a deterministic function of (strategy, candidates).
/// </para>
/// <para>
/// Anomalies whose evidence JSON fails to parse, whose source events lack a
/// final result, or whose event row has been pruned are filtered out before
/// simulation. They are not counted as "skipped" by the simulator — the
/// simulator's <see cref="BacktestResult.Skipped"/> counter only reflects
/// runs the strategy chose not to bet on (below threshold, no edge, etc.).
/// </para>
/// </remarks>
public sealed class RunBacktestUseCase
{
private readonly IAnomalyRepository _anomalies;
private readonly IEventRepository _events;
private readonly IResultRepository _results;
private readonly ILogger<RunBacktestUseCase> _logger;
public RunBacktestUseCase(
IAnomalyRepository anomalies,
IEventRepository events,
IResultRepository results,
ILogger<RunBacktestUseCase> logger)
{
_anomalies = anomalies ?? throw new ArgumentNullException(nameof(anomalies));
_events = events ?? throw new ArgumentNullException(nameof(events));
_results = results ?? throw new ArgumentNullException(nameof(results));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <summary>Runs the backtest over every graded anomaly (no date filter).</summary>
public Task<BacktestResult> ExecuteAsync(
BacktestStrategy strategy,
CancellationToken ct = default)
=> ExecuteAsync(strategy, dateRange: null, ct);
/// <summary>
/// Runs the backtest over anomalies detected within <paramref name="dateRange"/>
/// (inclusive); pass <c>null</c> to include every graded anomaly. The date filter
/// is pushed to SQL via <see cref="IAnomalyRepository.ListByDateRangeAsync"/>.
/// </summary>
public async Task<BacktestResult> ExecuteAsync(
BacktestStrategy strategy,
DateRange? dateRange,
CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(strategy);
_logger.LogInformation(
"RunBacktestUseCase: started — bankroll={Bankroll}, minScore={MinScore}, stakeRule={Rule}, range={Range}",
strategy.StartingBankroll, strategy.MinScore, strategy.StakeRule,
dateRange is null ? "all" : $"{dateRange.From:O}..{dateRange.To:O}");
var anomalies = dateRange is null
? await _anomalies.ListAsync(ct).ConfigureAwait(false)
: await _anomalies.ListByDateRangeAsync(dateRange.From, dateRange.To, ct).ConfigureAwait(false);
if (anomalies.Count == 0)
{
_logger.LogInformation("RunBacktestUseCase: no anomalies — empty result");
return BacktestSimulator.Run(strategy, Array.Empty<BacktestCandidate>());
}
// Batched lookups — a single query each, replacing the prior per-event
// GetAsync round-trip (N+1 against SQLite).
var distinctEventIds = anomalies.Select(a => a.EventId).Distinct().ToList();
var eventLookup = await _events.GetManyAsync(distinctEventIds, ct).ConfigureAwait(false);
var resultLookup = await _results.GetManyAsync(distinctEventIds, ct).ConfigureAwait(false);
var titles = new Dictionary<DomainEventId, string>(eventLookup.Count);
foreach (var (id, ev) in eventLookup)
titles[id] = ev.Title;
var candidates = new List<BacktestCandidate>(anomalies.Count);
foreach (var anomaly in anomalies)
{
ct.ThrowIfCancellationRequested();
// Cannot simulate a bet whose event hasn't been graded yet.
if (!resultLookup.TryGetValue(anomaly.EventId, out var result))
continue;
if (!AnomalyEvidenceParser.TryParse(anomaly.EvidenceJson, out var evidence))
continue;
eventLookup.TryGetValue(anomaly.EventId, out var ev);
candidates.Add(new BacktestCandidate(anomaly, evidence, result, ev?.Sport));
}
var simResult = BacktestSimulator.Run(strategy, candidates, titles);
_logger.LogInformation(
"RunBacktestUseCase: done — bets={Bets}, wins={Wins}, losses={Losses}, ROI={Roi:0.##}%, finalBankroll={Final}",
simResult.BetsPlaced, simResult.Wins, simResult.Losses,
simResult.RoiPercent ?? 0m, simResult.FinalBankroll);
return simResult;
}
}
@@ -1,5 +1,3 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using Marathon.Domain.Entities;
using Marathon.Domain.Enums;
using Marathon.Domain.ValueObjects;
@@ -23,20 +21,15 @@ namespace Marathon.Domain.AnomalyDetection;
/// </list>
///
/// This class is stateless and deterministic — identical inputs always produce identical output.
/// It has no I/O or DI dependencies.
/// It has no I/O or DI dependencies. Evidence formatting is delegated to
/// <see cref="MatchWinEvidence"/> so every detector kind writes the identical shape.
/// </summary>
public sealed class AnomalyDetector
public sealed class AnomalyDetector : IAnomalyDetector
{
private readonly int _suspensionGapSeconds;
private readonly decimal _oddsFlipThreshold;
private readonly int _minSnapshotCount;
private static readonly JsonSerializerOptions JsonOptions = new()
{
WriteIndented = false,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
};
/// <param name="suspensionGapSeconds">
/// Minimum gap between adjacent live snapshots (in seconds) to classify as a suspension.
/// Default per spec: 60.
@@ -68,16 +61,7 @@ public sealed class AnomalyDetector
_minSnapshotCount = minSnapshotCount;
}
/// <summary>
/// Analyses <paramref name="snapshots"/> for the given <paramref name="eventId"/> and
/// returns 0 or more anomalies detected in this timeline.
/// </summary>
/// <param name="eventId">The event being analysed.</param>
/// <param name="snapshots">All snapshots for this event (any source, any order).</param>
/// <returns>
/// An <see cref="IReadOnlyList{T}"/> of <see cref="Anomaly"/> records, one per qualifying
/// suspension interval. May be empty.
/// </returns>
/// <inheritdoc />
public IReadOnlyList<Anomaly> Detect(EventId eventId, IReadOnlyList<OddsSnapshot> snapshots)
{
ArgumentNullException.ThrowIfNull(eventId);
@@ -119,9 +103,9 @@ public sealed class AnomalyDetector
private Anomaly? TryDetectFlip(EventId eventId, SuspensionInterval interval)
{
// Extract Match-Win bets from each snapshot.
var preProbs = ExtractMatchWinProbabilities(interval.PreSuspension);
var postProbs = ExtractMatchWinProbabilities(interval.PostSuspension);
// Extract Match-Win implied probabilities from each snapshot.
var preProbs = MatchWinEvidence.Extract(interval.PreSuspension);
var postProbs = MatchWinEvidence.Extract(interval.PostSuspension);
// Cannot compute flip if either snapshot lacks Win bets.
if (preProbs is null || postProbs is null)
@@ -129,10 +113,8 @@ public sealed class AnomalyDetector
// Step 4 — compute flip score = max(|p_post[i] p_pre[i]|) across common sides.
decimal flipScore = 0m;
flipScore = Math.Max(flipScore,
Math.Abs(postProbs.P1 - preProbs.P1));
flipScore = Math.Max(flipScore,
Math.Abs(postProbs.P2 - preProbs.P2));
flipScore = Math.Max(flipScore, Math.Abs(postProbs.P1 - preProbs.P1));
flipScore = Math.Max(flipScore, Math.Abs(postProbs.P2 - preProbs.P2));
if (preProbs.PDraw.HasValue && postProbs.PDraw.HasValue)
{
flipScore = Math.Max(flipScore,
@@ -140,7 +122,8 @@ public sealed class AnomalyDetector
}
// Step 5 — favourite-changed test: argmax of implied probability must differ.
bool favouriteChanged = DetermineFavourite(preProbs) != DetermineFavourite(postProbs);
bool favouriteChanged =
MatchWinEvidence.Favourite(preProbs) != MatchWinEvidence.Favourite(postProbs);
if (flipScore < _oddsFlipThreshold || !favouriteChanged)
return null;
@@ -148,8 +131,11 @@ public sealed class AnomalyDetector
// Clamp score to [0, 1] before constructing the Anomaly (domain invariant).
var clampedScore = Math.Min(1m, flipScore);
// Step 6 — build evidence JSON.
var evidenceJson = BuildEvidenceJson(interval, preProbs, postProbs);
// Step 6 — build evidence JSON via the shared formatter.
var evidenceJson = MatchWinEvidence.BuildJson(
(int)interval.Gap.TotalSeconds,
interval.PreSuspension, preProbs,
interval.PostSuspension, postProbs);
return new Anomaly(
Id: Guid.NewGuid(),
@@ -159,100 +145,4 @@ public sealed class AnomalyDetector
Score: clampedScore,
EvidenceJson: evidenceJson);
}
private static MatchWinProbabilities? ExtractMatchWinProbabilities(OddsSnapshot snapshot)
{
// Find Match-scope Win bets.
var matchWinBets = snapshot.Bets
.Where(b => b.Scope is MatchScope && b.Type == BetType.Win)
.ToList();
var win1 = matchWinBets.FirstOrDefault(b => b.Side == Side.Side1);
var win2 = matchWinBets.FirstOrDefault(b => b.Side == Side.Side2);
if (win1 is null || win2 is null)
return null; // Not enough data.
// Find optional Draw bet (MatchScope, BetType.Draw).
var drawBet = snapshot.Bets
.FirstOrDefault(b => b.Scope is MatchScope && b.Type == BetType.Draw);
// Raw implied probabilities: p = 1 / rate.
decimal rawP1 = 1m / win1.Rate.Value;
decimal rawP2 = 1m / win2.Rate.Value;
decimal rawDraw = drawBet is not null ? 1m / drawBet.Rate.Value : 0m;
decimal total = rawP1 + rawP2 + rawDraw;
// Normalise so they sum to 1.
decimal p1 = rawP1 / total;
decimal p2 = rawP2 / total;
decimal pDraw = drawBet is not null ? rawDraw / total : 0m;
return new MatchWinProbabilities(
P1: p1,
PDraw: drawBet is not null ? pDraw : null,
P2: p2,
Rate1: win1.Rate.Value,
RateDraw: drawBet?.Rate.Value,
Rate2: win2.Rate.Value);
}
private static string DetermineFavourite(MatchWinProbabilities probs)
{
// The favourite is the side with the highest normalised implied probability.
if (probs.PDraw.HasValue && probs.PDraw.Value > probs.P1 && probs.PDraw.Value > probs.P2)
return "Draw";
return probs.P1 >= probs.P2 ? "Side1" : "Side2";
}
private string BuildEvidenceJson(
SuspensionInterval interval,
MatchWinProbabilities preProbs,
MatchWinProbabilities postProbs)
{
var payload = new EvidencePayload(
SuspensionGapSeconds: (int)interval.Gap.TotalSeconds,
PreSuspension: new SnapshotEvidence(
CapturedAt: interval.PreSuspension.CapturedAt.ToString("O"),
P1: preProbs.P1,
PDraw: preProbs.PDraw,
P2: preProbs.P2,
Rate1: preProbs.Rate1,
RateDraw: preProbs.RateDraw,
Rate2: preProbs.Rate2),
PostSuspension: new SnapshotEvidence(
CapturedAt: interval.PostSuspension.CapturedAt.ToString("O"),
P1: postProbs.P1,
PDraw: postProbs.PDraw,
P2: postProbs.P2,
Rate1: postProbs.Rate1,
RateDraw: postProbs.RateDraw,
Rate2: postProbs.Rate2));
return JsonSerializer.Serialize(payload, JsonOptions);
}
// ── Nested types ─────────────────────────────────────────────────────────
private sealed record MatchWinProbabilities(
decimal P1,
decimal? PDraw,
decimal P2,
decimal Rate1,
decimal? RateDraw,
decimal Rate2);
private sealed record EvidencePayload(
[property: JsonPropertyName("suspensionGapSeconds")] int SuspensionGapSeconds,
[property: JsonPropertyName("preSuspension")] SnapshotEvidence PreSuspension,
[property: JsonPropertyName("postSuspension")] SnapshotEvidence PostSuspension);
private sealed record SnapshotEvidence(
[property: JsonPropertyName("capturedAt")] string CapturedAt,
[property: JsonPropertyName("p1")] decimal P1,
[property: JsonPropertyName("pDraw")] decimal? PDraw,
[property: JsonPropertyName("p2")] decimal P2,
[property: JsonPropertyName("rate1")] decimal Rate1,
[property: JsonPropertyName("rateDraw")] decimal? RateDraw,
[property: JsonPropertyName("rate2")] decimal Rate2);
}
@@ -0,0 +1,146 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using Marathon.Domain.Entities;
using Marathon.Domain.Enums;
namespace Marathon.Domain.AnomalyDetection;
/// <summary>
/// Strongly typed projection of the JSON payload written by <see cref="AnomalyDetector"/>
/// into <see cref="Anomaly.EvidenceJson"/>. Captures pre- and post-suspension snapshots
/// of normalised implied probabilities and raw rates for the Match-Win market.
/// </summary>
/// <remarks>
/// The evaluator and any reader that needs to inspect an anomaly's evidence should
/// parse via <see cref="AnomalyEvidenceParser.TryParse"/> rather than re-implement
/// the JSON shape — the detector owns the schema.
/// </remarks>
public sealed record AnomalyEvidenceData(
int SuspensionGapSeconds,
AnomalyEvidenceSide PreSuspension,
AnomalyEvidenceSide PostSuspension);
/// <summary>
/// One side (pre or post) of a suspension interval. Probabilities are normalised
/// so that <c>P1 + (PDraw ?? 0) + P2 == 1</c>. Two-way markets (e.g. tennis)
/// leave <see cref="PDraw"/> and <see cref="RateDraw"/> null.
/// </summary>
public sealed record AnomalyEvidenceSide(
DateTimeOffset CapturedAt,
decimal P1,
decimal? PDraw,
decimal P2,
decimal Rate1,
decimal? RateDraw,
decimal Rate2)
{
/// <summary>
/// The side carrying the highest normalised implied probability — i.e.,
/// the bookmaker's favourite at this point in time.
/// </summary>
public Side Favourite
{
get
{
// Three-way: include Draw in the argmax.
var best = Side.Side1;
var bestValue = P1;
if (PDraw is { } pd && pd > bestValue)
{
best = Side.Draw;
bestValue = pd;
}
if (P2 > bestValue)
{
best = Side.Side2;
}
return best;
}
}
}
/// <summary>
/// Parses the <see cref="Anomaly.EvidenceJson"/> string emitted by
/// <see cref="AnomalyDetector"/>. Tolerant of malformed payloads — returns false
/// rather than throwing so callers can skip un-parseable anomalies silently.
/// </summary>
public static class AnomalyEvidenceParser
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true,
};
/// <summary>
/// Attempts to deserialise the evidence JSON. Returns <c>true</c> only when
/// both pre- and post-suspension snapshots are present.
/// </summary>
public static bool TryParse(string? evidenceJson, out AnomalyEvidenceData data)
{
data = default!;
if (string.IsNullOrWhiteSpace(evidenceJson)) return false;
try
{
var dto = JsonSerializer.Deserialize<EvidenceDto>(evidenceJson, JsonOptions);
if (dto is null || dto.PreSuspension is null || dto.PostSuspension is null)
return false;
data = new AnomalyEvidenceData(
SuspensionGapSeconds: dto.SuspensionGapSeconds,
PreSuspension: ToSide(dto.PreSuspension),
PostSuspension: ToSide(dto.PostSuspension));
return true;
}
catch (JsonException)
{
return false;
}
}
private static AnomalyEvidenceSide ToSide(EvidenceSideDto dto) =>
new(
CapturedAt: dto.CapturedAt,
P1: dto.P1 ?? 0m,
PDraw: dto.PDraw,
P2: dto.P2 ?? 0m,
Rate1: dto.Rate1 ?? 0m,
RateDraw: dto.RateDraw,
Rate2: dto.Rate2 ?? 0m);
private sealed class EvidenceDto
{
[JsonPropertyName("suspensionGapSeconds")]
public int SuspensionGapSeconds { get; init; }
[JsonPropertyName("preSuspension")]
public EvidenceSideDto? PreSuspension { get; init; }
[JsonPropertyName("postSuspension")]
public EvidenceSideDto? PostSuspension { get; init; }
}
private sealed class EvidenceSideDto
{
[JsonPropertyName("capturedAt")]
public DateTimeOffset CapturedAt { get; init; }
[JsonPropertyName("p1")]
public decimal? P1 { get; init; }
[JsonPropertyName("pDraw")]
public decimal? PDraw { get; init; }
[JsonPropertyName("p2")]
public decimal? P2 { get; init; }
[JsonPropertyName("rate1")]
public decimal? Rate1 { get; init; }
[JsonPropertyName("rateDraw")]
public decimal? RateDraw { get; init; }
[JsonPropertyName("rate2")]
public decimal? Rate2 { get; init; }
}
}
@@ -0,0 +1,51 @@
using Marathon.Domain.Entities;
using Marathon.Domain.Enums;
using Marathon.Domain.ValueObjects;
namespace Marathon.Domain.AnomalyDetection;
/// <summary>
/// Verdict produced by comparing an anomaly's predicted post-flip favourite
/// against the actual <see cref="EventResult.WinnerSide"/>.
/// </summary>
public enum AnomalyOutcomeKind
{
/// <summary>
/// The post-flip favourite (the side the bookmaker shortened odds on AFTER
/// the suspension) ended up winning. The flip was directionally correct.
/// </summary>
Hit,
/// <summary>
/// The post-flip favourite did NOT win. The flip pointed at the wrong side.
/// </summary>
Miss,
/// <summary>
/// No <see cref="EventResult"/> is available yet — outcome cannot be judged.
/// </summary>
Unresolved,
}
/// <summary>
/// One anomaly paired with its evaluated outcome. Surfaced to the UI so each
/// resolved anomaly can be reviewed individually (e.g., when investigating
/// why the algorithm got a specific event wrong).
/// </summary>
/// <remarks>
/// <see cref="PreFlipFavourite"/> and <see cref="PostFlipFavourite"/> are null
/// when the anomaly's evidence JSON could not be parsed — the outcome will be
/// <see cref="AnomalyOutcomeKind.Unresolved"/> in that case. Encoding the
/// absence keeps consumers from being shown a fabricated side.
/// </remarks>
public sealed record ResolvedAnomaly(
Guid AnomalyId,
EventId EventId,
DateTimeOffset DetectedAt,
decimal Score,
AnomalyKind Kind,
SportCode? Sport,
Side? PreFlipFavourite,
Side? PostFlipFavourite,
Side? ActualWinner,
AnomalyOutcomeKind Outcome);
@@ -0,0 +1,117 @@
using Marathon.Domain.Entities;
using Marathon.Domain.Enums;
using Marathon.Domain.ValueObjects;
namespace Marathon.Domain.AnomalyDetection;
/// <summary>
/// Pure domain function that evaluates whether a <see cref="AnomalyKind.SuspensionFlip"/>
/// anomaly's prediction (the post-suspension favourite) matched the actual
/// <see cref="EventResult.WinnerSide"/>.
/// </summary>
/// <remarks>
/// <para>
/// A "hit" is recorded when the side carrying the highest implied probability
/// in <see cref="AnomalyEvidenceData.PostSuspension"/> equals
/// <see cref="EventResult.WinnerSide"/>. For two-way markets (tennis), Draw is
/// not a possible favourite — the evaluator naturally never emits Draw there.
/// </para>
/// <para>
/// Stateless, deterministic, no I/O. Safe to call in tight loops.
/// </para>
/// </remarks>
public static class AnomalyOutcomeEvaluator
{
/// <summary>
/// Evaluates one anomaly against its event (optional metadata) and its result
/// (optional — null when the match hasn't been graded yet).
/// </summary>
/// <param name="anomaly">The persisted anomaly.</param>
/// <param name="sport">
/// The event's sport — surfaced into <see cref="ResolvedAnomaly"/> so the UI
/// can group by sport. Null when the originating event row is missing.
/// </param>
/// <param name="result">The event's final result, if known.</param>
/// <returns>
/// A <see cref="ResolvedAnomaly"/> with <see cref="AnomalyOutcomeKind.Unresolved"/>
/// when <paramref name="result"/> is null or the evidence JSON cannot be parsed,
/// otherwise <see cref="AnomalyOutcomeKind.Hit"/> / <see cref="AnomalyOutcomeKind.Miss"/>.
/// </returns>
public static ResolvedAnomaly Evaluate(
Anomaly anomaly,
SportCode? sport,
EventResult? result)
{
ArgumentNullException.ThrowIfNull(anomaly);
if (!AnomalyEvidenceParser.TryParse(anomaly.EvidenceJson, out var data))
{
// Cannot determine favourite without evidence; treat as unresolved.
return new ResolvedAnomaly(
AnomalyId: anomaly.Id,
EventId: anomaly.EventId,
DetectedAt: anomaly.DetectedAt,
Score: anomaly.Score,
Kind: anomaly.Kind,
Sport: sport,
PreFlipFavourite: null,
PostFlipFavourite: null,
ActualWinner: result?.WinnerSide,
Outcome: AnomalyOutcomeKind.Unresolved);
}
var preFav = data.PreSuspension.Favourite;
var postFav = data.PostSuspension.Favourite;
if (result is null)
{
return new ResolvedAnomaly(
AnomalyId: anomaly.Id,
EventId: anomaly.EventId,
DetectedAt: anomaly.DetectedAt,
Score: anomaly.Score,
Kind: anomaly.Kind,
Sport: sport,
PreFlipFavourite: preFav,
PostFlipFavourite: postFav,
ActualWinner: null,
Outcome: AnomalyOutcomeKind.Unresolved);
}
// Guard rail for sport-specific impossibilities. A two-way market
// (e.g. tennis) cannot produce a Draw outcome — if one shows up the
// EventResult disagrees with the evidence schema, so we refuse to
// grade it instead of silently counting it as a Miss.
var isTwoWay = data.PreSuspension.PDraw is null && data.PostSuspension.PDraw is null;
if (isTwoWay && result.WinnerSide == Side.Draw)
{
return new ResolvedAnomaly(
AnomalyId: anomaly.Id,
EventId: anomaly.EventId,
DetectedAt: anomaly.DetectedAt,
Score: anomaly.Score,
Kind: anomaly.Kind,
Sport: sport,
PreFlipFavourite: preFav,
PostFlipFavourite: postFav,
ActualWinner: result.WinnerSide,
Outcome: AnomalyOutcomeKind.Unresolved);
}
var outcome = postFav == result.WinnerSide
? AnomalyOutcomeKind.Hit
: AnomalyOutcomeKind.Miss;
return new ResolvedAnomaly(
AnomalyId: anomaly.Id,
EventId: anomaly.EventId,
DetectedAt: anomaly.DetectedAt,
Score: anomaly.Score,
Kind: anomaly.Kind,
Sport: sport,
PreFlipFavourite: preFav,
PostFlipFavourite: postFav,
ActualWinner: result.WinnerSide,
Outcome: outcome);
}
}
@@ -0,0 +1,29 @@
namespace Marathon.Domain.AnomalyDetection;
/// <summary>
/// Single source of truth for the severity bucket boundaries that the UI
/// pill / badge, the Insights breakdowns, and any future reporter share.
/// </summary>
/// <remarks>
/// Buckets are inclusive on the left, exclusive on the right (except High
/// which extends to 1.00 inclusive):
/// <list type="bullet">
/// <item>Low [<see cref="Low"/>, <see cref="Medium"/>)</item>
/// <item>Medium [<see cref="Medium"/>, <see cref="High"/>)</item>
/// <item>High [<see cref="High"/>, 1.00]</item>
/// </list>
/// Defined at the Domain layer so both the Application reporter and the
/// Marathon.UI severity rules consume the same numbers — re-tuning happens
/// in one place.
/// </remarks>
public static class AnomalySeverityThresholds
{
/// <summary>Lower bound of the Low bucket. Matches the detector's default flip threshold.</summary>
public const decimal Low = 0.30m;
/// <summary>Lower bound of the Medium bucket.</summary>
public const decimal Medium = 0.45m;
/// <summary>Lower bound of the High bucket.</summary>
public const decimal High = 0.60m;
}
@@ -0,0 +1,18 @@
using Marathon.Domain.Entities;
using Marathon.Domain.ValueObjects;
namespace Marathon.Domain.AnomalyDetection;
/// <summary>
/// A pure, stateless detector that scans one event's snapshot timeline and returns
/// any anomalies it finds. Implementations are deterministic and free of I/O so they
/// can be composed (fanned out) and unit-tested in isolation.
/// </summary>
public interface IAnomalyDetector
{
/// <summary>
/// Analyses <paramref name="snapshots"/> for <paramref name="eventId"/> and returns
/// 0 or more anomalies. May be empty; never null.
/// </summary>
IReadOnlyList<Anomaly> Detect(EventId eventId, IReadOnlyList<OddsSnapshot> snapshots);
}
@@ -0,0 +1,118 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using Marathon.Domain.Entities;
using Marathon.Domain.Enums;
using Marathon.Domain.ValueObjects;
namespace Marathon.Domain.AnomalyDetection;
/// <summary>
/// Shared helper for the match-win implied-probability extraction and the canonical
/// pre/post evidence-JSON shape used by every <see cref="IAnomalyDetector"/>.
/// </summary>
/// <remarks>
/// Centralising the evidence format here guarantees that all detector kinds write the
/// identical on-disk shape, so the UI parser (<c>AnomalyEvidenceParser</c>) and the
/// outcome evaluator (<see cref="AnomalyOutcomeEvaluator"/>) work for every kind
/// without branching. The <c>suspensionGapSeconds</c> field carries the elapsed
/// seconds between the two snapshots — a suspension gap for flips, a drift window for
/// steam moves.
/// </remarks>
internal static class MatchWinEvidence
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
WriteIndented = false,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
};
/// <summary>Normalised match-win implied probabilities + raw rates for a snapshot.</summary>
public sealed record Probabilities(
decimal P1,
decimal? PDraw,
decimal P2,
decimal Rate1,
decimal? RateDraw,
decimal Rate2);
/// <summary>
/// Extracts normalised match-win implied probabilities, or null when the snapshot
/// lacks both Side1 and Side2 Match-Win bets.
/// </summary>
public static Probabilities? Extract(OddsSnapshot snapshot)
{
var matchWinBets = snapshot.Bets
.Where(b => b.Scope is MatchScope && b.Type == BetType.Win)
.ToList();
var win1 = matchWinBets.FirstOrDefault(b => b.Side == Side.Side1);
var win2 = matchWinBets.FirstOrDefault(b => b.Side == Side.Side2);
if (win1 is null || win2 is null)
return null;
var drawBet = snapshot.Bets
.FirstOrDefault(b => b.Scope is MatchScope && b.Type == BetType.Draw);
// Raw implied probabilities: p = 1 / rate; normalise so they sum to 1.
decimal rawP1 = 1m / win1.Rate.Value;
decimal rawP2 = 1m / win2.Rate.Value;
decimal rawDraw = drawBet is not null ? 1m / drawBet.Rate.Value : 0m;
decimal total = rawP1 + rawP2 + rawDraw;
return new Probabilities(
P1: rawP1 / total,
PDraw: drawBet is not null ? rawDraw / total : null,
P2: rawP2 / total,
Rate1: win1.Rate.Value,
RateDraw: drawBet?.Rate.Value,
Rate2: win2.Rate.Value);
}
/// <summary>Label of the side carrying the highest normalised implied probability.</summary>
public static string Favourite(Probabilities p)
{
if (p.PDraw.HasValue && p.PDraw.Value > p.P1 && p.PDraw.Value > p.P2)
return "Draw";
return p.P1 >= p.P2 ? "Side1" : "Side2";
}
/// <summary>Serialises the canonical pre/post evidence payload.</summary>
public static string BuildJson(
int gapSeconds,
OddsSnapshot pre,
Probabilities preProbs,
OddsSnapshot post,
Probabilities postProbs)
{
var payload = new EvidencePayload(
SuspensionGapSeconds: gapSeconds,
PreSuspension: ToEvidence(pre, preProbs),
PostSuspension: ToEvidence(post, postProbs));
return JsonSerializer.Serialize(payload, JsonOptions);
}
private static SnapshotEvidence ToEvidence(OddsSnapshot snapshot, Probabilities p) =>
new(
CapturedAt: snapshot.CapturedAt.ToString("O"),
P1: p.P1,
PDraw: p.PDraw,
P2: p.P2,
Rate1: p.Rate1,
RateDraw: p.RateDraw,
Rate2: p.Rate2);
private sealed record EvidencePayload(
[property: JsonPropertyName("suspensionGapSeconds")] int SuspensionGapSeconds,
[property: JsonPropertyName("preSuspension")] SnapshotEvidence PreSuspension,
[property: JsonPropertyName("postSuspension")] SnapshotEvidence PostSuspension);
private sealed record SnapshotEvidence(
[property: JsonPropertyName("capturedAt")] string CapturedAt,
[property: JsonPropertyName("p1")] decimal P1,
[property: JsonPropertyName("pDraw")] decimal? PDraw,
[property: JsonPropertyName("p2")] decimal P2,
[property: JsonPropertyName("rate1")] decimal Rate1,
[property: JsonPropertyName("rateDraw")] decimal? RateDraw,
[property: JsonPropertyName("rate2")] decimal Rate2);
}
@@ -0,0 +1,121 @@
using Marathon.Domain.Entities;
using Marathon.Domain.Enums;
using Marathon.Domain.ValueObjects;
namespace Marathon.Domain.AnomalyDetection;
/// <summary>
/// Detects a "steam move": a rapid, one-directional rise in a side's normalised
/// implied probability over a short CONTINUOUS window — money moving the line.
/// </summary>
/// <remarks>
/// <para>
/// A window is only considered when it contains no suspension-sized gap between
/// consecutive snapshots (controlled by <c>maxStepGapSeconds</c>); drift across a
/// suspension is the <see cref="AnomalyDetector"/>'s (SuspensionFlip) territory, so
/// the two detectors never double-flag the same interval.
/// </para>
/// <para>
/// Emits an <see cref="AnomalyKind.SteamMove"/> anomaly whose pre/post evidence
/// brackets the drift, written in the shared <see cref="MatchWinEvidence"/> shape so
/// the UI and <see cref="AnomalyOutcomeEvaluator"/> handle it without branching.
/// A sustained steam may cross the threshold at several consecutive snapshots; those
/// are collapsed to one persisted row by the detection use case's dedup window.
/// </para>
/// </remarks>
public sealed class SteamMoveDetector : IAnomalyDetector
{
private readonly int _windowSeconds;
private readonly decimal _driftThreshold;
private readonly int _minSnapshotCount;
private readonly int _maxStepGapSeconds;
/// <param name="windowSeconds">Trailing window (seconds) over which drift is measured.</param>
/// <param name="driftThreshold">Minimum one-directional implied-probability rise to flag; in (0, 1).</param>
/// <param name="minSnapshotCount">Minimum live snapshots before detection runs (>= 2).</param>
/// <param name="maxStepGapSeconds">
/// Maximum gap between consecutive snapshots for the window to count as continuous.
/// A larger gap means a suspension occurred — that is flip territory, not steam.
/// </param>
public SteamMoveDetector(int windowSeconds, decimal driftThreshold, int minSnapshotCount, int maxStepGapSeconds)
{
if (windowSeconds <= 0)
throw new ArgumentOutOfRangeException(nameof(windowSeconds), windowSeconds, "Must be positive.");
if (driftThreshold is <= 0m or >= 1m)
throw new ArgumentOutOfRangeException(nameof(driftThreshold), driftThreshold, "Must be in (0, 1).");
if (minSnapshotCount < 2)
throw new ArgumentOutOfRangeException(nameof(minSnapshotCount), minSnapshotCount, "Must be at least 2.");
if (maxStepGapSeconds <= 0)
throw new ArgumentOutOfRangeException(nameof(maxStepGapSeconds), maxStepGapSeconds, "Must be positive.");
_windowSeconds = windowSeconds;
_driftThreshold = driftThreshold;
_minSnapshotCount = minSnapshotCount;
_maxStepGapSeconds = maxStepGapSeconds;
}
/// <inheritdoc />
public IReadOnlyList<Anomaly> Detect(EventId eventId, IReadOnlyList<OddsSnapshot> snapshots)
{
ArgumentNullException.ThrowIfNull(eventId);
ArgumentNullException.ThrowIfNull(snapshots);
var live = snapshots
.Where(s => s.Source == OddsSource.Live)
.OrderBy(s => s.CapturedAt)
.ToList();
if (live.Count < _minSnapshotCount)
return Array.Empty<Anomaly>();
var window = TimeSpan.FromSeconds(_windowSeconds);
var maxStepGap = TimeSpan.FromSeconds(_maxStepGapSeconds);
var anomalies = new List<Anomaly>();
int windowStart = 0;
int continuityStart = 0;
for (int end = 1; end < live.Count; end++)
{
// A suspension-sized step resets continuity: the drift after it is a flip,
// not a steam move, so steam windows never span a suspension.
if (live[end].CapturedAt - live[end - 1].CapturedAt > maxStepGap)
continuityStart = end;
// Shrink the trailing window so [windowStart, end] is within windowSeconds.
while (live[end].CapturedAt - live[windowStart].CapturedAt > window)
windowStart++;
int start = Math.Max(windowStart, continuityStart);
if (start >= end)
continue;
var pre = MatchWinEvidence.Extract(live[start]);
var post = MatchWinEvidence.Extract(live[end]);
if (pre is null || post is null)
continue;
// One-directional rise: a side's normalised probability INCREASED (odds
// shortened) by at least the threshold — money steamed onto that side.
decimal drift = Math.Max(post.P1 - pre.P1, post.P2 - pre.P2);
if (pre.PDraw.HasValue && post.PDraw.HasValue)
drift = Math.Max(drift, post.PDraw.Value - pre.PDraw.Value);
if (drift < _driftThreshold)
continue;
var gapSeconds = (int)(live[end].CapturedAt - live[start].CapturedAt).TotalSeconds;
var evidenceJson = MatchWinEvidence.BuildJson(gapSeconds, live[start], pre, live[end], post);
anomalies.Add(new Anomaly(
Id: Guid.NewGuid(),
EventId: eventId,
DetectedAt: MoscowTime.Now,
Kind: AnomalyKind.SteamMove,
Score: Math.Min(1m, drift),
EvidenceJson: evidenceJson));
}
return anomalies.AsReadOnly();
}
}
@@ -0,0 +1,24 @@
using Marathon.Domain.AnomalyDetection;
using Marathon.Domain.Entities;
using Marathon.Domain.ValueObjects;
namespace Marathon.Domain.Backtesting;
/// <summary>
/// Input row for <see cref="BacktestSimulator"/> — one anomaly fully resolved
/// against its event metadata and result. The use case constructs these once
/// per simulation run and feeds them to the pure simulator in chronological
/// order.
/// </summary>
/// <param name="Anomaly">The flagged anomaly being simulated.</param>
/// <param name="Evidence">
/// Parsed evidence payload (pre- and post-suspension snapshots). The simulator
/// reads the post-suspension favourite and rate from here.
/// </param>
/// <param name="Result">Final event result — drives the win/loss verdict.</param>
/// <param name="Sport">Sport metadata, optional, surfaced into the trace row.</param>
public sealed record BacktestCandidate(
Anomaly Anomaly,
AnomalyEvidenceData Evidence,
EventResult Result,
SportCode? Sport);
@@ -0,0 +1,113 @@
using Marathon.Domain.Entities;
using Marathon.Domain.Enums;
using Marathon.Domain.ValueObjects;
namespace Marathon.Domain.Backtesting;
/// <summary>
/// Aggregate output of one simulation run. Contains both the headline numbers
/// the user looks at (final bankroll, ROI, max drawdown) and the per-bet
/// trace needed to draw an equity curve.
/// </summary>
/// <param name="StartingBankroll">Echoed from the strategy for the UI.</param>
/// <param name="FinalBankroll">Bankroll after the last simulated bet settled.</param>
/// <param name="NetProfit"><c>FinalBankroll StartingBankroll</c>.</param>
/// <param name="RoiPercent">
/// <c>NetProfit / TotalStaked × 100</c>. Null when no bets were placed
/// (no anomaly met the threshold, or the bankroll went to zero before any
/// stake could be sized).
/// </param>
/// <param name="TotalStaked">Sum of stake sizes across every settled bet.</param>
/// <param name="TotalReturned">Sum of gross returns across every settled bet.</param>
/// <param name="MaxDrawdown">
/// Largest peak-to-trough drop in bankroll observed during the run, as an
/// absolute amount. Always ≥ 0.
/// </param>
/// <param name="MaxDrawdownPercent">
/// <see cref="MaxDrawdown"/> as a percentage of the peak that preceded it.
/// Null when there were no draws (no bets or no losses).
/// </param>
/// <param name="BetsPlaced">Total bets the strategy actually placed.</param>
/// <param name="Wins">Settled bets whose post-flip favourite won.</param>
/// <param name="Losses">Settled bets whose post-flip favourite lost.</param>
/// <param name="Skipped">
/// Total anomalies inspected but skipped. Equals
/// <see cref="SkippedByThreshold"/> + <see cref="SkippedByDataQuality"/> +
/// <see cref="SkippedByBankroll"/>. Surfaced separately so the UI can
/// distinguish a strategy choice ("threshold too high") from a real-world
/// signal ("bankroll empty") or a data-quality issue.
/// </param>
/// <param name="SkippedByThreshold">
/// Skipped because <c>Anomaly.Score &lt; strategy.MinScore</c> — pure strategy choice.
/// </param>
/// <param name="SkippedByDataQuality">
/// Skipped because the evidence parsed but the post-flip favourite has no
/// rate / probability, or because a two-way market produced a Draw winner.
/// Strategy-orthogonal — these would be skipped under any rule.
/// </param>
/// <param name="SkippedByBankroll">
/// Skipped because the sized stake was non-positive (Kelly returned no edge,
/// or bankroll was depleted) or exceeded the current bankroll.
/// </param>
/// <param name="MaxWinStreak">Longest run of consecutive wins.</param>
/// <param name="MaxLossStreak">Longest run of consecutive losses.</param>
/// <param name="Trace">
/// Per-bet records in chronological order — drives the equity curve.
/// </param>
/// <param name="EventTitles">
/// Pre-shaped <c>"Side1Name vs Side2Name"</c> strings keyed by event id, for
/// every event in <see cref="Trace"/>. Carried alongside the result so the UI
/// projection does not need a second pass over <c>IEventRepository</c>.
/// Missing events (pruned by retention) are absent from the map; consumers
/// fall back to <c>EventId.Value</c>.
/// </param>
public sealed record BacktestResult(
decimal StartingBankroll,
decimal FinalBankroll,
decimal NetProfit,
decimal? RoiPercent,
decimal TotalStaked,
decimal TotalReturned,
decimal MaxDrawdown,
decimal? MaxDrawdownPercent,
int BetsPlaced,
int Wins,
int Losses,
int Skipped,
int SkippedByThreshold,
int SkippedByDataQuality,
int SkippedByBankroll,
int MaxWinStreak,
int MaxLossStreak,
IReadOnlyList<BacktestTrace> Trace,
IReadOnlyDictionary<Marathon.Domain.ValueObjects.EventId, string> EventTitles);
/// <summary>
/// One settled simulated bet. Carries enough metadata to surface a
/// drill-down row and a point on the equity curve.
/// </summary>
/// <param name="AnomalyId">Source anomaly for the link-back affordance.</param>
/// <param name="EventId">Event being bet on.</param>
/// <param name="DetectedAt">When the anomaly was originally detected.</param>
/// <param name="Score">Confidence score of the anomaly.</param>
/// <param name="Sport">Sport metadata if available — null when the event is missing.</param>
/// <param name="PostFlipFavourite">Side bet on (the post-suspension favourite).</param>
/// <param name="TakenRate">Rate at which the simulator "bought" the bet (post-flip rate).</param>
/// <param name="Stake">Stake sized for this bet.</param>
/// <param name="WinnerSide">Actual winner of the event.</param>
/// <param name="IsWin"><c>true</c> if the post-flip favourite was the winner.</param>
/// <param name="Payout">Gross return — <c>Stake × Rate</c> for a win, 0 for a loss.</param>
/// <param name="BankrollAfter">Bankroll after this bet settled — equity-curve y-axis.</param>
public sealed record BacktestTrace(
Guid AnomalyId,
EventId EventId,
DateTimeOffset DetectedAt,
decimal Score,
SportCode? Sport,
Side PostFlipFavourite,
decimal TakenRate,
decimal Stake,
Side WinnerSide,
bool IsWin,
decimal Payout,
decimal BankrollAfter);
@@ -0,0 +1,248 @@
using Marathon.Domain.Enums;
namespace Marathon.Domain.Backtesting;
/// <summary>
/// Pure simulator that replays a <see cref="BacktestStrategy"/> over a
/// chronological list of <see cref="BacktestCandidate"/> rows and returns the
/// resulting <see cref="BacktestResult"/>. No I/O, no DI — safe to call in
/// hot loops or property tests.
/// </summary>
/// <remarks>
/// <para>
/// Loop body per candidate:
/// <list type="number">
/// <item>Skip if <c>Anomaly.Score &lt; strategy.MinScore</c>.</item>
/// <item>
/// Skip if the evidence is two-way and the actual winner is <c>Draw</c>:
/// this mirrors <c>AnomalyOutcomeEvaluator</c> — we refuse to grade
/// selections that are structurally impossible for the market.
/// </item>
/// <item>Compute stake from the chosen <see cref="StakeRule"/>.</item>
/// <item>Skip when the stake is non-positive (Kelly returned no edge, or bankroll empty).</item>
/// <item>Settle: payout = stake × rate when the post-flip favourite won, 0 otherwise.</item>
/// <item>Update bankroll, streaks, and running peak-to-trough drawdown.</item>
/// </list>
/// </para>
/// </remarks>
public static class BacktestSimulator
{
public static BacktestResult Run(
BacktestStrategy strategy,
IReadOnlyList<BacktestCandidate> candidates,
IReadOnlyDictionary<Marathon.Domain.ValueObjects.EventId, string>? eventTitles = null)
{
ArgumentNullException.ThrowIfNull(strategy);
ArgumentNullException.ThrowIfNull(candidates);
var bankroll = strategy.StartingBankroll;
var peakBankroll = bankroll;
var maxDrawdown = 0m;
decimal? maxDrawdownPct = null;
var trace = new List<BacktestTrace>();
var totalStaked = 0m;
var totalReturned = 0m;
var wins = 0;
var losses = 0;
var skippedByThreshold = 0;
var skippedByDataQuality = 0;
var skippedByBankroll = 0;
var currentWinStreak = 0;
var currentLossStreak = 0;
var maxWinStreak = 0;
var maxLossStreak = 0;
// Process in chronological order so bankroll progression is meaningful.
var ordered = candidates
.OrderBy(c => c.Anomaly.DetectedAt)
.ToList();
foreach (var candidate in ordered)
{
if (candidate.Anomaly.Score < strategy.MinScore)
{
skippedByThreshold++;
continue;
}
var postFav = candidate.Evidence.PostSuspension.Favourite;
var isTwoWay = candidate.Evidence.PreSuspension.PDraw is null
&& candidate.Evidence.PostSuspension.PDraw is null;
if (isTwoWay && candidate.Result.WinnerSide == Side.Draw)
{
// Data inconsistency — refuse to grade.
skippedByDataQuality++;
continue;
}
var (postRate, postProb) = ExtractPostFlipRateAndProbability(candidate.Evidence, postFav);
if (postRate is null || postProb is null)
{
skippedByDataQuality++;
continue;
}
var stake = SizeStake(
strategy: strategy,
bankroll: bankroll,
postRate: postRate.Value,
postProb: postProb.Value);
if (stake <= 0m || stake > bankroll)
{
// Either Kelly returned no edge, or the user is broke. Either way
// do not place this bet.
skippedByBankroll++;
continue;
}
var isWin = postFav == candidate.Result.WinnerSide;
var payout = isWin ? stake * postRate.Value : 0m;
bankroll = bankroll - stake + payout;
totalStaked += stake;
totalReturned += payout;
if (isWin)
{
wins++;
currentWinStreak++;
currentLossStreak = 0;
maxWinStreak = Math.Max(maxWinStreak, currentWinStreak);
}
else
{
losses++;
currentLossStreak++;
currentWinStreak = 0;
maxLossStreak = Math.Max(maxLossStreak, currentLossStreak);
}
// Drawdown tracking: peak is the running maximum bankroll observed
// before the current point; drawdown is peak current. We update
// peak only on new highs so the trough is measured from the right
// reference.
if (bankroll > peakBankroll)
{
peakBankroll = bankroll;
}
else
{
var dd = peakBankroll - bankroll;
if (dd > maxDrawdown)
{
maxDrawdown = dd;
maxDrawdownPct = peakBankroll > 0m
? Math.Round((dd / peakBankroll) * 100m, 2)
: null;
}
}
// Round money columns away-from-zero so a -0.005 stake reads as "-0.01"
// — the convention every accountant in the world expects.
trace.Add(new BacktestTrace(
AnomalyId: candidate.Anomaly.Id,
EventId: candidate.Anomaly.EventId,
DetectedAt: candidate.Anomaly.DetectedAt,
Score: candidate.Anomaly.Score,
Sport: candidate.Sport,
PostFlipFavourite: postFav,
TakenRate: postRate.Value,
Stake: Math.Round(stake, 2, MidpointRounding.AwayFromZero),
WinnerSide: candidate.Result.WinnerSide,
IsWin: isWin,
Payout: Math.Round(payout, 2, MidpointRounding.AwayFromZero),
BankrollAfter: Math.Round(bankroll, 2, MidpointRounding.AwayFromZero)));
}
decimal? roi = totalStaked > 0m
? Math.Round(((bankroll - strategy.StartingBankroll) / totalStaked) * 100m, 2,
MidpointRounding.AwayFromZero)
: null;
var totalSkipped = skippedByThreshold + skippedByDataQuality + skippedByBankroll;
return new BacktestResult(
StartingBankroll: strategy.StartingBankroll,
FinalBankroll: Math.Round(bankroll, 2, MidpointRounding.AwayFromZero),
NetProfit: Math.Round(bankroll - strategy.StartingBankroll, 2, MidpointRounding.AwayFromZero),
RoiPercent: roi,
TotalStaked: Math.Round(totalStaked, 2, MidpointRounding.AwayFromZero),
TotalReturned: Math.Round(totalReturned, 2, MidpointRounding.AwayFromZero),
MaxDrawdown: Math.Round(maxDrawdown, 2, MidpointRounding.AwayFromZero),
MaxDrawdownPercent: maxDrawdownPct,
BetsPlaced: trace.Count,
Wins: wins,
Losses: losses,
Skipped: totalSkipped,
SkippedByThreshold: skippedByThreshold,
SkippedByDataQuality: skippedByDataQuality,
SkippedByBankroll: skippedByBankroll,
MaxWinStreak: maxWinStreak,
MaxLossStreak: maxLossStreak,
Trace: trace,
EventTitles: eventTitles
?? new Dictionary<Marathon.Domain.ValueObjects.EventId, string>());
}
// ── Helpers ──────────────────────────────────────────────────────────────
private static (decimal? Rate, decimal? Probability) ExtractPostFlipRateAndProbability(
AnomalyDetection.AnomalyEvidenceData evidence,
Side favourite)
{
var post = evidence.PostSuspension;
return favourite switch
{
Side.Side1 => (post.Rate1, post.P1),
Side.Side2 => (post.Rate2, post.P2),
Side.Draw => (post.RateDraw, post.PDraw),
_ => (null, null),
};
}
private static decimal SizeStake(
BacktestStrategy strategy,
decimal bankroll,
decimal postRate,
decimal postProb)
{
if (bankroll <= 0m) return 0m;
return strategy.StakeRule switch
{
StakeRule.Flat => strategy.FlatStake,
StakeRule.PercentOfBankroll => bankroll * strategy.PercentOfBankroll,
StakeRule.Kelly => ComputeKellyStake(
bankroll: bankroll,
postRate: postRate,
postProb: postProb,
fraction: strategy.KellyFraction),
_ => 0m,
};
}
private static decimal ComputeKellyStake(
decimal bankroll,
decimal postRate,
decimal postProb,
decimal fraction)
{
// Kelly: f* = (b·p q) / b where b = rate 1, p = win prob, q = 1 p.
// Skip non-positive edge (no bet rather than betting "negative size").
var b = postRate - 1m;
if (b <= 0m) return 0m;
var p = postProb;
var q = 1m - p;
var fullKelly = ((b * p) - q) / b;
if (fullKelly <= 0m) return 0m;
// Quarter / half / etc.-Kelly: scale full edge by the configured fraction.
var stakeFraction = fullKelly * fraction;
return bankroll * stakeFraction;
}
}
@@ -0,0 +1,72 @@
namespace Marathon.Domain.Backtesting;
/// <summary>
/// Parameters fed to <see cref="BacktestSimulator"/>. The strategy is "for every
/// SuspensionFlip anomaly with score ≥ <see cref="MinScore"/>, stake
/// according to <see cref="StakeRule"/> on the post-flip favourite at the
/// post-flip rate, then settle against the actual <c>EventResult</c>."
/// </summary>
/// <param name="StartingBankroll">
/// Initial bankroll for compounding stake rules. Must be positive.
/// </param>
/// <param name="MinScore">
/// Lower bound on <c>Anomaly.Score</c> — only anomalies at or above this
/// threshold are bet on. Must be in [0, 1].
/// </param>
/// <param name="StakeRule">How to size each bet — see the enum docs.</param>
/// <param name="FlatStake">
/// Used when <see cref="StakeRule"/> is <see cref="Backtesting.StakeRule.Flat"/>.
/// Must be positive.
/// </param>
/// <param name="PercentOfBankroll">
/// Used when <see cref="StakeRule"/> is
/// <see cref="Backtesting.StakeRule.PercentOfBankroll"/>. Expressed as a
/// fraction in (0, 1]. e.g. 0.02 = 2 % of bankroll.
/// </param>
/// <param name="KellyFraction">
/// Used when <see cref="StakeRule"/> is <see cref="Backtesting.StakeRule.Kelly"/>.
/// Multiplier on the raw Kelly fraction; in (0, 1]. 0.25 (quarter-Kelly) is
/// the conservative default.
/// </param>
public sealed record BacktestStrategy(
decimal StartingBankroll,
decimal MinScore,
StakeRule StakeRule,
decimal FlatStake,
decimal PercentOfBankroll,
decimal KellyFraction)
{
public decimal StartingBankroll { get; } = StartingBankroll > 0m
? StartingBankroll
: throw new ArgumentOutOfRangeException(nameof(StartingBankroll),
StartingBankroll, "StartingBankroll must be positive.");
public decimal MinScore { get; } = MinScore is >= 0m and <= 1m
? MinScore
: throw new ArgumentOutOfRangeException(nameof(MinScore),
MinScore, "MinScore must be in [0, 1].");
public decimal FlatStake { get; } = FlatStake > 0m
? FlatStake
: throw new ArgumentOutOfRangeException(nameof(FlatStake),
FlatStake, "FlatStake must be positive.");
public decimal PercentOfBankroll { get; } = PercentOfBankroll is > 0m and <= 1m
? PercentOfBankroll
: throw new ArgumentOutOfRangeException(nameof(PercentOfBankroll),
PercentOfBankroll, "PercentOfBankroll must be in (0, 1].");
public decimal KellyFraction { get; } = KellyFraction is > 0m and <= 1m
? KellyFraction
: throw new ArgumentOutOfRangeException(nameof(KellyFraction),
KellyFraction, "KellyFraction must be in (0, 1].");
/// <summary>Sensible defaults — flat-stake, score ≥ 0.45, ¼-Kelly waiting in the wings.</summary>
public static BacktestStrategy Default { get; } = new(
StartingBankroll: 1000m,
MinScore: 0.45m,
StakeRule: StakeRule.Flat,
FlatStake: 50m,
PercentOfBankroll: 0.02m,
KellyFraction: 0.25m);
}
@@ -0,0 +1,28 @@
namespace Marathon.Domain.Backtesting;
/// <summary>
/// How the simulator decides how much to stake on each bet during a backtest.
/// </summary>
public enum StakeRule
{
/// <summary>
/// Same fixed amount every bet, independent of bankroll.
/// Suitable for "flat-betting" historical analysis — the simplest baseline.
/// </summary>
Flat,
/// <summary>
/// A fixed percentage of the current bankroll every bet. Compounds: a
/// winning streak grows stake size; losses shrink it. Equivalent to
/// proportional betting.
/// </summary>
PercentOfBankroll,
/// <summary>
/// Fractional Kelly using the post-flip implied probability as the edge
/// estimate: <c>f = ((b·p) q) / b</c>, scaled by the configured
/// <see cref="BacktestStrategy.KellyFraction"/>. Negative-expectation bets
/// stake zero (and are skipped). Half/quarter-Kelly is the usual practice.
/// </summary>
Kelly,
}
@@ -0,0 +1,95 @@
using Marathon.Domain.Entities;
using Marathon.Domain.Enums;
using Marathon.Domain.ValueObjects;
namespace Marathon.Domain.Betting;
/// <summary>
/// Pure function that grades a <see cref="Bet"/> selection against a final
/// <see cref="EventResult"/>. Used by the bet-journal resolver to auto-settle
/// pending wagers the moment a result lands.
/// </summary>
/// <remarks>
/// <para>
/// Grading rules:
/// <list type="bullet">
/// <item><c>Win</c> (Side1/Side2): selection wins iff <c>WinnerSide</c> matches the side.</item>
/// <item><c>Draw</c>: wins iff <c>WinnerSide == Draw</c>.</item>
/// <item><c>WinFora</c> with handicap <c>h</c> on side S: adjusted S-score
/// = <c>S.Score + h</c>. Wins when adjusted > opponent, voids on tie, loses otherwise.</item>
/// <item><c>Total</c> with threshold <c>t</c>: combined = <c>Side1Score + Side2Score</c>.
/// <c>More</c> wins when combined > t, voids on equal, loses when less.
/// <c>Less</c> is the mirror image.</item>
/// </list>
/// </para>
/// <para>
/// Returns <c>null</c> when the bet cannot be graded against this result —
/// today only period-scope selections, because <see cref="EventResult"/> stores
/// the full-time score only. Callers must leave such bets in
/// <see cref="BetOutcome.Pending"/> for manual settlement.
/// </para>
/// </remarks>
public static class BetOutcomeResolver
{
/// <summary>
/// Grades <paramref name="selection"/> against <paramref name="result"/>.
/// Returns the resulting <see cref="BetOutcome"/> or <c>null</c> if the
/// bet shape cannot be auto-resolved from the available result data.
/// </summary>
public static BetOutcome? Resolve(Bet selection, EventResult result)
{
ArgumentNullException.ThrowIfNull(selection);
ArgumentNullException.ThrowIfNull(result);
// Period-scope bets need per-period scores which EventResult does not
// carry today — leave for manual grading.
if (selection.Scope is not MatchScope)
return null;
return selection.Type switch
{
BetType.Win => ResolveWin(selection.Side, result),
BetType.Draw => ResolveDraw(result),
BetType.WinFora => ResolveFora(selection.Side, selection.Value!.Value, result),
BetType.Total => ResolveTotal(selection.Side, selection.Value!.Value, result),
_ => null,
};
}
private static BetOutcome ResolveWin(Side side, EventResult result) =>
result.WinnerSide == side ? BetOutcome.Won : BetOutcome.Lost;
private static BetOutcome ResolveDraw(EventResult result) =>
result.WinnerSide == Side.Draw ? BetOutcome.Won : BetOutcome.Lost;
private static BetOutcome ResolveFora(Side side, decimal handicap, EventResult result)
{
// Adjusted score for the side that took the handicap.
var (own, opponent) = side == Side.Side1
? (result.Side1Score, result.Side2Score)
: (result.Side2Score, result.Side1Score);
var adjusted = own + handicap;
if (adjusted > opponent) return BetOutcome.Won;
if (adjusted == opponent) return BetOutcome.Void;
return BetOutcome.Lost;
}
private static BetOutcome ResolveTotal(Side side, decimal threshold, EventResult result)
{
var total = (decimal)(result.Side1Score + result.Side2Score);
// More wins when total > threshold; Less wins when total < threshold.
// Equality is a push (Void) for both sides.
if (total == threshold) return BetOutcome.Void;
var totalIsOver = total > threshold;
return side switch
{
Side.More => totalIsOver ? BetOutcome.Won : BetOutcome.Lost,
Side.Less => totalIsOver ? BetOutcome.Lost : BetOutcome.Won,
_ => BetOutcome.Lost, // Defensive — Bet invariant rejects other sides for Total.
};
}
}
@@ -0,0 +1,82 @@
namespace Marathon.Domain.Betting;
/// <summary>
/// Pure fractional-Kelly stake sizing for a single back bet at decimal odds.
/// </summary>
/// <remarks>
/// <para>
/// The Kelly criterion maximises the long-run growth rate of a bankroll by staking
/// a fraction of it proportional to the edge. For decimal odds <c>o</c> and an
/// estimated win probability <c>p</c>, the full-Kelly fraction of bankroll is:
/// </para>
/// <code>f* = (p·o 1) / (o 1)</code>
/// <para>
/// When <c>f*</c> is zero or negative there is no positive expected value, and the
/// suggested stake is <c>0</c> — the calculator never recommends betting into a
/// negative-EV price. Most disciplined bettors stake a <i>fraction</i> of full
/// Kelly (e.g. quarter-Kelly, <c>fraction = 0.25</c>) to cut variance and blunt the
/// impact of probability-estimation error; full Kelly is famously over-aggressive
/// once <c>p</c> is even slightly wrong.
/// </para>
/// <para>
/// The win probability is an input the bettor supplies — it is intentionally NOT
/// derived from an anomaly score here, so the calculator stays a pure, reusable
/// money-management primitive independent of any signal source.
/// </para>
/// </remarks>
public static class KellyCalculator
{
/// <summary>Default Kelly fraction: quarter-Kelly — the conventional variance-safe choice.</summary>
public const decimal DefaultFraction = 0.25m;
/// <summary>
/// Full-Kelly fraction of bankroll <c>(p·o 1)/(o 1)</c>. May be negative or
/// zero, signalling no positive edge. Exposed for callers that want the raw
/// figure (e.g. to display the edge) rather than a clamped stake.
/// </summary>
/// <param name="winProbability">Estimated win probability in the closed interval [0, 1].</param>
/// <param name="decimalOdds">Decimal odds, strictly greater than 1.0.</param>
public static decimal FullKellyFraction(decimal winProbability, decimal decimalOdds)
{
if (winProbability is < 0m or > 1m)
throw new ArgumentOutOfRangeException(nameof(winProbability), winProbability, "Must be in [0, 1].");
if (decimalOdds <= 1m)
throw new ArgumentOutOfRangeException(nameof(decimalOdds), decimalOdds, "Decimal odds must be greater than 1.0.");
return (winProbability * decimalOdds - 1m) / (decimalOdds - 1m);
}
/// <summary>
/// Suggested stake using fractional Kelly, rounded down to two decimals so the
/// suggestion is never larger than the theoretical figure. Returns <c>0</c> when
/// there is no positive edge.
/// </summary>
/// <param name="winProbability">Estimated win probability in the open interval (0, 1).</param>
/// <param name="decimalOdds">Decimal odds, strictly greater than 1.0.</param>
/// <param name="bankroll">Total bankroll; must be non-negative.</param>
/// <param name="fraction">Kelly fraction in (0, 1]; defaults to <see cref="DefaultFraction"/>.</param>
public static decimal SuggestStake(
decimal winProbability,
decimal decimalOdds,
decimal bankroll,
decimal fraction = DefaultFraction)
{
if (winProbability is <= 0m or >= 1m)
throw new ArgumentOutOfRangeException(nameof(winProbability), winProbability, "Must be in the open interval (0, 1).");
if (bankroll < 0m)
throw new ArgumentOutOfRangeException(nameof(bankroll), bankroll, "Must be non-negative.");
if (fraction is <= 0m or > 1m)
throw new ArgumentOutOfRangeException(nameof(fraction), fraction, "Kelly fraction must be in (0, 1].");
// FullKellyFraction validates decimalOdds.
var full = FullKellyFraction(winProbability, decimalOdds);
if (full <= 0m)
return 0m;
var stake = fraction * full * bankroll;
// Truncate (floor toward zero) to two decimals so a stake suggestion never
// exceeds the computed figure — a conservative bias for real-money sizing.
return Math.Truncate(stake * 100m) / 100m;
}
}
+7
View File
@@ -63,4 +63,11 @@ public sealed record Event(
/// numeric event ID.
/// </remarks>
public string? EventPath { get; init; }
/// <summary>
/// Display title in the canonical "{Side1Name} vs {Side2Name}" form. Single
/// source for the home-vs-away join that was previously duplicated across the
/// report use cases and list/feed services.
/// </summary>
public string Title => $"{Side1Name} vs {Side2Name}";
}
+89
View File
@@ -0,0 +1,89 @@
using Marathon.Domain.Enums;
using Marathon.Domain.ValueObjects;
namespace Marathon.Domain.Entities;
/// <summary>
/// A wager the user manually recorded as having placed (with this or another
/// bookmaker). Reuses the <see cref="Bet"/> vocabulary so the journal can mirror
/// scraped markets directly — same Scope / Type / Side / Value / Rate invariants
/// apply to <see cref="Selection"/>.
/// </summary>
/// <param name="Id">Stable identifier — Guid so duplicates can be detected by the UI.</param>
/// <param name="EventId">Event the wager is on.</param>
/// <param name="Selection">
/// The market + rate the user took. <c>Selection.Rate</c> is the "taken rate"
/// used for ROI and CLV calculations.
/// </param>
/// <param name="Stake">
/// Money risked, in the user's currency. The domain does not encode currency —
/// stake values are compared as raw decimals.
/// </param>
/// <param name="PlacedAt">When the bet was recorded. Stored as Moscow time.</param>
/// <param name="Outcome">Current settlement state — see <see cref="BetOutcome"/>.</param>
/// <param name="Notes">Optional free text — strategy tag, source, etc.</param>
public sealed record PlacedBet(
Guid Id,
EventId EventId,
Bet Selection,
decimal Stake,
DateTimeOffset PlacedAt,
BetOutcome Outcome,
string? Notes)
{
public Guid Id { get; } = Id == Guid.Empty
? throw new ArgumentException("PlacedBet Id must not be an empty GUID.", nameof(Id))
: Id;
public EventId EventId { get; } = EventId ?? throw new ArgumentNullException(nameof(EventId));
public Bet Selection { get; } = Selection ?? throw new ArgumentNullException(nameof(Selection));
public decimal Stake { get; } = Stake > 0m
? Stake
: throw new ArgumentOutOfRangeException(nameof(Stake), Stake,
"Stake must be positive.");
public DateTimeOffset PlacedAt { get; } = PlacedAt.Offset == MoscowTime.Offset
? PlacedAt
: throw new ArgumentException(
$"PlacedAt must be in Europe/Moscow time (UTC+03:00). " +
$"Received offset: {PlacedAt.Offset:hh\\:mm}.",
nameof(PlacedAt));
public BetOutcome Outcome { get; } = Outcome;
public string? Notes { get; } = string.IsNullOrWhiteSpace(Notes) ? null : Notes;
/// <summary>
/// Gross return on this bet for the current outcome — the amount the
/// bookmaker pays back to the user (stake + winnings).
/// <list type="bullet">
/// <item><see cref="BetOutcome.Won"/>: <c>Stake × Rate</c></item>
/// <item><see cref="BetOutcome.Void"/>: <c>Stake</c> (push — stake returned)</item>
/// <item><see cref="BetOutcome.Lost"/>: <c>0</c></item>
/// <item><see cref="BetOutcome.Pending"/>: <c>null</c> (unknown)</item>
/// </list>
/// </summary>
public decimal? GrossReturn => Outcome switch
{
BetOutcome.Won => Stake * Selection.Rate.Value,
BetOutcome.Void => Stake,
BetOutcome.Lost => 0m,
_ => null,
};
/// <summary>
/// Net profit for the current outcome — <see cref="GrossReturn"/> minus
/// <see cref="Stake"/>. Negative for losses. Null while pending.
/// </summary>
public decimal? NetProfit => GrossReturn is null ? null : GrossReturn.Value - Stake;
/// <summary>
/// Returns a copy with a new <see cref="Outcome"/> — used by the resolver
/// use case after grading the event. Constructs explicitly because the
/// manual validating <c>get</c>-only properties prevent <c>with</c>.
/// </summary>
public PlacedBet WithOutcome(BetOutcome outcome) =>
new(Id, EventId, Selection, Stake, PlacedAt, outcome, Notes);
}
+6
View File
@@ -10,4 +10,10 @@ public enum AnomalyKind
/// Bookmaker suspended the market, then flipped the underdog/favourite coefficients.
/// </summary>
SuspensionFlip,
/// <summary>
/// A rapid, one-directional drift in a side's implied probability over a short
/// continuous window (no suspension) — money moving the line ("steam").
/// </summary>
SteamMove,
}
+24
View File
@@ -0,0 +1,24 @@
namespace Marathon.Domain.Enums;
/// <summary>
/// Settlement status of a user-tracked <see cref="Marathon.Domain.Entities.PlacedBet"/>.
/// </summary>
public enum BetOutcome
{
/// <summary>
/// The event has not been graded yet, or the bet has not been auto-resolved
/// yet. Default state for a freshly recorded bet.
/// </summary>
Pending,
/// <summary>The selection won — stake returned plus winnings.</summary>
Won,
/// <summary>The selection lost — stake is forfeit.</summary>
Lost,
/// <summary>
/// Handicap/total push or event abandoned — stake returned, no profit/loss.
/// </summary>
Void,
}
@@ -0,0 +1,57 @@
using Marathon.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Marathon.Infrastructure.Migrations;
/// <inheritdoc />
[DbContext(typeof(MarathonDbContext))]
[Migration("20260516000000_AddPlacedBets")]
public partial class AddPlacedBets : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "PlacedBets",
columns: table => new
{
Id = table.Column<string>(type: "TEXT", nullable: false),
EventCode = table.Column<string>(type: "TEXT", nullable: false),
Scope = table.Column<int>(type: "INTEGER", nullable: false),
PeriodNumber = table.Column<int>(type: "INTEGER", nullable: true),
Type = table.Column<int>(type: "INTEGER", nullable: false),
Side = table.Column<int>(type: "INTEGER", nullable: false),
Value = table.Column<decimal>(type: "TEXT", nullable: true),
Rate = table.Column<decimal>(type: "TEXT", nullable: false),
Stake = table.Column<decimal>(type: "TEXT", nullable: false),
PlacedAt = table.Column<string>(type: "TEXT", nullable: false),
Outcome = table.Column<int>(type: "INTEGER", nullable: false),
Notes = table.Column<string>(type: "TEXT", nullable: true),
},
constraints: table =>
{
table.PrimaryKey("PK_PlacedBets", x => x.Id);
// No foreign key to Events — the journal is user data and must
// survive snapshot retention pruning the source event row.
});
migrationBuilder.CreateIndex(
name: "IX_PlacedBets_EventCode",
table: "PlacedBets",
column: "EventCode");
migrationBuilder.CreateIndex(
name: "IX_PlacedBets_Outcome",
table: "PlacedBets",
column: "Outcome");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(name: "PlacedBets");
}
}
@@ -0,0 +1,43 @@
using Marathon.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Marathon.Infrastructure.Migrations;
/// <inheritdoc />
[DbContext(typeof(MarathonDbContext))]
[Migration("20260528000000_AddSnapshotCapturedAtIndexes")]
public partial class AddSnapshotCapturedAtIndexes : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
// Composite index for the dominant read shape: filter by EventCode + a
// CapturedAt range, frequently with ORDER BY CapturedAt. Lets SQLite serve
// both the predicate and the ordering from the index rather than scanning.
migrationBuilder.CreateIndex(
name: "IX_Snapshots_EventCode_CapturedAt",
table: "Snapshots",
columns: new[] { "EventCode", "CapturedAt" });
// Covers GetLatestPreMatchAsync: EventCode + Source filter, ORDER BY CapturedAt DESC.
migrationBuilder.CreateIndex(
name: "IX_Snapshots_EventCode_Source_CapturedAt",
table: "Snapshots",
columns: new[] { "EventCode", "Source", "CapturedAt" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_Snapshots_EventCode_Source_CapturedAt",
table: "Snapshots");
migrationBuilder.DropIndex(
name: "IX_Snapshots_EventCode_CapturedAt",
table: "Snapshots");
}
}
@@ -92,6 +92,8 @@ partial class MarathonDbContextModelSnapshot : ModelSnapshot
b.Property<int>("Source").HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("EventCode").HasDatabaseName("IX_Snapshots_EventCode");
b.HasIndex("EventCode", "CapturedAt").HasDatabaseName("IX_Snapshots_EventCode_CapturedAt");
b.HasIndex("EventCode", "Source", "CapturedAt").HasDatabaseName("IX_Snapshots_EventCode_Source_CapturedAt");
b.ToTable("Snapshots");
});
@@ -104,6 +106,26 @@ partial class MarathonDbContextModelSnapshot : ModelSnapshot
b.ToTable("Sports");
});
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.PlacedBetEntity", b =>
{
b.Property<string>("Id").HasColumnType("TEXT");
b.Property<string>("EventCode").IsRequired().HasColumnType("TEXT");
b.Property<int>("Scope").HasColumnType("INTEGER");
b.Property<int?>("PeriodNumber").HasColumnType("INTEGER");
b.Property<int>("Type").HasColumnType("INTEGER");
b.Property<int>("Side").HasColumnType("INTEGER");
b.Property<decimal?>("Value").HasColumnType("TEXT");
b.Property<decimal>("Rate").HasColumnType("TEXT");
b.Property<decimal>("Stake").HasColumnType("TEXT");
b.Property<string>("PlacedAt").IsRequired().HasColumnType("TEXT");
b.Property<int>("Outcome").HasColumnType("INTEGER");
b.Property<string>("Notes").HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("EventCode").HasDatabaseName("IX_PlacedBets_EventCode");
b.HasIndex("Outcome").HasDatabaseName("IX_PlacedBets_Outcome");
b.ToTable("PlacedBets");
});
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.AnomalyEntity", b =>
{
b.HasOne("Marathon.Infrastructure.Persistence.Entities.EventEntity", "Event")
@@ -0,0 +1,35 @@
using Marathon.Infrastructure.Persistence.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Marathon.Infrastructure.Persistence.Configurations;
internal sealed class PlacedBetConfiguration : IEntityTypeConfiguration<PlacedBetEntity>
{
public void Configure(EntityTypeBuilder<PlacedBetEntity> builder)
{
builder.ToTable("PlacedBets");
builder.HasKey(b => b.Id);
builder.Property(b => b.Id).HasColumnType("TEXT").IsRequired();
builder.Property(b => b.EventCode).HasColumnType("TEXT").IsRequired();
builder.Property(b => b.Scope).HasColumnType("INTEGER").IsRequired();
builder.Property(b => b.PeriodNumber).HasColumnType("INTEGER");
builder.Property(b => b.Type).HasColumnType("INTEGER").IsRequired();
builder.Property(b => b.Side).HasColumnType("INTEGER").IsRequired();
builder.Property(b => b.Value).HasColumnType("TEXT");
builder.Property(b => b.Rate).HasColumnType("TEXT").IsRequired();
builder.Property(b => b.Stake).HasColumnType("TEXT").IsRequired();
builder.Property(b => b.PlacedAt).HasColumnType("TEXT").IsRequired();
builder.Property(b => b.Outcome).HasColumnType("INTEGER").IsRequired();
builder.Property(b => b.Notes).HasColumnType("TEXT");
// EventCode is intentionally NOT a foreign key — the journal is the
// user's data and must survive snapshot retention pruning the source
// event row. Existence is checked once at insert time by the use case.
builder.HasIndex(b => b.EventCode).HasDatabaseName("IX_PlacedBets_EventCode");
builder.HasIndex(b => b.Outcome).HasDatabaseName("IX_PlacedBets_Outcome");
}
}
@@ -18,6 +18,17 @@ internal sealed class SnapshotConfiguration : IEntityTypeConfiguration<SnapshotE
builder.HasIndex(s => s.EventCode).HasDatabaseName("IX_Snapshots_EventCode");
// Snapshots is the largest table (live cadence 510s, 90-day retention) and
// every hot read filters EventCode + CapturedAt range, often with an ORDER BY
// CapturedAt. These composite indexes let SQLite satisfy the filter and the
// ordering from the index instead of scanning + sorting the table.
builder.HasIndex(s => new { s.EventCode, s.CapturedAt })
.HasDatabaseName("IX_Snapshots_EventCode_CapturedAt");
// Covers GetLatestPreMatchAsync: EventCode + Source filter, ORDER BY CapturedAt DESC.
builder.HasIndex(s => new { s.EventCode, s.Source, s.CapturedAt })
.HasDatabaseName("IX_Snapshots_EventCode_Source_CapturedAt");
builder.HasMany(s => s.Bets)
.WithOne(b => b.Snapshot)
.HasForeignKey(b => b.SnapshotId)
@@ -0,0 +1,47 @@
namespace Marathon.Infrastructure.Persistence.Entities;
/// <summary>
/// EF Core persistence entity for a user-tracked <see cref="Marathon.Domain.Entities.PlacedBet"/>.
/// Flattens the embedded <c>Bet</c> selection (Scope / Type / Side / Value / Rate)
/// into columns so SQLite can index by event and outcome cheaply.
/// </summary>
public sealed class PlacedBetEntity
{
/// <summary>GUID primary key stored as TEXT.</summary>
public string Id { get; set; } = default!;
/// <summary>Foreign key to <see cref="EventEntity.EventCode"/>.</summary>
public string EventCode { get; set; } = default!;
// ─── Embedded Bet selection ──────────────────────────────────────────────
/// <summary>Scope discriminator: 0 = Match, 1 = Period.</summary>
public int Scope { get; set; }
/// <summary>Period number when <see cref="Scope"/> = 1; null otherwise.</summary>
public int? PeriodNumber { get; set; }
/// <summary>BetType as int (Win / Draw / WinFora / Total).</summary>
public int Type { get; set; }
/// <summary>Side as int (Side1 / Side2 / Draw / Less / More).</summary>
public int Side { get; set; }
/// <summary>Handicap or total threshold; null for Win / Draw markets.</summary>
public decimal? Value { get; set; }
/// <summary>Decimal odds the user took.</summary>
public decimal Rate { get; set; }
// ─── Wager fields ────────────────────────────────────────────────────────
/// <summary>Stake in the user's currency.</summary>
public decimal Stake { get; set; }
/// <summary>ISO 8601 timestamp when the bet was recorded (Moscow time).</summary>
public string PlacedAt { get; set; } = default!;
/// <summary>BetOutcome as int (Pending / Won / Lost / Void).</summary>
public int Outcome { get; set; }
/// <summary>Optional free-text note from the user.</summary>
public string? Notes { get; set; }
}
@@ -1,4 +1,3 @@
using System.Globalization;
using Marathon.Domain.Entities;
using Marathon.Domain.Enums;
using Marathon.Domain.ValueObjects;
@@ -10,16 +9,15 @@ namespace Marathon.Infrastructure.Persistence;
/// Mapping helpers that translate between domain objects and EF Core persistence entities.
/// Domain invariants are enforced on the domain side; mapping is purely structural.
/// </summary>
/// <remarks>
/// ScheduledAt / CapturedAt / DetectedAt / CompletedAt / PlacedAt are encoded and
/// decoded exclusively through <see cref="SqliteDateText"/> so the write format and
/// the repositories' range-predicate format can never drift apart.
/// </remarks>
internal static class Mapping
{
// ScheduledAt / CapturedAt / DetectedAt / CompletedAt are written via
// DateTimeOffset.ToString("O") — round-trip ISO 8601. Parse with the
// invariant culture and RoundtripKind so a non-en-US thread culture
// (or a future locale change) cannot corrupt the round-trip.
private const DateTimeStyles RoundtripStyles = DateTimeStyles.RoundtripKind;
// ─── Bet scope discriminator constants ────────────────────────────────────
private const int ScopeMatch = 0;
private const int ScopeMatch = 0;
private const int ScopePeriod = 1;
// ─── Event ───────────────────────────────────────────────────────────────
@@ -31,7 +29,7 @@ internal static class Mapping
CountryCode = domain.CountryCode,
LeagueId = domain.LeagueId,
Category = domain.Category,
ScheduledAt = domain.ScheduledAt.ToString("O"),
ScheduledAt = SqliteDateText.Key(domain.ScheduledAt),
Side1Name = domain.Side1Name,
Side2Name = domain.Side2Name,
EventPath = domain.EventPath,
@@ -44,7 +42,7 @@ internal static class Mapping
CountryCode: entity.CountryCode,
LeagueId: entity.LeagueId,
Category: entity.Category,
ScheduledAt: DateTimeOffset.Parse(entity.ScheduledAt, CultureInfo.InvariantCulture, RoundtripStyles),
ScheduledAt: SqliteDateText.Parse(entity.ScheduledAt),
Side1Name: entity.Side1Name,
Side2Name: entity.Side2Name)
{
@@ -57,7 +55,7 @@ internal static class Mapping
new()
{
EventCode = domain.EventId.Value,
CapturedAt = domain.CapturedAt.ToString("O"),
CapturedAt = SqliteDateText.Key(domain.CapturedAt),
Source = (int)domain.Source,
Bets = domain.Bets.Select(ToEntity).ToList(),
};
@@ -65,7 +63,7 @@ internal static class Mapping
public static OddsSnapshot ToDomain(SnapshotEntity entity) =>
new(
eventId: new EventId(entity.EventCode),
capturedAt: DateTimeOffset.Parse(entity.CapturedAt, CultureInfo.InvariantCulture, RoundtripStyles),
capturedAt: SqliteDateText.Parse(entity.CapturedAt),
source: (OddsSource)entity.Source,
bets: entity.Bets.Select(ToDomain).ToList().AsReadOnly());
@@ -86,7 +84,7 @@ internal static class Mapping
{
var scope = entity.Scope switch
{
ScopeMatch => (BetScope)MatchScope.Instance,
ScopeMatch => (BetScope)MatchScope.Instance,
ScopePeriod => new PeriodScope(entity.PeriodNumber!.Value),
_ => throw new InvalidOperationException(
$"Unknown BetScope discriminator: {entity.Scope}"),
@@ -109,7 +107,7 @@ internal static class Mapping
Side1Score = domain.Side1Score,
Side2Score = domain.Side2Score,
WinnerSide = (int)domain.WinnerSide,
CompletedAt = domain.CompletedAt.ToString("O"),
CompletedAt = SqliteDateText.Key(domain.CompletedAt),
};
public static EventResult ToDomain(EventResultEntity entity) =>
@@ -118,7 +116,7 @@ internal static class Mapping
Side1Score: entity.Side1Score,
Side2Score: entity.Side2Score,
WinnerSide: (Side)entity.WinnerSide,
CompletedAt: DateTimeOffset.Parse(entity.CompletedAt, CultureInfo.InvariantCulture, RoundtripStyles));
CompletedAt: SqliteDateText.Parse(entity.CompletedAt));
// ─── Anomaly ──────────────────────────────────────────────────────────────
@@ -127,7 +125,7 @@ internal static class Mapping
{
Id = domain.Id.ToString(),
EventCode = domain.EventId.Value,
DetectedAt = domain.DetectedAt.ToString("O"),
DetectedAt = SqliteDateText.Key(domain.DetectedAt),
Kind = (int)domain.Kind,
Score = domain.Score,
EvidenceJson = domain.EvidenceJson,
@@ -137,7 +135,7 @@ internal static class Mapping
new(
Id: Guid.Parse(entity.Id),
EventId: new EventId(entity.EventCode),
DetectedAt: DateTimeOffset.Parse(entity.DetectedAt, CultureInfo.InvariantCulture, RoundtripStyles),
DetectedAt: SqliteDateText.Parse(entity.DetectedAt),
Kind: (AnomalyKind)entity.Kind,
Score: entity.Score,
EvidenceJson: entity.EvidenceJson);
@@ -158,6 +156,51 @@ internal static class Mapping
NameRu: entity.NameRu,
NameEn: entity.NameEn);
// ─── PlacedBet ────────────────────────────────────────────────────────────
public static PlacedBetEntity ToEntity(PlacedBet domain) =>
new()
{
Id = domain.Id.ToString(),
EventCode = domain.EventId.Value,
Scope = domain.Selection.Scope is MatchScope ? ScopeMatch : ScopePeriod,
PeriodNumber = domain.Selection.Scope is PeriodScope ps ? ps.Number : null,
Type = (int)domain.Selection.Type,
Side = (int)domain.Selection.Side,
Value = domain.Selection.Value?.Value,
Rate = domain.Selection.Rate.Value,
Stake = domain.Stake,
PlacedAt = SqliteDateText.Key(domain.PlacedAt),
Outcome = (int)domain.Outcome,
Notes = domain.Notes,
};
public static PlacedBet ToDomain(PlacedBetEntity entity)
{
var scope = entity.Scope switch
{
ScopeMatch => (BetScope)MatchScope.Instance,
ScopePeriod => new PeriodScope(entity.PeriodNumber!.Value),
_ => throw new InvalidOperationException(
$"Unknown BetScope discriminator: {entity.Scope}"),
};
var value = entity.Value.HasValue ? new OddsValue(entity.Value.Value) : null;
var rate = new OddsRate(entity.Rate);
var type = (BetType)entity.Type;
var side = (Side)entity.Side;
var selection = new Bet(scope, type, side, value, rate);
return new PlacedBet(
Id: Guid.Parse(entity.Id),
EventId: new EventId(entity.EventCode),
Selection: selection,
Stake: entity.Stake,
PlacedAt: SqliteDateText.Parse(entity.PlacedAt),
Outcome: (BetOutcome)entity.Outcome,
Notes: entity.Notes);
}
// ─── League ───────────────────────────────────────────────────────────────
public static LeagueEntity ToEntity(League domain) =>
@@ -18,6 +18,7 @@ public sealed class MarathonDbContext : DbContext
public DbSet<AnomalyEntity> Anomalies => Set<AnomalyEntity>();
public DbSet<SportEntity> Sports => Set<SportEntity>();
public DbSet<LeagueEntity> Leagues => Set<LeagueEntity>();
public DbSet<PlacedBetEntity> PlacedBets => Set<PlacedBetEntity>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
@@ -53,6 +53,7 @@ public static class PersistenceModule
services.AddScoped<ISnapshotRepository, SnapshotRepository>();
services.AddScoped<IResultRepository, ResultRepository>();
services.AddScoped<IAnomalyRepository, AnomalyRepository>();
services.AddScoped<IPlacedBetRepository, PlacedBetRepository>();
services.AddScoped<IExcelExporter, ExcelExporter>();
return services;
@@ -23,6 +23,44 @@ internal sealed class AnomalyRepository : IAnomalyRepository
return entities.Select(Mapping.ToDomain).ToList().AsReadOnly();
}
public async Task<int> CountSinceAsync(DateTimeOffset since, CancellationToken ct = default)
{
// Server-side COUNT(*) — the unread-badge hot path must not materialise the
// whole table (with EvidenceJson) just to count. DetectedAt is stored as the
// O-format TEXT key (see SqliteDateText); ">" matches the prior in-memory
// GetUnreadCountAsync semantics (strictly newer than the last-seen marker).
var sinceStr = SqliteDateText.Key(since);
return await _db.Anomalies.AsNoTracking()
.Where(a => a.DetectedAt.CompareTo(sinceStr) > 0)
.CountAsync(ct);
}
public async Task<IReadOnlyList<Anomaly>> ListByDateRangeAsync(
DateTimeOffset? from,
DateTimeOffset? to,
CancellationToken ct = default)
{
var q = _db.Anomalies.AsNoTracking();
if (from is { } f)
{
var fromStr = SqliteDateText.Key(f);
q = q.Where(a => a.DetectedAt.CompareTo(fromStr) >= 0);
}
if (to is { } t)
{
var toStr = SqliteDateText.Key(t);
q = q.Where(a => a.DetectedAt.CompareTo(toStr) <= 0);
}
var entities = await q
.OrderByDescending(a => a.DetectedAt)
.ToListAsync(ct);
return entities.Select(Mapping.ToDomain).ToList().AsReadOnly();
}
public async Task AddAsync(Anomaly entity, CancellationToken ct = default)
{
var efEntity = Mapping.ToEntity(entity);
@@ -26,9 +26,10 @@ internal sealed class EventRepository : IEventRepository
public async Task<IReadOnlyList<Event>> ListByDateRangeAsync(DateRange range, CancellationToken ct = default)
{
// ScheduledAt is stored as ISO 8601 TEXT; SQLite TEXT comparison sorts correctly for ISO 8601.
var fromStr = range.From.ToString("O");
var toStr = range.To.ToString("O");
// ScheduledAt is stored as ISO 8601 TEXT (see SqliteDateText); SQLite TEXT
// comparison sorts chronologically for the fixed-offset O format.
var fromStr = SqliteDateText.Key(range.From);
var toStr = SqliteDateText.Key(range.To);
// EF Core SQLite cannot translate string.Compare(...) with StringComparison; it can
// translate the relational operators on string columns (which use BINARY/ordinal
@@ -41,6 +42,57 @@ internal sealed class EventRepository : IEventRepository
return entities.Select(Mapping.ToDomain).ToList().AsReadOnly();
}
public async Task<IReadOnlyList<Event>> QueryAsync(EventQuery query, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(query);
var fromStr = SqliteDateText.Key(query.Dates.From);
var toStr = SqliteDateText.Key(query.Dates.To);
// Date range + sport filter pushed to SQL so a multi-sport page no longer
// materialises every event in the window. The composite
// IX_Events_SportCode_ScheduledAt index covers this predicate. Case-sensitive
// search / country filtering and locale-aware sorting stay in the service
// layer where Cyrillic ordinal semantics are preserved.
var q = _db.Events.AsNoTracking()
.Where(e => e.ScheduledAt.CompareTo(fromStr) >= 0
&& e.ScheduledAt.CompareTo(toStr) <= 0);
if (query.SportCodes is { Count: > 0 } sports)
{
var sportArray = sports.Distinct().ToArray();
q = q.Where(e => sportArray.Contains(e.SportCode));
}
var entities = await q.ToListAsync(ct);
return entities.Select(Mapping.ToDomain).ToList().AsReadOnly();
}
public async Task<IReadOnlyDictionary<EventId, Event>> GetManyAsync(
IReadOnlyCollection<EventId> ids,
CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(ids);
var result = new Dictionary<EventId, Event>(ids.Count);
if (ids.Count == 0)
return result;
var codes = ids.Select(e => e.Value).Distinct().ToArray();
var entities = await _db.Events.AsNoTracking()
.Where(e => codes.Contains(e.EventCode))
.ToListAsync(ct);
foreach (var entity in entities)
{
var domain = Mapping.ToDomain(entity);
result[domain.Id] = domain;
}
return result;
}
public async Task<IReadOnlyList<Event>> ListBySportAsync(SportCode sport, CancellationToken ct = default)
{
var entities = await _db.Events.AsNoTracking()
@@ -50,6 +102,9 @@ internal sealed class EventRepository : IEventRepository
return entities.Select(Mapping.ToDomain).ToList().AsReadOnly();
}
public Task<int> CountAsync(CancellationToken ct = default) =>
_db.Events.AsNoTracking().CountAsync(ct);
public async Task<IReadOnlyList<int>> ListDistinctSportCodesAsync(CancellationToken ct = default)
{
var codes = await _db.Events.AsNoTracking()
@@ -0,0 +1,87 @@
using Marathon.Application.Abstractions;
using Marathon.Application.Storage;
using Marathon.Domain.Entities;
using Marathon.Domain.Enums;
using Marathon.Domain.ValueObjects;
using Microsoft.EntityFrameworkCore;
namespace Marathon.Infrastructure.Persistence.Repositories;
internal sealed class PlacedBetRepository : IPlacedBetRepository
{
private readonly MarathonDbContext _db;
public PlacedBetRepository(MarathonDbContext db) => _db = db;
public async Task<PlacedBet?> GetAsync(Guid key, CancellationToken ct = default)
{
var idStr = key.ToString();
// AsNoTracking so callers can re-map and UpdateAsync without tripping
// EF's "another instance with the same key is already tracked" guard.
var entity = await _db.PlacedBets.AsNoTracking()
.FirstOrDefaultAsync(b => b.Id == idStr, ct);
return entity is null ? null : Mapping.ToDomain(entity);
}
public async Task<IReadOnlyList<PlacedBet>> ListAsync(CancellationToken ct = default)
{
var entities = await _db.PlacedBets.AsNoTracking().ToListAsync(ct);
return entities.Select(Mapping.ToDomain).ToList().AsReadOnly();
}
public async Task<IReadOnlyList<PlacedBet>> ListByOutcomeAsync(BetOutcome outcome, CancellationToken ct = default)
{
var outcomeInt = (int)outcome;
var entities = await _db.PlacedBets.AsNoTracking()
.Where(b => b.Outcome == outcomeInt)
.ToListAsync(ct);
return entities.Select(Mapping.ToDomain).ToList().AsReadOnly();
}
public async Task<IReadOnlyList<PlacedBet>> ListByDateRangeAsync(DateRange range, CancellationToken ct = default)
{
// PlacedAt is stored as ISO 8601 TEXT — same lexical-equals-chronological ordering
// trick used in EventRepository.ListByDateRangeAsync.
var fromStr = range.From.ToString("O");
var toStr = range.To.ToString("O");
var entities = await _db.PlacedBets.AsNoTracking()
.Where(b => b.PlacedAt.CompareTo(fromStr) >= 0
&& b.PlacedAt.CompareTo(toStr) <= 0)
.ToListAsync(ct);
return entities.Select(Mapping.ToDomain).ToList().AsReadOnly();
}
public async Task<IReadOnlyList<PlacedBet>> ListByEventAsync(EventId eventId, CancellationToken ct = default)
{
var entities = await _db.PlacedBets.AsNoTracking()
.Where(b => b.EventCode == eventId.Value)
.ToListAsync(ct);
return entities.Select(Mapping.ToDomain).ToList().AsReadOnly();
}
public async Task AddAsync(PlacedBet entity, CancellationToken ct = default)
{
var efEntity = Mapping.ToEntity(entity);
await _db.PlacedBets.AddAsync(efEntity, ct);
}
public Task UpdateAsync(PlacedBet entity, CancellationToken ct = default)
{
var efEntity = Mapping.ToEntity(entity);
_db.PlacedBets.Update(efEntity);
return Task.CompletedTask;
}
public async Task DeleteAsync(Guid key, CancellationToken ct = default)
{
var idStr = key.ToString();
var entity = await _db.PlacedBets.FirstOrDefaultAsync(b => b.Id == idStr, ct);
if (entity is not null)
_db.PlacedBets.Remove(entity);
}
public async Task SaveChangesAsync(CancellationToken ct = default) =>
await _db.SaveChangesAsync(ct);
}
@@ -23,6 +23,31 @@ internal sealed class ResultRepository : IResultRepository
return entities.Select(Mapping.ToDomain).ToList().AsReadOnly();
}
public async Task<IReadOnlyDictionary<EventId, EventResult>> GetManyAsync(
IReadOnlyCollection<EventId> ids,
CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(ids);
var result = new Dictionary<EventId, EventResult>(ids.Count);
if (ids.Count == 0)
return result;
var codes = ids.Select(e => e.Value).Distinct().ToArray();
var entities = await _db.EventResults.AsNoTracking()
.Where(r => codes.Contains(r.EventCode))
.ToListAsync(ct);
foreach (var entity in entities)
{
var domain = Mapping.ToDomain(entity);
result[domain.EventId] = domain;
}
return result;
}
public async Task AddAsync(EventResult entity, CancellationToken ct = default)
{
var efEntity = Mapping.ToEntity(entity);
@@ -19,14 +19,22 @@ internal sealed class SnapshotRepository : ISnapshotRepository
return entities.Select(Mapping.ToDomain).ToList().AsReadOnly();
}
public Task<int> CountSinceAsync(DateTimeOffset since, CancellationToken ct = default)
{
var sinceStr = SqliteDateText.Key(since);
return _db.Snapshots.AsNoTracking()
.Where(s => s.CapturedAt.CompareTo(sinceStr) >= 0)
.CountAsync(ct);
}
public async Task<IReadOnlyList<OddsSnapshot>> ListByEventAsync(
EventId eventId,
DateTimeOffset from,
DateTimeOffset to,
CancellationToken ct = default)
{
var fromStr = from.ToString("O");
var toStr = to.ToString("O");
var fromStr = SqliteDateText.Key(from);
var toStr = SqliteDateText.Key(to);
var entities = await _db.Snapshots.AsNoTracking()
.Include(s => s.Bets)
@@ -51,8 +59,8 @@ internal sealed class SnapshotRepository : ISnapshotRepository
return result;
var ids = eventIds.Select(e => e.Value).Distinct().ToArray();
var fromStr = from.ToString("O");
var toStr = to.ToString("O");
var fromStr = SqliteDateText.Key(from);
var toStr = SqliteDateText.Key(to);
var entities = await _db.Snapshots.AsNoTracking()
.Include(s => s.Bets)
@@ -83,4 +91,26 @@ internal sealed class SnapshotRepository : ISnapshotRepository
public async Task SaveChangesAsync(CancellationToken ct = default) =>
await _db.SaveChangesAsync(ct);
public async Task<OddsSnapshot?> GetLatestPreMatchAsync(
EventId eventId,
DateTimeOffset atOrBefore,
CancellationToken ct = default)
{
// OddsSource enum: PreMatch == 0. Inlined as an int constant to keep the
// expression EF-translatable (the IL would otherwise carry a cast).
const int preMatchSource = (int)Marathon.Domain.Enums.OddsSource.PreMatch;
var toStr = SqliteDateText.Key(atOrBefore);
var entity = await _db.Snapshots.AsNoTracking()
.Include(s => s.Bets)
.Where(s => s.EventCode == eventId.Value
&& s.Source == preMatchSource
&& s.CapturedAt.CompareTo(toStr) <= 0)
.OrderByDescending(s => s.CapturedAt)
.FirstOrDefaultAsync(ct);
return entity is null ? null : Mapping.ToDomain(entity);
}
}
@@ -0,0 +1,42 @@
using System.Globalization;
namespace Marathon.Infrastructure.Persistence;
/// <summary>
/// Single source of truth for how <see cref="DateTimeOffset"/> values are encoded
/// as the TEXT used by both the write path (<see cref="Mapping"/>) and the
/// date-range predicates / ORDER BY clauses in the repositories.
/// </summary>
/// <remarks>
/// <para>
/// Dates are stored as round-trip ISO-8601 (<c>"O"</c> format) TEXT. SQLite TEXT
/// columns use BINARY (ordinal) collation by default, so the relational operators
/// (<c>&gt;=</c>, <c>&lt;=</c>) and <c>ORDER BY</c> on these strings sort
/// <b>chronologically</b> — but ONLY because every persisted timestamp carries the
/// same Moscow <c>+03:00</c> offset (see the project invariant in CLAUDE.md). Two
/// instants written with different offsets would sort lexically, not
/// chronologically, and silently corrupt range filtering.
/// </para>
/// <para>
/// Centralising the format here means the write encoding and the query-bound
/// encoding can never drift apart, and the offset invariant is documented in one
/// authoritative place. If a future change normalises storage to UTC or a native
/// DATETIME column, this is the only call site that must change.
/// </para>
/// </remarks>
internal static class SqliteDateText
{
// Parse with the invariant culture + RoundtripKind so a non-en-US thread
// culture (or a future locale change) cannot corrupt the round-trip.
private const DateTimeStyles RoundtripStyles = DateTimeStyles.RoundtripKind;
/// <summary>
/// Encodes a <see cref="DateTimeOffset"/> as the TEXT key used for storage and
/// for the bounds of range/ordering predicates.
/// </summary>
public static string Key(DateTimeOffset value) => value.ToString("O");
/// <summary>Decodes a stored TEXT key back into a <see cref="DateTimeOffset"/>.</summary>
public static DateTimeOffset Parse(string text) =>
DateTimeOffset.Parse(text, CultureInfo.InvariantCulture, RoundtripStyles);
}
@@ -6,9 +6,8 @@ using Marathon.Domain.Entities;
using Marathon.Domain.Enums;
using Marathon.Domain.ValueObjects;
using Microsoft.Extensions.Logging;
using DomainEventId = Marathon.Domain.ValueObjects.EventId;
using AngleSharpConfig = AngleSharp.Configuration;
using DomainEventId = Marathon.Domain.ValueObjects.EventId;
namespace Marathon.Infrastructure.Scraping.Parsers;
@@ -110,7 +109,7 @@ public sealed partial class EventOddsParser : IEventOddsParser
// no longer rescans the document with QuerySelector
// for every key — that was an O(N) cost paid 6× per
// period).
var priceIndex = BuildSelectionPriceIndex(selections);
var priceIndex = BuildSelectionPriceIndex(selections);
var elementIndex = BuildSelectionElementIndex(selections);
var bets = new List<Bet>();
@@ -187,19 +186,19 @@ public sealed partial class EventOddsParser : IEventOddsParser
// Try each market variant; first match wins
foreach (var market in MatchResultMarkets)
{
var win1Key = $"{eventId}@{market}.1";
var drawKey = $"{eventId}@{market}.draw";
var win2Key = $"{eventId}@{market}.3";
var win1Key = $"{eventId}@{market}.1";
var drawKey = $"{eventId}@{market}.draw";
var win2Key = $"{eventId}@{market}.3";
// Basketball 2-way OT market uses HB_H / HB_A
var hbhKey = $"{eventId}@{market}.HB_H";
var hbaKey = $"{eventId}@{market}.HB_A";
var hbhKey = $"{eventId}@{market}.HB_H";
var hbaKey = $"{eventId}@{market}.HB_A";
var hasWin1 = idx.TryGetValue(win1Key, out var rate1);
var hasDraw = idx.TryGetValue(drawKey, out var rateDraw);
var hasWin2 = idx.TryGetValue(win2Key, out var rate2);
var hasHbh = idx.TryGetValue(hbhKey, out var rateHbh);
var hasHba = idx.TryGetValue(hbaKey, out var rateHba);
var hasWin1 = idx.TryGetValue(win1Key, out var rate1);
var hasDraw = idx.TryGetValue(drawKey, out var rateDraw);
var hasWin2 = idx.TryGetValue(win2Key, out var rate2);
var hasHbh = idx.TryGetValue(hbhKey, out var rateHbh);
var hasHba = idx.TryGetValue(hbaKey, out var rateHba);
if (hasWin1 || hasDraw || hasWin2 || hasHbh || hasHba)
{
@@ -517,8 +516,11 @@ public sealed partial class EventOddsParser : IEventOddsParser
value.HasValue ? new OddsValue(value.Value) : null,
new OddsRate(rate)));
}
catch (Exception ex)
catch (ArgumentException ex)
{
// OddsValue / OddsRate / Bet guard clauses throw ArgumentException and its
// derivatives (ArgumentNullException, ArgumentOutOfRangeException). Catch
// only those — anything else is a real bug that must not be swallowed here.
_logger.LogDebug(ex,
"Skipping bet ({Type}, {Side}, value={Value}, rate={Rate}) — invariant violation.",
type, side, value, rate);
@@ -28,8 +28,8 @@ internal sealed class LiveOddsPoller : BackgroundService
ILogger<LiveOddsPoller> logger)
{
_services = services ?? throw new ArgumentNullException(nameof(services));
_opts = opts ?? throw new ArgumentNullException(nameof(opts));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_opts = opts ?? throw new ArgumentNullException(nameof(opts));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
@@ -47,6 +47,8 @@ internal sealed class LiveOddsPoller : BackgroundService
continue;
}
var cycleStart = DateTime.UtcNow;
try
{
await using var scope = _services.CreateAsyncScope();
@@ -69,9 +71,17 @@ internal sealed class LiveOddsPoller : BackgroundService
var interval = TimeSpan.FromSeconds(
Math.Max(1, _opts.CurrentValue.LivePollIntervalSeconds));
// Budget the sleep against the time the cycle already consumed so the
// effective cadence tracks the configured interval instead of
// (interval + scrapeDuration). If a cycle overran the interval, loop
// immediately rather than sleeping a full extra interval.
var remaining = interval - (DateTime.UtcNow - cycleStart);
if (remaining <= TimeSpan.Zero)
continue;
try
{
await Task.Delay(interval, stoppingToken);
await Task.Delay(remaining, stoppingToken);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
@@ -212,6 +212,7 @@
private string KindLabel(AnomalyKind kind) => kind switch
{
AnomalyKind.SuspensionFlip => L["Anomaly.Kind.SuspensionFlip"],
AnomalyKind.SteamMove => L["Anomaly.Kind.SteamMove"],
_ => kind.ToString(),
};
+16
View File
@@ -39,8 +39,24 @@
<MudIcon Icon="@Icons.Material.Outlined.Done" Size="Size.Small" />
<span>@L["Nav.Results"]</span>
</NavLink>
<NavLink class="m-nav__link" href="anomalies/insights">
<MudIcon Icon="@Icons.Material.Outlined.Insights" Size="Size.Small" />
<span>@L["Nav.Insights"]</span>
</NavLink>
<NavLink class="m-nav__link" href="my-bets">
<MudIcon Icon="@Icons.Material.Outlined.Receipt" Size="Size.Small" />
<span>@L["Nav.MyBets"]</span>
</NavLink>
<NavLink class="m-nav__link" href="anomalies/backtest">
<MudIcon Icon="@Icons.Material.Outlined.QueryStats" Size="Size.Small" />
<span>@L["Nav.Backtest"]</span>
</NavLink>
<div class="m-nav__group" style="margin-top: var(--m-space-5);">@L["Nav.Section.System"]</div>
<NavLink class="m-nav__link" href="export">
<MudIcon Icon="@Icons.Material.Outlined.FileDownload" Size="Size.Small" />
<span>@L["Nav.Export"]</span>
</NavLink>
<NavLink class="m-nav__link" href="settings">
<MudIcon Icon="@Icons.Material.Outlined.Tune" Size="Size.Small" />
<span>@L["Nav.Settings"]</span>
+17
View File
@@ -3,6 +3,7 @@
@inject ThemeState ThemeState
@inject LocaleState LocaleState
@inject IStringLocalizer<SharedResource> L
@inject IOptionsMonitor<WorkerOptions> Workers
<MudThemeProvider IsDarkMode="@ThemeState.IsDark" Theme="@_theme" />
<MudPopoverProvider />
@@ -24,6 +25,12 @@
<div class="m-appbar__spacer"></div>
<div class="m-appbar__tools m-rise m-rise-2">
<span class="m-capture-pill" data-test="capture-pill"
aria-label="@L["Scraping.Aria"]" title="@L["Scraping.Aria"]"
style="display:inline-flex; align-items:center; gap:7px; font-family:var(--m-font-mono); font-size:0.6875rem; text-transform:uppercase; letter-spacing:0.12em; color:@(Capturing ? "var(--m-c-positive)" : "var(--m-c-ink-soft)");">
<span style="width:8px; height:8px; border-radius:50%; background:@(Capturing ? "var(--m-c-positive)" : "var(--m-c-ink-soft)");"></span>
@(Capturing ? L["Scraping.On"] : L["Scraping.Off"])
</span>
<LocaleSwitcher />
<ThemeToggle />
</div>
@@ -123,11 +130,20 @@
@code {
private bool _drawerOpen = true;
private MudBlazor.MudTheme _theme = Theme.MarathonTheme.Build();
private IDisposable? _workerOptionsListener;
// "Capturing" when any of the primary pollers is enabled in config.
private bool Capturing =>
Workers.CurrentValue.LivePollerEnabled
|| Workers.CurrentValue.UpcomingPollerEnabled
|| Workers.CurrentValue.AnomalyDetectionEnabled;
protected override void OnInitialized()
{
ThemeState.OnChange += StateHasChanged;
LocaleState.OnChange += StateHasChanged;
// Reflect Settings toggles live without requiring a navigation.
_workerOptionsListener = Workers.OnChange(_ => InvokeAsync(StateHasChanged));
}
private void ToggleDrawer() => _drawerOpen = !_drawerOpen;
@@ -136,5 +152,6 @@
{
ThemeState.OnChange -= StateHasChanged;
LocaleState.OnChange -= StateHasChanged;
_workerOptionsListener?.Dispose();
}
}
@@ -98,6 +98,9 @@
<button type="button" class="m-chip" @onclick="MarkAllRead" data-test="mark-read">
@L["Anomaly.Filter.MarkRead"]
</button>
<button type="button" class="m-chip" @onclick="OpenInsights" data-test="open-insights">
@L["Nav.Insights"]
</button>
</div>
</div>
@@ -269,6 +272,11 @@
State.MarkAllSeen(DateTimeOffset.UtcNow);
}
private void OpenInsights()
{
Nav.NavigateTo("/anomalies/insights");
}
private void HandleClick(AnomalyListItem item)
{
Nav.NavigateTo($"/anomalies/{item.Id}");
@@ -0,0 +1,897 @@
@*
Backtest — historical strategy replayer.
Picks a confidence threshold and a staking rule, runs the simulator over
every graded anomaly, and reports the P&L story: equity curve, KPI strip,
per-bet trade trace. Same editorial-quant tone as Insights / Journal —
accent kicker (not anomaly-red), staged m-rise reveal, m-card form,
inline SVG equity curve, mono table.
*@
@page "/anomalies/backtest"
@using Marathon.Domain.Backtesting
@implements IDisposable
@inject IStringLocalizer<SharedResource> L
@inject IBacktestService Service
@inject NavigationManager Nav
@inject ISnackbar Snackbar
@inject ILogger<Backtest> Logger
<PageTitle>@L["App.Title"] · @L["Nav.Backtest"]</PageTitle>
<section class="m-shell">
<header class="m-rise m-rise-1 m-backtest__header" data-test="backtest-header">
<div class="m-backtest__header-text">
<span class="m-kicker">@L["Backtest.Kicker"]</span>
<h1 class="m-display" style="font-size: clamp(2rem, 4vw, 3rem);">@L["Backtest.Title"]</h1>
<p style="color: var(--m-c-ink-soft); max-width: 64ch;">@L["Backtest.Lede"]</p>
</div>
</header>
@* ---------- Strategy form ---------- *@
<section class="m-backtest__section m-rise m-rise-2" data-test="backtest-form-section">
<header class="m-backtest__section-head">
<span class="m-kicker">@L["Backtest.Section.Strategy"]</span>
</header>
<article class="m-card m-card--accented m-backtest__form-card">
<div class="m-backtest__form-grid">
<div class="m-backtest__form-field">
<label class="m-backtest__form-label">@L["Backtest.Field.Bankroll"]</label>
<MudNumericField T="decimal"
@bind-Value="_form.StartingBankroll"
Min="1m"
Step="100m"
Variant="Variant.Outlined"
data-test="backtest-bankroll" />
</div>
<div class="m-backtest__form-field">
<label class="m-backtest__form-label">@L["Backtest.Field.From"]</label>
<MudDatePicker @bind-Date="_form.From"
DateFormat="yyyy-MM-dd"
Clearable="true"
Variant="Variant.Outlined"
data-test="backtest-from" />
</div>
<div class="m-backtest__form-field">
<label class="m-backtest__form-label">@L["Backtest.Field.To"]</label>
<MudDatePicker @bind-Date="_form.To"
DateFormat="yyyy-MM-dd"
Clearable="true"
Variant="Variant.Outlined"
data-test="backtest-to" />
<span class="m-backtest__form-hint">@L["Backtest.Field.DateRange.Hint"]</span>
</div>
<div class="m-backtest__form-field">
<label class="m-backtest__form-label">@L["Backtest.Field.MinScore"]</label>
<MudNumericField T="decimal"
@bind-Value="_form.MinScore"
Min="0m"
Max="1m"
Step="0.05m"
Variant="Variant.Outlined"
data-test="backtest-min-score" />
<span class="m-backtest__form-hint">@L["Backtest.Field.MinScore.Hint"]</span>
</div>
<div class="m-backtest__form-field">
<label class="m-backtest__form-label">@L["Backtest.Field.StakeRule"]</label>
<MudSelect T="StakeRule"
Value="_form.StakeRule"
ValueChanged="OnStakeRuleChanged"
Variant="Variant.Outlined"
data-test="backtest-stake-rule">
@foreach (var rule in _stakeRules)
{
<MudSelectItem T="StakeRule" Value="@rule">@StakeRuleLabel(rule)</MudSelectItem>
}
</MudSelect>
</div>
@switch (_form.StakeRule)
{
case StakeRule.Flat:
<div class="m-backtest__form-field" data-test="backtest-flat-stake-field">
<label class="m-backtest__form-label">@L["Backtest.Field.FlatStake"]</label>
<MudNumericField T="decimal"
@bind-Value="_form.FlatStake"
Min="0.01m"
Step="10m"
Variant="Variant.Outlined"
data-test="backtest-flat-stake" />
</div>
break;
case StakeRule.PercentOfBankroll:
<div class="m-backtest__form-field" data-test="backtest-percent-field">
<label class="m-backtest__form-label">@L["Backtest.Field.PercentOfBankroll"]</label>
<MudNumericField T="decimal"
@bind-Value="_form.PercentOfBankrollPercent"
Min="0.01m"
Max="100m"
Step="0.5m"
Variant="Variant.Outlined"
data-test="backtest-percent" />
</div>
break;
case StakeRule.Kelly:
<div class="m-backtest__form-field" data-test="backtest-kelly-field">
<label class="m-backtest__form-label">@L["Backtest.Field.KellyFraction"]</label>
<MudNumericField T="decimal"
@bind-Value="_form.KellyFractionPercent"
Min="1m"
Max="100m"
Step="5m"
Variant="Variant.Outlined"
data-test="backtest-kelly" />
<span class="m-backtest__form-hint">@L["Backtest.Field.KellyFraction.Hint"]</span>
</div>
break;
}
</div>
@if (!string.IsNullOrEmpty(_formError))
{
<p class="m-backtest__form-error" data-test="backtest-form-error">@_formError</p>
}
<div class="m-backtest__form-actions">
<button type="button"
class="m-chip m-backtest__submit"
@onclick="RunAsync"
disabled="@_running"
data-test="backtest-run">
<span class="m-backtest__submit-glyph @(_running ? "is-spinning" : null)" aria-hidden="true">▶</span>
<span>@(_running ? L["Backtest.Action.Running"] : L["Backtest.Action.Run"])</span>
</button>
@if (_running)
{
<button type="button"
class="m-chip"
@onclick="CancelRun"
data-test="backtest-cancel">
<span>@L["Backtest.Action.Cancel"]</span>
</button>
}
</div>
</article>
</section>
@if (_vm is { } vm)
{
<hr class="m-rule--double" />
@* ---------- Result headline ---------- *@
<section class="m-backtest__section m-rise m-rise-3" data-test="backtest-result-section">
<header class="m-backtest__section-head">
<span class="m-kicker">@L["Backtest.Section.Headline"]</span>
</header>
<div class="m-backtest__kpis" data-test="backtest-kpis">
<article class="m-backtest__kpi m-backtest__kpi--@BankrollTone(vm)" data-test="backtest-kpi-final">
<span class="m-backtest__kpi-label">@L["Backtest.Stat.FinalBankroll"]</span>
<span class="m-backtest__kpi-value">@FormatDecimal(vm.FinalBankroll)</span>
</article>
<article class="m-backtest__kpi m-backtest__kpi--@ProfitTone(vm)" data-test="backtest-kpi-profit">
<span class="m-backtest__kpi-label">@L["Backtest.Stat.NetProfit"]</span>
<span class="m-backtest__kpi-value">@FormatSignedDecimal(vm.NetProfit, vm.BetsPlaced)</span>
</article>
<article class="m-backtest__kpi m-backtest__kpi--@RoiTone(vm.RoiPercent)" data-test="backtest-kpi-roi">
<span class="m-backtest__kpi-label">@L["Backtest.Stat.Roi"]</span>
<span class="m-backtest__kpi-value">@FormatSignedPercent(vm.RoiPercent)</span>
</article>
<article class="m-backtest__kpi m-backtest__kpi--drawdown" data-test="backtest-kpi-drawdown">
<span class="m-backtest__kpi-label">@L["Backtest.Stat.MaxDrawdown"]</span>
@if (vm.MaxDrawdown == 0m && vm.MaxDrawdownPercent is null)
{
<span class="m-backtest__kpi-value" style="color: var(--m-c-ink-soft);">—</span>
}
else
{
<span class="m-backtest__kpi-value">@FormatSignedDecimal(-vm.MaxDrawdown, 1)</span>
<span class="m-backtest__kpi-sub">@FormatSignedPercent(vm.MaxDrawdownPercent is null ? null : -vm.MaxDrawdownPercent.Value)</span>
}
</article>
</div>
<div class="m-backtest__counts m-mono" data-test="backtest-counts">
<span><span class="m-backtest__counts-label">@L["Backtest.Stat.BetsPlaced"]</span> <strong>@vm.BetsPlaced</strong></span>
<span aria-hidden="true">·</span>
<span><span class="m-backtest__counts-label">@L["Backtest.Stat.Wins"]</span> <strong style="color: var(--m-c-positive);">@vm.Wins</strong></span>
<span aria-hidden="true">·</span>
<span><span class="m-backtest__counts-label">@L["Backtest.Stat.Losses"]</span> <strong style="color: var(--m-c-anomaly);">@vm.Losses</strong></span>
<span aria-hidden="true">·</span>
<span><span class="m-backtest__counts-label">@L["Backtest.Stat.Skipped"]</span> <strong>@vm.Skipped</strong></span>
<span aria-hidden="true">·</span>
<span><span class="m-backtest__counts-label">@L["Backtest.Stat.MaxWinStreak"]</span> <strong>@vm.MaxWinStreak</strong></span>
<span aria-hidden="true">·</span>
<span><span class="m-backtest__counts-label">@L["Backtest.Stat.MaxLossStreak"]</span> <strong>@vm.MaxLossStreak</strong></span>
</div>
</section>
@if (vm.BetsPlaced == 0 && vm.Trace.Count == 0 && vm.Skipped == 0)
{
<div class="m-list-empty m-rise m-rise-4" data-test="backtest-empty-no-data">
<span class="m-kicker" style="border-color: var(--m-c-ink-soft); color: var(--m-c-ink-soft);">
@L["Common.Empty"]
</span>
<p style="color: var(--m-c-ink-soft); margin-top: var(--m-space-3); max-width: 56ch;">
@L["Backtest.Empty.NoData"]
</p>
</div>
}
else if (vm.BetsPlaced == 0)
{
<div class="m-list-empty m-rise m-rise-4" data-test="backtest-empty-no-bets">
<span class="m-kicker" style="border-color: var(--m-c-ink-soft); color: var(--m-c-ink-soft);">
@L["Common.Empty"]
</span>
<p style="color: var(--m-c-ink-soft); margin-top: var(--m-space-3); max-width: 56ch;">
@L["Backtest.Empty.NoBetsPlaced"]
</p>
</div>
}
else
{
<hr class="m-rule--double" />
@* ---------- Equity curve ---------- *@
<section class="m-backtest__section m-rise m-rise-4" data-test="backtest-equity-section">
<header class="m-backtest__section-head">
<span class="m-kicker">@L["Backtest.Section.Equity"]</span>
</header>
<article class="m-backtest__equity">
@if (vm.EquityCurve.Count == 0)
{
<div class="m-list-empty" data-test="backtest-equity-empty">
<p style="color: var(--m-c-ink-soft); max-width: 50ch;">@L["Backtest.Empty.NoBetsPlaced"]</p>
</div>
}
else
{
@RenderEquityCurve(vm)
}
</article>
</section>
<hr class="m-rule--double" />
@* ---------- Trade trace ---------- *@
<section class="m-backtest__section m-rise m-rise-5" data-test="backtest-trace-section">
<header class="m-backtest__section-head">
<span class="m-kicker">@L["Backtest.Section.Trace"]</span>
<span class="m-backtest__section-count m-mono">@vm.Trace.Count</span>
</header>
<div class="m-backtest__table-wrap">
<table class="m-backtest__table" data-test="backtest-trace-table">
<thead>
<tr>
<th scope="col">@L["Backtest.Column.DetectedAt"]</th>
<th scope="col">@L["Backtest.Column.Match"]</th>
<th scope="col" style="text-align: right;">@L["Backtest.Column.Score"]</th>
<th scope="col">@L["Backtest.Column.Pick"]</th>
<th scope="col" style="text-align: right;">@L["Backtest.Column.Rate"]</th>
<th scope="col" style="text-align: right;">@L["Backtest.Column.Stake"]</th>
<th scope="col" style="text-align: right;">@L["Backtest.Column.Payout"]</th>
<th scope="col" style="text-align: right;">@L["Backtest.Column.Bankroll"]</th>
<th scope="col">@L["Backtest.Column.Outcome"]</th>
<th scope="col"></th>
</tr>
</thead>
<tbody>
@foreach (var row in vm.Trace)
{
var local = row;
var trace = local.Trace;
<tr class="m-backtest__row m-backtest__row--@(trace.IsWin ? "win" : "loss")"
data-test="backtest-trace-row"
data-anomaly-id="@trace.AnomalyId">
<td class="m-mono">@trace.DetectedAt.ToLocalTime().ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture)</td>
<td style="font-weight: 500;">@local.EventTitle</td>
<td class="m-mono" style="text-align: right; font-weight: 600;">@trace.Score.ToString("0.00", CultureInfo.InvariantCulture)</td>
<td style="font-weight: 600;">@SideLabel(trace.PostFlipFavourite)</td>
<td class="m-mono" style="text-align: right;">@trace.TakenRate.ToString("0.00", CultureInfo.InvariantCulture)</td>
<td class="m-mono" style="text-align: right;">@trace.Stake.ToString("0.00", CultureInfo.InvariantCulture)</td>
<td class="m-mono m-backtest__payout m-backtest__payout--@(trace.IsWin ? "win" : "loss")" style="text-align: right;">
@trace.Payout.ToString("0.00", CultureInfo.InvariantCulture)
</td>
<td class="m-mono" style="text-align: right; font-weight: 600;">@trace.BankrollAfter.ToString("0.00", CultureInfo.InvariantCulture)</td>
<td>
<span class="m-backtest__verdict m-backtest__verdict--@(trace.IsWin ? "win" : "loss")">
@(trace.IsWin ? L["Backtest.Outcome.Win"] : L["Backtest.Outcome.Loss"])
</span>
</td>
<td>
<a href="@($"/anomalies/{trace.AnomalyId}")"
class="m-backtest__open"
data-test="backtest-trace-open"
@onclick="@(e => OpenAnomaly(e, trace.AnomalyId))"
@onclick:preventDefault>
@L["Insights.Action.OpenAnomaly"]
<span aria-hidden="true">→</span>
</a>
</td>
</tr>
}
</tbody>
</table>
</div>
</section>
}
}
</section>
<style>
/* ---- Header ---- */
.m-backtest__header {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: var(--m-space-3);
max-width: 880px;
}
.m-backtest__header-text { display: grid; gap: var(--m-space-3); }
/* ---- Sections ---- */
.m-backtest__section { display: grid; gap: var(--m-space-4); }
.m-backtest__section-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var(--m-space-3);
}
.m-backtest__section-count {
font-size: 0.6875rem;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--m-c-ink-soft);
}
/* ---- Form ---- */
.m-backtest__form-card {
display: grid;
gap: var(--m-space-4);
padding: var(--m-space-5);
}
.m-backtest__form-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: var(--m-space-4);
}
.m-backtest__form-field { display: grid; gap: var(--m-space-2); }
.m-backtest__form-label {
font-family: var(--m-font-mono);
font-size: 0.6875rem;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--m-c-ink-soft);
}
.m-backtest__form-hint {
font-size: 0.75rem;
color: var(--m-c-ink-soft);
}
.m-backtest__form-error {
margin: 0;
padding: var(--m-space-3) var(--m-space-4);
border: 1px solid var(--m-c-anomaly);
border-left-width: 3px;
background: rgba(220, 38, 38, 0.06);
color: var(--m-c-anomaly);
font-family: var(--m-font-mono);
font-size: 0.8125rem;
line-height: 1.5;
}
[data-theme="dark"] .m-backtest__form-error {
background: rgba(248, 113, 113, 0.10);
}
.m-backtest__form-actions {
display: flex;
justify-content: flex-end;
gap: var(--m-space-3);
}
.m-backtest__submit {
gap: var(--m-space-2);
padding: 8px 16px;
border-color: var(--m-c-accent);
color: var(--m-c-accent);
font-family: var(--m-font-mono);
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.14em;
}
.m-backtest__submit:not(:disabled):hover {
background: var(--m-c-accent);
color: var(--m-c-paper);
}
.m-backtest__submit:disabled { opacity: 0.6; cursor: progress; }
.m-backtest__submit-glyph {
display: inline-block;
font-size: 0.7rem;
line-height: 1;
}
.m-backtest__submit-glyph.is-spinning { animation: m-backtest-spin 1.1s linear infinite; }
@@keyframes m-backtest-spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
@@media (prefers-reduced-motion: reduce) {
.m-backtest__submit-glyph.is-spinning { animation: none; }
}
/* ---- KPI strip ---- */
.m-backtest__kpis {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: var(--m-space-4);
}
.m-backtest__kpi {
background: var(--m-c-paper);
border: 1px solid var(--m-c-rule);
border-left: 3px solid var(--m-c-rule);
padding: var(--m-space-4) var(--m-space-5);
display: flex;
flex-direction: column;
gap: var(--m-space-2);
position: relative;
}
.m-backtest__kpi--positive { border-left-color: var(--m-c-positive); }
.m-backtest__kpi--negative { border-left-color: var(--m-c-anomaly); }
.m-backtest__kpi--neutral { border-left-color: var(--m-c-accent); }
.m-backtest__kpi--drawdown { border-left-color: var(--m-c-anomaly); }
.m-backtest__kpi-label {
font-family: var(--m-font-mono);
font-size: 0.6875rem;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--m-c-ink-soft);
}
.m-backtest__kpi-value {
font-family: var(--m-font-mono);
font-feature-settings: var(--m-num-feature);
font-size: clamp(1.85rem, 3.4vw, 2.5rem);
font-weight: 500;
line-height: 1;
letter-spacing: -0.02em;
color: var(--m-c-ink);
}
.m-backtest__kpi--positive .m-backtest__kpi-value { color: var(--m-c-positive); }
.m-backtest__kpi--negative .m-backtest__kpi-value { color: var(--m-c-anomaly); }
.m-backtest__kpi--drawdown .m-backtest__kpi-value { color: var(--m-c-anomaly); }
.m-backtest__kpi-sub {
font-family: var(--m-font-mono);
font-feature-settings: var(--m-num-feature);
font-size: 0.8125rem;
color: var(--m-c-anomaly);
letter-spacing: 0.02em;
}
/* ---- Counts row ---- */
.m-backtest__counts {
display: flex;
gap: var(--m-space-3);
flex-wrap: wrap;
align-items: baseline;
padding: var(--m-space-2) 0;
font-size: 0.8125rem;
color: var(--m-c-ink-soft);
font-feature-settings: var(--m-num-feature);
}
.m-backtest__counts strong {
color: var(--m-c-ink);
font-weight: 600;
}
.m-backtest__counts-label {
text-transform: uppercase;
letter-spacing: 0.12em;
font-size: 0.6875rem;
}
/* ---- Equity curve ---- */
.m-backtest__equity {
background: var(--m-c-paper);
border: 1px solid var(--m-c-rule);
padding: var(--m-space-4) var(--m-space-5);
position: relative;
}
.m-backtest__equity-svg {
display: block;
width: 100%;
height: 200px;
}
.m-backtest__equity-baseline {
stroke: var(--m-c-rule);
stroke-width: 1;
stroke-dasharray: 3 4;
fill: none;
}
.m-backtest__equity-path {
fill: none;
stroke-width: 2;
stroke-linejoin: round;
stroke-linecap: round;
}
.m-backtest__equity-path--positive { stroke: var(--m-c-positive); }
.m-backtest__equity-path--negative { stroke: var(--m-c-anomaly); }
.m-backtest__equity-tick {
font-family: var(--m-font-mono);
font-feature-settings: var(--m-num-feature);
font-size: 10px;
fill: var(--m-c-ink-soft);
}
.m-backtest__equity-tick--anchor { fill: var(--m-c-ink); font-weight: 600; }
/* ---- Trace table ---- */
.m-backtest__table-wrap {
background: var(--m-c-paper);
border: 1px solid var(--m-c-rule);
overflow-x: auto;
}
.m-backtest__table {
width: 100%;
border-collapse: collapse;
font-family: var(--m-font-body);
}
.m-backtest__table thead th {
font-family: var(--m-font-mono);
font-size: 0.6875rem;
letter-spacing: 0.14em;
text-transform: uppercase;
text-align: left;
padding: var(--m-space-3) var(--m-space-3);
border-bottom: 1px solid var(--m-c-rule);
color: var(--m-c-ink-soft);
background: var(--m-c-paper-2);
white-space: nowrap;
}
.m-backtest__table tbody td {
padding: var(--m-space-3) var(--m-space-3);
border-bottom: 1px solid var(--m-c-rule);
vertical-align: middle;
font-size: 0.9375rem;
}
.m-backtest__table tbody tr:last-child td { border-bottom: 0; }
.m-backtest__row { transition: background 120ms ease; }
.m-backtest__row:hover { background: var(--m-c-paper-2); }
.m-backtest__row--win { box-shadow: inset 2px 0 0 0 var(--m-c-positive); }
.m-backtest__row--loss { box-shadow: inset 2px 0 0 0 var(--m-c-anomaly); }
@@media (prefers-reduced-motion: reduce) {
.m-backtest__row { transition: none; }
}
.m-backtest__payout { font-feature-settings: var(--m-num-feature); font-weight: 600; }
.m-backtest__payout--win { color: var(--m-c-positive); }
.m-backtest__payout--loss { color: var(--m-c-anomaly); }
.m-backtest__verdict {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 2px 8px;
font-family: var(--m-font-mono);
font-size: 0.6875rem;
letter-spacing: 0.14em;
text-transform: uppercase;
border: 1px solid currentColor;
border-radius: var(--m-radius-xs);
background: rgba(0, 0, 0, 0);
}
.m-backtest__verdict--win {
color: var(--m-c-positive);
background: rgba(21, 128, 61, 0.10);
}
.m-backtest__verdict--loss {
color: var(--m-c-anomaly);
background: rgba(220, 38, 38, 0.10);
}
[data-theme="dark"] .m-backtest__verdict--win {
color: var(--m-c-positive);
background: rgba(34, 197, 94, 0.15);
}
[data-theme="dark"] .m-backtest__verdict--loss {
color: var(--m-c-anomaly);
background: rgba(248, 113, 113, 0.15);
}
.m-backtest__open {
display: inline-flex;
align-items: center;
gap: 6px;
font-family: var(--m-font-mono);
font-size: 0.75rem;
letter-spacing: 0.12em;
text-transform: uppercase;
text-decoration: none;
color: var(--m-c-ink);
border-bottom: 1px solid var(--m-c-accent);
padding-bottom: 1px;
transition: color 120ms ease, border-color 120ms ease;
}
.m-backtest__open:hover {
color: var(--m-c-accent);
border-bottom-color: var(--m-c-ink);
}
/* ---- Empty-state ---- */
.m-list-empty {
display: grid;
place-content: center;
gap: var(--m-space-3);
padding: var(--m-space-7);
text-align: center;
background: var(--m-c-paper);
border: 1px solid var(--m-c-rule);
}
</style>
@code {
private static readonly StakeRule[] _stakeRules =
{ StakeRule.Flat, StakeRule.PercentOfBankroll, StakeRule.Kelly };
private BacktestForm _form = new();
private BacktestVm? _vm;
private bool _running;
private string? _formError;
private CancellationTokenSource? _runCts;
private async Task RunAsync()
{
if (_running) return;
_formError = null;
if (!_form.IsValid(out var err))
{
_formError = err;
StateHasChanged();
return;
}
_runCts?.Cancel();
_runCts = new CancellationTokenSource();
var ct = _runCts.Token;
_running = true;
StateHasChanged();
try
{
var result = await Service.RunAsync(_form, ct);
if (ct.IsCancellationRequested) return;
_vm = result;
}
catch (OperationCanceledException) { /* superseded */ }
catch (ArgumentException ex)
{
_formError = ex.Message;
}
catch (Exception ex)
{
Logger.LogError(ex, "Backtest simulation failed.");
Snackbar.Add(L["Backtest.Error.Generic"].Value, Severity.Error);
}
finally
{
_running = false;
StateHasChanged();
}
}
private void CancelRun() => _runCts?.Cancel();
private void OnStakeRuleChanged(StakeRule next)
{
_form.StakeRule = next;
_formError = null;
}
private void OpenAnomaly(MouseEventArgs e, Guid anomalyId)
{
Nav.NavigateTo("/anomalies/" + anomalyId.ToString());
}
// ---- Equity curve rendering --------------------------------------------
private RenderFragment RenderEquityCurve(BacktestVm vm) => builder =>
{
var points = vm.EquityCurve;
var pointCount = points.Count;
// Y-axis bounds: include starting bankroll + min/max bankroll, 5% padding.
decimal minB = vm.StartingBankroll;
decimal maxB = vm.StartingBankroll;
foreach (var p in points)
{
if (p.Bankroll < minB) minB = p.Bankroll;
if (p.Bankroll > maxB) maxB = p.Bankroll;
}
var rawRange = maxB - minB;
if (rawRange <= 0m) rawRange = Math.Max(1m, Math.Abs(vm.StartingBankroll) * 0.1m);
var pad = rawRange * 0.05m;
var yMin = minB - pad;
var yMax = maxB + pad;
var yRange = yMax - yMin;
if (yRange <= 0m) yRange = 1m;
// SVG canvas (viewBox 0..1000 x 0..200).
const int vbW = 1000;
const int vbH = 200;
const int padL = 56;
const int padR = 16;
const int padT = 12;
const int padB = 22;
var plotW = vbW - padL - padR;
var plotH = vbH - padT - padB;
double XAt(int i)
{
if (pointCount <= 1) return padL + plotW / 2.0;
return padL + (plotW * (double)i) / (pointCount - 1);
}
double YAt(decimal bankroll)
{
var t = (double)((bankroll - yMin) / yRange);
// Flip — SVG y grows downward.
return padT + (1.0 - t) * plotH;
}
// Baseline (StartingBankroll) y.
var baselineY = YAt(vm.StartingBankroll);
// Build polyline points string.
var sb = new System.Text.StringBuilder();
for (var i = 0; i < pointCount; i++)
{
if (i > 0) sb.Append(' ');
sb.Append(XAt(i).ToString("0.##", CultureInfo.InvariantCulture));
sb.Append(',');
sb.Append(YAt(points[i].Bankroll).ToString("0.##", CultureInfo.InvariantCulture));
}
var pathTone = vm.FinalBankroll >= vm.StartingBankroll ? "positive" : "negative";
builder.OpenElement(0, "svg");
builder.AddAttribute(1, "class", "m-backtest__equity-svg");
builder.AddAttribute(2, "viewBox", "0 0 " + vbW.ToString(CultureInfo.InvariantCulture) + " " + vbH.ToString(CultureInfo.InvariantCulture));
builder.AddAttribute(3, "preserveAspectRatio", "none");
builder.AddAttribute(4, "role", "img");
builder.AddAttribute(5, "aria-label", L["Backtest.Section.Equity"].Value);
builder.AddAttribute(6, "data-test", "backtest-equity-svg");
// Baseline (dotted horizontal at starting bankroll).
builder.OpenElement(10, "line");
builder.AddAttribute(11, "class", "m-backtest__equity-baseline");
builder.AddAttribute(12, "x1", padL.ToString(CultureInfo.InvariantCulture));
builder.AddAttribute(13, "y1", baselineY.ToString("0.##", CultureInfo.InvariantCulture));
builder.AddAttribute(14, "x2", (vbW - padR).ToString(CultureInfo.InvariantCulture));
builder.AddAttribute(15, "y2", baselineY.ToString("0.##", CultureInfo.InvariantCulture));
builder.CloseElement();
// Polyline.
builder.OpenElement(20, "polyline");
builder.AddAttribute(21, "class", "m-backtest__equity-path m-backtest__equity-path--" + pathTone);
builder.AddAttribute(22, "points", sb.ToString());
builder.CloseElement();
// Y-axis ticks: yMax (top), starting bankroll (middle), yMin (bottom).
var topLabel = FormatTickValue(yMax);
var midLabel = FormatTickValue(vm.StartingBankroll);
var botLabel = FormatTickValue(yMin);
// Top tick
builder.OpenElement(30, "text");
builder.AddAttribute(31, "class", "m-backtest__equity-tick");
builder.AddAttribute(32, "x", (padL - 6).ToString(CultureInfo.InvariantCulture));
builder.AddAttribute(33, "y", (padT + 8).ToString(CultureInfo.InvariantCulture));
builder.AddAttribute(34, "text-anchor", "end");
builder.AddContent(35, topLabel);
builder.CloseElement();
// Mid tick (starting bankroll anchor)
builder.OpenElement(40, "text");
builder.AddAttribute(41, "class", "m-backtest__equity-tick m-backtest__equity-tick--anchor");
builder.AddAttribute(42, "x", (padL - 6).ToString(CultureInfo.InvariantCulture));
builder.AddAttribute(43, "y", (baselineY + 3).ToString("0.##", CultureInfo.InvariantCulture));
builder.AddAttribute(44, "text-anchor", "end");
builder.AddContent(45, midLabel);
builder.CloseElement();
// Bottom tick
builder.OpenElement(50, "text");
builder.AddAttribute(51, "class", "m-backtest__equity-tick");
builder.AddAttribute(52, "x", (padL - 6).ToString(CultureInfo.InvariantCulture));
builder.AddAttribute(53, "y", (vbH - padB + 12).ToString(CultureInfo.InvariantCulture));
builder.AddAttribute(54, "text-anchor", "end");
builder.AddContent(55, botLabel);
builder.CloseElement();
// Final-value end label (on far right at end point).
if (pointCount > 0)
{
var endY = YAt(points[pointCount - 1].Bankroll);
builder.OpenElement(60, "text");
builder.AddAttribute(61, "class", "m-backtest__equity-tick m-backtest__equity-tick--anchor");
builder.AddAttribute(62, "x", (vbW - padR - 4).ToString(CultureInfo.InvariantCulture));
builder.AddAttribute(63, "y", (endY - 6).ToString("0.##", CultureInfo.InvariantCulture));
builder.AddAttribute(64, "text-anchor", "end");
builder.AddContent(65, FormatTickValue(points[pointCount - 1].Bankroll));
builder.CloseElement();
}
builder.CloseElement(); // svg
};
// ---- Formatting / labels -----------------------------------------------
private string StakeRuleLabel(StakeRule rule) => rule switch
{
StakeRule.Flat => L["Backtest.StakeRule.Flat"],
StakeRule.PercentOfBankroll => L["Backtest.StakeRule.PercentOfBankroll"],
StakeRule.Kelly => L["Backtest.StakeRule.Kelly"],
_ => rule.ToString(),
};
private string SideLabel(Side side) => side switch
{
Side.Side1 => L["Journal.Side.Side1"],
Side.Side2 => L["Journal.Side.Side2"],
Side.Draw => L["Journal.Side.Draw"],
Side.Less => L["Journal.Side.Less"],
Side.More => L["Journal.Side.More"],
_ => side.ToString(),
};
private static string FormatDecimal(decimal value) =>
value.ToString("0.00", CultureInfo.InvariantCulture);
private static string FormatSignedDecimal(decimal value, int betsPlaced)
{
if (betsPlaced == 0) return "—";
var sign = value > 0m ? "+" : (value < 0m ? "-" : "");
var abs = Math.Abs(value);
return sign + abs.ToString("0.00", CultureInfo.InvariantCulture);
}
private static string FormatSignedPercent(decimal? value)
{
if (value is null) return "—";
var v = value.Value;
var sign = v > 0m ? "+" : (v < 0m ? "-" : "");
var abs = Math.Abs(v);
return sign + abs.ToString("0.0", CultureInfo.InvariantCulture) + "%";
}
private static string FormatTickValue(decimal value) =>
value.ToString("0", CultureInfo.InvariantCulture);
private static string BankrollTone(BacktestVm vm)
{
if (vm.BetsPlaced == 0) return "neutral";
if (vm.FinalBankroll > vm.StartingBankroll) return "positive";
if (vm.FinalBankroll < vm.StartingBankroll) return "negative";
return "neutral";
}
private static string ProfitTone(BacktestVm vm)
{
if (vm.BetsPlaced == 0) return "neutral";
if (vm.NetProfit > 0m) return "positive";
if (vm.NetProfit < 0m) return "negative";
return "neutral";
}
private static string RoiTone(decimal? roi) => roi switch
{
null => "neutral",
> 0m => "positive",
< 0m => "negative",
_ => "neutral",
};
public void Dispose()
{
_runCts?.Cancel();
_runCts?.Dispose();
}
}
@@ -54,6 +54,13 @@
data-test="link-back-to-event">
@L["Anomaly.Detail.LinkBackToEvent"]
</MudButton>
<MudButton Variant="Variant.Outlined"
StartIcon="@Icons.Material.Outlined.Receipt"
OnClick="@(() => Nav.NavigateTo($"/my-bets?eventId={Uri.EscapeDataString(_detail.Item.EventId.Value)}"))"
Class="m-detail-header__export"
data-test="log-bet">
@L["Action.LogBet"]
</MudButton>
</aside>
</header>
@@ -99,6 +106,7 @@
private string KindLabel(AnomalyKind kind) => kind switch
{
AnomalyKind.SuspensionFlip => L["Anomaly.Kind.SuspensionFlip"],
AnomalyKind.SteamMove => L["Anomaly.Kind.SteamMove"],
_ => kind.ToString(),
};
@@ -0,0 +1,887 @@
@*
Insights — calibration page for the SuspensionFlip detector.
Loads a precomputed AnomalyInsightsVm and answers the single question that
matters: when the bookmaker flipped, did the post-flip favourite actually
win? Big numbers up top, three breakdowns in the middle, drill-down tables
at the bottom. Same editorial-quant tone as AnomalyFeed / Home.
*@
@page "/anomalies/insights"
@using Marathon.Application.Reporting
@using Marathon.Domain.AnomalyDetection
@using Marathon.Domain.Enums
@implements IDisposable
@inject IStringLocalizer<SharedResource> L
@inject IAnomalyInsightsService InsightsService
@inject NavigationManager Nav
@inject ILogger<Insights> Logger
<PageTitle>@L["App.Title"] · @L["Nav.Insights"]</PageTitle>
<section class="m-shell">
<header class="m-rise m-rise-1 m-insights__header" data-test="insights-header">
<div class="m-insights__header-text">
<span class="m-kicker" style="color: var(--m-c-anomaly); border-color: var(--m-c-anomaly);">
@L["Insights.Kicker"]
</span>
<h1 class="m-display" style="font-size: clamp(2rem, 4vw, 3rem);">@L["Insights.Title"]</h1>
<p style="color: var(--m-c-ink-soft); max-width: 64ch;">@L["Insights.Lede"]</p>
</div>
<div class="m-insights__header-actions">
<button type="button"
class="m-chip m-insights__refresh"
@onclick="LoadAsync"
disabled="@_loading"
data-test="insights-refresh">
<span class="m-insights__refresh-glyph @(_loading ? "is-spinning" : null)" aria-hidden="true">↻</span>
<span>@L["Insights.Action.Refresh"]</span>
</button>
</div>
</header>
@if (_loading && _vm is null)
{
<div class="m-list-empty m-rise m-rise-2" data-test="insights-loading">
<MudProgressCircular Indeterminate="true" Size="Size.Small" />
<span class="m-mono">@L["Common.Loading"]</span>
</div>
}
else if (_errored && _vm is null)
{
<div class="m-list-empty m-rise m-rise-2" data-test="insights-error">
<span class="m-kicker" style="border-color: var(--m-c-anomaly); color: var(--m-c-anomaly);">
@L["Common.Empty"]
</span>
<p style="color: var(--m-c-ink-soft); margin-top: var(--m-space-3); max-width: 50ch;">
@L["Insights.Empty.None"]
</p>
</div>
}
else if (_vm is { } vm)
{
@* ---------- KPI strip ---------- *@
<div class="m-insights__kpis m-rise m-rise-2" data-test="insights-kpis">
<article class="m-insights__kpi m-insights__kpi--@HitRateTone(vm.HitRate)" data-test="insights-kpi-hitrate">
<span class="m-insights__kpi-label">@L["Insights.Stat.HitRate"]</span>
<span class="m-insights__kpi-value">@FormatPercent(vm.HitRate)</span>
<span class="m-insights__kpi-hint">@L["Insights.Stat.HitRate.Hint"]</span>
</article>
<article class="m-insights__kpi" data-test="insights-kpi-resolved">
<span class="m-insights__kpi-label">@L["Insights.Stat.Resolved"]</span>
<span class="m-insights__kpi-value">
@vm.ResolvedCount<span class="m-insights__kpi-denom">&nbsp;/&nbsp;@vm.TotalAnomalies</span>
</span>
<span class="m-insights__kpi-hint">@L["Insights.Stat.Resolved.Hint"]</span>
</article>
<article class="m-insights__kpi" data-test="insights-kpi-unresolved">
<span class="m-insights__kpi-label">@L["Insights.Stat.Unresolved"]</span>
<span class="m-insights__kpi-value">@vm.UnresolvedCount</span>
<span class="m-insights__kpi-hint">@L["Insights.Stat.Unresolved.Hint"]</span>
</article>
<article class="m-insights__kpi m-insights__kpi--split" data-test="insights-kpi-hitsmisses">
<div class="m-insights__split">
<div class="m-insights__split-cell">
<span class="m-insights__kpi-label">@L["Insights.Stat.Hits"]</span>
<span class="m-insights__kpi-value m-insights__kpi-value--positive">@vm.HitCount</span>
</div>
<div class="m-insights__split-divider" aria-hidden="true"></div>
<div class="m-insights__split-cell">
<span class="m-insights__kpi-label">@L["Insights.Stat.Misses"]</span>
<span class="m-insights__kpi-value m-insights__kpi-value--negative">@vm.MissCount</span>
</div>
</div>
</article>
</div>
<hr class="m-rule--double" />
@* ---------- By severity ---------- *@
<section class="m-insights__section m-rise m-rise-3" data-test="insights-by-severity">
<header class="m-insights__section-head">
<span class="m-kicker">@L["Insights.Section.BySeverity"]</span>
</header>
@RenderBucketTable(vm.BySeverity, BucketRenderKind.Severity)
</section>
<hr class="m-rule--double" />
@* ---------- By sport ---------- *@
<section class="m-insights__section m-rise m-rise-3" data-test="insights-by-sport">
<header class="m-insights__section-head">
<span class="m-kicker">@L["Insights.Section.BySport"]</span>
</header>
@RenderBucketTable(vm.BySport, BucketRenderKind.Sport)
</section>
<hr class="m-rule--double" />
@* ---------- By score bin (7 fixed rows) ---------- *@
<section class="m-insights__section m-rise m-rise-3" data-test="insights-by-score">
<header class="m-insights__section-head">
<span class="m-kicker">@L["Insights.Section.ByScore"]</span>
</header>
@RenderBucketTable(vm.ByScoreBin, BucketRenderKind.Score)
</section>
<hr class="m-rule--double" />
@* ---------- Resolved table ---------- *@
<section class="m-insights__section m-rise m-rise-4" data-test="insights-resolved">
<header class="m-insights__section-head">
<span class="m-kicker">@L["Insights.Section.Resolved"]</span>
<span class="m-insights__section-count m-mono">@vm.Resolved.Count</span>
</header>
@if (vm.TotalAnomalies == 0)
{
<div class="m-list-empty" data-test="insights-empty-none">
<span class="m-kicker" style="border-color: var(--m-c-ink-soft); color: var(--m-c-ink-soft);">
@L["Common.Empty"]
</span>
<p style="color: var(--m-c-ink-soft); margin-top: var(--m-space-3); max-width: 56ch;">
@L["Insights.Empty.None"]
</p>
</div>
}
else if (vm.Resolved.Count == 0)
{
<div class="m-list-empty" data-test="insights-empty-resolved">
<span class="m-kicker" style="border-color: var(--m-c-ink-soft); color: var(--m-c-ink-soft);">
@L["Common.Empty"]
</span>
<p style="color: var(--m-c-ink-soft); margin-top: var(--m-space-3); max-width: 56ch;">
@L["Insights.Empty.NoneResolved"]
</p>
</div>
}
else
{
<div class="m-insights__table-wrap">
<table class="m-insights__table" data-test="insights-resolved-table">
<thead>
<tr>
<th scope="col">@L["Insights.Column.DetectedAt"]</th>
<th scope="col">@L["Insights.Column.Match"]</th>
<th scope="col">@L["Insights.Column.Sport"]</th>
<th scope="col" style="text-align: right;">@L["Insights.Column.Score"]</th>
<th scope="col">@L["Insights.Column.PreFavourite"]</th>
<th scope="col">@L["Insights.Column.PostFavourite"]</th>
<th scope="col">@L["Insights.Column.Winner"]</th>
<th scope="col">@L["Insights.Column.Outcome"]</th>
<th scope="col"></th>
</tr>
</thead>
<tbody>
@foreach (var row in vm.Resolved)
{
var local = row;
<tr class="m-insights__row m-insights__row--@OutcomeCss(local.Outcome)"
data-test="insights-resolved-row"
data-anomaly-id="@local.AnomalyId">
<td class="m-mono">@local.DetectedAt.ToLocalTime().ToString("yyyy-MM-dd HH:mm", System.Globalization.CultureInfo.InvariantCulture)</td>
<td style="font-weight: 500;">@local.EventTitle</td>
<td>
@if (local.Sport is { } sport)
{
<span class="m-insights__sport">
<SportIcon Code="@sport.Value" Label="@SportLabels.Resolve(L, sport.Value)" ClassName="m-insights__sport-icon" />
<span>@SportLabels.Resolve(L, sport.Value)</span>
</span>
}
else
{
<span style="color: var(--m-c-ink-soft);">—</span>
}
</td>
<td class="m-mono" style="text-align: right; font-weight: 600;">
@local.Score.ToString("0.00", System.Globalization.CultureInfo.InvariantCulture)
</td>
<td>@SideLabel(local.PreFlipFavourite)</td>
<td style="font-weight: 600;">@SideLabel(local.PostFlipFavourite)</td>
<td>@SideLabel(local.ActualWinner)</td>
<td>
<span class="m-insights__verdict m-insights__verdict--@OutcomeCss(local.Outcome)">
@OutcomeLabel(local.Outcome)
</span>
</td>
<td>
<a href="@($"/anomalies/{local.AnomalyId}")"
class="m-insights__open"
data-test="insights-open-link"
@onclick="@(e => OpenAnomaly(e, local.AnomalyId))"
@onclick:preventDefault>
@L["Insights.Action.OpenAnomaly"]
<span aria-hidden="true">→</span>
</a>
</td>
</tr>
}
</tbody>
</table>
</div>
}
</section>
@* ---------- Unresolved table (only when non-empty) ---------- *@
@if (vm.Unresolved.Count > 0)
{
<hr class="m-rule--double" />
<section class="m-insights__section m-rise m-rise-5" data-test="insights-unresolved">
<header class="m-insights__section-head">
<span class="m-kicker" style="color: var(--m-c-ink-soft); border-color: var(--m-c-ink-soft);">
@L["Insights.Section.Unresolved"]
</span>
<span class="m-insights__section-count m-mono">@vm.Unresolved.Count</span>
</header>
<div class="m-insights__table-wrap m-insights__table-wrap--dim">
<table class="m-insights__table" data-test="insights-unresolved-table">
<thead>
<tr>
<th scope="col">@L["Insights.Column.DetectedAt"]</th>
<th scope="col">@L["Insights.Column.Match"]</th>
<th scope="col">@L["Insights.Column.Sport"]</th>
<th scope="col" style="text-align: right;">@L["Insights.Column.Score"]</th>
<th scope="col">@L["Insights.Column.PreFavourite"]</th>
<th scope="col">@L["Insights.Column.PostFavourite"]</th>
<th scope="col">@L["Insights.Column.Outcome"]</th>
<th scope="col"></th>
</tr>
</thead>
<tbody>
@foreach (var row in vm.Unresolved)
{
var local = row;
<tr class="m-insights__row m-insights__row--pending"
data-test="insights-unresolved-row"
data-anomaly-id="@local.AnomalyId">
<td class="m-mono">@local.DetectedAt.ToLocalTime().ToString("yyyy-MM-dd HH:mm", System.Globalization.CultureInfo.InvariantCulture)</td>
<td>@local.EventTitle</td>
<td>
@if (local.Sport is { } sport)
{
<span class="m-insights__sport">
<SportIcon Code="@sport.Value" Label="@SportLabels.Resolve(L, sport.Value)" ClassName="m-insights__sport-icon" />
<span>@SportLabels.Resolve(L, sport.Value)</span>
</span>
}
else
{
<span style="color: var(--m-c-ink-soft);">—</span>
}
</td>
<td class="m-mono" style="text-align: right; font-weight: 600;">
@local.Score.ToString("0.00", System.Globalization.CultureInfo.InvariantCulture)
</td>
<td>@SideLabel(local.PreFlipFavourite)</td>
<td style="font-weight: 600;">@SideLabel(local.PostFlipFavourite)</td>
<td>
<span class="m-insights__verdict m-insights__verdict--pending">
@L["Insights.Outcome.Unresolved"]
</span>
</td>
<td>
<a href="@($"/anomalies/{local.AnomalyId}")"
class="m-insights__open"
@onclick="@(e => OpenAnomaly(e, local.AnomalyId))"
@onclick:preventDefault>
@L["Insights.Action.OpenAnomaly"]
<span aria-hidden="true">→</span>
</a>
</td>
</tr>
}
</tbody>
</table>
</div>
</section>
}
}
</section>
<style>
.m-insights__header {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: var(--m-space-5);
align-items: end;
}
@@media (max-width: 720px) {
.m-insights__header { grid-template-columns: 1fr; }
.m-insights__header-actions { justify-self: start; }
}
.m-insights__header-text {
display: grid;
gap: var(--m-space-3);
max-width: 880px;
}
.m-insights__header-actions { display: flex; gap: var(--m-space-3); }
.m-insights__refresh {
gap: var(--m-space-2);
padding: 6px 12px;
font-family: var(--m-font-mono);
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.14em;
}
.m-insights__refresh:disabled { opacity: 0.6; cursor: progress; }
.m-insights__refresh-glyph {
display: inline-block;
font-size: 0.875rem;
line-height: 1;
transition: transform 200ms ease;
}
.m-insights__refresh:hover .m-insights__refresh-glyph { transform: rotate(45deg); }
.m-insights__refresh-glyph.is-spinning { animation: m-insights-spin 1.1s linear infinite; }
@@keyframes m-insights-spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
@@media (prefers-reduced-motion: reduce) {
.m-insights__refresh-glyph.is-spinning { animation: none; }
.m-insights__refresh:hover .m-insights__refresh-glyph { transform: none; }
}
/* ---- KPI strip ---- */
.m-insights__kpis {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: var(--m-space-4);
}
.m-insights__kpi {
background: var(--m-c-paper);
border: 1px solid var(--m-c-rule);
border-left: 3px solid var(--m-c-rule);
padding: var(--m-space-4) var(--m-space-5);
display: flex;
flex-direction: column;
gap: var(--m-space-2);
position: relative;
}
.m-insights__kpi--positive { border-left-color: var(--m-c-positive); }
.m-insights__kpi--neutral { border-left-color: var(--m-c-accent); }
.m-insights__kpi--negative { border-left-color: var(--m-c-anomaly); }
.m-insights__kpi-label {
font-family: var(--m-font-mono);
font-size: 0.6875rem;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--m-c-ink-soft);
}
.m-insights__kpi-value {
font-family: var(--m-font-mono);
font-feature-settings: var(--m-num-feature);
font-size: clamp(2rem, 3.5vw, 2.625rem);
font-weight: 500;
line-height: 1;
letter-spacing: -0.02em;
color: var(--m-c-ink);
}
.m-insights__kpi--positive .m-insights__kpi-value { color: var(--m-c-positive); }
.m-insights__kpi--negative .m-insights__kpi-value { color: var(--m-c-anomaly); }
.m-insights__kpi-value--positive { color: var(--m-c-positive); }
.m-insights__kpi-value--negative { color: var(--m-c-anomaly); }
.m-insights__kpi-denom {
font-size: 0.55em;
color: var(--m-c-ink-soft);
font-weight: 400;
}
.m-insights__kpi-hint {
font-size: 0.8125rem;
color: var(--m-c-ink-soft);
}
.m-insights__kpi--split { padding: var(--m-space-4) var(--m-space-5); }
.m-insights__split {
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
gap: var(--m-space-3);
}
.m-insights__split-cell { display: flex; flex-direction: column; gap: 6px; }
.m-insights__split-cell:last-child { text-align: right; }
.m-insights__split-divider {
width: 1px;
height: 56px;
background: var(--m-c-rule);
}
/* ---- Section headers ---- */
.m-insights__section { display: grid; gap: var(--m-space-4); }
.m-insights__section-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var(--m-space-3);
}
.m-insights__section-count {
font-size: 0.6875rem;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--m-c-ink-soft);
}
/* ---- Bucket / breakdown grid ---- */
.m-insights__buckets {
background: var(--m-c-paper);
border: 1px solid var(--m-c-rule);
overflow: hidden;
}
.m-insights__bucket-head,
.m-insights__bucket-row {
display: grid;
grid-template-columns: minmax(180px, 1.4fr) minmax(140px, 1fr) minmax(220px, 2fr);
gap: var(--m-space-4);
align-items: center;
padding: var(--m-space-3) var(--m-space-4);
}
.m-insights__bucket-head {
font-family: var(--m-font-mono);
font-size: 0.6875rem;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--m-c-ink-soft);
background: var(--m-c-paper-2);
border-bottom: 1px solid var(--m-c-rule);
}
.m-insights__bucket-row {
border-bottom: 1px solid var(--m-c-rule);
font-size: 0.9375rem;
}
.m-insights__bucket-row:last-child { border-bottom: 0; }
.m-insights__bucket-row--dim { color: var(--m-c-ink-soft); }
.m-insights__bucket-row--dim .m-insights__bucket-label { color: var(--m-c-ink-soft); }
.m-insights__bucket-label {
display: inline-flex;
align-items: center;
gap: var(--m-space-3);
font-weight: 500;
}
.m-insights__bucket-label--mono {
font-family: var(--m-font-mono);
letter-spacing: 0.04em;
}
.m-insights__bucket-counts {
font-family: var(--m-font-mono);
font-feature-settings: var(--m-num-feature);
color: var(--m-c-ink-soft);
}
.m-insights__bucket-counts strong {
color: var(--m-c-ink);
font-weight: 600;
}
.m-insights__bar {
display: grid;
grid-template-columns: 1fr 56px;
gap: var(--m-space-3);
align-items: center;
}
.m-insights__bar-track {
position: relative;
height: 8px;
background: var(--m-c-paper-2);
border: 1px solid var(--m-c-rule);
overflow: hidden;
}
.m-insights__bar-fill {
position: absolute;
inset: 0 auto 0 0;
background: var(--m-c-accent);
transition: width 320ms cubic-bezier(0.2, 0.7, 0.2, 1);
}
.m-insights__bar-fill--positive { background: var(--m-c-positive); }
.m-insights__bar-fill--negative { background: var(--m-c-anomaly); }
.m-insights__bar-fill--neutral { background: var(--m-c-accent); }
@@media (prefers-reduced-motion: reduce) {
.m-insights__bar-fill { transition: none; }
}
.m-insights__bar-pct {
font-family: var(--m-font-mono);
font-feature-settings: var(--m-num-feature);
font-size: 0.875rem;
font-weight: 600;
color: var(--m-c-ink);
text-align: right;
}
.m-insights__bar-na {
font-family: var(--m-font-mono);
font-size: 0.75rem;
color: var(--m-c-ink-soft);
letter-spacing: 0.1em;
}
/* ---- Resolved / unresolved tables ---- */
.m-insights__table-wrap {
background: var(--m-c-paper);
border: 1px solid var(--m-c-rule);
overflow-x: auto;
}
.m-insights__table-wrap--dim { background: var(--m-c-paper-2); opacity: 0.92; }
.m-insights__table {
width: 100%;
border-collapse: collapse;
font-family: var(--m-font-body);
}
.m-insights__table thead th {
font-family: var(--m-font-mono);
font-size: 0.6875rem;
letter-spacing: 0.14em;
text-transform: uppercase;
text-align: left;
padding: var(--m-space-3) var(--m-space-3);
border-bottom: 1px solid var(--m-c-rule);
color: var(--m-c-ink-soft);
background: var(--m-c-paper-2);
white-space: nowrap;
}
.m-insights__table tbody td {
padding: var(--m-space-3) var(--m-space-3);
border-bottom: 1px solid var(--m-c-rule);
vertical-align: middle;
font-size: 0.9375rem;
}
.m-insights__table tbody tr:last-child td { border-bottom: 0; }
.m-insights__row { transition: background 120ms ease; }
.m-insights__row:hover { background: var(--m-c-paper-2); }
.m-insights__row--hit { box-shadow: inset 2px 0 0 0 var(--m-c-positive); }
.m-insights__row--miss { box-shadow: inset 2px 0 0 0 var(--m-c-anomaly); }
.m-insights__row--pending { box-shadow: inset 2px 0 0 0 var(--m-c-rule); }
.m-insights__sport {
display: inline-flex;
align-items: center;
gap: var(--m-space-2);
}
.m-insights__sport-icon { --m-sport-size: 18px; }
.m-insights__verdict {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 2px 8px;
font-family: var(--m-font-mono);
font-size: 0.6875rem;
letter-spacing: 0.14em;
text-transform: uppercase;
border: 1px solid currentColor;
border-radius: var(--m-radius-xs);
background: rgba(0, 0, 0, 0);
}
.m-insights__verdict--hit {
color: var(--m-c-positive);
background: rgba(21, 128, 61, 0.10);
}
.m-insights__verdict--miss {
color: var(--m-c-anomaly);
background: rgba(220, 38, 38, 0.10);
}
.m-insights__verdict--pending {
color: var(--m-c-ink-soft);
background: transparent;
}
[data-theme="dark"] .m-insights__verdict--hit {
color: var(--m-c-positive);
background: rgba(34, 197, 94, 0.15);
}
[data-theme="dark"] .m-insights__verdict--miss {
color: var(--m-c-anomaly);
background: rgba(248, 113, 113, 0.15);
}
.m-insights__open {
display: inline-flex;
align-items: center;
gap: 6px;
font-family: var(--m-font-mono);
font-size: 0.75rem;
letter-spacing: 0.12em;
text-transform: uppercase;
text-decoration: none;
color: var(--m-c-ink);
border-bottom: 1px solid var(--m-c-accent);
padding-bottom: 1px;
transition: color 120ms ease, border-color 120ms ease;
}
.m-insights__open:hover {
color: var(--m-c-accent);
border-bottom-color: var(--m-c-ink);
}
/* ---- Empty-state block (shared with feed) ---- */
.m-list-empty {
display: grid;
place-content: center;
gap: var(--m-space-3);
padding: var(--m-space-7);
text-align: center;
background: var(--m-c-paper);
border: 1px solid var(--m-c-rule);
}
</style>
@code {
// Render kind for the breakdown grid — disambiguates how `Key` is shown.
private enum BucketRenderKind
{
Severity,
Sport,
Score,
}
private AnomalyInsightsVm? _vm;
private bool _loading = true;
private bool _errored;
private CancellationTokenSource? _loadCts;
protected override async Task OnInitializedAsync()
{
await LoadAsync();
}
private async Task LoadAsync()
{
_loadCts?.Cancel();
_loadCts = new CancellationTokenSource();
var ct = _loadCts.Token;
_loading = true;
_errored = false;
StateHasChanged();
try
{
var report = await InsightsService.GetReportAsync(ct);
if (ct.IsCancellationRequested) return;
_vm = report;
}
catch (OperationCanceledException) { /* superseded */ }
catch (Exception ex)
{
Logger.LogError(ex, "Insights: failed to build the anomaly outcome report.");
_errored = true;
_vm = null;
}
finally
{
_loading = false;
StateHasChanged();
}
}
private void OpenAnomaly(MouseEventArgs e, Guid anomalyId)
{
Nav.NavigateTo("/anomalies/" + anomalyId.ToString());
}
// ---- Bucket rendering ---------------------------------------------------
private RenderFragment RenderBucketTable(
IReadOnlyList<OutcomeBucket> buckets,
BucketRenderKind kind) => builder =>
{
builder.OpenElement(0, "div");
builder.AddAttribute(1, "class", "m-insights__buckets");
builder.AddAttribute(2, "data-test", "insights-bucket-grid");
// Head row
builder.OpenElement(10, "div");
builder.AddAttribute(11, "class", "m-insights__bucket-head");
builder.OpenElement(12, "span");
builder.AddContent(13, L["Insights.Column.Bucket"]);
builder.CloseElement();
builder.OpenElement(14, "span");
builder.AddContent(15, L["Insights.Column.HitsOfTotal"]);
builder.CloseElement();
builder.OpenElement(16, "span");
builder.AddContent(17, L["Insights.Column.HitRate"]);
builder.CloseElement();
builder.CloseElement();
// Data rows
var seq = 100;
foreach (var bucket in buckets)
{
var local = bucket;
var isEmpty = local.Total == 0;
var rowClass = isEmpty
? "m-insights__bucket-row m-insights__bucket-row--dim"
: "m-insights__bucket-row";
builder.OpenElement(seq++, "div");
builder.AddAttribute(seq++, "class", rowClass);
builder.AddAttribute(seq++, "data-test", "insights-bucket-row");
builder.AddAttribute(seq++, "data-bucket-key", local.Key);
// Label cell
builder.OpenElement(seq++, "span");
var labelClass = kind == BucketRenderKind.Score
? "m-insights__bucket-label m-insights__bucket-label--mono"
: "m-insights__bucket-label";
builder.AddAttribute(seq++, "class", labelClass);
builder.AddContent(seq++, RenderBucketLabel(local.Key, kind));
builder.CloseElement();
// Counts cell
builder.OpenElement(seq++, "span");
builder.AddAttribute(seq++, "class", "m-insights__bucket-counts");
builder.OpenElement(seq++, "strong");
builder.AddContent(seq++, local.Hits.ToString(System.Globalization.CultureInfo.InvariantCulture));
builder.CloseElement();
builder.AddContent(seq++, " / " + local.Total.ToString(System.Globalization.CultureInfo.InvariantCulture));
builder.CloseElement();
// Hit-rate cell — bar + percent, or N/A pill when empty
builder.OpenElement(seq++, "div");
builder.AddAttribute(seq++, "class", "m-insights__bar");
if (isEmpty || local.HitRate is null)
{
builder.OpenElement(seq++, "span");
builder.AddAttribute(seq++, "class", "m-insights__bar-na");
builder.AddContent(seq++, L["Insights.Bucket.NotApplicable"]);
builder.CloseElement();
builder.OpenElement(seq++, "span");
builder.AddAttribute(seq++, "class", "m-insights__bar-pct");
builder.AddAttribute(seq++, "style", "color: var(--m-c-ink-soft);");
builder.AddContent(seq++, "—");
builder.CloseElement();
}
else
{
var rate = local.HitRate.Value;
var pct = (double)(rate * 100m);
var pctClamped = Math.Max(0, Math.Min(100, pct));
var tone = rate >= 0.60m ? "positive" : (rate < 0.40m ? "negative" : "neutral");
builder.OpenElement(seq++, "div");
builder.AddAttribute(seq++, "class", "m-insights__bar-track");
builder.AddAttribute(seq++, "role", "progressbar");
builder.AddAttribute(seq++, "aria-valuemin", "0");
builder.AddAttribute(seq++, "aria-valuemax", "100");
builder.AddAttribute(seq++, "aria-valuenow", pctClamped.ToString("0", System.Globalization.CultureInfo.InvariantCulture));
builder.OpenElement(seq++, "div");
builder.AddAttribute(seq++, "class", "m-insights__bar-fill m-insights__bar-fill--" + tone);
builder.AddAttribute(seq++, "style", "width: " + pctClamped.ToString("0.###", System.Globalization.CultureInfo.InvariantCulture) + "%;");
builder.CloseElement();
builder.CloseElement();
builder.OpenElement(seq++, "span");
builder.AddAttribute(seq++, "class", "m-insights__bar-pct");
builder.AddContent(seq++, ((int)Math.Round(pct, MidpointRounding.AwayFromZero)).ToString(System.Globalization.CultureInfo.InvariantCulture) + "%");
builder.CloseElement();
}
builder.CloseElement(); // .m-insights__bar
builder.CloseElement(); // .m-insights__bucket-row
}
builder.CloseElement(); // .m-insights__buckets
};
private RenderFragment RenderBucketLabel(string key, BucketRenderKind kind) => builder =>
{
switch (kind)
{
case BucketRenderKind.Severity:
{
var locKey = key switch
{
OutcomeBucketKeys.SeverityHigh => "Anomaly.Severity.High",
OutcomeBucketKeys.SeverityMedium => "Anomaly.Severity.Medium",
OutcomeBucketKeys.SeverityLow => "Anomaly.Severity.Low",
_ => "Anomaly.Severity.Low",
};
builder.AddContent(0, L[locKey]);
break;
}
case BucketRenderKind.Sport:
{
var trimmed = key.StartsWith(OutcomeBucketKeys.SportPrefix, StringComparison.Ordinal)
? key.Substring(OutcomeBucketKeys.SportPrefix.Length)
: key;
if (int.TryParse(trimmed, System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out var code))
{
var label = SportLabels.Resolve(L, code);
builder.OpenComponent<SportIcon>(0);
builder.AddAttribute(1, "Code", code);
builder.AddAttribute(2, "Label", label);
builder.AddAttribute(3, "ClassName", "m-insights__sport-icon");
builder.CloseComponent();
builder.AddContent(4, label);
}
else
{
builder.AddContent(0, key);
}
break;
}
case BucketRenderKind.Score:
default:
{
var trimmed = key.StartsWith(OutcomeBucketKeys.BinPrefix, StringComparison.Ordinal)
? key.Substring(OutcomeBucketKeys.BinPrefix.Length)
: key;
builder.AddContent(0, trimmed);
break;
}
}
};
// ---- Formatting / labels -----------------------------------------------
private static string HitRateTone(decimal? rate) => rate switch
{
null => "neutral",
>= 0.60m => "positive",
< 0.40m => "negative",
_ => "neutral",
};
private static string FormatPercent(decimal? rate)
{
if (rate is null) return "—";
var pct = (int)Math.Round(rate.Value * 100m, MidpointRounding.AwayFromZero);
return pct.ToString(System.Globalization.CultureInfo.InvariantCulture) + "%";
}
private string SideLabel(Side? side) => side switch
{
Side.Side1 => L["Insights.Side.Side1"],
Side.Side2 => L["Insights.Side.Side2"],
Side.Draw => L["Insights.Side.Draw"],
_ => L["Insights.Side.Unknown"],
};
private string OutcomeLabel(AnomalyOutcomeKind o) => o switch
{
AnomalyOutcomeKind.Hit => L["Insights.Outcome.Hit"],
AnomalyOutcomeKind.Miss => L["Insights.Outcome.Miss"],
AnomalyOutcomeKind.Unresolved => L["Insights.Outcome.Unresolved"],
_ => L["Insights.Outcome.Unresolved"],
};
private static string OutcomeCss(AnomalyOutcomeKind o) => o switch
{
AnomalyOutcomeKind.Hit => "hit",
AnomalyOutcomeKind.Miss => "miss",
AnomalyOutcomeKind.Unresolved => "pending",
_ => "pending",
};
public void Dispose()
{
_loadCts?.Cancel();
_loadCts?.Dispose();
}
}
@@ -0,0 +1,58 @@
@*
ExportHub — a top-level home for the Excel export so it is no longer gated
behind opening a single event's detail page. Hosts the shared ExportDialog
(date range + kind) which is not event-specific.
*@
@page "/export"
@inject IStringLocalizer<SharedResource> L
@inject IDialogService Dialog
@inject ISnackbar Snackbar
<PageTitle>@L["App.Title"] · @L["Export.Title"]</PageTitle>
<section class="m-shell">
<header class="m-rise m-rise-1" style="display: grid; gap: var(--m-space-3); max-width: 880px;">
<span class="m-kicker">@L["Export.Kicker"]</span>
<h1 class="m-display" style="font-size: clamp(2rem, 4vw, 3rem);">@L["Export.Title"]</h1>
<p style="color: var(--m-c-ink-soft); max-width: 60ch;">@L["Export.Hub.Lede"]</p>
</header>
<hr class="m-rule--double" />
<div class="m-rise m-rise-2" style="display: grid; gap: var(--m-space-3);">
<div>
<button type="button" class="m-chip" @onclick="OpenExportDialog" data-test="export-hub-open">
@L["Export.Hub.Action"] →
</button>
</div>
<span class="m-mono" style="font-size: 0.75rem; color: var(--m-c-ink-soft); letter-spacing: 0.08em;">
@L["Export.Hub.FilenameHint"]
</span>
</div>
</section>
@code {
private async Task OpenExportDialog()
{
var parameters = new DialogParameters
{
["InitialFrom"] = MoscowTime.Now.Date.AddDays(-7),
["InitialTo"] = MoscowTime.Now.Date,
};
var options = new DialogOptions
{
CloseOnEscapeKey = true,
FullWidth = true,
MaxWidth = MaxWidth.Small,
};
var reference = await Dialog.ShowAsync<ExportDialog>(L["Export.Title"], parameters, options);
var result = await reference.Result;
if (result is { Canceled: false, Data: string path })
{
Snackbar.Add(string.Format(CultureInfo.CurrentCulture, L["Export.Success"].Value, path), Severity.Success);
}
}
}
+103 -35
View File
@@ -1,5 +1,7 @@
@page "/"
@inject IStringLocalizer<SharedResource> L
@inject IDashboardSummaryService Dashboard
@inject ILogger<Home> Logger
<PageTitle>@L["App.Title"] · @L["Nav.Dashboard"]</PageTitle>
@@ -15,10 +17,13 @@
<hr class="m-rule--double" />
<div class="m-grid--three m-rise m-rise-2">
<StatCard Label="@L["Home.Stat.Events"]" Value="@_eventsTracked.ToString("N0")" Delta="+12%" />
<StatCard Label="@L["Home.Stat.Snapshots"]" Value="@_snapshotsToday.ToString("N0")" Delta="+318" />
<StatCard Label="@L["Home.Stat.Anomalies"]" Value="@_anomalies.ToString()" Delta="3 NEW" Anomaly="true" />
<StatCard Label="@L["Home.Stat.Coverage"]" Value="4" Delta="BSK · FBL · TNS · HKY" />
<StatCard Label="@L["Home.Stat.Events"]" Value="@_summary.EventsTracked.ToString("N0")" />
<StatCard Label="@L["Home.Stat.Snapshots"]" Value="@_summary.SnapshotsToday.ToString("N0")" />
<StatCard Label="@L["Home.Stat.Anomalies"]"
Value="@_summary.AnomaliesTotal.ToString("N0")"
Delta="@AnomaliesDelta"
Anomaly="true" />
<StatCard Label="@L["Home.Stat.Coverage"]" Value="@_summary.SportsCovered.ToString()" />
</div>
<div class="m-grid--asym m-rise m-rise-3" style="margin-top: var(--m-space-6);">
@@ -30,49 +35,112 @@
@L["Anomaly.Kind.SuspensionFlip"]
</h2>
<div style="display: grid; gap: var(--m-space-4);">
@foreach (var item in _placeholderFeed)
{
<article style="display: grid; grid-template-columns: 80px 1fr auto; gap: var(--m-space-4); padding: var(--m-space-3) 0; border-top: 1px solid var(--m-c-rule);">
<div class="m-mono" style="font-size: 0.75rem; color: var(--m-c-ink-soft); text-transform: uppercase; letter-spacing: 0.1em;">
@item.Time
</div>
<div>
<div style="font-weight: 500;">@item.Match</div>
<div style="color: var(--m-c-ink-soft); font-size: 0.8125rem;">@item.Detail</div>
</div>
<span class="m-anomaly">
<span class="m-anomaly__pulse"></span>
@($"{item.Score:0.00}")
</span>
</article>
}
</div>
@if (!_summary.HasAnyData)
{
@* First-run: nothing captured yet. Make the next step unmissable. *@
<div data-test="home-empty" style="display: grid; gap: var(--m-space-4); padding: var(--m-space-4) 0;">
<span class="m-mono" style="font-size: 0.6875rem; text-transform: uppercase; letter-spacing: 0.16em; color: var(--m-c-accent);">
@L["Home.Empty.Heading"]
</span>
<p style="color: var(--m-c-ink-soft); max-width: 48ch; margin: 0;">
@L["Home.Empty"]
</p>
<div>
<a href="/settings" data-test="home-empty-cta"
style="display: inline-flex; align-items: center; gap: 8px; padding: 10px 18px; border: 1px solid var(--m-c-accent); color: var(--m-c-accent); font-family: var(--m-font-mono); font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.12em; text-decoration: none;">
@L["Home.Empty.Cta"] →
</a>
</div>
</div>
}
else if (_summary.LatestSignals.Count == 0)
{
@* Capturing, but the detector hasn't flagged anything yet. *@
<div data-test="home-no-signals" style="display: grid; gap: var(--m-space-3); padding: var(--m-space-3) 0; border-top: 1px solid var(--m-c-rule);">
<p style="color: var(--m-c-ink-soft); margin: 0;">@L["Home.NoSignals"]</p>
<a href="/anomalies" style="font-family: var(--m-font-mono); font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.12em; color: var(--m-c-accent); text-decoration: none;">
@L["Home.ViewAll"] →
</a>
</div>
}
else
{
<div style="display: grid; gap: var(--m-space-4);">
@foreach (var signal in _summary.LatestSignals)
{
<a href="@($"/anomalies/{signal.Id}")" data-test="home-signal"
style="display: grid; grid-template-columns: 80px 1fr auto; gap: var(--m-space-4); padding: var(--m-space-3) 0; border-top: 1px solid var(--m-c-rule); text-decoration: none; color: inherit;">
<div class="m-mono" style="font-size: 0.75rem; color: var(--m-c-ink-soft); text-transform: uppercase; letter-spacing: 0.1em;">
@FormatSignalTime(signal.DetectedAt)
</div>
<div>
<div style="font-weight: 500;">@signal.EventTitle</div>
<div style="color: var(--m-c-ink-soft); font-size: 0.8125rem;">
@SportLabel(signal.Sport.Value) · @SeverityLabel(signal.Severity)
</div>
</div>
<span class="m-anomaly">
<span class="m-anomaly__pulse"></span>
@signal.Score.ToString("0.00", CultureInfo.InvariantCulture)
</span>
</a>
}
</div>
<div style="margin-top: var(--m-space-5); padding-top: var(--m-space-3); border-top: 1px solid var(--m-c-rule); color: var(--m-c-ink-soft); font-size: 0.8125rem;">
@L["Home.Empty"]
</div>
<div style="margin-top: var(--m-space-5); padding-top: var(--m-space-3); border-top: 1px solid var(--m-c-rule);">
<a href="/anomalies" style="font-family: var(--m-font-mono); font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.12em; color: var(--m-c-accent); text-decoration: none;">
@L["Home.ViewAll"] →
</a>
</div>
}
</div>
<aside class="m-card m-card--accented">
<span class="m-kicker">@L["Home.Section.Pipeline"]</span>
<ol style="list-style: none; padding: 0; margin: var(--m-space-4) 0 0; display: grid; gap: var(--m-space-3); counter-reset: m-step;">
<PipelineStep Index="01" Label="@L["Home.Pipeline.Step1"]" Status="ok" />
<PipelineStep Index="02" Label="@L["Home.Pipeline.Step2"]" Status="ok" />
<PipelineStep Index="03" Label="@L["Home.Pipeline.Step3"]" Status="warn" />
<PipelineStep Index="04" Label="@L["Home.Pipeline.Step4"]" Status="idle" />
<PipelineStep Index="01" Label="@L["Home.Pipeline.Step1"]" Status="@_summary.ScheduleStatus" />
<PipelineStep Index="02" Label="@L["Home.Pipeline.Step2"]" Status="@_summary.SnapshotStatus" />
<PipelineStep Index="03" Label="@L["Home.Pipeline.Step3"]" Status="@_summary.DetectorStatus" />
<PipelineStep Index="04" Label="@L["Home.Pipeline.Step4"]" Status="@_summary.ExportStatus" />
</ol>
</aside>
</div>
</section>
@code {
// Mock data — Phase 6+ will replace with live queries.
private readonly int _eventsTracked = 0;
private readonly int _snapshotsToday = 0;
private readonly int _anomalies = 0;
private DashboardSummary _summary = DashboardSummary.Empty;
private record FeedItem(string Time, string Match, string Detail, decimal Score);
private string? AnomaliesDelta => _summary.AnomaliesToday > 0
? string.Format(CultureInfo.CurrentCulture, L["Home.Stat.NewToday"], _summary.AnomaliesToday)
: null;
private readonly List<FeedItem> _placeholderFeed = new();
protected override async Task OnInitializedAsync()
{
try
{
_summary = await Dashboard.GetAsync(CancellationToken.None);
}
catch (Exception ex)
{
// Dashboard is read-only chrome; on failure keep the empty summary and log.
Logger.LogError(ex, "Home: failed to load dashboard summary.");
}
}
private string FormatSignalTime(DateTimeOffset at)
{
var moscow = at.ToOffset(MoscowTime.Offset);
return moscow.Date == MoscowTime.Now.Date
? moscow.ToString("HH:mm", CultureInfo.InvariantCulture)
: moscow.ToString("dd MMM", CultureInfo.InvariantCulture);
}
private string SportLabel(int code) => SportLabels.Resolve(L, code);
private string SeverityLabel(AnomalySeverity severity) => severity switch
{
AnomalySeverity.High => L["Anomaly.Severity.High"],
AnomalySeverity.Medium => L["Anomaly.Severity.Medium"],
_ => L["Anomaly.Severity.Low"],
};
}
File diff suppressed because it is too large Load Diff
+24 -8
View File
@@ -49,7 +49,7 @@
<Field Label="@L["Settings.Scraping.RetryBaseDelayMs"]">
<MudNumericField T="int" @bind-Value="_scraping.RetryPolicy.BaseDelayMs" Min="100" Max="60000" Variant="Variant.Outlined" />
</Field>
<Field Label="@L["Settings.Scraping.BaseUrl"]">
<Field Label="@L["Settings.Scraping.BaseUrl"]" Hint="@L["Settings.Scraping.BaseUrl.Hint"]">
<MudTextField T="string" @bind-Value="_scraping.BaseUrl" Variant="Variant.Outlined" />
</Field>
<Field Label="@L["Settings.Scraping.RequestTimeoutSeconds"]">
@@ -242,6 +242,20 @@
}
}
if (payload is ScrapingSettingsForm scraping
&& !(Uri.TryCreate(scraping.BaseUrl, UriKind.Absolute, out var baseUri)
&& (baseUri.Scheme == Uri.UriSchemeHttp || baseUri.Scheme == Uri.UriSchemeHttps)))
{
Snackbar.Add(L["Settings.Scraping.BaseUrl.Invalid"], Severity.Error);
return;
}
if (payload is WorkerOptions workers && !IsPlausibleCron(workers.UpcomingScheduleCron))
{
Snackbar.Add(L["Settings.Workers.Cron.Invalid"], Severity.Error);
return;
}
var confirmed = await ConfirmAsync();
if (!confirmed)
{
@@ -260,6 +274,15 @@
}
}
// Lightweight 5- or 6-field cron sanity check — avoids a Cronos dependency in the
// UI layer; the worker still does the authoritative parse at startup.
private static bool IsPlausibleCron(string? expression)
{
if (string.IsNullOrWhiteSpace(expression)) return false;
var fields = expression.Split(' ', StringSplitOptions.RemoveEmptyEntries);
return fields.Length is 5 or 6;
}
private async Task ResetSectionAsync(string section)
{
var confirmed = await ConfirmAsync();
@@ -282,13 +305,6 @@
private async Task<bool> ConfirmAsync()
{
var parameters = new DialogParameters
{
["ContentText"] = L["Settings.Confirm.Body"].Value,
["ButtonText"] = L["Settings.Action.Save"].Value,
["CancelText"] = L["Common.Cancel"].Value,
};
var result = await Dialogs.ShowMessageBox(
title: L["Settings.Confirm.Title"],
message: L["Settings.Confirm.Body"],
@@ -14,6 +14,7 @@
@using DomainEventId = Marathon.Domain.ValueObjects.EventId
@implements IDisposable
@inject IStringLocalizer<SharedResource> L
@inject ILogger<EventListShell> Logger
<section class="m-shell">
<header class="m-rise m-rise-1" style="display: grid; gap: var(--m-space-3); max-width: 880px;">
@@ -364,7 +365,11 @@
private void StartTimer()
{
_refreshTimer?.Dispose();
if (_refreshTimer is not null)
{
_refreshTimer.Elapsed -= OnRefreshTimerElapsed;
_refreshTimer.Dispose();
}
var interval = Math.Max(5, AutoRefreshSeconds) * 1000.0;
_refreshTimer = new System.Timers.Timer(interval) { AutoReset = true };
_refreshTimer.Elapsed += OnRefreshTimerElapsed;
@@ -383,10 +388,11 @@
{
await InvokeAsync(LoadAsync);
}
catch
catch (Exception ex)
{
// Swallowed — LoadAsync already handles its own errors; this catch
// is the last line of defense for InvokeAsync itself.
// Last line of defense for InvokeAsync itself — LoadAsync handles its
// own errors. Log rather than silently dropping the failure.
Logger.LogError(ex, "EventListShell ({Surface}): auto-refresh tick failed.", Surface);
}
}
@@ -414,9 +420,10 @@
{
// Swallow — superseded by a newer load.
}
catch
catch (Exception ex)
{
// Hide errors from the UI; Phase 9 will add a snackbar.
// Degrade gracefully (clear the rows) but record the failure for diagnosis.
Logger.LogError(ex, "EventListShell ({Surface}): failed to load event rows.", Surface);
_rows = new List<EventListItem>();
}
finally
@@ -523,7 +530,11 @@
public void Dispose()
{
_refreshTimer?.Dispose();
if (_refreshTimer is not null)
{
_refreshTimer.Elapsed -= OnRefreshTimerElapsed;
_refreshTimer.Dispose();
}
_searchCts?.Cancel();
_searchCts?.Dispose();
_loadCts?.Cancel();
@@ -64,6 +64,7 @@
<data name="Nav.Anomalies"><value>Anomalies</value></data>
<data name="Nav.Results"><value>Results</value></data>
<data name="Nav.Settings"><value>Settings</value></data>
<data name="Nav.Export"><value>Export</value></data>
<data name="Home.Kicker"><value>Briefing</value></data>
<data name="Home.Title"><value>Hunting odds-flip anomalies</value></data>
@@ -79,6 +80,14 @@
<data name="Home.Pipeline.Step3"><value>Flip detector</value></data>
<data name="Home.Pipeline.Step4"><value>XLSX export</value></data>
<data name="Home.Empty"><value>No data yet. Enable the background pollers in Settings to start the feed.</value></data>
<data name="Home.Empty.Heading"><value>Nothing captured yet</value></data>
<data name="Home.Empty.Cta"><value>Open Settings</value></data>
<data name="Home.NoSignals"><value>Capturing lines — no flips flagged yet.</value></data>
<data name="Home.ViewAll"><value>View all signals</value></data>
<data name="Home.Stat.NewToday"><value>{0} new today</value></data>
<data name="Scraping.On"><value>Capturing</value></data>
<data name="Scraping.Off"><value>Paused</value></data>
<data name="Scraping.Aria"><value>Data capture status</value></data>
<data name="Settings.Kicker"><value>Configuration</value></data>
<data name="Settings.Title"><value>Settings</value></data>
@@ -107,6 +116,9 @@
<data name="Settings.Scraping.RateLimitRps"><value>Rate limit (RPS)</value></data>
<data name="Settings.Scraping.RateLimitRps.Hint"><value>Requests per second. 1 is recommended.</value></data>
<data name="Settings.Scraping.BaseUrl"><value>Base URL</value></data>
<data name="Settings.Scraping.BaseUrl.Hint"><value>Must be an absolute http(s) URL, e.g. https://www.marathonbet.by</value></data>
<data name="Settings.Scraping.BaseUrl.Invalid"><value>Base URL must be an absolute http(s) address.</value></data>
<data name="Settings.Workers.Cron.Invalid"><value>Schedule must be a 5- or 6-field cron expression.</value></data>
<data name="Settings.Scraping.RequestTimeoutSeconds"><value>Request timeout (sec)</value></data>
<data name="Settings.Scraping.UsePlaywright"><value>Use Playwright</value></data>
@@ -154,6 +166,7 @@
<data name="Anomaly.Live"><value>Anomaly</value></data>
<data name="Anomaly.Kind.SuspensionFlip"><value>Suspension flip</value></data>
<data name="Anomaly.Kind.SteamMove"><value>Steam move</value></data>
<data name="Anomaly.Score"><value>Confidence</value></data>
<!-- Phase 7 — Anomaly feed UI -->
@@ -181,6 +194,7 @@
<data name="Anomaly.Evidence.FavouriteSwap"><value>Favourite swap</value></data>
<data name="Anomaly.Detail.EvidenceTitle"><value>Evidence timeline</value></data>
<data name="Anomaly.Detail.LinkBackToEvent"><value>Open event</value></data>
<data name="Action.LogBet"><value>Log bet</value></data>
<data name="Anomaly.Detail.BackToFeed"><value>Back to feed</value></data>
<data name="Anomaly.Detail.NotFound"><value>Anomaly not found — it may have been pruned.</value></data>
<data name="Anomaly.Empty.NoneInRange"><value>No anomalies match the current filters. Loosen the severity threshold or widen the date range.</value></data>
@@ -254,6 +268,9 @@
<data name="Export.Submit"><value>Export</value></data>
<data name="Export.Cancel"><value>Cancel</value></data>
<data name="Export.Success"><value>Export saved to {0}</value></data>
<data name="Export.Hub.Lede"><value>Export captured odds snapshots to an Excel workbook for any date range — no need to open a specific event first.</value></data>
<data name="Export.Hub.Action"><value>Configure export</value></data>
<data name="Export.Hub.FilenameHint"><value>Saved as Marathon_&lt;from&gt;_to_&lt;to&gt;.xlsx in the configured export directory.</value></data>
<data name="Export.Error.MissingDates"><value>Pick a start and end date.</value></data>
<data name="Export.Error.InvalidRange"><value>End date must be on or after the start date.</value></data>
<data name="Export.Error.Failed"><value>Export failed.</value></data>
@@ -305,4 +322,172 @@
<data name="Results.Loader.Progress.Failed"><value>Failed</value></data>
<data name="Results.Loader.Summary.Format"><value>Loaded {0}, skipped {1}, processed {2} total.</value></data>
<data name="Results.Loader.Empty.NoCandidates"><value>No events to load in this range.</value></data>
<data name="Nav.Insights"><value>Insights</value></data>
<data name="Insights.Kicker"><value>Calibration</value></data>
<data name="Insights.Title"><value>Did the flips predict the winner?</value></data>
<data name="Insights.Lede"><value>Every persisted suspension-flip anomaly joined against the final event result. The hit rate tells you whether the post-flip favourite is the side that actually won — the only metric that says the detector is doing its job.</value></data>
<data name="Insights.Stat.HitRate"><value>Hit rate</value></data>
<data name="Insights.Stat.HitRate.Hint"><value>Post-flip favourite won.</value></data>
<data name="Insights.Stat.Resolved"><value>Resolved</value></data>
<data name="Insights.Stat.Resolved.Hint"><value>Anomalies with a graded event.</value></data>
<data name="Insights.Stat.Unresolved"><value>Unresolved</value></data>
<data name="Insights.Stat.Unresolved.Hint"><value>Awaiting event result.</value></data>
<data name="Insights.Stat.Hits"><value>Hits</value></data>
<data name="Insights.Stat.Misses"><value>Misses</value></data>
<data name="Insights.Stat.Total"><value>Total anomalies</value></data>
<data name="Insights.Section.BySeverity"><value>By severity</value></data>
<data name="Insights.Section.BySport"><value>By sport</value></data>
<data name="Insights.Section.ByScore"><value>By confidence score</value></data>
<data name="Insights.Section.Resolved"><value>Resolved anomalies</value></data>
<data name="Insights.Section.Unresolved"><value>Awaiting results</value></data>
<data name="Insights.Column.DetectedAt"><value>Detected</value></data>
<data name="Insights.Column.Match"><value>Match</value></data>
<data name="Insights.Column.Sport"><value>Sport</value></data>
<data name="Insights.Column.Score"><value>Score</value></data>
<data name="Insights.Column.PreFavourite"><value>Pre-flip pick</value></data>
<data name="Insights.Column.PostFavourite"><value>Post-flip pick</value></data>
<data name="Insights.Column.Winner"><value>Actual winner</value></data>
<data name="Insights.Column.Outcome"><value>Verdict</value></data>
<data name="Insights.Column.Bucket"><value>Bucket</value></data>
<data name="Insights.Column.HitRate"><value>Hit rate</value></data>
<data name="Insights.Column.HitsOfTotal"><value>Hits / total</value></data>
<data name="Insights.Outcome.Hit"><value>Hit</value></data>
<data name="Insights.Outcome.Miss"><value>Miss</value></data>
<data name="Insights.Outcome.Unresolved"><value>Pending</value></data>
<data name="Insights.Side.Side1"><value>Side 1</value></data>
<data name="Insights.Side.Side2"><value>Side 2</value></data>
<data name="Insights.Side.Draw"><value>Draw</value></data>
<data name="Insights.Side.Unknown"><value>—</value></data>
<data name="Insights.Empty.None"><value>No anomalies have been recorded yet. Once the detector flags one and the matching event finishes, its verdict will appear here.</value></data>
<data name="Insights.Empty.NoneResolved"><value>Anomalies exist but no matching events have been graded yet. Run the results loader or wait for matches to complete.</value></data>
<data name="Insights.Action.Refresh"><value>Refresh</value></data>
<data name="Insights.Action.OpenAnomaly"><value>Open</value></data>
<data name="Insights.Bucket.NotApplicable"><value>—</value></data>
<data name="Nav.MyBets"><value>My bets</value></data>
<data name="Journal.Kicker"><value>Journal</value></data>
<data name="Journal.Title"><value>Your bets and CLV</value></data>
<data name="Journal.Lede"><value>Every wager you've recorded, graded against final results and scored against the closing line. Positive CLV is the leading indicator that says you're consistently beating the market.</value></data>
<data name="Journal.Stat.Roi"><value>ROI</value></data>
<data name="Journal.Stat.Roi.Hint"><value>Net profit ÷ total staked.</value></data>
<data name="Journal.Stat.StrikeRate"><value>Strike rate</value></data>
<data name="Journal.Stat.StrikeRate.Hint"><value>Wins ÷ (wins + losses).</value></data>
<data name="Journal.Stat.AvgClv"><value>Avg CLV</value></data>
<data name="Journal.Stat.AvgClv.Hint"><value>Mean closing-line implied-probability gain.</value></data>
<data name="Journal.Stat.NetProfit"><value>Net profit</value></data>
<data name="Journal.Stat.NetProfit.Hint"><value>Returns minus stakes (resolved bets).</value></data>
<data name="Journal.Stat.TotalBets"><value>Total bets</value></data>
<data name="Journal.Stat.Pending"><value>Pending</value></data>
<data name="Journal.Stat.Won"><value>Won</value></data>
<data name="Journal.Stat.Lost"><value>Lost</value></data>
<data name="Journal.Stat.Void"><value>Void</value></data>
<data name="Journal.Section.Add"><value>Record a bet</value></data>
<data name="Journal.Section.List"><value>Bet journal</value></data>
<data name="Journal.Action.Refresh"><value>Refresh</value></data>
<data name="Journal.Action.Resolve"><value>Resolve pending</value></data>
<data name="Journal.Action.Submit"><value>Record bet</value></data>
<data name="Journal.Action.Delete"><value>Delete</value></data>
<data name="Journal.Action.Confirm"><value>Confirm</value></data>
<data name="Journal.Action.Cancel"><value>Cancel</value></data>
<data name="Journal.Field.EventId"><value>Event ID</value></data>
<data name="Journal.Field.EventId.Hint"><value>Numeric ID from the event detail URL.</value></data>
<data name="Journal.Field.FindEvent"><value>Find event</value></data>
<data name="Journal.Field.FindEvent.Placeholder"><value>Search by team name…</value></data>
<data name="Journal.Field.FindEvent.Hint"><value>Pick an upcoming event to fill the ID, or type it below.</value></data>
<data name="Journal.Field.Type"><value>Bet type</value></data>
<data name="Journal.Field.Side"><value>Side</value></data>
<data name="Journal.Field.Value"><value>Threshold</value></data>
<data name="Journal.Field.Value.Hint"><value>Handicap or total line (e.g. -1.5, 2.5).</value></data>
<data name="Journal.Field.Rate"><value>Taken rate</value></data>
<data name="Journal.Field.Stake"><value>Stake</value></data>
<data name="Journal.Field.Notes"><value>Notes</value></data>
<data name="Journal.Field.Notes.Placeholder"><value>Strategy tag, bookmaker, or anything you want to remember…</value></data>
<data name="Journal.BetType.Win"><value>Win</value></data>
<data name="Journal.BetType.Draw"><value>Draw</value></data>
<data name="Journal.BetType.WinFora"><value>Handicap</value></data>
<data name="Journal.BetType.Total"><value>Total</value></data>
<data name="Journal.Side.Side1"><value>Side 1</value></data>
<data name="Journal.Side.Side2"><value>Side 2</value></data>
<data name="Journal.Side.Draw"><value>Draw</value></data>
<data name="Journal.Side.Less"><value>Under</value></data>
<data name="Journal.Side.More"><value>Over</value></data>
<data name="Journal.Outcome.Pending"><value>Pending</value></data>
<data name="Journal.Outcome.Won"><value>Won</value></data>
<data name="Journal.Outcome.Lost"><value>Lost</value></data>
<data name="Journal.Outcome.Void"><value>Void</value></data>
<data name="Journal.Column.PlacedAt"><value>Placed</value></data>
<data name="Journal.Column.Match"><value>Match</value></data>
<data name="Journal.Column.Selection"><value>Selection</value></data>
<data name="Journal.Column.Stake"><value>Stake</value></data>
<data name="Journal.Column.Rate"><value>Rate</value></data>
<data name="Journal.Column.Profit"><value>P&amp;L</value></data>
<data name="Journal.Column.Clv"><value>CLV</value></data>
<data name="Journal.Column.Outcome"><value>Outcome</value></data>
<data name="Journal.Empty.None"><value>No bets recorded yet. Use the form above to log a wager — once the event finishes the journal will auto-grade it and compute closing-line value against the latest pre-match snapshot.</value></data>
<data name="Journal.Empty.NotApplicable"><value>—</value></data>
<data name="Journal.Error.Generic"><value>Failed to save bet — check the event ID and try again.</value></data>
<data name="Journal.Submitted"><value>Bet recorded.</value></data>
<data name="Journal.Kelly.Title"><value>Stake helper (¼-Kelly)</value></data>
<data name="Journal.Kelly.Bankroll"><value>Bankroll</value></data>
<data name="Journal.Kelly.Probability"><value>Win probability (%)</value></data>
<data name="Journal.Kelly.Suggestion"><value>Suggested stake: {0:0.00}</value></data>
<data name="Journal.Kelly.Apply"><value>Apply</value></data>
<data name="Journal.Kelly.NoEdge"><value>No positive edge at this price.</value></data>
<data name="Journal.Kelly.Hint"><value>Enter bankroll + win probability for a ¼-Kelly stake.</value></data>
<data name="Journal.Resolve.None"><value>No pending bets needed grading.</value></data>
<data name="Journal.Resolve.Done"><value>Graded {0} pending bet(s).</value></data>
<data name="Journal.Confirm.Delete"><value>Delete this bet permanently?</value></data>
<data name="Nav.Backtest"><value>Backtest</value></data>
<data name="Backtest.Kicker"><value>Simulator</value></data>
<data name="Backtest.Title"><value>Replay the detector against history</value></data>
<data name="Backtest.Lede"><value>Run a hypothetical strategy over every anomaly the detector has flagged. Choose a confidence threshold and a staking rule — the simulator settles every bet against the actual event result, compounds bankroll, and reports the headline numbers you need to judge edge.</value></data>
<data name="Backtest.Section.Strategy"><value>Strategy</value></data>
<data name="Backtest.Section.Headline"><value>Result</value></data>
<data name="Backtest.Section.Equity"><value>Equity curve</value></data>
<data name="Backtest.Section.Trace"><value>Trade trace</value></data>
<data name="Backtest.Field.Bankroll"><value>Starting bankroll</value></data>
<data name="Backtest.Field.From"><value>From date</value></data>
<data name="Backtest.Field.To"><value>To date</value></data>
<data name="Backtest.Field.DateRange.Hint"><value>Leave both empty to backtest every graded anomaly.</value></data>
<data name="Backtest.Field.MinScore"><value>Min anomaly score</value></data>
<data name="Backtest.Field.MinScore.Hint"><value>Only bet anomalies at or above this confidence.</value></data>
<data name="Backtest.Field.StakeRule"><value>Staking rule</value></data>
<data name="Backtest.Field.FlatStake"><value>Flat stake</value></data>
<data name="Backtest.Field.PercentOfBankroll"><value>Percent of bankroll</value></data>
<data name="Backtest.Field.KellyFraction"><value>Kelly fraction</value></data>
<data name="Backtest.Field.KellyFraction.Hint"><value>0.25 (quarter-Kelly) is the conservative default.</value></data>
<data name="Backtest.StakeRule.Flat"><value>Flat</value></data>
<data name="Backtest.StakeRule.PercentOfBankroll"><value>% of bankroll</value></data>
<data name="Backtest.StakeRule.Kelly"><value>Kelly</value></data>
<data name="Backtest.Action.Run"><value>Run simulation</value></data>
<data name="Backtest.Action.Running"><value>Simulating…</value></data>
<data name="Backtest.Action.Cancel"><value>Cancel</value></data>
<data name="Backtest.Stat.FinalBankroll"><value>Final bankroll</value></data>
<data name="Backtest.Stat.NetProfit"><value>Net profit</value></data>
<data name="Backtest.Stat.Roi"><value>ROI</value></data>
<data name="Backtest.Stat.MaxDrawdown"><value>Max drawdown</value></data>
<data name="Backtest.Stat.BetsPlaced"><value>Bets placed</value></data>
<data name="Backtest.Stat.Wins"><value>Wins</value></data>
<data name="Backtest.Stat.Losses"><value>Losses</value></data>
<data name="Backtest.Stat.Skipped"><value>Skipped</value></data>
<data name="Backtest.Stat.MaxWinStreak"><value>Max win streak</value></data>
<data name="Backtest.Stat.MaxLossStreak"><value>Max loss streak</value></data>
<data name="Backtest.Stat.TotalStaked"><value>Total staked</value></data>
<data name="Backtest.Stat.TotalReturned"><value>Total returned</value></data>
<data name="Backtest.Column.DetectedAt"><value>Detected</value></data>
<data name="Backtest.Column.Match"><value>Match</value></data>
<data name="Backtest.Column.Score"><value>Score</value></data>
<data name="Backtest.Column.Pick"><value>Pick</value></data>
<data name="Backtest.Column.Rate"><value>Rate</value></data>
<data name="Backtest.Column.Stake"><value>Stake</value></data>
<data name="Backtest.Column.Payout"><value>Payout</value></data>
<data name="Backtest.Column.Bankroll"><value>Bankroll</value></data>
<data name="Backtest.Column.Outcome"><value>Outcome</value></data>
<data name="Backtest.Outcome.Win"><value>Win</value></data>
<data name="Backtest.Outcome.Loss"><value>Loss</value></data>
<data name="Backtest.Empty.NoData"><value>No graded anomalies to simulate yet. Run the results loader so the detector has outcomes to replay against.</value></data>
<data name="Backtest.Empty.NoBetsPlaced"><value>The strategy placed zero bets — try lowering the score threshold, or switch staking rule.</value></data>
<data name="Backtest.Error.Generic"><value>Simulation failed — check the form values and try again.</value></data>
</root>
@@ -66,6 +66,7 @@
<data name="Nav.Anomalies"><value>Аномалии</value></data>
<data name="Nav.Results"><value>Результаты</value></data>
<data name="Nav.Settings"><value>Настройки</value></data>
<data name="Nav.Export"><value>Экспорт</value></data>
<!-- Home / Dashboard -->
<data name="Home.Kicker"><value>Сводка</value></data>
@@ -82,6 +83,14 @@
<data name="Home.Pipeline.Step3"><value>Детектор разворота</value></data>
<data name="Home.Pipeline.Step4"><value>Экспорт XLSX</value></data>
<data name="Home.Empty"><value>Пока пусто. Запустите фоновые сборщики на странице «Настройки», чтобы пошёл поток данных.</value></data>
<data name="Home.Empty.Heading"><value>Пока ничего не собрано</value></data>
<data name="Home.Empty.Cta"><value>Открыть настройки</value></data>
<data name="Home.NoSignals"><value>Идёт сбор линий — разворотов пока нет.</value></data>
<data name="Home.ViewAll"><value>Все сигналы</value></data>
<data name="Home.Stat.NewToday"><value>{0} новых сегодня</value></data>
<data name="Scraping.On"><value>Идёт сбор</value></data>
<data name="Scraping.Off"><value>Пауза</value></data>
<data name="Scraping.Aria"><value>Статус сбора данных</value></data>
<!-- Settings — sections -->
<data name="Settings.Kicker"><value>Конфигурация</value></data>
@@ -112,6 +121,9 @@
<data name="Settings.Scraping.RateLimitRps"><value>Лимит RPS</value></data>
<data name="Settings.Scraping.RateLimitRps.Hint"><value>Запросов в секунду. Рекомендовано 1.</value></data>
<data name="Settings.Scraping.BaseUrl"><value>Базовый URL</value></data>
<data name="Settings.Scraping.BaseUrl.Hint"><value>Должен быть абсолютный http(s)-адрес, например https://www.marathonbet.by</value></data>
<data name="Settings.Scraping.BaseUrl.Invalid"><value>Базовый URL должен быть абсолютным http(s)-адресом.</value></data>
<data name="Settings.Workers.Cron.Invalid"><value>Расписание должно быть cron-выражением из 5 или 6 полей.</value></data>
<data name="Settings.Scraping.RequestTimeoutSeconds"><value>Тайм-аут запроса (сек)</value></data>
<data name="Settings.Scraping.UsePlaywright"><value>Использовать Playwright</value></data>
@@ -167,6 +179,7 @@
<!-- Anomaly (Phase 7 placeholders) -->
<data name="Anomaly.Live"><value>Аномалия</value></data>
<data name="Anomaly.Kind.SuspensionFlip"><value>Разворот после заморозки</value></data>
<data name="Anomaly.Kind.SteamMove"><value>Движение линии</value></data>
<data name="Anomaly.Score"><value>Уверенность</value></data>
<!-- Phase 7 — Лента аномалий -->
@@ -194,6 +207,7 @@
<data name="Anomaly.Evidence.FavouriteSwap"><value>Смена фаворита</value></data>
<data name="Anomaly.Detail.EvidenceTitle"><value>Хроника свидетельств</value></data>
<data name="Anomaly.Detail.LinkBackToEvent"><value>Открыть событие</value></data>
<data name="Action.LogBet"><value>Записать ставку</value></data>
<data name="Anomaly.Detail.BackToFeed"><value>К ленте</value></data>
<data name="Anomaly.Detail.NotFound"><value>Аномалия не найдена — возможно, она была удалена.</value></data>
<data name="Anomaly.Empty.NoneInRange"><value>Под текущие фильтры аномалии не попадают. Снизьте порог важности или расширьте диапазон дат.</value></data>
@@ -267,6 +281,9 @@
<data name="Export.Submit"><value>Экспорт</value></data>
<data name="Export.Cancel"><value>Отмена</value></data>
<data name="Export.Success"><value>Файл сохранён в {0}</value></data>
<data name="Export.Hub.Lede"><value>Экспорт собранных снимков коэффициентов в книгу Excel за любой диапазон дат — без необходимости открывать конкретное событие.</value></data>
<data name="Export.Hub.Action"><value>Настроить экспорт</value></data>
<data name="Export.Hub.FilenameHint"><value>Сохраняется как Marathon_&lt;от&gt;_to_&lt;до&gt;.xlsx в указанной папке экспорта.</value></data>
<data name="Export.Error.MissingDates"><value>Выберите даты начала и конца.</value></data>
<data name="Export.Error.InvalidRange"><value>Дата конца должна быть не раньше даты начала.</value></data>
<data name="Export.Error.Failed"><value>Экспорт не удался.</value></data>
@@ -318,4 +335,172 @@
<data name="Results.Loader.Progress.Failed"><value>Ошибка</value></data>
<data name="Results.Loader.Summary.Format"><value>Загружено {0}, пропущено {1}, всего обработано {2}.</value></data>
<data name="Results.Loader.Empty.NoCandidates"><value>Нет событий для загрузки в этом диапазоне.</value></data>
<data name="Nav.Insights"><value>Калибровка</value></data>
<data name="Insights.Kicker"><value>Калибровка</value></data>
<data name="Insights.Title"><value>Угадывают ли флипы победителя?</value></data>
<data name="Insights.Lede"><value>Каждая зафиксированная аномалия suspension-flip сопоставлена с итогом матча. Hit rate показывает, оказался ли пост-флип фаворит реальным победителем — это и есть единственная метрика, говорящая, что детектор работает.</value></data>
<data name="Insights.Stat.HitRate"><value>Hit rate</value></data>
<data name="Insights.Stat.HitRate.Hint"><value>Пост-флип фаворит выиграл.</value></data>
<data name="Insights.Stat.Resolved"><value>Подтверждены</value></data>
<data name="Insights.Stat.Resolved.Hint"><value>Аномалии с известным итогом.</value></data>
<data name="Insights.Stat.Unresolved"><value>Без результата</value></data>
<data name="Insights.Stat.Unresolved.Hint"><value>Ждём окончания матча.</value></data>
<data name="Insights.Stat.Hits"><value>Попадания</value></data>
<data name="Insights.Stat.Misses"><value>Промахи</value></data>
<data name="Insights.Stat.Total"><value>Всего аномалий</value></data>
<data name="Insights.Section.BySeverity"><value>По уровню</value></data>
<data name="Insights.Section.BySport"><value>По виду спорта</value></data>
<data name="Insights.Section.ByScore"><value>По уверенности</value></data>
<data name="Insights.Section.Resolved"><value>Подтверждённые аномалии</value></data>
<data name="Insights.Section.Unresolved"><value>Ожидают итога</value></data>
<data name="Insights.Column.DetectedAt"><value>Замечено</value></data>
<data name="Insights.Column.Match"><value>Матч</value></data>
<data name="Insights.Column.Sport"><value>Вид спорта</value></data>
<data name="Insights.Column.Score"><value>Score</value></data>
<data name="Insights.Column.PreFavourite"><value>До флипа</value></data>
<data name="Insights.Column.PostFavourite"><value>После флипа</value></data>
<data name="Insights.Column.Winner"><value>Победитель</value></data>
<data name="Insights.Column.Outcome"><value>Вердикт</value></data>
<data name="Insights.Column.Bucket"><value>Группа</value></data>
<data name="Insights.Column.HitRate"><value>Hit rate</value></data>
<data name="Insights.Column.HitsOfTotal"><value>Попаданий / всего</value></data>
<data name="Insights.Outcome.Hit"><value>Попадание</value></data>
<data name="Insights.Outcome.Miss"><value>Промах</value></data>
<data name="Insights.Outcome.Unresolved"><value>Ожидает</value></data>
<data name="Insights.Side.Side1"><value>Сторона 1</value></data>
<data name="Insights.Side.Side2"><value>Сторона 2</value></data>
<data name="Insights.Side.Draw"><value>Ничья</value></data>
<data name="Insights.Side.Unknown"><value>—</value></data>
<data name="Insights.Empty.None"><value>Аномалии ещё не зафиксированы. Когда детектор отметит первую и матч завершится, его вердикт появится здесь.</value></data>
<data name="Insights.Empty.NoneResolved"><value>Аномалии есть, но ни у одного из их событий нет результата. Запустите загрузчик результатов или подождите окончания матчей.</value></data>
<data name="Insights.Action.Refresh"><value>Обновить</value></data>
<data name="Insights.Action.OpenAnomaly"><value>Открыть</value></data>
<data name="Insights.Bucket.NotApplicable"><value>—</value></data>
<data name="Nav.MyBets"><value>Мои ставки</value></data>
<data name="Journal.Kicker"><value>Журнал</value></data>
<data name="Journal.Title"><value>Ваши ставки и CLV</value></data>
<data name="Journal.Lede"><value>Каждая зафиксированная ставка с автоматическим расчётом результата и оценкой против линии закрытия. Положительный CLV — главный долгосрочный индикатор того, что вы стабильно обыгрываете рынок.</value></data>
<data name="Journal.Stat.Roi"><value>ROI</value></data>
<data name="Journal.Stat.Roi.Hint"><value>Чистая прибыль ÷ сумма ставок.</value></data>
<data name="Journal.Stat.StrikeRate"><value>Strike rate</value></data>
<data name="Journal.Stat.StrikeRate.Hint"><value>Победы ÷ (победы + поражения).</value></data>
<data name="Journal.Stat.AvgClv"><value>Средний CLV</value></data>
<data name="Journal.Stat.AvgClv.Hint"><value>Средний прирост вероятности к линии закрытия.</value></data>
<data name="Journal.Stat.NetProfit"><value>Чистая прибыль</value></data>
<data name="Journal.Stat.NetProfit.Hint"><value>Возвраты минус ставки (учтены сыгравшие).</value></data>
<data name="Journal.Stat.TotalBets"><value>Всего ставок</value></data>
<data name="Journal.Stat.Pending"><value>В ожидании</value></data>
<data name="Journal.Stat.Won"><value>Победа</value></data>
<data name="Journal.Stat.Lost"><value>Проигрыш</value></data>
<data name="Journal.Stat.Void"><value>Возврат</value></data>
<data name="Journal.Section.Add"><value>Записать ставку</value></data>
<data name="Journal.Section.List"><value>Журнал ставок</value></data>
<data name="Journal.Action.Refresh"><value>Обновить</value></data>
<data name="Journal.Action.Resolve"><value>Рассчитать ожидающие</value></data>
<data name="Journal.Action.Submit"><value>Записать</value></data>
<data name="Journal.Action.Delete"><value>Удалить</value></data>
<data name="Journal.Action.Confirm"><value>Подтвердить</value></data>
<data name="Journal.Action.Cancel"><value>Отмена</value></data>
<data name="Journal.Field.EventId"><value>ID события</value></data>
<data name="Journal.Field.EventId.Hint"><value>Числовой ID из URL детальной страницы.</value></data>
<data name="Journal.Field.FindEvent"><value>Найти событие</value></data>
<data name="Journal.Field.FindEvent.Placeholder"><value>Поиск по названию команды…</value></data>
<data name="Journal.Field.FindEvent.Hint"><value>Выберите предстоящее событие, чтобы подставить ID, или введите его ниже.</value></data>
<data name="Journal.Field.Type"><value>Тип ставки</value></data>
<data name="Journal.Field.Side"><value>Сторона</value></data>
<data name="Journal.Field.Value"><value>Порог</value></data>
<data name="Journal.Field.Value.Hint"><value>Гандикап или тотал (например 1.5, 2.5).</value></data>
<data name="Journal.Field.Rate"><value>Кэф на момент ставки</value></data>
<data name="Journal.Field.Stake"><value>Сумма ставки</value></data>
<data name="Journal.Field.Notes"><value>Заметки</value></data>
<data name="Journal.Field.Notes.Placeholder"><value>Тег стратегии, букмекер или что угодно для памяти…</value></data>
<data name="Journal.BetType.Win"><value>Победа</value></data>
<data name="Journal.BetType.Draw"><value>Ничья</value></data>
<data name="Journal.BetType.WinFora"><value>Фора</value></data>
<data name="Journal.BetType.Total"><value>Тотал</value></data>
<data name="Journal.Side.Side1"><value>Сторона 1</value></data>
<data name="Journal.Side.Side2"><value>Сторона 2</value></data>
<data name="Journal.Side.Draw"><value>Ничья</value></data>
<data name="Journal.Side.Less"><value>Меньше</value></data>
<data name="Journal.Side.More"><value>Больше</value></data>
<data name="Journal.Outcome.Pending"><value>Ожидает</value></data>
<data name="Journal.Outcome.Won"><value>Победа</value></data>
<data name="Journal.Outcome.Lost"><value>Проигрыш</value></data>
<data name="Journal.Outcome.Void"><value>Возврат</value></data>
<data name="Journal.Column.PlacedAt"><value>Размещено</value></data>
<data name="Journal.Column.Match"><value>Матч</value></data>
<data name="Journal.Column.Selection"><value>Выбор</value></data>
<data name="Journal.Column.Stake"><value>Ставка</value></data>
<data name="Journal.Column.Rate"><value>Кэф</value></data>
<data name="Journal.Column.Profit"><value>P&amp;L</value></data>
<data name="Journal.Column.Clv"><value>CLV</value></data>
<data name="Journal.Column.Outcome"><value>Итог</value></data>
<data name="Journal.Empty.None"><value>Ставок пока нет. Запишите свою ставку через форму выше — после окончания матча журнал авто-проставит результат и посчитает CLV против последнего пре-матч снимка.</value></data>
<data name="Journal.Empty.NotApplicable"><value>—</value></data>
<data name="Journal.Error.Generic"><value>Не удалось сохранить ставку — проверьте ID события и повторите.</value></data>
<data name="Journal.Submitted"><value>Ставка записана.</value></data>
<data name="Journal.Kelly.Title"><value>Калькулятор ставки (¼-Келли)</value></data>
<data name="Journal.Kelly.Bankroll"><value>Банкролл</value></data>
<data name="Journal.Kelly.Probability"><value>Вероятность выигрыша (%)</value></data>
<data name="Journal.Kelly.Suggestion"><value>Рекомендуемая ставка: {0:0.00}</value></data>
<data name="Journal.Kelly.Apply"><value>Применить</value></data>
<data name="Journal.Kelly.NoEdge"><value>Нет преимущества при этом коэффициенте.</value></data>
<data name="Journal.Kelly.Hint"><value>Введите банкролл и вероятность для ставки по ¼-Келли.</value></data>
<data name="Journal.Resolve.None"><value>Ожидающих ставок к расчёту нет.</value></data>
<data name="Journal.Resolve.Done"><value>Рассчитано ожидающих: {0}.</value></data>
<data name="Journal.Confirm.Delete"><value>Удалить эту ставку безвозвратно?</value></data>
<data name="Nav.Backtest"><value>Бэктест</value></data>
<data name="Backtest.Kicker"><value>Симулятор</value></data>
<data name="Backtest.Title"><value>Прогон детектора по истории</value></data>
<data name="Backtest.Lede"><value>Запустите гипотетическую стратегию на всех зафиксированных аномалиях. Выберите порог уверенности и правило стейкинга — симулятор разыграет каждую ставку против реального исхода, нарастит банк и покажет ключевые метрики для оценки преимущества.</value></data>
<data name="Backtest.Section.Strategy"><value>Стратегия</value></data>
<data name="Backtest.Section.Headline"><value>Результат</value></data>
<data name="Backtest.Section.Equity"><value>Кривая банка</value></data>
<data name="Backtest.Section.Trace"><value>Хронология ставок</value></data>
<data name="Backtest.Field.Bankroll"><value>Стартовый банк</value></data>
<data name="Backtest.Field.From"><value>Дата с</value></data>
<data name="Backtest.Field.To"><value>Дата по</value></data>
<data name="Backtest.Field.DateRange.Hint"><value>Оставьте оба поля пустыми, чтобы прогнать все оценённые аномалии.</value></data>
<data name="Backtest.Field.MinScore"><value>Мин. score аномалии</value></data>
<data name="Backtest.Field.MinScore.Hint"><value>Ставим только при уверенности не ниже этого порога.</value></data>
<data name="Backtest.Field.StakeRule"><value>Правило стейкинга</value></data>
<data name="Backtest.Field.FlatStake"><value>Фикс. ставка</value></data>
<data name="Backtest.Field.PercentOfBankroll"><value>Процент от банка</value></data>
<data name="Backtest.Field.KellyFraction"><value>Доля Келли</value></data>
<data name="Backtest.Field.KellyFraction.Hint"><value>0,25 (четверть-Келли) — консервативный дефолт.</value></data>
<data name="Backtest.StakeRule.Flat"><value>Фиксированная</value></data>
<data name="Backtest.StakeRule.PercentOfBankroll"><value>% от банка</value></data>
<data name="Backtest.StakeRule.Kelly"><value>Келли</value></data>
<data name="Backtest.Action.Run"><value>Запустить</value></data>
<data name="Backtest.Action.Running"><value>Симуляция…</value></data>
<data name="Backtest.Action.Cancel"><value>Отмена</value></data>
<data name="Backtest.Stat.FinalBankroll"><value>Итоговый банк</value></data>
<data name="Backtest.Stat.NetProfit"><value>Чистая прибыль</value></data>
<data name="Backtest.Stat.Roi"><value>ROI</value></data>
<data name="Backtest.Stat.MaxDrawdown"><value>Макс. просадка</value></data>
<data name="Backtest.Stat.BetsPlaced"><value>Поставлено</value></data>
<data name="Backtest.Stat.Wins"><value>Победы</value></data>
<data name="Backtest.Stat.Losses"><value>Поражения</value></data>
<data name="Backtest.Stat.Skipped"><value>Пропущено</value></data>
<data name="Backtest.Stat.MaxWinStreak"><value>Макс. серия побед</value></data>
<data name="Backtest.Stat.MaxLossStreak"><value>Макс. серия пораж.</value></data>
<data name="Backtest.Stat.TotalStaked"><value>Всего поставлено</value></data>
<data name="Backtest.Stat.TotalReturned"><value>Всего возвращено</value></data>
<data name="Backtest.Column.DetectedAt"><value>Замечено</value></data>
<data name="Backtest.Column.Match"><value>Матч</value></data>
<data name="Backtest.Column.Score"><value>Score</value></data>
<data name="Backtest.Column.Pick"><value>Выбор</value></data>
<data name="Backtest.Column.Rate"><value>Кэф</value></data>
<data name="Backtest.Column.Stake"><value>Ставка</value></data>
<data name="Backtest.Column.Payout"><value>Выплата</value></data>
<data name="Backtest.Column.Bankroll"><value>Банк</value></data>
<data name="Backtest.Column.Outcome"><value>Исход</value></data>
<data name="Backtest.Outcome.Win"><value>Победа</value></data>
<data name="Backtest.Outcome.Loss"><value>Проигрыш</value></data>
<data name="Backtest.Empty.NoData"><value>Аномалий с результатом ещё нет. Запустите загрузчик результатов, чтобы симулятору было на чём прогоняться.</value></data>
<data name="Backtest.Empty.NoBetsPlaced"><value>Стратегия не сделала ни одной ставки — снизьте порог score или поменяйте правило стейкинга.</value></data>
<data name="Backtest.Error.Generic"><value>Симуляция упала — проверьте параметры формы и повторите.</value></data>
</root>
@@ -28,10 +28,12 @@ public sealed class AnomalyBrowsingService : IAnomalyBrowsingService
{
ArgumentNullException.ThrowIfNull(filter);
var all = await _anomalies.ListAsync(ct).ConfigureAwait(false);
// Date filter pushed to SQL; severity needs the parsed score and sport needs
// the event join, so those two stay in memory over the smaller returned set.
var all = await _anomalies.ListByDateRangeAsync(filter.From, filter.To, ct).ConfigureAwait(false);
if (all.Count == 0) return Array.Empty<AnomalyListItem>();
// Resolve event metadata in one pass — distinct EventIds only.
// Resolve event metadata in one batched pass — distinct EventIds only.
var eventLookup = await BuildEventLookupAsync(all, ct).ConfigureAwait(false);
var items = new List<AnomalyListItem>(all.Count);
@@ -44,7 +46,7 @@ public sealed class AnomalyBrowsingService : IAnomalyBrowsingService
}
}
// Apply filters in-memory (small list, UI page).
// Remaining filters in-memory (page-sized set).
IEnumerable<AnomalyListItem> filtered = items;
if (filter.MinSeverity is { } minSeverity)
@@ -57,16 +59,6 @@ public sealed class AnomalyBrowsingService : IAnomalyBrowsingService
filtered = filtered.Where(i => sports.Contains(i.Sport.Value));
}
if (filter.From is { } from)
{
filtered = filtered.Where(i => i.DetectedAt >= from);
}
if (filter.To is { } to)
{
filtered = filtered.Where(i => i.DetectedAt <= to);
}
return filtered
.OrderByDescending(static i => i.DetectedAt)
.ToList();
@@ -88,16 +80,9 @@ public sealed class AnomalyBrowsingService : IAnomalyBrowsingService
return new AnomalyDetailVm(item, pre, post);
}
public async Task<int> GetUnreadCountAsync(DateTimeOffset since, CancellationToken ct)
{
var all = await _anomalies.ListAsync(ct).ConfigureAwait(false);
var count = 0;
foreach (var anomaly in all)
{
if (anomaly.DetectedAt > since) count++;
}
return count;
}
public Task<int> GetUnreadCountAsync(DateTimeOffset since, CancellationToken ct)
// Server-side COUNT(*) — no longer materialises the table to count.
=> _anomalies.CountSinceAsync(since, ct);
public async Task<IReadOnlyList<int>> ListKnownSportCodesAsync(CancellationToken ct)
{
@@ -125,14 +110,8 @@ public sealed class AnomalyBrowsingService : IAnomalyBrowsingService
.Distinct()
.ToList();
var dict = new Dictionary<DomainEventId, Event>(distinct.Count);
foreach (var eid in distinct)
{
ct.ThrowIfCancellationRequested();
var ev = await _events.GetAsync(eid, ct).ConfigureAwait(false);
if (ev is not null) dict[eid] = ev;
}
return dict;
// Single batched query instead of one GetAsync per distinct event (N+1).
return await _events.GetManyAsync(distinct, ct).ConfigureAwait(false);
}
private static bool TryProject(
@@ -151,7 +130,7 @@ public sealed class AnomalyBrowsingService : IAnomalyBrowsingService
var country = ev?.CountryCode ?? string.Empty;
var league = ev?.LeagueId ?? string.Empty;
var title = ev is not null
? $"{ev.Side1Name} vs {ev.Side2Name}"
? ev.Title
: anomaly.EventId.Value;
var preSnap = ToSnapshot(dto.PreSuspension);
@@ -0,0 +1,62 @@
using Marathon.Application.UseCases;
using Marathon.Domain.AnomalyDetection;
using DomainEventId = Marathon.Domain.ValueObjects.EventId;
namespace Marathon.UI.Services;
/// <summary>
/// Page-facing implementation of <see cref="IAnomalyInsightsService"/>. Runs
/// the application use case and reshapes its output for the page — event title
/// strings and severity buckets are computed once from the report's payload, so
/// the service performs no repository I/O of its own.
/// </summary>
public sealed class AnomalyInsightsService : IAnomalyInsightsService
{
private readonly EvaluateAnomalyOutcomesUseCase _useCase;
public AnomalyInsightsService(EvaluateAnomalyOutcomesUseCase useCase)
{
_useCase = useCase ?? throw new ArgumentNullException(nameof(useCase));
}
public async Task<AnomalyInsightsVm> GetReportAsync(CancellationToken ct)
{
var report = await _useCase.ExecuteAsync(ct).ConfigureAwait(false);
var resolvedRows = report.Resolved
.Select(r => ToRow(r, report.EventTitles))
.ToList();
var unresolvedRows = report.Unresolved
.Select(r => ToRow(r, report.EventTitles))
.ToList();
return new AnomalyInsightsVm(
TotalAnomalies: report.TotalAnomalies,
ResolvedCount: report.ResolvedCount,
UnresolvedCount: report.UnresolvedCount,
HitCount: report.HitCount,
MissCount: report.MissCount,
HitRate: report.HitRate,
BySeverity: report.BySeverity,
BySport: report.BySport,
ByScoreBin: report.ByScoreBin,
Resolved: resolvedRows,
Unresolved: unresolvedRows);
}
private static ResolvedAnomalyRow ToRow(
ResolvedAnomaly src,
IReadOnlyDictionary<DomainEventId, string> titles) =>
new(
AnomalyId: src.AnomalyId,
EventId: src.EventId,
EventTitle: titles.TryGetValue(src.EventId, out var t) ? t : src.EventId.Value,
DetectedAt: src.DetectedAt,
Score: src.Score,
Severity: AnomalySeverityRules.FromScore(src.Score),
Sport: src.Sport,
PreFlipFavourite: src.PreFlipFavourite,
PostFlipFavourite: src.PostFlipFavourite,
ActualWinner: src.ActualWinner,
Outcome: src.Outcome);
}
@@ -0,0 +1,41 @@
using Marathon.Application.Reporting;
using Marathon.Domain.AnomalyDetection;
using Marathon.Domain.Enums;
using Marathon.Domain.ValueObjects;
namespace Marathon.UI.Services;
/// <summary>
/// UI-facing projection of <see cref="AnomalyOutcomeReport"/>. Adds a resolved
/// event title and severity bucket per row so the page never has to round-trip
/// to a repository.
/// </summary>
public sealed record AnomalyInsightsVm(
int TotalAnomalies,
int ResolvedCount,
int UnresolvedCount,
int HitCount,
int MissCount,
decimal? HitRate,
IReadOnlyList<OutcomeBucket> BySeverity,
IReadOnlyList<OutcomeBucket> BySport,
IReadOnlyList<OutcomeBucket> ByScoreBin,
IReadOnlyList<ResolvedAnomalyRow> Resolved,
IReadOnlyList<ResolvedAnomalyRow> Unresolved);
/// <summary>
/// One row in the resolved / unresolved drill-down list — anomaly + outcome +
/// pre-shaped event title for the link-back affordance.
/// </summary>
public sealed record ResolvedAnomalyRow(
Guid AnomalyId,
EventId EventId,
string EventTitle,
DateTimeOffset DetectedAt,
decimal Score,
AnomalySeverity Severity,
SportCode? Sport,
Side? PreFlipFavourite,
Side? PostFlipFavourite,
Side? ActualWinner,
AnomalyOutcomeKind Outcome);
@@ -1,3 +1,4 @@
using Marathon.Domain.AnomalyDetection;
using Marathon.Domain.Enums;
using Marathon.Domain.ValueObjects;
@@ -83,12 +84,14 @@ public enum AnomalyFavourite
None,
}
/// <summary>Helpers for severity bucketing.</summary>
/// <summary>Helpers for severity bucketing. Thresholds come from
/// <see cref="AnomalySeverityThresholds"/> so the UI badges and the
/// Application-layer outcome report agree by construction.</summary>
public static class AnomalySeverityRules
{
public const decimal LowThreshold = 0.30m;
public const decimal MediumThreshold = 0.45m;
public const decimal HighThreshold = 0.60m;
public const decimal LowThreshold = AnomalySeverityThresholds.Low;
public const decimal MediumThreshold = AnomalySeverityThresholds.Medium;
public const decimal HighThreshold = AnomalySeverityThresholds.High;
public static AnomalySeverity FromScore(decimal score) => score switch
{
@@ -0,0 +1,61 @@
using Marathon.Application.UseCases;
namespace Marathon.UI.Services;
/// <summary>
/// Page-facing implementation of <see cref="IBacktestService"/>. The use case
/// hands back per-event titles inside the result so the service does no
/// repository I/O of its own.
/// </summary>
public sealed class BacktestService : IBacktestService
{
private readonly RunBacktestUseCase _useCase;
public BacktestService(RunBacktestUseCase useCase)
{
_useCase = useCase ?? throw new ArgumentNullException(nameof(useCase));
}
public async Task<BacktestVm> RunAsync(BacktestForm form, CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(form);
if (!form.IsValid(out var err))
throw new ArgumentException(err ?? "Invalid form.", nameof(form));
var result = await _useCase.ExecuteAsync(form.ToStrategy(), form.ToDateRange(), ct).ConfigureAwait(false);
var rows = result.Trace
.Select(t => new BacktestTraceRow(
Trace: t,
EventTitle: result.EventTitles.TryGetValue(t.EventId, out var title)
? title
: t.EventId.Value))
.ToList();
var curve = result.Trace
.Select(t => new EquityPoint(t.DetectedAt, t.BankrollAfter))
.ToList();
return new BacktestVm(
StartingBankroll: result.StartingBankroll,
FinalBankroll: result.FinalBankroll,
NetProfit: result.NetProfit,
RoiPercent: result.RoiPercent,
TotalStaked: result.TotalStaked,
TotalReturned: result.TotalReturned,
MaxDrawdown: result.MaxDrawdown,
MaxDrawdownPercent: result.MaxDrawdownPercent,
BetsPlaced: result.BetsPlaced,
Wins: result.Wins,
Losses: result.Losses,
Skipped: result.Skipped,
SkippedByThreshold: result.SkippedByThreshold,
SkippedByDataQuality: result.SkippedByDataQuality,
SkippedByBankroll: result.SkippedByBankroll,
MaxWinStreak: result.MaxWinStreak,
MaxLossStreak: result.MaxLossStreak,
Trace: rows,
EquityCurve: curve);
}
}
@@ -0,0 +1,113 @@
using Marathon.Application.Storage;
using Marathon.Domain.Backtesting;
using Marathon.Domain.Enums;
using Marathon.Domain.ValueObjects;
namespace Marathon.UI.Services;
/// <summary>
/// Form bound by the Backtest page. Loose-typed so MudBlazor fields can bind
/// raw numerics; the service translates this into a domain
/// <see cref="BacktestStrategy"/> after validation.
/// </summary>
public sealed class BacktestForm
{
public decimal StartingBankroll { get; set; } = 1000m;
public decimal MinScore { get; set; } = 0.45m;
public StakeRule StakeRule { get; set; } = StakeRule.Flat;
public decimal FlatStake { get; set; } = 50m;
/// <summary>Bound to the UI as a percentage 0100; converted to a fraction before sim.</summary>
public decimal PercentOfBankrollPercent { get; set; } = 2m;
/// <summary>Bound to the UI as a percentage 0100; converted to a fraction before sim.</summary>
public decimal KellyFractionPercent { get; set; } = 25m;
/// <summary>Optional inclusive date-range filter (Moscow dates). Both null = all anomalies.</summary>
public DateTime? From { get; set; }
/// <summary>Optional inclusive date-range filter (Moscow dates). Both null = all anomalies.</summary>
public DateTime? To { get; set; }
public bool IsValid(out string? error)
{
if (StartingBankroll <= 0m) { error = "Bankroll must be positive."; return false; }
if (MinScore is < 0m or > 1m) { error = "Min score must be in [0, 1]."; return false; }
if (From is { } f && To is { } t && f.Date > t.Date)
{ error = "From date must be on or before To date."; return false; }
switch (StakeRule)
{
case StakeRule.Flat:
if (FlatStake <= 0m) { error = "Flat stake must be positive."; return false; }
if (FlatStake > StartingBankroll) { error = "Flat stake exceeds starting bankroll."; return false; }
break;
case StakeRule.PercentOfBankroll:
if (PercentOfBankrollPercent is <= 0m or > 100m)
{ error = "Percent of bankroll must be in (0, 100]."; return false; }
break;
case StakeRule.Kelly:
if (KellyFractionPercent is <= 0m or > 100m)
{ error = "Kelly fraction must be in (0, 100]."; return false; }
break;
}
error = null;
return true;
}
public BacktestStrategy ToStrategy() =>
new(
StartingBankroll: StartingBankroll,
MinScore: MinScore,
StakeRule: StakeRule,
FlatStake: FlatStake,
PercentOfBankroll: PercentOfBankrollPercent / 100m,
KellyFraction: KellyFractionPercent / 100m);
/// <summary>
/// The inclusive Moscow-day date range, or null when either bound is unset
/// (meaning: run over every graded anomaly).
/// </summary>
public DateRange? ToDateRange()
{
if (From is not { } from || To is not { } to)
return null;
return new DateRange(
new DateTimeOffset(from.Date, MoscowTime.Offset),
MoscowTime.EndOfMoscowDay(DateOnly.FromDateTime(to.Date)));
}
}
/// <summary>UI-facing projection of <see cref="BacktestResult"/>.</summary>
public sealed record BacktestVm(
decimal StartingBankroll,
decimal FinalBankroll,
decimal NetProfit,
decimal? RoiPercent,
decimal TotalStaked,
decimal TotalReturned,
decimal MaxDrawdown,
decimal? MaxDrawdownPercent,
int BetsPlaced,
int Wins,
int Losses,
int Skipped,
int SkippedByThreshold,
int SkippedByDataQuality,
int SkippedByBankroll,
int MaxWinStreak,
int MaxLossStreak,
IReadOnlyList<BacktestTraceRow> Trace,
IReadOnlyList<EquityPoint> EquityCurve);
/// <summary>
/// Trace row plus pre-shaped event title for the link-back affordance.
/// </summary>
public sealed record BacktestTraceRow(
BacktestTrace Trace,
string EventTitle);
/// <summary>One point on the equity curve — bankroll over time.</summary>
/// <param name="DetectedAt">When the bet would have been placed.</param>
/// <param name="Bankroll">Bankroll after this bet settled.</param>
public sealed record EquityPoint(DateTimeOffset DetectedAt, decimal Bankroll);
@@ -0,0 +1,117 @@
using System.Globalization;
using Marathon.Application.Abstractions;
using Marathon.Application.Storage;
using Marathon.Application.UseCases;
using Marathon.Domain.Entities;
using Marathon.Domain.Enums;
using Marathon.Domain.ValueObjects;
using DomainEventId = Marathon.Domain.ValueObjects.EventId;
namespace Marathon.UI.Services;
/// <summary>
/// Page-facing implementation of <see cref="IBetJournalService"/>. Composes the
/// four bet-journal use cases and joins event titles for the table rows.
/// </summary>
public sealed class BetJournalService : IBetJournalService
{
private readonly BuildBetJournalReportUseCase _build;
private readonly RecordPlacedBetUseCase _record;
private readonly ResolvePendingBetsUseCase _resolve;
private readonly DeletePlacedBetUseCase _delete;
private readonly IEventRepository _events;
public BetJournalService(
BuildBetJournalReportUseCase build,
RecordPlacedBetUseCase record,
ResolvePendingBetsUseCase resolve,
DeletePlacedBetUseCase delete,
IEventRepository events)
{
_build = build ?? throw new ArgumentNullException(nameof(build));
_record = record ?? throw new ArgumentNullException(nameof(record));
_resolve = resolve ?? throw new ArgumentNullException(nameof(resolve));
_delete = delete ?? throw new ArgumentNullException(nameof(delete));
_events = events ?? throw new ArgumentNullException(nameof(events));
}
public async Task<BetJournalVm> GetReportAsync(CancellationToken ct)
{
var report = await _build.ExecuteAsync(ct).ConfigureAwait(false);
if (report.Bets.Count == 0)
return new BetJournalVm(report.Stats, Array.Empty<BetJournalRowVm>());
// Resolve event titles in one batched query — distinct ids only (was N+1).
// Missing events (pruned by snapshot retention) fall back to the raw id.
var distinctIds = report.Bets.Select(r => r.Bet.EventId).Distinct().ToList();
var events = await _events.GetManyAsync(distinctIds, ct).ConfigureAwait(false);
var titles = new Dictionary<DomainEventId, string>(distinctIds.Count);
foreach (var id in distinctIds)
{
titles[id] = events.TryGetValue(id, out var ev)
? ev.Title
: id.Value;
}
var rows = report.Bets
.Select(r => new BetJournalRowVm(
Id: r.Bet.Id,
EventTitle: titles.TryGetValue(r.Bet.EventId, out var t) ? t : r.Bet.EventId.Value,
Bet: r.Bet,
ClvProbabilityDelta: r.ClvProbabilityDelta))
.ToList();
return new BetJournalVm(report.Stats, rows);
}
public async Task<Guid> AddAsync(AddBetForm form, CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(form);
if (!form.IsValid(out var error))
throw new ArgumentException(error ?? "Invalid form.", nameof(form));
var selection = new Bet(
scope: MatchScope.Instance,
type: form.Type,
side: form.Side,
value: form.Value is { } v ? new OddsValue(v) : null,
rate: new OddsRate(form.Rate));
var bet = new PlacedBet(
Id: Guid.NewGuid(),
EventId: new DomainEventId(form.EventId.Trim()),
Selection: selection,
Stake: form.Stake,
PlacedAt: MoscowTime.Now,
Outcome: BetOutcome.Pending,
Notes: form.Notes);
var stored = await _record.ExecuteAsync(bet, ct).ConfigureAwait(false);
return stored.Id;
}
public Task DeleteAsync(Guid betId, CancellationToken ct) =>
_delete.ExecuteAsync(betId, ct);
public Task<int> ResolvePendingAsync(CancellationToken ct) =>
_resolve.ExecuteAsync(ct);
public async Task<IReadOnlyList<EventOption>> GetUpcomingEventOptionsAsync(CancellationToken ct)
{
// Generous betting window: recently started through a month out. Loaded once
// by the page; the autocomplete filters this list in memory per keystroke.
var now = MoscowTime.Now;
var range = new DateRange(now.AddDays(-7), now.AddDays(30));
var events = await _events.ListByDateRangeAsync(range, ct).ConfigureAwait(false);
return events
.OrderBy(e => e.ScheduledAt)
.Select(e => new EventOption(
e.Id.Value,
string.Concat(e.Title, " · ", e.ScheduledAt.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture)),
e.ScheduledAt))
.ToList();
}
}
@@ -0,0 +1,86 @@
using Marathon.Application.Betting;
using Marathon.Domain.Entities;
using Marathon.Domain.Enums;
namespace Marathon.UI.Services;
/// <summary>
/// Page-facing projection of <see cref="BetJournalReport"/>. Adds the
/// pre-shaped event title per row so the Journal page never has to round-trip
/// back to <see cref="Marathon.Application.Abstractions.IEventRepository"/>.
/// </summary>
public sealed record BetJournalVm(
BetJournalStats Stats,
IReadOnlyList<BetJournalRowVm> Bets);
/// <summary>Row-level view model for the journal table.</summary>
public sealed record BetJournalRowVm(
Guid Id,
string EventTitle,
PlacedBet Bet,
decimal? ClvProbabilityDelta);
/// <summary>
/// Data the Add-Bet form posts. Loose-typed so the form can bind raw inputs;
/// <see cref="ToDomain"/> applies the same invariants as
/// <see cref="Bet"/> / <see cref="PlacedBet"/> and surfaces validation errors
/// as exceptions.
/// </summary>
public sealed class AddBetForm
{
public string EventId { get; set; } = string.Empty;
/// <summary>Bet type enum value — defaults to Win.</summary>
public BetType Type { get; set; } = BetType.Win;
/// <summary>Side enum value — Win/Side1 default.</summary>
public Side Side { get; set; } = Side.Side1;
/// <summary>Handicap / total threshold; required for WinFora and Total.</summary>
public decimal? Value { get; set; }
/// <summary>The decimal odds the user took at placement.</summary>
public decimal Rate { get; set; } = 1.90m;
public decimal Stake { get; set; } = 100m;
public string? Notes { get; set; }
/// <summary>Upper sanity caps so a typo cannot torch the KPI strip.</summary>
public const decimal MaxRate = 1000m;
/// <summary>Upper sanity cap on a single wager.</summary>
public const decimal MaxStake = 10_000_000m;
public bool IsValid(out string? error)
{
if (string.IsNullOrWhiteSpace(EventId)) { error = "EventId is required."; return false; }
if (Stake <= 0m) { error = "Stake must be positive."; return false; }
if (Stake > MaxStake) { error = $"Stake must be at most {MaxStake:N0}."; return false; }
if (Rate < 1.01m) { error = "Rate must be at least 1.01."; return false; }
if (Rate > MaxRate) { error = $"Rate must be at most {MaxRate:N0}."; return false; }
// Mirror Bet invariants — surface a friendly message instead of throwing
// ArgumentException deep in the use case.
switch (Type)
{
case BetType.Win:
if (Side is not (Side.Side1 or Side.Side2)) { error = "Win bet requires Side1 or Side2."; return false; }
break;
case BetType.Draw:
if (Side != Side.Draw) { error = "Draw bet requires Side = Draw."; return false; }
break;
case BetType.WinFora:
if (Side is not (Side.Side1 or Side.Side2)) { error = "Handicap bet requires Side1 or Side2."; return false; }
if (Value is null or 0m) { error = "Handicap bet needs a non-zero threshold."; return false; }
break;
case BetType.Total:
if (Side is not (Side.Less or Side.More)) { error = "Total bet requires Less or More."; return false; }
if (Value is null or 0m) { error = "Total bet needs a non-zero threshold."; return false; }
break;
}
error = null;
return true;
}
}
@@ -0,0 +1,74 @@
using Marathon.Application.Abstractions;
using Marathon.Domain.ValueObjects;
using Microsoft.Extensions.Options;
namespace Marathon.UI.Services;
/// <summary>
/// Repository-backed implementation of <see cref="IDashboardSummaryService"/>.
/// Composes server-side counts (no full-table materialisation) with the top few
/// anomaly signals and the worker-toggle state. Scoped — captures the per-circuit
/// repository scope like the other browsing services.
/// </summary>
public sealed class DashboardSummaryService : IDashboardSummaryService
{
private const int LatestSignalCount = 5;
private readonly IEventRepository _events;
private readonly ISnapshotRepository _snapshots;
private readonly IAnomalyRepository _anomalies;
private readonly IAnomalyBrowsingService _anomalyBrowsing;
private readonly IOptionsMonitor<WorkerOptions> _workers;
public DashboardSummaryService(
IEventRepository events,
ISnapshotRepository snapshots,
IAnomalyRepository anomalies,
IAnomalyBrowsingService anomalyBrowsing,
IOptionsMonitor<WorkerOptions> workers)
{
_events = events ?? throw new ArgumentNullException(nameof(events));
_snapshots = snapshots ?? throw new ArgumentNullException(nameof(snapshots));
_anomalies = anomalies ?? throw new ArgumentNullException(nameof(anomalies));
_anomalyBrowsing = anomalyBrowsing ?? throw new ArgumentNullException(nameof(anomalyBrowsing));
_workers = workers ?? throw new ArgumentNullException(nameof(workers));
}
public async Task<DashboardSummary> GetAsync(CancellationToken ct)
{
var todayStart = new DateTimeOffset(MoscowTime.Now.Date, MoscowTime.Offset);
var eventsTracked = await _events.CountAsync(ct).ConfigureAwait(false);
var snapshotsToday = await _snapshots.CountSinceAsync(todayStart, ct).ConfigureAwait(false);
// DateTimeOffset.MinValue lower bound counts every row (year dominates the
// lexical comparison regardless of offset).
var anomaliesTotal = await _anomalies.CountSinceAsync(DateTimeOffset.MinValue, ct).ConfigureAwait(false);
var anomaliesToday = await _anomalies.CountSinceAsync(todayStart, ct).ConfigureAwait(false);
var sports = await _events.ListDistinctSportCodesAsync(ct).ConfigureAwait(false);
var latest = anomaliesTotal == 0
? (IReadOnlyList<AnomalyListItem>)Array.Empty<AnomalyListItem>()
: (await _anomalyBrowsing.ListAsync(new AnomalyFilter(), ct).ConfigureAwait(false))
.Take(LatestSignalCount)
.ToList();
var w = _workers.CurrentValue;
return new DashboardSummary(
EventsTracked: eventsTracked,
SnapshotsToday: snapshotsToday,
AnomaliesTotal: anomaliesTotal,
AnomaliesToday: anomaliesToday,
SportsCovered: sports.Count,
LatestSignals: latest,
ScheduleStatus: StageStatus(w.UpcomingPollerEnabled, eventsTracked > 0),
SnapshotStatus: StageStatus(w.LivePollerEnabled, snapshotsToday > 0),
DetectorStatus: StageStatus(w.AnomalyDetectionEnabled, anomaliesTotal > 0),
ExportStatus: eventsTracked > 0 ? "ok" : "idle");
}
// Maps a worker stage to the PipelineStep token: disabled → idle, enabled but
// not yet producing → warn (waiting), enabled and producing → ok.
private static string StageStatus(bool enabled, bool producing) =>
!enabled ? "idle" : producing ? "ok" : "warn";
}
@@ -83,15 +83,17 @@ public sealed class EventBrowsingService : IEventBrowsingService
{
ArgumentNullException.ThrowIfNull(filter);
var range = new DateRange(filter.Dates.From, filter.Dates.To);
var events = await _events.ListByDateRangeAsync(range, ct).ConfigureAwait(false);
// Date range + sport filter pushed to SQL (was: load the whole date range,
// then filter sports in memory). Country/search filtering and locale-aware
// sorting stay here to preserve the Cyrillic ordinal semantics that SQLite's
// BINARY collation would change.
var query = new EventQuery(
new DateRange(filter.Dates.From, filter.Dates.To),
filter.SportCodes);
var events = await _events.QueryAsync(query, ct).ConfigureAwait(false);
// Apply non-temporal filters in-memory — list size is small (UI page).
IEnumerable<Event> filtered = events;
if (filter.SportCodes is { Count: > 0 } sports)
filtered = filtered.Where(e => sports.Contains(e.Sport.Value));
if (filter.CountryCodes is { Count: > 0 } countries)
filtered = filtered.Where(e => countries.Contains(e.CountryCode, StringComparer.OrdinalIgnoreCase));
@@ -0,0 +1,13 @@
namespace Marathon.UI.Services;
/// <summary>
/// Browsing facade in front of <see cref="Marathon.Application.UseCases.EvaluateAnomalyOutcomesUseCase"/>.
/// The Insights page binds to this — never to the use case directly — so the
/// per-row event-title join, severity bucketing, and any future caching live
/// in one place.
/// </summary>
public interface IAnomalyInsightsService
{
/// <summary>Builds the full report and projects it for the UI.</summary>
Task<AnomalyInsightsVm> GetReportAsync(CancellationToken ct);
}
@@ -0,0 +1,13 @@
namespace Marathon.UI.Services;
/// <summary>
/// Browsing facade in front of <see cref="Marathon.Application.UseCases.RunBacktestUseCase"/>.
/// The Backtest page binds to this — view-model shaping and event-title
/// joining live here so the page stays declarative.
/// </summary>
public interface IBacktestService
{
/// <summary>Validates the form, runs the simulator, projects for the UI.</summary>
/// <exception cref="ArgumentException">Form fails its own validation.</exception>
Task<BacktestVm> RunAsync(BacktestForm form, CancellationToken ct);
}
@@ -0,0 +1,41 @@
namespace Marathon.UI.Services;
/// <summary>
/// Browsing facade over the bet-journal use cases. The Journal page binds to
/// this — never the use cases directly — so view-model shaping, event-title
/// joining, and validation surface in one place.
/// </summary>
public interface IBetJournalService
{
/// <summary>Builds the full report and projects it for the UI.</summary>
Task<BetJournalVm> GetReportAsync(CancellationToken ct);
/// <summary>
/// Validates and persists a manually entered bet. Returns the newly stored
/// row's id. Throws <see cref="InvalidOperationException"/> when the form
/// validates but references an unknown event.
/// </summary>
/// <exception cref="ArgumentException">Form fails its own validation.</exception>
Task<Guid> AddAsync(AddBetForm form, CancellationToken ct);
/// <summary>Removes a bet by id. No-op when the id is unknown.</summary>
Task DeleteAsync(Guid betId, CancellationToken ct);
/// <summary>
/// Sweeps pending bets and grades the ones whose events are now resolved.
/// Returns the count graded in this pass.
/// </summary>
Task<int> ResolvePendingAsync(CancellationToken ct);
/// <summary>
/// Upcoming (and recently-started) events for the bet-entry autocomplete,
/// ordered by kickoff. Loaded once by the page; filtered client-side per keystroke.
/// </summary>
Task<IReadOnlyList<EventOption>> GetUpcomingEventOptionsAsync(CancellationToken ct);
}
/// <summary>A selectable event for the bet-journal "find event" autocomplete.</summary>
/// <param name="Id">The bookmaker event code (what the form's Event ID expects).</param>
/// <param name="Label">Display text: "Home vs Away · yyyy-MM-dd HH:mm".</param>
/// <param name="ScheduledAt">Kickoff, for ordering.</param>
public sealed record EventOption(string Id, string Label, DateTimeOffset ScheduledAt);
@@ -0,0 +1,45 @@
namespace Marathon.UI.Services;
/// <summary>
/// Live figures for the dashboard (Home) page: real counts, the most recent
/// anomaly signals, and a per-stage capture-pipeline health read derived from the
/// worker toggles + whether each stage is actually producing data.
/// </summary>
public interface IDashboardSummaryService
{
Task<DashboardSummary> GetAsync(CancellationToken ct);
}
/// <summary>
/// Snapshot of dashboard figures. <see cref="HasAnyData"/> drives the first-run
/// empty state. Pipeline statuses use the PipelineStep token vocabulary
/// (<c>ok</c> / <c>warn</c> / <c>idle</c> / <c>error</c>).
/// </summary>
public sealed record DashboardSummary(
int EventsTracked,
int SnapshotsToday,
int AnomaliesTotal,
int AnomaliesToday,
int SportsCovered,
IReadOnlyList<AnomalyListItem> LatestSignals,
string ScheduleStatus,
string SnapshotStatus,
string DetectorStatus,
string ExportStatus)
{
/// <summary>True once anything has been captured — gates the welcome/empty state.</summary>
public bool HasAnyData => EventsTracked > 0 || AnomaliesTotal > 0;
/// <summary>An empty summary used as the initial render state before data loads.</summary>
public static DashboardSummary Empty { get; } = new(
EventsTracked: 0,
SnapshotsToday: 0,
AnomaliesTotal: 0,
AnomaliesToday: 0,
SportsCovered: 0,
LatestSignals: Array.Empty<AnomalyListItem>(),
ScheduleStatus: "idle",
SnapshotStatus: "idle",
DetectorStatus: "idle",
ExportStatus: "idle");
}
@@ -57,7 +57,11 @@ public static class UiServicesExtensions
// Browsing facades — Scoped so they capture the per-circuit repository scope.
services.AddScoped<IEventBrowsingService, EventBrowsingService>();
services.AddScoped<IAnomalyBrowsingService, AnomalyBrowsingService>();
services.AddScoped<IAnomalyInsightsService, AnomalyInsightsService>();
services.AddScoped<IResultsBrowsingService, ResultsBrowsingService>();
services.AddScoped<IBetJournalService, BetJournalService>();
services.AddScoped<IBacktestService, BacktestService>();
services.AddScoped<IDashboardSummaryService, DashboardSummaryService>();
// Settings writer — file path is host-resolved.
services.AddSingleton<ISettingsWriter>(_ => new JsonSettingsWriter(settingsLocalPath));
@@ -0,0 +1,96 @@
using FluentAssertions;
using Marathon.Application.Betting;
using Marathon.Domain.Entities;
using Marathon.Domain.Enums;
using Marathon.Domain.ValueObjects;
namespace Marathon.Application.Tests.Betting;
/// <summary>
/// Unit tests for <see cref="ClosingLineValueCalculator"/> covering the math
/// itself and the snapshot-matching path used by the report use case.
/// </summary>
public sealed class ClosingLineValueCalculatorTests
{
private static readonly EventId EventId = new("11111111");
[Fact]
public void Compute_Should_ReturnPositive_When_TakenRate_BeatsClose()
{
// Taken 2.20 (implied 0.4545); closed 2.00 (implied 0.5000) → CLV = +0.0455
var clv = ClosingLineValueCalculator.Compute(takenRate: 2.20m, closingRate: 2.00m);
clv.Should().BeGreaterThan(0m);
clv.Should().BeApproximately(0.04545m, 0.00001m);
}
[Fact]
public void Compute_Should_ReturnNegative_When_TakenRate_WorseThanClose()
{
// Taken 1.80 (0.5556); closed 2.00 (0.5000) → CLV = -0.0556
var clv = ClosingLineValueCalculator.Compute(takenRate: 1.80m, closingRate: 2.00m);
clv.Should().BeLessThan(0m);
clv.Should().BeApproximately(-0.05556m, 0.00001m);
}
[Fact]
public void Compute_Should_ReturnZero_When_RatesMatch()
{
ClosingLineValueCalculator.Compute(2.00m, 2.00m).Should().Be(0m);
}
[Theory]
[InlineData(0)]
[InlineData(-1)]
public void Compute_Should_Throw_When_AnyRateIsZeroOrNegative(decimal rate)
{
((Action)(() => ClosingLineValueCalculator.Compute(rate, 2m)))
.Should().Throw<ArgumentOutOfRangeException>();
((Action)(() => ClosingLineValueCalculator.Compute(2m, rate)))
.Should().Throw<ArgumentOutOfRangeException>();
}
[Fact]
public void TryCompute_Should_ReturnNull_When_NoMatchingBetInSnapshot()
{
var taken = new Bet(MatchScope.Instance, BetType.Win, Side.Side1,
value: null, new OddsRate(2.20m));
// Snapshot contains only Side2 — no match for Side1 Win.
var snapshot = new OddsSnapshot(EventId, DateTimeOffset.UtcNow, OddsSource.PreMatch,
new[] { new Bet(MatchScope.Instance, BetType.Win, Side.Side2, null, new OddsRate(1.70m)) });
var clv = ClosingLineValueCalculator.TryCompute(2.20m, taken, snapshot);
clv.Should().BeNull();
}
[Fact]
public void TryCompute_Should_ReturnNull_When_SnapshotIsNull()
{
var taken = new Bet(MatchScope.Instance, BetType.Win, Side.Side1, null, new OddsRate(2m));
ClosingLineValueCalculator.TryCompute(2m, taken, closingSnapshot: null).Should().BeNull();
}
[Fact]
public void TryCompute_Should_MatchOnScopeTypeSideAndValue()
{
// Two handicap markets with different thresholds — pick the right one.
var taken = new Bet(MatchScope.Instance, BetType.WinFora, Side.Side1,
new OddsValue(-1.5m), new OddsRate(2.20m));
var snapshot = new OddsSnapshot(EventId, DateTimeOffset.UtcNow, OddsSource.PreMatch,
new[]
{
new Bet(MatchScope.Instance, BetType.WinFora, Side.Side1,
new OddsValue(-2.5m), new OddsRate(3.50m)), // wrong threshold
new Bet(MatchScope.Instance, BetType.WinFora, Side.Side1,
new OddsValue(-1.5m), new OddsRate(2.00m)), // match
});
var clv = ClosingLineValueCalculator.TryCompute(2.20m, taken, snapshot);
clv.Should().BeApproximately(0.04545m, 0.00001m);
}
}
@@ -0,0 +1,210 @@
using FluentAssertions;
using Marathon.Application.Abstractions;
using Marathon.Application.UseCases;
using Marathon.Domain.Entities;
using Marathon.Domain.Enums;
using Marathon.Domain.ValueObjects;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
namespace Marathon.Application.Tests.UseCases;
public sealed class BuildBetJournalReportUseCaseTests
{
private static readonly TimeSpan MoscowOffset = TimeSpan.FromHours(3);
private static readonly DateTimeOffset Placed = new(2026, 5, 16, 12, 0, 0, MoscowOffset);
private static readonly DateTimeOffset Kickoff = new(2026, 5, 16, 18, 0, 0, MoscowOffset);
private readonly IPlacedBetRepository _bets = Substitute.For<IPlacedBetRepository>();
private readonly IEventRepository _events = Substitute.For<IEventRepository>();
private readonly ISnapshotRepository _snapshots = Substitute.For<ISnapshotRepository>();
public BuildBetJournalReportUseCaseTests()
{
// Use case batches event loads via GetManyAsync; route through per-id stubs.
TestFixtures.BridgeGetMany(_events);
}
private BuildBetJournalReportUseCase CreateSut() =>
new(_bets, _events, _snapshots, NullLogger<BuildBetJournalReportUseCase>.Instance);
private static PlacedBet MakeBet(
EventId id,
BetOutcome outcome,
Side side = Side.Side1,
decimal stake = 100m,
decimal rate = 2.10m) =>
new(
Guid.NewGuid(), id,
new Bet(MatchScope.Instance, BetType.Win, side, null, new OddsRate(rate)),
stake, Placed, outcome, null);
private static Event MakeEvent(EventId id, DateTimeOffset scheduledAt) =>
new(id, new SportCode(11), "BY", "L1", "Cat", scheduledAt, "Team A", "Team B");
private static OddsSnapshot MakeSnapshot(EventId id, DateTimeOffset at, decimal rateSide1) =>
new(id, at, OddsSource.PreMatch,
new[]
{
new Bet(MatchScope.Instance, BetType.Win, Side.Side1, null, new OddsRate(rateSide1)),
new Bet(MatchScope.Instance, BetType.Win, Side.Side2, null, new OddsRate(2.00m)),
});
[Fact]
public async Task Should_ReturnEmptyReport_When_NoBets()
{
_bets.ListAsync(Arg.Any<CancellationToken>())
.Returns(Array.Empty<PlacedBet>().ToList().AsReadOnly());
var report = await CreateSut().ExecuteAsync(CancellationToken.None);
report.Bets.Should().BeEmpty();
report.Stats.TotalBets.Should().Be(0);
report.Stats.RoiPercent.Should().BeNull();
}
[Fact]
public async Task Should_AggregateStats_AcrossMixedOutcomes()
{
var id1 = new EventId("e-1");
var id2 = new EventId("e-2");
var id3 = new EventId("e-3");
_bets.ListAsync(Arg.Any<CancellationToken>())
.Returns(new[]
{
MakeBet(id1, BetOutcome.Won, stake: 100m, rate: 2.00m), // gross 200, +100
MakeBet(id2, BetOutcome.Lost, stake: 100m, rate: 2.00m), // gross 0, -100
MakeBet(id3, BetOutcome.Pending),
}.ToList().AsReadOnly());
// Wire events so the report can compute CLV (we don't need actual CLV here — leave snapshots empty).
foreach (var id in new[] { id1, id2, id3 })
{
_events.GetAsync(id, Arg.Any<CancellationToken>()).Returns(MakeEvent(id, Kickoff));
_snapshots.GetLatestPreMatchAsync(id, Arg.Any<DateTimeOffset>(), Arg.Any<CancellationToken>())
.Returns((OddsSnapshot?)null);
}
var report = await CreateSut().ExecuteAsync(CancellationToken.None);
report.Stats.TotalBets.Should().Be(3);
report.Stats.PendingCount.Should().Be(1);
report.Stats.WonCount.Should().Be(1);
report.Stats.LostCount.Should().Be(1);
report.Stats.TotalStaked.Should().Be(200m, "pending bets are excluded from totals");
report.Stats.TotalReturned.Should().Be(200m);
report.Stats.NetProfit.Should().Be(0m);
report.Stats.RoiPercent.Should().Be(0m);
report.Stats.StrikeRatePercent.Should().Be(50m);
}
[Fact]
public async Task Should_ComputeClv_AgainstClosingSnapshot()
{
var id = new EventId("clv-event");
var bet = MakeBet(id, BetOutcome.Won, rate: 2.20m, stake: 100m);
_bets.ListAsync(Arg.Any<CancellationToken>())
.Returns(new[] { bet }.ToList().AsReadOnly());
_events.GetAsync(id, Arg.Any<CancellationToken>()).Returns(MakeEvent(id, Kickoff));
// Closing snapshot returned by the dedicated repo method.
_snapshots.GetLatestPreMatchAsync(id, Kickoff, Arg.Any<CancellationToken>())
.Returns(MakeSnapshot(id, Kickoff.AddMinutes(-5), rateSide1: 2.00m));
var report = await CreateSut().ExecuteAsync(CancellationToken.None);
report.Bets.Should().HaveCount(1);
report.Bets[0].ClvProbabilityDelta.Should().NotBeNull();
// taken 2.20 vs closing 2.00 → +0.04545
report.Bets[0].ClvProbabilityDelta!.Value.Should().BeApproximately(0.04545m, 0.00001m);
report.Stats.AverageClvProbabilityDelta.Should().NotBeNull();
}
[Fact]
public async Task Should_LeaveClvNull_When_NoClosingSnapshotAvailable()
{
var id = new EventId("no-close");
_bets.ListAsync(Arg.Any<CancellationToken>())
.Returns(new[] { MakeBet(id, BetOutcome.Won) }.ToList().AsReadOnly());
_events.GetAsync(id, Arg.Any<CancellationToken>()).Returns(MakeEvent(id, Kickoff));
_snapshots.GetLatestPreMatchAsync(id, Kickoff, Arg.Any<CancellationToken>())
.Returns((OddsSnapshot?)null);
var report = await CreateSut().ExecuteAsync(CancellationToken.None);
report.Bets[0].ClvProbabilityDelta.Should().BeNull();
report.Stats.AverageClvProbabilityDelta.Should().BeNull(
"no rows had a computable CLV — average is undefined");
}
[Fact]
public async Task Should_ExcludeVoidStakes_FromRoiTurnover()
{
// 1 Won (+100), 1 Lost (-100), 1 Void (stake returned). Industry-standard
// ROI excludes pushes from turnover, so total staked = 200, returned 200,
// net 0, ROI 0%. If voids were included turnover would be 300 → ROI ≈ 0%
// numerator but inflated denominator semantics.
var ids = Enumerable.Range(1, 3)
.Select(i => new EventId($"void-{i}")).ToArray();
_bets.ListAsync(Arg.Any<CancellationToken>())
.Returns(new[]
{
MakeBet(ids[0], BetOutcome.Won, stake: 100m, rate: 2.00m),
MakeBet(ids[1], BetOutcome.Lost, stake: 100m, rate: 2.00m),
MakeBet(ids[2], BetOutcome.Void, stake: 100m, rate: 2.00m),
}.ToList().AsReadOnly());
foreach (var id in ids)
{
_events.GetAsync(id, Arg.Any<CancellationToken>()).Returns(MakeEvent(id, Kickoff));
_snapshots.GetLatestPreMatchAsync(id, Arg.Any<DateTimeOffset>(), Arg.Any<CancellationToken>())
.Returns((OddsSnapshot?)null);
}
var report = await CreateSut().ExecuteAsync(CancellationToken.None);
report.Stats.VoidCount.Should().Be(1);
report.Stats.TotalStaked.Should().Be(200m,
"void bets are pushes — the stake was returned and should not count as turnover");
report.Stats.TotalReturned.Should().Be(200m);
report.Stats.NetProfit.Should().Be(0m);
report.Stats.RoiPercent.Should().Be(0m);
}
[Fact]
public async Task Should_OrderBets_NewestPlacedFirst()
{
var ids = Enumerable.Range(0, 3).Select(i => new EventId($"ord-{i}")).ToArray();
var older = new DateTimeOffset(2026, 5, 10, 12, 0, 0, MoscowOffset);
var newer = new DateTimeOffset(2026, 5, 16, 12, 0, 0, MoscowOffset);
// Bet 0 is the middle one, bet 1 oldest, bet 2 newest.
var b0 = new PlacedBet(Guid.NewGuid(), ids[0],
new Bet(MatchScope.Instance, BetType.Win, Side.Side1, null, new OddsRate(2m)),
100m, older.AddDays(1), BetOutcome.Won, null);
var b1 = new PlacedBet(Guid.NewGuid(), ids[1],
new Bet(MatchScope.Instance, BetType.Win, Side.Side1, null, new OddsRate(2m)),
100m, older, BetOutcome.Lost, null);
var b2 = new PlacedBet(Guid.NewGuid(), ids[2],
new Bet(MatchScope.Instance, BetType.Win, Side.Side1, null, new OddsRate(2m)),
100m, newer, BetOutcome.Pending, null);
_bets.ListAsync(Arg.Any<CancellationToken>())
.Returns(new[] { b0, b1, b2 }.ToList().AsReadOnly());
foreach (var id in ids)
{
_events.GetAsync(id, Arg.Any<CancellationToken>()).Returns(MakeEvent(id, Kickoff));
_snapshots.GetLatestPreMatchAsync(id, Arg.Any<DateTimeOffset>(), Arg.Any<CancellationToken>())
.Returns((OddsSnapshot?)null);
}
var report = await CreateSut().ExecuteAsync(CancellationToken.None);
report.Bets.Select(r => r.Bet.Id).Should().ContainInOrder(b2.Id, b0.Id, b1.Id);
}
}
@@ -0,0 +1,346 @@
using FluentAssertions;
using Marathon.Application.Abstractions;
using Marathon.Application.Reporting;
using Marathon.Application.UseCases;
using Marathon.Domain.AnomalyDetection;
using Marathon.Domain.Entities;
using Marathon.Domain.Enums;
using Marathon.Domain.ValueObjects;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
namespace Marathon.Application.Tests.UseCases;
/// <summary>
/// Unit tests for <see cref="EvaluateAnomalyOutcomesUseCase"/> covering empty
/// state, mixed hit/miss aggregation, unresolved partitioning, and missing
/// event metadata fallbacks.
/// </summary>
public sealed class EvaluateAnomalyOutcomesUseCaseTests
{
private readonly IAnomalyRepository _anomalies = Substitute.For<IAnomalyRepository>();
private readonly IEventRepository _events = Substitute.For<IEventRepository>();
private readonly IResultRepository _results = Substitute.For<IResultRepository>();
public EvaluateAnomalyOutcomesUseCaseTests()
{
// Use cases batch event/result loads via GetManyAsync; route those through
// the per-id GetAsync stubs each test already configures.
TestFixtures.BridgeGetMany(_events);
TestFixtures.BridgeGetMany(_results);
}
private static readonly TimeSpan MoscowOffset = TimeSpan.FromHours(3);
private static readonly DateTimeOffset BaseTime =
new(2026, 5, 10, 18, 0, 0, MoscowOffset);
// Flip evidence with Side1 → Side2 reversal.
private const string FlipEvidence = """
{
"suspensionGapSeconds": 90,
"preSuspension": {
"capturedAt": "2026-05-10T18:00:00+03:00",
"p1": 0.55, "pDraw": 0.20, "p2": 0.25,
"rate1": 1.8, "rateDraw": 4.5, "rate2": 4.0
},
"postSuspension": {
"capturedAt": "2026-05-10T18:02:30+03:00",
"p1": 0.25, "pDraw": 0.20, "p2": 0.55,
"rate1": 4.0, "rateDraw": 4.5, "rate2": 1.8
}
}
""";
private EvaluateAnomalyOutcomesUseCase CreateSut() =>
new(_anomalies, _events, _results,
NullLogger<EvaluateAnomalyOutcomesUseCase>.Instance);
private static Anomaly MakeAnomaly(EventId eventId, decimal score) =>
new(Guid.NewGuid(), eventId, BaseTime, AnomalyKind.SuspensionFlip,
score, FlipEvidence);
private static Event MakeEvent(EventId id, int sportCode) =>
new(id, new SportCode(sportCode), "BY", "L1", "Cat",
BaseTime, "Team A", "Team B");
[Fact]
public async Task Should_ReturnEmptyReport_When_NoAnomaliesExist()
{
_anomalies.ListAsync(Arg.Any<CancellationToken>())
.Returns(Array.Empty<Anomaly>().ToList().AsReadOnly());
var report = await CreateSut().ExecuteAsync(CancellationToken.None);
report.TotalAnomalies.Should().Be(0);
report.HitRate.Should().BeNull();
report.Resolved.Should().BeEmpty();
report.BySport.Should().BeEmpty();
report.BySeverity.Should().BeEmpty();
report.ByScoreBin.Should().BeEmpty();
}
[Fact]
public async Task Should_PartitionAnomalies_Into_ResolvedAndUnresolved()
{
var id1 = new EventId("11111111");
var id2 = new EventId("22222222");
_anomalies.ListAsync(Arg.Any<CancellationToken>())
.Returns(new[]
{
MakeAnomaly(id1, score: 0.65m),
MakeAnomaly(id2, score: 0.40m),
}.ToList().AsReadOnly());
_events.GetAsync(id1, Arg.Any<CancellationToken>()).Returns(MakeEvent(id1, 11));
_events.GetAsync(id2, Arg.Any<CancellationToken>()).Returns(MakeEvent(id2, 6));
// id1 has a result → resolved; id2 has no result → unresolved.
_results.GetAsync(id1, Arg.Any<CancellationToken>())
.Returns(new EventResult(id1, 0, 2, Side.Side2, DateTimeOffset.UtcNow));
_results.GetAsync(id2, Arg.Any<CancellationToken>())
.Returns((EventResult?)null);
var report = await CreateSut().ExecuteAsync(CancellationToken.None);
report.TotalAnomalies.Should().Be(2);
report.ResolvedCount.Should().Be(1);
report.UnresolvedCount.Should().Be(1);
report.HitCount.Should().Be(1, "id1's post-flip favourite (Side2) matched the actual winner");
report.MissCount.Should().Be(0);
report.HitRate.Should().Be(1.0m);
}
[Fact]
public async Task Should_ComputeHitRate_Across_MixedHitsAndMisses()
{
var ids = Enumerable.Range(1, 4)
.Select(i => new EventId($"event-{i:00000000}"))
.ToArray();
_anomalies.ListAsync(Arg.Any<CancellationToken>())
.Returns(ids.Select(id => MakeAnomaly(id, score: 0.55m)).ToList().AsReadOnly());
foreach (var id in ids)
{
_events.GetAsync(id, Arg.Any<CancellationToken>()).Returns(MakeEvent(id, 11));
}
// Three hits (Side2 wins), one miss (Side1 wins).
_results.GetAsync(ids[0], Arg.Any<CancellationToken>())
.Returns(new EventResult(ids[0], 0, 2, Side.Side2, DateTimeOffset.UtcNow));
_results.GetAsync(ids[1], Arg.Any<CancellationToken>())
.Returns(new EventResult(ids[1], 0, 2, Side.Side2, DateTimeOffset.UtcNow));
_results.GetAsync(ids[2], Arg.Any<CancellationToken>())
.Returns(new EventResult(ids[2], 0, 2, Side.Side2, DateTimeOffset.UtcNow));
_results.GetAsync(ids[3], Arg.Any<CancellationToken>())
.Returns(new EventResult(ids[3], 2, 0, Side.Side1, DateTimeOffset.UtcNow));
var report = await CreateSut().ExecuteAsync(CancellationToken.None);
report.HitCount.Should().Be(3);
report.MissCount.Should().Be(1);
report.HitRate.Should().Be(0.75m);
}
[Fact]
public async Task Should_BuildSeverityBuckets_Across_LowMediumHigh()
{
var idLow = new EventId("low000000");
var idMed = new EventId("med000000");
var idHigh = new EventId("high00000");
_anomalies.ListAsync(Arg.Any<CancellationToken>())
.Returns(new[]
{
MakeAnomaly(idLow, score: 0.35m),
MakeAnomaly(idMed, score: 0.50m),
MakeAnomaly(idHigh, score: 0.75m),
}.ToList().AsReadOnly());
foreach (var id in new[] { idLow, idMed, idHigh })
{
_events.GetAsync(id, Arg.Any<CancellationToken>()).Returns(MakeEvent(id, 11));
_results.GetAsync(id, Arg.Any<CancellationToken>())
.Returns(new EventResult(id, 0, 2, Side.Side2, DateTimeOffset.UtcNow));
}
var report = await CreateSut().ExecuteAsync(CancellationToken.None);
report.BySeverity.Should().HaveCount(3);
report.BySeverity.Single(b => b.Key == OutcomeBucketKeys.SeverityLow).Total.Should().Be(1);
report.BySeverity.Single(b => b.Key == OutcomeBucketKeys.SeverityMedium).Total.Should().Be(1);
report.BySeverity.Single(b => b.Key == OutcomeBucketKeys.SeverityHigh).Total.Should().Be(1);
}
[Fact]
public async Task Should_GroupBySport_When_AnomaliesSpanMultipleSports()
{
var idFb = new EventId("fb000000");
var idBb = new EventId("bb000000");
_anomalies.ListAsync(Arg.Any<CancellationToken>())
.Returns(new[]
{
MakeAnomaly(idFb, score: 0.55m),
MakeAnomaly(idBb, score: 0.55m),
}.ToList().AsReadOnly());
_events.GetAsync(idFb, Arg.Any<CancellationToken>()).Returns(MakeEvent(idFb, 11));
_events.GetAsync(idBb, Arg.Any<CancellationToken>()).Returns(MakeEvent(idBb, 6));
_results.GetAsync(idFb, Arg.Any<CancellationToken>())
.Returns(new EventResult(idFb, 0, 2, Side.Side2, DateTimeOffset.UtcNow));
_results.GetAsync(idBb, Arg.Any<CancellationToken>())
.Returns(new EventResult(idBb, 2, 0, Side.Side1, DateTimeOffset.UtcNow));
var report = await CreateSut().ExecuteAsync(CancellationToken.None);
report.BySport.Select(b => b.Key)
.Should().BeEquivalentTo(new[] { "Sport.6", "Sport.11" });
report.BySport.Single(b => b.Key == "Sport.11").HitRate.Should().Be(1.0m);
report.BySport.Single(b => b.Key == "Sport.6").HitRate.Should().Be(0.0m);
}
[Fact]
public async Task Should_BuildSevenScoreBins_With_CanonicalKeys()
{
var id = new EventId("score000");
_anomalies.ListAsync(Arg.Any<CancellationToken>())
.Returns(new[] { MakeAnomaly(id, score: 0.95m) }.ToList().AsReadOnly());
_events.GetAsync(id, Arg.Any<CancellationToken>()).Returns(MakeEvent(id, 11));
_results.GetAsync(id, Arg.Any<CancellationToken>())
.Returns(new EventResult(id, 0, 2, Side.Side2, DateTimeOffset.UtcNow));
var report = await CreateSut().ExecuteAsync(CancellationToken.None);
report.ByScoreBin.Should().HaveCount(7, "default buckets cover [0.30, 1.00] in 0.10-wide bins");
report.ByScoreBin.Select(b => b.Key).Should().BeEquivalentTo(
new[]
{
"Bin.0.30-0.40", "Bin.0.40-0.50", "Bin.0.50-0.60", "Bin.0.60-0.70",
"Bin.0.70-0.80", "Bin.0.80-0.90", "Bin.0.90-1.00",
},
options => options.WithStrictOrdering(),
"the page reads these literals to render labels");
report.ByScoreBin.Last().Total.Should().Be(1, "score 0.95 should land in the [0.90, 1.00] bin");
}
[Theory]
[InlineData(0.30, "Bin.0.30-0.40")]
[InlineData(0.40, "Bin.0.40-0.50")]
[InlineData(0.5999, "Bin.0.50-0.60")]
[InlineData(0.60, "Bin.0.60-0.70")]
[InlineData(1.00, "Bin.0.90-1.00")]
public async Task Should_PlaceScore_InCorrectBin_AtBoundary(double scoreDouble, string expectedKey)
{
var score = (decimal)scoreDouble;
var id = new EventId("boundary");
_anomalies.ListAsync(Arg.Any<CancellationToken>())
.Returns(new[] { MakeAnomaly(id, score) }.ToList().AsReadOnly());
_events.GetAsync(id, Arg.Any<CancellationToken>()).Returns(MakeEvent(id, 11));
_results.GetAsync(id, Arg.Any<CancellationToken>())
.Returns(new EventResult(id, 0, 2, Side.Side2, DateTimeOffset.UtcNow));
var report = await CreateSut().ExecuteAsync(CancellationToken.None);
var bin = report.ByScoreBin.Single(b => b.Total == 1);
bin.Key.Should().Be(expectedKey);
}
[Fact]
public async Task Should_ExtendScoreBinsBelow_When_DetectorThresholdIsLowered()
{
// Operator lowered Anomaly.OddsFlipThreshold to 0.10 → anomalies with
// score 0.15 exist. The histogram must still account for them.
var idLow = new EventId("lowscore");
var idHigh = new EventId("hicscore");
_anomalies.ListAsync(Arg.Any<CancellationToken>())
.Returns(new[]
{
MakeAnomaly(idLow, score: 0.15m),
MakeAnomaly(idHigh, score: 0.85m),
}.ToList().AsReadOnly());
foreach (var id in new[] { idLow, idHigh })
{
_events.GetAsync(id, Arg.Any<CancellationToken>()).Returns(MakeEvent(id, 11));
_results.GetAsync(id, Arg.Any<CancellationToken>())
.Returns(new EventResult(id, 0, 2, Side.Side2, DateTimeOffset.UtcNow));
}
var report = await CreateSut().ExecuteAsync(CancellationToken.None);
report.ByScoreBin.Sum(b => b.Total).Should().Be(report.ResolvedCount,
"the histogram total must equal ResolvedCount regardless of detector tuning");
report.ByScoreBin.First().Key.Should().Be("Bin.0.10-0.20",
"buckets are extended downward to include the lowest observed score");
}
[Fact]
public async Task Should_PopulateEventTitles_ForJoinedEvents()
{
var id = new EventId("title000");
_anomalies.ListAsync(Arg.Any<CancellationToken>())
.Returns(new[] { MakeAnomaly(id, 0.55m) }.ToList().AsReadOnly());
_events.GetAsync(id, Arg.Any<CancellationToken>()).Returns(MakeEvent(id, 11));
_results.GetAsync(id, Arg.Any<CancellationToken>())
.Returns(new EventResult(id, 0, 2, Side.Side2, DateTimeOffset.UtcNow));
var report = await CreateSut().ExecuteAsync(CancellationToken.None);
report.EventTitles.Should().ContainKey(id);
report.EventTitles[id].Should().Be("Team A vs Team B");
}
[Fact]
public async Task Should_BatchEventAndResultLoads_InsteadOfPerIdGetAsync()
{
// Regression guard for the N+1 fix: the use case must resolve events/results
// via the batched GetManyAsync, never the per-id GetAsync in a loop. We stub
// GetManyAsync directly (overriding the constructor bridge) so DidNotReceive()
// on GetAsync is meaningful.
var id1 = new EventId("11111111");
var id2 = new EventId("22222222");
_anomalies.ListAsync(Arg.Any<CancellationToken>())
.Returns(new[] { MakeAnomaly(id1, 0.55m), MakeAnomaly(id2, 0.55m) }.ToList().AsReadOnly());
_events.GetManyAsync(Arg.Any<IReadOnlyCollection<EventId>>(), Arg.Any<CancellationToken>())
.Returns(new Dictionary<EventId, Event> { [id1] = MakeEvent(id1, 11), [id2] = MakeEvent(id2, 6) });
_results.GetManyAsync(Arg.Any<IReadOnlyCollection<EventId>>(), Arg.Any<CancellationToken>())
.Returns(new Dictionary<EventId, EventResult>());
await CreateSut().ExecuteAsync(CancellationToken.None);
await _events.Received(1)
.GetManyAsync(Arg.Any<IReadOnlyCollection<EventId>>(), Arg.Any<CancellationToken>());
await _events.DidNotReceive()
.GetAsync(Arg.Any<EventId>(), Arg.Any<CancellationToken>());
await _results.Received(1)
.GetManyAsync(Arg.Any<IReadOnlyCollection<EventId>>(), Arg.Any<CancellationToken>());
await _results.DidNotReceive()
.GetAsync(Arg.Any<EventId>(), Arg.Any<CancellationToken>());
}
[Fact]
public async Task Should_HandleMissingEvent_By_OmittingFromSportBuckets()
{
var id = new EventId("orphan00");
_anomalies.ListAsync(Arg.Any<CancellationToken>())
.Returns(new[] { MakeAnomaly(id, score: 0.55m) }.ToList().AsReadOnly());
_events.GetAsync(id, Arg.Any<CancellationToken>()).Returns((Event?)null);
_results.GetAsync(id, Arg.Any<CancellationToken>())
.Returns(new EventResult(id, 0, 2, Side.Side2, DateTimeOffset.UtcNow));
var report = await CreateSut().ExecuteAsync(CancellationToken.None);
report.Resolved.Should().HaveCount(1,
"orphan anomalies are still evaluated for hit/miss");
report.BySport.Should().BeEmpty(
"missing event metadata excludes the row from sport breakdown");
}
}
@@ -17,6 +17,13 @@ public sealed class PullResultsUseCaseTests
private readonly IEventRepository _eventRepo = Substitute.For<IEventRepository>();
private readonly IResultRepository _resultRepo = Substitute.For<IResultRepository>();
public PullResultsUseCaseTests()
{
// Selection-mode candidate resolution now batches via GetManyAsync; route
// it through the per-id GetAsync stubs each test configures.
TestFixtures.BridgeGetMany(_eventRepo);
}
private static readonly DateRange AnyRange = new(
DateTimeOffset.UtcNow.AddDays(-1),
DateTimeOffset.UtcNow);
@@ -0,0 +1,75 @@
using FluentAssertions;
using Marathon.Application.Abstractions;
using Marathon.Application.UseCases;
using Marathon.Domain.Entities;
using Marathon.Domain.Enums;
using Marathon.Domain.ValueObjects;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
namespace Marathon.Application.Tests.UseCases;
public sealed class RecordPlacedBetUseCaseTests
{
private static readonly TimeSpan MoscowOffset = TimeSpan.FromHours(3);
private readonly IPlacedBetRepository _bets = Substitute.For<IPlacedBetRepository>();
private readonly IEventRepository _events = Substitute.For<IEventRepository>();
private readonly IResultRepository _results = Substitute.For<IResultRepository>();
private RecordPlacedBetUseCase CreateSut() =>
new(_bets, _events, _results, NullLogger<RecordPlacedBetUseCase>.Instance);
private static PlacedBet MakePending(EventId id, Side side = Side.Side1) =>
new(
Id: Guid.NewGuid(),
EventId: id,
Selection: new Bet(MatchScope.Instance, BetType.Win, side, null, new OddsRate(2.10m)),
Stake: 100m,
PlacedAt: new DateTimeOffset(2026, 5, 16, 12, 0, 0, MoscowOffset),
Outcome: BetOutcome.Pending,
Notes: null);
[Fact]
public async Task Should_Throw_When_EventDoesNotExist()
{
var id = new EventId("missing");
_events.GetAsync(id, Arg.Any<CancellationToken>()).Returns((Event?)null);
var act = async () => await CreateSut().ExecuteAsync(MakePending(id), CancellationToken.None);
await act.Should().ThrowAsync<InvalidOperationException>().WithMessage("*unknown event*");
await _bets.DidNotReceive().AddAsync(Arg.Any<PlacedBet>(), Arg.Any<CancellationToken>());
}
[Fact]
public async Task Should_PersistPending_When_NoResultYet()
{
var id = new EventId("event001");
_events.GetAsync(id, Arg.Any<CancellationToken>()).Returns(TestFixtures.MakeEvent(id.Value));
_results.GetAsync(id, Arg.Any<CancellationToken>()).Returns((EventResult?)null);
var bet = MakePending(id);
var stored = await CreateSut().ExecuteAsync(bet, CancellationToken.None);
stored.Outcome.Should().Be(BetOutcome.Pending, "no result yet — should remain pending");
await _bets.Received(1).AddAsync(Arg.Any<PlacedBet>(), Arg.Any<CancellationToken>());
await _bets.Received(1).SaveChangesAsync(Arg.Any<CancellationToken>());
}
[Fact]
public async Task Should_AutoGrade_When_ResultAlreadyAvailable()
{
var id = new EventId("event002");
_events.GetAsync(id, Arg.Any<CancellationToken>()).Returns(TestFixtures.MakeEvent(id.Value));
_results.GetAsync(id, Arg.Any<CancellationToken>())
.Returns(new EventResult(id, 2, 1, Side.Side1, DateTimeOffset.UtcNow));
var bet = MakePending(id, side: Side.Side1);
var stored = await CreateSut().ExecuteAsync(bet, CancellationToken.None);
stored.Outcome.Should().Be(BetOutcome.Won, "Side1 was selected and Side1 won");
}
}
@@ -0,0 +1,91 @@
using FluentAssertions;
using Marathon.Application.Abstractions;
using Marathon.Application.UseCases;
using Marathon.Domain.Entities;
using Marathon.Domain.Enums;
using Marathon.Domain.ValueObjects;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
namespace Marathon.Application.Tests.UseCases;
public sealed class ResolvePendingBetsUseCaseTests
{
private static readonly TimeSpan MoscowOffset = TimeSpan.FromHours(3);
private static readonly DateTimeOffset Placed =
new(2026, 5, 16, 12, 0, 0, MoscowOffset);
private readonly IPlacedBetRepository _bets = Substitute.For<IPlacedBetRepository>();
private readonly IResultRepository _results = Substitute.For<IResultRepository>();
private ResolvePendingBetsUseCase CreateSut() =>
new(_bets, _results, NullLogger<ResolvePendingBetsUseCase>.Instance);
private static PlacedBet MakePending(EventId id, Side side = Side.Side1) =>
new(
Guid.NewGuid(), id,
new Bet(MatchScope.Instance, BetType.Win, side, null, new OddsRate(2.10m)),
100m, Placed, BetOutcome.Pending, null);
[Fact]
public async Task Should_ReturnZero_When_NoPendingBets()
{
_bets.ListByOutcomeAsync(BetOutcome.Pending, Arg.Any<CancellationToken>())
.Returns(Array.Empty<PlacedBet>().ToList().AsReadOnly());
var count = await CreateSut().ExecuteAsync(CancellationToken.None);
count.Should().Be(0);
await _bets.DidNotReceive().UpdateAsync(Arg.Any<PlacedBet>(), Arg.Any<CancellationToken>());
}
[Fact]
public async Task Should_GradeBetsWithResults_AndLeaveOthersAlone()
{
var idGraded = new EventId("event-1");
var idUngraded = new EventId("event-2");
_bets.ListByOutcomeAsync(BetOutcome.Pending, Arg.Any<CancellationToken>())
.Returns(new[]
{
MakePending(idGraded, side: Side.Side1),
MakePending(idUngraded, side: Side.Side2),
}.ToList().AsReadOnly());
_results.GetAsync(idGraded, Arg.Any<CancellationToken>())
.Returns(new EventResult(idGraded, 2, 1, Side.Side1, DateTimeOffset.UtcNow));
_results.GetAsync(idUngraded, Arg.Any<CancellationToken>())
.Returns((EventResult?)null);
var count = await CreateSut().ExecuteAsync(CancellationToken.None);
count.Should().Be(1, "only event-1 has a result");
await _bets.Received(1).UpdateAsync(
Arg.Is<PlacedBet>(b => b.EventId == idGraded && b.Outcome == BetOutcome.Won),
Arg.Any<CancellationToken>());
await _bets.DidNotReceive().UpdateAsync(
Arg.Is<PlacedBet>(b => b.EventId == idUngraded),
Arg.Any<CancellationToken>());
}
[Fact]
public async Task Should_CacheResultLookups_PerEvent()
{
// Two pending bets on the same event — only one result fetch should fire.
var id = new EventId("event-shared");
_bets.ListByOutcomeAsync(BetOutcome.Pending, Arg.Any<CancellationToken>())
.Returns(new[]
{
MakePending(id, side: Side.Side1),
MakePending(id, side: Side.Side2),
}.ToList().AsReadOnly());
_results.GetAsync(id, Arg.Any<CancellationToken>())
.Returns(new EventResult(id, 2, 1, Side.Side1, DateTimeOffset.UtcNow));
await CreateSut().ExecuteAsync(CancellationToken.None);
await _results.Received(1).GetAsync(id, Arg.Any<CancellationToken>());
}
}
@@ -0,0 +1,159 @@
using FluentAssertions;
using Marathon.Application.Abstractions;
using Marathon.Application.Storage;
using Marathon.Application.UseCases;
using Marathon.Domain.Backtesting;
using Marathon.Domain.Entities;
using Marathon.Domain.Enums;
using Marathon.Domain.ValueObjects;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
namespace Marathon.Application.Tests.UseCases;
/// <summary>
/// Tests the orchestration: anomaly + event + result join + parse + delegate to
/// the simulator. The simulator's own correctness is covered in
/// Marathon.Domain.Tests.
/// </summary>
public sealed class RunBacktestUseCaseTests
{
private static readonly TimeSpan MoscowOffset = TimeSpan.FromHours(3);
private static readonly DateTimeOffset BaseTime =
new(2026, 5, 10, 18, 0, 0, MoscowOffset);
private readonly IAnomalyRepository _anomalies = Substitute.For<IAnomalyRepository>();
private readonly IEventRepository _events = Substitute.For<IEventRepository>();
private readonly IResultRepository _results = Substitute.For<IResultRepository>();
public RunBacktestUseCaseTests()
{
// Use case batches event/result loads via GetManyAsync; route through per-id stubs.
TestFixtures.BridgeGetMany(_events);
TestFixtures.BridgeGetMany(_results);
}
private RunBacktestUseCase CreateSut() =>
new(_anomalies, _events, _results, NullLogger<RunBacktestUseCase>.Instance);
private const string FlipEvidence = """
{
"suspensionGapSeconds": 90,
"preSuspension": {
"capturedAt": "2026-05-10T18:00:00+03:00",
"p1": 0.55, "pDraw": 0.20, "p2": 0.25,
"rate1": 1.8, "rateDraw": 4.5, "rate2": 4.0
},
"postSuspension": {
"capturedAt": "2026-05-10T18:02:30+03:00",
"p1": 0.25, "pDraw": 0.20, "p2": 0.55,
"rate1": 4.0, "rateDraw": 4.5, "rate2": 1.8
}
}
""";
private static Anomaly MakeAnomaly(EventId eventId, decimal score = 0.55m, string? evidence = null) =>
new(Guid.NewGuid(), eventId, BaseTime, AnomalyKind.SuspensionFlip,
score, evidence ?? FlipEvidence);
private static Event MakeEvent(EventId id) =>
new(id, new SportCode(11), "BY", "L1", "Cat", BaseTime, "Team A", "Team B");
private static BacktestStrategy DefaultStrategy(decimal minScore = 0.30m) =>
new(StartingBankroll: 1000m, MinScore: minScore,
StakeRule: StakeRule.Flat,
FlatStake: 100m, PercentOfBankroll: 0.02m, KellyFraction: 0.25m);
[Fact]
public async Task Should_LoadByDateRange_When_RangeProvided()
{
var id = new EventId("77777777");
var range = new DateRange(BaseTime.AddDays(-1), BaseTime.AddDays(1));
_anomalies.ListByDateRangeAsync(
Arg.Any<DateTimeOffset?>(), Arg.Any<DateTimeOffset?>(), Arg.Any<CancellationToken>())
.Returns(new[] { MakeAnomaly(id) }.ToList().AsReadOnly());
_events.GetAsync(id, Arg.Any<CancellationToken>()).Returns(MakeEvent(id));
_results.GetAsync(id, Arg.Any<CancellationToken>())
.Returns(new EventResult(id, 0, 2, Side.Side2, DateTimeOffset.UtcNow));
var result = await CreateSut().ExecuteAsync(DefaultStrategy(), range, CancellationToken.None);
await _anomalies.Received(1).ListByDateRangeAsync(
Arg.Any<DateTimeOffset?>(), Arg.Any<DateTimeOffset?>(), Arg.Any<CancellationToken>());
await _anomalies.DidNotReceive().ListAsync(Arg.Any<CancellationToken>());
result.BetsPlaced.Should().BeGreaterThan(0, "the in-range graded anomaly produces a bet");
}
[Fact]
public async Task Should_ReturnEmptyResult_When_NoAnomaliesExist()
{
_anomalies.ListAsync(Arg.Any<CancellationToken>())
.Returns(Array.Empty<Anomaly>().ToList().AsReadOnly());
var result = await CreateSut().ExecuteAsync(DefaultStrategy(), CancellationToken.None);
result.BetsPlaced.Should().Be(0);
result.FinalBankroll.Should().Be(1000m);
}
[Fact]
public async Task Should_SimulateBet_When_AnomalyHasResult()
{
// Anomaly with result, Side2 (post-flip favourite) wins → +100.
var id = new EventId("event-1");
_anomalies.ListAsync(Arg.Any<CancellationToken>())
.Returns(new[] { MakeAnomaly(id, score: 0.55m) }.ToList().AsReadOnly());
_events.GetAsync(id, Arg.Any<CancellationToken>()).Returns(MakeEvent(id));
_results.GetAsync(id, Arg.Any<CancellationToken>())
.Returns(new EventResult(id, 0, 2, Side.Side2, DateTimeOffset.UtcNow));
var result = await CreateSut().ExecuteAsync(DefaultStrategy(), CancellationToken.None);
result.BetsPlaced.Should().Be(1);
result.Wins.Should().Be(1);
result.NetProfit.Should().Be(80m,
"stake 100 at rate 1.8 — win pays 180, profit 80");
}
[Fact]
public async Task Should_FilterOut_AnomaliesWithoutResults()
{
var graded = new EventId("graded");
var ungraded = new EventId("ungraded");
_anomalies.ListAsync(Arg.Any<CancellationToken>())
.Returns(new[] { MakeAnomaly(graded), MakeAnomaly(ungraded) }.ToList().AsReadOnly());
_events.GetAsync(graded, Arg.Any<CancellationToken>()).Returns(MakeEvent(graded));
_events.GetAsync(ungraded, Arg.Any<CancellationToken>()).Returns(MakeEvent(ungraded));
_results.GetAsync(graded, Arg.Any<CancellationToken>())
.Returns(new EventResult(graded, 0, 2, Side.Side2, DateTimeOffset.UtcNow));
_results.GetAsync(ungraded, Arg.Any<CancellationToken>())
.Returns((EventResult?)null);
var result = await CreateSut().ExecuteAsync(DefaultStrategy(), CancellationToken.None);
// Only the graded anomaly should reach the simulator — the ungraded one is filtered out
// before the simulator, so it does NOT appear in result.Skipped.
(result.BetsPlaced + result.Skipped).Should().Be(1);
}
[Fact]
public async Task Should_FilterOut_AnomaliesWithMalformedEvidence()
{
var id = new EventId("bad-evidence");
_anomalies.ListAsync(Arg.Any<CancellationToken>())
.Returns(new[] { MakeAnomaly(id, evidence: "{not json") }.ToList().AsReadOnly());
_events.GetAsync(id, Arg.Any<CancellationToken>()).Returns(MakeEvent(id));
_results.GetAsync(id, Arg.Any<CancellationToken>())
.Returns(new EventResult(id, 0, 2, Side.Side2, DateTimeOffset.UtcNow));
var result = await CreateSut().ExecuteAsync(DefaultStrategy(), CancellationToken.None);
result.BetsPlaced.Should().Be(0);
result.Skipped.Should().Be(0,
"malformed evidence is filtered before the simulator — not counted as a strategy skip");
}
}
@@ -1,8 +1,10 @@
using Marathon.Application.Abstractions;
using Marathon.Application.Configuration;
using Marathon.Domain.Entities;
using Marathon.Domain.Enums;
using Marathon.Domain.ValueObjects;
using Microsoft.Extensions.Options;
using NSubstitute;
namespace Marathon.Application.Tests.UseCases;
@@ -13,6 +15,45 @@ internal static class TestFixtures
{
private static readonly TimeSpan MoscowOffset = TimeSpan.FromHours(3);
/// <summary>
/// Bridges the legacy per-id <c>GetAsync</c> stubs to the batched
/// <c>GetManyAsync</c> the use cases now call: each requested id is resolved
/// through whatever <c>GetAsync</c> was configured to return for it. Lets the
/// existing per-id <c>.Returns(...)</c> setups keep working unchanged.
/// </summary>
public static void BridgeGetMany(IEventRepository events)
{
events.GetManyAsync(Arg.Any<IReadOnlyCollection<EventId>>(), Arg.Any<CancellationToken>())
.Returns(ci =>
{
var ids = ci.Arg<IReadOnlyCollection<EventId>>();
var dict = new Dictionary<EventId, Event>();
foreach (var id in ids.Distinct())
{
var ev = events.GetAsync(id, CancellationToken.None).GetAwaiter().GetResult();
if (ev is not null) dict[id] = ev;
}
return (IReadOnlyDictionary<EventId, Event>)dict;
});
}
/// <inheritdoc cref="BridgeGetMany(IEventRepository)"/>
public static void BridgeGetMany(IResultRepository results)
{
results.GetManyAsync(Arg.Any<IReadOnlyCollection<EventId>>(), Arg.Any<CancellationToken>())
.Returns(ci =>
{
var ids = ci.Arg<IReadOnlyCollection<EventId>>();
var dict = new Dictionary<EventId, EventResult>();
foreach (var id in ids.Distinct())
{
var r = results.GetAsync(id, CancellationToken.None).GetAwaiter().GetResult();
if (r is not null) dict[id] = r;
}
return (IReadOnlyDictionary<EventId, EventResult>)dict;
});
}
/// <summary>Creates a minimal valid <see cref="Event"/> with the given event ID string.</summary>
public static Event MakeEvent(string eventIdValue = "12345678")
{
@@ -0,0 +1,122 @@
using FluentAssertions;
using Marathon.Domain.AnomalyDetection;
using Marathon.Domain.Enums;
namespace Marathon.Domain.Tests.AnomalyDetection;
/// <summary>
/// Unit tests for <see cref="AnomalyEvidenceParser"/> covering happy path,
/// two-way (no draw), and malformed JSON tolerance.
/// </summary>
public sealed class AnomalyEvidenceParserTests
{
[Fact]
public void Should_Parse_ThreeWayEvidence_With_DrawOutcome()
{
const string json = """
{
"suspensionGapSeconds": 90,
"preSuspension": {
"capturedAt": "2026-05-10T18:00:00+03:00",
"p1": 0.55, "pDraw": 0.20, "p2": 0.25,
"rate1": 1.8, "rateDraw": 4.5, "rate2": 4.0
},
"postSuspension": {
"capturedAt": "2026-05-10T18:02:30+03:00",
"p1": 0.25, "pDraw": 0.20, "p2": 0.55,
"rate1": 4.0, "rateDraw": 4.5, "rate2": 1.8
}
}
""";
var parsed = AnomalyEvidenceParser.TryParse(json, out var data);
parsed.Should().BeTrue();
data.SuspensionGapSeconds.Should().Be(90);
data.PreSuspension.P1.Should().Be(0.55m);
data.PreSuspension.PDraw.Should().Be(0.20m);
data.PreSuspension.Favourite.Should().Be(Side.Side1, "Side1 had the highest pre-suspension probability");
data.PostSuspension.Favourite.Should().Be(Side.Side2, "Side2 became favourite after the flip");
}
[Fact]
public void Should_Parse_TwoWayEvidence_With_NullDraw()
{
const string json = """
{
"suspensionGapSeconds": 75,
"preSuspension": {
"capturedAt": "2026-05-10T18:00:00+03:00",
"p1": 0.70, "p2": 0.30,
"rate1": 1.4, "rate2": 3.3
},
"postSuspension": {
"capturedAt": "2026-05-10T18:01:30+03:00",
"p1": 0.30, "p2": 0.70,
"rate1": 3.3, "rate2": 1.4
}
}
""";
var parsed = AnomalyEvidenceParser.TryParse(json, out var data);
parsed.Should().BeTrue();
data.PreSuspension.PDraw.Should().BeNull("tennis has no draw outcome");
data.PreSuspension.RateDraw.Should().BeNull();
data.PreSuspension.Favourite.Should().Be(Side.Side1);
data.PostSuspension.Favourite.Should().Be(Side.Side2);
}
[Fact]
public void Should_ReturnFalse_When_JsonIsNullOrEmpty()
{
AnomalyEvidenceParser.TryParse(null, out _).Should().BeFalse();
AnomalyEvidenceParser.TryParse(string.Empty, out _).Should().BeFalse();
AnomalyEvidenceParser.TryParse(" ", out _).Should().BeFalse();
}
[Fact]
public void Should_ReturnFalse_When_JsonIsMalformed()
{
AnomalyEvidenceParser.TryParse("{not json", out _).Should().BeFalse();
}
[Fact]
public void Should_ReturnFalse_When_PreOrPostSuspensionMissing()
{
const string onlyPre = """
{
"suspensionGapSeconds": 90,
"preSuspension": {
"capturedAt": "2026-05-10T18:00:00+03:00",
"p1": 0.55, "p2": 0.25, "rate1": 1.8, "rate2": 4.0
}
}
""";
AnomalyEvidenceParser.TryParse(onlyPre, out _).Should().BeFalse();
}
[Fact]
public void Favourite_Should_Be_Draw_When_DrawIsMostLikely()
{
const string json = """
{
"suspensionGapSeconds": 60,
"preSuspension": {
"capturedAt": "2026-05-10T18:00:00+03:00",
"p1": 0.30, "pDraw": 0.50, "p2": 0.20,
"rate1": 3.3, "rateDraw": 2.0, "rate2": 5.0
},
"postSuspension": {
"capturedAt": "2026-05-10T18:01:00+03:00",
"p1": 0.30, "pDraw": 0.50, "p2": 0.20,
"rate1": 3.3, "rateDraw": 2.0, "rate2": 5.0
}
}
""";
AnomalyEvidenceParser.TryParse(json, out var data).Should().BeTrue();
data.PreSuspension.Favourite.Should().Be(Side.Draw);
}
}
@@ -0,0 +1,178 @@
using FluentAssertions;
using Marathon.Domain.AnomalyDetection;
using Marathon.Domain.Entities;
using Marathon.Domain.Enums;
using Marathon.Domain.ValueObjects;
namespace Marathon.Domain.Tests.AnomalyDetection;
/// <summary>
/// Unit tests for <see cref="AnomalyOutcomeEvaluator"/> covering the join with
/// <see cref="EventResult"/> for hit / miss / unresolved verdicts.
/// </summary>
public sealed class AnomalyOutcomeEvaluatorTests
{
private static readonly TimeSpan MoscowOffset = TimeSpan.FromHours(3);
private static readonly EventId DefaultEventId = new("12345678");
private const string ThreeWayFlipJson = """
{
"suspensionGapSeconds": 90,
"preSuspension": {
"capturedAt": "2026-05-10T18:00:00+03:00",
"p1": 0.55, "pDraw": 0.20, "p2": 0.25,
"rate1": 1.8, "rateDraw": 4.5, "rate2": 4.0
},
"postSuspension": {
"capturedAt": "2026-05-10T18:02:30+03:00",
"p1": 0.25, "pDraw": 0.20, "p2": 0.55,
"rate1": 4.0, "rateDraw": 4.5, "rate2": 1.8
}
}
""";
private static Anomaly MakeAnomaly(string evidenceJson, decimal score = 0.5m) =>
new(
Id: Guid.NewGuid(),
EventId: DefaultEventId,
DetectedAt: new DateTimeOffset(2026, 5, 10, 18, 5, 0, MoscowOffset),
Kind: AnomalyKind.SuspensionFlip,
Score: score,
EvidenceJson: evidenceJson);
private static EventResult MakeResult(Side winner, int s1 = 1, int s2 = 1) =>
new(DefaultEventId, s1, s2, winner, DateTimeOffset.UtcNow);
[Fact]
public void Should_ReportHit_When_PostFlipFavourite_Wins()
{
// Post-flip favourite = Side2; result = Side2 wins → Hit.
var anomaly = MakeAnomaly(ThreeWayFlipJson, score: 0.65m);
var result = MakeResult(Side.Side2, s1: 0, s2: 2);
var verdict = AnomalyOutcomeEvaluator.Evaluate(anomaly, new SportCode(6), result);
verdict.Outcome.Should().Be(AnomalyOutcomeKind.Hit);
verdict.PreFlipFavourite.Should().Be(Side.Side1);
verdict.PostFlipFavourite.Should().Be(Side.Side2);
verdict.ActualWinner.Should().Be(Side.Side2);
verdict.Sport!.Value.Should().Be(6);
}
[Fact]
public void Should_ReportMiss_When_PostFlipFavourite_Loses()
{
// Post-flip favourite = Side2; result = Side1 wins → Miss (detector wrong).
var anomaly = MakeAnomaly(ThreeWayFlipJson);
var result = MakeResult(Side.Side1, s1: 2, s2: 0);
var verdict = AnomalyOutcomeEvaluator.Evaluate(anomaly, new SportCode(11), result);
verdict.Outcome.Should().Be(AnomalyOutcomeKind.Miss);
verdict.PostFlipFavourite.Should().Be(Side.Side2);
verdict.ActualWinner.Should().Be(Side.Side1);
}
[Fact]
public void Should_ReportMiss_When_DrawOccurred_AndPostFlipFavouriteIsNotDraw()
{
var anomaly = MakeAnomaly(ThreeWayFlipJson);
var result = MakeResult(Side.Draw, s1: 1, s2: 1);
var verdict = AnomalyOutcomeEvaluator.Evaluate(anomaly, null, result);
verdict.Outcome.Should().Be(AnomalyOutcomeKind.Miss);
verdict.ActualWinner.Should().Be(Side.Draw);
}
[Fact]
public void Should_ReportUnresolved_When_ResultIsNull()
{
var anomaly = MakeAnomaly(ThreeWayFlipJson);
var verdict = AnomalyOutcomeEvaluator.Evaluate(anomaly, new SportCode(22723), result: null);
verdict.Outcome.Should().Be(AnomalyOutcomeKind.Unresolved);
verdict.ActualWinner.Should().BeNull();
// Pre/post favourites still computed for display.
verdict.PreFlipFavourite.Should().Be(Side.Side1);
verdict.PostFlipFavourite.Should().Be(Side.Side2);
}
[Fact]
public void Should_ReportUnresolved_When_EvidenceJsonIsMalformed()
{
var anomaly = MakeAnomaly("{malformed");
var result = MakeResult(Side.Side1);
var verdict = AnomalyOutcomeEvaluator.Evaluate(anomaly, null, result);
verdict.Outcome.Should().Be(AnomalyOutcomeKind.Unresolved,
"evidence cannot be parsed so we cannot judge the prediction");
verdict.PreFlipFavourite.Should().BeNull(
"fabricated favourites would mislead any consumer that reads the unresolved branch");
verdict.PostFlipFavourite.Should().BeNull();
verdict.ActualWinner.Should().Be(Side.Side1, "the result side is still known and surfaced");
}
[Fact]
public void Should_ReportHit_For_TwoWayTennis_When_PostFlipFavouriteWins()
{
const string twoWayJson = """
{
"suspensionGapSeconds": 75,
"preSuspension": {
"capturedAt": "2026-05-10T18:00:00+03:00",
"p1": 0.70, "p2": 0.30, "rate1": 1.4, "rate2": 3.3
},
"postSuspension": {
"capturedAt": "2026-05-10T18:01:30+03:00",
"p1": 0.30, "p2": 0.70, "rate1": 3.3, "rate2": 1.4
}
}
""";
var anomaly = MakeAnomaly(twoWayJson, score: 0.55m);
var result = MakeResult(Side.Side2, s1: 0, s2: 2);
var verdict = AnomalyOutcomeEvaluator.Evaluate(anomaly, new SportCode(22723), result);
verdict.Outcome.Should().Be(AnomalyOutcomeKind.Hit);
verdict.PostFlipFavourite.Should().Be(Side.Side2);
}
[Fact]
public void Should_ReportUnresolved_When_TwoWayMarket_Has_DrawWinner()
{
// Tennis cannot draw — if the result is Draw the data is inconsistent
// with the evidence and we refuse to grade rather than silently miss-classify.
const string twoWayJson = """
{
"suspensionGapSeconds": 75,
"preSuspension": {
"capturedAt": "2026-05-10T18:00:00+03:00",
"p1": 0.70, "p2": 0.30, "rate1": 1.4, "rate2": 3.3
},
"postSuspension": {
"capturedAt": "2026-05-10T18:01:30+03:00",
"p1": 0.30, "p2": 0.70, "rate1": 3.3, "rate2": 1.4
}
}
""";
var anomaly = MakeAnomaly(twoWayJson, score: 0.55m);
var result = MakeResult(Side.Draw);
var verdict = AnomalyOutcomeEvaluator.Evaluate(anomaly, new SportCode(22723), result);
verdict.Outcome.Should().Be(AnomalyOutcomeKind.Unresolved);
verdict.ActualWinner.Should().Be(Side.Draw);
verdict.PostFlipFavourite.Should().Be(Side.Side2,
"favourite is still computed for display, just not graded");
}
[Fact]
public void Should_Throw_When_AnomalyIsNull()
{
var act = () => AnomalyOutcomeEvaluator.Evaluate(null!, null, null);
act.Should().Throw<ArgumentNullException>();
}
}
@@ -0,0 +1,130 @@
using FluentAssertions;
using Marathon.Domain.AnomalyDetection;
using Marathon.Domain.Entities;
using Marathon.Domain.Enums;
using Marathon.Domain.ValueObjects;
namespace Marathon.Domain.Tests.AnomalyDetection;
public sealed class SteamMoveDetectorTests
{
private static readonly TimeSpan MoscowOffset = TimeSpan.FromHours(3);
private static readonly DateTimeOffset BaseTime = new(2026, 5, 10, 18, 0, 0, MoscowOffset);
private static readonly EventId Event = new("26000001");
// window 120s, drift threshold 0.20, min 3 snapshots, continuity break at 60s.
private static SteamMoveDetector CreateSut() => new(120, 0.20m, 3, 60);
private static OddsSnapshot Live(int seconds, decimal r1, decimal r2) =>
new(Event, BaseTime.AddSeconds(seconds), OddsSource.Live,
new List<Bet>
{
new(MatchScope.Instance, BetType.Win, Side.Side1, null, new OddsRate(r1)),
new(MatchScope.Instance, BetType.Win, Side.Side2, null, new OddsRate(r2)),
});
[Fact]
public void Should_FlagSteamMove_When_OneSideShortensContinuously()
{
// Side2 shortens (3.0 → 1.6) over 90s in continuous 30s steps: its normalised
// implied probability rises ~0.33 → ~0.61, a ~0.28 drift > 0.20 threshold.
var snapshots = new[]
{
Live(0, 1.5m, 3.0m),
Live(30, 1.7m, 2.3m),
Live(60, 2.1m, 1.9m),
Live(90, 2.5m, 1.6m),
};
var result = CreateSut().Detect(Event, snapshots);
result.Should().NotBeEmpty();
result.Should().OnlyContain(a => a.Kind == AnomalyKind.SteamMove);
result.Max(a => a.Score).Should().BeGreaterThanOrEqualTo(0.20m);
}
[Fact]
public void Should_NotFlag_When_DriftBelowThreshold()
{
// Gentle drift: Side2 0.333 → ~0.38, well under the 0.20 threshold.
var snapshots = new[]
{
Live(0, 1.5m, 3.0m),
Live(30, 1.55m, 2.85m),
Live(60, 1.6m, 2.7m),
};
CreateSut().Detect(Event, snapshots).Should().BeEmpty();
}
[Fact]
public void Should_NotFlag_When_DriftSpansSuspensionGap()
{
// The big move happens across a 90s gap (> 60s continuity break) — that is the
// SuspensionFlip detector's territory, so steam must not double-flag it.
var snapshots = new[]
{
Live(0, 1.3m, 4.0m),
Live(30, 1.3m, 4.0m),
Live(120, 4.0m, 1.3m), // 90s gap
Live(150, 4.0m, 1.3m),
};
CreateSut().Detect(Event, snapshots).Should().BeEmpty();
}
[Fact]
public void Should_ReturnEmpty_When_FewerThanMinSnapshots()
{
var snapshots = new[] { Live(0, 1.5m, 3.0m), Live(30, 2.5m, 1.6m) };
CreateSut().Detect(Event, snapshots).Should().BeEmpty();
}
[Fact]
public void Should_IgnorePreMatchSnapshots()
{
var snapshots = new[]
{
new OddsSnapshot(Event, BaseTime, OddsSource.PreMatch,
new List<Bet> { new(MatchScope.Instance, BetType.Win, Side.Side1, null, new OddsRate(1.5m)),
new(MatchScope.Instance, BetType.Win, Side.Side2, null, new OddsRate(3.0m)) }),
new OddsSnapshot(Event, BaseTime.AddSeconds(30), OddsSource.PreMatch,
new List<Bet> { new(MatchScope.Instance, BetType.Win, Side.Side1, null, new OddsRate(2.5m)),
new(MatchScope.Instance, BetType.Win, Side.Side2, null, new OddsRate(1.6m)) }),
new OddsSnapshot(Event, BaseTime.AddSeconds(60), OddsSource.PreMatch,
new List<Bet> { new(MatchScope.Instance, BetType.Win, Side.Side1, null, new OddsRate(2.6m)),
new(MatchScope.Instance, BetType.Win, Side.Side2, null, new OddsRate(1.55m)) }),
};
CreateSut().Detect(Event, snapshots).Should().BeEmpty();
}
[Fact]
public void Should_EmitParseableEvidence_For_DetectedSteamMove()
{
var snapshots = new[]
{
Live(0, 1.5m, 3.0m),
Live(30, 1.9m, 2.0m),
Live(60, 2.5m, 1.6m),
};
var anomaly = CreateSut().Detect(Event, snapshots).First();
AnomalyEvidenceParser.TryParse(anomaly.EvidenceJson, out var data).Should().BeTrue();
data.PreSuspension.Should().NotBeNull();
data.PostSuspension.Should().NotBeNull();
// Post favourite is the steamed (shortened) side — drives the outcome evaluator.
data.PostSuspension.Favourite.Should().Be(Side.Side2);
}
[Theory]
[InlineData(0)]
[InlineData(-30)]
public void Should_Throw_When_ConstructedWithInvalidWindow(int windowSeconds)
{
var act = () => new SteamMoveDetector(windowSeconds, 0.20m, 3, 60);
act.Should().Throw<ArgumentOutOfRangeException>();
}
}
@@ -0,0 +1,386 @@
using FluentAssertions;
using Marathon.Domain.AnomalyDetection;
using Marathon.Domain.Backtesting;
using Marathon.Domain.Entities;
using Marathon.Domain.Enums;
using Marathon.Domain.ValueObjects;
namespace Marathon.Domain.Tests.Backtesting;
/// <summary>
/// Unit tests for <see cref="BacktestSimulator"/>. Math-heavy — every test
/// pins one branch of the loop and the resulting headline numbers.
/// </summary>
public sealed class BacktestSimulatorTests
{
private static readonly TimeSpan MoscowOffset = TimeSpan.FromHours(3);
private static readonly DateTimeOffset BaseTime =
new(2026, 5, 10, 18, 0, 0, MoscowOffset);
// ── Strategy helpers ─────────────────────────────────────────────────────
private static BacktestStrategy Flat(decimal bankroll = 1000m, decimal stake = 100m, decimal minScore = 0.30m) =>
new(StartingBankroll: bankroll, MinScore: minScore,
StakeRule: StakeRule.Flat,
FlatStake: stake, PercentOfBankroll: 0.02m, KellyFraction: 0.25m);
private static BacktestStrategy Percent(decimal pct = 0.10m, decimal bankroll = 1000m, decimal minScore = 0.30m) =>
new(StartingBankroll: bankroll, MinScore: minScore,
StakeRule: StakeRule.PercentOfBankroll,
FlatStake: 1m, PercentOfBankroll: pct, KellyFraction: 0.25m);
private static BacktestStrategy Kelly(decimal fraction = 1.0m, decimal bankroll = 1000m, decimal minScore = 0.30m) =>
new(StartingBankroll: bankroll, MinScore: minScore,
StakeRule: StakeRule.Kelly,
FlatStake: 1m, PercentOfBankroll: 0.02m, KellyFraction: fraction);
// ── Candidate helpers ────────────────────────────────────────────────────
private static BacktestCandidate MakeCandidate(
DateTimeOffset detectedAt,
decimal score,
Side postFav,
Side winnerSide,
decimal postRate1 = 2.0m,
decimal postRate2 = 2.0m,
bool twoWay = false,
int s1 = 1, int s2 = 0)
{
var ev = BuildEvidence(postFav, postRate1, postRate2, twoWay);
var anomaly = new Anomaly(
Id: Guid.NewGuid(),
EventId: new EventId(detectedAt.Ticks.ToString()),
DetectedAt: detectedAt,
Kind: AnomalyKind.SuspensionFlip,
Score: score,
EvidenceJson: "{\"x\":0}"); // unused — evidence is passed in directly
var result = new EventResult(
EventId: anomaly.EventId,
Side1Score: s1, Side2Score: s2,
WinnerSide: winnerSide,
CompletedAt: detectedAt.AddHours(2));
return new BacktestCandidate(anomaly, ev, result, Sport: null);
}
private static AnomalyEvidenceData BuildEvidence(
Side postFav, decimal postRate1, decimal postRate2, bool twoWay)
{
// Construct probabilities consistent with the rates so the simulator's
// Kelly path has a meaningful p to read.
decimal p1 = 1m / postRate1;
decimal p2 = 1m / postRate2;
decimal? pDraw = twoWay ? null : (decimal?)(1m - p1 - p2);
decimal? rateDraw = twoWay ? null : (decimal?)5.0m;
// Normalise to 1.0 (mirrors AnomalyDetector's normalisation).
decimal total = p1 + p2 + (pDraw ?? 0m);
p1 /= total;
p2 /= total;
if (pDraw is not null) pDraw = pDraw.Value / total;
// Override the post-favourite side to actually be the highest probability —
// tests want to verify behaviour for that specific side being the favourite.
// We set the chosen side's prob to 0.6, distribute the rest.
switch (postFav)
{
case Side.Side1: p1 = 0.60m; p2 = twoWay ? 0.40m : 0.30m; pDraw = twoWay ? null : 0.10m; break;
case Side.Side2: p2 = 0.60m; p1 = twoWay ? 0.40m : 0.30m; pDraw = twoWay ? null : 0.10m; break;
case Side.Draw: pDraw = 0.50m; p1 = 0.25m; p2 = 0.25m; break;
}
var preSide = new AnomalyEvidenceSide(
CapturedAt: BaseTime,
P1: p2, // pre = flipped (irrelevant to most tests)
PDraw: pDraw,
P2: p1,
Rate1: postRate2,
RateDraw: rateDraw,
Rate2: postRate1);
var postSide = new AnomalyEvidenceSide(
CapturedAt: BaseTime.AddMinutes(1),
P1: p1, PDraw: pDraw, P2: p2,
Rate1: postRate1, RateDraw: rateDraw, Rate2: postRate2);
return new AnomalyEvidenceData(60, preSide, postSide);
}
// ── Tests ────────────────────────────────────────────────────────────────
[Fact]
public void Should_ReturnEmptyShell_When_NoCandidates()
{
var result = BacktestSimulator.Run(Flat(), Array.Empty<BacktestCandidate>());
result.BetsPlaced.Should().Be(0);
result.FinalBankroll.Should().Be(1000m);
result.NetProfit.Should().Be(0m);
result.RoiPercent.Should().BeNull();
result.Trace.Should().BeEmpty();
}
[Fact]
public void Should_PlaceFlatBet_AndWin_PayoutEqualsStakeTimesRate()
{
// Stake 100 at rate 2.0 winning → +100 profit; bankroll 1000 → 1100.
var candidate = MakeCandidate(
detectedAt: BaseTime,
score: 0.50m,
postFav: Side.Side1,
winnerSide: Side.Side1,
postRate1: 2.0m);
var result = BacktestSimulator.Run(Flat(stake: 100m), new[] { candidate });
result.BetsPlaced.Should().Be(1);
result.Wins.Should().Be(1);
result.Losses.Should().Be(0);
result.FinalBankroll.Should().Be(1100m);
result.NetProfit.Should().Be(100m);
result.RoiPercent.Should().Be(100m, "+100 / 100 staked");
result.TotalStaked.Should().Be(100m);
result.TotalReturned.Should().Be(200m);
result.Trace.Single().IsWin.Should().BeTrue();
result.Trace.Single().Payout.Should().Be(200m);
result.Trace.Single().BankrollAfter.Should().Be(1100m);
}
[Fact]
public void Should_PlaceFlatBet_AndLose_PayoutZero()
{
var candidate = MakeCandidate(
detectedAt: BaseTime,
score: 0.50m,
postFav: Side.Side1,
winnerSide: Side.Side2,
postRate1: 2.0m);
var result = BacktestSimulator.Run(Flat(stake: 100m), new[] { candidate });
result.BetsPlaced.Should().Be(1);
result.Losses.Should().Be(1);
result.FinalBankroll.Should().Be(900m);
result.NetProfit.Should().Be(-100m);
result.Trace.Single().IsWin.Should().BeFalse();
result.Trace.Single().Payout.Should().Be(0m);
}
[Fact]
public void Should_SkipCandidate_When_ScoreBelowThreshold()
{
var candidate = MakeCandidate(
detectedAt: BaseTime,
score: 0.20m,
postFav: Side.Side1,
winnerSide: Side.Side1);
var result = BacktestSimulator.Run(Flat(minScore: 0.50m), new[] { candidate });
result.BetsPlaced.Should().Be(0);
result.Skipped.Should().Be(1);
result.SkippedByThreshold.Should().Be(1, "score 0.20 is below threshold 0.50");
result.SkippedByDataQuality.Should().Be(0);
result.SkippedByBankroll.Should().Be(0);
result.FinalBankroll.Should().Be(1000m);
}
[Fact]
public void Should_SkipTwoWayCandidate_When_WinnerIsDraw()
{
// Tennis cannot draw — refuse to grade.
var candidate = MakeCandidate(
detectedAt: BaseTime,
score: 0.50m,
postFav: Side.Side1,
winnerSide: Side.Draw,
twoWay: true);
var result = BacktestSimulator.Run(Flat(), new[] { candidate });
result.BetsPlaced.Should().Be(0);
result.Skipped.Should().Be(1);
result.SkippedByDataQuality.Should().Be(1, "two-way market with draw winner is structurally impossible");
result.SkippedByThreshold.Should().Be(0);
}
[Fact]
public void Should_ProcessCandidates_InChronologicalOrder()
{
// Provide out-of-order — simulator must sort by DetectedAt.
var c1 = MakeCandidate(BaseTime.AddHours(0), 0.50m, Side.Side1, Side.Side1, postRate1: 2.0m);
var c2 = MakeCandidate(BaseTime.AddHours(1), 0.50m, Side.Side1, Side.Side2, postRate1: 2.0m);
var c3 = MakeCandidate(BaseTime.AddHours(2), 0.50m, Side.Side1, Side.Side1, postRate1: 2.0m);
var result = BacktestSimulator.Run(Flat(stake: 100m), new[] { c3, c1, c2 });
result.Trace.Select(t => t.DetectedAt).Should().BeInAscendingOrder();
// Bankroll: 1000 → 1100 (win) → 1000 (loss) → 1100 (win)
result.Trace[0].BankrollAfter.Should().Be(1100m);
result.Trace[1].BankrollAfter.Should().Be(1000m);
result.Trace[2].BankrollAfter.Should().Be(1100m);
}
[Fact]
public void Should_TrackMaxDrawdown_Across_Losses()
{
// 5 candidates: W W L L L → bankroll 1000 → 1100 → 1200 → 1100 → 1000 → 900
// Peak = 1200, trough = 900, max drawdown = 300.
var cands = new[]
{
MakeCandidate(BaseTime.AddHours(0), 0.5m, Side.Side1, Side.Side1, postRate1: 2.0m),
MakeCandidate(BaseTime.AddHours(1), 0.5m, Side.Side1, Side.Side1, postRate1: 2.0m),
MakeCandidate(BaseTime.AddHours(2), 0.5m, Side.Side1, Side.Side2, postRate1: 2.0m),
MakeCandidate(BaseTime.AddHours(3), 0.5m, Side.Side1, Side.Side2, postRate1: 2.0m),
MakeCandidate(BaseTime.AddHours(4), 0.5m, Side.Side1, Side.Side2, postRate1: 2.0m),
};
var result = BacktestSimulator.Run(Flat(stake: 100m), cands);
result.MaxDrawdown.Should().Be(300m);
result.MaxDrawdownPercent.Should().Be(25m, "300 / 1200 = 25 %");
result.MaxLossStreak.Should().Be(3);
result.MaxWinStreak.Should().Be(2);
}
[Fact]
public void Should_CompoundBankroll_With_PercentOfBankrollRule()
{
// 10 % of bankroll. Bankroll 1000 → bet 100 at 2.0 win → 1100 → bet 110 at 2.0 win → 1210.
var c1 = MakeCandidate(BaseTime.AddHours(0), 0.5m, Side.Side1, Side.Side1, postRate1: 2.0m);
var c2 = MakeCandidate(BaseTime.AddHours(1), 0.5m, Side.Side1, Side.Side1, postRate1: 2.0m);
var result = BacktestSimulator.Run(Percent(pct: 0.10m), new[] { c1, c2 });
result.Trace[0].Stake.Should().Be(100m);
result.Trace[0].BankrollAfter.Should().Be(1100m);
result.Trace[1].Stake.Should().Be(110m);
result.Trace[1].BankrollAfter.Should().Be(1210m);
}
[Fact]
public void Kelly_Should_StakeZero_When_EdgeIsNegative()
{
// Post-favourite has 60% prob at rate 1.50 → b = 0.5, p = 0.6, q = 0.4.
// Full Kelly = (0.5*0.6 - 0.4) / 0.5 = (0.30 - 0.40) / 0.5 = -0.20 → no bet.
var c = MakeCandidate(BaseTime, 0.5m, Side.Side1, Side.Side1, postRate1: 1.50m);
var result = BacktestSimulator.Run(Kelly(fraction: 1.0m), new[] { c });
result.BetsPlaced.Should().Be(0);
result.Skipped.Should().Be(1);
result.FinalBankroll.Should().Be(1000m);
}
[Fact]
public void Kelly_Should_StakePositive_When_EdgeIsPositive()
{
// Post-favourite has 60% prob (set inside BuildEvidence) at rate 2.0 → b = 1, p = 0.6, q = 0.4.
// Full Kelly = (1*0.6 - 0.4) / 1 = 0.20. Stake = 0.20 * 1000 = 200.
var c = MakeCandidate(BaseTime, 0.5m, Side.Side1, Side.Side1, postRate1: 2.0m);
var result = BacktestSimulator.Run(Kelly(fraction: 1.0m), new[] { c });
result.BetsPlaced.Should().Be(1);
// BankrollAfter on a win at rate 2.0 with stake 200 = 1000 - 200 + 400 = 1200.
result.Trace.Single().Stake.Should().Be(200m);
result.Trace.Single().BankrollAfter.Should().Be(1200m);
}
[Fact]
public void QuarterKelly_Should_StakeAQuarterOfFullKelly()
{
// Same setup as Kelly_Should_StakePositive but fraction 0.25 → stake 50.
var c = MakeCandidate(BaseTime, 0.5m, Side.Side1, Side.Side1, postRate1: 2.0m);
var result = BacktestSimulator.Run(Kelly(fraction: 0.25m), new[] { c });
result.Trace.Single().Stake.Should().Be(50m);
result.Trace.Single().BankrollAfter.Should().Be(1050m, "1000 - 50 + 100");
}
[Fact]
public void Should_SkipBet_When_StakeExceedsBankroll()
{
// Starting bankroll 500, flat stake 500 each bet.
// c1 loses → bankroll 0. c2 + c3 then can't be sized (stake > bankroll).
var c1 = MakeCandidate(BaseTime.AddHours(0), 0.5m, Side.Side1, Side.Side2, postRate1: 2.0m);
var c2 = MakeCandidate(BaseTime.AddHours(1), 0.5m, Side.Side1, Side.Side2, postRate1: 2.0m);
var c3 = MakeCandidate(BaseTime.AddHours(2), 0.5m, Side.Side1, Side.Side1, postRate1: 2.0m);
var result = BacktestSimulator.Run(Flat(bankroll: 500m, stake: 500m), new[] { c1, c2, c3 });
result.BetsPlaced.Should().Be(1);
result.Skipped.Should().Be(2);
result.SkippedByBankroll.Should().Be(2, "bankroll empty / stake too large");
result.FinalBankroll.Should().Be(0m);
}
[Fact]
public void Should_PickDeepestDrawdown_AcrossMultipleWindows()
{
// Two drawdown windows: 1000→1100→1050 (dd=50), then 1050→1250→1100 (dd=150).
// Max drawdown should be the second window (150), not the first.
var cands = new[]
{
MakeCandidate(BaseTime.AddHours(0), 0.5m, Side.Side1, Side.Side1, postRate1: 2.0m), // win → 1100
MakeCandidate(BaseTime.AddHours(1), 0.5m, Side.Side1, Side.Side2, postRate1: 1.5m), // loss → 1050
MakeCandidate(BaseTime.AddHours(2), 0.5m, Side.Side1, Side.Side1, postRate1: 3.0m), // win → 1250
MakeCandidate(BaseTime.AddHours(3), 0.5m, Side.Side1, Side.Side2, postRate1: 1.5m), // loss → 1150
MakeCandidate(BaseTime.AddHours(4), 0.5m, Side.Side1, Side.Side2, postRate1: 1.5m), // loss → 1050
};
var result = BacktestSimulator.Run(Flat(stake: 100m), cands);
// Window 1: peak 1100 → trough 1050 = 50 drop.
// Window 2: peak 1250 → trough 1050 = 200 drop.
// (Bankroll path: 1000 → 1100 → 1050 → 1250 → 1150 → 1050)
result.MaxDrawdown.Should().Be(200m);
result.MaxDrawdownPercent.Should().Be(16.67m, "200 / 1200 ≈ 16.67 % (peak was 1200 not 1250)");
}
[Fact]
public void Should_HandleDrawFavourite_Win()
{
// 3-way market, post-flip favourite is Draw, event ends in Draw → win.
var c = MakeCandidate(
detectedAt: BaseTime,
score: 0.5m,
postFav: Side.Draw,
winnerSide: Side.Draw,
twoWay: false);
var result = BacktestSimulator.Run(Flat(stake: 100m), new[] { c });
result.BetsPlaced.Should().Be(1);
result.Wins.Should().Be(1);
result.Trace.Single().PostFlipFavourite.Should().Be(Side.Draw);
result.Trace.Single().IsWin.Should().BeTrue();
}
[Fact]
public void Should_PassEventTitles_Through_ToResult()
{
var c = MakeCandidate(BaseTime, 0.5m, Side.Side1, Side.Side1, postRate1: 2.0m);
var titles = new Dictionary<Marathon.Domain.ValueObjects.EventId, string>
{
[c.Anomaly.EventId] = "Arsenal vs Chelsea",
};
var result = BacktestSimulator.Run(Flat(stake: 100m), new[] { c }, titles);
result.EventTitles.Should().ContainKey(c.Anomaly.EventId);
result.EventTitles[c.Anomaly.EventId].Should().Be("Arsenal vs Chelsea");
}
[Fact]
public void Should_ReturnEmptyEventTitles_When_NoneProvided()
{
var c = MakeCandidate(BaseTime, 0.5m, Side.Side1, Side.Side1, postRate1: 2.0m);
var result = BacktestSimulator.Run(Flat(stake: 100m), new[] { c });
result.EventTitles.Should().NotBeNull().And.BeEmpty();
}
}
@@ -0,0 +1,127 @@
using FluentAssertions;
using Marathon.Domain.Betting;
using Marathon.Domain.Entities;
using Marathon.Domain.Enums;
using Marathon.Domain.ValueObjects;
namespace Marathon.Domain.Tests.Betting;
/// <summary>
/// Unit tests for <see cref="BetOutcomeResolver"/> across every bet type +
/// every important boundary (handicap push, total push, period-scope null).
/// </summary>
public sealed class BetOutcomeResolverTests
{
private static readonly EventId EventId = new("12345678");
// ── Win bets ─────────────────────────────────────────────────────────────
[Theory]
[InlineData(Side.Side1, Side.Side1, BetOutcome.Won)]
[InlineData(Side.Side1, Side.Side2, BetOutcome.Lost)]
[InlineData(Side.Side1, Side.Draw, BetOutcome.Lost)]
[InlineData(Side.Side2, Side.Side2, BetOutcome.Won)]
[InlineData(Side.Side2, Side.Side1, BetOutcome.Lost)]
public void Should_GradeWinBet(Side selectionSide, Side winner, BetOutcome expected)
{
var bet = MakeBet(BetType.Win, selectionSide);
var result = MakeResult(winner);
BetOutcomeResolver.Resolve(bet, result).Should().Be(expected);
}
// ── Draw bets ────────────────────────────────────────────────────────────
[Theory]
[InlineData(Side.Draw, BetOutcome.Won)]
[InlineData(Side.Side1, BetOutcome.Lost)]
[InlineData(Side.Side2, BetOutcome.Lost)]
public void Should_GradeDrawBet(Side winner, BetOutcome expected)
{
var bet = MakeBet(BetType.Draw, Side.Draw);
var result = MakeResult(winner, 1, 1);
BetOutcomeResolver.Resolve(bet, result).Should().Be(expected);
}
// ── Handicap (WinFora) ───────────────────────────────────────────────────
[Theory]
// Side1 with +1.5 handicap; final 0-2 → adjusted 1.5 vs 2 → loss
[InlineData(Side.Side1, 1.5, 0, 2, BetOutcome.Lost)]
// Side1 with +1.5; final 1-2 → adjusted 2.5 vs 2 → win
[InlineData(Side.Side1, 1.5, 1, 2, BetOutcome.Won)]
// Side1 with -1.5; final 3-1 → adjusted 1.5 vs 1 → win
[InlineData(Side.Side1, -1.5, 3, 1, BetOutcome.Won)]
// Whole-number handicap that ties: Side1 +1, final 1-2 → 2 vs 2 → push (Void)
[InlineData(Side.Side1, 1, 1, 2, BetOutcome.Void)]
// Side2 with -1; final 0-2 → adjusted Side2 1 vs Side1 0 → win
[InlineData(Side.Side2, -1, 0, 2, BetOutcome.Won)]
public void Should_GradeHandicapBet(
Side side, double handicap, int s1, int s2, BetOutcome expected)
{
var bet = new Bet(MatchScope.Instance, BetType.WinFora, side,
new OddsValue((decimal)handicap), new OddsRate(1.85m));
var result = new EventResult(EventId, s1, s2,
DeriveWinner(s1, s2), DateTimeOffset.UtcNow);
BetOutcomeResolver.Resolve(bet, result).Should().Be(expected);
}
// ── Totals ───────────────────────────────────────────────────────────────
[Theory]
// Over 2.5; total 3 → win
[InlineData(Side.More, 2.5, 1, 2, BetOutcome.Won)]
// Over 2.5; total 2 → loss
[InlineData(Side.More, 2.5, 0, 2, BetOutcome.Lost)]
// Over 3.0; total 3 → push (Void)
[InlineData(Side.More, 3.0, 1, 2, BetOutcome.Void)]
// Under 2.5; total 2 → win
[InlineData(Side.Less, 2.5, 1, 1, BetOutcome.Won)]
// Under 2.5; total 3 → loss
[InlineData(Side.Less, 2.5, 1, 2, BetOutcome.Lost)]
public void Should_GradeTotalBet(
Side side, double threshold, int s1, int s2, BetOutcome expected)
{
var bet = new Bet(MatchScope.Instance, BetType.Total, side,
new OddsValue((decimal)threshold), new OddsRate(1.85m));
var result = new EventResult(EventId, s1, s2,
DeriveWinner(s1, s2), DateTimeOffset.UtcNow);
BetOutcomeResolver.Resolve(bet, result).Should().Be(expected);
}
// ── Period-scope guard ───────────────────────────────────────────────────
[Fact]
public void Should_ReturnNull_When_BetScopeIsPeriod()
{
var bet = new Bet(new PeriodScope(1), BetType.Win, Side.Side1,
value: null, new OddsRate(2.10m));
var result = MakeResult(Side.Side1);
BetOutcomeResolver.Resolve(bet, result).Should().BeNull(
"period scope cannot be graded from full-time score alone");
}
[Fact]
public void Should_Throw_When_BetOrResultIsNull()
{
((Action)(() => BetOutcomeResolver.Resolve(null!, MakeResult(Side.Side1))))
.Should().Throw<ArgumentNullException>();
((Action)(() => BetOutcomeResolver.Resolve(MakeBet(BetType.Win, Side.Side1), null!)))
.Should().Throw<ArgumentNullException>();
}
// ── Helpers ──────────────────────────────────────────────────────────────
private static Bet MakeBet(BetType type, Side side) =>
new(MatchScope.Instance, type, side, value: null, new OddsRate(2.00m));
private static EventResult MakeResult(Side winner, int s1 = 1, int s2 = 0) =>
new(EventId, s1, s2, winner, DateTimeOffset.UtcNow);
private static Side DeriveWinner(int s1, int s2) =>
s1 == s2 ? Side.Draw : (s1 > s2 ? Side.Side1 : Side.Side2);
}
@@ -0,0 +1,133 @@
using FluentAssertions;
using Marathon.Domain.Betting;
namespace Marathon.Domain.Tests.Betting;
public sealed class KellyCalculatorTests
{
// ── SuggestStake: edge handling ───────────────────────────────────────────
[Fact]
public void Should_ReturnZeroStake_When_NoPositiveEdge()
{
// p·o = 0.50 × 1.90 = 0.95 < 1 → negative edge.
var stake = KellyCalculator.SuggestStake(
winProbability: 0.50m, decimalOdds: 1.90m, bankroll: 1000m);
stake.Should().Be(0m);
}
[Fact]
public void Should_ReturnZeroStake_When_ExactlyBreakeven()
{
// p·o = 0.50 × 2.00 = 1 → zero edge.
var stake = KellyCalculator.SuggestStake(
winProbability: 0.50m, decimalOdds: 2.00m, bankroll: 1000m);
stake.Should().Be(0m);
}
[Fact]
public void Should_SizeQuarterKellyStake_When_PositiveEdge()
{
// full = (0.55×2.10 1)/(2.10 1) = 0.155/1.10 = 0.140909…
// quarter = 0.0352272… × 1000 = 35.2272… → truncated to 35.22.
var stake = KellyCalculator.SuggestStake(
winProbability: 0.55m, decimalOdds: 2.10m, bankroll: 1000m, fraction: 0.25m);
stake.Should().Be(35.22m);
}
[Fact]
public void Should_UseFullKelly_When_FractionIsOne()
{
// full = (0.60×2.00 1)/(2.00 1) = 0.20 → 0.20 × 1000 = 200.
var stake = KellyCalculator.SuggestStake(
winProbability: 0.60m, decimalOdds: 2.00m, bankroll: 1000m, fraction: 1.0m);
stake.Should().Be(200m);
}
[Fact]
public void Should_ScaleStake_Proportionally_WithBankroll()
{
var small = KellyCalculator.SuggestStake(0.60m, 2.00m, bankroll: 500m, fraction: 1.0m);
var large = KellyCalculator.SuggestStake(0.60m, 2.00m, bankroll: 1000m, fraction: 1.0m);
small.Should().Be(100m);
large.Should().Be(200m);
}
[Fact]
public void Should_NeverExceedComputedFigure_When_Truncating()
{
// Raw quarter-Kelly stake is 35.2272…; the suggestion must floor, not round up.
var stake = KellyCalculator.SuggestStake(0.55m, 2.10m, bankroll: 1000m, fraction: 0.25m);
stake.Should().BeLessThanOrEqualTo(0.25m * KellyCalculator.FullKellyFraction(0.55m, 2.10m) * 1000m);
}
// ── FullKellyFraction ─────────────────────────────────────────────────────
[Fact]
public void FullKellyFraction_Should_ComputeEdgeFraction()
{
KellyCalculator.FullKellyFraction(0.55m, 2.10m)
.Should().BeApproximately(0.140909m, 0.000001m);
}
[Fact]
public void FullKellyFraction_Should_BeNegative_When_NoEdge()
{
KellyCalculator.FullKellyFraction(0.40m, 2.00m).Should().Be(-0.20m);
}
[Fact]
public void FullKellyFraction_Should_BeZero_When_Breakeven()
{
KellyCalculator.FullKellyFraction(0.50m, 2.00m).Should().Be(0m);
}
// ── Guard clauses ─────────────────────────────────────────────────────────
[Theory]
[InlineData(0.0)]
[InlineData(1.0)]
[InlineData(-0.1)]
[InlineData(1.1)]
public void SuggestStake_Should_Throw_When_ProbabilityOutOfOpenInterval(double probability)
{
var act = () => KellyCalculator.SuggestStake((decimal)probability, 2.0m, 1000m);
act.Should().Throw<ArgumentOutOfRangeException>();
}
[Theory]
[InlineData(1.0)]
[InlineData(0.5)]
public void SuggestStake_Should_Throw_When_OddsNotGreaterThanOne(double odds)
{
var act = () => KellyCalculator.SuggestStake(0.55m, (decimal)odds, 1000m);
act.Should().Throw<ArgumentOutOfRangeException>();
}
[Fact]
public void SuggestStake_Should_Throw_When_BankrollNegative()
{
var act = () => KellyCalculator.SuggestStake(0.55m, 2.10m, bankroll: -1m);
act.Should().Throw<ArgumentOutOfRangeException>();
}
[Theory]
[InlineData(0.0)]
[InlineData(-0.25)]
[InlineData(1.5)]
public void SuggestStake_Should_Throw_When_FractionOutOfRange(double fraction)
{
var act = () => KellyCalculator.SuggestStake(0.55m, 2.10m, 1000m, (decimal)fraction);
act.Should().Throw<ArgumentOutOfRangeException>();
}
}
@@ -0,0 +1,120 @@
using FluentAssertions;
using Marathon.Domain.Entities;
using Marathon.Domain.Enums;
using Marathon.Domain.ValueObjects;
namespace Marathon.Domain.Tests.Entities;
/// <summary>
/// Invariants and derived-property tests for <see cref="PlacedBet"/>.
/// </summary>
public sealed class PlacedBetTests
{
private static readonly TimeSpan MoscowOffset = TimeSpan.FromHours(3);
private static readonly DateTimeOffset MoscowMoment =
new(2026, 5, 16, 12, 0, 0, MoscowOffset);
private static PlacedBet Make(BetOutcome outcome, decimal rate = 2.10m, decimal stake = 100m) =>
new(
Id: Guid.NewGuid(),
EventId: new EventId("12345678"),
Selection: new Bet(MatchScope.Instance, BetType.Win, Side.Side1,
value: null, new OddsRate(rate)),
Stake: stake,
PlacedAt: MoscowMoment,
Outcome: outcome,
Notes: null);
[Fact]
public void Should_ComputeWonReturn_As_StakeTimesRate()
{
var bet = Make(BetOutcome.Won, rate: 2.10m, stake: 100m);
bet.GrossReturn.Should().Be(210m);
bet.NetProfit.Should().Be(110m);
}
[Fact]
public void Should_ComputeLossReturn_As_Zero()
{
var bet = Make(BetOutcome.Lost, stake: 50m);
bet.GrossReturn.Should().Be(0m);
bet.NetProfit.Should().Be(-50m);
}
[Fact]
public void Should_ReturnStake_When_Outcome_IsVoid()
{
var bet = Make(BetOutcome.Void, stake: 75m);
bet.GrossReturn.Should().Be(75m);
bet.NetProfit.Should().Be(0m);
}
[Fact]
public void Should_ReturnNullProfit_When_OutcomeIsPending()
{
var bet = Make(BetOutcome.Pending);
bet.GrossReturn.Should().BeNull();
bet.NetProfit.Should().BeNull();
}
[Fact]
public void WithOutcome_Should_ReturnNewInstance_With_GradedOutcome()
{
var pending = Make(BetOutcome.Pending);
var graded = pending.WithOutcome(BetOutcome.Won);
graded.Should().NotBeSameAs(pending);
graded.Outcome.Should().Be(BetOutcome.Won);
graded.Id.Should().Be(pending.Id);
graded.Stake.Should().Be(pending.Stake);
}
[Fact]
public void Should_Throw_When_StakeIsZeroOrNegative()
{
var act = () => new PlacedBet(
Id: Guid.NewGuid(),
EventId: new EventId("11111111"),
Selection: new Bet(MatchScope.Instance, BetType.Win, Side.Side1, null, new OddsRate(2m)),
Stake: 0m,
PlacedAt: MoscowMoment,
Outcome: BetOutcome.Pending,
Notes: null);
act.Should().Throw<ArgumentOutOfRangeException>().WithMessage("*Stake*");
}
[Fact]
public void Should_Throw_When_PlacedAt_IsNotMoscowOffset()
{
var act = () => new PlacedBet(
Id: Guid.NewGuid(),
EventId: new EventId("11111111"),
Selection: new Bet(MatchScope.Instance, BetType.Win, Side.Side1, null, new OddsRate(2m)),
Stake: 100m,
PlacedAt: DateTimeOffset.UtcNow, // UTC offset, not Moscow
Outcome: BetOutcome.Pending,
Notes: null);
act.Should().Throw<ArgumentException>().WithMessage("*Moscow*");
}
[Fact]
public void Should_NormaliseWhitespace_Notes_To_Null()
{
var bet = new PlacedBet(
Id: Guid.NewGuid(),
EventId: new EventId("11111111"),
Selection: new Bet(MatchScope.Instance, BetType.Win, Side.Side1, null, new OddsRate(2m)),
Stake: 100m,
PlacedAt: MoscowMoment,
Outcome: BetOutcome.Pending,
Notes: " ");
bet.Notes.Should().BeNull();
}
}

Some files were not shown because too many files have changed in this diff Show More