#!/usr/bin/env python3 """ 모멘텀 백테 ws_ticks 리플레이 — 실매 MomentumStrategy 진입·청산 정렬. - 진입: live_align 신호봉(T-1) → 진입봉(T) 첫 틱/시가 - 청산: 1~2초 폴링 근사로 ``check_sell_signal_momentum_live`` → **틱 체결가** - 틱 공백: OHLC intrabar 폴백(기본 OFF) 대신 **last price + 벽시계** (``MOMENTUM_BACKTEST_WALLCLOCK_LAST_PRICE``, 기본 ON) """ from __future__ import annotations from datetime import datetime from typing import Any, Dict, List, Optional, Tuple from kis_trader.engine.momentum_engine import check_sell_signal_momentum_live from kis_trader.utils.env import get_env_bool, get_env_float, get_env_int def _param_bool( params: Optional[Dict[str, Any]], param_key: str, env_key: str, default: bool, ) -> bool: if params is not None and params.get(param_key) is not None: s = str(params.get(param_key)).strip().lower() if s in ("1", "true", "t", "y", "yes", "on"): return True if s in ("0", "false", "f", "n", "no", "off", ""): return False return get_env_bool(env_key, default) def momentum_backtest_use_tick_exit(params: Optional[Dict[str, Any]] = None) -> bool: """백테 청산에 ws_ticks 사용 (기본 ON — 실매 체결 정합). 실매 청산은 초단위 실제 틱(ws_ticks)으로 체결된다. 1분봉 OHLC 경로 (open→high→low→close)는 "고가 먼저" 낙관 가정이라 어깨컷·트레일·익절을 실제보다 유리하게 체결해 백테 손익을 부풀린다(검증: 2026-07-03 OHLC 순위 전 조합 흑자 → 틱 순위 전 조합 적자, 실매 -48,742 정합). 그래서 파람서치·웹 백테 모두 기본 틱재생으로 실매 정합 순위를 낸다. 끄려면 params 또는 env MOMENTUM_BACKTEST_USE_TICK_EXIT=0. """ return _param_bool( params, "backtest_use_tick_exit", "MOMENTUM_BACKTEST_USE_TICK_EXIT", True, ) def momentum_backtest_tick_fallback_ohlc(params: Optional[Dict[str, Any]] = None) -> bool: """해당 분 틱 없을 때 1분봉 OHLC intrabar 폴백.""" return _param_bool( params, "backtest_tick_fallback_ohlc", "MOMENTUM_BACKTEST_TICK_FALLBACK_OHLC", False, ) def momentum_backtest_wallclock_last_price(params: Optional[Dict[str, Any]] = None) -> bool: """틱 공백 시 실매처럼 last price + 벽시계로 청산 검사 (기본 ON). OHLC intrabar 폴백과 다름 — 고가·저가 경로를 만들지 않고 직전 틱가(없으면 분봉 종가 1개)만 사용. 파람서치가 OHLC 가짜경로에 맞추는 것을 피하면서 시간컷/EOD·현재가 청산을 실매에 맞춘다. 끄려면 ``MOMENTUM_BACKTEST_WALLCLOCK_LAST_PRICE=0``. """ return _param_bool( params, "backtest_wallclock_last_price", "MOMENTUM_BACKTEST_WALLCLOCK_LAST_PRICE", True, ) def update_momentum_bt_last_px( position: Dict[str, Any], px: float, t_key: str = "", ) -> None: """백테 보유 중 last price 캐시 (틱·봉 종가 갱신).""" try: v = float(px) except (TypeError, ValueError): return if v <= 0: return position["_bt_last_px"] = v tk = str(t_key or "").strip() if tk: position["_bt_last_px_t"] = tk[:12] def resolve_momentum_wallclock_last_px( position: Dict[str, Any], bar: Optional[Dict[str, Any]], *, ticks_by_code: Optional[Dict[str, Dict[str, List[Dict[str, Any]]]]] = None, code: str = "", minute_key: str = "", ) -> Optional[float]: """last price: 해당 분 마지막 틱 → 분봉 종가 → 캐시 → 진입가.""" mk = str(minute_key or (bar or {}).get("candle_time") or "")[:12] if ticks_by_code and code and mk: minute_ticks = collect_minute_ticks(ticks_by_code, code, mk) if minute_ticks: try: from kis_trader.backtest.shared_ticks import TickColumnView if isinstance(minute_ticks, TickColumnView): last_i = None for i in minute_ticks.iter_idx(): last_i = i if last_i is not None: px = float(minute_ticks.owner._price[last_i]) if px > 0: return px else: for tick in reversed(list(minute_ticks)): px = float(tick.get("price") or 0) if px > 0: return px except Exception: for tick in reversed(list(minute_ticks)): try: px = float(tick.get("price") or 0) except (TypeError, ValueError): continue if px > 0: return px if bar is not None: try: px = float(bar.get("close") or 0) if px > 0: return px except (TypeError, ValueError): pass cached = position.get("_bt_last_px") if cached is not None: try: px = float(cached) if px > 0: return px except (TypeError, ValueError): pass try: px = float(position.get("entry_price") or 0) return px if px > 0 else None except (TypeError, ValueError): return None def try_momentum_sell_wallclock_last( position: Dict[str, Any], last_px: float, candle_time: str, params: Dict[str, Any], *, is_eod: bool = False, ) -> Optional[Tuple[str, float, str, float]]: """실매 ``check_sell_signals`` 와 동일 — last 1가 + 벽시계 candle_time. Returns: (reason, fill_price, sell_time, hold_min) 또는 None """ try: px = float(last_px) except (TypeError, ValueError): return None if px <= 0: return None ct = str(candle_time or "")[:12] if len(ct) < 12: return None mp = max(float(position.get("max_price", position.get("entry_price") or px)), px) position["max_price"] = mp candle = { "high": mp, "low": px, "close": px, "candle_time": ct, } res = check_sell_signal_momentum_live(position, candle, params, is_eod=is_eod) if not res: return None reason, _theoretical = res slip_pct = abs(float(get_env_float("MOMENTUM_BACKTEST_SELL_SLIP_PCT", 0.0))) fill_px = px * (1.0 - slip_pct / 100.0) if slip_pct > 0 else px entry_time = str(position.get("entry_time") or "") try: entry_dt = parse_backtest_time(entry_time) sell_dt = parse_backtest_time(ct) hold_min = round((sell_dt - entry_dt).total_seconds() / 60.0, 1) except ValueError: hold_min = 0.0 return reason, float(fill_px), ct, hold_min def momentum_backtest_tick_only_codes(params: Optional[Dict[str, Any]] = None) -> bool: """틱재생 시 **틱 데이터가 전혀 없는 종목을 백테/파람서치에서 제외** (기본 ON). 틱 전무 종목은 대부분 개장 직후(09:00~09:02) 조건검색에 잠깐 떴다 빠진 순간 후보로, 유니버스 존속이 2분 남짓이라 실제 매매로 이어질 수 없다. 이런 종목을 남겨두면 OHLC 폴백으로 유령 거래를 만들어 손익을 왜곡한다. → 틱 있는 종목만 신뢰. 끄려면 env MOMENTUM_BACKTEST_TICK_ONLY_CODES=0. """ return _param_bool( params, "backtest_tick_only_codes", "MOMENTUM_BACKTEST_TICK_ONLY_CODES", True, ) def momentum_backtest_wants_tick_replay( params: Optional[Dict[str, Any]] = None, ticks_by_code: Optional[Dict[str, Any]] = None, ) -> bool: if not ticks_by_code: return False return momentum_backtest_use_tick_exit(params) or momentum_backtest_use_tick_entry(params) def momentum_backtest_use_tick_entry(params: Optional[Dict[str, Any]] = None) -> bool: """백테 진입에 ws_ticks 첫 체결가 사용 (기본 ON — 실매 체결 정합). 위 ``momentum_backtest_use_tick_exit`` 과 동일 취지 — 실매는 진입봉(T) 첫 틱 체결가로 매수하므로 백테도 틱 첫 체결가로 진입해 정합을 맞춘다. 끄려면 params 또는 env MOMENTUM_BACKTEST_USE_TICK_ENTRY=0. """ return _param_bool( params, "backtest_use_tick_entry", "MOMENTUM_BACKTEST_USE_TICK_ENTRY", True, ) def momentum_live_align_enabled(params: Optional[Dict[str, Any]] = None) -> bool: """실매·백테 동일: 신호봉(T-1) → 진입봉(T) 시가.""" return _param_bool( params, "live_backtest_align", "MOMENTUM_LIVE_BACKTEST_ALIGN", True, ) def momentum_backtest_skip_pre_subscribe(params: Optional[Dict[str, Any]] = None) -> bool: """정합용: 종목 첫 ws_tick 분 이전 진입봉 매수 제외 (기본 OFF — 파람 탐색 폭 유지).""" return _param_bool( params, "backtest_skip_pre_subscribe", "MOMENTUM_BACKTEST_SKIP_PRE_SUBSCRIBE", False, ) def momentum_backtest_live_scan_queue_enabled(params: Optional[Dict[str, Any]] = None) -> bool: """백테 매수: 실매처럼 N초마다 후보 순회·1건 매수 (기본 ON).""" return _param_bool( params, "backtest_live_scan_queue", "MOMENTUM_BACKTEST_LIVE_SCAN_QUEUE", True, ) def momentum_backtest_scan_sec(params: Optional[Dict[str, Any]] = None) -> int: """실매 루프·조건검색 주기 근사(초). 기본 10초.""" if params is not None and params.get("backtest_scan_sec") is not None: try: return max(1, int(float(params["backtest_scan_sec"]))) except (TypeError, ValueError): pass return max(1, int(get_env_int("MOMENTUM_BACKTEST_SCAN_SEC", 10))) def align_momentum_entry_from_ticks( ticks_by_code: Optional[Dict[str, Dict[str, List[Dict[str, Any]]]]], code: str, entry_bar_time: str, fallback_open: float, params: Optional[Dict[str, Any]] = None, *, min_tick_time: str = "", ) -> Tuple[float, str, str]: """ 진입봉 첫 유효 틱 체결가 (없으면 분봉 시가). Returns: (fill_price, entry_time_key, source) — source: ws_ticks | ohlc_open """ p = params or {} fo = float(fallback_open or 0) bar_key = str(entry_bar_time or "")[:12] if not momentum_backtest_use_tick_entry(p) or not ticks_by_code: return fo, bar_key, "ohlc_open" ticks = collect_minute_ticks(ticks_by_code, code, bar_key) slip_pct = abs(float(get_env_float("MOMENTUM_BACKTEST_BUY_SLIP_PCT", 0.0))) min_tt = str(min_tick_time or "").strip() # 백테 틱재생: 컬럼 뷰면 dict 재구성 없이 배열 직접 읽기(첫 유효틱 즉시 반환 → 동일). try: from kis_trader.backtest.shared_ticks import TickColumnView _is_view = isinstance(ticks, TickColumnView) except Exception: _is_view = False if _is_view: owner = ticks.owner _price = owner._price _tick_time = owner._tick_time for i in ticks.iter_idx(): tt = _tick_time[i].decode("utf-8") if min_tt and len(tt) >= 14 and tt[:14] < min_tt[:14]: continue px = float(_price[i]) if px <= 0: continue fill_px = px * (1.0 + slip_pct / 100.0) if slip_pct > 0 else px et = tt[:14] if len(tt) >= 14 else bar_key return fill_px, et, "ws_ticks" return fo, bar_key, "ohlc_open" for tick in ticks: tt = str(tick.get("tick_time") or "") if min_tt and len(tt) >= 14 and tt[:14] < min_tt[:14]: continue px = float(tick.get("price") or 0) if px <= 0: continue fill_px = px * (1.0 + slip_pct / 100.0) if slip_pct > 0 else px tt = str(tick.get("tick_time") or "") et = tt[:14] if len(tt) >= 14 else bar_key return fill_px, et, "ws_ticks" return fo, bar_key, "ohlc_open" def parse_backtest_time(t: str) -> datetime: """YYYYMMDDHHMM[SS] · 실매 포맷 → datetime (공통 파서).""" from kis_trader.utils.trade_time import parse_trade_datetime return parse_trade_datetime(t) def _tick_time_to_ms(tick_time: str) -> int: dt = parse_backtest_time(tick_time) return int(dt.timestamp() * 1000) def collect_minute_ticks( ticks_by_code: Optional[Dict[str, Dict[str, List[Dict[str, Any]]]]], code: str, minute_key: str, ) -> List[Dict[str, Any]]: if not ticks_by_code: return [] bucket = ticks_by_code.get(code) or {} # 백테 틱재생: 공유메모리 버킷이면 dict 리스트 대신 컬럼 뷰 반환. # 로더가 tick_time 오름차순으로 담아둔 저장순서 == 아래 정렬 결과라 순서 동일. try: from kis_trader.backtest.shared_ticks import SharedBucketMapping if isinstance(bucket, SharedBucketMapping): return bucket.column_view_minute(minute_key) except Exception: pass ticks = list(bucket.get(str(minute_key)[:12]) or []) ticks.sort(key=lambda x: str(x.get("tick_time") or "")) return ticks def _try_momentum_sell_on_ticks_columnar( position: Dict[str, Any], view: Any, params: Dict[str, Any], *, is_eod: bool = False, entry_time: str = "", ) -> Optional[Tuple[str, float, str, float]]: """try_momentum_sell_on_ticks 의 컬럼 직접접근 판(백테 틱재생 전용). dict 재구성 없이 공유 컬럼 배열(epoch/price/tick_time)을 직접 읽는다. 로직·산출물은 dict 판과 100% 동일하다: - dict판 ``len(tt)<12 or 파싱실패`` skip ↔ epoch(=사전계산)<=0 skip - dict판 ``tt[:12] < entry_key`` (분 문자열 비교) ↔ epoch < entry_key_epoch (entry_key 는 12자리 분→_tick_epoch_sec 가 :00 경계로 패딩하므로 동치) - dict판 ``_tick_time_to_ms(tt)`` ↔ epoch*1000 (12/14자리 모두 동일 값) max_price 갱신은 dict판과 동일하게 '유효 틱마다' 수행한다(폴링 게이트 이전). """ if len(view) == 0: return None from kis_trader.engine.whipsaw_filter import _tick_epoch_sec owner = view.owner _epoch = owner._epoch _price = owner._price _tick_time = owner._tick_time # 실매 STRATEGY_LOOP_SLEEP≈0.1초 정합 — 기본 100ms (하한 50ms) poll_ms = max(50, int(get_env_int("MOMENTUM_BACKTEST_POLL_MS", 100))) slip_pct = abs(float(get_env_float("MOMENTUM_BACKTEST_SELL_SLIP_PCT", 0.0))) entry_key = str(entry_time or "")[:12] entry_key_epoch = _tick_epoch_sec(entry_key) if entry_key else 0 # entry_dt — dict판과 동일: entry_time 우선, 없으면 첫 틱 tick_time if entry_time: _entry_src = entry_time else: _fi = view.first_idx() _entry_src = _tick_time[_fi].decode("utf-8") if _fi >= 0 else entry_key try: entry_dt = parse_backtest_time(_entry_src) except ValueError: entry_dt = parse_backtest_time(entry_key) last_check_ms = -10**15 n = len(view) idx = -1 for i in view.iter_idx(): idx += 1 ts = int(_epoch[i]) if ts <= 0: continue if entry_key and ts < entry_key_epoch: continue px = float(_price[i]) if px <= 0: continue mp = max(float(position.get("max_price", position["entry_price"])), px) position["max_price"] = mp tick_ms = ts * 1000 if tick_ms - last_check_ms < poll_ms: continue last_check_ms = tick_ms tt = _tick_time[i].decode("utf-8") candle = { "high": mp, "low": px, "close": px, "candle_time": tt[:12], } eod_here = bool(is_eod and idx == n - 1) res = check_sell_signal_momentum_live(position, candle, params, is_eod=eod_here) if not res: continue reason, _theoretical = res fill_px = px if slip_pct > 0: fill_px = px * (1.0 - slip_pct / 100.0) try: sell_dt = parse_backtest_time(tt) except ValueError: sell_dt = parse_backtest_time(tt[:12]) hold_min = round((sell_dt - entry_dt).total_seconds() / 60.0, 1) sell_time = tt[:14] if len(tt) >= 14 else tt[:12] return reason, fill_px, sell_time, hold_min return None def try_momentum_sell_on_ticks( position: Dict[str, Any], ticks: List[Dict[str, Any]], params: Dict[str, Any], *, is_eod: bool = False, entry_time: str = "", ) -> Optional[Tuple[str, float, str, float]]: """ 틱 시간순 청산 검사 (실매 폴링 간격 근사). Returns: (reason, fill_price, sell_time, hold_min) 또는 None sell_time: YYYYMMDDHHMMSS (가능 시) — hold_min 은 초 단위 반영 """ # 백테 틱재생: 공유메모리 컬럼 뷰면 직접집계(실매 dict 경로 무변경). try: from kis_trader.backtest.shared_ticks import TickColumnView except ImportError: TickColumnView = None # type: ignore[misc,assignment] if TickColumnView is not None and isinstance(ticks, TickColumnView): return _try_momentum_sell_on_ticks_columnar( position, ticks, params, is_eod=is_eod, entry_time=entry_time, ) if not ticks: return None # 실매 STRATEGY_LOOP_SLEEP≈0.1초 정합 — 기본 100ms (하한 50ms) poll_ms = max(50, int(get_env_int("MOMENTUM_BACKTEST_POLL_MS", 100))) slip_pct = abs(float(get_env_float("MOMENTUM_BACKTEST_SELL_SLIP_PCT", 0.0))) entry_key = str(entry_time or "")[:12] try: entry_dt = parse_backtest_time(entry_time or ticks[0].get("tick_time", entry_key)) except ValueError: entry_dt = parse_backtest_time(entry_key) last_check_ms = -10**15 n = len(ticks) for idx, tick in enumerate(ticks): tt = str(tick.get("tick_time") or "") if len(tt) < 12: continue if tt[:12] < entry_key: continue try: tick_ms = _tick_time_to_ms(tt) except ValueError: continue px = float(tick.get("price") or 0) if px <= 0: continue mp = max(float(position.get("max_price", position["entry_price"])), px) position["max_price"] = mp if tick_ms - last_check_ms < poll_ms: continue last_check_ms = tick_ms candle = { "high": mp, "low": px, "close": px, "candle_time": tt[:12], } eod_here = bool(is_eod and idx == n - 1) res = check_sell_signal_momentum_live(position, candle, params, is_eod=eod_here) if not res: continue reason, _theoretical = res fill_px = px if slip_pct > 0: fill_px = px * (1.0 - slip_pct / 100.0) try: sell_dt = parse_backtest_time(tt) except ValueError: sell_dt = parse_backtest_time(tt[:12]) hold_min = round((sell_dt - entry_dt).total_seconds() / 60.0, 1) sell_time = tt[:14] if len(tt) >= 14 else tt[:12] return reason, fill_px, sell_time, hold_min return None def resolve_momentum_sell_for_bar( position: Dict[str, Any], bar: Dict[str, Any], params: Dict[str, Any], *, is_eod: bool = False, ticks_by_code: Optional[Dict[str, Dict[str, List[Dict[str, Any]]]]] = None, code: str = "", ) -> Optional[Tuple[str, float, str, float, str]]: """ 한 분봉 청산 — 틱 우선 → (옵션) OHLC intrabar → last-price 벽시계. Returns: (reason, fill_price, sell_time, hold_min, exit_source) exit_source: ws_ticks | ohlc_bar | wallclock_last """ from kis_trader.engine.momentum_engine import check_sell_signal_momentum_backtest_bar ct = str(bar.get("candle_time") or "") entry_time = str(position.get("entry_time") or "") if momentum_backtest_use_tick_exit(params) and ticks_by_code and code: minute_ticks = collect_minute_ticks(ticks_by_code, code, ct) if minute_ticks: tick_res = try_momentum_sell_on_ticks( position, minute_ticks, params, is_eod=is_eod, entry_time=entry_time, ) if tick_res: reason, fill_px, sell_time, hold_min = tick_res return reason, fill_px, sell_time, hold_min, "ws_ticks" # 틱은 있었으나 미청산 → last 갱신 후 벽시계 경로에서 시간컷 등 재검사 try: from kis_trader.backtest.shared_ticks import TickColumnView if isinstance(minute_ticks, TickColumnView): last_i = None for i in minute_ticks.iter_idx(): last_i = i if last_i is not None: update_momentum_bt_last_px( position, float(minute_ticks.owner._price[last_i]), ct, ) else: for tick in reversed(list(minute_ticks)): px = float(tick.get("price") or 0) if px > 0: update_momentum_bt_last_px(position, px, ct) break except Exception: pass if momentum_backtest_tick_fallback_ohlc(params): res = check_sell_signal_momentum_backtest_bar(position, bar, params, is_eod=is_eod) if res: reason, exit_price = res try: entry_dt = parse_backtest_time(entry_time) sell_dt = parse_backtest_time(ct) hold_min = round((sell_dt - entry_dt).total_seconds() / 60.0, 1) except ValueError: hold_min = 0.0 return reason, float(exit_price), ct, hold_min, "ohlc_bar" if not momentum_backtest_wallclock_last_price(params): return None last_px = resolve_momentum_wallclock_last_px( position, bar, ticks_by_code=ticks_by_code, code=code, minute_key=ct, ) if last_px is None: return None update_momentum_bt_last_px(position, last_px, ct) wall_res = try_momentum_sell_wallclock_last( position, last_px, ct, params, is_eod=is_eod, ) if not wall_res: return None reason, fill_px, sell_time, hold_min = wall_res return reason, fill_px, sell_time, hold_min, "wallclock_last"