fix: 보유종목/자산은 매매 시에만 갱신 (polling 제거, WebSocket trade_executed 추가)

This commit is contained in:
2026-02-24 16:43:21 +09:00
parent 1c07a59352
commit d2b4b398e5

View File

@@ -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);
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<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} />