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;
}
}