Changes: - Introduced new files for strategy definitions and study names. - Enhanced `backtest_web.py` with functions to handle integer display prices and trade data formatting. - Updated backtesting logic to incorporate end-of-day (EOD) parameters for breakout and momentum strategies. - Added EOD configuration options in the database and parameter search files. Impact: - These changes improve the modularity and usability of the backtesting framework, allowing for better integration of EOD strategies and clearer trade data presentation.
637 lines
24 KiB
Python
637 lines
24 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,
|
|
portfolio_env_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 ("MOMENTUM", "BREAKOUT", "SCALP", "TAIL", "UPDOW"):
|
|
return s
|
|
|
|
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("search_momentum_") or base.startswith("optuna_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["SCALP_MOM_RSI_MIN"] = patch["MOMENTUM_RSI_MIN"]
|
|
patch["SCALP_MOM_RSI_MAX"] = patch["MOMENTUM_RSI_MAX"]
|
|
|
|
patch["MOMENTUM_VOL_MULT"] = str(gf("mom_vol_mult", 1.5))
|
|
patch["SCALP_MOM_VOL_MULT"] = patch["MOMENTUM_VOL_MULT"]
|
|
|
|
patch["MOMENTUM_VOL_WIN"] = str(gi("mom_vol_win", 5))
|
|
patch["SCALP_MOM_VOL_WIN"] = patch["MOMENTUM_VOL_WIN"]
|
|
|
|
patch["MOMENTUM_TIME_END_HM"] = str(gi("mom_time_end_hm", 1430))
|
|
patch["SCALP_MOM_TIME_END_HM"] = patch["MOMENTUM_TIME_END_HM"]
|
|
|
|
ts = m.get("time_start_hm")
|
|
if ts not in (None, ""):
|
|
patch["MOMENTUM_TIME_START"] = str(int(float(ts)))
|
|
|
|
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["SCALP_STOP_LOSS_PCT"] = sl_r
|
|
patch["MOMENTUM_TAKE_PROFIT_PCT"] = tp_r
|
|
patch["SCALP_TAKE_PROFIT_PCT"] = tp_r
|
|
|
|
patch["SCALP_ATR_UP_MULT"] = str(abs(gf("trail_trigger", 0.7)) / 100.0)
|
|
patch["SCALP_ATR_DOWN_MULT"] = str(abs(gf("trail_stop", 0.4)) / 100.0)
|
|
|
|
patch["SCALP_COOLDOWN_SEC"] = str(int(float(gf("cooldown_min", 10)) * 60))
|
|
|
|
md = str(gi("max_daily", 5))
|
|
patch["MOMENTUM_MAX_DAILY"] = md
|
|
patch["SCALP_MAX_DAILY"] = md
|
|
|
|
sm = m.get("slot_money")
|
|
if sm not in (None, ""):
|
|
sms = str(int(float(sm)))
|
|
patch["MOMENTUM_SLOT_MONEY"] = sms
|
|
patch["SLOT_MONEY_DEFAULT"] = sms
|
|
patch["MOMENTUM_MAX_BUY_AMOUNT"] = sms
|
|
patch["MAX_BUY_AMOUNT_PER_STOCK"] = sms
|
|
|
|
hc = gf("high_chase_thr", 0.96)
|
|
ratio_hc = hc if 0 < hc <= 1 else hc / 100.0
|
|
sr = str(ratio_hc)
|
|
patch["HIGH_CHASE_THR"] = sr
|
|
patch["SCALP_HIGH_PRICE_CHASE_THRESHOLD"] = sr
|
|
patch["HIGH_PRICE_CHASE_THRESHOLD"] = sr
|
|
|
|
vchg = str(gf("max_daily_chg", 20.0))
|
|
patch["MAX_DAILY_CHG"] = vchg
|
|
patch["SCALP_MAX_DAILY_CHANGE_PCT"] = vchg
|
|
patch["MAX_DAILY_CHANGE_PCT"] = vchg
|
|
|
|
mp = str(gf("min_price", 1000))
|
|
patch["MOMENTUM_MIN_PRICE"] = mp
|
|
patch["SCALP_MIN_PRICE"] = mp
|
|
|
|
ml = str(int(float(m.get("max_loss_krw") or 200000)))
|
|
patch["MOMENTUM_MAX_LOSS_PER_TRADE_KRW"] = ml
|
|
patch["SCALP_MAX_LOSS_PER_TRADE_KRW"] = ml
|
|
patch["MAX_LOSS_PER_TRADE_KRW"] = ml
|
|
|
|
mm = str(gf("min_margin", 0.2))
|
|
patch["MOMENTUM_MIN_PROFIT_PCT"] = mm
|
|
patch["SCALP_MIN_PROFIT_PCT"] = mm
|
|
|
|
if "use_defense_filters" in m:
|
|
patch["SCALP_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, ""):
|
|
sr = str(abs(float(x)) / 100.0)
|
|
patch["MOMENTUM_TP_MAX_PCT"] = sr
|
|
patch["SCALP_TP_MAX_PCT"] = sr
|
|
|
|
x = m.get("shoulder_min_high")
|
|
if x not in (None, ""):
|
|
sr = str(abs(float(x)) / 100.0)
|
|
patch["MOMENTUM_SHOULDER_MIN_HIGH_PCT"] = sr
|
|
patch["SCALP_SHOULDER_MIN_HIGH_PCT"] = sr
|
|
patch["SHOULDER_MIN_HIGH_PCT"] = sr
|
|
|
|
x = m.get("shoulder_cut_pct")
|
|
if x not in (None, ""):
|
|
sr = str(abs(float(x)) / 100.0)
|
|
patch["MOMENTUM_SHOULDER_CUT_PCT"] = sr
|
|
patch["SCALP_SHOULDER_CUT_PCT"] = sr
|
|
patch["SHOULDER_CUT_PCT"] = sr
|
|
|
|
# 전용 트레일(momentum_engine) — UI 퍼센트(0.5) → 엔진 비율(0.005) 저장.
|
|
# (기존엔 trail_trigger/stop=SCALP_ATR_*만 기록돼 MOMENTUM_TRAIL_PCT가 0으로 남던 버그 수정)
|
|
x = m.get("trail_pct")
|
|
if x not in (None, ""):
|
|
patch["MOMENTUM_TRAIL_PCT"] = str(abs(float(x)) / 100.0)
|
|
x = m.get("trail_arm_pct")
|
|
if x not in (None, ""):
|
|
patch["MOMENTUM_TRAIL_ARM_PCT"] = str(abs(float(x)) / 100.0)
|
|
|
|
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("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)))
|
|
|
|
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))
|
|
|
|
x = gv("time_start_hm")
|
|
if x is not None:
|
|
patch["BREAKOUT_TIME_START"] = str(int(float(x)))
|
|
x = gv("time_end_hm")
|
|
if x is not None:
|
|
te = str(int(float(x)))
|
|
patch["BREAKOUT_TIME_END"] = te
|
|
patch["BREAKOUT_GOLDEN_END_HM"] = te
|
|
|
|
x = gv("max_daily")
|
|
if x is not None:
|
|
patch["BREAKOUT_MAX_DAILY"] = str(int(float(x)))
|
|
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)))
|
|
|
|
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(portfolio_env_patch("MOMENTUM", merged))
|
|
patch.update(session_env_patch("MOMENTUM", merged))
|
|
|
|
elif strategy == "BREAKOUT":
|
|
merged = merge_param_search_apply_source(item, data)
|
|
patch = _patch_from_breakout_merged(merged)
|
|
patch.update(portfolio_env_patch("BREAKOUT", merged))
|
|
patch.update(session_env_patch("BREAKOUT", merged))
|
|
|
|
elif strategy == "SCALP":
|
|
merged = merge_param_search_apply_source(item, data)
|
|
ds = item.get("db_snapshot")
|
|
if isinstance(ds, dict) and ds:
|
|
patch = {str(k): str(v) for k, v in ds.items() if v not in (None, "")}
|
|
patch.update(portfolio_env_patch("SCALP", merged))
|
|
else:
|
|
try:
|
|
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
|
|
|
|
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())
|