fix: 자동매매 신뢰성·소액계좌 안전배치 5종
감사에서 드러난 신뢰성·조용한실패 버그 수정. 전략값(§5)·시그니처(§4) 불변. ① KIS API 신뢰성(korea_investment_api, config): 스로틀에 _throttle_lock 추가로 thundering herd(8코루틴 동시발사) 제거, API_TIMEOUT 10→20초, circuit_breaker 임계 15→7, 재시도 백오프 지터. → 시간당 121건 실패의 근원 완화. ② portfolio summary 보존(portfolio): 잔고 API 실패 시 summary가 영영 None으로 남아 현금 0→매수 스킵되던 것을, 최초 실패 시 holdings 기반 부분 summary로 채움. ③ output2 빈리스트 가드: KIS가 output2=[] 반환 시 IndexError로 MDD 업데이트가 조용히 스킵(하방보호 비활성)되던 것 방어. swing/scheduler·adaptive_strategy (실버그) + daytrading 2곳(정규화). trade_executor:555는 load-bearing 가드라 의도적 미변경. ④ MIN_BUY_AMOUNT config화+소액완화(trade_executor, config): 하드코딩 50만을 MIN_BUY_AMOUNT_KRW로. 자산 833만↓ 계좌가 전종목 차단되던 것을 자산 대비로 완화(큰 계좌는 기존 50만 하한 불변). ⑤ adaptive 서브버그(adaptive_strategy): fallback 시 _last_update_ts 갱신(refresh 남발 방지), force_mode_override 전체필드(crisis의 max_daily_trades=0 실제 적용), get_event_loop→get_running_loop. 검증: code-verifier 통과(CONFIRMED 버그/회귀 없음, 4개 위험지점 안전), pytest 442 통과. (day_trading.py·entry_service.py는 소량 인접 선재편집 포함 — 검증 완료.) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HBmzf1HDnvFHLraJ8eaaKJ
This commit is contained in:
@@ -13,6 +13,7 @@ from typing import Dict, Any, Optional, Deque
|
||||
from datetime import datetime, timedelta
|
||||
import logging
|
||||
import asyncio
|
||||
import random
|
||||
import time
|
||||
import json
|
||||
import os
|
||||
@@ -194,6 +195,9 @@ class KoreaInvestmentAPI:
|
||||
instance.access_token = None
|
||||
instance.token_expires_at = None
|
||||
instance.last_request_time = None
|
||||
instance._throttle_lock = asyncio.Lock()
|
||||
instance._http_client = None
|
||||
instance._http_client_lock = asyncio.Lock()
|
||||
instance.TOKEN_CACHE_FILE = Path.home() / ".cache" / "auto-stock" / "kis_token_cache_real.json"
|
||||
instance.min_request_interval = settings.API_MIN_REQUEST_INTERVAL
|
||||
instance.max_retries = settings.API_MAX_RETRIES
|
||||
@@ -203,7 +207,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=15, recovery_timeout=120)
|
||||
instance.circuit_breaker = CircuitBreaker(failure_threshold=7, recovery_timeout=120)
|
||||
logger.info(
|
||||
"🔧 KIS 실계좌 시세조회 API 초기화: base_url=%s, app_key=%s...",
|
||||
instance.base_url, instance.app_key[:6]
|
||||
@@ -219,6 +223,9 @@ class KoreaInvestmentAPI:
|
||||
self.access_token: Optional[str] = None
|
||||
self.token_expires_at: Optional[datetime] = None
|
||||
self.last_request_time: Optional[float] = None
|
||||
# 스로틀 계산(간격 측정+sleep+last_request_time 갱신)을 직렬화하는 락
|
||||
# (thundering herd 방지: 여러 코루틴이 동시에 깨어나 동시에 요청 쏘는 것 방지)
|
||||
self._throttle_lock = asyncio.Lock()
|
||||
|
||||
# 설정 기반 쓰로틀링 파라미터
|
||||
self.min_request_interval = settings.API_MIN_REQUEST_INTERVAL # 기본 0.5초 (초당 2회)
|
||||
@@ -240,8 +247,8 @@ class KoreaInvestmentAPI:
|
||||
# 모니터링
|
||||
self.monitor = APICallMonitor(window_size=settings.API_MONITORING_WINDOW)
|
||||
|
||||
# Circuit Breaker (5회 연속 실패 → 10분 차단)
|
||||
self.circuit_breaker = CircuitBreaker(failure_threshold=15, recovery_timeout=120)
|
||||
# Circuit Breaker (7회 연속 실패 → 2분 차단)
|
||||
self.circuit_breaker = CircuitBreaker(failure_threshold=7, recovery_timeout=120)
|
||||
|
||||
# 시작 시 설정 로깅 (키 마스킹)
|
||||
logger.info(f"🔧 KIS API 초기화: mode={settings.KIS_MODE}, "
|
||||
@@ -446,13 +453,16 @@ class KoreaInvestmentAPI:
|
||||
for attempt in range(self.max_retries + 1):
|
||||
try:
|
||||
# 쓰로틀링: 최소 요청 간격 보장
|
||||
if self.last_request_time:
|
||||
elapsed = time.time() - self.last_request_time
|
||||
if elapsed < self.min_request_interval:
|
||||
await asyncio.sleep(self.min_request_interval - elapsed)
|
||||
|
||||
self.last_request_time = time.time()
|
||||
|
||||
# _throttle_lock으로 "간격 계산 + sleep + last_request_time 갱신"을 직렬화하여
|
||||
# 여러 코루틴이 락 없이 동시에 깨어나 한꺼번에 요청을 쏘는 thundering herd를 방지한다.
|
||||
async with self._throttle_lock:
|
||||
if self.last_request_time:
|
||||
elapsed = time.time() - self.last_request_time
|
||||
if elapsed < self.min_request_interval:
|
||||
await asyncio.sleep(self.min_request_interval - elapsed)
|
||||
|
||||
self.last_request_time = time.time()
|
||||
|
||||
# 토큰 갱신
|
||||
token = await self._get_access_token()
|
||||
|
||||
@@ -474,6 +484,7 @@ class KoreaInvestmentAPI:
|
||||
method,
|
||||
f"{self.base_url}{endpoint}",
|
||||
headers=headers,
|
||||
timeout=timeout,
|
||||
**kwargs
|
||||
)
|
||||
|
||||
@@ -490,7 +501,7 @@ class KoreaInvestmentAPI:
|
||||
elif response.status_code in [429, 500, 502, 503, 504]:
|
||||
retry_count += 1
|
||||
if attempt < self.max_retries:
|
||||
delay = self.retry_delay * (self.retry_backoff ** attempt)
|
||||
delay = self.retry_delay * (self.retry_backoff ** attempt) + random.uniform(0, 0.3)
|
||||
logger.warning(f"⏳ API 재시도 {attempt + 1}/{self.max_retries}: {response.status_code}, {delay:.1f}초 후")
|
||||
await asyncio.sleep(delay)
|
||||
continue
|
||||
@@ -511,7 +522,7 @@ class KoreaInvestmentAPI:
|
||||
last_exception = e
|
||||
|
||||
if attempt < self.max_retries:
|
||||
delay = self.retry_delay * (self.retry_backoff ** attempt)
|
||||
delay = self.retry_delay * (self.retry_backoff ** attempt) + random.uniform(0, 0.3)
|
||||
logger.warning(f"⏳ API 타임아웃, {delay:.1f}초 후 재시도 ({attempt + 1}/{self.max_retries})")
|
||||
await asyncio.sleep(delay)
|
||||
continue
|
||||
@@ -930,12 +941,13 @@ class KoreaInvestmentAPI:
|
||||
) -> Dict[str, Any]:
|
||||
"""해외주식 주문 (미국주식 기준)"""
|
||||
try:
|
||||
order_type = order_type.upper() # 방어적 정규화: 소문자 "buy"/"sell" 전달 시 잘못된 TR_ID 방지
|
||||
account_number_prefix = self.account_number[:8]
|
||||
account_number_suffix = self.account_number[8:]
|
||||
|
||||
|
||||
# 모의/실전 구분
|
||||
is_virtual = "vts" in self.base_url.lower()
|
||||
|
||||
|
||||
# TR_ID 설정 (미국 매수/매도)
|
||||
if order_type == "BUY":
|
||||
tr_id = "VTTT1002U" if is_virtual else "TTTT1002U"
|
||||
@@ -990,8 +1002,8 @@ class KoreaInvestmentAPI:
|
||||
"CTX_AREA_NK100": ""
|
||||
}
|
||||
)
|
||||
logger.info(f"📋 Balance API Full Response: {result}")
|
||||
logger.info(f"📋 Balance API Keys: {result.keys()}")
|
||||
logger.debug(f"📋 Balance API Full Response: {result}")
|
||||
logger.debug(f"📋 Balance API Keys: {result.keys()}")
|
||||
if result.get('rt_cd') != '0':
|
||||
logger.error(f"❌ Balance API error - rt_cd={result.get('rt_cd')}, msg1={result.get('msg1')}")
|
||||
return result
|
||||
|
||||
Reference in New Issue
Block a user