Initial commit
This commit is contained in:
18
frontend/src/App.tsx
Normal file
18
frontend/src/App.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { Routes, Route } from 'react-router-dom'
|
||||
import Dashboard from './pages/Dashboard'
|
||||
import TradingRules from './pages/TradingRules'
|
||||
import TradeHistory from './pages/TradeHistory'
|
||||
import MCPStatus from './pages/MCPStatus'
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route path="/rules" element={<TradingRules />} />
|
||||
<Route path="/trades" element={<TradeHistory />} />
|
||||
<Route path="/mcp" element={<MCPStatus />} />
|
||||
</Routes>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
434
frontend/src/components/CandlestickChart.tsx
Normal file
434
frontend/src/components/CandlestickChart.tsx
Normal file
@@ -0,0 +1,434 @@
|
||||
import { useMemo } from 'react'
|
||||
|
||||
interface CandleData {
|
||||
date: string
|
||||
dateLabel: string
|
||||
open: number
|
||||
high: number
|
||||
low: number
|
||||
close: number
|
||||
volume: number
|
||||
}
|
||||
|
||||
interface CandlestickChartProps {
|
||||
data: CandleData[]
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
// 이동평균 계산
|
||||
const calculateMA = (data: CandleData[], period: number): (number | null)[] => {
|
||||
return data.map((_, index) => {
|
||||
if (index < period - 1) return null
|
||||
const sum = data.slice(index - period + 1, index + 1).reduce((acc, d) => acc + d.close, 0)
|
||||
return sum / period
|
||||
})
|
||||
}
|
||||
|
||||
// 볼린저밴드 계산
|
||||
const calculateBollingerBands = (data: CandleData[], period: number = 20, stdDev: number = 2) => {
|
||||
const ma = calculateMA(data, period)
|
||||
const upper: (number | null)[] = []
|
||||
const lower: (number | null)[] = []
|
||||
|
||||
data.forEach((_, index) => {
|
||||
if (index < period - 1 || ma[index] === null) {
|
||||
upper.push(null)
|
||||
lower.push(null)
|
||||
return
|
||||
}
|
||||
|
||||
const slice = data.slice(index - period + 1, index + 1)
|
||||
const mean = ma[index]!
|
||||
const variance = slice.reduce((acc, d) => acc + Math.pow(d.close - mean, 2), 0) / period
|
||||
const std = Math.sqrt(variance)
|
||||
|
||||
upper.push(mean + stdDev * std)
|
||||
lower.push(mean - stdDev * std)
|
||||
})
|
||||
|
||||
return { middle: ma, upper, lower }
|
||||
}
|
||||
|
||||
const CandlestickChart = ({ data, width, height }: CandlestickChartProps) => {
|
||||
const { candles, yScale, volumeScale, maxVolume, indicators } = useMemo(() => {
|
||||
if (!data || data.length === 0) {
|
||||
console.log('CandlestickChart: No data')
|
||||
return {
|
||||
candles: [],
|
||||
yScale: { min: 0, max: 0, range: 0 },
|
||||
volumeScale: 0,
|
||||
maxVolume: 0,
|
||||
indicators: { ma5: [], ma20: [], ma60: [], bb: { middle: [], upper: [], lower: [] } }
|
||||
}
|
||||
}
|
||||
|
||||
console.log('CandlestickChart: Rendering', data.length, 'candles')
|
||||
console.log('First candle:', data[0])
|
||||
console.log('Last candle:', data[data.length - 1])
|
||||
|
||||
// 지표 계산
|
||||
const ma5 = calculateMA(data, 5)
|
||||
const ma20 = calculateMA(data, 20)
|
||||
const ma60 = calculateMA(data, 60)
|
||||
const bb = calculateBollingerBands(data, 20, 2)
|
||||
|
||||
// 가격 범위 계산 (지표 포함)
|
||||
const prices = data.flatMap(d => [d.open, d.high, d.low, d.close]).filter(p => p > 0)
|
||||
const indicatorValues = [
|
||||
...ma5.filter((v): v is number => v !== null && v > 0),
|
||||
...ma20.filter((v): v is number => v !== null && v > 0),
|
||||
...ma60.filter((v): v is number => v !== null && v > 0),
|
||||
...bb.upper.filter((v): v is number => v !== null && v > 0),
|
||||
...bb.lower.filter((v): v is number => v !== null && v > 0),
|
||||
]
|
||||
const allValues = [...prices, ...indicatorValues]
|
||||
|
||||
if (allValues.length === 0) {
|
||||
console.error('❌ No valid price data')
|
||||
return {
|
||||
candles: [],
|
||||
yScale: { min: 0, max: 0, range: 0 },
|
||||
volumeScale: 0,
|
||||
maxVolume: 0,
|
||||
indicators: { ma5: [], ma20: [], ma60: [], bb: { middle: [], upper: [], lower: [] } }
|
||||
}
|
||||
}
|
||||
|
||||
const min = Math.min(...allValues)
|
||||
const max = Math.max(...allValues)
|
||||
|
||||
// 이상치 검증: max가 min의 10배를 넘으면 지표 제외
|
||||
const priceRange = Math.max(...prices) - Math.min(...prices)
|
||||
const totalRange = max - min
|
||||
|
||||
if (totalRange > priceRange * 3) {
|
||||
console.warn('⚠️ Detected outlier in indicators, using price range only')
|
||||
const priceMin = Math.min(...prices)
|
||||
const priceMax = Math.max(...prices)
|
||||
const range = priceMax - priceMin
|
||||
const padding = range * 0.1 // 패딩: 10%
|
||||
|
||||
const chartHeight = height - 80
|
||||
const volumeHeight = 60
|
||||
const yMin = priceMin - padding
|
||||
const yMax = priceMax + padding
|
||||
const yRange = yMax - yMin
|
||||
const leftMargin = 80 // 왼쪽 여백 증가: 60 → 80
|
||||
const rightMargin = 20 // 오른쪽 여백 추가
|
||||
const chartWidth = width - leftMargin - rightMargin
|
||||
const candleWidth = Math.max(5, Math.min(15, chartWidth / data.length * 0.7)) // 캔들 너비 증가
|
||||
const spacing = chartWidth / data.length
|
||||
const maxVol = Math.max(...data.map(d => d.volume))
|
||||
|
||||
const candles = data.map((item, index) => {
|
||||
const x = leftMargin + index * spacing + spacing / 2
|
||||
const isUp = item.close >= item.open
|
||||
const openY = chartHeight - ((item.open - yMin) / yRange) * chartHeight + 10
|
||||
const closeY = chartHeight - ((item.close - yMin) / yRange) * chartHeight + 10
|
||||
const highY = chartHeight - ((item.high - yMin) / yRange) * chartHeight + 10
|
||||
const lowY = chartHeight - ((item.low - yMin) / yRange) * chartHeight + 10
|
||||
const bodyTop = Math.min(openY, closeY)
|
||||
const bodyHeight = Math.abs(closeY - openY) || 1
|
||||
const volumeBarHeight = (item.volume / maxVol) * volumeHeight
|
||||
|
||||
return {
|
||||
x, highY, lowY, bodyTop, bodyHeight, isUp,
|
||||
dateLabel: item.dateLabel,
|
||||
open: item.open, high: item.high, low: item.low, close: item.close,
|
||||
volume: item.volume, volumeBarHeight, candleWidth,
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
candles,
|
||||
yScale: { min: yMin, max: yMax, range: yRange },
|
||||
volumeScale: volumeHeight,
|
||||
maxVolume: maxVol,
|
||||
indicators: { ma5, ma20, ma60, bb }
|
||||
}
|
||||
}
|
||||
|
||||
const range = max - min
|
||||
const padding = range * 0.1 // 패딩: 10%
|
||||
|
||||
const chartHeight = height - 80 // 거래량 영역 제외
|
||||
const volumeHeight = 60
|
||||
|
||||
const yMin = min - padding
|
||||
const yMax = max + padding
|
||||
const yRange = yMax - yMin
|
||||
|
||||
const leftMargin = 80 // 왼쪽 여백 증가: 60 → 80
|
||||
const rightMargin = 20 // 오른쪽 여백 추가
|
||||
const chartWidth = width - leftMargin - rightMargin
|
||||
const candleWidth = Math.max(5, Math.min(15, chartWidth / data.length * 0.7)) // 캔들 너비 증가
|
||||
const spacing = chartWidth / data.length
|
||||
|
||||
const maxVol = Math.max(...data.map(d => d.volume))
|
||||
|
||||
const candles = data.map((item, index) => {
|
||||
const x = leftMargin + index * spacing + spacing / 2
|
||||
const isUp = item.close >= item.open
|
||||
|
||||
const openY = chartHeight - ((item.open - yMin) / yRange) * chartHeight + 10
|
||||
const closeY = chartHeight - ((item.close - yMin) / yRange) * chartHeight + 10
|
||||
const highY = chartHeight - ((item.high - yMin) / yRange) * chartHeight + 10
|
||||
const lowY = chartHeight - ((item.low - yMin) / yRange) * chartHeight + 10
|
||||
|
||||
const bodyTop = Math.min(openY, closeY)
|
||||
const bodyHeight = Math.abs(closeY - openY) || 1
|
||||
|
||||
const volumeBarHeight = (item.volume / maxVol) * volumeHeight
|
||||
|
||||
return {
|
||||
x,
|
||||
highY,
|
||||
lowY,
|
||||
bodyTop,
|
||||
bodyHeight,
|
||||
isUp,
|
||||
dateLabel: item.dateLabel,
|
||||
open: item.open,
|
||||
high: item.high,
|
||||
low: item.low,
|
||||
close: item.close,
|
||||
volume: item.volume,
|
||||
volumeBarHeight,
|
||||
candleWidth,
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
candles,
|
||||
yScale: { min: yMin, max: yMax, range: yRange },
|
||||
volumeScale: volumeHeight,
|
||||
maxVolume: maxVol,
|
||||
indicators: { ma5, ma20, ma60, bb }
|
||||
}
|
||||
}, [data, width, height])
|
||||
|
||||
if (!data || data.length === 0) {
|
||||
return (
|
||||
<div style={{ width, height, display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#666' }}>
|
||||
데이터가 없습니다
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const chartHeight = height - 80
|
||||
const volumeTop = chartHeight + 20
|
||||
|
||||
// 지표 라인을 위한 좌표 계산
|
||||
const getLineY = (value: number) => {
|
||||
return chartHeight - ((value - yScale.min) / yScale.range) * chartHeight + 10
|
||||
}
|
||||
|
||||
const createLinePath = (values: (number | null)[]) => {
|
||||
const segments: string[] = []
|
||||
let currentSegment: string[] = []
|
||||
|
||||
values.forEach((value, index) => {
|
||||
if (value !== null && candles[index]) {
|
||||
const x = candles[index].x
|
||||
const y = getLineY(value)
|
||||
if (currentSegment.length === 0) {
|
||||
currentSegment.push(`M ${x} ${y}`)
|
||||
} else {
|
||||
currentSegment.push(`L ${x} ${y}`)
|
||||
}
|
||||
} else if (currentSegment.length > 0) {
|
||||
// null 값을 만나면 현재 세그먼트를 종료하고 새로 시작
|
||||
segments.push(currentSegment.join(' '))
|
||||
currentSegment = []
|
||||
}
|
||||
})
|
||||
|
||||
// 마지막 세그먼트 추가
|
||||
if (currentSegment.length > 0) {
|
||||
segments.push(currentSegment.join(' '))
|
||||
}
|
||||
|
||||
return segments.join(' ')
|
||||
}
|
||||
|
||||
// 볼린저밴드 영역 path 생성
|
||||
const createBBAreaPath = () => {
|
||||
const upperPoints: [number, number][] = []
|
||||
const lowerPoints: [number, number][] = []
|
||||
|
||||
indicators.bb.upper.forEach((value, index) => {
|
||||
if (value !== null && indicators.bb.lower[index] !== null && candles[index]) {
|
||||
upperPoints.push([candles[index].x, getLineY(value)])
|
||||
lowerPoints.push([candles[index].x, getLineY(indicators.bb.lower[index]!)])
|
||||
}
|
||||
})
|
||||
|
||||
if (upperPoints.length === 0) return ''
|
||||
|
||||
const pathParts: string[] = []
|
||||
pathParts.push(`M ${upperPoints[0][0]} ${upperPoints[0][1]}`)
|
||||
upperPoints.slice(1).forEach(([x, y]) => pathParts.push(`L ${x} ${y}`))
|
||||
lowerPoints.reverse().forEach(([x, y]) => pathParts.push(`L ${x} ${y}`))
|
||||
pathParts.push('Z')
|
||||
|
||||
return pathParts.join(' ')
|
||||
}
|
||||
|
||||
return (
|
||||
<svg width={width} height={height} style={{ background: 'transparent' }}>
|
||||
{/* Y축 그리드 */}
|
||||
{[0, 0.25, 0.5, 0.75, 1].map((ratio) => {
|
||||
const y = chartHeight - ratio * chartHeight + 10
|
||||
const price = yScale.min + ratio * yScale.range
|
||||
return (
|
||||
<g key={ratio}>
|
||||
<line
|
||||
x1={80}
|
||||
y1={y}
|
||||
x2={width - 20}
|
||||
y2={y}
|
||||
stroke="#222"
|
||||
strokeDasharray="3 3"
|
||||
/>
|
||||
<text
|
||||
x={75}
|
||||
y={y + 4}
|
||||
fill="#888"
|
||||
fontSize="11"
|
||||
textAnchor="end"
|
||||
>
|
||||
{new Intl.NumberFormat('ko-KR', { maximumFractionDigits: 0 }).format(price)}
|
||||
</text>
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* 볼린저밴드 영역 - 그리드 뒤, 캔들 앞 */}
|
||||
{indicators.bb.upper.length > 0 && (
|
||||
<path
|
||||
d={createBBAreaPath()}
|
||||
fill="rgba(139, 92, 246, 0.08)"
|
||||
stroke="none"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 이동평균선 - 캔들 앞 */}
|
||||
<path
|
||||
d={createLinePath(indicators.ma60)}
|
||||
stroke="#06b6d4"
|
||||
strokeWidth={2}
|
||||
fill="none"
|
||||
opacity={0.9}
|
||||
/>
|
||||
<path
|
||||
d={createLinePath(indicators.ma20)}
|
||||
stroke="#8b5cf6"
|
||||
strokeWidth={2}
|
||||
fill="none"
|
||||
opacity={0.9}
|
||||
/>
|
||||
<path
|
||||
d={createLinePath(indicators.ma5)}
|
||||
stroke="#f59e0b"
|
||||
strokeWidth={2}
|
||||
fill="none"
|
||||
opacity={0.9}
|
||||
/>
|
||||
|
||||
{/* 볼린저밴드 라인 - 캔들 앞 */}
|
||||
<path
|
||||
d={createLinePath(indicators.bb.upper)}
|
||||
stroke="#8b5cf6"
|
||||
strokeWidth={1.5}
|
||||
fill="none"
|
||||
strokeDasharray="4 4"
|
||||
opacity={0.5}
|
||||
/>
|
||||
<path
|
||||
d={createLinePath(indicators.bb.middle)}
|
||||
stroke="#8b5cf6"
|
||||
strokeWidth={1.5}
|
||||
fill="none"
|
||||
opacity={0.4}
|
||||
/>
|
||||
<path
|
||||
d={createLinePath(indicators.bb.lower)}
|
||||
stroke="#8b5cf6"
|
||||
strokeWidth={1.5}
|
||||
fill="none"
|
||||
strokeDasharray="4 4"
|
||||
opacity={0.5}
|
||||
/>
|
||||
|
||||
{/* 캔들스틱 */}
|
||||
{candles.map((candle, index) => (
|
||||
<g key={index}>
|
||||
{/* 심지 (High-Low 라인) */}
|
||||
<line
|
||||
x1={candle.x}
|
||||
y1={candle.highY}
|
||||
x2={candle.x}
|
||||
y2={candle.lowY}
|
||||
stroke={candle.isUp ? '#22c55e' : '#ef4444'}
|
||||
strokeWidth={1}
|
||||
/>
|
||||
{/* 캔들 몸통 */}
|
||||
<rect
|
||||
x={candle.x - candle.candleWidth / 2}
|
||||
y={candle.bodyTop}
|
||||
width={candle.candleWidth}
|
||||
height={candle.bodyHeight}
|
||||
fill={candle.isUp ? '#22c55e' : '#ef4444'}
|
||||
stroke={candle.isUp ? '#22c55e' : '#ef4444'}
|
||||
strokeWidth={1}
|
||||
/>
|
||||
{/* X축 라벨 (일부만 표시) */}
|
||||
{index % Math.max(1, Math.floor(candles.length / 10)) === 0 && (
|
||||
<text
|
||||
x={candle.x}
|
||||
y={chartHeight + 25}
|
||||
fill="#888"
|
||||
fontSize="10"
|
||||
textAnchor="middle"
|
||||
>
|
||||
{candle.dateLabel}
|
||||
</text>
|
||||
)}
|
||||
{/* 거래량 바 */}
|
||||
<rect
|
||||
x={candle.x - candle.candleWidth / 2}
|
||||
y={volumeTop + volumeScale - candle.volumeBarHeight}
|
||||
width={candle.candleWidth}
|
||||
height={candle.volumeBarHeight}
|
||||
fill={candle.isUp ? '#22c55e' : '#ef4444'}
|
||||
opacity={0.4}
|
||||
/>
|
||||
</g>
|
||||
))}
|
||||
|
||||
{/* 거래량 라벨 */}
|
||||
<text x={10} y={volumeTop + 15} fill="#888" fontSize="10">거래량</text>
|
||||
<text x={width - 20} y={volumeTop + volumeScale} fill="#888" fontSize="10" textAnchor="end">
|
||||
{new Intl.NumberFormat('ko-KR', { notation: 'compact' }).format(maxVolume)}
|
||||
</text>
|
||||
|
||||
{/* 범례 */}
|
||||
<g transform={`translate(70, 15)`}>
|
||||
<line x1={0} y1={0} x2={20} y2={0} stroke="#f59e0b" strokeWidth={2} />
|
||||
<text x={25} y={4} fill="#f59e0b" fontSize="11" fontWeight="600">MA5</text>
|
||||
|
||||
<line x1={70} y1={0} x2={90} y2={0} stroke="#8b5cf6" strokeWidth={2} />
|
||||
<text x={95} y={4} fill="#8b5cf6" fontSize="11" fontWeight="600">MA20</text>
|
||||
|
||||
<line x1={150} y1={0} x2={170} y2={0} stroke="#06b6d4" strokeWidth={2} />
|
||||
<text x={175} y={4} fill="#06b6d4" fontSize="11" fontWeight="600">MA60</text>
|
||||
|
||||
<line x1={240} y1={0} x2={260} y2={0} stroke="#94a3b8" strokeWidth={1} strokeDasharray="3 3" />
|
||||
<text x={265} y={4} fill="#94a3b8" fontSize="11" fontWeight="600">BB</text>
|
||||
</g>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export default CandlestickChart
|
||||
123
frontend/src/components/HtsHeader.css
Normal file
123
frontend/src/components/HtsHeader.css
Normal file
@@ -0,0 +1,123 @@
|
||||
.hts-header {
|
||||
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
|
||||
padding: 12px 20px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border-bottom: 2px solid #00ff88;
|
||||
box-shadow: 0 2px 10px rgba(0, 255, 136, 0.3);
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.header-left h1 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: #00ff88;
|
||||
text-shadow: 0 0 10px rgba(0, 255, 136, 0.5);
|
||||
}
|
||||
|
||||
.header-center {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.hts-nav {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
padding: 5px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
padding: 8px 20px;
|
||||
color: #8892b0;
|
||||
text-decoration: none;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
border-radius: 6px;
|
||||
transition: all 0.3s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.nav-item:hover {
|
||||
background: rgba(0, 255, 136, 0.1);
|
||||
color: #00ff88;
|
||||
}
|
||||
|
||||
.nav-item.active {
|
||||
background: #00ff88;
|
||||
color: #0a192f;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.live-indicator {
|
||||
font-size: 11px;
|
||||
padding: 3px 8px;
|
||||
background: rgba(244, 67, 54, 0.2);
|
||||
border: 1px solid #f44336;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.header-right .time {
|
||||
font-size: 13px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.mcp-status-inline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.status-indicator.connected {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
background: #00ff88;
|
||||
border-radius: 50%;
|
||||
border: 1px solid #00ff88;
|
||||
margin-right: 2px;
|
||||
}
|
||||
|
||||
.status-indicator.disconnected {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
background: #f44336;
|
||||
border-radius: 50%;
|
||||
border: 1px solid #f44336;
|
||||
margin-right: 2px;
|
||||
}
|
||||
|
||||
.mcp-indicator {
|
||||
font-size: 11px;
|
||||
padding: 3px 8px;
|
||||
background: rgba(0, 255, 136, 0.15);
|
||||
border: 1px solid #00ff88;
|
||||
border-radius: 3px;
|
||||
margin-left: 8px;
|
||||
color: #00ff88;
|
||||
font-weight: 600;
|
||||
letter-spacing: 1px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.mcp-indicator.offline {
|
||||
background: rgba(244, 67, 54, 0.15);
|
||||
border: 1px solid #f44336;
|
||||
color: #f44336;
|
||||
}
|
||||
101
frontend/src/components/HtsHeader.tsx
Normal file
101
frontend/src/components/HtsHeader.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link, useLocation } from 'react-router-dom'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import './HtsHeader.css'
|
||||
|
||||
const navItems = [
|
||||
{ path: '/', label: '대시보드' },
|
||||
{ path: '/rules', label: '트레이딩 규칙' },
|
||||
{ path: '/trades', label: '거래 내역' },
|
||||
{ path: '/mcp', label: 'MCP 상태' },
|
||||
]
|
||||
|
||||
const HtsHeader = () => {
|
||||
const location = useLocation()
|
||||
const [currentTime, setCurrentTime] = useState(() => new Date())
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => {
|
||||
setCurrentTime(new Date())
|
||||
}, 1000)
|
||||
|
||||
return () => clearInterval(timer)
|
||||
}, [])
|
||||
|
||||
// MCP 상태 가져오기
|
||||
const { data: mcpStatus } = useQuery({
|
||||
queryKey: ['mcp-status'],
|
||||
queryFn: async () => {
|
||||
const response = await fetch('/mcp/status')
|
||||
return response.json()
|
||||
},
|
||||
refetchInterval: 5000,
|
||||
})
|
||||
|
||||
const handleRiskCheck = async () => {
|
||||
try {
|
||||
const response = await fetch('http://localhost:8001/analyze', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
analysis_type: 'RISK_ASSESSMENT',
|
||||
data: { message: '현재 포트폴리오 리스크를 분석해주세요' }
|
||||
})
|
||||
});
|
||||
const result = await response.json();
|
||||
alert(`⚠️ 포트폴리오 리스크 평가:\n\n${result.analysis || result.result}`);
|
||||
} catch (error) {
|
||||
console.error('리스크 평가 실패:', error);
|
||||
alert('리스크 평가에 실패했습니다. MCP 서버를 확인해주세요.');
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="hts-header">
|
||||
<div className="header-left">
|
||||
<h1>📊 Auto Stock Trading Dashboard</h1>
|
||||
</div>
|
||||
<div className="header-center">
|
||||
<nav className="hts-nav">
|
||||
{navItems.map((item) => (
|
||||
<Link
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
className={`nav-item ${location.pathname === item.path ? 'active' : ''}`}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
<div className="header-right">
|
||||
{/* MCP 상태 표시 */}
|
||||
<span className={`mcp-indicator ${mcpStatus?.status === 'connected' ? 'live' : 'offline'}`}>
|
||||
{mcpStatus?.status === 'connected' ? '🟢 MCP LIVE' : '⚫ MCP'}
|
||||
</span>
|
||||
<span className="live-indicator" style={{ marginLeft: '8px' }}>🔴 LIVE</span>
|
||||
<button
|
||||
onClick={handleRiskCheck}
|
||||
style={{
|
||||
marginLeft: '16px',
|
||||
padding: '6px 12px',
|
||||
background: '#f59e0b',
|
||||
color: '#fff',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '12px',
|
||||
fontWeight: '600'
|
||||
}}
|
||||
onMouseOver={(e) => e.currentTarget.style.background = '#d97706'}
|
||||
onMouseOut={(e) => e.currentTarget.style.background = '#f59e0b'}
|
||||
>
|
||||
⚠️ 리스크 체크
|
||||
</button>
|
||||
<span className="time" style={{ marginLeft: '16px' }}>{currentTime.toLocaleTimeString('ko-KR')}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default HtsHeader
|
||||
19
frontend/src/components/HtsPage.css
Normal file
19
frontend/src/components/HtsPage.css
Normal file
@@ -0,0 +1,19 @@
|
||||
.hts-page {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #000;
|
||||
color: #e0e6ed;
|
||||
font-family: 'Segoe UI', 'Malgun Gothic', sans-serif;
|
||||
}
|
||||
|
||||
.hts-content {
|
||||
flex: 1;
|
||||
padding: 16px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.hts-content.dashboard-content {
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
23
frontend/src/components/HtsPage.tsx
Normal file
23
frontend/src/components/HtsPage.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import HtsHeader from './HtsHeader'
|
||||
import './HtsPage.css'
|
||||
|
||||
interface HtsPageProps {
|
||||
children: ReactNode
|
||||
className?: string
|
||||
contentClassName?: string
|
||||
}
|
||||
|
||||
const HtsPage = ({ children, className, contentClassName }: HtsPageProps) => {
|
||||
return (
|
||||
<div className={clsx('hts-page', className)}>
|
||||
<HtsHeader />
|
||||
<div className={clsx('hts-content', contentClassName)}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default HtsPage
|
||||
66
frontend/src/components/Layout.tsx
Normal file
66
frontend/src/components/Layout.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import { Link, useLocation } from 'react-router-dom'
|
||||
import { LayoutDashboard, FileText, History, Cpu } from 'lucide-react'
|
||||
|
||||
interface LayoutProps {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
const Layout = ({ children }: LayoutProps) => {
|
||||
const location = useLocation()
|
||||
|
||||
const navItems = [
|
||||
{ path: '/', icon: LayoutDashboard, label: '대시보드' },
|
||||
{ path: '/rules', icon: FileText, label: '전략 룰 설정' },
|
||||
{ path: '/trades', icon: History, label: '매매 로그' },
|
||||
{ path: '/mcp', icon: Cpu, label: 'MCP 상태' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-900">
|
||||
{/* Header */}
|
||||
<header className="bg-slate-800 border-b border-slate-700">
|
||||
<div className="container mx-auto px-4 py-4">
|
||||
<h1 className="text-2xl font-bold text-white">
|
||||
Auto Stock Trading System
|
||||
</h1>
|
||||
<p className="text-slate-400 text-sm">
|
||||
Claude AI & MCP 기반 자동 매매 시스템
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="flex">
|
||||
{/* Sidebar */}
|
||||
<aside className="w-64 bg-slate-800 min-h-[calc(100vh-73px)] border-r border-slate-700">
|
||||
<nav className="p-4 space-y-2">
|
||||
{navItems.map((item) => {
|
||||
const Icon = item.icon
|
||||
const isActive = location.pathname === item.path
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
className={`flex items-center space-x-3 px-4 py-3 rounded-lg transition-colors ${isActive
|
||||
? 'bg-primary text-white'
|
||||
: 'text-slate-400 hover:bg-slate-700 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<Icon size={20} />
|
||||
<span>{item.label}</span>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="flex-1 p-6">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Layout
|
||||
8
frontend/src/config.ts
Normal file
8
frontend/src/config.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000'
|
||||
const WS_BASE_URL = import.meta.env.VITE_WS_URL || 'ws://localhost:8000'
|
||||
|
||||
export const config = {
|
||||
apiBaseUrl: API_BASE_URL,
|
||||
wsBaseUrl: WS_BASE_URL,
|
||||
apiUrl: `${API_BASE_URL}/api/v1`,
|
||||
}
|
||||
54
frontend/src/hooks/useWebSocket.ts
Normal file
54
frontend/src/hooks/useWebSocket.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { config } from '../config'
|
||||
|
||||
interface WebSocketMessage {
|
||||
type: string
|
||||
data: unknown
|
||||
}
|
||||
|
||||
export const useWebSocket = (clientId: string) => {
|
||||
const [socket, setSocket] = useState<WebSocket | null>(null)
|
||||
const [messages, setMessages] = useState<WebSocketMessage[]>([])
|
||||
const [connected, setConnected] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const ws = new WebSocket(`${config.wsBaseUrl}/ws/${clientId}`)
|
||||
|
||||
ws.onopen = () => {
|
||||
console.log('WebSocket connected')
|
||||
setConnected(true)
|
||||
}
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const message = JSON.parse(event.data) as WebSocketMessage
|
||||
setMessages((prev: WebSocketMessage[]) => [...prev, message])
|
||||
} catch (error) {
|
||||
console.error('Failed to parse WebSocket message:', error)
|
||||
}
|
||||
}
|
||||
|
||||
ws.onerror = (error) => {
|
||||
console.error('WebSocket error:', error)
|
||||
}
|
||||
|
||||
ws.onclose = () => {
|
||||
console.log('WebSocket disconnected')
|
||||
setConnected(false)
|
||||
}
|
||||
|
||||
setSocket(ws)
|
||||
|
||||
return () => {
|
||||
ws.close()
|
||||
}
|
||||
}, [clientId])
|
||||
|
||||
const sendMessage = useCallback((message: string) => {
|
||||
if (socket && connected) {
|
||||
socket.send(message)
|
||||
}
|
||||
}, [socket, connected])
|
||||
|
||||
return { messages, connected, sendMessage }
|
||||
}
|
||||
63
frontend/src/index.css
Normal file
63
frontend/src/index.css
Normal file
@@ -0,0 +1,63 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
|
||||
color-scheme: light dark;
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
background-color: #0f172a;
|
||||
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
#root {
|
||||
width: 100%;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.card {
|
||||
@apply bg-slate-800 rounded-lg shadow-lg p-6;
|
||||
}
|
||||
|
||||
.btn {
|
||||
@apply px-4 py-2 rounded-lg font-medium transition-colors;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
@apply btn bg-primary hover:bg-blue-700 text-white;
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
@apply btn bg-success hover:bg-green-700 text-white;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
@apply btn bg-danger hover:bg-red-700 text-white;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
@apply btn bg-secondary hover:bg-slate-600 text-white;
|
||||
}
|
||||
|
||||
.input {
|
||||
@apply w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-primary;
|
||||
}
|
||||
|
||||
.label {
|
||||
@apply block text-sm font-medium text-slate-300 mb-1;
|
||||
}
|
||||
}
|
||||
25
frontend/src/main.tsx
Normal file
25
frontend/src/main.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import App from './App'
|
||||
import './index.css'
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
refetchOnWindowFocus: false,
|
||||
retry: 1,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
</React.StrictMode>,
|
||||
)
|
||||
660
frontend/src/pages/Dashboard.css
Normal file
660
frontend/src/pages/Dashboard.css
Normal file
@@ -0,0 +1,660 @@
|
||||
/* HTS 대시보드 컨테이너 */
|
||||
.hts-container {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #000000;
|
||||
color: #e0e6ed;
|
||||
font-family: 'Segoe UI', 'Malgun Gothic', sans-serif;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 메인 레이아웃 */
|
||||
.hts-main-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 300px 1fr 420px;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 왼쪽 패널: 계좌 정보 & 보유종목 */
|
||||
.left-panel {
|
||||
background: #1a1a1a;
|
||||
border: 1px solid #333;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
padding: 12px 15px;
|
||||
background: #222;
|
||||
border-bottom: 1px solid #333;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.balance-info {
|
||||
padding: 12px 15px;
|
||||
background: #111;
|
||||
border-bottom: 1px solid #333;
|
||||
}
|
||||
|
||||
.balance-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.balance-row span:first-child {
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.balance-row span.up {
|
||||
color: #ff3b3b;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.balance-row span.down {
|
||||
color: #2979ff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.portfolio-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.portfolio-item {
|
||||
padding: 10px;
|
||||
margin-bottom: 6px;
|
||||
background: #222;
|
||||
border: 1px solid #333;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.portfolio-item:hover {
|
||||
background: #2a2a2a;
|
||||
border-color: #4fc3f7;
|
||||
}
|
||||
|
||||
.portfolio-item .stock-name {
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
margin-bottom: 6px;
|
||||
display: block;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.portfolio-item .stock-info {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 11px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.portfolio-item .stock-info span.up {
|
||||
color: #ff3b3b;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.portfolio-item .stock-info span.down {
|
||||
color: #2979ff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 중앙 패널 */
|
||||
.center-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 차트 섹션 */
|
||||
.chart-section {
|
||||
background: #1a1a1a;
|
||||
border: 1px solid #333;
|
||||
border-radius: 4px;
|
||||
padding: 15px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 420px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.chart-toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.chart-stocks {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.chart-stock {
|
||||
background: #111;
|
||||
border: 1px solid #333;
|
||||
border-radius: 4px;
|
||||
padding: 8px 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 120px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.chart-stock .name {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.chart-stock .rate {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.chart-stock.active {
|
||||
border-color: #00ff88;
|
||||
box-shadow: 0 0 12px rgba(0, 255, 136, 0.2);
|
||||
}
|
||||
|
||||
.chart-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.ws-status {
|
||||
padding: 6px 12px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
background: rgba(136, 146, 176, 0.2);
|
||||
border: 1px solid rgba(136, 146, 176, 0.4);
|
||||
}
|
||||
|
||||
.ws-status.online {
|
||||
background: rgba(0, 255, 136, 0.15);
|
||||
border-color: rgba(0, 255, 136, 0.5);
|
||||
color: #00ff88;
|
||||
}
|
||||
|
||||
.ws-status.offline {
|
||||
color: #8892b0;
|
||||
}
|
||||
|
||||
.chart-period {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.chart-period button {
|
||||
padding: 6px 12px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #333;
|
||||
background: #111;
|
||||
color: #8892b0;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.chart-period button.active,
|
||||
.chart-period button:hover {
|
||||
border-color: #00ff88;
|
||||
color: #00ff88;
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
flex: 1;
|
||||
min-height: 340px;
|
||||
background: #0a0a0a;
|
||||
border: 1px solid #222;
|
||||
border-radius: 4px;
|
||||
padding: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* 거래 이력 섹션 */
|
||||
.trades-section {
|
||||
background: #1a1a1a;
|
||||
border: 1px solid #333;
|
||||
border-radius: 4px;
|
||||
padding: 15px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 280px;
|
||||
max-height: 320px;
|
||||
}
|
||||
|
||||
.trades-table-container {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.trades-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.trades-table thead {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: #1a1a1a;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.trades-table th {
|
||||
padding: 8px;
|
||||
font-size: 11px;
|
||||
color: #999;
|
||||
font-weight: 600;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #333;
|
||||
}
|
||||
|
||||
.trades-table td {
|
||||
padding: 8px;
|
||||
font-size: 11px;
|
||||
color: #ccc;
|
||||
border-bottom: 1px solid #222;
|
||||
}
|
||||
|
||||
.trades-table tbody tr:hover {
|
||||
background: #222;
|
||||
}
|
||||
|
||||
.trade-status {
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.trade-status.executed {
|
||||
background: rgba(76, 175, 80, 0.2);
|
||||
color: #4caf50;
|
||||
}
|
||||
|
||||
.trade-status.pending {
|
||||
background: rgba(255, 152, 0, 0.2);
|
||||
color: #ff9800;
|
||||
}
|
||||
|
||||
.trade-status.failed {
|
||||
background: rgba(244, 67, 54, 0.2);
|
||||
color: #f44336;
|
||||
}
|
||||
|
||||
/* 오른쪽 패널 */
|
||||
.right-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.recommend-section,
|
||||
.mcp-status-section,
|
||||
.news-section,
|
||||
.ai-analysis-section,
|
||||
.risk-check-section {
|
||||
background: #1a1a1a;
|
||||
border: 1px solid #333;
|
||||
border-radius: 4px;
|
||||
padding: 15px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.ai-analysis-section {
|
||||
min-height: 180px;
|
||||
max-height: 220px;
|
||||
}
|
||||
|
||||
.risk-check-section {
|
||||
min-height: 150px;
|
||||
max-height: 180px;
|
||||
}
|
||||
|
||||
.analysis-content,
|
||||
.risk-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 12px;
|
||||
background: #0a0a0a;
|
||||
border: 1px solid #222;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.analysis-loading,
|
||||
.risk-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: #666;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.risk-item {
|
||||
padding: 8px;
|
||||
margin-bottom: 6px;
|
||||
background: #111;
|
||||
border: 1px solid #333;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.risk-item-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 4px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.risk-level {
|
||||
padding: 2px 8px;
|
||||
border-radius: 3px;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.risk-level.low {
|
||||
background: rgba(76, 175, 80, 0.2);
|
||||
color: #4caf50;
|
||||
}
|
||||
|
||||
.risk-level.medium {
|
||||
background: rgba(255, 152, 0, 0.2);
|
||||
color: #ff9800;
|
||||
}
|
||||
|
||||
.risk-level.high {
|
||||
background: rgba(244, 67, 54, 0.2);
|
||||
color: #f44336;
|
||||
}
|
||||
|
||||
.risk-description {
|
||||
color: #999;
|
||||
font-size: 10px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.recommend-section {
|
||||
min-height: 300px;
|
||||
max-height: 360px;
|
||||
}
|
||||
|
||||
.recommend-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.recommend-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
background: #111;
|
||||
border: 1px solid #333;
|
||||
border-radius: 4px;
|
||||
padding: 10px 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.recommend-item .info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.recommend-item .info .name {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.recommend-item .info .code {
|
||||
font-size: 10px;
|
||||
color: #666;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.recommend-item .figures {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.recommend-item .figures .price {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.recommend-item .figures .rate {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.recommend-item:hover,
|
||||
.recommend-item.active {
|
||||
border-color: #00ff88;
|
||||
box-shadow: 0 0 12px rgba(0, 255, 136, 0.2);
|
||||
}
|
||||
|
||||
.btn-small {
|
||||
background: #1a1a1a;
|
||||
border: 1px solid #333;
|
||||
color: #4fc3f7;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-small:hover {
|
||||
background: #222;
|
||||
border-color: #4fc3f7;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #4fc3f7;
|
||||
color: #000;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #6fd4ff;
|
||||
}
|
||||
|
||||
.btn-primary:disabled {
|
||||
background: #2a4a5a;
|
||||
color: #666;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.mcp-status-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.mcp-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px;
|
||||
background: #222;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.status-indicator {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
.status-indicator.connected {
|
||||
background: #4caf50;
|
||||
}
|
||||
|
||||
.status-indicator.disconnected {
|
||||
background: #f44336;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.status-text {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.status-label {
|
||||
font-size: 11px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.status-value {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.mcp-tools-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.mcp-tool-badge {
|
||||
padding: 6px;
|
||||
background: #111;
|
||||
border: 1px solid #333;
|
||||
border-radius: 3px;
|
||||
font-size: 10px;
|
||||
color: #999;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.news-section {
|
||||
flex: 1;
|
||||
min-height: 180px;
|
||||
max-height: 220px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.news-section .news-list {
|
||||
max-height: 160px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.news-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.news-item {
|
||||
padding: 10px;
|
||||
background: #222;
|
||||
border: 1px solid #333;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.news-item:hover {
|
||||
border-color: #4fc3f7;
|
||||
transform: translateX(2px);
|
||||
}
|
||||
|
||||
.news-title {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
margin-bottom: 4px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.news-meta {
|
||||
font-size: 10px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
/* 공통 */
|
||||
.no-data {
|
||||
padding: 40px 20px;
|
||||
text-align: center;
|
||||
color: #666;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* 색상 */
|
||||
.up {
|
||||
color: #ff3b3b !important;
|
||||
}
|
||||
|
||||
.down {
|
||||
color: #2979ff !important;
|
||||
}
|
||||
|
||||
/* 스크롤바 */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: #1a1a1a;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #333;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #444;
|
||||
}
|
||||
922
frontend/src/pages/Dashboard.tsx
Normal file
922
frontend/src/pages/Dashboard.tsx
Normal 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
|
||||
176
frontend/src/pages/MCPStatus.tsx
Normal file
176
frontend/src/pages/MCPStatus.tsx
Normal file
@@ -0,0 +1,176 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import api from '../lib/api'
|
||||
import { Activity, CheckCircle, XCircle, AlertCircle } from 'lucide-react'
|
||||
import { useWebSocket } from '../hooks/useWebSocket'
|
||||
import { useEffect, useState } from 'react'
|
||||
import HtsPage from '../components/HtsPage'
|
||||
|
||||
const MCPStatus = () => {
|
||||
const { messages } = useWebSocket('mcp-monitor')
|
||||
const [mcpMessages, setMcpMessages] = useState<any[]>([])
|
||||
|
||||
// MCP 서버 상태 조회
|
||||
const { data: mcpStatus, refetch } = useQuery({
|
||||
queryKey: ['mcp-status'],
|
||||
queryFn: async () => {
|
||||
const response = await api.get('/mcp/status')
|
||||
return response.data
|
||||
},
|
||||
refetchInterval: 5000,
|
||||
})
|
||||
|
||||
// 사용 가능한 MCP 도구 목록
|
||||
const { data: mcpTools } = useQuery({
|
||||
queryKey: ['mcp-tools'],
|
||||
queryFn: async () => {
|
||||
const response = await api.get('/mcp/tools')
|
||||
return response.data
|
||||
},
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
// MCP 상태 업데이트 메시지 수신
|
||||
const statusMessages = messages.filter(msg => msg.type === 'mcp_status')
|
||||
if (statusMessages.length > 0) {
|
||||
setMcpMessages(prev => [...statusMessages, ...prev].slice(0, 50))
|
||||
}
|
||||
}, [messages])
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'connected':
|
||||
return 'text-success'
|
||||
case 'disconnected':
|
||||
return 'text-danger'
|
||||
default:
|
||||
return 'text-warning'
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusIcon = (status: string) => {
|
||||
switch (status) {
|
||||
case 'connected':
|
||||
return <CheckCircle className="text-success" size={24} />
|
||||
case 'disconnected':
|
||||
return <XCircle className="text-danger" size={24} />
|
||||
default:
|
||||
return <AlertCircle className="text-warning" size={24} />
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<HtsPage>
|
||||
<div className="space-y-6">
|
||||
<h2 className="text-3xl font-bold text-white">MCP 상태 모니터</h2>
|
||||
|
||||
{/* MCP 서버 상태 */}
|
||||
<div className="card">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-xl font-bold text-white">MCP 서버 연결 상태</h3>
|
||||
<button
|
||||
onClick={() => refetch()}
|
||||
className="btn-secondary text-sm"
|
||||
>
|
||||
새로고침
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{mcpStatus && (
|
||||
<div className="flex items-center space-x-4">
|
||||
{getStatusIcon(mcpStatus.status)}
|
||||
<div>
|
||||
<p className={`text-xl font-bold ${getStatusColor(mcpStatus.status)}`}>
|
||||
{mcpStatus.status === 'connected' ? '연결됨' :
|
||||
mcpStatus.status === 'disconnected' ? '연결 끊김' : '알 수 없음'}
|
||||
</p>
|
||||
<p className="text-sm text-slate-400">
|
||||
{mcpStatus.mcp_server_url || '서버 URL 없음'}
|
||||
</p>
|
||||
{mcpStatus.message && (
|
||||
<p className="text-sm text-slate-500 mt-1">{mcpStatus.message}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 사용 가능한 도구 */}
|
||||
<div className="card">
|
||||
<h3 className="text-xl font-bold text-white mb-4">사용 가능한 MCP 도구</h3>
|
||||
|
||||
{mcpTools && mcpTools.tools && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{mcpTools.tools.map((tool: any, index: number) => (
|
||||
<div
|
||||
key={index}
|
||||
className="bg-slate-900 rounded-lg p-4 border border-slate-700"
|
||||
>
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<h4 className="text-lg font-semibold text-white">{tool.name}</h4>
|
||||
<Activity className="text-primary" size={20} />
|
||||
</div>
|
||||
<p className="text-sm text-slate-400">{tool.description}</p>
|
||||
|
||||
{tool.input_schema && tool.input_schema.properties && (
|
||||
<div className="mt-3 text-xs">
|
||||
<p className="text-slate-500 font-medium mb-1">파라미터:</p>
|
||||
<ul className="space-y-1">
|
||||
{Object.entries(tool.input_schema.properties).map(([key, prop]: [string, any]) => (
|
||||
<li key={key} className="text-slate-400">
|
||||
• <span className="text-primary">{key}</span>
|
||||
{tool.input_schema.required?.includes(key) && (
|
||||
<span className="text-danger ml-1">*</span>
|
||||
)}
|
||||
: {prop.type}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(!mcpTools || !mcpTools.tools || mcpTools.tools.length === 0) && (
|
||||
<p className="text-slate-400 text-center py-8">
|
||||
사용 가능한 도구가 없습니다.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* MCP 메시지 로그 */}
|
||||
<div className="card">
|
||||
<h3 className="text-xl font-bold text-white mb-4">실시간 MCP 로그</h3>
|
||||
|
||||
<div className="space-y-2 max-h-96 overflow-y-auto">
|
||||
{mcpMessages.map((msg, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="bg-slate-900 rounded p-3 text-sm font-mono"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-slate-500">
|
||||
{new Date().toLocaleTimeString('ko-KR')}
|
||||
</span>
|
||||
<span className="text-primary">{msg.type}</span>
|
||||
</div>
|
||||
<pre className="text-slate-300 whitespace-pre-wrap">
|
||||
{JSON.stringify(msg.data, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{mcpMessages.length === 0 && (
|
||||
<p className="text-slate-400 text-center py-8">
|
||||
MCP 로그가 없습니다.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</HtsPage>
|
||||
)
|
||||
}
|
||||
|
||||
export default MCPStatus
|
||||
163
frontend/src/pages/TradeHistory.tsx
Normal file
163
frontend/src/pages/TradeHistory.tsx
Normal file
@@ -0,0 +1,163 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { format } from 'date-fns'
|
||||
import { ko } from 'date-fns/locale'
|
||||
import { TrendingUp, TrendingDown, Clock, CheckCircle, XCircle } from 'lucide-react'
|
||||
import HtsPage from '../components/HtsPage'
|
||||
import api from '../lib/api'
|
||||
|
||||
interface Trade {
|
||||
id: string
|
||||
created_at: string
|
||||
status: 'EXECUTED' | 'FAILED' | 'PENDING' | string
|
||||
stock_code: string
|
||||
stock_name?: string
|
||||
order_type: 'BUY' | 'SELL'
|
||||
order_price: number | null
|
||||
order_quantity: number
|
||||
executed_price: number | null
|
||||
executed_quantity: number | null
|
||||
message?: string | null
|
||||
}
|
||||
|
||||
const TradeHistory = () => {
|
||||
const {
|
||||
data: trades = [],
|
||||
isLoading,
|
||||
isError,
|
||||
} = useQuery<Trade[]>({
|
||||
queryKey: ['trade-history'],
|
||||
queryFn: async () => {
|
||||
const response = await api.get('/trades', {
|
||||
params: { limit: 100, days: 30 },
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
refetchInterval: 10000,
|
||||
})
|
||||
|
||||
const getStatusIcon = (status: Trade['status']) => {
|
||||
switch (status) {
|
||||
case 'EXECUTED':
|
||||
return <CheckCircle className="text-success" size={20} />
|
||||
case 'FAILED':
|
||||
return <XCircle className="text-danger" size={20} />
|
||||
case 'PENDING':
|
||||
return <Clock className="text-warning" size={20} />
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const formatCurrency = (value: number | null) => {
|
||||
if (value == null) {
|
||||
return '시장가'
|
||||
}
|
||||
|
||||
return new Intl.NumberFormat('ko-KR', {
|
||||
style: 'currency',
|
||||
currency: 'KRW',
|
||||
maximumFractionDigits: 0,
|
||||
}).format(value)
|
||||
}
|
||||
|
||||
return (
|
||||
<HtsPage contentClassName="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-3xl font-bold text-white">매매 로그</h2>
|
||||
{isLoading && <span className="text-sm text-slate-400">데이터 갱신 중...</span>}
|
||||
</div>
|
||||
|
||||
{isError && (
|
||||
<div className="card border border-danger/40 bg-danger/10 text-danger">
|
||||
최근 거래 내역을 불러오는 중 문제가 발생했습니다.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="card">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-700">
|
||||
<th className="text-left py-3 px-4 text-slate-400">시간</th>
|
||||
<th className="text-left py-3 px-4 text-slate-400">상태</th>
|
||||
<th className="text-left py-3 px-4 text-slate-400">종목</th>
|
||||
<th className="text-center py-3 px-4 text-slate-400">구분</th>
|
||||
<th className="text-right py-3 px-4 text-slate-400">주문가</th>
|
||||
<th className="text-right py-3 px-4 text-slate-400">수량</th>
|
||||
<th className="text-right py-3 px-4 text-slate-400">체결가</th>
|
||||
<th className="text-right py-3 px-4 text-slate-400">체결수량</th>
|
||||
<th className="text-left py-3 px-4 text-slate-400">메시지</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{trades.map((trade) => (
|
||||
<tr key={trade.id} className="border-b border-slate-700/50 hover:bg-slate-700/30">
|
||||
<td className="py-3 px-4 text-slate-300 text-sm whitespace-nowrap">
|
||||
{format(new Date(trade.created_at), 'MM/dd HH:mm:ss', { locale: ko })}
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
{getStatusIcon(trade.status)}
|
||||
<span className="text-sm text-slate-300">{trade.status}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<div>
|
||||
<div className="text-white font-medium">{trade.stock_code}</div>
|
||||
{trade.stock_name && (
|
||||
<div className="text-xs text-slate-400">{trade.stock_name}</div>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3 px-4 text-center">
|
||||
<span
|
||||
className={`inline-flex items-center px-2 py-1 rounded text-xs font-medium ${trade.order_type === 'BUY'
|
||||
? 'bg-primary/20 text-primary'
|
||||
: 'bg-danger/20 text-danger'
|
||||
}`}
|
||||
>
|
||||
{trade.order_type === 'BUY' ? (
|
||||
<>
|
||||
<TrendingUp size={12} className="mr-1" />
|
||||
매수
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<TrendingDown size={12} className="mr-1" />
|
||||
매도
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3 px-4 text-right text-white">
|
||||
{formatCurrency(trade.order_price)}
|
||||
</td>
|
||||
<td className="py-3 px-4 text-right text-white">
|
||||
{trade.order_quantity.toLocaleString()}
|
||||
</td>
|
||||
<td className="py-3 px-4 text-right text-white">
|
||||
{trade.executed_price != null ? formatCurrency(trade.executed_price) : '-'}
|
||||
</td>
|
||||
<td className="py-3 px-4 text-right text-white">
|
||||
{trade.executed_quantity != null ? trade.executed_quantity.toLocaleString() : '-'}
|
||||
</td>
|
||||
<td className="py-3 px-4 text-slate-400 text-sm">
|
||||
{trade.message || '-'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{!trades.length && !isLoading && (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-slate-400">거래 이력이 없습니다.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</HtsPage>
|
||||
)
|
||||
}
|
||||
|
||||
export default TradeHistory
|
||||
370
frontend/src/pages/TradingRules.css
Normal file
370
frontend/src/pages/TradingRules.css
Normal file
@@ -0,0 +1,370 @@
|
||||
.trading-rules-container {
|
||||
padding: 20px;
|
||||
background: #0a192f;
|
||||
min-height: 100vh;
|
||||
color: #ccd6f6;
|
||||
}
|
||||
|
||||
.rules-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 30px;
|
||||
padding-bottom: 15px;
|
||||
border-bottom: 2px solid #00ff88;
|
||||
}
|
||||
|
||||
.rules-header h1 {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: #00ff88;
|
||||
text-shadow: 0 0 10px rgba(0, 255, 136, 0.5);
|
||||
}
|
||||
|
||||
.btn-create {
|
||||
padding: 10px 20px;
|
||||
background: #00ff88;
|
||||
color: #0a192f;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.btn-create:hover {
|
||||
background: #00cc6a;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 255, 136, 0.4);
|
||||
}
|
||||
|
||||
/* 규칙 목록 */
|
||||
.rules-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.rule-card {
|
||||
background: linear-gradient(135deg, #16213e 0%, #1a1a2e 100%);
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
border: 1px solid rgba(0, 255, 136, 0.2);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.rule-card:hover {
|
||||
border-color: #00ff88;
|
||||
box-shadow: 0 4px 20px rgba(0, 255, 136, 0.3);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.rule-card.active {
|
||||
border-color: #00ff88;
|
||||
}
|
||||
|
||||
.rule-card.inactive {
|
||||
opacity: 0.6;
|
||||
border-color: #64748b;
|
||||
}
|
||||
|
||||
.rule-header-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.rule-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.rule-title h3 {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #00ff88;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.rule-badge {
|
||||
padding: 4px 12px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.rule-badge.buy {
|
||||
background: rgba(0, 255, 136, 0.2);
|
||||
color: #00ff88;
|
||||
}
|
||||
|
||||
.rule-badge.sell {
|
||||
background: rgba(255, 99, 71, 0.2);
|
||||
color: #ff6347;
|
||||
}
|
||||
|
||||
.rule-badge.stop_loss {
|
||||
background: rgba(255, 215, 0, 0.2);
|
||||
color: #ffd700;
|
||||
}
|
||||
|
||||
.rule-badge.take_profit {
|
||||
background: rgba(64, 224, 208, 0.2);
|
||||
color: #40e0d0;
|
||||
}
|
||||
|
||||
.rule-priority {
|
||||
font-size: 12px;
|
||||
color: #8892b0;
|
||||
padding: 4px 8px;
|
||||
background: rgba(136, 146, 176, 0.1);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.rule-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.btn-toggle {
|
||||
padding: 6px 12px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.btn-toggle.active {
|
||||
background: rgba(0, 255, 136, 0.2);
|
||||
color: #00ff88;
|
||||
}
|
||||
|
||||
.btn-toggle.inactive {
|
||||
background: rgba(255, 99, 71, 0.2);
|
||||
color: #ff6347;
|
||||
}
|
||||
|
||||
.btn-edit,
|
||||
.btn-delete {
|
||||
padding: 6px 12px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.btn-edit {
|
||||
background: rgba(100, 116, 139, 0.2);
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
.btn-edit:hover {
|
||||
background: rgba(100, 116, 139, 0.4);
|
||||
}
|
||||
|
||||
.btn-delete {
|
||||
background: rgba(255, 99, 71, 0.2);
|
||||
color: #ff6347;
|
||||
}
|
||||
|
||||
.btn-delete:hover {
|
||||
background: rgba(255, 99, 71, 0.4);
|
||||
}
|
||||
|
||||
.rule-description {
|
||||
color: #8892b0;
|
||||
margin-bottom: 15px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.rule-conditions {
|
||||
background: rgba(10, 25, 47, 0.6);
|
||||
padding: 15px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 15px;
|
||||
border: 1px solid rgba(0, 255, 136, 0.1);
|
||||
}
|
||||
|
||||
.rule-conditions strong {
|
||||
color: #00ff88;
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.rule-conditions pre {
|
||||
color: #cbd5e1;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.rule-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
padding-top: 15px;
|
||||
border-top: 1px solid rgba(100, 116, 139, 0.2);
|
||||
}
|
||||
|
||||
/* 규칙 추가/수정 모달 */
|
||||
.rule-form-modal {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.rule-form-content {
|
||||
background: linear-gradient(135deg, #16213e 0%, #1a1a2e 100%);
|
||||
border-radius: 12px;
|
||||
padding: 30px;
|
||||
max-width: 600px;
|
||||
width: 100%;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
border: 2px solid #00ff88;
|
||||
box-shadow: 0 10px 40px rgba(0, 255, 136, 0.3);
|
||||
}
|
||||
|
||||
.rule-form-content h2 {
|
||||
color: #00ff88;
|
||||
margin-bottom: 25px;
|
||||
font-size: 24px;
|
||||
text-shadow: 0 0 10px rgba(0, 255, 136, 0.5);
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
color: #00ff88;
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.form-group input,
|
||||
.form-group textarea,
|
||||
.form-group select {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
background: rgba(10, 25, 47, 0.8);
|
||||
border: 1px solid rgba(0, 255, 136, 0.3);
|
||||
border-radius: 6px;
|
||||
color: #ccd6f6;
|
||||
font-size: 14px;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.form-group input:focus,
|
||||
.form-group textarea:focus,
|
||||
.form-group select:focus {
|
||||
outline: none;
|
||||
border-color: #00ff88;
|
||||
box-shadow: 0 0 10px rgba(0, 255, 136, 0.3);
|
||||
}
|
||||
|
||||
.form-group textarea {
|
||||
resize: vertical;
|
||||
font-family: 'Monaco', 'Courier New', monospace;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.checkbox-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.checkbox-group label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.checkbox-group input[type="checkbox"] {
|
||||
width: auto;
|
||||
margin-right: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-top: 30px;
|
||||
}
|
||||
|
||||
.btn-save,
|
||||
.btn-cancel {
|
||||
flex: 1;
|
||||
padding: 12px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.btn-save {
|
||||
background: #00ff88;
|
||||
color: #0a192f;
|
||||
}
|
||||
|
||||
.btn-save:hover {
|
||||
background: #00cc6a;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 255, 136, 0.4);
|
||||
}
|
||||
|
||||
.btn-cancel {
|
||||
background: rgba(100, 116, 139, 0.2);
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
.btn-cancel:hover {
|
||||
background: rgba(100, 116, 139, 0.4);
|
||||
}
|
||||
|
||||
/* 빈 상태 */
|
||||
.no-rules {
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
color: #8892b0;
|
||||
}
|
||||
|
||||
.no-rules p:first-child {
|
||||
font-size: 18px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.no-rules p:last-child {
|
||||
font-size: 14px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
/* 로딩 */
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
font-size: 18px;
|
||||
color: #00ff88;
|
||||
}
|
||||
286
frontend/src/pages/TradingRules.tsx
Normal file
286
frontend/src/pages/TradingRules.tsx
Normal file
@@ -0,0 +1,286 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import api from '../lib/api'
|
||||
import { Plus, Trash2, Power, PowerOff } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import HtsPage from '../components/HtsPage'
|
||||
|
||||
const TradingRules = () => {
|
||||
const queryClient = useQueryClient()
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
description: '',
|
||||
rule_type: 'BUY',
|
||||
priority: 1,
|
||||
conditions: '{\n "rsi_threshold": 40,\n "sma_short": 5,\n "sma_long": 20\n}',
|
||||
actions: '{\n "take_profit": 5.0,\n "stop_loss": 3.0,\n "position_sizing": {\n "target_ratio": 0.15\n }\n}'
|
||||
})
|
||||
|
||||
// 트레이딩 규칙 조회
|
||||
const { data: rules, isLoading } = useQuery({
|
||||
queryKey: ['trading-rules'],
|
||||
queryFn: async () => {
|
||||
const response = await api.get('/rules')
|
||||
return response.data
|
||||
},
|
||||
})
|
||||
|
||||
// 규칙 생성
|
||||
const createMutation = useMutation({
|
||||
mutationFn: async (data: any) => {
|
||||
// MCP로 규칙 검증
|
||||
try {
|
||||
const validateResponse = await fetch('http://localhost:8001/analyze', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
analysis_type: 'RULE_VALIDATION',
|
||||
data: {
|
||||
name: data.name,
|
||||
conditions: JSON.parse(data.conditions),
|
||||
actions: JSON.parse(data.actions)
|
||||
}
|
||||
})
|
||||
});
|
||||
const validation = await validateResponse.json();
|
||||
|
||||
if (!confirm(`🤖 AI 규칙 검증 결과:\n\n${validation.analysis || validation.result}\n\n계속 진행하시겠습니까?`)) {
|
||||
throw new Error('사용자가 취소했습니다.');
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error.message === '사용자가 취소했습니다.') {
|
||||
throw error;
|
||||
}
|
||||
console.warn('MCP 검증 실패, 그대로 진행:', error);
|
||||
}
|
||||
|
||||
return api.post('/rules', {
|
||||
...data,
|
||||
conditions: JSON.parse(data.conditions),
|
||||
actions: JSON.parse(data.actions)
|
||||
})
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['trading-rules'] })
|
||||
setShowForm(false)
|
||||
setFormData({
|
||||
name: '',
|
||||
description: '',
|
||||
rule_type: 'BUY',
|
||||
priority: 1,
|
||||
conditions: '{\n "rsi_threshold": 40,\n "sma_short": 5,\n "sma_long": 20\n}',
|
||||
actions: '{\n "take_profit": 5.0,\n "stop_loss": 3.0,\n "position_sizing": {\n "target_ratio": 0.15\n }\n}'
|
||||
})
|
||||
alert('✅ 규칙이 성공적으로 추가되었습니다!')
|
||||
},
|
||||
onError: (error: any) => {
|
||||
if (error.message !== '사용자가 취소했습니다.') {
|
||||
alert(`❌ 규칙 추가 실패: ${error.response?.data?.detail || error.message}`)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
// 규칙 활성화/비활성화
|
||||
const toggleMutation = useMutation({
|
||||
mutationFn: async (ruleId: number) => {
|
||||
return api.post(`/rules/${ruleId}/toggle`)
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['trading-rules'] })
|
||||
},
|
||||
})
|
||||
|
||||
// 규칙 삭제
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: async (ruleId: number) => {
|
||||
return api.delete(`/rules/${ruleId}`)
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['trading-rules'] })
|
||||
},
|
||||
})
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="text-white">로딩 중...</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<HtsPage>
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-3xl font-bold text-white">전략 룰 설정</h2>
|
||||
<button
|
||||
onClick={() => setShowForm(true)}
|
||||
className="btn-primary flex items-center space-x-2"
|
||||
>
|
||||
<Plus size={20} />
|
||||
<span>새 규칙 추가</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showForm && (
|
||||
<div className="card border border-primary/40 bg-slate-900/70">
|
||||
<h3 className="text-xl font-bold text-white mb-4">새 규칙 추가</h3>
|
||||
<form onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
createMutation.mutate(formData)
|
||||
}}>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-300 mb-1">규칙 이름</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
className="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded text-white"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-300 mb-1">설명</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
className="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded text-white"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-300 mb-1">규칙 타입</label>
|
||||
<select
|
||||
value={formData.rule_type}
|
||||
onChange={(e) => setFormData({ ...formData, rule_type: e.target.value })}
|
||||
className="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded text-white"
|
||||
>
|
||||
<option value="BUY">BUY</option>
|
||||
<option value="SELL">SELL</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-300 mb-1">우선순위</label>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.priority}
|
||||
onChange={(e) => setFormData({ ...formData, priority: parseInt(e.target.value) })}
|
||||
className="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded text-white"
|
||||
min="1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-300 mb-1">조건 (JSON)</label>
|
||||
<textarea
|
||||
value={formData.conditions}
|
||||
onChange={(e) => setFormData({ ...formData, conditions: e.target.value })}
|
||||
className="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded text-white font-mono text-xs"
|
||||
rows={6}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-300 mb-1">실행 액션 (JSON)</label>
|
||||
<textarea
|
||||
value={formData.actions}
|
||||
onChange={(e) => setFormData({ ...formData, actions: e.target.value })}
|
||||
className="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded text-white font-mono text-xs"
|
||||
rows={6}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 flex justify-end space-x-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowForm(false)}
|
||||
className="btn-secondary text-sm"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn-primary text-sm"
|
||||
disabled={createMutation.isPending}
|
||||
>
|
||||
{createMutation.isPending ? '검증 중...' : '🤖 AI 검증 후 추가'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-4">
|
||||
{rules && rules.map((rule: any) => (
|
||||
<div key={rule.id} className="card">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center space-x-3 mb-2">
|
||||
<h3 className="text-xl font-bold text-white">{rule.name}</h3>
|
||||
<span className={`px-2 py-1 rounded text-xs font-medium ${rule.is_active ? 'bg-success/20 text-success' : 'bg-slate-700 text-slate-400'
|
||||
}`}>
|
||||
{rule.is_active ? '활성' : '비활성'}
|
||||
</span>
|
||||
<span className={`px-2 py-1 rounded text-xs font-medium ${rule.rule_type === 'BUY' ? 'bg-primary/20 text-primary' : 'bg-danger/20 text-danger'
|
||||
}`}>
|
||||
{rule.rule_type}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{rule.description && (
|
||||
<p className="text-slate-400 mb-3">{rule.description}</p>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<p className="text-slate-500 font-medium mb-1">조건</p>
|
||||
<pre className="bg-slate-900 p-2 rounded text-slate-300 overflow-x-auto">
|
||||
{JSON.stringify(rule.conditions, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-slate-500 font-medium mb-1">실행</p>
|
||||
<pre className="bg-slate-900 p-2 rounded text-slate-300 overflow-x-auto">
|
||||
{JSON.stringify(rule.actions, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex space-x-2 ml-4">
|
||||
<button
|
||||
onClick={() => toggleMutation.mutate(rule.id)}
|
||||
className={`btn ${rule.is_active ? 'btn-danger' : 'btn-success'}`}
|
||||
title={rule.is_active ? '비활성화' : '활성화'}
|
||||
>
|
||||
{rule.is_active ? <PowerOff size={18} /> : <Power size={18} />}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => deleteMutation.mutate(rule.id)}
|
||||
className="btn btn-danger"
|
||||
title="삭제"
|
||||
>
|
||||
<Trash2 size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{(!rules || rules.length === 0) && (
|
||||
<div className="card text-center py-12">
|
||||
<p className="text-slate-400">설정된 트레이딩 규칙이 없습니다.</p>
|
||||
<button
|
||||
onClick={() => setShowForm(true)}
|
||||
className="btn-primary mt-4"
|
||||
>
|
||||
첫 규칙 추가하기
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</HtsPage>
|
||||
)
|
||||
}
|
||||
|
||||
export default TradingRules
|
||||
10
frontend/src/vite-env.d.ts
vendored
Normal file
10
frontend/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_API_URL: string
|
||||
readonly VITE_WS_URL: string
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv
|
||||
}
|
||||
Reference in New Issue
Block a user