6d3b0bacc0
- 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>
500 lines
22 KiB
Python
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()
|