feat: TradingView 스타일 대시보드 + 차트 오버레이 API, Crisis 손절 소급 차단 플래그(기본 OFF)

- frontend: 대시보드를 TradingView 레이아웃으로 재구성(components/tv/*) — 캔들+MA/BB/거래량,
  평단·손절·목표·트레일링 가격선, 체결 마커, RSI 기준선은 실효 설정값, 하단 탭(보유·체결·DT 카드·
  비용 후 손익곡선/낙폭·저널), 디자인 토큰·한국식 색·최소 11px, 모바일 가로 스크롤 제거
- backend(GET 전용, require_api_key): /positions/overlay, /risk/equity-history,
  /strategy-config/display, /trades 응답 필드(side·strategy_type·commission·net_profit_loss),
  /risk/mdd-status thresholds, /market-data/chart 1m|5m(캐시·in-flight 공유)
- mean_reversion_strategy: RSI 청산 임계를 resolve_swing_exit_rsi_v2 로 추출(동작 동일) —
  청산과 화면 표시가 같은 값을 쓴다
- §8.5-39: SWING_STOP_IGNORE_CRISIS_OVERLAY(기본 False) — 반사실 재생에서 Crisis 조기 손절이
  오히려 손실을 줄여 기본 OFF 유지, 정식 백테스트 전 켜지 말 것
- 유닛 스위트 2816 passed / 0 failed, tsc·build 통과

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-17 18:47:00 +09:00
parent d140055e64
commit a45007cbb2
28 changed files with 2607 additions and 351 deletions

View File

@@ -56,30 +56,30 @@ const ChartPanel = React.memo(({
emptyReason,
}: ChartPanelProps) => {
return (
<div className="bg-[#0c1522] border border-[#152035] rounded flex flex-col h-[45%] min-h-0">
<div className="bg-tv-panel border border-tv-border 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="px-3 py-2 flex justify-between items-center border-b border-tv-border bg-tv-bg 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">
<span className="text-base font-bold text-tv-text truncate">{stockName}</span>
<span className="text-[11px] font-mono text-tv-muted bg-tv-border px-1.5 py-px rounded-sm flex-shrink-0">
{selectedStock}
</span>
</>
) : (
<span className="text-sm text-[#3a5570]">종목을 선택하세요</span>
<span className="text-sm text-tv-muted">종목을 선택하세요</span>
)}
</div>
<div className="flex items-center bg-[#0a0f1c] border border-[#152035] rounded-sm overflow-hidden flex-shrink-0">
<div className="flex items-center bg-tv-raised border border-tv-border 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 ${
className={`px-3 py-1 text-[11px] font-mono transition-colors ${
chartPeriod === p
? 'bg-[#1a3050] text-[#5b9aff]'
: 'text-[#4a6580] hover:text-[#8faabf]'
? 'bg-tv-raised text-[#5b9aff]'
: 'text-tv-muted hover:text-tv-text'
}`}
>
{p === 'D' ? '일봉' : p === 'W' ? '주봉' : '월봉'}
@@ -89,7 +89,7 @@ const ChartPanel = React.memo(({
</div>
{/* Chart area — 로딩 중에도 기존 차트 유지 (깜빡임 방지) */}
<div className="flex-1 relative bg-[#0b0e14] rounded-b overflow-hidden">
<div className="flex-1 relative bg-tv-bg rounded-b overflow-hidden">
{chartData && chartData.length > 0 ? (
<TradingChart
data={chartData}
@@ -99,18 +99,18 @@ const ChartPanel = React.memo(({
stockCode={selectedStock}
/>
) : !chartLoading ? (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-1 text-[#3a5570] text-xs font-mono">
<div className="absolute inset-0 flex flex-col items-center justify-center gap-1 text-tv-muted text-xs font-mono">
<span>차트 데이터 없음</span>
{emptyReason && (
<span className="text-[10px] text-[#eab308]/80">({emptyReason})</span>
<span className="text-[11px] text-tv-warn/80">({emptyReason})</span>
)}
</div>
) : null}
{chartLoading && (
<div className="absolute inset-0 flex items-center justify-center bg-[#0b0e14]/70 z-10">
<div className="absolute inset-0 flex items-center justify-center bg-tv-bg/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>
<span className="text-xs text-tv-muted font-mono">차트 로딩중...</span>
</div>
</div>
)}
@@ -163,66 +163,66 @@ export const AiAnalysisPanel = React.memo(({ aiAnalysisData }: AiAnalysisPanelPr
const sizingTitleFallback = action === 'HOLD' ? '관망 (매수 제안 없음)' : action === 'SELL' ? '매도 의견 (매수 제안 없음)' : '매수 추천'
const sizingTitle = headline ?? sizingTitleFallback
return (
<div className="bg-[#0c1522] border border-[#152035] rounded flex flex-col overflow-hidden">
<div className="bg-tv-panel border border-tv-border 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">
<div className="px-3 py-2 border-b border-tv-border bg-tv-bg flex justify-between items-center flex-shrink-0">
<h3 className="flex items-center gap-1.5 text-tv-accent text-xs font-semibold uppercase tracking-wider">
<Brain size={12} />
AI 투자 분석
</h3>
{aiAnalysisData?.data?.analyzed_at && (
<span className="text-[9px] text-[#3a5570] font-mono">
<span className="text-[11px] text-tv-muted 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="px-3 py-1.5 border-b border-tv-border bg-tv-raised flex-shrink-0">
<div className="flex items-baseline gap-2">
<span className={`text-[9px] uppercase tracking-wider ${action === 'HOLD' ? 'text-[#eab308]' : action === 'SELL' ? 'text-[#3b82f6]' : 'text-[#4a6580]'}`}>{sizingTitle}</span>
<span className={`text-[11px] uppercase tracking-wider ${action === 'HOLD' ? 'text-tv-warn' : action === 'SELL' ? 'text-[#3b82f6]' : 'text-tv-muted'}`}>{sizingTitle}</span>
{showBuyPlan && 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]">
<span className="text-[11px] font-mono text-tv-text">
{aiAnalysisData.data.suggested_quantity}주
</span>
<span className="ml-auto text-[9px] font-mono text-[#4a6580]">
<span className="ml-auto text-[11px] font-mono text-tv-muted">
총자산 {aiAnalysisData.data.position_ratio}%
</span>
</>
) : (
<span className="text-[10px] text-[#4a6580]">
<span className="text-[11px] text-tv-muted">
{aiAnalysisData.data.sizing_note || '매수 제안 없음'}
</span>
)}
</div>
{showBuyPlan && aiAnalysisData.data.suggested_quantity > 0 && aiAnalysisData.data.sizing_note && (
<div className="text-[9px] text-[#3a5570] mt-0.5">{aiAnalysisData.data.sizing_note}</div>
<div className="text-[11px] text-tv-muted mt-0.5">{aiAnalysisData.data.sizing_note}</div>
)}
{/* 목표가 · 손절가 (CLAUDE.md §5.2: 손절은 ATR/퍼센트 중 타이트한 쪽, 목표는 +2R) */}
{showBuyPlan && hasTarget && (
<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]">
<span className="text-[11px] uppercase tracking-wider text-tv-muted">목표</span>
<span className="text-[11px] font-mono text-tv-up">
{Number(aiAnalysisData.data.target_price).toLocaleString('ko-KR')}원
<span className="text-[9px] ml-1">{aiAnalysisData.data.target_pct != null && <>(+{aiAnalysisData.data.target_pct}%)</>}</span>
<span className="text-[11px] ml-1">{aiAnalysisData.data.target_pct != null && <>(+{aiAnalysisData.data.target_pct}%)</>}</span>
</span>
<span className="text-[9px] uppercase tracking-wider text-[#4a6580]">손절</span>
<span className="text-[11px] uppercase tracking-wider text-tv-muted">손절</span>
<span className="text-[11px] font-mono text-[#3b82f6]">
{aiAnalysisData.data.stop_loss_price != null ? `${Number(aiAnalysisData.data.stop_loss_price).toLocaleString('ko-KR')}원` : '-'}
{aiAnalysisData.data.stop_loss_pct != null && <span className="text-[9px] ml-1">({aiAnalysisData.data.stop_loss_pct}%)</span>}
{aiAnalysisData.data.stop_loss_pct != null && <span className="text-[11px] ml-1">({aiAnalysisData.data.stop_loss_pct}%)</span>}
</span>
<span className="ml-auto text-[9px] font-mono text-[#4a6580]">
<span className="ml-auto text-[11px] font-mono text-tv-muted">
{aiAnalysisData.data.risk_reward != null && <>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 className="text-[11px] text-tv-muted mt-0.5">{aiAnalysisData.data.target_note}</div>
)}
</div>
)}
@@ -230,11 +230,11 @@ export const AiAnalysisPanel = React.memo(({ aiAnalysisData }: AiAnalysisPanelPr
{/* 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">
<pre className="text-[11px] text-tv-text 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">
<div className="h-full flex items-center justify-center text-tv-muted text-xs text-center">
<p>종목을 선택하면<br />AI 투자 분석을 표시합니다.</p>
</div>
)}

View File

@@ -69,30 +69,30 @@ const JournalPanel = React.memo(({ journals, tradeHistory, onResetPending, reset
}, [tradeHistory])
return (
<div className="bg-[#0c1522] border border-[#152035] rounded overflow-hidden flex flex-col h-1/2">
<div className="bg-tv-panel border border-tv-border 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="px-3 py-1.5 border-b border-tv-border bg-tv-bg 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 ${
className={`flex items-center gap-1 px-2 py-1 text-[11px] font-semibold uppercase tracking-wider rounded transition-colors ${
tab === 'trades'
? 'text-[#3b82f6] bg-[#3b82f6]/10'
: 'text-[#3a5570] hover:text-[#64748b]'
: 'text-tv-muted hover:text-tv-muted'
}`}
>
<ArrowUpDown size={10} />
매매내역
{tradeHistory && tradeHistory.length > 0 && (
<span className="ml-1 text-[8px] font-mono bg-[#1e293b] px-1 rounded">{tradeHistory.length}</span>
<span className="ml-1 text-[11px] font-mono bg-tv-border 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 ${
className={`flex items-center gap-1 px-2 py-1 text-[11px] font-semibold uppercase tracking-wider rounded transition-colors ${
tab === 'journal'
? 'text-[#22c55e] bg-[#22c55e]/10'
: 'text-[#3a5570] hover:text-[#64748b]'
: 'text-tv-muted hover:text-tv-muted'
}`}
>
<BookOpen size={10} />
@@ -102,7 +102,7 @@ const JournalPanel = React.memo(({ journals, tradeHistory, onResetPending, reset
<button
onClick={onResetPending}
disabled={resetDisabled}
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 disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:text-[#3a5570] disabled:hover:border-[#152035]"
className="px-2 py-0.5 text-[11px] font-mono text-tv-muted hover:text-tv-up border border-tv-border hover:border-tv-up/30 rounded-sm transition-colors disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:text-tv-muted disabled:hover:border-tv-border"
title="미체결 주문 정리"
>
미체결 정리
@@ -142,7 +142,7 @@ const JournalPanel = React.memo(({ journals, tradeHistory, onResetPending, reset
return (
<React.Fragment key={trade.id || idx}>
{header && (
<div className="sticky top-0 z-10 px-1 py-0.5 text-[9px] font-mono text-[#5b9aff] bg-[#0c1522] border-b border-[#152035]">
<div className="sticky top-0 z-10 px-1 py-0.5 text-[11px] font-mono text-[#5b9aff] bg-tv-panel border-b border-tv-border">
{header}
</div>
)}
@@ -150,28 +150,28 @@ const JournalPanel = React.memo(({ journals, tradeHistory, onResetPending, reset
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]'
: 'bg-tv-bg border-tv-border hover:border-tv-raised'
}`}
>
<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]'
<span className={`text-[11px] font-bold px-1.5 py-0.5 rounded ${
isBuy ? 'bg-tv-up/15 text-tv-up' : 'bg-[#3b82f6]/15 text-[#3b82f6]'
}`}>
{isBuy ? '매수' : '매도'}
</span>
<span className="text-xs font-semibold text-[#c5d5e8] truncate">
<span className="text-xs font-semibold text-tv-text 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>
<span className="text-[11px] font-mono px-1 py-px rounded-sm bg-[#7f1d1d] text-[#fca5a5]">실패</span>
)}
</div>
<span className="text-[9px] font-mono text-[#3a5570] flex-shrink-0 ml-1">{dateShort} {timeStr}</span>
<span className="text-[11px] font-mono text-tv-muted flex-shrink-0 ml-1">{dateShort} {timeStr}</span>
</div>
<div className="flex justify-between items-center mt-1">
<div className="text-[10px] font-mono text-[#64748b]">
<div className="text-[11px] font-mono text-tv-muted">
{/* 체결가(executed_price) 우선. null 이면 아직 체결 확인 전 — 주문가는 회색으로 구분 */}
{Number(trade.executed_price) > 0 ? (
<>{Number(trade.executed_quantity || trade.order_quantity || 0)}주 × {Math.round(Number(trade.executed_price)).toLocaleString()}원</>
@@ -179,33 +179,33 @@ const JournalPanel = React.memo(({ journals, tradeHistory, onResetPending, reset
<>
{Number(trade.order_quantity || trade.executed_quantity || 0)}주
{Number(trade.order_price) > 0 && (
<span className="text-[#3a4a5c]" title="체결 전 주문가"> × 주문가 {Math.round(Number(trade.order_price)).toLocaleString()}원</span>
<span className="text-tv-muted" title="체결 전 주문가"> × 주문가 {Math.round(Number(trade.order_price)).toLocaleString()}원</span>
)}
{!isFailed && <span className="ml-1 text-[9px] text-[#eab308]/80">체결 확인 중</span>}
{!isFailed && <span className="ml-1 text-[11px] text-tv-warn/80">체결 확인 중</span>}
</>
)}
</div>
{isSell && statusOk && pnl !== 0 && (
<div className={`text-[10px] font-mono font-semibold ${isProfit ? 'text-[#ef4444]' : isLoss ? 'text-[#3b82f6]' : 'text-[#64748b]'}`}>
<div className={`text-[11px] font-mono font-semibold ${isProfit ? 'text-tv-up' : isLoss ? 'text-[#3b82f6]' : 'text-tv-muted'}`}>
{pnl > 0 ? '+' : ''}{Math.round(pnl).toLocaleString()}원
<span className="ml-1 text-[9px]">({pnlRate > 0 ? '+' : ''}{pnlRate.toFixed(1)}%)</span>
<span className="ml-1 text-[11px]">({pnlRate > 0 ? '+' : ''}{pnlRate.toFixed(1)}%)</span>
</div>
)}
</div>
{trade.entry_reason && isBuy && (
<div className="text-[9px] text-[#475569] mt-1 truncate">{trade.entry_reason}</div>
<div className="text-[11px] text-tv-muted 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 className="text-[11px] text-tv-muted mt-1 truncate">{trade.exit_reason}</div>
)}
</div>
</React.Fragment>
)
})
) : (
<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]" />
<div className="h-full flex flex-col items-center justify-center text-tv-muted text-xs text-center gap-1 pt-8">
<ArrowUpDown size={20} className="text-tv-border" />
<span>매매내역 없음</span>
</div>
)
@@ -228,25 +228,25 @@ const JournalPanel = React.memo(({ journals, tradeHistory, onResetPending, reset
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 key={journal.id} className="p-2.5 bg-tv-bg border border-tv-border rounded-sm hover:border-tv-raised 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">
<span className="text-xs font-semibold text-tv-text 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>
<span className="text-[11px] font-mono text-tv-muted flex-shrink-0 ml-1">{dateStr} {timeStr}</span>
</div>
<div className="text-[10px] text-[#4a6580] leading-relaxed line-clamp-2 whitespace-pre-line">
<div className="text-[11px] text-tv-muted 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>
<span key={i} className="text-[11px] font-mono px-1 py-px rounded-sm bg-tv-border text-tv-muted">#{tag}</span>
))}
</div>
)}
@@ -254,10 +254,10 @@ const JournalPanel = React.memo(({ journals, tradeHistory, onResetPending, reset
)
})
) : (
<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]" />
<div className="h-full flex flex-col items-center justify-center text-tv-muted text-xs text-center gap-1 pt-8">
<BookOpen size={20} className="text-tv-border" />
<span>매매일지 없음</span>
<span className="text-[9px] text-[#2a3d55]">장 마감 후 자동 생성됩니다</span>
<span className="text-[11px] text-tv-border">장 마감 후 자동 생성됩니다</span>
</div>
)
)}

View File

