fix: DEBT-027 - schedule trade status polling/settlement job

PollTradeStatusHandler and ConfirmSettlementHandler were fully
implemented and registered in DI, but nothing in the running
application ever called them - no endpoint, no Hangfire job. A trade
submitted via POST /trades could reach Submitted and never progress:
KIS fills and settlement confirmations were never picked up. Same
class of gap as DEBT-026 (a complete handler with no caller).

Adds TradeStatusPollingJob, a Hangfire recurring job (every 2 minutes,
q-customer-sla queue) that polls Submitted/Accepted/PartiallyFilled
trades via PollTradeStatusHandler, then confirms settlement for
FullyFilled trades via ConfirmSettlementHandler. Registered in
Program.cs alongside the other recurring jobs.

dotnet build KArtSell.sln -c Release: clean. No dedicated test added
(thin orchestration over already-covered handlers; a fake
IKisTradeExecutionService/ITradeSql test double would be a new pattern
not used elsewhere in this codebase) and not run against a live
database or KIS - see TECH_DEBT_REGISTER.md DEBT-027.

Also corrected WBS_PROGRESS_TRACKER.csv's AEG-VS-28-01 row: the
trade-execution frontend UI agent actually succeeded on retry (it had
previously failed on the session spend limit) - the row still said
"failed, not resumed" from before the retry completed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-09 00:12:32 +09:00
parent 8ed232e224
commit ace9fe8a9c
4 changed files with 117 additions and 1 deletions
@@ -0,0 +1,112 @@
using Hangfire;
using KArtSell.Modules.ModelOperations.TradeExecution;
using Microsoft.Extensions.Logging;
namespace KArtSell.Host.Jobs;
/// <summary>
/// Polls KIS for status on trades that have been submitted but haven't reached a terminal state,
/// and confirms settlement for trades that have fully filled.
///
/// DEBT-027: before this job existed, PollTradeStatusHandler and ConfirmSettlementHandler were
/// registered in DI and fully implemented, but nothing in the running application ever invoked
/// them — no endpoint, no scheduled job. A trade could reach Submitted via POST /trades and never
/// progress any further; the same class of gap DEBT-026 found and fixed for the approval
/// workflow's Draft-&gt;Proposed transition.
/// </summary>
public sealed class TradeStatusPollingJob(
ITradeSql sql,
PollTradeStatusHandler pollHandler,
ConfirmSettlementHandler settlementHandler,
ILogger<TradeStatusPollingJob> logger)
{
private static readonly TradeStatus[] PollableStatuses =
[TradeStatus.Submitted, TradeStatus.Accepted, TradeStatus.PartiallyFilled];
private static readonly Action<ILogger, int, Exception?> LogPolled =
LoggerMessage.Define<int>(
LogLevel.Information,
new EventId(1, nameof(LogPolled)),
"Trade status polling processed {TradeCount} non-terminal trades.");
private static readonly Action<ILogger, Guid, Exception?> LogPollError =
LoggerMessage.Define<Guid>(
LogLevel.Error,
new EventId(2, nameof(LogPollError)),
"Failed to poll trade status for {TradeId}");
private static readonly Action<ILogger, int, Exception?> LogSettled =
LoggerMessage.Define<int>(
LogLevel.Information,
new EventId(3, nameof(LogSettled)),
"Trade settlement confirmation processed {TradeCount} fully-filled trades.");
private static readonly Action<ILogger, Guid, Exception?> LogSettleError =
LoggerMessage.Define<Guid>(
LogLevel.Error,
new EventId(4, nameof(LogSettleError)),
"Failed to confirm settlement for {TradeId}");
[Queue("q-customer-sla")]
[DisableConcurrentExecution(timeoutInSeconds: 120)]
[AutomaticRetry(Attempts = 3, OnAttemptsExceeded = AttemptsExceededAction.Fail)]
public async Task ExecuteAsync(CancellationToken ct = default)
{
var polledCount = 0;
foreach (var status in PollableStatuses)
{
var trades = await sql.GetTradesByStatusAsync(status, Guid.NewGuid(), ct);
foreach (var trade in trades)
{
if (string.IsNullOrEmpty(trade.KisOrderId))
{
continue;
}
try
{
await pollHandler.HandleAsync(new PollTradeStatusCommand
{
TradeId = trade.Id,
KisOrderId = trade.KisOrderId,
CorrelationId = trade.CorrelationId
}, ct);
polledCount++;
}
catch (Exception ex)
{
LogPollError(logger, trade.Id, ex);
}
}
}
LogPolled(logger, polledCount, null);
var filledTrades = await sql.GetTradesByStatusAsync(TradeStatus.FullyFilled, Guid.NewGuid(), ct);
var settledCount = 0;
foreach (var trade in filledTrades)
{
if (string.IsNullOrEmpty(trade.KisOrderId))
{
continue;
}
try
{
await settlementHandler.HandleAsync(new ConfirmSettlementCommand
{
TradeId = trade.Id,
KisOrderId = trade.KisOrderId,
CorrelationId = trade.CorrelationId
}, ct);
settledCount++;
}
catch (Exception ex)
{
LogSettleError(logger, trade.Id, ex);
}
}
LogSettled(logger, settledCount, null);
}
}
+3
View File
@@ -200,6 +200,7 @@ builder.Services.AddScoped<KArtSell.Modules.ModelOperations.TradeExecution.ITrad
builder.Services.AddScoped<KArtSell.Modules.ModelOperations.TradeExecution.SubmitTradeHandler>();
builder.Services.AddScoped<KArtSell.Modules.ModelOperations.TradeExecution.PollTradeStatusHandler>();
builder.Services.AddScoped<KArtSell.Modules.ModelOperations.TradeExecution.ConfirmSettlementHandler>();
builder.Services.AddScoped<KArtSell.Host.Jobs.TradeStatusPollingJob>();
// Portfolio Reconciliation (VS-14)
builder.Services.AddScoped<KArtSell.Modules.ModelOperations.PortfolioReconciliation.IReconciliationRepository, KArtSell.Modules.ModelOperations.PortfolioReconciliation.ReconciliationSql>();
@@ -397,6 +398,8 @@ try { RecurringJob.AddOrUpdate<OpenDartDailyBatchJob>("opendart-daily-batch", jo
try { RecurringJob.AddOrUpdate<GenerateDailyRecommendationJob>("daily-recommendation", job => job.ExecuteAsync(CancellationToken.None), "0 9 * * *", new RecurringJobOptions { TimeZone = kstTimeZone }); logger.LogInformation("✅ daily-recommendation registered"); } catch (Exception ex) { logger.LogWarning(ex, "⚠️ daily-recommendation error"); }
try { RecurringJob.AddOrUpdate<GenerateWeeklyRecommendationJob>("weekly-recommendation", job => job.ExecuteAsync(CancellationToken.None), "0 9 * * 6", new RecurringJobOptions { TimeZone = kstTimeZone }); logger.LogInformation("✅ weekly-recommendation registered"); } catch (Exception ex) { logger.LogWarning(ex, "⚠️ weekly-recommendation error"); }
try { RecurringJob.AddOrUpdate<GenerateMonthlyRecommendationJob>("monthly-recommendation", job => job.ExecuteAsync(CancellationToken.None), "0 9 1 * *", new RecurringJobOptions { TimeZone = kstTimeZone }); logger.LogInformation("✅ monthly-recommendation registered"); } catch (Exception ex) { logger.LogWarning(ex, "⚠️ monthly-recommendation error"); }
// DEBT-027: without this, PollTradeStatusHandler/ConfirmSettlementHandler were reachable via DI but never invoked by anything.
try { RecurringJob.AddOrUpdate<KArtSell.Host.Jobs.TradeStatusPollingJob>("trade-status-polling", job => job.ExecuteAsync(CancellationToken.None), "*/2 * * * *", new RecurringJobOptions { TimeZone = TimeZoneInfo.Utc }); logger.LogInformation("✅ trade-status-polling registered"); } catch (Exception ex) { logger.LogWarning(ex, "⚠️ trade-status-polling error"); }
logger.LogInformation("🎯 Hangfire background jobs initialization complete");