243 lines
6.9 KiB
Python
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
|