수정본
This commit is contained in:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user