커밋 1 — 실매 가격 TTL 구멍 (본체)
왜: 체결이 없어도 마지막가는 유지인데, TTL로 None 만들고 매도/EOD를 건너뛰어 8/5 돌파·금요일 leftover가 남음. 호가필터 TTL 구멍과 같은 병. 넣을 파일 신규: kis_trader/engine/live_sell_price.py kis_trader/strategies/base.py (_ws_last_quote, _resolve_sell_price) 전략: momentum.py scalping.py tail_catch.py breakout.py range_break.py dart_strategy.py updow_strategy.py updown_feed.py us_momentum.py WS: ws_manager.py kis_ws.py kiwoom_ws.py ls_ws.py kis_ws_overseas.py kis_trader/web/live_config_schema.py (WS_PRICE_MAX_AGE_SEC 기본 0) database.py (키 주석 + legacy/ sys.path) kis_trader/execution/order_manager.py (잔고 있는데 40240000 ghost_purge 금지 — 같은 EOD 사고) EOD가 min_hold에 안 막히게 손본 momentum_hts_logic.py / scalping_engine.py / tail_engine.py (이 대화에서 손본 부분만 확인 후) 문서: docs/like_mcp.md/db_erd.md code_architecture.md (가격 TTL 문구) 메시지 초안 fix: 매수·매도 현재가를 TTL로 버리지 않음 (마지막 RAM) 횡보·체결 공백을 죽은 캐시로 오인해 None 처리하면 손절·EOD가 스킵된다. 호가필터와 같이 나이는 무시하고 마지막 체결가를 유지한다. EOD는 매수가 폴백. 영향: 실매 O / 백테·옵투나 봉 경로 거의 무관 (엔진 식 변경 아님) 커밋 2 — 루트 정리 (remove/ vs legacy/) 왜: 루트 단독봇·테스트는 지울 보관함으로. 웹·알람이 아직 쓰는 모듈은 remove에 두면 나중에 폴더째 삭제 때 깨짐. 넣을 파일 이동: 미사용 → remove/legacy_root/ (래퍼, ETF/키움 옛봇, 테스트, kiwoom_rest_api 등) 이동: 사용 중 → legacy/ (holding_bot kis_holding_ver1 news_analyzer kis_long_ver1/2) 신규: kis_trader/utils/legacy_root.py legacy/README.md remove/README.md import 경로: backtest_web.py mm_butler.py mm_remote.py updow_holding_cfg.py dbband_stock_cfg.py param_search_updow*.py dbband_param_search.py param_search_apply_snapshot.py verify_three_paths.py docs/like_mcp.md/code_architecture.md 수동 노트 메시지 초안 chore: 미사용 루트는 remove/, 웹·알람 구모듈은 legacy/ remove는 나중에 통째 삭제 예정. holding_bot·news_analyzer·kis_long은 ensure_legacy_root로 legacy/만 본다. 빼기: scratch/set_ws_price_max_age_zero.py (일회성)
This commit is contained in:
697
remove/legacy_root/_test_ls_condition_realtime.py
Normal file
697
remove/legacy_root/_test_ls_condition_realtime.py
Normal file
@@ -0,0 +1,697 @@
|
||||
#!/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())
|
||||
Reference in New Issue
Block a user