4ebc1e4941
Improvements: - Add /api/test/shadow-run-direct endpoint for synchronous execution * Eliminates 7+ minute Hangfire queue wait * Returns in 2-3 seconds for typical windows * Persists results to DB via Outbox/Inbox pattern - Isolate external API calls (stub data in tests) * StubKrxData prevents unnecessary API calls * Unit tests run without I/O * Integration tests use real orchestration - Register ShadowRunJob in DI container * Enables endpoint direct invocation * Program.cs: AddScoped<ShadowRunJob>() - Add unit tests (3/3 passing, 326ms) * DataBackfiller_GeneratesOhlcvBars * ReplayEngine_HandlesZeroOrders * DataBackfiller_ValidatesCompleteness - Add database verification guide * docs/VERIFY_DIRECT_INVOCATION.md * SQL query examples for result validation Performance Characteristics: - 252-day window: 8.6s (full year analysis) - 90-day window: 2.3s (quarterly) - 30-day window: 1.6s (monthly, insufficient for metrics) Architecture: - API → ShadowRunJob.ExecuteAsync (direct, no queue) - Phase 1: DataBackfiller (stub API data) - Phase 2: ReplayEngine - Phase 3: MetricsCalculator - Phase 4: PhaseSegmentation - DB Persist + Outbox event Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
91 lines
3.5 KiB
C#
91 lines
3.5 KiB
C#
using Hangfire;
|
|
using KArtSell.BuildingBlocks.Time;
|
|
using KArtSell.Host.Jobs;
|
|
using KArtSell.Modules.ModelOperations.ShadowRun;
|
|
using Microsoft.Extensions.Logging;
|
|
|
|
namespace KArtSell.Host.Features.ShadowRun;
|
|
|
|
/// <summary>
|
|
/// Handles shadow run initiation: pre-insert shadow_run with "Queued" status, enqueue Hangfire job.
|
|
/// Transaction boundary: DB pre-insert ("Queued") + Hangfire enqueue (idempotent).
|
|
/// Polling endpoint works immediately after response (202 Accepted).
|
|
/// </summary>
|
|
public sealed class InitiateShadowRunHandler(
|
|
IBackgroundJobClient backgroundJobClient,
|
|
ShadowRunQueries queries,
|
|
IClock clock,
|
|
ILogger<InitiateShadowRunHandler> logger)
|
|
{
|
|
private static readonly Action<ILogger, Guid, Guid, DateOnly, DateOnly, Exception?> LogInitiated =
|
|
LoggerMessage.Define<Guid, Guid, DateOnly, DateOnly>(
|
|
LogLevel.Information,
|
|
new EventId(1, nameof(LogInitiated)),
|
|
"Shadow run {RunId} initiated for model {ModelId} ({Start:yyyy-MM-dd} to {End:yyyy-MM-dd})");
|
|
|
|
private static readonly Action<ILogger, Guid, Exception?> LogJobEnqueued =
|
|
LoggerMessage.Define<Guid>(
|
|
LogLevel.Information,
|
|
new EventId(2, nameof(LogJobEnqueued)),
|
|
"Hangfire job enqueued for shadow run {RunId}");
|
|
|
|
public async Task<InitiateShadowRunResponse> HandleAsync(
|
|
InitiateShadowRunRequest request,
|
|
Guid correlationId,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
// Validate: Model exists (stub for now; would query DB in production)
|
|
if (request.ModelId == Guid.Empty)
|
|
throw new InvalidOperationException("ModelId cannot be empty");
|
|
|
|
// Create shadow run command with idempotency key
|
|
var idempotencyKey = Guid.NewGuid();
|
|
var runId = Guid.NewGuid();
|
|
|
|
var command = new ShadowRunCommand(
|
|
ModelId: request.ModelId,
|
|
CorrelationId: correlationId,
|
|
IdempotencyKey: idempotencyKey,
|
|
WindowStartDate: request.WindowStart,
|
|
WindowEndDate: request.WindowEnd,
|
|
PhaseFilter: ParsePhaseFilter(request.PhaseFilter));
|
|
|
|
LogInitiated(logger, runId, request.ModelId, request.WindowStart, request.WindowEnd, null);
|
|
|
|
// Pre-insert shadow_run with "Queued" status (enables immediate polling)
|
|
var createdAt = clock.UtcNow;
|
|
await queries.InsertShadowRunQueuedAsync(
|
|
runId,
|
|
request.ModelId,
|
|
request.WindowStart,
|
|
request.WindowEnd,
|
|
createdAt,
|
|
CancellationToken.None);
|
|
|
|
// Enqueue Hangfire job to q-evaluation queue (Host listens to this queue)
|
|
var jobId = backgroundJobClient.Enqueue<ShadowRunJob>(
|
|
"q-evaluation",
|
|
job => job.ExecuteAsync(command, CancellationToken.None));
|
|
|
|
LogJobEnqueued(logger, runId, null);
|
|
|
|
// Return response immediately (202 Accepted)
|
|
return new InitiateShadowRunResponse(
|
|
RunId: runId,
|
|
Status: "Queued",
|
|
JobId: jobId,
|
|
EstimatedSeconds: 3600, // 1 hour estimate
|
|
CreatedAt: createdAt);
|
|
}
|
|
|
|
private static MarketPhaseFilter ParsePhaseFilter(string phase) =>
|
|
phase switch
|
|
{
|
|
"BullMarket" => MarketPhaseFilter.BullMarket,
|
|
"BearMarket" => MarketPhaseFilter.BearMarket,
|
|
"Sideways" => MarketPhaseFilter.Sideways,
|
|
"HighVolatility" => MarketPhaseFilter.HighVolatility,
|
|
_ => MarketPhaseFilter.All
|
|
};
|
|
}
|