/** * Dashboard.tsx — 메인 대시보드 페이지 컴포넌트 * * 역할: * - 전체 트레이딩 대시보드의 상태 관리 및 레이아웃 구성 * - WebSocket을 통한 실시간 시장 데이터 및 차트 데이터 수신 * - 포트폴리오, 차트, 호가, 추천종목, 매매내역, AI분석 패널 통합 * * 레이아웃 (12컬럼 그리드): * [2col] 보유종목 | [7col] 차트+AI분석+호가 | [3col] 추천종목+매매내역 * * 데이터 흐름: * - WebSocket: 실시간 tick/quote → useMarketData, 차트 데이터 → processChartData * - react-query: 포트폴리오(30s), 호가(10s), 추천종목(2min), 매매내역(30s), 일지(이벤트) * - 매매 감지: tradeHistory 폴링에서 새 거래 ID 감지 → 포트폴리오 즉시 갱신 * * 성능 최적화: * - 각 패널은 React.memo로 불필요한 리렌더 방지 * - ref 기반 stale closure 방지 (selectedStockRef, chartPeriodRef 등) * - useCallback/useMemo로 콜백 및 파생 데이터 안정화 */ import { useEffect, useState, useRef, useMemo, useCallback } from 'react' import { useQuery, useQueryClient } from '@tanstack/react-query' import api from '../lib/api' import { useWebSocket } from '../hooks/useWebSocket' import { usePortfolioData } from '../hooks/usePortfolioData' import { useMarketData } from '../hooks/useMarketData' import { useTradeActions, TradeActionError } from '../hooks/useTradeActions' import { useAccountSetupStatus } from '../hooks/useAccountSetupStatus' import { useTenantMode } from '../lib/tenant' import { getTradesV2 } from '../lib/api' import { PortfolioSummary } from '../components/PortfolioSummary' import PortfolioPanel from '../components/PortfolioPanel' import ChartPanel, { AiAnalysisPanel } from '../components/ChartPanel' import OrderbookPanel from '../components/OrderbookPanel' import RecommendationPanel from '../components/RecommendationPanel' import JournalPanel from '../components/JournalPanel' import AccountSetupNotice from '../components/AccountSetupNotice' import MarketIndexBar from '../components/MarketIndexBar' import { CandleData, Position, TradeHistory } from '../types/trading' import { isUsStock } from '../utils/stockUtils' import './Dashboard.css' /** * 마켓 운영 시간 체크 (한국장 + 미국장) * * 한국장: 09:00~15:30 KST (540분~930분) * 미국장: 22:30~06:00 KST (1350분~다음날 360분) * * 장 운영 여부에 따라 API 폴링 간격이 달라짐: * - 장중: 빈번한 폴링 (호가 10초, 매매내역 30초 등) * - 장외: 폴링 중단 또는 간격 확대 * * @returns 한국장 또는 미국장이 운영 중이면 true */ const checkMarketOpen = () => { const now = new Date(); const kstTime = new Date(now.toLocaleString("en-US", { timeZone: "Asia/Seoul" })); const day = kstTime.getDay(); // 0: 일, 6: 토 const currentMinutes = kstTime.getHours() * 60 + kstTime.getMinutes(); // 일요일 또는 토요일 07:00 이후 → 장 종료 if (day === 0 || (day === 6 && currentMinutes >= 420)) return false; // 한국장: 09:00(540분) ~ 15:30(930분) const isKoreaOpen = currentMinutes >= 540 && currentMinutes <= 930; // 미국장: 22:30(1350분) ~ 익일 06:00(360분) KST 기준 const isUSOpen = currentMinutes >= 1350 || currentMinutes <= 360; // 금요일 22:30 이후 → 미국장 개장 if (day === 5 && currentMinutes >= 1350) return true; // 토요일 06:00 이전 → 미국장 진행 중 if (day === 6 && currentMinutes <= 360) return true; return isKoreaOpen || isUSOpen; }; /** 마켓 필터 타입 (전체/국내/미국) */ type MarketFilter = 'ALL' | 'KR' | 'US' /** * liveSummary 보정 신뢰 임계값 (2026-09-28) * * `/portfolio/combined` 폴링 주기는 30초(usePortfolioData.ts staleTime/refetchInterval), * livePrices는 WebSocket tick마다 1초 단위로 flush된다(useMarketData.ts). 즉 보정항은 * "백엔드 스냅샷과 최대 30초 사이의 실시간 가격 변동분"만 반영해야 정상이다. * 국내 개별종목의 30초 단위 변동은 일중 변동성 기준으로도 통상 1% 미만이고, * 포트폴리오 전체(다종목 분산) 단위로는 이보다 더 작다 — 3%는 정상적인 실시간 변동 * 범위를 넉넉히 덮으면서도, 2026-09-28 사고에서 실제 관측된 오차(9,937,380원 − * 8,709,324원 = 1,228,056원, 총자산 대비 12.36%)는 확실히 걸러낸다. * (당시 "한미사이언스 10주 매도가 원인"이라는 가설을 세웠으나 뒷받침 근거가 없어 철회 — * 정확한 유발 메커니즘은 미규명. 아래 F12 설명 참조.) */ const LIVE_CORRECTION_MAX_RATIO = 0.03 /** * Dashboard — 메인 대시보드 컴포넌트 * * 주요 상태: * - marketFilter: 마켓 필터 (ALL/KR/US) * - selectedStock: 선택된 종목 코드 (localStorage 저장) * - chartData/chartLoading: 차트 데이터 및 로딩 상태 * - chartPeriod: 차트 기간 (D/W/M) */ const Dashboard = () => { const [marketFilter, setMarketFilter] = useState('ALL') // 차트 데이터 (WebSocket chart_data 메시지로 로드) const [chartData, setChartData] = useState(null) const chartDataRef = useRef(null) const [chartLoading, setChartLoading] = useState(false) const chartLoadingRef = useRef(false) // selectedStock / chartPeriod을 콜백 클로저에서 안전하게 참조하기 위한 ref const selectedStockRef = useRef('') const chartPeriodRef = useRef<'D' | 'W' | 'M'>('D') // localStorage 초기값 사용 → portfolio 로드 전에 즉시 chart 쿼리 활성화 const [selectedStock, setSelectedStock] = useState( () => localStorage.getItem('selectedStock') || '' ) selectedStockRef.current = selectedStock // 렌더마다 ref 동기화 (stale closure 방지) const [chartPeriod, setChartPeriod] = useState<'D' | 'W' | 'M'>('D') chartPeriodRef.current = chartPeriod // 렌더마다 ref 동기화 (stale closure 방지) const queryClient = useQueryClient() const prevLatestTradeIdRef = useRef(null) const isMarketOpen = checkMarketOpen() // 종목 선택 시 localStorage에 저장 (재진입 시 Waterfall 제거) // useCallback으로 안정화 → React.memo 패널에 전달 시 불필요한 리렌더 방지 const handleSelectStock = useCallback((code: string) => { setSelectedStock(code) localStorage.setItem('selectedStock', code) }, []) // ───────────────────────────────────────────────────────────── // Custom Hooks // ───────────────────────────────────────────────────────────── // Portfolio data hook const { portfolio, portfolioSummary, portfolioSummaryError, invalidatePortfolio, accountSetupRequired, accountSetupMessage, } = usePortfolioData() // v2(로그인 세션) 사용자의 KIS 계좌 등록/검증 상태 — 매수/매도 시도 전 선제 안내용 const accountStatus = useAccountSetupStatus() const { mode: tenantMode } = useTenantMode() // 매수/매도 액션 중 계좌 미등록/미검증(400/423)을 받으면 배너로 노출 (alert 대체) const [tradeAccountNotice, setTradeAccountNotice] = useState(null) // Market data hook (with throttled live prices and market update handler) const { livePrices, liveQuote, handleMarketUpdate, } = useMarketData( selectedStock, portfolio ) // chart_data 메시지 처리 함수 (wsMessages useEffect에서 호출) const processChartData = useCallback((m: Record) => { console.log('📊 processChartData called', { msgStock: m?.stock_code, msgPeriod: m?.period, refStock: selectedStockRef.current, refPeriod: chartPeriodRef.current, }) // 응답이 현재 선택 종목/기간과 일치할 때만 반영 (레이스 컨디션 방지) if ( m?.stock_code !== selectedStockRef.current || m?.period !== chartPeriodRef.current ) { console.log('📊 processChartData SKIP — stock/period mismatch') return } const rawData = (m.data as Record[]) || [] if (rawData.length === 0) { chartDataRef.current = null setChartData(null) chartLoadingRef.current = false setChartLoading(false) return } const data = rawData.map((item: Record) => ({ date: item.date as string, open: Number(item.open), high: Number(item.high), low: Number(item.low), close: Number(item.close), volume: Number(item.volume), })) // 동일 데이터면 setState 건너뛰기 (차트 재생성 방지) const prev = chartDataRef.current const isSame = prev && prev.length === data.length && prev.length > 0 && prev[0].date === data[0].date && prev[prev.length - 1].date === data[data.length - 1].date && prev[prev.length - 1].close === data[data.length - 1].close if (!isSame) { chartDataRef.current = data setChartData(data) } if (chartLoadingRef.current) { chartLoadingRef.current = false setChartLoading(false) } }, []) // WebSocket hook - market_update는 콜백, chart_data는 messages로 처리 // 탭마다 고유 client_id 부여 (동일 ID로 2개 연결 시 서버에서 이전 연결 덮어쓰기 → 끊김 방지) const [wsClientId] = useState(() => `dashboard_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`) const { connected: wsConnected, messages: wsMessages, sendMessage } = useWebSocket(wsClientId, { onMarketUpdate: handleMarketUpdate, }) // 차트 요청 헬퍼용 ref — useWebSocket 호출 직후 정의 (사용 useEffect보다 앞에 위치해야 함) const wsConnectedRef = useRef(wsConnected) wsConnectedRef.current = wsConnected const sendMessageRef = useRef(sendMessage) sendMessageRef.current = sendMessage // chart_data: CustomEvent 리스너 (React 상태 우회 — StrictMode 타이밍 이슈 방지) useEffect(() => { const handler = (e: Event) => { const detail = (e as CustomEvent).detail console.log('📨 CustomEvent chart_data 수신', detail?.stock_code) processChartData(detail as Record) } window.addEventListener('ws-chart-data', handler) return () => window.removeEventListener('ws-chart-data', handler) }, [processChartData]) // Trade actions hook // isReady=false(useTenantMode 확정 전)면 매수/매도/미체결정리 버튼을 모두 disabled 처리 const { handleSell, handleBuy, resetPendingTrades, isReady: tradeActionsReady } = useTradeActions() // 계좌 미등록/미검증(400/423)은 상단 배너로 흡수하고, 그 외 에러는 그대로 전파해 // 각 패널의 기존 alert 처리(다른 실패 사유)가 계속 동작하도록 한다. const handleBuyGuarded = useCallback( async (stockCode: string, stockName: string) => { try { await handleBuy(stockCode, stockName) } catch (error: unknown) { const err = error as TradeActionError if (err.isAccountSetupRequired) { setTradeAccountNotice(err.response?.data?.detail || '자동매매가 비활성화 상태입니다. 내 계좌 설정에서 계좌를 등록·검증하세요.') return } throw error } }, [handleBuy] ) const handleSellGuarded = useCallback( async (stockCode: string, stockName: string, quantity: number) => { try { await handleSell(stockCode, stockName, quantity) } catch (error: unknown) { const err = error as TradeActionError if (err.isAccountSetupRequired) { setTradeAccountNotice(err.response?.data?.detail || '자동매매가 비활성화 상태입니다. 내 계좌 설정에서 계좌를 등록·검증하세요.') return } throw error } }, [handleSell] ) // Subscribe to market data via WebSocket // reconnect 감지: wsConnected가 false→true 전환 시 강제 재구독 const prevSubCodesRef = useRef('') const prevWsConnectedRef = useRef(false) useEffect(() => { if (!wsConnected) { prevWsConnectedRef.current = false return } const isReconnect = !prevWsConnectedRef.current prevWsConnectedRef.current = true const codes = [ ...new Set([ ...(portfolio || []).map((s: any) => s.stock_code as string), ...(selectedStock ? [selectedStock] : []), ]), ].sort() if (codes.length === 0) return const key = codes.join(',') // reconnect 시 강제 재전송, 그 외엔 변경 시에만 if (!isReconnect && key === prevSubCodesRef.current) return prevSubCodesRef.current = key sendMessageRef.current( JSON.stringify({ type: 'set_subscriptions', stock_codes: codes }) ) }, [wsConnected, portfolio, selectedStock]) // 호가창 - REST fallback (WebSocket quote로 실시간 갱신) const { data: orderbook } = useQuery({ queryKey: ['orderbook', selectedStock], queryFn: async () => { if (!selectedStock) return null try { const response = await api.get( `/market-data/orderbook/${selectedStock}` ) return response.data } catch (error: any) { console.error( `Orderbook fetch failed (${selectedStock}):`, error.response?.data?.detail || error.message ) return null } }, enabled: Boolean(selectedStock), staleTime: isMarketOpen ? 5000 : 60000, refetchInterval: (query) => { // 500 에러 시 폴링 간격을 60초로 늘려 서버 부하 방지 if (query.state.error) return 60000 return isMarketOpen ? 10000 : false }, retry: 1, }) // 추천 종목 (배치 분석 결과) - 2분 간격 (배치 주기 10분이므로 잦은 호출 불필요) // trades_executed 발생 시: portfolio + trade-history 즉시 invalidate const { data: ruleBasedRecommendations, refetch: refetchRecommendations, isError: isRecommendationsError, } = useQuery({ queryKey: ['rule-based-recommendations'], queryFn: async () => { const response = await api.get('/recommendations/rule-based', { params: { realtime: false, auto_trade: true, }, }) const data = response.data // 자동매매 체결 감지 → 포트폴리오 즉시 갱신 (setTimeout 제거) if (data.trades_executed && data.trades_executed.length > 0) { console.log( '✅ 자동매매 체결:', data.trades_executed.length, '건' ) queryClient.invalidateQueries({ queryKey: ['trade-history'], }) invalidatePortfolio() } return data }, refetchInterval: isMarketOpen ? 120000 : 300000, staleTime: isMarketOpen ? 110000 : 290000, refetchOnWindowFocus: false, }) // 차트 요청 헬퍼 — ref 기반으로 의존성 최소화 (리렌더 방지) const requestChartRef = useRef((stock: string, period: string): boolean => { console.log('📤 requestChart', { stock, period, wsConnected: wsConnectedRef.current }) if (!stock || !wsConnectedRef.current) return false sendMessageRef.current( JSON.stringify({ type: 'get_chart', stock_code: stock, period: period, limit: 200, }) ) console.log('📤 requestChart SENT') return true }) // ref 내용은 항상 최신 — useCallback 불필요 (의존성 변경에 의한 리렌더 차단) // 차트 데이터 — selectedStock / chartPeriod 변경 시 요청 useEffect(() => { if (!selectedStock) { chartDataRef.current = null setChartData(null) chartLoadingRef.current = false setChartLoading(false) return } const sent = requestChartRef.current(selectedStock, chartPeriod) if (sent) { chartLoadingRef.current = true setChartLoading(true) } }, [selectedStock, chartPeriod]) // WebSocket (재)연결 시 — 차트 데이터 요청 useEffect(() => { if (!selectedStock || !wsConnected) return // 이미 데이터가 있으면 로딩 표시 안 함 (조용히 갱신) if (!chartDataRef.current) { chartLoadingRef.current = true setChartLoading(true) } requestChartRef.current(selectedStock, chartPeriod) }, [wsConnected]) // eslint-disable-line react-hooks/exhaustive-deps // 거래 이력 - 30초 (자동매매 체결 감지용) const { data: tradeHistory } = useQuery({ queryKey: ['trade-history', tenantMode], queryFn: async () => { const data = ( tenantMode === 'v2' ? await getTradesV2({ limit: 50 }) : await api.get('/trades', { params: { limit: 50 } }) ).data as TradeHistory[] const seen = new Set() return data .filter((trade: TradeHistory) => { if (seen.has(trade.id)) return false seen.add(trade.id) return true }) .slice(0, 20) }, // M5: mode 미확정(undefined) 동안은 v2/legacy 어느 쪽으로도 조회하지 않음 enabled: tenantMode !== undefined, staleTime: 0, refetchInterval: isMarketOpen ? 30000 : false, refetchOnWindowFocus: true, }) // 매매일지 — 폴링 기반 (WebSocket journal_created는 보조 트리거일 뿐, 아래 참고) // // F-J1 (2026-09-30): 이전엔 staleTime: Infinity + refetchOnWindowFocus: false + // refetchInterval 없음이라, WebSocket 'journal_created' 없이는 페이지 로드 순간 // 목록에서 영원히 멈췄다. 그런데 그 브로드캐스트 자체가 신뢰할 수 없다 — // 실측 205건 중 13건(6%)만 발화(16:00 스케줄러/‘/analysis/generate-journals’ // 경로는 브로드캐스트 자체가 없음, backend/app/api/endpoints/portfolio.py 확인— // 읽기만 함, 백엔드는 journal-fix-be 담당). // 그래서 이벤트에 의존하지 않는 폴링을 기본으로 하고, 이벤트는 보조 트리거로만 쓴다. // // 60초 간격 근거: 일지는 포트폴리오(30초, usePortfolioData.ts)·매매내역(30초, // 위 tradeHistory)만큼 실시간성이 필요 없다(매매 직후 화면에 바로 안 보여도 // 치명적이지 않음, 사후 기록 성격) — 그 두 배인 60초로 두어 불필요한 API 부하를 // 늘리지 않는다. 탭이 백그라운드일 때 폴링을 줄이려 장이 닫혀 있으면(포트폴리오 // 폴링과 동일 기준 재사용하지 않고, 일지는 장외에도 16:00 배치 등으로 계속 // 생성되므로) 장 상태와 무관하게 항상 60초로 유지한다. const { data: journals } = useQuery({ queryKey: ['journals'], queryFn: async () => (await api.get('/journals', { params: { limit: 20 } })).data, staleTime: 30000, refetchInterval: 60000, refetchOnWindowFocus: true, }) // ───────────────────────────────────────────────────────────── // 매매 감지 → 보유종목/자산 갱신 (핵심 로직) // tradeHistory polling(30s)에서 새 거래 ID 감지 시 1회만 invalidate // ───────────────────────────────────────────────────────────── useEffect(() => { if (!tradeHistory || tradeHistory.length === 0) return const latestId = tradeHistory[0].id if ( prevLatestTradeIdRef.current !== null && latestId > prevLatestTradeIdRef.current ) { const t = tradeHistory[0] console.log( '✅ 새 매매 감지:', t.stock_name, t.trade_type, '→ 포트폴리오 갱신' ) invalidatePortfolio() } prevLatestTradeIdRef.current = latestId }, [tradeHistory, invalidatePortfolio]) // ───────────────────────────────────────────────────────────── // WebSocket 이벤트 처리 // - trade_executed: 체결 즉시 포트폴리오 갱신 (백엔드에서 broadcast 필요) // - journal_created: 매매일지 즉시 갱신 // - processedCountRef로 미처리 메시지를 모두 순회 (동시 이벤트 누락 방지) // ───────────────────────────────────────────────────────────── const processedCountRef = useRef(0) useEffect(() => { if (wsMessages.length <= processedCountRef.current) return const unprocessed = wsMessages.slice(processedCountRef.current) processedCountRef.current = wsMessages.length for (const msg of unprocessed) { if (msg.type === 'trade_executed') { console.log('WebSocket trade_executed -> portfolio refresh') invalidatePortfolio() queryClient.invalidateQueries({ queryKey: ['trade-history'] }) } if (msg.type === 'journal_created') { // 브로드캐스트가 db_worker INSERT 커밋 전에 발화하는 레이스가 있다 // (backend/app/api/endpoints/portfolio.py 확인 — 큐 적재 직후 즉시 // 브로드캐스트, 커밋은 비동기 워커가 나중에 수행). 즉시 1회 + 지연 // 재조회 1회로 레이스에 대비 — 최종적으로는 60초 폴링이 항상 따라잡는다. queryClient.invalidateQueries({ queryKey: ['journals'] }) setTimeout(() => { queryClient.invalidateQueries({ queryKey: ['journals'] }) }, 3000) } } }, [wsMessages, invalidatePortfolio, queryClient]) // 실시간 주식평가금액/손익 — livePrices tick 기반 재계산 // total_assets = KIS API 기준값(예수금+주식+미수 등) 사용, 주식 평가분만 live 반영 const liveSummary = useMemo(() => { if (!portfolioSummary || !portfolio) return portfolioSummary // live 현재가 기반 주식 평가금액 // 가격 결손 종목(WS tick도 없고 DB current_price도 0/NaN)을 별도 수집 — // 이런 종목이 있으면 liveValue 자체가 축소 계산되어 있어 보정에 쓸 수 없다. // // M7 (2026-09-29): backendStockValue(stock_value_from_api = KIS 국내계좌 // scts_evlu_amt)는 국내 원화 전용이다(backend/app/api/endpoints/portfolio.py:274 // — output2, 국내 잔고 API에서만 추출). liveValue는 해외 종목의 USD 단가를 // 환산 없이 그대로 더하므로(기존 동작, 이번 수정 대상 아님) 해외 보유가 있으면 // 통화·범위가 다른 값끼리 비교하게 되어 correctionRatio가 항상 커진다. // 백엔드 summary에는 domestic_value 필드가 없어(실측: /portfolio/summary 응답에 // 없음 — _calculate_portfolio_values가 계산은 하지만 summary_data에 담지 않음) // 새 필드 없이, 프론트에서 국내 보유만 골라 liveDomesticValue로 비교한다 // (backendStockValue와 동일 범위·통화를 맞추는 최소 수정 — 해외 USD 환산 로직 // 자체는 건드리지 않음). const missingPriceCodes: string[] = [] const domesticMissingPriceCodes: string[] = [] let liveDomesticValue = 0 let overseasHoldingsCount = 0 const liveValue = portfolio.reduce((sum, s) => { const isDomestic = !isUsStock(s.stock_code) const rawPrice = livePrices[s.stock_code] ?? Number(s.current_price || 0) const price = Number.isFinite(rawPrice) ? rawPrice : 0 const qty = Number(s.quantity || 0) if (!(price > 0)) { missingPriceCodes.push(s.stock_code) if (isDomestic) domesticMissingPriceCodes.push(s.stock_code) } if (isDomestic) { liveDomesticValue += price * qty } else { overseasHoldingsCount += 1 } return sum + price * qty }, 0) // 총 매입금액 (평균단가 × 수량) — average_price null 시 백엔드 summary 값 사용 const computedInvestment = portfolio.reduce( (sum, s) => sum + Number(s.average_price || 0) * Number(s.quantity || 0), 0 ) const totalInvestment = computedInvestment || Number(portfolioSummary.total_investment || 0) // 평가손익 = 주식 평가금액 - 매입금액 const liveProfitLoss = liveValue - totalInvestment const liveProfitLossRate = totalInvestment > 0 ? (liveProfitLoss / totalInvestment) * 100 : 0 // 총 자산 = KIS tot_evlu_amt 기준으로 실시간 주식평가 변동분 보정 // stock_value_from_api(scts_evlu_amt)가 0이면 예수금+실시간주식평가로 단순 계산 const backendStockValue = portfolioSummary.stock_value_from_api || 0 const backendTotalAssets = Number(portfolioSummary.total_assets || 0) // 보정 신뢰도 가드 (2026-09-28 추가, 09-28 2차 보강, 09-29 F12 주석 정정) // // 2026-09-28 사고(총자산 12.36% 과소표시)의 정확한 유발 메커니즘은 규명되지 않았다. // "portfolioSummary와 portfolio가 서로 다른 시점에 어긋난다"는 초기 가설은 legacy // 경로에서는 성립하지 않는다 — 감사 결과(F12): 이 경로에서 `portfolio`(holdings)와 // `portfolioSummary`는 `/portfolio/combined` 단일 응답의 두 필드이고 // (usePortfolioData.ts의 react-query 쿼리 하나가 원자적으로 반환, invalidatePortfolio()도 // 같은 쿼리키 하나만 무효화), 백엔드도 `summary.total_stocks`를 // `len([h for h in self.holdings if h.quantity > 0])`로 **같은 holdings 리스트에서** // 계산한다(portfolio.py `_build_combined_resp`/`cache_summary`). 즉 legacy 경로에서 // 두 소스가 "다른 시점"일 수는 없다. // 남은 실제 불일치 벡터는 세 가지뿐이다: // (a) livePrices(WebSocket tick, 초 단위) vs 백엔드 스냅샷(30초 폴링) 사이의 // 진짜 시차 — 유일하게 확실한 "다른 시점" 소스 // (b) DB `current_price`가 0/NULL인 행이 있으면 liveValue가 축소 계산됨 // (유력 가설이나 미확증 — 아래 ②의 결손 가드가 대응) // (c) 해외 종목 USD 미환산(M7) — 현재 US_TRADING_ENABLED=False라 해당 없음 // 이 가드는 원인을 특정해 막는 것이 아니라, 위 벡터로 인한 증상(허위 손익 표시)을 // 억제하는 안전망이다. // // ① summary.total_stocks 와 실제 보유 종목 수가 다르거나 // ② 보유 종목 중 실시간 가격이 결손(WS tick 없음 + DB current_price도 0/NaN)인 종목이 // 하나라도 있거나 — 종목 수는 같아도 liveValue 자체가 축소 계산돼 있어 위험하다 // ③ 보정폭이 총자산의 LIVE_CORRECTION_MAX_RATIO(3%, 근거는 상수 선언부 주석)를 넘으면 // — 두 소스가 어긋난 것으로 보고 보정을 스킵, 백엔드 원본값(total_assets/today_profit_loss)을 // 그대로 신뢰한다. 불변식: 보정을 적용하지 않을 때 백엔드 값보다 더 틀린 값을 만들지 않는다. // // F14 (2026-09-29): ①(holdingsCountMatches)은 위 근거대로 legacy 경로에서는 // summary.total_stocks 와 portfolio.length 가 같은 리스트에서 나오므로 구조적으로 // 항상 true다 — 즉 legacy 에서는 죽은 가드다. 제거하지 않는 이유는 v2/멀티테넌트 // 경로나 백엔드 구현이 바뀌면 의미가 생길 수 있고 무해하기 때문. 실효 가드는 // ②(가격 결손)와 ③(보정폭) 두 개뿐이라고 보고 다른 안전망으로 착각하지 말 것. const summaryStockCount = Number(portfolioSummary.total_stocks) const holdingsCountMatches = Number.isFinite(summaryStockCount) ? portfolio.length === summaryStockCount : true // M7: 비교는 국내분(liveDomesticValue)끼리만 — backendStockValue가 국내 전용이므로 const hasMissingPrice = domesticMissingPriceCodes.length > 0 const correctionDelta = backendStockValue > 0 ? liveDomesticValue - backendStockValue : 0 const correctionRatio = backendTotalAssets > 0 ? Math.abs(correctionDelta) / backendTotalAssets : 1 const canTrustLiveCorrection = backendStockValue > 0 && holdingsCountMatches && !hasMissingPrice && correctionRatio <= LIVE_CORRECTION_MAX_RATIO // 가드가 발동해 보정을 스킵한 경우 원인 추적용 로그 (화면에는 노출하지 않음) // overseasHoldingsCount > 0 이면 통화 혼재(M7) 가능성을 의심할 것 — 현재 // US_TRADING_ENABLED=False 라 해외 보유가 없어야 정상이므로 0이 아니면 이상 신호 if (backendStockValue > 0 && !canTrustLiveCorrection) { console.warn('[liveSummary] 보정 스킵 — 백엔드 원본값 사용', { expectedStockCount: summaryStockCount, actualStockCount: portfolio.length, overseasHoldingsCount, correctionRatio, missingPriceCodes, domesticMissingPriceCodes, }) } const liveTotal = canTrustLiveCorrection ? backendTotalAssets + correctionDelta : backendStockValue > 0 ? backendTotalAssets // 신뢰 불가 → 백엔드 원본 총자산 그대로 사용 : Number(portfolioSummary.available_cash || 0) + liveValue // 금일 손익도 live 주식 변동분 반영 — 단, 보정을 신뢰할 수 없으면 백엔드 원본값 사용 const bfdyTotal = portfolioSummary.bfdy_total_assets || 0 const liveTodayPL = canTrustLiveCorrection && bfdyTotal > 0 ? liveTotal - bfdyTotal : Number(portfolioSummary.today_profit_loss || 0) const liveTodayPLRate = canTrustLiveCorrection && bfdyTotal > 0 ? (liveTodayPL / bfdyTotal) * 100 : Number(portfolioSummary.today_profit_loss_rate || 0) return { ...portfolioSummary, total_value: liveValue, total_investment: totalInvestment, total_profit_loss: liveProfitLoss, total_profit_loss_rate: liveProfitLossRate, total_assets: liveTotal, today_profit_loss: liveTodayPL, today_profit_loss_rate: liveTodayPLRate, } }, [portfolioSummary, portfolio, livePrices]) // 최근 거래일 매매 통계 — tradeHistory 기반 실시간 집계 // 오늘 체결 거래가 없으면 가장 최근 체결일 기준으로 집계 // API 필드: order_type(BUY/SELL), total_amount(체결금액), executed_price, executed_quantity const todayTradeStats = useMemo(() => { if (!tradeHistory) return null const DONE_STATUSES = ['EXECUTED', 'COMPLETED', 'FILLED'] const executedTrades = tradeHistory.filter( (t: TradeHistory) => DONE_STATUSES.includes(t.status) ) if (executedTrades.length === 0) return { buyAmount: 0, sellAmount: 0, realizedPL: 0, count: 0 } // 오늘 거래 우선, 없으면 가장 최근 체결일 사용 const todayStr = new Date().toDateString() const getDateStr = (t: TradeHistory) => new Date(t.executed_at || t.created_at || '').toDateString() const hasTodayTrades = executedTrades.some((t: TradeHistory) => getDateStr(t) === todayStr) const targetDateStr = hasTodayTrades ? todayStr : getDateStr(executedTrades[0]) // 최신순 정렬이므로 첫 번째가 최근 const todayTrades = executedTrades.filter( (t: TradeHistory) => getDateStr(t) === targetDateStr ) const toAmount = (t: TradeHistory) => Number(t.total_amount || 0) || Number(t.executed_price || 0) * Number(t.executed_quantity || 0) const orderType = (t: TradeHistory) => t.order_type || t.trade_type const buyAmount = todayTrades .filter((t: TradeHistory) => orderType(t) === 'BUY') .reduce((sum: number, t: TradeHistory) => sum + toAmount(t), 0) const sellAmount = todayTrades .filter((t: TradeHistory) => orderType(t) === 'SELL') .reduce((sum: number, t: TradeHistory) => sum + toAmount(t), 0) const realizedPL = todayTrades .filter((t: TradeHistory) => orderType(t) === 'SELL') .reduce((sum: number, t: TradeHistory) => sum + Number(t.profit_loss || 0), 0) const isToday = targetDateStr === todayStr return { buyAmount, sellAmount, realizedPL, count: todayTrades.length, isToday, dateStr: targetDateStr } }, [tradeHistory]) const livePrice = livePrices[selectedStock] // 실시간 호가: WebSocket quote(H0STASP0) → REST 초기값 폴백 const activeOrderbook = useMemo(() => { if (!liveQuote) return orderbook ?? null return { current_price: livePrice ?? liveQuote.ask1 ?? orderbook?.current_price ?? 0, stock_name: orderbook?.stock_name, asks: [ { price: liveQuote.ask1, volume: liveQuote.ask_qty1 ?? 0 }, { price: liveQuote.ask2, volume: liveQuote.ask_qty2 ?? 0 }, { price: liveQuote.ask3, volume: liveQuote.ask_qty3 ?? 0 }, { price: liveQuote.ask4, volume: liveQuote.ask_qty4 ?? 0 }, { price: liveQuote.ask5, volume: liveQuote.ask_qty5 ?? 0 }, ].filter((a) => a.price != null && a.price > 0), bids: [ { price: liveQuote.bid1, volume: liveQuote.bid_qty1 ?? 0 }, { price: liveQuote.bid2, volume: liveQuote.bid_qty2 ?? 0 }, { price: liveQuote.bid3, volume: liveQuote.bid_qty3 ?? 0 }, { price: liveQuote.bid4, volume: liveQuote.bid_qty4 ?? 0 }, { price: liveQuote.bid5, volume: liveQuote.bid_qty5 ?? 0 }, ].filter((b) => b.price != null && b.price > 0), current_volume: 0, } }, [liveQuote, livePrice, orderbook]) // 마켓 필터 적용된 포트폴리오 const filteredPortfolio = useMemo(() => { if (!portfolio) return portfolio if (marketFilter === 'ALL') return portfolio return portfolio.filter((s: Position) => marketFilter === 'US' ? isUsStock(s.stock_code) : !isUsStock(s.stock_code) ) }, [portfolio, marketFilter]) // useMemo로 Set 안정화 → React.memo 패널에 전달 시 레퍼런스 유지 const heldStockCodes = useMemo>( () => new Set((portfolio || []).map((s) => s.stock_code as string)), [portfolio] ) const pendingBuyStockCodes = useMemo>( () => new Set( (tradeHistory || []) .filter((t: TradeHistory) => t.status === 'PENDING' && t.trade_type === 'BUY') .map((t: TradeHistory) => t.stock_code as string) ), [tradeHistory] ) // 차트 헤더용 종목명 (useMemo로 안정화) const stockName = useMemo(() => { return ( portfolio?.find((s: any) => s.stock_code === selectedStock)?.stock_name || ruleBasedRecommendations?.recommendations_by_rule?.flatMap((r: any) => r.stocks).find((s: any) => s.stock_code === selectedStock)?.stock_name || selectedStock || '' ) }, [portfolio, ruleBasedRecommendations, selectedStock]) const { data: aiAnalysisData, refetch: refetchAiAnalysis } = useQuery({ queryKey: ['ai-analysis', selectedStock], queryFn: async () => { if (!selectedStock) return null; console.log('🤖 AI 분석 요청:', selectedStock); try { const response = await api.get(`/market-data/ai-analysis/${selectedStock}`); const data = response.data; const stockNameFallback = orderbook?.stock_name?.trim() || portfolio?.find((s: any) => s.stock_code === selectedStock)?.stock_name?.trim() || (ruleBasedRecommendations?.analyzed_stock_list || []).find((s: any) => s.stock_code === selectedStock)?.stock_name?.trim() || '' const displayName = data.stock_name?.trim() || stockNameFallback || selectedStock let analysis = `📈 ${displayName} (${data.stock_code})\n\n`; analysis += `💰 현재가: ${data.current_price?.toLocaleString() ?? 'N/A'}원\n`; analysis += `📊 고가/저가: ${data.high_price?.toLocaleString() ?? 'N/A'} / ${data.low_price?.toLocaleString() ?? 'N/A'}원\n`; analysis += `📦 거래량: ${data.volume != null ? (data.volume / 1000).toFixed(0) : 'N/A'}K주\n`; analysis += `\n📊 기술적 지표:\n`; const hasIndicators = data.indicators && Object.values(data.indicators).some(v => v !== null && v !== undefined); if (hasIndicators) { if (data.indicators.rsi != null) { analysis += ` RSI(14): ${data.indicators.rsi.toFixed(1)}\n`; } if (data.indicators.ma5 != null) { analysis += ` 5일 이평: ${data.indicators.ma5.toFixed(0)}원\n`; } if (data.indicators.ma20 != null) { analysis += ` 20일 이평: ${data.indicators.ma20.toFixed(0)}원\n`; } if (data.indicators.ma60 != null) { analysis += ` 60일 이평: ${data.indicators.ma60.toFixed(0)}원\n`; } if (data.indicators.stochastic_k != null) { analysis += ` 스토캐스틱 K: ${data.indicators.stochastic_k.toFixed(1)}\n`; } } else { analysis += ` (DB에 저장된 지표 없음 - 배치 작업 대기중)\n`; } analysis += `\n🤖 분석 결과:\n`; const recommendMap: Record = { 'BUY': '💚 매수', 'SELL': '❤️ 매도', 'HOLD': '💛 관망' }; const trendMap: Record = { 'UP': '📈 상승', 'DOWN': '📉 하락', 'SIDEWAYS': '➡️ 횡보' }; analysis += ` 추천: ${recommendMap[data.recommendation] || data.recommendation}\n`; analysis += ` 신뢰도: ${data.confidence != null ? (data.confidence * 100).toFixed(0) : 'N/A'}%\n`; analysis += ` 추세: ${trendMap[data.trend] || data.trend}\n`; const skipMessages = [ '기술적 지표 데이터 없음', '기술적 지표 데이터 부족', 'AI 분석을 사용할 수 없습니다.', '충분한 신호 없음' ]; if (data.ai_analysis && !skipMessages.includes(data.ai_analysis)) { analysis += `\n💡 분석 근거:\n${data.ai_analysis}\n`; } return { analysis, data }; } catch (error: unknown) { const err = error as any; console.error('❌ AI 분석 실패:', error); return { analysis: `❌ AI 분석을 불러올 수 없습니다.\n${err.response?.data?.detail || err.message}`, data: null }; } }, enabled: !!selectedStock, staleTime: 30000, }); // useCallback으로 안정화 → 패널에 전달 시 매 렌더마다 새 함수 생성 방지 const stableRefetchAiAnalysis = useCallback(() => { refetchAiAnalysis() }, [refetchAiAnalysis]) const stableRefetchRecommendations = useCallback(() => { refetchRecommendations() }, [refetchRecommendations]) // 초기 종목 선택 (localStorage에 없을 때만 동작) useEffect(() => { if (!selectedStock) { if (portfolio && portfolio.length > 0) { handleSelectStock(portfolio[0].stock_code) } else if (ruleBasedRecommendations?.recommendations_by_rule?.length > 0) { const firstRule = ruleBasedRecommendations.recommendations_by_rule[0]; if (firstRule.stocks && firstRule.stocks.length > 0) { handleSelectStock(firstRule.stocks[0].stock_code) } } } }, [portfolio, ruleBasedRecommendations, selectedStock, handleSelectStock]) return (
{([['ALL', '전체'], ['KR', '국내'], ['US', '미국']] as const).map(([value, label]) => ( ))}
{tradeAccountNotice && ( )}
{/* [Left Column] 보유 종목 */}
{/* [Center Column] 차트 & 트레이딩 정보 */}
{/* 차트 섹션 */} {/* 하단: AI 투자 분석 & 호가창 - AiAnalysisPanel: selectedStock 변경 시에만 리렌더 - OrderbookPanel: 10초마다 독립 리렌더 (다른 패널 영향 없음) */}
{/* [Right Column] 분석 종목 & 매매일지 */}
); } export default Dashboard;