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:
887
kis_trader/engine/dbband_engine.py
Normal file
887
kis_trader/engine/dbband_engine.py
Normal 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
|
||||
Reference in New Issue
Block a user