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