""" 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()