변경 사항 ---- - _test_kiwoom_condition_list.py: 키움 웹소켓 조건검색 '목록조회' 기능을 단독으로 테스트하는 스크립트 추가 - _test_kiwoom_condition_realtime.py: 'momentum' 조건식을 실시간으로 등록하고 초기 매칭 종목 리스트 및 실시간 편입/이탈을 수신하는 테스트 스크립트 추가 - _verify_columnar_bitid.py, _verify_shared_e2e_breakout.py, _verify_shared_e2e.py: 공유 메모리 및 dict 간의 데이터 일관성을 검증하는 테스트 추가 영향 ---- - 신규 테스트 스크립트 추가로 키움 웹소켓 API의 기능 검증 및 안정성을 높임 - 기존 기능에 대한 영향 없음 Co-authored-by: Cursor <cursoragent@cursor.com>
619 lines
23 KiB
Python
619 lines
23 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
|
|
TAIL search_tail_*.json results[N-1].params (정렬된 순서)
|
|
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 _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_"):
|
|
return "BREAKOUT"
|
|
if base.startswith("search_momentum_"):
|
|
return "MOMENTUM"
|
|
|
|
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[] 기반 (MOMENTUM / SCALP / BREAKOUT) ─────────────────
|
|
top = data.get("top") or []
|
|
if not top:
|
|
print("❌ JSON 에 top 배열이 없습니다.")
|
|
return 8
|
|
if rank > len(top):
|
|
print(f"❌ rank 범위 초과 (1~{len(top)})")
|
|
return 5
|
|
|
|
item = top[rank - 1]
|
|
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())
|