923 lines
48 KiB
TypeScript
923 lines
48 KiB
TypeScript
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
|