perf: Fix chart waterfall delay, add staleTime, fix aiAnalysis double-fetch
This commit is contained in:
@@ -20,36 +20,37 @@ const checkMarketOpen = () => {
|
|||||||
const day = kstTime.getDay();
|
const day = kstTime.getDay();
|
||||||
const currentMinutes = kstTime.getHours() * 60 + kstTime.getMinutes();
|
const currentMinutes = kstTime.getHours() * 60 + kstTime.getMinutes();
|
||||||
|
|
||||||
// 주말 체크 (토요일 오전 7시 이후 ~ 일요일은 폐장)
|
if (day === 0 || (day === 6 && currentMinutes >= 420)) return false;
|
||||||
if (day === 0 || (day === 6 && currentMinutes >= 420)) return false; // 일요일 or 토요일 7시 이후
|
|
||||||
|
|
||||||
// 한국장: 월~금 09:00 ~ 15:30 (540 ~ 930)
|
|
||||||
const isKoreaOpen = currentMinutes >= 540 && currentMinutes <= 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;
|
const isUSOpen = currentMinutes >= 1350 || currentMinutes <= 360;
|
||||||
|
|
||||||
// 금요일 밤 ~ 토요일 새벽은 미국장 개장 (금요일 22:30 ~ 토요일 06:00)
|
if (day === 5 && currentMinutes >= 1350) return true;
|
||||||
if (day === 5 && currentMinutes >= 1350) return true; // 금요일 밤
|
if (day === 6 && currentMinutes <= 360) return true;
|
||||||
if (day === 6 && currentMinutes <= 360) return true; // 토요일 새벽 (미국 금요일)
|
|
||||||
|
|
||||||
return isKoreaOpen || isUSOpen;
|
return isKoreaOpen || isUSOpen;
|
||||||
};
|
};
|
||||||
|
|
||||||
const Dashboard = () => {
|
const Dashboard = () => {
|
||||||
const { connected: wsConnected, messages: wsMessages } = useWebSocket('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 [chartPeriod, setChartPeriod] = useState<'D' | 'W' | 'M'>('D')
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const prevLatestTradeIdRef = useRef<number | null>(null)
|
const prevLatestTradeIdRef = useRef<number | null>(null)
|
||||||
|
|
||||||
const isMarketOpen = checkMarketOpen();
|
const isMarketOpen = checkMarketOpen();
|
||||||
|
|
||||||
|
// ✅ FIX 1-b: 종목 선택 시 localStorage에 저장 (재진입 시 Waterfall 제거)
|
||||||
|
const handleSelectStock = (code: string) => {
|
||||||
|
setSelectedStock(code)
|
||||||
|
localStorage.setItem('selectedStock', code)
|
||||||
|
}
|
||||||
|
|
||||||
// --- Queries ---
|
// --- Queries ---
|
||||||
// 장 마감 시에는 자동 폴링 완전 중단 (refetchInterval: false)
|
|
||||||
|
|
||||||
const { data: portfolioSummary, error: portfolioSummaryError } = useQuery({
|
const { data: portfolioSummary, error: portfolioSummaryError } = useQuery({
|
||||||
queryKey: ['portfolio-summary'],
|
queryKey: ['portfolio-summary'],
|
||||||
@@ -61,7 +62,6 @@ const Dashboard = () => {
|
|||||||
queryKey: ['portfolio'],
|
queryKey: ['portfolio'],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const data = (await api.get('/portfolio')).data;
|
const data = (await api.get('/portfolio')).data;
|
||||||
// 수량 0인 종목 필터링 (안전장치)
|
|
||||||
return data.filter((item: any) => item.quantity > 0);
|
return data.filter((item: any) => item.quantity > 0);
|
||||||
},
|
},
|
||||||
refetchInterval: isMarketOpen ? 10000 : false,
|
refetchInterval: isMarketOpen ? 10000 : false,
|
||||||
@@ -70,7 +70,7 @@ const Dashboard = () => {
|
|||||||
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,
|
||||||
refetchInterval: isMarketOpen ? 30000 : false, // 5초 → 30초 (MCP 상태는 빈번한 폴링 불필요)
|
refetchInterval: isMarketOpen ? 30000 : false,
|
||||||
staleTime: 20000,
|
staleTime: 20000,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -80,13 +80,12 @@ const Dashboard = () => {
|
|||||||
const response = await api.get('/recommendations/rule-based', {
|
const response = await api.get('/recommendations/rule-based', {
|
||||||
params: {
|
params: {
|
||||||
realtime: false,
|
realtime: false,
|
||||||
auto_trade: true // 자동매매 활성화
|
auto_trade: true
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = response.data;
|
const data = response.data;
|
||||||
|
|
||||||
// 실제로 거래가 체결된 경우에만 거래 내역 갱신
|
|
||||||
if (data.trades_executed && data.trades_executed.length > 0) {
|
if (data.trades_executed && data.trades_executed.length > 0) {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['trade-history'] });
|
queryClient.invalidateQueries({ queryKey: ['trade-history'] });
|
||||||
@@ -96,9 +95,9 @@ const Dashboard = () => {
|
|||||||
|
|
||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
refetchInterval: isMarketOpen ? 120000 : 300000, // 장중 2분, 장외 5분
|
refetchInterval: isMarketOpen ? 120000 : 300000,
|
||||||
staleTime: isMarketOpen ? 110000 : 290000, // refetchInterval보다 약간 짧게 → 재진입 시 즉시 표시
|
staleTime: isMarketOpen ? 110000 : 290000,
|
||||||
refetchOnWindowFocus: false, // 탭 전환 시 불필요한 재요청 방지 (자동매매 중복 트리거 차단)
|
refetchOnWindowFocus: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
const { data: chartData, isLoading: chartLoading } = useQuery({
|
const { data: chartData, isLoading: chartLoading } = useQuery({
|
||||||
@@ -121,6 +120,8 @@ const Dashboard = () => {
|
|||||||
})).reverse();
|
})).reverse();
|
||||||
},
|
},
|
||||||
enabled: Boolean(selectedStock),
|
enabled: Boolean(selectedStock),
|
||||||
|
// ✅ FIX 2: staleTime 추가 → 재진입 시 불필요한 재요청 방지
|
||||||
|
staleTime: 60000,
|
||||||
refetchInterval: isMarketOpen ? 10000 : false,
|
refetchInterval: isMarketOpen ? 10000 : false,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -137,8 +138,8 @@ const Dashboard = () => {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
enabled: Boolean(selectedStock),
|
enabled: Boolean(selectedStock),
|
||||||
refetchInterval: isMarketOpen ? 5000 : false, // 5초마다 갱신 (API 부하 감소)
|
refetchInterval: isMarketOpen ? 5000 : false,
|
||||||
retry: 1, // 재시도 1회로 제한
|
retry: 1,
|
||||||
})
|
})
|
||||||
|
|
||||||
const { data: tradeHistory } = useQuery({
|
const { data: tradeHistory } = useQuery({
|
||||||
@@ -146,7 +147,6 @@ const Dashboard = () => {
|
|||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const data = (await api.get('/trades', { params: { limit: 50 } })).data;
|
const data = (await api.get('/trades', { params: { limit: 50 } })).data;
|
||||||
|
|
||||||
// ID 기반 중복 제거 (DB에서 이미 unique)
|
|
||||||
const seen = new Set();
|
const seen = new Set();
|
||||||
return data.filter((trade: any) => {
|
return data.filter((trade: any) => {
|
||||||
if (seen.has(trade.id)) return false;
|
if (seen.has(trade.id)) return false;
|
||||||
@@ -166,21 +166,18 @@ const Dashboard = () => {
|
|||||||
refetchOnWindowFocus: true,
|
refetchOnWindowFocus: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
// 거래 이력이 변경되면 포트폴리오 즉시 갱신 (매매 발생 시에만 조회)
|
// 거래 이력이 변경되면 포트폴리오 즉시 갱신
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (tradeHistory && tradeHistory.length > 0) {
|
if (tradeHistory && tradeHistory.length > 0) {
|
||||||
const latestTrade = tradeHistory[0];
|
const latestTrade = tradeHistory[0];
|
||||||
const latestId = latestTrade.id;
|
const latestId = latestTrade.id;
|
||||||
|
|
||||||
// 새로운 거래 ID가 감지되면 포트폴리오 강제 갱신
|
|
||||||
if (prevLatestTradeIdRef.current !== null && latestId > prevLatestTradeIdRef.current) {
|
if (prevLatestTradeIdRef.current !== null && latestId > prevLatestTradeIdRef.current) {
|
||||||
console.log('✅ 새 매매 감지:', latestTrade.stock_name, latestTrade.order_type, '→ 포트폴리오 갱신');
|
console.log('✅ 새 매매 감지:', latestTrade.stock_name, latestTrade.order_type, '→ 포트폴리오 갱신');
|
||||||
|
|
||||||
// 포트폴리오 즉시 갱신
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['portfolio'] });
|
queryClient.invalidateQueries({ queryKey: ['portfolio'] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['portfolio-summary'] });
|
queryClient.invalidateQueries({ queryKey: ['portfolio-summary'] });
|
||||||
|
|
||||||
// 1초, 3초 후 추가 갱신 (백엔드 동기화 대기)
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['portfolio'] });
|
queryClient.invalidateQueries({ queryKey: ['portfolio'] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['portfolio-summary'] });
|
queryClient.invalidateQueries({ queryKey: ['portfolio-summary'] });
|
||||||
@@ -203,13 +200,12 @@ const Dashboard = () => {
|
|||||||
}
|
}
|
||||||
}, [wsMessages, queryClient])
|
}, [wsMessages, queryClient])
|
||||||
|
|
||||||
// 보유종목 코드만 수집 (현재가 일괄 실시간 조회용)
|
// 보유종목 코드만 수집
|
||||||
// 분석종목은 stock_analysis.current_price (DB값)를 폴백으로 사용하므로 실시간 조회 불필요
|
|
||||||
const allStockCodes = useMemo(() => {
|
const allStockCodes = useMemo(() => {
|
||||||
return (portfolio || []).map((s: any) => s.stock_code)
|
return (portfolio || []).map((s: any) => s.stock_code)
|
||||||
}, [portfolio])
|
}, [portfolio])
|
||||||
|
|
||||||
// 현재가 일괄 실시간 조회 (보유종목·분석종목 동기화)
|
// 현재가 일괄 실시간 조회
|
||||||
const { data: livePrices } = useQuery({
|
const { data: livePrices } = useQuery({
|
||||||
queryKey: ['live-prices', allStockCodes.join(',')],
|
queryKey: ['live-prices', allStockCodes.join(',')],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
@@ -224,54 +220,35 @@ const Dashboard = () => {
|
|||||||
staleTime: 5000,
|
staleTime: 5000,
|
||||||
})
|
})
|
||||||
|
|
||||||
// 보유 종목 코드 Set (추천 목록 필터링용)
|
|
||||||
const heldStockCodes = new Set((portfolio || []).map((s: any) => s.stock_code))
|
const heldStockCodes = new Set((portfolio || []).map((s: any) => s.stock_code))
|
||||||
|
|
||||||
// 매수 진행 중인 종목 코드 Set (PENDING BUY 주문)
|
|
||||||
const pendingBuyStockCodes = new Set(
|
const pendingBuyStockCodes = new Set(
|
||||||
(tradeHistory || [])
|
(tradeHistory || [])
|
||||||
.filter((t: any) => t.status === 'PENDING' && t.trade_type === 'BUY')
|
.filter((t: any) => t.status === 'PENDING' && t.trade_type === 'BUY')
|
||||||
.map((t: any) => t.stock_code)
|
.map((t: any) => t.stock_code)
|
||||||
)
|
)
|
||||||
|
|
||||||
// 미체결 주문 정리 함수
|
|
||||||
const resetPendingTrades = async () => {
|
const resetPendingTrades = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await api.delete('/trades/pending');
|
const response = await api.delete('/trades/pending');
|
||||||
queryClient.invalidateQueries({ queryKey: ['trade-history'] });
|
queryClient.invalidateQueries({ queryKey: ['trade-history'] });
|
||||||
console.log('✅ 미체결 주문 정리 완료:', response.data);
|
console.log('✅ 미체결 주문 정리 완료:', response.data);
|
||||||
// alert 제거 - 콘솔 로그로만 확인
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('미체결 주문 정리 실패:', error);
|
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({
|
const { data: aiAnalysisData, refetch: refetchAiAnalysis } = useQuery({
|
||||||
// orderbook?.stock_name을 key에 포함: 호가 데이터 로드 후 종목명이 생기면 재조회
|
// ✅ FIX 3: orderbook?.stock_name 제거 → orderbook 로드 시 이중 fetch 방지
|
||||||
queryKey: ['ai-analysis', selectedStock, orderbook?.stock_name || ''],
|
queryKey: ['ai-analysis', selectedStock],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
if (!selectedStock) return null;
|
if (!selectedStock) return null;
|
||||||
console.log('🤖 AI 분석 요청:', selectedStock);
|
console.log('🤖 AI 분석 요청:', selectedStock);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// AI 분석 API 호출
|
|
||||||
const response = await api.get(`/market-data/ai-analysis/${selectedStock}`);
|
const response = await api.get(`/market-data/ai-analysis/${selectedStock}`);
|
||||||
const data = response.data;
|
const data = response.data;
|
||||||
|
|
||||||
// 분석 텍스트 생성 (종목명 없으면 여러 소스에서 순차 폴백)
|
|
||||||
// trim(): KIS API가 공백 문자열을 반환할 경우 대비
|
|
||||||
const stockNameFallback =
|
const stockNameFallback =
|
||||||
orderbook?.stock_name?.trim() ||
|
orderbook?.stock_name?.trim() ||
|
||||||
portfolio?.find((s: any) => s.stock_code === selectedStock)?.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
|
const displayName = data.stock_name?.trim() || stockNameFallback || selectedStock
|
||||||
let analysis = `📈 ${displayName} (${data.stock_code})\n\n`;
|
let analysis = `📈 ${displayName} (${data.stock_code})\n\n`;
|
||||||
|
|
||||||
// 현재가 & 거래량
|
|
||||||
analysis += `💰 현재가: ${data.current_price.toLocaleString()}원\n`;
|
analysis += `💰 현재가: ${data.current_price.toLocaleString()}원\n`;
|
||||||
analysis += `📊 고가/저가: ${data.high_price.toLocaleString()} / ${data.low_price.toLocaleString()}원\n`;
|
analysis += `📊 고가/저가: ${data.high_price.toLocaleString()} / ${data.low_price.toLocaleString()}원\n`;
|
||||||
analysis += `📦 거래량: ${(data.volume / 1000).toFixed(0)}K주\n`;
|
analysis += `📦 거래량: ${(data.volume / 1000).toFixed(0)}K주\n`;
|
||||||
|
|
||||||
// 기술적 지표 (항상 표시)
|
|
||||||
analysis += `\n📊 기술적 지표:\n`;
|
analysis += `\n📊 기술적 지표:\n`;
|
||||||
const hasIndicators = data.indicators && Object.values(data.indicators).some(v => v !== null && v !== undefined);
|
const hasIndicators = data.indicators && Object.values(data.indicators).some(v => v !== null && v !== undefined);
|
||||||
|
|
||||||
@@ -309,7 +284,6 @@ const Dashboard = () => {
|
|||||||
analysis += ` (DB에 저장된 지표 없음 - 배치 작업 대기중)\n`;
|
analysis += ` (DB에 저장된 지표 없음 - 배치 작업 대기중)\n`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 추천 및 추세
|
|
||||||
analysis += `\n🤖 분석 결과:\n`;
|
analysis += `\n🤖 분석 결과:\n`;
|
||||||
const recommendMap: any = {
|
const recommendMap: any = {
|
||||||
'BUY': '💚 매수',
|
'BUY': '💚 매수',
|
||||||
@@ -325,7 +299,6 @@ const Dashboard = () => {
|
|||||||
analysis += ` 신뢰도: ${(data.confidence * 100).toFixed(0)}%\n`;
|
analysis += ` 신뢰도: ${(data.confidence * 100).toFixed(0)}%\n`;
|
||||||
analysis += ` 추세: ${trendMap[data.trend] || data.trend}\n`;
|
analysis += ` 추세: ${trendMap[data.trend] || data.trend}\n`;
|
||||||
|
|
||||||
// 분석 근거 (의미있는 내용만 표시)
|
|
||||||
const skipMessages = [
|
const skipMessages = [
|
||||||
'기술적 지표 데이터 없음',
|
'기술적 지표 데이터 없음',
|
||||||
'기술적 지표 데이터 부족',
|
'기술적 지표 데이터 부족',
|
||||||
@@ -350,18 +323,17 @@ const Dashboard = () => {
|
|||||||
staleTime: 30000,
|
staleTime: 30000,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Extract analysis text from the query result
|
|
||||||
const aiAnalysisText = aiAnalysisData?.analysis || null;
|
const aiAnalysisText = aiAnalysisData?.analysis || null;
|
||||||
|
|
||||||
// 초기 종목 선택 (포트폴리오가 있으면 첫번째, 아니면 추천 종목 첫번째)
|
// 초기 종목 선택 (localStorage에 없을 때만 동작)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedStock) {
|
if (!selectedStock) {
|
||||||
if (portfolio && portfolio.length > 0) {
|
if (portfolio && portfolio.length > 0) {
|
||||||
setSelectedStock(portfolio[0].stock_code)
|
handleSelectStock(portfolio[0].stock_code)
|
||||||
} else if (ruleBasedRecommendations?.recommendations_by_rule?.length > 0) {
|
} else if (ruleBasedRecommendations?.recommendations_by_rule?.length > 0) {
|
||||||
const firstRule = ruleBasedRecommendations.recommendations_by_rule[0];
|
const firstRule = ruleBasedRecommendations.recommendations_by_rule[0];
|
||||||
if (firstRule.stocks && firstRule.stocks.length > 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">
|
<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="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="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]">
|
<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', {
|
await api.post('/portfolio/sell', {
|
||||||
stock_code: stock.stock_code,
|
stock_code: stock.stock_code,
|
||||||
quantity: quantity,
|
quantity: quantity,
|
||||||
price: null // 시장가
|
price: null
|
||||||
});
|
});
|
||||||
queryClient.invalidateQueries({ queryKey: ['portfolio'] });
|
queryClient.invalidateQueries({ queryKey: ['portfolio'] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['trade-history'] });
|
queryClient.invalidateQueries({ queryKey: ['trade-history'] });
|
||||||
@@ -451,7 +423,7 @@ const Dashboard = () => {
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSelectedStock(stock.stock_code);
|
handleSelectStock(stock.stock_code);
|
||||||
refetchAiAnalysis();
|
refetchAiAnalysis();
|
||||||
}}
|
}}
|
||||||
className="cursor-pointer"
|
className="cursor-pointer"
|
||||||
@@ -479,7 +451,6 @@ const Dashboard = () => {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* 청산 버튼 */}
|
|
||||||
<button
|
<button
|
||||||
onClick={handleSell}
|
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"
|
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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* [Center Column] 차트 & 트레이딩 정보 (Chart, AI, Trade) */}
|
{/* [Center Column] 차트 & 트레이딩 정보 */}
|
||||||
<div className="col-span-7 flex flex-col h-full gap-4 overflow-hidden">
|
<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="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 className="px-5 py-3 flex justify-between items-center border-b border-gray-700/30">
|
||||||
<div>
|
<div>
|
||||||
@@ -543,7 +514,7 @@ const Dashboard = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 하단: AI 투자 분석 & 관련 뉴스 (Bottom Split) */}
|
{/* 하단: AI 투자 분석 & 호가창 */}
|
||||||
<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">
|
||||||
{/* AI 투자 분석 */}
|
{/* AI 투자 분석 */}
|
||||||
<div className="bg-gray-800/50 backdrop-blur-sm border border-gray-700/50 rounded-xl p-4 shadow-lg flex flex-col">
|
<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>
|
||||||
</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">
|
<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">
|
<h3 className="font-bold flex items-center gap-2 mb-3 text-yellow-400 text-sm">
|
||||||
<Activity size={16} />
|
<Activity size={16} />
|
||||||
@@ -579,9 +550,7 @@ const Dashboard = () => {
|
|||||||
</h3>
|
</h3>
|
||||||
<div className="overflow-y-auto custom-scrollbar flex-1">
|
<div className="overflow-y-auto custom-scrollbar flex-1">
|
||||||
{orderbook ? (() => {
|
{orderbook ? (() => {
|
||||||
// 현재가 주변 ±10단계를 10원 단위로 연속 생성
|
|
||||||
const currentPrice = orderbook.current_price;
|
const currentPrice = orderbook.current_price;
|
||||||
// KRX 호가 단위 (코스피/코스닥 공통)
|
|
||||||
const priceUnit =
|
const priceUnit =
|
||||||
currentPrice < 1000 ? 1
|
currentPrice < 1000 ? 1
|
||||||
: currentPrice < 5000 ? 5
|
: currentPrice < 5000 ? 5
|
||||||
@@ -592,49 +561,36 @@ const Dashboard = () => {
|
|||||||
: 1000;
|
: 1000;
|
||||||
const steps = 10;
|
const steps = 10;
|
||||||
|
|
||||||
// 매도 호가: 현재가 + 10 ~ 현재가 + 100 (높은 가격부터)
|
|
||||||
const sequentialAsks = [];
|
const sequentialAsks = [];
|
||||||
for (let i = steps; i >= 1; i--) {
|
for (let i = steps; i >= 1; i--) {
|
||||||
const price = currentPrice + (priceUnit * i);
|
const price = currentPrice + (priceUnit * i);
|
||||||
const existingAsk = orderbook.asks?.find((ask: any) => ask.price === price);
|
const existingAsk = orderbook.asks?.find((ask: any) => ask.price === price);
|
||||||
sequentialAsks.push({
|
sequentialAsks.push({ price, volume: existingAsk ? existingAsk.volume : 0 });
|
||||||
price: price,
|
|
||||||
volume: existingAsk ? existingAsk.volume : 0
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 매수 호가: 현재가 - 10 ~ 현재가 - 100 (높은 가격부터)
|
|
||||||
const sequentialBids = [];
|
const sequentialBids = [];
|
||||||
for (let i = 1; i <= steps; i++) {
|
for (let i = 1; i <= steps; i++) {
|
||||||
const price = currentPrice - (priceUnit * i);
|
const price = currentPrice - (priceUnit * i);
|
||||||
const existingBid = orderbook.bids?.find((bid: any) => bid.price === price);
|
const existingBid = orderbook.bids?.find((bid: any) => bid.price === price);
|
||||||
sequentialBids.push({
|
sequentialBids.push({ price, volume: existingBid ? existingBid.volume : 0 });
|
||||||
price: price,
|
|
||||||
volume: existingBid ? existingBid.volume : 0
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{/* 매도 영역 (상단 10단계) */}
|
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
{sequentialAsks.map((ask: any, i: number) => (
|
{sequentialAsks.map((ask: any, i: number) => (
|
||||||
<div key={`ask-${i}`} className="grid grid-cols-3 items-center gap-2 py-1 px-2">
|
<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">
|
<div className="text-xs text-gray-300 font-medium text-right font-mono">
|
||||||
{ask.volume > 0 ? ask.volume.toLocaleString() : '-'}
|
{ask.volume > 0 ? ask.volume.toLocaleString() : '-'}
|
||||||
</div>
|
</div>
|
||||||
{/* 매도호가 (중앙, 빨간색) */}
|
|
||||||
<div className="text-sm font-bold text-red-400 font-mono text-center">
|
<div className="text-sm font-bold text-red-400 font-mono text-center">
|
||||||
{ask.price.toLocaleString()}
|
{ask.price.toLocaleString()}
|
||||||
</div>
|
</div>
|
||||||
{/* 빈 공간 */}
|
|
||||||
<div></div>
|
<div></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="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="flex items-center justify-center gap-4 px-4">
|
||||||
<div className="text-2xl font-bold text-red-400 font-mono">
|
<div className="text-2xl font-bold text-red-400 font-mono">
|
||||||
@@ -646,22 +602,16 @@ const Dashboard = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-red-300 mt-1 text-center">
|
<div className="text-xs text-red-300 mt-1 text-center">연속 호가창</div>
|
||||||
연속 호가창
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 매수 영역 (하단 10단계) */}
|
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
{sequentialBids.map((bid: any, i: number) => (
|
{sequentialBids.map((bid: any, i: number) => (
|
||||||
<div key={`bid-${i}`} className="grid grid-cols-3 items-center gap-2 py-1 px-2">
|
<div key={`bid-${i}`} className="grid grid-cols-3 items-center gap-2 py-1 px-2">
|
||||||
{/* 빈 공간 */}
|
|
||||||
<div></div>
|
<div></div>
|
||||||
{/* 매수호가 (중앙, 파란색) */}
|
|
||||||
<div className="text-sm font-bold text-blue-400 font-mono text-center">
|
<div className="text-sm font-bold text-blue-400 font-mono text-center">
|
||||||
{bid.price.toLocaleString()}
|
{bid.price.toLocaleString()}
|
||||||
</div>
|
</div>
|
||||||
{/* 매수잔량 (우측, 좌측정렬) */}
|
|
||||||
<div className="text-xs text-gray-300 font-medium text-left font-mono">
|
<div className="text-xs text-gray-300 font-medium text-left font-mono">
|
||||||
{bid.volume > 0 ? bid.volume.toLocaleString() : '-'}
|
{bid.volume > 0 ? bid.volume.toLocaleString() : '-'}
|
||||||
</div>
|
</div>
|
||||||
@@ -680,9 +630,9 @@ const Dashboard = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* [Right Column] 분석 종목 & 매수/체결 */}
|
{/* [Right Column] 분석 종목 & 매매일지 */}
|
||||||
<div className="col-span-3 flex flex-col gap-4 h-full overflow-hidden">
|
<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="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]">
|
<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">
|
<h3 className="font-semibold flex items-center gap-2 text-gray-200 text-sm">
|
||||||
@@ -730,7 +680,7 @@ const Dashboard = () => {
|
|||||||
<button
|
<button
|
||||||
key={`${stock.stock_code}-${idx}`}
|
key={`${stock.stock_code}-${idx}`}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSelectedStock(stock.stock_code);
|
handleSelectStock(stock.stock_code);
|
||||||
refetchAiAnalysis();
|
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'}`}
|
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>
|
</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="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 p-3">
|
||||||
<div className="flex flex-col h-full min-h-0">
|
<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 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 justify-between items-start mb-1.5">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className={`text-base ${isGoodMood ? '' : isBadMood ? '' : ''}`}>
|
<span className="text-base">
|
||||||
{isGoodMood ? '😊' : isBadMood ? '😔' : '📝'}
|
{isGoodMood ? '😊' : isBadMood ? '😔' : '📝'}
|
||||||
</span>
|
</span>
|
||||||
<span className="font-bold text-gray-200 text-sm">{journal.stock_name || journal.stock_code}</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;
|
export default Dashboard;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user