import type { DashboardRealtimeTradeTick, DashboardStockOrderBookResponse, StockCandlePoint, } from "@/features/dashboard/types/dashboard.types"; const REALTIME_SIGN_NEGATIVE = new Set(["4", "5"]); 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; /** * KIS 실시간 구독/해제 웹소켓 메시지를 생성합니다. */ 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, }, }, }; } /** * 실시간 체결 스트림(raw)을 배열 단위로 파싱합니다. * - 배치 전송(복수 틱)일 때도 모든 틱을 추출 * - 심볼 불일치/가격 0 이하 데이터는 제외 */ 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: Allow H0STCNT0 (Real/Mock) or H0STOUP0 (Overtime) const receivedTrId = parts[1]; if (receivedTrId !== "H0STCNT0" && receivedTrId !== "H0STOUP0") { // console.warn("[KisRealtime] Unknown TR ID for Trade Tick:", 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; } export function formatRealtimeTickTime(hhmmss?: string) { if (!hhmmss || hhmmss.length !== 6) return "실시간"; return `${hhmmss.slice(0, 2)}:${hhmmss.slice(2, 4)}:${hhmmss.slice(4, 6)}`; } export function appendRealtimeTick( prev: StockCandlePoint[], next: StockCandlePoint, ) { if (prev.length === 0) return [next]; const last = prev[prev.length - 1]; if (last.time === next.time) { return [...prev.slice(0, -1), next]; } return [...prev, next].slice(-80); } export function toTickOrderValue(hhmmss?: string) { if (!hhmmss || !/^\d{6}$/.test(hhmmss)) return -1; return Number(hhmmss); } /** * 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 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/dashboard/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; }