import type { DashboardRealtimeTradeTick, DashboardStockOrderBookResponse, } from "@/features/trade/types/trade.types"; import { shouldUseAfterHoursSinglePriceTr, shouldUseExpectedExecutionTr, type DomesticKisSession, } from "@/lib/kis/domestic-market-session"; const REALTIME_SIGN_NEGATIVE = new Set(["4", "5"]); const EXECUTED_REALTIME_TRADE_TR_IDS = new Set([ "H0STCNT0", "H0STOUP0", "H0UNCNT0", "H0NXCNT0", ]); const EXPECTED_REALTIME_TRADE_TR_IDS = new Set([ "H0STANC0", "H0STOAC0", "H0UNANC0", "H0NXANC0", ]); const TICK_FIELD_INDEX = { symbol: 0, tickTime: 1, price: 2, sign: 3, change: 4, changeRate: 5, open: 7, high: 8, low: 9, askPrice1: 10, bidPrice1: 11, tradeVolume: 12, accumulatedVolume: 13, sellExecutionCount: 15, buyExecutionCount: 16, netBuyExecutionCount: 17, tradeStrength: 18, executionClassCode: 21, } as const; const TRADE_TR_ID = "H0STCNT0"; const TRADE_TR_ID_EXPECTED = "H0STANC0"; const TRADE_TR_ID_OVERTIME = "H0STOUP0"; const TRADE_TR_ID_OVERTIME_EXPECTED = "H0STOAC0"; const TRADE_TR_ID_TOTAL = "H0UNCNT0"; const TRADE_TR_ID_TOTAL_EXPECTED = "H0UNANC0"; const ORDERBOOK_TR_ID = "H0STASP0"; const ORDERBOOK_TR_ID_OVERTIME = "H0STOAA0"; export function parseKisRealtimeTickBatch(raw: string, expectedSymbol: string) { if (!/^([01])\|/.test(raw)) return [] as DashboardRealtimeTradeTick[]; const parts = raw.split("|"); if (parts.length < 4) return [] as DashboardRealtimeTradeTick[]; const receivedTrId = parts[1]; const isExecutedTick = EXECUTED_REALTIME_TRADE_TR_IDS.has(receivedTrId); const isExpectedTick = EXPECTED_REALTIME_TRADE_TR_IDS.has(receivedTrId); if (!isExecutedTick || isExpectedTick) { return [] as DashboardRealtimeTradeTick[]; } const tickCount = Number(parts[2] ?? "1"); const values = parts[3].split("^"); if (values.length === 0) return [] as DashboardRealtimeTradeTick[]; const parsedCount = Number.isInteger(tickCount) && tickCount > 0 ? tickCount : 1; const fieldsPerTick = Math.floor(values.length / parsedCount); if (fieldsPerTick <= TICK_FIELD_INDEX.tradeStrength) { return [] as DashboardRealtimeTradeTick[]; } const normalizedExpected = normalizeDomesticSymbol(expectedSymbol); const ticks: DashboardRealtimeTradeTick[] = []; for (let index = 0; index < parsedCount; index++) { const base = index * fieldsPerTick; const symbol = readString(values, base + TICK_FIELD_INDEX.symbol); const normalizedSymbol = normalizeDomesticSymbol(symbol); if (normalizedSymbol !== normalizedExpected) { continue; } const price = readNumber(values, base + TICK_FIELD_INDEX.price); if (!Number.isFinite(price) || price <= 0) continue; const sign = readString(values, base + TICK_FIELD_INDEX.sign); const rawChange = readNumber(values, base + TICK_FIELD_INDEX.change); const rawChangeRate = readNumber( values, base + TICK_FIELD_INDEX.changeRate, ); const change = REALTIME_SIGN_NEGATIVE.has(sign) ? -Math.abs(rawChange) : rawChange; const changeRate = REALTIME_SIGN_NEGATIVE.has(sign) ? -Math.abs(rawChangeRate) : rawChangeRate; ticks.push({ symbol: normalizedExpected, tickTime: readString(values, base + TICK_FIELD_INDEX.tickTime), price, change, changeRate, tradeVolume: readNumber(values, base + TICK_FIELD_INDEX.tradeVolume), accumulatedVolume: readNumber( values, base + TICK_FIELD_INDEX.accumulatedVolume, ), tradeStrength: readNumber(values, base + TICK_FIELD_INDEX.tradeStrength), askPrice1: readNumber(values, base + TICK_FIELD_INDEX.askPrice1), bidPrice1: readNumber(values, base + TICK_FIELD_INDEX.bidPrice1), sellExecutionCount: readNumber( values, base + TICK_FIELD_INDEX.sellExecutionCount, ), buyExecutionCount: readNumber( values, base + TICK_FIELD_INDEX.buyExecutionCount, ), netBuyExecutionCount: readNumber( values, base + TICK_FIELD_INDEX.netBuyExecutionCount, ), executionClassCode: readString( values, base + TICK_FIELD_INDEX.executionClassCode, ), open: readNumber(values, base + TICK_FIELD_INDEX.open), high: readNumber(values, base + TICK_FIELD_INDEX.high), low: readNumber(values, base + TICK_FIELD_INDEX.low), }); } return ticks; } /** * @description 장 구간/시장별 누락을 줄이기 위해 TR ID를 우선순위 배열로 반환합니다. */ export function resolveTradeTrIds( env: "real" | "mock", session: DomesticKisSession, ) { if (env === "mock") return [TRADE_TR_ID]; if (shouldUseAfterHoursSinglePriceTr(session)) { return uniqueTrIds([ TRADE_TR_ID_OVERTIME, TRADE_TR_ID_OVERTIME_EXPECTED, TRADE_TR_ID_TOTAL, TRADE_TR_ID_TOTAL_EXPECTED, ]); } if (shouldUseExpectedExecutionTr(session)) { return uniqueTrIds([ TRADE_TR_ID_EXPECTED, TRADE_TR_ID_TOTAL_EXPECTED, TRADE_TR_ID, TRADE_TR_ID_TOTAL, ]); } if (session === "afterCloseFixedPrice") { return uniqueTrIds([ TRADE_TR_ID, TRADE_TR_ID_TOTAL, TRADE_TR_ID_OVERTIME, TRADE_TR_ID_OVERTIME_EXPECTED, TRADE_TR_ID_TOTAL_EXPECTED, ]); } return uniqueTrIds([TRADE_TR_ID, TRADE_TR_ID_TOTAL]); } /** * @description 장 구간별 호가 TR ID 후보를 우선순위 배열로 반환합니다. */ export function resolveOrderBookTrIds( env: "real" | "mock", session: DomesticKisSession, ) { if (env === "mock") return [ORDERBOOK_TR_ID]; if (shouldUseAfterHoursSinglePriceTr(session)) { return uniqueTrIds([ORDERBOOK_TR_ID_OVERTIME, ORDERBOOK_TR_ID]); } if (session === "afterCloseFixedPrice") { return uniqueTrIds([ORDERBOOK_TR_ID, ORDERBOOK_TR_ID_OVERTIME]); } return uniqueTrIds([ORDERBOOK_TR_ID]); } /** * @description 호가 패킷이 실제 표시 가능한 값(호가/잔량/총잔량)을 포함하는지 확인합니다. */ export function hasMeaningfulOrderBookPayload( data: DashboardStockOrderBookResponse, ) { const hasLevelData = data.levels.some( (level) => level.askPrice > 0 || level.bidPrice > 0 || level.askSize > 0 || level.bidSize > 0, ); const hasSummaryData = data.totalAskSize > 0 || data.totalBidSize > 0 || (data.anticipatedPrice ?? 0) > 0 || (data.accumulatedVolume ?? 0) > 0; return hasLevelData || hasSummaryData; } /** * @description KIS 실시간 호가(H0STASP0/H0UNASP0/H0STOAA0)를 OrderBook 구조로 파싱합니다. */ export function parseKisRealtimeOrderbook( raw: string, expectedSymbol: string, ): DashboardStockOrderBookResponse | null { if (!/^([01])\|/.test(raw)) return null; const parts = raw.split("|"); if (parts.length < 4) return null; const trId = parts[1]; if (trId !== "H0STASP0" && trId !== "H0UNASP0" && trId !== "H0STOAA0") { return null; } const values = parts[3].split("^"); // 시간외(H0STOAA0)는 문서 버전에 따라 9레벨/10레벨이 혼재할 수 있어 // payload 길이로 레벨 수를 동적으로 판별합니다. const levelCount = trId === "H0STOAA0" ? (values.length >= 56 ? 10 : 9) : 10; const symbol = values[0]?.trim() ?? ""; const normalizedSymbol = normalizeDomesticSymbol(symbol); const normalizedExpected = normalizeDomesticSymbol(expectedSymbol); if (normalizedSymbol !== normalizedExpected) return null; const askPriceStart = 3; const bidPriceStart = askPriceStart + levelCount; const askSizeStart = bidPriceStart + levelCount; const bidSizeStart = askSizeStart + levelCount; const totalAskIndex = bidSizeStart + levelCount; const totalBidIndex = totalAskIndex + 1; const overtimeTotalAskIndex = totalBidIndex + 1; const overtimeTotalBidIndex = overtimeTotalAskIndex + 1; const anticipatedPriceIndex = overtimeTotalBidIndex + 1; const anticipatedVolumeIndex = anticipatedPriceIndex + 1; const anticipatedTotalVolumeIndex = anticipatedPriceIndex + 2; const anticipatedChangeIndex = anticipatedPriceIndex + 3; const anticipatedChangeSignIndex = anticipatedPriceIndex + 4; const anticipatedChangeRateIndex = anticipatedPriceIndex + 5; const accumulatedVolumeIndex = anticipatedPriceIndex + 6; const totalAskDeltaIndex = anticipatedPriceIndex + 7; const totalBidDeltaIndex = anticipatedPriceIndex + 8; const minFieldLength = totalBidDeltaIndex + 1; if (values.length < minFieldLength) return null; const realtimeLevels = Array.from({ length: levelCount }, (_, i) => ({ askPrice: readNumber(values, askPriceStart + i), bidPrice: readNumber(values, bidPriceStart + i), askSize: readNumber(values, askSizeStart + i), bidSize: readNumber(values, bidSizeStart + i), })); const regularTotalAskSize = readNumber(values, totalAskIndex); const regularTotalBidSize = readNumber(values, totalBidIndex); const overtimeTotalAskSize = readNumber(values, overtimeTotalAskIndex); const overtimeTotalBidSize = readNumber(values, overtimeTotalBidIndex); return { symbol: normalizedExpected, // 장후 시간외에서는 일반 총잔량이 0이고 OVTM 총잔량만 채워지는 경우가 있습니다. totalAskSize: regularTotalAskSize > 0 ? regularTotalAskSize : overtimeTotalAskSize, totalBidSize: regularTotalBidSize > 0 ? regularTotalBidSize : overtimeTotalBidSize, businessHour: readString(values, 1), hourClassCode: readString(values, 2), anticipatedPrice: readNumber(values, anticipatedPriceIndex), anticipatedVolume: readNumber(values, anticipatedVolumeIndex), anticipatedTotalVolume: readNumber(values, anticipatedTotalVolumeIndex), anticipatedChange: readNumber(values, anticipatedChangeIndex), anticipatedChangeSign: readString(values, anticipatedChangeSignIndex), anticipatedChangeRate: readNumber(values, anticipatedChangeRateIndex), accumulatedVolume: readNumber(values, accumulatedVolumeIndex), totalAskSizeDelta: readNumber(values, totalAskDeltaIndex), totalBidSizeDelta: readNumber(values, totalBidDeltaIndex), levels: realtimeLevels, source: "REALTIME", tradingEnv: "real", fetchedAt: new Date().toISOString(), }; } /** * @description 국내 종목코드 비교를 위해 접두 문자를 제거하고 6자리 코드로 정규화합니다. */ function normalizeDomesticSymbol(value: string) { const trimmed = value.trim(); const digits = trimmed.replace(/\D/g, ""); if (digits.length >= 6) { return digits.slice(-6); } return trimmed; } function readString(values: string[], index: number) { return (values[index] ?? "").trim(); } function readNumber(values: string[], index: number) { const raw = readString(values, index).replaceAll(",", ""); const value = Number(raw); return Number.isFinite(value) ? value : 0; } function uniqueTrIds(ids: string[]) { return [...new Set(ids)]; }