수정본

This commit is contained in:
2026-02-22 17:23:20 +09:00
parent b147498c31
commit 32e2263f0a
109 changed files with 13979 additions and 4580 deletions

View File

@@ -1,9 +1,12 @@
import httpx
from typing import Dict, Any, Optional, Deque
from datetime import datetime
from datetime import datetime, timedelta
import logging
import asyncio
import time
import json
import os
from pathlib import Path
from collections import deque
from app.core.config import settings
@@ -65,6 +68,9 @@ class APICallMonitor:
class KoreaInvestmentAPI:
"""한국투자증권 API 클라이언트 (안전한 재시도 및 쓰로틀링 적용)"""
# 토큰 캐시 파일 경로 — 사용자 홈 디렉토리 하위에 저장 (/tmp는 보안 취약)
TOKEN_CACHE_FILE = Path.home() / ".cache" / "auto-stock" / "kis_token_cache.json"
def __init__(self):
self.base_url = settings.KOREA_INVESTMENT_API_BASE_URL
@@ -85,42 +91,148 @@ class KoreaInvestmentAPI:
# 동시 요청 제한 (세마포어)
self.semaphore = asyncio.Semaphore(settings.API_MAX_CONCURRENT_REQUESTS)
# 토큰 발급 Lock (중복 발급 방지)
self._token_lock = asyncio.Lock()
# 모니터링
self.monitor = APICallMonitor(window_size=settings.API_MONITORING_WINDOW)
# 시작 시 설정 로깅 (키 마스킹)
logger.info(f"🔧 KIS API 초기화: mode={settings.KIS_MODE}, "
f"base_url={self.base_url}, "
f"app_key={self.app_key[:6]}..., "
f"account={self.account_number[:4]}****{self.account_number[-2:]}")
# 캐시된 토큰 로드
self._load_token_from_cache()
def _load_token_from_cache(self):
"""파일에서 토큰 로드 (앱 시작 시 동기 호출)"""
try:
cache_file = Path(self.TOKEN_CACHE_FILE)
if cache_file.exists():
with open(cache_file, 'r') as f:
cache = json.load(f)
self.access_token = cache.get('access_token')
expires_str = cache.get('expires_at')
if expires_str:
self.token_expires_at = datetime.fromisoformat(expires_str)
if self.access_token and datetime.now() < self.token_expires_at:
remaining = (self.token_expires_at - datetime.now()).total_seconds() / 3600
logger.info(f"💾 캐시 파일에서 토큰 로드 성공 (남은 시간: {remaining:.1f}시간)")
else:
logger.info("💾 캐시 파일의 토큰이 만료됨")
self.access_token = None
self.token_expires_at = None
except Exception as e:
logger.warning(f"⚠️ 토큰 캐시 파일 로드 실패: {e}")
def _write_token_file(self):
"""토큰을 파일에 동기 저장 (asyncio.to_thread 내부에서 실행)"""
cache_file = Path(self.TOKEN_CACHE_FILE)
cache_file.parent.mkdir(parents=True, exist_ok=True)
cache = {
'access_token': self.access_token,
'expires_at': self.token_expires_at.isoformat() if self.token_expires_at else None,
}
with open(cache_file, 'w') as f:
json.dump(cache, f)
# 소유자만 읽기/쓰기 가능하도록 퍼미션 설정 (보안)
os.chmod(cache_file, 0o600)
logger.debug("💾 토큰 캐시 파일 저장 완료")
async def _save_token_to_cache(self):
"""토큰을 파일에 비동기 저장 (이벤트 루프 블로킹 방지)"""
try:
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시간 유효)
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
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)
# 🔒 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
else:
raise Exception(f"Failed to get access token: {response.text}")
# 토큰 발급 시도 전에 마지막 시도 시간 확인 (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()
async with httpx.AsyncClient() as client:
try:
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)
# 파일에 저장 (비동기)
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"⚠️ 토큰 발급 실패했지만 기존 토큰 재사용 시도")
# 만료 시간 연장
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}")
# 기존 토큰이 있으면 사용
if self.access_token:
logger.warning(f"⚠️ 토큰 요청 실패, 기존 토큰 재사용")
self.token_expires_at = datetime.now() + timedelta(hours=1)
return self.access_token
raise
async def _make_request(self, method: str, endpoint: str, tr_id: str, **kwargs) -> Dict[str, Any]:
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 요청 실행 (안전한 재시도 + 쓰로틀링 + 모니터링)"""
# 동시 요청 수 제한 (세마포어)
async with self.semaphore:
return await self._make_request_with_retry(method, endpoint, tr_id, **kwargs)
return await self._make_request_with_retry(method, endpoint, tr_id, extra_headers=extra_headers, **kwargs)
async def _make_request_with_retry(self, method: str, endpoint: str, tr_id: str, **kwargs) -> Dict[str, Any]:
async def _make_request_with_retry(self, method: str, endpoint: str, tr_id: str, extra_headers: Optional[Dict[str, str]] = None, **kwargs) -> (Dict[str, Any], Any):
"""재시도 로직을 포함한 실제 요청 실행"""
start_time = time.time()
retry_count = 0
@@ -146,6 +258,8 @@ class KoreaInvestmentAPI:
"appsecret": self.app_secret,
"tr_id": tr_id,
}
if extra_headers:
headers.update(extra_headers)
# 타임아웃 설정
timeout = httpx.Timeout(self.timeout, connect=10.0)
@@ -170,7 +284,7 @@ class KoreaInvestmentAPI:
f"(임계값: {settings.API_ERROR_THRESHOLD:.1%})"
)
return response.json()
return response.json(), response.headers
# API 호출 제한 초과 (429) 또는 서버 오류 (5xx)
elif response.status_code in [429, 500, 502, 503, 504]:
@@ -199,8 +313,12 @@ class KoreaInvestmentAPI:
error_msg = f"API 클라이언트 오류 (재시도 안함): {response.status_code} - {response.text}"
logger.error(f"❌ {error_msg}")
raise Exception(error_msg)
# 에러 응답도 json과 headers 튜플로 반환 시도
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
@@ -242,7 +360,7 @@ class KoreaInvestmentAPI:
async def get_current_price(self, stock_code: str) -> Dict[str, Any]:
"""현재가 조회"""
try:
result = await self._make_request(
result, _ = await self._make_request(
"GET",
"/uapi/domestic-stock/v1/quotations/inquire-price",
"FHKST01010100",
@@ -256,39 +374,292 @@ class KoreaInvestmentAPI:
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]:
"""일봉/주봉/월봉 조회
async def get_daily_chart(self, stock_code: str, period: str = "D", limit: int = 240) -> Dict[str, Any]:
"""일봉/주봉/월봉 조회 (페이지네이션 지원으로 최대 240개까지 조회)"""
all_output2 = []
KIS API는 기본적으로 최근 30~100개 데이터를 반환
더 많은 데이터가 필요하면 반복 호출 필요 (페이지네이션 없음)
"""
# 1. 첫 요청
params = {
"FID_COND_MRKT_DIV_CODE": "J",
"FID_INPUT_ISCD": stock_code,
"FID_PERIOD_DIV_CODE": period,
"FID_ORG_ADJ_PRC": "0" # 0: 수정주가, 1: 원주가
}
try:
result = await self._make_request(
result, headers = await self._make_request(
"GET",
"/uapi/domestic-stock/v1/quotations/inquire-daily-price",
"FHKST01010400",
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
if not result or result.get('rt_cd') != '0' or data_key is None:
logger.warning(f"No chart data for {stock_code}: {result.get('msg1', 'Unknown')}")
return result
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
extra_headers = {'tr_cont': 'Y'}
result_cont, headers_cont = await self._make_request(
"GET",
"/uapi/domestic-stock/v1/quotations/inquire-daily-price",
"FHKST01010400",
params=params,
extra_headers=extra_headers
)
if result_cont and result_cont.get('rt_cd') == '0':
cont_key = 'output2' if isinstance(result_cont.get('output2'), list) else 'output'
all_output2.extend(result_cont.get(cont_key, []))
tr_cont = headers_cont.get('tr_cont') if headers_cont else None
# 3. 최종 결과 조합
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}")
return final_result
except Exception as e:
logger.error(f"Error fetching paginated chart for {stock_code}: {e}")
raise
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",
"/uapi/overseas-price/v1/quotations/price",
"HHDFS00000300",
params={
"FID_COND_MRKT_DIV_CODE": "J",
"FID_INPUT_ISCD": stock_code,
"FID_PERIOD_DIV_CODE": period,
"FID_ORG_ADJ_PRC": "0" # 0: 수정주가, 1: 원주가
"AUTH": "",
"EXCD": exchange_code.upper(), # NASD, NYSE, AMEX
"SYMB": symbol.upper()
}
)
return result
except Exception as e:
logger.error(f"Error fetching chart for {stock_code}: {e}")
logger.error(f"Error fetching overseas price for {symbol}: {e}")
raise
async def get_minute_chart(
self,
stock_code: str,
ref_time: str = "",
include_past: bool = True,
) -> Dict[str, Any]:
"""국내주식 분봉차트 조회 (FHKST03010200)
KIS API: /uapi/domestic-stock/v1/quotations/inquire-time-itemchartprice
한 번 호출에 최대 30개 분봉 반환. 전체 장 데이터는 여러 번 호출 필요.
Args:
stock_code: 종목코드 (e.g. "005930")
ref_time: 조회 기준 시간 (HHMMSS). 빈 문자열이면 현재 시간
include_past: True = 과거 데이터 포함 (Y), False = 당일만 (N)
Returns:
{
"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", # 누적 거래대금(원)
}, ...
]
}
"""
try:
if not ref_time:
from datetime import datetime
import pytz
kst = pytz.timezone("Asia/Seoul")
ref_time = datetime.now(kst).strftime("%H%M%S")
params = {
"FID_ETC_CLS_CODE": "",
"FID_INPUT_ISCD": stock_code,
"FID_INPUT_HOUR_1": ref_time,
"FID_PW_DATA_INCU_YN": "Y" if include_past else "N",
}
result, _ = await self._make_request(
"GET",
"/uapi/domestic-stock/v1/quotations/inquire-time-itemchartprice",
"FHKST03010200",
params=params,
)
return result
except Exception as e:
logger.error(f"Error fetching minute chart for {stock_code}: {e}")
raise
async def get_minute_chart_full_day(
self,
stock_code: str,
market_open: str = "090000",
) -> list:
"""당일 전체 분봉 데이터 수집 (여러 번 호출 집계)
09:00부터 현재까지의 모든 1분봉을 순서대로 반환.
한 번 호출에 30개씩 → 최대 ~13회 호출 필요 (390분/30).
시간 역순 데이터를 정순으로 변환해서 반환.
Returns:
List[dict] — output2 레코드 목록 (오래된 것 → 최신 순)
"""
from datetime import datetime
import pytz
kst = pytz.timezone("Asia/Seoul")
now_str = datetime.now(kst).strftime("%H%M%S")
all_candles: list = []
ref = now_str
seen_times: set = set()
for _ in range(15): # 최대 15회 (450분, 여유 있게)
try:
result = await self.get_minute_chart(stock_code, ref_time=ref)
except Exception as e:
logger.warning(f"Minute chart fetch failed for {stock_code} at {ref}: {e}")
break
candles = result.get("output2") if isinstance(result.get("output2"), list) else []
if not candles:
break
new_added = 0
for c in candles:
t = c.get("stck_cntg_hour", "")
if t and t not in seen_times:
seen_times.add(t)
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"
if oldest <= market_open or new_added == 0:
break
ref = oldest
await asyncio.sleep(self.min_request_interval + 0.05)
# 오래된 것 → 최신 순으로 정렬
all_candles.sort(key=lambda c: c.get("stck_cntg_hour", ""))
return all_candles
async def get_overseas_daily_chart(self, symbol: str, exchange_code: str = "NASD", period: str = "D") -> Dict[str, Any]:
"""해외주식 차트 조회"""
try:
# 기간 코드: O(일), P(주), Q(월) - KIS 해외주식 문서 기준
# 또는 0(일), 1(주), 2(월)
gubn = "0"
if period == "W": gubn = "1"
elif period == "M": gubn = "2"
result, _ = await self._make_request(
"GET",
"/uapi/overseas-price/v1/quotations/dailyprice",
"HHDFS76240000",
params={
"AUTH": "",
"EXCD": exchange_code.upper(),
"SYMB": symbol.upper(),
"GUBN": gubn,
"BYMD": "",
"MODP": "1"
}
)
return result
except Exception as e:
logger.error(f"Error fetching overseas chart for {symbol}: {e}")
raise
async def place_overseas_order(
self,
symbol: str,
order_type: str, # "BUY" or "SELL"
quantity: int,
price: float, # 해외주식은 지정가(Limit)가 기본
exchange_code: str = "NASD"
) -> Dict[str, Any]:
"""해외주식 주문 (미국주식 기준)"""
try:
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" # 미국 매수
else:
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",
tr_id,
json={
"CANO": account_number_prefix,
"ACNT_PRDT_CD": account_number_suffix,
"OVRS_EXCG_CD": exchange_code.upper(),
"PDNO": symbol.upper(),
"ORD_QTY": str(quantity),
"OVRS_ORD_UNPR": str(price),
"ORD_SVR_DVSN_CD": "0",
"ORD_DVSN": "00" # 00: 지정가
}
)
return result
except Exception as e:
logger.error(f"Error placing overseas order: {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(
# 모의투자 환경 확인 (URL에 'vts' 포함 여부로 판단)
is_virtual = "vts" in self.base_url.lower()
tr_id = "VTTC8434R" if is_virtual else "TTTC8434R"
result, _ = await self._make_request(
"GET",
"/uapi/domestic-stock/v1/trading/inquire-balance",
"TTTC8434R",
tr_id,
params={
"CANO": account_number_prefix,
"ACNT_PRDT_CD": account_number_suffix,
@@ -303,6 +674,10 @@ class KoreaInvestmentAPI:
"CTX_AREA_NK100": ""
}
)
logger.info(f"📋 Balance API Full Response: {result}")
logger.info(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
except Exception as e:
logger.error(f"Error fetching balance: {e}")
@@ -317,20 +692,35 @@ 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)
return {
"rt_cd": "-1",
"msg1": error_msg,
"odno": None
}
account_number_prefix = self.account_number[:8]
account_number_suffix = self.account_number[8:]
# 매수/매도 구분
# 모의투자 환경 확인
is_virtual = "vts" in self.base_url.lower()
# 매수/매도 구분 (모의투자는 V 접두사)
if order_type == "BUY":
tr_id = "TTTC0802U" # 현금 매수
tr_id = "VTTC0802U" if is_virtual else "TTTC0802U" # 현금 매수
else:
tr_id = "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))
result = await self._make_request(
logger.info(f"📤 주문 요청: {order_type} {stock_code} {quantity}주 @{order_price}원 (tr_id={tr_id}, is_virtual={is_virtual})")
result, _ = await self._make_request(
"POST",
"/uapi/domestic-stock/v1/trading/order-cash",
tr_id,
@@ -343,10 +733,16 @@ class KoreaInvestmentAPI:
"ORD_UNPR": order_price
}
)
logger.info(f"📥 주문 응답: rt_cd={result.get('rt_cd')}, msg1={result.get('msg1')}, odno={result.get('odno')}")
return result
except Exception as e:
logger.error(f"Error placing order: {e}")
raise
logger.error(f"❌ 주문 실행 오류: {e}", exc_info=True)
return {
"rt_cd": "-1",
"msg1": str(e),
"odno": None
}
async def cancel_order(self, order_id: str, quantity: int) -> Dict[str, Any]:
"""주문 취소"""
@@ -354,10 +750,14 @@ class KoreaInvestmentAPI:
account_number_prefix = self.account_number[:8]
account_number_suffix = self.account_number[8:]
result = await self._make_request(
# 모의투자 환경 확인
is_virtual = "vts" in self.base_url.lower()
tr_id = "VTTC0803U" if is_virtual else "TTTC0803U"
result, _ = await self._make_request(
"POST",
"/uapi/domestic-stock/v1/trading/order-rvsecncl",
"TTTC0803U",
tr_id,
json={
"CANO": account_number_prefix,
"ACNT_PRDT_CD": account_number_suffix,
@@ -383,7 +783,7 @@ class KoreaInvestmentAPI:
) -> Dict[str, Any]:
"""조건 검색으로 종목 조회"""
try:
result = await self._make_request(
result, _ = await self._make_request(
"GET",
"/uapi/domestic-stock/v1/quotations/inquire-search",
"HHKST03900300",
@@ -410,7 +810,7 @@ class KoreaInvestmentAPI:
"""전체 종목 리스트 조회"""
try:
# 거래대금 상위 종목 조회
result = await self._make_request(
result, _ = await self._make_request(
"GET",
"/uapi/domestic-stock/v1/quotations/volume-rank",
"FHPST01710000",
@@ -437,6 +837,32 @@ class KoreaInvestmentAPI:
"""거래량 순위 조회 (get_stock_list와 동일하지만 명시적 이름)"""
return await self.get_stock_list(market)
async def get_orderbook(self, stock_code: str) -> Dict[str, Any]:
"""호가 정보 조회 (매수/매도 호가 10단계)"""
try:
result, _ = await self._make_request(
"GET",
"/uapi/domestic-stock/v1/quotations/inquire-asking-price-exp-ccn",
"FHKST01010200",
params={
"FID_COND_MRKT_DIV_CODE": "J",
"FID_INPUT_ISCD": stock_code
}
)
# 디버깅용 로그
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'):
logger.info(f" output1 keys: {list(result['output1'].keys())[:10]}")
if result.get('output2'):
logger.info(f" output2 keys: {list(result['output2'].keys())[:10]}")
return result
except Exception as e:
logger.error(f"Error fetching orderbook for {stock_code}: {e}")
raise
def get_monitor_stats(self) -> Dict[str, Any]:
"""API 호출 모니터링 통계 반환"""
return self.monitor.get_stats()