Files
kis_bot/ai_recommend_notice.py
2026-03-17 12:33:30 +09:00

338 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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()