Files
kis_bot/kis_trader/scripts/test_kis_ws_diagnostic.py
Your Name 0ecac7cb95 이번에 들어간 내용
한투 호가 = 2번째 앱키 전용
키 없거나 start 실패 시 메인에 H0STASP0 안 붙임. 운영설정 WS_ORDERBOOK_SAVE_KIS 빨간 danger.

LS RAM 합집합
후보∪보유∪영구∪grace. sync_targets와 split reconcile 둘 다. 틱 DB 영구 게이트는 그대로.

분봉 쓰레기 → 다음 소스 봉 통째
그 분 틱 0건이거나 전부 봉끝 대비 LIVE_FEED_FALLBACK_MAX_AGE_SEC 초과면 구멍. 메인 WS → 2차 → LS → REST → rollup. CANDLE_GARBAGE_FALLBACK 기본 true.

파일: feed_fallback.py(신규), ws_manager.py, kis_ws.py, candle_series.py, bt_candle_source.py, live_config_schema.py, database.py, 스모크, MD 2개.

같은 ws_manager/database/kis_ws/live_config에는 직전 커밋 이후 쌓여 있던 시세 폴백·ENV 키 정리도 같이 들어갔습니다. 파일 단위로 나눌 수 없어서입니다.
2026-08-19 22:11:31 +09:00

