Files
trumpsignal-backend/app/services/telegram.py
T
k 3754d2caf8 feat: Telegram daily digest + System-2 manage-only refactor
Two changes ship together — both reshape the Telegram-bot surface.

──── 1. Telegram daily digest (3-section brief)
Once-a-day push covering Macro Vibes / KOL talks-vs-trades / Trump 24h.
Body is rule-based templating (no LLM); each section reads structured DB
fields and picks a phrasing. Per-user opt-out + per-user UTC hour.

  - 023 migration: TelegramBinding gains digest_enabled, digest_hour_utc,
    last_digest_sent_at (idempotent against coalesced cron / restarts).
  - New services/telegram_digest.py: build_global_digest +
    format_digest + send_daily_digest + send_preview_for.
  - Hourly cron at :00 fans out to bindings whose digest_hour_utc matches.
  - New bot commands: /digest (preview), /digest on|off, /digest_time HH.
  - HELP_TEXT + /status updated to surface the new prefs.

──── 2. System-2 manage-only refactor
The Macro Vibes (BTC bottom + funding) signal no longer auto-opens
positions. Strategy is day-K — a 24h entry delay is irrelevant — but the
auto-open path carried real execution surface (leverage clipping, daily
budget split, concurrency caps, paper branches, key handling) for ~zero
alpha. The valuable part — multi-month exit management (5-rung stop
ladder + de-risk + pyramid + peak-trail) — is preserved and runs
against positions the user adopts.

Flow: scanner fires → Telegram alert with "/adopt" CTA → user opens
manually on Hyperliquid → /adopt picks the position via inline keyboard
→ picks Standard or Aggressive mode → bot creates BotTrade + registers
watchdog. Escape hatch: /release marks released_at, unregisters
watchdog, leaves the HL position open under user control.

  - 024 migration: BotTrade.released_at — "user took back control" marker.
  - bot_engine.process_post early-returns for sys2 (no auto-open path).
  - New services/adoption.py: list_hl_positions + adopt_position +
    release_management + AdoptionError. Per-wallet asyncio lock prevents
    dual-adopt race. Pre-flight checks: no_subscription / no_hl_key /
    paper_mode / circuit_breaker / already_adopted / concurrency_cap.
    Protective stop + de-risk + addon + peak-trail ladders all built
    against the ACTUAL HL leverage so the "inside liquidation" guarantee
    holds.
  - New API: GET /positions/hl/{wallet}, POST /positions/adopt,
    POST /positions/{id}/release (all signed).
  - telegram.py: send_message supports reply_markup; new edit_message +
    answer_callback for the inline-keyboard pickers; sys2 alert format
    now ends with the "/adopt" CTA.
  - telegram_bot.py: /adopt + /release commands with picker → confirm
    → execute flow via inline keyboards. New _handle_callback dispatches
    on "adopt:*" / "release:*" callback_data; run_bot_loop now consumes
    callback_query updates alongside messages.
  - recovery.py + reconciler.py skip released_at IS NOT NULL rows so a
    restart doesn't silently re-attach the watchdog to a released trade.
  - /positions/open, /positions/today, and telegram_digest's user-state
    line all filter released_at so they don't lie about what the bot is
    actually managing.

Tests: 26 new (8 digest snapshot + 14 adoption + 4 absorbed via existing
suites). All 64 pass.

Deploy: alembic upgrade head (runs 023 + 024) → restart backend. Existing
TelegramBindings get digest_enabled=true / digest_hour_utc=12 via server
defaults. In-flight auto-opened System-2 positions continue to be managed
(recovery rehydrates them) — no in-flight trade is abandoned.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 12:53:16 +08:00

