DataGoKrStockParser.cs 7.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214
  1. using System.Globalization;
  2. using System.Text.Json;
  3. namespace Infrastructure.StockData;
  4. /// <summary>
  5. /// 공공데이터포털(금융위) 표준 JSON 응답 파서 — 순수 C# (System.Text.Json).
  6. /// 응답 골격: response.header(resultCode/resultMsg) + response.body(totalCount, items.item[]).
  7. /// item 이 단건이면 객체, 0건이면 items 가 빈 문자열로 오는 케이스까지 흡수한다.
  8. /// </summary>
  9. public static class DataGoKrStockParser
  10. {
  11. /// <summary>KRX 상장종목정보 (GetKrxListedInfoService/getItemInfo) 1행</summary>
  12. public sealed record ListedItem(string Code, string? Isin, string Name, string MarketName, DateOnly BaseDate);
  13. /// <summary>주식시세정보 (GetStockSecuritiesInfoService/getStockPriceInfo) 1행</summary>
  14. public sealed record DailyPriceItem(
  15. string Code,
  16. DateOnly TradingDate,
  17. int Open,
  18. int High,
  19. int Low,
  20. int Close,
  21. long Volume,
  22. long TradingValue,
  23. int ChangeAmount,
  24. decimal ChangeRate,
  25. long? MarketCap,
  26. long? ListedShares
  27. );
  28. public static (IReadOnlyList<ListedItem> Items, int TotalCount) ParseListedItems(string json)
  29. {
  30. using var doc = JsonDocument.Parse(json);
  31. var body = GetBody(doc);
  32. var items = new List<ListedItem>();
  33. foreach (var item in EnumerateItems(body))
  34. {
  35. var code = NormalizeCode(GetString(item, "srtnCd"));
  36. var name = GetString(item, "itmsNm");
  37. var market = GetString(item, "mrktCtg");
  38. var baseDate = ParseDate(GetString(item, "basDt"));
  39. if (code is null || name.Length == 0 || baseDate is null)
  40. {
  41. continue;
  42. }
  43. var isin = GetString(item, "isinCd");
  44. items.Add(new ListedItem(code, isin.Length == 12 ? isin : null, name, market, baseDate.Value));
  45. }
  46. return (items, GetTotalCount(body));
  47. }
  48. public static (IReadOnlyList<DailyPriceItem> Items, int TotalCount) ParseDailyPrices(string json)
  49. {
  50. using var doc = JsonDocument.Parse(json);
  51. var body = GetBody(doc);
  52. var items = new List<DailyPriceItem>();
  53. foreach (var item in EnumerateItems(body))
  54. {
  55. var code = NormalizeCode(GetString(item, "srtnCd"));
  56. var tradingDate = ParseDate(GetString(item, "basDt"));
  57. if (code is null || tradingDate is null)
  58. {
  59. continue;
  60. }
  61. items.Add(new DailyPriceItem(
  62. code,
  63. tradingDate.Value,
  64. ParseInt(GetString(item, "mkp")),
  65. ParseInt(GetString(item, "hipr")),
  66. ParseInt(GetString(item, "lopr")),
  67. ParseInt(GetString(item, "clpr")),
  68. ParseLong(GetString(item, "trqu")),
  69. ParseLong(GetString(item, "trPrc")),
  70. ParseInt(GetString(item, "vs")),
  71. ParseDecimal(GetString(item, "fltRt")),
  72. ParseNullableLong(GetString(item, "mrktTotAmt")),
  73. ParseNullableLong(GetString(item, "lstgStCnt"))
  74. ));
  75. }
  76. return (items, GetTotalCount(body));
  77. }
  78. private static JsonElement GetBody(JsonDocument doc)
  79. {
  80. if (!doc.RootElement.TryGetProperty("response", out var response))
  81. {
  82. throw new InvalidOperationException("data.go.kr 응답 형식 오류 — response 노드 없음");
  83. }
  84. if (response.TryGetProperty("header", out var header))
  85. {
  86. var resultCode = header.TryGetProperty("resultCode", out var rc) ? rc.GetString() : null;
  87. if (resultCode is not null && resultCode != "00")
  88. {
  89. var resultMsg = header.TryGetProperty("resultMsg", out var rm) ? rm.GetString() : null;
  90. throw new InvalidOperationException($"data.go.kr 오류 응답 — resultCode={resultCode}, resultMsg={resultMsg}");
  91. }
  92. }
  93. if (!response.TryGetProperty("body", out var body))
  94. {
  95. throw new InvalidOperationException("data.go.kr 응답 형식 오류 — body 노드 없음");
  96. }
  97. return body;
  98. }
  99. private static IEnumerable<JsonElement> EnumerateItems(JsonElement body)
  100. {
  101. // 0건이면 items 가 "" (빈 문자열) 로 온다
  102. if (!body.TryGetProperty("items", out var items) || items.ValueKind != JsonValueKind.Object)
  103. {
  104. yield break;
  105. }
  106. if (!items.TryGetProperty("item", out var item))
  107. {
  108. yield break;
  109. }
  110. if (item.ValueKind == JsonValueKind.Array)
  111. {
  112. foreach (var element in item.EnumerateArray())
  113. {
  114. yield return element;
  115. }
  116. }
  117. else if (item.ValueKind == JsonValueKind.Object)
  118. {
  119. // 단건이면 배열이 아닌 객체로 온다
  120. yield return item;
  121. }
  122. }
  123. private static int GetTotalCount(JsonElement body)
  124. {
  125. if (!body.TryGetProperty("totalCount", out var total))
  126. {
  127. return 0;
  128. }
  129. return total.ValueKind switch
  130. {
  131. JsonValueKind.Number => total.GetInt32(),
  132. JsonValueKind.String => ParseInt(total.GetString() ?? ""),
  133. _ => 0
  134. };
  135. }
  136. private static string GetString(JsonElement element, string name)
  137. {
  138. if (!element.TryGetProperty(name, out var value))
  139. {
  140. return string.Empty;
  141. }
  142. return value.ValueKind switch
  143. {
  144. JsonValueKind.String => value.GetString()?.Trim() ?? string.Empty,
  145. JsonValueKind.Number => value.GetRawText(),
  146. _ => string.Empty
  147. };
  148. }
  149. /// <summary>단축코드 정규화 — 'A' 프리픽스 등 6자리 초과분은 뒤 6자리만 취하고, 6자리 숫자만 유효</summary>
  150. private static string? NormalizeCode(string raw)
  151. {
  152. if (raw.Length > 6)
  153. {
  154. raw = raw[^6..];
  155. }
  156. if (raw is not { Length: 6 } || raw.Any(c => !char.IsAsciiDigit(c)))
  157. {
  158. return null;
  159. }
  160. return raw;
  161. }
  162. private static DateOnly? ParseDate(string raw)
  163. {
  164. return DateOnly.TryParseExact(raw, "yyyyMMdd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var date) ? date : null;
  165. }
  166. private static int ParseInt(string raw)
  167. {
  168. return int.TryParse(raw, NumberStyles.Integer | NumberStyles.AllowThousands, CultureInfo.InvariantCulture, out var value) ? value : 0;
  169. }
  170. private static long ParseLong(string raw)
  171. {
  172. return long.TryParse(raw, NumberStyles.Integer | NumberStyles.AllowThousands, CultureInfo.InvariantCulture, out var value) ? value : 0L;
  173. }
  174. private static long? ParseNullableLong(string raw)
  175. {
  176. return long.TryParse(raw, NumberStyles.Integer | NumberStyles.AllowThousands, CultureInfo.InvariantCulture, out var value) ? value : null;
  177. }
  178. private static decimal ParseDecimal(string raw)
  179. {
  180. return decimal.TryParse(raw, NumberStyles.Float, CultureInfo.InvariantCulture, out var value) ? value : 0m;
  181. }
  182. }