import type { DashboardRealtimeTradeTick, DashboardStockOrderBookResponse, } from "@/features/trade/types/trade.types"; const REALTIME_SIGN_NEGATIVE = new Set(["4", "5"]); const ALLOWED_REALTIME_TRADE_TR_IDS = new Set([ "H0STCNT0", "H0STANC0", "H0STOUP0", "H0STOAC0", ]); 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, } as const; /** * @description KIS 실시간 구독/해제 소켓 메시지를 생성합니다. * @see features/trade/hooks/useKisTradeWebSocket.ts 구독/해제 요청 payload 생성에 사용됩니다. */ export function buildKisRealtimeMessage( approvalKey: string, symbol: string, trId: string, trType: "1" | "2", ) { return { header: { approval_key: approvalKey, custtype: "P", tr_type: trType, "content-type": "utf-8", }, body: { input: { tr_id: trId, tr_key: symbol, }, }, }; } /** * @description 실시간 체결 스트림(raw)을 배열 단위로 파싱합니다. * - 배치 전송(복수 체결) 데이터를 모두 추출합니다. * - 종목 불일치 또는 가격 0 이하 데이터는 제외합니다. * @see features/trade/hooks/useKisTradeWebSocket.ts onmessage 이벤트에서 체결 패킷 파싱에 사용됩니다. */ 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[]; // TR ID check: regular tick / expected tick / after-hours tick. const receivedTrId = parts[1]; if (!ALLOWED_REALTIME_TRADE_TR_IDS.has(receivedTrId)) { return [] as DashboardRealtimeTradeTick[]; } // if (parts[1] !== expectedTrId) 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 ticks: DashboardRealtimeTradeTick[] = []; for (let index = 0; index < parsedCount; index++) { const base = index * fieldsPerTick; const symbol = readString(values, base + TICK_FIELD_INDEX.symbol); if (symbol !== expectedSymbol) { if (symbol.trim() !== expectedSymbol.trim()) { console.warn( `[KisRealtime] Symbol mismatch: received '${symbol}', expected '${expectedSymbol}'`, ); 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, 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, ), 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 KIS 실시간 호가(H0STASP0/H0UNASP0/H0STOAA0)를 OrderBook 구조로 파싱합니다. * @see features/trade/hooks/useKisTradeWebSocket.ts onOrderBookMessage 콜백 데이터 생성에 사용됩니다. */ 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 levelCount = trId === "H0STOAA0" ? 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 anticipatedPriceIndex = totalBidIndex + 3; 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), })); return { symbol: normalizedExpected, totalAskSize: readNumber(values, totalAskIndex), totalBidSize: readNumber(values, totalBidIndex), 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자리 코드로 정규화합니다. * @see features/trade/utils/kis-realtime.utils.ts parseKisRealtimeOrderbook 종목 매칭 비교에 사용됩니다. */ 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; }