1580 lines
62 KiB
Python
1580 lines
62 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 강제재연결 = **벽시계 우선** (국장 정규창 / 해외 hold).
|
|
sticky JIF ``jstatus=21`` 만으로 장외 워치독 금지.
|
|
``JIF`` 마감 상태는 벽시계 안에서만 추가 차단.
|
|
- D: 재연결·REG 복구 중 ``is_recovering()`` — 매수 게이트는 env 로 선택.
|
|
- E: 국내/해외 **hold 창**(``ls_ws_session_windows``) — 갭·장외는 소켓 close 후 대기.
|
|
세션 전환 시 **oauth 재발급 금지**(공용 토큰 무효화 방지, 키움 과거 사고와 동일 유형).
|
|
|
|
토글: ``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"})
|
|
|
|
# AFR 세션 sticky — 장중 다른 jstatus(66/70 등)가 덮어써도 개장 sticky 유지.
|
|
# 실측(2026-07-31): 08:50~ 25/24/23/22 → 09:00 21 / 마감 30·31·41~44
|
|
_JIF_AFR_OPEN_DEFAULT = frozenset({"21", "22", "23", "24", "25"})
|
|
_JIF_AFR_CLOSE_DEFAULT = frozenset({"30", "31", "41", "42", "43", "44"})
|
|
|
|
|
|
def _parse_jif_status_set(env_key: str, default: frozenset) -> frozenset:
|
|
raw = str(get_env_from_db(env_key, "") or "").strip()
|
|
if not raw:
|
|
return default
|
|
out = {p.strip() for p in raw.replace(";", ",").split(",") if p.strip()}
|
|
return frozenset(out) if out else default
|
|
|
|
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._session_guard_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
|
|
# 국장 AFR용 sticky (해외 밤장과 무관 — LS JIF 만)
|
|
self._kr_afr_session_live: bool = False
|
|
self._kr_afr_session_src: str = ""
|
|
self._kr_afr_opened_ymd: str = "" # 당일 개장전/장시작 JIF 수신일
|
|
self._kr_afr_closed_ymd: str = "" # 당일 마감 JIF 수신일
|
|
|
|
# 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, force=False, 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._session_guard_thread = threading.Thread(
|
|
target=self._session_guard_loop, name="LS-WS-SessionGuard", daemon=True
|
|
)
|
|
self._thread.start()
|
|
self._reg_thread.start()
|
|
self._watch_thread.start()
|
|
self._session_guard_thread.start()
|
|
with _active_lock:
|
|
_active_ls_ws = self
|
|
# hold 외 기동: OPEN 대기하지 않음 — 루프가 다음 hold 까지 슬립 (재발급 없음)
|
|
if not self._should_hold_socket():
|
|
from kis_trader.utils.ls_ws_session_windows import (
|
|
seconds_until_ls_socket_open,
|
|
)
|
|
|
|
wait_sec = seconds_until_ls_socket_open(
|
|
n_us_subscribed=self._n_us_subscribed(),
|
|
)
|
|
logger.info(
|
|
"✅ LS WS 기동(hold 외 대기) — 다음 hold까지 약 %.0f분 "
|
|
"(소켓 미연결·토큰 캐시 유지)",
|
|
wait_sec / 60.0,
|
|
)
|
|
return True
|
|
if not self._opened.wait(timeout=15.0):
|
|
logger.error("LS WS OPEN timeout")
|
|
self.stop()
|
|
return False
|
|
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._session_guard_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 공유 캐시 — force 금지(세션 전환·재연결에서 공용 토큰 무효화 방지)
|
|
self._token = fetch_ls_access_token(
|
|
self.app_key, self.app_secret, force=False, reason="ls_ws_ensure",
|
|
)
|
|
self._token_at = time.time()
|
|
return self._token
|
|
|
|
def _force_close_socket(self, reason: str) -> None:
|
|
"""hold 이탈·갭 — 소켓만 양보. 토큰 revoke/재발급 없음."""
|
|
ws = self._ws
|
|
if ws is None:
|
|
return
|
|
try:
|
|
logger.info("🔌 [LS WS] 세션 양보 close (%s)", reason)
|
|
ws.close()
|
|
except Exception as exc:
|
|
logger.debug("LS WS force close 예외: %s", exc)
|
|
|
|
def _n_us_subscribed(self) -> int:
|
|
with self._sub_lock:
|
|
return len(self._us_subscribed)
|
|
|
|
def _should_hold_socket(self, now: Optional[datetime] = None) -> bool:
|
|
"""국내 hold 또는 (해외구독+해외 hold). ``LS_WS_SESSION_HOLD_ENABLED=false`` 면 항상 True."""
|
|
if not get_env_bool("LS_WS_SESSION_HOLD_ENABLED", True):
|
|
return True
|
|
from kis_trader.utils.ls_ws_session_windows import should_hold_ls_socket
|
|
|
|
return should_hold_ls_socket(
|
|
n_us_subscribed=self._n_us_subscribed(),
|
|
now=now,
|
|
)
|
|
|
|
def _session_guard_loop(self) -> None:
|
|
"""hold 창 이탈 시 소켓 능동 종료 — oauth 재발급 없음."""
|
|
from kis_trader.utils.ls_ws_session_windows import (
|
|
in_us_ws_hold_window,
|
|
session_guard_interval_sec,
|
|
)
|
|
|
|
while self._running:
|
|
try:
|
|
time.sleep(session_guard_interval_sec())
|
|
except Exception:
|
|
time.sleep(15.0)
|
|
if not self._running:
|
|
break
|
|
if not get_env_bool("LS_WS_SESSION_HOLD_ENABLED", True):
|
|
continue
|
|
if not self._opened.is_set():
|
|
continue
|
|
if self._should_hold_socket():
|
|
continue
|
|
why = "해외hold외" if not in_us_ws_hold_window() else "KR hold외"
|
|
self._force_close_socket("%s → 소켓 양보(토큰 유지)" % why)
|
|
|
|
def _in_kr_watchdog_wallclock(self, now: Optional[datetime] = None) -> bool:
|
|
"""국장 틱 silence 워치독용 정규장 벽시계 (기본 09:00~15:25, 월~금)."""
|
|
n = now or datetime.now()
|
|
if n.weekday() >= 5:
|
|
return False
|
|
hhmm = n.hour * 100 + n.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 _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, now: Optional[datetime] = None) -> bool:
|
|
"""틱 silence 강제재연결을 허용할 세션인가 (VI·장외 오탐 방지).
|
|
|
|
- ``LS_WS_WATCHDOG_SESSION_GATE=false`` 이면 항상 True(레거시).
|
|
- **벽시계 우선**: sticky JIF ``jstatus=21`` 만으로 장외 True 금지.
|
|
- 국장 구독: 정규창(기본 09:00~15:25) + 당일 마감 JIF/close 상태면 차단.
|
|
- 해외 구독: ``LS_WS_US_HOLD_*`` 창에서만.
|
|
"""
|
|
if not get_env_bool("LS_WS_WATCHDOG_SESSION_GATE", True):
|
|
return True
|
|
now = now or datetime.now()
|
|
ymd = now.strftime("%Y%m%d")
|
|
with self._sub_lock:
|
|
n_kr = len(self._subscribed)
|
|
n_us = len(self._us_subscribed)
|
|
kr_ok = False
|
|
if n_kr > 0 and self._in_kr_watchdog_wallclock(now):
|
|
close_set = _parse_jif_status_set(
|
|
"LS_AFR_JIF_CLOSE_STATUSES", _JIF_AFR_CLOSE_DEFAULT,
|
|
)
|
|
with self._jif_lock:
|
|
st = str(self._jstatus or "").strip()
|
|
closed = str(self._kr_afr_closed_ymd or "")
|
|
# 벽시계 안이면 기본 OK. sticky JIF 21 로 장외 연장 안 함.
|
|
# 당일 마감 JIF/close status 만 추가 차단.
|
|
if closed == ymd:
|
|
kr_ok = False
|
|
elif st and st in close_set:
|
|
kr_ok = False
|
|
else:
|
|
kr_ok = True
|
|
us_ok = False
|
|
if n_us > 0:
|
|
from kis_trader.utils.ls_ws_session_windows import in_us_ws_hold_window
|
|
|
|
us_ok = in_us_ws_hold_window(now)
|
|
return bool(kr_ok or us_ok)
|
|
|
|
def is_kr_afr_session_open(self) -> bool:
|
|
"""국장 LS 조건 AFR(t1860) 허용 세션인가.
|
|
|
|
해외 밤장과 무관. **장전 준비** + 마감 후 차단.
|
|
|
|
- 평일 ``LS_AFR_SESSION_START_HM``(기본 07:00)~END(15:30)
|
|
- **장전**: JIF 22~25 오기 전에도 START~``OPEN_DEADLINE``(기본 09:30) 전은 허용
|
|
→ AFR/스냅샷 미리 준비 (09:00 jstatus=21 기다리면 늦음)
|
|
- 개장 JIF(21~25) → sticky ON / opened_ymd
|
|
- 마감 JIF(30·31·41~44) → closed_ymd → 당일 재시도 금지
|
|
- DEADLINE 지나도 개장 JIF 없으면 휴장으로 보고 OFF (연타 방지)
|
|
"""
|
|
now = datetime.now()
|
|
if now.weekday() >= 5:
|
|
return False
|
|
hhmm = now.hour * 100 + now.minute
|
|
start = int(get_env_int("LS_AFR_SESSION_START_HM", 700) or 700)
|
|
end = int(get_env_int("LS_AFR_SESSION_END_HM", 1530) or 1530)
|
|
deadline = int(get_env_int("LS_AFR_OPEN_DEADLINE_HM", 930) or 930)
|
|
if not (start <= hhmm <= end):
|
|
return False
|
|
ymd = now.strftime("%Y%m%d")
|
|
with self._jif_lock:
|
|
live = bool(self._kr_afr_session_live)
|
|
opened = str(self._kr_afr_opened_ymd or "")
|
|
closed = str(self._kr_afr_closed_ymd or "")
|
|
# 당일 마감 후에는 상관없음(재시도 불필요)
|
|
if closed == ymd:
|
|
return False
|
|
# 당일 개장 신호 있으면 장중 유지 (jstatus 66/70 덮어쓰기 무시)
|
|
if live or opened == ymd:
|
|
return True
|
|
# 장전 준비 구간 — sticky 없어도 허용
|
|
if hhmm < deadline:
|
|
return True
|
|
# 장중 재기동: JIF sticky 유실(재시작 직후 jstatus 없음).
|
|
# 구로직은 DEADLINE 후 sticky 없으면 휴장으로 OFF → AFR 영구 0 + 주기 t1859
|
|
# 끄면 LS 유니버스 동결. 거래일·미마감이면 벽시계 장중 허용.
|
|
try:
|
|
from kis_trader.utils.kr_trading_day import is_kr_trading_day
|
|
|
|
if is_kr_trading_day(now.date()):
|
|
return True
|
|
except Exception:
|
|
pass
|
|
return False
|
|
|
|
def get_jif_snapshot(self) -> Dict[str, Any]:
|
|
"""진단용 — jangubun/jstatus/AFR sticky."""
|
|
with self._jif_lock:
|
|
return {
|
|
"jangubun": self._jangubun,
|
|
"jstatus": self._jstatus,
|
|
"jif_age_sec": (
|
|
(time.monotonic() - self._jif_mono) if self._jif_mono > 0 else None
|
|
),
|
|
"kr_afr_session_live": bool(self._kr_afr_session_live),
|
|
"kr_afr_session_src": self._kr_afr_session_src,
|
|
"kr_afr_opened_ymd": self._kr_afr_opened_ymd,
|
|
"kr_afr_closed_ymd": self._kr_afr_closed_ymd,
|
|
}
|
|
|
|
def _on_jif(self, body: Dict[str, Any]) -> None:
|
|
jangubun = str(body.get("jangubun") or "").strip()
|
|
jstatus = str(body.get("jstatus") or "").strip()
|
|
open_set = _parse_jif_status_set(
|
|
"LS_AFR_JIF_OPEN_STATUSES", _JIF_AFR_OPEN_DEFAULT,
|
|
)
|
|
close_set = _parse_jif_status_set(
|
|
"LS_AFR_JIF_CLOSE_STATUSES", _JIF_AFR_CLOSE_DEFAULT,
|
|
)
|
|
sticky_changed = False
|
|
ymd = datetime.now().strftime("%Y%m%d")
|
|
with self._jif_lock:
|
|
self._jangubun = jangubun
|
|
self._jstatus = jstatus
|
|
self._jif_mono = time.monotonic()
|
|
prev = bool(self._kr_afr_session_live)
|
|
if jstatus in open_set:
|
|
self._kr_afr_session_live = True
|
|
self._kr_afr_opened_ymd = ymd
|
|
# 다음날 개장 대비 — 당일 마감 플래그는 개장 시 클리어
|
|
if self._kr_afr_closed_ymd == ymd:
|
|
self._kr_afr_closed_ymd = ""
|
|
self._kr_afr_session_src = f"open:{jangubun}/{jstatus}"
|
|
elif jstatus in close_set:
|
|
self._kr_afr_session_live = False
|
|
self._kr_afr_closed_ymd = ymd
|
|
self._kr_afr_session_src = f"close:{jangubun}/{jstatus}"
|
|
sticky_changed = prev != bool(self._kr_afr_session_live)
|
|
live = bool(self._kr_afr_session_live)
|
|
logger.info(
|
|
"LS JIF 장운영 jangubun=%s jstatus=%s afr_live=%s%s",
|
|
jangubun, jstatus, live,
|
|
" (sticky변경)" if sticky_changed else "",
|
|
)
|
|
|
|
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 갱신 + (옵션) interval 모드 DB 저장.
|
|
|
|
tick 모드(기본)는 체결 콜백에서 틱과 1:1 저장하므로 여기선 DB INSERT 안 함.
|
|
"""
|
|
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
|
|
mode = (
|
|
get_env_from_db("LS_WS_ORDERBOOK_SAVE_MODE", "tick") or "tick"
|
|
).strip().lower()
|
|
# tick / on_tick / tick_sync → 체결 경로에서만 저장 (여기는 RAM만)
|
|
if mode in ("tick", "on_tick", "tick_sync", "sync"):
|
|
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 active and get_env_bool("OPS_ALERT_VI_ENABLED", True):
|
|
try:
|
|
from kis_trader.utils.ops_alert import ops_alert
|
|
ops_alert(
|
|
f"vi_{code}",
|
|
f"VI 발동 {code}",
|
|
detail=(
|
|
f"tr={tr_cd} gubun={vi_g} krx={krx_g or '-'} "
|
|
f"nxt={nxt_g or '-'} trg={trg}"
|
|
),
|
|
level="warn",
|
|
session_only=False,
|
|
)
|
|
except Exception:
|
|
pass
|
|
|
|
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,
|
|
)
|
|
try:
|
|
from kis_trader.utils.ops_alert import ops_alert
|
|
ops_alert(
|
|
"ws_tick_silence",
|
|
"LS 시세 WS 틱 공백 → 강제 재연결",
|
|
detail=(
|
|
f"silence={int(now - self._last_tick_mono)}s "
|
|
f"subs={n_sub} streak={self._watchdog_empty_streak} jstatus={jst}"
|
|
),
|
|
level="critical",
|
|
)
|
|
except Exception:
|
|
pass
|
|
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:
|
|
# ── hold 외 → 소켓 양보 후 다음 국내/해외 창까지 대기 (재발급 없음) ──
|
|
if not self._should_hold_socket():
|
|
if self._opened.is_set() or self._ws is not None:
|
|
self._force_close_socket("hold 외 — 루프 대기 전 양보")
|
|
from kis_trader.utils.ls_ws_session_windows import (
|
|
seconds_until_ls_socket_open,
|
|
)
|
|
|
|
wait_sec = float(
|
|
seconds_until_ls_socket_open(
|
|
n_us_subscribed=self._n_us_subscribed(),
|
|
)
|
|
)
|
|
logger.info(
|
|
"🌙 LS WS hold 외 — 재연결 중지, 다음 hold까지 %.0f분 대기 "
|
|
"(토큰 캐시 유지·재발급 없음)",
|
|
wait_sec / 60.0,
|
|
)
|
|
self._opened.clear()
|
|
self._set_recovering(True)
|
|
backoff = 1.0
|
|
self._watchdog_empty_streak = 0
|
|
for _ in range(int(wait_sec // 60)):
|
|
if not self._running:
|
|
return
|
|
time.sleep(60)
|
|
time.sleep(wait_sec % 60)
|
|
continue
|
|
|
|
try:
|
|
self._opened.clear()
|
|
self._set_recovering(True)
|
|
# 만료/임박만 갱신 — force 없음 (세션 전환·재연결이 공용 토큰 무효화 금지)
|
|
try:
|
|
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)
|
|
continue
|
|
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
|
|
# hold 이탈로 끊긴 경우 즉시 대기 분기로 (불필요 재발급·재연결 폭주 방지)
|
|
if not self._should_hold_socket():
|
|
continue
|
|
time.sleep(min(backoff, 60.0))
|
|
backoff = min(backoff * 2, 60.0)
|
|
|
|
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)
|