fix: 하드닝 루프 8회차 — 체결 보정·고아 감지·청산 경보·실시간/차트·리스크 가드 일괄 수정
- 스윙 체결가 확인 전 executed_price NULL + fill_sweep, 진입가 resolve(ActiveTrade↔Portfolio), 0체결/부분체결 매수 장부 원복 - DT 고아 감지(근거 한정)·0체결 매도 복원·stale 점검 확대, 시간손절 -0.3% 원복 - 폴백 손절 ATR 캐시, 거래량부족-차단 신호 매수 차단, 현금 5% 유보 재적용, MDD peak/reset 수정 - KIS 서킷브레이커 청산 우회·경보, db_worker 재큐잉 순서, heartbeat·health-watch 사이드카 - 실시간 WS 재구독·41건 우선순위·다건 프레임 분할, 차트 샘플 제거·당일봉 합성, WS 서브프로토콜 인증 - 프론트: 미관리 그룹·확인 모달·차트 실시간 반영·예수금 표시 정정 - 전문가 에이전트 6종 추가(.claude/agents) - 유닛 스위트 2667 passed (소켓 차단 하), tsc OK Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y83Za98XsNw9zuA4FTUytN
This commit is contained in:
@@ -6,8 +6,8 @@
|
||||
* - AiAnalysisPanel: 선택 종목의 AI 투자 분석 텍스트 표시
|
||||
*
|
||||
* 최적화:
|
||||
* - React.memo + 커스텀 비교 함수로 livePrice 변경 시 리렌더 방지
|
||||
* (livePrice는 TradingChart 내부에서 ref 기반으로 처리)
|
||||
* - React.memo + 커스텀 비교 함수 — livePrice 는 통과시켜 TradingChart 가 마지막 캔들만 update
|
||||
* (차트 재생성 없음, livePrices 는 초당 최대 1회 flush)
|
||||
* - 차트 로딩 중에도 기존 차트를 유지하여 깜빡임 방지
|
||||
*/
|
||||
import React from 'react'
|
||||
@@ -29,16 +29,20 @@ interface ChartPanelProps {
|
||||
onPeriodChange: (period: 'D' | 'W' | 'M') => void
|
||||
/** 차트 헤더에 표시할 종목명 */
|
||||
stockName: string
|
||||
/** 실시간 현재가 (TradingChart에서 ref 기반 처리) */
|
||||
/** 실시간 현재가 — chartData 가 선택 종목의 데이터일 때만 전달할 것 (Dashboard 책임) */
|
||||
livePrice?: number
|
||||
/** 당일 누적 거래량 (일봉 오늘 캔들 반영용) */
|
||||
liveVolume?: number
|
||||
/** 차트 데이터가 없을 때 표시할 사유 (샘플 데이터 거부·해외 차트 미지원·오류 등) */
|
||||
emptyReason?: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* ChartPanel — 종목 캔들 차트 패널
|
||||
*
|
||||
* React.memo 커스텀 비교 함수:
|
||||
* - livePrice 변경은 무시 (TradingChart가 ref 기반 500ms polling으로 직접 처리)
|
||||
* - chartData, chartLoading, selectedStock, chartPeriod 등 변경 시에만 리렌더
|
||||
* - chartData, chartLoading, selectedStock, chartPeriod, livePrice/liveVolume 등 변경 시 리렌더
|
||||
* - TradingChart 는 livePrice 변경 시 series.update 만 수행 (차트 재생성 없음)
|
||||
*/
|
||||
const ChartPanel = React.memo(({
|
||||
chartData,
|
||||
@@ -48,6 +52,8 @@ const ChartPanel = React.memo(({
|
||||
onPeriodChange,
|
||||
stockName,
|
||||
livePrice,
|
||||
liveVolume,
|
||||
emptyReason,
|
||||
}: ChartPanelProps) => {
|
||||
return (
|
||||
<div className="bg-[#0c1522] border border-[#152035] rounded flex flex-col h-[45%] min-h-0">
|
||||
@@ -85,10 +91,19 @@ const ChartPanel = React.memo(({
|
||||
{/* Chart area — 로딩 중에도 기존 차트 유지 (깜빡임 방지) */}
|
||||
<div className="flex-1 relative bg-[#0b0e14] rounded-b overflow-hidden">
|
||||
{chartData && chartData.length > 0 ? (
|
||||
<TradingChart data={chartData} livePrice={livePrice} />
|
||||
<TradingChart
|
||||
data={chartData}
|
||||
livePrice={livePrice}
|
||||
liveVolume={liveVolume}
|
||||
period={chartPeriod}
|
||||
stockCode={selectedStock}
|
||||
/>
|
||||
) : !chartLoading ? (
|
||||
<div className="absolute inset-0 flex items-center justify-center text-[#3a5570] text-xs font-mono">
|
||||
데이터 없음
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center gap-1 text-[#3a5570] text-xs font-mono">
|
||||
<span>차트 데이터 없음</span>
|
||||
{emptyReason && (
|
||||
<span className="text-[10px] text-[#eab308]/80">({emptyReason})</span>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
{chartLoading && (
|
||||
@@ -103,14 +118,16 @@ const ChartPanel = React.memo(({
|
||||
</div>
|
||||
)
|
||||
}, (prev, next) => {
|
||||
// livePrice 변경은 TradingChart 내부에서 ref 기반 처리 — ChartPanel 리렌더 불필요
|
||||
if (prev.chartData !== next.chartData) return false
|
||||
if (prev.chartLoading !== next.chartLoading) return false
|
||||
if (prev.selectedStock !== next.selectedStock) return false
|
||||
if (prev.chartPeriod !== next.chartPeriod) return false
|
||||
if (prev.stockName !== next.stockName) return false
|
||||
if (prev.onPeriodChange !== next.onPeriodChange) return false
|
||||
// livePrice 변경은 무시 (TradingChart가 ref로 직접 처리)
|
||||
// 실시간 현재가/거래량 — TradingChart 가 마지막 캔들만 update (재생성 없음)
|
||||
if (prev.livePrice !== next.livePrice) return false
|
||||
if (prev.liveVolume !== next.liveVolume) return false
|
||||
if (prev.emptyReason !== next.emptyReason) return false
|
||||
return true
|
||||
})
|
||||
|
||||
@@ -129,6 +146,22 @@ interface AiAnalysisPanelProps {
|
||||
|
||||
export const AiAnalysisPanel = React.memo(({ aiAnalysisData }: AiAnalysisPanelProps) => {
|
||||
const aiAnalysisText = aiAnalysisData?.analysis || null
|
||||
// 결론 action 정규화 (구조화 필드 우선, 없으면 recommendation)
|
||||
const rawAction = String(
|
||||
aiAnalysisData?.data?.action ?? aiAnalysisData?.data?.conclusion?.action ?? aiAnalysisData?.data?.recommendation ?? ''
|
||||
).toUpperCase()
|
||||
const action: 'BUY' | 'SELL' | 'HOLD' | null =
|
||||
rawAction.includes('BUY') || rawAction.includes('매수') ? 'BUY'
|
||||
: rawAction.includes('SELL') || rawAction.includes('매도') ? 'SELL'
|
||||
: rawAction.includes('HOLD') || rawAction.includes('관망') ? 'HOLD'
|
||||
: null
|
||||
// 매수 결론일 때만 매수 제안 금액/목표가/손절가를 노출 (HOLD/SELL 이면 모순된 매수 제안 숨김)
|
||||
const showBuyPlan = action === 'BUY' || action === null
|
||||
const d = aiAnalysisData?.data
|
||||
const hasTarget = d?.target_price != null && Number(d.target_price) > 0
|
||||
const headline: string | null = typeof d?.headline === 'string' && d.headline ? d.headline : null
|
||||
const sizingTitleFallback = action === 'HOLD' ? '관망 (매수 제안 없음)' : action === 'SELL' ? '매도 의견 (매수 제안 없음)' : '매수 추천'
|
||||
const sizingTitle = headline ?? sizingTitleFallback
|
||||
return (
|
||||
<div className="bg-[#0c1522] border border-[#152035] rounded flex flex-col overflow-hidden">
|
||||
{/* Header */}
|
||||
@@ -147,8 +180,8 @@ export const AiAnalysisPanel = React.memo(({ aiAnalysisData }: AiAnalysisPanelPr
|
||||
{aiAnalysisData?.data && (
|
||||
<div className="px-3 py-1.5 border-b border-[#152035] bg-[#0a1120] flex-shrink-0">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-[9px] uppercase tracking-wider text-[#4a6580]">매수 추천</span>
|
||||
{aiAnalysisData.data.suggested_quantity > 0 ? (
|
||||
<span className={`text-[9px] uppercase tracking-wider ${action === 'HOLD' ? 'text-[#eab308]' : action === 'SELL' ? 'text-[#3b82f6]' : 'text-[#4a6580]'}`}>{sizingTitle}</span>
|
||||
{showBuyPlan && aiAnalysisData.data.suggested_quantity > 0 ? (
|
||||
<>
|
||||
<span className="text-[13px] font-mono text-[#22c55e]">
|
||||
{Number(aiAnalysisData.data.suggested_amount).toLocaleString('ko-KR')}원
|
||||
@@ -166,25 +199,25 @@ export const AiAnalysisPanel = React.memo(({ aiAnalysisData }: AiAnalysisPanelPr
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{aiAnalysisData.data.suggested_quantity > 0 && aiAnalysisData.data.sizing_note && (
|
||||
{showBuyPlan && aiAnalysisData.data.suggested_quantity > 0 && aiAnalysisData.data.sizing_note && (
|
||||
<div className="text-[9px] text-[#3a5570] mt-0.5">{aiAnalysisData.data.sizing_note}</div>
|
||||
)}
|
||||
|
||||
{/* 목표가 · 손절가 (CLAUDE.md §5.2: 손절은 ATR/퍼센트 중 타이트한 쪽, 목표는 +2R) */}
|
||||
{aiAnalysisData.data.target_price > 0 && (
|
||||
{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]">
|
||||
{Number(aiAnalysisData.data.target_price).toLocaleString('ko-KR')}원
|
||||
<span className="text-[9px] ml-1">(+{aiAnalysisData.data.target_pct}%)</span>
|
||||
<span className="text-[9px] 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] font-mono text-[#3b82f6]">
|
||||
{Number(aiAnalysisData.data.stop_loss_price).toLocaleString('ko-KR')}원
|
||||
<span className="text-[9px] ml-1">({aiAnalysisData.data.stop_loss_pct}%)</span>
|
||||
{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>}
|
||||
</span>
|
||||
<span className="ml-auto text-[9px] font-mono text-[#4a6580]">
|
||||
R:R 1:{aiAnalysisData.data.risk_reward}
|
||||
{aiAnalysisData.data.risk_reward != null && <>R:R 1:{aiAnalysisData.data.risk_reward}</>}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
75
frontend/src/components/ConfirmDialog.tsx
Normal file
75
frontend/src/components/ConfirmDialog.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* ConfirmDialog.tsx — 앱 내 확인 모달 (주문 전 확인용)
|
||||
*
|
||||
* window.confirm 대신 사용. 처리 중(busy)에는 버튼 비활성화로 중복 클릭을 막고,
|
||||
* 배경 클릭/ESC 로 취소할 수 있다(처리 중에는 닫히지 않음).
|
||||
*/
|
||||
import React, { useEffect } from 'react'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
|
||||
interface ConfirmDialogProps {
|
||||
open: boolean
|
||||
title: string
|
||||
/** 본문 (줄 단위 배열 또는 노드) */
|
||||
lines: React.ReactNode[]
|
||||
confirmLabel: string
|
||||
/** 'sell' = 파랑(매도), 'buy' = 빨강(매수, 한국 관례) */
|
||||
tone?: 'sell' | 'buy'
|
||||
busy?: boolean
|
||||
onConfirm: () => void
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
const ConfirmDialog = ({ open, title, lines, confirmLabel, tone = 'sell', busy, onConfirm, onCancel }: ConfirmDialogProps) => {
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && !busy) onCancel()
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [open, busy, onCancel])
|
||||
|
||||
if (!open) return null
|
||||
const confirmCls = tone === 'buy'
|
||||
? 'bg-[#b91c1c] hover:bg-[#dc2626]'
|
||||
: 'bg-[#1d4ed8] hover:bg-[#2563eb]'
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4"
|
||||
onClick={(e) => { e.stopPropagation(); if (!busy) onCancel() }}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
>
|
||||
<div
|
||||
className="bg-[#0c1522] border border-[#1e3050] rounded-lg p-5 max-w-sm w-full space-y-3"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<h3 className="text-sm font-semibold text-[#c5d5e8]">{title}</h3>
|
||||
<div className="text-xs text-[#8faabf] space-y-1">
|
||||
{lines.map((l, i) => <p key={i}>{l}</p>)}
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 pt-1">
|
||||
<button
|
||||
onClick={onCancel}
|
||||
disabled={busy}
|
||||
className="px-3 py-1.5 text-xs text-[#8faabf] border border-[#152035] rounded hover:text-white disabled:opacity-40"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
onClick={onConfirm}
|
||||
disabled={busy}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 text-xs text-white rounded disabled:opacity-50 ${confirmCls}`}
|
||||
>
|
||||
{busy && <Loader2 size={12} className="animate-spin" />}
|
||||
{busy ? '주문 처리 중...' : confirmLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ConfirmDialog
|
||||
@@ -13,9 +13,20 @@
|
||||
* - journals: react-query 'journals' 쿼리 결과
|
||||
* - tradeHistory: react-query 'trade-history' 쿼리 결과
|
||||
*/
|
||||
import React, { useState } from 'react'
|
||||
import React, { useMemo, useState } from 'react'
|
||||
import { BookOpen, ArrowUpDown } from 'lucide-react'
|
||||
import { Journal, TradeHistory } from '../types/trading'
|
||||
import { parseKst } from '../utils/formatUtils'
|
||||
|
||||
const kstDateKey = (d: Date | null): string =>
|
||||
d ? d.toLocaleDateString('sv-SE', { timeZone: 'Asia/Seoul' }) : ''
|
||||
|
||||
const kstDateLabel = (d: Date | null, todayKey: string): string => {
|
||||
if (!d) return '날짜 미상'
|
||||
const key = kstDateKey(d)
|
||||
const md = d.toLocaleDateString('ko-KR', { month: '2-digit', day: '2-digit', weekday: 'short', timeZone: 'Asia/Seoul' })
|
||||
return key === todayKey ? `${md} (오늘)` : md
|
||||
}
|
||||
|
||||
/** JournalPanel Props 정의 */
|
||||
interface JournalPanelProps {
|
||||
@@ -38,6 +49,25 @@ interface JournalPanelProps {
|
||||
const JournalPanel = React.memo(({ journals, tradeHistory, onResetPending, resetDisabled }: JournalPanelProps) => {
|
||||
const [tab, setTab] = useState<'trades' | 'journal'>('trades')
|
||||
|
||||
// 최신순 정렬 (created_at/executed_at 기준, KST 보정). 시각 파싱 불가 건은 id 로 보조 정렬
|
||||
const sortedTrades = useMemo(() => {
|
||||
const todayKey = kstDateKey(new Date())
|
||||
return (tradeHistory ?? [])
|
||||
.map((t: TradeHistory) => {
|
||||
const dt = parseKst(t.created_at || t.executed_at || '')
|
||||
return { t, dt, key: kstDateKey(dt) }
|
||||
})
|
||||
.sort((a, b) => {
|
||||
const d = (b.dt?.getTime() ?? 0) - (a.dt?.getTime() ?? 0)
|
||||
return d !== 0 ? d : Number(b.t.id || 0) - Number(a.t.id || 0)
|
||||
})
|
||||
.map((x, i, arr) => ({
|
||||
...x,
|
||||
// 날짜가 바뀌는 첫 행에 구분선 라벨
|
||||
header: i === 0 || arr[i - 1].key !== x.key ? kstDateLabel(x.dt, todayKey) : null,
|
||||
}))
|
||||
}, [tradeHistory])
|
||||
|
||||
return (
|
||||
<div className="bg-[#0c1522] border border-[#152035] rounded overflow-hidden flex flex-col h-1/2">
|
||||
{/* Header with tabs */}
|
||||
@@ -83,8 +113,8 @@ const JournalPanel = React.memo(({ journals, tradeHistory, onResetPending, reset
|
||||
<div className="overflow-y-auto custom-scrollbar flex-1 p-2 space-y-1">
|
||||
{tab === 'trades' ? (
|
||||
/* ── 매매내역 탭 ── */
|
||||
tradeHistory && tradeHistory.length > 0 ? (
|
||||
tradeHistory.map((trade: TradeHistory, idx: number) => {
|
||||
sortedTrades.length > 0 ? (
|
||||
sortedTrades.map(({ t: trade, dt: createdAt, header }, idx: number) => {
|
||||
// 거래 유형 판별 (매수/매도)
|
||||
const isBuy = trade.order_type === 'BUY'
|
||||
const isSell = trade.order_type === 'SELL'
|
||||
@@ -98,17 +128,25 @@ const JournalPanel = React.memo(({ journals, tradeHistory, onResetPending, reset
|
||||
const statusOk = st === 'COMPLETED' || st === 'EXECUTED' || st === 'FILLED'
|
||||
const isFailed = st === 'FAILED'
|
||||
|
||||
// DB가 KST로 저장하므로 타임존 없는 ISO 문자열에 +09:00 보정
|
||||
const rawDt = trade.created_at || trade.executed_at || ''
|
||||
const createdAt = new Date(rawDt.includes('+') || rawDt.includes('Z') ? rawDt : rawDt + '+09:00')
|
||||
const timeStr = createdAt.toLocaleTimeString('ko-KR', {
|
||||
hour: '2-digit', minute: '2-digit', second: '2-digit',
|
||||
hour12: false, timeZone: 'Asia/Seoul',
|
||||
})
|
||||
// DB가 KST로 저장하므로 타임존 없는 ISO 문자열에 +09:00 보정 (parseKst)
|
||||
const timeStr = createdAt
|
||||
? createdAt.toLocaleTimeString('ko-KR', {
|
||||
hour: '2-digit', minute: '2-digit', second: '2-digit',
|
||||
hour12: false, timeZone: 'Asia/Seoul',
|
||||
})
|
||||
: '--:--:--'
|
||||
const dateShort = createdAt
|
||||
? createdAt.toLocaleDateString('ko-KR', { month: '2-digit', day: '2-digit', timeZone: 'Asia/Seoul' })
|
||||
: ''
|
||||
|
||||
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]">
|
||||
{header}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
key={trade.id || idx}
|
||||
className={`px-2.5 py-2 rounded-sm border transition-colors ${
|
||||
isFailed
|
||||
? 'bg-[#1a0808] border-red-900/20'
|
||||
@@ -129,13 +167,23 @@ const JournalPanel = React.memo(({ journals, tradeHistory, onResetPending, reset
|
||||
<span className="text-[8px] 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">{timeStr}</span>
|
||||
<span className="text-[9px] font-mono text-[#3a5570] 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]">
|
||||
{/* 시장가 주문은 order_price가 null → 체결가(executed_price) 우선 표시 */}
|
||||
{Number(trade.executed_quantity || trade.order_quantity || 0)}주 × {Math.round(Number(trade.executed_price || trade.order_price || 0)).toLocaleString()}원
|
||||
{/* 체결가(executed_price) 우선. null 이면 아직 체결 확인 전 — 주문가는 회색으로 구분 */}
|
||||
{Number(trade.executed_price) > 0 ? (
|
||||
<>{Number(trade.executed_quantity || trade.order_quantity || 0)}주 × {Math.round(Number(trade.executed_price)).toLocaleString()}원</>
|
||||
) : (
|
||||
<>
|
||||
{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>
|
||||
)}
|
||||
{!isFailed && <span className="ml-1 text-[9px] text-[#eab308]/80">체결 확인 중</span>}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{isSell && statusOk && pnl !== 0 && (
|
||||
<div className={`text-[10px] font-mono font-semibold ${isProfit ? 'text-[#ef4444]' : isLoss ? 'text-[#3b82f6]' : 'text-[#64748b]'}`}>
|
||||
@@ -152,6 +200,7 @@ const JournalPanel = React.memo(({ journals, tradeHistory, onResetPending, reset
|
||||
<div className="text-[9px] text-[#475569] mt-1 truncate">{trade.exit_reason}</div>
|
||||
)}
|
||||
</div>
|
||||
</React.Fragment>
|
||||
)
|
||||
})
|
||||
) : (
|
||||
|
||||
@@ -13,27 +13,20 @@
|
||||
* 한국/미국 시장 구분:
|
||||
* - 종목 코드가 6자리 숫자면 KR, 그 외는 US
|
||||
*/
|
||||
import React, { useMemo } from 'react'
|
||||
import React, { useMemo, useRef, useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import api from '../lib/api'
|
||||
import { Activity, TrendingUp, UserCircle, Globe } from 'lucide-react'
|
||||
import { Activity, TrendingUp, UserCircle, Globe, AlertTriangle } from 'lucide-react'
|
||||
import { Position } from '../types/trading'
|
||||
import { isUsStock } from '../utils/stockUtils'
|
||||
import { groupHoldings, GroupedHolding } from '../utils/portfolioGrouping'
|
||||
import { formatPriceByMarket } from '../utils/formatUtils'
|
||||
import AccountSetupNotice from './AccountSetupNotice'
|
||||
import ConfirmDialog from './ConfirmDialog'
|
||||
import DayTradingStatusBar from './DayTradingStatusBar'
|
||||
|
||||
/**
|
||||
* 가격 포맷 — 시장에 따라 원화 또는 달러 표시
|
||||
* @param price - 가격
|
||||
* @param isUs - 미국 주식 여부
|
||||
*/
|
||||
const formatPrice = (price: number, isUs: boolean): string => {
|
||||
if (isUs) {
|
||||
return `$${price.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
|
||||
}
|
||||
return `${price.toLocaleString()}원`
|
||||
}
|
||||
/** 가격 포맷 — 원화는 정수 반올림, USD 는 소수 2자리 */
|
||||
const formatPrice = formatPriceByMarket
|
||||
|
||||
/** 데이트레이딩 엔진 포지션 (GET /daytrading/positions) */
|
||||
interface DayTradePosition {
|
||||
@@ -62,6 +55,8 @@ interface PortfolioItemProps {
|
||||
onSell: (stockCode: string, stockName: string, quantity: number) => Promise<void>
|
||||
/** true면 청산 버튼 비활성화 (예: 사용자 모드 확정 전) */
|
||||
sellDisabled?: boolean
|
||||
/** 속한 그룹 (스윙 카드는 swing_avg_price 우선) */
|
||||
groupKey?: 'swing' | 'manual' | 'overseas' | 'unmanaged'
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -83,15 +78,42 @@ const PortfolioItem = React.memo(({
|
||||
onRefetchAiAnalysis,
|
||||
onSell,
|
||||
sellDisabled,
|
||||
groupKey,
|
||||
}: PortfolioItemProps) => {
|
||||
// 현재가: 실시간 가격 우선, 없으면 포트폴리오 API 가격 사용
|
||||
const currentPrice = livePrices?.[stock.stock_code] ?? Number(stock.current_price || 0)
|
||||
const avgPrice = Number(stock.average_price || 0)
|
||||
// 스윙 카드는 전략별 평단(swing_avg_price)이 있으면 우선 (합산 행 평단과 다를 수 있음)
|
||||
const isSwingCard = groupKey === 'swing'
|
||||
const swingAvg = Number(stock.swing_avg_price || 0)
|
||||
const useSwingBasis = isSwingCard && swingAvg > 0
|
||||
const avgPrice = useSwingBasis ? swingAvg : Number(stock.average_price || 0)
|
||||
const quantity = Number(stock.quantity || 0)
|
||||
// 수익률: 평균단가 기반 실시간 계산 (평균단가 없으면 API 값 폴백)
|
||||
const profitLossRate = avgPrice > 0
|
||||
? ((currentPrice - avgPrice) / avgPrice) * 100
|
||||
// 수익률: 평균단가 기반 실시간 계산 (현재가/평단 없으면 API 값 폴백 — 스윙 카드는 swing_* 우선)
|
||||
const fallbackRate = isSwingCard && stock.swing_profit_loss_rate != null
|
||||
? Number(stock.swing_profit_loss_rate)
|
||||
: Number(stock.profit_loss_rate || 0)
|
||||
const profitLossRate = avgPrice > 0 && currentPrice > 0
|
||||
? ((currentPrice - avgPrice) / avgPrice) * 100
|
||||
: fallbackRate
|
||||
// 평가손익(원): 실시간 계산, 불가하면 스윙 카드는 swing_profit_loss 폴백
|
||||
const profitLoss: number | null = avgPrice > 0 && currentPrice > 0
|
||||
? (currentPrice - avgPrice) * quantity
|
||||
: isSwingCard && stock.swing_profit_loss != null
|
||||
? Number(stock.swing_profit_loss)
|
||||
: null
|
||||
// 합산 평단(스윙+DT)을 분리한 경우 — 근거 툴팁
|
||||
const avgMixed = isSwingCard && !!stock.avg_price_mixed
|
||||
const basisLabel: Record<string, string> = {
|
||||
active_trade: '스윙 진입 기록 평단',
|
||||
conservative_max: '보수적(높은 쪽) 평단',
|
||||
portfolio_avg: '계좌 평단',
|
||||
derived_from_mixed: '합산 평단에서 DT 분 역산',
|
||||
none: '평단 근거 없음',
|
||||
}
|
||||
const basis = String(stock.swing_avg_price_basis || '')
|
||||
const mixedTitle = `스윙·데이트레이딩 합산 평단을 분리해 표시합니다 (근거: ${basisLabel[basis] ?? (basis || '알 수 없음')})`
|
||||
+ (Number(stock.dt_quantity) > 0 ? ` · DT ${Number(stock.dt_quantity)}주 @ ${Math.round(Number(stock.dt_avg_price || 0)).toLocaleString()}` : '')
|
||||
+ (Number(stock.average_price) > 0 ? ` · 계좌 합산 평단 ${Math.round(Number(stock.average_price)).toLocaleString()}` : '')
|
||||
const isUs = isUsStock(stock.stock_code)
|
||||
const isSelected = selectedStock === stock.stock_code
|
||||
const isUp = profitLossRate >= 0
|
||||
@@ -99,9 +121,21 @@ const PortfolioItem = React.memo(({
|
||||
const splitLocked = Number((stock as GroupedHolding).isSplit) > 0 || Number((stock as GroupedHolding).dtIncluded) > 0
|
||||
const sellBlocked = !!sellDisabled || splitLocked
|
||||
|
||||
const handleSellClick = async (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
const [confirmOpen, setConfirmOpen] = useState(false)
|
||||
const [selling, setSelling] = useState(false)
|
||||
const inflightRef = useRef(false)
|
||||
|
||||
const handleSellClick = (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.stopPropagation()
|
||||
if (sellBlocked) return
|
||||
if (sellBlocked || selling || inflightRef.current) return
|
||||
setConfirmOpen(true)
|
||||
}
|
||||
|
||||
const handleConfirmSell = async () => {
|
||||
// 중복 클릭 방지: ref 로 동기 차단 (state 갱신 전 연타 대비)
|
||||
if (inflightRef.current) return
|
||||
inflightRef.current = true
|
||||
setSelling(true)
|
||||
try {
|
||||
await onSell(stock.stock_code, stock.stock_name, quantity)
|
||||
} catch (error: unknown) {
|
||||
@@ -109,6 +143,10 @@ const PortfolioItem = React.memo(({
|
||||
// 계좌 미등록/미검증(400/423)은 Dashboard 상단 인라인 안내로 처리 — alert 생략
|
||||
if (err?.isAccountSetupRequired) return
|
||||
// 실패 알림은 useTradeActions의 toast.error로 표시됨 (alert는 화면 블로킹)
|
||||
} finally {
|
||||
inflightRef.current = false
|
||||
setSelling(false)
|
||||
setConfirmOpen(false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,6 +179,14 @@ const PortfolioItem = React.memo(({
|
||||
{stock.strategy_type === 'external' ? '외부' : '수동'}
|
||||
</span>
|
||||
)}
|
||||
{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"
|
||||
>
|
||||
미관리
|
||||
</span>
|
||||
)}
|
||||
{Number((stock as GroupedHolding).dtIncluded) > 0 && (
|
||||
<span
|
||||
title="데이트레이딩 엔진 수량을 확인할 수 없어 DT 보유분이 포함됐을 수 있음"
|
||||
@@ -163,7 +209,14 @@ const PortfolioItem = React.memo(({
|
||||
|
||||
{/* Row 3: Avg → Current */}
|
||||
<div className="flex justify-between items-center mt-0.5">
|
||||
<span className="text-[9px] font-mono text-[#4a6580]">{formatPrice(avgPrice, isUs)}</span>
|
||||
<span className="text-[9px] font-mono text-[#4a6580] 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>
|
||||
)}
|
||||
</span>
|
||||
<span className={`text-[10px] font-mono font-semibold ${isUp ? 'text-[#e84444]' : 'text-[#3d7eff]'}`}>
|
||||
{formatPrice(currentPrice, isUs)}
|
||||
</span>
|
||||
@@ -174,13 +227,29 @@ const PortfolioItem = React.memo(({
|
||||
<div className="px-3 pb-2">
|
||||
<button
|
||||
onClick={handleSellClick}
|
||||
disabled={sellBlocked}
|
||||
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]"
|
||||
>
|
||||
전량 청산 (시장가)
|
||||
{selling ? '주문 처리 중...' : '전량 청산 (시장가)'}
|
||||
</button>
|
||||
</div>
|
||||
<ConfirmDialog
|
||||
open={confirmOpen}
|
||||
title="전량 청산 (시장가) 확인"
|
||||
tone="sell"
|
||||
busy={selling}
|
||||
confirmLabel="시장가 매도"
|
||||
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>로 매도합니다.</>,
|
||||
<>현재가 {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>,
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
@@ -207,6 +276,8 @@ interface PortfolioPanelProps {
|
||||
accountSetupRequired?: boolean
|
||||
/** 계좌 미등록/미검증 안내 문구 (accountSetupRequired=true일 때만 사용) */
|
||||
accountSetupMessage?: string
|
||||
/** USD/KRW 환율 (서버 summary.exchange_rate). 0/없음이면 '환율 미수신' */
|
||||
exchangeRate?: number
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -225,11 +296,12 @@ const PortfolioPanel = React.memo(({
|
||||
sellDisabled,
|
||||
accountSetupRequired,
|
||||
accountSetupMessage,
|
||||
exchangeRate,
|
||||
}: PortfolioPanelProps) => {
|
||||
// 데이트레이딩 포지션은 매매 엔진(인메모리)이 진실 — Portfolio 테이블은 종목당 1행이라
|
||||
// 같은 종목을 스윙+데이트레이딩으로 동시 보유하면 구분이 사라진다. 엔진에서 직접 조회해
|
||||
// 데이트레이딩 그룹으로 표시하고, 스윙 행에서는 그 수량을 뺀다. (2026-09-23)
|
||||
const { data: dtPositions, isError: dtError } = useQuery<DayTradePosition[]>({
|
||||
const { data: dtPositions, isError: dtError, isLoading: dtLoading } = useQuery<DayTradePosition[]>({
|
||||
queryKey: ['daytrading-positions'],
|
||||
queryFn: async () => (await api.get('/daytrading/positions')).data?.positions ?? [],
|
||||
refetchInterval: 30000,
|
||||
@@ -237,7 +309,7 @@ const PortfolioPanel = React.memo(({
|
||||
})
|
||||
|
||||
const dtRows: Position[] | undefined = useMemo(() => {
|
||||
if (dtError) return undefined // 엔진 수량 불명 -> 합산 행에 "DT 포함" 배지
|
||||
if (dtError || dtLoading || dtPositions === undefined) return undefined // 로딩/실패: 엔진 수량 불명 -> 합산 행에 "DT 포함" 배지
|
||||
return (dtPositions ?? [])
|
||||
.filter(p => p.quantity > 0)
|
||||
.map(p => ({
|
||||
@@ -250,10 +322,11 @@ const PortfolioPanel = React.memo(({
|
||||
strategy_type: 'daytrading',
|
||||
market: 'KRX',
|
||||
}))
|
||||
}, [dtPositions, dtError])
|
||||
}, [dtPositions, dtError, dtLoading])
|
||||
|
||||
const groups = useMemo(() => groupHoldings(portfolio, dtRows), [portfolio, dtRows])
|
||||
const total = groups.swing.length + groups.manual.length + groups.overseas.length
|
||||
// 미관리(엔진 외) DT 잔고 포함. 엔진이 관리 중인 DT 포지션은 하단 상태바에서 확인
|
||||
const total = groups.swing.length + groups.manual.length + groups.overseas.length + groups.unmanaged.length
|
||||
|
||||
/** 그룹 소계 (실시간 가격 우선). 통화가 다르므로 그룹 내 같은 통화끼리만 합산 */
|
||||
const subtotal = (stocks: Position[]) => {
|
||||
@@ -276,6 +349,8 @@ const PortfolioPanel = React.memo(({
|
||||
color: string,
|
||||
note?: string,
|
||||
usd?: boolean,
|
||||
groupKey: 'swing' | 'manual' | 'overseas' | 'unmanaged' = 'swing',
|
||||
warn?: boolean,
|
||||
) => {
|
||||
if (stocks.length === 0) return null
|
||||
const sub = subtotal(stocks)
|
||||
@@ -283,16 +358,20 @@ const PortfolioPanel = React.memo(({
|
||||
const fmt = (n: number) => usd
|
||||
? `$${Math.round(n).toLocaleString('en-US')}`
|
||||
: `${Math.round(n).toLocaleString()}원`
|
||||
const rateOk = Number(exchangeRate) > 0
|
||||
const krwNote = usd
|
||||
? (rateOk ? `≈ ${Math.round(sub.value * Number(exchangeRate)).toLocaleString()}원` : '환율 미수신')
|
||||
: null
|
||||
return (
|
||||
<section key={label}>
|
||||
<div className="px-3 py-1 bg-[#091018] border-b border-[#152035] sticky top-0 z-10" title={note}>
|
||||
<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="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>
|
||||
</div>
|
||||
<div className="flex justify-between items-center mt-0.5 text-[8px] font-mono text-[#4a6580]">
|
||||
<span>{fmt(sub.value)}</span>
|
||||
<span>{fmt(sub.value)}{krwNote && <span className={`ml-1 ${rateOk ? '' : 'text-[#eab308]'}`}>{krwNote}</span>}</span>
|
||||
<span className={up ? 'text-[#e84444]' : 'text-[#3d7eff]'}>
|
||||
{up ? '+' : ''}{fmt(sub.pnl)} ({up ? '+' : ''}{sub.rate.toFixed(2)}%)
|
||||
</span>
|
||||
@@ -309,6 +388,7 @@ const PortfolioPanel = React.memo(({
|
||||
onRefetchAiAnalysis={onRefetchAiAnalysis}
|
||||
onSell={onSell}
|
||||
sellDisabled={sellDisabled}
|
||||
groupKey={groupKey}
|
||||
/>
|
||||
))}
|
||||
</section>
|
||||
@@ -327,6 +407,9 @@ const PortfolioPanel = React.memo(({
|
||||
{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>
|
||||
)}
|
||||
{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>
|
||||
)}
|
||||
{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>
|
||||
)}
|
||||
@@ -345,10 +428,12 @@ const PortfolioPanel = React.memo(({
|
||||
</div>
|
||||
) : total > 0 ? (
|
||||
<>
|
||||
{/* 데이트레이딩 포지션은 보유종목 목록에서 제외(사용자 요청 2026-10-06) — 하단 DayTradingStatusBar 에서 확인 */}
|
||||
{renderGroup(groups.swing, '스윙', <TrendingUp className="w-2.5 h-2.5 text-[#a78bfa]" />, 'text-[#a78bfa]')}
|
||||
{renderGroup(groups.manual, '수동 / 외부', <UserCircle className="w-2.5 h-2.5 text-[#eab308]" />, 'text-[#eab308]', '자동 청산 대상 아님 - 직접 청산 필요')}
|
||||
{renderGroup(groups.overseas, '해외', <Globe className="w-2.5 h-2.5 text-[#22c55e]" />, 'text-[#22c55e]', undefined, true)}
|
||||
{/* 엔진이 관리 중인 데이트레이딩 포지션은 목록에서 제외(사용자 요청 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.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">
|
||||
|
||||
@@ -78,13 +78,22 @@ export const PortfolioSummary = ({ summary, error, todayTradeStats, accountSetup
|
||||
|
||||
// 핵심 지표 추출
|
||||
const total_assets = n(summary.total_assets); // 총 자산 (예수금 + 주식 평가)
|
||||
const available_cash = n(summary.available_cash); // 예수금
|
||||
// 예수금: 총자산 기준 현금(총자산 − 주식평가)이 있으면 그것을 표시 → 화면에서 총자산 = 주식 + 예수금 이 맞는다
|
||||
// (KIS 예수금(dnca)은 결제 전 금액이라 총자산 구성과 어긋날 수 있음)
|
||||
const hasCashForTotal = summary.cash_for_total_assets !== undefined && summary.cash_for_total_assets !== null;
|
||||
const available_cash = hasCashForTotal ? n(summary.cash_for_total_assets) : n(summary.available_cash);
|
||||
const hasOrderableCash = summary.orderable_cash !== undefined && summary.orderable_cash !== null;
|
||||
const orderable_cash = n(summary.orderable_cash);
|
||||
const fxUnavailable = summary.exchange_rate_unavailable === true;
|
||||
const total_value = n(summary.total_value); // 주식 평가금액
|
||||
const total_investment = n(summary.total_investment); // 총 매입금액
|
||||
const total_profit_loss = n(summary.total_profit_loss); // 평가손익
|
||||
const total_profit_loss_rate = n(summary.total_profit_loss_rate); // 수익률
|
||||
const today_profit_loss = n(summary.today_profit_loss); // 금일 손익
|
||||
const today_profit_loss_rate = n(summary.today_profit_loss_rate); // 금일 수익률
|
||||
// 해외 보유(USD) 원화 환산: 환율이 0/없으면 임의 환산하지 않고 '환율 미수신' 표기
|
||||
const overseasUsd = n(summary.overseas_value_usd);
|
||||
const fxRate = n(summary.exchange_rate);
|
||||
// 누적 실현손익 — 아직 조회되지 않은 상태(undefined/null)와 실제 0원을 구분한다.
|
||||
// (2026-09-28: legacy 모드는 별도 쿼리로 병합되는 필드라 초기 렌더 시 undefined일 수 있음)
|
||||
const hasCumulativeRealizedPl = summary.cumulative_realized_pl !== undefined && summary.cumulative_realized_pl !== null;
|
||||
@@ -121,13 +130,21 @@ export const PortfolioSummary = ({ summary, error, todayTradeStats, accountSetup
|
||||
{fmt(total_assets)}<span className="text-xs text-[#64748b] ml-0.5">원</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<span className="text-[10px] font-mono text-[#475569]">
|
||||
<span
|
||||
className="text-[10px] font-mono text-[#475569]"
|
||||
title={hasCashForTotal ? '총자산 − 주식평가 기준 현금' : undefined}
|
||||
>
|
||||
예수금 {fmt(available_cash)}원
|
||||
</span>
|
||||
<span className="text-[10px] font-mono text-[#334155]">
|
||||
({cashRatio.toFixed(0)}%)
|
||||
</span>
|
||||
</div>
|
||||
{hasOrderableCash && (
|
||||
<div className="text-[9px] font-mono text-[#334155]" title="KIS 주문가능현금">
|
||||
주문가능 {fmt(orderable_cash)}원
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 평가손익 + 수익률 (합침) */}
|
||||
@@ -147,6 +164,14 @@ export const PortfolioSummary = ({ summary, error, todayTradeStats, accountSetup
|
||||
<div className="text-[10px] font-mono text-[#475569] mt-0.5">
|
||||
주식 {fmt(total_value)}원 · 매입 {fmt(total_investment)}원
|
||||
</div>
|
||||
{overseasUsd > 0 && (
|
||||
<div className="text-[10px] font-mono text-[#475569] mt-0.5">
|
||||
해외 ${Math.round(overseasUsd).toLocaleString('en-US')}{' '}
|
||||
{fxRate > 0 && !fxUnavailable
|
||||
? `(≈ ${fmt(overseasUsd * fxRate)}원)`
|
||||
: <span className="text-[#eab308]">환율 미수신</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 금일 손익 */}
|
||||
|
||||
@@ -15,10 +15,69 @@
|
||||
* 최적화:
|
||||
* - RecommendationItem, RecommendationPanel 모두 React.memo 적용
|
||||
*/
|
||||
import React, { useState } from 'react'
|
||||
import React, { useMemo, useState } from 'react'
|
||||
import { RefreshCw } from 'lucide-react'
|
||||
import { RecommendationsData, RecommendedStock, Recommendation } from '../types/trading'
|
||||
import { formatTime } from '../utils/formatUtils'
|
||||
import { parseKst } from '../utils/formatUtils'
|
||||
import ConfirmDialog from './ConfirmDialog'
|
||||
|
||||
/** 대체 규칙 이름 — 규칙 미충족 종목을 점수순으로 채우는 출처 */
|
||||
const FALLBACK_RULE_NAME = '고점수 분석 종목'
|
||||
/** 백엔드가 구조화 필드를 주지 않을 때 문자열 사유에서 매수 차단을 추론하는 패턴 */
|
||||
const BLOCK_PATTERN = /차단|급등\s*과열|거래량\s*부족/
|
||||
|
||||
/** 배치 시각 표시: KST ISO 면 날짜+시각, 날짜만 오면 날짜만 (시각 생략) */
|
||||
const formatBatchTime = (raw?: string): string | null => {
|
||||
if (!raw) return null
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(raw.trim())) return raw.trim().slice(5).replace('-', '/')
|
||||
const d = parseKst(raw)
|
||||
if (!d) return null
|
||||
const date = d.toLocaleDateString('ko-KR', { month: '2-digit', day: '2-digit', timeZone: 'Asia/Seoul' })
|
||||
const time = d.toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit', hour12: false, timeZone: 'Asia/Seoul' })
|
||||
return `${date} ${time}`
|
||||
}
|
||||
|
||||
const asStrings = (v: unknown): string[] => {
|
||||
if (typeof v === 'string') return v ? [v] : []
|
||||
if (Array.isArray(v)) return v.filter((x): x is string => typeof x === 'string' && x.length > 0)
|
||||
return []
|
||||
}
|
||||
|
||||
/** 종목 + (있다면) 추천 항목에서 출처/차단/ML 정보 추출 — 백엔드 확정 필드 우선, 없을 때만 추론 */
|
||||
const deriveInfo = (stock: any, rec: any, ruleNames: string[]) => {
|
||||
const src = [stock, rec].filter(Boolean)
|
||||
// 출처: match_source 확정 필드 우선
|
||||
const ms = src.map(o => o.match_source).find(v => typeof v === 'string' && v) as string | undefined
|
||||
let fallback: boolean
|
||||
if (ms) {
|
||||
fallback = ms === 'fallback_high_score'
|
||||
} else {
|
||||
// 폴백: 규칙 이름으로 추론
|
||||
fallback = src.some(o => o.is_fallback_rule === true) ||
|
||||
[...src.flatMap(o => asStrings(o.source_rule)), ...src.flatMap(o => asStrings(o.rule_name)), ...ruleNames]
|
||||
.some(n => n.includes(FALLBACK_RULE_NAME))
|
||||
}
|
||||
// 차단: buy_allowed === false 가 정본, block_reasons 가 사유
|
||||
const hasBuyAllowed = src.some(o => typeof o.buy_allowed === 'boolean')
|
||||
const buyAllowedFalse = src.some(o => o.buy_allowed === false)
|
||||
const reasons = src.flatMap(o => asStrings(o.block_reasons))
|
||||
const mlBlocks = src.some(o => o.ml_blocks_buy === true)
|
||||
if (mlBlocks) reasons.push('ML SELL')
|
||||
let blocked = buyAllowedFalse
|
||||
if (!hasBuyAllowed && reasons.length === 0) {
|
||||
// 폴백: 확정 필드가 전혀 없을 때만 문자열 사유에서 추론
|
||||
const inferred = [
|
||||
...src.flatMap(o => asStrings(o.signals)),
|
||||
...src.flatMap(o => asStrings(o.reasons)),
|
||||
...src.flatMap(o => asStrings(o.warnings)),
|
||||
].filter(t => BLOCK_PATTERN.test(t))
|
||||
reasons.push(...inferred)
|
||||
blocked = reasons.length > 0
|
||||
}
|
||||
const mlRaw = src.map(o => o.ml_signal).find(v => typeof v === 'string' && v) as string | undefined
|
||||
return { fallback, blocked, reasons: Array.from(new Set(reasons)), ml: mlRaw ? mlRaw.toUpperCase() : undefined }
|
||||
}
|
||||
|
||||
|
||||
/** RecommendationItem Props 정의 */
|
||||
interface RecommendationItemProps {
|
||||
@@ -34,6 +93,13 @@ interface RecommendationItemProps {
|
||||
onBuy: (stockCode: string, stockName: string) => Promise<void>
|
||||
/** true면 매수 버튼 비활성화 (예: 사용자 모드 확정 전) */
|
||||
buyDisabled?: boolean
|
||||
/** 대체 규칙('고점수 분석 종목') 출처 — '신호'가 아니라 '관심(규칙 미충족)' 표시 */
|
||||
isFallback?: boolean
|
||||
/** 매수 차단 여부 및 사유(툴팁) */
|
||||
blocked?: boolean
|
||||
blockReasons?: string
|
||||
/** ML 신호 (BUY/SELL/HOLD) */
|
||||
mlSignal?: string
|
||||
}
|
||||
|
||||
const RecommendationItem = React.memo(({
|
||||
@@ -48,16 +114,25 @@ const RecommendationItem = React.memo(({
|
||||
livePrices,
|
||||
onBuy,
|
||||
buyDisabled,
|
||||
isFallback,
|
||||
blocked,
|
||||
blockReasons,
|
||||
mlSignal,
|
||||
}: RecommendationItemProps) => {
|
||||
const changeRate = stock.change_rate || 0
|
||||
const isSelected = selectedStock === stock.stock_code
|
||||
const isUp = changeRate >= 0
|
||||
const [buying, setBuying] = useState(false)
|
||||
const [confirmOpen, setConfirmOpen] = useState(false)
|
||||
|
||||
const handleBuyClick = async (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
const handleBuyClick = (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.stopPropagation()
|
||||
if (buying || buyDisabled) return
|
||||
if (!window.confirm(`${stock.stock_name} 시장가 매수할까요?\n(수량: 총자산 × 종목당 최대비중으로 자동 산출)`)) return
|
||||
if (buying || buyDisabled || blocked) return
|
||||
setConfirmOpen(true)
|
||||
}
|
||||
|
||||
const handleConfirmBuy = async () => {
|
||||
if (buying) return
|
||||
setBuying(true)
|
||||
try {
|
||||
await onBuy(stock.stock_code, stock.stock_name)
|
||||
@@ -68,6 +143,7 @@ const RecommendationItem = React.memo(({
|
||||
// 실패 알림은 useTradeActions의 toast.error로 표시됨 (alert는 화면 블로킹)
|
||||
} finally {
|
||||
setBuying(false)
|
||||
setConfirmOpen(false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,19 +174,27 @@ const RecommendationItem = React.memo(({
|
||||
</span>
|
||||
) : isHeld ? (
|
||||
<span className="text-[8px] 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>
|
||||
) : isRecommended ? (
|
||||
<span className="text-[8px] 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 className={`text-[10px] font-mono font-bold flex-shrink-0 ${isUp ? 'text-[#e84444]' : 'text-[#3d7eff]'}`}>
|
||||
{changeRate > 0 ? '+' : ''}{changeRate.toFixed(2)}%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Row 2: code + price + RSI + score */}
|
||||
<div className="flex items-center gap-2 pl-5">
|
||||
<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]">
|
||||
{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>
|
||||
{stock.rsi != null && (
|
||||
<span className={`text-[8px] font-mono ${stock.rsi < 30 ? 'text-[#22c55e]' : stock.rsi > 70 ? 'text-[#e84444]' : 'text-[#4a6580]'}`}>
|
||||
@@ -123,13 +207,23 @@ const RecommendationItem = React.memo(({
|
||||
{(stock.score ?? 0) > 0 && (
|
||||
<span className="text-[8px] font-mono text-[#9f7aea]">{stock.score}pt</span>
|
||||
)}
|
||||
{mlSignal && (
|
||||
<span className={`text-[8px] font-mono px-1 rounded-sm ${
|
||||
mlSignal === 'SELL' ? 'text-[#3d7eff] bg-[#3d7eff]/10'
|
||||
: mlSignal === 'BUY' ? 'text-[#e84444] bg-[#e84444]/10'
|
||||
: 'text-[#4a6580] bg-[#152035]'
|
||||
}`}>
|
||||
ML {mlSignal}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Row 3: signals */}
|
||||
{stock.signals && stock.signals.length > 0 && (
|
||||
<div className="pl-5 mt-0.5">
|
||||
<span className="text-[8px] text-[#3a5570] truncate block">
|
||||
{stock.signals.slice(0, 2).join(' · ')}
|
||||
{/* 잘림 방지: 한 줄 truncate 대신 줄바꿈 허용 (ML SELL 등 뒤쪽 신호가 가려지지 않게) */}
|
||||
<span className="text-[8px] text-[#3a5570] block break-words leading-tight">
|
||||
{stock.signals.join(' · ')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -140,11 +234,25 @@ const RecommendationItem = React.memo(({
|
||||
<div className="px-3 pb-1.5 pl-8">
|
||||
<button
|
||||
onClick={handleBuyClick}
|
||||
disabled={buying || buyDisabled}
|
||||
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={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]"
|
||||
>
|
||||
{buying ? '매수 주문 중...' : '매수 (시장가)'}
|
||||
{buying ? '매수 주문 중...' : blocked ? '매수 차단' : '매수 (시장가)'}
|
||||
</button>
|
||||
<ConfirmDialog
|
||||
open={confirmOpen}
|
||||
title="시장가 매수 확인"
|
||||
tone="buy"
|
||||
busy={buying}
|
||||
confirmLabel="시장가 매수"
|
||||
onConfirm={handleConfirmBuy}
|
||||
onCancel={() => setConfirmOpen(false)}
|
||||
lines={[
|
||||
<><b className="text-[#c5d5e8]">{stock.stock_name}</b> ({stock.stock_code})</>,
|
||||
<>시장가 매수 (수량: 총자산 x 종목당 최대비중으로 자동 산출)</>,
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -192,6 +300,19 @@ const RecommendationPanel = React.memo(({
|
||||
onBuy,
|
||||
buyDisabled,
|
||||
}: RecommendationPanelProps) => {
|
||||
// 종목코드 -> 추천 항목 / 출처 규칙 이름 (구조화 필드가 없을 때 추론용)
|
||||
const { recMap, ruleMap } = useMemo(() => {
|
||||
const rm = new Map<string, any>()
|
||||
for (const r of recommendations?.recommendations || []) rm.set(r.stock_code, r)
|
||||
const um = new Map<string, string[]>()
|
||||
for (const g of recommendations?.recommendations_by_rule || []) {
|
||||
for (const st of g.stocks || []) {
|
||||
um.set(st.stock_code, [...(um.get(st.stock_code) || []), g.rule_name])
|
||||
}
|
||||
}
|
||||
return { recMap: rm, ruleMap: um }
|
||||
}, [recommendations])
|
||||
|
||||
return (
|
||||
<div className="bg-[#0c1522] border border-[#152035] rounded overflow-hidden flex flex-col h-1/2">
|
||||
{/* Header */}
|
||||
@@ -200,9 +321,9 @@ const RecommendationPanel = React.memo(({
|
||||
분석 종목 스캐너
|
||||
</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
{recommendations?.batch_time && (
|
||||
{(recommendations?.batch_time || recommendations?.batch_date) && (
|
||||
<span className="text-[9px] text-[#3a5570] font-mono">
|
||||
{formatTime(recommendations.batch_time)}
|
||||
{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">
|
||||
@@ -237,6 +358,7 @@ const RecommendationPanel = React.memo(({
|
||||
const isRecommended = (recommendations?.recommendations || []).some((r: Recommendation) => r.stock_code === stock.stock_code)
|
||||
const isHeld = heldStockCodes.has(stock.stock_code)
|
||||
const isPending = pendingBuyStockCodes.has(stock.stock_code)
|
||||
const info = deriveInfo(stock, recMap.get(stock.stock_code), ruleMap.get(stock.stock_code) || [])
|
||||
return (
|
||||
<RecommendationItem
|
||||
key={`${stock.stock_code}-${idx}`}
|
||||
@@ -251,6 +373,10 @@ const RecommendationPanel = React.memo(({
|
||||
livePrices={livePrices}
|
||||
onBuy={onBuy}
|
||||
buyDisabled={buyDisabled}
|
||||
isFallback={info.fallback}
|
||||
blocked={info.blocked}
|
||||
blockReasons={info.reasons.join(', ')}
|
||||
mlSignal={info.ml}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -4,11 +4,14 @@
|
||||
* 역할:
|
||||
* - 4단 차트 구성: 캔들+볼린저+이평선 | 거래량 | RSI(14) | MACD
|
||||
* - 모든 차트의 시간축 동기화 (메인 차트 스크롤 시 하위 차트 연동)
|
||||
* - 실시간 현재가 반영 (500ms polling, ref 기반 — 리렌더 없음)
|
||||
* - 실시간 현재가 반영 (livePrice prop 변경 시 마지막 캔들만 update — 차트 재생성 없음)
|
||||
* · 일봉: 마지막 캔들이 오늘이면 update, 과거면 국내 장중에만 오늘 캔들 추가
|
||||
* · 주/월봉: 마지막 캔들이 이번 주/월일 때만 update (아니면 무시)
|
||||
* · 장중 고가/저가는 ref 로 누적, 거래량은 당일 누적(ACML_VOL)이 있으면 일봉에만 반영
|
||||
* - 반응형 크기 조절 (ResizeObserver + window resize)
|
||||
*
|
||||
* 최적화 전략:
|
||||
* - React.memo + 커스텀 비교 함수: livePrice 변경 무시, data 첫/끝 캔들만 비교
|
||||
* - React.memo + 커스텀 비교 함수: data 첫/끝 캔들 + livePrice/liveVolume 비교
|
||||
* - 차트 인스턴스는 마운트 시 1회만 생성 (chartsCreatedRef)
|
||||
* - 데이터 변경 시 시리즈 데이터만 교체 (차트 재생성 없음 → 깜빡임 방지)
|
||||
* - 기술적 지표 계산은 useMemo로 캐싱 (O(n) 슬라이딩 윈도우)
|
||||
@@ -24,13 +27,45 @@ import {
|
||||
calculateMACD
|
||||
} from '../utils/technicalIndicators';
|
||||
import { CandleData, ProcessedChartData } from '../types/trading';
|
||||
import { isKrMarketOpen } from '../utils/marketHours';
|
||||
import { isUsStock } from '../utils/stockUtils';
|
||||
|
||||
/** TradingChart Props 정의 */
|
||||
interface TradingChartProps {
|
||||
/** 캔들 데이터 배열 (OHLCV) */
|
||||
data: CandleData[];
|
||||
/** 실시간 현재가 (ref 기반 처리, 리렌더 트리거 안 함) */
|
||||
/** 실시간 현재가 — 변경 시 마지막 캔들만 update (차트 재생성 없음) */
|
||||
livePrice?: number;
|
||||
/** 당일 누적 거래량 (일봉 오늘 캔들에만 반영) */
|
||||
liveVolume?: number;
|
||||
/** 차트 기간 (D/W/M) — 실시간 캔들 반영 규칙 결정 */
|
||||
period?: 'D' | 'W' | 'M';
|
||||
/** 종목 코드 — 국내/해외 판정(오늘 날짜 기준 시간대, 장중 캔들 추가 여부) */
|
||||
stockCode?: string;
|
||||
}
|
||||
|
||||
/** 지정 시간대 기준 오늘 날짜 'YYYY-MM-DD' */
|
||||
const todayInTz = (timeZone: string): string => {
|
||||
// en-CA 로캘은 YYYY-MM-DD 형식
|
||||
return new Intl.DateTimeFormat('en-CA', { timeZone, year: 'numeric', month: '2-digit', day: '2-digit' }).format(new Date());
|
||||
};
|
||||
|
||||
/** 'YYYY-MM-DD' 가 속한 주의 월요일 'YYYY-MM-DD' (UTC 계산 — 시간대 영향 없음) */
|
||||
const weekStartOf = (ymd: string): string => {
|
||||
const [y, m, d] = ymd.split('-').map(Number);
|
||||
if (!y || !m || !d) return '';
|
||||
const t = Date.UTC(y, m - 1, d);
|
||||
const dow = new Date(t).getUTCDay(); // 0=일
|
||||
const monday = new Date(t - ((dow + 6) % 7) * 86400000);
|
||||
return monday.toISOString().slice(0, 10);
|
||||
};
|
||||
|
||||
/** 실시간으로 갱신 중인 캔들 상태 (setData 시 초기화) */
|
||||
interface LiveCandleState {
|
||||
time: string;
|
||||
open: number;
|
||||
high: number;
|
||||
low: number;
|
||||
}
|
||||
|
||||
// 한국 시장 색상 규칙 (상승=빨강, 하락=파랑)
|
||||
@@ -50,10 +85,16 @@ const COLORS = {
|
||||
border: '#374151',
|
||||
} as const;
|
||||
|
||||
export const TradingChart = React.memo(({ data, livePrice: livePriceProp }: TradingChartProps) => {
|
||||
// livePrice를 ref로 유지 — 리렌더 없이 useEffect에서 최신 값 사용
|
||||
export const TradingChart = React.memo(({ data, livePrice: livePriceProp, liveVolume, period = 'D', stockCode = '' }: TradingChartProps) => {
|
||||
// 최신 props 를 ref 로 유지 — 차트 생성/데이터 교체 콜백에서 참조
|
||||
const livePriceRef = useRef(livePriceProp);
|
||||
livePriceRef.current = livePriceProp;
|
||||
const liveVolumeRef = useRef(liveVolume);
|
||||
liveVolumeRef.current = liveVolume;
|
||||
const periodRef = useRef(period);
|
||||
periodRef.current = period;
|
||||
const stockCodeRef = useRef(stockCode);
|
||||
stockCodeRef.current = stockCode;
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [containerHeight, setContainerHeight] = useState(350);
|
||||
// containerHeight를 ref로도 유지 — ResizeObserver 클로저가 최신 값 참조 (Observer 마운트 1회만 생성)
|
||||
@@ -264,6 +305,11 @@ export const TradingChart = React.memo(({ data, livePrice: livePriceProp }: Trad
|
||||
volumeSeriesRef.current = null;
|
||||
};
|
||||
|
||||
// 실시간 캔들 상태 refs (applySeriesData 에서 초기화)
|
||||
const liveCandleRef = useRef<LiveCandleState | null>(null);
|
||||
const lastAppliedPriceRef = useRef<number | null>(null);
|
||||
const lastAppliedVolumeRef = useRef<number | null>(null);
|
||||
|
||||
// 보조 시리즈 refs (데이터 교체용)
|
||||
const mainLineSeriesRef = useRef<ISeriesApi<'Line'>[]>([]);
|
||||
const rsiLineSeriesRef = useRef<ISeriesApi<'Line'>[]>([]);
|
||||
@@ -292,8 +338,11 @@ export const TradingChart = React.memo(({ data, livePrice: livePriceProp }: Trad
|
||||
try { chart?.timeScale().setVisibleLogicalRange(logicalRange); } catch (_) { /* not laid out yet */ }
|
||||
};
|
||||
|
||||
// 메인 차트 데이터 교체
|
||||
// 메인 차트 데이터 교체 — 실시간 캔들 상태도 초기화 (새 데이터 기준으로 다시 누적)
|
||||
candlestickSeriesRef.current.setData(pd.candles);
|
||||
liveCandleRef.current = null;
|
||||
lastAppliedPriceRef.current = null;
|
||||
lastAppliedVolumeRef.current = null;
|
||||
const mainLines = mainLineSeriesRef.current;
|
||||
if (mainLines.length === 5) {
|
||||
mainLines[0].setData(pd.bollinger.upper);
|
||||
@@ -329,6 +378,64 @@ export const TradingChart = React.memo(({ data, livePrice: livePriceProp }: Trad
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 실시간 현재가를 마지막(또는 오늘) 캔들에 반영 — 차트 재생성 없이 series.update 만 호출
|
||||
const applyLivePrice = useCallback(() => {
|
||||
const price = livePriceRef.current;
|
||||
const series = candlestickSeriesRef.current;
|
||||
const pd = processedDataRef.current as any;
|
||||
if (!price || !(price > 0) || !series || !pd) return;
|
||||
const candles = pd.candles as CandlestickData[];
|
||||
if (!candles || candles.length === 0) return;
|
||||
const vol = liveVolumeRef.current;
|
||||
if (price === lastAppliedPriceRef.current && vol === lastAppliedVolumeRef.current) return;
|
||||
|
||||
const last = candles[candles.length - 1];
|
||||
const lastTime = String(last.time);
|
||||
const code = stockCodeRef.current;
|
||||
const us = !!code && isUsStock(code);
|
||||
const today = todayInTz(us ? 'America/New_York' : 'Asia/Seoul');
|
||||
const p = periodRef.current;
|
||||
|
||||
// 반영 방식 결정 — 해당 기간 캔들이 아니면 아무것도 하지 않는다 (과거 캔들 오염 방지)
|
||||
let mode: 'update' | 'append' | null = null;
|
||||
if (p === 'D') {
|
||||
if (lastTime === today) mode = 'update';
|
||||
else if (lastTime < today && !us && isKrMarketOpen()) mode = 'append';
|
||||
} else if (p === 'W') {
|
||||
if (weekStartOf(lastTime) && weekStartOf(lastTime) === weekStartOf(today)) mode = 'update';
|
||||
} else if (p === 'M') {
|
||||
if (lastTime.slice(0, 7) === today.slice(0, 7)) mode = 'update';
|
||||
}
|
||||
if (!mode) return;
|
||||
|
||||
let lc = liveCandleRef.current;
|
||||
if (!lc) {
|
||||
lc = mode === 'update'
|
||||
? { time: lastTime, open: last.open, high: last.high, low: last.low }
|
||||
: { time: today, open: price, high: price, low: price };
|
||||
liveCandleRef.current = lc;
|
||||
}
|
||||
// 장중 고가/저가 누적 (틱 사이 되돌림이 있어도 고저가 유지)
|
||||
lc.high = Math.max(lc.high, price);
|
||||
lc.low = Math.min(lc.low, price);
|
||||
try {
|
||||
series.update({ time: lc.time as any, open: lc.open, high: lc.high, low: lc.low, close: price });
|
||||
// 거래량: 당일 누적(ACML_VOL)이므로 일봉 오늘 캔들에만 반영
|
||||
if (p === 'D' && vol && vol > 0 && volumeSeriesRef.current) {
|
||||
volumeSeriesRef.current.update({
|
||||
time: lc.time as any,
|
||||
value: vol,
|
||||
color: price >= lc.open ? 'rgba(239, 68, 68, 0.6)' : 'rgba(59, 130, 246, 0.6)',
|
||||
});
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const err = error as Error | null;
|
||||
console.warn('실시간 캔들 반영 실패:', err?.message);
|
||||
}
|
||||
lastAppliedPriceRef.current = price;
|
||||
lastAppliedVolumeRef.current = vol ?? null;
|
||||
}, []);
|
||||
|
||||
// 차트 초기 생성 (마운트 시 1회만) — 생성 직후 대기 중인 데이터 즉시 적용
|
||||
const chartsCreatedRef = useRef(false);
|
||||
|
||||
@@ -429,6 +536,7 @@ export const TradingChart = React.memo(({ data, livePrice: livePriceProp }: Trad
|
||||
// 차트 생성 완료 — 이미 대기 중인 processedData가 있으면 즉시 적용
|
||||
if (processedDataRef.current) {
|
||||
applySeriesData(processedDataRef.current);
|
||||
applyLivePrice();
|
||||
}
|
||||
|
||||
// Cleanup (컴포넌트 언마운트 시에만)
|
||||
@@ -450,30 +558,15 @@ export const TradingChart = React.memo(({ data, livePrice: livePriceProp }: Trad
|
||||
// 차트가 아직 생성되지 않았으면 건너뛰기 (생성 완료 시 자동 적용됨)
|
||||
if (!chartsCreatedRef.current) return;
|
||||
applySeriesData(processedData);
|
||||
}, [processedData, applySeriesData]);
|
||||
// setData 로 실시간 상태가 초기화됐으므로 현재가를 즉시 재적용
|
||||
applyLivePrice();
|
||||
}, [processedData, applySeriesData, applyLivePrice]);
|
||||
|
||||
// 실시간 현재가 — 마지막 캔들만 update() (차트 재생성 없음)
|
||||
// ref 기반 polling: 리렌더 없이 500ms마다 최신 livePrice 반영
|
||||
const lastAppliedPriceRef = useRef<number | null>(null);
|
||||
// 실시간 현재가/거래량/기간 변경 — 마지막 캔들만 update() (차트 재생성 없음)
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => {
|
||||
const price = livePriceRef.current;
|
||||
if (!price || !candlestickSeriesRef.current || !processedData) return;
|
||||
if (price === lastAppliedPriceRef.current) return; // 같은 가격이면 skip
|
||||
const candles = processedData.candles;
|
||||
if (!candles || candles.length === 0) return;
|
||||
const last = candles[candles.length - 1];
|
||||
candlestickSeriesRef.current.update({
|
||||
time: last.time as any,
|
||||
open: last.open,
|
||||
high: Math.max(last.high, price),
|
||||
low: Math.min(last.low, price),
|
||||
close: price,
|
||||
});
|
||||
lastAppliedPriceRef.current = price;
|
||||
}, 500);
|
||||
return () => clearInterval(id);
|
||||
}, [processedData]);
|
||||
if (!chartsCreatedRef.current) return;
|
||||
applyLivePrice();
|
||||
}, [livePriceProp, liveVolume, period, stockCode, applyLivePrice]);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="w-full h-full relative bg-[#0b0e14] rounded-lg overflow-hidden">
|
||||
@@ -519,7 +612,11 @@ export const TradingChart = React.memo(({ data, livePrice: livePriceProp }: Trad
|
||||
</div>
|
||||
);
|
||||
}, (prevProps, nextProps) => {
|
||||
// livePrice는 ref로 처리 — 리렌더 불필요 (비교 제외)
|
||||
// 실시간 값 변경은 리렌더 필요 (effect 에서 series.update — 차트 재생성 없음, 초당 최대 1회)
|
||||
if (prevProps.livePrice !== nextProps.livePrice) return false;
|
||||
if (prevProps.liveVolume !== nextProps.liveVolume) return false;
|
||||
if (prevProps.period !== nextProps.period) return false;
|
||||
if (prevProps.stockCode !== nextProps.stockCode) return false;
|
||||
// data 배열의 내용이 같으면 리렌더 건너뛰기 (참조가 달라도)
|
||||
if (prevProps.data === nextProps.data) return true;
|
||||
if (!prevProps.data || !nextProps.data) return false;
|
||||
|
||||
@@ -16,6 +16,11 @@
|
||||
* @returns livePrices, livePricesRef, liveQuote, handleMarketUpdate
|
||||
*/
|
||||
import { useEffect, useState, useRef, useCallback } from 'react'
|
||||
import { isKrMarketOpen } from '../utils/marketHours'
|
||||
import { isUsStock } from '../utils/stockUtils'
|
||||
|
||||
/** 최근 체결 수신 후 호가(bid1)로 가격을 덮지 않는 유예 시간 */
|
||||
const QUOTE_OVERRIDE_TICK_GRACE_MS = 10_000
|
||||
|
||||
export const useMarketData = (
|
||||
selectedStock: string,
|
||||
@@ -29,6 +34,12 @@ export const useMarketData = (
|
||||
const livePricesRef = useRef<Record<string, number>>({})
|
||||
/** 가격 변경 여부 플래그 — flush interval에서 확인 */
|
||||
const pricesDirtyRef = useRef(false)
|
||||
/** 종목별 마지막 체결(tick) 수신 시각(ms) — 최근 체결이 있으면 호가 bid1 으로 가격을 덮지 않는다 */
|
||||
const lastTickAtRef = useRef<Record<string, number>>({})
|
||||
/** 종목별 당일 누적 거래량(tick ACML_VOL) — 차트 오늘 캔들 거래량 반영용 */
|
||||
const [liveVolumes, setLiveVolumes] = useState<Record<string, number>>({})
|
||||
const liveVolumesRef = useRef<Record<string, number>>({})
|
||||
const volumesDirtyRef = useRef(false)
|
||||
|
||||
/** 선택 종목의 실시간 호가 데이터 (WebSocket quote 메시지) */
|
||||
const [liveQuote, setLiveQuote] = useState<any>(null)
|
||||
@@ -40,6 +51,10 @@ export const useMarketData = (
|
||||
pricesDirtyRef.current = false
|
||||
setLivePrices({ ...livePricesRef.current })
|
||||
}
|
||||
if (volumesDirtyRef.current) {
|
||||
volumesDirtyRef.current = false
|
||||
setLiveVolumes({ ...liveVolumesRef.current })
|
||||
}
|
||||
}, 1000)
|
||||
return () => clearInterval(id)
|
||||
}, [])
|
||||
@@ -54,7 +69,17 @@ export const useMarketData = (
|
||||
*/
|
||||
const handleMarketUpdate = useCallback((data: unknown) => {
|
||||
const d = data as any
|
||||
// 국내 장외 시간엔 잔고(portfolio current_price)가 기준 — 시간외/잔여 틱이 잔고 동기화 값을 덮어쓰지 않도록 가격 캐시 갱신을 건너뛴다.
|
||||
// (호가창 liveQuote 는 아래에서 계속 갱신)
|
||||
const krClosedForCode = !!d?.stock_code && !isUsStock(d.stock_code) && !isKrMarketOpen()
|
||||
if (krClosedForCode && d?._type === 'tick') return
|
||||
if (d?._type === 'tick' && d.stock_code && d.price != null) {
|
||||
lastTickAtRef.current[d.stock_code] = Date.now()
|
||||
const vol = Number(d.volume)
|
||||
if (Number.isFinite(vol) && vol > 0 && liveVolumesRef.current[d.stock_code] !== vol) {
|
||||
liveVolumesRef.current[d.stock_code] = vol
|
||||
volumesDirtyRef.current = true
|
||||
}
|
||||
// 체결가 변경 시에만 ref 업데이트 + dirty 플래그 설정
|
||||
const prev = livePricesRef.current[d.stock_code]
|
||||
if (prev !== d.price) {
|
||||
@@ -64,8 +89,12 @@ export const useMarketData = (
|
||||
} else if (d?._type === 'quote' && d.stock_code) {
|
||||
// 호가 데이터에서 현재가 추정 (bid1 = 최우선 매수호가)
|
||||
// tick 데이터가 없을 때 P&L 계산용 가격 업데이트
|
||||
// 최근 QUOTE_OVERRIDE_TICK_GRACE_MS 내 체결(tick)이 있으면 체결가가 기준 — bid1 으로 덮지 않는다
|
||||
// (매 호가마다 체결가↔매수호가가 번갈아 표시되며 가격이 흔들리던 문제)
|
||||
const bidPrice = d.bid1 as number | undefined
|
||||
if (bidPrice && bidPrice > 0) {
|
||||
const lastTickAt = lastTickAtRef.current[d.stock_code] ?? 0
|
||||
const recentTick = Date.now() - lastTickAt < QUOTE_OVERRIDE_TICK_GRACE_MS
|
||||
if (bidPrice && bidPrice > 0 && !krClosedForCode && !recentTick) {
|
||||
const prev = livePricesRef.current[d.stock_code]
|
||||
if (prev !== bidPrice) {
|
||||
livePricesRef.current[d.stock_code] = bidPrice
|
||||
@@ -80,14 +109,20 @@ export const useMarketData = (
|
||||
}, [selectedStock])
|
||||
|
||||
|
||||
// 포트폴리오 로드 시 초기 가격 설정 (WebSocket 연결 전 가격 폴백)
|
||||
// 포트폴리오 로드 시 가격 설정
|
||||
// - 장중: 실시간 가격이 없는 종목만 잔고 가격으로 초기화
|
||||
// - 국내 장외: 잔고(KIS 동기화) 가격이 우선 — 이전 장중 틱 캐시를 덮어쓴다 (카드/잔고 불일치 방지)
|
||||
useEffect(() => {
|
||||
if (!portfolio) return
|
||||
let changed = false
|
||||
const krOpen = isKrMarketOpen()
|
||||
portfolio.forEach((s: any) => {
|
||||
// 아직 실시간 가격이 없는 종목만 초기화
|
||||
if (s.current_price && !livePricesRef.current[s.stock_code]) {
|
||||
livePricesRef.current[s.stock_code] = Number(s.current_price)
|
||||
const px = Number(s.current_price || 0)
|
||||
if (!(px > 0)) return
|
||||
const cur = livePricesRef.current[s.stock_code]
|
||||
const overrideClosed = !isUsStock(s.stock_code) && !krOpen
|
||||
if (overrideClosed ? cur !== px : !cur) {
|
||||
livePricesRef.current[s.stock_code] = px
|
||||
changed = true
|
||||
}
|
||||
})
|
||||
@@ -107,6 +142,8 @@ export const useMarketData = (
|
||||
livePrices,
|
||||
/** 종목별 실시간 가격 ref (리렌더 없이 즉시 접근) */
|
||||
livePricesRef,
|
||||
/** 종목별 당일 누적 거래량 (state — 1초마다 갱신) */
|
||||
liveVolumes,
|
||||
/** 선택 종목 실시간 호가 데이터 */
|
||||
liveQuote,
|
||||
/** WebSocket market_update 메시지 콜백 */
|
||||
|
||||
@@ -46,6 +46,8 @@ export const useWebSocket = (clientId: string, options?: UseWebSocketOptions) =>
|
||||
const pongTimeoutRef = useRef<NodeJS.Timeout | null>(null)
|
||||
const reconnectAttemptsRef = useRef(0)
|
||||
const mountedRef = useRef(true)
|
||||
/** 마지막 onopen 시각 — 안정 연결(>=STABLE_MS) 판단용 */
|
||||
const openedAtRef = useRef(0)
|
||||
const socketRef = useRef<WebSocket | null>(null)
|
||||
// 콜백을 ref로 유지 — connect 클로저가 재생성되지 않아도 최신 콜백 참조
|
||||
const onMarketUpdateRef = useRef(options?.onMarketUpdate)
|
||||
@@ -80,7 +82,11 @@ export const useWebSocket = (clientId: string, options?: UseWebSocketOptions) =>
|
||||
|
||||
try {
|
||||
const apiKey = localStorage.getItem('api_key') || ''
|
||||
const ws = new WebSocket(`${config.wsBaseUrl}/ws/${clientId}?token=${encodeURIComponent(apiKey)}`)
|
||||
// 빈 토큰은 서브프로토콜로 쓸 수 없고(SyntaxError) 어차피 인증 실패 — 연결 시도하지 않음
|
||||
if (!apiKey) return
|
||||
// 토큰은 URL 쿼리(로그/히스토리에 남음) 대신 서브프로토콜로 전달: ["autostock.auth", token]
|
||||
// 주의: 백엔드가 이 서브프로토콜을 지원해야 연결된다 — 백엔드와 같은 릴리스로 배포할 것.
|
||||
const ws = new WebSocket(`${config.wsBaseUrl}/ws/${clientId}`, ['autostock.auth', apiKey])
|
||||
// 즉시 socketRef에 할당 — onclose 가드가 null 상태를 보지 않도록
|
||||
socketRef.current = ws
|
||||
|
||||
@@ -89,7 +95,8 @@ export const useWebSocket = (clientId: string, options?: UseWebSocketOptions) =>
|
||||
if (socketRef.current !== ws) { ws.close(); return }
|
||||
console.log('🔗 WebSocket connected')
|
||||
setConnected(true)
|
||||
reconnectAttemptsRef.current = 0
|
||||
// 백오프 리셋은 연결이 일정 시간 유지된 뒤에만 (열자마자 닫히는 서버에서 3초 폭주 방지)
|
||||
openedAtRef.current = Date.now()
|
||||
lastPongReceivedRef.current = Date.now()
|
||||
|
||||
// 30초마다 ping 전송 (Nginx/프록시 타임아웃 방지)
|
||||
@@ -155,6 +162,10 @@ export const useWebSocket = (clientId: string, options?: UseWebSocketOptions) =>
|
||||
if (socketRef.current !== null && socketRef.current !== ws) return
|
||||
|
||||
console.log('🔌 WebSocket disconnected')
|
||||
if (openedAtRef.current && Date.now() - openedAtRef.current >= 15000) {
|
||||
reconnectAttemptsRef.current = 0
|
||||
}
|
||||
openedAtRef.current = 0
|
||||
setConnected(false)
|
||||
socketRef.current = null
|
||||
|
||||
@@ -171,7 +182,8 @@ export const useWebSocket = (clientId: string, options?: UseWebSocketOptions) =>
|
||||
// 지수 백오프 재연결 (최대 30초 간격, 무제한 재시도)
|
||||
if (mountedRef.current) {
|
||||
const attempt = reconnectAttemptsRef.current
|
||||
const delay = Math.min(baseDelay * Math.pow(1.5, attempt), 30000)
|
||||
// 지수 백오프 + 지터(동시 재연결 폭주 방지)
|
||||
const delay = Math.min(baseDelay * Math.pow(1.5, attempt), 30000) * (0.8 + Math.random() * 0.4)
|
||||
reconnectAttemptsRef.current += 1
|
||||
console.log(`🔄 Reconnecting WebSocket in ${(delay / 1000).toFixed(1)}s (attempt ${attempt + 1})...`)
|
||||
reconnectTimeoutRef.current = setTimeout(() => {
|
||||
|
||||
@@ -34,6 +34,11 @@ function showOfflineIndicator(offline: boolean) {
|
||||
|
||||
// 재시도 가능 여부 판단
|
||||
function isRetryableError(error: AxiosError): boolean {
|
||||
// 멱등하지 않은 요청(주문 POST/DELETE 등)은 절대 자동 재시도하지 않는다 — 타임아웃/5xx 후 재시도하면 중복 주문이 된다.
|
||||
const method = (error.config?.method || 'get').toLowerCase()
|
||||
if (method !== 'get' && method !== 'head') return false
|
||||
// 타임아웃은 서버가 느린 것이므로 재시도가 부하만 키운다(15초 x 4회 폭주 방지)
|
||||
if (error.code === 'ECONNABORTED' || error.code === 'ETIMEDOUT') return false
|
||||
if (!error.response) {
|
||||
// 네트워크 오류 (재시도 가능)
|
||||
return true
|
||||
@@ -70,7 +75,7 @@ api.interceptors.request.use(
|
||||
// 재시도 카운트 초기화 (첫 요청인 경우)
|
||||
if (!config.retryCount) {
|
||||
config.retryCount = 0
|
||||
config.retryMaxCount = 3
|
||||
config.retryMaxCount = 2
|
||||
}
|
||||
|
||||
return config
|
||||
@@ -93,18 +98,18 @@ api.interceptors.response.use(
|
||||
|
||||
if (!config.retryCount) {
|
||||
config.retryCount = 0
|
||||
config.retryMaxCount = 3
|
||||
config.retryMaxCount = 2
|
||||
}
|
||||
|
||||
// 재시도 가능 여부 확인
|
||||
const maxCount = config.retryMaxCount ?? 3
|
||||
const maxCount = config.retryMaxCount ?? 2
|
||||
const shouldRetry =
|
||||
isRetryableError(error) &&
|
||||
config.retryCount < maxCount
|
||||
|
||||
if (shouldRetry) {
|
||||
config.retryCount += 1
|
||||
const delay = getBackoffDelay(config.retryCount - 1)
|
||||
const delay = getBackoffDelay(config.retryCount - 1) * (0.8 + Math.random() * 0.4)
|
||||
console.log(
|
||||
`🔄 Retrying request (${config.retryCount}/${config.retryMaxCount}) ` +
|
||||
`after ${delay}ms: ${config.method?.toUpperCase()} ${config.url}`
|
||||
|
||||
@@ -10,11 +10,18 @@ const queryClient = new QueryClient({
|
||||
queries: {
|
||||
refetchOnWindowFocus: false,
|
||||
// 배포(1~2분 다운) 후에도 화면이 스스로 살아나도록:
|
||||
// - 재시도 3회(지수 백오프, 최대 10초)
|
||||
// - 재시도 최대 2회(지수 백오프, 최대 30초)
|
||||
// - 네트워크 복구 시 자동 재조회
|
||||
// - 오프라인 판단으로 쿼리가 멈추지 않도록 항상 실행
|
||||
retry: 3,
|
||||
retryDelay: (attempt) => Math.min(1000 * 2 ** attempt, 10000),
|
||||
// axios 인터셉터가 이미 GET 을 재시도(지수 백오프)했거나 타임아웃인 오류는 react-query 가 다시 재시도하지 않는다
|
||||
// (이전: 인터셉터 4회 x react-query 4회 = 요청 15~16회 연속 폭주)
|
||||
retry: (failureCount, error) => {
|
||||
const e = error as { config?: { retryCount?: number }; code?: string }
|
||||
if (e?.code === 'ECONNABORTED' || e?.code === 'ETIMEDOUT') return failureCount < 1
|
||||
if ((e?.config?.retryCount ?? 0) > 0) return false
|
||||
return failureCount < 2
|
||||
},
|
||||
retryDelay: (attempt) => Math.min(2000 * 2 ** attempt, 30000),
|
||||
refetchOnReconnect: true,
|
||||
networkMode: 'always',
|
||||
},
|
||||
|
||||
@@ -39,6 +39,8 @@ import AccountSetupNotice from '../components/AccountSetupNotice'
|
||||
import MarketIndexBar from '../components/MarketIndexBar'
|
||||
import { CandleData, Position, TradeHistory } from '../types/trading'
|
||||
import { isUsStock } from '../utils/stockUtils'
|
||||
import { isKrMarketOpen } from '../utils/marketHours'
|
||||
import { parseKst } from '../utils/formatUtils'
|
||||
import './Dashboard.css'
|
||||
|
||||
/**
|
||||
@@ -53,6 +55,25 @@ import './Dashboard.css'
|
||||
*
|
||||
* @returns 한국장 또는 미국장이 운영 중이면 true
|
||||
*/
|
||||
/** 동일 차트 요청 중복 억제 창 */
|
||||
const CHART_REQ_DEDUPE_MS = 3000
|
||||
/** 장중 차트 재요청 주기 */
|
||||
const CHART_REFRESH_INTERVAL_MS = 60_000
|
||||
/** 차트 로딩 스피너 최대 표시 시간 */
|
||||
const CHART_LOADING_TIMEOUT_MS = 15_000
|
||||
|
||||
/** 차트 응답의 빈 데이터/샘플/오류 사유를 사용자 문구로 변환 */
|
||||
const describeChartEmptyReason = (reason: unknown, error: string | null, isSample: boolean): string | null => {
|
||||
const r = typeof reason === 'string' ? reason : ''
|
||||
const known: Record<string, string> = {
|
||||
overseas_chart_unavailable: '해외 종목 차트 미지원',
|
||||
}
|
||||
if (r) return known[r] ?? r
|
||||
if (error) return error
|
||||
if (isSample) return '실데이터 없음 — 샘플 데이터 표시 안 함'
|
||||
return null
|
||||
}
|
||||
|
||||
const checkMarketOpen = () => {
|
||||
const now = new Date();
|
||||
const kstTime = new Date(now.toLocaleString("en-US", { timeZone: "Asia/Seoul" }));
|
||||
@@ -110,6 +131,13 @@ const Dashboard = () => {
|
||||
const chartDataRef = useRef<CandleData[] | null>(null)
|
||||
const [chartLoading, setChartLoading] = useState(false)
|
||||
const chartLoadingRef = useRef(false)
|
||||
// chartData 가 어느 종목의 데이터인지 — 종목 전환 직후 옛 차트에 새 종목 실시간가가 적용되는 것을 막는다
|
||||
const [chartStock, setChartStock] = useState<string>('')
|
||||
const chartStockRef = useRef<string>('')
|
||||
// 차트 데이터가 없을 때 표시할 사유 (샘플 데이터·해외 미지원·오류)
|
||||
const [chartEmptyReason, setChartEmptyReason] = useState<string | null>(null)
|
||||
// 마지막으로 보낸 차트 요청 중 응답을 기다리는 것 (silent=백그라운드 갱신)
|
||||
const chartAwaitingRef = useRef<{ key: string; at: number; silent: boolean } | null>(null)
|
||||
// selectedStock / chartPeriod을 콜백 클로저에서 안전하게 참조하기 위한 ref
|
||||
const selectedStockRef = useRef<string>('')
|
||||
const chartPeriodRef = useRef<'D' | 'W' | 'M'>('D')
|
||||
@@ -156,6 +184,7 @@ const Dashboard = () => {
|
||||
// Market data hook (with throttled live prices and market update handler)
|
||||
const {
|
||||
livePrices,
|
||||
liveVolumes,
|
||||
liveQuote,
|
||||
handleMarketUpdate,
|
||||
} = useMarketData(
|
||||
@@ -163,28 +192,40 @@ const Dashboard = () => {
|
||||
portfolio
|
||||
)
|
||||
|
||||
// chart_data 메시지 처리 함수 (wsMessages useEffect에서 호출)
|
||||
// chart_data 메시지 처리 함수 (ws-chart-data CustomEvent 에서 호출)
|
||||
const processChartData = useCallback((m: Record<string, unknown>) => {
|
||||
console.log('📊 processChartData called', {
|
||||
msgStock: m?.stock_code,
|
||||
msgPeriod: m?.period,
|
||||
refStock: selectedStockRef.current,
|
||||
refPeriod: chartPeriodRef.current,
|
||||
})
|
||||
// 응답이 현재 선택 종목/기간과 일치할 때만 반영 (레이스 컨디션 방지)
|
||||
// 오류 응답은 period 가 없을 수 있어 종목만 일치하면 수용
|
||||
if (
|
||||
m?.stock_code !== selectedStockRef.current ||
|
||||
m?.period !== chartPeriodRef.current
|
||||
(m?.period != null && m.period !== chartPeriodRef.current)
|
||||
) {
|
||||
console.log('📊 processChartData SKIP — stock/period mismatch')
|
||||
return
|
||||
}
|
||||
const rawData = (m.data as Record<string, unknown>[]) || []
|
||||
if (rawData.length === 0) {
|
||||
chartDataRef.current = null
|
||||
setChartData(null)
|
||||
chartLoadingRef.current = false
|
||||
setChartLoading(false)
|
||||
const key = `${selectedStockRef.current}|${chartPeriodRef.current}`
|
||||
const awaiting = chartAwaitingRef.current
|
||||
const silent = !!awaiting && awaiting.key === key && awaiting.silent
|
||||
if (awaiting && awaiting.key === key) chartAwaitingRef.current = null
|
||||
const clearLoading = () => {
|
||||
if (chartLoadingRef.current) {
|
||||
chartLoadingRef.current = false
|
||||
setChartLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const rawData = Array.isArray(m.data) ? (m.data as Record<string, unknown>[]) : []
|
||||
const isSample = m.is_sample === true
|
||||
const errMsg = typeof m.error === 'string' && m.error ? m.error : null
|
||||
if (rawData.length === 0 || isSample || errMsg) {
|
||||
// 백그라운드 갱신이 실패한 경우엔 기존 실데이터를 유지한다 (일시 오류로 차트가 사라지지 않게)
|
||||
const hasCurrent = !!chartDataRef.current && chartStockRef.current === selectedStockRef.current
|
||||
if (!(silent && hasCurrent)) {
|
||||
// 샘플(가짜) 캔들은 그리지 않는다
|
||||
chartDataRef.current = null
|
||||
setChartData(null)
|
||||
setChartEmptyReason(describeChartEmptyReason(m.reason, errMsg, isSample))
|
||||
}
|
||||
clearLoading()
|
||||
return
|
||||
}
|
||||
const data = rawData.map((item: Record<string, unknown>) => ({
|
||||
@@ -195,21 +236,25 @@ const Dashboard = () => {
|
||||
close: Number(item.close),
|
||||
volume: Number(item.volume),
|
||||
}))
|
||||
// 동일 데이터면 setState 건너뛰기 (차트 재생성 방지)
|
||||
// 동일 종목·동일 데이터면 setState 건너뛰기 (차트 재생성 방지)
|
||||
const prev = chartDataRef.current
|
||||
const isSame = prev && prev.length === data.length &&
|
||||
const isSame = prev && chartStockRef.current === selectedStockRef.current &&
|
||||
prev.length === data.length &&
|
||||
prev.length > 0 &&
|
||||
prev[0].date === data[0].date &&
|
||||
prev[prev.length - 1].date === data[data.length - 1].date &&
|
||||
prev[prev.length - 1].close === data[data.length - 1].close
|
||||
prev[prev.length - 1].close === data[data.length - 1].close &&
|
||||
prev[prev.length - 1].high === data[data.length - 1].high &&
|
||||
prev[prev.length - 1].low === data[data.length - 1].low &&
|
||||
prev[prev.length - 1].volume === data[data.length - 1].volume
|
||||
if (!isSame) {
|
||||
chartDataRef.current = data
|
||||
setChartData(data)
|
||||
chartStockRef.current = selectedStockRef.current
|
||||
setChartStock(selectedStockRef.current)
|
||||
}
|
||||
if (chartLoadingRef.current) {
|
||||
chartLoadingRef.current = false
|
||||
setChartLoading(false)
|
||||
}
|
||||
setChartEmptyReason(null)
|
||||
clearLoading()
|
||||
}, [])
|
||||
|
||||
// WebSocket hook - market_update는 콜백, chart_data는 messages로 처리
|
||||
@@ -370,49 +415,118 @@ const Dashboard = () => {
|
||||
})
|
||||
|
||||
// 차트 요청 헬퍼 — ref 기반으로 의존성 최소화 (리렌더 방지)
|
||||
const requestChartRef = useRef((stock: string, period: string): boolean => {
|
||||
console.log('📤 requestChart', { stock, period, wsConnected: wsConnectedRef.current })
|
||||
if (!stock || !wsConnectedRef.current) return false
|
||||
sendMessageRef.current(
|
||||
JSON.stringify({
|
||||
type: 'get_chart',
|
||||
stock_code: stock,
|
||||
period: period,
|
||||
limit: 200,
|
||||
})
|
||||
)
|
||||
console.log('📤 requestChart SENT')
|
||||
return true
|
||||
})
|
||||
// 동일 (종목, 기간) 요청은 3초 내 재전송하지 않는다 — WS 재연결 flapping 시 차트 요청 폭주 방지
|
||||
// 억제됐는데 직전 요청의 응답을 아직 못 받았다면(재연결로 유실 가능) 억제 창이 끝난 뒤 1회 재시도한다
|
||||
// → 억제 + 재연결이 겹쳐 로딩 스피너가 고착되던 문제(L-1) 방지
|
||||
const lastChartReqRef = useRef<{ key: string; at: number }>({ key: '', at: 0 })
|
||||
const chartRetryTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const requestChartRef = useRef(
|
||||
(stock: string, period: string, silent = false): 'sent' | 'suppressed' | 'offline' => {
|
||||
if (!stock || !wsConnectedRef.current) return 'offline'
|
||||
const key = `${stock}|${period}`
|
||||
const now = Date.now()
|
||||
const elapsed = now - lastChartReqRef.current.at
|
||||
if (lastChartReqRef.current.key === key && elapsed < CHART_REQ_DEDUPE_MS) {
|
||||
const awaiting = chartAwaitingRef.current
|
||||
if (awaiting && awaiting.key === key && !chartRetryTimerRef.current) {
|
||||
chartRetryTimerRef.current = setTimeout(() => {
|
||||
chartRetryTimerRef.current = null
|
||||
const a = chartAwaitingRef.current
|
||||
if (!a || a.key !== key) return // 그 사이 응답 도착
|
||||
if (`${selectedStockRef.current}|${chartPeriodRef.current}` !== key) return
|
||||
requestChartRef.current(stock, period, a.silent)
|
||||
}, CHART_REQ_DEDUPE_MS - elapsed + 50)
|
||||
}
|
||||
return 'suppressed'
|
||||
}
|
||||
lastChartReqRef.current = { key, at: now }
|
||||
chartAwaitingRef.current = { key, at: now, silent }
|
||||
sendMessageRef.current(
|
||||
JSON.stringify({
|
||||
type: 'get_chart',
|
||||
stock_code: stock,
|
||||
period: period,
|
||||
limit: 200,
|
||||
})
|
||||
)
|
||||
return 'sent'
|
||||
}
|
||||
)
|
||||
// ref 내용은 항상 최신 — useCallback 불필요 (의존성 변경에 의한 리렌더 차단)
|
||||
|
||||
/** 차트 요청 + 로딩 표시. 억제됐어도 응답 대기 중이면 로딩 유지(재시도 예약됨), 아니면 로딩 해제 */
|
||||
const startChartRequest = useCallback((stock: string, period: 'D' | 'W' | 'M', showLoading: boolean) => {
|
||||
const r = requestChartRef.current(stock, period)
|
||||
const key = `${stock}|${period}`
|
||||
const pending = r === 'sent' || (r === 'suppressed' && chartAwaitingRef.current?.key === key)
|
||||
const want = showLoading && pending
|
||||
if (chartLoadingRef.current !== want) {
|
||||
chartLoadingRef.current = want
|
||||
setChartLoading(want)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 언마운트 시 재시도 타이머 정리
|
||||
useEffect(() => () => {
|
||||
if (chartRetryTimerRef.current) clearTimeout(chartRetryTimerRef.current)
|
||||
}, [])
|
||||
|
||||
// 차트 데이터 — selectedStock / chartPeriod 변경 시 요청
|
||||
useEffect(() => {
|
||||
setChartEmptyReason(null)
|
||||
if (!selectedStock) {
|
||||
chartDataRef.current = null
|
||||
setChartData(null)
|
||||
chartStockRef.current = ''
|
||||
setChartStock('')
|
||||
chartLoadingRef.current = false
|
||||
setChartLoading(false)
|
||||
return
|
||||
}
|
||||
const sent = requestChartRef.current(selectedStock, chartPeriod)
|
||||
if (sent) {
|
||||
chartLoadingRef.current = true
|
||||
setChartLoading(true)
|
||||
}
|
||||
}, [selectedStock, chartPeriod])
|
||||
// 오프라인이면 (재)연결 effect 가 요청한다 — 그때까지는 로딩 표시 안 함
|
||||
startChartRequest(selectedStock, chartPeriod, true)
|
||||
}, [selectedStock, chartPeriod, startChartRequest])
|
||||
|
||||
// WebSocket (재)연결 시 — 차트 데이터 요청
|
||||
useEffect(() => {
|
||||
if (!selectedStock || !wsConnected) return
|
||||
// 이미 데이터가 있으면 로딩 표시 안 함 (조용히 갱신)
|
||||
if (!chartDataRef.current) {
|
||||
chartLoadingRef.current = true
|
||||
setChartLoading(true)
|
||||
}
|
||||
requestChartRef.current(selectedStock, chartPeriod)
|
||||
// 이미 이 종목 데이터가 있으면 로딩 표시 안 함 (조용히 갱신)
|
||||
const hasCurrent = !!chartDataRef.current && chartStockRef.current === selectedStock
|
||||
startChartRequest(selectedStock, chartPeriod, !hasCurrent)
|
||||
}, [wsConnected]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// 로딩 워치독 — 응답이 끝내 오지 않으면 스피너를 내리고 사유를 표시 (무한 스피너 방지)
|
||||
useEffect(() => {
|
||||
if (!chartLoading) return
|
||||
const id = setTimeout(() => {
|
||||
if (!chartLoadingRef.current) return
|
||||
chartLoadingRef.current = false
|
||||
setChartLoading(false)
|
||||
chartAwaitingRef.current = null
|
||||
if (!chartDataRef.current) setChartEmptyReason('응답 시간 초과 — 잠시 후 다시 시도')
|
||||
}, CHART_LOADING_TIMEOUT_MS)
|
||||
return () => clearTimeout(id)
|
||||
}, [chartLoading])
|
||||
|
||||
// 장중 차트 주기 갱신 (H-2) — 국내 종목 + 국내 정규장 + 보이는 탭에서만 60초마다 조용히 재요청
|
||||
// (실시간 현재가는 마지막 캔들만 움직이므로, 지표·거래량·전일 대비 정합을 위해 원본을 주기적으로 다시 받는다)
|
||||
useEffect(() => {
|
||||
if (!selectedStock || isUsStock(selectedStock)) return
|
||||
const refresh = () => {
|
||||
if (document.visibilityState !== 'visible') return
|
||||
if (!isKrMarketOpen()) return
|
||||
requestChartRef.current(selectedStockRef.current, chartPeriodRef.current, true)
|
||||
}
|
||||
const id = setInterval(refresh, CHART_REFRESH_INTERVAL_MS)
|
||||
// 탭이 다시 보이면 즉시 1회 갱신 (숨김 동안은 interval 이 요청하지 않음)
|
||||
const onVisible = () => { if (document.visibilityState === 'visible') refresh() }
|
||||
document.addEventListener('visibilitychange', onVisible)
|
||||
return () => {
|
||||
clearInterval(id)
|
||||
document.removeEventListener('visibilitychange', onVisible)
|
||||
}
|
||||
}, [selectedStock])
|
||||
|
||||
// 거래 이력 - 30초 (자동매매 체결 감지용)
|
||||
const { data: tradeHistory } = useQuery({
|
||||
queryKey: ['trade-history', tenantMode],
|
||||
@@ -424,7 +538,10 @@ const Dashboard = () => {
|
||||
).data as TradeHistory[]
|
||||
|
||||
const seen = new Set<number>()
|
||||
return data
|
||||
// 서버 정렬에 의존하지 않고 최신순 정렬 후 20건 (id 기반 신규 체결 감지도 최신 기준 유지)
|
||||
const ts = (t: TradeHistory) => parseKst(t.created_at || t.executed_at || '')?.getTime() ?? 0
|
||||
return [...data]
|
||||
.sort((x: TradeHistory, y: TradeHistory) => ts(y) - ts(x) || Number(y.id) - Number(x.id))
|
||||
.filter((trade: TradeHistory) => {
|
||||
if (seen.has(trade.id)) return false
|
||||
seen.add(trade.id)
|
||||
@@ -526,6 +643,26 @@ const Dashboard = () => {
|
||||
const liveSummary = useMemo(() => {
|
||||
if (!portfolioSummary || !portfolio) return portfolioSummary
|
||||
|
||||
// 국내 장외 시간 또는 해외 보유(통화 혼재)가 있으면 클라이언트 재계산을 하지 않고 서버 값을 그대로 쓴다.
|
||||
// (가격 소스 불일치 방지 - 잔고 동기화 값이 기준. 누락된 파생 필드만 같은 소스(portfolio.current_price)로 채움)
|
||||
if (!isKrMarketOpen() || portfolio.some((s) => isUsStock(s.stock_code))) {
|
||||
const sv = portfolioSummary as Record<string, any>
|
||||
const valueFallback = portfolio.reduce(
|
||||
(sum, s) => sum + Number(s.current_price || 0) * Number(s.quantity || 0), 0)
|
||||
const costFallback = portfolio.reduce(
|
||||
(sum, s) => sum + Number(s.average_price || 0) * Number(s.quantity || 0), 0)
|
||||
const tv = sv.total_value ?? valueFallback
|
||||
const ti = sv.total_investment ?? costFallback
|
||||
const tpl = sv.total_profit_loss ?? (tv - ti)
|
||||
return {
|
||||
...portfolioSummary,
|
||||
total_value: tv,
|
||||
total_investment: ti,
|
||||
total_profit_loss: tpl,
|
||||
total_profit_loss_rate: sv.total_profit_loss_rate ?? (ti > 0 ? (tpl / ti) * 100 : 0),
|
||||
}
|
||||
}
|
||||
|
||||
// live 현재가 기반 주식 평가금액
|
||||
// 가격 결손 종목(WS tick도 없고 DB current_price도 0/NaN)을 별도 수집 —
|
||||
// 이런 종목이 있으면 liveValue 자체가 축소 계산되어 있어 보정에 쓸 수 없다.
|
||||
@@ -711,6 +848,9 @@ const Dashboard = () => {
|
||||
}, [tradeHistory])
|
||||
|
||||
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(() => {
|
||||
@@ -855,6 +995,9 @@ const Dashboard = () => {
|
||||
},
|
||||
enabled: !!selectedStock,
|
||||
staleTime: 30000,
|
||||
// 장애 시 재시도/재연결 재조회로 요청이 반복되지 않게 (queryFn 이 오류를 결과로 흡수함)
|
||||
retry: false,
|
||||
refetchOnReconnect: false,
|
||||
});
|
||||
|
||||
// useCallback으로 안정화 → 패널에 전달 시 매 렌더마다 새 함수 생성 방지
|
||||
@@ -929,6 +1072,7 @@ const Dashboard = () => {
|
||||
sellDisabled={!tradeActionsReady}
|
||||
accountSetupRequired={accountSetupRequired || accountStatus.required}
|
||||
accountSetupMessage={accountSetupMessage || accountStatus.message}
|
||||
exchangeRate={portfolioSummary?.exchange_rate_unavailable === true ? 0 : (Number(portfolioSummary?.exchange_rate) || 0)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -942,7 +1086,9 @@ const Dashboard = () => {
|
||||
chartPeriod={chartPeriod}
|
||||
onPeriodChange={setChartPeriod}
|
||||
stockName={stockName}
|
||||
livePrice={livePrice}
|
||||
livePrice={chartLivePrice}
|
||||
liveVolume={chartLiveVolume}
|
||||
emptyReason={chartEmptyReason}
|
||||
/>
|
||||
|
||||
{/* 하단: AI 투자 분석 & 호가창
|
||||
|
||||
@@ -6,7 +6,9 @@ import { useEffect, useState } from 'react'
|
||||
import HtsPage from '../components/HtsPage'
|
||||
|
||||
const MCPStatus = () => {
|
||||
const { messages } = useWebSocket('mcp-monitor')
|
||||
// 탭별 고유 client_id: 서버가 client_id 를 인증 사용자에 바인딩하므로 고정 id 는 두 번째 사용자를 거부시킨다
|
||||
const [wsClientId] = useState(() => `mcp-monitor_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`)
|
||||
const { messages } = useWebSocket(wsClientId)
|
||||
const [mcpMessages, setMcpMessages] = useState<any[]>([])
|
||||
|
||||
// MCP 서버 상태 조회
|
||||
|
||||
@@ -56,6 +56,19 @@ const fmtPrice = (v: number | null | string) => {
|
||||
return n == null ? '-' : fmtKRW(n)
|
||||
}
|
||||
|
||||
/** 체결가 우선 표시. 체결가가 없으면 '체결 확인 중' + 회색 주문가 */
|
||||
const PriceCell = ({ executed, order }: { executed: number | null; order: number | null }) => {
|
||||
if (executed != null && Number(executed) > 0) return <>{fmtPrice(executed)}</>
|
||||
return (
|
||||
<span className="inline-flex flex-col items-end leading-tight">
|
||||
<span className="text-yellow-400/80 text-xs">체결 확인 중</span>
|
||||
{order != null && Number(order) > 0 && (
|
||||
<span className="text-slate-500 text-[10px]" title="체결 전 주문가">주문가 {fmtPrice(order)}</span>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
// ── 종목별 뷰: 매수→매도 페어 매칭 ──────────────────────────────
|
||||
interface TradePair {
|
||||
@@ -151,7 +164,7 @@ function PairView({ pairs }: { pairs: TradePair[] }) {
|
||||
{format(toKST(p.buy.created_at), 'MM/dd HH:mm', { locale: ko })}
|
||||
</td>
|
||||
<td className="py-2.5 px-3 text-right text-white tabular-nums">
|
||||
{fmtPrice(p.buy.executed_price ?? p.buy.order_price)}
|
||||
<PriceCell executed={p.buy.executed_price} order={p.buy.order_price} />
|
||||
</td>
|
||||
<td className="py-2.5 px-3 text-xs whitespace-nowrap">
|
||||
{isOpen
|
||||
@@ -160,7 +173,7 @@ function PairView({ pairs }: { pairs: TradePair[] }) {
|
||||
}
|
||||
</td>
|
||||
<td className="py-2.5 px-3 text-right tabular-nums text-slate-300">
|
||||
{isOpen ? '-' : fmtPrice(p.sell!.executed_price ?? p.sell!.order_price)}
|
||||
{isOpen ? '-' : <PriceCell executed={p.sell!.executed_price} order={p.sell!.order_price} />}
|
||||
</td>
|
||||
<td className="py-2.5 px-3 text-right text-slate-300 tabular-nums">
|
||||
{(p.buy.executed_quantity ?? p.buy.order_quantity)?.toLocaleString()}주
|
||||
|
||||
@@ -70,6 +70,16 @@ export interface Position {
|
||||
exchange_code?: string;
|
||||
currency_code?: string;
|
||||
strategy_type?: string;
|
||||
/** 스윙 전략 기준 평단·수량·손익 (합산 행에서 DT 분 분리, 백엔드 확정 필드) */
|
||||
swing_avg_price?: number;
|
||||
swing_quantity?: number;
|
||||
/** active_trade | conservative_max | portfolio_avg | derived_from_mixed | none */
|
||||
swing_avg_price_basis?: string;
|
||||
swing_profit_loss?: number;
|
||||
swing_profit_loss_rate?: number;
|
||||
dt_quantity?: number;
|
||||
dt_avg_price?: number;
|
||||
// avg_price_mixed(boolean): 합산 평단에 스윙·DT 가 섞여 있어 분리했음 — 인덱스 시그니처로 접근(`!!stock.avg_price_mixed`)
|
||||
[key: string]: string | number | undefined;
|
||||
}
|
||||
|
||||
@@ -96,7 +106,19 @@ export interface PortfolioSummary {
|
||||
overseas_value_usd?: number;
|
||||
overseas_value_krw?: number;
|
||||
exchange_rate?: number;
|
||||
[key: string]: string | number | undefined;
|
||||
/** 총자산 기준 현금 = 총자산 − 주식평가 (화면에서 총자산 = 주식 + 예수금 이 맞도록) */
|
||||
cash_for_total_assets?: number;
|
||||
/** 주문가능현금 */
|
||||
orderable_cash?: number;
|
||||
/** 결제 기준 예수금 */
|
||||
settlement_cash?: number;
|
||||
/** 현금 기준 간 차이 (진단용) */
|
||||
cash_basis_gap?: number;
|
||||
exchange_rate_source?: string;
|
||||
/** true 면 환율 미수신 — 해외 원화 환산하지 않음 */
|
||||
exchange_rate_unavailable?: boolean;
|
||||
fx_conversion_skipped?: boolean;
|
||||
[key: string]: string | number | boolean | undefined;
|
||||
}
|
||||
|
||||
export interface PortfolioCombined {
|
||||
@@ -139,7 +161,16 @@ export interface RecommendedStock {
|
||||
rsi?: number | null;
|
||||
stochastic_k?: number | null;
|
||||
score?: number;
|
||||
[key: string]: string | number | null | string[] | undefined;
|
||||
match_source?: 'rule' | 'fallback_high_score' | string;
|
||||
match_source_label?: string;
|
||||
block_reasons?: string[];
|
||||
buy_allowed?: boolean;
|
||||
ml_blocks_buy?: boolean;
|
||||
ml_signal?: string | null;
|
||||
price_source?: 'realtime' | 'batch' | string;
|
||||
batch_current_price?: number | null;
|
||||
batch_change_rate?: number | null;
|
||||
[key: string]: string | number | boolean | null | string[] | undefined;
|
||||
}
|
||||
|
||||
export interface Recommendation {
|
||||
@@ -152,6 +183,8 @@ export interface RecommendationsData {
|
||||
analyzed_stock_list: RecommendedStock[];
|
||||
recommendations: Recommendation[];
|
||||
batch_time?: string;
|
||||
batch_date?: string;
|
||||
realtime_overlay_count?: number;
|
||||
trades_executed?: Trade[];
|
||||
recommendations_by_rule?: Array<{
|
||||
rule_name: string;
|
||||
|
||||
@@ -14,3 +14,19 @@ export const formatTime = (isoStr?: string): string | null => {
|
||||
const d = new Date(isoStr)
|
||||
return d.toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false, timeZone: 'Asia/Seoul' })
|
||||
}
|
||||
|
||||
/** 타임존 없는 ISO(서버 KST naive)는 +09:00 보정 */
|
||||
export const parseKst = (raw?: string | null): Date | null => {
|
||||
if (!raw) return null
|
||||
const hasTz = /([zZ]|[+-]\d{2}:?\d{2})$/.test(raw)
|
||||
const d = new Date(hasTz ? raw : raw + '+09:00')
|
||||
return Number.isNaN(d.getTime()) ? null : d
|
||||
}
|
||||
|
||||
/** 가격 포맷: 원화는 정수 반올림, USD 는 소수 2자리 */
|
||||
export const formatPriceByMarket = (price: number, isUs: boolean): string => {
|
||||
if (isUs) {
|
||||
return `$${price.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
|
||||
}
|
||||
return `${Math.round(price).toLocaleString('ko-KR')}원`
|
||||
}
|
||||
|
||||
36
frontend/src/utils/marketHours.ts
Normal file
36
frontend/src/utils/marketHours.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* KRX 휴장일(YYYY-MM-DD, KST). 주말은 별도 판정하므로 평일 휴장일만 둔다.
|
||||
* 정적 표이므로 연 1회 갱신 필요. 백엔드 is_krx_holiday 와 어긋나면 setKrHolidays 로 덮어쓴다.
|
||||
* 추정: 2026-07-17(제헌절)은 백엔드 holidays 라이브러리와 KRX 공지가 다를 수 있어 넣지 않았다.
|
||||
*/
|
||||
const DEFAULT_KR_HOLIDAYS: readonly string[] = [
|
||||
'2026-01-01', '2026-02-16', '2026-02-17', '2026-02-18', '2026-03-02',
|
||||
'2026-05-01', '2026-05-05', '2026-05-25', '2026-06-03', '2026-08-17',
|
||||
'2026-09-24', '2026-09-25', '2026-10-05', '2026-10-09', '2026-12-25', '2026-12-31',
|
||||
'2027-01-01',
|
||||
]
|
||||
|
||||
let krHolidays: Set<string> = new Set(DEFAULT_KR_HOLIDAYS)
|
||||
|
||||
/** 백엔드 등에서 받은 휴장일 목록으로 교체(기본 표에 추가 병합). */
|
||||
export const setKrHolidays = (dates: readonly string[]): void => {
|
||||
krHolidays = new Set([...DEFAULT_KR_HOLIDAYS, ...dates])
|
||||
}
|
||||
|
||||
const pad = (n: number): string => String(n).padStart(2, '0')
|
||||
|
||||
/** 해당 시각의 KST 날짜가 평일 휴장일(공휴일/KRX 휴장)인가. */
|
||||
export const isKrHoliday = (now: Date = new Date()): boolean => {
|
||||
const kst = new Date(now.toLocaleString('en-US', { timeZone: 'Asia/Seoul' }))
|
||||
return krHolidays.has(`${kst.getFullYear()}-${pad(kst.getMonth() + 1)}-${pad(kst.getDate())}`)
|
||||
}
|
||||
|
||||
/** 한국 정규장(09:00~15:30 KST, 평일, 휴장일 제외) 여부. */
|
||||
export const isKrMarketOpen = (now: Date = new Date()): boolean => {
|
||||
const kst = new Date(now.toLocaleString('en-US', { timeZone: 'Asia/Seoul' }))
|
||||
const day = kst.getDay()
|
||||
if (day === 0 || day === 6) return false
|
||||
if (isKrHoliday(now)) return false
|
||||
const m = kst.getHours() * 60 + kst.getMinutes()
|
||||
return m >= 540 && m <= 930
|
||||
}
|
||||
@@ -3,17 +3,19 @@
|
||||
*
|
||||
* 우선순위:
|
||||
* 1. DT 엔진 인메모리 포지션(/daytrading/positions) 수량 -> 데이트레이딩
|
||||
* 2. 행의 strategy_type: daytrading/day_trade -> 데이트레이딩(엔진 수량을 모를 때 행 전체)
|
||||
* 2. 행의 strategy_type: daytrading/day_trade 인데 엔진 수량이 없으면 -> 미관리(엔진 외) 보유 그룹(반드시 표시)
|
||||
* external/manual -> 수동/외부, us_swing -> 해외, swing/NULL -> 스윙
|
||||
* Portfolio 는 stock_code UNIQUE 라 스윙+DT 수량이 합산돼 있으므로 DT 엔진 수량을 빼서 나눈다.
|
||||
*/
|
||||
import { Position } from '../types/trading'
|
||||
|
||||
export type PortfolioGroupKey = 'daytrading' | 'swing' | 'manual' | 'overseas'
|
||||
export type PortfolioGroupKey = 'daytrading' | 'swing' | 'manual' | 'overseas' | 'unmanaged'
|
||||
|
||||
export interface GroupedHolding extends Position {
|
||||
/** 합산 행에서 DT 수량을 알 수 없어 DT 가 포함됐을 수 있음 */
|
||||
dtIncluded?: number
|
||||
/** 엔진이 관리하지 않는 DT 잔고(엔진 인메모리에 없음) — 자동 청산 대상 아님 */
|
||||
unmanaged?: number
|
||||
/** 스윙+DT 합산 행에서 분할된 카드(1) — 수동매도는 DT 몫부터 차감되므로 청산 비활성 */
|
||||
isSplit?: number
|
||||
}
|
||||
@@ -39,7 +41,7 @@ export function groupHoldings(
|
||||
holdings: Position[] | undefined,
|
||||
dtPositions: Position[] | undefined,
|
||||
): PortfolioGroups {
|
||||
const groups: PortfolioGroups = { daytrading: [], swing: [], manual: [], overseas: [] }
|
||||
const groups: PortfolioGroups = { daytrading: [], swing: [], manual: [], overseas: [], unmanaged: [] }
|
||||
const dtKnown = dtPositions !== undefined
|
||||
const dtQty = new Map<string, number>()
|
||||
for (const d of dtPositions ?? []) {
|
||||
@@ -64,8 +66,12 @@ export function groupHoldings(
|
||||
if (rest > 0) groups[kind === 'daytrading' ? 'swing' : kind].push({ ...row, quantity: rest, isSplit: 1 })
|
||||
continue
|
||||
}
|
||||
if (kind === 'daytrading') {
|
||||
groups.daytrading.push(row)
|
||||
if (kind === 'daytrading' && !dtKnown) {
|
||||
// 엔진 조회 로딩/실패 — 관리 여부 불명. '미관리' 로 단정하지 않고 DT 포함 배지(청산 잠금)로 표시
|
||||
groups.swing.push({ ...row, dtIncluded: 1 })
|
||||
} else if (kind === 'daytrading') {
|
||||
// 엔진 조회가 확정됐는데 인메모리에 없음 -> 엔진이 청산하지 않는 고아 잔고. 숨기지 말고 경고 그룹으로.
|
||||
groups.unmanaged.push({ ...row, unmanaged: 1 })
|
||||
} else if (kind === 'swing' && !dtKnown) {
|
||||
// 엔진 수량을 알 수 없는 스윙 행 — DT 가 합산됐을 수 있음
|
||||
groups.swing.push({ ...row, dtIncluded: 1 })
|
||||
|
||||
Reference in New Issue
Block a user