Files
auto-stock/frontend/src/pages/Dashboard.tsx
shuim b4e958b477 feat(market): 대시보드 전체시장 지수 바 (KOSPI·KOSDAQ·KOSPI200·S&P500·NASDAQ)
- GET /api/v1/market/indices — 네이버 JSON API(비-KIS), 45초 캐시
- 부분 실패 시 직전값 병합, 전부 실패 시 12초 쿨다운 + 직전 캐시 반환
- 방향 code 해석 불가 시 원 부호 폴백, NaN/inf 거부
- MarketIndexBar: 60초 갱신, 상승 빨강/하락 파랑, 장 마감 배지
- 테스트 12건 (전체 877 passed)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 11:41:37 +09:00

986 lines
48 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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<MarketFilter>('ALL')
// 차트 데이터 (WebSocket chart_data 메시지로 로드)
const [chartData, setChartData] = useState<CandleData[] | null>(null)
const chartDataRef = useRef<CandleData[] | null>(null)
const [chartLoading, setChartLoading] = useState(false)
const chartLoadingRef = useRef(false)
// selectedStock / chartPeriod을 콜백 클로저에서 안전하게 참조하기 위한 ref
const selectedStockRef = useRef<string>('')
const chartPeriodRef = useRef<'D' | 'W' | 'M'>('D')
// localStorage 초기값 사용 → portfolio 로드 전에 즉시 chart 쿼리 활성화
const [selectedStock, setSelectedStock] = useState<string>(
() => 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<number | null>(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<string | null>(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<string, unknown>) => {
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<string, unknown>[]) || []
if (rawData.length === 0) {
chartDataRef.current = null
setChartData(null)
chartLoadingRef.current = false
setChartLoading(false)
return
}
const data = rawData.map((item: Record<string, unknown>) => ({
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<string, unknown>)
}
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<string>('')
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<number>()
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<Set<string>>(
() => new Set((portfolio || []).map((s) => s.stock_code as string)),
[portfolio]
)
const pendingBuyStockCodes = useMemo<Set<string>>(
() => 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<string, string> = {
'BUY': '💚 매수',
'SELL': '❤️ 매도',
'HOLD': '💛 관망'
};
const trendMap: Record<string, string> = {
'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 (
<div className="bg-[#080d16] text-[#c5d5e8] font-sans flex flex-col h-[calc(100vh-4rem)] overflow-hidden">
<main className="flex-1 p-4 max-w-[1920px] mx-auto w-full flex flex-col gap-3 overflow-hidden">
<div className="flex-shrink-0">
<MarketIndexBar />
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-0 bg-[#0c1522] border border-[#152035] rounded-sm overflow-hidden">
{([['ALL', '전체'], ['KR', '국내'], ['US', '미국']] as const).map(([value, label]) => (
<button
key={value}
onClick={() => setMarketFilter(value as MarketFilter)}
className={`px-3 py-1.5 text-[10px] font-mono transition-colors ${
marketFilter === value
? 'bg-[#1a3050] text-[#5b9aff]'
: 'text-[#4a6580] hover:text-[#8faabf]'
}`}
>
{label}
</button>
))}
</div>
</div>
{tradeAccountNotice && (
<AccountSetupNotice message={tradeAccountNotice} className="mb-2" />
)}
<PortfolioSummary
summary={liveSummary}
error={portfolioSummaryError?.message ?? null}
todayTradeStats={todayTradeStats}
accountSetupRequired={accountSetupRequired || accountStatus.required}
accountSetupMessage={accountSetupMessage || accountStatus.message}
/>
</div>
<div className="grid grid-cols-12 gap-3 flex-1 min-h-0">
{/* [Left Column] 보유 종목 */}
<div className="col-span-2 flex flex-col gap-3 h-full overflow-hidden">
<PortfolioPanel
portfolio={filteredPortfolio}
livePrices={livePrices}
selectedStock={selectedStock}
onSelectStock={handleSelectStock}
onRefetchAiAnalysis={stableRefetchAiAnalysis}
onSell={handleSellGuarded}
sellDisabled={!tradeActionsReady}
accountSetupRequired={accountSetupRequired || accountStatus.required}
accountSetupMessage={accountSetupMessage || accountStatus.message}
/>
</div>
{/* [Center Column] 차트 & 트레이딩 정보 */}
<div className="col-span-7 flex flex-col h-full gap-3 overflow-hidden">
{/* 차트 섹션 */}
<ChartPanel
chartData={chartData}
chartLoading={chartLoading}
selectedStock={selectedStock}
chartPeriod={chartPeriod}
onPeriodChange={setChartPeriod}
stockName={stockName}
livePrice={livePrice}
/>
{/* 하단: AI 투자 분석 & 호가창
- AiAnalysisPanel: selectedStock 변경 시에만 리렌더
- OrderbookPanel: 10초마다 독립 리렌더 (다른 패널 영향 없음) */}
<div className="flex-1 grid grid-cols-2 gap-3 min-h-0">
<AiAnalysisPanel aiAnalysisData={aiAnalysisData} />
<OrderbookPanel orderbook={activeOrderbook} />
</div>
</div>
{/* [Right Column] 분석 종목 & 매매일지 */}
<div className="col-span-3 flex flex-col gap-3 h-full overflow-hidden">
<RecommendationPanel
recommendations={ruleBasedRecommendations}
isError={isRecommendationsError}
onRefetch={stableRefetchRecommendations}
selectedStock={selectedStock}
onSelectStock={handleSelectStock}
onRefetchAiAnalysis={stableRefetchAiAnalysis}
heldStockCodes={heldStockCodes}
pendingBuyStockCodes={pendingBuyStockCodes}
livePrices={livePrices}
onBuy={handleBuyGuarded}
buyDisabled={!tradeActionsReady}
/>
<JournalPanel
journals={journals}
tradeHistory={tradeHistory}
onResetPending={resetPendingTrades}
resetDisabled={!tradeActionsReady}
/>
</div>
</div>
</main>
</div>
);
}
export default Dashboard;