YahooChartParser.cs 5.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142
  1. using System.Text.Json;
  2. namespace Infrastructure.StockData;
  3. /// <summary>
  4. /// Yahoo Finance v8 chart JSON 파서 — 순수 C#(System.Text.Json). 지수·종목 공용.
  5. /// chart.result[0].meta 에서 종가(regularMarketPrice)·고저(regularMarketDayHigh/Low)·거래량(regularMarketVolume)·
  6. /// 거래시각(regularMarketTime, gmtoffset)을 뽑는다. Open 은 meta 에 없어 종가로 대체(화면 미표시).
  7. /// 전일 대비는 스냅샷 엔티티가 직전 저장 종가(PrevClose)로 산출하므로 여기서 다루지 않는다(Stooq 파서와 동일 계약).
  8. /// </summary>
  9. public static class YahooChartParser
  10. {
  11. /// <summary>시세 1행 (외부 소스 최신 종가 기준)</summary>
  12. public sealed record YahooQuote(
  13. string Symbol,
  14. DateOnly TradeDate,
  15. decimal Open,
  16. decimal High,
  17. decimal Low,
  18. decimal Close,
  19. long Volume
  20. );
  21. /// <summary>파싱 실패(빈 응답·result 없음·종가 없음·시각 없음)는 null. requestedSymbol 은 meta.symbol 결측 시 폴백.</summary>
  22. public static YahooQuote? Parse(string? json, string requestedSymbol)
  23. {
  24. if (string.IsNullOrWhiteSpace(json))
  25. {
  26. return null;
  27. }
  28. try
  29. {
  30. using var doc = JsonDocument.Parse(json);
  31. if (!doc.RootElement.TryGetProperty("chart", out var chart) || !chart.TryGetProperty("result", out var result))
  32. {
  33. return null;
  34. }
  35. if (result.ValueKind != JsonValueKind.Array || result.GetArrayLength() == 0)
  36. {
  37. return null;
  38. }
  39. if (!result[0].TryGetProperty("meta", out var meta))
  40. {
  41. return null;
  42. }
  43. if (!TryDecimal(meta, "regularMarketPrice", out var close))
  44. {
  45. return null;
  46. }
  47. if (!TryInt64(meta, "regularMarketTime", out var epoch) || epoch <= 0)
  48. {
  49. return null;
  50. }
  51. var high = TryDecimal(meta, "regularMarketDayHigh", out var h) ? h : close;
  52. var low = TryDecimal(meta, "regularMarketDayLow", out var l) ? l : close;
  53. var volume = TryInt64(meta, "regularMarketVolume", out var v) ? v : 0L;
  54. var gmt = TryInt64(meta, "gmtoffset", out var g) ? g : 0L;
  55. var open = close; // meta 에 시가 없음 — 화면 미표시라 종가로 대체
  56. var tradeDate = DateOnly.FromDateTime(DateTimeOffset.FromUnixTimeSeconds(epoch).ToOffset(TimeSpan.FromSeconds(gmt)).DateTime);
  57. var symbol = meta.TryGetProperty("symbol", out var s) && s.ValueKind == JsonValueKind.String
  58. ? s.GetString()!
  59. : requestedSymbol;
  60. return new YahooQuote(symbol.Trim().ToLowerInvariant(), tradeDate, open, high, low, close, volume);
  61. }
  62. catch (JsonException)
  63. {
  64. return null;
  65. }
  66. }
  67. /// <summary>
  68. /// 일별 종가 시계열(스파크라인용) 추출 — chart.result[0].indicators.quote[0].close 배열의 비-null 값을 과거→최신 순으로 반환.
  69. /// 결측(휴장/미체결)은 null 로 오므로 제외한다. maxPoints 초과 시 최신 maxPoints 개만 유지. 실패·빈 응답은 빈 리스트.
  70. /// </summary>
  71. public static IReadOnlyList<decimal> ParseCloseSeries(string? json, int maxPoints)
  72. {
  73. if (string.IsNullOrWhiteSpace(json) || maxPoints <= 0)
  74. {
  75. return [];
  76. }
  77. try
  78. {
  79. using var doc = JsonDocument.Parse(json);
  80. if (!doc.RootElement.TryGetProperty("chart", out var chart)
  81. || !chart.TryGetProperty("result", out var result)
  82. || result.ValueKind != JsonValueKind.Array
  83. || result.GetArrayLength() == 0)
  84. {
  85. return [];
  86. }
  87. if (!result[0].TryGetProperty("indicators", out var indicators)
  88. || !indicators.TryGetProperty("quote", out var quote)
  89. || quote.ValueKind != JsonValueKind.Array
  90. || quote.GetArrayLength() == 0
  91. || !quote[0].TryGetProperty("close", out var closeArr)
  92. || closeArr.ValueKind != JsonValueKind.Array)
  93. {
  94. return [];
  95. }
  96. var closes = new List<decimal>();
  97. foreach (var el in closeArr.EnumerateArray())
  98. {
  99. if (el.ValueKind == JsonValueKind.Number && el.TryGetDecimal(out var c))
  100. {
  101. closes.Add(c);
  102. }
  103. }
  104. return closes.Count > maxPoints ? closes.GetRange(closes.Count - maxPoints, maxPoints) : closes;
  105. }
  106. catch (JsonException)
  107. {
  108. return [];
  109. }
  110. }
  111. private static bool TryDecimal(JsonElement obj, string name, out decimal value)
  112. {
  113. value = 0m;
  114. return obj.TryGetProperty(name, out var e) && e.ValueKind == JsonValueKind.Number && e.TryGetDecimal(out value);
  115. }
  116. private static bool TryInt64(JsonElement obj, string name, out long value)
  117. {
  118. value = 0L;
  119. return obj.TryGetProperty(name, out var e) && e.ValueKind == JsonValueKind.Number && e.TryGetInt64(out value);
  120. }
  121. }