Files
kis_bot/kis_trader/backtest/param_search_momentum.py
2026-07-30 18:05:07 +09:00

2546 lines
125 KiB
Python
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.
#!/usr/bin/env python3
"""
kis_trader/backtest/param_search_momentum.py — 모멘텀 백테스트 파라미터 자동 탐색 (Grid Search)
=======================================================================================================
[전략 = MOMENTUM A안 — HTS ``momentum`` (E∧F∧H∧I) 일봉 SCAN + E 정합 TRIGGER]
- SCAN: 키움 ``momentum`` 조건검색 → ``target_candidates_history``
- TRIGGER: ``momentum_hts_logic`` — 전일시가(E) 유지 + 양봉·거래량 펄스
- 청산: ``check_sell_signal_momentum_live`` — 래칫·어깨·트레일·손절·시간컷
- ``--mode exit``: 진입 DB 고정 × 청산(어깨·래칫·트레일·SL·TP) 광범위 그리드
- 백테·파서치: ``momentum_backtest_common.run_momentum_backtest_web_aligned``
→ ``momentum_engine.run_momentum_backtest`` → ``check_sell_signal_momentum_backtest_bar``
[관련 CLI (전략별 파일 분리)]
SCALP(Reversal): kis_trader/backtest/param_search_scalping.py
MOMENTUM : kis_trader/backtest/param_search_momentum.py ← 이 파일
BREAKOUT : kis_trader/backtest/param_search_breakout.py
SHORT(꼬리잡기) : kis_trader/backtest/tail_param_search.py
통합 적용 CLI : kis_trader/backtest/param_search_apply_snapshot.py (--json · --rank)
실행:
cd /home/hoon/kis_bot
python3 kis_trader/backtest/param_search_momentum.py --start 2026-05-11 --end 2026-05-27
python3 -m kis_trader.backtest.param_search_momentum --mode fast --top 30
옵션:
--start 시작일 (기본: 오늘-7일)
--end 종료일 (기본: 오늘)
--mode 탐색 모드: fast(기본) / rr / coarse / fine / full
--top 상위 N개 출력·JSON 저장 (기본: 1000)
--min_trades 최소 거래 건수 필터 (기본: 1)
--apply N번째 결과를 DB(MOMENTUM_* env)에 자동 적용 (총손익 > 0 일 때만)
--fallback-universe 저장 이력 무시, 시뮬 유니버스만 사용
⚠️ 모멘텀 백테는 ``momentum_engine`` 매수 루프 사용 (SCALP reversal high_chase 등 미적용).
방어필터는 MOMENTUM_USE_* / 시가·일변동 컷만 반영.
⚠️ 2026-05 이전 JSON 은 워커 힙 병합 버그로 순위 불일치 가능 — 동일 기간 재탐색 권장.
"""
import sys, os, json, time, argparse, signal
import heapq
from datetime import datetime, timedelta
from itertools import product
from concurrent.futures import as_completed
from typing import Optional, List, Dict, Any, Tuple
# fast: V2 TRIGGER 축 → MOMENTUM_FAST_MAX_COMBOS(기본 200) 균등샘플 (~1015분·이력유니버스 기준)
DEFAULT_MAX_COMBOS = 5000 # coarse/fine 상한 (env MOMENTUM_PARAM_SEARCH_MAX_COMBOS)
# kis_bot 루트를 경로에 추가 (스크립트·패키지 실행 모두 대응)
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(os.path.dirname(HERE))
if ROOT not in sys.path:
sys.path.insert(0, ROOT)
if HERE not in sys.path:
sys.path.insert(0, HERE)
import logging
logging.getLogger("TradeDB").setLevel(logging.WARNING)
from database import TradeDB
from kis_trader.engine import momentum_engine as me
from kis_trader.engine.momentum_hts_logic import resolve_momentum_skip_hts_scan_dupes
from kis_trader.engine.indicator_cache import attach_indicator_caches_to_params
from kis_trader.backtest import momentum_backtest_common as mbc
from kis_trader.backtest import scalping_backtest_common as sbc
from kis_trader.backtest.backtest_portfolio_common import (
merge_param_search_apply_source,
strip_portfolio_keys_from_apply_patch,
session_env_patch,
)
from kis_trader.backtest.param_search_cli_common import (
add_portfolio_cli_args,
add_search_filter_cli_args,
apply_session_to_fixed,
combo_passes_search_filters,
format_session_hm,
search_json_meta,
)
from kis_trader.backtest.param_search_pool import (
ParamSearchSharedPayload,
ParamSearchProgressETA,
assert_parent_alive,
cap_combos_uniform,
cap_combos_uniform_lazy,
grid_total_combinations,
iter_pool_chunk_results,
managed_process_pool,
param_search_chunk_plan,
param_search_worker_budget_line,
try_acquire_run_lock,
worker_shared_get,
)
from kis_trader.strategies.breakout import normalize_breakout_max_loss_krw # noqa: E402
from kis_trader.utils.env import ( # noqa: E402 — DB/os 폴백, 하드코딩 금지 원칙
get_env_bool,
get_env_float,
get_env_from_db,
get_env_int,
)
def _parse_csv_floats(env_key: str, fallback: List[float]) -> List[float]:
"""env 에 콤마 구분 목록이 있으면 사용 (예: `1.0,1.5,2.0`). 없으면 fallback."""
raw = str(get_env_from_db(env_key, "") or "").strip()
if not raw:
return list(fallback)
out: List[float] = []
for part in raw.split(","):
part = part.strip()
if not part:
continue
try:
out.append(float(part))
except ValueError:
continue
return out if out else list(fallback)
def _parse_csv_ints(env_key: str, fallback: List[int]) -> List[int]:
raw = str(get_env_from_db(env_key, "") or "").strip()
if not raw:
return list(fallback)
out: List[int] = []
for part in raw.split(","):
part = part.strip()
if not part:
continue
try:
out.append(int(float(part)))
except ValueError:
continue
return out if out else list(fallback)
def _parse_csv_strings(env_key: str, fallback: List[str], sep: str = "|") -> List[str]:
"""문자열 축(래칫 티어 등) — env 는 ``|`` 구분, 빈 토큰은 OFF."""
raw = get_env_from_db(env_key, "")
if not raw:
return list(fallback)
s = str(raw).strip()
if sep in s:
parts = [p.strip() for p in s.split(sep)]
else:
parts = [p.strip() for p in s.split(",")]
if not parts:
return list(fallback)
return parts
def _parse_csv_bools(env_key: str, fallback: List[bool]) -> List[bool]:
raw = str(get_env_from_db(env_key, "") or "").strip()
if not raw:
return list(fallback)
out: List[bool] = []
for part in raw.replace("|", ",").split(","):
part = part.strip().lower()
if not part:
continue
out.append(part in ("1", "true", "t", "y", "yes", "on"))
return out if out else list(fallback)
# ──────────────────────────────────────────────────────────────────────────────
# 모멘텀 기본값 (DB 우선, 없으면 코드 default)
# ──────────────────────────────────────────────────────────────────────────────
def _rr_search_base_from_env() -> Dict[str, float]:
"""``MOMENTUM_RR_SEARCH_BASE_JSON`` — R:R 스윕 1위 청산축 (UI % 단위)."""
import json as _json
raw = str(os.environ.get("MOMENTUM_RR_SEARCH_BASE_JSON") or "").strip()
if not raw:
raw = str(get_env_from_db("MOMENTUM_RR_SEARCH_BASE_JSON", "") or "").strip()
if not raw:
return {}
try:
data = _json.loads(raw)
if isinstance(data, dict):
return {k: float(v) for k, v in data.items()}
except (TypeError, ValueError, _json.JSONDecodeError):
pass
return {}
def _mom_fixed_defaults(market: str = "KR") -> Dict[str, Any]:
"""그리드에 안 넣은 축만 채움 + 각 축의 폴백값.
- 숫자 기본값은 전부 ``get_env_*`` / DB ``_pick`` 으로만 둠 (리터럴 하드코딩 금지).
- 트레일·쿨다운·일일한도·방어·슬롯 등은 coarse/fine/full 그리드가 덮어씀 (폴백은 JSON 재현용).
- ``MOMENTUM_RR_SEARCH_BASE_JSON`` 이 있으면 청산 축 폴백을 R:R 스윕 1위로 맞춤.
- market=US 이면 ``US_MOMENTUM_*``(config_us_momentum) 로 실매 앵커 덮어씀.
"""
rr_base = _rr_search_base_from_env()
_d = me.get_momentum_defaults_from_db()
from kis_trader.utils.env import get_strategy_env_dict
env = get_strategy_env_dict("MOMENTUM")
def _pick(*keys, default=None, cast=float):
for k in keys:
v = env.get(k)
if v not in (None, "", "None"):
try:
return cast(v)
except (ValueError, TypeError):
continue
return default
_fee_r = float(_d.get("fee_rate", 0.00015))
_tax_r = float(_d.get("sell_tax", 0.0018))
out = {
"rsi_period": get_env_int("MOMENTUM_SEARCH_RSI_PERIOD", int(_d.get("rsi_period", 3))),
# 슬롯: 탐색 시 max_loss/sl 로 _ui_to_engine_params 가 재계산. 폴백만 env/DB.
"slot_money": float(_pick(
"MOMENTUM_SLOT_MONEY", "SLOT_MONEY_DEFAULT",
default=get_env_int("MOMENTUM_SEARCH_SLOT_MONEY_KRW", int(_d.get("slot_money", 3_000_000))),
cast=lambda v: float(v),
)),
"vol_mult": get_env_float("MOMENTUM_SEARCH_VOL_MULT", 0.0),
# 아래는 그리드에 해당 키가 없을 때만 쓰는 폴백 (모든 모드에서 키가 있으면 조합값이 덮어씀)
"shoulder_min_high": float(rr_base.get(
"shoulder_min_high",
float(_d.get("shoulder_min_high", 0.005)) * 100.0,
)),
"shoulder_cut_pct": float(rr_base.get(
"shoulder_cut_pct",
float(_d.get("shoulder_cut_pct", 0.003)) * 100.0,
)),
"tp_max_pct": float(rr_base.get(
"tp_max_pct",
float(_d.get("tp_max_pct", 0.02)) * 100.0,
)),
"sl_pct": float(rr_base.get("sl_pct", float(_d.get("sl_pct", 0.02)) * 100.0)),
"tp_pct": float(rr_base.get("tp_pct", float(_d.get("tp_pct", 0.02)) * 100.0)),
# 모멘텀 전용 트레일(UI%) — momentum_hts_logic 의 trail_pct/arm (0=OFF)
"trail_pct": float(rr_base.get(
"trail_pct",
float(_d.get("trail_pct", 0.0)) * 100.0,
)),
"trail_arm_pct": float(rr_base.get(
"trail_arm_pct",
float(_d.get("trail_arm_pct", 0.0)) * 100.0,
)),
"cooldown_min": float(
_d.get("cooldown_min")
if _d.get("cooldown_min") is not None
else get_env_int("MOMENTUM_SEARCH_COOLDOWN_MIN", 1)
),
"time_start_hm": int(_pick(
"MOMENTUM_TIME_START", "SCALP_TIME_START", "TIME_START",
default=get_env_int("MOMENTUM_SEARCH_TIME_START_HM", int(_d.get("time_start_hm", 900))),
cast=lambda v: int(float(v)),
)),
"time_end_hm": int(_pick(
"MOMENTUM_TIME_END", "SCALP_TIME_END", "TIME_END",
default=get_env_int("MOMENTUM_SEARCH_TIME_END_HM", int(_d.get("time_end_hm", 1530))),
cast=lambda v: int(float(v)),
)),
# 실매 앵커 = config_momentum (SEARCH_* 단독 기본값 금지 — fine 그리드 누락 방지)
"max_daily": float(int(float(_d.get("max_daily") or get_env_int("MOMENTUM_SEARCH_MAX_DAILY", 10)))),
"fee_rate": _fee_r * 100.0,
"sell_tax": _tax_r * 100.0,
"high_chase_thr": float(_d.get("high_chase_thr") or get_env_float("MOMENTUM_SEARCH_HIGH_CHASE_THR", 0.99)),
"max_daily_chg": float(_d.get("max_daily_chg") or get_env_float("MOMENTUM_SEARCH_MAX_DAILY_CHG_PCT", 50.0)),
"min_price": int(_pick(
"MOMENTUM_MIN_PRICE", "MIN_STOCK_PRICE",
default=get_env_float("MOMENTUM_SEARCH_MIN_PRICE_KRW", float(_d.get("min_price", 1000))),
cast=lambda v: int(float(v)),
)),
# 99M 등 OFF 값 → 20만 정규화 (breakout·웹 백테와 동일, 포지션 부풀림 방지)
"max_loss_krw": float(normalize_breakout_max_loss_krw(_pick(
"MOMENTUM_MAX_LOSS_PER_TRADE_KRW", "MAX_LOSS_PER_TRADE_KRW",
default=get_env_int("MOMENTUM_SEARCH_MAX_LOSS_KRW", 200_000),
cast=lambda v: int(float(v)),
))),
# 탐색 기본 0.2% — DB MOMENTUM_MIN_PROFIT_PCT=10 은 TP(3~7%)와 어긋나 본절선이 비현실적
"min_margin": get_env_float("MOMENTUM_SEARCH_MIN_MARGIN_PCT", 0.2),
"use_defense_filters": get_env_bool(
"MOMENTUM_SEARCH_USE_DEFENSE",
bool(_d.get("use_defense_filters", True)),
),
"mom_vol_win": float(get_env_int(
"MOMENTUM_SEARCH_VOL_WIN",
int(_pick("MOMENTUM_VOL_WIN", default=5, cast=lambda v: int(float(v)))),
)),
"mom_time_end_hm": int(_pick(
"MOMENTUM_TIME_END_HM",
default=get_env_int("MOMENTUM_SEARCH_TIME_END_BUY_HM", 1530),
cast=lambda v: int(float(v)),
)),
"mom_min_from_open_pct": get_env_float("MOMENTUM_SEARCH_MIN_FROM_OPEN_PCT", -999.0),
"mom_max_from_open_pct": float(_d.get("mom_max_from_open_pct", 999.0)),
"pattern_breakout": bool(_d.get("pattern_breakout", True)),
"pattern_pullback": bool(_d.get("pattern_pullback", True)),
"chase_lookback_min": int(
_d.get("chase_lookback_min")
if _d.get("chase_lookback_min") is not None
else get_env_int("MOMENTUM_CHASE_LOOKBACK_MIN", 10)
),
"pullback_lookback_min": int(
_d.get("pullback_lookback_min")
if _d.get("pullback_lookback_min") is not None
else get_env_int("MOMENTUM_PULLBACK_LOOKBACK_MIN", 15)
),
"pullback_min_pct": float(
_d.get("pullback_min_pct")
if _d.get("pullback_min_pct") is not None
else get_env_float("MOMENTUM_PULLBACK_MIN_PCT", 0.3)
),
"pullback_max_pct": float(
_d.get("pullback_max_pct")
if _d.get("pullback_max_pct") is not None
else get_env_float("MOMENTUM_PULLBACK_MAX_PCT", 3.0)
),
"setup_vol_max_mult": float(
_d.get("setup_vol_max_mult")
if _d.get("setup_vol_max_mult") is not None
else get_env_float("MOMENTUM_SETUP_VOL_MAX_MULT", 0.8)
),
"setup_bear_bars_min": int(
_d.get("setup_bear_bars_min")
if _d.get("setup_bear_bars_min") is not None
else get_env_int("MOMENTUM_SETUP_BEAR_BARS_MIN", 1)
),
"use_rsi_max_filter": bool(_d.get("use_rsi_max_filter", False)),
"use_ema_filter": bool(_d.get("use_ema_filter", True)),
"use_high_chase_filter": bool(_d.get("use_high_chase_filter", False)),
"use_daily_range_filter": bool(_d.get("use_daily_range_filter", False)),
"ema_fast_period": int(_d.get("ema_fast_period", 9)),
"ema_slow_period": int(_d.get("ema_slow_period", 21)),
"eod_enabled": bool(_d.get("eod_enabled", True)),
"eod_hm": str(_d.get("eod_hm") or "15:25").strip() or "15:25",
"skip_hts_scan_dupes": False, # Optuna/그리드: false 고정 (True 스윕·universe 폴백 금지)
# HTS E(전일시가) TRIGGER — 실매 ``MOMENTUM_TRIGGER_E_CONFIRM`` 과 동일 키.
# 전일 1분봉이 DB에 없으면 E가 전원 탈락 → Optuna -1e18. 그때만 env=0 으로 탐색.
"trigger_e_confirm": get_env_bool(
"MOMENTUM_TRIGGER_E_CONFIRM",
bool(_d.get("trigger_e_confirm", True)),
),
"trigger_require_bull_bar": get_env_bool(
"MOMENTUM_TRIGGER_REQUIRE_BULL_BAR",
bool(_d.get("trigger_require_bull_bar", True)),
),
# 래칫(이익구간 손절선 상향) — 빈칸=OFF. 실매 MOMENTUM_RATCHET_TIERS 앵커.
"ratchet_tiers": str(_pick(
"MOMENTUM_RATCHET_TIERS",
default=str(_d.get("ratchet_tiers") or ""),
cast=lambda v: str(v or "").strip(),
) or "").strip(),
}
# mom_rsi / mom_vol — get_momentum_defaults 에 있으면 포함 (그리드 앵커)
if _d.get("mom_rsi_min") is not None:
out["mom_rsi_min"] = float(_d["mom_rsi_min"])
if _d.get("mom_rsi_max") is not None:
out["mom_rsi_max"] = float(_d["mom_rsi_max"])
if _d.get("mom_vol_mult") is not None:
out["mom_vol_mult"] = float(_d["mom_vol_mult"])
if _d.get("max_hold_bars") is not None:
out["max_hold_bars"] = int(float(_d["max_hold_bars"]))
if str(market or "KR").strip().upper() == "US":
out = _overlay_us_momentum_ui_fixed(out)
return out
def _overlay_us_momentum_ui_fixed(base: Dict[str, Any]) -> Dict[str, Any]:
"""국내 UI fixed 위에 config_us_momentum(US_MOMENTUM_*) 실매 앵커 덮어씀."""
out = dict(base or {})
from kis_trader.utils.env import get_strategy_env_dict, get_env_bool, get_env_float, get_env_int
env = get_strategy_env_dict("US_MOMENTUM")
def _pick(*keys, default=None, cast=float):
for k in keys:
v = env.get(k)
if v not in (None, "", "None"):
try:
return cast(v)
except (ValueError, TypeError):
continue
return default
def _pct_ui(key: str, default_ui: float) -> float:
raw = env.get(key)
if raw in (None, "", "None"):
return float(default_ui)
try:
x = abs(float(raw))
except (TypeError, ValueError):
return float(default_ui)
if x == 0:
return 0.0
return x if x >= 0.5 else round(x * 100.0, 6)
def _bool(key: str, default: bool) -> bool:
v = env.get(key)
if v in (None, "", "None"):
return bool(default)
return str(v).strip().lower() in ("1", "true", "t", "y", "yes", "on")
# 수수료·SEC·환전 — UI% 단위(fee/tax는 *100). fx는 엔진 비율 그대로.
try:
from kis_trader.engine.us_momentum_env_keys import us_momentum_trading_cost_rates
_c = us_momentum_trading_cost_rates()
out["fee_rate"] = float(_c["fee_rate"]) * 100.0
out["sell_tax"] = float(_c["sell_tax"]) * 100.0
out["fx_fee_rate"] = float(_c["fx_fee_rate"])
except Exception:
out["fee_rate"] = float(get_env_float("US_MOMENTUM_FEE_RATE", 0.0025)) * 100.0
out["sell_tax"] = float(get_env_float("US_MOMENTUM_SELL_TAX", 0.0000206)) * 100.0
out["fx_fee_rate"] = float(get_env_float("US_MOMENTUM_FX_FEE_RATE", 0.0005))
out["time_start_hm"] = int(_pick("US_MOMENTUM_TIME_START", default=2230, cast=lambda v: int(float(v))))
out["time_end_hm"] = int(_pick("US_MOMENTUM_TIME_END", default=500, cast=lambda v: int(float(v))))
out["mom_time_end_hm"] = out["time_end_hm"]
out["slot_money"] = float(_pick("US_MOMENTUM_SLOT_MONEY", "US_MOMENTUM_MAX_BUY_AMOUNT", default=out.get("slot_money", 200000), cast=float))
# USD 최소가 — 국내 1000원 잔존 금지. 기본 $1 (천달러 필터 아님)
_mp = _pick("US_MOMENTUM_MIN_PRICE", default=None, cast=float)
out["min_price"] = float(_mp) if _mp is not None else 1.0
out["max_daily"] = float(int(_pick("US_MOMENTUM_MAX_DAILY", default=int(out.get("max_daily") or 5), cast=lambda v: int(float(v)))))
cd = _pick("US_MOMENTUM_COOLDOWN_SEC", default=None, cast=lambda v: int(float(v)))
if cd is not None and cd >= 0:
out["cooldown_min"] = float(cd) / 60.0
# 고정 유니버스 — Optuna/TPE 에서 금액(슬롯·손절액) 탐색/연동 안 함. DB 슬롯만 사용.
out["max_loss_krw"] = 0.0
out["sl_pct"] = _pct_ui("US_MOMENTUM_STOP_LOSS_PCT", float(out.get("sl_pct") or 1.5))
out["tp_pct"] = _pct_ui("US_MOMENTUM_TAKE_PROFIT_PCT", float(out.get("tp_pct") or 2.5))
out["tp_max_pct"] = _pct_ui("US_MOMENTUM_TP_MAX_PCT", float(out.get("tp_max_pct") or 2.0))
out["shoulder_min_high"] = _pct_ui("US_MOMENTUM_SHOULDER_MIN_HIGH_PCT", float(out.get("shoulder_min_high") or 0.5))
out["shoulder_cut_pct"] = _pct_ui("US_MOMENTUM_SHOULDER_CUT_PCT", float(out.get("shoulder_cut_pct") or 0.3))
out["trail_pct"] = _pct_ui("US_MOMENTUM_TRAIL_PCT", float(out.get("trail_pct") or 0.0))
out["trail_arm_pct"] = _pct_ui("US_MOMENTUM_TRAIL_ARM_PCT", float(out.get("trail_arm_pct") or 0.0))
mh = _pick("US_MOMENTUM_MAX_HOLD_BARS", default=None, cast=lambda v: int(float(v)))
if mh is not None:
out["max_hold_bars"] = mh
rt = env.get("US_MOMENTUM_RATCHET_TIERS")
if rt not in (None, "", "None"):
out["ratchet_tiers"] = str(rt).strip()
out["mom_rsi_min"] = float(_pick("US_MOMENTUM_RSI_MIN", default=out.get("mom_rsi_min", 50), cast=float))
out["mom_rsi_max"] = float(_pick("US_MOMENTUM_RSI_MAX", default=out.get("mom_rsi_max", 90), cast=float))
out["mom_vol_mult"] = float(_pick("US_MOMENTUM_VOL_MULT", default=out.get("mom_vol_mult", 1.5), cast=float))
out["mom_vol_win"] = float(_pick("US_MOMENTUM_VOL_WIN", default=out.get("mom_vol_win", 5), cast=float))
out["high_chase_thr"] = float(_pick("US_MOMENTUM_HIGH_CHASE_THR", default=out.get("high_chase_thr", 0.96), cast=float))
out["max_daily_chg"] = float(_pick("US_MOMENTUM_MAX_DAILY_CHG", default=out.get("max_daily_chg", 20), cast=float))
out["mom_max_from_open_pct"] = float(_pick("US_MOMENTUM_MAX_FROM_OPEN_PCT", default=out.get("mom_max_from_open_pct", 999), cast=float))
out["mom_min_from_open_pct"] = float(_pick("US_MOMENTUM_MIN_FROM_OPEN_PCT", default=out.get("mom_min_from_open_pct", -999), cast=float))
mm = _pick("US_MOMENTUM_MIN_PROFIT_PCT", default=None, cast=float)
if mm is not None:
out["min_margin"] = float(mm)
out["use_defense_filters"] = _bool("US_MOMENTUM_USE_DEFENSE_FILTERS", bool(out.get("use_defense_filters", True)))
out["use_high_chase_filter"] = _bool("US_MOMENTUM_USE_HIGH_CHASE_FILTER", False)
out["use_daily_range_filter"] = _bool("US_MOMENTUM_USE_DAILY_RANGE_FILTER", False)
out["use_ema_filter"] = _bool("US_MOMENTUM_USE_EMA_FILTER", True)
out["use_rsi_max_filter"] = _bool("US_MOMENTUM_USE_RSI_MAX_FILTER", False)
out["pattern_breakout"] = _bool("US_MOMENTUM_PATTERN_BREAKOUT", True)
out["pattern_pullback"] = _bool("US_MOMENTUM_PATTERN_PULLBACK", True)
out["chase_lookback_min"] = int(_pick("US_MOMENTUM_CHASE_LOOKBACK_MIN", default=out.get("chase_lookback_min", 10), cast=lambda v: int(float(v))))
out["pullback_lookback_min"] = int(_pick("US_MOMENTUM_PULLBACK_LOOKBACK_MIN", default=out.get("pullback_lookback_min", 15), cast=lambda v: int(float(v))))
out["pullback_min_pct"] = float(_pick("US_MOMENTUM_PULLBACK_MIN_PCT", default=out.get("pullback_min_pct", 0.3), cast=float))
out["pullback_max_pct"] = float(_pick("US_MOMENTUM_PULLBACK_MAX_PCT", default=out.get("pullback_max_pct", 3.0), cast=float))
out["setup_vol_max_mult"] = float(_pick("US_MOMENTUM_SETUP_VOL_MAX_MULT", default=out.get("setup_vol_max_mult", 0.8), cast=float))
out["setup_bear_bars_min"] = int(_pick("US_MOMENTUM_SETUP_BEAR_BARS_MIN", default=out.get("setup_bear_bars_min", 1), cast=lambda v: int(float(v))))
out["ema_fast_period"] = int(_pick("US_MOMENTUM_EMA_FAST_PERIOD", default=out.get("ema_fast_period", 9), cast=lambda v: int(float(v))))
out["ema_slow_period"] = int(_pick("US_MOMENTUM_EMA_SLOW_PERIOD", default=out.get("ema_slow_period", 21), cast=lambda v: int(float(v))))
out["eod_enabled"] = _bool("US_MOMENTUM_EOD_ENABLED", False)
out["eod_hm"] = str(env.get("US_MOMENTUM_EOD_HM") or out.get("eod_hm") or "05:00").strip() or "05:00"
# 해외: HTS 없음 — 엔진 skip 은 True. Optuna 그리드 false 스윕은 하지 않음(고정).
out["skip_hts_scan_dupes"] = True
out["trigger_e_confirm"] = False
out["use_rsi_filter"] = True
out["market"] = "US"
out["_session_wrap_midnight"] = True
return out
# ──────────────────────────────────────────────────────────────────────────────
# 결과 디렉터리 (param_search_scalping.py 와 공유)
# ──────────────────────────────────────────────────────────────────────────────
def _results_dir_for_write() -> str:
d = os.path.join(HERE, "results")
os.makedirs(d, exist_ok=True)
return d
def _results_dirs_for_read() -> List[str]:
"""읽기용: 신규 results → 홈 폴백 (param_search_scalping 과 동일)."""
return [
os.path.join(HERE, "results"),
os.path.join(os.path.expanduser("~"), ".kis_bot_search_results"),
]
def _latest_json(prefix: str) -> Optional[str]:
"""prefix 로 시작하는 가장 최근 search_momentum_*.json 경로."""
best_path, best_mtime = None, -1.0
for d in _results_dirs_for_read():
if not os.path.isdir(d):
continue
for f in os.listdir(d):
if not (f.startswith(prefix) and f.endswith(".json")):
continue
p = os.path.join(d, f)
try:
m = os.path.getmtime(p)
except OSError:
continue
if m > best_mtime:
best_mtime = m
best_path = p
return best_path
# 호가필터 스윕 축 (2단계 정밀에서 '전수'로 돌리는 손잡이) — 나머지는 코어로 간주.
_MOMENTUM_OB_AXES = ("max_spread_pct", "min_bid_ask_ratio", "ask_max_mult")
def _load_stage1_cores(
json_path: str, top_n: int, core_keys: List[str],
) -> List[Dict[str, Any]]:
"""stage1 결과 JSON의 top[:N] 에서 '코어축만' 추출 (호가 3축 제외).
2단계 정밀(coarse-to-fine)에서 1단계 우승 코어를 고정하기 위함.
양수 손익(total_pnl>0) 결과만 채택해, 1단계에서 검증된 코어만 넘긴다.
"""
with open(json_path, "r", encoding="utf-8") as f:
data = json.load(f)
top = data.get("top") or []
cores: List[Dict[str, Any]] = []
for item in top:
if len(cores) >= top_n:
break
if float(item.get("total_pnl", 0) or 0) <= 0:
continue # 1단계에서 손실난 코어는 정밀 단계에서 제외
p = item.get("params") or {}
core = {k: p[k] for k in core_keys if k in p}
if core:
cores.append(core)
return cores
def _resolve_stage1_json_path(explicit: Optional[str] = None) -> str:
"""2단계 코어고정용 stage1 결과 JSON 경로 (명시 없으면 최신 search_momentum_*.json)."""
if explicit and os.path.isfile(explicit):
return explicit
latest = _latest_json("search_momentum_")
if not latest:
raise FileNotFoundError(
"stage1 결과 JSON이 없습니다. 1단계를 먼저 실행하거나 --stage1-json 경로를 지정하세요."
)
return latest
def _build_stage2_core_lock_combos(
grid: Dict[str, List[Any]],
stage1_json: str,
core_top_n: int,
) -> Tuple[List[Dict[str, Any]], List[str], Dict[str, Any], List[Dict[str, Any]]]:
"""1단계 Top-N 코어 고정 × 호가 3축 전수(27) 조합 생성.
coarse-to-fine 2단계: 코어는 1단계 우승 레시피 그대로, 호가필터만 정밀 스윕.
"""
keys = list(grid.keys())
core_keys = [k for k in keys if k not in _MOMENTUM_OB_AXES]
ob_keys = [k for k in keys if k in _MOMENTUM_OB_AXES]
with open(stage1_json, "r", encoding="utf-8") as f:
stage1_data = json.load(f)
cores = _load_stage1_cores(stage1_json, core_top_n, core_keys)
if not cores:
raise RuntimeError(
f"stage1 JSON에서 양수 손익 코어를 {core_top_n}개 찾지 못했습니다: {stage1_json}"
)
ob_axes = [grid[k] for k in ob_keys]
ob_n = grid_total_combinations(ob_axes)
dict_combos: List[Dict[str, Any]] = []
for core in cores:
for ob_t in product(*ob_axes):
combo = dict(core)
combo.update(dict(zip(ob_keys, ob_t)))
if _momentum_combo_grid_valid(combo):
dict_combos.append(combo)
# stage1 top 에서 코어와 매칭되는 1단계 성과(비교용)
stage1_baselines: List[Dict[str, Any]] = []
for item in (stage1_data.get("top") or [])[:core_top_n * 2]:
if float(item.get("total_pnl", 0) or 0) <= 0:
continue
p = item.get("params") or {}
stage1_baselines.append({
"params": {k: p[k] for k in core_keys if k in p},
"total_pnl": item.get("total_pnl"),
"win_rate": item.get("win_rate"),
"total_trades": item.get("total_trades"),
"pf": item.get("pf"),
})
if len(stage1_baselines) >= core_top_n:
break
return dict_combos, keys, stage1_data, stage1_baselines
def _apply_from_latest_json(rank: int) -> None:
"""최근 search_momentum_*.json 에서 rank(1-based) merged_params → env_config."""
latest_path = _latest_json("search_momentum_")
if not latest_path:
print("⚠️ search_momentum_*.json 파일이 없습니다.")
return
with open(latest_path, "r", encoding="utf-8") as f:
data = json.load(f)
top = data.get("top") or []
if rank < 1 or rank > len(top):
print(f"⚠️ 순번 {rank}이(가) 유효하지 않습니다. (1~{len(top)})")
return
item = top[rank - 1]
merged = merge_param_search_apply_source(item, data)
if not merged:
print("⚠️ 해당 항목에 merged_params/params가 없습니다.")
return
if item.get("total_pnl", 0) <= 0:
print(f"⚠️ {rank}번째 결과는 총손익 ≤ 0 → DB 미적용. 기존 설정 유지.")
return
print(f"📂 {latest_path} 에서 {rank}번째 적용합니다.")
_apply_to_db(merged)
# ──────────────────────────────────────────────────────────────────────────────
# 모멘텀 그리드 — coarse / fine / full
# ──────────────────────────────────────────────────────────────────────────────
def _momentum_grids(market: str = "KR") -> Dict[str, Dict[str, List]]:
"""탐색 모드별 모멘텀 그리드.
각 축은 DB ``env_config`` 문자열 ``MOMENTUM_GRID_{MODE}_{키대문자}`` 로 덮어쓸 수 있다
(예: ``MOMENTUM_GRID_COARSE_TRAIL_TRIGGER_PCT`` — 키 suffix 는 아래 ``_parse_csv_*`` 호출 첫 인자 참고).
- fast : HTS TRIGGER(거래량) + 청산 광범위(어깨·래칫·트레일·SL·TP) 균등샘플
- exit : **청산 전용** 광범위 그리드 — 진입은 DB 고정, 어깨·래칫·트레일·SL·TP·시간컷
- rr : 손익비(어깨·SL·TP) 중심
- wide : **축 스크리닝** — 축당 ~10값 초광범위, Optuna 소수 trial로 유효 축·구간 탐색용
(실매 앵커 포함, 적대값 min_margin≥10 / vol≥20 단독 제외)
- market=US: ``config_us_momentum`` 실매값을 그리드에 강제 포함
"""
live = _mom_fixed_defaults(market=market)
# 실매 래칫 문자열 (빈칸=OFF). 그리드에 없으면 DB 고정값으로만 동작.
_live_ratchet = str(live.get("ratchet_tiers") or "").strip()
# 안전용 래칫 = 늦게 잠금 (이익 충분히 난 뒤 보호). 조기(+1~2%) 익절형 제외.
# OFF + 실매 앵커 + 근처 밴드. env MOMENTUM_GRID_*_RATCHET_TIERS 로 덮어쓰기 가능.
_late_ratchet_cands = [
"",
"5:2,10:1.5",
"5:2,10:1",
"5:2.5,10:1.5",
"7:2,12:1.5",
]
def _fmerge(cands: List[float], live_v: float) -> List[float]:
return sorted(set([float(x) for x in cands] + [float(live_v)]))
def _imerge(cands: List[int], live_v: int) -> List[int]:
return sorted(set([int(x) for x in cands] + [int(live_v)]))
def _smerge(cands: List[str], live_v: str) -> List[str]:
"""문자열 축 머지 — 순서 유지, 실매 앵커 말미 추가(미포함 시)."""
out: List[str] = []
seen = set()
for x in list(cands) + [str(live_v or "").strip()]:
s = str(x).strip()
if s not in seen:
seen.add(s)
out.append(s)
return out
grids = {
# ─────────────────────────────────────────────────────────────────────
# [FAST] wide(7/15) Top 근방 스모크 — tp15·sl5·sh0.8·0.2·trail3/1.5 분지
# 실매 앵커: rsi55/80 · vol2 · tp3 · sl3.5 · sh3/0.15 · trail0/0.5 · cd5 · daily50
# ─────────────────────────────────────────────────────────────────────
"fast": {
"mom_rsi_min": _parse_csv_ints(
"MOMENTUM_GRID_FAST_MOM_RSI_MIN",
_imerge([49, 52, 55, 58], int(float(live.get("mom_rsi_min") or 55))),
),
"mom_rsi_max": _parse_csv_ints(
"MOMENTUM_GRID_FAST_MOM_RSI_MAX",
_imerge([80, 90], int(float(live.get("mom_rsi_max") or 80))),
),
"mom_vol_mult": _parse_csv_floats(
"MOMENTUM_GRID_FAST_MOM_VOL_MULT",
_fmerge([1.0, 2.0, 5.0], float(live.get("mom_vol_mult") or 2.0)),
),
"tp_pct": _parse_csv_floats(
"MOMENTUM_GRID_FAST_TP_PCT",
_fmerge([3.0, 5.0, 15.0], float(live.get("tp_pct") or 3.0)),
),
"sl_pct": _parse_csv_floats(
"MOMENTUM_GRID_FAST_SL_PCT",
_fmerge([3.5, 4.0, 5.0], float(live.get("sl_pct") or 3.5)),
),
"mom_time_end_hm": _parse_csv_ints(
"MOMENTUM_GRID_FAST_MOM_TIME_END_HM",
_imerge([1430, 1520, 1530], int(float(live.get("mom_time_end_hm") or 1530))),
),
"mom_max_from_open_pct": _parse_csv_floats(
"MOMENTUM_GRID_FAST_MOM_MAX_FROM_OPEN_PCT",
_fmerge(
[25.0, 40.0],
float(live.get("mom_max_from_open_pct") or 40.0),
),
),
"min_margin": _parse_csv_floats(
"MOMENTUM_GRID_FAST_MIN_MARGIN",
_fmerge([0.5, 0.8], float(live.get("min_margin") or 0.5)),
),
"shoulder_min_high": _parse_csv_floats(
"MOMENTUM_GRID_FAST_SHOULDER_MIN_HIGH_PCT",
_fmerge([0.8, 3.0], float(live.get("shoulder_min_high") or 3.0)),
),
"shoulder_cut_pct": _parse_csv_floats(
"MOMENTUM_GRID_FAST_SHOULDER_CUT_PCT",
_fmerge([0.15, 0.2], float(live.get("shoulder_cut_pct") or 0.15)),
),
"trail_pct": _parse_csv_floats(
"MOMENTUM_GRID_FAST_TRAIL_PCT",
_fmerge([0.0, 0.7, 3.0], float(live.get("trail_pct") or 0.0)),
),
"trail_arm_pct": _parse_csv_floats(
"MOMENTUM_GRID_FAST_TRAIL_ARM_PCT",
_fmerge([0.5, 1.5], float(live.get("trail_arm_pct") or 0.5)),
),
"cooldown_min": _parse_csv_floats(
"MOMENTUM_GRID_FAST_COOLDOWN_MIN",
_fmerge([5.0, 15.0], float(live.get("cooldown_min") or 5.0)),
),
"max_daily": _parse_csv_ints(
"MOMENTUM_GRID_FAST_MAX_DAILY",
_imerge([30, 50], int(float(live.get("max_daily") or 50))),
),
"max_daily_chg": _parse_csv_floats(
"MOMENTUM_GRID_FAST_MAX_DAILY_CHG_PCT",
_fmerge([25.0, 30.0], float(live.get("max_daily_chg") or 30.0)),
),
"max_hold_bars": _parse_csv_ints(
"MOMENTUM_GRID_FAST_MAX_HOLD_BARS",
_imerge([0, 15], int(float(live.get("max_hold_bars") or 15))),
),
# 래칫: OFF + 실매 근처(늦게 잠금). 조기(+1~2%) 티어 제외
"ratchet_tiers": _parse_csv_strings(
"MOMENTUM_GRID_FAST_RATCHET_TIERS",
_smerge(_late_ratchet_cands, _live_ratchet),
),
},
# exit: 청산 전용 광범위 — TRIGGER/진입은 _mom_fixed_defaults(DB) 고정
"exit": {
"sl_pct": _parse_csv_floats(
"MOMENTUM_GRID_EXIT_SL_PCT", [1.5, 2.0, 2.5, 3.0, 4.0, 5.0, 6.0],
),
"tp_pct": _parse_csv_floats(
"MOMENTUM_GRID_EXIT_TP_PCT", [3.0, 5.0, 6.0, 8.0, 10.0, 12.0, 15.0],
),
"tp_max_pct": _parse_csv_floats(
"MOMENTUM_GRID_EXIT_TP_MAX_PCT", [5.0, 8.0, 10.0, 12.0, 15.0, 20.0],
),
"shoulder_min_high": _parse_csv_floats(
"MOMENTUM_GRID_EXIT_SHOULDER_MIN_HIGH_PCT",
[0.2, 0.3, 0.5, 0.8, 1.0, 1.5, 2.0, 3.0, 5.0],
),
"shoulder_cut_pct": _parse_csv_floats(
"MOMENTUM_GRID_EXIT_SHOULDER_CUT_PCT",
[0.10, 0.15, 0.20, 0.25, 0.30, 0.40, 0.50, 0.80],
),
"trail_pct": _parse_csv_floats(
"MOMENTUM_GRID_EXIT_TRAIL_PCT",
[0.0, 0.3, 0.5, 0.8, 1.0, 1.5, 2.0, 2.5, 3.0, 4.0],
),
"trail_arm_pct": _parse_csv_floats(
"MOMENTUM_GRID_EXIT_TRAIL_ARM_PCT", [0.0, 0.3, 0.5, 0.8, 1.0, 1.5, 2.0, 3.0],
),
"ratchet_tiers": _parse_csv_strings(
"MOMENTUM_GRID_EXIT_RATCHET_TIERS",
[
"",
"1:0.5,2:0.8,5:1",
"2:1.5,5:1",
"2:1,5:0.8,8:0.6",
"3:2,6:1.2,10:0.8",
"5:2,10:1.5",
],
),
"max_hold_bars": _parse_csv_ints(
"MOMENTUM_GRID_EXIT_MAX_HOLD_BARS",
[0, 15, 30, 45, 60, 90, 120, 180, 240],
),
},
# rr: 손익비(어깨·SL·TP) 중심 — 진입은 운영값 1점 고정 (~720조합)
"rr": {
"mom_rsi_min": _parse_csv_ints(
"MOMENTUM_GRID_RR_MOM_RSI_MIN",
[int(get_env_int("MOMENTUM_RSI_MIN", 55))],
),
"mom_rsi_max": _parse_csv_ints(
"MOMENTUM_GRID_RR_MOM_RSI_MAX",
[int(get_env_int("MOMENTUM_RSI_MAX", 90))],
),
"mom_vol_mult": _parse_csv_floats(
"MOMENTUM_GRID_RR_MOM_VOL_MULT",
[float(get_env_float("MOMENTUM_VOL_MULT", 1.5))],
),
"sl_pct": _parse_csv_floats(
"MOMENTUM_GRID_RR_SL_PCT", [1.2, 1.5, 1.8, 2.0],
),
"tp_pct": _parse_csv_floats(
"MOMENTUM_GRID_RR_TP_PCT", [2.0, 2.5, 3.0],
),
"tp_max_pct": _parse_csv_floats(
"MOMENTUM_GRID_RR_TP_MAX_PCT", [2.0, 2.5, 3.0],
),
"shoulder_min_high": _parse_csv_floats(
"MOMENTUM_GRID_RR_SHOULDER_MIN_HIGH_PCT", [0.5, 1.0, 50.0],
),
"shoulder_cut_pct": _parse_csv_floats(
"MOMENTUM_GRID_RR_SHOULDER_CUT_PCT", [0.02, 0.15, 0.25],
),
},
# coarse — wide Top 밴드 조금 넓게 (1차 스크리닝)
"coarse": {
"mom_rsi_min": _parse_csv_ints(
"MOMENTUM_GRID_COARSE_MOM_RSI_MIN",
_imerge([48, 49, 52, 55, 58], int(float(live.get("mom_rsi_min") or 55))),
),
"mom_rsi_max": _parse_csv_ints(
"MOMENTUM_GRID_COARSE_MOM_RSI_MAX",
_imerge([70, 80, 90, 100], int(float(live.get("mom_rsi_max") or 80))),
),
"mom_vol_mult": _parse_csv_floats(
"MOMENTUM_GRID_COARSE_MOM_VOL_MULT",
_fmerge([1.0, 2.0, 3.0, 5.0], float(live.get("mom_vol_mult") or 2.0)),
),
"tp_pct": _parse_csv_floats(
"MOMENTUM_GRID_COARSE_TP_PCT",
_fmerge([3.0, 5.0, 8.0, 15.0], float(live.get("tp_pct") or 3.0)),
),
"sl_pct": _parse_csv_floats(
"MOMENTUM_GRID_COARSE_SL_PCT",
_fmerge([3.0, 3.5, 4.0, 5.0], float(live.get("sl_pct") or 3.5)),
),
"mom_time_end_hm": _parse_csv_ints(
"MOMENTUM_GRID_COARSE_MOM_TIME_END_HM",
_imerge(
[1400, 1430, 1520, 1530],
int(float(live.get("mom_time_end_hm") or 1530)),
),
),
"mom_max_from_open_pct": _parse_csv_floats(
"MOMENTUM_GRID_COARSE_MOM_MAX_FROM_OPEN_PCT",
_fmerge(
[25.0, 40.0, 45.0],
float(live.get("mom_max_from_open_pct") or 40.0),
),
),
"min_margin": _parse_csv_floats(
"MOMENTUM_GRID_COARSE_MIN_MARGIN",
_fmerge([0.3, 0.5, 0.8, 2.0], float(live.get("min_margin") or 0.5)),
),
"shoulder_min_high": _parse_csv_floats(
"MOMENTUM_GRID_COARSE_SHOULDER_MIN_HIGH_PCT",
_fmerge([0.8, 1.5, 3.0, 5.0], float(live.get("shoulder_min_high") or 3.0)),
),
"shoulder_cut_pct": _parse_csv_floats(
"MOMENTUM_GRID_COARSE_SHOULDER_CUT_PCT",
_fmerge(
[0.15, 0.2, 0.25, 0.4],
float(live.get("shoulder_cut_pct") or 0.15),
),
),
"trail_pct": _parse_csv_floats(
"MOMENTUM_GRID_COARSE_TRAIL_PCT",
_fmerge([0.0, 0.7, 1.0, 3.0], float(live.get("trail_pct") or 0.0)),
),
"trail_arm_pct": _parse_csv_floats(
"MOMENTUM_GRID_COARSE_TRAIL_ARM_PCT",
_fmerge([0.5, 1.5, 2.0, 3.0], float(live.get("trail_arm_pct") or 0.5)),
),
"cooldown_min": _parse_csv_floats(
"MOMENTUM_GRID_COARSE_COOLDOWN_MIN",
_fmerge([5.0, 8.0, 15.0], float(live.get("cooldown_min") or 5.0)),
),
"max_daily": _parse_csv_ints(
"MOMENTUM_GRID_COARSE_MAX_DAILY",
_imerge([5, 30, 50, 80], int(float(live.get("max_daily") or 50))),
),
"max_daily_chg": _parse_csv_floats(
"MOMENTUM_GRID_COARSE_MAX_DAILY_CHG_PCT",
_fmerge(
[25.0, 30.0, 60.0],
float(live.get("max_daily_chg") or 30.0),
),
),
"max_hold_bars": _parse_csv_ints(
"MOMENTUM_GRID_COARSE_MAX_HOLD_BARS",
_imerge([0, 15, 60, 120], int(float(live.get("max_hold_bars") or 15))),
),
"ratchet_tiers": _parse_csv_strings(
"MOMENTUM_GRID_COARSE_RATCHET_TIERS",
_smerge(_late_ratchet_cands, _live_ratchet),
),
},
# fine — 2026-07-15 wide Top 재설계 (best~+60k / Top: tp15·sl5·vol2·rsi52/90)
# 실매 앵커 포함. apply는 별도 확인 후. + 늦게잠금 래칫
"fine": {
"mom_rsi_min": _parse_csv_ints(
"MOMENTUM_GRID_FINE_MOM_RSI_MIN",
_imerge([49, 52, 55, 58], int(float(live.get("mom_rsi_min") or 55))),
),
"mom_rsi_max": _parse_csv_ints(
"MOMENTUM_GRID_FINE_MOM_RSI_MAX",
_imerge([80, 90, 100], int(float(live.get("mom_rsi_max") or 80))),
),
"mom_vol_mult": _parse_csv_floats(
"MOMENTUM_GRID_FINE_MOM_VOL_MULT",
_fmerge([1.0, 2.0, 3.0, 5.0], float(live.get("mom_vol_mult") or 2.0)),
),
"tp_pct": _parse_csv_floats(
"MOMENTUM_GRID_FINE_TP_PCT",
_fmerge([2.0, 3.0, 5.0, 8.0, 15.0], float(live.get("tp_pct") or 3.0)),
),
"tp_max_pct": _parse_csv_floats(
"MOMENTUM_GRID_FINE_TP_MAX_PCT",
_fmerge([3.0, 5.0, 8.0], float(live.get("tp_max_pct") or 5.0)),
),
"sl_pct": _parse_csv_floats(
"MOMENTUM_GRID_FINE_SL_PCT",
_fmerge([3.0, 3.5, 4.0, 5.0], float(live.get("sl_pct") or 3.5)),
),
# V4 추격 패턴 축 (기존 fine 미포함 → 실매 고정값만 사용하던 구간)
"chase_lookback_min": _parse_csv_ints(
"MOMENTUM_GRID_FINE_CHASE_LOOKBACK_MIN",
_imerge(
[5, 10, 15],
int(float(live.get("chase_lookback_min") or 10)),
),
),
"pullback_lookback_min": _parse_csv_ints(
"MOMENTUM_GRID_FINE_PULLBACK_LOOKBACK_MIN",
_imerge(
[10, 15, 20],
int(float(live.get("pullback_lookback_min") or 15)),
),
),
"pullback_min_pct": _parse_csv_floats(
"MOMENTUM_GRID_FINE_PULLBACK_MIN_PCT",
_fmerge(
[0.2, 0.3, 0.5],
float(live.get("pullback_min_pct") or 0.3),
),
),
"pullback_max_pct": _parse_csv_floats(
"MOMENTUM_GRID_FINE_PULLBACK_MAX_PCT",
_fmerge(
[2.0, 3.0, 5.0],
float(live.get("pullback_max_pct") or 3.0),
),
),
"setup_vol_max_mult": _parse_csv_floats(
"MOMENTUM_GRID_FINE_SETUP_VOL_MAX_MULT",
_fmerge(
[0.5, 0.8, 1.0],
float(live.get("setup_vol_max_mult") or 0.8),
),
),
"setup_bear_bars_min": _parse_csv_ints(
"MOMENTUM_GRID_FINE_SETUP_BEAR_BARS_MIN",
_imerge(
[0, 1, 2],
int(float(live.get("setup_bear_bars_min") or 1)),
),
),
"mom_vol_win": _parse_csv_ints(
"MOMENTUM_GRID_FINE_MOM_VOL_WIN",
_imerge([3, 5, 8], int(float(live.get("mom_vol_win") or 5))),
),
"time_start_hm": _parse_csv_ints(
"MOMENTUM_GRID_FINE_TIME_START_HM",
_imerge(
[830, 900, 930],
int(float(live.get("time_start_hm") or 830)),
),
),
"mom_time_end_hm": _parse_csv_ints(
"MOMENTUM_GRID_FINE_MOM_TIME_END_HM",
_imerge(
[1430, 1520, 1530],
int(float(live.get("mom_time_end_hm") or 1530)),
),
),
"mom_max_from_open_pct": _parse_csv_floats(
"MOMENTUM_GRID_FINE_MOM_MAX_FROM_OPEN_PCT",
_fmerge(
[25.0, 40.0, 45.0],
float(live.get("mom_max_from_open_pct") or 40.0),
),
),
"min_margin": _parse_csv_floats(
"MOMENTUM_GRID_FINE_MIN_MARGIN",
_fmerge([0.3, 0.5, 0.8, 2.0], float(live.get("min_margin") or 0.5)),
),
"shoulder_min_high": _parse_csv_floats(
"MOMENTUM_GRID_FINE_SHOULDER_MIN_HIGH_PCT",
_fmerge([0.8, 1.5, 3.0, 5.0], float(live.get("shoulder_min_high") or 3.0)),
),
"shoulder_cut_pct": _parse_csv_floats(
"MOMENTUM_GRID_FINE_SHOULDER_CUT_PCT",
_fmerge(
[0.15, 0.2, 0.25, 0.4],
float(live.get("shoulder_cut_pct") or 0.15),
),
),
"trail_pct": _parse_csv_floats(
"MOMENTUM_GRID_FINE_TRAIL_PCT",
_fmerge([0.0, 0.7, 1.0, 3.0], float(live.get("trail_pct") or 0.0)),
),
"trail_arm_pct": _parse_csv_floats(
"MOMENTUM_GRID_FINE_TRAIL_ARM_PCT",
_fmerge([0.5, 1.5, 2.0, 3.0], float(live.get("trail_arm_pct") or 0.5)),
),
"cooldown_min": _parse_csv_floats(
"MOMENTUM_GRID_FINE_COOLDOWN_MIN",
_fmerge([5.0, 8.0, 15.0], float(live.get("cooldown_min") or 5.0)),
),
"max_daily": _parse_csv_ints(
"MOMENTUM_GRID_FINE_MAX_DAILY",
_imerge([5, 30, 50, 80], int(float(live.get("max_daily") or 50))),
),
"max_daily_chg": _parse_csv_floats(
"MOMENTUM_GRID_FINE_MAX_DAILY_CHG_PCT",
_fmerge(
[25.0, 30.0, 60.0],
float(live.get("max_daily_chg") or 30.0),
),
),
"max_hold_bars": _parse_csv_ints(
"MOMENTUM_GRID_FINE_MAX_HOLD_BARS",
_imerge([0, 15, 60, 120], int(float(live.get("max_hold_bars") or 15))),
),
"ratchet_tiers": _parse_csv_strings(
"MOMENTUM_GRID_FINE_RATCHET_TIERS",
_smerge(_late_ratchet_cands, _live_ratchet),
),
},
# wide: 축 스크리닝용 초광범위 (축당 ~10값) — fine 승자(+761)가 너무 얇을 때
# Optuna trials≈50 으로 어떤 축·구간이 PnL/건수에 반응하는지 먼저 찾고, 이후 fine 재세팅
# 실매 앵커: vol1.05 / sh0.3·0.1 / trail0.7·0.4 / chg40 / hold15 / daily100 / cd1 / end1530
"wide": {
"mom_rsi_min": _parse_csv_ints(
"MOMENTUM_GRID_WIDE_MOM_RSI_MIN",
[40, 45, 48, 49, 50, 52, 55, 58, 60, 65],
),
"mom_rsi_max": _parse_csv_ints(
"MOMENTUM_GRID_WIDE_MOM_RSI_MAX",
[70, 75, 80, 85, 88, 90, 92, 95, 98, 100],
),
"mom_vol_mult": _parse_csv_floats(
"MOMENTUM_GRID_WIDE_MOM_VOL_MULT",
[1.0, 1.05, 1.2, 1.5, 2.0, 3.0, 5.0, 8.0, 10.0, 15.0],
),
"tp_pct": _parse_csv_floats(
"MOMENTUM_GRID_WIDE_TP_PCT",
[2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 10.0, 12.0, 15.0],
),
"sl_pct": _parse_csv_floats(
"MOMENTUM_GRID_WIDE_SL_PCT",
[1.0, 1.2, 1.5, 1.8, 2.0, 2.5, 3.0, 3.5, 4.0, 5.0],
),
"mom_time_end_hm": _parse_csv_ints(
"MOMENTUM_GRID_WIDE_MOM_TIME_END_HM",
[1100, 1200, 1300, 1330, 1400, 1430, 1500, 1510, 1520, 1530],
),
"mom_max_from_open_pct": _parse_csv_floats(
"MOMENTUM_GRID_WIDE_MOM_MAX_FROM_OPEN_PCT",
[10.0, 15.0, 20.0, 25.0, 30.0, 35.0, 40.0, 45.0, 50.0, 60.0],
),
"min_margin": _parse_csv_floats(
"MOMENTUM_GRID_WIDE_MIN_MARGIN",
[0.1, 0.2, 0.3, 0.5, 0.8, 1.0, 1.5, 2.0, 3.0, 5.0],
),
"shoulder_min_high": _parse_csv_floats(
"MOMENTUM_GRID_WIDE_SHOULDER_MIN_HIGH_PCT",
[0.1, 0.2, 0.3, 0.5, 0.8, 1.0, 1.5, 2.0, 3.0, 5.0],
),
"shoulder_cut_pct": _parse_csv_floats(
"MOMENTUM_GRID_WIDE_SHOULDER_CUT_PCT",
[0.05, 0.1, 0.15, 0.2, 0.25, 0.3, 0.4, 0.5, 0.8, 1.0],
),
"trail_pct": _parse_csv_floats(
"MOMENTUM_GRID_WIDE_TRAIL_PCT",
[0.0, 0.3, 0.5, 0.7, 1.0, 1.5, 2.0, 2.5, 3.0, 4.0],
),
"trail_arm_pct": _parse_csv_floats(
"MOMENTUM_GRID_WIDE_TRAIL_ARM_PCT",
[0.0, 0.3, 0.5, 0.7, 1.0, 1.5, 2.0, 2.5, 3.0, 4.0],
),
"cooldown_min": _parse_csv_floats(
"MOMENTUM_GRID_WIDE_COOLDOWN_MIN",
[0.0, 1.0, 2.0, 3.0, 5.0, 8.0, 10.0, 15.0, 20.0, 30.0],
),
"max_daily": _parse_csv_ints(
"MOMENTUM_GRID_WIDE_MAX_DAILY",
[5, 10, 15, 20, 30, 50, 80, 100, 150, 200],
),
"max_daily_chg": _parse_csv_floats(
"MOMENTUM_GRID_WIDE_MAX_DAILY_CHG_PCT",
[10.0, 15.0, 20.0, 25.0, 30.0, 35.0, 40.0, 45.0, 50.0, 60.0],
),
"max_hold_bars": _parse_csv_ints(
"MOMENTUM_GRID_WIDE_MAX_HOLD_BARS",
[0, 10, 15, 20, 30, 45, 60, 90, 120, 180],
),
"ratchet_tiers": _parse_csv_strings(
"MOMENTUM_GRID_WIDE_RATCHET_TIERS",
_smerge(_late_ratchet_cands, _live_ratchet),
),
},
# full: 가설 폭넓힘 (낮은 vol_mult·짧은 TP 가설 포함) + 트레일 0 = TP 상한 위주 청산
# 조합 수 대략 수만 단위 — env 로 각 축 목록 줄이는 것 권장.
"full": {
"mom_rsi_min": _parse_csv_ints(
"MOMENTUM_GRID_FULL_MOM_RSI_MIN", [45, 55],
),
"mom_rsi_max": _parse_csv_ints(
"MOMENTUM_GRID_FULL_MOM_RSI_MAX", [80, 90, 95],
),
"mom_vol_mult": _parse_csv_floats(
"MOMENTUM_GRID_FULL_MOM_VOL_MULT", [3.0, 10.0, 20.0],
),
"tp_pct": _parse_csv_floats(
"MOMENTUM_GRID_FULL_TP_PCT", [3.0, 5.0, 7.0],
),
"sl_pct": _parse_csv_floats(
"MOMENTUM_GRID_FULL_SL_PCT", [1.5, 2.5, 3.0],
),
"mom_time_end_hm": _parse_csv_ints(
"MOMENTUM_GRID_FULL_MOM_TIME_END_HM", [1330, 1430, 1530],
),
"mom_max_from_open_pct": _parse_csv_floats(
"MOMENTUM_GRID_FULL_MOM_MAX_FROM_OPEN_PCT", [22.0, 30.0],
),
"min_margin": _parse_csv_floats(
"MOMENTUM_GRID_FULL_MIN_MARGIN", [0.2, 10.0],
),
"trail_pct": _parse_csv_floats(
"MOMENTUM_GRID_FULL_TRAIL_PCT", [0.0, 0.8, 1.2],
),
"trail_arm_pct": _parse_csv_floats(
"MOMENTUM_GRID_FULL_TRAIL_ARM_PCT", [0.0, 0.5, 1.0],
),
"cooldown_min": _parse_csv_floats(
"MOMENTUM_GRID_FULL_COOLDOWN_MIN", [1.0, 5.0],
),
"max_daily": _parse_csv_ints(
"MOMENTUM_GRID_FULL_MAX_DAILY", [5, 10],
),
"max_daily_chg": _parse_csv_floats(
"MOMENTUM_GRID_FULL_MAX_DAILY_CHG_PCT", [35.0, 50.0],
),
# skip_hts_scan_dupes: full 스윕 금지 — 운영 false 고정 (FIXED_DEFAULTS)
},
}
# 해외: 국내 장종료(1430/1520/1530) 스윕이 base 2230~500 을 덮어 세션이 깨짐
# → US 세션축만 해외 RTH(기본 2230~500) 로 교체
if str(market or "KR").strip().upper() == "US":
us_start = int(float(live.get("time_start_hm") or 2230))
us_end = int(float(live.get("mom_time_end_hm") or live.get("time_end_hm") or 500))
us_ends = _imerge([400, 500, 530, 600], us_end)
us_starts = _imerge([2200, 2230, 2300], us_start)
for _mode, _g in grids.items():
if "mom_time_end_hm" in _g:
_g["mom_time_end_hm"] = _parse_csv_ints(
"US_MOMENTUM_GRID_%s_MOM_TIME_END_HM" % str(_mode).upper(),
us_ends,
)
if "time_start_hm" in _g:
_g["time_start_hm"] = _parse_csv_ints(
"US_MOMENTUM_GRID_%s_TIME_START_HM" % str(_mode).upper(),
us_starts,
)
return grids
MOMENTUM_GRID_AXIS_HINTS_KO: Dict[str, str] = {
"mom_rsi_min": "RSI3 하한 — 약세 제외",
"mom_rsi_max": "RSI3 상한 — use_rsi_max_filter ON 시만",
"mom_vol_mult": "TRIGGER 거래량 펄스: 현재봉 ≥ N봉평균 × 배수",
"trigger_require_bull_bar": "양봉 필수 (0=OFF, 1=ON) — HTS TRIGGER",
"pattern_breakout": "(레거시) N분 고가 돌파 패턴",
"pattern_pullback": "(레거시) 눌림 재돌파 패턴",
"chase_lookback_min": "돌파 패턴 관찰 구간(분)",
"pullback_lookback_min": "눌림 패턴 스윙고점 탐색 구간(분)",
"pullback_min_pct": "눌림 최소 깊이(%)",
"pullback_max_pct": "눌림 최대 깊이(%)",
"setup_vol_max_mult": "눌림 구간 거래량 상한배수 (0=OFF)",
"setup_bear_bars_min": "눌림 음봉 최소 개수 (0=OFF)",
"mom_vol_win": "거래량 관찰 봉수",
"time_start_hm": "매수 시작 HHMM",
"mom_max_from_open_pct": "레거시: 당일 시가 대비 과열 상한(%)",
"sl_pct": "고정 손절(%)",
"tp_pct": "고정 익절(%)",
"tp_max_pct": "익절 상한(%) — 어깨·트레일 이후 하드 캡",
"shoulder_min_high": "어깨 발동: 진입가 대비 최소 수익(%) 후 고점 추적",
"shoulder_cut_pct": "고점 대비 되돌림(%) 시 어깨컷",
"trail_pct": "전용 트레일 폭(%, 0=OFF) — momentum_engine",
"trail_arm_pct": "트레일 무장: 진입×(1+arm%) 도달 후 (0=즉시)",
"max_hold_bars": "최대 보유 1분봉 (0=비활성)",
"ratchet_tiers": "래칫 티어 문자열 (빈칸=OFF)",
"use_ema_filter": "EMA 추세필터 (0=OFF, 1=ON) — TRIGGER",
"ema_fast_period": "EMA 빠른 기간 (fast)",
"ema_slow_period": "EMA 느린 기간 (slow, fast보다 커야 함)",
"max_spread_pct": "호가 스프레드 상한(%) — kiwoom_0d 본체 재계산(6/25~)",
"min_bid_ask_ratio": "매수/매도 잔량비 하한 — kiwoom_0d 본체 재계산(6/25~)",
"ask_max_mult": "매도벽 허용배수(필요수량×N) — kiwoom_0d 본체 재계산(6/25~)",
}
def _momentum_ema_filter_on(raw: Any) -> bool:
if isinstance(raw, bool):
return raw
if raw in (None, ""):
return False
return str(raw).strip().lower() in ("1", "true", "t", "y", "yes", "on")
def _momentum_combo_grid_valid(combo: Dict[str, Any]) -> bool:
"""그리드 무효 조합 제거 — HTS TRIGGER + 레거시 패턴 축 호환."""
if "mom_rsi_min" in combo and "mom_rsi_max" in combo:
if float(combo["mom_rsi_min"]) >= float(combo["mom_rsi_max"]):
return False
if "pattern_breakout" in combo or "pattern_pullback" in combo:
bo = _momentum_ema_filter_on(combo.get("pattern_breakout", 1))
pb = _momentum_ema_filter_on(combo.get("pattern_pullback", 1))
if not bo and not pb:
return False
if "pullback_min_pct" in combo and "pullback_max_pct" in combo:
if float(combo["pullback_min_pct"]) >= float(combo["pullback_max_pct"]):
return False
if "use_ema_filter" in combo and (
"ema_fast_period" in combo or "ema_slow_period" in combo
):
ema_on = _momentum_ema_filter_on(combo.get("use_ema_filter", 0))
fast = int(float(combo.get("ema_fast_period", 9)))
slow = int(float(combo.get("ema_slow_period", 21)))
if not ema_on:
return fast == 9 and slow == 21
return slow > fast
return True
# ──────────────────────────────────────────────────────────────────────────────
# DB 적용 — MOMENTUM_* env 키
# ──────────────────────────────────────────────────────────────────────────────
def _get_momentum_field_map() -> Dict[str, Tuple[str, Any]]:
"""모멘텀 파라미터 → env_config 컬럼 매핑.
※ MOMENTUM_* 키만 저장.
--apply 사용자 의도에 따라 향후 옵션 추가 가능. 지금은 새 키만.
"""
return {
# 진입 룰
"mom_rsi_min": ("MOMENTUM_RSI_MIN", lambda v: str(int(v))),
"mom_rsi_max": ("MOMENTUM_RSI_MAX", lambda v: str(int(v))),
"mom_vol_mult": ("MOMENTUM_VOL_MULT", lambda v: str(float(v))),
"mom_vol_win": ("MOMENTUM_VOL_WIN", lambda v: str(int(float(v)))),
"mom_time_end_hm":("MOMENTUM_TIME_END_HM", lambda v: str(int(float(v)))),
"mom_max_from_open_pct": ("MOMENTUM_MAX_FROM_OPEN_PCT", lambda v: str(float(v))),
"mom_min_from_open_pct": ("MOMENTUM_MIN_FROM_OPEN_PCT", lambda v: str(float(v))),
"pattern_breakout": ("MOMENTUM_PATTERN_BREAKOUT", lambda v: "true" if _momentum_ema_filter_on(v) else "false"),
"pattern_pullback": ("MOMENTUM_PATTERN_PULLBACK", lambda v: "true" if _momentum_ema_filter_on(v) else "false"),
"chase_lookback_min": ("MOMENTUM_CHASE_LOOKBACK_MIN", lambda v: str(int(float(v)))),
"pullback_lookback_min": ("MOMENTUM_PULLBACK_LOOKBACK_MIN", lambda v: str(int(float(v)))),
"pullback_min_pct": ("MOMENTUM_PULLBACK_MIN_PCT", lambda v: str(float(v))),
"pullback_max_pct": ("MOMENTUM_PULLBACK_MAX_PCT", lambda v: str(float(v))),
"setup_vol_max_mult": ("MOMENTUM_SETUP_VOL_MAX_MULT", lambda v: str(float(v))),
"setup_bear_bars_min": ("MOMENTUM_SETUP_BEAR_BARS_MIN", lambda v: str(int(float(v)))),
"use_ema_filter": ("MOMENTUM_USE_EMA_FILTER", lambda v: "true" if _momentum_ema_filter_on(v) else "false"),
"ema_fast_period": ("MOMENTUM_EMA_FAST_PERIOD", lambda v: str(int(float(v)))),
"ema_slow_period": ("MOMENTUM_EMA_SLOW_PERIOD", lambda v: str(int(float(v)))),
# 청산 룰
"sl_pct": ("MOMENTUM_STOP_LOSS_PCT", lambda v: str(float(v) / 100)),
"tp_pct": ("MOMENTUM_TAKE_PROFIT_PCT", lambda v: str(float(v) / 100)),
# 청산 보조 — 웹 저장과 동일: UI 퍼센트 → DB 비율 (/100)
# trail_trigger/stop 레거시는 모멘텀 미사용(전용 trail_pct/arm). SCALP ATR 오염 금지.
"shoulder_min_high": ("MOMENTUM_SHOULDER_MIN_HIGH_PCT", lambda v: str(float(v) / 100)),
"shoulder_cut_pct": ("MOMENTUM_SHOULDER_CUT_PCT", lambda v: str(float(v) / 100)),
"trail_pct": ("MOMENTUM_TRAIL_PCT", lambda v: str(abs(float(v)) / 100.0)),
"trail_arm_pct": ("MOMENTUM_TRAIL_ARM_PCT", lambda v: str(abs(float(v)) / 100.0)),
"max_hold_bars": ("MOMENTUM_MAX_HOLD_BARS", lambda v: str(int(float(v)))),
"ratchet_tiers": ("MOMENTUM_RATCHET_TIERS", lambda v: str(v or "").strip()),
"trigger_require_bull_bar": (
"MOMENTUM_TRIGGER_REQUIRE_BULL_BAR",
lambda v: "true" if _momentum_ema_filter_on(v) else "false",
),
# skip_hts_scan_dupes: 그리드 스윕 금지 — FIXED_DEFAULTS false 고정
"tp_max_pct": ("MOMENTUM_TP_MAX_PCT", lambda v: str(float(v) / 100)),
# 엔진 get_momentum_defaults 와 동일 — SCALP_* 오염 금지
"cooldown_min": ("MOMENTUM_COOLDOWN_SEC", lambda v: str(int(float(v)) * 60)),
"time_start_hm": ("MOMENTUM_TIME_START", lambda v: str(int(float(v)))),
"high_chase_thr": ("MOMENTUM_HIGH_CHASE_THR", lambda v: str(float(v))),
"max_daily_chg": ("MOMENTUM_MAX_DAILY_CHG", lambda v: str(float(v))),
"min_price": ("MOMENTUM_MIN_PRICE", lambda v: str(int(float(v)))),
"max_loss_krw": ("MOMENTUM_MAX_LOSS_PER_TRADE_KRW", lambda v: str(int(float(v)))),
"min_margin": ("MOMENTUM_MIN_PROFIT_PCT", lambda v: str(float(v))),
"use_defense_filters": ("MOMENTUM_USE_DEFENSE_FILTERS", lambda v: "true" if v else "false"),
"max_daily": ("MOMENTUM_MAX_DAILY", lambda v: str(int(float(v)))),
"slot_money": ("MOMENTUM_SLOT_MONEY", lambda v: str(int(float(v)))),
}
def _apply_to_db(best_params: dict) -> Optional[int]:
"""1위 파라미터 → insert_env_snapshot (config_momentum + env_config). env_id 반환."""
from kis_trader.backtest.param_search_apply_snapshot import (
_patch_from_momentum_merged,
apply_env_patch,
)
patch = _patch_from_momentum_merged(best_params)
patch.update(session_env_patch("MOMENTUM", best_params))
# 슬롯·동시보유·총한도 제외 — Optuna/Grid 가 slot×종목수로 한도를 덮지 않음
patch = strip_portfolio_keys_from_apply_patch(patch, "MOMENTUM")
if not patch:
print("DB 적용할 파라미터가 없습니다.")
return None
eid = apply_env_patch(patch)
if eid is None:
print("❌ insert_env_snapshot 실패")
return None
print(f"\n✅ config_momentum + env_config INSERT id={eid} (MOMENTUM_* 키):")
for k, v in sorted(patch.items()):
print(f" {k:<35s} = {v}")
return eid
def apply_params_to_db(best_params: dict) -> Optional[int]:
"""웹·CLI 공통 — 파라서치 merged → DB (tail_param_search.apply_params_to_db 와 동일 UX)."""
return _apply_to_db(best_params)
def _apply_to_db_us(best_params: dict) -> Optional[int]:
"""1위 파라미터 → config_us_momentum INSERT (US_MOMENTUM_* 만, 국내 미오염)."""
from kis_trader.engine.us_momentum_env_keys import params_to_us_momentum_env_patch
from kis_trader.backtest.param_search_apply_snapshot import apply_env_patch
patch = params_to_us_momentum_env_patch(best_params)
patch.update(session_env_patch("US_MOMENTUM", best_params))
patch = strip_portfolio_keys_from_apply_patch(patch, "US_MOMENTUM")
# 안전: US_ 접두만
patch = {k: v for k, v in patch.items() if str(k).startswith("US_MOMENTUM_")}
if not patch:
print("DB 적용할 US 파라미터가 없습니다.")
return None
eid = apply_env_patch(patch)
if eid is None:
print("❌ insert_env_snapshot 실패 (US)")
return None
print(f"\n✅ config_us_momentum INSERT id={eid} (US_MOMENTUM_* 키):")
for k, v in sorted(patch.items()):
print(f" {k:<40s} = {v}")
return eid
def apply_params_to_db_us(best_params: dict, *, symbol: str = "") -> Optional[int]:
"""해외 모멘텀 Optuna/웹 apply — US_MOMENTUM_* 전용.
symbol 이 있으면 전역 INSERT 대신 ``us_momentum_stock_config`` 행에
TRIGGER/청산 축을 핀(성격별 탐색 결과).
"""
sym = str(symbol or best_params.get("_apply_symbol") or "").strip().upper()
if sym:
try:
from kis_trader.strategies.us_momentum_stock_cfg import (
upsert_us_momentum_stock_config,
)
from database import TradeDB
p = best_params or {}
fields = {
"sl_pct": abs(float(p["sl_pct"])) if p.get("sl_pct") is not None else None,
"tp_pct": abs(float(p["tp_pct"])) if p.get("tp_pct") is not None else None,
"tp_max_pct": abs(float(p["tp_max_pct"])) if p.get("tp_max_pct") is not None else None,
"shoulder_min_high_pct": abs(float(p["shoulder_min_high"])) if p.get("shoulder_min_high") is not None else None,
"shoulder_cut_pct": abs(float(p["shoulder_cut_pct"])) if p.get("shoulder_cut_pct") is not None else None,
"trail_pct": abs(float(p["trail_pct"])) if p.get("trail_pct") is not None else None,
"trail_arm_pct": abs(float(p["trail_arm_pct"])) if p.get("trail_arm_pct") is not None else None,
"ratchet_tiers": str(p.get("ratchet_tiers") or "").strip() or None,
"max_hold_bars": int(float(p["max_hold_bars"])) if p.get("max_hold_bars") not in (None, "") else None,
"max_daily": int(float(p["max_daily"])) if p.get("max_daily") not in (None, "") else None,
"slot_money": float(p["slot_money"]) if p.get("slot_money") not in (None, "") else None,
"rsi_min": float(p["mom_rsi_min"]) if p.get("mom_rsi_min") is not None else None,
"rsi_max": float(p["mom_rsi_max"]) if p.get("mom_rsi_max") is not None else None,
"vol_mult": float(p["mom_vol_mult"]) if p.get("mom_vol_mult") is not None else None,
"vol_win": int(float(p["mom_vol_win"])) if p.get("mom_vol_win") not in (None, "") else None,
"chase_lookback_min": int(float(p["chase_lookback_min"])) if p.get("chase_lookback_min") not in (None, "") else None,
"pullback_lookback_min": int(float(p["pullback_lookback_min"])) if p.get("pullback_lookback_min") not in (None, "") else None,
"pullback_min_pct": float(p["pullback_min_pct"]) if p.get("pullback_min_pct") is not None else None,
"pullback_max_pct": float(p["pullback_max_pct"]) if p.get("pullback_max_pct") is not None else None,
"setup_vol_max_mult": float(p["setup_vol_max_mult"]) if p.get("setup_vol_max_mult") is not None else None,
"setup_bear_bars_min": int(float(p["setup_bear_bars_min"])) if p.get("setup_bear_bars_min") not in (None, "") else None,
"high_chase_thr": float(p["high_chase_thr"]) if p.get("high_chase_thr") is not None else None,
"max_daily_chg": float(p["max_daily_chg"]) if p.get("max_daily_chg") is not None else None,
"min_price": float(p["min_price"]) if p.get("min_price") is not None else None,
"ema_fast_period": int(float(p["ema_fast_period"])) if p.get("ema_fast_period") not in (None, "") else None,
"ema_slow_period": int(float(p["ema_slow_period"])) if p.get("ema_slow_period") not in (None, "") else None,
}
# bool 축 — 키가 있을 때만
for eng, col in (
("use_defense_filters", "use_defense_filters"),
("use_high_chase_filter", "use_high_chase_filter"),
("use_daily_range_filter", "use_daily_range_filter"),
("use_ema_filter", "use_ema_filter"),
("use_rsi_max_filter", "use_rsi_max_filter"),
("pattern_breakout", "pattern_breakout"),
("pattern_pullback", "pattern_pullback"),
):
if eng in p and p.get(eng) is not None:
fields[col] = 1 if p.get(eng) else 0
if p.get("cooldown_min") is not None:
fields["cooldown_sec"] = int(float(p["cooldown_min"]) * 60)
# None 값 키는 upsert 에 넣지 않음 → 기존 컬럼 보존
fields = {k: v for k, v in fields.items() if v is not None}
db = TradeDB()
try:
ok = upsert_us_momentum_stock_config(
db, sym,
exchange=str(p.get("exchange") or "NASD"),
symbol=sym,
name=sym,
stock_group=str(p.get("stock_group") or "STOCK"),
fields=fields,
)
finally:
db.close()
if ok:
print(f"\n✅ us_momentum_stock_config UPSERT {sym}")
return 1
print(f"❌ stock_config 저장 실패 {sym}")
return None
except Exception as e:
print(f"❌ stock apply 실패: {e}")
return None
return _apply_to_db_us(best_params)
# ──────────────────────────────────────────────────────────────────────────────
# UI(%) → 엔진(비율) 변환 + 워커 청크 평가
# ──────────────────────────────────────────────────────────────────────────────
def _ui_to_engine_params(ui_params: dict) -> dict:
"""UI 표시용(% 등) → 엔진용 비율 단위. 워커에서 공통 사용."""
engine_params = dict(ui_params)
engine_params["sl_pct"] = ui_params["sl_pct"] / 100
engine_params["tp_pct"] = ui_params["tp_pct"] / 100
if "tp_max_pct" in ui_params:
engine_params["tp_max_pct"] = ui_params["tp_max_pct"] / 100
elif "tp_max_pct" not in engine_params:
engine_params["tp_max_pct"] = get_env_float("MOMENTUM_TP_MAX_PCT", 0.02)
# 모멘텀 청산은 trail_pct/arm 만 사용 (0=OFF).
# 레거시 trail_trigger/stop 만 있으면 과거 Optuna와 동일하게 전용 트레일 OFF.
if "trail_pct" in ui_params:
engine_params["trail_pct"] = abs(float(ui_params["trail_pct"])) / 100.0
if "trail_arm_pct" in ui_params:
engine_params["trail_arm_pct"] = abs(float(ui_params["trail_arm_pct"])) / 100.0
if "trail_pct" not in ui_params and "trail_arm_pct" not in ui_params:
if "trail_trigger" in ui_params or "trail_stop" in ui_params:
engine_params["trail_pct"] = 0.0
engine_params["trail_arm_pct"] = 0.0
if "trail_trigger" in ui_params:
engine_params["trail_trigger"] = ui_params["trail_trigger"] / 100
if "trail_stop" in ui_params:
engine_params["trail_stop"] = ui_params["trail_stop"] / 100
if "max_hold_bars" in ui_params:
engine_params["max_hold_bars"] = int(float(ui_params["max_hold_bars"] or 0))
if "shoulder_min_high" in ui_params:
engine_params["shoulder_min_high"] = ui_params["shoulder_min_high"] / 100
if "shoulder_cut_pct" in ui_params:
engine_params["shoulder_cut_pct"] = ui_params["shoulder_cut_pct"] / 100
if "ratchet_tiers" in ui_params:
engine_params["ratchet_tiers"] = str(ui_params["ratchet_tiers"] or "").strip()
if "trigger_require_bull_bar" in ui_params:
engine_params["trigger_require_bull_bar"] = _momentum_ema_filter_on(
ui_params["trigger_require_bull_bar"],
)
if "trigger_e_confirm" in ui_params:
engine_params["trigger_e_confirm"] = _momentum_ema_filter_on(
ui_params["trigger_e_confirm"],
)
if "skip_hts_scan_dupes" in ui_params:
engine_params["skip_hts_scan_dupes"] = bool(ui_params["skip_hts_scan_dupes"])
elif "skip_hts_scan_dupes" not in engine_params:
engine_params["skip_hts_scan_dupes"] = resolve_momentum_skip_hts_scan_dupes()
engine_params["fee_rate"] = ui_params["fee_rate"] / 100
engine_params["sell_tax"] = ui_params["sell_tax"] / 100
engine_params["min_margin"] = ui_params.get("min_margin", 0.2) / 100
if "use_defense_filters" in ui_params:
engine_params["use_defense_filters"] = bool(ui_params["use_defense_filters"])
if "use_ema_filter" in ui_params:
engine_params["use_ema_filter"] = _momentum_ema_filter_on(ui_params["use_ema_filter"])
if "use_rsi_max_filter" in ui_params:
engine_params["use_rsi_max_filter"] = _momentum_ema_filter_on(ui_params["use_rsi_max_filter"])
if "pattern_breakout" in ui_params:
engine_params["pattern_breakout"] = _momentum_ema_filter_on(ui_params["pattern_breakout"])
if "pattern_pullback" in ui_params:
engine_params["pattern_pullback"] = _momentum_ema_filter_on(ui_params["pattern_pullback"])
if "chase_lookback_min" in ui_params:
engine_params["chase_lookback_min"] = int(float(ui_params["chase_lookback_min"]))
if "pullback_lookback_min" in ui_params:
engine_params["pullback_lookback_min"] = int(float(ui_params["pullback_lookback_min"]))
if "pullback_min_pct" in ui_params:
engine_params["pullback_min_pct"] = float(ui_params["pullback_min_pct"])
if "pullback_max_pct" in ui_params:
engine_params["pullback_max_pct"] = float(ui_params["pullback_max_pct"])
if "setup_vol_max_mult" in ui_params:
engine_params["setup_vol_max_mult"] = float(ui_params["setup_vol_max_mult"])
if "setup_bear_bars_min" in ui_params:
engine_params["setup_bear_bars_min"] = int(float(ui_params["setup_bear_bars_min"]))
if "mom_vol_win" in ui_params:
engine_params["mom_vol_win"] = int(float(ui_params["mom_vol_win"]))
if "time_start_hm" in ui_params:
engine_params["time_start_hm"] = int(float(ui_params["time_start_hm"]))
if "ema_fast_period" in ui_params:
engine_params["ema_fast_period"] = int(float(ui_params["ema_fast_period"]))
if "ema_slow_period" in ui_params:
engine_params["ema_slow_period"] = int(float(ui_params["ema_slow_period"]))
# 호가필터 임계값 → per-run 오버라이드 (kiwoom_0d 본체 재계산 시 적용)
if "max_spread_pct" in ui_params and ui_params["max_spread_pct"] is not None:
engine_params["_ob_max_spread_pct"] = float(ui_params["max_spread_pct"])
if "min_bid_ask_ratio" in ui_params and ui_params["min_bid_ask_ratio"] is not None:
engine_params["_ob_min_bid_ask_ratio"] = float(ui_params["min_bid_ask_ratio"])
if "ask_max_mult" in ui_params and ui_params["ask_max_mult"] is not None:
engine_params["_ob_ask_max_mult"] = float(ui_params["ask_max_mult"])
engine_params["max_loss_krw"] = normalize_breakout_max_loss_krw(
ui_params.get("max_loss_krw", 200_000),
)
# 손절 % 에 맞춰 1회 투자금(slot_money) 자동 계산 (라이브 봇과 동일 공식)
# ※ 해외 US: 고정 유니버스 — 금액 재계산 금지. DB/포트폴리오 슬롯만 사용.
if str(ui_params.get("market") or "").strip().upper() == "US":
engine_params["max_loss_krw"] = float(ui_params.get("max_loss_krw") or 0.0)
if "slot_money" in ui_params and ui_params.get("slot_money") is not None:
try:
engine_params["slot_money"] = float(ui_params["slot_money"])
except (TypeError, ValueError):
pass
else:
max_loss = engine_params.get("max_loss_krw", 200_000)
if engine_params["sl_pct"] > 0 and 0 < max_loss < 10_000_000:
# max_loss < 1000만일 때만 자동 계산 (라이브 운영 정상 범위)
engine_params["slot_money"] = max_loss / engine_params["sl_pct"]
return engine_params
def evaluate_momentum_param_combo(
combo: Dict[str, Any],
*,
base_fixed: Dict[str, Any],
grid_keys: List[str],
codes_candles: Dict[str, List[Dict]],
min_trades: int,
min_win_rate: float,
min_pf: float,
universe_by_slot: Optional[Dict[str, List[str]]] = None,
slot_money: float = 3_000_000.0,
max_stocks: int = 3,
total_budget_krw: float = 9_000_000.0,
fee_rate: float = 0.00015,
sell_tax: float = 0.0018,
period_days: int = 1,
cache_holder: Optional[Dict[str, Any]] = None,
ticks_by_code: Any = None,
orderbook_by_code: Any = None,
program_by_code: Any = None,
log_verdict_by_code: Any = None,
start_key: str = "",
end_key: str = "",
) -> Optional[Dict[str, Any]]:
"""단일 모멘텀 조합 백테 — Grid 워커·Optuna objective 공통."""
if not _momentum_combo_grid_valid(combo):
return None
ui_params = dict(base_fixed)
ui_params.update(combo)
engine_params = _ui_to_engine_params(ui_params)
if cache_holder:
engine_params.update(cache_holder)
engine_params["slot_money"] = float(slot_money)
engine_params["max_stocks"] = int(max_stocks)
engine_params["total_budget_krw"] = float(total_budget_krw)
engine_params["portfolio_mode"] = True
if log_verdict_by_code:
engine_params["_backtest_log_verdict_by_code"] = log_verdict_by_code
meta: Dict[str, Any] = {}
if len(start_key) >= 12:
meta["start_key"] = start_key
engine_params["_backtest_period_start_key"] = start_key[:12]
if len(end_key) >= 12:
meta["end_key"] = end_key
trades = mbc.run_momentum_backtest_web_aligned(
codes_candles, engine_params, universe_by_slot,
slot_money=slot_money, fee_rate=fee_rate, sell_tax=sell_tax,
max_stocks=max_stocks, total_budget_krw=total_budget_krw,
ticks_by_code=ticks_by_code,
orderbook_by_code=orderbook_by_code,
program_by_code=program_by_code,
meta_out=meta,
)
stats = mbc.summarize_momentum_trades(
trades, total_budget_krw=total_budget_krw, period_days=period_days,
)
total_trades = stats["total_trades"]
if total_trades < min_trades:
return None
total_pnl = stats["total_pnl"]
win_rate = stats["win_rate"]
pf = float(stats.get("pf") or 0)
if not combo_passes_search_filters(
win_rate=win_rate, pf=pf,
min_win_rate=min_win_rate, min_pf=min_pf,
):
return None
avg_hold = stats["avg_hold_min"]
peak, mdd, cum = 0.0, 0.0, 0.0
for t in trades:
cum += t["pnl"]
if cum > peak:
peak = cum
dd = peak - cum
if dd > mdd:
mdd = dd
merged = dict(ui_params)
merged["slot_money"] = float(slot_money)
merged["max_stocks"] = int(max_stocks)
merged["total_budget_krw"] = float(total_budget_krw)
from kis_trader.backtest.optuna_common import attach_daily_stability
return attach_daily_stability({
"params": {k: ui_params[k] for k in grid_keys if k in ui_params},
"total_pnl": int(total_pnl),
"win_rate": round(win_rate, 2),
"total_trades": total_trades,
"pf": round(pf, 2),
"avg_hold": round(avg_hold, 1),
"mdd": round(mdd),
"bot_pct": stats["bot_pct"],
"daily_avg_pct": stats["daily_avg_pct"],
"sell_reasons": mbc.count_momentum_sell_reasons(trades),
"skipped_micro_buys": int(
(meta.get("skip_stats") or {}).get("skipped_micro_buys") or 0
),
"merged_params": merged,
}, trades)
def _evaluate_momentum_chunk(
param_chunk: List[Dict[str, Any]],
base_fixed: Dict[str, Any],
keys: List[str],
codes_candles: Optional[Dict[str, List[Dict]]],
min_trades: int,
min_win_rate: float,
min_pf: float,
top_n: int,
universe_by_slot: Optional[Dict[str, List[str]]] = None,
slot_money: float = 3_000_000.0,
max_stocks: int = 3,
total_budget_krw: float = 9_000_000.0,
fee_rate: float = 0.00015,
sell_tax: float = 0.0018,
period_days: int = 1,
) -> List[Tuple[float, float, int, Dict]]:
"""워커: 청크 내 조합 평가 — 시각순 포트폴리오 (scalping_backtest_common, mode=momentum)."""
shared = worker_shared_get()
orderbook_preloaded = None
program_preloaded = None
ticks_preloaded = None
log_verdict_preloaded = None
if shared:
if codes_candles is None:
codes_candles = shared.get("codes_candles") or {}
if universe_by_slot is None:
universe_by_slot = shared.get("universe_by_slot")
orderbook_preloaded = shared.get("orderbook_by_code")
program_preloaded = shared.get("program_by_code")
ticks_preloaded = shared.get("ticks_by_code")
# ws_ticks 공유메모리(opt-in): descriptor 만 pickle 로 받고, 워커는 이름으로
# shared_memory 에 read-only attach 한다(사본 없음). SharedTicksMapping 은
# 워커당 1회만 만들어 재사용(attach lazy·1회) → dict 경로와 산출물 동일.
if not ticks_preloaded:
_desc = shared.get("ticks_shared_descriptor")
if _desc:
_tm = shared.get("_ticks_mapping_cache")
if _tm is None:
from kis_trader.backtest.shared_ticks import SharedTicksMapping
_tm = SharedTicksMapping(_desc)
shared["_ticks_mapping_cache"] = _tm # 모듈 전역 _WORKER_SHARED 에 유지
ticks_preloaded = _tm
log_verdict_preloaded = shared.get("log_verdict_by_code")
if codes_candles is None:
codes_candles = {}
cache_holder: Dict[str, Any] = {}
attach_indicator_caches_to_params(cache_holder, codes_candles)
local_heap: List[Tuple[float, float, int, Dict]] = []
for combo in param_chunk:
assert_parent_alive()
result_pkg = evaluate_momentum_param_combo(
combo,
base_fixed=base_fixed,
grid_keys=keys,
codes_candles=codes_candles,
min_trades=min_trades,
min_win_rate=min_win_rate,
min_pf=min_pf,
universe_by_slot=universe_by_slot,
slot_money=slot_money,
max_stocks=max_stocks,
total_budget_krw=total_budget_krw,
fee_rate=fee_rate,
sell_tax=sell_tax,
period_days=period_days,
cache_holder=cache_holder,
ticks_by_code=ticks_preloaded,
orderbook_by_code=orderbook_preloaded,
program_by_code=program_preloaded,
log_verdict_by_code=log_verdict_preloaded,
start_key=str(shared.get("start_key") or "") if shared else "",
end_key=str(shared.get("end_key") or "") if shared else "",
)
if result_pkg is None:
continue
total_pnl = result_pkg["total_pnl"]
win_rate = result_pkg["win_rate"]
item_t = (total_pnl, win_rate, id(result_pkg), result_pkg)
if len(local_heap) < top_n:
heapq.heappush(local_heap, item_t)
elif total_pnl > local_heap[0][0]:
heapq.heapreplace(local_heap, item_t)
return local_heap
# ──────────────────────────────────────────────────────────────────────────────
# 캔들 로드 (param_search_scalping.py 와 동일 패턴)
# ──────────────────────────────────────────────────────────────────────────────
def _load_candles_for_search(
start: str,
end: str,
rsi_period: int,
*,
market: Optional[str] = None,
codes_filter: Optional[List[str]] = None,
) -> dict:
db = TradeDB()
codes_candles: Dict[str, List[Dict]] = {}
try:
start_key = (start.replace("-", "") + "0000") if start else "20260101"
end_key = (end.replace("-", "") + "2359") if end else "99991231"
mk = (market or "").strip().upper()
want = {
str(c).strip().upper()
for c in (codes_filter or [])
if str(c).strip()
}
if mk in ("US", "KR"):
codes_raw = db.conn.execute(
"SELECT DISTINCT code FROM ws_candles WHERE timeframe=1 AND market=%s "
"AND candle_time >= %s AND candle_time <= %s ORDER BY code",
[mk, start_key, end_key],
).fetchall()
else:
codes_raw = db.conn.execute(
"SELECT DISTINCT code FROM ws_candles WHERE timeframe=1 "
"AND candle_time >= %s AND candle_time <= %s ORDER BY code",
[start_key, end_key]
).fetchall()
codes = [r["code"] for r in codes_raw]
if mk == "US":
try:
from permanent_subs import codes_by_market as _perm_us
_us_perm = {str(r.get("code") or "").upper() for r in _perm_us(db, "US")}
if _us_perm:
codes = [c for c in codes if str(c).upper() in _us_perm] or codes
except Exception:
pass
if want:
codes = [c for c in codes if str(c).upper() in want]
for code in codes:
if mk in ("US", "KR"):
rows = db.conn.execute(
"SELECT candle_time, open, high, low, close, volume "
"FROM ws_candles "
"WHERE timeframe=1 AND code=%s AND market=%s "
"AND candle_time >= %s AND candle_time <= %s "
"AND is_confirmed=1 "
"ORDER BY candle_time ASC",
[code, mk, start_key, end_key]
).fetchall()
else:
rows = db.conn.execute(
"SELECT candle_time, open, high, low, close, volume "
"FROM ws_candles "
"WHERE timeframe=1 AND code=%s "
"AND candle_time >= %s AND candle_time <= %s "
"AND is_confirmed=1 "
"ORDER BY candle_time ASC",
[code, start_key, end_key]
).fetchall()
if len(rows) < rsi_period + 5:
continue
codes_candles[code] = [dict(r) for r in rows]
if codes_candles:
from kis_trader.backtest.momentum_backtest_common import (
prepend_momentum_candle_warmup,
)
prepend_momentum_candle_warmup(db, codes_candles, start_key[:12])
finally:
db.close()
return codes_candles
# ──────────────────────────────────────────────────────────────────────────────
# 메인 탐색 루틴
# ──────────────────────────────────────────────────────────────────────────────
def run_search(
start: str, end: str, mode: str, top_n: int,
min_trades: int, min_win_rate: float, min_pf: float,
apply_rank: Optional[int],
from_file_only: bool = False,
use_fallback_universe: bool = False,
slot_money: Optional[float] = None,
max_stocks: Optional[int] = None,
total_budget_krw: Optional[float] = None,
time_start_hm: Optional[int] = None,
time_end_hm: Optional[int] = None,
max_combos: Optional[int] = None,
orderbook_filter: str = "off",
stage2_core_lock: bool = False,
stage1_json: Optional[str] = None,
) -> bool:
if from_file_only and apply_rank is not None and apply_rank >= 1:
_apply_from_latest_json(apply_rank)
return True
grid = _momentum_grids()[mode]
keys = list(grid.keys())
stage1_baselines: List[Dict[str, Any]] = []
stage1_source_path = ""
stage2_core_lock_meta: Dict[str, Any] = {}
if stage2_core_lock:
# 2단계 정밀: 1단계 Top-N 코어 고정 × 호가 3축 전수 (균등샘플 아님).
stage1_source_path = _resolve_stage1_json_path(stage1_json)
core_top_n = get_env_int("MOMENTUM_STAGE2_CORE_TOPN", 7)
dict_combos, keys, stage1_data, stage1_baselines = _build_stage2_core_lock_combos(
grid, stage1_source_path, core_top_n,
)
ob_axes_n = grid_total_combinations([grid[k] for k in _MOMENTUM_OB_AXES])
total_grid = len(dict_combos)
total_combos = total_grid
max_combos_cap = total_grid
dropped_by_cap = 0
sampled = len(dict_combos)
# 2단계는 호가필터 ON + kiwoom 본체 강제
orderbook_filter = "on"
stage2_core_lock_meta = {
"stage2_core_lock": True,
"stage1_json": stage1_source_path,
"stage1_start": stage1_data.get("start"),
"stage1_end": stage1_data.get("end"),
"core_top_n": core_top_n,
"ob_combos_per_core": ob_axes_n,
"stage1_baselines": stage1_baselines,
}
print(f"\n[MOMENTUM STAGE2 · 코어고정] 1단계: {stage1_source_path}")
print(
f"📌 Top-{core_top_n} 코어(양수) × 호가 {ob_axes_n}전수 "
f"= {sampled:,}조합 (균등샘플 없음) | 기간: {start} ~ {end}"
)
for k in _MOMENTUM_OB_AXES:
hint = MOMENTUM_GRID_AXIS_HINTS_KO.get(k)
if hint:
print(f" [{k}] {hint}{grid.get(k)}")
print("📌 1단계 코어 성과(필터 OFF · 비교 기준):")
for i, bl in enumerate(stage1_baselines, 1):
print(
f" 코어#{i}: 손익 {bl.get('total_pnl', 0):+,.0f}원 | "
f"승률 {bl.get('win_rate', 0):.1f}% | "
f"거래 {bl.get('total_trades', 0)} | PF {bl.get('pf', 0):.2f}"
)
# ★ 2단계 = 검증된 코어에 호가필터만 씌워 '비교'하는 단계.
# 여기에 승률/PF/거래수 합격필터를 적용하면 전부 탈락 → "만족 0개" 가 떠버린다.
# 필터를 켜서 결과가 '나빠지는 것'도 의미있는 데이터이므로, 컷 없이 전 조합을 출력한다.
# (0거래 조합 = "필터가 해당 코어의 진입을 전부 막음" 을 그대로 보여줌)
min_trades = 0
min_win_rate = 0.0
min_pf = 0.0
print("📌 2단계 출력 정책: 합격필터 없음 — 전 조합 비교 출력(거래0 포함)")
else:
axes = [grid[k] for k in keys]
# 데카르트곱 전체를 RAM 에 펼치지 않는다 (호가 3축 스윕 시 수천만 → OOM 방지).
total_combos = grid_total_combinations(axes)
dict_combos, total_grid, max_combos_cap, dropped_by_cap = cap_combos_uniform_lazy(
keys,
axes,
mode,
strategy_env_prefix="MOMENTUM",
default_fast=get_env_int("MOMENTUM_FAST_MAX_COMBOS", 512),
default_other=(
get_env_int("MOMENTUM_EXIT_MAX_COMBOS", 2000)
if mode == "exit"
else DEFAULT_MAX_COMBOS
),
max_combos_override=max_combos,
valid_fn=_momentum_combo_grid_valid,
)
sampled = len(dict_combos)
print(f"\n[MOMENTUM {mode.upper()} 모드] 그리드: {total_grid:,} → 백테: {sampled:,} | 기간: {start} ~ {end}")
if mode == "fast":
print(
f"📌 [fast] HTS TRIGGER + 청산 광범위 · {len(keys)}축 · "
f"{total_grid:,}{max_combos_cap}균등샘플"
)
elif mode == "exit":
print(
f"📌 [exit] 청산 전용 광범위(어깨·래칫·트레일·SL·TP·시간) · "
f"{total_grid:,}{max_combos_cap}균등샘플 · 진입=DB고정"
)
if dropped_by_cap:
print(f" (max-combos={max_combos_cap} 균등 샘플, 제외 {dropped_by_cap:,}개)")
if not stage2_core_lock:
for k in keys:
hint = MOMENTUM_GRID_AXIS_HINTS_KO.get(k)
if hint:
print(f" [{k}] {hint}")
print(f"📌 1위 정렬 기준: 순수익/MDD 점수 최대 (낙폭 대비 자본효율 — 동점 시 순익)")
print(f"📌 필터: 승률≥{min_win_rate}% · PF≥{min_pf} · 거래≥{min_trades}")
print("=" * 70)
t0 = time.time()
FIXED_DEFAULTS = _mom_fixed_defaults()
apply_session_to_fixed(
FIXED_DEFAULTS,
time_start_hm=time_start_hm,
time_end_hm=time_end_hm,
)
# ── 틱재생(ws_ticks) — 기본 ON (실매 체결 정합) ──────────────────────
# 실매 청산은 초단위 실제 틱(ws_ticks)으로 체결된다. OHLC 경로(open→high→low→close)
# 는 "고가 먼저" 낙관 가정이라 어깨컷·트레일컷·익절을 실제보다 유리하게 체결해
# 백테 손익을 부풀린다(검증: 2026-07-03 OHLC 순위 전 조합 흑자 → 틱 순위 전 조합
# 적자, 실매 -48,742 정합). OHLC 로 뽑은 "최적"은 실매에서 손실나는 파라미터였다.
# → 파람서치·웹 백테 모두 기본 틱재생. 끄려면 MOMENTUM_BACKTEST_USE_TICK_EXIT=0.
from kis_trader.engine.momentum_tick_replay import (
momentum_backtest_use_tick_entry as _mom_use_tick_entry,
momentum_backtest_use_tick_exit as _mom_use_tick_exit,
)
FIXED_DEFAULTS["backtest_use_tick_entry"] = _mom_use_tick_entry(None)
FIXED_DEFAULTS["backtest_use_tick_exit"] = _mom_use_tick_exit(None)
if FIXED_DEFAULTS["backtest_use_tick_exit"] or FIXED_DEFAULTS["backtest_use_tick_entry"]:
print(
f"📌 틱재생(ws_ticks): 진입={FIXED_DEFAULTS['backtest_use_tick_entry']} "
f"청산={FIXED_DEFAULTS['backtest_use_tick_exit']} — 실매 체결 정합 모드"
)
# ── 호가필터 ON/OFF (2단계 워크플로) ───────────────────────────────
# off(기본): 1단계 — 호가 게이트 없이 코어 파라미터만 순수 탐색(표본↑).
# log_backfill 판정도 무시되어 "필터 없는 세상의 최적" 을 찾는다.
# on : 2단계 — kiwoom_0d 본체로 스프레드/잔량비/매도벽을 실제 적용.
# auto : env/DB 의 *_ORDERBOOK_FILTER_ENABLED 값을 그대로 따름(실매 기본).
_ob_mode = (orderbook_filter or "off").strip().lower()
if _ob_mode == "off":
FIXED_DEFAULTS["_orderbook_filter_enabled"] = False
elif _ob_mode == "on":
FIXED_DEFAULTS["_orderbook_filter_enabled"] = True
# auto → 키 주입 안 함 (orderbook_filter._orderbook_filter_enabled_for_entry 가 DB값 사용)
_ob_filter_on = bool(FIXED_DEFAULTS.get("_orderbook_filter_enabled")) or _ob_mode == "auto"
print(
f"📌 호가필터: {_ob_mode.upper()} "
f"({'적용' if _ob_filter_on else '스킵 — 코어 파라미터 순수 탐색'})"
)
db = TradeDB()
try:
from kis_trader.backtest.backtest_portfolio_common import load_portfolio_env_row
env_row = load_portfolio_env_row(db)
finally:
db.close()
fee_rate, sell_tax, slot_from_env = sbc.fee_and_slot_from_env(env_row, strategy="MOMENTUM")
portfolio = sbc.resolve_scalp_portfolio_params(
env_row,
None,
strategy="MOMENTUM",
slot_money=slot_money if slot_money is not None else slot_from_env,
max_stocks=max_stocks,
total_budget_krw=total_budget_krw,
)
slot_money_v = float(portfolio["slot_money"])
max_stocks_v = int(portfolio["max_stocks"])
total_budget_v = float(portfolio["total_budget_krw"])
period_days = max(
1,
(datetime.strptime(end, "%Y-%m-%d") - datetime.strptime(start, "%Y-%m-%d")).days + 1,
)
print(
f"💼 포트폴리오: 1회 {slot_money_v:,.0f}원 | 동시 {max_stocks_v}종 | "
f"총한도 {total_budget_v:,.0f}원 | 매매 {format_session_hm(FIXED_DEFAULTS)}"
)
if portfolio.get("budget_warning"):
print(f"💰 {portfolio['budget_warning']}")
print("⏳ DB에서 캔들 데이터를 메모리로 불러오는 중...")
codes_candles = _load_candles_for_search(start, end, FIXED_DEFAULTS.get("rsi_period", 3))
print(f"✅ 데이터 로드 완료: {len(codes_candles):,}종목")
# ── 유니버스 ──
# 기본: target_candidates_history(MOMENTUM) 저장 이력 (웹 momentum 탭과 동일).
# --fallback-universe: 시뮬만 사용.
universe_by_slot = None
fallback_sim_interval = 5
start_ymd = start.replace("-", "") if start else ""
end_ymd = end.replace("-", "") if end else ""
if not use_fallback_universe and start_ymd and end_ymd:
try:
from kis_trader.backtest.momentum_backtest_common import resolve_momentum_universe
history, src, n_bins, _scan_iv, timing = resolve_momentum_universe(
start_ymd, end_ymd, use_saved_history=True, strategy_id="MOMENTUM",
)
if history:
universe_by_slot = history
avg = sum(len(v) for v in history.values()) / max(1, n_bins)
timing_label = "strict" if timing == "strict" else "분단위"
print(
f"✅ 유니버스: 신봇 MOMENTUM 이력({timing_label}) | "
f"{n_bins:,}분봉 · 평균 {avg:.1f}종목"
)
else:
print(" MOMENTUM 이력 없음 → 시뮬레이션 fallback 자동 사용")
except Exception as _e:
logging.getLogger("param_search_momentum").debug(
"신봇 유니버스 이력 조회 스킵: %s", _e,
)
if universe_by_slot is None:
universe_top_n = int(os.environ.get("UPDATE_UNIVERSE_TOP_N", "20"))
universe_min_score = float(os.environ.get("UPDATE_UNIVERSE_MIN_SCORE", "4.0"))
# ★ 2026-05: 모멘텀 전용 점수 공식 사용 (Reversal 의 drop_rate 점수 → Momentum 의 강세/추세 점수)
# ``build_universe_simulation_momentum`` 은 시가대비 상승률·고점근접·양봉비율·RSI 모멘텀
# 기반으로 점수 매겨, 진짜 "모멘텀형" 종목만 universe 에 올림.
universe_by_slot = me.build_universe_simulation_momentum(
codes_candles,
top_n=universe_top_n, min_score=universe_min_score,
scan_interval_min=fallback_sim_interval,
)
n_slots = len(universe_by_slot)
avg_per_slot = sum(len(c) for c in universe_by_slot.values()) / max(1, n_slots)
print(f"✅ 유니버스: 모멘텀 시뮬레이션 사용 (Reversal 점수 → Momentum 점수 공식 전환) | "
f"{fallback_sim_interval}분 슬롯 {n_slots}개 · 슬롯당 평균 {avg_per_slot:.1f}종목")
FIXED_DEFAULTS["scan_interval_min"] = fallback_sim_interval
else:
FIXED_DEFAULTS["scan_interval_min"] = 1
orderbook_by_code: Dict[str, Dict[str, List[Dict]]] = {}
program_by_code: Dict[str, Dict[str, List[Dict]]] = {}
log_verdict_by_code: Dict[str, Dict[str, List[Dict]]] = {}
trigger_snap_meta: Dict[str, Any] = {}
ticks_by_code: Dict[str, Dict[str, List[Dict]]] = {}
# kiwoom_0d 본체 재계산 모드 (6/25~ 유효) — 다음을 **모두** 만족할 때만 켠다.
# ① 호가필터 ON (off/1단계에서는 본체가 불필요 → 메모리·시간 절약)
# ② 호가필터 축 중 하나라도 **실제 스윕**(값 2개↑) → 단일 운영값이면 의미 없음
# 단일값 스프레드(0.45 하나)만으로 본체를 끌어와 동작이 바뀌던 버그를 막는다.
_ob_axes = ("max_spread_pct", "min_bid_ask_ratio", "ask_max_mult")
_ob_sweeping = stage2_core_lock or any(
len(set(grid.get(k) or [])) > 1 for k in _ob_axes
)
if _ob_filter_on and _ob_sweeping:
FIXED_DEFAULTS["backtest_use_kiwoom_body_snapshot"] = True
FIXED_DEFAULTS["_backtest_use_kiwoom_body"] = True
_ob_axis_vals = {k: grid.get(k) for k in _ob_axes if len(set(grid.get(k) or [])) > 1}
print(
f"📌 호가필터 스윕 활성 → kiwoom_0d 본체 재계산 "
f"(축 {_ob_axis_vals}, 본체 없는 날짜는 log_backfill 판정 폴백)"
)
elif _ob_filter_on:
print("📌 호가필터 ON · 스윕 없음 → 본체 재계산 생략(판정 재생 경로)")
if codes_candles:
start_key = (start.replace("-", "") + "0000") if start else "20260101"
end_key = (end.replace("-", "") + "2359") if end else "99991231"
_snap_db = TradeDB()
try:
from kis_trader.backtest.trigger_snapshot_loader import (
backtest_needs_trigger_snapshot_load,
load_trigger_snapshots_by_code,
)
if backtest_needs_trigger_snapshot_load(FIXED_DEFAULTS, strategy="MOMENTUM"):
orderbook_by_code, program_by_code, trigger_snap_meta = load_trigger_snapshots_by_code(
_snap_db, start_key, end_key, set(codes_candles.keys()),
engine_params=FIXED_DEFAULTS, strategy="MOMENTUM",
)
log_verdict_by_code = trigger_snap_meta.get("log_verdict_by_code") or {}
ob_rows = int(trigger_snap_meta.get("ws_orderbook_rows_loaded") or 0)
pg_rows = int(trigger_snap_meta.get("ws_program_rows_loaded") or 0)
lv_rows = int(trigger_snap_meta.get("log_verdict_rows") or 0)
print(
f"✅ TRIGGER 스냅샷 ws_orderbook {ob_rows:,}건 | ws_program {pg_rows:,}"
f"| log_backfill 판정 {lv_rows:,}"
f"(호가종목 {trigger_snap_meta.get('orderbook_codes_with_data', 0)} / "
f"프로그램종목 {trigger_snap_meta.get('program_codes_with_data', 0)})"
)
if ob_rows <= 0 and pg_rows <= 0:
print("⚠️ TRIGGER 스냅샷 없음 — 호가·프로그램 필터 스킵 (실매 수집 후 재탐색)")
else:
print("📌 호가·프로그램 필터 OFF → TRIGGER 스냅샷 DB 로딩 생략")
from kis_trader.engine.momentum_tick_replay import (
momentum_backtest_use_tick_entry,
momentum_backtest_use_tick_exit,
)
if momentum_backtest_use_tick_exit(FIXED_DEFAULTS) or momentum_backtest_use_tick_entry(FIXED_DEFAULTS):
from kis_trader.backtest.momentum_tick_loader import (
load_momentum_ticks_by_code,
tick_coverage_stats,
)
ticks_by_code, tick_rows = load_momentum_ticks_by_code(
_snap_db, start_key, end_key, set(codes_candles.keys()),
market=str(FIXED_DEFAULTS.get("market") or "KR").strip().upper() or "KR",
)
tick_meta = tick_coverage_stats(codes_candles, ticks_by_code)
tick_meta["ws_tick_rows_loaded"] = tick_rows
_mkt = str(FIXED_DEFAULTS.get("market") or "KR").strip().upper()
_tick_tbl = "ws_ticks_us" if _mkt == "US" else "ws_ticks"
print(
f"✅ 모멘텀 청산 {_tick_tbl} {tick_rows:,}"
f"(커버리지 {tick_meta.get('tick_bar_coverage_pct', 0):.1f}% · "
f"{tick_meta.get('tick_codes_with_data', 0)}/{tick_meta.get('tick_codes_total', 0)}종목)"
)
if tick_rows <= 0:
print(f"⚠️ {_tick_tbl} 없음 — 1분봉 OHLC 청산 폴백")
finally:
_snap_db.close()
# 멀티프로세싱 — start_key·end_key 둘 다 워커에 전달 (scan_at 유니버스 = 웹 백테 정합)
_ps_start_key = (start.replace("-", "") + "0000") if start else "202601010000"
_ps_end_key = (end.replace("-", "") + "2359") if end else "999912312359"
# ── ws_ticks 공유메모리 — 워커별 사본(~3.3GB×N) 대신 1벌만 공유 ────────────
# 기본 ON(검증 완료: 실측 CPU 184%→1061%·메모리 오히려↓). ON 이면 틱을 컬럼
# (numpy)로 shared_memory 에 1벌 올리고, 워커는 이름으로 read-only attach(mmap
# 공유) → 메모리 N배 제거 + 워커 상한 해제 → CPU 포화 회복. 엔진 핫루프는
# TickColumnView 로 배열을 직접 읽어(속도 회복) dict 경로와 bit-identical.
# 끄려면 MOMENTUM_PARAM_SEARCH_SHARED_TICKS=0. numpy/shm 미지원·빌드 실패 시 자동 폴백.
shared_tick_store = None
if get_env_bool("MOMENTUM_PARAM_SEARCH_SHARED_TICKS", True) and ticks_by_code:
from kis_trader.backtest.shared_ticks import (
build_shared_ticks,
shared_ticks_available,
)
if shared_ticks_available():
shared_tick_store = build_shared_ticks(ticks_by_code)
if shared_tick_store is not None:
import atexit as _atexit
_atexit.register(shared_tick_store.unlink) # 크래시 시 /dev/shm 누수 방지
print("📦 ws_ticks 공유메모리 ON — 워커 attach(read-only), 사본 제거")
ticks_by_code = {} # 부모 대용량 dict 해제 (틱은 shm 에 1벌 존재)
# 해제한 힙을 OS 로 돌려줘 메모리 플래너가 가용 RAM 을 제대로 보고
# 워커 수를 충분히(≈CPU 상한) 잡게 한다. (안 하면 잔존 힙 때문에 과소산정)
import gc as _gc
_gc.collect()
try:
import ctypes as _ctypes
_ctypes.CDLL("libc.so.6").malloc_trim(0)
except Exception:
pass
else:
print("⚠️ ws_ticks 공유메모리 build 실패 — 기존(디스크 pickle) 경로 폴백")
else:
print("⚠️ numpy/shared_memory 미지원 — 기존(디스크 pickle) 경로 폴백")
shared = ParamSearchSharedPayload({
"codes_candles": codes_candles,
"universe_by_slot": universe_by_slot,
"orderbook_by_code": orderbook_by_code,
"program_by_code": program_by_code,
"log_verdict_by_code": log_verdict_by_code,
"trigger_snapshot_meta": trigger_snap_meta,
"ticks_by_code": ticks_by_code,
"ticks_shared_descriptor": (shared_tick_store.descriptor() if shared_tick_store else None),
"start_key": _ps_start_key,
"end_key": _ps_end_key,
})
payload_bytes = shared.estimate_bytes()
n_cpu = os.cpu_count() or 4
_cpu_frac = get_env_float("PARAM_SEARCH_CPU_FRAC", 0.8)
max_workers, chunk_size, _ = param_search_chunk_plan(sampled, payload_bytes)
# ── 틱재생(ws_ticks) payload 시 워커 상한 (OOM 방지) ─────────────────
# 틱재생 기본 ON 이후, 워커별로 ws_ticks·휩쏘틱 등 사적(private) 구조가
# payload 추정치(~수백MB)보다 훨씬 크게(관측: anon-RSS ~3.3GB/워커) 부풀어
# 5워커 동시 실행 시 13GB 머신이 OOM-kill 로 죽는 현상 확인(2026-07-05).
# 꼬리잡기(tail_param_search)와 동일하게 틱 존재 시 워커 수를 상한한다.
# (하드코딩 금지 — DB/Env 로 조정, 기본 3)
if ticks_by_code:
# 기본 2 — 13GB 머신에서 워커당 anon-RSS ~3.3GB(틱 사적구조) 관측,
# 3워커는 부모+payload 합산 시 OOM 경계. 여유 있는 머신은 Env 로 상향.
tick_cap = get_env_int("MOMENTUM_PARAM_SEARCH_MAX_WORKERS_WITH_TICKS", 2)
if tick_cap > 0 and max_workers > tick_cap:
max_workers = tick_cap
print(f"📌 ws_ticks payload — 워커 상한 {max_workers} (MOMENTUM_PARAM_SEARCH_MAX_WORKERS_WITH_TICKS)")
# 공유메모리 틱은 사본이 없어 OOM 위험이 없다 → 기본 상한 없음(CPU/메모리 계획대로).
# 필요 시 Env 로 별도 상한(기본 0=무제한).
if shared_tick_store is not None:
shared_cap = get_env_int("MOMENTUM_PARAM_SEARCH_MAX_WORKERS_WITH_SHARED_TICKS", 0)
if shared_cap > 0 and max_workers > shared_cap:
max_workers = shared_cap
print(f"📌 ws_ticks 공유메모리 — 워커 상한 {max_workers} (MOMENTUM_PARAM_SEARCH_MAX_WORKERS_WITH_SHARED_TICKS)")
chunks = [dict_combos[i:i + chunk_size] for i in range(0, len(dict_combos), chunk_size)]
print(param_search_worker_budget_line(payload_bytes))
print(f"⚙️ 멀티프로세싱 시작 (코어: {n_cpu}, 워커: {max_workers}, CPU {_cpu_frac*100:.0f}%) | 청크: {len(chunks):,}개 (청크당 ~{chunk_size}조합)")
start_time = time.time()
global_heap: List[Tuple[float, float, Tuple[int, int], Dict]] = []
progress_eta = ParamSearchProgressETA(len(chunks), max_workers)
with managed_process_pool(max_workers, shared_payload=shared) as executor:
def _submit(chunk: List[Dict[str, Any]]):
return executor.submit(
_evaluate_momentum_chunk, chunk, FIXED_DEFAULTS, keys, None,
min_trades, min_win_rate, min_pf, top_n, None,
slot_money_v, max_stocks_v, total_budget_v, fee_rate, sell_tax, period_days,
)
processed = 0
use_carriage_return = sys.stdout.isatty()
n_chunks = len(chunks)
# 진행 바 + 경과 + ETA 한 줄 렌더 (청크 완료 시·대기 중 하트비트 시 공용).
# last_eta_str: 청크 사이 대기 중엔 ETA 재계산이 안 되므로 직전 값을 유지해 표시.
_state = {"last_eta": "계산 중..."}
def _render(done: int, elapsed_so_far: float, eta_str: str, running: int = 0) -> None:
progress = (done / max(1, n_chunks)) * 100
bar = ParamSearchProgressETA.render_bar(done / max(1, n_chunks))
elapsed_str = ParamSearchProgressETA.format_elapsed(elapsed_so_far)
run_tail = f" | 진행 {running}" if (running and done > 0) else ""
line = (f"{bar} {progress:.1f}% ({done:,}/{n_chunks:,}) | "
f"경과 {elapsed_str} | 남은 {eta_str}{run_tail}")
if use_carriage_return:
print(f"\r{line} ", end="", flush=True)
else:
print(line, flush=True)
def _heartbeat(done: int, total: int, elapsed_so_far: float, running: int) -> None:
# 청크 사이 텀·워밍업 동안 같은 줄을 경과 시간만 갱신해 다시 그림 (멈춘 듯 안 보이게).
_render(done, elapsed_so_far, _state["last_eta"], running)
for local_results in iter_pool_chunk_results(
executor, chunks, _submit, max_workers=max_workers,
on_heartbeat=_heartbeat,
):
processed += 1
for idx, item in enumerate(local_results):
pnl, wr, _, result_pkg = item
tie = (processed, idx)
entry = (pnl, wr, tie, result_pkg)
if len(global_heap) < top_n:
heapq.heappush(global_heap, entry)
elif pnl > global_heap[0][0]:
heapq.heapreplace(global_heap, entry)
elapsed_so_far = time.time() - start_time
eta_str = ParamSearchProgressETA.format_sec(
progress_eta.remaining_sec(processed, elapsed_so_far),
)
_state["last_eta"] = eta_str
_render(processed, elapsed_so_far, eta_str)
if use_carriage_return:
print(flush=True)
# 워커 종료(풀 close) 후 공유메모리 즉시 해제 (atexit 는 크래시 대비 이중 안전장치).
if shared_tick_store is not None:
shared_tick_store.unlink()
shared_tick_store = None
elapsed = time.time() - start_time
if not global_heap:
print("\n⚠️ 조건을 만족하는 조합이 없습니다. (--min_trades 를 낮추거나 기간을 늘려보세요.)")
return False
# 힙 → 결과 취합 (힙 프리필터는 총손익 최대 기준 — 가장 수익난 후보 top_n 보관)
results = [heapq.heappop(global_heap)[3] for _ in range(len(global_heap))]
# ── 위험조정 점수(순수익/MDD, Calmar 유사) ─────────────────────────────
# PF 는 "손실 대비 이익" 비율일 뿐 낙폭(자금 최대 손실)을 못 본다.
# → 순익 +10만/MDD 8만 (거칠게 벌었다) 보다 순익 +8만/MDD 2만 (안정적)이
# 실전에서 더 좋은 파라미터다. score = 순수익 ÷ max(MDD, 하한) 으로
# '낙폭 대비 얼마나 벌었나'(자본 효율)를 랭킹한다.
# MDD 하한(SCORE_MDD_FLOOR)은 0낙폭·초소액 조합이 무한대 점수로 튀는 것을
# 막는 방어값(하드코딩 금지 규칙 준수 — DB/Env 로 조정).
mdd_floor = get_env_float("MOMENTUM_SCORE_MDD_FLOOR", 10000.0)
for r in results:
_pnl = float(r.get("total_pnl", 0) or 0)
_mdd = float(r.get("mdd", 0) or 0)
r["score"] = round(_pnl / max(_mdd, mdd_floor), 4)
# 정렬: 위험조정 점수(순수익/MDD) 최대 → 동점 시 순익 → 승률.
# 음수(적자) 조합은 score 도 음수라 자연히 하위로 밀린다.
results.sort(key=lambda r: (-r.get("score", 0.0), -r["total_pnl"], -r["win_rate"]))
# ★ 양수 조합 별도 카운트 (양수가 없어도 아래 JSON 저장은 그대로 진행된다)
pos_results = [r for r in results if r.get("total_pnl", 0) > 0]
print(f"\n💰 양수 조합: {len(pos_results)}건 / 전체 top {len(results)}")
# ── 콘솔 출력 ──
print(f"\n완료: {elapsed:.1f}초 | 유효 결과: {len(results):,}")
print(f"\n{'='*100}")
print(f" 🏆 MOMENTUM TOP {min(top_n, len(results))} (순수익/MDD 점수 기준 — 낙폭 대비 자본효율)")
print(f"{'='*100}")
hdr_keys = list(keys)
col_w = max(len(k) for k in hdr_keys) + 2
hdr = " ".join(f"{k:>{col_w}}" for k in hdr_keys)
print(f"{hdr} | {'손익(원)':>12} {'승률':>6} {'거래':>5} {'PF':>5} {'MDD':>10} {'점수':>8}")
print("-" * (len(hdr) + 72))
for r in results[:top_n]:
p = r["params"]
row = " ".join(
(f"{str(p[k]):>{col_w}}" if isinstance(p[k], bool) else f"{p[k]:>{col_w}.4g}")
for k in hdr_keys
)
print(f"{row} | {r['total_pnl']:>+12,.0f} {r['win_rate']:>5.1f}% "
f"{r['total_trades']:>5} {r['pf']:>5.2f} {r.get('mdd', 0):>10,.0f} "
f"{r.get('score', 0.0):>8.3f}")
# 거래수 다양성 통계 (사용자 의문 해소용)
trade_counts = sorted({r["total_trades"] for r in results})
print(f"\n📊 거래수 다양성: {len(trade_counts)}가지 → {trade_counts[:20]}{'...' if len(trade_counts) > 20 else ''}")
if stage2_core_lock and stage1_baselines:
core_keys = [k for k in keys if k not in _MOMENTUM_OB_AXES]
print("\n📊 코어별 최고 호가필터 (stage1 OFF vs stage2 ON):")
for i, bl in enumerate(stage1_baselines, 1):
base_core = bl.get("params") or {}
matching = [
r for r in results
if all(r["params"].get(k) == base_core.get(k) for k in core_keys if k in base_core)
]
if not matching:
print(f" 코어#{i}: stage2 유효 결과 없음")
continue
best = max(matching, key=lambda r: (r["total_pnl"], r["win_rate"]))
bp = best["params"]
wr_delta = float(best["win_rate"]) - float(bl.get("win_rate") or 0)
print(
f" 코어#{i}: stage1 {bl.get('total_pnl', 0):+,.0f}원/{bl.get('win_rate', 0):.1f}% "
f"→ stage2최고 {best['total_pnl']:+,.0f}원/{best['win_rate']:.1f}% "
f"(승률 {wr_delta:+.1f}%p) | "
f"spread={bp.get('max_spread_pct')} ratio={bp.get('min_bid_ask_ratio')} "
f"ask×{bp.get('ask_max_mult')}"
)
# ── JSON 저장 ──
out_dir = _results_dir_for_write()
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
_fname_mode = f"{mode}_stage2" if stage2_core_lock else mode
out_path = os.path.join(out_dir, f"search_momentum_{_fname_mode}_{ts}.json")
payload = {
"strategy": "MOMENTUM",
"mode": mode,
"orderbook_filter": _ob_mode,
"start": start,
"end": end,
"min_win_rate": min_win_rate,
"min_pf": min_pf,
"slot_money": slot_money_v,
"max_stocks": max_stocks_v,
"total_budget_krw": total_budget_v,
"time_start_hm": FIXED_DEFAULTS.get("time_start_hm"),
"time_end_hm": FIXED_DEFAULTS.get("time_end_hm"),
"grid_keys": keys,
"grid_axis_hints": {k: MOMENTUM_GRID_AXIS_HINTS_KO[k] for k in keys if k in MOMENTUM_GRID_AXIS_HINTS_KO},
"cartesian_product": total_combos,
"tested_combos": sampled,
"max_combos_cap": max_combos_cap,
"top": [
{
"rank": i + 1,
"params": r["params"],
"merged_params": r.get("merged_params", r["params"]),
"total_pnl": r["total_pnl"],
"win_rate": r["win_rate"],
"total_trades": r["total_trades"],
"pf": r["pf"],
"avg_hold": r["avg_hold"],
"mdd": r["mdd"],
"score": r.get("score", 0.0),
"bot_pct": r.get("bot_pct"),
"daily_avg_pct": r.get("daily_avg_pct"),
"skipped_micro_buys": r.get("skipped_micro_buys", 0),
}
for i, r in enumerate(results)
],
}
if stage2_core_lock_meta:
payload.update(stage2_core_lock_meta)
payload.update(search_json_meta(portfolio, FIXED_DEFAULTS))
try:
with open(out_path, "w", encoding="utf-8") as f:
json.dump(payload, f, ensure_ascii=False, indent=2)
print(f"\n💾 결과 저장: {out_path}")
except (PermissionError, OSError) as _e:
fallback_dir = os.path.join(os.path.expanduser("~"), ".kis_bot_search_results")
os.makedirs(fallback_dir, exist_ok=True)
out_path = os.path.join(fallback_dir, f"search_momentum_{_fname_mode}_{ts}.json")
with open(out_path, "w", encoding="utf-8") as f:
json.dump(payload, f, ensure_ascii=False, indent=2)
print(f"\n⚠️ 기본 경로 쓰기 실패({type(_e).__name__}). 폴백 저장: {out_path}")
# ── DB 적용 ──
if apply_rank is not None and 1 <= apply_rank <= len(results):
cand = results[apply_rank - 1]
if cand.get("total_pnl", 0) <= 0:
print(f"⚠️ {apply_rank}번째 결과 총손익 ≤ 0 → DB 미적용. 기존 설정 유지.")
else:
merged_apply = merge_param_search_apply_source(cand, payload)
_apply_to_db(merged_apply)
print(f"{apply_rank}번째 결과 적용 완료")
return True
# ──────────────────────────────────────────────────────────────────────────────
# CLI 진입점
# ──────────────────────────────────────────────────────────────────────────────
def main():
from kis_trader.backtest.param_search_dates import resolve_param_search_range
week_ago, today = resolve_param_search_range("MOMENTUM", lookback_days=7)
parser = argparse.ArgumentParser(
description="모멘텀 Grid Search (momentum_engine · MomentumStrategy 실매 동일 경로)",
)
parser.add_argument("--start", default=week_ago, help="시작일 (YYYY-MM-DD, 거래일 보정)")
parser.add_argument("--end", default=today, help="종료일 (YYYY-MM-DD, 주말·휴장이면 이전 장운영일)")
parser.add_argument("--mode", default="exit",
choices=["fast", "exit", "rr", "coarse", "fine", "wide", "full"],
help="탐색 모드: exit(청산광범위·기본) / fast / rr / coarse / fine / wide(축스크리닝) / full")
parser.add_argument(
"--max-combos", type=int, default=None, dest="max_combos",
help="백테 조합 상한 (fast 기본 env MOMENTUM_FAST_MAX_COMBOS=200, coarse 등 5000, 0=무제한)",
)
parser.add_argument("--top", default=1000, type=int, help="상위 N개 출력·JSON 저장 (기본 1000)")
parser.add_argument("--min_trades", default=1, type=int, help="최소 거래 건수 (기본 1 — 0건 조합만 제외)")
add_search_filter_cli_args(parser)
parser.add_argument("--apply", nargs="?", const=1, type=int, default=None, metavar="N",
help="N번째 결과 DB 적용 (기본 1, 총손익>0 일 때만). --from-file 시 최근 JSON에서 적용")
parser.add_argument("--from-file", action="store_true",
help="--apply N 과 함께: 탐색 생략, 최근 search_momentum_*.json 에서만 DB 적용")
parser.add_argument("--fallback-universe", action="store_true", dest="fallback_universe",
help="저장 이력 무시, 시뮬 유니버스만 사용 (기본: 저장 이력 우선)")
parser.add_argument(
"--orderbook-filter", default="off", choices=["off", "on", "auto"],
dest="orderbook_filter",
help="호가필터: off=1단계(코어만·기본) / on=2단계(kiwoom 본체 스프레드·잔량비·매도벽) / auto=DB값",
)
parser.add_argument(
"--stage2-core-lock", action="store_true", dest="stage2_core_lock",
help="2단계 정밀: stage1 Top-N 코어 고정 × 호가 3축 전수 "
"(MOMENTUM_STAGE2_CORE_TOPN 기본 7, --stage1-json 미지정 시 최신 JSON)",
)
parser.add_argument(
"--stage1-json", default=None, dest="stage1_json",
help="2단계용 stage1 결과 JSON 경로 (미지정 시 최신 search_momentum_*.json)",
)
add_portfolio_cli_args(parser)
args = parser.parse_args()
def _sigterm_to_kbd(_sig, _frm):
raise KeyboardInterrupt("SIGTERM 수신 → 워커 정리 후 종료")
try:
signal.signal(signal.SIGTERM, _sigterm_to_kbd)
except Exception:
pass
run_lock = None
if not (args.from_file and args.apply is not None):
run_lock = try_acquire_run_lock("param_search_momentum")
if run_lock is None:
print(
"⛔ 이미 실행 중인 param_search_momentum 이 있습니다.\n"
" ps -ef | grep param_search_momentum\n"
" pkill -f 'param_search_momentum.py' 후 재실행하세요.",
flush=True,
)
sys.exit(2)
try:
run_search(
start = args.start,
end = args.end,
mode = args.mode,
top_n = args.top,
min_trades = args.min_trades,
min_win_rate = args.min_win_rate,
min_pf = args.min_pf,
apply_rank = args.apply,
from_file_only = args.from_file,
use_fallback_universe = args.fallback_universe,
slot_money = args.slot_money,
max_stocks = args.max_stocks,
total_budget_krw = args.total_budget,
time_start_hm = args.time_start,
time_end_hm = args.time_end,
max_combos = args.max_combos,
orderbook_filter = args.orderbook_filter,
stage2_core_lock = args.stage2_core_lock,
stage1_json = args.stage1_json,
)
except KeyboardInterrupt as e:
print(f"\n{e} — 미완료 결과 없이 종료합니다.", flush=True)
sys.exit(130)
finally:
if run_lock is not None:
run_lock.release()
if __name__ == "__main__":
main()