using System.Text.Json;
namespace Infrastructure.StockData;
///
/// Yahoo Finance v8 chart JSON 파서 — 순수 C#(System.Text.Json). 지수·종목 공용.
/// chart.result[0].meta 에서 종가(regularMarketPrice)·고저(regularMarketDayHigh/Low)·거래량(regularMarketVolume)·
/// 거래시각(regularMarketTime, gmtoffset)을 뽑는다. Open 은 meta 에 없어 종가로 대체(화면 미표시).
/// 전일 대비는 스냅샷 엔티티가 직전 저장 종가(PrevClose)로 산출하므로 여기서 다루지 않는다(Stooq 파서와 동일 계약).
///
public static class YahooChartParser
{
/// 시세 1행 (외부 소스 최신 종가 기준)
public sealed record YahooQuote(
string Symbol,
DateOnly TradeDate,
decimal Open,
decimal High,
decimal Low,
decimal Close,
long Volume
);
/// 파싱 실패(빈 응답·result 없음·종가 없음·시각 없음)는 null. requestedSymbol 은 meta.symbol 결측 시 폴백.
public static YahooQuote? Parse(string? json, string requestedSymbol)
{
if (string.IsNullOrWhiteSpace(json))
{
return null;
}
try
{
using var doc = JsonDocument.Parse(json);
if (!doc.RootElement.TryGetProperty("chart", out var chart) || !chart.TryGetProperty("result", out var result))
{
return null;
}
if (result.ValueKind != JsonValueKind.Array || result.GetArrayLength() == 0)
{
return null;
}
if (!result[0].TryGetProperty("meta", out var meta))
{
return null;
}
if (!TryDecimal(meta, "regularMarketPrice", out var close))
{
return null;
}
if (!TryInt64(meta, "regularMarketTime", out var epoch) || epoch <= 0)
{
return null;
}
var high = TryDecimal(meta, "regularMarketDayHigh", out var h) ? h : close;
var low = TryDecimal(meta, "regularMarketDayLow", out var l) ? l : close;
var volume = TryInt64(meta, "regularMarketVolume", out var v) ? v : 0L;
var gmt = TryInt64(meta, "gmtoffset", out var g) ? g : 0L;
var open = close; // meta 에 시가 없음 — 화면 미표시라 종가로 대체
var tradeDate = DateOnly.FromDateTime(DateTimeOffset.FromUnixTimeSeconds(epoch).ToOffset(TimeSpan.FromSeconds(gmt)).DateTime);
var symbol = meta.TryGetProperty("symbol", out var s) && s.ValueKind == JsonValueKind.String
? s.GetString()!
: requestedSymbol;
return new YahooQuote(symbol.Trim().ToLowerInvariant(), tradeDate, open, high, low, close, volume);
}
catch (JsonException)
{
return null;
}
}
///
/// 일별 종가 시계열(스파크라인용) 추출 — chart.result[0].indicators.quote[0].close 배열의 비-null 값을 과거→최신 순으로 반환.
/// 결측(휴장/미체결)은 null 로 오므로 제외한다. maxPoints 초과 시 최신 maxPoints 개만 유지. 실패·빈 응답은 빈 리스트.
///
public static IReadOnlyList ParseCloseSeries(string? json, int maxPoints)
{
if (string.IsNullOrWhiteSpace(json) || maxPoints <= 0)
{
return [];
}
try
{
using var doc = JsonDocument.Parse(json);
if (!doc.RootElement.TryGetProperty("chart", out var chart)
|| !chart.TryGetProperty("result", out var result)
|| result.ValueKind != JsonValueKind.Array
|| result.GetArrayLength() == 0)
{
return [];
}
if (!result[0].TryGetProperty("indicators", out var indicators)
|| !indicators.TryGetProperty("quote", out var quote)
|| quote.ValueKind != JsonValueKind.Array
|| quote.GetArrayLength() == 0
|| !quote[0].TryGetProperty("close", out var closeArr)
|| closeArr.ValueKind != JsonValueKind.Array)
{
return [];
}
var closes = new List();
foreach (var el in closeArr.EnumerateArray())
{
if (el.ValueKind == JsonValueKind.Number && el.TryGetDecimal(out var c))
{
closes.Add(c);
}
}
return closes.Count > maxPoints ? closes.GetRange(closes.Count - maxPoints, maxPoints) : closes;
}
catch (JsonException)
{
return [];
}
}
private static bool TryDecimal(JsonElement obj, string name, out decimal value)
{
value = 0m;
return obj.TryGetProperty(name, out var e) && e.ValueKind == JsonValueKind.Number && e.TryGetDecimal(out value);
}
private static bool TryInt64(JsonElement obj, string name, out long value)
{
value = 0L;
return obj.TryGetProperty(name, out var e) && e.ValueKind == JsonValueKind.Number && e.TryGetInt64(out value);
}
}