Initial commit

This commit is contained in:
2025-11-14 11:05:31 +09:00
commit b147498c31
124 changed files with 17983 additions and 0 deletions

View File

@@ -0,0 +1,922 @@
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