Files
auto-stock/frontend/src/components/PortfolioPanel.tsx
shuim b2a1b884d4 feat: 데이트레이딩 조회 API를 실제 엔진 상태에 연결 + 대시보드 엔진 상태 바
/daytrading/{status,watchlist,positions}(v1)는 main.py가 실행하는 엔진
(services/daytrading)과 공유되지 않는 별개 인메모리 dict를 읽어 항상 0을 반환했음.
조회는 entry_service 감시목록·day_trading_state 기준으로 응답하고, POST /watchlist는
엔진에도 반영. 프론트 보유종목 패널 하단에 감시/ORB/포지션/청산/당일손익·현재 단계
(ORB 수집중·신호 대기·쿨다운 등)를 30초 폴링으로 표시.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 11:23:13 +09:00

270 lines
12 KiB
TypeScript

/**
* PortfolioPanel.tsx — 보유 종목 패널 컴포넌트
*
* 역할:
* - 보유 종목 목록을 데이트레이딩/스윙 그룹별로 구분 표시
* - 각 종목의 수익률, 현재가, 평균단가, 전량 청산 버튼 제공
* - 실시간 가격(livePrices)으로 수익률 실시간 갱신
*
* 최적화:
* - PortfolioItem, PortfolioPanel 모두 React.memo 적용
* - 가격 변동 시에도 선택된 종목만 시각적 변화 (border-l-2 하이라이트)
*
* 한국/미국 시장 구분:
* - 종목 코드가 6자리 숫자면 KR, 그 외는 US
*/
import React from 'react'
import { Activity, TrendingUp, Clock } from 'lucide-react'
import { Position } from '../types/trading'
import { isUsStock } from '../utils/stockUtils'
import AccountSetupNotice from './AccountSetupNotice'
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()}원`
}
/** PortfolioItem Props 정의 */
interface PortfolioItemProps {
/** 보유 종목 데이터 */
stock: Position
/** 실시간 가격 맵 (종목코드 → 현재가) */
livePrices: Record<string, number> | undefined
/** 현재 선택된 종목 코드 (하이라이트용) */
selectedStock: string
/** 종목 선택 콜백 */
onSelectStock: (code: string) => void
/** AI 분석 새로고침 콜백 (이미 선택된 종목 클릭 시) */
onRefetchAiAnalysis: () => void
/** 전량 청산(시장가 매도) 콜백 */
onSell: (stockCode: string, stockName: string, quantity: number) => Promise<void>
/** true면 청산 버튼 비활성화 (예: 사용자 모드 확정 전) */
sellDisabled?: boolean
}
/**
* PortfolioItem — 개별 보유 종목 카드
*
* 표시 정보: 종목명, KR/US 마켓 배지, 데이트레이딩 배지, 수익률,
* 종목코드, 수량, 평균단가, 현재가, 전량 청산 버튼
*
* 동작:
* - 클릭: 해당 종목 선택 (차트/호가/AI분석 전환)
* - 이미 선택된 종목 클릭: AI 분석 새로고침
* - 전량 청산 버튼: 시장가 매도 요청
*/
const PortfolioItem = React.memo(({
stock,
livePrices,
selectedStock,
onSelectStock,
onRefetchAiAnalysis,
onSell,
sellDisabled,
}: PortfolioItemProps) => {
// 현재가: 실시간 가격 우선, 없으면 포트폴리오 API 가격 사용
const currentPrice = livePrices?.[stock.stock_code] ?? Number(stock.current_price || 0)
const avgPrice = Number(stock.average_price || 0)
const quantity = Number(stock.quantity || 0)
// 수익률: 평균단가 기반 실시간 계산 (평균단가 없으면 API 값 폴백)
const profitLossRate = avgPrice > 0
? ((currentPrice - avgPrice) / avgPrice) * 100
: Number(stock.profit_loss_rate || 0)
const isUs = isUsStock(stock.stock_code)
const isSelected = selectedStock === stock.stock_code
const isUp = profitLossRate >= 0
const handleSellClick = async (e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation()
if (sellDisabled) return
try {
await onSell(stock.stock_code, stock.stock_name, quantity)
} catch (error: unknown) {
const err = error as any
// 계좌 미등록/미검증(400/423)은 Dashboard 상단 인라인 안내로 처리 — alert 생략
if (err?.isAccountSetupRequired) return
alert(`매도 실패: ${err.response?.data?.detail || err.message}`)
}
}
return (
<div className={`border-l-2 ${isSelected ? 'border-[#2b65d9] bg-[#0d1e33]' : 'border-transparent hover:bg-[#0a1628]'} transition-colors`}>
<div
onClick={() => {
if (isSelected) onRefetchAiAnalysis()
else onSelectStock(stock.stock_code)
}}
className="px-3 pt-2 pb-1 cursor-pointer"
>
{/* Row 1: Name + P&L% */}
<div className="flex justify-between items-start mb-0.5">
<div className="flex items-center gap-1 min-w-0 flex-1 mr-1">
<span className={`text-xs font-semibold truncate ${isSelected ? 'text-[#5b9aff]' : 'text-[#c5d5e8]'}`}>
{stock.stock_name}
</span>
<span className={`text-[7px] px-1 py-px rounded-sm flex-shrink-0 font-mono ${isUs ? 'text-[#22c55e] bg-[#22c55e]/10' : 'text-[#5b9aff] bg-[#5b9aff]/10'}`}>
{isUs ? 'US' : 'KR'}
</span>
{stock.strategy_type === 'daytrading' && (
<span className="text-[7px] px-1 py-px rounded-sm text-[#f97316] bg-[#f97316]/10 flex-shrink-0">데이</span>
)}
</div>
<span className={`text-xs font-mono font-bold flex-shrink-0 ${isUp ? 'text-[#e84444]' : 'text-[#3d7eff]'}`}>
{profitLossRate > 0 ? '+' : ''}{profitLossRate.toFixed(2)}%
</span>
</div>
{/* Row 2: Code + qty */}
<div className="flex justify-between items-center">
<span className="text-[9px] font-mono text-[#3a5570]">{stock.stock_code}</span>
<span className="text-[9px] text-[#3a5570]">{quantity}{isUs ? 'shares' : '주'}</span>
</div>
{/* 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-[10px] font-mono font-semibold ${isUp ? 'text-[#e84444]' : 'text-[#3d7eff]'}`}>
{formatPrice(currentPrice, isUs)}
</span>
</div>
</div>
{/* Sell button */}
<div className="px-3 pb-2">
<button
onClick={handleSellClick}
disabled={sellDisabled}
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]"
>
전량 청산 (시장가)
</button>
</div>
</div>
)
})
PortfolioItem.displayName = 'PortfolioItem'
/** PortfolioPanel Props 정의 */
interface PortfolioPanelProps {
/** 보유 종목 배열 (마켓 필터 적용 후) */
portfolio: Position[] | undefined
/** 실시간 가격 맵 */
livePrices: Record<string, number> | undefined
/** 현재 선택된 종목 코드 */
selectedStock: string
/** 종목 선택 콜백 */
onSelectStock: (code: string) => void
/** AI 분석 새로고침 콜백 */
onRefetchAiAnalysis: () => void
/** 전량 청산 콜백 */
onSell: (stockCode: string, stockName: string, quantity: number) => Promise<void>
/** true면 청산 버튼 비활성화 (예: 사용자 모드 확정 전) */
sellDisabled?: boolean
/** true면 KIS 계좌 미등록/미검증 — 빈 목록 대신 계좌 설정 안내 표시 */
accountSetupRequired?: boolean
/** 계좌 미등록/미검증 안내 문구 (accountSetupRequired=true일 때만 사용) */
accountSetupMessage?: string
}
/**
* PortfolioPanel — 보유 종목 목록 패널
*
* 그룹 구분:
* - 데이트레이딩: strategy_type === 'daytrading' (주황색 배지)
* - 스윙: 그 외 (보라색 배지)
*/
const PortfolioPanel = React.memo(({
portfolio,
livePrices,
selectedStock,
onSelectStock,
onRefetchAiAnalysis,
onSell,
sellDisabled,
accountSetupRequired,
accountSetupMessage,
}: PortfolioPanelProps) => {
const dayStocks = portfolio?.filter(s => s.strategy_type === 'daytrading') ?? []
const swingStocks = portfolio?.filter(s => s.strategy_type !== 'daytrading') ?? []
const total = portfolio?.length ?? 0
const renderGroup = (stocks: Position[], label: string, icon: React.ReactNode, color: string) => {
if (stocks.length === 0) return null
return (
<>
<div className="flex items-center gap-1.5 px-3 py-1 bg-[#091018] border-b border-[#152035] sticky top-0 z-10">
{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>
{stocks.map((stock: Position) => (
<PortfolioItem
key={stock.stock_code}
stock={stock}
livePrices={livePrices}
selectedStock={selectedStock}
onSelectStock={onSelectStock}
onRefetchAiAnalysis={onRefetchAiAnalysis}
onSell={onSell}
sellDisabled={sellDisabled}
/>
))}
</>
)
}
return (
<div className="bg-[#0c1522] border border-[#152035] rounded overflow-hidden flex flex-col h-full">
{/* Header */}
<div className="px-3 py-2 border-b border-[#152035] bg-[#091018] flex justify-between items-center flex-shrink-0">
<h3 className="flex items-center gap-1.5 text-[#8faabf] text-xs font-semibold uppercase tracking-wider">
<Activity className="w-3 h-3 text-[#2b65d9]" />
보유 종목
</h3>
<div className="flex items-center gap-1">
{dayStocks.length > 0 && (
<span className="text-[8px] font-mono text-[#f97316] bg-[#f97316]/10 px-1.5 py-px rounded-sm">데이 {dayStocks.length}</span>
)}
{swingStocks.length > 0 && (
<span className="text-[8px] font-mono text-[#a78bfa] bg-[#a78bfa]/10 px-1.5 py-px rounded-sm">스윙 {swingStocks.length}</span>
)}
<span className="text-[8px] font-mono text-[#2b65d9] bg-[#2b65d9]/10 px-1.5 py-px rounded-sm">{total}</span>
</div>
</div>
{/* List */}
<div className="divide-y divide-[#0f1e2e] overflow-y-auto custom-scrollbar flex-1">
{accountSetupRequired ? (
<div className="h-full flex flex-col items-center justify-center p-3">
<AccountSetupNotice message={accountSetupMessage} compact />
</div>
) : total > 0 ? (
<>
{renderGroup(dayStocks, '데이트레이딩', <Clock className="w-2.5 h-2.5 text-[#f97316]" />, 'text-[#f97316]')}
{renderGroup(swingStocks, '스윙', <TrendingUp className="w-2.5 h-2.5 text-[#a78bfa]" />, 'text-[#a78bfa]')}
</>
) : (
<div className="h-full flex flex-col items-center justify-center text-[#3a5570] p-4 text-center">
<p className="text-xs">보유 중인 종목이 없습니다.</p>
</div>
)}
</div>
{/* 데이트레이딩 엔진 상태 (감시/ORB/포지션/당일손익) */}
{!accountSetupRequired && <DayTradingStatusBar />}
</div>
)
})
PortfolioPanel.displayName = 'PortfolioPanel'
export default PortfolioPanel