import type { DashboardRealtimeTradeTick, DashboardStockOrderBookResponse, } from "@/features/dashboard/types/dashboard.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; /** * 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: 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; } /** * 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; }