#!/usr/bin/env python3 """kis_trader/backtest/optuna_breakout.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 breakout_backtest_common as bbc from kis_trader.backtest.optuna_search_space import breakout_grid_axis_keys, suggest_breakout_params from kis_trader.backtest.optuna_breakout_tpe_space import ( breakout_tpe_axis_keys, normalize_tpe_breakout_ob_mode, normalize_tpe_breakout_sl_mode, suggest_breakout_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_breakout import ( _bo_fixed_defaults, _load_candles_for_search, _breakout_grids, _ui_to_engine_params, apply_params_to_db, evaluate_breakout_param_combo, ) from kis_trader.backtest.param_search_cli_common import ( apply_session_to_fixed, combo_passes_search_filters, format_session_hm, ) from kis_trader.backtest.tail_param_search import _results_dir_for_write from kis_trader.strategies.breakout import breakout_backtest_wants_tick_replay, breakout_entry_mode from kis_trader.engine.indicator_cache import attach_indicator_caches_to_params from kis_trader.backtest.breakout_tick_loader import load_common_ticks_by_code from kis_trader.utils.env import get_env_bool logger = logging.getLogger("param_search_optuna") _FAIL_OBJECTIVE = -1e18 @dataclass class BreakoutSearchContext: 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] share_denom_by_code: Dict[str, float] 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) tpe_sl_mode: str = "fixed" # TPE 고정 손절모드(탐색 축 아님) tpe_ob_mode: str = "off" # TPE 호가 스터디 스위치 on|off (탐색 축 아님) def prepare_breakout_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", history_source: Optional[str] = None, sl_mode: Optional[str] = None, ) -> Optional[BreakoutSearchContext]: grids = _breakout_grids() # tpe = 연속 Optuna 전용 (Grid 메뉴 미사용) if mode == "tpe": grid: Dict[str, Any] = {} logger.info( "📌 mode=tpe — 연속(float/int) 탐색 (Grid categorical 미사용, TPE 가 구간 축소)" ) elif mode not in grids: logger.error("❌ 돌파 mode: %s (fast/coarse/fine/wide/full/tpe)", mode) return None else: grid = grids[mode] base_fixed = _bo_fixed_defaults() if os.environ.get("BACKTEST_USE_RUST") == "1": base_fixed["use_rust"] = True if mode == "tpe": base_fixed["skip_hts_scan_dupes"] = False apply_session_to_fixed(base_fixed, time_start_hm=time_start_hm, time_end_hm=time_end_hm) # 돌파 TPE: 호가=스터디 스위치(--orderbook-filter on|off). trial categorical 금지. tpe_ob_mode = "off" if mode == "tpe": tpe_ob_mode = normalize_tpe_breakout_ob_mode(orderbook_filter) _ob_mode = tpe_ob_mode else: _ob_mode = (orderbook_filter or "off").strip().lower() if _ob_mode == "off": base_fixed["_orderbook_filter_enabled"] = False elif _ob_mode in ("on", "auto"): base_fixed["_orderbook_filter_enabled"] = True from kis_trader.backtest.optuna_tpe_common import optuna_tpe_needs_orderbook_feed if mode == "tpe": # 글로벌 INCLUDE_ORDERBOOK 때문에 off 스터디까지 스냅 로드하던 구멍 차단 need_ob_feed = tpe_ob_mode == "on" else: need_ob_feed = optuna_tpe_needs_orderbook_feed(mode, _ob_mode) ob_filter_on = bool(base_fixed.get("_orderbook_filter_enabled")) or _ob_mode == "auto" if need_ob_feed: base_fixed["backtest_use_trigger_snapshot_db"] = True # 돌파 filter_eval 거의 없음 → 호가 ON 스터디는 kiwoom_0d 본체 재계산 base_fixed["backtest_use_kiwoom_body_snapshot"] = True base_fixed["_backtest_use_kiwoom_body"] = True logger.info( "📌 호가필터: %s (%s)%s", _ob_mode.upper(), "스냅로드" if need_ob_feed else ("적용" if ob_filter_on else "스킵 — 코어 파라미터 순수 탐색"), " · 돌파TPE 스터디스위치" if mode == "tpe" 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 = bbc.fee_and_slot_from_env(env_row) portfolio = bbc.resolve_breakout_portfolio_params( env_row, None, slot_money=slot_money if slot_money is not None else slot_from_env, max_stocks=max_stocks, 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)}" ) logger.info("📌 진입 모드: %s", breakout_entry_mode()) tpe_sl_mode = "fixed" if mode == "tpe": tpe_sl_mode = normalize_tpe_breakout_sl_mode(sl_mode) base_fixed["sl_mode"] = tpe_sl_mode logger.info( "📌 TPE 손절모드 고정: %s · 호가스위치: %s (둘 다 탐색 축 아님)", tpe_sl_mode, tpe_ob_mode, ) from kis_trader.backtest.universe_history_source import ( resolve_backtest_universe_history_source, ) _hs = resolve_backtest_universe_history_source(history_source) base_fixed["_universe_history_source"] = _hs try: from kis_trader.backtest.optuna_feed_trace import log_bt_feed_chain_banner log_bt_feed_chain_banner(context="Optuna-BREAKOUT") except Exception: pass codes_candles = _load_candles_for_search( start, end, base_fixed.get("lookback_min", 1), base_fixed, history_source=_hs, ) if not codes_candles: logger.error("❌ 캔들 데이터 없음") return None logger.info("✅ 데이터 로드: %s종목 (history=%s)", len(codes_candles), _hs) share_denom_by_code: Dict[str, float] = {} _share_db = TradeDB() try: from kis_trader.share.stock_share import load_share_denom_map share_denom_by_code = load_share_denom_map(_share_db, codes_candles.keys()) finally: _share_db.close() 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 "" ticks_by_code: Dict[str, Any] = {} engine_probe = _ui_to_engine_params(base_fixed) engine_probe["_orderbook_filter_enabled"] = base_fixed.get("_orderbook_filter_enabled") if breakout_backtest_wants_tick_replay(engine_probe): _tick_db = TradeDB() try: if _hs in ("ls", "ls_condition", "ls_ws"): from kis_trader.backtest.ls_history_loaders import load_ls_ticks_by_code ticks_by_code, tick_rows = load_ls_ticks_by_code( _tick_db, start_key, end_key, set(codes_candles.keys()), ) logger.info("✅ ls_ws_ticks %s건", f"{tick_rows:,}") else: ticks_by_code, tick_rows = load_common_ticks_by_code( _tick_db, start_key, end_key, set(codes_candles.keys()), ) logger.info("✅ ws_ticks %s건", f"{tick_rows:,}") finally: _tick_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 # grid 는 상단에서 mode별 설정 (tpe=빈 dict). grids[mode] 재조회 금지. # TPE=ask_max_mult · Grid 레거시=ask_wall_max_qty — 둘 다 스윕 축으로 인정 _ob_axes = ("max_spread_pct", "min_bid_ask_ratio", "ask_max_mult", "ask_wall_max_qty") _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 # engine_probe 는 위에서 만들었으므로 body/스냅 플래그를 동기화 (로드·trial 공통) for _k in ( "backtest_use_trigger_snapshot_db", "backtest_use_kiwoom_body_snapshot", "_backtest_use_kiwoom_body", ): if _k in base_fixed: engine_probe[_k] = base_fixed[_k] orderbook_by_code: Dict[str, Any] = {} program_by_code: Dict[str, Any] = {} log_verdict_by_code: Dict[str, Any] = {} if need_ob_feed: _snap_db = TradeDB() try: from kis_trader.backtest.trigger_snapshot_loader import load_trigger_snapshots_by_code _load_params = dict(engine_probe) _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="BREAKOUT", ) log_verdict_by_code = trigger_snap_meta.get("log_verdict_by_code") or {} ob_rows = int(trigger_snap_meta.get("ws_orderbook_rows_loaded") or 0) logger.info("✅ 호가ON 스터디용 ws_orderbook %s건", f"{ob_rows:,}") finally: _snap_db.close() else: logger.info("📌 호가OFF 스터디 — 호가 스냅 로드 생략") universe_by_slot = None fallback_sim_interval = 5 if not use_fallback_universe and start_ymd and end_ymd: try: from kis_trader.backtest.breakout_backtest_common import resolve_breakout_universe history, src, n_bins, _scan_iv = resolve_breakout_universe( start_ymd, end_ymd, use_saved_history=True, history_source=_hs, ) if history: universe_by_slot = history avg = sum(len(v) for v in history.values()) / max(1, n_bins) logger.info( "✅ 유니버스: BREAKOUT 이력 src=%s | %s분봉 · 평균 %.1f종목", src, n_bins, avg, ) except Exception as exc: logger.debug("유니버스 이력 스킵: %s", exc) if universe_by_slot is None: from kis_trader.engine import scalping_engine as se 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 = se.build_universe_simulation( 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) else: base_fixed["scan_interval_min"] = 1 cache_holder: Dict[str, Any] = {} attach_indicator_caches_to_params(cache_holder, codes_candles) if universe_by_slot is not None: from kis_trader.backtest.universe_timeline import build_universe_timeline from kis_trader.backtest.breakout_backtest_common import BREAKOUT_STRATEGY_ID, breakout_universe_exit_debounce_sec from kis_trader.backtest.universe_history_source import resolve_backtest_universe_history_source _deb = breakout_universe_exit_debounce_sec() _hs = resolve_backtest_universe_history_source(base_fixed.get("_universe_history_source") or base_fixed.get("universe_history_source")) _tl = build_universe_timeline( strategy_id=BREAKOUT_STRATEGY_ID, start_ymd=start_key[:8], end_ymd=end_key[:8], debounce_sec=_deb, strict=False, strict_lag_minutes=0, history_source=_hs, ) if _tl is not None: cache_holder["_universe_timeline"] = _tl if ticks_by_code: from kis_trader.backtest.breakout_tick_loader import tick_coverage_stats cache_holder["_tick_meta_cached"] = tick_coverage_stats(codes_candles, ticks_by_code) return BreakoutSearchContext( 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, share_denom_by_code=share_denom_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=( breakout_tpe_axis_keys(tpe_sl_mode, orderbook_filter=tpe_ob_mode) if mode == "tpe" else breakout_grid_axis_keys(mode) ), start_key=start_key, end_key=end_key, cache_holder=cache_holder, shared_tick_store=shared_tick_store, tpe_sl_mode=tpe_sl_mode, tpe_ob_mode=tpe_ob_mode, ) 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 run_breakout_optuna( ctx: BreakoutSearchContext, *, 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) import uuid rust_session_id = f"optuna_break_{uuid.uuid4().hex[:8]}" try: import kis_rust_core import json candles_json = json.dumps(ctx.codes_candles) kis_rust_core.init_backtest_session_json( rust_session_id, candles_json ) except Exception as e: logger.warning(f"Failed to init_rust_session: {e}") def objective(trial: optuna.Trial) -> float: if ctx.mode == "tpe": combo = suggest_breakout_params_tpe( trial, sl_mode=ctx.tpe_sl_mode, orderbook_filter=ctx.tpe_ob_mode, ) else: combo = suggest_breakout_params(trial, ctx.mode) combo["_rust_session_id"] = rust_session_id result = evaluate_breakout_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, share_denom_by_code=ctx.share_denom_by_code, ) if result is None: trial.set_user_attr("gates_ok", False) return _FAIL_OBJECTIVE 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("total_trades", int(result["total_trades"])) trial.set_user_attr("merged_json", json.dumps(result.get("merged_params") or {}, ensure_ascii=False)) from kis_trader.backtest.optuna_common import optuna_store_trial_score_user_attrs set_optuna_trial_stability_attrs(trial, result) return optuna_store_trial_score_user_attrs( trial, result, sort_by, start=ctx.start, end=ctx.end, strategy="breakout", ) logger.info("🔬 Optuna BREAKOUT | study=%s | trials=%d", study_name, n_trials) 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 from kis_trader.backtest.optuna_common import optuna_score_fields_from_trial 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), **optuna_score_fields_from_trial(trial), "period_daily_avg_pnl": float(trial.user_attrs.get("period_daily_avg_pnl") or 0), "optuna_trial_number": trial.number, } row.update(stability_fields_from_trial_attrs(trial)) passing.append(row) from kis_trader.backtest.optuna_common import _sort_optuna_rows passing = _sort_optuna_rows(passing, sort_by) tiers = build_optuna_result_tiers(passing, sort_by=sort_by) hints: Dict[str, str] = {} out_data = { "engine": "optuna", "strategy": "breakout", "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: hints[k] for k in ctx.grid_keys if k in hints}, "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, } from kis_trader.backtest.optuna_common import annotate_optuna_period_daily_avg annotate_optuna_period_daily_avg(out_data) ts = datetime.now().strftime("%Y%m%d_%H%M%S") out_path = os.path.join(_results_dir_for_write(), f"optuna_breakout_{ctx.mode}_{ts}.json") 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="breakout", mode=ctx.mode, note="중간저장(mode 전)", log=logger, ) def _eval_mode(combo: Dict[str, Any]) -> Optional[Dict[str, Any]]: return evaluate_breakout_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, share_denom_by_code=ctx.share_denom_by_code, include_trades=True, ) 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="breakout", 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="breakout", mode=ctx.mode, note="최종 JSON", log=logger, ) finalize_optuna_export( study, out_data=out_data, out_path=out_path, strategy="breakout", mode=ctx.mode, enrich_fn=_enrich, log=logger, ) study._kis_export_path = out_path # type: ignore[attr-defined] return study finally: release_shared_tick_store(ctx, log=logger) def apply_best_breakout_trial(study: optuna.Study) -> bool: trial = pick_gated_apply_trial(study, sort_by="score", fail_objective=_FAIL_OBJECTIVE) if trial is None: logger.warning( "⚠️ 사후게이트(results_gated) 통과 trial 없음 — DB 미적용" ) return False pnl = float(trial.user_attrs.get("total_pnl") or 0) if pnl <= 0: logger.warning("⚠️ gated trial 총손익 ≤ 0 — DB 미적용") return False merged = json.loads(trial.user_attrs.get("merged_json") or "{}") apply_params_to_db(merged) logger.info("🚀 [Optuna apply-best] breakout 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="breakout", log=logger, ) except Exception as exc: logger.warning("⚠️ 다단트레일 추천 반영 스킵: %s", exc) return True