""" kis_trader/strategies/updow_buy.py — 「직전 분봉 음봉·몸통 하락 → 다음 봉 시가 매수」(Updow) 백테스트·파라미터 탐색 엔진 ================================================================================================ - 기본값·그리드 끝값: ``env_config`` 최신 행의 ``UPDOW_*`` / ``UPDOW_GRID_*`` → 없으면 ``kis_trader.utils.env`` 폴백. - **웹 백테·탐색·실매**: 종목별 ``updow_stock_config`` (없으면 ``UPDOW_*`` env 폴백). ``updow_holding_cfg.py`` 참고. - **시장 레짐(선택)**: ``UPDOW_KOSPI_1MIN_PROXY_CODE``(기본 KODEX KOSPI 등 6자리) 1분봉 종가의 ``regime_ma_bars`` 이동평균 — **종가 < SMA×(1−ease/100)** 이면(``ease``=``regime_ma_ease_pct``) 코스피 프록시 **하락세**로 보고 **신규 매수만** 스킵. ``ease``=0 이면 기존과 동일(종가0 이면 MA 아래로 더 내려가야 차단(널널). 한투 지수(U) 직접 1분봉이 아니라 **주식 분봉 API(J)** 로 ETF 프록시를 씀. - 신호 봉 si: 음봉(close < open) 이고, 몸통 하락률 (open-close)/open*100 ≥ body_drop_min_pct - 진입: 그 다음 봉 시가 (1봉 지연) - 청산(V4): 1순위 어깨컷 → 2순위 익절% → 3순위 손절% → (선택) 양봉 종가 → 최대 보유 봉 수 - 백테 SL/TP/어깨: 분봉 OHLC intrabar(N회) + 실매 ``current_price`` 정렬; 양봉·보유한도는 봉 마감 - **RSI·당일 시가→저가 낙폭·꼬리비율 등은 사용하지 않음** (꼬리잡기/단타와 다른 축). 구 ``updowbuyy.py`` 는 ``remove/legacy_standalone_bots/`` 로 이동 (수정 금지). """ from __future__ import annotations from bisect import bisect_left from datetime import datetime, timedelta from itertools import product as iproduct from typing import Any, Dict, List, Optional, Tuple from ..engine.limit_entry_common import ( compute_atr_limit_price, floor_limit_price_krw, is_limit_atr_entry, limit_valid_until_bar_key, resolve_limit_anchor_price, try_limit_fill_on_bar, updow_entry_mode, updow_limit_params, ) from ..utils.env import get_env_bool, get_env_float, get_env_int # 엔진이 다루는 UI/백테 공통 키 (holding_bot·웹 쿼리 파라미터 이름과 동일) CFG_ENGINE_KEYS: Tuple[str, ...] = ( "body_drop_min_pct", "body_drop_max_pct", "tp_pct", "stop_loss_pct", "atr_use_dynamic", "atr_period", "atr_sl_mult", "atr_tp_mult", "atr_sl_min_pct", "atr_sl_max_pct", "atr_tp_min_pct", "atr_tp_max_pct", "max_hold_bars", "exit_on_green", "slot_money", "regime_ma_bars", "regime_ma_ease_pct", "shoulder_min_high_pct", "shoulder_cut_pct", # 종목별 지정가 깊이(anchor−ATR×배수) — updow_stock_config 컬럼으로 저장/적용 "limit_atr_mult", ) # 문서·구버전 호환용 정적 폴백 (실행 시 값은 cfg_from_env_snapshot 우선) DEFAULT_UPDOW_CONFIG: Dict[str, float] = { "body_drop_min_pct": 0.0, # 하락률 상한(%) — 0=OFF(상한 없음). 양수면 한 봉에 이 % 초과 폭락한 # 종목은 신호에서 제외 → 악재성 칼날잡기 완화 (백테·실매 동일 적용) "body_drop_max_pct": 0.0, "tp_pct": 3.0, "stop_loss_pct": 2.0, "atr_use_dynamic": 0.0, "atr_period": 14.0, "atr_sl_mult": 2.0, "atr_tp_mult": 4.0, "atr_sl_min_pct": 0.8, "atr_sl_max_pct": 6.0, "atr_tp_min_pct": 1.5, "atr_tp_max_pct": 12.0, "max_hold_bars": 16.0, "exit_on_green": 0.0, "slot_money": 3_000_000.0, "regime_ma_bars": 0.0, "regime_ma_ease_pct": 0.0, "shoulder_min_high_pct": 0.3, "shoulder_cut_pct": 0.2, # 지정가 깊이 기본 1.5 (env UPDOW_LIMIT_ATR_MULT 미설정 시와 동일 — 기존 동작 보존) "limit_atr_mult": 1.5, } UPDOW_GRID_AXIS_HINTS_KO: Dict[str, str] = { "body_drop_min_pct": ( "직전 봉이 음봉일 때 몸통 하락률(%) 하한 — (시가−종가)/시가×100 이 값 이상이면 " "「하락 충분」으로 보고 다음 봉 시가 매수 신호(그리드에서 여러 하한값을 탐색)" ), "body_drop_max_pct": ( "몸통 하락률(%) 상한 — 0=OFF(상한 없음). 양수면 한 봉에 이 % 초과 폭락한 종목은 " "신호 제외(악재성 떨어지는 칼날잡기 완화). 하한<하락률≤상한 구간만 매수" ), "tp_pct": ( "목표 익절률(%) — 매수가 대비 종가·익절 로직 기준 상한(그리드: 여러 익절% 후보)" ), "stop_loss_pct": ( "고정 손절률(%) — 매수가 대비 이 비율 이상 손실 시 청산(그리드: 손절 폭 후보)" ), "atr_use_dynamic": ( "ATR 동적 청산 사용 여부 — 1=ON: ATR 기반으로 손절/익절 폭 산출, 0=OFF: 고정 % 사용" ), "atr_period": ( "ATR 기간(봉) — 최근 변동성 측정 창. 짧을수록 민감(예: 7), 길수록 완만(예: 14)" ), "atr_sl_mult": ( "손절 ATR 배수 — 손절폭(%)=ATR×배수/진입가×100 (min/max 캡으로 제한)" ), "atr_tp_mult": ( "익절 ATR 배수 — 익절폭(%)=ATR×배수/진입가×100 (min/max 캡으로 제한)" ), "atr_sl_min_pct": "ATR 손절 하한(%) — 노이즈 구간에서 과도하게 타이트해지는 것 방지", "atr_sl_max_pct": "ATR 손절 상한(%) — 비정상 급변동에서 과도하게 넓어지는 것 방지", "atr_tp_min_pct": "ATR 익절 하한(%)", "atr_tp_max_pct": "ATR 익절 상한(%)", "max_hold_bars": ( "최대 보유 봉 수 — 진입 후 이 봉 수를 넘기면 다음 봉 시가 청산(그리드: 보유 기간 후보)" ), "exit_on_green": ( "양봉 청산 사용 여부 — 1=ON: 종가>시가인 봉에서 청산, 0=OFF(그리드: ON/OFF 비교)" ), "regime_ma_bars": ( "KOSPI 프록시 1분 종가의 단순 이동평균(분) 봉 수 — 0=OFF. " "양수일 때 종가 List[float]: """그리드 끝값 — ``UPDOW_GRID_LIMIT_ATR0/1/2`` (파라서치·웹 탐색).""" return [ _read_snap_float(snap, "UPDOW_GRID_LIMIT_ATR0", 1.0), _read_snap_float(snap, "UPDOW_GRID_LIMIT_ATR1", 1.5), _read_snap_float(snap, "UPDOW_GRID_LIMIT_ATR2", 2.0), ] def _strip_comment(val: Any) -> Any: if isinstance(val, str) and "#" in val: return val.split("#", 1)[0].strip() return val def _read_snap_float(snap: Optional[Dict[str, Any]], env_key: str, default: float) -> float: """env_config 스냅샷에 값이 있으면 우선, 없으면 get_env_float(DB→os→기본).""" if snap: raw = snap.get(env_key) raw = _strip_comment(raw) if raw is not None and str(raw).strip() != "": try: return float(raw) except (TypeError, ValueError): pass return get_env_float(env_key, default) def _read_snap_int(snap: Optional[Dict[str, Any]], env_key: str, default: int) -> int: if snap: raw = snap.get(env_key) raw = _strip_comment(raw) if raw is not None and str(raw).strip() != "": try: return int(float(raw)) except (TypeError, ValueError): pass return get_env_int(env_key, default) def clamp_regime_ma_ease_pct(raw: Any) -> float: """레짐 MA 완화율(%) — 0 이상, 상한은 env ``UPDOW_REGIME_MA_EASE_CAP`` (기본 2.5).""" try: v = float(raw) except (TypeError, ValueError): v = 0.0 cap = get_env_float("UPDOW_REGIME_MA_EASE_CAP", 2.5) return max(0.0, min(v, cap)) def cfg_from_env_snapshot(snap: Optional[Dict[str, Any]] = None) -> Dict[str, float]: """env_config 스냅샷(또는 None)에서 엔진 cfg 생성. None이면 DB/env/os 순으로 조회.""" exit_on = 1.0 if get_env_bool("UPDOW_EXIT_ON_GREEN", False) else 0.0 if snap: raw = snap.get("UPDOW_EXIT_ON_GREEN") raw = _strip_comment(raw) if raw is not None and str(raw).strip() != "": s = str(raw).strip().lower() if s in ("true", "1", "yes", "y", "on"): exit_on = 1.0 elif s in ("false", "0", "no", "n", "off"): exit_on = 0.0 else: try: exit_on = 1.0 if float(raw) >= 0.5 else 0.0 except (TypeError, ValueError): exit_on = 1.0 if get_env_bool("UPDOW_EXIT_ON_GREEN", False) else 0.0 _probe: Dict[str, Any] = {} if snap: raw_em = _strip_comment(snap.get("UPDOW_ENTRY_MODE")) if raw_em is not None and str(raw_em).strip(): _probe["entry_mode"] = str(raw_em).strip().lower() for js_key, env_key, as_int in ( ("limit_atr_mult", "UPDOW_LIMIT_ATR_MULT", False), ("limit_anchor", "UPDOW_LIMIT_ANCHOR", False), ("limit_valid_bars", "UPDOW_LIMIT_VALID_BARS", True), ("limit_fill_slip_pct", "UPDOW_LIMIT_FILL_SLIP_PCT", False), ): raw = _strip_comment(snap.get(env_key)) if raw is None or str(raw).strip() == "": continue if as_int: try: _probe[js_key] = int(float(raw)) except (TypeError, ValueError): pass elif js_key == "limit_anchor": _probe[js_key] = str(raw).strip().lower() else: try: _probe[js_key] = float(raw) except (TypeError, ValueError): pass _entry_m = updow_entry_mode(_probe if _probe else None) _probe["entry_mode"] = _entry_m _lp = updow_limit_params(_probe) return { "body_drop_min_pct": _read_snap_float(snap, "UPDOW_BODY_DROP_MIN_PCT", DEFAULT_UPDOW_CONFIG["body_drop_min_pct"]), "body_drop_max_pct": _read_snap_float(snap, "UPDOW_BODY_DROP_MAX_PCT", DEFAULT_UPDOW_CONFIG["body_drop_max_pct"]), "tp_pct": _read_snap_float(snap, "UPDOW_TP_PCT", DEFAULT_UPDOW_CONFIG["tp_pct"]), "stop_loss_pct": _read_snap_float(snap, "UPDOW_STOP_LOSS_PCT", DEFAULT_UPDOW_CONFIG["stop_loss_pct"]), "atr_use_dynamic": _read_snap_float(snap, "UPDOW_ATR_USE_DYNAMIC", DEFAULT_UPDOW_CONFIG["atr_use_dynamic"]), "atr_period": float(_read_snap_int(snap, "UPDOW_ATR_PERIOD", int(DEFAULT_UPDOW_CONFIG["atr_period"]))), "atr_sl_mult": _read_snap_float(snap, "UPDOW_ATR_SL_MULT", DEFAULT_UPDOW_CONFIG["atr_sl_mult"]), "atr_tp_mult": _read_snap_float(snap, "UPDOW_ATR_TP_MULT", DEFAULT_UPDOW_CONFIG["atr_tp_mult"]), "atr_sl_min_pct": _read_snap_float(snap, "UPDOW_ATR_SL_MIN_PCT", DEFAULT_UPDOW_CONFIG["atr_sl_min_pct"]), "atr_sl_max_pct": _read_snap_float(snap, "UPDOW_ATR_SL_MAX_PCT", DEFAULT_UPDOW_CONFIG["atr_sl_max_pct"]), "atr_tp_min_pct": _read_snap_float(snap, "UPDOW_ATR_TP_MIN_PCT", DEFAULT_UPDOW_CONFIG["atr_tp_min_pct"]), "atr_tp_max_pct": _read_snap_float(snap, "UPDOW_ATR_TP_MAX_PCT", DEFAULT_UPDOW_CONFIG["atr_tp_max_pct"]), "max_hold_bars": float(_read_snap_int(snap, "UPDOW_MAX_HOLD_BARS", int(DEFAULT_UPDOW_CONFIG["max_hold_bars"]))), "exit_on_green": exit_on, "slot_money": _read_snap_float(snap, "UPDOW_SLOT_MONEY", DEFAULT_UPDOW_CONFIG["slot_money"]), "regime_ma_bars": float(_read_snap_int(snap, "UPDOW_REGIME_MA_BARS", int(DEFAULT_UPDOW_CONFIG["regime_ma_bars"]))), "regime_ma_ease_pct": _read_snap_float(snap, "UPDOW_REGIME_MA_EASE_PCT", DEFAULT_UPDOW_CONFIG["regime_ma_ease_pct"]), "shoulder_min_high_pct": _read_snap_float( snap, "UPDOW_SHOULDER_MIN_HIGH_PCT", DEFAULT_UPDOW_CONFIG["shoulder_min_high_pct"], ), "shoulder_cut_pct": _read_snap_float( snap, "UPDOW_SHOULDER_CUT_PCT", DEFAULT_UPDOW_CONFIG["shoulder_cut_pct"], ), "entry_mode": _entry_m, "limit_atr_mult": _lp["mult"], "limit_anchor": _lp["anchor"], "limit_valid_bars": int(_lp["valid_bars"]), "limit_fill_slip_pct": float(_lp["fill_slip_pct"]), } def env_snapshot_patch_from_engine_cfg( cfg: Dict[str, Any], tf_min: Optional[int] = None, ) -> Dict[str, str]: """insert_env_snapshot 병합용 — UPDOW_* 키만 str 로 반환.""" out: Dict[str, str] = {} if "body_drop_min_pct" in cfg and cfg["body_drop_min_pct"] is not None: out["UPDOW_BODY_DROP_MIN_PCT"] = str(float(cfg["body_drop_min_pct"])) if "body_drop_max_pct" in cfg and cfg["body_drop_max_pct"] is not None: out["UPDOW_BODY_DROP_MAX_PCT"] = str(float(cfg["body_drop_max_pct"])) if "tp_pct" in cfg and cfg["tp_pct"] is not None: out["UPDOW_TP_PCT"] = str(float(cfg["tp_pct"])) if "stop_loss_pct" in cfg and cfg["stop_loss_pct"] is not None: out["UPDOW_STOP_LOSS_PCT"] = str(float(cfg["stop_loss_pct"])) if "atr_use_dynamic" in cfg and cfg["atr_use_dynamic"] is not None: out["UPDOW_ATR_USE_DYNAMIC"] = "1" if float(cfg["atr_use_dynamic"]) >= 0.5 else "0" if "atr_period" in cfg and cfg["atr_period"] is not None: out["UPDOW_ATR_PERIOD"] = str(int(float(cfg["atr_period"]))) if "atr_sl_mult" in cfg and cfg["atr_sl_mult"] is not None: out["UPDOW_ATR_SL_MULT"] = str(float(cfg["atr_sl_mult"])) if "atr_tp_mult" in cfg and cfg["atr_tp_mult"] is not None: out["UPDOW_ATR_TP_MULT"] = str(float(cfg["atr_tp_mult"])) if "atr_sl_min_pct" in cfg and cfg["atr_sl_min_pct"] is not None: out["UPDOW_ATR_SL_MIN_PCT"] = str(float(cfg["atr_sl_min_pct"])) if "atr_sl_max_pct" in cfg and cfg["atr_sl_max_pct"] is not None: out["UPDOW_ATR_SL_MAX_PCT"] = str(float(cfg["atr_sl_max_pct"])) if "atr_tp_min_pct" in cfg and cfg["atr_tp_min_pct"] is not None: out["UPDOW_ATR_TP_MIN_PCT"] = str(float(cfg["atr_tp_min_pct"])) if "atr_tp_max_pct" in cfg and cfg["atr_tp_max_pct"] is not None: out["UPDOW_ATR_TP_MAX_PCT"] = str(float(cfg["atr_tp_max_pct"])) if "max_hold_bars" in cfg and cfg["max_hold_bars"] is not None: out["UPDOW_MAX_HOLD_BARS"] = str(int(float(cfg["max_hold_bars"]))) if "exit_on_green" in cfg and cfg["exit_on_green"] is not None: v = float(cfg["exit_on_green"]) out["UPDOW_EXIT_ON_GREEN"] = "1" if v >= 0.5 else "0" if "slot_money" in cfg and cfg["slot_money"] is not None: out["UPDOW_SLOT_MONEY"] = str(int(float(cfg["slot_money"]))) if "regime_ma_bars" in cfg and cfg["regime_ma_bars"] is not None: out["UPDOW_REGIME_MA_BARS"] = str(int(float(cfg["regime_ma_bars"]))) if "regime_ma_ease_pct" in cfg and cfg["regime_ma_ease_pct"] is not None: out["UPDOW_REGIME_MA_EASE_PCT"] = str(float(cfg["regime_ma_ease_pct"])) if "shoulder_min_high_pct" in cfg and cfg["shoulder_min_high_pct"] is not None: out["UPDOW_SHOULDER_MIN_HIGH_PCT"] = str(float(cfg["shoulder_min_high_pct"])) if "shoulder_cut_pct" in cfg and cfg["shoulder_cut_pct"] is not None: out["UPDOW_SHOULDER_CUT_PCT"] = str(float(cfg["shoulder_cut_pct"])) if cfg.get("entry_mode") is not None: out["UPDOW_ENTRY_MODE"] = str(cfg["entry_mode"]).strip().lower() for js_key, env_key in ( ("limit_atr_mult", "UPDOW_LIMIT_ATR_MULT"), ("limit_anchor", "UPDOW_LIMIT_ANCHOR"), ("limit_valid_bars", "UPDOW_LIMIT_VALID_BARS"), ("limit_fill_slip_pct", "UPDOW_LIMIT_FILL_SLIP_PCT"), ): if js_key in cfg and cfg[js_key] is not None: out[env_key] = str(cfg[js_key]) if tf_min is not None: out["UPDOW_TF_MIN"] = str(int(tf_min)) return out def default_param_grid(snap: Optional[Dict[str, Any]] = None) -> Dict[str, List[float]]: """그리드 축 — 끝값은 env_config ``UPDOW_GRID_*`` (또는 get_env_float 폴백). 조합 수 ≈ 9000.""" return { "body_drop_min_pct": [ 0.0, _read_snap_float(snap, "UPDOW_GRID_BODY0", 0.3), _read_snap_float(snap, "UPDOW_GRID_BODY1", 0.6), _read_snap_float(snap, "UPDOW_GRID_BODY2", 1.0), _read_snap_float(snap, "UPDOW_GRID_BODY3", 1.5), ], "tp_pct": [ _read_snap_float(snap, "UPDOW_GRID_TP0", 2.0), _read_snap_float(snap, "UPDOW_GRID_TP1", 3.0), _read_snap_float(snap, "UPDOW_GRID_TP2", 4.0), _read_snap_float(snap, "UPDOW_GRID_TP3", 6.0), _read_snap_float(snap, "UPDOW_GRID_TP4", 8.0), ], "stop_loss_pct": [ _read_snap_float(snap, "UPDOW_GRID_SL0", 1.5), _read_snap_float(snap, "UPDOW_GRID_SL1", 2.0), _read_snap_float(snap, "UPDOW_GRID_SL2", 2.5), _read_snap_float(snap, "UPDOW_GRID_SL3", 3.0), ], # ATR 동적 청산 on/off 비교 (배수/기간은 베이스 cfg를 사용) "atr_use_dynamic": [0.0, 1.0], "max_hold_bars": [ float(_read_snap_int(snap, "UPDOW_GRID_HOLD0", 8)), float(_read_snap_int(snap, "UPDOW_GRID_HOLD1", 16)), float(_read_snap_int(snap, "UPDOW_GRID_HOLD2", 32)), float(_read_snap_int(snap, "UPDOW_GRID_HOLD3", 48)), float(_read_snap_int(snap, "UPDOW_GRID_HOLD4", 64)), ], "exit_on_green": [0.0, 1.0], "regime_ma_bars": [ 0.0, float(_read_snap_int(snap, "UPDOW_GRID_REGIME0", 60)), float(_read_snap_int(snap, "UPDOW_GRID_REGIME1", 120)), ], "regime_ma_ease_pct": [ 0.0, _read_snap_float(snap, "UPDOW_GRID_REGIME_EASE0", 0.15), _read_snap_float(snap, "UPDOW_GRID_REGIME_EASE1", 0.30), ], "shoulder_min_high_pct": [ _read_snap_float(snap, "UPDOW_GRID_SHOULDER_SMIN0", 0.2), _read_snap_float(snap, "UPDOW_GRID_SHOULDER_SMIN1", 0.3), _read_snap_float(snap, "UPDOW_GRID_SHOULDER_SMIN2", 0.5), ], "shoulder_cut_pct": [ _read_snap_float(snap, "UPDOW_GRID_SHOULDER_CUT0", 0.15), _read_snap_float(snap, "UPDOW_GRID_SHOULDER_CUT1", 0.2), _read_snap_float(snap, "UPDOW_GRID_SHOULDER_CUT2", 0.3), ], "limit_atr_mult": _grid_limit_atr_mult_values(snap), } def default_param_grid_web_fast(snap: Optional[Dict[str, Any]] = None) -> Dict[str, List[float]]: """ 웹 탐색 전용 경량 그리드. - 브라우저 타임아웃/프록시 타임아웃 방지 목적 - ATR on/off 비교는 유지하되 조합 수를 크게 줄임 """ return { "body_drop_min_pct": [ 0.0, _read_snap_float(snap, "UPDOW_GRID_BODY1", 0.6), _read_snap_float(snap, "UPDOW_GRID_BODY3", 1.5), ], "tp_pct": [ _read_snap_float(snap, "UPDOW_GRID_TP1", 3.0), _read_snap_float(snap, "UPDOW_GRID_TP2", 4.0), _read_snap_float(snap, "UPDOW_GRID_TP3", 6.0), ], "stop_loss_pct": [ _read_snap_float(snap, "UPDOW_GRID_SL0", 1.5), _read_snap_float(snap, "UPDOW_GRID_SL1", 2.0), _read_snap_float(snap, "UPDOW_GRID_SL2", 2.5), ], "atr_use_dynamic": [0.0, 1.0], "max_hold_bars": [ float(_read_snap_int(snap, "UPDOW_GRID_HOLD1", 1)), float(_read_snap_int(snap, "UPDOW_GRID_HOLD2", 2)), ], "exit_on_green": [0.0, 1.0], "regime_ma_bars": [ 0.0, float(_read_snap_int(snap, "UPDOW_GRID_REGIME0", 60)), ], "regime_ma_ease_pct": [0.0], "shoulder_min_high_pct": [ _read_snap_float(snap, "UPDOW_GRID_SHOULDER_SMIN1", 0.3), _read_snap_float(snap, "UPDOW_GRID_SHOULDER_SMIN2", 0.5), ], "shoulder_cut_pct": [ _read_snap_float(snap, "UPDOW_GRID_SHOULDER_CUT1", 0.2), _read_snap_float(snap, "UPDOW_GRID_SHOULDER_CUT2", 0.3), ], "limit_atr_mult": _grid_limit_atr_mult_values(snap), } def default_param_grid_us(snap: Optional[Dict[str, Any]] = None) -> Dict[str, List[float]]: """ 해외(US) UPDOW 그리드 — 코스피 레짐(069500) 축 제외. ``regime_ma_bars`` / ``regime_ma_ease_pct`` 는 0(OFF)만 탐색. """ g = default_param_grid(snap) g["regime_ma_bars"] = [0.0] g["regime_ma_ease_pct"] = [0.0] return g def read_us_fast_max_combos(snap: Optional[Dict[str, Any]] = None) -> int: """US fast 탐색 조합 상한 — ``UPDOW_US_FAST_MAX_COMBOS`` (기본 2880 ≈ 10~15분). 1회 백테 ≈ 0.25초(60분 400봉) 기준 2880조합 ≈ 약 12분. 봉 수가 많은(장기) 백테면 비례해서 길어지므로 env로 조절. """ return _read_snap_int(snap, "UPDOW_US_FAST_MAX_COMBOS", 2880) def default_param_grid_us_fast(snap: Optional[Dict[str, Any]] = None) -> Dict[str, List[float]]: """ 해외(US) 전용 fast 그리드 — 저변동 지수 ETF(QQQM·SPYM 등) 15분봉 맞춤. - **레짐 OFF 고정**(코스피 069500 미사용). - **limit_atr_mult 에 낮은 값(0.0~0.5) 포함**: 지수 ETF는 변동성이 낮아 깊은 지정가 (저점−ATR×1.5)는 거의 체결이 안 됨 → 체결률을 살리려면 얕은 지정가가 필수. - **인덱스 dip-buy 특성 반영**: 눌림이 얕으므로 body_drop 하한을 낮게(0~0.3), tp/sl 도 저변동 폭(0.8~4 / 0.6~1.8), 그리고 "회복까지 보유"를 위해 max_hold 를 15분봉 기준으로 길게(8/16/32봉 ≈ 2/4/8시간) 잡는다. (60분봉이면 그만큼 더 김) - 카르테시안 = limit(4)×body(4)×tp(5)×sl(3)×hold(3)×atr(2)×green(2) = **2,880조합**. - 끝값은 ``UPDOW_US_GRID_*`` env_config 로 조절 (하드코딩 금지 원칙 준수). """ return { # 지정가 깊이(anchor−ATR×배수) — 0=저점 그대로(체결↑), 클수록 더 아래(체결↓·진입가↓) # 저변동 ETF는 얕게(0~1.0) — 깊으면 체결 자체가 안 됨 "limit_atr_mult": [ _read_snap_float(snap, "UPDOW_US_GRID_LIMIT_ATR0", 0.0), _read_snap_float(snap, "UPDOW_US_GRID_LIMIT_ATR1", 0.3), _read_snap_float(snap, "UPDOW_US_GRID_LIMIT_ATR2", 0.5), _read_snap_float(snap, "UPDOW_US_GRID_LIMIT_ATR3", 1.0), ], # 몸통 하락 하한 — 인덱스는 눌림이 얕음 → 0~0.3 위주 (0=음봉이면 무조건 신호) "body_drop_min_pct": [ _read_snap_float(snap, "UPDOW_US_GRID_BODY0", 0.0), _read_snap_float(snap, "UPDOW_US_GRID_BODY1", 0.1), _read_snap_float(snap, "UPDOW_US_GRID_BODY2", 0.2), _read_snap_float(snap, "UPDOW_US_GRID_BODY3", 0.3), ], # 익절 — 저변동 ETF 15분봉 폭에 맞춰 작게~중간 (회복 반등 목표) "tp_pct": [ _read_snap_float(snap, "UPDOW_US_GRID_TP0", 0.8), _read_snap_float(snap, "UPDOW_US_GRID_TP1", 1.2), _read_snap_float(snap, "UPDOW_US_GRID_TP2", 1.8), _read_snap_float(snap, "UPDOW_US_GRID_TP3", 2.5), _read_snap_float(snap, "UPDOW_US_GRID_TP4", 4.0), ], # 손절 — 저변동이라 타이트하게 (노이즈 손절 방지 위해 0.6 하한) "stop_loss_pct": [ _read_snap_float(snap, "UPDOW_US_GRID_SL0", 0.6), _read_snap_float(snap, "UPDOW_US_GRID_SL1", 1.0), _read_snap_float(snap, "UPDOW_US_GRID_SL2", 1.8), ], # 보유 봉 수 — 인덱스 "회복까지 보유" → 15분봉 기준 길게 (2/4/8시간) "max_hold_bars": [ float(_read_snap_int(snap, "UPDOW_US_GRID_HOLD0", 8)), float(_read_snap_int(snap, "UPDOW_US_GRID_HOLD1", 16)), float(_read_snap_int(snap, "UPDOW_US_GRID_HOLD2", 32)), ], # ATR 동적 청산 on/off 비교 (배수/기간은 베이스 cfg) "atr_use_dynamic": [0.0, 1.0], # 양봉 청산 on/off 비교 (저변동 ETF에서 효과 큼) "exit_on_green": [0.0, 1.0], # 고정축 (조합 수 영향 없음) — 레짐 OFF "regime_ma_bars": [0.0], "regime_ma_ease_pct": [0.0], } def _downsample_combos_uniform( combos: List[Tuple[Any, ...]], max_combos: int, ) -> Tuple[List[Tuple[Any, ...]], int]: """조합 수가 클 때 균등 간격으로 샘플링.""" cap = int(max_combos) total = len(combos) if cap <= 0 or total <= cap: return combos, 0 if cap == 1: return [combos[0]], total - 1 last = total - 1 picked: List[Tuple[Any, ...]] = [] for i in range(cap): idx = int(round(i * last / (cap - 1))) picked.append(combos[idx]) dropped = total - len(picked) return picked, max(0, dropped) def _parse_bar_datetime(val: Any) -> Optional[datetime]: """holding_min_candles.candle_date 문자열 → datetime (실패 시 None).""" s = str(val or "").strip() if not s: return None if len(s) >= 19 and s[4] == "-" and s[10] in " T": try: return datetime.strptime(s[:19].replace("T", " "), "%Y-%m-%d %H:%M:%S") except ValueError: return None digits = "".join(ch for ch in s if ch.isdigit()) if len(digits) >= 12: try: return datetime.strptime(digits[:12], "%Y%m%d%H%M") except ValueError: return None return None def precompute_regime_pause_buy_flags( stock_candles: List[Dict[str, Any]], regime_candles: List[Dict[str, Any]], ma_bars: int, stock_tf_min: int, ease_pct: float = 0.0, ) -> Optional[List[bool]]: """ 각 주식 봉 인덱스 ``si`` 에 대해, 해당 봉 구간 종료 시점까지의 KOSPI **프록시** 1분 종가가 ``ma_bars`` SMA 기준 **차단선**(SMA×(1−ease/100)) **미만**이면 True (신규 매수 스킵). ``regime_candles`` 가 비어 있거나 ``ma_bars`` < 1 이면 None (필터 미적용). """ mb = int(ma_bars) if mb < 1 or not regime_candles: return None ease = clamp_regime_ma_ease_pct(ease_pct) pairs: List[Tuple[datetime, float]] = [] for c in regime_candles: dt = _parse_bar_datetime(c.get("candle_date")) if dt is None: continue try: cl = float(c.get("close", 0) or 0) except (TypeError, ValueError): continue if cl <= 0: continue pairs.append((dt, cl)) if not pairs: return None pairs.sort(key=lambda x: x[0]) times = [p[0] for p in pairs] closes = [p[1] for p in pairs] tfm = max(1, int(stock_tf_min)) out = [False] * len(stock_candles) for si in range(len(stock_candles)): st = _parse_bar_datetime(stock_candles[si].get("candle_date")) if st is None: continue t_cut = st + timedelta(minutes=tfm) j = bisect_left(times, t_cut) - 1 if j < mb - 1: continue s0 = j - mb + 1 sma = sum(closes[s0 : j + 1]) / float(mb) floor = sma * (1.0 - ease / 100.0) if closes[j] < floor: out[si] = True return out def kospi_proxy_regime_block_state( closes: List[float], ma_bars: int, ease_pct: float = 0.0 ) -> Optional[Tuple[bool, float, float, float, float]]: """ (차단여부, 마지막종가, SMA, 차단선가격, 적용 ease(%)). 봉 부족 시 None — 실매 로그·백테 공통. """ mb = int(ma_bars) if mb < 1 or len(closes) < mb: return None ease = clamp_regime_ma_ease_pct(ease_pct) tail = closes[-mb:] sma = sum(tail) / float(mb) last = float(closes[-1]) floor = sma * (1.0 - ease / 100.0) blocks = last < floor return (blocks, last, sma, floor, ease) def kospi_proxy_regime_blocks_new_buy_from_closes( closes: List[float], ma_bars: int, ease_pct: float = 0.0, ) -> bool: """ 실시간용: 1분 종가 시계열(오름차순)이 있을 때 마지막 종가 < SMA×(1−ease/100) 이면 True. ``ease_pct``=0 이면 기존과 동일(종가 List[Optional[float]]: """ ATR (Wilder) 시리즈. - TR = max(high-low, abs(high-prev_close), abs(low-prev_close)) - ATR: 첫 period 는 단순평균, 이후 Wilder smoothing """ n = len(candles) out: List[Optional[float]] = [None] * n if n < 2 or period < 1: return out highs = [float(c.get("high", 0) or 0) for c in candles] lows = [float(c.get("low", 0) or 0) for c in candles] closes = [float(c.get("close", 0) or 0) for c in candles] trs: List[float] = [0.0] * n for i in range(1, n): h = highs[i] l = lows[i] pc = closes[i - 1] tr = max(h - l, abs(h - pc), abs(l - pc)) trs[i] = max(0.0, tr) if n <= period: return out seed = trs[1 : period + 1] atr = sum(seed) / float(period) out[period] = atr for i in range(period + 1, n): atr = ((atr * (period - 1)) + trs[i]) / float(period) out[i] = atr return out def _effective_exit_pcts( cfg: Dict[str, Any], entry_price: float, atr_value: Optional[float], ) -> Tuple[float, float, float]: """ 진입 시점 손절/익절 비율(소수) 산출. Returns: (sl_pct_dec, tp_pct_dec, atr_used) """ exit_floor_dec = max(0.000001, get_env_float("UPDOW_EXIT_PCT_FLOOR", 0.01) / 100.0) sl_fix = max(exit_floor_dec, float(cfg.get("stop_loss_pct", DEFAULT_UPDOW_CONFIG["stop_loss_pct"])) / 100.0) tp_fix = max(exit_floor_dec, float(cfg.get("tp_pct", DEFAULT_UPDOW_CONFIG["tp_pct"])) / 100.0) use_atr = float(cfg.get("atr_use_dynamic", DEFAULT_UPDOW_CONFIG["atr_use_dynamic"])) >= 0.5 if (not use_atr) or entry_price <= 0 or atr_value is None or atr_value <= 0: return sl_fix, tp_fix, 0.0 mult_floor = max(0.0001, get_env_float("UPDOW_ATR_MULT_FLOOR", 0.1)) sl_mult = max(mult_floor, float(cfg.get("atr_sl_mult", DEFAULT_UPDOW_CONFIG["atr_sl_mult"]))) tp_mult = max(mult_floor, float(cfg.get("atr_tp_mult", DEFAULT_UPDOW_CONFIG["atr_tp_mult"]))) sl_pct = (atr_value * sl_mult / entry_price) * 100.0 tp_pct = (atr_value * tp_mult / entry_price) * 100.0 atr_pct_floor = max(0.0001, get_env_float("UPDOW_ATR_PCT_FLOOR", 0.05)) sl_min = max(atr_pct_floor, float(cfg.get("atr_sl_min_pct", DEFAULT_UPDOW_CONFIG["atr_sl_min_pct"]))) sl_max = max(sl_min, float(cfg.get("atr_sl_max_pct", DEFAULT_UPDOW_CONFIG["atr_sl_max_pct"]))) tp_min = max(atr_pct_floor, float(cfg.get("atr_tp_min_pct", DEFAULT_UPDOW_CONFIG["atr_tp_min_pct"]))) tp_max = max(tp_min, float(cfg.get("atr_tp_max_pct", DEFAULT_UPDOW_CONFIG["atr_tp_max_pct"]))) sl_pct = min(max(sl_pct, sl_min), sl_max) tp_pct = min(max(tp_pct, tp_min), tp_max) return max(exit_floor_dec, sl_pct / 100.0), max(exit_floor_dec, tp_pct / 100.0), float(atr_value) def _updow_shoulder_ratios_from_cfg(cfg: Dict[str, Any]) -> Tuple[float, float]: """cfg/env의 어깨 % → 소수 비율 (0.3% → 0.003).""" smh_pct = float(cfg.get("shoulder_min_high_pct", 0.0) or 0.0) if smh_pct <= 0: smh_pct = get_env_float("UPDOW_SHOULDER_MIN_HIGH_PCT", DEFAULT_UPDOW_CONFIG["shoulder_min_high_pct"]) sc_pct = float(cfg.get("shoulder_cut_pct", 0.0) or 0.0) if sc_pct <= 0: sc_pct = get_env_float("UPDOW_SHOULDER_CUT_PCT", DEFAULT_UPDOW_CONFIG["shoulder_cut_pct"]) return max(0.0, smh_pct / 100.0), max(0.0, sc_pct / 100.0) def _eval_updow_exit_v4_at_price( avg: float, max_price: float, px: float, sl_eff: float, tp_eff: float, shoulder_min_high: float, shoulder_cut_pct: float, ) -> Tuple[Optional[Tuple[str, float]], float]: """ UPDOW V4 intrabar/실매 공용 — 1순위 어깨컷 → 2순위 익절 → 3순위 손절. 반환: ((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) pnl_pct = (px - avg) / avg trail_armed = max_p >= avg * (1.0 + shoulder_min_high) trail_stop_px = max_p * (1.0 - shoulder_cut_pct) if trail_armed else 0.0 if trail_armed and px <= trail_stop_px: return ("어깨컷", float(trail_stop_px)), max_p if pnl_pct >= tp_eff: return (f"익절(+{pnl_pct * 100:.2f}/{tp_eff * 100:.2f}%)", float(px)), max_p if pnl_pct <= -sl_eff: return (f"손절({pnl_pct * 100:.2f}/{sl_eff * 100:.2f}%)", float(px)), max_p return None, max_p def _check_sell_updow_backtest_bar( position: Dict[str, Any], candle: Dict[str, Any], bar_index: int, *, sl_pct_default: float, tp_pct_default: float, max_hold: int, exit_green: bool, shoulder_min_high: float, shoulder_cut_pct: float, ) -> Optional[Tuple[str, float]]: """UPDOW 백테 청산 — V4 intrabar(어깨→익절→손절) + 봉마감 양봉·보유한도. 실매 ``check_sell_signal_updow_live`` 와 동일 우선순위. """ from kis_trader.engine.scalping_engine import _intrabar_exit_prices avg = float(position.get("avg", 0) or 0) ei = int(position.get("entry_i", 0)) if avg <= 0: return None sl_eff = float(position.get("sl_pct", sl_pct_default)) tp_eff = float(position.get("tp_pct", tp_pct_default)) bars_held = bar_index - ei o = float(candle.get("open", candle.get("close", 0)) or 0) h = float(candle.get("high", candle.get("close", 0)) or 0) l = float(candle.get("low", candle.get("close", 0)) or 0) c = float(candle.get("close", 0) or 0) if c <= 0: return None max_p = max(float(position.get("max_price", avg) or avg), h) n_checks = get_env_int("BACKTEST_EXIT_CHECKS_PER_BAR", 6) prices = [c] if n_checks <= 1 else _intrabar_exit_prices(o, h, l, c, n_checks) for px in prices: res, max_p = _eval_updow_exit_v4_at_price( avg, max_p, float(px), sl_eff, tp_eff, shoulder_min_high, shoulder_cut_pct, ) if res: position["max_price"] = max_p return res position["max_price"] = max_p if exit_green and c > o and o > 0: return ("양봉청산", c) if bars_held >= max_hold and bars_held > 0: return (f"보유한도({max_hold}봉)", c) return None def run_backtest_updow( candles: List[Dict[str, Any]], cfg: Dict[str, Any], fee_rate: float = 0.015 / 100, sell_tax: float = 0.18 / 100, *, regime_candles: Optional[List[Dict[str, Any]]] = None, stock_tf_min: int = 60, ) -> Dict[str, Any]: """분봉 리스트(시간 오름차순, holding_bot.get_stored_min_candles 형식)를 받아 백테스트.""" if len(candles) < 5: return {"error": "봉 부족", "summary": {}, "trades": [], "equity": [], "reasons": {}} body_min = float(cfg.get("body_drop_min_pct", DEFAULT_UPDOW_CONFIG["body_drop_min_pct"])) body_max = float(cfg.get("body_drop_max_pct", DEFAULT_UPDOW_CONFIG["body_drop_max_pct"])) tp_pct = float(cfg.get("tp_pct", DEFAULT_UPDOW_CONFIG["tp_pct"])) / 100.0 sl_pct = float(cfg.get("stop_loss_pct", DEFAULT_UPDOW_CONFIG["stop_loss_pct"])) / 100.0 atr_period = max(1, int(float(cfg.get("atr_period", DEFAULT_UPDOW_CONFIG["atr_period"])))) max_hold = int(cfg.get("max_hold_bars", DEFAULT_UPDOW_CONFIG["max_hold_bars"])) max_hold = max(1, max_hold) exit_green = float(cfg.get("exit_on_green", DEFAULT_UPDOW_CONFIG["exit_on_green"])) >= 0.5 slot_money = float(cfg.get("slot_money", DEFAULT_UPDOW_CONFIG["slot_money"])) regime_ma = int(float(cfg.get("regime_ma_bars", DEFAULT_UPDOW_CONFIG["regime_ma_bars"]))) regime_ease = clamp_regime_ma_ease_pct(cfg.get("regime_ma_ease_pct", DEFAULT_UPDOW_CONFIG["regime_ma_ease_pct"])) shoulder_min_high, shoulder_cut_pct = _updow_shoulder_ratios_from_cfg(cfg) pause_flags = precompute_regime_pause_buy_flags( candles, regime_candles or [], regime_ma, stock_tf_min, regime_ease, ) opens = [float(c["open"]) for c in candles] closes = [float(c["close"]) for c in candles] dts = [str(c["candle_date"]) for c in candles] atr_series = _compute_atr_series(candles, atr_period) position: Optional[Dict[str, Any]] = None trades: List[Dict[str, Any]] = [] equity: List[Dict[str, Any]] = [] cum_pnl = 0.0 def _signal(si: int) -> bool: o1 = opens[si] c1 = closes[si] if o1 <= 0 or c1 <= 0: return False if c1 >= o1: return False body_drop = (o1 - c1) / o1 * 100.0 if body_max > 0.0 and body_drop > body_max: return False return body_drop >= body_min for si in range(0, len(candles) - 2): i = si + 1 next_open = float(opens[i]) if opens[i] > 0 else closes[i] if next_open <= 0: continue if position is not None: ei = position["entry_i"] avg = position["avg"] qty = position["qty"] sell_res = _check_sell_updow_backtest_bar( position, candles[i], i, sl_pct_default=sl_pct, tp_pct_default=tp_pct, max_hold=max_hold, exit_green=exit_green, shoulder_min_high=shoulder_min_high, shoulder_cut_pct=shoulder_cut_pct, ) if sell_res: sell_reason, exit_price = sell_res fee = exit_price * qty * (fee_rate + sell_tax) pnl = (exit_price - avg) * qty - fee cum_pnl += pnl hold_b = i + 1 - ei trades.append({ "buy_date": dts[ei][:16], "sell_date": dts[i][:16], "avg_price": round(avg), "exit_price": round(exit_price), "qty": qty, "pnl": round(pnl), "hold_days": hold_b, "reason": sell_reason, }) equity.append({"date": dts[i][:16], "cum_pnl": round(cum_pnl)}) position = None continue if _signal(si): if pause_flags is not None and regime_ma >= 1 and si < len(pause_flags) and pause_flags[si]: continue entry_px = next_open entry_i = i if is_limit_atr_entry(updow_entry_mode(cfg)): lp_cfg = updow_limit_params(cfg) sig_bar = candles[si] anchor_px = resolve_limit_anchor_price( lp_cfg["anchor"], sig_bar, candles, si, ) atr_sig = atr_series[si] if si < len(atr_series) else None limit_px = compute_atr_limit_price(anchor_px, atr_sig, lp_cfg["mult"]) filled = False if limit_px > 0: for j in range(si + 1, min(si + 1 + lp_cfg["valid_bars"], len(candles))): fp = try_limit_fill_on_bar( candles[j], limit_px, lp_cfg["fill_slip_pct"], ) if fp and fp > 0: entry_px = fp entry_i = j filled = True break if not filled: continue invest = slot_money qty = max(1, int(invest / entry_px)) sl_eff, tp_eff, atr_used = _effective_exit_pcts(cfg, entry_px, atr_series[si]) position = { "avg": entry_px, "qty": qty, "entry_i": entry_i, "sl_pct": sl_eff, "tp_pct": tp_eff, "atr_entry": atr_used, "max_price": entry_px, } # 백테스트 마지막 시점까지도 포지션이 남아 있으면: 실제 장이 아니므로 "다음 봉 시가" 청산을 시뮬할 수 없음 # → 마지막 봉 **종가**로 강제 청산하고 사유를「기간종료」로 표기 (미결 포지션 정리용) if position is not None and len(candles) > 0: exit_price = closes[-1] avg = position["avg"] qty = position["qty"] ei = position["entry_i"] fee = exit_price * qty * (fee_rate + sell_tax) pnl = (exit_price - avg) * qty - fee cum_pnl += pnl trades.append({ "buy_date": dts[ei][:16], "sell_date": dts[-1][:16], "avg_price": round(avg), "exit_price": round(exit_price), "qty": qty, "pnl": round(pnl), "hold_days": len(candles) - 1 - ei, "reason": "기간종료", "sl_pct": round(float(position.get("sl_pct", sl_pct)) * 100.0, 3), "tp_pct": round(float(position.get("tp_pct", tp_pct)) * 100.0, 3), "atr_entry": round(float(position.get("atr_entry", 0.0)), 4), }) equity.append({"date": dts[-1][:16], "cum_pnl": round(cum_pnl)}) total = len(trades) wins = [t for t in trades if t["pnl"] > 0] losses = [t for t in trades if t["pnl"] < 0] total_pnl = sum(t["pnl"] for t in trades) avg_hold = sum(t["hold_days"] for t in trades) / total if total else 0.0 win_pnl = sum(t["pnl"] for t in wins) loss_pnl = sum(t["pnl"] for t in losses) pf = round(abs(win_pnl / loss_pnl), 2) if loss_pnl != 0 else 9999.0 peak, mdd, cum = 0.0, 0.0, 0.0 for t in trades: cum += t["pnl"] peak = max(peak, cum) mdd = max(mdd, peak - cum) reasons: Dict[str, int] = {} for t in trades: prefix = t["reason"].split("(")[0] reasons[prefix] = reasons.get(prefix, 0) + 1 c0 = closes[0] c1 = closes[-1] bnh_pct = round((c1 - c0) / c0 * 100, 2) if c0 > 0 else 0.0 bnh_pnl = round(slot_money * bnh_pct / 100) bot_pct = round(total_pnl / slot_money * 100, 2) if slot_money > 0 else 0.0 alpha_pct = round(bot_pct - bnh_pct, 2) return { "summary": { "total_trades": total, "win_trades": len(wins), "loss_trades": len(losses), "win_rate": round(len(wins) / total * 100, 1) if total else 0.0, "total_pnl": round(total_pnl), "avg_hold_days": round(avg_hold, 1), "profit_factor": round(min(pf, 9999.0), 2), "max_drawdown": round(mdd), "bnh_pct": bnh_pct, "bnh_pnl": bnh_pnl, "bot_pct": bot_pct, "alpha_pct": alpha_pct, "bnh_aligned_pct": bnh_pct, "bnh_aligned_pnl": bnh_pnl, "alpha_aligned_pct": alpha_pct, }, "equity": equity, "reasons": reasons, "trades": trades[-200:], } def run_param_search_updow( candles: List[Dict[str, Any]], grid: Optional[Dict[str, List[float]]] = None, min_trades: int = 1, base_cfg: Optional[Dict[str, Any]] = None, env_snapshot: Optional[Dict[str, Any]] = None, *, regime_candles: Optional[List[Dict[str, Any]]] = None, stock_tf_min: int = 60, max_combos: int = 0, ) -> Tuple[List[Dict[str, Any]], Dict[str, Any]]: """그리드 서치. ``env_snapshot`` 에 env_config 최신 스냅샷을 넘기면 그 값으로 그리드·베이스 고정.""" if grid is None: grid = default_param_grid(env_snapshot) keys = list(grid.keys()) combos = list(iproduct(*[grid[k] for k in keys])) cart_total = len(combos) combos, dropped_by_cap = _downsample_combos_uniform(combos, max_combos) _base = cfg_from_env_snapshot(env_snapshot) if base_cfg: for k, v in base_cfg.items(): if k in CFG_ENGINE_KEYS: try: _base[k] = float(v) except (TypeError, ValueError): pass results: List[Dict[str, Any]] = [] skip_low = 0 skip_err = 0 ran = 0 for vals in combos: cfg = dict(_base) cfg.update(dict(zip(keys, vals))) ran += 1 res = run_backtest_updow( candles, cfg, regime_candles=regime_candles, stock_tf_min=stock_tf_min, ) if res.get("error"): skip_err += 1 continue s = res.get("summary", {}) if min_trades > 0 and s.get("total_trades", 0) < min_trades: skip_low += 1 continue results.append({ "params": {k: cfg[k] for k in keys}, "apply_cfg": dict(cfg), "total_pnl": s["total_pnl"], "win_rate": s["win_rate"], "total_trades": s["total_trades"], "pf": s["profit_factor"], "avg_hold": s["avg_hold_days"], "mdd": s["max_drawdown"], }) results.sort(key=lambda x: x["total_pnl"], reverse=True) fixed_keys = sorted(set(CFG_ENGINE_KEYS) - set(keys)) meta = { "grid_keys": keys, "fixed_param_keys": fixed_keys, "cartesian_product": cart_total, "sampled_backtests": len(combos), "dropped_by_combo_cap": dropped_by_cap, "max_combos": int(max_combos), "backtests_run": ran, "skipped_backtest_error": skip_err, "skipped_below_min_trades": skip_low, "min_trades": min_trades, "passed": len(results), "grid_axis_hints": {k: UPDOW_GRID_AXIS_HINTS_KO.get(k, k) for k in keys}, } return results, meta def read_updow_tf_min(snap: Optional[Dict[str, Any]] = None) -> int: """웹·CLI 기본 분봉 — ``UPDOW_TF_MIN`` (env_config 또는 get_env_int 폴백).""" return _read_snap_int(snap, "UPDOW_TF_MIN", 60) def read_updow_total_budget_krw(snap: Optional[Dict[str, Any]] = None) -> int: """ 실매 UPDOW **전략 총 운용 한도(원)** — 동시 보유 매입금 합이 이 값을 넘지 않음. ``UPDOW_MAX_BUY_AMOUNT`` → ``MAX_BUY_AMOUNT_PER_STOCK`` 폴백. 0 이면 한도 없음. (1회 주문 상한이 아님 — 1회 크기는 ``UPDOW_SLOT_MONEY`` / 종목 slot_money) """ cap = _read_snap_int(snap, "UPDOW_MAX_BUY_AMOUNT", 0) if cap <= 0: cap = _read_snap_int(snap, "MAX_BUY_AMOUNT_PER_STOCK", 0) return int(cap) def read_updow_max_buy_krw(snap: Optional[Dict[str, Any]] = None) -> int: """하위 호환 — ``read_updow_total_budget_krw`` 와 동일 (총 운용 한도).""" return read_updow_total_budget_krw(snap) def read_updow_max_stocks(snap: Optional[Dict[str, Any]] = None) -> int: """UPDOW 동시 보유 종목 수 — ``UPDOW_MAX_STOCKS`` → ``MAX_STOCKS``.""" n = _read_snap_int(snap, "UPDOW_MAX_STOCKS", 0) if n > 0: return n return _read_snap_int(snap, "MAX_STOCKS", 3) def _norm_candle_time_key(c: Dict[str, Any]) -> str: """WS/DB 혼용 candle_time·candle_date·candle_time_str → YYYYMMDDHHMM 비교용.""" ct = c.get("candle_time") or c.get("candle_time_str") or c.get("candle_date") or "" s = str(ct).strip() if not s: return "" if len(s) >= 19 and (" " in s or "-" in s[:5]): return s.replace("-", "").replace(" ", "").replace(":", "")[:12] digits = "".join(ch for ch in s if ch.isdigit()) return digits[:12] if digits else s def _signal_bar_updow( candle: Dict[str, Any], body_drop_min_pct: float, body_drop_max_pct: float = 0.0, ) -> Tuple[bool, float]: """직전 확정봉이 음봉이며 몸통 하락률(%)이 하한 이상·(상한>0이면)상한 이하면 True.""" try: o1 = float(candle.get("open", 0) or 0) c1 = float(candle.get("close", 0) or 0) except (TypeError, ValueError): return False, 0.0 if o1 <= 0 or c1 <= 0: return False, 0.0 if c1 >= o1: return False, 0.0 body_drop = (o1 - c1) / o1 * 100.0 # 상한(>0)을 넘는 과도 폭락은 악재성 칼날잡기로 보고 제외 (0=OFF) if float(body_drop_max_pct) > 0.0 and body_drop > float(body_drop_max_pct): return False, body_drop return body_drop >= float(body_drop_min_pct), body_drop def check_buy_signal_updow_live( candles: List[Dict[str, Any]], cfg: Dict[str, Any], *, last_fired_entry_key: Optional[str] = None, ) -> Tuple[Optional[str], str, Optional[Dict[str, Any]]]: """ 라이브 매수 판정 (``run_backtest_updow`` 와 동일 규칙). - 신호봉: 확정봉 중 직전 봉(``candles[-2]``) — 음봉 + 몸통 하락률 ≥ ``body_drop_min_pct`` - 진입가: 최신 확정봉(``candles[-1]``) 시가 (백테의 '다음 봉 시가'에 대응) Returns: ``(reject_code, message, signal_or_none)`` — ``reject_code`` 가 None 이면 통과. """ min_need = get_env_int("UPDOW_LIVE_MIN_CANDLES", 5) if len(candles) < min_need: return ("탈락-봉부족", f"확정봉 {len(candles)}개 (권장 최소 {min_need})", None) if len(candles) < 2: return ("탈락-봉부족", "신호·진입 봉을 나누려면 확정봉 2개 이상 필요", None) sig_bar = candles[-2] 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) body_min = float(cfg.get("body_drop_min_pct", DEFAULT_UPDOW_CONFIG["body_drop_min_pct"])) body_max = float(cfg.get("body_drop_max_pct", DEFAULT_UPDOW_CONFIG["body_drop_max_pct"])) ok_sig, body_drop = _signal_bar_updow(sig_bar, body_min, body_max) if not ok_sig: sk = _norm_candle_time_key(sig_bar) return ( "탈락-비신호봉", f"직전봉({sk}) 음봉·몸통하락 미달/초과 " f"(하락률 {body_drop:.3f}% / 하한 {body_min}% · 상한 {body_max or 'OFF'})", None, ) atr_period = max(1, int(float(cfg.get("atr_period", DEFAULT_UPDOW_CONFIG["atr_period"])))) atr_series = _compute_atr_series(candles, atr_period) sig_i = len(candles) - 2 atr_sig = atr_series[sig_i] if sig_i < len(atr_series) else None if is_limit_atr_entry(updow_entry_mode(cfg)): lp_cfg = updow_limit_params(cfg) anchor_px = resolve_limit_anchor_price( lp_cfg["anchor"], sig_bar, candles, sig_i, ) min_px = float(cfg.get("min_price", get_env_float("MIN_STOCK_PRICE", 1000.0))) limit_px = compute_atr_limit_price( anchor_px, atr_sig, lp_cfg["mult"], min_price=min_px, ) limit_int = floor_limit_price_krw(limit_px) if limit_int <= 0: return ("탈락-지정가", "ATR 지정가 산출 실패(가격·ATR)", None) entry_px = float(limit_int) valid_until = limit_valid_until_bar_key(candles, sig_i, lp_cfg["valid_bars"]) sl_eff, tp_eff, atr_used = _effective_exit_pcts(cfg, entry_px, atr_sig) sig = { "entry_price": entry_px, "updow_entry_bar_key": ent_key, "signal_candle_key": _norm_candle_time_key(sig_bar), "body_drop_pct": body_drop, "stop_price": entry_px * (1.0 - sl_eff), "target_price": entry_px * (1.0 + tp_eff), "sl_pct": sl_eff * 100.0, "tp_pct": tp_eff * 100.0, "atr_entry": atr_used, "use_limit_buy": True, "valid_until_bar_key": valid_until, "entry_mode": "limit_atr", } return (None, f"직전봉 하락 → ATR 지정가 {limit_int:,}원 (유효~{valid_until})", sig) try: entry_open = float(ent_bar.get("open", 0) or 0) except (TypeError, ValueError): entry_open = 0.0 if entry_open <= 0: return ("탈락-시가없음", "진입봉 시가가 0 이하", None) sl_eff, tp_eff, atr_used = _effective_exit_pcts(cfg, entry_open, atr_sig) sig = { "entry_price": entry_open, "updow_entry_bar_key": ent_key, "signal_candle_key": _norm_candle_time_key(sig_bar), "body_drop_pct": body_drop, "stop_price": entry_open * (1.0 - sl_eff), "target_price": entry_open * (1.0 + tp_eff), "sl_pct": sl_eff * 100.0, "tp_pct": tp_eff * 100.0, "atr_entry": atr_used, } return (None, "직전봉 하락 신호 → 최신봉 시가 진입", sig) def estimate_updow_entry_bar_index( candles: List[Dict[str, Any]], entry_bar_key: str, buy_time_str: str, ) -> int: """ 진입봉 키로 인덱스를 찾고, 없으면 buy_time 기준으로 근사(봇 재시작 등). 못 찾으면 -1. """ if not candles: return -1 want = _norm_candle_time_key({"candle_time": entry_bar_key}) if want: for i, c in enumerate(candles): if _norm_candle_time_key(c) == want: return i # buy_time "YYYY-MM-DD HH:MM:SS" → 분 단위 비교 bts = (buy_time_str or "").strip().replace("-", "").replace(":", "").replace(" ", "") b12 = "".join(ch for ch in bts if ch.isdigit())[:12] if len(b12) >= 12: for i, c in enumerate(candles): ck = _norm_candle_time_key(c) if ck and ck >= b12[:12]: return i return -1 def check_sell_signal_updow_live( *, buy_price: float, candles: List[Dict[str, Any]], cfg: Dict[str, Any], entry_bar_key: str, buy_time_str: str, current_price: float, stop_price: float = 0.0, target_price: float = 0.0, max_price: float = 0.0, ) -> Optional[Tuple[str, float]]: """ 라이브 매도 판정 (백테와 동일 V4 우선순위). 1순위 어깨컷 → 2순위 익절% → 3순위 손절% → (옵션) 양봉 청산 → 최대 보유 봉. ``current_price`` 는 WS 현재가 등으로 청산 주문 참고가에 사용. ``max_price`` 는 보유 중 고점 추적(호출 측 holding에 저장 후 재전달). """ if buy_price <= 0 or not candles: return None tp_pct = float(cfg.get("tp_pct", DEFAULT_UPDOW_CONFIG["tp_pct"])) / 100.0 sl_pct = float(cfg.get("stop_loss_pct", DEFAULT_UPDOW_CONFIG["stop_loss_pct"])) / 100.0 # 진입 시점에 고정된 손절/익절 가격이 있으면 우선 사용 (ATR 동적 값 유지) exit_floor_dec = max(0.000001, get_env_float("UPDOW_EXIT_PCT_FLOOR", 0.01) / 100.0) if stop_price > 0 and buy_price > 0: sl_pct = max(exit_floor_dec, (buy_price - stop_price) / buy_price) if target_price > 0 and buy_price > 0: tp_pct = max(exit_floor_dec, (target_price - buy_price) / buy_price) max_hold = int(float(cfg.get("max_hold_bars", DEFAULT_UPDOW_CONFIG["max_hold_bars"]))) max_hold = max(1, max_hold) exit_green = float(cfg.get("exit_on_green", DEFAULT_UPDOW_CONFIG["exit_on_green"])) >= 0.5 shoulder_min_high, shoulder_cut_pct = _updow_shoulder_ratios_from_cfg(cfg) last = candles[-1] try: c_now = float(last.get("close", 0) or 0) o_now = float(last.get("open", 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 = float(max_price) if max_price > 0 else buy_price mp = max(mp, h_now, px) j = estimate_updow_entry_bar_index(candles, entry_bar_key, buy_time_str) last_idx = len(candles) - 1 # 백테: 진입 봉(i)에서는 청산 루프를 돌지 않고, 다음 봉(i+1)부터 평가. if j >= 0 and last_idx <= j: return None entry_unknown = j < 0 if entry_unknown: bars_held = 0 else: bars_held = last_idx - j v4_res, _ = _eval_updow_exit_v4_at_price( buy_price, mp, px, sl_pct, tp_pct, shoulder_min_high, shoulder_cut_pct, ) sell_reason: Optional[str] = None exit_px = px if v4_res: sell_reason, exit_px = v4_res elif (not entry_unknown) and exit_green and c_now > o_now and o_now > 0: sell_reason = "양봉청산" exit_px = c_now elif (not entry_unknown) and bars_held >= max_hold and bars_held > 0: sell_reason = f"보유한도({max_hold}봉)" exit_px = c_now if not sell_reason: return None return (sell_reason, float(exit_px)) __all__ = [ "CFG_ENGINE_KEYS", "DEFAULT_UPDOW_CONFIG", "UPDOW_GRID_AXIS_HINTS_KO", "cfg_from_env_snapshot", "default_param_grid", "default_param_grid_web_fast", "default_param_grid_us", "default_param_grid_us_fast", "env_snapshot_patch_from_engine_cfg", "read_updow_tf_min", "run_backtest_updow", "run_param_search_updow", "precompute_regime_pause_buy_flags", "kospi_proxy_regime_blocks_new_buy_from_closes", "kospi_proxy_regime_block_state", "clamp_regime_ma_ease_pct", "check_buy_signal_updow_live", "check_sell_signal_updow_live", "estimate_updow_entry_bar_index", "_norm_candle_time_key", ]