feat: 새로운 안전 규칙 및 최적화 적용을 통한 트레이딩 시스템 개선
변경 사항 (Changes): 구문 오류(Syntax error) 및 토큰 낭비를 방지하기 위해 에이전트 쉘(Agent shell)과 파이썬 코드 스니펫에 다수의 신규 안전 규칙(Safety rules)을 추가함. 스키마 검증 및 적절한 SQL 포맷팅을 보장하기 위해 임시(Ad-hoc) 데이터베이스 쿼리 작성 가이드라인을 도입함. 코드 수정 후 UI 기능이 정상 작동하는지 확인하기 위해, 백테스트 웹 서비스 재시작 및 브라우저 검증에 대한 새로운 규칙을 구현함. 시스템 전반의 무결성(Integrity)을 유지하기 위해 실전 매매(Live trading), 웹 백테스팅, 파라미터 탐색(Parameter searches) 간의 일관성 검사(Consistency checks) 체계를 확립함. 기대 효과 (Impact): 이러한 개선 사항들은 트레이딩 시스템의 견고성(Robustness)과 신뢰성을 향상시키며, 에러 발생을 최소화하고 다양한 시스템 컴포넌트 간의 원활한 상호작용을 보장함.
This commit is contained in:
403
kis_trader/scripts/test_kis_ws_diagnostic.py
Normal file
403
kis_trader/scripts/test_kis_ws_diagnostic.py
Normal file
@@ -0,0 +1,403 @@
|
||||
#!/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())
|
||||
@@ -169,9 +169,49 @@ def verify_tail() -> int:
|
||||
return _cmp_rows(rows)
|
||||
|
||||
|
||||
def verify_scalping() -> int:
|
||||
import backtest_web as bw
|
||||
import kis_trader.engine.scalping_engine as se
|
||||
from kis_trader.backtest.param_search_scalping import _fixed_defaults, _ui_to_engine_params
|
||||
|
||||
print("\n=== 스캘핑 (SCALP) ===")
|
||||
can = se.get_scalping_defaults_from_db() # 실매 + param_search base (비율)
|
||||
ui = bw._scalp_ui_defaults_from_db() # 웹 표시값 (%)
|
||||
|
||||
# [A] 웹 표시(%) → ÷100 왕복 == 실매 비율
|
||||
rows = [
|
||||
("sl_pct(손절)", can["sl_pct"], _ui_pct_to_ratio(ui["sl_pct"]), "비율"),
|
||||
("tp_pct(익절)", can["tp_pct"], _ui_pct_to_ratio(ui["tp_pct"]), "비율"),
|
||||
("tp_max_pct(익절상한)", can["tp_max_pct"], _ui_pct_to_ratio(ui["tp_max_pct"]), "비율"),
|
||||
("drop_rate(낙폭)", can["drop_rate"], _ui_pct_to_ratio(ui["drop_rate"]), "비율"),
|
||||
("shoulder_min_high", can["shoulder_min_high"], _ui_pct_to_ratio(ui["shoulder_min_high"]), "비율"),
|
||||
("shoulder_cut_pct", can["shoulder_cut_pct"], _ui_pct_to_ratio(ui["shoulder_cut_pct"]), "비율"),
|
||||
("rsi_oversold", can["rsi_oversold"], float(ui["rsi_oversold"]), "그대로"),
|
||||
("rsi_period", can["rsi_period"], int(float(ui["rsi_period"])), "정수"),
|
||||
("max_daily", can["max_daily"], int(float(ui["max_daily"])), "정수"),
|
||||
("use_macd_cross", bool(can.get("use_macd_cross")), bool(ui.get("use_macd_cross")), "bool"),
|
||||
("skip_hts_scan_dupes", bool(can.get("skip_hts_scan_dupes")),
|
||||
bool(ui.get("skip_hts_scan_dupes")), "bool"),
|
||||
]
|
||||
bad = _cmp_rows(rows)
|
||||
|
||||
# [B] param_search _ui_to_engine == 실매 비율
|
||||
print(" [B] param_search _ui_to_engine vs 실매 비율")
|
||||
ps_ui = _fixed_defaults()
|
||||
eng = _ui_to_engine_params(dict(ps_ui))
|
||||
rows_b = []
|
||||
for k in (
|
||||
"sl_pct", "tp_pct", "tp_max_pct", "drop_rate",
|
||||
"shoulder_min_high", "shoulder_cut_pct",
|
||||
):
|
||||
rows_b.append((k, can.get(k), eng.get(k), "비율"))
|
||||
bad += _cmp_rows(rows_b)
|
||||
return bad
|
||||
|
||||
|
||||
def main() -> int:
|
||||
total_bad = 0
|
||||
for fn in (verify_momentum, verify_breakout, verify_tail):
|
||||
for fn in (verify_momentum, verify_breakout, verify_tail, verify_scalping):
|
||||
try:
|
||||
total_bad += fn()
|
||||
except Exception as e: # noqa: BLE001
|
||||
|
||||
Reference in New Issue
Block a user