"""API 거절/주문 예외 → JSONL 백로그 + (선택) 주문 재시도 쿨다운. 실제 주문·토큰 발급과 무관. 실패 사건만 파일에 남겨 나중에 고치기 쉽게 한다. 동일 fingerprint 는 짧은 간격으로 파일에 도배하지 않는다. 영구형 거절(모의 미제공 등)은 주문 API 재호출만 N초 건너뛴다(완전 중단 아님). """ from __future__ import annotations import hashlib import json import logging import os import threading import time from datetime import datetime, timezone from typing import Any, Dict, Optional, Tuple from kis_trader.utils.env import get_env_bool, get_env_float, get_env_from_db, get_env_int logger = logging.getLogger("kis_trader.api_reject_log") _lock = threading.Lock() # fingerprint → last_file_write_ts _last_file_ts: Dict[str, float] = {} # fingerprint → cooldown_until_ts (주문 API 스킵) _order_cooldown_until: Dict[str, float] = {} _SCRIPT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) def _default_log_path() -> str: rel = ( get_env_from_db("KIS_API_REJECT_LOG_PATH", "logs/kis_api_rejects.jsonl") or "logs/kis_api_rejects.jsonl" ).strip() if os.path.isabs(rel): return rel return os.path.join(_SCRIPT_ROOT, rel) def _norm(s: Any) -> str: return " ".join(str(s or "").strip().split()) def make_fingerprint( *, kind: str, side: str = "", code: str = "", msg_cd: str = "", msg1: str = "", path: str = "", http: Any = "", ) -> str: raw = "|".join( [ _norm(kind).lower(), _norm(side).upper(), _norm(code).upper(), _norm(msg_cd), _norm(msg1)[:120], _norm(path), _norm(http), ] ) return hashlib.sha1(raw.encode("utf-8")).hexdigest()[:16] def _is_permanent_reject( *, msg_cd: str = "", msg1: str = "", http: Any = None, ) -> bool: """1시간 주문 쿨다운 대상인지.""" cd = _norm(msg_cd) msg = _norm(msg1) cds = ( get_env_from_db("KIS_API_REJECT_COOLDOWN_MSG_CD", "90000000") or "90000000" ) for part in cds.replace(";", ",").split(","): p = part.strip() if p and (cd == p or cd.endswith(p)): return True subs = ( get_env_from_db( "KIS_API_REJECT_COOLDOWN_MSG_SUBSTR", "해당업무가 제공되지 않습니다,모의투자에서는", ) or "해당업무가 제공되지 않습니다,모의투자에서는" ) for part in subs.replace(";", ",").split(","): p = part.strip() if p and p in msg: return True try: http_i = int(http) if http is not None and str(http).strip() != "" else 0 except Exception: http_i = 0 if http_i <= 0 and cd.upper().startswith("HTTP_"): try: http_i = int(cd.split("_", 1)[1]) except Exception: http_i = 0 http_min = int(get_env_int("KIS_API_REJECT_COOLDOWN_HTTP_MIN", 500) or 500) if http_min > 0 and http_i >= http_min: return True return False def order_cooldown_remaining( *, side: str, code: str, strategy_id: str = "", ) -> float: """남아 있는 주문 쿨다운 초. 0 이면 호출 가능.""" if not get_env_bool("KIS_API_REJECT_ORDER_COOLDOWN_ENABLED", True): return 0.0 fp = make_fingerprint( kind="order_cooldown", side=side, code=code, msg_cd=strategy_id, msg1="", path="order", ) with _lock: until = float(_order_cooldown_until.get(fp) or 0.0) rem = until - time.time() return rem if rem > 0 else 0.0 def mark_order_cooldown( *, side: str, code: str, strategy_id: str = "", msg_cd: str = "", msg1: str = "", http: Any = None, ) -> Optional[float]: """영구형 거절이면 주문 쿨다운 설정. 설정됐으면 until_ts, 아니면 None.""" if not get_env_bool("KIS_API_REJECT_ORDER_COOLDOWN_ENABLED", True): return None if not _is_permanent_reject(msg_cd=msg_cd, msg1=msg1, http=http): return None sec = float(get_env_float("KIS_API_REJECT_ORDER_COOLDOWN_SEC", 3600.0) or 3600.0) sec = max(60.0, min(sec, 86400.0)) fp = make_fingerprint( kind="order_cooldown", side=side, code=code, msg_cd=strategy_id, msg1="", path="order", ) until = time.time() + sec with _lock: _order_cooldown_until[fp] = until return until def record_api_reject( *, kind: str, side: str = "", code: str = "", strategy_id: str = "", msg_cd: str = "", msg1: str = "", rt_cd: str = "", http: Any = None, path: str = "", extra: Optional[Dict[str, Any]] = None, force: bool = False, ) -> Tuple[bool, str]: """ JSONL 1줄 append. 성공 시 (True, fingerprint). 파일 dedupe 로 스킵하면 (False, fingerprint). """ if not get_env_bool("KIS_API_REJECT_LOG_ENABLED", True): return False, "" fp = make_fingerprint( kind=kind, side=side, code=code, msg_cd=msg_cd, msg1=msg1, path=path, http=http, ) dedup = float(get_env_float("KIS_API_REJECT_LOG_DEDUP_SEC", 180.0) or 180.0) dedup = max(0.0, min(dedup, 86400.0)) now = time.time() with _lock: last = float(_last_file_ts.get(fp) or 0.0) if not force and dedup > 0 and (now - last) < dedup: return False, fp _last_file_ts[fp] = now row = { "ts": datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds"), "kind": _norm(kind), "strategy_id": _norm(strategy_id), "side": _norm(side).upper(), "code": _norm(code).upper(), "msg_cd": _norm(msg_cd), "msg1": _norm(msg1)[:300], "rt_cd": _norm(rt_cd), "http": http if http is not None else "", "path": _norm(path), "fingerprint": fp, } if extra and isinstance(extra, dict): # 비밀키 유입 방지 — 얕은 복사 + 문자열 길이 제한 safe_extra = {} for k, v in list(extra.items())[:20]: sk = str(k)[:40] if any(x in sk.lower() for x in ("secret", "token", "appkey", "password")): continue safe_extra[sk] = _norm(v)[:200] if not isinstance(v, (int, float, bool)) else v if safe_extra: row["extra"] = safe_extra path_out = _default_log_path() try: os.makedirs(os.path.dirname(path_out) or ".", exist_ok=True) line = json.dumps(row, ensure_ascii=False) + "\n" with open(path_out, "a", encoding="utf-8") as f: f.write(line) f.flush() try: os.fsync(f.fileno()) except Exception: pass return True, fp except Exception as e: logger.warning("api_reject JSONL 기록 실패: %s", e) return False, fp