Changes: - Introduced the `e_min_chg_pct` parameter to define the minimum price change percentage compared to the previous day's close, enhancing the momentum trading strategy. - Updated various functions and classes to incorporate this new parameter, ensuring it is utilized in both backtesting and live trading scenarios. - Improved documentation and comments to clarify the purpose and usage of the new parameter across the codebase. Impact: - This addition allows for more precise control over trading conditions, potentially increasing the effectiveness of the momentum strategy while maintaining system integrity and performance.
625 lines
25 KiB
Python
625 lines
25 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
돌파매매 백테스트 공통 로더 — backtest_web / param_search 가
|
|
동일한 캔들·유니버스·손익 계산을 쓰도록 단일 진입점.
|
|
|
|
청산: ``check_sell_signal_breakout_live`` — EOD → 익절 → 어깨 → 손절 → 트레일.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from typing import Any, Dict, List, Optional, Tuple
|
|
|
|
from kis_trader.backtest.backtest_portfolio_common import (
|
|
attach_scalp_trade_pnl,
|
|
backtest_slip_pct,
|
|
build_budget_warning,
|
|
fee_and_slot_from_env_row,
|
|
merge_portfolio_into_params,
|
|
min_invest_ratio_of_slot,
|
|
resolve_portfolio_params,
|
|
resolve_trigger_snapshots_for_backtest,
|
|
summarize_trades,
|
|
)
|
|
from kis_trader.backtest.breakout_tick_loader import (
|
|
load_breakout_ticks_by_code,
|
|
tick_coverage_stats,
|
|
)
|
|
from kis_trader.engine.indicator_cache import (
|
|
materialize_ws_candles_batch,
|
|
ws_candles_select_indicator_cols,
|
|
)
|
|
from kis_trader.share.stock_share import attach_share_denoms_to_params
|
|
from kis_trader.strategies.breakout import (
|
|
breakout_backtest_wants_tick_replay,
|
|
breakout_invest_amount_krw,
|
|
breakout_min_bars_required,
|
|
normalize_breakout_max_loss_krw,
|
|
resolve_breakout_skip_hts_scan_dupes,
|
|
run_breakout_backtest,
|
|
)
|
|
|
|
BREAKOUT_STRATEGY_ID = "BREAKOUT"
|
|
|
|
|
|
def _bo_golden_end_to_hm(s: str) -> int:
|
|
"""'10:30' 또는 HHMM → 1030 (실패 시 1030)."""
|
|
try:
|
|
raw = str(s or "").strip()
|
|
if ":" in raw:
|
|
hh, mm = raw.split(":", 1)
|
|
return int(hh) * 100 + int(mm)
|
|
if raw.isdigit():
|
|
return int(raw[:4]) if len(raw) >= 4 else int(raw)
|
|
except Exception:
|
|
pass
|
|
return 1030
|
|
|
|
|
|
def _breakout_trigger_filter_enabled(
|
|
env: Dict[str, Any],
|
|
*,
|
|
prefix: str,
|
|
kind: str,
|
|
global_key: str,
|
|
) -> bool:
|
|
"""전략별 TRIGGER 필터 ON/OFF. ORDERBOOK=전략키만(없으면 OFF). PROGRAM=전략→글로벌."""
|
|
sk = f"{prefix}_{kind}_FILTER_ENABLED"
|
|
raw = env.get(sk)
|
|
if raw not in (None, "", "None"):
|
|
return str(raw).strip().lower() in ("1", "true", "t", "y", "yes", "on")
|
|
if kind == "ORDERBOOK":
|
|
return False # 글로벌 ORDERBOOK_FILTER_ENABLED 폐기
|
|
raw_g = env.get(global_key)
|
|
if raw_g not in (None, "", "None"):
|
|
return str(raw_g).strip().lower() in ("1", "true", "t", "y", "yes", "on")
|
|
return True
|
|
|
|
|
|
def get_breakout_defaults_from_env_row(env: Dict[str, Any]) -> Dict[str, Any]:
|
|
"""돌파 엔진 params — env_row 스냅샷만 사용 (웹 백테 env 타임라인)."""
|
|
fee_rate, sell_tax, _slot = fee_and_slot_from_env_row(env, strategy="BREAKOUT")
|
|
|
|
def pick(keys: Tuple[str, ...], default: Any, cast=float):
|
|
for k in keys:
|
|
v = env.get(k)
|
|
if v not in (None, "", "None"):
|
|
try:
|
|
return cast(v)
|
|
except (ValueError, TypeError):
|
|
continue
|
|
return default
|
|
|
|
sl_r = pick(("BREAKOUT_STOP_LOSS_PCT",), -0.02, float)
|
|
tp_r = pick(("BREAKOUT_TAKE_PROFIT_PCT",), 0.05, float)
|
|
tr_r = pick(("BREAKOUT_TRAIL_PCT",), 0.015, float)
|
|
tra_r = pick(("BREAKOUT_TRAIL_ARM_PCT",), 0.0, float)
|
|
smh_r = pick(("BREAKOUT_SHOULDER_MIN_HIGH_PCT",), 0.02, float)
|
|
sc_r = pick(("BREAKOUT_SHOULDER_CUT_PCT",), 0.01, float)
|
|
|
|
def pct_ui(ratio: float) -> float:
|
|
av = abs(float(ratio))
|
|
if av == 0:
|
|
return 0.0
|
|
return round(av * 100, 3) if av < 0.5 else round(av, 3)
|
|
|
|
time_end_raw = env.get("BREAKOUT_TIME_END")
|
|
if time_end_raw not in (None, "", "None"):
|
|
try:
|
|
time_end_hm = int(float(time_end_raw))
|
|
except (ValueError, TypeError):
|
|
time_end_hm = _bo_golden_end_to_hm(str(env.get("BREAKOUT_GOLDEN_END_HM", "10:30")))
|
|
else:
|
|
time_end_hm = _bo_golden_end_to_hm(str(env.get("BREAKOUT_GOLDEN_END_HM", "10:30")))
|
|
|
|
cd_sec = pick(("BREAKOUT_COOLDOWN_SEC",), 0.0, float)
|
|
if cd_sec and cd_sec > 0:
|
|
cooldown_min = int(cd_sec / 60) if cd_sec > 120 else int(cd_sec)
|
|
else:
|
|
re_sec = pick(("REENTRY_COOLDOWN_SEC",), 1800.0, float)
|
|
cooldown_min = int(re_sec / 60) if re_sec > 120 else int(re_sec)
|
|
|
|
sl_pct_ui = pct_ui(sl_r)
|
|
max_loss_raw = pick(
|
|
("BREAKOUT_MAX_LOSS_PER_TRADE_KRW", "MAX_LOSS_PER_TRADE_KRW"),
|
|
200_000,
|
|
lambda v: int(float(v)),
|
|
)
|
|
max_loss_krw = normalize_breakout_max_loss_krw(max_loss_raw)
|
|
slot_cap = pick(
|
|
("BREAKOUT_SLOT_MONEY", "SLOT_MONEY_DEFAULT"),
|
|
2_000_000,
|
|
lambda v: int(float(v)),
|
|
)
|
|
slot_money = int(breakout_invest_amount_krw(max_loss_krw, sl_pct_ui, slot_cap))
|
|
|
|
portfolio = resolve_breakout_portfolio_params(
|
|
env, None, slot_money=float(slot_money),
|
|
)
|
|
max_stocks_v = int(portfolio["max_stocks"])
|
|
total_budget_v = int(float(portfolio["total_budget_krw"]))
|
|
|
|
eod_raw = str(env.get("BREAKOUT_EOD_HM") or "15:15").strip()
|
|
if eod_raw in ("", "None"):
|
|
eod_hm = "15:15"
|
|
elif ":" in eod_raw:
|
|
eod_hm = eod_raw
|
|
elif len(eod_raw) == 4 and eod_raw.isdigit():
|
|
eod_hm = f"{eod_raw[:2]}:{eod_raw[2:]}"
|
|
else:
|
|
eod_hm = eod_raw
|
|
eod_enabled_raw = env.get("BREAKOUT_EOD_ENABLED")
|
|
if eod_enabled_raw in (None, "", "None"):
|
|
eod_enabled = True
|
|
else:
|
|
eod_enabled = str(eod_enabled_raw).strip().lower() in ("1", "true", "t", "y", "yes", "on")
|
|
|
|
return {
|
|
"lookback_min": pick(("BREAKOUT_LOOKBACK_MIN",), 1, lambda v: int(float(v))),
|
|
"vol_window": pick(("BREAKOUT_VOL_WIN",), 1, lambda v: int(float(v))),
|
|
"vol_mult": pick(("BREAKOUT_VOL_MULT",), 0.0, float),
|
|
"min_turnover_1m_pct": pick(("BREAKOUT_MIN_TURNOVER_1M_PCT",), 0.05, float),
|
|
"prev_chg_min": pick(("BREAKOUT_PREV_CHG_MIN",), 1.0, float),
|
|
"prev_chg_max": pick(("BREAKOUT_PREV_CHG_MAX",), 10.0, float),
|
|
"sl_pct": sl_pct_ui,
|
|
"tp_pct": pct_ui(tp_r),
|
|
"trail_pct": pct_ui(tr_r),
|
|
"trail_arm_pct": pct_ui(tra_r),
|
|
"shoulder_min_high_pct": pct_ui(smh_r),
|
|
"shoulder_cut_pct": pct_ui(sc_r),
|
|
"sl_mode": str(env.get("BREAKOUT_SL_MODE") or "fixed").strip().lower() or "fixed",
|
|
"atr_period": pick(("BREAKOUT_ATR_PERIOD",), 14, lambda v: int(float(v))),
|
|
"atr_sl_mult": pick(("BREAKOUT_ATR_SL_MULT",), 2.0, float),
|
|
"atr_sl_min_pct": pick(("BREAKOUT_ATR_SL_MIN_PCT",), 0.8, float),
|
|
"atr_sl_max_pct": pick(("BREAKOUT_ATR_SL_MAX_PCT",), 6.0, float),
|
|
"max_hold_bars": pick(("BREAKOUT_MAX_HOLD_BARS",), 0, lambda v: int(float(v))),
|
|
"ratchet_tiers": str(env.get("BREAKOUT_RATCHET_TIERS") or ""),
|
|
"time_start_hm": pick(("BREAKOUT_TIME_START",), 900, lambda v: int(float(v))),
|
|
"time_end_hm": time_end_hm,
|
|
"eod_enabled": eod_enabled,
|
|
"eod_hm": eod_hm,
|
|
"max_daily": pick(("BREAKOUT_MAX_DAILY",), 1, lambda v: int(float(v))),
|
|
"cooldown_min": cooldown_min,
|
|
"max_daily_chg": pick(("BREAKOUT_MAX_DAILY_CHG",), 15.0, float),
|
|
"min_price": pick(("BREAKOUT_MIN_PRICE", "MIN_STOCK_PRICE"), 1000.0, float),
|
|
"confirm_margin_pct": pick(("BREAKOUT_CONFIRM_MARGIN_PCT",), 0.0, float),
|
|
"body_min_pct": pick(("BREAKOUT_BODY_MIN_PCT",), 0.0, float),
|
|
"max_loss_krw": max_loss_krw,
|
|
"slot_money": slot_money,
|
|
"max_stocks": max_stocks_v,
|
|
"total_budget_krw": total_budget_v,
|
|
"fee_rate_pct": fee_rate * 100.0 if fee_rate < 1 else fee_rate,
|
|
"sell_tax_pct": sell_tax * 100.0 if sell_tax < 1 else sell_tax,
|
|
"entry_mode": str(env.get("BREAKOUT_ENTRY_MODE") or "intrabar").strip().lower(),
|
|
"intrabar_slippage_pct": float(pick(("BREAKOUT_INTRABAR_SLIPPAGE_PCT",), 0.0, float)),
|
|
"use_ema_filter": (
|
|
str(env.get("BREAKOUT_USE_EMA_FILTER")).strip().lower()
|
|
in ("1", "true", "t", "y", "yes", "on")
|
|
if env.get("BREAKOUT_USE_EMA_FILTER") not in (None, "", "None")
|
|
else False
|
|
),
|
|
"ema_fast_period": pick(("BREAKOUT_EMA_FAST_PERIOD",), 9, lambda v: int(float(v))),
|
|
"ema_slow_period": pick(("BREAKOUT_EMA_SLOW_PERIOD",), 21, lambda v: int(float(v))),
|
|
"skip_hts_scan_dupes": resolve_breakout_skip_hts_scan_dupes(env),
|
|
"ob_filter_enabled": _breakout_trigger_filter_enabled(
|
|
env, prefix="BREAKOUT", kind="ORDERBOOK", global_key="ORDERBOOK_FILTER_ENABLED",
|
|
),
|
|
"pg_filter_enabled": _breakout_trigger_filter_enabled(
|
|
env, prefix="BREAKOUT", kind="PROGRAM", global_key="PROGRAM_FILTER_ENABLED",
|
|
),
|
|
"max_spread_pct": pick(
|
|
("BREAKOUT_ORDERBOOK_MAX_SPREAD_PCT",), 0.45, float,
|
|
),
|
|
}
|
|
|
|
|
|
def breakout_backtest_universe_scan_at_enabled(params: Optional[Dict[str, Any]] = None) -> bool:
|
|
"""백테 유니버스: 1분 슬롯 대신 초단위 스캔시각 타임라인 (기본 ON, 실매 정합).
|
|
|
|
실매 돌파는 봉 마감 시점의 조건검색 유니버스를 본다. 1분 슬롯(strict lag)은
|
|
편입을 최대 1분 늦춰 실매와 어긋난다. 초단위 타임라인은 그 봉 마감(HH:MM:59)
|
|
직전 최신 스냅샷을 그대로 써 실매 ``get_universe_at`` 와 정합.
|
|
끄려면 env BREAKOUT_BACKTEST_UNIVERSE_SCAN_AT=0.
|
|
"""
|
|
if params is not None and params.get("backtest_universe_scan_at") is not None:
|
|
s = str(params.get("backtest_universe_scan_at")).strip().lower()
|
|
if s in ("1", "true", "t", "y", "yes", "on"):
|
|
return True
|
|
if s in ("0", "false", "f", "n", "no", "off", ""):
|
|
return False
|
|
from kis_trader.utils.env import get_env_bool
|
|
return get_env_bool("BREAKOUT_BACKTEST_UNIVERSE_SCAN_AT", True)
|
|
|
|
|
|
def breakout_universe_exit_debounce_sec() -> int:
|
|
"""실매 ``CONDITION_EXIT_GRACE_SEC`` 정합 — 스냅샷 축소 시 N초 유지."""
|
|
from kis_trader.backtest.universe_timeline import universe_exit_debounce_sec_for_strategy
|
|
|
|
return universe_exit_debounce_sec_for_strategy("BREAKOUT")
|
|
|
|
|
|
def breakout_backtest_candle_warmup_bars() -> int:
|
|
"""백테 지표·lookback warm-up — 실매 WS 전일봉 버퍼와 동일하게 전일 봉 선행."""
|
|
from kis_trader.utils.env import get_env_int
|
|
|
|
return max(0, int(get_env_int("BREAKOUT_BACKTEST_CANDLE_WARMUP_BARS", 50)))
|
|
|
|
|
|
def prepend_breakout_candle_warmup(
|
|
db,
|
|
candles_by_code: Dict[str, List[Dict]],
|
|
period_start_key: str,
|
|
*,
|
|
warmup_bars: Optional[int] = None,
|
|
) -> int:
|
|
"""
|
|
``period_start_key``(YYYYMMDDHHMM) 이전 N봉(1분)을 종목별로 prepend.
|
|
lookback/vol_window 판별용 — 루프 시각(all_times)에는 기간일만 포함.
|
|
"""
|
|
wb = (
|
|
breakout_backtest_candle_warmup_bars()
|
|
if warmup_bars is None
|
|
else max(0, int(warmup_bars))
|
|
)
|
|
if wb <= 0 or db is None or not period_start_key:
|
|
return 0
|
|
ps = str(period_start_key)[:12]
|
|
ind_cols = ws_candles_select_indicator_cols(db)
|
|
total_prepended = 0
|
|
for code, rows in list(candles_by_code.items()):
|
|
if not rows:
|
|
continue
|
|
first_period_idx = None
|
|
for i, r in enumerate(rows):
|
|
ct = str(r.get("candle_time") or "")
|
|
if ct >= ps:
|
|
first_period_idx = i
|
|
break
|
|
if first_period_idx is None:
|
|
continue
|
|
if first_period_idx > 0:
|
|
continue
|
|
first_ct = str(rows[first_period_idx].get("candle_time") or "")
|
|
if not first_ct:
|
|
continue
|
|
warm_rows = db.conn.execute(
|
|
f"SELECT candle_time, open, high, low, close, volume, is_confirmed{ind_cols} "
|
|
"FROM ws_candles WHERE timeframe=1 AND code=%s "
|
|
"AND candle_time < %s ORDER BY candle_time DESC LIMIT %s",
|
|
[code, first_ct, wb],
|
|
).fetchall()
|
|
if not warm_rows:
|
|
continue
|
|
prefix = [dict(r) for r in reversed(warm_rows)]
|
|
candles_by_code[code] = prefix + [dict(r) for r in rows]
|
|
total_prepended += len(prefix)
|
|
if total_prepended > 0:
|
|
materialize_ws_candles_batch(db, candles_by_code, 1)
|
|
return total_prepended
|
|
|
|
|
|
def date_keys(start: str, end: str) -> Tuple[str, str, str, str]:
|
|
"""YYYY-MM-DD → candle_time 키 및 ymd."""
|
|
start_key = start.replace("-", "") + "0000"
|
|
end_key = end.replace("-", "") + "2359"
|
|
return start_key, end_key, start_key[:8], end_key[:8]
|
|
|
|
|
|
def resolve_breakout_universe(
|
|
start_ymd: str,
|
|
end_ymd: str,
|
|
*,
|
|
use_saved_history: bool,
|
|
strategy_id: str = BREAKOUT_STRATEGY_ID,
|
|
history_source: str = "kiwoom",
|
|
) -> Tuple[Optional[Dict[str, List[str]]], str, int, int]:
|
|
if use_saved_history and strategy_id:
|
|
try:
|
|
from kis_trader.database.db_manager import get_db as _get_ext_db
|
|
from kis_trader.backtest.universe_history_source import (
|
|
history_source_label,
|
|
resolve_backtest_universe_history_source,
|
|
)
|
|
|
|
hs = resolve_backtest_universe_history_source(history_source)
|
|
history = _get_ext_db().get_universe_by_candle_time(
|
|
strategy_id=strategy_id,
|
|
start_ymd=start_ymd,
|
|
end_ymd=end_ymd,
|
|
exit_debounce_sec=breakout_universe_exit_debounce_sec(),
|
|
history_source=hs,
|
|
)
|
|
if history:
|
|
return history, history_source_label(hs), len(history), 1
|
|
except Exception:
|
|
pass
|
|
return None, "all", 0, 1
|
|
|
|
|
|
def load_breakout_candles_by_code(
|
|
db,
|
|
start_key: str,
|
|
end_key: str,
|
|
lookback_min: int = 1,
|
|
vol_window: int = 7,
|
|
*,
|
|
history_source: str = "kiwoom",
|
|
) -> Tuple[Dict[str, List[Dict]], int]:
|
|
"""1분봉 전 종목 로드. history_source=ls → ls_ws_candles.
|
|
|
|
반환은 ``(candles_by_code, total_rows)`` 만 — 호출부에서 ``codes`` 리스트를
|
|
만들지 않는다. 웹 응답용 종목 수는 ``_codes_from_candles_map`` 으로 바인딩.
|
|
(LS/키움 테이블만 다르고 dict 키 스키마는 동일)
|
|
"""
|
|
min_bars = breakout_min_bars_required({
|
|
"lookback_min": lookback_min,
|
|
"vol_window": int(vol_window),
|
|
})
|
|
hs = str(history_source or "kiwoom").strip().lower()
|
|
if hs in ("ls", "ls_condition", "ls_ws"):
|
|
from kis_trader.backtest.ls_history_loaders import load_ls_candles_by_code
|
|
|
|
return load_ls_candles_by_code(
|
|
db, start_key, end_key, min_bars=min_bars,
|
|
)
|
|
|
|
ind_cols = ws_candles_select_indicator_cols(db)
|
|
codes_raw = db.conn.execute(
|
|
"SELECT DISTINCT code FROM ws_candles WHERE timeframe=1 "
|
|
"AND candle_time >= %s AND candle_time <= %s ORDER BY code",
|
|
[start_key, end_key],
|
|
).fetchall()
|
|
codes = [r["code"] for r in codes_raw]
|
|
|
|
candles_by_code: Dict[str, List[Dict]] = {}
|
|
total_candles = 0
|
|
|
|
for code in codes:
|
|
rows = db.conn.execute(
|
|
f"SELECT candle_time, open, high, low, close, volume{ind_cols} "
|
|
"FROM ws_candles WHERE timeframe=1 AND code=%s "
|
|
"AND candle_time >= %s AND candle_time <= %s AND is_confirmed=1 "
|
|
"ORDER BY candle_time ASC",
|
|
[code, start_key, end_key],
|
|
).fetchall()
|
|
if len(rows) < min_bars:
|
|
continue
|
|
candles_by_code[code] = [dict(r) for r in rows]
|
|
total_candles += len(rows)
|
|
|
|
materialize_ws_candles_batch(db, candles_by_code, 1)
|
|
return candles_by_code, total_candles
|
|
|
|
|
|
def run_breakout_backtest_web_aligned(
|
|
candles_by_code: Dict[str, List[Dict]],
|
|
params: Dict[str, Any],
|
|
universe_by_slot: Optional[Dict[str, List[str]]],
|
|
*,
|
|
slot_money: float,
|
|
fee_rate: float,
|
|
sell_tax: float,
|
|
max_stocks: Optional[int] = None,
|
|
total_budget_krw: Optional[float] = None,
|
|
ticks_by_code: Optional[Dict[str, Dict[str, List[Dict]]]] = None,
|
|
orderbook_by_code: Optional[Dict[str, Dict[str, List[Any]]]] = None,
|
|
program_by_code: Optional[Dict[str, Dict[str, List[Any]]]] = None,
|
|
meta_out: Optional[Dict[str, Any]] = None,
|
|
) -> List[Dict]:
|
|
"""엔진 1회 + 웹과 동일 손익 부착."""
|
|
engine_params = dict(params)
|
|
engine_params["slot_money"] = float(slot_money)
|
|
if max_stocks is not None:
|
|
engine_params["max_stocks"] = int(max_stocks)
|
|
if total_budget_krw is not None:
|
|
tb = float(total_budget_krw)
|
|
engine_params["total_budget_krw"] = tb if tb > 0 else float(
|
|
int(engine_params.get("max_stocks") or 3) * slot_money
|
|
)
|
|
if universe_by_slot is not None:
|
|
engine_params.setdefault("scan_interval_min", 1)
|
|
engine_params.setdefault("portfolio_mode", True)
|
|
|
|
# ── 전일 봉 웜업 (실매 WS 버퍼 정합) ─────────────────────────────────
|
|
warmup_prepended = 0
|
|
_sk_w = str((meta_out or {}).get("start_key") or "")
|
|
if meta_out is not None and _sk_w:
|
|
engine_params["_backtest_period_start_key"] = str(_sk_w)[:12]
|
|
_db_w = meta_out.get("db")
|
|
if _db_w is not None:
|
|
warmup_prepended = prepend_breakout_candle_warmup(
|
|
_db_w, candles_by_code, str(_sk_w)[:12],
|
|
)
|
|
|
|
# ── 초단위 유니버스 타임라인 (실매 get_universe_at 정합) ──────────────
|
|
# 1분 슬롯(strict lag)의 "편입 +최대 1분 지연" 을 제거. 봉 마감(HH:MM:59) 직전
|
|
# 최신 조건검색 스냅샷을 그대로 조회해 실매와 동일 시점 유니버스로 매수 판정.
|
|
# EXIT 디바운스 = CONDITION_EXIT_GRACE_SEC (실매 sticky/grace 정합).
|
|
if universe_by_slot is not None and breakout_backtest_universe_scan_at_enabled(engine_params):
|
|
_sk = str((meta_out or {}).get("start_key") or "")
|
|
_ek = str((meta_out or {}).get("end_key") or "")
|
|
if len(_sk) < 8 or len(_ek) < 8:
|
|
# meta_out 키 없으면 캔들 시각 min/max 일자로 폴백
|
|
_days = [
|
|
str(c.get("candle_time") or "")[:8]
|
|
for rows in candles_by_code.values() for c in rows
|
|
if c.get("candle_time")
|
|
]
|
|
if _days:
|
|
_sk, _ek = min(_days), max(_days)
|
|
if len(_sk) >= 8 and len(_ek) >= 8:
|
|
from kis_trader.backtest.universe_timeline import build_universe_timeline
|
|
from kis_trader.backtest.universe_history_source import (
|
|
resolve_backtest_universe_history_source,
|
|
)
|
|
|
|
_deb = breakout_universe_exit_debounce_sec()
|
|
# 슬롯 dict(resolve_breakout_universe)와 동일 소스 — LS 라벨인데 키움 타임라인 쓰는 사고 방지
|
|
_hs = resolve_backtest_universe_history_source(
|
|
engine_params.get("_universe_history_source")
|
|
or engine_params.get("universe_history_source")
|
|
)
|
|
engine_params["_universe_history_source"] = _hs
|
|
_tl = build_universe_timeline(
|
|
strategy_id=BREAKOUT_STRATEGY_ID,
|
|
start_ymd=_sk[:8], end_ymd=_ek[:8],
|
|
debounce_sec=_deb, strict=False, strict_lag_minutes=0,
|
|
history_source=_hs,
|
|
)
|
|
if _tl is not None:
|
|
engine_params["_universe_timeline"] = _tl
|
|
if meta_out is not None:
|
|
meta_out["universe_timing"] = "scan_at"
|
|
meta_out["universe_timeline_snapshots"] = _tl.snapshot_count
|
|
meta_out["universe_exit_debounce_sec"] = _deb
|
|
meta_out["universe_history_source"] = _hs
|
|
|
|
db_for_share = (meta_out or {}).get("db")
|
|
if db_for_share and "share_denom_by_code" not in engine_params:
|
|
engine_params = attach_share_denoms_to_params(
|
|
engine_params, db_for_share, candles_by_code.keys(),
|
|
)
|
|
|
|
loaded_ticks: Dict[str, Dict[str, List[Dict]]] = dict(ticks_by_code or {})
|
|
tick_meta: Dict[str, Any] = {}
|
|
if breakout_backtest_wants_tick_replay(engine_params):
|
|
if not loaded_ticks and meta_out is not None:
|
|
start_key = str(meta_out.get("start_key") or "")
|
|
end_key = str(meta_out.get("end_key") or "")
|
|
db = meta_out.get("db")
|
|
if db is None and start_key and end_key:
|
|
from kis_trader.backtest.backtest_portfolio_common import ensure_meta_db
|
|
|
|
db = ensure_meta_db(meta_out)
|
|
if db and start_key and end_key:
|
|
_hs_tick = str(
|
|
engine_params.get("_universe_history_source")
|
|
or engine_params.get("universe_history_source")
|
|
or (meta_out or {}).get("universe_history_source")
|
|
or "kiwoom"
|
|
).strip().lower()
|
|
if _hs_tick in ("ls", "ls_condition", "ls_ws"):
|
|
from kis_trader.backtest.ls_history_loaders import load_ls_ticks_by_code
|
|
|
|
loaded_ticks, tick_rows = load_ls_ticks_by_code(
|
|
db, start_key, end_key, set(candles_by_code.keys()),
|
|
)
|
|
_tick_tbl = "ls_ws_ticks"
|
|
else:
|
|
loaded_ticks, tick_rows = load_breakout_ticks_by_code(
|
|
db, start_key, end_key, set(candles_by_code.keys()),
|
|
)
|
|
_tick_tbl = "ws_ticks"
|
|
tick_meta = tick_coverage_stats(candles_by_code, loaded_ticks)
|
|
tick_meta["ws_tick_rows_loaded"] = tick_rows
|
|
tick_meta["tick_table"] = _tick_tbl
|
|
if tick_rows <= 0:
|
|
from kis_trader.utils.logger import get_logger as _get_logger
|
|
|
|
_get_logger("kis_trader.breakout_backtest").warning(
|
|
"⚠️ %s 데이터 없음 — B안 OHLC high 폴백 (틱 수집 후 재백테 권장)",
|
|
_tick_tbl,
|
|
)
|
|
elif loaded_ticks:
|
|
tick_meta = tick_coverage_stats(candles_by_code, loaded_ticks)
|
|
tick_meta["ws_tick_rows_loaded"] = sum(
|
|
len(lst) for cm in loaded_ticks.values() for lst in cm.values()
|
|
)
|
|
|
|
ob_loaded, pg_loaded, snap_meta = resolve_trigger_snapshots_for_backtest(
|
|
candles_by_code, engine_params, strategy="BREAKOUT", meta_out=meta_out,
|
|
orderbook_by_code=orderbook_by_code, program_by_code=program_by_code,
|
|
)
|
|
if snap_meta.get("log_verdict_by_code"):
|
|
engine_params["_backtest_log_verdict_by_code"] = snap_meta["log_verdict_by_code"]
|
|
|
|
from kis_trader.backtest.backtest_env_timeline import attach_backtest_env_timeline_to_params
|
|
attach_backtest_env_timeline_to_params(engine_params, meta_out, BREAKOUT_STRATEGY_ID)
|
|
|
|
trades = run_breakout_backtest(
|
|
candles_by_code,
|
|
engine_params,
|
|
universe_by_slot=universe_by_slot,
|
|
ticks_by_code=loaded_ticks or None,
|
|
orderbook_by_code=ob_loaded,
|
|
program_by_code=pg_loaded,
|
|
)
|
|
attach_scalp_trade_pnl(
|
|
trades, fee_rate=fee_rate, sell_tax=sell_tax,
|
|
slip_pct=backtest_slip_pct(engine_params),
|
|
)
|
|
if meta_out is not None:
|
|
skip_stats = engine_params.get("_portfolio_skip_stats") or {}
|
|
meta_out["skip_stats"] = dict(skip_stats)
|
|
if warmup_prepended > 0 or breakout_backtest_candle_warmup_bars() > 0:
|
|
meta_out["skip_stats"]["candle_warmup_bars"] = breakout_backtest_candle_warmup_bars()
|
|
meta_out["skip_stats"]["candle_warmup_prepended"] = warmup_prepended
|
|
meta_out["engine_params"] = engine_params
|
|
if tick_meta:
|
|
from kis_trader.backtest.breakout_tick_loader import enrich_tick_meta_with_traded_codes
|
|
tick_meta = enrich_tick_meta_with_traded_codes(
|
|
tick_meta, candles_by_code, loaded_ticks, trades,
|
|
)
|
|
meta_out["tick_backtest"] = tick_meta
|
|
mode = engine_params.get("entry_mode", "intrabar")
|
|
if tick_meta.get("ws_tick_rows_loaded", 0) > 0:
|
|
meta_out["backtest_buy_source"] = "ws_ticks"
|
|
elif breakout_backtest_wants_tick_replay(engine_params):
|
|
meta_out["backtest_buy_source"] = "ohlc_fallback"
|
|
else:
|
|
meta_out["backtest_buy_source"] = mode
|
|
if snap_meta:
|
|
meta_out["trigger_snapshot_backtest"] = snap_meta
|
|
return trades
|
|
|
|
|
|
def resolve_breakout_portfolio_params(
|
|
env_row: Optional[Dict[str, Any]],
|
|
base_defaults: Optional[Dict[str, Any]] = None,
|
|
*,
|
|
slot_money: Optional[float] = None,
|
|
max_stocks: Optional[int] = None,
|
|
total_budget_krw: Optional[float] = None,
|
|
) -> Dict[str, Any]:
|
|
return resolve_portfolio_params(
|
|
env_row,
|
|
base_defaults,
|
|
strategy="BREAKOUT",
|
|
slot_money=slot_money,
|
|
max_stocks=max_stocks,
|
|
total_budget_krw=total_budget_krw,
|
|
)
|
|
|
|
|
|
def merge_breakout_portfolio_into_params(
|
|
params: Dict[str, Any],
|
|
portfolio: Dict[str, Any],
|
|
) -> Dict[str, Any]:
|
|
return merge_portfolio_into_params(params, portfolio)
|
|
|
|
|
|
def build_breakout_budget_warning(
|
|
portfolio: Dict[str, Any],
|
|
skip_stats: Optional[Dict[str, Any]] = None,
|
|
) -> Optional[str]:
|
|
ratio = min_invest_ratio_of_slot({}, strategy="BREAKOUT")
|
|
return build_budget_warning(portfolio, skip_stats, min_invest_ratio=ratio)
|
|
|
|
|
|
def summarize_breakout_trades(
|
|
trades: List[Dict],
|
|
*,
|
|
total_budget_krw: float,
|
|
period_days: int = 1,
|
|
) -> Dict[str, Any]:
|
|
return summarize_trades(
|
|
trades,
|
|
total_budget_krw=total_budget_krw,
|
|
period_days=period_days,
|
|
)
|
|
|
|
|
|
def fee_and_slot_from_env(
|
|
row: Optional[Dict[str, Any]],
|
|
) -> Tuple[float, float, float]:
|
|
return fee_and_slot_from_env_row(row, strategy="BREAKOUT")
|