Files
auto-stock/frontend/src/components/RecommendationPanel.tsx
shuim 028a1c352b feat: 자동매매 신뢰성 개선 — 시세 실서버 라우팅·일일 한도 전역화·수동 매수 API
- 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>
2026-09-19 23:37:08 +09:00

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