#!/usr/bin/env python3 """ 돌파매매 시각순 포트폴리오 백테스트 — tail/scalping 포트폴리오와 동일 Phase0/1/2 구조. """ from __future__ import annotations from typing import Any, Dict, List, Optional, Tuple from kis_trader.backtest.backtest_portfolio_common import ( min_invest_ratio_of_slot, portfolio_exposure_krw, target_qty_and_cost, ) from kis_trader.engine.scalping_engine import ( _t2dt, _to_bool, check_sell_signal_backtest_bar, ) from kis_trader.share.stock_share import share_denom_for_code from kis_trader.engine.indicator_cache import ( attach_indicator_caches_to_params, get_indicator_cache_from_params, ) from kis_trader.engine.whipsaw_filter import inject_whipsaw_ticks_into_params from kis_trader.backtest.trigger_snapshot_loader import inject_trigger_snapshots_into_params from kis_trader.strategies.breakout import ( _bt_slot_key, breakout_entry_mode, breakout_invest_amount_krw, breakout_scan_buy_at_bar, check_sell_signal_breakout_live, normalize_breakout_max_loss_krw, ) from kis_trader.strategies.base import is_strategy_eod_bar from kis_trader.engine.atr_series import compute_atr_series def _entry_atr_at(ctx: Dict[str, Any], idx: int) -> float: """ctx 사전계산 ATR 시리즈에서 진입 봉(idx) 변동성 조회. 없으면 0.0(=고정손절 폴백).""" arr = ctx.get("atr_arr") if arr is not None and 0 <= idx < len(arr): v = arr[idx] if v is not None: return float(v) return 0.0 def _buy_priority_key( code: str, uni_codes: Optional[List[str]], ) -> Tuple[int, str]: """유니버스 편입 순서(HTS/DB insert 순) = 실매 매수 우선순위. None=필터없음.""" if uni_codes is None: return (0, code) try: return (uni_codes.index(code), code) except ValueError: return (999999, code) def _universe_codes_at( t: str, slot_key: str, universe_timeline: Optional[Any], universe_by_slot: Optional[Dict[str, List[str]]], ) -> Optional[List[str]]: """그 시각(봉 마감초) 유효 유니버스 코드 리스트. - ``universe_timeline`` (초단위, 실매 get_universe_at 정합) 우선 — 봉 마감(HH:MM:59) 직전 최신 스냅샷. strict lag(1분 지연) 없이 실매와 동일 시점 조회. - 없으면 1분 슬롯(``universe_by_slot``) 폴백. 둘 다 없으면 None(전종목·필터없음). """ if universe_timeline is not None: return universe_timeline.codes_at(str(t)[:12] + "59") if universe_by_slot is not None: return universe_by_slot.get(slot_key, []) return None def _max_stocks_from_params(params: Dict[str, Any]) -> int: for key in ("max_stocks", "breakout_max_stocks", "short_max_stocks"): v = params.get(key) if v not in (None, "", 0): return max(1, int(v)) try: from kis_trader.utils.env import get_env_int n = get_env_int("BREAKOUT_MAX_STOCKS", 0) or get_env_int("MAX_STOCKS", 3) return max(1, int(n)) except Exception: return 3 def _total_budget_from_params(params: Dict[str, Any]) -> float: for key in ("total_budget_krw", "breakout_total_budget_krw", "short_total_budget_krw"): v = params.get(key) if v not in (None, ""): try: return float(v) except (TypeError, ValueError): pass try: from kis_trader.utils.env import get_env_int cap = get_env_int("BREAKOUT_TOTAL_BUDGET_KRW", 0) if cap > 0: return float(cap) except Exception: pass return 0.0 def _resolve_breakout_invest_cap(params: Dict[str, Any]) -> float: slot_money = float(params.get("slot_money", 2_000_000)) sl_pct = abs(float(params.get("stop_loss_pct", params.get("sl_pct", -0.02)))) max_loss_krw = normalize_breakout_max_loss_krw(params.get("max_loss_krw", 200_000)) return breakout_invest_amount_krw(max_loss_krw, sl_pct * 100.0, slot_money) def run_breakout_backtest_portfolio( codes_candles: Dict[str, List[Dict]], params: Dict[str, Any], universe_by_slot: Optional[Dict[str, List[str]]] = 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, ) -> List[Dict]: """ 시각순 포트폴리오 돌파 백테스트. - 매수: ``check_buy_signal_breakout_live`` → 다음 봉 시가 예약 - 매도: ``check_sell_signal_breakout_live`` via ``check_sell_signal_backtest_bar`` """ lookback_min = int(params.get("lookback_min", 1)) vol_window = int(params.get("vol_window", 7)) need_n = max(lookback_min, vol_window) + 2 _mode = breakout_entry_mode(params) min_bars = need_n + (0 if _mode in ("intrabar", "b", "live_b", "hts") else 1) cooldown_min = float(params.get("cooldown_min", 30)) max_daily = int(params.get("max_daily", 1)) max_stocks = _max_stocks_from_params(params) slot_money = float(params.get("slot_money", 2_000_000)) total_budget = _total_budget_from_params(params) if total_budget <= 0: total_budget = float(max_stocks * slot_money) min_invest_ratio = min_invest_ratio_of_slot(params, strategy="BREAKOUT") invest_cap = _resolve_breakout_invest_cap(params) time_start_hm = int(params.get("time_start_hm", 900)) time_end_hm = int(params.get("time_end_hm", 1030)) # [ATR 동적 손절] sl_mode='atr' 일 때만 종목별 ATR(RMA) 시리즈를 1회 사전계산해 ctx 에 캐시. # fixed(기본)면 계산 자체를 건너뛰어 기존 경로와 동일한 비용/동작 유지. _sl_mode_atr = str(params.get("sl_mode", "fixed") or "fixed").strip().lower() == "atr" _atr_period = int(params.get("atr_period", 14) or 14) buy_params = dict(params) buy_params["time_start_hm"] = time_start_hm buy_params["time_end_hm"] = time_end_hm attach_indicator_caches_to_params(buy_params, codes_candles) skipped_micro_buys = 0 ctx_by_code: Dict[str, Dict[str, Any]] = {} all_times_set = set() for code, raw_rows in codes_candles.items(): if len(raw_rows) < min_bars: continue candles = [dict(r) for r in raw_rows] # [성능] 봉별 '당일 시가' 사전계산(O(n) 1회). 매수스캔(_eval 이격과열 필터)에서 # 매번 처음부터 당일시가를 정주행 스캔하던 비용을 제거하기 위해 주입한다. day_open_arr: List[float] = [0.0] * len(candles) _cur_day = None _cur_open = 0.0 _first_open = float(candles[0].get("open") or 0) if candles else 0.0 for _idx, _c in enumerate(candles): _d = str(_c.get("candle_time") or "")[:8] if _d != _cur_day: _cur_day = _d _cur_open = float(_c.get("open") or 0) day_open_arr[_idx] = _cur_open if _cur_open > 0 else _first_open # ATR 시리즈(진입 봉 변동성) — sl_mode='atr' 일 때만. 아니면 None(기존과 동일). atr_arr = compute_atr_series(candles, _atr_period) if _sl_mode_atr else None ctx_by_code[code] = { "code": code, "candles": candles, "time_index": {c["candle_time"]: idx for idx, c in enumerate(candles)}, "day_open_arr": day_open_arr, "atr_arr": atr_arr, "last_exit_dt": {}, "daily_cnt": {}, "pending_entry": None, } for c in candles: all_times_set.add(c["candle_time"]) all_times = sorted(all_times_set) portfolio: Dict[str, Dict[str, Any]] = {} all_trades: List[Dict] = [] universe_timeline = params.get("_universe_timeline") for t in all_times: slot_key = _bt_slot_key(t, int(params.get("scan_interval_min", 1))) # 초단위 유니버스(실매 정합) — 타임라인 우선, 없으면 1분 슬롯 폴백 uni_codes = _universe_codes_at(t, slot_key, universe_timeline, universe_by_slot) uni_set = set(uni_codes) if uni_codes is not None else None # ── Phase 0: 예약 진입 ── pending_codes = [ code for code, ctx in ctx_by_code.items() if ctx.get("pending_entry") and ctx["pending_entry"].get("entry_time") == t ] pending_codes.sort(key=lambda c: _buy_priority_key(c, uni_codes)) for code in pending_codes: ctx = ctx_by_code[code] pe = ctx.pop("pending_entry", None) if not pe or code in portfolio: continue if len(portfolio) >= max_stocks: break entry_price = float(pe["entry_price"]) if entry_price <= 0: continue exposure = portfolio_exposure_krw(portfolio) remaining = max(0.0, total_budget - exposure) target_qty, target_cost = target_qty_and_cost(entry_price, invest_cap) min_required = target_cost * min_invest_ratio if target_qty < 1 or remaining < min_required: skipped_micro_buys += 1 continue invest = min(invest_cap, remaining, target_cost) qty = int(invest / entry_price) if qty < 1: skipped_micro_buys += 1 continue cost = qty * entry_price if cost < min_required: skipped_micro_buys += 1 continue if exposure + cost > total_budget + 1e-6: skipped_micro_buys += 1 continue portfolio[code] = { "entry_price": entry_price, "entry_time": t, "qty": qty, "max_price": entry_price, "entry_atr": float(pe.get("entry_atr") or 0.0), # 신호 봉에서 운반된 ATR } ctx["daily_cnt"][t[:8]] = ctx["daily_cnt"].get(t[:8], 0) + 1 break # ── Phase 1: 청산 ── for code in list(portfolio.keys()): ctx = ctx_by_code.get(code) if ctx is None: continue idx = ctx["time_index"].get(t) if idx is None: continue candles = ctx["candles"] c = candles[idx] day = t[:8] cl = float(c["close"]) is_eod = is_strategy_eod_bar(t, params, "BREAKOUT") pos = portfolio[code] if t == pos["entry_time"]: continue bar = dict(c) if "open" not in bar or bar.get("open") in (None, ""): bar["open"] = float(c.get("open") or cl) res = check_sell_signal_backtest_bar( pos, bar, params, is_eod=is_eod, sell_fn=check_sell_signal_breakout_live, low_mode="current", ) if not res: continue reason, exit_price = res all_trades.append({ "code": code, "buy_time": pos["entry_time"], "sell_time": t, "buy_price": pos["entry_price"], "sell_price": round(exit_price, 2), "qty": pos.get("qty", 1), "pnl": 0, "sell_reason": reason, "hold_min": 0, }) ctx["last_exit_dt"][day] = _t2dt(t) del portfolio[code] # ── Phase 2: 신규 매수 신호 ── if len(portfolio) >= max_stocks: continue exposure = portfolio_exposure_krw(portfolio) if exposure >= total_budget - 1e-6: continue candidates: List[Tuple[Tuple[int, str], str, Dict[str, Any]]] = [] for code, ctx in ctx_by_code.items(): if code in portfolio or ctx.get("pending_entry"): continue idx = ctx["time_index"].get(t) if idx is None: continue candles = ctx["candles"] c = candles[idx] day = t[:8] cl = float(c["close"]) if cl <= 0: continue if uni_set is not None: if code not in uni_set: continue if day in ctx["last_exit_dt"]: elapsed = (_t2dt(t) - ctx["last_exit_dt"][day]).total_seconds() / 60 if elapsed < cooldown_min: continue if ctx["daily_cnt"].get(day, 0) >= max_daily: continue minute_ticks = None if ticks_by_code: minute_ticks = (ticks_by_code.get(code) or {}).get(str(t)[:12]) _day_open_arr = ctx.get("day_open_arr") _day_open = ( _day_open_arr[idx] if _day_open_arr is not None and 0 <= idx < len(_day_open_arr) else None ) code_buy = dict(buy_params) code_buy["share_denom"] = share_denom_for_code(buy_params, code) ic = get_indicator_cache_from_params(buy_params, code) if ic is not None: code_buy["_indicator_cache"] = ic inject_whipsaw_ticks_into_params( code_buy, ticks_by_code=ticks_by_code, code=code, bar_candle_time=t, strategy="BREAKOUT", tf_min=1, ) inject_trigger_snapshots_into_params( code_buy, orderbook_by_code=orderbook_by_code, program_by_code=program_by_code, code=code, bar_candle_time=t, ) _reason, _msg, signal, entry_price, entry_time = breakout_scan_buy_at_bar( candles, idx, code_buy, minute_ticks=minute_ticks, day_open=_day_open, ) if not signal or entry_price <= 0 or not entry_time: continue if entry_time[:8] != day: continue pri = _buy_priority_key(code, uni_codes) pe = { "entry_time": entry_time, "entry_price": entry_price, "entry_atr": _entry_atr_at(ctx, idx), # 신호 봉 변동성(ATR 동적 손절용) } if entry_time == t and code not in portfolio: exposure = portfolio_exposure_krw(portfolio) remaining = max(0.0, total_budget - exposure) target_qty, target_cost = target_qty_and_cost(entry_price, invest_cap) min_required = target_cost * min_invest_ratio if ( len(portfolio) < max_stocks and target_qty >= 1 and remaining >= min_required and exposure + target_cost <= total_budget + 1e-6 ): invest = min(invest_cap, remaining, target_cost) qty = int(invest / entry_price) if qty < 1: continue cost = qty * entry_price if cost >= min_required: portfolio[code] = { "entry_price": entry_price, "entry_time": t, "qty": qty, "max_price": entry_price, "entry_atr": _entry_atr_at(ctx, idx), } ctx["daily_cnt"][day] = ctx["daily_cnt"].get(day, 0) + 1 continue candidates.append((pri, code, pe)) if not candidates: continue candidates.sort(key=lambda x: x[0]) _pri, pick_code, pe = candidates[0] ctx_by_code[pick_code]["pending_entry"] = pe if skipped_micro_buys: params["_portfolio_skip_stats"] = {"skipped_micro_buys": skipped_micro_buys} all_trades.sort(key=lambda x: x["sell_time"]) return all_trades