변경 사항 ---- - _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>
1384 lines
57 KiB
Python
1384 lines
57 KiB
Python
"""
|
||
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 이면 기존과 동일(종가<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",
|
||
# 종목별 지정가 깊이(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. "
|
||
"양수일 때 종가<SMA×(1−ease/100)이면 신규 매수만 일시 정지, 그 위로 올라오면 재개(백테·실매 동일)"
|
||
),
|
||
"regime_ma_ease_pct": (
|
||
"레짐 MA 완화(%) — 0이면 종가<SMA일 때만 차단. "
|
||
"양수이면 SMA를 (1−ease/100)배로 낮춘 기준선 아래로 더 내려가야 차단(그리드·env로 탐색)"
|
||
),
|
||
"shoulder_min_high_pct": (
|
||
"어깨컷 발동(%) — 매수가 대비 고가가 이 % 이상 올라가면 트레일링 어깨컷 무장"
|
||
),
|
||
"shoulder_cut_pct": (
|
||
"어깨컷 폭(%) — 무장 후 고점 대비 이 % 되돌림 시 1순위 청산(모멘텀·스캘핑 V4와 동일 개념)"
|
||
),
|
||
"limit_atr_mult": (
|
||
"ATR 지정가 깊이(anchor−ATR×배수) — 클수록 체결↓·진입가↓ (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 {
|
||
# 지정가 깊이(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 이면 기존과 동일(종가<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",
|
||
]
|