Files
KArtSell.Aegis/src/KArtSell.Host/Features/ShadowRun/Handler.cs
T
kjh2064 4ebc1e4941 feat: implement direct Shadow Run invocation endpoint (bypass Hangfire queue)
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>
2026-08-12 15:23:23 +09:00

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
};
}