#!/usr/bin/env python3 """ kis_trader/backtest/param_search_pool.py — 파라서치 ProcessPool 공통 안전장치 ============================================================================ - PR_SET_PDEATHSIG + PPID 감시: 부모 종료·터미널 끊김 후에도 워커(PPID=1)가 CPU를 잡지 않게 함 - managed_process_pool: Ctrl+C / SIGTERM 시 cancel + terminate + kill 순 정리 - ParamSearchRunLock: 동일 전략 파라서치 중복 실행 방지 - ParamSearchSharedPayload: 캔들·유니버스 1회 파일 → 워커 init 1회 로드 (task마다 pickle 방지) - param_search_max_workers: CPU 80%(고정) + 가용 RAM × PARAM_SEARCH_MEM_FRAC(기본 80%) 중 min """ from __future__ import annotations import atexit import os import pickle import signal import sys import tempfile import time from concurrent.futures import ( FIRST_COMPLETED, Future, ProcessPoolExecutor, as_completed, wait, ) from contextlib import contextmanager from itertools import product from typing import Any, Callable, Dict, Iterator, List, Optional, Tuple, TypeVar from kis_trader.utils.env import get_env_float, get_env_int # 워커 프로세스 전용 — fork/spawn 직후 worker_init·shared init 에서 기록 _EXPECTED_PPID: int = 0 _WORKER_SHARED: Optional[Dict[str, Any]] = None T = TypeVar("T") def worker_init() -> None: """ProcessPoolExecutor initializer — 워커 생성 직후 한 번 호출.""" global _EXPECTED_PPID _EXPECTED_PPID = os.getppid() try: if sys.platform.startswith("linux"): import ctypes PR_SET_PDEATHSIG = 1 libc = ctypes.CDLL("libc.so.6", use_errno=True) libc.prctl(PR_SET_PDEATHSIG, signal.SIGTERM, 0, 0, 0) except Exception: pass try: signal.signal(signal.SIGINT, signal.SIG_IGN) except Exception: pass def worker_shared_get() -> Dict[str, Any]: """워커: ParamSearchSharedPayload 로 로드된 캔들·유니버스 등.""" return _WORKER_SHARED if isinstance(_WORKER_SHARED, dict) else {} def _worker_init_from_shared_path(payload_path: str) -> None: """워커 1회: 디스크 pickle → _WORKER_SHARED (task 인자로 대용량 dict 전달 금지).""" global _WORKER_SHARED worker_init() try: with open(payload_path, "rb") as f: _WORKER_SHARED = pickle.load(f) except Exception: _WORKER_SHARED = {} def linux_mem_available_bytes() -> int: """Linux MemAvailable (없으면 MemFree).""" try: mem: Dict[str, int] = {} with open("/proc/meminfo", "r", encoding="utf-8") as f: for line in f: parts = line.split() if len(parts) >= 2 and parts[0].rstrip(":") in ( "MemAvailable", "MemFree", "SwapFree", ): mem[parts[0].rstrip(":")] = int(parts[1]) * 1024 if "MemAvailable" in mem: return mem["MemAvailable"] if "MemFree" in mem: return mem["MemFree"] except OSError: pass return 2 * 1024 ** 3 def estimate_pickle_bytes(obj: Any) -> int: """대용량 payload 예상 크기 (pickle 직렬화 길이).""" try: return len(pickle.dumps(obj, protocol=pickle.HIGHEST_PROTOCOL)) except Exception: return 0 def downsample_combos_uniform( combos: List[Any], max_combos: int, ) -> Tuple[List[Any], int]: """그리드 조합이 많을 때 균등 간격 샘플링 (tail/momentum fast 공통).""" cap = int(max_combos) total = len(combos) if cap <= 0 or total <= cap: return combos, 0 if cap == 1: return [combos[0]], total - 1 last = total - 1 picked: List[Any] = [] for i in range(cap): idx = int(round(i * last / (cap - 1))) picked.append(combos[idx]) return picked, max(0, total - len(picked)) def resolve_param_search_max_combos( mode: str, *, strategy_env_prefix: str, default_fast: int, default_other: int = 0, max_combos_override: Optional[int] = None, ) -> int: """ fast 모드 기본: 균등 샘플 상한. 우선순위: CLI override → {PREFIX}_FAST_MAX_COMBOS → PARAM_SEARCH_FAST_MAX_COMBOS. """ if max_combos_override is not None: return max(0, int(max_combos_override)) m = (mode or "fast").strip().lower() if m == "fast": specific = get_env_int(f"{strategy_env_prefix}_FAST_MAX_COMBOS", 0) if specific > 0: return specific return get_env_int("PARAM_SEARCH_FAST_MAX_COMBOS", default_fast) cap = get_env_int(f"{strategy_env_prefix}_PARAM_SEARCH_MAX_COMBOS", default_other) return max(0, cap) def cap_combos_uniform( dict_combos: List[Any], mode: str, *, strategy_env_prefix: str, default_fast: int, default_other: int = 0, max_combos_override: Optional[int] = None, ) -> Tuple[List[Any], int, int, int]: """그리드 전체 → (균등 샘플 리스트, 그리드 크기, 적용 cap, 제외 개수).""" total_grid = len(dict_combos) cap = resolve_param_search_max_combos( mode, strategy_env_prefix=strategy_env_prefix, default_fast=default_fast, default_other=default_other, max_combos_override=max_combos_override, ) dropped = 0 sampled = dict_combos if cap > 0 and total_grid > cap: sampled, dropped = downsample_combos_uniform(dict_combos, cap) return sampled, total_grid, cap, dropped def grid_total_combinations(axes: List[List[Any]]) -> int: """축별 값 개수의 곱 — 데카르트곱을 **펼치지 않고** 전체 조합 수만 계산. (list(product(...)) 로 수천만 개를 RAM 에 펼치다 OOM 나는 것을 막기 위함) """ total = 1 for vals in axes: n = len(vals) if n <= 0: return 0 total *= n return total def _decode_combo_by_index(axes: List[List[Any]], idx: int) -> Tuple[Any, ...]: """혼합진법 디코딩: 정수 idx → 각 축의 값 (마지막 축이 최하위 자리). itertools.product 와 동일한 순서의 idx→조합 매핑이라, '전체 리스트를 안 만들고' 원하는 자리(idx)의 조합만 바로 꺼낼 수 있다. 초대형 그리드 균등샘플 핵심. """ out: List[Any] = [None] * len(axes) for ax in range(len(axes) - 1, -1, -1): n = len(axes[ax]) out[ax] = axes[ax][idx % n] idx //= n return tuple(out) def _uniform_indices(total: int, count: int) -> List[int]: """0..total-1 에서 균등 간격 정수 인덱스 count개 (stage1 균등샘플과 동일한 간격).""" if total <= 0 or count <= 0: return [] if count == 1: return [0] if count >= total: return list(range(total)) last = total - 1 seen: set = set() out: List[int] = [] for i in range(count): idx = int(round(i * last / (count - 1))) if idx not in seen: seen.add(idx) out.append(idx) return out def cap_combos_uniform_lazy( keys: List[str], axes: List[List[Any]], mode: str, *, strategy_env_prefix: str, default_fast: int, default_other: int = 0, max_combos_override: Optional[int] = None, valid_fn: Optional[Callable[[Dict[str, Any]], bool]] = None, max_materialize: Optional[int] = None, ) -> Tuple[List[Dict[str, Any]], int, int, int]: """데카르트곱을 통째로 펼치지 않고 (cap 균등샘플 / 전수) dict 조합 생성. - 전체 곱이 PARAM_SEARCH_MAX_MATERIALIZE(기본 200만) **이하**면 기존 경로 그대로 (materialize → 유효성 필터 → 균등 cap). 기존 동작·결과 100% 동일. - 그보다 **크면** 인덱스 균등샘플: 균등 간격 인덱스를 혼합진법으로 디코딩해 그 조합만 생성한다. 수천만 개를 펼치지 않으므로 OOM 없음 + 균등 커버리지 유지. 반환 형식은 cap_combos_uniform 과 동일: (dict_combos, total_grid, cap, dropped) """ cap = resolve_param_search_max_combos( mode, strategy_env_prefix=strategy_env_prefix, default_fast=default_fast, default_other=default_other, max_combos_override=max_combos_override, ) if max_materialize is None: max_materialize = get_env_int("PARAM_SEARCH_MAX_MATERIALIZE", 2_000_000) raw_total = grid_total_combinations(axes) # ── 작은 그리드: 기존 경로 그대로 (동작·결과 동일) ── if raw_total <= max(1, max_materialize): dict_combos = [dict(zip(keys, c)) for c in product(*axes)] if valid_fn is not None: dict_combos = [c for c in dict_combos if valid_fn(c)] total_grid = len(dict_combos) dropped = 0 if cap > 0 and total_grid > cap: dict_combos, dropped = downsample_combos_uniform(dict_combos, cap) return dict_combos, total_grid, cap, dropped # ── 초대형 그리드: 펼치지 않고 인덱스 균등샘플 ── target = cap if cap > 0 else max_materialize # 유효성 필터로 일부 빠질 수 있어 넉넉히 뽑은 뒤(oversample) 통과분을 다시 균등 cap. oversample_mult = max(1, get_env_int("PARAM_SEARCH_OVERSAMPLE_MULT", 4)) n_idx = min(raw_total, max(target, target * oversample_mult)) picked: List[Dict[str, Any]] = [] for idx in _uniform_indices(raw_total, n_idx): c = dict(zip(keys, _decode_combo_by_index(axes, idx))) if valid_fn is None or valid_fn(c): picked.append(c) if cap > 0 and len(picked) > cap: picked, _ = downsample_combos_uniform(picked, cap) dropped = max(0, raw_total - len(picked)) return picked, raw_total, cap, dropped def param_search_workers_by_memory(payload_bytes: int) -> int: """ 가용 RAM × PARAM_SEARCH_MEM_FRAC(기본 0.8) 안에서 워커 수 추정. 부모 1×payload + 워커당 ~(payload + WORKER_EXTRA) 보수적 가정. """ avail = linux_mem_available_bytes() frac = get_env_float("PARAM_SEARCH_MEM_FRAC", 0.8) budget = max(0, int(avail * frac)) parent_extra = get_env_int("PARAM_SEARCH_PARENT_EXTRA_MB", 512) * 1024 ** 2 worker_extra = get_env_int("PARAM_SEARCH_WORKER_EXTRA_MB", 384) * 1024 ** 2 fallback = get_env_int("PARAM_SEARCH_MB_PER_WORKER", 768) * 1024 ** 2 parent_need = (max(0, payload_bytes) + parent_extra) if payload_bytes > 0 else parent_extra + fallback per_worker = (max(0, payload_bytes) + worker_extra) if payload_bytes > 0 else fallback remain = budget - parent_need if remain <= 0: return 1 return max(1, remain // max(per_worker, 1)) def param_search_max_workers(payload_bytes: int = 0) -> int: """CPU 코어 × PARAM_SEARCH_CPU_FRAC(기본 0.8=80% 고정) vs 메모리 예산 — min.""" n_cpu = os.cpu_count() or 4 cpu_frac = get_env_float("PARAM_SEARCH_CPU_FRAC", 0.8) cpu_workers = max(1, int(n_cpu * cpu_frac)) cap = get_env_int("PARAM_SEARCH_MAX_WORKERS", 0) if cap > 0: cpu_workers = min(cpu_workers, cap) mem_workers = param_search_workers_by_memory(payload_bytes) return max(1, min(cpu_workers, mem_workers)) def param_search_worker_budget_line(payload_bytes: int) -> str: """run_search 로그용 — 가용 메모리·워커 산출 근거.""" avail_gb = linux_mem_available_bytes() / (1024 ** 3) frac = get_env_float("PARAM_SEARCH_MEM_FRAC", 0.8) cpu_w = max(1, int((os.cpu_count() or 4) * get_env_float("PARAM_SEARCH_CPU_FRAC", 0.8))) mem_w = param_search_workers_by_memory(payload_bytes) workers = param_search_max_workers(payload_bytes) payload_mb = payload_bytes / (1024 ** 2) if payload_bytes > 0 else 0.0 return ( f"💾 메모리: 가용 {avail_gb:.1f}GB × {frac * 100:.0f}% 예산 | " f"payload ~{payload_mb:.0f}MB | CPU워커≤{cpu_w} RAM워커≤{mem_w} → 실제 {workers}" ) def param_search_chunk_size(total: int, max_workers: int) -> int: """ 청크 크기 — 조합 수가 적어도 워커 수 이상으로 나눠 CPU 포화. (fast 240조합 × 청크 50 → 5청크만 → 5코어만 100% 사용되는 문제 방지) """ if total <= 0: return 50 chunk_max = get_env_int("PARAM_SEARCH_CHUNK_MAX", 300) mult = max(1, get_env_int("PARAM_SEARCH_CHUNK_MULTIPLIER", 4)) target_chunks = min(total, max(max_workers, max_workers * mult)) return max(1, min(chunk_max, -(-total // target_chunks))) def param_search_chunk_plan( total: int, payload_bytes: int = 0, ) -> Tuple[int, int, int]: """(max_workers, chunk_size, num_chunks) — run_search 공통.""" workers = param_search_max_workers(payload_bytes) size = param_search_chunk_size(total, workers) n_chunks = max(1, -(-total // size)) return workers, size, n_chunks class ParamSearchProgressETA: """멀티프로세스 파도식 청크 완료에 맞춘 ETA 추정기. 워커 N개가 동시에 청크를 처리하면 완료가 파도(배치) 단위로 몰린다. 청크 간격 기반 ETA는 0↔파도통째로 요동하므로, 워밍업 1파도 제외 누적 평균 사용. """ def __init__(self, total_chunks: int, max_workers: int) -> None: self._total_chunks = max(1, total_chunks) self._warmup_chunks = min(max(1, max_workers), self._total_chunks) self._warmup_end_elapsed: Optional[float] = None def remaining_sec(self, processed: int, elapsed_so_far: float) -> float: """남은 예상 초 — 워밍업 파도 이후 누적 평균 × 남은 청크.""" processed = max(1, processed) remaining_chunks = self._total_chunks - processed if remaining_chunks <= 0: return 0.0 if processed == self._warmup_chunks: self._warmup_end_elapsed = elapsed_so_far if self._warmup_end_elapsed is not None and processed > self._warmup_chunks: steady_elapsed = elapsed_so_far - self._warmup_end_elapsed steady_done = processed - self._warmup_chunks per_chunk = steady_elapsed / max(1, steady_done) else: per_chunk = elapsed_so_far / processed return per_chunk * remaining_chunks @staticmethod def format_sec(sec: float) -> str: """초 → 'N분 N초' / 'N시간 N분 N초'.""" eta_m, eta_s = divmod(max(0, int(sec)), 60) eta_h, eta_m = divmod(eta_m, 60) if eta_h > 0: return f"{eta_h}시간 {eta_m}분 {eta_s}초" if eta_m > 0: return f"{eta_m}분 {eta_s}초" return f"{eta_s}초" @staticmethod def format_elapsed(elapsed_so_far: float) -> str: """경과 초 → 'N분 N초'.""" elapsed_m, elapsed_s = divmod(max(0, int(elapsed_so_far)), 60) return f"{elapsed_m}분 {elapsed_s}초" @staticmethod def render_bar(frac: float, width: int = 20) -> str: """진행 바: 채움 ■ / 빈칸 □ (시각적 진행 표시용).""" frac = max(0.0, min(1.0, frac)) filled = int(round(frac * width)) return "■" * filled + "□" * (width - filled) def param_search_max_inflight(max_workers: int) -> int: """동시 pending Future 상한 (큐에 대용량 인자 적재 방지).""" extra = get_env_int("PARAM_SEARCH_INFLIGHT_EXTRA", 1) cap = get_env_int("PARAM_SEARCH_MAX_INFLIGHT", 0) if cap > 0: return max(1, cap) return max(1, max_workers + max(0, extra)) class ParamSearchSharedPayload: """ 부모: 캔들·유니버스 등 1회 pickle 파일. 워커: init 시 1회 로드 → evaluate 에서 worker_shared_get() 사용. """ def __init__(self, data: Dict[str, Any]) -> None: self.data = dict(data) self.path: str = "" self._prepared = False def estimate_bytes(self) -> int: return estimate_pickle_bytes(self.data) def prepare(self) -> str: if self._prepared and self.path and os.path.isfile(self.path): return self.path fd, path = tempfile.mkstemp(prefix="kis_param_search_", suffix=".pkl") os.close(fd) with open(path, "wb") as f: pickle.dump(self.data, f, protocol=pickle.HIGHEST_PROTOCOL) self.path = path self._prepared = True return self.path def cleanup(self) -> None: if self.path and os.path.isfile(self.path): try: os.remove(self.path) except OSError: pass self.path = "" self._prepared = False def make_process_pool( max_workers: int, *, shared_payload_path: Optional[str] = None, ) -> ProcessPoolExecutor: """ProcessPoolExecutor 생성 (max_tasks_per_child 로 장시간 워커 누수 완화). shared_payload_path: spawn 시 pickle 가능한 모듈 함수 + initargs 로 전달 (nested closure 금지). """ max_tasks = get_env_int("PARAM_SEARCH_MAX_TASKS_PER_CHILD", 50) if shared_payload_path: init_fn = _worker_init_from_shared_path init_args: Tuple[Any, ...] = (shared_payload_path,) else: init_fn = worker_init init_args = () kwargs: dict = { "max_workers": max_workers, "initializer": init_fn, "initargs": init_args, } if max_tasks > 0: kwargs["max_tasks_per_child"] = max_tasks return ProcessPoolExecutor(**kwargs) def shutdown_executor_hard( executor: Optional[ProcessPoolExecutor], *, wait_sec: Optional[float] = None, ) -> None: """Executor 워커: cancel → shutdown(wait=False) → SIGTERM → SIGKILL.""" if executor is None: return if wait_sec is None: wait_sec = get_env_float("PARAM_SEARCH_SHUTDOWN_WAIT_SEC", 8.0) try: executor.shutdown(wait=False, cancel_futures=True) except TypeError: executor.shutdown(wait=False) except Exception: pass procs = getattr(executor, "_processes", None) if not procs: return deadline = time.time() + wait_sec for proc in list(procs.values()): try: if proc.is_alive(): proc.terminate() except Exception: pass while time.time() < deadline: if not any(p.is_alive() for p in procs.values()): break time.sleep(0.2) for proc in procs.values(): try: if proc.is_alive(): proc.kill() except Exception: pass @contextmanager def managed_process_pool( max_workers: int, *, shared_payload: Optional[ParamSearchSharedPayload] = None, ) -> Iterator[ProcessPoolExecutor]: """정상 종료는 wait=True, KeyboardInterrupt 등은 hard shutdown.""" payload_path = shared_payload.prepare() if shared_payload else None executor = make_process_pool(max_workers, shared_payload_path=payload_path) try: yield executor except (KeyboardInterrupt, SystemExit): shutdown_executor_hard(executor) raise except BaseException: shutdown_executor_hard(executor) raise else: try: executor.shutdown(wait=True, cancel_futures=False) except TypeError: executor.shutdown(wait=True) except Exception: shutdown_executor_hard(executor) finally: if shared_payload is not None: shared_payload.cleanup() def iter_pool_chunk_results( executor: ProcessPoolExecutor, chunks: List[Any], submit_fn: Callable[[Any], Future], *, max_workers: int = 1, max_inflight: Optional[int] = None, heartbeat_sec: Optional[float] = None, on_heartbeat: Optional[Callable[[int, int, float, int], None]] = None, ) -> Iterator[Any]: """ 청크별 Future — pending 수를 max_inflight 로 제한 (메모리·큐 폭주 방지). submit_fn(chunk) → Future (대용량 dict 는 ParamSearchSharedPayload 사용). 완료 청크가 안 나오는 '대기 구간'마다(첫 청크 워밍업 포함) 하트비트를 찍어 화면이 멈춘 것처럼 보이지 않게 한다. (사람이 진행을 눈으로 확인하고 안심하도록) - on_heartbeat(done_count, total, elapsed_sec, running) 콜백을 주면 그걸 호출 (호출자가 진행 바·경과·마지막 ETA 를 같은 줄에 다시 그림 — 권장). - 콜백이 없으면 내장 하트비트(진행 바 + 완료 수 + 경과)를 직접 출력. 터미널이면 ~1초마다, 파일(nohup)이면 heartbeat_sec(기본 10초)마다 갱신. """ if not chunks: return inflight = max_inflight if max_inflight is not None else param_search_max_inflight(max_workers) if heartbeat_sec is None: heartbeat_sec = get_env_float("PARAM_SEARCH_HEARTBEAT_SEC", 10.0) use_cr = sys.stdout.isatty() # 터미널은 자주(1초) 갱신해 진행 바가 살아있게, 파일은 너무 잦은 줄 방지로 길게. beat_iv = 1.0 if use_cr else max(1.0, heartbeat_sec) total = len(chunks) pending: Dict[Future, Any] = {} chunk_iter = iter(chunks) def _fill() -> None: while len(pending) < inflight: try: ch = next(chunk_iter) except StopIteration: break pending[submit_fn(ch)] = ch _fill() start = time.time() done_count = 0 while pending: # 완료된 청크가 생길 때까지 beat_iv 단위로 끊어 대기 → 그 사이 하트비트 출력. done, _ = wait(list(pending.keys()), timeout=beat_iv, return_when=FIRST_COMPLETED) if not done: if heartbeat_sec <= 0: continue el = time.time() - start running = len(pending) if on_heartbeat is not None: on_heartbeat(done_count, total, el, running) else: bar = ParamSearchProgressETA.render_bar(done_count / max(1, total)) if done_count == 0: label = f"🔥 워밍업·첫 청크 진행 중(워커 {running})" else: label = f"⚙️ 처리 중(진행 {running})" msg = (f"{label} {bar} {done_count}/{total} | " f"경과 {ParamSearchProgressETA.format_elapsed(el)}") if use_cr: sys.stdout.write(f"\r{msg} ") sys.stdout.flush() else: print(msg, flush=True) continue for fut in done: pending.pop(fut, None) done_count += 1 yield fut.result() _fill() def _is_pid_alive(pid: int) -> bool: if pid <= 0: return False try: os.kill(pid, 0) except ProcessLookupError: return False except PermissionError: return True else: return True class ParamSearchRunLock: """동일 이름 파라서치 중복 실행 방지 (PID 락 파일).""" def __init__(self, name: str) -> None: self.name = name lock_dir = os.path.join(os.path.expanduser("~"), ".kis_bot_locks") os.makedirs(lock_dir, exist_ok=True) self.path = os.path.join(lock_dir, f"{name}.lock") self._acquired = False def acquire(self) -> bool: if os.path.isfile(self.path): old_pid = 0 try: with open(self.path, "r", encoding="utf-8") as f: old_pid = int(f.read().strip().split()[0]) except (ValueError, OSError): old_pid = 0 if _is_pid_alive(old_pid): return False try: os.remove(self.path) except OSError: pass try: fd = os.open(self.path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644) with os.fdopen(fd, "w", encoding="utf-8") as f: f.write(f"{os.getpid()}\n") self._acquired = True atexit.register(self.release) return True except FileExistsError: return False def release(self) -> None: if not self._acquired: return try: if os.path.isfile(self.path): with open(self.path, "r", encoding="utf-8") as f: content = f.read().strip() if content.startswith(str(os.getpid())): os.remove(self.path) except OSError: pass self._acquired = False def __enter__(self) -> "ParamSearchRunLock": if not self.acquire(): raise RuntimeError( f"이미 실행 중인 {self.name} 파라서치가 있습니다.\n" f" ps -ef | grep {self.name}\n" f" pkill -f '{self.name}' 또는 rm {self.path}" ) return self def __exit__(self, *_args) -> None: self.release() def try_acquire_run_lock(name: str) -> Optional[ParamSearchRunLock]: """락 획득 실패 시 None (main 에서 메시지 출력용).""" lock = ParamSearchRunLock(name) if lock.acquire(): return lock return None def assert_parent_alive() -> None: """ 워커 청크 루프에서 호출. 부모가 죽어 init(PPID=1)에 붙었거나 예상 부모와 다르면 즉시 종료. """ ppid = os.getppid() if ppid == 1: os._exit(0) if _EXPECTED_PPID > 1 and ppid != _EXPECTED_PPID: os._exit(0)