실시간 웹소켓 리팩토링
This commit is contained in:
@@ -52,10 +52,16 @@ const subscribers = new Map<string, Set<RealtimeCallback>>();
|
|||||||
const subscriberCounts = new Map<string, number>(); // 실제 소켓 구독 요청 여부 추적용
|
const subscriberCounts = new Map<string, number>(); // 실제 소켓 구독 요청 여부 추적용
|
||||||
|
|
||||||
let socket: WebSocket | null = null;
|
let socket: WebSocket | null = null;
|
||||||
let pingInterval: number | undefined;
|
|
||||||
let isConnecting = false; // 연결 진행 중 상태 잠금
|
let isConnecting = false; // 연결 진행 중 상태 잠금
|
||||||
let reconnectRetryTimer: number | undefined;
|
let reconnectRetryTimer: number | undefined;
|
||||||
let lastAppKeyConflictAt = 0;
|
let lastAppKeyConflictAt = 0;
|
||||||
|
let reconnectAttempt = 0;
|
||||||
|
let manualDisconnectRequested = false;
|
||||||
|
|
||||||
|
const MAX_AUTO_RECONNECT_ATTEMPTS = 8;
|
||||||
|
const RECONNECT_BASE_DELAY_MS = 1_000;
|
||||||
|
const RECONNECT_MAX_DELAY_MS = 30_000;
|
||||||
|
const RECONNECT_JITTER_MS = 300;
|
||||||
|
|
||||||
export const useKisWebSocketStore = create<KisWebSocketState>((set, get) => ({
|
export const useKisWebSocketStore = create<KisWebSocketState>((set, get) => ({
|
||||||
isConnected: false,
|
isConnected: false,
|
||||||
@@ -63,6 +69,9 @@ export const useKisWebSocketStore = create<KisWebSocketState>((set, get) => ({
|
|||||||
|
|
||||||
connect: async (options) => {
|
connect: async (options) => {
|
||||||
const forceApprovalRefresh = options?.forceApprovalRefresh ?? false;
|
const forceApprovalRefresh = options?.forceApprovalRefresh ?? false;
|
||||||
|
manualDisconnectRequested = false;
|
||||||
|
window.clearTimeout(reconnectRetryTimer);
|
||||||
|
reconnectRetryTimer = undefined;
|
||||||
const currentSocket = socket;
|
const currentSocket = socket;
|
||||||
|
|
||||||
if (currentSocket?.readyState === WebSocket.CLOSING) {
|
if (currentSocket?.readyState === WebSocket.CLOSING) {
|
||||||
@@ -70,12 +79,7 @@ export const useKisWebSocketStore = create<KisWebSocketState>((set, get) => ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 1. 이미 연결되어 있거나, 연결 시도 중이면 중복 실행 방지
|
// 1. 이미 연결되어 있거나, 연결 시도 중이면 중복 실행 방지
|
||||||
if (
|
if (isSocketUnavailableForNewConnect(socket) || isConnecting) {
|
||||||
socket?.readyState === WebSocket.OPEN ||
|
|
||||||
socket?.readyState === WebSocket.CONNECTING ||
|
|
||||||
socket?.readyState === WebSocket.CLOSING ||
|
|
||||||
isConnecting
|
|
||||||
) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,10 +93,7 @@ export const useKisWebSocketStore = create<KisWebSocketState>((set, get) => ({
|
|||||||
const wsConnection = await getOrFetchWsConnection();
|
const wsConnection = await getOrFetchWsConnection();
|
||||||
|
|
||||||
// 비동기 대기 중에 다른 연결이 성사되었는지 다시 확인
|
// 비동기 대기 중에 다른 연결이 성사되었는지 다시 확인
|
||||||
if (
|
if (isSocketOpenOrConnecting(socket)) {
|
||||||
socket?.readyState === WebSocket.OPEN ||
|
|
||||||
socket?.readyState === WebSocket.CONNECTING
|
|
||||||
) {
|
|
||||||
isConnecting = false;
|
isConnecting = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -113,6 +114,7 @@ export const useKisWebSocketStore = create<KisWebSocketState>((set, get) => ({
|
|||||||
if (socket !== ws) return;
|
if (socket !== ws) return;
|
||||||
|
|
||||||
set({ isConnected: true, error: null });
|
set({ isConnected: true, error: null });
|
||||||
|
reconnectAttempt = 0;
|
||||||
console.log("[KisWebSocket] Connected");
|
console.log("[KisWebSocket] Connected");
|
||||||
|
|
||||||
// 재연결 시 기존 구독 복구
|
// 재연결 시 기존 구독 복구
|
||||||
@@ -127,23 +129,46 @@ export const useKisWebSocketStore = create<KisWebSocketState>((set, get) => ({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// PINGPONG (Keep-alive)
|
|
||||||
window.clearInterval(pingInterval);
|
|
||||||
pingInterval = window.setInterval(() => {
|
|
||||||
if (socket?.readyState === WebSocket.OPEN) {
|
|
||||||
socket.send("PINGPONG"); // 일부 환경에서는 PINGPONG 텍스트 전송
|
|
||||||
}
|
|
||||||
}, 100_000); // 100초 주기
|
|
||||||
};
|
};
|
||||||
|
|
||||||
ws.onclose = () => {
|
ws.onclose = (event) => {
|
||||||
if (socket === ws) {
|
if (socket === ws) {
|
||||||
isConnecting = false;
|
isConnecting = false;
|
||||||
set({ isConnected: false });
|
set({ isConnected: false });
|
||||||
console.log("[KisWebSocket] Disconnected");
|
|
||||||
window.clearInterval(pingInterval);
|
|
||||||
socket = null;
|
socket = null;
|
||||||
|
|
||||||
|
const hasSubscribers = hasActiveRealtimeSubscribers();
|
||||||
|
const canAutoReconnect =
|
||||||
|
!manualDisconnectRequested &&
|
||||||
|
hasSubscribers &&
|
||||||
|
reconnectAttempt < MAX_AUTO_RECONNECT_ATTEMPTS;
|
||||||
|
|
||||||
|
if (canAutoReconnect) {
|
||||||
|
reconnectAttempt += 1;
|
||||||
|
const delayMs = getReconnectDelayMs(reconnectAttempt);
|
||||||
|
console.warn(
|
||||||
|
`[KisWebSocket] Disconnected (code=${event.code}, reason=${event.reason || "none"}) -> reconnect in ${delayMs}ms (attempt ${reconnectAttempt}/${MAX_AUTO_RECONNECT_ATTEMPTS})`,
|
||||||
|
);
|
||||||
|
|
||||||
|
window.clearTimeout(reconnectRetryTimer);
|
||||||
|
reconnectRetryTimer = window.setTimeout(() => {
|
||||||
|
const refreshApproval = reconnectAttempt % 3 === 0;
|
||||||
|
void get().reconnect({ refreshApproval });
|
||||||
|
}, delayMs);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasSubscribers && reconnectAttempt >= MAX_AUTO_RECONNECT_ATTEMPTS) {
|
||||||
|
set({
|
||||||
|
error:
|
||||||
|
"실시간 연결이 반복 종료되어 자동 재연결을 중단했습니다. 새로고침 또는 수동 재연결을 시도해 주세요.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
reconnectAttempt = 0;
|
||||||
|
console.log(
|
||||||
|
`[KisWebSocket] Disconnected (code=${event.code}, reason=${event.reason || "none"})`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -165,7 +190,17 @@ export const useKisWebSocketStore = create<KisWebSocketState>((set, get) => ({
|
|||||||
// PINGPONG 응답 또는 제어 메시지 처리
|
// PINGPONG 응답 또는 제어 메시지 처리
|
||||||
if (data.startsWith("{")) {
|
if (data.startsWith("{")) {
|
||||||
const control = parseControlMessage(data);
|
const control = parseControlMessage(data);
|
||||||
if (control?.rt_cd && control.rt_cd !== "0") {
|
if (!control) return;
|
||||||
|
|
||||||
|
if (control.trId === "PINGPONG") {
|
||||||
|
// KIS 샘플 구현과 동일하게 원문을 그대로 echo하여 연결 유지를 보조합니다.
|
||||||
|
if (socket === ws && ws.readyState === WebSocket.OPEN) {
|
||||||
|
ws.send(data);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (control.rtCd && control.rtCd !== "0") {
|
||||||
const errorMessage = buildControlErrorMessage(control);
|
const errorMessage = buildControlErrorMessage(control);
|
||||||
set({
|
set({
|
||||||
error: errorMessage,
|
error: errorMessage,
|
||||||
@@ -173,7 +208,7 @@ export const useKisWebSocketStore = create<KisWebSocketState>((set, get) => ({
|
|||||||
|
|
||||||
// KIS 제어 메시지: ALREADY IN USE appkey
|
// KIS 제어 메시지: ALREADY IN USE appkey
|
||||||
// 이전 세션이 닫히기 전에 재연결될 때 간헐적으로 발생합니다.
|
// 이전 세션이 닫히기 전에 재연결될 때 간헐적으로 발생합니다.
|
||||||
if (control.msg_cd === "OPSP8996") {
|
if (control.msgCd === "OPSP8996") {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
if (now - lastAppKeyConflictAt > 5_000) {
|
if (now - lastAppKeyConflictAt > 5_000) {
|
||||||
lastAppKeyConflictAt = now;
|
lastAppKeyConflictAt = now;
|
||||||
@@ -183,6 +218,14 @@ export const useKisWebSocketStore = create<KisWebSocketState>((set, get) => ({
|
|||||||
}, 1_200);
|
}, 1_200);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 승인키가 유효하지 않을 때는 승인키 재발급 후 재연결합니다.
|
||||||
|
if (control.msgCd === "OPSP0011") {
|
||||||
|
window.clearTimeout(reconnectRetryTimer);
|
||||||
|
reconnectRetryTimer = window.setTimeout(() => {
|
||||||
|
void get().reconnect({ refreshApproval: true });
|
||||||
|
}, 1_200);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -212,6 +255,7 @@ export const useKisWebSocketStore = create<KisWebSocketState>((set, get) => ({
|
|||||||
|
|
||||||
reconnect: async (options) => {
|
reconnect: async (options) => {
|
||||||
const refreshApproval = options?.refreshApproval ?? false;
|
const refreshApproval = options?.refreshApproval ?? false;
|
||||||
|
manualDisconnectRequested = false;
|
||||||
const currentSocket = socket;
|
const currentSocket = socket;
|
||||||
get().disconnect();
|
get().disconnect();
|
||||||
if (currentSocket && currentSocket.readyState !== WebSocket.CLOSED) {
|
if (currentSocket && currentSocket.readyState !== WebSocket.CLOSED) {
|
||||||
@@ -223,6 +267,7 @@ export const useKisWebSocketStore = create<KisWebSocketState>((set, get) => ({
|
|||||||
},
|
},
|
||||||
|
|
||||||
disconnect: () => {
|
disconnect: () => {
|
||||||
|
manualDisconnectRequested = true;
|
||||||
const currentSocket = socket;
|
const currentSocket = socket;
|
||||||
if (
|
if (
|
||||||
currentSocket &&
|
currentSocket &&
|
||||||
@@ -236,8 +281,9 @@ export const useKisWebSocketStore = create<KisWebSocketState>((set, get) => ({
|
|||||||
socket = null;
|
socket = null;
|
||||||
}
|
}
|
||||||
set({ isConnected: false });
|
set({ isConnected: false });
|
||||||
window.clearInterval(pingInterval);
|
|
||||||
window.clearTimeout(reconnectRetryTimer);
|
window.clearTimeout(reconnectRetryTimer);
|
||||||
|
reconnectRetryTimer = undefined;
|
||||||
|
reconnectAttempt = 0;
|
||||||
isConnecting = false;
|
isConnecting = false;
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -310,9 +356,12 @@ function sendSubscription(
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface KisWsControlMessage {
|
interface KisWsControlMessage {
|
||||||
rt_cd?: string;
|
trId?: string;
|
||||||
msg_cd?: string;
|
trKey?: string;
|
||||||
|
rtCd?: string;
|
||||||
|
msgCd?: string;
|
||||||
msg1?: string;
|
msg1?: string;
|
||||||
|
encrypt?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -323,8 +372,32 @@ interface KisWsControlMessage {
|
|||||||
*/
|
*/
|
||||||
function parseControlMessage(rawData: string): KisWsControlMessage | null {
|
function parseControlMessage(rawData: string): KisWsControlMessage | null {
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(rawData) as KisWsControlMessage;
|
const parsed = JSON.parse(rawData) as {
|
||||||
return parsed && typeof parsed === "object" ? parsed : null;
|
header?: {
|
||||||
|
tr_id?: string;
|
||||||
|
tr_key?: string;
|
||||||
|
encrypt?: string;
|
||||||
|
};
|
||||||
|
body?: {
|
||||||
|
rt_cd?: string;
|
||||||
|
msg_cd?: string;
|
||||||
|
msg1?: string;
|
||||||
|
};
|
||||||
|
rt_cd?: string;
|
||||||
|
msg_cd?: string;
|
||||||
|
msg1?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!parsed || typeof parsed !== "object") return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
trId: parsed.header?.tr_id,
|
||||||
|
trKey: parsed.header?.tr_key,
|
||||||
|
encrypt: parsed.header?.encrypt,
|
||||||
|
rtCd: parsed.body?.rt_cd ?? parsed.rt_cd,
|
||||||
|
msgCd: parsed.body?.msg_cd ?? parsed.msg_cd,
|
||||||
|
msg1: parsed.body?.msg1 ?? parsed.msg1,
|
||||||
|
};
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -337,13 +410,67 @@ function parseControlMessage(rawData: string): KisWsControlMessage | null {
|
|||||||
* @see features/kis-realtime/stores/kisWebSocketStore.ts ws.onmessage
|
* @see features/kis-realtime/stores/kisWebSocketStore.ts ws.onmessage
|
||||||
*/
|
*/
|
||||||
function buildControlErrorMessage(message: KisWsControlMessage) {
|
function buildControlErrorMessage(message: KisWsControlMessage) {
|
||||||
if (message.msg_cd === "OPSP8996") {
|
if (message.msgCd === "OPSP8996") {
|
||||||
return "실시간 연결이 다른 세션과 충돌해 재연결을 시도합니다.";
|
return "실시간 연결이 다른 세션과 충돌해 재연결을 시도합니다.";
|
||||||
}
|
}
|
||||||
const detail = [message.msg1, message.msg_cd].filter(Boolean).join(" / ");
|
const detail = [message.msg1, message.msgCd].filter(Boolean).join(" / ");
|
||||||
return detail ? `실시간 제어 메시지 오류: ${detail}` : "실시간 제어 메시지 오류";
|
return detail ? `실시간 제어 메시지 오류: ${detail}` : "실시간 제어 메시지 오류";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description 활성화된 웹소켓 구독이 존재하는지 반환합니다.
|
||||||
|
* @returns 구독 중인 TR/심볼이 1개 이상이면 true
|
||||||
|
* @see features/kis-realtime/stores/kisWebSocketStore.ts ws.onclose
|
||||||
|
*/
|
||||||
|
function hasActiveRealtimeSubscribers() {
|
||||||
|
for (const count of subscriberCounts.values()) {
|
||||||
|
if (count > 0) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description 자동 재연결 시도 횟수에 따라 지수 백오프 지연시간(ms)을 계산합니다.
|
||||||
|
* @param attempt 1부터 시작하는 재연결 시도 횟수
|
||||||
|
* @returns 지연시간(ms)
|
||||||
|
* @see features/kis-realtime/stores/kisWebSocketStore.ts ws.onclose
|
||||||
|
*/
|
||||||
|
function getReconnectDelayMs(attempt: number) {
|
||||||
|
const exponential = RECONNECT_BASE_DELAY_MS * 2 ** Math.max(0, attempt - 1);
|
||||||
|
const clamped = Math.min(exponential, RECONNECT_MAX_DELAY_MS);
|
||||||
|
const jitter = Math.floor(Math.random() * RECONNECT_JITTER_MS);
|
||||||
|
return clamped + jitter;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description 소켓이 OPEN 또는 CONNECTING 상태인지 검사합니다.
|
||||||
|
* @param target 검사 대상 소켓
|
||||||
|
* @returns 연결 유지/진행 상태면 true
|
||||||
|
* @see features/kis-realtime/stores/kisWebSocketStore.ts connect
|
||||||
|
*/
|
||||||
|
function isSocketOpenOrConnecting(target: WebSocket | null) {
|
||||||
|
if (!target) return false;
|
||||||
|
return (
|
||||||
|
target.readyState === WebSocket.OPEN ||
|
||||||
|
target.readyState === WebSocket.CONNECTING
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description 새 연결을 시작하면 안 되는 소켓 상태인지 검사합니다.
|
||||||
|
* @param target 검사 대상 소켓
|
||||||
|
* @returns OPEN/CONNECTING/CLOSING 중 하나면 true
|
||||||
|
* @see features/kis-realtime/stores/kisWebSocketStore.ts connect
|
||||||
|
*/
|
||||||
|
function isSocketUnavailableForNewConnect(target: WebSocket | null) {
|
||||||
|
if (!target) return false;
|
||||||
|
return (
|
||||||
|
target.readyState === WebSocket.OPEN ||
|
||||||
|
target.readyState === WebSocket.CONNECTING ||
|
||||||
|
target.readyState === WebSocket.CLOSING
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @description 특정 웹소켓 인스턴스가 완전히 닫힐 때까지 대기합니다.
|
* @description 특정 웹소켓 인스턴스가 완전히 닫힐 때까지 대기합니다.
|
||||||
* @param target 대기할 웹소켓 인스턴스
|
* @param target 대기할 웹소켓 인스턴스
|
||||||
|
|||||||
@@ -148,6 +148,7 @@ export function TradeContainer() {
|
|||||||
updateRealtimeTradeTick,
|
updateRealtimeTradeTick,
|
||||||
{
|
{
|
||||||
orderBookSymbol: selectedStock?.symbol,
|
orderBookSymbol: selectedStock?.symbol,
|
||||||
|
orderBookMarket: selectedStock?.market,
|
||||||
onOrderBookMessage: handleOrderBookMessage,
|
onOrderBookMessage: handleOrderBookMessage,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -89,7 +89,6 @@ export function TradeDashboardContent({
|
|||||||
<OrderBook
|
<OrderBook
|
||||||
symbol={selectedStock?.symbol}
|
symbol={selectedStock?.symbol}
|
||||||
referencePrice={referencePrice}
|
referencePrice={referencePrice}
|
||||||
currentPrice={currentPrice}
|
|
||||||
latestTick={latestTick}
|
latestTick={latestTick}
|
||||||
recentTicks={recentTradeTicks}
|
recentTicks={recentTradeTicks}
|
||||||
orderBook={orderBook}
|
orderBook={orderBook}
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import { AnimatedQuantity } from "./AnimatedQuantity";
|
|||||||
interface OrderBookProps {
|
interface OrderBookProps {
|
||||||
symbol?: string;
|
symbol?: string;
|
||||||
referencePrice?: number;
|
referencePrice?: number;
|
||||||
currentPrice?: number;
|
|
||||||
latestTick: DashboardRealtimeTradeTick | null;
|
latestTick: DashboardRealtimeTradeTick | null;
|
||||||
recentTicks: DashboardRealtimeTradeTick[];
|
recentTicks: DashboardRealtimeTradeTick[];
|
||||||
orderBook: DashboardStockOrderBookResponse | null;
|
orderBook: DashboardStockOrderBookResponse | null;
|
||||||
@@ -24,7 +23,7 @@ interface OrderBookProps {
|
|||||||
interface BookRow {
|
interface BookRow {
|
||||||
price: number;
|
price: number;
|
||||||
size: number;
|
size: number;
|
||||||
changePercent: number | null;
|
changeValue: number | null;
|
||||||
isHighlighted: boolean;
|
isHighlighted: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,6 +78,51 @@ function pctChange(price: number, base: number) {
|
|||||||
return base > 0 ? ((price - base) / base) * 100 : 0;
|
return base > 0 ? ((price - base) / base) * 100 : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description 기준가 대비 증감값/증감률을 함께 계산합니다.
|
||||||
|
* @see features/trade/components/orderbook/OrderBook.tsx buildBookRows
|
||||||
|
*/
|
||||||
|
function resolvePriceChange(price: number, basePrice: number) {
|
||||||
|
if (price <= 0 || basePrice <= 0) {
|
||||||
|
return { changeValue: null } as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
const changeValue = price - basePrice;
|
||||||
|
|
||||||
|
return { changeValue } as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description 증감 숫자를 부호 포함 문자열로 포맷합니다.
|
||||||
|
* @see features/trade/components/orderbook/OrderBook.tsx BookSideRows
|
||||||
|
*/
|
||||||
|
function fmtSignedChange(v: number) {
|
||||||
|
if (!Number.isFinite(v)) return "-";
|
||||||
|
if (v > 0) return `+${fmt(v)}`;
|
||||||
|
if (v < 0) return `-${fmt(Math.abs(v))}`;
|
||||||
|
return "0";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description 증감값에 따라 색상 톤 클래스를 반환합니다.
|
||||||
|
* @see features/trade/components/orderbook/OrderBook.tsx BookSideRows
|
||||||
|
*/
|
||||||
|
function getChangeToneClass(
|
||||||
|
changeValue: number | null,
|
||||||
|
neutralClass = "text-muted-foreground",
|
||||||
|
) {
|
||||||
|
if (changeValue === null) {
|
||||||
|
return neutralClass;
|
||||||
|
}
|
||||||
|
if (changeValue > 0) {
|
||||||
|
return "text-red-500";
|
||||||
|
}
|
||||||
|
if (changeValue < 0) {
|
||||||
|
return "text-blue-600 dark:text-blue-400";
|
||||||
|
}
|
||||||
|
return neutralClass;
|
||||||
|
}
|
||||||
|
|
||||||
/** 체결 시각 포맷 */
|
/** 체결 시각 포맷 */
|
||||||
function fmtTime(hms: string) {
|
function fmtTime(hms: string) {
|
||||||
if (!hms || hms.length !== 6) return "--:--:--";
|
if (!hms || hms.length !== 6) return "--:--:--";
|
||||||
@@ -131,6 +175,65 @@ function resolveTickExecutionSide(
|
|||||||
return "neutral" as const;
|
return "neutral" as const;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description 호가 레벨을 화면 렌더링용 행 모델로 변환합니다.
|
||||||
|
* UI 흐름: OrderBook(levels/basePrice/latestPrice) -> buildBookRows -> BookSideRows -> 가격/증감 반영
|
||||||
|
* @see features/trade/components/orderbook/OrderBook.tsx OrderBook askRows/bidRows 계산
|
||||||
|
*/
|
||||||
|
function buildBookRows({
|
||||||
|
levels,
|
||||||
|
side,
|
||||||
|
basePrice,
|
||||||
|
latestPrice,
|
||||||
|
}: {
|
||||||
|
levels: DashboardStockOrderBookResponse["levels"];
|
||||||
|
side: "ask" | "bid";
|
||||||
|
basePrice: number;
|
||||||
|
latestPrice: number;
|
||||||
|
}) {
|
||||||
|
const normalizedLevels = side === "ask" ? [...levels].reverse() : levels;
|
||||||
|
|
||||||
|
return normalizedLevels.map((level) => {
|
||||||
|
const price = side === "ask" ? level.askPrice : level.bidPrice;
|
||||||
|
const size = side === "ask" ? level.askSize : level.bidSize;
|
||||||
|
const { changeValue } = resolvePriceChange(price, basePrice);
|
||||||
|
|
||||||
|
return {
|
||||||
|
price,
|
||||||
|
size: Math.max(size, 0),
|
||||||
|
changeValue,
|
||||||
|
isHighlighted: latestPrice > 0 && price === latestPrice,
|
||||||
|
} satisfies BookRow;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description 호가/체결 증감 표시용 기준가(전일종가)를 결정합니다.
|
||||||
|
* @summary UI 흐름: OrderBook props -> resolveReferencePrice -> 호가행/체결가 증감 계산 반영
|
||||||
|
* @see features/trade/components/orderbook/OrderBook.tsx basePrice 계산
|
||||||
|
*/
|
||||||
|
function resolveReferencePrice({
|
||||||
|
referencePrice,
|
||||||
|
latestTick,
|
||||||
|
}: {
|
||||||
|
referencePrice?: number;
|
||||||
|
latestTick: DashboardRealtimeTradeTick | null;
|
||||||
|
}) {
|
||||||
|
if ((referencePrice ?? 0) > 0) {
|
||||||
|
return referencePrice!;
|
||||||
|
}
|
||||||
|
|
||||||
|
// referencePrice 미전달 케이스에서도 틱 데이터(price-change)로 전일종가를 역산합니다.
|
||||||
|
if (latestTick?.price && Number.isFinite(latestTick.change)) {
|
||||||
|
const derivedPrevClose = latestTick.price - latestTick.change;
|
||||||
|
if (derivedPrevClose > 0) {
|
||||||
|
return derivedPrevClose;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
// ─── 메인 컴포넌트 ──────────────────────────────────────
|
// ─── 메인 컴포넌트 ──────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -139,7 +242,6 @@ function resolveTickExecutionSide(
|
|||||||
export function OrderBook({
|
export function OrderBook({
|
||||||
symbol,
|
symbol,
|
||||||
referencePrice,
|
referencePrice,
|
||||||
currentPrice,
|
|
||||||
latestTick,
|
latestTick,
|
||||||
recentTicks,
|
recentTicks,
|
||||||
orderBook,
|
orderBook,
|
||||||
@@ -162,42 +264,29 @@ export function OrderBook({
|
|||||||
latestTick?.price && latestTick.price > 0 ? latestTick.price : 0;
|
latestTick?.price && latestTick.price > 0 ? latestTick.price : 0;
|
||||||
|
|
||||||
// 등락률 기준가
|
// 등락률 기준가
|
||||||
const basePrice =
|
const basePrice = resolveReferencePrice({ referencePrice, latestTick });
|
||||||
(referencePrice ?? 0) > 0
|
|
||||||
? referencePrice!
|
|
||||||
: (currentPrice ?? 0) > 0
|
|
||||||
? currentPrice!
|
|
||||||
: latestPrice > 0
|
|
||||||
? latestPrice
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
// 매도호가 (역순: 10호가 → 1호가)
|
// 매도호가 (역순: 10호가 → 1호가)
|
||||||
const askRows: BookRow[] = useMemo(
|
const askRows: BookRow[] = useMemo(
|
||||||
() =>
|
() =>
|
||||||
[...levels].reverse().map((l) => ({
|
buildBookRows({
|
||||||
price: l.askPrice,
|
levels,
|
||||||
size: Math.max(l.askSize, 0),
|
side: "ask",
|
||||||
changePercent:
|
basePrice,
|
||||||
l.askPrice > 0 && basePrice > 0
|
latestPrice,
|
||||||
? pctChange(l.askPrice, basePrice)
|
}),
|
||||||
: null,
|
|
||||||
isHighlighted: latestPrice > 0 && l.askPrice === latestPrice,
|
|
||||||
})),
|
|
||||||
[levels, basePrice, latestPrice],
|
[levels, basePrice, latestPrice],
|
||||||
);
|
);
|
||||||
|
|
||||||
// 매수호가 (1호가 → 10호가)
|
// 매수호가 (1호가 → 10호가)
|
||||||
const bidRows: BookRow[] = useMemo(
|
const bidRows: BookRow[] = useMemo(
|
||||||
() =>
|
() =>
|
||||||
levels.map((l) => ({
|
buildBookRows({
|
||||||
price: l.bidPrice,
|
levels,
|
||||||
size: Math.max(l.bidSize, 0),
|
side: "bid",
|
||||||
changePercent:
|
basePrice,
|
||||||
l.bidPrice > 0 && basePrice > 0
|
latestPrice,
|
||||||
? pctChange(l.bidPrice, basePrice)
|
}),
|
||||||
: null,
|
|
||||||
isHighlighted: latestPrice > 0 && l.bidPrice === latestPrice,
|
|
||||||
})),
|
|
||||||
[levels, basePrice, latestPrice],
|
[levels, basePrice, latestPrice],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -439,15 +528,11 @@ function BookSideRows({
|
|||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-[10px]",
|
"w-[58px] shrink-0 text-right text-[10px] tabular-nums",
|
||||||
row.changePercent !== null
|
getChangeToneClass(row.changeValue),
|
||||||
? row.changePercent >= 0
|
|
||||||
? "text-red-500"
|
|
||||||
: "text-blue-600 dark:text-blue-400"
|
|
||||||
: "text-muted-foreground",
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{row.changePercent === null ? "-" : fmtPct(row.changePercent)}
|
{row.changeValue === null ? "-" : fmtSignedChange(row.changeValue)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -493,6 +578,11 @@ function SummaryPanel({
|
|||||||
totalAsk: number;
|
totalAsk: number;
|
||||||
totalBid: number;
|
totalBid: number;
|
||||||
}) {
|
}) {
|
||||||
|
const displayTradeVolume =
|
||||||
|
latestTick?.isExpected && (orderBook?.anticipatedVolume ?? 0) > 0
|
||||||
|
? (orderBook?.anticipatedVolume ?? 0)
|
||||||
|
: (latestTick?.tradeVolume ?? orderBook?.anticipatedVolume ?? 0);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-w-0 border-l bg-muted/10 p-2 text-[11px] dark:border-brand-800/45 dark:bg-brand-900/30">
|
<div className="min-w-0 border-l bg-muted/10 p-2 text-[11px] dark:border-brand-800/45 dark:bg-brand-900/30">
|
||||||
<Row
|
<Row
|
||||||
@@ -502,7 +592,7 @@ function SummaryPanel({
|
|||||||
/>
|
/>
|
||||||
<Row
|
<Row
|
||||||
label="거래량"
|
label="거래량"
|
||||||
value={fmt(latestTick?.tradeVolume ?? orderBook?.anticipatedVolume ?? 0)}
|
value={fmt(displayTradeVolume)}
|
||||||
/>
|
/>
|
||||||
<Row
|
<Row
|
||||||
label="누적거래량"
|
label="누적거래량"
|
||||||
@@ -635,7 +725,12 @@ function TradeTape({ ticks }: { ticks: DashboardRealtimeTradeTick[] }) {
|
|||||||
<div className="flex items-center tabular-nums">
|
<div className="flex items-center tabular-nums">
|
||||||
{fmtTime(t.tickTime)}
|
{fmtTime(t.tickTime)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-end tabular-nums text-red-600">
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex items-center justify-end tabular-nums",
|
||||||
|
getChangeToneClass(t.change, "text-foreground dark:text-brand-50"),
|
||||||
|
)}
|
||||||
|
>
|
||||||
{fmt(t.price)}
|
{fmt(t.price)}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -25,9 +25,16 @@ export function useCurrentPrice({
|
|||||||
// 1. Priority: Realtime Tick (Trade WS)
|
// 1. Priority: Realtime Tick (Trade WS)
|
||||||
if (latestTick?.price && latestTick.price > 0) {
|
if (latestTick?.price && latestTick.price > 0) {
|
||||||
currentPrice = latestTick.price;
|
currentPrice = latestTick.price;
|
||||||
|
// 실시간 틱(change/changeRate)은 TR 종류(실체결/예상체결)에 따라 해석 차이가 있을 수 있어
|
||||||
|
// 화면 표시는 항상 전일종가(prevClose) 기준으로 재계산해 일관성을 유지합니다.
|
||||||
|
if (prevClose > 0) {
|
||||||
|
change = currentPrice - prevClose;
|
||||||
|
changeRate = (change / prevClose) * 100;
|
||||||
|
} else {
|
||||||
change = latestTick.change;
|
change = latestTick.change;
|
||||||
changeRate = latestTick.changeRate;
|
changeRate = latestTick.changeRate;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
// 2. Fallback: OrderBook Best Ask (Proxy for current price if no tick)
|
// 2. Fallback: OrderBook Best Ask (Proxy for current price if no tick)
|
||||||
else if (
|
else if (
|
||||||
orderBook?.levels[0]?.askPrice &&
|
orderBook?.levels[0]?.askPrice &&
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export function useKisTradeWebSocket(
|
|||||||
onTick?: (tick: DashboardRealtimeTradeTick) => void,
|
onTick?: (tick: DashboardRealtimeTradeTick) => void,
|
||||||
options?: {
|
options?: {
|
||||||
orderBookSymbol?: string;
|
orderBookSymbol?: string;
|
||||||
|
orderBookMarket?: "KOSPI" | "KOSDAQ";
|
||||||
onOrderBookMessage?: (data: DashboardStockOrderBookResponse) => void;
|
onOrderBookMessage?: (data: DashboardStockOrderBookResponse) => void;
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
@@ -45,6 +46,7 @@ export function useKisTradeWebSocket(
|
|||||||
|
|
||||||
useOrderbookSubscription({
|
useOrderbookSubscription({
|
||||||
symbol: options?.orderBookSymbol,
|
symbol: options?.orderBookSymbol,
|
||||||
|
market: options?.orderBookMarket,
|
||||||
isVerified,
|
isVerified,
|
||||||
credentials,
|
credentials,
|
||||||
marketSession,
|
marketSession,
|
||||||
|
|||||||
@@ -3,18 +3,23 @@ import type { KisRuntimeCredentials } from "@/features/settings/store/use-kis-ru
|
|||||||
import type { DashboardStockOrderBookResponse } from "@/features/trade/types/trade.types";
|
import type { DashboardStockOrderBookResponse } from "@/features/trade/types/trade.types";
|
||||||
import { useKisWebSocketStore } from "@/features/kis-realtime/stores/kisWebSocketStore";
|
import { useKisWebSocketStore } from "@/features/kis-realtime/stores/kisWebSocketStore";
|
||||||
import {
|
import {
|
||||||
|
extractKisRealtimeTrId,
|
||||||
|
hasMeaningfulOrderBookPayload,
|
||||||
parseKisRealtimeOrderbook,
|
parseKisRealtimeOrderbook,
|
||||||
resolveOrderBookTrIds,
|
resolveOrderBookTrIds,
|
||||||
|
shouldAcceptRealtimeMessageByPriority,
|
||||||
} from "@/features/trade/utils/kisRealtimeUtils";
|
} from "@/features/trade/utils/kisRealtimeUtils";
|
||||||
import type { DomesticKisSession } from "@/lib/kis/domestic-market-session";
|
import type { DomesticKisSession } from "@/lib/kis/domestic-market-session";
|
||||||
|
|
||||||
interface UseOrderbookSubscriptionParams {
|
interface UseOrderbookSubscriptionParams {
|
||||||
symbol: string | undefined; // orderBookSymbol
|
symbol: string | undefined; // orderBookSymbol
|
||||||
|
market: "KOSPI" | "KOSDAQ" | undefined;
|
||||||
isVerified: boolean;
|
isVerified: boolean;
|
||||||
credentials: KisRuntimeCredentials | null;
|
credentials: KisRuntimeCredentials | null;
|
||||||
marketSession: DomesticKisSession;
|
marketSession: DomesticKisSession;
|
||||||
onOrderBookMessage?: (data: DashboardStockOrderBookResponse) => void;
|
onOrderBookMessage?: (data: DashboardStockOrderBookResponse) => void;
|
||||||
}
|
}
|
||||||
|
const STABLE_SOURCE_STALE_MS = Number.POSITIVE_INFINITY;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @description 실시간 호가(Orderbook) 구독 로직을 담당하는 훅입니다.
|
* @description 실시간 호가(Orderbook) 구독 로직을 담당하는 훅입니다.
|
||||||
@@ -24,6 +29,7 @@ interface UseOrderbookSubscriptionParams {
|
|||||||
*/
|
*/
|
||||||
export function useOrderbookSubscription({
|
export function useOrderbookSubscription({
|
||||||
symbol,
|
symbol,
|
||||||
|
market,
|
||||||
isVerified,
|
isVerified,
|
||||||
credentials,
|
credentials,
|
||||||
marketSession,
|
marketSession,
|
||||||
@@ -31,6 +37,8 @@ export function useOrderbookSubscription({
|
|||||||
}: UseOrderbookSubscriptionParams) {
|
}: UseOrderbookSubscriptionParams) {
|
||||||
const { subscribe, connect } = useKisWebSocketStore();
|
const { subscribe, connect } = useKisWebSocketStore();
|
||||||
const onOrderBookMessageRef = useRef(onOrderBookMessage);
|
const onOrderBookMessageRef = useRef(onOrderBookMessage);
|
||||||
|
const activeOrderBookTrIdRef = useRef<string | null>(null);
|
||||||
|
const activeOrderBookTrUpdatedAtRef = useRef(0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
onOrderBookMessageRef.current = onOrderBookMessage;
|
onOrderBookMessageRef.current = onOrderBookMessage;
|
||||||
@@ -41,12 +49,34 @@ export function useOrderbookSubscription({
|
|||||||
|
|
||||||
connect();
|
connect();
|
||||||
|
|
||||||
const trIds = resolveOrderBookTrIds(credentials.tradingEnv, marketSession);
|
const trIds = resolveOrderBookTrIds(
|
||||||
|
credentials.tradingEnv,
|
||||||
|
marketSession,
|
||||||
|
market,
|
||||||
|
);
|
||||||
const unsubscribers: Array<() => void> = [];
|
const unsubscribers: Array<() => void> = [];
|
||||||
|
|
||||||
const handleOrderBookMessage = (data: string) => {
|
const handleOrderBookMessage = (data: string) => {
|
||||||
|
const incomingTrId = extractKisRealtimeTrId(data);
|
||||||
|
if (!incomingTrId) return;
|
||||||
|
|
||||||
|
// UI 흐름: 소켓 수신 -> TR 우선순위 고정(시장별 상위 소스 우선) -> 파싱 -> 호가 상태 반영
|
||||||
|
const shouldAccept = shouldAcceptRealtimeMessageByPriority({
|
||||||
|
incomingTrId,
|
||||||
|
preferredTrIds: trIds,
|
||||||
|
activeTrId: activeOrderBookTrIdRef.current,
|
||||||
|
activeTrUpdatedAtMs: activeOrderBookTrUpdatedAtRef.current,
|
||||||
|
// 정규장/동시호가에서는 한 번 잡은 상위 소스를 유지해 소스 간 왕복 반영을 막습니다.
|
||||||
|
staleAfterMs: STABLE_SOURCE_STALE_MS,
|
||||||
|
});
|
||||||
|
if (!shouldAccept) return;
|
||||||
|
|
||||||
const ob = parseKisRealtimeOrderbook(data, symbol);
|
const ob = parseKisRealtimeOrderbook(data, symbol);
|
||||||
if (ob) {
|
if (ob) {
|
||||||
|
if (hasMeaningfulOrderBookPayload(ob)) {
|
||||||
|
activeOrderBookTrIdRef.current = incomingTrId;
|
||||||
|
activeOrderBookTrUpdatedAtRef.current = Date.now();
|
||||||
|
}
|
||||||
ob.tradingEnv = credentials.tradingEnv;
|
ob.tradingEnv = credentials.tradingEnv;
|
||||||
onOrderBookMessageRef.current?.(ob);
|
onOrderBookMessageRef.current?.(ob);
|
||||||
}
|
}
|
||||||
@@ -58,6 +88,8 @@ export function useOrderbookSubscription({
|
|||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
unsubscribers.forEach((unsub) => unsub());
|
unsubscribers.forEach((unsub) => unsub());
|
||||||
|
activeOrderBookTrIdRef.current = null;
|
||||||
|
activeOrderBookTrUpdatedAtRef.current = 0;
|
||||||
};
|
};
|
||||||
}, [symbol, isVerified, credentials, marketSession, connect, subscribe]);
|
}, [symbol, market, isVerified, credentials, marketSession, connect, subscribe]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,12 +3,16 @@ import type { KisRuntimeCredentials } from "@/features/settings/store/use-kis-ru
|
|||||||
import type { DashboardRealtimeTradeTick } from "@/features/trade/types/trade.types";
|
import type { DashboardRealtimeTradeTick } from "@/features/trade/types/trade.types";
|
||||||
import { useKisWebSocketStore } from "@/features/kis-realtime/stores/kisWebSocketStore";
|
import { useKisWebSocketStore } from "@/features/kis-realtime/stores/kisWebSocketStore";
|
||||||
import {
|
import {
|
||||||
|
extractKisRealtimeTrId,
|
||||||
parseKisRealtimeTickBatch,
|
parseKisRealtimeTickBatch,
|
||||||
resolveTradeTrIds,
|
resolveTradeTrIds,
|
||||||
|
shouldAcceptRealtimeMessageByPriority,
|
||||||
} from "@/features/trade/utils/kisRealtimeUtils";
|
} from "@/features/trade/utils/kisRealtimeUtils";
|
||||||
import type { DomesticKisSession } from "@/lib/kis/domestic-market-session";
|
import type { DomesticKisSession } from "@/lib/kis/domestic-market-session";
|
||||||
|
|
||||||
const MAX_TRADE_TICKS = 10;
|
const MAX_TRADE_TICKS = 10;
|
||||||
|
const STABLE_SOURCE_STALE_MS = Number.POSITIVE_INFINITY;
|
||||||
|
const FLEXIBLE_SOURCE_STALE_MS = 3_000;
|
||||||
|
|
||||||
interface UseTradeTickSubscriptionParams {
|
interface UseTradeTickSubscriptionParams {
|
||||||
symbol: string | undefined;
|
symbol: string | undefined;
|
||||||
@@ -38,6 +42,8 @@ export function useTradeTickSubscription({
|
|||||||
>([]);
|
>([]);
|
||||||
const [lastTickAt, setLastTickAt] = useState<number | null>(null);
|
const [lastTickAt, setLastTickAt] = useState<number | null>(null);
|
||||||
const seenTickRef = useRef<Set<string>>(new Set());
|
const seenTickRef = useRef<Set<string>>(new Set());
|
||||||
|
const activeTradeTrIdRef = useRef<string | null>(null);
|
||||||
|
const activeTradeTrUpdatedAtRef = useRef(0);
|
||||||
|
|
||||||
const { subscribe, connect } = useKisWebSocketStore();
|
const { subscribe, connect } = useKisWebSocketStore();
|
||||||
const onTickRef = useRef(onTick);
|
const onTickRef = useRef(onTick);
|
||||||
@@ -59,6 +65,8 @@ export function useTradeTickSubscription({
|
|||||||
// Ref는 렌더링 도중 수정하면 안 되므로 useEffect에서 초기화
|
// Ref는 렌더링 도중 수정하면 안 되므로 useEffect에서 초기화
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
seenTickRef.current.clear();
|
seenTickRef.current.clear();
|
||||||
|
activeTradeTrIdRef.current = null;
|
||||||
|
activeTradeTrUpdatedAtRef.current = 0;
|
||||||
}, [symbol]);
|
}, [symbol]);
|
||||||
|
|
||||||
// 2. 실시간 데이터 구독
|
// 2. 실시간 데이터 구독
|
||||||
@@ -71,21 +79,63 @@ export function useTradeTickSubscription({
|
|||||||
const unsubscribers: Array<() => void> = [];
|
const unsubscribers: Array<() => void> = [];
|
||||||
|
|
||||||
const handleTradeMessage = (data: string) => {
|
const handleTradeMessage = (data: string) => {
|
||||||
|
const incomingTrId = extractKisRealtimeTrId(data);
|
||||||
|
if (!incomingTrId) return;
|
||||||
|
|
||||||
|
// UI 흐름: 소켓 수신 -> TR 우선순위 고정(ST 우선) -> 파싱 -> 상태 반영
|
||||||
|
const shouldAccept = shouldAcceptRealtimeMessageByPriority({
|
||||||
|
incomingTrId,
|
||||||
|
preferredTrIds: trIds,
|
||||||
|
activeTrId: activeTradeTrIdRef.current,
|
||||||
|
activeTrUpdatedAtMs: activeTradeTrUpdatedAtRef.current,
|
||||||
|
// 장중에는 상위 소스를 고정하고, 시간외에서는 상위 소스가 잠잠할 때 하위(예상체결)로 폴백 허용
|
||||||
|
staleAfterMs:
|
||||||
|
marketSession === "regular" ||
|
||||||
|
marketSession === "openAuction" ||
|
||||||
|
marketSession === "closeAuction"
|
||||||
|
? STABLE_SOURCE_STALE_MS
|
||||||
|
: FLEXIBLE_SOURCE_STALE_MS,
|
||||||
|
});
|
||||||
|
if (!shouldAccept) return;
|
||||||
|
|
||||||
const ticks = parseKisRealtimeTickBatch(data, symbol);
|
const ticks = parseKisRealtimeTickBatch(data, symbol);
|
||||||
if (ticks.length === 0) return;
|
if (ticks.length === 0) return;
|
||||||
|
|
||||||
const meaningfulTicks = ticks.filter((tick) => tick.tradeVolume > 0);
|
const executedTicks = ticks.filter(
|
||||||
if (meaningfulTicks.length === 0) return;
|
(tick) => !tick.isExpected && tick.tradeVolume > 0,
|
||||||
|
);
|
||||||
|
const expectedTicks = ticks.filter((tick) => tick.isExpected);
|
||||||
|
|
||||||
const dedupedTicks = meaningfulTicks.filter((tick) => {
|
// 시간외 예상체결은 가격 갱신용으로만 사용하고, 체결목록(recentTradeTicks)에는 넣지 않습니다.
|
||||||
const key = `${tick.tickTime}-${tick.price}-${tick.tradeVolume}`;
|
// UI 흐름: 소켓 수신 -> 예상체결 파싱 -> latestTick 갱신 -> 헤더/중앙가 반영
|
||||||
|
if (executedTicks.length === 0 && expectedTicks.length > 0) {
|
||||||
|
const latestExpected = expectedTicks[expectedTicks.length - 1];
|
||||||
|
const expectedForDisplay = {
|
||||||
|
...latestExpected,
|
||||||
|
tradeVolume: 0,
|
||||||
|
};
|
||||||
|
activeTradeTrIdRef.current = incomingTrId;
|
||||||
|
activeTradeTrUpdatedAtRef.current = Date.now();
|
||||||
|
setLatestTick(expectedForDisplay);
|
||||||
|
setLastTickAt(Date.now());
|
||||||
|
onTickRef.current?.(expectedForDisplay);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (executedTicks.length === 0) return;
|
||||||
|
|
||||||
|
activeTradeTrIdRef.current = incomingTrId;
|
||||||
|
activeTradeTrUpdatedAtRef.current = Date.now();
|
||||||
|
|
||||||
|
const dedupedTicks = executedTicks.filter((tick) => {
|
||||||
|
const key = `exe-${incomingTrId}-${tick.tickTime}-${tick.price}-${tick.tradeVolume}`;
|
||||||
if (seenTickRef.current.has(key)) return false;
|
if (seenTickRef.current.has(key)) return false;
|
||||||
seenTickRef.current.add(key);
|
seenTickRef.current.add(key);
|
||||||
if (seenTickRef.current.size > 5_000) seenTickRef.current.clear();
|
if (seenTickRef.current.size > 5_000) seenTickRef.current.clear();
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
const latest = meaningfulTicks[meaningfulTicks.length - 1];
|
const latest = executedTicks[executedTicks.length - 1];
|
||||||
setLatestTick(latest);
|
setLatestTick(latest);
|
||||||
setLastTickAt(Date.now());
|
setLastTickAt(Date.now());
|
||||||
|
|
||||||
|
|||||||
@@ -141,6 +141,8 @@ export interface DashboardStockOrderBookResponse {
|
|||||||
*/
|
*/
|
||||||
export interface DashboardRealtimeTradeTick {
|
export interface DashboardRealtimeTradeTick {
|
||||||
symbol: string;
|
symbol: string;
|
||||||
|
trId?: string;
|
||||||
|
isExpected?: boolean;
|
||||||
tickTime: string;
|
tickTime: string;
|
||||||
price: number;
|
price: number;
|
||||||
change: number;
|
change: number;
|
||||||
|
|||||||
@@ -56,6 +56,18 @@ const TRADE_TR_ID_TOTAL_EXPECTED = "H0UNANC0";
|
|||||||
const ORDERBOOK_TR_ID = "H0STASP0";
|
const ORDERBOOK_TR_ID = "H0STASP0";
|
||||||
const ORDERBOOK_TR_ID_TOTAL = "H0UNASP0";
|
const ORDERBOOK_TR_ID_TOTAL = "H0UNASP0";
|
||||||
const ORDERBOOK_TR_ID_OVERTIME = "H0STOAA0";
|
const ORDERBOOK_TR_ID_OVERTIME = "H0STOAA0";
|
||||||
|
const DEFAULT_REALTIME_TR_STALE_MS = 3_000;
|
||||||
|
const OVERTIME_ORDERBOOK_MIN_FIELDS_9 = 52;
|
||||||
|
const OVERTIME_ORDERBOOK_MIN_FIELDS_10 = 56;
|
||||||
|
|
||||||
|
interface RealtimeTrPriorityDecisionParams {
|
||||||
|
incomingTrId: string;
|
||||||
|
preferredTrIds: string[];
|
||||||
|
activeTrId: string | null;
|
||||||
|
activeTrUpdatedAtMs: number;
|
||||||
|
nowMs?: number;
|
||||||
|
staleAfterMs?: number;
|
||||||
|
}
|
||||||
export function parseKisRealtimeTickBatch(raw: string, expectedSymbol: string) {
|
export function parseKisRealtimeTickBatch(raw: string, expectedSymbol: string) {
|
||||||
if (!/^([01])\|/.test(raw)) return [] as DashboardRealtimeTradeTick[];
|
if (!/^([01])\|/.test(raw)) return [] as DashboardRealtimeTradeTick[];
|
||||||
|
|
||||||
@@ -66,7 +78,10 @@ export function parseKisRealtimeTickBatch(raw: string, expectedSymbol: string) {
|
|||||||
const isExecutedTick = EXECUTED_REALTIME_TRADE_TR_IDS.has(receivedTrId);
|
const isExecutedTick = EXECUTED_REALTIME_TRADE_TR_IDS.has(receivedTrId);
|
||||||
const isExpectedTick = EXPECTED_REALTIME_TRADE_TR_IDS.has(receivedTrId);
|
const isExpectedTick = EXPECTED_REALTIME_TRADE_TR_IDS.has(receivedTrId);
|
||||||
|
|
||||||
if (!isExecutedTick || isExpectedTick) {
|
// 정규 체결(TR)뿐 아니라 시간외/동시호가 예상체결(TR)도 틱 화면에 반영합니다.
|
||||||
|
// UI 흐름: useTradeTickSubscription -> resolveTradeTrIds(세션별 TR) -> parseKisRealtimeTickBatch
|
||||||
|
// 시간외 구간(예: H0STOAC0 only 수신)에서 틱이 비는 문제를 방지합니다.
|
||||||
|
if (!isExecutedTick && !isExpectedTick) {
|
||||||
return [] as DashboardRealtimeTradeTick[];
|
return [] as DashboardRealtimeTradeTick[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,6 +125,8 @@ export function parseKisRealtimeTickBatch(raw: string, expectedSymbol: string) {
|
|||||||
|
|
||||||
ticks.push({
|
ticks.push({
|
||||||
symbol: normalizedExpected,
|
symbol: normalizedExpected,
|
||||||
|
trId: receivedTrId,
|
||||||
|
isExpected: isExpectedTick,
|
||||||
tickTime: readString(values, base + TICK_FIELD_INDEX.tickTime),
|
tickTime: readString(values, base + TICK_FIELD_INDEX.tickTime),
|
||||||
price,
|
price,
|
||||||
change,
|
change,
|
||||||
@@ -197,6 +214,7 @@ export function resolveTradeTrIds(
|
|||||||
export function resolveOrderBookTrIds(
|
export function resolveOrderBookTrIds(
|
||||||
env: "real" | "mock",
|
env: "real" | "mock",
|
||||||
session: DomesticKisSession,
|
session: DomesticKisSession,
|
||||||
|
market?: "KOSPI" | "KOSDAQ",
|
||||||
) {
|
) {
|
||||||
if (env === "mock") return [ORDERBOOK_TR_ID];
|
if (env === "mock") return [ORDERBOOK_TR_ID];
|
||||||
|
|
||||||
@@ -218,9 +236,60 @@ export function resolveOrderBookTrIds(
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 통합장(통합호가) 값이 체결앱과 더 잘 맞는 케이스가 있어
|
||||||
|
// 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]);
|
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 호가 패킷이 실제 표시 가능한 값(호가/잔량/총잔량)을 포함하는지 확인합니다.
|
* @description 호가 패킷이 실제 표시 가능한 값(호가/잔량/총잔량)을 포함하는지 확인합니다.
|
||||||
*/
|
*/
|
||||||
@@ -270,20 +339,31 @@ export function parseKisRealtimeOrderbook(
|
|||||||
// 시간외(H0STOAA0)는 문서 버전에 따라 9호가/10호가가 혼재할 수 있어
|
// 시간외(H0STOAA0)는 문서 버전에 따라 9호가/10호가가 혼재할 수 있어
|
||||||
// 두 형식을 모두 시도한 뒤 의미 있는 데이터 점수가 더 높은 결과를 선택합니다.
|
// 두 형식을 모두 시도한 뒤 의미 있는 데이터 점수가 더 높은 결과를 선택합니다.
|
||||||
if (trId === "H0STOAA0") {
|
if (trId === "H0STOAA0") {
|
||||||
const parsedByNineLevels = parseOrderBookByLevelCount(
|
const candidates: DashboardStockOrderBookResponse[] = [];
|
||||||
values,
|
|
||||||
normalizedExpected,
|
// 길이가 충분하면 10호가를 우선 시도합니다.
|
||||||
9,
|
if (values.length >= OVERTIME_ORDERBOOK_MIN_FIELDS_10) {
|
||||||
);
|
|
||||||
const parsedByTenLevels = parseOrderBookByLevelCount(
|
const parsedByTenLevels = parseOrderBookByLevelCount(
|
||||||
values,
|
values,
|
||||||
normalizedExpected,
|
normalizedExpected,
|
||||||
10,
|
10,
|
||||||
);
|
);
|
||||||
|
if (parsedByTenLevels) {
|
||||||
|
candidates.push(parsedByTenLevels);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const candidates = [parsedByNineLevels, parsedByTenLevels].filter(
|
if (values.length >= OVERTIME_ORDERBOOK_MIN_FIELDS_9) {
|
||||||
(item): item is DashboardStockOrderBookResponse => item !== null,
|
const parsedByNineLevels = parseOrderBookByLevelCount(
|
||||||
|
values,
|
||||||
|
normalizedExpected,
|
||||||
|
9,
|
||||||
);
|
);
|
||||||
|
if (parsedByNineLevels) {
|
||||||
|
candidates.push(parsedByNineLevels);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (candidates.length === 0) return null;
|
if (candidates.length === 0) return null;
|
||||||
|
|
||||||
return pickBestOrderBookPayload(candidates);
|
return pickBestOrderBookPayload(candidates);
|
||||||
@@ -411,11 +491,30 @@ function scoreOrderBookPayload(payload: DashboardStockOrderBookResponse) {
|
|||||||
level.bidSize > 0,
|
level.bidSize > 0,
|
||||||
).length;
|
).length;
|
||||||
|
|
||||||
return (
|
let score =
|
||||||
nonZeroLevels * 10 +
|
nonZeroLevels * 10 +
|
||||||
(payload.totalAskSize > 0 ? 4 : 0) +
|
(payload.totalAskSize > 0 ? 4 : 0) +
|
||||||
(payload.totalBidSize > 0 ? 4 : 0) +
|
(payload.totalBidSize > 0 ? 4 : 0) +
|
||||||
((payload.anticipatedPrice ?? 0) > 0 ? 2 : 0) +
|
((payload.anticipatedPrice ?? 0) > 0 ? 2 : 0) +
|
||||||
((payload.accumulatedVolume ?? 0) > 0 ? 1 : 0)
|
((payload.accumulatedVolume ?? 0) > 0 ? 1 : 0);
|
||||||
);
|
|
||||||
|
// 시간외 9/10호가 파서 후보 중 잘못 정렬된 결과를 배제하기 위한 가격 밴드 검증입니다.
|
||||||
|
// (예: anticipatedPrice가 총잔량값으로 잘못 파싱되는 케이스)
|
||||||
|
const ladderPrices = payload.levels.flatMap((level) => [
|
||||||
|
level.askPrice,
|
||||||
|
level.bidPrice,
|
||||||
|
]);
|
||||||
|
const positivePrices = ladderPrices.filter((price) => price > 0);
|
||||||
|
const anticipatedPrice = payload.anticipatedPrice ?? 0;
|
||||||
|
|
||||||
|
if (anticipatedPrice > 0 && positivePrices.length > 0) {
|
||||||
|
const minPrice = Math.min(...positivePrices);
|
||||||
|
const maxPrice = Math.max(...positivePrices);
|
||||||
|
const isInReasonableBand =
|
||||||
|
anticipatedPrice >= minPrice * 0.7 && anticipatedPrice <= maxPrice * 1.3;
|
||||||
|
|
||||||
|
score += isInReasonableBand ? 12 : -80;
|
||||||
|
}
|
||||||
|
|
||||||
|
return score;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user