Files
kis_bot/kis_trader/backtest/param_search_apply_snapshot.py
Your Name 8fbba264ba feat(옵투나·웹): 후처리 재탐색·ob_modes·적용감사·수집통계
- Optuna web jobs/TPE/apply snapshot·틱로더 정합, jobs limit·감사로그
- 백테 UI 호가모드·후보 적용 흐름, feed_collect_stats API/탭
- 가설검증·교차검증 룰, 4전략 스모크·OB slot41 진단 스크립트

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-27 15:23:44 +09:00

685 lines
27 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))
patch.update(_whipsaw_combo_to_env_patch("MOMENTUM", m))
return patch
def _whipsaw_combo_to_env_patch(strategy: str, m: Dict[str, Any]) -> Dict[str, str]:
"""TPE/Grid merged 의 whipsaw_* → {STRAT}_WHIPSAW_* (돌파·꼬리 스킵은 호출측)."""
if "whipsaw_enabled" not in m and "whipsaw_subbar_sec" not in m:
return {}
from kis_trader.backtest.optuna_whipsaw_recommend import _whipsaw_env_prefix
pfx = _whipsaw_env_prefix(strategy)
if not pfx:
return {}
out: Dict[str, str] = {}
if "whipsaw_enabled" in m and m.get("whipsaw_enabled") is not None:
on = str(m.get("whipsaw_enabled")).strip().lower() in ("1", "true", "yes", "on")
out[f"{pfx}_WHIPSAW_FILTER_ENABLED"] = "true" if on else "false"
if m.get("whipsaw_subbar_sec") not in (None, ""):
out[f"{pfx}_WHIPSAW_SUBBAR_SEC"] = str(int(float(m["whipsaw_subbar_sec"])))
if m.get("whipsaw_lookback_sec") not in (None, ""):
out[f"{pfx}_WHIPSAW_LOOKBACK_SEC"] = str(int(float(m["whipsaw_lookback_sec"])))
if m.get("whipsaw_dip_pct") not in (None, ""):
out[f"{pfx}_WHIPSAW_DIP_PCT"] = str(float(m["whipsaw_dip_pct"]))
if m.get("whipsaw_recovery_tol_pct") not in (None, ""):
out[f"{pfx}_WHIPSAW_RECOVERY_TOL_PCT"] = str(float(m["whipsaw_recovery_tol_pct"]))
return out
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)
env_id = db.insert_env_snapshot(snap)
# insert 가 invalidate 하지만, 웹 폼 즉시 재조회(/api/env/params?fresh=1)와
# 같은 프로세스 백테가 옛 호가·휩쏘를 쓰지 않도록 한 번 더 보장.
try:
from kis_trader.utils.env import invalidate_merged_env_cache
invalidate_merged_env_cache()
except Exception:
pass
return env_id
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))
# 돌파 휩쏘는 DB 적용 스킵(기존) — TPE에도 휩쏘 축 없음
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:
from kis_trader.utils.legacy_root import ensure_legacy_root
ensure_legacy_root()
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())