| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188 |
- using System.Globalization;
- using System.Text.Json;
- using Domain.Entities.Stocks.ValueObject;
- namespace Infrastructure.StockData;
- /// <summary>
- /// 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)
- /// </summary>
- public static class KrxEtpParser
- {
- /// <summary>증권상품 일별매매 1행 (등락률은 소수 % 그대로 — Bp 아님)</summary>
- 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
- );
- /// <summary>etf/etn/elw_bydd_trd 파싱. etpType 은 호출 엔드포인트로 결정, 유형별 필드는 조건 분기.</summary>
- public static IReadOnlyList<EtpRow> ParseDaily(string json, EtpType etpType)
- {
- using var doc = JsonDocument.Parse(json);
- var rows = new List<EtpRow>();
- 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
- };
- }
- /// <summary>단축코드 정규화 — 'A' 등 프리픽스로 6자리 초과분은 뒤 6자리만 취한다. ELW(58K001) 처럼 영숫자도 허용, 정확히 6자만 유효.</summary>
- 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;
- }
- /// <summary>빈 문자열/"-" 은 null, 나머지는 trim 값</summary>
- 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;
- }
- /// <summary>가격/등락률(%) 문자열 → decimal 그대로. "" 나 "-" 등 파싱 실패는 0.</summary>
- private static decimal ParseDecimal(string raw)
- {
- return decimal.TryParse(raw, NumberStyles.Float | NumberStyles.AllowThousands, CultureInfo.InvariantCulture, out var value) ? value : 0m;
- }
- /// <summary>NAV/기초지수 종가 등 — "" 나 "-" 등 파싱 실패는 null (0 과 구분)</summary>
- private static decimal? ParseNullableDecimal(string raw)
- {
- return decimal.TryParse(raw, NumberStyles.Float | NumberStyles.AllowThousands, CultureInfo.InvariantCulture, out var value) ? value : null;
- }
- }
|