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:
+312
-12
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user