using System.Globalization; using System.Text.Json; using Domain.Entities.Stocks.ValueObject; namespace Infrastructure.StockData; /// /// KRX OpenAPI(data-dbg.krx.co.kr) 일반상품(석유/금/배출권) 일별매매 응답 파서 — 순수 C# (System.Text.Json). /// 응답 골격: { "OutBlock_1": [ { ...string values }, ... ] }. 모든 값은 문자열, 천단위 콤마 가능, 결측치는 "" 또는 "-". /// 시장별 응답 shape 이 다르다: /// - 금(gold)/배출권(ets): ISU_CD/ISU_NM + TDD_CLSPRC/OPNPRC/HGPRC/LWPRC(주식형 OHLC) + CMPPREVDD_PRC(전일대비)·FLUC_RT(등락률). /// - 석유(oil): ISU_CD 없음 → OIL_NM(유종구분)을 Code·Name 으로 사용. OHLC 미제공(0), 전일대비·등락률 없음(null). /// 가격은 WT_AVG_PRC(가중평균가격_경쟁)를 Close 로, WT_DIS_AVG_PRC(가중평균가격_협의)를 WeightedDiscussionPrice 로. /// 시장은 호출 엔드포인트(commodityMarket)로 결정. /// public static class KrxCommodityParser { /// 일반상품 일별매매 1행 (등락은 금/배출권만, 협의가격은 석유만 — 나머지는 null) public sealed record CommodityRow( CommodityMarket CommodityMarket, string Code, string Name, DateOnly TradeDate, decimal Close, decimal Open, decimal High, decimal Low, decimal? ChangeAmount, decimal? ChangeRate, decimal? WeightedDiscussionPrice, long Volume, long TradeValue ); /// oil/gold/ets_bydd_trd 파싱. commodityMarket 은 호출 엔드포인트로 결정, 석유 전용/금·배출권 전용 필드는 조건 분기. public static IReadOnlyList ParseDaily(string json, CommodityMarket commodityMarket) { using var doc = JsonDocument.Parse(json); var rows = new List(); if (!doc.RootElement.TryGetProperty("OutBlock_1", out var block) || block.ValueKind != JsonValueKind.Array) { return rows; } foreach (var item in block.EnumerateArray()) { var tradeDate = ParseDate(GetString(item, "BAS_DD")); if (tradeDate is null) { continue; } if (commodityMarket == CommodityMarket.Oil) { var oilName = ToNull(GetString(item, "OIL_NM")); if (oilName is null) { continue; } rows.Add(new CommodityRow( commodityMarket, oilName, oilName, tradeDate.Value, ParseDecimal(GetString(item, "WT_AVG_PRC")), 0m, 0m, 0m, null, null, ParseNullableDecimal(GetString(item, "WT_DIS_AVG_PRC")), ParseLong(GetString(item, "ACC_TRDVOL")), ParseLong(GetString(item, "ACC_TRDVAL")) )); } else { var code = ToNull(GetString(item, "ISU_CD")); var name = GetString(item, "ISU_NM"); if (code is null || name.Length == 0) { continue; } rows.Add(new CommodityRow( commodityMarket, code, name, tradeDate.Value, ParseDecimal(GetString(item, "TDD_CLSPRC")), ParseDecimal(GetString(item, "TDD_OPNPRC")), ParseDecimal(GetString(item, "TDD_HGPRC")), ParseDecimal(GetString(item, "TDD_LWPRC")), ParseNullableDecimal(GetString(item, "CMPPREVDD_PRC")), ParseNullableDecimal(GetString(item, "FLUC_RT")), null, ParseLong(GetString(item, "ACC_TRDVOL")), ParseLong(GetString(item, "ACC_TRDVAL")) )); } } return rows; } 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 }; } /// 빈 문자열/"-" 은 null, 나머지는 trim 값 private static string? ToNull(string raw) { return raw.Length == 0 || raw == "-" ? null : raw; } private static DateOnly? ParseDate(string raw) { return DateOnly.TryParseExact(raw, "yyyyMMdd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var date) ? date : null; } private static long ParseLong(string raw) { return long.TryParse(raw, NumberStyles.Integer | NumberStyles.AllowThousands, CultureInfo.InvariantCulture, out var value) ? value : 0L; } /// 가격 문자열 → decimal 그대로. "" 나 "-" 등 파싱 실패는 0. private static decimal ParseDecimal(string raw) { return decimal.TryParse(raw, NumberStyles.Float | NumberStyles.AllowThousands, CultureInfo.InvariantCulture, out var value) ? value : 0m; } /// 전일대비/등락률/협의가격 — "" 나 "-" 등 파싱 실패는 null (0 과 구분, 미제공은 값 없음) private static decimal? ParseNullableDecimal(string raw) { return decimal.TryParse(raw, NumberStyles.Float | NumberStyles.AllowThousands, CultureInfo.InvariantCulture, out var value) ? value : null; } }