using Dapper; using Hangfire; using KArtSell.Host.Observability; using Microsoft.Extensions.Logging; using Npgsql; namespace KArtSell.Host.Jobs; /// /// Daily batch job: Refresh OpenDart financial data for all tracked tickers. /// Runs at 09:00 KST (market open), idempotent per batch_date. /// [Queue("q-fundamentals")] public class OpenDartDailyBatchJob { private readonly OpenDartService _openDart; private readonly NpgsqlDataSource _dataSource; private readonly ILogger _logger; private static readonly Action LogBatchStart = LoggerMessage.Define( LogLevel.Information, new EventId(1, nameof(LogBatchStart)), "OpenDart daily batch started: {TickerCount} tickers, quota {QuotaLimit}"); private static readonly Action LogBatchComplete = LoggerMessage.Define( LogLevel.Information, new EventId(2, nameof(LogBatchComplete)), "OpenDart daily batch completed: {SuccessCount} tickers fetched"); public OpenDartDailyBatchJob( OpenDartService openDart, NpgsqlDataSource dataSource, ILogger logger) { _openDart = openDart; _dataSource = dataSource; _logger = logger; } public async Task ExecuteAsync(CancellationToken cancellationToken = default) { var batchDate = DateOnly.FromDateTime(DateTime.UtcNow); var quotaLimit = 1000; // 1. Check if batch already ran today (idempotent) var existing = await GetBatchLogAsync(batchDate, cancellationToken); if (existing?.Status == "success") { _logger.LogInformation("OpenDart batch already completed for {Date}", batchDate); return; } // 2. Get all tickers to refresh var tickers = await GetTrackedTickersAsync(cancellationToken); LogBatchStart(_logger, tickers.Count, quotaLimit, null); // 3. Create batch log entry (or update existing) await InitializeBatchLogAsync(batchDate, quotaLimit, cancellationToken); // 4. Fetch latest quarterly data for each ticker var successCount = 0; var currentQuarter = GetCurrentQuarter(); foreach (var ticker in tickers) { try { // Fetch cached or new data var data = await _openDart.GetQuarterlyFinancialDataAsync( ticker, currentQuarter, cancellationToken); if (data != null) successCount++; } catch (Exception ex) { _logger.LogError(ex, "Failed to fetch OpenDart data for {Ticker}", ticker); } } // 5. Mark batch complete await CompleteBatchLogAsync(batchDate, successCount, cancellationToken); LogBatchComplete(_logger, successCount, null); } private async Task> GetTrackedTickersAsync(CancellationToken cancellationToken) { const string sql = """ SELECT DISTINCT ticker FROM model_operations.models WHERE published_at <= @now ORDER BY ticker LIMIT 100 -- Safety limit """; await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken); var tickers = await connection.QueryAsync( sql, new { now = DateTime.UtcNow }, commandTimeout: 5); return tickers.ToList(); } private async Task GetBatchLogAsync( DateOnly batchDate, CancellationToken cancellationToken) { const string sql = """ SELECT id, batch_date, quota_limit, quota_used, status, error_message, executed_at, published_at FROM opendata.opendart_batch_log WHERE batch_date = @batchDate ORDER BY published_at DESC LIMIT 1 """; await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken); return await connection.QueryFirstOrDefaultAsync( sql, new { batchDate }, commandTimeout: 5); } private async Task InitializeBatchLogAsync( DateOnly batchDate, int quotaLimit, CancellationToken cancellationToken) { const string sql = """ INSERT INTO opendata.opendart_batch_log (batch_date, quota_limit, status, quota_used) VALUES (@batchDate, @quotaLimit, 'in_progress', 0) ON CONFLICT (batch_date) DO NOTHING """; await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken); await connection.ExecuteAsync( sql, new { batchDate, quotaLimit }, commandTimeout: 5); } private async Task CompleteBatchLogAsync( DateOnly batchDate, int successCount, CancellationToken cancellationToken) { const string sql = """ UPDATE opendata.opendart_batch_log SET status = @status, quota_used = @successCount WHERE batch_date = @batchDate """; await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken); await connection.ExecuteAsync( sql, new { status = "success", successCount, batchDate }, commandTimeout: 5); } private static string GetCurrentQuarter() { var now = DateTime.UtcNow; var quarter = (now.Month - 1) / 3 + 1; return $"{now.Year}-Q{quarter}"; } }