Files
kis_bot/kis_trader/utils/api_reject_log.py
2026-07-30 18:05:07 +09:00

243 lines
6.9 KiB
Python

"""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