ls kiwoom 구독 히스토리 모두 적재
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user