Files
maraphon-app/src/Marathon.Infrastructure/Persistence/PersistenceModule.cs
T
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

62 lines
2.5 KiB
C#

using Marathon.Application.Abstractions;
using Marathon.Application.Storage;
using Marathon.Infrastructure.Export;
using Marathon.Infrastructure.Persistence.Repositories;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
namespace Marathon.Infrastructure.Persistence;
/// <summary>
/// DI extension that wires up the persistence layer (DbContext, repositories, exporter).
/// Call this from the host's DI setup — do NOT call from DependencyInjection.cs (Phase 4).
/// </summary>
public static class PersistenceModule
{
/// <summary>
/// Registers EF Core DbContext, all repositories and the Excel exporter.
/// Reads <c>Storage:DatabasePath</c> from <paramref name="config"/>.
/// </summary>
public static IServiceCollection AddMarathonPersistence(
this IServiceCollection services,
IConfiguration config)
{
services.AddOptions<StorageOptions>()
.Bind(config.GetSection(StorageOptions.SectionName))
.ValidateOnStart();
services.AddDbContext<MarathonDbContext>((sp, opts) =>
{
var storageOptions = sp.GetRequiredService<IOptions<StorageOptions>>().Value;
var dbPath = storageOptions.DatabasePath;
// Ensure the directory exists
var dir = Path.GetDirectoryName(dbPath);
if (!string.IsNullOrEmpty(dir))
Directory.CreateDirectory(dir);
// Configure SQLite with WAL journal mode
opts.UseSqlite(
$"Data Source={dbPath}",
sqliteOpts => sqliteOpts.CommandTimeout(30));
});
// Register initializer — the HOST must resolve this at startup and call InitializeAsync().
// Example in Program.cs:
// using var scope = app.Services.CreateScope();
// await scope.ServiceProvider.GetRequiredService<MarathonDbContextInitializer>().InitializeAsync();
services.AddScoped<MarathonDbContextInitializer>();
services.AddScoped<IEventRepository, EventRepository>();
services.AddScoped<ISnapshotRepository, SnapshotRepository>();
services.AddScoped<IResultRepository, ResultRepository>();
services.AddScoped<IAnomalyRepository, AnomalyRepository>();
services.AddScoped<IPlacedBetRepository, PlacedBetRepository>();
services.AddScoped<IExcelExporter, ExcelExporter>();
return services;
}
}