f622dadf95
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.
95 lines
3.5 KiB
C#
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.
|
|
}
|
|
}
|
|
}
|