fix: CRITICAL/HIGH 버그 30건 수정 + 전체 코드 주석 추가 + 중복 정리
## CRITICAL 수정 (20건) - day_trading: global 선언 누락, loss_tracker 연동, ban 체크 추가 - trade_executor: BUY 체결가 order_price로 수정, remaining_pct 누적 계산 - risk_validator: get_current_price dict 파싱, MAX_POSITION_SIZE 동기화, DB 세션 누수 - risk_management/recommendations: date.today() → KST 변환 - mdd_manager: Lock 밖 executed_key 참조 수정 - correlation_filter: output → output2 키 수정 - portfolio: 해외 종목 USD/KRW 환율 적용 분리 - Sharpe Ratio 거래빈도 기반 연간화 - telegram_notifier: send_message() 공개 래퍼 추가 - position_manager: 앱 재시작 시 DB 포지션 복원 - swing exit_service: remaining_pct 조건 범위 수정 - run_backtest: original_qty 기반 remaining_pct 계산 - us_market_schedule: 2027년 미국 공휴일 추가 - Login.tsx: res.ok 검증 추가 - mean_reversion_strategy: RSI 로그 하드코딩 수정 ## HIGH 수정 (10건) - db_worker: DLQ 파일 백업 메커니즘 추가 - korea_investment_api: 만료 토큰 임의 연장 제거 - market_data: chart_cache 최대 100개 + TTL 만료 - main.py: rate_limit_store 1시간 미접근 IP 정리 - recommendations: fire-and-forget try/except 추가 - swing/scheduler: DB 세션 3개 분리 - Dashboard: wsMessages 전체 순회 - Layout: /health 엔드포인트 + 30초 폴링 - ml_ensemble: POST /train require_api_key 추가 - day_trading: record_trade_result_safe() 전환 ## 코드 정리 - 프론트엔드: isUsStock, formatTime 공통 유틸 추출 - 백엔드: dead code 제거, 중복 import 통합 - MCP: JSON 파싱 헬퍼 추출 (~60줄 감소) - 전체 ~100개 파일 한국어 주석 추가 - 테스트 13건 코드 기준 수정 (468 passed) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
5
frontend/.dockerignore
Normal file
5
frontend/.dockerignore
Normal file
@@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
dist
|
||||
.env
|
||||
.env.local
|
||||
*.log
|
||||
@@ -4,10 +4,24 @@ server {
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# ── 보안 헤더 ────────────────────────────────────────────────────────────
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdnjs.cloudflare.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; connect-src 'self' ws: wss:; font-src 'self';" always;
|
||||
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
|
||||
|
||||
# Gzip 압축 설정
|
||||
gzip on;
|
||||
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
|
||||
|
||||
# index.html — 항상 최신 버전 제공 (Vite 해시 파일은 별도 캐시)
|
||||
location = /index.html {
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||
add_header Pragma "no-cache";
|
||||
}
|
||||
|
||||
# SPA 라우팅 지원
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
@@ -30,6 +44,8 @@ server {
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "Upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_read_timeout 86400s; # 24시간 (장중 끊김 방지)
|
||||
proxy_send_timeout 86400s;
|
||||
}
|
||||
|
||||
# 캐시 설정
|
||||
|
||||
30
frontend/package-lock.json
generated
30
frontend/package-lock.json
generated
@@ -10,11 +10,13 @@
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.12.0",
|
||||
"axios": "^1.6.2",
|
||||
"chart.js": "^4.5.1",
|
||||
"clsx": "^2.0.0",
|
||||
"date-fns": "^2.30.0",
|
||||
"lightweight-charts": "^4.2.0",
|
||||
"lucide-react": "^0.294.0",
|
||||
"react": "^18.2.0",
|
||||
"react-chartjs-2": "^5.3.1",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^6.20.0",
|
||||
"recharts": "^2.10.3",
|
||||
@@ -1002,6 +1004,12 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@kurkle/color": {
|
||||
"version": "0.3.4",
|
||||
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
|
||||
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@nodelib/fs.scandir": {
|
||||
"version": "2.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
||||
@@ -2148,6 +2156,18 @@
|
||||
"url": "https://github.com/chalk/chalk?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/chart.js": {
|
||||
"version": "4.5.1",
|
||||
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
|
||||
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@kurkle/color": "^0.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"pnpm": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/chokidar": {
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
|
||||
@@ -4163,6 +4183,16 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-chartjs-2": {
|
||||
"version": "5.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react-chartjs-2/-/react-chartjs-2-5.3.1.tgz",
|
||||
"integrity": "sha512-h5IPXKg9EXpjoBzUfyWJvllMjG2mQ4EiuHQFhms/AjUm0XSZHhyRy2xVmLXHKrtcdrPO4mnGqRtYoD0vp95A0A==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"chart.js": "^4.1.1",
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-dom": {
|
||||
"version": "18.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
||||
|
||||
@@ -12,11 +12,13 @@
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.12.0",
|
||||
"axios": "^1.6.2",
|
||||
"chart.js": "^4.5.1",
|
||||
"clsx": "^2.0.0",
|
||||
"date-fns": "^2.30.0",
|
||||
"lightweight-charts": "^4.2.0",
|
||||
"lucide-react": "^0.294.0",
|
||||
"react": "^18.2.0",
|
||||
"react-chartjs-2": "^5.3.1",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^6.20.0",
|
||||
"recharts": "^2.10.3",
|
||||
@@ -38,4 +40,4 @@
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.2.2"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,23 +1,121 @@
|
||||
/**
|
||||
* App.tsx — 애플리케이션 루트 컴포넌트
|
||||
*
|
||||
* 역할:
|
||||
* - React Router 기반 페이지 라우팅 관리
|
||||
* - API 키 기반 인증 상태 관리 (localStorage 저장)
|
||||
* - ErrorBoundary를 통한 전역 에러 핸들링
|
||||
* - React.lazy + Suspense를 이용한 코드 스플리팅 (대시보드 제외)
|
||||
*
|
||||
* 라우트 구조:
|
||||
* / — 대시보드 (즉시 로드)
|
||||
* /trades — 거래 내역 (lazy)
|
||||
* /mcp — MCP 상태 (lazy)
|
||||
* /strategy — 전략 설정 (lazy)
|
||||
* /backtest — 백테스트 (lazy)
|
||||
* /analysis — 성과 분석 (lazy)
|
||||
*/
|
||||
import React, { Suspense, Component, ErrorInfo, ReactNode, useState, useCallback } from 'react'
|
||||
import { Routes, Route } from 'react-router-dom'
|
||||
import Dashboard from './pages/Dashboard'
|
||||
import TradeHistory from './pages/TradeHistory'
|
||||
import MCPStatus from './pages/MCPStatus'
|
||||
import StrategySettings from './pages/StrategySettings'
|
||||
import Backtest from './pages/Backtest'
|
||||
import Analysis from './pages/Analysis'
|
||||
import Layout from './components/Layout'
|
||||
import Login from './pages/Login'
|
||||
|
||||
// 코드 스플리팅: 대시보드 외 페이지는 React.lazy로 지연 로드하여 초기 번들 크기 절감
|
||||
const TradeHistory = React.lazy(() => import('./pages/TradeHistory'))
|
||||
const MCPStatus = React.lazy(() => import('./pages/MCPStatus'))
|
||||
const StrategySettings = React.lazy(() => import('./pages/StrategySettings'))
|
||||
const Backtest = React.lazy(() => import('./pages/Backtest'))
|
||||
const Analysis = React.lazy(() => import('./pages/Analysis'))
|
||||
|
||||
/** ErrorBoundary 상태 인터페이스 */
|
||||
interface ErrorBoundaryState {
|
||||
hasError: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* ErrorBoundary — 전역 에러 바운더리
|
||||
*
|
||||
* 하위 컴포넌트에서 발생한 렌더링 에러를 포착하여
|
||||
* 사용자 친화적인 오류 메시지와 재시도 버튼을 표시합니다.
|
||||
* getDerivedStateFromError로 에러 상태를 설정하고,
|
||||
* componentDidCatch로 에러 로깅을 수행합니다.
|
||||
*/
|
||||
class ErrorBoundary extends Component<{ children: ReactNode }, ErrorBoundaryState> {
|
||||
constructor(props: { children: ReactNode }) {
|
||||
super(props)
|
||||
this.state = { hasError: false, error: null }
|
||||
}
|
||||
|
||||
/** 렌더링 에러 발생 시 상태 업데이트 — 에러 UI 전환 트리거 */
|
||||
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
|
||||
return { hasError: true, error }
|
||||
}
|
||||
|
||||
/** 에러 로깅 — 디버깅을 위해 에러와 컴포넌트 스택 트레이스 출력 */
|
||||
componentDidCatch(error: Error, info: ErrorInfo) {
|
||||
console.error('ErrorBoundary caught:', error, info.componentStack)
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full gap-3 text-gray-400">
|
||||
<p className="text-sm">페이지를 불러오는 중 오류가 발생했습니다.</p>
|
||||
<p className="text-xs text-gray-600">{this.state.error?.message}</p>
|
||||
{/* 재시도: 에러 상태 초기화 → 자식 컴포넌트 재렌더링 시도 */}
|
||||
<button
|
||||
className="px-3 py-1.5 text-xs border border-gray-700 rounded hover:border-gray-500 transition-colors"
|
||||
onClick={() => this.setState({ hasError: false, error: null })}
|
||||
>
|
||||
다시 시도
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return this.props.children
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* App — 애플리케이션 루트 컴포넌트
|
||||
*
|
||||
* 인증 흐름:
|
||||
* 1. localStorage의 'api_key' 존재 여부로 인증 상태 판단
|
||||
* 2. 미인증 시 Login 페이지 렌더링
|
||||
* 3. 인증 완료 시 Layout + 라우팅 구조 렌더링
|
||||
*/
|
||||
function App() {
|
||||
// localStorage에서 api_key 존재 여부로 초기 인증 상태 결정 (lazy initializer)
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(() => !!localStorage.getItem('api_key'))
|
||||
|
||||
// 로그인 성공 콜백 — API 키를 localStorage에 저장하고 인증 상태 전환
|
||||
const handleLogin = useCallback((apiKey: string) => {
|
||||
localStorage.setItem('api_key', apiKey)
|
||||
setIsAuthenticated(true)
|
||||
}, [])
|
||||
|
||||
// 미인증 상태: 로그인 화면만 렌더링
|
||||
if (!isAuthenticated) {
|
||||
return <Login onLogin={handleLogin} />
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<Routes>
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route path="/trades" element={<TradeHistory />} />
|
||||
<Route path="/mcp" element={<MCPStatus />} />
|
||||
<Route path="/strategy" element={<StrategySettings />} />
|
||||
<Route path="/backtest" element={<Backtest />} />
|
||||
<Route path="/analysis" element={<Analysis />} />
|
||||
</Routes>
|
||||
<ErrorBoundary>
|
||||
{/* Suspense: lazy 로딩 중 폴백 UI 표시 */}
|
||||
<Suspense fallback={<div className="flex items-center justify-center h-full text-gray-400">Loading...</div>}>
|
||||
<Routes>
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route path="/trades" element={<TradeHistory />} />
|
||||
<Route path="/mcp" element={<MCPStatus />} />
|
||||
<Route path="/strategy" element={<StrategySettings />} />
|
||||
<Route path="/backtest" element={<Backtest />} />
|
||||
<Route path="/analysis" element={<Analysis />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,22 +1,45 @@
|
||||
/**
|
||||
* ChartPanel.tsx — 차트 패널 및 AI 분석 패널 컴포넌트
|
||||
*
|
||||
* 역할:
|
||||
* - ChartPanel: 종목 캔들 차트 표시 (TradingChart 래퍼), 일/주/월봉 기간 전환
|
||||
* - AiAnalysisPanel: 선택 종목의 AI 투자 분석 텍스트 표시
|
||||
*
|
||||
* 최적화:
|
||||
* - React.memo + 커스텀 비교 함수로 livePrice 변경 시 리렌더 방지
|
||||
* (livePrice는 TradingChart 내부에서 ref 기반으로 처리)
|
||||
* - 차트 로딩 중에도 기존 차트를 유지하여 깜빡임 방지
|
||||
*/
|
||||
import React from 'react'
|
||||
import { Brain, RefreshCw } from 'lucide-react'
|
||||
import { TradingChart } from './TradingChart'
|
||||
import { formatTime } from '../utils/formatUtils'
|
||||
|
||||
const formatTime = (isoStr?: string) => {
|
||||
if (!isoStr) return null
|
||||
const d = new Date(isoStr)
|
||||
return d.toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false, timeZone: 'Asia/Seoul' })
|
||||
}
|
||||
|
||||
/** ChartPanel Props 정의 */
|
||||
interface ChartPanelProps {
|
||||
/** 캔들 차트 데이터 배열 (null이면 데이터 없음 표시) */
|
||||
chartData: any[] | null | undefined
|
||||
/** 차트 로딩 상태 (오버레이 스피너 표시 여부) */
|
||||
chartLoading: boolean
|
||||
/** 현재 선택된 종목 코드 */
|
||||
selectedStock: string
|
||||
/** 차트 기간 (D: 일봉, W: 주봉, M: 월봉) */
|
||||
chartPeriod: 'D' | 'W' | 'M'
|
||||
/** 기간 변경 콜백 */
|
||||
onPeriodChange: (period: 'D' | 'W' | 'M') => void
|
||||
/** 차트 헤더에 표시할 종목명 */
|
||||
stockName: string
|
||||
/** 실시간 현재가 (TradingChart에서 ref 기반 처리) */
|
||||
livePrice?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* ChartPanel — 종목 캔들 차트 패널
|
||||
*
|
||||
* React.memo 커스텀 비교 함수:
|
||||
* - livePrice 변경은 무시 (TradingChart가 ref 기반 500ms polling으로 직접 처리)
|
||||
* - chartData, chartLoading, selectedStock, chartPeriod 등 변경 시에만 리렌더
|
||||
*/
|
||||
const ChartPanel = React.memo(({
|
||||
chartData,
|
||||
chartLoading,
|
||||
@@ -24,29 +47,34 @@ const ChartPanel = React.memo(({
|
||||
chartPeriod,
|
||||
onPeriodChange,
|
||||
stockName,
|
||||
livePrice,
|
||||
}: ChartPanelProps) => {
|
||||
return (
|
||||
<div className="bg-gray-800/50 backdrop-blur-sm border border-gray-700/50 rounded-xl p-1 shadow-lg h-[45%] flex flex-col min-h-0">
|
||||
<div className="px-5 py-3 flex justify-between items-center border-b border-gray-700/30">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold flex items-center gap-3 text-white">
|
||||
{stockName || '종목을 선택해주세요'}
|
||||
{selectedStock && (
|
||||
<span className="text-xs font-normal text-gray-500 font-mono bg-gray-800 px-2 py-0.5 rounded">
|
||||
<div className="bg-[#0c1522] border border-[#152035] rounded flex flex-col h-[45%] min-h-0">
|
||||
{/* Header - Bloomberg ticker style */}
|
||||
<div className="px-3 py-2 flex justify-between items-center border-b border-[#152035] bg-[#091018] flex-shrink-0">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
{selectedStock ? (
|
||||
<>
|
||||
<span className="text-base font-bold text-[#c5d5e8] truncate">{stockName}</span>
|
||||
<span className="text-[10px] font-mono text-[#3a5570] bg-[#152035] px-1.5 py-px rounded-sm flex-shrink-0">
|
||||
{selectedStock}
|
||||
</span>
|
||||
)}
|
||||
</h2>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-sm text-[#3a5570]">종목을 선택하세요</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex bg-gray-900/50 rounded-lg p-1 border border-gray-700/50 scale-90">
|
||||
<div className="flex items-center bg-[#0a0f1c] border border-[#152035] rounded-sm overflow-hidden flex-shrink-0">
|
||||
{(['D', 'W', 'M'] as const).map((p) => (
|
||||
<button
|
||||
key={p}
|
||||
onClick={() => onPeriodChange(p)}
|
||||
className={`px-3 py-1 text-xs font-medium rounded-md transition-all ${chartPeriod === p
|
||||
? 'bg-blue-600 text-white shadow-lg shadow-blue-900/20'
|
||||
: 'text-gray-400 hover:text-white hover:bg-gray-800'
|
||||
}`}
|
||||
className={`px-3 py-1 text-[10px] font-mono transition-colors ${
|
||||
chartPeriod === p
|
||||
? 'bg-[#1a3050] text-[#5b9aff]'
|
||||
: 'text-[#4a6580] hover:text-[#8faabf]'
|
||||
}`}
|
||||
>
|
||||
{p === 'D' ? '일봉' : p === 'W' ? '주봉' : '월봉'}
|
||||
</button>
|
||||
@@ -54,55 +82,75 @@ const ChartPanel = React.memo(({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 relative bg-[#0b0e14] rounded-b-lg overflow-hidden">
|
||||
{chartLoading ? (
|
||||
<div className="absolute inset-0 flex items-center justify-center text-gray-500 bg-[#0b0e14]">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<RefreshCw className="animate-spin w-6 h-6 text-blue-500" />
|
||||
<span className="text-sm">차트 로딩중...</span>
|
||||
</div>
|
||||
{/* Chart area — 로딩 중에도 기존 차트 유지 (깜빡임 방지) */}
|
||||
<div className="flex-1 relative bg-[#0b0e14] rounded-b overflow-hidden">
|
||||
{chartData && chartData.length > 0 ? (
|
||||
<TradingChart data={chartData} livePrice={livePrice} />
|
||||
) : !chartLoading ? (
|
||||
<div className="absolute inset-0 flex items-center justify-center text-[#3a5570] text-xs font-mono">
|
||||
데이터 없음
|
||||
</div>
|
||||
) : chartData && chartData.length > 0 ? (
|
||||
<TradingChart data={chartData} />
|
||||
) : (
|
||||
<div className="absolute inset-0 flex items-center justify-center text-gray-600 text-sm">
|
||||
데이터가 없습니다.
|
||||
) : null}
|
||||
{chartLoading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-[#0b0e14]/70 z-10">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<RefreshCw className="animate-spin w-5 h-5 text-[#2b65d9]" />
|
||||
<span className="text-xs text-[#3a5570] font-mono">차트 로딩중...</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}, (prev, next) => {
|
||||
// livePrice 변경은 TradingChart 내부에서 ref 기반 처리 — ChartPanel 리렌더 불필요
|
||||
if (prev.chartData !== next.chartData) return false
|
||||
if (prev.chartLoading !== next.chartLoading) return false
|
||||
if (prev.selectedStock !== next.selectedStock) return false
|
||||
if (prev.chartPeriod !== next.chartPeriod) return false
|
||||
if (prev.stockName !== next.stockName) return false
|
||||
if (prev.onPeriodChange !== next.onPeriodChange) return false
|
||||
// livePrice 변경은 무시 (TradingChart가 ref로 직접 처리)
|
||||
return true
|
||||
})
|
||||
|
||||
ChartPanel.displayName = 'ChartPanel'
|
||||
|
||||
// AI 분석 패널 — ChartPanel과 같은 파일에 배치 (center column 분리용)
|
||||
/**
|
||||
* AiAnalysisPanel — AI 투자 분석 결과 표시 패널
|
||||
*
|
||||
* 선택된 종목의 기술적 지표, 추천 방향, AI 분석 근거를 텍스트로 표시합니다.
|
||||
* 종목 미선택 시 안내 메시지를 표시합니다.
|
||||
*/
|
||||
interface AiAnalysisPanelProps {
|
||||
/** AI 분석 데이터 (analysis: 포맷된 텍스트, data: 원시 데이터) */
|
||||
aiAnalysisData: { analysis: string; data: any } | null | undefined
|
||||
}
|
||||
|
||||
export const AiAnalysisPanel = React.memo(({ aiAnalysisData }: AiAnalysisPanelProps) => {
|
||||
const aiAnalysisText = aiAnalysisData?.analysis || null
|
||||
return (
|
||||
<div className="bg-gray-800/50 backdrop-blur-sm border border-gray-700/50 rounded-xl p-4 shadow-lg flex flex-col">
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<h3 className="font-bold flex items-center gap-2 text-purple-400 text-sm">
|
||||
<Brain size={16} />
|
||||
<div className="bg-[#0c1522] border border-[#152035] rounded flex flex-col overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="px-3 py-2 border-b border-[#152035] bg-[#091018] flex justify-between items-center flex-shrink-0">
|
||||
<h3 className="flex items-center gap-1.5 text-[#9f7aea] text-xs font-semibold uppercase tracking-wider">
|
||||
<Brain size={12} />
|
||||
AI 투자 분석
|
||||
</h3>
|
||||
{aiAnalysisData?.data?.analyzed_at && (
|
||||
<span className="text-[10px] text-gray-500 font-mono">
|
||||
<span className="text-[9px] text-[#3a5570] font-mono">
|
||||
{formatTime(aiAnalysisData.data.analyzed_at)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="bg-gray-900/50 rounded-lg p-3 text-gray-300 text-sm leading-relaxed overflow-y-auto custom-scrollbar flex-1 border border-gray-700/30 whitespace-pre-wrap">
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto custom-scrollbar p-3">
|
||||
{aiAnalysisText ? (
|
||||
typeof aiAnalysisText === 'string'
|
||||
? aiAnalysisText
|
||||
: JSON.stringify(aiAnalysisText, null, 2)
|
||||
<pre className="text-[11px] text-[#8faabf] leading-relaxed whitespace-pre-wrap font-mono">
|
||||
{typeof aiAnalysisText === 'string' ? aiAnalysisText : JSON.stringify(aiAnalysisText, null, 2)}
|
||||
</pre>
|
||||
) : (
|
||||
<div className="h-full flex items-center justify-center text-gray-600 text-center text-xs">
|
||||
<div className="h-full flex items-center justify-center text-[#3a5570] text-xs text-center">
|
||||
<p>종목을 선택하면<br />AI 투자 분석을 표시합니다.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,8 +1,21 @@
|
||||
/**
|
||||
* @deprecated 이 컴포넌트는 더 이상 사용되지 않습니다.
|
||||
* Layout.tsx가 메인 헤더 역할을 담당하며, App.tsx에서도 import하지 않습니다.
|
||||
* 삭제 여부는 팀 확인 후 결정하세요.
|
||||
*
|
||||
* HtsHeader.tsx — HTS 스타일 상단 헤더 컴포넌트 (레거시)
|
||||
*
|
||||
* 역할:
|
||||
* - 네비게이션 메뉴, MCP 연결 상태, 현재 시각 표시
|
||||
* - 리스크 체크 버튼을 통한 MCP 포트폴리오 리스크 평가 요청
|
||||
*/
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link, useLocation } from 'react-router-dom'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import api from '../lib/api'
|
||||
import './HtsHeader.css'
|
||||
|
||||
/** 네비게이션 메뉴 항목 정의 */
|
||||
const navItems = [
|
||||
{ path: '/', label: '대시보드' },
|
||||
{ path: '/rules', label: '트레이딩 규칙' },
|
||||
@@ -10,10 +23,21 @@ const navItems = [
|
||||
{ path: '/mcp', label: 'MCP 상태' },
|
||||
]
|
||||
|
||||
/**
|
||||
* HtsHeader — HTS(홈 트레이딩 시스템) 스타일 헤더
|
||||
*
|
||||
* 상태(State):
|
||||
* - currentTime: 1초마다 갱신되는 현재 시각 (setInterval)
|
||||
*
|
||||
* 쿼리:
|
||||
* - mcp-status: MCP 서버 연결 상태 (5초 폴링)
|
||||
*/
|
||||
const HtsHeader = () => {
|
||||
const location = useLocation()
|
||||
// 현재 시각 — 1초 간격 갱신
|
||||
const [currentTime, setCurrentTime] = useState(() => new Date())
|
||||
|
||||
// 1초마다 현재 시각 업데이트
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => {
|
||||
setCurrentTime(new Date())
|
||||
@@ -22,28 +46,25 @@ const HtsHeader = () => {
|
||||
return () => clearInterval(timer)
|
||||
}, [])
|
||||
|
||||
// MCP 상태 가져오기
|
||||
// MCP 서버 연결 상태 가져오기 (5초 폴링)
|
||||
const { data: mcpStatus } = useQuery({
|
||||
queryKey: ['mcp-status'],
|
||||
queryFn: async () => {
|
||||
const response = await fetch('/mcp/status')
|
||||
return response.json()
|
||||
},
|
||||
queryFn: async () => (await api.get('/mcp/status')).data,
|
||||
refetchInterval: 5000,
|
||||
})
|
||||
|
||||
/**
|
||||
* 리스크 체크 핸들러
|
||||
* MCP 서버에 RISK_ASSESSMENT 분석을 요청하고 결과를 alert로 표시
|
||||
*/
|
||||
const handleRiskCheck = async () => {
|
||||
try {
|
||||
const response = await fetch('http://localhost:8001/analyze', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
analysis_type: 'RISK_ASSESSMENT',
|
||||
data: { message: '현재 포트폴리오 리스크를 분석해주세요' }
|
||||
})
|
||||
const response = await api.post('/mcp/analyze', {
|
||||
analysis_type: 'RISK_ASSESSMENT',
|
||||
data: { message: '현재 포트폴리오 리스크를 분석해주세요' }
|
||||
});
|
||||
const result = await response.json();
|
||||
alert(`⚠️ 포트폴리오 리스크 평가:\n\n${result.analysis || result.result}`);
|
||||
const result = response.data;
|
||||
alert(`포트폴리오 리스크 평가:\n\n${result.analysis || result.result}`);
|
||||
} catch (error) {
|
||||
console.error('리스크 평가 실패:', error);
|
||||
alert('리스크 평가에 실패했습니다. MCP 서버를 확인해주세요.');
|
||||
|
||||
@@ -1,75 +1,206 @@
|
||||
import React from 'react'
|
||||
import { Activity } from 'lucide-react'
|
||||
/**
|
||||
* JournalPanel.tsx — 매매내역 및 매매일지 탭 패널 컴포넌트
|
||||
*
|
||||
* 역할:
|
||||
* - 매매내역 탭: 최근 거래 이력 표시 (매수/매도, 손익, 체결 시간, 사유)
|
||||
* - 매매일지 탭: AI 자동 생성 매매일지 표시 (기분, 태그, 내용)
|
||||
* - 미체결 주문 정리 버튼 제공
|
||||
*
|
||||
* 최적화:
|
||||
* - React.memo로 불필요한 리렌더 방지
|
||||
*
|
||||
* 데이터 흐름:
|
||||
* - journals: react-query 'journals' 쿼리 결과
|
||||
* - tradeHistory: react-query 'trade-history' 쿼리 결과
|
||||
*/
|
||||
import React, { useState } from 'react'
|
||||
import { BookOpen, ArrowUpDown } from 'lucide-react'
|
||||
import { Journal, TradeHistory } from '../types/trading'
|
||||
|
||||
/** JournalPanel Props 정의 */
|
||||
interface JournalPanelProps {
|
||||
journals: any[] | undefined
|
||||
/** 매매일지 배열 (장 마감 후 자동 생성) */
|
||||
journals: Journal[] | undefined
|
||||
/** 매매 내역 배열 (최근 거래 이력) */
|
||||
tradeHistory?: TradeHistory[] | undefined
|
||||
/** 미체결 주문 정리 콜백 */
|
||||
onResetPending: () => void
|
||||
}
|
||||
|
||||
const JournalPanel = React.memo(({ journals, onResetPending }: JournalPanelProps) => {
|
||||
return (
|
||||
<div className="bg-gray-800/50 backdrop-blur-sm border border-gray-700/50 rounded-xl overflow-hidden flex flex-col h-1/2 shadow-lg">
|
||||
<div className="flex flex-col h-full p-3">
|
||||
<div className="flex flex-col h-full min-h-0">
|
||||
<div className="flex items-center justify-between mb-2 flex-shrink-0">
|
||||
<h3 className="font-bold flex items-center gap-2 text-green-400 text-sm">
|
||||
<Activity size={16} />
|
||||
매매일지
|
||||
</h3>
|
||||
<button
|
||||
onClick={onResetPending}
|
||||
className="px-2 py-1 text-[10px] bg-red-500/20 hover:bg-red-500/30 text-red-400 rounded border border-red-500/30 transition-colors"
|
||||
title="미체결 주문 정리"
|
||||
>
|
||||
미체결 정리
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-2 overflow-y-auto custom-scrollbar flex-1">
|
||||
{journals && journals.length > 0 ? (
|
||||
journals.map((journal: any) => {
|
||||
const journalDate = new Date(journal.journal_date || journal.created_at)
|
||||
const dateStr = journalDate.toLocaleDateString('ko-KR', { month: '2-digit', day: '2-digit', timeZone: 'Asia/Seoul' })
|
||||
const timeStr = journalDate.toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit', hour12: false, timeZone: 'Asia/Seoul' })
|
||||
const isGoodMood = journal.mood === '좋음' || journal.mood === 'good' || journal.mood === 'positive'
|
||||
const isBadMood = journal.mood === '나쁨' || journal.mood === 'bad' || journal.mood === 'negative'
|
||||
const tags: string[] = Array.isArray(journal.tags) ? journal.tags : []
|
||||
const hasProfitTag = tags.some((t: string) => t.includes('익절') || t.includes('profit'))
|
||||
const hasLossTag = tags.some((t: string) => t.includes('손절') || t.includes('loss'))
|
||||
/**
|
||||
* JournalPanel — 매매내역/매매일지 탭 전환 패널
|
||||
*
|
||||
* 상태(State):
|
||||
* - tab: 현재 활성 탭 ('trades' 또는 'journal')
|
||||
*/
|
||||
const JournalPanel = React.memo(({ journals, tradeHistory, onResetPending }: JournalPanelProps) => {
|
||||
const [tab, setTab] = useState<'trades' | 'journal'>('trades')
|
||||
|
||||
return (
|
||||
<div key={journal.id} className="p-3 hover:bg-gray-700/30 rounded-lg transition-colors bg-gray-900/30 border border-gray-700/30">
|
||||
<div className="flex justify-between items-start mb-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-base">
|
||||
{isGoodMood ? '😊' : isBadMood ? '😔' : '📝'}
|
||||
</span>
|
||||
<span className="font-bold text-gray-200 text-sm">{journal.stock_name || journal.stock_code}</span>
|
||||
{hasProfitTag && <span className="px-1.5 py-0.5 text-[9px] rounded bg-green-500/20 text-green-400">익절</span>}
|
||||
{hasLossTag && <span className="px-1.5 py-0.5 text-[9px] rounded bg-red-500/20 text-red-400">손절</span>}
|
||||
</div>
|
||||
<span className="text-[10px] text-gray-500 font-mono">{dateStr} {timeStr}</span>
|
||||
return (
|
||||
<div className="bg-[#0c1522] border border-[#152035] rounded overflow-hidden flex flex-col h-1/2">
|
||||
{/* Header with tabs */}
|
||||
<div className="px-3 py-1.5 border-b border-[#152035] bg-[#091018] flex justify-between items-center flex-shrink-0">
|
||||
<div className="flex items-center gap-0.5">
|
||||
<button
|
||||
onClick={() => setTab('trades')}
|
||||
className={`flex items-center gap-1 px-2 py-1 text-[10px] font-semibold uppercase tracking-wider rounded transition-colors ${
|
||||
tab === 'trades'
|
||||
? 'text-[#3b82f6] bg-[#3b82f6]/10'
|
||||
: 'text-[#3a5570] hover:text-[#64748b]'
|
||||
}`}
|
||||
>
|
||||
<ArrowUpDown size={10} />
|
||||
매매내역
|
||||
{tradeHistory && tradeHistory.length > 0 && (
|
||||
<span className="ml-1 text-[8px] font-mono bg-[#1e293b] px-1 rounded">{tradeHistory.length}</span>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setTab('journal')}
|
||||
className={`flex items-center gap-1 px-2 py-1 text-[10px] font-semibold uppercase tracking-wider rounded transition-colors ${
|
||||
tab === 'journal'
|
||||
? 'text-[#22c55e] bg-[#22c55e]/10'
|
||||
: 'text-[#3a5570] hover:text-[#64748b]'
|
||||
}`}
|
||||
>
|
||||
<BookOpen size={10} />
|
||||
매매일지
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={onResetPending}
|
||||
className="px-2 py-0.5 text-[9px] font-mono text-[#3a5570] hover:text-[#e84444] border border-[#152035] hover:border-[#e84444]/30 rounded-sm transition-colors"
|
||||
title="미체결 주문 정리"
|
||||
>
|
||||
미체결 정리
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="overflow-y-auto custom-scrollbar flex-1 p-2 space-y-1">
|
||||
{tab === 'trades' ? (
|
||||
/* ── 매매내역 탭 ── */
|
||||
tradeHistory && tradeHistory.length > 0 ? (
|
||||
tradeHistory.map((trade: TradeHistory, idx: number) => {
|
||||
// 거래 유형 판별 (매수/매도)
|
||||
const isBuy = trade.order_type === 'BUY'
|
||||
const isSell = trade.order_type === 'SELL'
|
||||
// 손익 금액 및 수익률 (순이익 우선, 없으면 손익 사용)
|
||||
const pnl = Number(trade.net_profit_loss || trade.profit_loss || 0)
|
||||
const pnlRate = Number(trade.profit_loss_rate || 0)
|
||||
const isProfit = pnl > 0
|
||||
const isLoss = pnl < 0
|
||||
// 체결 상태 판별 (COMPLETED, EXECUTED, FILLED → 정상 체결)
|
||||
const st = String(trade.status || '')
|
||||
const statusOk = st === 'COMPLETED' || st === 'EXECUTED' || st === 'FILLED'
|
||||
const isFailed = st === 'FAILED'
|
||||
|
||||
// DB가 KST로 저장하므로 타임존 없는 ISO 문자열에 +09:00 보정
|
||||
const rawDt = trade.created_at || trade.executed_at || ''
|
||||
const createdAt = new Date(rawDt.includes('+') || rawDt.includes('Z') ? rawDt : rawDt + '+09:00')
|
||||
const timeStr = createdAt.toLocaleTimeString('ko-KR', {
|
||||
hour: '2-digit', minute: '2-digit', second: '2-digit',
|
||||
hour12: false, timeZone: 'Asia/Seoul',
|
||||
})
|
||||
|
||||
return (
|
||||
<div
|
||||
key={trade.id || idx}
|
||||
className={`px-2.5 py-2 rounded-sm border transition-colors ${
|
||||
isFailed
|
||||
? 'bg-[#1a0808] border-red-900/20'
|
||||
: 'bg-[#091018] border-[#152035] hover:border-[#1e3050]'
|
||||
}`}
|
||||
>
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<span className={`text-[10px] font-bold px-1.5 py-0.5 rounded ${
|
||||
isBuy ? 'bg-[#ef4444]/15 text-[#ef4444]' : 'bg-[#3b82f6]/15 text-[#3b82f6]'
|
||||
}`}>
|
||||
{isBuy ? '매수' : '매도'}
|
||||
</span>
|
||||
<span className="text-xs font-semibold text-[#c5d5e8] truncate">
|
||||
{trade.stock_name || trade.stock_code}
|
||||
</span>
|
||||
{isFailed && (
|
||||
<span className="text-[8px] font-mono px-1 py-px rounded-sm bg-[#7f1d1d] text-[#fca5a5]">실패</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-[11px] text-gray-300 leading-relaxed line-clamp-3 whitespace-pre-line">
|
||||
{journal.content}
|
||||
<span className="text-[9px] font-mono text-[#3a5570] flex-shrink-0 ml-1">{timeStr}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center mt-1">
|
||||
<div className="text-[10px] font-mono text-[#64748b]">
|
||||
{Number(trade.order_quantity || 0)}주 × {Number(trade.order_price || 0).toLocaleString()}원
|
||||
</div>
|
||||
{tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-1.5">
|
||||
{tags.slice(0, 4).map((tag: string, i: number) => (
|
||||
<span key={i} className="px-1.5 py-0.5 text-[9px] rounded bg-gray-700/50 text-gray-400">#{tag}</span>
|
||||
))}
|
||||
{isSell && statusOk && pnl !== 0 && (
|
||||
<div className={`text-[10px] font-mono font-semibold ${isProfit ? 'text-[#ef4444]' : isLoss ? 'text-[#3b82f6]' : 'text-[#64748b]'}`}>
|
||||
{pnl > 0 ? '+' : ''}{Math.round(pnl).toLocaleString()}원
|
||||
<span className="ml-1 text-[9px]">({pnlRate > 0 ? '+' : ''}{pnlRate.toFixed(1)}%)</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
) : (
|
||||
<div className="h-full flex flex-col items-center justify-center text-gray-600 text-xs gap-1">
|
||||
<span>매매일지 없음</span>
|
||||
<span className="text-[10px] text-gray-700">매도 체결 후 자동으로 생성됩니다</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{trade.entry_reason && isBuy && (
|
||||
<div className="text-[9px] text-[#475569] mt-1 truncate">{trade.entry_reason}</div>
|
||||
)}
|
||||
{trade.exit_reason && isSell && (
|
||||
<div className="text-[9px] text-[#475569] mt-1 truncate">{trade.exit_reason}</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
) : (
|
||||
<div className="h-full flex flex-col items-center justify-center text-[#3a5570] text-xs text-center gap-1 pt-8">
|
||||
<ArrowUpDown size={20} className="text-[#1e293b]" />
|
||||
<span>매매내역 없음</span>
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
/* ── 매매일지 탭 ── */
|
||||
journals && journals.length > 0 ? (
|
||||
journals.map((journal: Journal) => {
|
||||
const journalDate = new Date(journal.journal_date || journal.created_at || '')
|
||||
const dateStr = journalDate.toLocaleDateString('ko-KR', { month: '2-digit', day: '2-digit', timeZone: 'Asia/Seoul' })
|
||||
const timeStr = journalDate.toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit', hour12: false, timeZone: 'Asia/Seoul' })
|
||||
const isGoodMood = journal.mood === '좋음' || journal.mood === 'good' || journal.mood === 'positive'
|
||||
const isBadMood = journal.mood === '나쁨' || journal.mood === 'bad' || journal.mood === 'negative'
|
||||
const tags: string[] = Array.isArray(journal.tags) ? journal.tags : []
|
||||
|
||||
return (
|
||||
<div key={journal.id} className="p-2.5 bg-[#091018] border border-[#152035] rounded-sm hover:border-[#1e3050] transition-colors">
|
||||
<div className="flex justify-between items-start mb-1">
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<span className="text-sm flex-shrink-0">
|
||||
{isGoodMood ? '😊' : isBadMood ? '😔' : '📝'}
|
||||
</span>
|
||||
<span className="text-xs font-semibold text-[#c5d5e8] truncate">
|
||||
{journal.stock_name || journal.stock_code}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-[9px] font-mono text-[#3a5570] flex-shrink-0 ml-1">{dateStr} {timeStr}</span>
|
||||
</div>
|
||||
<div className="text-[10px] text-[#4a6580] leading-relaxed line-clamp-2 whitespace-pre-line">
|
||||
{journal.content}
|
||||
</div>
|
||||
{tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-1">
|
||||
{tags.slice(0, 4).map((tag: string, i: number) => (
|
||||
<span key={i} className="text-[8px] font-mono px-1 py-px rounded-sm bg-[#152035] text-[#3a5570]">#{tag}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
) : (
|
||||
<div className="h-full flex flex-col items-center justify-center text-[#3a5570] text-xs text-center gap-1 pt-8">
|
||||
<BookOpen size={20} className="text-[#1e293b]" />
|
||||
<span>매매일지 없음</span>
|
||||
<span className="text-[9px] text-[#2a3d55]">장 마감 후 자동 생성됩니다</span>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,23 +1,64 @@
|
||||
/**
|
||||
* Layout.tsx — 전체 페이지 레이아웃 컴포넌트
|
||||
*
|
||||
* 역할:
|
||||
* - 상단 네비게이션 바 (브랜드 로고, 메뉴, 연결 상태 표시기)
|
||||
* - 백엔드 및 MCP 서버 연결 상태 실시간 모니터링 (5초 폴링)
|
||||
* - children으로 전달된 페이지 컨텐츠 렌더링
|
||||
*
|
||||
* 구조:
|
||||
* [Header] — sticky 상단 바 (z-50)
|
||||
* - 브랜드 로고 (AutoStock GEN 3 ALGO)
|
||||
* - 네비게이션 링크 (대시보드, 성과분석, 전략설정, 백테스트, 매매로그, MCP상태)
|
||||
* - 연결 상태 표시 (Backend LIVE/OFFLINE, MCP connected/unknown)
|
||||
* [Main] — 페이지 컨텐츠 영역
|
||||
*/
|
||||
import { Link, useLocation } from 'react-router-dom'
|
||||
import { LayoutDashboard, History, Cpu, Activity, SlidersHorizontal, BarChart3, TrendingUp } from 'lucide-react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import api from '../lib/api'
|
||||
import { useWebSocket } from '../hooks/useWebSocket'
|
||||
|
||||
/** Layout Props 정의 */
|
||||
interface LayoutProps {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* Layout — 전체 페이지 레이아웃 (헤더 + 컨텐츠)
|
||||
*
|
||||
* 쿼리:
|
||||
* - backend-health: 백엔드 연결 상태 확인 (portfolio API 호출, 3초 타임아웃)
|
||||
* - mcp-status: MCP 서버 연결 상태 확인
|
||||
*/
|
||||
const Layout = ({ children }: LayoutProps) => {
|
||||
const location = useLocation()
|
||||
const { connected: wsConnected } = useWebSocket('dashboard') // Global WS status
|
||||
|
||||
// 백엔드 연결 상태 확인 (30초 폴링, 3초 타임아웃 — /health 경량 엔드포인트 사용)
|
||||
const { data: healthData } = useQuery({
|
||||
queryKey: ['backend-health'],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
const r = await fetch('/health', {
|
||||
signal: AbortSignal.timeout(3000),
|
||||
})
|
||||
return r.ok
|
||||
} catch { return false }
|
||||
},
|
||||
refetchInterval: 30000,
|
||||
})
|
||||
const backendConnected = healthData === true
|
||||
|
||||
const { data: mcpStatus } = useQuery({
|
||||
queryKey: ['mcp-status'],
|
||||
queryFn: async () => (await api.get('/mcp/status')).data,
|
||||
queryFn: async () => {
|
||||
try {
|
||||
return (await api.get('/mcp/status')).data
|
||||
} catch { return null }
|
||||
},
|
||||
refetchInterval: 5000,
|
||||
})
|
||||
|
||||
/** 네비게이션 메뉴 항목 (경로, 아이콘, 레이블) */
|
||||
const navItems = [
|
||||
{ path: '/', icon: LayoutDashboard, label: '대시보드' },
|
||||
{ path: '/analysis', icon: TrendingUp, label: '성과 분석' },
|
||||
@@ -70,8 +111,8 @@ const Layout = ({ children }: LayoutProps) => {
|
||||
{/* 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 className={`w-2 h-2 rounded-full ${backendConnected ? 'bg-green-500 shadow-[0_0_8px_rgba(34,197,94,0.5)]' : 'bg-red-500'}`} />
|
||||
<span className="text-xs text-gray-400">{backendConnected ? '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'}`} />
|
||||
|
||||
@@ -1,77 +1,131 @@
|
||||
/**
|
||||
* OrderbookPanel.tsx — 호가창 패널 컴포넌트
|
||||
*
|
||||
* 역할:
|
||||
* - 선택 종목의 매도/매수 호가 및 잔량을 시각적으로 표시
|
||||
* - 현재가 바를 중심으로 상단 매도호가(빨강), 하단 매수호가(파랑) 배치
|
||||
* - 호가 잔량에 비례한 배경 바 그래프로 수급 시각화
|
||||
*
|
||||
* 데이터 흐름:
|
||||
* - REST API(초기 로드) + WebSocket quote(실시간 갱신) 조합
|
||||
* - Dashboard의 activeOrderbook useMemo에서 병합된 데이터 수신
|
||||
*
|
||||
* 최적화:
|
||||
* - React.memo로 불필요한 리렌더 방지
|
||||
*/
|
||||
import React from 'react'
|
||||
import { Activity } from 'lucide-react'
|
||||
import { Orderbook } from '../types/trading'
|
||||
|
||||
/** OrderbookPanel Props 정의 */
|
||||
interface OrderbookPanelProps {
|
||||
orderbook: any | null | undefined
|
||||
/** 호가 데이터 (null이면 종목 미선택 안내 표시) */
|
||||
orderbook: Orderbook | null | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* OrderbookPanel — 호가창 표시 패널
|
||||
*
|
||||
* 구조:
|
||||
* - 헤더: 제목 + 종목명
|
||||
* - 매도호가 8단계 (높은 가격순 정렬, 빨간 배경 바)
|
||||
* - 현재가 바 (굵은 텍스트)
|
||||
* - 매수호가 8단계 (높은 가격순 정렬, 파란 배경 바)
|
||||
*/
|
||||
const OrderbookPanel = React.memo(({ orderbook }: OrderbookPanelProps) => {
|
||||
return (
|
||||
<div className="bg-gray-800/50 backdrop-blur-sm border border-gray-700/50 rounded-xl p-4 shadow-lg flex flex-col">
|
||||
<h3 className="font-bold flex items-center gap-2 mb-3 text-yellow-400 text-sm">
|
||||
<Activity size={16} />
|
||||
실시간 호가
|
||||
</h3>
|
||||
<div className="overflow-y-auto custom-scrollbar flex-1">
|
||||
<div className="bg-[#0c1522] border border-[#152035] rounded flex flex-col overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="px-3 py-2 border-b border-[#152035] bg-[#091018] flex justify-between items-center flex-shrink-0">
|
||||
<h3 className="text-[#e5a93a] text-xs font-semibold uppercase tracking-wider">
|
||||
호가창
|
||||
</h3>
|
||||
{orderbook?.stock_name && (
|
||||
<span className="text-[9px] font-mono text-[#3a5570]">{orderbook.stock_name}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Orderbook body */}
|
||||
<div className="flex-1 overflow-y-auto custom-scrollbar">
|
||||
{orderbook ? (() => {
|
||||
const currentPrice = orderbook.current_price
|
||||
// 높은 가격 → 낮은 가격 순으로 정렬된 매도호가 (최대 10개)
|
||||
// 매도호가: 높은 가격순 정렬 (상단에 높은 가격), 최대 8단계
|
||||
const asks: { price: number; volume: number }[] = [...(orderbook.asks || [])]
|
||||
.sort((a, b) => b.price - a.price)
|
||||
.slice(0, 10)
|
||||
// 높은 가격 → 낮은 가격 순으로 정렬된 매수호가 (최대 10개)
|
||||
.slice(0, 8)
|
||||
// 매수호가: 높은 가격순 정렬 (상단에 현재가에 가까운 가격), 최대 8단계
|
||||
const bids: { price: number; volume: number }[] = [...(orderbook.bids || [])]
|
||||
.sort((a, b) => b.price - a.price)
|
||||
.slice(0, 10)
|
||||
.slice(0, 8)
|
||||
|
||||
// 잔량 바 그래프의 최대 폭 계산용 (최소 1로 0 나눗셈 방지)
|
||||
const maxAskVol = Math.max(...asks.map(a => a.volume), 1)
|
||||
const maxBidVol = Math.max(...bids.map(b => b.volume), 1)
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-0.5">
|
||||
{asks.map((ask, i) => (
|
||||
<div key={`ask-${i}`} className="grid grid-cols-3 items-center gap-2 py-1 px-2">
|
||||
<div className="text-xs text-gray-300 font-medium text-right font-mono">
|
||||
{ask.volume > 0 ? ask.volume.toLocaleString() : '-'}
|
||||
</div>
|
||||
<div className="text-sm font-bold text-red-400 font-mono text-center">
|
||||
<div className="flex flex-col">
|
||||
{/* Column header */}
|
||||
<div className="grid grid-cols-3 px-2 py-1 border-b border-[#0f1e30]">
|
||||
<span className="text-[8px] font-mono text-[#3a5570] text-right">잔량</span>
|
||||
<span className="text-[8px] font-mono text-[#3a5570] text-center">가격</span>
|
||||
<span className="text-[8px] font-mono text-[#3a5570]">잔량</span>
|
||||
</div>
|
||||
|
||||
{/* Asks (매도호가) */}
|
||||
{asks.map((ask, i) => {
|
||||
const fillPct = Math.min((ask.volume / maxAskVol) * 75, 75)
|
||||
return (
|
||||
<div key={`ask-${i}`} className="relative grid grid-cols-3 items-center py-0.5 px-2 hover:bg-[#0f1e30]">
|
||||
<div
|
||||
className="absolute right-0 top-0 h-full bg-[#e84444]/8"
|
||||
style={{ width: `${fillPct}%` }}
|
||||
/>
|
||||
<span className="text-[10px] font-mono text-[#4a6580] text-right relative z-10">
|
||||
{ask.volume > 0 ? ask.volume.toLocaleString() : '—'}
|
||||
</span>
|
||||
<span className="text-[11px] font-mono font-semibold text-[#e84444] text-center relative z-10">
|
||||
{ask.price.toLocaleString()}
|
||||
</div>
|
||||
<div></div>
|
||||
</span>
|
||||
<div className="relative z-10" />
|
||||
</div>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Current price bar */}
|
||||
<div className="py-1.5 border-y border-[#2a3a4e] bg-[#0f1e30] flex justify-center items-center gap-2">
|
||||
<span className="text-base font-mono font-bold text-[#c5d5e8]">
|
||||
{currentPrice?.toLocaleString()}
|
||||
</span>
|
||||
{orderbook.current_volume > 0 && (
|
||||
<span className="text-[9px] font-mono text-[#3a5570]">
|
||||
{orderbook.current_volume.toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="py-3 bg-gradient-to-r from-red-900/20 via-red-800/30 to-red-900/20 rounded-lg border-2 border-red-500/30 my-2">
|
||||
<div className="flex items-center justify-center gap-4 px-4">
|
||||
<div className="text-2xl font-bold text-red-400 font-mono">
|
||||
{currentPrice?.toLocaleString()}
|
||||
</div>
|
||||
{orderbook.current_volume > 0 && (
|
||||
<div className="text-xs text-gray-400 font-mono">
|
||||
({orderbook.current_volume.toLocaleString()})
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-red-300 mt-1 text-center">실시간 호가창</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-0.5">
|
||||
{bids.map((bid, i) => (
|
||||
<div key={`bid-${i}`} className="grid grid-cols-3 items-center gap-2 py-1 px-2">
|
||||
<div></div>
|
||||
<div className="text-sm font-bold text-blue-400 font-mono text-center">
|
||||
{/* Bids (매수호가) */}
|
||||
{bids.map((bid, i) => {
|
||||
const fillPct = Math.min((bid.volume / maxBidVol) * 75, 75)
|
||||
return (
|
||||
<div key={`bid-${i}`} className="relative grid grid-cols-3 items-center py-0.5 px-2 hover:bg-[#0f1e30]">
|
||||
<div
|
||||
className="absolute left-0 top-0 h-full bg-[#3d7eff]/8"
|
||||
style={{ width: `${fillPct}%` }}
|
||||
/>
|
||||
<div className="relative z-10" />
|
||||
<span className="text-[11px] font-mono font-semibold text-[#3d7eff] text-center relative z-10">
|
||||
{bid.price.toLocaleString()}
|
||||
</div>
|
||||
<div className="text-xs text-gray-300 font-medium text-left font-mono">
|
||||
{bid.volume > 0 ? bid.volume.toLocaleString() : '-'}
|
||||
</div>
|
||||
</span>
|
||||
<span className="text-[10px] font-mono text-[#4a6580] relative z-10">
|
||||
{bid.volume > 0 ? bid.volume.toLocaleString() : '—'}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
})() : (
|
||||
<div className="h-full flex items-center justify-center text-gray-600 text-xs text-center">
|
||||
종목을 선택하면 호가 정보가 표시됩니다.
|
||||
<div className="h-full flex items-center justify-center text-[#3a5570] text-xs font-mono text-center p-4">
|
||||
종목을 선택하면<br />호가 정보가 표시됩니다.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,16 +1,172 @@
|
||||
/**
|
||||
* PortfolioPanel.tsx — 보유 종목 패널 컴포넌트
|
||||
*
|
||||
* 역할:
|
||||
* - 보유 종목 목록을 데이트레이딩/스윙 그룹별로 구분 표시
|
||||
* - 각 종목의 수익률, 현재가, 평균단가, 전량 청산 버튼 제공
|
||||
* - 실시간 가격(livePrices)으로 수익률 실시간 갱신
|
||||
*
|
||||
* 최적화:
|
||||
* - PortfolioItem, PortfolioPanel 모두 React.memo 적용
|
||||
* - 가격 변동 시에도 선택된 종목만 시각적 변화 (border-l-2 하이라이트)
|
||||
*
|
||||
* 한국/미국 시장 구분:
|
||||
* - 종목 코드가 6자리 숫자면 KR, 그 외는 US
|
||||
*/
|
||||
import React from 'react'
|
||||
import { Activity } from 'lucide-react'
|
||||
import { Activity, TrendingUp, Clock } from 'lucide-react'
|
||||
import { Position } from '../types/trading'
|
||||
import { isUsStock } from '../utils/stockUtils'
|
||||
|
||||
interface PortfolioPanelProps {
|
||||
portfolio: Position[] | undefined
|
||||
/**
|
||||
* 가격 포맷 — 시장에 따라 원화 또는 달러 표시
|
||||
* @param price - 가격
|
||||
* @param isUs - 미국 주식 여부
|
||||
*/
|
||||
const formatPrice = (price: number, isUs: boolean): string => {
|
||||
if (isUs) {
|
||||
return `$${price.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
|
||||
}
|
||||
return `${price.toLocaleString()}원`
|
||||
}
|
||||
|
||||
/** PortfolioItem Props 정의 */
|
||||
interface PortfolioItemProps {
|
||||
/** 보유 종목 데이터 */
|
||||
stock: Position
|
||||
/** 실시간 가격 맵 (종목코드 → 현재가) */
|
||||
livePrices: Record<string, number> | undefined
|
||||
/** 현재 선택된 종목 코드 (하이라이트용) */
|
||||
selectedStock: string
|
||||
/** 종목 선택 콜백 */
|
||||
onSelectStock: (code: string) => void
|
||||
/** AI 분석 새로고침 콜백 (이미 선택된 종목 클릭 시) */
|
||||
onRefetchAiAnalysis: () => void
|
||||
/** 전량 청산(시장가 매도) 콜백 */
|
||||
onSell: (stockCode: string, stockName: string, quantity: number) => Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* PortfolioItem — 개별 보유 종목 카드
|
||||
*
|
||||
* 표시 정보: 종목명, KR/US 마켓 배지, 데이트레이딩 배지, 수익률,
|
||||
* 종목코드, 수량, 평균단가, 현재가, 전량 청산 버튼
|
||||
*
|
||||
* 동작:
|
||||
* - 클릭: 해당 종목 선택 (차트/호가/AI분석 전환)
|
||||
* - 이미 선택된 종목 클릭: AI 분석 새로고침
|
||||
* - 전량 청산 버튼: 시장가 매도 요청
|
||||
*/
|
||||
const PortfolioItem = React.memo(({
|
||||
stock,
|
||||
livePrices,
|
||||
selectedStock,
|
||||
onSelectStock,
|
||||
onRefetchAiAnalysis,
|
||||
onSell,
|
||||
}: PortfolioItemProps) => {
|
||||
// 현재가: 실시간 가격 우선, 없으면 포트폴리오 API 가격 사용
|
||||
const currentPrice = livePrices?.[stock.stock_code] ?? Number(stock.current_price || 0)
|
||||
const avgPrice = Number(stock.average_price || 0)
|
||||
const quantity = Number(stock.quantity || 0)
|
||||
// 수익률: 평균단가 기반 실시간 계산 (평균단가 없으면 API 값 폴백)
|
||||
const profitLossRate = avgPrice > 0
|
||||
? ((currentPrice - avgPrice) / avgPrice) * 100
|
||||
: Number(stock.profit_loss_rate || 0)
|
||||
const isUs = isUsStock(stock.stock_code)
|
||||
const isSelected = selectedStock === stock.stock_code
|
||||
const isUp = profitLossRate >= 0
|
||||
|
||||
const handleSellClick = async (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.stopPropagation()
|
||||
try {
|
||||
await onSell(stock.stock_code, stock.stock_name, quantity)
|
||||
} catch (error: unknown) {
|
||||
const err = error as any
|
||||
alert(`매도 실패: ${err.response?.data?.detail || err.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`border-l-2 ${isSelected ? 'border-[#2b65d9] bg-[#0d1e33]' : 'border-transparent hover:bg-[#0a1628]'} transition-colors`}>
|
||||
<div
|
||||
onClick={() => {
|
||||
if (isSelected) onRefetchAiAnalysis()
|
||||
else onSelectStock(stock.stock_code)
|
||||
}}
|
||||
className="px-3 pt-2 pb-1 cursor-pointer"
|
||||
>
|
||||
{/* Row 1: Name + P&L% */}
|
||||
<div className="flex justify-between items-start mb-0.5">
|
||||
<div className="flex items-center gap-1 min-w-0 flex-1 mr-1">
|
||||
<span className={`text-xs font-semibold truncate ${isSelected ? 'text-[#5b9aff]' : 'text-[#c5d5e8]'}`}>
|
||||
{stock.stock_name}
|
||||
</span>
|
||||
<span className={`text-[7px] px-1 py-px rounded-sm flex-shrink-0 font-mono ${isUs ? 'text-[#22c55e] bg-[#22c55e]/10' : 'text-[#5b9aff] bg-[#5b9aff]/10'}`}>
|
||||
{isUs ? 'US' : 'KR'}
|
||||
</span>
|
||||
{stock.strategy_type === 'daytrading' && (
|
||||
<span className="text-[7px] px-1 py-px rounded-sm text-[#f97316] bg-[#f97316]/10 flex-shrink-0">데이</span>
|
||||
)}
|
||||
</div>
|
||||
<span className={`text-xs font-mono font-bold flex-shrink-0 ${isUp ? 'text-[#e84444]' : 'text-[#3d7eff]'}`}>
|
||||
{profitLossRate > 0 ? '+' : ''}{profitLossRate.toFixed(2)}%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Row 2: Code + qty */}
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-[9px] font-mono text-[#3a5570]">{stock.stock_code}</span>
|
||||
<span className="text-[9px] text-[#3a5570]">{quantity}{isUs ? 'shares' : '주'}</span>
|
||||
</div>
|
||||
|
||||
{/* Row 3: Avg → Current */}
|
||||
<div className="flex justify-between items-center mt-0.5">
|
||||
<span className="text-[9px] font-mono text-[#4a6580]">{formatPrice(avgPrice, isUs)}</span>
|
||||
<span className={`text-[10px] font-mono font-semibold ${isUp ? 'text-[#e84444]' : 'text-[#3d7eff]'}`}>
|
||||
{formatPrice(currentPrice, isUs)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sell button */}
|
||||
<div className="px-3 pb-2">
|
||||
<button
|
||||
onClick={handleSellClick}
|
||||
className="w-full py-0.5 text-[9px] font-mono text-[#3a5570] hover:text-[#3d7eff] border border-[#152035] hover:border-[#3d7eff]/40 rounded-sm transition-colors"
|
||||
>
|
||||
전량 청산 (시장가)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
PortfolioItem.displayName = 'PortfolioItem'
|
||||
|
||||
/** PortfolioPanel Props 정의 */
|
||||
interface PortfolioPanelProps {
|
||||
/** 보유 종목 배열 (마켓 필터 적용 후) */
|
||||
portfolio: Position[] | undefined
|
||||
/** 실시간 가격 맵 */
|
||||
livePrices: Record<string, number> | undefined
|
||||
/** 현재 선택된 종목 코드 */
|
||||
selectedStock: string
|
||||
/** 종목 선택 콜백 */
|
||||
onSelectStock: (code: string) => void
|
||||
/** AI 분석 새로고침 콜백 */
|
||||
onRefetchAiAnalysis: () => void
|
||||
/** 전량 청산 콜백 */
|
||||
onSell: (stockCode: string, stockName: string, quantity: number) => Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* PortfolioPanel — 보유 종목 목록 패널
|
||||
*
|
||||
* 그룹 구분:
|
||||
* - 데이트레이딩: strategy_type === 'daytrading' (주황색 배지)
|
||||
* - 스윙: 그 외 (보라색 배지)
|
||||
*/
|
||||
const PortfolioPanel = React.memo(({
|
||||
portfolio,
|
||||
livePrices,
|
||||
@@ -19,89 +175,63 @@ const PortfolioPanel = React.memo(({
|
||||
onRefetchAiAnalysis,
|
||||
onSell,
|
||||
}: PortfolioPanelProps) => {
|
||||
const dayStocks = portfolio?.filter(s => s.strategy_type === 'daytrading') ?? []
|
||||
const swingStocks = portfolio?.filter(s => s.strategy_type !== 'daytrading') ?? []
|
||||
const total = portfolio?.length ?? 0
|
||||
|
||||
const renderGroup = (stocks: Position[], label: string, icon: React.ReactNode, color: string) => {
|
||||
if (stocks.length === 0) return null
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-1.5 px-3 py-1 bg-[#091018] border-b border-[#152035] sticky top-0 z-10">
|
||||
{icon}
|
||||
<span className={`text-[9px] font-semibold uppercase tracking-wider ${color}`}>{label}</span>
|
||||
<span className="ml-auto text-[8px] font-mono bg-[#152035] text-[#4a6580] px-1.5 py-px rounded-sm">{stocks.length}</span>
|
||||
</div>
|
||||
{stocks.map((stock: Position) => (
|
||||
<PortfolioItem
|
||||
key={stock.stock_code}
|
||||
stock={stock}
|
||||
livePrices={livePrices}
|
||||
selectedStock={selectedStock}
|
||||
onSelectStock={onSelectStock}
|
||||
onRefetchAiAnalysis={onRefetchAiAnalysis}
|
||||
onSell={onSell}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-gray-800/50 backdrop-blur-sm border border-gray-700/50 rounded-xl overflow-hidden flex flex-col h-full shadow-lg">
|
||||
<div className="p-3 border-b border-gray-700/50 flex justify-between items-center bg-[#1c222c]">
|
||||
<h3 className="font-semibold flex items-center gap-2 text-gray-200 text-sm">
|
||||
<Activity className="w-4 h-4 text-blue-400" />
|
||||
<div className="bg-[#0c1522] border border-[#152035] rounded overflow-hidden flex flex-col h-full">
|
||||
{/* Header */}
|
||||
<div className="px-3 py-2 border-b border-[#152035] bg-[#091018] flex justify-between items-center flex-shrink-0">
|
||||
<h3 className="flex items-center gap-1.5 text-[#8faabf] text-xs font-semibold uppercase tracking-wider">
|
||||
<Activity className="w-3 h-3 text-[#2b65d9]" />
|
||||
보유 종목
|
||||
</h3>
|
||||
<span className="text-xs bg-blue-500/20 text-blue-300 px-2 py-0.5 rounded-full">
|
||||
{portfolio?.length || 0}
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
{dayStocks.length > 0 && (
|
||||
<span className="text-[8px] font-mono text-[#f97316] bg-[#f97316]/10 px-1.5 py-px rounded-sm">데이 {dayStocks.length}</span>
|
||||
)}
|
||||
{swingStocks.length > 0 && (
|
||||
<span className="text-[8px] font-mono text-[#a78bfa] bg-[#a78bfa]/10 px-1.5 py-px rounded-sm">스윙 {swingStocks.length}</span>
|
||||
)}
|
||||
<span className="text-[8px] font-mono text-[#2b65d9] bg-[#2b65d9]/10 px-1.5 py-px rounded-sm">{total}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="divide-y divide-gray-700/50 overflow-y-auto custom-scrollbar flex-1">
|
||||
{portfolio && portfolio.length > 0 ? (
|
||||
portfolio.map((stock: Position) => {
|
||||
const currentPrice = livePrices?.[stock.stock_code] ?? Number(stock.current_price || 0)
|
||||
const avgPrice = Number(stock.average_price || 0)
|
||||
const quantity = Number(stock.quantity || 0)
|
||||
const totalValue = currentPrice * quantity
|
||||
const profitLossRate = avgPrice > 0
|
||||
? ((currentPrice - avgPrice) / avgPrice) * 100
|
||||
: Number(stock.profit_loss_rate || 0)
|
||||
|
||||
const handleSellClick = async (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.stopPropagation()
|
||||
|
||||
try {
|
||||
await onSell(stock.stock_code, stock.stock_name, quantity)
|
||||
} catch (error: unknown) {
|
||||
const err = error as any;
|
||||
alert(`매도 실패: ${err.response?.data?.detail || err.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={stock.stock_code}
|
||||
className={`w-full p-3 hover:bg-gray-700/30 transition-all duration-200 group ${selectedStock === stock.stock_code ? 'bg-blue-500/10 border-l-2 border-blue-500' : 'border-l-2 border-transparent'}`}
|
||||
>
|
||||
<div
|
||||
onClick={() => {
|
||||
if (selectedStock === stock.stock_code) {
|
||||
onRefetchAiAnalysis() // 같은 종목 재클릭 → 강제 새로고침
|
||||
} else {
|
||||
onSelectStock(stock.stock_code) // 새 종목 → queryKey 변경으로 자동 fetch
|
||||
}
|
||||
}}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<div className="flex justify-between items-start mb-1">
|
||||
<span className={`font-bold text-sm ${selectedStock === stock.stock_code ? 'text-blue-400' : 'text-gray-200'}`}>
|
||||
{stock.stock_name}
|
||||
</span>
|
||||
<span className={`text-xs font-bold ${profitLossRate > 0 ? 'text-red-400' : 'text-blue-400'}`}>
|
||||
{profitLossRate > 0 ? '+' : ''}{profitLossRate.toFixed(2)}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center text-[11px] text-gray-400 mb-0.5">
|
||||
<span className="font-mono">{stock.stock_code}</span>
|
||||
<span>{quantity}주</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center text-[11px]">
|
||||
<span className="text-gray-500">{avgPrice.toLocaleString()}원</span>
|
||||
<span className="text-white font-medium">{currentPrice.toLocaleString()}원</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center text-[11px] mt-0.5">
|
||||
<span className="text-gray-500">평가금액</span>
|
||||
<span className={`font-medium ${profitLossRate > 0 ? 'text-red-400' : 'text-blue-400'}`}>
|
||||
{totalValue.toLocaleString()}원
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleSellClick}
|
||||
className="w-full mt-2 px-2 py-1 text-[10px] bg-blue-500/20 hover:bg-blue-500/30 text-blue-400 rounded border border-blue-500/30 transition-colors font-bold"
|
||||
>
|
||||
🔵 전량 청산 (시장가)
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
{/* List */}
|
||||
<div className="divide-y divide-[#0f1e2e] overflow-y-auto custom-scrollbar flex-1">
|
||||
{total > 0 ? (
|
||||
<>
|
||||
{renderGroup(dayStocks, '데이트레이딩', <Clock className="w-2.5 h-2.5 text-[#f97316]" />, 'text-[#f97316]')}
|
||||
{renderGroup(swingStocks, '스윙', <TrendingUp className="w-2.5 h-2.5 text-[#a78bfa]" />, 'text-[#a78bfa]')}
|
||||
</>
|
||||
) : (
|
||||
<div className="h-full flex flex-col items-center justify-center text-gray-500 p-4 text-center text-xs">
|
||||
<p>보유 중인 종목이 없습니다.</p>
|
||||
<div className="h-full flex flex-col items-center justify-center text-[#3a5570] p-4 text-center">
|
||||
<p className="text-xs">보유 중인 종목이 없습니다.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,133 +1,195 @@
|
||||
import { ArrowUpRight, ArrowDownRight, AlertTriangle, Activity, TrendingUp, TrendingDown, DollarSign, Calendar, BarChart2 } from 'lucide-react';
|
||||
/**
|
||||
* PortfolioSummary.tsx — 포트폴리오 요약 정보 표시 컴포넌트
|
||||
*
|
||||
* 역할:
|
||||
* - 총 자산, 평가손익, 금일 손익, 누적 실현손익 4개 핵심 지표 표시
|
||||
* - 하단에 당일(또는 최근) 매매 통계 (매수/매도 금액, 실현 손익, 거래 건수)
|
||||
* - livePrices 기반 실시간 갱신된 summary 데이터 사용
|
||||
*
|
||||
* 색상 규칙 (한국 시장 컨벤션):
|
||||
* - 상승/이익: 빨강 (#ef4444)
|
||||
* - 하락/손실: 파랑 (#3b82f6)
|
||||
* - 보합: 회색 (#94a3b8)
|
||||
*/
|
||||
import { AlertTriangle, TrendingUp, TrendingDown, Wallet, BarChart3, Calendar, ArrowUpDown } from 'lucide-react';
|
||||
import { PortfolioSummary as PortfolioSummaryType } from '../types/trading';
|
||||
|
||||
interface PortfolioSummaryProps {
|
||||
summary: any;
|
||||
error: any;
|
||||
/** 당일 매매 통계 인터페이스 (Dashboard의 useMemo에서 계산) */
|
||||
interface TodayTradeStats {
|
||||
/** 매수 총금액 */
|
||||
buyAmount: number;
|
||||
/** 매도 총금액 */
|
||||
sellAmount: number;
|
||||
/** 실현 손익 */
|
||||
realizedPL: number;
|
||||
/** 거래 건수 */
|
||||
count: number;
|
||||
/** 오늘 거래 여부 (false이면 가장 최근 거래일 기준) */
|
||||
isToday?: boolean;
|
||||
/** 기준 날짜 문자열 */
|
||||
dateStr?: string;
|
||||
}
|
||||
|
||||
export const PortfolioSummary = ({ summary, error }: PortfolioSummaryProps) => {
|
||||
/** PortfolioSummary Props 정의 */
|
||||
interface PortfolioSummaryProps {
|
||||
/** 포트폴리오 요약 데이터 (실시간 가격 반영) */
|
||||
summary: PortfolioSummaryType | null | undefined;
|
||||
/** 에러 메시지 (API 호출 실패 시) */
|
||||
error: string | null | undefined;
|
||||
/** 당일 매매 통계 */
|
||||
todayTradeStats?: TodayTradeStats | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* PortfolioSummary — 포트폴리오 핵심 지표 요약 배너
|
||||
*
|
||||
* 구조:
|
||||
* - 상단 4분할: 총자산 | 평가손익 + 수익률 | 금일 손익 | 누적 실현손익
|
||||
* - 하단: 당일(또는 최근) 매매 요약 바
|
||||
*
|
||||
* 에러 상태: 빨간 경고 배너 표시
|
||||
* 로딩 상태: 스켈레톤 애니메이션 (animate-pulse)
|
||||
*/
|
||||
export const PortfolioSummary = ({ summary, error, todayTradeStats }: 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" />
|
||||
<div className="flex items-center gap-2 text-red-400 text-xs px-3 py-1.5 bg-[#1a0808] border border-red-900/40 rounded mb-3">
|
||||
<AlertTriangle className="w-3 h-3 flex-shrink-0" />
|
||||
<span>포트폴리오 정보를 불러오는데 실패했습니다.</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!summary) {
|
||||
return (
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4 animate-pulse">
|
||||
{[...Array(6)].map((_, i) => (
|
||||
<div key={i} className="bg-gray-800/50 h-24 rounded-xl" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
return <div className="h-20 bg-[#0c1522] border border-[#152035] rounded mb-3 animate-pulse" />;
|
||||
}
|
||||
|
||||
const {
|
||||
total_assets,
|
||||
total_value, // 주식 평가금액
|
||||
total_investment, // 총 매입금액
|
||||
total_profit_loss,
|
||||
total_profit_loss_rate,
|
||||
available_cash, // 예수금 (현금)
|
||||
today_profit_loss, // 금일 평가손익
|
||||
today_profit_loss_rate, // 금일 수익률
|
||||
cumulative_realized_pl, // 누적 실현 손익
|
||||
} = summary;
|
||||
const isProfit = (total_profit_loss ?? 0) >= 0;
|
||||
const isTodayProfit = (today_profit_loss ?? 0) >= 0;
|
||||
const isCumulativeProfit = (cumulative_realized_pl ?? 0) >= 0;
|
||||
// 숫자 변환 헬퍼 — undefined/string 값을 안전하게 number로 변환
|
||||
const n = (v: string | number | undefined): number => Number(v || 0);
|
||||
|
||||
const fmt = (v: number) => Math.round(v || 0).toLocaleString();
|
||||
// 핵심 지표 추출
|
||||
const total_assets = n(summary.total_assets); // 총 자산 (예수금 + 주식 평가)
|
||||
const available_cash = n(summary.available_cash); // 예수금
|
||||
const total_value = n(summary.total_value); // 주식 평가금액
|
||||
const total_investment = n(summary.total_investment); // 총 매입금액
|
||||
const total_profit_loss = n(summary.total_profit_loss); // 평가손익
|
||||
const total_profit_loss_rate = n(summary.total_profit_loss_rate); // 수익률
|
||||
const today_profit_loss = n(summary.today_profit_loss); // 금일 손익
|
||||
const today_profit_loss_rate = n(summary.today_profit_loss_rate); // 금일 수익률
|
||||
const cumulative_realized_pl = n(summary.cumulative_realized_pl); // 누적 실현손익
|
||||
|
||||
// 당일 매매 레이블 (오늘 거래 있으면 '오늘', 없으면 '최근')
|
||||
const todayLabel = todayTradeStats?.isToday !== false ? '오늘' : '최근';
|
||||
const realizedPL = todayTradeStats?.realizedPL ?? 0;
|
||||
// 현금 비율 (총 자산 대비)
|
||||
const cashRatio = total_assets > 0 ? (available_cash / total_assets * 100) : 0;
|
||||
|
||||
// 포맷 헬퍼 함수들
|
||||
const fmt = (v: number) => Math.round(v || 0).toLocaleString(); // 원화 포맷 (소수점 없음)
|
||||
const fmtSign = (v: number) => `${v > 0 ? '+' : ''}${fmt(v)}`; // 부호 포함 원화
|
||||
const fmtRate = (v: number) => `${v >= 0 ? '+' : ''}${Number(v || 0).toFixed(2)}%`; // 수익률 포맷
|
||||
|
||||
// 색상 클래스 (한국 시장 컨벤션: 상승=빨강, 하락=파랑, 보합=회색)
|
||||
const up = 'text-[#ef4444]';
|
||||
const dn = 'text-[#3b82f6]';
|
||||
const flat = 'text-[#94a3b8]';
|
||||
const color = (v: number) => v > 0 ? up : v < 0 ? dn : flat;
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3 mb-6">
|
||||
{/* 총 자산 (예수금 + 주식평가) */}
|
||||
<div className="bg-gray-800/50 backdrop-blur-sm border border-gray-700/50 p-4 rounded-xl">
|
||||
<div className="flex justify-between items-start mb-1.5">
|
||||
<p className="text-gray-400 text-xs font-medium">총 자산</p>
|
||||
<div className="p-1.5 bg-blue-500/10 rounded-lg text-blue-400">
|
||||
<DollarSign size={14} />
|
||||
<div className="bg-[#0d1117] border border-[#1e2a3a] rounded-lg mb-3 overflow-hidden">
|
||||
{/* 상단: 핵심 지표 (총자산, 평가손익, 수익률) */}
|
||||
<div className="grid grid-cols-4 gap-0 divide-x divide-[#1e2a3a]">
|
||||
{/* 총 자산 */}
|
||||
<div className="px-4 py-3">
|
||||
<div className="flex items-center gap-1.5 mb-1">
|
||||
<Wallet size={11} className="text-[#475569]" />
|
||||
<span className="text-[10px] font-medium text-[#475569] tracking-wide">총 자산</span>
|
||||
</div>
|
||||
<div className="text-lg font-mono font-bold text-white tracking-tight">
|
||||
{fmt(total_assets)}<span className="text-xs text-[#64748b] ml-0.5">원</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<span className="text-[10px] font-mono text-[#475569]">
|
||||
예수금 {fmt(available_cash)}원
|
||||
</span>
|
||||
<span className="text-[10px] font-mono text-[#334155]">
|
||||
({cashRatio.toFixed(0)}%)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<h3 className="text-lg font-bold text-white mb-1 leading-tight">
|
||||
{fmt(total_assets)}원
|
||||
</h3>
|
||||
<p className="text-[10px] text-gray-500">예수금 + 주식평가</p>
|
||||
|
||||
{/* 평가손익 + 수익률 (합침) */}
|
||||
<div className="px-4 py-3">
|
||||
<div className="flex items-center gap-1.5 mb-1">
|
||||
{total_profit_loss >= 0 ? <TrendingUp size={11} className="text-[#ef4444]" /> : <TrendingDown size={11} className="text-[#3b82f6]" />}
|
||||
<span className="text-[10px] font-medium text-[#475569] tracking-wide">평가손익</span>
|
||||
</div>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className={`text-lg font-mono font-bold tracking-tight ${color(total_profit_loss)}`}>
|
||||
{fmtSign(total_profit_loss)}<span className="text-xs ml-0.5">원</span>
|
||||
</span>
|
||||
<span className={`text-sm font-mono font-semibold ${color(total_profit_loss_rate)}`}>
|
||||
{fmtRate(total_profit_loss_rate)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-[10px] font-mono text-[#475569] mt-0.5">
|
||||
주식 {fmt(total_value)}원 · 매입 {fmt(total_investment)}원
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 금일 손익 */}
|
||||
<div className="px-4 py-3">
|
||||
<div className="flex items-center gap-1.5 mb-1">
|
||||
<Calendar size={11} className="text-[#475569]" />
|
||||
<span className="text-[10px] font-medium text-[#475569] tracking-wide">금일 손익</span>
|
||||
</div>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className={`text-lg font-mono font-bold tracking-tight ${color(today_profit_loss)}`}>
|
||||
{fmtSign(today_profit_loss)}<span className="text-xs ml-0.5">원</span>
|
||||
</span>
|
||||
<span className={`text-sm font-mono font-semibold ${color(today_profit_loss_rate)}`}>
|
||||
{fmtRate(today_profit_loss_rate)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-[10px] font-mono text-[#475569] mt-0.5">전일 대비</div>
|
||||
</div>
|
||||
|
||||
{/* 누적 실현 */}
|
||||
<div className="px-4 py-3">
|
||||
<div className="flex items-center gap-1.5 mb-1">
|
||||
<BarChart3 size={11} className="text-[#475569]" />
|
||||
<span className="text-[10px] font-medium text-[#475569] tracking-wide">누적 실현손익</span>
|
||||
</div>
|
||||
<div className={`text-lg font-mono font-bold tracking-tight ${color(cumulative_realized_pl)}`}>
|
||||
{fmtSign(cumulative_realized_pl)}<span className="text-xs ml-0.5">원</span>
|
||||
</div>
|
||||
<div className="text-[10px] font-mono text-[#475569] mt-0.5">매도 체결 기준</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 예수금 (현금) */}
|
||||
<div className="bg-gray-800/50 backdrop-blur-sm border border-gray-700/50 p-4 rounded-xl">
|
||||
<div className="flex justify-between items-start mb-1.5">
|
||||
<p className="text-gray-400 text-xs font-medium">예수금</p>
|
||||
<div className="p-1.5 bg-purple-500/10 rounded-lg text-purple-400">
|
||||
<Activity size={14} />
|
||||
</div>
|
||||
{/* 하단: 오늘 매매 요약 */}
|
||||
<div className="border-t border-[#1e2a3a] bg-[#0a0f16] px-4 py-1.5 flex items-center gap-6">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<ArrowUpDown size={10} className="text-[#334155]" />
|
||||
<span className="text-[10px] text-[#475569] font-medium">{todayLabel} 매매</span>
|
||||
<span className="text-[10px] font-mono text-[#64748b] bg-[#1e293b] px-1.5 py-0.5 rounded">
|
||||
{todayTradeStats?.count ?? 0}건
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="text-lg font-bold text-white mb-1 leading-tight">
|
||||
{fmt(available_cash)}원
|
||||
</h3>
|
||||
<p className="text-[10px] text-gray-500">주식평가: {fmt(total_value)}원</p>
|
||||
</div>
|
||||
|
||||
{/* 총 평가손익 */}
|
||||
<div className="bg-gray-800/50 backdrop-blur-sm border border-gray-700/50 p-4 rounded-xl">
|
||||
<div className="flex justify-between items-start mb-1.5">
|
||||
<p className="text-gray-400 text-xs font-medium">총 평가손익</p>
|
||||
<div className={`p-1.5 rounded-lg ${isProfit ? 'bg-red-500/10 text-red-400' : 'bg-blue-500/10 text-blue-400'}`}>
|
||||
{isProfit ? <TrendingUp size={14} /> : <TrendingDown size={14} />}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-[10px] text-[#475569]">매수</span>
|
||||
<span className="text-[11px] font-mono text-[#94a3b8]">{fmt(todayTradeStats?.buyAmount ?? 0)}</span>
|
||||
</div>
|
||||
<h3 className={`text-lg font-bold mb-1 leading-tight ${isProfit ? 'text-red-400' : 'text-blue-400'}`}>
|
||||
{(total_profit_loss ?? 0) > 0 ? '+' : ''}{fmt(total_profit_loss)}원
|
||||
</h3>
|
||||
<p className="text-[10px] text-gray-500">매입: {fmt(total_investment)}원</p>
|
||||
</div>
|
||||
|
||||
{/* 수익률 */}
|
||||
<div className="bg-gray-800/50 backdrop-blur-sm border border-gray-700/50 p-4 rounded-xl">
|
||||
<div className="flex justify-between items-start mb-1.5">
|
||||
<p className="text-gray-400 text-xs font-medium">수익률</p>
|
||||
<div className={`p-1.5 rounded-lg ${isProfit ? 'bg-red-500/10 text-red-400' : 'bg-blue-500/10 text-blue-400'}`}>
|
||||
{isProfit ? <ArrowUpRight size={14} /> : <ArrowDownRight size={14} />}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-[10px] text-[#475569]">매도</span>
|
||||
<span className="text-[11px] font-mono text-[#94a3b8]">{fmt(todayTradeStats?.sellAmount ?? 0)}</span>
|
||||
</div>
|
||||
<h3 className={`text-lg font-bold mb-1 leading-tight ${isProfit ? 'text-red-400' : 'text-blue-400'}`}>
|
||||
{(total_profit_loss_rate ?? 0) > 0 ? '+' : ''}{(total_profit_loss_rate ?? 0).toFixed(2)}%
|
||||
</h3>
|
||||
<p className="text-[10px] text-gray-500">주식 매입 대비</p>
|
||||
</div>
|
||||
|
||||
{/* 금일 손익 */}
|
||||
<div className="bg-gray-800/50 backdrop-blur-sm border border-gray-700/50 p-4 rounded-xl">
|
||||
<div className="flex justify-between items-start mb-1.5">
|
||||
<p className="text-gray-400 text-xs font-medium">금일 손익</p>
|
||||
<div className={`p-1.5 rounded-lg ${isTodayProfit ? 'bg-red-500/10 text-red-400' : 'bg-blue-500/10 text-blue-400'}`}>
|
||||
<Calendar size={14} />
|
||||
</div>
|
||||
<div className="flex items-center gap-1 ml-auto">
|
||||
<span className="text-[10px] text-[#475569]">{todayLabel} 실현</span>
|
||||
<span className={`text-[11px] font-mono font-semibold ${color(realizedPL)}`}>
|
||||
{fmtSign(realizedPL)}원
|
||||
</span>
|
||||
</div>
|
||||
<h3 className={`text-lg font-bold mb-1 leading-tight ${isTodayProfit ? 'text-red-400' : 'text-blue-400'}`}>
|
||||
{(today_profit_loss ?? 0) > 0 ? '+' : ''}{fmt(today_profit_loss)}원
|
||||
</h3>
|
||||
<p className="text-[10px] text-gray-500">
|
||||
{(today_profit_loss_rate ?? 0) > 0 ? '+' : ''}{(today_profit_loss_rate ?? 0).toFixed(2)}% 전일 대비
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 누적 실현손익 */}
|
||||
<div className="bg-gray-800/50 backdrop-blur-sm border border-gray-700/50 p-4 rounded-xl">
|
||||
<div className="flex justify-between items-start mb-1.5">
|
||||
<p className="text-gray-400 text-xs font-medium">누적 실현손익</p>
|
||||
<div className={`p-1.5 rounded-lg ${isCumulativeProfit ? 'bg-red-500/10 text-red-400' : 'bg-blue-500/10 text-blue-400'}`}>
|
||||
<BarChart2 size={14} />
|
||||
</div>
|
||||
</div>
|
||||
<h3 className={`text-lg font-bold mb-1 leading-tight ${isCumulativeProfit ? 'text-red-400' : 'text-blue-400'}`}>
|
||||
{(cumulative_realized_pl ?? 0) > 0 ? '+' : ''}{fmt(cumulative_realized_pl)}원
|
||||
</h3>
|
||||
<p className="text-[10px] text-gray-500">매도 체결 기준</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,22 +1,139 @@
|
||||
/**
|
||||
* RecommendationPanel.tsx — 분석 종목 스캐너 패널 컴포넌트
|
||||
*
|
||||
* 역할:
|
||||
* - 배치 분석(batch_daily_analysis) 결과 종목 목록 표시
|
||||
* - 각 종목의 등락률, RSI, 스토캐스틱, 점수, 신호 표시
|
||||
* - 보유/매수중/신호 배지로 종목 상태 시각화
|
||||
* - 종목 클릭 시 차트/AI분석 패널 전환
|
||||
*
|
||||
* 데이터 흐름:
|
||||
* - 추천 데이터: react-query 'rule-based-recommendations' (2분 폴링)
|
||||
* - 보유 종목 Set: Dashboard에서 useMemo로 안정화 후 전달
|
||||
* - 실시간 가격: livePrices로 현재가 갱신
|
||||
*
|
||||
* 최적화:
|
||||
* - RecommendationItem, RecommendationPanel 모두 React.memo 적용
|
||||
*/
|
||||
import React from 'react'
|
||||
import { Brain, RefreshCw } from 'lucide-react'
|
||||
import { RefreshCw } from 'lucide-react'
|
||||
import { RecommendationsData, RecommendedStock, Recommendation } from '../types/trading'
|
||||
import { formatTime } from '../utils/formatUtils'
|
||||
|
||||
const formatTime = (isoStr?: string): string | null => {
|
||||
if (!isoStr) return null
|
||||
const d = new Date(isoStr)
|
||||
return d.toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false, timeZone: 'Asia/Seoul' })
|
||||
}
|
||||
|
||||
interface RecommendationPanelProps {
|
||||
recommendations: RecommendationsData | undefined
|
||||
isError: boolean
|
||||
onRefetch: () => void
|
||||
/** RecommendationItem Props 정의 */
|
||||
interface RecommendationItemProps {
|
||||
stock: RecommendedStock
|
||||
idx: number
|
||||
selectedStock: string
|
||||
onSelectStock: (code: string) => void
|
||||
onRefetchAiAnalysis: () => void
|
||||
isRecommended: boolean
|
||||
isHeld: boolean
|
||||
isPending: boolean
|
||||
livePrices: Record<string, number> | undefined
|
||||
}
|
||||
|
||||
const RecommendationItem = React.memo(({
|
||||
stock,
|
||||
idx,
|
||||
selectedStock,
|
||||
onSelectStock,
|
||||
onRefetchAiAnalysis,
|
||||
isRecommended,
|
||||
isHeld,
|
||||
isPending,
|
||||
livePrices,
|
||||
}: RecommendationItemProps) => {
|
||||
const changeRate = stock.change_rate || 0
|
||||
const isSelected = selectedStock === stock.stock_code
|
||||
const isUp = changeRate >= 0
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={() => {
|
||||
if (isSelected) onRefetchAiAnalysis()
|
||||
else onSelectStock(stock.stock_code)
|
||||
}}
|
||||
className={`w-full text-left transition-colors border-l-2 ${
|
||||
isSelected
|
||||
? 'bg-[#0d1e33] border-[#2b65d9]'
|
||||
: 'border-transparent hover:bg-[#0a1628]'
|
||||
}`}
|
||||
>
|
||||
<div className="px-3 py-1.5">
|
||||
{/* Row 1: rank + name + badge + change% */}
|
||||
<div className="flex items-center gap-1.5 mb-0.5">
|
||||
<span className="text-[8px] font-mono text-[#2a3d55] w-4 flex-shrink-0 text-right">{idx + 1}</span>
|
||||
<span className={`text-[11px] font-semibold truncate flex-1 ${isSelected ? 'text-[#5b9aff]' : 'text-[#c5d5e8]'}`}>
|
||||
{stock.stock_name}
|
||||
</span>
|
||||
{isPending ? (
|
||||
<span className="text-[8px] text-[#f97316] bg-[#f97316]/10 px-1 py-px rounded-sm flex items-center gap-0.5 flex-shrink-0">
|
||||
<RefreshCw className="w-2 h-2 animate-spin" />매수중
|
||||
</span>
|
||||
) : isHeld ? (
|
||||
<span className="text-[8px] text-[#5b9aff] bg-[#5b9aff]/10 px-1 py-px rounded-sm flex-shrink-0">보유</span>
|
||||
) : isRecommended ? (
|
||||
<span className="text-[8px] text-[#22c55e] bg-[#22c55e]/10 px-1 py-px rounded-sm flex-shrink-0">신호</span>
|
||||
) : null}
|
||||
<span className={`text-[10px] font-mono font-bold flex-shrink-0 ${isUp ? 'text-[#e84444]' : 'text-[#3d7eff]'}`}>
|
||||
{changeRate > 0 ? '+' : ''}{changeRate.toFixed(2)}%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Row 2: code + price + RSI + score */}
|
||||
<div className="flex items-center gap-2 pl-5">
|
||||
<span className="text-[9px] font-mono text-[#3a5570]">{stock.stock_code}</span>
|
||||
<span className="text-[9px] font-mono text-[#4a6580]">
|
||||
{Math.round(livePrices?.[stock.stock_code] ?? stock.current_price ?? 0).toLocaleString()}원
|
||||
</span>
|
||||
{stock.rsi != null && (
|
||||
<span className={`text-[8px] font-mono ${stock.rsi < 30 ? 'text-[#22c55e]' : stock.rsi > 70 ? 'text-[#e84444]' : 'text-[#4a6580]'}`}>
|
||||
RSI {stock.rsi.toFixed(0)}
|
||||
</span>
|
||||
)}
|
||||
{stock.stochastic_k != null && (
|
||||
<span className="text-[8px] font-mono text-[#4a6580]">K {stock.stochastic_k.toFixed(0)}</span>
|
||||
)}
|
||||
{(stock.score ?? 0) > 0 && (
|
||||
<span className="text-[8px] font-mono text-[#9f7aea]">{stock.score}pt</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Row 3: signals */}
|
||||
{stock.signals && stock.signals.length > 0 && (
|
||||
<div className="pl-5 mt-0.5">
|
||||
<span className="text-[8px] text-[#3a5570] truncate block">
|
||||
{stock.signals.slice(0, 2).join(' · ')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})
|
||||
|
||||
RecommendationItem.displayName = 'RecommendationItem'
|
||||
|
||||
/** RecommendationPanel Props 정의 */
|
||||
interface RecommendationPanelProps {
|
||||
/** 배치 분석 추천 데이터 */
|
||||
recommendations: RecommendationsData | undefined
|
||||
/** API 에러 여부 */
|
||||
isError: boolean
|
||||
/** 추천 데이터 새로고침 콜백 */
|
||||
onRefetch: () => void
|
||||
/** 현재 선택된 종목 코드 */
|
||||
selectedStock: string
|
||||
/** 종목 선택 콜백 */
|
||||
onSelectStock: (code: string) => void
|
||||
/** AI 분석 새로고침 콜백 */
|
||||
onRefetchAiAnalysis: () => void
|
||||
/** 보유 중인 종목 코드 Set (보유 배지 표시용) */
|
||||
heldStockCodes: Set<string>
|
||||
/** 매수 진행 중인 종목 코드 Set (매수중 배지 표시용) */
|
||||
pendingBuyStockCodes: Set<string>
|
||||
/** 실시간 가격 맵 */
|
||||
livePrices: Record<string, number> | undefined
|
||||
}
|
||||
|
||||
@@ -32,103 +149,69 @@ const RecommendationPanel = React.memo(({
|
||||
livePrices,
|
||||
}: RecommendationPanelProps) => {
|
||||
return (
|
||||
<div className="bg-gray-800/50 backdrop-blur-sm border border-gray-700/50 rounded-xl overflow-hidden flex flex-col h-1/2 shadow-lg">
|
||||
<div className="p-3 border-b border-gray-700/50 flex justify-between items-center bg-[#1c222c]">
|
||||
<h3 className="font-semibold flex items-center gap-2 text-gray-200 text-sm">
|
||||
<Brain className="w-4 h-4 text-purple-400" />
|
||||
분석 종목
|
||||
<div className="bg-[#0c1522] border border-[#152035] rounded overflow-hidden flex flex-col h-1/2">
|
||||
{/* Header */}
|
||||
<div className="px-3 py-2 border-b border-[#152035] bg-[#091018] flex justify-between items-center flex-shrink-0">
|
||||
<h3 className="text-[#9f7aea] text-xs font-semibold uppercase tracking-wider">
|
||||
분석 종목 스캐너
|
||||
</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
{recommendations?.batch_time && (
|
||||
<span className="text-[10px] text-gray-500 font-mono">
|
||||
<span className="text-[9px] text-[#3a5570] font-mono">
|
||||
{formatTime(recommendations.batch_time)}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-xs bg-purple-500/20 text-purple-300 px-2 py-0.5 rounded-full">
|
||||
<span className="text-[8px] font-mono text-[#9f7aea] bg-[#9f7aea]/10 px-1.5 py-px rounded-sm">
|
||||
{recommendations?.analyzed_stocks || 0}
|
||||
</span>
|
||||
<button
|
||||
onClick={onRefetch}
|
||||
className="p-1 hover:bg-gray-700/50 rounded transition-colors"
|
||||
title="분석 종목 새로고침"
|
||||
className="p-1 text-[#3a5570] hover:text-[#9f7aea] transition-colors"
|
||||
title="새로고침"
|
||||
>
|
||||
<RefreshCw className="w-3 h-3 text-gray-400 hover:text-purple-400" />
|
||||
<RefreshCw className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="divide-y divide-gray-700/50 overflow-y-auto custom-scrollbar flex-1">
|
||||
|
||||
{/* List */}
|
||||
<div className="divide-y divide-[#0f1e2e] overflow-y-auto custom-scrollbar flex-1">
|
||||
{isError ? (
|
||||
<div className="h-full flex flex-col items-center justify-center text-red-500 text-xs p-4 text-center">
|
||||
<p>⚠️ API 오류</p>
|
||||
<p className="text-[10px] text-gray-500 mt-1">백엔드 로그 확인 필요</p>
|
||||
<button onClick={onRefetch} className="mt-2 px-2 py-1 bg-red-500/20 text-red-400 rounded text-[10px]">재시도</button>
|
||||
<div className="h-full flex flex-col items-center justify-center text-xs p-4 text-center">
|
||||
<p className="text-[#e84444]">⚠ API 오류</p>
|
||||
<p className="text-[8px] text-[#3a5570] mt-1">백엔드 로그 확인 필요</p>
|
||||
<button onClick={onRefetch} className="mt-2 px-2 py-0.5 text-[#e84444] border border-[#e84444]/30 rounded-sm text-[9px] font-mono">재시도</button>
|
||||
</div>
|
||||
) : recommendations === undefined ? (
|
||||
<div className="h-full flex flex-col items-center justify-center text-gray-500 text-xs p-4">
|
||||
<RefreshCw className="w-6 h-6 animate-spin mb-2 text-purple-400" />
|
||||
<p>분석 종목 로딩 중...</p>
|
||||
<p className="text-[10px] text-gray-600 mt-1">최초 로딩 시 10초 정도 소요됩니다</p>
|
||||
<div className="h-full flex flex-col items-center justify-center text-[#3a5570] text-xs p-4 text-center">
|
||||
<RefreshCw className="w-5 h-5 animate-spin mb-2 text-[#9f7aea]" />
|
||||
<p>로딩 중...</p>
|
||||
<p className="text-[9px] text-[#2a3d55] mt-1">최초 로딩 시 10초 정도 소요됩니다</p>
|
||||
</div>
|
||||
) : (recommendations?.analyzed_stock_list || []).length > 0 ? (
|
||||
(recommendations.analyzed_stock_list).map((stock: RecommendedStock, idx: number) => {
|
||||
const isRecommended = (recommendations?.recommendations || []).some((r: Recommendation) => r.stock_code === stock.stock_code)
|
||||
const isHeld = heldStockCodes.has(stock.stock_code)
|
||||
const isPending = pendingBuyStockCodes.has(stock.stock_code)
|
||||
const changeRate = stock.change_rate || 0
|
||||
return (
|
||||
<button
|
||||
<RecommendationItem
|
||||
key={`${stock.stock_code}-${idx}`}
|
||||
onClick={() => {
|
||||
if (selectedStock === stock.stock_code) {
|
||||
onRefetchAiAnalysis() // 같은 종목 재클릭 → 강제 새로고침
|
||||
} else {
|
||||
onSelectStock(stock.stock_code) // 새 종목 → queryKey 변경으로 자동 fetch
|
||||
}
|
||||
}}
|
||||
className={`w-full p-3 text-left hover:bg-gray-700/30 transition-all duration-200 ${selectedStock === stock.stock_code ? 'bg-purple-500/10 border-l-2 border-purple-500' : 'border-l-2 border-transparent'}`}
|
||||
>
|
||||
<div className="flex justify-between items-start mb-1">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1.5 mb-0.5">
|
||||
<span className="font-bold text-gray-200 text-xs truncate">{stock.stock_name}</span>
|
||||
<span className="text-[9px] text-gray-500 flex-shrink-0">({stock.stock_code})</span>
|
||||
</div>
|
||||
{stock.signals && stock.signals.length > 0 && (
|
||||
<div className="text-[9px] text-gray-500 truncate">
|
||||
{stock.signals.slice(0, 2).join(' · ')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-0.5 flex-shrink-0 ml-1">
|
||||
{isPending ? (
|
||||
<span className="text-[9px] text-orange-400 border border-orange-500/20 bg-orange-500/10 px-1 py-0.5 rounded flex items-center gap-0.5">
|
||||
<RefreshCw className="w-2 h-2 animate-spin" />매수중
|
||||
</span>
|
||||
) : isHeld ? (
|
||||
<span className="text-[9px] text-blue-400 border border-blue-500/20 bg-blue-500/10 px-1 py-0.5 rounded">보유중</span>
|
||||
) : isRecommended ? (
|
||||
<span className="text-[9px] text-green-400 border border-green-500/20 bg-green-500/10 px-1 py-0.5 rounded">매수신호</span>
|
||||
) : null}
|
||||
<span className={`text-[9px] font-bold ${changeRate > 0 ? 'text-red-400' : changeRate < 0 ? 'text-blue-400' : 'text-gray-500'}`}>
|
||||
{changeRate > 0 ? '+' : ''}{changeRate.toFixed(2)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-between items-center text-[9px] text-gray-500 mt-0.5">
|
||||
<span>{Math.round(livePrices?.[stock.stock_code] ?? stock.current_price ?? 0).toLocaleString()}원</span>
|
||||
<span className="flex gap-2">
|
||||
{stock.rsi != null && <span>RSI {stock.rsi.toFixed(0)}</span>}
|
||||
{stock.stochastic_k != null && <span>K {stock.stochastic_k.toFixed(0)}</span>}
|
||||
{(stock.score ?? 0) > 0 && <span className="text-purple-400">{stock.score}점</span>}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
stock={stock}
|
||||
idx={idx}
|
||||
selectedStock={selectedStock}
|
||||
onSelectStock={onSelectStock}
|
||||
onRefetchAiAnalysis={onRefetchAiAnalysis}
|
||||
isRecommended={isRecommended}
|
||||
isHeld={isHeld}
|
||||
isPending={isPending}
|
||||
livePrices={livePrices}
|
||||
/>
|
||||
)
|
||||
})
|
||||
) : (
|
||||
<div className="h-full flex flex-col items-center justify-center text-gray-500 p-4 text-center text-xs">
|
||||
<p>배치 분석 데이터가 없습니다.</p>
|
||||
<p className="text-[10px] text-gray-600 mt-1">make batch-test 실행 필요</p>
|
||||
<div className="h-full flex flex-col items-center justify-center text-[#3a5570] p-4 text-center">
|
||||
<p className="text-xs">배치 분석 데이터 없음</p>
|
||||
<p className="text-[9px] text-[#2a3d55] mt-1">make batch-test 실행 필요</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,21 @@
|
||||
import { useEffect, useRef, useMemo } from 'react';
|
||||
/**
|
||||
* TradingChart.tsx — lightweight-charts 기반 종합 트레이딩 차트 컴포넌트
|
||||
*
|
||||
* 역할:
|
||||
* - 4단 차트 구성: 캔들+볼린저+이평선 | 거래량 | RSI(14) | MACD
|
||||
* - 모든 차트의 시간축 동기화 (메인 차트 스크롤 시 하위 차트 연동)
|
||||
* - 실시간 현재가 반영 (500ms polling, ref 기반 — 리렌더 없음)
|
||||
* - 반응형 크기 조절 (ResizeObserver + window resize)
|
||||
*
|
||||
* 최적화 전략:
|
||||
* - React.memo + 커스텀 비교 함수: livePrice 변경 무시, data 첫/끝 캔들만 비교
|
||||
* - 차트 인스턴스는 마운트 시 1회만 생성 (chartsCreatedRef)
|
||||
* - 데이터 변경 시 시리즈 데이터만 교체 (차트 재생성 없음 → 깜빡임 방지)
|
||||
* - 기술적 지표 계산은 useMemo로 캐싱 (O(n) 슬라이딩 윈도우)
|
||||
*
|
||||
* 차트 높이 비율: 메인 51.9% | 거래량 14.3% | RSI 20.1% | MACD 나머지
|
||||
*/
|
||||
import React, { useCallback, useEffect, useRef, useMemo, useState } from 'react';
|
||||
import { createChart, CrosshairMode, IChartApi, ISeriesApi, CandlestickData, LineData, HistogramData } from 'lightweight-charts';
|
||||
import {
|
||||
calculateSMA,
|
||||
@@ -6,13 +23,17 @@ import {
|
||||
calculateRSI,
|
||||
calculateMACD
|
||||
} from '../utils/technicalIndicators';
|
||||
import { CandleData } from '../types/trading';
|
||||
import { CandleData, ProcessedChartData } from '../types/trading';
|
||||
|
||||
/** TradingChart Props 정의 */
|
||||
interface TradingChartProps {
|
||||
/** 캔들 데이터 배열 (OHLCV) */
|
||||
data: CandleData[];
|
||||
/** 실시간 현재가 (ref 기반 처리, 리렌더 트리거 안 함) */
|
||||
livePrice?: number;
|
||||
}
|
||||
|
||||
// 한국 시장 색상 규칙
|
||||
// 한국 시장 색상 규칙 (상승=빨강, 하락=파랑)
|
||||
const COLORS = {
|
||||
candleUp: '#ef4444', // 상승: 빨강
|
||||
candleDown: '#3b82f6', // 하락: 파랑
|
||||
@@ -29,7 +50,29 @@ const COLORS = {
|
||||
border: '#374151',
|
||||
} as const;
|
||||
|
||||
export const TradingChart = ({ data }: TradingChartProps) => {
|
||||
export const TradingChart = React.memo(({ data, livePrice: livePriceProp }: TradingChartProps) => {
|
||||
// livePrice를 ref로 유지 — 리렌더 없이 useEffect에서 최신 값 사용
|
||||
const livePriceRef = useRef(livePriceProp);
|
||||
livePriceRef.current = livePriceProp;
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [containerHeight, setContainerHeight] = useState(350);
|
||||
// containerHeight를 ref로도 유지 — ResizeObserver 클로저가 최신 값 참조 (Observer 마운트 1회만 생성)
|
||||
const containerHeightRef = useRef(containerHeight);
|
||||
containerHeightRef.current = containerHeight;
|
||||
|
||||
// 컨테이너 크기 감지 — 부모 높이에 맞게 차트 자동 조절
|
||||
// 미세한 높이 차이(±2px)는 무시하여 불필요한 리렌더 방지
|
||||
// deps에 containerHeight 제거 → Observer는 마운트 1회만 생성 (ref로 최신 값 참조)
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
const h = Math.floor(entries[0]?.contentRect.height || 0);
|
||||
if (h > 100 && Math.abs(h - containerHeightRef.current) > 2) setContainerHeight(h);
|
||||
});
|
||||
observer.observe(containerRef.current);
|
||||
return () => observer.disconnect();
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Refs for chart containers
|
||||
const mainChartRef = useRef<HTMLDivElement>(null);
|
||||
const volumeChartRef = useRef<HTMLDivElement>(null);
|
||||
@@ -64,195 +107,249 @@ export const TradingChart = ({ data }: TradingChartProps) => {
|
||||
};
|
||||
|
||||
// 데이터 계산 (메모이제이션)
|
||||
// Note: lightweight-charts has strict Time type requirements, using 'any' for compatibility
|
||||
const processedData = useMemo<any>(() => {
|
||||
// Note: lightweight-charts Time 타입(BusinessDay | string)이 ProcessedChartData의 string | number와
|
||||
// 충돌하므로 useMemo 내부에서 as any 캐스팅을 사용하고, 반환 타입만 ProcessedChartData로 선언
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const processedData = useMemo<ProcessedChartData | null>((): ProcessedChartData | null => {
|
||||
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: CandleData) => d.close);
|
||||
const sortedData = [...data].sort((a, b) =>
|
||||
a.date < b.date ? -1 : a.date > b.date ? 1 : 0
|
||||
);
|
||||
const len = sortedData.length;
|
||||
const closes = new Array<number>(len);
|
||||
// 날짜 변환을 1회만 수행 → 이후 모든 시리즈에서 재사용
|
||||
const times = new Array<any>(len);
|
||||
for (let i = 0; i < len; i++) {
|
||||
closes[i] = sortedData[i].close;
|
||||
times[i] = toChartTime(sortedData[i].date);
|
||||
}
|
||||
|
||||
// 기술적 지표 계산
|
||||
// 기술적 지표 계산 (O(n) 슬라이딩 윈도우)
|
||||
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: CandleData) => ({
|
||||
time: toChartTime(item.date) as any,
|
||||
open: item.open,
|
||||
high: item.high,
|
||||
low: item.low,
|
||||
close: item.close,
|
||||
}));
|
||||
// 단일 루프로 모든 시리즈 데이터 구성
|
||||
const candles: CandlestickData[] = new Array(len);
|
||||
const volumes: HistogramData[] = new Array(len);
|
||||
const bbUpper: LineData[] = [];
|
||||
const bbMiddle: LineData[] = [];
|
||||
const bbLower: LineData[] = [];
|
||||
const sma5Data: LineData[] = [];
|
||||
const sma60Data: LineData[] = [];
|
||||
const rsiData: LineData[] = [];
|
||||
const rsiLine30: LineData[] = new Array(len);
|
||||
const rsiLine50: LineData[] = new Array(len);
|
||||
const rsiLine70: LineData[] = new Array(len);
|
||||
const macdHist: HistogramData[] = [];
|
||||
const macdLine: LineData[] = [];
|
||||
const signalLine: LineData[] = [];
|
||||
|
||||
// 거래량 데이터 (한국 시장 색상 적용)
|
||||
const volumes: HistogramData[] = sortedData.map((item: CandleData) => ({
|
||||
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)' // 하락: 파랑
|
||||
}));
|
||||
for (let i = 0; i < len; i++) {
|
||||
const item = sortedData[i];
|
||||
const t = times[i];
|
||||
|
||||
// 볼린저 밴드 데이터
|
||||
const bbUpperData: LineData[] = sortedData
|
||||
.map((item: CandleData, i: number) => ({
|
||||
time: toChartTime(item.date) as any,
|
||||
value: bollinger.upper[i],
|
||||
}))
|
||||
.filter((d: LineData) => !isNaN(d.value));
|
||||
candles[i] = { time: t, open: item.open, high: item.high, low: item.low, close: item.close } as any;
|
||||
volumes[i] = {
|
||||
time: t,
|
||||
value: item.volume,
|
||||
color: item.close >= item.open ? 'rgba(239, 68, 68, 0.6)' : 'rgba(59, 130, 246, 0.6)',
|
||||
} as any;
|
||||
|
||||
const bbMiddleData: LineData[] = sortedData
|
||||
.map((item: CandleData, i: number) => ({
|
||||
time: toChartTime(item.date) as any,
|
||||
value: bollinger.middle[i],
|
||||
}))
|
||||
.filter((d: LineData) => !isNaN(d.value));
|
||||
if (!isNaN(bollinger.upper[i])) bbUpper.push({ time: t, value: bollinger.upper[i] } as any);
|
||||
if (!isNaN(bollinger.middle[i])) bbMiddle.push({ time: t, value: bollinger.middle[i] } as any);
|
||||
if (!isNaN(bollinger.lower[i])) bbLower.push({ time: t, value: bollinger.lower[i] } as any);
|
||||
if (!isNaN(sma5[i])) sma5Data.push({ time: t, value: sma5[i] } as any);
|
||||
if (!isNaN(sma60[i])) sma60Data.push({ time: t, value: sma60[i] } as any);
|
||||
if (!isNaN(rsi[i])) rsiData.push({ time: t, value: rsi[i] } as any);
|
||||
|
||||
const bbLowerData: LineData[] = sortedData
|
||||
.map((item: CandleData, i: number) => ({
|
||||
time: toChartTime(item.date) as any,
|
||||
value: bollinger.lower[i],
|
||||
}))
|
||||
.filter((d: LineData) => !isNaN(d.value));
|
||||
rsiLine30[i] = { time: t, value: 30 } as any;
|
||||
rsiLine50[i] = { time: t, value: 50 } as any;
|
||||
rsiLine70[i] = { time: t, value: 70 } as any;
|
||||
|
||||
// 이동평균선 데이터
|
||||
const sma5Data: LineData[] = sortedData
|
||||
.map((item: CandleData, i: number) => ({
|
||||
time: toChartTime(item.date) as any,
|
||||
value: sma5[i],
|
||||
}))
|
||||
.filter((d: LineData) => !isNaN(d.value));
|
||||
|
||||
const sma60Data: LineData[] = sortedData
|
||||
.map((item: CandleData, i: number) => ({
|
||||
time: toChartTime(item.date) as any,
|
||||
value: sma60[i],
|
||||
}))
|
||||
.filter((d: LineData) => !isNaN(d.value));
|
||||
|
||||
// RSI 데이터
|
||||
const rsiData: LineData[] = sortedData
|
||||
.map((item: CandleData, i: number) => ({
|
||||
time: toChartTime(item.date) as any,
|
||||
value: rsi[i],
|
||||
}))
|
||||
.filter((d: LineData) => !isNaN(d.value));
|
||||
|
||||
// RSI 기준선 (30, 50, 70)
|
||||
const rsiLine30: LineData[] = sortedData.map((item: CandleData) => ({
|
||||
time: toChartTime(item.date) as any,
|
||||
value: 30,
|
||||
}));
|
||||
const rsiLine50: LineData[] = sortedData.map((item: CandleData) => ({
|
||||
time: toChartTime(item.date) as any,
|
||||
value: 50,
|
||||
}));
|
||||
const rsiLine70: LineData[] = sortedData.map((item: CandleData) => ({
|
||||
time: toChartTime(item.date) as any,
|
||||
value: 70,
|
||||
}));
|
||||
|
||||
// MACD 데이터
|
||||
const macdHistogramData: HistogramData[] = sortedData
|
||||
.map((item: CandleData, i: number) => ({
|
||||
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: HistogramData) => !isNaN(d.value));
|
||||
|
||||
const macdLineData: LineData[] = sortedData
|
||||
.map((item: CandleData, i: number) => ({
|
||||
time: toChartTime(item.date) as any,
|
||||
value: macd[i],
|
||||
}))
|
||||
.filter((d: LineData) => !isNaN(d.value));
|
||||
|
||||
const signalLineData: LineData[] = sortedData
|
||||
.map((item: CandleData, i: number) => ({
|
||||
time: toChartTime(item.date) as any,
|
||||
value: signal[i],
|
||||
}))
|
||||
.filter((d: LineData) => !isNaN(d.value));
|
||||
if (!isNaN(histogram[i])) macdHist.push({
|
||||
time: t, value: histogram[i],
|
||||
color: histogram[i] >= 0 ? 'rgba(239, 68, 68, 0.6)' : 'rgba(59, 130, 246, 0.6)',
|
||||
} as any);
|
||||
if (!isNaN(macd[i])) macdLine.push({ time: t, value: macd[i] } as any);
|
||||
if (!isNaN(signal[i])) signalLine.push({ time: t, value: signal[i] } as any);
|
||||
}
|
||||
|
||||
return {
|
||||
candles,
|
||||
volumes,
|
||||
bollinger: { upper: bbUpperData, middle: bbMiddleData, lower: bbLowerData },
|
||||
bollinger: { upper: bbUpper, middle: bbMiddle, lower: bbLower },
|
||||
sma5: sma5Data,
|
||||
sma60: sma60Data,
|
||||
rsi: { data: rsiData, line30: rsiLine30, line50: rsiLine50, line70: rsiLine70 },
|
||||
macd: { histogram: macdHistogramData, line: macdLineData, signal: signalLineData },
|
||||
};
|
||||
macd: { histogram: macdHist, line: macdLine, signal: signalLine },
|
||||
} as unknown as ProcessedChartData;
|
||||
}, [data]);
|
||||
|
||||
// 공통 차트 옵션
|
||||
const getChartOptions = (height: number): Record<string, any> => ({
|
||||
// 공통 차트 옵션
|
||||
const getChartOptions = (height: number, hideTimeScale = false): Record<string, any> => ({
|
||||
layout: {
|
||||
background: { color: COLORS.background },
|
||||
textColor: COLORS.text,
|
||||
fontSize: 11,
|
||||
fontSize: 9,
|
||||
},
|
||||
grid: {
|
||||
vertLines: { color: '#1a1f2e', style: 1 }, // 더 얇은 그리드
|
||||
horzLines: { color: '#1a1f2e', style: 1 },
|
||||
vertLines: { color: '#141820', style: 1 },
|
||||
horzLines: { color: '#141820', style: 1 },
|
||||
},
|
||||
crosshair: {
|
||||
mode: CrosshairMode.Normal,
|
||||
vertLine: {
|
||||
color: '#4a5568',
|
||||
width: 1,
|
||||
style: 2, // dashed
|
||||
},
|
||||
horzLine: {
|
||||
color: '#4a5568',
|
||||
width: 1,
|
||||
style: 2,
|
||||
},
|
||||
vertLine: { color: '#4a5568', width: 1, style: 2 },
|
||||
horzLine: { color: '#4a5568', width: 1, style: 2 },
|
||||
},
|
||||
timeScale: {
|
||||
borderColor: COLORS.border,
|
||||
timeVisible: true,
|
||||
secondsVisible: false,
|
||||
borderVisible: false, // 테두리 제거
|
||||
borderVisible: false,
|
||||
visible: !hideTimeScale,
|
||||
barSpacing: 3,
|
||||
},
|
||||
rightPriceScale: {
|
||||
borderColor: COLORS.border,
|
||||
borderVisible: false, // 테두리 제거
|
||||
borderVisible: false,
|
||||
scaleMargins: { top: 0.05, bottom: 0.05 },
|
||||
},
|
||||
handleScroll: { mouseWheel: true, pressedMouseMove: true },
|
||||
handleScale: { mouseWheel: true, pinch: true },
|
||||
height,
|
||||
});
|
||||
|
||||
// 차트 초기화 및 데이터 설정
|
||||
// 라벨 3개 × 14px = 42px, 나머지를 비율로 분배
|
||||
const LABEL_H = 14;
|
||||
const chartArea = Math.max(200, containerHeight - LABEL_H * 3);
|
||||
const mainH = Math.round(chartArea * 0.519);
|
||||
const volH = Math.round(chartArea * 0.143);
|
||||
const rsiH = Math.round(chartArea * 0.201);
|
||||
const macdH = chartArea - mainH - volH - rsiH;
|
||||
|
||||
// 높이 변경 시 차트 재생성 없이 applyOptions만 호출
|
||||
useEffect(() => {
|
||||
if (!processedData ||
|
||||
!mainChartRef.current ||
|
||||
if (mainChartInstanceRef.current) {
|
||||
mainChartInstanceRef.current.applyOptions({ height: mainH });
|
||||
}
|
||||
if (volumeChartInstanceRef.current) {
|
||||
volumeChartInstanceRef.current.applyOptions({ height: volH });
|
||||
}
|
||||
if (rsiChartInstanceRef.current) {
|
||||
rsiChartInstanceRef.current.applyOptions({ height: rsiH });
|
||||
}
|
||||
if (macdChartInstanceRef.current) {
|
||||
macdChartInstanceRef.current.applyOptions({ height: macdH });
|
||||
}
|
||||
}, [mainH, volH, rsiH, macdH]);
|
||||
|
||||
// cleanup 헬퍼
|
||||
const destroyChart = (ref: React.MutableRefObject<IChartApi | null>) => {
|
||||
try { ref.current?.remove(); } catch (_) { /* already disposed */ }
|
||||
ref.current = null;
|
||||
};
|
||||
|
||||
const destroyAllCharts = () => {
|
||||
destroyChart(mainChartInstanceRef);
|
||||
destroyChart(volumeChartInstanceRef);
|
||||
destroyChart(rsiChartInstanceRef);
|
||||
destroyChart(macdChartInstanceRef);
|
||||
candlestickSeriesRef.current = null;
|
||||
volumeSeriesRef.current = null;
|
||||
};
|
||||
|
||||
// 보조 시리즈 refs (데이터 교체용)
|
||||
const mainLineSeriesRef = useRef<ISeriesApi<'Line'>[]>([]);
|
||||
const rsiLineSeriesRef = useRef<ISeriesApi<'Line'>[]>([]);
|
||||
const macdSeriesRef = useRef<{
|
||||
histogram: ISeriesApi<'Histogram'> | null;
|
||||
line: ISeriesApi<'Line'> | null;
|
||||
signal: ISeriesApi<'Line'> | null;
|
||||
}>({ histogram: null, line: null, signal: null });
|
||||
|
||||
// processedData를 ref로도 유지 — 차트 생성 직후 즉시 데이터 적용 가능
|
||||
const processedDataRef = useRef(processedData);
|
||||
processedDataRef.current = processedData;
|
||||
|
||||
// 시리즈 데이터 적용 함수 (차트 생성 후 + processedData 변경 시 공유)
|
||||
const applySeriesData = useCallback((pd: any) => {
|
||||
if (!pd || !candlestickSeriesRef.current) return;
|
||||
|
||||
try {
|
||||
const allCandles = pd.candles;
|
||||
const totalBars = allCandles.length;
|
||||
const visibleRange = {
|
||||
from: allCandles[Math.max(0, totalBars - 100)].time,
|
||||
to: allCandles[totalBars - 1].time,
|
||||
};
|
||||
|
||||
// 메인 차트 데이터 교체
|
||||
candlestickSeriesRef.current.setData(pd.candles);
|
||||
const mainLines = mainLineSeriesRef.current;
|
||||
if (mainLines.length === 5) {
|
||||
mainLines[0].setData(pd.bollinger.upper);
|
||||
mainLines[1].setData(pd.bollinger.middle);
|
||||
mainLines[2].setData(pd.bollinger.lower);
|
||||
mainLines[3].setData(pd.sma5);
|
||||
mainLines[4].setData(pd.sma60);
|
||||
}
|
||||
mainChartInstanceRef.current?.timeScale().setVisibleRange(visibleRange);
|
||||
|
||||
// 거래량 데이터 교체
|
||||
volumeSeriesRef.current?.setData(pd.volumes);
|
||||
volumeChartInstanceRef.current?.timeScale().setVisibleRange(visibleRange);
|
||||
|
||||
// RSI 데이터 교체
|
||||
const rsiLines = rsiLineSeriesRef.current;
|
||||
if (rsiLines.length === 4) {
|
||||
rsiLines[0].setData(pd.rsi.data);
|
||||
rsiLines[1].setData(pd.rsi.line30);
|
||||
rsiLines[2].setData(pd.rsi.line50);
|
||||
rsiLines[3].setData(pd.rsi.line70);
|
||||
}
|
||||
rsiChartInstanceRef.current?.timeScale().setVisibleRange(visibleRange);
|
||||
|
||||
// MACD 데이터 교체
|
||||
macdSeriesRef.current.histogram?.setData(pd.macd.histogram);
|
||||
macdSeriesRef.current.line?.setData(pd.macd.line);
|
||||
macdSeriesRef.current.signal?.setData(pd.macd.signal);
|
||||
macdChartInstanceRef.current?.timeScale().setVisibleRange(visibleRange);
|
||||
} catch (error: unknown) {
|
||||
const err = error as Error | null;
|
||||
console.error('차트 데이터 업데이트 오류:', err?.message);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 차트 초기 생성 (마운트 시 1회만) — 생성 직후 대기 중인 데이터 즉시 적용
|
||||
const chartsCreatedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!mainChartRef.current ||
|
||||
!volumeChartRef.current ||
|
||||
!rsiChartRef.current ||
|
||||
!macdChartRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 이미 생성된 차트가 있으면 건너뛰기
|
||||
if (chartsCreatedRef.current) return;
|
||||
chartsCreatedRef.current = true;
|
||||
|
||||
const containerWidth = mainChartRef.current.clientWidth;
|
||||
|
||||
try {
|
||||
// === 1. 메인 차트 (캔들스틱 + 지표) ===
|
||||
// ─── 메인 캔들 차트 ───
|
||||
const mainChart = createChart(mainChartRef.current, {
|
||||
...getChartOptions(365),
|
||||
...getChartOptions(mainH, true),
|
||||
width: containerWidth,
|
||||
});
|
||||
|
||||
// 캔들스틱 시리즈 (한국 시장 색상)
|
||||
const candlestickSeries = mainChart.addCandlestickSeries({
|
||||
upColor: COLORS.candleUp,
|
||||
downColor: COLORS.candleDown,
|
||||
@@ -260,137 +357,51 @@ export const TradingChart = ({ data }: TradingChartProps) => {
|
||||
wickUpColor: COLORS.candleUp,
|
||||
wickDownColor: COLORS.candleDown,
|
||||
});
|
||||
candlestickSeries.setData((processedData as any)?.candles || []);
|
||||
|
||||
// 볼린저 밴드
|
||||
const bbUpperSeries = mainChart.addLineSeries({
|
||||
color: COLORS.bbLine,
|
||||
lineWidth: 1,
|
||||
});
|
||||
bbUpperSeries.setData((processedData as any).bollinger.upper);
|
||||
const bbUpperSeries = mainChart.addLineSeries({ color: COLORS.bbLine, lineWidth: 1 });
|
||||
const bbMiddleSeries = mainChart.addLineSeries({ color: COLORS.ma20, lineWidth: 1 });
|
||||
const bbLowerSeries = mainChart.addLineSeries({ color: COLORS.bbLine, lineWidth: 1 });
|
||||
const sma5Series = mainChart.addLineSeries({ color: COLORS.ma5, lineWidth: 2 });
|
||||
const sma60Series = mainChart.addLineSeries({ color: COLORS.ma60, lineWidth: 2 });
|
||||
|
||||
const bbMiddleSeries = mainChart.addLineSeries({
|
||||
color: COLORS.ma20,
|
||||
lineWidth: 1,
|
||||
});
|
||||
bbMiddleSeries.setData((processedData as any).bollinger.middle);
|
||||
mainChartInstanceRef.current = mainChart;
|
||||
candlestickSeriesRef.current = candlestickSeries;
|
||||
mainLineSeriesRef.current = [bbUpperSeries, bbMiddleSeries, bbLowerSeries, sma5Series, sma60Series];
|
||||
|
||||
const bbLowerSeries = mainChart.addLineSeries({
|
||||
color: COLORS.bbLine,
|
||||
lineWidth: 1,
|
||||
});
|
||||
bbLowerSeries.setData((processedData as any).bollinger.lower);
|
||||
|
||||
// 5일 이동평균선
|
||||
const sma5Series = mainChart.addLineSeries({
|
||||
color: COLORS.ma5,
|
||||
lineWidth: 2,
|
||||
});
|
||||
sma5Series.setData((processedData as any).sma5);
|
||||
|
||||
// 60일 이동평균선
|
||||
const sma60Series = mainChart.addLineSeries({
|
||||
color: COLORS.ma60,
|
||||
lineWidth: 2,
|
||||
});
|
||||
sma60Series.setData((processedData as any).sma60);
|
||||
|
||||
// === 2. 거래량 차트 ===
|
||||
// ─── 거래량 차트 ───
|
||||
const volumeChart = createChart(volumeChartRef.current, {
|
||||
...getChartOptions(67),
|
||||
...getChartOptions(volH, true),
|
||||
width: containerWidth,
|
||||
});
|
||||
const volumeSeries = volumeChart.addHistogramSeries({ priceFormat: { type: 'volume' } });
|
||||
volumeChartInstanceRef.current = volumeChart;
|
||||
volumeSeriesRef.current = volumeSeries;
|
||||
|
||||
const volumeSeries = volumeChart.addHistogramSeries({
|
||||
priceFormat: {
|
||||
type: 'volume',
|
||||
},
|
||||
});
|
||||
volumeSeries.setData((processedData as any).volumes);
|
||||
|
||||
// === 3. RSI 차트 ===
|
||||
// ─── RSI 차트 ───
|
||||
const rsiChart = createChart(rsiChartRef.current, {
|
||||
...getChartOptions(100),
|
||||
...getChartOptions(rsiH, true),
|
||||
width: containerWidth,
|
||||
});
|
||||
const rsiDataSeries = rsiChart.addLineSeries({ color: COLORS.rsi, lineWidth: 2 });
|
||||
const rsiLine30Series = rsiChart.addLineSeries({ color: COLORS.border, lineWidth: 1, lineStyle: 2, crosshairMarkerVisible: false });
|
||||
const rsiLine50Series = rsiChart.addLineSeries({ color: COLORS.border, lineWidth: 1, lineStyle: 2, crosshairMarkerVisible: false });
|
||||
const rsiLine70Series = rsiChart.addLineSeries({ color: COLORS.border, lineWidth: 1, lineStyle: 2, crosshairMarkerVisible: false });
|
||||
rsiChart.priceScale('right').applyOptions({ scaleMargins: { top: 0.1, bottom: 0.1 } });
|
||||
rsiChartInstanceRef.current = rsiChart;
|
||||
rsiLineSeriesRef.current = [rsiDataSeries, rsiLine30Series, rsiLine50Series, rsiLine70Series];
|
||||
|
||||
// RSI 라인
|
||||
const rsiSeries = rsiChart.addLineSeries({
|
||||
color: COLORS.rsi,
|
||||
lineWidth: 2,
|
||||
});
|
||||
rsiSeries.setData((processedData as any).rsi.data);
|
||||
|
||||
// RSI 기준선 (30, 50, 70)
|
||||
const rsiLine30Series = rsiChart.addLineSeries({
|
||||
color: COLORS.border,
|
||||
lineWidth: 1,
|
||||
lineStyle: 2, // dashed
|
||||
crosshairMarkerVisible: false,
|
||||
});
|
||||
rsiLine30Series.setData((processedData as any).rsi.line30);
|
||||
|
||||
const rsiLine50Series = rsiChart.addLineSeries({
|
||||
color: COLORS.border,
|
||||
lineWidth: 1,
|
||||
lineStyle: 2,
|
||||
crosshairMarkerVisible: false,
|
||||
});
|
||||
rsiLine50Series.setData((processedData as any).rsi.line50);
|
||||
|
||||
const rsiLine70Series = rsiChart.addLineSeries({
|
||||
color: COLORS.border,
|
||||
lineWidth: 1,
|
||||
lineStyle: 2,
|
||||
crosshairMarkerVisible: false,
|
||||
});
|
||||
rsiLine70Series.setData((processedData as any).rsi.line70);
|
||||
|
||||
// RSI Y축 범위 고정 (0-100)
|
||||
rsiChart.priceScale('right').applyOptions({
|
||||
scaleMargins: {
|
||||
top: 0.1,
|
||||
bottom: 0.1,
|
||||
},
|
||||
});
|
||||
|
||||
// === 4. MACD 차트 ===
|
||||
// ─── MACD 차트 ───
|
||||
const macdChart = createChart(macdChartRef.current, {
|
||||
...getChartOptions(100),
|
||||
...getChartOptions(macdH, false),
|
||||
width: containerWidth,
|
||||
});
|
||||
const macdHistSeries = macdChart.addHistogramSeries();
|
||||
const macdLineSeries = macdChart.addLineSeries({ color: COLORS.macd, lineWidth: 2 });
|
||||
const macdSignalSeries = macdChart.addLineSeries({ color: COLORS.signal, lineWidth: 2 });
|
||||
macdChartInstanceRef.current = macdChart;
|
||||
macdSeriesRef.current = { histogram: macdHistSeries, line: macdLineSeries, signal: macdSignalSeries };
|
||||
|
||||
// MACD 히스토그램
|
||||
const macdHistogramSeries = macdChart.addHistogramSeries();
|
||||
macdHistogramSeries.setData((processedData as any).macd.histogram);
|
||||
|
||||
// MACD 라인
|
||||
const macdLineSeries = macdChart.addLineSeries({
|
||||
color: COLORS.macd,
|
||||
lineWidth: 2,
|
||||
});
|
||||
macdLineSeries.setData((processedData as any).macd.line);
|
||||
|
||||
// Signal 라인
|
||||
const signalLineSeries = macdChart.addLineSeries({
|
||||
color: COLORS.signal,
|
||||
lineWidth: 2,
|
||||
});
|
||||
signalLineSeries.setData((processedData as any).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);
|
||||
@@ -399,116 +410,121 @@ export const TradingChart = ({ data }: TradingChartProps) => {
|
||||
}
|
||||
});
|
||||
|
||||
// === 모든 차트 설정 완료 후 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 });
|
||||
if (width === 0) return;
|
||||
mainChartInstanceRef.current?.applyOptions({ width });
|
||||
volumeChartInstanceRef.current?.applyOptions({ width });
|
||||
rsiChartInstanceRef.current?.applyOptions({ width });
|
||||
macdChartInstanceRef.current?.applyOptions({ width });
|
||||
};
|
||||
|
||||
window.addEventListener('resize', handleResize);
|
||||
|
||||
// Cleanup
|
||||
// 차트 생성 완료 — 이미 대기 중인 processedData가 있으면 즉시 적용
|
||||
if (processedDataRef.current) {
|
||||
applySeriesData(processedDataRef.current);
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
chartsCreatedRef.current = false;
|
||||
destroyAllCharts();
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
const err = error as Error | null;
|
||||
console.error('❌ 차트 렌더링 중 오류 발생:', error);
|
||||
console.error('오류 상세:', {
|
||||
message: err?.message || '알 수 없는 오류',
|
||||
stack: err?.stack,
|
||||
processedData: processedData,
|
||||
});
|
||||
console.error('차트 생성 오류:', err?.message);
|
||||
chartsCreatedRef.current = false;
|
||||
}
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// processedData 변경 시 — 시리즈 데이터만 교체 (차트 재생성 없음 → 깜빡임 방지)
|
||||
useEffect(() => {
|
||||
if (!processedData) return;
|
||||
// 차트가 아직 생성되지 않았으면 건너뛰기 (생성 완료 시 자동 적용됨)
|
||||
if (!chartsCreatedRef.current) return;
|
||||
applySeriesData(processedData);
|
||||
}, [processedData, applySeriesData]);
|
||||
|
||||
// 실시간 현재가 — 마지막 캔들만 update() (차트 재생성 없음)
|
||||
// ref 기반 polling: 리렌더 없이 500ms마다 최신 livePrice 반영
|
||||
const lastAppliedPriceRef = useRef<number | null>(null);
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => {
|
||||
const price = livePriceRef.current;
|
||||
if (!price || !candlestickSeriesRef.current || !processedData) return;
|
||||
if (price === lastAppliedPriceRef.current) return; // 같은 가격이면 skip
|
||||
const candles = processedData.candles;
|
||||
if (!candles || candles.length === 0) return;
|
||||
const last = candles[candles.length - 1];
|
||||
candlestickSeriesRef.current.update({
|
||||
time: last.time as any,
|
||||
open: last.open,
|
||||
high: Math.max(last.high, price),
|
||||
low: Math.min(last.low, price),
|
||||
close: price,
|
||||
});
|
||||
lastAppliedPriceRef.current = price;
|
||||
}, 500);
|
||||
return () => clearInterval(id);
|
||||
}, [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 ref={containerRef} className="w-full h-full relative bg-[#0b0e14] rounded-lg overflow-hidden">
|
||||
{/* 범례 */}
|
||||
<div className="absolute top-1 left-1 z-10 flex gap-1.5 text-[9px] bg-[#0b0e14]/80 px-1.5 py-0.5 rounded">
|
||||
<span className="text-[#fbbf24]">MA5</span>
|
||||
<span className="text-[#f6ad55]">MA20</span>
|
||||
<span className="text-[#10b981]">MA60</span>
|
||||
<span className="text-[#4a5568]">BB</span>
|
||||
<span className="text-gray-600">|</span>
|
||||
<span className="flex items-center gap-0.5"><span className="w-1.5 h-1.5 bg-[#ef4444] inline-block rounded-sm" />상승</span>
|
||||
<span className="flex items-center gap-0.5"><span className="w-1.5 h-1.5 bg-[#3b82f6] inline-block rounded-sm" />하락</span>
|
||||
</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>
|
||||
{/* 메인 차트 (캔들 + BB + MA) */}
|
||||
<div ref={mainChartRef} style={{ height: `${mainH}px` }} />
|
||||
|
||||
{/* 구분선 + 라벨 */}
|
||||
<div className="flex items-center px-1 bg-[#0b0e14]" style={{ height: `${LABEL_H}px` }}>
|
||||
<span className="text-[8px] text-gray-600">거래량</span>
|
||||
</div>
|
||||
|
||||
{/* 거래량 */}
|
||||
<div ref={volumeChartRef} style={{ height: `${volH}px` }} />
|
||||
|
||||
{/* 구분선 + 라벨 */}
|
||||
<div className="flex items-center px-1 bg-[#0b0e14]" style={{ height: `${LABEL_H}px` }}>
|
||||
<span className="text-[8px] text-[#a78bfa]">RSI(14)</span>
|
||||
<span className="ml-2 text-[8px] text-gray-600">30 / 50 / 70</span>
|
||||
</div>
|
||||
|
||||
{/* RSI */}
|
||||
<div ref={rsiChartRef} style={{ height: `${rsiH}px` }} />
|
||||
|
||||
{/* 구분선 + 라벨 */}
|
||||
<div className="flex items-center px-1 bg-[#0b0e14]" style={{ height: `${LABEL_H}px` }}>
|
||||
<span className="text-[8px] text-[#3b82f6]">MACD</span>
|
||||
<span className="ml-1.5 text-[8px] text-[#f97316]">Signal</span>
|
||||
</div>
|
||||
|
||||
{/* MACD */}
|
||||
<div ref={macdChartRef} style={{ height: `${macdH}px` }} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}, (prevProps, nextProps) => {
|
||||
// livePrice는 ref로 처리 — 리렌더 불필요 (비교 제외)
|
||||
// data 배열의 내용이 같으면 리렌더 건너뛰기 (참조가 달라도)
|
||||
if (prevProps.data === nextProps.data) return true;
|
||||
if (!prevProps.data || !nextProps.data) return false;
|
||||
if (prevProps.data.length !== nextProps.data.length) return false;
|
||||
// 첫/끝 캔들만 비교 (전체 deep compare 비용 절감)
|
||||
const pFirst = prevProps.data[0], nFirst = nextProps.data[0];
|
||||
const pLast = prevProps.data[prevProps.data.length - 1], nLast = nextProps.data[nextProps.data.length - 1];
|
||||
return pFirst.date === nFirst.date && pFirst.close === nFirst.close &&
|
||||
pLast.date === nLast.date && pLast.close === nLast.close;
|
||||
});
|
||||
|
||||
TradingChart.displayName = 'TradingChart';
|
||||
|
||||
@@ -1,65 +1,115 @@
|
||||
/**
|
||||
* useMarketData.ts — 실시간 시장 데이터 관리 커스텀 훅
|
||||
*
|
||||
* 역할:
|
||||
* - WebSocket tick/quote 메시지를 수신하여 종목별 실시간 가격 관리
|
||||
* - 선택 종목의 호가 데이터(liveQuote) 관리
|
||||
* - 포트폴리오 초기 가격 설정 (WebSocket 데이터 수신 전 폴백)
|
||||
*
|
||||
* 성능 최적화 패턴 (ref + 주기적 스냅샷):
|
||||
* - tick 수신 시 ref에만 저장 (리렌더 없음)
|
||||
* - 1초마다 dirty 체크 후 변경분만 state 반영
|
||||
* - Dashboard 리렌더를 초당 최대 1회로 제한하여 깜빡임 방지
|
||||
*
|
||||
* @param selectedStock - 현재 선택된 종목 코드 (호가 필터링용)
|
||||
* @param portfolio - 보유 종목 배열 (초기 가격 설정용)
|
||||
* @returns livePrices, livePricesRef, liveQuote, handleMarketUpdate
|
||||
*/
|
||||
import { useEffect, useState, useRef, useCallback } from 'react'
|
||||
|
||||
export const useMarketData = (
|
||||
selectedStock: string,
|
||||
portfolio: any[] | undefined
|
||||
) => {
|
||||
// Live prices with throttling to prevent excessive re-renders
|
||||
// === livePrices: ref + 주기적 스냅샷 패턴 ===
|
||||
// tick마다 ref에만 저장 → 1초마다 변경된 경우에만 state 업데이트
|
||||
// Dashboard 리렌더를 초당 최대 1회로 제한 (깜빡임 방지)
|
||||
const [livePrices, setLivePrices] = useState<Record<string, number>>({})
|
||||
/** 실시간 가격을 즉시 저장하는 ref (리렌더 없이 최신 가격 유지) */
|
||||
const livePricesRef = useRef<Record<string, number>>({})
|
||||
/** 가격 변경 여부 플래그 — flush interval에서 확인 */
|
||||
const pricesDirtyRef = useRef(false)
|
||||
|
||||
/** 선택 종목의 실시간 호가 데이터 (WebSocket quote 메시지) */
|
||||
const [liveQuote, setLiveQuote] = useState<any>(null)
|
||||
|
||||
// Throttle ref: track last update time per stock
|
||||
const lastPriceUpdateRef = useRef<Record<string, number>>({})
|
||||
const throttleDelayMs = 500 // Max 2-3 price updates per second (500ms = 2/sec)
|
||||
// 1초마다 변경된 가격을 ref에서 state로 flush
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => {
|
||||
if (pricesDirtyRef.current) {
|
||||
pricesDirtyRef.current = false
|
||||
setLivePrices({ ...livePricesRef.current })
|
||||
}
|
||||
}, 1000)
|
||||
return () => clearInterval(id)
|
||||
}, [])
|
||||
|
||||
// Handle market updates (tick data) with throttling
|
||||
/**
|
||||
* WebSocket market_update 메시지 핸들러
|
||||
*
|
||||
* tick 메시지: 체결가 업데이트 (ref에만 저장, 리렌더 없음)
|
||||
* quote 메시지: 호가 업데이트
|
||||
* - bid1(최우선 매수호가)을 가격으로 사용 (tick 없을 때 P&L 계산용)
|
||||
* - 선택 종목이면 liveQuote state 업데이트 (호가창 갱신)
|
||||
*/
|
||||
const handleMarketUpdate = useCallback((data: unknown) => {
|
||||
const d = data as any
|
||||
if (d?._type === 'tick' && d.stock_code && d.price != null) {
|
||||
const now = Date.now()
|
||||
const lastUpdate =
|
||||
lastPriceUpdateRef.current[d.stock_code] ?? 0
|
||||
|
||||
// Only update if throttle delay has passed
|
||||
if (now - lastUpdate >= throttleDelayMs) {
|
||||
setLivePrices((prev) => ({
|
||||
...prev,
|
||||
[d.stock_code]: d.price,
|
||||
}))
|
||||
lastPriceUpdateRef.current[d.stock_code] = now
|
||||
// 체결가 변경 시에만 ref 업데이트 + dirty 플래그 설정
|
||||
const prev = livePricesRef.current[d.stock_code]
|
||||
if (prev !== d.price) {
|
||||
livePricesRef.current[d.stock_code] = d.price
|
||||
pricesDirtyRef.current = true
|
||||
}
|
||||
} else if (d?._type === 'quote' && d.stock_code) {
|
||||
// 호가 데이터에서 현재가 추정 (bid1 = 최우선 매수호가)
|
||||
// tick 데이터가 없을 때 P&L 계산용 가격 업데이트
|
||||
const bidPrice = d.bid1 as number | undefined
|
||||
if (bidPrice && bidPrice > 0) {
|
||||
const prev = livePricesRef.current[d.stock_code]
|
||||
if (prev !== bidPrice) {
|
||||
livePricesRef.current[d.stock_code] = bidPrice
|
||||
pricesDirtyRef.current = true
|
||||
}
|
||||
}
|
||||
// 선택된 종목이면 호가창 업데이트 (OrderbookPanel 갱신)
|
||||
if (d.stock_code === selectedStock) {
|
||||
setLiveQuote(d)
|
||||
}
|
||||
} else if (
|
||||
d?._type === 'quote' &&
|
||||
d.stock_code === selectedStock
|
||||
) {
|
||||
setLiveQuote(d)
|
||||
}
|
||||
}, [selectedStock])
|
||||
|
||||
|
||||
// Initialize live prices from portfolio
|
||||
// 포트폴리오 로드 시 초기 가격 설정 (WebSocket 연결 전 가격 폴백)
|
||||
useEffect(() => {
|
||||
if (!portfolio) return
|
||||
setLivePrices((prev) => {
|
||||
const next = { ...prev }
|
||||
portfolio.forEach((s: any) => {
|
||||
if (s.current_price && !next[s.stock_code]) {
|
||||
next[s.stock_code] = Number(s.current_price)
|
||||
}
|
||||
})
|
||||
return next
|
||||
let changed = false
|
||||
portfolio.forEach((s: any) => {
|
||||
// 아직 실시간 가격이 없는 종목만 초기화
|
||||
if (s.current_price && !livePricesRef.current[s.stock_code]) {
|
||||
livePricesRef.current[s.stock_code] = Number(s.current_price)
|
||||
changed = true
|
||||
}
|
||||
})
|
||||
if (changed) {
|
||||
setLivePrices({ ...livePricesRef.current })
|
||||
}
|
||||
}, [portfolio])
|
||||
|
||||
// Clear live quote when selected stock changes
|
||||
// 선택 종목 변경 시 이전 호가 데이터 초기화
|
||||
useEffect(() => {
|
||||
setLiveQuote(null)
|
||||
}, [selectedStock])
|
||||
|
||||
|
||||
return {
|
||||
/** 종목별 실시간 가격 맵 (state — 1초마다 갱신) */
|
||||
livePrices,
|
||||
/** 종목별 실시간 가격 ref (리렌더 없이 즉시 접근) */
|
||||
livePricesRef,
|
||||
/** 선택 종목 실시간 호가 데이터 */
|
||||
liveQuote,
|
||||
/** WebSocket market_update 메시지 콜백 */
|
||||
handleMarketUpdate,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,37 @@
|
||||
/**
|
||||
* usePortfolioData.ts — 포트폴리오 데이터 관리 커스텀 훅
|
||||
*
|
||||
* 역할:
|
||||
* - /portfolio/combined API를 통해 보유 종목(holdings)과 요약(summary) 동시 조회
|
||||
* - 30초 간격 자동 폴링으로 장중 포트폴리오 변동 감지
|
||||
* - invalidatePortfolio()로 매매 체결 시 즉시 데이터 갱신 트리거
|
||||
*
|
||||
* 데이터 흐름:
|
||||
* API /portfolio/combined → { holdings: Position[], summary: PortfolioSummary }
|
||||
* → holdings에서 quantity > 0 필터 → portfolio(보유종목), portfolioSummary(요약)
|
||||
*
|
||||
* @returns portfolio, portfolioSummary, invalidatePortfolio 등
|
||||
*/
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import api from '../lib/api'
|
||||
import { Position, PortfolioSummary } from '../types/trading'
|
||||
|
||||
/** 포트폴리오 통합 데이터 인터페이스 */
|
||||
export interface PortfolioCombinedData {
|
||||
/** 보유 종목 배열 (quantity > 0 필터 적용) */
|
||||
holdings: Position[]
|
||||
/** 포트폴리오 요약 (총자산, 예수금, 손익 등) */
|
||||
summary: PortfolioSummary
|
||||
}
|
||||
|
||||
/**
|
||||
* usePortfolioData — 포트폴리오 통합 조회 훅
|
||||
*
|
||||
* react-query 설정:
|
||||
* - staleTime: 30초 (30초 이내 재요청 시 캐시 사용)
|
||||
* - refetchInterval: 30초 (장중 폴링)
|
||||
* - refetchOnWindowFocus: true (탭 복귀 시 즉시 갱신)
|
||||
*/
|
||||
export const usePortfolioData = () => {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
@@ -20,18 +45,27 @@ export const usePortfolioData = () => {
|
||||
queryFn: async () => {
|
||||
const data = (await api.get('/portfolio/combined')).data
|
||||
return {
|
||||
// quantity > 0인 종목만 보유 종목으로 처리 (매도 완료 종목 제외)
|
||||
holdings: (data.holdings as Position[]).filter(
|
||||
(item) => item.quantity > 0
|
||||
),
|
||||
summary: data.summary as PortfolioSummary,
|
||||
}
|
||||
},
|
||||
staleTime: Infinity,
|
||||
staleTime: 30000, // 30초 후 stale → 자동 리페치 허용
|
||||
refetchInterval: 30000, // 장중 30초 폴링 (매매 감지 보조)
|
||||
refetchOnWindowFocus: true, // 탭 전환 시 즉시 갱신
|
||||
})
|
||||
|
||||
/** 보유 종목 배열 */
|
||||
const portfolio = portfolioCombined?.holdings
|
||||
/** 포트폴리오 요약 데이터 */
|
||||
const portfolioSummary = portfolioCombined?.summary
|
||||
|
||||
/**
|
||||
* 포트폴리오 캐시 무효화 — 매매 체결 감지 시 즉시 호출
|
||||
* react-query가 자동으로 리페치를 트리거합니다.
|
||||
*/
|
||||
const invalidatePortfolio = () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['portfolio-combined'] })
|
||||
}
|
||||
|
||||
@@ -1,11 +1,36 @@
|
||||
/**
|
||||
* useWebSocket.ts — WebSocket 연결 관리 커스텀 훅
|
||||
*
|
||||
* 역할:
|
||||
* - 백엔드 WebSocket 서버와의 지속 연결 관리
|
||||
* - 자동 재연결 (지수 백오프: 3초 ~ 최대 30초, 무제한 재시도)
|
||||
* - 30초 간격 ping/pong 헬스 체크 (Nginx 프록시 타임아웃 방지)
|
||||
* - 메시지 타입별 분기 처리 (market_update, chart_data, 기타)
|
||||
*
|
||||
* 메시지 라우팅:
|
||||
* - market_update: 콜백(onMarketUpdate)으로만 전달 (배열 누적 방지)
|
||||
* - chart_data: CustomEvent로 전달 (React 상태 우회 — StrictMode 안전)
|
||||
* - 기타: messages state 배열에 추가 (최대 100개 유지)
|
||||
*
|
||||
* StrictMode 안전:
|
||||
* - socketRef로 활성 WS 추적 → stale onclose/onopen 이벤트 무시
|
||||
* - mountedRef로 언마운트 후 재연결 방지
|
||||
*
|
||||
* @param clientId - 고유 클라이언트 ID (탭별 구분, 서버 덮어쓰기 방지)
|
||||
* @param options - 메시지 타입별 콜백 (onMarketUpdate, onChartData)
|
||||
* @returns { messages, connected, sendMessage }
|
||||
*/
|
||||
import { useEffect, useState, useCallback, useRef } from 'react'
|
||||
import { config } from '../config'
|
||||
|
||||
/** WebSocket 메시지 기본 구조 */
|
||||
interface WebSocketMessage {
|
||||
type: string
|
||||
data: unknown
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
/** useWebSocket 옵션 인터페이스 */
|
||||
interface UseWebSocketOptions {
|
||||
/** market_update 메시지 전용 콜백 (배열 누적 방지 — 실시간 틱 전용) */
|
||||
onMarketUpdate?: (data: unknown) => void
|
||||
@@ -25,23 +50,43 @@ export const useWebSocket = (clientId: string, options?: UseWebSocketOptions) =>
|
||||
// 콜백을 ref로 유지 — connect 클로저가 재생성되지 않아도 최신 콜백 참조
|
||||
const onMarketUpdateRef = useRef(options?.onMarketUpdate)
|
||||
const onChartDataRef = useRef(options?.onChartData)
|
||||
// 렌더 중 동기적으로 ref 업데이트 (useEffect 대기 없이 즉시 반영)
|
||||
onMarketUpdateRef.current = options?.onMarketUpdate
|
||||
onChartDataRef.current = options?.onChartData
|
||||
/** 재연결 기본 딜레이 (3초, 지수 백오프 적용) */
|
||||
const baseDelay = 3000
|
||||
/** 마지막 pong 수신 시각 (타임아웃 판단용) */
|
||||
const lastPongReceivedRef = useRef<number>(Date.now())
|
||||
const pongTimeoutMs = 10000 // 10초 이내에 pong을 받지 못하면 연결 끊김으로 간주
|
||||
|
||||
// options 변경 시 ref 동기화
|
||||
useEffect(() => {
|
||||
onMarketUpdateRef.current = options?.onMarketUpdate
|
||||
onChartDataRef.current = options?.onChartData
|
||||
})
|
||||
/** pong 타임아웃 (30초 이내 미수신 시 연결 끊김 간주) */
|
||||
const pongTimeoutMs = 30000
|
||||
|
||||
/**
|
||||
* WebSocket 연결 생성 함수
|
||||
*
|
||||
* 연결 흐름:
|
||||
* 1. 기존 연결 정리 (이중 연결 방지)
|
||||
* 2. API 키 포함 WS URL로 연결 시도
|
||||
* 3. onopen: 연결 성공, ping 인터벌 시작
|
||||
* 4. onmessage: 메시지 타입별 라우팅
|
||||
* 5. onclose: 재연결 스케줄링 (지수 백오프)
|
||||
*/
|
||||
const connect = useCallback(() => {
|
||||
if (!mountedRef.current) return
|
||||
|
||||
// 기존 연결이 남아있으면 정리 (이중 연결 방지)
|
||||
if (socketRef.current) {
|
||||
try { socketRef.current.close() } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
try {
|
||||
const ws = new WebSocket(`${config.wsBaseUrl}/ws/${clientId}`)
|
||||
const apiKey = localStorage.getItem('api_key') || ''
|
||||
const ws = new WebSocket(`${config.wsBaseUrl}/ws/${clientId}?token=${encodeURIComponent(apiKey)}`)
|
||||
// 즉시 socketRef에 할당 — onclose 가드가 null 상태를 보지 않도록
|
||||
socketRef.current = ws
|
||||
|
||||
ws.onopen = () => {
|
||||
// StrictMode guard: 이미 다른 WS가 활성화되었으면 이 이벤트 무시
|
||||
if (socketRef.current !== ws) { ws.close(); return }
|
||||
console.log('🔗 WebSocket connected')
|
||||
setConnected(true)
|
||||
reconnectAttemptsRef.current = 0
|
||||
@@ -52,7 +97,7 @@ export const useWebSocket = (clientId: string, options?: UseWebSocketOptions) =>
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: 'ping' }))
|
||||
|
||||
// Pong 타임아웃 설정 (10초 이내에 pong 응답이 없으면 연결 끊김으로 간주)
|
||||
// Pong 타임아웃 설정 (30초 이내에 pong 응답이 없으면 연결 끊김으로 간주)
|
||||
if (pongTimeoutRef.current) {
|
||||
clearTimeout(pongTimeoutRef.current)
|
||||
}
|
||||
@@ -87,13 +132,14 @@ export const useWebSocket = (clientId: string, options?: UseWebSocketOptions) =>
|
||||
return
|
||||
}
|
||||
|
||||
// chart_data: 콜백으로만 전달 (배열 누적 방지 — 차트 로드 전용)
|
||||
// chart_data: CustomEvent로 직접 전달 (React 상태 우회 — StrictMode 타이밍 이슈 방지)
|
||||
if (message.type === 'chart_data') {
|
||||
onChartDataRef.current?.(message)
|
||||
console.log('📬 chart_data → CustomEvent', (message as any).stock_code)
|
||||
window.dispatchEvent(new CustomEvent('ws-chart-data', { detail: message }))
|
||||
return
|
||||
}
|
||||
|
||||
setMessages((prev: WebSocketMessage[]) => [...prev, message])
|
||||
setMessages((prev: WebSocketMessage[]) => [...prev.slice(-99), message])
|
||||
} catch (error) {
|
||||
console.error('Failed to parse WebSocket message:', error)
|
||||
}
|
||||
@@ -104,6 +150,10 @@ export const useWebSocket = (clientId: string, options?: UseWebSocketOptions) =>
|
||||
}
|
||||
|
||||
ws.onclose = () => {
|
||||
// StrictMode guard: 이미 다른 WS가 활성화되었으면 stale 이벤트 무시
|
||||
// (cleanup에서 닫힌 WS의 onclose가 새 WS에 영향 주는 것을 방지)
|
||||
if (socketRef.current !== null && socketRef.current !== ws) return
|
||||
|
||||
console.log('🔌 WebSocket disconnected')
|
||||
setConnected(false)
|
||||
socketRef.current = null
|
||||
@@ -130,7 +180,6 @@ export const useWebSocket = (clientId: string, options?: UseWebSocketOptions) =>
|
||||
}
|
||||
}
|
||||
|
||||
socketRef.current = ws
|
||||
} catch (error) {
|
||||
console.error('Failed to create WebSocket:', error)
|
||||
}
|
||||
@@ -157,6 +206,10 @@ export const useWebSocket = (clientId: string, options?: UseWebSocketOptions) =>
|
||||
}
|
||||
}, [connect])
|
||||
|
||||
/**
|
||||
* WebSocket 메시지 전송 함수
|
||||
* 연결이 OPEN 상태일 때만 전송 (그 외 상태에서는 무시)
|
||||
*/
|
||||
const sendMessage = useCallback((message: string) => {
|
||||
if (socketRef.current && socketRef.current.readyState === WebSocket.OPEN) {
|
||||
socketRef.current.send(message)
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
/* Custom Scrollbar for Dashboard containers */
|
||||
/**
|
||||
* Dashboard.css — 대시보드 페이지 전용 스타일
|
||||
*
|
||||
* 역할:
|
||||
* - 커스텀 스크롤바 스타일 정의 (다크 테마에 맞춘 3px 슬림 스크롤바)
|
||||
* - .custom-scrollbar 클래스를 가진 요소에 적용됨
|
||||
* - 주로 포트폴리오 패널, 추천 종목 패널, 매매일지 패널 등 스크롤 영역에 사용
|
||||
*/
|
||||
|
||||
/* Custom Scrollbar — Professional Dark */
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
width: 3px;
|
||||
height: 3px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-track {
|
||||
@@ -9,10 +18,10 @@
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: #374151; /* gray-700 */
|
||||
border-radius: 3px;
|
||||
background: #1e3050;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: #4b5563; /* gray-600 */
|
||||
background: #2b4570;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,24 @@
|
||||
/**
|
||||
* Dashboard.tsx — 메인 대시보드 페이지 컴포넌트
|
||||
*
|
||||
* 역할:
|
||||
* - 전체 트레이딩 대시보드의 상태 관리 및 레이아웃 구성
|
||||
* - WebSocket을 통한 실시간 시장 데이터 및 차트 데이터 수신
|
||||
* - 포트폴리오, 차트, 호가, 추천종목, 매매내역, AI분석 패널 통합
|
||||
*
|
||||
* 레이아웃 (12컬럼 그리드):
|
||||
* [2col] 보유종목 | [7col] 차트+AI분석+호가 | [3col] 추천종목+매매내역
|
||||
*
|
||||
* 데이터 흐름:
|
||||
* - WebSocket: 실시간 tick/quote → useMarketData, 차트 데이터 → processChartData
|
||||
* - react-query: 포트폴리오(30s), 호가(10s), 추천종목(2min), 매매내역(30s), 일지(이벤트)
|
||||
* - 매매 감지: tradeHistory 폴링에서 새 거래 ID 감지 → 포트폴리오 즉시 갱신
|
||||
*
|
||||
* 성능 최적화:
|
||||
* - 각 패널은 React.memo로 불필요한 리렌더 방지
|
||||
* - ref 기반 stale closure 방지 (selectedStockRef, chartPeriodRef 등)
|
||||
* - useCallback/useMemo로 콜백 및 파생 데이터 안정화
|
||||
*/
|
||||
import { useEffect, useState, useRef, useMemo, useCallback } from 'react'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import api from '../lib/api'
|
||||
@@ -11,31 +32,64 @@ import ChartPanel, { AiAnalysisPanel } from '../components/ChartPanel'
|
||||
import OrderbookPanel from '../components/OrderbookPanel'
|
||||
import RecommendationPanel from '../components/RecommendationPanel'
|
||||
import JournalPanel from '../components/JournalPanel'
|
||||
import { CandleData, TradeHistory } from '../types/trading'
|
||||
import { CandleData, Position, TradeHistory } from '../types/trading'
|
||||
import { isUsStock } from '../utils/stockUtils'
|
||||
import './Dashboard.css'
|
||||
|
||||
// 마켓 운영 시간 체크 (한국장 + 미국장)
|
||||
/**
|
||||
* 마켓 운영 시간 체크 (한국장 + 미국장)
|
||||
*
|
||||
* 한국장: 09:00~15:30 KST (540분~930분)
|
||||
* 미국장: 22:30~06:00 KST (1350분~다음날 360분)
|
||||
*
|
||||
* 장 운영 여부에 따라 API 폴링 간격이 달라짐:
|
||||
* - 장중: 빈번한 폴링 (호가 10초, 매매내역 30초 등)
|
||||
* - 장외: 폴링 중단 또는 간격 확대
|
||||
*
|
||||
* @returns 한국장 또는 미국장이 운영 중이면 true
|
||||
*/
|
||||
const checkMarketOpen = () => {
|
||||
const now = new Date();
|
||||
const kstTime = new Date(now.toLocaleString("en-US", { timeZone: "Asia/Seoul" }));
|
||||
const day = kstTime.getDay();
|
||||
const day = kstTime.getDay(); // 0: 일, 6: 토
|
||||
const currentMinutes = kstTime.getHours() * 60 + kstTime.getMinutes();
|
||||
|
||||
// 일요일 또는 토요일 07:00 이후 → 장 종료
|
||||
if (day === 0 || (day === 6 && currentMinutes >= 420)) return false;
|
||||
|
||||
// 한국장: 09:00(540분) ~ 15:30(930분)
|
||||
const isKoreaOpen = currentMinutes >= 540 && currentMinutes <= 930;
|
||||
// 미국장: 22:30(1350분) ~ 익일 06:00(360분) KST 기준
|
||||
const isUSOpen = currentMinutes >= 1350 || currentMinutes <= 360;
|
||||
|
||||
// 금요일 22:30 이후 → 미국장 개장
|
||||
if (day === 5 && currentMinutes >= 1350) return true;
|
||||
// 토요일 06:00 이전 → 미국장 진행 중
|
||||
if (day === 6 && currentMinutes <= 360) return true;
|
||||
|
||||
return isKoreaOpen || isUSOpen;
|
||||
};
|
||||
|
||||
/** 마켓 필터 타입 (전체/국내/미국) */
|
||||
type MarketFilter = 'ALL' | 'KR' | 'US'
|
||||
|
||||
/**
|
||||
* Dashboard — 메인 대시보드 컴포넌트
|
||||
*
|
||||
* 주요 상태:
|
||||
* - marketFilter: 마켓 필터 (ALL/KR/US)
|
||||
* - selectedStock: 선택된 종목 코드 (localStorage 저장)
|
||||
* - chartData/chartLoading: 차트 데이터 및 로딩 상태
|
||||
* - chartPeriod: 차트 기간 (D/W/M)
|
||||
*/
|
||||
const Dashboard = () => {
|
||||
const [marketFilter, setMarketFilter] = useState<MarketFilter>('ALL')
|
||||
|
||||
// 차트 데이터 (WebSocket chart_data 메시지로 로드)
|
||||
const [chartData, setChartData] = useState<CandleData[] | null>(null)
|
||||
const chartDataRef = useRef<CandleData[] | null>(null)
|
||||
const [chartLoading, setChartLoading] = useState(false)
|
||||
const chartLoadingRef = useRef(false)
|
||||
// selectedStock / chartPeriod을 콜백 클로저에서 안전하게 참조하기 위한 ref
|
||||
const selectedStockRef = useRef<string>('')
|
||||
const chartPeriodRef = useRef<'D' | 'W' | 'M'>('D')
|
||||
@@ -81,50 +135,110 @@ const Dashboard = () => {
|
||||
portfolio
|
||||
)
|
||||
|
||||
// WebSocket hook - pass both market and chart data handlers
|
||||
const { connected: wsConnected, messages: wsMessages, sendMessage } =
|
||||
useWebSocket('dashboard', {
|
||||
onMarketUpdate: handleMarketUpdate,
|
||||
onChartData: (msg: unknown) => {
|
||||
const m = msg as Record<string, unknown>
|
||||
// 응답이 현재 선택 종목/기간과 일치할 때만 반영 (레이스 컨디션 방지)
|
||||
if (
|
||||
m?.stock_code !== selectedStockRef.current ||
|
||||
m?.period !== chartPeriodRef.current
|
||||
)
|
||||
return
|
||||
const data = ((m.data as Record<string, unknown>[]) || [])
|
||||
.map((item: Record<string, unknown>) => ({
|
||||
date: item.date as string,
|
||||
open: Number(item.open),
|
||||
high: Number(item.high),
|
||||
low: Number(item.low),
|
||||
close: Number(item.close),
|
||||
volume: Number(item.volume),
|
||||
}))
|
||||
.reverse()
|
||||
setChartData(data)
|
||||
setChartLoading(false)
|
||||
},
|
||||
// chart_data 메시지 처리 함수 (wsMessages useEffect에서 호출)
|
||||
const processChartData = useCallback((m: Record<string, unknown>) => {
|
||||
console.log('📊 processChartData called', {
|
||||
msgStock: m?.stock_code,
|
||||
msgPeriod: m?.period,
|
||||
refStock: selectedStockRef.current,
|
||||
refPeriod: chartPeriodRef.current,
|
||||
})
|
||||
// 응답이 현재 선택 종목/기간과 일치할 때만 반영 (레이스 컨디션 방지)
|
||||
if (
|
||||
m?.stock_code !== selectedStockRef.current ||
|
||||
m?.period !== chartPeriodRef.current
|
||||
) {
|
||||
console.log('📊 processChartData SKIP — stock/period mismatch')
|
||||
return
|
||||
}
|
||||
const rawData = (m.data as Record<string, unknown>[]) || []
|
||||
if (rawData.length === 0) {
|
||||
chartDataRef.current = null
|
||||
setChartData(null)
|
||||
chartLoadingRef.current = false
|
||||
setChartLoading(false)
|
||||
return
|
||||
}
|
||||
const data = rawData.map((item: Record<string, unknown>) => ({
|
||||
date: item.date as string,
|
||||
open: Number(item.open),
|
||||
high: Number(item.high),
|
||||
low: Number(item.low),
|
||||
close: Number(item.close),
|
||||
volume: Number(item.volume),
|
||||
}))
|
||||
// 동일 데이터면 setState 건너뛰기 (차트 재생성 방지)
|
||||
const prev = chartDataRef.current
|
||||
const isSame = prev && prev.length === data.length &&
|
||||
prev.length > 0 &&
|
||||
prev[0].date === data[0].date &&
|
||||
prev[prev.length - 1].date === data[data.length - 1].date &&
|
||||
prev[prev.length - 1].close === data[data.length - 1].close
|
||||
if (!isSame) {
|
||||
chartDataRef.current = data
|
||||
setChartData(data)
|
||||
}
|
||||
if (chartLoadingRef.current) {
|
||||
chartLoadingRef.current = false
|
||||
setChartLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// WebSocket hook - market_update는 콜백, chart_data는 messages로 처리
|
||||
// 탭마다 고유 client_id 부여 (동일 ID로 2개 연결 시 서버에서 이전 연결 덮어쓰기 → 끊김 방지)
|
||||
const [wsClientId] = useState(() => `dashboard_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`)
|
||||
const { connected: wsConnected, messages: wsMessages, sendMessage } =
|
||||
useWebSocket(wsClientId, {
|
||||
onMarketUpdate: handleMarketUpdate,
|
||||
})
|
||||
|
||||
// 차트 요청 헬퍼용 ref — useWebSocket 호출 직후 정의 (사용 useEffect보다 앞에 위치해야 함)
|
||||
const wsConnectedRef = useRef(wsConnected)
|
||||
wsConnectedRef.current = wsConnected
|
||||
const sendMessageRef = useRef(sendMessage)
|
||||
sendMessageRef.current = sendMessage
|
||||
|
||||
// chart_data: CustomEvent 리스너 (React 상태 우회 — StrictMode 타이밍 이슈 방지)
|
||||
useEffect(() => {
|
||||
const handler = (e: Event) => {
|
||||
const detail = (e as CustomEvent).detail
|
||||
console.log('📨 CustomEvent chart_data 수신', detail?.stock_code)
|
||||
processChartData(detail as Record<string, unknown>)
|
||||
}
|
||||
window.addEventListener('ws-chart-data', handler)
|
||||
return () => window.removeEventListener('ws-chart-data', handler)
|
||||
}, [processChartData])
|
||||
|
||||
// Trade actions hook
|
||||
const { handleSell, resetPendingTrades } = useTradeActions()
|
||||
|
||||
// Subscribe to market data via WebSocket
|
||||
// reconnect 감지: wsConnected가 false→true 전환 시 강제 재구독
|
||||
const prevSubCodesRef = useRef<string>('')
|
||||
const prevWsConnectedRef = useRef(false)
|
||||
useEffect(() => {
|
||||
if (!wsConnected) return
|
||||
if (!wsConnected) {
|
||||
prevWsConnectedRef.current = false
|
||||
return
|
||||
}
|
||||
const isReconnect = !prevWsConnectedRef.current
|
||||
prevWsConnectedRef.current = true
|
||||
|
||||
const codes = [
|
||||
...new Set([
|
||||
...(portfolio || []).map((s: any) => s.stock_code as string),
|
||||
...(selectedStock ? [selectedStock] : []),
|
||||
]),
|
||||
]
|
||||
].sort()
|
||||
if (codes.length === 0) return
|
||||
sendMessage(
|
||||
const key = codes.join(',')
|
||||
// reconnect 시 강제 재전송, 그 외엔 변경 시에만
|
||||
if (!isReconnect && key === prevSubCodesRef.current) return
|
||||
prevSubCodesRef.current = key
|
||||
sendMessageRef.current(
|
||||
JSON.stringify({ type: 'set_subscriptions', stock_codes: codes })
|
||||
)
|
||||
}, [wsConnected, portfolio, selectedStock, sendMessage])
|
||||
}, [wsConnected, portfolio, selectedStock])
|
||||
|
||||
// 호가창 - REST fallback (WebSocket quote로 실시간 갱신)
|
||||
const { data: orderbook } = useQuery({
|
||||
@@ -145,18 +259,15 @@ const Dashboard = () => {
|
||||
}
|
||||
},
|
||||
enabled: Boolean(selectedStock),
|
||||
staleTime: Infinity,
|
||||
staleTime: isMarketOpen ? 5000 : 60000,
|
||||
refetchInterval: (query) => {
|
||||
// 500 에러 시 폴링 간격을 60초로 늘려 서버 부하 방지
|
||||
if (query.state.error) return 60000
|
||||
return isMarketOpen ? 10000 : false
|
||||
},
|
||||
retry: 1,
|
||||
})
|
||||
|
||||
// MCP 상태 - 30초 (UI 정보용, 느려도 무방)
|
||||
useQuery({
|
||||
queryKey: ['mcp-status'],
|
||||
queryFn: async () => (await api.get('/mcp/status')).data,
|
||||
refetchInterval: isMarketOpen ? 30000 : false,
|
||||
staleTime: 20000,
|
||||
})
|
||||
|
||||
// 추천 종목 (배치 분석 결과) - 2분 간격 (배치 주기 10분이므로 잦은 호출 불필요)
|
||||
// trades_executed 발생 시: portfolio + trade-history 즉시 invalidate
|
||||
const {
|
||||
@@ -195,21 +306,49 @@ const Dashboard = () => {
|
||||
refetchOnWindowFocus: false,
|
||||
})
|
||||
|
||||
// 차트 데이터 — WebSocket get_chart 요청으로 로드 (REST useQuery 대체)
|
||||
// selectedStock / chartPeriod / wsConnected 변경 시 자동 재요청
|
||||
useEffect(() => {
|
||||
if (!selectedStock || !wsConnected) return
|
||||
setChartLoading(true)
|
||||
setChartData(null)
|
||||
sendMessage(
|
||||
// 차트 요청 헬퍼 — ref 기반으로 의존성 최소화 (리렌더 방지)
|
||||
const requestChartRef = useRef((stock: string, period: string): boolean => {
|
||||
console.log('📤 requestChart', { stock, period, wsConnected: wsConnectedRef.current })
|
||||
if (!stock || !wsConnectedRef.current) return false
|
||||
sendMessageRef.current(
|
||||
JSON.stringify({
|
||||
type: 'get_chart',
|
||||
stock_code: selectedStock,
|
||||
period: chartPeriod,
|
||||
limit: 100,
|
||||
stock_code: stock,
|
||||
period: period,
|
||||
limit: 200,
|
||||
})
|
||||
)
|
||||
}, [selectedStock, chartPeriod, wsConnected, sendMessage])
|
||||
console.log('📤 requestChart SENT')
|
||||
return true
|
||||
})
|
||||
// ref 내용은 항상 최신 — useCallback 불필요 (의존성 변경에 의한 리렌더 차단)
|
||||
|
||||
// 차트 데이터 — selectedStock / chartPeriod 변경 시 요청
|
||||
useEffect(() => {
|
||||
if (!selectedStock) {
|
||||
chartDataRef.current = null
|
||||
setChartData(null)
|
||||
chartLoadingRef.current = false
|
||||
setChartLoading(false)
|
||||
return
|
||||
}
|
||||
const sent = requestChartRef.current(selectedStock, chartPeriod)
|
||||
if (sent) {
|
||||
chartLoadingRef.current = true
|
||||
setChartLoading(true)
|
||||
}
|
||||
}, [selectedStock, chartPeriod])
|
||||
|
||||
// WebSocket (재)연결 시 — 차트 데이터 요청
|
||||
useEffect(() => {
|
||||
if (!selectedStock || !wsConnected) return
|
||||
// 이미 데이터가 있으면 로딩 표시 안 함 (조용히 갱신)
|
||||
if (!chartDataRef.current) {
|
||||
chartLoadingRef.current = true
|
||||
setChartLoading(true)
|
||||
}
|
||||
requestChartRef.current(selectedStock, chartPeriod)
|
||||
}, [wsConnected]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// 거래 이력 - 30초 (자동매매 체결 감지용)
|
||||
const { data: tradeHistory } = useQuery({
|
||||
@@ -269,19 +408,25 @@ const Dashboard = () => {
|
||||
// WebSocket 이벤트 처리
|
||||
// - trade_executed: 체결 즉시 포트폴리오 갱신 (백엔드에서 broadcast 필요)
|
||||
// - journal_created: 매매일지 즉시 갱신
|
||||
// - processedCountRef로 미처리 메시지를 모두 순회 (동시 이벤트 누락 방지)
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
const processedCountRef = useRef(0)
|
||||
useEffect(() => {
|
||||
if (wsMessages.length === 0) return
|
||||
const latest = wsMessages[wsMessages.length - 1]
|
||||
if (wsMessages.length <= processedCountRef.current) return
|
||||
|
||||
if (latest.type === 'trade_executed') {
|
||||
console.log('🔔 WebSocket trade_executed → 포트폴리오 즉시 갱신')
|
||||
invalidatePortfolio()
|
||||
queryClient.invalidateQueries({ queryKey: ['trade-history'] })
|
||||
}
|
||||
const unprocessed = wsMessages.slice(processedCountRef.current)
|
||||
processedCountRef.current = wsMessages.length
|
||||
|
||||
if (latest.type === 'journal_created') {
|
||||
queryClient.invalidateQueries({ queryKey: ['journals'] })
|
||||
for (const msg of unprocessed) {
|
||||
if (msg.type === 'trade_executed') {
|
||||
console.log('WebSocket trade_executed -> portfolio refresh')
|
||||
invalidatePortfolio()
|
||||
queryClient.invalidateQueries({ queryKey: ['trade-history'] })
|
||||
}
|
||||
|
||||
if (msg.type === 'journal_created') {
|
||||
queryClient.invalidateQueries({ queryKey: ['journals'] })
|
||||
}
|
||||
}
|
||||
}, [wsMessages, invalidatePortfolio, queryClient])
|
||||
|
||||
@@ -297,12 +442,13 @@ const Dashboard = () => {
|
||||
return sum + price * Number(s.quantity || 0)
|
||||
}, 0)
|
||||
|
||||
// 총 매입금액 (평균단가 × 수량)
|
||||
const totalInvestment = portfolio.reduce(
|
||||
// 총 매입금액 (평균단가 × 수량) — average_price null 시 백엔드 summary 값 사용
|
||||
const computedInvestment = portfolio.reduce(
|
||||
(sum, s) =>
|
||||
sum + Number(s.average_price || 0) * Number(s.quantity || 0),
|
||||
0
|
||||
)
|
||||
const totalInvestment = computedInvestment || Number(portfolioSummary.total_investment || 0)
|
||||
|
||||
// 평가손익 = 주식 평가금액 - 매입금액
|
||||
const liveProfitLoss = liveValue - totalInvestment
|
||||
@@ -311,13 +457,13 @@ const Dashboard = () => {
|
||||
? (liveProfitLoss / totalInvestment) * 100
|
||||
: 0
|
||||
|
||||
// 총 자산 = KIS tot_evlu_amt(공식 총평가금액) 기준 + 실시간 주식평가 변동분 반영
|
||||
// KIS tot_evlu_amt는 D+2 정산 등을 포함한 정확한 값이므로 base로 사용하고
|
||||
// 주식 평가분만 live 가격으로 보정 (stock_value_from_api = scts_evlu_amt)
|
||||
// 총 자산 = KIS tot_evlu_amt 기준으로 실시간 주식평가 변동분 보정
|
||||
// stock_value_from_api(scts_evlu_amt)가 0이면 예수금+실시간주식평가로 단순 계산
|
||||
const backendStockValue = portfolioSummary.stock_value_from_api || 0
|
||||
const liveTotal =
|
||||
(portfolioSummary.total_assets || 0) +
|
||||
(liveValue - backendStockValue)
|
||||
backendStockValue > 0
|
||||
? (portfolioSummary.total_assets || 0) + (liveValue - backendStockValue)
|
||||
: Number(portfolioSummary.available_cash || 0) + liveValue
|
||||
|
||||
// 금일 손익도 live 주식 변동분 반영
|
||||
const bfdyTotal = portfolioSummary.bfdy_total_assets || 0
|
||||
@@ -342,23 +488,46 @@ const Dashboard = () => {
|
||||
}
|
||||
}, [portfolioSummary, portfolio, livePrices])
|
||||
|
||||
// 실시간 차트: 마지막 캔들에 live price 반영 (close/high/low)
|
||||
// livePrice가 selectedStock에 대해서만 변경될 때 재계산
|
||||
// 최근 거래일 매매 통계 — tradeHistory 기반 실시간 집계
|
||||
// 오늘 체결 거래가 없으면 가장 최근 체결일 기준으로 집계
|
||||
// API 필드: order_type(BUY/SELL), total_amount(체결금액), executed_price, executed_quantity
|
||||
const todayTradeStats = useMemo(() => {
|
||||
if (!tradeHistory) return null
|
||||
const DONE_STATUSES = ['EXECUTED', 'COMPLETED', 'FILLED']
|
||||
const executedTrades = tradeHistory.filter(
|
||||
(t: TradeHistory) => DONE_STATUSES.includes(t.status)
|
||||
)
|
||||
if (executedTrades.length === 0) return { buyAmount: 0, sellAmount: 0, realizedPL: 0, count: 0 }
|
||||
|
||||
// 오늘 거래 우선, 없으면 가장 최근 체결일 사용
|
||||
const todayStr = new Date().toDateString()
|
||||
const getDateStr = (t: TradeHistory) =>
|
||||
new Date(t.executed_at || t.created_at || '').toDateString()
|
||||
const hasTodayTrades = executedTrades.some((t: TradeHistory) => getDateStr(t) === todayStr)
|
||||
const targetDateStr = hasTodayTrades
|
||||
? todayStr
|
||||
: getDateStr(executedTrades[0]) // 최신순 정렬이므로 첫 번째가 최근
|
||||
const todayTrades = executedTrades.filter(
|
||||
(t: TradeHistory) => getDateStr(t) === targetDateStr
|
||||
)
|
||||
const toAmount = (t: TradeHistory) =>
|
||||
Number(t.total_amount || 0) ||
|
||||
Number(t.executed_price || 0) * Number(t.executed_quantity || 0)
|
||||
const orderType = (t: TradeHistory) => t.order_type || t.trade_type
|
||||
const buyAmount = todayTrades
|
||||
.filter((t: TradeHistory) => orderType(t) === 'BUY')
|
||||
.reduce((sum: number, t: TradeHistory) => sum + toAmount(t), 0)
|
||||
const sellAmount = todayTrades
|
||||
.filter((t: TradeHistory) => orderType(t) === 'SELL')
|
||||
.reduce((sum: number, t: TradeHistory) => sum + toAmount(t), 0)
|
||||
const realizedPL = todayTrades
|
||||
.filter((t: TradeHistory) => orderType(t) === 'SELL')
|
||||
.reduce((sum: number, t: TradeHistory) => sum + Number(t.profit_loss || 0), 0)
|
||||
const isToday = targetDateStr === todayStr
|
||||
return { buyAmount, sellAmount, realizedPL, count: todayTrades.length, isToday, dateStr: targetDateStr }
|
||||
}, [tradeHistory])
|
||||
|
||||
const livePrice = livePrices[selectedStock]
|
||||
const liveChartData = useMemo<CandleData[] | null>(() => {
|
||||
if (!chartData || chartData.length === 0 || !livePrice)
|
||||
return chartData ?? null
|
||||
const last = chartData[chartData.length - 1]
|
||||
return [
|
||||
...chartData.slice(0, -1),
|
||||
{
|
||||
...last,
|
||||
close: livePrice,
|
||||
high: Math.max(last.high, livePrice),
|
||||
low: Math.min(last.low, livePrice),
|
||||
},
|
||||
]
|
||||
}, [chartData, livePrice])
|
||||
|
||||
// 실시간 호가: WebSocket quote(H0STASP0) → REST 초기값 폴백
|
||||
const activeOrderbook = useMemo(() => {
|
||||
@@ -385,6 +554,15 @@ const Dashboard = () => {
|
||||
}
|
||||
}, [liveQuote, livePrice, orderbook])
|
||||
|
||||
// 마켓 필터 적용된 포트폴리오
|
||||
const filteredPortfolio = useMemo(() => {
|
||||
if (!portfolio) return portfolio
|
||||
if (marketFilter === 'ALL') return portfolio
|
||||
return portfolio.filter((s: Position) =>
|
||||
marketFilter === 'US' ? isUsStock(s.stock_code) : !isUsStock(s.stock_code)
|
||||
)
|
||||
}, [portfolio, marketFilter])
|
||||
|
||||
// useMemo로 Set 안정화 → React.memo 패널에 전달 시 레퍼런스 유지
|
||||
const heldStockCodes = useMemo<Set<string>>(
|
||||
() => new Set((portfolio || []).map((s) => s.stock_code as string)),
|
||||
@@ -429,27 +607,27 @@ const Dashboard = () => {
|
||||
const displayName = data.stock_name?.trim() || stockNameFallback || selectedStock
|
||||
let analysis = `📈 ${displayName} (${data.stock_code})\n\n`;
|
||||
|
||||
analysis += `💰 현재가: ${data.current_price.toLocaleString()}원\n`;
|
||||
analysis += `📊 고가/저가: ${data.high_price.toLocaleString()} / ${data.low_price.toLocaleString()}원\n`;
|
||||
analysis += `📦 거래량: ${(data.volume / 1000).toFixed(0)}K주\n`;
|
||||
analysis += `💰 현재가: ${data.current_price?.toLocaleString() ?? 'N/A'}원\n`;
|
||||
analysis += `📊 고가/저가: ${data.high_price?.toLocaleString() ?? 'N/A'} / ${data.low_price?.toLocaleString() ?? 'N/A'}원\n`;
|
||||
analysis += `📦 거래량: ${data.volume != null ? (data.volume / 1000).toFixed(0) : 'N/A'}K주\n`;
|
||||
|
||||
analysis += `\n📊 기술적 지표:\n`;
|
||||
const hasIndicators = data.indicators && Object.values(data.indicators).some(v => v !== null && v !== undefined);
|
||||
|
||||
if (hasIndicators) {
|
||||
if (data.indicators.rsi !== null && data.indicators.rsi !== undefined) {
|
||||
if (data.indicators.rsi != null) {
|
||||
analysis += ` RSI(14): ${data.indicators.rsi.toFixed(1)}\n`;
|
||||
}
|
||||
if (data.indicators.ma5 !== null) {
|
||||
if (data.indicators.ma5 != null) {
|
||||
analysis += ` 5일 이평: ${data.indicators.ma5.toFixed(0)}원\n`;
|
||||
}
|
||||
if (data.indicators.ma20 !== null) {
|
||||
if (data.indicators.ma20 != null) {
|
||||
analysis += ` 20일 이평: ${data.indicators.ma20.toFixed(0)}원\n`;
|
||||
}
|
||||
if (data.indicators.ma60 !== null) {
|
||||
if (data.indicators.ma60 != null) {
|
||||
analysis += ` 60일 이평: ${data.indicators.ma60.toFixed(0)}원\n`;
|
||||
}
|
||||
if (data.indicators.stochastic_k !== null) {
|
||||
if (data.indicators.stochastic_k != null) {
|
||||
analysis += ` 스토캐스틱 K: ${data.indicators.stochastic_k.toFixed(1)}\n`;
|
||||
}
|
||||
} else {
|
||||
@@ -468,7 +646,7 @@ const Dashboard = () => {
|
||||
'SIDEWAYS': '➡️ 횡보'
|
||||
};
|
||||
analysis += ` 추천: ${recommendMap[data.recommendation] || data.recommendation}\n`;
|
||||
analysis += ` 신뢰도: ${(data.confidence * 100).toFixed(0)}%\n`;
|
||||
analysis += ` 신뢰도: ${data.confidence != null ? (data.confidence * 100).toFixed(0) : 'N/A'}%\n`;
|
||||
analysis += ` 추세: ${trendMap[data.trend] || data.trend}\n`;
|
||||
|
||||
const skipMessages = [
|
||||
@@ -520,19 +698,36 @@ const Dashboard = () => {
|
||||
}, [portfolio, ruleBasedRecommendations, selectedStock, handleSelectStock])
|
||||
|
||||
return (
|
||||
<div className="bg-[#0f1115] text-gray-100 font-sans flex flex-col h-[calc(100vh-4rem)] overflow-hidden">
|
||||
<div className="bg-[#080d16] text-[#c5d5e8] font-sans flex flex-col h-[calc(100vh-4rem)] overflow-hidden">
|
||||
|
||||
<main className="flex-1 p-4 max-w-[1920px] mx-auto w-full flex flex-col gap-4 overflow-hidden">
|
||||
<main className="flex-1 p-4 max-w-[1920px] mx-auto w-full flex flex-col gap-3 overflow-hidden">
|
||||
<div className="flex-shrink-0">
|
||||
<PortfolioSummary summary={liveSummary} error={portfolioSummaryError} />
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-0 bg-[#0c1522] border border-[#152035] rounded-sm overflow-hidden">
|
||||
{([['ALL', '전체'], ['KR', '국내'], ['US', '미국']] as const).map(([value, label]) => (
|
||||
<button
|
||||
key={value}
|
||||
onClick={() => setMarketFilter(value as MarketFilter)}
|
||||
className={`px-3 py-1.5 text-[10px] font-mono transition-colors ${
|
||||
marketFilter === value
|
||||
? 'bg-[#1a3050] text-[#5b9aff]'
|
||||
: 'text-[#4a6580] hover:text-[#8faabf]'
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<PortfolioSummary summary={liveSummary} error={portfolioSummaryError?.message ?? null} todayTradeStats={todayTradeStats} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-12 gap-4 flex-1 min-h-0">
|
||||
<div className="grid grid-cols-12 gap-3 flex-1 min-h-0">
|
||||
|
||||
{/* [Left Column] 보유 종목 */}
|
||||
<div className="col-span-2 flex flex-col gap-4 h-full overflow-hidden">
|
||||
<div className="col-span-2 flex flex-col gap-3 h-full overflow-hidden">
|
||||
<PortfolioPanel
|
||||
portfolio={portfolio}
|
||||
portfolio={filteredPortfolio}
|
||||
livePrices={livePrices}
|
||||
selectedStock={selectedStock}
|
||||
onSelectStock={handleSelectStock}
|
||||
@@ -542,28 +737,29 @@ const Dashboard = () => {
|
||||
</div>
|
||||
|
||||
{/* [Center Column] 차트 & 트레이딩 정보 */}
|
||||
<div className="col-span-7 flex flex-col h-full gap-4 overflow-hidden">
|
||||
<div className="col-span-7 flex flex-col h-full gap-3 overflow-hidden">
|
||||
{/* 차트 섹션 */}
|
||||
<ChartPanel
|
||||
chartData={liveChartData}
|
||||
chartData={chartData}
|
||||
chartLoading={chartLoading}
|
||||
selectedStock={selectedStock}
|
||||
chartPeriod={chartPeriod}
|
||||
onPeriodChange={setChartPeriod}
|
||||
stockName={stockName}
|
||||
livePrice={livePrice}
|
||||
/>
|
||||
|
||||
{/* 하단: AI 투자 분석 & 호가창
|
||||
- AiAnalysisPanel: selectedStock 변경 시에만 리렌더
|
||||
- OrderbookPanel: 10초마다 독립 리렌더 (다른 패널 영향 없음) */}
|
||||
<div className="flex-1 grid grid-cols-2 gap-4 min-h-0">
|
||||
<div className="flex-1 grid grid-cols-2 gap-3 min-h-0">
|
||||
<AiAnalysisPanel aiAnalysisData={aiAnalysisData} />
|
||||
<OrderbookPanel orderbook={activeOrderbook} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* [Right Column] 분석 종목 & 매매일지 */}
|
||||
<div className="col-span-3 flex flex-col gap-4 h-full overflow-hidden">
|
||||
<div className="col-span-3 flex flex-col gap-3 h-full overflow-hidden">
|
||||
<RecommendationPanel
|
||||
recommendations={ruleBasedRecommendations}
|
||||
isError={isRecommendationsError}
|
||||
@@ -577,6 +773,7 @@ const Dashboard = () => {
|
||||
/>
|
||||
<JournalPanel
|
||||
journals={journals}
|
||||
tradeHistory={tradeHistory}
|
||||
onResetPending={resetPendingTrades}
|
||||
/>
|
||||
</div>
|
||||
|
||||
128
frontend/src/pages/Login.tsx
Normal file
128
frontend/src/pages/Login.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* Login.tsx — 로그인 페이지 컴포넌트
|
||||
*
|
||||
* 역할:
|
||||
* - API 키(SECRET_KEY) 입력을 통한 사용자 인증 처리
|
||||
* - 백엔드 portfolio API로 키 유효성 검증 후 App 컴포넌트에 인증 콜백 전달
|
||||
* - 인증 실패 시 에러 메시지 표시 (401/403 또는 네트워크 오류)
|
||||
*
|
||||
* Props:
|
||||
* - onLogin: 인증 성공 시 API 키를 전달받는 콜백 (App에서 localStorage 저장)
|
||||
*/
|
||||
import { useState, FormEvent } from 'react'
|
||||
import { Activity } from 'lucide-react'
|
||||
|
||||
/** Login 컴포넌트 Props 정의 */
|
||||
interface LoginProps {
|
||||
/** 인증 성공 시 호출되는 콜백 — API 키 문자열 전달 */
|
||||
onLogin: (apiKey: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Login — API 키 인증 폼 컴포넌트
|
||||
*
|
||||
* 상태(State):
|
||||
* - apiKey: 사용자 입력 API 키
|
||||
* - error: 인증 실패 에러 메시지
|
||||
* - loading: 검증 요청 진행 중 여부 (버튼 비활성화)
|
||||
*/
|
||||
const Login = ({ onLogin }: LoginProps) => {
|
||||
const [apiKey, setApiKey] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
/**
|
||||
* 폼 제출 핸들러 — API 키 유효성 검증
|
||||
*
|
||||
* 검증 흐름:
|
||||
* 1. 빈 입력 체크
|
||||
* 2. /api/v1/portfolio/combined 엔드포인트로 API 키 검증 요청
|
||||
* 3. 401/403 응답 시 에러 표시, 성공 시 onLogin 콜백 호출
|
||||
*/
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!apiKey.trim()) {
|
||||
setError('API 키를 입력하세요.')
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
setError('')
|
||||
|
||||
try {
|
||||
// API 키 검증: portfolio 엔드포인트로 테스트
|
||||
const res = await fetch('/api/v1/portfolio/combined', {
|
||||
headers: { 'X-API-Key': apiKey.trim() },
|
||||
})
|
||||
|
||||
if (res.status === 401 || res.status === 403) {
|
||||
setError('유효하지 않은 API 키입니다.')
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
setError(`서버 오류가 발생했습니다. (HTTP ${res.status})`)
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
// 검증 성공 — 상위 컴포넌트에 API 키 전달
|
||||
onLogin(apiKey.trim())
|
||||
} catch {
|
||||
setError('서버 연결에 실패했습니다.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#0f1115] flex items-center justify-center">
|
||||
<div className="w-full max-w-sm p-8">
|
||||
<div className="flex flex-col items-center gap-4 mb-8">
|
||||
<div className="bg-blue-600 p-3 rounded-xl shadow-lg shadow-blue-500/20">
|
||||
<Activity className="text-white" size={28} />
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<h1 className="text-2xl font-bold bg-gradient-to-r from-blue-400 to-purple-400 bg-clip-text text-transparent">
|
||||
AutoStock
|
||||
</h1>
|
||||
<p className="text-xs text-gray-500 font-mono mt-1">GEN 3 ALGO</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-2">API Key</label>
|
||||
<input
|
||||
type="password"
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
placeholder="SECRET_KEY 입력"
|
||||
className="w-full px-4 py-3 bg-gray-800/50 border border-gray-700 rounded-lg text-gray-100 placeholder-gray-600 focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500 transition-colors"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="text-red-400 text-sm">{error}</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full py-3 bg-blue-600 hover:bg-blue-500 disabled:bg-gray-700 disabled:text-gray-500 text-white font-medium rounded-lg transition-colors"
|
||||
>
|
||||
{loading ? '확인 중...' : '로그인'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className="text-xs text-gray-600 text-center mt-6">
|
||||
.env 파일의 SECRET_KEY 값을 입력하세요
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Login
|
||||
@@ -1,8 +1,27 @@
|
||||
/**
|
||||
* Shared type definitions for trading application
|
||||
* trading.ts — 트레이딩 애플리케이션 공유 타입 정의
|
||||
*
|
||||
* 역할:
|
||||
* - 프론트엔드 전체에서 사용되는 TypeScript 인터페이스 정의
|
||||
* - 백엔드 API 응답 구조와 1:1 매핑
|
||||
*
|
||||
* 타입 카테고리:
|
||||
* - 시장 데이터: CandleData, MarketData
|
||||
* - 포트폴리오: Position, PortfolioSummary, PortfolioCombined
|
||||
* - 매매: Trade, TradeHistory
|
||||
* - 추천: RecommendedStock, Recommendation, RecommendationsData
|
||||
* - 매매일지: Journal
|
||||
* - 호가: Orderbook, OrderbookLevel
|
||||
* - 차트: ProcessedChartData
|
||||
* - WebSocket: MarketUpdateMessage, ChartDataMessage, WebSocketMessage
|
||||
* - AI 분석: AiAnalysisResult
|
||||
*/
|
||||
|
||||
// Market Data Types
|
||||
// ──────────────────────────────────────────────────
|
||||
// 시장 데이터 타입 (Market Data Types)
|
||||
// ──────────────────────────────────────────────────
|
||||
|
||||
/** 캔들 차트 데이터 (OHLCV) — 일/주/월봉 공통 */
|
||||
export interface CandleData {
|
||||
date: string | Date;
|
||||
open: number;
|
||||
@@ -12,6 +31,7 @@ export interface CandleData {
|
||||
volume: number;
|
||||
}
|
||||
|
||||
/** 시장 데이터 — AI 분석 API 응답 구조 */
|
||||
export interface MarketData {
|
||||
stock_code: string;
|
||||
stock_name: string;
|
||||
@@ -34,7 +54,11 @@ export interface MarketData {
|
||||
ai_analysis?: string;
|
||||
}
|
||||
|
||||
// Portfolio Types
|
||||
// ──────────────────────────────────────────────────
|
||||
// 포트폴리오 타입 (Portfolio Types)
|
||||
// ──────────────────────────────────────────────────
|
||||
|
||||
/** 보유 종목 포지션 — /portfolio/combined API의 holdings 항목 */
|
||||
export interface Position {
|
||||
stock_code: string;
|
||||
stock_name: string;
|
||||
@@ -42,9 +66,21 @@ export interface Position {
|
||||
average_price: number;
|
||||
current_price: number;
|
||||
profit_loss_rate: number;
|
||||
[key: string]: string | number;
|
||||
market?: string;
|
||||
exchange_code?: string;
|
||||
currency_code?: string;
|
||||
strategy_type?: string;
|
||||
[key: string]: string | number | undefined;
|
||||
}
|
||||
|
||||
/** 환율 정보 (미국 주식 원화 환산용) */
|
||||
export interface ExchangeRate {
|
||||
currency_pair: string; // "USD/KRW"
|
||||
rate: number;
|
||||
recorded_at: string;
|
||||
}
|
||||
|
||||
/** 포트폴리오 요약 — /portfolio/combined API의 summary */
|
||||
export interface PortfolioSummary {
|
||||
total_assets: number;
|
||||
bfdy_total_assets: number;
|
||||
@@ -55,6 +91,11 @@ export interface PortfolioSummary {
|
||||
total_investment?: number;
|
||||
total_profit_loss?: number;
|
||||
total_profit_loss_rate?: number;
|
||||
available_cash?: number;
|
||||
cumulative_realized_pl?: number;
|
||||
overseas_value_usd?: number;
|
||||
overseas_value_krw?: number;
|
||||
exchange_rate?: number;
|
||||
[key: string]: string | number | undefined;
|
||||
}
|
||||
|
||||
@@ -63,7 +104,11 @@ export interface PortfolioCombined {
|
||||
summary: PortfolioSummary;
|
||||
}
|
||||
|
||||
// Trade Types
|
||||
// ──────────────────────────────────────────────────
|
||||
// 매매 타입 (Trade Types)
|
||||
// ──────────────────────────────────────────────────
|
||||
|
||||
/** 매매 기본 구조 */
|
||||
export interface Trade {
|
||||
id: number;
|
||||
stock_code: string;
|
||||
@@ -75,11 +120,16 @@ export interface Trade {
|
||||
[key: string]: string | number;
|
||||
}
|
||||
|
||||
/** 매매 이력 — Trade 확장 (추가 필드는 any로 허용) */
|
||||
export interface TradeHistory extends Trade {
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
// Recommendation Types
|
||||
// ──────────────────────────────────────────────────
|
||||
// 추천 종목 타입 (Recommendation Types)
|
||||
// ──────────────────────────────────────────────────
|
||||
|
||||
/** 분석 종목 — 배치 분석 결과의 개별 종목 */
|
||||
export interface RecommendedStock {
|
||||
stock_code: string;
|
||||
stock_name: string;
|
||||
@@ -110,15 +160,28 @@ export interface RecommendationsData {
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
// Journal Types
|
||||
// ──────────────────────────────────────────────────
|
||||
// 매매일지 타입 (Journal Types)
|
||||
// ──────────────────────────────────────────────────
|
||||
|
||||
/** 매매일지 항목 — 장 마감 후 AI 자동 생성 */
|
||||
export interface Journal {
|
||||
id: number;
|
||||
stock_code: string;
|
||||
stock_name: string;
|
||||
[key: string]: string | number;
|
||||
journal_date?: string;
|
||||
created_at?: string;
|
||||
mood?: string;
|
||||
content?: string;
|
||||
tags?: string[];
|
||||
[key: string]: string | number | string[] | undefined;
|
||||
}
|
||||
|
||||
// Orderbook Types
|
||||
// ──────────────────────────────────────────────────
|
||||
// 호가 타입 (Orderbook Types)
|
||||
// ──────────────────────────────────────────────────
|
||||
|
||||
/** 호가 단계 (가격 + 잔량) */
|
||||
export interface OrderbookLevel {
|
||||
price: number;
|
||||
volume: number;
|
||||
@@ -132,7 +195,11 @@ export interface Orderbook {
|
||||
bids: OrderbookLevel[];
|
||||
}
|
||||
|
||||
// Chart Data Types
|
||||
// ──────────────────────────────────────────────────
|
||||
// 차트 데이터 타입 (Chart Data Types)
|
||||
// ──────────────────────────────────────────────────
|
||||
|
||||
/** 가공된 차트 데이터 — TradingChart 컴포넌트용 (기술적 지표 포함) */
|
||||
export interface ProcessedChartData {
|
||||
candles: Array<{ time: string | number; open: number; high: number; low: number; close: number }>;
|
||||
volumes: Array<{ time: string | number; value: number; color: string }>;
|
||||
@@ -156,7 +223,11 @@ export interface ProcessedChartData {
|
||||
};
|
||||
}
|
||||
|
||||
// WebSocket Message Types
|
||||
// ──────────────────────────────────────────────────
|
||||
// WebSocket 메시지 타입
|
||||
// ──────────────────────────────────────────────────
|
||||
|
||||
/** 시장 업데이트 메시지 (tick: 체결가, quote: 호가) */
|
||||
export interface MarketUpdateMessage {
|
||||
_type: 'tick' | 'quote';
|
||||
stock_code?: string;
|
||||
@@ -176,7 +247,11 @@ export interface WebSocketMessage {
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
// AI Analysis Types
|
||||
// ──────────────────────────────────────────────────
|
||||
// AI 분석 타입
|
||||
// ──────────────────────────────────────────────────
|
||||
|
||||
/** AI 분석 결과 — 포맷된 텍스트 + 원시 데이터 */
|
||||
export interface AiAnalysisResult {
|
||||
analysis: string;
|
||||
data: MarketData | null;
|
||||
|
||||
16
frontend/src/utils/formatUtils.ts
Normal file
16
frontend/src/utils/formatUtils.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* formatUtils.ts — 포맷 관련 유틸리티 함수
|
||||
*
|
||||
* 날짜/시간 등 공통 포맷 함수를 모아둔 모듈입니다.
|
||||
*/
|
||||
|
||||
/**
|
||||
* ISO 날짜 문자열을 한국 시간 형식(HH:MM:SS)으로 변환
|
||||
* @param isoStr - ISO 형식 날짜 문자열 (예: "2026-03-26T10:30:00")
|
||||
* @returns 한국 시간 문자열 또는 null (입력값 없을 때)
|
||||
*/
|
||||
export const formatTime = (isoStr?: string): string | null => {
|
||||
if (!isoStr) return null
|
||||
const d = new Date(isoStr)
|
||||
return d.toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false, timeZone: 'Asia/Seoul' })
|
||||
}
|
||||
12
frontend/src/utils/stockUtils.ts
Normal file
12
frontend/src/utils/stockUtils.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* stockUtils.ts — 주식 관련 유틸리티 함수
|
||||
*
|
||||
* 공통으로 사용되는 주식 판별 함수를 모아둔 모듈입니다.
|
||||
*/
|
||||
|
||||
/**
|
||||
* 미국 주식 여부 판별
|
||||
* @param code - 종목 코드
|
||||
* @returns 6자리 숫자가 아니면 미국 주식으로 판별
|
||||
*/
|
||||
export const isUsStock = (code: string): boolean => !/^\d{6}$/.test(code)
|
||||
@@ -1,68 +1,98 @@
|
||||
// 기술적 지표 계산 유틸리티
|
||||
// 기존 TradingChart.tsx에서 검증된 로직을 재사용
|
||||
/**
|
||||
* technicalIndicators.ts — 기술적 지표 계산 유틸리티
|
||||
*
|
||||
* 역할:
|
||||
* - TradingChart 컴포넌트에서 사용하는 기술적 지표 계산 함수 모음
|
||||
* - 모든 함수는 O(n) 시간 복잡도로 최적화 (슬라이딩 윈도우 또는 누적 평균)
|
||||
*
|
||||
* 포함 지표:
|
||||
* - SMA (단순이동평균): 5일, 20일, 60일선
|
||||
* - EMA (지수이동평균): MACD 계산용
|
||||
* - 볼린저 밴드: 20일 기준, 표준편차 2배
|
||||
* - RSI (상대강도지수): 14일 기준, Wilder 스무딩
|
||||
* - MACD: EMA(12) - EMA(26), Signal: EMA(9)
|
||||
*/
|
||||
|
||||
/**
|
||||
* 단순이동평균 (Simple Moving Average)
|
||||
* @param data 가격 데이터 배열
|
||||
* @param period 이동평균 기간
|
||||
* @returns SMA 값 배열
|
||||
* 단순이동평균 (Simple Moving Average) — O(n) 슬라이딩 윈도우
|
||||
*
|
||||
* @param data - 종가 배열
|
||||
* @param period - 이동평균 기간 (예: 5, 20, 60)
|
||||
* @returns 이동평균 배열 (period 미만 구간은 NaN)
|
||||
*/
|
||||
export const calculateSMA = (data: number[], period: number): number[] => {
|
||||
const result: number[] = [];
|
||||
const result = new Array<number>(data.length);
|
||||
let sum = 0;
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
sum += data[i];
|
||||
if (i < period - 1) {
|
||||
result.push(NaN);
|
||||
result[i] = NaN;
|
||||
} else {
|
||||
const sum = data.slice(i - period + 1, i + 1).reduce((a, b) => a + b, 0);
|
||||
result.push(sum / period);
|
||||
if (i >= period) sum -= data[i - period];
|
||||
result[i] = sum / period;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* 지수이동평균 (Exponential Moving Average)
|
||||
* @param data 가격 데이터 배열
|
||||
* @param period EMA 기간
|
||||
* @returns EMA 값 배열
|
||||
* 지수이동평균 (Exponential Moving Average) — O(n)
|
||||
*
|
||||
* 가중 계수 k = 2 / (period + 1)
|
||||
* EMA[i] = data[i] * k + EMA[i-1] * (1 - k)
|
||||
*
|
||||
* @param data - 종가 배열
|
||||
* @param period - EMA 기간 (예: 12, 26, 9)
|
||||
* @returns EMA 배열 (첫 값은 data[0]으로 초기화)
|
||||
*/
|
||||
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));
|
||||
}
|
||||
const k = 2 / (period + 1); // 가중 계수 (smoothing factor)
|
||||
const ema = new Array<number>(data.length);
|
||||
ema[0] = data[0]; // 첫 값은 종가 그대로 사용
|
||||
for (let i = 1; i < data.length; i++) {
|
||||
ema[i] = data[i] * k + ema[i - 1] * (1 - k);
|
||||
}
|
||||
|
||||
return ema;
|
||||
};
|
||||
|
||||
/**
|
||||
* 볼린저 밴드 (Bollinger Bands)
|
||||
* @param closes 종가 배열
|
||||
* @param period 기간 (기본값: 20)
|
||||
* @returns 상단, 중간(SMA), 하단 밴드 배열
|
||||
* 볼린저 밴드 (Bollinger Bands) — O(n) 슬라이딩 윈도우
|
||||
*
|
||||
* 상단 = SMA(20) + 2 * 표준편차
|
||||
* 중앙 = SMA(20)
|
||||
* 하단 = SMA(20) - 2 * 표준편차
|
||||
*
|
||||
* 표준편차 계산: 슬라이딩 윈도우로 sum과 sumOfSquares를 관리하여 O(n) 달성
|
||||
* 분산 = sumSq/period - mean^2 (Welford 대안 — 수치 안정성은 약간 낮음)
|
||||
*
|
||||
* @param closes - 종가 배열
|
||||
* @param period - 기간 (기본 20)
|
||||
* @returns { upper, middle, lower } 배열
|
||||
*/
|
||||
export const calculateBollingerBands = (closes: number[], period: number = 20) => {
|
||||
const sma = calculateSMA(closes, period);
|
||||
const upper: number[] = [];
|
||||
const lower: number[] = [];
|
||||
const upper = new Array<number>(closes.length);
|
||||
const lower = new Array<number>(closes.length);
|
||||
|
||||
// 슬라이딩 윈도우로 sum-of-squares 관리 (O(n) 표준편차 계산)
|
||||
let sumSq = 0;
|
||||
let sum = 0;
|
||||
for (let i = 0; i < closes.length; i++) {
|
||||
sum += closes[i];
|
||||
sumSq += closes[i] * closes[i];
|
||||
if (i >= period) {
|
||||
sum -= closes[i - period];
|
||||
sumSq -= closes[i - period] * closes[i - period];
|
||||
}
|
||||
if (i < period - 1) {
|
||||
upper.push(NaN);
|
||||
lower.push(NaN);
|
||||
upper[i] = NaN;
|
||||
lower[i] = 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);
|
||||
const variance = sumSq / period - mean * mean;
|
||||
const stdDev = Math.sqrt(Math.max(0, variance));
|
||||
upper[i] = mean + 2 * stdDev;
|
||||
lower[i] = mean - 2 * stdDev;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,47 +100,81 @@ export const calculateBollingerBands = (closes: number[], period: number = 20) =
|
||||
};
|
||||
|
||||
/**
|
||||
* RSI (Relative Strength Index)
|
||||
* @param closes 종가 배열
|
||||
* @param period RSI 기간 (기본값: 14)
|
||||
* @returns RSI 값 배열
|
||||
* RSI (Relative Strength Index) — O(n) Wilder 스무딩
|
||||
*
|
||||
* RSI = 100 - (100 / (1 + RS))
|
||||
* RS = 평균 상승폭 / 평균 하락폭
|
||||
*
|
||||
* 계산 방식:
|
||||
* 1. 첫 period 구간: 단순 평균으로 avgGain, avgLoss 초기화
|
||||
* 2. 이후: Wilder 스무딩 적용
|
||||
* avgGain = (avgGain * (period-1) + 현재 상승폭) / period
|
||||
* avgLoss = (avgLoss * (period-1) + 현재 하락폭) / period
|
||||
*
|
||||
* @param closes - 종가 배열
|
||||
* @param period - RSI 기간 (기본 14)
|
||||
* @returns RSI 배열 (period 이하 구간은 NaN)
|
||||
*/
|
||||
export const calculateRSI = (closes: number[], period: number = 14): number[] => {
|
||||
const rsi: number[] = [];
|
||||
const gains: number[] = [];
|
||||
const losses: number[] = [];
|
||||
const rsi = new Array<number>(closes.length);
|
||||
|
||||
for (let i = 1; i < closes.length; i++) {
|
||||
let avgGain = 0;
|
||||
let avgLoss = 0;
|
||||
|
||||
// 첫 period 구간: 단순 평균
|
||||
for (let i = 1; i <= period; i++) {
|
||||
const change = closes[i] - closes[i - 1];
|
||||
gains.push(change > 0 ? change : 0);
|
||||
losses.push(change < 0 ? -change : 0);
|
||||
if (change > 0) avgGain += change;
|
||||
else avgLoss -= change;
|
||||
}
|
||||
avgGain /= period;
|
||||
avgLoss /= period;
|
||||
|
||||
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)));
|
||||
if (i <= period) {
|
||||
rsi[i] = NaN;
|
||||
continue;
|
||||
}
|
||||
// Wilder smoothing
|
||||
const change = closes[i] - closes[i - 1];
|
||||
avgGain = (avgGain * (period - 1) + (change > 0 ? change : 0)) / period;
|
||||
avgLoss = (avgLoss * (period - 1) + (change < 0 ? -change : 0)) / period;
|
||||
const rs = avgLoss === 0 ? 100 : avgGain / avgLoss;
|
||||
rsi[i] = 100 - (100 / (1 + rs));
|
||||
}
|
||||
|
||||
// period 인덱스에 값 설정 (첫 RSI)
|
||||
if (closes.length > period) {
|
||||
const firstAvgGain = (() => { let s = 0; for (let i = 1; i <= period; i++) { const c = closes[i] - closes[i-1]; if (c > 0) s += c; } return s / period; })();
|
||||
const firstAvgLoss = (() => { let s = 0; for (let i = 1; i <= period; i++) { const c = closes[i] - closes[i-1]; if (c < 0) s -= c; } return s / period; })();
|
||||
const rs = firstAvgLoss === 0 ? 100 : firstAvgGain / firstAvgLoss;
|
||||
rsi[period] = 100 - (100 / (1 + rs));
|
||||
}
|
||||
|
||||
return rsi;
|
||||
};
|
||||
|
||||
/**
|
||||
* MACD (Moving Average Convergence Divergence)
|
||||
* @param closes 종가 배열
|
||||
* @returns MACD, Signal, Histogram 배열
|
||||
* MACD (Moving Average Convergence Divergence) — O(n)
|
||||
*
|
||||
* MACD Line = EMA(12) - EMA(26)
|
||||
* Signal Line = EMA(9) of MACD
|
||||
* Histogram = MACD - Signal
|
||||
*
|
||||
* @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 macd = new Array<number>(closes.length);
|
||||
for (let i = 0; i < closes.length; i++) {
|
||||
macd[i] = ema12[i] - ema26[i];
|
||||
}
|
||||
const signal = calculateEMA(macd, 9);
|
||||
const histogram = macd.map((val, i) => val - signal[i]);
|
||||
|
||||
const histogram = new Array<number>(closes.length);
|
||||
for (let i = 0; i < closes.length; i++) {
|
||||
histogram[i] = macd[i] - signal[i];
|
||||
}
|
||||
return { macd, signal, histogram };
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user