2026-02-12 14:20:07 +09:00
|
|
|
"use client";
|
|
|
|
|
|
|
|
|
|
import { useMemo } from "react";
|
2026-03-12 09:26:27 +09:00
|
|
|
import { BriefcaseBusiness, Gauge, Sparkles } from "lucide-react";
|
2026-02-12 14:20:07 +09:00
|
|
|
import { useShallow } from "zustand/react/shallow";
|
|
|
|
|
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
2026-03-12 09:26:27 +09:00
|
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
2026-02-12 17:16:41 +09:00
|
|
|
import { ActivitySection } from "@/features/dashboard/components/ActivitySection";
|
2026-02-12 14:20:07 +09:00
|
|
|
import { DashboardAccessGate } from "@/features/dashboard/components/DashboardAccessGate";
|
|
|
|
|
import { DashboardSkeleton } from "@/features/dashboard/components/DashboardSkeleton";
|
|
|
|
|
import { HoldingsList } from "@/features/dashboard/components/HoldingsList";
|
2026-03-12 09:26:27 +09:00
|
|
|
import { MarketHubSection } from "@/features/dashboard/components/MarketHubSection";
|
2026-02-12 14:20:07 +09:00
|
|
|
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";
|
2026-02-13 12:17:35 +09:00
|
|
|
import { useMarketRealtime } from "@/features/dashboard/hooks/use-market-realtime";
|
|
|
|
|
import { useKisWebSocketStore } from "@/features/kis-realtime/stores/kisWebSocketStore";
|
2026-02-12 14:20:07 +09:00
|
|
|
import { useKisRuntimeStore } from "@/features/settings/store/use-kis-runtime-store";
|
2026-02-13 12:17:35 +09:00
|
|
|
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";
|
2026-02-12 14:20:07 +09:00
|
|
|
|
|
|
|
|
/**
|
2026-02-13 12:17:35 +09:00
|
|
|
* @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
|
2026-02-12 14:20:07 +09:00
|
|
|
*/
|
|
|
|
|
export function DashboardContainer() {
|
2026-02-13 12:17:35 +09:00
|
|
|
// [Store] KIS 런타임 설정 상태 (인증 여부, 접속 계좌, 웹소켓 정보 등)
|
2026-02-12 14:20:07 +09:00
|
|
|
const {
|
|
|
|
|
verifiedCredentials,
|
|
|
|
|
isKisVerified,
|
2026-02-12 17:16:41 +09:00
|
|
|
isKisProfileVerified,
|
|
|
|
|
verifiedAccountNo,
|
2026-02-12 14:20:07 +09:00
|
|
|
_hasHydrated,
|
|
|
|
|
wsApprovalKey,
|
|
|
|
|
wsUrl,
|
|
|
|
|
} = useKisRuntimeStore(
|
|
|
|
|
useShallow((state) => ({
|
|
|
|
|
verifiedCredentials: state.verifiedCredentials,
|
|
|
|
|
isKisVerified: state.isKisVerified,
|
2026-02-12 17:16:41 +09:00
|
|
|
isKisProfileVerified: state.isKisProfileVerified,
|
|
|
|
|
verifiedAccountNo: state.verifiedAccountNo,
|
2026-02-12 14:20:07 +09:00
|
|
|
_hasHydrated: state._hasHydrated,
|
|
|
|
|
wsApprovalKey: state.wsApprovalKey,
|
|
|
|
|
wsUrl: state.wsUrl,
|
|
|
|
|
})),
|
|
|
|
|
);
|
|
|
|
|
|
2026-02-13 12:17:35 +09:00
|
|
|
// KIS 접근 가능 여부 판단
|
2026-02-12 14:20:07 +09:00
|
|
|
const canAccess = isKisVerified && Boolean(verifiedCredentials);
|
|
|
|
|
|
2026-02-13 12:17:35 +09:00
|
|
|
// [Hooks] 기본적인 대시보드 데이터(잔고, 지수, 활동내역) 조회 및 선택 상태 관리
|
|
|
|
|
// @see use-dashboard-data.ts - 초기 데이터 로딩 및 폴링 처리
|
2026-02-12 14:20:07 +09:00
|
|
|
const {
|
2026-02-12 17:16:41 +09:00
|
|
|
activity,
|
2026-02-12 14:20:07 +09:00
|
|
|
balance,
|
2026-02-13 12:17:35 +09:00
|
|
|
indices: initialIndices,
|
2026-02-12 14:20:07 +09:00
|
|
|
selectedSymbol,
|
|
|
|
|
setSelectedSymbol,
|
|
|
|
|
isLoading,
|
|
|
|
|
isRefreshing,
|
2026-02-12 17:16:41 +09:00
|
|
|
activityError,
|
2026-02-12 14:20:07 +09:00
|
|
|
balanceError,
|
|
|
|
|
indicesError,
|
2026-03-12 09:26:27 +09:00
|
|
|
marketHub,
|
|
|
|
|
marketHubError,
|
2026-02-12 14:20:07 +09:00
|
|
|
lastUpdatedAt,
|
|
|
|
|
refresh,
|
|
|
|
|
} = useDashboardData(canAccess ? verifiedCredentials : null);
|
|
|
|
|
|
2026-02-13 12:17:35 +09:00
|
|
|
// [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;
|
2026-03-12 09:26:27 +09:00
|
|
|
const restStatusLabel = isKisRestConnected ? "REST 정상" : "REST 점검 필요";
|
|
|
|
|
const realtimeStatusLabel = isWsConnected
|
|
|
|
|
? isRealtimePending
|
|
|
|
|
? "실시간 대기중"
|
|
|
|
|
: "실시간 수신중"
|
|
|
|
|
: "실시간 미연결";
|
|
|
|
|
const profileStatusLabel = isKisProfileVerified
|
|
|
|
|
? "계좌 인증 완료"
|
|
|
|
|
: "계좌 인증 필요";
|
2026-02-13 12:17:35 +09:00
|
|
|
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 }),
|
|
|
|
|
]);
|
|
|
|
|
};
|
2026-02-12 14:20:07 +09:00
|
|
|
|
2026-02-13 12:17:35 +09:00
|
|
|
/**
|
|
|
|
|
* 실시간 보유종목 데이터를 기반으로 전체 자산 요약을 계산합니다.
|
|
|
|
|
* @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]);
|
|
|
|
|
|
|
|
|
|
// 하이드레이션 이전에는 로딩 스피너 표시
|
2026-02-12 14:20:07 +09:00
|
|
|
if (!_hasHydrated) {
|
|
|
|
|
return (
|
|
|
|
|
<div className="flex h-full items-center justify-center p-6">
|
|
|
|
|
<LoadingSpinner />
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-13 12:17:35 +09:00
|
|
|
// KIS 인증이 되지 않은 경우 접근 제한 게이트 표시
|
2026-02-12 14:20:07 +09:00
|
|
|
if (!canAccess) {
|
|
|
|
|
return <DashboardAccessGate canAccess={canAccess} />;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-13 12:17:35 +09:00
|
|
|
// 데이터 로딩 중이며 아직 데이터가 없는 경우 스켈레톤 표시
|
2026-02-12 14:20:07 +09:00
|
|
|
if (isLoading && !balance && indices.length === 0) {
|
|
|
|
|
return <DashboardSkeleton />;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
2026-03-12 09:26:27 +09:00
|
|
|
<section className="relative mx-auto flex w-full max-w-7xl flex-col gap-4 overflow-hidden p-4 md:p-6">
|
|
|
|
|
<div className="pointer-events-none absolute -left-24 top-10 h-52 w-52 rounded-full bg-brand-400/15 blur-3xl dark:bg-brand-600/15" />
|
|
|
|
|
<div className="pointer-events-none absolute -right-28 top-36 h-64 w-64 rounded-full bg-brand-300/20 blur-3xl dark:bg-brand-700/20" />
|
|
|
|
|
|
|
|
|
|
<div className="relative rounded-3xl border border-brand-200/70 bg-linear-to-br from-brand-100/70 via-brand-50/30 to-background p-4 shadow-sm dark:border-brand-800/50 dark:from-brand-900/35 dark:via-brand-950/15">
|
|
|
|
|
<div className="flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
<p className="inline-flex items-center gap-1.5 rounded-full border border-brand-300/70 bg-background/80 px-3 py-1 text-[11px] font-semibold tracking-wide text-brand-700 dark:border-brand-700 dark:bg-brand-950/50 dark:text-brand-300">
|
|
|
|
|
<Sparkles className="h-3.5 w-3.5" />
|
|
|
|
|
TRADING OVERVIEW
|
|
|
|
|
</p>
|
|
|
|
|
<h1 className="text-xl font-bold tracking-tight text-foreground md:text-2xl">
|
|
|
|
|
시장과 내 자산을 한 화면에서 빠르게 확인하세요
|
|
|
|
|
</h1>
|
|
|
|
|
<p className="text-sm text-muted-foreground">
|
|
|
|
|
핵심 지표를 상단에 모으고, 시장 흐름과 자산 상태를 탭으로 분리했습니다.
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="grid gap-2 sm:grid-cols-3">
|
|
|
|
|
<TopStatusPill label="서버" value={restStatusLabel} ok={isKisRestConnected} />
|
|
|
|
|
<TopStatusPill
|
|
|
|
|
label="실시간"
|
|
|
|
|
value={realtimeStatusLabel}
|
|
|
|
|
ok={isWsConnected}
|
|
|
|
|
warn={isRealtimePending}
|
|
|
|
|
/>
|
|
|
|
|
<TopStatusPill
|
|
|
|
|
label="계좌"
|
|
|
|
|
value={profileStatusLabel}
|
|
|
|
|
ok={isKisProfileVerified}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<Tabs defaultValue="assets" className="relative gap-4">
|
|
|
|
|
<TabsList className="h-auto w-full justify-start rounded-2xl border border-brand-200/80 bg-background/90 p-1 backdrop-blur-sm dark:border-brand-800/50 dark:bg-background/60">
|
|
|
|
|
<TabsTrigger
|
|
|
|
|
value="market"
|
|
|
|
|
className="h-10 rounded-xl px-4 data-[state=active]:bg-brand-600 data-[state=active]:text-white data-[state=active]:shadow-md dark:data-[state=active]:bg-brand-600"
|
|
|
|
|
>
|
|
|
|
|
<Gauge className="h-4 w-4" />
|
|
|
|
|
시장 인사이트
|
|
|
|
|
</TabsTrigger>
|
|
|
|
|
<TabsTrigger
|
|
|
|
|
value="assets"
|
|
|
|
|
className="h-10 rounded-xl px-4 data-[state=active]:bg-brand-600 data-[state=active]:text-white data-[state=active]:shadow-md dark:data-[state=active]:bg-brand-600"
|
|
|
|
|
>
|
|
|
|
|
<BriefcaseBusiness className="h-4 w-4" />
|
|
|
|
|
내 자산
|
|
|
|
|
</TabsTrigger>
|
|
|
|
|
</TabsList>
|
|
|
|
|
|
|
|
|
|
<TabsContent value="market" className="space-y-4 animate-in fade-in-0 slide-in-from-bottom-2 duration-300">
|
|
|
|
|
<div className="grid gap-4 xl:grid-cols-[1.05fr_1.45fr]">
|
|
|
|
|
<MarketSummary
|
|
|
|
|
items={indices}
|
|
|
|
|
isLoading={isLoading}
|
|
|
|
|
error={effectiveIndicesError}
|
|
|
|
|
warning={indicesWarning}
|
|
|
|
|
isWebSocketReady={isWsConnected}
|
|
|
|
|
isRealtimePending={isRealtimePending}
|
|
|
|
|
onRetry={() => {
|
|
|
|
|
void handleRefreshAll();
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
<MarketHubSection
|
|
|
|
|
marketHub={marketHub}
|
|
|
|
|
isLoading={isLoading}
|
|
|
|
|
error={marketHubError}
|
|
|
|
|
onRetry={() => {
|
|
|
|
|
void handleRefreshAll();
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
</TabsContent>
|
|
|
|
|
|
|
|
|
|
<TabsContent value="assets" className="space-y-4 animate-in fade-in-0 slide-in-from-bottom-2 duration-300">
|
|
|
|
|
<StatusHeader
|
|
|
|
|
summary={mergedSummary}
|
|
|
|
|
isKisRestConnected={isKisRestConnected}
|
|
|
|
|
isWebSocketReady={Boolean(wsApprovalKey && wsUrl && isWsConnected)}
|
2026-02-13 12:17:35 +09:00
|
|
|
isRealtimePending={isRealtimePending}
|
2026-03-12 09:26:27 +09:00
|
|
|
isProfileVerified={isKisProfileVerified}
|
|
|
|
|
verifiedAccountNo={verifiedAccountNo}
|
|
|
|
|
isRefreshing={isRefreshing}
|
|
|
|
|
lastUpdatedAt={lastUpdatedAt}
|
|
|
|
|
onRefresh={() => {
|
2026-02-13 12:17:35 +09:00
|
|
|
void handleRefreshAll();
|
|
|
|
|
}}
|
2026-02-12 14:20:07 +09:00
|
|
|
/>
|
|
|
|
|
|
2026-03-12 09:26:27 +09:00
|
|
|
<div className="grid gap-4 xl:grid-cols-[1.25fr_0.75fr]">
|
|
|
|
|
<div className="space-y-4">
|
|
|
|
|
<ActivitySection
|
|
|
|
|
activity={activity}
|
|
|
|
|
isLoading={isLoading}
|
|
|
|
|
error={activityError}
|
|
|
|
|
onRetry={() => {
|
|
|
|
|
void handleRefreshAll();
|
|
|
|
|
}}
|
|
|
|
|
/>
|
2026-02-12 17:16:41 +09:00
|
|
|
|
2026-03-12 09:26:27 +09:00
|
|
|
<HoldingsList
|
|
|
|
|
holdings={mergedHoldings}
|
|
|
|
|
selectedSymbol={selectedSymbol}
|
|
|
|
|
isLoading={isLoading}
|
|
|
|
|
error={balanceError}
|
|
|
|
|
onRetry={() => {
|
|
|
|
|
void handleRefreshAll();
|
|
|
|
|
}}
|
|
|
|
|
onSelect={setSelectedSymbol}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="xl:sticky xl:top-5 xl:self-start">
|
|
|
|
|
<StockDetailPreview
|
|
|
|
|
holding={realtimeSelectedHolding}
|
|
|
|
|
totalAmount={mergedSummary?.totalAmount ?? 0}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</TabsContent>
|
|
|
|
|
</Tabs>
|
2026-02-12 14:20:07 +09:00
|
|
|
</section>
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-02-13 12:17:35 +09:00
|
|
|
|
2026-03-12 09:26:27 +09:00
|
|
|
function TopStatusPill({
|
|
|
|
|
label,
|
|
|
|
|
value,
|
|
|
|
|
ok,
|
|
|
|
|
warn = false,
|
|
|
|
|
}: {
|
|
|
|
|
label: string;
|
|
|
|
|
value: string;
|
|
|
|
|
ok: boolean;
|
|
|
|
|
warn?: boolean;
|
|
|
|
|
}) {
|
|
|
|
|
const toneClass = ok
|
|
|
|
|
? warn
|
|
|
|
|
? "border-amber-300/70 bg-amber-50/70 text-amber-700 dark:border-amber-700/50 dark:bg-amber-950/30 dark:text-amber-300"
|
|
|
|
|
: "border-emerald-300/70 bg-emerald-50/70 text-emerald-700 dark:border-emerald-700/50 dark:bg-emerald-950/30 dark:text-emerald-300"
|
|
|
|
|
: "border-red-300/70 bg-red-50/70 text-red-700 dark:border-red-700/50 dark:bg-red-950/30 dark:text-red-300";
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className={`rounded-xl border px-3 py-2 ${toneClass}`}>
|
|
|
|
|
<p className="text-[11px] font-medium opacity-80">{label}</p>
|
|
|
|
|
<p className="text-xs font-semibold">{value}</p>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-13 12:17:35 +09:00
|
|
|
/**
|
|
|
|
|
* @description REST 지수 데이터가 없을 때 실시간 수신값만으로 코스피/코스닥 표시 데이터를 구성합니다.
|
|
|
|
|
* @param realtimeIndices 실시간 지수 맵
|
|
|
|
|
* @returns 화면 렌더링용 지수 배열
|
|
|
|
|
* @remarks UI 흐름: DashboardContainer -> buildRealtimeOnlyIndices -> MarketSummary 렌더링
|
|
|
|
|
* @see features/dashboard/hooks/use-market-realtime.ts 실시간 지수 수신 훅
|
|
|
|
|
*/
|
|
|
|
|
function buildRealtimeOnlyIndices(
|
|
|
|
|
realtimeIndices: Record<string, { price: number; change: number; changeRate: number }>,
|
|
|
|
|
) {
|
|
|
|
|
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<string, KisRealtimeStockTick>,
|
|
|
|
|
) {
|
|
|
|
|
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;
|
|
|
|
|
}
|