""" kis_trader/backtest/optuna_whipsaw_recommend.py ================================================= Optuna 차트 캔들 최적화 완료 후, 후처리로 고속 휩쏘 파라미터 탐색을 수행하여 전략별 최적의 휩쏘 필터 수치(Consensus)를 도출하고 Optuna out_data 및 Apply 패치에 자동으로 결합하는 모듈입니다. """ from __future__ import annotations import logging from dataclasses import dataclass from datetime import datetime, timedelta from typing import Any, Dict, List, Optional, Tuple import optuna from database import TradeDB from kis_trader.engine.whipsaw_filter import whipsaw_reject_for_signal logger = logging.getLogger("OptunaWhipsawRecommend") optuna.logging.set_verbosity(optuna.logging.WARNING) @dataclass class TradeInfo: code: str name: str buy_dt: datetime buy_price: float actual_pnl: float actual_profit_rate: float ticks: List[Dict[str, Any]] def recommend_whipsaw_parameters( *, strategy: str = "MOMENTUM", n_trials: int = 500, days: int = 7, log: Optional[logging.Logger] = None, ) -> Dict[str, Any]: lg = log or logger strat_upper = str(strategy).strip().upper() db = TradeDB() try: now = datetime.now() start_date = (now - timedelta(days=days)).strftime("%Y-%m-%d") buys = db.conn.execute( """ SELECT id, code, name, buy_date, buy_price, realized_pnl, profit_rate FROM trade_history WHERE strategy=%s AND DATE(buy_date) >= %s ORDER BY buy_date """, (strat_upper, start_date), ).fetchall() trades: List[TradeInfo] = [] for b in buys: code = b["code"] raw_dt = b["buy_date"] if isinstance(raw_dt, str): try: buy_dt = datetime.strptime(raw_dt, "%Y-%m-%d %H:%M:%S") except ValueError: buy_dt = datetime.fromisoformat(raw_dt) else: buy_dt = raw_dt buy_price = float(b["buy_price"] or 0) pnl = float(b["realized_pnl"] or 0) rate = float(b["profit_rate"] or 0) start_tick_time = (buy_dt - timedelta(seconds=180)).strftime("%Y%m%d%H%M%S") end_tick_time = buy_dt.strftime("%Y%m%d%H%M%S") ticks = db.get_ws_ticks(code, market="KR", start_tick_time=start_tick_time, end_tick_time=end_tick_time) if not ticks: continue trades.append( TradeInfo( code=code, name=str(b.get("name") or code), buy_dt=buy_dt, buy_price=buy_price, actual_pnl=pnl, actual_profit_rate=rate, ticks=ticks, ) ) finally: db.close() if len(trades) < 3: lg.warning("⚠️ [%s] 휩쏘 연산 가능한 실제 틱 보유 매수 건수(%s건)가 부족하여 최적화 생략.", strat_upper, len(trades)) return {"ok": False, "reason": "not_enough_trades", "trade_count": len(trades)} orig_cnt = len(trades) orig_win = sum(1 for t in trades if t.actual_pnl > 0) / orig_cnt * 100.0 orig_pnl = sum(t.actual_pnl for t in trades) orig_rate = sum(t.actual_profit_rate for t in trades) / orig_cnt def _sim_trade(tr: TradeInfo, p: Dict[str, Any]) -> Tuple[float, float, str]: params_for_eval = { "whipsaw_filter_enabled": True, "whipsaw_subbar_sec": p["subbar_sec"], "whipsaw_lookback_sec": p["lookback_sec"], "whipsaw_dip_pct": p["dip_pct"], "whipsaw_recovery_tol_pct": p.get("recov_pct", 0.0), } sig_bar = {"low": tr.buy_price, "dt": tr.buy_dt} reject_reason, _ = whipsaw_reject_for_signal( params=params_for_eval, strategy=strat_upper, signal_bar=sig_bar, current_price=tr.buy_price, ticks=tr.ticks ) if reject_reason: return (0.0, 0.0, "ENTRY_REJECTED") return (tr.actual_pnl, tr.actual_profit_rate, "ORIGINAL") def _calc_suite(p: Dict[str, Any]) -> Tuple[int, float, float, float]: t_cnt = 0 w_cnt = 0 tot_pnl = 0.0 tot_rate = 0.0 for t in trades: pnl, rate, rtype = _sim_trade(t, p) if rtype != "ENTRY_REJECTED": t_cnt += 1 tot_pnl += pnl tot_rate += rate if pnl > 0: w_cnt += 1 w_rate = (w_cnt / t_cnt * 100.0) if t_cnt > 0 else 0.0 avg_r = (tot_rate / t_cnt) if t_cnt > 0 else 0.0 return t_cnt, w_rate, tot_pnl, avg_r valid_records: List[Dict[str, Any]] = [] def obj_func(trial: optuna.Trial) -> float: params = { "subbar_sec": trial.suggest_categorical("subbar_sec", [10, 15, 20, 30, 45, 60]), "lookback_sec": trial.suggest_categorical("lookback_sec", [30, 45, 60, 90, 120, 180]), "dip_pct": trial.suggest_float("dip_pct", 0.001, 0.010, step=0.001), } cnt, win_r, pnl, rate = _calc_suite(params) if cnt < max(3, int(orig_cnt * 0.3)): return -999999999.0 w_p = (pnl / 100000.0) w_w = win_r * 2.0 score = w_p + w_w if win_r >= 60.0: score += (win_r - 60.0) * 1.5 valid_records.append({"score": score, "pnl": pnl, "win_rate": win_r, "count": cnt, "rate": rate, "params": params}) return score study = optuna.create_study(direction="maximize") study.optimize(obj_func, n_trials=n_trials) valid_records.sort(key=lambda x: x["score"], reverse=True) top5 = valid_records[: min(5, len(valid_records))] if not top5: return {"ok": False, "reason": "no_valid_trials"} # Consensus 도출 best = top5[0] avg_subbar = int(sum(r["params"]["subbar_sec"] for r in top5) / len(top5)) avg_lookback = int(sum(r["params"]["lookback_sec"] for r in top5) / len(top5)) avg_dip = round(sum(r["params"]["dip_pct"] for r in top5) / len(top5), 4) cons_params = { "subbar_sec": avg_subbar, "lookback_sec": avg_lookback, "dip_pct": avg_dip, } c_cnt, c_win, c_pnl, c_rate = _calc_suite(cons_params) lg.info( "⚡ [휩쏘 필터 합의 추천] 전략=%s (모수=%d건, %d회 탐색) | subbar=%d lookback=%d dip=%.3f | 승률: %.1f%% 손익: %.0f원", strat_upper, len(trades), n_trials, avg_subbar, avg_lookback, avg_dip, c_win, c_pnl, ) return { "ok": True, "strategy": strat_upper, "n_trials": n_trials, "trade_count": len(trades), "orig_stats": {"count": orig_cnt, "win_rate": round(orig_win, 1), "pnl": orig_pnl, "avg_rate": round(orig_rate, 2)}, "recommended_stats": {"count": c_cnt, "win_rate": round(c_win, 1), "pnl": c_pnl, "avg_rate": round(c_rate, 2), "pnl_diff": c_pnl - orig_pnl}, "params": { "whipsaw_filter_enabled": True, "whipsaw_subbar_sec": avg_subbar, "whipsaw_lookback_sec": avg_lookback, "whipsaw_dip_pct": avg_dip, }, } def attach_whipsaw_recommend( out_data: Dict[str, Any], *, log: Optional[logging.Logger] = None, ) -> Dict[str, Any]: """out_data에 휩쏘 필터 추천 결과를 첨부.""" lg = log or logger strat = str(out_data.get("strategy") or "MOMENTUM").strip().upper() rec = recommend_whipsaw_parameters(strategy=strat, n_trials=500, log=lg) out_data["whipsaw_recommend"] = rec mc = out_data.get("mode_combo") if isinstance(mc, dict): mc["whipsaw_recommend"] = rec if not rec.get("ok"): lg.info("⚡ [휩쏘 필터 합의 추천] 생략 — %s", rec.get("reason") or "n/a") return out_data _WHIPSAW_DB_SKIP_STRATS = {"TAIL", "SHORT", "BREAKOUT"} def build_whipsaw_env_patch(rec: Dict[str, Any]) -> Dict[str, str]: """휩쏘 추천 결과를 DB env 패치 dict로 변환.""" if not rec or not rec.get("ok"): return {} strat = str(rec.get("strategy") or "").strip().upper() pfx = "TAIL" if strat in ("SHORT", "TAIL") else strat p = rec.get("params", {}) if not pfx or not p: return {} if strat in _WHIPSAW_DB_SKIP_STRATS: logger.info( "🚫 [%s] 휩쏘 필터 DB 적용 차단 (전략 특성상 UI 표시만) — 수치: subbar=%s lookback=%s dip=%s", strat, p.get('whipsaw_subbar_sec'), p.get('whipsaw_lookback_sec'), p.get('whipsaw_dip_pct'), ) return {} patch = { f"{pfx}_WHIPSAW_FILTER_ENABLED": "true", f"{pfx}_WHIPSAW_SUBBAR_SEC": str(p["whipsaw_subbar_sec"]), f"{pfx}_WHIPSAW_LOOKBACK_SEC": str(p["whipsaw_lookback_sec"]), f"{pfx}_WHIPSAW_DIP_PCT": str(p["whipsaw_dip_pct"]), } return patch