#!/usr/bin/env python3 """ 꼬리잡기 백테 ws_ticks 리플레이 — limit_atr 지정가 체결·align 다음봉 진입가 정밀화. - 3분봉 OHLC low/open 대신 분 단위 체결 틱으로 첫 터치 시점·가격 추정. - 틱 없으면 기존 ``try_limit_fill_on_bar`` / 봉 시가 폴백 (돌파 BREAKOUT_BACKTEST_TICK_FALLBACK_OHLC 와 동일). """ from __future__ import annotations from datetime import datetime, timedelta from typing import Any, Dict, List, Optional, Tuple from kis_trader.engine.limit_entry_common import is_limit_atr_entry, short_entry_mode from kis_trader.utils.env import get_env_bool 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 tail_backtest_use_tick_db(params: Optional[Dict[str, Any]] = None) -> bool: """백테 시 ws_ticks 재생 사용 (기본 ON — 실매 체결 정합). 3분봉 OHLC 경로(open→high→low→close 가정)는 손절보다 익절/어깨컷을 먼저 체결하는 **낙관적 편향**을 만들어 실매 손실을 백테 수익으로 둔갑시킨다. (7/3 검증: 모멘텀 OHLC +22k → 틱재생 -57k, 실매 -49k 와 정합) 따라서 모멘텀(MOMENTUM_BACKTEST_USE_TICK_*)·돌파와 동일하게 기본 ON 으로 저장 틱 (실 체결 경로)을 재생한다. 틱 없는 구간은 TAIL_BACKTEST_TICK_FALLBACK_OHLC 로 OHLC 폴백. 끄려면 params 또는 env TAIL_BACKTEST_USE_TICK_DB=0. """ return _param_bool(params, "backtest_use_tick_db", "TAIL_BACKTEST_USE_TICK_DB", True) def tail_backtest_use_tick_exit(params: Optional[Dict[str, Any]] = None) -> bool: """백테 청산에 ws_ticks 사용 (기본 ON — OHLC intrabar 낙관 편향 제거).""" return _param_bool(params, "backtest_use_tick_exit", "TAIL_BACKTEST_USE_TICK_EXIT", True) def tail_backtest_tick_fallback_ohlc(params: Optional[Dict[str, Any]] = None) -> bool: """해당 구간 틱 없을 때 3분봉 OHLC 폴백 (기본 OFF — 유령거래 방지).""" return _param_bool(params, "backtest_tick_fallback_ohlc", "TAIL_BACKTEST_TICK_FALLBACK_OHLC", False) def tail_backtest_wants_tick_replay(params: Optional[Dict[str, Any]] = None) -> bool: """진입(align/limit) 또는 청산 틱 재생이 필요한지.""" if tail_backtest_use_tick_exit(params): return True if not tail_backtest_use_tick_db(params): return False mode = short_entry_mode(params) return mode in ("align", "limit_atr", "limit") def tail_timeframe_min(params: Optional[Dict[str, Any]] = None) -> int: p = params or {} try: tf = int(float(p.get("timeframe") or p.get("tf") or 3)) except (TypeError, ValueError): tf = 3 return tf if tf in (3, 5, 15, 60) else 3 def tail_bar_minute_keys(candle_time: str, tf_min: int) -> List[str]: """3분(등) 봉 시각 → 해당 봉에 포함되는 분 키(YYYYMMDDHHMM) 목록.""" base = str(candle_time or "")[:12] if len(base) < 12: return [] try: dt0 = datetime.strptime(base, "%Y%m%d%H%M") except ValueError: return [base] out: List[str] = [] for k in range(max(1, int(tf_min))): out.append((dt0 + timedelta(minutes=k)).strftime("%Y%m%d%H%M")) return out def collect_bar_ticks( ticks_by_code: Optional[Dict[str, Dict[str, List[Dict[str, Any]]]]], code: str, bar_candle_time: str, tf_min: int, ) -> List[Dict[str, Any]]: if not ticks_by_code: return [] bucket = ticks_by_code.get(code) or {} keys = tail_bar_minute_keys(bar_candle_time, tf_min) # 백테 틱재생: 공유메모리 버킷이면 컬럼 뷰 반환. 분 오름차순·분내 오름차순 저장이라 # dict 경로의 (분 병합 후 tick_time 정렬) 결과와 순서 동일(정렬은 무연산). try: from kis_trader.backtest.shared_ticks import SharedBucketMapping if isinstance(bucket, SharedBucketMapping): return bucket.column_view_lookback(keys) except Exception: pass merged: List[Dict[str, Any]] = [] for mk in keys: merged.extend(bucket.get(mk) or []) merged.sort(key=lambda x: str(x.get("tick_time") or "")) return merged def try_limit_fill_from_ticks( ticks: List[Dict[str, Any]], limit_price: float, fill_slip_pct: float = 0.0, ) -> Optional[float]: """틱 시간순 — 첫 price≤지정가 체결가 (슬리피지는 불리하게만).""" if limit_price <= 0: return None slip = float(fill_slip_pct or 0.0) # 백테 틱재생: 컬럼 뷰면 dict 재구성 없이 price 배열 직접 읽기(동일 로직). try: from kis_trader.backtest.shared_ticks import TickColumnView if isinstance(ticks, TickColumnView): _price = ticks.owner._price for i in ticks.iter_idx(): price = float(_price[i]) if price <= 0 or price > limit_price: continue if slip > 0: return limit_price * (1.0 + slip / 100.0) return limit_price return None except Exception: pass for tick in ticks: price = float(tick.get("price") or 0) if price <= 0 or price > limit_price: continue if slip > 0: return limit_price * (1.0 + slip / 100.0) return limit_price return None def align_entry_price_from_ticks( ticks: List[Dict[str, Any]], fallback_open: float, ) -> Tuple[float, str]: """신호 직후(진입봉) 첫 체결 틱 가격 (없으면 시가).""" fo = float(fallback_open or 0) if not ticks: return fo, "ohlc_open" # 백테 틱재생: 컬럼 뷰면 price 배열 직접 읽기(첫 유효틱 즉시 반환 → 동일). try: from kis_trader.backtest.shared_ticks import TickColumnView if isinstance(ticks, TickColumnView): _price = ticks.owner._price for i in ticks.iter_idx(): price = float(_price[i]) if price > 0: return price, "ws_ticks" return fo, "ohlc_open" except Exception: pass for tick in ticks: price = float(tick.get("price") or 0) if price > 0: return price, "ws_ticks" return fo, "ohlc_open" def resolve_align_entry_bar( candles: List[Dict[str, Any]], signal_idx: int, tf_min: int, ) -> Optional[Dict[str, Any]]: """ align 진입봉 — 신호봉 직후 **기대 다음 봉**(signal+tf)을 우선. DB 3분 구멍으로 멀리 떨어진 다음 시가에 밀리지 않도록, ``candle_time == signal_time+tf`` 봉이 있으면 그걸 쓴다 (1M→3M 합성으로 메워진 경우 포함). 없으면 기존처럼 다음 당일 봉. """ from kis_trader.engine.candle_rollup import add_candle_minutes if signal_idx < 0 or signal_idx >= len(candles): return None sig = candles[signal_idx] day = str(sig.get("candle_time") or "")[:8] if len(day) < 8: return None expected = add_candle_minutes(str(sig.get("candle_time") or ""), int(tf_min)) if not expected: return None for j in range(signal_idx + 1, len(candles)): ct = str(candles[j].get("candle_time") or "") if ct[:8] != day: return None if ct[:12] == expected[:12]: return candles[j] # 기대 봉 없음 → 레거시: 바로 다음 당일 봉 if signal_idx + 1 >= len(candles): return None next_c = candles[signal_idx + 1] if str(next_c.get("candle_time") or "")[:8] != day: return None return next_c def align_entry_execute_time(bar_candle_time: str, tf_min: int) -> str: """align 체결 시각 = 진입봉 시작 + tf (봉 확정 직후, 실매 check_buy_signal_live 와 동일).""" from kis_trader.engine.candle_rollup import add_candle_minutes return add_candle_minutes(str(bar_candle_time or "")[:12], int(tf_min)) def try_limit_fill_on_bar_with_ticks( bar: Dict[str, Any], limit_price: float, fill_slip_pct: float, *, ticks: Optional[List[Dict[str, Any]]] = None, params: Optional[Dict[str, Any]] = None, ) -> Tuple[Optional[float], str]: """ 틱 우선 → OHLC low 폴백. Returns: (fill_price or None, source: ws_ticks|ohlc_low|none) """ from kis_trader.engine.limit_entry_common import try_limit_fill_on_bar p = params or {} if ticks and tail_backtest_use_tick_db(p): fp = try_limit_fill_from_ticks(ticks, limit_price, fill_slip_pct) if fp and fp > 0: return fp, "ws_ticks" if tail_backtest_tick_fallback_ohlc(p): fp = try_limit_fill_on_bar(bar, limit_price, fill_slip_pct) if fp and fp > 0: return fp, "ohlc_low" return None, "none"