""" Trading AI Telegram Bot (양방향 대화) 명령어: /start, /help — 안내 /추천 또는 /buy — 오늘 강력매수 top 5 /매도 또는 /sell — 오늘 강력매도 top 3 /종목 005930 또는 /stock 005930 — 특정 종목 상세 /시장 또는 /market — 시장 상황 요약 자유 텍스트 → EXAONE 3.5가 시스템 DB 데이터 + 사용자 질문 종합해 답변 """ import os import re import asyncio import json import logging from datetime import datetime from typing import Optional, List import asyncpg import httpx import structlog from telegram import Update, BotCommand from telegram.constants import ParseMode from telegram.ext import (Application, CommandHandler, MessageHandler, CallbackQueryHandler, filters, ContextTypes) logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s") logger = structlog.get_logger() PG = { "host": os.getenv("POSTGRES_HOST", "postgres"), "port": int(os.getenv("POSTGRES_PORT", 5432)), "database": os.getenv("POSTGRES_DB", "trading_ai"), "user": os.getenv("POSTGRES_USER", "kyu"), "password": os.getenv("POSTGRES_PASSWORD", ""), } TG_TOKEN = os.getenv("TELEGRAM_BOT_TOKEN", "") ALLOWED_CHAT_ID = os.getenv("TELEGRAM_CHAT_ID", "") # 허용된 사용자만 OLLAMA_URL = os.getenv("OLLAMA_URL", "http://ollama:11434") EXAONE_MODEL = os.getenv("EXAONE_MODEL", "exaone3.5:7.8b") SCORE_API = os.getenv("SCORE_API_URL", "http://score-engine:8686") pg_pool: Optional[asyncpg.Pool] = None # ───────────────────────────────────────────────────────────── # 권한 체크 # ───────────────────────────────────────────────────────────── def is_allowed(update: Update) -> bool: if not ALLOWED_CHAT_ID: return True return str(update.effective_chat.id) == ALLOWED_CHAT_ID # ───────────────────────────────────────────────────────────── # DB 조회 헬퍼 # ───────────────────────────────────────────────────────────── async def db_top_buys(limit: int = 5) -> list: async with pg_pool.acquire() as conn: return await conn.fetch(""" SELECT s.stock_code, s.stock_name, s.total_score, s.recommendation, s.us_overnight_adj, s.us_pair_top, s.insider_signal, s.consensus_signal, s.macro_signal, s.inst_flow_signal, s.valuation_pct, s.top_reasons FROM stock_scores s WHERE s.score_date=(SELECT MAX(score_date) FROM stock_scores) AND s.recommendation IN ('강력매수','매수관심') ORDER BY s.total_score DESC LIMIT $1 """, limit) async def db_top_sells(limit: int = 3) -> list: async with pg_pool.acquire() as conn: return await conn.fetch(""" SELECT stock_code, stock_name, total_score, recommendation, top_reasons FROM stock_scores WHERE score_date=(SELECT MAX(score_date) FROM stock_scores) AND recommendation IN ('강력매도','매도관심') ORDER BY total_score ASC LIMIT $1 """, limit) async def db_stock_detail(code: str) -> Optional[dict]: async with pg_pool.acquire() as conn: row = await conn.fetchrow(""" SELECT s.*, d.corp_name, d.sector_name, f.roe, f.operating_margin, f.debt_ratio, f.revenue_growth, f.fcf_ratio FROM stock_scores s JOIN dart_corps d ON d.stock_code=s.stock_code LEFT JOIN dart_financials f ON f.stock_code=s.stock_code AND f.reprt_code='11011' AND f.bsns_year=(SELECT MAX(bsns_year) FROM dart_financials f2 WHERE f2.stock_code=s.stock_code AND f2.reprt_code='11011') WHERE s.stock_code=$1 AND s.score_date=(SELECT MAX(score_date) FROM stock_scores WHERE stock_code=$1) """, code) return dict(row) if row else None async def db_market_regime() -> Optional[dict]: async with pg_pool.acquire() as conn: row = await conn.fetchrow( "SELECT regime, regime_adj, dt FROM market_regime ORDER BY dt DESC LIMIT 1") macro = await conn.fetch(""" SELECT DISTINCT ON (indicator) indicator, trade_date, value FROM macro_daily ORDER BY indicator, trade_date DESC """) return { "regime": dict(row) if row else None, "macro": {r["indicator"]: float(r["value"]) for r in macro} } async def find_stock_code_by_name(name: str) -> Optional[str]: """이름으로 종목코드 검색""" async with pg_pool.acquire() as conn: row = await conn.fetchrow(""" SELECT stock_code FROM dart_corps WHERE is_active=true AND corp_name ILIKE $1 ORDER BY CASE WHEN corp_name=$2 THEN 0 ELSE 1 END LIMIT 1 """, f"%{name}%", name) return row["stock_code"] if row else None # ───────────────────────────────────────────────────────────── # EXAONE 호출 # ───────────────────────────────────────────────────────────── SYSTEM_PROMPT = """당신은 워렌 버핏 스타일의 한국 주식 가치투자 분석가입니다. 사용자 질문에 대해 제공된 시스템 데이터를 우선 참고하여 답변하세요. 원칙: - 데이터에 근거한 객관적 답변 - 짧고 명확하게 (3~5문장) - ROE/부채비율/영업이익률 중심의 가치투자 관점 - 투자는 본인 책임이라는 점 마지막에 짧게 언급 - 데이터에 없는 내용은 추측하지 말고 "모르겠습니다"라고 말할 것 """ async def ask_exaone(user_msg: str, context: str = "") -> str: prompt = f"{SYSTEM_PROMPT}\n\n=== 시스템 데이터 ===\n{context}\n\n=== 사용자 질문 ===\n{user_msg}" try: async with httpx.AsyncClient(timeout=120) as cl: r = await cl.post(f"{OLLAMA_URL}/api/generate", json={ "model": EXAONE_MODEL, "prompt": prompt, "stream": False, "options": {"temperature": 0.3, "num_predict": 400} }) if r.status_code != 200: return f"⚠️ LLM 호출 실패 (status {r.status_code})" j = r.json() return j.get("response", "").strip() or "(빈 응답)" except Exception as e: return f"⚠️ LLM 오류: {e}" # ───────────────────────────────────────────────────────────── # 포매팅 헬퍼 # ───────────────────────────────────────────────────────────── def fmt_pct(v) -> str: try: return f"{float(v):+.1f}" except: return "-" def fmt_buys(rows: list) -> str: if not rows: return "📭 오늘 매수 추천 없음" lines = ["🟢 오늘 매수 추천"] for i, r in enumerate(rows, 1): emoji = "🔥" if r["recommendation"] == "강력매수" else "✨" line = f"{emoji} {i}. {r['stock_name']}({r['stock_code']}) {r['total_score']:.1f}점" lines.append(line) sigs = [] if r["us_overnight_adj"] and r["us_overnight_adj"] != 0: sigs.append(f"US{fmt_pct(r['us_overnight_adj'])}") if r["insider_signal"] and r["insider_signal"] != 0: sigs.append(f"내부자{fmt_pct(r['insider_signal'])}") if r["consensus_signal"] and r["consensus_signal"] != 0: sigs.append(f"컨센{fmt_pct(r['consensus_signal'])}") if r["valuation_pct"] and r["valuation_pct"] != 0: sigs.append(f"밸류{fmt_pct(r['valuation_pct'])}") if r["inst_flow_signal"] and r["inst_flow_signal"] != 0: sigs.append(f"기관{fmt_pct(r['inst_flow_signal'])}") if sigs: lines.append(f" ▸ {' / '.join(sigs)}") if r["top_reasons"]: lines.append(f" ▸ {r['top_reasons'][:80]}") return "\n".join(lines) def fmt_sells(rows: list) -> str: if not rows: return "📭 오늘 매도 추천 없음" lines = ["🔴 오늘 매도 신호"] for i, r in enumerate(rows, 1): lines.append(f"{i}. {r['stock_name']}({r['stock_code']}) {r['total_score']:.1f}점 · {r['recommendation']}") if r["top_reasons"]: lines.append(f" ▸ {r['top_reasons'][:80]}") return "\n".join(lines) def fmt_stock(d: dict) -> str: if not d: return "❓ 종목을 찾을 수 없습니다" lines = [ f"📊 {d['corp_name']}({d['stock_code']})", f"종합점수 {d['total_score']:.1f} · {d['recommendation']}", f"섹터: {d.get('sector_name') or '미분류'}", "", "📈 시그널", ] sigs = [ ("뉴스", d.get("news_score")), ("공시", d.get("dart_score")), ("기술", d.get("technical_score")), ("외국인", d.get("foreign_score")), ("공매도", d.get("short_score")), ("미국동조", d.get("us_overnight_adj")), ("내부자", d.get("insider_signal")), ("컨센", d.get("consensus_signal")), ("매크로", d.get("macro_signal")), ("기관 5d", d.get("inst_flow_signal")), ("밸류", d.get("valuation_pct")), ] for k, v in sigs: if v and float(v) != 0: lines.append(f" {k}: {fmt_pct(v)}") lines.append("") lines.append("💰 재무") if d.get("roe") is not None: lines.append(f" ROE {d['roe']:.1f}% · 부채 {d.get('debt_ratio',0):.0f}% · 영업이익률 {d.get('operating_margin',0):.1f}%") if d.get("intrinsic_value"): lines.append(f" 내재가치 {int(d['intrinsic_value']):,}원 · 안전마진 {d.get('margin_of_safety',0):.0f}%") if d.get("us_pair_top"): lines.append("") lines.append(f"🇺🇸 {d['us_pair_top']}") if d.get("top_reasons"): lines.append("") lines.append(f"{d['top_reasons'][:200]}") return "\n".join(lines) # ───────────────────────────────────────────────────────────── # 핸들러 # ───────────────────────────────────────────────────────────── async def cmd_start(update: Update, ctx: ContextTypes.DEFAULT_TYPE): if not is_allowed(update): return msg = ( "👋 Trading AI 봇\n\n" "📊 추천 조회\n" "• /buy 또는 '추천' — 오늘 매수 추천(가치투자)\n" "• /hot 또는 '뜨는' — 지금 뜨는 종목(모멘텀)\n" "• /sell 또는 '매도종목' — 오늘 매도 신호\n" "• /stock 005930 — 종목 상세\n" "• /deep 005930 — AI 심층분석\n" "• /market 또는 '시장' — 시장 상황\n\n" "🤖 자동매매\n" "• /status 또는 '상태' — 시스템 상태\n" "• /portfolio 또는 '보유' — 보유 종목 손익\n" "• /orders 또는 '주문' — 매매 주문 목록\n" "• /settings 또는 '설정' — 안전 설정\n" "• /autotrade on|off 또는 '자동매매꺼' — 자동매매 토글\n\n" "💱 주문 처리 (텔레그램 알림 도착 시)\n" "• 버튼: [✅ 매수/매도 실행] [❌ 취소]\n" "• 텍스트: '매수 2', '매도 2', '취소 2', '승인 2'\n" "• 명령: /confirm 2, /cancel 2\n\n" "💬 자유 질문\n" "\"에스엠 사도 돼?\"\n" "\"삼성전자 어때?\"" ) await update.message.reply_text(msg, parse_mode=ParseMode.HTML) async def cmd_help(update: Update, ctx: ContextTypes.DEFAULT_TYPE): await cmd_start(update, ctx) async def cmd_buys(update: Update, ctx: ContextTypes.DEFAULT_TYPE): if not is_allowed(update): return rows = await db_top_buys(10) await update.message.reply_text(fmt_buys(rows), parse_mode=ParseMode.HTML) async def cmd_sells(update: Update, ctx: ContextTypes.DEFAULT_TYPE): if not is_allowed(update): return rows = await db_top_sells(5) await update.message.reply_text(fmt_sells(rows), parse_mode=ParseMode.HTML) def fmt_hot(rows: list) -> str: if not rows: return "📭 지금 뜨는 종목 없음" lines = ["🔥 지금 뜨는 종목 (뉴스·가격·거래량 모멘텀)"] for i, r in enumerate(rows, 1): lines.append(f"🔥 {i}. {r['stock_name']}({r['stock_code']}) {r['hot_score']:.0f}점") parts = [] if r.get("pos_news_3d"): parts.append(f"호재 {r['pos_news_3d']}건") if r.get("price_5d_pct"): parts.append(f"5일 {r['price_5d_pct']:+.1f}%") if r.get("vol_ratio") and r["vol_ratio"] >= 1.5: parts.append(f"거래량 {r['vol_ratio']:.1f}배") if parts: lines.append(f" ▸ {' · '.join(parts)}") vs, vr = r.get("value_score"), r.get("value_reco") if vr: lines.append(f" ▸ 가치판단: {vr} ({vs}점)") lines.append("\n※ 모멘텀 기반이라 가치투자 추천(/buy)과 별개입니다. 변동성·추격매수 주의") return "\n".join(lines) async def cmd_hot(update: Update, ctx: ContextTypes.DEFAULT_TYPE): if not is_allowed(update): return try: async with httpx.AsyncClient(timeout=30) as c: r = await c.get(f"{SCORE_API}/hot?limit=12") rows = r.json() except Exception as e: await update.message.reply_text(f"오류: {e}") return await update.message.reply_text(fmt_hot(rows), parse_mode=ParseMode.HTML) async def cmd_stock(update: Update, ctx: ContextTypes.DEFAULT_TYPE): if not is_allowed(update): return args = ctx.args if not args: await update.message.reply_text("종목코드 또는 이름을 입력하세요. 예: /종목 005930 또는 /종목 삼성전자") return q = " ".join(args).strip() code = q if re.match(r"^\d{6}$", q) else await find_stock_code_by_name(q) if not code: await update.message.reply_text(f"❓ '{q}' 종목을 찾을 수 없습니다") return d = await db_stock_detail(code) await update.message.reply_text(fmt_stock(d), parse_mode=ParseMode.HTML) def fmt_deep(r: dict) -> str: if r.get("error"): return f"⚠️ {r.get('code')}: {r['error']}" icon = {"강력매수": "🟢🟢", "매수": "🟢", "중립": "⚪", "매도": "🔴", "강력매도": "🔴🔴"}.get(r.get("recommendation"), "⚪") lines = [ f"{icon} {r.get('name')}({r.get('code')}) — AI심층분석", f"판단: {r.get('recommendation')} (확신도 {r.get('conviction')}/5) " f"· 퀀트 {r.get('quant_score',0):.0f}점", f"밸류: {r.get('valuation_view','-')} · 기간: {r.get('time_horizon','-')}", "", f"📝 투자논거\n{r.get('thesis','')}", ] if r.get("key_points"): lines.append("\n✅ 핵심근거") lines += [f" • {p}" for p in r["key_points"][:5]] if r.get("risks"): lines.append("\n⚠️ 리스크") lines += [f" • {p}" for p in r["risks"][:4]] if r.get("catalyst_watch"): lines.append("\n👀 관전포인트") lines += [f" • {p}" for p in r["catalyst_watch"][:3]] tp, sl = r.get("target_price", 0) or 0, r.get("stop_loss", 0) or 0 if tp or sl: lines.append(f"\n🎯 목표가 {tp:,}원 · 손절가 {sl:,}원") lines.append("\n※ 투자 판단·책임은 본인에게 있습니다") return "\n".join(lines) async def cmd_deep(update: Update, ctx: ContextTypes.DEFAULT_TYPE): if not is_allowed(update): return args = ctx.args if not args: await update.message.reply_text("종목코드/이름 입력. 예: /deep 005930 또는 /deep 삼성전자") return q = " ".join(args).strip() code = q if re.match(r"^\d{6}$", q) else await find_stock_code_by_name(q) if not code: await update.message.reply_text(f"❓ '{q}' 종목을 찾을 수 없습니다") return await update.message.reply_text("🧠 RAG+EXAONE 심층분석 중… (최대 1~2분)") try: async with httpx.AsyncClient(timeout=200) as cl: resp = await cl.get(f"{SCORE_API}/deep-analysis/{code}", params={"refresh": "true", "notify": "false"}) r = resp.json() except Exception as e: await update.message.reply_text(f"⚠️ 분석 실패: {e}") return await update.message.reply_text(fmt_deep(r), parse_mode=ParseMode.HTML) SCORE_ENGINE_URL = os.getenv("SCORE_ENGINE_URL", "http://score-engine:8686") async def cmd_confirm(update: Update, ctx: ContextTypes.DEFAULT_TYPE): """/confirm — 자동매매 매수 신호 승인""" if not is_allowed(update): return if not ctx.args: await update.message.reply_text("사용법: /confirm <주문번호>") return try: oid = int(ctx.args[0]) except ValueError: await update.message.reply_text("주문번호는 숫자") return import httpx try: async with httpx.AsyncClient(timeout=30) as c: r = await c.post(f"{SCORE_ENGINE_URL}/trade/confirm/{oid}") res = r.json() if "error" in res: await update.message.reply_text(f"❌ {res['error']}") else: await update.message.reply_text(f"✅ 주문 #{oid} 체결 완료") except Exception as e: await update.message.reply_text(f"오류: {e}") async def cmd_cancel(update: Update, ctx: ContextTypes.DEFAULT_TYPE): """/cancel — 매수 신호 거부""" if not is_allowed(update): return if not ctx.args: await update.message.reply_text("사용법: /cancel <주문번호>") return try: oid = int(ctx.args[0]) except ValueError: await update.message.reply_text("주문번호는 숫자") return import httpx try: async with httpx.AsyncClient(timeout=30) as c: r = await c.post(f"{SCORE_ENGINE_URL}/trade/cancel/{oid}") await update.message.reply_text(f"🚫 주문 #{oid} 취소") except Exception as e: await update.message.reply_text(f"오류: {e}") async def on_callback(update: Update, ctx: ContextTypes.DEFAULT_TYPE): """inline 버튼 콜백 처리: confirm: / cancel:""" q = update.callback_query if not q or not q.data: return await q.answer() try: action, oid_str = q.data.split(":", 1) oid = int(oid_str) except Exception: await q.edit_message_text(f"{q.message.text}\n\n❌ 잘못된 콜백") return import httpx try: async with httpx.AsyncClient(timeout=30) as c: r = await c.post(f"{SCORE_ENGINE_URL}/trade/{action}/{oid}") res = r.json() if "error" in res: new_text = f"{q.message.text_html}\n\n❌ {res['error']}" elif action == "confirm": new_text = f"{q.message.text_html}\n\n✅ 승인됨 (#{oid} 체결)" else: new_text = f"{q.message.text_html}\n\n🚫 거부됨 (#{oid} 취소)" # 버튼 제거 + 결과 표시 await q.edit_message_text(new_text, parse_mode=ParseMode.HTML) except Exception as e: await q.edit_message_text(f"{q.message.text}\n\n⚠️ 오류: {e}") async def cmd_portfolio(update: Update, ctx: ContextTypes.DEFAULT_TYPE): """/portfolio — 보유 종목 + 손익""" if not is_allowed(update): return import httpx try: async with httpx.AsyncClient(timeout=30) as c: r = await c.get(f"{SCORE_ENGINE_URL}/portfolio") rows = r.json() if not rows: await update.message.reply_text("보유 종목 없음") return lines = ["👜 보유 종목\n"] total_buy = 0 total_cur = 0 for p in rows: cur = int(p.get("current_price") or 0) buy = p["buy_price"] qty = p["qty"] pnl_pct = p.get("pnl_pct") or 0 pnl_won = p.get("pnl_won") or 0 total_buy += buy * qty total_cur += cur * qty sign = "🟢" if pnl_pct > 0 else "🔴" if pnl_pct < -3 else "🟡" lines.append( f"{sign} {p.get('stock_name') or p['stock_code']}({p['stock_code']})\n" f" {qty}주 @{buy:,}원 → {cur:,}원 {pnl_pct:+.2f}% ({pnl_won:+,}원)" ) if total_buy: tot_pct = (total_cur - total_buy) / total_buy * 100 lines.append(f"\n합계 {tot_pct:+.2f}% ({total_cur - total_buy:+,}원)") await update.message.reply_text("\n".join(lines), parse_mode=ParseMode.HTML) except Exception as e: await update.message.reply_text(f"오류: {e}") async def cmd_autotrade(update: Update, ctx: ContextTypes.DEFAULT_TYPE): """/autotrade on|off — 자동매매 토글""" if not is_allowed(update): return if not ctx.args or ctx.args[0].lower() not in ("on","off"): await update.message.reply_text("사용법: /autotrade on 또는 /autotrade off") return enabled = ctx.args[0].lower() == "on" import httpx try: async with httpx.AsyncClient(timeout=10) as c: await c.post(f"{SCORE_ENGINE_URL}/trade/autotrade?enabled={'true' if enabled else 'false'}") await update.message.reply_text( f"⚙️ 자동매매 {'ON ✅' if enabled else 'OFF 🛑'}", parse_mode=ParseMode.HTML) except Exception as e: await update.message.reply_text(f"오류: {e}") async def cmd_settings(update: Update, ctx: ContextTypes.DEFAULT_TYPE): """/settings — 자동매매 안전설정 조회""" if not is_allowed(update): return import httpx try: async with httpx.AsyncClient(timeout=10) as c: r = await c.get(f"{SCORE_ENGINE_URL}/trade/settings") s = r.json().get("current", {}) lines = ["⚙️ 자동매매 설정\n"] for k, v in s.items(): lines.append(f" • {k}: {v}") await update.message.reply_text("\n".join(lines), parse_mode=ParseMode.HTML) except Exception as e: await update.message.reply_text(f"오류: {e}") async def cmd_status(update: Update, ctx: ContextTypes.DEFAULT_TYPE): """/status — 시스템 상태 한눈에""" if not is_allowed(update): return import httpx try: async with httpx.AsyncClient(timeout=10) as c: r = await c.get(f"{SCORE_ENGINE_URL}/system/status") s = r.json() t = s.get("trading", {}) a = s.get("analysis", {}) sc = s.get("scoring", {}) p = s.get("portfolio", {}) o = t.get("orders_today", {}) auto_icon = "✅" if t.get("enabled") else "🛑" halt_icon = "🛑 HALTED" if t.get("halted") else "" lines = [ f"📊 시스템 상태", f"\n자동매매 {auto_icon} {halt_icon}", f" 오늘 주문: pending {o.get('pending',0)} / filled {o.get('filled',0)} / cancelled {o.get('cancelled',0)} / expired {o.get('expired',0)}", f" 실현손익: {t.get('realized_pnl_today', 0):+,}원 / 평가손익: {t.get('unrealized_pnl_today', 0):+,}원", f"\n점수 ({sc.get('latest_score_date')})", f" 분석 종목: {sc.get('scored_today',0)}건 / 강력매수: {sc.get('strong_buy_count',0)}건", f"\nLLM 분석", f" 오늘: {a.get('today_analyses',0)}건 ({a.get('today_llm_cost_krw',0)}원)", f" 이번 달: {a.get('month_llm_cost_krw',0)}원", f"\n보유", f" 활성 포지션: {p.get('active_positions',0)}건", ] await update.message.reply_text("\n".join(lines), parse_mode=ParseMode.HTML) except Exception as e: await update.message.reply_text(f"오류: {e}") async def cmd_orders(update: Update, ctx: ContextTypes.DEFAULT_TYPE): """/orders — 최근 매매 주문 목록""" if not is_allowed(update): return import httpx try: async with httpx.AsyncClient(timeout=30) as c: r = await c.get(f"{SCORE_ENGINE_URL}/trade/orders?days=7") rows = r.json() if not rows: await update.message.reply_text("최근 주문 없음") return lines = ["📋 최근 매매 주문 (7일)\n"] for r in rows[:15]: icon = {"pending":"⏳","confirmed":"✅","filled":"✅", "cancelled":"🚫","expired":"⌛"}.get(r["status"], "·") lines.append( f"{icon} #{r['id']} {r['stock_name']}({r['stock_code']}) " f"{r['side']} {r['qty']}주 @{r['price']:,}원 [{r['status']}]" ) await update.message.reply_text("\n".join(lines), parse_mode=ParseMode.HTML) except Exception as e: await update.message.reply_text(f"오류: {e}") async def cmd_market(update: Update, ctx: ContextTypes.DEFAULT_TYPE): if not is_allowed(update): return m = await db_market_regime() regime = m.get("regime") or {} macro = m.get("macro") or {} lines = ["🌍 시장 상황"] if regime: lines.append(f" 레짐: {regime.get('regime','?')} (보정 {regime.get('regime_adj',0):+.0f})") lines.append(f" 날짜: {regime.get('dt')}") lines.append("") lines.append("매크로") if "usdkrw" in macro: lines.append(f" USD/KRW {macro['usdkrw']:.1f}원") if "kor_10y" in macro: lines.append(f" 국고채 10년 {macro['kor_10y']:.2f}%") if "kor_3y" in macro: lines.append(f" 국고채 3년 {macro['kor_3y']:.2f}%") if "kospi" in macro: lines.append(f" KOSPI {macro['kospi']:,.1f}") await update.message.reply_text("\n".join(lines), parse_mode=ParseMode.HTML) # 자유 텍스트 async def on_text(update: Update, ctx: ContextTypes.DEFAULT_TYPE): if not is_allowed(update): return text = (update.message.text or "").strip() if not text: return # 한글 단축 키워드 → 명령 라우팅 (빠른 응답) if text in ("추천", "오늘추천", "buy"): rows = await db_top_buys(10) await update.message.reply_text(fmt_buys(rows), parse_mode=ParseMode.HTML) return if text in ("팔것", "매도종목", "sell"): rows = await db_top_sells(5) await update.message.reply_text(fmt_sells(rows), parse_mode=ParseMode.HTML) return if text in ("시장", "장", "market"): await cmd_market(update, ctx) return if text in ("뜨는", "핫", "급등", "뜨는종목", "핫종목", "hot"): await cmd_hot(update, ctx) return # 자동매매 한글 명령 라우팅 if text in ("상태", "status"): await cmd_status(update, ctx); return if text in ("보유", "포트폴리오", "내주식", "손익"): await cmd_portfolio(update, ctx); return if text in ("주문", "주문목록", "오더"): await cmd_orders(update, ctx); return if text in ("설정", "셋팅"): await cmd_settings(update, ctx); return if text in ("자동매매켜", "자동매매on", "자동매매시작"): ctx.args = ["on"] await cmd_autotrade(update, ctx); return if text in ("자동매매꺼", "자동매매off", "자동매매중단", "자동매매정지"): ctx.args = ["off"] await cmd_autotrade(update, ctx); return # 주문 confirm/cancel 자연어 — "매수 2", "매도 2", "승인 2", "취소 2" import re as _re, httpx as _httpx m = _re.match(r"^(매수|매도|승인|확인|실행)\s+(\d+)$", text) if m: oid = int(m.group(2)) try: async with _httpx.AsyncClient(timeout=30) as c: r = await c.post(f"{SCORE_ENGINE_URL}/trade/confirm/{oid}") res = r.json() if "error" in res: await update.message.reply_text(f"❌ {res['error']}") else: await update.message.reply_text(f"✅ 주문 #{oid} 체결 완료") except Exception as e: await update.message.reply_text(f"오류: {e}") return m = _re.match(r"^(취소|거부|중단)\s+(\d+)$", text) if m: oid = int(m.group(2)) try: async with _httpx.AsyncClient(timeout=30) as c: await c.post(f"{SCORE_ENGINE_URL}/trade/cancel/{oid}") await update.message.reply_text(f"🚫 주문 #{oid} 취소") except Exception as e: await update.message.reply_text(f"오류: {e}") return await update.message.chat.send_action("typing") # 컨텍스트 구성: 종목명 언급되면 그 종목 데이터, 아니면 시장 전반 context_parts = [] # 종목코드 / 종목명 감지 code = None code_match = re.search(r"\b(\d{6})\b", text) if code_match: code = code_match.group(1) if not code: # 종목명 추정 (2~10자 한글) for cand in re.findall(r"[가-힣A-Z]{2,10}", text): if cand in ("매수", "매도", "추천", "오늘", "지금", "사도", "팔아", "어때"): continue c = await find_stock_code_by_name(cand) if c: code = c break if code: d = await db_stock_detail(code) if d: context_parts.append(f"[종목 {d['corp_name']}({code}) 데이터]") context_parts.append(f"종합점수 {d.get('total_score',0):.1f}점, 등급 {d.get('recommendation','-')}, 섹터 {d.get('sector_name') or '미분류'}") context_parts.append( f"세부 점수 — 뉴스 {d.get('news_score',0):.0f}, 기술 {d.get('technical_score',0):.0f}, " f"공시 {d.get('dart_score',0):.0f}, 외국인 {d.get('foreign_score',0):.0f}, " f"미국동조 {d.get('us_overnight_adj',0):.1f}, 내부자 {d.get('insider_signal',0):.1f}, " f"컨센서스 {d.get('consensus_signal',0):.1f}, 기관5d {d.get('inst_flow_signal',0):.1f}, " f"밸류 percentile {d.get('valuation_pct',0):.1f}") if d.get("roe") is not None: context_parts.append( f"재무 — ROE {d['roe']:.1f}%, 영업이익률 {d.get('operating_margin',0):.1f}%, " f"부채비율 {d.get('debt_ratio',0):.0f}%, 매출성장 {d.get('revenue_growth',0):.1f}%") if d.get("intrinsic_value"): context_parts.append( f"DCF 내재가치 {int(d['intrinsic_value']):,}원, 안전마진 {d.get('margin_of_safety',0):.0f}%") if d.get("us_pair_top"): context_parts.append(f"미국 페어: {d['us_pair_top']}") if d.get("top_reasons"): context_parts.append(f"근거: {d['top_reasons']}") else: # 시장 전반 buys = await db_top_buys(5) sells = await db_top_sells(3) m = await db_market_regime() regime = (m.get("regime") or {}) macro = m.get("macro") or {} context_parts.append(f"[시장 상황] 레짐: {regime.get('regime','?')}") if macro: context_parts.append( f"환율 {macro.get('usdkrw',0):.0f}원, 10년물 {macro.get('kor_10y',0):.2f}%, " f"KOSPI {macro.get('kospi',0):,.0f}") if buys: context_parts.append("[오늘 매수 추천 top]") for r in buys[:5]: context_parts.append(f"- {r['stock_name']}({r['stock_code']}) {r['total_score']:.0f}점 {r['recommendation']}") if sells: context_parts.append("[오늘 매도 신호]") for r in sells[:3]: context_parts.append(f"- {r['stock_name']}({r['stock_code']}) {r['total_score']:.0f}점 {r['recommendation']}") context_str = "\n".join(context_parts) if context_parts else "(관련 데이터 없음)" answer = await ask_exaone(text, context_str) # 답변 길이 제한 if len(answer) > 3500: answer = answer[:3500] + "..." await update.message.reply_text(answer) # ───────────────────────────────────────────────────────────── # 부트 # ───────────────────────────────────────────────────────────── async def post_init(app: Application): global pg_pool pg_pool = await asyncpg.create_pool(**PG, min_size=2, max_size=5) # 커맨드 메뉴 등록 await app.bot.set_my_commands([ BotCommand("buy", "오늘 매수 추천"), BotCommand("sell", "오늘 매도 신호"), BotCommand("stock", "종목 상세 (예: /stock 005930)"), BotCommand("market", "시장 상황"), BotCommand("help", "도움말"), ]) logger.info("telegram-bot.ready") def main(): if not TG_TOKEN: logger.error("missing TELEGRAM_BOT_TOKEN") return app = Application.builder().token(TG_TOKEN).post_init(post_init).build() # 한국어/영어 명령어 둘 다 app.add_handler(CommandHandler("start", cmd_start)) app.add_handler(CommandHandler("help", cmd_help)) app.add_handler(CommandHandler(["buy", "buys"], cmd_buys)) app.add_handler(CommandHandler(["sell", "sells"], cmd_sells)) app.add_handler(CommandHandler(["stock"], cmd_stock)) app.add_handler(CommandHandler(["hot"], cmd_hot)) app.add_handler(CommandHandler(["deep"], cmd_deep)) app.add_handler(CommandHandler(["market"], cmd_market)) app.add_handler(CommandHandler(["confirm"], cmd_confirm)) app.add_handler(CommandHandler(["cancel"], cmd_cancel)) app.add_handler(CommandHandler(["orders"], cmd_orders)) app.add_handler(CommandHandler(["portfolio", "pnl"], cmd_portfolio)) app.add_handler(CommandHandler(["autotrade"], cmd_autotrade)) app.add_handler(CommandHandler(["settings"], cmd_settings)) app.add_handler(CommandHandler(["status"], cmd_status)) app.add_handler(CallbackQueryHandler(on_callback)) app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, on_text)) logger.info("telegram-bot.start", model=EXAONE_MODEL) app.run_polling(close_loop=False) if __name__ == "__main__": main()