대시보드
This commit is contained in:
999
features/dashboard/components/dashboard-main.tsx
Normal file
999
features/dashboard/components/dashboard-main.tsx
Normal file
@@ -0,0 +1,999 @@
|
||||
"use client";
|
||||
|
||||
import { FormEvent, useCallback, useEffect, useRef, useState, useTransition } from "react";
|
||||
import { Activity, Search, ShieldCheck, TrendingDown, TrendingUp } from "lucide-react";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
useKisRuntimeStore,
|
||||
type KisRuntimeCredentials,
|
||||
} from "@/features/dashboard/store/use-kis-runtime-store";
|
||||
import type {
|
||||
DashboardMarketPhase,
|
||||
DashboardKisRevokeResponse,
|
||||
DashboardPriceSource,
|
||||
DashboardKisValidateResponse,
|
||||
DashboardKisWsApprovalResponse,
|
||||
DashboardStockItem,
|
||||
DashboardStockOverviewResponse,
|
||||
DashboardStockSearchItem,
|
||||
DashboardStockSearchResponse,
|
||||
StockCandlePoint,
|
||||
} from "@/features/dashboard/types/dashboard.types";
|
||||
|
||||
/**
|
||||
* @file features/dashboard/components/dashboard-main.tsx
|
||||
* @description 대시보드 메인 UI(검색/시세/차트)
|
||||
*/
|
||||
|
||||
const PRICE_FORMATTER = new Intl.NumberFormat("ko-KR");
|
||||
|
||||
function formatPrice(value: number) {
|
||||
return `${PRICE_FORMATTER.format(value)}원`;
|
||||
}
|
||||
|
||||
function formatVolume(value: number) {
|
||||
return `${PRICE_FORMATTER.format(value)}주`;
|
||||
}
|
||||
|
||||
function getPriceSourceLabel(source: DashboardPriceSource, marketPhase: DashboardMarketPhase) {
|
||||
switch (source) {
|
||||
case "inquire-overtime-price":
|
||||
return "시간외 현재가(inquire-overtime-price)";
|
||||
case "inquire-ccnl":
|
||||
return marketPhase === "afterHours"
|
||||
? "체결가 폴백(inquire-ccnl)"
|
||||
: "체결가(inquire-ccnl)";
|
||||
default:
|
||||
return "현재가(inquire-price)";
|
||||
}
|
||||
}
|
||||
|
||||
function getMarketPhaseLabel(marketPhase: DashboardMarketPhase) {
|
||||
return marketPhase === "regular" ? "장중(한국시간 09:00~15:30)" : "장외/휴장";
|
||||
}
|
||||
|
||||
/**
|
||||
* 주가 라인 차트(SVG)
|
||||
*/
|
||||
function StockLineChart({ candles }: { candles: StockCandlePoint[] }) {
|
||||
const chart = (() => {
|
||||
const width = 760;
|
||||
const height = 280;
|
||||
const paddingX = 24;
|
||||
const paddingY = 20;
|
||||
const plotWidth = width - paddingX * 2;
|
||||
const plotHeight = height - paddingY * 2;
|
||||
|
||||
const prices = candles.map((item) => item.price);
|
||||
const minPrice = Math.min(...prices);
|
||||
const maxPrice = Math.max(...prices);
|
||||
const range = Math.max(maxPrice - minPrice, 1);
|
||||
|
||||
const points = candles.map((item, index) => {
|
||||
const x = paddingX + (index / Math.max(candles.length - 1, 1)) * plotWidth;
|
||||
const y = paddingY + ((maxPrice - item.price) / range) * plotHeight;
|
||||
return { x, y };
|
||||
});
|
||||
|
||||
const linePoints = points.map((point) => `${point.x},${point.y}`).join(" ");
|
||||
const firstPoint = points[0];
|
||||
const lastPoint = points[points.length - 1];
|
||||
const areaPoints = `${linePoints} ${lastPoint.x},${height - paddingY} ${firstPoint.x},${height - paddingY}`;
|
||||
|
||||
return { width, height, paddingX, paddingY, minPrice, maxPrice, linePoints, areaPoints };
|
||||
})();
|
||||
|
||||
return (
|
||||
<div className="h-[300px] w-full">
|
||||
<svg viewBox={`0 0 ${chart.width} ${chart.height}`} className="h-full w-full">
|
||||
<defs>
|
||||
<linearGradient id="priceAreaGradient" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="var(--color-brand-500)" stopOpacity="0.35" />
|
||||
<stop offset="100%" stopColor="var(--color-brand-500)" stopOpacity="0.02" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<line
|
||||
x1={chart.paddingX}
|
||||
y1={chart.paddingY}
|
||||
x2={chart.width - chart.paddingX}
|
||||
y2={chart.paddingY}
|
||||
stroke="currentColor"
|
||||
className="text-border"
|
||||
/>
|
||||
<line
|
||||
x1={chart.paddingX}
|
||||
y1={chart.height / 2}
|
||||
x2={chart.width - chart.paddingX}
|
||||
y2={chart.height / 2}
|
||||
stroke="currentColor"
|
||||
className="text-border/70"
|
||||
/>
|
||||
<line
|
||||
x1={chart.paddingX}
|
||||
y1={chart.height - chart.paddingY}
|
||||
x2={chart.width - chart.paddingX}
|
||||
y2={chart.height - chart.paddingY}
|
||||
stroke="currentColor"
|
||||
className="text-border"
|
||||
/>
|
||||
|
||||
<polygon points={chart.areaPoints} fill="url(#priceAreaGradient)" />
|
||||
<polyline
|
||||
points={chart.linePoints}
|
||||
fill="none"
|
||||
stroke="var(--color-brand-600)"
|
||||
strokeWidth={3}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<div className="mt-3 flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>{candles[0]?.time}</span>
|
||||
<span>저가 {formatPrice(chart.minPrice)}</span>
|
||||
<span>고가 {formatPrice(chart.maxPrice)}</span>
|
||||
<span>{candles[candles.length - 1]?.time}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PriceStat({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="rounded-xl border border-border/70 bg-background/70 p-3">
|
||||
<p className="text-xs text-muted-foreground">{label}</p>
|
||||
<p className="mt-1 text-sm font-semibold text-foreground">{value}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
async function fetchStockSearch(keyword: string) {
|
||||
const response = await fetch(`/api/kis/domestic/search?q=${encodeURIComponent(keyword)}`, {
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
const payload = (await response.json()) as DashboardStockSearchResponse | { error?: string };
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("error" in payload ? payload.error : "종목 검색 중 오류가 발생했습니다.");
|
||||
}
|
||||
|
||||
return payload as DashboardStockSearchResponse;
|
||||
}
|
||||
|
||||
async function fetchStockOverview(symbol: string, credentials: KisRuntimeCredentials) {
|
||||
const response = await fetch(`/api/kis/domestic/overview?symbol=${encodeURIComponent(symbol)}`, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"x-kis-app-key": credentials.appKey,
|
||||
"x-kis-app-secret": credentials.appSecret,
|
||||
"x-kis-trading-env": credentials.tradingEnv,
|
||||
},
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
const payload = (await response.json()) as DashboardStockOverviewResponse | { error?: string };
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("error" in payload ? payload.error : "종목 조회 중 오류가 발생했습니다.");
|
||||
}
|
||||
|
||||
return payload as DashboardStockOverviewResponse;
|
||||
}
|
||||
|
||||
async function validateKisCredentials(credentials: KisRuntimeCredentials) {
|
||||
const response = await fetch("/api/kis/validate", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(credentials),
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
const payload = (await response.json()) as DashboardKisValidateResponse;
|
||||
|
||||
if (!response.ok || !payload.ok) {
|
||||
throw new Error(payload.message || "API 키 검증에 실패했습니다.");
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* KIS 접근토큰 폐기 요청
|
||||
* @param credentials 검증 완료된 KIS 키
|
||||
* @returns 폐기 응답
|
||||
* @see app/api/kis/revoke/route.ts POST - revokeP 폐기 프록시
|
||||
*/
|
||||
async function revokeKisCredentials(credentials: KisRuntimeCredentials) {
|
||||
const response = await fetch("/api/kis/revoke", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(credentials),
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
const payload = (await response.json()) as DashboardKisRevokeResponse;
|
||||
|
||||
if (!response.ok || !payload.ok) {
|
||||
throw new Error(payload.message || "API 키 접근 폐기에 실패했습니다.");
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
const KIS_REALTIME_TR_ID_REAL = "H0UNCNT0";
|
||||
const KIS_REALTIME_TR_ID_MOCK = "H0STCNT0";
|
||||
|
||||
function resolveRealtimeTrId(tradingEnv: KisRuntimeCredentials["tradingEnv"]) {
|
||||
return tradingEnv === "mock" ? KIS_REALTIME_TR_ID_MOCK : KIS_REALTIME_TR_ID_REAL;
|
||||
}
|
||||
|
||||
/**
|
||||
* KIS 실시간 웹소켓 승인키를 발급받습니다.
|
||||
* @param credentials 검증 완료된 KIS 키
|
||||
* @returns approval key + ws url
|
||||
* @see app/api/kis/ws/approval/route.ts POST - Approval 발급 프록시
|
||||
*/
|
||||
async function fetchKisWebSocketApproval(credentials: KisRuntimeCredentials) {
|
||||
const response = await fetch("/api/kis/ws/approval", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(credentials),
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
const payload = (await response.json()) as DashboardKisWsApprovalResponse;
|
||||
if (!response.ok || !payload.ok || !payload.approvalKey || !payload.wsUrl) {
|
||||
throw new Error(payload.message || "KIS 실시간 웹소켓 승인키 발급에 실패했습니다.");
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* KIS 실시간 체결가 구독/해제 메시지를 생성합니다.
|
||||
* @param approvalKey websocket 승인키
|
||||
* @param symbol 종목코드
|
||||
* @param trType "1"(구독) | "2"(해제)
|
||||
* @returns websocket 요청 메시지
|
||||
* @see https://github.com/koreainvestment/open-trading-api
|
||||
*/
|
||||
function buildKisRealtimeMessage(
|
||||
approvalKey: string,
|
||||
symbol: string,
|
||||
trId: string,
|
||||
trType: "1" | "2",
|
||||
) {
|
||||
return {
|
||||
header: {
|
||||
approval_key: approvalKey,
|
||||
custtype: "P",
|
||||
tr_type: trType,
|
||||
"content-type": "utf-8",
|
||||
},
|
||||
body: {
|
||||
input: {
|
||||
tr_id: trId,
|
||||
tr_key: symbol,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
interface KisRealtimeTick {
|
||||
point: StockCandlePoint;
|
||||
price: number;
|
||||
accumulatedVolume: number;
|
||||
tickTime: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* KIS 실시간 체결가 원문을 차트 포인트로 변환합니다.
|
||||
* @param raw websocket 수신 원문
|
||||
* @param expectedSymbol 현재 선택 종목코드
|
||||
* @returns 실시간 포인트 또는 null
|
||||
*/
|
||||
function parseKisRealtimeTick(raw: string, expectedSymbol: string, expectedTrId: string): KisRealtimeTick | null {
|
||||
if (!/^([01])\|/.test(raw)) return null;
|
||||
|
||||
const parts = raw.split("|");
|
||||
if (parts.length < 4) return null;
|
||||
if (parts[1] !== expectedTrId) return null;
|
||||
|
||||
const tickCount = Number(parts[2] ?? "1");
|
||||
const values = parts[3].split("^");
|
||||
const isBatch = Number.isInteger(tickCount) && tickCount > 1 && values.length % tickCount === 0;
|
||||
const fieldsPerTick = isBatch ? values.length / tickCount : values.length;
|
||||
const baseIndex = isBatch ? (tickCount - 1) * fieldsPerTick : 0;
|
||||
const symbol = values[baseIndex];
|
||||
const hhmmss = values[baseIndex + 1];
|
||||
const price = Number((values[baseIndex + 2] ?? "").replaceAll(",", "").trim());
|
||||
const accumulatedVolume = Number((values[baseIndex + 13] ?? "").replaceAll(",", "").trim());
|
||||
|
||||
if (symbol !== expectedSymbol) return null;
|
||||
if (!Number.isFinite(price) || price <= 0) return null;
|
||||
|
||||
return {
|
||||
point: {
|
||||
time: formatRealtimeTickTime(hhmmss),
|
||||
price,
|
||||
},
|
||||
price,
|
||||
accumulatedVolume: Number.isFinite(accumulatedVolume) && accumulatedVolume > 0 ? accumulatedVolume : 0,
|
||||
tickTime: hhmmss ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
function formatRealtimeTickTime(hhmmss?: string) {
|
||||
if (!hhmmss || hhmmss.length !== 6) return "실시간";
|
||||
return `${hhmmss.slice(0, 2)}:${hhmmss.slice(2, 4)}:${hhmmss.slice(4, 6)}`;
|
||||
}
|
||||
|
||||
function appendRealtimeTick(prev: StockCandlePoint[], next: StockCandlePoint) {
|
||||
if (prev.length === 0) return [next];
|
||||
|
||||
const last = prev[prev.length - 1];
|
||||
if (last.time === next.time) {
|
||||
return [...prev.slice(0, -1), next];
|
||||
}
|
||||
|
||||
return [...prev, next].slice(-80);
|
||||
}
|
||||
|
||||
function toTickOrderValue(hhmmss?: string) {
|
||||
if (!hhmmss || !/^\d{6}$/.test(hhmmss)) return -1;
|
||||
return Number(hhmmss);
|
||||
}
|
||||
|
||||
/**
|
||||
* 대시보드 메인 화면
|
||||
*/
|
||||
export function DashboardMain() {
|
||||
// [State] KIS 키 입력/검증 상태(zustand + persist)
|
||||
const {
|
||||
kisTradingEnvInput,
|
||||
kisAppKeyInput,
|
||||
kisAppSecretInput,
|
||||
verifiedCredentials,
|
||||
isKisVerified,
|
||||
tradingEnv,
|
||||
setKisTradingEnvInput,
|
||||
setKisAppKeyInput,
|
||||
setKisAppSecretInput,
|
||||
setVerifiedKisSession,
|
||||
invalidateKisVerification,
|
||||
clearKisRuntimeSession,
|
||||
} = useKisRuntimeStore(
|
||||
useShallow((state) => ({
|
||||
kisTradingEnvInput: state.kisTradingEnvInput,
|
||||
kisAppKeyInput: state.kisAppKeyInput,
|
||||
kisAppSecretInput: state.kisAppSecretInput,
|
||||
verifiedCredentials: state.verifiedCredentials,
|
||||
isKisVerified: state.isKisVerified,
|
||||
tradingEnv: state.tradingEnv,
|
||||
setKisTradingEnvInput: state.setKisTradingEnvInput,
|
||||
setKisAppKeyInput: state.setKisAppKeyInput,
|
||||
setKisAppSecretInput: state.setKisAppSecretInput,
|
||||
setVerifiedKisSession: state.setVerifiedKisSession,
|
||||
invalidateKisVerification: state.invalidateKisVerification,
|
||||
clearKisRuntimeSession: state.clearKisRuntimeSession,
|
||||
})),
|
||||
);
|
||||
|
||||
// [State] 검증 상태 메시지
|
||||
const [kisStatusMessage, setKisStatusMessage] = useState<string | null>(null);
|
||||
const [kisStatusError, setKisStatusError] = useState<string | null>(null);
|
||||
|
||||
// [State] 검색/선택 데이터
|
||||
const [keyword, setKeyword] = useState("삼성전자");
|
||||
const [searchResults, setSearchResults] = useState<DashboardStockSearchItem[]>([]);
|
||||
const [selectedStock, setSelectedStock] = useState<DashboardStockItem | null>(null);
|
||||
const [selectedOverviewMeta, setSelectedOverviewMeta] = useState<{
|
||||
priceSource: DashboardPriceSource;
|
||||
marketPhase: DashboardMarketPhase;
|
||||
fetchedAt: string;
|
||||
} | null>(null);
|
||||
const [realtimeCandles, setRealtimeCandles] = useState<StockCandlePoint[]>([]);
|
||||
const [isRealtimeConnected, setIsRealtimeConnected] = useState(false);
|
||||
const [realtimeError, setRealtimeError] = useState<string | null>(null);
|
||||
const [lastRealtimeTickAt, setLastRealtimeTickAt] = useState<number | null>(null);
|
||||
const [realtimeTickCount, setRealtimeTickCount] = useState(0);
|
||||
|
||||
// [State] 영역별 에러
|
||||
const [searchError, setSearchError] = useState<string | null>(null);
|
||||
const [overviewError, setOverviewError] = useState<string | null>(null);
|
||||
|
||||
// [State] 비동기 전환 상태
|
||||
const [isValidatingKis, startValidateTransition] = useTransition();
|
||||
const [isRevokingKis, startRevokeTransition] = useTransition();
|
||||
const [isSearching, startSearchTransition] = useTransition();
|
||||
const [isLoadingOverview, startOverviewTransition] = useTransition();
|
||||
|
||||
const realtimeSocketRef = useRef<WebSocket | null>(null);
|
||||
const realtimeApprovalKeyRef = useRef<string | null>(null);
|
||||
const lastRealtimeTickOrderRef = useRef<number>(-1);
|
||||
const isPositive = (selectedStock?.change ?? 0) >= 0;
|
||||
const chartCandles =
|
||||
isRealtimeConnected && realtimeCandles.length > 0 ? realtimeCandles : (selectedStock?.candles ?? []);
|
||||
const apiPriceSourceLabel = selectedOverviewMeta
|
||||
? getPriceSourceLabel(selectedOverviewMeta.priceSource, selectedOverviewMeta.marketPhase)
|
||||
: null;
|
||||
const realtimeTrId = verifiedCredentials ? resolveRealtimeTrId(verifiedCredentials.tradingEnv) : null;
|
||||
const effectivePriceSourceLabel =
|
||||
isRealtimeConnected && lastRealtimeTickAt
|
||||
? `실시간 체결(WebSocket ${realtimeTrId ?? KIS_REALTIME_TR_ID_REAL})`
|
||||
: apiPriceSourceLabel;
|
||||
|
||||
useEffect(() => {
|
||||
setRealtimeCandles([]);
|
||||
setIsRealtimeConnected(false);
|
||||
setRealtimeError(null);
|
||||
setLastRealtimeTickAt(null);
|
||||
setRealtimeTickCount(0);
|
||||
lastRealtimeTickOrderRef.current = -1;
|
||||
}, [selectedStock?.symbol]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isRealtimeConnected || lastRealtimeTickAt) return;
|
||||
|
||||
const noTickTimer = window.setTimeout(() => {
|
||||
setRealtimeError("실시간 연결은 되었지만 체결 데이터가 없습니다. 장중(09:00~15:30)인지 확인해 주세요.");
|
||||
}, 8000);
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(noTickTimer);
|
||||
};
|
||||
}, [isRealtimeConnected, lastRealtimeTickAt]);
|
||||
|
||||
useEffect(() => {
|
||||
const symbol = selectedStock?.symbol;
|
||||
|
||||
if (!symbol || !isKisVerified || !verifiedCredentials) {
|
||||
setIsRealtimeConnected(false);
|
||||
setRealtimeError(null);
|
||||
setRealtimeTickCount(0);
|
||||
lastRealtimeTickOrderRef.current = -1;
|
||||
realtimeSocketRef.current?.close();
|
||||
realtimeSocketRef.current = null;
|
||||
realtimeApprovalKeyRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
let disposed = false;
|
||||
let socket: WebSocket | null = null;
|
||||
|
||||
const realtimeTrId = resolveRealtimeTrId(verifiedCredentials.tradingEnv);
|
||||
|
||||
const connectKisRealtimePrice = async () => {
|
||||
try {
|
||||
setRealtimeError(null);
|
||||
setIsRealtimeConnected(false);
|
||||
|
||||
const approval = await fetchKisWebSocketApproval(verifiedCredentials);
|
||||
if (disposed) return;
|
||||
|
||||
realtimeApprovalKeyRef.current = approval.approvalKey ?? null;
|
||||
socket = new WebSocket(`${approval.wsUrl}/tryitout`);
|
||||
realtimeSocketRef.current = socket;
|
||||
|
||||
socket.onopen = () => {
|
||||
if (disposed || !realtimeApprovalKeyRef.current) return;
|
||||
|
||||
const subscribeMessage = buildKisRealtimeMessage(realtimeApprovalKeyRef.current, symbol, realtimeTrId, "1");
|
||||
socket?.send(JSON.stringify(subscribeMessage));
|
||||
setIsRealtimeConnected(true);
|
||||
};
|
||||
|
||||
socket.onmessage = (event) => {
|
||||
if (disposed || typeof event.data !== "string") return;
|
||||
|
||||
const tick = parseKisRealtimeTick(event.data, symbol, realtimeTrId);
|
||||
if (!tick) return;
|
||||
|
||||
// 지연 도착으로 시간이 역행하는 틱은 무시해 차트 흔들림을 줄입니다.
|
||||
const nextTickOrder = toTickOrderValue(tick.tickTime);
|
||||
if (nextTickOrder > 0 && lastRealtimeTickOrderRef.current > nextTickOrder) {
|
||||
return;
|
||||
}
|
||||
if (nextTickOrder > 0) {
|
||||
lastRealtimeTickOrderRef.current = nextTickOrder;
|
||||
}
|
||||
|
||||
setRealtimeError(null);
|
||||
setLastRealtimeTickAt(Date.now());
|
||||
setRealtimeTickCount((prev) => prev + 1);
|
||||
setRealtimeCandles((prev) => appendRealtimeTick(prev, tick.point));
|
||||
|
||||
// 실시간 체결가를 카드 현재가/등락/거래량에도 반영합니다.
|
||||
setSelectedStock((prev) => {
|
||||
if (!prev || prev.symbol !== symbol) return prev;
|
||||
|
||||
const nextPrice = tick.price;
|
||||
const nextChange = nextPrice - prev.prevClose;
|
||||
const nextChangeRate = prev.prevClose > 0 ? (nextChange / prev.prevClose) * 100 : prev.changeRate;
|
||||
const nextHigh = prev.high > 0 ? Math.max(prev.high, nextPrice) : nextPrice;
|
||||
const nextLow = prev.low > 0 ? Math.min(prev.low, nextPrice) : nextPrice;
|
||||
|
||||
return {
|
||||
...prev,
|
||||
currentPrice: nextPrice,
|
||||
change: nextChange,
|
||||
changeRate: nextChangeRate,
|
||||
high: nextHigh,
|
||||
low: nextLow,
|
||||
volume: tick.accumulatedVolume > 0 ? tick.accumulatedVolume : prev.volume,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
socket.onerror = () => {
|
||||
if (disposed) return;
|
||||
setIsRealtimeConnected(false);
|
||||
setRealtimeError("실시간 연결 중 오류가 발생했습니다.");
|
||||
};
|
||||
|
||||
socket.onclose = () => {
|
||||
if (disposed) return;
|
||||
setIsRealtimeConnected(false);
|
||||
};
|
||||
} catch (error) {
|
||||
if (disposed) return;
|
||||
const message =
|
||||
error instanceof Error ? error.message : "실시간 웹소켓 초기화 중 오류가 발생했습니다.";
|
||||
setRealtimeError(message);
|
||||
setIsRealtimeConnected(false);
|
||||
}
|
||||
};
|
||||
|
||||
void connectKisRealtimePrice();
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
setIsRealtimeConnected(false);
|
||||
|
||||
const approvalKey = realtimeApprovalKeyRef.current;
|
||||
if (socket?.readyState === WebSocket.OPEN && approvalKey) {
|
||||
const unsubscribeMessage = buildKisRealtimeMessage(approvalKey, symbol, realtimeTrId, "2");
|
||||
socket.send(JSON.stringify(unsubscribeMessage));
|
||||
}
|
||||
|
||||
socket?.close();
|
||||
if (realtimeSocketRef.current === socket) {
|
||||
realtimeSocketRef.current = null;
|
||||
}
|
||||
realtimeApprovalKeyRef.current = null;
|
||||
};
|
||||
}, [
|
||||
isKisVerified,
|
||||
selectedStock?.symbol,
|
||||
verifiedCredentials,
|
||||
]);
|
||||
|
||||
const loadOverview = useCallback(
|
||||
async (symbol: string, credentials: KisRuntimeCredentials) => {
|
||||
try {
|
||||
setOverviewError(null);
|
||||
|
||||
const data = await fetchStockOverview(symbol, credentials);
|
||||
setSelectedStock(data.stock);
|
||||
setSelectedOverviewMeta({
|
||||
priceSource: data.priceSource,
|
||||
marketPhase: data.marketPhase,
|
||||
fetchedAt: data.fetchedAt,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "종목 조회 중 오류가 발생했습니다.";
|
||||
setOverviewError(message);
|
||||
setSelectedOverviewMeta(null);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const loadSearch = useCallback(
|
||||
async (nextKeyword: string, credentials: KisRuntimeCredentials, pickFirst = false) => {
|
||||
try {
|
||||
setSearchError(null);
|
||||
|
||||
const data = await fetchStockSearch(nextKeyword);
|
||||
setSearchResults(data.items);
|
||||
|
||||
if (pickFirst && data.items[0]) {
|
||||
await loadOverview(data.items[0].symbol, credentials);
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "종목 검색 중 오류가 발생했습니다.";
|
||||
setSearchError(message);
|
||||
}
|
||||
},
|
||||
[loadOverview],
|
||||
);
|
||||
|
||||
function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
|
||||
if (!isKisVerified || !verifiedCredentials) {
|
||||
setSearchError("상단에서 API 키 검증을 먼저 완료해 주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
startSearchTransition(() => {
|
||||
void loadSearch(keyword, verifiedCredentials, true);
|
||||
});
|
||||
}
|
||||
|
||||
function handlePickStock(item: DashboardStockSearchItem) {
|
||||
if (!isKisVerified || !verifiedCredentials) {
|
||||
setSearchError("상단에서 API 키 검증을 먼저 완료해 주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
setKeyword(item.name);
|
||||
|
||||
startOverviewTransition(() => {
|
||||
void loadOverview(item.symbol, verifiedCredentials);
|
||||
});
|
||||
}
|
||||
|
||||
function handleValidateKis() {
|
||||
startValidateTransition(() => {
|
||||
void (async () => {
|
||||
try {
|
||||
setKisStatusError(null);
|
||||
setKisStatusMessage(null);
|
||||
|
||||
const trimmedAppKey = kisAppKeyInput.trim();
|
||||
const trimmedAppSecret = kisAppSecretInput.trim();
|
||||
|
||||
if (!trimmedAppKey || !trimmedAppSecret) {
|
||||
throw new Error("앱 키와 앱 시크릿을 모두 입력해 주세요.");
|
||||
}
|
||||
|
||||
const credentials: KisRuntimeCredentials = {
|
||||
appKey: trimmedAppKey,
|
||||
appSecret: trimmedAppSecret,
|
||||
tradingEnv: kisTradingEnvInput,
|
||||
};
|
||||
|
||||
const result = await validateKisCredentials(credentials);
|
||||
|
||||
setVerifiedKisSession(credentials, result.tradingEnv);
|
||||
setKisStatusMessage(
|
||||
`${result.message} (${result.tradingEnv === "real" ? "실전" : "모의"} 모드)`,
|
||||
);
|
||||
|
||||
startSearchTransition(() => {
|
||||
void loadSearch(keyword || "삼성전자", credentials, true);
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "API 키 검증 중 오류가 발생했습니다.";
|
||||
|
||||
invalidateKisVerification();
|
||||
setSearchResults([]);
|
||||
setSelectedStock(null);
|
||||
setSelectedOverviewMeta(null);
|
||||
setKisStatusError(message);
|
||||
}
|
||||
})();
|
||||
});
|
||||
}
|
||||
|
||||
function handleRevokeKis() {
|
||||
if (!verifiedCredentials) {
|
||||
setKisStatusError("먼저 API 키 검증을 완료해 주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
startRevokeTransition(() => {
|
||||
void (async () => {
|
||||
try {
|
||||
// 접근 폐기 전, 화면 상태 메시지를 초기화합니다.
|
||||
setKisStatusError(null);
|
||||
setKisStatusMessage(null);
|
||||
|
||||
const result = await revokeKisCredentials(verifiedCredentials);
|
||||
|
||||
// 로그아웃처럼 검증/조회 상태를 초기화합니다.
|
||||
clearKisRuntimeSession(result.tradingEnv);
|
||||
setSearchResults([]);
|
||||
setSelectedStock(null);
|
||||
setSelectedOverviewMeta(null);
|
||||
setSearchError(null);
|
||||
setOverviewError(null);
|
||||
setKisStatusMessage(`${result.message} (${result.tradingEnv === "real" ? "실전" : "모의"} 모드)`);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "API 키 접근 폐기 중 오류가 발생했습니다.";
|
||||
setKisStatusError(message);
|
||||
}
|
||||
})();
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
{/* ========== KIS KEY VERIFY SECTION ========== */}
|
||||
<section>
|
||||
<Card className="border-brand-200 bg-gradient-to-r from-brand-50/60 to-background">
|
||||
<CardHeader>
|
||||
<CardTitle>KIS API 키 연결</CardTitle>
|
||||
<CardDescription>
|
||||
대시보드 사용 전, 개인 API 키를 입력하고 검증해 주세요. 검증에 성공해야 시세 조회가 동작합니다.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-3 md:grid-cols-3">
|
||||
<div className="md:col-span-1">
|
||||
<label className="mb-1 block text-xs text-muted-foreground">거래 모드</label>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant={kisTradingEnvInput === "real" ? "default" : "outline"}
|
||||
className={cn("flex-1", kisTradingEnvInput === "real" ? "bg-brand-600 hover:bg-brand-700" : "")}
|
||||
onClick={() => setKisTradingEnvInput("real")}
|
||||
>
|
||||
실전
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant={kisTradingEnvInput === "mock" ? "default" : "outline"}
|
||||
className={cn("flex-1", kisTradingEnvInput === "mock" ? "bg-brand-600 hover:bg-brand-700" : "")}
|
||||
onClick={() => setKisTradingEnvInput("mock")}
|
||||
>
|
||||
모의
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-1">
|
||||
<label className="mb-1 block text-xs text-muted-foreground">KIS App Key</label>
|
||||
<Input
|
||||
type="password"
|
||||
value={kisAppKeyInput}
|
||||
onChange={(event) => setKisAppKeyInput(event.target.value)}
|
||||
placeholder="앱 키 입력"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-1">
|
||||
<label className="mb-1 block text-xs text-muted-foreground">KIS App Secret</label>
|
||||
<Input
|
||||
type="password"
|
||||
value={kisAppSecretInput}
|
||||
onChange={(event) => setKisAppSecretInput(event.target.value)}
|
||||
placeholder="앱 시크릿 입력"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleValidateKis}
|
||||
disabled={isValidatingKis || !kisAppKeyInput.trim() || !kisAppSecretInput.trim()}
|
||||
className="bg-brand-600 hover:bg-brand-700"
|
||||
>
|
||||
{isValidatingKis ? "검증 중..." : "API 키 검증"}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleRevokeKis}
|
||||
disabled={isRevokingKis || !isKisVerified || !verifiedCredentials}
|
||||
className="border-brand-200 text-brand-700 hover:bg-brand-50 hover:text-brand-800"
|
||||
>
|
||||
{isRevokingKis ? "폐기 중..." : "접근 폐기"}
|
||||
</Button>
|
||||
|
||||
{isKisVerified ? (
|
||||
<span className="rounded-full bg-brand-100 px-3 py-1 text-xs font-semibold text-brand-700">
|
||||
검증 완료 ({tradingEnv === "real" ? "실전" : "모의"})
|
||||
</span>
|
||||
) : (
|
||||
<span className="rounded-full bg-muted px-3 py-1 text-xs text-muted-foreground">미검증</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{kisStatusError ? <p className="text-sm text-red-600">{kisStatusError}</p> : null}
|
||||
{kisStatusMessage ? <p className="text-sm text-brand-700">{kisStatusMessage}</p> : null}
|
||||
|
||||
<div className="rounded-lg border border-brand-200 bg-brand-50/70 px-3 py-2 text-xs text-brand-800">
|
||||
입력한 API 키는 새로고침 유지를 위해 현재 브라우저 저장소(zustand persist)에만 보관되며, 접근 폐기를 누르면 즉시 초기화됩니다.
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
{/* ========== DASHBOARD TITLE SECTION ========== */}
|
||||
<section>
|
||||
<h2 className="text-3xl font-bold tracking-tight">국내주식 대시보드</h2>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
종목명 검색, 현재가, 일자별 차트를 한 화면에서 확인합니다.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* ========== STOCK SEARCH SECTION ========== */}
|
||||
<section>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>종목 검색</CardTitle>
|
||||
<CardDescription>종목명 또는 종목코드(예: 삼성전자, 005930)로 검색할 수 있습니다.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-3 md:flex-row">
|
||||
<div className="relative flex-1">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={keyword}
|
||||
onChange={(event) => setKeyword(event.target.value)}
|
||||
placeholder="종목명 / 종목코드 검색"
|
||||
className="pl-9"
|
||||
disabled={!isKisVerified}
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" className="md:min-w-28" disabled={!isKisVerified || isSearching || !keyword.trim()}>
|
||||
{isSearching ? "검색 중..." : "검색"}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{!isKisVerified ? (
|
||||
<p className="text-xs text-muted-foreground">상단에서 API 키 검증을 완료해야 검색/시세 기능이 동작합니다.</p>
|
||||
) : searchError ? (
|
||||
<p className="text-sm text-red-600">{searchError}</p>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{searchResults.length > 0
|
||||
? `검색 결과 ${searchResults.length}개`
|
||||
: "검색어를 입력하고 엔터를 누르면 종목이 표시됩니다."}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="grid gap-2 md:grid-cols-2 xl:grid-cols-5">
|
||||
{searchResults.map((item) => {
|
||||
const active = item.symbol === selectedStock?.symbol;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={`${item.symbol}-${item.market}`}
|
||||
type="button"
|
||||
onClick={() => handlePickStock(item)}
|
||||
className={cn(
|
||||
"rounded-lg border px-3 py-2 text-left transition-colors",
|
||||
active
|
||||
? "border-brand-500 bg-brand-50 text-brand-700 dark:bg-brand-900/30 dark:text-brand-300"
|
||||
: "border-border bg-background hover:bg-muted/60",
|
||||
)}
|
||||
disabled={!isKisVerified}
|
||||
>
|
||||
<p className="text-sm font-semibold">{item.name}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{item.symbol} · {item.market}
|
||||
</p>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
{/* ========== STOCK OVERVIEW SECTION ========== */}
|
||||
<section className="grid gap-4 xl:grid-cols-3">
|
||||
<Card className="xl:col-span-2">
|
||||
<CardHeader className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-xl">{selectedStock?.name ?? "종목을 선택해 주세요"}</CardTitle>
|
||||
<CardDescription>
|
||||
{selectedStock ? `${selectedStock.symbol} · ${selectedStock.market}` : "선택된 종목이 없습니다."}
|
||||
</CardDescription>
|
||||
</div>
|
||||
|
||||
{selectedStock && (
|
||||
<div
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 rounded-full px-3 py-1 text-sm font-semibold",
|
||||
isPositive
|
||||
? "bg-brand-50 text-brand-700 dark:bg-brand-900/35 dark:text-brand-300"
|
||||
: "bg-red-50 text-red-700 dark:bg-red-900/30 dark:text-red-300",
|
||||
)}
|
||||
>
|
||||
{isPositive ? <TrendingUp className="h-4 w-4" /> : <TrendingDown className="h-4 w-4" />}
|
||||
{isPositive ? "+" : ""}
|
||||
{selectedStock.change.toLocaleString()} ({isPositive ? "+" : ""}
|
||||
{selectedStock.changeRate.toFixed(2)}%)
|
||||
</div>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{overviewError ? (
|
||||
<p className="text-sm text-red-600">{overviewError}</p>
|
||||
) : !isKisVerified ? (
|
||||
<p className="text-sm text-muted-foreground">상단에서 API 키 검증을 완료해 주세요.</p>
|
||||
) : isLoadingOverview && !selectedStock ? (
|
||||
<p className="text-sm text-muted-foreground">종목 데이터를 불러오는 중입니다...</p>
|
||||
) : selectedStock ? (
|
||||
<>
|
||||
<p className="mb-4 text-3xl font-extrabold tracking-tight">{formatPrice(selectedStock.currentPrice)}</p>
|
||||
{effectivePriceSourceLabel && selectedOverviewMeta ? (
|
||||
<div className="mb-4 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||
<span className="rounded-full border border-brand-200 bg-brand-50 px-2 py-1 text-brand-700">
|
||||
현재가 소스: {effectivePriceSourceLabel}
|
||||
</span>
|
||||
<span className="rounded-full border border-border px-2 py-1">
|
||||
구간: {getMarketPhaseLabel(selectedOverviewMeta.marketPhase)}
|
||||
</span>
|
||||
<span className="rounded-full border border-border px-2 py-1">
|
||||
조회시각: {new Date(selectedOverviewMeta.fetchedAt).toLocaleTimeString("ko-KR")}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
<StockLineChart candles={chartCandles} />
|
||||
{realtimeError ? <p className="mt-3 text-xs text-red-600">{realtimeError}</p> : null}
|
||||
</>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">종목을 선택하면 시세와 차트가 표시됩니다.</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">핵심 지표</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-2">
|
||||
<PriceStat label="시가" value={formatPrice(selectedStock?.open ?? 0)} />
|
||||
<PriceStat label="고가" value={formatPrice(selectedStock?.high ?? 0)} />
|
||||
<PriceStat label="저가" value={formatPrice(selectedStock?.low ?? 0)} />
|
||||
<PriceStat label="전일 종가" value={formatPrice(selectedStock?.prevClose ?? 0)} />
|
||||
<PriceStat label="누적 거래량" value={formatVolume(selectedStock?.volume ?? 0)} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">연동 상태</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 text-sm text-muted-foreground">
|
||||
<div className="flex items-center gap-2">
|
||||
<ShieldCheck className="h-4 w-4 text-brand-500" />
|
||||
<p>국내주식 {tradingEnv === "real" ? "실전" : "모의"}투자 API 연결 완료</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Activity className={cn("h-4 w-4", isRealtimeConnected ? "text-brand-500" : "text-muted-foreground")} />
|
||||
<p>
|
||||
실시간 체결가 연결 상태:{" "}
|
||||
{isRealtimeConnected ? "연결됨 (WebSocket)" : "대기 중 (일봉 차트 표시)"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Activity className="h-4 w-4 text-muted-foreground" />
|
||||
<p>마지막 실시간 수신: {lastRealtimeTickAt ? new Date(lastRealtimeTickAt).toLocaleTimeString("ko-KR") : "수신 전"}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Activity className="h-4 w-4 text-muted-foreground" />
|
||||
<p>실시간 틱 수신 수: {realtimeTickCount.toLocaleString("ko-KR")}건</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Activity className="h-4 w-4 text-brand-500" />
|
||||
<p>다음 단계: 주문/리스크 제어 API 연결</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user