Files
auto-stock/frontend/src/components/RecommendationPanel.tsx
shuim 496529782b fix: 수동 매도 즉시 반영 + 매도/매수 결과 토스트 + 당일 매도대금 가용현금 반영
- 프론트: 토스트 알림 시스템(lib/toast + ToastHost) 추가, 매도/매수 성공·실패를 화면에 표시
  (기존엔 console.log만, 실패는 블로킹 alert). 성공 토스트에 손익 표시.
- 매도 직후 예수금/평가액 요약 즉시 갱신(apply_sell), 최근 5분 내 수동 매도 종목은
  KIS 잔고 반영 지연으로 동기화가 되살리지 않도록 스킵(recent_sells).
- 예수금이 D+2 결제 기준(dnca_tot_amt)이라 당일 매도대금이 화면·데이트레이딩 가용현금에
  반영되지 않던 문제: 매수가능조회(inquire-psbl-order) ord_psbl_cash 를 우선 사용.

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

270 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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<string, number> | undefined
onBuy: (stockCode: string, stockName: string) => Promise<void>
/** true면 매수 버튼 비활성화 (예: 사용자 모드 확정 전) */
buyDisabled?: boolean
}
const RecommendationItem = React.memo(({
stock,
idx,
selectedStock,
onSelectStock,
onRefetchAiAnalysis,
isRecommended,
isHeld,
isPending,
livePrices,
onBuy,
buyDisabled,
}: 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<HTMLButtonElement>) => {
e.stopPropagation()
if (buying || buyDisabled) 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
// 계좌 미등록/미검증(400/423)은 Dashboard 상단 인라인 안내로 처리 — alert 생략
if (err?.isAccountSetupRequired) return
// 실패 알림은 useTradeActions의 toast.error로 표시됨 (alert는 화면 블로킹)
} finally {
setBuying(false)
}
}
return (
<div
className={`w-full text-left transition-colors border-l-2 ${
isSelected
? 'bg-[#0d1e33] border-[#2b65d9]'
: 'border-transparent hover:bg-[#0a1628]'
}`}
>
<div
onClick={() => {
if (isSelected) onRefetchAiAnalysis()
else onSelectStock(stock.stock_code)
}}
className="px-3 pt-1.5 pb-1 cursor-pointer"
>
{/* Row 1: rank + name + badge + change% */}
<div className="flex items-center gap-1.5 mb-0.5">
<span className="text-[8px] font-mono text-[#2a3d55] w-4 flex-shrink-0 text-right">{idx + 1}</span>
<span className={`text-[11px] font-semibold truncate flex-1 ${isSelected ? 'text-[#5b9aff]' : 'text-[#c5d5e8]'}`}>
{stock.stock_name}
</span>
{isPending ? (
<span className="text-[8px] text-[#f97316] bg-[#f97316]/10 px-1 py-px rounded-sm flex items-center gap-0.5 flex-shrink-0">
<RefreshCw className="w-2 h-2 animate-spin" />매수중
</span>
) : isHeld ? (
<span className="text-[8px] text-[#5b9aff] bg-[#5b9aff]/10 px-1 py-px rounded-sm flex-shrink-0">보유</span>
) : isRecommended ? (
<span className="text-[8px] text-[#22c55e] bg-[#22c55e]/10 px-1 py-px rounded-sm flex-shrink-0">신호</span>
) : null}
<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">
<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()}원
</span>
{stock.rsi != null && (
<span className={`text-[8px] font-mono ${stock.rsi < 30 ? 'text-[#22c55e]' : stock.rsi > 70 ? 'text-[#e84444]' : 'text-[#4a6580]'}`}>
RSI {stock.rsi.toFixed(0)}
</span>
)}
{stock.stochastic_k != null && (
<span className="text-[8px] font-mono text-[#4a6580]">K {stock.stochastic_k.toFixed(0)}</span>
)}
{(stock.score ?? 0) > 0 && (
<span className="text-[8px] font-mono text-[#9f7aea]">{stock.score}pt</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(' · ')}
</span>
</div>
)}
</div>
{/* Buy button — 보유/매수중 종목은 숨김 (PortfolioPanel 청산 버튼과 동일 스타일) */}
{!isHeld && !isPending && (
<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"
>
{buying ? '매수 주문 중...' : '매수 (시장가)'}
</button>
</div>
)}
</div>
)
})
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<string>
/** 매수 진행 중인 종목 코드 Set (매수중 배지 표시용) */
pendingBuyStockCodes: Set<string>
/** 실시간 가격 맵 */
livePrices: Record<string, number> | undefined
/** 수동 매수 콜백 */
onBuy: (stockCode: string, stockName: string) => Promise<void>
/** true면 매수 버튼 비활성화 (예: 사용자 모드 확정 전) */
buyDisabled?: boolean
}
const RecommendationPanel = React.memo(({
recommendations,
isError,
onRefetch,
selectedStock,
onSelectStock,
onRefetchAiAnalysis,
heldStockCodes,
pendingBuyStockCodes,
livePrices,
onBuy,
buyDisabled,
}: RecommendationPanelProps) => {
return (
<div className="bg-[#0c1522] border border-[#152035] rounded overflow-hidden flex flex-col h-1/2">
{/* Header */}
<div className="px-3 py-2 border-b border-[#152035] bg-[#091018] flex justify-between items-center flex-shrink-0">
<h3 className="text-[#9f7aea] text-xs font-semibold uppercase tracking-wider">
분석 종목 스캐너
</h3>
<div className="flex items-center gap-2">
{recommendations?.batch_time && (
<span className="text-[9px] text-[#3a5570] font-mono">
{formatTime(recommendations.batch_time)}
</span>
)}
<span className="text-[8px] font-mono text-[#9f7aea] bg-[#9f7aea]/10 px-1.5 py-px rounded-sm">
{recommendations?.analyzed_stocks || 0}
</span>
<button
onClick={onRefetch}
className="p-1 text-[#3a5570] hover:text-[#9f7aea] transition-colors"
title="새로고침"
>
<RefreshCw className="w-3 h-3" />
</button>
</div>
</div>
{/* List */}
<div className="divide-y divide-[#0f1e2e] overflow-y-auto custom-scrollbar flex-1">
{isError ? (
<div className="h-full flex flex-col items-center justify-center text-xs p-4 text-center">
<p className="text-[#e84444]">⚠ API 오류</p>
<p className="text-[8px] text-[#3a5570] mt-1">백엔드 로그 확인 필요</p>
<button onClick={onRefetch} className="mt-2 px-2 py-0.5 text-[#e84444] border border-[#e84444]/30 rounded-sm text-[9px] font-mono">재시도</button>
</div>
) : recommendations === undefined ? (
<div className="h-full flex flex-col items-center justify-center text-[#3a5570] text-xs p-4 text-center">
<RefreshCw className="w-5 h-5 animate-spin mb-2 text-[#9f7aea]" />
<p>로딩 중...</p>
<p className="text-[9px] text-[#2a3d55] mt-1">최초 로딩 시 10초 정도 소요됩니다</p>
</div>
) : (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 (
<RecommendationItem
key={`${stock.stock_code}-${idx}`}
stock={stock}
idx={idx}
selectedStock={selectedStock}
onSelectStock={onSelectStock}
onRefetchAiAnalysis={onRefetchAiAnalysis}
isRecommended={isRecommended}
isHeld={isHeld}
isPending={isPending}
livePrices={livePrices}
onBuy={onBuy}
buyDisabled={buyDisabled}
/>
)
})
) : (
<div className="h-full flex flex-col items-center justify-center text-[#3a5570] p-4 text-center">
<p className="text-xs">배치 분석 데이터 없음</p>
<p className="text-[9px] text-[#2a3d55] mt-1">make batch-test 실행 필요</p>
</div>
)}
</div>
</div>
)
})
RecommendationPanel.displayName = 'RecommendationPanel'
export default RecommendationPanel