Files
kis_bot/_test_ls_condition_realtime.py

698 lines
23 KiB
Python

#!/usr/bin/env python3
"""
_test_ls_condition_realtime.py — LS증권 서버저장조건 실시간검색 단독 테스트
=======================================================================
[목적]
실매/키움 조건검색과 무관하게, LS OpenAPI 로
1) 서버저장조건 목록 (t1866)
2) 1회 스냅샷 검색 (t1859)
3) 실시간 등록 (t1860) → WS AFR 편입/이탈 push
가 되는지 확인한다. (유니버스 소스 LS 전환의 전제조건)
[공식 흐름]
REST POST /stock/item-search
t1866 서버저장조건 리스트조회 → query_index / query_name
t1859 서버저장조건 조건검색 → 현재 매칭 종목 스냅샷
t1860 서버저장조건 실시간검색 → sFlag=E 등록 / D 중지, sAlertNum
WS wss://openapi.ls-sec.co.kr:9443/websocket
AFR API사용자조건검색실시간 tr_key = t1860OutBlock.sAlertNum
gsJobFlag: N=진입, R=재진입, O=이탈
[사용]
# 목록만
python3 _test_ls_condition_realtime.py --user-id <LS로그인ID> --list-only
# 이름 매칭 + 스냅샷 + 실시간 수신 (기본 이름=momentum)
python3 _test_ls_condition_realtime.py --user-id <LS로그인ID> --name momentum
# query_index 직접 지정
python3 _test_ls_condition_realtime.py --user-id <LS로그인ID> --query-index <id0000>
# user-id 생략 시 DB env_config.LS_USER_ID (없으면 env LS_USER_ID)
python3 _test_ls_condition_realtime.py --list-only
python3 _test_ls_condition_realtime.py --name momentum
[주의]
- 읽기·실시간 수신만. 주문/DB 저장 없음.
- 종목검색 TR 초당 1회 → 호출 사이 sleep.
- 주말·장외에는 AFR push 가 거의 없고, t1859 스냅샷·t1860 등록 ACK 로 성공 판정.
"""
from __future__ import annotations
import argparse
import json
import logging
import os
import sys
import threading
import time
from datetime import datetime
from pathlib import Path
from typing import Any, Optional
import requests
SCRIPT_DIR = Path(__file__).resolve().parent
sys.path.insert(0, str(SCRIPT_DIR))
LS_REST_BASE = "https://openapi.ls-sec.co.kr:8080"
LS_WS_REAL = "wss://openapi.ls-sec.co.kr:9443/websocket"
LS_WS_MOCK = "wss://openapi.ls-sec.co.kr:29443/websocket"
ITEM_SEARCH_URL = f"{LS_REST_BASE}/stock/item-search"
# 종목검색 TR 초당 1회 (스펙 transactionPerSec)
_TR_GAP_SEC = 1.1
try:
from kis_trader.network.condition_common import JOB_KO as AFR_JOB_KO
except Exception:
AFR_JOB_KO = {"N": "진입", "R": "재진입", "O": "이탈"}
def _setup_logger(log_path: Path) -> logging.Logger:
logger = logging.getLogger("ls_cond_rt")
logger.setLevel(logging.INFO)
logger.handlers.clear()
fmt = logging.Formatter("[%(asctime)s] %(message)s", datefmt="%H:%M:%S")
sh = logging.StreamHandler(sys.stdout)
sh.setFormatter(fmt)
logger.addHandler(sh)
log_path.parent.mkdir(parents=True, exist_ok=True)
fh = logging.FileHandler(log_path, encoding="utf-8")
fh.setFormatter(fmt)
logger.addHandler(fh)
return logger
def load_ls_creds(*, use_mock: bool) -> tuple[str, str]:
"""DB env_config 에서 LS AppKey/Secret 로드 (test_ls_ws 와 동일)."""
from database import TradeDB
db = TradeDB()
try:
row = db.conn.execute(
"SELECT LS_APP_KEY_REAL, LS_APP_SECRET_REAL, "
"LS_APP_KEY_MOCK, LS_APP_SECRET_MOCK "
"FROM env_config ORDER BY id DESC LIMIT 1"
).fetchone()
if not row:
return "", ""
r = dict(row)
if use_mock:
key = (r.get("LS_APP_KEY_MOCK") or "").strip()
secret = (r.get("LS_APP_SECRET_MOCK") or "").strip()
else:
key = (r.get("LS_APP_KEY_REAL") or "").strip()
secret = (r.get("LS_APP_SECRET_REAL") or "").strip()
return key, secret
finally:
db.close()
def fetch_access_token(
app_key: str,
app_secret: str,
timeout: float = 15.0,
*,
force: bool = False,
reason: str = "",
) -> str:
"""LS ``/oauth2/token`` — expires_in 캐시 공유 (강제 연타 발급 금지)."""
from kis_trader.network.ls_token import fetch_ls_access_token
return fetch_ls_access_token(
app_key,
app_secret,
timeout=timeout,
force=force,
reason=reason or "ls_condition",
)
def _rest_headers(token: str, tr_cd: str, *, tr_cont: str = "N", tr_cont_key: str = "") -> dict:
return {
"content-type": "application/json; charset=UTF-8",
"authorization": f"Bearer {token}",
"tr_cd": tr_cd,
"tr_cont": tr_cont,
"tr_cont_key": tr_cont_key or "",
"mac_address": "",
}
def call_item_search(
token: str,
tr_cd: str,
body: dict,
*,
logger: logging.Logger,
timeout: float = 20.0,
) -> tuple[dict, dict]:
"""POST /stock/item-search → (json body, response headers dict)."""
headers = _rest_headers(token, tr_cd)
logger.info("REST %s body=%s", tr_cd, json.dumps(body, ensure_ascii=False)[:300])
resp = requests.post(
ITEM_SEARCH_URL,
headers=headers,
data=json.dumps(body),
timeout=timeout,
)
text = resp.text
try:
data = resp.json() if text else {}
except Exception:
data = {"_raw": text[:800]}
rh = {k.lower(): v for k, v in resp.headers.items()}
logger.info(
"REST %s HTTP=%s rsp_cd=%s rsp_msg=%s",
tr_cd,
resp.status_code,
data.get("rsp_cd"),
data.get("rsp_msg"),
)
if resp.status_code >= 400:
logger.error("REST %s fail: %s", tr_cd, text[:500])
try:
from kis_trader.network.ls_token import is_ls_auth_error
if is_ls_auth_error(
rsp_cd=data.get("rsp_cd") if isinstance(data, dict) else None,
rsp_msg=data.get("rsp_msg") if isinstance(data, dict) else None,
http_status=resp.status_code,
text=text,
):
raise RuntimeError(
f"LS {tr_cd} auth "
f"{(data or {}).get('rsp_cd')}: {(data or {}).get('rsp_msg')}"
)
except RuntimeError:
raise
except Exception:
pass
return data, rh
def t1866_list_conditions(
token: str,
user_id: str,
*,
logger: logging.Logger,
gb: str = "0",
) -> list[dict[str, str]]:
"""서버저장조건 전체 목록 (연속조회 포함)."""
out: list[dict[str, str]] = []
cont = ""
cont_key = ""
page = 0
while True:
page += 1
if page > 1:
time.sleep(_TR_GAP_SEC)
body = {
"t1866InBlock": {
"user_id": user_id,
"gb": gb,
"group_name": "",
"cont": cont or "",
"cont_key": cont_key or "",
}
}
# 연속은 header tr_cont 도 맞춤
headers = _rest_headers(
token,
"t1866",
tr_cont="Y" if cont in ("1", "Y", "y") else "N",
tr_cont_key=cont_key or "",
)
logger.info("REST t1866 page=%d cont=%r", page, cont)
resp = requests.post(
ITEM_SEARCH_URL,
headers=headers,
data=json.dumps(body),
timeout=20.0,
)
data = resp.json() if resp.text else {}
logger.info(
"REST t1866 HTTP=%s rsp_cd=%s rsp_msg=%s raw_head=%s",
resp.status_code,
data.get("rsp_cd"),
data.get("rsp_msg"),
json.dumps(data, ensure_ascii=False)[:400],
)
# 인증 오류는 빈 목록으로 넘기지 않음 — rematch 가 토큰 갱신하도록 raise
try:
from kis_trader.network.ls_token import is_ls_auth_error
except Exception:
is_ls_auth_error = None # type: ignore
if is_ls_auth_error is not None and is_ls_auth_error(
rsp_cd=data.get("rsp_cd"),
rsp_msg=data.get("rsp_msg"),
http_status=resp.status_code,
text=resp.text,
):
raise RuntimeError(
f"LS t1866 auth {data.get('rsp_cd')}: {data.get('rsp_msg')}"
)
if resp.status_code >= 400:
# GW 라우팅 등 — 빈 목록으로 '조건 소실' 오판 금지
raise RuntimeError(
f"LS t1866 HTTP={resp.status_code} "
f"rsp_cd={data.get('rsp_cd')} rsp_msg={data.get('rsp_msg')}"
)
rows = data.get("t1866OutBlock1") or []
if isinstance(rows, dict):
rows = [rows]
for r in rows:
if not isinstance(r, dict):
continue
out.append(
{
"query_index": str(r.get("query_index") or "").strip(),
"group_name": str(r.get("group_name") or "").strip(),
"query_name": str(r.get("query_name") or "").strip(),
}
)
ob = data.get("t1866OutBlock") or {}
next_cont = str(ob.get("cont") or "").strip()
next_key = str(ob.get("contkey") or ob.get("cont_key") or "").strip()
# header 연속 폴백
rh = {k.lower(): v for k, v in resp.headers.items()}
if not next_cont:
next_cont = str(rh.get("tr_cont") or "").strip()
if not next_key:
next_key = str(rh.get("tr_cont_key") or "").strip()
if next_cont in ("1", "Y", "y") and next_key:
cont, cont_key = next_cont, next_key
continue
break
return out
def t1859_snapshot(
token: str,
query_index: str,
*,
logger: logging.Logger,
) -> list[dict[str, Any]]:
"""서버저장조건 1회 검색 스냅샷."""
time.sleep(_TR_GAP_SEC)
data, _ = call_item_search(
token,
"t1859",
{"t1859InBlock": {"query_index": query_index}},
logger=logger,
)
ob = data.get("t1859OutBlock") or {}
logger.info(
"t1859 result_count=%s result_time=%s text=%s",
ob.get("result_count"),
ob.get("result_time"),
(ob.get("text") or "")[:80],
)
rows = data.get("t1859OutBlock1") or []
if isinstance(rows, dict):
rows = [rows]
return [r for r in rows if isinstance(r, dict)]
def t1860_realtime(
token: str,
query_index: str,
*,
flag: str,
alert_num: str = "",
logger: logging.Logger,
) -> dict[str, Any]:
"""실시간 검색 등록(E) / 중지(D)."""
time.sleep(_TR_GAP_SEC)
data, _ = call_item_search(
token,
"t1860",
{
"t1860InBlock": {
"sSysUserFlag": "U",
"sFlag": flag,
"sAlertNum": alert_num or "",
"query_index": query_index,
}
},
logger=logger,
)
return data.get("t1860OutBlock") or {}
class AfrWatcher:
"""LS WS 로 AFR(조건검색 실시간) 수신."""
def __init__(self, token: str, ws_url: str, logger: logging.Logger):
import websocket # websocket-client
self._websocket = websocket
self.token = token
self.ws_url = ws_url
self.logger = logger
self._ws: Any = None
self._thread: Optional[threading.Thread] = None
self.opened = threading.Event()
self._stop = threading.Event()
self.events: list[dict[str, Any]] = []
self.msg_count = 0
self.errors: list[str] = []
self._lock = threading.Lock()
def start(self) -> None:
self._ws = self._websocket.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._thread = threading.Thread(target=self._run, name="ls-afr", daemon=True)
self._thread.start()
def _run(self) -> None:
assert self._ws is not None
self._ws.run_forever(ping_interval=20, ping_timeout=10)
def wait_open(self, timeout: float = 15.0) -> bool:
return self.opened.wait(timeout=timeout)
def reg_afr(self, alert_num: str, *, tr_type: str = "3") -> None:
"""tr_type 3=등록, 4=해제. tr_key = t1860 sAlertNum."""
payload = {
"header": {"token": self.token, "tr_type": tr_type},
"body": {"tr_cd": "AFR", "tr_key": alert_num},
}
if self._ws is None:
raise RuntimeError("WS not started")
self._ws.send(json.dumps(payload, ensure_ascii=False))
self.logger.info(
"AFR %s tr_key=%r",
"REG" if tr_type == "3" else "UNREG",
alert_num,
)
def stop(self) -> None:
self._stop.set()
if self._ws is not None:
try:
self._ws.close()
except Exception:
pass
def _on_open(self, _ws: Any) -> None:
self.logger.info("WS OPEN %s", self.ws_url)
self.opened.set()
def _on_close(self, _ws: Any, status: Any, msg: Any) -> None:
self.logger.info("WS CLOSE status=%s msg=%s", status, msg)
def _on_error(self, _ws: Any, err: Any) -> None:
text = str(err)
self.errors.append(text)
self.logger.error("WS ERROR %s", text)
def _on_message(self, _ws: Any, message: Any) -> None:
self.msg_count += 1
try:
data = json.loads(message) if isinstance(message, str) else message
except Exception:
self.logger.warning("non-JSON: %s", str(message)[:200])
return
header = data.get("header") or {}
body = data.get("body") or {}
tr_cd = str(header.get("tr_cd") or body.get("tr_cd") or "")
tr_key = str(header.get("tr_key") or body.get("tr_key") or "")
# 등록 ACK / 일반 응답
if tr_cd != "AFR" and not body.get("gsCode"):
self.logger.info(
"WS MSG#%d tr_cd=%s tr_key=%r head=%s body=%s",
self.msg_count,
tr_cd,
tr_key,
json.dumps(header, ensure_ascii=False)[:200],
json.dumps(body, ensure_ascii=False)[:300],
)
return
code = str(body.get("gsCode") or "").strip()
name = str(body.get("gshname") or "").strip()
job = str(body.get("gsJobFlag") or "").strip()
price = body.get("gsPrice")
chg = body.get("gsChgRate")
vol = body.get("gsVolume")
ev = {
"ts": datetime.now().strftime("%H:%M:%S"),
"tr_key": tr_key,
"code": code,
"name": name,
"job": job,
"job_ko": AFR_JOB_KO.get(job, job),
"price": price,
"chg_rate": chg,
"volume": vol,
"raw": body,
}
with self._lock:
self.events.append(ev)
self.logger.info(
"📶 AFR %s %s(%s) px=%s chg=%s%% vol=%s tr_key=%r",
AFR_JOB_KO.get(job, job or "?"),
code,
name,
price,
chg,
vol,
tr_key,
)
# 원문 1회 더 (필드 확인용)
if len(self.events) <= 3:
self.logger.info("AFR raw body=%s", json.dumps(body, ensure_ascii=False))
def _resolve_query(
rows: list[dict[str, str]],
*,
name: str,
query_index: str,
) -> Optional[dict[str, str]]:
if query_index:
for r in rows:
if r["query_index"] == query_index:
return r
return {
"query_index": query_index,
"group_name": "",
"query_name": "(직접지정)",
}
name_l = (name or "").strip().lower()
if not name_l:
return None
exact = [r for r in rows if r["query_name"].lower() == name_l]
if exact:
return exact[0]
soft = [r for r in rows if name_l in r["query_name"].lower()]
return soft[0] if soft else None
def main() -> int:
ap = argparse.ArgumentParser(description="LS 서버저장조건 실시간검색 테스트")
ap.add_argument(
"--user-id",
default="",
help="LS 로그인 ID (t1866 필수, 최대 8자). "
"생략 시 DB LS_USER_ID → env LS_USER_ID",
)
ap.add_argument(
"--name",
default=(os.environ.get("LS_COND_NAME") or "momentum").strip(),
help="조건저장명 (query_name). 기본 momentum",
)
ap.add_argument("--query-index", default="", help="t1866 query_index 직접 지정")
ap.add_argument("--list-only", action="store_true", help="목록(t1866)만 조회 후 종료")
ap.add_argument(
"--no-snapshot",
action="store_true",
help="t1859 스냅샷 생략",
)
ap.add_argument(
"--no-realtime",
action="store_true",
help="t1860/AFR 실시간 생략 (목록+스냅샷만)",
)
ap.add_argument(
"--watch-sec",
type=int,
default=int(os.environ.get("LS_COND_WATCH_SEC") or "45"),
help="AFR 수신 관찰 초 (기본 45)",
)
ap.add_argument("--mock", action="store_true", help="모의 키/WS 사용")
args = ap.parse_args()
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
log_path = SCRIPT_DIR / "logs" / f"ls_condition_rt_{ts}.log"
logger = _setup_logger(log_path)
logger.info("log → %s", log_path)
logger.info(
"mode list_only=%s name=%r query_index=%r watch=%ds mock=%s",
args.list_only,
args.name,
args.query_index,
args.watch_sec,
args.mock,
)
user_id = (args.user_id or "").strip()
if not user_id:
try:
from kis_trader.utils.env import get_env_from_db
user_id = (get_env_from_db("LS_USER_ID", "") or "").strip()
except Exception:
user_id = ""
if not user_id:
user_id = (os.environ.get("LS_USER_ID") or "").strip()
if not user_id:
logger.error(
"❌ LS 로그인 ID 필요. --user-id / DB LS_USER_ID / env LS_USER_ID "
"(OpenAPI/HTS 로그인 ID, 스펙 최대 8자)"
)
return 1
logger.info("LS_USER_ID=%s", user_id)
if len(user_id) > 8:
logger.warning("⚠️ user_id 길이 %d > 8 — 스펙은 8자. 그대로 시도합니다.", len(user_id))
try:
import websocket # noqa: F401
except Exception as e:
logger.error("❌ websocket-client 미설치: %s", e)
return 1
app_key, app_secret = load_ls_creds(use_mock=bool(args.mock))
if not app_key or not app_secret:
logger.error("❌ LS 앱키 미설정 (env_config LS_APP_KEY_REAL/SECRET_REAL)")
return 1
logger.info("🔑 LS 키 OK (mock=%s, key앞8=%s…)", args.mock, app_key[:8])
try:
token = fetch_access_token(app_key, app_secret)
except Exception as e:
logger.error("❌ 토큰 실패: %s", e)
return 1
logger.info("🎫 토큰 OK (앞8=%s…)", token[:8])
# ── 1) 목록 ──
rows = t1866_list_conditions(token, user_id, logger=logger)
logger.info("📋 서버저장조건 %d", len(rows))
for i, r in enumerate(rows):
logger.info(
" [%d] query_index=%s group=%s name=%s",
i,
r["query_index"],
r["group_name"],
r["query_name"],
)
if not rows:
logger.error("❌ 조건 목록 비어 있음 — user_id / 서버저장조건 확인")
return 2
if args.list_only:
logger.info("✅ list-only 완료. log=%s", log_path)
return 0
target = _resolve_query(rows, name=args.name, query_index=args.query_index)
if not target or not target.get("query_index"):
logger.error("❌ 조건 미매칭 name=%r query_index=%r", args.name, args.query_index)
return 3
qidx = target["query_index"]
logger.info(
"🎯 선택 query_index=%s name=%s group=%s",
qidx,
target.get("query_name"),
target.get("group_name"),
)
# ── 2) 스냅샷 ──
snap_codes: list[str] = []
if not args.no_snapshot:
snap = t1859_snapshot(token, qidx, logger=logger)
for r in snap:
code = str(r.get("shcode") or "").strip()
if code:
snap_codes.append(code)
logger.info(
" SNAP %s %s px=%s diff=%s vol=%s",
code,
r.get("hname"),
r.get("price"),
r.get("diff"),
r.get("volume"),
)
logger.info("📸 스냅샷 %d종목", len(snap_codes))
if args.no_realtime:
logger.info("✅ 스냅샷까지 완료 (realtime 생략). log=%s", log_path)
return 0
# ── 3) WS + t1860 + AFR ──
ws_url = LS_WS_MOCK if args.mock else LS_WS_REAL
watcher = AfrWatcher(token, ws_url, logger)
watcher.start()
if not watcher.wait_open(15.0):
logger.error("❌ WS OPEN 타임아웃")
watcher.stop()
return 4
alert_num = ""
try:
ob = t1860_realtime(token, qidx, flag="E", alert_num="", logger=logger)
logger.info("t1860 OutBlock=%s", json.dumps(ob, ensure_ascii=False))
result_flag = str(ob.get("sResultFlag") or "").strip()
alert_num = str(ob.get("sAlertNum") or "").strip()
msg = str(ob.get("Msg") or "")
if result_flag != "S" or not alert_num:
logger.error("❌ t1860 등록 실패 result=%s msg=%s", result_flag, msg)
return 5
logger.info("✅ t1860 등록 OK sAlertNum=%s msg=%s", alert_num, msg)
watcher.reg_afr(alert_num, tr_type="3")
logger.info("👀 AFR 수신 대기 %d초 (장외면 push 0건 가능)…", args.watch_sec)
deadline = time.time() + max(1, int(args.watch_sec))
while time.time() < deadline and not watcher._stop.is_set():
time.sleep(0.5)
finally:
# 해제: AFR UNREG → t1860 D
if alert_num:
try:
watcher.reg_afr(alert_num, tr_type="4")
except Exception as e:
logger.warning("AFR UNREG 실패: %s", e)
try:
ob_d = t1860_realtime(
token, qidx, flag="D", alert_num=alert_num, logger=logger
)
logger.info(
"t1860 중지 OutBlock=%s",
json.dumps(ob_d, ensure_ascii=False),
)
except Exception as e:
logger.warning("t1860 D 실패: %s", e)
watcher.stop()
logger.info(
"📊 요약 snap=%d AFR이벤트=%d WSmsg=%d errors=%d",
len(snap_codes),
len(watcher.events),
watcher.msg_count,
len(watcher.errors),
)
logger.info("✅ 완료. log=%s (tail -f %s)", log_path, log_path)
return 0
if __name__ == "__main__":
raise SystemExit(main())