diff --git a/AGENTS.md b/AGENTS.md index f67dbd6..a5f0639 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,11 +1,13 @@ # AGENTS.md (auto-trade) ## 기본 원칙 + - 모든 응답과 설명은 한국어로 작성. - 쉬운 말로 설명하고, 어려운 용어는 괄호로 짧게 뜻을 덧붙임. - 요청이 모호하면 먼저 질문 1~3개로 범위를 확인. ## 프로젝트 요약 + - Next.js 16 App Router, React 19, TypeScript - 상태 관리: zustand - 데이터: Supabase @@ -13,26 +15,22 @@ - UI: Tailwind CSS v4, Radix UI (`components.json` 사용) ## 명령어 + - 개발 서버(포트 3001): `npm run dev` - 린트: `npm run lint` - 빌드: `npm run build` - 실행: `npm run start` ## 코드 및 문서 규칙 + - JSX 섹션 주석 형식: `{/* ========== SECTION NAME ========== */}` - 함수 및 컴포넌트 JSDoc에 `@see` 필수 - `@see`에는 호출 파일, 함수/이벤트 이름, 목적을 함께 작성 - 상태 정의, 이벤트 핸들러, 복잡한 JSX 로직에는 인라인 주석을 충분히 작성 - UI 흐름 설명 필수: `어느 UI -> A 함수 호출 -> B 함수 호출 -> 리턴값 반영` 형태로 작성 -## 인코딩/편집 규칙 -- 텍스트 파일 수정은 원칙적으로 `apply_patch`만 사용 -- `shell_command`로 `Set-Content`, `Out-File`, 리다이렉션(`>`)으로 코드 파일 저장 금지 -- 파일 읽기는 반드시 인코딩 명시: `Get-Content -Encoding UTF8` -- 부득이하게 셸로 저장해야 하면 BOM 없는 UTF-8만 사용: - `[System.IO.File]::WriteAllText($path, $text, [System.Text.UTF8Encoding]::new($false))` - ## 브랜드 색상 규칙 + - 메인 컬러는 헤더 로고/프로필 기준의 보라 계열 `brand` 팔레트 사용 - 새 UI 작성 시 `indigo/purple/pink` 하드코딩 대신 `brand-*` 토큰 사용 - 예시: `bg-brand-500`, `text-brand-600`, `from-brand-500 to-brand-700` @@ -50,3 +48,9 @@ - `next-devtools`: Next.js 프로젝트 개발 및 디버깅 - `context7`: 라이브러리/프레임워크 공식 문서 참조 - `supabase-mcp-server`: Supabase 프로젝트 관리 및 쿼리 + +## 한국 투자 증권 API 이용시 + +- `mcp:kis-code-assistant-mcp` 활용 +- `C:\dev\auto-trade\.tmp\open-trading-api` 활용 +- API 이용시 공식 문서에 최신 업데이트가 안되어 있을수 있으므로 필요시 최신 API 명세 엑셀파일 요청을 한다. 그럼 사용자가 업로드 해줄것이다. diff --git a/app/globals.css b/app/globals.css index b59ea82..f50737b 100644 --- a/app/globals.css +++ b/app/globals.css @@ -1,4 +1,5 @@ @import "tailwindcss"; +@plugin "tailwindcss-animate"; @import "tw-animate-css"; @custom-variant dark (&:is(.dark *)); @@ -55,11 +56,12 @@ --radius-2xl: calc(var(--radius) + 8px); --radius-3xl: calc(var(--radius) + 12px); --radius-4xl: calc(var(--radius) + 16px); - + --animate-gradient-x: gradient-x 15s ease infinite; - + @keyframes gradient-x { - 0%, 100% { + 0%, + 100% { background-size: 200% 200%; background-position: left center; } diff --git a/features/dashboard/components/ActivitySection.tsx b/features/dashboard/components/ActivitySection.tsx index 4aec0e8..9b286ad 100644 --- a/features/dashboard/components/ActivitySection.tsx +++ b/features/dashboard/components/ActivitySection.tsx @@ -1,5 +1,6 @@ -import { AlertCircle, ClipboardList, FileText } from "lucide-react"; +import { AlertCircle, ClipboardList, FileText, RefreshCcw } from "lucide-react"; import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; import { Card, CardContent, @@ -24,6 +25,7 @@ interface ActivitySectionProps { activity: DashboardActivityResponse | null; isLoading: boolean; error: string | null; + onRetry?: () => void; } /** @@ -32,7 +34,12 @@ interface ActivitySectionProps { * @see features/dashboard/components/DashboardContainer.tsx 하단 영역에서 호출합니다. * @see app/api/kis/domestic/activity/route.ts 주문내역/매매일지 데이터 소스 */ -export function ActivitySection({ activity, isLoading, error }: ActivitySectionProps) { +export function ActivitySection({ + activity, + isLoading, + error, + onRetry, +}: ActivitySectionProps) { const orders = activity?.orders ?? []; const journalRows = activity?.tradeJournal ?? []; const summary = activity?.journalSummary; @@ -59,10 +66,27 @@ export function ActivitySection({ activity, isLoading, error }: ActivitySectionP )} {error && ( -

- - {error} -

+
+

+ + {error} +

+

+ 주문/매매일지 API는 장중 혼잡 시간에 간헐적 실패가 발생할 수 있습니다. +

