임시커밋
This commit is contained in:
@@ -1,53 +0,0 @@
|
||||
import { useMemo } from "react";
|
||||
import type {
|
||||
DashboardRealtimeTradeTick,
|
||||
DashboardStockItem,
|
||||
DashboardStockOrderBookResponse,
|
||||
} from "@/features/dashboard/types/dashboard.types";
|
||||
|
||||
interface UseCurrentPriceParams {
|
||||
stock?: DashboardStockItem | null;
|
||||
latestTick: DashboardRealtimeTradeTick | null;
|
||||
orderBook: DashboardStockOrderBookResponse | null;
|
||||
}
|
||||
|
||||
export function useCurrentPrice({
|
||||
stock,
|
||||
latestTick,
|
||||
orderBook,
|
||||
}: UseCurrentPriceParams) {
|
||||
return useMemo(() => {
|
||||
let currentPrice = stock?.currentPrice ?? 0;
|
||||
let change = stock?.change ?? 0;
|
||||
let changeRate = stock?.changeRate ?? 0;
|
||||
const prevClose = stock?.prevClose ?? 0;
|
||||
|
||||
// 1. Priority: Realtime Tick (Trade WS)
|
||||
if (latestTick?.price && latestTick.price > 0) {
|
||||
currentPrice = latestTick.price;
|
||||
change = latestTick.change;
|
||||
changeRate = latestTick.changeRate;
|
||||
}
|
||||
// 2. Fallback: OrderBook Best Ask (Proxy for current price if no tick)
|
||||
else if (
|
||||
orderBook?.levels[0]?.askPrice &&
|
||||
orderBook.levels[0].askPrice > 0
|
||||
) {
|
||||
const askPrice = orderBook.levels[0].askPrice;
|
||||
currentPrice = askPrice;
|
||||
|
||||
// Recalculate change/rate based on prevClose
|
||||
if (prevClose > 0) {
|
||||
change = currentPrice - prevClose;
|
||||
changeRate = (change / prevClose) * 100;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
currentPrice,
|
||||
change,
|
||||
changeRate,
|
||||
prevClose,
|
||||
};
|
||||
}, [stock, latestTick, orderBook]);
|
||||
}
|
||||
@@ -1,295 +0,0 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
type KisRuntimeCredentials,
|
||||
useKisRuntimeStore,
|
||||
} from "@/features/dashboard/store/use-kis-runtime-store";
|
||||
import type {
|
||||
DashboardRealtimeTradeTick,
|
||||
DashboardStockOrderBookResponse,
|
||||
} from "@/features/dashboard/types/dashboard.types";
|
||||
import {
|
||||
buildKisRealtimeMessage,
|
||||
parseKisRealtimeOrderbook,
|
||||
parseKisRealtimeTickBatch,
|
||||
} 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";
|
||||
|
||||
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;
|
||||
|
||||
function resolveTradeTrId(
|
||||
env: KisRuntimeCredentials["tradingEnv"],
|
||||
session: DomesticKisSession,
|
||||
) {
|
||||
if (env === "mock") return 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(
|
||||
env: KisRuntimeCredentials["tradingEnv"],
|
||||
session: DomesticKisSession,
|
||||
) {
|
||||
if (env === "mock") return ORDERBOOK_TR_ID;
|
||||
if (shouldUseAfterHoursSinglePriceTr(session)) return ORDERBOOK_TR_ID_OVERTIME;
|
||||
return ORDERBOOK_TR_ID;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Subscribes trade ticks and orderbook over one websocket.
|
||||
* @see features/dashboard/components/DashboardContainer.tsx
|
||||
* @see lib/kis/domestic-market-session.ts
|
||||
*/
|
||||
export function useKisTradeWebSocket(
|
||||
symbol: string | undefined,
|
||||
credentials: KisRuntimeCredentials | null,
|
||||
isVerified: boolean,
|
||||
onTick?: (tick: DashboardRealtimeTradeTick) => void,
|
||||
options?: {
|
||||
orderBookSymbol?: string;
|
||||
onOrderBookMessage?: (data: DashboardStockOrderBookResponse) => void;
|
||||
},
|
||||
) {
|
||||
const [latestTick, setLatestTick] =
|
||||
useState<DashboardRealtimeTradeTick | null>(null);
|
||||
const [recentTradeTicks, setRecentTradeTicks] = useState<
|
||||
DashboardRealtimeTradeTick[]
|
||||
>([]);
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [lastTickAt, setLastTickAt] = useState<number | null>(null);
|
||||
const [marketSession, setMarketSession] = useState<DomesticKisSession>(() =>
|
||||
resolveSessionInClient(),
|
||||
);
|
||||
|
||||
const socketRef = useRef<WebSocket | null>(null);
|
||||
const approvalKeyRef = useRef<string | null>(null);
|
||||
const seenTickRef = useRef<Set<string>>(new Set());
|
||||
|
||||
const obSymbol = options?.orderBookSymbol;
|
||||
const onOrderBookMsg = options?.onOrderBookMessage;
|
||||
const realtimeTrId = credentials
|
||||
? resolveTradeTrId(credentials.tradingEnv, marketSession)
|
||||
: TRADE_TR_ID;
|
||||
|
||||
useEffect(() => {
|
||||
const timerId = window.setInterval(() => {
|
||||
const nextSession = resolveSessionInClient();
|
||||
setMarketSession((prev) => (prev === nextSession ? prev : nextSession));
|
||||
}, 30_000);
|
||||
|
||||
return () => window.clearInterval(timerId);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isConnected || lastTickAt) return;
|
||||
|
||||
const timer = window.setTimeout(() => {
|
||||
setError(
|
||||
"실시간 연결은 되었지만 체결 데이터가 없습니다. 장 시간(한국시간)과 TR ID를 확인해 주세요.",
|
||||
);
|
||||
}, 8000);
|
||||
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [isConnected, lastTickAt]);
|
||||
|
||||
useEffect(() => {
|
||||
setLatestTick(null);
|
||||
setRecentTradeTicks([]);
|
||||
setError(null);
|
||||
setLastTickAt(null);
|
||||
seenTickRef.current.clear();
|
||||
|
||||
if (!symbol || !isVerified || !credentials) {
|
||||
socketRef.current?.close();
|
||||
socketRef.current = null;
|
||||
approvalKeyRef.current = null;
|
||||
setIsConnected(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let disposed = false;
|
||||
let socket: WebSocket | null = null;
|
||||
|
||||
const tradeTrId = resolveTradeTrId(credentials.tradingEnv, marketSession);
|
||||
const orderBookTrId = obSymbol
|
||||
? resolveOrderBookTrId(credentials.tradingEnv, marketSession)
|
||||
: null;
|
||||
|
||||
const connect = async () => {
|
||||
try {
|
||||
setError(null);
|
||||
setIsConnected(false);
|
||||
|
||||
const wsConnection = await useKisRuntimeStore
|
||||
.getState()
|
||||
.getOrFetchWsConnection();
|
||||
|
||||
if (!wsConnection) {
|
||||
throw new Error("웹소켓 승인키 발급에 실패했습니다.");
|
||||
}
|
||||
|
||||
if (disposed) return;
|
||||
approvalKeyRef.current = wsConnection.approvalKey;
|
||||
|
||||
socket = new WebSocket(`${wsConnection.wsUrl}/tryitout/${tradeTrId}`);
|
||||
socketRef.current = socket;
|
||||
|
||||
socket.onopen = () => {
|
||||
if (disposed || !approvalKeyRef.current) return;
|
||||
|
||||
socket?.send(
|
||||
JSON.stringify(
|
||||
buildKisRealtimeMessage(
|
||||
approvalKeyRef.current,
|
||||
symbol,
|
||||
tradeTrId,
|
||||
"1",
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
if (obSymbol && orderBookTrId) {
|
||||
socket?.send(
|
||||
JSON.stringify(
|
||||
buildKisRealtimeMessage(
|
||||
approvalKeyRef.current,
|
||||
obSymbol,
|
||||
orderBookTrId,
|
||||
"1",
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
setIsConnected(true);
|
||||
};
|
||||
|
||||
socket.onmessage = (event) => {
|
||||
if (disposed || typeof event.data !== "string") return;
|
||||
|
||||
if (obSymbol && onOrderBookMsg) {
|
||||
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;
|
||||
|
||||
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;
|
||||
});
|
||||
|
||||
const latest = ticks[ticks.length - 1];
|
||||
setLatestTick(latest);
|
||||
|
||||
if (dedupedTicks.length > 0) {
|
||||
setRecentTradeTicks((prev) =>
|
||||
[...dedupedTicks.reverse(), ...prev].slice(0, MAX_TRADE_TICKS),
|
||||
);
|
||||
}
|
||||
|
||||
setError(null);
|
||||
setLastTickAt(Date.now());
|
||||
onTick?.(latest);
|
||||
};
|
||||
|
||||
socket.onerror = () => {
|
||||
if (!disposed) setIsConnected(false);
|
||||
};
|
||||
|
||||
socket.onclose = () => {
|
||||
if (!disposed) setIsConnected(false);
|
||||
};
|
||||
} catch (err) {
|
||||
if (disposed) return;
|
||||
|
||||
setError(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: "실시간 웹소켓 초기화 중 오류가 발생했습니다.",
|
||||
);
|
||||
setIsConnected(false);
|
||||
}
|
||||
};
|
||||
|
||||
void connect();
|
||||
const seenRef = seenTickRef.current;
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
setIsConnected(false);
|
||||
|
||||
const key = approvalKeyRef.current;
|
||||
if (socket?.readyState === WebSocket.OPEN && key) {
|
||||
socket.send(
|
||||
JSON.stringify(buildKisRealtimeMessage(key, symbol, tradeTrId, "2")),
|
||||
);
|
||||
|
||||
if (obSymbol && orderBookTrId) {
|
||||
socket.send(
|
||||
JSON.stringify(
|
||||
buildKisRealtimeMessage(key, obSymbol, orderBookTrId, "2"),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
socket?.close();
|
||||
if (socketRef.current === socket) socketRef.current = null;
|
||||
approvalKeyRef.current = null;
|
||||
seenRef.clear();
|
||||
};
|
||||
}, [
|
||||
symbol,
|
||||
isVerified,
|
||||
credentials,
|
||||
marketSession,
|
||||
onTick,
|
||||
obSymbol,
|
||||
onOrderBookMsg,
|
||||
]);
|
||||
|
||||
return {
|
||||
latestTick,
|
||||
recentTradeTicks,
|
||||
isConnected,
|
||||
error,
|
||||
lastTickAt,
|
||||
realtimeTrId,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveSessionInClient() {
|
||||
if (typeof window === "undefined") {
|
||||
return resolveDomesticKisSession();
|
||||
}
|
||||
|
||||
try {
|
||||
const override = window.localStorage.getItem(
|
||||
DOMESTIC_KIS_SESSION_OVERRIDE_STORAGE_KEY,
|
||||
);
|
||||
return resolveDomesticKisSession(override);
|
||||
} catch {
|
||||
return resolveDomesticKisSession();
|
||||
}
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
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 { toast } from "sonner";
|
||||
|
||||
/**
|
||||
* @description 초기 REST 호가를 한 번 조회하고, 이후에는 웹소켓 호가를 우선 사용합니다.
|
||||
* 웹소켓 호가 데이터는 DashboardContainer에서 useKisTradeWebSocket을 통해
|
||||
* 단일 WebSocket으로 수신되어 externalRealtimeOrderBook으로 주입됩니다.
|
||||
* @see features/dashboard/components/DashboardContainer.tsx 호가 데이터 흐름
|
||||
* @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;
|
||||
/** 체결 WS에서 받은 실시간 호가 데이터 (단일 WS 통합) */
|
||||
externalRealtimeOrderBook?: DashboardStockOrderBookResponse | null;
|
||||
} = {},
|
||||
) {
|
||||
const { enabled = true, externalRealtimeOrderBook = null } = 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]);
|
||||
|
||||
// 외부 실시간 호가 → 초기 데이터 → null 순 우선
|
||||
const orderBook = isRequestEnabled
|
||||
? (externalRealtimeOrderBook ?? initialData)
|
||||
: null;
|
||||
const mergedError = isRequestEnabled ? error : null;
|
||||
const mergedLoading = isRequestEnabled ? isLoading && !orderBook : false;
|
||||
|
||||
return {
|
||||
orderBook,
|
||||
isLoading: mergedLoading,
|
||||
error: mergedError,
|
||||
isWsConnected: !!externalRealtimeOrderBook,
|
||||
};
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
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);
|
||||
}
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
/**
|
||||
* 실시간 체결 수신 시 헤더/주요 시세 상태만 갱신합니다.
|
||||
* 차트 캔들은 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 } = tick;
|
||||
const nextChange = change;
|
||||
const nextChangeRate = Number.isFinite(changeRate)
|
||||
? changeRate
|
||||
: prev.prevClose > 0
|
||||
? (nextChange / prev.prevClose) * 100
|
||||
: prev.changeRate;
|
||||
|
||||
return {
|
||||
...prev,
|
||||
currentPrice: price,
|
||||
change: nextChange,
|
||||
changeRate: nextChangeRate,
|
||||
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,
|
||||
};
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return {
|
||||
selectedStock,
|
||||
setSelectedStock,
|
||||
meta,
|
||||
setMeta,
|
||||
error,
|
||||
setError,
|
||||
isLoading,
|
||||
loadOverview,
|
||||
updateRealtimeTradeTick,
|
||||
};
|
||||
}
|
||||
@@ -1,191 +0,0 @@
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { fetchStockSearch } from "@/features/dashboard/apis/kis-stock.api";
|
||||
import type { KisRuntimeCredentials } from "@/features/dashboard/store/use-kis-runtime-store";
|
||||
import type {
|
||||
DashboardStockSearchHistoryItem,
|
||||
DashboardStockSearchItem,
|
||||
} from "@/features/dashboard/types/dashboard.types";
|
||||
|
||||
const SEARCH_HISTORY_STORAGE_KEY = "jurini:stock-search-history:v1";
|
||||
const SEARCH_HISTORY_LIMIT = 12;
|
||||
|
||||
interface StoredSearchHistory {
|
||||
version: 1;
|
||||
items: DashboardStockSearchHistoryItem[];
|
||||
}
|
||||
|
||||
function readSearchHistory(): DashboardStockSearchHistoryItem[] {
|
||||
if (typeof window === "undefined") return [];
|
||||
|
||||
try {
|
||||
const raw = window.localStorage.getItem(SEARCH_HISTORY_STORAGE_KEY);
|
||||
if (!raw) return [];
|
||||
|
||||
const parsed = JSON.parse(raw) as StoredSearchHistory;
|
||||
if (parsed?.version !== 1 || !Array.isArray(parsed.items)) return [];
|
||||
|
||||
return parsed.items
|
||||
.filter((item) => item?.symbol && item?.name && item?.market)
|
||||
.slice(0, SEARCH_HISTORY_LIMIT);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function writeSearchHistory(items: DashboardStockSearchHistoryItem[]) {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
const payload: StoredSearchHistory = {
|
||||
version: 1,
|
||||
items,
|
||||
};
|
||||
window.localStorage.setItem(SEARCH_HISTORY_STORAGE_KEY, JSON.stringify(payload));
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 종목 검색 상태(키워드/결과/에러)와 검색 히스토리(localStorage)를 함께 관리합니다.
|
||||
* @see features/dashboard/components/DashboardContainer.tsx 검색 제출/자동검색/히스토리 클릭 이벤트에서 호출합니다.
|
||||
* @see features/dashboard/components/search/StockSearchHistory.tsx 히스토리 목록 렌더링 데이터로 사용합니다.
|
||||
*/
|
||||
export function useStockSearch() {
|
||||
// ========== SEARCH STATE ==========
|
||||
const [keyword, setKeyword] = useState("삼성전자");
|
||||
const [searchResults, setSearchResults] = useState<DashboardStockSearchItem[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
|
||||
// ========== SEARCH HISTORY STATE ==========
|
||||
const [searchHistory, setSearchHistory] = useState<DashboardStockSearchHistoryItem[]>(
|
||||
() => readSearchHistory(),
|
||||
);
|
||||
|
||||
// 동일 시점 중복 요청과 경합 응답을 막기 위한 취소 컨트롤러
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
const loadSearch = useCallback(async (query: string) => {
|
||||
const controller = new AbortController();
|
||||
abortRef.current?.abort();
|
||||
abortRef.current = controller;
|
||||
|
||||
setIsSearching(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const data = await fetchStockSearch(query, controller.signal);
|
||||
setSearchResults(data.items);
|
||||
return data.items;
|
||||
} catch (err) {
|
||||
if (controller.signal.aborted) return [];
|
||||
|
||||
setError(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: "종목 검색 중 오류가 발생했습니다.",
|
||||
);
|
||||
return [];
|
||||
} finally {
|
||||
if (!controller.signal.aborted) {
|
||||
setIsSearching(false);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* @description 검색어를 받아 종목 검색 API를 호출합니다.
|
||||
* @see features/dashboard/components/DashboardContainer.tsx handleSearchSubmit 자동/수동 검색에 사용합니다.
|
||||
*/
|
||||
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],
|
||||
);
|
||||
|
||||
/**
|
||||
* @description 검색 결과를 지우고 진행 중인 검색 요청을 중단합니다.
|
||||
* @see features/dashboard/components/DashboardContainer.tsx 종목 선택 직후 검색 패널 정리에 사용합니다.
|
||||
*/
|
||||
const clearSearch = useCallback(() => {
|
||||
abortRef.current?.abort();
|
||||
setSearchResults([]);
|
||||
setError(null);
|
||||
setIsSearching(false);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* @description API 검증 전 같은 상황에서 공통 에러 메시지를 표시하기 위한 setter입니다.
|
||||
* @see features/dashboard/components/DashboardContainer.tsx ensureSearchReady 인증 가드에서 사용합니다.
|
||||
*/
|
||||
const setSearchError = useCallback((message: string | null) => {
|
||||
setError(message);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* @description 선택한 종목을 검색 히스토리 맨 위에 추가(중복 제거)합니다.
|
||||
* @see features/dashboard/components/DashboardContainer.tsx handleSelectStock 종목 선택 이벤트에서 호출합니다.
|
||||
*/
|
||||
const appendSearchHistory = useCallback((item: DashboardStockSearchItem) => {
|
||||
setSearchHistory((prev) => {
|
||||
const deduped = prev.filter((historyItem) => historyItem.symbol !== item.symbol);
|
||||
const nextItems: DashboardStockSearchHistoryItem[] = [
|
||||
{ ...item, savedAt: Date.now() },
|
||||
...deduped,
|
||||
].slice(0, SEARCH_HISTORY_LIMIT);
|
||||
|
||||
writeSearchHistory(nextItems);
|
||||
return nextItems;
|
||||
});
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* @description 종목코드 기준으로 히스토리 항목을 삭제합니다.
|
||||
* @see features/dashboard/components/search/StockSearchHistory.tsx 삭제 버튼 클릭 이벤트에서 호출합니다.
|
||||
*/
|
||||
const removeSearchHistory = useCallback((symbol: string) => {
|
||||
setSearchHistory((prev) => {
|
||||
const nextItems = prev.filter((item) => item.symbol !== symbol);
|
||||
writeSearchHistory(nextItems);
|
||||
return nextItems;
|
||||
});
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* @description 저장된 검색 히스토리를 전체 삭제합니다.
|
||||
* @see features/dashboard/components/search/StockSearchHistory.tsx 전체 삭제 버튼 이벤트에서 호출합니다.
|
||||
*/
|
||||
const clearSearchHistory = useCallback(() => {
|
||||
setSearchHistory([]);
|
||||
writeSearchHistory([]);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
keyword,
|
||||
setKeyword,
|
||||
searchResults,
|
||||
error,
|
||||
isSearching,
|
||||
search,
|
||||
clearSearch,
|
||||
setSearchError,
|
||||
searchHistory,
|
||||
appendSearchHistory,
|
||||
removeSearchHistory,
|
||||
clearSearchHistory,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user