ls증권 히스토리 구독 넣음

This commit is contained in:
Your Name
2026-07-30 18:05:07 +09:00
parent 61bec4bd1d
commit 67eab24603
1593 changed files with 135733 additions and 1232 deletions

View File

@@ -0,0 +1,238 @@
"""
kis_trader/network/ls_chart.py — LS 분봉 REST (t8412)
==============================================================================
``ls_condition`` 전략 갭보정용. 주문 경로와 무관.
TR: t8412 주식차트(N분) — POST /stock/chart — 초당 1건.
"""
from __future__ import annotations
import json
import threading
import time
from datetime import datetime
from typing import Any, Dict, List, Optional, Tuple
import pandas as pd
from ..utils.env import get_env_bool, get_env_float, get_env_from_db, get_env_int
from ..utils.logger import get_logger
from ..utils.request_handler import SafeRequest
from ..ws.ls_ws import LS_REST_BASE, fetch_ls_access_token
logger = get_logger("kis_trader.ls_chart")
CHART_URL = f"{LS_REST_BASE}/stock/chart"
def ls_datetime_to_candle_time(dt_s: str) -> str:
"""``YYYY-MM-DD HH:MM:00`` → ``YYYYMMDDHHMM``."""
s = (dt_s or "").strip()
if len(s) >= 16 and s[4] == "-" and s[10] == " ":
return s[0:4] + s[5:7] + s[8:10] + s[11:13] + s[14:16]
digits = "".join(ch for ch in s if ch.isdigit())
return digits[:12]
def candle_time_to_ls_datetime(ct: str) -> str:
"""``YYYYMMDDHHMM`` → ``YYYY-MM-DD HH:MM:00``."""
d = "".join(ch for ch in (ct or "") if ch.isdigit())[:12]
if len(d) < 12:
return ""
return f"{d[0:4]}-{d[4:6]}-{d[6:8]} {d[8:10]}:{d[10:12]}:00"
def load_ls_real_creds() -> Tuple[str, str]:
"""실키만 (시세 REST). 빈 값이면 ("","")."""
try:
from database import TradeDB
db = TradeDB()
row = db.conn.execute(
"SELECT LS_APP_KEY_REAL, LS_APP_SECRET_REAL FROM env_config "
"ORDER BY id DESC LIMIT 1"
).fetchone()
if not row:
return "", ""
r = dict(row)
return (
(r.get("LS_APP_KEY_REAL") or "").strip(),
(r.get("LS_APP_SECRET_REAL") or "").strip(),
)
except Exception as e:
logger.debug("LS 키 로드 실패: %s", e)
return "", ""
class LSChartClient(SafeRequest):
"""t8412 SafeRequest — min_interval 기본 1.05s (서버 초당 1건)."""
def __init__(self) -> None:
gap = max(1.0, float(get_env_float("LS_GAP_FILL_INTERVAL_SEC", 1.05) or 1.05))
super().__init__(
min_interval_sec=gap,
max_retries=max(1, get_env_int("LS_GAP_FILL_MAX_RETRIES", 3)),
timeout_sec=max(5.0, float(get_env_float("LS_GAP_FILL_TIMEOUT_SEC", 20.0) or 20.0)),
)
self._token = ""
self._token_at = 0.0
self._tok_lock = threading.Lock()
self._app_key = ""
self._app_secret = ""
def _ensure_creds(self) -> bool:
if self._app_key and self._app_secret:
return True
k, s = load_ls_real_creds()
self._app_key, self._app_secret = k, s
return bool(k and s)
def _ensure_token(self) -> str:
with self._tok_lock:
if self._token and (time.time() - self._token_at) < 12 * 3600:
return self._token
if not self._ensure_creds():
raise RuntimeError("LS AppKey/Secret 미설정")
self._token = fetch_ls_access_token(self._app_key, self._app_secret)
self._token_at = time.time()
return self._token
def fetch_minute_bars(
self,
code: str,
*,
ncnt: int = 1,
qrycnt: Optional[int] = None,
edate: str = "99999999",
) -> pd.DataFrame:
"""
t8412 → DataFrame columns: time(YYYYMMDDHHMM), open, high, low, close, volume
오래된→최신 정렬.
"""
if not get_env_bool("LS_GAP_FILL_ENABLED", True):
return pd.DataFrame()
code = (code or "").strip()
if not (code.isdigit() and len(code) == 6):
return pd.DataFrame()
limit = int(
qrycnt
if qrycnt is not None
else get_env_int("LS_GAP_FILL_LIMIT", 150)
)
limit = max(1, min(500, limit))
ncnt = max(1, int(ncnt or 1))
token = self._ensure_token()
body = {
"t8412InBlock": {
"shcode": code,
"ncnt": ncnt,
"qrycnt": limit,
"nday": "0",
"sdate": "",
"stime": "",
"edate": edate or "99999999",
"etime": "",
"cts_date": "",
"cts_time": "",
"comp_yn": "N",
}
}
headers = {
"content-type": "application/json; charset=UTF-8",
"authorization": f"Bearer {token}",
"tr_cd": "t8412",
"tr_cont": "N",
"tr_cont_key": "",
"mac_address": "",
}
resp = self.request("POST", CHART_URL, headers=headers, data=json.dumps(body))
if resp is None:
return pd.DataFrame()
try:
data = resp.json() if resp.text else {}
except Exception:
logger.warning("t8412 JSON 파싱 실패 code=%s", code)
return pd.DataFrame()
rsp = str(data.get("rsp_cd") or "")
if rsp and rsp not in ("00000", "0"):
# 09000 등 자료없음은 빈 DF
logger.debug(
"t8412 rsp_cd=%s msg=%s code=%s",
rsp, data.get("rsp_msg"), code,
)
if rsp != "00000":
return pd.DataFrame()
rows_out = data.get("t8412OutBlock1") or []
if isinstance(rows_out, dict):
rows_out = [rows_out]
parsed: List[Dict[str, Any]] = []
for r in rows_out:
if not isinstance(r, dict):
continue
date = "".join(ch for ch in str(r.get("date") or "") if ch.isdigit())
tim = "".join(ch for ch in str(r.get("time") or "") if ch.isdigit())
if len(tim) >= 6:
tim = tim[:4] # HHMMSS → HHMM for candle_time
elif len(tim) == 4:
pass
else:
continue
if len(date) < 8:
continue
ctime = date[:8] + tim[:4]
try:
close = float(r.get("close") or 0)
except (TypeError, ValueError):
continue
if close <= 0:
continue
try:
o = float(r.get("open") or close)
h = float(r.get("high") or close)
l = float(r.get("low") or close)
v = float(r.get("jdiff_vol") or r.get("volume") or 0)
except (TypeError, ValueError):
o = h = l = close
v = 0.0
parsed.append({
"time": ctime,
"open": o,
"high": h,
"low": l,
"close": close,
"volume": v,
})
if not parsed:
return pd.DataFrame()
df = pd.DataFrame(parsed)
df = df.drop_duplicates(subset=["time"], keep="last")
df = df.sort_values("time").reset_index(drop=True)
return df
_client: Optional[LSChartClient] = None
_client_lock = threading.Lock()
def get_ls_chart_client() -> LSChartClient:
global _client
with _client_lock:
if _client is None:
_client = LSChartClient()
return _client
def fetch_ls_minute_chart_df(
code: str,
*,
ncnt: int = 1,
qrycnt: Optional[int] = None,
) -> pd.DataFrame:
"""갭보정 진입점 — DataFrame(time/open/high/low/close/volume)."""
try:
return get_ls_chart_client().fetch_minute_bars(
code, ncnt=ncnt, qrycnt=qrycnt,
)
except Exception as e:
logger.warning("LS t8412 실패 %s: %s", code, e)
return pd.DataFrame()

View File

