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

822 lines
32 KiB
Python

"""
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._token_expire_at: float = 0.0
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] = {}
# t1860 E 가 sAlertNum=0 이면 장중 재시도 (장외 정상 ACK + 키 미발급)
self._afr_pending_retry: Set[str] = set()
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=False, reason="ls_cond_start"):
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, reason: str = "") -> bool:
"""``/oauth2/token`` — expires_in 캐시 재사용. 강제 연타 발급 금지."""
if not (self._app_key and self._app_secret and self._rt):
return False
try:
from kis_trader.network.ls_token import fetch_ls_access_token_info
tok, exp_at, _exp_in = fetch_ls_access_token_info(
self._app_key,
self._app_secret,
force=force,
reason=reason or ("force" if force else "ensure"),
)
except Exception as e:
logger.warning("LS 토큰 발급 실패: %s", e)
return False
if not tok:
logger.warning("LS 토큰 빈값")
return False
self._token = str(tok)
self._token_expire_at = float(exp_at or 0)
w = self._watcher
if w is not None:
try:
w.token = self._token
except Exception:
pass
return True
def _refresh_token_on_auth_error(self, err: Any, *, where: str) -> bool:
"""IGW00121/123 등 — 스펙 준수 1회 재발급(최소간격 캐시)."""
from kis_trader.network.ls_token import is_ls_auth_error
err_s = str(err or "")
if not is_ls_auth_error(rsp_msg=err_s, text=err_s):
# RuntimeError 메시지에 rsp_cd 포함
if "IGW00121" not in err_s and "IGW00123" not in err_s:
return False
logger.warning("LS 인증 오류(%s) → /oauth2/token 갱신: %s", where, err_s[:200])
return self._ensure_token(force=True, reason=f"auth:{where}")
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:
if self._refresh_token_on_auth_error(e, where="t1860"):
try:
ob = self._rt.t1860_realtime(
self._token, st.query_index, flag="E", alert_num="",
logger=logger,
)
self._tr_sleep()
except Exception as e2:
logger.error("t1860 재시도 예외 %s: %s", st.strategy_id, e2)
return
else:
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") == ""):
# 장외·장전: sResultFlag=S + Msg=정상처리 인데 sAlertNum=000… 인 경우
# (실측 로그 다수). AFR WS 등록 불가 → 대기 후 rematch 재시도.
self._afr_pending_retry.add(st.strategy_id)
logger.warning(
"sAlertNum 미발급 sid=%s alert=%r msg=%s — AFR 대기재시도 "
"(장외/장전 LS 서버가 키 0 반환. 코드 버그 아님)",
st.strategy_id, alert, str(ob.get("Msg") or "")[:40],
)
return
self._afr_pending_retry.discard(st.strategy_id)
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, reason="remap"):
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:
# 인증 오류 → 스펙 준수 재발급 후 1회 재시도. 그 외(GW라우팅 등)는
# 빈목록 '조건 소실'로 오판해 AFR 해제하지 않음.
if self._refresh_token_on_auth_error(e, where="t1866"):
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:
logger.error(
"t1866 실패(기존 매핑·AFR 유지, 소실 해제 안 함): %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)
# 목록 조회 성공인데 이름만 없음 = 진짜 소실.
# (HTTP/인증 실패는 위에서 return — 여기 도달 안 함)
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)
self._afr_pending_retry.discard(sid)
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())
or (sid in self._afr_pending_retry)
)
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._afr_pending_retry.discard(sid)
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, reason="snap_refresh"):
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()