using System.Globalization;
using System.Text.Json;
using System.Text.RegularExpressions;
using Domain.Entities.Stocks.ValueObject;
namespace Infrastructure.StockData;
///
/// 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 는 호출 엔드포인트로 결정.
/// 일반옵션(opt_bydd_trd)은 MKT_NM 필드 없이 동일 ISU_CD 로 정규·야간 세션 행이 각각 온다 (스펙·라이브 확인 — 18,908행 = 9,454 코드 × 2).
/// 세션 표기는 종목명 "(정규)"/"(야간)" 이므로 MKT_NM(있으면) → 종목명 순으로 "야간" 을 찾아 Session 매핑, 그 외/부재는 Regular.
/// 위클리 일부(예: BAK...)는 정규·야간 두 행 모두 세션 표기가 없어 구분 불가 → 둘 다 Regular (last-wins, 설계된 degrade).
///
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);
/// 옵션 일별매매 1행 (미체결가·행사가·내재변동성·미결제약정은 nullable)
public sealed record OptionsRow(
OptionsKind OptionsKind,
string IsuCode,
string IsuName,
DateOnly TradeDate,
MarketSession Session,
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
);
/// opt/eqsop/eqkop_bydd_trd 파싱. optionsKind 는 호출 엔드포인트로 결정.
public static IReadOnlyList ParseDaily(string json, OptionsKind optionsKind)
{
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 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,
MapSession(GetString(item, "MKT_NM"), isuName),
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;
}
/// 옵션 응답엔 MKT_NM 이 없어 종목명 "(야간)" 표기로 세션 판정 (MKT_NM 이 오면 우선). "야간" 만 Night, 그 외/부재는 Regular.
private static MarketSession MapSession(string marketName, string isuName)
{
if (marketName.Contains("야간", StringComparison.Ordinal) || isuName.Contains("야간", StringComparison.Ordinal))
{
return MarketSession.Night;
}
return MarketSession.Regular;
}
/// RGHT_TP_NM("CALL"/"PUT") → OptionRightType. 그 외/빈값은 null (해당 행 skip).
private static OptionRightType? ParseRightType(string raw)
{
return raw.ToUpperInvariant() switch
{
"CALL" => OptionRightType.Call,
"PUT" => OptionRightType.Put,
_ => null
};
}
/// ISU_NM 에서 행사가 추출. 만기월(6자리) 뒤 첫 수치(콤마 제거), 승수 "(...)" 는 무시. 실패 시 null.
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
};
}
/// 빈 문자열/"-" 은 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;
}
/// 행사가/내재변동성/익일정산가 등 — "" 나 "-" 등 파싱 실패는 null (0 과 구분).
private static decimal? ParseNullableDecimal(string raw)
{
return decimal.TryParse(raw, NumberStyles.Float | NumberStyles.AllowThousands, CultureInfo.InvariantCulture, out var value) ? value : null;
}
}