Files
trading/telegram-bot/main.py
T
kyu 6d3b0bacc0 Initial commit: Korean stock value-investing AI pipeline
- 19개 마이크로서비스 (news-collector, score-engine, ta-engine, dart-collector,
  aux-signal, us-market, graph-engine, telegram-bot, dashboard-api, kis-api 등)
- 가치투자 스코어링 + 10공식 앙상블 보팅 (매직포뮬러/F-Score/Altman/PEG/
  모멘텀/Beneish/GP-A/G-Score/Amihud/BAB)
- 뉴스 수집→형태소→임베딩→중복제거→AI분석 파이프라인
- 기술적분석 + GAT 그래프신경망 + 미증시 동조 시그널

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 21:33:56 +09:00

500 lines
22 KiB
Python

"""
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,
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 = ["🟢 <b>오늘 매수 추천</b>"]
for i, r in enumerate(rows, 1):
emoji = "🔥" if r["recommendation"] == "강력매수" else ""
line = f"{emoji} <b>{i}. {r['stock_name']}({r['stock_code']})</b> {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 = ["🔴 <b>오늘 매도 신호</b>"]
for i, r in enumerate(rows, 1):
lines.append(f"<b>{i}. {r['stock_name']}({r['stock_code']})</b> {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"📊 <b>{d['corp_name']}({d['stock_code']})</b>",
f"종합점수 <b>{d['total_score']:.1f}</b> · {d['recommendation']}",
f"섹터: {d.get('sector_name') or '미분류'}",
"",
"<b>📈 시그널</b>",
]
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("<b>💰 재무</b>")
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"<i>{d['top_reasons'][:200]}</i>")
return "\n".join(lines)
# ─────────────────────────────────────────────────────────────
# 핸들러
# ─────────────────────────────────────────────────────────────
async def cmd_start(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
if not is_allowed(update): return
msg = (
"👋 <b>Trading AI 봇</b>\n\n"
"명령어:\n"
"/buy — 오늘 매수 추천\n"
"/sell — 오늘 매도 신호\n"
"/stock 005930 — 특정 종목 상세\n"
"/deep 005930 — AI 심층분석(RAG+EXAONE)\n"
"/market — 시장 상황\n"
"/help — 도움말\n\n"
"자유 텍스트로 질문해도 됩니다. 예:\n"
"<i>\"에스엠 사도 돼?\"</i>\n"
"<i>\"오늘 추천 알려줘\"</i>\n"
"<i>\"삼성전자 어때?\"</i>"
)
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)
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} <b>{r.get('name')}({r.get('code')})</b> — AI심층분석",
f"판단: <b>{r.get('recommendation')}</b> (확신도 {r.get('conviction')}/5) "
f"· 퀀트 {r.get('quant_score',0):.0f}",
f"밸류: {r.get('valuation_view','-')} · 기간: {r.get('time_horizon','-')}",
"",
f"<b>📝 투자논거</b>\n{r.get('thesis','')}",
]
if r.get("key_points"):
lines.append("\n<b>✅ 핵심근거</b>")
lines += [f"{p}" for p in r["key_points"][:5]]
if r.get("risks"):
lines.append("\n<b>⚠️ 리스크</b>")
lines += [f"{p}" for p in r["risks"][:4]]
if r.get("catalyst_watch"):
lines.append("\n<b>👀 관전포인트</b>")
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<i>※ 투자 판단·책임은 본인에게 있습니다</i>")
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)
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 = ["🌍 <b>시장 상황</b>"]
if regime:
lines.append(f" 레짐: <b>{regime.get('regime','?')}</b> (보정 {regime.get('regime_adj',0):+.0f})")
lines.append(f" 날짜: {regime.get('dt')}")
lines.append("")
lines.append("<b>매크로</b>")
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
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(["deep"], cmd_deep))
app.add_handler(CommandHandler(["market"], cmd_market))
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()