Changes: - Added new API endpoints for continuing and confirming Optuna jobs, allowing for better management of ongoing studies. - Introduced detailed logging for tick feed tracking and order book processing, improving traceability of vendor performance during backtests. - Updated database schema to include new fields for managing Optuna study results, enhancing the ability to track study progress and outcomes. - Refactored existing functions to utilize the new logging and tracking features, ensuring consistency across the backtesting framework. Impact: - These enhancements improve the robustness and transparency of the Optuna backtesting process, facilitating better analysis and optimization of trading strategies.
657 lines
24 KiB
Python
657 lines
24 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
모멘텀 백테 ws_ticks 리플레이 — 실매 MomentumStrategy 진입·청산 정렬.
|
|
|
|
- 진입: live_align 신호봉(T-1) → 진입봉(T) 첫 틱/시가
|
|
- 청산: 1~2초 폴링 근사로 ``check_sell_signal_momentum_live`` → **틱 체결가**
|
|
- 틱 공백: OHLC intrabar 폴백(기본 OFF) 대신 **last price + 벽시계**
|
|
(``MOMENTUM_BACKTEST_WALLCLOCK_LAST_PRICE``, 기본 ON)
|
|
"""
|
|
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_wallclock_last_price(params: Optional[Dict[str, Any]] = None) -> bool:
|
|
"""틱 공백 시 실매처럼 last price + 벽시계로 청산 검사 (기본 ON).
|
|
|
|
OHLC intrabar 폴백과 다름 — 고가·저가 경로를 만들지 않고
|
|
직전 틱가(없으면 분봉 종가 1개)만 사용. 파람서치가 OHLC 가짜경로에
|
|
맞추는 것을 피하면서 시간컷/EOD·현재가 청산을 실매에 맞춘다.
|
|
끄려면 ``MOMENTUM_BACKTEST_WALLCLOCK_LAST_PRICE=0``.
|
|
"""
|
|
return _param_bool(
|
|
params,
|
|
"backtest_wallclock_last_price",
|
|
"MOMENTUM_BACKTEST_WALLCLOCK_LAST_PRICE",
|
|
True,
|
|
)
|
|
|
|
|
|
def update_momentum_bt_last_px(
|
|
position: Dict[str, Any],
|
|
px: float,
|
|
t_key: str = "",
|
|
) -> None:
|
|
"""백테 보유 중 last price 캐시 (틱·봉 종가 갱신)."""
|
|
try:
|
|
v = float(px)
|
|
except (TypeError, ValueError):
|
|
return
|
|
if v <= 0:
|
|
return
|
|
position["_bt_last_px"] = v
|
|
tk = str(t_key or "").strip()
|
|
if tk:
|
|
position["_bt_last_px_t"] = tk[:12]
|
|
|
|
|
|
def resolve_momentum_wallclock_last_px(
|
|
position: Dict[str, Any],
|
|
bar: Optional[Dict[str, Any]],
|
|
*,
|
|
ticks_by_code: Optional[Dict[str, Dict[str, List[Dict[str, Any]]]]] = None,
|
|
code: str = "",
|
|
minute_key: str = "",
|
|
) -> Optional[float]:
|
|
"""last price: 해당 분 마지막 틱 → 분봉 종가 → 캐시 → 진입가."""
|
|
mk = str(minute_key or (bar or {}).get("candle_time") or "")[:12]
|
|
if ticks_by_code and code and mk:
|
|
minute_ticks = collect_minute_ticks(ticks_by_code, code, mk)
|
|
if minute_ticks:
|
|
try:
|
|
from kis_trader.backtest.shared_ticks import TickColumnView
|
|
if isinstance(minute_ticks, TickColumnView):
|
|
last_i = None
|
|
for i in minute_ticks.iter_idx():
|
|
last_i = i
|
|
if last_i is not None:
|
|
px = float(minute_ticks.owner._price[last_i])
|
|
if px > 0:
|
|
return px
|
|
else:
|
|
for tick in reversed(list(minute_ticks)):
|
|
px = float(tick.get("price") or 0)
|
|
if px > 0:
|
|
return px
|
|
except Exception:
|
|
for tick in reversed(list(minute_ticks)):
|
|
try:
|
|
px = float(tick.get("price") or 0)
|
|
except (TypeError, ValueError):
|
|
continue
|
|
if px > 0:
|
|
return px
|
|
if bar is not None:
|
|
try:
|
|
px = float(bar.get("close") or 0)
|
|
if px > 0:
|
|
return px
|
|
except (TypeError, ValueError):
|
|
pass
|
|
cached = position.get("_bt_last_px")
|
|
if cached is not None:
|
|
try:
|
|
px = float(cached)
|
|
if px > 0:
|
|
return px
|
|
except (TypeError, ValueError):
|
|
pass
|
|
try:
|
|
px = float(position.get("entry_price") or 0)
|
|
return px if px > 0 else None
|
|
except (TypeError, ValueError):
|
|
return None
|
|
|
|
|
|
def try_momentum_sell_wallclock_last(
|
|
position: Dict[str, Any],
|
|
last_px: float,
|
|
candle_time: str,
|
|
params: Dict[str, Any],
|
|
*,
|
|
is_eod: bool = False,
|
|
) -> Optional[Tuple[str, float, str, float]]:
|
|
"""실매 ``check_sell_signals`` 와 동일 — last 1가 + 벽시계 candle_time.
|
|
|
|
Returns:
|
|
(reason, fill_price, sell_time, hold_min) 또는 None
|
|
"""
|
|
try:
|
|
px = float(last_px)
|
|
except (TypeError, ValueError):
|
|
return None
|
|
if px <= 0:
|
|
return None
|
|
ct = str(candle_time or "")[:12]
|
|
if len(ct) < 12:
|
|
return None
|
|
|
|
mp = max(float(position.get("max_price", position.get("entry_price") or px)), px)
|
|
position["max_price"] = mp
|
|
candle = {
|
|
"high": mp,
|
|
"low": px,
|
|
"close": px,
|
|
"candle_time": ct,
|
|
}
|
|
res = check_sell_signal_momentum_live(position, candle, params, is_eod=is_eod)
|
|
if not res:
|
|
return None
|
|
reason, _theoretical = res
|
|
slip_pct = abs(float(get_env_float("MOMENTUM_BACKTEST_SELL_SLIP_PCT", 0.0)))
|
|
fill_px = px * (1.0 - slip_pct / 100.0) if slip_pct > 0 else px
|
|
entry_time = str(position.get("entry_time") or "")
|
|
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(fill_px), ct, hold_min
|
|
|
|
|
|
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:<vendor> | ohlc_open
|
|
(vendor=kis|kiwoom|ls … — 옵투나/백테 피드 추적용)
|
|
"""
|
|
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
|
|
_source = getattr(owner, "_source", None)
|
|
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
|
|
vendor = ""
|
|
if _source is not None:
|
|
try:
|
|
vendor = _source[i].decode("utf-8").strip().lower()
|
|
except Exception:
|
|
vendor = ""
|
|
return fill_px, et, (f"ws_ticks:{vendor}" if vendor else "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
|
|
try:
|
|
from kis_trader.backtest.optuna_feed_trace import tick_source_label
|
|
|
|
return fill_px, et, tick_source_label(tick)
|
|
except Exception:
|
|
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 = "",
|
|
orderbook_by_code: Optional[Dict[str, Any]] = None,
|
|
) -> Optional[Tuple[str, float, str, float, str]]:
|
|
"""
|
|
한 분봉 청산 — 틱 우선 → (옵션) OHLC intrabar → last-price 벽시계.
|
|
|
|
Returns:
|
|
(reason, fill_price, sell_time, hold_min, exit_source)
|
|
exit_source: ws_ticks | ohlc_bar | wallclock_last
|
|
"""
|
|
from kis_trader.engine.momentum_engine import check_sell_signal_momentum_backtest_bar
|
|
from kis_trader.engine.momentum_hts_logic import (
|
|
collect_exit_ob_or_history,
|
|
need_ob_or_history,
|
|
_ob_or_ma_window_for_history,
|
|
)
|
|
|
|
ct = str(bar.get("candle_time") or "")
|
|
entry_time = str(position.get("entry_time") or "")
|
|
|
|
# 수익구간·손절호가 OR 히스토리 (둘 중 ON일 때만 DB 스냅 수집 · 기본 OFF → no-op)
|
|
p = params
|
|
if need_ob_or_history(p) and code and orderbook_by_code:
|
|
ors = collect_exit_ob_or_history(
|
|
orderbook_by_code,
|
|
code,
|
|
entry_time=entry_time,
|
|
asof_time=ct,
|
|
ma_window=_ob_or_ma_window_for_history(p),
|
|
)
|
|
position["_ob_or_history"] = ors
|
|
p = dict(params)
|
|
p["_ob_or_history"] = list(ors)
|
|
else:
|
|
p = dict(params)
|
|
p["_ob_or_history"] = list(position.get("_ob_or_history") or [])
|
|
|
|
if momentum_backtest_use_tick_exit(p) 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, p,
|
|
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"
|
|
# 틱은 있었으나 미청산 → last 갱신 후 벽시계 경로에서 시간컷 등 재검사
|
|
try:
|
|
from kis_trader.backtest.shared_ticks import TickColumnView
|
|
if isinstance(minute_ticks, TickColumnView):
|
|
last_i = None
|
|
for i in minute_ticks.iter_idx():
|
|
last_i = i
|
|
if last_i is not None:
|
|
update_momentum_bt_last_px(
|
|
position, float(minute_ticks.owner._price[last_i]), ct,
|
|
)
|
|
else:
|
|
for tick in reversed(list(minute_ticks)):
|
|
px = float(tick.get("price") or 0)
|
|
if px > 0:
|
|
update_momentum_bt_last_px(position, px, ct)
|
|
break
|
|
except Exception:
|
|
pass
|
|
|
|
# 절대규칙: 틱 청산 ON 이면 OHLC 봉 폴백으로 숫자 변조 금지
|
|
# (FALLBACK_OHLC 체크 ON 이어도 무시. 벽시계 last-price 는 OHLC intrabar 가 아님)
|
|
if (
|
|
not momentum_backtest_use_tick_exit(p)
|
|
and momentum_backtest_tick_fallback_ohlc(p)
|
|
):
|
|
res = check_sell_signal_momentum_backtest_bar(position, bar, p, is_eod=is_eod)
|
|
if res:
|
|
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"
|
|
|
|
if not momentum_backtest_wallclock_last_price(p):
|
|
return None
|
|
|
|
last_px = resolve_momentum_wallclock_last_px(
|
|
position, bar, ticks_by_code=ticks_by_code, code=code, minute_key=ct,
|
|
)
|
|
if last_px is None:
|
|
return None
|
|
update_momentum_bt_last_px(position, last_px, ct)
|
|
wall_res = try_momentum_sell_wallclock_last(
|
|
position, last_px, ct, p, is_eod=is_eod,
|
|
)
|
|
if not wall_res:
|
|
return None
|
|
reason, fill_px, sell_time, hold_min = wall_res
|
|
return reason, fill_px, sell_time, hold_min, "wallclock_last"
|