using System.Globalization; using System.Text.Json; namespace Infrastructure.StockData; /// /// 공공데이터포털(금융위) 표준 JSON 응답 파서 — 순수 C# (System.Text.Json). /// 응답 골격: response.header(resultCode/resultMsg) + response.body(totalCount, items.item[]). /// item 이 단건이면 객체, 0건이면 items 가 빈 문자열로 오는 케이스까지 흡수한다. /// public static class DataGoKrStockParser { /// KRX 상장종목정보 (GetKrxListedInfoService/getItemInfo) 1행 public sealed record ListedItem(string Code, string? Isin, string Name, string MarketName, DateOnly BaseDate); /// 주식시세정보 (GetStockSecuritiesInfoService/getStockPriceInfo) 1행 public sealed record DailyPriceItem( string Code, DateOnly TradingDate, int Open, int High, int Low, int Close, long Volume, long TradingValue, int ChangeAmount, decimal ChangeRate, long? MarketCap, long? ListedShares ); public static (IReadOnlyList Items, int TotalCount) ParseListedItems(string json) { using var doc = JsonDocument.Parse(json); var body = GetBody(doc); var items = new List(); foreach (var item in EnumerateItems(body)) { var code = NormalizeCode(GetString(item, "srtnCd")); var name = GetString(item, "itmsNm"); var market = GetString(item, "mrktCtg"); var baseDate = ParseDate(GetString(item, "basDt")); if (code is null || name.Length == 0 || baseDate is null) { continue; } var isin = GetString(item, "isinCd"); items.Add(new ListedItem(code, isin.Length == 12 ? isin : null, name, market, baseDate.Value)); } return (items, GetTotalCount(body)); } public static (IReadOnlyList Items, int TotalCount) ParseDailyPrices(string json) { using var doc = JsonDocument.Parse(json); var body = GetBody(doc); var items = new List(); foreach (var item in EnumerateItems(body)) { var code = NormalizeCode(GetString(item, "srtnCd")); var tradingDate = ParseDate(GetString(item, "basDt")); if (code is null || tradingDate is null) { continue; } items.Add(new DailyPriceItem( code, tradingDate.Value, ParseInt(GetString(item, "mkp")), ParseInt(GetString(item, "hipr")), ParseInt(GetString(item, "lopr")), ParseInt(GetString(item, "clpr")), ParseLong(GetString(item, "trqu")), ParseLong(GetString(item, "trPrc")), ParseInt(GetString(item, "vs")), ParseDecimal(GetString(item, "fltRt")), ParseNullableLong(GetString(item, "mrktTotAmt")), ParseNullableLong(GetString(item, "lstgStCnt")) )); } return (items, GetTotalCount(body)); } private static JsonElement GetBody(JsonDocument doc) { if (!doc.RootElement.TryGetProperty("response", out var response)) { throw new InvalidOperationException("data.go.kr 응답 형식 오류 — response 노드 없음"); } if (response.TryGetProperty("header", out var header)) { var resultCode = header.TryGetProperty("resultCode", out var rc) ? rc.GetString() : null; if (resultCode is not null && resultCode != "00") { var resultMsg = header.TryGetProperty("resultMsg", out var rm) ? rm.GetString() : null; throw new InvalidOperationException($"data.go.kr 오류 응답 — resultCode={resultCode}, resultMsg={resultMsg}"); } } if (!response.TryGetProperty("body", out var body)) { throw new InvalidOperationException("data.go.kr 응답 형식 오류 — body 노드 없음"); } return body; } private static IEnumerable EnumerateItems(JsonElement body) { // 0건이면 items 가 "" (빈 문자열) 로 온다 if (!body.TryGetProperty("items", out var items) || items.ValueKind != JsonValueKind.Object) { yield break; } if (!items.TryGetProperty("item", out var item)) { yield break; } if (item.ValueKind == JsonValueKind.Array) { foreach (var element in item.EnumerateArray()) { yield return element; } } else if (item.ValueKind == JsonValueKind.Object) { // 단건이면 배열이 아닌 객체로 온다 yield return item; } } private static int GetTotalCount(JsonElement body) { if (!body.TryGetProperty("totalCount", out var total)) { return 0; } return total.ValueKind switch { JsonValueKind.Number => total.GetInt32(), JsonValueKind.String => ParseInt(total.GetString() ?? ""), _ => 0 }; } private static string GetString(JsonElement element, string name) { if (!element.TryGetProperty(name, out var value)) { return string.Empty; } return value.ValueKind switch { JsonValueKind.String => value.GetString()?.Trim() ?? string.Empty, JsonValueKind.Number => value.GetRawText(), _ => string.Empty }; } /// 단축코드 정규화 — 'A' 프리픽스 등 6자리 초과분은 뒤 6자리만 취하고, 6자리 숫자만 유효 private static string? NormalizeCode(string raw) { if (raw.Length > 6) { raw = raw[^6..]; } if (raw is not { Length: 6 } || raw.Any(c => !char.IsAsciiDigit(c))) { return null; } return raw; } private static DateOnly? ParseDate(string raw) { return DateOnly.TryParseExact(raw, "yyyyMMdd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var date) ? date : null; } private static int ParseInt(string raw) { return int.TryParse(raw, NumberStyles.Integer | NumberStyles.AllowThousands, CultureInfo.InvariantCulture, out var value) ? value : 0; } private static long ParseLong(string raw) { return long.TryParse(raw, NumberStyles.Integer | NumberStyles.AllowThousands, CultureInfo.InvariantCulture, out var value) ? value : 0L; } private static long? ParseNullableLong(string raw) { return long.TryParse(raw, NumberStyles.Integer | NumberStyles.AllowThousands, CultureInfo.InvariantCulture, out var value) ? value : null; } private static decimal ParseDecimal(string raw) { return decimal.TryParse(raw, NumberStyles.Float, CultureInfo.InvariantCulture, out var value) ? value : 0m; } }