diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index a522dbd..564c916 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -32,7 +32,7 @@ const checkMarketOpen = () => { const Dashboard = () => { const { connected: wsConnected, messages: wsMessages } = useWebSocket('dashboard') - // ✅ FIX 1: localStorage 초기값 사용 → portfolio 로드 전에 즉시 chart 쿼리 활성화 + // localStorage 초기값 사용 → portfolio 로드 전에 즉시 chart 쿼리 활성화 const [selectedStock, setSelectedStock] = useState( () => localStorage.getItem('selectedStock') || '' ) @@ -42,30 +42,37 @@ const Dashboard = () => { const isMarketOpen = checkMarketOpen(); - // ✅ FIX 1-b: 종목 선택 시 localStorage에 저장 (재진입 시 Waterfall 제거) + // 종목 선택 시 localStorage에 저장 (재진입 시 Waterfall 제거) // useCallback으로 안정화 → React.memo 패널에 전달 시 불필요한 리렌더 방지 const handleSelectStock = useCallback((code: string) => { setSelectedStock(code) localStorage.setItem('selectedStock', code) }, []) - // --- Queries --- + // ───────────────────────────────────────────────────────────── + // Queries + // 원칙: 매매가 일어날 때만 호출되어야 하는 것은 polling 제거 + staleTime: Infinity + // 실시간성이 필요한 것만 refetchInterval 유지 + // ───────────────────────────────────────────────────────────── + // ✅ 보유종목 요약 자산 - 매매 시에만 갱신 (polling 없음) const { data: portfolioSummary, error: portfolioSummaryError } = useQuery({ queryKey: ['portfolio-summary'], queryFn: async () => (await api.get('/portfolio/summary')).data, - refetchInterval: isMarketOpen ? 10000 : false, + staleTime: Infinity, }) + // ✅ 보유종목 목록 - 매매 시에만 갱신 (polling 없음) const { data: portfolio } = useQuery({ queryKey: ['portfolio'], queryFn: async () => { const data = (await api.get('/portfolio')).data; return data.filter((item: any) => item.quantity > 0); }, - refetchInterval: isMarketOpen ? 10000 : false, + staleTime: Infinity, }) + // MCP 상태 - 30초 (UI 정보용, 느려도 무방) const { data: mcpStatus } = useQuery({ queryKey: ['mcp-status'], queryFn: async () => (await api.get('/mcp/status')).data, @@ -73,6 +80,8 @@ const Dashboard = () => { staleTime: 20000, }) + // 추천 종목 (배치 분석 결과) - 2분 간격 (배치 주기 10분이므로 잦은 호출 불필요) + // trades_executed 발생 시: portfolio + trade-history 즉시 invalidate const { data: ruleBasedRecommendations, refetch: refetchRecommendations, isError: isRecommendationsError } = useQuery({ queryKey: ['rule-based-recommendations'], queryFn: async () => { @@ -85,11 +94,12 @@ const Dashboard = () => { const data = response.data; + // 자동매매 체결 감지 → 포트폴리오 즉시 갱신 (setTimeout 제거) if (data.trades_executed && data.trades_executed.length > 0) { - setTimeout(() => { - queryClient.invalidateQueries({ queryKey: ['trade-history'] }); - console.log('✅ 자동매매 체결:', data.trades_executed.length, '건'); - }, 3000); + console.log('✅ 자동매매 체결:', data.trades_executed.length, '건'); + queryClient.invalidateQueries({ queryKey: ['trade-history'] }); + queryClient.invalidateQueries({ queryKey: ['portfolio'] }); + queryClient.invalidateQueries({ queryKey: ['portfolio-summary'] }); } return data; @@ -99,6 +109,7 @@ const Dashboard = () => { refetchOnWindowFocus: false, }) + // 차트 데이터 - 30초 (캔들 갱신은 1분봉 기준이므로 10초는 과도) const { data: chartData, isLoading: chartLoading } = useQuery({ queryKey: ['chart-data', selectedStock, chartPeriod], queryFn: async () => { @@ -119,11 +130,11 @@ const Dashboard = () => { })).reverse(); }, enabled: Boolean(selectedStock), - // ✅ FIX 2: staleTime 추가 → 재진입 시 불필요한 재요청 방지 staleTime: 60000, - refetchInterval: isMarketOpen ? 10000 : false, + refetchInterval: isMarketOpen ? 30000 : false, }) + // 호가창 - 10초 (실시간성 필요하지만 5초는 과도) const { data: orderbook } = useQuery({ queryKey: ['orderbook', selectedStock], queryFn: async () => { @@ -137,10 +148,11 @@ const Dashboard = () => { } }, enabled: Boolean(selectedStock), - refetchInterval: isMarketOpen ? 5000 : false, + refetchInterval: isMarketOpen ? 10000 : false, retry: 1, }) + // 거래 이력 - 30초 (자동매매 체결 감지용) const { data: tradeHistory } = useQuery({ queryKey: ['trade-history'], queryFn: async () => { @@ -158,53 +170,60 @@ const Dashboard = () => { refetchOnWindowFocus: true, }) + // 매매일지 - 이벤트 기반 (WebSocket journal_created 또는 수동 갱신) const { data: journals } = useQuery({ queryKey: ['journals'], queryFn: async () => (await api.get('/journals', { params: { limit: 20 } })).data, - staleTime: 0, - refetchOnWindowFocus: true, + staleTime: Infinity, + refetchOnWindowFocus: false, }) - // 거래 이력이 변경되면 포트폴리오 즉시 갱신 + // ───────────────────────────────────────────────────────────── + // 매매 감지 → 보유종목/자산 갱신 (핵심 로직) + // tradeHistory polling(30s)에서 새 거래 ID 감지 시 1회만 invalidate + // ───────────────────────────────────────────────────────────── useEffect(() => { - if (tradeHistory && tradeHistory.length > 0) { - const latestTrade = tradeHistory[0]; - const latestId = latestTrade.id; + if (!tradeHistory || tradeHistory.length === 0) return; + const latestId = tradeHistory[0].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'] }); - - setTimeout(() => { - queryClient.invalidateQueries({ queryKey: ['portfolio'] }); - queryClient.invalidateQueries({ queryKey: ['portfolio-summary'] }); - }, 1000); - setTimeout(() => { - queryClient.invalidateQueries({ queryKey: ['portfolio'] }); - queryClient.invalidateQueries({ queryKey: ['portfolio-summary'] }); - }, 3000); - } - prevLatestTradeIdRef.current = latestId; + if (prevLatestTradeIdRef.current !== null && latestId > prevLatestTradeIdRef.current) { + const t = tradeHistory[0]; + console.log('✅ 새 매매 감지:', t.stock_name, t.trade_type, '→ 포트폴리오 갱신'); + // setTimeout 중복 제거 → 1회만 호출 + queryClient.invalidateQueries({ queryKey: ['portfolio'] }); + queryClient.invalidateQueries({ queryKey: ['portfolio-summary'] }); } + prevLatestTradeIdRef.current = latestId; }, [tradeHistory, queryClient]) - // WebSocket journal_created 이벤트 → 매매일지 즉시 갱신 + // ───────────────────────────────────────────────────────────── + // WebSocket 이벤트 처리 + // - trade_executed: 체결 즉시 포트폴리오 갱신 (백엔드에서 broadcast 필요) + // - journal_created: 매매일지 즉시 갱신 + // ───────────────────────────────────────────────────────────── useEffect(() => { - if (wsMessages.length === 0) return - const latest = wsMessages[wsMessages.length - 1] + if (wsMessages.length === 0) return; + const latest = wsMessages[wsMessages.length - 1]; + + if (latest.type === 'trade_executed') { + console.log('🔔 WebSocket trade_executed → 포트폴리오 즉시 갱신'); + queryClient.invalidateQueries({ queryKey: ['portfolio'] }); + queryClient.invalidateQueries({ queryKey: ['portfolio-summary'] }); + queryClient.invalidateQueries({ queryKey: ['trade-history'] }); + } + if (latest.type === 'journal_created') { - queryClient.invalidateQueries({ queryKey: ['journals'] }) + queryClient.invalidateQueries({ queryKey: ['journals'] }); } }, [wsMessages, queryClient]) - // 보유종목 코드만 수집 + // 보유종목 코드 목록 (useMemo로 안정화) const allStockCodes = useMemo(() => { return (portfolio || []).map((s: any) => s.stock_code) }, [portfolio]) - // 현재가 일괄 실시간 조회 + // ✅ 현재가 - 보유종목이 있을 때만, 30초 간격 + // (portfolio가 변경될 때만 queryKey도 바뀌어 자동 재조회) const { data: livePrices } = useQuery({ queryKey: ['live-prices', allStockCodes.join(',')], queryFn: async () => { @@ -215,8 +234,8 @@ const Dashboard = () => { return (response.data.prices || {}) as Record }, enabled: allStockCodes.length > 0, - refetchInterval: isMarketOpen ? 10000 : false, - staleTime: 5000, + refetchInterval: isMarketOpen ? 30000 : false, + staleTime: 20000, }) // useMemo로 Set 안정화 → React.memo 패널에 전달 시 레퍼런스 유지 @@ -247,7 +266,9 @@ const Dashboard = () => { // useCallback으로 안정화 → 패널 리렌더 방지 const handleSell = useCallback(async (stockCode: string, stockName: string, quantity: number) => { await api.post('/portfolio/sell', { stock_code: stockCode, quantity, price: null }) + // 매도 즉시 포트폴리오 갱신 queryClient.invalidateQueries({ queryKey: ['portfolio'] }) + queryClient.invalidateQueries({ queryKey: ['portfolio-summary'] }) queryClient.invalidateQueries({ queryKey: ['trade-history'] }) console.log(`✅ ${stockName} 매도 주문 완료`) }, [queryClient]) @@ -263,7 +284,6 @@ const Dashboard = () => { }, [queryClient]) const { data: aiAnalysisData, refetch: refetchAiAnalysis } = useQuery({ - // ✅ FIX 3: orderbook?.stock_name 제거 → orderbook 로드 시 이중 fetch 방지 queryKey: ['ai-analysis', selectedStock], queryFn: async () => { if (!selectedStock) return null; @@ -430,7 +450,7 @@ const Dashboard = () => { {/* 하단: AI 투자 분석 & 호가창 - AiAnalysisPanel: selectedStock 변경 시에만 리렌더 - - OrderbookPanel: 5초마다 독립 리렌더 (다른 패널 영향 없음) */} + - OrderbookPanel: 10초마다 독립 리렌더 (다른 패널 영향 없음) */}