Files
kis_bot/kis_trader/scripts/test_kis_ws_diagnostic.py
Your Name fc27e726f9 feat: 새로운 안전 규칙 및 최적화 적용을 통한 트레이딩 시스템 개선
변경 사항 (Changes):

구문 오류(Syntax error) 및 토큰 낭비를 방지하기 위해 에이전트 쉘(Agent shell)과 파이썬 코드 스니펫에 다수의 신규 안전 규칙(Safety rules)을 추가함.

스키마 검증 및 적절한 SQL 포맷팅을 보장하기 위해 임시(Ad-hoc) 데이터베이스 쿼리 작성 가이드라인을 도입함.

코드 수정 후 UI 기능이 정상 작동하는지 확인하기 위해, 백테스트 웹 서비스 재시작 및 브라우저 검증에 대한 새로운 규칙을 구현함.

시스템 전반의 무결성(Integrity)을 유지하기 위해 실전 매매(Live trading), 웹 백테스팅, 파라미터 탐색(Parameter searches) 간의 일관성 검사(Consistency checks) 체계를 확립함.

기대 효과 (Impact):

이러한 개선 사항들은 트레이딩 시스템의 견고성(Robustness)과 신뢰성을 향상시키며, 에러 발생을 최소화하고 다양한 시스템 컴포넌트 간의 원활한 상호작용을 보장함.
2026-07-17 01:09:09 +09:00

404 lines
14 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()
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.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)
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())