Changes: - Replaced the import of `get_env_int` with `get_env_from_db` in `momentum_engine.py` to improve environment variable retrieval. - Modified error handling in `momentum_tick_replay.py` to ensure `TickColumnView` is only referenced if successfully imported, enhancing robustness. Impact: - These updates streamline the import process and improve the stability of the momentum trading logic by ensuring that the code handles import errors gracefully.
434 lines
16 KiB
Python
434 lines
16 KiB
Python
#!/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", True,
|
|
)
|
|
|
|
|
|
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_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 또는 YYYYMMDDHHMMSS → datetime."""
|
|
s = str(t or "").strip().replace("-", "").replace(":", "").replace(" ", "")
|
|
if len(s) >= 14:
|
|
return datetime.strptime(s[:14], "%Y%m%d%H%M%S")
|
|
if len(s) >= 12:
|
|
return datetime.strptime(s[:12], "%Y%m%d%H%M")
|
|
raise ValueError("invalid backtest time: %r" % (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
|
|
|
|
poll_ms = max(200, int(get_env_int("MOMENTUM_BACKTEST_POLL_MS", 1500)))
|
|
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
|
|
|
|
poll_ms = max(200, int(get_env_int("MOMENTUM_BACKTEST_POLL_MS", 1500)))
|
|
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"
|