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:
@@ -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->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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
|
||||
Reference in New Issue
Block a user