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

@@ -86,12 +86,11 @@ class ConditionSearchManager:
"CONDITION_HISTORY_SAVE",
get_env_bool("UNIVERSE_HISTORY_SAVE", True),
)
# EXIT grace period: 한 번 빠진 종목을 N초간 universe 에 keep.
# 단발성 EXIT/RE-ENTER 회전을 흡수해 WS 구독 해제 → 캐시·갭보정 리셋
# → resubscribe 후 데이터 부족으로 매수 시그널 못내는 사이클을 차단.
# 기본 0 = 비활성 (기존 동작과 동일). 권장 60.
# EXIT grace: 빠진 종목을 N초간 universe 에 keep (0=즉시 pop = 순차 push/pop).
# 정합(실매=history)을 깨지 않으려면 history 도 effective(이 목록)을 저장해야 함.
# 기본 0. 켜도 history=effective 이면 슬롯정합은 유지됨(실매만 sticky).
self._exit_grace_sec = float(get_env_int("CONDITION_EXIT_GRACE_SEC", 0))
# strategy_id → {code: first_missing_at_epoch}
# strategy_id → {code: first_missing_at_epoch} — grace>0 일 때만 사용
self._pending_exit: Dict[str, Dict[str, float]] = {}
# 설정 정규화: legacy(single) → multi 형식으로 흡수
@@ -114,9 +113,9 @@ class ConditionSearchManager:
self._thread: Optional[threading.Thread] = None
self._running = False
# 상태
# 상태 — 순서 리스트가 본체(순차 ENTER append / EXIT remove), Set 은 조회 캐시
self._current: Dict[str, Set[str]] = {} # strategy_id → code set
self._current_order: Dict[str, List[str]] = {} # strategy_id → 매수 우선순위(HTS 응답 순)
self._current_order: Dict[str, List[str]] = {} # strategy_id → push/pop 순차 목록
self._name_map: Dict[str, str] = {} # code → name (전역)
# 초기 tick 에서 "빈 set → 첫 결과" 를 변동으로 간주해 1회는 저장
self._initialized: Set[str] = set()
@@ -294,41 +293,39 @@ class ConditionSearchManager:
def _apply_result(self, strategy_id: str, rows: List[Dict]) -> None:
"""
조건검색 raw 결과를 universe 에 반영.
조건검색 raw → 유니버스 순차 반영 (ENTER append / EXIT remove).
EXIT grace 정책 (``CONDITION_EXIT_GRACE_SEC`` > 0 일 때):
- raw 결과에서 빠진 종목을 즉시 EXIT 처리하지 않고 ``_pending_exit`` 에
``first_missing_at`` 시각과 함께 등록.
- grace 초가 지나야 진짜 EXIT (universe 에서 제거) → WS 구독 해제 → 갭보정 리셋.
- grace 중 다시 raw 에 등장하면 pending 에서 빼고 universe 에 그대로 keep
(구독·캐시·봉 데이터 보존).
효과: 단발성 EXIT/RE-ENTER 폭주 흡수. 시장 노이즈로 1~2 tick 빠지는 케이스를
걸러 매수 시그널 평가용 RAM 데이터 (회복률·낙폭 등) 가 휘발 안 됨.
기본(``CONDITION_EXIT_GRACE_SEC=0``): raw 변동을 즉시 push/pop.
grace>0: EXIT 를 N초 지연(sticky). history 는 반드시 effective 목록을
저장해야 슬롯정합이 안 깨짐 — 부모 ``_save_snapshot`` / LS override 경로 동일.
"""
raw_set = {r["code"] for r in rows if r.get("code")}
new_names = {
r["code"]: r.get("name", r["code"]) for r in rows if r.get("code")
}
raw_order: List[str] = []
raw_seen: Set[str] = set()
new_names: Dict[str, str] = {}
for r in rows or []:
c = str(r.get("code") or "").strip()
if not c or c in raw_seen:
continue
raw_seen.add(c)
raw_order.append(c)
new_names[c] = str(r.get("name") or c)
raw_set = set(raw_seen)
now = time.time()
grace = self._exit_grace_sec
with self._lock:
prev = self._current.get(strategy_id, set())
prev_order = list(self._current_order.get(strategy_id) or [])
prev = set(prev_order) if prev_order else set(self._current.get(strategy_id) or [])
first_tick = strategy_id not in self._initialized
pending = self._pending_exit.setdefault(strategy_id, {})
# ── grace 적용 ────────────────────────────────────────
kept_in_grace: Set[str] = set()
if grace > 0:
# 1) raw 에 다시 등장 → grace 해제 (회생)
for c in raw_set & set(pending.keys()):
pending.pop(c, None)
# 2) raw 에서 빠진 prev 종목 → pending 등록 (처음 사라진 시각)
for c in prev - raw_set:
if c not in pending:
pending[c] = now
# 3) grace 미경과 종목 → universe 에 keep / 경과 → pending 제거
expired: Set[str] = set()
for c, first_at in list(pending.items()):
if now - first_at >= grace:
@@ -336,20 +333,26 @@ class ConditionSearchManager:
for c in expired:
pending.pop(c, None)
kept_in_grace = set(pending.keys())
# grace=0 (기존 동작) 이면 kept_in_grace = ∅, pending 안 쓰임.
else:
pending.clear()
# 봇이 실제로 보는 effective universe (raw + grace keep)
new_set = raw_set | kept_in_grace
# ── 변동 계산 (effective 기준) ───────────────────────
# 순차 목록: raw 응답 순서 유지 + grace 잔여(이전 순서) + 나머지
ordered = self._build_ordered_universe(
rows=[{"code": c, "name": new_names.get(c, c)} for c in raw_order],
new_set=new_set,
prev_order=prev_order,
)
enters = new_set - prev
exits = prev - new_set
self._current[strategy_id] = new_set
self._current_order[strategy_id] = self._build_ordered_universe(
rows=rows,
new_set=new_set,
prev_order=list(self._current_order.get(strategy_id) or []),
)
# 신규 ENTER 를 맨 앞 — {SID}_CAND_LIMIT 하드캡이 오래된 저코드만
# 남기고 AFR/스냅 신규를 잘라먹던 sticky 를 막는다.
if enters:
head = [c for c in ordered if c in enters]
tail = [c for c in ordered if c not in enters]
ordered = head + tail
self._current_order[strategy_id] = ordered
self._current[strategy_id] = set(ordered)
self._initialized.add(strategy_id)
for c, n in new_names.items():
self._name_map[c] = n
@@ -359,9 +362,10 @@ class ConditionSearchManager:
if changed:
grace_tag = f", grace={n_grace}" if n_grace else ""
mode_tag = "push/pop" if grace <= 0 else "grace-sticky"
logger.info(
"🔄 [%s] +%d / -%d (현재 %d종목%s)",
strategy_id, len(enters), len(exits), len(new_set), grace_tag,
"🔄 [%s] +%d / -%d (현재 %d종목%s, %s)",
strategy_id, len(enters), len(exits), len(ordered), grace_tag, mode_tag,
)
if enters:
preview = ", ".join(
@@ -376,17 +380,15 @@ class ConditionSearchManager:
if self.on_change and changed:
try:
self.on_change(strategy_id, new_set, enters, exits)
self.on_change(strategy_id, set(ordered), enters, exits)
except Exception as e:
logger.warning("on_change 콜백 예외: %s", e)
# 백테스트 재현성 보장: 첫 tick 또는 변동 발생 tick 마다 풀 스냅샷 저장
# (effective universe 기준 — 봇이 실제로 보던 universe 가 그대로 기록됨)
# 변동 없는 tick 은 공간 절약 위해 skip
# effective 순서 그대로 history (첫 tick 또는 변동 시)
if changed or first_tick:
with self._lock:
ordered = list(self._current_order.get(strategy_id) or [])
self._save_snapshot(strategy_id, ordered, new_names)
ordered_save = list(self._current_order.get(strategy_id) or [])
self._save_snapshot(strategy_id, ordered_save, new_names)
@staticmethod
def _build_ordered_universe(