임시커밋

This commit is contained in:
2026-02-11 16:31:28 +09:00
parent f650d51f68
commit 3cea3e66d0
45 changed files with 289 additions and 236 deletions

View File

@@ -0,0 +1,53 @@
import { useMemo } from "react";
import type {
DashboardRealtimeTradeTick,
DashboardStockItem,
DashboardStockOrderBookResponse,
} from "@/features/trade/types/trade.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]);
}

View File

@@ -0,0 +1,296 @@
import { useEffect, useRef, useState } from "react";
import {
type KisRuntimeCredentials,
useKisRuntimeStore,
} from "@/features/settings/store/use-kis-runtime-store";
import type {
DashboardRealtimeTradeTick,
DashboardStockOrderBookResponse,
} from "@/features/trade/types/trade.types";
import {
buildKisRealtimeMessage,
parseKisRealtimeOrderbook,
parseKisRealtimeTickBatch,
} from "@/features/trade/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;
if (shouldUseExpectedExecutionTr(session)) return TRADE_TR_ID;
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/trade/components/TradeContainer.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();
}
}

View File

@@ -0,0 +1,61 @@
import { useState, useCallback } from "react";
import type { KisRuntimeCredentials } from "@/features/settings/store/use-kis-runtime-store";
import type {
DashboardStockCashOrderRequest,
DashboardStockCashOrderResponse,
} from "@/features/trade/types/trade.types";
import { fetchOrderCash } from "@/features/trade/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,
};
}

View File

@@ -0,0 +1,91 @@
import { useEffect, useRef, useState } from "react";
import type { KisRuntimeCredentials } from "@/features/settings/store/use-kis-runtime-store";
import type { DashboardStockOrderBookResponse } from "@/features/trade/types/trade.types";
import { fetchStockOrderBook } from "@/features/trade/apis/kis-stock.api";
import { toast } from "sonner";
/**
* @description 초기 REST 호가를 한 번 조회하고, 이후에는 웹소켓 호가를 우선 사용합니다.
* 웹소켓 호가 데이터는 TradeContainer에서 useKisTradeWebSocket을 통해
* 단일 WebSocket으로 수신되어 externalRealtimeOrderBook으로 주입됩니다.
* @see features/trade/components/TradeContainer.tsx 호가 데이터 흐름
* @see features/trade/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,
};
}

View File

@@ -0,0 +1,104 @@
import { useCallback, useState, useTransition } from "react";
import type { KisRuntimeCredentials } from "@/features/settings/store/use-kis-runtime-store";
import type {
DashboardMarketPhase,
DashboardPriceSource,
DashboardRealtimeTradeTick,
DashboardStockSearchItem,
DashboardStockItem,
} from "@/features/trade/types/trade.types";
import { fetchStockOverview } from "@/features/trade/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/trade/components/TradeContainer.tsx useKisTradeWebSocket onTick 전달
* @see features/trade/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,
};
}

View File

@@ -0,0 +1,191 @@
import { useCallback, useRef, useState } from "react";
import { fetchStockSearch } from "@/features/trade/apis/kis-stock.api";
import type { KisRuntimeCredentials } from "@/features/settings/store/use-kis-runtime-store";
import type {
DashboardStockSearchHistoryItem,
DashboardStockSearchItem,
} from "@/features/trade/types/trade.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/trade/components/TradeContainer.tsx 검색 제출/자동검색/히스토리 클릭 이벤트에서 호출합니다.
* @see features/trade/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/trade/components/TradeContainer.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/trade/components/TradeContainer.tsx 종목 선택 직후 검색 패널 정리에 사용합니다.
*/
const clearSearch = useCallback(() => {
abortRef.current?.abort();
setSearchResults([]);
setError(null);
setIsSearching(false);
}, []);
/**
* @description API 검증 전 같은 상황에서 공통 에러 메시지를 표시하기 위한 setter입니다.
* @see features/trade/components/TradeContainer.tsx ensureSearchReady 인증 가드에서 사용합니다.
*/
const setSearchError = useCallback((message: string | null) => {
setError(message);
}, []);
/**
* @description 선택한 종목을 검색 히스토리 맨 위에 추가(중복 제거)합니다.
* @see features/trade/components/TradeContainer.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/trade/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/trade/components/search/StockSearchHistory.tsx 전체 삭제 버튼 이벤트에서 호출합니다.
*/
const clearSearchHistory = useCallback(() => {
setSearchHistory([]);
writeSearchHistory([]);
}, []);
return {
keyword,
setKeyword,
searchResults,
error,
isSearching,
search,
clearSearch,
setSearchError,
searchHistory,
appendSearchHistory,
removeSearchHistory,
clearSearchHistory,
};
}