변경 사항 (Changes): 구문 오류(Syntax error) 및 토큰 낭비를 방지하기 위해 에이전트 쉘(Agent shell)과 파이썬 코드 스니펫에 다수의 신규 안전 규칙(Safety rules)을 추가함. 스키마 검증 및 적절한 SQL 포맷팅을 보장하기 위해 임시(Ad-hoc) 데이터베이스 쿼리 작성 가이드라인을 도입함. 코드 수정 후 UI 기능이 정상 작동하는지 확인하기 위해, 백테스트 웹 서비스 재시작 및 브라우저 검증에 대한 새로운 규칙을 구현함. 시스템 전반의 무결성(Integrity)을 유지하기 위해 실전 매매(Live trading), 웹 백테스팅, 파라미터 탐색(Parameter searches) 간의 일관성 검사(Consistency checks) 체계를 확립함. 기대 효과 (Impact): 이러한 개선 사항들은 트레이딩 시스템의 견고성(Robustness)과 신뢰성을 향상시키며, 에러 발생을 최소화하고 다양한 시스템 컴포넌트 간의 원활한 상호작용을 보장함.
973 lines
35 KiB
Python
973 lines
35 KiB
Python
#!/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
|