#!/usr/bin/env python3 """ kis_trader/engine/dbband_engine.py — 더블 볼린저 백테스트·실매매 공통 엔진 ========================================================================== 캐시 리엔 더블 BB(20/2 + 20/3) + 추세 MA(기본 200) 필터. ■ LONG (우상향 지수·ETF용, 기본) - 추세: 종가 > SMA(trend_ma) 일 때만 매수 - 신호봉: 저가가 outer(σ3) 하단 밖 → 종가가 inner(σ2) 밴드 안으로 복귀 - 진입: 신호봉 고가 돌파 (또는 다음 봉 시가) - 손절: 신호봉 저점(또는 %/버퍼) - 익절: 반대편 inner 상단 밴드 / 고정% / 손익비 ■ SHORT (선택, side_mode=short_only|both) - 추세: 종가 < SMA 일 때만 - 신호: 고가 outer 상단 밖 → 종가 inner 안 복귀 → 신호봉 저가 이탈 시 진입 ■ 청산 (exit_mode 로 전략 분리 — 단타 V4 와 혼용 금지) - classic (기본·영상 원형): 익절(반대 2σ / RR / %) → 손절(신호저점) - v4_scalp (선택·기존 단타식): 어깨컷 → 익절 → 손절 → 트레일 → 시간컷 → 장마감 """ from __future__ import annotations import math from datetime import datetime from typing import Any, Dict, List, Optional, Tuple from kis_trader.engine.dbband_env_keys import ( dbband_env_bool, dbband_env_float, dbband_env_int, ) # 종목별 dbband_stock_config · 웹·파라서치 공통 키 CFG_ENGINE_KEYS: Tuple[str, ...] = ( "bb_period", "bb_inner_std", "bb_outer_std", "trend_ma_period", "use_trend_filter", "side_mode", "entry_valid_bars", "entry_mode", "stop_mode", "stop_buffer_pct", "stop_loss_pct", "tp_mode", "take_profit_pct", "rr_ratio", "exit_mode", "shoulder_min_high_pct", "shoulder_cut_pct", "trail_pct", "trail_arm_pct", "max_hold_bars", "slot_money", "cooldown_min", "max_daily", ) DEFAULT_DBBAND_CONFIG: Dict[str, Any] = { "bb_period": 20.0, "bb_inner_std": 2.0, "bb_outer_std": 3.0, "trend_ma_period": 200.0, "use_trend_filter": 1.0, "side_mode": "long_only", "entry_valid_bars": 3.0, "entry_mode": "break_high", "stop_mode": "signal_low", "stop_buffer_pct": 0.1, "stop_loss_pct": 2.0, "tp_mode": "opposite_band", "take_profit_pct": 3.0, "rr_ratio": 2.0, "exit_mode": "classic", "shoulder_min_high_pct": 0.3, "shoulder_cut_pct": 0.2, "trail_pct": 0.0, "trail_arm_pct": 0.0, "max_hold_bars": 0.0, "slot_money": 3_000_000.0, "cooldown_min": 15.0, "max_daily": 3.0, "timeframe": 15.0, "force_eod_exit": 0.0, } def _to_bool(v: Any, default: bool = True) -> bool: if v is None: return default if isinstance(v, bool): return v s = str(v).strip().lower() if s in ("1", "true", "t", "y", "yes", "on"): return True if s in ("0", "false", "f", "n", "no", "off", ""): return False return default def _t2dt(candle_time: str) -> datetime: from kis_trader.utils.trade_time import parse_trade_datetime return parse_trade_datetime(candle_time) def _confirmed_candles_only(candles: List[Dict]) -> List[Dict]: confirmed = [c for c in candles if _to_bool(c.get("is_confirmed", 1), True)] return confirmed if confirmed else list(candles) def _sma_series(values: List[float], period: int) -> List[Optional[float]]: n = len(values) out: List[Optional[float]] = [None] * n if period <= 0: return out for i in range(period - 1, n): window = values[i - period + 1: i + 1] if all(x > 0 for x in window): out[i] = sum(window) / period return out def _rolling_std(values: List[float], period: int) -> List[Optional[float]]: n = len(values) out: List[Optional[float]] = [None] * n if period <= 1: return out for i in range(period - 1, n): window = values[i - period + 1: i + 1] if not all(x > 0 for x in window): continue mean = sum(window) / period var = sum((x - mean) ** 2 for x in window) / period out[i] = math.sqrt(var) return out def compute_bb_series( closes: List[float], period: int, std_mult: float, ) -> Tuple[List[Optional[float]], List[Optional[float]], List[Optional[float]]]: """SMA 중심선, 상단, 하단 시리즈.""" mid = _sma_series(closes, period) stds = _rolling_std(closes, period) n = len(closes) upper: List[Optional[float]] = [None] * n lower: List[Optional[float]] = [None] * n for i in range(n): if mid[i] is not None and stds[i] is not None: upper[i] = mid[i] + std_mult * stds[i] lower[i] = mid[i] - std_mult * stds[i] return mid, upper, lower def get_dbband_defaults_from_db(db=None, *, env_row: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: """env_config + config_dbband 병합 → 엔진 params dict.""" own_db = None r: Dict[str, Any] = {} try: if env_row is not None: r = dict(env_row) else: if db is None: from database import TradeDB own_db = TradeDB() db = own_db if hasattr(db, "get_merged_env_snapshot"): r = db.get_merged_env_snapshot() elif hasattr(db, "get_latest_env"): latest = db.get_latest_env() r = dict((latest or {}).get("snapshot") or {}) else: row = db.conn.execute( "SELECT * FROM env_config ORDER BY id DESC LIMIT 1" ).fetchone() r = dict(row) if row else {} bb_period = dbband_env_int(r, "DBBAND_BB_PERIOD", 20) inner_std = dbband_env_float(r, "DBBAND_BB_INNER_STD", 2.0) outer_std = dbband_env_float(r, "DBBAND_BB_OUTER_STD", 3.0) trend_ma = dbband_env_int(r, "DBBAND_TREND_MA_PERIOD", 200) use_trend = dbband_env_bool(r, "DBBAND_USE_TREND_FILTER", True) side_mode = str(r.get("DBBAND_SIDE_MODE") or "long_only").strip().lower() entry_valid = dbband_env_int(r, "DBBAND_ENTRY_VALID_BARS", 3) entry_mode = str(r.get("DBBAND_ENTRY_MODE") or "break_high").strip().lower() stop_mode = str(r.get("DBBAND_STOP_MODE") or "signal_low").strip().lower() stop_buffer = dbband_env_float(r, "DBBAND_STOP_BUFFER_PCT", 0.1) sl_pct = abs(dbband_env_float(r, "DBBAND_STOP_LOSS_PCT", 2.0)) / 100.0 tp_mode = str(r.get("DBBAND_TP_MODE") or "opposite_band").strip().lower() tp_pct = dbband_env_float(r, "DBBAND_TAKE_PROFIT_PCT", 3.0) / 100.0 rr_ratio = dbband_env_float(r, "DBBAND_RR_RATIO", 2.0) shoulder_high = dbband_env_float(r, "DBBAND_SHOULDER_MIN_HIGH_PCT", 0.3) / 100.0 shoulder_cut = dbband_env_float(r, "DBBAND_SHOULDER_CUT_PCT", 0.2) / 100.0 trail_pct = abs(dbband_env_float(r, "DBBAND_TRAIL_PCT", 0.0)) / 100.0 trail_arm = abs(dbband_env_float(r, "DBBAND_TRAIL_ARM_PCT", 0.0)) / 100.0 cooldown_sec = dbband_env_int(r, "DBBAND_COOLDOWN_SEC", 900) time_start = dbband_env_int(r, "DBBAND_TIME_START", 930) time_end = dbband_env_int(r, "DBBAND_TIME_END", 1500) max_daily = dbband_env_int(r, "DBBAND_MAX_DAILY", 3) min_price = dbband_env_float(r, "DBBAND_MIN_PRICE", 1000.0) slot_money = dbband_env_int(r, "DBBAND_SLOT_MONEY", 3_000_000) max_stocks = dbband_env_int(r, "DBBAND_MAX_STOCKS", 3) total_budget = dbband_env_int(r, "DBBAND_TOTAL_BUDGET_KRW", 0) max_hold = dbband_env_int(r, "DBBAND_MAX_HOLD_BARS", 0) timeframe = dbband_env_int(r, "DBBAND_TIMEFRAME", 15) min_invest = dbband_env_float(r, "DBBAND_MIN_INVEST_RATIO_OF_SLOT", 0.9) force_eod = dbband_env_bool(r, "DBBAND_FORCE_EOD_EXIT", False) exit_mode = str(r.get("DBBAND_EXIT_MODE") or "classic").strip().lower() return { "bb_period": bb_period, "bb_inner_std": inner_std, "bb_outer_std": outer_std, "trend_ma_period": trend_ma, "use_trend_filter": use_trend, "side_mode": side_mode, "entry_valid_bars": max(1, entry_valid), "entry_mode": entry_mode, "stop_mode": stop_mode, "stop_buffer_pct": stop_buffer / 100.0, "sl_pct": sl_pct, "tp_mode": tp_mode, "tp_pct": tp_pct, "rr_ratio": rr_ratio, "exit_mode": exit_mode, "shoulder_min_high": shoulder_high, "shoulder_cut_pct": shoulder_cut, "trail_pct": trail_pct, "trail_arm_pct": trail_arm, "cooldown_min": cooldown_sec / 60.0, "time_start_hm": time_start, "time_end_hm": time_end, "max_daily": max_daily, "min_price": min_price, "slot_money": slot_money, "max_stocks": max_stocks, "total_budget_krw": total_budget, "max_hold_bars": max_hold, "timeframe": timeframe, "min_invest_ratio_of_slot": min_invest, "force_eod_exit": force_eod, } finally: if own_db is not None: try: own_db.close() except Exception: pass return {} def _hm_from_candle(candle_time: str) -> int: s = str(candle_time) return int(s[8:12]) if len(s) >= 12 else 0 def _resolve_stop_price( side: str, entry: float, signal_low: float, signal_high: float, params: Dict[str, Any], ) -> float: mode = str(params.get("stop_mode") or "signal_low").strip().lower() buf = float(params.get("stop_buffer_pct") or 0.0) sl_pct = abs(float(params.get("sl_pct") or 0.02)) if side == "long": if mode == "pct": return entry * (1.0 - sl_pct) if mode == "buffer_pct": return signal_low * (1.0 - buf) return signal_low * (1.0 - buf) if buf > 0 else signal_low if mode == "pct": return entry * (1.0 + sl_pct) if mode == "buffer_pct": return signal_high * (1.0 + buf) return signal_high * (1.0 + buf) if buf > 0 else signal_high def _resolve_target_price( side: str, entry: float, stop: float, inner_upper: Optional[float], inner_lower: Optional[float], params: Dict[str, Any], ) -> float: tp_mode = str(params.get("tp_mode") or "opposite_band").strip().lower() tp_pct = abs(float(params.get("tp_pct") or 0.03)) rr = abs(float(params.get("rr_ratio") or 2.0)) risk = abs(entry - stop) if side == "long": if tp_mode == "opposite_band" and inner_upper is not None and inner_upper > entry: return float(inner_upper) if tp_mode == "rr" and risk > 0: return entry + risk * rr return entry * (1.0 + tp_pct) if tp_mode == "opposite_band" and inner_lower is not None and inner_lower < entry: return float(inner_lower) if tp_mode == "rr" and risk > 0: return entry - risk * rr return entry * (1.0 - tp_pct) def _long_signal_bar( i: int, lows: List[float], closes: List[float], inner_lower: List[Optional[float]], inner_upper: List[Optional[float]], outer_lower: List[Optional[float]], trend_ma: List[Optional[float]], params: Dict[str, Any], ) -> bool: if i < 0 or i >= len(closes): return False il = inner_lower[i] iu = inner_upper[i] ol = outer_lower[i] cl = closes[i] lo = lows[i] if il is None or iu is None or ol is None or cl <= 0 or lo <= 0: return False use_trend = _to_bool(params.get("use_trend_filter"), True) if use_trend: tm = trend_ma[i] if tm is None or cl <= tm: return False if lo >= ol: return False if cl <= il or cl >= iu: return False return True def _short_signal_bar( i: int, highs: List[float], closes: List[float], inner_lower: List[Optional[float]], inner_upper: List[Optional[float]], outer_upper: List[Optional[float]], trend_ma: List[Optional[float]], params: Dict[str, Any], ) -> bool: if i < 0 or i >= len(closes): return False il = inner_lower[i] iu = inner_upper[i] ou = outer_upper[i] cl = closes[i] hi = highs[i] if il is None or iu is None or ou is None or cl <= 0 or hi <= 0: return False use_trend = _to_bool(params.get("use_trend_filter"), True) if use_trend: tm = trend_ma[i] if tm is None or cl >= tm: return False if hi <= ou: return False if cl >= iu or cl <= il: return False return True def _try_entry_on_bar( side: str, bar_i: int, signal_i: int, candles: List[Dict], highs: List[float], lows: List[float], opens: List[float], params: Dict[str, Any], ) -> Optional[Tuple[float, str]]: """신호봉 이후 bar_i 에서 돌파 진입 시도.""" entry_mode = str(params.get("entry_mode") or "break_high").strip().lower() sig_hi = highs[signal_i] sig_lo = lows[signal_i] if side == "long": trigger = sig_hi if entry_mode == "next_open" and bar_i == signal_i + 1: op = opens[bar_i] return (op, "next_open") if op > 0 else None if highs[bar_i] >= trigger and trigger > 0: op = opens[bar_i] fill = max(trigger, op) if op > 0 else trigger return (fill, "break_high") else: trigger = sig_lo if entry_mode == "next_open" and bar_i == signal_i + 1: op = opens[bar_i] return (op, "next_open") if op > 0 else None if lows[bar_i] <= trigger and trigger > 0: op = opens[bar_i] fill = min(trigger, op) if op > 0 else trigger return (fill, "break_low") return None def _normalize_exit_mode(params: Dict[str, Any]) -> str: """classic=영상 원형 BB 청산, v4_scalp=단타·모멘텀 V4 어깨컷 체계.""" mode = str(params.get("exit_mode") or "classic").strip().lower() if mode in ("classic", "video", "video_pure", "bb", "original"): return "classic" if mode in ("v4", "v4_scalp", "scalp", "v4scalp"): return "v4_scalp" return "classic" def _check_sell_classic( position: Dict[str, Any], current_candle: Dict[str, Any], params: Dict[str, Any], *, inner_upper: Optional[float] = None, inner_lower: Optional[float] = None, ) -> Optional[Tuple[str, float]]: """ 영상 원형 청산 — 반대 2σ 밴드(또는 RR/%) 익절, 신호저점 손절만. 어깨컷·장마감·시간컷 없음 (다른 전략과 분리). """ side = str(position.get("side") or "long").strip().lower() hi = float(current_candle.get("high", current_candle["close"])) lo = float(current_candle.get("low", current_candle["close"])) ep = float(position["entry_price"]) stop = float(position["stop"]) target = float(position["target"]) if side == "long": tp_touch = target if str(params.get("tp_mode") or "").strip().lower() == "opposite_band": if inner_upper is not None and inner_upper > ep: tp_touch = float(inner_upper) if hi >= tp_touch: return ("익절", tp_touch) if lo <= stop: return ("손절", stop) else: tp_touch = target if str(params.get("tp_mode") or "").strip().lower() == "opposite_band": if inner_lower is not None and inner_lower < ep: tp_touch = float(inner_lower) if lo <= tp_touch: return ("익절", tp_touch) if hi >= stop: return ("손절", stop) return None def _check_sell_v4_scalp( position: Dict[str, Any], current_candle: Dict[str, Any], params: Dict[str, Any], *, inner_upper: Optional[float] = None, inner_lower: Optional[float] = None, is_eod: bool = False, ) -> Optional[Tuple[str, float]]: """ 단타 V4 청산 (모멘텀·스캘핑과 동일 계열) — exit_mode=v4_scalp 일 때만. """ side = str(position.get("side") or "long").strip().lower() shoulder_min_high = float(params.get("shoulder_min_high") or 0.003) shoulder_cut_pct = float(params.get("shoulder_cut_pct") or 0.002) trail_pct = abs(float(params.get("trail_pct") or 0.0)) trail_arm_pct = abs(float(params.get("trail_arm_pct") or 0.0)) max_hold = int(float(params.get("max_hold_bars") or 0)) hi = float(current_candle.get("high", current_candle["close"])) lo = float(current_candle.get("low", current_candle["close"])) cl = float(current_candle["close"]) candle_time = current_candle.get("candle_time", "") ep = float(position["entry_price"]) stop = float(position["stop"]) target = float(position["target"]) max_p = float(position.get("max_price") or ep) min_p = float(position.get("min_price") or ep) if side == "long": max_p = max(max_p, hi) min_p = min(min_p, lo) else: min_p = min(min_p, lo) max_p = max(max_p, hi) reason = None exit_price = cl trail_armed = False if side == "long": if max_p >= ep * (1.0 + shoulder_min_high): trail_armed = True trail_stop = max_p * (1.0 - shoulder_cut_pct) if lo <= trail_stop: reason = "어깨컷" exit_price = trail_stop if not reason: tp_touch = target if str(params.get("tp_mode") or "").strip().lower() == "opposite_band": if inner_upper is not None and inner_upper > ep: tp_touch = inner_upper if hi >= tp_touch: reason = "익절" exit_price = tp_touch if not reason and lo <= stop: reason = "손절" exit_price = stop if not reason and trail_pct > 0 and max_p > ep: arm_line = ep * (1.0 + trail_arm_pct) if max_p >= arm_line: tline = max_p * (1.0 - trail_pct) if lo <= tline: reason = "트레일컷" exit_price = tline else: if min_p <= ep * (1.0 - shoulder_min_high): trail_armed = True trail_stop = min_p * (1.0 + shoulder_cut_pct) if hi >= trail_stop: reason = "어깨컷" exit_price = trail_stop if not reason: tp_touch = target if str(params.get("tp_mode") or "").strip().lower() == "opposite_band": if inner_lower is not None and inner_lower < ep: tp_touch = inner_lower if lo <= tp_touch: reason = "익절" exit_price = tp_touch if not reason and hi >= stop: reason = "손절" exit_price = stop if not reason and trail_pct > 0 and min_p < ep: arm_line = ep * (1.0 - trail_arm_pct) if min_p <= arm_line: tline = min_p * (1.0 + trail_pct) if hi >= tline: reason = "트레일컷" exit_price = tline if not reason and max_hold > 0 and position.get("entry_time") and candle_time: try: held = int( (_t2dt(candle_time) - _t2dt(position["entry_time"])).total_seconds() // 60 ) tf = int(params.get("timeframe") or 15) bars_held = held // max(1, tf) if bars_held >= max_hold: reason = "시간컷" exit_price = cl except Exception: pass if not reason and is_eod and _to_bool(params.get("force_eod_exit"), True): reason = "장마감" exit_price = cl if reason: position["max_price"] = max_p if side == "long" else position.get("max_price", max_p) position["min_price"] = min_p if side == "short" else position.get("min_price", min_p) return (reason, exit_price) position["max_price"] = max_p position["min_price"] = min_p return None def check_sell_signal_live( position: Dict[str, Any], current_candle: Dict[str, Any], params: Dict[str, Any], *, inner_upper: Optional[float] = None, inner_lower: Optional[float] = None, is_eod: bool = False, ) -> Optional[Tuple[str, float]]: """ 실시간·백테 공통 청산 — exit_mode 에 따라 classic / v4_scalp 분기. position: entry_price, entry_time, stop, target, max_price, qty, side(long|short) """ if _normalize_exit_mode(params) == "classic": return _check_sell_classic( position, current_candle, params, inner_upper=inner_upper, inner_lower=inner_lower, ) return _check_sell_v4_scalp( position, current_candle, params, inner_upper=inner_upper, inner_lower=inner_lower, is_eod=is_eod, ) def check_buy_signal_live( candles: List[Dict], params: Dict[str, Any], state: Dict[str, Any], ) -> Tuple[Optional[str], Optional[str], Optional[Dict[str, Any]]]: """ 라이브 매수 신호 (LONG 기본, side_mode 에 따라 SHORT 신호도 동일 dict 로 side 태그). state: daily_cnt, last_exit_dt """ from kis_trader.utils.env import get_env_int min_need = get_env_int("DBBAND_LIVE_MIN_CANDLES", 210) confirmed = _confirmed_candles_only(candles) if len(confirmed) < min_need: return ("탈락-봉부족", f"확정봉 {len(confirmed)} < {min_need}", None) lookback = max(1, get_env_int("DBBAND_LIVE_SIGNAL_LOOKBACK_BARS", 5)) side_mode = str(params.get("side_mode") or "long_only").strip().lower() closes = [float(c["close"]) for c in confirmed] highs = [float(c["high"]) for c in confirmed] lows = [float(c["low"]) for c in confirmed] opens = [float(c["open"]) for c in confirmed] period = int(params.get("bb_period") or 20) inner_std = float(params.get("bb_inner_std") or 2.0) outer_std = float(params.get("bb_outer_std") or 3.0) trend_period = int(params.get("trend_ma_period") or 200) _, i_upper, i_lower = compute_bb_series(closes, period, inner_std) _, o_upper, o_lower = compute_bb_series(closes, period, outer_std) trend_ma = _sma_series(closes, trend_period) ent_i = len(confirmed) - 1 time_start = int(params.get("time_start_hm") or 930) time_end = int(params.get("time_end_hm") or 1500) hm = _hm_from_candle(confirmed[ent_i]["candle_time"]) if hm < time_start or hm > time_end: return (None, None, None) if int(state.get("daily_cnt") or 0) >= int(params.get("max_daily") or 3): return (None, None, None) last_exit = state.get("last_exit_dt") cooldown_min = float(params.get("cooldown_min") or 15.0) if last_exit is not None: elapsed = (_t2dt(confirmed[ent_i]["candle_time"]) - last_exit).total_seconds() / 60 if elapsed < cooldown_min: return (None, None, None) valid_bars = int(params.get("entry_valid_bars") or 3) for k in range(lookback): sig_i = ent_i - 1 - k if sig_i < max(period, trend_period): break sides_to_try: List[str] = [] if side_mode in ("long_only", "both"): sides_to_try.append("long") if side_mode in ("short_only", "both"): sides_to_try.append("short") for side in sides_to_try: if side == "long": if not _long_signal_bar( sig_i, lows, closes, i_lower, i_upper, o_lower, trend_ma, params, ): continue else: if not _short_signal_bar( sig_i, highs, closes, i_lower, i_upper, o_upper, trend_ma, params, ): continue for j in range(sig_i + 1, min(sig_i + 1 + valid_bars, ent_i + 1)): ent = _try_entry_on_bar( side, j, sig_i, confirmed, highs, lows, opens, params, ) if not ent: continue entry_px, ent_src = ent min_px = float(params.get("min_price") or 1000.0) if entry_px < min_px: return ("탈락-가격", f"진입가 {entry_px:,.0f} < 최소 {min_px:,.0f}", None) sig_lo = lows[sig_i] sig_hi = highs[sig_i] stop = _resolve_stop_price(side, entry_px, sig_lo, sig_hi, params) iu = i_upper[j] if j < len(i_upper) else i_upper[sig_i] il = i_lower[j] if j < len(i_lower) else i_lower[sig_i] target = _resolve_target_price(side, entry_px, stop, iu, il, params) return ( None, None, { "signal": True, "side": side, "entry_price": entry_px, "entry_bar_key": str(confirmed[j].get("candle_time", ""))[:12], "signal_candle_key": str(confirmed[sig_i].get("candle_time", ""))[:12], "stop_price": stop, "target_price": target, "entry_mode": ent_src, }, ) return ("탈락-비신호", "더블BB 신호·돌파 미충족", None) def run_dbband_backtest_single( candles: List[Dict], params: Dict[str, Any], ticks_by_minute: Optional[Dict[str, List[Dict]]] = None, ) -> List[Dict]: """단일 종목 시계열 백테스트. ticks_by_minute: candle_time[:12] → 틱 리스트 (선택). 있으면 틱 우선 청산. """ if len(candles) < 50: return [] closes = [float(c["close"]) for c in candles] highs = [float(c["high"]) for c in candles] lows = [float(c["low"]) for c in candles] opens = [float(c["open"]) for c in candles] times = [str(c["candle_time"]) for c in candles] period = int(params.get("bb_period") or 20) inner_std = float(params.get("bb_inner_std") or 2.0) outer_std = float(params.get("bb_outer_std") or 3.0) trend_period = int(params.get("trend_ma_period") or 200) side_mode = str(params.get("side_mode") or "long_only").strip().lower() valid_bars = int(params.get("entry_valid_bars") or 3) time_start = int(params.get("time_start_hm") or 930) time_end = int(params.get("time_end_hm") or 1500) _, i_upper, i_lower = compute_bb_series(closes, period, inner_std) _, o_upper, o_lower = compute_bb_series(closes, period, outer_std) trend_ma = _sma_series(closes, trend_period) min_i = max(period, trend_period) + 1 trades: List[Dict] = [] position: Optional[Dict[str, Any]] = None pending: Optional[Dict[str, Any]] = None daily_cnt: Dict[str, int] = {} last_exit_dt: Optional[datetime] = None cooldown_min = float(params.get("cooldown_min") or 15.0) from kis_trader.engine.tick_exit_common import ( backtest_sell_slip_pct, backtest_tick_poll_ms, resolve_backtest_sell, strategy_tick_fallback_ohlc, strategy_use_tick_exit, ) use_tick_exit = bool(ticks_by_minute) and strategy_use_tick_exit( params, "DBBAND_BACKTEST_USE_TICK_EXIT", default=True, ) tick_fallback_ohlc = strategy_tick_fallback_ohlc( params, "DBBAND_BACKTEST_TICK_FALLBACK_OHLC", default=False, ) tick_poll_ms = backtest_tick_poll_ms(params, strategy_env="DBBAND_BACKTEST_POLL_MS") tick_sell_slip = backtest_sell_slip_pct(params, strategy_env="DBBAND_BACKTEST_SELL_SLIP_PCT") def _day_key(t: str) -> str: return t[:8] def _is_eod_bar(i: int) -> bool: if i + 1 >= len(times): return True return _day_key(times[i]) != _day_key(times[i + 1]) from kis_trader.backtest.backtest_env_timeline import apply_env_timeline_at for i in range(min_i, len(candles)): if apply_env_timeline_at(params, times[i], "DBBAND"): period = int(params.get("bb_period") or 20) inner_std = float(params.get("bb_inner_std") or 2.0) outer_std = float(params.get("bb_outer_std") or 3.0) trend_period = int(params.get("trend_ma_period") or 200) side_mode = str(params.get("side_mode") or "long_only").strip().lower() valid_bars = int(params.get("entry_valid_bars") or 3) time_start = int(params.get("time_start_hm") or 930) time_end = int(params.get("time_end_hm") or 1500) cooldown_min = float(params.get("cooldown_min") or 15.0) c = candles[i] hm = _hm_from_candle(times[i]) day = _day_key(times[i]) if position is not None: pos = position iu = i_upper[i] il = i_lower[i] def _sell_fn(p_pos, candle, p_params, is_eod=False, _iu=iu, _il=il): return check_sell_signal_live( p_pos, candle, p_params, inner_upper=_iu, inner_lower=_il, is_eod=is_eod, ) if use_tick_exit: mk = str(times[i])[:12] minute_ticks = list((ticks_by_minute or {}).get(mk) or []) if minute_ticks: minute_ticks.sort(key=lambda x: str(x.get("tick_time") or "")) bar = dict(c) if "open" not in bar or bar.get("open") in (None, ""): bar["open"] = float(c.get("open") or c.get("close") or 0) res5 = resolve_backtest_sell( pos, bar, params, is_eod=_is_eod_bar(i), sell_fn=_sell_fn, low_mode="current", ticks=minute_ticks or None, use_tick_exit=use_tick_exit, tick_fallback_ohlc=tick_fallback_ohlc, poll_ms=tick_poll_ms, slip_pct=tick_sell_slip, ) if res5: reason, exit_px, sell_time, _hold, _src = res5 trades.append({ "entry_time": pos["entry_time"], "exit_time": sell_time or times[i], "entry": round(pos["entry_price"]), "exit": round(exit_px), "qty": pos.get("qty", 1), "side": pos.get("side", "long"), "reason": reason, "signal_time": pos.get("signal_time"), }) position = None last_exit_dt = _t2dt(sell_time or times[i]) continue sell = check_sell_signal_live( pos, c, params, inner_upper=iu, inner_lower=il, is_eod=_is_eod_bar(i), ) if sell: reason, exit_px = sell trades.append({ "entry_time": pos["entry_time"], "exit_time": times[i], "entry": round(pos["entry_price"]), "exit": round(exit_px), "qty": pos.get("qty", 1), "side": pos.get("side", "long"), "reason": reason, "signal_time": pos.get("signal_time"), }) position = None last_exit_dt = _t2dt(times[i]) continue if pending is not None: sig_i = pending["signal_i"] side = pending["side"] if i > sig_i + valid_bars: pending = None else: ent = _try_entry_on_bar(side, i, sig_i, candles, highs, lows, opens, params) if ent: entry_px, ent_src = ent sig_lo = lows[sig_i] sig_hi = highs[sig_i] stop = _resolve_stop_price(side, entry_px, sig_lo, sig_hi, params) target = _resolve_target_price( side, entry_px, stop, i_upper[i], i_lower[i], params, ) position = { "entry_price": entry_px, "entry_time": times[i], "signal_time": times[sig_i], "stop": stop, "target": target, "max_price": entry_px, "min_price": entry_px, "qty": 1, "side": side, } daily_cnt[day] = daily_cnt.get(day, 0) + 1 pending = None continue if hm < time_start or hm > time_end: continue if daily_cnt.get(day, 0) >= int(params.get("max_daily") or 3): continue if last_exit_dt is not None: elapsed = (_t2dt(times[i]) - last_exit_dt).total_seconds() / 60 if elapsed < cooldown_min: continue sig_i = i sides: List[str] = [] if side_mode in ("long_only", "both"): sides.append("long") if side_mode in ("short_only", "both"): sides.append("short") for side in sides: ok = False if side == "long": ok = _long_signal_bar( sig_i, lows, closes, i_lower, i_upper, o_lower, trend_ma, params, ) else: ok = _short_signal_bar( sig_i, highs, closes, i_lower, i_upper, o_upper, trend_ma, params, ) if not ok: continue ent_now = _try_entry_on_bar(side, i, sig_i, candles, highs, lows, opens, params) if ent_now: entry_px, _ = ent_now stop = _resolve_stop_price(side, entry_px, lows[sig_i], highs[sig_i], params) target = _resolve_target_price( side, entry_px, stop, i_upper[i], i_lower[i], params, ) position = { "entry_price": entry_px, "entry_time": times[i], "signal_time": times[sig_i], "stop": stop, "target": target, "max_price": entry_px, "min_price": entry_px, "qty": 1, "side": side, } daily_cnt[day] = daily_cnt.get(day, 0) + 1 else: pending = {"signal_i": sig_i, "side": side} break return trades def run_dbband_backtest_portfolio( candles_by_code: 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, ) -> List[Dict]: """ 종목별 독립 백테 후 합산 (포트폴리오 동시보유 제약은 1차 단순화). universe_by_slot 이 있으면 해당 슬롯 종목만. """ all_trades: List[Dict] = [] allowed: Optional[set] = None if universe_by_slot: allowed = set() for codes in universe_by_slot.values(): allowed.update(codes) for code, rows in candles_by_code.items(): if allowed is not None and code not in allowed: continue ticks_by_minute = None if ticks_by_code: ticks_by_minute = ticks_by_code.get(code) or None tr = run_dbband_backtest_single(rows, params, ticks_by_minute=ticks_by_minute) for t in tr: t["code"] = code all_trades.append(t) all_trades.sort(key=lambda x: (x.get("entry_time") or "", x.get("code") or "")) return all_trades