Files
kis_bot/kis_trader/ws/ls_ws.py
2026-07-30 20:23:37 +09:00

1297 lines
50 KiB
Python

"""
kis_trader/ws/ls_ws.py — LS증권 WebSocket 시세 캐시 (그림자 검증용)
==================================================================
목적
----
실매 시세(KIS/키움)와 **병렬**로 LS 실키 WS 를 붙여
가격·틱·1분봉을 **별도 테이블**에 쌓고 갭을 비교한다.
**매매 의사결정에는 기본 사용하지 않는다** (읽기·적재 전용).
``LS_WS_BLOCK_BUY_WHILE_RECOVERING=true`` 일 때만 복구 중 매수 게이트에 참여.
안정성 (B/A/C/D)
----------------
- B: 모든 WS send 는 ``_send_lock`` + **timeout** 직렬화. REG 는 워커 큐.
- A: ``LS_WS_TR_MODE=us3``(기본) → 통합 ``US3`` + ``U``+패딩.
``s3k3`` → ``S3_``/``K3_`` (실험용).
- C: 생존 = **프로토콜 ping**(기본 20s). 앱 JSON 하트비트는 보내지 않음(스펙 없음).
틱 silence 강제재연결은 **정규장 세션(JIF/벽시계)에서만** + 연속 실패 백오프.
``JIF``(장운영정보) 구독으로 장상태 참고.
- D: 재연결·REG 복구 중 ``is_recovering()`` — 매수 게이트는 env 로 선택.
토글: ``LS_WS_VALIDATION_ENABLED`` (기본 false)
"""
from __future__ import annotations
import json
import logging
import queue
import threading
import time
from collections import defaultdict, deque
from datetime import datetime
from typing import Any, Callable, Dict, List, Optional, Set, Tuple
import requests
logger = logging.getLogger("LSWebSocket")
try:
from kis_trader.utils.env import get_env_bool, get_env_float, get_env_from_db, get_env_int
except ImportError:
def get_env_bool(key, default=False): # type: ignore[misc]
return default
def get_env_int(key, default=0): # type: ignore[misc]
return int(default)
def get_env_float(key, default=0.0): # type: ignore[misc]
return float(default)
def get_env_from_db(key, default=""): # type: ignore[misc]
return default
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"
# JIF jstatus — 정규장 체결이 기대되는 상태 (스펙: 21=장시작)
_JIF_STATUS_EXPECT_TICKS = frozenset({"21"})
TickRecorder = Callable[[str, Dict[str, Any]], None]
CandleFlusher = Callable[[str, Dict[str, Any]], None]
OrderbookRecorder = Callable[[str, Dict[str, Any]], None]
ViRecorder = Callable[[str, Dict[str, Any]], None]
# 매수 게이트·진단용 — start() 시 등록, stop() 시 해제
_active_ls_ws: Optional["LSWebSocketPriceCache"] = None
_active_lock = threading.Lock()
def get_active_ls_ws() -> Optional["LSWebSocketPriceCache"]:
with _active_lock:
return _active_ls_ws
def domestic_unified_tr_key(shcode: str, width: int = 10) -> str:
code = (shcode or "").strip()
raw = f"U{code}" if (len(code) == 6 and code.isdigit()) else (
code if code.startswith("U") else f"U{code}"
)
return raw if len(raw) >= width else raw.ljust(width)
def overseas_tr_key(exchcd: str, symbol: str, width: int = 18) -> str:
raw = f"{exchcd}{symbol}"
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,
*,
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",
)
class LSWebSocketPriceCache:
"""LS WS 가격 캐시 — get_price 포맷을 KIS/키움과 맞춤 (stck_prpr, _age_ms)."""
def __init__(
self,
app_key: str,
app_secret: str,
*,
is_mock: bool = False,
also_hoga: bool = False,
) -> None:
try:
import websocket # websocket-client
except ImportError as e:
raise RuntimeError("websocket-client 필요") from e
self._websocket = websocket
self.app_key = app_key
self.app_secret = app_secret
self.is_mock = bool(is_mock)
self.also_hoga = bool(also_hoga)
self.ws_url = LS_WS_MOCK if self.is_mock else LS_WS_REAL
self._token: str = ""
self._token_at: float = 0.0
self._ws: Any = None
self._thread: Optional[threading.Thread] = None
self._reg_thread: Optional[threading.Thread] = None
self._watch_thread: Optional[threading.Thread] = None
self._running = False
self._opened = threading.Event()
self._sub_lock = threading.Lock()
self._send_lock = threading.Lock() # SSL write 직렬화 (BAD_LENGTH 레이스 방지)
self._subscribed: Set[str] = set() # 6자리 KR 코드 (소켓 실구독)
# code → {"permanent","condition","default",...} — 해제는 owner 전부일 때만
self._sub_owners: Dict[str, Set[str]] = {}
self._us_subscribed: Set[str] = set() # 티커
self._us_sub_owners: Dict[str, Set[str]] = {}
self._cache_lock = threading.Lock()
self._cache: Dict[str, Dict[str, Any]] = {}
self._market_cache: Dict[str, str] = {} # code → K|Q|E
self._reg_q: queue.Queue = queue.Queue()
self._recovering = False
self._recovering_lock = threading.Lock()
self._last_tick_mono: float = 0.0
self._opened_mono: float = 0.0
self._watchdog_empty_streak: int = 0
# JIF 장운영정보 (앱 하트비트 아님 — 세션 판정용)
self._jif_lock = threading.Lock()
self._jangubun: str = ""
self._jstatus: str = ""
self._jif_mono: float = 0.0
# 1분봉 롤업 (code → bucket) + 확정봉 RAM (전략 get_candles 호환)
self._candle_lock = threading.Lock()
self._candles: Dict[str, Dict[str, Any]] = {}
# (code, tf_min) → 확정봉 deque (candle_time 스키마)
self._confirmed: Dict[Tuple[str, int], deque] = defaultdict(
lambda: deque(maxlen=max(50, get_env_int("LS_WS_CONFIRMED_MAX", 500)))
)
self._tick_recorder: Optional[TickRecorder] = None
self._candle_flusher: Optional[CandleFlusher] = None
self._orderbook_recorder: Optional[OrderbookRecorder] = None
self._vi_recorder: Optional[ViRecorder] = None
self._orderbook_cache: Any = None
self._ob_last_save_mono: Dict[str, float] = {}
# BaseStrategy 틱매도 등 — KIS/키움과 동일 시그니처
self._price_listeners: List[Callable] = []
# code → last VI-active monotonic (해제·만료 시 discard)
self._vi_lock = threading.Lock()
self._vi_active_mono: Dict[str, float] = {}
try:
from kis_trader.ws.orderbook_cache import OrderbookCache
self._orderbook_cache = OrderbookCache()
except Exception as e:
logger.debug("LS OrderbookCache 미사용: %s", e)
# ── public API (키움/KIS 와 동일 취지) ──────────────────────────────
def attach_tick_recorder(self, fn: TickRecorder) -> None:
self._tick_recorder = fn
def attach_candle_flusher(self, fn: CandleFlusher) -> None:
self._candle_flusher = fn
def attach_orderbook_recorder(self, fn: OrderbookRecorder) -> None:
"""LS UH1/H1_/HA_ → DB 등 (실매 호가필터 경로와 분리)."""
self._orderbook_recorder = fn
def attach_vi_recorder(self, fn: ViRecorder) -> None:
"""LS UVI/VI_ 발동·해제 → ls_ws_vi 등."""
self._vi_recorder = fn
def is_in_vi(self, code: str) -> bool:
"""종목이 VI 발동 중인지 (해제 누락 대비 stale 만료)."""
c = self._normalize_kr_code(code)
if not c:
return False
stale = max(30, int(get_env_int("LS_WS_VI_STALE_SEC", 180) or 180))
now = time.monotonic()
with self._vi_lock:
mono = self._vi_active_mono.get(c)
if mono is None:
return False
if now - mono > stale:
self._vi_active_mono.pop(c, None)
return False
return True
def get_orderbook_snapshot(self, code: str, max_age_sec: float = 3.0):
"""키움 ``get_orderbook_snapshot`` 호환 — LS RAM 호가."""
if self._orderbook_cache is None:
return None
return self._orderbook_cache.get(code, max_age_sec=max_age_sec)
def is_recovering(self) -> bool:
with self._recovering_lock:
return bool(self._recovering)
def blocks_new_buy(self) -> bool:
"""복구 중 신규매수 차단 — env ON 일 때만 True."""
if not get_env_bool("LS_WS_BLOCK_BUY_WHILE_RECOVERING", False):
return False
return self.is_recovering() or not self.is_connected()
def start(self) -> bool:
global _active_ls_ws
if self._thread and self._thread.is_alive():
return True
try:
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)
return False
self._running = True
self._opened.clear()
self._last_tick_mono = time.monotonic()
self._set_recovering(True)
self._thread = threading.Thread(
target=self._run_forever, name="LS-WS", daemon=True
)
self._reg_thread = threading.Thread(
target=self._reg_worker, name="LS-WS-REG", daemon=True
)
self._watch_thread = threading.Thread(
target=self._watchdog_loop, name="LS-WS-Watch", daemon=True
)
self._thread.start()
self._reg_thread.start()
self._watch_thread.start()
if not self._opened.wait(timeout=15.0):
logger.error("LS WS OPEN timeout")
self.stop()
return False
with _active_lock:
_active_ls_ws = self
logger.info(
"✅ LS WS 연결 (%s mock=%s tr_mode=%s ping=%ss)",
self.ws_url,
self.is_mock,
self._tr_mode(),
int(get_env_int("LS_WS_PING_INTERVAL_SEC", 20) or 0),
)
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:
self._reg_q.put_nowait(None) # poison
except Exception:
pass
if self._ws is not None:
try:
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
def subscribe(self, code: str, owner: str = "default") -> None:
"""시세 구독. ``owner`` 가 남아 있으면 중복 REG 안 함.
owner 예: ``permanent``(영구구독), ``condition``(LS 조건 이력), ``default``.
"""
code = (code or "").strip()
if not code:
return
own = (owner or "default").strip() or "default"
if code.isdigit() and len(code) == 6:
need_reg = False
with self._sub_lock:
ow = self._sub_owners.setdefault(code, set())
if own in ow and code in self._subscribed:
return
ow.add(own)
if code not in self._subscribed:
self._subscribed.add(code)
need_reg = True
if need_reg:
self._enqueue_kr_reg(code)
else:
need_reg = False
with self._sub_lock:
ow = self._us_sub_owners.setdefault(code, set())
if own in ow and code in self._us_subscribed:
return
ow.add(own)
if code not in self._us_subscribed:
self._us_subscribed.add(code)
need_reg = True
if need_reg:
self._enqueue_send("3", "GSC", overseas_tr_key("82", code))
def unsubscribe(self, code: str, owner: str = "default") -> None:
"""owner 제거. 다른 owner 가 남으면 소켓 구독 유지."""
code = (code or "").strip()
if not code:
return
own = (owner or "default").strip() or "default"
if code.isdigit() and len(code) == 6:
drop_socket = False
with self._sub_lock:
ow = self._sub_owners.get(code)
if ow is not None:
ow.discard(own)
if ow:
return
self._sub_owners.pop(code, None)
if code not in self._subscribed:
return
self._subscribed.discard(code)
drop_socket = True
if not drop_socket:
return
tr_cd, tr_key, hoga_cd, hoga_key = self._kr_tr_pair(code)
self._enqueue_send("4", tr_cd, tr_key)
if self.also_hoga:
self._enqueue_send("4", hoga_cd, hoga_key)
if get_env_bool("LS_WS_UVI_ENABLED", True):
vi_cd, vi_key = self._vi_tr_pair(code)
self._enqueue_send("4", vi_cd, vi_key)
if self._orderbook_cache is not None:
self._orderbook_cache.remove(code)
with self._vi_lock:
self._vi_active_mono.pop(code, None)
else:
drop_socket = False
with self._sub_lock:
ow = self._us_sub_owners.get(code)
if ow is not None:
ow.discard(own)
if ow:
return
self._us_sub_owners.pop(code, None)
if code not in self._us_subscribed:
return
self._us_subscribed.discard(code)
drop_socket = True
if drop_socket:
self._enqueue_send("4", "GSC", overseas_tr_key("82", code))
def sync_owner_codes(self, owner: str, codes: Set[str]) -> None:
"""특정 owner 구독 집합을 ``codes`` 로 맞춤 (diff subscribe/unsubscribe)."""
own = (owner or "default").strip() or "default"
desired = {(c or "").strip() for c in (codes or set()) if (c or "").strip()}
with self._sub_lock:
cur: Set[str] = set()
for c, ow in self._sub_owners.items():
if own in ow:
cur.add(c)
for c, ow in self._us_sub_owners.items():
if own in ow:
cur.add(c)
add = sorted(desired - cur)
drop = sorted(cur - desired)
if add or drop or own == "condition":
logger.info(
"LS WS owner=%s sync +%d -%d desired=%d (socket_KR≈확인은 OPEN/워치독)",
own, len(add), len(drop), len(desired),
)
for c in add:
self.subscribe(c, owner=own)
for c in drop:
self.unsubscribe(c, owner=own)
def get_price(self, code: str, max_age_sec: float = 10.0) -> Optional[Dict[str, Any]]:
with self._cache_lock:
d = self._cache.get(code)
if not d:
return None
age = time.time() - float(d.get("_ts", 0))
if age > max_age_sec:
return None
out = dict(d)
out["_age_ms"] = int(age * 1000)
return out
def add_price_listener(self, callback) -> None:
if callback is None:
return
if callback not in self._price_listeners:
self._price_listeners.append(callback)
def remove_price_listener(self, callback) -> None:
if callback is None:
return
try:
self._price_listeners.remove(callback)
except ValueError:
pass
def _notify_price_listeners(self, code: str, price: float, data: Dict[str, Any]) -> None:
for cb in list(self._price_listeners):
try:
cb(code, price, data)
except Exception:
pass
@staticmethod
def _forming_to_strategy_bar(cur: Dict[str, Any]) -> Dict[str, Any]:
"""LS forming/flush dict → CandleAggregator 호환."""
from kis_trader.network.ls_chart import ls_datetime_to_candle_time
dt_s = str(cur.get("datetime") or "")
ct = ls_datetime_to_candle_time(dt_s)
return {
"candle_time": ct,
"open": float(cur.get("open") or 0),
"high": float(cur.get("high") or 0),
"low": float(cur.get("low") or 0),
"close": float(cur.get("close") or 0),
"volume": float(cur.get("volume") or 0),
"tick_count": int(cur.get("tick_count") or 0),
"is_confirmed": 0,
"source": "ls",
"rsi_2": None,
"rsi_3": None,
"rsi_5": None,
}
@staticmethod
def _calc_rsi(closes: list, period: int) -> Optional[float]:
if len(closes) < period + 1:
return None
recent = closes[-(period + 1):]
gains, losses = [], []
for i in range(1, len(recent)):
delta = recent[i] - recent[i - 1]
gains.append(max(delta, 0))
losses.append(max(-delta, 0))
avg_gain = sum(gains) / period if period > 0 else 0
avg_loss = sum(losses) / period if period > 0 else 0
if avg_loss == 0:
return 100.0
rs = avg_gain / avg_loss
return round(100 - (100 / (1 + rs)), 2)
def _attach_rsi(self, bars: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
closes = [float(b.get("close") or 0) for b in bars]
out = []
for i, b in enumerate(bars):
row = dict(b)
sub = closes[: i + 1]
row["rsi_2"] = self._calc_rsi(sub, 2)
row["rsi_3"] = self._calc_rsi(sub, 3)
row["rsi_5"] = self._calc_rsi(sub, 5)
out.append(row)
return out
def _push_confirmed(self, code: str, bar: Dict[str, Any]) -> None:
"""확정봉 RAM 적재 (동일 candle_time 이면 갱신)."""
tf = max(1, int(bar.get("tf_min") or get_env_int("LS_WS_CANDLE_TF_MIN", 1)))
ct = str(bar.get("candle_time") or "")[:12]
if len(ct) < 12:
return
key = (code, tf)
with self._candle_lock:
buf = self._confirmed[key]
if buf and str(buf[-1].get("candle_time") or "")[:12] == ct:
buf[-1] = dict(bar)
buf[-1]["is_confirmed"] = 1
else:
row = dict(bar)
row["is_confirmed"] = 1
buf.append(row)
def get_candles(self, code: str, tf: int, n: int = 50) -> list:
"""최근 n개 확정 봉 (오래된→최신). RAM → DB 폴백."""
tf_i = max(1, int(tf or 1))
lim = max(1, int(n or 50))
with self._candle_lock:
buf = list(self._confirmed.get((code, tf_i), []))
if len(buf) >= lim:
return self._attach_rsi(buf[-lim:])
# DB 폴백으로 보강
try:
from database import TradeDB
db_bars = TradeDB().get_ls_ws_candles(code, tf_min=tf_i, limit=lim)
except Exception:
db_bars = []
if not db_bars and not buf:
return []
# RAM 우선 merge by candle_time
by_ct: Dict[str, Dict[str, Any]] = {}
for b in db_bars:
ct = str(b.get("candle_time") or "")[:12]
if ct:
by_ct[ct] = dict(b)
for b in buf:
ct = str(b.get("candle_time") or "")[:12]
if ct:
by_ct[ct] = dict(b)
merged = [by_ct[k] for k in sorted(by_ct.keys())]
return self._attach_rsi(merged[-lim:])
def get_current_candle(self, code: str, tf: int) -> Optional[dict]:
"""진행 중 봉 (is_confirmed=0)."""
tf_i = max(1, int(tf or 1))
want_tf = max(1, get_env_int("LS_WS_CANDLE_TF_MIN", 1))
if tf_i != want_tf:
# 1분 롤업만 유지 — 다른 TF 요청은 None (키움 롤업과 동일 제약)
return None
with self._candle_lock:
cur = self._candles.get(code)
if not cur:
return None
return self._forming_to_strategy_bar(cur)
def fill_gap_from_rest(
self,
code: str,
*,
qrycnt: Optional[int] = None,
upsert_db: bool = True,
) -> int:
"""
t8412 분봉 → RAM 확정봉 + ls_ws_candles upsert.
반환: 반영한 봉 수. 실패/비활성 시 0.
"""
from kis_trader.network.ls_chart import (
candle_time_to_ls_datetime,
fetch_ls_minute_chart_df,
)
code = (code or "").strip()
if not code:
return 0
df = fetch_ls_minute_chart_df(code, ncnt=1, qrycnt=qrycnt)
if df is None or df.empty:
return 0
tf = max(1, get_env_int("LS_WS_CANDLE_TF_MIN", 1))
n_ok = 0
db = None
if upsert_db:
try:
from database import TradeDB
db = TradeDB()
except Exception as e:
logger.debug("LS gap DB 연결 실패: %s", e)
db = None
for _, row in df.iterrows():
ct = str(row.get("time") or "")[:12]
if len(ct) < 12:
continue
bar = {
"candle_time": ct,
"open": float(row["open"]),
"high": float(row["high"]),
"low": float(row["low"]),
"close": float(row["close"]),
"volume": float(row.get("volume") or 0),
"tick_count": 0,
"is_confirmed": 1,
"source": "ls_t8412",
"tf_min": tf,
}
self._push_confirmed(code, bar)
if db is not None:
try:
db.upsert_ls_ws_candle(
code=code,
candle={
"datetime": candle_time_to_ls_datetime(ct),
"tf_min": tf,
"open": bar["open"],
"high": bar["high"],
"low": bar["low"],
"close": bar["close"],
"volume": bar["volume"],
"tick_count": 0,
},
)
except Exception as e:
logger.debug("LS gap upsert %s %s: %s", code, ct, e)
n_ok += 1
if n_ok:
logger.info("📥 [LS갭] %s t8412 → %d봉 (RAM+DB)", code, n_ok)
return n_ok
# ── TR / 시장 ─────────────────────────────────────────────────────
def _tr_mode(self) -> str:
# 기본 us3: 당일 실측 전 틱이 US3. s3k3 전환 후 틱 0+watchdog 폭주 확인됨.
v = (get_env_from_db("LS_WS_TR_MODE", "us3") or "us3").strip().lower()
if v in ("s3k3", "s3", "k3", "split"):
return "s3k3"
return "us3"
def _lookup_market(self, code: str) -> str:
"""stock_meta.market: K=KOSPI, Q=KOSDAQ, E=ETF. 없으면 ''."""
if code in self._market_cache:
return self._market_cache[code]
m = ""
try:
from database import TradeDB
db = TradeDB()
row = db.conn.execute(
"SELECT market FROM stock_meta WHERE code=%s LIMIT 1",
(code,),
).fetchone()
if row:
m = str(row.get("market") or "").strip().upper()[:1]
except Exception as e:
logger.debug("LS stock_meta 조회 실패 %s: %s", code, e)
self._market_cache[code] = m
return m
def _kr_tr_pair(self, code: str) -> Tuple[str, str, str, str]:
"""(체결 tr_cd, tr_key, 호가 tr_cd, 호가 tr_key)."""
if self._tr_mode() == "us3":
k = domestic_unified_tr_key(code)
return "US3", k, "UH1", k
m = self._lookup_market(code)
if m == "K" or m == "E":
return "S3_", code, "H1_", code
if m == "Q":
return "K3_", code, "HA_", code
# 메타 없음: 통합 US3 폴백 (잘못된 S3_/K3_ 보다 안전)
k = domestic_unified_tr_key(code)
return "US3", k, "UH1", k
def _vi_tr_pair(self, code: str) -> Tuple[str, str]:
"""(VI tr_cd, tr_key) — us3→UVI+U패딩, s3k3→VI_+6자리."""
if self._tr_mode() == "us3":
return "UVI", domestic_unified_tr_key(code)
return "VI_", code
def _enqueue_kr_reg(self, code: str) -> None:
tr_cd, tr_key, hoga_cd, hoga_key = self._kr_tr_pair(code)
self._enqueue_send("3", tr_cd, tr_key)
if self.also_hoga:
self._enqueue_send("3", hoga_cd, hoga_key)
if get_env_bool("LS_WS_UVI_ENABLED", True):
vi_cd, vi_key = self._vi_tr_pair(code)
self._enqueue_send("3", vi_cd, vi_key)
def _enqueue_send(self, tr_type: str, tr_cd: str, tr_key: str) -> None:
try:
self._reg_q.put_nowait(("send", tr_type, tr_cd, tr_key))
except Exception as e:
logger.debug("LS REG 큐 put 실패: %s", e)
def _enqueue_replay(self) -> None:
try:
self._reg_q.put_nowait(("replay", None, None, None))
except Exception as e:
logger.debug("LS REG replay 큐 실패: %s", e)
def _set_recovering(self, on: bool) -> None:
with self._recovering_lock:
self._recovering = bool(on)
# ── internals ─────────────────────────────────────────────────────
def _ensure_token(self) -> str:
# 스펙 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
def _send_raw(self, payload: dict) -> None:
if self._ws is None or not self._opened.is_set():
return
timeout = max(0.5, float(get_env_float("LS_WS_SEND_LOCK_TIMEOUT_SEC", 3.0) or 3.0))
acquired = self._send_lock.acquire(timeout=timeout)
if not acquired:
logger.warning(
"LS WS send lock timeout %.1fs → close (half-open 블로킹 방지)",
timeout,
)
try:
if self._ws is not None:
self._ws.close()
except Exception as e:
logger.debug("LS send-lock timeout close: %s", e)
return
try:
try:
self._ws.send(json.dumps(payload, ensure_ascii=False))
except Exception as e:
logger.debug("LS WS send 실패: %s", e)
try:
if self._ws is not None:
self._ws.close()
except Exception as e2:
logger.debug("LS send 실패 후 close: %s", e2)
finally:
self._send_lock.release()
def _send_typed(self, tr_type: str, tr_cd: str, tr_key: str) -> None:
self._send_raw({
"header": {"token": self._ensure_token(), "tr_type": str(tr_type)},
"body": {"tr_cd": tr_cd, "tr_key": tr_key},
})
def _session_expects_trade_ticks(self) -> bool:
"""틱 silence 강제재연결을 허용할 세션인가 (VI·점심·장외 오탐 방지).
- ``LS_WS_WATCHDOG_SESSION_GATE=false`` 이면 항상 True(레거시).
- JIF ``jstatus=21``(장시작) 이면 True.
- JIF 미수신 시 벽시계 정규장 창(기본 09:00~15:25) 폴백.
"""
if not get_env_bool("LS_WS_WATCHDOG_SESSION_GATE", True):
return True
with self._jif_lock:
st = str(self._jstatus or "").strip()
got_jif = bool(self._jif_mono > 0)
if got_jif:
return st in _JIF_STATUS_EXPECT_TICKS
now = datetime.now()
if now.weekday() >= 5:
return False
hhmm = now.hour * 100 + now.minute
start = int(get_env_int("LS_WS_WATCHDOG_SESSION_START_HM", 900) or 900)
end = int(get_env_int("LS_WS_WATCHDOG_SESSION_END_HM", 1525) or 1525)
return start <= hhmm <= end
def _on_jif(self, body: Dict[str, Any]) -> None:
jangubun = str(body.get("jangubun") or "").strip()
jstatus = str(body.get("jstatus") or "").strip()
with self._jif_lock:
self._jangubun = jangubun
self._jstatus = jstatus
self._jif_mono = time.monotonic()
logger.info("LS JIF 장운영 jangubun=%s jstatus=%s", jangubun, jstatus)
def _normalize_kr_code(self, raw: str) -> str:
code = str(raw or "").strip()
if code.startswith("U") and len(code) >= 7 and code[1:7].isdigit():
return code[1:7]
if code.isdigit() and len(code) == 6:
return code
# ex_shcode 등
digits = "".join(ch for ch in code if ch.isdigit())
if len(digits) >= 6:
return digits[-6:]
return code
def _on_hoga(self, tr_cd: str, body: Dict[str, Any]) -> None:
"""UH1(통합호가) 등 — RAM 갱신 + (옵션) ls_ws_orderbook 스로틀 저장."""
if self._orderbook_cache is None:
return
code = self._normalize_kr_code(
str(body.get("shcode") or body.get("ex_shcode") or "")
)
if not (code.isdigit() and len(code) == 6):
return
src_map = {"UH1": "ls_uh1", "H1_": "ls_h1", "HA_": "ls_ha", "NH1": "ls_nh1"}
source = src_map.get(tr_cd, f"ls_{tr_cd.lower()}")
try:
snap = self._orderbook_cache.update_from_ls_hoga(code, body, source=source)
except Exception as e:
logger.debug("LS 호가 파싱 실패 %s: %s", code, e)
return
if not self._orderbook_recorder:
return
if not get_env_bool("LS_WS_ORDERBOOK_SAVE", True):
return
gap_ms = max(0, int(get_env_int("LS_WS_ORDERBOOK_SAVE_MS", 1000) or 1000))
now_m = time.monotonic()
last = self._ob_last_save_mono.get(code, 0.0)
if gap_ms > 0 and (now_m - last) * 1000.0 < gap_ms:
return
self._ob_last_save_mono[code] = now_m
try:
self._orderbook_recorder(code, snap.to_storage_dict())
except Exception as e:
logger.debug("LS orderbook recorder: %s", e)
@staticmethod
def _vi_gubun_active(g: str) -> bool:
return str(g or "").strip() in ("1", "2", "3")
def _all_kr_in_vi(self) -> bool:
"""구독 중인 KR 이 1개 이상이고 전부 VI 활성인가."""
with self._sub_lock:
kr = [c for c in self._subscribed if c.isdigit() and len(c) == 6]
if not kr:
return False
return all(self.is_in_vi(c) for c in kr)
def _on_vi(self, tr_cd: str, body: Dict[str, Any]) -> None:
"""UVI/VI_ — RAM VI 상태 + ls_ws_vi 이벤트 적재."""
code = self._normalize_kr_code(
str(body.get("shcode") or body.get("ref_shcode") or body.get("ex_shcode") or "")
)
if not (code.isdigit() and len(code) == 6):
return
krx_g = str(body.get("krx_vi_gubun") or "").strip()
nxt_g = str(body.get("nxt_vi_gubun") or "").strip()
plain_g = str(body.get("vi_gubun") or "").strip()
if tr_cd == "UVI":
active = self._vi_gubun_active(krx_g) or self._vi_gubun_active(nxt_g)
# 저장용 대표 구분: KRX 우선, 없으면 NXT
vi_g = krx_g if krx_g != "" else (nxt_g if nxt_g != "" else "0")
if active and not self._vi_gubun_active(vi_g):
vi_g = nxt_g if self._vi_gubun_active(nxt_g) else krx_g
event_time = str(body.get("krx_time") or body.get("nxt_time") or "").strip()
try:
svi = float(body.get("krx_svi_recprice") or body.get("nxt_svi_recprice") or 0) or None
except (TypeError, ValueError):
svi = None
try:
dvi = float(body.get("krx_dvi_recprice") or body.get("nxt_dvi_recprice") or 0) or None
except (TypeError, ValueError):
dvi = None
try:
trg = float(body.get("krx_vi_trgprice") or body.get("nxt_vi_trgprice") or 0) or None
except (TypeError, ValueError):
trg = None
else:
active = self._vi_gubun_active(plain_g)
vi_g = plain_g or "0"
event_time = str(body.get("time") or "").strip()
try:
svi = float(body.get("svi_recprice") or 0) or None
except (TypeError, ValueError):
svi = None
try:
dvi = float(body.get("dvi_recprice") or 0) or None
except (TypeError, ValueError):
dvi = None
try:
trg = float(body.get("vi_trgprice") or 0) or None
except (TypeError, ValueError):
trg = None
krx_g = plain_g or None
nxt_g = None
now_m = time.monotonic()
with self._vi_lock:
if active:
self._vi_active_mono[code] = now_m
else:
self._vi_active_mono.pop(code, None)
logger.info(
"LS VI %s code=%s gubun=%s krx=%s nxt=%s active=%s",
tr_cd, code, vi_g, krx_g or "-", nxt_g or "-", active,
)
if not self._vi_recorder or not get_env_bool("LS_WS_VI_SAVE", True):
return
payload = {
"ts": datetime.now(),
"event_time": event_time,
"vi_gubun": vi_g,
"krx_vi_gubun": krx_g,
"nxt_vi_gubun": nxt_g,
"svi_recprice": svi,
"dvi_recprice": dvi,
"vi_trgprice": trg,
"tr_cd": tr_cd,
"exchname": str(body.get("exchname") or ""),
"active": active,
}
try:
self._vi_recorder(code, payload)
except Exception as e:
logger.debug("LS VI recorder: %s", e)
def _reg_worker(self) -> None:
"""OPEN 콜백에서 sleep 하지 않고, 여기서 gap 두고 REG/UNREG."""
while self._running:
try:
item = self._reg_q.get(timeout=0.5)
except queue.Empty:
continue
if item is None:
break
kind = item[0]
gap_ms = max(20, int(get_env_int("LS_WS_REG_GAP_MS", 80) or 80))
if kind == "replay":
delay_ms = max(0, int(get_env_int("LS_WS_OPEN_REG_DELAY_MS", 500) or 0))
if delay_ms > 0:
time.sleep(delay_ms / 1000.0)
with self._sub_lock:
kr = list(self._subscribed)
us = list(self._us_subscribed)
n_ok = 0
# JIF 장운영 — 스펙 예: tr_key=0 (앱 하트비트 아님)
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"
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():
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:
self._send_typed("3", hoga_cd, hoga_key)
n_ok += 1
if get_env_bool("LS_WS_UVI_ENABLED", True):
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():
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)
logger.info(
"LS WS OPEN — 구독 복구 KR=%d US=%d sends≈%d (delay=%dms gap=%dms ping=%s)",
len(kr),
len(us),
n_ok,
delay_ms,
gap_ms,
"off" if ping_iv <= 0 else f"{ping_iv}s",
)
self._set_recovering(False)
continue
# send
_, tr_type, tr_cd, tr_key = item
if self._opened.is_set():
self._send_typed(str(tr_type), str(tr_cd), str(tr_key))
time.sleep(gap_ms / 1000.0)
def _watchdog_loop(self) -> None:
"""정규장에서만: 구독 전체 틱 N초 없음 → 강제 close.
생존 1순위는 프로토콜 ping. 앱 JSON 하트비트는 보내지 않음.
장외·동시호가·마감·JIF 비정규 상태에서는 틱 silence로 끊지 않음.
"""
while self._running:
time.sleep(max(1, int(get_env_int("LS_WS_WATCHDOG_POLL_SEC", 5) or 5)))
if not get_env_bool("LS_WS_WATCHDOG_ENABLED", True):
continue
if not self._opened.is_set():
continue
with self._sub_lock:
n_sub = len(self._subscribed) + len(self._us_subscribed)
if n_sub <= 0:
continue
if not self._session_expects_trade_ticks():
continue
# 구독 KR 전부가 VI 중이면 틱 공백이 정상 → 강제재연결 금지
if get_env_bool("LS_WS_WATCHDOG_SKIP_WHEN_VI", True) and self._all_kr_in_vi():
continue
silence = max(15, int(get_env_int("LS_WS_WATCHDOG_SILENCE_SEC", 45) or 45))
grace = max(5, int(get_env_int("LS_WS_WATCHDOG_OPEN_GRACE_SEC", 30) or 30))
empty_after = max(1, int(get_env_int("LS_WS_WATCHDOG_EMPTY_BACKOFF_AFTER", 3) or 3))
empty_mult = max(2, int(get_env_int("LS_WS_WATCHDOG_EMPTY_BACKOFF_MULT", 4) or 4))
if self._watchdog_empty_streak >= empty_after:
silence = silence * empty_mult
now = time.monotonic()
if now - self._opened_mono < grace:
continue
if now - self._last_tick_mono < silence:
continue
if self.is_recovering():
continue
self._watchdog_empty_streak += 1
with self._jif_lock:
jst = self._jstatus or "-"
logger.warning(
"LS WS watchdog: %ds 틱 없음 (subs=%d streak=%d silence=%ds jstatus=%s) → 강제 재연결",
int(now - self._last_tick_mono),
n_sub,
self._watchdog_empty_streak,
silence,
jst,
)
self._set_recovering(True)
try:
if self._ws is not None:
self._ws.close()
except Exception as e:
logger.debug("LS watchdog close: %s", e)
def _run_forever(self) -> None:
backoff = 1.0
while self._running:
try:
self._opened.clear()
self._set_recovering(True)
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,
)
# 헬퍼/문서와 동일: 프로토콜 ping 기본 ON(20).
# BAD_LENGTH 완화는 send lock + REG 워커(콜백 비서면)로 처리.
ping_iv = int(get_env_int("LS_WS_PING_INTERVAL_SEC", 20) or 0)
ping_to = int(get_env_int("LS_WS_PING_TIMEOUT_SEC", 10) or 10)
run_kw: Dict[str, Any] = {}
if ping_iv > 0:
run_kw["ping_interval"] = ping_iv
run_kw["ping_timeout"] = max(1, ping_to)
self._ws.run_forever(**run_kw)
except Exception as e:
logger.warning("LS WS run 예외: %s", e)
self._opened.clear()
self._set_recovering(True)
if not self._running:
break
time.sleep(min(backoff, 60.0))
backoff = min(backoff * 2, 60.0)
try:
self._ensure_token()
except Exception as e:
logger.warning("LS 토큰 재발급 실패: %s", e)
def _on_open(self, _ws: Any) -> None:
# 콜백에서 sleep/REG 연타 금지 → 워커에 replay 위임
self._opened_mono = time.monotonic()
self._last_tick_mono = time.monotonic()
self._opened.set()
self._set_recovering(True)
self._enqueue_replay()
def _on_close(self, _ws: Any, status: Any, msg: Any) -> None:
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)
def _on_message(self, _ws: Any, message: Any) -> None:
try:
data = json.loads(message) if isinstance(message, str) else message
except Exception:
return
header = data.get("header") or {}
body = data.get("body") or {}
if not isinstance(body, dict) or not body:
return
tr_cd = str(header.get("tr_cd") or "")
if tr_cd == "JIF":
self._on_jif(body)
return
if tr_cd in ("UH1", "H1_", "HA_", "NH1"):
self._on_hoga(tr_cd, body)
return
if tr_cd in ("UVI", "VI_", "NVI", "DVI"):
self._on_vi(tr_cd, body)
return
code = str(body.get("shcode") or body.get("symbol") or "").strip()
if not code:
return
# U005930 → 005930
if code.startswith("U") and len(code) >= 7 and code[1:7].isdigit():
code = code[1:7]
price_raw = body.get("price")
try:
price = float(price_raw)
except (TypeError, ValueError):
return
if price <= 0:
return
now = time.time()
self._last_tick_mono = time.monotonic()
self._watchdog_empty_streak = 0
chetime = str(body.get("chetime") or body.get("kortm") or body.get("trdtm") or "")
cvol = body.get("cvolume") or body.get("trdq") or 0
totq = body.get("volume") or body.get("totq") or 0
try:
cvol_f = float(cvol or 0)
except (TypeError, ValueError):
cvol_f = 0.0
try:
tot_f = float(totq or 0)
except (TypeError, ValueError):
tot_f = 0.0
# KIS 캐시 호환 필드
row = {
"stck_prpr": str(int(price) if price >= 1000 else price),
"prdy_ctrt": str(body.get("drate") or body.get("rate") or ""),
"acml_vol": str(tot_f),
"cntg_vol": str(cvol_f),
"stck_oprc": str(body.get("open") or ""),
"stck_hgpr": str(body.get("high") or ""),
"stck_lwpr": str(body.get("low") or ""),
"chetime": chetime,
"_ts": now,
"_src": "ls",
"_tr_cd": tr_cd,
"_price_f": price,
}
with self._cache_lock:
self._cache[code] = row
self._notify_price_listeners(code, price, row)
if self._tick_recorder and get_env_bool("LS_WS_TICK_SAVE", True):
try:
self._tick_recorder(
code,
{
"ts": datetime.now(),
"price": price,
"volume": cvol_f,
"tot_volume": tot_f,
"chetime": chetime,
"tr_cd": tr_cd,
},
)
except Exception as e:
logger.debug("LS tick recorder: %s", e)
if get_env_bool("LS_WS_CANDLE_SAVE", True):
self._roll_candle(code, price, cvol_f, now)
def _roll_candle(self, code: str, price: float, cvol: float, now: float) -> None:
tf = max(1, get_env_int("LS_WS_CANDLE_TF_MIN", 1))
dt = datetime.fromtimestamp(now)
# 분 버킷
minute = (dt.minute // tf) * tf
bucket = dt.replace(minute=minute, second=0, microsecond=0)
key = bucket.strftime("%Y-%m-%d %H:%M:00")
flushed = None
with self._candle_lock:
cur = self._candles.get(code)
if cur and cur.get("datetime") != key:
flushed = dict(cur)
cur = None
if cur is None:
cur = {
"datetime": key,
"tf_min": tf,
"open": price,
"high": price,
"low": price,
"close": price,
"volume": max(0.0, cvol),
"tick_count": 1,
}
self._candles[code] = cur
else:
cur["high"] = max(float(cur["high"]), price)
cur["low"] = min(float(cur["low"]), price)
cur["close"] = price
cur["volume"] = float(cur.get("volume") or 0) + max(0.0, cvol)
cur["tick_count"] = int(cur.get("tick_count") or 0) + 1
if flushed:
# 확정봉 RAM (전략 get_candles)
try:
bar = self._forming_to_strategy_bar(flushed)
bar["is_confirmed"] = 1
bar["tf_min"] = int(flushed.get("tf_min") or tf)
self._push_confirmed(code, bar)
except Exception as e:
logger.debug("LS confirmed push: %s", e)
if self._candle_flusher:
try:
self._candle_flusher(code, flushed)
except Exception as e:
logger.debug("LS candle flush: %s", e)