Files
kis_bot/kis_trader/network/ls_condition_manager.py

1321 lines
55 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 (재시작 불필요).
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()