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,958 @@
"""
kis_trader/engine/updown_box.py — 박스권(횡보) 판별 엔진 (UPDOWN 전용)
================================================================================
[목적]
"이 종목이 지금 박스권(횡보)인가?" 를 분봉(기본 15분)으로 판정하고, 박스의
하단/상단/높이/품질점수를 산출한다. SCAN(watchlist 충전)·TRIGGER(하단 되돌림 진입)
양쪽이 공유하는 **단일 소스** — 백테=실매 동일 로직.
[박스권이란? — 투자 용어]
- 박스권(Box / Range, 횡보): 가격이 일정 범위(상단·하단) 안에서 오르내리며
뚜렷한 추세 없이 갇혀 있는 구간. "지지선(하단)에서 사고 저항선(상단)에서 판다"
박스 매매의 기본. 우리 전략은 **하단 되돌림 매수(dip-buy)** 에 쓴다.
[판별 3축 — 모두 만족해야 박스]
1) 좁은 범위 : (box_high - box_low) / box_low × 100 ≤ ``UPDOWN_BOX_RANGE_MAX_PCT``
→ 박스가 너무 넓으면(변동성 큼) 박스권이 아니다.
2) 밴드 스퀴즈 : 볼린저밴드 폭(BandWidth) ≤ ``UPDOWN_BOX_BB_BW_MAX``
· BandWidth(%) = (상단밴드 - 하단밴드) / 중심선(MA) × 100
· 변동성이 수축(squeeze)하면 횡보 신호.
3) 추세 없음 : |MA 기울기| ≤ ``UPDOWN_BOX_MA_SLOPE_TOL_PCT``
· 기울기(%) = (MA_now - MA_prev) / MA_prev × 100
· 0 에 가까울수록 추세가 없다(횡보).
[하드코딩 금지]
- 모든 임계값은 ``get_env_*`` 로 DB/Env 로드 (변수명 대문자 스네이크).
"""
from __future__ import annotations
import logging
from typing import Any, Dict, List, Optional, Tuple
from ..utils.env import get_env_float, get_env_from_db, get_env_int
logger = logging.getLogger("kis_trader.updown_box")
# ----------------------------------------------------------------------
# env 파라미터 (그리드·실매 공통 — 종목 행 없으면 글로벌)
# ----------------------------------------------------------------------
def get_box_cfg_from_env() -> Dict[str, Any]:
"""박스 판별/진입 파라미터 글로벌 기본값 (env_config ``UPDOWN_BOX_*``).
파라서치 그리드 끝값·실매 기본 모두 이 함수를 단일 소스로 쓴다.
※ ``ratchet_tiers`` 는 문자열(다단 래칫), 그 외는 숫자.
"""
return {
# 박스 정의에 쓸 분봉 개수 (lookback)
"lookback_bars": float(get_env_int("UPDOWN_BOX_LOOKBACK_BARS", 20)),
# 박스 최대 높이(%) — 좁을수록 타이트한 박스
"range_max_pct": get_env_float("UPDOWN_BOX_RANGE_MAX_PCT", 8.0),
# 볼린저밴드
"bb_period": float(get_env_int("UPDOWN_BOX_BB_PERIOD", 20)),
"bb_std": get_env_float("UPDOWN_BOX_BB_STD", 2.0),
"bb_bw_max": get_env_float("UPDOWN_BOX_BB_BW_MAX", 12.0),
# 이동평균 기울기
"ma_period": float(get_env_int("UPDOWN_BOX_MA_PERIOD", 20)),
"ma_slope_tol_pct": get_env_float("UPDOWN_BOX_MA_SLOPE_TOL_PCT", 0.5),
# 진입: 박스 하단에서 이 % 이내로 내려와야 (싸게 줍기)
"near_low_pct": get_env_float("UPDOWN_BOX_NEAR_LOW_PCT", 2.0),
# 진입: 직전봉 음봉(하락 바디) 요구 여부 (1=요구). 되돌림에서 줍기.
"require_down_bar": get_env_float("UPDOWN_BOX_REQUIRE_DOWN_BAR", 1.0),
# 진입: 직전봉 최소 음봉 몸통 하락률(%) (0=무시)
"body_drop_min_pct": get_env_float("UPDOWN_BOX_BODY_DROP_MIN_PCT", 0.2),
# 판별 최소 봉 수 (이보다 적으면 판별 불가)
"min_bars": float(get_env_int("UPDOWN_BOX_MIN_BARS", 20)),
# ── 청산 스택 ────────────────────────────────────────
# 박스 하단 이탈 손절 여유(%) — 노이즈 이탈 방지 (price < low×(1-buf/100))
"breach_buffer_pct": get_env_float("UPDOWN_BOX_BREACH_BUFFER_PCT", 0.3),
# 패닉 손절: 보유 중 고점 대비 이 %p 급락하면 무조건 컷 (진입가 무관)
"panic_from_high_pct": get_env_float("UPDOWN_PANIC_FROM_HIGH_PCT", 3.0),
# 하드 손절(%) — 진입가 대비. (박스/패닉이 안 걸릴 때 최종 방어)
"stop_loss_pct": get_env_float("UPDOWN_BOX_STOP_LOSS_PCT", 2.5),
# 익절(%) — 진입가 대비 (박스 상단 도달 기대치 근방)
"tp_pct": get_env_float("UPDOWN_BOX_TP_PCT", 3.0),
# 돌파 추종(breakout follow): ON(1)이면 보유 고점이 박스 상단을 위로 돌파한 뒤에는
# 익절(tp) 청산을 끄고 래칫(트레일)만으로 추종 → 추세를 끝까지 따라간다.
# 횡보 중엔 평소대로 tp로 짧게, '확실히 오르는' 돌파 종목은 덜 먹어도 안전하게 추종.
# (손절/패닉/박스이탈은 돌파 후에도 그대로 유지)
"breakout_follow": float(get_env_int("UPDOWN_BOX_BREAKOUT_FOLLOW", 0)),
# 래칫(어깨컷): 고점이 진입가 대비 +X% 도달 시 무장(arm)
"shoulder_min_high_pct": get_env_float("UPDOWN_BOX_SHOULDER_MIN_HIGH_PCT", 1.0),
# 래칫(어깨컷): 무장 후 고점 대비 -Y% 하락 시 컷
"shoulder_cut_pct": get_env_float("UPDOWN_BOX_SHOULDER_CUT_PCT", 1.0),
# 다단 래칫 — TAIL·BREAKOUT 과 동일 형식 "수익%:컷%,…" (예 "2:1.5,5:1.0").
# 값이 있으면 단일 어깨컷 대신 다단 트레일 사용. 빈 값="" → OFF(단일 어깨컷).
"ratchet_tiers": str(get_env_from_db("UPDOWN_BOX_RATCHET_TIERS", "") or ""),
# 보유 한도(봉) — 이만큼 지나면 무조건 청산
"max_hold_bars": float(get_env_int("UPDOWN_BOX_MAX_HOLD_BARS", 64)),
}
def parse_box_ratchet_tiers(cfg: Optional[Dict[str, Any]] = None) -> List[Tuple[float, float]]:
"""다단 래칫 문자열 → [(gain비율, cut비율), …] (TAIL ``_tail_ratchet_tiers`` 와 동일).
"2:1.5,5:1.0" → [(0.02, 0.015), (0.05, 0.01)] = +2%부터 고점대비 1.5%컷, +5%부터 1.0%컷.
비어 있으면 [] (= OFF → 단일 어깨컷 사용).
"""
raw = (cfg or {}).get("ratchet_tiers")
if raw is None:
raw = get_env_from_db("UPDOWN_BOX_RATCHET_TIERS", "")
if isinstance(raw, (list, tuple)):
pairs = list(raw)
else:
s = str(raw or "").strip()
if not s:
return []
pairs = []
for chunk in s.split(","):
chunk = chunk.strip()
if not chunk or ":" not in chunk:
continue
g, c = chunk.split(":", 1)
pairs.append((g, c))
tiers: List[Tuple[float, float]] = []
for g, c in pairs:
try:
gain = abs(float(g)) / 100.0
cut = abs(float(c)) / 100.0
except (TypeError, ValueError):
continue
if gain <= 0 or cut <= 0:
continue
tiers.append((gain, cut))
tiers.sort(key=lambda x: x[0])
return tiers
# ----------------------------------------------------------------------
# 기초 계산기 (순수 함수)
# ----------------------------------------------------------------------
def _sma(values: List[float], period: int) -> Optional[float]:
"""단순이동평균(SMA) — 최근 period 개. 부족하면 None."""
if period <= 0 or len(values) < period:
return None
window = values[-period:]
return sum(window) / float(period)
def _stddev(values: List[float], period: int) -> Optional[float]:
"""표준편차(모집단) — 볼린저밴드용. 최근 period 개."""
if period <= 0 or len(values) < period:
return None
window = values[-period:]
m = sum(window) / float(period)
var = sum((v - m) ** 2 for v in window) / float(period)
return var ** 0.5
def bollinger_bandwidth(closes: List[float], period: int, std_mult: float) -> Optional[float]:
"""볼린저밴드 폭(BandWidth, %) = (상단-하단)/중심선 × 100.
값이 작을수록 변동성 수축(스퀴즈)=횡보 가능성. None 이면 데이터 부족.
"""
ma = _sma(closes, period)
sd = _stddev(closes, period)
if ma is None or sd is None or ma <= 0:
return None
upper = ma + std_mult * sd
lower = ma - std_mult * sd
return (upper - lower) / ma * 100.0
def ma_slope_pct(closes: List[float], period: int) -> Optional[float]:
"""MA 기울기(%) = (MA_now - MA_prev) / MA_prev × 100.
직전 봉 기준 MA 와 현재 MA 를 비교. 0 에 가까우면 추세 없음(횡보).
None 이면 데이터 부족.
"""
if len(closes) < period + 1:
return None
ma_now = _sma(closes, period)
ma_prev = _sma(closes[:-1], period)
if ma_now is None or ma_prev is None or ma_prev <= 0:
return None
return (ma_now - ma_prev) / ma_prev * 100.0
def compute_box(candles: List[Dict[str, Any]], lookback: int) -> Optional[Dict[str, float]]:
"""최근 lookback 봉의 박스 경계 산출.
box_low = 최근 lookback 봉 중 최저가(저가의 최소)
box_high = 최근 lookback 봉 중 최고가(고가의 최대)
Returns: {box_low, box_high, box_mid, range_pct} 또는 None(데이터 부족).
"""
if lookback <= 0 or len(candles) < lookback:
return None
window = candles[-lookback:]
lows = [float(c.get("low", c.get("close", 0)) or 0) for c in window]
highs = [float(c.get("high", c.get("close", 0)) or 0) for c in window]
lows = [v for v in lows if v > 0]
highs = [v for v in highs if v > 0]
if not lows or not highs:
return None
box_low = min(lows)
box_high = max(highs)
if box_low <= 0 or box_high <= box_low:
return None
box_mid = (box_low + box_high) / 2.0
range_pct = (box_high - box_low) / box_low * 100.0
return {
"box_low": box_low,
"box_high": box_high,
"box_mid": box_mid,
"range_pct": range_pct,
}
# ----------------------------------------------------------------------
# 박스 판별 (SCAN/TRIGGER 공용)
# ----------------------------------------------------------------------
def evaluate_box(
candles: List[Dict[str, Any]],
cfg: Optional[Dict[str, float]] = None,
) -> Dict[str, Any]:
"""종목이 박스권인지 판정 + 박스 정보·품질점수 반환.
Returns dict:
is_box : bool — 3축(범위·스퀴즈·기울기) 모두 만족
box_low/high/mid/range_pct
bb_bw : 볼린저 밴드폭(%) (None 가능)
ma_slope : MA 기울기(%) (None 가능)
box_score : 품질점수(높을수록 좋은 박스 — eviction 우선순위·정렬용)
reason : 탈락 사유(디버그)
"""
c = cfg or get_box_cfg_from_env()
out: Dict[str, Any] = {
"is_box": False, "box_low": 0.0, "box_high": 0.0, "box_mid": 0.0,
"range_pct": 0.0, "bb_bw": None, "ma_slope": None, "box_score": 0.0,
"reason": "",
}
lookback = int(c.get("lookback_bars", 20))
min_bars = int(c.get("min_bars", 20))
if len(candles) < max(min_bars, lookback):
out["reason"] = f"봉부족({len(candles)}<{max(min_bars, lookback)})"
return out
box = compute_box(candles, lookback)
if not box:
out["reason"] = "박스경계 산출불가"
return out
out.update(box)
closes = [float(x.get("close", 0) or 0) for x in candles]
closes = [v for v in closes if v > 0]
bb_period = int(c.get("bb_period", 20))
bb_std = float(c.get("bb_std", 2.0))
bb_bw = bollinger_bandwidth(closes, bb_period, bb_std)
out["bb_bw"] = bb_bw
ma_period = int(c.get("ma_period", 20))
slope = ma_slope_pct(closes, ma_period)
out["ma_slope"] = slope
# ── 3축 판별 ───────────────────────────────────────────────
range_max = float(c.get("range_max_pct", 8.0))
bw_max = float(c.get("bb_bw_max", 12.0))
slope_tol = float(c.get("ma_slope_tol_pct", 0.5))
if box["range_pct"] > range_max:
out["reason"] = f"범위과대({box['range_pct']:.1f}>{range_max:.1f}%)"
return out
if bb_bw is not None and bb_bw > bw_max:
out["reason"] = f"밴드폭과대({bb_bw:.1f}>{bw_max:.1f}%)"
return out
if slope is not None and abs(slope) > slope_tol:
out["reason"] = f"추세존재(|{slope:.2f}|>{slope_tol:.2f}%)"
return out
out["is_box"] = True
# 품질점수: 박스가 좁고(range_pct↓)·스퀴즈 강하고(bb_bw↓)·평평할수록(|slope|↓) 高.
# 정규화 단순화 — 작을수록 좋은 3지표의 역수 합 (eviction/정렬 우선순위용).
rp = max(0.1, box["range_pct"])
bw = max(0.1, bb_bw if bb_bw is not None else range_max)
sl = max(0.01, abs(slope) if slope is not None else slope_tol)
out["box_score"] = round((1.0 / rp) * 50 + (1.0 / bw) * 30 + (1.0 / sl) * 5, 3)
return out
def near_box_low(price: float, box_low: float, box_high: float, near_pct: float) -> bool:
"""현재가가 박스 하단에서 near_pct% 이내인가 (싸게 줍는 진입 구역).
near = box_low × (1 + near_pct/100). price ≤ near 이면 True.
하단 이탈(price < box_low)도 '이내'로 본다(진입 판정은 호출부에서 음봉 등과 조합).
"""
if box_low <= 0 or box_high <= box_low or price <= 0:
return False
threshold = box_low * (1.0 + max(0.0, near_pct) / 100.0)
return price <= threshold
def box_low_breached(price: float, box_low: float, buffer_pct: float = 0.0) -> bool:
"""박스 하단 이탈 여부 (구조적 손절 트리거).
price < box_low × (1 - buffer_pct/100) 이면 박스가 깨진 것 → 손절 신호.
buffer_pct 는 노이즈 이탈 방지용 여유(기본 0).
"""
if box_low <= 0 or price <= 0:
return False
floor_px = box_low * (1.0 - max(0.0, buffer_pct) / 100.0)
return price < floor_px
# ----------------------------------------------------------------------
# 진입 신호 (SCAN 통과 후 TRIGGER — 박스 하단 되돌림 매수)
# ----------------------------------------------------------------------
def box_entry_signal(
candles: List[Dict[str, Any]],
cfg: Optional[Dict[str, float]] = None,
) -> Tuple[bool, str, Dict[str, Any]]:
"""박스 하단 되돌림 매수 신호.
조건(모두 만족):
1) 박스권일 것 (``evaluate_box.is_box``)
2) 직전 확정봉(candles[-2]) 종가가 박스 하단 near_low_pct 이내 (싸게)
3) (옵션) 직전봉이 음봉이고 몸통 하락률 ≥ body_drop_min_pct (되돌림)
진입가는 최신 확정봉(candles[-1]) 시가 — 백테 '다음봉 시가' 와 동일.
Returns: (ok, reason, info) — info 에 box_low/high, entry_ref 등.
"""
c = cfg or get_box_cfg_from_env()
info: Dict[str, Any] = {}
if len(candles) < 2:
return (False, "봉부족(<2)", info)
box = evaluate_box(candles[:-1], c) # 신호 시점까지의 확정봉으로 박스 판정
info.update(box)
if not box.get("is_box"):
return (False, f"비박스:{box.get('reason','')}", info)
box_low = float(box["box_low"])
box_high = float(box["box_high"])
sig_bar = candles[-2] # 신호봉 (직전 확정봉)
ent_bar = candles[-1] # 진입봉 (최신 확정봉) — 시가 진입
sig_close = float(sig_bar.get("close", 0) or 0)
sig_open = float(sig_bar.get("open", sig_close) or sig_close)
ent_open = float(ent_bar.get("open", ent_bar.get("close", 0)) or 0)
info["entry_ref"] = ent_open
near_pct = float(c.get("near_low_pct", 2.0))
if not near_box_low(sig_close, box_low, box_high, near_pct):
return (False, f"하단미근접(c={sig_close:.0f}/low={box_low:.0f})", info)
require_down = float(c.get("require_down_bar", 1.0)) >= 0.5
if require_down:
if not (sig_close < sig_open and sig_open > 0):
return (False, "직전봉 음봉아님", info)
body_min = float(c.get("body_drop_min_pct", 0.0))
if body_min > 0:
drop_pct = (sig_open - sig_close) / sig_open * 100.0
if drop_pct < body_min:
return (False, f"음봉약함({drop_pct:.2f}<{body_min:.2f}%)", info)
if ent_open <= 0:
return (False, "진입봉 시가 0", info)
return (True, "박스하단되돌림", info)
# ----------------------------------------------------------------------
# 청산 스택 (백테=실매 공용 — 단일 가격레벨 평가기)
# ----------------------------------------------------------------------
def eval_box_exit_at_price(
avg: float,
max_price: float,
px: float,
*,
box_low: float,
breach_buffer_pct: float,
panic_from_high_pct: float,
sl_eff: float,
tp_eff: float,
shoulder_min_high: float,
shoulder_cut_pct: float,
ratchet_tiers: Optional[List[Tuple[float, float]]] = None,
box_high: float = 0.0,
breakout_follow: bool = False,
) -> Tuple[Optional[Tuple[str, float]], float]:
"""UPDOWN 박스 청산 — 단일 가격레벨 우선순위 평가 (백테 intrabar = 실매 틱 동일).
우선순위:
1) 박스 하단 이탈 (구조 붕괴 → 즉시 손절)
2) 패닉(고점 대비 -X%) (무조건 긴급컷, 진입가 무관)
3) 래칫 트레일 (tiers 있으면 다단 래칫컷 / 없으면 단일 어깨컷)
4) 익절 (+tp_eff) ← **돌파 추종 ON + 박스 상단 돌파 시 이 단계만 스킵**
5) 하드 손절 (-sl_eff)
돌파 추종(breakout_follow): 보유 고점(max_p)이 box_high 를 위로 넘어선 뒤에는 익절(4)을
끄고 래칫(3)이 천장 역할 → 추세를 끝까지 따라간다. 손절·패닉·박스이탈(1,2,5)은 유지.
Args:
sl_eff, tp_eff, shoulder_* : 소수 비율(0.025 = 2.5%)
ratchet_tiers : [(gain비율, cut비율), …] (TAIL/BREAKOUT 동일). 있으면 단일 어깨 대체.
box_high : 박스 상단가(0이면 돌파판정 불가 → tp 정상 작동)
breakout_follow : True 면 박스 상단 돌파 후 tp 해제(래칫 추종)
Returns: ((reason, exit_px) 또는 None, 갱신된 max_price)
"""
if avg <= 0 or px <= 0:
return None, max_price
max_p = max(float(max_price or avg), px)
# 박스 상단 돌파 여부 — 보유 중 고점이 박스 천장을 위로 넘었으면 '추세 전환'으로 본다.
broke_out = bool(breakout_follow) and box_high > 0 and max_p > box_high
# 1순위: 박스 하단 이탈 (구조적)
if box_low > 0 and box_low_breached(px, box_low, breach_buffer_pct):
return ("박스이탈", float(px)), max_p
# 2순위: 패닉 — 보유 고점 대비 급락 (진입가 무관, 무조건)
if panic_from_high_pct > 0 and max_p > 0:
panic_floor = max_p * (1.0 - panic_from_high_pct / 100.0)
if px <= panic_floor:
return (f"패닉(고점-{panic_from_high_pct:.1f}%)", float(px)), max_p
# 3순위: 래칫 트레일 — TAIL/BREAKOUT 과 동일하게 tiers 있으면 다단 래칫(단일 어깨 대체)
if ratchet_tiers:
peak_gain = (max_p - avg) / avg
cut_ratio = 0.0
for gain_thr, cut in ratchet_tiers:
if peak_gain >= gain_thr:
cut_ratio = cut # 더 높은 수익 단계일수록 타이트한 컷으로 갱신
if cut_ratio > 0.0:
ratchet_line = max_p * (1.0 - cut_ratio)
if px <= ratchet_line:
return ("래칫컷", float(ratchet_line)), max_p
else:
# 단일 어깨컷 (이익 무장 후 트레일)
trail_armed = max_p >= avg * (1.0 + shoulder_min_high)
if trail_armed:
trail_stop_px = max_p * (1.0 - shoulder_cut_pct)
if px <= trail_stop_px:
return ("어깨컷", float(trail_stop_px)), max_p
pnl_pct = (px - avg) / avg
# 4순위: 익절 — 단, 박스 상단 돌파(추종 ON) 상태면 tp 를 끄고 래칫(3)이 천장 역할.
if pnl_pct >= tp_eff and not broke_out:
return (f"익절(+{pnl_pct * 100:.2f}/{tp_eff * 100:.2f}%)", float(px)), max_p
# 5순위: 하드 손절
if pnl_pct <= -sl_eff:
return (f"손절({pnl_pct * 100:.2f}/{sl_eff * 100:.2f}%)", float(px)), max_p
return None, max_p
# ----------------------------------------------------------------------
# 백테스트 러너 (실매와 동일 진입/청산 로직 공유)
# ----------------------------------------------------------------------
def run_backtest_box(
candles: List[Dict[str, Any]],
cfg: Optional[Dict[str, float]] = None,
fee_rate: float = 0.015 / 100,
sell_tax: float = 0.18 / 100,
) -> Dict[str, Any]:
"""분봉 리스트(시간 오름차순)로 박스 전략 백테스트.
진입: ``box_entry_signal`` (박스+하단근접+직전음봉), 진입가=해당봉 시가.
청산: ``eval_box_exit_at_price`` (박스이탈→패닉→어깨컷→익절→손절) + 보유한도.
봉내 체결: ``_intrabar_exit_prices`` 로 실매 틱과 동일하게 근사.
Returns: 거래목록·요약(PF/승률/총손익) dict.
"""
from .scalping_engine import _intrabar_exit_prices
c = cfg or get_box_cfg_from_env()
n = len(candles)
trades: List[Dict[str, Any]] = []
if n < int(c.get("min_bars", 20)) + 2:
return {"trades": [], "n_trades": 0, "pf": 0.0, "win_rate": 0.0, "total_pnl": 0.0}
sl_eff = max(0.0001, float(c.get("stop_loss_pct", 2.5)) / 100.0)
tp_eff = max(0.0001, float(c.get("tp_pct", 3.0)) / 100.0)
smh = max(0.0, float(c.get("shoulder_min_high_pct", 1.0)) / 100.0)
scut = max(0.0, float(c.get("shoulder_cut_pct", 1.0)) / 100.0)
panic = max(0.0, float(c.get("panic_from_high_pct", 3.0)))
breach_buf = max(0.0, float(c.get("breach_buffer_pct", 0.3)))
max_hold = int(float(c.get("max_hold_bars", 64)))
tiers = parse_box_ratchet_tiers(c) # 다단 래칫(있으면 단일 어깨 대체)
bo_follow = bool(float(c.get("breakout_follow", 0) or 0)) # 박스 상단 돌파 추종
n_checks = max(1, get_env_int("BACKTEST_EXIT_CHECKS_PER_BAR", 6))
pos: Optional[Dict[str, Any]] = None
lookback = int(c.get("lookback_bars", 20))
i = lookback
while i < n:
bar = candles[i]
o = float(bar.get("open", bar.get("close", 0)) or 0)
h = float(bar.get("high", bar.get("close", 0)) or 0)
l = float(bar.get("low", bar.get("close", 0)) or 0)
cl = float(bar.get("close", 0) or 0)
if cl <= 0:
i += 1
continue
if pos is None:
# 진입 판정: 신호봉=i-1, 진입봉=i (candles[:i+1] 의 마지막이 진입봉)
ok, _reason, info = box_entry_signal(candles[: i + 1], c)
if ok and o > 0:
pos = {
"entry_i": i, "avg": o, "max_price": o,
"box_low": float(info.get("box_low", 0) or 0),
"box_high": float(info.get("box_high", 0) or 0),
}
i += 1
continue
# 보유 중 — 봉내 체결 우선순위 평가
avg = float(pos["avg"])
max_p = max(float(pos["max_price"]), h)
box_low = float(pos["box_low"])
box_high = float(pos.get("box_high", 0) or 0)
prices = [cl] if n_checks <= 1 else _intrabar_exit_prices(o, h, l, cl, n_checks)
exit_res: Optional[Tuple[str, float]] = None
for px in prices:
res, max_p = eval_box_exit_at_price(
avg, max_p, float(px),
box_low=box_low, breach_buffer_pct=breach_buf,
panic_from_high_pct=panic, sl_eff=sl_eff, tp_eff=tp_eff,
shoulder_min_high=smh, shoulder_cut_pct=scut,
ratchet_tiers=tiers, box_high=box_high, breakout_follow=bo_follow,
)
if res:
exit_res = res
break
pos["max_price"] = max_p
bars_held = i - int(pos["entry_i"])
if exit_res is None and bars_held >= max_hold and bars_held > 0:
exit_res = (f"보유한도({max_hold}봉)", cl)
if exit_res:
reason, exit_px = exit_res
buy_cost = avg * (1.0 + fee_rate)
sell_rev = exit_px * (1.0 - fee_rate - sell_tax)
pnl_pct = (sell_rev - buy_cost) / buy_cost * 100.0
ent_bar = candles[int(pos["entry_i"])]
trades.append({
"entry_i": int(pos["entry_i"]), "exit_i": i,
"entry_time": str(ent_bar.get("candle_time") or ent_bar.get("candle_time_str") or ""),
"exit_time": str(bar.get("candle_time") or bar.get("candle_time_str") or ""),
"buy": avg, "sell": exit_px, "reason": reason,
"pnl_pct": round(pnl_pct, 4),
"bars_held": bars_held,
})
pos = None
i += 1
wins = [t for t in trades if t["pnl_pct"] > 0]
losses = [t for t in trades if t["pnl_pct"] <= 0]
gross_win = sum(t["pnl_pct"] for t in wins)
gross_loss = abs(sum(t["pnl_pct"] for t in losses))
pf = (gross_win / gross_loss) if gross_loss > 0 else (gross_win if gross_win > 0 else 0.0)
total_pnl = sum(t["pnl_pct"] for t in trades)
# === B&H(단순보유) 벤치마크 ===
# "그냥 처음부터 끝까지 들고만 있었으면?" — 봇이 첫 매수 판정을 시작하는 봉(lookback)의
# 시가에 1주 사서 마지막 봉 종가에 판 수익률. 거래와 동일한 수수료·세금을 적용해 공정 비교.
# 횡보(박스권) 종목은 B&H가 0% 근처일 것이므로, 봇의 total_pnl 이 이를 얼마나 초과(alpha)
# 했는지가 전략의 실효 판단 기준이 된다.
buy_hold_pct = 0.0
if n > lookback:
bh_buy = float(candles[lookback].get("open", candles[lookback].get("close", 0)) or 0)
bh_sell = float(candles[-1].get("close", 0) or 0)
if bh_buy > 0 and bh_sell > 0:
bh_cost = bh_buy * (1.0 + fee_rate)
bh_rev = bh_sell * (1.0 - fee_rate - sell_tax)
buy_hold_pct = (bh_rev - bh_cost) / bh_cost * 100.0
return {
"trades": trades,
"n_trades": len(trades),
"pf": round(pf, 3),
"win_rate": round(len(wins) / len(trades) * 100.0, 2) if trades else 0.0,
"total_pnl": round(total_pnl, 4),
"buy_hold_pct": round(buy_hold_pct, 4), # 단순보유 수익률(같은 구간·수수료)
"alpha_pct": round(total_pnl - buy_hold_pct, 4), # 봇 초과수익(전략 실효)
}
# ----------------------------------------------------------------------
# 라이브 신호 (백테=실매 동일 — updow_buy.check_*_live 와 동일 시그니처 패턴)
# ----------------------------------------------------------------------
def _norm_candle_time_key(c: Dict[str, Any]) -> str:
ct = c.get("candle_time") or c.get("candle_time_str", "")
if isinstance(ct, str) and len(ct) == 19 and " " in ct:
ct = ct.replace("-", "").replace(" ", "").replace(":", "")[:12]
return str(ct or "").strip()[:12]
def check_buy_signal_box_live(
candles: List[Dict[str, Any]],
cfg: Optional[Dict[str, float]] = None,
*,
last_fired_entry_key: Optional[str] = None,
) -> Tuple[Optional[str], str, Optional[Dict[str, Any]]]:
"""라이브 매수 — ``box_entry_signal`` 과 동일 (백테 ``run_backtest_box`` 정합).
Returns: (reject_code, message, signal_or_none)
"""
c = cfg or get_box_cfg_from_env()
min_need = max(2, int(c.get("min_bars", 20)))
if len(candles) < min_need:
return ("탈락-봉부족", f"확정봉 {len(candles)}개 (최소 {min_need})", None)
if len(candles) < 2:
return ("탈락-봉부족", "신호·진입 봉 2개 이상 필요", None)
ent_bar = candles[-1]
ent_key = _norm_candle_time_key(ent_bar)
if not ent_key:
return ("탈락-봉시각", "진입봉 candle_time 비어 있음", None)
if last_fired_entry_key and last_fired_entry_key == ent_key:
return ("탈락-중복진입봉", f"이번 진입봉({ent_key})에 이미 주문 시도함", None)
ok, reason, info = box_entry_signal(candles, c)
if not ok:
return ("탈락-비신호", reason, None)
entry_open = float(info.get("entry_ref", 0) or ent_bar.get("open", 0) or 0)
if entry_open <= 0:
return ("탈락-시가없음", "진입봉 시가 0", None)
sl_pct = float(c.get("stop_loss_pct", 2.5)) / 100.0
tp_pct = float(c.get("tp_pct", 3.0)) / 100.0
box_low = float(info.get("box_low", 0) or 0)
sig = {
"entry_price": entry_open,
"updow_entry_bar_key": ent_key,
"signal_candle_key": _norm_candle_time_key(candles[-2]),
"stop_price": entry_open * (1.0 - sl_pct),
"target_price": entry_open * (1.0 + tp_pct),
"sl_pct": sl_pct * 100.0,
"tp_pct": tp_pct * 100.0,
"box_low": box_low,
"box_high": float(info.get("box_high", 0) or 0),
"box_score": float(info.get("box_score", 0) or 0),
"atr_entry": 0.0,
}
return (None, f"박스하단되돌림 → 시가 {entry_open:.0f} (low={box_low:.0f})", sig)
def check_sell_signal_box_live(
*,
buy_price: float,
candles: List[Dict[str, Any]],
cfg: Optional[Dict[str, float]] = None,
entry_bar_key: str,
buy_time_str: str,
current_price: float,
max_price: float = 0.0,
box_low: float = 0.0,
box_high: float = 0.0,
) -> Optional[Tuple[str, float]]:
"""라이브 매도 — ``eval_box_exit_at_price`` (박스이탈→패닉→어깨→익절→손절) + 보유한도.
``box_low``/``box_high`` 는 진입 시점 박스 경계( watchlist / entry_features 저장값).
``box_high`` + cfg.breakout_follow 로 박스 상단 돌파 추종(익절 해제→래칫 추종) 판정.
"""
from ..strategies.updow_buy import estimate_updow_entry_bar_index
if buy_price <= 0 or not candles:
return None
c = cfg or get_box_cfg_from_env()
sl_eff = max(0.0001, float(c.get("stop_loss_pct", 2.5)) / 100.0)
tp_eff = max(0.0001, float(c.get("tp_pct", 3.0)) / 100.0)
smh = max(0.0, float(c.get("shoulder_min_high_pct", 1.0)) / 100.0)
scut = max(0.0, float(c.get("shoulder_cut_pct", 1.0)) / 100.0)
panic = max(0.0, float(c.get("panic_from_high_pct", 3.0)))
breach_buf = max(0.0, float(c.get("breach_buffer_pct", 0.3)))
max_hold = max(1, int(float(c.get("max_hold_bars", 64))))
last = candles[-1]
try:
c_now = float(last.get("close", 0) or 0)
h_now = float(last.get("high", c_now) or c_now)
except (TypeError, ValueError):
return None
if c_now <= 0:
return None
px = float(current_price) if current_price > 0 else c_now
mp = max(float(max_price or buy_price), h_now, px)
j = estimate_updow_entry_bar_index(candles, entry_bar_key, buy_time_str)
last_idx = len(candles) - 1
if j >= 0 and last_idx <= j:
return None
bars_held = 0 if j < 0 else (last_idx - j)
bl = float(box_low or 0)
bh = float(box_high or 0)
bo_follow = bool(float(c.get("breakout_follow", 0) or 0))
res, _ = eval_box_exit_at_price(
buy_price, mp, px,
box_low=bl, breach_buffer_pct=breach_buf,
panic_from_high_pct=panic, sl_eff=sl_eff, tp_eff=tp_eff,
shoulder_min_high=smh, shoulder_cut_pct=scut,
ratchet_tiers=parse_box_ratchet_tiers(c),
box_high=bh, breakout_follow=bo_follow,
)
if res:
return res
if j >= 0 and bars_held >= max_hold and bars_held > 0:
return (f"보유한도({max_hold}봉)", c_now)
return None
# ----------------------------------------------------------------------
# 파라서치 그리드 (env ``UPDOWN_BOX_GRID_*`` — 하드코딩 0)
# ----------------------------------------------------------------------
def _grid_vals(env_key: str, default_csv: str) -> List[float]:
from ..utils.env import get_env_from_db
s = (get_env_from_db(env_key, default_csv) or default_csv).strip()
out: List[float] = []
for part in s.split(","):
part = part.strip()
if not part:
continue
try:
out.append(float(part))
except ValueError:
pass
return out or [float(x) for x in default_csv.split(",") if x.strip()]
def _grid_strs(env_key: str, default_semicolon: str) -> List[str]:
"""문자열 그리드 후보(다단 래칫) — 세미콜론(;) 구분. 빈 문자열 후보="" (OFF) 포함 가능.
예 ``";2:1.5,5:1.0"`` → ["", "2:1.5,5:1.0"] (OFF + 1프리셋). 래칫 내부는 쉼표라 구분자는 ;.
"""
from ..utils.env import get_env_from_db
raw = get_env_from_db(env_key, default_semicolon)
raw = default_semicolon if raw is None else str(raw)
out: List[str] = []
seen = set()
for chunk in raw.split(";"):
c = chunk.strip()
if c not in seen:
seen.add(c)
out.append(c)
return out or [""]
def default_box_param_grid(mode: str = "fast") -> Dict[str, List[Any]]:
"""박스권 UPDOWN 그리드 축. mode=fast 는 축 수·값 축소.
``ratchet_tiers`` 는 문자열 후보(다단 래칫) — 빈 값="" 은 단일 어깨컷(OFF).
"""
if mode == "full":
return {
"near_low_pct": _grid_vals("UPDOWN_BOX_GRID_NEAR0", "1.0,1.5,2.0,2.5,3.0"),
"tp_pct": _grid_vals("UPDOWN_BOX_GRID_TP0", "2.0,2.5,3.0,3.5,4.0,5.0"),
"stop_loss_pct": _grid_vals("UPDOWN_BOX_GRID_SL0", "2.0,2.5,3.0,3.5"),
"shoulder_min_high_pct": _grid_vals("UPDOWN_BOX_GRID_SHOULDER_ARM0", "0.5,1.0,1.5,2.0"),
"shoulder_cut_pct": _grid_vals("UPDOWN_BOX_GRID_SHOULDER_CUT0", "0.5,1.0,1.5"),
"panic_from_high_pct": _grid_vals("UPDOWN_BOX_GRID_PANIC0", "2.0,2.5,3.0,4.0"),
"range_max_pct": _grid_vals("UPDOWN_BOX_GRID_RANGE0", "6.0,8.0,10.0"),
# 다단 래칫 후보: OFF(단일 어깨컷) + 2 프리셋
"ratchet_tiers": _grid_strs("UPDOWN_BOX_GRID_RATCHET0", ";1.5:1.0,3:0.7;2:1.5,5:1.0"),
# 돌파추종 후보: 0=OFF(횡보 익절형) / 1=ON(박스 상단 돌파 후 tp 해제·래칫 추종)
"breakout_follow": _grid_vals("UPDOWN_BOX_GRID_BREAKOUT0", "0,1"),
}
# fast — 핵심 4축 + 래칫(OFF/1프리셋) + 돌파추종(OFF/ON)
return {
"near_low_pct": _grid_vals("UPDOWN_BOX_GRID_NEAR_FAST", "1.5,2.0,2.5"),
"tp_pct": _grid_vals("UPDOWN_BOX_GRID_TP_FAST", "2.5,3.0,3.5,4.0"),
"stop_loss_pct": _grid_vals("UPDOWN_BOX_GRID_SL_FAST", "2.0,2.5,3.0"),
"shoulder_cut_pct": _grid_vals("UPDOWN_BOX_GRID_SHOULDER_CUT_FAST", "0.8,1.0,1.2"),
"ratchet_tiers": _grid_strs("UPDOWN_BOX_GRID_RATCHET_FAST", ";2:1.5,5:1.0"),
# 돌파추종 0/1 — 종목 성격(횡보 vs 돌파)에 맞는 쪽을 α 기준으로 자동 선택
"breakout_follow": _grid_vals("UPDOWN_BOX_GRID_BREAKOUT_FAST", "0,1"),
}
def run_param_search_box(
candles: List[Dict[str, Any]],
*,
grid: Optional[Dict[str, List[float]]] = None,
base_cfg: Optional[Dict[str, float]] = None,
min_trades: int = 1,
max_combos: int = 800,
rank_by: str = "alpha",
min_alpha: Optional[float] = None,
) -> Tuple[List[Dict[str, Any]], Dict[str, Any]]:
"""박스 전략 그리드 탐색 — ``run_backtest_box`` 단일 소스.
rank_by: "alpha"=B&H 초과수익(α) 우선 정렬(기본) | "pnl"=절대 총손익 우선.
min_alpha: None=필터 없음 | 값 지정 시 α≤min_alpha 조합 제외(0.0 이면 단순보유만 못한 조합 제거).
"""
from itertools import product
g = grid or default_box_param_grid("fast")
base = dict(base_cfg or get_box_cfg_from_env())
keys = list(g.keys())
combos = list(product(*[g[k] for k in keys]))
if max_combos > 0 and len(combos) > max_combos:
combos = combos[:max_combos]
results: List[Dict[str, Any]] = []
for combo in combos:
cfg = dict(base)
apply_cfg: Dict[str, Any] = {}
for k, v in zip(keys, combo):
if k == "ratchet_tiers":
cfg[k] = str(v)
apply_cfg[k] = str(v)
else:
cfg[k] = float(v)
apply_cfg[k] = float(v)
bt = run_backtest_box(candles, cfg)
if bt["n_trades"] < min_trades:
continue
alpha = float(bt.get("alpha_pct", bt["total_pnl"]))
if min_alpha is not None and alpha <= float(min_alpha):
continue
results.append({
"apply_cfg": apply_cfg,
"n_trades": bt["n_trades"],
"pf": bt["pf"],
"win_rate": bt["win_rate"],
"total_pnl": bt["total_pnl"],
"buy_hold_pct": bt.get("buy_hold_pct", 0.0),
"alpha_pct": round(alpha, 4),
})
if str(rank_by).lower() == "pnl":
results.sort(key=lambda r: (-float(r["total_pnl"]), -float(r["pf"]), -int(r["n_trades"])))
else:
results.sort(key=lambda r: (-float(r["alpha_pct"]), -float(r["pf"]), -int(r["n_trades"])))
meta = {
"cartesian_product": len(combos),
"backtests_run": len(combos),
"passed": len(results),
"rank_by": str(rank_by).lower(),
"grid_axes": {k: g[k] for k in keys},
}
return results, meta
def run_param_search_box_multi(
candles_by_code: Dict[str, List[Dict[str, Any]]],
*,
grid: Optional[Dict[str, List[float]]] = None,
base_cfg: Optional[Dict[str, float]] = None,
min_trades_total: int = 3,
max_combos: int = 800,
rank_by: str = "alpha",
) -> Tuple[List[Dict[str, Any]], Dict[str, Any]]:
"""여러 종목 분봉을 같은 글로벌 파라미터로 백테 → 조합별 합산 성과.
글로벌 튜닝용 — 종목별 ``run_backtest_box`` 결과를 합산(총손익 합·총거래·통합 PF).
실매가 글로벌 ``UPDOWN_BOX_*`` 단일 파라미터로 모든 watchlist 종목을 돌리는 것과 정합.
rank_by: "alpha"=합산 B&H 초과수익(α) 우선 정렬(기본) | "pnl"=합산 절대손익 우선.
추세종목(B&H 압도)이 절대손익으로 1위를 오염시키는 것을 막기 위해 기본은 α 정렬.
"""
from itertools import product
g = grid or default_box_param_grid("fast")
base = dict(base_cfg or get_box_cfg_from_env())
keys = list(g.keys())
combos = list(product(*[g[k] for k in keys]))
if max_combos > 0 and len(combos) > max_combos:
combos = combos[:max_combos]
results: List[Dict[str, Any]] = []
for combo in combos:
cfg = dict(base)
apply_cfg: Dict[str, Any] = {}
for k, v in zip(keys, combo):
if k == "ratchet_tiers": # 다단 래칫은 문자열 축
cfg[k] = str(v)
apply_cfg[k] = str(v)
else:
cfg[k] = float(v)
apply_cfg[k] = float(v)
tot_pnl = 0.0
tot_bh = 0.0
tot_trades = 0
gross_win = 0.0
gross_loss = 0.0
wins = 0
per_code: Dict[str, float] = {}
per_code_alpha: Dict[str, float] = {}
for code, candles in candles_by_code.items():
bt = run_backtest_box(candles, cfg)
tot_pnl += float(bt["total_pnl"])
tot_bh += float(bt.get("buy_hold_pct", 0.0))
tot_trades += int(bt["n_trades"])
per_code[code] = float(bt["total_pnl"])
per_code_alpha[code] = float(bt.get("alpha_pct", bt["total_pnl"]))
for t in bt["trades"]:
p = float(t["pnl_pct"])
if p > 0:
gross_win += p
wins += 1
else:
gross_loss += abs(p)
if tot_trades < min_trades_total:
continue
pf = (gross_win / gross_loss) if gross_loss > 0 else (gross_win if gross_win > 0 else 0.0)
tot_alpha = tot_pnl - tot_bh
results.append({
"apply_cfg": apply_cfg,
"n_trades": tot_trades,
"pf": round(pf, 3),
"win_rate": round(wins / tot_trades * 100.0, 2) if tot_trades else 0.0,
"total_pnl": round(tot_pnl, 4),
"buy_hold_pct": round(tot_bh, 4),
"alpha_pct": round(tot_alpha, 4),
"per_code_pnl": {k: round(v, 3) for k, v in per_code.items()},
"per_code_alpha": {k: round(v, 3) for k, v in per_code_alpha.items()},
})
if str(rank_by).lower() == "pnl":
results.sort(key=lambda r: (-float(r["total_pnl"]), -float(r["pf"]), -int(r["n_trades"])))
else:
results.sort(key=lambda r: (-float(r["alpha_pct"]), -float(r["pf"]), -int(r["n_trades"])))
meta = {
"cartesian_product": len(combos),
"backtests_run": len(combos) * max(1, len(candles_by_code)),
"passed": len(results),
"rank_by": str(rank_by).lower(),
"codes": list(candles_by_code.keys()),
"grid_axes": {k: g[k] for k in keys},
}
return results, meta
def box_cfg_to_env_patch(cfg: Dict[str, Any]) -> Dict[str, Any]:
"""그리드 apply_cfg → env_config ``UPDOWN_BOX_*`` 키 맵 (ratchet_tiers 는 문자열)."""
key_map = {
"near_low_pct": "UPDOWN_BOX_NEAR_LOW_PCT",
"range_max_pct": "UPDOWN_BOX_RANGE_MAX_PCT",
"bb_bw_max": "UPDOWN_BOX_BB_BW_MAX",
"ma_slope_tol_pct": "UPDOWN_BOX_MA_SLOPE_TOL_PCT",
"body_drop_min_pct": "UPDOWN_BOX_BODY_DROP_MIN_PCT",
"breach_buffer_pct": "UPDOWN_BOX_BREACH_BUFFER_PCT",
"panic_from_high_pct": "UPDOWN_PANIC_FROM_HIGH_PCT",
"stop_loss_pct": "UPDOWN_BOX_STOP_LOSS_PCT",
"tp_pct": "UPDOWN_BOX_TP_PCT",
"shoulder_min_high_pct": "UPDOWN_BOX_SHOULDER_MIN_HIGH_PCT",
"shoulder_cut_pct": "UPDOWN_BOX_SHOULDER_CUT_PCT",
"ratchet_tiers": "UPDOWN_BOX_RATCHET_TIERS",
"max_hold_bars": "UPDOWN_BOX_MAX_HOLD_BARS",
"lookback_bars": "UPDOWN_BOX_LOOKBACK_BARS",
"breakout_follow": "UPDOWN_BOX_BREAKOUT_FOLLOW",
}
patch: Dict[str, Any] = {}
for k, env_k in key_map.items():
if k in cfg:
patch[env_k] = cfg[k] # ratchet_tiers 는 문자열, 그 외 숫자
return patch