fix: pre-launch hardening — HYPE price feed, KOL wallet cleanup, Telegram Trump alert, rate limiting, brittle test

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>
This commit is contained in:
k
2026-05-29 11:57:19 +08:00
parent 6471e44aac
commit d6c802ef26
40 changed files with 1833 additions and 209 deletions
+137 -9
View File
@@ -146,16 +146,100 @@ def format_post(post: Post) -> str:
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, text: str, *,
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": [
@@ -179,12 +263,12 @@ async def send_message(chat_id: int, text: str, *,
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",
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=%d: %s", chat_id, exc)
logger.warning("Telegram sendMessage exception chat=%s: %s", chat_id, exc)
return False
@@ -246,7 +330,20 @@ async def answer_callback(callback_query_id: str, text: str = "",
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."""
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
@@ -256,10 +353,32 @@ async def _dispatch(post_id: int) -> None:
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"):
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)
@@ -282,9 +401,6 @@ async def _dispatch(post_id: int) -> None:
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
@@ -318,6 +434,18 @@ async def _dispatch(post_id: int) -> None:
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