대시보드 중간 커밋
This commit is contained in:
184
features/dashboard/hooks/useKisOrderbookWebSocket.ts
Normal file
184
features/dashboard/hooks/useKisOrderbookWebSocket.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
type KisRuntimeCredentials,
|
||||
useKisRuntimeStore,
|
||||
} from "@/features/dashboard/store/use-kis-runtime-store";
|
||||
import type { DashboardStockOrderBookResponse } from "@/features/dashboard/types/dashboard.types";
|
||||
import {
|
||||
buildKisRealtimeMessage,
|
||||
parseKisRealtimeOrderbook,
|
||||
} from "@/features/dashboard/utils/kis-realtime.utils";
|
||||
|
||||
const KRX_ORDERBOOK_TR_ID = "H0STASP0";
|
||||
const KRX_OVERTIME_ORDERBOOK_TR_ID = "H0STOAA0";
|
||||
const DEFAULT_ORDERBOOK_TR_ID = "H0UNASP0";
|
||||
|
||||
/**
|
||||
* @description 한국 시간 기준으로 시간외 단일가 구간(16:00~18:00) 여부를 판별합니다.
|
||||
* @see features/dashboard/hooks/useKisOrderbookWebSocket.ts resolveOrderBookTrId에서 TR ID 선택 기준으로 사용합니다.
|
||||
*/
|
||||
function isKrxOvertimeInKst(now = new Date()) {
|
||||
const formatter = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone: "Asia/Seoul",
|
||||
weekday: "short",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
});
|
||||
const parts = formatter.formatToParts(now);
|
||||
const partMap = new Map(parts.map((part) => [part.type, part.value]));
|
||||
const weekday = partMap.get("weekday");
|
||||
|
||||
if (weekday === "Sat" || weekday === "Sun") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const hour = Number(partMap.get("hour") ?? "0");
|
||||
const minute = Number(partMap.get("minute") ?? "0");
|
||||
const totalMinutes = hour * 60 + minute;
|
||||
|
||||
return totalMinutes >= 16 * 60 && totalMinutes < 18 * 60;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 마켓/시간대에 맞는 국내주식 호가 TR ID를 반환합니다.
|
||||
* @see .tmp/open-trading-api/examples_user/domestic_stock/domestic_stock_functions_ws.py asking_price_krx(H0STASP0), overtime_asking_price_krx(H0STOAA0) 기준
|
||||
*/
|
||||
function resolveOrderBookTrId(market: "KOSPI" | "KOSDAQ" | undefined) {
|
||||
if (market === "KOSPI" || market === "KOSDAQ") {
|
||||
return isKrxOvertimeInKst()
|
||||
? KRX_OVERTIME_ORDERBOOK_TR_ID
|
||||
: KRX_ORDERBOOK_TR_ID;
|
||||
}
|
||||
|
||||
return DEFAULT_ORDERBOOK_TR_ID;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description KIS 실시간 호가 웹소켓 훅
|
||||
* @see .tmp/open-trading-api/examples_user/domestic_stock/domestic_stock_functions_ws.py KRX 호가(H0STASP0) / 통합 호가(H0UNASP0) 기준
|
||||
*/
|
||||
export function useKisOrderbookWebSocket(
|
||||
symbol: string | undefined,
|
||||
market: "KOSPI" | "KOSDAQ" | undefined,
|
||||
credentials: KisRuntimeCredentials | null,
|
||||
isVerified: boolean,
|
||||
) {
|
||||
const [realtimeOrderBook, setRealtimeOrderBook] =
|
||||
useState<DashboardStockOrderBookResponse | null>(null);
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const socketRef = useRef<WebSocket | null>(null);
|
||||
const approvalKeyRef = useRef<string | null>(null);
|
||||
|
||||
const trId = resolveOrderBookTrId(market);
|
||||
|
||||
useEffect(() => {
|
||||
// 종목 변경 시 이전 호가 데이터 즉시 초기화
|
||||
setRealtimeOrderBook(null);
|
||||
setError(null);
|
||||
|
||||
if (!symbol || !isVerified || !credentials) {
|
||||
socketRef.current?.close();
|
||||
socketRef.current = null;
|
||||
approvalKeyRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
let disposed = false;
|
||||
let socket: WebSocket | null = null;
|
||||
|
||||
const connect = async () => {
|
||||
try {
|
||||
const approvalKey = await useKisRuntimeStore
|
||||
.getState()
|
||||
.getOrFetchApprovalKey();
|
||||
|
||||
if (!approvalKey) {
|
||||
throw new Error("웹소켓 승인키 발급에 실패했습니다.");
|
||||
}
|
||||
if (disposed) return;
|
||||
|
||||
approvalKeyRef.current = approvalKey;
|
||||
const wsBaseUrl =
|
||||
process.env.NEXT_PUBLIC_KIS_WS_URL ||
|
||||
"ws://ops.koreainvestment.com:21000";
|
||||
socket = new WebSocket(`${wsBaseUrl}/tryitout/${trId}`);
|
||||
socketRef.current = socket;
|
||||
|
||||
socket.onopen = () => {
|
||||
if (disposed || !approvalKeyRef.current) return;
|
||||
|
||||
const subscribeMessage = buildKisRealtimeMessage(
|
||||
approvalKeyRef.current,
|
||||
symbol,
|
||||
trId,
|
||||
"1",
|
||||
);
|
||||
socket?.send(JSON.stringify(subscribeMessage));
|
||||
setIsConnected(true);
|
||||
};
|
||||
|
||||
socket.onmessage = (event) => {
|
||||
if (disposed || typeof event.data !== "string") return;
|
||||
|
||||
const orderbook = parseKisRealtimeOrderbook(event.data, symbol);
|
||||
if (!orderbook) return;
|
||||
|
||||
orderbook.tradingEnv = credentials.tradingEnv;
|
||||
setRealtimeOrderBook(orderbook);
|
||||
};
|
||||
|
||||
socket.onerror = (event) => {
|
||||
if (disposed) return;
|
||||
setIsConnected(false);
|
||||
console.error("WebSocket Error:", event);
|
||||
};
|
||||
|
||||
socket.onclose = () => {
|
||||
if (disposed) return;
|
||||
setIsConnected(false);
|
||||
};
|
||||
} catch (err) {
|
||||
if (disposed) return;
|
||||
setError(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: "실시간 호가 소켓 초기화 중 오류가 발생했습니다.",
|
||||
);
|
||||
setIsConnected(false);
|
||||
}
|
||||
};
|
||||
|
||||
void connect();
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
setIsConnected(false);
|
||||
|
||||
const approvalKey = approvalKeyRef.current;
|
||||
if (socket?.readyState === WebSocket.OPEN && approvalKey) {
|
||||
const unsubscribeMessage = buildKisRealtimeMessage(
|
||||
approvalKey,
|
||||
symbol,
|
||||
trId,
|
||||
"2",
|
||||
);
|
||||
socket.send(JSON.stringify(unsubscribeMessage));
|
||||
}
|
||||
|
||||
socket?.close();
|
||||
if (socketRef.current === socket) {
|
||||
socketRef.current = null;
|
||||
}
|
||||
approvalKeyRef.current = null;
|
||||
};
|
||||
}, [isVerified, symbol, market, credentials, trId]);
|
||||
|
||||
return {
|
||||
realtimeOrderBook,
|
||||
isConnected,
|
||||
error,
|
||||
};
|
||||
}
|
||||
283
features/dashboard/hooks/useKisTradeWebSocket.ts
Normal file
283
features/dashboard/hooks/useKisTradeWebSocket.ts
Normal file
@@ -0,0 +1,283 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
type KisRuntimeCredentials,
|
||||
useKisRuntimeStore,
|
||||
} from "@/features/dashboard/store/use-kis-runtime-store";
|
||||
import type {
|
||||
DashboardRealtimeTradeTick,
|
||||
StockCandlePoint,
|
||||
} from "@/features/dashboard/types/dashboard.types";
|
||||
import {
|
||||
appendRealtimeTick,
|
||||
buildKisRealtimeMessage,
|
||||
parseKisRealtimeTickBatch,
|
||||
toTickOrderValue,
|
||||
} from "@/features/dashboard/utils/kis-realtime.utils";
|
||||
|
||||
const KIS_REALTIME_TR_ID_REAL = "H0STCNT0";
|
||||
const KIS_REALTIME_TR_ID_OVERTIME = "H0STOUP0"; // 시간외 단일가
|
||||
const KIS_REALTIME_TR_ID_MOCK = "H0STCNT0";
|
||||
|
||||
const MAX_TRADE_TICKS = 10; // 체결 테이프용 최대 개수
|
||||
|
||||
function resolveRealtimeTrId(tradingEnv: KisRuntimeCredentials["tradingEnv"]) {
|
||||
if (tradingEnv === "mock") return KIS_REALTIME_TR_ID_MOCK;
|
||||
|
||||
// 현재 시간(KST) 체크 - 사용자 브라우저 시간 기준
|
||||
const now = new Date();
|
||||
const hours = now.getHours();
|
||||
const minutes = now.getMinutes();
|
||||
const time = hours * 100 + minutes;
|
||||
|
||||
// 16:00 ~ 18:00 사이는 시간외 단일가 TR ID 사용
|
||||
if (time >= 1600 && time < 1800) {
|
||||
return KIS_REALTIME_TR_ID_OVERTIME;
|
||||
}
|
||||
|
||||
return KIS_REALTIME_TR_ID_REAL;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 통합 실시간 체결 웹소켓 훅 (H0STCNT0)
|
||||
* - StockHeader: 실시간 현재가, 등락률, 시가/고가/저가/거래량
|
||||
* - StockLineChart: 실시간 캔들 (분봉/일봉 등)
|
||||
* - OrderBook (TradeTape): 최근 체결 내역 리스트
|
||||
*/
|
||||
export function useKisTradeWebSocket(
|
||||
symbol: string | undefined,
|
||||
credentials: KisRuntimeCredentials | null,
|
||||
isVerified: boolean,
|
||||
onTick?: (tick: DashboardRealtimeTradeTick) => void,
|
||||
) {
|
||||
// 1. StockHeader용 최신 데이터
|
||||
const [latestTick, setLatestTick] =
|
||||
useState<DashboardRealtimeTradeTick | null>(null);
|
||||
|
||||
// 2. StockLineChart용 캔들 데이터
|
||||
const [realtimeCandles, setRealtimeCandles] = useState<StockCandlePoint[]>(
|
||||
[],
|
||||
);
|
||||
|
||||
// 3. TradeTape용 최근 체결 리스트
|
||||
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 socketRef = useRef<WebSocket | null>(null);
|
||||
const approvalKeyRef = useRef<string | null>(null);
|
||||
const lastTickOrderRef = useRef<number>(-1);
|
||||
const seenTickRef = useRef<Set<string>>(new Set());
|
||||
|
||||
const realtimeTrId = credentials
|
||||
? resolveRealtimeTrId(credentials.tradingEnv)
|
||||
: null;
|
||||
|
||||
// 데이터 없음 감지 (8초)
|
||||
useEffect(() => {
|
||||
if (!isConnected || lastTickAt) return;
|
||||
|
||||
const noTickTimer = window.setTimeout(() => {
|
||||
setError(
|
||||
"실시간 연결은 되었지만 체결 데이터가 없습니다. 장중(09:00~15:30)인지 확인해 주세요.",
|
||||
);
|
||||
}, 8000);
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(noTickTimer);
|
||||
};
|
||||
}, [isConnected, lastTickAt]);
|
||||
|
||||
// 웹소켓 연결 로직
|
||||
useEffect(() => {
|
||||
// 초기화
|
||||
setLatestTick(null);
|
||||
setRealtimeCandles([]);
|
||||
setRecentTradeTicks([]);
|
||||
setError(null);
|
||||
seenTickRef.current.clear();
|
||||
|
||||
if (!symbol || !isVerified || !credentials) {
|
||||
if (socketRef.current) {
|
||||
socketRef.current.close();
|
||||
socketRef.current = null;
|
||||
}
|
||||
approvalKeyRef.current = null;
|
||||
setIsConnected(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let disposed = false;
|
||||
let socket: WebSocket | null = null;
|
||||
const trId = resolveRealtimeTrId(credentials.tradingEnv);
|
||||
|
||||
const connect = async () => {
|
||||
try {
|
||||
setError(null);
|
||||
setIsConnected(false);
|
||||
|
||||
const approvalKey = await useKisRuntimeStore
|
||||
.getState()
|
||||
.getOrFetchApprovalKey();
|
||||
|
||||
if (!approvalKey) {
|
||||
throw new Error("웹소켓 승인키 발급에 실패했습니다.");
|
||||
}
|
||||
if (disposed) return;
|
||||
|
||||
approvalKeyRef.current = approvalKey;
|
||||
|
||||
const wsBaseUrl =
|
||||
process.env.NEXT_PUBLIC_KIS_WS_URL ||
|
||||
"ws://ops.koreainvestment.com:21000";
|
||||
socket = new WebSocket(`${wsBaseUrl}/tryitout/${trId}`);
|
||||
socketRef.current = socket;
|
||||
|
||||
console.log("[WS URL]", `${wsBaseUrl}/tryitout/${trId}`);
|
||||
|
||||
socket.onopen = () => {
|
||||
if (disposed || !approvalKeyRef.current) return;
|
||||
console.log("[WS Open] Connected");
|
||||
|
||||
const subscribeMessage = buildKisRealtimeMessage(
|
||||
approvalKeyRef.current,
|
||||
symbol,
|
||||
trId,
|
||||
"1",
|
||||
);
|
||||
socket?.send(JSON.stringify(subscribeMessage));
|
||||
setIsConnected(true);
|
||||
};
|
||||
|
||||
socket.onmessage = (event) => {
|
||||
if (disposed || typeof event.data !== "string") return;
|
||||
|
||||
const parsedTicks = parseKisRealtimeTickBatch(event.data, symbol);
|
||||
|
||||
if (parsedTicks.length === 0) {
|
||||
console.log(
|
||||
"[WS Parsed] No ticks found. Check TrId/Symbol match.",
|
||||
{ symbol, trId },
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// 1. 데이터 정제 및 중복 제거 (TradeTape용)
|
||||
const meaningfulTicks = parsedTicks.filter(
|
||||
(tick) => tick.tradeVolume > 0,
|
||||
);
|
||||
const dedupedForTape = meaningfulTicks.filter((tick) => {
|
||||
const key = `${tick.tickTime}-${tick.price}-${tick.tradeVolume}`;
|
||||
if (seenTickRef.current.has(key)) return false;
|
||||
seenTickRef.current.add(key);
|
||||
return true;
|
||||
});
|
||||
|
||||
// 2. 최신 틱 업데이트 (StockHeader용)
|
||||
// 배치 중 가장 마지막(최신) 틱을 사용
|
||||
const lastTickInBatch = parsedTicks[parsedTicks.length - 1];
|
||||
setLatestTick(lastTickInBatch);
|
||||
|
||||
// 3. 캔들 업데이트 (StockLineChart용)
|
||||
// 지연 도착 틱 필터링
|
||||
const nextTickOrder = toTickOrderValue(lastTickInBatch.tickTime);
|
||||
if (nextTickOrder > 0) {
|
||||
if (lastTickOrderRef.current <= nextTickOrder) {
|
||||
lastTickOrderRef.current = nextTickOrder;
|
||||
const candlePoint: StockCandlePoint = {
|
||||
time: formatTime(lastTickInBatch.tickTime),
|
||||
price: lastTickInBatch.price,
|
||||
// 필요한 경우 open, high, low, volume 등을 여기서 조합 가능
|
||||
// 현재 차트 컴포넌트는 Point 단위로 price만 주로 씀
|
||||
};
|
||||
setRealtimeCandles((prev) =>
|
||||
appendRealtimeTick(prev, candlePoint),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 체결 테이프 업데이트
|
||||
if (dedupedForTape.length > 0) {
|
||||
setRecentTradeTicks((prev) =>
|
||||
[...dedupedForTape.reverse(), ...prev].slice(0, MAX_TRADE_TICKS),
|
||||
);
|
||||
}
|
||||
|
||||
// 5. 콜백 및 상태 업데이트
|
||||
setError(null);
|
||||
setLastTickAt(Date.now());
|
||||
|
||||
// onTick 콜백용 데이터 구성
|
||||
if (onTick) {
|
||||
onTick(lastTickInBatch);
|
||||
}
|
||||
};
|
||||
|
||||
socket.onerror = () => {
|
||||
if (disposed) return;
|
||||
setIsConnected(false);
|
||||
// setError("실시간 체결 연결 중 오류가 발생했습니다.");
|
||||
};
|
||||
|
||||
socket.onclose = () => {
|
||||
if (disposed) return;
|
||||
setIsConnected(false);
|
||||
};
|
||||
} catch (err) {
|
||||
if (disposed) return;
|
||||
const message =
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: "실시간 웹소켓 초기화 중 오류가 발생했습니다.";
|
||||
setError(message);
|
||||
setIsConnected(false);
|
||||
}
|
||||
};
|
||||
|
||||
void connect();
|
||||
|
||||
const seenTickRefCurrent = seenTickRef.current;
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
setIsConnected(false);
|
||||
|
||||
const approvalKey = approvalKeyRef.current;
|
||||
if (socket?.readyState === WebSocket.OPEN && approvalKey) {
|
||||
const unsubscribeMessage = buildKisRealtimeMessage(
|
||||
approvalKey,
|
||||
symbol,
|
||||
trId,
|
||||
"2",
|
||||
);
|
||||
socket.send(JSON.stringify(unsubscribeMessage));
|
||||
}
|
||||
|
||||
socket?.close();
|
||||
if (socketRef.current === socket) {
|
||||
socketRef.current = null;
|
||||
}
|
||||
approvalKeyRef.current = null;
|
||||
seenTickRefCurrent.clear();
|
||||
};
|
||||
}, [isVerified, symbol, credentials, onTick]);
|
||||
|
||||
return {
|
||||
latestTick, // Header용
|
||||
realtimeCandles, // Chart용
|
||||
recentTradeTicks, // Tape용
|
||||
isConnected,
|
||||
error,
|
||||
lastTickAt,
|
||||
realtimeTrId: realtimeTrId ?? KIS_REALTIME_TR_ID_REAL,
|
||||
};
|
||||
}
|
||||
|
||||
function formatTime(hhmmss: string) {
|
||||
if (!hhmmss || hhmmss.length !== 6) return "실시간";
|
||||
return `${hhmmss.slice(0, 2)}:${hhmmss.slice(2, 4)}:${hhmmss.slice(4, 6)}`;
|
||||
}
|
||||
61
features/dashboard/hooks/useOrder.ts
Normal file
61
features/dashboard/hooks/useOrder.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import type { KisRuntimeCredentials } from "@/features/dashboard/store/use-kis-runtime-store";
|
||||
import type {
|
||||
DashboardStockCashOrderRequest,
|
||||
DashboardStockCashOrderResponse,
|
||||
} from "@/features/dashboard/types/dashboard.types";
|
||||
import { fetchOrderCash } from "@/features/dashboard/apis/kis-stock.api";
|
||||
|
||||
export function useOrder() {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [result, setResult] = useState<DashboardStockCashOrderResponse | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const placeOrder = useCallback(
|
||||
async (
|
||||
request: DashboardStockCashOrderRequest,
|
||||
credentials: KisRuntimeCredentials | null,
|
||||
) => {
|
||||
if (!credentials) {
|
||||
setError("KIS API 자격 증명이 없습니다.");
|
||||
return null;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
setResult(null);
|
||||
|
||||
try {
|
||||
const data = await fetchOrderCash(request, credentials);
|
||||
setResult(data);
|
||||
return data;
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: "주문 처리 중 오류가 발생했습니다.";
|
||||
setError(message);
|
||||
return null;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setError(null);
|
||||
setResult(null);
|
||||
setIsLoading(false);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
placeOrder,
|
||||
isLoading,
|
||||
error,
|
||||
result,
|
||||
reset,
|
||||
};
|
||||
}
|
||||
115
features/dashboard/hooks/useOrderBook.ts
Normal file
115
features/dashboard/hooks/useOrderBook.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { KisRuntimeCredentials } from "@/features/dashboard/store/use-kis-runtime-store";
|
||||
import type { DashboardStockOrderBookResponse } from "@/features/dashboard/types/dashboard.types";
|
||||
import { fetchStockOrderBook } from "@/features/dashboard/apis/kis-stock.api";
|
||||
import { useKisOrderbookWebSocket } from "@/features/dashboard/hooks/useKisOrderbookWebSocket";
|
||||
import { toast } from "sonner";
|
||||
|
||||
/**
|
||||
* @description 선택한 종목의 초기 호가를 조회하고 실시간 호가로 갱신합니다.
|
||||
* @see features/dashboard/components/orderbook/OrderBook.tsx 호가창 화면에서 사용합니다.
|
||||
*/
|
||||
export function useOrderBook(
|
||||
symbol: string | undefined,
|
||||
market: "KOSPI" | "KOSDAQ" | undefined,
|
||||
credentials: KisRuntimeCredentials | null,
|
||||
isVerified: boolean,
|
||||
options: { enabled?: boolean } = {},
|
||||
) {
|
||||
const { enabled = true } = options;
|
||||
const isRequestEnabled = enabled && !!symbol && !!credentials;
|
||||
const requestSeqRef = useRef(0);
|
||||
const lastErrorToastRef = useRef<string>("");
|
||||
|
||||
const [initialData, setInitialData] =
|
||||
useState<DashboardStockOrderBookResponse | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isRequestEnabled || !symbol || !credentials) {
|
||||
return;
|
||||
}
|
||||
|
||||
const requestSeq = ++requestSeqRef.current;
|
||||
let isDisposed = false;
|
||||
|
||||
const loadInitialOrderBook = async () => {
|
||||
setInitialData(null);
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const data = await fetchStockOrderBook(symbol, credentials);
|
||||
if (isDisposed || requestSeq !== requestSeqRef.current) return;
|
||||
setInitialData(data);
|
||||
} catch (err) {
|
||||
if (isDisposed || requestSeq !== requestSeqRef.current) return;
|
||||
console.error("Failed to fetch initial orderbook:", err);
|
||||
const message =
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: "호가 정보를 불러오지 못했습니다. 잠시 후 다시 시도해 주세요.";
|
||||
setError(message);
|
||||
if (lastErrorToastRef.current !== message) {
|
||||
lastErrorToastRef.current = message;
|
||||
toast.error(message);
|
||||
}
|
||||
} finally {
|
||||
if (isDisposed || requestSeq !== requestSeqRef.current) return;
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
void loadInitialOrderBook();
|
||||
|
||||
return () => {
|
||||
isDisposed = true;
|
||||
};
|
||||
}, [isRequestEnabled, symbol, credentials]);
|
||||
|
||||
const {
|
||||
realtimeOrderBook,
|
||||
isConnected: isWsConnected,
|
||||
error: wsError,
|
||||
} = useKisOrderbookWebSocket(
|
||||
symbol,
|
||||
market,
|
||||
credentials,
|
||||
isVerified && enabled,
|
||||
);
|
||||
|
||||
// 실시간 패킷이 0값으로 들어오는 경우에는 초기 REST 호가를 유지합니다.
|
||||
const safeRealtimeOrderBook = isUsableOrderBook(realtimeOrderBook)
|
||||
? realtimeOrderBook
|
||||
: null;
|
||||
const orderBook = isRequestEnabled
|
||||
? (safeRealtimeOrderBook ?? initialData)
|
||||
: null;
|
||||
const mergedError = isRequestEnabled ? error || wsError : null;
|
||||
const mergedLoading = isRequestEnabled ? isLoading && !orderBook : false;
|
||||
|
||||
return {
|
||||
orderBook,
|
||||
isLoading: mergedLoading,
|
||||
error: mergedError,
|
||||
isWsConnected,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 호가 데이터가 실제 유효한지(0만 있는지) 판별합니다.
|
||||
* @see features/dashboard/hooks/useOrderBook.ts 실시간/초기 호가 병합 우선순위 결정에 사용합니다.
|
||||
*/
|
||||
function isUsableOrderBook(orderBook: DashboardStockOrderBookResponse | null) {
|
||||
if (!orderBook) return false;
|
||||
if (orderBook.totalAskSize > 0 || orderBook.totalBidSize > 0) return true;
|
||||
|
||||
return orderBook.levels.some(
|
||||
(level) =>
|
||||
level.askPrice > 0 ||
|
||||
level.bidPrice > 0 ||
|
||||
level.askSize > 0 ||
|
||||
level.bidSize > 0,
|
||||
);
|
||||
}
|
||||
118
features/dashboard/hooks/useStockOverview.ts
Normal file
118
features/dashboard/hooks/useStockOverview.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { useCallback, useState, useTransition } from "react";
|
||||
import type { KisRuntimeCredentials } from "@/features/dashboard/store/use-kis-runtime-store";
|
||||
import type {
|
||||
DashboardMarketPhase,
|
||||
DashboardPriceSource,
|
||||
DashboardRealtimeTradeTick,
|
||||
DashboardStockSearchItem,
|
||||
DashboardStockItem,
|
||||
} from "@/features/dashboard/types/dashboard.types";
|
||||
import { fetchStockOverview } from "@/features/dashboard/apis/kis-stock.api";
|
||||
|
||||
interface OverviewMeta {
|
||||
priceSource: DashboardPriceSource;
|
||||
marketPhase: DashboardMarketPhase;
|
||||
fetchedAt: string;
|
||||
}
|
||||
|
||||
export function useStockOverview() {
|
||||
const [selectedStock, setSelectedStock] = useState<DashboardStockItem | null>(
|
||||
null,
|
||||
);
|
||||
const [meta, setMeta] = useState<OverviewMeta | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isLoading, startTransition] = useTransition();
|
||||
|
||||
const loadOverview = useCallback(
|
||||
(
|
||||
symbol: string,
|
||||
credentials: KisRuntimeCredentials | null,
|
||||
marketHint?: DashboardStockSearchItem["market"],
|
||||
) => {
|
||||
if (!credentials) return;
|
||||
|
||||
startTransition(async () => {
|
||||
try {
|
||||
setError(null);
|
||||
const data = await fetchStockOverview(symbol, credentials);
|
||||
setSelectedStock({
|
||||
...data.stock,
|
||||
market: marketHint ?? data.stock.market,
|
||||
});
|
||||
setMeta({
|
||||
priceSource: data.priceSource,
|
||||
marketPhase: data.marketPhase,
|
||||
fetchedAt: data.fetchedAt,
|
||||
});
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: "종목 조회 중 오류가 발생했습니다.";
|
||||
setError(message);
|
||||
setMeta(null);
|
||||
}
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// 실시간 체결 데이터 수신 시 헤더/차트 기준 가격을 갱신합니다.
|
||||
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 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,
|
||||
volume: accumulatedVolume > 0 ? accumulatedVolume : prev.volume,
|
||||
candles: nextCandles,
|
||||
};
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return {
|
||||
selectedStock,
|
||||
setSelectedStock,
|
||||
meta,
|
||||
setMeta,
|
||||
error,
|
||||
setError,
|
||||
isLoading,
|
||||
loadOverview,
|
||||
updateRealtimeTradeTick,
|
||||
};
|
||||
}
|
||||
91
features/dashboard/hooks/useStockSearch.ts
Normal file
91
features/dashboard/hooks/useStockSearch.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import type { KisRuntimeCredentials } from "@/features/dashboard/store/use-kis-runtime-store";
|
||||
import type { DashboardStockSearchItem } from "@/features/dashboard/types/dashboard.types";
|
||||
import { fetchStockSearch } from "@/features/dashboard/apis/kis-stock.api";
|
||||
|
||||
export function useStockSearch() {
|
||||
const [keyword, setKeyword] = useState("삼성전자");
|
||||
const [searchResults, setSearchResults] = useState<
|
||||
DashboardStockSearchItem[]
|
||||
>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
const requestIdRef = useRef(0);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
const loadSearch = useCallback(async (query: string) => {
|
||||
const requestId = ++requestIdRef.current;
|
||||
const controller = new AbortController();
|
||||
abortRef.current?.abort();
|
||||
abortRef.current = controller;
|
||||
|
||||
setIsSearching(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const data = await fetchStockSearch(query, controller.signal);
|
||||
if (requestId === requestIdRef.current) {
|
||||
setSearchResults(data.items);
|
||||
}
|
||||
return data.items;
|
||||
} catch (err) {
|
||||
if (controller.signal.aborted) {
|
||||
return [];
|
||||
}
|
||||
if (requestId === requestIdRef.current) {
|
||||
setError(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: "종목 검색 중 오류가 발생했습니다.",
|
||||
);
|
||||
}
|
||||
return [];
|
||||
} finally {
|
||||
if (requestId === requestIdRef.current) {
|
||||
setIsSearching(false);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const search = useCallback(
|
||||
(query: string, credentials: KisRuntimeCredentials | null) => {
|
||||
if (!credentials) {
|
||||
setError("API 키 검증이 필요합니다.");
|
||||
setSearchResults([]);
|
||||
setIsSearching(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const trimmed = query.trim();
|
||||
if (!trimmed) {
|
||||
abortRef.current?.abort();
|
||||
setSearchResults([]);
|
||||
setError(null);
|
||||
setIsSearching(false);
|
||||
return;
|
||||
}
|
||||
|
||||
void loadSearch(trimmed);
|
||||
},
|
||||
[loadSearch],
|
||||
);
|
||||
|
||||
const clearSearch = useCallback(() => {
|
||||
abortRef.current?.abort();
|
||||
setSearchResults([]);
|
||||
setError(null);
|
||||
setIsSearching(false);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
keyword,
|
||||
setKeyword,
|
||||
searchResults,
|
||||
setSearchResults,
|
||||
error,
|
||||
setError,
|
||||
isSearching,
|
||||
search,
|
||||
clearSearch,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user