Files
kis_bot/kis_trader/engine/tail_tick_replay.py
2026-07-30 18:05:07 +09:00

304 lines
11 KiB
Python

#!/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 _live_entry_tick_buffer_enabled() -> bool:
"""실매 진입 계산가: RAM 틱 첫가 우선 (기본 ON). 없으면 분봉 시가 폴백."""
from kis_trader.utils.env import get_env_bool
return get_env_bool("LIVE_ALIGN_ENTRY_FROM_TICK_BUFFER", True)
def _live_entry_tick_buffer_limit() -> int:
from kis_trader.utils.env import get_env_int
return max(10, int(get_env_int("LIVE_ENTRY_TICK_BUFFER_LIMIT", 300) or 300))
def filter_recent_ticks_for_entry_bar(
ticks: List[Dict[str, Any]],
entry_bar_key: str,
tf_min: int = 1,
) -> List[Dict[str, Any]]:
"""RAM 틱 중 진입봉(T)에 속하는 것만 (시간 오름차순 유지)."""
keys = set(tail_bar_minute_keys(entry_bar_key, tf_min))
if not keys:
return []
out: List[Dict[str, Any]] = []
for tick in ticks or []:
tt = str(tick.get("tick_time") or "").replace(":", "").replace("-", "").replace(" ", "")
if len(tt) >= 12 and tt[:12] in keys:
out.append(tick)
return out
def live_align_entry_price(
ws: Any,
code: str,
fallback_open: float,
*,
entry_bar_key: Optional[str] = None,
tf_min: int = 1,
enabled: Optional[bool] = None,
) -> Tuple[float, str]:
"""실매 진입 **계산가** — 백테 ``align_entry_price_from_ticks`` 와 동일 의미.
1) ``LIVE_ALIGN_ENTRY_FROM_TICK_BUFFER`` ON 이고 WS RAM에 진입봉 틱이 있으면 첫 체결가
2) 아니면 분봉 시가(``fallback_open``)
주문 유형(시장가/지정가)은 바꾸지 않는다. 수량·손절·백테 정합용 기준가만 정밀화.
"""
fo = float(fallback_open or 0)
use = _live_entry_tick_buffer_enabled() if enabled is None else bool(enabled)
if not use or fo <= 0:
return fo, "ohlc_open"
bar_key = str(entry_bar_key or "")[:12]
if len(bar_key) < 12:
return fo, "ohlc_open"
if ws is None or not code or not getattr(ws, "get_recent_ticks", None):
return fo, "ohlc_open"
try:
raw = list(ws.get_recent_ticks(str(code).strip(), limit=_live_entry_tick_buffer_limit()) or [])
except Exception:
return fo, "ohlc_open"
bar_ticks = filter_recent_ticks_for_entry_bar(raw, bar_key, tf_min=max(1, int(tf_min or 1)))
return align_entry_price_from_ticks(bar_ticks, fo)
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 {}
use_ticks = tail_backtest_use_tick_db(p)
if use_ticks and ticks:
fp = try_limit_fill_from_ticks(ticks, limit_price, fill_slip_pct)
if fp and fp > 0:
return fp, "ws_ticks"
# 절대규칙: 틱 진입 ON 이면 OHLC low 폴백으로 체결가 변조 금지
if use_ticks:
return None, "none"
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"