수정본
This commit is contained in:
@@ -1,434 +0,0 @@
|
||||
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
|
||||
@@ -1,5 +1,8 @@
|
||||
import { Link, useLocation } from 'react-router-dom'
|
||||
import { LayoutDashboard, FileText, History, Cpu } from 'lucide-react'
|
||||
import { LayoutDashboard, FileText, History, Cpu, Activity } from 'lucide-react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import api from '../lib/api'
|
||||
import { useWebSocket } from '../hooks/useWebSocket'
|
||||
|
||||
interface LayoutProps {
|
||||
children: React.ReactNode
|
||||
@@ -7,58 +10,78 @@ interface LayoutProps {
|
||||
|
||||
const Layout = ({ children }: LayoutProps) => {
|
||||
const location = useLocation()
|
||||
const { connected: wsConnected } = useWebSocket('dashboard') // Global WS status
|
||||
|
||||
const { data: mcpStatus } = useQuery({
|
||||
queryKey: ['mcp-status'],
|
||||
queryFn: async () => (await api.get('/mcp/status')).data,
|
||||
refetchInterval: 5000,
|
||||
})
|
||||
|
||||
const navItems = [
|
||||
{ path: '/', icon: LayoutDashboard, label: '대시보드' },
|
||||
{ path: '/rules', icon: FileText, 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="min-h-screen bg-[#0f1115] text-gray-100 font-sans flex flex-col">
|
||||
{/* Top Navigation Bar */}
|
||||
<header className="border-b border-gray-800 bg-[#161b22] h-16 flex items-center justify-between px-6 sticky top-0 z-50 backdrop-blur-md bg-opacity-90">
|
||||
<div className="flex items-center gap-8">
|
||||
{/* Brand */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="bg-blue-600 p-2 rounded-lg shadow-lg shadow-blue-500/20">
|
||||
<Activity className="text-white" size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold bg-gradient-to-r from-blue-400 to-purple-400 bg-clip-text text-transparent leading-none">
|
||||
AutoStock
|
||||
</h1>
|
||||
<p className="text-[10px] text-gray-500 font-mono tracking-wider">GEN 3 ALGO</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
{/* Navigation */}
|
||||
<nav className="flex items-center gap-1">
|
||||
{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'
|
||||
className={`flex items-center gap-2 px-3 py-2 rounded-md transition-all text-sm font-medium ${isActive
|
||||
? 'bg-gray-800 text-blue-400'
|
||||
: 'text-gray-400 hover:text-white hover:bg-gray-800/50'
|
||||
}`}
|
||||
>
|
||||
<Icon size={20} />
|
||||
<Icon size={16} />
|
||||
<span>{item.label}</span>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="flex-1 p-6">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
{/* Status Indicators */}
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 bg-gray-800/50 rounded-full border border-gray-700/50">
|
||||
<div className={`w-2 h-2 rounded-full ${wsConnected ? 'bg-green-500 shadow-[0_0_8px_rgba(34,197,94,0.5)]' : 'bg-red-500'}`} />
|
||||
<span className="text-xs text-gray-400">{wsConnected ? 'LIVE' : 'OFFLINE'}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 bg-gray-800/50 rounded-full border border-gray-700/50">
|
||||
<div className={`w-2 h-2 rounded-full ${mcpStatus?.status === 'connected' ? 'bg-blue-500 shadow-[0_0_8px_rgba(59,130,246,0.5)]' : 'bg-yellow-500'}`} />
|
||||
<span className="text-xs text-gray-400">MCP: {mcpStatus?.status || 'Unknown'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Main Content Area */}
|
||||
<main className="flex-1 overflow-x-hidden">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
90
frontend/src/components/PortfolioSummary.tsx
Normal file
90
frontend/src/components/PortfolioSummary.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import { ArrowUpRight, ArrowDownRight, AlertTriangle, Activity, TrendingUp, TrendingDown, DollarSign } from 'lucide-react';
|
||||
|
||||
interface PortfolioSummaryProps {
|
||||
summary: any;
|
||||
error: any;
|
||||
}
|
||||
|
||||
export const PortfolioSummary = ({ summary, error }: PortfolioSummaryProps) => {
|
||||
if (error) {
|
||||
return (
|
||||
<div className="bg-red-900/20 border border-red-500/30 rounded-lg p-4 flex items-center gap-3 text-red-400">
|
||||
<AlertTriangle className="w-5 h-5 flex-shrink-0" />
|
||||
<span>포트폴리오 정보를 불러오는데 실패했습니다.</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!summary) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 animate-pulse">
|
||||
{[...Array(4)].map((_, i) => (
|
||||
<div key={i} className="bg-gray-800/50 h-24 rounded-xl" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { total_assets, total_value, total_investment, total_profit_loss, total_profit_loss_rate, available_cash } = summary;
|
||||
const isProfit = total_profit_loss >= 0;
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
||||
{/* 총 자산 */}
|
||||
<div className="bg-gray-800/50 backdrop-blur-sm border border-gray-700/50 p-5 rounded-xl">
|
||||
<div className="flex justify-between items-start mb-2">
|
||||
<p className="text-gray-400 text-sm font-medium">총 자산</p>
|
||||
<div className="p-2 bg-blue-500/10 rounded-lg text-blue-400">
|
||||
<DollarSign size={18} />
|
||||
</div>
|
||||
</div>
|
||||
<h3 className="text-2xl font-bold text-white mb-1">
|
||||
{Math.round(total_assets || 0).toLocaleString()}원
|
||||
</h3>
|
||||
<div className="flex flex-col gap-0.5 text-xs text-gray-400">
|
||||
<span>예수금: {Math.round(available_cash || 0).toLocaleString()}원</span>
|
||||
<span>평가금액: {Math.round(total_value || 0).toLocaleString()}원</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 총 매입금액 */}
|
||||
<div className="bg-gray-800/50 backdrop-blur-sm border border-gray-700/50 p-5 rounded-xl">
|
||||
<div className="flex justify-between items-start mb-2">
|
||||
<p className="text-gray-400 text-sm font-medium">총 매입금액</p>
|
||||
<div className="p-2 bg-purple-500/10 rounded-lg text-purple-400">
|
||||
<Activity size={18} />
|
||||
</div>
|
||||
</div>
|
||||
<h3 className="text-2xl font-bold text-white mb-1">
|
||||
{Math.round(total_investment || 0).toLocaleString()}원
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{/* 총 평가손익 */}
|
||||
<div className="bg-gray-800/50 backdrop-blur-sm border border-gray-700/50 p-5 rounded-xl">
|
||||
<div className="flex justify-between items-start mb-2">
|
||||
<p className="text-gray-400 text-sm font-medium">총 평가손익</p>
|
||||
<div className={`p-2 rounded-lg ${isProfit ? 'bg-red-500/10 text-red-400' : 'bg-blue-500/10 text-blue-400'}`}>
|
||||
{isProfit ? <TrendingUp size={18} /> : <TrendingDown size={18} />}
|
||||
</div>
|
||||
</div>
|
||||
<h3 className={`text-2xl font-bold mb-1 ${isProfit ? 'text-red-400' : 'text-blue-400'}`}>
|
||||
{total_profit_loss > 0 ? '+' : ''}{Math.round(total_profit_loss || 0).toLocaleString()}원
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{/* 수익률 */}
|
||||
<div className="bg-gray-800/50 backdrop-blur-sm border border-gray-700/50 p-5 rounded-xl">
|
||||
<div className="flex justify-between items-start mb-2">
|
||||
<p className="text-gray-400 text-sm font-medium">수익률</p>
|
||||
<div className={`p-2 rounded-lg ${isProfit ? 'bg-red-500/10 text-red-400' : 'bg-blue-500/10 text-blue-400'}`}>
|
||||
{isProfit ? <ArrowUpRight size={18} /> : <ArrowDownRight size={18} />}
|
||||
</div>
|
||||
</div>
|
||||
<h3 className={`text-2xl font-bold mb-1 ${isProfit ? 'text-red-400' : 'text-blue-400'}`}>
|
||||
{total_profit_loss_rate > 0 ? '+' : ''}{total_profit_loss_rate?.toFixed(2)}%
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
512
frontend/src/components/TradingChart.tsx
Normal file
512
frontend/src/components/TradingChart.tsx
Normal file
@@ -0,0 +1,512 @@
|
||||
// @ts-nocheck
|
||||
import { useEffect, useRef, useMemo } from 'react';
|
||||
import { createChart, CrosshairMode, IChartApi, ISeriesApi, CandlestickData, LineData, HistogramData } from 'lightweight-charts';
|
||||
import {
|
||||
calculateSMA,
|
||||
calculateBollingerBands,
|
||||
calculateRSI,
|
||||
calculateMACD
|
||||
} from '../utils/technicalIndicators';
|
||||
|
||||
interface TradingChartProps {
|
||||
data: any[];
|
||||
}
|
||||
|
||||
// 한국 시장 색상 규칙
|
||||
const COLORS = {
|
||||
candleUp: '#ef4444', // 상승: 빨강
|
||||
candleDown: '#3b82f6', // 하락: 파랑
|
||||
ma5: '#fbbf24', // 5일선: 노란색
|
||||
ma20: '#f6ad55', // 20일선: 오렌지색
|
||||
ma60: '#10b981', // 60일선: 초록색
|
||||
bbLine: '#4a5568', // 볼린저 밴드: 회색
|
||||
rsi: '#a78bfa', // RSI: 보라색
|
||||
macd: '#3b82f6', // MACD: 파란색
|
||||
signal: '#f97316', // Signal: 주황색
|
||||
background: '#0b0e14',
|
||||
grid: '#1a1f2e',
|
||||
text: '#9ca3af',
|
||||
border: '#374151',
|
||||
};
|
||||
|
||||
export const TradingChart = ({ data }: TradingChartProps) => {
|
||||
// Refs for chart containers
|
||||
const mainChartRef = useRef<HTMLDivElement>(null);
|
||||
const volumeChartRef = useRef<HTMLDivElement>(null);
|
||||
const rsiChartRef = useRef<HTMLDivElement>(null);
|
||||
const macdChartRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Refs for chart instances
|
||||
const mainChartInstanceRef = useRef<IChartApi | null>(null);
|
||||
const volumeChartInstanceRef = useRef<IChartApi | null>(null);
|
||||
const rsiChartInstanceRef = useRef<IChartApi | null>(null);
|
||||
const macdChartInstanceRef = useRef<IChartApi | null>(null);
|
||||
|
||||
// Refs for series
|
||||
const candlestickSeriesRef = useRef<ISeriesApi<'Candlestick'> | null>(null);
|
||||
const volumeSeriesRef = useRef<ISeriesApi<'Histogram'> | null>(null);
|
||||
|
||||
// 날짜를 차트 시간 형식으로 변환하는 헬퍼 함수
|
||||
const toChartTime = (date: string | Date): string => {
|
||||
if (typeof date === 'string') {
|
||||
// YYYY-MM-DD 형식이면 그대로 반환
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(date)) {
|
||||
return date;
|
||||
}
|
||||
// YYYYMMDD 형식이면 YYYY-MM-DD로 변환
|
||||
if (/^\d{8}$/.test(date)) {
|
||||
return `${date.slice(0, 4)}-${date.slice(4, 6)}-${date.slice(6, 8)}`;
|
||||
}
|
||||
}
|
||||
// 그 외의 경우 Date 객체로 변환
|
||||
const d = new Date(date);
|
||||
return d.toISOString().split('T')[0]; // "YYYY-MM-DD"
|
||||
};
|
||||
|
||||
// 데이터 계산 (메모이제이션)
|
||||
const processedData = useMemo(() => {
|
||||
if (!data || data.length === 0) return null;
|
||||
|
||||
// 데이터 정렬 (시간 오름차순)
|
||||
const sortedData = [...data].sort((a, b) => {
|
||||
const timeA = new Date(a.date).getTime();
|
||||
const timeB = new Date(b.date).getTime();
|
||||
return timeA - timeB;
|
||||
});
|
||||
const closes = sortedData.map(d => d.close);
|
||||
|
||||
// 기술적 지표 계산
|
||||
const bollinger = calculateBollingerBands(closes, 20);
|
||||
const sma5 = calculateSMA(closes, 5);
|
||||
const sma60 = calculateSMA(closes, 60);
|
||||
const rsi = calculateRSI(closes, 14);
|
||||
const { macd, signal, histogram } = calculateMACD(closes);
|
||||
|
||||
// 캔들스틱 데이터 변환 (날짜 문자열을 YYYY-MM-DD 형식으로 변환)
|
||||
const candles: CandlestickData[] = sortedData.map(item => ({
|
||||
time: toChartTime(item.date) as any,
|
||||
open: item.open,
|
||||
high: item.high,
|
||||
low: item.low,
|
||||
close: item.close,
|
||||
}));
|
||||
|
||||
// 거래량 데이터 (한국 시장 색상 적용)
|
||||
const volumes: HistogramData[] = sortedData.map(item => ({
|
||||
time: toChartTime(item.date) as any,
|
||||
value: item.volume,
|
||||
color: item.close >= item.open
|
||||
? 'rgba(239, 68, 68, 0.6)' // 상승: 빨강
|
||||
: 'rgba(59, 130, 246, 0.6)' // 하락: 파랑
|
||||
}));
|
||||
|
||||
// 볼린저 밴드 데이터
|
||||
const bbUpperData: LineData[] = sortedData
|
||||
.map((item, i) => ({
|
||||
time: toChartTime(item.date) as any,
|
||||
value: bollinger.upper[i],
|
||||
}))
|
||||
.filter(d => !isNaN(d.value));
|
||||
|
||||
const bbMiddleData: LineData[] = sortedData
|
||||
.map((item, i) => ({
|
||||
time: toChartTime(item.date) as any,
|
||||
value: bollinger.middle[i],
|
||||
}))
|
||||
.filter(d => !isNaN(d.value));
|
||||
|
||||
const bbLowerData: LineData[] = sortedData
|
||||
.map((item, i) => ({
|
||||
time: toChartTime(item.date) as any,
|
||||
value: bollinger.lower[i],
|
||||
}))
|
||||
.filter(d => !isNaN(d.value));
|
||||
|
||||
// 이동평균선 데이터
|
||||
const sma5Data: LineData[] = sortedData
|
||||
.map((item, i) => ({
|
||||
time: toChartTime(item.date) as any,
|
||||
value: sma5[i],
|
||||
}))
|
||||
.filter(d => !isNaN(d.value));
|
||||
|
||||
const sma60Data: LineData[] = sortedData
|
||||
.map((item, i) => ({
|
||||
time: toChartTime(item.date) as any,
|
||||
value: sma60[i],
|
||||
}))
|
||||
.filter(d => !isNaN(d.value));
|
||||
|
||||
// RSI 데이터
|
||||
const rsiData: LineData[] = sortedData
|
||||
.map((item, i) => ({
|
||||
time: toChartTime(item.date) as any,
|
||||
value: rsi[i],
|
||||
}))
|
||||
.filter(d => !isNaN(d.value));
|
||||
|
||||
// RSI 기준선 (30, 50, 70)
|
||||
const rsiLine30: LineData[] = sortedData.map(item => ({
|
||||
time: toChartTime(item.date) as any,
|
||||
value: 30,
|
||||
}));
|
||||
const rsiLine50: LineData[] = sortedData.map(item => ({
|
||||
time: toChartTime(item.date) as any,
|
||||
value: 50,
|
||||
}));
|
||||
const rsiLine70: LineData[] = sortedData.map(item => ({
|
||||
time: toChartTime(item.date) as any,
|
||||
value: 70,
|
||||
}));
|
||||
|
||||
// MACD 데이터
|
||||
const macdHistogramData: HistogramData[] = sortedData
|
||||
.map((item, i) => ({
|
||||
time: toChartTime(item.date) as any,
|
||||
value: histogram[i],
|
||||
color: histogram[i] >= 0
|
||||
? 'rgba(239, 68, 68, 0.6)' // 양수: 빨강
|
||||
: 'rgba(59, 130, 246, 0.6)' // 음수: 파랑
|
||||
}))
|
||||
.filter(d => !isNaN(d.value));
|
||||
|
||||
const macdLineData: LineData[] = sortedData
|
||||
.map((item, i) => ({
|
||||
time: toChartTime(item.date) as any,
|
||||
value: macd[i],
|
||||
}))
|
||||
.filter(d => !isNaN(d.value));
|
||||
|
||||
const signalLineData: LineData[] = sortedData
|
||||
.map((item, i) => ({
|
||||
time: toChartTime(item.date) as any,
|
||||
value: signal[i],
|
||||
}))
|
||||
.filter(d => !isNaN(d.value));
|
||||
|
||||
return {
|
||||
candles,
|
||||
volumes,
|
||||
bollinger: { upper: bbUpperData, middle: bbMiddleData, lower: bbLowerData },
|
||||
sma5: sma5Data,
|
||||
sma60: sma60Data,
|
||||
rsi: { data: rsiData, line30: rsiLine30, line50: rsiLine50, line70: rsiLine70 },
|
||||
macd: { histogram: macdHistogramData, line: macdLineData, signal: signalLineData },
|
||||
};
|
||||
}, [data]);
|
||||
|
||||
// 공통 차트 옵션
|
||||
const getChartOptions = (height: number) => ({
|
||||
layout: {
|
||||
background: { color: COLORS.background },
|
||||
textColor: COLORS.text,
|
||||
fontSize: 11,
|
||||
},
|
||||
grid: {
|
||||
vertLines: { color: '#1a1f2e', style: 1 }, // 더 얇은 그리드
|
||||
horzLines: { color: '#1a1f2e', style: 1 },
|
||||
},
|
||||
crosshair: {
|
||||
mode: CrosshairMode.Normal,
|
||||
vertLine: {
|
||||
color: '#4a5568',
|
||||
width: 1,
|
||||
style: 2, // dashed
|
||||
},
|
||||
horzLine: {
|
||||
color: '#4a5568',
|
||||
width: 1,
|
||||
style: 2,
|
||||
},
|
||||
},
|
||||
timeScale: {
|
||||
borderColor: COLORS.border,
|
||||
timeVisible: true,
|
||||
secondsVisible: false,
|
||||
borderVisible: false, // 테두리 제거
|
||||
},
|
||||
rightPriceScale: {
|
||||
borderColor: COLORS.border,
|
||||
borderVisible: false, // 테두리 제거
|
||||
},
|
||||
height,
|
||||
});
|
||||
|
||||
// 차트 초기화 및 데이터 설정
|
||||
useEffect(() => {
|
||||
if (!processedData ||
|
||||
!mainChartRef.current ||
|
||||
!volumeChartRef.current ||
|
||||
!rsiChartRef.current ||
|
||||
!macdChartRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const containerWidth = mainChartRef.current.clientWidth;
|
||||
|
||||
try {
|
||||
// === 1. 메인 차트 (캔들스틱 + 지표) ===
|
||||
const mainChart = createChart(mainChartRef.current, {
|
||||
...getChartOptions(365),
|
||||
width: containerWidth,
|
||||
});
|
||||
|
||||
// 캔들스틱 시리즈 (한국 시장 색상)
|
||||
const candlestickSeries = (mainChart ).addCandlestickSeries({
|
||||
upColor: COLORS.candleUp,
|
||||
downColor: COLORS.candleDown,
|
||||
borderVisible: false,
|
||||
wickUpColor: COLORS.candleUp,
|
||||
wickDownColor: COLORS.candleDown,
|
||||
});
|
||||
candlestickSeries.setData(processedData.candles);
|
||||
|
||||
// 볼린저 밴드
|
||||
const bbUpperSeries = (mainChart ).addLineSeries({
|
||||
color: COLORS.bbLine,
|
||||
lineWidth: 1,
|
||||
});
|
||||
bbUpperSeries.setData(processedData.bollinger.upper);
|
||||
|
||||
const bbMiddleSeries = (mainChart ).addLineSeries({
|
||||
color: COLORS.ma20,
|
||||
lineWidth: 1,
|
||||
});
|
||||
bbMiddleSeries.setData(processedData.bollinger.middle);
|
||||
|
||||
const bbLowerSeries = (mainChart ).addLineSeries({
|
||||
color: COLORS.bbLine,
|
||||
lineWidth: 1,
|
||||
});
|
||||
bbLowerSeries.setData(processedData.bollinger.lower);
|
||||
|
||||
// 5일 이동평균선
|
||||
const sma5Series = (mainChart ).addLineSeries({
|
||||
color: COLORS.ma5,
|
||||
lineWidth: 1.5,
|
||||
});
|
||||
sma5Series.setData(processedData.sma5);
|
||||
|
||||
// 60일 이동평균선
|
||||
const sma60Series = (mainChart ).addLineSeries({
|
||||
color: COLORS.ma60,
|
||||
lineWidth: 1.5,
|
||||
});
|
||||
sma60Series.setData(processedData.sma60);
|
||||
|
||||
// === 2. 거래량 차트 ===
|
||||
const volumeChart = createChart(volumeChartRef.current, {
|
||||
...getChartOptions(67),
|
||||
width: containerWidth,
|
||||
});
|
||||
|
||||
const volumeSeries = (volumeChart ).addHistogramSeries({
|
||||
priceFormat: {
|
||||
type: 'volume',
|
||||
},
|
||||
});
|
||||
volumeSeries.setData(processedData.volumes);
|
||||
|
||||
// === 3. RSI 차트 ===
|
||||
const rsiChart = createChart(rsiChartRef.current, {
|
||||
...getChartOptions(100),
|
||||
width: containerWidth,
|
||||
});
|
||||
|
||||
// RSI 라인
|
||||
const rsiSeries = (rsiChart ).addLineSeries({
|
||||
color: COLORS.rsi,
|
||||
lineWidth: 2,
|
||||
});
|
||||
rsiSeries.setData(processedData.rsi.data);
|
||||
|
||||
// RSI 기준선 (30, 50, 70)
|
||||
const rsiLine30Series = (rsiChart ).addLineSeries({
|
||||
color: COLORS.border,
|
||||
lineWidth: 1,
|
||||
lineStyle: 2, // dashed
|
||||
crosshairMarkerVisible: false,
|
||||
});
|
||||
rsiLine30Series.setData(processedData.rsi.line30);
|
||||
|
||||
const rsiLine50Series = (rsiChart ).addLineSeries({
|
||||
color: COLORS.border,
|
||||
lineWidth: 1,
|
||||
lineStyle: 2,
|
||||
crosshairMarkerVisible: false,
|
||||
});
|
||||
rsiLine50Series.setData(processedData.rsi.line50);
|
||||
|
||||
const rsiLine70Series = (rsiChart ).addLineSeries({
|
||||
color: COLORS.border,
|
||||
lineWidth: 1,
|
||||
lineStyle: 2,
|
||||
crosshairMarkerVisible: false,
|
||||
});
|
||||
rsiLine70Series.setData(processedData.rsi.line70);
|
||||
|
||||
// RSI Y축 범위 고정 (0-100)
|
||||
(rsiChart ).priceScale('right').applyOptions({
|
||||
scaleMargins: {
|
||||
top: 0.1,
|
||||
bottom: 0.1,
|
||||
},
|
||||
});
|
||||
|
||||
// === 4. MACD 차트 ===
|
||||
const macdChart = createChart(macdChartRef.current, {
|
||||
...getChartOptions(100),
|
||||
width: containerWidth,
|
||||
});
|
||||
|
||||
// MACD 히스토그램
|
||||
const macdHistogramSeries = (macdChart ).addHistogramSeries();
|
||||
macdHistogramSeries.setData(processedData.macd.histogram);
|
||||
|
||||
// MACD 라인
|
||||
const macdLineSeries = (macdChart ).addLineSeries({
|
||||
color: COLORS.macd,
|
||||
lineWidth: 1.5,
|
||||
});
|
||||
macdLineSeries.setData(processedData.macd.line);
|
||||
|
||||
// Signal 라인
|
||||
const signalLineSeries = (macdChart ).addLineSeries({
|
||||
color: COLORS.signal,
|
||||
lineWidth: 1.5,
|
||||
});
|
||||
signalLineSeries.setData(processedData.macd.signal);
|
||||
|
||||
// === 시간축 동기화 ===
|
||||
// 초기 visible range 설정 (모든 차트가 동일한 범위를 보이도록)
|
||||
mainChart.timeScale().fitContent();
|
||||
|
||||
// 메인 차트의 visible range를 다른 차트들에 적용
|
||||
const initialRange = mainChart.timeScale().getVisibleRange();
|
||||
if (initialRange) {
|
||||
volumeChart.timeScale().setVisibleRange(initialRange);
|
||||
rsiChart.timeScale().setVisibleRange(initialRange);
|
||||
macdChart.timeScale().setVisibleRange(initialRange);
|
||||
}
|
||||
|
||||
// 실시간 동기화
|
||||
mainChart.timeScale().subscribeVisibleTimeRangeChange((timeRange) => {
|
||||
if (timeRange) {
|
||||
volumeChart.timeScale().setVisibleRange(timeRange);
|
||||
rsiChart.timeScale().setVisibleRange(timeRange);
|
||||
macdChart.timeScale().setVisibleRange(timeRange);
|
||||
}
|
||||
});
|
||||
|
||||
// === 모든 차트 설정 완료 후 refs에 저장 ===
|
||||
// (cleanup이 부분적으로 구성된 차트를 제거하지 않도록)
|
||||
mainChartInstanceRef.current = mainChart;
|
||||
volumeChartInstanceRef.current = volumeChart;
|
||||
rsiChartInstanceRef.current = rsiChart;
|
||||
macdChartInstanceRef.current = macdChart;
|
||||
candlestickSeriesRef.current = candlestickSeries;
|
||||
volumeSeriesRef.current = volumeSeries;
|
||||
|
||||
// === 반응형 처리 ===
|
||||
const handleResize = () => {
|
||||
const width = mainChartRef.current?.clientWidth || 0;
|
||||
mainChart.applyOptions({ width });
|
||||
volumeChart.applyOptions({ width });
|
||||
rsiChart.applyOptions({ width });
|
||||
macdChart.applyOptions({ width });
|
||||
};
|
||||
|
||||
window.addEventListener('resize', handleResize);
|
||||
|
||||
// Cleanup
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleResize);
|
||||
|
||||
// Safely remove chart instances
|
||||
try {
|
||||
if (mainChartInstanceRef.current) {
|
||||
mainChartInstanceRef.current.remove();
|
||||
mainChartInstanceRef.current = null;
|
||||
}
|
||||
} catch (e) {
|
||||
// Chart already disposed
|
||||
}
|
||||
|
||||
try {
|
||||
if (volumeChartInstanceRef.current) {
|
||||
volumeChartInstanceRef.current.remove();
|
||||
volumeChartInstanceRef.current = null;
|
||||
}
|
||||
} catch (e) {
|
||||
// Chart already disposed
|
||||
}
|
||||
|
||||
try {
|
||||
if (rsiChartInstanceRef.current) {
|
||||
rsiChartInstanceRef.current.remove();
|
||||
rsiChartInstanceRef.current = null;
|
||||
}
|
||||
} catch (e) {
|
||||
// Chart already disposed
|
||||
}
|
||||
|
||||
try {
|
||||
if (macdChartInstanceRef.current) {
|
||||
macdChartInstanceRef.current.remove();
|
||||
macdChartInstanceRef.current = null;
|
||||
}
|
||||
} catch (e) {
|
||||
// Chart already disposed
|
||||
}
|
||||
};
|
||||
} catch (error: any) {
|
||||
console.error('❌ 차트 렌더링 중 오류 발생:', error);
|
||||
console.error('오류 상세:', {
|
||||
message: error?.message || '알 수 없는 오류',
|
||||
stack: error?.stack,
|
||||
processedData: processedData,
|
||||
});
|
||||
}
|
||||
}, [processedData]);
|
||||
|
||||
return (
|
||||
<div className="w-full h-[665px] relative bg-[#0b0e14] rounded-lg overflow-hidden">
|
||||
{/* 메인 차트 (55%) - 더 크게 */}
|
||||
<div ref={mainChartRef} style={{ height: '365px' }} />
|
||||
|
||||
{/* 거래량 (10%) */}
|
||||
<div ref={volumeChartRef} style={{ height: '67px', marginTop: '2px' }} />
|
||||
|
||||
{/* RSI (15%) */}
|
||||
<div ref={rsiChartRef} style={{ height: '100px', marginTop: '2px' }} />
|
||||
|
||||
{/* MACD (15%) */}
|
||||
<div ref={macdChartRef} style={{ height: '100px', marginTop: '2px' }} />
|
||||
|
||||
{/* 간소화된 범례 - 배경 투명도 높임 */}
|
||||
<div className="absolute top-2 left-2 text-[10px]">
|
||||
<div className="flex gap-2 bg-gray-900/60 px-2 py-1 rounded backdrop-blur-sm">
|
||||
<span className="text-[#fbbf24]">MA5</span>
|
||||
<span className="text-[#f6ad55]">MA20</span>
|
||||
<span className="text-[#10b981]">MA60</span>
|
||||
<span className="text-gray-500">|</span>
|
||||
<span className="text-[#4a5568]">BB</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 한국 시장 표시 - 더 작게 */}
|
||||
<div className="absolute top-2 right-2 text-[10px]">
|
||||
<div className="flex gap-1.5 bg-gray-900/60 px-2 py-1 rounded backdrop-blur-sm">
|
||||
<span className="flex items-center gap-1">
|
||||
<div className="w-2 h-2 bg-[#ef4444] rounded-sm"></div>
|
||||
<span className="text-gray-400">상승</span>
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<div className="w-2 h-2 bg-[#3b82f6] rounded-sm"></div>
|
||||
<span className="text-gray-400">하락</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,5 +1,13 @@
|
||||
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'
|
||||
|
||||
// WebSocket URL: 현재 페이지 기준으로 자동 결정 (Docker/로컬 모두 호환)
|
||||
const deriveWsUrl = () => {
|
||||
const env = import.meta.env.VITE_WS_URL
|
||||
if (env) return env
|
||||
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
return `${proto}//${window.location.host}`
|
||||
}
|
||||
const WS_BASE_URL = deriveWsUrl()
|
||||
|
||||
export const config = {
|
||||
apiBaseUrl: API_BASE_URL,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { useEffect, useState, useCallback, useRef } from 'react'
|
||||
import { config } from '../config'
|
||||
|
||||
interface WebSocketMessage {
|
||||
@@ -10,40 +10,91 @@ export const useWebSocket = (clientId: string) => {
|
||||
const [socket, setSocket] = useState<WebSocket | null>(null)
|
||||
const [messages, setMessages] = useState<WebSocketMessage[]>([])
|
||||
const [connected, setConnected] = useState(false)
|
||||
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null)
|
||||
const pingIntervalRef = useRef<NodeJS.Timeout | null>(null)
|
||||
const reconnectAttemptsRef = useRef(0)
|
||||
const mountedRef = useRef(true)
|
||||
const baseDelay = 3000
|
||||
|
||||
useEffect(() => {
|
||||
const ws = new WebSocket(`${config.wsBaseUrl}/ws/${clientId}`)
|
||||
const connect = useCallback(() => {
|
||||
if (!mountedRef.current) return
|
||||
|
||||
ws.onopen = () => {
|
||||
console.log('WebSocket connected')
|
||||
setConnected(true)
|
||||
}
|
||||
try {
|
||||
const ws = new WebSocket(`${config.wsBaseUrl}/ws/${clientId}`)
|
||||
|
||||
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.onopen = () => {
|
||||
console.log('🔗 WebSocket connected')
|
||||
setConnected(true)
|
||||
reconnectAttemptsRef.current = 0
|
||||
|
||||
// 30초마다 ping 전송 (Nginx/프록시 타임아웃 방지)
|
||||
pingIntervalRef.current = setInterval(() => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: 'ping' }))
|
||||
}
|
||||
}, 30000)
|
||||
}
|
||||
}
|
||||
|
||||
ws.onerror = (error) => {
|
||||
console.error('WebSocket error:', error)
|
||||
}
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const message = JSON.parse(event.data) as WebSocketMessage
|
||||
if (message.type === 'pong') return
|
||||
setMessages((prev: WebSocketMessage[]) => [...prev, message])
|
||||
} catch (error) {
|
||||
console.error('Failed to parse WebSocket message:', error)
|
||||
}
|
||||
}
|
||||
|
||||
ws.onclose = () => {
|
||||
console.log('WebSocket disconnected')
|
||||
setConnected(false)
|
||||
}
|
||||
ws.onerror = () => {
|
||||
// onclose가 항상 뒤따르므로 여기서는 무시
|
||||
}
|
||||
|
||||
setSocket(ws)
|
||||
ws.onclose = () => {
|
||||
console.log('🔌 WebSocket disconnected')
|
||||
setConnected(false)
|
||||
setSocket(null)
|
||||
|
||||
return () => {
|
||||
ws.close()
|
||||
if (pingIntervalRef.current) {
|
||||
clearInterval(pingIntervalRef.current)
|
||||
pingIntervalRef.current = null
|
||||
}
|
||||
|
||||
// 지수 백오프 재연결 (최대 30초 간격, 무제한 재시도)
|
||||
if (mountedRef.current) {
|
||||
const attempt = reconnectAttemptsRef.current
|
||||
const delay = Math.min(baseDelay * Math.pow(1.5, attempt), 30000)
|
||||
reconnectAttemptsRef.current += 1
|
||||
console.log(`🔄 Reconnecting WebSocket in ${(delay / 1000).toFixed(1)}s (attempt ${attempt + 1})...`)
|
||||
reconnectTimeoutRef.current = setTimeout(() => {
|
||||
connect()
|
||||
}, delay)
|
||||
}
|
||||
}
|
||||
|
||||
setSocket(ws)
|
||||
} catch (error) {
|
||||
console.error('Failed to create WebSocket:', error)
|
||||
}
|
||||
}, [clientId])
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true
|
||||
connect()
|
||||
|
||||
return () => {
|
||||
mountedRef.current = false
|
||||
if (reconnectTimeoutRef.current) {
|
||||
clearTimeout(reconnectTimeoutRef.current)
|
||||
}
|
||||
if (pingIntervalRef.current) {
|
||||
clearInterval(pingIntervalRef.current)
|
||||
}
|
||||
if (socket) {
|
||||
socket.close()
|
||||
}
|
||||
}
|
||||
}, [connect])
|
||||
|
||||
const sendMessage = useCallback((message: string) => {
|
||||
if (socket && connected) {
|
||||
socket.send(message)
|
||||
|
||||
@@ -17,7 +17,12 @@ const queryClient = new QueryClient({
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
<BrowserRouter
|
||||
future={{
|
||||
v7_startTransition: true,
|
||||
v7_relativeSplatPath: true,
|
||||
}}
|
||||
>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
|
||||
@@ -1,660 +1,18 @@
|
||||
/* 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 {
|
||||
/* Custom Scrollbar for Dashboard containers */
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: #1a1a1a;
|
||||
.custom-scrollbar::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #333;
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: #374151; /* gray-700 */
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #444;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: #4b5563; /* gray-600 */
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -8,6 +8,7 @@ import api from '../lib/api'
|
||||
interface Trade {
|
||||
id: string
|
||||
created_at: string
|
||||
executed_at?: string
|
||||
status: 'EXECUTED' | 'FAILED' | 'PENDING' | string
|
||||
stock_code: string
|
||||
stock_name?: string
|
||||
@@ -16,7 +17,13 @@ interface Trade {
|
||||
order_quantity: number
|
||||
executed_price: number | null
|
||||
executed_quantity: number | null
|
||||
total_amount?: number | null
|
||||
message?: string | null
|
||||
entry_reason?: string | null
|
||||
exit_reason?: string | null
|
||||
signals?: string[] | null
|
||||
profit_loss?: number | null
|
||||
profit_loss_rate?: number | null
|
||||
}
|
||||
|
||||
const TradeHistory = () => {
|
||||
@@ -82,11 +89,11 @@ const TradeHistory = () => {
|
||||
<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-right py-3 px-4 text-slate-400">체결수량</th>
|
||||
<th className="text-left 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>
|
||||
@@ -130,19 +137,55 @@ const TradeHistory = () => {
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3 px-4 text-right text-white">
|
||||
{formatCurrency(trade.order_price)}
|
||||
{formatCurrency(trade.executed_price || trade.order_price)}
|
||||
</td>
|
||||
<td className="py-3 px-4 text-right text-white">
|
||||
{trade.order_quantity.toLocaleString()}
|
||||
{(trade.executed_quantity || trade.order_quantity).toLocaleString()}주
|
||||
</td>
|
||||
<td className="py-3 px-4 text-right text-white">
|
||||
{trade.executed_price != null ? formatCurrency(trade.executed_price) : '-'}
|
||||
<td className="py-3 px-4 text-right text-white font-medium">
|
||||
{trade.total_amount != null ? formatCurrency(trade.total_amount) : '-'}
|
||||
</td>
|
||||
<td className="py-3 px-4 text-right text-white">
|
||||
{trade.executed_quantity != null ? trade.executed_quantity.toLocaleString() : '-'}
|
||||
<td className="py-3 px-4 text-right">
|
||||
{trade.order_type === 'SELL' && trade.profit_loss != null ? (
|
||||
<div className="space-y-0.5">
|
||||
<div className={`font-medium ${Number(trade.profit_loss) >= 0 ? 'text-primary' : 'text-danger'
|
||||
}`}>
|
||||
{Number(trade.profit_loss) >= 0 ? '+' : ''}{formatCurrency(Number(trade.profit_loss))}
|
||||
</div>
|
||||
{trade.profit_loss_rate != null && (
|
||||
<div className={`text-xs ${Number(trade.profit_loss_rate) >= 0 ? 'text-primary' : 'text-danger'
|
||||
}`}>
|
||||
({Number(trade.profit_loss_rate) >= 0 ? '+' : ''}{Number(trade.profit_loss_rate).toFixed(2)}%)
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-slate-500">-</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-3 px-4 text-slate-400 text-sm">
|
||||
{trade.message || '-'}
|
||||
<td className="py-3 px-4 text-slate-400 text-sm max-w-md">
|
||||
{trade.order_type === 'BUY' && trade.entry_reason && (
|
||||
<div className="space-y-1">
|
||||
<div className="text-primary text-xs font-medium">📈 진입 이유</div>
|
||||
<div>{trade.entry_reason}</div>
|
||||
{trade.signals && trade.signals.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-1">
|
||||
{trade.signals.slice(0, 3).map((signal, idx) => (
|
||||
<span key={idx} className="text-xs bg-slate-700 px-2 py-0.5 rounded">
|
||||
{signal}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{trade.order_type === 'SELL' && trade.exit_reason && (
|
||||
<div className="space-y-1">
|
||||
<div className="text-danger text-xs font-medium">📉 매도 이유</div>
|
||||
<div>{trade.exit_reason}</div>
|
||||
</div>
|
||||
)}
|
||||
{!trade.entry_reason && !trade.exit_reason && (trade.message || '-')}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
|
||||
@@ -11,9 +11,10 @@ const TradingRules = () => {
|
||||
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}'
|
||||
risk_level: 'MEDIUM',
|
||||
stop_loss: 3.0,
|
||||
take_profit: 5.0,
|
||||
max_quantity: 1
|
||||
})
|
||||
|
||||
// 트레이딩 규칙 조회
|
||||
@@ -28,36 +29,27 @@ const TradingRules = () => {
|
||||
// 규칙 생성
|
||||
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();
|
||||
// AI 자동 매매를 위한 기본 템플릿 구성
|
||||
const finalConditions = {
|
||||
type: "AI_AUTO_OPTIMIZATION",
|
||||
risk_level: data.risk_level,
|
||||
description: "AI가 시장 상황에 따라 진입 조건을 동적으로 결정합니다."
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
const finalActions = {
|
||||
stop_loss: parseFloat(data.stop_loss),
|
||||
take_profit: parseFloat(data.take_profit),
|
||||
max_quantity: parseInt(data.max_quantity),
|
||||
trailing_stop: true
|
||||
};
|
||||
|
||||
return api.post('/rules', {
|
||||
...data,
|
||||
conditions: JSON.parse(data.conditions),
|
||||
actions: JSON.parse(data.actions)
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
rule_type: data.rule_type,
|
||||
priority: 1,
|
||||
conditions: finalConditions,
|
||||
actions: finalActions
|
||||
})
|
||||
},
|
||||
onSuccess: () => {
|
||||
@@ -67,16 +59,15 @@ const TradingRules = () => {
|
||||
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}'
|
||||
risk_level: 'MEDIUM',
|
||||
stop_loss: 3.0,
|
||||
take_profit: 5.0,
|
||||
max_quantity: 1
|
||||
})
|
||||
alert('✅ 규칙이 성공적으로 추가되었습니다!')
|
||||
alert('✅ AI 자동매매 규칙이 설정되었습니다!')
|
||||
},
|
||||
onError: (error: any) => {
|
||||
if (error.message !== '사용자가 취소했습니다.') {
|
||||
alert(`❌ 규칙 추가 실패: ${error.response?.data?.detail || error.message}`)
|
||||
}
|
||||
alert(`❌ 규칙 추가 실패: ${error.response?.data?.detail || error.message}`)
|
||||
},
|
||||
})
|
||||
|
||||
@@ -158,35 +149,50 @@ const TradingRules = () => {
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-300 mb-1">우선순위</label>
|
||||
<label className="block text-sm font-medium text-slate-300 mb-1">리스크 레벨</label>
|
||||
<select
|
||||
value={formData.risk_level}
|
||||
onChange={(e) => setFormData({ ...formData, risk_level: e.target.value })}
|
||||
className="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded text-white"
|
||||
>
|
||||
<option value="LOW">LOW (보수적)</option>
|
||||
<option value="MEDIUM">MEDIUM (중립)</option>
|
||||
<option value="HIGH">HIGH (공격적)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<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) })}
|
||||
value={formData.stop_loss}
|
||||
onChange={(e) => setFormData({ ...formData, stop_loss: parseFloat(e.target.value) })}
|
||||
className="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded text-white"
|
||||
min="1"
|
||||
step="0.1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-300 mb-1">기본 익절가 (%)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.take_profit}
|
||||
onChange={(e) => setFormData({ ...formData, take_profit: parseFloat(e.target.value) })}
|
||||
className="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded text-white"
|
||||
step="0.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
|
||||
<label className="block text-sm font-medium text-slate-300 mb-1">최대 매수 수량</label>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.max_quantity}
|
||||
onChange={(e) => setFormData({ ...formData, max_quantity: parseInt(e.target.value) })}
|
||||
className="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded text-white"
|
||||
min="1"
|
||||
/>
|
||||
<p className="text-xs text-slate-500 mt-1">* 실제 진입 타이밍은 AI가 시장 상황에 따라 최적화합니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 flex justify-end space-x-2">
|
||||
@@ -202,7 +208,7 @@ const TradingRules = () => {
|
||||
className="btn-primary text-sm"
|
||||
disabled={createMutation.isPending}
|
||||
>
|
||||
{createMutation.isPending ? '검증 중...' : '🤖 AI 검증 후 추가'}
|
||||
{createMutation.isPending ? '처리 중...' : '자동매매 규칙 등록'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
116
frontend/src/utils/technicalIndicators.ts
Normal file
116
frontend/src/utils/technicalIndicators.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
// 기술적 지표 계산 유틸리티
|
||||
// 기존 TradingChart.tsx에서 검증된 로직을 재사용
|
||||
|
||||
/**
|
||||
* 단순이동평균 (Simple Moving Average)
|
||||
* @param data 가격 데이터 배열
|
||||
* @param period 이동평균 기간
|
||||
* @returns SMA 값 배열
|
||||
*/
|
||||
export const calculateSMA = (data: number[], period: number): number[] => {
|
||||
const result: number[] = [];
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
if (i < period - 1) {
|
||||
result.push(NaN);
|
||||
} else {
|
||||
const sum = data.slice(i - period + 1, i + 1).reduce((a, b) => a + b, 0);
|
||||
result.push(sum / period);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* 지수이동평균 (Exponential Moving Average)
|
||||
* @param data 가격 데이터 배열
|
||||
* @param period EMA 기간
|
||||
* @returns EMA 값 배열
|
||||
*/
|
||||
export const calculateEMA = (data: number[], period: number): number[] => {
|
||||
const k = 2 / (period + 1);
|
||||
const ema: number[] = [];
|
||||
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
if (i === 0) {
|
||||
ema.push(data[i]);
|
||||
} else {
|
||||
ema.push(data[i] * k + ema[i - 1] * (1 - k));
|
||||
}
|
||||
}
|
||||
|
||||
return ema;
|
||||
};
|
||||
|
||||
/**
|
||||
* 볼린저 밴드 (Bollinger Bands)
|
||||
* @param closes 종가 배열
|
||||
* @param period 기간 (기본값: 20)
|
||||
* @returns 상단, 중간(SMA), 하단 밴드 배열
|
||||
*/
|
||||
export const calculateBollingerBands = (closes: number[], period: number = 20) => {
|
||||
const sma = calculateSMA(closes, period);
|
||||
const upper: number[] = [];
|
||||
const lower: number[] = [];
|
||||
|
||||
for (let i = 0; i < closes.length; i++) {
|
||||
if (i < period - 1) {
|
||||
upper.push(NaN);
|
||||
lower.push(NaN);
|
||||
} else {
|
||||
const slice = closes.slice(i - period + 1, i + 1);
|
||||
const mean = sma[i];
|
||||
const variance = slice.reduce((sum, val) => sum + Math.pow(val - mean, 2), 0) / period;
|
||||
const stdDev = Math.sqrt(variance);
|
||||
upper.push(mean + 2 * stdDev);
|
||||
lower.push(mean - 2 * stdDev);
|
||||
}
|
||||
}
|
||||
|
||||
return { upper, middle: sma, lower };
|
||||
};
|
||||
|
||||
/**
|
||||
* RSI (Relative Strength Index)
|
||||
* @param closes 종가 배열
|
||||
* @param period RSI 기간 (기본값: 14)
|
||||
* @returns RSI 값 배열
|
||||
*/
|
||||
export const calculateRSI = (closes: number[], period: number = 14): number[] => {
|
||||
const rsi: number[] = [];
|
||||
const gains: number[] = [];
|
||||
const losses: number[] = [];
|
||||
|
||||
for (let i = 1; i < closes.length; i++) {
|
||||
const change = closes[i] - closes[i - 1];
|
||||
gains.push(change > 0 ? change : 0);
|
||||
losses.push(change < 0 ? -change : 0);
|
||||
}
|
||||
|
||||
for (let i = 0; i < closes.length; i++) {
|
||||
if (i < period) {
|
||||
rsi.push(NaN);
|
||||
} else {
|
||||
const avgGain = gains.slice(i - period, i).reduce((a, b) => a + b, 0) / period;
|
||||
const avgLoss = losses.slice(i - period, i).reduce((a, b) => a + b, 0) / period;
|
||||
const rs = avgLoss === 0 ? 100 : avgGain / avgLoss;
|
||||
rsi.push(100 - (100 / (1 + rs)));
|
||||
}
|
||||
}
|
||||
|
||||
return rsi;
|
||||
};
|
||||
|
||||
/**
|
||||
* MACD (Moving Average Convergence Divergence)
|
||||
* @param closes 종가 배열
|
||||
* @returns MACD, Signal, Histogram 배열
|
||||
*/
|
||||
export const calculateMACD = (closes: number[]) => {
|
||||
const ema12 = calculateEMA(closes, 12);
|
||||
const ema26 = calculateEMA(closes, 26);
|
||||
const macd = ema12.map((val, i) => val - ema26[i]);
|
||||
const signal = calculateEMA(macd, 9);
|
||||
const histogram = macd.map((val, i) => val - signal[i]);
|
||||
|
||||
return { macd, signal, histogram };
|
||||
};
|
||||
Reference in New Issue
Block a user