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:
2026-03-26 16:05:41 +09:00
parent 739a8a2e5d
commit de7921d270
149 changed files with 133353 additions and 3338 deletions

View File

@@ -1,75 +1,206 @@
import React from 'react'
import { Activity } from 'lucide-react'
/**
* JournalPanel.tsx — 매매내역 및 매매일지 탭 패널 컴포넌트
*
* 역할:
* - 매매내역 탭: 최근 거래 이력 표시 (매수/매도, 손익, 체결 시간, 사유)
* - 매매일지 탭: AI 자동 생성 매매일지 표시 (기분, 태그, 내용)
* - 미체결 주문 정리 버튼 제공
*
* 최적화:
* - React.memo로 불필요한 리렌더 방지
*
* 데이터 흐름:
* - journals: react-query 'journals' 쿼리 결과
* - tradeHistory: react-query 'trade-history' 쿼리 결과
*/
import React, { useState } from 'react'
import { BookOpen, ArrowUpDown } from 'lucide-react'
import { Journal, TradeHistory } from '../types/trading'
/** JournalPanel Props 정의 */
interface JournalPanelProps {
journals: any[] | undefined
/** 매매일지 배열 (장 마감 후 자동 생성) */
journals: Journal[] | undefined
/** 매매 내역 배열 (최근 거래 이력) */
tradeHistory?: TradeHistory[] | undefined
/** 미체결 주문 정리 콜백 */
onResetPending: () => void
}
const JournalPanel = React.memo(({ journals, onResetPending }: JournalPanelProps) => {
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="flex flex-col h-full p-3">
<div className="flex flex-col h-full min-h-0">
<div className="flex items-center justify-between mb-2 flex-shrink-0">
<h3 className="font-bold flex items-center gap-2 text-green-400 text-sm">
<Activity size={16} />
매매일지
</h3>
<button
onClick={onResetPending}
className="px-2 py-1 text-[10px] bg-red-500/20 hover:bg-red-500/30 text-red-400 rounded border border-red-500/30 transition-colors"
title="미체결 주문 정리"
>
미체결 정리
</button>
</div>
<div className="space-y-2 overflow-y-auto custom-scrollbar flex-1">
{journals && journals.length > 0 ? (
journals.map((journal: any) => {
const journalDate = new Date(journal.journal_date || journal.created_at)
const dateStr = journalDate.toLocaleDateString('ko-KR', { month: '2-digit', day: '2-digit', timeZone: 'Asia/Seoul' })
const timeStr = journalDate.toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit', hour12: false, timeZone: 'Asia/Seoul' })
const isGoodMood = journal.mood === '좋음' || journal.mood === 'good' || journal.mood === 'positive'
const isBadMood = journal.mood === '나쁨' || journal.mood === 'bad' || journal.mood === 'negative'
const tags: string[] = Array.isArray(journal.tags) ? journal.tags : []
const hasProfitTag = tags.some((t: string) => t.includes('익절') || t.includes('profit'))
const hasLossTag = tags.some((t: string) => t.includes('손절') || t.includes('loss'))
/**
* JournalPanel — 매매내역/매매일지 탭 전환 패널
*
* 상태(State):
* - tab: 현재 활성 탭 ('trades' 또는 'journal')
*/
const JournalPanel = React.memo(({ journals, tradeHistory, onResetPending }: JournalPanelProps) => {
const [tab, setTab] = useState<'trades' | 'journal'>('trades')
return (
<div key={journal.id} className="p-3 hover:bg-gray-700/30 rounded-lg transition-colors bg-gray-900/30 border border-gray-700/30">
<div className="flex justify-between items-start mb-1.5">
<div className="flex items-center gap-2">
<span className="text-base">
{isGoodMood ? '😊' : isBadMood ? '😔' : '📝'}
</span>
<span className="font-bold text-gray-200 text-sm">{journal.stock_name || journal.stock_code}</span>
{hasProfitTag && <span className="px-1.5 py-0.5 text-[9px] rounded bg-green-500/20 text-green-400">익절</span>}
{hasLossTag && <span className="px-1.5 py-0.5 text-[9px] rounded bg-red-500/20 text-red-400">손절</span>}
</div>
<span className="text-[10px] text-gray-500 font-mono">{dateStr} {timeStr}</span>
return (
<div className="bg-[#0c1522] border border-[#152035] rounded overflow-hidden flex flex-col h-1/2">
{/* Header with tabs */}
<div className="px-3 py-1.5 border-b border-[#152035] bg-[#091018] flex justify-between items-center flex-shrink-0">
<div className="flex items-center gap-0.5">
<button
onClick={() => setTab('trades')}
className={`flex items-center gap-1 px-2 py-1 text-[10px] font-semibold uppercase tracking-wider rounded transition-colors ${
tab === 'trades'
? 'text-[#3b82f6] bg-[#3b82f6]/10'
: 'text-[#3a5570] hover:text-[#64748b]'
}`}
>
<ArrowUpDown size={10} />
매매내역
{tradeHistory && tradeHistory.length > 0 && (
<span className="ml-1 text-[8px] font-mono bg-[#1e293b] px-1 rounded">{tradeHistory.length}</span>
)}
</button>
<button
onClick={() => setTab('journal')}
className={`flex items-center gap-1 px-2 py-1 text-[10px] font-semibold uppercase tracking-wider rounded transition-colors ${
tab === 'journal'
? 'text-[#22c55e] bg-[#22c55e]/10'
: 'text-[#3a5570] hover:text-[#64748b]'
}`}
>
<BookOpen size={10} />
매매일지
</button>
</div>
<button
onClick={onResetPending}
className="px-2 py-0.5 text-[9px] font-mono text-[#3a5570] hover:text-[#e84444] border border-[#152035] hover:border-[#e84444]/30 rounded-sm transition-colors"
title="미체결 주문 정리"
>
미체결 정리
</button>
</div>
{/* Content */}
<div className="overflow-y-auto custom-scrollbar flex-1 p-2 space-y-1">
{tab === 'trades' ? (
/* ── 매매내역 탭 ── */
tradeHistory && tradeHistory.length > 0 ? (
tradeHistory.map((trade: TradeHistory, idx: number) => {
// 거래 유형 판별 (매수/매도)
const isBuy = trade.order_type === 'BUY'
const isSell = trade.order_type === 'SELL'
// 손익 금액 및 수익률 (순이익 우선, 없으면 손익 사용)
const pnl = Number(trade.net_profit_loss || trade.profit_loss || 0)
const pnlRate = Number(trade.profit_loss_rate || 0)
const isProfit = pnl > 0
const isLoss = pnl < 0
// 체결 상태 판별 (COMPLETED, EXECUTED, FILLED → 정상 체결)
const st = String(trade.status || '')
const statusOk = st === 'COMPLETED' || st === 'EXECUTED' || st === 'FILLED'
const isFailed = st === 'FAILED'
// DB가 KST로 저장하므로 타임존 없는 ISO 문자열에 +09:00 보정
const rawDt = trade.created_at || trade.executed_at || ''
const createdAt = new Date(rawDt.includes('+') || rawDt.includes('Z') ? rawDt : rawDt + '+09:00')
const timeStr = createdAt.toLocaleTimeString('ko-KR', {
hour: '2-digit', minute: '2-digit', second: '2-digit',
hour12: false, timeZone: 'Asia/Seoul',
})
return (
<div
key={trade.id || idx}
className={`px-2.5 py-2 rounded-sm border transition-colors ${
isFailed
? 'bg-[#1a0808] border-red-900/20'
: 'bg-[#091018] border-[#152035] hover:border-[#1e3050]'
}`}
>
<div className="flex justify-between items-center">
<div className="flex items-center gap-1.5 min-w-0">
<span className={`text-[10px] font-bold px-1.5 py-0.5 rounded ${
isBuy ? 'bg-[#ef4444]/15 text-[#ef4444]' : 'bg-[#3b82f6]/15 text-[#3b82f6]'
}`}>
{isBuy ? '매수' : '매도'}
</span>
<span className="text-xs font-semibold text-[#c5d5e8] truncate">
{trade.stock_name || trade.stock_code}
</span>
{isFailed && (
<span className="text-[8px] font-mono px-1 py-px rounded-sm bg-[#7f1d1d] text-[#fca5a5]">실패</span>
)}
</div>
<div className="text-[11px] text-gray-300 leading-relaxed line-clamp-3 whitespace-pre-line">
{journal.content}
<span className="text-[9px] font-mono text-[#3a5570] flex-shrink-0 ml-1">{timeStr}</span>
</div>
<div className="flex justify-between items-center mt-1">
<div className="text-[10px] font-mono text-[#64748b]">
{Number(trade.order_quantity || 0)}주 × {Number(trade.order_price || 0).toLocaleString()}원
</div>
{tags.length > 0 && (
<div className="flex flex-wrap gap-1 mt-1.5">
{tags.slice(0, 4).map((tag: string, i: number) => (
<span key={i} className="px-1.5 py-0.5 text-[9px] rounded bg-gray-700/50 text-gray-400">#{tag}</span>
))}
{isSell && statusOk && pnl !== 0 && (
<div className={`text-[10px] font-mono font-semibold ${isProfit ? 'text-[#ef4444]' : isLoss ? 'text-[#3b82f6]' : 'text-[#64748b]'}`}>
{pnl > 0 ? '+' : ''}{Math.round(pnl).toLocaleString()}원
<span className="ml-1 text-[9px]">({pnlRate > 0 ? '+' : ''}{pnlRate.toFixed(1)}%)</span>
</div>
)}
</div>
)
})
) : (
<div className="h-full flex flex-col items-center justify-center text-gray-600 text-xs gap-1">
<span>매매일지 없음</span>
<span className="text-[10px] text-gray-700">매도 체결 후 자동으로 생성됩니다</span>
</div>
)}
</div>
</div>
{trade.entry_reason && isBuy && (
<div className="text-[9px] text-[#475569] mt-1 truncate">{trade.entry_reason}</div>
)}
{trade.exit_reason && isSell && (
<div className="text-[9px] text-[#475569] mt-1 truncate">{trade.exit_reason}</div>
)}
</div>
)
})
) : (
<div className="h-full flex flex-col items-center justify-center text-[#3a5570] text-xs text-center gap-1 pt-8">
<ArrowUpDown size={20} className="text-[#1e293b]" />
<span>매매내역 없음</span>
</div>
)
) : (
/* ── 매매일지 탭 ── */
journals && journals.length > 0 ? (
journals.map((journal: Journal) => {
const journalDate = new Date(journal.journal_date || journal.created_at || '')
const dateStr = journalDate.toLocaleDateString('ko-KR', { month: '2-digit', day: '2-digit', timeZone: 'Asia/Seoul' })
const timeStr = journalDate.toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit', hour12: false, timeZone: 'Asia/Seoul' })
const isGoodMood = journal.mood === '좋음' || journal.mood === 'good' || journal.mood === 'positive'
const isBadMood = journal.mood === '나쁨' || journal.mood === 'bad' || journal.mood === 'negative'
const tags: string[] = Array.isArray(journal.tags) ? journal.tags : []
return (
<div key={journal.id} className="p-2.5 bg-[#091018] border border-[#152035] rounded-sm hover:border-[#1e3050] transition-colors">
<div className="flex justify-between items-start mb-1">
<div className="flex items-center gap-1.5 min-w-0">
<span className="text-sm flex-shrink-0">
{isGoodMood ? '😊' : isBadMood ? '😔' : '📝'}
</span>
<span className="text-xs font-semibold text-[#c5d5e8] truncate">
{journal.stock_name || journal.stock_code}
</span>
</div>
<span className="text-[9px] font-mono text-[#3a5570] flex-shrink-0 ml-1">{dateStr} {timeStr}</span>
</div>
<div className="text-[10px] text-[#4a6580] leading-relaxed line-clamp-2 whitespace-pre-line">
{journal.content}
</div>
{tags.length > 0 && (
<div className="flex flex-wrap gap-1 mt-1">
{tags.slice(0, 4).map((tag: string, i: number) => (
<span key={i} className="text-[8px] font-mono px-1 py-px rounded-sm bg-[#152035] text-[#3a5570]">#{tag}</span>
))}
</div>
)}
</div>
)
})
) : (
<div className="h-full flex flex-col items-center justify-center text-[#3a5570] text-xs text-center gap-1 pt-8">
<BookOpen size={20} className="text-[#1e293b]" />
<span>매매일지 없음</span>
<span className="text-[9px] text-[#2a3d55]">장 마감 후 자동 생성됩니다</span>
</div>
)
)}
</div>
</div>
)