@@ -0,0 +1,777 @@
"""
kis_trader/network/ls_condition_manager.py — LS 서버저장조건(AFR)
==============================================================================
두 가지 용도 (실매 시세 get_price 와 무관):
1) ``LS_CONDITION_HISTORY_ENABLED=true``
→ ``ls_candidates_history`` 적재 (실매 ``*_UNIVERSE_SOURCE`` 와 **분리**)
2) ``{SID}_UNIVERSE_SOURCE=ls_condition``
→ BaseStrategy 유니버스 소비 (opt-in)
흐름: t1866 → t1859 → t1860/AFR → RAM + (옵션) 이력 INSERT
+ (옵션) LS WS owner=condition 구독 diff
운영 중 LS HTS 에서 조건 CRUD(삭제·이름변경·인덱스 재배열) 시:
``LS_CONDITION_REMAP_SEC`` 주기로 t1866 이름→query_index 재매핑,
바뀌면 구 AFR UNREG + t1860 D → t1859 → t1860 E → AFR REG (재시작 불필요).
``LS_CONDITION_SNAPSHOT_REFRESH_SEC`` 주기로 동일 인덱스라도 t1859 스냅샷
재동기화 (AFR 는 델타만이라 장중 sticky/빈 RAM 보정).
"""
from __future__ import annotations
import importlib
import json
import threading
import time
from datetime import datetime
from typing import Any, Callable, Dict, List, Optional, Set, Tuple
from .condition_manager import ConditionSearchManager
from ..utils.env import get_env_bool, get_env_float, get_env_from_db, get_env_int
from ..utils.logger import get_logger
logger = get_logger("kis_trader.lscond")
# LS query_name → 전략 id (키움 CONDITION_*_KIWOOM_NAME 동명 정책)
DEFAULT_SID_TO_LS_NAME: Dict[str, str] = {
"BREAKOUT": "breakout",
"SHORT": "tail",
"MOMENTUM": "momentum",
"SCALP": "scalp_re",
}
_LS_HISTORY_DDL = """
CREATE TABLE IF NOT EXISTS ls_candidates_history (
id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
slot_key VARCHAR(12) NOT NULL,
scan_time VARCHAR(30) NOT NULL,
code VARCHAR(20) NOT NULL,
name VARCHAR(100) NOT NULL DEFAULT '',
score DOUBLE NOT NULL DEFAULT 0,
price DOUBLE NOT NULL DEFAULT 0,
market CHAR(1) DEFAULT 'Q',
sector VARCHAR(100) NULL,
theme VARCHAR(100) NULL,
strategy_id VARCHAR(32) NOT NULL DEFAULT '',
event_time VARCHAR(30) NOT NULL DEFAULT '',
query_name VARCHAR(64) NOT NULL DEFAULT '',
query_index VARCHAR(32) NOT NULL DEFAULT '',
source VARCHAR(16) NOT NULL DEFAULT 'ls_afr',
INDEX idx_ls_cand_slot (slot_key),
INDEX idx_ls_cand_sid_evt (strategy_id, event_time),
INDEX idx_ls_cand_code (code),
INDEX idx_ls_cand_scan (scan_time)
) CHARACTER SET utf8mb4
"""
def _load_ls_rt():
"""단독 테스트 모듈의 REST/WS 헬퍼 재사용 (실매 시세 경로와 분리)."""
return importlib.import_module("_test_ls_condition_realtime")
def insert_ls_candidates_snapshot(
db,
*,
strategy_id: str,
query_name: str,
query_index: str,
items: List[Dict[str, Any]],
event_time: Optional[str] = None,
source: str = "ls_afr",
) -> int:
"""풀 유니버스 스냅샷 → ls_candidates_history (동일 event_time 재기록)."""
if db is None:
return 0
try:
db.conn.execute(_LS_HISTORY_DDL)
except Exception:
pass
et = event_time or datetime.now().strftime("%Y-%m-%d %H:%M:%S")
sk = et.replace("-", "").replace(":", "").replace(" ", "")[:12]
sid = (strategy_id or "").strip().upper()
qn = (query_name or "")[:64]
qi = (query_index or "")[:32]
inserted = 0
with db.conn:
db.conn.execute(
"DELETE FROM ls_candidates_history "
"WHERE strategy_id=%s AND event_time=%s",
(sid, et),
)
for it in items:
code = str(it.get("code") or "").strip()
if not code:
continue
name = str(it.get("name") or code)[:100]
try:
price = float(it.get("price") or 0)
except (TypeError, ValueError):
price = 0.0
db.conn.execute(
"""
INSERT INTO ls_candidates_history
(slot_key, scan_time, code, name, score, price,
market, sector, theme, strategy_id, event_time,
query_name, query_index, source)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
""",
(
sk, et, code, name, 0.0, price,
"Q", "", "", sid, et, qn, qi, source[:16],
),
)
inserted += 1
return inserted
class _LsConditionState:
"""AFR 편입/이탈 누적 (수집기 ConditionState 와 동일)."""
def __init__(
self,
*,
strategy_id: str,
query_name: str,
query_index: str,
alert_num: str = "",
) -> None:
self.strategy_id = strategy_id
self.query_name = query_name
self.query_index = query_index
self.alert_num = alert_num
self.codes: Dict[str, Dict[str, Any]] = {}
self.lock = threading.Lock()
def set_from_snapshot(self, rows: List[Dict[str, Any]]) -> None:
with self.lock:
self.codes.clear()
for r in rows:
code = str(r.get("shcode") or r.get("code") or "").strip()
if not code:
continue
self.codes[code] = {
"code": code,
"name": str(r.get("hname") or r.get("name") or code)[:100],
"price": r.get("price") or 0,
}
def apply_afr(self, body: Dict[str, Any]) -> tuple[str, Optional[str]]:
job = str(body.get("gsJobFlag") or "").strip().upper()
code = str(body.get("gsCode") or body.get("shcode") or "").strip()
if not code:
return job, None
name = str(body.get("gsHname") or body.get("hname") or code)[:100]
try:
price = float(str(body.get("gsPrice") or body.get("price") or 0).replace(",", ""))
except (TypeError, ValueError):
price = 0.0
with self.lock:
if job in ("N", "R"):
self.codes[code] = {"code": code, "name": name, "price": price}
elif job == "O":
self.codes.pop(code, None)
return job, code
def items(self) -> List[Dict[str, Any]]:
with self.lock:
return [dict(v) for v in self.codes.values()]
def as_rows(self) -> List[Dict[str, Any]]:
return [{"code": it["code"], "name": it.get("name", it["code"])} for it in self.items()]
class LsConditionSearchManager(ConditionSearchManager):
"""LS AFR 실시간 조건검색 — ConditionSearchManager 와 동일 public API."""
def __init__(
self,
*,
configs: Optional[List[Dict]] = None,
db=None,
on_change=None,
user_id: str = "",
use_mock: bool = False,
):
super().__init__(
client=None,
user_id=(user_id or "LS").strip() or "LS",
configs=configs,
db=db,
on_change=on_change,
)
self._use_mock = bool(use_mock)
self._token: Optional[str] = None
self._app_key: str = ""
self._app_secret: str = ""
self._rt = None
self._by_alert: Dict[str, _LsConditionState] = {}
self._states_by_sid: Dict[str, _LsConditionState] = {}
self._watcher: Any = None
self._flush_sec = float(get_env_int("LS_CONDITION_FLUSH_SEC", 60))
# 서버 조건 CRUD 후 query_index/alert 재부착 (재시작 대체)
self._remap_sec = float(get_env_int("LS_CONDITION_REMAP_SEC", 60))
# AFR 델타만 보정 — 동일 인덱스라도 t1859 로 RAM sticky 동기화
self._snapshot_refresh_sec = float(
get_env_int("LS_CONDITION_SNAPSHOT_REFRESH_SEC", 120)
)
self._tr_gap_sec = float(get_env_float("LS_CONDITION_TR_GAP_SEC", 1.1))
self._flush_thread: Optional[threading.Thread] = None
self._maint_lock = threading.RLock()
self._last_remap_mono = 0.0
self._last_snap_refresh_mono = 0.0
self._last_missing_warn: Dict[str, float] = {}
self._ready = threading.Event()
self._start_ok = False
# LS 조건 유니버스 합집합 → LS WS sync 등 (main 이 등록)
self._universe_codes_listener: Optional[Callable[[Set[str]], None]] = None
# 이력: LS_CONDITION_HISTORY_ENABLED 우선, 없으면 기존 CONDITION/UNIVERSE 저장 토글
self.history_enabled = get_env_bool(
"LS_CONDITION_HISTORY_ENABLED",
get_env_bool(
"CONDITION_HISTORY_SAVE",
get_env_bool("UNIVERSE_HISTORY_SAVE", True),
),
)
def set_universe_codes_listener(
self, fn: Optional[Callable[[Set[str]], None]],
) -> None:
"""전 전략 LS 유니버스 코드 합집합이 바뀔 때 호출 (WS condition 구독 diff)."""
self._universe_codes_listener = fn
def universe_code_union(self, *, plain6_only: Optional[bool] = None) -> Set[str]:
"""RAM 상 전 전략 조건 종목 합집합."""
if plain6_only is None:
plain6_only = get_env_bool("LS_WS_CONDITION_PLAIN6_ONLY", True)
out: Set[str] = set()
for st in list(self._states_by_sid.values()):
for it in st.items():
code = str(it.get("code") or "").strip()
if not code:
continue
if plain6_only and not (code.isdigit() and len(code) == 6):
continue
out.add(code)
return out
def emit_universe_union(self) -> None:
fn = self._universe_codes_listener
if fn is None:
return
try:
fn(self.universe_code_union())
except Exception as e:
logger.debug("LS universe listener: %s", e)
def start(self) -> bool:
if not self._configs:
logger.info("LS 조건검색 configs 비어 있음 → 매니저 비활성")
return False
if not get_env_bool("LS_CONDITION_MANAGER_ENABLED", True):
logger.info("LS_CONDITION_MANAGER_ENABLED=false → 매니저 비활성")
return False
try:
self._rt = _load_ls_rt()
except Exception as e:
logger.warning("LS 조건 헬퍼 로드 실패: %s", e)
return False
user_id = (self.user_id or "").strip()
if user_id in ("", "LS"):
user_id = (get_env_from_db("LS_USER_ID", "") or "").strip()
if not user_id:
logger.warning("LS_USER_ID 미설정 → LS 조건검색 매니저 비활성")
return False
self.user_id = user_id
try:
app_key, app_secret = self._rt.load_ls_creds(use_mock=self._use_mock)
except Exception as e:
logger.warning("LS 앱키 로드 실패: %s", e)
return False
if not (app_key and app_secret):
logger.warning("LS 앱키 없음 → 조건검색 매니저 비활성")
return False
self._app_key = app_key
self._app_secret = app_secret
if not self._ensure_token(force=True):
return False
logger.warning(
"⚠️ LS 조건 매니저 ON — 단독 collect_ls_condition_history 와 "
"ls_candidates_history 이중 기록 가능. 수집기는 중지 권고."
)
# by_alert 는 watcher 와 동일 dict 참조 — rematch 시 in-place 갱신
self._by_alert = {}
self._states_by_sid = {}
self._running = True
# 초기 매핑 (실패해도 rematch 대기 모드로 기동 — 재시작 없이 조건 복구)
try:
self._remap_all(force_snapshot=True, force_afr=True)
except Exception as e:
logger.error("LS 초기 rematch 실패: %s", e)
# AfrHistoryWatcher: 빈 by_alert 로도 기동 → 이후 rematch 가 REG
if not self._start_afr_watcher():
# WS 실패여도 t1859 스냅샷·rematch 는 유지 (유니버스 sticky)
logger.warning(
"⚠️ LS AFR WS 미기동 → 스냅샷/rematch 전용 "
"(실시간 편입·이탈 없음). rematch 로 재시도."
)
self._flush_thread = threading.Thread(
target=self._maint_loop, daemon=True, name="LsCondMaint",
)
self._flush_thread.start()
self._start_ok = True
self._ready.set()
n_map = len(self._states_by_sid)
n_afr = len(self._by_alert)
if n_map <= 0:
logger.warning(
"⚠️ LS 조건 매핑 0건 → rematch 대기 "
"(remap=%ss snap=%ss). 서버에 조건명 생기면 자동 부착.",
int(self._remap_sec), int(self._snapshot_refresh_sec),
)
elif n_afr <= 0:
logger.warning(
"⚠️ LS AFR alert 0건 (매핑 %d) → 스냅샷/rematch 전용. "
"장중·조건복구 시 자동 AFR 재등록.",
n_map,
)
else:
logger.info(
"✅ LS 조건검색 매니저 시작 (매핑=%d AFR=%d flush=%ss remap=%ss snap=%ss)",
n_map, n_afr,
int(self._flush_sec), int(self._remap_sec),
int(self._snapshot_refresh_sec),
)
return True
def stop(self) -> None:
self._running = False
with self._maint_lock:
for st in list(self._states_by_sid.values()):
try:
self._teardown_afr(st, clear_ram=False)
except Exception:
pass
w = self._watcher
self._watcher = None
if w is not None:
try:
w.stop()
except Exception:
pass
logger.info("⏹ LS 조건검색 매니저 정지")
def _tr_sleep(self) -> None:
time.sleep(max(0.2, self._tr_gap_sec))
def _desired_bindings(self) -> List[Tuple[str, str]]:
"""configs → (strategy_id, query_name) 목록."""
out: List[Tuple[str, str]] = []
for cfg in self._configs or []:
sid = str(cfg.get("strategy_id") or "").strip().upper()
nm = (
str(cfg.get("name") or "").strip()
or DEFAULT_SID_TO_LS_NAME.get(sid, "")
)
if not sid or not nm:
logger.warning("LS 조건 설정 스킵 (sid/name 부족): %s", cfg)
continue
out.append((sid, nm))
return out
def _ensure_token(self, *, force: bool = False) -> bool:
if self._token and not force:
return True
if not (self._app_key and self._app_secret and self._rt):
return False
try:
tok = self._rt.fetch_access_token(self._app_key, self._app_secret)
except Exception as e:
logger.warning("LS 토큰 발급 실패: %s", e)
return False
if not tok:
logger.warning("LS 토큰 빈값")
return False
self._token = str(tok)
w = self._watcher
if w is not None:
try:
w.token = self._token
except Exception:
pass
return True
def _start_afr_watcher(self) -> bool:
if self._watcher is not None:
return True
if not self._token or self._rt is None:
return False
import importlib.util
from pathlib import Path
_mod_path = (
Path(__file__).resolve().parents[2]
/ "scripts"
/ "collect_ls_condition_history.py"
)
try:
spec = importlib.util.spec_from_file_location(
"collect_ls_condition_history_runtime", _mod_path,
)
if spec is None or spec.loader is None:
raise ImportError(f"spec 실패: {_mod_path}")
col = importlib.util.module_from_spec(spec)
spec.loader.exec_module(col)
AfrHistoryWatcher = col.AfrHistoryWatcher
except Exception as e:
logger.error("AfrHistoryWatcher import 실패: %s", e)
return False
ws_url = self._rt.LS_WS_MOCK if self._use_mock else self._rt.LS_WS_REAL
def on_change(st: Any) -> None:
try:
self._apply_result(st.strategy_id, st.as_rows())
self._save_ls_snapshot(st, source="ls_afr")
except Exception as e:
logger.warning("LS on_change: %s", e)
self._watcher = AfrHistoryWatcher(
self._token,
ws_url,
logger,
by_alert=self._by_alert,
on_change=on_change,
also_tick_codes=None,
)
self._watcher.start()
if not self._watcher.wait_open(20.0):
logger.error("LS AFR WS OPEN 타임아웃")
try:
self._watcher.stop()
except Exception:
pass
self._watcher = None
return False
# on_open 이 by_alert 전량 REG — 여기서 중복 REG 하지 않음
return True
def _warn_missing(self, sid: str, name: str) -> None:
now = time.monotonic()
last = float(self._last_missing_warn.get(sid) or 0.0)
# 동일 sid 미매칭은 5분마다 1회 (로그 폭주 방지)
gap = float(get_env_int("LS_CONDITION_MISSING_WARN_SEC", 300))
if now - last < max(30.0, gap):
return
self._last_missing_warn[sid] = now
logger.warning(
"⚠️ LS 조건명 미매칭 sid=%s name=%r — 서버 목록에 없음. "
"HTS에서 생성·이름복구 시 rematch(%ss)로 자동 부착",
sid, name, int(self._remap_sec),
)
def _teardown_afr(self, st: _LsConditionState, *, clear_ram: bool) -> None:
"""구 alert UNREG + t1860 D. clear_ram 시 유니버스 비움."""
alert = str(st.alert_num or "").strip()
qidx = str(st.query_index or "").strip()
w = self._watcher
if alert and w is not None:
try:
w.reg_afr(alert, tr_type="4")
except Exception as e:
logger.debug("AFR UNREG %s: %s", alert, e)
self._by_alert.pop(alert, None)
if alert and qidx and self._token and self._rt:
try:
self._rt.t1860_realtime(
self._token, qidx, flag="D", alert_num=alert, logger=logger,
)
self._tr_sleep()
except Exception as e:
logger.debug("t1860 D %s: %s", st.strategy_id, e)
st.alert_num = ""
if clear_ram:
st.set_from_snapshot([])
self._apply_result(st.strategy_id, [])
self._save_ls_snapshot(st, source="ls_remap_clear")
def _mount_snapshot_and_afr(
self,
st: _LsConditionState,
*,
do_snapshot: bool,
do_afr: bool,
) -> None:
"""t1859 → RAM, (옵션) t1860 E + AFR REG."""
if not self._token or self._rt is None:
return
if do_snapshot:
try:
snap = self._rt.t1859_snapshot(
self._token, st.query_index, logger=logger,
)
self._tr_sleep()
except Exception as e:
logger.warning("t1859 실패 %s: %s", st.strategy_id, e)
snap = []
st.set_from_snapshot(snap or [])
self._apply_result(st.strategy_id, st.as_rows())
self._save_ls_snapshot(st, source="ls_t1859")
n = len(st.as_rows())
if n <= 0:
logger.warning(
"⚠️ LS 스냅샷 0종목 sid=%s name=%s idx=%s "
"(AFR 델타만으로는 장중 sticky 불가 → 주기 t1859 대기)",
st.strategy_id, st.query_name, st.query_index,
)
else:
logger.info(
"📌 LS 스냅샷 sid=%s name=%s %d종목",
st.strategy_id, st.query_name, n,
)
if not do_afr:
return
# 기존 alert 있으면 먼저 정리
if str(st.alert_num or "").strip():
self._teardown_afr(st, clear_ram=False)
try:
ob = self._rt.t1860_realtime(
self._token, st.query_index, flag="E", alert_num="", logger=logger,
)
self._tr_sleep()
except Exception as e:
logger.error("t1860 예외 %s: %s", st.strategy_id, e)
return
if str(ob.get("sResultFlag") or "").strip() != "S":
logger.error(
"t1860 실패 sid=%s %s",
st.strategy_id, json.dumps(ob, ensure_ascii=False),
)
return
alert = str(ob.get("sAlertNum") or "").strip()
if (not alert) or (alert.strip("0") == ""):
logger.error(
"sAlertNum 무효 sid=%s alert=%r — AFR 스킵",
st.strategy_id, alert,
)
return
st.alert_num = alert
self._by_alert[alert] = st
logger.info(
"✅ LS AFR 등록 sid=%s name=%s alert=%s idx=%s",
st.strategy_id, st.query_name, alert, st.query_index,
)
# watcher 신규 기동 시 on_open 이 by_alert REG — 중복 전송 방지
started_fresh = self._watcher is None
if started_fresh:
self._start_afr_watcher()
w = self._watcher
if w is not None and not started_fresh:
try:
w.reg_afr(alert, tr_type="3")
except Exception as e:
logger.warning("AFR REG 실패 %s: %s", alert, e)
def _remap_all(
self,
*,
force_snapshot: bool = False,
force_afr: bool = False,
) -> None:
"""t1866 이름→index 재매핑. 변경 시 AFR 재부착 / 신규 부착 / 소실 시 해제."""
with self._maint_lock:
if not self._ensure_token(force=False):
return
assert self._rt is not None and self._token
try:
rows = self._rt.t1866_list_conditions(
self._token, self.user_id, logger=logger,
)
except Exception as e:
err_s = str(e).lower()
# 인증 만료 시에만 1회 재발급 (한도 존중)
if any(x in err_s for x in ("401", "403", "token", "auth", "unauthorized")):
logger.warning("t1866 인증성 오류 → 토큰 1회 재발급: %s", e)
if self._ensure_token(force=True):
try:
rows = self._rt.t1866_list_conditions(
self._token, self.user_id, logger=logger,
)
except Exception as e2:
logger.error("t1866 재시도 실패: %s", e2)
return
else:
return
else:
logger.error("t1866 실패: %s", e)
return
wanted = self._desired_bindings()
seen_sid: Set[str] = set()
for sid, nm in wanted:
seen_sid.add(sid)
hit = self._rt._resolve_query(rows, name=nm, query_index="")
if not hit or not hit.get("query_index"):
self._warn_missing(sid, nm)
st_old = self._states_by_sid.get(sid)
if st_old is not None:
logger.warning(
"🔄 LS 조건 소실 → 해제 sid=%s was=%s/%s",
sid, st_old.query_name, st_old.query_index,
)
self._teardown_afr(st_old, clear_ram=True)
self._states_by_sid.pop(sid, None)
continue
qidx = str(hit["query_index"])
qname = str(hit.get("query_name") or nm)
st = self._states_by_sid.get(sid)
if st is None:
st = _LsConditionState(
strategy_id=sid,
query_name=qname,
query_index=qidx,
)
self._states_by_sid[sid] = st
logger.info(
"📌 LS 신규 매핑 sid=%s name=%s query_index=%s",
sid, qname, qidx,
)
self._mount_snapshot_and_afr(
st, do_snapshot=True, do_afr=True,
)
continue
idx_changed = str(st.query_index) != qidx
name_changed = str(st.query_name) != qname
need_afr = force_afr or (not str(st.alert_num or "").strip())
if idx_changed or name_changed:
logger.warning(
"🔄 LS rematch sid=%s %s/%s%s/%s",
sid, st.query_name, st.query_index, qname, qidx,
)
self._teardown_afr(st, clear_ram=False)
st.query_name = qname
st.query_index = qidx
self._mount_snapshot_and_afr(
st, do_snapshot=True, do_afr=True,
)
elif need_afr:
logger.info(
"🔄 LS AFR 재등록 sid=%s name=%s (alert 없음/강제)",
sid, st.query_name,
)
self._mount_snapshot_and_afr(
st, do_snapshot=True, do_afr=True,
)
elif force_snapshot:
self._mount_snapshot_and_afr(
st, do_snapshot=True, do_afr=False,
)
# configs 에 없는 sid 정리 (설정 제거된 경우)
for sid in list(self._states_by_sid.keys()):
if sid in seen_sid:
continue
st = self._states_by_sid.pop(sid)
logger.warning("🔄 LS 설정 제거 → 해제 sid=%s", sid)
self._teardown_afr(st, clear_ram=True)
self._last_remap_mono = time.monotonic()
if force_snapshot:
self._last_snap_refresh_mono = self._last_remap_mono
def _refresh_snapshots_only(self) -> None:
"""인덱스 유지한 채 t1859 만 재동기화 (AFR sticky 보정)."""
with self._maint_lock:
if not self._ensure_token(force=False):
return
for st in list(self._states_by_sid.values()):
if not str(st.query_index or "").strip():
continue
try:
self._mount_snapshot_and_afr(
st, do_snapshot=True, do_afr=False,
)
except Exception as e:
logger.warning(
"LS snapshot refresh 실패 %s: %s", st.strategy_id, e,
)
self._last_snap_refresh_mono = time.monotonic()
def _maint_loop(self) -> None:
"""flush + rematch + t1859 sticky 주기 루프."""
# 기동 직후 즉시 rematch 하지 않음 (start 에서 1회 완료)
now0 = time.monotonic()
self._last_remap_mono = now0
self._last_snap_refresh_mono = now0
last_flush_mono = now0
while self._running:
tick = min(
max(5.0, self._flush_sec),
max(5.0, self._remap_sec),
max(5.0, self._snapshot_refresh_sec),
)
time.sleep(tick)
if not self._running:
return
now = time.monotonic()
try:
if now - self._last_remap_mono >= max(5.0, self._remap_sec):
self._remap_all(force_snapshot=False, force_afr=False)
now = time.monotonic()
if (
now - self._last_snap_refresh_mono
>= max(5.0, self._snapshot_refresh_sec)
):
self._refresh_snapshots_only()
now = time.monotonic()
if now - last_flush_mono >= max(5.0, self._flush_sec):
for st in list(self._states_by_sid.values()):
try:
self._save_ls_snapshot(st, source="ls_flush")
except Exception as e:
logger.debug(
"LS flush 실패 %s: %s", st.strategy_id, e,
)
last_flush_mono = time.monotonic()
except Exception as e:
logger.warning("LS maint 루프 예외: %s", e)
def _save_snapshot(
self,
strategy_id: str,
codes_ordered: List[str],
names: Dict[str, str],
) -> None:
"""부모는 target_candidates_history — LS 는 override 로 no-op 후 별도 저장."""
# _apply_result 가 호출하는 부모 경로를 차단 (키움 테이블 오염 금지)
return
def _save_ls_snapshot(self, st: _LsConditionState, *, source: str) -> None:
if self.history_enabled and self.db is not None:
n = insert_ls_candidates_snapshot(
self.db,
strategy_id=st.strategy_id,
query_name=st.query_name,
query_index=st.query_index,
items=st.items(),
source=source,
)
logger.debug(
"📼 [ls_history] %s src=%s %d종목",
st.strategy_id, source, n,
)
# 이력 OFF 여도 WS follow 를 위해 합집합 emit
self.emit_universe_union()

