#!/usr/bin/env python3 """ 모멘텀 백테 ws_ticks 리플레이 — 실매 MomentumStrategy 진입·청산 정렬. - 진입: live_align 신호봉(T-1) → 진입봉(T) 첫 틱/시가 - 청산: 1~2초 폴링 근사로 ``check_sell_signal_momentum_live`` → **틱 체결가** """ 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_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 폴백. Returns: (reason, fill_price, sell_time, hold_min, exit_source) exit_source: ws_ticks | ohlc_bar """ 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" if not momentum_backtest_tick_fallback_ohlc(params): return None res = check_sell_signal_momentum_backtest_bar(position, bar, params, is_eod=is_eod) if not res: return None 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"