d6c802ef26
Batch of the pre-launch audit campaign (BUG-01…14 plus three new features): Pricing / TP-SL protection - Add app/services/hl_price_feed.py: supplemental HL allMids poller for HL-native assets (HYPE, PURR) not listed on Binance. Pumps price_store + tp_sl_monitor.on_price_tick so bot trades on these assets keep full stop-loss / take-profit / trailing protection instead of max-hold only. - Wire feed into main.py lifespan (startup task + graceful shutdown cancel). Telegram - Add format_trump_mention + PATH B in _dispatch: crypto-relevant Trump posts with no directional signal (relevant=True, signal=hold) now alert the public channel only (no per-subscriber noise). - Rate limiter (slowapi) on the API; assorted bot/digest fixes. KOL on-chain - seed_kol_wallets.py: KOL_FEEDS coverage cross-check; reversibly deactivate orphaned wallets (handle not in KOL_FEEDS → can never produce divergence) so the scanner stops burning cycles on them. Tests / misc - Fix brittle test_macro_ahr999_uses_same_formula_as_scanner: mock now uses realistic ms timestamps so the in-progress-day drop fires, matching the fetcher's bar count (was 0.3179 vs 0.3178 off-by-one). - Refresh stale notify_signal comment in truth_social.py. Frontend reduce-action type fix lives in the sibling repo. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
489 lines
19 KiB
Python
489 lines
19 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]
|
||
|
||
|
||
def format_trump_mention(post: Post) -> str:
|
||
"""Public-channel-only alert for Trump posts that are crypto-relevant
|
||
but don't carry a clear directional signal (signal=hold, relevant=True).
|
||
|
||
These posts can still move markets (e.g. "I support crypto development")
|
||
so we surface them as low-urgency monitoring alerts. Never sent per-user
|
||
to avoid subscriber spam; only broadcast to the public channel.
|
||
"""
|
||
body = (post.text or "").strip()
|
||
if len(body) > 300:
|
||
body = body[:300].rstrip() + "…"
|
||
|
||
fe = (settings.frontend_url or "").rstrip("/")
|
||
link = f'\n\n<a href="{fe}/en/trump">→ view on TrumpAlpha</a>' if fe else ""
|
||
|
||
msg = (
|
||
f"📢 <b>Trump mentioned crypto</b>\n"
|
||
f"<i>Trump · Truth Social — no clear trade signal</i>\n\n"
|
||
f"{body}"
|
||
f"{link}"
|
||
)
|
||
return msg[:MAX_LEN]
|
||
|
||
|
||
def format_public_post(post: Post) -> str:
|
||
"""Render a sanitised version of a Post for the public broadcast channel.
|
||
|
||
Intentionally omits:
|
||
- expected_move_pct / invalidation_price (execution-sensitive data)
|
||
- /adopt CTA (requires a private bot session)
|
||
- Any wallet address or subscriber-only details
|
||
|
||
Keeps: signal direction, asset, confidence tier, source label, post
|
||
excerpt (≤ 280 chars), and a link back to the relevant dashboard section.
|
||
"""
|
||
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
|
||
|
||
# Confidence tier label instead of raw number (less intimidating publicly)
|
||
if conf >= 80:
|
||
conf_label = "HIGH"
|
||
elif conf >= 60:
|
||
conf_label = "MED"
|
||
elif conf > 0:
|
||
conf_label = "LOW"
|
||
else:
|
||
conf_label = "—"
|
||
|
||
head = f"{emoji} <b>{asset} · {sig}</b> · conf <b>{conf_label}</b>"
|
||
sub = f"<i>{src}</i>"
|
||
|
||
body = (post.text or "").strip()
|
||
if len(body) > 280:
|
||
body = body[:280].rstrip() + "…"
|
||
|
||
# KOL divergence: strip wallet address from the text if present
|
||
# (the raw text shouldn't contain one, but belt-and-suspenders)
|
||
if post.source == "kol_divergence" and post.ai_reasoning:
|
||
reason = post.ai_reasoning.strip()
|
||
if len(reason) > 200:
|
||
reason = reason[:200].rstrip() + "…"
|
||
body = reason
|
||
|
||
fe = (settings.frontend_url or "").rstrip("/")
|
||
link = ""
|
||
if fe:
|
||
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}">→ view on TrumpAlpha</a>'
|
||
|
||
msg = f"{head}\n{sub}\n\n{body}{link}"
|
||
return msg[:MAX_LEN]
|
||
|
||
|
||
# ── Low-level HTTP ────────────────────────────────────────────────────────
|
||
|
||
|
||
async def send_message(chat_id: int | str, 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).
|
||
|
||
`chat_id` may be an integer user/group ID or a string channel username
|
||
such as ``"@trumpalpha"`` (with or without the leading @).
|
||
|
||
`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=%s 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=%s: %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.
|
||
|
||
Two distinct paths:
|
||
|
||
PATH A — actionable signal (signal=buy/short):
|
||
• Per-subscriber fan-out filtered by alert preference + min_confidence
|
||
• Public channel broadcast (format_public_post)
|
||
|
||
PATH B — crypto mention (source=truth, relevant=True, signal=hold):
|
||
• Public channel ONLY (format_trump_mention) — no per-subscriber push
|
||
to avoid noise for individual users who subscribed for trade signals.
|
||
• These posts are crypto-relevant but lack a clear directional call.
|
||
They can still move markets so we surface them as monitoring alerts.
|
||
"""
|
||
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
|
||
|
||
is_actionable = post.signal in ("buy", "short")
|
||
is_trump_mention = (
|
||
post.source == "truth"
|
||
and bool(post.relevant)
|
||
and post.signal == "hold"
|
||
)
|
||
|
||
if not is_actionable and not is_trump_mention:
|
||
# Non-relevant noise — nothing to send.
|
||
return
|
||
|
||
# ── PATH B: crypto mention (public channel only) ──────────────────
|
||
if is_trump_mention and not is_actionable:
|
||
channel_id = settings.telegram_public_channel_id
|
||
if channel_id:
|
||
mention_text = format_trump_mention(post)
|
||
ok = await send_message(channel_id, mention_text)
|
||
if ok:
|
||
logger.info("Telegram mention alert: post=%d sent to %s",
|
||
post_id, channel_id)
|
||
else:
|
||
logger.warning("Telegram mention alert: post=%d send FAILED to %s",
|
||
post_id, channel_id)
|
||
return
|
||
|
||
# ── PATH A: actionable signal (buy/short) ─────────────────────────
|
||
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()
|
||
|
||
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))
|
||
|
||
# ── Public channel broadcast ──────────────────────────────────────
|
||
channel_id = settings.telegram_public_channel_id
|
||
if channel_id:
|
||
public_text = format_public_post(post)
|
||
ok = await send_message(channel_id, public_text)
|
||
if ok:
|
||
logger.info("Telegram public channel: post=%d sent to %s",
|
||
post_id, channel_id)
|
||
else:
|
||
logger.warning("Telegram public channel: post=%d send FAILED to %s",
|
||
post_id, channel_id)
|
||
|
||
|
||
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.")
|