using System.Globalization;
using System.Text.Json;
using Domain.Entities.Stocks.ValueObject;
namespace Infrastructure.StockData;
///
/// KRX OpenAPI(data-dbg.krx.co.kr) 증권상품(ETF/ETN/ELW) 일별매매 응답 파서 — 순수 C# (System.Text.Json). "KRX 증권상품(ETF/ETN/ELW) 수집".
/// 응답 골격: { "OutBlock_1": [ { ...string values }, ... ] }. 모든 값은 문자열, 천단위 콤마 포함 가능, 결측치는 "" 또는 "-".
/// 등락률(FLUC_RT)은 소수 % 그대로 저장한다 (EtpDailyTrade.ChangeRate 가 decimal % — KrxStockParser 와 동일 스케일, Bp 아님).
/// 유형별 필드 차이:
/// - ETF: FLUC_RT(자체), NAV, INVSTASST_NETASST_TOTAMT(순자산총액), IDX_IND_NM/OBJ_STKPRC_IDX(기초지수명·종가)
/// - ETN: FLUC_RT(자체), PER1SECU_INDIC_VAL(지표가치 IV → Nav), INDIC_VAL_AMT(지표가치총액 → NetAssetTotal), IDX_IND_NM/OBJ_STKPRC_IDX
/// - ELW: 자체 등락률 없음(ChangeRate=0), ULY_NM/ULY_PRC(기초자산명·종가). ISU_CD 가 영숫자 6자리(예: 58K001)
///
public static class KrxEtpParser
{
/// 증권상품 일별매매 1행 (등락률은 소수 % 그대로 — Bp 아님)
public sealed record EtpRow(
EtpType EtpType,
string Code,
string Name,
DateOnly TradeDate,
decimal Close,
decimal Open,
decimal High,
decimal Low,
decimal ChangeAmount,
decimal ChangeRate,
long Volume,
long TradeValue,
long? MarketCap,
long? ListedShares,
decimal? Nav,
long? NetAssetTotal,
string? BaseIndexName,
decimal? BaseIndexClose,
string? Underlying,
decimal? UnderlyingClose
);
/// etf/etn/elw_bydd_trd 파싱. etpType 은 호출 엔드포인트로 결정, 유형별 필드는 조건 분기.
public static IReadOnlyList ParseDaily(string json, EtpType etpType)
{
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 code = NormalizeCode(GetString(item, "ISU_CD"));
var name = GetString(item, "ISU_NM");
var tradeDate = ParseDate(GetString(item, "BAS_DD"));
if (code is null || name.Length == 0 || tradeDate is null)
{
continue;
}
// ETF/ETN 공통: NAV(ETF) 또는 IV(ETN), 순자산/지표가치총액, 기초지수명·종가
decimal? nav = null;
long? netAssetTotal = null;
string? baseIndexName = null;
decimal? baseIndexClose = null;
string? underlying = null;
decimal? underlyingClose = null;
var changeRate = 0m;
switch (etpType)
{
case EtpType.ETF:
changeRate = ParseDecimal(GetString(item, "FLUC_RT"));
nav = ParseNullableDecimal(GetString(item, "NAV"));
netAssetTotal = ParseNullableLong(GetString(item, "INVSTASST_NETASST_TOTAMT"));
baseIndexName = ToNull(GetString(item, "IDX_IND_NM"));
baseIndexClose = ParseNullableDecimal(GetString(item, "OBJ_STKPRC_IDX"));
break;
case EtpType.ETN:
changeRate = ParseDecimal(GetString(item, "FLUC_RT"));
nav = ParseNullableDecimal(GetString(item, "PER1SECU_INDIC_VAL"));
netAssetTotal = ParseNullableLong(GetString(item, "INDIC_VAL_AMT"));
baseIndexName = ToNull(GetString(item, "IDX_IND_NM"));
baseIndexClose = ParseNullableDecimal(GetString(item, "OBJ_STKPRC_IDX"));
break;
case EtpType.ELW:
// ELW 는 자체 등락률 없음 → ChangeRate=0. 기초자산 정보만.
underlying = ToNull(GetString(item, "ULY_NM"));
underlyingClose = ParseNullableDecimal(GetString(item, "ULY_PRC"));
break;
}
rows.Add(new EtpRow(
etpType,
code,
name,
tradeDate.Value,
ParseDecimal(GetString(item, "TDD_CLSPRC")),
ParseDecimal(GetString(item, "TDD_OPNPRC")),
ParseDecimal(GetString(item, "TDD_HGPRC")),
ParseDecimal(GetString(item, "TDD_LWPRC")),
ParseDecimal(GetString(item, "CMPPREVDD_PRC")),
changeRate,
ParseLong(GetString(item, "ACC_TRDVOL")),
ParseLong(GetString(item, "ACC_TRDVAL")),
ParseNullableLong(GetString(item, "MKTCAP")),
ParseNullableLong(GetString(item, "LIST_SHRS")),
nav,
netAssetTotal,
baseIndexName,
baseIndexClose,
underlying,
underlyingClose
));
}
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
};
}
/// 단축코드 정규화 — 'A' 등 프리픽스로 6자리 초과분은 뒤 6자리만 취한다. ELW(58K001) 처럼 영숫자도 허용, 정확히 6자만 유효.
private static string? NormalizeCode(string raw)
{
if (raw.Length > 6)
{
raw = raw[^6..];
}
if (raw is not { Length: 6 } || raw.Any(c => !char.IsLetterOrDigit(c)))
{
return null;
}
return raw;
}
/// 빈 문자열/"-" 은 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;
}
private static long? ParseNullableLong(string raw)
{
return long.TryParse(raw, NumberStyles.Integer | NumberStyles.AllowThousands, CultureInfo.InvariantCulture, out var value) ? value : null;
}
/// 가격/등락률(%) 문자열 → decimal 그대로. "" 나 "-" 등 파싱 실패는 0.
private static decimal ParseDecimal(string raw)
{
return decimal.TryParse(raw, NumberStyles.Float | NumberStyles.AllowThousands, CultureInfo.InvariantCulture, out var value) ? value : 0m;
}
/// NAV/기초지수 종가 등 — "" 나 "-" 등 파싱 실패는 null (0 과 구분)
private static decimal? ParseNullableDecimal(string raw)
{
return decimal.TryParse(raw, NumberStyles.Float | NumberStyles.AllowThousands, CultureInfo.InvariantCulture, out var value) ? value : null;
}
}