변경 사항 ---- - _test_kiwoom_condition_list.py: 키움 웹소켓 조건검색 '목록조회' 기능을 단독으로 테스트하는 스크립트 추가 - _test_kiwoom_condition_realtime.py: 'momentum' 조건식을 실시간으로 등록하고 초기 매칭 종목 리스트 및 실시간 편입/이탈을 수신하는 테스트 스크립트 추가 - _verify_columnar_bitid.py, _verify_shared_e2e_breakout.py, _verify_shared_e2e.py: 공유 메모리 및 dict 간의 데이터 일관성을 검증하는 테스트 추가 영향 ---- - 신규 테스트 스크립트 추가로 키움 웹소켓 API의 기능 검증 및 안정성을 높임 - 기존 기능에 대한 영향 없음 Co-authored-by: Cursor <cursoragent@cursor.com>
187 lines
7.0 KiB
Python
187 lines
7.0 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_tick_fallback_ohlc(params: Optional[Dict[str, Any]] = None) -> bool:
|
|
"""해당 구간 틱 없을 때 3분봉 OHLC 폴백."""
|
|
return _param_bool(params, "backtest_tick_fallback_ohlc", "TAIL_BACKTEST_TICK_FALLBACK_OHLC", True)
|
|
|
|
|
|
def tail_backtest_wants_tick_replay(params: Optional[Dict[str, Any]] = None) -> bool:
|
|
"""limit_atr 또는 align 백테에서 틱 DB 로드·재생이 필요한지."""
|
|
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 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"
|