- korea_investment_api: KIS_QUOTES_VIA_REAL — 모의투자 모드에서 시세/차트/호가 조회를 실계좌 시세 서버(20/s)로 위임, 주문·잔고는 모의서버 유지 (EGW00201 대응) - recommendations: execute_auto_trade_background asyncio.Lock 중복 실행 방지 + MAX_DAILY_TRADES를 당일 BUY 건수 차감 전역 기준으로 적용 (11종목 동시 매수 재발 방지) - manual_buy.py 신규: POST /api/v1/portfolio/buy (리스크 가드 + safe_place_order + db_worker) - mcp-server: claude_code_client(claude -p 헤드리스 모드) 추가, trade_monitor 부분매도 시 active 유지·remaining_pct 갱신, API 키 헤더·/api/v1 경로 수정, ODNO 주문ID 수정 - config: MCP_REQUEST_TIMEOUT, MCP_RISK_CACHE_TTL, BATCH_ANALYSIS_INTERVAL, SWING_INDEX_BREAKER_ENABLED(OFF), API_TRIGGERED_AUTO_TRADE(OFF) 추가 - docker-compose: 포트 충돌 해소 (postgres 15433, frontend 3001) - frontend: 추천 패널 매수 버튼, 차트/호가/저널 패널 개선 - CLAUDE.md 압축 최적화 (규칙 변경 없음), _sweep_single_axis.py 백테스트 단일축 스윕 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
261 lines
12 KiB
TypeScript
261 lines
12 KiB
TypeScript
/**
|
||
* 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>
|
||
}
|
||
|
||
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<HTMLButtonElement>) => {
|
||
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 (
|
||
<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}
|
||
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>
|
||
}
|
||
|
||
const RecommendationPanel = React.memo(({
|
||
recommendations,
|
||
isError,
|
||
onRefetch,
|
||
selectedStock,
|
||
onSelectStock,
|
||
onRefetchAiAnalysis,
|
||
heldStockCodes,
|
||
pendingBuyStockCodes,
|
||
livePrices,
|
||
onBuy,
|
||
}: 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}
|
||
/>
|
||
)
|
||
})
|
||
) : (
|
||
<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
|