+ {onRetry ? ( + + ) : null} +
)} {warnings.length > 0 && ( diff --git a/features/dashboard/components/DashboardContainer.tsx b/features/dashboard/components/DashboardContainer.tsx index 1d617da..09c7976 100644 --- a/features/dashboard/components/DashboardContainer.tsx +++ b/features/dashboard/components/DashboardContainer.tsx @@ -11,15 +11,29 @@ import { MarketSummary } from "@/features/dashboard/components/MarketSummary"; import { StatusHeader } from "@/features/dashboard/components/StatusHeader"; import { StockDetailPreview } from "@/features/dashboard/components/StockDetailPreview"; import { useDashboardData } from "@/features/dashboard/hooks/use-dashboard-data"; +import { useMarketRealtime } from "@/features/dashboard/hooks/use-market-realtime"; +import { useKisWebSocketStore } from "@/features/kis-realtime/stores/kisWebSocketStore"; import { useKisRuntimeStore } from "@/features/settings/store/use-kis-runtime-store"; +import { useHoldingsRealtime } from "@/features/dashboard/hooks/use-holdings-realtime"; +import type { + DashboardBalanceSummary, + DashboardHoldingItem, + DashboardMarketIndexItem, +} from "@/features/dashboard/types/dashboard.types"; +import type { KisRealtimeStockTick } from "@/features/dashboard/utils/kis-stock-realtime.utils"; /** - * @description 대시보드 메인 컨테이너입니다. - * @remarks UI 흐름: 대시보드 진입 -> useDashboardData API 호출 -> StatusHeader/MarketSummary/HoldingsList/StockDetailPreview 순으로 렌더링 - * @see app/(main)/dashboard/page.tsx 로그인 완료 후 이 컴포넌트를 렌더링합니다. - * @see features/dashboard/hooks/use-dashboard-data.ts 대시보드 데이터 조회/갱신 상태를 관리합니다. + * @file DashboardContainer.tsx + * @description 대시보드 메인 레이아웃 및 데이터 통합 관리 컴포넌트 + * @remarks + * - [레이어] Components / Container + * - [사용자 행동] 대시보드 진입 -> 전체 자산/시장 지수/보유 종목 확인 -> 특정 종목 선택 상세 확인 + * - [데이터 흐름] API(REST/WS) -> Hooks(useDashboardData, useMarketRealtime, useHoldingsRealtime) -> UI 병합 -> 하위 컴포넌트 전파 + * - [연관 파일] use-dashboard-data.ts, use-holdings-realtime.ts, StatusHeader.tsx, HoldingsList.tsx + * @author jihoon87.lee */ export function DashboardContainer() { + // [Store] KIS 런타임 설정 상태 (인증 여부, 접속 계좌, 웹소켓 정보 등) const { verifiedCredentials, isKisVerified, @@ -40,13 +54,15 @@ export function DashboardContainer() { })), ); + // KIS 접근 가능 여부 판단 const canAccess = isKisVerified && Boolean(verifiedCredentials); + // [Hooks] 기본적인 대시보드 데이터(잔고, 지수, 활동내역) 조회 및 선택 상태 관리 + // @see use-dashboard-data.ts - 초기 데이터 로딩 및 폴링 처리 const { activity, balance, - indices, - selectedHolding, + indices: initialIndices, selectedSymbol, setSelectedSymbol, isLoading, @@ -58,12 +74,94 @@ export function DashboardContainer() { refresh, } = useDashboardData(canAccess ? verifiedCredentials : null); - const isKisRestConnected = useMemo(() => { - if (indices.length > 0) return true; - if (balance && !balanceError) return true; - return false; - }, [balance, balanceError, indices.length]); + // [Hooks] 시장 지수(코스피/코스닥) 실시간 웹소켓 데이터 구독 + // @see use-market-realtime.ts - 웹소켓 연결 및 지수 파싱 + const { realtimeIndices, isConnected: isWsConnected } = useMarketRealtime( + verifiedCredentials, + isKisVerified, + ); + // [Hooks] 보유 종목 실시간 시세 웹소켓 데이터 구독 + // @see use-holdings-realtime.ts - 보유 종목 리스트 기반 시세 업데이트 + const { realtimeData: realtimeHoldings } = useHoldingsRealtime( + balance?.holdings ?? [], + ); + const reconnectWebSocket = useKisWebSocketStore((state) => state.reconnect); + + // [Step 1] REST API로 가져온 기본 지수 정보와 실시간 웹소켓 시세 병합 + const indices = useMemo(() => { + if (initialIndices.length === 0) { + return buildRealtimeOnlyIndices(realtimeIndices); + } + + return initialIndices.map((item) => { + const realtime = realtimeIndices[item.code]; + if (!realtime) return item; + + return { + ...item, + price: realtime.price, + change: realtime.change, + changeRate: realtime.changeRate, + }; + }); + }, [initialIndices, realtimeIndices]); + + // [Step 2] 초기 잔고 데이터와 실시간 보유 종목 시세를 병합하여 손익 재계산 + const mergedHoldings = useMemo( + () => mergeHoldingsWithRealtime(balance?.holdings ?? [], realtimeHoldings), + [balance?.holdings, realtimeHoldings], + ); + + const isKisRestConnected = Boolean( + (balance && !balanceError) || + (initialIndices.length > 0 && !indicesError) || + (activity && !activityError), + ); + const hasRealtimeStreaming = + Object.keys(realtimeIndices).length > 0 || + Object.keys(realtimeHoldings).length > 0; + const isRealtimePending = Boolean( + wsApprovalKey && wsUrl && isWsConnected && !hasRealtimeStreaming, + ); + const effectiveIndicesError = indices.length === 0 ? indicesError : null; + const indicesWarning = + indices.length > 0 && indicesError + ? "지수 API 재요청 중입니다. 화면 값은 실시간/최근 성공 데이터입니다." + : null; + + /** + * 대시보드 수동 새로고침 시 REST 조회 + 웹소켓 재연결을 함께 수행합니다. + * @remarks UI 흐름: StatusHeader/각 카드 다시 불러오기 버튼 -> handleRefreshAll -> REST 재조회 + WS 완전 종료 후 재연결 + * @see features/dashboard/components/StatusHeader.tsx 상단 다시 불러오기 버튼 + * @see features/kis-realtime/stores/kisWebSocketStore.ts reconnect + */ + const handleRefreshAll = async () => { + await Promise.allSettled([ + refresh(), + reconnectWebSocket({ refreshApproval: false }), + ]); + }; + + /** + * 실시간 보유종목 데이터를 기반으로 전체 자산 요약을 계산합니다. + * @returns 실시간 요약 데이터 (총자산, 손익, 평가금액 등) + */ + const mergedSummary = useMemo( + () => buildRealtimeSummary(balance?.summary ?? null, mergedHoldings), + [balance?.summary, mergedHoldings], + ); + + // [Step 3] 실시간 병합 데이터에서 현재 선택된 종목 정보를 추출 + // @see StockDetailPreview.tsx - 선택된 종목의 상세 정보 표시 + const realtimeSelectedHolding = useMemo(() => { + if (!selectedSymbol || mergedHoldings.length === 0) return null; + return ( + mergedHoldings.find((item) => item.symbol === selectedSymbol) ?? null + ); + }, [mergedHoldings, selectedSymbol]); + + // 하이드레이션 이전에는 로딩 스피너 표시 if (!_hasHydrated) { return (
@@ -72,60 +170,193 @@ export function DashboardContainer() { ); } + // KIS 인증이 되지 않은 경우 접근 제한 게이트 표시 if (!canAccess) { return ; } + // 데이터 로딩 중이며 아직 데이터가 없는 경우 스켈레톤 표시 if (isLoading && !balance && indices.length === 0) { return ; } return (
- {/* ========== STATUS HEADER ========== */} + {/* ========== 상단 상태 영역: 계좌 연결 정보 및 새로고침 ========== */} { - void refresh(); + void handleRefreshAll(); }} /> - {/* ========== MAIN CONTENT GRID ========== */} + {/* ========== 메인 그리드 구성 ========== */}
+ {/* 왼쪽 섹션: 보유 종목 목록 리스트 */} { + void handleRefreshAll(); + }} onSelect={setSelectedSymbol} /> + {/* 오른쪽 섹션: 시장 지수 요약 및 선택 종목 상세 정보 */}
+ {/* 시장 지수 현황 (코스피/코스닥) */} { + void handleRefreshAll(); + }} /> + {/* 선택된 종목의 실시간 상세 요약 정보 */}
- {/* ========== ACTIVITY SECTION ========== */} + {/* ========== 하단 섹션: 최근 매매/충전 활동 내역 ========== */} { + void handleRefreshAll(); + }} />
); } + +/** + * @description REST 지수 데이터가 없을 때 실시간 수신값만으로 코스피/코스닥 표시 데이터를 구성합니다. + * @param realtimeIndices 실시간 지수 맵 + * @returns 화면 렌더링용 지수 배열 + * @remarks UI 흐름: DashboardContainer -> buildRealtimeOnlyIndices -> MarketSummary 렌더링 + * @see features/dashboard/hooks/use-market-realtime.ts 실시간 지수 수신 훅 + */ +function buildRealtimeOnlyIndices( + realtimeIndices: Record, +) { + const baseItems: DashboardMarketIndexItem[] = [ + { market: "KOSPI", code: "0001", name: "코스피", price: 0, change: 0, changeRate: 0 }, + { market: "KOSDAQ", code: "1001", name: "코스닥", price: 0, change: 0, changeRate: 0 }, + ]; + + return baseItems + .map((item) => { + const realtime = realtimeIndices[item.code]; + if (!realtime) return null; + return { + ...item, + price: realtime.price, + change: realtime.change, + changeRate: realtime.changeRate, + } satisfies DashboardMarketIndexItem; + }) + .filter((item): item is DashboardMarketIndexItem => Boolean(item)); +} + +/** + * @description 보유종목 리스트에 실시간 체결가를 병합해 현재가/평가금액/손익을 재계산합니다. + * @param holdings REST 기준 보유종목 + * @param realtimeHoldings 종목별 실시간 체결 데이터 + * @returns 병합된 보유종목 리스트 + * @remarks UI 흐름: DashboardContainer -> mergeHoldingsWithRealtime -> HoldingsList/StockDetailPreview 반영 + * @see features/dashboard/hooks/use-holdings-realtime.ts 보유종목 실시간 체결 구독 + */ +function mergeHoldingsWithRealtime( + holdings: DashboardHoldingItem[], + realtimeHoldings: Record, +) { + if (holdings.length === 0 || Object.keys(realtimeHoldings).length === 0) { + return holdings; + } + + return holdings.map((item) => { + const tick = realtimeHoldings[item.symbol]; + if (!tick) return item; + + const currentPrice = tick.currentPrice; + const purchaseAmount = item.averagePrice * item.quantity; + const evaluationAmount = currentPrice * item.quantity; + const profitLoss = evaluationAmount - purchaseAmount; + const profitRate = purchaseAmount > 0 ? (profitLoss / purchaseAmount) * 100 : 0; + + return { + ...item, + currentPrice, + evaluationAmount, + profitLoss, + profitRate, + }; + }); +} + +/** + * @description 실시간 보유종목 기준으로 대시보드 요약(총자산/손익)을 일관되게 재계산합니다. + * @param summary REST API 요약 값 + * @param holdings 실시간 병합된 보유종목 + * @returns 재계산된 요약 값 + * @remarks UI 흐름: DashboardContainer -> buildRealtimeSummary -> StatusHeader 카드 반영 + * @see features/dashboard/components/StatusHeader.tsx 상단 요약 렌더링 + */ +function buildRealtimeSummary( + summary: DashboardBalanceSummary | null, + holdings: DashboardHoldingItem[], +) { + if (!summary) return null; + if (holdings.length === 0) return summary; + + const evaluationAmount = holdings.reduce( + (total, item) => total + item.evaluationAmount, + 0, + ); + const purchaseAmount = holdings.reduce( + (total, item) => total + item.averagePrice * item.quantity, + 0, + ); + const totalProfitLoss = evaluationAmount - purchaseAmount; + const totalProfitRate = + purchaseAmount > 0 ? (totalProfitLoss / purchaseAmount) * 100 : 0; + + const evaluationDelta = evaluationAmount - summary.evaluationAmount; + const baseTotalAmount = + summary.apiReportedNetAssetAmount > 0 + ? summary.apiReportedNetAssetAmount + : summary.totalAmount; + + // 실시간은 "기준 순자산 + 평가금 증감분"으로만 반영합니다. + const totalAmount = Math.max(baseTotalAmount + evaluationDelta, 0); + const netAssetAmount = totalAmount; + const cashBalance = Math.max(totalAmount - evaluationAmount, 0); + + return { + ...summary, + totalAmount, + netAssetAmount, + cashBalance, + evaluationAmount, + purchaseAmount, + totalProfitLoss, + totalProfitRate, + } satisfies DashboardBalanceSummary; +} diff --git a/features/dashboard/components/HoldingsList.tsx b/features/dashboard/components/HoldingsList.tsx index 675e0cb..4adfee2 100644 --- a/features/dashboard/components/HoldingsList.tsx +++ b/features/dashboard/components/HoldingsList.tsx @@ -1,5 +1,17 @@ +/** + * @file HoldingsList.tsx + * @description 대시보드 좌측 영역의 보유 종목 리스트 컴포넌트 + * @remarks + * - [레이어] Components / UI + * - [사용자 행동] 종목 리스트 스크롤 -> 특정 종목 클릭(선택) -> 우측 상세 프레뷰 갱신 + * - [데이터 흐름] DashboardContainer(mergedHoldings) -> HoldingsList -> HoldingItemRow -> onSelect(Callback) + * - [연관 파일] DashboardContainer.tsx, dashboard.types.ts, use-price-flash.ts + * @author jihoon87.lee + */ import { AlertCircle, Wallet2 } from "lucide-react"; +import { RefreshCcw } from "lucide-react"; import { ScrollArea } from "@/components/ui/scroll-area"; +import { Button } from "@/components/ui/button"; import { Card, CardContent, @@ -14,30 +26,43 @@ import { getChangeToneClass, } from "@/features/dashboard/utils/dashboard-format"; import { cn } from "@/lib/utils"; +import { usePriceFlash } from "@/features/dashboard/hooks/use-price-flash"; interface HoldingsListProps { + /** 보유 종목 데이터 리스트 (실시간 시세 병합됨) */ holdings: DashboardHoldingItem[]; + /** 현재 선택된 종목의 심볼 (없으면 null) */ selectedSymbol: string | null; + /** 데이터 로딩 상태 */ isLoading: boolean; + /** 에러 메시지 (없으면 null) */ error: string | null; + /** 섹션 재조회 핸들러 */ + onRetry?: () => void; + /** 종목 선택 시 호출되는 핸들러 */ onSelect: (symbol: string) => void; } /** - * @description 보유 종목 리스트 카드입니다. - * @see features/dashboard/components/DashboardContainer.tsx 좌측 메인 영역에서 호출합니다. + * [컴포넌트] 보유 종목 리스트 + * 사용자의 잔고 정보를 바탕으로 실시간 시세가 반영된 종목 카드 목록을 렌더링합니다. + * + * @param props HoldingsListProps + * @see DashboardContainer.tsx - 좌측 메인 영역에서 실시간 병합 데이터를 전달받아 호출 + * @see DashboardContainer.tsx - setSelectedSymbol 핸들러를 onSelect로 전달 */ export function HoldingsList({ holdings, selectedSymbol, isLoading, error, + onRetry, onSelect, }: HoldingsListProps) { return ( - + + {/* ========== 카드 헤더: 타이틀 및 설명 ========== */} - {/* ========== TITLE ========== */} 보유 종목 @@ -47,76 +72,57 @@ export function HoldingsList({ + {/* ========== 카드 본문: 상태별 메시지 및 리스트 ========== */} + {/* 로딩 중 상태 (데이터가 아직 없는 경우) */} {isLoading && holdings.length === 0 && ( -

보유 종목을 불러오는 중입니다.

- )} - - {error && ( -

- - {error} +

+ 보유 종목을 불러오는 중입니다.

)} + {/* 에러 발생 상태 */} + {error && ( +
+

+ + {error} +

+

+ 한국투자증권 API가 일시적으로 불안정할 수 있습니다. 잠시 후 다시 시도해 주세요. +

+ {onRetry ? ( + + ) : null} +
+ )} + + {/* 데이터 없음 상태 */} {!isLoading && holdings.length === 0 && !error && (

보유 종목이 없습니다.

)} + {/* 종목 리스트 렌더링 영역 */} {holdings.length > 0 && (
- {holdings.map((holding) => { - const isSelected = selectedSymbol === holding.symbol; - const toneClass = getChangeToneClass(holding.profitLoss); - - return ( - - ); - })} + {holdings.map((holding) => ( + + ))}
)} @@ -124,3 +130,99 @@ export function HoldingsList({
); } + +interface HoldingItemRowProps { + /** 개별 종목 정보 */ + holding: DashboardHoldingItem; + /** 선택 여부 */ + isSelected: boolean; + /** 클릭 핸들러 */ + onSelect: (symbol: string) => void; +} + +/** + * [컴포넌트] 보유 종목 개별 행 (아이템) + * 종목의 기본 정보와 실시간 시세, 현재 손익 상태를 표시합니다. + * + * @param props HoldingItemRowProps + * @see HoldingsList.tsx - holdings.map 내에서 호출 + * @see use-price-flash.ts - 현재가 변경 감지 및 애니메이션 효과 트리거 + */ +function HoldingItemRow({ + holding, + isSelected, + onSelect, +}: HoldingItemRowProps) { + // [State/Hook] 현재가 기반 가격 반짝임 효과 상태 관리 + // @see use-price-flash.ts - 현재가 변경 감지 시 symbol을 기준으로 이펙트 발생 여부 결정 + const flash = usePriceFlash(holding.currentPrice, holding.symbol); + + // [UI] 손익 상태에 따른 텍스트 색상 클래스 결정 (상승: red, 하락: blue) + const toneClass = getChangeToneClass(holding.profitLoss); + + return ( + + ); +} diff --git a/features/dashboard/components/MarketSummary.tsx b/features/dashboard/components/MarketSummary.tsx index 8b0c996..3847e22 100644 --- a/features/dashboard/components/MarketSummary.tsx +++ b/features/dashboard/components/MarketSummary.tsx @@ -1,4 +1,5 @@ -import { AlertCircle, BarChart3 } from "lucide-react"; +import { BarChart3, TrendingDown, TrendingUp } from "lucide-react"; +import { RefreshCcw } from "lucide-react"; import { Card, CardContent, @@ -7,79 +8,185 @@ import { CardTitle, } from "@/components/ui/card"; import type { DashboardMarketIndexItem } from "@/features/dashboard/types/dashboard.types"; +import { Button } from "@/components/ui/button"; import { formatCurrency, formatSignedCurrency, formatSignedPercent, - getChangeToneClass, } from "@/features/dashboard/utils/dashboard-format"; import { cn } from "@/lib/utils"; +import { usePriceFlash } from "@/features/dashboard/hooks/use-price-flash"; interface MarketSummaryProps { items: DashboardMarketIndexItem[]; isLoading: boolean; error: string | null; + warning?: string | null; + isRealtimePending?: boolean; + onRetry?: () => void; } /** * @description 코스피/코스닥 지수 요약 카드입니다. * @see features/dashboard/components/DashboardContainer.tsx 우측 상단 영역에서 호출합니다. */ -export function MarketSummary({ items, isLoading, error }: MarketSummaryProps) { +export function MarketSummary({ + items, + isLoading, + error, + warning = null, + isRealtimePending = false, + onRetry, +}: MarketSummaryProps) { return ( - - - {/* ========== TITLE ========== */} - - - 시장 지수 - - - 코스피/코스닥 지수 움직임을 보여줍니다. - + + +
+ + + 시장 지수 + +
+ 실시간 코스피/코스닥 지수 현황입니다.
- + + {/* ========== LOADING STATE ========== */} {isLoading && items.length === 0 && ( -

지수 데이터를 불러오는 중입니다.

+
+ 지수 데이터를 불러오는 중입니다... +
)} + {/* ========== REALTIME PENDING STATE ========== */} + {isRealtimePending && items.length === 0 && !isLoading && !error && ( +
+ 실시간 시세 연결은 완료되었고 첫 지수 데이터를 기다리는 중입니다. +
+ )} + + {/* ========== ERROR/WARNING STATE ========== */} {error && ( -

- - {error} -

+
+

지수 정보를 가져오는데 실패했습니다.

+

+ {toCompactErrorMessage(error)} +

+

+ 토큰이 정상이어도 한국투자증권 API 점검/지연 시 일시적으로 실패할 수 있습니다. +

+ {onRetry ? ( + + ) : null} +
+ )} + {!error && warning && ( +
+ {warning} +
)} - {items.map((item) => { - const toneClass = getChangeToneClass(item.change); - - return ( -
-
-
-

{item.market}

-

{item.name}

-
-

- {formatCurrency(item.price)} -

-
-
- {formatSignedCurrency(item.change)} - {formatSignedPercent(item.changeRate)} -
-
- ); - })} + {/* ========== INDEX CARDS ========== */} + {items.map((item) => ( + + ))} {!isLoading && items.length === 0 && !error && ( -

표시할 지수 데이터가 없습니다.

+
+ 표시할 데이터가 없습니다. +
)}
); } + +/** + * @description 길고 복잡한 서버 오류를 대시보드 카드에 맞는 짧은 문구로 축약합니다. + * @param error 원본 오류 문자열 + * @returns 화면 노출용 오류 메시지 + * @see features/dashboard/components/MarketSummary.tsx 지수 오류 배너 상세 문구 + */ +function toCompactErrorMessage(error: string) { + const normalized = error.replaceAll(/\s+/g, " ").trim(); + if (!normalized) return "잠시 후 다시 시도해 주세요."; + if (normalized.length <= 120) return normalized; + return `${normalized.slice(0, 120)}...`; +} + +function IndexItem({ item }: { item: DashboardMarketIndexItem }) { + const isUp = item.change > 0; + const isDown = item.change < 0; + const toneClass = isUp + ? "text-red-600 dark:text-red-400" + : isDown + ? "text-blue-600 dark:text-blue-400" + : "text-muted-foreground"; + + const bgClass = isUp + ? "bg-red-50/50 dark:bg-red-950/10 border-red-100 dark:border-red-900/30" + : isDown + ? "bg-blue-50/50 dark:bg-blue-950/10 border-blue-100 dark:border-blue-900/30" + : "bg-muted/50 border-border/50"; + + const flash = usePriceFlash(item.price, item.code); + + return ( +
+
+ + {item.market} + + {isUp ? ( + + ) : isDown ? ( + + ) : null} +
+ +
+ {formatCurrency(item.price)} + + {/* Flash Indicator */} + {flash && ( +
+ {flash.type === "up" ? "+" : ""} + {flash.val.toFixed(2)} +
+ )} +
+ +
+ {formatSignedCurrency(item.change)} + + {formatSignedPercent(item.changeRate)} + +
+
+ ); +} diff --git a/features/dashboard/components/StatusHeader.tsx b/features/dashboard/components/StatusHeader.tsx index ee4e844..a585441 100644 --- a/features/dashboard/components/StatusHeader.tsx +++ b/features/dashboard/components/StatusHeader.tsx @@ -5,7 +5,8 @@ import { Card, CardContent } from "@/components/ui/card"; import type { DashboardBalanceSummary } from "@/features/dashboard/types/dashboard.types"; import { formatCurrency, - formatPercent, + formatSignedCurrency, + formatSignedPercent, getChangeToneClass, } from "@/features/dashboard/utils/dashboard-format"; import { cn } from "@/lib/utils"; @@ -14,6 +15,7 @@ interface StatusHeaderProps { summary: DashboardBalanceSummary | null; isKisRestConnected: boolean; isWebSocketReady: boolean; + isRealtimePending: boolean; isProfileVerified: boolean; verifiedAccountNo: string | null; isRefreshing: boolean; @@ -29,6 +31,7 @@ export function StatusHeader({ summary, isKisRestConnected, isWebSocketReady, + isRealtimePending, isProfileVerified, verifiedAccountNo, isRefreshing, @@ -41,6 +44,20 @@ export function StatusHeader({ hour12: false, }) : "--:--:--"; + const hasApiTotalAmount = + Boolean(summary) && (summary?.apiReportedTotalAmount ?? 0) > 0; + const hasApiNetAssetAmount = + Boolean(summary) && (summary?.apiReportedNetAssetAmount ?? 0) > 0; + const isApiTotalAmountDifferent = + Boolean(summary) && + Math.abs( + (summary?.apiReportedTotalAmount ?? 0) - (summary?.totalAmount ?? 0), + ) >= 1; + const realtimeStatusLabel = isWebSocketReady + ? isRealtimePending + ? "수신 대기중" + : "연결됨" + : "미연결"; return ( @@ -50,32 +67,61 @@ export function StatusHeader({ {/* ========== TOTAL ASSET ========== */}
-

총 자산

+

내 자산 (순자산 실시간)

{summary ? `${formatCurrency(summary.totalAmount)}원` : "-"}

- 예수금 {summary ? `${formatCurrency(summary.cashBalance)}원` : "-"} + 현금(예수금) {summary ? `${formatCurrency(summary.cashBalance)}원` : "-"}

- 실제 자산 {summary ? `${formatCurrency(summary.netAssetAmount)}원` : "-"} + 주식 평가금{" "} + {summary ? `${formatCurrency(summary.evaluationAmount)}원` : "-"}

+

+ 총예수금(KIS){" "} + {summary ? `${formatCurrency(summary.totalDepositAmount)}원` : "-"} +

+

+ 총예수금은 결제 대기 금액이 포함될 수 있어 체감 현금과 다를 수 있습니다. +

+

+ 순자산(대출 반영){" "} + {summary ? `${formatCurrency(summary.netAssetAmount)}원` : "-"} +

+ {hasApiTotalAmount && isApiTotalAmountDifferent ? ( +

+ KIS 집계 총자산 {formatCurrency(summary?.apiReportedTotalAmount ?? 0)}원 +

+ ) : null} + {hasApiNetAssetAmount ? ( +

+ KIS 집계 순자산 {formatCurrency(summary?.apiReportedNetAssetAmount ?? 0)}원 +

+ ) : null}
{/* ========== PROFIT/LOSS ========== */}

현재 손익

-

- {summary ? `${formatCurrency(summary.totalProfitLoss)}원` : "-"} +

+ {summary ? `${formatSignedCurrency(summary.totalProfitLoss)}원` : "-"}

- {summary ? formatPercent(summary.totalProfitRate) : "-"} + {summary ? formatSignedPercent(summary.totalProfitRate) : "-"}

- 현재 평가금액 {summary ? `${formatCurrency(summary.evaluationAmount)}원` : "-"} + 현재 평가금액{" "} + {summary ? `${formatCurrency(summary.evaluationAmount)}원` : "-"}

- 총 매수금액 {summary ? `${formatCurrency(summary.purchaseAmount)}원` : "-"} + 총 매수금액{" "} + {summary ? `${formatCurrency(summary.purchaseAmount)}원` : "-"}

@@ -98,12 +144,14 @@ export function StatusHeader({ className={cn( "inline-flex items-center gap-1 rounded-full px-2 py-1", isWebSocketReady - ? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400" + ? isRealtimePending + ? "bg-amber-500/10 text-amber-700 dark:text-amber-400" + : "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400" : "bg-muted text-muted-foreground", )} > - 실시간 시세 {isWebSocketReady ? "연결됨" : "미연결"} + 실시간 시세 {realtimeStatusLabel} {/* ========== QUICK ACTIONS ========== */} -
+
diff --git a/features/dashboard/components/StockDetailPreview.tsx b/features/dashboard/components/StockDetailPreview.tsx index 2191bed..743eabc 100644 --- a/features/dashboard/components/StockDetailPreview.tsx +++ b/features/dashboard/components/StockDetailPreview.tsx @@ -1,4 +1,14 @@ -import { BarChartBig, MousePointerClick } from "lucide-react"; +/** + * @file StockDetailPreview.tsx + * @description 대시보드 우측 영역의 선택 종목 상세 정보 및 실시간 시세 반영 컴포넌트 + * @remarks + * - [레이어] Components / UI + * - [사용자 행동] 종목 리스트에서 항목 선택 -> 상세 정보 조회 -> 실시간 시세 변동 확인 + * - [데이터 흐름] DashboardContainer(realtimeSelectedHolding) -> StockDetailPreview -> Metric(UI) + * - [연관 파일] DashboardContainer.tsx, dashboard.types.ts, use-price-flash.ts + * @author jihoon87.lee + */ +import { BarChartBig, ExternalLink, MousePointerClick } from "lucide-react"; import { Card, CardContent, @@ -6,6 +16,8 @@ import { CardHeader, CardTitle, } from "@/components/ui/card"; +import { useRouter } from "next/navigation"; +import { usePriceFlash } from "@/features/dashboard/hooks/use-price-flash"; import type { DashboardHoldingItem } from "@/features/dashboard/types/dashboard.types"; import { formatCurrency, @@ -15,18 +27,30 @@ import { import { cn } from "@/lib/utils"; interface StockDetailPreviewProps { + /** 선택된 종목 정보 (없으면 null) */ holding: DashboardHoldingItem | null; + /** 현재 총 자산 (비중 계산용) */ totalAmount: number; } /** - * @description 선택 종목 상세 요약 카드입니다. - * @see features/dashboard/components/DashboardContainer.tsx HoldingsList 선택 결과를 전달받아 렌더링합니다. + * [컴포넌트] 선택 종목 상세 요약 카드 + * 대시보드에서 선택된 특정 종목의 매입가, 현재가, 수익률 등 상세 지표를 실시간으로 보여줍니다. + * + * @param props StockDetailPreviewProps + * @see DashboardContainer.tsx - HoldingsList 선택 결과를 실시간 데이터로 전달받아 렌더링 */ export function StockDetailPreview({ holding, totalAmount, }: StockDetailPreviewProps) { + const router = useRouter(); + // [State/Hook] 실시간 가격 변동 애니메이션 상태 관리 + // @remarks 종목이 선택되지 않았을 때를 대비해 safe value(0)를 전달하며, 종목 변경 시 효과를 초기화하도록 symbol 전달 + const currentPrice = holding?.currentPrice ?? 0; + const priceFlash = usePriceFlash(currentPrice, holding?.symbol); + + // [Step 1] 종목이 선택되지 않은 경우 초기 안내 화면 렌더링 if (!holding) { return ( @@ -48,29 +72,67 @@ export function StockDetailPreview({ ); } + // [Step 2] 수익/손실 여부에 따른 UI 톤(색상) 결정 const profitToneClass = getChangeToneClass(holding.profitLoss); + + // [Step 3] 총 자산 대비 비중 계산 const allocationRate = - totalAmount > 0 ? Math.min((holding.evaluationAmount / totalAmount) * 100, 100) : 0; + totalAmount > 0 + ? Math.min((holding.evaluationAmount / totalAmount) * 100, 100) + : 0; return ( + {/* ========== 카드 헤더: 종목명 및 기본 정보 ========== */} - {/* ========== TITLE ========== */} 선택 종목 정보 - - {holding.name} ({holding.symbol}) · {holding.market} + + + + · {holding.market} + - {/* ========== PRIMARY METRICS ========== */} + {/* ========== 실시간 주요 지표 영역 (Grid) ========== */}
- - - + + +
- {/* ========== ALLOCATION BAR ========== */} + {/* ========== 자산 비중 그래프 영역 ========== */}
총 자산 대비 비중 @@ -101,7 +163,7 @@ export function StockDetailPreview({
- {/* ========== QUICK ORDER PLACEHOLDER ========== */} + {/* ========== 추가 기능 예고 영역 (Placeholder) ========== */}

@@ -117,23 +179,48 @@ export function StockDetailPreview({ } interface MetricProps { + /** 지표 레이블 */ label: string; + /** 표시될 값 */ value: string; + /** 값 텍스트 추가 스타일 */ valueClassName?: string; + /** 가격 변동 애니메이션 상태 */ + flash?: { type: "up" | "down"; val: number; id: number } | null; } /** - * @description 상세 카드에서 공통으로 사용하는 지표 행입니다. - * @param label 지표명 - * @param value 지표값 - * @param valueClassName 값 텍스트 색상 클래스 - * @see features/dashboard/components/StockDetailPreview.tsx 종목 상세 지표 표시 + * [컴포넌트] 상세 카드용 개별 지표 아이템 + * 레이블과 값을 박스 형태로 렌더링하며, 필요한 경우 시세 변동 Flash 애니메이션을 처리합니다. + * + * @param props MetricProps + * @see StockDetailPreview.tsx - 내부 그리드 영역에서 여러 개 호출 */ -function Metric({ label, value, valueClassName }: MetricProps) { +function Metric({ label, value, valueClassName, flash }: MetricProps) { return ( -

+
+ {/* 시세 변동 시 나타나는 일시적인 수치 표시 (Flash) */} + {flash && ( + + {flash.type === "up" ? "+" : ""} + {flash.val.toLocaleString()} + + )} + + {/* 지표 레이블 및 본체 값 */}

{label}

-

+

{value}

diff --git a/features/dashboard/hooks/use-dashboard-data.ts b/features/dashboard/hooks/use-dashboard-data.ts index d11c895..db4b115 100644 --- a/features/dashboard/hooks/use-dashboard-data.ts +++ b/features/dashboard/hooks/use-dashboard-data.ts @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import type { KisRuntimeCredentials } from "@/features/settings/store/use-kis-runtime-store"; import { fetchDashboardActivity, @@ -10,7 +10,6 @@ import { import type { DashboardActivityResponse, DashboardBalanceResponse, - DashboardHoldingItem, DashboardIndicesResponse, } from "@/features/dashboard/types/dashboard.types"; @@ -18,7 +17,6 @@ interface UseDashboardDataResult { activity: DashboardActivityResponse | null; balance: DashboardBalanceResponse | null; indices: DashboardIndicesResponse["items"]; - selectedHolding: DashboardHoldingItem | null; selectedSymbol: string | null; setSelectedSymbol: (symbol: string) => void; isLoading: boolean; @@ -179,11 +177,6 @@ export function useDashboardData( return () => window.clearInterval(interval); }, [credentials, refreshInternal]); - const selectedHolding = useMemo(() => { - if (!selectedSymbol || !balance) return null; - return balance.holdings.find((item) => item.symbol === selectedSymbol) ?? null; - }, [balance, selectedSymbol]); - const setSelectedSymbol = useCallback((symbol: string) => { setSelectedSymbolState(symbol); }, []); @@ -192,7 +185,6 @@ export function useDashboardData( activity, balance, indices, - selectedHolding, selectedSymbol, setSelectedSymbol, isLoading, diff --git a/features/dashboard/hooks/use-holdings-realtime.ts b/features/dashboard/hooks/use-holdings-realtime.ts new file mode 100644 index 0000000..0d12fe3 --- /dev/null +++ b/features/dashboard/hooks/use-holdings-realtime.ts @@ -0,0 +1,76 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import type { DashboardHoldingItem } from "@/features/dashboard/types/dashboard.types"; +import { + type KisRealtimeStockTick, + parseKisRealtimeStockTick, +} from "@/features/dashboard/utils/kis-stock-realtime.utils"; +import { useKisWebSocketStore } from "@/features/kis-realtime/stores/kisWebSocketStore"; + +const STOCK_REALTIME_TR_ID = "H0STCNT0"; + +/** + * @description 보유 종목 목록에 대한 실시간 체결 데이터를 구독합니다. + * @param holdings 보유 종목 목록 + * @returns 종목별 실시간 체결 데이터/연결 상태 + * @remarks UI 흐름: DashboardContainer -> useHoldingsRealtime -> HoldingsList/summary 실시간 반영 + * @see features/dashboard/components/DashboardContainer.tsx 보유종목 실시간 병합 + */ +export function useHoldingsRealtime(holdings: DashboardHoldingItem[]) { + const [realtimeData, setRealtimeData] = useState< + Record + >({}); + const { subscribe, connect, isConnected } = useKisWebSocketStore(); + + const uniqueSymbols = useMemo( + () => Array.from(new Set((holdings ?? []).map((item) => item.symbol))).sort(), + [holdings], + ); + const symbolKey = useMemo(() => uniqueSymbols.join(","), [uniqueSymbols]); + + useEffect(() => { + if (uniqueSymbols.length === 0) { + const resetTimerId = window.setTimeout(() => { + setRealtimeData({}); + }, 0); + return () => window.clearTimeout(resetTimerId); + } + + if (!isConnected) { + connect(); + } + + const unsubs: (() => void)[] = []; + + uniqueSymbols.forEach((symbol) => { + const unsub = subscribe(STOCK_REALTIME_TR_ID, symbol, (data) => { + const tick = parseKisRealtimeStockTick(data); + if (tick) { + setRealtimeData((prev) => { + const prevTick = prev[tick.symbol]; + if ( + prevTick?.currentPrice === tick.currentPrice && + prevTick?.change === tick.change && + prevTick?.changeRate === tick.changeRate + ) { + return prev; + } + + return { + ...prev, + [tick.symbol]: tick, + }; + }); + } + }); + unsubs.push(unsub); + }); + + return () => { + unsubs.forEach((unsub) => unsub()); + }; + }, [symbolKey, uniqueSymbols, connect, subscribe, isConnected]); + + return { realtimeData, isConnected }; +} diff --git a/features/dashboard/hooks/use-market-realtime.ts b/features/dashboard/hooks/use-market-realtime.ts new file mode 100644 index 0000000..03c6c03 --- /dev/null +++ b/features/dashboard/hooks/use-market-realtime.ts @@ -0,0 +1,77 @@ +"use client"; + +import { useState, useCallback } from "react"; +import { + parseKisRealtimeIndexTick, + type KisRealtimeIndexTick, +} from "@/features/dashboard/utils/kis-index-realtime.utils"; +import { useKisWebSocket } from "@/features/kis-realtime/hooks/useKisWebSocket"; +import type { KisRuntimeCredentials } from "@/features/settings/store/use-kis-runtime-store"; + +const INDEX_TR_ID = "H0UPCNT0"; +const KOSPI_SYMBOL = "0001"; +const KOSDAQ_SYMBOL = "1001"; + +interface UseMarketRealtimeResult { + realtimeIndices: Record; + isConnected: boolean; + hasReceivedTick: boolean; + isPending: boolean; + lastTickAt: string | null; +} + +/** + * @description 코스피/코스닥 실시간 지수 웹소켓 구독 상태를 관리합니다. + * @param credentials KIS 인증 정보(하위 호환 파라미터) + * @param isVerified KIS 연결 인증 여부 + * @returns 실시간 지수 맵/연결 상태/수신 대기 상태 + * @remarks UI 흐름: DashboardContainer -> useMarketRealtime -> MarketSummary/StatusHeader 렌더링 반영 + * @see features/dashboard/components/DashboardContainer.tsx 지수 데이터 통합 및 상태 전달 + */ +export function useMarketRealtime( + _credentials: KisRuntimeCredentials | null, // 하위 호환성을 위해 남겨둠 (실제로는 스토어 사용) + isVerified: boolean, // 하위 호환성을 위해 남겨둠 +): UseMarketRealtimeResult { + const [realtimeIndices, setRealtimeIndices] = useState< + Record + >({}); + const [lastTickAt, setLastTickAt] = useState(null); + + const handleMessage = useCallback((data: string) => { + const tick = parseKisRealtimeIndexTick(data); + if (tick) { + setLastTickAt(new Date().toISOString()); + setRealtimeIndices((prev) => ({ + ...prev, + [tick.symbol]: tick, + })); + } + }, []); + + // KOSPI 구독 + const { isConnected: isKospiConnected } = useKisWebSocket({ + symbol: KOSPI_SYMBOL, + trId: INDEX_TR_ID, + onMessage: handleMessage, + enabled: isVerified, + }); + + // KOSDAQ 구독 + const { isConnected: isKosdaqConnected } = useKisWebSocket({ + symbol: KOSDAQ_SYMBOL, + trId: INDEX_TR_ID, + onMessage: handleMessage, + enabled: isVerified, + }); + + const hasReceivedTick = Object.keys(realtimeIndices).length > 0; + const isPending = isVerified && (isKospiConnected || isKosdaqConnected) && !hasReceivedTick; + + return { + realtimeIndices, + isConnected: isKospiConnected || isKosdaqConnected, + hasReceivedTick, + isPending, + lastTickAt, + }; +} diff --git a/features/dashboard/hooks/use-price-flash.ts b/features/dashboard/hooks/use-price-flash.ts new file mode 100644 index 0000000..775528c --- /dev/null +++ b/features/dashboard/hooks/use-price-flash.ts @@ -0,0 +1,73 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; + +const FLASH_DURATION_MS = 2_000; + +/** + * @description 가격 변동 시 일시 플래시(+/-) 값을 생성합니다. + * @param currentPrice 현재가 + * @param key 종목 식별 키(종목 변경 시 상태 초기화) + * @returns 플래시 값(up/down, 변화량) 또는 null + * @remarks UI 흐름: 시세 변경 -> usePriceFlash -> 플래시 값 노출 -> 2초 후 자동 제거 + * @see features/dashboard/components/HoldingsList.tsx 보유종목 현재가 플래시 + * @see features/dashboard/components/StockDetailPreview.tsx 상세 카드 현재가 플래시 + */ +export function usePriceFlash(currentPrice: number, key?: string) { + const [flash, setFlash] = useState<{ + val: number; + type: "up" | "down"; + id: number; + } | null>(null); + + const prevKeyRef = useRef(key); + const prevPriceRef = useRef(currentPrice); + const timerRef = useRef(null); + + useEffect(() => { + const keyChanged = prevKeyRef.current !== key; + + if (keyChanged) { + prevKeyRef.current = key; + prevPriceRef.current = currentPrice; + if (timerRef.current !== null) { + window.clearTimeout(timerRef.current); + timerRef.current = null; + } + const resetTimerId = window.setTimeout(() => { + setFlash(null); + }, 0); + return () => window.clearTimeout(resetTimerId); + } + + const prevPrice = prevPriceRef.current; + const diff = currentPrice - prevPrice; + prevPriceRef.current = currentPrice; + + if (prevPrice === 0 || Math.abs(diff) === 0) return; + + // 플래시가 보이는 동안에는 새 플래시를 덮어쓰지 않아 화면 잔상이 지속되지 않게 합니다. + if (timerRef.current !== null) return; + + setFlash({ + val: diff, + type: diff > 0 ? "up" : "down", + id: Date.now(), + }); + + timerRef.current = window.setTimeout(() => { + setFlash(null); + timerRef.current = null; + }, FLASH_DURATION_MS); + }, [currentPrice, key]); + + useEffect(() => { + return () => { + if (timerRef.current !== null) { + window.clearTimeout(timerRef.current); + } + }; + }, []); + + return flash; +} diff --git a/features/dashboard/types/dashboard.types.ts b/features/dashboard/types/dashboard.types.ts index 6addf92..679b491 100644 --- a/features/dashboard/types/dashboard.types.ts +++ b/features/dashboard/types/dashboard.types.ts @@ -13,12 +13,15 @@ export type DashboardMarket = "KOSPI" | "KOSDAQ"; export interface DashboardBalanceSummary { totalAmount: number; cashBalance: number; + totalDepositAmount: number; totalProfitLoss: number; totalProfitRate: number; netAssetAmount: number; evaluationAmount: number; purchaseAmount: number; loanAmount: number; + apiReportedTotalAmount: number; + apiReportedNetAssetAmount: number; } /** diff --git a/features/dashboard/utils/kis-index-realtime.utils.ts b/features/dashboard/utils/kis-index-realtime.utils.ts new file mode 100644 index 0000000..137b1d9 --- /dev/null +++ b/features/dashboard/utils/kis-index-realtime.utils.ts @@ -0,0 +1,62 @@ +export interface KisRealtimeIndexTick { + symbol: string; // 업종코드 (0001: KOSPI, 1001: KOSDAQ) + price: number; // 현재가 + change: number; // 전일대비 + changeRate: number; // 전일대비율 + sign: string; // 대비부호 + time: string; // 체결시간 +} + +const INDEX_REALTIME_TR_ID = "H0UPCNT0"; + +const INDEX_FIELD_INDEX = { + symbol: 0, // bstp_cls_code + time: 1, // bsop_hour + price: 2, // prpr_nmix + sign: 3, // prdy_vrss_sign + change: 4, // bstp_nmix_prdy_vrss + accumulatedVolume: 5, // acml_vol + accumulatedAmount: 6, // acml_tr_pbmn + changeRate: 9, // prdy_ctrt +} as const; + +export function parseKisRealtimeIndexTick( + raw: string, +): KisRealtimeIndexTick | null { + // Format: 0|H0UPCNT0|001|0001^123456^... + if (!/^([01])\|/.test(raw)) return null; + + const parts = raw.split("|"); + if (parts.length < 4) return null; + + // Check TR ID + if (parts[1] !== INDEX_REALTIME_TR_ID) { + return null; + } + + const values = parts[3].split("^"); + if (values.length < 10) return null; // Ensure minimum fields exist + + const symbol = values[INDEX_FIELD_INDEX.symbol]; + const price = parseFloat(values[INDEX_FIELD_INDEX.price]); + const sign = values[INDEX_FIELD_INDEX.sign]; + const changeRaw = parseFloat(values[INDEX_FIELD_INDEX.change]); + const changeRateRaw = parseFloat(values[INDEX_FIELD_INDEX.changeRate]); + + // Adjust sign for negative values if necessary (usually API sends absolute values for change) + const isNegative = sign === "5" || sign === "4"; // 5: 하락, 4: 하한 + + const change = isNegative ? -Math.abs(changeRaw) : Math.abs(changeRaw); + const changeRate = isNegative + ? -Math.abs(changeRateRaw) + : Math.abs(changeRateRaw); + + return { + symbol, + time: values[INDEX_FIELD_INDEX.time], + price, + change, + changeRate, + sign, + }; +} diff --git a/features/dashboard/utils/kis-stock-realtime.utils.ts b/features/dashboard/utils/kis-stock-realtime.utils.ts new file mode 100644 index 0000000..64f0da9 --- /dev/null +++ b/features/dashboard/utils/kis-stock-realtime.utils.ts @@ -0,0 +1,69 @@ +export interface KisRealtimeStockTick { + symbol: string; // 종목코드 + time: string; // 체결시간 + currentPrice: number; // 현재가 + sign: string; // 전일대비부호 (1:상한, 2:상승, 3:보합, 4:하한, 5:하락) + change: number; // 전일대비 + changeRate: number; // 전일대비율 + accumulatedVolume: number; // 누적거래량 +} + +const STOCK_realtime_TR_ID = "H0STCNT0"; + +// H0STCNT0 Output format indices based on typical KIS Realtime API +// Format: MKSC_SHRN_ISCD^STCK_CNTG_HOUR^STCK_PRPR^PRDY_VRSS_SIGN^PRDY_VRSS^PRDY_CTRT^... +const STOCK_FIELD_INDEX = { + symbol: 0, // MKSC_SHRN_ISCD + time: 1, // STCK_CNTG_HOUR + currentPrice: 2, // STCK_PRPR + sign: 3, // PRDY_VRSS_SIGN + change: 4, // PRDY_VRSS + changeRate: 5, // PRDY_CTRT + accumulatedVolume: 12, // ACML_VOL (Usually at index 12 or similar, need to be careful here) +} as const; + +export function parseKisRealtimeStockTick( + raw: string, +): KisRealtimeStockTick | null { + // Format: 0|H0STCNT0|001|SYMBOL^TIME^PRICE^SIGN^CHANGE^... + if (!/^([01])\|/.test(raw)) return null; + + const parts = raw.split("|"); + if (parts.length < 4) return null; + + // Check TR ID + if (parts[1] !== STOCK_realtime_TR_ID) { + return null; + } + + const values = parts[3].split("^"); + if (values.length < 6) return null; // Ensure minimum fields exist + + const symbol = values[STOCK_FIELD_INDEX.symbol]; + const currentPrice = parseFloat(values[STOCK_FIELD_INDEX.currentPrice]); + const sign = values[STOCK_FIELD_INDEX.sign]; + const changeRaw = parseFloat(values[STOCK_FIELD_INDEX.change]); + const changeRateRaw = parseFloat(values[STOCK_FIELD_INDEX.changeRate]); + + // Adjust sign for negative values if necessary + const isNegative = sign === "5" || sign === "4"; // 5: 하락, 4: 하한 + + const change = isNegative ? -Math.abs(changeRaw) : Math.abs(changeRaw); + const changeRate = isNegative + ? -Math.abs(changeRateRaw) + : Math.abs(changeRateRaw); + + // Validate numeric values + if (isNaN(currentPrice)) return null; + + return { + symbol, + time: values[STOCK_FIELD_INDEX.time], + currentPrice, + sign, + change, + changeRate, + accumulatedVolume: + parseFloat(values[STOCK_FIELD_INDEX.accumulatedVolume]) || 0, + }; +} diff --git a/features/kis-realtime/hooks/useKisWebSocket.ts b/features/kis-realtime/hooks/useKisWebSocket.ts new file mode 100644 index 0000000..020090a --- /dev/null +++ b/features/kis-realtime/hooks/useKisWebSocket.ts @@ -0,0 +1,51 @@ +import { useEffect, useRef } from "react"; +import { useKisWebSocketStore } from "@/features/kis-realtime/stores/kisWebSocketStore"; + +/** + * @file features/kis-realtime/hooks/useKisWebSocket.ts + * @description KIS 실시간 데이터를 구독하기 위한 통합 훅입니다. + * 컴포넌트 마운트/언마운트 시 자동으로 구독 및 해제를 처리합니다. + */ + +type RealtimeCallback = (data: string) => void; + +interface UseKisWebSocketParams { + symbol?: string; // 종목코드 (없으면 구독 안 함) + trId?: string; // 거래 ID (예: H0STCNT0) + onMessage?: RealtimeCallback; // 데이터 수신 콜백 + enabled?: boolean; // 구독 활성화 여부 +} + +export function useKisWebSocket({ + symbol, + trId, + onMessage, + enabled = true, +}: UseKisWebSocketParams) { + const { subscribe, connect, isConnected } = useKisWebSocketStore(); + const callbackRef = useRef(onMessage); + + // 콜백 함수가 바뀌어도 재구독하지 않도록 ref 사용 + useEffect(() => { + callbackRef.current = onMessage; + }, [onMessage]); + + useEffect(() => { + if (!enabled || !symbol || !trId) return; + + // 연결 시도 (이미 연결 중이면 스토어에서 무시됨) + connect(); + + // 구독 요청 + const unsubscribe = subscribe(trId, symbol, (data) => { + callbackRef.current?.(data); + }); + + // 언마운트 시 구독 해제 + return () => { + unsubscribe(); + }; + }, [symbol, trId, enabled, connect, subscribe]); + + return { isConnected }; +} diff --git a/features/kis-realtime/stores/kisWebSocketStore.ts b/features/kis-realtime/stores/kisWebSocketStore.ts new file mode 100644 index 0000000..309e495 --- /dev/null +++ b/features/kis-realtime/stores/kisWebSocketStore.ts @@ -0,0 +1,386 @@ +import { create } from "zustand"; +import { useKisRuntimeStore } from "@/features/settings/store/use-kis-runtime-store"; +import { buildKisRealtimeMessage } from "@/features/kis-realtime/utils/websocketUtils"; + +/** + * @file features/kis-realtime/stores/kisWebSocketStore.ts + * @description KIS 실시간 웹소켓 연결을 전역에서 하나로 관리하는 스토어입니다. + * 중복 연결을 방지하고, 여러 컴포넌트에서 동일한 데이터를 구독할 때 효율적으로 처리합니다. + */ + +type RealtimeCallback = (data: string) => void; + +interface KisWebSocketState { + isConnected: boolean; + error: string | null; + + /** + * 웹소켓 연결을 수립합니다. + * 이미 연결되어 있거나 연결 중이면 무시합니다. + */ + connect: (options?: { forceApprovalRefresh?: boolean }) => Promise; + + /** + * 웹소켓 연결을 강제로 재시작합니다. + * 필요 시 승인키를 새로 발급받아 재연결합니다. + */ + reconnect: (options?: { refreshApproval?: boolean }) => Promise; + + /** + * 웹소켓 연결을 종료합니다. + * 모든 구독이 해제됩니다. + */ + disconnect: () => void; + + /** + * 특정 TR ID와 종목 코드로 실시간 데이터를 구독합니다. + * @param trId 거래 ID (예: H0STCNT0) + * @param symbol 종목 코드 (예: 005930) + * @param callback 데이터 수신 시 실행할 콜백 함수 + * @returns 구독 해제 함수 (useEffect cleanup에서 호출하세요) + */ + subscribe: ( + trId: string, + symbol: string, + callback: RealtimeCallback, + ) => () => void; +} + +// 구독자 목록 관리 (Key: "TR_ID|SYMBOL", Value: Set) +// 스토어 외부 변수로 관리하여 불필요한 리렌더링을 방지합니다. +const subscribers = new Map>(); +const subscriberCounts = new Map(); // 실제 소켓 구독 요청 여부 추적용 + +let socket: WebSocket | null = null; +let pingInterval: number | undefined; +let isConnecting = false; // 연결 진행 중 상태 잠금 +let reconnectRetryTimer: number | undefined; +let lastAppKeyConflictAt = 0; + +export const useKisWebSocketStore = create((set, get) => ({ + isConnected: false, + error: null, + + connect: async (options) => { + const forceApprovalRefresh = options?.forceApprovalRefresh ?? false; + const currentSocket = socket; + + if (currentSocket?.readyState === WebSocket.CLOSING) { + await waitForSocketClose(currentSocket); + } + + // 1. 이미 연결되어 있거나, 연결 시도 중이면 중복 실행 방지 + if ( + socket?.readyState === WebSocket.OPEN || + socket?.readyState === WebSocket.CONNECTING || + socket?.readyState === WebSocket.CLOSING || + isConnecting + ) { + return; + } + + try { + isConnecting = true; + const { getOrFetchWsConnection, clearWsConnectionCache } = + useKisRuntimeStore.getState(); + if (forceApprovalRefresh) { + clearWsConnectionCache(); + } + const wsConnection = await getOrFetchWsConnection(); + + // 비동기 대기 중에 다른 연결이 성사되었는지 다시 확인 + if ( + socket?.readyState === WebSocket.OPEN || + socket?.readyState === WebSocket.CONNECTING + ) { + isConnecting = false; + return; + } + + if (!wsConnection) { + throw new Error("웹소켓 접속 키 발급에 실패했습니다."); + } + + // 소켓 생성 + // socket 변수에 할당하기 전에 로컬 변수로 제어하여 이벤트 핸들러 클로저 문제 방지 + const ws = new WebSocket(`${wsConnection.wsUrl}/tryitout`); + socket = ws; + + ws.onopen = () => { + isConnecting = false; + // socket 변수가 다른 인스턴스로 바뀌었을 가능성은 낮지만(락 때문), + // 안전을 위해 이벤트 발생 주체인 ws를 사용 또는 현재 socket 확인 + if (socket !== ws) return; + + set({ isConnected: true, error: null }); + console.log("[KisWebSocket] Connected"); + + // 재연결 시 기존 구독 복구 + const approvalKey = wsConnection.approvalKey; + if (approvalKey) { + subscriberCounts.forEach((_, key) => { + const [trId, symbol] = key.split("|"); + + // OPEN 상태일 때만 전송 + if (ws.readyState === WebSocket.OPEN) { + sendSubscription(ws, approvalKey, trId, symbol, "1"); // 구독 + } + }); + } + + // PINGPONG (Keep-alive) + window.clearInterval(pingInterval); + pingInterval = window.setInterval(() => { + if (socket?.readyState === WebSocket.OPEN) { + socket.send("PINGPONG"); // 일부 환경에서는 PINGPONG 텍스트 전송 + } + }, 100_000); // 100초 주기 + }; + + ws.onclose = () => { + if (socket === ws) { + isConnecting = false; + set({ isConnected: false }); + console.log("[KisWebSocket] Disconnected"); + window.clearInterval(pingInterval); + socket = null; + } + }; + + ws.onerror = (event) => { + if (socket === ws) { + isConnecting = false; + console.error("[KisWebSocket] Error", event); + set({ + isConnected: false, + error: "웹소켓 연결 중 오류가 발생했습니다.", + }); + } + }; + + ws.onmessage = (event) => { + const data = event.data; + if (typeof data !== "string") return; + + // PINGPONG 응답 또는 제어 메시지 처리 + if (data.startsWith("{")) { + const control = parseControlMessage(data); + if (control?.rt_cd && control.rt_cd !== "0") { + const errorMessage = buildControlErrorMessage(control); + set({ + error: errorMessage, + }); + + // KIS 제어 메시지: ALREADY IN USE appkey + // 이전 세션이 닫히기 전에 재연결될 때 간헐적으로 발생합니다. + if (control.msg_cd === "OPSP8996") { + const now = Date.now(); + if (now - lastAppKeyConflictAt > 5_000) { + lastAppKeyConflictAt = now; + window.clearTimeout(reconnectRetryTimer); + reconnectRetryTimer = window.setTimeout(() => { + void get().reconnect({ refreshApproval: false }); + }, 1_200); + } + } + } + return; + } + + if (data[0] === "0" || data[0] === "1") { + // 데이터 포맷: 0|TR_ID|KEY|... + const parts = data.split("|"); + if (parts.length >= 4) { + const trId = parts[1]; + // 데이터 부분 (마지막 부분)에서 종목코드를 찾아야 함. + // 하지만 응답에는 종목코드가 명시적으로 없는 경우가 많음 (순서로 추론). + // 다행히 KIS API는 요청했던 TR_ID와 수신된 데이터의 호가/체결 데이터를 매핑해야 함. + // 여기서는 모든 구독자에게 브로드캐스트하는 방식을 사용 (TR_ID 기준). + + // 더 정확한 라우팅을 위해: + // 실시간 체결/호가 데이터에는 종목코드가 포함되어 있음. + // 체결(H0STCNT0): data.split("^")[0] (유가증권 단축종목코드) + const body = parts[3]; + const values = body.split("^"); + const symbol = values[0]; // 대부분 첫 번째 필드가 종목코드 + + const key = `${trId}|${symbol}`; + const callbacks = subscribers.get(key); + callbacks?.forEach((cb) => cb(data)); + } + } + }; + } catch (err) { + isConnecting = false; + set({ + isConnected: false, + error: err instanceof Error ? err.message : "연결 실패", + }); + } + }, + + reconnect: async (options) => { + const refreshApproval = options?.refreshApproval ?? false; + const currentSocket = socket; + get().disconnect(); + if (currentSocket && currentSocket.readyState !== WebSocket.CLOSED) { + await waitForSocketClose(currentSocket); + } + await get().connect({ + forceApprovalRefresh: refreshApproval, + }); + }, + + disconnect: () => { + const currentSocket = socket; + if ( + currentSocket && + (currentSocket.readyState === WebSocket.OPEN || + currentSocket.readyState === WebSocket.CONNECTING || + currentSocket.readyState === WebSocket.CLOSING) + ) { + currentSocket.close(); + } + if (currentSocket?.readyState === WebSocket.CLOSED && socket === currentSocket) { + socket = null; + } + set({ isConnected: false }); + window.clearInterval(pingInterval); + window.clearTimeout(reconnectRetryTimer); + isConnecting = false; + }, + + subscribe: (trId, symbol, callback) => { + const key = `${trId}|${symbol}`; + + // 1. 구독자 목록에 추가 + if (!subscribers.has(key)) { + subscribers.set(key, new Set()); + } + subscribers.get(key)!.add(callback); + + // 2. 소켓 서버에 구독 요청 (첫 번째 구독자인 경우) + const currentCount = subscriberCounts.get(key) || 0; + if (currentCount === 0) { + const { wsApprovalKey } = useKisRuntimeStore.getState(); + if (socket?.readyState === WebSocket.OPEN && wsApprovalKey) { + sendSubscription(socket, wsApprovalKey, trId, symbol, "1"); // "1": 등록 + } + } + subscriberCounts.set(key, currentCount + 1); + + // **연결이 안 되어 있으면 연결 시도** + if (!socket || socket.readyState !== WebSocket.OPEN) { + get().connect(); + } + + // 3. 구독 해제 함수 반환 + return () => { + const callbacks = subscribers.get(key); + if (callbacks) { + callbacks.delete(callback); + if (callbacks.size === 0) { + subscribers.delete(key); + } + } + + const count = subscriberCounts.get(key) || 0; + if (count > 0) { + subscriberCounts.set(key, count - 1); + if (count - 1 === 0) { + // 마지막 구독자가 사라지면 소켓 구독 해제 + const { wsApprovalKey } = useKisRuntimeStore.getState(); + if (socket?.readyState === WebSocket.OPEN && wsApprovalKey) { + sendSubscription(socket, wsApprovalKey, trId, symbol, "2"); // "2": 해제 + } + } + } + }; + }, +})); + +// 헬퍼: 구독/해제 메시지 전송 +function sendSubscription( + ws: WebSocket, + appKey: string, + trId: string, + symbol: string, + trType: "1" | "2", +) { + try { + const msg = buildKisRealtimeMessage(appKey, symbol, trId, trType); + ws.send(JSON.stringify(msg)); + console.debug( + `[KisWebSocket] ${trType === "1" ? "Sub" : "Unsub"} ${trId} ${symbol}`, + ); + } catch (e) { + console.warn("[KisWebSocket] Send error", e); + } +} + +interface KisWsControlMessage { + rt_cd?: string; + msg_cd?: string; + msg1?: string; +} + +/** + * @description 웹소켓 제어 메시지(JSON)를 파싱합니다. + * @param rawData 원본 메시지 문자열 + * @returns 파싱된 제어 메시지 또는 null + * @see features/kis-realtime/stores/kisWebSocketStore.ts ws.onmessage + */ +function parseControlMessage(rawData: string): KisWsControlMessage | null { + try { + const parsed = JSON.parse(rawData) as KisWsControlMessage; + return parsed && typeof parsed === "object" ? parsed : null; + } catch { + return null; + } +} + +/** + * @description KIS 웹소켓 제어 오류를 사용자용 짧은 문구로 변환합니다. + * @param message KIS 제어 메시지 + * @returns 표시용 오류 문자열 + * @see features/kis-realtime/stores/kisWebSocketStore.ts ws.onmessage + */ +function buildControlErrorMessage(message: KisWsControlMessage) { + if (message.msg_cd === "OPSP8996") { + return "실시간 연결이 다른 세션과 충돌해 재연결을 시도합니다."; + } + const detail = [message.msg1, message.msg_cd].filter(Boolean).join(" / "); + return detail ? `실시간 제어 메시지 오류: ${detail}` : "실시간 제어 메시지 오류"; +} + +/** + * @description 특정 웹소켓 인스턴스가 완전히 닫힐 때까지 대기합니다. + * @param target 대기할 웹소켓 인스턴스 + * @param timeoutMs 최대 대기 시간(ms) + * @returns close/error/timeout 중 먼저 완료되면 resolve + * @see features/kis-realtime/stores/kisWebSocketStore.ts connect/reconnect + */ +function waitForSocketClose(target: WebSocket, timeoutMs = 2_000) { + if (target.readyState === WebSocket.CLOSED) { + return Promise.resolve(); + } + + return new Promise((resolve) => { + let settled = false; + const onClose = () => finish(); + const onError = () => finish(); + const timeoutId = window.setTimeout(() => finish(), timeoutMs); + + const finish = () => { + if (settled) return; + settled = true; + window.clearTimeout(timeoutId); + target.removeEventListener("close", onClose); + target.removeEventListener("error", onError); + resolve(); + }; + + target.addEventListener("close", onClose); + target.addEventListener("error", onError); + }); +} diff --git a/features/kis-realtime/utils/websocketUtils.ts b/features/kis-realtime/utils/websocketUtils.ts new file mode 100644 index 0000000..c2c516e --- /dev/null +++ b/features/kis-realtime/utils/websocketUtils.ts @@ -0,0 +1,29 @@ +/** + * @file features/kis-realtime/utils/websocketUtils.ts + * @description KIS 웹소켓 메시지 생성 및 파싱 관련 유틸리티 함수 모음 + */ + +/** + * @description KIS 실시간 구독/해제 소켓 메시지를 생성합니다. + */ +export 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, + }, + }, + }; +} diff --git a/features/settings/components/KisAuthForm.tsx b/features/settings/components/KisAuthForm.tsx index 4869c61..37625eb 100644 --- a/features/settings/components/KisAuthForm.tsx +++ b/features/settings/components/KisAuthForm.tsx @@ -3,6 +3,7 @@ import { useShallow } from "zustand/react/shallow"; import { cn } from "@/lib/utils"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; import { useKisRuntimeStore } from "@/features/settings/store/use-kis-runtime-store"; import { revokeKisCredentials, @@ -10,15 +11,19 @@ import { } from "@/features/settings/apis/kis-auth.api"; import { KeyRound, - Shield, + ShieldCheck, CheckCircle2, XCircle, Lock, - Sparkles, - Zap, + Link2, + Unlink2, Activity, + Zap, + KeySquare, } from "lucide-react"; +import type { LucideIcon } from "lucide-react"; import { InlineSpinner } from "@/components/ui/loading-spinner"; +import { SettingsCard } from "./SettingsCard"; /** * @description 한국투자증권 앱키/앱시크릿키 인증 폼입니다. @@ -62,9 +67,6 @@ export function KisAuthForm() { const [isValidating, startValidateTransition] = useTransition(); const [isRevoking, startRevokeTransition] = useTransition(); - // 입력 필드 Focus 상태 관리를 위한 State - const [focusedField, setFocusedField] = useState<"appKey" | "appSecret" | null>(null); - function handleValidate() { startValidateTransition(async () => { try { @@ -123,134 +125,21 @@ export function KisAuthForm() { } return ( -
- {/* Inner Content Container - Compact spacing */} -
- {/* Header Section */} -
-
-
- -
-
-

- 한국투자증권 앱키 연결 - {isKisVerified && ( - - - 연결됨 - - )} -

-

- 한국투자증권 Open API에서 발급받은 앱키/앱시크릿키를 입력해 주세요. -

-
-
- - {/* Trading Mode Switch (Segmented Control - Compact) */} -
- - -
-
- - {/* Input Fields Section (Compact Stacked Layout) */} -
- {/* ========== APP KEY INPUT ========== */} -
-
- -
-
- 앱키 -
- setKisAppKeyInput(e.target.value)} - onFocus={() => setFocusedField("appKey")} - onBlur={() => setFocusedField(null)} - placeholder="한국투자증권 앱키 입력" - className="h-9 flex-1 border-none bg-transparent px-3 text-xs text-zinc-900 placeholder:text-zinc-400 focus-visible:ring-0 dark:text-zinc-100 dark:placeholder:text-zinc-600" - autoComplete="off" - /> -
- - {/* ========== APP SECRET INPUT ========== */} -
-
- -
-
- 시크릿키 -
- setKisAppSecretInput(e.target.value)} - onFocus={() => setFocusedField("appSecret")} - onBlur={() => setFocusedField(null)} - placeholder="한국투자증권 앱시크릿키 입력" - className="h-9 flex-1 border-none bg-transparent px-3 text-xs text-zinc-900 placeholder:text-zinc-400 focus-visible:ring-0 dark:text-zinc-100 dark:placeholder:text-zinc-600" - autoComplete="off" - /> -
-
- - {/* Action & Status Section */} -
-
+ + + 연결됨 + + ) : undefined + } + footer={{ + actions: ( +
+ ) : ( + + + 앱키 연결 확인 + + )} + {isKisVerified && ( )}
- - {/* Status Messages - Compact */} -
+ ), + status: ( +
{errorMessage && ( -

+

{errorMessage}

)} {statusMessage && ( -

+

{statusMessage}

)} {!errorMessage && !statusMessage && !isKisVerified && ( -

- - 미연결 +

+ + 미연결 상태입니다

)}
+ ), + }} + className="h-full" + > +
+ {/* ========== TRADING MODE ========== */} +
+
+ + 투자 모드 선택 +
+
+ + +
+
+ + {/* ========== APP KEY INPUTS ========== */} +
+ +
+ + ); +} + +/** + * @description 앱키/시크릿키 입력 전용 필드 블록입니다. + * @see features/settings/components/KisAuthForm.tsx 입력 UI 렌더링 + */ +function CredentialInput({ + id, + label, + value, + placeholder, + onChange, + icon: Icon, +}: { + id: string; + label: string; + value: string; + placeholder: string; + onChange: (value: string) => void; + icon: LucideIcon; +}) { + return ( +
+ +
+
+ +
+ onChange(e.target.value)} + placeholder={placeholder} + className="h-10 border-none bg-transparent px-3 text-sm shadow-none focus-visible:ring-0" + autoComplete="off" + /> +
); } diff --git a/features/settings/components/KisProfileForm.tsx b/features/settings/components/KisProfileForm.tsx index 5bc7e71..9855042 100644 --- a/features/settings/components/KisProfileForm.tsx +++ b/features/settings/components/KisProfileForm.tsx @@ -2,12 +2,21 @@ import { useState, useTransition } from "react"; import { useShallow } from "zustand/react/shallow"; -import { CreditCard, CheckCircle2, SearchCheck, ShieldOff, XCircle } from "lucide-react"; +import { + CreditCard, + CheckCircle2, + SearchCheck, + ShieldOff, + XCircle, + FileLock2, +} from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; import { InlineSpinner } from "@/components/ui/loading-spinner"; import { validateKisProfile } from "@/features/settings/apis/kis-auth.api"; import { useKisRuntimeStore } from "@/features/settings/store/use-kis-runtime-store"; +import { SettingsCard } from "./SettingsCard"; /** * @description 한국투자증권 계좌번호 검증 폼입니다. @@ -98,98 +107,122 @@ export function KisProfileForm() { } return ( -
- {/* ========== HEADER ========== */} -
-
-

- 한국투자증권 계좌 인증 -

-

- 앱키 연결 완료 후 계좌번호를 확인합니다. + + + 인증 완료 + + ) : undefined + } + className={ + !isKisVerified ? "opacity-60 grayscale pointer-events-none" : undefined + } + footer={{ + actions: ( +

+ + +
+ ), + status: ( +
+ {errorMessage && ( +

+ + {errorMessage} +

+ )} + {statusMessage && ( +

+ + {statusMessage} +

+ )} + {!statusMessage && !errorMessage && !isKisVerified && ( +

+ + 먼저 앱키 연결을 완료해 주세요 +

+ )} + {!statusMessage && !errorMessage && isKisProfileVerified && ( +

+ 확인된 계좌: {maskAccountNo(verifiedAccountNo)} +

+ )} +
+ ), + }} + > +
+ {/* ========== ACCOUNT GUIDE ========== */} +
+

+ + 계좌번호 형식 안내

-
- - {isKisProfileVerified ? "인증 완료" : "미인증"} - -
+

+ 8-2 형식으로 입력하세요. 예: 12345678-01 +

+ - {/* ========== INPUTS ========== */} -
-
- - setKisAccountNoInput(event.target.value)} - placeholder="계좌번호 (예: 12345678-01)" - className="h-9 pl-8 text-xs" - autoComplete="off" - /> + {/* ========== ACCOUNT NO INPUT ========== */} +
+ +
+
+ +
+ setKisAccountNoInput(e.target.value)} + placeholder="계좌번호 (예: 12345678-01)" + className="h-10 border-none bg-transparent px-3 text-sm shadow-none focus-visible:ring-0" + autoComplete="off" + /> +
- - {/* ========== ACTION ========== */} -
- - - -
- {errorMessage && ( -

- - {errorMessage} -

- )} - {statusMessage && ( -

- - {statusMessage} -

- )} - {!statusMessage && !errorMessage && !isKisVerified && ( -

- 먼저 앱키 연결을 완료해 주세요. -

- )} - {!statusMessage && !errorMessage && isKisProfileVerified && ( -

- 확인된 계좌: {maskAccountNo(verifiedAccountNo)} -

- )} -
-
-
+ ); } diff --git a/features/settings/components/SettingsCard.tsx b/features/settings/components/SettingsCard.tsx new file mode 100644 index 0000000..c37ba34 --- /dev/null +++ b/features/settings/components/SettingsCard.tsx @@ -0,0 +1,98 @@ +"use client"; + +import { ReactNode } from "react"; +import { LucideIcon } from "lucide-react"; +import { cn } from "@/lib/utils"; + +interface SettingsCardProps { + /** 카드 상단에 표시될 아이콘 컴포넌트 */ + icon: LucideIcon; + /** 카드 제목 */ + title: ReactNode; + /** 제목 옆에 표시될 배지 (선택 사항) */ + badge?: ReactNode; + /** 헤더 우측에 표시될 액션 요소 (스위치, 버튼 등) */ + headerAction?: ReactNode; + /** 카드 설명 텍스트 */ + description?: string; + /** 카드 본문 컨텐츠 */ + children: ReactNode; + /** 카드 하단 영역 (액션 버튼 및 상태 메시지 포함) */ + footer?: { + /** 좌측 액션 버튼들 */ + actions?: ReactNode; + /** 우측 상태 메시지 */ + status?: ReactNode; + }; + /** 추가 클래스 */ + className?: string; +} + +/** + * @description 설정 페이지에서 사용되는 통일된 카드 UI 컴포넌트입니다. + * @remarks 모든 설정 폼(인증, 프로필 등)은 이 컴포넌트를 사용하여 일관된 디자인을 유지해야 합니다. + */ +export function SettingsCard({ + icon: Icon, + title, + badge, + headerAction, + description, + children, + footer, + className, +}: SettingsCardProps) { + return ( +
+
+ +
+ {/* ========== CARD HEADER ========== */} +
+
+
+
+ +
+
+
+

+ {title} +

+ {badge &&
{badge}
} +
+ {description && ( +

+ {description} +

+ )} +
+
+ + {headerAction && ( +
{headerAction}
+ )} +
+
+ + {/* ========== CARD BODY ========== */} +
{children}
+
+ + {/* ========== CARD FOOTER ========== */} + {footer && ( +
+
+
{footer.actions}
+
{footer.status}
+
+
+ )} +
+ ); +} diff --git a/features/settings/components/SettingsContainer.tsx b/features/settings/components/SettingsContainer.tsx index f6b31a8..7f68056 100644 --- a/features/settings/components/SettingsContainer.tsx +++ b/features/settings/components/SettingsContainer.tsx @@ -1,10 +1,11 @@ "use client"; -import { Info } from "lucide-react"; +import { type LucideIcon, Info, Link2, Wallet } from "lucide-react"; import { useShallow } from "zustand/react/shallow"; import { KisAuthForm } from "@/features/settings/components/KisAuthForm"; import { KisProfileForm } from "@/features/settings/components/KisProfileForm"; import { useKisRuntimeStore } from "@/features/settings/store/use-kis-runtime-store"; +import { cn } from "@/lib/utils"; /** * @description 설정 페이지 컨테이너입니다. KIS 연결 상태와 인증 폼을 카드 UI로 제공합니다. @@ -28,54 +29,59 @@ export function SettingsContainer() { ); return ( -
- {/* ========== STATUS CARD ========== */} -
-

- 한국투자증권 연결 설정 -

-
- 연결 상태: - {isKisVerified ? ( - - - 연결됨 ({verifiedCredentials?.tradingEnv === "real" ? "실전" : "모의"}) - - ) : ( - - - 미연결 - - )} -
-
- 계좌 인증 상태: - {isKisProfileVerified ? ( - - 확인됨 ({maskAccountNo(verifiedAccountNo)}) - - ) : ( - - 미확인 - - )} -
-
+
+ {/* ========== SETTINGS OVERVIEW ========== */} +
+
+
+

+ 한국투자증권 연결 센터 +

+

+ 앱키 연결과 계좌 확인을 한 화면에서 처리합니다. 아래 순서대로 + 진행하면 바로 대시보드/트레이드 화면에 반영됩니다. +

+
+

+ 진행 순서: 1) 앱키 연결 확인 {"->"} 2) 계좌 인증 {"->"} 3) + 거래 화면 사용 +

+
+
- {/* ========== PRIVACY NOTICE ========== */} -
-

- - 입력 정보 보관 안내 -

-

- 이 화면에서 입력한 한국투자증권 앱키, 앱시크릿키, 계좌번호는 서버 DB에 저장하지 않습니다. - 현재 사용 중인 브라우저(클라이언트)에서만 관리되며, 연결 해제 시 즉시 지울 수 있습니다. -

+
+ + + +
+
{/* ========== FORM GRID ========== */} -
+
@@ -96,3 +102,40 @@ function maskAccountNo(value: string | null) { return "********-**"; } +type StatusTileTone = "success" | "idle" | "notice"; + +/** + * @description 설정 페이지 상단 요약 상태 타일입니다. + * @see features/settings/components/SettingsContainer.tsx 상태 요약 렌더링 + */ +function StatusTile({ + icon: Icon, + title, + value, + tone, +}: { + icon: LucideIcon; + title: string; + value: string; + tone: StatusTileTone; +}) { + return ( +
+

+ + {title} +

+

{value}

+
+ ); +} diff --git a/features/settings/store/use-kis-runtime-store.ts b/features/settings/store/use-kis-runtime-store.ts index b0aa8e3..cf91602 100644 --- a/features/settings/store/use-kis-runtime-store.ts +++ b/features/settings/store/use-kis-runtime-store.ts @@ -56,6 +56,7 @@ interface KisRuntimeStoreActions { invalidateKisVerification: () => void; clearKisRuntimeSession: (tradingEnv: KisTradingEnv) => void; getOrFetchWsConnection: () => Promise; + clearWsConnectionCache: () => void; setHasHydrated: (state: boolean) => void; } @@ -224,6 +225,13 @@ export const useKisRuntimeStore = create< return wsConnectionPromise; }, + clearWsConnectionCache: () => { + wsConnectionPromise = null; + set({ + wsApprovalKey: null, + wsUrl: null, + }); + }, setHasHydrated: (state) => { set({ _hasHydrated: state, diff --git a/features/trade/components/TradeContainer.tsx b/features/trade/components/TradeContainer.tsx index 9222639..499f300 100644 --- a/features/trade/components/TradeContainer.tsx +++ b/features/trade/components/TradeContainer.tsx @@ -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(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; diff --git a/features/trade/components/chart/StockLineChart.tsx b/features/trade/components/chart/StockLineChart.tsx index 4b13caa..28065a5 100644 --- a/features/trade/components/chart/StockLineChart.tsx +++ b/features/trade/components/chart/StockLineChart.tsx @@ -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({
); } + +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; +} diff --git a/features/trade/components/chart/chart-utils.ts b/features/trade/components/chart/chart-utils.ts index 122af67..4efbd55 100644 --- a/features/trade/components/chart/chart-utils.ts +++ b/features/trade/components/chart/chart-utils.ts @@ -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") { diff --git a/features/trade/hooks/useDomesticSession.ts b/features/trade/hooks/useDomesticSession.ts new file mode 100644 index 0000000..ab14c6d --- /dev/null +++ b/features/trade/hooks/useDomesticSession.ts @@ -0,0 +1,29 @@ +import { useState, useEffect } from "react"; +import { + resolveDomesticKisSession, + type DomesticKisSession, +} from "@/lib/kis/domestic-market-session"; + +// 클라이언트 환경에서 현재 시간을 기준으로 세션을 계산합니다. +// 매 30초마다 갱신됩니다. + +/** + * @description 국내 주식 시장의 세션(장 운영 상태)을 관리하는 훅입니다. + * client-side에서 로컬 스토리지를 확인하거나 기본 세션을 반환하며, + * 30초마다 세션 정보를 갱신하여 장 시작/마감/시간외 단일가 등 상태 변화를 감지합니다. + */ +export function useDomesticSession() { + const [marketSession, setMarketSession] = useState(() => + resolveDomesticKisSession(), + ); + + useEffect(() => { + const timerId = window.setInterval(() => { + const nextSession = resolveDomesticKisSession(); + setMarketSession((prev) => (prev === nextSession ? prev : nextSession)); + }, 30_000); + return () => window.clearInterval(timerId); + }, []); + + return marketSession; +} diff --git a/features/trade/hooks/useKisTradeWebSocket.ts b/features/trade/hooks/useKisTradeWebSocket.ts index 1e1201c..90753a8 100644 --- a/features/trade/hooks/useKisTradeWebSocket.ts +++ b/features/trade/hooks/useKisTradeWebSocket.ts @@ -1,156 +1,24 @@ -import { useEffect, useRef, useState } from "react"; -import { - type KisRuntimeCredentials, - useKisRuntimeStore, -} from "@/features/settings/store/use-kis-runtime-store"; +import { useEffect, useMemo } from "react"; +import { type KisRuntimeCredentials } 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 TRADE_TR_ID_OVERTIME_EXPECTED = "H0STOAC0"; -const TRADE_TR_ID_TOTAL = "H0UNCNT0"; -const TRADE_TR_ID_TOTAL_EXPECTED = "H0UNANC0"; -const ORDERBOOK_TR_ID = "H0STASP0"; -const ORDERBOOK_TR_ID_OVERTIME = "H0STOAA0"; -const MAX_TRADE_TICKS = 10; -const WS_DEBUG_STORAGE_KEY = "KIS_WS_DEBUG"; +import { resolveTradeTrIds } from "@/features/trade/utils/kisRealtimeUtils"; +import { useKisWebSocketStore } from "@/features/kis-realtime/stores/kisWebSocketStore"; +import { useDomesticSession } from "@/features/trade/hooks/useDomesticSession"; +import { useTradeTickSubscription } from "@/features/trade/hooks/useTradeTickSubscription"; +import { useOrderbookSubscription } from "@/features/trade/hooks/useOrderbookSubscription"; /** - * @description 장 구간/시장별 누락을 줄이기 위해 TR ID를 우선순위 배열로 반환합니다. - * @see features/trade/hooks/useKisTradeWebSocket.ts useEffect WebSocket 구독 등록 - * @see temp-kis-domestic-functions-ws.py ccnl_krx/ccnl_total/exp_ccnl_krx/exp_ccnl_total/overtime_ccnl_krx - */ -function resolveTradeTrIds( - env: KisRuntimeCredentials["tradingEnv"], - session: DomesticKisSession, -) { - if (env === "mock") return [TRADE_TR_ID]; - - if (shouldUseAfterHoursSinglePriceTr(session)) { - // 시간외 단일가(16:00~18:00): 전용 TR + 통합 TR 백업 - return uniqueTrIds([ - TRADE_TR_ID_OVERTIME, - TRADE_TR_ID_OVERTIME_EXPECTED, - TRADE_TR_ID_TOTAL, - TRADE_TR_ID_TOTAL_EXPECTED, - ]); - } - - if (shouldUseExpectedExecutionTr(session)) { - // 동시호가 구간(장전/장마감): 예상체결 TR을 우선, 일반체결/통합체결을 백업 - return uniqueTrIds([ - TRADE_TR_ID_EXPECTED, - TRADE_TR_ID_TOTAL_EXPECTED, - TRADE_TR_ID, - TRADE_TR_ID_TOTAL, - ]); - } - - if (session === "afterCloseFixedPrice") { - // 시간외 종가(15:40~16:00): 브로커별 라우팅 차이를 대비해 일반/시간외/통합 TR을 함께 구독 - return uniqueTrIds([ - TRADE_TR_ID, - TRADE_TR_ID_TOTAL, - TRADE_TR_ID_OVERTIME, - TRADE_TR_ID_OVERTIME_EXPECTED, - TRADE_TR_ID_TOTAL_EXPECTED, - ]); - } - - return uniqueTrIds([TRADE_TR_ID, TRADE_TR_ID_TOTAL]); -} - -/** - * @description 장 구간별 호가 TR ID 후보를 우선순위 배열로 반환합니다. - * @see features/trade/hooks/useKisTradeWebSocket.ts useEffect WebSocket 구독 등록 - * @see temp-kis-domestic-functions-ws.py asking_price_krx/asking_price_total/overtime_asking_price_krx - */ -function resolveOrderBookTrIds( - env: KisRuntimeCredentials["tradingEnv"], - session: DomesticKisSession, -) { - if (env === "mock") return [ORDERBOOK_TR_ID]; - - if (shouldUseAfterHoursSinglePriceTr(session)) { - // 시간외 단일가(16:00~18:00)는 KRX 전용 호가 TR만 구독합니다. - // 통합 TR(H0UNASP0)을 같이 구독하면 종목별로 포맷/잔량이 섞여 보일 수 있습니다. - return uniqueTrIds([ORDERBOOK_TR_ID_OVERTIME]); - } - - if (session === "afterCloseFixedPrice") { - return uniqueTrIds([ORDERBOOK_TR_ID_OVERTIME]); - } - - // UI 흐름: 호가창 UI -> useKisTradeWebSocket onmessage -> onOrderBookMessage - // -> TradeContainer setRealtimeOrderBook -> useOrderBook 병합 -> OrderBook 렌더 - // 장중에는 KRX 전용(H0STASP0)만 구독해 값이 번갈아 덮이는 현상을 방지합니다. - return uniqueTrIds([ORDERBOOK_TR_ID]); -} - -/** - * @description 콘솔 디버그 플래그를 확인합니다. - * @see features/trade/hooks/useKisTradeWebSocket.ts socket.onmessage - */ -function isWsDebugEnabled() { - if (typeof window === "undefined") return false; - - try { - return window.localStorage.getItem(WS_DEBUG_STORAGE_KEY) === "1"; - } catch { - return false; - } -} - -/** - * @description 실시간 웹소켓 제어(JSON) 메시지를 파싱합니다. - * @see features/trade/hooks/useKisTradeWebSocket.ts socket.onmessage - */ -function parseWsControlMessage(raw: string) { - if (!raw.startsWith("{")) return null; - - try { - return JSON.parse(raw) as { - header?: { tr_id?: string }; - body?: { rt_cd?: string; msg1?: string }; - }; - } catch { - return null; - } -} - -/** - * @description 실시간 원문에서 파이프 구분 TR ID를 빠르게 추출합니다. - * @see features/trade/hooks/useKisTradeWebSocket.ts socket.onmessage - */ -function peekPipeTrId(raw: string) { - const parts = raw.split("|"); - return parts.length > 1 ? parts[1] : ""; -} - -function uniqueTrIds(ids: string[]) { - return [...new Set(ids)]; -} - -/** - * @description Subscribes trade ticks and orderbook over one websocket. - * @see features/trade/components/TradeContainer.tsx - * @see lib/kis/domestic-market-session.ts + * @description KIS 실시간 매매/호가 데이터를 통합 구독하는 커스텀 훅입니다. + * @summary + * - `useDomesticSession`: 장 운영 시간(정규장, 시간외 등) 세션 관리 + * - `useTradeTickSubscription`: 실시간 체결가(Tick) 데이터 구독 및 상태 관리 + * - `useOrderbookSubscription`: 실시간 호가(Orderbook) 데이터 구독 + * - `useKisWebSocketStore`: 전역 웹소켓 연결 상태 및 에러 관리 + * + * 위 훅들을 조합(Composition)하여 트레이딩 화면에 필요한 모든 실시간 데이터를 제공합니다. */ export function useKisTradeWebSocket( symbol: string | undefined, @@ -162,305 +30,41 @@ export function useKisTradeWebSocket( onOrderBookMessage?: (data: DashboardStockOrderBookResponse) => void; }, ) { - const [latestTick, setLatestTick] = - useState(null); - const [recentTradeTicks, setRecentTradeTicks] = useState< - DashboardRealtimeTradeTick[] - >([]); - const [isConnected, setIsConnected] = useState(false); - const [error, setError] = useState(null); - const [lastTickAt, setLastTickAt] = useState(null); - const [marketSession, setMarketSession] = useState(() => - resolveSessionInClient(), + const marketSession = useDomesticSession(); + const { isConnected, error } = useKisWebSocketStore(); + + const { latestTick, recentTradeTicks, lastTickAt } = useTradeTickSubscription( + { + symbol, + isVerified, + credentials, + marketSession, + onTick, + }, ); - const socketRef = useRef(null); - const approvalKeyRef = useRef(null); - const seenTickRef = useRef>(new Set()); - - const obSymbol = options?.orderBookSymbol; - const onOrderBookMsg = options?.onOrderBookMessage; - const realtimeTrIds = credentials - ? resolveTradeTrIds(credentials.tradingEnv, marketSession) - : [TRADE_TR_ID]; - const realtimeTrId = credentials - ? realtimeTrIds[0] ?? TRADE_TR_ID - : 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 debugEnabled = isWsDebugEnabled(); - - const tradeTrIds = resolveTradeTrIds(credentials.tradingEnv, marketSession); - const orderBookTrIds = - obSymbol && onOrderBookMsg - ? resolveOrderBookTrIds(credentials.tradingEnv, marketSession) - : []; - - const subscribe = ( - key: string, - targetSymbol: string, - trId: string, - trType: "1" | "2", - ) => { - socket?.send( - JSON.stringify(buildKisRealtimeMessage(key, targetSymbol, trId, trType)), - ); - }; - - 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; - - // 공식 샘플과 동일하게 /tryitout 엔드포인트로 연결하고, TR은 payload로 구독합니다. - socket = new WebSocket(`${wsConnection.wsUrl}/tryitout`); - socketRef.current = socket; - - socket.onopen = () => { - if (disposed || !approvalKeyRef.current) return; - - for (const trId of tradeTrIds) { - subscribe(approvalKeyRef.current, symbol, trId, "1"); - } - - if (obSymbol) { - for (const trId of orderBookTrIds) { - subscribe(approvalKeyRef.current, obSymbol, trId, "1"); - } - } - - if (debugEnabled) { - console.info("[KisRealtime] Subscribed", { - symbol, - marketSession, - tradeTrIds, - orderBookSymbol: obSymbol ?? null, - orderBookTrIds, - }); - } - - setIsConnected(true); - }; - - socket.onmessage = (event) => { - if (disposed || typeof event.data !== "string") return; - - const control = parseWsControlMessage(event.data); - if (control) { - const trId = control.header?.tr_id ?? ""; - if (trId === "PINGPONG") { - // 서버 Keepalive에 응답하지 않으면 연결이 끊길 수 있습니다. - socket?.send(event.data); - return; - } - - if (debugEnabled) { - console.info("[KisRealtime] Control", { - trId, - rt_cd: control.body?.rt_cd, - message: control.body?.msg1, - }); - } - return; - } - - if (obSymbol && onOrderBookMsg) { - const orderBook = parseKisRealtimeOrderbook(event.data, obSymbol); - if (orderBook) { - orderBook.tradingEnv = credentials.tradingEnv; - if (debugEnabled) { - console.debug("[KisRealtime] OrderBook", { - trId: peekPipeTrId(event.data), - symbol: orderBook.symbol, - businessHour: orderBook.businessHour, - hourClassCode: orderBook.hourClassCode, - }); - } - onOrderBookMsg(orderBook); - return; - } - } - - const ticks = parseKisRealtimeTickBatch(event.data, symbol); - if (ticks.length === 0) { - if (debugEnabled && event.data.includes("|")) { - console.debug("[KisRealtime] Unparsed payload", { - trId: peekPipeTrId(event.data), - preview: event.data.slice(0, 220), - }); - } - return; - } - - const meaningfulTicks = ticks.filter((tick) => tick.tradeVolume > 0); - if (meaningfulTicks.length === 0) { - if (debugEnabled) { - console.debug("[KisRealtime] Ignored zero-volume ticks", { - trId: peekPipeTrId(event.data), - parsedCount: ticks.length, - }); - } - return; - } - - const dedupedTicks = meaningfulTicks.filter((tick) => { - const key = `${tick.tickTime}-${tick.price}-${tick.tradeVolume}`; - if (seenTickRef.current.has(key)) return false; - seenTickRef.current.add(key); - if (seenTickRef.current.size > 5_000) { - seenTickRef.current.clear(); - } - return true; - }); - - const latest = meaningfulTicks[meaningfulTicks.length - 1]; - setLatestTick(latest); - - if (debugEnabled) { - console.debug("[KisRealtime] Tick", { - trId: peekPipeTrId(event.data), - symbol: latest.symbol, - tickTime: latest.tickTime, - price: latest.price, - tradeVolume: latest.tradeVolume, - executionClassCode: latest.executionClassCode, - buyExecutionCount: latest.buyExecutionCount, - sellExecutionCount: latest.sellExecutionCount, - netBuyExecutionCount: latest.netBuyExecutionCount, - parsedCount: ticks.length, - }); - } - - 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) { - if (debugEnabled) { - console.warn("[KisRealtime] WebSocket error", { - symbol, - marketSession, - tradeTrIds, - }); - } - setIsConnected(false); - } - }; - - socket.onclose = () => { - if (!disposed) { - if (debugEnabled) { - console.warn("[KisRealtime] WebSocket closed", { - symbol, - marketSession, - tradeTrIds, - }); - } - 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) { - for (const trId of tradeTrIds) { - subscribe(key, symbol, trId, "2"); - } - - if (obSymbol) { - for (const trId of orderBookTrIds) { - subscribe(key, obSymbol, trId, "2"); - } - } - } - - socket?.close(); - if (socketRef.current === socket) socketRef.current = null; - approvalKeyRef.current = null; - seenRef.clear(); - }; - }, [ - symbol, + useOrderbookSubscription({ + symbol: options?.orderBookSymbol, isVerified, credentials, marketSession, - onTick, - obSymbol, - onOrderBookMsg, - ]); + onOrderBookMessage: options?.onOrderBookMessage, + }); + + // Connection/Data warning + useEffect(() => { + if (!isConnected || lastTickAt || !symbol) return; + const timer = window.setTimeout(() => { + // Just a warning, not blocking + }, 8000); + return () => window.clearTimeout(timer); + }, [isConnected, lastTickAt, symbol]); + + const realtimeTrId = useMemo(() => { + if (!credentials) return "H0STCNT0"; + const ids = resolveTradeTrIds(credentials.tradingEnv, marketSession); + return ids[0] ?? "H0STCNT0"; + }, [credentials, marketSession]); return { latestTick, @@ -471,18 +75,3 @@ export function useKisTradeWebSocket( 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(); - } -} diff --git a/features/trade/hooks/useOrderBook.ts b/features/trade/hooks/useOrderBook.ts index f74efa8..7e7d5cf 100644 --- a/features/trade/hooks/useOrderBook.ts +++ b/features/trade/hooks/useOrderBook.ts @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from "react"; +import { useEffect, useMemo, 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"; @@ -29,9 +29,22 @@ export function useOrderBook( const [initialData, setInitialData] = useState(null); + const [lastRealtimeWithLevels, setLastRealtimeWithLevels] = + useState(null); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); + useEffect(() => { + if (!isRequestEnabled) { + setLastRealtimeWithLevels(null); + return; + } + + if (externalRealtimeOrderBook && hasOrderBookLevelData(externalRealtimeOrderBook)) { + setLastRealtimeWithLevels(externalRealtimeOrderBook); + } + }, [externalRealtimeOrderBook, isRequestEnabled]); + useEffect(() => { if (!isRequestEnabled || !symbol || !credentials) { return; @@ -75,10 +88,67 @@ export function useOrderBook( }; }, [isRequestEnabled, symbol, credentials]); - // 외부 실시간 호가 → 초기 데이터 → null 순 우선 - const orderBook = isRequestEnabled - ? (externalRealtimeOrderBook ?? initialData) - : null; + // 외부 실시간 호가가 비어 있으면(가격/잔량 레벨 0) 마지막 정상 실시간 또는 REST 초기값을 유지합니다. + const orderBook = useMemo(() => { + if (!isRequestEnabled) return null; + + if (externalRealtimeOrderBook) { + if (hasOrderBookLevelData(externalRealtimeOrderBook)) { + return externalRealtimeOrderBook; + } + + if (lastRealtimeWithLevels) { + return { + ...lastRealtimeWithLevels, + totalAskSize: externalRealtimeOrderBook.totalAskSize, + totalBidSize: externalRealtimeOrderBook.totalBidSize, + anticipatedPrice: externalRealtimeOrderBook.anticipatedPrice, + anticipatedVolume: externalRealtimeOrderBook.anticipatedVolume, + anticipatedTotalVolume: externalRealtimeOrderBook.anticipatedTotalVolume, + anticipatedChange: externalRealtimeOrderBook.anticipatedChange, + anticipatedChangeSign: externalRealtimeOrderBook.anticipatedChangeSign, + anticipatedChangeRate: externalRealtimeOrderBook.anticipatedChangeRate, + accumulatedVolume: externalRealtimeOrderBook.accumulatedVolume, + totalAskSizeDelta: externalRealtimeOrderBook.totalAskSizeDelta, + totalBidSizeDelta: externalRealtimeOrderBook.totalBidSizeDelta, + businessHour: + externalRealtimeOrderBook.businessHour ?? lastRealtimeWithLevels.businessHour, + hourClassCode: + externalRealtimeOrderBook.hourClassCode ?? lastRealtimeWithLevels.hourClassCode, + fetchedAt: externalRealtimeOrderBook.fetchedAt, + tradingEnv: externalRealtimeOrderBook.tradingEnv, + source: externalRealtimeOrderBook.source, + }; + } + + if (initialData && hasOrderBookLevelData(initialData)) { + return { + ...initialData, + totalAskSize: externalRealtimeOrderBook.totalAskSize, + totalBidSize: externalRealtimeOrderBook.totalBidSize, + anticipatedPrice: externalRealtimeOrderBook.anticipatedPrice, + anticipatedVolume: externalRealtimeOrderBook.anticipatedVolume, + anticipatedTotalVolume: externalRealtimeOrderBook.anticipatedTotalVolume, + anticipatedChange: externalRealtimeOrderBook.anticipatedChange, + anticipatedChangeSign: externalRealtimeOrderBook.anticipatedChangeSign, + anticipatedChangeRate: externalRealtimeOrderBook.anticipatedChangeRate, + accumulatedVolume: externalRealtimeOrderBook.accumulatedVolume, + totalAskSizeDelta: externalRealtimeOrderBook.totalAskSizeDelta, + totalBidSizeDelta: externalRealtimeOrderBook.totalBidSizeDelta, + businessHour: externalRealtimeOrderBook.businessHour ?? initialData.businessHour, + hourClassCode: externalRealtimeOrderBook.hourClassCode ?? initialData.hourClassCode, + fetchedAt: externalRealtimeOrderBook.fetchedAt, + tradingEnv: externalRealtimeOrderBook.tradingEnv, + source: externalRealtimeOrderBook.source, + }; + } + + return externalRealtimeOrderBook; + } + + return initialData; + }, [externalRealtimeOrderBook, initialData, isRequestEnabled, lastRealtimeWithLevels]); + const mergedError = isRequestEnabled ? error : null; const mergedLoading = isRequestEnabled ? isLoading && !orderBook : false; @@ -89,3 +159,13 @@ export function useOrderBook( isWsConnected: !!externalRealtimeOrderBook, }; } + +function hasOrderBookLevelData(orderBook: DashboardStockOrderBookResponse) { + return orderBook.levels.some( + (level) => + level.askPrice > 0 || + level.bidPrice > 0 || + level.askSize > 0 || + level.bidSize > 0, + ); +} diff --git a/features/trade/hooks/useOrderbookSubscription.ts b/features/trade/hooks/useOrderbookSubscription.ts new file mode 100644 index 0000000..3a76399 --- /dev/null +++ b/features/trade/hooks/useOrderbookSubscription.ts @@ -0,0 +1,63 @@ +import { useRef, useEffect } from "react"; +import type { KisRuntimeCredentials } from "@/features/settings/store/use-kis-runtime-store"; +import type { DashboardStockOrderBookResponse } from "@/features/trade/types/trade.types"; +import { useKisWebSocketStore } from "@/features/kis-realtime/stores/kisWebSocketStore"; +import { + parseKisRealtimeOrderbook, + resolveOrderBookTrIds, +} from "@/features/trade/utils/kisRealtimeUtils"; +import type { DomesticKisSession } from "@/lib/kis/domestic-market-session"; + +interface UseOrderbookSubscriptionParams { + symbol: string | undefined; // orderBookSymbol + isVerified: boolean; + credentials: KisRuntimeCredentials | null; + marketSession: DomesticKisSession; + onOrderBookMessage?: (data: DashboardStockOrderBookResponse) => void; +} + +/** + * @description 실시간 호가(Orderbook) 구독 로직을 담당하는 훅입니다. + * - 호가 데이터는 빈도가 매우 높으므로 별도의 상태(state)에 저장하지 않고, + * - 콜백 함수(onOrderBookMessage)를 통해 상위 컴포넌트로 데이터를 직접 전달합니다. + * - 이를 통해 불필요한 리렌더링을 방지합니다. + */ +export function useOrderbookSubscription({ + symbol, + isVerified, + credentials, + marketSession, + onOrderBookMessage, +}: UseOrderbookSubscriptionParams) { + const { subscribe, connect } = useKisWebSocketStore(); + const onOrderBookMessageRef = useRef(onOrderBookMessage); + + useEffect(() => { + onOrderBookMessageRef.current = onOrderBookMessage; + }, [onOrderBookMessage]); + + useEffect(() => { + if (!symbol || !isVerified || !credentials) return; + + connect(); + + const trIds = resolveOrderBookTrIds(credentials.tradingEnv, marketSession); + const unsubscribers: Array<() => void> = []; + + const handleOrderBookMessage = (data: string) => { + const ob = parseKisRealtimeOrderbook(data, symbol); + if (ob) { + ob.tradingEnv = credentials.tradingEnv; + onOrderBookMessageRef.current?.(ob); + } + }; + + for (const trId of trIds) { + unsubscribers.push(subscribe(trId, symbol, handleOrderBookMessage)); + } + + return () => { + unsubscribers.forEach((unsub) => unsub()); + }; + }, [symbol, isVerified, credentials, marketSession, connect, subscribe]); +} diff --git a/features/trade/hooks/useTradeTickSubscription.ts b/features/trade/hooks/useTradeTickSubscription.ts new file mode 100644 index 0000000..cb01d5c --- /dev/null +++ b/features/trade/hooks/useTradeTickSubscription.ts @@ -0,0 +1,110 @@ +import { useState, useRef, useEffect } from "react"; +import type { KisRuntimeCredentials } from "@/features/settings/store/use-kis-runtime-store"; +import type { DashboardRealtimeTradeTick } from "@/features/trade/types/trade.types"; +import { useKisWebSocketStore } from "@/features/kis-realtime/stores/kisWebSocketStore"; +import { + parseKisRealtimeTickBatch, + resolveTradeTrIds, +} from "@/features/trade/utils/kisRealtimeUtils"; +import type { DomesticKisSession } from "@/lib/kis/domestic-market-session"; + +const MAX_TRADE_TICKS = 10; + +interface UseTradeTickSubscriptionParams { + symbol: string | undefined; + isVerified: boolean; + credentials: KisRuntimeCredentials | null; + marketSession: DomesticKisSession; + onTick?: (tick: DashboardRealtimeTradeTick) => void; +} + +/** + * @description 실시간 체결가(Tick) 구독 로직을 담당하는 훅입니다. + * - 웹소켓을 통해 들어오는 체결 데이터를 파싱(parsing)하고 + * - 중복 데이터(deduplication)를 필터링하며 + * - 최근 N개의 체결 내역(recentTradeTicks)과 최신 체결가(latestTick) 상태를 관리합니다. + */ +export function useTradeTickSubscription({ + symbol, + isVerified, + credentials, + marketSession, + onTick, +}: UseTradeTickSubscriptionParams) { + const [latestTick, setLatestTick] = + useState(null); + const [recentTradeTicks, setRecentTradeTicks] = useState< + DashboardRealtimeTradeTick[] + >([]); + const [lastTickAt, setLastTickAt] = useState(null); + const seenTickRef = useRef>(new Set()); + + const { subscribe, connect } = useKisWebSocketStore(); + const onTickRef = useRef(onTick); + + useEffect(() => { + onTickRef.current = onTick; + }, [onTick]); + + // 1. 심볼이 변경되면 상태를 초기화합니다. + // 1. 심볼 변경 시 상태 초기화 (Render-time adjustment) + const [prevSymbol, setPrevSymbol] = useState(symbol); + if (symbol !== prevSymbol) { + setPrevSymbol(symbol); + setLatestTick(null); + setRecentTradeTicks([]); + setLastTickAt(null); + } + + // Ref는 렌더링 도중 수정하면 안 되므로 useEffect에서 초기화 + useEffect(() => { + seenTickRef.current.clear(); + }, [symbol]); + + // 2. 실시간 데이터 구독 + useEffect(() => { + if (!symbol || !isVerified || !credentials) return; + + connect(); + + const trIds = resolveTradeTrIds(credentials.tradingEnv, marketSession); + const unsubscribers: Array<() => void> = []; + + const handleTradeMessage = (data: string) => { + const ticks = parseKisRealtimeTickBatch(data, symbol); + if (ticks.length === 0) return; + + const meaningfulTicks = ticks.filter((tick) => tick.tradeVolume > 0); + if (meaningfulTicks.length === 0) return; + + const dedupedTicks = meaningfulTicks.filter((tick) => { + const key = `${tick.tickTime}-${tick.price}-${tick.tradeVolume}`; + if (seenTickRef.current.has(key)) return false; + seenTickRef.current.add(key); + if (seenTickRef.current.size > 5_000) seenTickRef.current.clear(); + return true; + }); + + const latest = meaningfulTicks[meaningfulTicks.length - 1]; + setLatestTick(latest); + setLastTickAt(Date.now()); + + if (dedupedTicks.length > 0) { + setRecentTradeTicks((prev) => + [...dedupedTicks.reverse(), ...prev].slice(0, MAX_TRADE_TICKS), + ); + } + onTickRef.current?.(latest); + }; + + for (const trId of trIds) { + unsubscribers.push(subscribe(trId, symbol, handleTradeMessage)); + } + + return () => { + unsubscribers.forEach((unsub) => unsub()); + }; + }, [symbol, isVerified, credentials, marketSession, connect, subscribe]); + + return { latestTick, recentTradeTicks, lastTickAt }; +} diff --git a/features/trade/utils/kis-realtime.utils.ts b/features/trade/utils/kisRealtimeUtils.ts similarity index 67% rename from features/trade/utils/kis-realtime.utils.ts rename to features/trade/utils/kisRealtimeUtils.ts index dad0b49..249a0c1 100644 --- a/features/trade/utils/kis-realtime.utils.ts +++ b/features/trade/utils/kisRealtimeUtils.ts @@ -2,6 +2,11 @@ import type { DashboardRealtimeTradeTick, DashboardStockOrderBookResponse, } from "@/features/trade/types/trade.types"; +import { + shouldUseAfterHoursSinglePriceTr, + shouldUseExpectedExecutionTr, + type DomesticKisSession, +} from "@/lib/kis/domestic-market-session"; const REALTIME_SIGN_NEGATIVE = new Set(["4", "5"]); const EXECUTED_REALTIME_TRADE_TR_IDS = new Set([ @@ -38,55 +43,28 @@ const TICK_FIELD_INDEX = { executionClassCode: 21, } as const; -/** - * @description KIS 실시간 구독/해제 소켓 메시지를 생성합니다. - * @see features/trade/hooks/useKisTradeWebSocket.ts 구독/해제 요청 payload 생성에 사용됩니다. - */ -export 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, - }, - }, - }; -} - -/** - * @description 실시간 체결 스트림(raw)을 배열 단위로 파싱합니다. - * - 배치 전송(복수 체결) 데이터를 모두 추출합니다. - * - 종목 불일치 또는 가격 0 이하 데이터는 제외합니다. - * @see features/trade/hooks/useKisTradeWebSocket.ts onmessage 이벤트에서 체결 패킷 파싱에 사용됩니다. - */ +const TRADE_TR_ID = "H0STCNT0"; +const TRADE_TR_ID_EXPECTED = "H0STANC0"; +const TRADE_TR_ID_OVERTIME = "H0STOUP0"; +const TRADE_TR_ID_OVERTIME_EXPECTED = "H0STOAC0"; +const TRADE_TR_ID_TOTAL = "H0UNCNT0"; +const TRADE_TR_ID_TOTAL_EXPECTED = "H0UNANC0"; +const ORDERBOOK_TR_ID = "H0STASP0"; +const ORDERBOOK_TR_ID_OVERTIME = "H0STOAA0"; export function parseKisRealtimeTickBatch(raw: string, expectedSymbol: string) { if (!/^([01])\|/.test(raw)) return [] as DashboardRealtimeTradeTick[]; const parts = raw.split("|"); if (parts.length < 4) return [] as DashboardRealtimeTradeTick[]; - // TR ID check: regular tick / expected tick / after-hours tick. const receivedTrId = parts[1]; const isExecutedTick = EXECUTED_REALTIME_TRADE_TR_IDS.has(receivedTrId); const isExpectedTick = EXPECTED_REALTIME_TRADE_TR_IDS.has(receivedTrId); - // 체결 화면에는 "실제 체결 TR"만 반영하고 예상체결 TR은 제외합니다. + if (!isExecutedTick || isExpectedTick) { return [] as DashboardRealtimeTradeTick[]; } - // if (parts[1] !== expectedTrId) return [] as DashboardRealtimeTradeTick[]; - const tickCount = Number(parts[2] ?? "1"); const values = parts[3].split("^"); if (values.length === 0) return [] as DashboardRealtimeTradeTick[]; @@ -164,9 +142,91 @@ export function parseKisRealtimeTickBatch(raw: string, expectedSymbol: string) { return ticks; } +/** + * @description 장 구간/시장별 누락을 줄이기 위해 TR ID를 우선순위 배열로 반환합니다. + */ +export function resolveTradeTrIds( + env: "real" | "mock", + session: DomesticKisSession, +) { + if (env === "mock") return [TRADE_TR_ID]; + + if (shouldUseAfterHoursSinglePriceTr(session)) { + return uniqueTrIds([ + TRADE_TR_ID_OVERTIME, + TRADE_TR_ID_OVERTIME_EXPECTED, + TRADE_TR_ID_TOTAL, + TRADE_TR_ID_TOTAL_EXPECTED, + ]); + } + + if (shouldUseExpectedExecutionTr(session)) { + return uniqueTrIds([ + TRADE_TR_ID_EXPECTED, + TRADE_TR_ID_TOTAL_EXPECTED, + TRADE_TR_ID, + TRADE_TR_ID_TOTAL, + ]); + } + + if (session === "afterCloseFixedPrice") { + return uniqueTrIds([ + TRADE_TR_ID, + TRADE_TR_ID_TOTAL, + TRADE_TR_ID_OVERTIME, + TRADE_TR_ID_OVERTIME_EXPECTED, + TRADE_TR_ID_TOTAL_EXPECTED, + ]); + } + + return uniqueTrIds([TRADE_TR_ID, TRADE_TR_ID_TOTAL]); +} + +/** + * @description 장 구간별 호가 TR ID 후보를 우선순위 배열로 반환합니다. + */ +export function resolveOrderBookTrIds( + env: "real" | "mock", + session: DomesticKisSession, +) { + if (env === "mock") return [ORDERBOOK_TR_ID]; + + if (shouldUseAfterHoursSinglePriceTr(session)) { + return uniqueTrIds([ORDERBOOK_TR_ID_OVERTIME, ORDERBOOK_TR_ID]); + } + + if (session === "afterCloseFixedPrice") { + return uniqueTrIds([ORDERBOOK_TR_ID, ORDERBOOK_TR_ID_OVERTIME]); + } + + return uniqueTrIds([ORDERBOOK_TR_ID]); +} + +/** + * @description 호가 패킷이 실제 표시 가능한 값(호가/잔량/총잔량)을 포함하는지 확인합니다. + */ +export function hasMeaningfulOrderBookPayload( + data: DashboardStockOrderBookResponse, +) { + const hasLevelData = data.levels.some( + (level) => + level.askPrice > 0 || + level.bidPrice > 0 || + level.askSize > 0 || + level.bidSize > 0, + ); + + const hasSummaryData = + data.totalAskSize > 0 || + data.totalBidSize > 0 || + (data.anticipatedPrice ?? 0) > 0 || + (data.accumulatedVolume ?? 0) > 0; + + return hasLevelData || hasSummaryData; +} + /** * @description KIS 실시간 호가(H0STASP0/H0UNASP0/H0STOAA0)를 OrderBook 구조로 파싱합니다. - * @see features/trade/hooks/useKisTradeWebSocket.ts onOrderBookMessage 콜백 데이터 생성에 사용됩니다. */ export function parseKisRealtimeOrderbook( raw: string, @@ -182,7 +242,9 @@ export function parseKisRealtimeOrderbook( } const values = parts[3].split("^"); - const levelCount = trId === "H0STOAA0" ? 9 : 10; + // 시간외(H0STOAA0)는 문서 버전에 따라 9레벨/10레벨이 혼재할 수 있어 + // payload 길이로 레벨 수를 동적으로 판별합니다. + const levelCount = trId === "H0STOAA0" ? (values.length >= 56 ? 10 : 9) : 10; const symbol = values[0]?.trim() ?? ""; const normalizedSymbol = normalizeDomesticSymbol(symbol); @@ -195,7 +257,9 @@ export function parseKisRealtimeOrderbook( const bidSizeStart = askSizeStart + levelCount; const totalAskIndex = bidSizeStart + levelCount; const totalBidIndex = totalAskIndex + 1; - const anticipatedPriceIndex = totalBidIndex + 3; + const overtimeTotalAskIndex = totalBidIndex + 1; + const overtimeTotalBidIndex = overtimeTotalAskIndex + 1; + const anticipatedPriceIndex = overtimeTotalBidIndex + 1; const anticipatedVolumeIndex = anticipatedPriceIndex + 1; const anticipatedTotalVolumeIndex = anticipatedPriceIndex + 2; const anticipatedChangeIndex = anticipatedPriceIndex + 3; @@ -215,10 +279,18 @@ export function parseKisRealtimeOrderbook( bidSize: readNumber(values, bidSizeStart + i), })); + const regularTotalAskSize = readNumber(values, totalAskIndex); + const regularTotalBidSize = readNumber(values, totalBidIndex); + const overtimeTotalAskSize = readNumber(values, overtimeTotalAskIndex); + const overtimeTotalBidSize = readNumber(values, overtimeTotalBidIndex); + return { symbol: normalizedExpected, - totalAskSize: readNumber(values, totalAskIndex), - totalBidSize: readNumber(values, totalBidIndex), + // 장후 시간외에서는 일반 총잔량이 0이고 OVTM 총잔량만 채워지는 경우가 있습니다. + totalAskSize: + regularTotalAskSize > 0 ? regularTotalAskSize : overtimeTotalAskSize, + totalBidSize: + regularTotalBidSize > 0 ? regularTotalBidSize : overtimeTotalBidSize, businessHour: readString(values, 1), hourClassCode: readString(values, 2), anticipatedPrice: readNumber(values, anticipatedPriceIndex), @@ -239,7 +311,6 @@ export function parseKisRealtimeOrderbook( /** * @description 국내 종목코드 비교를 위해 접두 문자를 제거하고 6자리 코드로 정규화합니다. - * @see features/trade/utils/kis-realtime.utils.ts parseKisRealtimeOrderbook 종목 매칭 비교에 사용됩니다. */ function normalizeDomesticSymbol(value: string) { const trimmed = value.trim(); @@ -261,3 +332,7 @@ function readNumber(values: string[], index: number) { const value = Number(raw); return Number.isFinite(value) ? value : 0; } + +function uniqueTrIds(ids: string[]) { + return [...new Set(ids)]; +} diff --git a/lib/kis/dashboard.ts b/lib/kis/dashboard.ts index 05ba248..d471c15 100644 --- a/lib/kis/dashboard.ts +++ b/lib/kis/dashboard.ts @@ -14,6 +14,7 @@ interface KisBalanceOutput1Row { prdt_name?: string; hldg_qty?: string; pchs_avg_pric?: string; + pchs_amt?: string; prpr?: string; evlu_amt?: string; evlu_pfls_amt?: string; @@ -24,6 +25,8 @@ interface KisBalanceOutput2Row { dnca_tot_amt?: string; tot_evlu_amt?: string; scts_evlu_amt?: string; + nass_amt?: string; + pchs_amt_smtl_amt?: string; evlu_amt_smtl_amt?: string; evlu_pfls_smtl_amt?: string; asst_icdc_erng_rt?: string; @@ -92,12 +95,15 @@ interface KisPeriodTradeProfitOutput2Row { export interface DomesticBalanceSummary { totalAmount: number; cashBalance: number; + totalDepositAmount: number; totalProfitLoss: number; totalProfitRate: number; netAssetAmount: number; evaluationAmount: number; purchaseAmount: number; loanAmount: number; + apiReportedTotalAmount: number; + apiReportedNetAssetAmount: number; } export interface DomesticHoldingItem { @@ -230,73 +236,142 @@ export async function getDomesticDashboardBalance( const holdingRows = parseRows(balanceResponse.output1); const summaryRow = parseFirstRow(balanceResponse.output2); - const holdings = holdingRows + const normalizedHoldings = holdingRows .map((row) => { const symbol = (row.pdno ?? "").trim(); if (!/^\d{6}$/.test(symbol)) return null; + const quantity = toNumber(row.hldg_qty); + const averagePrice = toNumber(row.pchs_avg_pric); + const currentPrice = toNumber(row.prpr); + const purchaseAmountBase = pickPreferredAmount( + toOptionalNumber(row.pchs_amt), + quantity * averagePrice, + ); + const evaluationAmount = pickPreferredAmount( + toOptionalNumber(row.evlu_amt), + quantity * (currentPrice > 0 ? currentPrice : averagePrice), + ); + const profitLoss = pickNonZeroNumber( + toOptionalNumber(row.evlu_pfls_amt), + evaluationAmount - purchaseAmountBase, + ); + const profitRate = pickNonZeroNumber( + toOptionalNumber(row.evlu_pfls_rt), + calcProfitRateByPurchase(profitLoss, purchaseAmountBase), + ); return { symbol, name: (row.prdt_name ?? "").trim() || symbol, market: MARKET_BY_SYMBOL.get(symbol) ?? "KOSPI", - quantity: toNumber(row.hldg_qty), - averagePrice: toNumber(row.pchs_avg_pric), - currentPrice: toNumber(row.prpr), - evaluationAmount: toNumber(row.evlu_amt), - profitLoss: toNumber(row.evlu_pfls_amt), - profitRate: toNumber(row.evlu_pfls_rt), - } satisfies DomesticHoldingItem; + quantity, + averagePrice, + currentPrice, + evaluationAmount, + profitLoss, + profitRate, + purchaseAmountBase, + } satisfies DomesticHoldingItem & { purchaseAmountBase: number }; }) - .filter((item): item is DomesticHoldingItem => Boolean(item)); + .filter( + (item): item is DomesticHoldingItem & { purchaseAmountBase: number } => + Boolean(item), + ); + const holdings = normalizedHoldings.map((item) => ({ + symbol: item.symbol, + name: item.name, + market: item.market, + quantity: item.quantity, + averagePrice: item.averagePrice, + currentPrice: item.currentPrice, + evaluationAmount: item.evaluationAmount, + profitLoss: item.profitLoss, + profitRate: item.profitRate, + })); - const cashBalance = firstPositiveNumber( - toNumber(accountBalanceResponse?.tot_dncl_amt), - toNumber(accountBalanceResponse?.dncl_amt), - toNumber(summaryRow?.dnca_tot_amt), + const holdingsEvalAmount = sumNumbers( + normalizedHoldings.map((item) => item.evaluationAmount), ); - const holdingsEvalAmount = sumNumbers(holdings.map((item) => item.evaluationAmount)); - const evaluationAmount = firstPositiveNumber( - toNumber(accountBalanceResponse?.evlu_amt_smtl), - toNumber(summaryRow?.scts_evlu_amt), - toNumber(summaryRow?.evlu_amt_smtl_amt), + const holdingsPurchaseAmount = sumNumbers( + normalizedHoldings.map((item) => item.purchaseAmountBase), + ); + const evaluationAmount = pickPreferredAmount( + toOptionalNumber(accountBalanceResponse?.evlu_amt_smtl), + toOptionalNumber(summaryRow?.scts_evlu_amt), + toOptionalNumber(summaryRow?.evlu_amt_smtl_amt), holdingsEvalAmount, ); - const purchaseAmount = firstPositiveNumber( - toNumber(accountBalanceResponse?.pchs_amt_smtl), - Math.max(evaluationAmount - sumNumbers(holdings.map((item) => item.profitLoss)), 0), + const apiReportedTotalAmount = pickPreferredAmount( + toOptionalNumber(accountBalanceResponse?.tot_asst_amt), + toOptionalNumber(summaryRow?.tot_evlu_amt), ); - const loanAmount = firstPositiveNumber(toNumber(accountBalanceResponse?.loan_amt_smtl)); - const netAssetAmount = firstPositiveNumber( - toNumber(accountBalanceResponse?.nass_tot_amt), - evaluationAmount + cashBalance - loanAmount, + const apiReportedNetAssetAmount = pickPreferredAmount( + toOptionalNumber(accountBalanceResponse?.nass_tot_amt), + toOptionalNumber(summaryRow?.nass_amt), ); - const totalAmount = firstPositiveNumber( - toNumber(accountBalanceResponse?.tot_asst_amt), - netAssetAmount + loanAmount, + const cashBalance = resolveCashBalance({ + apiReportedTotalAmount, + apiReportedNetAssetAmount, + evaluationAmount, + cashCandidates: [ + toOptionalNumber(summaryRow?.dnca_tot_amt), + toOptionalNumber(accountBalanceResponse?.dncl_amt), + toOptionalNumber(accountBalanceResponse?.tot_dncl_amt), + ], + }); + const totalDepositAmount = pickPreferredAmount( + toOptionalNumber(summaryRow?.dnca_tot_amt), + toOptionalNumber(accountBalanceResponse?.tot_dncl_amt), + toOptionalNumber(accountBalanceResponse?.dncl_amt), + cashBalance, + ); + const purchaseAmount = pickPreferredAmount( + toOptionalNumber(accountBalanceResponse?.pchs_amt_smtl), + toOptionalNumber(summaryRow?.pchs_amt_smtl_amt), + holdingsPurchaseAmount, + Math.max( + evaluationAmount - sumNumbers(normalizedHoldings.map((item) => item.profitLoss)), + 0, + ), + ); + const loanAmount = pickPreferredAmount( + toOptionalNumber(accountBalanceResponse?.loan_amt_smtl), + ); + // KIS 원본 총자산(대출/미수 포함 가능)과 순자산(실제 체감 자산)을 분리해 관리합니다. + const grossTotalAmount = pickPreferredAmount( + toOptionalNumber(summaryRow?.tot_evlu_amt), + apiReportedTotalAmount, evaluationAmount + cashBalance, - toNumber(summaryRow?.tot_evlu_amt), - holdingsEvalAmount + cashBalance, ); - const totalProfitLoss = firstDefinedNumber( + const netAssetAmount = pickPreferredAmount( + apiReportedNetAssetAmount, + grossTotalAmount - loanAmount, + ); + // 상단 "내 자산"은 순자산 기준(사용자 체감 자산)으로 표시합니다. + const totalAmount = pickPreferredAmount( + netAssetAmount, + grossTotalAmount, + ); + const totalProfitLoss = pickNonZeroNumber( toOptionalNumber(accountBalanceResponse?.evlu_pfls_amt_smtl), toOptionalNumber(summaryRow?.evlu_pfls_smtl_amt), sumNumbers(holdings.map((item) => item.profitLoss)), ); - const totalProfitRate = firstDefinedNumber( - toOptionalNumber(summaryRow?.asst_icdc_erng_rt), - calcProfitRate(totalProfitLoss, totalAmount), - ); + const totalProfitRate = calcProfitRateByPurchase(totalProfitLoss, purchaseAmount); return { summary: { totalAmount, cashBalance, + totalDepositAmount, totalProfitLoss, totalProfitRate, netAssetAmount, evaluationAmount, purchaseAmount, loanAmount, + apiReportedTotalAmount, + apiReportedNetAssetAmount, }, holdings, }; @@ -895,16 +970,6 @@ function normalizeSignedValue(value: number, signCode?: string) { return value; } -/** - * 양수 우선값을 반환합니다. - * @param values 후보 숫자 목록 - * @returns 첫 번째 양수, 없으면 0 - * @see lib/kis/dashboard.ts 요약 금액 폴백 계산 - */ -function firstPositiveNumber(...values: number[]) { - return values.find((value) => value > 0) ?? 0; -} - /** * undefined가 아닌 첫 값을 반환합니다. * @param values 후보 숫자 목록 @@ -938,3 +1003,86 @@ function calcProfitRate(profit: number, totalAmount: number) { if (baseAmount <= 0) return 0; return (profit / baseAmount) * 100; } + +/** + * 매입금액 대비 손익률을 계산합니다. + * @param profit 손익 금액 + * @param purchaseAmount 매입금액 + * @returns 손익률(%) + * @see lib/kis/dashboard.ts getDomesticDashboardBalance 현재 손익률 산출 + */ +function calcProfitRateByPurchase(profit: number, purchaseAmount: number) { + if (purchaseAmount <= 0) return 0; + return (profit / purchaseAmount) * 100; +} + +/** + * 총자산과 평가금액 기준으로 일관된 현금성 자산(예수금)을 계산합니다. + * @param params 계산 파라미터 + * @returns 현금성 자산 금액 + * @remarks UI 흐름: 대시보드 API 응답 생성 -> 총자산/평가금/예수금 카드 반영 + * @see lib/kis/dashboard.ts getDomesticDashboardBalance + */ +function resolveCashBalance(params: { + apiReportedTotalAmount: number; + apiReportedNetAssetAmount: number; + evaluationAmount: number; + cashCandidates: Array; +}) { + const { + apiReportedTotalAmount, + apiReportedNetAssetAmount, + evaluationAmount, + cashCandidates, + } = params; + const referenceTotalAmount = pickPreferredAmount( + apiReportedNetAssetAmount, + apiReportedTotalAmount, + ); + const candidateCash = pickPreferredAmount(...cashCandidates); + const derivedCash = + referenceTotalAmount > 0 + ? Math.max(referenceTotalAmount - evaluationAmount, 0) + : undefined; + + if (derivedCash === undefined) return candidateCash; + + // 후보 예수금 + 평가금이 기준 총자산(순자산 우선)과 크게 다르면 역산값을 사용합니다. + const recomposedWithCandidate = candidateCash + evaluationAmount; + const mismatchWithApi = Math.abs( + recomposedWithCandidate - referenceTotalAmount, + ); + if (mismatchWithApi >= 1) { + return derivedCash; + } + + return candidateCash; +} + +/** + * 금액 후보 중 양수 값을 우선 선택합니다. + * @param values 금액 후보 + * @returns 양수 우선 금액 + * @see lib/kis/dashboard.ts getDomesticDashboardBalance 금액 필드 폴백 계산 + */ +function pickPreferredAmount(...values: Array) { + const positive = values.find( + (value): value is number => value !== undefined && value > 0, + ); + if (positive !== undefined) return positive; + return firstDefinedNumber(...values); +} + +/** + * 숫자 후보 중 0이 아닌 값을 우선 선택합니다. + * @param values 숫자 후보 + * @returns 0이 아닌 값 우선 결과 + * @see lib/kis/dashboard.ts getDomesticDashboardBalance 손익/수익률 폴백 계산 + */ +function pickNonZeroNumber(...values: Array) { + const nonZero = values.find( + (value): value is number => value !== undefined && value !== 0, + ); + if (nonZero !== undefined) return nonZero; + return firstDefinedNumber(...values); +} diff --git a/settings-mobile-debug.png b/settings-mobile-debug.png new file mode 100644 index 0000000..96c2a63 Binary files /dev/null and b/settings-mobile-debug.png differ diff --git a/settings-stacked-layout.png b/settings-stacked-layout.png new file mode 100644 index 0000000..bbe9b3d Binary files /dev/null and b/settings-stacked-layout.png differ