커밋 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:
Your Name
2026-08-18 00:13:17 +09:00
parent 36a3e2b4a1
commit 6d2a706a48
1416 changed files with 0 additions and 0 deletions

View File

@@ -0,0 +1,998 @@
#!/usr/bin/env python3
"""
test_ls_ws.py — LS증권 OpenAPI WebSocket 실시간 구독 단독 검증
=============================================================
봇과 무관하게 LS 접근토큰 발급 → (해외면 REST 현재가) → WS 구독 →
**수신 body 필드 전체**를 한글로 덤프한다.
공식 참고
---------
- REST 토큰: ``POST https://openapi.ls-sec.co.kr:8080/oauth2/token``
- 실전 WS: ``wss://openapi.ls-sec.co.kr:9443/websocket``
- 모의 WS: ``wss://openapi.ls-sec.co.kr:29443/websocket``
- 해외 GSC ``tr_key``: ``{거래소코드}{심볼}`` 을 **18자리 공백 패딩**
(예: NASDAQ TSLA → ``\"82TSLA\" + 공백12`` = 총 18자. 공식 예: ``81SOXL ``)
- 해외 REST 현재가: ``POST /overseas-stock/market-data`` ``g3101``
사용법
------
영구구독 KR::
python3 test_ls_ws.py
해외 SPCX / TSLA / QQQM (필드 덤프 + REST 현재가)::
python3 test_ls_ws.py --codes SPCX,TSLA,QQQM --duration 45
"""
from __future__ import annotations
import argparse
import json
import logging
import sys
import threading
import time
from datetime import datetime
from pathlib import Path
from typing import Any
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"
KR_TR_UNIFIED = "US3"
KR_TR_UNIFIED_HOGA = "UH1"
KR_TR_KOSPI = "S3_"
KR_TR_KOSDAQ = "K3_"
KR_TR_KOSPI_HOGA = "H1_"
KR_TR_KOSDAQ_HOGA = "HA_"
US_TR_TRADE = "GSC"
# LS/xing 해외 거래소코드 (미국)
EXCHANGE_TO_LS: dict[str, str] = {
"NASD": "82",
"NASDAQ": "82",
"NQ": "82",
"NYSE": "81",
"NYS": "81",
"AMEX": "83",
"AMS": "83",
"ASE": "83",
}
# GSC 실시간 체결 OutBlock (스펙 blocks.json)
GSC_FIELD_KO: dict[str, str] = {
"symbol": "종목코드",
"price": "현재가",
"sign": "등락부호",
"diff": "전일대비",
"rate": "등락률(%)",
"open": "시가",
"high": "고가",
"low": "저가",
"high52p": "52주최고",
"low52p": "52주최저",
"trdq": "체결수량",
"totq": "누적거래량",
"amount": "누적거래대금",
"cgubun": "체결구분",
"trdtm": "체결시각(현지)",
"kortm": "체결시각(한국)",
"ovsdate": "현지일자",
"kordate": "한국일자",
"lSeq": "시퀀스",
}
# US3/S3_ 국내 체결 OutBlock (주요 필드)
KR_TICK_FIELD_KO: dict[str, str] = {
"shcode": "종목코드",
"price": "현재가",
"sign": "등락부호",
"change": "전일대비",
"drate": "등락률(%)",
"open": "시가",
"high": "고가",
"low": "저가",
"volume": "누적거래량",
"cvolume": "체결량",
"value": "누적거래대금",
"chetime": "체결시각",
"bidho": "매수호가",
"offerho": "매도호가",
"cpower": "체결강도",
"w_avrg": "가중평균가",
"exchname": "거래소",
"status": "상태",
}
# g3101 REST 현재가 OutBlock
G3101_FIELD_KO: dict[str, str] = {
"symbol": "종목코드",
"korname": "한글명",
"exchcd": "거래소코드",
"exchange": "거래소명",
"currency": "통화",
"price": "현재가",
"sign": "등락부호",
"diff": "전일대비",
"rate": "등락률(%)",
"open": "시가",
"high": "고가",
"low": "저가",
"volume": "거래량",
"amount": "거래대금",
"high52p": "52주최고",
"low52p": "52주최저",
"uplimit": "상한가",
"dnlimit": "하한가",
"suspend": "거래정지",
"sellonly": "매도만가능",
"floatpoint": "소수점자리",
"induname": "업종",
"perv": "PER",
"epsv": "EPS",
"delaygb": "지연구분",
"keysymbol": "키심볼",
}
def setup_logging(verbose: bool, log_path: Path | None) -> logging.Logger:
logger = logging.getLogger("test_ls_ws")
logger.handlers.clear()
logger.setLevel(logging.DEBUG if verbose else logging.INFO)
fmt = logging.Formatter("[%(asctime)s] %(message)s", datefmt="%H:%M:%S")
sh = logging.StreamHandler(sys.stdout)
sh.setFormatter(fmt)
logger.addHandler(sh)
if log_path is not None:
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 로드."""
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 load_permanent_codes(market: str) -> list[dict[str, str]]:
"""permanent_subscriptions 에서 enabled=1 종목 로드."""
from database import TradeDB
db = TradeDB()
try:
market_u = (market or "KR").strip().upper()
if market_u == "ALL":
sql = (
"SELECT code, market_type, exchange, symbol "
"FROM permanent_subscriptions WHERE enabled=1 "
"ORDER BY market_type, code"
)
rows = db.conn.execute(sql).fetchall()
else:
sql = (
"SELECT code, market_type, exchange, symbol "
"FROM permanent_subscriptions "
"WHERE enabled=1 AND market_type=%s "
"ORDER BY code"
)
rows = db.conn.execute(sql, (market_u,)).fetchall()
out: list[dict[str, str]] = []
for row in rows:
r = dict(row)
out.append(
{
"code": str(r.get("code") or "").strip(),
"market_type": str(r.get("market_type") or "").strip().upper(),
"exchange": str(r.get("exchange") or "").strip().upper(),
"symbol": str(r.get("symbol") or r.get("code") or "").strip(),
}
)
return [x for x in out if x["code"]]
finally:
db.close()
def fetch_access_token(app_key: str, app_secret: str, timeout: float = 15.0) -> str:
"""LS OAuth2 client_credentials → access_token."""
url = f"{LS_REST_BASE}/oauth2/token"
headers = {"Content-Type": "application/x-www-form-urlencoded"}
data = {
"grant_type": "client_credentials",
"appkey": app_key,
"appsecretkey": app_secret,
"scope": "oob",
}
resp = requests.post(url, headers=headers, data=data, timeout=timeout)
if resp.status_code >= 400:
raise RuntimeError(f"token HTTP {resp.status_code}: {resp.text[:400]}")
body = resp.json()
token = body.get("access_token") or body.get("accesstoken")
if not token:
raise RuntimeError(f"no access_token in response: {body}")
return str(token)
def ls_exchcd(exchange: str, default: str = "82") -> str:
"""영구구독 exchange(NASD/NYSE/AMEX) → LS 2자리 코드."""
ex = (exchange or "").strip().upper()
if ex.isdigit() and len(ex) == 2:
return ex
return EXCHANGE_TO_LS.get(ex, default)
def overseas_tr_key(exchcd: str, symbol: str, width: int = 18) -> str:
"""GSC tr_key = 거래소코드+심볼, 오른쪽 공백 패딩 **18자리**.
LS API 가이드([해외주식] 실시간 시세 GSC):
Length=18, 예) ``'82TSLA' + 공백 12자리`` / Request ``\"81SOXL \"``
"""
raw = f"{exchcd}{symbol}"
if len(raw) >= width:
return raw
return raw.ljust(width)
def domestic_unified_tr_key(shcode: str, width: int = 10) -> str:
"""US3/UH1 tr_key = ``U`` + 6자리단축코드 + 공백3 = 총 10자리.
공식 예: ``\"U005930 \"``
"""
code = (shcode or "").strip()
if len(code) == 6 and code.isdigit():
raw = f"U{code}"
else:
raw = code if code.startswith("U") else f"U{code}"
if len(raw) >= width:
return raw
return raw.ljust(width)
def print_field_catalog(title: str, labels: dict[str, str]) -> None:
print(f"\n📋 {title}")
for k, ko in labels.items():
print(f" {k:12s} {ko}")
def dump_body_fields(
body: dict[str, Any],
labels: dict[str, str],
*,
title: str = "",
indent: str = " ",
) -> None:
"""수신 dict 를 한글 라벨 + 값으로 출력 (스펙에 없는 키도 전부)."""
if title:
print(title)
if not isinstance(body, dict) or not body:
print(f"{indent}(empty body)")
return
shown: set[str] = set()
for key, ko in labels.items():
if key in body:
print(f"{indent}{ko} ({key}): {body.get(key)!r}")
shown.add(key)
extras = [k for k in body.keys() if k not in shown]
for key in extras:
print(f"{indent}{key}: {body.get(key)!r}")
def _ls_rest_headers(
token: str,
app_key: str,
app_secret: str,
tr_cd: str,
) -> dict[str, str]:
"""LS REST 공통 헤더. tr_cont 누락 시 IGW40010."""
return {
"Content-Type": "application/json; charset=UTF-8",
"authorization": f"Bearer {token}",
"appkey": app_key,
"appsecretkey": app_secret,
"tr_cd": tr_cd,
"tr_cont": "N",
"tr_cont_key": "",
}
def fetch_overseas_quote_g3101(
token: str,
app_key: str,
app_secret: str,
*,
exchcd: str,
symbol: str,
delaygb: str = "R",
timeout: float = 15.0,
) -> dict[str, Any]:
"""해외주식 REST 현재가 (g3101). delaygb: R=실시간, D=지연."""
url = f"{LS_REST_BASE}/overseas-stock/market-data"
keysymbol = f"{exchcd}{symbol}"
headers = _ls_rest_headers(token, app_key, app_secret, "g3101")
payload = {
"g3101InBlock": {
"delaygb": delaygb,
"keysymbol": keysymbol,
"exchcd": exchcd,
"symbol": symbol,
}
}
resp = requests.post(url, headers=headers, json=payload, timeout=timeout)
try:
data = resp.json()
except Exception:
return {"_http": resp.status_code, "_raw": resp.text[:500]}
data["_http"] = resp.status_code
return data
def fetch_kr_quote_t1101(
token: str,
app_key: str,
app_secret: str,
*,
shcode: str = "005930",
timeout: float = 15.0,
) -> dict[str, Any]:
"""국내 주식현재가 (t1101) — 해외 API 빈응답일 때 필드 덤프 검증용."""
url = f"{LS_REST_BASE}/stock/market-data"
headers = _ls_rest_headers(token, app_key, app_secret, "t1101")
payload = {"t1101InBlock": {"shcode": shcode}}
resp = requests.post(url, headers=headers, json=payload, timeout=timeout)
try:
data = resp.json()
except Exception:
return {"_http": resp.status_code, "_raw": resp.text[:500]}
data["_http"] = resp.status_code
return data
# t1101 주요 필드 (호가 10단 전부는 생략, 핵심만 라벨)
T1101_FIELD_KO: dict[str, str] = {
"hname": "종목명",
"price": "현재가",
"sign": "등락부호",
"change": "전일대비",
"diff": "등락률(%)",
"volume": "누적거래량",
"jnilclose": "전일종가",
"offerho1": "매도1호가",
"bidho1": "매수1호가",
"offerrem1": "매도1잔량",
"bidrem1": "매수1잔량",
"offer": "매도총잔량",
"bid": "매수총잔량",
"uplmtprice": "상한가",
"dnlmtprice": "하한가",
"open": "시가",
"high": "고가",
"low": "저가",
"ho_yn": "관리종목",
"shcode": "종목코드",
"exchid": "거래소",
}
# H1_/UH1 호가 잔량 (1호가만 라벨, 나머지는 키 그대로 덤프)
HOGA_FIELD_KO: dict[str, str] = {
"shcode": "종목코드",
"hotime": "호가시간",
"offerho1": "매도1호가",
"bidho1": "매수1호가",
"offerrem1": "매도1잔량",
"bidrem1": "매수1잔량",
"totofferrem": "총매도잔량",
"totbidrem": "총매수잔량",
"volume": "누적거래량",
"donsigubun": "동시호가구분",
"midprice": "중간가격",
}
def build_subscriptions(
items: list[dict[str, str]],
*,
kr_tr: str,
also_hoga: bool = False,
) -> list[tuple[str, str, str]]:
"""(tr_cd, tr_key, label) 목록.
국내 통합(US3/UH1): tr_key = ``U005930 `` (U+6자리+공백3)
국내 단독(S3_/H1_/K3_/HA_): tr_key = ``005930`` (6자리)
"""
subs: list[tuple[str, str, str]] = []
seen: set[tuple[str, str]] = set()
def add(tr_cd: str, tr_key: str, label: str) -> None:
key = (tr_cd, tr_key)
if key in seen:
return
seen.add(key)
subs.append((tr_cd, tr_key, label))
mode = (kr_tr or "unified").strip().lower()
for it in items:
code = it["code"]
mt = it["market_type"]
if mt == "US":
sym = it.get("symbol") or code
exchcd = ls_exchcd(it.get("exchange") or "NASD")
tr_key = overseas_tr_key(exchcd, sym)
add(US_TR_TRADE, tr_key, f"US:{sym} exch={exchcd}")
continue
# KR
if mode == "dual":
add(KR_TR_KOSPI, code, f"KR:{code}:S3_")
add(KR_TR_KOSDAQ, code, f"KR:{code}:K3_")
if also_hoga:
add(KR_TR_KOSPI_HOGA, code, f"KR:{code}:H1_")
add(KR_TR_KOSDAQ_HOGA, code, f"KR:{code}:HA_")
elif mode == "kospi":
add(KR_TR_KOSPI, code, f"KR:{code}:S3_")
if also_hoga:
add(KR_TR_KOSPI_HOGA, code, f"KR:{code}:H1_")
elif mode == "kosdaq":
add(KR_TR_KOSDAQ, code, f"KR:{code}:K3_")
if also_hoga:
add(KR_TR_KOSDAQ_HOGA, code, f"KR:{code}:HA_")
else:
ukey = domestic_unified_tr_key(code)
add(KR_TR_UNIFIED, ukey, f"KR:{code}:US3")
if also_hoga:
add(KR_TR_UNIFIED_HOGA, ukey, f"KR:{code}:UH1")
return subs
def _is_tick_body(body: Any) -> bool:
if not isinstance(body, dict) or not body:
return False
tick_keys = (
"price",
"chetime",
"drate",
"cvolume",
"trdq",
"totq",
"rate",
"kortm",
"trdtm",
"shcode",
"symbol",
"offerho1",
"bidho1",
"offerrem1",
"bidrem1",
)
return any(k in body for k in tick_keys)
class LsWsProbe:
"""websocket-client 기반 짧은 구독 프로브."""
def __init__(
self,
ws_url: str,
token: str,
logger: logging.Logger,
*,
verbose: bool = False,
max_full_dumps: int = 3,
) -> None:
try:
import websocket # websocket-client
except ImportError as e:
raise RuntimeError(
"websocket-client 필요: pip install websocket-client"
) from e
self._websocket = websocket
self.ws_url = ws_url
self.token = token
self.logger = logger
self.verbose = verbose
self.max_full_dumps = max_full_dumps
self._ws: Any = None
self._thread: threading.Thread | None = None
self.opened = threading.Event()
self._stop = threading.Event()
self.msg_count = 0
self.tick_count = 0
self.reg_acks = 0
self.errors: list[str] = []
self.by_label: dict[str, int] = {}
self._label_by_key: dict[tuple[str, str], str] = {}
self._label_by_key_stripped: dict[tuple[str, str], str] = {}
self._last_tick: dict[str, Any] = {}
self._full_dumps_left = max_full_dumps
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-ws-probe", 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 register(self, tr_cd: str, tr_key: str, label: str) -> None:
self._label_by_key[(tr_cd, tr_key)] = label
self._label_by_key_stripped[(tr_cd, tr_key.strip())] = label
payload = {
"header": {"token": self.token, "tr_type": "3"},
"body": {"tr_cd": tr_cd, "tr_key": tr_key},
}
if self._ws is None:
raise RuntimeError("WS not started")
self._ws.send(json.dumps(payload, ensure_ascii=False))
self.logger.info(
"REG sent tr_cd=%s tr_key=%r (%s)", tr_cd, tr_key, label
)
def stop(self) -> None:
self._stop.set()
if self._ws is not None:
try:
self._ws.close()
except Exception:
pass
def _resolve_label(self, tr_cd: str, tr_key: str) -> str:
if (tr_cd, tr_key) in self._label_by_key:
return self._label_by_key[(tr_cd, tr_key)]
stripped = tr_key.strip()
if (tr_cd, stripped) in self._label_by_key_stripped:
return self._label_by_key_stripped[(tr_cd, stripped)]
# symbol only match (GSC body.symbol)
for (tcd, tk), lab in self._label_by_key.items():
if tcd == tr_cd and (stripped in tk or tk.strip().endswith(stripped)):
return lab
return f"{tr_cd}:{tr_key!r}"
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 msg: %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 body.get("shcode")
or body.get("symbol")
or ""
)
rsp_cd = str(header.get("rsp_cd") or "")
rsp_msg = str(header.get("rsp_msg") or "")
if not _is_tick_body(body):
if rsp_msg or rsp_cd:
self.reg_acks += 1
self.logger.info(
"REG ack tr_cd=%s tr_key=%r rsp=%s %s",
tr_cd,
tr_key,
rsp_cd,
rsp_msg,
)
return
label = self._resolve_label(tr_cd, tr_key)
with self._lock:
self.tick_count += 1
self.by_label[label] = self.by_label.get(label, 0) + 1
self._last_tick[label] = dict(body) if isinstance(body, dict) else body
do_full = self._full_dumps_left > 0
if do_full:
self._full_dumps_left -= 1
labels = GSC_FIELD_KO if tr_cd == "GSC" else KR_TICK_FIELD_KO
if tr_cd in ("UH1", "H1_", "HA_", "NH1", "B7_"):
labels = HOGA_FIELD_KO
px = body.get("price", body.get("offerho1", "?"))
rate = body.get("rate", body.get("drate", "?"))
tmk = (
body.get("kortm")
or body.get("trdtm")
or body.get("chetime")
or body.get("hotime")
or ""
)
print(
f"\n📈 TICK #{self.tick_count} {label} "
f"price/ask={px} rate={rate} time={tmk} tr={tr_cd}"
)
if do_full or self.verbose:
dump_body_fields(body, labels, title=" [전문 body 전체]")
raw = json.dumps(body, ensure_ascii=False)
print(f" [raw JSON] {raw[:900]}{'' if len(raw) > 900 else ''}")
else:
# 요약만 (이미 전체 dump 한도 초과)
for k in (
"symbol",
"shcode",
"price",
"diff",
"rate",
"drate",
"trdq",
"cvolume",
"totq",
"volume",
"offerho1",
"bidho1",
"offerrem1",
"bidrem1",
):
if k in body:
ko = labels.get(k, k)
print(f" {ko} ({k}): {body[k]!r}")
def main() -> int:
p = argparse.ArgumentParser(description="LS증권 WebSocket 실시간 구독 단독 검증")
p.add_argument("--codes", default="", help="콤마 구분 종목. 예: SPCX,TSLA,QQQM")
p.add_argument(
"--market",
default="KR",
choices=["KR", "US", "all", "ALL"],
help="영구구독 market_type (기본 KR). --codes 있으면 무시",
)
p.add_argument(
"--kr-tr",
default="unified",
choices=["unified", "dual", "kospi", "kosdaq"],
help="국내 TR (기본 US3 통합). dual=S3_+K3_",
)
p.add_argument(
"--also-hoga",
action="store_true",
help="국내 호가잔량도 구독 (unified→UH1, kospi→H1_, kosdaq→HA_)",
)
p.add_argument("--duration", type=int, default=30, help="구독 유지 초")
p.add_argument("--mock", action="store_true", help="모의 키 + WS 29443")
p.add_argument(
"--no-rest-quote",
action="store_true",
help="해외 g3101 / 국내 t1101 REST 현재가 조회 생략",
)
p.add_argument(
"--delaygb",
default="R",
help="g3101 delaygb (R=실시간 기본, D=지연)",
)
p.add_argument(
"--dump-n",
type=int,
default=5,
help="WS tick 전문 전체 덤프 횟수 (기본 5)",
)
p.add_argument("-v", "--verbose", action="store_true")
p.add_argument("--log", default="")
args = p.parse_args()
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
log_path = Path(args.log) if args.log else (SCRIPT_DIR / "logs" / f"ls_ws_{ts}.log")
logger = setup_logging(args.verbose, log_path)
print("=" * 70)
print("🔍 LS증권 WS 실시간 구독 검증 (필드 덤프)")
print("=" * 70)
use_mock = bool(args.mock)
try:
app_key, app_secret = load_ls_creds(use_mock=use_mock)
except Exception as e:
print(f"❌ LS 키 로드 실패: {e}")
return 1
if not app_key or not app_secret:
cols = (
"LS_APP_KEY_MOCK / LS_APP_SECRET_MOCK"
if use_mock
else "LS_APP_KEY_REAL / LS_APP_SECRET_REAL"
)
print(f"❌ LS 키 비어있음 (env_config 의 {cols})")
return 1
ws_url = LS_WS_MOCK if use_mock else LS_WS_REAL
print(f" 모드: {'모의' if use_mock else '실전'}")
print(f" AppKey: {app_key[:8]}{app_key[-4:]}")
print(f" WS: {ws_url}")
print(f" 로그: {log_path}")
if args.codes.strip():
items: list[dict[str, str]] = []
for raw in args.codes.split(","):
c = raw.strip()
if not c:
continue
mt = "KR" if (c.isdigit() and len(c) == 6) else "US"
# 미국 티커 기본 NASDAQ(82). AMEX 등은 --codes 에 EX:SYM 형태로 가능
exchange = "NASD"
symbol = c
if mt == "US" and ":" in c:
exchange, symbol = c.split(":", 1)
exchange, symbol = exchange.strip().upper(), symbol.strip().upper()
c = symbol
items.append(
{
"code": c,
"market_type": mt,
"exchange": exchange if mt == "US" else "",
"symbol": symbol if mt == "US" else c,
}
)
print(f" 종목: --codes ({len(items)}개)")
else:
mkt = "ALL" if str(args.market).upper() == "ALL" else str(args.market).upper()
try:
items = load_permanent_codes(mkt)
except Exception as e:
print(f"❌ permanent_subscriptions 로드 실패: {e}")
return 1
print(f" 종목: permanent_subscriptions enabled ({mkt}) {len(items)}")
if not items:
print("❌ 구독 대상 종목 없음")
return 2
for it in items:
print(
f" - {it['market_type']} {it['code']}"
+ (f" ({it['exchange']})" if it.get("exchange") else "")
)
us_items = [x for x in items if x["market_type"] == "US"]
if us_items:
print_field_catalog("GSC(해외 실시간 체결) 수신 필드", GSC_FIELD_KO)
print_field_catalog("g3101(REST 현재가) 수신 필드", G3101_FIELD_KO)
if any(x["market_type"] == "KR" for x in items):
print_field_catalog("US3/S3_(국내 체결) 주요 수신 필드", KR_TICK_FIELD_KO)
if args.also_hoga:
print_field_catalog("UH1/H1_(호가잔량) 주요 수신 필드", HOGA_FIELD_KO)
print(
" ※ LS [주식] 실시간 시세 TR 약 65종 "
"(체결/호가/거래원/프로그램/VI/NXT·통합…)\n"
" → 키움·한투에서 REST로 떼오던 호가·거래원 등을 WS로 바로 구독 가능"
)
subs = build_subscriptions(
items, kr_tr=args.kr_tr, also_hoga=bool(args.also_hoga)
)
print(f"\n 구독 슬롯: {len(subs)} (kr-tr={args.kr_tr})")
for tr_cd, tr_key, label in subs:
print(f" · {label} tr_cd={tr_cd} tr_key={tr_key!r}")
print(f" 유지: {args.duration}")
print()
try:
token = fetch_access_token(app_key, app_secret)
except Exception as e:
print(f"❌ TOKEN 실패: {e}")
logger.error("TOKEN fail: %s", e)
return 1
print(f"✅ TOKEN OK ({token[:12]}…)")
logger.info("TOKEN OK len=%d", len(token))
# ── REST 현재가 (해외) — WS tick 없어도 값 확인 ─────────────────────
us_quote_ok = 0
if us_items and not args.no_rest_quote:
print("\n" + "" * 70)
print(f"💵 REST g3101 현재가 (delaygb={args.delaygb})")
print("" * 70)
for it in us_items:
sym = it.get("symbol") or it["code"]
exchcd = ls_exchcd(it.get("exchange") or "NASD")
print(f"\n── {sym} exchcd={exchcd} keysymbol={exchcd}{sym}")
try:
data = fetch_overseas_quote_g3101(
token,
app_key,
app_secret,
exchcd=exchcd,
symbol=sym,
delaygb=str(args.delaygb),
)
except Exception as e:
print(f" ❌ REST 실패: {e}")
continue
rsp_cd = str(data.get("rsp_cd") or "")
rsp_msg = str(data.get("rsp_msg") or "")
print(f" http={data.get('_http')} rsp={rsp_cd!r} msg={rsp_msg!r}")
out = data.get("g3101OutBlock")
if isinstance(out, dict) and out:
us_quote_ok += 1
dump_body_fields(out, G3101_FIELD_KO, title=" [g3101OutBlock — 실제 수신값]")
print(f" [raw] {json.dumps(out, ensure_ascii=False)}")
else:
print(" ⚠️ g3101OutBlock 없음 (해외주식 API 미신청/권한 또는 종목키 문제)")
print(f" [raw] {json.dumps(data, ensure_ascii=False)[:600]}")
time.sleep(0.15) # TPS 여유
if us_quote_ok == 0:
print("\n" + "" * 70)
print(
" 해외 g3101 이 비어 있음.\n"
" LS 홈페이지에서 「해외주식 API」 사용신청이 별도인지 확인.\n"
" (국내 주식 API만 된 키면 g3101/GSC 값이 안 옴)\n"
" 아래는 같은 키로 국내 t1101 을 조회한 **실제 필드 덤프 샘플**."
)
print("" * 70)
try:
kr = fetch_kr_quote_t1101(
token, app_key, app_secret, shcode="005930"
)
out = kr.get("t1101OutBlock") or {}
print(
f"\n── 국내 샘플 005930 삼성전자 "
f"http={kr.get('_http')} rsp={kr.get('rsp_cd')!r}"
)
if isinstance(out, dict) and out:
# 핵심 필드만 + 일부 호가
dump_body_fields(out, T1101_FIELD_KO, title=" [t1101OutBlock 핵심]")
# 너무 긴 호가10단은 raw 앞부분만
raw = json.dumps(out, ensure_ascii=False)
print(f" [raw 앞 700자] {raw[:700]}")
else:
print(f" {json.dumps(kr, ensure_ascii=False)[:500]}")
except Exception as e:
print(f" 국내 샘플 실패: {e}")
# ── REST 현재가 (국내 t1101) — 장외에도 스냅샷 값 확인 ───────────────
kr_items = [x for x in items if x["market_type"] == "KR"]
if kr_items and not args.no_rest_quote:
print("\n" + "" * 70)
print("💵 REST t1101 국내 현재가/호가 (영구구독 KR)")
print("" * 70)
for it in kr_items:
code = it["code"]
print(f"\n── {code}")
try:
data = fetch_kr_quote_t1101(
token, app_key, app_secret, shcode=code
)
except Exception as e:
print(f" ❌ REST 실패: {e}")
continue
out = data.get("t1101OutBlock") or {}
print(
f" http={data.get('_http')} "
f"rsp={data.get('rsp_cd')!r} msg={data.get('rsp_msg')!r}"
)
if isinstance(out, dict) and out:
dump_body_fields(out, T1101_FIELD_KO, title=" [t1101OutBlock 핵심]")
else:
print(f" [raw] {json.dumps(data, ensure_ascii=False)[:500]}")
time.sleep(0.12)
# ── WS ─────────────────────────────────────────────────────────────
probe = LsWsProbe(
ws_url,
token,
logger,
verbose=args.verbose,
max_full_dumps=max(1, int(args.dump_n)),
)
probe.start()
if not probe.wait_open(timeout=15.0):
print("❌ WS OPEN timeout (15s)")
probe.stop()
return 1
print("\n✅ WS OPEN")
for tr_cd, tr_key, label in subs:
try:
probe.register(tr_cd, tr_key, label)
time.sleep(0.05)
except Exception as e:
print(f"❌ REG 실패 {label}: {e}")
logger.error("REG fail %s: %s", label, e)
print()
print(f"📡 {args.duration}초간 WS 수신 (전문 필드 덤프 최대 {args.dump_n}건)...")
end_ts = time.time() + max(1, int(args.duration))
last_summary = 0.0
while time.time() < end_ts:
now = time.time()
if now - last_summary >= 5.0:
last_summary = now
print(
f" … msg={probe.msg_count} tick={probe.tick_count} "
f"ack={probe.reg_acks} err={len(probe.errors)}"
)
time.sleep(0.2)
probe.stop()
time.sleep(0.3)
print()
print("" * 70)
print("종료 통계")
print(f" 메시지 총합: {probe.msg_count}")
print(f" REG ack: {probe.reg_acks}")
print(f" TICK: {probe.tick_count}")
print(f" WS 에러: {len(probe.errors)}")
if probe._last_tick:
print("\n 마지막 tick 스냅샷 (종목별):")
for label, body in sorted(probe._last_tick.items()):
labels = GSC_FIELD_KO if "US:" in label else KR_TICK_FIELD_KO
print(f"\n{label}")
if isinstance(body, dict):
dump_body_fields(body, labels)
else:
print(f" {body!r}")
elif us_items:
print(
" ⚠️ WS tick 0 — 해외 실시간(GSC) 미수신.\n"
" 위 REST g3101 값이 있으면 조회 API는 정상.\n"
" tick 없으면 HTS [2007] 해외주식 실시간 시세신청 필요할 수 있음."
)
else:
print(" ⚠️ tick 0 — 장외이거나 권한/TR 확인")
print(f"\n 로그: {log_path}")
print("" * 70)
if probe.errors and probe.msg_count == 0:
return 1
return 0
if __name__ == "__main__":
sys.exit(main())