Files
auto-stock/backend/app/services/korea_investment_api.py
shuim d140055e64 fix: 하드닝 루프 9회차 — 매수 원복·잔고 연속조회·결과 불명 매도·브레이커·경보 보강
- 8회차 HIGH 수정 16건 레드팀 재검증: 12 확인·3 부분·1 미수정 → 전부 보강
- trade_executor/fill_sweep: 늦게 확정된 0체결 BUY → CANCELLED + ActiveTrade close, 부분체결 → 수량 축소(멱등, 재기동 후 TH 재구성)
- korea_investment_api: get_balance_all_v2(tr_cont 연속조회, _partial), get_todays_orders_v2(실패 None), 브레이커 청산용 조회 면제(OPEN 중 미집계)
- swing/exit_service·mdd_manager: 매도 직전 상한·MDD 축소가 전체 잔고 페이지 사용(§8.5-37), 불명 대기 중 부분익절 skip·쿨다운·pre_hldg 차감, 대기 TTL 15:30+재매수 폐기(대조 1회 선행)(§8.5-38 해소)
- swing/scheduler: 청산 TIMED_OUT/SKIPPED/in-flight 시 MDD 축소·진입 보류(예외는 진입만), 3사이클 연속 시 error+텔레그램
- daytrading: 결과 불명 매도 재판정 v3(주문조회 v2 None→보류, 등록 이후 일치 행→accepted, 원장≤0→보류), 재기동 복원 min(주문가능,보유), 잔고 조회 후 일일손실한도 재확인, 단독 실행 Redis 접속 제거
- main: 스트림 무수신 경보 v4(희망 구독 기준), 첫 하트비트 없음 stale(HB_FIRST_BEAT_GRACE_SEC)
- 운영: Jenkinsfile health_watch.py 디렉터리 잔존 복구, prod_smoke 사이드카 확인, config 키 12개 선언(동작 무변경)
- 진입가 v3(max(AT,PA))는 KIS 이동평균 평단 전제 오류로 레드팀 반증 → 폐기
- CLAUDE.md §3.2 ③-d, §8.5-37/38 갱신, §8.5-39~48 신규(Crisis 손절 소급 §8.5-39 는 사용자 결정 대기)
- 유닛 스위트 2781 passed / 0 failed (소켓 차단), 네트워크 시도 0건

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-12 15:37:16 +09:00

