From 82346fcc46e9f6e6a643782436b61785b68f257a Mon Sep 17 00:00:00 2001 From: YoungWooSeo Date: Thu, 26 Feb 2026 13:36:06 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=B0=A8=ED=8A=B8=20API=EC=97=90=20Red?= =?UTF-8?q?is=20=EC=BA=90=EC=8B=9C=20=EB=A0=88=EC=9D=B4=EC=96=B4=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20(=EC=9D=BC=EB=B4=89/=EB=B6=84=EB=B4=89/?= =?UTF-8?q?=ED=95=B4=EC=99=B8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/services/korea_investment_api.py | 175 ++++++++++++++----- 1 file changed, 130 insertions(+), 45 deletions(-) diff --git a/backend/app/services/korea_investment_api.py b/backend/app/services/korea_investment_api.py index ce65d4b..d0cea99 100644 --- a/backend/app/services/korea_investment_api.py +++ b/backend/app/services/korea_investment_api.py @@ -12,6 +12,12 @@ from app.core.config import settings logger = logging.getLogger(__name__) +# ── 차트 캐시 TTL 상수 ──────────────────────────────────────────────────────── +TTL_DAILY_CHART = 300 # 일봉 캐시 5분 (장중 업데이트 주기 고려) +TTL_MINUTE_CHART = 60 # 분봉 캐시 1분 (실시간성 유지) +TTL_MINUTE_FULLDAY = 60 # 당일 전체 분봉 캐시 1분 +TTL_OVERSEAS_CHART = 300 # 해외 일봉 캐시 5분 + class APICallMonitor: """API 호출 모니터링 및 통계""" @@ -66,6 +72,26 @@ class APICallMonitor: return self.get_error_rate() > threshold +# ── Redis 캐시 헬퍼 (redis.py 의존성 없이 안전하게 호출) ────────────────────── + +async def _cache_get(key: str) -> Optional[Any]: + """Redis 캐시에서 값 조회. Redis 미연결 시 None 반환 (non-blocking).""" + try: + from app.core.redis import get_json + return await get_json(key) + except Exception: + return None + + +async def _cache_set(key: str, ttl: int, data: Any) -> None: + """Redis 캐시에 값 저장. 실패 시 조용히 무시 (non-blocking).""" + try: + from app.core.redis import setex_json + await setex_json(key, ttl, data) + except Exception: + pass + + class KoreaInvestmentAPI: """한국투자증권 API 클라이언트 (안전한 재시도 및 쓰로틀링 적용)""" @@ -376,10 +402,22 @@ class KoreaInvestmentAPI: raise async def get_daily_chart(self, stock_code: str, period: str = "D", limit: int = 240) -> Dict[str, Any]: - """일봉/주봉/월봉 조회 (페이지네이션 지원으로 최대 240개까지 조회)""" + """일봉/주봉/월봉 조회 (Redis 캐시 적용, 페이지네이션 지원으로 최대 240개까지 조회) + + 캐시 키: chart:daily:{stock_code}:{period}:{limit} + TTL: 5분 (장중 업데이트 주기 고려) + """ + cache_key = f"chart:daily:{stock_code}:{period}:{limit}" + + # ── 1. 캐시 히트 확인 ─────────────────────────────────────────────── + cached = await _cache_get(cache_key) + if cached is not None: + logger.debug(f"📦 [캐시 히트] {cache_key}") + return cached + + # ── 2. KIS API 호출 ───────────────────────────────────────────────── all_output2 = [] - # 1. 첫 요청 params = { "FID_COND_MRKT_DIV_CODE": "J", "FID_INPUT_ISCD": stock_code, @@ -394,8 +432,6 @@ class KoreaInvestmentAPI: params=params ) - # FHKST01010400은 'output' (배열) 또는 'output2' (배열)로 데이터를 반환 - # 실전/모의투자 환경에 따라 키 이름이 다를 수 있음 data_key = 'output2' if isinstance(result.get('output2'), list) and result.get('output2') else \ 'output' if isinstance(result.get('output'), list) and result.get('output') else None @@ -406,9 +442,9 @@ class KoreaInvestmentAPI: all_output2.extend(result.get(data_key, [])) tr_cont = headers.get('tr_cont') - # 2. 연속 조회 (필요 시) + # 연속 조회 while tr_cont in ('F', 'M') and len(all_output2) < limit: - await asyncio.sleep(self.min_request_interval + 0.1) # Rate limit + await asyncio.sleep(self.min_request_interval + 0.1) extra_headers = {'tr_cont': 'Y'} result_cont, headers_cont = await self._make_request( @@ -425,12 +461,16 @@ class KoreaInvestmentAPI: tr_cont = headers_cont.get('tr_cont') if headers_cont else None - # 3. 최종 결과 조합 - final_result = result # 첫 페이지 응답을 기반으로 조합 + final_result = result final_result['output2'] = all_output2 final_result['msg1'] = f"OK (fetched {len(all_output2)} data points)" logger.info(f"✅ Fetched {len(all_output2)} chart data points for {stock_code}") + + # ── 3. 캐시 저장 ──────────────────────────────────────────────── + await _cache_set(cache_key, TTL_DAILY_CHART, final_result) + logger.debug(f"💾 [캐시 저장] {cache_key} TTL={TTL_DAILY_CHART}s") + return final_result except Exception as e: @@ -440,12 +480,8 @@ class KoreaInvestmentAPI: async def get_overseas_price(self, symbol: str, exchange_code: str = "NASD") -> Dict[str, Any]: """해외주식 현재가 조회 (NASD, NYSE, AMEX)""" try: - # 거래소 코드 보정 exch_map = {"NASD": "NAS", "NYSE": "NYS", "AMEX": "AMS"} formatted_exchange = exch_map.get(exchange_code.upper(), exchange_code.upper()) - - # 모의투자 여부에 따라 TR_ID가 다를 수 있는지 확인 필요하나, - # 시세 조회는 실전/모의 공통인 경우가 많음 (HHDFS00000300) result, _ = await self._make_request( "GET", @@ -453,7 +489,7 @@ class KoreaInvestmentAPI: "HHDFS00000300", params={ "AUTH": "", - "EXCD": exchange_code.upper(), # NASD, NYSE, AMEX + "EXCD": exchange_code.upper(), "SYMB": symbol.upper() } ) @@ -468,10 +504,14 @@ class KoreaInvestmentAPI: ref_time: str = "", include_past: bool = True, ) -> Dict[str, Any]: - """국내주식 분봉차트 조회 (FHKST03010200) + """국내주식 분봉차트 조회 (FHKST03010200, Redis 캐시 적용) + + 캐시 키: chart:minute:{stock_code}:{ref_time_bucket} + - ref_time_bucket: ref_time을 1분 단위로 버킷화 (HHMM) + TTL: 1분 KIS API: /uapi/domestic-stock/v1/quotations/inquire-time-itemchartprice - 한 번 호출에 최대 30개 분봉 반환. 전체 장 데이터는 여러 번 호출 필요. + 한 번 호출에 최대 30개 분봉 반환. Args: stock_code: 종목코드 (e.g. "005930") @@ -483,15 +523,15 @@ class KoreaInvestmentAPI: "output1": { 현재가 요약 }, "output2": [ { - "stck_bsop_date": "20260222", # 날짜 YYYYMMDD - "stck_cntg_hour": "091500", # 시간 HHMMSS - "stck_prpr": "71800", # 종가(현재가) - "stck_oprc": "71500", # 시가 - "stck_hgpr": "72000", # 고가 - "stck_lwpr": "71400", # 저가 - "cntg_vol": "12345", # 분봉 거래량 - "acml_vol": "987654", # 누적 거래량 - "acml_tr_pbmn": "70896123456", # 누적 거래대금(원) + "stck_bsop_date": "20260222", + "stck_cntg_hour": "091500", + "stck_prpr": "71800", + "stck_oprc": "71500", + "stck_hgpr": "72000", + "stck_lwpr": "71400", + "cntg_vol": "12345", + "acml_vol": "987654", + "acml_tr_pbmn": "70896123456", }, ... ] } @@ -503,6 +543,17 @@ class KoreaInvestmentAPI: kst = pytz.timezone("Asia/Seoul") ref_time = datetime.now(kst).strftime("%H%M%S") + # 캐시 키: 분 단위 버킷 (HHMM) — 같은 분 안에서 중복 호출 방지 + ref_bucket = ref_time[:4] # HHMM + cache_key = f"chart:minute:{stock_code}:{ref_bucket}" + + # ── 1. 캐시 히트 확인 ───────────────────────────────────────── + cached = await _cache_get(cache_key) + if cached is not None: + logger.debug(f"📦 [캐시 히트] {cache_key}") + return cached + + # ── 2. KIS API 호출 ─────────────────────────────────────────── params = { "FID_ETC_CLS_CODE": "", "FID_INPUT_ISCD": stock_code, @@ -515,6 +566,11 @@ class KoreaInvestmentAPI: "FHKST03010200", params=params, ) + + # ── 3. 캐시 저장 ────────────────────────────────────────────── + await _cache_set(cache_key, TTL_MINUTE_CHART, result) + logger.debug(f"💾 [캐시 저장] {cache_key} TTL={TTL_MINUTE_CHART}s") + return result except Exception as e: logger.error(f"Error fetching minute chart for {stock_code}: {e}") @@ -525,11 +581,14 @@ class KoreaInvestmentAPI: stock_code: str, market_open: str = "090000", ) -> list: - """당일 전체 분봉 데이터 수집 (여러 번 호출 집계) + """당일 전체 분봉 데이터 수집 (Redis 캐시 적용, 여러 번 호출 집계) + + 캐시 키: chart:minute_fullday:{stock_code}:{YYYYMMDD_HHMM} + - HHMM: 현재 시각의 분 단위 버킷 → 1분마다 갱신 + TTL: 1분 09:00부터 현재까지의 모든 1분봉을 순서대로 반환. 한 번 호출에 30개씩 → 최대 ~13회 호출 필요 (390분/30). - 시간 역순 데이터를 정순으로 변환해서 반환. Returns: List[dict] — output2 레코드 목록 (오래된 것 → 최신 순) @@ -537,8 +596,20 @@ class KoreaInvestmentAPI: from datetime import datetime import pytz kst = pytz.timezone("Asia/Seoul") - now_str = datetime.now(kst).strftime("%H%M%S") + now_dt = datetime.now(kst) + today_str = now_dt.strftime("%Y%m%d") + now_bucket = now_dt.strftime("%H%M") # 1분 단위 버킷 + cache_key = f"chart:minute_fullday:{stock_code}:{today_str}_{now_bucket}" + + # ── 1. 캐시 히트 확인 ─────────────────────────────────────────────── + cached = await _cache_get(cache_key) + if cached is not None: + logger.debug(f"📦 [캐시 히트] {cache_key} ({len(cached)}개 분봉)") + return cached + + # ── 2. KIS API 다중 호출 집계 ──────────────────────────────────────── + now_str = now_dt.strftime("%H%M%S") all_candles: list = [] ref = now_str seen_times: set = set() @@ -562,7 +633,6 @@ class KoreaInvestmentAPI: all_candles.append(c) new_added += 1 - # 가장 오래된 시간 확인 times = [c.get("stck_cntg_hour", "999999") for c in candles if c.get("stck_cntg_hour")] oldest = min(times) if times else "000000" @@ -574,13 +644,30 @@ class KoreaInvestmentAPI: # 오래된 것 → 최신 순으로 정렬 all_candles.sort(key=lambda c: c.get("stck_cntg_hour", "")) + + # ── 3. 캐시 저장 ──────────────────────────────────────────────────── + if all_candles: + await _cache_set(cache_key, TTL_MINUTE_FULLDAY, all_candles) + logger.debug(f"💾 [캐시 저장] {cache_key} ({len(all_candles)}개 분봉) TTL={TTL_MINUTE_FULLDAY}s") + return all_candles async def get_overseas_daily_chart(self, symbol: str, exchange_code: str = "NASD", period: str = "D") -> Dict[str, Any]: - """해외주식 차트 조회""" + """해외주식 차트 조회 (Redis 캐시 적용) + + 캐시 키: chart:overseas:{exchange_code}:{symbol}:{period} + TTL: 5분 + """ + cache_key = f"chart:overseas:{exchange_code.upper()}:{symbol.upper()}:{period}" + + # ── 1. 캐시 히트 확인 ─────────────────────────────────────────────── + cached = await _cache_get(cache_key) + if cached is not None: + logger.debug(f"📦 [캐시 히트] {cache_key}") + return cached + + # ── 2. KIS API 호출 ───────────────────────────────────────────────── try: - # 기간 코드: O(일), P(주), Q(월) - KIS 해외주식 문서 기준 - # 또는 0(일), 1(주), 2(월) gubn = "0" if period == "W": gubn = "1" elif period == "M": gubn = "2" @@ -598,6 +685,11 @@ class KoreaInvestmentAPI: "MODP": "1" } ) + + # ── 3. 캐시 저장 ──────────────────────────────────────────────── + await _cache_set(cache_key, TTL_OVERSEAS_CHART, result) + logger.debug(f"💾 [캐시 저장] {cache_key} TTL={TTL_OVERSEAS_CHART}s") + return result except Exception as e: logger.error(f"Error fetching overseas chart for {symbol}: {e}") @@ -621,12 +713,10 @@ class KoreaInvestmentAPI: # TR_ID 설정 (미국 매수/매도) if order_type == "BUY": - tr_id = "VTTT1002U" if is_virtual else "TTTT1002U" # 미국 매수 + tr_id = "VTTT1002U" if is_virtual else "TTTT1002U" else: - tr_id = "VTTT1006U" if is_virtual else "TTTT1006U" # 미국 매도 + tr_id = "VTTT1006U" if is_virtual else "TTTT1006U" - # 해외주식 주문 (미국) - # OVRS_EXCG_CD: NASD, NYSE, AMEX result, _ = await self._make_request( "POST", "/uapi/overseas-stock/v1/trading/order", @@ -639,7 +729,7 @@ class KoreaInvestmentAPI: "ORD_QTY": str(quantity), "OVRS_ORD_UNPR": str(price), "ORD_SVR_DVSN_CD": "0", - "ORD_DVSN": "00" # 00: 지정가 + "ORD_DVSN": "00" } ) return result @@ -693,7 +783,6 @@ class KoreaInvestmentAPI: ) -> Dict[str, Any]: """주문 실행""" try: - # 환경 설정 확인 if not self.app_key or not self.app_secret or not self.account_number: error_msg = f"KIS API 설정 누락: app_key={bool(self.app_key)}, secret={bool(self.app_secret)}, account={bool(self.account_number)}" logger.error(error_msg) @@ -709,13 +798,11 @@ class KoreaInvestmentAPI: # 모의투자 환경 확인 is_virtual = "vts" in self.base_url.lower() - # 매수/매도 구분 (모의투자는 V 접두사) if order_type == "BUY": - tr_id = "VTTC0802U" if is_virtual else "TTTC0802U" # 현금 매수 + tr_id = "VTTC0802U" if is_virtual else "TTTC0802U" else: - tr_id = "VTTC0801U" if is_virtual else "TTTC0801U" # 현금 매도 + tr_id = "VTTC0801U" if is_virtual else "TTTC0801U" - # 주문 유형 (00: 지정가, 01: 시장가) order_dvsn = "01" if price is None else "00" order_price = "0" if price is None else str(int(price)) @@ -778,7 +865,7 @@ class KoreaInvestmentAPI: async def search_stocks_by_condition( self, - market: str = "J", # J: 코스피, Q: 코스닥 + market: str = "J", condition_name: str = "", max_results: int = 100 ) -> Dict[str, Any]: @@ -790,7 +877,7 @@ class KoreaInvestmentAPI: "HHKST03900300", params={ "FID_COND_MRKT_DIV_CODE": market, - "FID_COND_SCR_DIV_CODE": "20171", # 조건검색 + "FID_COND_SCR_DIV_CODE": "20171", "FID_INPUT_ISCD": "0000", "FID_DIV_CLS_CODE": "0", "FID_BLNG_CLS_CODE": "0", @@ -810,7 +897,6 @@ class KoreaInvestmentAPI: 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", @@ -851,7 +937,6 @@ class KoreaInvestmentAPI: } ) - # 디버깅용 로그 logger.info(f"📊 Orderbook raw response for {stock_code}:") logger.info(f" rt_cd: {result.get('rt_cd')}, msg1: {result.get('msg1')}") if result.get('output1'):