perf: Fix chart waterfall delay, add staleTime, fix aiAnalysis double-fetch

This commit is contained in:
2026-02-24 13:55:35 +09:00
parent a2b076f269
commit 06759711f7

View File

@@ -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<string>('')
// ✅ FIX 1: localStorage 초기값 사용 → portfolio 로드 전에 즉시 chart 쿼리 활성화
const [selectedStock, setSelectedStock] = useState<string>(
() => localStorage.getItem('selectedStock') || ''
)
const [chartPeriod, setChartPeriod] = useState<'D' | 'W' | 'M'>('D')
const queryClient = useQueryClient()
const prevLatestTradeIdRef = useRef<number | null>(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 = () => {
<div className="grid grid-cols-12 gap-4 flex-1 min-h-0">
{/* [Left Column] 보유 종목 (Holding Stocks) */}
{/* [Left Column] 보유 종목 */}
<div className="col-span-2 flex flex-col gap-4 h-full overflow-hidden">
<div className="bg-gray-800/50 backdrop-blur-sm border border-gray-700/50 rounded-xl overflow-hidden flex flex-col h-full shadow-lg">
<div className="p-3 border-b border-gray-700/50 flex justify-between items-center bg-[#1c222c]">
@@ -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 = () => {
>
<div
onClick={() => {
setSelectedStock(stock.stock_code);
handleSelectStock(stock.stock_code);
refetchAiAnalysis();
}}
className="cursor-pointer"
@@ -479,7 +451,6 @@ const Dashboard = () => {
</span>
</div>
</div>
{/* 청산 버튼 */}
<button
onClick={handleSell}
className="w-full mt-2 px-2 py-1 text-[10px] bg-blue-500/20 hover:bg-blue-500/30 text-blue-400 rounded border border-blue-500/30 transition-colors font-bold"
@@ -498,9 +469,9 @@ const Dashboard = () => {
</div>
</div>
{/* [Center Column] 차트 & 트레이딩 정보 (Chart, AI, Trade) */}
{/* [Center Column] 차트 & 트레이딩 정보 */}
<div className="col-span-7 flex flex-col h-full gap-4 overflow-hidden">
{/* 차트 섹션 (Top) */}
{/* 차트 섹션 */}
<div className="bg-gray-800/50 backdrop-blur-sm border border-gray-700/50 rounded-xl p-1 shadow-lg h-[45%] flex flex-col min-h-0">
<div className="px-5 py-3 flex justify-between items-center border-b border-gray-700/30">
<div>
@@ -543,7 +514,7 @@ const Dashboard = () => {
</div>
</div>
{/* 하단: AI 투자 분석 & 관련 뉴스 (Bottom Split) */}
{/* 하단: AI 투자 분석 & 호가창 */}
<div className="flex-1 grid grid-cols-2 gap-4 min-h-0">
{/* AI 투자 분석 */}
<div className="bg-gray-800/50 backdrop-blur-sm border border-gray-700/50 rounded-xl p-4 shadow-lg flex flex-col">
@@ -571,7 +542,7 @@ const Dashboard = () => {
</div>
</div>
{/* 호가창 - 증권사 스타일 (2컬럼) */}
{/* 호가창 */}
<div className="bg-gray-800/50 backdrop-blur-sm border border-gray-700/50 rounded-xl p-4 shadow-lg flex flex-col">
<h3 className="font-bold flex items-center gap-2 mb-3 text-yellow-400 text-sm">
<Activity size={16} />
@@ -579,9 +550,7 @@ const Dashboard = () => {
</h3>
<div className="overflow-y-auto custom-scrollbar flex-1">
{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 (
<div className="space-y-2">
{/* 매도 영역 (상단 10단계) */}
<div className="space-y-0.5">
{sequentialAsks.map((ask: any, i: number) => (
<div key={`ask-${i}`} className="grid grid-cols-3 items-center gap-2 py-1 px-2">
{/* 매도잔량 (좌측, 우측정렬) */}
<div className="text-xs text-gray-300 font-medium text-right font-mono">
{ask.volume > 0 ? ask.volume.toLocaleString() : '-'}
</div>
{/* 매도호가 (중앙, 빨간색) */}
<div className="text-sm font-bold text-red-400 font-mono text-center">
{ask.price.toLocaleString()}
</div>
{/* 빈 공간 */}
<div></div>
</div>
))}
</div>
{/* 현재가 (중앙 강조) */}
<div className="py-3 bg-gradient-to-r from-red-900/20 via-red-800/30 to-red-900/20 rounded-lg border-2 border-red-500/30 my-2">
<div className="flex items-center justify-center gap-4 px-4">
<div className="text-2xl font-bold text-red-400 font-mono">
@@ -646,22 +602,16 @@ const Dashboard = () => {
</div>
)}
</div>
<div className="text-xs text-red-300 mt-1 text-center">
연속 호가창
</div>
<div className="text-xs text-red-300 mt-1 text-center">연속 호가창</div>
</div>
{/* 매수 영역 (하단 10단계) */}
<div className="space-y-0.5">
{sequentialBids.map((bid: any, i: number) => (
<div key={`bid-${i}`} className="grid grid-cols-3 items-center gap-2 py-1 px-2">
{/* 빈 공간 */}
<div></div>
{/* 매수호가 (중앙, 파란색) */}
<div className="text-sm font-bold text-blue-400 font-mono text-center">
{bid.price.toLocaleString()}
</div>
{/* 매수잔량 (우측, 좌측정렬) */}
<div className="text-xs text-gray-300 font-medium text-left font-mono">
{bid.volume > 0 ? bid.volume.toLocaleString() : '-'}
</div>
@@ -680,9 +630,9 @@ const Dashboard = () => {
</div>
</div>
{/* [Right Column] 분석 종목 & 매수/체결 */}
{/* [Right Column] 분석 종목 & 매매일지 */}
<div className="col-span-3 flex flex-col gap-4 h-full overflow-hidden">
{/* 분석 종목 (Top) */}
{/* 분석 종목 */}
<div className="bg-gray-800/50 backdrop-blur-sm border border-gray-700/50 rounded-xl overflow-hidden flex flex-col h-1/2 shadow-lg">
<div className="p-3 border-b border-gray-700/50 flex justify-between items-center bg-[#1c222c]">
<h3 className="font-semibold flex items-center gap-2 text-gray-200 text-sm">
@@ -730,7 +680,7 @@ const Dashboard = () => {
<button
key={`${stock.stock_code}-${idx}`}
onClick={() => {
setSelectedStock(stock.stock_code);
handleSelectStock(stock.stock_code);
refetchAiAnalysis();
}}
className={`w-full p-3 text-left hover:bg-gray-700/30 transition-all duration-200 ${selectedStock === stock.stock_code ? 'bg-purple-500/10 border-l-2 border-purple-500' : 'border-l-2 border-transparent'}`}
@@ -782,7 +732,7 @@ const Dashboard = () => {
</div>
</div>
{/* 매매일지 (전체 너비) */}
{/* 매매일지 */}
<div className="bg-gray-800/50 backdrop-blur-sm border border-gray-700/50 rounded-xl overflow-hidden flex flex-col h-1/2 shadow-lg">
<div className="flex flex-col h-full p-3">
<div className="flex flex-col h-full min-h-0">
@@ -815,7 +765,7 @@ const Dashboard = () => {
<div key={journal.id} className="p-3 hover:bg-gray-700/30 rounded-lg transition-colors bg-gray-900/30 border border-gray-700/30">
<div className="flex justify-between items-start mb-1.5">
<div className="flex items-center gap-2">
<span className={`text-base ${isGoodMood ? '' : isBadMood ? '' : ''}`}>
<span className="text-base">
{isGoodMood ? '😊' : isBadMood ? '😔' : '📝'}
</span>
<span className="font-bold text-gray-200 text-sm">{journal.stock_name || journal.stock_code}</span>
@@ -855,4 +805,3 @@ const Dashboard = () => {
}
export default Dashboard;