694 lines
22 KiB
Python
694 lines
22 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
|
|
|
|
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())
|