fix: 하드닝 루프 9회차 — 매수 원복·잔고 연속조회·결과 불명 매도·브레이커·경보 보강
- 8회차 HIGH 수정 16건 레드팀 재검증: 12 확인·3 부분·1 미수정 → 전부 보강 - trade_executor/fill_sweep: 늦게 확정된 0체결 BUY → CANCELLED + ActiveTrade close, 부분체결 → 수량 축소(멱등, 재기동 후 TH 재구성) - korea_investment_api: get_balance_all_v2(tr_cont 연속조회, _partial), get_todays_orders_v2(실패 None), 브레이커 청산용 조회 면제(OPEN 중 미집계) - swing/exit_service·mdd_manager: 매도 직전 상한·MDD 축소가 전체 잔고 페이지 사용(§8.5-37), 불명 대기 중 부분익절 skip·쿨다운·pre_hldg 차감, 대기 TTL 15:30+재매수 폐기(대조 1회 선행)(§8.5-38 해소) - swing/scheduler: 청산 TIMED_OUT/SKIPPED/in-flight 시 MDD 축소·진입 보류(예외는 진입만), 3사이클 연속 시 error+텔레그램 - daytrading: 결과 불명 매도 재판정 v3(주문조회 v2 None→보류, 등록 이후 일치 행→accepted, 원장≤0→보류), 재기동 복원 min(주문가능,보유), 잔고 조회 후 일일손실한도 재확인, 단독 실행 Redis 접속 제거 - main: 스트림 무수신 경보 v4(희망 구독 기준), 첫 하트비트 없음 stale(HB_FIRST_BEAT_GRACE_SEC) - 운영: Jenkinsfile health_watch.py 디렉터리 잔존 복구, prod_smoke 사이드카 확인, config 키 12개 선언(동작 무변경) - 진입가 v3(max(AT,PA))는 KIS 이동평균 평단 전제 오류로 레드팀 반증 → 폐기 - CLAUDE.md §3.2 ③-d, §8.5-37/38 갱신, §8.5-39~48 신규(Crisis 손절 소급 §8.5-39 는 사용자 결정 대기) - 유닛 스위트 2781 passed / 0 failed (소켓 차단), 네트워크 시도 0건 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -89,6 +89,12 @@ _TOKEN_REFRESH_RETRIED: contextvars.ContextVar[bool] = contextvars.ContextVar(
|
||||
"kis_token_refresh_retried", default=False
|
||||
)
|
||||
|
||||
# [9회차 D-2b] 현재 요청이 "브레이커 비-CLOSED 중의 면제 조회" 인가 — 참이면 성공·실패 모두
|
||||
# 브레이커에 기록하지 않는다(OPEN 해제는 비면제 조회의 HALF_OPEN 프로브로만).
|
||||
_BREAKER_UNCOUNTED: contextvars.ContextVar[bool] = contextvars.ContextVar(
|
||||
"kis_breaker_uncounted", default=False
|
||||
)
|
||||
|
||||
|
||||
class KISTokenUnavailableError(Exception):
|
||||
"""토큰을 지금 얻을 수 없다(재발급 최소 간격 내 + 유효 토큰 없음).
|
||||
@@ -774,11 +780,36 @@ class KoreaInvestmentAPI:
|
||||
# Circuit Breaker 상태 확인
|
||||
# [R4] 주문 엔드포인트는 breaker 로 차단하지 않는다 — 시세/잔고 조회 장애(EGW00201·타임아웃)가
|
||||
# 손절·EOD·MDD 매도까지 막으면 §8.1-11(청산 우선) 위반. 주문 실패는 breaker 에 세지도 않는다.
|
||||
if endpoint not in self._NON_IDEMPOTENT_ENDPOINTS and not self.circuit_breaker.can_request():
|
||||
# [9회차 D-2] 청산 경로가 쓰는 읽기 조회(_CIRCUIT_EXEMPT_READ_ENDPOINTS)도 게이트 면제.
|
||||
# 면제 호출이 실패하면 예외가 나므로 청산 보류는 KIS 가 실제로 죽었을 때만 일어난다.
|
||||
# [9회차 D-2b] 브레이커가 CLOSED 가 아닐 때(OPEN/HALF_OPEN) 면제 호출은 can_request() 를
|
||||
# 부르지 않고(HALF_OPEN 전이·프로브 소비 금지) 성공·실패 모두 브레이커에 기록하지 않는다.
|
||||
# - 면제 성공이 record_success 로 OPEN 을 닫으면 EGW00201 폭주 중 부하 완화가 사라진다.
|
||||
# - 면제 실패가 last_failure_time 을 갱신하면 비면제 조회가 HALF_OPEN 프로브를 영영 못 얻는다.
|
||||
# CLOSED 상태의 면제 호출은 기존대로 집계한다(장애 감지는 유지).
|
||||
uncounted = False
|
||||
if endpoint in self._CIRCUIT_EXEMPT_READ_ENDPOINTS:
|
||||
if self.circuit_breaker.state != CircuitBreakerState.CLOSED:
|
||||
uncounted = True
|
||||
warn_throttled(
|
||||
logger, f"korea_investment_api:breaker_exempt:{endpoint}",
|
||||
"⚡ Circuit breaker %s — 청산 경로 조회는 게이트 면제로 전송(브레이커 집계 제외): %s (차단 %s초)",
|
||||
self.circuit_breaker.state.value, endpoint, self.circuit_breaker.recovery_timeout,
|
||||
interval=60.0,
|
||||
)
|
||||
elif endpoint not in self._NON_IDEMPOTENT_ENDPOINTS and not self.circuit_breaker.can_request():
|
||||
wait_sec = self.circuit_breaker.recovery_timeout
|
||||
logger.warning(f"⚡ Circuit breaker OPEN — {wait_sec}초 후 재시도 가능. 요청 거부: {endpoint}")
|
||||
raise Exception(f"Circuit breaker open: KIS API 일시 차단 ({wait_sec}s)")
|
||||
|
||||
_uc_tok = _BREAKER_UNCOUNTED.set(uncounted)
|
||||
try:
|
||||
return await self._make_request_dispatch_v2(method, endpoint, tr_id, extra_headers=extra_headers, **kwargs)
|
||||
finally:
|
||||
_BREAKER_UNCOUNTED.reset(_uc_tok)
|
||||
|
||||
async def _make_request_dispatch_v2(self, method: str, endpoint: str, tr_id: str, extra_headers: Optional[Dict[str, str]] = None, **kwargs) -> (Dict[str, Any], Any):
|
||||
"""_make_request 의 게이트 이후 본체(토큰 확보 + 세마포어 + 재시도 + 토큰 무효 1회 재전송)."""
|
||||
# [H-3] 토큰은 세마포어 **밖에서** 확보한다. 세마포어를 잡은 채 토큰 발급(최대
|
||||
# HTTP 30초)·락 대기를 하면 다른 모든 KIS 호출(매도·EOD 청산 포함)이 슬롯을 못 얻는다.
|
||||
# 여기서 캐시가 채워지므로 아래 재시도 루프의 토큰 조회는 fast path 로 끝난다.
|
||||
@@ -807,6 +838,18 @@ class KoreaInvestmentAPI:
|
||||
"/uapi/overseas-stock/v1/trading/order",
|
||||
)
|
||||
|
||||
# [9회차 D-2, CLAUDE.md §8.1-11 청산 우선] 서킷브레이커 **게이트 면제** 읽기 엔드포인트.
|
||||
# 조회 실패 누적으로 breaker 가 OPEN 이면 recovery_timeout 동안 모든 조회가 거부되는데,
|
||||
# 스윙 매도 직전 잔고 상한(_fetch_kis_holding_v2)·손절 판정용 현재가·결과 불명 주문 확인
|
||||
# (당일 주문/체결)까지 막히면 손절·전량 청산이 보류된다. 이 집합은 게이트만 통과시키고
|
||||
# 성공/실패 집계(record_success/record_failure)는 그대로 한다 — 면제 호출이 실패하면
|
||||
# 평소처럼 예외/오류 응답이 나므로 청산 보류는 KIS 가 실제로 죽었을 때만 일어난다.
|
||||
_CIRCUIT_EXEMPT_READ_ENDPOINTS = (
|
||||
"/uapi/domestic-stock/v1/trading/inquire-balance",
|
||||
"/uapi/domestic-stock/v1/quotations/inquire-price",
|
||||
"/uapi/domestic-stock/v1/trading/inquire-daily-ccld",
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _safe_json(response) -> Any:
|
||||
try:
|
||||
@@ -816,12 +859,12 @@ class KoreaInvestmentAPI:
|
||||
|
||||
def _record_breaker_failure_v2(self, endpoint: str) -> None:
|
||||
"""주문 엔드포인트 실패는 조회 breaker 에 세지 않는다(주문·조회 장애 분리)."""
|
||||
if endpoint in self._NON_IDEMPOTENT_ENDPOINTS:
|
||||
if endpoint in self._NON_IDEMPOTENT_ENDPOINTS or _BREAKER_UNCOUNTED.get():
|
||||
return
|
||||
self.circuit_breaker.record_failure()
|
||||
|
||||
def _record_breaker_success_v2(self, endpoint: str) -> None:
|
||||
if endpoint in self._NON_IDEMPOTENT_ENDPOINTS:
|
||||
if endpoint in self._NON_IDEMPOTENT_ENDPOINTS or _BREAKER_UNCOUNTED.get():
|
||||
return
|
||||
self.circuit_breaker.record_success()
|
||||
|
||||
@@ -1618,12 +1661,142 @@ class KoreaInvestmentAPI:
|
||||
logger.error(f"Error fetching balance: {e}")
|
||||
raise
|
||||
|
||||
# ── [9회차 D-1] 잔고 연속조회 ──────────────────────────────────────────
|
||||
_BALANCE_ENDPOINT = "/uapi/domestic-stock/v1/trading/inquire-balance"
|
||||
|
||||
def _balance_request_v2(self, ctx_fk100: str = "", ctx_nk100: str = "") -> tuple:
|
||||
"""inquire-balance 요청 (tr_id, params) — get_balance() 와 동일한 파라미터 + 연속조회 키."""
|
||||
is_virtual = "vts" in self.base_url.lower()
|
||||
tr_id = "VTTC8434R" if is_virtual else "TTTC8434R"
|
||||
params = {
|
||||
"CANO": self.account_number[:8],
|
||||
"ACNT_PRDT_CD": self.account_number[8:],
|
||||
"AFHR_FLPR_YN": "N",
|
||||
"OFL_YN": "",
|
||||
"INQR_DVSN": "02",
|
||||
"UNPR_DVSN": "01",
|
||||
"FUND_STTL_ICLD_YN": "N",
|
||||
"FNCG_AMT_AUTO_RDPT_YN": "N",
|
||||
"PRCS_DVSN": "01",
|
||||
"CTX_AREA_FK100": ctx_fk100 or "",
|
||||
"CTX_AREA_NK100": ctx_nk100 or "",
|
||||
}
|
||||
return tr_id, params
|
||||
|
||||
async def get_balance_all_v2(self) -> Dict[str, Any]:
|
||||
"""계좌 잔고 조회 — 연속조회(tr_cont F/M → 요청 헤더 tr_cont=N)로 전 페이지 합산.
|
||||
|
||||
반환 형태는 get_balance() 와 같다(rt_cd/msg_cd/msg1/output1/output2 ...).
|
||||
- output1: 전 페이지 합산(pdno 기준 dedupe, 먼저 나온 행 유지)
|
||||
- output2: 첫 페이지 것(총자산 등)
|
||||
- _pages: 실제로 받은 페이지 수(성공 페이지만)
|
||||
- _partial: 합산이 불완전하면 True — 2페이지 이후 조회 실패(예외·rt_cd≠0), 다음 페이지가
|
||||
있다는데 CTX_AREA_NK100 이 비어 있음, KIS_BALANCE_MAX_PAGES 상한 도달 후에도 남은 페이지.
|
||||
완전하면 키가 없다. 호출부는 _partial 이면 "미발견 = 미보유" 로 판단하지 말 것.
|
||||
첫 페이지 실패는 get_balance() 와 동일(예외는 그대로 raise, rt_cd≠0 은 error 로그 후 그 dict 반환).
|
||||
"""
|
||||
max_pages = max(1, int(getattr(settings, "KIS_BALANCE_MAX_PAGES", 5) or 1))
|
||||
try:
|
||||
tr_id, params = self._balance_request_v2()
|
||||
first, headers = await self._make_request("GET", self._BALANCE_ENDPOINT, tr_id, params=params)
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching balance (all pages): {e}")
|
||||
raise
|
||||
if not isinstance(first, dict) or first.get("rt_cd") != "0":
|
||||
_r = first if isinstance(first, dict) else {}
|
||||
logger.error(f"❌ Balance API error - rt_cd={_r.get('rt_cd')}, msg1={_r.get('msg1')}")
|
||||
return first
|
||||
|
||||
merged: list = []
|
||||
seen: set = set()
|
||||
|
||||
def _absorb(rows: Any) -> None:
|
||||
if isinstance(rows, dict):
|
||||
rows = [rows]
|
||||
if not isinstance(rows, list):
|
||||
return
|
||||
for row in rows:
|
||||
code = str(row.get("pdno") or "").strip() if isinstance(row, dict) else ""
|
||||
if code:
|
||||
if code in seen:
|
||||
continue
|
||||
seen.add(code)
|
||||
merged.append(row)
|
||||
|
||||
_absorb(first.get("output1"))
|
||||
pages = 1
|
||||
partial = False
|
||||
tr_cont = ((headers.get("tr_cont") if headers is not None else None) or "").strip()
|
||||
page_result = first
|
||||
while tr_cont in ("F", "M"):
|
||||
ctx_fk = str(page_result.get("ctx_area_fk100") or "").strip()
|
||||
ctx_nk = str(page_result.get("ctx_area_nk100") or "").strip()
|
||||
if not ctx_nk:
|
||||
partial = True
|
||||
logger.warning(
|
||||
f"[get_balance_all_v2] tr_cont={tr_cont} 인데 ctx_area_nk100 비어 있음 — "
|
||||
f"{pages}페이지까지만 합산(불완전)"
|
||||
)
|
||||
break
|
||||
if pages >= max_pages:
|
||||
partial = True
|
||||
logger.warning(
|
||||
f"[get_balance_all_v2] 연속조회 상한 KIS_BALANCE_MAX_PAGES={max_pages} 도달 — "
|
||||
f"남은 페이지 있음(tr_cont={tr_cont}), 불완전"
|
||||
)
|
||||
break
|
||||
try:
|
||||
tr_id, params = self._balance_request_v2(ctx_fk100=ctx_fk, ctx_nk100=ctx_nk)
|
||||
page_result, headers = await self._make_request(
|
||||
"GET", self._BALANCE_ENDPOINT, tr_id, extra_headers={"tr_cont": "N"}, params=params,
|
||||
)
|
||||
except Exception as e:
|
||||
partial = True
|
||||
logger.warning(
|
||||
f"[get_balance_all_v2] {pages + 1}페이지 조회 예외 — {pages}페이지까지만 합산(불완전): "
|
||||
f"{type(e).__name__}: {e}"
|
||||
)
|
||||
break
|
||||
if not isinstance(page_result, dict) or page_result.get("rt_cd") != "0":
|
||||
partial = True
|
||||
_r = page_result if isinstance(page_result, dict) else {}
|
||||
logger.warning(
|
||||
f"[get_balance_all_v2] {pages + 1}페이지 조회 실패 rt_cd={_r.get('rt_cd')} "
|
||||
f"msg_cd={_r.get('msg_cd')} msg1={_r.get('msg1')} — {pages}페이지까지만 합산(불완전)"
|
||||
)
|
||||
break
|
||||
pages += 1
|
||||
_absorb(page_result.get("output1"))
|
||||
tr_cont = ((headers.get("tr_cont") if headers is not None else None) or "").strip()
|
||||
|
||||
out = dict(first)
|
||||
out["output1"] = merged
|
||||
out["output2"] = first.get("output2")
|
||||
out["_pages"] = pages
|
||||
if partial:
|
||||
out["_partial"] = True
|
||||
return out
|
||||
|
||||
async def get_todays_orders(self, stock_code: str = "") -> list[dict]:
|
||||
"""당일 주문체결 내역 조회 (inquire-daily-ccld) — 첫 페이지만(실전 100건 / 모의 15건, 역순).
|
||||
|
||||
주문 전송 후 응답을 못 받았을 때 '실제로 접수됐는지' 확인해 중복 주문을 막는 용도.
|
||||
TR: 현행 TTTC0081R/VTTC0081R 우선, 실패 시 구 TTTC8001R/VTTC8001R. 실패 시 빈 리스트.
|
||||
(실패와 '주문 없음' 을 구분해야 하면 get_todays_orders_v2 — 실패 시 None)
|
||||
"""
|
||||
return await self._get_todays_orders_impl(stock_code=stock_code, strict=False)
|
||||
|
||||
async def get_todays_orders_v2(self, stock_code: str = "") -> Optional[list]:
|
||||
"""[9회차 D-3] get_todays_orders 와 같되 **조회 실패면 None**(예외·rt_cd≠0·output1 파싱 불가).
|
||||
|
||||
성공 시 list(주문 없음이면 []). 결과 불명 주문 판정에서 '조회 실패' 를 '미접수' 로
|
||||
오인해 재주문(과다매도)하지 않도록 호출부는 None 을 "판정 보류"로 취급할 것.
|
||||
"""
|
||||
return await self._get_todays_orders_impl(stock_code=stock_code, strict=True)
|
||||
|
||||
async def _get_todays_orders_impl(self, stock_code: str = "", strict: bool = False) -> Optional[list]:
|
||||
"""get_todays_orders(_v2) 공통 본체. strict=False 면 실패 시 [](기존 동작), True 면 None."""
|
||||
fail: Optional[list] = None if strict else []
|
||||
try:
|
||||
result, _ = await self._daily_ccld_fetch_v2(stock_code=stock_code)
|
||||
if result.get("rt_cd") != "0":
|
||||
@@ -1632,13 +1805,21 @@ class KoreaInvestmentAPI:
|
||||
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 []
|
||||
return fail
|
||||
out = result.get("output1") or []
|
||||
return out if isinstance(out, list) else [out]
|
||||
if isinstance(out, list):
|
||||
return out
|
||||
if strict and not isinstance(out, dict):
|
||||
logger.warning(
|
||||
f"[get_todays_orders_v2] {stock_code or '*'} output1 파싱 불가 "
|
||||
f"(type={type(out).__name__}) — 조회 실패로 취급"
|
||||
)
|
||||
return None
|
||||
return [out]
|
||||
except Exception as e:
|
||||
# CLAUDE.md §4: 예외 로그 debug 금지 (중복주문 방지·DT 체결가 확인의 근거 조회)
|
||||
logger.warning(f"[get_todays_orders] {stock_code or '*'} 조회 예외: {type(e).__name__}: {e}")
|
||||
return []
|
||||
return fail
|
||||
|
||||
def _daily_ccld_request_v2(
|
||||
self,
|
||||
|
||||
Reference in New Issue
Block a user