#!/usr/bin/env python3 """ [1회성] 13시 발송용 AI 추천 수치 스크립트 - DB env_config 최신 수치 + trade_history 최근 10건 + journalctl 최근 200줄을 참고해 Gemini로 문제점·수치 추천 분석 후 Mattermost 발송 및 last_ai_recommendations 저장. - 실행: python3 ai_recommend_notice.py (cron 0 13 * * * 등으로 13:00 실행 가능) """ import os import re import sys import json import subprocess import warnings from pathlib import Path SCRIPT_DIR = Path(__file__).resolve().parent sys.path.insert(0, str(SCRIPT_DIR)) # DB/설정은 프로젝트 DB 사용 from database import TradeDB, ENV_CONFIG_KEYS warnings.filterwarnings("ignore", message=".*google.*") # Gemini GEMINI_MODEL_ID = "gemini-2.5-flash" gemini_client = None try: import google.generativeai as genai db = TradeDB(db_path=str(SCRIPT_DIR / "quant_bot.db")) latest = db.get_latest_env() snap = (latest or {}).get("snapshot") or {} GEMINI_API_KEY = (snap.get("GEMINI_API_KEY") or "").strip() if GEMINI_API_KEY: genai.configure(api_key=GEMINI_API_KEY) gemini_client = genai.GenerativeModel(GEMINI_MODEL_ID) else: db.close() print("❌ GEMINI_API_KEY 없음 (DB env_config)") sys.exit(1) except Exception as e: print(f"❌ Gemini/DB 초기화 실패: {e}") sys.exit(1) # 단타 봇(kis_short_ver1.py) 코드 기준: env 키가 쓰이는 위치 (AI 추천 시 반드시 참고) KIS_SHORT_CODE_REFERENCE = """ **[단타 봇 kis_short_ver1.py – env 수치 사용 위치 (추천 시 이 봇에만 쓰이는 키만 제안할 것)]** ■ reload_config() – DB에서 읽어 봇에 반영 - STOP_LOSS_PCT: 퍼센트 손절 (profit_pct <= 이 값이면 칼손절, 기본 -0.04) - TAKE_PROFIT_PCT: 퍼센트 익절 (profit_pct >= 이 값이면 익절(퍼센트), 기본 0.05) - MAX_STOCKS: 최대 보유 종목 수 - MIN_DROP_RATE: 매수 조건 – 당일 낙폭 하한 (시가 대비 저가 하락률, drop_rate < 이 값이면 탈락-낙폭, 기본 0.03) - MIN_RECOVERY_RATIO_SHORT: 매수 조건 – 일봉 회복률 하한 (저가~현재가/당일범위, recovery_pos_day < 이 값이면 탈락-회복률, 기본 0.5). 동일 값이 3분봉 회복률 구간 하한으로도 사용됨(min_recovery_ratio ~ MAX_RECOVERY_RATIO_3M). - STOP_ATR_MULTIPLIER_TAIL, TARGET_ATR_MULTIPLIER_TAIL: ATR 손절가/목표가 배율 (stop_price = 매수가 - ATR*배율, target = 매수가 + ATR*배율) - MIN_HOLD_HOURS: 24시간 보유 전략의 기준 시간(시간). 이 시간 미만이면 “24시간내” 규칙, 이상이면 “24시간 경과” 규칙 적용. - RISK_PCT_PER_TRADE, KELLY_MULTIPLIER, MAX_POSITION_PCT, MIN_POSITION_AMOUNT, USE_KELLY_FORMULA: RiskManager(매수 금액·종목당 상한) - SLOT_BASE_AMOUNT_CAP: RiskManager 종목당 원화 상한(0이면 미적용). 매수 금액이 이 값을 넘으면 이 값으로 잘림. - ML: USE_ML_SIGNAL, ML_MIN_PROBABILITY - TOTAL_DEPOSIT: 리포트용 ■ 매수 필터 (check_buy_signal_tail_catch) – 순서대로 적용, 하나라도 실패하면 탈락 - 낙폭: drop_rate = (시가-저가)/시가. drop_rate < MIN_DROP_RATE → 탈락-낙폭 - 회복률(일봉): recovery_pos_day = (현재가-저가)/(고가-저가). < MIN_RECOVERY_RATIO_SHORT → 탈락-회복률 - 꼬리: tail_ratio = 꼬리길이/몸통길이, tail_pct = 꼬리길이/저가. TAIL_RATIO_MIN(기본 1.5) 또는 TAIL_PCT_MIN(기본 0.003) 미만 → 탈락-꼬리 - 3분봉 회복: recovery_pos = (현재가-저가)/(고가-저가) (해당 3분봉 내). MIN_RECOVERY_RATIO_SHORT ~ MAX_RECOVERY_RATIO_3M(기본 0.8) 구간이 아니면 → 탈락-회복3분 - 피뢰침: HIGH_PRICE_CHASE_THRESHOLD(0.96) – 현재가 >= 당일고가*이 값 → 탈락-피뢰침 고점추격. MAX_DAILY_CHANGE_PCT(20) – (고가-저가)/저가*100 > 이 값 → 탈락-피뢰침 급등주 - RSI_OVERHEAT_THRESHOLD(78): RSI >= 이 값 → 탈락-RSI - MA20: 현재가 < MA20 → 탈락-MA20. 현재가 > MA20*(1+MA20_MAX_ABOVE_PCT/100) → 탈락-MA20초과 ■ 매도 (check_sell_signals) – 아래 순서대로 적용, 먼저 걸린 사유로 매도 - [1] SHOULDER_CUT_PCT(0.03): 고점 대비 하락률 >= 이 값 → 어깨매도 (최우선) - [2] MAX_LOSS_PER_TRADE_KRW(200000): 원화 손실(profit_val) <= -이 값 → 금액손실컷. 0이면 이 조건 비활성화. - [3] SCALP_ATR_UP_MULT, SCALP_ATR_DOWN_MULT, SCALP_ATR_DROP_MULT: ATR 스캘핑 → 스캘핑_본절사수 / 스캘핑_익절보존 - [4] USE_QUICK_PROFIT_PROTECTION(True): **매수 후 0.5시간(QUICK_PROFIT_PROTECT_HOURS) 이내에만** 적용. 이 시간이 지나면 이 조건은 더 이상 보지 않음. 조건: 고점이 매수가*QUICK_PROFIT_MAX_RATIO(1.005) 이상 올랐는데, 현재가가 매수가*QUICK_PROFIT_CURRENT_MIN(1.0015) 이하로 다시 내려오면 → "💨 작은수익보호" 매도. (24시간 제한과 별개: 0.5시간만 적용, 그 이후는 [5] 24시간 보유 전략이 적용됨) - [5] 24시간 보유 전략 (MIN_HOLD_HOURS 기준): **보유 시간이 MIN_HOLD_HOURS(24) 미만**이면: 수익률 > MIN_HOLD_EARLY_TAKE_PCT(0.05) → 익절, 또는 고점이 매수가*MIN_HOLD_HIGH_PCT(1.07) 이상 올랐다가 현재가 <= 고점*MIN_HOLD_DROP_FROM_HIGH(0.97) → 고점→하락 매도. **MIN_HOLD_HOURS 이상**이면: 수익률 > POST_HOLD_TAKE_PCT(0.02) → 익절, 또는 수익 중인데 현재가 < 고점*POST_HOLD_DROP_FROM_HIGH(0.97) → 익절보호. - [6] (위에서 모두 걸리지 않았을 때만) 목표가/손절: 현재가 >= target_price → 목표달성. 현재가 <= stop_price(ATR 손절가) → 전략손절. profit_pct <= stop_loss_pct → 칼손절. profit_pct >= take_profit_pct → 익절(퍼센트). ※ CONSECUTIVE_LOSS_LIMIT, MAX_PEG, MIN_RECOVERY_RATIO(단타는 MIN_RECOVERY_RATIO_SHORT만 사용)는 이 단타 봇에서 사용하지 않음. MAX_PEG는 kis_long_ver1.py(늘림목 봇) 전용. 추천 시 위에 나온 키만 사용할 것. """ # DB 최신 env에서 수치만 (계좌/키/토큰 제외) EXCLUDE = { "MM_SERVER_URL", "MM_BOT_TOKEN_", "MATTERMOST_CHANNEL", "GEMINI_API_KEY", "KIS_APP_KEY_REAL", "KIS_APP_SECRET_REAL", "KIS_APP_KEY_MOCK", "KIS_APP_SECRET_MOCK", "KIS_ACCOUNT_NO_REAL", "KIS_ACCOUNT_CODE_REAL", "KIS_ACCOUNT_NO_MOCK", "KIS_ACCOUNT_CODE_MOCK", "KIS_SHORT_MM_CHANNEL", "KIS_LONG_MM_CHANNEL", } def get_env_numeric_snapshot(db_instance): """DB 최신 env에서 계좌/키 제외한 수치만 키=값 줄 단위로 반환.""" latest = db_instance.get_latest_env() if not latest or not latest.get("snapshot"): return "" snap = latest["snapshot"] lines = [] for k, v in sorted(snap.items()): if k in EXCLUDE or v is None: continue v = (str(v) or "").strip() if "#" in v: v = v.split("#")[0].strip() if not v: continue lines.append(f"{k}={v}") return "\n".join(lines) def get_recent_trades(db_instance, limit=10): """trade_history 최근 N건.""" out = [] try: cursor = db_instance.conn.execute(""" SELECT code, name, buy_price, sell_price, qty, profit_rate, realized_pnl, strategy, sell_reason, buy_date, sell_date, hold_minutes FROM trade_history ORDER BY id DESC LIMIT ? """, (limit,)) for row in cursor.fetchall(): out.append({ "code": row[0], "name": row[1], "buy_price": row[2], "sell_price": row[3], "qty": row[4], "profit_rate": row[5], "realized_pnl": row[6], "strategy": row[7], "sell_reason": row[8], "buy_date": row[9], "sell_date": row[10], "hold_minutes": row[11] or 0, }) except Exception as e: print(f"⚠️ 거래 내역 조회 실패: {e}") return out def get_journalctl_recent(lines=200, unit=None): """journalctl 최근 N줄. unit 있으면 -u unit 적용 (예: kis_short).""" cmd = ["journalctl", "-n", str(lines), "-o", "short-iso"] if unit: cmd = ["journalctl", "-u", unit, "-n", str(lines), "-o", "short-iso"] try: r = subprocess.run(cmd, capture_output=True, text=True, timeout=10) if r.returncode == 0 and r.stdout: return r.stdout.strip() except Exception as e: print(f"⚠️ journalctl 조회 실패: {e}") return "" def save_ai_recommendations_from_text(db_instance, analysis_text): """AI 분석문에서 KEY=값 추천만 추출해 DB last_ai_recommendations에 저장.""" if not analysis_text or not db_instance: return valid_keys = set(ENV_CONFIG_KEYS) result = [] for line in analysis_text.splitlines(): line = line.strip() if not line or line.startswith("#"): continue m = re.match(r"^([A-Z][A-Z0-9_]*)=(.+)$", line) if m and m.group(1) in valid_keys: result.append(f"{m.group(1)}={m.group(2).strip()}") if result: db_instance.set_last_ai_recommendations("\n".join(result)) def send_mm(message, db_instance): """Mattermost 발송 (DB env에서 URL/토큰/채널 조회, mm_config.json 채널 ID).""" latest = db_instance.get_latest_env() snap = (latest or {}).get("snapshot") or {} url = (snap.get("MM_SERVER_URL") or "https://mattermost.hoonfam.org").strip().rstrip("/") token = (snap.get("MM_BOT_TOKEN_") or "").strip() channel_alias = (snap.get("KIS_SHORT_MM_CHANNEL") or snap.get("MATTERMOST_CHANNEL") or "stock").strip() if not token: print("❌ MM_BOT_TOKEN_ 없음") return False config_path = SCRIPT_DIR / "mm_config.json" channels = {} if config_path.exists(): try: with open(config_path, "r", encoding="utf-8") as f: channels = json.load(f).get("channels", {}) except Exception: pass channel_id = channels.get(channel_alias) if not channel_id: print(f"❌ mm_config.json에 채널 alias '{channel_alias}' 없음") return False import requests api_url = f"{url}/api/v4/posts" headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"} payload = {"channel_id": channel_id, "message": message} try: r = requests.post(api_url, headers=headers, json=payload, timeout=10) r.raise_for_status() return True except Exception as e: print(f"❌ MM 전송 에러: {e}") return False def main(): # DB 참고: 수치 스냅샷, 최근 거래, 후보 수 env_lines = get_env_numeric_snapshot(db) recent_trades = get_recent_trades(db, 10) db_candidates = db.get_target_candidates() candidate_count = len(db_candidates) # journalctl 최근 200줄 (단위는 환경변수 JOURNALCTL_UNIT 있으면 사용, 없으면 전체) journal_unit = os.environ.get("JOURNALCTL_UNIT", "kis_short_ver1.service").strip() or None journal_log = get_journalctl_recent(200, unit=journal_unit) if not journal_log: journal_log = "(journalctl 로그 없음)" # 프롬프트: DB + 거래 + journal 로그 반영 if not recent_trades: summary = f"- 유니버스 후보: {candidate_count}개\n- 최근 거래: 없음" trades_text = "없음" prompt = f"""당신은 퀀트 트레이딩 전문가입니다. **현재 상태** {summary} **현재 DB 설정 수치 (일부만, 계좌/키 제외)** ``` {env_lines} ``` **봇 최근 로그 (journalctl 최근 200줄) – 탈락 사유·API 과부하·매수 시도 등 참고** ``` {journal_log[:15000]} ``` {KIS_SHORT_CODE_REFERENCE} **당신의 임무** 1. 위 설정·후보 수·로그·코드 기준을 보고 문제점 분석 (필터 과도, API 제한, 매수 미발생 원인 등). 추천은 반드시 위 코드 기준에 나온 키만 제안할 것. 2. **추천**: 반드시 "설정수치=값" 한 줄에 하나만. 이유·주석 붙이지 말 것. DB에 그대로 복붙 적용 가능하게. 예: MIN_DROP_RATE=0.005 예: MIN_RECOVERY_RATIO_SHORT=0.25 예: TAIL_RATIO_MIN=0.5 3. 예상 효과 한두 줄. **출력 형식 (반드시 준수)** ## 🔍 문제점 1. [구체적 문제 1] 2. [구체적 문제 2] ## 💡 수치 추천 (한 줄에 하나, 그대로 DB 적용 가능) KEY=value ... ## 📈 예상 효과 - [효과] """ else: total = len(recent_trades) wins = sum(1 for t in recent_trades if (t.get("profit_rate") or 0) > 0) losses = total - wins win_rate = (wins / total * 100) if total > 0 else 0 avg_profit = sum(t.get("profit_rate") or 0 for t in recent_trades) / total total_pnl = sum(t.get("realized_pnl") or 0 for t in recent_trades) avg_hold = sum(t.get("hold_minutes") or 0 for t in recent_trades) / total trades_text = "" for i, t in enumerate(recent_trades, 1): trades_text += f""" [거래 {i}] {t['name']} ({t.get('strategy','')}) - 매수: {t.get('buy_price',0):,.0f}원 × {t.get('qty',0)}주 | 매도: {t.get('sell_price',0):,.0f}원 - 손익: {t.get('profit_rate',0):+.2f}% ({t.get('realized_pnl',0):,.0f}원) | 보유: {t.get('hold_minutes',0)}분 - 사유: {t.get('sell_reason','')} """ summary = f"- 유니버스 후보: {candidate_count}개\n- 최근 거래: {total}건 | 승률: {win_rate:.1f}% ({wins}승 {losses}패)\n- 평균 수익률: {avg_profit:.2f}% | 총 손익: {total_pnl:,.0f}원 | 평균 보유: {avg_hold:.0f}분" prompt = f"""당신은 퀀트 트레이딩 전문가입니다. **현재 상태** {summary} **최근 거래 내역** {trades_text} **현재 DB 설정 수치 (계좌/키 제외)** ``` {env_lines} ``` **봇 최근 로그 (journalctl 최근 200줄) – 탈락 사유·매수 실패·과부하 등 참고** ``` {journal_log[:15000]} ``` {KIS_SHORT_CODE_REFERENCE} **당신의 임무** 1. 거래 내역·설정·로그·코드 기준을 보고 승률/매수 미발생 원인 등 구체적으로 3가지 진단. 추천은 반드시 위 코드 기준에 나온 키만 제안할 것. 2. **추천**: 반드시 "설정수치=값" 한 줄에 하나만. 이유·주석 붙이지 말 것. DB에 그대로 복붙 적용 가능하게. 3. 예상 효과 한두 줄. **출력 형식 (반드시 준수)** ## 🔍 문제점 (승률/매수 원인) 1. [구체적 문제 1] 2. [구체적 문제 2] 3. [구체적 문제 3] ## 💡 수치 추천 (한 줄에 하나, 그대로 DB 적용) KEY=value ... ## 📈 예상 효과 - [효과] """ # Gemini 호출 try: response = gemini_client.generate_content(prompt) analysis = (response.text if hasattr(response, "text") else (response.candidates[0].content.parts[0].text if response.candidates else "")) except Exception as e: print(f"❌ Gemini 생성 실패: {e}") db.close() sys.exit(1) save_ai_recommendations_from_text(db, analysis) message = f"""🤖 **[13시 AI 추천 수치]** (DB + journalctl 로그 반영) {summary} {analysis} --- 💬 단타 전략 최적화. 추천 줄은 그대로 DB에 적용 가능합니다. """ if send_mm(message, db): print("✅ AI 추천 안내 MM 발송 완료") else: print("⚠️ MM 발송 실패 (내용은 생성됨, last_ai_recommendations 저장됨)") db.close() if __name__ == "__main__": main()