CLAUDE.md §5.2 기준으로 계산해 매수 추천 금액 아래에 함께 표시. · 손절 = ATR(2.5×)과 -7% 중 타이트한 쪽(가격 높은 쪽), 적응형 모드면 그 값 사용 · 목표 = 진입가 + 2R (R = 진입가 − 손절가) — +2R 50% 분할 매도 지점 ATR 은 stock_analysis.atr_14 우선, 없으면 DB 일봉으로 계산(KIS API 미사용). 테스트 4건 추가. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
215 lines
11 KiB
TypeScript
215 lines
11 KiB
TypeScript
/**
|
|
* 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'
|
|
|
|
/** 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,
|
|
selectedStock,
|
|
chartPeriod,
|
|
onPeriodChange,
|
|
stockName,
|
|
livePrice,
|
|
}: ChartPanelProps) => {
|
|
return (
|
|
<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>
|
|
</>
|
|
) : (
|
|
<span className="text-sm text-[#3a5570]">종목을 선택하세요</span>
|
|
)}
|
|
</div>
|
|
<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-[10px] font-mono transition-colors ${
|
|
chartPeriod === p
|
|
? 'bg-[#1a3050] text-[#5b9aff]'
|
|
: 'text-[#4a6580] hover:text-[#8faabf]'
|
|
}`}
|
|
>
|
|
{p === 'D' ? '일봉' : p === 'W' ? '주봉' : '월봉'}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</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>
|
|
) : 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'
|
|
|
|
/**
|
|
* 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-[#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-[9px] text-[#3a5570] font-mono">
|
|
{formatTime(aiAnalysisData.data.analyzed_at)}
|
|
</span>
|
|
)}
|
|
</div>
|
|
{/* 매수 추천 금액 — 실제 자동매매 사이징과 동일 기준 (총자산×종목당 비중, 신뢰도 반영) */}
|
|
{aiAnalysisData?.data && (
|
|
<div className="px-3 py-1.5 border-b border-[#152035] bg-[#0a1120] flex-shrink-0">
|
|
<div className="flex items-baseline gap-2">
|
|
<span className="text-[9px] uppercase tracking-wider text-[#4a6580]">매수 추천</span>
|
|
{aiAnalysisData.data.suggested_quantity > 0 ? (
|
|
<>
|
|
<span className="text-[13px] font-mono text-[#22c55e]">
|
|
{Number(aiAnalysisData.data.suggested_amount).toLocaleString('ko-KR')}원
|
|
</span>
|
|
<span className="text-[10px] font-mono text-[#8faabf]">
|
|
{aiAnalysisData.data.suggested_quantity}주
|
|
</span>
|
|
<span className="ml-auto text-[9px] font-mono text-[#4a6580]">
|
|
총자산 {aiAnalysisData.data.position_ratio}%
|
|
</span>
|
|
</>
|
|
) : (
|
|
<span className="text-[10px] text-[#4a6580]">
|
|
{aiAnalysisData.data.sizing_note || '매수 제안 없음'}
|
|
</span>
|
|
)}
|
|
</div>
|
|
{aiAnalysisData.data.suggested_quantity > 0 && aiAnalysisData.data.sizing_note && (
|
|
<div className="text-[9px] text-[#3a5570] mt-0.5">{aiAnalysisData.data.sizing_note}</div>
|
|
)}
|
|
|
|
{/* 목표가 · 손절가 (CLAUDE.md §5.2: 손절은 ATR/퍼센트 중 타이트한 쪽, 목표는 +2R) */}
|
|
{aiAnalysisData.data.target_price > 0 && (
|
|
<div className="flex items-baseline gap-3 mt-1">
|
|
<span className="text-[9px] uppercase tracking-wider text-[#4a6580]">목표</span>
|
|
<span className="text-[11px] font-mono text-[#ef4444]">
|
|
{Number(aiAnalysisData.data.target_price).toLocaleString('ko-KR')}원
|
|
<span className="text-[9px] ml-1">(+{aiAnalysisData.data.target_pct}%)</span>
|
|
</span>
|
|
<span className="text-[9px] uppercase tracking-wider text-[#4a6580]">손절</span>
|
|
<span className="text-[11px] font-mono text-[#3b82f6]">
|
|
{Number(aiAnalysisData.data.stop_loss_price).toLocaleString('ko-KR')}원
|
|
<span className="text-[9px] ml-1">({aiAnalysisData.data.stop_loss_pct}%)</span>
|
|
</span>
|
|
<span className="ml-auto text-[9px] font-mono text-[#4a6580]">
|
|
R:R 1:{aiAnalysisData.data.risk_reward}
|
|
</span>
|
|
</div>
|
|
)}
|
|
{aiAnalysisData.data.target_note && (
|
|
<div className="text-[9px] text-[#3a5570] mt-0.5">{aiAnalysisData.data.target_note}</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Content */}
|
|
<div className="flex-1 overflow-y-auto custom-scrollbar p-3">
|
|
{aiAnalysisText ? (
|
|
<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-[#3a5570] text-xs text-center">
|
|
<p>종목을 선택하면<br />AI 투자 분석을 표시합니다.</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
})
|
|
|
|
AiAnalysisPanel.displayName = 'AiAnalysisPanel'
|
|
export default ChartPanel
|