Pre-launch hardening: KOL module, Telegram, scanners, WS resilience
Big-picture changes since b941223:
KOL pipeline (new) — Substack/podcast/blog RSS → AI ticker extraction →
on-chain wallet diff → talks-vs-trades divergence detection. Daily polls,
19 feeds, divergence emits Post + Telegram fan-out.
Telegram push (new) — walletless free tier + wallet-linked Pro upgrade,
in-bot preference commands (/trump /btc /funding /kol /conf /quiet),
signed-envelope API for dashboard. Disconnect-wallet keeps free
subscription.
BTC funding-rate reversal scanner (new) — hourly cron, 30d cumulative
funding threshold + mean-revert + 7d price confirm, emits via
/api/signals/ingest. BTC bottom-reversal scanner promoted to System 2.
WS broadcast rewrite — per-client send timeout + parallel fan-out
(asyncio.gather). Fixes "Binance WS no close frame" reconnect storms +
APScheduler 11-min job misses, both caused by one slow client stalling
the kline loop.
Error visibility — three silent-error sites (trumpstruth/truth_social
fetchers, funding_reversal scanner) now include exception type name so
httpx ConnectError-style empty-message errors stop logging blank lines.
Telegram bot loop now classifies ReadTimeout vs network vs unknown +
logger.exception for the unknown bucket.
Security hygiene — trumpsignal.db untracked from git (held subscriber
wallets + encrypted HL keys + 22 bot trades); .gitignore now blocks
*.db/.next/backups. CORS only allows FRONTEND_URL in production.
New ops scripts —
- scripts/preflight.py: env/DB/Telegram/AI auth verification gate
- scripts/backup_db.sh: cron-friendly daily DB backup (SQLite + Postgres)
- scripts/seed_kol_wallets.py: idempotent KOL on-chain wallet seeder
15 new Alembic migrations (007-021) covering convex strategy fields,
phase-1 safety, two-system frozen exits, invalidation prices, dynamic
SYS2 leverage, staged de-risk + pyramiding, peak gain tracking, risk
mode, auto-trade + grow flags, KOL module, KOL on-chain, KOL divergence,
Telegram bindings + walletless.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,280 @@
|
||||
"""
|
||||
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/btc",
|
||||
"funding_reversal": "/en/btc",
|
||||
"kol_divergence": "/en/kol",
|
||||
}.get(post.source, "/en")
|
||||
link = f'\n\n<a href="{fe}{path}">→ open in dashboard</a>'
|
||||
|
||||
msg = f"{head}\n{sub}\n\n{body}{extra}{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) -> bool:
|
||||
"""Single HTTP POST to Telegram Bot API. Returns True on 200, False on
|
||||
any failure (caller decides whether to bump the failure counter)."""
|
||||
token = settings.telegram_bot_token
|
||||
if not token:
|
||||
return False
|
||||
url = TG_API.format(token=token, method="sendMessage")
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
r = await client.post(url, json={
|
||||
"chat_id": chat_id, "text": text,
|
||||
"parse_mode": parse_mode,
|
||||
"disable_web_page_preview": disable_preview,
|
||||
})
|
||||
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
|
||||
|
||||
|
||||
# ── 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.")
|
||||
Reference in New Issue
Block a user