#!/usr/bin/env python3 """ kis_trader/backtest/param_apply_ai.py ===================================== 파라미터 탐색 결과 JSON을 Gemini에 넘겨, 수익 나는 것 중 승률 높은 조합을 골라 DB env_config 에 적용한다. - 스캘핑 : 최근 search_*.json (top 배열) 중 total_pnl > 0 만 AI 후보 - 꼬리잡기: tail_param_result.json 또는 tail_search_*.json (results 배열) 중 total_pnl > 0 만 AI 후보 - 승률만 높거나 손익만 큰 것은 제외하고 "수익이 나는 것 중 승률이 높은 것" 을 고르도록 프롬프트 구성 - [안전장치] 총손익(total_pnl)이 양수인 조합이 하나도 없으면 AI 적용을 즉시 취소하여 계좌를 보호 위치 이관(2026-04 기준): backtest_scalping/param_apply_ai.py → kis_trader/backtest/param_apply_ai.py 경로 정책: - ROOT = kis_bot 프로젝트 루트 (kis_trader/backtest/파일 기준 3단계 위) - HERE = 본 스크립트 폴더 (kis_trader/backtest/) - 결과 디렉터리 = [HERE/results] 가 있으면 사용, 없으면 구(舊) [ROOT/backtest_scalping/results] 를 fallback - tail 중간파일 = [HERE/tail_param_result.json] → fallback [ROOT/backtest_scalping/tail_param_result.json] 사용: param_search.py / tail_param_search.py 에서 --apply-ai 옵션으로 호출 (이 모듈을 임포트해 사용). """ import os import re import sys import json import logging from typing import List, Dict, Any, Optional # 프로젝트 루트 (kis_bot) — 3단계 위 ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) if ROOT not in sys.path: sys.path.insert(0, ROOT) # 본 스크립트 디렉터리 (kis_trader/backtest/) — param_search / tail_param_search 동일 폴더 임포트용 HERE = os.path.dirname(os.path.abspath(__file__)) if HERE not in sys.path: sys.path.insert(0, HERE) logger = logging.getLogger("param_apply_ai") # Gemini 클라이언트 (지연 초기화) _gemini_client = None _gemini_model_id = "gemini-2.5-flash" # ────────────────────────────────────────────────────────────────────────────── # 결과 파일 탐색 유틸 (신규 → 구 경로 fallback) # ────────────────────────────────────────────────────────────────────────────── def _candidate_result_dirs() -> List[str]: """결과 디렉터리 후보 [신규 > 구] 순서로 반환.""" return [ os.path.join(HERE, "results"), os.path.join(ROOT, "backtest_scalping", "results"), ] def _find_latest_json(prefix: str) -> Optional[str]: """prefix(예: 'search_', 'tail_search_') 로 시작하는 가장 최근 JSON 경로를 반환.""" best_path, best_mtime = None, -1.0 for d in _candidate_result_dirs(): if not os.path.isdir(d): continue for f in os.listdir(d): if not (f.startswith(prefix) and f.endswith(".json")): continue p = os.path.join(d, f) try: m = os.path.getmtime(p) except OSError: continue if m > best_mtime: best_mtime = m best_path = p return best_path def _find_tail_result_json() -> Optional[str]: """tail_param_result.json 을 신규·구 경로 순으로 찾는다.""" candidates = [ os.path.join(HERE, "tail_param_result.json"), os.path.join(ROOT, "backtest_scalping", "tail_param_result.json"), ] for p in candidates: if os.path.isfile(p): return p return None # ────────────────────────────────────────────────────────────────────────────── # Gemini # ────────────────────────────────────────────────────────────────────────────── def _get_gemini_client(): """DB env_config 에서 GEMINI_API_KEY 를 읽어 클라이언트 생성.""" global _gemini_client if _gemini_client is not None: return _gemini_client try: from database import TradeDB db = TradeDB() try: latest = db.get_latest_env() snap = (latest or {}).get("snapshot") or {} key = (snap.get("GEMINI_API_KEY") or "").strip() finally: db.close() if not key: msg = "❌ GEMINI_API_KEY 가 DB env_config 에 없습니다." logger.error(msg) print(msg) return None import google.genai as genai _gemini_client = genai.Client(api_key=key) return _gemini_client except ImportError: msg = "❌ google-genai 미설치. pip install google-genai" logger.error(msg) print(msg) return None except Exception as e: msg = f"❌ Gemini 클라이언트 초기화 실패: {e}" logger.error(msg) print(msg) return None def _build_prompt(results: List[Dict], strategy_name: str, max_rank: int) -> str: """Gemini에 넘길 프롬프트 문자열 생성. 수익 나는 것 중 승률 높은 것 우선 선택 요청.""" lines = [ f"아래는 {strategy_name} 백테스트 파라미터 탐색 결과 상위 {len(results)}개입니다.", "각 행: 순위, 승률(%), 총손익(원), 거래수, Profit Factor", "", ] for i, r in enumerate(results, 1): pnl = r.get("total_pnl", 0) wr = r.get("win_rate", 0) trades = r.get("total_trades", 0) pf = r.get("pf", 0) lines.append(f" {i:2d}위 | 승률 {wr}% | 손익 {pnl:+,}원 | 거래 {trades}건 | PF {pf}") lines.extend([ "", "조건: 반드시 **총손익이 플러스(total_pnl > 0)** 인 것만 고려하고,", "그 중에서 **승률이 높은 것**을 우선으로 하나만 골라주세요.", f"1~{max_rank} 중 하나의 순위만 숫자로 답해주세요. 예: 3", ]) return "\n".join(lines) def _parse_rank_from_response(text: str, max_rank: int) -> Optional[int]: """Gemini 응답에서 1~max_rank 사이 숫자 하나 추출.""" if not text: return None for m in re.finditer(r"\b([1-9]|[1-9][0-9]?)\b", text): n = int(m.group(1)) if 1 <= n <= max_rank: return n return None def pick_rank_by_ai(results: List[Dict], strategy_name: str, max_candidates: int = 50) -> Optional[int]: """ 결과 리스트(상위 max_candidates개)를 Gemini에 넘겨, 수익 나는 것 중 승률 높은 순위 하나 선택. Returns: 1-based rank (1~len(candidates)), 또는 None (실패 시). """ candidates = results[:max_candidates] if len(results) > max_candidates else results if not candidates: return None max_rank = len(candidates) prompt = _build_prompt(candidates, strategy_name, max_rank) client = _get_gemini_client() if not client: return None try: response = client.models.generate_content(model=_gemini_model_id, contents=prompt) text = (response.text or "").strip() rank = _parse_rank_from_response(text, max_rank) if rank is not None: logger.info(f"🤖 Gemini 선택: {rank}위 (응답 일부: {text[:200]}...)") print(f"🤖 Gemini 선택: {rank}위") return rank except Exception as e: msg = f"❌ Gemini 호출 실패: {e}" logger.error(msg) print(msg) return None # ────────────────────────────────────────────────────────────────────────────── # 스캘핑 / 꼬리잡기 AI 적용 엔트리 # ────────────────────────────────────────────────────────────────────────────── def apply_ai_scalp() -> bool: """ 최근 search_*.json 을 찾아 상위 목록 로드 → 수익 나는 것 중 Gemini가 고른 순위를 DB에 적용. Returns: 성공 여부. """ latest_path = _find_latest_json("search_") if not latest_path: msg = "❌ search_*.json 파일이 없습니다. 먼저 탐색을 실행하세요." logger.error(msg) print(msg) print("📌 DB 미적용. 기존 설정 유지.") return False print(f"📂 사용 파일 (최근 결과): {latest_path}") with open(latest_path, "r", encoding="utf-8") as f: data = json.load(f) top = data.get("top") or [] if not top: msg = "❌ JSON에 top 배열이 없습니다." logger.error(msg) print(msg) print("📌 DB 미적용. 기존 설정 유지.") return False # [안전장치] 양수(수익)인 조합만 철저히 필터링 profitable_top = [t for t in top if t.get("total_pnl", 0) > 0] if not profitable_top: print("\n🚨 scalp [안전장치 작동] 수익(총손익 > 0)이 발생하는 파라미터 조합이 하나도 없습니다.") print("🚨 확정 손실 파라미터를 라이브 봇에 적용할 수 없으므로 AI 자동 적용을 취소합니다.") print("📌 DB 미적용. 기존 설정 유지.") return False results = [ {"total_pnl": t.get("total_pnl"), "win_rate": t.get("win_rate"), "total_trades": t.get("total_trades"), "pf": t.get("pf")} for t in profitable_top ] rank = pick_rank_by_ai(results, "스캘핑") if rank is None: msg = "❌ Gemini가 순위를 선택하지 못했습니다. 수동으로 --apply N 을 사용하세요." logger.error(msg) print(msg) print("📌 DB 미적용. 기존 설정 유지.") return False # 같은 폴더의 param_search 모듈 사용 (HERE 가 sys.path 에 들어있음) import param_search as _ps merged = profitable_top[rank - 1].get("merged_params") or profitable_top[rank - 1].get("params") if not merged: msg = "❌ 해당 순위에 merged_params/params가 없습니다." logger.error(msg) print(msg) print("📌 DB 미적용. 기존 설정 유지.") return False _ps._apply_to_db(merged) logger.info(f"✅ 스캘핑 {rank}위 조합을 DB에 적용했습니다. (파일: {latest_path})") print(f"✅ 스캘핑 {rank}위 조합을 DB에 적용했습니다.") return True def apply_ai_tail() -> bool: """ tail_param_result.json (또는 최근 tail_search_*.json) 을 읽어 results 중 Gemini가 고른 순위를 DB에 적용. Returns: 성공 여부. """ # 1) tail_param_result.json 우선 (신규 위치 → 구 위치) path = _find_tail_result_json() # 2) 없으면 tail_search_*.json 중 최신 if not path: path = _find_latest_json("tail_search_") if not path: msg = "❌ tail_param_result.json / tail_search_*.json 이 없습니다." logger.error(msg) print(msg) print("📌 DB 미적용. 기존 설정 유지.") return False print(f"📂 사용 파일: {path}") with open(path, "r", encoding="utf-8") as f: data = json.load(f) results = data.get("results") or [] if not results: msg = "❌ JSON에 results 배열이 없습니다." logger.error(msg) print(msg) print("📌 DB 미적용. 기존 설정 유지.") return False # [안전장치] 양수(수익)인 조합만 철저히 필터링 profitable_results = [r for r in results if r.get("total_pnl", 0) > 0] if not profitable_results: print("\n🚨 tail [안전장치 작동] 수익(총손익 > 0)이 발생하는 파라미터 조합이 하나도 없습니다.") print("🚨 확정 손실 파라미터를 라이브 봇에 적용할 수 없으므로 AI 자동 적용을 취소합니다.") print("📌 DB 미적용. 기존 설정 유지.") return False list_for_ai = [ {"total_pnl": r.get("total_pnl"), "win_rate": r.get("win_rate"), "total_trades": r.get("total_trades"), "pf": r.get("pf", 0)} for r in profitable_results ] rank = pick_rank_by_ai(list_for_ai, "꼬리잡기") if rank is None: msg = "❌ Gemini가 순위를 선택하지 못했습니다. 수동으로 --apply N 을 사용하세요." logger.error(msg) print(msg) print("📌 DB 미적용. 기존 설정 유지.") return False import tail_param_search as _tps # AI가 고른 순위(profitable_results 기준)의 params를 DB에 직접 반영 merged = profitable_results[rank - 1].get("params") _tps.apply_params_to_db(merged) logger.info(f"✅ 꼬리잡기 {rank}위 조합을 DB에 적용했습니다. (파일: {path})") print(f"✅ 꼬리잡기 {rank}위 조합을 DB에 적용했습니다.") return True