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.
This commit is contained in:
2026-05-29 02:25:54 +03:00
parent 2a0ea7b3a6
commit f622dadf95
23 changed files with 1525 additions and 0 deletions
@@ -0,0 +1,84 @@
using Marathon.Application.Abstractions;
using Marathon.Domain.AnomalyDetection;
using Marathon.Domain.Entities;
using Marathon.Domain.Enums;
using Microsoft.Extensions.Logging;
namespace Marathon.Application.UseCases;
/// <summary>
/// Opens flat-stake paper bets for directional anomalies detected in
/// (<c>since</c>..<c>until</c>] whose score clears the threshold and that don't
/// already have one. The picked side is the post-flip favourite; the rate is that
/// side's post-suspension rate — locking in the price the moment the signal fired.
/// </summary>
public sealed class OpenPaperBetsUseCase
{
private readonly IAnomalyRepository _anomalies;
private readonly IPaperBetRepository _paperBets;
private readonly ILogger<OpenPaperBetsUseCase> _logger;
public OpenPaperBetsUseCase(
IAnomalyRepository anomalies,
IPaperBetRepository paperBets,
ILogger<OpenPaperBetsUseCase> logger)
{
_anomalies = anomalies ?? throw new ArgumentNullException(nameof(anomalies));
_paperBets = paperBets ?? throw new ArgumentNullException(nameof(paperBets));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <summary>Returns the paper bets opened this pass (empty when nothing qualified).</summary>
public async Task<IReadOnlyList<PaperBet>> ExecuteAsync(
DateTimeOffset since,
DateTimeOffset until,
decimal minScore,
decimal flatStake,
CancellationToken ct = default)
{
if (flatStake <= 0m)
throw new ArgumentOutOfRangeException(nameof(flatStake), flatStake, "Flat stake must be positive.");
var anomalies = await _anomalies.ListByDateRangeAsync(since, until, ct).ConfigureAwait(false);
// Only directional kinds make a side prediction worth forward-testing; the rest
// are informational and would just measure the base favourite-win rate.
var candidates = anomalies
.Where(a => a.Kind.IsDirectional() && a.Score >= minScore)
.ToList();
if (candidates.Count == 0)
return Array.Empty<PaperBet>();
var existing = await _paperBets
.GetExistingAnomalyIdsAsync(candidates.Select(a => a.Id).ToList(), ct)
.ConfigureAwait(false);
var opened = new List<PaperBet>();
foreach (var anomaly in candidates)
{
ct.ThrowIfCancellationRequested();
if (existing.Contains(anomaly.Id))
continue;
if (!AnomalyEvidenceParser.TryParse(anomaly.EvidenceJson, out var evidence))
continue;
var pick = evidence.PostSuspension.Favourite;
if (evidence.PostSuspension.RateFor(pick) is not { } rate || rate <= 1m)
continue;
opened.Add(PaperBet.Open(anomaly.Id, anomaly.EventId, pick, rate, flatStake, anomaly.DetectedAt));
}
if (opened.Count == 0)
return Array.Empty<PaperBet>();
foreach (var bet in opened)
await _paperBets.AddAsync(bet, ct).ConfigureAwait(false);
await _paperBets.SaveChangesAsync(ct).ConfigureAwait(false);
_logger.LogInformation("OpenPaperBetsUseCase: opened {Count} paper bet(s)", opened.Count);
return opened;
}
}
@@ -0,0 +1,61 @@
using Marathon.Application.Abstractions;
using Marathon.Domain.Enums;
using Marathon.Domain.ValueObjects;
using Microsoft.Extensions.Logging;
namespace Marathon.Application.UseCases;
/// <summary>
/// Settles every open (<see cref="BetOutcome.Pending"/>) paper bet whose event now has
/// a final result — Won when the picked side matches the winner, otherwise Lost. Bets
/// on events that aren't graded yet stay open and are retried next cycle.
/// </summary>
public sealed class SettlePaperBetsUseCase
{
private readonly IPaperBetRepository _paperBets;
private readonly IResultRepository _results;
private readonly ILogger<SettlePaperBetsUseCase> _logger;
public SettlePaperBetsUseCase(
IPaperBetRepository paperBets,
IResultRepository results,
ILogger<SettlePaperBetsUseCase> logger)
{
_paperBets = paperBets ?? throw new ArgumentNullException(nameof(paperBets));
_results = results ?? throw new ArgumentNullException(nameof(results));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <summary>Returns the number of paper bets settled this pass.</summary>
public async Task<int> ExecuteAsync(CancellationToken ct = default)
{
var open = await _paperBets.ListByOutcomeAsync(BetOutcome.Pending, ct).ConfigureAwait(false);
if (open.Count == 0)
return 0;
// Batched result lookup — one query, not one per open bet.
var eventIds = open.Select(b => b.EventId).Distinct().ToList();
var results = await _results.GetManyAsync(eventIds, ct).ConfigureAwait(false);
var settledAt = MoscowTime.Now;
var settled = 0;
foreach (var bet in open)
{
ct.ThrowIfCancellationRequested();
if (!results.TryGetValue(bet.EventId, out var result))
continue; // event not graded yet
await _paperBets.UpdateAsync(bet.SettleAgainst(result.WinnerSide, settledAt), ct).ConfigureAwait(false);
settled++;
}
if (settled > 0)
{
await _paperBets.SaveChangesAsync(ct).ConfigureAwait(false);
_logger.LogInformation("SettlePaperBetsUseCase: settled {Count} paper bet(s)", settled);
}
return settled;
}
}