using Dapper; using KArtSell.BuildingBlocks.Time; using Microsoft.AspNetCore.Authentication; using Npgsql; using Microsoft.Extensions.Logging; namespace KArtSell.Host.Observability; /// /// OpenDart API client with 3-month caching and quota tracking. /// Idempotent: Daily batch run caches results per ticker/quarter, never refetches if cached. /// public class OpenDartService { private readonly NpgsqlDataSource _dataSource; private readonly HttpClient _httpClient; private readonly IClock _clock; private readonly string _apiKey; private readonly ILogger _logger; private const string OpenDartApiUrl = "https://opendart.fss.or.kr/api/"; private const int CacheTtlDays = 90; // 3-month cache private const int DailyQuotaLimit = 1000; private static readonly Action LogCacheHit = LoggerMessage.Define( LogLevel.Information, new EventId(1, nameof(LogCacheHit)), "OpenDart cache hit for ticker {Ticker}"); private static readonly Action LogQuotaUsage = LoggerMessage.Define( LogLevel.Information, new EventId(2, nameof(LogQuotaUsage)), "OpenDart quota used for {Ticker}: {QuotaUsed}/1000"); public OpenDartService( NpgsqlDataSource dataSource, HttpClient httpClient, IClock clock, ILogger logger) { _dataSource = dataSource; _httpClient = httpClient; _clock = clock; _apiKey = Environment.GetEnvironmentVariable("OPENDART_API_KEY") ?? throw new InvalidOperationException("OPENDART_API_KEY required"); _logger = logger; } public async Task GetQuarterlyFinancialDataAsync( string ticker, string quarterKey, // Format: "2024-Q1" CancellationToken cancellationToken = default) { // 1. Check cache (idempotent: don't refetch if cached) var cached = await GetCachedAsync(ticker, quarterKey, cancellationToken); if (cached != null) { LogCacheHit(_logger, ticker, null); return cached; } // 2. Fetch from API var result = await FetchFromApiAsync(ticker, quarterKey, cancellationToken); if (result == null) return null; // 3. Store in cache (3-month TTL) await CacheResultAsync(ticker, quarterKey, result, cancellationToken); // 4. Track quota usage await RecordQuotaUsageAsync(ticker, cancellationToken); return result; } private async Task GetCachedAsync( string ticker, string quarterKey, CancellationToken cancellationToken) { const string sql = """ SELECT data_json FROM opendata.opendart_cache WHERE ticker = @ticker AND quarter = @quarter AND expires_at > @now AND published_at <= @now ORDER BY published_at DESC LIMIT 1 """; await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken); #pragma warning disable DAP005 var json = await connection.QueryFirstOrDefaultAsync( sql, new { ticker, quarter = quarterKey, now = _clock.UtcNow.UtcDateTime }, commandTimeout: 5); if (json == null) return null; #pragma warning restore DAP005 return System.Text.Json.JsonSerializer.Deserialize(json); } private async Task FetchFromApiAsync( string ticker, string quarterKey, CancellationToken cancellationToken) { try { var (year, q) = ParseQuarterKey(quarterKey); var url = $"{OpenDartApiUrl}companySearch/quarterlyFinancial?serviceKey={_apiKey}&ticker={ticker}&quarter={q}{year}"; var response = await _httpClient.GetAsync(url, cancellationToken); response.EnsureSuccessStatusCode(); var content = await response.Content.ReadAsStringAsync(cancellationToken); return System.Text.Json.JsonSerializer.Deserialize(content); } catch (HttpRequestException ex) { _logger.LogError(ex, "OpenDart API error for {Ticker}", ticker); return null; } } private async Task CacheResultAsync( string ticker, string quarterKey, OpenDartQuarterlyData data, CancellationToken cancellationToken) { const string sql = """ INSERT INTO opendata.opendart_cache (ticker, quarter, data_json, expires_at, published_at) VALUES (@ticker, @quarter, @dataJson, @expiresAt, @publishedAt) ON CONFLICT (ticker, quarter) DO UPDATE SET data_json = EXCLUDED.data_json, expires_at = EXCLUDED.expires_at, published_at = EXCLUDED.published_at """; var dataJson = System.Text.Json.JsonSerializer.Serialize(data); var now = _clock.UtcNow.UtcDateTime; var expiresAt = now.AddDays(CacheTtlDays); await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken); await connection.ExecuteAsync( sql, new { ticker, quarter = quarterKey, dataJson, expiresAt, publishedAt = now }, commandTimeout: 10); } private async Task RecordQuotaUsageAsync(string ticker, CancellationToken cancellationToken) { const string sql = """ UPDATE opendata.opendart_batch_log SET quota_used = quota_used + 1 WHERE batch_date = CURRENT_DATE """; await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken); await connection.ExecuteAsync(sql, commandTimeout: 5); LogQuotaUsage(_logger, ticker, 1, null); } private static (string Year, string Quarter) ParseQuarterKey(string key) { // Format: "2024-Q1" → ("2024", "1") var parts = key.Split('-'); var quarter = parts[1].ToUpperInvariant().Replace("Q", ""); return (parts[0], quarter); } } public class OpenDartQuarterlyData { public string? Ticker { get; set; } public string? Quarter { get; set; } public decimal? Revenue { get; set; } public decimal? OperatingIncome { get; set; } public decimal? NetIncome { get; set; } public decimal? EPS { get; set; } public decimal? ROE { get; set; } }