361 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Telegram push alerts — send + signal dispatcher.
This module is fire-and-forget by design. `notify_signal()` returns immediately
to the caller (signal ingestion path) and dispatches in a background task. A
DB failure or Telegram API error MUST NOT block a signal from being saved.
The bot token is loaded from `settings.telegram_bot_token`. If empty, every
function in this module degrades into a no-op (and logs once at module load).
notify_signal(post) ← called from /api/signals/ingest
send_test_message(wallet) ← called from /api/telegram/test
send_message(chat_id, text) ← low-level escape hatch
Source → user-toggle mapping:
truth → alert_trump
btc_bottom_reversal → alert_btc_bottom
funding_reversal → alert_funding
kol_divergence (future) → alert_kol_divergence
"""
from __future__ import annotations
import asyncio
import logging
from datetime import datetime, timezone
from typing import Optional
import httpx
from sqlalchemy import select, update
from app.config import settings
from app.database import AsyncSessionLocal as async_session
from app.models import Post, TelegramBinding
logger = logging.getLogger(__name__)
TG_API = "https://api.telegram.org/bot{token}/{method}"
# Telegram caps a single message at 4096 chars; we render way below this.
MAX_LEN = 3500
# ── Source → preference column mapping ────────────────────────────────────
def _pref_column_for_source(source: str) -> Optional[str]:
"""Which user-toggle column gates this source. None → unknown source,
don't send."""
if source == "truth":
return "alert_trump"
if source == "btc_bottom_reversal":
return "alert_btc_bottom"
if source == "funding_reversal":
return "alert_funding"
if source == "kol_divergence":
return "alert_kol_divergence"
return None
def _is_in_mute_window(now_hour: int, mute_from: Optional[int],
mute_until: Optional[int]) -> bool:
"""Both null → never mute. start<until → mute inside [start, until).
start>until → window wraps midnight (e.g. 23..7 → mute 23, 06)."""
if mute_from is None or mute_until is None:
return False
if mute_from == mute_until:
return False
if mute_from < mute_until:
return mute_from <= now_hour < mute_until
# wraps midnight
return now_hour >= mute_from or now_hour < mute_until
# ── Formatting ────────────────────────────────────────────────────────────
def _signal_emoji(post: Post) -> str:
if post.signal == "buy":
return "🟢"
if post.signal == "short":
return "🔴"
return ""
def _source_label(source: str) -> str:
return {
"truth": "Trump · Truth Social",
"btc_bottom_reversal": "BTC · Macro Bottom",
"funding_reversal": "BTC · Funding Reversal",
"kol_divergence": "KOL · Talks vs Trades",
}.get(source, source)
def format_post(post: Post) -> str:
"""Render a Post into a single Telegram message (HTML parse mode).
Keep it scannable: heading, one-line verdict, the underlying text, link."""
emoji = _signal_emoji(post)
src = _source_label(post.source)
sig = (post.signal or "noise").upper()
asset = post.target_asset or "?"
conf = post.ai_confidence or 0
# Heading: emoji + asset + direction + confidence
head = f"{emoji} <b>{asset} · {sig}</b> · conf <b>{conf}</b>"
sub = f"<i>{src}</i>"
body = (post.text or "").strip()
if len(body) > 600:
body = body[:600].rstrip() + ""
# Move size hint if present (BTC bottom & funding emit expected_move_pct)
extra = ""
if post.expected_move_pct is not None:
extra = f"\n📐 expected move: <b>{post.expected_move_pct:+.1f}%</b>"
if post.invalidation_price is not None:
extra += f"\n🛑 invalidation @ <code>{post.invalidation_price:g}</code>"
# Deep-link back to the dashboard. Frontend URL comes from settings.
fe = (settings.frontend_url or "").rstrip("/")
link = ""
if fe:
# Use the section that matches the source
path = {
"truth": "/en/trump",
"btc_bottom_reversal": "/en/macro",
"funding_reversal": "/en/macro",
"kol_divergence": "/en/kol",
}.get(post.source, "/en")
link = f'\n\n<a href="{fe}{path}">→ open in dashboard</a>'
# System-2 (Macro Vibes bottom / funding reversal) is MANUAL-OPEN now:
# we no longer auto-trade these on behalf of subscribers. The signal is
# day-K level so an entry delay doesn't matter — but the alert needs
# to make the next user action obvious.
cta = ""
if post.source in ("btc_bottom_reversal", "funding_reversal"):
cta = (
"\n\n<b>Want bot to manage your trade?</b>\n"
"1. Open the position on Hyperliquid (your size / leverage).\n"
"2. Reply <code>/adopt</code> here to hand it to the bot.\n"
"Bot will run the 5-rung stop ladder, de-risk, pyramid, peak-trail. "
"You can take it back any time with <code>/release</code>."
)
msg = f"{head}\n{sub}\n\n{body}{extra}{cta}{link}"
return msg[:MAX_LEN]
# ── Low-level HTTP ────────────────────────────────────────────────────────
async def send_message(chat_id: int, text: str, *,
parse_mode: str = "HTML",
disable_preview: bool = True,
reply_markup: Optional[dict] = None) -> bool:
"""Single HTTP POST to Telegram Bot API. Returns True on 200, False on
any failure (caller decides whether to bump the failure counter).
`reply_markup` is the standard Bot-API inline keyboard / reply keyboard
payload, e.g.
{"inline_keyboard": [
[{"text": "Yes", "callback_data": "x:y"}],
[{"text": "No", "callback_data": "x:n"}],
]}
Callback_data is limited to 64 bytes by Telegram; keep payloads short.
"""
token = settings.telegram_bot_token
if not token:
return False
url = TG_API.format(token=token, method="sendMessage")
payload: dict = {
"chat_id": chat_id, "text": text,
"parse_mode": parse_mode,
"disable_web_page_preview": disable_preview,
}
if reply_markup is not None:
payload["reply_markup"] = reply_markup
try:
async with httpx.AsyncClient(timeout=10) as client:
r = await client.post(url, json=payload)
if r.status_code != 200:
logger.warning("Telegram sendMessage failed chat=%d status=%d body=%s",
chat_id, r.status_code, r.text[:200])
return False
return True
except Exception as exc:
logger.warning("Telegram sendMessage exception chat=%d: %s", chat_id, exc)
return False
async def edit_message(chat_id: int, message_id: int, text: str, *,
parse_mode: str = "HTML",
reply_markup: Optional[dict] = None) -> bool:
"""Edit an existing message (typically to swap out an inline keyboard
after the user pressed a button). Returns True on 200."""
token = settings.telegram_bot_token
if not token:
return False
url = TG_API.format(token=token, method="editMessageText")
payload: dict = {
"chat_id": chat_id, "message_id": message_id, "text": text,
"parse_mode": parse_mode,
}
if reply_markup is not None:
payload["reply_markup"] = reply_markup
try:
async with httpx.AsyncClient(timeout=10) as client:
r = await client.post(url, json=payload)
if r.status_code != 200:
# Telegram returns 400 on "message is not modified" — harmless.
if "message is not modified" not in r.text:
logger.warning("Telegram editMessageText failed chat=%d msg=%d "
"status=%d body=%s",
chat_id, message_id, r.status_code, r.text[:200])
return False
return True
except Exception as exc:
logger.warning("Telegram editMessageText exception: %s", exc)
return False
async def answer_callback(callback_query_id: str, text: str = "",
show_alert: bool = False) -> bool:
"""Acknowledge an inline-button press. MUST be called within 30s of the
callback firing or the button stays in 'loading' state on the user's
client. Empty text → silent ack."""
token = settings.telegram_bot_token
if not token:
return False
url = TG_API.format(token=token, method="answerCallbackQuery")
payload = {"callback_query_id": callback_query_id}
if text:
payload["text"] = text
payload["show_alert"] = show_alert
try:
async with httpx.AsyncClient(timeout=10) as client:
await client.post(url, json=payload)
return True
except Exception as exc:
logger.debug("Telegram answerCallback exception: %s", exc)
return False
# ── Dispatcher ────────────────────────────────────────────────────────────
async def _dispatch(post_id: int) -> None:
"""Fan-out a single Post to every eligible subscriber. Always runs in
its own DB session so signal ingestion's session is unaffected."""
if not settings.telegram_bot_token:
return
async with async_session() as db:
post = await db.get(Post, post_id)
if not post:
logger.warning("Telegram dispatch: post id=%d not found", post_id)
return
# Only fan out for actionable signals (not NOISE / null)
if not post.signal or post.signal not in ("buy", "short"):
return
pref_col = _pref_column_for_source(post.source)
if pref_col is None:
logger.debug("Telegram: unknown source %r — not fanning out", post.source)
return
# Build the query: alerts_enabled + the relevant per-source flag.
# min_confidence applies to every source — scanner-emitted signals
# carry their own confidence in the Post.ai_confidence column.
col = getattr(TelegramBinding, pref_col)
base_filters = [
TelegramBinding.alerts_enabled.is_(True),
col.is_(True),
]
# Only apply confidence gate when the post has a real score (> 0).
# Scanner-generated signals (funding, BTC) always carry a score, but
# a Truth-Social post might be dispatched before Claude scores it (score=0).
# In that edge case we let it through so no alert is silently swallowed.
if post.ai_confidence and post.ai_confidence > 0:
base_filters.append(TelegramBinding.min_confidence <= post.ai_confidence)
q = select(TelegramBinding).where(*base_filters)
bindings = (await db.execute(q)).scalars().all()
if not bindings:
return
text = format_post(post)
now = datetime.now(timezone.utc)
hour = now.hour
sent = 0
for b in bindings:
if _is_in_mute_window(hour, b.mute_from_hour, b.mute_until_hour):
continue
ok = await send_message(b.chat_id, text)
# Update audit counters per binding. Single UPDATE per row keeps
# us out of trouble if one user blocks the bot.
if ok:
await db.execute(
update(TelegramBinding)
.where(TelegramBinding.id == b.id)
.values(
last_alert_at=now,
total_alerts_sent=TelegramBinding.total_alerts_sent + 1,
)
)
sent += 1
else:
await db.execute(
update(TelegramBinding)
.where(TelegramBinding.id == b.id)
.values(
total_alerts_failed=TelegramBinding.total_alerts_failed + 1,
)
)
await db.commit()
logger.info("Telegram fan-out: post=%d source=%s sent=%d/%d",
post_id, post.source, sent, len(bindings))
def notify_signal(post: Post) -> None:
"""Fire-and-forget. Schedules `_dispatch(post.id)` on the running loop
and returns immediately. Safe to call from any async context — falls
back to a no-op if telegram is disabled or no event loop is running.
We pass post.id rather than the post object because the caller's DB
session might close before our background task runs."""
if not settings.telegram_bot_token:
return
if not post or not post.id:
return
try:
asyncio.create_task(_dispatch(post.id))
except RuntimeError:
# No running loop — extremely unusual in our FastAPI context.
logger.warning("notify_signal: no running event loop, skipping post=%s", post.id)
async def send_test_message(wallet: str) -> bool:
"""Triggered from the Settings UI to verify the binding works end-to-end."""
if not settings.telegram_bot_token:
return False
async with async_session() as db:
b = (await db.execute(
select(TelegramBinding).where(TelegramBinding.wallet_address == wallet.lower())
)).scalar_one_or_none()
if not b:
return False
return await send_message(
b.chat_id,
"✅ <b>Trump Alpha connected.</b>\n\n"
"You'll get push alerts here when signals fire. "
"Use /trump /btc /funding /kol /conf /quiet in this chat to tune "
"which sources and thresholds apply to you.",
)
if not settings.telegram_bot_token:
logger.info("Telegram bot token not set — push alerts disabled.")