/** * RecommendationPanel.tsx — 분석 종목 스캐너 패널 컴포넌트 * * 역할: * - 배치 분석(batch_daily_analysis) 결과 종목 목록 표시 * - 각 종목의 등락률, RSI, 스토캐스틱, 점수, 신호 표시 * - 보유/매수중/신호 배지로 종목 상태 시각화 * - 종목 클릭 시 차트/AI분석 패널 전환 * * 데이터 흐름: * - 추천 데이터: react-query 'rule-based-recommendations' (2분 폴링) * - 보유 종목 Set: Dashboard에서 useMemo로 안정화 후 전달 * - 실시간 가격: livePrices로 현재가 갱신 * * 최적화: * - RecommendationItem, RecommendationPanel 모두 React.memo 적용 */ import React, { useState } from 'react' import { RefreshCw } from 'lucide-react' import { RecommendationsData, RecommendedStock, Recommendation } from '../types/trading' import { formatTime } from '../utils/formatUtils' /** RecommendationItem Props 정의 */ interface RecommendationItemProps { stock: RecommendedStock idx: number selectedStock: string onSelectStock: (code: string) => void onRefetchAiAnalysis: () => void isRecommended: boolean isHeld: boolean isPending: boolean livePrices: Record | undefined onBuy: (stockCode: string, stockName: string) => Promise } const RecommendationItem = React.memo(({ stock, idx, selectedStock, onSelectStock, onRefetchAiAnalysis, isRecommended, isHeld, isPending, livePrices, onBuy, }: RecommendationItemProps) => { const changeRate = stock.change_rate || 0 const isSelected = selectedStock === stock.stock_code const isUp = changeRate >= 0 const [buying, setBuying] = useState(false) const handleBuyClick = async (e: React.MouseEvent) => { e.stopPropagation() if (buying) return if (!window.confirm(`${stock.stock_name} 시장가 매수할까요?\n(수량: 총자산 × 종목당 최대비중으로 자동 산출)`)) return setBuying(true) try { await onBuy(stock.stock_code, stock.stock_name) } catch (error: unknown) { const err = error as any alert(`매수 실패: ${err.response?.data?.detail || err.message}`) } finally { setBuying(false) } } return (
{ if (isSelected) onRefetchAiAnalysis() else onSelectStock(stock.stock_code) }} className="px-3 pt-1.5 pb-1 cursor-pointer" > {/* Row 1: rank + name + badge + change% */}
{idx + 1} {stock.stock_name} {isPending ? ( 매수중 ) : isHeld ? ( 보유 ) : isRecommended ? ( 신호 ) : null} {changeRate > 0 ? '+' : ''}{changeRate.toFixed(2)}%
{/* Row 2: code + price + RSI + score */}
{stock.stock_code} {Math.round(livePrices?.[stock.stock_code] ?? stock.current_price ?? 0).toLocaleString()}원 {stock.rsi != null && ( 70 ? 'text-[#e84444]' : 'text-[#4a6580]'}`}> RSI {stock.rsi.toFixed(0)} )} {stock.stochastic_k != null && ( K {stock.stochastic_k.toFixed(0)} )} {(stock.score ?? 0) > 0 && ( {stock.score}pt )}
{/* Row 3: signals */} {stock.signals && stock.signals.length > 0 && (
{stock.signals.slice(0, 2).join(' · ')}
)}
{/* Buy button — 보유/매수중 종목은 숨김 (PortfolioPanel 청산 버튼과 동일 스타일) */} {!isHeld && !isPending && (
)}
) }) RecommendationItem.displayName = 'RecommendationItem' /** RecommendationPanel Props 정의 */ interface RecommendationPanelProps { /** 배치 분석 추천 데이터 */ recommendations: RecommendationsData | undefined /** API 에러 여부 */ isError: boolean /** 추천 데이터 새로고침 콜백 */ onRefetch: () => void /** 현재 선택된 종목 코드 */ selectedStock: string /** 종목 선택 콜백 */ onSelectStock: (code: string) => void /** AI 분석 새로고침 콜백 */ onRefetchAiAnalysis: () => void /** 보유 중인 종목 코드 Set (보유 배지 표시용) */ heldStockCodes: Set /** 매수 진행 중인 종목 코드 Set (매수중 배지 표시용) */ pendingBuyStockCodes: Set /** 실시간 가격 맵 */ livePrices: Record | undefined /** 수동 매수 콜백 */ onBuy: (stockCode: string, stockName: string) => Promise } const RecommendationPanel = React.memo(({ recommendations, isError, onRefetch, selectedStock, onSelectStock, onRefetchAiAnalysis, heldStockCodes, pendingBuyStockCodes, livePrices, onBuy, }: RecommendationPanelProps) => { return (
{/* Header */}

분석 종목 스캐너

{recommendations?.batch_time && ( {formatTime(recommendations.batch_time)} )} {recommendations?.analyzed_stocks || 0}
{/* List */}
{isError ? (

⚠ API 오류

백엔드 로그 확인 필요

) : recommendations === undefined ? (

로딩 중...

최초 로딩 시 10초 정도 소요됩니다

) : (recommendations?.analyzed_stock_list || []).length > 0 ? ( (recommendations.analyzed_stock_list).map((stock: RecommendedStock, idx: number) => { 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) return ( ) }) ) : (

배치 분석 데이터 없음

make batch-test 실행 필요

)}
) }) RecommendationPanel.displayName = 'RecommendationPanel' export default RecommendationPanel