대시보드 실시간 기능 추가
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { type FormEvent, useCallback, useState } from "react";
|
||||
import { type FormEvent, useCallback, useEffect, useState } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import { useKisRuntimeStore } from "@/features/settings/store/use-kis-runtime-store";
|
||||
@@ -26,6 +27,10 @@ import type {
|
||||
* @see features/trade/hooks/useStockOverview.ts 선택 종목 상세 상태를 관리합니다.
|
||||
*/
|
||||
export function TradeContainer() {
|
||||
const searchParams = useSearchParams();
|
||||
const symbolParam = searchParams.get("symbol");
|
||||
const nameParam = searchParams.get("name");
|
||||
|
||||
// [State] 호가 실시간 데이터 (체결 WS와 동일 소켓에서 수신)
|
||||
const [realtimeOrderBook, setRealtimeOrderBook] =
|
||||
useState<DashboardStockOrderBookResponse | null>(null);
|
||||
@@ -53,6 +58,36 @@ export function TradeContainer() {
|
||||
} = useStockSearch();
|
||||
const { selectedStock, loadOverview, updateRealtimeTradeTick } =
|
||||
useStockOverview();
|
||||
|
||||
/**
|
||||
* [Effect] URL 파라미터(symbol) 감지 시 자동 종목 로드
|
||||
* 대시보드 등 외부에서 종목 코드를 넘겨받아 트레이딩 페이지로 진입할 때 사용합니다.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (symbolParam && isKisVerified && verifiedCredentials && _hasHydrated) {
|
||||
// 현재 선택된 종목과 파라미터가 다를 경우에만 자동 로드 수행
|
||||
if (selectedStock?.symbol !== symbolParam) {
|
||||
setKeyword(nameParam || symbolParam);
|
||||
appendSearchHistory({
|
||||
symbol: symbolParam,
|
||||
name: nameParam || symbolParam,
|
||||
market: "KOSPI", // 기본값 설정, loadOverview 이후 실제 데이터로 보완됨
|
||||
});
|
||||
loadOverview(symbolParam, verifiedCredentials);
|
||||
}
|
||||
}
|
||||
}, [
|
||||
symbolParam,
|
||||
nameParam,
|
||||
isKisVerified,
|
||||
verifiedCredentials,
|
||||
_hasHydrated,
|
||||
selectedStock?.symbol,
|
||||
loadOverview,
|
||||
setKeyword,
|
||||
appendSearchHistory,
|
||||
]);
|
||||
|
||||
const canTrade = isKisVerified && !!verifiedCredentials;
|
||||
const canSearch = canTrade;
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ import {
|
||||
} from "./chart-utils";
|
||||
|
||||
const UP_COLOR = "#ef4444";
|
||||
const MINUTE_SYNC_INTERVAL_MS = 5000;
|
||||
const MINUTE_SYNC_INTERVAL_MS = 30000;
|
||||
const REALTIME_STALE_THRESHOLD_MS = 12000;
|
||||
|
||||
interface ChartPalette {
|
||||
@@ -522,11 +522,11 @@ export function StockLineChart({
|
||||
}
|
||||
}, [isChartReady, renderableBars, setSeriesData]);
|
||||
|
||||
/**
|
||||
* @description WebSocket 체결 틱을 현재 timeframe 캔들에 반영합니다.
|
||||
* @see features/trade/hooks/useKisTradeWebSocket.ts latestTick
|
||||
* @see features/trade/components/chart/chart-utils.ts toRealtimeTickBar
|
||||
*/
|
||||
/**
|
||||
* @description WebSocket 체결 틱을 현재 timeframe 캔들에 반영합니다.
|
||||
* @see features/trade/hooks/useKisTradeWebSocket.ts latestTick
|
||||
* @see features/trade/components/chart/chart-utils.ts toRealtimeTickBar
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!latestTick) return;
|
||||
if (bars.length === 0) return;
|
||||
@@ -567,7 +567,10 @@ export function StockLineChart({
|
||||
const recentBars = latestPageBars.slice(-10);
|
||||
if (recentBars.length === 0) return;
|
||||
|
||||
setBars((prev) => mergeBars(prev, recentBars));
|
||||
setBars((prev) => {
|
||||
const merged = mergeBars(prev, recentBars);
|
||||
return areBarsEqual(prev, merged) ? prev : merged;
|
||||
});
|
||||
} catch {
|
||||
// 폴링 실패는 치명적이지 않으므로 조용히 다음 주기에서 재시도합니다.
|
||||
}
|
||||
@@ -690,3 +693,25 @@ export function StockLineChart({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function areBarsEqual(left: ChartBar[], right: ChartBar[]) {
|
||||
if (left.length !== right.length) return false;
|
||||
|
||||
for (let index = 0; index < left.length; index += 1) {
|
||||
const lhs = left[index];
|
||||
const rhs = right[index];
|
||||
if (!lhs || !rhs) return false;
|
||||
if (
|
||||
lhs.time !== rhs.time ||
|
||||
lhs.open !== rhs.open ||
|
||||
lhs.high !== rhs.high ||
|
||||
lhs.low !== rhs.low ||
|
||||
lhs.close !== rhs.close ||
|
||||
lhs.volume !== rhs.volume
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -150,7 +150,7 @@ function resolveBarTimestamp(
|
||||
|
||||
/**
|
||||
* 타임스탬프를 타임프레임 버킷 경계에 정렬
|
||||
* - 1m: 그대로
|
||||
* - 1m: 초/밀리초를 제거해 분 경계에 정렬
|
||||
* - 30m/1h: 분 단위를 버킷에 정렬
|
||||
* - 1d: 00:00:00
|
||||
* - 1w: 월요일 00:00:00
|
||||
@@ -161,7 +161,9 @@ function alignTimestamp(
|
||||
): UTCTimestamp {
|
||||
const d = new Date(timestamp * 1000);
|
||||
|
||||
if (timeframe === "30m" || timeframe === "1h") {
|
||||
if (timeframe === "1m") {
|
||||
d.setUTCSeconds(0, 0);
|
||||
} else if (timeframe === "30m" || timeframe === "1h") {
|
||||
const bucket = timeframe === "30m" ? 30 : 60;
|
||||
d.setUTCMinutes(Math.floor(d.getUTCMinutes() / bucket) * bucket, 0, 0);
|
||||
} else if (timeframe === "1d") {
|
||||
|
||||
29
features/trade/hooks/useDomesticSession.ts
Normal file
29
features/trade/hooks/useDomesticSession.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import {
|
||||
resolveDomesticKisSession,
|
||||
type DomesticKisSession,
|
||||
} from "@/lib/kis/domestic-market-session";
|
||||
|
||||
// 클라이언트 환경에서 현재 시간을 기준으로 세션을 계산합니다.
|
||||
// 매 30초마다 갱신됩니다.
|
||||
|
||||
/**
|
||||
* @description 국내 주식 시장의 세션(장 운영 상태)을 관리하는 훅입니다.
|
||||
* client-side에서 로컬 스토리지를 확인하거나 기본 세션을 반환하며,
|
||||
* 30초마다 세션 정보를 갱신하여 장 시작/마감/시간외 단일가 등 상태 변화를 감지합니다.
|
||||
*/
|
||||
export function useDomesticSession() {
|
||||
const [marketSession, setMarketSession] = useState<DomesticKisSession>(() =>
|
||||
resolveDomesticKisSession(),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const timerId = window.setInterval(() => {
|
||||
const nextSession = resolveDomesticKisSession();
|
||||
setMarketSession((prev) => (prev === nextSession ? prev : nextSession));
|
||||
}, 30_000);
|
||||
return () => window.clearInterval(timerId);
|
||||
}, []);
|
||||
|
||||
return marketSession;
|
||||
}
|
||||
@@ -1,156 +1,24 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
type KisRuntimeCredentials,
|
||||
useKisRuntimeStore,
|
||||
} from "@/features/settings/store/use-kis-runtime-store";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { type KisRuntimeCredentials } from "@/features/settings/store/use-kis-runtime-store";
|
||||
import type {
|
||||
DashboardRealtimeTradeTick,
|
||||
DashboardStockOrderBookResponse,
|
||||
} from "@/features/trade/types/trade.types";
|
||||
import {
|
||||
buildKisRealtimeMessage,
|
||||
parseKisRealtimeOrderbook,
|
||||
parseKisRealtimeTickBatch,
|
||||
} from "@/features/trade/utils/kis-realtime.utils";
|
||||
import {
|
||||
DOMESTIC_KIS_SESSION_OVERRIDE_STORAGE_KEY,
|
||||
resolveDomesticKisSession,
|
||||
shouldUseAfterHoursSinglePriceTr,
|
||||
shouldUseExpectedExecutionTr,
|
||||
type DomesticKisSession,
|
||||
} from "@/lib/kis/domestic-market-session";
|
||||
|
||||
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";
|
||||
const MAX_TRADE_TICKS = 10;
|
||||
const WS_DEBUG_STORAGE_KEY = "KIS_WS_DEBUG";
|
||||
import { resolveTradeTrIds } from "@/features/trade/utils/kisRealtimeUtils";
|
||||
import { useKisWebSocketStore } from "@/features/kis-realtime/stores/kisWebSocketStore";
|
||||
import { useDomesticSession } from "@/features/trade/hooks/useDomesticSession";
|
||||
import { useTradeTickSubscription } from "@/features/trade/hooks/useTradeTickSubscription";
|
||||
import { useOrderbookSubscription } from "@/features/trade/hooks/useOrderbookSubscription";
|
||||
|
||||
/**
|
||||
* @description 장 구간/시장별 누락을 줄이기 위해 TR ID를 우선순위 배열로 반환합니다.
|
||||
* @see features/trade/hooks/useKisTradeWebSocket.ts useEffect WebSocket 구독 등록
|
||||
* @see temp-kis-domestic-functions-ws.py ccnl_krx/ccnl_total/exp_ccnl_krx/exp_ccnl_total/overtime_ccnl_krx
|
||||
*/
|
||||
function resolveTradeTrIds(
|
||||
env: KisRuntimeCredentials["tradingEnv"],
|
||||
session: DomesticKisSession,
|
||||
) {
|
||||
if (env === "mock") return [TRADE_TR_ID];
|
||||
|
||||
if (shouldUseAfterHoursSinglePriceTr(session)) {
|
||||
// 시간외 단일가(16:00~18:00): 전용 TR + 통합 TR 백업
|
||||
return uniqueTrIds([
|
||||
TRADE_TR_ID_OVERTIME,
|
||||
TRADE_TR_ID_OVERTIME_EXPECTED,
|
||||
TRADE_TR_ID_TOTAL,
|
||||
TRADE_TR_ID_TOTAL_EXPECTED,
|
||||
]);
|
||||
}
|
||||
|
||||
if (shouldUseExpectedExecutionTr(session)) {
|
||||
// 동시호가 구간(장전/장마감): 예상체결 TR을 우선, 일반체결/통합체결을 백업
|
||||
return uniqueTrIds([
|
||||
TRADE_TR_ID_EXPECTED,
|
||||
TRADE_TR_ID_TOTAL_EXPECTED,
|
||||
TRADE_TR_ID,
|
||||
TRADE_TR_ID_TOTAL,
|
||||
]);
|
||||
}
|
||||
|
||||
if (session === "afterCloseFixedPrice") {
|
||||
// 시간외 종가(15:40~16:00): 브로커별 라우팅 차이를 대비해 일반/시간외/통합 TR을 함께 구독
|
||||
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 후보를 우선순위 배열로 반환합니다.
|
||||
* @see features/trade/hooks/useKisTradeWebSocket.ts useEffect WebSocket 구독 등록
|
||||
* @see temp-kis-domestic-functions-ws.py asking_price_krx/asking_price_total/overtime_asking_price_krx
|
||||
*/
|
||||
function resolveOrderBookTrIds(
|
||||
env: KisRuntimeCredentials["tradingEnv"],
|
||||
session: DomesticKisSession,
|
||||
) {
|
||||
if (env === "mock") return [ORDERBOOK_TR_ID];
|
||||
|
||||
if (shouldUseAfterHoursSinglePriceTr(session)) {
|
||||
// 시간외 단일가(16:00~18:00)는 KRX 전용 호가 TR만 구독합니다.
|
||||
// 통합 TR(H0UNASP0)을 같이 구독하면 종목별로 포맷/잔량이 섞여 보일 수 있습니다.
|
||||
return uniqueTrIds([ORDERBOOK_TR_ID_OVERTIME]);
|
||||
}
|
||||
|
||||
if (session === "afterCloseFixedPrice") {
|
||||
return uniqueTrIds([ORDERBOOK_TR_ID_OVERTIME]);
|
||||
}
|
||||
|
||||
// UI 흐름: 호가창 UI -> useKisTradeWebSocket onmessage -> onOrderBookMessage
|
||||
// -> TradeContainer setRealtimeOrderBook -> useOrderBook 병합 -> OrderBook 렌더
|
||||
// 장중에는 KRX 전용(H0STASP0)만 구독해 값이 번갈아 덮이는 현상을 방지합니다.
|
||||
return uniqueTrIds([ORDERBOOK_TR_ID]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 콘솔 디버그 플래그를 확인합니다.
|
||||
* @see features/trade/hooks/useKisTradeWebSocket.ts socket.onmessage
|
||||
*/
|
||||
function isWsDebugEnabled() {
|
||||
if (typeof window === "undefined") return false;
|
||||
|
||||
try {
|
||||
return window.localStorage.getItem(WS_DEBUG_STORAGE_KEY) === "1";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 실시간 웹소켓 제어(JSON) 메시지를 파싱합니다.
|
||||
* @see features/trade/hooks/useKisTradeWebSocket.ts socket.onmessage
|
||||
*/
|
||||
function parseWsControlMessage(raw: string) {
|
||||
if (!raw.startsWith("{")) return null;
|
||||
|
||||
try {
|
||||
return JSON.parse(raw) as {
|
||||
header?: { tr_id?: string };
|
||||
body?: { rt_cd?: string; msg1?: string };
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 실시간 원문에서 파이프 구분 TR ID를 빠르게 추출합니다.
|
||||
* @see features/trade/hooks/useKisTradeWebSocket.ts socket.onmessage
|
||||
*/
|
||||
function peekPipeTrId(raw: string) {
|
||||
const parts = raw.split("|");
|
||||
return parts.length > 1 ? parts[1] : "";
|
||||
}
|
||||
|
||||
function uniqueTrIds(ids: string[]) {
|
||||
return [...new Set(ids)];
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Subscribes trade ticks and orderbook over one websocket.
|
||||
* @see features/trade/components/TradeContainer.tsx
|
||||
* @see lib/kis/domestic-market-session.ts
|
||||
* @description KIS 실시간 매매/호가 데이터를 통합 구독하는 커스텀 훅입니다.
|
||||
* @summary
|
||||
* - `useDomesticSession`: 장 운영 시간(정규장, 시간외 등) 세션 관리
|
||||
* - `useTradeTickSubscription`: 실시간 체결가(Tick) 데이터 구독 및 상태 관리
|
||||
* - `useOrderbookSubscription`: 실시간 호가(Orderbook) 데이터 구독
|
||||
* - `useKisWebSocketStore`: 전역 웹소켓 연결 상태 및 에러 관리
|
||||
*
|
||||
* 위 훅들을 조합(Composition)하여 트레이딩 화면에 필요한 모든 실시간 데이터를 제공합니다.
|
||||
*/
|
||||
export function useKisTradeWebSocket(
|
||||
symbol: string | undefined,
|
||||
@@ -162,305 +30,41 @@ export function useKisTradeWebSocket(
|
||||
onOrderBookMessage?: (data: DashboardStockOrderBookResponse) => void;
|
||||
},
|
||||
) {
|
||||
const [latestTick, setLatestTick] =
|
||||
useState<DashboardRealtimeTradeTick | null>(null);
|
||||
const [recentTradeTicks, setRecentTradeTicks] = useState<
|
||||
DashboardRealtimeTradeTick[]
|
||||
>([]);
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [lastTickAt, setLastTickAt] = useState<number | null>(null);
|
||||
const [marketSession, setMarketSession] = useState<DomesticKisSession>(() =>
|
||||
resolveSessionInClient(),
|
||||
const marketSession = useDomesticSession();
|
||||
const { isConnected, error } = useKisWebSocketStore();
|
||||
|
||||
const { latestTick, recentTradeTicks, lastTickAt } = useTradeTickSubscription(
|
||||
{
|
||||
symbol,
|
||||
isVerified,
|
||||
credentials,
|
||||
marketSession,
|
||||
onTick,
|
||||
},
|
||||
);
|
||||
|
||||
const socketRef = useRef<WebSocket | null>(null);
|
||||
const approvalKeyRef = useRef<string | null>(null);
|
||||
const seenTickRef = useRef<Set<string>>(new Set());
|
||||
|
||||
const obSymbol = options?.orderBookSymbol;
|
||||
const onOrderBookMsg = options?.onOrderBookMessage;
|
||||
const realtimeTrIds = credentials
|
||||
? resolveTradeTrIds(credentials.tradingEnv, marketSession)
|
||||
: [TRADE_TR_ID];
|
||||
const realtimeTrId = credentials
|
||||
? realtimeTrIds[0] ?? TRADE_TR_ID
|
||||
: TRADE_TR_ID;
|
||||
|
||||
useEffect(() => {
|
||||
const timerId = window.setInterval(() => {
|
||||
const nextSession = resolveSessionInClient();
|
||||
setMarketSession((prev) => (prev === nextSession ? prev : nextSession));
|
||||
}, 30_000);
|
||||
|
||||
return () => window.clearInterval(timerId);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isConnected || lastTickAt) return;
|
||||
|
||||
const timer = window.setTimeout(() => {
|
||||
setError(
|
||||
"실시간 연결은 되었지만 체결 데이터가 없습니다. 장 시간(한국시간)과 TR ID를 확인해 주세요.",
|
||||
);
|
||||
}, 8000);
|
||||
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [isConnected, lastTickAt]);
|
||||
|
||||
useEffect(() => {
|
||||
setLatestTick(null);
|
||||
setRecentTradeTicks([]);
|
||||
setError(null);
|
||||
setLastTickAt(null);
|
||||
seenTickRef.current.clear();
|
||||
|
||||
if (!symbol || !isVerified || !credentials) {
|
||||
socketRef.current?.close();
|
||||
socketRef.current = null;
|
||||
approvalKeyRef.current = null;
|
||||
setIsConnected(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let disposed = false;
|
||||
let socket: WebSocket | null = null;
|
||||
const debugEnabled = isWsDebugEnabled();
|
||||
|
||||
const tradeTrIds = resolveTradeTrIds(credentials.tradingEnv, marketSession);
|
||||
const orderBookTrIds =
|
||||
obSymbol && onOrderBookMsg
|
||||
? resolveOrderBookTrIds(credentials.tradingEnv, marketSession)
|
||||
: [];
|
||||
|
||||
const subscribe = (
|
||||
key: string,
|
||||
targetSymbol: string,
|
||||
trId: string,
|
||||
trType: "1" | "2",
|
||||
) => {
|
||||
socket?.send(
|
||||
JSON.stringify(buildKisRealtimeMessage(key, targetSymbol, trId, trType)),
|
||||
);
|
||||
};
|
||||
|
||||
const connect = async () => {
|
||||
try {
|
||||
setError(null);
|
||||
setIsConnected(false);
|
||||
|
||||
const wsConnection = await useKisRuntimeStore
|
||||
.getState()
|
||||
.getOrFetchWsConnection();
|
||||
|
||||
if (!wsConnection) {
|
||||
throw new Error("웹소켓 승인키 발급에 실패했습니다.");
|
||||
}
|
||||
|
||||
if (disposed) return;
|
||||
approvalKeyRef.current = wsConnection.approvalKey;
|
||||
|
||||
// 공식 샘플과 동일하게 /tryitout 엔드포인트로 연결하고, TR은 payload로 구독합니다.
|
||||
socket = new WebSocket(`${wsConnection.wsUrl}/tryitout`);
|
||||
socketRef.current = socket;
|
||||
|
||||
socket.onopen = () => {
|
||||
if (disposed || !approvalKeyRef.current) return;
|
||||
|
||||
for (const trId of tradeTrIds) {
|
||||
subscribe(approvalKeyRef.current, symbol, trId, "1");
|
||||
}
|
||||
|
||||
if (obSymbol) {
|
||||
for (const trId of orderBookTrIds) {
|
||||
subscribe(approvalKeyRef.current, obSymbol, trId, "1");
|
||||
}
|
||||
}
|
||||
|
||||
if (debugEnabled) {
|
||||
console.info("[KisRealtime] Subscribed", {
|
||||
symbol,
|
||||
marketSession,
|
||||
tradeTrIds,
|
||||
orderBookSymbol: obSymbol ?? null,
|
||||
orderBookTrIds,
|
||||
});
|
||||
}
|
||||
|
||||
setIsConnected(true);
|
||||
};
|
||||
|
||||
socket.onmessage = (event) => {
|
||||
if (disposed || typeof event.data !== "string") return;
|
||||
|
||||
const control = parseWsControlMessage(event.data);
|
||||
if (control) {
|
||||
const trId = control.header?.tr_id ?? "";
|
||||
if (trId === "PINGPONG") {
|
||||
// 서버 Keepalive에 응답하지 않으면 연결이 끊길 수 있습니다.
|
||||
socket?.send(event.data);
|
||||
return;
|
||||
}
|
||||
|
||||
if (debugEnabled) {
|
||||
console.info("[KisRealtime] Control", {
|
||||
trId,
|
||||
rt_cd: control.body?.rt_cd,
|
||||
message: control.body?.msg1,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (obSymbol && onOrderBookMsg) {
|
||||
const orderBook = parseKisRealtimeOrderbook(event.data, obSymbol);
|
||||
if (orderBook) {
|
||||
orderBook.tradingEnv = credentials.tradingEnv;
|
||||
if (debugEnabled) {
|
||||
console.debug("[KisRealtime] OrderBook", {
|
||||
trId: peekPipeTrId(event.data),
|
||||
symbol: orderBook.symbol,
|
||||
businessHour: orderBook.businessHour,
|
||||
hourClassCode: orderBook.hourClassCode,
|
||||
});
|
||||
}
|
||||
onOrderBookMsg(orderBook);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const ticks = parseKisRealtimeTickBatch(event.data, symbol);
|
||||
if (ticks.length === 0) {
|
||||
if (debugEnabled && event.data.includes("|")) {
|
||||
console.debug("[KisRealtime] Unparsed payload", {
|
||||
trId: peekPipeTrId(event.data),
|
||||
preview: event.data.slice(0, 220),
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const meaningfulTicks = ticks.filter((tick) => tick.tradeVolume > 0);
|
||||
if (meaningfulTicks.length === 0) {
|
||||
if (debugEnabled) {
|
||||
console.debug("[KisRealtime] Ignored zero-volume ticks", {
|
||||
trId: peekPipeTrId(event.data),
|
||||
parsedCount: ticks.length,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const dedupedTicks = meaningfulTicks.filter((tick) => {
|
||||
const key = `${tick.tickTime}-${tick.price}-${tick.tradeVolume}`;
|
||||
if (seenTickRef.current.has(key)) return false;
|
||||
seenTickRef.current.add(key);
|
||||
if (seenTickRef.current.size > 5_000) {
|
||||
seenTickRef.current.clear();
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
const latest = meaningfulTicks[meaningfulTicks.length - 1];
|
||||
setLatestTick(latest);
|
||||
|
||||
if (debugEnabled) {
|
||||
console.debug("[KisRealtime] Tick", {
|
||||
trId: peekPipeTrId(event.data),
|
||||
symbol: latest.symbol,
|
||||
tickTime: latest.tickTime,
|
||||
price: latest.price,
|
||||
tradeVolume: latest.tradeVolume,
|
||||
executionClassCode: latest.executionClassCode,
|
||||
buyExecutionCount: latest.buyExecutionCount,
|
||||
sellExecutionCount: latest.sellExecutionCount,
|
||||
netBuyExecutionCount: latest.netBuyExecutionCount,
|
||||
parsedCount: ticks.length,
|
||||
});
|
||||
}
|
||||
|
||||
if (dedupedTicks.length > 0) {
|
||||
setRecentTradeTicks((prev) =>
|
||||
[...dedupedTicks.reverse(), ...prev].slice(0, MAX_TRADE_TICKS),
|
||||
);
|
||||
}
|
||||
|
||||
setError(null);
|
||||
setLastTickAt(Date.now());
|
||||
onTick?.(latest);
|
||||
};
|
||||
|
||||
socket.onerror = () => {
|
||||
if (!disposed) {
|
||||
if (debugEnabled) {
|
||||
console.warn("[KisRealtime] WebSocket error", {
|
||||
symbol,
|
||||
marketSession,
|
||||
tradeTrIds,
|
||||
});
|
||||
}
|
||||
setIsConnected(false);
|
||||
}
|
||||
};
|
||||
|
||||
socket.onclose = () => {
|
||||
if (!disposed) {
|
||||
if (debugEnabled) {
|
||||
console.warn("[KisRealtime] WebSocket closed", {
|
||||
symbol,
|
||||
marketSession,
|
||||
tradeTrIds,
|
||||
});
|
||||
}
|
||||
setIsConnected(false);
|
||||
}
|
||||
};
|
||||
} catch (err) {
|
||||
if (disposed) return;
|
||||
|
||||
setError(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: "실시간 웹소켓 초기화 중 오류가 발생했습니다.",
|
||||
);
|
||||
setIsConnected(false);
|
||||
}
|
||||
};
|
||||
|
||||
void connect();
|
||||
const seenRef = seenTickRef.current;
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
setIsConnected(false);
|
||||
|
||||
const key = approvalKeyRef.current;
|
||||
if (socket?.readyState === WebSocket.OPEN && key) {
|
||||
for (const trId of tradeTrIds) {
|
||||
subscribe(key, symbol, trId, "2");
|
||||
}
|
||||
|
||||
if (obSymbol) {
|
||||
for (const trId of orderBookTrIds) {
|
||||
subscribe(key, obSymbol, trId, "2");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
socket?.close();
|
||||
if (socketRef.current === socket) socketRef.current = null;
|
||||
approvalKeyRef.current = null;
|
||||
seenRef.clear();
|
||||
};
|
||||
}, [
|
||||
symbol,
|
||||
useOrderbookSubscription({
|
||||
symbol: options?.orderBookSymbol,
|
||||
isVerified,
|
||||
credentials,
|
||||
marketSession,
|
||||
onTick,
|
||||
obSymbol,
|
||||
onOrderBookMsg,
|
||||
]);
|
||||
onOrderBookMessage: options?.onOrderBookMessage,
|
||||
});
|
||||
|
||||
// Connection/Data warning
|
||||
useEffect(() => {
|
||||
if (!isConnected || lastTickAt || !symbol) return;
|
||||
const timer = window.setTimeout(() => {
|
||||
// Just a warning, not blocking
|
||||
}, 8000);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [isConnected, lastTickAt, symbol]);
|
||||
|
||||
const realtimeTrId = useMemo(() => {
|
||||
if (!credentials) return "H0STCNT0";
|
||||
const ids = resolveTradeTrIds(credentials.tradingEnv, marketSession);
|
||||
return ids[0] ?? "H0STCNT0";
|
||||
}, [credentials, marketSession]);
|
||||
|
||||
return {
|
||||
latestTick,
|
||||
@@ -471,18 +75,3 @@ export function useKisTradeWebSocket(
|
||||
realtimeTrId,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveSessionInClient() {
|
||||
if (typeof window === "undefined") {
|
||||
return resolveDomesticKisSession();
|
||||
}
|
||||
|
||||
try {
|
||||
const override = window.localStorage.getItem(
|
||||
DOMESTIC_KIS_SESSION_OVERRIDE_STORAGE_KEY,
|
||||
);
|
||||
return resolveDomesticKisSession(override);
|
||||
} catch {
|
||||
return resolveDomesticKisSession();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { KisRuntimeCredentials } from "@/features/settings/store/use-kis-runtime-store";
|
||||
import type { DashboardStockOrderBookResponse } from "@/features/trade/types/trade.types";
|
||||
import { fetchStockOrderBook } from "@/features/trade/apis/kis-stock.api";
|
||||
@@ -29,9 +29,22 @@ export function useOrderBook(
|
||||
|
||||
const [initialData, setInitialData] =
|
||||
useState<DashboardStockOrderBookResponse | null>(null);
|
||||
const [lastRealtimeWithLevels, setLastRealtimeWithLevels] =
|
||||
useState<DashboardStockOrderBookResponse | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isRequestEnabled) {
|
||||
setLastRealtimeWithLevels(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (externalRealtimeOrderBook && hasOrderBookLevelData(externalRealtimeOrderBook)) {
|
||||
setLastRealtimeWithLevels(externalRealtimeOrderBook);
|
||||
}
|
||||
}, [externalRealtimeOrderBook, isRequestEnabled]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isRequestEnabled || !symbol || !credentials) {
|
||||
return;
|
||||
@@ -75,10 +88,67 @@ export function useOrderBook(
|
||||
};
|
||||
}, [isRequestEnabled, symbol, credentials]);
|
||||
|
||||
// 외부 실시간 호가 → 초기 데이터 → null 순 우선
|
||||
const orderBook = isRequestEnabled
|
||||
? (externalRealtimeOrderBook ?? initialData)
|
||||
: null;
|
||||
// 외부 실시간 호가가 비어 있으면(가격/잔량 레벨 0) 마지막 정상 실시간 또는 REST 초기값을 유지합니다.
|
||||
const orderBook = useMemo(() => {
|
||||
if (!isRequestEnabled) return null;
|
||||
|
||||
if (externalRealtimeOrderBook) {
|
||||
if (hasOrderBookLevelData(externalRealtimeOrderBook)) {
|
||||
return externalRealtimeOrderBook;
|
||||
}
|
||||
|
||||
if (lastRealtimeWithLevels) {
|
||||
return {
|
||||
...lastRealtimeWithLevels,
|
||||
totalAskSize: externalRealtimeOrderBook.totalAskSize,
|
||||
totalBidSize: externalRealtimeOrderBook.totalBidSize,
|
||||
anticipatedPrice: externalRealtimeOrderBook.anticipatedPrice,
|
||||
anticipatedVolume: externalRealtimeOrderBook.anticipatedVolume,
|
||||
anticipatedTotalVolume: externalRealtimeOrderBook.anticipatedTotalVolume,
|
||||
anticipatedChange: externalRealtimeOrderBook.anticipatedChange,
|
||||
anticipatedChangeSign: externalRealtimeOrderBook.anticipatedChangeSign,
|
||||
anticipatedChangeRate: externalRealtimeOrderBook.anticipatedChangeRate,
|
||||
accumulatedVolume: externalRealtimeOrderBook.accumulatedVolume,
|
||||
totalAskSizeDelta: externalRealtimeOrderBook.totalAskSizeDelta,
|
||||
totalBidSizeDelta: externalRealtimeOrderBook.totalBidSizeDelta,
|
||||
businessHour:
|
||||
externalRealtimeOrderBook.businessHour ?? lastRealtimeWithLevels.businessHour,
|
||||
hourClassCode:
|
||||
externalRealtimeOrderBook.hourClassCode ?? lastRealtimeWithLevels.hourClassCode,
|
||||
fetchedAt: externalRealtimeOrderBook.fetchedAt,
|
||||
tradingEnv: externalRealtimeOrderBook.tradingEnv,
|
||||
source: externalRealtimeOrderBook.source,
|
||||
};
|
||||
}
|
||||
|
||||
if (initialData && hasOrderBookLevelData(initialData)) {
|
||||
return {
|
||||
...initialData,
|
||||
totalAskSize: externalRealtimeOrderBook.totalAskSize,
|
||||
totalBidSize: externalRealtimeOrderBook.totalBidSize,
|
||||
anticipatedPrice: externalRealtimeOrderBook.anticipatedPrice,
|
||||
anticipatedVolume: externalRealtimeOrderBook.anticipatedVolume,
|
||||
anticipatedTotalVolume: externalRealtimeOrderBook.anticipatedTotalVolume,
|
||||
anticipatedChange: externalRealtimeOrderBook.anticipatedChange,
|
||||
anticipatedChangeSign: externalRealtimeOrderBook.anticipatedChangeSign,
|
||||
anticipatedChangeRate: externalRealtimeOrderBook.anticipatedChangeRate,
|
||||
accumulatedVolume: externalRealtimeOrderBook.accumulatedVolume,
|
||||
totalAskSizeDelta: externalRealtimeOrderBook.totalAskSizeDelta,
|
||||
totalBidSizeDelta: externalRealtimeOrderBook.totalBidSizeDelta,
|
||||
businessHour: externalRealtimeOrderBook.businessHour ?? initialData.businessHour,
|
||||
hourClassCode: externalRealtimeOrderBook.hourClassCode ?? initialData.hourClassCode,
|
||||
fetchedAt: externalRealtimeOrderBook.fetchedAt,
|
||||
tradingEnv: externalRealtimeOrderBook.tradingEnv,
|
||||
source: externalRealtimeOrderBook.source,
|
||||
};
|
||||
}
|
||||
|
||||
return externalRealtimeOrderBook;
|
||||
}
|
||||
|
||||
return initialData;
|
||||
}, [externalRealtimeOrderBook, initialData, isRequestEnabled, lastRealtimeWithLevels]);
|
||||
|
||||
const mergedError = isRequestEnabled ? error : null;
|
||||
const mergedLoading = isRequestEnabled ? isLoading && !orderBook : false;
|
||||
|
||||
@@ -89,3 +159,13 @@ export function useOrderBook(
|
||||
isWsConnected: !!externalRealtimeOrderBook,
|
||||
};
|
||||
}
|
||||
|
||||
function hasOrderBookLevelData(orderBook: DashboardStockOrderBookResponse) {
|
||||
return orderBook.levels.some(
|
||||
(level) =>
|
||||
level.askPrice > 0 ||
|
||||
level.bidPrice > 0 ||
|
||||
level.askSize > 0 ||
|
||||
level.bidSize > 0,
|
||||
);
|
||||
}
|
||||
|
||||
63
features/trade/hooks/useOrderbookSubscription.ts
Normal file
63
features/trade/hooks/useOrderbookSubscription.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { useRef, useEffect } from "react";
|
||||
import type { KisRuntimeCredentials } from "@/features/settings/store/use-kis-runtime-store";
|
||||
import type { DashboardStockOrderBookResponse } from "@/features/trade/types/trade.types";
|
||||
import { useKisWebSocketStore } from "@/features/kis-realtime/stores/kisWebSocketStore";
|
||||
import {
|
||||
parseKisRealtimeOrderbook,
|
||||
resolveOrderBookTrIds,
|
||||
} from "@/features/trade/utils/kisRealtimeUtils";
|
||||
import type { DomesticKisSession } from "@/lib/kis/domestic-market-session";
|
||||
|
||||
interface UseOrderbookSubscriptionParams {
|
||||
symbol: string | undefined; // orderBookSymbol
|
||||
isVerified: boolean;
|
||||
credentials: KisRuntimeCredentials | null;
|
||||
marketSession: DomesticKisSession;
|
||||
onOrderBookMessage?: (data: DashboardStockOrderBookResponse) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 실시간 호가(Orderbook) 구독 로직을 담당하는 훅입니다.
|
||||
* - 호가 데이터는 빈도가 매우 높으므로 별도의 상태(state)에 저장하지 않고,
|
||||
* - 콜백 함수(onOrderBookMessage)를 통해 상위 컴포넌트로 데이터를 직접 전달합니다.
|
||||
* - 이를 통해 불필요한 리렌더링을 방지합니다.
|
||||
*/
|
||||
export function useOrderbookSubscription({
|
||||
symbol,
|
||||
isVerified,
|
||||
credentials,
|
||||
marketSession,
|
||||
onOrderBookMessage,
|
||||
}: UseOrderbookSubscriptionParams) {
|
||||
const { subscribe, connect } = useKisWebSocketStore();
|
||||
const onOrderBookMessageRef = useRef(onOrderBookMessage);
|
||||
|
||||
useEffect(() => {
|
||||
onOrderBookMessageRef.current = onOrderBookMessage;
|
||||
}, [onOrderBookMessage]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!symbol || !isVerified || !credentials) return;
|
||||
|
||||
connect();
|
||||
|
||||
const trIds = resolveOrderBookTrIds(credentials.tradingEnv, marketSession);
|
||||
const unsubscribers: Array<() => void> = [];
|
||||
|
||||
const handleOrderBookMessage = (data: string) => {
|
||||
const ob = parseKisRealtimeOrderbook(data, symbol);
|
||||
if (ob) {
|
||||
ob.tradingEnv = credentials.tradingEnv;
|
||||
onOrderBookMessageRef.current?.(ob);
|
||||
}
|
||||
};
|
||||
|
||||
for (const trId of trIds) {
|
||||
unsubscribers.push(subscribe(trId, symbol, handleOrderBookMessage));
|
||||
}
|
||||
|
||||
return () => {
|
||||
unsubscribers.forEach((unsub) => unsub());
|
||||
};
|
||||
}, [symbol, isVerified, credentials, marketSession, connect, subscribe]);
|
||||
}
|
||||
110
features/trade/hooks/useTradeTickSubscription.ts
Normal file
110
features/trade/hooks/useTradeTickSubscription.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import type { KisRuntimeCredentials } from "@/features/settings/store/use-kis-runtime-store";
|
||||
import type { DashboardRealtimeTradeTick } from "@/features/trade/types/trade.types";
|
||||
import { useKisWebSocketStore } from "@/features/kis-realtime/stores/kisWebSocketStore";
|
||||
import {
|
||||
parseKisRealtimeTickBatch,
|
||||
resolveTradeTrIds,
|
||||
} from "@/features/trade/utils/kisRealtimeUtils";
|
||||
import type { DomesticKisSession } from "@/lib/kis/domestic-market-session";
|
||||
|
||||
const MAX_TRADE_TICKS = 10;
|
||||
|
||||
interface UseTradeTickSubscriptionParams {
|
||||
symbol: string | undefined;
|
||||
isVerified: boolean;
|
||||
credentials: KisRuntimeCredentials | null;
|
||||
marketSession: DomesticKisSession;
|
||||
onTick?: (tick: DashboardRealtimeTradeTick) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 실시간 체결가(Tick) 구독 로직을 담당하는 훅입니다.
|
||||
* - 웹소켓을 통해 들어오는 체결 데이터를 파싱(parsing)하고
|
||||
* - 중복 데이터(deduplication)를 필터링하며
|
||||
* - 최근 N개의 체결 내역(recentTradeTicks)과 최신 체결가(latestTick) 상태를 관리합니다.
|
||||
*/
|
||||
export function useTradeTickSubscription({
|
||||
symbol,
|
||||
isVerified,
|
||||
credentials,
|
||||
marketSession,
|
||||
onTick,
|
||||
}: UseTradeTickSubscriptionParams) {
|
||||
const [latestTick, setLatestTick] =
|
||||
useState<DashboardRealtimeTradeTick | null>(null);
|
||||
const [recentTradeTicks, setRecentTradeTicks] = useState<
|
||||
DashboardRealtimeTradeTick[]
|
||||
>([]);
|
||||
const [lastTickAt, setLastTickAt] = useState<number | null>(null);
|
||||
const seenTickRef = useRef<Set<string>>(new Set());
|
||||
|
||||
const { subscribe, connect } = useKisWebSocketStore();
|
||||
const onTickRef = useRef(onTick);
|
||||
|
||||
useEffect(() => {
|
||||
onTickRef.current = onTick;
|
||||
}, [onTick]);
|
||||
|
||||
// 1. 심볼이 변경되면 상태를 초기화합니다.
|
||||
// 1. 심볼 변경 시 상태 초기화 (Render-time adjustment)
|
||||
const [prevSymbol, setPrevSymbol] = useState(symbol);
|
||||
if (symbol !== prevSymbol) {
|
||||
setPrevSymbol(symbol);
|
||||
setLatestTick(null);
|
||||
setRecentTradeTicks([]);
|
||||
setLastTickAt(null);
|
||||
}
|
||||
|
||||
// Ref는 렌더링 도중 수정하면 안 되므로 useEffect에서 초기화
|
||||
useEffect(() => {
|
||||
seenTickRef.current.clear();
|
||||
}, [symbol]);
|
||||
|
||||
// 2. 실시간 데이터 구독
|
||||
useEffect(() => {
|
||||
if (!symbol || !isVerified || !credentials) return;
|
||||
|
||||
connect();
|
||||
|
||||
const trIds = resolveTradeTrIds(credentials.tradingEnv, marketSession);
|
||||
const unsubscribers: Array<() => void> = [];
|
||||
|
||||
const handleTradeMessage = (data: string) => {
|
||||
const ticks = parseKisRealtimeTickBatch(data, symbol);
|
||||
if (ticks.length === 0) return;
|
||||
|
||||
const meaningfulTicks = ticks.filter((tick) => tick.tradeVolume > 0);
|
||||
if (meaningfulTicks.length === 0) return;
|
||||
|
||||
const dedupedTicks = meaningfulTicks.filter((tick) => {
|
||||
const key = `${tick.tickTime}-${tick.price}-${tick.tradeVolume}`;
|
||||
if (seenTickRef.current.has(key)) return false;
|
||||
seenTickRef.current.add(key);
|
||||
if (seenTickRef.current.size > 5_000) seenTickRef.current.clear();
|
||||
return true;
|
||||
});
|
||||
|
||||
const latest = meaningfulTicks[meaningfulTicks.length - 1];
|
||||
setLatestTick(latest);
|
||||
setLastTickAt(Date.now());
|
||||
|
||||
if (dedupedTicks.length > 0) {
|
||||
setRecentTradeTicks((prev) =>
|
||||
[...dedupedTicks.reverse(), ...prev].slice(0, MAX_TRADE_TICKS),
|
||||
);
|
||||
}
|
||||
onTickRef.current?.(latest);
|
||||
};
|
||||
|
||||
for (const trId of trIds) {
|
||||
unsubscribers.push(subscribe(trId, symbol, handleTradeMessage));
|
||||
}
|
||||
|
||||
return () => {
|
||||
unsubscribers.forEach((unsub) => unsub());
|
||||
};
|
||||
}, [symbol, isVerified, credentials, marketSession, connect, subscribe]);
|
||||
|
||||
return { latestTick, recentTradeTicks, lastTickAt };
|
||||
}
|
||||
@@ -2,6 +2,11 @@ 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([
|
||||
@@ -38,55 +43,28 @@ const TICK_FIELD_INDEX = {
|
||||
executionClassCode: 21,
|
||||
} 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 이벤트에서 체결 패킷 파싱에 사용됩니다.
|
||||
*/
|
||||
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[];
|
||||
|
||||
// TR ID check: regular tick / expected tick / after-hours tick.
|
||||
const receivedTrId = parts[1];
|
||||
const isExecutedTick = EXECUTED_REALTIME_TRADE_TR_IDS.has(receivedTrId);
|
||||
const isExpectedTick = EXPECTED_REALTIME_TRADE_TR_IDS.has(receivedTrId);
|
||||
// 체결 화면에는 "실제 체결 TR"만 반영하고 예상체결 TR은 제외합니다.
|
||||
|
||||
if (!isExecutedTick || isExpectedTick) {
|
||||
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[];
|
||||
@@ -164,9 +142,91 @@ export function parseKisRealtimeTickBatch(raw: string, expectedSymbol: string) {
|
||||
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 구조로 파싱합니다.
|
||||
* @see features/trade/hooks/useKisTradeWebSocket.ts onOrderBookMessage 콜백 데이터 생성에 사용됩니다.
|
||||
*/
|
||||
export function parseKisRealtimeOrderbook(
|
||||
raw: string,
|
||||
@@ -182,7 +242,9 @@ export function parseKisRealtimeOrderbook(
|
||||
}
|
||||
|
||||
const values = parts[3].split("^");
|
||||
const levelCount = trId === "H0STOAA0" ? 9 : 10;
|
||||
// 시간외(H0STOAA0)는 문서 버전에 따라 9레벨/10레벨이 혼재할 수 있어
|
||||
// payload 길이로 레벨 수를 동적으로 판별합니다.
|
||||
const levelCount = trId === "H0STOAA0" ? (values.length >= 56 ? 10 : 9) : 10;
|
||||
|
||||
const symbol = values[0]?.trim() ?? "";
|
||||
const normalizedSymbol = normalizeDomesticSymbol(symbol);
|
||||
@@ -195,7 +257,9 @@ export function parseKisRealtimeOrderbook(
|
||||
const bidSizeStart = askSizeStart + levelCount;
|
||||
const totalAskIndex = bidSizeStart + levelCount;
|
||||
const totalBidIndex = totalAskIndex + 1;
|
||||
const anticipatedPriceIndex = totalBidIndex + 3;
|
||||
const overtimeTotalAskIndex = totalBidIndex + 1;
|
||||
const overtimeTotalBidIndex = overtimeTotalAskIndex + 1;
|
||||
const anticipatedPriceIndex = overtimeTotalBidIndex + 1;
|
||||
const anticipatedVolumeIndex = anticipatedPriceIndex + 1;
|
||||
const anticipatedTotalVolumeIndex = anticipatedPriceIndex + 2;
|
||||
const anticipatedChangeIndex = anticipatedPriceIndex + 3;
|
||||
@@ -215,10 +279,18 @@ export function parseKisRealtimeOrderbook(
|
||||
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,
|
||||
totalAskSize: readNumber(values, totalAskIndex),
|
||||
totalBidSize: readNumber(values, totalBidIndex),
|
||||
// 장후 시간외에서는 일반 총잔량이 0이고 OVTM 총잔량만 채워지는 경우가 있습니다.
|
||||
totalAskSize:
|
||||
regularTotalAskSize > 0 ? regularTotalAskSize : overtimeTotalAskSize,
|
||||
totalBidSize:
|
||||
regularTotalBidSize > 0 ? regularTotalBidSize : overtimeTotalBidSize,
|
||||
businessHour: readString(values, 1),
|
||||
hourClassCode: readString(values, 2),
|
||||
anticipatedPrice: readNumber(values, anticipatedPriceIndex),
|
||||
@@ -239,7 +311,6 @@ export function parseKisRealtimeOrderbook(
|
||||
|
||||
/**
|
||||
* @description 국내 종목코드 비교를 위해 접두 문자를 제거하고 6자리 코드로 정규화합니다.
|
||||
* @see features/trade/utils/kis-realtime.utils.ts parseKisRealtimeOrderbook 종목 매칭 비교에 사용됩니다.
|
||||
*/
|
||||
function normalizeDomesticSymbol(value: string) {
|
||||
const trimmed = value.trim();
|
||||
@@ -261,3 +332,7 @@ function readNumber(values: string[], index: number) {
|
||||
const value = Number(raw);
|
||||
return Number.isFinite(value) ? value : 0;
|
||||
}
|
||||
|
||||
function uniqueTrIds(ids: string[]) {
|
||||
return [...new Set(ids)];
|
||||
}
|
||||
Reference in New Issue
Block a user