변경 사항 ---- - _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>
959 lines
42 KiB
Python
959 lines
42 KiB
Python
"""
|
||
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
|
||
|