Files
kis_bot/kis_trader/strategies/updow_buy.py
Hwang 61c72a8a4c 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>
2026-07-06 01:27:00 +09:00

1384 lines
57 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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×(1ease/100)** 이면(``ease``=``regime_ma_ease_pct``) 코스피 프록시 **하락세**로 보고 **신규 매수만** 스킵.
``ease``=0 이면 기존과 동일(종가<SMA). ``ease``>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",
# 종목별 지정가 깊이(anchorATR×배수) — 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. "
"양수일 때 종가<SMA×(1ease/100)이면 신규 매수만 일시 정지, 그 위로 올라오면 재개(백테·실매 동일)"
),
"regime_ma_ease_pct": (
"레짐 MA 완화(%) — 0이면 종가<SMA일 때만 차단. "
"양수이면 SMA를 (1ease/100)배로 낮춘 기준선 아래로 더 내려가야 차단(그리드·env로 탐색)"
),
"shoulder_min_high_pct": (
"어깨컷 발동(%) — 매수가 대비 고가가 이 % 이상 올라가면 트레일링 어깨컷 무장"
),
"shoulder_cut_pct": (
"어깨컷 폭(%) — 무장 후 고점 대비 이 % 되돌림 시 1순위 청산(모멘텀·스캘핑 V4와 동일 개념)"
),
"limit_atr_mult": (
"ATR 지정가 깊이(anchorATR×배수) — 클수록 체결↓·진입가↓ (limit_atr 모드, env 고정)"
),
}
def _grid_limit_atr_mult_values(snap: Optional[Dict[str, Any]] = None) -> 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 {
# 지정가 깊이(anchorATR×배수) — 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×(1ease/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×(1ease/100) 이면 True.
``ease_pct``=0 이면 기존과 동일(종가<SMA). 데이터 부족 시 False (차단 안 함).
"""
st = kospi_proxy_regime_block_state(closes, ma_bars, ease_pct)
return bool(st and st[0])
def _compute_atr_series(candles: List[Dict[str, Any]], period: int) -> 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",
]