@@ -84,18 +84,18 @@ const Layout = ({ children }: LayoutProps) => {
<div className="min-h-screen bg-[#0f1115] text-gray-100 font-sans flex flex-col">
<ToastHost />
{/* Top Navigation Bar */}
<header className="border-b border-gray-800 bg-[#161b22] h-16 flex items-center justify-between px-6 sticky top-0 z-50 backdrop-blur-md bg-opacity-90">
<div className="flex items-center gap-8">
<header className="border-b border-gray-800 bg-[#161b22] h-16 flex items-center justify-between gap-2 px-2 sm:px-6 sticky top-0 z-50 backdrop-blur-md bg-opacity-90">
<div className="flex items-center gap-2 sm:gap-8 min-w-0 flex-1">
{/* Brand */}
<div className="flex items-center gap-3">
<div className="flex items-center gap-3 shrink-0">
<div className="bg-blue-600 p-2 rounded-lg shadow-lg shadow-blue-500/20">
<Activity className="text-white" size={20} />
</div>
<h1 className="text-lg font-semibold tracking-tight leading-none">AutoStock</h1>
<h1 className="hidden lg:block text-lg font-semibold tracking-tight leading-none">AutoStock</h1>
</div>
{/* Navigation */}
<nav className="flex items-center gap-1">
<nav className="flex items-center gap-1 min-w-0 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
{navItems.map((item) => {
const Icon = item.icon
const isActive = location.pathname === item.path
@@ -103,7 +103,7 @@ const Layout = ({ children }: LayoutProps) => {
<Link
key={item.path}
to={item.path}
className={`flex items-center gap-2 px-3 py-2 rounded-md transition-all text-sm font-medium ${isActive
className={`flex items-center gap-1.5 sm:gap-2 px-2 sm:px-3 py-2 rounded-md whitespace-nowrap shrink-0 transition-all text-sm font-medium ${isActive
? 'bg-gray-800 text-blue-400'
: 'text-gray-400 hover:text-white hover:bg-gray-800/50'
}`}
@@ -117,25 +117,25 @@ const Layout = ({ children }: LayoutProps) => {
</div>
{/* Status Indicators */}
<div className="flex items-center gap-4">
<div className="flex items-center gap-2 px-3 py-1.5 bg-gray-800/50 rounded-full border border-gray-700/50">
<div className="flex items-center gap-2 sm:gap-4 shrink-0">
<div className="hidden sm: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 ${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="hidden lg:flex items-center gap-2 px-3 py-1.5 bg-gray-800/50 rounded-full border border-gray-700/50">
<div className={`w-2 h-2 rounded-full ${mcpStatus?.status === 'connected' ? 'bg-blue-500 shadow-[0_0_8px_rgba(59,130,246,0.5)]' : 'bg-yellow-500'}`} />
<span className="text-xs text-gray-400">MCP: {mcpStatus?.status || 'Unknown'}</span>
</div>
{/* 사용자 · 로그아웃 */}
<div className="flex items-center gap-1 pl-3 border-l border-gray-800">
<div className="flex items-center gap-1 sm:pl-3 sm:border-l border-gray-800">
<Link
to="/account"
className="flex items-center gap-1.5 px-2 py-1.5 rounded-lg text-xs text-gray-300 hover:bg-gray-800/60 transition-colors"
title="내 계좌 설정"
>
<UserIcon size={14} className="text-gray-500" />
<span className="max-w-[120px] truncate">
<span className="hidden sm:inline max-w-[120px] truncate">
{me ? (me.auth_type === 'api_key' ? '운영자 (API 키)' : me.username) : '…'}
</span>
{me?.is_admin && me.auth_type === 'session' && (

View File

@@ -33,14 +33,14 @@ interface OrderbookPanelProps {
*/
const OrderbookPanel = React.memo(({ orderbook }: OrderbookPanelProps) => {
return (
<div className="bg-[#0c1522] border border-[#152035] rounded flex flex-col overflow-hidden">
<div className="bg-tv-panel border border-tv-border 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">
<div className="px-3 py-2 border-b border-tv-border bg-tv-bg flex justify-between items-center flex-shrink-0">
<h3 className="text-tv-warn text-xs font-semibold uppercase tracking-wider">
호가창
</h3>
{orderbook?.stock_name && (
<span className="text-[9px] font-mono text-[#3a5570]">{orderbook.stock_name}</span>
<span className="text-[11px] font-mono text-tv-muted">{orderbook.stock_name}</span>
)}
</div>
@@ -66,25 +66,25 @@ const OrderbookPanel = React.memo(({ orderbook }: OrderbookPanelProps) => {
return (
<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 className="grid grid-cols-3 px-2 py-1 border-b border-tv-raised">
<span className="text-[11px] font-mono text-tv-muted text-right">잔량</span>
<span className="text-[11px] font-mono text-tv-muted text-center">가격</span>
<span className="text-[11px] font-mono text-tv-muted">잔량</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 key={`ask-${i}`} className="relative grid grid-cols-3 items-center py-0.5 px-2 hover:bg-tv-raised">
<div
className="absolute right-0 top-0 h-full bg-[#e84444]/8"
className="absolute right-0 top-0 h-full bg-tv-up/8"
style={{ width: `${fillPct}%` }}
/>
<span className="text-[10px] font-mono text-[#4a6580] text-right relative z-10">
<span className="text-[11px] font-mono text-tv-muted 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">
<span className="text-[11px] font-mono font-semibold text-tv-up text-center relative z-10">
{ask.price.toLocaleString()}
</span>
<div className="relative z-10" />
@@ -93,12 +93,12 @@ const OrderbookPanel = React.memo(({ orderbook }: OrderbookPanelProps) => {
})}
{/* 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]">
<div className="py-1.5 border-y border-tv-border bg-tv-raised flex justify-center items-center gap-2">
<span className="text-base font-mono font-bold text-tv-text">
{currentPrice?.toLocaleString()}
</span>
{orderbook.current_volume > 0 && (
<span className="text-[9px] font-mono text-[#3a5570]">
<span className="text-[11px] font-mono text-tv-muted">
{orderbook.current_volume.toLocaleString()}
</span>
)}
@@ -108,7 +108,7 @@ const OrderbookPanel = React.memo(({ orderbook }: OrderbookPanelProps) => {
{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 key={`bid-${i}`} className="relative grid grid-cols-3 items-center py-0.5 px-2 hover:bg-tv-raised">
<div
className="absolute left-0 top-0 h-full bg-[#3d7eff]/8"
style={{ width: `${fillPct}%` }}
@@ -117,7 +117,7 @@ const OrderbookPanel = React.memo(({ orderbook }: OrderbookPanelProps) => {
<span className="text-[11px] font-mono font-semibold text-[#3d7eff] text-center relative z-10">
{bid.price.toLocaleString()}
</span>
<span className="text-[10px] font-mono text-[#4a6580] relative z-10">
<span className="text-[11px] font-mono text-tv-muted relative z-10">
{bid.volume > 0 ? bid.volume.toLocaleString() : '—'}
</span>
</div>
@@ -126,7 +126,7 @@ const OrderbookPanel = React.memo(({ orderbook }: OrderbookPanelProps) => {
</div>
)
})() : (
<div className="h-full flex items-center justify-center text-[#3a5570] text-xs font-mono text-center p-4">
<div className="h-full flex items-center justify-center text-tv-muted text-xs font-mono text-center p-4">
종목을 선택하면<br />호가 정보가 표시됩니다.
</div>
)}

View File

@@ -151,7 +151,7 @@ const PortfolioItem = React.memo(({
}
return (
<div className={`border-l-2 ${isSelected ? 'border-[#2b65d9] bg-[#0d1e33]' : 'border-transparent hover:bg-[#0a1628]'} transition-colors`}>
<div className={`border-l-2 ${isSelected ? 'border-[#2b65d9] bg-tv-raised' : 'border-transparent hover:bg-tv-raised'} transition-colors`}>
<div
onClick={() => {
if (isSelected) onRefetchAiAnalysis()
@@ -162,19 +162,19 @@ const PortfolioItem = React.memo(({
{/* 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]'}`}>
<span className={`text-xs font-semibold truncate ${isSelected ? 'text-[#5b9aff]' : 'text-tv-text'}`}>
{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'}`}>
<span className={`text-[11px] 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' || stock.strategy_type === 'day_trade') && (
<span className="text-[7px] px-1 py-px rounded-sm text-[#f97316] bg-[#f97316]/10 flex-shrink-0">데이</span>
<span className="text-[11px] px-1 py-px rounded-sm text-tv-warn bg-tv-warn/10 flex-shrink-0">데이</span>
)}
{(stock.strategy_type === 'external' || stock.strategy_type === 'manual') && (
<span
title="자동 청산 대상 아님"
className="text-[7px] px-1 py-px rounded-sm text-[#eab308] bg-[#eab308]/10 flex-shrink-0"
className="text-[11px] px-1 py-px rounded-sm text-tv-warn bg-tv-warn/10 flex-shrink-0"
>
{stock.strategy_type === 'external' ? '외부' : '수동'}
</span>
@@ -182,7 +182,7 @@ const PortfolioItem = React.memo(({
{Number((stock as GroupedHolding).unmanaged) > 0 && (
<span
title="데이트레이딩 엔진이 관리하지 않는 잔고 - 자동 청산되지 않음"
className="text-[7px] px-1 py-px rounded-sm text-[#fb923c] bg-[#fb923c]/15 flex-shrink-0"
className="text-[11px] px-1 py-px rounded-sm text-tv-warn bg-tv-warn/15 flex-shrink-0"
>
미관리
</span>
@@ -190,34 +190,34 @@ const PortfolioItem = React.memo(({
{Number((stock as GroupedHolding).dtIncluded) > 0 && (
<span
title="데이트레이딩 엔진 수량을 확인할 수 없어 DT 보유분이 포함됐을 수 있음"
className="text-[7px] px-1 py-px rounded-sm text-[#f97316]/70 bg-[#f97316]/10 flex-shrink-0"
className="text-[11px] px-1 py-px rounded-sm text-tv-warn/70 bg-tv-warn/10 flex-shrink-0"
>
DT 포함?
</span>
)}
</div>
<span className={`text-xs font-mono font-bold flex-shrink-0 ${isUp ? 'text-[#e84444]' : 'text-[#3d7eff]'}`}>
<span className={`text-xs font-mono font-bold flex-shrink-0 ${isUp ? 'text-tv-up' : '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>
<span className="text-[11px] font-mono text-tv-muted">{stock.stock_code}</span>
<span className="text-[11px] text-tv-muted">{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] flex items-center gap-1">
<span className="text-[11px] font-mono text-tv-muted flex items-center gap-1">
{formatPrice(avgPrice, isUs)}
{avgMixed && (
<span title={mixedTitle} className="text-[7px] px-1 py-px rounded-sm text-[#a78bfa] bg-[#a78bfa]/10 cursor-help">
<span title={mixedTitle} className="text-[11px] px-1 py-px rounded-sm text-tv-accent bg-tv-accent/10 cursor-help">
혼합평단 분리
</span>
)}
</span>
<span className={`text-[10px] font-mono font-semibold ${isUp ? 'text-[#e84444]' : 'text-[#3d7eff]'}`}>
<span className={`text-[11px] font-mono font-semibold ${isUp ? 'text-tv-up' : 'text-[#3d7eff]'}`}>
{formatPrice(currentPrice, isUs)}
</span>
</div>
@@ -229,7 +229,7 @@ const PortfolioItem = React.memo(({
onClick={handleSellClick}
disabled={sellBlocked || selling}
title={splitLocked ? '스윙·데이트레이딩 합산 종목 — DT 포지션은 15:20 자동 청산, 수동 매도 시 DT 몫부터 차감되므로 여기서는 분할 매도할 수 없습니다' : undefined}
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 disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:text-[#3a5570] disabled:hover:border-[#152035]"
className="w-full py-0.5 text-[11px] font-mono text-tv-muted hover:text-[#3d7eff] border border-tv-border hover:border-[#3d7eff]/40 rounded-sm transition-colors disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:text-tv-muted disabled:hover:border-tv-border"
>
{selling ? '주문 처리 중...' : '전량 청산 (시장가)'}
</button>
@@ -243,11 +243,11 @@ const PortfolioItem = React.memo(({
onConfirm={handleConfirmSell}
onCancel={() => setConfirmOpen(false)}
lines={[
<><b className="text-[#c5d5e8]">{stock.stock_name}</b> ({stock.stock_code})</>,
<>수량 <b className="text-[#c5d5e8]">{quantity.toLocaleString()}{isUs ? '주(shares)' : '주'}</b> 전량을 <b className="text-[#5b9aff]">시장가</b>로 매도합니다.</>,
<><b className="text-tv-text">{stock.stock_name}</b> ({stock.stock_code})</>,
<>수량 <b className="text-tv-text">{quantity.toLocaleString()}{isUs ? '주(shares)' : '주'}</b> 전량을 <b className="text-[#5b9aff]">시장가</b>로 매도합니다.</>,
<>현재가 {formatPrice(currentPrice, isUs)} · 평가손익 {profitLossRate > 0 ? '+' : ''}{profitLossRate.toFixed(2)}%{profitLoss != null && !isUs ? ` (${profitLoss > 0 ? '+' : ''}${Math.round(profitLoss).toLocaleString()}원)` : ''}</>,
...(Number((stock as GroupedHolding).unmanaged) > 0 ? [<span className="text-[#fb923c]">엔진 미관리 잔고입니다.</span>] : []),
<span className="text-[#4a6580]">체결 후에는 되돌릴 수 없습니다.</span>,
...(Number((stock as GroupedHolding).unmanaged) > 0 ? [<span className="text-tv-warn">엔진 미관리 잔고입니다.</span>] : []),
<span className="text-tv-muted">체결 후에는 되돌릴 수 없습니다.</span>,
]}
/>
</div>
@@ -364,19 +364,19 @@ const PortfolioPanel = React.memo(({
: null
return (
<section key={label}>
<div className={`px-3 py-1 border-b sticky top-0 z-10 ${warn ? 'bg-[#1c1208] border-[#fb923c]/40' : 'bg-[#091018] border-[#152035]'}`} title={note}>
<div className={`px-3 py-1 border-b sticky top-0 z-10 ${warn ? 'bg-[#1c1208] border-tv-warn/40' : 'bg-tv-bg border-tv-border'}`} title={note}>
<div className="flex items-center gap-1.5">
{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>
<span className={`text-[11px] font-semibold uppercase tracking-wider ${color}`}>{label}</span>
<span className="ml-auto text-[11px] font-mono bg-tv-border text-tv-muted px-1.5 py-px rounded-sm">{stocks.length}</span>
</div>
<div className="flex justify-between items-center mt-0.5 text-[8px] font-mono text-[#4a6580]">
<span>{fmt(sub.value)}{krwNote && <span className={`ml-1 ${rateOk ? '' : 'text-[#eab308]'}`}>{krwNote}</span>}</span>
<span className={up ? 'text-[#e84444]' : 'text-[#3d7eff]'}>
<div className="flex justify-between items-center mt-0.5 text-[11px] font-mono text-tv-muted">
<span>{fmt(sub.value)}{krwNote && <span className={`ml-1 ${rateOk ? '' : 'text-tv-warn'}`}>{krwNote}</span>}</span>
<span className={up ? 'text-tv-up' : 'text-[#3d7eff]'}>
{up ? '+' : ''}{fmt(sub.pnl)} ({up ? '+' : ''}{sub.rate.toFixed(2)}%)
</span>
</div>
{note && <p className="mt-0.5 text-[8px] text-[#3a5570] leading-tight">{note}</p>}
{note && <p className="mt-0.5 text-[11px] text-tv-muted leading-tight">{note}</p>}
</div>
{stocks.map((stock) => (
<PortfolioItem
@@ -396,32 +396,32 @@ const PortfolioPanel = React.memo(({
}
return (
<div className="bg-[#0c1522] border border-[#152035] rounded overflow-hidden flex flex-col h-full">
<div className="bg-tv-panel border border-tv-border 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">
<div className="px-3 py-2 border-b border-tv-border bg-tv-bg flex justify-between items-center flex-shrink-0">
<h3 className="flex items-center gap-1.5 text-tv-text text-xs font-semibold uppercase tracking-wider">
<Activity className="w-3 h-3 text-[#2b65d9]" />
보유 종목
</h3>
<div className="flex items-center gap-1">
{groups.swing.length > 0 && (
<span className="text-[8px] font-mono text-[#a78bfa] bg-[#a78bfa]/10 px-1.5 py-px rounded-sm">스윙 {groups.swing.length}</span>
<span className="text-[11px] font-mono text-tv-accent bg-tv-accent/10 px-1.5 py-px rounded-sm">스윙 {groups.swing.length}</span>
)}
{groups.unmanaged.length > 0 && (
<span title="엔진이 관리하지 않는 DT 잔고" className="text-[8px] font-mono text-[#fb923c] bg-[#fb923c]/15 px-1.5 py-px rounded-sm">미관리 {groups.unmanaged.length}</span>
<span title="엔진이 관리하지 않는 DT 잔고" className="text-[11px] font-mono text-tv-warn bg-tv-warn/15 px-1.5 py-px rounded-sm">미관리 {groups.unmanaged.length}</span>
)}
{groups.manual.length > 0 && (
<span className="text-[8px] font-mono text-[#eab308] bg-[#eab308]/10 px-1.5 py-px rounded-sm">수동 {groups.manual.length}</span>
<span className="text-[11px] font-mono text-tv-warn bg-tv-warn/10 px-1.5 py-px rounded-sm">수동 {groups.manual.length}</span>
)}
{groups.overseas.length > 0 && (
<span className="text-[8px] font-mono text-[#22c55e] bg-[#22c55e]/10 px-1.5 py-px rounded-sm">해외 {groups.overseas.length}</span>
<span className="text-[11px] font-mono text-[#22c55e] bg-[#22c55e]/10 px-1.5 py-px rounded-sm">해외 {groups.overseas.length}</span>
)}
<span className="text-[8px] font-mono text-[#2b65d9] bg-[#2b65d9]/10 px-1.5 py-px rounded-sm">{total}</span>
<span className="text-[11px] font-mono text-[#2b65d9] bg-[#2b65d9]/10 px-1.5 py-px rounded-sm">{total}</span>
</div>
</div>
{/* List */}
<div className="divide-y divide-[#0f1e2e] overflow-y-auto custom-scrollbar flex-1">
<div className="divide-y divide-tv-raised overflow-y-auto custom-scrollbar flex-1">
{accountSetupRequired ? (
<div className="h-full flex flex-col items-center justify-center p-3">
<AccountSetupNotice message={accountSetupMessage} compact />
@@ -430,13 +430,13 @@ const PortfolioPanel = React.memo(({
<>
{/* 엔진이 관리 중인 데이트레이딩 포지션은 목록에서 제외(사용자 요청 2026-10-06) — 하단 DayTradingStatusBar 에서 확인.
단 엔진이 모르는 DT 잔고는 반드시 경고 그룹으로 표시한다. */}
{renderGroup(groups.unmanaged, '미관리 (엔진 외) 보유', <AlertTriangle className="w-2.5 h-2.5 text-[#fb923c]" />, 'text-[#fb923c]', '데이트레이딩 엔진이 관리하지 않는 잔고 - 자동 청산되지 않으니 직접 확인/청산 필요', false, 'unmanaged', true)}
{renderGroup(groups.swing, '스윙', <TrendingUp className="w-2.5 h-2.5 text-[#a78bfa]" />, 'text-[#a78bfa]', undefined, false, 'swing')}
{renderGroup(groups.manual, '수동 / 외부', <UserCircle className="w-2.5 h-2.5 text-[#eab308]" />, 'text-[#eab308]', '자동 청산 대상 아님 - 직접 청산 필요', false, 'manual')}
{renderGroup(groups.unmanaged, '미관리 (엔진 외) 보유', <AlertTriangle className="w-2.5 h-2.5 text-tv-warn" />, 'text-tv-warn', '데이트레이딩 엔진이 관리하지 않는 잔고 - 자동 청산되지 않으니 직접 확인/청산 필요', false, 'unmanaged', true)}
{renderGroup(groups.swing, '스윙', <TrendingUp className="w-2.5 h-2.5 text-tv-accent" />, 'text-tv-accent', undefined, false, 'swing')}
{renderGroup(groups.manual, '수동 / 외부', <UserCircle className="w-2.5 h-2.5 text-tv-warn" />, 'text-tv-warn', '자동 청산 대상 아님 - 직접 청산 필요', false, 'manual')}
{renderGroup(groups.overseas, '해외', <Globe className="w-2.5 h-2.5 text-[#22c55e]" />, 'text-[#22c55e]', undefined, true, 'overseas')}
</>
) : (
<div className="h-full flex flex-col items-center justify-center text-[#3a5570] p-4 text-center">
<div className="h-full flex flex-col items-center justify-center text-tv-muted p-4 text-center">
<p className="text-xs">보유 중인 종목이 없습니다.</p>
</div>
)}

View File

@@ -151,8 +151,8 @@ const RecommendationItem = React.memo(({
<div
className={`w-full text-left transition-colors border-l-2 ${
isSelected
? 'bg-[#0d1e33] border-[#2b65d9]'
: 'border-transparent hover:bg-[#0a1628]'
? 'bg-tv-raised border-[#2b65d9]'
: 'border-transparent hover:bg-tv-raised'
}`}
>
<div
@@ -164,54 +164,54 @@ const RecommendationItem = React.memo(({
>
{/* 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]'}`}>
<span className="text-[11px] font-mono text-tv-border w-4 flex-shrink-0 text-right">{idx + 1}</span>
<span className={`text-[11px] font-semibold truncate flex-1 ${isSelected ? 'text-[#5b9aff]' : 'text-tv-text'}`}>
{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">
<span className="text-[11px] text-tv-warn bg-tv-warn/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>
<span className="text-[11px] text-[#5b9aff] bg-[#5b9aff]/10 px-1 py-px rounded-sm flex-shrink-0">보유</span>
) : isRecommended && isFallback ? (
<span title="매수 규칙은 충족하지 못했고 점수 상위 종목으로 채워진 관심 종목" className="text-[8px] text-[#eab308] bg-[#eab308]/10 px-1 py-px rounded-sm flex-shrink-0">관심(규칙 미충족)</span>
<span title="매수 규칙은 충족하지 못했고 점수 상위 종목으로 채워진 관심 종목" className="text-[11px] text-tv-warn bg-tv-warn/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>
<span className="text-[11px] text-[#22c55e] bg-[#22c55e]/10 px-1 py-px rounded-sm flex-shrink-0">신호</span>
) : null}
{blocked && !isHeld && !isPending && (
<span title={blockReasons || '매수 차단'} className="text-[8px] text-[#fb923c] bg-[#fb923c]/10 px-1 py-px rounded-sm flex-shrink-0">차단</span>
<span title={blockReasons || '매수 차단'} className="text-[11px] text-tv-warn bg-tv-warn/10 px-1 py-px rounded-sm flex-shrink-0">차단</span>
)}
<span className={`text-[10px] font-mono font-bold flex-shrink-0 ${isUp ? 'text-[#e84444]' : 'text-[#3d7eff]'}`}>
<span className={`text-[11px] font-mono font-bold flex-shrink-0 ${isUp ? 'text-tv-up' : 'text-[#3d7eff]'}`}>
{changeRate > 0 ? '+' : ''}{changeRate.toFixed(2)}%
</span>
</div>
{/* Row 2: code + price + RSI + score */}
<div className="flex flex-wrap items-center gap-x-2 gap-y-0.5 pl-5">
<span className="text-[9px] font-mono text-[#3a5570]">{stock.stock_code}</span>
<span className="text-[9px] font-mono text-[#4a6580]">
<span className="text-[11px] font-mono text-tv-muted">{stock.stock_code}</span>
<span className="text-[11px] font-mono text-tv-muted">
{Math.round(livePrices?.[stock.stock_code] ?? stock.current_price ?? 0).toLocaleString()}원
{stock.price_source === 'batch' && livePrices?.[stock.stock_code] == null && (
<span title="실시간 시세 없음 — 배치 분석 시점 가격" className="ml-0.5 text-[7px] text-[#3a5570]">배치</span>
<span title="실시간 시세 없음 — 배치 분석 시점 가격" className="ml-0.5 text-[11px] text-tv-muted">배치</span>
)}
</span>
{stock.rsi != null && (
<span className={`text-[8px] font-mono ${stock.rsi < 30 ? 'text-[#22c55e]' : stock.rsi > 70 ? 'text-[#e84444]' : 'text-[#4a6580]'}`}>
<span className={`text-[11px] font-mono ${stock.rsi < 30 ? 'text-[#22c55e]' : stock.rsi > 70 ? 'text-tv-up' : 'text-tv-muted'}`}>
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>
<span className="text-[11px] font-mono text-tv-muted">K {stock.stochastic_k.toFixed(0)}</span>
)}
{(stock.score ?? 0) > 0 && (
<span className="text-[8px] font-mono text-[#9f7aea]">{stock.score}pt</span>
<span className="text-[11px] font-mono text-tv-accent">{stock.score}pt</span>
)}
{mlSignal && (
<span className={`text-[8px] font-mono px-1 rounded-sm ${
<span className={`text-[11px] font-mono px-1 rounded-sm ${
mlSignal === 'SELL' ? 'text-[#3d7eff] bg-[#3d7eff]/10'
: mlSignal === 'BUY' ? 'text-[#e84444] bg-[#e84444]/10'
: 'text-[#4a6580] bg-[#152035]'
: mlSignal === 'BUY' ? 'text-tv-up bg-tv-up/10'
: 'text-tv-muted bg-tv-border'
}`}>
ML {mlSignal}
</span>
@@ -222,7 +222,7 @@ const RecommendationItem = React.memo(({
{stock.signals && stock.signals.length > 0 && (
<div className="pl-5 mt-0.5">
{/* 잘림 방지: 한 줄 truncate 대신 줄바꿈 허용 (ML SELL 등 뒤쪽 신호가 가려지지 않게) */}
<span className="text-[8px] text-[#3a5570] block break-words leading-tight">
<span className="text-[11px] text-tv-muted block break-words leading-tight">
{stock.signals.join(' · ')}
</span>
</div>
@@ -236,7 +236,7 @@ const RecommendationItem = React.memo(({
onClick={handleBuyClick}
disabled={buying || buyDisabled || blocked}
title={blocked ? `매수 차단: ${blockReasons || '사유 미상'}` : undefined}
className="w-full py-0.5 text-[9px] font-mono text-[#3a5570] hover:text-[#e84444] border border-[#152035] hover:border-[#e84444]/40 rounded-sm transition-colors disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:text-[#3a5570] disabled:hover:border-[#152035]"
className="w-full py-0.5 text-[11px] font-mono text-tv-muted hover:text-tv-up border border-tv-border hover:border-tv-up/40 rounded-sm transition-colors disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:text-tv-muted disabled:hover:border-tv-border"
>
{buying ? '매수 주문 중...' : blocked ? '매수 차단' : '매수 (시장가)'}
</button>
@@ -249,7 +249,7 @@ const RecommendationItem = React.memo(({
onConfirm={handleConfirmBuy}
onCancel={() => setConfirmOpen(false)}
lines={[
<><b className="text-[#c5d5e8]">{stock.stock_name}</b> ({stock.stock_code})</>,
<><b className="text-tv-text">{stock.stock_name}</b> ({stock.stock_code})</>,
<>시장가 매수 (수량: 총자산 x 종목당 최대비중으로 자동 산출)</>,
]}
/>
@@ -314,24 +314,24 @@ const RecommendationPanel = React.memo(({
}, [recommendations])
return (
<div className="bg-[#0c1522] border border-[#152035] rounded overflow-hidden flex flex-col h-1/2">
<div className="bg-tv-panel border border-tv-border 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">
<div className="px-3 py-2 border-b border-tv-border bg-tv-bg flex justify-between items-center flex-shrink-0">
<h3 className="text-tv-accent text-xs font-semibold uppercase tracking-wider">
분석 종목 스캐너
</h3>
<div className="flex items-center gap-2">
{(recommendations?.batch_time || recommendations?.batch_date) && (
<span className="text-[9px] text-[#3a5570] font-mono">
<span className="text-[11px] text-tv-muted font-mono">
{formatBatchTime(recommendations.batch_time) ?? formatBatchTime(recommendations.batch_date)}
</span>
)}
<span className="text-[8px] font-mono text-[#9f7aea] bg-[#9f7aea]/10 px-1.5 py-px rounded-sm">
<span className="text-[11px] font-mono text-tv-accent bg-tv-accent/10 px-1.5 py-px rounded-sm">
{recommendations?.analyzed_stocks || 0}
</span>
<button
onClick={onRefetch}
className="p-1 text-[#3a5570] hover:text-[#9f7aea] transition-colors"
className="p-1 text-tv-muted hover:text-tv-accent transition-colors"
title="새로고침"
>
<RefreshCw className="w-3 h-3" />
@@ -340,18 +340,18 @@ const RecommendationPanel = React.memo(({
</div>
{/* List */}
<div className="divide-y divide-[#0f1e2e] overflow-y-auto custom-scrollbar flex-1">
<div className="divide-y divide-tv-raised overflow-y-auto custom-scrollbar flex-1">
{isError ? (
<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>
<p className="text-tv-up">⚠ API 오류</p>
<p className="text-[11px] text-tv-muted mt-1">백엔드 로그 확인 필요</p>
<button onClick={onRefetch} className="mt-2 px-2 py-0.5 text-tv-up border border-tv-up/30 rounded-sm text-[11px] font-mono">재시도</button>
</div>
) : recommendations === undefined ? (
<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]" />
<div className="h-full flex flex-col items-center justify-center text-tv-muted text-xs p-4 text-center">
<RefreshCw className="w-5 h-5 animate-spin mb-2 text-tv-accent" />
<p>로딩 중...</p>
<p className="text-[9px] text-[#2a3d55] mt-1">최초 로딩 시 10초 정도 소요됩니다</p>
<p className="text-[11px] text-tv-border mt-1">최초 로딩 시 10초 정도 소요됩니다</p>
</div>
) : (recommendations?.analyzed_stock_list || []).length > 0 ? (
(recommendations.analyzed_stock_list).map((stock: RecommendedStock, idx: number) => {
@@ -381,9 +381,9 @@ const RecommendationPanel = React.memo(({
)
})
) : (
<div className="h-full flex flex-col items-center justify-center text-[#3a5570] p-4 text-center">
<div className="h-full flex flex-col items-center justify-center text-tv-muted p-4 text-center">
<p className="text-xs">배치 분석 데이터 없음</p>
<p className="text-[9px] text-[#2a3d55] mt-1">make batch-test 실행 필요</p>
<p className="text-[11px] text-tv-border mt-1">make batch-test 실행 필요</p>
</div>
)}
</div>

View File

@@ -0,0 +1,84 @@
/**
* BottomTabs.tsx — 하단 탭: 보유 | 체결내역 | DT 포지션 | 손익곡선 | 로그/저널
* 각 탭은 선택된 탭만 마운트한다(손익곡선·DT 쿼리 불필요 호출 방지). 기존 패널은 노드로 주입받는다.
*/
import React, { useState } from 'react'
import type { TradeHistory } from '../../types/trading'
import type { StrategyDisplay } from '../../lib/tvApi'
import { EquityPanel } from './EquityPanel'
import { DtPositionCards } from './DtPositionCards'
import { fmtPrice } from './chartUtils'
type Tab = 'hold' | 'trades' | 'dt' | 'equity' | 'journal'
interface Props {
holdNode: React.ReactNode
journalNode: React.ReactNode
trades: TradeHistory[] | undefined
onSelect: (code: string) => void
cfg: StrategyDisplay | null
totalAssets?: number
}
const TABS: { v: Tab; label: string }[] = [
{ v: 'hold', label: '보유' }, { v: 'trades', label: '체결내역' }, { v: 'dt', label: 'DT 포지션' },
{ v: 'equity', label: '손익곡선' }, { v: 'journal', label: '로그/저널' },
]
const cls = (v?: number | null) => (v == null || v === 0 ? 'text-tv-text' : v > 0 ? 'text-tv-up' : 'text-tv-down')
const TradesTable = ({ trades, onSelect }: { trades: TradeHistory[] | undefined; onSelect: (c: string) => void }) => {
if (!trades) return <div className="p-4 text-sm text-tv-muted">불러오는 중…</div>
if (trades.length === 0) return <div className="p-4 text-sm text-tv-muted">체결 내역 없음</div>
return (
<table className="w-full text-xs">
<thead className="sticky top-0 bg-tv-panel text-tv-muted">
<tr className="text-left">
<th className="px-2 py-1">시각</th><th className="px-2">종목</th><th className="px-2">구분</th><th className="px-2">전략</th>
<th className="px-2 text-right">수량</th><th className="px-2 text-right">체결가</th><th className="px-2 text-right">순손익</th><th className="px-2">상태</th>
</tr>
</thead>
<tbody>
{trades.map((t) => {
const side = String(t.side ?? t.order_type ?? t.trade_type ?? '').toUpperCase()
const net = t.net_profit_loss != null ? Number(t.net_profit_loss) : t.profit_loss != null ? Number(t.profit_loss) : null
const ts = String(t.executed_at || t.created_at || '')
return (
<tr key={t.id} onClick={() => onSelect(t.stock_code)} className="cursor-pointer hover:bg-tv-raised border-t border-tv-border/50">
<td className="px-2 py-1 font-mono text-tv-muted whitespace-nowrap">{ts.replace('T', ' ').slice(5, 16)}</td>
<td className="px-2 truncate max-w-[9rem]">{t.stock_name || t.stock_code}</td>
<td className={`px-2 font-semibold ${side === 'BUY' ? 'text-tv-up' : 'text-tv-down'}`}>{side === 'BUY' ? '매수' : side === 'SELL' ? '매도' : side}</td>
<td className="px-2 text-tv-muted">{String(t.strategy_type ?? '-')}</td>
<td className="px-2 text-right font-mono">{Number(t.executed_quantity ?? t.quantity ?? 0).toLocaleString('ko-KR')}</td>
<td className="px-2 text-right font-mono">{fmtPrice(Number(t.executed_price ?? t.price))}</td>
<td className={`px-2 text-right font-mono ${cls(net)}`}>{net == null || side === 'BUY' ? '-' : `${net > 0 ? '+' : ''}${Math.round(net).toLocaleString('ko-KR')}`}</td>
<td className="px-2 text-tv-muted">{String(t.status)}</td>
</tr>
)
})}
</tbody>
</table>
)
}
export const BottomTabs = React.memo(({ holdNode, journalNode, trades, onSelect, cfg, totalAssets }: Props) => {
const [tab, setTab] = useState<Tab>('hold')
return (
<div className="flex flex-col h-full min-h-0 bg-tv-panel border-t border-tv-border">
<div className="flex border-b border-tv-border flex-shrink-0 overflow-x-auto" role="tablist">
{TABS.map((t) => (
<button key={t.v} role="tab" aria-selected={tab === t.v} onClick={() => setTab(t.v)}
className={`px-4 py-1.5 text-xs whitespace-nowrap border-b-2 ${tab === t.v ? 'border-tv-accent text-tv-text' : 'border-transparent text-tv-muted hover:text-tv-text'}`}>{t.label}</button>
))}
</div>
<div className="flex-1 min-h-0 overflow-y-auto [&>.tv-fill>div]:!h-full [&>.tv-fill>div]:!max-h-none">
{tab === 'hold' && <div className="tv-fill h-full">{holdNode}</div>}
{tab === 'trades' && <TradesTable trades={trades} onSelect={onSelect} />}
{tab === 'dt' && <DtPositionCards cfg={cfg} totalAssets={totalAssets} />}
{tab === 'equity' && <EquityPanel active />}
{tab === 'journal' && <div className="tv-fill h-full">{journalNode}</div>}
</div>
</div>
)
})
BottomTabs.displayName = 'BottomTabs'

View File

@@ -0,0 +1,126 @@
/**
* DtPositionCards.tsx — 데이트레이딩 포지션 카드 탭
* 손절-현재-목표 게이지, 경과/시간손절, 트레일링, 일일 손실한도.
* 소스: GET /daytrading/positions (기존) + GET /positions/overlay (신규, 실패 시 기존 필드만으로 축소 표시)
*/
import React from 'react'
import { useQuery } from '@tanstack/react-query'
import api from '../../lib/api'
import { usePositionOverlay, StrategyDisplay, OverlayPosition } from '../../lib/tvApi'
import { fmtPrice } from './chartUtils'
interface DtPos {
stock_code: string; stock_name?: string; strategy?: string; entry_price?: number; current_price?: number | null
stop_loss?: number | null; target_price?: number | null; partial_sold?: boolean; quantity?: number
pnl?: number | null; pnl_pct?: number | null; entry_time?: string
}
interface DtResp { positions: DtPos[]; daily_pnl?: number; is_daily_loss_exceeded?: boolean }
const pnlCls = (v?: number | null) => (v == null ? 'text-tv-muted' : v > 0 ? 'text-tv-up' : v < 0 ? 'text-tv-down' : 'text-tv-text')
const signed = (v?: number | null, d = 0) => (v == null ? '-' : `${v > 0 ? '+' : ''}${v.toLocaleString('ko-KR', { maximumFractionDigits: d })}`)
const Gauge = ({ stop, cur, target, entry }: { stop?: number | null; cur?: number | null; target?: number | null; entry?: number | null }) => {
if (stop == null || target == null || cur == null || !(target > stop)) {
return <div className="text-[11px] text-tv-muted">손절/목표 데이터 없음{stop != null ? ` · 손절 ${fmtPrice(stop)}` : ''}</div>
}
const pos = (v: number) => Math.max(0, Math.min(100, ((v - stop) / (target - stop)) * 100))
return (
<div>
<div className="relative h-2.5 rounded bg-tv-raised overflow-hidden">
<div className="absolute inset-y-0 left-0 bg-tv-down/40" style={{ width: `${entry != null ? pos(entry) : 0}%` }} />
<div className="absolute inset-y-0 right-0 bg-tv-up/40" style={{ width: `${entry != null ? 100 - pos(entry) : 0}%` }} />
{entry != null && <div className="absolute inset-y-0 w-px bg-tv-text/60" style={{ left: `${pos(entry)}%` }} />}
<div className="absolute -top-0.5 bottom-[-2px] w-1 bg-tv-text rounded" style={{ left: `calc(${pos(cur)}% - 2px)` }} />
</div>
<div className="flex justify-between text-[11px] font-mono mt-0.5">
<span className="text-tv-down">손절 {fmtPrice(stop)}</span>
<span className="text-tv-text">현재 {fmtPrice(cur)}</span>
<span className="text-tv-up">목표 {fmtPrice(target)}</span>
</div>
</div>
)
}
export const DtPositionCards = React.memo(({ cfg, totalAssets }: { cfg: StrategyDisplay | null; totalAssets?: number }) => {
const { data, isLoading, isError } = useQuery<DtResp>({
queryKey: ['tv-dt-positions'],
queryFn: async () => (await api.get('/daytrading/positions')).data,
refetchInterval: 15000, staleTime: 8000, retry: false,
})
const { data: ov } = usePositionOverlay(undefined)
const ovMap = new Map<string, OverlayPosition>()
for (const p of ov?.positions ?? []) {
const st = (p.strategy ?? '').toLowerCase()
if (st === 'dt' || st === 'daytrading' || st === 'day_trade') ovMap.set(p.stock_code, p)
}
if (isLoading) return <div className="p-4 text-sm text-tv-muted">불러오는 중…</div>
if (isError || !data) return <div className="p-4 text-sm text-tv-muted">DT 포지션 데이터 없음</div>
const lim = cfg?.dt?.daily_loss_pct
const limPct = typeof lim === 'number' ? Math.abs(lim) : null // 백엔드 계약: 퍼센트 단위(-3.0)
const limAmt = limPct != null && totalAssets ? (totalAssets * limPct) / 100 : null
const daily = data.daily_pnl ?? 0
const used = limAmt && daily < 0 ? Math.min(100, (Math.abs(daily) / limAmt) * 100) : 0
const tsMin = cfg?.dt?.time_stop_min
return (
<div className="p-2 flex flex-col gap-2">
<div className="flex flex-wrap items-center gap-3 px-2 py-1.5 rounded border border-tv-border bg-tv-panel text-xs">
<span className="text-tv-muted">일일 손익(순)</span>
<b className={`font-mono ${pnlCls(daily)}`}>{signed(daily)}원</b>
<span className="text-tv-muted">한도</span>
<span className="font-mono">{limPct != null ? `-${limPct}%` : '-'}{limAmt != null ? ` (≈-${Math.round(limAmt).toLocaleString('ko-KR')}원)` : ''}</span>
{limAmt != null && (
<span className="flex-1 min-w-[80px] h-1.5 rounded bg-tv-raised overflow-hidden">
<span className={`block h-full ${used >= 80 ? 'bg-tv-up' : 'bg-tv-warn'}`} style={{ width: `${used}%` }} />
</span>
)}
{data.is_daily_loss_exceeded && <span className="px-1.5 rounded bg-tv-up/20 text-tv-up font-semibold">한도 초과 — 당일 중단</span>}
</div>
{data.positions.length === 0 && <div className="p-4 text-sm text-tv-muted text-center">보유 중인 DT 포지션 없음</div>}
<div className="grid gap-2 grid-cols-1 md:grid-cols-2 xl:grid-cols-3">
{data.positions.map((p) => {
const o = ovMap.get(p.stock_code)
const stop = o?.stop_loss ?? p.stop_loss
const target = o?.target_price ?? p.target_price
const cur = o?.current_price ?? p.current_price
const el = o?.elapsed_min
const tsPct = el != null && tsMin ? Math.min(100, (el / tsMin) * 100) : null
return (
<div key={p.stock_code} className="rounded border border-tv-border bg-tv-panel p-2 text-xs">
<div className="flex items-baseline gap-2 mb-1.5">
<b className="text-sm truncate">{p.stock_name || p.stock_code}</b>
<span className="text-tv-muted font-mono">{p.stock_code}</span>
<span className="px-1 rounded border border-tv-border text-tv-muted">{p.strategy || 'DT'}</span>
{p.partial_sold && <span className="px-1 rounded bg-tv-warn/15 text-tv-warn">분할익절 후</span>}
<span className={`ml-auto font-mono font-semibold ${pnlCls(o?.pnl_pct ?? p.pnl_pct)}`}>
{signed(o?.pnl_pct ?? p.pnl_pct, 2)}%
</span>
</div>
<Gauge stop={stop} cur={cur} target={target} entry={o?.avg_price ?? o?.entry_price ?? p.entry_price} />
<div className="mt-1.5 grid grid-cols-2 gap-x-3 gap-y-0.5 text-[11px]">
<span className="text-tv-muted">수량 <b className="text-tv-text font-mono">{p.quantity ?? '-'}</b></span>
<span className="text-tv-muted">평가손익 <b className={`font-mono ${pnlCls(o?.pnl ?? p.pnl)}`}>{signed(o?.pnl ?? p.pnl)}</b></span>
<span className="text-tv-muted">경과 <b className="text-tv-text font-mono">{el != null ? `${Math.round(el)}분` : '-'}</b>
{tsMin ? ` / 시간손절 ${tsMin}분` : ''}</span>
<span className="text-tv-muted">
트레일링 {o?.trailing_active
? <b className="text-tv-warn font-mono">{fmtPrice(o.trailing_stop)}</b>
: <b className="text-tv-text">{o ? '미발동' : '-'}</b>}
</span>
</div>
{tsPct != null && (
<div className="mt-1 h-1 rounded bg-tv-raised overflow-hidden" title="시간손절까지 경과 비율 (수익률이 기준 미만일 때만 청산)">
<div className={`h-full ${tsPct >= 100 ? 'bg-tv-warn' : 'bg-tv-muted'}`} style={{ width: `${tsPct}%` }} />
</div>
)}
</div>
)
})}
</div>
</div>
)
})
DtPositionCards.displayName = 'DtPositionCards'

View File

@@ -0,0 +1,142 @@
/**
* EquityPanel.tsx — 손익곡선 탭 (GET /risk/equity-history)
* 비용 후 누적 순손익(Baseline: 0 위 빨강/아래 파랑) + 낙폭 영역 + MDD 임계선(축소/강제), 전체/스윙/DT 구분.
* 엔드포인트가 없거나 실패하면 "데이터 없음" 만 표시한다.
*/
import React, { useEffect, useMemo, useRef, useState } from 'react'
import { createChart, LineStyle, IChartApi, ISeriesApi, Time } from 'lightweight-charts'
import { useEquityHistory } from '../../lib/tvApi'
import { tvColor } from './chartUtils'
type Mode = 'all' | 'swing' | 'dt'
const MODES: { v: Mode; label: string }[] = [{ v: 'all', label: '전체' }, { v: 'swing', label: '스윙' }, { v: 'dt', label: 'DT' }]
const DAYS = [30, 90, 180]
export const EquityPanel = React.memo(({ active }: { active: boolean }) => {
const [days, setDays] = useState(90)
const [mode, setMode] = useState<Mode>('all')
const { data, isLoading } = useEquityHistory(days, active)
const el = useRef<HTMLDivElement>(null)
const ddEl = useRef<HTMLDivElement>(null)
const chartRef = useRef<{ cum: IChartApi; dd: IChartApi } | null>(null)
const seriesRef = useRef<{ cum: ISeriesApi<'Baseline'>; dd: ISeriesApi<'Area'> } | null>(null)
const linesRef = useRef<ReturnType<ISeriesApi<'Area'>['createPriceLine']>[]>([])
const thrRef = useRef<{ lo: number }>({ lo: 0 })
const points = useMemo(() => data?.points ?? [], [data])
const cum = useMemo(() => {
let run = 0
return points.map((p) => {
const d = mode === 'swing' ? p.realized_net_swing : mode === 'dt' ? p.realized_net_dt : undefined
if (mode === 'all') return { date: p.date.slice(0, 10), v: p.cum_net }
run += Number(d ?? 0)
return { date: p.date.slice(0, 10), v: run }
})
}, [points, mode])
useEffect(() => {
if (!el.current || !ddEl.current) return
const base = () => ({
autoSize: true,
layout: { background: { color: 'transparent' }, textColor: tvColor('--tv-muted'), fontSize: 12 },
grid: { vertLines: { color: tvColor('--tv-border', 0.3) }, horzLines: { color: tvColor('--tv-border', 0.3) } },
rightPriceScale: { borderColor: tvColor('--tv-border'), minimumWidth: 80 },
timeScale: { borderColor: tvColor('--tv-border') },
})
const cumC = createChart(el.current, { ...base(), localization: { priceFormatter: (v: number) => Math.round(v).toLocaleString('ko-KR') } })
const ddC = createChart(ddEl.current, { ...base(), localization: { priceFormatter: (v: number) => `${v.toFixed(1)}%` } })
const up = tvColor('--tv-up'), down = tvColor('--tv-down')
const cumS = cumC.addBaselineSeries({
baseValue: { type: 'price', price: 0 },
topLineColor: up, topFillColor1: tvColor('--tv-up', 0.3), topFillColor2: tvColor('--tv-up', 0.02),
bottomLineColor: down, bottomFillColor1: tvColor('--tv-down', 0.02), bottomFillColor2: tvColor('--tv-down', 0.3),
lineWidth: 2,
priceFormat: { type: 'price', precision: 0, minMove: 1 },
})
const ddS = ddC.addAreaSeries({
lineColor: tvColor('--tv-warn'), topColor: tvColor('--tv-warn', 0.02), bottomColor: tvColor('--tv-warn', 0.35),
lineWidth: 1, priceLineVisible: false,
priceFormat: { type: 'price', precision: 1, minMove: 0.1 },
autoscaleInfoProvider: () => ({ priceRange: { minValue: Math.min(thrRef.current.lo, -1), maxValue: 0 } }),
})
let syncing = false
const link = (src: IChartApi, dst: IChartApi) => src.timeScale().subscribeVisibleLogicalRangeChange((r) => {
if (!r || syncing) return
syncing = true; dst.timeScale().setVisibleLogicalRange(r); syncing = false
})
link(cumC, ddC); link(ddC, cumC)
chartRef.current = { cum: cumC, dd: ddC }
seriesRef.current = { cum: cumS, dd: ddS }
return () => { cumC.remove(); ddC.remove(); chartRef.current = null; seriesRef.current = null; linesRef.current = [] }
}, [])
useEffect(() => {
const s = seriesRef.current, c = chartRef.current
if (!s || !c) return
s.cum.setData(cum.map((p) => ({ time: p.date as Time, value: p.v })))
const showDd = mode === 'all'
s.dd.setData(showDd ? points
.filter((p) => Number.isFinite(p.drawdown_pct))
.map((p) => ({ time: p.date.slice(0, 10) as Time, value: -Math.abs(p.drawdown_pct as number) })) : [])
linesRef.current.forEach((l) => s.dd.removePriceLine(l))
linesRef.current = []
const th = data?.mdd_thresholds
const lows: number[] = []
if (showDd && th) {
const add = (v: number | undefined, title: string, color: string) => {
if (typeof v !== 'number' || !Number.isFinite(v)) return
const price = -Math.abs(v)
lows.push(price - 2)
linesRef.current.push(s.dd.createPriceLine({ price, color, lineWidth: 1, lineStyle: LineStyle.Dashed, axisLabelVisible: true, title: `${title} ${price}%` }))
}
add(th.reduce_pct, 'MDD 축소', tvColor('--tv-warn'))
add(th.force_pct, 'MDD 강제', tvColor('--tv-up'))
}
thrRef.current.lo = lows.length ? Math.min(...lows) : -1
c.cum.timeScale().fitContent()
}, [cum, points, data, mode])
const last = cum.length ? cum[cum.length - 1].v : null
const empty = !isLoading && points.length === 0
return (
<div className="flex flex-col h-full min-h-0">
<div className="flex flex-wrap items-center gap-2 px-3 py-1.5 text-xs border-b border-tv-border">
<span className="text-tv-muted">비용 차감 후 누적 순손익 · 낙폭=실현손익 낙폭(자산 MDD와 다름)</span>
{last != null && (
<b className={`font-mono ${last > 0 ? 'text-tv-up' : last < 0 ? 'text-tv-down' : ''}`}>
{last > 0 ? '+' : ''}{Math.round(last).toLocaleString('ko-KR')}원
</b>
)}
{!!data?.null_net_rows && (
<span className="text-tv-warn" title="net_profit_loss 가 비어 있는 체결 행 수 — 손익 과대/과소 가능">비용 미기록 {data.null_net_rows}건 제외</span>
)}
{!!data?.excluded_non_krw_rows && (
<span className="text-tv-muted" title="원화 외 체결은 합산에서 제외">비원화 {data.excluded_non_krw_rows}건 제외</span>
)}
<span className="ml-auto flex gap-1">
{MODES.map((m) => (
<button key={m.v} onClick={() => setMode(m.v)} className={`px-2 py-0.5 rounded border ${mode === m.v ? 'border-tv-accent text-tv-accent' : 'border-tv-border text-tv-muted'}`}>{m.label}</button>
))}
<span className="w-2" />
{DAYS.map((d) => (
<button key={d} onClick={() => setDays(d)} className={`px-2 py-0.5 rounded border ${days === d ? 'border-tv-accent text-tv-accent' : 'border-tv-border text-tv-muted'}`}>{d}일</button>
))}
</span>
</div>
<div className="relative flex-1 min-h-[180px] flex flex-col">
<div className="relative flex-1 min-h-[100px]"><div ref={el} className="absolute inset-0" /></div>
<div className={`relative border-t border-tv-border ${mode === 'all' ? 'h-[84px]' : 'hidden'}`}>
<span className="absolute left-2 top-0.5 z-10 text-xs text-tv-muted pointer-events-none">실현손익 낙폭 (%)</span>
<div ref={ddEl} className="absolute inset-0" />
</div>
{(empty || isLoading) && (
<div className="absolute inset-0 z-20 flex items-center justify-center text-sm text-tv-muted bg-tv-bg/70">
{isLoading ? '불러오는 중…' : '데이터 없음'}
</div>
)}
</div>
</div>
)
})
EquityPanel.displayName = 'EquityPanel'

View File

@@ -0,0 +1,140 @@
/**
* RightPanel.tsx — 우측 패널: 선택 종목 포지션 요약 + 탭(관심종목 | 추천 | 호가 | AI)
* 관심종목 = 보유 + DT 감시(/daytrading/watchlist) + 추천(analyzed_stock_list). 클릭 시 차트 심볼 전환.
* 기존 RecommendationPanel(매수 버튼)·OrderbookPanel·AiAnalysisPanel 은 각 탭으로 이동했다.
*/
import React, { useMemo, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import api from '../../lib/api'
import type { OverlayPosition } from '../../lib/tvApi'
import type { Position, RecommendationsData } from '../../types/trading'
import { fmtPrice } from './chartUtils'
type Tab = 'watch' | 'reco' | 'book' | 'ai'
type Src = 'hold' | 'dt' | 'reco'
interface Item { code: string; name: string; price?: number; chg?: number; srcs: Set<Src> }
interface Props {
portfolio: Position[] | undefined
recommendations: RecommendationsData | undefined
selectedStock: string
onSelect: (code: string) => void
livePrices: Record<string, number> | undefined
selectedOverlay: OverlayPosition[] | null
recoNode: React.ReactNode
bookNode: React.ReactNode
aiNode: React.ReactNode
}
const SRC_LABEL: Record<Src, string> = { hold: '보유', dt: 'DT', reco: '추천' }
const cls = (v?: number | null) => (v == null ? 'text-tv-muted' : v > 0 ? 'text-tv-up' : v < 0 ? 'text-tv-down' : 'text-tv-text')
export const RightPanel = React.memo(({
portfolio, recommendations, selectedStock, onSelect, livePrices, selectedOverlay, recoNode, bookNode, aiNode,
}: Props) => {
const [tab, setTab] = useState<Tab>('watch')
const [filter, setFilter] = useState<'all' | Src>('all')
const { data: dtWl } = useQuery<{ watchlist: { code: string; name: string }[] } | null>({
queryKey: ['tv-dt-watchlist'],
queryFn: async () => { try { return (await api.get('/daytrading/watchlist')).data } catch { return null } },
refetchInterval: 60000, staleTime: 30000, retry: false,
})
const items = useMemo(() => {
const m = new Map<string, Item>()
const put = (code: string, name: string, src: Src, price?: number, chg?: number) => {
const it = m.get(code) ?? { code, name, srcs: new Set<Src>() }
it.srcs.add(src)
if (name && (!it.name || it.name === code)) it.name = name
if (price && !it.price) it.price = price
if (chg != null && it.chg == null) it.chg = chg
m.set(code, it)
}
for (const p of portfolio ?? []) put(p.stock_code, p.stock_name, 'hold', p.current_price, p.profit_loss_rate)
for (const w of dtWl?.watchlist ?? []) put(w.code, w.name, 'dt')
for (const s of recommendations?.analyzed_stock_list ?? []) put(s.stock_code, s.stock_name, 'reco', s.current_price, s.change_rate)
return [...m.values()]
}, [portfolio, dtWl, recommendations])
const shown = filter === 'all' ? items : items.filter((i) => i.srcs.has(filter))
const held = portfolio?.find((p) => p.stock_code === selectedStock)
const TABS: { v: Tab; label: string }[] = [{ v: 'watch', label: '관심종목' }, { v: 'reco', label: '추천' }, { v: 'book', label: '호가' }, { v: 'ai', label: 'AI' }]
return (
<div className="flex flex-col h-full min-h-0 bg-tv-panel border-l border-tv-border">
{/* 선택 종목 포지션 요약 */}
<div className="px-3 py-2 border-b border-tv-border text-xs flex-shrink-0">
<div className="text-tv-muted mb-1">선택 종목 포지션</div>
{selectedOverlay === null && !held && <div className="text-tv-muted">포지션 없음 / 오버레이 데이터 없음</div>}
{(selectedOverlay ?? []).map((p, i) => (
<div key={i} className="grid grid-cols-2 gap-x-3 gap-y-0.5 mb-1 font-mono">
<span className="col-span-2 text-tv-text font-sans font-semibold">{(p.strategy ?? '').toUpperCase() || '포지션'} · {p.quantity ?? '-'}주{p.orphan && <span className="ml-1 px-1 rounded bg-tv-warn/15 text-tv-warn font-normal" title="엔진 관리 밖(고아) 포지션">고아</span>}
<b className={`ml-2 ${cls(p.pnl_pct)}`}>{p.pnl_pct != null ? `${p.pnl_pct > 0 ? '+' : ''}${p.pnl_pct.toFixed(2)}%` : ''}</b></span>
<span className="text-tv-muted">평단 <b className="text-tv-text">{fmtPrice(p.avg_price ?? p.entry_price)}</b></span>
<span className="text-tv-muted" title={p.stop_note ?? undefined}>손절 <b className="text-tv-down">{fmtPrice(p.stop_loss)}{p.stop_basis === 'pct' ? '(추정)' : ''}</b></span>
<span className="text-tv-muted">목표 <b className="text-tv-up">{fmtPrice(p.target_price)}</b></span>
<span className="text-tv-muted">트레일링 <b className="text-tv-warn">{p.trailing_active ? fmtPrice(p.trailing_stop) : '-'}</b></span>
</div>
))}
{selectedOverlay !== null && selectedOverlay.length === 0 && held && (
<div className="font-mono text-tv-muted">보유 {held.quantity}주 · 평단 <b className="text-tv-text">{fmtPrice(held.average_price)}</b>
<b className={`ml-2 ${cls(held.profit_loss_rate)}`}>{held.profit_loss_rate > 0 ? '+' : ''}{Number(held.profit_loss_rate).toFixed(2)}%</b></div>
)}
{selectedOverlay !== null && selectedOverlay.length === 0 && !held && <div className="text-tv-muted">보유 없음</div>}
{selectedOverlay === null && held && (
<div className="font-mono text-tv-muted">보유 {held.quantity}주 · 평단 <b className="text-tv-text">{fmtPrice(held.average_price)}</b>
<span className="ml-2">(가격선 데이터 없음)</span></div>
)}
</div>
<div className="flex border-b border-tv-border flex-shrink-0" role="tablist">
{TABS.map((t) => (
<button key={t.v} role="tab" aria-selected={tab === t.v} onClick={() => setTab(t.v)}
className={`flex-1 py-1.5 text-xs border-b-2 ${tab === t.v ? 'border-tv-accent text-tv-text' : 'border-transparent text-tv-muted hover:text-tv-text'}`}>{t.label}</button>
))}
</div>
<div className="flex-1 min-h-0 overflow-hidden">
{tab === 'watch' && (
<div className="h-full flex flex-col">
<div className="flex gap-1 px-2 py-1.5 flex-shrink-0">
{(['all', 'hold', 'dt', 'reco'] as const).map((f) => (
<button key={f} onClick={() => setFilter(f)}
className={`px-2 py-0.5 text-xs rounded border ${filter === f ? 'border-tv-accent text-tv-accent' : 'border-tv-border text-tv-muted'}`}>
{f === 'all' ? '전체' : SRC_LABEL[f]}</button>
))}
</div>
<div className="flex-1 overflow-y-auto">
{shown.length === 0 && <div className="p-4 text-sm text-tv-muted text-center">종목 없음</div>}
{shown.map((it) => {
const price = livePrices?.[it.code] ?? it.price
return (
<button key={it.code} onClick={() => onSelect(it.code)}
className={`w-full flex items-center gap-2 px-3 py-1.5 text-left text-xs border-b border-tv-border/50 hover:bg-tv-raised ${it.code === selectedStock ? 'bg-tv-raised' : ''}`}>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm text-tv-text">{it.name || it.code}</span>
<span className="text-tv-muted font-mono">{it.code}
{[...it.srcs].map((s) => <span key={s} className="ml-1 px-1 rounded border border-tv-border">{SRC_LABEL[s]}</span>)}
</span>
</span>
<span className="text-right font-mono">
<span className="block text-tv-text">{price ? fmtPrice(price) : '-'}</span>
<span className={cls(it.chg)}>{it.chg != null ? `${it.chg > 0 ? '+' : ''}${Number(it.chg).toFixed(2)}%` : ''}</span>
</span>
</button>
)
})}
</div>
</div>
)}
{tab === 'reco' && <div className="h-full overflow-y-auto [&>div]:!h-full [&>div]:!max-h-none">{recoNode}</div>}
{tab === 'book' && <div className="h-full overflow-y-auto [&>div]:!h-full">{bookNode}</div>}
{tab === 'ai' && <div className="h-full overflow-y-auto [&>div]:!h-full">{aiNode}</div>}
</div>
</div>
)
})
RightPanel.displayName = 'RightPanel'

View File

@@ -0,0 +1,130 @@
/**
* SymbolBar.tsx — 상단 심볼 바
* 심볼·현재가·등락 | 타임프레임(D/W/M/5m) | 지표 토글 | 모드 배지(적응형·국면·MDD·WS) | 계좌 요약
* 배지 데이터(/adaptive-strategy/status, /risk/mdd-status)가 실패하면 해당 배지만 "—" 로 표시한다.
*/
import React from 'react'
import { Wifi, WifiOff, ChevronDown, ChevronUp } from 'lucide-react'
import { useAdaptiveStatus, useMddStatus, StrategyDisplay } from '../../lib/tvApi'
import type { Timeframe } from './chartUtils'
import type { ChartToggles } from './TvChart'
interface SymbolBarProps {
stockCode: string
stockName: string
price?: number
changePct?: number | null
timeframe: Timeframe
onTimeframe: (tf: Timeframe) => void
intradayAvailable: boolean
toggles: ChartToggles
onToggle: (k: keyof ChartToggles) => void
wsConnected: boolean
totalAssets?: number
todayPlRate?: number
summaryOpen: boolean
onToggleSummary: () => void
displayCfg: StrategyDisplay | null
}
const MODE_LABEL: Record<string, string> = { aggressive: '공격', normal: '보통', defensive: '방어', crisis: '위기' }
const MODE_CLS: Record<string, string> = {
aggressive: 'text-tv-up border-tv-up/50',
normal: 'text-tv-text border-tv-border',
defensive: 'text-tv-warn border-tv-warn/60',
crisis: 'text-tv-warn border-tv-warn bg-tv-warn/15 font-semibold',
}
const Badge = ({ label, value, cls = '', title }: { label: string; value: string; cls?: string; title?: string }) => (
<span title={title} className={`inline-flex items-center gap-1 px-1.5 py-0.5 rounded border text-[11px] leading-4 whitespace-nowrap ${cls || 'border-tv-border text-tv-text'}`}>
<span className="text-tv-muted">{label}</span>{value}
</span>
)
const TF: { v: Timeframe; label: string }[] = [
{ v: 'D', label: '1D' }, { v: 'W', label: '1W' }, { v: 'M', label: '1M' }, { v: '5m', label: '5m' },
]
const IND: { k: keyof ChartToggles; label: string }[] = [
{ k: 'ma', label: 'MA' }, { k: 'bb', label: 'BB' }, { k: 'vol', label: 'VOL' }, { k: 'rsi', label: 'RSI' }, { k: 'macd', label: 'MACD' },
]
export const SymbolBar = React.memo(({
stockCode, stockName, price, changePct, timeframe, onTimeframe, intradayAvailable,
toggles, onToggle, wsConnected, totalAssets, todayPlRate, summaryOpen, onToggleSummary, displayCfg,
}: SymbolBarProps) => {
const { data: adaptive } = useAdaptiveStatus()
const { data: mdd } = useMddStatus()
const mode = adaptive?.trading_mode ?? displayCfg?.adaptive_mode
const dd = mdd?.drawdown_pct == null ? null : Math.abs(mdd.drawdown_pct)
const reduce = Math.abs(mdd?.thresholds?.reduce_pct ?? displayCfg?.mdd?.reduce_pct ?? NaN)
const force = Math.abs(mdd?.thresholds?.force_pct ?? displayCfg?.mdd?.force_pct ?? NaN)
// drawdown_pct 는 양수=하락폭. 임계는 절대값으로 비교
const mddCls = dd == null ? '' : Number.isFinite(force) && dd >= force ? 'border-tv-up text-tv-up bg-tv-up/15 font-semibold'
: Number.isFinite(reduce) && dd >= reduce ? 'border-tv-up/70 text-tv-up'
: Number.isFinite(reduce) && dd >= reduce * 0.7 ? 'border-tv-warn/60 text-tv-warn' : ''
const regime = adaptive?.condition?.trend_strength
const chgCls = changePct == null ? 'text-tv-muted' : changePct > 0 ? 'text-tv-up' : changePct < 0 ? 'text-tv-down' : 'text-tv-text'
return (
<div className="flex flex-wrap items-center gap-x-4 gap-y-1.5 px-3 py-1.5 bg-tv-panel border-b border-tv-border text-tv-text">
<div className="flex items-baseline gap-2 min-w-0">
<span className={stockName && stockName !== stockCode ? 'text-sm font-mono text-tv-muted' : 'text-base font-bold'}>{stockCode || '종목 선택'}</span>
{stockName && stockName !== stockCode && <span className="text-base font-bold truncate max-w-[10rem]">{stockName}</span>}
<span className={`text-lg font-mono font-bold ${chgCls}`}>{price ? Math.round(price).toLocaleString('ko-KR') : '-'}</span>
<span className={`text-sm font-mono ${chgCls}`}>
{changePct == null ? '' : `${changePct > 0 ? '+' : ''}${changePct.toFixed(2)}%`}
</span>
</div>
<div className="flex items-center rounded overflow-hidden border border-tv-border" role="group" aria-label="타임프레임">
{TF.map(({ v, label }) => {
const disabled = v === '5m' && !intradayAvailable
return (
<button
key={v}
disabled={disabled}
title={disabled ? '분봉 데이터 없음 (장중에만 제공)' : undefined}
onClick={() => onTimeframe(v)}
className={`px-2.5 py-1 text-xs font-mono ${timeframe === v ? 'bg-tv-raised text-tv-text' : 'text-tv-muted hover:text-tv-text'} ${disabled ? 'opacity-40 cursor-not-allowed' : ''}`}
>{label}</button>
)
})}
</div>
<div className="flex items-center gap-1" role="group" aria-label="지표">
<span className="text-xs text-tv-muted mr-0.5">지표</span>
{IND.map(({ k, label }) => (
<button
key={k}
onClick={() => onToggle(k)}
aria-pressed={toggles[k]}
className={`px-2 py-0.5 text-xs rounded border ${toggles[k] ? 'border-tv-accent text-tv-accent bg-tv-accent/10' : 'border-tv-border text-tv-muted hover:text-tv-text'}`}
>{label}</button>
))}
</div>
<div className="flex flex-wrap items-center gap-1.5 ml-auto">
<Badge label="모드" value={mode ? (MODE_LABEL[mode] ?? mode) + (adaptive?.fallback_used ? '*' : '') : '—'}
cls={mode ? MODE_CLS[mode] : ''} title={adaptive?.fallback_used ? '적응형 폴백 사용 중' : '적응형 모드'} />
<Badge label="국면" value={regime != null ? String(regime) : '—'} />
<Badge label="MDD" value={dd == null ? '—' : `-${Math.abs(dd).toFixed(1)}%${mdd?.is_paused ? ' 중단' : ''}`}
cls={mddCls} title={Number.isFinite(reduce) ? `축소 -${reduce}% / 강제 -${force}%` : 'MDD 임계 데이터 없음'} />
<span className={`inline-flex items-center gap-1 text-[11px] ${wsConnected ? 'text-tv-text' : 'text-tv-warn'}`} title="실시간 연결">
{wsConnected ? <Wifi className="w-3.5 h-3.5" /> : <WifiOff className="w-3.5 h-3.5" />}
{wsConnected ? 'LIVE' : '연결 끊김'}
</span>
<button onClick={onToggleSummary} className="inline-flex items-center gap-1 px-2 py-0.5 text-[11px] rounded border border-tv-border text-tv-muted hover:text-tv-text">
{totalAssets != null && <span className="font-mono text-tv-text">{Math.round(totalAssets).toLocaleString('ko-KR')}</span>}
{todayPlRate != null && (
<span className={`font-mono ${todayPlRate > 0 ? 'text-tv-up' : todayPlRate < 0 ? 'text-tv-down' : ''}`}>
{todayPlRate > 0 ? '+' : ''}{todayPlRate.toFixed(2)}%
</span>
)}
계좌 {summaryOpen ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />}
</button>
</div>
</div>
)
})
SymbolBar.displayName = 'SymbolBar'

View File

@@ -0,0 +1,335 @@
/**
* TvChart.tsx — TradingView 스타일 메인 차트
*
* 구성: 캔들 + 거래량(오버레이 스케일) + MA5/20/60 + 볼린저(20,2) | RSI 패널 | MACD 패널(토글)
* - 가격선: /positions/overlay 의 평단·손절·목표·트레일링 (null 이면 그리지 않음)
* - 체결 마커: ▲매수(빨강) ▼매도(파랑) — 봉에 스냅
* - RSI 기준선: /strategy-config/display 의 rsi 설정값. 값이 없으면 기준선 숨김(하드코딩 금지)
* - 패널 간 시간축 동기화, autoSize(리사이즈 대응)
* - 라이브 시세: 일봉(D)에서 오늘 캔들만 update (차트 재생성 없음)
*/
import React, { useEffect, useMemo, useRef, useState } from 'react'
import {
createChart, CrosshairMode, LineStyle,
IChartApi, ISeriesApi, IPriceLine, SeriesMarker, Time, LogicalRange,
} from 'lightweight-charts'
import { calculateSMA, calculateBollingerBands, calculateRSI, calculateMACD } from '../../utils/technicalIndicators'
import type { OverlayPosition, StrategyDisplay } from '../../lib/tvApi'
import { Bar, Timeframe, TradeMark, snapToBar, tvColor, todayKst, fmtPrice } from './chartUtils'
export interface ChartToggles { ma: boolean; bb: boolean; vol: boolean; rsi: boolean; macd: boolean }
interface TvChartProps {
bars: Bar[]
timeframe: Timeframe
loading: boolean
emptyReason?: string | null
livePrice?: number
overlays: OverlayPosition[] | null
trades: TradeMark[]
rsiCfg: StrategyDisplay['rsi'] | null
toggles: ChartToggles
/** 값이 바뀔 때마다 화면 맞춤(fitContent) */
fitSignal: number
/** 미국 종목이면 소수 2자리, 원화는 정수 */
isUs: boolean
}
interface LegendRow { o: number; h: number; l: number; c: number; v: number; ma5?: number; ma20?: number; ma60?: number; rsi?: number }
const num = (v: unknown): v is number => typeof v === 'number' && Number.isFinite(v)
const clean = <T extends { value: number }>(arr: (T | null)[]): T[] => arr.filter((x): x is T => x !== null && Number.isFinite(x.value))
export const TvChart = React.memo(({
bars, timeframe, loading, emptyReason, livePrice, overlays, trades, rsiCfg, toggles, fitSignal, isUs,
}: TvChartProps) => {
const mainEl = useRef<HTMLDivElement>(null)
const rsiEl = useRef<HTMLDivElement>(null)
const macdEl = useRef<HTMLDivElement>(null)
const charts = useRef<{ main: IChartApi; rsi: IChartApi; macd: IChartApi } | null>(null)
const series = useRef<{
candle: ISeriesApi<'Candlestick'>; vol: ISeriesApi<'Histogram'>
ma5: ISeriesApi<'Line'>; ma20: ISeriesApi<'Line'>; ma60: ISeriesApi<'Line'>
bbU: ISeriesApi<'Line'>; bbL: ISeriesApi<'Line'>
rsi: ISeriesApi<'Line'>
macdHist: ISeriesApi<'Histogram'>; macd: ISeriesApi<'Line'>; macdSig: ISeriesApi<'Line'>
} | null>(null)
const priceLines = useRef<IPriceLine[]>([])
const rsiLines = useRef<IPriceLine[]>([])
const lastBar = useRef<Bar | null>(null)
const legendMap = useRef<Map<string | number, LegendRow>>(new Map())
const [legend, setLegend] = useState<LegendRow | null>(null)
const rsiPeriod = rsiCfg?.period && rsiCfg.period > 1 ? rsiCfg.period : 14
// ── 1) 차트 3개 생성 (마운트 1회) ──────────────────────────────
useEffect(() => {
if (!mainEl.current || !rsiEl.current || !macdEl.current) return
const base = () => ({
autoSize: true,
layout: { background: { color: 'transparent' }, textColor: tvColor('--tv-muted'), fontSize: 12 },
grid: { vertLines: { color: tvColor('--tv-border', 0.35) }, horzLines: { color: tvColor('--tv-border', 0.35) } },
crosshair: { mode: CrosshairMode.Normal },
rightPriceScale: { borderColor: tvColor('--tv-border'), minimumWidth: 68 },
timeScale: { borderColor: tvColor('--tv-border'), rightOffset: 4, timeVisible: false },
handleScale: { axisPressedMouseMove: true },
})
const main = createChart(mainEl.current, base())
const rsiC = createChart(rsiEl.current, { ...base(), timeScale: { ...base().timeScale, visible: false } })
rsiC.priceScale('right').applyOptions({ scaleMargins: { top: 0.1, bottom: 0.1 } })
const macdC = createChart(macdEl.current, base())
const up = tvColor('--tv-up'), down = tvColor('--tv-down')
const candle = main.addCandlestickSeries({
upColor: up, downColor: down, borderUpColor: up, borderDownColor: down, wickUpColor: up, wickDownColor: down,
priceLineVisible: true,
})
const vol = main.addHistogramSeries({ priceFormat: { type: 'volume' }, priceScaleId: 'vol', lastValueVisible: false, priceLineVisible: false })
main.priceScale('vol').applyOptions({ scaleMargins: { top: 0.86, bottom: 0 } })
candle.priceScale().applyOptions({ scaleMargins: { top: 0.06, bottom: 0.16 } })
const line = (color: string, w: 1 | 2 = 1) => main.addLineSeries({ color, lineWidth: w, lastValueVisible: false, priceLineVisible: false, crosshairMarkerVisible: false })
const bbColor = tvColor('--tv-muted', 0.7)
const s = {
candle, vol,
ma5: line('#ffd54f'), ma20: line('#26c6da'), ma60: line('#ab47bc'),
bbU: line(bbColor), bbL: line(bbColor),
rsi: rsiC.addLineSeries({
priceFormat: { type: 'price', precision: 0, minMove: 1 },
color: tvColor('--tv-accent'), lineWidth: 2, priceLineVisible: false,
autoscaleInfoProvider: () => ({ priceRange: { minValue: 0, maxValue: 100 } }),
}),
macdHist: macdC.addHistogramSeries({ priceLineVisible: false, lastValueVisible: false }),
macd: macdC.addLineSeries({ color: '#26c6da', lineWidth: 1, priceLineVisible: false, lastValueVisible: false }),
macdSig: macdC.addLineSeries({ color: '#ffd54f', lineWidth: 1, priceLineVisible: false, lastValueVisible: false }),
}
charts.current = { main, rsi: rsiC, macd: macdC }
series.current = s
// 시간축 동기화 (논리 범위). 재귀 호출 방지용 플래그
let syncing = false
const all = [main, rsiC, macdC]
const handlers = all.map((src) => {
const h = (r: LogicalRange | null) => {
if (!r || syncing) return
syncing = true
for (const dst of all) if (dst !== src) dst.timeScale().setVisibleLogicalRange(r)
syncing = false
}
src.timeScale().subscribeVisibleLogicalRangeChange(h)
return h
})
main.subscribeCrosshairMove((p) => {
const key = p.time as string | number | undefined
const row = key != null ? legendMap.current.get(key) : undefined
setLegend(row ?? null)
})
return () => {
all.forEach((c, i) => c.timeScale().unsubscribeVisibleLogicalRangeChange(handlers[i]))
all.forEach((c) => c.remove())
charts.current = null
series.current = null
priceLines.current = []
rsiLines.current = []
}
}, [])
// ── 2) 데이터/지표 ────────────────────────────────────────────
const computed = useMemo(() => {
if (!bars.length) return null
const closes = bars.map((b) => b.close)
const bb = calculateBollingerBands(closes, 20)
return {
ma5: calculateSMA(closes, 5), ma20: calculateSMA(closes, 20), ma60: calculateSMA(closes, 60),
bbU: bb.upper, bbL: bb.lower,
rsi: calculateRSI(closes, rsiPeriod),
macd: calculateMACD(closes),
}
}, [bars, rsiPeriod])
useEffect(() => {
const s = series.current, c = charts.current
if (!s || !c) return
const intraday = timeframe === '5m'
for (const ch of [c.main, c.macd]) ch.applyOptions({ timeScale: { timeVisible: intraday, secondsVisible: false } })
if (!bars.length || !computed) {
for (const k of Object.keys(s) as (keyof typeof s)[]) (s[k] as ISeriesApi<'Line'>).setData([])
lastBar.current = null
legendMap.current = new Map()
setLegend(null)
return
}
const up = tvColor('--tv-up'), down = tvColor('--tv-down')
const upA = tvColor('--tv-up', 0.28), downA = tvColor('--tv-down', 0.28)
const t = (i: number) => bars[i].time as Time
const mk = (arr: number[]) => clean(arr.map((v, i) => (num(v) ? { time: t(i), value: v } : null)))
s.candle.setData(bars.map((b) => ({ time: b.time as Time, open: b.open, high: b.high, low: b.low, close: b.close })))
s.vol.setData(bars.map((b) => ({ time: b.time as Time, value: b.volume, color: b.close >= b.open ? upA : downA })))
s.ma5.setData(mk(computed.ma5)); s.ma20.setData(mk(computed.ma20)); s.ma60.setData(mk(computed.ma60))
s.bbU.setData(mk(computed.bbU)); s.bbL.setData(mk(computed.bbL))
s.rsi.setData(mk(computed.rsi))
s.macd.setData(mk(computed.macd.macd)); s.macdSig.setData(mk(computed.macd.signal))
s.macdHist.setData(clean(computed.macd.histogram.map((v, i) => (num(v) ? { time: t(i), value: v, color: v >= 0 ? up : down } : null))))
lastBar.current = bars[bars.length - 1]
const m = new Map<string | number, LegendRow>()
bars.forEach((b, i) => m.set(b.time, {
o: b.open, h: b.high, l: b.low, c: b.close, v: b.volume,
ma5: computed.ma5[i], ma20: computed.ma20[i], ma60: computed.ma60[i], rsi: computed.rsi[i],
}))
legendMap.current = m
setLegend(null)
c.main.timeScale().fitContent()
}, [bars, computed, timeframe])
// 표시 토글
useEffect(() => {
const s = series.current
if (!s) return
const vis = (x: ISeriesApi<'Line'> | ISeriesApi<'Histogram'>, v: boolean) => x.applyOptions({ visible: v })
vis(s.ma5, toggles.ma); vis(s.ma20, toggles.ma); vis(s.ma60, toggles.ma)
vis(s.bbU, toggles.bb); vis(s.bbL, toggles.bb)
vis(s.vol, toggles.vol)
}, [toggles.ma, toggles.bb, toggles.vol])
// 가격 포맷: 원화=정수+천단위, 미국=소수 2자리 (축·가격선·크로스헤어 공통)
useEffect(() => {
const c = charts.current, s = series.current
if (!c || !s) return
const fmt = (v: number) => (isUs ? v.toFixed(2) : Math.round(v).toLocaleString('ko-KR'))
c.main.applyOptions({ localization: { priceFormatter: fmt } })
const pf = isUs ? { type: 'price' as const, precision: 2, minMove: 0.01 } : { type: 'price' as const, precision: 0, minMove: 1 }
for (const x of [s.candle, s.ma5, s.ma20, s.ma60, s.bbU, s.bbL]) x.applyOptions({ priceFormat: pf })
}, [isUs])
// 화면 맞춤
useEffect(() => {
charts.current?.main.timeScale().fitContent()
}, [fitSignal])
// 라이브 시세: 일봉의 오늘 캔들만 갱신
useEffect(() => {
const s = series.current, lb = lastBar.current
if (!s || !lb || !num(livePrice) || livePrice <= 0 || timeframe !== 'D') return
if (lb.time !== todayKst()) return
const next = { ...lb, close: livePrice, high: Math.max(lb.high, livePrice), low: Math.min(lb.low, livePrice) }
lastBar.current = next
s.candle.update({ time: next.time as Time, open: next.open, high: next.high, low: next.low, close: next.close })
}, [livePrice, timeframe])
// ── 3) 가격선 (진입/손절/목표/트레일링) ─────────────────────────
useEffect(() => {
const s = series.current
if (!s) return
priceLines.current.forEach((l) => s.candle.removePriceLine(l))
priceLines.current = []
if (!overlays) return
const multi = overlays.length > 1
for (const p of overlays) {
const tag = multi || p.strategy ? `${(p.strategy ?? '').toUpperCase().slice(0, 5)} ` : ''
const add = (price: number | null | undefined, title: string, color: string, style: LineStyle) => {
if (!num(price) || price <= 0) return
priceLines.current.push(s.candle.createPriceLine({
price, color, lineWidth: 1, lineStyle: style, axisLabelVisible: true, title: `${tag}${title}`,
}))
}
add(p.avg_price ?? p.entry_price, '평단', tvColor('--tv-text'), LineStyle.Dashed)
add(p.stop_loss, p.stop_basis === 'pct' ? (p.stop_loss_alt != null ? '손절(추정 -7~-10%)' : '손절(추정)') : p.stop_basis ? `손절(${p.stop_basis})` : '손절', tvColor('--tv-down'), LineStyle.Solid)
add(p.target_price, '목표', tvColor('--tv-up'), LineStyle.Solid)
if (p.trailing_active) add(p.trailing_stop, '트레일링', tvColor('--tv-warn'), LineStyle.Dotted)
}
}, [overlays, bars])
// ── 4) 체결 마커 ───────────────────────────────────────────────
useEffect(() => {
const s = series.current
if (!s) return
if (!bars.length || !trades.length) { s.candle.setMarkers([]); return }
const times = bars.map((b) => b.time)
const up = tvColor('--tv-up'), down = tvColor('--tv-down')
const marks: SeriesMarker<Time>[] = []
for (const tr of trades) {
const bt = snapToBar(tr.ts, times, timeframe)
if (bt == null) continue
const net = tr.net != null && tr.side === 'sell' ? ` ${tr.net > 0 ? '+' : ''}${Math.round(tr.net).toLocaleString('ko-KR')}` : ''
marks.push(tr.side === 'buy'
? { time: bt as Time, position: 'belowBar', shape: 'arrowUp', color: up, text: `매수 ${fmtPrice(tr.price)}` }
: { time: bt as Time, position: 'aboveBar', shape: 'arrowDown', color: down, text: `매도${net}` })
}
marks.sort((a, b) => ((a.time as string | number) < (b.time as string | number) ? -1 : (a.time as string | number) > (b.time as string | number) ? 1 : 0))
s.candle.setMarkers(marks)
}, [trades, bars, timeframe])
// ── 5) RSI 기준선 (설정값만) ───────────────────────────────────
useEffect(() => {
const s = series.current
if (!s) return
rsiLines.current.forEach((l) => s.rsi.removePriceLine(l))
rsiLines.current = []
if (!rsiCfg) return
const add = (v: number | undefined, _title: string, color: string) => {
if (!num(v)) return
rsiLines.current.push(s.rsi.createPriceLine({ price: v, color, lineWidth: 1, lineStyle: LineStyle.Dashed, axisLabelVisible: true, title: String(v) }))
}
add(rsiCfg.bull_entry, '진입', tvColor('--tv-up'))
add(rsiCfg.bear_entry, '약세진입', tvColor('--tv-muted'))
add(rsiCfg.partial_exit, '부분익절', tvColor('--tv-down', 0.8))
add(rsiCfg.full_exit, '전량', tvColor('--tv-down'))
}, [rsiCfg])
const lg = legend ?? (bars.length ? legendMap.current.get(bars[bars.length - 1].time) ?? null : null)
const chg = lg ? lg.c - lg.o : 0
const col = chg >= 0 ? 'text-tv-up' : 'text-tv-down'
const f = (v?: number) => (num(v) ? Math.round(v).toLocaleString('ko-KR') : '-')
return (
<div className="relative flex flex-col w-full h-full min-h-0 bg-tv-bg">
{/* OHLC 범례 */}
<div className="absolute left-2 top-1 z-10 pointer-events-none text-xs font-mono flex flex-wrap gap-x-3 gap-y-0.5 max-w-[calc(100%-5rem)]">
{lg && (
<>
<span className="text-tv-muted">시 <b className={col}>{f(lg.o)}</b></span>
<span className="text-tv-muted">고 <b className={col}>{f(lg.h)}</b></span>
<span className="text-tv-muted">저 <b className={col}>{f(lg.l)}</b></span>
<span className="text-tv-muted">종 <b className={col}>{f(lg.c)}</b></span>
<span className="text-tv-muted">거래량 <b className="text-tv-text">{f(lg.v)}</b></span>
{toggles.ma && (
<>
<span style={{ color: '#ffd54f' }}>MA5 {f(lg.ma5)}</span>
<span style={{ color: '#26c6da' }}>MA20 {f(lg.ma20)}</span>
<span style={{ color: '#ab47bc' }}>MA60 {f(lg.ma60)}</span>
</>
)}
</>
)}
</div>
<div ref={mainEl} className="flex-1 min-h-[160px]" />
<div className={`relative border-t border-tv-border ${toggles.rsi ? 'h-[160px]' : 'hidden'}`}>
<span className="absolute left-2 top-0.5 z-10 text-xs text-tv-muted pointer-events-none">
RSI({rsiPeriod}) <b className="text-tv-accent">{num(lg?.rsi) ? lg!.rsi!.toFixed(1) : '-'}</b>
{!rsiCfg && <span className="ml-2 text-tv-muted">기준선: 설정 데이터 없음</span>}
</span>
<div ref={rsiEl} className="absolute inset-0" />
</div>
<div className={`relative border-t border-tv-border ${toggles.macd ? 'h-[100px]' : 'hidden'}`}>
<span className="absolute left-2 top-0.5 z-10 text-xs text-tv-muted pointer-events-none">MACD(12,26,9)</span>
<div ref={macdEl} className="absolute inset-0" />
</div>
{bars.length === 0 && (
<div className="absolute inset-0 z-20 flex items-center justify-center bg-tv-bg/70 text-sm text-tv-muted">
{loading ? '차트 불러오는 중…' : emptyReason || '데이터 없음'}
</div>
)}
{bars.length > 0 && loading && (
<div className="absolute right-3 top-1 z-20 text-xs text-tv-muted">갱신 중…</div>
)}
</div>
)
})
TvChart.displayName = 'TvChart'

View File

@@ -0,0 +1,105 @@
/**
* chartUtils.ts — TvChart 공용 순수 헬퍼 (시간 변환, 정규화, 마커 스냅, CSS 토큰 읽기)
*/
import type { CandleData } from '../../types/trading'
import type { IntradayCandle } from '../../lib/tvApi'
export type Timeframe = 'D' | 'W' | 'M' | '5m'
export interface Bar { time: string | number; open: number; high: number; low: number; close: number; volume: number }
/** KST 는 UTC+9 고정(서머타임 없음). lightweight-charts 는 epoch 를 UTC 로 그리므로 9시간을 더해 KST 시각으로 표시한다 */
export const KST_OFFSET_SEC = 9 * 3600
/** CSS 토큰(:root --tv-*) → 'rgb(r,g,b)' (alpha 지정 시 rgba). 라이트/다크 토큰 전환을 차트가 따라가게 한다 */
export const tvColor = (name: string, alpha?: number): string => {
const raw = getComputedStyle(document.documentElement).getPropertyValue(name).trim()
const parts = raw.split(/\s+/).filter(Boolean)
if (parts.length !== 3) return alpha == null ? '#888' : `rgba(136,136,136,${alpha})`
return alpha == null ? `rgb(${parts.join(',')})` : `rgba(${parts.join(',')},${alpha})`
}
const ymdKst = (d: Date): string =>
new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Seoul', year: 'numeric', month: '2-digit', day: '2-digit' }).format(d)
export const todayKst = (): string => ymdKst(new Date())
/** 'YYYY-MM-DD' / 'YYYYMMDD' / Date → 'YYYY-MM-DD' */
export const toYmd = (date: string | Date): string => {
if (typeof date === 'string') {
if (/^\d{4}-\d{2}-\d{2}/.test(date)) return date.slice(0, 10)
if (/^\d{8}$/.test(date)) return `${date.slice(0, 4)}-${date.slice(4, 6)}-${date.slice(6, 8)}`
}
return ymdKst(new Date(date))
}
/** 시간 오름차순 정렬 + 중복 시각 제거(lightweight-charts 는 중복/역순에서 예외를 던진다) */
const normalize = (bars: Bar[]): Bar[] => {
const sorted = [...bars].sort((a, b) => (a.time < b.time ? -1 : a.time > b.time ? 1 : 0))
const out: Bar[] = []
for (const b of sorted) {
if (out.length && out[out.length - 1].time === b.time) out[out.length - 1] = b
else out.push(b)
}
return out
}
export const barsFromDaily = (data: CandleData[]): Bar[] =>
normalize(
data
.map((c) => ({ time: toYmd(c.date), open: c.open, high: c.high, low: c.low, close: c.close, volume: c.volume }))
.filter((b) => [b.open, b.high, b.low, b.close].every(Number.isFinite) && b.close > 0)
)
export const barsFromIntraday = (data: IntradayCandle[]): Bar[] =>
normalize(data.map((c) => ({ ...c, time: c.time + KST_OFFSET_SEC })))
/** 거래 레코드(신/구 필드 혼용)에서 방향·가격·시각을 뽑는다. 구 필드: order_type/executed_price/executed_at */
export interface TradeMark { side: 'buy' | 'sell'; price: number; ts: number; qty: number; net: number | null }
const DONE = ['EXECUTED', 'COMPLETED', 'FILLED', 'PARTIAL', 'PARTIALLY_FILLED']
export const toTradeMarks = (rows: unknown): TradeMark[] => {
if (!Array.isArray(rows)) return []
const out: TradeMark[] = []
for (const r of rows as Record<string, unknown>[]) {
const status = typeof r.status === 'string' ? r.status.toUpperCase() : ''
if (status && !DONE.includes(status)) continue
const sideRaw = String(r.side ?? r.order_type ?? r.trade_type ?? '').toLowerCase()
if (sideRaw !== 'buy' && sideRaw !== 'sell') continue
const price = Number(r.executed_price ?? r.price)
const rawTs = (r.executed_at ?? r.created_at) as string | undefined
if (!rawTs || !(price > 0)) continue
// 타임존 표기 없는 문자열은 서버 KST 로컬 시각으로 취급(parseKst 규약과 동일)
const hasTz = /[zZ]|[+-]\d{2}:?\d{2}$/.test(rawTs)
const ms = new Date(hasTz ? rawTs : `${rawTs}+09:00`).getTime()
if (!Number.isFinite(ms)) continue
const net = r.net_profit_loss
out.push({
side: sideRaw as 'buy' | 'sell',
price,
ts: ms / 1000,
qty: Number(r.executed_quantity ?? r.quantity) || 0,
net: net == null || !Number.isFinite(Number(net)) ? null : Number(net),
})
}
return out.sort((a, b) => a.ts - b.ts)
}
/** 체결 시각 → 해당 봉 time. 대응 봉이 없으면 null */
export const snapToBar = (ts: number, times: (string | number)[], tf: Timeframe): string | number | null => {
if (!times.length) return null
const key: string | number = tf === '5m' ? ts + KST_OFFSET_SEC : ymdKst(new Date(ts * 1000))
let lo = 0, hi = times.length - 1, idx = -1
while (lo <= hi) {
const mid = (lo + hi) >> 1
if (times[mid] <= key) { idx = mid; lo = mid + 1 } else hi = mid - 1
}
if (idx < 0) return null
// 분봉은 같은 5분 버킷 안일 때만(다른 날 체결이 마지막 봉에 붙는 것 방지)
if (tf === '5m' && (key as number) - (times[idx] as number) >= 5 * 60) return null
return times[idx]
}
export const fmtPrice = (v: number | null | undefined): string =>
v == null || !Number.isFinite(v) ? '-' : Math.round(v).toLocaleString('ko-KR')

View File

@@ -17,6 +17,33 @@
-moz-osx-font-smoothing: grayscale;
}
/* TradingView 스타일 토큰 (RGB 3원소 — tailwind 의 <alpha-value> 와 호환).
한국식: 상승/이익 = 빨강(up), 하락/손실 = 파랑(down), 경고 = 앰버(warn, 빨강과 분리) */
:root {
--tv-bg: 19 23 34;
--tv-panel: 30 34 45;
--tv-raised: 42 46 57;
--tv-border: 54 58 69;
--tv-text: 209 212 220;
--tv-muted: 138 142 154;
--tv-up: 242 54 69;
--tv-down: 41 98 255;
--tv-warn: 255 167 38;
--tv-accent: 179 136 255;
}
[data-theme='light'] {
--tv-bg: 255 255 255;
--tv-panel: 249 249 249;
--tv-raised: 240 243 250;
--tv-border: 224 227 235;
--tv-text: 19 23 34;
--tv-muted: 106 109 120;
--tv-up: 214 40 56;
--tv-down: 33 80 224;
--tv-warn: 230 120 0;
--tv-accent: 103 58 183;
}
body {
margin: 0;
min-width: 320px;

163
frontend/src/lib/tvApi.ts Normal file
View File

@@ -0,0 +1,163 @@
/**
* tvApi.ts — TradingView 스타일 대시보드용 신규 엔드포인트 훅 모음
*
* 모든 쿼리는 404/5xx/네트워크 오류를 `null` 로 흡수한다(구 백엔드 배포 중일 수 있음).
* 호출부는 null 이면 해당 오버레이/패널만 "데이터 없음" 으로 처리하고, 화면 전체는 깨지지 않는다.
* retry:false — 404 를 반복 호출하지 않는다. 미지원(404)이면 재조회 주기를 길게 둔다.
*/
import { useQuery } from '@tanstack/react-query'
import api from './api'
/** GET /positions/overlay 의 한 포지션 */
export interface OverlayPosition {
stock_code: string
stock_name?: string | null
strategy?: string | null
entry_price?: number | null
avg_price?: number | null
quantity?: number | null
current_price?: number | null
pnl?: number | null
pnl_pct?: number | null
stop_loss?: number | null
stop_basis?: 'atr' | 'pct' | 'stored' | null
target_price?: number | null
trailing_active?: boolean | null
trailing_stop?: number | null
highest_price?: number | null
entry_time?: string | null
elapsed_min?: number | null
time_stop_at?: string | null
orphan?: boolean | null
stop_loss_alt?: number | string | null
stop_note?: string | null
}
export interface EquityPoint {
date: string
realized_net: number
realized_net_swing?: number
realized_net_dt?: number
realized_net_other?: number
cum_net: number
peak?: number
drawdown_pct?: number
}
export interface EquityHistory {
cost_basis?: string
points: EquityPoint[]
mdd_thresholds?: { reduce_pct?: number; force_pct?: number }
null_net_rows?: number
excluded_non_krw_rows?: number
}
export interface StrategyDisplay {
rsi?: { period?: number; bull_entry?: number; bear_entry?: number; partial_exit?: number; full_exit?: number }
atr?: { stop_mult?: number; trailing_mult?: number }
stop_loss_pct?: number
dt?: { daily_loss_pct?: number; time_stop_min?: number; time_stop_min_gain?: number; eod_flat?: string; entry_cutoff?: string }
mdd?: { reduce_pct?: number; force_pct?: number }
adaptive_mode?: string
source?: string
sources?: { entry?: string; exit?: string }
}
export interface MddStatus {
drawdown_pct?: number | null
is_paused?: boolean
reduction_applied?: unknown
thresholds?: { reduce_pct?: number; force_pct?: number }
}
export interface AdaptiveStatus {
trading_mode?: string
fallback_used?: boolean
condition?: { trend_strength?: string | number; volume_regime?: string; adx_value?: number }
}
/** 오류 → null. 호출부에서 "데이터 없음" 처리 */
async function getOrNull<T>(url: string, params?: Record<string, unknown>): Promise<T | null> {
try {
const res = await api.get(url, { params })
return (res.data ?? null) as T | null
} catch {
return null
}
}
const isOpenHint = () => document.visibilityState === 'visible'
/** 선택 종목(code) 또는 전체(code 생략)의 포지션 오버레이 */
export const usePositionOverlay = (code?: string, enabled = true) =>
useQuery<{ positions: OverlayPosition[]; as_of?: string } | null>({
queryKey: ['tv-pos-overlay', code ?? 'all'],
queryFn: () => getOrNull('/positions/overlay', code ? { code } : undefined),
enabled,
staleTime: 10_000,
refetchInterval: (q) => (q.state.data === null ? 120_000 : isOpenHint() ? 15_000 : false),
retry: false,
})
export const useEquityHistory = (days: number, enabled: boolean) =>
useQuery<EquityHistory | null>({
queryKey: ['tv-equity-history', days],
queryFn: () => getOrNull('/risk/equity-history', { days }),
enabled,
staleTime: 60_000,
refetchInterval: false,
retry: false,
})
export const useStrategyDisplay = () =>
useQuery<StrategyDisplay | null>({
queryKey: ['tv-strategy-display'],
queryFn: () => getOrNull('/strategy-config/display'),
staleTime: 60_000,
refetchInterval: 5 * 60_000,
retry: false,
})
export const useMddStatus = () =>
useQuery<MddStatus | null>({
queryKey: ['tv-mdd-status'],
queryFn: () => getOrNull('/risk/mdd-status'),
staleTime: 20_000,
refetchInterval: 30_000,
retry: false,
})
export const useAdaptiveStatus = () =>
useQuery<AdaptiveStatus | null>({
queryKey: ['tv-adaptive-status'],
queryFn: () => getOrNull('/adaptive-strategy/status'),
staleTime: 30_000,
refetchInterval: 60_000,
retry: false,
})
/** 분봉 캔들 (period=5m). 성공(데이터 있음)해야만 5m 타임프레임이 활성화된다. */
export interface IntradayCandle { time: number; open: number; high: number; low: number; close: number; volume: number }
export const useIntradayCandles = (code: string, enabled: boolean, live: boolean) =>
useQuery<IntradayCandle[] | null>({
queryKey: ['tv-chart-5m', code],
queryFn: async () => {
const r = await getOrNull<{ data?: Record<string, unknown>[]; is_sample?: boolean; error?: string }>(
`/market-data/chart/${code}`, { period: '5m' })
if (!r || r.is_sample || r.error || !Array.isArray(r.data) || r.data.length === 0) return null
const out: IntradayCandle[] = []
for (const c of r.data) {
const t = Number(c.time)
const o = Number(c.open), h = Number(c.high), l = Number(c.low), cl = Number(c.close)
if (![t, o, h, l, cl].every(Number.isFinite)) continue
out.push({ time: t, open: o, high: h, low: l, close: cl, volume: Number(c.volume) || 0 })
}
out.sort((a, b) => a.time - b.time)
return out.length ? out : null
},
enabled: enabled && !!code,
staleTime: 30_000,
refetchInterval: (q) => (live && q.state.data && !document.hidden ? 60_000 : false),
retry: false,
})

View File

@@ -31,7 +31,7 @@ import { useTenantMode } from '../lib/tenant'
import { getTradesV2 } from '../lib/api'
import { PortfolioSummary } from '../components/PortfolioSummary'
import PortfolioPanel from '../components/PortfolioPanel'
import ChartPanel, { AiAnalysisPanel } from '../components/ChartPanel'
import { AiAnalysisPanel } from '../components/ChartPanel'
import OrderbookPanel from '../components/OrderbookPanel'
import RecommendationPanel from '../components/RecommendationPanel'
import JournalPanel from '../components/JournalPanel'
@@ -41,6 +41,12 @@ import { CandleData, Position, TradeHistory } from '../types/trading'
import { isUsStock } from '../utils/stockUtils'
import { isKrMarketOpen } from '../utils/marketHours'
import { parseKst } from '../utils/formatUtils'
import { SymbolBar } from '../components/tv/SymbolBar'
import { TvChart, ChartToggles } from '../components/tv/TvChart'
import { RightPanel } from '../components/tv/RightPanel'
import { BottomTabs } from '../components/tv/BottomTabs'
import { Timeframe, barsFromDaily, barsFromIntraday, toTradeMarks, todayKst } from '../components/tv/chartUtils'
import { useIntradayCandles, usePositionOverlay, useStrategyDisplay } from '../lib/tvApi'
import './Dashboard.css'
/**
@@ -154,6 +160,17 @@ const Dashboard = () => {
const isMarketOpen = checkMarketOpen()
// TradingView 스타일 UI 상태
const [tf, setTf] = useState<Timeframe>('D')
const [toggles, setToggles] = useState<ChartToggles>({ ma: true, bb: true, vol: true, rsi: true, macd: false })
const [fitSignal, setFitSignal] = useState(0)
const [summaryOpen, setSummaryOpen] = useState(false)
const handleTimeframe = useCallback((v: Timeframe) => {
setTf(v)
if (v !== '5m') setChartPeriod(v) // D/W/M 은 기존 WS get_chart 흐름, 5m 은 REST(useIntradayCandles)
}, [])
const handleToggle = useCallback((k: keyof ChartToggles) => setToggles((t) => ({ ...t, [k]: !t[k] })), [])
// 종목 선택 시 localStorage에 저장 (재진입 시 Waterfall 제거)
// useCallback으로 안정화 → React.memo 패널에 전달 시 불필요한 리렌더 방지
const handleSelectStock = useCallback((code: string) => {
@@ -184,7 +201,6 @@ const Dashboard = () => {
// Market data hook (with throttled live prices and market update handler)
const {
livePrices,
liveVolumes,
liveQuote,
handleMarketUpdate,
} = useMarketData(
@@ -850,7 +866,6 @@ const Dashboard = () => {
const livePrice = livePrices[selectedStock]
// 차트에는 chartData 가 선택 종목의 데이터일 때만 실시간가 적용 (종목 전환 중 옛 차트 오염 방지 — M-3)
const chartLivePrice = chartStock === selectedStock ? livePrice : undefined
const chartLiveVolume = chartStock === selectedStock ? liveVolumes[selectedStock] : undefined
// 실시간 호가: WebSocket quote(H0STASP0) → REST 초기값 폴백
const activeOrderbook = useMemo(() => {
@@ -1023,109 +1038,202 @@ const Dashboard = () => {
}
}, [portfolio, ruleBasedRecommendations, selectedStock, handleSelectStock])
return (
<div className="bg-[#080d16] text-[#c5d5e8] font-sans flex flex-col h-[calc(100vh-4rem)] overflow-hidden">
// ── TradingView 레이아웃 파생 데이터 ─────────────────────────────
const displayCfgQ = useStrategyDisplay()
const displayCfg = displayCfgQ.data ?? null
const isKr = !!selectedStock && !isUsStock(selectedStock)
const intradayQ = useIntradayCandles(selectedStock, isKr, isKr && tf === '5m' && isMarketOpen)
const intradayAvailable = !!intradayQ.data && intradayQ.data.length > 0
const effTf: Timeframe = tf === '5m' && !intradayAvailable ? chartPeriod : tf
<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">
<MarketIndexBar />
<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>
{tradeAccountNotice && (
<AccountSetupNotice message={tradeAccountNotice} className="mb-2" />
)}
const bars = useMemo(() => {
if (effTf === '5m') return intradayQ.data ? barsFromIntraday(intradayQ.data) : []
return chartData && chartStock === selectedStock ? barsFromDaily(chartData) : []
}, [effTf, intradayQ.data, chartData, chartStock, selectedStock])
const overlayQ = usePositionOverlay(selectedStock || undefined, !!selectedStock)
const overlayPositions = overlayQ.data ? overlayQ.data.positions.filter((p) => p.stock_code === selectedStock) : null
const { data: chartTrades } = useQuery({
queryKey: ['tv-chart-trades', selectedStock, tenantMode],
queryFn: async () => {
try {
const params = { stock_code: selectedStock, days: 180, limit: 500 }
const res = tenantMode === 'v2' ? await getTradesV2(params) : await api.get('/trades', { params })
return toTradeMarks(res.data)
} catch {
return []
}
},
enabled: !!selectedStock && tenantMode !== undefined,
staleTime: 15000,
refetchInterval: isMarketOpen ? 30000 : false,
retry: false,
})
const tradeMarks = useMemo(() => chartTrades ?? [], [chartTrades])
const headerPrice = livePrice ?? (bars.length ? bars[bars.length - 1].close : undefined)
const headerChg = useMemo(() => {
if (effTf !== 'D' || bars.length === 0 || !headerPrice) return null
const last = bars[bars.length - 1]
const prev = last.time === todayKst() ? (bars.length > 1 ? bars[bars.length - 2].close : null) : last.close
return prev ? ((headerPrice - prev) / prev) * 100 : null
}, [effTf, bars, headerPrice])
const { data: dtWlForName } = useQuery<{ watchlist: { code: string; name: string }[] } | null>({
queryKey: ['tv-dt-watchlist'],
queryFn: async () => { try { return (await api.get('/daytrading/watchlist')).data } catch { return null } },
refetchInterval: 60000, staleTime: 30000, retry: false,
})
const headerName = useMemo(() => {
const cands = [
overlayPositions?.find((p) => p.stock_name)?.stock_name,
portfolio?.find((s: Position) => s.stock_code === selectedStock)?.stock_name,
ruleBasedRecommendations?.analyzed_stock_list?.find((s: { stock_code: string }) => s.stock_code === selectedStock)?.stock_name,
dtWlForName?.watchlist?.find((w) => w.code === selectedStock)?.name,
stockName,
]
return (cands.find((n) => !!n && n !== selectedStock) as string | undefined) ?? ''
}, [overlayPositions, portfolio, ruleBasedRecommendations, dtWlForName, stockName, selectedStock])
const accountBlocked = accountSetupRequired || accountStatus.required
const holdNode = (
<div className="flex flex-col h-full min-h-0">
<div className="flex gap-1 px-2 py-1 flex-shrink-0">
{([['ALL', '전체'], ['KR', '국내'], ['US', '미국']] as const).map(([value, label]) => (
<button key={value} onClick={() => setMarketFilter(value as MarketFilter)}
className={`px-2 py-0.5 text-xs rounded border ${marketFilter === value ? 'border-tv-accent text-tv-accent' : 'border-tv-border text-tv-muted'}`}>{label}</button>
))}
</div>
<div className="flex-1 min-h-0 [&>div]:!h-full">
<PortfolioPanel
portfolio={filteredPortfolio}
livePrices={livePrices}
selectedStock={selectedStock}
onSelectStock={handleSelectStock}
onRefetchAiAnalysis={stableRefetchAiAnalysis}
onSell={handleSellGuarded}
sellDisabled={!tradeActionsReady}
accountSetupRequired={accountBlocked}
accountSetupMessage={accountSetupMessage || accountStatus.message}
exchangeRate={portfolioSummary?.exchange_rate_unavailable === true ? 0 : (Number(portfolioSummary?.exchange_rate) || 0)}
/>
</div>
</div>
)
const railBtn = 'w-9 py-1 text-[11px] rounded border text-center'
const railOn = 'border-tv-accent text-tv-accent bg-tv-accent/10'
const railOff = 'border-tv-border text-tv-muted hover:text-tv-text'
return (
<div className="bg-tv-bg text-tv-text font-sans flex flex-col lg:h-[calc(100vh-4rem)] lg:overflow-hidden">
<div className="flex-shrink-0 px-2 pt-1">
<MarketIndexBar />
{tradeAccountNotice && <AccountSetupNotice message={tradeAccountNotice} className="mb-2" />}
</div>
<SymbolBar
stockCode={selectedStock}
stockName={headerName}
price={headerPrice}
changePct={headerChg}
timeframe={effTf}
onTimeframe={handleTimeframe}
intradayAvailable={intradayAvailable}
toggles={toggles}
onToggle={handleToggle}
wsConnected={wsConnected}
totalAssets={liveSummary?.total_assets}
todayPlRate={liveSummary?.today_profit_loss_rate}
summaryOpen={summaryOpen}
onToggleSummary={() => setSummaryOpen((v) => !v)}
displayCfg={displayCfg}
/>
{summaryOpen && (
<div className="flex-shrink-0 px-2 py-1 max-h-[40vh] overflow-y-auto border-b border-tv-border">
<PortfolioSummary
summary={liveSummary}
error={portfolioSummaryError?.message ?? null}
todayTradeStats={todayTradeStats}
accountSetupRequired={accountSetupRequired || accountStatus.required}
accountSetupRequired={accountBlocked}
accountSetupMessage={accountSetupMessage || accountStatus.message}
/>
</div>
)}
<div className="grid grid-cols-12 gap-3 flex-1 min-h-0">
{/* [Left Column] 보유 종목 */}
<div className="col-span-2 flex flex-col gap-3 h-full overflow-hidden">
<PortfolioPanel
portfolio={filteredPortfolio}
livePrices={livePrices}
selectedStock={selectedStock}
onSelectStock={handleSelectStock}
onRefetchAiAnalysis={stableRefetchAiAnalysis}
onSell={handleSellGuarded}
sellDisabled={!tradeActionsReady}
accountSetupRequired={accountSetupRequired || accountStatus.required}
accountSetupMessage={accountSetupMessage || accountStatus.message}
exchangeRate={portfolioSummary?.exchange_rate_unavailable === true ? 0 : (Number(portfolioSummary?.exchange_rate) || 0)}
/>
</div>
{/* [Center Column] 차트 & 트레이딩 정보 */}
<div className="col-span-7 flex flex-col h-full gap-3 overflow-hidden">
{/* 차트 섹션 */}
<ChartPanel
chartData={chartData}
chartLoading={chartLoading}
selectedStock={selectedStock}
chartPeriod={chartPeriod}
onPeriodChange={setChartPeriod}
stockName={stockName}
livePrice={chartLivePrice}
liveVolume={chartLiveVolume}
emptyReason={chartEmptyReason}
/>
{/* 하단: AI 투자 분석 & 호가창
- AiAnalysisPanel: selectedStock 변경 시에만 리렌더
- OrderbookPanel: 10초마다 독립 리렌더 (다른 패널 영향 없음) */}
<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-3 h-full overflow-hidden">
<RecommendationPanel
recommendations={ruleBasedRecommendations}
isError={isRecommendationsError}
onRefetch={stableRefetchRecommendations}
selectedStock={selectedStock}
onSelectStock={handleSelectStock}
onRefetchAiAnalysis={stableRefetchAiAnalysis}
heldStockCodes={heldStockCodes}
pendingBuyStockCodes={pendingBuyStockCodes}
livePrices={livePrices}
onBuy={handleBuyGuarded}
buyDisabled={!tradeActionsReady}
/>
<JournalPanel
journals={journals}
tradeHistory={tradeHistory}
onResetPending={resetPendingTrades}
resetDisabled={!tradeActionsReady}
/>
</div>
<div className="flex-1 min-h-0 flex flex-col lg:grid lg:grid-cols-[44px_minmax(0,1fr)_320px] lg:grid-rows-[minmax(0,1fr)_270px]">
{/* 도구 레일 */}
<div className="hidden lg:flex lg:row-span-2 flex-col items-center gap-1 py-2 bg-tv-panel border-r border-tv-border">
<button className={`${railBtn} ${railOff}`} title="화면 맞춤" onClick={() => setFitSignal((n) => n + 1)}>맞춤</button>
{(['ma', 'bb', 'vol', 'rsi', 'macd'] as const).map((k) => (
<button key={k} className={`${railBtn} ${toggles[k] ? railOn : railOff}`} onClick={() => handleToggle(k)}>{k.toUpperCase()}</button>
))}
</div>
</main>
<div className="h-[460px] lg:h-auto min-h-0 min-w-0">
<TvChart
bars={bars}
timeframe={effTf}
loading={effTf === '5m' ? intradayQ.isLoading : chartLoading}
emptyReason={chartEmptyReason}
livePrice={chartLivePrice}
overlays={overlayPositions}
trades={tradeMarks}
rsiCfg={displayCfg?.rsi ?? null}
toggles={toggles}
fitSignal={fitSignal}
isUs={!!selectedStock && isUsStock(selectedStock)}
/>
</div>
<div className="h-[460px] lg:h-auto min-h-0 min-w-0 border-t lg:border-t-0 border-tv-border">
<RightPanel
portfolio={portfolio}
recommendations={ruleBasedRecommendations}
selectedStock={selectedStock}
onSelect={handleSelectStock}
livePrices={livePrices}
selectedOverlay={overlayPositions}
recoNode={
<RecommendationPanel
recommendations={ruleBasedRecommendations}
isError={isRecommendationsError}
onRefetch={stableRefetchRecommendations}
selectedStock={selectedStock}
onSelectStock={handleSelectStock}
onRefetchAiAnalysis={stableRefetchAiAnalysis}
heldStockCodes={heldStockCodes}
pendingBuyStockCodes={pendingBuyStockCodes}
livePrices={livePrices}
onBuy={handleBuyGuarded}
buyDisabled={!tradeActionsReady}
/>
}
bookNode={<OrderbookPanel orderbook={activeOrderbook} />}
aiNode={<AiAnalysisPanel aiAnalysisData={aiAnalysisData} />}
/>
</div>
<div className="h-[380px] lg:h-auto min-h-0 min-w-0 lg:col-span-2">
<BottomTabs
holdNode={holdNode}
journalNode={
<JournalPanel
journals={journals}
tradeHistory={tradeHistory}
onResetPending={resetPendingTrades}
resetDisabled={!tradeActionsReady}
/>
}
trades={tradeHistory}
onSelect={handleSelectStock}
cfg={displayCfg}
totalAssets={liveSummary?.total_assets}
/>
</div>
</div>
</div>
);
)
}
export default Dashboard;

View File

@@ -1,71 +0,0 @@
/**
* Type declarations for lightweight-charts library
*/
export interface BarData {
time: string | number;
open: number;
high: number;
low: number;
close: number;
}
export interface CandlestickData {
time: string | number;
open: number;
high: number;
low: number;
close: number;
}
export interface LineData {
time: string | number;
value: number;
}
export interface HistogramData {
time: string | number;
value: number;
color?: string;
}
export interface AreaData {
time: string | number;
value: number;
}
export enum CrosshairMode {
Normal = 0,
Magnet = 1,
Hidden = 2,
}
export interface ISeriesApi<T extends string = 'Candlestick'> {
setData(data: CandlestickData[] | LineData[] | HistogramData[] | BarData[]): void;
update(data: CandlestickData | LineData | HistogramData | BarData): void;
}
export interface IPriceScaleApi {
applyOptions(options: any): void;
}
export interface ITimeScaleApi {
fitContent(): void;
getVisibleRange(): { from: number; to: number } | null;
setVisibleRange(range: { from: number; to: number }): void;
subscribeVisibleTimeRangeChange(callback: (range: { from: number; to: number } | null) => void): void;
}
export interface IChartApi {
addCandlestickSeries(options?: any): ISeriesApi<'Candlestick'>;
addLineSeries(options?: any): ISeriesApi<'Line'>;
addHistogramSeries(options?: any): ISeriesApi<'Histogram'>;
addBarSeries(options?: any): ISeriesApi<'Bar'>;
addAreaSeries(options?: any): ISeriesApi<'Area'>;
priceScale(position: string): IPriceScaleApi;
timeScale(): ITimeScaleApi;
applyOptions(options: any): void;
remove(): void;
}
export function createChart(container: HTMLElement, options?: any): IChartApi;

View File

@@ -13,6 +13,19 @@ export default {
danger: '#ef4444',
warning: '#f59e0b',
info: '#3b82f6',
// TradingView 스타일 디자인 토큰 (값은 src/index.css 의 :root / [data-theme=light])
tv: {
bg: 'rgb(var(--tv-bg) / <alpha-value>)',
panel: 'rgb(var(--tv-panel) / <alpha-value>)',
raised: 'rgb(var(--tv-raised) / <alpha-value>)',
border: 'rgb(var(--tv-border) / <alpha-value>)',
text: 'rgb(var(--tv-text) / <alpha-value>)',
muted: 'rgb(var(--tv-muted) / <alpha-value>)',
up: 'rgb(var(--tv-up) / <alpha-value>)',
down: 'rgb(var(--tv-down) / <alpha-value>)',
warn: 'rgb(var(--tv-warn) / <alpha-value>)',
accent: 'rgb(var(--tv-accent) / <alpha-value>)',
},
},
},
},