- Optuna web jobs/TPE/apply snapshot·틱로더 정합, jobs limit·감사로그 - 백테 UI 호가모드·후보 적용 흐름, feed_collect_stats API/탭 - 가설검증·교차검증 룰, 4전략 스모크·OB slot41 진단 스크립트 Co-authored-by: Cursor <cursoragent@cursor.com>
685 lines
27 KiB
Python
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())
|