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, askSize1: 36, bidSize1: 37, totalAskSize: 38, totalBidSize: 39, 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_TOTAL = "H0UNASP0"; const ORDERBOOK_TR_ID_OVERTIME = "H0STOAA0"; const DEFAULT_REALTIME_TR_STALE_MS = 3_000; interface RealtimeTrPriorityDecisionParams { incomingTrId: string; preferredTrIds: string[]; activeTrId: string | null; activeTrUpdatedAtMs: number; nowMs?: number; staleAfterMs?: number; } 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), askSize1: readNumber(values, base + TICK_FIELD_INDEX.askSize1), bidSize1: readNumber(values, base + TICK_FIELD_INDEX.bidSize1), totalAskSize: readNumber(values, base + TICK_FIELD_INDEX.totalAskSize), totalBidSize: readNumber(values, base + TICK_FIELD_INDEX.totalBidSize), 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, market?: "KOSPI" | "KOSDAQ", ) { if (env === "mock") return [ORDERBOOK_TR_ID]; // 시간외(16:00~18:00): 공식 문서 TR(H0STOAA0)을 최우선 구독하고 // 누락 대비용으로 통합/정규 TR을 뒤에 둡니다. if (shouldUseAfterHoursSinglePriceTr(session)) { return uniqueTrIds([ ORDERBOOK_TR_ID_OVERTIME, ORDERBOOK_TR_ID_TOTAL, ORDERBOOK_TR_ID, ]); } if (session === "afterCloseFixedPrice") { return uniqueTrIds([ ORDERBOOK_TR_ID_TOTAL, ORDERBOOK_TR_ID, ORDERBOOK_TR_ID_OVERTIME, ]); } // 통합장(통합호가) 값이 체결앱과 더 잘 맞는 케이스가 있어 // KOSPI는 통합(H0UNASP0) 우선, KOSDAQ은 KRX(H0STASP0) 우선으로 둡니다. if (market === "KOSPI") { return uniqueTrIds([ORDERBOOK_TR_ID_TOTAL, ORDERBOOK_TR_ID]); } return uniqueTrIds([ORDERBOOK_TR_ID, ORDERBOOK_TR_ID_TOTAL]); } /** * @description 웹소켓 원문에서 TR ID만 빠르게 추출합니다. * @param raw KIS 웹소켓 원문 * @returns TR ID. 포맷이 다르면 null * @see features/trade/hooks/useTradeTickSubscription.ts handleTradeMessage 소스 우선순위 필터 * @see features/trade/hooks/useOrderbookSubscription.ts handleOrderBookMessage 소스 우선순위 필터 */ export function extractKisRealtimeTrId(raw: string) { if (!/^([01])\|/.test(raw)) return null; const parts = raw.split("|", 3); const trId = parts[1]?.trim(); return trId ? trId : null; } /** * @description 다중 TR 구독 시 우선순위/유휴시간 기반으로 현재 메시지를 반영할지 결정합니다. * @remarks 높은 우선순위 TR(ST 계열)은 즉시 승격하고, 현재 소스가 일정 시간 멈췄을 때만 하위 TR(UN 계열)로 폴백합니다. * @see features/trade/hooks/useTradeTickSubscription.ts handleTradeMessage 체결 소스 고정 로직 * @see features/trade/hooks/useOrderbookSubscription.ts handleOrderBookMessage 호가 소스 고정 로직 */ export function shouldAcceptRealtimeMessageByPriority({ incomingTrId, preferredTrIds, activeTrId, activeTrUpdatedAtMs, nowMs = Date.now(), staleAfterMs = DEFAULT_REALTIME_TR_STALE_MS, }: RealtimeTrPriorityDecisionParams) { const incomingPriority = preferredTrIds.indexOf(incomingTrId); if (incomingPriority < 0) return false; if (!activeTrId) return true; if (incomingTrId === activeTrId) return true; const activePriority = preferredTrIds.indexOf(activeTrId); if (activePriority < 0) return true; if (incomingPriority < activePriority) { return true; } const isActiveStale = nowMs - activeTrUpdatedAtMs > staleAfterMs; return isActiveStale; } /** * @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("^"); const symbol = values[0]?.trim() ?? ""; const normalizedSymbol = normalizeDomesticSymbol(symbol); const normalizedExpected = normalizeDomesticSymbol(expectedSymbol); if (normalizedSymbol !== normalizedExpected) return null; // 시간외(H0STOAA0)는 문서 버전에 따라 9호가/10호가가 혼재할 수 있어 // 두 형식을 모두 시도한 뒤 의미 있는 데이터 점수가 더 높은 결과를 선택합니다. if (trId === "H0STOAA0") { const parsedByNineLevels = parseOrderBookByLevelCount( values, normalizedExpected, 9, ); const parsedByTenLevels = parseOrderBookByLevelCount( values, normalizedExpected, 10, ); const candidates = [parsedByNineLevels, parsedByTenLevels].filter( (item): item is DashboardStockOrderBookResponse => item !== null, ); if (candidates.length === 0) return null; return pickBestOrderBookPayload(candidates); } return parseOrderBookByLevelCount(values, normalizedExpected, 10); } /** * @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)]; } /** * @description levelCount(9/10)에 맞춰 호가 payload를 파싱합니다. * @see features/trade/utils/kisRealtimeUtils.ts parseKisRealtimeOrderbook 시간외/정규장 파서에서 공통 사용합니다. */ function parseOrderBookByLevelCount( values: string[], symbol: string, levelCount: 9 | 10, ): DashboardStockOrderBookResponse | 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 levels = Array.from({ length: levelCount }, (_, index) => ({ askPrice: readNumber(values, askPriceStart + index), bidPrice: readNumber(values, bidPriceStart + index), askSize: readNumber(values, askSizeStart + index), bidSize: readNumber(values, bidSizeStart + index), })); const regularTotalAskSize = readNumber(values, totalAskIndex); const regularTotalBidSize = readNumber(values, totalBidIndex); const overtimeTotalAskSize = readNumber(values, overtimeTotalAskIndex); const overtimeTotalBidSize = readNumber(values, overtimeTotalBidIndex); return { symbol, 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, source: "REALTIME", tradingEnv: "real", fetchedAt: new Date().toISOString(), }; } /** * @description 복수 파싱 결과 중 실제 표시 값이 풍부한 payload를 선택합니다. * @see features/trade/utils/kisRealtimeUtils.ts parseKisRealtimeOrderbook 시간외 9/10호가 자동 선택에 사용합니다. */ function pickBestOrderBookPayload( candidates: DashboardStockOrderBookResponse[], ) { return [...candidates].sort((left, right) => { return scoreOrderBookPayload(right) - scoreOrderBookPayload(left); })[0]; } /** * @description 호가 payload가 실제로 얼마나 유효한지 점수화합니다. * @see features/trade/utils/kisRealtimeUtils.ts pickBestOrderBookPayload 시간외 파서 후보 비교용입니다. */ function scoreOrderBookPayload(payload: DashboardStockOrderBookResponse) { const nonZeroLevels = payload.levels.filter( (level) => level.askPrice > 0 || level.bidPrice > 0 || level.askSize > 0 || level.bidSize > 0, ).length; return ( nonZeroLevels * 10 + (payload.totalAskSize > 0 ? 4 : 0) + (payload.totalBidSize > 0 ? 4 : 0) + ((payload.anticipatedPrice ?? 0) > 0 ? 2 : 0) + ((payload.accumulatedVolume ?? 0) > 0 ? 1 : 0) ); }