2026-02-13 12:17:35 +09:00
|
|
|
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 {
|
2026-02-23 15:37:22 +09:00
|
|
|
extractKisRealtimeTrId,
|
2026-02-13 12:17:35 +09:00
|
|
|
parseKisRealtimeTickBatch,
|
|
|
|
|
resolveTradeTrIds,
|
2026-02-23 15:37:22 +09:00
|
|
|
shouldAcceptRealtimeMessageByPriority,
|
2026-02-13 12:17:35 +09:00
|
|
|
} from "@/features/trade/utils/kisRealtimeUtils";
|
|
|
|
|
import type { DomesticKisSession } from "@/lib/kis/domestic-market-session";
|
|
|
|
|
|
|
|
|
|
const MAX_TRADE_TICKS = 10;
|
2026-02-23 15:37:22 +09:00
|
|
|
const STABLE_SOURCE_STALE_MS = Number.POSITIVE_INFINITY;
|
|
|
|
|
const FLEXIBLE_SOURCE_STALE_MS = 3_000;
|
2026-02-13 12:17:35 +09:00
|
|
|
|
|
|
|
|
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());
|
2026-02-23 15:37:22 +09:00
|
|
|
const activeTradeTrIdRef = useRef<string | null>(null);
|
|
|
|
|
const activeTradeTrUpdatedAtRef = useRef(0);
|
2026-02-13 12:17:35 +09:00
|
|
|
|
2026-02-24 15:43:56 +09:00
|
|
|
const subscribeRef = useRef(useKisWebSocketStore.getState().subscribe);
|
|
|
|
|
const connectRef = useRef(useKisWebSocketStore.getState().connect);
|
2026-02-13 12:17:35 +09:00
|
|
|
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();
|
2026-02-23 15:37:22 +09:00
|
|
|
activeTradeTrIdRef.current = null;
|
|
|
|
|
activeTradeTrUpdatedAtRef.current = 0;
|
2026-02-13 12:17:35 +09:00
|
|
|
}, [symbol]);
|
|
|
|
|
|
|
|
|
|
// 2. 실시간 데이터 구독
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!symbol || !isVerified || !credentials) return;
|
|
|
|
|
|
2026-02-24 15:43:56 +09:00
|
|
|
connectRef.current();
|
2026-02-13 12:17:35 +09:00
|
|
|
|
|
|
|
|
const trIds = resolveTradeTrIds(credentials.tradingEnv, marketSession);
|
|
|
|
|
const unsubscribers: Array<() => void> = [];
|
|
|
|
|
|
|
|
|
|
const handleTradeMessage = (data: string) => {
|
2026-02-23 15:37:22 +09:00
|
|
|
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;
|
|
|
|
|
|
2026-02-13 12:17:35 +09:00
|
|
|
const ticks = parseKisRealtimeTickBatch(data, symbol);
|
|
|
|
|
if (ticks.length === 0) return;
|
|
|
|
|
|
2026-02-23 15:37:22 +09:00
|
|
|
const executedTicks = ticks.filter(
|
|
|
|
|
(tick) => !tick.isExpected && tick.tradeVolume > 0,
|
|
|
|
|
);
|
|
|
|
|
const expectedTicks = ticks.filter((tick) => tick.isExpected);
|
|
|
|
|
|
|
|
|
|
// 시간외 예상체결은 가격 갱신용으로만 사용하고, 체결목록(recentTradeTicks)에는 넣지 않습니다.
|
|
|
|
|
// 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();
|
2026-02-13 12:17:35 +09:00
|
|
|
|
2026-02-23 15:37:22 +09:00
|
|
|
const dedupedTicks = executedTicks.filter((tick) => {
|
|
|
|
|
const key = `exe-${incomingTrId}-${tick.tickTime}-${tick.price}-${tick.tradeVolume}`;
|
2026-02-13 12:17:35 +09:00
|
|
|
if (seenTickRef.current.has(key)) return false;
|
|
|
|
|
seenTickRef.current.add(key);
|
|
|
|
|
if (seenTickRef.current.size > 5_000) seenTickRef.current.clear();
|
|
|
|
|
return true;
|
|
|
|
|
});
|
|
|
|
|
|
2026-02-23 15:37:22 +09:00
|
|
|
const latest = executedTicks[executedTicks.length - 1];
|
2026-02-13 12:17:35 +09:00
|
|
|
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) {
|
2026-02-24 15:43:56 +09:00
|
|
|
unsubscribers.push(
|
|
|
|
|
subscribeRef.current(trId, symbol, handleTradeMessage),
|
|
|
|
|
);
|
2026-02-13 12:17:35 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return () => {
|
|
|
|
|
unsubscribers.forEach((unsub) => unsub());
|
|
|
|
|
};
|
2026-02-24 15:43:56 +09:00
|
|
|
}, [symbol, isVerified, credentials, marketSession]);
|
2026-02-13 12:17:35 +09:00
|
|
|
|
|
|
|
|
return { latestTick, recentTradeTicks, lastTickAt };
|
|
|
|
|
}
|