refactor: enhance Optuna backtesting framework, optimize orderbook filtering, and update database management utilities.

This commit is contained in:
Your Name
2026-08-12 10:19:19 +09:00
parent cb7e5037a0
commit c6bd62a25f
218 changed files with 31613 additions and 759 deletions

View File

@@ -14,8 +14,31 @@ kis_trader/network/ls_condition_manager.py — LS 서버저장조건(AFR)
운영 중 LS HTS 에서 조건 CRUD(삭제·이름변경·인덱스 재배열) 시:
``LS_CONDITION_REMAP_SEC`` 주기로 t1866 이름→query_index 재매핑,
바뀌면 구 AFR UNREG + t1860 D → t1859 → t1860 E → AFR REG (재시작 불필요).
``LS_CONDITION_SNAPSHOT_REFRESH_SEC`` 주기로 동일 인덱스라도 t1859 스냅샷
재동기화 (AFR 는 델타만이라 장중 sticky/빈 RAM 보정).
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
@@ -142,19 +165,107 @@ class _LsConditionState:
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]]) -> None:
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:
self.codes.clear()
for r in rows:
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()
@@ -171,6 +282,8 @@ class _LsConditionState:
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]]:
@@ -209,21 +322,34 @@ class LsConditionSearchManager(ConditionSearchManager):
self._by_alert: Dict[str, _LsConditionState] = {}
self._states_by_sid: Dict[str, _LsConditionState] = {}
self._watcher: Any = None
self._flush_sec = float(get_env_int("LS_CONDITION_FLUSH_SEC", 60))
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))
# AFR 델타만 보정 — 동일 인덱스라도 t1859 로 RAM sticky 동기화
# 주기 t1859 — 기본 0=비활성 (AFR push/pop 본체). 시드/복구만 rematch·기동.
self._snapshot_refresh_sec = float(
get_env_int("LS_CONDITION_SNAPSHOT_REFRESH_SEC", 120)
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 이 등록)
@@ -459,9 +585,61 @@ class LsConditionSearchManager(ConditionSearchManager):
ws_url = self._rt.LS_WS_MOCK if self._use_mock else self._rt.LS_WS_REAL
def on_change(st: Any) -> None:
def on_change(
st: Any,
job: Optional[str] = None,
code: Optional[str] = None,
body: Optional[Dict[str, Any]] = None,
) -> None:
try:
self._apply_result(st.strategy_id, st.as_rows())
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)
@@ -521,20 +699,86 @@ class LsConditionSearchManager(ConditionSearchManager):
logger.debug("t1860 D %s: %s", st.strategy_id, e)
st.alert_num = ""
if clear_ram:
st.set_from_snapshot([])
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."""
"""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(
@@ -544,21 +788,64 @@ class LsConditionSearchManager(ConditionSearchManager):
except Exception as e:
logger.warning("t1859 실패 %s: %s", st.strategy_id, e)
snap = []
st.set_from_snapshot(snap or [])
self._apply_result(st.strategy_id, st.as_rows())
self._save_ls_snapshot(st, source="ls_t1859")
n = len(st.as_rows())
if n <= 0:
logger.warning(
"⚠️ LS 스냅샷 0종목 sid=%s name=%s idx=%s "
"(AFR 델타만으로는 장중 sticky 불가 → 주기 t1859 대기)",
st.strategy_id, st.query_name, st.query_index,
)
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:
logger.info(
"📌 LS 스냅샷 sid=%s name=%s %d종목",
st.strategy_id, st.query_name, n,
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
@@ -707,12 +994,18 @@ class LsConditionSearchManager(ConditionSearchManager):
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 없음/강제/대기재시도)",
"🔄 LS AFR 재등록 sid=%s name=%s (alert없음/대기재시도)",
sid, st.query_name,
)
self._mount_snapshot_and_afr(
@@ -736,38 +1029,196 @@ class LsConditionSearchManager(ConditionSearchManager):
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 sticky 보정)."""
"""필요 시에만 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 sticky 주기 루프."""
"""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:
tick = min(
max(5.0, self._flush_sec),
max(5.0, self._remap_sec),
max(5.0, self._snapshot_refresh_sec),
)
time.sleep(tick)
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()
@@ -776,12 +1227,30 @@ class LsConditionSearchManager(ConditionSearchManager):
self._remap_all(force_snapshot=False, force_afr=False)
now = time.monotonic()
if (
now - self._last_snap_refresh_mono
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()
if now - last_flush_mono >= max(5.0, self._flush_sec):
_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")
@@ -804,17 +1273,47 @@ class LsConditionSearchManager(ConditionSearchManager):
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=st.items(),
items=items,
source=source,
)
logger.debug(
"📼 [ls_history] %s src=%s %d종목",
"📼 [ls_history] %s src=%s %d종목 (effective)",
st.strategy_id, source, n,
)
# 이력 OFF 여도 WS follow 를 위해 합집합 emit