Files
kyu 8b200e6cc6 feat: 핫종목 렌즈 + 뉴스 파서 견고화 + 텔레그램 알림 2회 축소
- news-collector: EXAONE JSON 파서 견고화(max_tokens 800 + 관대 파싱 → 파싱실패율 9.3%→3.3%),
  평일 18:30 종목뉴스 스위프(중형주 신선도 사각지대 해소), /process/raw reprocess 플래그,
  파싱실패→Gemini 폴백 라우팅(일일캡 1→50 상수화)
- score-engine: /hot 모멘텀 렌즈(뉴스+거래량, 백테스트로 가격 제외) + /hot/backtest,
  텔레그램 정기 브리핑 2회로 축소(중복 send_briefing 자동스케줄 제거)
- dashboard-api: /api/hot 프록시 + 🔥 지금뜨는 탭
- telegram-bot: /hot 명령 + 한글(뜨는/핫/급등) 라우팅

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 01:22:23 +09:00

800 lines
35 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, 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 = ["🟢 <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"
"<b>📊 추천 조회</b>\n"
"• /buy 또는 '추천' — 오늘 매수 추천(가치투자)\n"
"• /hot 또는 '뜨는' — 지금 뜨는 종목(모멘텀)\n"
"• /sell 또는 '매도종목' — 오늘 매도 신호\n"
"• /stock 005930 — 종목 상세\n"
"• /deep 005930 — AI 심층분석\n"
"• /market 또는 '시장' — 시장 상황\n\n"
"<b>🤖 자동매매</b>\n"
"• /status 또는 '상태' — 시스템 상태\n"
"• /portfolio 또는 '보유' — 보유 종목 손익\n"
"• /orders 또는 '주문' — 매매 주문 목록\n"
"• /settings 또는 '설정' — 안전 설정\n"
"• /autotrade on|off 또는 '자동매매꺼' — 자동매매 토글\n\n"
"<b>💱 주문 처리</b> (텔레그램 알림 도착 시)\n"
"• 버튼: [✅ 매수/매도 실행] [❌ 취소]\n"
"• 텍스트: '매수 2', '매도 2', '취소 2', '승인 2'\n"
"• 명령: /confirm 2, /cancel 2\n\n"
"<b>💬 자유 질문</b>\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)
def fmt_hot(rows: list) -> str:
if not rows:
return "📭 지금 뜨는 종목 없음"
lines = ["🔥 <b>지금 뜨는 종목</b> <i>(뉴스·가격·거래량 모멘텀)</i>"]
for i, r in enumerate(rows, 1):
lines.append(f"🔥 <b>{i}. {r['stock_name']}({r['stock_code']})</b> {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<i>※ 모멘텀 기반이라 가치투자 추천(/buy)과 별개입니다. 변동성·추격매수 주의</i>")
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} <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)
SCORE_ENGINE_URL = os.getenv("SCORE_ENGINE_URL", "http://score-engine:8686")
async def cmd_confirm(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
"""/confirm <order_id> — 자동매매 매수 신호 승인"""
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 <order_id> — 매수 신호 거부"""
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:<id> / cancel:<id>"""
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 = ["👜 <b>보유 종목</b>\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} <b>{p.get('stock_name') or p['stock_code']}</b>({p['stock_code']})\n"
f" {qty}주 @{buy:,}원 → {cur:,}원 <b>{pnl_pct:+.2f}%</b> ({pnl_won:+,}원)"
)
if total_buy:
tot_pct = (total_cur - total_buy) / total_buy * 100
lines.append(f"\n<b>합계 {tot_pct:+.2f}% ({total_cur - total_buy:+,}원)</b>")
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 = ["⚙️ <b>자동매매 설정</b>\n"]
for k, v in s.items():
lines.append(f"{k}: <code>{v}</code>")
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"📊 <b>시스템 상태</b>",
f"\n<b>자동매매</b> {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<b>점수 ({sc.get('latest_score_date')})</b>",
f" 분석 종목: {sc.get('scored_today',0)}건 / 강력매수: {sc.get('strong_buy_count',0)}",
f"\n<b>LLM 분석</b>",
f" 오늘: {a.get('today_analyses',0)}건 ({a.get('today_llm_cost_krw',0)}원)",
f" 이번 달: {a.get('month_llm_cost_krw',0)}",
f"\n<b>보유</b>",
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 = ["📋 <b>최근 매매 주문 (7일)</b>\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 = ["🌍 <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
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()