Files
kis_trader/scripts/collect_ls_condition_history.py

659 lines
23 KiB
Python

#!/usr/bin/env python3
"""
LS 서버저장조건(동명) → ls_candidates_history 단독 수집기
========================================================
⚠️ 운영 기본 경로가 아님.
``LS_CONDITION_HISTORY_ENABLED=true`` 이면 **kis_trader_main** 이
조건 매니저 + (FOLLOW 시) LS WS 를 기동한다.
단독 수집기와 main 을 **동시에** 돌리면 AFR/유량 충돌 가능 → 둘 중 하나만.
실매(kis_trader_main) / 키움 조건검색 / target_candidates_history 를 **건드리지 않음**.
흐름:
1) t1866 목록 → 이름(breakout/tail/momentum/scalp_re)으로 query_index 해결
2) t1859 스냅샷 → 초기 유니버스
3) t1860 실시간등록 → WS AFR(tr_key=sAlertNum) REG
4) AFR 편입/이탈·주기 스냅샷 → ``ls_candidates_history`` INSERT
시세(US3) FOR 미러는 기본 OFF.
--also-ticks 를 켜면 **현재 조건 유니버스 코드만** 천천히 US3 REG
(조건명은 t1866 단계에서만 사용 — LS 시세 REG 는 코드/tr_key 만 받음).
사용:
# 기존 LS 히스토리 비우고 수집 시작
python3 scripts/collect_ls_condition_history.py --purge --run
# 목록만
python3 scripts/collect_ls_condition_history.py --list-only
로그:
logs/ls_condition_history_YYYYMMDD_HHMMSS.log
"""
from __future__ import annotations
import argparse
import importlib
import json
import logging
import os
import sys
import threading
import time
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, List, Optional, Set, Tuple
ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
# 기존 단독 테스트의 REST/WS 헬퍼 재사용 (실매 미의존)
_rt = importlib.import_module("_test_ls_condition_realtime")
# LS query_name → 전략 id (키움 CONDITION_*_KIWOOM_NAME 과 동일)
DEFAULT_NAME_TO_SID: Dict[str, str] = {
"breakout": "BREAKOUT",
"tail": "SHORT",
"momentum": "MOMENTUM",
"scalp_re": "SCALP",
}
DDL = """
CREATE TABLE IF NOT EXISTS ls_candidates_history (
id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
slot_key VARCHAR(12) NOT NULL,
scan_time VARCHAR(30) NOT NULL,
code VARCHAR(20) NOT NULL,
name VARCHAR(100) NOT NULL DEFAULT '',
score DOUBLE NOT NULL DEFAULT 0,
price DOUBLE NOT NULL DEFAULT 0,
market CHAR(1) DEFAULT 'Q',
sector VARCHAR(100) NULL,
theme VARCHAR(100) NULL,
strategy_id VARCHAR(32) NOT NULL DEFAULT '',
event_time VARCHAR(30) NOT NULL DEFAULT '',
query_name VARCHAR(64) NOT NULL DEFAULT '',
query_index VARCHAR(32) NOT NULL DEFAULT '',
source VARCHAR(16) NOT NULL DEFAULT 'ls_afr',
INDEX idx_ls_cand_slot (slot_key),
INDEX idx_ls_cand_sid_evt (strategy_id, event_time),
INDEX idx_ls_cand_code (code),
INDEX idx_ls_cand_scan (scan_time)
) CHARACTER SET utf8mb4
"""
def _setup_logger(log_path: Path) -> logging.Logger:
logger = logging.getLogger("ls_cond_hist")
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 _ensure_table(db) -> None:
db.conn.execute(DDL)
def _purge_ls_history(db, logger: logging.Logger) -> None:
"""기존 LS 쪽 히스토리만 비움 (키움 target_candidates_history / 실매 테이블 제외)."""
for tbl in ("ls_universe_history", "ls_candidates_history"):
try:
db.conn.execute(f"CREATE TABLE IF NOT EXISTS {tbl} (id INT)") # no-op if missing weird
except Exception:
pass
try:
n = db.conn.execute(f"SELECT COUNT(*) c FROM {tbl}").fetchone()["c"]
except Exception as e:
logger.warning("purge skip %s (없음?): %s", tbl, e)
continue
db.conn.execute(f"DELETE FROM {tbl}")
logger.info("🗑️ purge %s rows_was=%s → 0", tbl, n)
def _slot_key_now() -> str:
return datetime.now().strftime("%Y%m%d%H%M")
def _event_time_now() -> str:
return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
def insert_ls_snapshot(
db,
*,
strategy_id: str,
query_name: str,
query_index: str,
items: List[Dict[str, Any]],
event_time: Optional[str] = None,
source: str = "ls_afr",
) -> int:
"""풀 유니버스 스냅샷 → ls_candidates_history (동일 event_time 재기록)."""
et = event_time or _event_time_now()
sk = et.replace("-", "").replace(":", "").replace(" ", "")[:12]
sid = (strategy_id or "").strip().upper()
qn = (query_name or "")[:64]
qi = (query_index or "")[:32]
inserted = 0
with db.conn:
db.conn.execute(
"DELETE FROM ls_candidates_history "
"WHERE strategy_id=%s AND event_time=%s",
(sid, et),
)
for it in items:
code = str(it.get("code") or "").strip()
if not code:
continue
name = str(it.get("name") or code)[:100]
try:
price = float(it.get("price") or 0)
except (TypeError, ValueError):
price = 0.0
db.conn.execute(
"""
INSERT INTO ls_candidates_history
(slot_key, scan_time, code, name, score, price,
market, sector, theme, strategy_id, event_time,
query_name, query_index, source)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
""",
(
sk, et, code, name, 0.0, price,
"Q", "", "", sid, et, qn, qi, source[:16],
),
)
inserted += 1
return inserted
class ConditionState:
def __init__(
self,
*,
strategy_id: str,
query_name: str,
query_index: str,
alert_num: str = "",
) -> None:
self.strategy_id = strategy_id
self.query_name = query_name
self.query_index = query_index
self.alert_num = alert_num
self.codes: Dict[str, Dict[str, Any]] = {} # code → {name, price}
self.lock = threading.Lock()
def set_from_snapshot(self, rows: List[Dict[str, Any]]) -> None:
with self.lock:
self.codes.clear()
for r in rows:
code = str(r.get("shcode") or r.get("code") or "").strip()
if not code:
continue
self.codes[code] = {
"code": code,
"name": str(r.get("hname") or r.get("name") or code)[:100],
"price": r.get("price") or 0,
}
def apply_afr(self, body: Dict[str, Any]) -> Tuple[str, Optional[str]]:
"""반환: (job, code) job=N/R/O."""
job = str(body.get("gsJobFlag") or "").strip().upper()
code = str(body.get("gsCode") or body.get("shcode") or "").strip()
if not code:
return job, None
name = str(body.get("gsHname") or body.get("hname") or code)[:100]
try:
price = float(str(body.get("gsPrice") or body.get("price") or 0).replace(",", ""))
except (TypeError, ValueError):
price = 0.0
with self.lock:
if job in ("N", "R"):
self.codes[code] = {"code": code, "name": name, "price": price}
elif job == "O":
self.codes.pop(code, None)
return job, code
def items(self) -> List[Dict[str, Any]]:
with self.lock:
return [dict(v) for v in self.codes.values()]
class AfrHistoryWatcher:
"""AFR 전용 WS — 프로토콜 ping OFF (클라 ping 대기 타임아웃 방지)."""
def __init__(
self,
token: str,
ws_url: str,
logger: logging.Logger,
*,
by_alert: Dict[str, ConditionState],
on_change,
also_tick_codes: Optional[Set[str]] = None,
tick_gap_ms: int = 200,
) -> None:
import websocket
self._websocket = websocket
self.token = token
self.ws_url = ws_url
self.logger = logger
self.by_alert = by_alert
self.on_change = on_change
self.also_tick_codes = set(also_tick_codes or ())
self.tick_gap_ms = max(50, int(tick_gap_ms))
self._ws: Any = None
self._thread: Optional[threading.Thread] = None
self.opened = threading.Event()
self._stop = threading.Event()
self.msg_count = 0
self.afr_count = 0
self._tick_sub: Set[str] = set()
self._send_lock = threading.Lock()
# 죽은 alert / rematch 전 미매칭 — 조용히 drop 하지 않음
self._last_unmatched_afr_log = 0.0
self._unmatched_afr_warn_sec = 30.0
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-hist", daemon=True)
self._thread.start()
def _run(self) -> None:
assert self._ws is not None
# ping OFF — LS 가 클라 ping 에 pong 안 주는 케이스 회피
self._ws.run_forever()
def wait_open(self, timeout: float = 15.0) -> bool:
return self.opened.wait(timeout=timeout)
def _send(self, payload: dict) -> None:
if self._ws is None:
return
with self._send_lock:
self._ws.send(json.dumps(payload, ensure_ascii=False))
def reg_afr(self, alert_num: str, *, tr_type: str = "3") -> None:
self._send(
{
"header": {"token": self.token, "tr_type": tr_type},
"body": {"tr_cd": "AFR", "tr_key": alert_num},
}
)
self.logger.info(
"AFR %s tr_key=%r (조건명→alert 매핑 후 키만 전송)",
"REG" if tr_type == "3" else "UNREG",
alert_num,
)
def reg_us3(self, code: str, *, tr_type: str = "3") -> None:
"""시세 미러(옵션). tr_key = U+6자리 — 이름은 WS REG 에 없음."""
key = code if code.startswith("U") else f"U{code}"
self._send(
{
"header": {"token": self.token, "tr_type": tr_type},
"body": {"tr_cd": "US3", "tr_key": key},
}
)
def sync_ticks(self, codes: Set[str]) -> None:
if not self.also_tick_codes and not codes:
return
# also_tick_codes 가 비어있으면 유니버스 전체, 아니면 교집합만
want = set(codes)
if self.also_tick_codes:
# True sentinel: empty set means "all current universe" when flag set
pass
add = want - self._tick_sub
rem = self._tick_sub - want
for c in sorted(rem):
self.reg_us3(c, tr_type="4")
self._tick_sub.discard(c)
time.sleep(self.tick_gap_ms / 1000.0)
for c in sorted(add):
self.reg_us3(c, tr_type="3")
self._tick_sub.add(c)
time.sleep(self.tick_gap_ms / 1000.0)
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 ping=off", self.ws_url)
self.opened.set()
# 재연결 시 AFR 재 REG (갈아끼우기 = alert 몇 개만)
for alert, st in self.by_alert.items():
if alert:
try:
self.reg_afr(alert, tr_type="3")
time.sleep(0.3)
except Exception as e:
self.logger.warning("AFR re-REG 실패 %s: %s", alert, e)
def _on_close(self, _ws: Any, status: Any, msg: Any) -> None:
self.opened.clear()
self.logger.warning("WS CLOSE status=%s msg=%s", status, msg)
def _on_error(self, _ws: Any, err: Any) -> None:
self.logger.warning("WS ERROR %s", err)
def _on_message(self, _ws: Any, message: Any) -> None:
try:
data = json.loads(message) if isinstance(message, str) else message
except Exception:
return
self.msg_count += 1
header = data.get("header") or {}
body = data.get("body") or {}
if not isinstance(body, dict):
return
tr_cd = str(header.get("tr_cd") or "")
tr_key = str(header.get("tr_key") or body.get("tr_key") or "")
if tr_cd != "AFR" and not body.get("gsCode"):
return
st = self.by_alert.get(tr_key)
if st is None and len(self.by_alert) == 1:
st = next(iter(self.by_alert.values()))
if st is None:
# REG ACK 등 tr_key 빈 프레임은 무시 (미매칭 경고 폭주 방지)
if not str(tr_key or "").strip():
return
now = time.time()
if now - float(self._last_unmatched_afr_log or 0.0) >= float(
self._unmatched_afr_warn_sec or 30.0
):
self._last_unmatched_afr_log = now
self.logger.warning(
"⚠️ AFR 미매칭 tr_key=%r (등록 alert=%s) — "
"서버 조건 CRUD 후 죽은 키일 수 있음. "
"LS rematch(t1866) 대기/확인",
tr_key,
list(self.by_alert.keys()),
)
return
job, code = st.apply_afr(body)
if not code:
return
self.afr_count += 1
self.logger.info(
"AFR %s %s sid=%s name=%s px=%s alert=%s",
_rt.AFR_JOB_KO.get(job, job or "?"),
code,
st.strategy_id,
st.query_name,
(body.get("gsPrice") or body.get("price")),
tr_key,
)
try:
# job/code/body 는 진입·재진입·이탈 이벤트 DB 적재용 (구 콜백은 st 만)
try:
self.on_change(st, job=job, code=code, body=body)
except TypeError:
self.on_change(st)
except Exception as e:
self.logger.warning("on_change: %s", e)
def _parse_names(s: str) -> List[str]:
return [x.strip() for x in (s or "").split(",") if x.strip()]
def main() -> int:
ap = argparse.ArgumentParser(description="LS 조건식 → ls_candidates_history 단독 수집")
ap.add_argument("--list-only", action="store_true")
ap.add_argument("--purge", action="store_true", help="ls_universe/ls_candidates history DELETE")
ap.add_argument("--run", action="store_true", help="수집 루프 실행")
ap.add_argument(
"--names",
default="breakout,tail,momentum,scalp_re",
help="LS query_name 콤마목록 (기본=키움 동명)",
)
ap.add_argument(
"--flush-sec",
type=int,
default=60,
help="변동 없어도 N초마다 풀스냅샷 재저장 (기본 60)",
)
ap.add_argument(
"--also-ticks",
action="store_true",
help="조건 유니버스 코드만 US3 시세 REG (느린 gap, 기본 OFF)",
)
ap.add_argument("--tick-gap-ms", type=int, default=200)
ap.add_argument("--mock", action="store_true")
ap.add_argument("--user-id", default="")
args = ap.parse_args()
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
log_path = ROOT / "logs" / f"ls_condition_history_{ts}.log"
logger = _setup_logger(log_path)
logger.info("log → %s", log_path)
logger.info(
"purge=%s run=%s names=%r also_ticks=%s flush=%ds",
args.purge, args.run, args.names, args.also_ticks, args.flush_sec,
)
from database import TradeDB
db = TradeDB()
_ensure_table(db)
if args.purge:
_purge_ls_history(db, logger)
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_USER_ID 필요")
return 1
app_key, app_secret = _rt.load_ls_creds(use_mock=bool(args.mock))
if not app_key or not app_secret:
logger.error("LS 앱키 없음")
return 1
token = _rt.fetch_access_token(app_key, app_secret)
logger.info("token OK user_id=%s", user_id)
rows = _rt.t1866_list_conditions(token, user_id, logger=logger)
logger.info("서버저장조건 %d", len(rows))
for i, r in enumerate(rows):
logger.info(
" [%d] index=%s name=%s group=%s",
i, r["query_index"], r["query_name"], r["group_name"],
)
if args.list_only and not args.run:
return 0
want_names = _parse_names(args.names)
states: List[ConditionState] = []
for nm in want_names:
hit = _rt._resolve_query(rows, name=nm, query_index="")
if not hit or not hit.get("query_index"):
logger.error("조건명 미매칭: %r", nm)
return 3
sid = DEFAULT_NAME_TO_SID.get(nm.lower(), nm.upper())
st = ConditionState(
strategy_id=sid,
query_name=str(hit.get("query_name") or nm),
query_index=str(hit["query_index"]),
)
states.append(st)
logger.info(
"매핑 name=%s → sid=%s query_index=%s",
st.query_name, st.strategy_id, st.query_index,
)
# 스냅샷
for st in states:
snap = _rt.t1859_snapshot(token, st.query_index, logger=logger)
st.set_from_snapshot(snap)
n = insert_ls_snapshot(
db,
strategy_id=st.strategy_id,
query_name=st.query_name,
query_index=st.query_index,
items=st.items(),
source="ls_t1859",
)
logger.info(
"📸 [%s/%s] snap=%d → ls_candidates_history +%d",
st.strategy_id, st.query_name, len(st.items()), n,
)
if not args.run:
logger.info("스냅샷만 완료 (--run 없음). log=%s", log_path)
return 0
# 실시간 등록
by_alert: Dict[str, ConditionState] = {}
for st in states:
ob = _rt.t1860_realtime(
token, st.query_index, flag="E", alert_num="", logger=logger,
)
logger.info("t1860[%s] %s", st.query_name, json.dumps(ob, ensure_ascii=False))
if str(ob.get("sResultFlag") or "").strip() != "S":
logger.error("t1860 실패 name=%s", st.query_name)
return 5
alert = str(ob.get("sAlertNum") or "").strip()
if not alert:
logger.error("sAlertNum 없음 name=%s", st.query_name)
return 5
st.alert_num = alert
by_alert[alert] = st
logger.info(
"✅ 등록 name=%s sid=%s alert=%s (WS에는 alert만 REG — 이름은 로그/DB용)",
st.query_name, st.strategy_id, alert,
)
save_lock = threading.Lock()
last_flush = time.monotonic()
def on_change(st: ConditionState) -> None:
nonlocal last_flush
with save_lock:
n = insert_ls_snapshot(
db,
strategy_id=st.strategy_id,
query_name=st.query_name,
query_index=st.query_index,
items=st.items(),
source="ls_afr",
)
last_flush = time.monotonic()
logger.info(
"💾 [%s] universe=%d saved=%d",
st.strategy_id, len(st.items()), n,
)
ws_url = _rt.LS_WS_MOCK if args.mock else _rt.LS_WS_REAL
watcher = AfrHistoryWatcher(
token,
ws_url,
logger,
by_alert=by_alert,
on_change=on_change,
also_tick_codes=set() if args.also_ticks else None,
tick_gap_ms=args.tick_gap_ms,
)
# also_ticks: 빈 set 이면 "유니버스 전부" 의미로 sync — flag만 True 일 때
if args.also_ticks:
watcher.also_tick_codes = set() # unused marker; sync uses universe
# AFR REG 는 on_open 에서만 (재연결 복구 포함) — 여기서 또내면 이중 REG
watcher.start()
if not watcher.wait_open(20.0):
logger.error("WS OPEN 타임아웃")
watcher.stop()
return 4
time.sleep(0.5 + 0.35 * max(1, len(by_alert)))
if args.also_ticks:
all_codes: Set[str] = set()
for st in states:
all_codes |= {it["code"] for it in st.items()}
logger.info("US3 시세 미러 시작 codes=%d gap=%dms", len(all_codes), args.tick_gap_ms)
watcher.sync_ticks(all_codes)
logger.info("수집 루프 시작 (Ctrl+C 종료). flush=%ds", args.flush_sec)
try:
while not watcher._stop.is_set():
time.sleep(1.0)
if time.monotonic() - last_flush >= max(10, int(args.flush_sec)):
with save_lock:
for st in states:
n = insert_ls_snapshot(
db,
strategy_id=st.strategy_id,
query_name=st.query_name,
query_index=st.query_index,
items=st.items(),
source="ls_flush",
)
logger.info(
"⏱️ flush [%s/%s] n=%d univ=%d",
st.strategy_id, st.query_name, n, len(st.items()),
)
last_flush = time.monotonic()
if args.also_ticks:
all_codes = set()
for st in states:
all_codes |= {it["code"] for it in st.items()}
watcher.sync_ticks(all_codes)
except KeyboardInterrupt:
logger.info("KeyboardInterrupt → 정리")
finally:
for st in states:
if not st.alert_num:
continue
try:
watcher.reg_afr(st.alert_num, tr_type="4")
except Exception as e:
logger.warning("AFR UNREG: %s", e)
try:
_rt.t1860_realtime(
token,
st.query_index,
flag="D",
alert_num=st.alert_num,
logger=logger,
)
except Exception as e:
logger.warning("t1860 D: %s", e)
watcher.stop()
logger.info(
"종료 afr_events≈%d ws_msg=%d log=%s",
watcher.afr_count, watcher.msg_count, log_path,
)
return 0
if __name__ == "__main__":
raise SystemExit(main())