fix: 프로그램 검증 루프 7회차 — 리스크·주문·청산·인프라 결함 일괄 수정 + 거래 확대 결정 반영
하네스 규칙 검사 전역 위반 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>
This commit is contained in:
@@ -17,11 +17,14 @@ 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 상수 ────────────────────────────────────────────────────────
|
||||
@@ -44,6 +47,61 @@ 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회 실패 시 일정 시간 요청 차단.
|
||||
@@ -161,8 +219,8 @@ async def _cache_set(key: str, ttl: int, data: Any) -> None:
|
||||
try:
|
||||
from app.core.redis import setex_json
|
||||
await setex_json(key, ttl, data)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as _swallowed:
|
||||
warn_throttled(logger, "korea_investment_api:164", "[KIS] Redis 캐시 저장 실패: %s", _swallowed)
|
||||
|
||||
|
||||
# ── 국내 주문체결 조회 (inquire-daily-ccld) ────────────────────────────────
|
||||
@@ -434,6 +492,15 @@ class KoreaInvestmentAPI:
|
||||
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}시간)")
|
||||
@@ -448,9 +515,11 @@ class KoreaInvestmentAPI:
|
||||
"""토큰을 파일에 동기 저장 (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)
|
||||
@@ -476,24 +545,84 @@ class KoreaInvestmentAPI:
|
||||
async def _handle_rate_limit(self) -> Optional[str]:
|
||||
"""Handle 1-minute rate limit for token requests.
|
||||
|
||||
Returns token if can reuse existing token during rate limit, or sleeps appropriately.
|
||||
토큰이 이미 만료된 경우에는 연장하지 않고 재발급을 트리거한다.
|
||||
- 재발급 최소 간격(`KIS_TOKEN_REISSUE_MIN_INTERVAL_SEC`) 안이고 유효 토큰이 있으면 재사용.
|
||||
- 간격 안인데 유효 토큰이 없으면 **대기하지 않고** `KISTokenUnavailableError` (H-3).
|
||||
`_token_lock` 안에서 잠들면 대기자 수만큼 직렬 누적되기 때문이다.
|
||||
- 간격 밖이면 None (호출자가 재발급).
|
||||
"""
|
||||
if hasattr(self, '_last_token_request_time'):
|
||||
elapsed = (datetime.now() - self._last_token_request_time).total_seconds()
|
||||
if elapsed < 70: # 70초 대기 (여유있게)
|
||||
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
|
||||
|
||||
# 토큰이 없거나 이미 만료 -> rate limit 해제까지 대기 후 재발급
|
||||
wait_time = 70 - elapsed
|
||||
logger.warning(f"⏳ 토큰 발급 1분 제한: {wait_time:.1f}초 대기 중... (기존 토큰 만료됨, 재발급 필요)")
|
||||
await asyncio.sleep(wait_time + 1)
|
||||
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.
|
||||
|
||||
@@ -519,6 +648,14 @@ class KoreaInvestmentAPI:
|
||||
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()
|
||||
@@ -529,20 +666,17 @@ class KoreaInvestmentAPI:
|
||||
error_msg = response.text
|
||||
logger.error(f"❌ 토큰 발급 실패: {response.status_code} - {error_msg}")
|
||||
|
||||
# 토큰 발급 실패 시에도 기존 토큰이 있다면 계속 사용 시도
|
||||
if self.access_token:
|
||||
logger.warning(f"⚠️ 토큰 발급 실패했지만 기존 토큰 재사용 시도")
|
||||
# 만료 시간 연장
|
||||
self.token_expires_at = datetime.now() + timedelta(hours=1)
|
||||
return self.access_token
|
||||
# 토큰 발급 실패 시 기존 토큰은 실제 만료(하드) 안에서만 짧게 연장 (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}")
|
||||
# 기존 토큰이 있으면 사용
|
||||
if self.access_token:
|
||||
logger.warning(f"⚠️ 토큰 요청 실패, 기존 토큰 재사용")
|
||||
self.token_expires_at = datetime.now() + timedelta(hours=1)
|
||||
return self.access_token
|
||||
# 기존 토큰은 실제 만료(하드) 안에서만 짧게 연장 (H-4)
|
||||
_grace = self._grace_extend_existing_token()
|
||||
if _grace:
|
||||
return _grace
|
||||
raise
|
||||
|
||||
async def _get_access_token(self) -> str:
|
||||
@@ -583,9 +717,25 @@ class KoreaInvestmentAPI:
|
||||
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()
|
||||
|
||||
# 동시 요청 수 제한 (세마포어)
|
||||
async with self.semaphore:
|
||||
return await self._make_request_with_retry(method, endpoint, tr_id, extra_headers=extra_headers, **kwargs)
|
||||
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 가 체결 확인 후 재시도를 판단한다)
|
||||
@@ -595,6 +745,13 @@ class KoreaInvestmentAPI:
|
||||
"/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()
|
||||
@@ -642,6 +799,18 @@ class KoreaInvestmentAPI:
|
||||
**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
|
||||
@@ -654,6 +823,23 @@ class KoreaInvestmentAPI:
|
||||
# 서버 오류 (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}초 후")
|
||||
@@ -671,6 +857,15 @@ class KoreaInvestmentAPI:
|
||||
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
|
||||
@@ -1570,14 +1765,38 @@ class KoreaInvestmentAPI:
|
||||
|
||||
logger.info(f"📥 주문 응답: rt_cd={result.get('rt_cd')}, msg1={result.get('msg1')}, odno={result.get('odno')}")
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"❌ 주문 실행 오류: {e}", exc_info=True)
|
||||
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]:
|
||||
"""주문 체결 내역 단건 조회.
|
||||
|
||||
@@ -1640,36 +1859,116 @@ class KoreaInvestmentAPI:
|
||||
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:
|
||||
account_number_prefix = self.account_number[:8]
|
||||
account_number_suffix = self.account_number[8:]
|
||||
|
||||
# 모의투자 환경 확인
|
||||
is_virtual = "vts" in self.base_url.lower()
|
||||
tr_id = "VTTC0803U" if is_virtual else "TTTC0803U"
|
||||
|
||||
result, _ = await self._make_request(
|
||||
"POST",
|
||||
"/uapi/domestic-stock/v1/trading/order-rvsecncl",
|
||||
tr_id,
|
||||
json={
|
||||
"CANO": account_number_prefix,
|
||||
"ACNT_PRDT_CD": account_number_suffix,
|
||||
"KRX_FWDG_ORD_ORGNO": "",
|
||||
"ORGN_ODNO": order_id,
|
||||
"ORD_DVSN": "00",
|
||||
"RVSE_CNCL_DVSN_CD": "02", # 취소
|
||||
"ORD_QTY": "0",
|
||||
"ORD_UNPR": "0",
|
||||
"QTY_ALL_ORD_YN": "Y"
|
||||
}
|
||||
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",
|
||||
|
||||
Reference in New Issue
Block a user