418 lines
15 KiB
Python

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
KIS WebSocket(H0STCNT0) 연결·구독 진단 스크립트
================================================
봇 로그의 "장외 서버 거부 추정" / 즉시 끊김이 **진짜 KIS 차단**인지,
**구독 폭주·중복 세션·구독 오류**인지 단계별로 검증한다.
⚠️ kis_trader_main.service 가 이미 KIS WS 를 쓰는 중이면
동일 appkey 로 2번째 WS 를 열 때 기존 세션이 끊길 수 있다.
--skip-if-service-running (기본) 으로 서비스 기동 중이면 Phase 2+ 를 스킵한다.
사용:
cd /home/hoon/kis_bot
python3 kis_trader/scripts/test_kis_ws_diagnostic.py
python3 kis_trader/scripts/test_kis_ws_diagnostic.py --force --codes 005930,069500
python3 kis_trader/scripts/test_kis_ws_diagnostic.py --phase duplicate_sub
로그 파일:
logs/test_kis_ws_diagnostic.log (동시에 stdout 출력)
"""
from __future__ import annotations
import argparse
import json
import logging
import os
import subprocess
import sys
import threading
import time
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
HERE = Path(__file__).resolve()
ROOT = HERE.parents[2]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
import requests # noqa: E402
from kis_trader.execution.kis_client import KISClient # noqa: E402
from kis_trader.utils.env import get_env_bool, get_env_from_db # noqa: E402
LOG_DIR = ROOT / "logs"
LOG_DIR.mkdir(exist_ok=True)
LOG_PATH = LOG_DIR / "test_kis_ws_diagnostic.log"
DEFAULT_CODES = ["000660", "005380", "005930", "069500", "229200", "379810", "466930"]
def _setup_logging() -> logging.Logger:
lg = logging.getLogger("test_kis_ws")
lg.setLevel(logging.DEBUG)
lg.handlers.clear()
fmt = logging.Formatter("[%(asctime)s] %(message)s", datefmt="%H:%M:%S")
for h in (logging.StreamHandler(sys.stdout), logging.FileHandler(LOG_PATH, encoding="utf-8")):
h.setFormatter(fmt)
lg.addHandler(h)
return lg
def _is_market_hours(is_mock: bool) -> bool:
now = datetime.now()
if now.weekday() >= 5:
return False
open_h, open_m = (9, 0) if is_mock else (8, 25)
from datetime import time as dtime
return dtime(open_h, open_m) <= now.time() <= dtime(16, 5)
def _service_running(name: str = "kis_trader_main.service") -> bool:
try:
r = subprocess.run(
["systemctl", "is-active", name],
capture_output=True,
text=True,
timeout=5,
)
return r.stdout.strip() == "active"
except Exception:
return False
def _fetch_approval_key(base_url: str, app_key: str, app_secret: str) -> Tuple[Optional[str], Dict[str, Any]]:
url = f"{base_url}/oauth2/Approval"
body = {
"grant_type": "client_credentials",
"appkey": app_key,
"secretkey": app_secret,
}
try:
r = requests.post(url, json=body, timeout=10)
data = r.json()
return data.get("approval_key"), data
except Exception as e:
return None, {"error": str(e)}
class WsProbe:
"""단일 WS 연결 프로브 — 수신 메시지·끊김 시각 기록."""
def __init__(
self,
ws_url: str,
approval_key: str,
logger: logging.Logger,
gap_sec: float = 0.0,
):
self.ws_url = ws_url
self.approval_key = approval_key
self.logger = logger
self.gap_sec = gap_sec
self.messages: List[str] = []
self.json_responses: List[Dict[str, Any]] = []
self.ticks = 0
self.connected_at: float = 0.0
self.closed_at: float = 0.0
self.close_code: Any = None
self.close_msg: str = ""
self.error: str = ""
self._ws = None
self._thread: Optional[threading.Thread] = None
self._done = threading.Event()
self._sent_codes: List[str] = []
def _build_sub(self, code: str, subscribe: bool = True) -> str:
return json.dumps({
"header": {
"approval_key": self.approval_key,
"custtype": "P",
"tr_type": "1" if subscribe else "2",
"content-type": "utf-8",
},
"body": {
"input": {
"tr_id": "H0STCNT0",
"tr_key": code,
}
},
})
def _on_open(self, ws) -> None:
self.connected_at = time.time()
self.logger.info(" [WS] on_open OK (t=0)")
def _on_message(self, ws, message: str) -> None:
self.messages.append(message)
raw = (message or "").strip()
if raw == "PINGPONG":
try:
ws.send("PINGPONG")
except Exception:
pass
self.logger.debug(" [WS] PINGPONG echo")
return
if raw.startswith("{"):
try:
j = json.loads(raw)
self.json_responses.append(j)
hdr = j.get("header") or {}
body = j.get("body") or {}
rt = str(body.get("rt_cd", ""))
msg1 = str(body.get("msg1", ""))
tr_id = hdr.get("tr_id", "")
self.logger.info(
" [WS] JSON tr_id=%s rt_cd=%s msg1=%s",
tr_id, rt, msg1,
)
if rt and rt != "0":
self.logger.warning(" [WS] ★ 구독/서버 오류 rt_cd=%s: %s", rt, msg1)
except Exception as e:
self.logger.warning(" [WS] JSON 파싱 실패: %s | raw=%s", e, raw[:200])
return
parts = raw.split("|")
if len(parts) >= 2 and parts[1] == "H0STCNT0":
self.ticks += 1
if self.ticks <= 3:
self.logger.info(" [WS] H0STCNT0 tick #%d (샘플)", self.ticks)
def _on_error(self, ws, error) -> None:
self.error = str(error)
self.logger.warning(" [WS] on_error: %s", error)
def _on_close(self, ws, code, msg) -> None:
self.closed_at = time.time()
self.close_code = code
self.close_msg = str(msg or "")
dur = self.closed_at - self.connected_at if self.connected_at else 0.0
self.logger.info(
" [WS] on_close code=%s dur=%.2fs msg=%s",
code, dur, self.close_msg or "-",
)
self._done.set()
def run(
self,
codes: List[str],
*,
duplicate_subscribe: bool = False,
hold_sec: float = 8.0,
) -> Dict[str, Any]:
try:
import websocket as ws_lib
except ImportError:
return {"ok": False, "reason": "websocket-client 미설치"}
def _runner():
app = ws_lib.WebSocketApp(
self.ws_url,
on_open=self._on_open,
on_message=self._on_message,
on_error=self._on_error,
on_close=self._on_close,
)
self._ws = app
def _subscribe_all():
time.sleep(0.3)
for i, code in enumerate(codes):
if self.gap_sec > 0 and i > 0:
time.sleep(self.gap_sec)
try:
app.send(self._build_sub(code, True))
self._sent_codes.append(code)
self.logger.info(" [WS] subscribe sent: %s", code)
except Exception as e:
self.logger.warning(" [WS] subscribe fail %s: %s", code, e)
if duplicate_subscribe and codes:
self.logger.info(" [WS] duplicate subscribe burst (봇 _on_open 버그 재현)")
for code in codes:
try:
app.send(self._build_sub(code, True))
except Exception:
pass
if codes:
threading.Thread(target=_subscribe_all, daemon=True).start()
app.run_forever(ping_interval=20, ping_timeout=10)
self._thread = threading.Thread(target=_runner, daemon=True)
self._thread.start()
self._done.wait(timeout=hold_sec + 15.0)
gap = max(0.0, float(self.gap_sec or 0.12))
if self._ws and self._sent_codes:
self.logger.info(" [WS] unsubscribe %d 후 close", len(self._sent_codes))
for i, code in enumerate(self._sent_codes):
if i > 0 and gap > 0:
time.sleep(gap)
try:
self._ws.send(self._build_sub(code, False))
except Exception:
pass
if gap > 0:
time.sleep(gap)
try:
if self._ws:
self._ws.close()
except Exception:
pass
if self._thread.is_alive():
self._thread.join(timeout=3)
dur = (self.closed_at or time.time()) - self.connected_at if self.connected_at else 0.0
instant = dur > 0 and dur < 3.0
err_json = [j for j in self.json_responses if str((j.get("body") or {}).get("rt_cd", "0")) != "0"]
return {
"ok": self.connected_at > 0 and not instant and self.error == "",
"connected": self.connected_at > 0,
"duration_sec": round(dur, 2),
"instant_drop": instant,
"error": self.error,
"close_code": self.close_code,
"ticks": self.ticks,
"json_errors": len(err_json),
"json_total": len(self.json_responses),
"messages": len(self.messages),
}
def _verdict(phase: str, result: Dict[str, Any], market_open: bool) -> str:
if result.get("reason"):
return f"FAIL — {result['reason']}"
if not result.get("connected"):
return "FAIL — TCP/WS 핸드셰이크 실패 (approval_key·URL·방화벽 확인)"
if result.get("json_errors", 0) > 0:
return "FAIL — KIS JSON rt_cd≠0 (구독 거부·잘못된 tr_key·한도 초과)"
if result.get("instant_drop"):
if not market_open:
return "EXPECTED — 장외 즉시 끊김 (KIS WS 서비스 시간 외)"
if phase == "duplicate_sub":
return "LIKELY — 중복 구독 폭주 후 즉시 끊김 (봇 _on_open 이중 전송 의심)"
return "FAIL — 장중인데 즉시 끊김 (동시 세션·서버 장애·rate limit 의심)"
if result.get("ticks", 0) > 0:
return "OK — 연결 유지 + H0STCNT0 틱 수신"
if result.get("duration_sec", 0) >= 5:
return "OK — 연결 유지 (장중 틱 없음=거래 없는 종목·장외 가능)"
return "WARN — 연결 짧음, 추가 확인 필요"
def main() -> int:
parser = argparse.ArgumentParser(description="KIS WebSocket 진단")
parser.add_argument(
"--codes",
default=",".join(DEFAULT_CODES),
help="구독 테스트 종목 (쉼표구분)",
)
parser.add_argument(
"--phase",
choices=("all", "approval", "connect_only", "single", "multi", "duplicate_sub"),
default="all",
)
parser.add_argument(
"--force",
action="store_true",
help="kis_trader_main.service 기동 중에도 WS 테스트 실행",
)
parser.add_argument(
"--gap-sec",
type=float,
default=0.0,
help="종목별 구독 간격(초). 0=봇과 동일 즉시 연속",
)
parser.add_argument(
"--hold-sec",
type=float,
default=8.0,
help="연결 유지 관측 시간(초)",
)
args = parser.parse_args()
codes = [c.strip() for c in args.codes.split(",") if c.strip()]
log = _setup_logging()
log.info("=" * 60)
log.info("KIS WebSocket 진단 시작 → log: %s", LOG_PATH)
log.info("=" * 60)
mock = get_env_bool("KIS_MOCK", True)
client = KISClient(mock=mock)
ws_url = (
get_env_from_db("KIS_WS_URL_MOCK", "ws://ops.koreainvestment.com:31000")
if mock
else get_env_from_db("KIS_WS_URL_REAL", "ws://ops.koreainvestment.com:21000")
)
market_open = _is_market_hours(mock)
svc = _service_running()
log.info("KIS_MOCK=%s | WS_URL=%s", mock, ws_url)
log.info("장 서비스 시간=%s (mock기준 open %s)", market_open, "09:00" if mock else "08:25")
log.info("kis_trader_main.service active=%s", svc)
if svc and not args.force:
log.warning(
"⚠️ 메인 봇이 WS 사용 중 → 2번째 연결은 기존 세션을 끊을 수 있음. "
"Phase connect/subscribe 는 스킵 (--force 로 강행)",
)
# Phase 1: approval_key
if args.phase in ("all", "approval"):
log.info("--- Phase 1: approval_key REST ---")
key, raw = _fetch_approval_key(client.base_url, client.app_key, client.app_secret)
if key:
log.info(" approval_key OK (앞8자 %s…)", key[:8])
else:
log.error(" approval_key FAIL: %s", raw)
return 1
if svc and not args.force and args.phase != "approval":
log.info("진단 종료 (서비스 기동 중, WS Phase 스킵). --force 로 재실행하세요.")
return 0
key, _ = _fetch_approval_key(client.base_url, client.app_key, client.app_secret)
if not key:
log.error("approval_key 없음 — 종료")
return 1
phases: List[Tuple[str, List[str], bool]] = []
if args.phase in ("all", "connect_only"):
phases.append(("connect_only", [], False))
if args.phase in ("all", "single"):
phases.append(("single", [codes[0] if codes else "005930"], False))
if args.phase in ("all", "multi"):
phases.append(("multi", codes, False))
if args.phase in ("all", "duplicate_sub"):
phases.append(("duplicate_sub", codes[:3] if codes else ["005930"], True))
if args.phase == "duplicate_sub":
phases = [("duplicate_sub", codes[:3] if codes else ["005930"], True)]
summary: List[str] = []
for name, sub_codes, dup in phases:
log.info("--- Phase: %s (codes=%d dup=%s gap=%.1fs hold=%.0fs) ---",
name, len(sub_codes), dup, args.gap_sec, args.hold_sec)
probe = WsProbe(ws_url, key, log, gap_sec=args.gap_sec)
res = probe.run(sub_codes, duplicate_subscribe=dup, hold_sec=args.hold_sec)
v = _verdict(name, res, market_open)
log.info(" 결과: %s | detail=%s", v, res)
summary.append(f"{name}: {v}")
log.info("=" * 60)
log.info("요약")
for line in summary:
log.info("%s", line)
log.info("")
log.info("해석 가이드:")
log.info(" • 장중 instant_drop + 서비스 active → 동시 WS 세션 충돌 가능성 큼")
log.info(" • json_errors>0 → KIS 가 구독 거부 (rt_cd/msg1 로그 확인)")
log.info(" • duplicate_sub 만 instant_drop → kis_ws._on_open 이중 subscribe 버그")
log.info(" • 장외 instant_drop → '장외 서버 거부' 메시지는 이 경우 정상")
log.info("=" * 60)
return 0
if __name__ == "__main__":
raise SystemExit(main())