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,11 +1,36 @@
/**
* useWebSocket.ts — WebSocket 연결 관리 커스텀 훅
*
* 역할:
* - 백엔드 WebSocket 서버와의 지속 연결 관리
* - 자동 재연결 (지수 백오프: 3초 ~ 최대 30초, 무제한 재시도)
* - 30초 간격 ping/pong 헬스 체크 (Nginx 프록시 타임아웃 방지)
* - 메시지 타입별 분기 처리 (market_update, chart_data, 기타)
*
* 메시지 라우팅:
* - market_update: 콜백(onMarketUpdate)으로만 전달 (배열 누적 방지)
* - chart_data: CustomEvent로 전달 (React 상태 우회 — StrictMode 안전)
* - 기타: messages state 배열에 추가 (최대 100개 유지)
*
* StrictMode 안전:
* - socketRef로 활성 WS 추적 → stale onclose/onopen 이벤트 무시
* - mountedRef로 언마운트 후 재연결 방지
*
* @param clientId - 고유 클라이언트 ID (탭별 구분, 서버 덮어쓰기 방지)
* @param options - 메시지 타입별 콜백 (onMarketUpdate, onChartData)
* @returns { messages, connected, sendMessage }
*/
import { useEffect, useState, useCallback, useRef } from 'react'
import { config } from '../config'
/** WebSocket 메시지 기본 구조 */
interface WebSocketMessage {
type: string
data: unknown
[key: string]: unknown
}
/** useWebSocket 옵션 인터페이스 */
interface UseWebSocketOptions {
/** market_update 메시지 전용 콜백 (배열 누적 방지 — 실시간 틱 전용) */
onMarketUpdate?: (data: unknown) => void
@@ -25,23 +50,43 @@ export const useWebSocket = (clientId: string, options?: UseWebSocketOptions) =>
// 콜백을 ref로 유지 — connect 클로저가 재생성되지 않아도 최신 콜백 참조
const onMarketUpdateRef = useRef(options?.onMarketUpdate)
const onChartDataRef = useRef(options?.onChartData)
// 렌더 중 동기적으로 ref 업데이트 (useEffect 대기 없이 즉시 반영)
onMarketUpdateRef.current = options?.onMarketUpdate
onChartDataRef.current = options?.onChartData
/** 재연결 기본 딜레이 (3초, 지수 백오프 적용) */
const baseDelay = 3000
/** 마지막 pong 수신 시각 (타임아웃 판단용) */
const lastPongReceivedRef = useRef<number>(Date.now())
const pongTimeoutMs = 10000 // 10초 이내에 pong을 받지 못하면 연결 끊김으로 간주
// options 변경 시 ref 동기화
useEffect(() => {
onMarketUpdateRef.current = options?.onMarketUpdate
onChartDataRef.current = options?.onChartData
})
/** pong 타임아웃 (30초 이내 미수신 시 연결 끊김 간주) */
const pongTimeoutMs = 30000
/**
* WebSocket 연결 생성 함수
*
* 연결 흐름:
* 1. 기존 연결 정리 (이중 연결 방지)
* 2. API 키 포함 WS URL로 연결 시도
* 3. onopen: 연결 성공, ping 인터벌 시작
* 4. onmessage: 메시지 타입별 라우팅
* 5. onclose: 재연결 스케줄링 (지수 백오프)
*/
const connect = useCallback(() => {
if (!mountedRef.current) return
// 기존 연결이 남아있으면 정리 (이중 연결 방지)
if (socketRef.current) {
try { socketRef.current.close() } catch { /* ignore */ }
}
try {
const ws = new WebSocket(`${config.wsBaseUrl}/ws/${clientId}`)
const apiKey = localStorage.getItem('api_key') || ''
const ws = new WebSocket(`${config.wsBaseUrl}/ws/${clientId}?token=${encodeURIComponent(apiKey)}`)
// 즉시 socketRef에 할당 — onclose 가드가 null 상태를 보지 않도록
socketRef.current = ws
ws.onopen = () => {
// StrictMode guard: 이미 다른 WS가 활성화되었으면 이 이벤트 무시
if (socketRef.current !== ws) { ws.close(); return }
console.log('🔗 WebSocket connected')
setConnected(true)
reconnectAttemptsRef.current = 0
@@ -52,7 +97,7 @@ export const useWebSocket = (clientId: string, options?: UseWebSocketOptions) =>
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'ping' }))
// Pong 타임아웃 설정 (10초 이내에 pong 응답이 없으면 연결 끊김으로 간주)
// Pong 타임아웃 설정 (30초 이내에 pong 응답이 없으면 연결 끊김으로 간주)
if (pongTimeoutRef.current) {
clearTimeout(pongTimeoutRef.current)
}
@@ -87,13 +132,14 @@ export const useWebSocket = (clientId: string, options?: UseWebSocketOptions) =>
return
}
// chart_data: 콜백으로만 전달 (배열 누적 방지 — 차트 로드 전용)
// chart_data: CustomEvent로 직접 전달 (React 상태 우회 — StrictMode 타이밍 이슈 방지)
if (message.type === 'chart_data') {
onChartDataRef.current?.(message)
console.log('📬 chart_data → CustomEvent', (message as any).stock_code)
window.dispatchEvent(new CustomEvent('ws-chart-data', { detail: message }))
return
}
setMessages((prev: WebSocketMessage[]) => [...prev, message])
setMessages((prev: WebSocketMessage[]) => [...prev.slice(-99), message])
} catch (error) {
console.error('Failed to parse WebSocket message:', error)
}
@@ -104,6 +150,10 @@ export const useWebSocket = (clientId: string, options?: UseWebSocketOptions) =>
}
ws.onclose = () => {
// StrictMode guard: 이미 다른 WS가 활성화되었으면 stale 이벤트 무시
// (cleanup에서 닫힌 WS의 onclose가 새 WS에 영향 주는 것을 방지)
if (socketRef.current !== null && socketRef.current !== ws) return
console.log('🔌 WebSocket disconnected')
setConnected(false)
socketRef.current = null
@@ -130,7 +180,6 @@ export const useWebSocket = (clientId: string, options?: UseWebSocketOptions) =>
}
}
socketRef.current = ws
} catch (error) {
console.error('Failed to create WebSocket:', error)
}
@@ -157,6 +206,10 @@ export const useWebSocket = (clientId: string, options?: UseWebSocketOptions) =>
}
}, [connect])
/**
* WebSocket 메시지 전송 함수
* 연결이 OPEN 상태일 때만 전송 (그 외 상태에서는 무시)
*/
const sendMessage = useCallback((message: string) => {
if (socketRef.current && socketRef.current.readyState === WebSocket.OPEN) {
socketRef.current.send(message)