Changes: - Updated import paths for `compute_atr_series` and `is_strategy_eod_bar` to reflect new module structure. - Removed the unused `compute_atr_series` function from `tail_engine.py`, streamlining the codebase. Impact: - These changes enhance code organization and maintainability by ensuring that only necessary components are imported and utilized, while also eliminating redundant code.
414 lines
16 KiB
Python
414 lines
16 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 (
|
|
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
|