브랜치 분리 방식: A / B / C
A 선택 시 커밋 메시지: 위 초안 OK / 수정 / 직접 작성 작업 시점: 지금 / 운영 데이터 1~2일 쌓고 / 주말
This commit is contained in:
321
kis_trader/backtest/param_apply_ai.py
Normal file
321
kis_trader/backtest/param_apply_ai.py
Normal file
@@ -0,0 +1,321 @@
|
||||
#!/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
|
||||
Reference in New Issue
Block a user