505 lines
20 KiB
Python
505 lines
20 KiB
Python
#!/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 (
|
|
flatten_remaining_portfolio_trades,
|
|
min_invest_ratio_of_slot,
|
|
portfolio_exposure_krw,
|
|
target_qty_and_cost,
|
|
)
|
|
from kis_trader.engine.scalping_engine import (
|
|
_t2dt,
|
|
_to_bool,
|
|
)
|
|
from kis_trader.engine.tick_exit_common import (
|
|
backtest_sell_slip_pct,
|
|
backtest_tick_poll_ms,
|
|
collect_minute_ticks,
|
|
resolve_backtest_sell,
|
|
)
|
|
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_backtest_tick_fallback_ohlc,
|
|
breakout_backtest_use_tick_exit,
|
|
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_sl_pct_ui(params: Dict[str, Any]) -> float:
|
|
"""실매와 동일 — ``stop_loss_pct``(비율) → UI%, 없으면 ``sl_pct``(UI% 또는 비율)."""
|
|
if params.get("stop_loss_pct") not in (None, ""):
|
|
return abs(float(params["stop_loss_pct"])) * 100.0
|
|
raw = abs(float(params.get("sl_pct", 2.0)))
|
|
# UI% 는 보통 ≥0.5, 비율은 0.05 등
|
|
return raw if raw >= 0.5 else raw * 100.0
|
|
|
|
|
|
def _resolve_breakout_invest_cap(params: Dict[str, Any]) -> float:
|
|
slot_money = float(params.get("slot_money", 2_000_000))
|
|
sl_pct_ui = _resolve_breakout_sl_pct_ui(params)
|
|
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_ui, 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`` → 다음 봉 시가 예약
|
|
- 매도: 틱 우선 ``resolve_backtest_sell`` → ``check_sell_signal_breakout_live``
|
|
"""
|
|
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
|
|
use_tick_exit = bool(ticks_by_code) and breakout_backtest_use_tick_exit(params)
|
|
tick_fallback_ohlc = breakout_backtest_tick_fallback_ohlc(params)
|
|
tick_poll_ms = backtest_tick_poll_ms(params, strategy_env="BREAKOUT_BACKTEST_POLL_MS")
|
|
tick_sell_slip = backtest_sell_slip_pct(params, strategy_env="BREAKOUT_BACKTEST_SELL_SLIP_PCT")
|
|
tick_exit_count = 0
|
|
ohlc_exit_count = 0
|
|
|
|
ctx_by_code: Dict[str, Dict[str, Any]] = {}
|
|
all_times_set = set()
|
|
period_start = str(params.get("_backtest_period_start_key") or "")[:12]
|
|
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:
|
|
ct = str(c.get("candle_time") or "")
|
|
if period_start and ct < period_start:
|
|
continue
|
|
all_times_set.add(ct)
|
|
|
|
all_times = sorted(all_times_set)
|
|
portfolio: Dict[str, Dict[str, Any]] = {}
|
|
all_trades: List[Dict] = []
|
|
|
|
universe_timeline = params.get("_universe_timeline")
|
|
|
|
from kis_trader.backtest.backtest_env_timeline import apply_env_timeline_at
|
|
|
|
for t in all_times:
|
|
if apply_env_timeline_at(params, t, "BREAKOUT"):
|
|
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)
|
|
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))
|
|
buy_params["time_start_hm"] = time_start_hm
|
|
buy_params["time_end_hm"] = time_end_hm
|
|
for _bk in (
|
|
"lookback_min", "vol_window", "vol_mult", "prev_chg_min", "prev_chg_max",
|
|
"sl_pct", "tp_pct", "stop_loss_pct", "take_profit_pct",
|
|
"trail_pct", "trail_arm_pct",
|
|
"shoulder_min_high", "shoulder_min_high_pct", "shoulder_cut_pct",
|
|
"max_daily", "cooldown_min", "max_daily_chg", "min_price", "skip_hts_scan_dupes",
|
|
"confirm_margin_pct", "body_min_pct", "ratchet_tiers", "max_hold_bars",
|
|
"sl_mode", "atr_period", "atr_sl_mult", "atr_sl_min_pct", "atr_sl_max_pct",
|
|
"eod_enabled", "eod_hm", "entry_mode",
|
|
):
|
|
if _bk in params:
|
|
buy_params[_bk] = params[_bk]
|
|
|
|
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)
|
|
minute_ticks = (
|
|
collect_minute_ticks(ticks_by_code, code, t) if use_tick_exit else None
|
|
)
|
|
res = resolve_backtest_sell(
|
|
pos,
|
|
bar,
|
|
params,
|
|
is_eod=is_eod,
|
|
sell_fn=check_sell_signal_breakout_live,
|
|
low_mode="current",
|
|
ticks=minute_ticks,
|
|
use_tick_exit=use_tick_exit,
|
|
tick_fallback_ohlc=tick_fallback_ohlc,
|
|
poll_ms=tick_poll_ms,
|
|
slip_pct=tick_sell_slip,
|
|
)
|
|
if not res:
|
|
continue
|
|
reason, exit_price, sell_time, _hold_min, exit_src = res
|
|
if exit_src == "ws_ticks":
|
|
tick_exit_count += 1
|
|
else:
|
|
ohlc_exit_count += 1
|
|
all_trades.append({
|
|
"code": code,
|
|
"buy_time": pos["entry_time"],
|
|
"sell_time": sell_time or 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(sell_time or 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
|
|
|
|
from kis_trader.engine.mid_enroll_entry_gate import bt_should_defer_mid_enroll
|
|
_ebk = str(signal.get("entry_bar_key") or entry_time or "")[:12]
|
|
if bt_should_defer_mid_enroll(
|
|
_ebk,
|
|
code,
|
|
t,
|
|
tf_min=1,
|
|
universe_timeline=universe_timeline,
|
|
universe_by_slot=universe_by_slot,
|
|
params=buy_params,
|
|
):
|
|
continue
|
|
|
|
pri = _buy_priority_key(code, uni_codes)
|
|
pe = {
|
|
"entry_time": entry_time,
|
|
"entry_price": entry_price,
|
|
"entry_bar_key": _ebk,
|
|
"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
|
|
|
|
skip_stats: Dict[str, Any] = {}
|
|
if skipped_micro_buys:
|
|
skip_stats["skipped_micro_buys"] = skipped_micro_buys
|
|
if tick_exit_count or ohlc_exit_count:
|
|
skip_stats["tick_exit_count"] = tick_exit_count
|
|
skip_stats["ohlc_exit_count"] = ohlc_exit_count
|
|
flat_n = flatten_remaining_portfolio_trades(
|
|
portfolio, ctx_by_code, all_trades,
|
|
params=params, strategy="BREAKOUT",
|
|
)
|
|
if flat_n:
|
|
skip_stats["bt_flatten_count"] = flat_n
|
|
if skip_stats:
|
|
params["_portfolio_skip_stats"] = skip_stats
|
|
all_trades.sort(key=lambda x: x["sell_time"])
|
|
return all_trades
|