- 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>
986 lines
48 KiB
TypeScript
986 lines
48 KiB
TypeScript
/**
|
||
* 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;
|