39aef449f7
Adds the read-only paper-trading page (/paper-trading): settled-only P&L KPIs (net profit, ROI, hit rate, open count) plus a per-bet ledger table, with a Forward-test nav entry under Analysis. PaperTradingService batches the event-title join (no N+1) and folds settled bets into the summary. Also hardens PaperTradingWorker (review finding): settle now runs in its own catch so a transient settle failure can't advance the since-marker past an open window — the window replays until its opens succeed. - IPaperTradingService / PaperTradingService / PaperTradingVm + PaperBetRowVm. - en/ru resx (full parity), service registration, nav entry. - 2 service tests: empty ledger + settled-only aggregation incl. title fallback.
106 lines
4.2 KiB
C#
106 lines
4.2 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 an open failure replays the window.
|
|
_since = until;
|
|
|
|
// Settle in its own catch: it rescans every Pending bet each cycle (idempotent),
|
|
// so a transient settle failure must NOT strand the marker — otherwise the window
|
|
// just opened above would be lost to a settle-only error. Shutdown cancellation is
|
|
// excluded so it propagates to the outer break.
|
|
try
|
|
{
|
|
var settle = scope.ServiceProvider.GetRequiredService<SettlePaperBetsUseCase>();
|
|
await settle.ExecuteAsync(stoppingToken);
|
|
}
|
|
catch (Exception ex) when (!stoppingToken.IsCancellationRequested)
|
|
{
|
|
_logger.LogError(ex, "PaperTradingWorker: settle failed — open bets retried next cycle");
|
|
}
|
|
}
|
|
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
|
{
|
|
break;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "PaperTradingWorker: open 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.
|
|
}
|
|
}
|
|
}
|