#!/usr/bin/env python3 """ 유니버스 타임라인 (전략 공통) — 스캔 시각(초)별 ``get_universe_at`` 재현 + EXIT 디바운스. 실매: 조건검색 변동 시 초단위 스냅샷(``target_candidates_history``) → 루프에서 그 시각 최신 유니버스. 백테(구): 1분 슬롯 strict 집계 → 깜빡임 EXIT·분봉 끝 집계로 실매와 어긋남. 이 모듈은 전략 무관(strategy_id 파라미터)으로, 모멘텀·돌파 등 모든 전략이 **하나의 ``UniverseTimeline``** 로 실매(``get_universe_at``)와 초단위 정합을 맞추도록 통일한다. (기존 ``MomentumUniverseTimeline`` 은 이 클래스의 alias 로 유지 — 모멘텀 코드 무변경) """ from __future__ import annotations from bisect import bisect_right from datetime import datetime, timedelta from typing import Any, Dict, List, Optional, Sequence, Set, Tuple def resolve_universe_exit_debounce_sec( *, strategy_env_key: Optional[str] = None, default_when_no_grace: int = 0, ) -> int: """실매 ``CONDITION_EXIT_GRACE_SEC`` 와 동일 초로 스냅샷 EXIT 디바운스. history 가 장초·overnight 전환에서 raw 축소로 찍혀도, 실매 EXIT grace 처럼 빠진 종목을 N초간 유지한다. 전략별 키가 있으면 그 값 우선(0=디바운스 OFF). """ from kis_trader.utils.env import get_env_from_db, get_env_int if strategy_env_key: raw = get_env_from_db(strategy_env_key, None) if raw not in (None, ""): try: return max(0, int(float(raw))) except (TypeError, ValueError): pass grace = int(get_env_int("CONDITION_EXIT_GRACE_SEC", 0)) if grace > 0: return grace return max(0, int(default_when_no_grace)) # strategy_id → (전략별 오버라이드 env 키, CONDITION_EXIT_GRACE=0 일 때 폴백) # 웹·Optuna·resolve_*_universe 가 이 맵만 쓰면 debounce=0 특수케이스가 사라진다. _STRATEGY_UNIVERSE_EXIT_DEBOUNCE: Dict[str, Tuple[Optional[str], int]] = { "SCALP": ("SCALP_UNIVERSE_EXIT_DEBOUNCE_SEC", 0), "MOMENTUM": ("MOMENTUM_UNIVERSE_EXIT_DEBOUNCE_SEC", 30), # 모멘텀 기존 폴백 30 유지 "BREAKOUT": ("BREAKOUT_UNIVERSE_EXIT_DEBOUNCE_SEC", 0), "SHORT": ("TAIL_UNIVERSE_EXIT_DEBOUNCE_SEC", 0), "TAIL": ("TAIL_UNIVERSE_EXIT_DEBOUNCE_SEC", 0), "RANGE_BREAK": ("RANGE_BREAK_UNIVERSE_EXIT_DEBOUNCE_SEC", 0), } def universe_exit_debounce_sec_for_strategy(strategy_id: str) -> int: """전략 공통 — history 슬롯·타임라인 EXIT 디바운스 초. 우선순위: ``{STRATEGY}_UNIVERSE_EXIT_DEBOUNCE_SEC`` → ``CONDITION_EXIT_GRACE_SEC`` → 전략별 폴백(모멘텀만 30, 나머지 0). """ sid = str(strategy_id or "").strip().upper() key, dflt = _STRATEGY_UNIVERSE_EXIT_DEBOUNCE.get(sid, (None, 0)) return resolve_universe_exit_debounce_sec( strategy_env_key=key, default_when_no_grace=dflt, ) def _event_time_to_key(event_time: str) -> str: """YYYYMMDDHHMMSSffffff (20자). 초만 있으면 마이크로=000000.""" et = str(event_time or "") if len(et) < 19: return "" base = ( et[0:4] + et[5:7] + et[8:10] + et[11:13] + et[14:16] + et[17:19] ) frac = "000000" if len(et) > 19 and et[19] == ".": frac = (et[20:26] + "000000")[:6] frac = "".join(ch if ch.isdigit() else "0" for ch in frac) frac = (frac + "000000")[:6] return base + frac def event_time_query_upper(at_time: str) -> str: """초 단위 조회 상한 = 그 초의 마지막 마이크로초 스냅샷까지.""" s = str(at_time or "").strip() if not s: return s if "." in s: return s if len(s) >= 19: return s[:19] + ".999999" return s def _codes_at_key(scan_key: str) -> str: """codes_at 비교키. 14자리(초)면 그 초 끝(999999)까지.""" sk = str(scan_key or "") if not sk: return "" if len(sk) <= 14: return (sk + "0" * 14)[:14] + "999999" if len(sk) < 20: return (sk + "9" * 20)[:20] return sk[:20] def _parse_scan_key(scan_key: str) -> datetime: s = str(scan_key or "") base = s[:14] dt = datetime.strptime(base, "%Y%m%d%H%M%S") if len(s) > 14: frac = "".join(ch for ch in s[14:20] if ch.isdigit()) frac = (frac + "000000")[:6] try: dt = dt.replace(microsecond=int(frac)) except (TypeError, ValueError): pass return dt def _add_minutes_to_scan_key(scan_key: str, minutes: int) -> str: s = str(scan_key or "") frac = s[14:20] if len(s) > 14 else "000000" frac = ("".join(ch for ch in frac if ch.isdigit()) + "000000")[:6] dt = datetime.strptime(s[:14], "%Y%m%d%H%M%S") out = (dt + timedelta(minutes=int(minutes))).strftime("%Y%m%d%H%M%S") return out + frac def _dedupe_codes_preserve_order(codes: Sequence[str]) -> List[str]: """스냅샷 내 종목 순서 유지 + 중복 제거 (HTS/DB insert 순).""" seen: Set[str] = set() out: List[str] = [] for c in codes: cs = str(c).strip() if cs and cs not in seen: seen.add(cs) out.append(cs) return out def normalize_universe_events(events: Sequence[Dict[str, Any]]) -> List[Tuple[str, List[str]]]: """iter_universe_events 결과 → (YYYYMMDDHHMMSS, codes) 시간순 리스트.""" out: List[Tuple[str, List[str]]] = [] for ev in events: et_key = _event_time_to_key(str(ev.get("event_time") or "")) if not et_key: continue codes = _dedupe_codes_preserve_order( [str(it["code"]) for it in ev.get("items", []) if it.get("code")] ) out.append((et_key, codes)) out.sort(key=lambda x: x[0]) return out def debounce_universe_snapshots( normalized: Sequence[Tuple[str, List[str]]], debounce_sec: int, ) -> List[Tuple[str, List[str]]]: """ 짧은 EXIT 후 재편입은 EXIT 로 치지 않음 (HTS 조건검색 노이즈 완화). 각 스냅샷 시각에 **디바운스 적용 후** 유효 종목 리스트를 기록 (가나다 재정렬 없음). """ if debounce_sec <= 0 or not normalized: return list(normalized) effective_order: List[str] = [] effective_set: Set[str] = set() pending_exit: Dict[str, Tuple[str, datetime]] = {} out: List[Tuple[str, List[str]]] = [] def _eff_remove(code: str) -> None: if code not in effective_set: return effective_set.discard(code) try: effective_order.remove(code) except ValueError: pass def _eff_add(code: str) -> None: if code in effective_set: return effective_set.add(code) effective_order.append(code) for et_key, codes in normalized: cur_list = _dedupe_codes_preserve_order([str(c) for c in codes if c]) cur_set = set(cur_list) et_dt = _parse_scan_key(et_key) for code, (ex_key, ex_dt) in list(pending_exit.items()): if (et_dt - ex_dt).total_seconds() >= debounce_sec: if code not in cur_set: _eff_remove(code) pending_exit.pop(code, None) for code in list(effective_order): if code not in cur_set and code not in pending_exit: pending_exit[code] = (et_key, et_dt) for code in cur_list: pending_exit.pop(code, None) _eff_add(code) out.append((et_key, list(effective_order))) return out class UniverseTimeline: """스캔 시각별 유니버스 — 이진 탐색으로 O(log N) 조회 (전략 공통).""" __slots__ = ("_keys", "_codes", "_strict_avail") def __init__( self, snapshots: Sequence[Tuple[str, List[str]]], *, strict: bool = False, strict_lag_minutes: int = 0, ) -> None: keys: List[str] = [] codes_list: List[List[str]] = [] strict_avail: Dict[str, str] = {} prev_set: Set[str] = set() for et_key, codes in snapshots: keys.append(et_key) ordered = _dedupe_codes_preserve_order([str(c) for c in codes if c]) cur_set = set(ordered) if strict: lag = max(0, int(strict_lag_minutes)) for c in cur_set - prev_set: strict_avail[c] = ( _add_minutes_to_scan_key(et_key, lag) if lag > 0 else et_key ) for c in prev_set - cur_set: strict_avail.pop(c, None) prev_set = cur_set codes_list.append(ordered) self._keys = keys self._codes = codes_list self._strict_avail = strict_avail def codes_at(self, scan_key: str) -> List[str]: """scan_key(YYYYMMDDHHMMSS 또는 +마이크로) 시점 유니버스 — 실매 get_universe_at 동일. 초만 주면 그 초의 **마지막** 스냅샷. """ sk = _codes_at_key(scan_key) if not sk or not self._keys: return [] idx = bisect_right(self._keys, sk) - 1 if idx < 0: return [] raw = list(self._codes[idx]) if not self._strict_avail: return raw return [c for c in raw if sk >= self._strict_avail.get(c, "00000000000000")] @property def snapshot_count(self) -> int: return len(self._keys) def build_universe_timeline( *, strategy_id: str, start_ymd: str, end_ymd: str, debounce_sec: int = 30, strict: bool = False, strict_lag_minutes: int = 1, history_source: str = "kiwoom", ) -> Optional[UniverseTimeline]: """이력 테이블(키움/LS) → 초단위 유니버스 타임라인. ``history_source``: ``kiwoom`` | ``ls`` — resolve/슬롯 dict 와 **동일 소스**여야 scan_at 매수가 라벨과 어긋나지 않는다. """ try: from kis_trader.database.db_manager import get_db from kis_trader.backtest.universe_history_source import ( apply_ls_session_filter_to_start, normalize_universe_history_source, resolve_backtest_universe_history_source, ) hs = resolve_backtest_universe_history_source(history_source) hs = normalize_universe_history_source(hs) ext = get_db() start_time = f"{start_ymd[:4]}-{start_ymd[4:6]}-{start_ymd[6:8]} 00:00:00" start_time = apply_ls_session_filter_to_start(start_time, source=hs) end_time = f"{end_ymd[:4]}-{end_ymd[4:6]}-{end_ymd[6:8]} 23:59:59.999999" events = ext.iter_universe_events( strategy_id=strategy_id, start_time=start_time, end_time=end_time, preserve_insert_order=True, history_source=hs, ) normalized = normalize_universe_events(events) if not normalized: return None debounced = debounce_universe_snapshots(normalized, debounce_sec) return UniverseTimeline( debounced, strict=strict, strict_lag_minutes=strict_lag_minutes, ) except Exception: return None