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:
2026-03-26 16:05:41 +09:00
parent 739a8a2e5d
commit de7921d270
149 changed files with 133353 additions and 3338 deletions

5
frontend/.dockerignore Normal file
View File

@@ -0,0 +1,5 @@
node_modules
dist
.env
.env.local
*.log

View File

@@ -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;
}
# 캐시 설정

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 서버를 확인해주세요.');

View File

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

View File

@@ -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'}`} />

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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

View File

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

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

View File

@@ -0,0 +1,12 @@
/**
* stockUtils.ts — 주식 관련 유틸리티 함수
*
* 공통으로 사용되는 주식 판별 함수를 모아둔 모듈입니다.
*/
/**
* 미국 주식 여부 판별
* @param code - 종목 코드
* @returns 6자리 숫자가 아니면 미국 주식으로 판별
*/
export const isUsStock = (code: string): boolean => !/^\d{6}$/.test(code)

View File

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