1321 lines
55 KiB
Python
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()
|