Files
maraphon-app/src/Marathon.Infrastructure/Workers/PaperTradingWorker.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

95 lines
3.5 KiB
C#

using Marathon.Application.UseCases;
using Marathon.Domain.ValueObjects;
using Marathon.Infrastructure.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace Marathon.Infrastructure.Workers;
/// <summary>
/// Forward-test engine: each cycle opens flat-stake paper bets for newly detected
/// directional anomalies, then settles any open bets whose events have been graded.
/// Idle (cheap re-check) while <see cref="PaperTradingOptions.Enabled"/> is false.
/// </summary>
/// <remarks>
/// The "since" marker is baselined to startup so pre-existing anomalies are not
/// retro-traded, and advances to each cycle's upper bound only after the open pass
/// succeeds. A unique index on <c>PaperBets.AnomalyId</c> backstops any double-open.
/// Scoped use cases are resolved per cycle (EF Core DbContext lifetime).
/// </remarks>
internal sealed class PaperTradingWorker : BackgroundService
{
private readonly IServiceProvider _services;
private readonly IOptionsMonitor<PaperTradingOptions> _opts;
private readonly ILogger<PaperTradingWorker> _logger;
private DateTimeOffset _since;
public PaperTradingWorker(
IServiceProvider services,
IOptionsMonitor<PaperTradingOptions> opts,
ILogger<PaperTradingWorker> logger)
{
_services = services ?? throw new ArgumentNullException(nameof(services));
_opts = opts ?? throw new ArgumentNullException(nameof(opts));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
// Baseline: only forward-test anomalies detected after this worker started.
_since = MoscowTime.Now;
_logger.LogInformation("PaperTradingWorker: started");
while (!stoppingToken.IsCancellationRequested)
{
var opts = _opts.CurrentValue;
if (!opts.Enabled)
{
await DelayQuietly(TimeSpan.FromSeconds(10), stoppingToken);
continue;
}
try
{
var until = MoscowTime.Now;
await using var scope = _services.CreateAsyncScope();
var open = scope.ServiceProvider.GetRequiredService<OpenPaperBetsUseCase>();
await open.ExecuteAsync(_since, until, opts.MinScore, opts.FlatStake, stoppingToken);
// Advance only after a successful open pass, so a failure replays the window.
_since = until;
var settle = scope.ServiceProvider.GetRequiredService<SettlePaperBetsUseCase>();
await settle.ExecuteAsync(stoppingToken);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
catch (Exception ex)
{
_logger.LogError(ex, "PaperTradingWorker: cycle failed — will retry after interval");
}
await DelayQuietly(TimeSpan.FromSeconds(Math.Max(5, opts.PollIntervalSeconds)), stoppingToken);
}
_logger.LogInformation("PaperTradingWorker: stopping");
}
private static async Task DelayQuietly(TimeSpan delay, CancellationToken ct)
{
try
{
await Task.Delay(delay, ct);
}
catch (OperationCanceledException)
{
// Shutting down — swallow so ExecuteAsync's loop check exits cleanly.
}
}
}