대시보드 실시간 기능 추가

This commit is contained in:
2026-02-13 12:17:35 +09:00
parent 12feeb2775
commit 1ac907cd27
35 changed files with 2790 additions and 1032 deletions

View File

@@ -1,6 +1,7 @@
"use client";
import { type FormEvent, useCallback, useState } from "react";
import { type FormEvent, useCallback, useEffect, useState } from "react";
import { useSearchParams } from "next/navigation";
import { useShallow } from "zustand/react/shallow";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { useKisRuntimeStore } from "@/features/settings/store/use-kis-runtime-store";
@@ -26,6 +27,10 @@ import type {
* @see features/trade/hooks/useStockOverview.ts 선택 종목 상세 상태를 관리합니다.
*/
export function TradeContainer() {
const searchParams = useSearchParams();
const symbolParam = searchParams.get("symbol");
const nameParam = searchParams.get("name");
// [State] 호가 실시간 데이터 (체결 WS와 동일 소켓에서 수신)
const [realtimeOrderBook, setRealtimeOrderBook] =
useState<DashboardStockOrderBookResponse | null>(null);
@@ -53,6 +58,36 @@ export function TradeContainer() {
} = useStockSearch();
const { selectedStock, loadOverview, updateRealtimeTradeTick } =
useStockOverview();
/**
* [Effect] URL 파라미터(symbol) 감지 시 자동 종목 로드
* 대시보드 등 외부에서 종목 코드를 넘겨받아 트레이딩 페이지로 진입할 때 사용합니다.
*/
useEffect(() => {
if (symbolParam && isKisVerified && verifiedCredentials && _hasHydrated) {
// 현재 선택된 종목과 파라미터가 다를 경우에만 자동 로드 수행
if (selectedStock?.symbol !== symbolParam) {
setKeyword(nameParam || symbolParam);
appendSearchHistory({
symbol: symbolParam,
name: nameParam || symbolParam,
market: "KOSPI", // 기본값 설정, loadOverview 이후 실제 데이터로 보완됨
});
loadOverview(symbolParam, verifiedCredentials);
}
}
}, [
symbolParam,
nameParam,
isKisVerified,
verifiedCredentials,
_hasHydrated,
selectedStock?.symbol,
loadOverview,
setKeyword,
appendSearchHistory,
]);
const canTrade = isKisVerified && !!verifiedCredentials;
const canSearch = canTrade;

View File

@@ -35,7 +35,7 @@ import {
} from "./chart-utils";
const UP_COLOR = "#ef4444";
const MINUTE_SYNC_INTERVAL_MS = 5000;
const MINUTE_SYNC_INTERVAL_MS = 30000;
const REALTIME_STALE_THRESHOLD_MS = 12000;
interface ChartPalette {
@@ -522,11 +522,11 @@ export function StockLineChart({
}
}, [isChartReady, renderableBars, setSeriesData]);
/**
* @description WebSocket 체결 틱을 현재 timeframe 캔들에 반영합니다.
* @see features/trade/hooks/useKisTradeWebSocket.ts latestTick
* @see features/trade/components/chart/chart-utils.ts toRealtimeTickBar
*/
/**
* @description WebSocket 체결 틱을 현재 timeframe 캔들에 반영합니다.
* @see features/trade/hooks/useKisTradeWebSocket.ts latestTick
* @see features/trade/components/chart/chart-utils.ts toRealtimeTickBar
*/
useEffect(() => {
if (!latestTick) return;
if (bars.length === 0) return;
@@ -567,7 +567,10 @@ export function StockLineChart({
const recentBars = latestPageBars.slice(-10);
if (recentBars.length === 0) return;
setBars((prev) => mergeBars(prev, recentBars));
setBars((prev) => {
const merged = mergeBars(prev, recentBars);
return areBarsEqual(prev, merged) ? prev : merged;
});
} catch {
// 폴링 실패는 치명적이지 않으므로 조용히 다음 주기에서 재시도합니다.
}
@@ -690,3 +693,25 @@ export function StockLineChart({
</div>
);
}
function areBarsEqual(left: ChartBar[], right: ChartBar[]) {
if (left.length !== right.length) return false;
for (let index = 0; index < left.length; index += 1) {
const lhs = left[index];
const rhs = right[index];
if (!lhs || !rhs) return false;
if (
lhs.time !== rhs.time ||
lhs.open !== rhs.open ||
lhs.high !== rhs.high ||
lhs.low !== rhs.low ||
lhs.close !== rhs.close ||
lhs.volume !== rhs.volume
) {
return false;
}
}
return true;
}

View File

@@ -150,7 +150,7 @@ function resolveBarTimestamp(
/**
* 타임스탬프를 타임프레임 버킷 경계에 정렬
* - 1m: 그대로
* - 1m: 초/밀리초를 제거해 분 경계에 정렬
* - 30m/1h: 분 단위를 버킷에 정렬
* - 1d: 00:00:00
* - 1w: 월요일 00:00:00
@@ -161,7 +161,9 @@ function alignTimestamp(
): UTCTimestamp {
const d = new Date(timestamp * 1000);
if (timeframe === "30m" || timeframe === "1h") {
if (timeframe === "1m") {
d.setUTCSeconds(0, 0);
} else if (timeframe === "30m" || timeframe === "1h") {
const bucket = timeframe === "30m" ? 30 : 60;
d.setUTCMinutes(Math.floor(d.getUTCMinutes() / bucket) * bucket, 0, 0);
} else if (timeframe === "1d") {