| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163 |
- using System.Globalization;
- using System.Text.Json;
- using System.Text.RegularExpressions;
- using Domain.Entities.Stocks.ValueObject;
- namespace Infrastructure.StockData;
- /// <summary>
- /// KRX OpenAPI(data-dbg.krx.co.kr) 옵션(일반/주식유가/주식코스닥) 일별매매 응답 파서 — 순수 C# (System.Text.Json).
- /// 응답 골격: { "OutBlock_1": [ { ...string values }, ... ] }. 모든 값은 문자열, 천단위 콤마 가능, 결측치는 "" 또는 "-".
- /// 옵션은 선물과 달리 콜/풋(RGHT_TP_NM)·행사가(strike)·내재변동성(IMP_VOLT)을 갖는다:
- /// - 가격: TDD_CLSPRC/OPNPRC/HGPRC/LWPRC (미체결 시 "") + CMPPREVDD_PRC(대비)
- /// - 옵션 고유: RGHT_TP_NM(CALL/PUT → RightType), IMP_VOLT(내재변동성 nullable), NXTDD_BAS_PRC(익일정산가 nullable), ACC_OPNINT_QTY(미결제약정 nullable)
- /// - 행사가(strike): 응답 필드가 없어 ISU_NM 에서 파싱한다. 콜/풋 표기(C/P) + 6자리 만기월 뒤의 첫 수치가 행사가:
- /// 지수옵션 "미니코스피 C 202501 232.5 (정규)" → 232.5, 주식옵션 "HD한국조선 C 202501 150,000( 10)" → 150000. 추출 실패 시 null.
- /// 종목코드(ISU_CD)는 8자리 영숫자(예: 205W1232) — 6자리 정규화 없이 원문 유지. OptionsKind 는 호출 엔드포인트로 결정.
- /// </summary>
- public static class KrxOptionsParser
- {
- // ISU_NM 에서 " C/P 202501 <행사가>" 패턴의 행사가를 추출. 만기월(6자리) 뒤 첫 수치(콤마/소수 허용), 뒤따르는 "(승수)" 는 무시.
- private static readonly Regex StrikeRegex = new(
- @"\s[CP]\s+\d{6}\s+([\d,]+(?:\.\d+)?)",
- RegexOptions.Compiled | RegexOptions.CultureInvariant);
- /// <summary>옵션 일별매매 1행 (미체결가·행사가·내재변동성·미결제약정은 nullable)</summary>
- public sealed record OptionsRow(
- OptionsKind OptionsKind,
- string IsuCode,
- string IsuName,
- DateOnly TradeDate,
- OptionRightType RightType,
- decimal? StrikePrice,
- decimal Close,
- decimal Open,
- decimal High,
- decimal Low,
- decimal ChangeAmount,
- decimal? ImpliedVolatility,
- decimal? NextDayBasePrice,
- long Volume,
- long TradeValue,
- long? OpenInterest,
- string? ProductName
- );
- /// <summary>opt/eqsop/eqkop_bydd_trd 파싱. optionsKind 는 호출 엔드포인트로 결정.</summary>
- public static IReadOnlyList<OptionsRow> ParseDaily(string json, OptionsKind optionsKind)
- {
- using var doc = JsonDocument.Parse(json);
- var rows = new List<OptionsRow>();
- if (!doc.RootElement.TryGetProperty("OutBlock_1", out var block) || block.ValueKind != JsonValueKind.Array)
- {
- return rows;
- }
- foreach (var item in block.EnumerateArray())
- {
- var isuCode = ToNull(GetString(item, "ISU_CD"));
- var isuName = GetString(item, "ISU_NM");
- var tradeDate = ParseDate(GetString(item, "BAS_DD"));
- var rightType = ParseRightType(GetString(item, "RGHT_TP_NM"));
- if (isuCode is null || isuName.Length == 0 || tradeDate is null || rightType is null)
- {
- continue;
- }
- rows.Add(new OptionsRow(
- optionsKind,
- isuCode,
- isuName,
- tradeDate.Value,
- rightType.Value,
- ExtractStrike(isuName),
- 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")),
- ParseNullableDecimal(GetString(item, "IMP_VOLT")),
- ParseNullableDecimal(GetString(item, "NXTDD_BAS_PRC")),
- ParseLong(GetString(item, "ACC_TRDVOL")),
- ParseLong(GetString(item, "ACC_TRDVAL")),
- ParseNullableLong(GetString(item, "ACC_OPNINT_QTY")),
- ToNull(GetString(item, "PROD_NM"))
- ));
- }
- return rows;
- }
- /// <summary>RGHT_TP_NM("CALL"/"PUT") → OptionRightType. 그 외/빈값은 null (해당 행 skip).</summary>
- private static OptionRightType? ParseRightType(string raw)
- {
- return raw.ToUpperInvariant() switch
- {
- "CALL" => OptionRightType.Call,
- "PUT" => OptionRightType.Put,
- _ => null
- };
- }
- /// <summary>ISU_NM 에서 행사가 추출. 만기월(6자리) 뒤 첫 수치(콤마 제거), 승수 "(...)" 는 무시. 실패 시 null.</summary>
- private static decimal? ExtractStrike(string isuName)
- {
- var match = StrikeRegex.Match(isuName);
- if (!match.Success)
- {
- return null;
- }
- return ParseNullableDecimal(match.Groups[1].Value);
- }
- 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>빈 문자열/"-" 은 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>행사가/내재변동성/익일정산가 등 — "" 나 "-" 등 파싱 실패는 null (0 과 구분).</summary>
- private static decimal? ParseNullableDecimal(string raw)
- {
- return decimal.TryParse(raw, NumberStyles.Float | NumberStyles.AllowThousands, CultureInfo.InvariantCulture, out var value) ? value : null;
- }
- }
|