Files
kis_bot/kis_trader/backtest/optuna_briefing.py
2026-07-30 18:05:07 +09:00

433 lines
17 KiB
Python
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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("")
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", exc)
return None
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()