View File

@@ -0,0 +1,168 @@
"""
kis_trader/network/ls_ws_validator.py — KIS/키움 ↔ LS 시세 갭 검증
================================================================
``LS_WS_VALIDATION_ENABLED=true`` 일 때만 기동.
실매 시세 경로는 건드리지 않음 — 구독 동기화 + DB 비교 INSERT 만.
"""
from __future__ import annotations
import threading
import time
from typing import Optional, Set
from ..utils.env import get_env_float, get_env_int
from ..utils.logger import get_logger
logger = get_logger("kis_trader.ls_ws_validator")
class LSWSPriceValidator:
"""KIS(+키움) 구독 종목을 LS 에도 맞추고 가격 갭을 기록."""
def __init__(
self,
*,
ls_ws,
db,
kis_ws=None,
kiwoom_ws=None,
) -> None:
self.ls_ws = ls_ws
self.db = db
self.kis_ws = kis_ws
self.kiwoom_ws = kiwoom_ws
self._thread: Optional[threading.Thread] = None
self._running = False
self._last_warn_ts: dict = {}
def start(self) -> bool:
if self._thread and self._thread.is_alive():
return True
self._running = True
self._thread = threading.Thread(
target=self._loop, daemon=True, name="LSWSPriceValidator",
)
self._thread.start()
logger.info(
"✅ LS WS 갭 검증기 시작 (interval=%ds warn≥%.2f%%)",
self._interval_sec(),
self._warn_pct(),
)
return True
def stop(self) -> None:
self._running = False
def _interval_sec(self) -> int:
return max(1, get_env_int("LS_WS_VALIDATION_INTERVAL_SEC", 5))
def _warn_pct(self) -> float:
return max(0.0, get_env_float("LS_WS_VALIDATION_DIFF_WARN_PCT", 0.10))
def _codes_from(self, ws) -> Set[str]:
if ws is None:
return set()
try:
with ws._sub_lock: # type: ignore[attr-defined]
return set(ws._subscribed) # type: ignore[attr-defined]
except Exception:
return set()
def _sync_ls(self, target: Set[str]) -> None:
try:
with self.ls_ws._sub_lock:
current = set(self.ls_ws._subscribed)
# KR 6자리만 동기화 (US 는 영구구독에서 별도 붙일 수 있음)
kr_target = {c for c in target if c.isdigit() and len(c) == 6}
for code in kr_target - current:
self.ls_ws.subscribe(code)
for code in current - kr_target:
self.ls_ws.unsubscribe(code)
except Exception as e:
logger.debug("LS 구독 동기화 실패: %s", e)
def _parse_price(self, data, key: str = "stck_prpr") -> Optional[float]:
if not data:
return None
raw = data.get(key)
if raw is None and "_price_f" in data:
raw = data.get("_price_f")
try:
v = float(str(raw).replace(",", ""))
return v if v > 0 else None
except (TypeError, ValueError):
return None
def _parse_age_ms(self, data) -> Optional[int]:
if not data:
return None
try:
return int(data.get("_age_ms"))
except (TypeError, ValueError):
return None
def _loop(self) -> None:
time.sleep(15)
while self._running:
try:
self._tick()
except Exception as e:
logger.warning("LS 검증기 tick 예외: %s", e)
time.sleep(self._interval_sec())
def _tick(self) -> None:
kis_codes = self._codes_from(self.kis_ws)
kw_codes = self._codes_from(self.kiwoom_ws)
codes = kis_codes | kw_codes
if not codes:
return
self._sync_ls(codes)
if not self.ls_ws.is_connected():
return
warn_pct = self._warn_pct()
n = 0
for code in codes:
if not (code.isdigit() and len(code) == 6):
continue
kis_data = (
self.kis_ws.get_price(code, max_age_sec=10.0)
if self.kis_ws is not None
else None
)
kw_data = (
self.kiwoom_ws.get_price(code, max_age_sec=10.0)
if self.kiwoom_ws is not None and self.kiwoom_ws.is_connected()
else None
)
ls_data = self.ls_ws.get_price(code, max_age_sec=10.0)
kis_p = self._parse_price(kis_data)
kw_p = self._parse_price(kw_data)
ls_p = self._parse_price(ls_data)
if ls_p is None and kis_p is None and kw_p is None:
continue
self.db.insert_ws_price_validation_ls(
code=code,
kis_price=kis_p,
kiwoom_price=kw_p,
ls_price=ls_p,
kis_age_ms=self._parse_age_ms(kis_data),
kiwoom_age_ms=self._parse_age_ms(kw_data),
ls_age_ms=self._parse_age_ms(ls_data),
)
n += 1
if kis_p not in (None, 0) and ls_p is not None:
diff = (ls_p - kis_p) / kis_p * 100.0
if abs(diff) >= warn_pct:
now = time.time()
if now - self._last_warn_ts.get(code, 0) >= 60:
self._last_warn_ts[code] = now
logger.warning(
"⚠️ [LS 갭] %s KIS↔LS %.3f%% (KIS=%.0f LS=%.0f)",
code, diff, kis_p, ls_p,
)
if n > 0:
logger.debug("LS 갭 검증 샘플 %d", n)

