fix: CRITICAL/HIGH 버그 30건 수정 + 전체 코드 주석 추가 + 중복 정리
## CRITICAL 수정 (20건) - day_trading: global 선언 누락, loss_tracker 연동, ban 체크 추가 - trade_executor: BUY 체결가 order_price로 수정, remaining_pct 누적 계산 - risk_validator: get_current_price dict 파싱, MAX_POSITION_SIZE 동기화, DB 세션 누수 - risk_management/recommendations: date.today() → KST 변환 - mdd_manager: Lock 밖 executed_key 참조 수정 - correlation_filter: output → output2 키 수정 - portfolio: 해외 종목 USD/KRW 환율 적용 분리 - Sharpe Ratio 거래빈도 기반 연간화 - telegram_notifier: send_message() 공개 래퍼 추가 - position_manager: 앱 재시작 시 DB 포지션 복원 - swing exit_service: remaining_pct 조건 범위 수정 - run_backtest: original_qty 기반 remaining_pct 계산 - us_market_schedule: 2027년 미국 공휴일 추가 - Login.tsx: res.ok 검증 추가 - mean_reversion_strategy: RSI 로그 하드코딩 수정 ## HIGH 수정 (10건) - db_worker: DLQ 파일 백업 메커니즘 추가 - korea_investment_api: 만료 토큰 임의 연장 제거 - market_data: chart_cache 최대 100개 + TTL 만료 - main.py: rate_limit_store 1시간 미접근 IP 정리 - recommendations: fire-and-forget try/except 추가 - swing/scheduler: DB 세션 3개 분리 - Dashboard: wsMessages 전체 순회 - Layout: /health 엔드포인트 + 30초 폴링 - ml_ensemble: POST /train require_api_key 추가 - day_trading: record_trade_result_safe() 전환 ## 코드 정리 - 프론트엔드: isUsStock, formatTime 공통 유틸 추출 - 백엔드: dead code 제거, 중복 import 통합 - MCP: JSON 파싱 헬퍼 추출 (~60줄 감소) - 전체 ~100개 파일 한국어 주석 추가 - 테스트 13건 코드 기준 수정 (468 passed) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,22 +1,139 @@
|
||||
/**
|
||||
* RecommendationPanel.tsx — 분석 종목 스캐너 패널 컴포넌트
|
||||
*
|
||||
* 역할:
|
||||
* - 배치 분석(batch_daily_analysis) 결과 종목 목록 표시
|
||||
* - 각 종목의 등락률, RSI, 스토캐스틱, 점수, 신호 표시
|
||||
* - 보유/매수중/신호 배지로 종목 상태 시각화
|
||||
* - 종목 클릭 시 차트/AI분석 패널 전환
|
||||
*
|
||||
* 데이터 흐름:
|
||||
* - 추천 데이터: react-query 'rule-based-recommendations' (2분 폴링)
|
||||
* - 보유 종목 Set: Dashboard에서 useMemo로 안정화 후 전달
|
||||
* - 실시간 가격: livePrices로 현재가 갱신
|
||||
*
|
||||
* 최적화:
|
||||
* - RecommendationItem, RecommendationPanel 모두 React.memo 적용
|
||||
*/
|
||||
import React from 'react'
|
||||
import { Brain, RefreshCw } from 'lucide-react'
|
||||
import { RefreshCw } from 'lucide-react'
|
||||
import { RecommendationsData, RecommendedStock, Recommendation } from '../types/trading'
|
||||
import { formatTime } from '../utils/formatUtils'
|
||||
|
||||
const formatTime = (isoStr?: string): string | null => {
|
||||
if (!isoStr) return null
|
||||
const d = new Date(isoStr)
|
||||
return d.toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false, timeZone: 'Asia/Seoul' })
|
||||
}
|
||||
|
||||
interface RecommendationPanelProps {
|
||||
recommendations: RecommendationsData | undefined
|
||||
isError: boolean
|
||||
onRefetch: () => void
|
||||
/** 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
|
||||
}
|
||||
|
||||
const RecommendationItem = React.memo(({
|
||||
stock,
|
||||
idx,
|
||||
selectedStock,
|
||||
onSelectStock,
|
||||
onRefetchAiAnalysis,
|
||||
isRecommended,
|
||||
isHeld,
|
||||
isPending,
|
||||
livePrices,
|
||||
}: RecommendationItemProps) => {
|
||||
const changeRate = stock.change_rate || 0
|
||||
const isSelected = selectedStock === stock.stock_code
|
||||
const isUp = changeRate >= 0
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={() => {
|
||||
if (isSelected) onRefetchAiAnalysis()
|
||||
else onSelectStock(stock.stock_code)
|
||||
}}
|
||||
className={`w-full text-left transition-colors border-l-2 ${
|
||||
isSelected
|
||||
? 'bg-[#0d1e33] border-[#2b65d9]'
|
||||
: 'border-transparent hover:bg-[#0a1628]'
|
||||
}`}
|
||||
>
|
||||
<div className="px-3 py-1.5">
|
||||
{/* 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>
|
||||
</button>
|
||||
)
|
||||
})
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -32,103 +149,69 @@ const RecommendationPanel = React.memo(({
|
||||
livePrices,
|
||||
}: RecommendationPanelProps) => {
|
||||
return (
|
||||
<div className="bg-gray-800/50 backdrop-blur-sm border border-gray-700/50 rounded-xl overflow-hidden flex flex-col h-1/2 shadow-lg">
|
||||
<div className="p-3 border-b border-gray-700/50 flex justify-between items-center bg-[#1c222c]">
|
||||
<h3 className="font-semibold flex items-center gap-2 text-gray-200 text-sm">
|
||||
<Brain className="w-4 h-4 text-purple-400" />
|
||||
분석 종목
|
||||
<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-[10px] text-gray-500 font-mono">
|
||||
<span className="text-[9px] text-[#3a5570] font-mono">
|
||||
{formatTime(recommendations.batch_time)}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-xs bg-purple-500/20 text-purple-300 px-2 py-0.5 rounded-full">
|
||||
<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 hover:bg-gray-700/50 rounded transition-colors"
|
||||
title="분석 종목 새로고침"
|
||||
className="p-1 text-[#3a5570] hover:text-[#9f7aea] transition-colors"
|
||||
title="새로고침"
|
||||
>
|
||||
<RefreshCw className="w-3 h-3 text-gray-400 hover:text-purple-400" />
|
||||
<RefreshCw className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="divide-y divide-gray-700/50 overflow-y-auto custom-scrollbar flex-1">
|
||||
|
||||
{/* 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-red-500 text-xs p-4 text-center">
|
||||
<p>⚠️ API 오류</p>
|
||||
<p className="text-[10px] text-gray-500 mt-1">백엔드 로그 확인 필요</p>
|
||||
<button onClick={onRefetch} className="mt-2 px-2 py-1 bg-red-500/20 text-red-400 rounded text-[10px]">재시도</button>
|
||||
<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-gray-500 text-xs p-4">
|
||||
<RefreshCw className="w-6 h-6 animate-spin mb-2 text-purple-400" />
|
||||
<p>분석 종목 로딩 중...</p>
|
||||
<p className="text-[10px] text-gray-600 mt-1">최초 로딩 시 10초 정도 소요됩니다</p>
|
||||
<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)
|
||||
const changeRate = stock.change_rate || 0
|
||||
return (
|
||||
<button
|
||||
<RecommendationItem
|
||||
key={`${stock.stock_code}-${idx}`}
|
||||
onClick={() => {
|
||||
if (selectedStock === stock.stock_code) {
|
||||
onRefetchAiAnalysis() // 같은 종목 재클릭 → 강제 새로고침
|
||||
} else {
|
||||
onSelectStock(stock.stock_code) // 새 종목 → queryKey 변경으로 자동 fetch
|
||||
}
|
||||
}}
|
||||
className={`w-full p-3 text-left hover:bg-gray-700/30 transition-all duration-200 ${selectedStock === stock.stock_code ? 'bg-purple-500/10 border-l-2 border-purple-500' : 'border-l-2 border-transparent'}`}
|
||||
>
|
||||
<div className="flex justify-between items-start mb-1">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1.5 mb-0.5">
|
||||
<span className="font-bold text-gray-200 text-xs truncate">{stock.stock_name}</span>
|
||||
<span className="text-[9px] text-gray-500 flex-shrink-0">({stock.stock_code})</span>
|
||||
</div>
|
||||
{stock.signals && stock.signals.length > 0 && (
|
||||
<div className="text-[9px] text-gray-500 truncate">
|
||||
{stock.signals.slice(0, 2).join(' · ')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-0.5 flex-shrink-0 ml-1">
|
||||
{isPending ? (
|
||||
<span className="text-[9px] text-orange-400 border border-orange-500/20 bg-orange-500/10 px-1 py-0.5 rounded flex items-center gap-0.5">
|
||||
<RefreshCw className="w-2 h-2 animate-spin" />매수중
|
||||
</span>
|
||||
) : isHeld ? (
|
||||
<span className="text-[9px] text-blue-400 border border-blue-500/20 bg-blue-500/10 px-1 py-0.5 rounded">보유중</span>
|
||||
) : isRecommended ? (
|
||||
<span className="text-[9px] text-green-400 border border-green-500/20 bg-green-500/10 px-1 py-0.5 rounded">매수신호</span>
|
||||
) : null}
|
||||
<span className={`text-[9px] font-bold ${changeRate > 0 ? 'text-red-400' : changeRate < 0 ? 'text-blue-400' : 'text-gray-500'}`}>
|
||||
{changeRate > 0 ? '+' : ''}{changeRate.toFixed(2)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-between items-center text-[9px] text-gray-500 mt-0.5">
|
||||
<span>{Math.round(livePrices?.[stock.stock_code] ?? stock.current_price ?? 0).toLocaleString()}원</span>
|
||||
<span className="flex gap-2">
|
||||
{stock.rsi != null && <span>RSI {stock.rsi.toFixed(0)}</span>}
|
||||
{stock.stochastic_k != null && <span>K {stock.stochastic_k.toFixed(0)}</span>}
|
||||
{(stock.score ?? 0) > 0 && <span className="text-purple-400">{stock.score}점</span>}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
stock={stock}
|
||||
idx={idx}
|
||||
selectedStock={selectedStock}
|
||||
onSelectStock={onSelectStock}
|
||||
onRefetchAiAnalysis={onRefetchAiAnalysis}
|
||||
isRecommended={isRecommended}
|
||||
isHeld={isHeld}
|
||||
isPending={isPending}
|
||||
livePrices={livePrices}
|
||||
/>
|
||||
)
|
||||
})
|
||||
) : (
|
||||
<div className="h-full flex flex-col items-center justify-center text-gray-500 p-4 text-center text-xs">
|
||||
<p>배치 분석 데이터가 없습니다.</p>
|
||||
<p className="text-[10px] text-gray-600 mt-1">make batch-test 실행 필요</p>
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user