feat: Implement backtest source management and enhance candle data handling Changes: - Introduced a new function `_apply_backtest_source_env_from_request` to manage the environment variables for candle, tick, and order book sources based on incoming requests. - Added a teardown function `_teardown_backtest_source_env` to ensure that environment variables do not persist between requests, enhancing the stability of the backtesting environment. - Refactored existing code to utilize the new source management functions, improving code readability and maintainability. - Added new utility functions in `bt_candle_source.py` for fetching and managing candle data, ensuring consistency with live trading data sources. Impact: - These changes improve the flexibility and reliability of the backtesting framework, allowing for better management of data sources and reducing the risk of cross-request contamination.
640 lines
26 KiB
Python
640 lines
26 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
돌파매매 백테스트 공통 로더 — backtest_web / param_search 가
|
|
동일한 캔들·유니버스·손익 계산을 쓰도록 단일 진입점.
|
|
|
|
청산: ``check_sell_signal_breakout_live`` — EOD → 익절 → 어깨 → 호가컷/손절호가(OFF) → 손절 → 트레일.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from typing import Any, Dict, List, Optional, Tuple
|
|
import os
|
|
|
|
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,
|
|
),
|
|
# 수익구간 호가매도 / 손절호가 (UI: 이익·손실은 % 표시 · 기본 OFF)
|
|
"exit_ob_enabled": (
|
|
str(env.get("BREAKOUT_EXIT_OB_ENABLED") or "").strip().lower()
|
|
in ("1", "true", "t", "y", "yes", "on")
|
|
),
|
|
"exit_ob_ratio_min": pick(("BREAKOUT_EXIT_OB_RATIO_MIN",), 0.4, float),
|
|
"exit_ob_ma_window": pick(("BREAKOUT_EXIT_OB_MA_WINDOW",), 5, lambda v: int(float(v))),
|
|
"exit_ob_min_profit_pct": pct_ui(pick(("BREAKOUT_EXIT_OB_MIN_PROFIT_PCT",), 0.005, float)),
|
|
"exit_ob_min_hold_bars": pick(("BREAKOUT_EXIT_OB_MIN_HOLD_BARS",), 3, lambda v: int(float(v))),
|
|
"stop_ob_enabled": (
|
|
str(env.get("BREAKOUT_STOP_OB_ENABLED") or "").strip().lower()
|
|
in ("1", "true", "t", "y", "yes", "on")
|
|
),
|
|
"stop_ob_ratio_min": pick(("BREAKOUT_STOP_OB_RATIO_MIN",), 0.4, float),
|
|
"stop_ob_ma_window": pick(("BREAKOUT_STOP_OB_MA_WINDOW",), 5, lambda v: int(float(v))),
|
|
"stop_ob_min_loss_pct": pct_ui(pick(("BREAKOUT_STOP_OB_MIN_LOSS_PCT",), 0.003, float)),
|
|
"stop_ob_min_hold_bars": pick(("BREAKOUT_STOP_OB_MIN_HOLD_BARS",), 2, lambda v: int(float(v))),
|
|
}
|
|
|
|
|
|
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
|
|
from kis_trader.backtest.bt_candle_source import fetch_ws_candles_warmup_before
|
|
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 = fetch_ws_candles_warmup_before(
|
|
db, code, 1, first_ct, wb,
|
|
extra_select=ind_cols,
|
|
)
|
|
if not warm_rows:
|
|
continue
|
|
prefix = 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,
|
|
)
|
|
|
|
from kis_trader.backtest.bt_candle_source import (
|
|
fetch_ws_candles_for_code,
|
|
list_ws_candle_codes,
|
|
)
|
|
|
|
ind_cols = ws_candles_select_indicator_cols(db)
|
|
codes = list_ws_candle_codes(db, 1, start_key, end_key)
|
|
|
|
candles_by_code: Dict[str, List[Dict]] = {}
|
|
total_candles = 0
|
|
|
|
for code in codes:
|
|
rows = fetch_ws_candles_for_code(
|
|
db, code, 1, start_key, end_key,
|
|
extra_select=ind_cols,
|
|
confirmed_only=True,
|
|
)
|
|
if len(rows) < min_bars:
|
|
continue
|
|
candles_by_code[code] = 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")
|