#!/usr/bin/env python3 """ kis_trader/backtest/shared_ticks.py — 파라서치 ws_ticks 공유메모리 저장 (spawn-safe) ===================================================================================== [왜 필요한가] 파라서치 워커는 spawn(= max_tasks_per_child 가 강제) 방식이라, 워커마다 틱 dict (관측 ~3.3GB) 을 각자 디스크 pickle 에서 재역직렬화한다 → 메모리 N배·기동 지연. fork COW 로 공유하려 하면 부모가 멀티스레드(DB·로깅)라 fork+threads 데드락이 난다. [해결] 틱을 컬럼(numpy 배열) 로 평탄화해 ``multiprocessing.shared_memory`` 에 **1벌**만 올린다. 워커는 블록 '이름'으로 read-only attach(mmap → 물리 사본 1개 공유). 접근 시 해당 분(minute) 틱만 즉석에서 dict 로 만들어 반환한다(원본 스키마 100% 동일). fork 가 아니라 spawn-safe. [원본 자료구조] {code: {minute_key(12자리): [ {code,tick_time,price,volume,source}, ... ]}} [공유 표현] 평탄화 배열 price(f8) / volume(i8) / tick_time(S14) / source(S..) / epoch(i8) + 인덱스 {code: {minute: (offset, count)}} epoch(=_tick_epoch_sec) 은 사전계산해, 핫루프(휩쏘/청산)가 문자열을 재파싱하지 않고 ``TickColumnView`` 로 배열을 직접 읽게 한다(dict 재구성 제거 → 속도 회복). [동일성 보장] 읽기 래퍼(SharedTicksMapping / SharedBucketMapping)는 ``collections.abc.Mapping`` 이라 dict 프로토콜(.get/.keys/.values/.items/in/len/bool/dict()) 을 원본 dict 와 동일하게 제공한다. 분(minute) 값은 lazy 시퀀스(_TickList)로 감싸, ``len()``·``any()``·``bool()`` 같은 콤보별 집계 스캔은 dict 를 만들지 않고 O(1) 로 답하고, 실제로 순회(list()/extend())할 때만 원본과 완전히 같은 tick dict 를 생성한다. 기본은 사용하지 않는다(호출부에서 env 플래그로 opt-in). numpy/shared_memory 가 없으면 build_shared_ticks 가 None 을 반환해 호출부가 기존(디스크 pickle) 경로로 자연히 폴백한다. """ from __future__ import annotations from collections.abc import Mapping, Sequence from typing import Any, Dict, List, Optional, Tuple try: import numpy as np from multiprocessing import shared_memory _SHM_AVAILABLE = True except Exception: # pragma: no cover - numpy 미설치 환경 폴백 _SHM_AVAILABLE = False def shared_ticks_available() -> bool: """numpy + shared_memory 사용 가능 여부.""" return _SHM_AVAILABLE # 컬럼 dtype (엔디안 고정 — 다른 프로세스에서 attach 해도 동일 해석) _DT_PRICE = " None: """워커(attach 전용)가 종료할 때 shared_memory 를 unlink 하지 못하게 한다. 파이썬 resource_tracker 는 attach 만 한 자식도 종료 시 소유자로 오인해 unlink 를 시도하는 알려진 함정이 있다(부모가 아직 쓰는데 블록이 사라짐). attach 직후 등록을 해제해, unlink 권한은 오직 '생성한 부모'에게만 남긴다. """ try: from multiprocessing import resource_tracker # type: ignore resource_tracker.unregister(f"/{name}", "shared_memory") except Exception: pass class _TickList(Sequence): """한 분(minute) 의 틱 리스트 — 순회할 때만 원본과 동일한 tick dict 를 생성. ``len()`` / ``bool()`` 은 O(1)(count 만) 이라, 콤보마다 도는 커버리지·집계 스캔이 dict 를 만들지 않고 값싸게 끝난다. ``list(...)`` / ``extend(...)`` / 인덱싱 시에만 실제 dict 를 만든다. """ __slots__ = ("_owner", "_code", "_off", "_cnt") def __init__(self, owner: "SharedTicksMapping", code: str, off: int, cnt: int) -> None: self._owner = owner self._code = code self._off = off self._cnt = cnt def __len__(self) -> int: return self._cnt def __getitem__(self, i): if isinstance(i, slice): start, stop, step = i.indices(self._cnt) return [self._owner._make_tick(self._code, self._off + j) for j in range(start, stop, step)] if i < 0: i += self._cnt if i < 0 or i >= self._cnt: raise IndexError(i) return self._owner._make_tick(self._code, self._off + i) def column_view(self) -> "TickColumnView": """이 분(minute) 구간을 dict 재구성 없이 배열 직접 읽는 컬럼 뷰로 반환. ``bucket.get(minute)`` 가 반환한 _TickList 를 그대로 컬럼 뷰(단일 세그먼트)로 바꿔, 백테 틱재생 핫루프(breakout intrabar 등)가 dict 생성 없이 순회하도록 한다. 저장 순서(로더 ORDER BY tick_time)를 그대로 보존 → dict 경로 정렬과 동일. """ self._owner._ensure() if self._cnt <= 0: return TickColumnView(self._owner, []) return TickColumnView(self._owner, [(self._off, self._cnt)]) class TickColumnView: """공유 컬럼 배열 위의 '틱 구간 뷰' — dict 재구성 없이 배열을 직접 읽는다. [왜] 분(minute) 값을 dict 리스트로 재구성하면(_TickList) 틱마다 문자열 디코드·형변환 비용이 들어, 엔진이 같은 구간을 반복 스캔하는 휩쏘/청산 핫루프에서 dict판 대비 ~100배 느려진다(실측). 이 뷰는 owner 의 컬럼 배열(_epoch/_price/_volume/_tick_time)을 절대 인덱스로 직접 읽어 그 재구성 비용을 없앤다. segments: [(offset, count), ...] — 원본 merged 순서를 그대로 보존한다. ``iter_idx()`` 는 그 순서대로 절대 인덱스를 산출하므로, dict 경로에서 ``merged.extend(bucket[mk])`` 로 만든 리스트 순회와 완전히 동일한 순서다. """ __slots__ = ("owner", "segments", "_len") def __init__(self, owner: "SharedTicksMapping", segments: List[Tuple[int, int]]) -> None: self.owner = owner self.segments = segments total = 0 for _off, cnt in segments: total += cnt self._len = total def __len__(self) -> int: return self._len def __bool__(self) -> bool: return self._len > 0 def iter_idx(self): """merged 순서대로 절대 배열 인덱스 산출 (dict 리스트 순회와 동일 순서).""" for off, cnt in self.segments: end = off + cnt i = off while i < end: yield i i += 1 def cap_by_tick_time_le(self, key14: str) -> "TickColumnView": """tick_time[:14] <= key14 인 틱만 남긴 새 뷰 (라이브스캔큐 매도 캡핑용). dict 경로 ``[tk for tk in minute_ticks if str(tk['tick_time'])[:14] <= scan_key[:14]]`` 와 동일 semantics — 매 틱 문자열([:14]) 비교로 통과분을 순서대로 모아 세그먼트로 합친다(정렬 가정에 의존하지 않아, 원본 리스트 컴프리헨션과 bit-identical). """ ttarr = self.owner._tick_time segs: List[Tuple[int, int]] = [] cur_off = -1 cur_cnt = 0 for i in self.iter_idx(): tt = ttarr[i].decode("utf-8") if tt[:14] <= key14: if cur_off < 0: cur_off, cur_cnt = i, 1 elif i == cur_off + cur_cnt: cur_cnt += 1 else: segs.append((cur_off, cur_cnt)) cur_off, cur_cnt = i, 1 if cur_off >= 0: segs.append((cur_off, cur_cnt)) return TickColumnView(self.owner, segs) def first_idx(self) -> int: for off, cnt in self.segments: if cnt > 0: return off return -1 def last_idx(self) -> int: for off, cnt in reversed(self.segments): if cnt > 0: return off + cnt - 1 return -1 class SharedBucketMapping(Mapping): """한 종목의 {minute_key: [tick,...]} 매핑 — 값은 lazy _TickList. Mapping 상속으로 .get/.keys/.values/.items/in/len/bool/dict() 를 원본 dict 와 동일하게 제공한다(누락 키 → .get 은 None, 원본 동작과 일치). """ __slots__ = ("_owner", "_code", "_cidx") def __init__(self, owner: "SharedTicksMapping", code: str, cidx: Dict[str, Tuple[int, int]]) -> None: self._owner = owner self._code = code self._cidx = cidx def __getitem__(self, minute: str) -> _TickList: off, cnt = self._cidx[minute] # KeyError → Mapping.get 이 기본값 처리 self._owner._ensure() return _TickList(self._owner, self._code, off, cnt) def __iter__(self): return iter(self._cidx) def __len__(self) -> int: return len(self._cidx) # ── 컬럼 직접접근 뷰 (백테 틱재생 핫루프 전용) ─────────────────────── def column_view_minute(self, minute_key: str) -> "TickColumnView": """단일 분(minute) 구간 뷰 — collect_minute_ticks 컬럼판. 원본은 해당 분 틱을 tick_time 오름차순 정렬해 반환하는데, 로더가 이미 ``ORDER BY code, tick_time`` 로 담아둔 저장 순서가 오름차순이라 정렬은 무연산(stable no-op)이다 → 저장 순서 뷰가 정렬 결과와 완전히 동일하다. """ self._owner._ensure() seg = self._cidx.get(str(minute_key)[:12]) if seg is None or seg[1] <= 0: return TickColumnView(self._owner, []) return TickColumnView(self._owner, [seg]) def column_view_lookback(self, minute_keys: List[str]) -> "TickColumnView": """여러 분(minute) 을 주어진 순서대로 이어붙인 뷰 — collect_whipsaw_ticks 컬럼판. minute_keys 는 호출부에서 이미 원하는 순서(과거→최신)로 정렬해 넘긴다. 원본 ``merged.extend(bucket.get(mk) or [])`` 와 동일하게, 없는/빈 분은 건너뛴다. """ self._owner._ensure() segs: List[Tuple[int, int]] = [] for mk in minute_keys: seg = self._cidx.get(mk) if seg is not None and seg[1] > 0: segs.append(seg) return TickColumnView(self._owner, segs) class SharedTicksMapping(Mapping): """워커측 최상위 {code: bucket} 매핑 — 원본 ticks_by_code 를 그대로 대체. descriptor(이름·dtype·인덱스) 만 있으면 어느 프로세스에서든 attach 해 쓴다. 실제 shared_memory attach 는 처음 종목 접근 시 1회(lazy). """ def __init__(self, descriptor: Dict[str, Any]) -> None: self._meta = descriptor self._index: Dict[str, Dict[str, Tuple[int, int]]] = descriptor["index"] self._total: int = int(descriptor["total"]) self._src_dtype: str = descriptor["src_dtype"] self._names: Dict[str, str] = descriptor["names"] self._attached = False self._shms: Dict[str, Any] = {} self._price = None self._volume = None self._tick_time = None self._source = None self._epoch = None # ── attach (워커 1회) ──────────────────────────────────────────────── def _ensure(self) -> None: if self._attached: return for key, name in self._names.items(): shm = shared_memory.SharedMemory(name=name) self._shms[key] = shm _unregister_shm_from_tracker(name) # attach 전용 → unlink 권한 포기 self._price = np.ndarray((self._total,), dtype=_DT_PRICE, buffer=self._shms["price"].buf) self._volume = np.ndarray((self._total,), dtype=_DT_VOLUME, buffer=self._shms["volume"].buf) self._tick_time = np.ndarray((self._total,), dtype=_DT_TICKTIME, buffer=self._shms["tick_time"].buf) self._source = np.ndarray((self._total,), dtype=self._src_dtype, buffer=self._shms["source"].buf) self._epoch = np.ndarray((self._total,), dtype=_DT_EPOCH, buffer=self._shms["epoch"].buf) self._attached = True def _make_tick(self, code: str, i: int) -> Dict[str, Any]: """원본 로더가 만든 tick dict 와 100% 동일한 dict 를 복원.""" return { "code": code, "tick_time": self._tick_time[i].decode("utf-8"), "price": float(self._price[i]), "volume": int(self._volume[i]), "source": self._source[i].decode("utf-8"), } def __getitem__(self, code: str) -> SharedBucketMapping: cidx = self._index[code] # KeyError → Mapping.get 기본값 처리 self._ensure() return SharedBucketMapping(self, code, cidx) def __iter__(self): return iter(self._index) def __len__(self) -> int: return len(self._index) def close(self) -> None: """워커/사용측 attach 해제(블록 삭제 아님 — unlink 는 부모만).""" for shm in self._shms.values(): try: shm.close() except Exception: pass self._shms = {} self._attached = False class SharedTickStore: """부모: 컬럼 배열을 shared_memory 에 올리고 핸들을 보관(=블록 생존 유지). - descriptor(): 워커로 넘길 가벼운 dict (shm 이름·dtype·shape + 인덱스). - unlink(): 탐색 종료 후 블록 삭제(부모만 호출). """ def __init__(self, shms: Dict[str, Any], meta: Dict[str, Any]) -> None: self._shms = shms self._meta = meta def descriptor(self) -> Dict[str, Any]: return self._meta def close(self) -> None: for shm in self._shms.values(): try: shm.close() except Exception: pass def unlink(self) -> None: for shm in self._shms.values(): try: shm.close() except Exception: pass try: shm.unlink() except Exception: pass self._shms = {} def attach_mapping(self) -> SharedTicksMapping: """같은 프로세스(부모)에서도 검증용으로 매핑 뷰를 얻는다(이름으로 재-attach).""" return SharedTicksMapping(self._meta) def local_mapping(self) -> SharedTicksMapping: """같은 프로세스(Optuna 단일프로세스/스레드)용 — 부모가 이미 연 버퍼를 그대로 재사용. cross-process 용 ``attach_mapping`` 은 워커에서 ``SharedMemory(name=...)`` 로 다시 attach 하지만, 같은 프로세스에서 그렇게 하면 resource_tracker 재등록/해제가 엇갈려 종료 시 무해하지만 시끄러운 ``KeyError`` 노이즈가 난다. 여기서는 새 attach 없이 생성자가 보관 중인 shm 버퍼로 numpy 배열을 직접 얹어(추가 attach 0회) 그 노이즈를 원천 제거한다. 배열을 미리 연결하므로 스레드(n_jobs>1) 초기화 레이스도 없다. """ m = SharedTicksMapping(self._meta) total = int(self._meta["total"]) src_dtype = self._meta["src_dtype"] m._price = np.ndarray((total,), dtype=_DT_PRICE, buffer=self._shms["price"].buf) m._volume = np.ndarray((total,), dtype=_DT_VOLUME, buffer=self._shms["volume"].buf) m._tick_time = np.ndarray((total,), dtype=_DT_TICKTIME, buffer=self._shms["tick_time"].buf) m._source = np.ndarray((total,), dtype=src_dtype, buffer=self._shms["source"].buf) m._epoch = np.ndarray((total,), dtype=_DT_EPOCH, buffer=self._shms["epoch"].buf) m._attached = True # 배열 이미 연결 → _ensure() no-op (재-attach 안 함) return m def build_shared_ticks( ticks_by_code: Optional[Dict[str, Dict[str, List[Dict[str, Any]]]]], ) -> Optional[SharedTickStore]: """원본 ticks_by_code 를 공유메모리 컬럼 저장으로 변환. 실패/빈 데이터/미지원 환경이면 None → 호출부가 기존 경로로 폴백. 평탄화 순서는 code(정렬)→minute(원본 insertion order)→기존 리스트 순 으로 고정한다. """ if not _SHM_AVAILABLE or not ticks_by_code: return None # epoch 사전계산은 실매/백테 공용 _tick_epoch_sec 와 100% 동일해야 한다 → # 복제하지 않고 그 함수를 그대로 import (import 실패 시 폴백 = 디스크 경로). try: from kis_trader.engine.whipsaw_filter import _tick_epoch_sec except Exception: return None codes = sorted(ticks_by_code.keys()) # 1) 총 틱 수 + source 최대 바이트 길이 (S dtype 폭 결정) total = 0 max_src = 1 for code in codes: bucket = ticks_by_code.get(code) or {} for mk in bucket: lst = bucket.get(mk) or [] total += len(lst) for t in lst: s = str(t.get("source") or "") b = len(s.encode("utf-8")) if b > max_src: max_src = b if total <= 0: return None src_dtype = f"S{max_src}" specs = { "price": np.dtype(_DT_PRICE), "volume": np.dtype(_DT_VOLUME), "tick_time": np.dtype(_DT_TICKTIME), "source": np.dtype(src_dtype), "epoch": np.dtype(_DT_EPOCH), } shms: Dict[str, Any] = {} arrs: Dict[str, Any] = {} try: for name, dt in specs.items(): nbytes = int(dt.itemsize) * total shm = shared_memory.SharedMemory(create=True, size=max(1, nbytes)) shms[name] = shm arrs[name] = np.ndarray((total,), dtype=dt, buffer=shm.buf) except Exception: # 생성 도중 실패 → 이미 만든 블록 정리 후 폴백 for shm in shms.values(): try: shm.close(); shm.unlink() except Exception: pass return None price = arrs["price"] volume = arrs["volume"] tt = arrs["tick_time"] src = arrs["source"] epoch = arrs["epoch"] index: Dict[str, Dict[str, Tuple[int, int]]] = {} off = 0 for code in codes: bucket = ticks_by_code.get(code) or {} cidx: Dict[str, Tuple[int, int]] = {} for mk in bucket: # 원본 dict insertion order 보존 (iteration 동일성) lst = bucket.get(mk) or [] cnt = len(lst) start = off for t in lst: tt_str = str(t.get("tick_time") or "") price[off] = float(t.get("price") or 0) volume[off] = int(t.get("volume") or 0) tt[off] = tt_str[:14].encode("utf-8") src[off] = str(t.get("source") or "").encode("utf-8") epoch[off] = _tick_epoch_sec(tt_str) # 핫루프에서 재파싱 안 하도록 사전계산 off += 1 cidx[mk] = (start, cnt) index[code] = cidx meta = { "total": total, "src_dtype": src_dtype, "names": {k: shms[k].name for k in specs}, "index": index, } return SharedTickStore(shms, meta) def build_shared_ticks_view( ticks_by_code: Optional[Dict[str, Dict[str, List[Dict[str, Any]]]]], *, enabled: bool = True, ) -> Tuple[Any, Optional["SharedTickStore"]]: """단일 프로세스(Optuna 등)용 — dict 를 컬럼 공유메모리 '뷰' 로 치환해 RAM 절감. [Grid 와 차이] Grid 파라서치는 워커 '프로세스' 간 사본 제거가 목적이라 descriptor 를 워커로 넘겨 attach 시킨다. Optuna 는 단일 프로세스(또는 n_jobs 스레드)라 사본 문제는 없지만, dict-of-dict(파이썬 객체 오버헤드 큼)을 numpy 컬럼(shared_memory)으로 바꾸면 **메모리 사용량**이 크게 준다 → RAM 이 적은 WSL 에서도 대용량 틱으로 Optuna 를 돌릴 수 있다. 반환 ``(view, store)``: · 성공: ``(SharedTicksMapping, SharedTickStore)`` — 종료 시 ``store.unlink()`` 필수 · 미지원/빈데이터/off/실패: ``(원본 ticks_by_code, None)`` — 호출부가 dict 경로 그대로 사용 ``SharedTicksMapping`` 은 원본 dict 와 bit-identical(Grid E2E 검증) 이라 evaluate 무변경. """ if not enabled or not ticks_by_code or not shared_ticks_available(): return ticks_by_code, None store = build_shared_ticks(ticks_by_code) if store is None: return ticks_by_code, None # 단일 프로세스용 로컬 매핑 — 부모 버퍼 재사용(추가 attach 0회) → 종료 noise 없음 + 스레드 안전. view = store.local_mapping() return view, store __all__ = [ "shared_ticks_available", "build_shared_ticks", "build_shared_ticks_view", "SharedTickStore", "SharedTicksMapping", "SharedBucketMapping", "TickColumnView", ]