fix(시세): LS spill-only·토큰 파일캐시·호가 snap_time 3초컷

- _feed_fallback 미러 OFF, LS cap/grace/hold RAM을 KIS·키움 spill과 정합
- LS 접근토큰 .ls_token_cache_*.json (재시작 재사용, revoke 루프 없음)
- 호가 RAM을 틱과 동일 LIVE_FEED_FALLBACK(snap_time)로 컷, 필터 max_age=0은 유지
- 익절 지정가 로그에 실제 호가 벤더(kis/kiwoom/ls 1·2·3차) 표기

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Your Name
2026-08-27 15:21:21 +09:00
parent ba2f9f0953
commit 5e44b86f8b
24 changed files with 1923 additions and 203 deletions

View File

@@ -39,6 +39,8 @@ import requests
logger = logging.getLogger("LSWebSocket")
from .ws_reconnect_backoff import ws_reconnect_delay_for_attempt
try:
from kis_trader.utils.env import get_env_bool, get_env_float, get_env_from_db, get_env_int
except ImportError:
@@ -153,6 +155,8 @@ class LSWebSocketPriceCache:
self._sub_lock = threading.Lock()
self._send_lock = threading.Lock() # SSL write 직렬화 (BAD_LENGTH 레이스 방지)
self._force_closing = False # UNREG→close 재진입 방지
self._hold_ram_extra_codes_fn: Optional[Any] = None # WSManager → 보유 pin
self._ws_manager_bound: bool = False
self._subscribed: Set[str] = set() # 6자리 KR 코드 (소켓 실구독)
# code → {"permanent","condition","default",...} — 해제는 owner 전부일 때만
self._sub_owners: Dict[str, Set[str]] = {}
@@ -168,6 +172,13 @@ class LSWebSocketPriceCache:
self._last_tick_mono: float = 0.0
self._opened_mono: float = 0.0
self._watchdog_empty_streak: int = 0
self._ws_reconnect_step: int = 0
# Bye/조기 CLOSE 서킷 — OPEN 직후 끊김 시 백오프 리셋 금지·토큰 1회 갱신
self._early_close_streak: int = 0
self._last_close_lived_sec: float = 0.0
self._last_close_early: bool = False
self._pending_bye: bool = False
self._stable_open: bool = False
# JIF 장운영정보 (앱 하트비트 아님 — 세션 판정용)
self._jif_lock = threading.Lock()
@@ -319,12 +330,18 @@ class LSWebSocketPriceCache:
)
return True
def _graceful_unreg_all(self, *, clear_ram: bool = True) -> int:
def _graceful_unreg_all(
self,
*,
clear_ram: bool = True,
ram_keep_codes: Optional[Set[str]] = None,
) -> int:
"""종료 직전 서버 구독 UNREG — 재시작 시 세션 꼬임/한도 거부 완화.
REG 워커 큐가 아니라 동기 전송(갭 준수). 타임아웃 초과 시 남은 종목은
TCP close 에 맡긴다 (systemd stop 지연·폭주 방지).
``clear_ram=False``: 장 양보 시 서버만 해지, RAM 은 재접속 REG 용으로 유지.
``clear_ram=True`` + ``ram_keep_codes``: 서버 UNREG 후 RAM 은 keep 만 유지
(hold reopen 시 07:00 mass REG 방지 — permanent·보유 pin).
"""
if self._ws is None or not self._opened.is_set():
return 0
@@ -389,11 +406,26 @@ class LSWebSocketPriceCache:
break
if clear_ram:
keep = {
str(c).strip()
for c in (ram_keep_codes or set())
if str(c).strip()
}
with self._sub_lock:
self._subscribed.clear()
self._sub_owners.clear()
self._us_subscribed.clear()
self._us_sub_owners.clear()
if keep:
for c in list(self._subscribed):
if c not in keep:
self._subscribed.discard(c)
self._sub_owners.pop(c, None)
for c in list(self._us_subscribed):
if c not in keep:
self._us_subscribed.discard(c)
self._us_sub_owners.pop(c, None)
else:
self._subscribed.clear()
self._sub_owners.clear()
self._us_subscribed.clear()
self._us_sub_owners.clear()
if timed_out:
logger.warning(
@@ -460,7 +492,7 @@ class LSWebSocketPriceCache:
# spill 즉시성: 미연결이면 대기 없이 False (일반 owner 는 기존처럼 집합 적재 후 REG 큐)
if own == "spill" and not self.is_connected():
return False
max_n = int(get_env_int("LS_WS_MAX_SUBSCRIPTIONS", 0) or 0)
max_n = int(get_env_int("LS_WS_MAX_SUBSCRIPTIONS", 100) or 0)
if code.isdigit() and len(code) == 6:
need_reg = False
with self._sub_lock:
@@ -873,6 +905,58 @@ class LSWebSocketPriceCache:
self._token_at = time.time()
return self._token
def _stable_open_sec(self) -> float:
return max(5.0, float(get_env_float("LS_WS_STABLE_OPEN_SEC", 30.0) or 30.0))
def _mark_stable_open(self, why: str) -> None:
"""REG 완료·장시간 OPEN 후에만 재연결 step/Bye streak 리셋."""
if self._stable_open and self._ws_reconnect_step == 0 and self._early_close_streak == 0:
return
self._stable_open = True
self._ws_reconnect_step = 0
self._early_close_streak = 0
self._pending_bye = False
logger.info(
"LS WS 안정 OPEN 확정 — reconnect/Bye streak 리셋 (%s)",
why,
)
def _refresh_token_after_bye(self) -> None:
"""조기 Bye 서킷: 로컬 캐시 무효화 후 1회 재발급 (min_gap 존중)."""
from kis_trader.network.ls_token import invalidate_ls_access_token
invalidate_ls_access_token(
self.app_key, self.app_secret, reason="ls_ws_early_bye",
)
self._token = ""
self._token = fetch_ls_access_token(
self.app_key,
self.app_secret,
force=True,
reason="ls_ws_early_bye",
)
self._token_at = time.time()
def _hold_ram_keep_codes(self) -> Set[str]:
"""hold 양보 후 RAM 유지 집합 — _permanent owner + (옵션) 보유."""
keep: Set[str] = set()
with self._sub_lock:
for code, ow in self._sub_owners.items():
if "_permanent" in ow:
keep.add(code)
for code, ow in self._us_sub_owners.items():
if "_permanent" in ow:
keep.add(code)
if get_env_bool("LS_WS_HOLD_RAM_KEEP_HOLDINGS", True):
fn = getattr(self, "_hold_ram_extra_codes_fn", None)
if callable(fn):
try:
extra = fn()
keep |= {str(c).strip() for c in (extra or set()) if str(c).strip()}
except Exception:
pass
return keep
def _force_close_socket(self, reason: str) -> None:
"""hold 이탈·갭 — 구독 UNREG 후 소켓 양보. 토큰 revoke/재발급 없음."""
ws = self._ws
@@ -883,7 +967,8 @@ class LSWebSocketPriceCache:
self._force_closing = True
try:
try:
self._graceful_unreg_all(clear_ram=False)
keep = self._hold_ram_keep_codes()
self._graceful_unreg_all(clear_ram=True, ram_keep_codes=keep)
except Exception as e:
logger.warning("LS WS 세션 양보 전 UNREG 실패: %s", e)
try:
@@ -1150,6 +1235,8 @@ class LSWebSocketPriceCache:
except Exception as e:
logger.debug("LS 호가 파싱 실패 %s: %s", code, e)
return
if snap is None:
return
if not self._orderbook_recorder:
return
if not get_env_bool("LS_WS_ORDERBOOK_SAVE", True):
@@ -1299,44 +1386,72 @@ class LSWebSocketPriceCache:
kr = list(self._subscribed)
us = list(self._us_subscribed)
n_ok = 0
# 관측 전용(동작 동일): 루프 break 원인·기대 send 수. 복구/재REG 로직 변경 없음.
abort_why = ""
also_hoga = bool(self.also_hoga)
uvi_on = get_env_bool("LS_WS_UVI_ENABLED", True)
jif_on = get_env_bool("LS_WS_JIF_ENABLED", True)
per_kr = 1 + (1 if also_hoga else 0) + (1 if uvi_on else 0)
expected = (1 if jif_on else 0) + len(kr) * per_kr + len(us)
# JIF 장운영 — 스펙 예: tr_key=0 (앱 하트비트 아님)
if get_env_bool("LS_WS_JIF_ENABLED", True):
if jif_on:
jif_key = (get_env_from_db("LS_WS_JIF_TR_KEY", "0") or "0").strip() or "0"
self._send_typed("3", "JIF", jif_key)
n_ok += 1
time.sleep(gap_ms / 1000.0)
# replay 는 직접 전송 (큐 재투입 폭주 방지)
for code in kr:
if not self._running or not self._opened.is_set():
if not self._running:
abort_why = "not_running"
break
if not self._opened.is_set():
abort_why = "opened_cleared"
break
tr_cd, tr_key, hoga_cd, hoga_key = self._kr_tr_pair(code)
self._send_typed("3", tr_cd, tr_key)
n_ok += 1
if self.also_hoga:
if also_hoga:
self._send_typed("3", hoga_cd, hoga_key)
n_ok += 1
if get_env_bool("LS_WS_UVI_ENABLED", True):
if uvi_on:
vi_cd, vi_key = self._vi_tr_pair(code)
self._send_typed("3", vi_cd, vi_key)
n_ok += 1
time.sleep(gap_ms / 1000.0)
for sym in us:
if not self._running or not self._opened.is_set():
if not self._running:
abort_why = abort_why or "not_running"
break
if not self._opened.is_set():
abort_why = abort_why or "opened_cleared"
break
self._send_typed("3", "GSC", overseas_tr_key("82", sym))
n_ok += 1
time.sleep(gap_ms / 1000.0)
ping_iv = int(get_env_int("LS_WS_PING_INTERVAL_SEC", 20) or 0)
reg_ok = (not abort_why) and self._opened.is_set() and n_ok >= expected
logger.info(
"LS WS OPEN — 구독 복구 KR=%d US=%d sends≈%d (delay=%dms gap=%dms ping=%s)",
"LS WS OPEN — 구독 복구 KR=%d US=%d sends≈%d expected≈%d "
"abort_why=%s opened_now=%s also_hoga=%s reg_ok=%s "
"(delay=%dms gap=%dms ping=%s)",
len(kr),
len(us),
n_ok,
expected,
abort_why or "-",
self._opened.is_set(),
also_hoga,
reg_ok,
delay_ms,
gap_ms,
"off" if ping_iv <= 0 else f"{ping_iv}s",
)
self._set_recovering(False)
# 미완료 REG 인데 recovering=False → 워치독/게이트가 정상으로 착각
if reg_ok:
self._mark_stable_open("REG replay complete")
self._set_recovering(False)
else:
self._set_recovering(True)
continue
# send
_, tr_type, tr_cd, tr_key = item
@@ -1404,7 +1519,8 @@ class LSWebSocketPriceCache:
pass
self._set_recovering(True)
try:
self._graceful_unreg_all(clear_ram=False)
keep = self._hold_ram_keep_codes()
self._graceful_unreg_all(clear_ram=True, ram_keep_codes=keep)
except Exception as e:
logger.debug("LS watchdog UNREG: %s", e)
try:
@@ -1414,7 +1530,6 @@ class LSWebSocketPriceCache:
logger.debug("LS watchdog close: %s", e)
def _run_forever(self) -> None:
backoff = 1.0
while self._running:
# ── hold 외 → 소켓 양보 후 다음 국내/해외 창까지 대기 (재발급 없음) ──
if not self._should_hold_socket():
@@ -1436,7 +1551,7 @@ class LSWebSocketPriceCache:
)
self._opened.clear()
self._set_recovering(True)
backoff = 1.0
self._ws_reconnect_step = 0
self._watchdog_empty_streak = 0
for _ in range(int(wait_sec // 60)):
if not self._running:
@@ -1453,8 +1568,8 @@ class LSWebSocketPriceCache:
self._ensure_token()
except Exception as e:
logger.warning("LS 토큰 ensure 실패(캐시/한도): %s", e)
time.sleep(min(backoff, 60.0))
backoff = min(backoff * 2, 60.0)
self._ws_reconnect_step += 1
time.sleep(ws_reconnect_delay_for_attempt(self._ws_reconnect_step))
continue
self._ws = self._websocket.WebSocketApp(
self.ws_url,
@@ -1474,6 +1589,11 @@ class LSWebSocketPriceCache:
self._ws.run_forever(**run_kw)
except Exception as e:
logger.warning("LS WS run 예외: %s", e)
# 관측: run_forever 종료 후 clear (동작 동일)
logger.warning(
"LS WS run_forever 종료 → opened clear (ws_id=%s)",
id(self._ws) if self._ws is not None else None,
)
self._opened.clear()
self._set_recovering(True)
if not self._running:
@@ -1481,35 +1601,188 @@ class LSWebSocketPriceCache:
# hold 이탈로 끊긴 경우 즉시 대기 분기로 (불필요 재발급·재연결 폭주 방지)
if not self._should_hold_socket():
continue
time.sleep(min(backoff, 60.0))
backoff = min(backoff * 2, 60.0)
# 조기 Bye/CLOSE 서킷 — OPEN마다 step=0 리셋하던 버그 보정 + 토큰 1회 갱신
early = bool(self._last_close_early or self._pending_bye)
if early:
self._early_close_streak += 1
elif self._last_close_lived_sec >= self._stable_open_sec():
self._early_close_streak = 0
streak_need = max(2, int(get_env_int("LS_WS_EARLY_BYE_STREAK", 5) or 5))
if early and self._early_close_streak >= streak_need:
circuit_sleep = max(
15.0,
float(get_env_float("LS_WS_BYE_CIRCUIT_SLEEP_SEC", 60.0) or 60.0),
)
logger.warning(
"LS WS Bye 서킷 — early_streak=%d lived=%.1fs bye=%s "
"→ 토큰 무효화·재발급 후 %.0fs 대기",
self._early_close_streak,
self._last_close_lived_sec,
self._pending_bye,
circuit_sleep,
)
try:
self._refresh_token_after_bye()
except Exception as e:
logger.warning("LS Bye 서킷 토큰 갱신 실패: %s", e)
self._early_close_streak = 0
self._pending_bye = False
self._ws_reconnect_step = max(self._ws_reconnect_step, 1)
time.sleep(circuit_sleep)
continue
self._ws_reconnect_step += 1
time.sleep(ws_reconnect_delay_for_attempt(self._ws_reconnect_step))
def _on_open(self, _ws: Any) -> None:
# 콜백에서 sleep/REG 연타 금지 → 워커에 replay 위임
# ⚠️ flapping OPEN 에서 reconnect_step=0 리셋 금지 (Bye 루프 1초 연타 원인)
self._opened_mono = time.monotonic()
self._last_tick_mono = time.monotonic()
self._stable_open = False
self._last_close_early = False
self._opened.set()
self._set_recovering(True)
# 관측 전용: 소켓 신분(레이스 가설). clear/REG 동작은 그대로.
logger.info(
"LS WS OPEN cb ws_id=%s active_id=%s same=%s reconnect_step=%d early_streak=%d",
id(_ws) if _ws is not None else None,
id(self._ws) if self._ws is not None else None,
_ws is self._ws,
self._ws_reconnect_step,
self._early_close_streak,
)
self._enqueue_replay()
def _on_close(self, _ws: Any, status: Any, msg: Any) -> None:
# 관측 전용: 옛 소켓 close 여부. 동작은 기존과 동일하게 무조건 clear.
same = _ws is self._ws
msg_s = msg
try:
if isinstance(msg, (bytes, bytearray)):
msg_s = bytes(msg).decode("utf-8", errors="replace")
except Exception:
msg_s = repr(msg)
lived = 0.0
if self._opened_mono > 0:
lived = max(0.0, time.monotonic() - self._opened_mono)
stable = self._stable_open_sec()
msg_blob = str(msg_s or "")
is_bye = ("Bye" in msg_blob) or self._pending_bye
# 강제 세션 양보(hold 외)는 early Bye 서킷에 넣지 않음
early = (lived < stable) and (not self._force_closing)
self._last_close_lived_sec = lived
self._last_close_early = bool(early)
if is_bye:
self._pending_bye = True
logger.warning(
"LS WS CLOSE status=%s msg=%s ws_same=%s "
"close_id=%s active_id=%s opened_before=%s "
"lived=%.1fs early=%s bye=%s stable_was=%s",
status,
msg_s,
same,
id(_ws) if _ws is not None else None,
id(self._ws) if self._ws is not None else None,
self._opened.is_set(),
lived,
early,
is_bye,
self._stable_open,
)
self._stable_open = False
self._opened.clear()
self._set_recovering(True)
logger.warning("LS WS CLOSE status=%s msg=%s", status, msg)
def _on_error(self, _ws: Any, err: Any) -> None:
logger.warning("LS WS ERROR %s", err)
err_s = ""
try:
if isinstance(err, (bytes, bytearray)):
err_s = bytes(err).decode("utf-8", errors="replace")
else:
err_s = str(err)
except Exception:
err_s = repr(err)
if "Bye" in err_s or "opcode=8" in err_s or "\\x03\\xe8" in err_s:
self._pending_bye = True
logger.warning(
"LS WS ERROR %s ws_same=%s err_id=%s active_id=%s bye=%s",
err,
_ws is self._ws,
id(_ws) if _ws is not None else None,
id(self._ws) if self._ws is not None else None,
self._pending_bye,
)
def _on_message(self, _ws: Any, message: Any) -> None:
try:
data = json.loads(message) if isinstance(message, str) else message
except Exception:
logger.warning(
"LS WS MSG non-json type=%s head=%r",
type(message).__name__,
(str(message)[:200] if message is not None else ""),
)
return
if not isinstance(data, dict):
logger.warning("LS WS MSG not-dict type=%s", type(data).__name__)
return
header = data.get("header") or {}
body = data.get("body") or {}
if not isinstance(body, dict) or not body:
return
if not isinstance(header, dict):
header = {}
body_raw = data.get("body")
body: Dict[str, Any] = body_raw if isinstance(body_raw, dict) else {}
tr_cd = str(header.get("tr_cd") or "")
tr_key = str(header.get("tr_key") or "")
tr_type = str(header.get("tr_type") or "")
rsp_cd = str(
header.get("rsp_cd")
or header.get("rspCd")
or body.get("rsp_cd")
or ""
).strip()
rsp_msg = str(
header.get("rsp_msg")
or header.get("rspMsg")
or body.get("rsp_msg")
or ""
).strip()
# REG/UNREG 응답은 종종 body=null + header.rsp_cd/rsp_msg.
# 예전: body 없으면 즉시 return → 서버 거절 코드를 삼킴 (관측 공백).
if rsp_cd or rsp_msg or (
tr_type in ("1", "2", "3", "4") and not body
):
line = (
"LS WS RSP tr_type=%s tr_cd=%s tr_key=%s rsp_cd=%s rsp_msg=%s"
% (
tr_type or "-",
tr_cd or "-",
tr_key or "-",
rsp_cd or "-",
rsp_msg or "-",
)
)
if rsp_cd and rsp_cd not in ("00000", "0"):
logger.warning(line)
try:
from kis_trader.network.ls_token import is_ls_auth_error
if is_ls_auth_error(rsp_cd=rsp_cd, rsp_msg=rsp_msg):
self._pending_bye = True
logger.warning(
"LS WS auth RSP → Bye 서킷 후보 (rsp_cd=%s)",
rsp_cd,
)
except Exception:
pass
else:
logger.info(line)
if not body:
return
if not body:
return
if tr_cd == "JIF":
self._on_jif(body)
return