차트 수정
This commit is contained in:
@@ -6,53 +6,50 @@ import {
|
||||
import type {
|
||||
DashboardRealtimeTradeTick,
|
||||
DashboardStockOrderBookResponse,
|
||||
StockCandlePoint,
|
||||
} from "@/features/dashboard/types/dashboard.types";
|
||||
import {
|
||||
appendRealtimeTick,
|
||||
buildKisRealtimeMessage,
|
||||
formatRealtimeTickTime,
|
||||
parseKisRealtimeOrderbook,
|
||||
parseKisRealtimeTickBatch,
|
||||
toTickOrderValue,
|
||||
} from "@/features/dashboard/utils/kis-realtime.utils";
|
||||
import {
|
||||
DOMESTIC_KIS_SESSION_OVERRIDE_STORAGE_KEY,
|
||||
resolveDomesticKisSession,
|
||||
shouldUseAfterHoursSinglePriceTr,
|
||||
shouldUseExpectedExecutionTr,
|
||||
type DomesticKisSession,
|
||||
} from "@/lib/kis/domestic-market-session";
|
||||
|
||||
// ─── TR ID 상수 ─────────────────────────────────────────
|
||||
const TRADE_TR_ID = "H0STCNT0"; // 체결 (실전/모의 공통)
|
||||
const TRADE_TR_ID_OVERTIME = "H0STOUP0"; // 시간외 단일가
|
||||
const ORDERBOOK_TR_ID = "H0STASP0"; // 호가 (정규장)
|
||||
const ORDERBOOK_TR_ID_OVERTIME = "H0STOAA0"; // 호가 (시간외)
|
||||
|
||||
const TRADE_TR_ID = "H0STCNT0";
|
||||
const TRADE_TR_ID_EXPECTED = "H0STANC0";
|
||||
const TRADE_TR_ID_OVERTIME = "H0STOUP0";
|
||||
const ORDERBOOK_TR_ID = "H0STASP0";
|
||||
const ORDERBOOK_TR_ID_OVERTIME = "H0STOAA0";
|
||||
const MAX_TRADE_TICKS = 10;
|
||||
|
||||
// ─── 시간대별 TR ID 선택 ────────────────────────────────
|
||||
|
||||
function isOvertimeHours() {
|
||||
const now = new Date();
|
||||
const t = now.getHours() * 100 + now.getMinutes();
|
||||
return t >= 1600 && t < 1800;
|
||||
}
|
||||
|
||||
function resolveTradeTrId(env: KisRuntimeCredentials["tradingEnv"]) {
|
||||
function resolveTradeTrId(
|
||||
env: KisRuntimeCredentials["tradingEnv"],
|
||||
session: DomesticKisSession,
|
||||
) {
|
||||
if (env === "mock") return TRADE_TR_ID;
|
||||
return isOvertimeHours() ? TRADE_TR_ID_OVERTIME : TRADE_TR_ID;
|
||||
if (shouldUseAfterHoursSinglePriceTr(session)) return TRADE_TR_ID_OVERTIME;
|
||||
if (shouldUseExpectedExecutionTr(session)) return TRADE_TR_ID_EXPECTED;
|
||||
return TRADE_TR_ID;
|
||||
}
|
||||
|
||||
function resolveOrderBookTrId() {
|
||||
return isOvertimeHours() ? ORDERBOOK_TR_ID_OVERTIME : ORDERBOOK_TR_ID;
|
||||
function resolveOrderBookTrId(
|
||||
env: KisRuntimeCredentials["tradingEnv"],
|
||||
session: DomesticKisSession,
|
||||
) {
|
||||
if (env === "mock") return ORDERBOOK_TR_ID;
|
||||
if (shouldUseAfterHoursSinglePriceTr(session)) return ORDERBOOK_TR_ID_OVERTIME;
|
||||
return ORDERBOOK_TR_ID;
|
||||
}
|
||||
|
||||
// ─── 메인 훅 ────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 통합 실시간 웹소켓 훅 — 체결(H0STCNT0) + 호가(H0STASP0)를 단일 WS로 수신합니다.
|
||||
*
|
||||
* @param symbol 종목코드
|
||||
* @param credentials KIS 인증 정보
|
||||
* @param isVerified 인증 완료 여부
|
||||
* @param onTick 체결 콜백 (StockHeader 갱신용)
|
||||
* @param options.orderBookSymbol 호가 구독 종목코드
|
||||
* @param options.onOrderBookMessage 호가 수신 콜백
|
||||
* @description KIS 실시간 체결/호가를 단일 WebSocket으로 구독합니다.
|
||||
* @see features/dashboard/components/DashboardContainer.tsx useKisTradeWebSocket 호출
|
||||
* @see lib/kis/domestic-market-session.ts 장 세션 계산 및 테스트용 override
|
||||
*/
|
||||
export function useKisTradeWebSocket(
|
||||
symbol: string | undefined,
|
||||
@@ -66,45 +63,54 @@ export function useKisTradeWebSocket(
|
||||
) {
|
||||
const [latestTick, setLatestTick] =
|
||||
useState<DashboardRealtimeTradeTick | null>(null);
|
||||
const [realtimeCandles, setRealtimeCandles] = useState<StockCandlePoint[]>(
|
||||
[],
|
||||
);
|
||||
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 socketRef = useRef<WebSocket | null>(null);
|
||||
const approvalKeyRef = useRef<string | null>(null);
|
||||
const lastTickOrderRef = useRef<number>(-1);
|
||||
const seenTickRef = useRef<Set<string>>(new Set());
|
||||
|
||||
const trId = credentials ? resolveTradeTrId(credentials.tradingEnv) : null;
|
||||
const obSymbol = options?.orderBookSymbol;
|
||||
const onOrderBookMsg = options?.onOrderBookMessage;
|
||||
const obTrId = obSymbol ? resolveOrderBookTrId() : null;
|
||||
const realtimeTrId = credentials
|
||||
? resolveTradeTrId(credentials.tradingEnv, marketSession)
|
||||
: TRADE_TR_ID;
|
||||
|
||||
// 8초간 데이터 없을 시 안내 메시지
|
||||
// KST 장 세션을 주기적으로 재평가합니다.
|
||||
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(
|
||||
"실시간 연결은 되었지만 체결 데이터가 없습니다. 장중(09:00~15:30)인지 확인해 주세요.",
|
||||
"실시간 연결은 되었지만 체결 데이터가 없습니다. 장 시간(한국시간) 여부를 확인해 주세요.",
|
||||
);
|
||||
}, 8000);
|
||||
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [isConnected, lastTickAt]);
|
||||
|
||||
// ─── 웹소켓 연결 ─────────────────────────────────────
|
||||
useEffect(() => {
|
||||
setLatestTick(null);
|
||||
setRealtimeCandles([]);
|
||||
setRecentTradeTicks([]);
|
||||
setError(null);
|
||||
setLastTickAt(null);
|
||||
seenTickRef.current.clear();
|
||||
|
||||
if (!symbol || !isVerified || !credentials) {
|
||||
@@ -117,7 +123,11 @@ export function useKisTradeWebSocket(
|
||||
|
||||
let disposed = false;
|
||||
let socket: WebSocket | null = null;
|
||||
const currentTrId = resolveTradeTrId(credentials.tradingEnv);
|
||||
|
||||
const tradeTrId = resolveTradeTrId(credentials.tradingEnv, marketSession);
|
||||
const orderBookTrId = obSymbol
|
||||
? resolveOrderBookTrId(credentials.tradingEnv, marketSession)
|
||||
: null;
|
||||
|
||||
const connect = async () => {
|
||||
try {
|
||||
@@ -128,18 +138,20 @@ export function useKisTradeWebSocket(
|
||||
.getState()
|
||||
.getOrFetchApprovalKey();
|
||||
|
||||
if (!approvalKey) throw new Error("웹소켓 승인키 발급에 실패했습니다.");
|
||||
if (disposed) return;
|
||||
if (!approvalKey) {
|
||||
throw new Error("웹소켓 승인키 발급에 실패했습니다.");
|
||||
}
|
||||
|
||||
if (disposed) return;
|
||||
approvalKeyRef.current = approvalKey;
|
||||
|
||||
const wsBase =
|
||||
process.env.NEXT_PUBLIC_KIS_WS_URL ||
|
||||
"ws://ops.koreainvestment.com:21000";
|
||||
socket = new WebSocket(`${wsBase}/tryitout/${currentTrId}`);
|
||||
|
||||
socket = new WebSocket(`${wsBase}/tryitout/${tradeTrId}`);
|
||||
socketRef.current = socket;
|
||||
|
||||
// ── onopen: 체결 + 호가 구독 ──
|
||||
socket.onopen = () => {
|
||||
if (disposed || !approvalKeyRef.current) return;
|
||||
|
||||
@@ -148,19 +160,19 @@ export function useKisTradeWebSocket(
|
||||
buildKisRealtimeMessage(
|
||||
approvalKeyRef.current,
|
||||
symbol,
|
||||
currentTrId,
|
||||
tradeTrId,
|
||||
"1",
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
if (obSymbol && obTrId) {
|
||||
if (obSymbol && orderBookTrId) {
|
||||
socket?.send(
|
||||
JSON.stringify(
|
||||
buildKisRealtimeMessage(
|
||||
approvalKeyRef.current,
|
||||
obSymbol,
|
||||
obTrId,
|
||||
orderBookTrId,
|
||||
"1",
|
||||
),
|
||||
),
|
||||
@@ -170,53 +182,35 @@ export function useKisTradeWebSocket(
|
||||
setIsConnected(true);
|
||||
};
|
||||
|
||||
// ── onmessage: TR ID 기반 분기 ──
|
||||
socket.onmessage = (event) => {
|
||||
if (disposed || typeof event.data !== "string") return;
|
||||
|
||||
// 호가 메시지 확인
|
||||
if (obSymbol && onOrderBookMsg) {
|
||||
const ob = parseKisRealtimeOrderbook(event.data, obSymbol);
|
||||
if (ob) {
|
||||
if (credentials) ob.tradingEnv = credentials.tradingEnv;
|
||||
onOrderBookMsg(ob);
|
||||
const orderBook = parseKisRealtimeOrderbook(event.data, obSymbol);
|
||||
if (orderBook) {
|
||||
orderBook.tradingEnv = credentials.tradingEnv;
|
||||
onOrderBookMsg(orderBook);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 체결 메시지 파싱
|
||||
const ticks = parseKisRealtimeTickBatch(event.data, symbol);
|
||||
if (ticks.length === 0) return;
|
||||
|
||||
// 중복 제거 (TradeTape용)
|
||||
const meaningful = ticks.filter((t) => t.tradeVolume > 0);
|
||||
const deduped = meaningful.filter((t) => {
|
||||
const key = `${t.tickTime}-${t.price}-${t.tradeVolume}`;
|
||||
const meaningfulTicks = ticks.filter((tick) => tick.tradeVolume > 0);
|
||||
const dedupedTicks = meaningfulTicks.filter((tick) => {
|
||||
const key = `${tick.tickTime}-${tick.price}-${tick.tradeVolume}`;
|
||||
if (seenTickRef.current.has(key)) return false;
|
||||
seenTickRef.current.add(key);
|
||||
return true;
|
||||
});
|
||||
|
||||
// 최신 틱 → Header
|
||||
const latest = ticks[ticks.length - 1];
|
||||
setLatestTick(latest);
|
||||
|
||||
// 캔들 → Chart
|
||||
const order = toTickOrderValue(latest.tickTime);
|
||||
if (order > 0 && lastTickOrderRef.current <= order) {
|
||||
lastTickOrderRef.current = order;
|
||||
setRealtimeCandles((prev) =>
|
||||
appendRealtimeTick(prev, {
|
||||
time: formatRealtimeTickTime(latest.tickTime),
|
||||
price: latest.price,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// 체결 테이프
|
||||
if (deduped.length > 0) {
|
||||
if (dedupedTicks.length > 0) {
|
||||
setRecentTradeTicks((prev) =>
|
||||
[...deduped.reverse(), ...prev].slice(0, MAX_TRADE_TICKS),
|
||||
[...dedupedTicks.reverse(), ...prev].slice(0, MAX_TRADE_TICKS),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -228,11 +222,13 @@ export function useKisTradeWebSocket(
|
||||
socket.onerror = () => {
|
||||
if (!disposed) setIsConnected(false);
|
||||
};
|
||||
|
||||
socket.onclose = () => {
|
||||
if (!disposed) setIsConnected(false);
|
||||
};
|
||||
} catch (err) {
|
||||
if (disposed) return;
|
||||
|
||||
setError(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
@@ -245,7 +241,6 @@ export function useKisTradeWebSocket(
|
||||
void connect();
|
||||
const seenRef = seenTickRef.current;
|
||||
|
||||
// ── cleanup: 구독 해제 ──
|
||||
return () => {
|
||||
disposed = true;
|
||||
setIsConnected(false);
|
||||
@@ -253,13 +248,14 @@ export function useKisTradeWebSocket(
|
||||
const key = approvalKeyRef.current;
|
||||
if (socket?.readyState === WebSocket.OPEN && key) {
|
||||
socket.send(
|
||||
JSON.stringify(
|
||||
buildKisRealtimeMessage(key, symbol, currentTrId, "2"),
|
||||
),
|
||||
JSON.stringify(buildKisRealtimeMessage(key, symbol, tradeTrId, "2")),
|
||||
);
|
||||
if (obSymbol && obTrId) {
|
||||
|
||||
if (obSymbol && orderBookTrId) {
|
||||
socket.send(
|
||||
JSON.stringify(buildKisRealtimeMessage(key, obSymbol, obTrId, "2")),
|
||||
JSON.stringify(
|
||||
buildKisRealtimeMessage(key, obSymbol, orderBookTrId, "2"),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -270,22 +266,36 @@ export function useKisTradeWebSocket(
|
||||
seenRef.clear();
|
||||
};
|
||||
}, [
|
||||
isVerified,
|
||||
symbol,
|
||||
isVerified,
|
||||
credentials,
|
||||
marketSession,
|
||||
onTick,
|
||||
obSymbol,
|
||||
obTrId,
|
||||
onOrderBookMsg,
|
||||
]);
|
||||
|
||||
return {
|
||||
latestTick,
|
||||
realtimeCandles,
|
||||
recentTradeTicks,
|
||||
isConnected,
|
||||
error,
|
||||
lastTickAt,
|
||||
realtimeTrId: trId ?? TRADE_TR_ID,
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,47 +57,33 @@ export function useStockOverview() {
|
||||
[],
|
||||
);
|
||||
|
||||
// 실시간 체결 데이터 수신 시 헤더/차트 기준 가격을 갱신합니다.
|
||||
/**
|
||||
* 실시간 체결 수신 시 헤더/주요 시세 상태만 갱신합니다.
|
||||
* 차트 캔들은 StockLineChart 내부 API 응답을 기준으로 유지합니다.
|
||||
* @see features/dashboard/components/DashboardContainer.tsx useKisTradeWebSocket onTick 전달
|
||||
* @see features/dashboard/components/chart/StockLineChart.tsx 차트 데이터 fetchStockChart 기준 렌더링
|
||||
*/
|
||||
const updateRealtimeTradeTick = useCallback(
|
||||
(tick: DashboardRealtimeTradeTick) => {
|
||||
setSelectedStock((prev) => {
|
||||
if (!prev) return prev;
|
||||
const { price, accumulatedVolume, change, changeRate, tickTime } = tick;
|
||||
const pointTime =
|
||||
tickTime && tickTime.length === 6
|
||||
? `${tickTime.slice(0, 2)}:${tickTime.slice(2, 4)}`
|
||||
: "실시간";
|
||||
|
||||
const { price, accumulatedVolume, change, changeRate } = tick;
|
||||
const nextChange = change;
|
||||
const nextChangeRate = Number.isFinite(changeRate)
|
||||
? changeRate
|
||||
: prev.prevClose > 0
|
||||
? (nextChange / prev.prevClose) * 100
|
||||
: prev.changeRate;
|
||||
const nextHigh = prev.high > 0 ? Math.max(prev.high, price) : price;
|
||||
const nextLow = prev.low > 0 ? Math.min(prev.low, price) : price;
|
||||
const nextCandles =
|
||||
prev.candles.length > 0 &&
|
||||
prev.candles[prev.candles.length - 1]?.time === pointTime
|
||||
? [
|
||||
...prev.candles.slice(0, -1),
|
||||
{
|
||||
...prev.candles[prev.candles.length - 1],
|
||||
time: pointTime,
|
||||
price,
|
||||
},
|
||||
]
|
||||
: [...prev.candles, { time: pointTime, price }].slice(-80);
|
||||
|
||||
return {
|
||||
...prev,
|
||||
currentPrice: price,
|
||||
change: nextChange,
|
||||
changeRate: nextChangeRate,
|
||||
high: nextHigh,
|
||||
low: nextLow,
|
||||
high: prev.high > 0 ? Math.max(prev.high, price) : price,
|
||||
low: prev.low > 0 ? Math.min(prev.low, price) : price,
|
||||
volume: accumulatedVolume > 0 ? accumulatedVolume : prev.volume,
|
||||
candles: nextCandles,
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user