Files
maraphon-app/src/Marathon.Infrastructure/Persistence/Repositories/PaperBetRepository.cs
T
alexei.dolgolyov f622dadf95 feat(paper-trading): forward-test ledger engine
Adds a background forward-test engine that records flat-stake "paper" bets
for directional anomalies as they fire and settles them when results arrive,
measuring the detector's live, out-of-sample edge — the antidote to backtest
overfitting. The results UI is a follow-up.

- Domain: PaperBet entity (Rate>1 / Stake>0 invariants, Open factory,
  SettleAgainst — Won pays stake x rate, else Lost) + AnomalyEvidenceSide.RateFor.
- Application: OpenPaperBetsUseCase (directional + score gate, dedups by
  AnomalyId, picks the post-flip favourite and its locked-in rate) and
  SettlePaperBetsUseCase (Won when pick == winner else Lost; ungraded events
  stay open; batched result lookup).
- Infrastructure: PaperBetEntity + config (TEXT decimals, unique AnomalyId index,
  Outcome index), repository, mapping, additive AddPaperBets migration, and
  PaperTradingWorker (config-gated, baseline since-marker, open+settle per cycle).
- Config: PaperTradingOptions / appsettings PaperTrading (Enabled:false default).
- 25 tests: domain settlement, both use cases, and a real-SQLite round-trip
  incl. the unique-AnomalyId double-open backstop.
2026-05-29 02:25:54 +03:00

78 lines
2.7 KiB
C#

using Marathon.Application.Abstractions;
using Marathon.Domain.Entities;
using Marathon.Domain.Enums;
using Microsoft.EntityFrameworkCore;
namespace Marathon.Infrastructure.Persistence.Repositories;
internal sealed class PaperBetRepository : IPaperBetRepository
{
private readonly MarathonDbContext _db;
public PaperBetRepository(MarathonDbContext db) => _db = db;
public async Task<PaperBet?> GetAsync(Guid key, CancellationToken ct = default)
{
var idStr = key.ToString();
var entity = await _db.PaperBets.AsNoTracking()
.FirstOrDefaultAsync(b => b.Id == idStr, ct);
return entity is null ? null : Mapping.ToDomain(entity);
}
public async Task<IReadOnlyList<PaperBet>> ListAsync(CancellationToken ct = default)
{
var entities = await _db.PaperBets.AsNoTracking()
.OrderByDescending(b => b.OpenedAt)
.ToListAsync(ct);
return entities.Select(Mapping.ToDomain).ToList().AsReadOnly();
}
public async Task<IReadOnlyList<PaperBet>> ListByOutcomeAsync(BetOutcome outcome, CancellationToken ct = default)
{
var outcomeInt = (int)outcome;
var entities = await _db.PaperBets.AsNoTracking()
.Where(b => b.Outcome == outcomeInt)
.ToListAsync(ct);
return entities.Select(Mapping.ToDomain).ToList().AsReadOnly();
}
public async Task<IReadOnlySet<Guid>> GetExistingAnomalyIdsAsync(
IReadOnlyCollection<Guid> anomalyIds, CancellationToken ct = default)
{
if (anomalyIds.Count == 0)
return new HashSet<Guid>();
var idStrings = anomalyIds.Select(id => id.ToString()).ToList();
var existing = await _db.PaperBets.AsNoTracking()
.Where(b => idStrings.Contains(b.AnomalyId))
.Select(b => b.AnomalyId)
.ToListAsync(ct);
return existing.Select(Guid.Parse).ToHashSet();
}
public async Task AddAsync(PaperBet entity, CancellationToken ct = default)
{
var efEntity = Mapping.ToEntity(entity);
await _db.PaperBets.AddAsync(efEntity, ct);
}
public Task UpdateAsync(PaperBet entity, CancellationToken ct = default)
{
var efEntity = Mapping.ToEntity(entity);
_db.PaperBets.Update(efEntity);
return Task.CompletedTask;
}
public async Task DeleteAsync(Guid key, CancellationToken ct = default)
{
var idStr = key.ToString();
var entity = await _db.PaperBets.FirstOrDefaultAsync(b => b.Id == idStr, ct);
if (entity is not null)
_db.PaperBets.Remove(entity);
}
public async Task SaveChangesAsync(CancellationToken ct = default) =>
await _db.SaveChangesAsync(ct);
}