1470bbcff2
Resolves architecture test violations: - Removed all direct DateTime.UtcNow calls - Injected IClock into 7 service classes - Added TestClock implementation for tests - Updated all test constructors with fixture.Clock() - Fixed MetricsSql comment to avoid false SELECT * detection Services updated (IClock injection): - MetricsSql.cs (BuildingBlocks) - CircuitBreakerPolicyFactory.cs - KisConnectionPool.cs - RateLimiterService.cs - MetricsPolicy.cs - OpenDartDailyBatchJob.cs - OpenDartService.cs Tests updated: - DatabaseFixture.cs (added Clock() method + TestClock impl) - CircuitBreakerTests, ObservabilityMetricsTests, OpenDartServiceTests, RateLimiterServiceTests (added fixture.Clock() to constructors) Result: 95/95 integration tests PASS, DateTime violations 100% resolved Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
187 lines
6.5 KiB
C#
187 lines
6.5 KiB
C#
using Dapper;
|
|
using KArtSell.BuildingBlocks.Time;
|
|
using Microsoft.AspNetCore.Authentication;
|
|
using Npgsql;
|
|
using Microsoft.Extensions.Logging;
|
|
|
|
namespace KArtSell.Host.Observability;
|
|
|
|
/// <summary>
|
|
/// OpenDart API client with 3-month caching and quota tracking.
|
|
/// Idempotent: Daily batch run caches results per ticker/quarter, never refetches if cached.
|
|
/// </summary>
|
|
public class OpenDartService
|
|
{
|
|
private readonly NpgsqlDataSource _dataSource;
|
|
private readonly HttpClient _httpClient;
|
|
private readonly IClock _clock;
|
|
private readonly string _apiKey;
|
|
private readonly ILogger<OpenDartService> _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<ILogger, string, Exception?> LogCacheHit =
|
|
LoggerMessage.Define<string>(
|
|
LogLevel.Information,
|
|
new EventId(1, nameof(LogCacheHit)),
|
|
"OpenDart cache hit for ticker {Ticker}");
|
|
|
|
private static readonly Action<ILogger, string, int, Exception?> LogQuotaUsage =
|
|
LoggerMessage.Define<string, int>(
|
|
LogLevel.Information,
|
|
new EventId(2, nameof(LogQuotaUsage)),
|
|
"OpenDart quota used for {Ticker}: {QuotaUsed}/1000");
|
|
|
|
public OpenDartService(
|
|
NpgsqlDataSource dataSource,
|
|
HttpClient httpClient,
|
|
IClock clock,
|
|
ILogger<OpenDartService> 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<OpenDartQuarterlyData?> 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<OpenDartQuarterlyData?> 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<string>(
|
|
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<OpenDartQuarterlyData>(json);
|
|
}
|
|
|
|
private async Task<OpenDartQuarterlyData?> 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<OpenDartQuarterlyData>(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; }
|
|
}
|