하네스 규칙 검사 전역 위반 117→0, 회차별 검증(code-verifier opus 4도메인)으로 Critical/High 해소. 주문·리스크 - MDD 강제축소 반복 매도(종목별 완료 집합·재시도 상한·날짜 롤오버·잔고 오류 보류·epoch·장외 미주문) - KIS 거부 FAILED 기록, 결과 불명(전송 후) 재전송 금지·PENDING, 미전송 실패만 재시도(msg_cd 분류) - 국내 전량·손실 매도 시장가, 부분 익절 직전 현재가, 지정가만 미체결 취소(cancel_order_v2: TR 0013U·조직번호) + 0체결 롤백 - 누적 4%·현금·레버리지(신규분 포함)·사이클 내 매수 누적, 수동매수 공용 가드(buy_guards), 수동매도 DT 차감·락 - 가드 우회 엔드포인트 차단(/recommendations/auto-trade 위임, US /execute 매수 410·매도 가드) - 매수 슬롯=성공 매수, 평가 상한·회전, 업무거부/계좌 쿨다운, 프로세스 내 in-flight 주문 중복 차단 데이트레이딩 - 진입·청산 shield, 청산 진행 중 표식(소유 토큰), 2단계 청산 체크, 결과 불명 → 보유 감소 판정·비DT 차감 상한 - 전일 포지션 재시도(일시 실패 미산정), 분할매도 영속, ORB 구간 손절, /cycle 락, 사이클 내 현금 차감, 체결 수량 보정 스윙 - 손절 공백(판정 예외 시 하드 바닥), 단일 손절 소스·범위 검증, 매도 직전 KIS 잔고 상한(min(주문가능,보유)−DT) - 결과 불명 체결 대조·장부 반영, 대체가(나이·진입 이후·ATR 캐시), 분할 단계 기록, 국면 실패 fail-closed - 듀얼 플레이북·서킷브레이커 게이트, 평균회귀 우선·듀얼 독립, 시간손절 거래일 - 거래 확대(사용자 결정, 백테스트 미검증): RSI 46/35 정본 경로, 모멘텀 v2 ON, SIDEWAYS ADX AND, 트레일링 1차 익절 후, 20일 시간손절 ON 인프라·보안 - 계좌·손익 GET 인증, 세션 is_active·관리자 경로, 서명키 파생, realip, WS 토큰 마스킹 - KIS 토큰 즉시 실패(sleep 제거)·무효 시 캐시 삭제, 주문 5xx+JSON 본문 반환 - db_worker 종료 drain(멱등 재실행·커밋 불명 격리·예산·밴 커밋), stop_grace 40s, Redis 타임아웃·volatile-lru, /health DLQ·캐시 - KRX 휴장일, py3.11 비호환 f-string 수정 + Jenkins 이미지 내 compileall·헬스 실패 시 빌드 실패 하네스: rules_check 에 PEP 701 f-string 검사, 마커(allow-*), deploy 스모크 401 기준 CLAUDE.md: 실제 동작·플래그·백로그(§8.5-27~38) 갱신 backend 1451 passed / hooks 112 passed / tsc ok / py3.9 compile 0 errors Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2085 lines
100 KiB
Python
2085 lines
100 KiB
Python
"""
|
||
한국투자증권(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
|
||
)
|
||
|
||
|
||
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
|
||
|
||
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:
|
||
self.state = CircuitBreakerState.OPEN
|
||
logger.warning(
|
||
f"⚡ Circuit breaker OPEN: {self.failure_count}회 연속 실패. "
|
||
f"{self.recovery_timeout}초 후 재시도"
|
||
)
|
||
|
||
|
||
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 상태 확인
|
||
if 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)")
|
||
|
||
# [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",
|
||
)
|
||
|
||
@staticmethod
|
||
def _safe_json(response) -> Any:
|
||
try:
|
||
return response.json()
|
||
except Exception:
|
||
return None
|
||
|
||
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.circuit_breaker.record_success()
|
||
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.circuit_breaker.record_failure()
|
||
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.circuit_breaker.record_failure()
|
||
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.circuit_breaker.record_failure()
|
||
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.circuit_breaker.record_failure()
|
||
logger.error(f"❌ API 요청 중 예외 발생: {str(e)}")
|
||
raise
|
||
|
||
# 여기까지 왔다면 모든 재시도 실패
|
||
duration = time.time() - start_time
|
||
self.monitor.record_call(success=False, duration=duration, retries=retry_count)
|
||
self.circuit_breaker.record_failure()
|
||
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
|
||
|
||
async def get_todays_orders(self, stock_code: str = "") -> list[dict]:
|
||
"""당일 주문체결 내역 조회 (inquire-daily-ccld) — 첫 페이지만(실전 100건 / 모의 15건, 역순).
|
||
|
||
주문 전송 후 응답을 못 받았을 때 '실제로 접수됐는지' 확인해 중복 주문을 막는 용도.
|
||
TR: 현행 TTTC0081R/VTTC0081R 우선, 실패 시 구 TTTC8001R/VTTC8001R. 실패 시 빈 리스트.
|
||
"""
|
||
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 []
|
||
out = result.get("output1") or []
|
||
return out if isinstance(out, list) else [out]
|
||
except Exception as e:
|
||
# CLAUDE.md §4: 예외 로그 debug 금지 (중복주문 방지·DT 체결가 확인의 근거 조회)
|
||
logger.warning(f"[get_todays_orders] {stock_code or '*'} 조회 예외: {type(e).__name__}: {e}")
|
||
return []
|
||
|
||
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
|