3754d2caf8
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>
361 lines
14 KiB
Python
361 lines
14 KiB
Python
"""
|
||
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, 0–6)."""
|
||
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.")
|