""" 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 (재시작 불필요). t1859 주기 스냅샷 (``LS_CONDITION_SNAPSHOT_REFRESH_SEC``, 기본 0=비활성): **AFR alert 정상 + RAM 비어있지 않으면 스킵** (clear 교체는 레이스 구멍·빈 DB 이력 유발). 시드/복구만: alert 없음 · RAM 0종목 · rematch/기동 시. 국장 세션·장전 준비 밖이면 주기 t1859 도 스킵 (주말 REST 절약). 축소(20→1) 전체교체 기본 거부 (``LS_T1859_ALLOW_SHRINK=false``) — EXIT 는 AFR O 만. RAM 0 재시드 (``LS_T1859_EMPTY_RETRY_SEC``, 기본 1): AFR 는 풀덤프 없음 → 기동 t1859=검색결과없음 이면 **REST t1859 를 RAM>0 될 때까지** 재시도. 공식 t1859 초당 1건 → 기본 1초(실제 간격 ≥ ``LS_CONDITION_TR_GAP_SEC`` 1.1). ``=0`` 은 무한 연타가 아니라 **재시도 OFF** (영구 0 위험). 키움 CNSRREQ 와 다름. AFR 침묵 재동기 (``LS_T1859_STALE_RESYNC_SEC``, 기본 60=1분): alert 등록·RAM>0 인데 AFR N/R/O 가 N초 없으면 **소켓 침묵 방어(1분 주기 심폐소생술)** 수행. - LS는 매매 주문이 아닌 시세·조건식 전용 기지이므로 429 주문 방해 차단 위험 0%! - 1분 주기로 하루 390번 눈 감아도 총 암흑기 누계 58초(1분 미만)에 불과! - 0종목 응답 시: 통신 장애/가성 오류로 간주하여 기존 RAM 종목을 자살골 청산 없이 완벽 보존! - 정상 응답 시: 기존 RAM 리스트와 1:1 비교(Delta Diff)하여 끊김 틈새에 발생한 유령 종목 소거 및 신규 반영. HTS 조건식 수정·AFR 무푸시 시 HTS만 늘고 봇 RAM 고정되던 구멍을 안전하게 보정. AFR(t1860) 국장 세션 게이트 (``LS_AFR_SESSION_GATE``, 기본 true): - 장전 준비: 평일 ``START_HM``(기본 07:00)~``OPEN_DEADLINE``(09:30) 전은 JIF 개장 전에도 허용 (09:00 jstatus=21 기다리면 늦음). - 개장 JIF(21~25) sticky / 마감 JIF(30·31·41~44) 당일 차단. - 해외 밤장과 무관. DEADLINE 지나 개장 JIF 없으면 휴장으로 OFF. """ 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() # AFR N/R/O 수신 mono — 0 이면 시드만 있고 실시간 미수신 self.last_afr_event_mono: float = 0.0 self.last_seed_mono: float = 0.0 def set_from_snapshot( self, rows: List[Dict[str, Any]], *, allow_empty_clear: bool = False, allow_shrink: bool = False, ) -> bool: """t1859 → RAM. - 빈 목록으로 기존 RAM 지우기: 기본 거부 (구멍·빈이력). - 종목수 축소 전체교체: 기본 거부 (AFR ``O`` 만 EXIT). 장전 1종 응답이 sticky 20종을 덮어쓰던 20→1 사고를 막는다. - 빈 RAM 시드 / ``allow_shrink=True`` 일 때만 축소 허용. """ with self.lock: new_codes: Dict[str, Dict[str, Any]] = {} for r in rows or []: code = str(r.get("shcode") or r.get("code") or "").strip() if not code: continue new_codes[code] = { "code": code, "name": str(r.get("hname") or r.get("name") or code)[:100], "price": r.get("price") or 0, } if (not new_codes) and self.codes and (not allow_empty_clear): return False if ( self.codes and new_codes and (not allow_shrink) and len(new_codes) < len(self.codes) ): return False self.codes.clear() self.codes.update(new_codes) return True def merge_grow_from_snapshot(self, rows: List[Dict[str, Any]]) -> int: """t1859 결과에서 **없는 종목만 추가** (축소·삭제 없음). 추가 개수 반환.""" added = 0 with self.lock: for r in rows or []: code = str(r.get("shcode") or r.get("code") or "").strip() if not code or code in self.codes: continue self.codes[code] = { "code": code, "name": str(r.get("hname") or r.get("name") or code)[:100], "price": r.get("price") or 0, } added += 1 return added def rows_prefer_snapshot_order( self, snap_rows: List[Dict[str, Any]] ) -> List[Dict[str, Any]]: """t1859 스냅 순서 우선 + RAM 잔여(sticky) 뒤쪽. ``CAND_LIMIT`` 은 앞 N개만 매수체크하므로, AFR침묵 grow 후에도 **지금 서버 스냅에 있는 종목**이 캡 안에 들어오게 한다. """ head: List[Dict[str, Any]] = [] seen: set = set() for r in snap_rows or []: code = str(r.get("shcode") or r.get("code") or "").strip() if not code or code in seen: continue seen.add(code) with self.lock: cur = self.codes.get(code) or {} head.append({ "code": code, "name": str( r.get("hname") or r.get("name") or cur.get("name") or code )[:100], }) with self.lock: rest = [ {"code": c, "name": str(v.get("name") or c)[:100]} for c, v in self.codes.items() if c not in seen ] return head + rest def rows_prefer_code_first( self, code: str, *, name: str = "" ) -> List[Dict[str, Any]]: """AFR N/R 종목을 맨 앞 — CAND_LIMIT 에 즉시 들어가게.""" code = str(code or "").strip() rows = self.as_rows() if not code: return rows nm = (name or "").strip() or code head = [{"code": code, "name": nm[:100]}] tail = [r for r in rows if str(r.get("code") or "") != code] return head + tail 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) if job in ("N", "R", "O"): self.last_afr_event_mono = time.monotonic() 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", 0)) # 서버 조건 CRUD 후 query_index/alert 재부착 (재시작 대체) self._remap_sec = float(get_env_int("LS_CONDITION_REMAP_SEC", 60)) # 주기 t1859 — 기본 0=비활성 (AFR push/pop 본체). 시드/복구만 rematch·기동. self._snapshot_refresh_sec = float( get_env_int("LS_CONDITION_SNAPSHOT_REFRESH_SEC", 0) ) # RAM 0 일 때 t1859 재시드 주기(초). snap_refresh=0 이어도 동작 (영구0 방지) self._empty_retry_sec = float( get_env_int("LS_T1859_EMPTY_RETRY_SEC", 1) ) # AFR alert+종목 있는데 N/R/O 침묵 → t1859 Delta Diff+재REG (하루 누적 눈감는시간 58초, 기본 60=1분) self._stale_resync_sec = float( get_env_int("LS_T1859_STALE_RESYNC_SEC", 60) ) # t1859 가 기존 RAM 보다 적을 때 전체교체 허용 (기본 false — 20→1 방지) self._t1859_allow_shrink = get_env_bool("LS_T1859_ALLOW_SHRINK", False) 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_empty_retry_mono = 0.0 self._last_stale_resync_mono = 0.0 self._last_missing_warn: Dict[str, float] = {} # t1860 E 가 sAlertNum=0 이면 장중 재시도 (장외 정상 ACK + 키 미발급) self._afr_pending_retry: Set[str] = set() self._afr_offhours_log_mono: float = 0.0 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, job: Optional[str] = None, code: Optional[str] = None, body: Optional[Dict[str, Any]] = None, ) -> None: try: b = body if isinstance(body, dict) else {} nm = "" if job and code: try: from .condition_job_events import insert_condition_job_event from .condition_common import normalize_job_flag try: st.last_afr_event_mono = time.monotonic() except Exception: pass nm = str( b.get("gsHname") or b.get("hname") or code )[:100] try: px = float( str(b.get("gsPrice") or b.get("price") or 0) .replace(",", "") ) except (TypeError, ValueError): px = 0.0 bt = str( b.get("gsTime") or b.get("sTime") or "" ).strip() insert_condition_job_event( self.db, strategy_id=st.strategy_id, code=str(code), job_flag=normalize_job_flag(job), broker="ls", name=nm, price=px, broker_time=bt, query_name=str(getattr(st, "query_name", "") or ""), source="ls_afr", ) except Exception as e: logger.debug("LS job_event: %s", e) # N/R 은 맨 앞 — BREAKOUT_CAND_LIMIT 등에 신규가 안 잘리게 j_up = str(job or "").strip().upper() if j_up in ("N", "R") and code: if not nm: nm = str( b.get("gsHname") or b.get("hname") or code )[:100] rows = st.rows_prefer_code_first(str(code), name=nm) else: rows = st.as_rows() self._apply_result(st.strategy_id, 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([], allow_empty_clear=True) self._apply_result(st.strategy_id, []) self._save_ls_snapshot(st, source="ls_remap_clear") def _kr_afr_session_allows(self) -> bool: """국장 AFR(t1860) 허용 — LS JIF + 장전 준비 창 (해외 밤장과 무관). ``LS_AFR_SESSION_GATE=false`` 이면 항상 허용(레거시 연타). LS WS 미기동 시: 평일 START~DEADLINE 전 준비 허용, DEADLINE 후·주말 거부. """ if not get_env_bool("LS_AFR_SESSION_GATE", True): return True try: from kis_trader.ws.ls_ws import get_active_ls_ws ws = get_active_ls_ws() if ws is not None and hasattr(ws, "is_kr_afr_session_open"): return bool(ws.is_kr_afr_session_open()) except Exception as e: logger.debug("LS AFR 세션 조회 실패 → 벽시계 폴백: %s", e) now = datetime.now() if now.weekday() >= 5: return False hhmm = now.hour * 100 + now.minute start = int(get_env_int("LS_AFR_SESSION_START_HM", 700) or 700) end = int(get_env_int("LS_AFR_SESSION_END_HM", 1530) or 1530) deadline = int(get_env_int("LS_AFR_OPEN_DEADLINE_HM", 930) or 930) if not (start <= hhmm <= end): return False # WS 없으면 장전 준비만 (DEADLINE 이후 종일 연타 금지) return hhmm < deadline def _log_afr_session_wait(self, st: _LsConditionState, *, why: str) -> None: """장외 AFR 스킵 로그 — 스로틀 (해외 세션과 혼동 방지 문구).""" gap = float(get_env_float("LS_AFR_OFFHOURS_LOG_SEC", 600.0) or 600.0) now = time.monotonic() if gap > 0 and (now - float(self._afr_offhours_log_mono or 0)) < gap: return self._afr_offhours_log_mono = now jif = {} try: from kis_trader.ws.ls_ws import get_active_ls_ws ws = get_active_ls_ws() if ws is not None and hasattr(ws, "get_jif_snapshot"): jif = ws.get_jif_snapshot() or {} except Exception: jif = {} logger.info( "⏸ LS AFR 대기(국장세션 아님) sid=%s name=%s why=%s " "jstatus=%s afr_live=%s pending=%d — 해외밤장 무관·JIF/개장 시 재시도", st.strategy_id, st.query_name, why, jif.get("jstatus") or "-", jif.get("kr_afr_session_live"), len(self._afr_pending_retry), ) def _mount_snapshot_and_afr( self, st: _LsConditionState, *, do_snapshot: bool, do_afr: bool, stale_resync: bool = False, ) -> None: """t1859 → RAM, (옵션) t1860 E + AFR REG. ``stale_resync=True`` (AFR 침묵): - snap 종목수 ≥ RAM 이면 전체교체(동수·증가 — 조건식 변경 반영) - snap 이 더 적으면 **merge grow 만** (20→1 축소 사고 방지) - 이어서 AFR 재REG """ if not self._token or self._rt is None: return # 국장 장외: t1860 연타 금지 (sAlertNum=0 ACK 폭주 → 한도/정지 리스크). # rematch/스냅샷은 유지. 개장(JIF sticky) 되면 pending 이 재시도. if do_afr and not self._kr_afr_session_allows(): self._afr_pending_retry.add(st.strategy_id) self._log_afr_session_wait(st, why="session_gate") do_afr = False 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 = [] n_before = len(st.items()) n_snap = 0 try: n_snap = len(snap or []) except Exception: n_snap = 0 if stale_resync: if n_before > 0 and n_snap <= 0: logger.warning( "⚠️ [0종목 보호 방패] LS 15분 주기 소켓 재동기 중 t1859 응답 0종목(Empty) 감지 → " "통신 장애/일시 공백으로 판단하여 자살골 청산 없이 기존 RAM(%d종목) 완벽 보존 sid=%s name=%s", n_before, st.strategy_id, st.query_name, ) elif n_snap > 0: st.set_from_snapshot(snap or [], allow_shrink=True) st.last_seed_mono = time.monotonic() self._apply_result(st.strategy_id, st.as_rows()) self._save_ls_snapshot(st, source="ls_t1859_stale_diff") logger.info( "🔄 [Delta Diff 갱신] LS 15분 주기 소켓 재동기 sid=%s name=%s " "RAM 기존 %d → Diff 반영 %d종목 (유령종목 정제)", st.strategy_id, st.query_name, n_before, n_snap, ) else: allow_shrink = ( bool(self._t1859_allow_shrink) or (n_before <= 0) ) applied = st.set_from_snapshot( snap or [], allow_shrink=allow_shrink, ) if not applied: logger.warning( "⚠️ t1859 적용 거부 → RAM 유지 sid=%s (기존 %d / snap %d, " "빈응답·축소교체 방지 allow_shrink=%s)", st.strategy_id, n_before, n_snap, allow_shrink, ) else: n = len(st.items()) if n <= 0: logger.warning( "⚠️ LS t1859 스냅 0종목 sid=%s name=%s idx=%s " "(OpenAPI 검색결과없음/일시공백 — HTS·키움 CNSRREQ 와 별개. " "AFR 은 풀덤프 없음 → LS_T1859_EMPTY_RETRY 재시드)", st.strategy_id, st.query_name, st.query_index, ) else: st.last_seed_mono = time.monotonic() self._apply_result(st.strategy_id, st.as_rows()) src = ( "ls_t1859_stale" if stale_resync else "ls_t1859" ) self._save_ls_snapshot(st, source=src) logger.info( "📌 LS 스냅샷 sid=%s name=%s %d종목%s", st.strategy_id, st.query_name, n, " (AFR침묵재동기)" if stale_resync else "", ) 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 # 스냅샷은 유지, AFR 는 _mount 내부 국장세션 게이트 self._mount_snapshot_and_afr( st, do_snapshot=True, do_afr=True, ) elif need_afr: # 장외: t1859+t1860 연타 금지 — pending 만 유지, 개장 시 재시도 if not self._kr_afr_session_allows(): self._afr_pending_retry.add(sid) self._log_afr_session_wait(st, why="pending_offhours") continue 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 _afr_alert_live(self, st: _LsConditionState) -> bool: alert = str(st.alert_num or "").strip() return bool(alert) and (alert.strip("0") != "") def _afr_activity_mono(self, st: _LsConditionState) -> float: return max( float(getattr(st, "last_afr_event_mono", 0) or 0), float(getattr(st, "last_seed_mono", 0) or 0), ) def _afr_is_stale(self, st: _LsConditionState) -> bool: """AFR 침묵 sticky — HTS 만 갱신되고 봇 RAM 고정되는 구멍.""" if self._stale_resync_sec <= 0: return False if not str(st.query_index or "").strip(): return False if len(st.items()) <= 0: return False last = self._afr_activity_mono(st) if last <= 0: return True return (time.monotonic() - last) >= max(15.0, self._stale_resync_sec) def _any_afr_stale(self) -> bool: return any(self._afr_is_stale(st) for st in self._states_by_sid.values()) def _snapshot_periodic_needed(self, st: _LsConditionState) -> bool: """주기 t1859 가 필요한가. AFR 구독이 살아 있고 RAM 에 종목이 있으면 **교체 금지** (clear 레이스 → 순간 구멍·ls_candidates_history 빈 슬롯). AFR 침묵은 ``_refresh_stale_snapshots`` 가 별도 처리. """ if self._afr_alert_live(st) and len(st.items()) > 0: return False # 장외·주말: 시드용 주기도 REST 부담 — 세션/장전 준비 창에서만 if get_env_bool("LS_AFR_SESSION_GATE", True) and not self._kr_afr_session_allows(): return False return True def _refresh_snapshots_only(self) -> None: """필요 시에만 t1859 (AFR live 교체 금지 · 빈 RAM/미등록 시드).""" with self._maint_lock: if not self._ensure_token(force=False, reason="snap_refresh"): return n_skip = 0 n_run = 0 for st in list(self._states_by_sid.values()): if not str(st.query_index or "").strip(): continue if not self._snapshot_periodic_needed(st): n_skip += 1 continue try: self._mount_snapshot_and_afr( st, do_snapshot=True, do_afr=False, ) n_run += 1 except Exception as e: logger.warning( "LS snapshot refresh 실패 %s: %s", st.strategy_id, e, ) self._last_snap_refresh_mono = time.monotonic() if n_skip and not n_run: logger.debug( "LS snapshot refresh 스킵 %d건 (AFR live 또는 장외)", n_skip, ) def _refresh_empty_snapshots(self) -> None: """RAM 0 인 조건만 t1859 재시드. AFR REG 후 서버는 풀목록을 안 밀어주고 델타(N/R/O)만 준다. 기동 시 t1859=검색결과없음 이면 snap_refresh=0 일 때 **영구 0** → 여기로 회복. 축소교체는 ``LS_T1859_ALLOW_SHRINK`` (기본 false) 로 계속 거부. """ with self._maint_lock: if not self._ensure_token(force=False, reason="empty_seed"): return if get_env_bool("LS_AFR_SESSION_GATE", True) and not self._kr_afr_session_allows(): return n_run = 0 for st in list(self._states_by_sid.values()): if not str(st.query_index or "").strip(): continue if len(st.items()) > 0: continue try: logger.warning( "🔄 LS RAM0 → t1859 재시드 sid=%s name=%s", st.strategy_id, st.query_name, ) self._mount_snapshot_and_afr( st, do_snapshot=True, do_afr=False, ) n_run += 1 except Exception as e: logger.warning( "LS empty seed 실패 %s: %s", st.strategy_id, e, ) self._last_empty_retry_mono = time.monotonic() if n_run: logger.info("LS empty RAM t1859 재시드 %d건", n_run) def _refresh_stale_snapshots(self) -> None: """AFR 침묵 sticky → t1859 재동기 + AFR 재REG. alert 있어 주기 t1859 가 스킵되는데 서버가 N/R/O 를 안 주면 HTS 만 늘고 봇 RAM 이 고정됨 (BREAKOUT 실측). """ with self._maint_lock: if not self._ensure_token(force=False, reason="stale_resync"): return if get_env_bool("LS_AFR_SESSION_GATE", True) and not self._kr_afr_session_allows(): return n_run = 0 for st in list(self._states_by_sid.values()): if not self._afr_is_stale(st): continue age = 0 last = self._afr_activity_mono(st) if last > 0: age = int(time.monotonic() - last) try: logger.warning( "🔄 LS AFR침묵 → t1859+재REG sid=%s name=%s " "RAM=%d silence=%ss", st.strategy_id, st.query_name, len(st.items()), age, ) self._mount_snapshot_and_afr( st, do_snapshot=True, do_afr=True, stale_resync=True, ) n_run += 1 except Exception as e: logger.warning( "LS stale resync 실패 %s: %s", st.strategy_id, e, ) self._last_stale_resync_mono = time.monotonic() if n_run: logger.info("LS AFR침묵 재동기 %d건", n_run) def _empty_retry_interval(self) -> float: """RAM0 t1859 재시드 간격. 공식 초당1건 → TR_GAP(기본 1.1) 이상.""" if self._empty_retry_sec <= 0: return 0.0 gap = max(1.0, float(self._tr_gap_sec or 1.0)) return max(gap, float(self._empty_retry_sec)) def _stale_resync_interval(self) -> float: if self._stale_resync_sec <= 0: return 0.0 gap = max(1.0, float(self._tr_gap_sec or 1.0)) return max(gap, float(self._stale_resync_sec)) def _any_ram_empty(self) -> bool: for st in list(self._states_by_sid.values()): if not str(st.query_index or "").strip(): continue if len(st.items()) <= 0: return True return False def _maint_loop(self) -> None: """flush + rematch + (옵션) t1859 + RAM0 재시드 + AFR침묵 재동기.""" # 기동 직후 즉시 rematch 하지 않음 (start 에서 1회 완료) now0 = time.monotonic() self._last_remap_mono = now0 self._last_snap_refresh_mono = now0 # empty: 기동 t1859 공백이면 첫 sleep 후 바로 재시드 (만료시각을 과거로) _er = self._empty_retry_interval() self._last_empty_retry_mono = (now0 - _er) if _er > 0 else now0 _sr = self._stale_resync_interval() # 기동 직후 곧바로 침묵판정하지 않음 — 시드 직후 여유 self._last_stale_resync_mono = now0 last_flush_mono = now0 while self._running: intervals = [max(5.0, self._remap_sec)] if self._snapshot_refresh_sec > 0: intervals.append(max(5.0, self._snapshot_refresh_sec)) # RAM 비어 있을 때만 짧은 wake (채워지면 remap 주기로) if self._empty_retry_sec > 0 and self._any_ram_empty(): intervals.append(self._empty_retry_interval()) if self._stale_resync_sec > 0 and self._any_afr_stale(): intervals.append(min(30.0, self._stale_resync_interval())) if self._flush_sec > 0: intervals.append(max(5.0, self._flush_sec)) time.sleep(min(intervals)) 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 ( self._snapshot_refresh_sec > 0 and now - self._last_snap_refresh_mono >= max(5.0, self._snapshot_refresh_sec) ): self._refresh_snapshots_only() now = time.monotonic() _er = self._empty_retry_interval() if ( _er > 0 and now - self._last_empty_retry_mono >= _er ): self._refresh_empty_snapshots() now = time.monotonic() _sr = self._stale_resync_interval() if ( _sr > 0 and now - self._last_stale_resync_mono >= _sr ): self._refresh_stale_snapshots() now = time.monotonic() if ( self._flush_sec > 0 and 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: """ls_candidates_history 적재 — **봇이 실제로 보는** effective 유니버스. ``st.items()``(t1859 raw)만 쓰면 grace/sticky 로 RAM 에 남은 종목이 history 에 안 들어가 슬롯교집합이 20→1 로 전량탈락한다. """ if self.history_enabled and self.db is not None: sid = st.strategy_id items: List[Dict[str, Any]] = [] try: with self._lock: ordered = list(self._current_order.get(sid) or []) if not ordered: ordered = list(self._current.get(sid) or []) nm = dict(self._name_map) st_by_code = { str(it.get("code") or "").strip(): it for it in (st.items() or []) } for c in ordered: code = str(c or "").strip() if not code: continue base = st_by_code.get(code) or {} items.append({ "code": code, "name": str(base.get("name") or nm.get(code) or code)[:100], "price": base.get("price") or 0, }) except Exception: items = [] if not items: items = st.items() n = insert_ls_candidates_snapshot( self.db, strategy_id=st.strategy_id, query_name=st.query_name, query_index=st.query_index, items=items, source=source, ) logger.debug( "📼 [ls_history] %s src=%s %d종목 (effective)", st.strategy_id, source, n, ) # 이력 OFF 여도 WS follow 를 위해 합집합 emit self.emit_universe_union()