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,16 +1,172 @@
|
||||
/**
|
||||
* PortfolioPanel.tsx — 보유 종목 패널 컴포넌트
|
||||
*
|
||||
* 역할:
|
||||
* - 보유 종목 목록을 데이트레이딩/스윙 그룹별로 구분 표시
|
||||
* - 각 종목의 수익률, 현재가, 평균단가, 전량 청산 버튼 제공
|
||||
* - 실시간 가격(livePrices)으로 수익률 실시간 갱신
|
||||
*
|
||||
* 최적화:
|
||||
* - PortfolioItem, PortfolioPanel 모두 React.memo 적용
|
||||
* - 가격 변동 시에도 선택된 종목만 시각적 변화 (border-l-2 하이라이트)
|
||||
*
|
||||
* 한국/미국 시장 구분:
|
||||
* - 종목 코드가 6자리 숫자면 KR, 그 외는 US
|
||||
*/
|
||||
import React from 'react'
|
||||
import { Activity } from 'lucide-react'
|
||||
import { Activity, TrendingUp, Clock } from 'lucide-react'
|
||||
import { Position } from '../types/trading'
|
||||
import { isUsStock } from '../utils/stockUtils'
|
||||
|
||||
interface PortfolioPanelProps {
|
||||
portfolio: Position[] | undefined
|
||||
/**
|
||||
* 가격 포맷 — 시장에 따라 원화 또는 달러 표시
|
||||
* @param price - 가격
|
||||
* @param isUs - 미국 주식 여부
|
||||
*/
|
||||
const formatPrice = (price: number, isUs: boolean): string => {
|
||||
if (isUs) {
|
||||
return `$${price.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
|
||||
}
|
||||
return `${price.toLocaleString()}원`
|
||||
}
|
||||
|
||||
/** PortfolioItem Props 정의 */
|
||||
interface PortfolioItemProps {
|
||||
/** 보유 종목 데이터 */
|
||||
stock: Position
|
||||
/** 실시간 가격 맵 (종목코드 → 현재가) */
|
||||
livePrices: Record<string, number> | undefined
|
||||
/** 현재 선택된 종목 코드 (하이라이트용) */
|
||||
selectedStock: string
|
||||
/** 종목 선택 콜백 */
|
||||
onSelectStock: (code: string) => void
|
||||
/** AI 분석 새로고침 콜백 (이미 선택된 종목 클릭 시) */
|
||||
onRefetchAiAnalysis: () => void
|
||||
/** 전량 청산(시장가 매도) 콜백 */
|
||||
onSell: (stockCode: string, stockName: string, quantity: number) => Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* PortfolioItem — 개별 보유 종목 카드
|
||||
*
|
||||
* 표시 정보: 종목명, KR/US 마켓 배지, 데이트레이딩 배지, 수익률,
|
||||
* 종목코드, 수량, 평균단가, 현재가, 전량 청산 버튼
|
||||
*
|
||||
* 동작:
|
||||
* - 클릭: 해당 종목 선택 (차트/호가/AI분석 전환)
|
||||
* - 이미 선택된 종목 클릭: AI 분석 새로고침
|
||||
* - 전량 청산 버튼: 시장가 매도 요청
|
||||
*/
|
||||
const PortfolioItem = React.memo(({
|
||||
stock,
|
||||
livePrices,
|
||||
selectedStock,
|
||||
onSelectStock,
|
||||
onRefetchAiAnalysis,
|
||||
onSell,
|
||||
}: PortfolioItemProps) => {
|
||||
// 현재가: 실시간 가격 우선, 없으면 포트폴리오 API 가격 사용
|
||||
const currentPrice = livePrices?.[stock.stock_code] ?? Number(stock.current_price || 0)
|
||||
const avgPrice = Number(stock.average_price || 0)
|
||||
const quantity = Number(stock.quantity || 0)
|
||||
// 수익률: 평균단가 기반 실시간 계산 (평균단가 없으면 API 값 폴백)
|
||||
const profitLossRate = avgPrice > 0
|
||||
? ((currentPrice - avgPrice) / avgPrice) * 100
|
||||
: Number(stock.profit_loss_rate || 0)
|
||||
const isUs = isUsStock(stock.stock_code)
|
||||
const isSelected = selectedStock === stock.stock_code
|
||||
const isUp = profitLossRate >= 0
|
||||
|
||||
const handleSellClick = async (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.stopPropagation()
|
||||
try {
|
||||
await onSell(stock.stock_code, stock.stock_name, quantity)
|
||||
} catch (error: unknown) {
|
||||
const err = error as any
|
||||
alert(`매도 실패: ${err.response?.data?.detail || err.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`border-l-2 ${isSelected ? 'border-[#2b65d9] bg-[#0d1e33]' : 'border-transparent hover:bg-[#0a1628]'} transition-colors`}>
|
||||
<div
|
||||
onClick={() => {
|
||||
if (isSelected) onRefetchAiAnalysis()
|
||||
else onSelectStock(stock.stock_code)
|
||||
}}
|
||||
className="px-3 pt-2 pb-1 cursor-pointer"
|
||||
>
|
||||
{/* Row 1: Name + P&L% */}
|
||||
<div className="flex justify-between items-start mb-0.5">
|
||||
<div className="flex items-center gap-1 min-w-0 flex-1 mr-1">
|
||||
<span className={`text-xs font-semibold truncate ${isSelected ? 'text-[#5b9aff]' : 'text-[#c5d5e8]'}`}>
|
||||
{stock.stock_name}
|
||||
</span>
|
||||
<span className={`text-[7px] px-1 py-px rounded-sm flex-shrink-0 font-mono ${isUs ? 'text-[#22c55e] bg-[#22c55e]/10' : 'text-[#5b9aff] bg-[#5b9aff]/10'}`}>
|
||||
{isUs ? 'US' : 'KR'}
|
||||
</span>
|
||||
{stock.strategy_type === 'daytrading' && (
|
||||
<span className="text-[7px] px-1 py-px rounded-sm text-[#f97316] bg-[#f97316]/10 flex-shrink-0">데이</span>
|
||||
)}
|
||||
</div>
|
||||
<span className={`text-xs font-mono font-bold flex-shrink-0 ${isUp ? 'text-[#e84444]' : 'text-[#3d7eff]'}`}>
|
||||
{profitLossRate > 0 ? '+' : ''}{profitLossRate.toFixed(2)}%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Row 2: Code + qty */}
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-[9px] font-mono text-[#3a5570]">{stock.stock_code}</span>
|
||||
<span className="text-[9px] text-[#3a5570]">{quantity}{isUs ? 'shares' : '주'}</span>
|
||||
</div>
|
||||
|
||||
{/* Row 3: Avg → Current */}
|
||||
<div className="flex justify-between items-center mt-0.5">
|
||||
<span className="text-[9px] font-mono text-[#4a6580]">{formatPrice(avgPrice, isUs)}</span>
|
||||
<span className={`text-[10px] font-mono font-semibold ${isUp ? 'text-[#e84444]' : 'text-[#3d7eff]'}`}>
|
||||
{formatPrice(currentPrice, isUs)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sell button */}
|
||||
<div className="px-3 pb-2">
|
||||
<button
|
||||
onClick={handleSellClick}
|
||||
className="w-full py-0.5 text-[9px] font-mono text-[#3a5570] hover:text-[#3d7eff] border border-[#152035] hover:border-[#3d7eff]/40 rounded-sm transition-colors"
|
||||
>
|
||||
전량 청산 (시장가)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
PortfolioItem.displayName = 'PortfolioItem'
|
||||
|
||||
/** PortfolioPanel Props 정의 */
|
||||
interface PortfolioPanelProps {
|
||||
/** 보유 종목 배열 (마켓 필터 적용 후) */
|
||||
portfolio: Position[] | undefined
|
||||
/** 실시간 가격 맵 */
|
||||
livePrices: Record<string, number> | undefined
|
||||
/** 현재 선택된 종목 코드 */
|
||||
selectedStock: string
|
||||
/** 종목 선택 콜백 */
|
||||
onSelectStock: (code: string) => void
|
||||
/** AI 분석 새로고침 콜백 */
|
||||
onRefetchAiAnalysis: () => void
|
||||
/** 전량 청산 콜백 */
|
||||
onSell: (stockCode: string, stockName: string, quantity: number) => Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* PortfolioPanel — 보유 종목 목록 패널
|
||||
*
|
||||
* 그룹 구분:
|
||||
* - 데이트레이딩: strategy_type === 'daytrading' (주황색 배지)
|
||||
* - 스윙: 그 외 (보라색 배지)
|
||||
*/
|
||||
const PortfolioPanel = React.memo(({
|
||||
portfolio,
|
||||
livePrices,
|
||||
@@ -19,89 +175,63 @@ const PortfolioPanel = React.memo(({
|
||||
onRefetchAiAnalysis,
|
||||
onSell,
|
||||
}: PortfolioPanelProps) => {
|
||||
const dayStocks = portfolio?.filter(s => s.strategy_type === 'daytrading') ?? []
|
||||
const swingStocks = portfolio?.filter(s => s.strategy_type !== 'daytrading') ?? []
|
||||
const total = portfolio?.length ?? 0
|
||||
|
||||
const renderGroup = (stocks: Position[], label: string, icon: React.ReactNode, color: string) => {
|
||||
if (stocks.length === 0) return null
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-1.5 px-3 py-1 bg-[#091018] border-b border-[#152035] sticky top-0 z-10">
|
||||
{icon}
|
||||
<span className={`text-[9px] font-semibold uppercase tracking-wider ${color}`}>{label}</span>
|
||||
<span className="ml-auto text-[8px] font-mono bg-[#152035] text-[#4a6580] px-1.5 py-px rounded-sm">{stocks.length}</span>
|
||||
</div>
|
||||
{stocks.map((stock: Position) => (
|
||||
<PortfolioItem
|
||||
key={stock.stock_code}
|
||||
stock={stock}
|
||||
livePrices={livePrices}
|
||||
selectedStock={selectedStock}
|
||||
onSelectStock={onSelectStock}
|
||||
onRefetchAiAnalysis={onRefetchAiAnalysis}
|
||||
onSell={onSell}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-gray-800/50 backdrop-blur-sm border border-gray-700/50 rounded-xl overflow-hidden flex flex-col h-full 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">
|
||||
<Activity className="w-4 h-4 text-blue-400" />
|
||||
<div className="bg-[#0c1522] border border-[#152035] rounded overflow-hidden flex flex-col h-full">
|
||||
{/* Header */}
|
||||
<div className="px-3 py-2 border-b border-[#152035] bg-[#091018] flex justify-between items-center flex-shrink-0">
|
||||
<h3 className="flex items-center gap-1.5 text-[#8faabf] text-xs font-semibold uppercase tracking-wider">
|
||||
<Activity className="w-3 h-3 text-[#2b65d9]" />
|
||||
보유 종목
|
||||
</h3>
|
||||
<span className="text-xs bg-blue-500/20 text-blue-300 px-2 py-0.5 rounded-full">
|
||||
{portfolio?.length || 0}
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
{dayStocks.length > 0 && (
|
||||
<span className="text-[8px] font-mono text-[#f97316] bg-[#f97316]/10 px-1.5 py-px rounded-sm">데이 {dayStocks.length}</span>
|
||||
)}
|
||||
{swingStocks.length > 0 && (
|
||||
<span className="text-[8px] font-mono text-[#a78bfa] bg-[#a78bfa]/10 px-1.5 py-px rounded-sm">스윙 {swingStocks.length}</span>
|
||||
)}
|
||||
<span className="text-[8px] font-mono text-[#2b65d9] bg-[#2b65d9]/10 px-1.5 py-px rounded-sm">{total}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="divide-y divide-gray-700/50 overflow-y-auto custom-scrollbar flex-1">
|
||||
{portfolio && portfolio.length > 0 ? (
|
||||
portfolio.map((stock: Position) => {
|
||||
const currentPrice = livePrices?.[stock.stock_code] ?? Number(stock.current_price || 0)
|
||||
const avgPrice = Number(stock.average_price || 0)
|
||||
const quantity = Number(stock.quantity || 0)
|
||||
const totalValue = currentPrice * quantity
|
||||
const profitLossRate = avgPrice > 0
|
||||
? ((currentPrice - avgPrice) / avgPrice) * 100
|
||||
: Number(stock.profit_loss_rate || 0)
|
||||
|
||||
const handleSellClick = async (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.stopPropagation()
|
||||
|
||||
try {
|
||||
await onSell(stock.stock_code, stock.stock_name, quantity)
|
||||
} catch (error: unknown) {
|
||||
const err = error as any;
|
||||
alert(`매도 실패: ${err.response?.data?.detail || err.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={stock.stock_code}
|
||||
className={`w-full p-3 hover:bg-gray-700/30 transition-all duration-200 group ${selectedStock === stock.stock_code ? 'bg-blue-500/10 border-l-2 border-blue-500' : 'border-l-2 border-transparent'}`}
|
||||
>
|
||||
<div
|
||||
onClick={() => {
|
||||
if (selectedStock === stock.stock_code) {
|
||||
onRefetchAiAnalysis() // 같은 종목 재클릭 → 강제 새로고침
|
||||
} else {
|
||||
onSelectStock(stock.stock_code) // 새 종목 → queryKey 변경으로 자동 fetch
|
||||
}
|
||||
}}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<div className="flex justify-between items-start mb-1">
|
||||
<span className={`font-bold text-sm ${selectedStock === stock.stock_code ? 'text-blue-400' : 'text-gray-200'}`}>
|
||||
{stock.stock_name}
|
||||
</span>
|
||||
<span className={`text-xs font-bold ${profitLossRate > 0 ? 'text-red-400' : 'text-blue-400'}`}>
|
||||
{profitLossRate > 0 ? '+' : ''}{profitLossRate.toFixed(2)}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center text-[11px] text-gray-400 mb-0.5">
|
||||
<span className="font-mono">{stock.stock_code}</span>
|
||||
<span>{quantity}주</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center text-[11px]">
|
||||
<span className="text-gray-500">{avgPrice.toLocaleString()}원</span>
|
||||
<span className="text-white font-medium">{currentPrice.toLocaleString()}원</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center text-[11px] mt-0.5">
|
||||
<span className="text-gray-500">평가금액</span>
|
||||
<span className={`font-medium ${profitLossRate > 0 ? 'text-red-400' : 'text-blue-400'}`}>
|
||||
{totalValue.toLocaleString()}원
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleSellClick}
|
||||
className="w-full mt-2 px-2 py-1 text-[10px] bg-blue-500/20 hover:bg-blue-500/30 text-blue-400 rounded border border-blue-500/30 transition-colors font-bold"
|
||||
>
|
||||
🔵 전량 청산 (시장가)
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
{/* List */}
|
||||
<div className="divide-y divide-[#0f1e2e] overflow-y-auto custom-scrollbar flex-1">
|
||||
{total > 0 ? (
|
||||
<>
|
||||
{renderGroup(dayStocks, '데이트레이딩', <Clock className="w-2.5 h-2.5 text-[#f97316]" />, 'text-[#f97316]')}
|
||||
{renderGroup(swingStocks, '스윙', <TrendingUp className="w-2.5 h-2.5 text-[#a78bfa]" />, 'text-[#a78bfa]')}
|
||||
</>
|
||||
) : (
|
||||
<div className="h-full flex flex-col items-center justify-center text-gray-500 p-4 text-center text-xs">
|
||||
<p>보유 중인 종목이 없습니다.</p>
|
||||
<div className="h-full flex flex-col items-center justify-center text-[#3a5570] p-4 text-center">
|
||||
<p className="text-xs">보유 중인 종목이 없습니다.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user