import httpx from typing import Dict, Any, Optional, Deque from datetime import datetime import logging import asyncio import time from collections import deque from app.core.config import settings logger = logging.getLogger(__name__) class APICallMonitor: """API 호출 모니터링 및 통계""" def __init__(self, window_size: int = 100): self.window_size = window_size self.call_history: Deque[Dict[str, Any]] = deque(maxlen=window_size) self.total_calls = 0 self.total_errors = 0 self.total_retries = 0 def record_call(self, success: bool, duration: float, retries: int = 0): """API 호출 기록""" self.total_calls += 1 if not success: self.total_errors += 1 self.total_retries += retries self.call_history.append({ "timestamp": time.time(), "success": success, "duration": duration, "retries": retries }) def get_error_rate(self) -> float: """최근 윈도우 내 오류율 계산""" if not self.call_history: return 0.0 errors = sum(1 for call in self.call_history if not call["success"]) return errors / len(self.call_history) def get_avg_duration(self) -> float: """평균 응답 시간""" if not self.call_history: return 0.0 return sum(call["duration"] for call in self.call_history) / len(self.call_history) def get_stats(self) -> Dict[str, Any]: """통계 정보 반환""" return { "total_calls": self.total_calls, "total_errors": self.total_errors, "total_retries": self.total_retries, "error_rate": self.get_error_rate(), "avg_duration": self.get_avg_duration(), "recent_calls": len(self.call_history) } def should_alert(self, threshold: float = 0.3) -> bool: """오류율 임계값 초과 여부""" return self.get_error_rate() > threshold class KoreaInvestmentAPI: """한국투자증권 API 클라이언트 (안전한 재시도 및 쓰로틀링 적용)""" def __init__(self): self.base_url = settings.KOREA_INVESTMENT_API_BASE_URL self.app_key = settings.KOREA_INVESTMENT_APP_KEY self.app_secret = settings.KOREA_INVESTMENT_APP_SECRET self.account_number = settings.KOREA_INVESTMENT_ACCOUNT_NUMBER self.access_token: Optional[str] = None self.token_expires_at: Optional[datetime] = None self.last_request_time: Optional[float] = None # 설정 기반 쓰로틀링 파라미터 self.min_request_interval = settings.API_MIN_REQUEST_INTERVAL # 기본 0.5초 (초당 2회) self.max_retries = settings.API_MAX_RETRIES # 기본 3회 self.retry_delay = settings.API_RETRY_DELAY # 기본 1.5초 self.retry_backoff = settings.API_RETRY_BACKOFF # 기본 2.0배 self.timeout = settings.API_TIMEOUT # 기본 30초 # 동시 요청 제한 (세마포어) self.semaphore = asyncio.Semaphore(settings.API_MAX_CONCURRENT_REQUESTS) # 모니터링 self.monitor = APICallMonitor(window_size=settings.API_MONITORING_WINDOW) async def _get_access_token(self) -> str: """접근 토큰 발급""" if self.access_token and self.token_expires_at and datetime.now() < self.token_expires_at: return self.access_token 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 } ) if response.status_code == 200: data = response.json() self.access_token = data["access_token"] # 토큰 만료 시간 설정 (일반적으로 24시간) from datetime import timedelta self.token_expires_at = datetime.now() + timedelta(hours=23) return self.access_token else: raise Exception(f"Failed to get access token: {response.text}") async def _make_request(self, method: str, endpoint: str, tr_id: str, **kwargs) -> Dict[str, Any]: """API 요청 실행 (안전한 재시도 + 쓰로틀링 + 모니터링)""" # 동시 요청 수 제한 (세마포어) async with self.semaphore: return await self._make_request_with_retry(method, endpoint, tr_id, **kwargs) async def _make_request_with_retry(self, method: str, endpoint: str, tr_id: str, **kwargs) -> Dict[str, Any]: """재시도 로직을 포함한 실제 요청 실행""" start_time = time.time() retry_count = 0 last_exception = None 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() # 토큰 갱신 token = await self._get_access_token() headers = { "content-type": "application/json; charset=utf-8", "authorization": f"Bearer {token}", "appkey": self.app_key, "appsecret": self.app_secret, "tr_id": tr_id, } # 타임아웃 설정 timeout = httpx.Timeout(self.timeout, connect=10.0) async with httpx.AsyncClient(timeout=timeout) 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) # 오류율 경고 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%})" ) return response.json() # API 호출 제한 초과 (429) 또는 서버 오류 (5xx) 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}초 후 재시도" ) await asyncio.sleep(delay) continue else: 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}") raise Exception(error_msg) # 기타 오류 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) except httpx.TimeoutException as e: retry_count += 1 last_exception = e if attempt < self.max_retries: delay = self.retry_delay * (self.retry_backoff ** attempt) logger.warning(f"⏳ API 타임아웃, {delay:.1f}초 후 재시도 ({attempt + 1}/{self.max_retries})") await asyncio.sleep(delay) continue else: duration = time.time() - start_time self.monitor.record_call(success=False, duration=duration, retries=retry_count) logger.error(f"❌ API 타임아웃 최대 재시도 초과") raise except Exception as e: # 예상치 못한 오류 duration = time.time() - start_time self.monitor.record_call(success=False, duration=duration, retries=retry_count) logger.error(f"❌ API 요청 중 예외 발생: {str(e)}") raise # 여기까지 왔다면 모든 재시도 실패 duration = time.time() - start_time self.monitor.record_call(success=False, duration=duration, retries=retry_count) if last_exception: raise last_exception raise Exception("API 요청 실패 (알 수 없는 오류)") async def get_current_price(self, stock_code: str) -> Dict[str, Any]: """현재가 조회""" try: result = await self._make_request( "GET", "/uapi/domestic-stock/v1/quotations/inquire-price", "FHKST01010100", params={ "FID_COND_MRKT_DIV_CODE": "J", "FID_INPUT_ISCD": stock_code } ) return result except Exception as e: logger.error(f"Error fetching price for {stock_code}: {e}") raise async def get_daily_chart(self, stock_code: str, period: str = "D") -> Dict[str, Any]: """일봉/주봉/월봉 조회 KIS API는 기본적으로 최근 30~100개 데이터를 반환 더 많은 데이터가 필요하면 반복 호출 필요 (페이지네이션 없음) """ try: result = await self._make_request( "GET", "/uapi/domestic-stock/v1/quotations/inquire-daily-price", "FHKST01010400", params={ "FID_COND_MRKT_DIV_CODE": "J", "FID_INPUT_ISCD": stock_code, "FID_PERIOD_DIV_CODE": period, "FID_ORG_ADJ_PRC": "0" # 0: 수정주가, 1: 원주가 } ) return result except Exception as e: logger.error(f"Error fetching chart for {stock_code}: {e}") raise async def get_balance(self) -> Dict[str, Any]: """계좌 잔고 조회""" try: account_number_prefix = self.account_number[:8] account_number_suffix = self.account_number[8:] result = await self._make_request( "GET", "/uapi/domestic-stock/v1/trading/inquire-balance", "TTTC8434R", params={ "CANO": account_number_prefix, "ACNT_PRDT_CD": account_number_suffix, "AFHR_FLPR_YN": "N", "OFL_YN": "", "INQR_DVSN": "02", "UNPR_DVSN": "01", "FUND_STTL_ICLD_YN": "N", "FNCG_AMT_AUTO_RDPT_YN": "N", "PRCS_DVSN": "01", "CTX_AREA_FK100": "", "CTX_AREA_NK100": "" } ) return result except Exception as e: logger.error(f"Error fetching balance: {e}") raise async def place_order( self, stock_code: str, order_type: str, # "BUY" or "SELL" quantity: int, price: Optional[float] = None # None for market order ) -> Dict[str, Any]: """주문 실행""" try: account_number_prefix = self.account_number[:8] account_number_suffix = self.account_number[8:] # 매수/매도 구분 if order_type == "BUY": tr_id = "TTTC0802U" # 현금 매수 else: tr_id = "TTTC0801U" # 현금 매도 # 주문 유형 (00: 지정가, 01: 시장가) order_dvsn = "01" if price is None else "00" order_price = "0" if price is None else str(int(price)) result = await self._make_request( "POST", "/uapi/domestic-stock/v1/trading/order-cash", tr_id, json={ "CANO": account_number_prefix, "ACNT_PRDT_CD": account_number_suffix, "PDNO": stock_code, "ORD_DVSN": order_dvsn, "ORD_QTY": str(quantity), "ORD_UNPR": order_price } ) return result except Exception as e: logger.error(f"Error placing order: {e}") raise async def cancel_order(self, order_id: str, quantity: int) -> Dict[str, Any]: """주문 취소""" try: account_number_prefix = self.account_number[:8] account_number_suffix = self.account_number[8:] result = await self._make_request( "POST", "/uapi/domestic-stock/v1/trading/order-rvsecncl", "TTTC0803U", json={ "CANO": account_number_prefix, "ACNT_PRDT_CD": account_number_suffix, "KRX_FWDG_ORD_ORGNO": "", "ORGN_ODNO": order_id, "ORD_DVSN": "00", "RVSE_CNCL_DVSN_CD": "02", # 취소 "ORD_QTY": "0", "ORD_UNPR": "0", "QTY_ALL_ORD_YN": "Y" } ) return result except Exception as e: logger.error(f"Error canceling order: {e}") raise async def search_stocks_by_condition( self, market: str = "J", # J: 코스피, Q: 코스닥 condition_name: str = "", max_results: int = 100 ) -> Dict[str, Any]: """조건 검색으로 종목 조회""" try: result = await self._make_request( "GET", "/uapi/domestic-stock/v1/quotations/inquire-search", "HHKST03900300", params={ "FID_COND_MRKT_DIV_CODE": market, "FID_COND_SCR_DIV_CODE": "20171", # 조건검색 "FID_INPUT_ISCD": "0000", "FID_DIV_CLS_CODE": "0", "FID_BLNG_CLS_CODE": "0", "FID_TRGT_CLS_CODE": "111111111", "FID_TRGT_EXLS_CLS_CODE": "000000", "FID_INPUT_PRICE_1": "", "FID_INPUT_PRICE_2": "", "FID_VOL_CNT": "", "FID_INPUT_DATE_1": "" } ) return result except Exception as e: logger.error(f"Error searching stocks: {e}") raise async def get_stock_list(self, market: str = "J") -> Dict[str, Any]: """전체 종목 리스트 조회""" try: # 거래대금 상위 종목 조회 result = await self._make_request( "GET", "/uapi/domestic-stock/v1/quotations/volume-rank", "FHPST01710000", params={ "FID_COND_MRKT_DIV_CODE": market, "FID_COND_SCR_DIV_CODE": "20171", "FID_INPUT_ISCD": "0000", "FID_DIV_CLS_CODE": "0", "FID_BLNG_CLS_CODE": "0", "FID_TRGT_CLS_CODE": "111111111", "FID_TRGT_EXLS_CLS_CODE": "000000", "FID_INPUT_PRICE_1": "", "FID_INPUT_PRICE_2": "", "FID_VOL_CNT": "", "FID_INPUT_DATE_1": "" } ) return result except Exception as e: logger.error(f"Error getting stock list: {e}") raise async def get_volume_rank(self, market: str = "J") -> Dict[str, Any]: """거래량 순위 조회 (get_stock_list와 동일하지만 명시적 이름)""" return await self.get_stock_list(market) def get_monitor_stats(self) -> Dict[str, Any]: """API 호출 모니터링 통계 반환""" return self.monitor.get_stats() def log_monitor_stats(self): """모니터링 통계 로깅""" stats = self.get_monitor_stats() logger.info( f"📊 API 통계: 총 {stats['total_calls']}건, " f"오류 {stats['total_errors']}건 ({stats['error_rate']:.1%}), " f"재시도 {stats['total_retries']}건, " f"평균 응답 {stats['avg_duration']:.2f}초" ) # 싱글톤 인스턴스 korea_investment_api = KoreaInvestmentAPI()