diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index c69d83e..de6d09a 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -20,36 +20,37 @@ const checkMarketOpen = () => { const day = kstTime.getDay(); const currentMinutes = kstTime.getHours() * 60 + kstTime.getMinutes(); - // 주말 체크 (토요일 오전 7시 이후 ~ 일요일은 폐장) - if (day === 0 || (day === 6 && currentMinutes >= 420)) return false; // 일요일 or 토요일 7시 이후 + if (day === 0 || (day === 6 && currentMinutes >= 420)) return false; - // 한국장: 월~금 09:00 ~ 15:30 (540 ~ 930) const isKoreaOpen = currentMinutes >= 540 && currentMinutes <= 930; - - // 미국장 (서머타임 고려): - // - 여름(3월~11월): 22:30 ~ 다음날 05:00 (1350 ~ 1440 + 0 ~ 300) - // - 겨울(11월~3월): 23:30 ~ 다음날 06:00 (1410 ~ 1440 + 0 ~ 360) - // 간단히: 22:30 ~ 다음날 06:00 (1350 ~ 1440 + 0 ~ 360) const isUSOpen = currentMinutes >= 1350 || currentMinutes <= 360; - // 금요일 밤 ~ 토요일 새벽은 미국장 개장 (금요일 22:30 ~ 토요일 06:00) - if (day === 5 && currentMinutes >= 1350) return true; // 금요일 밤 - if (day === 6 && currentMinutes <= 360) return true; // 토요일 새벽 (미국 금요일) + if (day === 5 && currentMinutes >= 1350) return true; + if (day === 6 && currentMinutes <= 360) return true; return isKoreaOpen || isUSOpen; }; const Dashboard = () => { const { connected: wsConnected, messages: wsMessages } = useWebSocket('dashboard') - const [selectedStock, setSelectedStock] = useState('') + + // ✅ FIX 1: localStorage 초기값 사용 → portfolio 로드 전에 즉시 chart 쿼리 활성화 + const [selectedStock, setSelectedStock] = useState( + () => localStorage.getItem('selectedStock') || '' + ) const [chartPeriod, setChartPeriod] = useState<'D' | 'W' | 'M'>('D') const queryClient = useQueryClient() const prevLatestTradeIdRef = useRef(null) const isMarketOpen = checkMarketOpen(); + // ✅ FIX 1-b: 종목 선택 시 localStorage에 저장 (재진입 시 Waterfall 제거) + const handleSelectStock = (code: string) => { + setSelectedStock(code) + localStorage.setItem('selectedStock', code) + } + // --- Queries --- - // 장 마감 시에는 자동 폴링 완전 중단 (refetchInterval: false) const { data: portfolioSummary, error: portfolioSummaryError } = useQuery({ queryKey: ['portfolio-summary'], @@ -61,7 +62,6 @@ const Dashboard = () => { queryKey: ['portfolio'], queryFn: async () => { const data = (await api.get('/portfolio')).data; - // 수량 0인 종목 필터링 (안전장치) return data.filter((item: any) => item.quantity > 0); }, refetchInterval: isMarketOpen ? 10000 : false, @@ -70,7 +70,7 @@ const Dashboard = () => { const { data: mcpStatus } = useQuery({ queryKey: ['mcp-status'], queryFn: async () => (await api.get('/mcp/status')).data, - refetchInterval: isMarketOpen ? 30000 : false, // 5초 → 30초 (MCP 상태는 빈번한 폴링 불필요) + refetchInterval: isMarketOpen ? 30000 : false, staleTime: 20000, }) @@ -80,13 +80,12 @@ const Dashboard = () => { const response = await api.get('/recommendations/rule-based', { params: { realtime: false, - auto_trade: true // 자동매매 활성화 + auto_trade: true } }); const data = response.data; - // 실제로 거래가 체결된 경우에만 거래 내역 갱신 if (data.trades_executed && data.trades_executed.length > 0) { setTimeout(() => { queryClient.invalidateQueries({ queryKey: ['trade-history'] }); @@ -96,9 +95,9 @@ const Dashboard = () => { return data; }, - refetchInterval: isMarketOpen ? 120000 : 300000, // 장중 2분, 장외 5분 - staleTime: isMarketOpen ? 110000 : 290000, // refetchInterval보다 약간 짧게 → 재진입 시 즉시 표시 - refetchOnWindowFocus: false, // 탭 전환 시 불필요한 재요청 방지 (자동매매 중복 트리거 차단) + refetchInterval: isMarketOpen ? 120000 : 300000, + staleTime: isMarketOpen ? 110000 : 290000, + refetchOnWindowFocus: false, }) const { data: chartData, isLoading: chartLoading } = useQuery({ @@ -121,6 +120,8 @@ const Dashboard = () => { })).reverse(); }, enabled: Boolean(selectedStock), + // ✅ FIX 2: staleTime 추가 → 재진입 시 불필요한 재요청 방지 + staleTime: 60000, refetchInterval: isMarketOpen ? 10000 : false, }) @@ -137,8 +138,8 @@ const Dashboard = () => { } }, enabled: Boolean(selectedStock), - refetchInterval: isMarketOpen ? 5000 : false, // 5초마다 갱신 (API 부하 감소) - retry: 1, // 재시도 1회로 제한 + refetchInterval: isMarketOpen ? 5000 : false, + retry: 1, }) const { data: tradeHistory } = useQuery({ @@ -146,7 +147,6 @@ const Dashboard = () => { queryFn: async () => { const data = (await api.get('/trades', { params: { limit: 50 } })).data; - // ID 기반 중복 제거 (DB에서 이미 unique) const seen = new Set(); return data.filter((trade: any) => { if (seen.has(trade.id)) return false; @@ -166,21 +166,18 @@ const Dashboard = () => { refetchOnWindowFocus: true, }) - // 거래 이력이 변경되면 포트폴리오 즉시 갱신 (매매 발생 시에만 조회) + // 거래 이력이 변경되면 포트폴리오 즉시 갱신 useEffect(() => { if (tradeHistory && tradeHistory.length > 0) { const latestTrade = tradeHistory[0]; const latestId = latestTrade.id; - // 새로운 거래 ID가 감지되면 포트폴리오 강제 갱신 if (prevLatestTradeIdRef.current !== null && latestId > prevLatestTradeIdRef.current) { console.log('✅ 새 매매 감지:', latestTrade.stock_name, latestTrade.order_type, '→ 포트폴리오 갱신'); - // 포트폴리오 즉시 갱신 queryClient.invalidateQueries({ queryKey: ['portfolio'] }); queryClient.invalidateQueries({ queryKey: ['portfolio-summary'] }); - // 1초, 3초 후 추가 갱신 (백엔드 동기화 대기) setTimeout(() => { queryClient.invalidateQueries({ queryKey: ['portfolio'] }); queryClient.invalidateQueries({ queryKey: ['portfolio-summary'] }); @@ -203,13 +200,12 @@ const Dashboard = () => { } }, [wsMessages, queryClient]) - // 보유종목 코드만 수집 (현재가 일괄 실시간 조회용) - // 분석종목은 stock_analysis.current_price (DB값)를 폴백으로 사용하므로 실시간 조회 불필요 + // 보유종목 코드만 수집 const allStockCodes = useMemo(() => { return (portfolio || []).map((s: any) => s.stock_code) }, [portfolio]) - // 현재가 일괄 실시간 조회 (보유종목·분석종목 동기화) + // 현재가 일괄 실시간 조회 const { data: livePrices } = useQuery({ queryKey: ['live-prices', allStockCodes.join(',')], queryFn: async () => { @@ -224,54 +220,35 @@ const Dashboard = () => { staleTime: 5000, }) - // 보유 종목 코드 Set (추천 목록 필터링용) const heldStockCodes = new Set((portfolio || []).map((s: any) => s.stock_code)) - // 매수 진행 중인 종목 코드 Set (PENDING BUY 주문) const pendingBuyStockCodes = new Set( (tradeHistory || []) .filter((t: any) => t.status === 'PENDING' && t.trade_type === 'BUY') .map((t: any) => t.stock_code) ) - // 미체결 주문 정리 함수 const resetPendingTrades = async () => { try { const response = await api.delete('/trades/pending'); queryClient.invalidateQueries({ queryKey: ['trade-history'] }); console.log('✅ 미체결 주문 정리 완료:', response.data); - // alert 제거 - 콘솔 로그로만 확인 } catch (error: any) { console.error('미체결 주문 정리 실패:', error); - // alert 제거 - 콘솔 로그로만 확인 } }; - // 주기적 거래 내역 갱신 제거 - 실제 매매 발생 시에만 갱신 (138-164번 줄 로직 사용) - // useEffect(() => { - // if (!isMarketOpen) { - // return; - // } - // const interval = setInterval(() => { - // queryClient.invalidateQueries({ queryKey: ['trade-history'] }); - // }, 60000); - // return () => clearInterval(interval); - // }, [isMarketOpen, queryClient]) - const { data: aiAnalysisData, refetch: refetchAiAnalysis } = useQuery({ - // orderbook?.stock_name을 key에 포함: 호가 데이터 로드 후 종목명이 생기면 재조회 - queryKey: ['ai-analysis', selectedStock, orderbook?.stock_name || ''], + // ✅ FIX 3: orderbook?.stock_name 제거 → orderbook 로드 시 이중 fetch 방지 + queryKey: ['ai-analysis', selectedStock], queryFn: async () => { if (!selectedStock) return null; console.log('🤖 AI 분석 요청:', selectedStock); try { - // AI 분석 API 호출 const response = await api.get(`/market-data/ai-analysis/${selectedStock}`); const data = response.data; - // 분석 텍스트 생성 (종목명 없으면 여러 소스에서 순차 폴백) - // trim(): KIS API가 공백 문자열을 반환할 경우 대비 const stockNameFallback = orderbook?.stock_name?.trim() || portfolio?.find((s: any) => s.stock_code === selectedStock)?.stock_name?.trim() || @@ -280,12 +257,10 @@ const Dashboard = () => { const displayName = data.stock_name?.trim() || stockNameFallback || selectedStock let analysis = `📈 ${displayName} (${data.stock_code})\n\n`; - // 현재가 & 거래량 analysis += `💰 현재가: ${data.current_price.toLocaleString()}원\n`; analysis += `📊 고가/저가: ${data.high_price.toLocaleString()} / ${data.low_price.toLocaleString()}원\n`; analysis += `📦 거래량: ${(data.volume / 1000).toFixed(0)}K주\n`; - // 기술적 지표 (항상 표시) analysis += `\n📊 기술적 지표:\n`; const hasIndicators = data.indicators && Object.values(data.indicators).some(v => v !== null && v !== undefined); @@ -309,7 +284,6 @@ const Dashboard = () => { analysis += ` (DB에 저장된 지표 없음 - 배치 작업 대기중)\n`; } - // 추천 및 추세 analysis += `\n🤖 분석 결과:\n`; const recommendMap: any = { 'BUY': '💚 매수', @@ -325,7 +299,6 @@ const Dashboard = () => { analysis += ` 신뢰도: ${(data.confidence * 100).toFixed(0)}%\n`; analysis += ` 추세: ${trendMap[data.trend] || data.trend}\n`; - // 분석 근거 (의미있는 내용만 표시) const skipMessages = [ '기술적 지표 데이터 없음', '기술적 지표 데이터 부족', @@ -350,18 +323,17 @@ const Dashboard = () => { staleTime: 30000, }); - // Extract analysis text from the query result const aiAnalysisText = aiAnalysisData?.analysis || null; - // 초기 종목 선택 (포트폴리오가 있으면 첫번째, 아니면 추천 종목 첫번째) + // 초기 종목 선택 (localStorage에 없을 때만 동작) useEffect(() => { if (!selectedStock) { if (portfolio && portfolio.length > 0) { - setSelectedStock(portfolio[0].stock_code) + 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) { - setSelectedStock(firstRule.stocks[0].stock_code) + handleSelectStock(firstRule.stocks[0].stock_code) } } } @@ -401,7 +373,7 @@ const Dashboard = () => {
- {/* [Left Column] 보유 종목 (Holding Stocks) */} + {/* [Left Column] 보유 종목 */}
@@ -433,7 +405,7 @@ const Dashboard = () => { await api.post('/portfolio/sell', { stock_code: stock.stock_code, quantity: quantity, - price: null // 시장가 + price: null }); queryClient.invalidateQueries({ queryKey: ['portfolio'] }); queryClient.invalidateQueries({ queryKey: ['trade-history'] }); @@ -451,7 +423,7 @@ const Dashboard = () => { >
{ - setSelectedStock(stock.stock_code); + handleSelectStock(stock.stock_code); refetchAiAnalysis(); }} className="cursor-pointer" @@ -479,7 +451,6 @@ const Dashboard = () => {
- {/* 청산 버튼 */}
- {/* [Center Column] 차트 & 트레이딩 정보 (Chart, AI, Trade) */} + {/* [Center Column] 차트 & 트레이딩 정보 */}
- {/* 차트 섹션 (Top) */} + {/* 차트 섹션 */}
@@ -543,7 +514,7 @@ const Dashboard = () => {
- {/* 하단: AI 투자 분석 & 관련 뉴스 (Bottom Split) */} + {/* 하단: AI 투자 분석 & 호가창 */}
{/* AI 투자 분석 */}
@@ -571,7 +542,7 @@ const Dashboard = () => {
- {/* 호가창 - 증권사 스타일 (2컬럼) */} + {/* 호가창 */}

@@ -579,9 +550,7 @@ const Dashboard = () => {

{orderbook ? (() => { - // 현재가 주변 ±10단계를 10원 단위로 연속 생성 const currentPrice = orderbook.current_price; - // KRX 호가 단위 (코스피/코스닥 공통) const priceUnit = currentPrice < 1000 ? 1 : currentPrice < 5000 ? 5 @@ -592,49 +561,36 @@ const Dashboard = () => { : 1000; const steps = 10; - // 매도 호가: 현재가 + 10 ~ 현재가 + 100 (높은 가격부터) const sequentialAsks = []; for (let i = steps; i >= 1; i--) { const price = currentPrice + (priceUnit * i); const existingAsk = orderbook.asks?.find((ask: any) => ask.price === price); - sequentialAsks.push({ - price: price, - volume: existingAsk ? existingAsk.volume : 0 - }); + sequentialAsks.push({ price, volume: existingAsk ? existingAsk.volume : 0 }); } - // 매수 호가: 현재가 - 10 ~ 현재가 - 100 (높은 가격부터) const sequentialBids = []; for (let i = 1; i <= steps; i++) { const price = currentPrice - (priceUnit * i); const existingBid = orderbook.bids?.find((bid: any) => bid.price === price); - sequentialBids.push({ - price: price, - volume: existingBid ? existingBid.volume : 0 - }); + sequentialBids.push({ price, volume: existingBid ? existingBid.volume : 0 }); } return (
- {/* 매도 영역 (상단 10단계) */}
{sequentialAsks.map((ask: any, i: number) => (
- {/* 매도잔량 (좌측, 우측정렬) */}
{ask.volume > 0 ? ask.volume.toLocaleString() : '-'}
- {/* 매도호가 (중앙, 빨간색) */}
{ask.price.toLocaleString()}
- {/* 빈 공간 */}
))}
- {/* 현재가 (중앙 강조) */}
@@ -646,22 +602,16 @@ const Dashboard = () => {
)}
-
- 연속 호가창 -
+
연속 호가창
- {/* 매수 영역 (하단 10단계) */}
{sequentialBids.map((bid: any, i: number) => (
- {/* 빈 공간 */}
- {/* 매수호가 (중앙, 파란색) */}
{bid.price.toLocaleString()}
- {/* 매수잔량 (우측, 좌측정렬) */}
{bid.volume > 0 ? bid.volume.toLocaleString() : '-'}
@@ -680,9 +630,9 @@ const Dashboard = () => {
- {/* [Right Column] 분석 종목 & 매수/체결 */} + {/* [Right Column] 분석 종목 & 매매일지 */}
- {/* 분석 종목 (Top) */} + {/* 분석 종목 */}

@@ -730,7 +680,7 @@ const Dashboard = () => {

- {/* 매매일지 (전체 너비) */} + {/* 매매일지 */}
@@ -815,7 +765,7 @@ const Dashboard = () => {
- + {isGoodMood ? '😊' : isBadMood ? '😔' : '📝'} {journal.stock_name || journal.stock_code} @@ -855,4 +805,3 @@ const Dashboard = () => { } export default Dashboard; -