refactor: Defer Phase 2-3 implementation to Task execution
Remove preliminary code files for OpenDart, KIS, RateLimiter services. These will be implemented during Task #3-7 execution with proper: - Error handling and type safety - Database connection management - Unit/integration tests - AGENTS.md v16.0 compliance verification Current state: ✅ Build: 0 errors, 0 warnings ✅ Tests: 116/116 PASS (verified clean state) ✅ DB Migration: 0031 ready (11 tables, 23 indexes) ✅ Documentation: Strategy + Checklist + Status ready Next: 1. User starts Host (SSH tunnel + dotnet run) 2. Task #1: Gate 3 Shadow Run execution 3. Tasks #2-7: Phase 2-3 sequential implementation Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,282 +0,0 @@
|
||||
using KArtSell.BuildingBlocks.Data;
|
||||
using Dapper;
|
||||
using Npgsql;
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace KArtSell.Host.Observability;
|
||||
|
||||
/// <summary>
|
||||
/// OpenDart Financial Data Service
|
||||
/// Implements AGENTS.md v16.0 constraints: idempotent, cached, rate-limited
|
||||
/// 3-month cache TTL, 1000 req/day quota, single batch per day
|
||||
/// </summary>
|
||||
public sealed class OpenDartService : IDisposable
|
||||
{
|
||||
private readonly IDbConnectionFactory _connectionFactory;
|
||||
private readonly string _apiKey;
|
||||
private readonly ConcurrentDictionary<string, DateTime> _quotaResetDates;
|
||||
private readonly SemaphoreSlim _quotaLock;
|
||||
|
||||
public OpenDartService(IDbConnectionFactory connectionFactory, string apiKey)
|
||||
{
|
||||
_connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory));
|
||||
_apiKey = apiKey ?? throw new ArgumentNullException(nameof(apiKey));
|
||||
_quotaResetDates = new();
|
||||
_quotaLock = new(1, 1);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get financial data from OpenDart API with 3-month caching.
|
||||
/// Returns cached data if available and not expired; fetches fresh data otherwise.
|
||||
/// Idempotent: same ticker+quarter always returns same result.
|
||||
/// </summary>
|
||||
public async Task<OpenDartDataDto> GetFinancialDataAsync(
|
||||
string ticker,
|
||||
string quarter, // Format: "2024-Q1"
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(ticker))
|
||||
throw new ArgumentException("Ticker is required", nameof(ticker));
|
||||
|
||||
if (string.IsNullOrWhiteSpace(quarter) || !IsValidQuarterFormat(quarter))
|
||||
throw new ArgumentException("Quarter must be in format YYYY-QN", nameof(quarter));
|
||||
|
||||
using var conn = _connectionFactory.CreateConnection();
|
||||
await conn.OpenAsync(cancellationToken);
|
||||
|
||||
// Check cache first
|
||||
var cachedData = await GetCachedDataAsync(conn, ticker, quarter, cancellationToken);
|
||||
if (cachedData != null)
|
||||
return cachedData;
|
||||
|
||||
// Check quota before fetching
|
||||
var quotaKey = $"opendart:{DateTime.UtcNow:yyyy-MM-dd}";
|
||||
await _quotaLock.WaitAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
var quotaUsed = await GetDailyQuotaUsedAsync(conn, cancellationToken);
|
||||
if (quotaUsed >= 1000)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"OpenDart quota exceeded (1000/day): {quotaUsed} already used");
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_quotaLock.Release();
|
||||
}
|
||||
|
||||
// Fetch from API (TODO: implement actual OpenDart API call)
|
||||
var freshData = await FetchFromOpenDartApiAsync(ticker, quarter, cancellationToken);
|
||||
|
||||
// Cache the result
|
||||
await CacheDataAsync(conn, ticker, quarter, freshData, cancellationToken);
|
||||
|
||||
// Log batch usage
|
||||
await LogBatchUsageAsync(conn, 1, "success", cancellationToken);
|
||||
|
||||
return freshData;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Execute daily batch for all configured tickers.
|
||||
/// Idempotent: safe to retry on same day.
|
||||
/// </summary>
|
||||
public async Task ExecuteDailyBatchAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var batchDate = DateTime.UtcNow.Date;
|
||||
using var conn = _connectionFactory.CreateConnection();
|
||||
await conn.OpenAsync(cancellationToken);
|
||||
|
||||
// Check if batch already executed today
|
||||
var existingBatch = await CheckBatchExecutedAsync(conn, batchDate, cancellationToken);
|
||||
if (existingBatch && existingBatch.Status == "success")
|
||||
return; // Idempotent: skip if already successful
|
||||
|
||||
try
|
||||
{
|
||||
var tickers = new[] { "005930", "000660", "068270" }; // Samsung, SK Hynix, Naver (example)
|
||||
var currentYear = DateTime.UtcNow.Year;
|
||||
var currentQuarter = (DateTime.UtcNow.Month - 1) / 3 + 1;
|
||||
|
||||
var quotaUsed = 0;
|
||||
foreach (var ticker in tickers)
|
||||
{
|
||||
var quarter = $"{currentYear}-Q{currentQuarter}";
|
||||
try
|
||||
{
|
||||
await GetFinancialDataAsync(ticker, quarter, cancellationToken);
|
||||
quotaUsed++;
|
||||
}
|
||||
catch (InvalidOperationException ex) when (ex.Message.Contains("quota exceeded"))
|
||||
{
|
||||
// Partial success: log and continue
|
||||
await LogBatchUsageAsync(conn, quotaUsed, "quota_exceeded", cancellationToken);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
await LogBatchUsageAsync(conn, quotaUsed, "success", cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await LogBatchUsageAsync(conn, 0, "failed", ex.Message, cancellationToken);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
// Private methods
|
||||
|
||||
private bool IsValidQuarterFormat(string quarter)
|
||||
{
|
||||
if (quarter.Length != 7) return false; // "2024-Q1"
|
||||
if (quarter[4] != '-') return false;
|
||||
if (quarter[5] != 'Q') return false;
|
||||
if (!int.TryParse(quarter.Substring(0, 4), out var year)) return false;
|
||||
if (!int.TryParse(quarter.Substring(6, 1), out var q) || q < 1 || q > 4) return false;
|
||||
return year >= 2000 && year <= 2100;
|
||||
}
|
||||
|
||||
private async Task<OpenDartDataDto?> GetCachedDataAsync(
|
||||
DbConnection conn,
|
||||
string ticker,
|
||||
string quarter,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT data_json, cached_at, expires_at
|
||||
FROM opendata.opendart_cache
|
||||
WHERE ticker = @ticker
|
||||
AND quarter = @quarter
|
||||
AND expires_at > CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
|
||||
LIMIT 1
|
||||
""";
|
||||
|
||||
var row = await conn.QuerySingleOrDefaultAsync(
|
||||
sql,
|
||||
new { ticker, quarter });
|
||||
|
||||
if (row is null)
|
||||
return null;
|
||||
|
||||
return new OpenDartDataDto
|
||||
{
|
||||
Ticker = ticker,
|
||||
Quarter = quarter,
|
||||
DataJson = row.data_json,
|
||||
CachedAt = row.cached_at
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<int> GetDailyQuotaUsedAsync(DbConnection conn, CancellationToken cancellationToken)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT COALESCE(SUM(quota_used), 0) as total_used
|
||||
FROM opendata.opendart_batch_log
|
||||
WHERE batch_date = CURRENT_DATE AT TIME ZONE 'UTC'
|
||||
AND status IN ('success', 'partial')
|
||||
""";
|
||||
|
||||
var result = await conn.QueryFirstOrDefaultAsync<dynamic>(sql);
|
||||
return result?.total_used ?? 0;
|
||||
}
|
||||
|
||||
private async Task<(bool Exists, string Status)?> CheckBatchExecutedAsync(
|
||||
DbConnection conn,
|
||||
DateTime batchDate,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT status
|
||||
FROM opendata.opendart_batch_log
|
||||
WHERE batch_date = @batchDate
|
||||
LIMIT 1
|
||||
""";
|
||||
|
||||
var result = await conn.QuerySingleOrDefaultAsync<dynamic>(sql, new { batchDate });
|
||||
if (result is null)
|
||||
return null;
|
||||
|
||||
return (true, result.status);
|
||||
}
|
||||
|
||||
private async Task CacheDataAsync(
|
||||
DbConnection conn,
|
||||
string ticker,
|
||||
string quarter,
|
||||
OpenDartDataDto data,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
const string sql = """
|
||||
INSERT INTO opendata.opendart_cache (ticker, quarter, data_json, cached_at, expires_at, published_at)
|
||||
VALUES (@ticker, @quarter, @dataJson, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP + INTERVAL '90 days', CURRENT_TIMESTAMP)
|
||||
ON CONFLICT (ticker, quarter) DO UPDATE
|
||||
SET data_json = EXCLUDED.data_json,
|
||||
cached_at = CURRENT_TIMESTAMP,
|
||||
expires_at = CURRENT_TIMESTAMP + INTERVAL '90 days',
|
||||
published_at = CURRENT_TIMESTAMP
|
||||
""";
|
||||
|
||||
await conn.ExecuteAsync(sql, new
|
||||
{
|
||||
ticker,
|
||||
quarter,
|
||||
dataJson = data.DataJson
|
||||
});
|
||||
}
|
||||
|
||||
private async Task LogBatchUsageAsync(
|
||||
DbConnection conn,
|
||||
int quotaUsed,
|
||||
string status,
|
||||
string? errorMessage = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
INSERT INTO opendata.opendart_batch_log (batch_date, quota_limit, quota_used, status, error_message, executed_at, published_at)
|
||||
VALUES (CURRENT_DATE, 1000, @quotaUsed, @status, @errorMessage, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT (batch_date) DO UPDATE
|
||||
SET quota_used = GREATEST(opendart_batch_log.quota_used, EXCLUDED.quota_used),
|
||||
status = EXCLUDED.status,
|
||||
error_message = COALESCE(EXCLUDED.error_message, opendart_batch_log.error_message),
|
||||
executed_at = CURRENT_TIMESTAMP,
|
||||
published_at = CURRENT_TIMESTAMP
|
||||
""";
|
||||
|
||||
await conn.ExecuteAsync(sql, new
|
||||
{
|
||||
quotaUsed,
|
||||
status,
|
||||
errorMessage
|
||||
});
|
||||
}
|
||||
|
||||
private async Task<OpenDartDataDto> FetchFromOpenDartApiAsync(
|
||||
string ticker,
|
||||
string quarter,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// TODO: Implement actual OpenDart API call using _apiKey
|
||||
// For now, return placeholder
|
||||
return new OpenDartDataDto
|
||||
{
|
||||
Ticker = ticker,
|
||||
Quarter = quarter,
|
||||
DataJson = "{}",
|
||||
CachedAt = DateTime.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_quotaLock?.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class OpenDartDataDto
|
||||
{
|
||||
public required string Ticker { get; set; }
|
||||
public required string Quarter { get; set; }
|
||||
public required string DataJson { get; set; }
|
||||
public required DateTime CachedAt { get; set; }
|
||||
}
|
||||
Reference in New Issue
Block a user