8b200e6cc6
- 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>
800 lines
35 KiB
Python
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()
|