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:
@@ -19,6 +19,7 @@ import os
|
||||
from pathlib import Path
|
||||
from collections import deque
|
||||
from app.core.config import settings
|
||||
from app.services.utils import CircuitBreakerState
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -43,15 +44,15 @@ class CircuitBreaker:
|
||||
self.recovery_timeout = recovery_timeout # 차단 후 복구 대기 시간 (초)
|
||||
self.failure_count = 0
|
||||
self.last_failure_time: Optional[float] = None
|
||||
self.state = "CLOSED"
|
||||
self.state = CircuitBreakerState.CLOSED
|
||||
|
||||
def can_request(self) -> bool:
|
||||
"""요청 가능 여부 반환."""
|
||||
if self.state == "CLOSED":
|
||||
if self.state == CircuitBreakerState.CLOSED:
|
||||
return True
|
||||
if self.state == "OPEN":
|
||||
if self.state == CircuitBreakerState.OPEN:
|
||||
if self.last_failure_time and time.time() - self.last_failure_time >= self.recovery_timeout:
|
||||
self.state = "HALF_OPEN"
|
||||
self.state = CircuitBreakerState.HALF_OPEN
|
||||
logger.info("⚡ Circuit breaker HALF_OPEN: 복구 시도")
|
||||
return True
|
||||
return False
|
||||
@@ -59,17 +60,17 @@ class CircuitBreaker:
|
||||
|
||||
def record_success(self) -> None:
|
||||
"""성공 기록 — 차단 해제."""
|
||||
if self.state != "CLOSED":
|
||||
if self.state != CircuitBreakerState.CLOSED:
|
||||
logger.info("✅ Circuit breaker CLOSED: 복구 완료")
|
||||
self.failure_count = 0
|
||||
self.state = "CLOSED"
|
||||
self.state = CircuitBreakerState.CLOSED
|
||||
|
||||
def record_failure(self) -> None:
|
||||
"""실패 기록 — 임계치 초과 시 차단."""
|
||||
self.failure_count += 1
|
||||
self.last_failure_time = time.time()
|
||||
if self.state == "HALF_OPEN" or self.failure_count >= self.failure_threshold:
|
||||
self.state = "OPEN"
|
||||
if self.state == CircuitBreakerState.HALF_OPEN or self.failure_count >= self.failure_threshold:
|
||||
self.state = CircuitBreakerState.OPEN
|
||||
logger.warning(
|
||||
f"⚡ Circuit breaker OPEN: {self.failure_count}회 연속 실패. "
|
||||
f"{self.recovery_timeout}초 후 재시도"
|
||||
@@ -202,7 +203,7 @@ class KoreaInvestmentAPI:
|
||||
instance.semaphore = asyncio.Semaphore(settings.API_MAX_CONCURRENT_REQUESTS)
|
||||
instance._token_lock = asyncio.Lock()
|
||||
instance.monitor = APICallMonitor(window_size=settings.API_MONITORING_WINDOW)
|
||||
instance.circuit_breaker = CircuitBreaker(failure_threshold=5, recovery_timeout=600)
|
||||
instance.circuit_breaker = CircuitBreaker(failure_threshold=15, recovery_timeout=120)
|
||||
logger.info(
|
||||
"🔧 KIS 실계좌 시세조회 API 초기화: base_url=%s, app_key=%s...",
|
||||
instance.base_url, instance.app_key[:6]
|
||||
@@ -229,6 +230,9 @@ class KoreaInvestmentAPI:
|
||||
# 동시 요청 제한 (세마포어)
|
||||
self.semaphore = asyncio.Semaphore(settings.API_MAX_CONCURRENT_REQUESTS)
|
||||
|
||||
# httpx 싱글톤 클라이언트 (커넥션 풀 재활용 — 매 요청마다 TCP/TLS 핸드셰이크 방지)
|
||||
self._http_client: Optional[httpx.AsyncClient] = None
|
||||
|
||||
# 토큰 발급 Lock (중복 발급 방지)
|
||||
self._token_lock = asyncio.Lock()
|
||||
|
||||
@@ -236,7 +240,7 @@ class KoreaInvestmentAPI:
|
||||
self.monitor = APICallMonitor(window_size=settings.API_MONITORING_WINDOW)
|
||||
|
||||
# Circuit Breaker (5회 연속 실패 → 10분 차단)
|
||||
self.circuit_breaker = CircuitBreaker(failure_threshold=5, recovery_timeout=600)
|
||||
self.circuit_breaker = CircuitBreaker(failure_threshold=15, recovery_timeout=120)
|
||||
|
||||
# 시작 시 설정 로깅 (키 마스킹)
|
||||
logger.info(f"🔧 KIS API 초기화: mode={settings.KIS_MODE}, "
|
||||
@@ -288,84 +292,115 @@ class KoreaInvestmentAPI:
|
||||
await asyncio.to_thread(self._write_token_file)
|
||||
except Exception as e:
|
||||
logger.warning(f"⚠️ 토큰 캐시 파일 저장 실패: {e}")
|
||||
|
||||
async def _get_access_token(self) -> str:
|
||||
"""접근 토큰 발급 (Lock으로 중복 발급 방지)"""
|
||||
# 캐시된 토큰이 유효하면 재사용 (23시간 유효)
|
||||
|
||||
def _check_token_cache(self) -> Optional[str]:
|
||||
"""Check if cached token is still valid. Returns token if valid, None otherwise."""
|
||||
if self.access_token and self.token_expires_at and datetime.now() < self.token_expires_at:
|
||||
remaining = (self.token_expires_at - datetime.now()).total_seconds() / 3600
|
||||
logger.info(f"✅ 캐시된 토큰 재사용 (남은 시간: {remaining:.1f}시간)")
|
||||
return self.access_token
|
||||
|
||||
# 🔒 Lock 획득 - 동시 요청 시 하나만 토큰 발급
|
||||
async with self._token_lock:
|
||||
# Lock 대기 중 다른 요청이 토큰 발급했을 수 있으므로 재확인
|
||||
if self.access_token and self.token_expires_at and datetime.now() < self.token_expires_at:
|
||||
remaining = (self.token_expires_at - datetime.now()).total_seconds() / 3600
|
||||
logger.info(f"✅ 대기 중 토큰 발급됨, 재사용 (남은 시간: {remaining:.1f}시간)")
|
||||
return self.access_token
|
||||
|
||||
# 토큰 발급 시도 전에 마지막 시도 시간 확인 (1분 제한 대응)
|
||||
if hasattr(self, '_last_token_request_time'):
|
||||
elapsed = (datetime.now() - self._last_token_request_time).total_seconds()
|
||||
if elapsed < 70: # 70초 대기 (여유있게)
|
||||
# 기존 토큰이 있으면 계속 사용
|
||||
if self.access_token:
|
||||
logger.warning(f"⏳ 토큰 발급 1분 제한 ({elapsed:.0f}초 경과) - 기존 토큰 재사용")
|
||||
# 만료 시간 연장 (추가 1시간)
|
||||
self.token_expires_at = datetime.now() + timedelta(hours=1)
|
||||
return self.access_token
|
||||
|
||||
wait_time = 70 - elapsed
|
||||
logger.warning(f"⏳ 토큰 발급 1분 제한: {wait_time:.1f}초 대기 중...")
|
||||
await asyncio.sleep(wait_time + 1)
|
||||
|
||||
logger.info("🔑 새로운 토큰 발급 요청...")
|
||||
self._last_token_request_time = datetime.now()
|
||||
return None
|
||||
|
||||
# HTTP 요청도 Lock 안에서 실행 — 중복 발급 방지
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.post(
|
||||
f"{self.base_url}/oauth2/tokenP",
|
||||
json={
|
||||
"grant_type": "client_credentials",
|
||||
"appkey": self.app_key,
|
||||
"appsecret": self.app_secret
|
||||
},
|
||||
timeout=30.0
|
||||
)
|
||||
async def _handle_rate_limit(self) -> Optional[str]:
|
||||
"""Handle 1-minute rate limit for token requests.
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
self.access_token = data["access_token"]
|
||||
# 토큰 만료 시간 설정 (일반적으로 24시간)
|
||||
self.token_expires_at = datetime.now() + timedelta(hours=23)
|
||||
|
||||
# 파일에 저장 (비동기) — Lock 안에서 호출하되 내부는 to_thread이므로 OK
|
||||
await self._save_token_to_cache()
|
||||
|
||||
logger.info(f"✅ 새 토큰 발급 완료 (만료: {self.token_expires_at.strftime('%Y-%m-%d %H:%M')})")
|
||||
Returns token if can reuse existing token during rate limit, or sleeps appropriately.
|
||||
토큰이 이미 만료된 경우에는 연장하지 않고 재발급을 트리거한다.
|
||||
"""
|
||||
if hasattr(self, '_last_token_request_time'):
|
||||
elapsed = (datetime.now() - self._last_token_request_time).total_seconds()
|
||||
if elapsed < 70: # 70초 대기 (여유있게)
|
||||
# 기존 토큰이 있고, 아직 만료 전이면 재사용
|
||||
if self.access_token and self.token_expires_at and datetime.now() < self.token_expires_at:
|
||||
logger.warning(f"⏳ 토큰 발급 1분 제한 ({elapsed:.0f}초 경과) - 기존 토큰 재사용 (만료까지 {(self.token_expires_at - datetime.now()).total_seconds() / 60:.0f}분)")
|
||||
return self.access_token
|
||||
else:
|
||||
error_msg = response.text
|
||||
logger.error(f"❌ 토큰 발급 실패: {response.status_code} - {error_msg}")
|
||||
|
||||
# 토큰 발급 실패 시에도 기존 토큰이 있다면 계속 사용 시도
|
||||
if self.access_token:
|
||||
logger.warning(f"⚠️ 토큰 발급 실패했지만 기존 토큰 재사용 시도")
|
||||
# 만료 시간 연장
|
||||
self.token_expires_at = datetime.now() + timedelta(hours=1)
|
||||
return self.access_token
|
||||
raise Exception(f"Failed to get access token: {error_msg}")
|
||||
except Exception as e:
|
||||
logger.error(f"❌ 토큰 요청 중 오류: {e}")
|
||||
# 기존 토큰이 있으면 사용
|
||||
# 토큰이 없거나 이미 만료 -> rate limit 해제까지 대기 후 재발급
|
||||
wait_time = 70 - elapsed
|
||||
logger.warning(f"⏳ 토큰 발급 1분 제한: {wait_time:.1f}초 대기 중... (기존 토큰 만료됨, 재발급 필요)")
|
||||
await asyncio.sleep(wait_time + 1)
|
||||
|
||||
return None
|
||||
|
||||
async def _request_new_token(self) -> str:
|
||||
"""Request new access token from KIS API.
|
||||
|
||||
Returns access token on success, raises exception on failure.
|
||||
"""
|
||||
logger.info("🔑 새로운 토큰 발급 요청...")
|
||||
self._last_token_request_time = datetime.now()
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.post(
|
||||
f"{self.base_url}/oauth2/tokenP",
|
||||
json={
|
||||
"grant_type": "client_credentials",
|
||||
"appkey": self.app_key,
|
||||
"appsecret": self.app_secret
|
||||
},
|
||||
timeout=30.0
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
self.access_token = data["access_token"]
|
||||
# 토큰 만료 시간 설정 (일반적으로 24시간)
|
||||
self.token_expires_at = datetime.now() + timedelta(hours=23)
|
||||
|
||||
# 파일에 저장 (비동기) — Lock 안에서 호출하되 내부는 to_thread이므로 OK
|
||||
await self._save_token_to_cache()
|
||||
|
||||
logger.info(f"✅ 새 토큰 발급 완료 (만료: {self.token_expires_at.strftime('%Y-%m-%d %H:%M')})")
|
||||
return self.access_token
|
||||
else:
|
||||
error_msg = response.text
|
||||
logger.error(f"❌ 토큰 발급 실패: {response.status_code} - {error_msg}")
|
||||
|
||||
# 토큰 발급 실패 시에도 기존 토큰이 있다면 계속 사용 시도
|
||||
if self.access_token:
|
||||
logger.warning(f"⚠️ 토큰 요청 실패, 기존 토큰 재사용")
|
||||
logger.warning(f"⚠️ 토큰 발급 실패했지만 기존 토큰 재사용 시도")
|
||||
# 만료 시간 연장
|
||||
self.token_expires_at = datetime.now() + timedelta(hours=1)
|
||||
return self.access_token
|
||||
raise
|
||||
raise Exception(f"Failed to get access token: {error_msg}")
|
||||
except Exception as e:
|
||||
logger.error(f"❌ 토큰 요청 중 오류: {e}")
|
||||
# 기존 토큰이 있으면 사용
|
||||
if self.access_token:
|
||||
logger.warning(f"⚠️ 토큰 요청 실패, 기존 토큰 재사용")
|
||||
self.token_expires_at = datetime.now() + timedelta(hours=1)
|
||||
return self.access_token
|
||||
raise
|
||||
|
||||
async def _get_access_token(self) -> str:
|
||||
"""접근 토큰 발급 (Lock으로 중복 발급 방지)
|
||||
|
||||
주요 단계:
|
||||
1. 캐시된 토큰 확인 (재사용 가능하면 반환)
|
||||
2. Lock 획득 후 재확인 (중복 발급 방지)
|
||||
3. Rate limit 처리 (1분 제한)
|
||||
4. 새 토큰 요청
|
||||
"""
|
||||
# 1. Check cached token (fast path)
|
||||
cached_token = self._check_token_cache()
|
||||
if cached_token:
|
||||
return cached_token
|
||||
|
||||
# 2. Lock 획득 - 동시 요청 시 하나만 토큰 발급
|
||||
async with self._token_lock:
|
||||
# Lock 대기 중 다른 요청이 토큰 발급했을 수 있으므로 재확인
|
||||
cached_token = self._check_token_cache()
|
||||
if cached_token:
|
||||
return cached_token
|
||||
|
||||
# 3. Handle rate limit (1-minute restriction)
|
||||
rate_limited_token = await self._handle_rate_limit()
|
||||
if rate_limited_token:
|
||||
return rate_limited_token
|
||||
|
||||
# 4. Request new token
|
||||
return await self._request_new_token()
|
||||
|
||||
async def _make_request(self, method: str, endpoint: str, tr_id: str, extra_headers: Optional[Dict[str, str]] = None, **kwargs) -> (Dict[str, Any], Any):
|
||||
"""API 요청 실행 (Circuit Breaker + 세마포어 + 재시도 + 모니터링)"""
|
||||
@@ -412,71 +447,42 @@ class KoreaInvestmentAPI:
|
||||
# 타임아웃 설정
|
||||
timeout = httpx.Timeout(self.timeout, connect=10.0)
|
||||
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
async with httpx.AsyncClient(timeout=httpx.Timeout(10.0, connect=5.0)) as client:
|
||||
response = await client.request(
|
||||
method,
|
||||
f"{self.base_url}{endpoint}",
|
||||
headers=headers,
|
||||
**kwargs
|
||||
)
|
||||
|
||||
|
||||
# 성공
|
||||
if response.status_code == 200:
|
||||
duration = time.time() - start_time
|
||||
self.monitor.record_call(success=True, duration=duration, retries=retry_count)
|
||||
self.circuit_breaker.record_success()
|
||||
|
||||
# 오류율 경고
|
||||
if self.monitor.should_alert(threshold=settings.API_ERROR_THRESHOLD):
|
||||
logger.warning(
|
||||
f"⚠️ API 오류율 높음: {self.monitor.get_error_rate():.1%} "
|
||||
f"(임계값: {settings.API_ERROR_THRESHOLD:.1%})"
|
||||
)
|
||||
|
||||
logger.warning(f"⚠️ API 오류율 높음: {self.monitor.get_error_rate():.1%}")
|
||||
return response.json(), response.headers
|
||||
|
||||
# API 호출 제한 초과 (429) 또는 서버 오류 (5xx)
|
||||
# 서버 오류 (5xx, 429) — 재시도
|
||||
elif response.status_code in [429, 500, 502, 503, 504]:
|
||||
retry_count += 1
|
||||
last_exception = Exception(
|
||||
f"API rate limit or server error: {response.status_code} - {response.text}"
|
||||
)
|
||||
|
||||
if attempt < self.max_retries:
|
||||
# 지수 백오프 적용
|
||||
delay = self.retry_delay * (self.retry_backoff ** attempt)
|
||||
logger.warning(
|
||||
f"⏳ API 재시도 {attempt + 1}/{self.max_retries}: "
|
||||
f"상태코드 {response.status_code}, {delay:.1f}초 후 재시도"
|
||||
)
|
||||
logger.warning(f"⏳ API 재시도 {attempt + 1}/{self.max_retries}: {response.status_code}, {delay:.1f}초 후")
|
||||
await asyncio.sleep(delay)
|
||||
continue
|
||||
else:
|
||||
self.circuit_breaker.record_failure()
|
||||
logger.error(f"❌ API 최대 재시도 횟수 초과: {response.status_code}")
|
||||
raise last_exception
|
||||
|
||||
# 인증 오류 또는 잘못된 요청 (4xx) - 재시도 불필요
|
||||
elif 400 <= response.status_code < 500:
|
||||
duration = time.time() - start_time
|
||||
self.monitor.record_call(success=False, duration=duration, retries=retry_count)
|
||||
|
||||
error_msg = f"API 클라이언트 오류 (재시도 안함): {response.status_code} - {response.text}"
|
||||
logger.error(f"❌ {error_msg}")
|
||||
# 에러 응답도 json과 headers 튜플로 반환 시도
|
||||
raise Exception(f"API 최대 재시도 초과: {response.status_code}")
|
||||
|
||||
# 클라이언트 오류 (4xx) — 재시도 불필요
|
||||
else:
|
||||
self.monitor.record_call(success=False, duration=time.time() - start_time, retries=retry_count)
|
||||
try:
|
||||
return response.json(), response.headers
|
||||
except Exception:
|
||||
return {"rt_cd": str(response.status_code), "msg1": error_msg, "output": None}, None
|
||||
|
||||
# 기타 오류
|
||||
else:
|
||||
duration = time.time() - start_time
|
||||
self.monitor.record_call(success=False, duration=duration, retries=retry_count)
|
||||
|
||||
error_msg = f"API 요청 실패: {response.status_code} - {response.text}"
|
||||
logger.error(f"❌ {error_msg}")
|
||||
raise Exception(error_msg)
|
||||
return {"rt_cd": str(response.status_code), "msg1": response.text[:200], "output": None}, None
|
||||
|
||||
except httpx.TimeoutException as e:
|
||||
retry_count += 1
|
||||
@@ -751,6 +757,7 @@ class KoreaInvestmentAPI:
|
||||
|
||||
# ── 2. KIS API 호출 ───────────────────────────────────────────
|
||||
params = {
|
||||
"FID_COND_MRKT_DIV_CODE": "J",
|
||||
"FID_ETC_CLS_CODE": "",
|
||||
"FID_INPUT_ISCD": stock_code,
|
||||
"FID_INPUT_HOUR_1": ref_time,
|
||||
|
||||
Reference in New Issue
Block a user