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>
This commit is contained in:
kyu
2026-06-02 01:22:23 +09:00
parent a81feb48d8
commit 8b200e6cc6
5 changed files with 3989 additions and 367 deletions
+312 -12
View File
@@ -23,7 +23,7 @@ import httpx
import structlog
from telegram import Update, BotCommand
from telegram.constants import ParseMode
from telegram.ext import (Application, CommandHandler, MessageHandler,
from telegram.ext import (Application, CommandHandler, MessageHandler, CallbackQueryHandler,
filters, ContextTypes)
logging.basicConfig(level=logging.INFO,
@@ -254,16 +254,25 @@ 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"
"<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>\n"
"<i>\"삼성전자 어때?\"</i>"
)
await update.message.reply_text(msg, parse_mode=ParseMode.HTML)
@@ -285,6 +294,40 @@ async def cmd_sells(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
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
@@ -352,6 +395,208 @@ async def cmd_deep(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
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()
@@ -378,17 +623,63 @@ async def on_text(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
return
# 한글 단축 키워드 → 명령 라우팅 (빠른 응답)
if text in ("추천", "매수", "오늘추천", "buy"):
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"):
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")
@@ -488,8 +779,17 @@ def main():
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)