Initial commit
This commit is contained in:
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
|
||||
Reference in New Issue
Block a user