수정본

This commit is contained in:
2026-02-22 17:23:20 +09:00
parent b147498c31
commit 32e2263f0a
109 changed files with 13979 additions and 4580 deletions

View File

@@ -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

View File

@@ -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>
)
}

View 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>
);
};

View 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>
);
};