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:
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
)}
|
||||
|
||||
@@ -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' && (
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
84
frontend/src/components/tv/BottomTabs.tsx
Normal file
84
frontend/src/components/tv/BottomTabs.tsx
Normal 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'
|
||||
126
frontend/src/components/tv/DtPositionCards.tsx
Normal file
126
frontend/src/components/tv/DtPositionCards.tsx
Normal 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'
|
||||
142
frontend/src/components/tv/EquityPanel.tsx
Normal file
142
frontend/src/components/tv/EquityPanel.tsx
Normal 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'
|
||||
140
frontend/src/components/tv/RightPanel.tsx
Normal file
140
frontend/src/components/tv/RightPanel.tsx
Normal 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'
|
||||
130
frontend/src/components/tv/SymbolBar.tsx
Normal file
130
frontend/src/components/tv/SymbolBar.tsx
Normal 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'
|
||||
335
frontend/src/components/tv/TvChart.tsx
Normal file
335
frontend/src/components/tv/TvChart.tsx
Normal 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'
|
||||
105
frontend/src/components/tv/chartUtils.ts
Normal file
105
frontend/src/components/tv/chartUtils.ts
Normal 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')
|
||||
@@ -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
163
frontend/src/lib/tvApi.ts
Normal 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,
|
||||
})
|
||||
@@ -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;
|
||||
|
||||
71
frontend/src/types/lightweight-charts.d.ts
vendored
71
frontend/src/types/lightweight-charts.d.ts
vendored
@@ -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;
|
||||
@@ -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>)',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user