fix: 보유종목/자산은 매매 시에만 갱신 (polling 제거, WebSocket trade_executed 추가)
This commit is contained in:
@@ -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<string>(
|
||||
() => 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);
|
||||
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, '→ 포트폴리오 갱신');
|
||||
|
||||
const t = tradeHistory[0];
|
||||
console.log('✅ 새 매매 감지:', t.stock_name, t.trade_type, '→ 포트폴리오 갱신');
|
||||
// setTimeout 중복 제거 → 1회만 호출
|
||||
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;
|
||||
}
|
||||
}, [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<string, number>
|
||||
},
|
||||
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초마다 독립 리렌더 (다른 패널 영향 없음) */}
|
||||
<div className="flex-1 grid grid-cols-2 gap-4 min-h-0">
|
||||
<AiAnalysisPanel aiAnalysisData={aiAnalysisData} />
|
||||
<OrderbookPanel orderbook={orderbook} />
|
||||
|
||||
Reference in New Issue
Block a user