#!/usr/bin/env python3 """ optuna_briefing.py — Optuna 결과 AI/규칙 브리핑 (이전 장 · 앞으로 장) ===================================================================== TPE 탐색 JSON을 읽어 한국어 코멘트를 남긴다. - 기본: 규칙 기반 분석 (키·외부 API 없어도 동작) - PARAM_SEARCH_OPTUNA_BRIEFING_AI=1 이면 AI 보강 시도 순서: 1) GEMINI_API_KEY (구글) → GEMINI_MODEL_ID (기본 gemini-2.5-flash) 2) ANTHROPIC_API_KEY → CLAUDE_MODEL_ID 구글 키 넣는 방법 (택1): A) DB overflow (권장) .venv/bin/python - <<'PY' from database import TradeDB from datetime import datetime db = TradeDB() now = datetime.now().strftime("%Y-%m-%d %H:%M:%S") db._persist_env_config_overflow({ "GEMINI_API_KEY": "여기에_구글_AI_Studio_키", "GEMINI_MODEL_ID": "gemini-2.5-flash", "PARAM_SEARCH_OPTUNA_BRIEFING_AI": "1", }, now) from kis_trader.utils.env import invalidate_merged_env_cache invalidate_merged_env_cache() print("OK") PY B) 환경변수: export GEMINI_API_KEY=... 키 발급: https://aistudio.google.com/apikey 사용: .venv/bin/python -m kis_trader.backtest.optuna_briefing path/to/optuna_*.json """ from __future__ import annotations import argparse import json import logging import os from datetime import datetime from typing import Any, Dict, List, Optional, Tuple from kis_trader.utils.env import get_env_bool, get_env_from_db, get_env_int logger = logging.getLogger("optuna_briefing") _STRAT_KO = { "tail": "꼬리", "momentum": "모멘텀", "breakout": "돌파", "scalp": "스캘핑", "short": "꼬리", } def _f(x: Any, default: float = 0.0) -> float: try: return float(x) except (TypeError, ValueError): return default def _i(x: Any, default: int = 0) -> int: try: return int(x) except (TypeError, ValueError): return default def _top_row(data: Dict[str, Any]) -> Optional[Dict[str, Any]]: for key in ("results_gated", "results", "results_all"): rows = data.get(key) or [] if isinstance(rows, list) and rows: return rows[0] return None def _live_pnl_snapshot(strategy: str, start: str, end: str) -> str: """실매 trade_history 요약 (스키마 확인 후). 실패 시 빈 문자열.""" try: from database import TradeDB except ImportError: return "" db = TradeDB() try: cols = [r["Field"] for r in db.conn.execute("SHOW COLUMNS FROM trade_history").fetchall()] colset = set(cols) strat_col = "strategy" if "strategy" in colset else None pnl_col = "realized_pnl" if "realized_pnl" in colset else ( "pnl" if "pnl" in colset else None ) time_col = "sell_date" if "sell_date" in colset else ( "exit_time" if "exit_time" in colset else ( "buy_date" if "buy_date" in colset else None ) ) if not strat_col or not pnl_col or not time_col: return f"(실매 조회 스킵: trade_history 컬럼 부족)" aliases = { "tail": ("%tail%", "%short%", "%TAIL%", "%SHORT%"), "momentum": ("%momentum%", "%MOMENTUM%"), "breakout": ("%breakout%", "%BREAKOUT%"), "scalp": ("%scalp%", "%SCALP%", "%reversal%"), } likes = aliases.get(strategy, (f"%{strategy}%",)) or_sql = " OR ".join([f"`{strat_col}` LIKE %s"] * len(likes)) params = list(likes) + [start, end] rows = db.conn.execute( f"SELECT `{pnl_col}` AS pnl, `{time_col}` AS t " f"FROM trade_history " f"WHERE ({or_sql}) " f"AND `{time_col}` >= %s AND `{time_col}` < DATE_ADD(%s, INTERVAL 1 DAY) " f"ORDER BY `{time_col}`", tuple(params), ).fetchall() if not rows: return f"실매({start}~{end}) 해당 전략 청산 기록 없음(또는 전략명 불일치)." pnls = [] for r in rows: if isinstance(r, dict): pnls.append(_f(r.get("pnl"))) else: pnls.append(_f(r[0])) wins = sum(1 for p in pnls if p > 0) total = sum(pnls) wr = (wins / len(pnls) * 100.0) if pnls else 0.0 return ( f"실매 동기간 청산 {len(pnls)}건 · 승률 {wr:.0f}% · 합산 PnL {total:,.0f}원 " f"(백테 Optuna와 숫자 달라도 정상 — 고스트·유니버스·틱 경로 차이)." ) except Exception as exc: return f"(실매 요약 실패: {exc})" finally: try: db.conn.close() except Exception: pass def build_rule_briefing(data: Dict[str, Any]) -> str: """규칙 기반 — 이전 장 / 앞으로 장 코멘트.""" strategy = str(data.get("strategy") or "tail").strip().lower() if strategy in ("", "none"): strategy = "tail" name = _STRAT_KO.get(strategy, strategy) start = str(data.get("start") or "?") end = str(data.get("end") or "?") days = _i(data.get("backtest_days"), 0) mode = str(data.get("mode") or "") n_req = _i(data.get("optuna_n_trials_requested"), 0) n_done = _i(data.get("optuna_trials_completed"), 0) n_all = _i(data.get("n_results_all"), len(data.get("results_all") or [])) n_gated = _i(data.get("n_results_gated"), len(data.get("results_gated") or [])) search_wr = _f(data.get("min_win_rate")) search_pf = _f(data.get("min_pf")) search_mt = _i(data.get("min_trades"), 1) rg = data.get("report_gates") or {} rep_wr = _f(rg.get("min_win_rate"), 40.0) rep_pf = _f(rg.get("min_pf"), 1.0) rep_mt = _i(rg.get("min_trades"), search_mt) best_learn = (data.get("results") or data.get("results_all") or [None])[0] best_gate = (data.get("results_gated") or [None])[0] best = best_gate or best_learn lines: List[str] = [] lines.append(f"# Optuna 브리핑 — {name} ({mode})") lines.append(f"기간: {start} ~ {end} (거래일≈{days or '?'}일) · trials {n_done}/{n_req}") lines.append( f"탐색 게이트: WR≥{search_wr:g}% PF≥{search_pf:g} · min_trades≥{search_mt} " f"| 후보 게이트: WR≥{rep_wr:g}% PF≥{rep_pf:g} · min_trades≥{rep_mt}" ) lines.append( f"- min_trades={search_mt}: **합격 인정 최소 거래 건수**(표본 부족·우연 승률 방지). " f"이보다 적은 조합은 학습/후보 집계에서 제외됩니다." ) lines.append(f"학습용 통과 {n_all}건 · 사후합격(results_gated) {n_gated}건") lines.append("") # 과적합·적용가능도 (휴리스틱) + 임계값 분포 요약 diag = data.get("overfit_diagnostics") if not isinstance(diag, dict) or diag.get("overfit_risk_pct") is None: try: from kis_trader.backtest.optuna_common import build_optuna_overfit_diagnostics diag = build_optuna_overfit_diagnostics(data) data["overfit_diagnostics"] = diag except Exception: diag = {} if isinstance(diag, dict) and diag.get("overfit_risk_pct") is not None: lines.append("## 과적합·적용 가능도 (휴리스틱)") lines.append( f"- **과적합 위험 {diag.get('overfit_risk_pct')}%** · " f"**적용 가능도 {diag.get('apply_readiness_pct')}%** · " f"판정: **{diag.get('verdict')}** — {diag.get('verdict_ko')}" ) for fac in (diag.get("factors") or [])[:6]: if not isinstance(fac, dict): continue lines.append( f" - {fac.get('label')}: +{fac.get('points')}p — {fac.get('detail')}" ) dist = diag.get("threshold_distribution") or [] if dist: lines.append( f"- 임계값 분포 표본: {diag.get('threshold_pool')} " f"상위 {diag.get('threshold_pool_n')}건" ) lines.append( "| 파라미터 | median | p25~p75 | mode(비율) |" ) lines.append("|---|---:|---|---|") for row in dist[:12]: if not isinstance(row, dict): continue med = row.get("median") p25, p75 = row.get("p25"), row.get("p75") band = ( f"{p25} ~ {p75}" if p25 is not None and p75 is not None else "—" ) med_s = f"{med}" if med is not None else "—" share = row.get("mode_share") share_s = f"{float(share)*100:.0f}%" if share is not None else "?" lines.append( f"| `{row.get('param')}` | {med_s} | {band} | " f"{row.get('mode')} ({share_s}) |" ) if diag.get("note"): lines.append(f"- _참고: {diag.get('note')}_") lines.append("") lines.append("## 이전 장에서는") live = _live_pnl_snapshot(strategy, start, end) if live: lines.append(f"- {live}") if not best: lines.append( "- 이 기간 백테에서는 **유의미한 통과 조합이 거의 없었습니다.** " "유니버스·틱·손절·시간창을 먼저 점검하세요 (돌파 0건이면 특히)." ) else: pnl = _f(best.get("total_pnl")) wr = _f(best.get("win_rate")) pf = _f(best.get("pf")) nt = _i(best.get("total_trades")) tn = best.get("optuna_trial_number") tag = "사후합격 1위" if best_gate else "학습 1위(사후게이트 미달 가능)" lines.append( f"- {tag}: trial #{tn} · 거래 {nt}건 · 승률 {wr:.1f}% · PF {pf:.2f} · PnL {pnl:,.0f}원" ) if wr >= 95.0 and nt < 15: lines.append( "- 승률이 비정상적으로 높고 표본이 작습니다 → **해당 장에만 맞는 과적합** 신호." ) if days and days <= 2: lines.append( f"- 표본 장이 {days}일뿐이라, ‘이전 장에 잘 먹힌 숫자’일 뿐 **추세가 바뀌면 바로 깨질 수 있습니다.**" ) if n_gated == 0 and n_all > 0: lines.append( "- 플러스/학습 후보는 있으나 승률·PF 사후 기준은 못 넘김 → **당장 DB 적용은 비권장.**" ) if strategy == "breakout" and n_all == 0: lines.append( "- 돌파는 실매 고스트퍼지·숄더 청산과 백테 풀경로가 어긋나기 쉽습니다. " "‘손실이 지워진 실매’와 ‘손절까지 포함한 백테’를 같은 숫자로 보지 마세요." ) lines.append("") lines.append("## 앞으로 장에서는") lines.append( "- **1~2일 Optuna best를 바로 실매에 넣지 마세요.** 다일(≥5거래일) 재검증·웹백테 1회 후 검토." ) lines.append( "- 적용 시에는 `results_gated` 후보만. 탐색용 best(objective)와 다를 수 있습니다." ) lines.append( "- 장이 좁아지거나 변동성이 커지면: 손절·트레일·시간창이 먼저 깨집니다. " "승률 자랑보다 **손절이 살아 있는지**를 보세요." ) if best_gate is None: lines.append( "- 현재 사후합격 0건 → 파라미터 유지(현행 DB) + 유니버스/데이터 점검이 우선입니다." ) else: lines.append( "- 사후합격이 있어도 ‘내일도 같은 장’이 아닙니다. " "페이퍼/소액·모의로 1~2일 관찰 후 확대하세요." ) lines.append("") lines.append(f"_생성: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')} (rule)_") return "\n".join(lines) def _briefing_prompt(rule_text: str, data: Dict[str, Any]) -> str: compact = { "strategy": data.get("strategy"), "mode": data.get("mode"), "start": data.get("start"), "end": data.get("end"), "backtest_days": data.get("backtest_days"), "min_win_rate": data.get("min_win_rate"), "min_pf": data.get("min_pf"), "report_gates": data.get("report_gates"), "n_results_all": data.get("n_results_all"), "n_results_gated": data.get("n_results_gated"), "optuna_best_trial_number": data.get("optuna_best_trial_number"), "optuna_best_value": data.get("optuna_best_value"), "top_gated": (data.get("results_gated") or [None])[0], "top_learning": (data.get("results") or [None])[0], "mode_combo_vs_best": (data.get("mode_combo") or {}).get("vs_best"), } return ( "당신은 한국 주식 퀀트 헤지펀드 리스크 매니저입니다. " "아래 Optuna TPE 결과와 규칙 브리핑을 읽고, 초보자도 이해하게 " "「이전 장에서는」/「앞으로 장에서는」 두 절로만 한국어 코멘트를 쓰세요. " "과적합·표본부족·실매↔백테 괴리(고스트퍼지 등)를 분명히 경고하세요. " "특정 종목 매수 추천·확정 수익 약속 금지. 200~400자.\n\n" f"[규칙 브리핑]\n{rule_text}\n\n" f"[요약 JSON]\n{json.dumps(compact, ensure_ascii=False, default=str)[:6000]}" ) def _call_gemini_briefing(rule_text: str, data: Dict[str, Any]) -> Optional[str]: """GEMINI_API_KEY(구글) 있으면 코멘트. 실패 시 None.""" api_key = ( str(get_env_from_db("GEMINI_API_KEY", "") or "").strip() or str(os.environ.get("GEMINI_API_KEY", "") or "").strip() or str(os.environ.get("GOOGLE_API_KEY", "") or "").strip() ) if not api_key: return None try: import google.genai as genai except ImportError: logger.warning("google-genai 미설치 — pip install google-genai") return None prompt = _briefing_prompt(rule_text, data) try: model = ( str(get_env_from_db("GEMINI_MODEL_ID", "gemini-2.5-flash") or "").strip() or "gemini-2.5-flash" ) client = genai.Client(api_key=api_key) response = client.models.generate_content(model=model, contents=prompt) text = getattr(response, "text", None) or "" if not text and getattr(response, "candidates", None): try: text = response.candidates[0].content.parts[0].text except Exception: text = "" return (text or "").strip() or None except Exception as exc: logger.warning("Gemini 브리핑 실패: %s", exc) return None def _call_claude_briefing(rule_text: str, data: Dict[str, Any]) -> Optional[str]: """ANTHROPIC_API_KEY 있으면 코멘트. 실패 시 None.""" api_key = ( str(get_env_from_db("ANTHROPIC_API_KEY", "") or "").strip() or str(os.environ.get("ANTHROPIC_API_KEY", "") or "").strip() ) if not api_key: return None try: import anthropic except ImportError: logger.warning("anthropic 미설치 — Claude 스킵") return None prompt = _briefing_prompt(rule_text, data) try: model = str(get_env_from_db("CLAUDE_MODEL_ID", "claude-sonnet-4-5") or "").strip() or "claude-sonnet-4-5" max_tok = int(get_env_int("CLAUDE_MAX_TOKENS", 1024)) client = anthropic.Anthropic(api_key=api_key) resp = client.messages.create( model=model, max_tokens=min(2048, max(256, max_tok)), messages=[{"role": "user", "content": prompt}], ) text = resp.content[0].text if resp.content else "" return (text or "").strip() or None except Exception as exc: logger.warning("Claude 브리핑 실패: %s", exc) return None def _call_ai_briefing(rule_text: str, data: Dict[str, Any]) -> Tuple[Optional[str], str]: """AI 보강. 반환: (텍스트, provider명). Gemini → Claude.""" if not get_env_bool("PARAM_SEARCH_OPTUNA_BRIEFING_AI", True): return None, "BRIEFING_AI=off" gem = _call_gemini_briefing(rule_text, data) if gem: return gem, "gemini" claude = _call_claude_briefing(rule_text, data) if claude: return claude, "claude" has_g = bool( str(get_env_from_db("GEMINI_API_KEY", "") or "").strip() or str(os.environ.get("GEMINI_API_KEY", "") or "").strip() ) has_c = bool( str(get_env_from_db("ANTHROPIC_API_KEY", "") or "").strip() or str(os.environ.get("ANTHROPIC_API_KEY", "") or "").strip() ) if not has_g and not has_c: return None, "no_api_key(GEMINI_API_KEY 또는 ANTHROPIC_API_KEY)" return None, "api_call_failed" def write_briefing_for_json( json_path: str, *, log: Optional[logging.Logger] = None, ) -> Optional[str]: """JSON 옆 .briefing.md 저장 + 로그/프린트. 경로 반환.""" lg = log or logger abs_path = os.path.abspath(json_path) if not os.path.isfile(abs_path): lg.warning("⚠️ 브리핑 스킵 — JSON 없음: %s", abs_path) return None try: with open(abs_path, "r", encoding="utf-8") as f: data = json.load(f) except (OSError, json.JSONDecodeError) as exc: lg.warning("⚠️ 브리핑 JSON 읽기 실패: %s", abs_path) return None try: from kis_trader.backtest.optuna_common import attach_optuna_overfit_diagnostics attach_optuna_overfit_diagnostics(data) except Exception: pass rule = build_rule_briefing(data) ai, provider = _call_ai_briefing(rule, data) if ai: body = ( rule + f"\n\n## AI 코멘트 (이전 장 / 앞으로 장) [{provider}]\n\n" + ai + "\n" ) else: body = ( rule + f"\n\n## AI 코멘트\n\n_(미생성: {provider}. " "구글 키는 GEMINI_API_KEY 를 DB/환경에 넣으세요 — " "kis_trader/backtest/optuna_briefing.py 모듈 docstring 참고)_\n" ) lg.info("ℹ️ AI 브리핑 없음: %s", provider) out_md = abs_path.rsplit(".", 1)[0] + ".briefing.md" try: with open(out_md, "w", encoding="utf-8") as f: f.write(body) except OSError as exc: lg.warning("⚠️ 브리핑 저장 실패: %s", exc) return None try: data["briefing_md_path"] = out_md data["briefing_text"] = body data["briefing_ai_provider"] = provider if ai else None with open(abs_path, "w", encoding="utf-8") as f: json.dump(data, f, indent=2, ensure_ascii=False) except OSError: pass lg.info("🗣️ Optuna 브리핑: %s", out_md) print(f"OPTUNA_BRIEFING_MD={out_md}", flush=True) preview = (ai or rule).strip().splitlines() for line in preview[:12]: print(line, flush=True) if len(preview) > 12: print("… (전체는 briefing.md)", flush=True) return out_md def main() -> None: logging.basicConfig(level=logging.INFO, format="%(message)s") p = argparse.ArgumentParser(description="Optuna 결과 브리핑") p.add_argument("json_path", help="optuna_*.json 경로") args = p.parse_args() path = write_briefing_for_json(args.json_path) if not path: raise SystemExit(1) if __name__ == "__main__": main()