ls증권 히스토리 구독 넣음

This commit is contained in:
Your Name
2026-07-30 18:05:07 +09:00
parent 61bec4bd1d
commit 67eab24603
1593 changed files with 135733 additions and 1232 deletions

View File

@@ -0,0 +1,432 @@
#!/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()