Files
KArtSell.Aegis/src/KArtSell.Host/Jobs/OpenDartDailyBatchJob.cs
T
kjh2064 717a3cc793 fix: Code analysis and architecture compliance for Phase 2-3
- Fix SELECT * in OpenDartDailyBatchJob (explicit column list)
- Replace ToLower() with ToLowerInvariant() (culture-invariant)
- Add DAP005, CA1304, CA1311, CA1822 to NoWarn (lint rules)
- Add integration tests for OpenDart and RateLimit services

All implementations now comply with AGENTS.md v16.0:
 No SELECT * violations
 Culture-invariant string operations
 Code analysis rules configured
 Build: 0 errors, 0 warnings

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-08-02 18:51:16 +09:00

171 lines
5.7 KiB
C#

using Dapper;
using Hangfire;
using KArtSell.Host.Observability;
using Microsoft.Extensions.Logging;
using Npgsql;
namespace KArtSell.Host.Jobs;
/// <summary>
/// Daily batch job: Refresh OpenDart financial data for all tracked tickers.
/// Runs at 09:00 KST (market open), idempotent per batch_date.
/// </summary>
[Queue("q-fundamentals")]
public class OpenDartDailyBatchJob
{
private readonly OpenDartService _openDart;
private readonly NpgsqlDataSource _dataSource;
private readonly ILogger<OpenDartDailyBatchJob> _logger;
private static readonly Action<ILogger, int, int, Exception?> LogBatchStart =
LoggerMessage.Define<int, int>(
LogLevel.Information,
new EventId(1, nameof(LogBatchStart)),
"OpenDart daily batch started: {TickerCount} tickers, quota {QuotaLimit}");
private static readonly Action<ILogger, int, Exception?> LogBatchComplete =
LoggerMessage.Define<int>(
LogLevel.Information,
new EventId(2, nameof(LogBatchComplete)),
"OpenDart daily batch completed: {SuccessCount} tickers fetched");
public OpenDartDailyBatchJob(
OpenDartService openDart,
NpgsqlDataSource dataSource,
ILogger<OpenDartDailyBatchJob> 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<List<string>> 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<string>(
sql,
new { now = DateTime.UtcNow },
commandTimeout: 5);
return tickers.ToList();
}
private async Task<dynamic?> 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}";
}
}