Files
auto-stock/frontend/src/pages/Dashboard.tsx
2025-11-14 11:05:31 +09:00

923 lines
48 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useQuery } from '@tanstack/react-query'
import api from '../lib/api'
import { useWebSocket } from '../hooks/useWebSocket'
import { useEffect, useMemo, useState } from 'react'
import HtsPage from '../components/HtsPage'
import CandlestickChart from '../components/CandlestickChart'
import './Dashboard.css'
const Dashboard = () => {
console.log('🚀 Dashboard component mounted')
const { connected: wsConnected } = useWebSocket('dashboard')
const [selectedStock, setSelectedStock] = useState<string>('')
const [chartPeriod, setChartPeriod] = useState<'D' | 'W' | 'M'>('D')
const [showIndicators, setShowIndicators] = useState(false)
const [indicators, setIndicators] = useState({
use_macd: false,
use_rsi: true,
use_bollinger: false,
use_moving_average: true,
use_stochastic: false,
use_adx: false,
use_obv: false,
})
console.log('🔧 Setting up queries...')
const { data: portfolioSummary, error: portfolioSummaryError } = useQuery({
queryKey: ['portfolio-summary'],
queryFn: async () => {
console.log('🔵 Fetching portfolio summary...')
const response = await api.get('/portfolio/summary')
console.log('🟢 Portfolio summary received:', response.data)
return response.data
},
refetchInterval: 5000,
})
const { data: portfolio, error: portfolioError } = useQuery({
queryKey: ['portfolio'],
queryFn: async () => {
console.log('🔵 Fetching portfolio...')
try {
const response = await api.get('/portfolio')
console.log('🟢 Portfolio received:', response.data)
return response.data
} catch (error) {
console.error('❌ Portfolio fetch error:', error)
throw error
}
},
refetchInterval: 5000,
retry: 1,
})
// 에러 로깅
useEffect(() => {
if (portfolioSummaryError) {
console.error('❌ Portfolio Summary Error:', portfolioSummaryError)
}
if (portfolioError) {
console.error('❌ Portfolio Error:', portfolioError)
}
}, [portfolioSummaryError, portfolioError])
const { data: recentTrades } = useQuery({
queryKey: ['recent-trades'],
queryFn: async () => {
const response = await api.get('/trades', { params: { limit: 20 } })
return response.data
},
refetchInterval: 5000,
})
const { data: mcpStatus } = useQuery({
queryKey: ['mcp-status'],
queryFn: async () => {
const response = await api.get('/mcp/status')
return response.data
},
refetchInterval: 5000,
})
const { data: news } = useQuery({
queryKey: ['market-news'],
queryFn: async () => {
const response = await api.get('/market-data/news', { params: { limit: 5 } })
return response.data
},
refetchInterval: 30000,
})
// 규칙 기반 자동 추천 (자동 로드)
const { data: ruleBasedRecommendations } = useQuery({
queryKey: ['rule-based-recommendations'],
queryFn: async () => {
console.log('🔵 Fetching rule-based recommendations...')
try {
const response = await api.get('/recommendations/rule-based')
console.log('🟢 Rule-based recommendations received:', response.data)
return response.data
} catch (error) {
console.error('🔴 Rule-based recommendations error:', error)
throw error
}
},
refetchInterval: 300000, // 5분마다 자동 갱신
})
// 수동 지표 분석 (AI 분석 버튼용)
const { data: aiRecommendations, refetch: refetchRecommendations, isLoading: recommendationsLoading, error: recommendationsError } = useQuery({
queryKey: ['ai-recommendations', indicators],
queryFn: async () => {
console.log('🔵 Fetching recommendations with indicators:', indicators)
try {
const response = await api.get('/recommendations', { params: indicators })
console.log('🟢 Recommendations received:', response.data)
return response.data
} catch (error) {
console.error('🔴 Recommendations fetch error:', error)
throw error
}
},
enabled: false, // 수동으로만 호출
})
// 추천 종목 에러 로깅
if (recommendationsError) {
console.error('❌ Recommendations query error:', recommendationsError)
}
// 규칙 기반 추천 로깅
if (ruleBasedRecommendations) {
console.log('✅ Rule-based Recommendations:', {
total: ruleBasedRecommendations.total,
activeRules: ruleBasedRecommendations.active_rules,
found: ruleBasedRecommendations.recommendations?.length || 0
})
}
// 수동 추천 결과 로깅
if (aiRecommendations) {
console.log('✅ AI Recommendations:', {
total: aiRecommendations.total,
analyzed: aiRecommendations.analyzed_stocks,
found: aiRecommendations.recommendations?.length || 0
})
}
const { data: chartData, isLoading: chartLoading, error: chartError } = useQuery({
queryKey: ['chart-data', selectedStock, chartPeriod],
queryFn: async () => {
if (!selectedStock) {
console.log('⚠️ No stock selected, skipping chart fetch')
return null
}
console.log('🔵 Fetching chart data for:', selectedStock, 'period:', chartPeriod)
try {
const response = await api.get(`/market-data/chart/${selectedStock}`, {
params: { period: chartPeriod },
})
console.log('🟢 Chart data received:', {
stock_code: response.data.stock_code,
selectedStock: selectedStock,
dataLength: response.data.data?.length,
firstPrice: response.data.data?.[0]?.close,
match: response.data.stock_code === selectedStock
})
return response.data
} catch (error) {
console.error('🔴 Chart data fetch error:', error)
throw error
}
},
enabled: Boolean(selectedStock),
refetchInterval: 60000,
})
// 차트 에러 로깅
if (chartError) {
console.error('❌ Chart query error:', chartError)
}
// AI 종합 분석 (선택된 종목에 대해)
const { data: aiAnalysis, isLoading: aiAnalysisLoading } = useQuery({
queryKey: ['ai-analysis', selectedStock, chartData],
queryFn: async () => {
if (!selectedStock || !chartData?.data?.[0]) return null
console.log('🔵 Fetching AI analysis for:', selectedStock)
try {
const response = await fetch('http://localhost:8001/analyze', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
analysis_type: 'MARKET_ANALYSIS',
data: {
stock_code: selectedStock,
market_data: {
stock_name: chartData?.stock_name || selectedStock,
current_price: Number(chartData.data[0].close),
change_rate: Number(chartData.data[0].change_rate || 0),
volume: Number(chartData.data[0].volume || 0),
high: Number(chartData.data[0].high),
low: Number(chartData.data[0].low)
},
news: []
}
})
})
const result = await response.json()
console.log('🟢 AI analysis received:', result)
return result.result || result.analysis
} catch (error) {
console.error('🔴 AI analysis error:', error)
return '⚠️ AI 분석 서비스에 연결할 수 없습니다.'
}
},
enabled: Boolean(selectedStock && chartData?.data?.[0]),
refetchInterval: 300000, // 5분마다
})
// 리스크 체크
const { data: riskCheck, isLoading: riskCheckLoading } = useQuery({
queryKey: ['risk-check', selectedStock, portfolio, chartData],
queryFn: async () => {
if (!selectedStock || !chartData?.data?.[0]) return null
console.log('🔵 Fetching risk check for:', selectedStock)
try {
const response = await fetch('http://localhost:8001/analyze', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
analysis_type: 'RISK_ASSESSMENT',
data: {
portfolio: {
total_value: portfolioSummary?.total_value || 0,
total_stocks: portfolioSummary?.total_stocks || 0,
holdings: portfolio || []
},
trade_plan: {
stock_code: selectedStock,
stock_name: chartData?.stock_name || selectedStock,
current_price: Number(chartData.data[0].close),
action: 'BUY',
quantity: 1
}
}
})
})
const result = await response.json()
console.log('🟢 Risk check received:', result)
return result.result || result.analysis
} catch (error) {
console.error('🔴 Risk check error:', error)
return '⚠️ 리스크 분석 서비스에 연결할 수 없습니다.'
}
},
enabled: Boolean(selectedStock && chartData?.data?.[0]),
refetchInterval: 300000, // 5분마다
})
// 차트 상단에 표시할 종목 리스트 생성
const displayStocks = useMemo(() => {
// 1. 규칙 기반 추천이 있으면 우선 사용
if (ruleBasedRecommendations?.recommendations_by_rule?.length > 0) {
const stocks: any[] = []
ruleBasedRecommendations.recommendations_by_rule.forEach((ruleGroup: any) => {
if (ruleGroup.stocks && stocks.length < 4) {
ruleGroup.stocks.slice(0, 4 - stocks.length).forEach((stock: any) => {
stocks.push(stock)
})
}
})
return stocks.slice(0, 4)
}
// 2. 포트폴리오가 있으면 사용
if (portfolio && portfolio.length > 0) {
return [...portfolio]
.sort((a: any, b: any) => (b.profit_loss_rate ?? 0) - (a.profit_loss_rate ?? 0))
.slice(0, 4)
}
// 3. 둘 다 없으면 빈 배열 (테스트 데이터 제거)
return []
}, [ruleBasedRecommendations, portfolio])
// 표시할 종목들의 최신 가격 가져오기
const stockCodesToFetch = displayStocks.map((item: any) => item.stock_code)
const { data: stockPrices = {} } = useQuery<Record<string, number>>({
queryKey: ['all-stock-prices', stockCodesToFetch.join(',')],
queryFn: async () => {
if (stockCodesToFetch.length === 0) {
return {}
}
const promises = stockCodesToFetch.map(async (code: string) => {
try {
const response = await api.get(`/market-data/chart/${code}`, {
params: { period: 'D' },
})
// KIS API는 최신 데이터가 첫번째 (index 0)
const latestPrice = response.data?.data?.[0]?.close || 0
return { code, price: Number(latestPrice) }
} catch (error) {
console.error(`Failed to fetch price for ${code}:`, error)
return { code, price: 0 }
}
})
const results = await Promise.all(promises)
return results.reduce((acc: Record<string, number>, item) => {
acc[item.code] = item.price
return acc
}, {})
},
enabled: stockCodesToFetch.length > 0,
refetchInterval: 60000,
})
const recommendedStocks = useMemo(() => {
console.log('💡 Building recommendedStocks:', {
displayStocksCount: displayStocks.length,
stockPricesCount: Object.keys(stockPrices).length,
stockPrices
})
return displayStocks.map((item: any) => {
const priceFromQuery = stockPrices[item.stock_code]
const finalPrice = priceFromQuery || item.current_price || 0
console.log(` - ${item.stock_code}: query=${priceFromQuery}, original=${item.current_price}, final=${finalPrice}`)
return {
...item,
current_price: finalPrice,
}
})
}, [displayStocks, stockPrices])
const currentStockPrice = useMemo(() => {
if (!chartData?.data || chartData.data.length === 0) {
console.log('❌ currentStockPrice: No data')
return null
}
// KIS API는 최신 데이터가 첫번째 (index 0)
const price = Number(chartData.data[0].close)
console.log('✅ currentStockPrice:', price, 'from', chartData.data[0].date)
return price
}, [chartData])
// 초기 종목 선택 (한 번만 실행)
useEffect(() => {
console.log('📊 Initial stock selection check')
// 이미 종목이 선택되어 있으면 변경하지 않음
if (selectedStock && selectedStock !== '') {
console.log('✅ Stock already selected:', selectedStock)
return
}
// 규칙 기반 추천이 있으면 첫 번째 종목 선택
if (ruleBasedRecommendations?.recommendations_by_rule?.length > 0) {
const firstRule = ruleBasedRecommendations.recommendations_by_rule[0]
if (firstRule.stocks?.length > 0) {
const firstStock = firstRule.stocks[0]
console.log('🎯 Auto-selecting first rule-based recommendation:', firstStock.stock_code)
setSelectedStock(firstStock.stock_code)
return
}
}
// 포트폴리오가 있으면 첫 번째 종목 선택
if (portfolio && portfolio.length > 0) {
console.log('🎯 Auto-selecting first portfolio stock:', portfolio[0].stock_code)
setSelectedStock(portfolio[0].stock_code)
return
}
// 아무것도 없으면 종목 선택 없음 (테스트 데이터 제거)
console.log('⚠️ No stocks available')
}, [ruleBasedRecommendations, portfolio]) // selectedStock은 의존성에서 제거
const chartSeries = useMemo(() => {
if (!selectedStock) {
console.log('⚠️ chartSeries: No stock selected')
return []
}
if (!chartData?.data || chartData.data.length === 0) {
console.log('❌ chartSeries: No data', chartData)
return []
}
// 차트 데이터가 선택된 종목과 일치하는지 확인
if (chartData.stock_code !== selectedStock) {
console.error('🔴 CRITICAL: Chart data mismatch!', {
chartStock: chartData.stock_code,
selectedStock: selectedStock,
chartName: chartData.stock_name,
dataLength: chartData.data.length
})
// 데이터가 일치하지 않으면 빈 배열 반환 (새 데이터 로딩 대기)
return []
}
console.log('✅ Chart data matches selected stock:', selectedStock)
console.log('✅ Raw chartData:', {
stock: chartData.stock_code,
dataLength: chartData.data.length,
first: chartData.data[0],
last: chartData.data[chartData.data.length - 1]
})
// KIS API는 최신 데이터가 첫번째 (20251104, 20251103, ...)
// 차트는 오래된 것부터 최신 순서로 표시해야 하므로 reverse 필요
const sortedData = [...chartData.data]
.reverse() // 오래된 날짜부터 정렬
// KIS API가 반환하는 모든 데이터 사용 (slice 제거)
.filter((item: any) => {
// 유효한 데이터만 필터링 (0이 아닌 가격)
return item.open > 0 && item.high > 0 && item.low > 0 && item.close > 0
})
.map((item: any) => ({
date: item.date,
dateLabel: item.date ? `${item.date.slice(4, 6)}/${item.date.slice(6, 8)}` : '',
open: Number(item.open),
high: Number(item.high),
low: Number(item.low),
close: Number(item.close),
volume: Number(item.volume),
}))
console.log('✅ Chart series processed:', {
length: sortedData.length,
first: sortedData[0],
last: sortedData[sortedData.length - 1]
})
if (sortedData.length === 0) {
console.log('❌ All data filtered out (invalid prices)')
}
return sortedData
}, [chartData, selectedStock])
const formatCurrency = (value: number) => {
if (!value) {
return '0원'
}
return new Intl.NumberFormat('ko-KR').format(value) + '원'
}
const formatPercent = (value: number) => {
if (!value) {
return '+0.00%'
}
return `${value >= 0 ? '+' : ''}${value.toFixed(2)}%`
}
const formatDate = (dateStr: string) => {
if (!dateStr || dateStr.length !== 8) return ''
// YYYYMMDD → MM/DD
return `${dateStr.slice(4, 6)}/${dateStr.slice(6, 8)}`
}
const getTradeStatusClass = (status: string) => {
switch (status) {
case 'EXECUTED':
return 'executed'
case 'PENDING':
return 'pending'
case 'FAILED':
return 'failed'
default:
return ''
}
}
const handleStockSelect = (stockCode: string) => {
setSelectedStock(stockCode)
}
return (
<HtsPage contentClassName="dashboard-content">
<div className="hts-container">
<div className="hts-main-layout">
<div className="left-panel">
<div className="panel-header">계좌 정보</div>
<div className="balance-info">
<div className="balance-row">
<span>예수금</span>
<span style={{ color: '#22c55e' }}>10,000,000원</span>
</div>
<div className="balance-row">
<span>총 자산</span>
<span>{formatCurrency((portfolioSummary?.total_value || 0) + 10000000)}</span>
</div>
<div className="balance-row">
<span>총 손익</span>
<span className={(portfolioSummary?.total_profit_loss || 0) >= 0 ? 'up' : 'down'}>
{formatCurrency(portfolioSummary?.total_profit_loss || 0)}
</span>
</div>
<div className="balance-row">
<span>수익률</span>
<span className={(portfolioSummary?.total_profit_loss_rate || 0) >= 0 ? 'up' : 'down'}>
{formatPercent(portfolioSummary?.total_profit_loss_rate || 0)}
</span>
</div>
<div className="balance-row">
<span>보유 종목</span>
<span>{portfolioSummary?.total_stocks || 0}개</span>
</div>
</div>
<div className="panel-header" style={{ borderTop: '1px solid #333', marginTop: '12px' }}>보유 종목</div>
<div className="portfolio-list" style={{ maxHeight: '200px', overflowY: 'auto' }}>
{portfolio && portfolio.length > 0 ? (
portfolio.map((item: any) => (
<div
key={item.id}
className="portfolio-item"
onClick={() => handleStockSelect(item.stock_code)}
style={{ cursor: 'pointer' }}
>
<div className="stock-name">{item.stock_name} ({item.stock_code})</div>
<div className="stock-info">
<span>{item.quantity}주</span>
<span>{formatCurrency(item.current_price)}</span>
<span className={(item.profit_loss_rate || 0) >= 0 ? 'up' : 'down'}>
{formatPercent(item.profit_loss_rate || 0)}
</span>
</div>
</div>
))
) : (
<div className="no-data" style={{ padding: '20px', textAlign: 'center', color: '#666' }}>
보유 종목 없음
</div>
)}
</div>
</div>
<div className="center-panel">
<div className="chart-section">
<div className="panel-header">대표 종목 차트</div>
<div className="chart-toolbar">
<div className="chart-stocks">
{recommendedStocks.length > 0 ? (
recommendedStocks.map((item: any) => {
const isSelected = selectedStock === item.stock_code
// stockPrices에서 최신 가격 가져오기 (차트 상단과 추천 종목 동일한 가격)
const displayPrice = stockPrices[item.stock_code] || item.current_price || 0
return (
<button
key={item.stock_code}
className={`chart-stock ${isSelected ? 'active' : ''}`}
onClick={() => handleStockSelect(item.stock_code)}
>
<span className="name">{item.stock_name}</span>
<span className="price">{formatCurrency(displayPrice)}</span>
</button>
)
})
) : (
<div className="no-data">추천 종목 로딩 중...</div>
)}
</div>
<div className="chart-actions">
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start', gap: '4px' }}>
{/* MCP 상태 UI 제거됨 */}
{/* 실시간 연결 UI 제거됨 */}
</div>
<div className="chart-period">
{(['D', 'W', 'M'] as const).map((period) => (
<button
key={period}
className={chartPeriod === period ? 'active' : ''}
onClick={() => setChartPeriod(period)}
>
{period === 'D' ? '일봉' : period === 'W' ? '주봉' : '월봉'}
</button>
))}
</div>
</div>
</div>
<div className="chart-container">
{chartLoading && <div className="no-data">차트 로딩 중...</div>}
{!chartLoading && chartSeries.length === 0 && (
<div className="no-data">차트 데이터가 없습니다.</div>
)}
{!chartLoading && chartSeries.length > 0 && (
<div style={{ width: '100%', height: '100%' }}>
<CandlestickChart
data={chartSeries}
width={1100}
height={380}
/>
</div>
)}
</div>
</div>
<div className="trades-section">
<div className="panel-header">최근 거래 내역</div>
<div className="trades-table-container">
<table className="trades-table">
<thead>
<tr>
<th>시간</th>
<th>종목</th>
<th>구분</th>
<th>수량</th>
<th>가격</th>
<th>상태</th>
</tr>
</thead>
<tbody>
{recentTrades && recentTrades.length > 0 ? (
recentTrades.map((trade: any) => (
<tr key={trade.id}>
<td>{new Date(trade.created_at).toLocaleTimeString('ko-KR')}</td>
<td>{trade.stock_name}</td>
<td className={trade.order_type === 'BUY' ? 'up' : 'down'}>
{trade.order_type}
</td>
<td>{trade.order_quantity}</td>
<td>{formatCurrency(trade.order_price || 0)}</td>
<td>
<span className={`trade-status ${getTradeStatusClass(trade.status)}`}>
{trade.status}
</span>
</td>
</tr>
))
) : (
<tr>
<td colSpan={6} className="no-data">거래 내역 없음</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</div>
<div className="right-panel">
<div className="recommend-section">
<div className="panel-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span>추천 종목</span>
<button
onClick={async () => {
console.log('🤖 자동매매 버튼 클릭됨')
if (!confirm('규칙에 맞는 종목을 자동으로 매수/매도 하시겠습니까?')) {
console.log('⚠️ 사용자가 자동매매 취소함')
return
}
console.log('✅ 자동매매 확인됨, API 호출 시작...')
try {
console.log('📡 POST /api/v1/recommendations/auto-trade')
const response = await api.post('/recommendations/auto-trade')
console.log('🟢 자동매매 응답:', response.data)
const result = response.data
if (!result.trades || result.trades.length === 0) {
alert(`⚠️ ${result.message || '실행된 거래가 없습니다'}`)
return
}
alert(`✅ ${result.message}\n\n${result.trades.map((t: any) =>
`${t.action}: ${t.stock} ${t.quantity}주 @ ${t.price.toLocaleString()}원`
).join('\n')}`)
} catch (error: any) {
console.error('🔴 자동매매 에러:', error)
console.error('🔴 에러 상세:', {
message: error.message,
response: error.response?.data,
status: error.response?.status
})
const errorMsg = error.response?.data?.detail || error.message || '알 수 없는 오류'
alert(`❌ 자동 매매 실패: ${errorMsg}`)
}
}}
style={{
padding: '4px 8px',
background: '#22c55e',
color: '#fff',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '11px',
fontWeight: '600'
}}
>
🤖 자동매매 시작
</button>
</div>
<div className="recommend-list" style={{ maxHeight: '280px', overflowY: 'auto' }}>
{(() => {
console.log('🎯 Rendering recommend-list:', {
hasRule: !!ruleBasedRecommendations,
ruleLength: ruleBasedRecommendations?.recommendations?.length || 0,
hasAI: !!aiRecommendations,
aiLength: aiRecommendations?.recommendations?.length || 0,
portfolioLength: recommendedStocks.length
})
return null
})()}
{/* 1. 규칙 기반 추천 (규칙별 그룹핑) */}
{ruleBasedRecommendations && ruleBasedRecommendations.recommendations_by_rule?.length > 0 ? (
ruleBasedRecommendations.recommendations_by_rule.map((ruleGroup: any) => (
<div key={ruleGroup.rule_id} style={{ marginBottom: '16px' }}>
{/* 규칙 헤더 */}
<div style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
marginBottom: '8px',
padding: '8px',
background: '#0a0a0a',
borderRadius: '4px',
borderLeft: '3px solid #22c55e'
}}>
<span style={{
fontSize: '11px',
fontWeight: '600',
color: '#22c55e',
background: 'rgba(34, 197, 94, 0.1)',
padding: '2px 8px',
borderRadius: '4px'
}}>
규칙 {ruleGroup.priority}
</span>
<span style={{ fontSize: '12px', fontWeight: '600', color: '#fff' }}>
{ruleGroup.rule_name}
</span>
<span style={{ fontSize: '10px', color: '#666' }}>
({ruleGroup.matched_count}개 종목 매칭)
</span>
</div>
{/* 종목 리스트 */}
{ruleGroup.stocks.map((item: any) => {
// stockPrices에서 최신 가격 가져오기 (숫자 변환)
const latestPrice = Number(stockPrices[item.stock_code] || item.current_price || 0)
return (
<button
key={item.stock_code}
className={`recommend-item ${selectedStock === item.stock_code ? 'active' : ''}`}
onClick={() => handleStockSelect(item.stock_code)}
style={{ width: '100%', marginBottom: '4px' }}
>
<div className="info">
<span className="name">{item.stock_name}</span>
<span className="code">{item.stock_code}</span>
</div>
<div className="figures">
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end' }}>
<span className="price">{formatCurrency(latestPrice)}</span>
{item.price_date && (
<span style={{ fontSize: '9px', color: '#64748b' }}>
{formatDate(item.price_date)} 기준
</span>
)}
</div>
<span className={`rate ${(item.change_rate ?? 0) >= 0 ? 'up' : 'down'}`}>
{formatPercent(item.change_rate ?? 0)}
</span>
</div>
<div style={{ fontSize: '10px', color: '#64748b', marginTop: '4px' }}>
점수: {item.score} | {item.signals?.slice(0, 2).join(', ')}
</div>
</button>
)
})}
</div>
))
) : aiRecommendations && aiRecommendations.recommendations?.length > 0 ? (
/* 2. 수동 지표 분석 추천 */
aiRecommendations.recommendations.map((item: any) => {
// stockPrices에서 최신 가격 가져오기 (숫자 변환)
const latestPrice = Number(stockPrices[item.stock_code] || item.current_price || 0)
return (
<button
key={item.stock_code}
className={`recommend-item ${selectedStock === item.stock_code ? 'active' : ''}`}
onClick={() => handleStockSelect(item.stock_code)}
>
<div className="info">
<span className="name">{item.stock_name}</span>
<span className="code">{item.stock_code}</span>
</div>
<div className="figures">
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end' }}>
<span className="price">{formatCurrency(latestPrice)}</span>
{item.price_date && (
<span style={{ fontSize: '9px', color: '#64748b' }}>
{formatDate(item.price_date)} 기준
</span>
)}
</div>
<span className={`rate ${(item.change_rate ?? 0) >= 0 ? 'up' : 'down'}`}>
{formatPercent(item.change_rate ?? 0)}
</span>
</div>
<div style={{ fontSize: '10px', color: '#64748b', marginTop: '4px' }}>
점수: {item.score} | {item.signals.slice(0, 2).join(', ')}
</div>
</button>
)
})
) : recommendedStocks.length > 0 ? (
recommendedStocks.map((item: any) => {
const displayPrice = Number(selectedStock === item.stock_code && currentStockPrice
? currentStockPrice
: item.current_price)
return (
<button
key={item.stock_code}
className={`recommend-item ${selectedStock === item.stock_code ? 'active' : ''}`}
onClick={() => handleStockSelect(item.stock_code)}
>
<div className="info">
<span className="name">{item.stock_name}</span>
<span className="code">{item.stock_code}</span>
</div>
<div className="figures">
<span className="price">{formatCurrency(displayPrice || 0)}</span>
<span className={`rate ${(item.profit_loss_rate ?? 0) >= 0 ? 'up' : 'down'}`}>
{formatPercent(item.profit_loss_rate ?? 0)}
</span>
</div>
</button>
)
})
) : (
<div className="no-data">보유 종목이 없습니다</div>
)}
</div>
</div>
{/* AI 분석 섹션 */}
<div className="ai-analysis-section">
<div className="panel-header">🤖 AI 종합 분석</div>
<div className="analysis-content">
{aiAnalysisLoading ? (
<div className="analysis-loading">분석 중...</div>
) : aiAnalysis ? (
typeof aiAnalysis === 'string' ? (
<div style={{ whiteSpace: 'pre-wrap' }}>{aiAnalysis}</div>
) : typeof aiAnalysis === 'object' && aiAnalysis !== null ? (
<div style={{ whiteSpace: 'pre-wrap' }}>
{aiAnalysis.analysis || aiAnalysis.recommendation || JSON.stringify(aiAnalysis, null, 2)}
</div>
) : (
<div className="no-data">분석 결과를 표시할 수 없습니다</div>
)
) : (
<div className="no-data">종목을 선택하세요</div>
)}
</div>
</div>
{/* 리스크 체크 섹션 */}
<div className="risk-check-section">
<div className="panel-header">⚠️ 리스크 체크</div>
<div className="risk-content">
{riskCheckLoading ? (
<div className="risk-loading">분석 중...</div>
) : riskCheck ? (
typeof riskCheck === 'string' ? (
<div style={{ whiteSpace: 'pre-wrap' }}>{riskCheck}</div>
) : typeof riskCheck === 'object' && riskCheck !== null ? (
<div style={{ whiteSpace: 'pre-wrap' }}>
{riskCheck.analysis || riskCheck.recommendation || JSON.stringify(riskCheck, null, 2)}
</div>
) : (
<div className="no-data">분석 결과를 표시할 수 없습니다</div>
)
) : (
<div className="no-data">종목을 선택하세요</div>
)}
</div>
</div>
<div className="news-section">
<div className="panel-header">시장 뉴스</div>
<div className="news-list">
{news && news.length > 0 ? (
// 선택된 종목에 해당하는 뉴스만 필터링
news.filter((item: any) => !selectedStock || item.stock_code === selectedStock)
.slice(0, 5)
.map((item: any) => (
<div key={item.id} className="news-item" onClick={() => window.open(item.url, '_blank')}>
<div className="news-title">{item.title}</div>
<div className="news-meta">
{new Date(item.published_at).toLocaleString('ko-KR', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
})}
</div>
</div>
))
) : (
<div className="no-data">뉴스 없음</div>
)}
</div>
</div>
</div>
</div>
</div>
</HtsPage>
)
}
export default Dashboard