feat(tests): 신규 키움 웹소켓 조건검색 및 실시간 조건검색 테스트 추가

변경 사항
----
- _test_kiwoom_condition_list.py: 키움 웹소켓 조건검색 '목록조회' 기능을 단독으로 테스트하는 스크립트 추가
- _test_kiwoom_condition_realtime.py: 'momentum' 조건식을 실시간으로 등록하고 초기 매칭 종목 리스트 및 실시간 편입/이탈을 수신하는 테스트 스크립트 추가
- _verify_columnar_bitid.py, _verify_shared_e2e_breakout.py, _verify_shared_e2e.py: 공유 메모리 및 dict 간의 데이터 일관성을 검증하는 테스트 추가

영향
----
- 신규 테스트 스크립트 추가로 키움 웹소켓 API의 기능 검증 및 안정성을 높임
- 기존 기능에 대한 영향 없음

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-06 01:27:00 +09:00
parent d8ba01afa4
commit 61c72a8a4c
171 changed files with 176914 additions and 7329 deletions

View File

@@ -0,0 +1,887 @@
#!/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:
return datetime.strptime(str(candle_time)[:12], "%Y%m%d%H%M")
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) -> Dict[str, Any]:
"""env_config + config_dbband 병합 → 엔진 params dict."""
own_db = None
r: Dict[str, Any] = {}
try:
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],
) -> List[Dict]:
"""단일 종목 시계열 백테스트."""
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)
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])
for i in range(min_i, len(candles)):
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]
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,
) -> 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
tr = run_dbband_backtest_single(rows, params)
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