2339 lines
114 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
한국투자증권(KIS) REST API 클라이언트
- OAuth2 토큰 자동 발급 및 파일 캐시 (~/.cache/auto-stock/)
- 국내/해외 주식 현재가, 일봉/분봉 차트 조회
- 매수/매도 주문 및 체결 확인
- CircuitBreaker: 연속 N회 실패 시 일정 시간 요청 차단
- APICallMonitor: 호출 성공률·지연시간 통계
- KIS_MODE(virtual/real)에 따라 엔드포인트 자동 전환
"""
import httpx
from typing import Dict, Any, Optional, Deque
from datetime import datetime, timedelta
import logging
import asyncio
import random
import time
import json
import os
import contextvars
from pathlib import Path
from collections import deque
from app.core.config import settings
from app.services.utils import CircuitBreakerState
from app.core.log_throttle import warn_throttled # noqa: E402
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분
# ── 분봉 페이지네이션 상수 ────────────────────────────────────────────────────
MINUTE_CHART_MAX_PAGES = 15 # 1회 30봉 × 15 = 450봉 (장 전체 390분 + 여유)
# 전일 분봉 워밍업 **상한**(분). 종료 판정의 기준은 아래 `_WARMUP_BARS_5MIN`(봉 수)이고
# 이 값은 "아무리 모아도 여기서 멈춘다"는 안전장치다 — 저유동성 종목은 거래 없는 분이
# 응답에서 빠져 분 수로는 영원히 차지 않기 때문이다. 전략 임계값이 아니다.
MINUTE_CHART_PREV_DAY_MINUTES = 150
# 워밍업 목표는 **5분봉 개수**다: BB(20)=20봉, RSI(14)=15봉
# (`day_trading_strategy._bollinger(candles_5min, 20)` / `_rsi(candles_5min, 14)`).
# 당일 5분봉이 k개면 추가로 필요한 것은 (20-k)개이므로 장 중반 이후엔 0이 된다.
MINUTE_CHART_WARMUP_BARS_5MIN = 20
# 전일 구간 전용 페이지 상한 — 당일 수집과 별도로 더 낮게 잡아 레이트 리밋을 보호한다
# (한 페이지 30봉이 5분봉 6~30개를 덮으므로 3페이지면 20봉 목표에 충분하다).
MINUTE_CHART_PREV_DAY_MAX_PAGES = 3
# ── 토큰 무효 응답 코드 (H-4) ─────────────────────────────────────────────────
# EGW00121: 유효하지 않은 token / EGW00123: 기간이 만료된 token.
# 이 응답을 받으면 메모리·파일 캐시를 지우고 (조회 계열만) 1회 재발급 후 재전송한다.
KIS_TOKEN_INVALID_MSG_CODES: tuple[str, ...] = ("EGW00121", "EGW00123")
# 주문 취소(정정취소) — 공식 샘플 examples_llm/domestic_stock/order_rvsecncl (실전, 모의)
KIS_CANCEL_ENDPOINT = "/uapi/domestic-stock/v1/trading/order-rvsecncl"
KIS_CANCEL_TR_IDS: tuple[str, str] = ("TTTC0013U", "VTTC0013U")
KIS_CANCEL_DEFAULT_EXCHANGE = "KRX"
# 취소가 **전송 전** 원주문 조직번호를 못 찾아 실패했을 때의 msg_cd (자체 값)
KIS_PRESEND_ORGNO_NOT_FOUND = "ORGNO_NOT_FOUND"
def extract_order_org_no(order_result: Any) -> Optional[str]:
"""place_order 응답에서 한국거래소전송주문조직번호(output.KRX_FWDG_ORD_ORGNO) 추출 — cancel_order_v2 의 org_no."""
if not isinstance(order_result, dict):
return None
out = order_result.get("output")
if isinstance(out, list):
out = out[0] if out else None
if isinstance(out, dict):
v = str(out.get("KRX_FWDG_ORD_ORGNO") or out.get("krx_fwdg_ord_orgno") or "").strip()
return v or None
return None
# place_order 가 **전송 전** 토큰 부재로 실패했을 때의 msg_cd (KIS 코드와 겹치지 않는 자체 값)
KIS_PRESEND_TOKEN_UNAVAILABLE = "TOKEN_UNAVAILABLE"
class _KISTokenRefreshRequired(Exception):
"""[H-4] 조회 응답이 토큰 무효 — 세마포어 밖에서 재발급 후 1회 재전송하라는 내부 신호."""
def __init__(self, msg_code: str):
super().__init__(msg_code)
self.msg_code = msg_code
# 현재 요청이 이미 토큰 재발급 후 재전송 중인가 (요청당 1회 제한)
_TOKEN_REFRESH_RETRIED: contextvars.ContextVar[bool] = contextvars.ContextVar(
"kis_token_refresh_retried", default=False
)
# [9회차 D-2b] 현재 요청이 "브레이커 비-CLOSED 중의 면제 조회" 인가 — 참이면 성공·실패 모두
# 브레이커에 기록하지 않는다(OPEN 해제는 비면제 조회의 HALF_OPEN 프로브로만).
_BREAKER_UNCOUNTED: contextvars.ContextVar[bool] = contextvars.ContextVar(
"kis_breaker_uncounted", default=False
)
class KISTokenUnavailableError(Exception):
"""토큰을 지금 얻을 수 없다(재발급 최소 간격 내 + 유효 토큰 없음).
[H-3 2026-10-02] 이전에는 이 상황에서 `_token_lock`(+호출자의 세마포어)을 잡은 채
≈70초 sleep 했다. 대기자 N명이면 70×N초가 직렬로 누적돼(재현: 5건 → 355초) 매도·EOD
청산까지 멈췄다. 이제 대기하지 않고 즉시 실패한다 — 호출자는 다음 사이클에 재시도한다.
"""
def __init__(self, message: str, retry_after_sec: float = 0.0):
super().__init__(message)
self.retry_after_sec = retry_after_sec
class CircuitBreaker:
"""KIS API Circuit Breaker — 연속 N회 실패 시 일정 시간 요청 차단.
States:
CLOSED : 정상 운영 (요청 허용)
OPEN : 차단 (recovery_timeout 경과 전까지 요청 거부)
HALF_OPEN: 복구 시도 (1개 요청 허용 → 성공 시 CLOSED, 실패 시 OPEN)
"""
def __init__(self, failure_threshold: int = 5, recovery_timeout: int = 600):
self.failure_threshold = failure_threshold # 연속 실패 허용 횟수
self.recovery_timeout = recovery_timeout # 차단 후 복구 대기 시간 (초)
self.failure_count = 0
self.last_failure_time: Optional[float] = None
self.state = CircuitBreakerState.CLOSED
self.open_count = 0 # 누적 OPEN 전이 횟수 (헬스 노출용)
self.last_opened_at: Optional[float] = None
def can_request(self) -> bool:
"""요청 가능 여부 반환."""
if self.state == CircuitBreakerState.CLOSED:
return True
if self.state == CircuitBreakerState.OPEN:
if self.last_failure_time and time.time() - self.last_failure_time >= self.recovery_timeout:
self.state = CircuitBreakerState.HALF_OPEN
logger.info("⚡ Circuit breaker HALF_OPEN: 복구 시도")
return True
return False
return True # HALF_OPEN
def record_success(self) -> None:
"""성공 기록 — 차단 해제."""
if self.state != CircuitBreakerState.CLOSED:
logger.info("✅ Circuit breaker CLOSED: 복구 완료")
self.failure_count = 0
self.state = CircuitBreakerState.CLOSED
def record_failure(self) -> None:
"""실패 기록 — 임계치 초과 시 차단."""
self.failure_count += 1
self.last_failure_time = time.time()
if self.state == CircuitBreakerState.HALF_OPEN or self.failure_count >= self.failure_threshold:
_was_open = self.state == CircuitBreakerState.OPEN
self.state = CircuitBreakerState.OPEN
logger.warning(
f"⚡ Circuit breaker OPEN: {self.failure_count}회 연속 실패. "
f"{self.recovery_timeout}초 후 재시도"
)
if not _was_open:
self.open_count += 1
self.last_opened_at = time.time()
_alert_breaker_open_v2(self)
def health(self) -> Dict[str, Any]:
"""/health 노출용 스냅샷 (순수 조회)."""
return {
"state": getattr(self.state, "value", str(self.state)),
"failure_count": self.failure_count,
"open_count": self.open_count,
"last_opened_at": self.last_opened_at,
}
# 경보 태스크 참조 보관 (GC 로 경보가 사라지는 것을 방지)
_breaker_alert_tasks: set = set()
def _breaker_alert_done_v2(task: "asyncio.Task") -> None:
_breaker_alert_tasks.discard(task)
try:
if not task.cancelled() and task.exception() is not None:
logger.warning(f"[KIS] circuit breaker 경보 태스크 실패: {task.exception()}")
except Exception as _e:
logger.warning(f"[KIS] circuit breaker 경보 태스크 확인 실패: {_e}")
def _alert_breaker_open_v2(breaker: "CircuitBreaker") -> None:
"""OPEN 전이 시 error 로그 + 텔레그램 경보(fire-and-forget). 실패해도 요청 경로를 막지 않는다."""
logger.error(
f"🚨 [KIS] circuit breaker OPEN — 시세·잔고 조회가 {breaker.recovery_timeout}초간 차단된다 "
f"(주문은 차단하지 않음). 누적 {breaker.open_count}회"
)
try:
loop = asyncio.get_running_loop()
from app.services.telegram_notifier import telegram_notifier
_task = loop.create_task(telegram_notifier.notify_system_warning(
"KIS_CIRCUIT_BREAKER",
f"KIS 조회 차단기 OPEN ({breaker.failure_count}회 연속 실패, {breaker.recovery_timeout}초). "
f"손절 판정용 시세 조회가 지연될 수 있음",
))
_breaker_alert_tasks.add(_task)
_task.add_done_callback(_breaker_alert_done_v2)
except Exception as _alert_err:
logger.warning(f"[KIS] circuit breaker 경보 전송 실패: {_alert_err}")
def get_kis_breaker_health_v2() -> Dict[str, Any]:
"""모듈 레벨 KIS 인스턴스들의 breaker 상태 (main /health 에서 호출용)."""
out: Dict[str, Any] = {}
for _name in ("korea_investment_api", "real_market_api"):
_inst = globals().get(_name)
_cb = getattr(_inst, "circuit_breaker", None)
if _cb is not None:
out[_name] = _cb.health()
return out
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
# ── 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 as _swallowed:
warn_throttled(logger, "korea_investment_api:164", "[KIS] Redis 캐시 저장 실패: %s", _swallowed)
# ── 국내 주문체결 조회 (inquire-daily-ccld) ────────────────────────────────
# KIS 공식 "주식일별주문체결조회"(3개월 이내): GET /uapi/domestic-stock/v1/trading/inquire-daily-ccld
# TR — 현행 공식 샘플(koreainvestment/open-trading-api examples_llm/domestic_stock/inquire_daily_ccld):
# 실전 TTTC0081R / 모의 VTTC0081R. 구 TR(legacy/ 샘플·Postman v2.6): 실전 TTTC8001R / 모의 VTTC8001R.
# 구 TR 의 현재 지원 여부는 레포 근거로 확정 불가 → 현행 TR 우선, rt_cd != "0" 이면 구 TR 1회 폴백
# (성공한 쪽을 인스턴스에 기억). 응답 output1[] = 주문 단위 행
# (odno, pdno, sll_buy_dvsn_cd, ord_qty, tot_ccld_qty, avg_prvs, tot_ccld_amt, rmn_qty, cncl_yn ...).
# 연속조회: 응답 헤더 tr_cont F/M = 다음 있음 → 요청 헤더 tr_cont=N + CTX_AREA_FK100/NK100.
DAILY_CCLD_ENDPOINT = "/uapi/domestic-stock/v1/trading/inquire-daily-ccld"
DAILY_CCLD_TR_IDS = { # (실전, 모의)
"current": ("TTTC0081R", "VTTC0081R"),
"legacy": ("TTTC8001R", "VTTC8001R"),
}
ORDER_EXEC_MAX_PAGES = 5 # 단건 체결조회 연속조회 상한 (ODNO 필터가 무시돼도 무한루프 방지)
def _norm_kis_odno(value) -> str:
"""KIS 주문번호 정규화 — 주문 응답(ODNO)과 조회 응답(odno)의 0 패딩 차이를 흡수."""
s = str(value or "").strip()
return s.lstrip("0") or ("0" if s else "")
def _to_int_or_none(value) -> Optional[int]:
if value is None or (isinstance(value, str) and not value.strip()):
return None
try:
return int(float(str(value).replace(",", "")))
except (TypeError, ValueError):
return None
def parse_order_execution_rows(rows, order_id: str) -> Optional[Dict[str, Any]]:
"""inquire-daily-ccld output1 에서 주문번호가 일치하는 행을 체결 dict 로 변환 (순수 함수).
반환 (일치 행 없으면 None):
filled_qty : 총체결수량(tot_ccld_qty)
filled_price : 평균체결가(avg_prvs). 0 이면 tot_ccld_amt / tot_ccld_qty 로 보정
ordered_qty : 주문수량(ord_qty) — 모르면 None
remaining_qty : 잔여수량(rmn_qty) — **필드가 없으면 None**(0 과 구분: 0 은 '잔량 소멸')
cancelled : cncl_yn == "Y" (취소는 별도 행(orgn_odno)이라 원주문은 N 일 수 있음 — 로그용)
cancel_confirmed_qty : 취소확인수량(cnc_cfrm_qty) — 없으면 None (로그용)
rejected_qty : 거부수량(rjct_qty) — 없으면 None (로그용)
"""
target = _norm_kis_odno(order_id)
if not target:
return None
if isinstance(rows, dict):
rows = [rows]
for row in rows or []:
if not isinstance(row, dict):
continue
if _norm_kis_odno(row.get("odno")) != target:
continue
filled_qty = _to_int_or_none(row.get("tot_ccld_qty")) or 0
try:
filled_price = float(str(row.get("avg_prvs") or 0).replace(",", ""))
except (TypeError, ValueError):
filled_price = 0.0
if filled_price <= 0 and filled_qty > 0:
amt = _to_int_or_none(row.get("tot_ccld_amt")) or 0
filled_price = (amt / filled_qty) if amt > 0 else 0.0
return {
"filled_qty": max(0, filled_qty),
"filled_price": float(filled_price) if filled_qty > 0 else 0.0,
"ordered_qty": _to_int_or_none(row.get("ord_qty")),
"remaining_qty": _to_int_or_none(row.get("rmn_qty")),
"cancelled": str(row.get("cncl_yn") or "").upper() == "Y",
"cancel_confirmed_qty": _to_int_or_none(row.get("cnc_cfrm_qty")),
"rejected_qty": _to_int_or_none(row.get("rjct_qty")),
}
return None
def _round_to_krx_tick(price: int) -> int:
"""KRX 호가단위에 맞춰 가격을 올림(ceil) 처리. 모의투자 호가단위 오류 방지."""
if price < 1_000:
tick = 1
elif price < 5_000:
tick = 5
elif price < 10_000:
tick = 10
elif price < 50_000:
tick = 50
elif price < 100_000:
tick = 100
elif price < 500_000:
tick = 500
else:
tick = 1_000
remainder = price % tick
return price if remainder == 0 else price + (tick - remainder)
class KoreaInvestmentAPI:
"""한국투자증권 API 클라이언트 (안전한 재시도 및 쓰로틀링 적용)"""
# 토큰 캐시 파일 경로 — 사용자 홈 디렉토리 하위에 저장 (/tmp는 보안 취약)
TOKEN_CACHE_FILE = Path.home() / ".cache" / "auto-stock" / "kis_token_cache.json"
@classmethod
def for_real_market_data(cls) -> "KoreaInvestmentAPI":
"""실계좌 자격증명으로 시세조회 전용 인스턴스 생성.
모의투자 모드에서도 실계좌 API로 시세를 조회할 때 사용한다.
(모의투자 서버는 KOSDAQ 거래량 순위 등 일부 조회 API를 지원하지 않음)
주문 API는 절대 호출하지 말 것.
"""
if not settings.KIS_REAL_APP_KEY:
raise ValueError("KIS_REAL_APP_KEY가 설정되지 않아 실계좌 시세조회를 사용할 수 없습니다.")
instance = cls.__new__(cls)
instance.base_url = settings.KIS_REAL_URL
instance.app_key = settings.KIS_REAL_APP_KEY
instance.app_secret = settings.KIS_REAL_APP_SECRET
instance.account_number = settings.KIS_REAL_ACCOUNT_NO
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"
# 실서버는 초당 20건 허용 — 모의서버용 간격(API_MIN_REQUEST_INTERVAL)과 분리
instance.min_request_interval = getattr(settings, "KIS_REAL_MIN_REQUEST_INTERVAL", 0.1)
instance.max_retries = settings.API_MAX_RETRIES
instance.retry_delay = settings.API_RETRY_DELAY
instance.retry_backoff = settings.API_RETRY_BACKOFF
instance.timeout = settings.API_TIMEOUT
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=7, recovery_timeout=120)
logger.info(
"🔧 KIS 실계좌 시세조회 API 초기화: base_url=%s, app_key=%s...",
instance.base_url, instance.app_key[:6]
)
instance._load_token_from_cache()
return instance
@classmethod
def for_user(
cls,
user_id: int,
app_key: str,
app_secret: str,
account_no: str,
mode: str,
) -> "KoreaInvestmentAPI":
"""사용자별 주문·잔고 전용 인스턴스 생성 (멀티테넌트 1단계, §5.5).
시세 조회는 `_quote_delegate()`가 전역 `real_market_api`(공용, 계좌 무관)로
자동 위임하므로, 이 인스턴스의 앱키는 주문·잔고·체결확인에만 소비된다.
`place_order`/`get_balance`/`cancel_order` 등은 모두 `self.base_url`
(`"vts" in base_url`)로 모의/실전을 판별하므로, 전역 `settings.KIS_MODE`를
건드리지 않고 인스턴스별 `mode`가 그대로 적용된다(최소 침습).
자격증명 복호화는 호출 측(Registry/KISClientPool)이 수행해 평문으로 전달한다 —
이 클래스는 DB 세션을 열지 않는다.
"""
if mode not in ("virtual", "real"):
raise ValueError(f"지원하지 않는 mode: {mode!r} (virtual|real)")
instance = cls.__new__(cls)
instance.base_url = settings.KIS_REAL_URL if mode == "real" else settings.KIS_VIRTUAL_URL
instance.app_key = app_key
instance.app_secret = app_secret
instance.account_number = account_no
instance.mode = mode
instance.user_id = user_id
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()
token_dir = Path.home() / ".cache" / "auto-stock" / "users" / str(user_id)
token_dir.mkdir(parents=True, exist_ok=True)
instance.TOKEN_CACHE_FILE = token_dir / f"kis_token_{mode}.json"
instance.min_request_interval = (
0.5 if mode == "virtual" else getattr(settings, "KIS_REAL_MIN_REQUEST_INTERVAL", 0.1)
)
instance.max_retries = settings.API_MAX_RETRIES
instance.retry_delay = settings.API_RETRY_DELAY
instance.retry_backoff = settings.API_RETRY_BACKOFF
instance.timeout = settings.API_TIMEOUT
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=7, recovery_timeout=120)
logger.info(
"🔧 KIS 사용자별 API 초기화: user_id=%s mode=%s base_url=%s app_key=%s...",
user_id, mode, instance.base_url, app_key[:6] if app_key else ""
)
instance._load_token_from_cache()
return instance
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
# 스로틀 계산(간격 측정+sleep+last_request_time 갱신)을 직렬화하는 락
# (thundering herd 방지: 여러 코루틴이 동시에 깨어나 동시에 요청 쏘는 것 방지)
self._throttle_lock = asyncio.Lock()
# 설정 기반 쓰로틀링 파라미터
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)
# httpx 싱글톤 클라이언트 (커넥션 풀 재활용 — 매 요청마다 TCP/TLS 핸드셰이크 방지)
self._http_client: Optional[httpx.AsyncClient] = None
self._http_client_lock = asyncio.Lock()
# 토큰 발급 Lock (중복 발급 방지)
self._token_lock = asyncio.Lock()
# 모니터링
self.monitor = APICallMonitor(window_size=settings.API_MONITORING_WINDOW)
# Circuit Breaker (7회 연속 실패 → 2분 차단)
self.circuit_breaker = CircuitBreaker(failure_threshold=7, recovery_timeout=120)
# 시작 시 설정 로깅 (키 마스킹)
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()
async def _get_http_client(self) -> httpx.AsyncClient:
"""싱글톤 httpx 클라이언트 반환. 없으면 생성."""
if self._http_client is None or self._http_client.is_closed:
async with self._http_client_lock:
if self._http_client is None or self._http_client.is_closed:
self._http_client = httpx.AsyncClient(
timeout=httpx.Timeout(10.0, connect=5.0),
limits=httpx.Limits(
max_connections=20,
max_keepalive_connections=10,
keepalive_expiry=30,
),
)
return self._http_client
async def close(self) -> None:
"""httpx 클라이언트 정리. 앱 종료 시 호출."""
if self._http_client and not self._http_client.is_closed:
await self._http_client.aclose()
self._http_client = None
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)
# [H-4] 실제 만료(하드) — 구 캐시 파일에는 없으므로 로컬 만료(23h)+1h 로 추정
_hard_str = cache.get('hard_expires_at')
try:
self._token_hard_expires_at = (
datetime.fromisoformat(_hard_str) if _hard_str
else self.token_expires_at + timedelta(hours=1)
)
except ValueError:
self._token_hard_expires_at = self.token_expires_at + timedelta(hours=1)
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)
_hard = getattr(self, "_token_hard_expires_at", None)
cache = {
'access_token': self.access_token,
'expires_at': self.token_expires_at.isoformat() if self.token_expires_at else None,
'hard_expires_at': _hard.isoformat() if _hard 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}")
def _check_token_cache(self) -> Optional[str]:
"""Check if cached token is still valid. Returns token if valid, None otherwise."""
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
return None
async def _handle_rate_limit(self) -> Optional[str]:
"""Handle 1-minute rate limit for token requests.
- 재발급 최소 간격(`KIS_TOKEN_REISSUE_MIN_INTERVAL_SEC`) 안이고 유효 토큰이 있으면 재사용.
- 간격 안인데 유효 토큰이 없으면 **대기하지 않고** `KISTokenUnavailableError` (H-3).
`_token_lock` 안에서 잠들면 대기자 수만큼 직렬 누적되기 때문이다.
- 간격 밖이면 None (호출자가 재발급).
"""
last = getattr(self, '_last_token_request_time', None)
if last is not None:
interval = float(settings.KIS_TOKEN_REISSUE_MIN_INTERVAL_SEC)
elapsed = (datetime.now() - last).total_seconds()
if elapsed < interval:
# 기존 토큰이 있고, 아직 만료 전이면 재사용
if self.access_token and self.token_expires_at and datetime.now() < self.token_expires_at:
logger.warning(f"⏳ 토큰 발급 1분 제한 ({elapsed:.0f}초 경과) - 기존 토큰 재사용 (만료까지 {(self.token_expires_at - datetime.now()).total_seconds() / 60:.0f}분)")
return self.access_token
retry_after = interval - elapsed
warn_throttled(
logger, "kis:token_reissue_window",
f"⏳ 토큰 재발급 최소 간격 {interval:.0f}초 미경과 — 대기 없이 실패 처리 "
f"(약 {retry_after:.0f}초 후 재발급 가능, 유효 토큰 없음)",
)
raise KISTokenUnavailableError(
f"KIS 토큰 재발급 대기 중 ({retry_after:.0f}초 후 가능)", retry_after_sec=retry_after
)
return None
def _grace_extend_existing_token(self) -> Optional[str]:
"""[H-4] 재발급 실패 시 기존 토큰을 **실제 만료(하드) 안에서만** 짧게 연장.
이전에는 무조건 +1시간 연장해, 이미 KIS 가 만료시킨 토큰으로 1시간 동안 모든 호출이
EGW00123 을 받았다. 로컬 만료(23h)와 실제 만료(24h) 사이의 여유분만 쓴다.
"""
hard = getattr(self, "_token_hard_expires_at", None)
if not self.access_token or hard is None:
return None
now = datetime.now()
safety = timedelta(minutes=5)
if now >= hard - safety:
return None
self.token_expires_at = min(
now + timedelta(minutes=int(settings.KIS_TOKEN_GRACE_EXTEND_MINUTES)), hard - safety
)
logger.warning(
f"⚠️ 토큰 재발급 실패 — 기존 토큰을 {self.token_expires_at.strftime('%H:%M')} 까지 사용 "
f"(실제 만료 {hard.strftime('%m-%d %H:%M')})"
)
return self.access_token
async def _invalidate_token_v2(self, bad_token: Optional[str]) -> None:
"""[H-4] KIS 가 무효/만료라고 응답한 토큰을 메모리·파일 캐시에서 제거.
다른 코루틴이 이미 새 토큰으로 갈아끼웠다면(토큰 값이 다르면) 건드리지 않는다.
"""
if bad_token is not None and self.access_token != bad_token:
return
self.access_token = None
self.token_expires_at = None
self._token_hard_expires_at = None
def _unlink():
try:
Path(self.TOKEN_CACHE_FILE).unlink(missing_ok=True)
except OSError as e:
logger.warning(f"⚠️ 무효 토큰 캐시 파일 삭제 실패: {e}")
await asyncio.to_thread(_unlink)
logger.warning("🔑 KIS 가 토큰 무효/만료 응답 — 메모리·파일 캐시 삭제 (다음 호출에서 재발급)")
@staticmethod
def _token_invalid_msg_code(payload: Any) -> Optional[str]:
"""응답 본문이 토큰 무효/만료 코드면 그 코드를, 아니면 None."""
if isinstance(payload, dict):
code = str(payload.get("msg_cd") or "").strip()
if code in KIS_TOKEN_INVALID_MSG_CODES:
return code
return None
async def _request_new_token(self) -> str:
"""Request new access token from KIS API.
Returns access token on success, raises exception on failure.
"""
logger.info("🔑 새로운 토큰 발급 요청...")
self._last_token_request_time = datetime.now()
try:
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
},
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)
# [H-4] 실제 만료 — 응답의 expires_in(초) 우선, 없으면 설정값
try:
_ttl_sec = float(data.get("expires_in") or 0)
except (TypeError, ValueError):
_ttl_sec = 0.0
if _ttl_sec <= 0:
_ttl_sec = float(settings.KIS_TOKEN_HARD_TTL_HOURS) * 3600
self._token_hard_expires_at = datetime.now() + timedelta(seconds=_ttl_sec)
# 파일에 저장 (비동기) — Lock 안에서 호출하되 내부는 to_thread이므로 OK
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}")
# 토큰 발급 실패 시 기존 토큰은 실제 만료(하드) 안에서만 짧게 연장 (H-4)
_grace = self._grace_extend_existing_token()
if _grace:
return _grace
raise Exception(f"Failed to get access token: {error_msg}")
except Exception as e:
logger.error(f"❌ 토큰 요청 중 오류: {e}")
# 기존 토큰은 실제 만료(하드) 안에서만 짧게 연장 (H-4)
_grace = self._grace_extend_existing_token()
if _grace:
return _grace
raise
async def _get_access_token(self) -> str:
"""접근 토큰 발급 (Lock으로 중복 발급 방지)
주요 단계:
1. 캐시된 토큰 확인 (재사용 가능하면 반환)
2. Lock 획득 후 재확인 (중복 발급 방지)
3. Rate limit 처리 (1분 제한)
4. 새 토큰 요청
"""
# 1. Check cached token (fast path)
cached_token = self._check_token_cache()
if cached_token:
return cached_token
# 2. Lock 획득 - 동시 요청 시 하나만 토큰 발급
async with self._token_lock:
# Lock 대기 중 다른 요청이 토큰 발급했을 수 있으므로 재확인
cached_token = self._check_token_cache()
if cached_token:
return cached_token
# 3. Handle rate limit (1-minute restriction)
rate_limited_token = await self._handle_rate_limit()
if rate_limited_token:
return rate_limited_token
# 4. Request new token
return await self._request_new_token()
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 요청 실행 (Circuit Breaker + 세마포어 + 재시도 + 모니터링)"""
# Circuit Breaker 상태 확인
# [R4] 주문 엔드포인트는 breaker 로 차단하지 않는다 — 시세/잔고 조회 장애(EGW00201·타임아웃)가
# 손절·EOD·MDD 매도까지 막으면 §8.1-11(청산 우선) 위반. 주문 실패는 breaker 에 세지도 않는다.
# [9회차 D-2] 청산 경로가 쓰는 읽기 조회(_CIRCUIT_EXEMPT_READ_ENDPOINTS)도 게이트 면제.
# 면제 호출이 실패하면 예외가 나므로 청산 보류는 KIS 가 실제로 죽었을 때만 일어난다.
# [9회차 D-2b] 브레이커가 CLOSED 가 아닐 때(OPEN/HALF_OPEN) 면제 호출은 can_request() 를
# 부르지 않고(HALF_OPEN 전이·프로브 소비 금지) 성공·실패 모두 브레이커에 기록하지 않는다.
# - 면제 성공이 record_success 로 OPEN 을 닫으면 EGW00201 폭주 중 부하 완화가 사라진다.
# - 면제 실패가 last_failure_time 을 갱신하면 비면제 조회가 HALF_OPEN 프로브를 영영 못 얻는다.
# CLOSED 상태의 면제 호출은 기존대로 집계한다(장애 감지는 유지).
uncounted = False
if endpoint in self._CIRCUIT_EXEMPT_READ_ENDPOINTS:
if self.circuit_breaker.state != CircuitBreakerState.CLOSED:
uncounted = True
warn_throttled(
logger, f"korea_investment_api:breaker_exempt:{endpoint}",
"⚡ Circuit breaker %s — 청산 경로 조회는 게이트 면제로 전송(브레이커 집계 제외): %s (차단 %s초)",
self.circuit_breaker.state.value, endpoint, self.circuit_breaker.recovery_timeout,
interval=60.0,
)
elif endpoint not in self._NON_IDEMPOTENT_ENDPOINTS and not self.circuit_breaker.can_request():
wait_sec = self.circuit_breaker.recovery_timeout
logger.warning(f"⚡ Circuit breaker OPEN — {wait_sec}초 후 재시도 가능. 요청 거부: {endpoint}")
raise Exception(f"Circuit breaker open: KIS API 일시 차단 ({wait_sec}s)")
_uc_tok = _BREAKER_UNCOUNTED.set(uncounted)
try:
return await self._make_request_dispatch_v2(method, endpoint, tr_id, extra_headers=extra_headers, **kwargs)
finally:
_BREAKER_UNCOUNTED.reset(_uc_tok)
async def _make_request_dispatch_v2(self, method: str, endpoint: str, tr_id: str, extra_headers: Optional[Dict[str, str]] = None, **kwargs) -> (Dict[str, Any], Any):
"""_make_request 의 게이트 이후 본체(토큰 확보 + 세마포어 + 재시도 + 토큰 무효 1회 재전송)."""
# [H-3] 토큰은 세마포어 **밖에서** 확보한다. 세마포어를 잡은 채 토큰 발급(최대
# HTTP 30초)·락 대기를 하면 다른 모든 KIS 호출(매도·EOD 청산 포함)이 슬롯을 못 얻는다.
# 여기서 캐시가 채워지므로 아래 재시도 루프의 토큰 조회는 fast path 로 끝난다.
await self._get_access_token()
# 동시 요청 수 제한 (세마포어)
try:
async with self.semaphore:
return await self._make_request_with_retry(method, endpoint, tr_id, extra_headers=extra_headers, **kwargs)
except _KISTokenRefreshRequired as refresh:
# [H-4] 토큰 무효 응답(조회 계열) — 세마포어를 놓은 상태에서 재발급하고 1회만 재전송
logger.warning(f"🔑 토큰 무효({refresh.msg_code}) — 재발급 후 1회 재전송: {endpoint}")
await self._get_access_token()
_ctx = _TOKEN_REFRESH_RETRIED.set(True)
try:
async with self.semaphore:
return await self._make_request_with_retry(method, endpoint, tr_id, extra_headers=extra_headers, **kwargs)
finally:
_TOKEN_REFRESH_RETRIED.reset(_ctx)
# 주문 계열 엔드포인트 — 재전송 시 중복 체결 위험이 있어 내부 재시도를 하지 않는다.
# (상위 trade_executor._place_order_with_retry 가 체결 확인 후 재시도를 판단한다)
_NON_IDEMPOTENT_ENDPOINTS = (
"/uapi/domestic-stock/v1/trading/order-cash",
"/uapi/domestic-stock/v1/trading/order-rvsecncl",
"/uapi/overseas-stock/v1/trading/order",
)
# [9회차 D-2, CLAUDE.md §8.1-11 청산 우선] 서킷브레이커 **게이트 면제** 읽기 엔드포인트.
# 조회 실패 누적으로 breaker 가 OPEN 이면 recovery_timeout 동안 모든 조회가 거부되는데,
# 스윙 매도 직전 잔고 상한(_fetch_kis_holding_v2)·손절 판정용 현재가·결과 불명 주문 확인
# (당일 주문/체결)까지 막히면 손절·전량 청산이 보류된다. 이 집합은 게이트만 통과시키고
# 성공/실패 집계(record_success/record_failure)는 그대로 한다 — 면제 호출이 실패하면
# 평소처럼 예외/오류 응답이 나므로 청산 보류는 KIS 가 실제로 죽었을 때만 일어난다.
_CIRCUIT_EXEMPT_READ_ENDPOINTS = (
"/uapi/domestic-stock/v1/trading/inquire-balance",
"/uapi/domestic-stock/v1/quotations/inquire-price",
"/uapi/domestic-stock/v1/trading/inquire-daily-ccld",
)
@staticmethod
def _safe_json(response) -> Any:
try:
return response.json()
except Exception:
return None
def _record_breaker_failure_v2(self, endpoint: str) -> None:
"""주문 엔드포인트 실패는 조회 breaker 에 세지 않는다(주문·조회 장애 분리)."""
if endpoint in self._NON_IDEMPOTENT_ENDPOINTS or _BREAKER_UNCOUNTED.get():
return
self.circuit_breaker.record_failure()
def _record_breaker_success_v2(self, endpoint: str) -> None:
if endpoint in self._NON_IDEMPOTENT_ENDPOINTS or _BREAKER_UNCOUNTED.get():
return
self.circuit_breaker.record_success()
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
last_exception = None
# 주문은 1회만 전송 — 타임아웃/5xx 에도 재전송하지 않는다(중복 주문 방지)
_max_retries = 0 if endpoint in self._NON_IDEMPOTENT_ENDPOINTS else self.max_retries
for attempt in range(_max_retries + 1):
try:
# 쓰로틀링: 최소 요청 간격 보장
# _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()
headers = {
"content-type": "application/json; charset=utf-8",
"authorization": f"Bearer {token}",
"appkey": self.app_key,
"appsecret": self.app_secret,
"tr_id": tr_id,
}
if extra_headers:
headers.update(extra_headers)
# 타임아웃 설정
timeout = httpx.Timeout(self.timeout, connect=10.0)
client = await self._get_http_client()
response = await client.request(
method,
f"{self.base_url}{endpoint}",
headers=headers,
timeout=timeout,
**kwargs
)
# [H-4] 토큰 무효/만료 응답 — 캐시 삭제. 조회 계열은 _make_request 가 **세마포어
# 밖에서** 재발급 후 1회 재전송한다(_KISTokenRefreshRequired). 주문 계열은 재전송하지
# 않는다(게이트웨이 거부라 미접수지만, 재전송 판단은 상위 체결 확인 경로에 맡긴다).
# 재전송도 무효면 더 반복하지 않고 응답을 그대로 돌려준다.
_bad_code = self._token_invalid_msg_code(self._safe_json(response))
if _bad_code:
await self._invalidate_token_v2(token)
if endpoint not in self._NON_IDEMPOTENT_ENDPOINTS:
if not _TOKEN_REFRESH_RETRIED.get():
raise _KISTokenRefreshRequired(_bad_code)
logger.error(f"❌ 재발급 토큰도 무효 응답 — 재시도 중단: {endpoint}")
# 성공
if response.status_code == 200:
duration = time.time() - start_time
self.monitor.record_call(success=True, duration=duration, retries=retry_count)
self._record_breaker_success_v2(endpoint)
if self.monitor.should_alert(threshold=settings.API_ERROR_THRESHOLD):
logger.warning(f"⚠️ API 오류율 높음: {self.monitor.get_error_rate():.1%}")
return response.json(), response.headers
# 서버 오류 (5xx, 429) — 재시도
elif response.status_code in [429, 500, 502, 503, 504]:
retry_count += 1
# [M-1 2026-10-02] 주문(비멱등) 엔드포인트는 재시도 0회라 곧바로 예외였다 →
# place_order 가 '전송 후 결과 불명'으로 분류. KIS 는 EGW00201(초당 거래건수
# 초과)·토큰 만료 등 **확인 가능한 거부**를 HTTP 5xx + JSON 본문으로 주는 것으로
# 알려져 있다(실측 미확인). 본문이 rt_cd/msg_cd 를 가진 JSON 이면 그 dict 를
# 그대로 돌려 호출부가 msg_cd 로 재시도/거부를 판단하게 한다. 본문이 없거나 JSON 이
# 아니면 기존대로 예외(= 결과 불명). 조회 계열은 동작 불변.
if endpoint in self._NON_IDEMPOTENT_ENDPOINTS:
_payload = self._safe_json(response)
if isinstance(_payload, dict) and ("rt_cd" in _payload or "msg_cd" in _payload):
self.monitor.record_call(success=False, duration=time.time() - start_time, retries=retry_count)
self._record_breaker_failure_v2(endpoint)
logger.warning(
f"⚠️ 주문 엔드포인트 HTTP {response.status_code} — KIS 거부 응답 반환 "
f"(rt_cd={_payload.get('rt_cd')} msg_cd={_payload.get('msg_cd')} "
f"msg1={_payload.get('msg1')}): {endpoint}"
)
return _payload, response.headers
if attempt < _max_retries:
delay = self.retry_delay * (self.retry_backoff ** attempt) + random.uniform(0, 0.3)
logger.warning(f"⏳ API 재시도 {attempt + 1}/{_max_retries}: {response.status_code}, {delay:.1f}초 후")
await asyncio.sleep(delay)
continue
else:
self._record_breaker_failure_v2(endpoint)
raise Exception(f"API 최대 재시도 초과: {response.status_code}")
# 클라이언트 오류 (4xx) — 재시도 불필요
else:
self.monitor.record_call(success=False, duration=time.time() - start_time, retries=retry_count)
try:
return response.json(), response.headers
except Exception:
return {"rt_cd": str(response.status_code), "msg1": response.text[:200], "output": None}, None
except KISTokenUnavailableError:
# [H-3] 토큰 재발급 대기 — KIS 장애가 아니므로 circuit breaker 에 세지 않는다
self.monitor.record_call(success=False, duration=time.time() - start_time, retries=retry_count)
raise
except _KISTokenRefreshRequired:
# [H-4] 재발급 신호 — 실패로 세지 않고 _make_request 로 올린다
raise
except httpx.TimeoutException as e:
retry_count += 1
last_exception = e
if attempt < _max_retries:
delay = self.retry_delay * (self.retry_backoff ** attempt) + random.uniform(0, 0.3)
logger.warning(f"⏳ API 타임아웃, {delay:.1f}초 후 재시도 ({attempt + 1}/{_max_retries})")
await asyncio.sleep(delay)
continue
else:
duration = time.time() - start_time
self.monitor.record_call(success=False, duration=duration, retries=retry_count)
self._record_breaker_failure_v2(endpoint)
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)
self._record_breaker_failure_v2(endpoint)
logger.error(f"❌ API 요청 중 예외 발생: {str(e)}")
raise
# 여기까지 왔다면 모든 재시도 실패
duration = time.time() - start_time
self.monitor.record_call(success=False, duration=duration, retries=retry_count)
self._record_breaker_failure_v2(endpoint)
if last_exception:
raise last_exception
raise Exception("API 요청 실패 (알 수 없는 오류)")
# ── 시세 조회 실서버 라우팅 ──────────────────────────────────────────────
# 모의투자 서버(openapivts)는 앱키당 초당 2건 제한 + 응답 지연이 잦아(EGW00201)
# 시세·차트·호가 조회를 감당하지 못한다. KIS_QUOTES_VIA_REAL=True 이고 실계좌
# 시세 인스턴스(real_market_api)가 있으면 조회성 호출만 실서버로 위임한다.
# 주문·잔고는 그대로 모의서버. (real_market_api 자신은 위임하지 않음 → 재귀 방지)
def _quote_delegate(self) -> "KoreaInvestmentAPI | None":
if not getattr(settings, "KIS_QUOTES_VIA_REAL", False):
return None
_real = globals().get("real_market_api")
if _real is None or _real is self:
return None
return _real
async def get_current_price(self, stock_code: str) -> Dict[str, Any]:
"""현재가 조회"""
_d = self._quote_delegate()
if _d is not None:
return await _d.get_current_price(stock_code)
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", limit: int = 240) -> Dict[str, Any]:
"""일봉/주봉/월봉 조회 (Redis 캐시 적용, 페이지네이션 지원으로 최대 240개까지 조회)
캐시 키: chart:daily:{stock_code}:{period}:{limit}
TTL: 5분 (장중 업데이트 주기 고려)
"""
_d = self._quote_delegate()
if _d is not None:
return await _d.get_daily_chart(stock_code, period, limit)
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 = []
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, headers = await self._make_request(
"GET",
"/uapi/domestic-stock/v1/quotations/inquire-daily-price",
"FHKST01010400",
params=params
)
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')
# 연속 조회
while tr_cont in ('F', 'M') and len(all_output2) < limit:
await asyncio.sleep(self.min_request_interval + 0.1)
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
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:
logger.error(f"Error fetching paginated chart for {stock_code}: {e}")
raise
async def get_daily_ohlcv_range(
self, stock_code: str, start_date: str, end_date: str
) -> list:
"""날짜 범위 일봉 OHLCV 전체 조회 (FHKST03010100)
KIS API는 1회 최대 100행 반환 → 날짜를 소급하며 start_date까지 전부 수집.
Args:
stock_code: 종목코드 (예: "005930")
start_date: 조회 시작일 YYYYMMDD
end_date: 조회 종료일 YYYYMMDD
Returns:
[{"stck_bsop_date": "20240101", "stck_oprc": ..., "stck_hgpr": ...,
"stck_lwpr": ..., "stck_clpr": ..., "acml_vol": ..., "prdy_ctrt": ...}, ...]
최신 → 오래된 순 정렬 (KIS API 기본값)
"""
all_data: list = []
current_end = end_date
while True:
params = {
"FID_COND_MRKT_DIV_CODE": "J",
"FID_INPUT_ISCD": stock_code,
"FID_INPUT_DATE_1": start_date,
"FID_INPUT_DATE_2": current_end,
"FID_PERIOD_DIV_CODE": "D",
"FID_ORG_ADJ_PRC": "0", # 수정주가
}
try:
result, _ = await self._make_request(
"GET",
"/uapi/domestic-stock/v1/quotations/inquire-daily-itemchartprice",
"FHKST03010100",
params=params,
)
except Exception as exc:
logger.error(
"get_daily_ohlcv_range %s %s~%s 실패: %s",
stock_code, start_date, current_end, exc,
)
break
if not result or result.get("rt_cd") != "0":
logger.warning(
"get_daily_ohlcv_range %s: %s",
stock_code, result.get("msg1") if result else "no response",
)
break
output = result.get("output2") or []
if not output:
break
all_data.extend(output)
# 가장 오래된 행의 날짜를 확인하고 그보다 하루 전으로 소급
oldest_date = output[-1].get("stck_bsop_date", "")
if not oldest_date or oldest_date <= start_date:
break
oldest_dt = datetime.strptime(oldest_date, "%Y%m%d")
current_end = (oldest_dt - timedelta(days=1)).strftime("%Y%m%d")
if current_end < start_date:
break
await asyncio.sleep(self.min_request_interval)
return all_data
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())
result, _ = await self._make_request(
"GET",
"/uapi/overseas-price/v1/quotations/price",
"HHDFS00000300",
params={
"AUTH": "",
"EXCD": exchange_code.upper(),
"SYMB": symbol.upper()
}
)
return result
except Exception as 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, 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개 분봉 반환.
Args:
stock_code: 종목코드 (e.g. "005930")
ref_time: 조회 기준 시간 (HHMMSS). 빈 문자열이면 현재 시간
include_past: True = 과거 데이터 포함 (Y), False = 당일만 (N)
Returns:
{
"output1": { 현재가 요약 },
"output2": [
{
"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",
}, ...
]
}
"""
_d = self._quote_delegate()
if _d is not None:
return await _d.get_minute_chart(stock_code, ref_time, include_past)
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")
# 캐시 키: 분 단위 버킷 (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_COND_MRKT_DIV_CODE": "J",
"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,
)
# ── 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}")
raise
async def get_minute_chart_full_day(
self,
stock_code: str,
market_open: str = "090000",
) -> list:
"""당일 전체 분봉 데이터 수집 (Redis 캐시 적용, 여러 번 호출 집계)
**당일 봉만 반환한다** — 동작·시그니처 모두 기존과 같다(CLAUDE.md §4).
ORB 레인지·평균분당거래량·VWAP·현재가처럼 **세션 앵커** 기준이 필요한
소비지점(`daytrading/entry_service.collect_opening_ranges`,
`daytrading/exit_service`, `api/endpoints/day_trading`)이 이 메서드를 쓴다.
전일 봉을 워밍업으로 함께 받아야 하는 경우(5분봉 RSI(14)/BB(20))는
`get_minute_chart_full_day_v2(..., include_prev_day=True)` 를 쓴다.
캐시 키: chart:minute_fullday:{stock_code}:{YYYYMMDD_HHMM}
- HHMM: 현재 시각의 분 단위 버킷 → 1분마다 갱신
TTL: 1분
09:00부터 현재까지의 모든 1분봉을 순서대로 반환.
한 번 호출에 30개씩 → 최대 ~13회 호출 필요 (390분/30).
Returns:
List[dict] — output2 레코드 목록 (오래된 것 → 최신 순)
"""
return await self._get_minute_chart_full_day_impl(
stock_code, market_open, include_prev_day=False
)
async def get_minute_chart_full_day_v2(
self,
stock_code: str,
market_open: str = "090000",
include_prev_day: bool = False,
prev_day_minutes: int = MINUTE_CHART_PREV_DAY_MINUTES,
) -> list:
"""분봉 수집 — 전일 봉 포함 여부를 **호출부가 선택**한다.
`include_prev_day=False`(기본)면 `get_minute_chart_full_day()` 와 완전히 같다.
`include_prev_day=True` 면 전일 종반 봉을 **부족한 5분봉 개수만큼**
(`MINUTE_CHART_WARMUP_BARS_5MIN` 기준, `prev_day_minutes`·
`MINUTE_CHART_PREV_DAY_MAX_PAGES` 가 상한) 더 수집해 앞쪽에 붙인다.
용도는 **RSI(14)/BB(20) 판정 게이트의 워밍업**이다 — 5분봉 RSI(14)=15봉/
BB(20)=20봉 요건 때문에 당일 봉만으로는 VWAP 전략이 약 10:15, RSI_BB 가 약
10:40 이전에 발화하지 못한다 (`indicators_core.rsi_wilder` 는 봉이 모자라면
`None` 을 반환하고 짧은 기간으로 대체 계산하지 않는다).
⚠️ **"워밍업이 RSI/BB 에만 영향을 준다"는 단언은 사실이 아니었다** (2026-09-30
정정). 이 시계열을 그대로 ATR 에 넘기면 **당일 첫 봉의 TR 이 오버나이트 갭**이
되어 손절·목표가가 부풀었다(`day_trading_strategy._atr` docstring 참조).
지금은 `_atr` 이 내부에서 `_last_session_candles()` 로 세션을 끊어 막는다 —
**TR·ATR 처럼 봉 간 차분을 쓰는 지표에 이 시계열을 새로 먹이지 말 것.**
⚠️ **미검증 전제**: 이 경로는 "장중에 `FID_INPUT_HOUR_1` 로 **미래 시각**
(예: 전일 14:51)을 주면 KIS 가 전일 데이터를 돌려준다"에 의존한다. 요청 파라미터에
날짜 필드가 없어(`get_minute_chart` 참조) 그렇게 밖에 거슬러 갈 수 없는데,
**KIS 가 실제로 그렇게 응답한다는 보장은 코드·테스트에 없다.** 최신 봉을 그대로
돌려주면 전부 dedupe 되어 `new_added==0` 으로 즉시 중단되고, 전일 봉은 경계
페이지 1장(약 29봉)만 남는다 — 그때는 워밍업이 부분적으로만 채워져 발화 시각이
조금 앞당겨지는 수준에 그친다. 확보량은 종료 시 로그로 남으므로 **운영 로그로만
확정할 수 있다**. 따라서 "개장 직후부터 발화한다"고 단언하지 말 것.
⚠️ 반환 시계열에는 **날짜가 다른 봉이 섞여 있다.** 세션 앵커 지표
(VWAP·ORB 레인지·평균분당거래량·현재가·당일 거래량·갭)에 그대로 넘기면
CLAUDE.md §7 마지막 항목이 경고하는 오염이 발생한다. 호출부는
`entry_service.filter_today_candles()` 로 당일 봉을 분리해 쓸 것.
Returns:
List[dict] — output2 레코드 목록 (영업일자+시각 오름차순)
"""
return await self._get_minute_chart_full_day_impl(
stock_code,
market_open,
include_prev_day=include_prev_day,
prev_day_minutes=prev_day_minutes,
)
async def _get_minute_chart_full_day_impl(
self,
stock_code: str,
market_open: str = "090000",
include_prev_day: bool = False,
prev_day_minutes: int = MINUTE_CHART_PREV_DAY_MINUTES,
) -> list:
"""분봉 수집 공통 구현 — 목킹은 공통 종단인 이 메서드가 아니라 공개
메서드(`get_minute_chart_full_day`/`_v2`)에 걸어도 되게 위임 구조를 유지한다
(CLAUDE.md §4.1).
"""
_d = self._quote_delegate()
if _d is not None:
# 위임 대상의 **공개 메서드**를 호출한다 — 기존 목킹 지점 보존
if include_prev_day:
return await _d.get_minute_chart_full_day_v2(
stock_code, market_open,
include_prev_day=True, prev_day_minutes=prev_day_minutes,
)
return await _d.get_minute_chart_full_day(stock_code, market_open)
from datetime import datetime
import pytz
kst = pytz.timezone("Asia/Seoul")
now_dt = datetime.now(kst)
today_str = now_dt.strftime("%Y%m%d")
now_bucket = now_dt.strftime("%H%M") # 1분 단위 버킷
# 전일 포함 응답은 내용이 다르므로 캐시 키를 분리한다 (섞이면 세션 앵커 오염)
_variant = f":prev{prev_day_minutes}" if include_prev_day else ""
cache_key = f"chart:minute_fullday:{stock_code}:{today_str}_{now_bucket}{_variant}"
# ── 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()
prev_day_count = 0 # include_prev_day 일 때 수집한 전일 1분봉 수(상한 판정용)
today_count = 0 # 당일 1분봉 수
today_buckets: set = set() # 당일 5분봉 버킷 (워밍업 충족 판정)
prev_buckets: set = set() # 전일 5분봉 버킷
prev_pages = 0 # 전일 구간을 파기 위해 쓴 페이지 수
for _ in range(MINUTE_CHART_MAX_PAGES):
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
# [M4 2026-09-27] get_minute_chart 는 FID_PW_DATA_INCU_YN="Y"(과거 포함)로
# 호출되므로 장 초반에는 전일 종반(15:00~15:20) 캔들이 섞여 들어온다.
# 시각(HHMMSS)만으로 dedupe/정렬하면 "152000" > "092000" 이라 전일 캔들이
# 리스트 끝에 놓여 candles[-1] 기반 계산(ORB 거래량 폭발, VWAP)이 전일 값으로
# 오염된다. 영업일자(stck_bsop_date, 응답 필드 — 본 파일 get_minute_chart
# docstring 참조)를 키에 포함하고, 당일 캔들만 유지한다.
new_added = 0
for c in candles:
t = c.get("stck_cntg_hour", "")
d = (c.get("stck_bsop_date") or "").strip()
if not t:
continue
_is_prev = bool(d) and d != today_str
# 영업일자가 있으면 당일이 아닌 캔들은 제외 (없으면 필터 불가 → 유지).
# include_prev_day=True 는 워밍업 목적으로 이 제외를 끈다.
if _is_prev and not include_prev_day:
continue
key = f"{d}_{t}"
if key not in seen_times:
seen_times.add(key)
all_candles.append(c)
new_added += 1
if _is_prev:
prev_day_count += 1
else:
today_count += 1
# 5분봉 버킷 집계 — 워밍업 종료 판정의 기준(분 수가 아니라 봉 수).
# 거래 없는 분이 응답에서 빠져도 봉 수는 정확히 센다.
if len(t) >= 4 and t[:4].isdigit():
_bkt = (int(t[:2]) * 60 + int(t[2:4])) // 5
(prev_buckets if _is_prev else today_buckets).add((d, _bkt))
if include_prev_day:
# 다음 기준 시각은 **영업일자+시각의 최솟값**으로 잡아야 한다.
# 시각만 비교하면 전일 14:51 봉이 당일 09:00 보다 크게 평가돼
# ref 가 갱신되지 않고(=같은 페이지 재요청) 한 페이지에서 멈춘다.
_keyed = sorted(
((c.get("stck_bsop_date") or today_str).strip(),
c.get("stck_cntg_hour") or "999999")
for c in candles if c.get("stck_cntg_hour")
)
oldest_date, oldest = _keyed[0] if _keyed else (today_str, "000000")
_today_done = oldest_date < today_str or oldest <= market_open
# 워밍업 목표 = **부족한 5분봉 개수**. 당일 5분봉이 이미 20개면 0이 되어
# 전일 봉을 한 장도 더 받지 않는다(10:40 이후). 09:30 엔 6개뿐이라
# 14개만 더 받으면 끝난다. 분 수 상한·전일 페이지 상한은 저유동성 종목에서
# 무한 페이징을 막는 안전장치다.
_needed_bars = max(0, MINUTE_CHART_WARMUP_BARS_5MIN - len(today_buckets))
_warm_enough = (
len(prev_buckets) >= _needed_bars
or prev_day_count >= prev_day_minutes
or prev_pages >= MINUTE_CHART_PREV_DAY_MAX_PAGES
)
if (_today_done and _warm_enough) or new_added == 0:
break
if _today_done:
# 다음 요청은 전일 구간을 파러 가는 페이지다
prev_pages += 1
else:
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_bsop_date") or today_str).strip(),
c.get("stck_cntg_hour", ""),
)
)
# 워밍업 미달은 조용히 지나가면 "왜 여전히 발화하지 않는가"를 추적할 수 없다
# (지표가 None 이 되어 진입 판정이 그냥 사라진다).
# ⚠️ **이 로그가 "KIS 가 미래 ref_time 에 전일 데이터를 주는가"를 확정하는 유일한
# 근거다.** 전일 봉이 경계 페이지 1장(약 29봉)에서 멈추면 여기서 드러난다.
if include_prev_day:
_bars = len(today_buckets) + len(prev_buckets)
if _bars < MINUTE_CHART_WARMUP_BARS_5MIN:
logger.warning(
f"[분봉] ⚠️ {stock_code} 워밍업 미달 — 5분봉 {_bars}/"
f"{MINUTE_CHART_WARMUP_BARS_5MIN}봉 "
f"(당일 {len(today_buckets)} + 전일 {len(prev_buckets)}, "
f"전일 1분봉 {prev_day_count}봉/{prev_pages}페이지). "
f"BB(20) 미충족 → RSI_BB 스킵, 15봉 미달이면 VWAP 도 스킵된다"
)
else:
logger.info(
f"[분봉] {stock_code} 워밍업 5분봉 {_bars}봉 "
f"(당일 {len(today_buckets)} + 전일 {len(prev_buckets)})"
)
# ── 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:
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"
}
)
# ── 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}")
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:
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"
else:
tr_id = "VTTT1006U" if is_virtual else "TTTT1006U"
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"
}
)
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:]
# 모의투자 환경 확인 (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",
tr_id,
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": ""
}
)
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
except Exception as e:
logger.error(f"Error fetching balance: {e}")
raise
# ── [9회차 D-1] 잔고 연속조회 ──────────────────────────────────────────
_BALANCE_ENDPOINT = "/uapi/domestic-stock/v1/trading/inquire-balance"
def _balance_request_v2(self, ctx_fk100: str = "", ctx_nk100: str = "") -> tuple:
"""inquire-balance 요청 (tr_id, params) — get_balance() 와 동일한 파라미터 + 연속조회 키."""
is_virtual = "vts" in self.base_url.lower()
tr_id = "VTTC8434R" if is_virtual else "TTTC8434R"
params = {
"CANO": self.account_number[:8],
"ACNT_PRDT_CD": self.account_number[8:],
"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_fk100 or "",
"CTX_AREA_NK100": ctx_nk100 or "",
}
return tr_id, params
async def get_balance_all_v2(self) -> Dict[str, Any]:
"""계좌 잔고 조회 — 연속조회(tr_cont F/M → 요청 헤더 tr_cont=N)로 전 페이지 합산.
반환 형태는 get_balance() 와 같다(rt_cd/msg_cd/msg1/output1/output2 ...).
- output1: 전 페이지 합산(pdno 기준 dedupe, 먼저 나온 행 유지)
- output2: 첫 페이지 것(총자산 등)
- _pages: 실제로 받은 페이지 수(성공 페이지만)
- _partial: 합산이 불완전하면 True — 2페이지 이후 조회 실패(예외·rt_cd≠0), 다음 페이지가
있다는데 CTX_AREA_NK100 이 비어 있음, KIS_BALANCE_MAX_PAGES 상한 도달 후에도 남은 페이지.
완전하면 키가 없다. 호출부는 _partial 이면 "미발견 = 미보유" 로 판단하지 말 것.
첫 페이지 실패는 get_balance() 와 동일(예외는 그대로 raise, rt_cd≠0 은 error 로그 후 그 dict 반환).
"""
max_pages = max(1, int(getattr(settings, "KIS_BALANCE_MAX_PAGES", 5) or 1))
try:
tr_id, params = self._balance_request_v2()
first, headers = await self._make_request("GET", self._BALANCE_ENDPOINT, tr_id, params=params)
except Exception as e:
logger.error(f"Error fetching balance (all pages): {e}")
raise
if not isinstance(first, dict) or first.get("rt_cd") != "0":
_r = first if isinstance(first, dict) else {}
logger.error(f"❌ Balance API error - rt_cd={_r.get('rt_cd')}, msg1={_r.get('msg1')}")
return first
merged: list = []
seen: set = set()
def _absorb(rows: Any) -> None:
if isinstance(rows, dict):
rows = [rows]
if not isinstance(rows, list):
return
for row in rows:
code = str(row.get("pdno") or "").strip() if isinstance(row, dict) else ""
if code:
if code in seen:
continue
seen.add(code)
merged.append(row)
_absorb(first.get("output1"))
pages = 1
partial = False
tr_cont = ((headers.get("tr_cont") if headers is not None else None) or "").strip()
page_result = first
while tr_cont in ("F", "M"):
ctx_fk = str(page_result.get("ctx_area_fk100") or "").strip()
ctx_nk = str(page_result.get("ctx_area_nk100") or "").strip()
if not ctx_nk:
partial = True
logger.warning(
f"[get_balance_all_v2] tr_cont={tr_cont} 인데 ctx_area_nk100 비어 있음 — "
f"{pages}페이지까지만 합산(불완전)"
)
break
if pages >= max_pages:
partial = True
logger.warning(
f"[get_balance_all_v2] 연속조회 상한 KIS_BALANCE_MAX_PAGES={max_pages} 도달 — "
f"남은 페이지 있음(tr_cont={tr_cont}), 불완전"
)
break
try:
tr_id, params = self._balance_request_v2(ctx_fk100=ctx_fk, ctx_nk100=ctx_nk)
page_result, headers = await self._make_request(
"GET", self._BALANCE_ENDPOINT, tr_id, extra_headers={"tr_cont": "N"}, params=params,
)
except Exception as e:
partial = True
logger.warning(
f"[get_balance_all_v2] {pages + 1}페이지 조회 예외 — {pages}페이지까지만 합산(불완전): "
f"{type(e).__name__}: {e}"
)
break
if not isinstance(page_result, dict) or page_result.get("rt_cd") != "0":
partial = True
_r = page_result if isinstance(page_result, dict) else {}
logger.warning(
f"[get_balance_all_v2] {pages + 1}페이지 조회 실패 rt_cd={_r.get('rt_cd')} "
f"msg_cd={_r.get('msg_cd')} msg1={_r.get('msg1')} — {pages}페이지까지만 합산(불완전)"
)
break
pages += 1
_absorb(page_result.get("output1"))
tr_cont = ((headers.get("tr_cont") if headers is not None else None) or "").strip()
out = dict(first)
out["output1"] = merged
out["output2"] = first.get("output2")
out["_pages"] = pages
if partial:
out["_partial"] = True
return out
async def get_todays_orders(self, stock_code: str = "") -> list[dict]:
"""당일 주문체결 내역 조회 (inquire-daily-ccld) — 첫 페이지만(실전 100건 / 모의 15건, 역순).
주문 전송 후 응답을 못 받았을 때 '실제로 접수됐는지' 확인해 중복 주문을 막는 용도.
TR: 현행 TTTC0081R/VTTC0081R 우선, 실패 시 구 TTTC8001R/VTTC8001R. 실패 시 빈 리스트.
(실패와 '주문 없음' 을 구분해야 하면 get_todays_orders_v2 — 실패 시 None)
"""
return await self._get_todays_orders_impl(stock_code=stock_code, strict=False)
async def get_todays_orders_v2(self, stock_code: str = "") -> Optional[list]:
"""[9회차 D-3] get_todays_orders 와 같되 **조회 실패면 None**(예외·rt_cd≠0·output1 파싱 불가).
성공 시 list(주문 없음이면 []). 결과 불명 주문 판정에서 '조회 실패' 를 '미접수' 로
오인해 재주문(과다매도)하지 않도록 호출부는 None 을 "판정 보류"로 취급할 것.
"""
return await self._get_todays_orders_impl(stock_code=stock_code, strict=True)
async def _get_todays_orders_impl(self, stock_code: str = "", strict: bool = False) -> Optional[list]:
"""get_todays_orders(_v2) 공통 본체. strict=False 면 실패 시 [](기존 동작), True 면 None."""
fail: Optional[list] = None if strict else []
try:
result, _ = await self._daily_ccld_fetch_v2(stock_code=stock_code)
if result.get("rt_cd") != "0":
# [2026-10-01] 이전에는 조용히 [] — 조회 실패와 '주문 없음' 이 구분되지 않았다.
logger.warning(
f"[get_todays_orders] {stock_code or '*'} 조회 실패 "
f"rt_cd={result.get('rt_cd')} msg_cd={result.get('msg_cd')} msg1={result.get('msg1')}"
)
return fail
out = result.get("output1") or []
if isinstance(out, list):
return out
if strict and not isinstance(out, dict):
logger.warning(
f"[get_todays_orders_v2] {stock_code or '*'} output1 파싱 불가 "
f"(type={type(out).__name__}) — 조회 실패로 취급"
)
return None
return [out]
except Exception as e:
# CLAUDE.md §4: 예외 로그 debug 금지 (중복주문 방지·DT 체결가 확인의 근거 조회)
logger.warning(f"[get_todays_orders] {stock_code or '*'} 조회 예외: {type(e).__name__}: {e}")
return fail
def _daily_ccld_request_v2(
self,
stock_code: str = "",
order_id: str = "",
ctx_fk100: str = "",
ctx_nk100: str = "",
tr_generation: str = "current",
) -> tuple:
"""inquire-daily-ccld 요청 (tr_id, params) 구성 — 당일 조회. 모의/실전 TR 분기 포함.
`get_todays_orders` 와 `get_order_execution` 이 같은 요청 구성을 공유한다
(2026-10-01 이전에는 후자가 존재하지 않는 `/trading/inquire-ccnl` 경로로 따로 조립했다).
tr_generation: "current"(TTTC0081R/VTTC0081R) | "legacy"(TTTC8001R/VTTC8001R)
"""
is_virtual = "vts" in self.base_url.lower()
today = datetime.now().strftime("%Y%m%d")
real_tr, virtual_tr = DAILY_CCLD_TR_IDS[tr_generation]
tr_id = virtual_tr if is_virtual else real_tr
params = {
"CANO": self.account_number[:8],
"ACNT_PRDT_CD": self.account_number[8:],
"INQR_STRT_DT": today,
"INQR_END_DT": today,
"SLL_BUY_DVSN_CD": "00", # 00 전체 / 01 매도 / 02 매수
"INQR_DVSN": "00", # 00 역순(최신 먼저)
"PDNO": stock_code or "",
"CCLD_DVSN": "00", # 00 전체 / 01 체결 / 02 미체결
"ORD_GNO_BRNO": "",
"ODNO": order_id or "",
"INQR_DVSN_3": "00",
"INQR_DVSN_1": "",
"CTX_AREA_FK100": ctx_fk100 or "",
"CTX_AREA_NK100": ctx_nk100 or "",
"EXCG_ID_DVSN_CD": "KRX", # 거래소ID구분 (공식 샘플 기본값 KRX / NXT / SOR / ALL)
}
return tr_id, params
async def _daily_ccld_fetch_v2(
self,
stock_code: str = "",
order_id: str = "",
ctx_fk100: str = "",
ctx_nk100: str = "",
next_page: bool = False,
) -> tuple:
"""inquire-daily-ccld 1페이지 호출 → (result, headers). 예외는 그대로 올린다.
현행 TR 이 rt_cd != "0" 이면 구 TR 로 1회 재시도하고, 구 TR 이 성공하면 이후 구 TR 을
우선 쓴다(인스턴스 상태). 둘 다 실패하면 마지막 결과를 그대로 반환(호출부가 warning).
연속조회는 요청 헤더 tr_cont=N (공식 샘플 규약).
"""
preferred = getattr(self, "_daily_ccld_tr_generation", "current")
order = [preferred] + [g for g in ("current", "legacy") if g != preferred]
extra = {"tr_cont": "N"} if next_page else None
result, headers = {}, None
for i, gen in enumerate(order):
tr_id, params = self._daily_ccld_request_v2(
stock_code=stock_code, order_id=order_id,
ctx_fk100=ctx_fk100, ctx_nk100=ctx_nk100, tr_generation=gen,
)
result, headers = await self._make_request(
"GET", DAILY_CCLD_ENDPOINT, tr_id, extra_headers=extra, params=params,
)
if isinstance(result, dict) and result.get("rt_cd") == "0":
if gen != preferred:
logger.warning(
f"[daily-ccld] TR {gen} 로 전환 (선호 TR {preferred} 실패) — tr_id={tr_id}"
)
self._daily_ccld_tr_generation = gen
return result, headers
if i == 0:
_r = result if isinstance(result, dict) else {}
logger.warning(
f"[daily-ccld] tr_id={tr_id} 실패 rt_cd={_r.get('rt_cd')} "
f"msg_cd={_r.get('msg_cd')} msg1={_r.get('msg1')} — 대체 TR 재시도"
)
return result, headers
async def get_buyable_cash(self, stock_code: str = "005930") -> float:
"""주문가능현금(ord_psbl_cash) 조회 — 매수가능조회 API.
예수금총액(dnca_tot_amt)은 D+2 결제 기준이라 당일 매도대금이 반영되지 않는다.
국내 주식은 당일 매도대금을 즉시 재매수에 쓸 수 있으므로, 화면 '예수금'과
데이트레이딩 가용 현금은 이 값을 우선 사용한다. (2026-09-22)
실전: TTTC8908R / 모의: VTTC8908R. 실패 시 0.0 반환(호출측에서 폴백).
"""
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 = "VTTC8908R" if is_virtual else "TTTC8908R"
result, _ = await self._make_request(
"GET",
"/uapi/domestic-stock/v1/trading/inquire-psbl-order",
tr_id,
params={
"CANO": account_number_prefix,
"ACNT_PRDT_CD": account_number_suffix,
"PDNO": stock_code,
"ORD_UNPR": "0",
"ORD_DVSN": "01", # 시장가 기준
"CMA_EVLU_AMT_ICLD_YN": "N",
"OVRS_ICLD_YN": "N",
},
)
if result.get("rt_cd") != "0":
logger.warning(f"[get_buyable_cash] rt_cd={result.get('rt_cd')} msg1={result.get('msg1')}")
return 0.0
out = result.get("output") or {}
if isinstance(out, list):
out = out[0] if out else {}
return float(out.get("ord_psbl_cash", 0) or 0)
except Exception as e:
logger.warning(f"[get_buyable_cash] 조회 실패: {e}")
return 0.0
async def get_overseas_balance(self) -> Dict[str, Any]:
"""해외주식 잔고 조회 (미국 기준)
실전: TTTS3012R / 모의: VTTS3012R
응답 output1: 보유 종목 목록 (ovrs_pdno, ovrs_item_name, ovrs_excg_cd, ord_psbl_qty, pchs_avg_pric, now_pric2, evlu_pfls_amt, evlu_pfls_rt2)
응답 output2: 계좌 요약 (tot_evlu_amt, frcr_evlu_tota, usd_amt, ovrs_tot_pfls)
"""
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 = "VTTS3012R" if is_virtual else "TTTS3012R"
result, _ = await self._make_request(
"GET",
"/uapi/overseas-stock/v1/trading/inquire-balance",
tr_id,
params={
"CANO": account_number_prefix,
"ACNT_PRDT_CD": account_number_suffix,
"OVRS_EXCG_CD": "NASD", # 미국 전체 (NASD 조회 시 NYSE·AMEX 포함)
"TR_CRCY_CD": "USD",
"CTX_AREA_FK200": "",
"CTX_AREA_NK200": "",
}
)
return result
except Exception as e:
logger.error(f"Error fetching overseas balance: {e}")
return {}
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:
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()
if order_type == "BUY":
tr_id = "VTTC0802U" if is_virtual else "TTTC0802U"
else:
tr_id = "VTTC0801U" if is_virtual else "TTTC0801U"
order_dvsn = "01" if price is None else "00"
order_price = "0" if price is None else str(_round_to_krx_tick(int(price)))
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,
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
}
)
logger.info(f"📥 주문 응답: rt_cd={result.get('rt_cd')}, msg1={result.get('msg1')}, odno={result.get('odno')}")
return result
except KISTokenUnavailableError as e:
# [2026-10-02] **전송 전 실패** — 주문이 KIS 에 도달하지 않았다. 호출부가 "미전송"과
# "전송 후 결과 불명"을 구분할 수 있게 msg_cd 를 고정값으로 준다(재시도·기록 판단용).
logger.warning(f"⏳ 주문 미전송 — 토큰 재발급 대기 중 ({e.retry_after_sec:.0f}초 후 가능): {stock_code}")
return {
"rt_cd": "-1",
"msg_cd": KIS_PRESEND_TOKEN_UNAVAILABLE,
"msg1": str(e),
"odno": None,
"retry_after_sec": e.retry_after_sec,
}
except Exception as e:
# [H-1 2026-10-02] 전송 전 실패와 '전송 후 응답 유실'을 msg_cd 로 구분한다.
# 예전엔 둘 다 msg_cd 없는 rt_cd "-1" 이라, 호출부가 응답을 잃은 매도를 '거부'로
# 보고 수량을 유지한 채 재매도할 수 있었다(체결됐으면 이중 매도·스윙 물량 매도).
# - 연결 수립 실패·풀 대기 초과·circuit breaker 차단 → 요청이 나가지 않음(전송 전)
# - 그 외(ReadTimeout·연결 끊김·5xx 재시도 초과·알 수 없음) → 접수 여부 불명
_presend_types = (httpx.ConnectError, httpx.ConnectTimeout, httpx.PoolTimeout)
if isinstance(e, _presend_types):
_msg_cd = "KIS_PRESEND_CONNECT"
elif "Circuit breaker open" in str(e):
_msg_cd = "KIS_PRESEND_CIRCUIT_OPEN"
else:
_msg_cd = "KIS_TRANSPORT_UNKNOWN"
logger.error(f"❌ 주문 실행 오류({_msg_cd}): {type(e).__name__}: {e}", exc_info=True)
return {
"rt_cd": "-1",
"msg_cd": _msg_cd,
"msg1": str(e),
"odno": None
}
async def get_order_execution(self, order_id: str, is_domestic: bool = True) -> Dict[str, Any]:
"""주문 체결 내역 단건 조회.
국내: GET /uapi/domestic-stock/v1/trading/inquire-daily-ccld,
TTTC0081R/VTTC0081R (현행) → 실패 시 TTTC8001R/VTTC8001R (구)
Returns:
{"filled_qty": int, "filled_price": float} ← 기존 계약(불변)
+ 추가 키(국내, 하위호환): "found"(주문행 발견), "ok"(조회 성공),
"ordered_qty", "remaining_qty"(None=모름), "cancelled",
"cancel_confirmed_qty", "rejected_qty"
체결 미확인·조회 실패 시 filled_qty=0 반환.
[2026-10-01] 이전 구현은 `/domestic-stock/v1/trading/inquire-ccnl` 을 호출했다 — KIS 에
그런 경로는 없다(국내 `inquire-ccnl` 은 `/quotations/` 아래 시세 체결 FHKST01010300,
`/trading/inquire-ccnl` 은 해외 `/overseas-stock/` 의 체결조회 TTTS3035R). 그리고 실패를 debug 로
삼키거나(예외) 아예 로그 없이(4xx 응답) filled_qty=0 을 돌려줬다 → 체결 확인이 항상 실패.
"""
if not is_domestic:
# 해외 체결 조회(TTTS3035R)는 미구현 — 포트폴리오 동기화로 대체
return {"filled_qty": 0, "filled_price": 0.0}
return await self._get_order_execution_domestic_impl(order_id)
async def _get_order_execution_domestic_impl(self, order_id: str) -> Dict[str, Any]:
"""국내 단건 체결조회 본체 — inquire-daily-ccld 를 ODNO 로 조회 + 클라이언트측 ODNO 매칭.
서버측 ODNO 필터가 기대대로 동작하지 않아도(전체 반환) 응답에서 주문번호로 다시 고르고,
연속조회(tr_cont F/M → N)로 최대 ORDER_EXEC_MAX_PAGES 페이지까지 찾는다.
"""
empty = {"filled_qty": 0, "filled_price": 0.0, "found": False, "ok": False,
"ordered_qty": None, "remaining_qty": None, "cancelled": False,
"cancel_confirmed_qty": None, "rejected_qty": None}
if not order_id:
return empty
try:
ctx_fk, ctx_nk = "", ""
for page in range(ORDER_EXEC_MAX_PAGES):
result, headers = await self._daily_ccld_fetch_v2(
order_id=order_id, ctx_fk100=ctx_fk, ctx_nk100=ctx_nk, next_page=page > 0,
)
if not isinstance(result, dict) or result.get("rt_cd") != "0":
_r = result if isinstance(result, dict) else {}
logger.warning(
f"[get_order_execution] order_id={order_id} 체결조회 실패 "
f"rt_cd={_r.get('rt_cd')} msg_cd={_r.get('msg_cd')} msg1={_r.get('msg1')}"
)
return empty
parsed = parse_order_execution_rows(result.get("output1") or [], order_id)
if parsed is not None:
return {**parsed, "found": True, "ok": True}
tr_cont = (headers.get("tr_cont") if headers is not None else None) or ""
ctx_fk = str(result.get("ctx_area_fk100") or "").strip()
ctx_nk = str(result.get("ctx_area_nk100") or "").strip()
if tr_cont not in ("F", "M") or not ctx_nk:
break
return {**empty, "ok": True} # 조회는 성공했으나 주문행 없음(접수 전/다른 날)
except Exception as e:
logger.warning(
f"[get_order_execution] order_id={order_id} 체결조회 예외: {type(e).__name__}: {e}"
)
return empty
async def cancel_order(self, order_id: str, quantity: int) -> Dict[str, Any]:
"""주문 취소 (레거시 시그니처) — `cancel_order_v2` 로 위임.
기존 동작대로 **잔량 전부 취소**(QTY_ALL_ORD_YN=Y)다. `quantity` 는 호출부 호환을 위해
받기만 한다(이전 구현도 무시했다). 조직번호는 당일 주문내역에서 조회한다.
"""
return await self.cancel_order_v2(order_id, org_no=None, quantity=None)
async def _lookup_order_org_no_v2(self, order_id: str, stock_code: str = "") -> Optional[str]:
"""당일 주문내역(inquire-daily-ccld)에서 ODNO 의 주문채번지점번호(`ord_gno_brno`) 조회.
정정취소 API 의 KRX_FWDG_ORD_ORGNO 에 넣는 값이다(공식 legacy 샘플 `get_orders` →
`_do_cancel_revise` 가 같은 필드를 쓴다). 못 찾으면 None.
"""
try:
result, _ = await self._daily_ccld_fetch_v2(stock_code=stock_code, order_id=order_id)
except Exception as e:
logger.warning(f"[cancel] 조직번호 조회 예외 (odno={order_id}): {type(e).__name__}: {e}")
return None
if not isinstance(result, dict) or result.get("rt_cd") != "0":
_r = result if isinstance(result, dict) else {}
logger.warning(
f"[cancel] 조직번호 조회 실패 (odno={order_id}) rt_cd={_r.get('rt_cd')} "
f"msg_cd={_r.get('msg_cd')} msg1={_r.get('msg1')}"
)
return None
rows = result.get("output1") or []
rows = rows if isinstance(rows, list) else [rows]
want = _norm_kis_odno(order_id)
for row in rows:
if isinstance(row, dict) and _norm_kis_odno(row.get("odno")) == want:
org = str(row.get("ord_gno_brno") or "").strip()
if org:
return org
logger.warning(f"[cancel] 당일 주문내역에 odno={order_id} 의 조직번호 없음 ({len(rows)}행 조회)")
return None
async def cancel_order_v2(
self,
order_id: str,
org_no: Optional[str] = None,
quantity: Optional[int] = None,
stock_code: str = "",
exchange: str = KIS_CANCEL_DEFAULT_EXCHANGE,
) -> Dict[str, Any]:
"""국내주식 주문 취소 — 공식 샘플 `examples_llm/domestic_stock/order_rvsecncl` 기준 (2026-10-02).
이전 구현은 KRX_FWDG_ORD_ORGNO="" 로 보내 실전에서 거부될 수 있었다(필수값).
Args:
order_id: 원주문번호(ODNO)
org_no: 한국거래소전송주문조직번호 — 주문 응답 `output.KRX_FWDG_ORD_ORGNO`
(`extract_order_org_no(place_order 결과)`). None 이면 당일 주문내역의
`ord_gno_brno` 로 조회한다. 끝내 없으면 **전송하지 않고** 실패 반환.
quantity: None/0 이면 잔량 전부 취소(QTY_ALL_ORD_YN=Y, ORD_QTY=0), 양수면 그 수량만 취소.
stock_code: 조직번호 조회 범위를 좁히는 용도(선택).
exchange: EXCG_ID_DVSN_CD (KRX/NXT/SOR).
Returns: KIS 응답 dict. 전송 전 실패는 rt_cd="-1" + msg_cd
(`KIS_PRESEND_ORGNO_NOT_FOUND` / `KIS_PRESEND_TOKEN_UNAVAILABLE`).
"""
try:
if not org_no:
org_no = await self._lookup_order_org_no_v2(order_id, stock_code=stock_code)
if not org_no:
logger.warning(f"⚠️ [cancel] 조직번호 미확인 — 취소 미전송 (odno={order_id}, {stock_code or '-'})")
return {
"rt_cd": "-1",
"msg_cd": KIS_PRESEND_ORGNO_NOT_FOUND,
"msg1": f"원주문 조직번호를 찾을 수 없음 (odno={order_id})",
"output": None,
}
is_virtual = "vts" in self.base_url.lower()
tr_id = KIS_CANCEL_TR_IDS[1] if is_virtual else KIS_CANCEL_TR_IDS[0]
all_qty = not quantity or int(quantity) <= 0
body = {
"CANO": self.account_number[:8],
"ACNT_PRDT_CD": self.account_number[8:],
"KRX_FWDG_ORD_ORGNO": str(org_no),
"ORGN_ODNO": str(order_id),
"ORD_DVSN": "00",
"RVSE_CNCL_DVSN_CD": "02", # 01 정정 / 02 취소
"ORD_QTY": "0" if all_qty else str(int(quantity)),
"ORD_UNPR": "0",
"QTY_ALL_ORD_YN": "Y" if all_qty else "N",
"EXCG_ID_DVSN_CD": exchange,
}
result, _ = await self._make_request("POST", KIS_CANCEL_ENDPOINT, tr_id, json=body)
if not isinstance(result, dict) or result.get("rt_cd") != "0":
_r = result if isinstance(result, dict) else {}
logger.warning(
f"⚠️ [cancel] 취소 거부 odno={order_id} orgno={org_no} tr_id={tr_id} "
f"rt_cd={_r.get('rt_cd')} msg_cd={_r.get('msg_cd')} msg1={_r.get('msg1')}"
)
else:
logger.info(f"✅ [cancel] 취소 접수 odno={order_id} ({'전량' if all_qty else quantity})")
return result
except KISTokenUnavailableError as e:
logger.warning(f"⏳ [cancel] 취소 미전송 — 토큰 재발급 대기 중 (odno={order_id})")
return {
"rt_cd": "-1",
"msg_cd": KIS_PRESEND_TOKEN_UNAVAILABLE,
"msg1": str(e),
"output": None,
"retry_after_sec": e.retry_after_sec,
}
except Exception as e:
logger.error(f"Error canceling order: {e}")
raise
async def search_stocks_by_condition(
self,
market: str = "J",
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": "5000", # 5,000원 이상 종목만 조회
"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)
async def get_orderbook(self, stock_code: str) -> Dict[str, Any]:
"""호가 정보 조회 (매수/매도 호가 10단계)"""
_d = self._quote_delegate()
if _d is not None:
return await _d.get_orderbook(stock_code)
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()
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()
# 실계좌 시세조회 전용 싱글톤 (KIS_REAL_APP_KEY 설정 시 초기화)
# 모의투자 모드에서도 시세/차트/종목 조회는 실계좌 API를 사용한다.
try:
real_market_api = KoreaInvestmentAPI.for_real_market_data()
except Exception:
real_market_api = None