Changes: - Introduced the `e_min_chg_pct` parameter to define the minimum price change percentage compared to the previous day's close, enhancing the momentum trading strategy. - Updated various functions and classes to incorporate this new parameter, ensuring it is utilized in both backtesting and live trading scenarios. - Improved documentation and comments to clarify the purpose and usage of the new parameter across the codebase. Impact: - This addition allows for more precise control over trading conditions, potentially increasing the effectiveness of the momentum strategy while maintaining system integrity and performance.
649 lines
25 KiB
Python
649 lines
25 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
param_search_apply_snapshot.py — 파라미터 탐색 결과 JSON → insert_env_snapshot (config_* + env_config)
|
|
===========================================================================================
|
|
지원 전략 (파일·내용 자동 판별):
|
|
|
|
MOMENTUM search_momentum_*.json top[].merged_params
|
|
SCALP search_*.json (rsi_oversold) top[].db_snapshot 우선, 없으면 merged에서 생성
|
|
BREAKOUT search_breakout_*.json top[].merged_params
|
|
BREAKOUT optuna_breakout_*.json results[N-1] (Optuna — top 없음)
|
|
MOMENTUM optuna_momentum_*.json results[N-1] (Optuna)
|
|
TAIL search_tail_*.json results[N-1].params (정렬된 순서)
|
|
TAIL optuna_tail_*.json results[N-1] (Optuna)
|
|
UPDOW updow_param_*.json top[N-1].apply_cfg + tf
|
|
|
|
사용 예:
|
|
cd /path/to/kis_bot
|
|
python3 kis_trader/backtest/param_search_apply_snapshot.py \\
|
|
--json kis_trader/backtest/results/search_momentum_coarse_20260514_150927.json --rank 1
|
|
|
|
python3 kis_trader/backtest/param_search_apply_snapshot.py \\
|
|
--json kis_trader/backtest/results/search_coarse_20260509_183233.json --rank 1 --dry-run
|
|
|
|
python3 kis_trader/backtest/param_search_apply_snapshot.py \\
|
|
--json results/search_tail_coarse_*.json --rank 3 --allow-non-positive-pnl
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
import sys
|
|
from typing import Any, Dict, List, Optional, Tuple
|
|
|
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
ROOT = os.path.dirname(os.path.dirname(HERE))
|
|
for _p in (ROOT, HERE):
|
|
if _p not in sys.path:
|
|
sys.path.insert(0, _p)
|
|
|
|
from database import TradeDB # noqa: E402
|
|
from kis_trader.backtest.backtest_portfolio_common import ( # noqa: E402
|
|
merge_param_search_apply_source,
|
|
strip_portfolio_keys_from_apply_patch,
|
|
session_env_patch,
|
|
)
|
|
|
|
|
|
def _ranked_items(data: Dict[str, Any]) -> List[Dict[str, Any]]:
|
|
"""Grid ``top[]`` 또는 Optuna ``results[]`` — rank 1 = index 0."""
|
|
top = data.get("top")
|
|
if isinstance(top, list) and top:
|
|
return top
|
|
results = data.get("results")
|
|
if isinstance(results, list) and results:
|
|
return results
|
|
return []
|
|
|
|
|
|
def _env_bool_10(v: Any) -> str:
|
|
if isinstance(v, bool):
|
|
return "1" if v else "0"
|
|
s = str(v).strip().lower()
|
|
return "1" if s in ("1", "true", "t", "y", "yes", "on") else "0"
|
|
|
|
|
|
def _detect_strategy(data: Dict[str, Any], path: str) -> str:
|
|
base = os.path.basename(path).lower()
|
|
s = (data.get("strategy") or "").strip().upper()
|
|
if s in ("US_MOMENTUM", "US-MOMENTUM"):
|
|
return "US_MOMENTUM"
|
|
if s in ("MOMENTUM", "BREAKOUT", "SCALP", "TAIL", "UPDOW"):
|
|
return s
|
|
# CLI / JSON strategy 슬러그
|
|
s_low = (data.get("strategy") or "").strip().lower()
|
|
if s_low == "us_momentum":
|
|
return "US_MOMENTUM"
|
|
|
|
if base.startswith("updow_param_"):
|
|
return "UPDOW"
|
|
if base.startswith("search_tail_") or base.startswith("tail_search_"):
|
|
return "TAIL"
|
|
if base.startswith("search_breakout_") or base.startswith("optuna_breakout_"):
|
|
return "BREAKOUT"
|
|
if base.startswith("optuna_us_momentum_") or base.startswith("search_us_momentum_"):
|
|
return "US_MOMENTUM"
|
|
if base.startswith("search_momentum_") or base.startswith("optuna_momentum_"):
|
|
# market=US 결과 JSON
|
|
if str(data.get("market") or "").strip().upper() == "US":
|
|
return "US_MOMENTUM"
|
|
return "MOMENTUM"
|
|
if base.startswith("optuna_tail_"):
|
|
return "TAIL"
|
|
|
|
if isinstance(data.get("code"), str) and len(str(data.get("code")).strip()) == 6:
|
|
if "tf" in data and isinstance(data.get("top"), list):
|
|
return "UPDOW"
|
|
|
|
results = data.get("results")
|
|
if isinstance(results, list) and results:
|
|
p0 = results[0].get("params") or {}
|
|
if isinstance(p0, dict) and (
|
|
"min_drop_rate" in p0 or "tail_ratio_min" in p0 or "shoulder_cut_pct" in p0
|
|
):
|
|
return "TAIL"
|
|
|
|
top = data.get("top")
|
|
if isinstance(top, list) and top:
|
|
merged = top[0].get("merged_params") or {}
|
|
params = top[0].get("params") or {}
|
|
ref = merged if isinstance(merged, dict) else {}
|
|
if not ref:
|
|
ref = params if isinstance(params, dict) else {}
|
|
if "mom_rsi_min" in ref:
|
|
return "MOMENTUM"
|
|
if "prev_chg_min" in ref:
|
|
return "BREAKOUT"
|
|
if "rsi_oversold" in ref:
|
|
return "SCALP"
|
|
|
|
return "UNKNOWN"
|
|
|
|
|
|
def _patch_from_momentum_merged(m: Dict[str, Any]) -> Dict[str, str]:
|
|
"""merged_params(UI 퍼센트 등) → env_config 문자열 패치 (웹 momentum 저장과 동일 계열)."""
|
|
patch: Dict[str, str] = {}
|
|
|
|
def gi(key: str, default: int = 0) -> int:
|
|
v = m.get(key)
|
|
return int(float(v)) if v not in (None, "") else default
|
|
|
|
def gf(key: str, default: float = 0.0) -> float:
|
|
v = m.get(key)
|
|
return float(v) if v not in (None, "") else default
|
|
|
|
patch["MOMENTUM_RSI_MIN"] = str(gi("mom_rsi_min", 50))
|
|
patch["MOMENTUM_RSI_MAX"] = str(gi("mom_rsi_max", 80))
|
|
|
|
patch["MOMENTUM_VOL_MULT"] = str(gf("mom_vol_mult", 1.5))
|
|
|
|
patch["MOMENTUM_VOL_WIN"] = str(gi("mom_vol_win", 5))
|
|
|
|
# HTS K: 전일종가 대비 최소등락(%) — UI/그리드와 동일 단위(0.2 = 0.2%)
|
|
patch["MOMENTUM_E_MIN_CHG_PCT"] = str(gf("e_min_chg_pct", 0.2))
|
|
|
|
# TIME_* 는 session_env_patch / PARAM_SEARCH_APPLY_SESSION_TIME 경로만.
|
|
# Optuna 과적합 창이 실매 매수시간을 덮지 않도록 여기서는 쓰지 않음.
|
|
|
|
# ※ 모멘텀 apply 는 MOMENTUM_* 만 기록. SCALP_* 를 덮으면 스캘핑 실매/웹이 오염되고,
|
|
# 엔진은 MOMENTUM_COOLDOWN_SEC 등을 읽는데 SCALP_COOLDOWN 만 써서 apply 가 무효가 된다.
|
|
sl_r = str(abs(gf("sl_pct", 1.5)) / 100.0)
|
|
tp_r = str(abs(gf("tp_pct", 2.5)) / 100.0)
|
|
patch["MOMENTUM_STOP_LOSS_PCT"] = sl_r
|
|
patch["MOMENTUM_TAKE_PROFIT_PCT"] = tp_r
|
|
|
|
patch["MOMENTUM_COOLDOWN_SEC"] = str(int(float(gf("cooldown_min", 10)) * 60))
|
|
|
|
md = str(gi("max_daily", 5))
|
|
patch["MOMENTUM_MAX_DAILY"] = md
|
|
|
|
sm = m.get("slot_money")
|
|
if sm not in (None, ""):
|
|
sms = str(int(float(sm)))
|
|
patch["MOMENTUM_SLOT_MONEY"] = sms
|
|
patch["MOMENTUM_MAX_BUY_AMOUNT"] = sms
|
|
|
|
hc = gf("high_chase_thr", 0.96)
|
|
ratio_hc = hc if 0 < hc <= 1 else hc / 100.0
|
|
patch["MOMENTUM_HIGH_CHASE_THR"] = str(ratio_hc)
|
|
|
|
patch["MOMENTUM_MAX_DAILY_CHG"] = str(gf("max_daily_chg", 20.0))
|
|
|
|
mp = str(gf("min_price", 1000))
|
|
patch["MOMENTUM_MIN_PRICE"] = mp
|
|
|
|
ml = str(int(float(m.get("max_loss_krw") or 200000)))
|
|
patch["MOMENTUM_MAX_LOSS_PER_TRADE_KRW"] = ml
|
|
|
|
mm = str(gf("min_margin", 0.2))
|
|
patch["MOMENTUM_MIN_PROFIT_PCT"] = mm
|
|
|
|
if "use_defense_filters" in m:
|
|
patch["MOMENTUM_USE_DEFENSE_FILTERS"] = _env_bool_10(m.get("use_defense_filters"))
|
|
|
|
if "mom_max_from_open_pct" in m and m.get("mom_max_from_open_pct") not in (None, ""):
|
|
patch["MOMENTUM_MAX_FROM_OPEN_PCT"] = str(float(m["mom_max_from_open_pct"]))
|
|
if "mom_min_from_open_pct" in m and m.get("mom_min_from_open_pct") not in (None, ""):
|
|
patch["MOMENTUM_MIN_FROM_OPEN_PCT"] = str(float(m["mom_min_from_open_pct"]))
|
|
|
|
x = m.get("tp_max_pct")
|
|
if x not in (None, ""):
|
|
patch["MOMENTUM_TP_MAX_PCT"] = str(abs(float(x)) / 100.0)
|
|
|
|
x = m.get("shoulder_min_high")
|
|
if x not in (None, ""):
|
|
patch["MOMENTUM_SHOULDER_MIN_HIGH_PCT"] = str(abs(float(x)) / 100.0)
|
|
|
|
x = m.get("shoulder_cut_pct")
|
|
if x not in (None, ""):
|
|
patch["MOMENTUM_SHOULDER_CUT_PCT"] = str(abs(float(x)) / 100.0)
|
|
|
|
# 전용 트레일(momentum_engine) — UI 퍼센트 → MOMENTUM_TRAIL_* 비율.
|
|
# trail_pct/arm 우선. 레거시 trail_trigger/stop 만 있으면 과거 Optuna와 같이 OFF(0).
|
|
has_trail_pct = "trail_pct" in m and m.get("trail_pct") not in (None, "")
|
|
has_trail_arm = "trail_arm_pct" in m and m.get("trail_arm_pct") not in (None, "")
|
|
has_legacy_trail = (
|
|
("trail_trigger" in m and m.get("trail_trigger") not in (None, ""))
|
|
or ("trail_stop" in m and m.get("trail_stop") not in (None, ""))
|
|
)
|
|
if has_trail_pct:
|
|
patch["MOMENTUM_TRAIL_PCT"] = str(abs(float(m["trail_pct"])) / 100.0)
|
|
elif has_legacy_trail and not has_trail_arm:
|
|
patch["MOMENTUM_TRAIL_PCT"] = "0"
|
|
if has_trail_arm:
|
|
patch["MOMENTUM_TRAIL_ARM_PCT"] = str(abs(float(m["trail_arm_pct"])) / 100.0)
|
|
elif has_legacy_trail and not has_trail_pct:
|
|
patch["MOMENTUM_TRAIL_ARM_PCT"] = "0"
|
|
|
|
# 래칫·최대보유 — exit Optuna 1위 적용 시 누락되지 않도록 (BREAKOUT_RATCHET_TIERS 와 동일 계열)
|
|
if "ratchet_tiers" in m:
|
|
patch["MOMENTUM_RATCHET_TIERS"] = str(m.get("ratchet_tiers") or "").strip()
|
|
x = m.get("max_hold_bars")
|
|
if x not in (None, ""):
|
|
patch["MOMENTUM_MAX_HOLD_BARS"] = str(int(float(x)))
|
|
|
|
if "use_ema_filter" in m:
|
|
patch["MOMENTUM_USE_EMA_FILTER"] = _env_bool_10(m.get("use_ema_filter"))
|
|
if "use_rsi_max_filter" in m:
|
|
patch["MOMENTUM_USE_RSI_MAX_FILTER"] = _env_bool_10(m.get("use_rsi_max_filter"))
|
|
if "pattern_breakout" in m:
|
|
patch["MOMENTUM_PATTERN_BREAKOUT"] = _env_bool_10(m.get("pattern_breakout"))
|
|
if "pattern_pullback" in m:
|
|
patch["MOMENTUM_PATTERN_PULLBACK"] = _env_bool_10(m.get("pattern_pullback"))
|
|
if "use_high_chase_filter" in m:
|
|
patch["MOMENTUM_USE_HIGH_CHASE_FILTER"] = _env_bool_10(m.get("use_high_chase_filter"))
|
|
if "use_daily_range_filter" in m:
|
|
patch["MOMENTUM_USE_DAILY_RANGE_FILTER"] = _env_bool_10(m.get("use_daily_range_filter"))
|
|
x = m.get("chase_lookback_min")
|
|
if x not in (None, ""):
|
|
patch["MOMENTUM_CHASE_LOOKBACK_MIN"] = str(int(float(x)))
|
|
x = m.get("pullback_lookback_min")
|
|
if x not in (None, ""):
|
|
patch["MOMENTUM_PULLBACK_LOOKBACK_MIN"] = str(int(float(x)))
|
|
x = m.get("pullback_min_pct")
|
|
if x not in (None, ""):
|
|
patch["MOMENTUM_PULLBACK_MIN_PCT"] = str(float(x))
|
|
x = m.get("pullback_max_pct")
|
|
if x not in (None, ""):
|
|
patch["MOMENTUM_PULLBACK_MAX_PCT"] = str(float(x))
|
|
x = m.get("setup_vol_max_mult")
|
|
if x not in (None, ""):
|
|
patch["MOMENTUM_SETUP_VOL_MAX_MULT"] = str(float(x))
|
|
x = m.get("setup_bear_bars_min")
|
|
if x not in (None, ""):
|
|
patch["MOMENTUM_SETUP_BEAR_BARS_MIN"] = str(int(float(x)))
|
|
x = m.get("ema_fast_period")
|
|
if x not in (None, ""):
|
|
patch["MOMENTUM_EMA_FAST_PERIOD"] = str(int(float(x)))
|
|
x = m.get("ema_slow_period")
|
|
if x not in (None, ""):
|
|
patch["MOMENTUM_EMA_SLOW_PERIOD"] = str(int(float(x)))
|
|
|
|
from kis_trader.engine.orderbook_env import orderbook_params_to_env_patch
|
|
patch.update(orderbook_params_to_env_patch("MOMENTUM", m))
|
|
|
|
return patch
|
|
|
|
|
|
def apply_env_patch(patch: Dict[str, str]) -> Optional[int]:
|
|
"""병합 스냅샷에 patch 반영 후 insert_env_snapshot — config_scalp/momentum/… 분리 저장."""
|
|
if not patch:
|
|
return None
|
|
db = TradeDB()
|
|
try:
|
|
latest = db.get_latest_env()
|
|
snap = dict(latest["snapshot"]) if latest else {}
|
|
snap.update(patch)
|
|
return db.insert_env_snapshot(snap)
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
def _patch_from_breakout_merged(m: Dict[str, Any]) -> Dict[str, str]:
|
|
patch: Dict[str, str] = {}
|
|
|
|
def gv(key: str) -> Optional[Any]:
|
|
v = m.get(key)
|
|
return None if v in (None, "") else v
|
|
|
|
x = gv("lookback_min")
|
|
if x is not None:
|
|
patch["BREAKOUT_LOOKBACK_MIN"] = str(int(float(x)))
|
|
x = gv("vol_window")
|
|
if x is not None:
|
|
patch["BREAKOUT_VOL_WIN"] = str(int(float(x)))
|
|
x = gv("vol_mult")
|
|
if x is not None:
|
|
patch["BREAKOUT_VOL_MULT"] = str(float(x))
|
|
x = gv("min_turnover_1m_pct")
|
|
if x is not None:
|
|
patch["BREAKOUT_MIN_TURNOVER_1M_PCT"] = str(float(x))
|
|
x = gv("prev_chg_min")
|
|
if x is not None:
|
|
patch["BREAKOUT_PREV_CHG_MIN"] = str(float(x))
|
|
x = gv("prev_chg_max")
|
|
if x is not None:
|
|
patch["BREAKOUT_PREV_CHG_MAX"] = str(float(x))
|
|
|
|
x = gv("sl_pct")
|
|
if x is not None:
|
|
patch["BREAKOUT_STOP_LOSS_PCT"] = str(-abs(float(x)) / 100.0)
|
|
x = gv("tp_pct")
|
|
if x is not None:
|
|
patch["BREAKOUT_TAKE_PROFIT_PCT"] = str(abs(float(x)) / 100.0)
|
|
x = gv("trail_pct")
|
|
if x is not None:
|
|
patch["BREAKOUT_TRAIL_PCT"] = str(abs(float(x)) / 100.0)
|
|
x = gv("trail_arm_pct")
|
|
if x is not None:
|
|
patch["BREAKOUT_TRAIL_ARM_PCT"] = str(abs(float(x)) / 100.0)
|
|
x = gv("shoulder_min_high_pct")
|
|
if x is not None:
|
|
patch["BREAKOUT_SHOULDER_MIN_HIGH_PCT"] = str(abs(float(x)) / 100.0)
|
|
x = gv("shoulder_cut_pct")
|
|
if x is not None:
|
|
patch["BREAKOUT_SHOULDER_CUT_PCT"] = str(abs(float(x)) / 100.0)
|
|
# ── ATR 동적 손절 (sl_min/max 는 % 단위 그대로 — 엔진 _breakout_sl_line 가 /100) ──
|
|
x = gv("sl_mode")
|
|
if x is not None:
|
|
patch["BREAKOUT_SL_MODE"] = str(x).strip().lower()
|
|
x = gv("atr_period")
|
|
if x is not None:
|
|
patch["BREAKOUT_ATR_PERIOD"] = str(int(float(x)))
|
|
x = gv("atr_sl_mult")
|
|
if x is not None:
|
|
patch["BREAKOUT_ATR_SL_MULT"] = str(float(x))
|
|
x = gv("atr_sl_min_pct")
|
|
if x is not None:
|
|
patch["BREAKOUT_ATR_SL_MIN_PCT"] = str(float(x))
|
|
x = gv("atr_sl_max_pct")
|
|
if x is not None:
|
|
patch["BREAKOUT_ATR_SL_MAX_PCT"] = str(float(x))
|
|
# 래칫(단계식 트레일) 문자열 — % 단위 그대로 저장(엔진 _breakout_ratchet_tiers 가 /100).
|
|
# 빈 문자열("")=OFF 도 명시 저장하여, 기존 DB 래칫을 1위가 OFF 일 때 확실히 끈다.
|
|
if "ratchet_tiers" in m:
|
|
patch["BREAKOUT_RATCHET_TIERS"] = str(m.get("ratchet_tiers") or "").strip()
|
|
x = gv("max_hold_bars")
|
|
if x is not None:
|
|
patch["BREAKOUT_MAX_HOLD_BARS"] = str(int(float(x)))
|
|
|
|
# 가짜돌파(휩쏘) 필터 — % 그대로 저장
|
|
x = gv("confirm_margin_pct")
|
|
if x is not None:
|
|
patch["BREAKOUT_CONFIRM_MARGIN_PCT"] = str(float(x))
|
|
x = gv("body_min_pct")
|
|
if x is not None:
|
|
patch["BREAKOUT_BODY_MIN_PCT"] = str(float(x))
|
|
|
|
# TIME_* / GOLDEN_END 는 session_env_patch(기본 OFF) — Optuna apply 가 창을 자르지 않음
|
|
|
|
x = gv("max_daily")
|
|
if x is not None:
|
|
# 일일횟수는 포트폴리오성 운영값 — Optuna apply 가 1로 덮지 않음(실매·웹에서 별도)
|
|
pass
|
|
x = gv("cooldown_min")
|
|
if x is not None:
|
|
patch["BREAKOUT_COOLDOWN_SEC"] = str(int(float(x) * 60))
|
|
x = gv("max_daily_chg")
|
|
if x is not None:
|
|
patch["BREAKOUT_MAX_DAILY_CHG"] = str(float(x))
|
|
x = gv("min_price")
|
|
if x is not None:
|
|
patch["BREAKOUT_MIN_PRICE"] = str(int(float(x)))
|
|
x = gv("entry_mode")
|
|
if x is not None:
|
|
patch["BREAKOUT_ENTRY_MODE"] = str(x).strip().lower()
|
|
x = gv("intrabar_slippage_pct")
|
|
if x is not None:
|
|
patch["BREAKOUT_INTRABAR_SLIPPAGE_PCT"] = str(float(x))
|
|
x = gv("max_loss_krw")
|
|
sm_raw = gv("slot_money")
|
|
ml_i = None
|
|
if x is not None:
|
|
from kis_trader.strategies.breakout import ( # noqa: WPS433
|
|
breakout_invest_amount_krw,
|
|
normalize_breakout_max_loss_krw,
|
|
)
|
|
ml_i = normalize_breakout_max_loss_krw(x)
|
|
patch["BREAKOUT_MAX_LOSS_PER_TRADE_KRW"] = str(ml_i)
|
|
patch["MAX_LOSS_PER_TRADE_KRW"] = str(ml_i)
|
|
if sm_raw is not None:
|
|
cap = int(float(sm_raw))
|
|
patch["BREAKOUT_SLOT_MONEY"] = str(cap)
|
|
patch["SLOT_MONEY_DEFAULT"] = str(cap)
|
|
patch["BREAKOUT_MAX_BUY_AMOUNT"] = str(cap)
|
|
patch["MAX_BUY_AMOUNT_PER_STOCK"] = str(cap)
|
|
elif ml_i is not None:
|
|
from kis_trader.strategies.breakout import breakout_invest_amount_krw # noqa: WPS433
|
|
sl_ui = float(m.get("sl_pct") or 1.5)
|
|
cap = int(breakout_invest_amount_krw(ml_i, sl_ui, 2_000_000))
|
|
patch["BREAKOUT_SLOT_MONEY"] = str(cap)
|
|
patch["SLOT_MONEY_DEFAULT"] = str(cap)
|
|
patch["BREAKOUT_MAX_BUY_AMOUNT"] = str(cap)
|
|
|
|
if "use_ema_filter" in m:
|
|
patch["BREAKOUT_USE_EMA_FILTER"] = _env_bool_10(m.get("use_ema_filter"))
|
|
x = gv("ema_fast_period")
|
|
if x is not None:
|
|
patch["BREAKOUT_EMA_FAST_PERIOD"] = str(int(float(x)))
|
|
x = gv("ema_slow_period")
|
|
if x is not None:
|
|
patch["BREAKOUT_EMA_SLOW_PERIOD"] = str(int(float(x)))
|
|
|
|
from kis_trader.engine.orderbook_env import orderbook_params_to_env_patch
|
|
patch.update(orderbook_params_to_env_patch("BREAKOUT", m))
|
|
|
|
return patch
|
|
|
|
|
|
def _apply_tail_params(params: Dict[str, Any]) -> None:
|
|
"""tail_param_search.apply_params_to_db 와 동일 경로 (단일 INSERT)."""
|
|
import tail_param_search as tps # noqa: WPS433 — 동일 디렉터리 스크립트
|
|
|
|
tps.apply_params_to_db(params)
|
|
|
|
|
|
def _apply_updow_row(row: Dict[str, Any], tf: int) -> Dict[str, str]:
|
|
from kis_trader.strategies import updow_buy as udb
|
|
|
|
ac = row.get("apply_cfg") or {}
|
|
if not isinstance(ac, dict):
|
|
return {}
|
|
return udb.env_snapshot_patch_from_engine_cfg(ac, tf_min=tf)
|
|
|
|
|
|
def _apply_updow_stock(code: str, row: Dict[str, Any], tf: int, dry_run: bool) -> None:
|
|
"""탐색 ``apply_cfg`` → ``updow_stock_config`` (하락매수 종목 단일 소스)."""
|
|
from kis_trader.strategies import updow_holding_cfg as uhc
|
|
|
|
ac = row.get("apply_cfg") or {}
|
|
if not isinstance(ac, dict) or not ac:
|
|
print("⚠️ updow_stock_config 미반영: apply_cfg 없음")
|
|
return
|
|
code = str(code).strip()
|
|
if len(code) != 6:
|
|
print("⚠️ updow_stock_config 미반영: code 없음/형식 오류")
|
|
return
|
|
if dry_run:
|
|
print(f"[dry-run] updow_stock_config {code} ← apply_cfg keys={list(ac.keys())}")
|
|
return
|
|
db = TradeDB()
|
|
try:
|
|
import holding_bot as hb
|
|
|
|
uhc.ensure_updow_stock_config_table(db)
|
|
if tf not in hb.KIWOOM_MINUTE_TICS:
|
|
tf = 60
|
|
meta = uhc.get_updow_stock_meta(db, code)
|
|
name = str(meta.get("name") if meta else code).strip() or code
|
|
uhc.set_updow_stock_config(db, code, name, ac, tf_min=tf)
|
|
mh = int(float(ac.get("max_hold_bars", 16)))
|
|
print(f"✅ updow_stock_config 저장 ({code}, tf={tf}, max_hold={mh})")
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
def main(argv: Optional[List[str]] = None) -> int:
|
|
ap = argparse.ArgumentParser(
|
|
description="파라미터 탐색 JSON의 N위 조합을 env_config 에 INSERT (최신 행 복사 후 패치)",
|
|
)
|
|
ap.add_argument("--json", required=True, help="결과 JSON 경로")
|
|
ap.add_argument("--rank", type=int, default=1, help="순위 (1부터). TAIL은 results 정렬 기준")
|
|
ap.add_argument("--dry-run", action="store_true", help="INSERT 생략, 패치 키만 출력")
|
|
ap.add_argument(
|
|
"--allow-non-positive-pnl",
|
|
action="store_true",
|
|
help="total_pnl<=0 이더라도 적용 (기본: 해당 시 경고 후 종료)",
|
|
)
|
|
ap.add_argument(
|
|
"--env",
|
|
action="store_true",
|
|
help="UPDOW: env_config 전역 UPDOW_* 도 함께 갱신 (기본: updow_stock_config 만)",
|
|
)
|
|
args = ap.parse_args(argv)
|
|
|
|
path = os.path.abspath(args.json)
|
|
if not os.path.isfile(path):
|
|
print(f"❌ 파일 없음: {path}")
|
|
return 2
|
|
|
|
try:
|
|
with open(path, "r", encoding="utf-8") as f:
|
|
data = json.load(f)
|
|
except Exception as e:
|
|
print(f"❌ JSON 로드 실패: {e}")
|
|
return 3
|
|
|
|
strategy = _detect_strategy(data, path)
|
|
if strategy == "UNKNOWN":
|
|
print("❌ 전략을 자동 판별하지 못했습니다. 파일명·키 구조를 확인하세요.")
|
|
return 4
|
|
|
|
rank = max(1, int(args.rank))
|
|
print(f"📂 {path}")
|
|
print(f"📌 전략={strategy} rank={rank}")
|
|
|
|
# ── TAIL: 별도 모듈이 INSERT 수행 ─────────────────────────────
|
|
if strategy == "TAIL":
|
|
results = data.get("results") or []
|
|
if rank > len(results):
|
|
print(f"❌ rank 범위 초과 (1~{len(results)})")
|
|
return 5
|
|
target = results[rank - 1]
|
|
pnl = int(target.get("total_pnl") or 0)
|
|
if pnl <= 0 and not args.allow_non_positive_pnl:
|
|
print(f"⚠️ total_pnl={pnl} ≤ 0 → 중단. 적용하려면 --allow-non-positive-pnl")
|
|
return 6
|
|
params = target.get("params") or {}
|
|
if args.dry_run:
|
|
merged = merge_param_search_apply_source(target, data)
|
|
print("[dry-run] TAIL merged:", json.dumps(merged, ensure_ascii=False, indent=2))
|
|
return 0
|
|
merged = merge_param_search_apply_source(target, data)
|
|
_apply_tail_params(merged)
|
|
print("✅ TAIL env_config INSERT 완료 (tail_param_search.apply_params_to_db)")
|
|
return 0
|
|
|
|
# ── UPDOW ─────────────────────────────────────────────────────
|
|
if strategy == "UPDOW":
|
|
top = data.get("top") or []
|
|
if rank > len(top):
|
|
print(f"❌ rank 범위 초과 (1~{len(top)})")
|
|
return 5
|
|
row = top[rank - 1]
|
|
pnl = int(row.get("total_pnl") or 0)
|
|
if pnl <= 0 and not args.allow_non_positive_pnl:
|
|
print(f"⚠️ total_pnl={pnl} ≤ 0 → 중단. 적용하려면 --allow-non-positive-pnl")
|
|
return 6
|
|
tf = int(data.get("tf") or 60)
|
|
patch = _apply_updow_row(row, tf=tf)
|
|
if not patch:
|
|
print("❌ apply_cfg 가 비어 있습니다.")
|
|
return 7
|
|
code_h = str(data.get("code") or "").strip()
|
|
if args.dry_run:
|
|
if args.env:
|
|
print("[dry-run] env patch:", json.dumps(patch, ensure_ascii=False, indent=2))
|
|
if code_h:
|
|
_apply_updow_stock(code_h, row, tf=tf, dry_run=True)
|
|
return 0
|
|
if args.env:
|
|
db = TradeDB()
|
|
try:
|
|
latest = db.get_latest_env()
|
|
snap = dict(latest["snapshot"]) if latest else {}
|
|
snap.update(patch)
|
|
eid = db.insert_env_snapshot(snap)
|
|
print(f"✅ UPDOW env_config INSERT id={eid} keys={list(patch.keys())}")
|
|
finally:
|
|
db.close()
|
|
if code_h:
|
|
_apply_updow_stock(code_h, row, tf=tf, dry_run=False)
|
|
else:
|
|
print("⚠️ JSON 에 code 없음 → updow_stock_config 건너뜀")
|
|
return 0
|
|
|
|
# ── top[] (Grid) 또는 results[] (Optuna) — MOMENTUM / SCALP / BREAKOUT ──
|
|
ranked = _ranked_items(data)
|
|
if not ranked:
|
|
print("❌ JSON 에 top[] 또는 results[] 배열이 없습니다.")
|
|
return 8
|
|
if rank > len(ranked):
|
|
print(f"❌ rank 범위 초과 (1~{len(ranked)})")
|
|
return 5
|
|
|
|
item = ranked[rank - 1]
|
|
if str(data.get("engine") or "").lower() == "optuna":
|
|
print(f" (Optuna results[{rank - 1}], trial=#{item.get('optuna_trial_number')})")
|
|
pnl = int(item.get("total_pnl") or 0)
|
|
if pnl <= 0 and not args.allow_non_positive_pnl:
|
|
print(f"⚠️ total_pnl={pnl} ≤ 0 → 중단. 적용하려면 --allow-non-positive-pnl")
|
|
return 6
|
|
|
|
patch: Dict[str, str] = {}
|
|
|
|
if strategy == "MOMENTUM":
|
|
merged = merge_param_search_apply_source(item, data)
|
|
patch = _patch_from_momentum_merged(merged)
|
|
patch.update(session_env_patch("MOMENTUM", merged))
|
|
patch = strip_portfolio_keys_from_apply_patch(patch, "MOMENTUM")
|
|
|
|
elif strategy == "US_MOMENTUM":
|
|
from kis_trader.engine.us_momentum_env_keys import params_to_us_momentum_env_patch
|
|
merged = merge_param_search_apply_source(item, data)
|
|
patch = params_to_us_momentum_env_patch(merged)
|
|
patch.update(session_env_patch("US_MOMENTUM", merged))
|
|
patch = strip_portfolio_keys_from_apply_patch(patch, "US_MOMENTUM")
|
|
patch = {k: v for k, v in patch.items() if str(k).startswith("US_MOMENTUM_")}
|
|
|
|
elif strategy == "BREAKOUT":
|
|
merged = merge_param_search_apply_source(item, data)
|
|
patch = _patch_from_breakout_merged(merged)
|
|
patch.update(session_env_patch("BREAKOUT", merged))
|
|
patch = strip_portfolio_keys_from_apply_patch(patch, "BREAKOUT")
|
|
|
|
elif strategy == "SCALP":
|
|
merged = merge_param_search_apply_source(item, data)
|
|
ds = item.get("db_snapshot")
|
|
if isinstance(ds, dict) and ds:
|
|
# 구 JSON db_snapshot 에 포트폴리오(600만 등)가 있어도 apply 시 제외
|
|
patch = {str(k): str(v) for k, v in ds.items() if v not in (None, "")}
|
|
else:
|
|
try:
|
|
from kis_trader.backtest import param_search_scalping as pss # noqa: WPS433
|
|
|
|
patch = pss._params_to_db_snapshot(merged)
|
|
except Exception as e:
|
|
print(f"❌ SCALP 스냅샷 생성 실패: {e}")
|
|
return 9
|
|
patch = strip_portfolio_keys_from_apply_patch(patch, "SCALP")
|
|
|
|
if not patch:
|
|
print("❌ 적용할 패치가 비어 있습니다.")
|
|
return 10
|
|
|
|
meta_line = (
|
|
f" 기간={data.get('start')}~{data.get('end')} mode={data.get('mode')} "
|
|
f"pnl={item.get('total_pnl')} win={item.get('win_rate')}% trades={item.get('total_trades')}"
|
|
)
|
|
print(meta_line)
|
|
|
|
if args.dry_run:
|
|
print("[dry-run] patch keys:", len(patch))
|
|
print(json.dumps(patch, ensure_ascii=False, indent=2))
|
|
return 0
|
|
|
|
eid = apply_env_patch(patch)
|
|
if eid is None:
|
|
print("❌ insert_env_snapshot 실패")
|
|
return 11
|
|
print(f"✅ config_* + env_config INSERT id={eid} 갱신 키 수={len(patch)}")
|
|
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|