변경 사항 ---- - _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>
315 lines
13 KiB
Python
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
|