Files
kis_bot/kis_trader/backtest/param_apply_ai.py
Hwang 61c72a8a4c feat(tests): 신규 키움 웹소켓 조건검색 및 실시간 조건검색 테스트 추가
변경 사항
----
- _test_kiwoom_condition_list.py: 키움 웹소켓 조건검색 '목록조회' 기능을 단독으로 테스트하는 스크립트 추가
- _test_kiwoom_condition_realtime.py: 'momentum' 조건식을 실시간으로 등록하고 초기 매칭 종목 리스트 및 실시간 편입/이탈을 수신하는 테스트 스크립트 추가
- _verify_columnar_bitid.py, _verify_shared_e2e_breakout.py, _verify_shared_e2e.py: 공유 메모리 및 dict 간의 데이터 일관성을 검증하는 테스트 추가

영향
----
- 신규 테스트 스크립트 추가로 키움 웹소켓 API의 기능 검증 및 안정성을 높임
- 기존 기능에 대한 영향 없음

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-06 01:27:00 +09:00

315 lines
13 KiB
Python

#!/usr/bin/env python3
"""
kis_trader/backtest/param_apply_ai.py
=====================================
파라미터 탐색 결과 JSON을 Gemini에 넘겨, 수익 나는 것 중 승률 높은 조합을
골라 DB env_config 에 적용한다.
- 스캘핑 : 최근 search_*.json (top 배열) 중 total_pnl > 0 만 AI 후보
- 꼬리잡기: 최신 search_tail_*.json (또는 구 tail_search_*.json · 레거시 tail_param_result.json)
- 승률만 높거나 손익만 큰 것은 제외하고 "수익이 나는 것 중 승률이 높은 것" 을 고르도록 프롬프트 구성
- [안전장치] 총손익(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] · [~/.kis_bot_search_results] · 구 [ROOT/backtest_scalping/results]
- tail 결과 파일 = search_tail_{mode}_{ts}.json (매 실행 새 파일 · momentum/breakout 동일 패턴)
사용: 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)
from kis_trader.backtest.backtest_portfolio_common import merge_param_search_apply_source # noqa: E402
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(os.path.expanduser("~"), ".kis_bot_search_results"),
os.path.join(ROOT, "backtest_scalping", "results"),
]
def _find_latest_json(prefix: str) -> Optional[str]:
"""prefix(예: 'search_tail_', '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
# ──────────────────────────────────────────────────────────────────────────────
# 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_scalping 모듈 사용 (HERE 가 sys.path 에 들어있음)
# 2026-05: param_search.py → param_search_scalping.py 로 rename (전략별 파일 분리)
import param_search_scalping as _ps
chosen = profitable_top[rank - 1]
merged = merge_param_search_apply_source(chosen, data)
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:
"""
최신 search_tail_*.json (또는 구 tail_search_* / 레거시 tail_param_result.json) 을 읽어
results 중 Gemini가 고른 순위를 DB에 적용.
Returns: 성공 여부.
"""
import tail_param_search as _tps
path = _tps._find_tail_result_json()
if not path:
msg = (
"❌ search_tail_*.json / tail_search_*.json / tail_param_result.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
chosen = profitable_results[rank - 1]
merged = merge_param_search_apply_source(chosen, data)
_tps.apply_params_to_db(merged)
logger.info(f"✅ 꼬리잡기 {rank}위 조합을 DB에 적용했습니다. (파일: {path})")
print(f"✅ 꼬리잡기 {rank}위 조합을 DB에 적용했습니다.")
return True