View File

@@ -115,6 +115,8 @@ class WSManager:
self._lock = threading.Lock()
# 갭보정 WS 재접속 시: split 모드면 KIS키움 관심 종목 전체
self._gap_refill_codes: Set[str] = set()
# BaseStrategy 틱매도 등 — 시세 캐시 갱신 리스너
self._price_listeners: list = []
# ── 갭보정 비동기 파이프라인 ─────────────────────────────
# (전략 쓰레드에서 subscribe() 시 동기 REST 호출하면 매수 체크가
@@ -126,12 +128,19 @@ class WSManager:
self._gap_inflight: Set[str] = set() # 큐에 등록/처리 중인 코드
self._gap_retry_count: Dict[str, int] = {} # TF 실패 시 재시도 카운터
self._gap_tf_ok: Dict[str, Set[int]] = {} # 종목별 성공한 TF (재시도 시 스킵)
# 최대 재시도 초과 후 force 재큐 차단 (전략 check_buy 매초 fill_gap(force) 폭주 방지)
self._gap_give_up_until: Dict[str, float] = {}
self._gap_empty_log_ts: Dict[str, float] = {} # 빈응답 로그 스로틀
self._gap_empty_hit_ts: List[float] = [] # 전역 빈응답 회로차단용
self._gap_empty_circuit_until: float = 0.0
self._gap_lock = threading.Lock()
self._gap_worker_threads: List[threading.Thread] = []
self._gap_worker_boot_logged = False
# 전체 재갭보정(재접속 시) 중복 트리거 방지
self._bulk_refill_running = False
self._bulk_refill_last_ts: float = 0.0
# 평일 장시작 1회 — 장외 거짓완료 마커 클리어 + bulk refill (YYYYMMDD)
self._gap_session_day: Optional[str] = None
# 키움 ka10001 유통/상장주식수 — 전략 공통 (stock_share_meta DB 동기)
self._share_cache: Dict[str, Dict[str, int]] = {}
self._share_q: "queue.Queue[str]" = queue.Queue(maxsize=1024)
@@ -139,6 +148,17 @@ class WSManager:
self._share_lock = threading.Lock()
self._share_worker_thread: Optional[threading.Thread] = None
# ── ls_condition 전략 시세 라우팅 (키움/KIS 와 분리) ─────────────
# owner → 해당 전략의 LS 피드 코드 (후보∪보유)
self._ls_feed_owners: Dict[str, Set[str]] = defaultdict(set)
self._ls_gap_q: "queue.Queue[str]" = queue.Queue(maxsize=512)
self._ls_gap_filled: Set[str] = set()
self._ls_gap_inflight: Set[str] = set()
self._ls_gap_fail: Dict[str, int] = {}
self._ls_gap_lock = threading.Lock()
self._ls_gap_worker_threads: List[threading.Thread] = []
self._ls_ws_missing_warned: bool = False
# ------------------------------------------------------------------
# 시작/종료
# ------------------------------------------------------------------
@@ -206,6 +226,7 @@ class WSManager:
self._load_share_cache_from_db()
self._start_share_meta_worker()
self._start_gap_worker()
self._start_ls_gap_worker()
# 영구 구독 (KOSPI/KOSDAQ ETF 등)
self._load_permanent_codes()
@@ -218,11 +239,12 @@ class WSManager:
# 연결 성공 후 자동 갭 보정 등록 (WS 재접속 시 전체 재갭보정)
self.ws_cache.set_on_connected_callback(self._trigger_bulk_refill_async)
self._reattach_all_price_listeners()
logger.info(
"✅ WSManager 활성 (tfs=%s, permanent=%d, gap_workers=%d)",
tfs, len(self._permanent_codes),
max(1, min(get_env_int("WS_GAP_FILL_WORKERS", 4), 4)),
max(1, min(get_env_int("WS_GAP_FILL_WORKERS", 2), 4)),
)
return True
except Exception as e:
@@ -248,6 +270,7 @@ class WSManager:
def set_kiwoom_ws(self, kiwoom_ws: Any) -> None:
"""키움 WS 인스턴스 (기동 후 주입). ``activate_split_feed`` 전에 설정."""
self._kiwoom_ws = kiwoom_ws
self._reattach_all_price_listeners()
def activate_split_feed(self, active: bool) -> None:
"""``WS_SUBSCRIBE_KIS_MINIMAL`` + 키움 준비 완료 후 True → 후보 구독을 키움으로."""
@@ -260,23 +283,178 @@ class WSManager:
owner: str,
candidates: Iterable[str],
holdings: Iterable[str],
*,
ls_feed: bool = False,
) -> None:
"""전략별 후보/보유를 분리 반영. ``WS_SUBSCRIBE_KIS_MINIMAL`` 아니면 레거시와 동일."""
"""전략별 후보/보유를 분리 반영. ``WS_SUBSCRIBE_KIS_MINIMAL`` 아니면 레거시와 동일.
ls_feed=True (``UNIVERSE_SOURCE=ls_condition``):
- LS US3 구독 → 틱·현재가 (ls_ws_ticks 와 동일 파이프)
- 키움 구독·갭보정·분봉은 **그대로** (pure_ls 제외 안 함)
"""
cand = {str(c).strip() for c in candidates if c}
hold = {str(h).strip() for h in holdings if h}
with self._lock:
if ls_feed:
self._ls_feed_owners[owner] = set(cand | hold)
else:
self._ls_feed_owners.pop(owner, None)
if not self._split_feed_active:
if ls_feed:
self._reconcile_ls_feed_subscriptions()
self.sync_targets(owner, cand | hold)
return
if not self._kiwoom_ws:
logger.warning(
"⚠️ WS 분리 시세 요청이나 키움 WS 없음 → KIS 전체 구독(레거시)으로 폴백",
)
if ls_feed:
self._reconcile_ls_feed_subscriptions()
self.sync_targets(owner, cand | hold)
return
with self._lock:
self._owner_candidates[owner] = cand
self._owner_holdings[owner] = hold
self._reconcile_split_subscriptions()
self._reconcile_ls_feed_subscriptions()
def _ls_feed_codes_locked(self) -> Set[str]:
"""호출자 _lock 보유 가정."""
out: Set[str] = set()
for s in self._ls_feed_owners.values():
out |= s
return out
def _non_ls_codes_locked(self) -> Set[str]:
"""ls_feed 가 아닌 전략의 후보∪보유. 호출자 _lock 보유."""
ls_owners = set(self._ls_feed_owners.keys())
out: Set[str] = set()
for own, s in self._owner_candidates.items():
if own not in ls_owners:
out |= s
for own, s in self._owner_holdings.items():
if own not in ls_owners:
out |= s
# 레거시 sync_targets 경로
for own, s in self._owner_codes.items():
if own not in ls_owners:
out |= s
return out
def _pure_ls_codes_locked(self) -> Set[str]:
"""예전: LS 전용 코드를 키움/갭에서 빼던 집합.
지금은 비움 — ls_condition 도 키움 갭·분봉을 쓰고, LS 는 틱만.
"""
return set()
def is_ls_feed_code(self, code: str) -> bool:
"""LS 틱·현재가 라우팅 대상 (갭/분봉은 키움)."""
with self._lock:
return code in self._ls_feed_codes_locked()
def _get_ls_ws(self):
try:
from ..ws.ls_ws import get_active_ls_ws
return get_active_ls_ws()
except Exception:
return None
def _reconcile_ls_feed_subscriptions(self) -> None:
"""ls_condition 코드를 LS US3 에만 sync — 갭(t8412)은 돌리지 않음."""
with self._lock:
owners = {
str(o): set(codes)
for o, codes in self._ls_feed_owners.items()
}
if not owners:
return
ls_ws = self._get_ls_ws()
if ls_ws is None:
if not self._ls_ws_missing_warned:
logger.warning(
"⚠️ ls_condition 틱용 LS WS 미기동 — "
"AFR/틱 수신 실패 가능 (main LS WS 확인)",
)
self._ls_ws_missing_warned = True
return
self._ls_ws_missing_warned = False
for owner, codes in owners.items():
try:
ls_ws.sync_owner_codes(owner, codes)
except Exception as e:
logger.warning("LS sync_owner_codes(%s) 실패: %s", owner, e)
def _enqueue_ls_gap_fill(self, code: str, *, force: bool = False, priority: bool = False) -> None:
if not code or not get_env_bool("LS_GAP_FILL_ENABLED", True):
return
fail_max = max(1, get_env_int("LS_GAP_FILL_FAIL_MAX", 5))
with self._ls_gap_lock:
if self._ls_gap_fail.get(code, 0) >= fail_max and not force:
return
if code in self._ls_gap_inflight:
return
if code in self._ls_gap_filled and not force:
return
if force:
self._ls_gap_filled.discard(code)
self._ls_gap_inflight.add(code)
try:
self._ls_gap_q.put_nowait(code)
except queue.Full:
with self._ls_gap_lock:
self._ls_gap_inflight.discard(code)
logger.warning("⚠️ LS 갭보정 큐 full → %s 스킵", code)
def _start_ls_gap_worker(self) -> None:
if self._ls_gap_worker_threads:
alive = [t for t in self._ls_gap_worker_threads if t.is_alive()]
if alive:
return
n = max(1, min(get_env_int("LS_GAP_FILL_WORKERS", 1), 2))
threads = []
for i in range(n):
t = threading.Thread(
target=self._ls_gap_worker_loop,
name=f"WS-LSGapWorker-{i}",
daemon=True,
)
t.start()
threads.append(t)
self._ls_gap_worker_threads = threads
logger.info("✅ LS 갭보정 워커 시작 (t8412, workers=%d)", n)
def _ls_gap_worker_loop(self) -> None:
while True:
try:
try:
code = self._ls_gap_q.get(timeout=1.0)
except queue.Empty:
continue
if not code:
continue
ok = False
try:
ls_ws = self._get_ls_ws()
if ls_ws is None:
logger.debug("LS 갭 워커: LS WS 없음 code=%s", code)
else:
n = int(ls_ws.fill_gap_from_rest(code) or 0)
ok = n > 0
if not ok:
logger.debug("LS 갭 빈응답 code=%s", code)
except Exception as e:
logger.warning("LS 갭 실패 %s: %s", code, e)
with self._ls_gap_lock:
self._ls_gap_inflight.discard(code)
if ok:
self._ls_gap_filled.add(code)
self._ls_gap_fail.pop(code, None)
else:
self._ls_gap_fail[code] = int(self._ls_gap_fail.get(code, 0)) + 1
except Exception as e:
logger.debug("LS gap worker: %s", e)
time.sleep(0.5)
def _reconcile_split_subscriptions(self) -> None:
"""KIS/키움 구독 집합을 후보·보유·영구 기준으로 재동기화."""
@@ -295,9 +473,13 @@ class WSManager:
for s in self._owner_holdings.values():
hold_u |= s
perm = set(self._permanent_codes)
kis_want = perm | hold_u
kw_want = cand_u | hold_u | perm
tick_to_agg = set(cand_u - hold_u)
pure_ls = self._pure_ls_codes_locked() - perm
# ls_condition 전용 종목은 키움/KIS 후보·갭에서 제외 (교차 폭주 방지)
cand_u_kw = cand_u - pure_ls
hold_u_kw = hold_u - pure_ls
kis_want = perm | hold_u_kw
kw_want = cand_u_kw | hold_u_kw | perm
tick_to_agg = set(cand_u_kw - hold_u_kw)
self._gap_refill_codes = set(kis_want) | set(kw_want)
# 재진입 시 grace 재사용 가능하도록 소진 플래그 해제
active_want = cand_u | hold_u | perm
@@ -341,7 +523,10 @@ class WSManager:
str(owner): set(codes)
for owner, codes in self._owner_candidates.items()
}
pure_ls_now = self._pure_ls_codes_locked()
for code in added_kw:
if code in pure_ls_now:
continue # 방어: LS 전용은 키움 갭 enqueue 금지
if code in self._permanent_codes:
self._enqueue_gap_fill(code)
else:
@@ -525,7 +710,7 @@ class WSManager:
if first_ref and self.ws_cache:
self.ws_cache.subscribe(code)
# 신규 구독 → 워커에게 갭보정 위임 (논블로킹)
# 신규 구독 → 워커에게 갭보정 위임 (논블로킹) — 키움/KIS 경로
self._enqueue_gap_fill(code)
self._sync_tick_record_codes()
@@ -578,19 +763,22 @@ class WSManager:
return []
return []
def get_current_candle(self, code: str, tf: int) -> Optional[dict]:
"""진행 중 봉 (RAM, is_confirmed=0) — B안 거래량·양봉 판정용."""
if self.candle_agg:
try:
return self.candle_agg.get_current_candle(code, tf)
except Exception:
return None
return None
# ------------------------------------------------------------------
# 조회 헬퍼 (전략이 쓰는 API)
# ------------------------------------------------------------------
def get_price(self, code: str, max_age_sec: float = 5.0) -> Optional[dict]:
# ls_condition 전략 코드 → LS WS 우선
if self.is_ls_feed_code(code):
ls_ws = self._get_ls_ws()
if ls_ws is not None:
try:
p = ls_ws.get_price(code, max_age_sec=max_age_sec)
if p:
return p
except Exception:
pass
else:
logger.debug("LS 피드 코드이나 LS WS 없음: %s", code)
if self.ws_cache:
try:
p = self.ws_cache.get_price(code, max_age_sec=max_age_sec)
@@ -605,6 +793,41 @@ class WSManager:
return None
return None
def register_price_listener(self, callback) -> None:
"""현재가 틱 갱신 콜백. callback(code, price, data_dict). BaseStrategy 틱매도용."""
if callback is None:
return
if callback not in self._price_listeners:
self._price_listeners.append(callback)
self._attach_price_listener(callback)
def _attach_price_listener(self, callback) -> None:
for src in (self.ws_cache, self._kiwoom_ws, self._get_ls_ws()):
if src is not None and hasattr(src, "add_price_listener"):
try:
src.add_price_listener(callback)
except Exception:
pass
def unregister_price_listener(self, callback) -> None:
if callback is None:
return
try:
self._price_listeners.remove(callback)
except ValueError:
pass
for src in (self.ws_cache, self._kiwoom_ws, self._get_ls_ws()):
if src is not None and hasattr(src, "remove_price_listener"):
try:
src.remove_price_listener(callback)
except Exception:
pass
def _reattach_all_price_listeners(self) -> None:
"""키움/KIS WS 기동·교체 후 기존 리스너 재연결."""
for cb in list(self._price_listeners):
self._attach_price_listener(cb)
def get_orderbook_snapshot(self, code: str, max_age_sec: float = 3.0):
"""키움 0D 호가 스냅샷 (분리 시세·키움 WS 활성 시)."""
if self._kiwoom_ws and hasattr(self._kiwoom_ws, "get_orderbook_snapshot"):
@@ -639,6 +862,7 @@ class WSManager:
return None
def get_candles(self, code: str, tf: int, n: int = 50) -> list:
# ls_condition 도 분봉은 키움 갭·candle_agg (틱만 LS)
if self.candle_agg:
try:
return self.candle_agg.get_candles(code, tf, n)
@@ -650,21 +874,50 @@ class WSManager:
except Exception:
return []
def get_current_candle(self, code: str, tf: int) -> Optional[dict]:
"""진행 중 봉 (RAM, is_confirmed=0) — B안 거래량·양봉 판정용."""
if self.candle_agg:
try:
return self.candle_agg.get_current_candle(code, tf)
except Exception:
return None
return None
def fill_gap(
self,
codes: Optional[Iterable[str]] = None,
*,
force: bool = False,
) -> None:
"""외부에서 수동으로 갭 보정 트리거 (비동기: 큐 등록 후 즉시 리턴).
"""갭 보정 — 전부 키움/KIS 기존 경로 (LS t8412 안 씀).
force=True: 이미 ``_gap_filled`` 여도 재큐 (RAM 삭제 후 재ENTER·봉부족 복구).
force=True: 이미 ``_gap_filled`` 여도 재큐.
"""
self._maybe_arm_session_gap_refill()
if codes is None:
self._trigger_bulk_refill_async()
else:
for c in codes:
self._enqueue_gap_fill(c, force=bool(force), priority=bool(force))
return
for c in codes:
self._enqueue_gap_fill(c, force=bool(force), priority=bool(force))
def _maybe_arm_session_gap_refill(self) -> None:
"""평일 장시작 세션 1회: 장외 거짓완료 마커 제거 + 구독 종목 bulk refill.
주말/장외에 ``_gap_filled`` 만 찍히면 개장 직후 REST 없이 have=0 레이스가 난다.
"""
if not self._is_market_hours():
return
day = time.strftime("%Y%m%d")
if self._gap_session_day == day:
return
self._gap_session_day = day
logger.info(
"🔄 [갭보정-장시작] 세션 %s 오픈 → 완료마커 클리어 + bulk refill",
day,
)
# debounce 우회: 세션 오픈은 강제 (직전 장외 bulk 와 충돌해도 재실행)
self._bulk_refill_last_ts = 0.0
self._trigger_bulk_refill_async()
def _clear_gap_fill_state(self, code: str) -> None:
"""RAM 봉 삭제와 짝 — 갭보정 완료 마커 해제.
@@ -677,6 +930,80 @@ class WSManager:
with self._gap_lock:
self._gap_filled.discard(code)
self._gap_tf_ok.pop(code, None)
self._gap_retry_count.pop(code, None)
self._gap_give_up_until.pop(code, None)
self._gap_empty_log_ts.pop(code, None)
def _gap_give_up_sec(self) -> float:
"""최대 재시도 초과 후 force 재큐 차단 시간(초). 계정 REST 폭주 방지."""
return float(get_env_float("WS_GAP_FILL_GIVE_UP_SEC", 300.0))
def _gap_in_give_up(self, code: str, *, now: Optional[float] = None) -> bool:
"""포기 쿨다운 중이면 True. 만료 시 카운터 리셋 후 False."""
ts = float(now if now is not None else time.time())
with self._gap_lock:
until = float(self._gap_give_up_until.get(code, 0.0) or 0.0)
if until <= 0:
return False
if ts < until:
return True
self._gap_give_up_until.pop(code, None)
self._gap_retry_count.pop(code, None)
return False
def _mark_gap_give_up(self, code: str) -> None:
"""REST 실패 상한 도달 — 전략 force 재큐를 일정 시간 무시."""
sec = self._gap_give_up_sec()
if sec <= 0 or not code:
return
until = time.time() + sec
with self._gap_lock:
self._gap_give_up_until[code] = until
logger.warning(
"🛑 [갭보정] %s 포기 쿨다운 %.0fs — force 재큐 차단 (REST 폭주 방지)",
code, sec,
)
def _gap_empty_circuit_open(self) -> bool:
"""짧은 구간에 빈응답이 몰리면 전역 갭보정 REST 일시 정지."""
now = time.time()
with self._gap_lock:
if now < float(self._gap_empty_circuit_until or 0.0):
return True
return False
def _note_gap_empty_response(self, code: str, tf: int) -> None:
"""빈응답 기록 + 로그 스로틀 + 전역 회로차단 갱신."""
now = time.time()
win = float(get_env_float("WS_GAP_FILL_EMPTY_CIRCUIT_WINDOW_SEC", 10.0))
max_hits = get_env_int("WS_GAP_FILL_EMPTY_CIRCUIT_MAX", 40)
pause = float(get_env_float("WS_GAP_FILL_EMPTY_CIRCUIT_PAUSE_SEC", 60.0))
log_every = float(get_env_float("WS_GAP_FILL_EMPTY_LOG_SEC", 30.0))
opened = False
with self._gap_lock:
last_log = float(self._gap_empty_log_ts.get(code, 0.0) or 0.0)
do_log = (now - last_log) >= log_every
if do_log:
self._gap_empty_log_ts[code] = now
if win > 0 and max_hits > 0:
self._gap_empty_hit_ts.append(now)
cut = now - win
self._gap_empty_hit_ts = [t for t in self._gap_empty_hit_ts if t >= cut]
if len(self._gap_empty_hit_ts) >= max_hits and pause > 0:
if now >= float(self._gap_empty_circuit_until or 0.0):
self._gap_empty_circuit_until = now + pause
self._gap_empty_hit_ts.clear()
opened = True
if do_log:
logger.warning(
"⚠️ [갭보정] %s %dM → REST 빈 응답 (재시도 대상)", code, tf,
)
if opened:
logger.error(
"🚨 [갭보정] 빈응답 폭주 → 전역 REST %.0fs 정지 "
"(window=%.0fs max=%d) — 계정 한도 보호",
pause, win, max_hits,
)
def _remove_candle_ram(self, code: str) -> None:
"""구독 해제 시 RAM 봉 정리 + 갭보정 재실행 가능하도록 상태 리셋."""
@@ -694,7 +1021,7 @@ class WSManager:
# ------------------------------------------------------------------
def _start_gap_worker(self) -> None:
"""갭보정 백그라운드 워커 N개 기동 — 우선큐(후보 1M)와 일반큐 병렬 소진."""
want = max(1, min(get_env_int("WS_GAP_FILL_WORKERS", 4), 4))
want = max(1, min(get_env_int("WS_GAP_FILL_WORKERS", 2), 4))
alive = [t for t in self._gap_worker_threads if t.is_alive()]
if len(alive) >= want:
return
@@ -758,6 +1085,11 @@ class WSManager:
return
mode_key = str(mode).strip().lower()
fill_mode = mode_key if mode_key in ("1m", "3m") else "full"
# 빈응답 회로차단 / 종목 포기 쿨다운 — force 여부와 무관하게 REST 재큐 차단
if self._gap_empty_circuit_open():
return
if self._gap_in_give_up(code):
return
with self._gap_lock:
if code in self._gap_inflight:
return
@@ -942,18 +1274,32 @@ class WSManager:
1 if gap_mode == "1m" else (3 if gap_mode == "3m" else None)
)
# 장중만 실행 (장외면 완료 마커 찍고 다음)
# 장시작 세션 암 — 워커가 장중 첫 작업을 잡을 때도 보장
self._maybe_arm_session_gap_refill()
# 장중만 REST. 장외는 완료 마커를 찍지 않음(거짓완료 → 개장 have=0 방지).
if not self._is_market_hours() and not get_env_bool("WS_GAP_FILL_OFF_HOURS", False):
with self._gap_lock:
self._gap_inflight.discard(code)
self._gap_mode.pop(code, None)
self._gap_filled.add(code)
if from_prio:
self._gap_prio_q.task_done()
else:
self._gap_q.task_done()
continue
# 전역 빈응답 회로차단 중이면 REST 호출 없이 큐만 비움 (재큐는 enqueue가 막음)
if self._gap_empty_circuit_open():
with self._gap_lock:
self._gap_inflight.discard(code)
self._gap_mode.pop(code, None)
if from_prio:
self._gap_prio_q.task_done()
else:
self._gap_q.task_done()
time.sleep(0.2)
continue
if kw_key is None:
kw_key, kw_secret, kw_mock = self._get_kiwoom_credentials()
with self._gap_lock:
@@ -969,7 +1315,7 @@ class WSManager:
else:
kw_status = ""
n_workers = max(
1, min(get_env_int("WS_GAP_FILL_WORKERS", 4), 4),
1, min(get_env_int("WS_GAP_FILL_WORKERS", 2), 4),
)
logger.info(
"🔧 [갭보정-워커×%d] kiwoom=%s, KIS_fallback=%s",
@@ -977,6 +1323,11 @@ class WSManager:
kw_status,
"ON" if get_env_bool("WS_GAP_FILL_KIS_FALLBACK", False) else "OFF",
)
# 키움 ka10080 유량=5 — 워커 수 > 세마포어면 대기만 늘어남
logger.info(
"🔧 [갭보정] ka10080 MAX_INFLIGHT=%d (유량=5 보호)",
max(1, min(get_env_int("KIWOOM_KA10080_MAX_INFLIGHT", 2), 4)),
)
self._gap_worker_boot_logged = True
try:
@@ -993,6 +1344,7 @@ class WSManager:
logger.debug("갭보정 워커 예외 (%s): %s", code, e)
ok = False
finally:
_give_up_code: Optional[str] = None
with self._gap_lock:
self._gap_inflight.discard(code)
self._gap_mode.pop(code, None)
@@ -1013,6 +1365,7 @@ class WSManager:
else:
self._gap_filled.add(code)
self._gap_retry_count.pop(code, None)
self._gap_give_up_until.pop(code, None)
else:
retries = self._gap_retry_count.get(code, 0) + 1
max_retries = get_env_int("WS_GAP_FILL_MAX_RETRIES", 3)
@@ -1036,9 +1389,11 @@ class WSManager:
code, partial_tf,
)
self._gap_filled.add(code)
_give_up_code = code
elif ok:
self._gap_filled.add(code)
self._gap_retry_count.pop(code, None)
self._gap_give_up_until.pop(code, None)
else:
retries = self._gap_retry_count.get(code, 0) + 1
max_retries = get_env_int("WS_GAP_FILL_MAX_RETRIES", 3)
@@ -1059,6 +1414,10 @@ class WSManager:
code,
)
self._gap_filled.add(code)
_give_up_code = code
if _give_up_code:
self._mark_gap_give_up(_give_up_code)
if from_prio:
self._gap_prio_q.task_done()
@@ -1330,6 +1689,8 @@ class WSManager:
return False
if not self._is_market_hours() and not get_env_bool("WS_GAP_FILL_OFF_HOURS", False):
return False
if self._gap_empty_circuit_open():
return False
if self._all_gap_tfs_ok(code):
return True
@@ -1408,7 +1769,10 @@ class WSManager:
if tf == 1:
self._maybe_rollup_3m_from_1m(code)
else:
logger.warning("⚠️ [갭보정] %s %dM → REST 빈 응답 (재시도 대상)", code, tf)
self._note_gap_empty_response(code, tf)
# 한 TF 빈응답이면 같은 패스의 나머지 TF REST도 생략 (1M+3M 이중 폭격 방지)
if get_env_bool("WS_GAP_FILL_ABORT_TF_ON_EMPTY", True):
break
prev_tf = tf
self._gap_tf_sleep()