diff --git a/src/KArtSell.Modules.ModelOperations/ShadowRun/Services/KrxDataService.cs b/src/KArtSell.Modules.ModelOperations/ShadowRun/Services/KrxDataService.cs index 004ec108..b12b802b 100644 --- a/src/KArtSell.Modules.ModelOperations/ShadowRun/Services/KrxDataService.cs +++ b/src/KArtSell.Modules.ModelOperations/ShadowRun/Services/KrxDataService.cs @@ -147,52 +147,117 @@ public sealed class KrxDataService : IKrxDataService DateOnly endDate, CancellationToken cancellationToken) { - // Stub: In production, call real KRX API - // For now, return simulated data - await Task.Delay(100, cancellationToken); + // Real KRX OpenAPI: Stock Price endpoint + var apiKey = Environment.GetEnvironmentVariable("KRX_API_KEY") ?? ""; - // Simulate successful response - return $$""" - [ - {"Date":"{{startDate:yyyy-MM-dd}}","Open":100.00,"High":105.00,"Low":99.50,"Close":103.50,"Volume":1000000}, - {"Date":"{{startDate.AddDays(1):yyyy-MM-dd}}","Open":103.50,"High":107.00,"Low":103.00,"Close":106.00,"Volume":1100000} - ] - """; + if (string.IsNullOrEmpty(apiKey)) + { + _logger.LogWarning("KRX_API_KEY not set, using stub data"); + // Fallback to stub for local development (KRX format) + await Task.Delay(100, cancellationToken); + return $$""" + [ + {"BasDt":"{{startDate:yyyyMMdd}}","Mkp":100.00,"Hipr":105.00,"Lopr":99.50,"Clpr":103.50,"Trqu":1000000}, + {"BasDt":"{{startDate.AddDays(1):yyyyMMdd}}","Mkp":103.50,"Hipr":107.00,"Lopr":103.00,"Clpr":106.00,"Trqu":1100000} + ] + """; + } + + var results = new List(); + + // Fetch each trading day in range + for (var date = startDate; date <= endDate; date = date.AddDays(1)) + { + var endpoint = $"{KrxApiBaseUrl}/home/service/oss/StockPrice" + + $"?serviceKey={Uri.EscapeDataString(apiKey)}" + + $"&basDt={date:yyyyMMdd}" + + $"&isuCd={ticker}"; + + var response = await _httpClient.GetAsync(endpoint, cancellationToken); + response.EnsureSuccessStatusCode(); + + var json = await response.Content.ReadAsStringAsync(cancellationToken); + results.Add(json); + } + + // Combine all responses + return $"[{string.Join(",", results.Select(r => ExtractPriceItems(r)))}]"; + } + + private string ExtractPriceItems(string krxResponse) + { + try + { + var response = JsonSerializer.Deserialize(krxResponse); + var items = response?.Response?.Body?.Items ?? new List(); + return JsonSerializer.Serialize(items); + } + catch + { + return "[]"; + } } private List ParseOhlcvResponse(string ticker, string jsonResponse) { var bars = new List(); - using var doc = JsonDocument.Parse(jsonResponse); - var root = doc.RootElement; - - if (root.ValueKind != JsonValueKind.Array) + try { - _logger.LogWarning("Unexpected response format for {Ticker}: expected array", ticker); - return bars; + using var doc = JsonDocument.Parse(jsonResponse); + var root = doc.RootElement; + + if (root.ValueKind != JsonValueKind.Array) + { + _logger.LogWarning("Unexpected response format for {Ticker}: expected array", ticker); + return bars; + } + + foreach (var element in root.EnumerateArray()) + { + try + { + // Parse KRX PriceItem format + if (!element.TryGetProperty("BasDt", out var basDto)) + continue; + + var date = DateOnly.ParseExact(basDto.GetString()!, "yyyyMMdd"); + + var bar = new DataBackfiller.OhlcvBar( + Date: date, + Ticker: ticker, + Open: element.GetProperty("Mkp").GetDecimal(), // 시가 + High: element.GetProperty("Hipr").GetDecimal(), // 고가 + Low: element.GetProperty("Lopr").GetDecimal(), // 저가 + Close: element.GetProperty("Clpr").GetDecimal(), // 종가 + Volume: element.GetProperty("Trqu").GetInt64()); // 거래량 + + bars.Add(bar); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to parse OHLCV element for {Ticker}", ticker); + } + } } - - foreach (var element in root.EnumerateArray()) + catch (JsonException ex) { - var bar = new DataBackfiller.OhlcvBar( - Date: DateOnly.Parse(element.GetProperty("Date").GetString()!), - Ticker: ticker, - Open: element.GetProperty("Open").GetDecimal(), - High: element.GetProperty("High").GetDecimal(), - Low: element.GetProperty("Low").GetDecimal(), - Close: element.GetProperty("Close").GetDecimal(), - Volume: element.GetProperty("Volume").GetInt64()); - - bars.Add(bar); + _logger.LogWarning(ex, "Failed to deserialize OHLCV response for {Ticker}", ticker); } return bars; } private static bool IsTransientError(HttpRequestException ex) - => ex.InnerException is HttpRequestException - && (ex.StatusCode == HttpStatusCode.ServiceUnavailable - || ex.StatusCode == HttpStatusCode.GatewayTimeout - || ex.StatusCode == HttpStatusCode.RequestTimeout); + { + // 429: Too Many Requests (rate limit) + // 503: Service Unavailable + // 504: Gateway Timeout + // 408: Request Timeout + return ex.StatusCode == HttpStatusCode.TooManyRequests + || ex.StatusCode == HttpStatusCode.ServiceUnavailable + || ex.StatusCode == HttpStatusCode.GatewayTimeout + || ex.StatusCode == HttpStatusCode.RequestTimeout + || (ex.InnerException is TimeoutException); + } }