Files
kis_bot/kis_trader/engine/dbband_engine.py
Your Name fc27e726f9 feat: 새로운 안전 규칙 및 최적화 적용을 통한 트레이딩 시스템 개선
변경 사항 (Changes):

구문 오류(Syntax error) 및 토큰 낭비를 방지하기 위해 에이전트 쉘(Agent shell)과 파이썬 코드 스니펫에 다수의 신규 안전 규칙(Safety rules)을 추가함.

스키마 검증 및 적절한 SQL 포맷팅을 보장하기 위해 임시(Ad-hoc) 데이터베이스 쿼리 작성 가이드라인을 도입함.

코드 수정 후 UI 기능이 정상 작동하는지 확인하기 위해, 백테스트 웹 서비스 재시작 및 브라우저 검증에 대한 새로운 규칙을 구현함.

시스템 전반의 무결성(Integrity)을 유지하기 위해 실전 매매(Live trading), 웹 백테스팅, 파라미터 탐색(Parameter searches) 간의 일관성 검사(Consistency checks) 체계를 확립함.

기대 효과 (Impact):

이러한 개선 사항들은 트레이딩 시스템의 견고성(Robustness)과 신뢰성을 향상시키며, 에러 발생을 최소화하고 다양한 시스템 컴포넌트 간의 원활한 상호작용을 보장함.
2026-07-17 01:09:09 +09:00

973 lines
35 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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