Files
kis_bot/kis_trader/backtest/optuna_momentum.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

750 lines
30 KiB
Python

#!/usr/bin/env python3
"""kis_trader/backtest/optuna_momentum.py — 모멘텀 Optuna (Grid add-on)."""
from __future__ import annotations
import json
import logging
import os
import time
from dataclasses import dataclass, field
from datetime import datetime
from typing import Any, Dict, List, Optional
import optuna
from optuna.samplers import RandomSampler, TPESampler
from database import TradeDB
from kis_trader.backtest import momentum_backtest_common as mbc
from kis_trader.backtest import scalping_backtest_common as sbc
from kis_trader.backtest.optuna_search_space import momentum_grid_axis_keys, suggest_momentum_params
from kis_trader.backtest.optuna_momentum_tpe_space import (
momentum_tpe_axis_keys,
suggest_momentum_params_tpe,
)
from kis_trader.backtest.optuna_mode_combo import enrich_out_data_with_mode_combo
from kis_trader.backtest.optuna_common import (
announce_optuna_json_path,
build_optuna_result_tiers,
pick_gated_apply_trial,
release_shared_tick_store,
set_optuna_trial_stability_attrs,
stability_fields_from_trial_attrs,
)
from kis_trader.backtest.param_search_cli_common import (
apply_session_to_fixed,
combo_passes_search_filters,
format_session_hm,
)
from kis_trader.backtest.param_search_momentum import (
MOMENTUM_GRID_AXIS_HINTS_KO,
_load_candles_for_search,
_mom_fixed_defaults,
_momentum_grids,
apply_params_to_db,
evaluate_momentum_param_combo,
)
from kis_trader.backtest.tail_param_search import _results_dir_for_write
from kis_trader.engine import momentum_engine as me
from kis_trader.engine.indicator_cache import attach_indicator_caches_to_params
from kis_trader.utils.env import get_env_bool, get_env_float
logger = logging.getLogger("param_search_optuna")
_FAIL_OBJECTIVE = -1e18
@dataclass
class MomentumSearchContext:
start: str
end: str
mode: str
base_fixed: Dict[str, Any]
codes_candles: Dict[str, List[Dict]]
universe_by_slot: Optional[Dict[str, List[str]]]
ticks_by_code: Any
orderbook_by_code: Dict[str, Any]
program_by_code: Dict[str, Any]
log_verdict_by_code: Dict[str, Any]
fee_rate: float
sell_tax: float
slot_money: float
max_stocks: int
total_budget_krw: float
period_days: int
portfolio: Dict[str, Any]
grid_keys: List[str]
start_key: str
end_key: str
cache_holder: Dict[str, Any] = field(default_factory=dict)
shared_tick_store: Any = None # ws_ticks 공유메모리 핸들 (종료 시 unlink)
market: str = "KR"
# 종목 cfg Optuna: 1종목 유니버스 (없으면 전역)
symbol: str = ""
def prepare_momentum_search_context(
start: str,
end: str,
mode: str,
*,
use_fallback_universe: bool = False,
time_start_hm: Optional[int] = None,
time_end_hm: Optional[int] = None,
slot_money: Optional[float] = None,
max_stocks: Optional[int] = None,
total_budget_krw: Optional[float] = None,
orderbook_filter: str = "off",
market: str = "KR",
codes_filter: Optional[List[str]] = None,
symbol: Optional[str] = None,
history_source: Optional[str] = None,
) -> Optional[MomentumSearchContext]:
mk = (market or "KR").strip().upper() or "KR"
sym = str(symbol or "").strip().upper()
filt: Optional[List[str]] = None
if codes_filter:
filt = [str(c).strip().upper() for c in codes_filter if str(c).strip()]
elif sym:
filt = [sym]
grids = _momentum_grids(market=mk if mk in ("US", "KR") else "KR")
# tpe = 연속 Optuna 전용 (Grid 메뉴 미사용). 기존 fast/fine/… 는 그대로.
if mode == "tpe":
grid: Dict[str, Any] = {}
elif mode not in grids:
logger.error(
"❌ 모멘텀 mode: %s (fast/exit/rr/coarse/fine/wide/full/tpe)",
mode,
)
return None
else:
grid = grids[mode]
base_fixed = _mom_fixed_defaults(market=mk if mk in ("US", "KR") else "KR")
if mode == "tpe":
# 연속 탐색도 HTS skip 스윕 금지 (KR=false 고정 / US=True 고정 — HTS 없음)
if mk != "US":
base_fixed["skip_hts_scan_dupes"] = False
logger.info(
"📌 mode=tpe — 연속(float/int) 탐색 (Grid categorical 미사용, TPE 가 구간 축소)"
)
apply_session_to_fixed(base_fixed, time_start_hm=time_start_hm, time_end_hm=time_end_hm)
from kis_trader.engine.momentum_tick_replay import (
momentum_backtest_use_tick_entry as _te,
momentum_backtest_use_tick_exit as _tx,
)
base_fixed["backtest_use_tick_entry"] = _te(None)
base_fixed["backtest_use_tick_exit"] = _tx(None)
# 절대규칙: Optuna OHLC 폴백으로 숫자 변조 금지
base_fixed["backtest_tick_fallback_ohlc"] = False
_ob_mode = (orderbook_filter or "off").strip().lower()
if mk == "US":
# 해외: 호가 없음 · HTS 없음 · 자정 넘김 · 매매세 0 기본
_ob_mode = "off"
use_fallback_universe = True
base_fixed["_session_wrap_midnight"] = True
# skip_hts 는 _overlay_us 에서 True (HTS 유니버스 없음)
base_fixed["market"] = "US"
# fee/tax 는 _overlay_us 가 UI% 로 이미 세팅. 여기서 비율로 덮어쓰지 않음.
# 환전 편도 비율은 엔진 파라미터로 유지 (_ui_to_engine 이 나누지 않음).
try:
from kis_trader.engine.us_momentum_env_keys import us_momentum_trading_cost_rates
base_fixed["fx_fee_rate"] = float(us_momentum_trading_cost_rates()["fx_fee_rate"])
except Exception:
base_fixed["fx_fee_rate"] = float(get_env_float("US_MOMENTUM_FX_FEE_RATE", 0.0005))
if _ob_mode == "off":
base_fixed["_orderbook_filter_enabled"] = False
elif _ob_mode == "on":
base_fixed["_orderbook_filter_enabled"] = True
from kis_trader.backtest.optuna_tpe_common import optuna_tpe_needs_orderbook_feed
need_ob_feed = (
mk != "US" and optuna_tpe_needs_orderbook_feed(mode, _ob_mode)
)
ob_filter_on = (
bool(base_fixed.get("_orderbook_filter_enabled"))
or _ob_mode == "auto"
or need_ob_feed
)
if need_ob_feed:
# trial 이 ON/OFF — 스냅은 미리 로드 (필터 OFF 베이스여도)
base_fixed["backtest_use_trigger_snapshot_db"] = True
logger.info(
"📌 호가필터: %s (%s) market=%s%s",
_ob_mode.upper(),
"스냅로드" if need_ob_feed else ("적용" if ob_filter_on else "스킵 — 코어 파라미터 순수 탐색"),
mk,
" · TPE 호가축" if need_ob_feed else "",
)
db = TradeDB()
try:
from kis_trader.backtest.backtest_portfolio_common import load_portfolio_env_row
env_row = load_portfolio_env_row(db)
finally:
db.close()
fee_rate, sell_tax, slot_from_env = sbc.fee_and_slot_from_env(
env_row, strategy="US_MOMENTUM" if mk == "US" else "MOMENTUM",
)
if mk == "US":
# overlay UI% → 비율 (attach_scalp_trade_pnl / portfolio)
fee_rate = float(base_fixed.get("fee_rate", 0.25)) / 100.0
sell_tax = float(base_fixed.get("sell_tax", 0.00206)) / 100.0
if fee_rate > 1.0:
fee_rate = fee_rate / 100.0
if sell_tax > 1.0:
sell_tax = sell_tax / 100.0
# 종목 cfg Optuna: 유니버스 1종 → 동시보유 1 고정 (전역 포트와 혼동 방지)
_ms_arg = max_stocks
if filt and len(filt) == 1 and _ms_arg is None:
_ms_arg = 1
portfolio = sbc.resolve_scalp_portfolio_params(
env_row, None, strategy="US_MOMENTUM" if mk == "US" else "MOMENTUM",
slot_money=slot_money if slot_money is not None else slot_from_env,
max_stocks=_ms_arg,
total_budget_krw=total_budget_krw,
)
slot_money_f = float(portfolio["slot_money"])
max_stocks_i = int(portfolio["max_stocks"])
total_budget_f = float(portfolio["total_budget_krw"])
period_days = max(
1,
(datetime.strptime(end, "%Y-%m-%d") - datetime.strptime(start, "%Y-%m-%d")).days + 1,
)
logger.info(
f"💼 포트폴리오: 1회 {slot_money_f:,.0f}원 | 동시 {max_stocks_i}종 | "
f"총한도 {total_budget_f:,.0f}원 | 매매 {format_session_hm(base_fixed)} | market={mk}"
)
# 실매과 동일 1·2·3차 체인 — 옵투나 로그에서도 벤더 추적 가능
try:
from kis_trader.backtest.optuna_feed_trace import log_bt_feed_chain_banner
log_bt_feed_chain_banner(context="Optuna-MOMENTUM")
except Exception:
pass
logger.info("📂 캔들 로드 중 %s~%s (이 동안 Top5는 비어 있음 · trial 0)", start, end)
codes_candles = _load_candles_for_search(
start, end, base_fixed.get("rsi_period", 3),
market=mk if mk in ("US", "KR") else None,
codes_filter=filt,
)
if not codes_candles:
logger.error("❌ 캔들 데이터 없음 (market=%s filt=%s)", mk, filt)
return None
if filt and len(codes_candles) == 1:
logger.info(
"✅ 종목 Optuna 유니버스: %s (1종 · market=%s)",
next(iter(codes_candles.keys())), mk,
)
else:
logger.info("✅ 데이터 로드: %s종목 (market=%s)", len(codes_candles), mk)
start_key = (start.replace("-", "") + "0000") if start else "202601010000"
end_key = (end.replace("-", "") + "2359") if end else "999912312359"
start_ymd = start.replace("-", "") if start else ""
end_ymd = end.replace("-", "") if end else ""
universe_by_slot = None
fallback_sim_interval = 5
if mk == "US":
# 영구구독 US — HTS history 없음 · 시뮬 유니버스 스킵(봉에 있는 US 종목 전부)
use_fallback_universe = True
universe_by_slot = None
base_fixed["scan_interval_min"] = 1
logger.info("📌 US 모멘텀 Optuna — 영구구독/봉 유니버스 (HTS·시뮬 미사용)")
elif not use_fallback_universe and start_ymd and end_ymd:
try:
from kis_trader.backtest.momentum_backtest_common import resolve_momentum_universe
from kis_trader.backtest.universe_history_source import (
resolve_backtest_universe_history_source,
)
# scan_at 타임라인과 슬롯 dict 가 같은 이력소스(키움/LS)를 쓰도록 스태시
_hs = resolve_backtest_universe_history_source(history_source)
base_fixed["_universe_history_source"] = _hs
history, src, n_bins, _scan_iv, timing = resolve_momentum_universe(
start_ymd, end_ymd, use_saved_history=True, strategy_id="MOMENTUM",
history_source=_hs,
)
if history:
universe_by_slot = history
avg = sum(len(v) for v in history.values()) / max(1, n_bins)
logger.info(
"✅ 유니버스: MOMENTUM 이력 src=%s | %s분봉 · 평균 %.1f종목",
src, n_bins, avg,
)
except Exception as exc:
logger.debug("유니버스 이력 스킵: %s", exc)
if universe_by_slot is None and mk != "US":
universe_top_n = int(os.environ.get("UPDATE_UNIVERSE_TOP_N", "20"))
universe_min_score = float(os.environ.get("UPDATE_UNIVERSE_MIN_SCORE", "4.0"))
universe_by_slot = me.build_universe_simulation_momentum(
codes_candles,
top_n=universe_top_n,
min_score=universe_min_score,
scan_interval_min=fallback_sim_interval,
)
base_fixed["scan_interval_min"] = fallback_sim_interval
logger.info("📌 유니버스: 모멘텀 시뮬 fallback (%d분)", fallback_sim_interval)
elif universe_by_slot is not None:
base_fixed["scan_interval_min"] = 1
# DB 전일봉 없으면 키움 REST 1회/종목 → 메모리 prepend (실매 갭보정 정합, DB 미기록)
# 해외 US: 키움 분봉 불가 → REST 웜업 스킵
if mk != "US":
try:
from kis_trader.backtest.momentum_backtest_common import (
inject_momentum_rest_warmup_memory,
)
_rw = inject_momentum_rest_warmup_memory(
codes_candles,
start_key,
universe_by_slot=universe_by_slot,
)
if int(_rw.get("ok") or 0) > 0 or int(_rw.get("need") or 0) > 0:
logger.info(
"📡 REST 웜업: need=%s ok=%s fail=%s bars=%s",
_rw.get("need"), _rw.get("ok"), _rw.get("fail"), _rw.get("bars"),
)
except Exception as exc:
logger.warning("⚠️ REST 웜업 스킵: %s", exc)
# grid 는 상단에서 mode별 설정 (tpe=빈 dict). 여기서 grids[mode] 재조회하면 tpe KeyError.
_ob_axes = ("max_spread_pct", "min_bid_ask_ratio", "ask_max_mult")
_ob_sweeping = any(len(set(grid.get(k) or [])) > 1 for k in _ob_axes)
if ob_filter_on and _ob_sweeping:
base_fixed["backtest_use_kiwoom_body_snapshot"] = True
base_fixed["_backtest_use_kiwoom_body"] = True
orderbook_by_code: Dict[str, Any] = {}
program_by_code: Dict[str, Any] = {}
log_verdict_by_code: Dict[str, Any] = {}
ticks_by_code: Dict[str, Any] = {}
_snap_db = TradeDB()
try:
from kis_trader.backtest.trigger_snapshot_loader import (
backtest_needs_trigger_snapshot_load,
load_trigger_snapshots_by_code,
)
from kis_trader.engine.momentum_tick_replay import (
momentum_backtest_use_tick_entry,
momentum_backtest_use_tick_exit,
)
if backtest_needs_trigger_snapshot_load(base_fixed, strategy="MOMENTUM") or need_ob_feed:
_load_params = dict(base_fixed)
if need_ob_feed:
_load_params["_orderbook_filter_enabled"] = True
orderbook_by_code, program_by_code, trigger_snap_meta = load_trigger_snapshots_by_code(
_snap_db, start_key, end_key, set(codes_candles.keys()),
engine_params=_load_params, strategy="MOMENTUM",
)
log_verdict_by_code = trigger_snap_meta.get("log_verdict_by_code") or {}
if need_ob_feed:
ob_rows = int(trigger_snap_meta.get("ws_orderbook_rows_loaded") or 0)
logger.info(
"✅ TPE 호가축용 ws_orderbook %s",
f"{ob_rows:,}",
)
if momentum_backtest_use_tick_exit(base_fixed) or momentum_backtest_use_tick_entry(base_fixed):
from kis_trader.backtest.momentum_tick_loader import load_momentum_ticks_by_code
ticks_by_code, tick_rows = load_momentum_ticks_by_code(
_snap_db, start_key, end_key, set(codes_candles.keys()),
market=mk,
)
_tick_tbl = "ws_ticks_us" if mk == "US" else "ws_ticks"
logger.info("%s %s건 (market=%s)", _tick_tbl, f"{tick_rows:,}", mk)
finally:
_snap_db.close()
# ── ws_ticks 공유메모리 (Optuna, opt-in) — dict→numpy 컬럼 shared_memory 로 RAM 절감 ──
# 끄려면 OPTUNA_PARAM_SEARCH_SHARED_TICKS=0. numpy/shm 미지원·빌드 실패 시 자동 폴백.
shared_tick_store = None
if get_env_bool("OPTUNA_PARAM_SEARCH_SHARED_TICKS", True) and ticks_by_code:
from kis_trader.backtest.shared_ticks import build_shared_ticks_view
_view, shared_tick_store = build_shared_ticks_view(ticks_by_code, enabled=True)
if shared_tick_store is not None:
import atexit as _atexit
_atexit.register(shared_tick_store.unlink) # 크래시 시 /dev/shm 누수 방지
logger.info("📦 ws_ticks 공유메모리 ON (Optuna) — dict 사본 제거, RAM 절감")
ticks_by_code = _view
import gc as _gc
_gc.collect()
try:
import ctypes as _ctypes
_ctypes.CDLL("libc.so.6").malloc_trim(0)
except Exception:
pass
cache_holder: Dict[str, Any] = {}
attach_indicator_caches_to_params(cache_holder, codes_candles)
return MomentumSearchContext(
start=start,
end=end,
mode=mode,
base_fixed=base_fixed,
codes_candles=codes_candles,
universe_by_slot=universe_by_slot,
ticks_by_code=ticks_by_code,
orderbook_by_code=orderbook_by_code,
program_by_code=program_by_code,
log_verdict_by_code=log_verdict_by_code,
fee_rate=fee_rate,
sell_tax=sell_tax,
slot_money=slot_money_f,
max_stocks=max_stocks_i,
total_budget_krw=total_budget_f,
period_days=period_days,
portfolio=portfolio,
grid_keys=(
momentum_tpe_axis_keys() if mode == "tpe" else momentum_grid_axis_keys(mode, market=mk)
),
start_key=start_key,
end_key=end_key,
cache_holder=cache_holder,
shared_tick_store=shared_tick_store,
market=mk,
symbol=(sym or (filt[0] if filt and len(filt) == 1 else "")),
)
def _make_sampler(name: str, seed: Optional[int]):
n = (name or "tpe").strip().lower()
if n == "random":
return RandomSampler(seed=seed)
# multivariate TPE + 조건부 suggest 시 independent sampling 경고가 trial마다 폭주 → 억제
return TPESampler(seed=seed, multivariate=True, warn_independent_sampling=False)
def _momentum_objective_value(result: Dict[str, Any], sort_by: str) -> float:
pnl = float(result["total_pnl"])
if sort_by == "score":
mdd_floor = get_env_float("MOMENTUM_SCORE_MDD_FLOOR", 10000.0)
mdd = float(result.get("mdd") or 0)
return pnl / max(mdd, mdd_floor)
if sort_by == "win_rate":
return float(result["win_rate"])
return pnl
def run_momentum_optuna(
ctx: MomentumSearchContext,
*,
n_trials: int,
storage_url: str,
study_name: str,
min_trades: int,
min_win_rate: float,
min_pf: float,
sort_by: str = "score",
sampler_name: str = "tpe",
seed: Optional[int] = None,
n_jobs: int = 1,
show_progress: bool = True,
) -> optuna.Study:
study = optuna.create_study(
study_name=study_name,
storage=storage_url,
load_if_exists=True,
direction="maximize",
sampler=_make_sampler(sampler_name, seed),
)
from kis_trader.backtest.optuna_study_store import (
bind_study_trials,
clamp_optimize_n_trials,
finalize_optuna_export,
make_study_goal_stop_callback,
)
bind_study_trials(study, n_trials=n_trials, log=logger)
n_trials = clamp_optimize_n_trials(study, n_trials, log=logger)
def objective(trial: optuna.Trial) -> float:
if ctx.mode == "tpe":
combo = suggest_momentum_params_tpe(
trial, market=getattr(ctx, "market", "KR") or "KR",
)
else:
combo = suggest_momentum_params(trial, ctx.mode, market=getattr(ctx, "market", "KR") or "KR")
result = evaluate_momentum_param_combo(
combo,
base_fixed=ctx.base_fixed,
grid_keys=ctx.grid_keys,
codes_candles=ctx.codes_candles,
min_trades=min_trades,
min_win_rate=min_win_rate,
min_pf=min_pf,
universe_by_slot=ctx.universe_by_slot,
slot_money=ctx.slot_money,
max_stocks=ctx.max_stocks,
total_budget_krw=ctx.total_budget_krw,
fee_rate=ctx.fee_rate,
sell_tax=ctx.sell_tax,
period_days=ctx.period_days,
cache_holder=ctx.cache_holder,
ticks_by_code=ctx.ticks_by_code,
orderbook_by_code=ctx.orderbook_by_code,
program_by_code=ctx.program_by_code,
log_verdict_by_code=ctx.log_verdict_by_code,
start_key=ctx.start_key,
end_key=ctx.end_key,
)
if result is None:
trial.set_user_attr("gates_ok", False)
return _FAIL_OBJECTIVE
obj = _momentum_objective_value(result, sort_by)
trial.set_user_attr("gates_ok", True)
trial.set_user_attr("total_pnl", float(result["total_pnl"]))
trial.set_user_attr("win_rate", float(result["win_rate"]))
trial.set_user_attr("pf", float(result.get("pf") or 0))
trial.set_user_attr("mdd", float(result.get("mdd") or 0))
trial.set_user_attr("score", float(obj if sort_by == "score" else _momentum_objective_value(result, "score")))
trial.set_user_attr("total_trades", int(result["total_trades"]))
trial.set_user_attr("merged_json", json.dumps(result.get("merged_params") or {}, ensure_ascii=False))
set_optuna_trial_stability_attrs(trial, result)
return float(obj)
logger.info(
"🔬 Optuna MOMENTUM | study=%s | trials=%d | sort=%s",
study_name, n_trials, sort_by,
)
t0 = time.time()
try:
if n_trials <= 0:
logger.info("📌 추가 trial 없음 — 기존 study 결과만 정리")
else:
study.optimize(
objective,
n_trials=n_trials,
n_jobs=n_jobs,
show_progress_bar=show_progress,
callbacks=[make_study_goal_stop_callback(logger)],
)
elapsed = time.time() - t0
passing: List[Dict[str, Any]] = []
for trial in study.trials:
if trial.state != optuna.trial.TrialState.COMPLETE:
continue
if not trial.user_attrs.get("gates_ok"):
continue
merged_raw = trial.user_attrs.get("merged_json") or "{}"
try:
merged = json.loads(merged_raw)
except json.JSONDecodeError:
merged = dict(trial.params)
row = {
"params": dict(trial.params),
"merged_params": merged,
"total_trades": int(trial.user_attrs.get("total_trades") or 0),
"win_rate": float(trial.user_attrs.get("win_rate") or 0),
"total_pnl": float(trial.user_attrs.get("total_pnl") or 0),
"pf": float(trial.user_attrs.get("pf") or 0),
"mdd": float(trial.user_attrs.get("mdd") or 0),
"score": float(trial.user_attrs.get("score") or 0),
"optuna_trial_number": trial.number,
}
row.update(stability_fields_from_trial_attrs(trial))
passing.append(row)
tiers = build_optuna_result_tiers(passing, sort_by=sort_by)
out_data = {
"engine": "optuna",
"strategy": "momentum",
"mode": ctx.mode,
"start": ctx.start,
"end": ctx.end,
"slot_money": int(ctx.slot_money),
"max_stocks": ctx.max_stocks,
"total_budget_krw": int(ctx.total_budget_krw),
"backtest_days": ctx.period_days,
"min_trades": min_trades,
"min_win_rate": min_win_rate,
"min_pf": min_pf,
"sort_by": sort_by,
"grid_keys": ctx.grid_keys,
"grid_axis_hints": {k: MOMENTUM_GRID_AXIS_HINTS_KO[k] for k in ctx.grid_keys if k in MOMENTUM_GRID_AXIS_HINTS_KO},
"optuna_study_name": study_name,
"optuna_storage": storage_url,
"optuna_n_trials_requested": n_trials,
"optuna_trials_completed": len(study.trials),
"optuna_best_value": study.best_value if study.best_trial else None,
"optuna_best_trial_number": study.best_trial.number if study.best_trial else None,
"elapsed_sec": round(elapsed, 1),
**tiers,
}
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
_mk = str(getattr(ctx, "market", "") or "").strip().upper()
_sym = str(getattr(ctx, "symbol", "") or "").strip().upper()
if _mk == "US" and _sym:
_fname = f"optuna_us_momentum_{_sym}_{ctx.mode}_{ts}.json"
elif _mk == "US":
_fname = f"optuna_us_momentum_{ctx.mode}_{ts}.json"
else:
_fname = f"optuna_momentum_{ctx.mode}_{ts}.json"
out_path = os.path.join(_results_dir_for_write(), _fname)
out_data["strategy"] = "us_momentum" if _mk == "US" else "momentum"
out_data["market"] = _mk or "KR"
if _sym:
out_data["symbol"] = _sym
out_data["_apply_symbol"] = _sym
# 최빈 실측 전에 먼저 저장·경로 고지 (실측이 길어도 바로 파일 열 수 있게)
with open(out_path, "w", encoding="utf-8") as f:
json.dump(out_data, f, indent=2, ensure_ascii=False)
announce_optuna_json_path(
out_path,
strategy=("us_momentum" if _mk == "US" else "momentum"),
mode=ctx.mode,
note="중간저장(mode 전)",
log=logger,
)
def _eval_mode(combo: Dict[str, Any]) -> Optional[Dict[str, Any]]:
# 최빈 Frankenstein 실측 — 게이트는 느슨하게(리포트용)
# ※ shared_tick_store 가 아직 살아 있어야 함 (optimize 직후 unlink 금지)
return evaluate_momentum_param_combo(
combo,
base_fixed=ctx.base_fixed,
grid_keys=ctx.grid_keys,
codes_candles=ctx.codes_candles,
min_trades=1,
min_win_rate=0.0,
min_pf=0.0,
universe_by_slot=ctx.universe_by_slot,
slot_money=ctx.slot_money,
max_stocks=ctx.max_stocks,
total_budget_krw=ctx.total_budget_krw,
fee_rate=ctx.fee_rate,
sell_tax=ctx.sell_tax,
period_days=ctx.period_days,
cache_holder=ctx.cache_holder,
ticks_by_code=ctx.ticks_by_code,
orderbook_by_code=ctx.orderbook_by_code,
program_by_code=ctx.program_by_code,
log_verdict_by_code=ctx.log_verdict_by_code,
start_key=ctx.start_key,
end_key=ctx.end_key,
include_trades=True,
)
_ann_strat = "us_momentum" if _mk == "US" else "momentum"
def _save_partial(_data: Dict[str, Any]) -> None:
with open(out_path, "w", encoding="utf-8") as f:
json.dump(_data, f, indent=2, ensure_ascii=False)
announce_optuna_json_path(
out_path,
strategy=_ann_strat,
mode=ctx.mode,
note="mode_combo params 저장(실측 전)",
log=logger,
)
def _enrich() -> None:
enrich_out_data_with_mode_combo(
out_data,
evaluate_fn=_eval_mode,
grid_keys=ctx.grid_keys,
log=logger,
on_partial_save=_save_partial,
)
with open(out_path, "w", encoding="utf-8") as f:
json.dump(out_data, f, indent=2, ensure_ascii=False)
announce_optuna_json_path(
out_path, strategy=_ann_strat, mode=ctx.mode, note="최종 JSON", log=logger,
)
finalize_optuna_export(
study,
out_data=out_data,
out_path=out_path,
strategy=_ann_strat,
mode=ctx.mode,
enrich_fn=_enrich,
log=logger,
symbol=_sym,
)
study._kis_export_path = out_path # type: ignore[attr-defined]
return study
finally:
# mode_combo 실측이 ticks 공유메모리 뷰를 쓰므로, 여기서 해제 (optimize 직후 X)
release_shared_tick_store(ctx, log=logger)
def apply_best_momentum_trial(study: optuna.Study) -> bool:
# 탐색 best(objective)가 아니라 report_gates 통과 후보만 적용
trial = pick_gated_apply_trial(study, sort_by="score", fail_objective=_FAIL_OBJECTIVE)
if trial is None:
logger.warning(
"⚠️ 사후게이트(results_gated) 통과 trial 없음 — DB 미적용 "
"(탐색 min_wr/pf=0 이어도 apply 는 승률·PF 하한 필요)"
)
return False
pnl = float(trial.user_attrs.get("total_pnl") or 0)
if pnl <= 0:
logger.warning("⚠️ gated trial 총손익 ≤ 0 — DB 미적용")
return False
merged_raw = trial.user_attrs.get("merged_json") or "{}"
merged = json.loads(merged_raw)
apply_params_to_db(merged)
logger.info("🚀 [Optuna apply-best] momentum gated trial #%d → env_config", trial.number)
try:
from kis_trader.backtest.optuna_daily_trail_recommend import (
apply_daily_trail_recommend_from_optuna_json,
)
apply_daily_trail_recommend_from_optuna_json(
getattr(study, "_kis_export_path", None),
strategy="momentum",
log=logger,
)
except Exception as exc:
logger.warning("⚠️ 다단트레일 추천 반영 스킵: %s", exc)
return True
def apply_best_us_momentum_trial(study: optuna.Study, *, symbol: str = "") -> bool:
"""해외 모멘텀 — 전역 config_us_momentum 또는 종목 stock_config.
symbol 있으면 ``us_momentum_stock_config`` 행만 갱신(전역·다단트레일 미오염).
"""
from kis_trader.backtest.param_search_momentum import apply_params_to_db_us
sym = str(symbol or "").strip().upper()
trial = pick_gated_apply_trial(study, sort_by="score", fail_objective=_FAIL_OBJECTIVE)
if trial is None:
logger.warning("⚠️ us_momentum gated trial 없음 — DB 미적용")
return False
pnl = float(trial.user_attrs.get("total_pnl") or 0)
if pnl <= 0:
logger.warning("⚠️ us_momentum gated trial 총손익 ≤ 0 — DB 미적용")
return False
merged_raw = trial.user_attrs.get("merged_json") or "{}"
merged = json.loads(merged_raw)
apply_params_to_db_us(merged, symbol=sym)
if sym:
logger.info(
"🚀 [Optuna apply-best] us_momentum gated #%d → stock_config %s",
trial.number, sym,
)
return True
try:
from kis_trader.backtest.optuna_daily_trail_recommend import (
apply_daily_trail_recommend_from_optuna_json,
)
apply_daily_trail_recommend_from_optuna_json(
getattr(study, "_kis_export_path", None),
strategy="us_momentum",
log=logger,
)
except Exception as exc:
logger.warning("⚠️ us_momentum 다단트레일 추천 반영 스킵: %s", exc)
logger.info(
"🚀 [Optuna apply-best] us_momentum gated trial #%d → config_us_momentum",
trial.number,
)
return True