ls kiwoom 구독 히스토리 모두 적재

This commit is contained in:
Your Name
2026-07-30 20:23:37 +09:00
parent 67eab24603
commit 7050f788c5
18 changed files with 1379 additions and 120 deletions

View File

@@ -86,26 +86,20 @@ def overseas_tr_key(exchcd: str, symbol: str, width: int = 18) -> str:
return raw if len(raw) >= width else raw.ljust(width)
def fetch_ls_access_token(app_key: str, app_secret: str, timeout: float = 15.0) -> str:
url = f"{LS_REST_BASE}/oauth2/token"
resp = requests.post(
url,
headers={"Content-Type": "application/x-www-form-urlencoded"},
data={
"grant_type": "client_credentials",
"appkey": app_key,
"appsecretkey": app_secret,
"scope": "oob",
},
timeout=timeout,
def fetch_ls_access_token(
app_key: str,
app_secret: str,
timeout: float = 15.0,
*,
force: bool = False,
reason: str = "",
) -> str:
"""``POST /oauth2/token`` — expires_in 기반 공유 캐시 (한도 준수 재사용)."""
from kis_trader.network.ls_token import fetch_ls_access_token as _shared
return _shared(
app_key, app_secret, timeout=timeout, force=force, reason=reason or "ls_ws",
)
if resp.status_code >= 400:
raise RuntimeError(f"LS token HTTP {resp.status_code}: {resp.text[:300]}")
body = resp.json()
token = body.get("access_token") or body.get("accesstoken")
if not token:
raise RuntimeError(f"LS token empty: {body}")
return str(token)
class LSWebSocketPriceCache:
@@ -240,7 +234,9 @@ class LSWebSocketPriceCache:
if self._thread and self._thread.is_alive():
return True
try:
self._token = fetch_ls_access_token(self.app_key, self.app_secret)
self._token = fetch_ls_access_token(
self.app_key, self.app_secret, reason="ls_ws_start",
)
self._token_at = time.time()
except Exception as e:
logger.error("LS 토큰 발급 실패: %s", e)
@@ -276,8 +272,100 @@ class LSWebSocketPriceCache:
)
return True
def _graceful_unreg_all(self) -> int:
"""종료 직전 서버 구독 UNREG — 재시작 시 세션 꼬임/한도 거부 완화.
REG 워커 큐가 아니라 동기 전송(갭 준수). 타임아웃 초과 시 남은 종목은
TCP close 에 맡긴다 (systemd stop 지연·폭주 방지).
"""
if self._ws is None or not self._opened.is_set():
return 0
with self._sub_lock:
kr = list(self._subscribed)
us = list(self._us_subscribed)
if not kr and not us:
# JIF 만 구독 중일 수 있음
pass
timeout = max(
1.0,
float(get_env_float("LS_WS_STOP_UNREG_TIMEOUT_SEC", 8.0) or 8.0),
)
gap_ms = max(
10,
int(
get_env_int(
"LS_WS_STOP_UNREG_GAP_MS",
int(get_env_int("LS_WS_REG_GAP_MS", 80) or 80),
)
or 30
),
)
deadline = time.monotonic() + timeout
n_ok = 0
timed_out = False
def _one(tr_type: str, tr_cd: str, tr_key: str) -> bool:
nonlocal n_ok, timed_out
if time.monotonic() >= deadline:
timed_out = True
return False
if not self._opened.is_set() or self._ws is None:
return False
self._send_typed(tr_type, tr_cd, tr_key)
n_ok += 1
time.sleep(gap_ms / 1000.0)
return True
# 장운영 JIF 해지
if get_env_bool("LS_WS_JIF_ENABLED", True):
jif_key = (get_env_from_db("LS_WS_JIF_TR_KEY", "0") or "0").strip() or "0"
_one("4", "JIF", jif_key)
for code in kr:
if timed_out:
break
tr_cd, tr_key, hoga_cd, hoga_key = self._kr_tr_pair(code)
if not _one("4", tr_cd, tr_key):
break
if self.also_hoga and not _one("4", hoga_cd, hoga_key):
break
if get_env_bool("LS_WS_UVI_ENABLED", True):
vi_cd, vi_key = self._vi_tr_pair(code)
if not _one("4", vi_cd, vi_key):
break
for sym in us:
if timed_out:
break
if not _one("4", "GSC", overseas_tr_key("82", sym)):
break
with self._sub_lock:
self._subscribed.clear()
self._sub_owners.clear()
self._us_subscribed.clear()
self._us_sub_owners.clear()
if timed_out:
logger.warning(
"⚠️ LS WS 종료 UNREG 타임아웃 %.1fs — sends=%d KR=%d US=%d "
"(잔여 서버구독은 close 에 위임)",
timeout, n_ok, len(kr), len(us),
)
elif n_ok > 0:
logger.info(
"✅ LS WS 종료 전 UNREG 완료 sends=%d KR=%d US=%d gap=%dms",
n_ok, len(kr), len(us), gap_ms,
)
return n_ok
def stop(self) -> None:
"""구독 UNREG → 소켓 close — 봇 재시작 시 서버 세션 잔존 완화."""
global _active_ls_ws
try:
self._graceful_unreg_all()
except Exception as e:
logger.warning("LS WS 종료 전 UNREG 실패: %s", e)
self._running = False
self._set_recovering(False)
try:
@@ -289,9 +377,16 @@ class LSWebSocketPriceCache:
self._ws.close()
except Exception:
pass
for th in (self._reg_thread, self._watch_thread, self._thread):
if th is not None and th.is_alive():
try:
th.join(timeout=2.0)
except Exception:
pass
with _active_lock:
if _active_ls_ws is self:
_active_ls_ws = None
logger.info("⏹ LS WS 종료")
def is_connected(self) -> bool:
return self._opened.is_set() and self._running
@@ -692,10 +787,10 @@ class LSWebSocketPriceCache:
# ── internals ─────────────────────────────────────────────────────
def _ensure_token(self) -> str:
# LS 토큰: 신청일~익일 07시. 12h 지나면 재발급.
if self._token and (time.time() - self._token_at) < 12 * 3600:
return self._token
self._token = fetch_ls_access_token(self.app_key, self.app_secret)
# 스펙 expires_in 공유 캐시 — 하드코딩 12h/익일07시 추측 금지
self._token = fetch_ls_access_token(
self.app_key, self.app_secret, reason="ls_ws_ensure",
)
self._token_at = time.time()
return self._token