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:
2026-10-10 12:17:49 +09:00
parent 5c4803b339
commit a264561576
230 changed files with 41160 additions and 2334 deletions

View File

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

View 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

View File

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

View File

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

View File

@@ -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>
{/* 금일 손익 */}

View File

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

View File

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

View File

@@ -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 메시지 콜백 */

View File

@@ -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(() => {

View File

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

View File

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

View File

@@ -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 투자 분석 & 호가창

View File

@@ -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 서버 상태 조회

View File

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

View File

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

View File

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

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

View File

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