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:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user